@wave3d/core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/config/model.d.ts +209 -6
  2. package/dist/config/model.js +85 -3
  3. package/dist/config/model.js.map +1 -1
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.js +3 -2
  6. package/dist/presets.js +295 -0
  7. package/dist/presets.js.map +1 -1
  8. package/dist/renderer/WaveGeometry.js +21 -0
  9. package/dist/renderer/WaveGeometry.js.map +1 -1
  10. package/dist/renderer/WaveRenderer.d.ts +62 -0
  11. package/dist/renderer/WaveRenderer.js +349 -10
  12. package/dist/renderer/WaveRenderer.js.map +1 -1
  13. package/dist/renderer/WaveRendererGPU.js +32 -2
  14. package/dist/renderer/WaveRendererGPU.js.map +1 -1
  15. package/dist/renderer/interaction.js +12 -0
  16. package/dist/renderer/interaction.js.map +1 -1
  17. package/dist/renderer/particleField.js +26 -2
  18. package/dist/renderer/particleField.js.map +1 -1
  19. package/dist/renderer/particleFieldGPU.js +6 -0
  20. package/dist/renderer/particleFieldGPU.js.map +1 -1
  21. package/dist/renderer/shaders.js +748 -13
  22. package/dist/renderer/shaders.js.map +1 -1
  23. package/dist/renderer/tsl/dissolve.js +56 -0
  24. package/dist/renderer/tsl/dissolve.js.map +1 -0
  25. package/dist/renderer/tsl/particleMaterial.js +46 -8
  26. package/dist/renderer/tsl/particleMaterial.js.map +1 -1
  27. package/dist/renderer/tsl/uniforms.js +40 -1
  28. package/dist/renderer/tsl/uniforms.js.map +1 -1
  29. package/dist/renderer/tsl/waveMaterial.js +275 -26
  30. package/dist/renderer/tsl/waveMaterial.js.map +1 -1
  31. package/dist/renderer/tsl/waveShape.js +31 -10
  32. package/dist/renderer/tsl/waveShape.js.map +1 -1
  33. package/dist/renderer/wavePath.js +189 -0
  34. package/dist/renderer/wavePath.js.map +1 -0
  35. package/dist/shell/createWave.d.ts +23 -3
  36. package/dist/shell/createWave.js +5 -4
  37. package/dist/shell/createWave.js.map +1 -1
  38. package/dist/shell/probe.d.ts +28 -0
  39. package/dist/shell/probe.js +58 -11
  40. package/dist/shell/probe.js.map +1 -1
  41. package/dist/standalone/wave3d.standalone.js +3401 -2108
  42. package/dist/standalone/wave3d.standalone.webgpu.js +7372 -5848
  43. package/dist/standalone.d.ts +2 -2
  44. package/dist/standalone.js +2 -2
  45. package/dist/studio/StudioWaveRenderer.d.ts +115 -8
  46. package/dist/studio/StudioWaveRenderer.js +588 -14
  47. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  48. package/dist/studio/index.d.ts +2 -2
  49. package/dist/studio/index.js.map +1 -1
  50. package/dist/studio/randomize.js +0 -1
  51. package/dist/studio/randomize.js.map +1 -1
  52. package/package.json +1 -1
  53. package/skills/wave3d/SKILL.md +142 -5
@@ -1 +1 @@
1
- {"version":3,"file":"waveShape.js","names":[],"sources":["../../../src/renderer/tsl/waveShape.ts"],"sourcesContent":["/**\n * The wave SHAPE deform in TSL — the port of the `waveShapeChunk` GLSL in `../shaders.ts`.\n *\n * A baked hairpin position + its uv become the displaced / helixed / twisted / fanned local\n * position, plus the three twists the pointer field needs. The particle emitter calls this too, so\n * a wave and the dust it sheds ride one deform.\n *\n * The GLSL `#ifdef` gates (LOOP_MOTION, DETAIL_OCTAVE, HELIX, TWIST_MOTION, RADIAL) become plain JS\n * flags: the graph is built per material, so an unused branch is simply never constructed. That is\n * strictly better than the define-based variants — there is no dead uniform to declare and no\n * program-key bookkeeping — but it does mean the \"byte-identical when off\" contract the GLSL\n * comments assert is no longer a thing to verify. `parity/` covers the behaviour instead.\n */\nimport {\n Fn,\n float,\n vec2,\n vec3,\n cos,\n sin,\n radians,\n exp2,\n pow,\n max,\n clamp,\n mix,\n cross,\n dot,\n normalize,\n} from \"three/tsl\";\nimport { RIBBON_Z_CENTER } from \"../WaveGeometry\";\nimport { simplexNoise } from \"./noise\";\nimport type { FloatNode, Vec2Node, Vec3Node } from \"./types\";\nimport type { WaveTslUniforms } from \"./uniforms\";\n\n/**\n * A falloff from 1 (at x=0) toward 0, sharpness set by n. `max()` guards `pow(0, n)` (= Infinity →\n * NaN), which also makes negative n safe — it just concentrates the twist toward the other end.\n */\nexport const expStep = /*@__PURE__*/ Fn(([x, n]: [FloatNode, FloatNode]) =>\n exp2(\n exp2(n)\n .mul(pow(max(x, 1.0e-3), n))\n .negate(),\n ),\n).setLayout({\n name: \"wave_expStep\",\n type: \"float\",\n inputs: [\n { name: \"x\", type: \"float\" },\n { name: \"n\", type: \"float\" },\n ],\n});\n\n/**\n * One twist: an axis and the angle the GLSL passes to `rotationMatrix`.\n *\n * The GLSL builds a matrix and applies it ROW-vector style — `(vec4(pos,1) * R).xyz`. Note the\n * matrix it builds is the TRANSPOSE of the standard Rodrigues matrix (GLSL's `mat4(...)` fills\n * column-major, and the literal is laid out by rows), so `v * R` is `transpose(R) * v`, which is a\n * plain rotation by +angle. Check with axis = +z and v = (1,0,0): `v * R` = (cos, sin, 0).\n *\n * `applyTwist` performs that rotation directly, so there is no matrix-storage-order assumption to\n * get wrong between GLSL's `mat4(...)` and WGSL's `mat4x4<f32>(...)`. Rotations are linear, so the\n * same helper serves direction vectors (the pointer field's displacement axis and ribbon tangent)\n * with no w-component special case. `parity:math` pins this against the original.\n */\nexport interface Twist {\n axis: Vec3Node;\n angle: FloatNode;\n}\n\n/** Rotate `v` about `t.axis` by `t.angle` — the row-vector product the GLSL performs. */\nexport const applyTwist = (v: Vec3Node, t: Twist): Vec3Node => {\n const k = normalize(t.axis);\n const c = cos(t.angle);\n const s = sin(t.angle);\n return v\n .mul(c)\n .add(cross(k, v).mul(s))\n .add(k.mul(dot(k, v)).mul(float(1).sub(c)));\n};\n\n/**\n * The helix: the periodic sweep the three twists (monotone falloffs) can't reach.\n *\n * Runs AFTER the displacement (so the noise still samples the undeformed position) and BEFORE the\n * twist (so they compose). The roll is about the ribbon's width centre, not the origin — see\n * RIBBON_Z_CENTER in WaveGeometry. Exported so `parity:math` can probe it against the GLSL.\n */\nexport function applyHelix(\n pos: Vec3Node,\n uvY: FloatNode,\n turns: FloatNode,\n phaseDeg: FloatNode,\n roll: FloatNode,\n radius: FloatNode,\n): Vec3Node {\n const hAng = float(6.28318530718).mul(turns).mul(uvY).add(radians(phaseDeg)).toVar();\n const rollA = hAng.mul(roll);\n const rollC = cos(rollA);\n const rollS = sin(rollA);\n const rel = vec2(pos.y, pos.z.sub(RIBBON_Z_CENTER)).toVar();\n return vec3(\n pos.x,\n rel.x\n .mul(rollC)\n .sub(rel.y.mul(rollS))\n .add(radius.mul(cos(hAng))),\n float(RIBBON_Z_CENTER)\n .add(rel.x.mul(rollS))\n .add(rel.y.mul(rollC))\n .add(radius.mul(sin(hAng))),\n );\n}\n\n/**\n * Radial fan: remap the ribbon to polar around the LOCAL origin so its LENGTH fans into a plume.\n *\n * uv.x (the folded WIDTH) becomes the fan ANGLE across `arc`; uv.y (the LENGTH) becomes the RADIUS,\n * so a constant-uv.x combed fiber turns into a constant-angle radial spoke. `amount` 0 is the\n * identity mix. Exported so `parity:math` can probe it against the GLSL.\n */\nexport function applyRadial(\n pos: Vec3Node,\n uv: Vec2Node,\n amount: FloatNode,\n arc: FloatNode,\n spread: FloatNode,\n radius: FloatNode,\n center: FloatNode,\n): Vec3Node {\n const rAng = radians(center)\n .add(clamp(uv.x, 0, 1).sub(0.5).mul(radians(arc)))\n .toVar(\"rAng\");\n const rRho = radius.add(uv.y.mul(400.0).mul(spread)); // 400 = native ribbon length\n const rEr = vec3(cos(rAng), sin(rAng), 0.0); // radial dir, in local X-Y (the screen plane)\n const rEt = vec3(sin(rAng).negate(), cos(rAng), 0.0); // tangential\n const fanned = rEr\n .mul(rRho)\n .add(rEt.mul(pos.z.sub(RIBBON_Z_CENTER)).mul(0.5))\n .add(vec3(0.0, 0.0, pos.y));\n return mix(pos, fanned, clamp(amount, 0, 1));\n}\n\n/** Which optional blocks this material's graph includes — the JS twin of the GLSL `#ifdef` set. */\nexport interface WaveShapeFlags {\n loopMotion: boolean;\n detailOctave: boolean;\n helix: boolean;\n twistMotion: boolean;\n radial: boolean;\n}\n\nexport interface WaveShapeResult {\n pos: Vec3Node;\n /** The three twists, in application order — the pointer field carries its axes through these. */\n twists: [Twist, Twist, Twist];\n}\n\n/**\n * Deform one vertex. `t` is linear time and `loopOff` the orbit offset; only the one selected by\n * `flags.loopMotion` is read, exactly as in the GLSL where the other is a dead argument.\n */\nexport function waveShape(\n u: WaveTslUniforms,\n flags: WaveShapeFlags,\n position: Vec3Node,\n uv: Vec2Node,\n t: FloatNode,\n loopOff: Vec2Node,\n): WaveShapeResult {\n const pos = position.toVar();\n\n // Displacement lifts Y by simplex noise of the (x,z) position.\n const dispArg = flags.loopMotion\n ? vec2(pos.x.mul(u.uDispFreqX), pos.z.mul(u.uDispFreqZ)).add(loopOff)\n : vec2(pos.x.mul(u.uDispFreqX).add(t), pos.z.mul(u.uDispFreqZ).add(t));\n pos.y.addAssign(simplexNoise(dispArg).mul(u.uDispAmount));\n\n if (flags.detailOctave) {\n // A second, finer octave riding on the broad swell (loop-orbit shared so it stays periodic).\n const detailArg = flags.loopMotion\n ? vec2(pos.x.mul(u.uDetailFreq), pos.z.mul(u.uDetailFreq)).add(loopOff)\n : vec2(pos.x.mul(u.uDetailFreq).add(t), pos.z.mul(u.uDetailFreq).add(t));\n pos.y.addAssign(simplexNoise(detailArg).mul(u.uDetailAmount));\n }\n\n if (flags.helix) {\n // The periodic sweep the three twists (monotone falloffs) can't reach. Runs AFTER displacement\n // (so the noise still samples undeformed pos) and BEFORE the twist (so they compose).\n const hAng = float(6.28318530718)\n .mul(u.uHelixTurns)\n .mul(uv.y)\n .add(radians(u.uHelixPhase))\n .toVar();\n // Roll about the ribbon's width centre, not the origin — see RIBBON_Z_CENTER in WaveGeometry.\n const rollA = hAng.mul(u.uHelixRoll);\n const rollC = cos(rollA);\n const rollS = sin(rollA);\n const rel = vec2(pos.y, pos.z.sub(RIBBON_Z_CENTER)).toVar();\n pos.y.assign(rel.x.mul(rollC).sub(rel.y.mul(rollS)));\n pos.z.assign(float(RIBBON_Z_CENTER).add(rel.x.mul(rollS)).add(rel.y.mul(rollC)));\n pos.y.addAssign(u.uHelixRadius.mul(cos(hAng)));\n pos.z.addAssign(u.uHelixRadius.mul(sin(hAng)));\n }\n\n // The X-twist frequency feeding the second rotation. Under TWIST_MOTION it is modulated by\n // simplex noise indexed along the ribbon (uv.y), so the twist breathes over time.\n let twistXFreq: FloatNode = u.uTwFreqX;\n if (flags.twistMotion) {\n const noiseArg = flags.loopMotion\n ? vec2(uv.y.mul(2.0), 0.0).add(loopOff)\n : vec2(uv.y.mul(2.0), t);\n twistXFreq = u.uTwFreqX.sub(simplexNoise(noiseArg).mul(0.1));\n }\n\n // Three-axis twist. rotA keys off uv.x (the folded WIDTH), rotB/rotC off uv.y (the LENGTH).\n const twists: [Twist, Twist, Twist] = [\n { axis: vec3(0.5, 0.0, 0.5), angle: u.uTwFreqY.mul(expStep(uv.x, u.uTwPowY)) },\n { axis: vec3(0.0, 0.5, 0.5), angle: twistXFreq.mul(expStep(uv.y, u.uTwPowX)) },\n { axis: vec3(0.5, 0.0, 0.5), angle: u.uTwFreqZ.mul(expStep(uv.y, u.uTwPowZ)) },\n ];\n const twisted = applyTwist(applyTwist(applyTwist(pos, twists[0]), twists[1]), twists[2]).toVar();\n\n if (!flags.radial) return { pos: twisted, twists };\n return {\n pos: applyRadial(\n twisted,\n uv,\n u.uRadialAmount,\n u.uRadialArc,\n u.uRadialSpread,\n u.uRadialRadius,\n u.uRadialCenter,\n ),\n twists,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"waveShape.js","names":[],"sources":["../../../src/renderer/tsl/waveShape.ts"],"sourcesContent":["/**\n * The wave SHAPE deform in TSL — the port of the `waveShapeChunk` GLSL in `../shaders.ts`.\n *\n * A baked hairpin position + its uv become the displaced / helixed / twisted / fanned local\n * position, plus the three twists the pointer field needs. The particle emitter calls this too, so\n * a wave and the dust it sheds ride one deform.\n *\n * The GLSL `#ifdef` gates (LOOP_MOTION, DETAIL_OCTAVE, HELIX, TWIST_MOTION, RADIAL) become plain JS\n * flags: the graph is built per material, so an unused branch is simply never constructed. That is\n * strictly better than the define-based variants — there is no dead uniform to declare and no\n * program-key bookkeeping — but it does mean the \"byte-identical when off\" contract the GLSL\n * comments assert is no longer a thing to verify. `parity/` covers the behaviour instead.\n */\nimport {\n Fn,\n float,\n vec2,\n vec3,\n cos,\n sin,\n radians,\n exp2,\n pow,\n max,\n clamp,\n mix,\n cross,\n dot,\n normalize,\n fract,\n select,\n floor,\n min,\n} from \"three/tsl\";\nimport { RIBBON_Z_CENTER } from \"../WaveGeometry\";\nimport { PATH_ROWS, PATH_SAMPLES } from \"../wavePath\";\nimport { simplexNoise } from \"./noise\";\nimport type { FloatNode, Vec2Node, Vec3Node } from \"./types\";\nimport type { WaveTslUniforms } from \"./uniforms\";\n\n/**\n * A falloff from 1 (at x=0) toward 0, sharpness set by n. `max()` guards `pow(0, n)` (= Infinity →\n * NaN), which also makes negative n safe — it just concentrates the twist toward the other end.\n */\nexport const expStep = /*@__PURE__*/ Fn(([x, n]: [FloatNode, FloatNode]) =>\n exp2(\n exp2(n)\n .mul(pow(max(x, 1.0e-3), n))\n .negate(),\n ),\n).setLayout({\n name: \"wave_expStep\",\n type: \"float\",\n inputs: [\n { name: \"x\", type: \"float\" },\n { name: \"n\", type: \"float\" },\n ],\n});\n\n/**\n * One twist: an axis and the angle the GLSL passes to `rotationMatrix`.\n *\n * The GLSL builds a matrix and applies it ROW-vector style — `(vec4(pos,1) * R).xyz`. Note the\n * matrix it builds is the TRANSPOSE of the standard Rodrigues matrix (GLSL's `mat4(...)` fills\n * column-major, and the literal is laid out by rows), so `v * R` is `transpose(R) * v`, which is a\n * plain rotation by +angle. Check with axis = +z and v = (1,0,0): `v * R` = (cos, sin, 0).\n *\n * `applyTwist` performs that rotation directly, so there is no matrix-storage-order assumption to\n * get wrong between GLSL's `mat4(...)` and WGSL's `mat4x4<f32>(...)`. Rotations are linear, so the\n * same helper serves direction vectors (the pointer field's displacement axis and ribbon tangent)\n * with no w-component special case. `parity:math` pins this against the original.\n */\nexport interface Twist {\n axis: Vec3Node;\n angle: FloatNode;\n}\n\n/** Rotate `v` about `t.axis` by `t.angle` — the row-vector product the GLSL performs. */\nexport const applyTwist = (v: Vec3Node, t: Twist): Vec3Node => {\n const k = normalize(t.axis);\n const c = cos(t.angle);\n const s = sin(t.angle);\n return v\n .mul(c)\n .add(cross(k, v).mul(s))\n .add(k.mul(dot(k, v)).mul(float(1).sub(c)));\n};\n\n/**\n * The helix: the periodic sweep the three twists (monotone falloffs) can't reach.\n *\n * Runs AFTER the displacement (so the noise still samples the undeformed position) and BEFORE the\n * twist (so they compose). The roll is about the ribbon's width centre, not the origin — see\n * RIBBON_Z_CENTER in WaveGeometry. Exported so `parity:math` can probe it against the GLSL.\n */\nexport function applyHelix(\n pos: Vec3Node,\n uvY: FloatNode,\n turns: FloatNode,\n phaseDeg: FloatNode,\n roll: FloatNode,\n radius: FloatNode,\n): Vec3Node {\n const hAng = float(6.28318530718).mul(turns).mul(uvY).add(radians(phaseDeg)).toVar();\n const rollA = hAng.mul(roll);\n const rollC = cos(rollA);\n const rollS = sin(rollA);\n const rel = vec2(pos.y, pos.z.sub(RIBBON_Z_CENTER)).toVar();\n return vec3(\n pos.x,\n rel.x\n .mul(rollC)\n .sub(rel.y.mul(rollS))\n .add(radius.mul(cos(hAng))),\n float(RIBBON_Z_CENTER)\n .add(rel.x.mul(rollS))\n .add(rel.y.mul(rollC))\n .add(radius.mul(sin(hAng))),\n );\n}\n\n/**\n * Radial fan: remap the ribbon to polar around the LOCAL origin so its LENGTH fans into a plume.\n *\n * uv.x (the folded WIDTH) becomes the fan ANGLE across `arc`; uv.y (the LENGTH) becomes the RADIUS,\n * so a constant-uv.x combed fiber turns into a constant-angle radial spoke. `amount` 0 is the\n * identity mix. Exported so `parity:math` can probe it against the GLSL.\n */\nexport function applyRadial(\n pos: Vec3Node,\n uv: Vec2Node,\n amount: FloatNode,\n arc: FloatNode,\n spread: FloatNode,\n radius: FloatNode,\n center: FloatNode,\n cone: FloatNode,\n swirl: FloatNode,\n): Vec3Node {\n // Swirl: the angle advances along the band as well as across it, so the arm curves around the\n // throat into a spiral rather than running straight out from it.\n const rAng = radians(center)\n .add(clamp(uv.x, 0, 1).sub(0.5).mul(radians(arc)))\n .add(uv.y.mul(radians(swirl)))\n .toVar(\"rAng\");\n const rRho = radius.add(uv.y.mul(400.0).mul(spread)); // 400 = native ribbon length\n const rEr = vec3(cos(rAng), sin(rAng), 0.0); // radial dir, in local X-Y (the screen plane)\n const rEt = vec3(sin(rAng).negate(), cos(rAng), 0.0); // tangential\n const fanned = rEr\n .mul(rRho)\n .add(rEt.mul(pos.z.sub(RIBBON_Z_CENTER)).mul(0.5))\n // Cone: lift the fan out of its own plane as it spreads, so the flat plume becomes a TRUMPET\n // whose combed strands run down the slant into the throat. 0 is the flat fan.\n .add(vec3(0.0, 0.0, pos.y.add(uv.y.mul(400.0).mul(cone))));\n return mix(pos, fanned, clamp(amount, 0, 1));\n}\n\n/** Which optional blocks this material's graph includes — the JS twin of the GLSL `#ifdef` set. */\nexport interface WaveShapeFlags {\n loopMotion: boolean;\n detailOctave: boolean;\n helix: boolean;\n twistMotion: boolean;\n radial: boolean;\n path: boolean;\n}\n\nexport interface WaveShapeResult {\n pos: Vec3Node;\n /** The three twists, in application order — the pointer field carries its axes through these. */\n twists: [Twist, Twist, Twist];\n}\n\n/**\n * Deform one vertex. `t` is linear time and `loopOff` the orbit offset; only the one selected by\n * `flags.loopMotion` is read, exactly as in the GLSL where the other is a dead argument.\n */\nexport function waveShape(\n u: WaveTslUniforms,\n flags: WaveShapeFlags,\n position: Vec3Node,\n uv: Vec2Node,\n t: FloatNode,\n loopOff: Vec2Node,\n): WaveShapeResult {\n const pos = position.toVar();\n\n // Displacement lifts Y by simplex noise of the (x,z) position.\n const dispArg = flags.loopMotion\n ? vec2(pos.x.mul(u.uDispFreqX), pos.z.mul(u.uDispFreqZ)).add(loopOff)\n : vec2(pos.x.mul(u.uDispFreqX).add(t), pos.z.mul(u.uDispFreqZ).add(t));\n pos.y.addAssign(simplexNoise(dispArg).mul(u.uDispAmount));\n\n if (flags.detailOctave) {\n // A second, finer octave riding on the broad swell (loop-orbit shared so it stays periodic).\n const detailArg = flags.loopMotion\n ? vec2(pos.x.mul(u.uDetailFreq), pos.z.mul(u.uDetailFreq)).add(loopOff)\n : vec2(pos.x.mul(u.uDetailFreq).add(t), pos.z.mul(u.uDetailFreq).add(t));\n pos.y.addAssign(simplexNoise(detailArg).mul(u.uDetailAmount));\n }\n\n if (flags.helix) {\n // The periodic sweep the three twists (monotone falloffs) can't reach. Runs AFTER displacement\n // (so the noise still samples undeformed pos) and BEFORE the twist (so they compose).\n const hAng = float(6.28318530718)\n .mul(u.uHelixTurns)\n .mul(uv.y)\n .add(radians(u.uHelixPhase))\n .toVar();\n // Roll about the ribbon's width centre, not the origin — see RIBBON_Z_CENTER in WaveGeometry.\n const rollA = hAng.mul(u.uHelixRoll);\n const rollC = cos(rollA);\n const rollS = sin(rollA);\n const rel = vec2(pos.y, pos.z.sub(RIBBON_Z_CENTER)).toVar();\n pos.y.assign(rel.x.mul(rollC).sub(rel.y.mul(rollS)));\n pos.z.assign(float(RIBBON_Z_CENTER).add(rel.x.mul(rollS)).add(rel.y.mul(rollC)));\n pos.y.addAssign(u.uHelixRadius.mul(cos(hAng)));\n pos.z.addAssign(u.uHelixRadius.mul(sin(hAng)));\n }\n\n // The X-twist frequency feeding the second rotation. Under TWIST_MOTION it is modulated by\n // simplex noise indexed along the ribbon (uv.y), so the twist breathes over time.\n let twistXFreq: FloatNode = u.uTwFreqX;\n if (flags.twistMotion) {\n const noiseArg = flags.loopMotion\n ? vec2(uv.y.mul(2.0), 0.0).add(loopOff)\n : vec2(uv.y.mul(2.0), t);\n twistXFreq = u.uTwFreqX.sub(simplexNoise(noiseArg).mul(0.1));\n }\n\n // Three-axis twist. rotA keys off uv.x (the folded WIDTH), rotB/rotC off uv.y (the LENGTH).\n const twists: [Twist, Twist, Twist] = [\n { axis: vec3(0.5, 0.0, 0.5), angle: u.uTwFreqY.mul(expStep(uv.x, u.uTwPowY)) },\n { axis: vec3(0.0, 0.5, 0.5), angle: twistXFreq.mul(expStep(uv.y, u.uTwPowX)) },\n { axis: vec3(0.5, 0.0, 0.5), angle: u.uTwFreqZ.mul(expStep(uv.y, u.uTwPowZ)) },\n ];\n const twisted = applyTwist(applyTwist(applyTwist(pos, twists[0]), twists[1]), twists[2]).toVar();\n\n const fanned = flags.radial\n ? applyRadial(\n twisted,\n uv,\n u.uRadialAmount,\n u.uRadialArc,\n u.uRadialSpread,\n u.uRadialRadius,\n u.uRadialCenter,\n u.uRadialCone,\n u.uRadialSwirl,\n )\n : twisted;\n // The path goes LAST, after the radial fan, exactly as in the GLSL. It used to run before the fan\n // here, so a wave with both was a different shape on each backend — and no preset combined them,\n // so parity never saw it.\n return { pos: flags.path ? applyPath(fanned, u.uPathTex) : fanned, twists };\n}\n\n/**\n * Sweep the deformed ribbon onto its authored centreline. The TSL twin of the GLSL `PATH` block —\n * see that for the four details that make a straight path exactly the identity.\n */\n/** V coordinate of a LUT row's texel centre. */\nconst pathRow = (r: number): number => (r + 0.5) / PATH_ROWS;\n\nexport function applyPath(pos: Vec3Node, tex: WaveTslUniforms[\"uPathTex\"]): Vec3Node {\n const at = (x: FloatNode, r: number) => tex.sample(vec2(x, pathRow(r))).level(float(0));\n const sRaw = pos.x.add(200.0).div(400.0).toVar();\n // Closure is in the binormal row's alpha; it decides how s is addressed, so read it first.\n const closed = at(float(0.5 / PATH_SAMPLES), 2)\n .w.greaterThan(0.5)\n .toVar();\n const s = select(closed, fract(sRaw), clamp(sRaw, 0, 1)).toVar();\n // Interpolate between neighbouring samples here, at full precision, from a NEAREST texture — see\n // bakePathTexture for why hardware filtering of float32 is neither guaranteed nor exact.\n const i = s.mul(PATH_SAMPLES - 1).toVar();\n const i0 = floor(i).toVar();\n const f = i.sub(i0).toVar();\n const u0 = i0.add(0.5).div(PATH_SAMPLES).toVar();\n const u1 = min(i0.add(1), PATH_SAMPLES - 1)\n .add(0.5)\n .div(PATH_SAMPLES)\n .toVar();\n const lerpRow = (r: number) => mix(at(u0, r), at(u1, r), f);\n const pP = lerpRow(0).toVar();\n const pNL = lerpRow(1).toVar(); // .w = the path's arc length\n const pN = normalize(pNL.xyz).toVar();\n const pB = normalize(lerpRow(2).xyz).toVar();\n const past = select(closed, float(0), sRaw.sub(s).mul(pNL.w));\n return pP.xyz\n .add(cross(pN, pB).mul(past))\n .add(pN.mul(pos.y))\n .add(pB.mul(pos.z.sub(RIBBON_Z_CENTER).mul(pP.w)));\n}\n"],"mappings":"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"}
@@ -0,0 +1,189 @@
1
+ import * as THREE from "three";
2
+ /** A path whose last point sits on its first is a closed ring — that repeat is the whole
3
+ * declaration. One rule, shared by the sampler, the LUT and the studio. */
4
+ function isClosedPath(points) {
5
+ if (points.length <= 2) return false;
6
+ const a = points[0];
7
+ const b = points[points.length - 1];
8
+ return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) < .5;
9
+ }
10
+ const tmpA = new THREE.Vector3();
11
+ const tmpB = new THREE.Vector3();
12
+ const tmpC = new THREE.Vector3();
13
+ /** Catmull-Rom through the control points, clamped at the ends (so the curve starts and finishes
14
+ * exactly on the first and last point rather than overshooting). */
15
+ function curveOf(points) {
16
+ const vs = points.map((p) => new THREE.Vector3(p.x, p.y, p.z));
17
+ const closed = isClosedPath(points);
18
+ if (closed) vs.pop();
19
+ if (vs.length === 2) vs.splice(1, 0, vs[0].clone().lerp(vs[1], .5));
20
+ return new THREE.CatmullRomCurve3(vs, closed, "catmullrom", .5);
21
+ }
22
+ /** Interpolate the per-point scalars (width / twist) along the same normalized parameter the curve
23
+ * uses, so a point's width lands where that point does. */
24
+ function scalarAt(points, t, key, fallback) {
25
+ if (points.length === 1) return points[0][key] ?? fallback;
26
+ const f = t * (points.length - 1);
27
+ const i = Math.min(points.length - 2, Math.floor(f));
28
+ const a = points[i][key] ?? fallback;
29
+ const b = points[i + 1][key] ?? fallback;
30
+ const u = f - i;
31
+ return a + (b - a) * (u * u * (3 - 2 * u));
32
+ }
33
+ /**
34
+ * Sample the path into evenly spaced frames.
35
+ *
36
+ * Two details decide whether the result is usable:
37
+ *
38
+ * - ARC LENGTH, not curve parameter. Catmull-Rom runs fast through straight stretches and slow
39
+ * through tight ones, so sampling by `t` would bunch the ribbon's strands wherever the author
40
+ * happened to put a control point. `getSpacedPoints` walks it by distance instead, which keeps the
41
+ * comb even however the path is dragged.
42
+ * - PARALLEL TRANSPORT, not a Frenet frame. Frenet builds its normal from curvature, which flips
43
+ * through every inflection and is undefined on a straight stretch — the ribbon would snap 180°
44
+ * mid-sweep. Carrying the previous frame forward and only rotating it by the tangent's own change
45
+ * gives a frame that never spins, which is what a physical ribbon does.
46
+ */
47
+ function samplePath(points, samples = 128) {
48
+ const curve = curveOf(points);
49
+ const pts = curve.getSpacedPoints(samples - 1);
50
+ const out = [];
51
+ let tangent = curve.getTangentAt(0).normalize();
52
+ let normal = tmpA.set(0, 1, 0).clone();
53
+ if (Math.abs(normal.dot(tangent)) > .99) normal.set(1, 0, 0);
54
+ normal.sub(tmpB.copy(tangent).multiplyScalar(normal.dot(tangent))).normalize();
55
+ for (let i = 0; i < samples; i++) {
56
+ const t = i / (samples - 1);
57
+ const nextTangent = curve.getTangentAt(Math.min(t, 1)).normalize();
58
+ const axis = tmpB.copy(tangent).cross(nextTangent);
59
+ const sin = axis.length();
60
+ if (sin > 1e-6) {
61
+ const angle = Math.atan2(sin, tangent.dot(nextTangent));
62
+ normal.applyAxisAngle(axis.divideScalar(sin), angle);
63
+ }
64
+ tangent = nextTangent.clone();
65
+ normal.sub(tmpC.copy(tangent).multiplyScalar(normal.dot(tangent))).normalize();
66
+ const twistDeg = scalarAt(points, t, "twist", 0);
67
+ const twist = THREE.MathUtils.degToRad(twistDeg);
68
+ const n = normal.clone();
69
+ if (twist !== 0) n.applyAxisAngle(tangent, twist);
70
+ const b = tangent.clone().cross(n).normalize();
71
+ out.push({
72
+ pos: pts[i].clone(),
73
+ normal: n,
74
+ binormal: b,
75
+ width: Math.max(0, scalarAt(points, t, "width", 1)),
76
+ twist: twistDeg
77
+ });
78
+ }
79
+ return out;
80
+ }
81
+ /**
82
+ * Bake the frames into an RGBA float texture the vertex shader can read: three rows of
83
+ * {@link PATH_SAMPLES} texels — position (+width in alpha), normal (+arc length), binormal
84
+ * (+closed flag).
85
+ *
86
+ * NEAREST, and the shader interpolates between neighbouring samples itself. Hardware filtering of a
87
+ * float32 texture is not portable: WebGL2 needs OES_texture_float_linear and WebGPU the optional
88
+ * float32-filterable feature, and without them a linearly filtered float32 texture is incomplete and
89
+ * reads as zero — every path would collapse onto the origin. Where filtering IS available its
90
+ * weights may be quantized (commonly to 8 bits), which alone keeps a straight path from being an
91
+ * exact identity. A manual mix is full float precision on every device.
92
+ */
93
+ function bakePathTexture(points, samples = 128) {
94
+ const data = new Float32Array(samples * 3 * 4);
95
+ writePathTexture(data, points, samples);
96
+ const tex = new THREE.DataTexture(data, samples, 3, THREE.RGBAFormat, THREE.FloatType);
97
+ tex.minFilter = THREE.NearestFilter;
98
+ tex.magFilter = THREE.NearestFilter;
99
+ tex.wrapS = THREE.ClampToEdgeWrapping;
100
+ tex.wrapT = THREE.ClampToEdgeWrapping;
101
+ tex.generateMipmaps = false;
102
+ tex.needsUpdate = true;
103
+ return tex;
104
+ }
105
+ /** Rewrite an existing LUT in place — what a control-point drag calls, so editing costs one small
106
+ * texture upload per frame instead of a geometry rebuild. */
107
+ function writePathTexture(data, points, samples = 128) {
108
+ const frames = samplePath(points, samples);
109
+ let length = 0;
110
+ for (let i = 1; i < samples; i++) length += frames[i].pos.distanceTo(frames[i - 1].pos);
111
+ const closed = isClosedPath(points) ? 1 : 0;
112
+ for (let i = 0; i < samples; i++) {
113
+ const f = frames[i];
114
+ let o = i * 4;
115
+ data[o] = f.pos.x;
116
+ data[o + 1] = f.pos.y;
117
+ data[o + 2] = f.pos.z;
118
+ data[o + 3] = f.width;
119
+ o = (samples + i) * 4;
120
+ data[o] = f.normal.x;
121
+ data[o + 1] = f.normal.y;
122
+ data[o + 2] = f.normal.z;
123
+ data[o + 3] = length;
124
+ o = (samples * 2 + i) * 4;
125
+ data[o] = f.binormal.x;
126
+ data[o + 1] = f.binormal.y;
127
+ data[o + 2] = f.binormal.z;
128
+ data[o + 3] = closed;
129
+ }
130
+ }
131
+ /**
132
+ * Points along a circular arc in the ribbon's own plane (z = RIBBON_Z_CENTER), `turns` of a full
133
+ * circle, so a very shallow arc is the straight ribbon barely bent — 1 closes the
134
+ * ring (the first point is repeated, which is how a path says it is closed). The radius follows from
135
+ * the ribbon's own 400-unit length, so the sweep neither stretches nor bunches whatever the turns.
136
+ */
137
+ function arcPath(turns, count = 9) {
138
+ const r = 400 / (2 * Math.PI * Math.max(Math.abs(turns), .001));
139
+ const span = 2 * Math.PI * turns;
140
+ const out = [];
141
+ for (let i = 0; i < count; i++) {
142
+ const a = -span / 2 + span * i / (count - 1);
143
+ out.push({
144
+ x: Math.sin(a) * r,
145
+ y: r - Math.cos(a) * r,
146
+ z: -8
147
+ });
148
+ }
149
+ if (Math.abs(Math.abs(turns) - 1) < 1e-6) out[out.length - 1] = { ...out[0] };
150
+ return out;
151
+ }
152
+ /**
153
+ * The default path for a wave that is taking one for the first time: the straight centreline it
154
+ * already has, as three points, so entering path mode changes nothing until a point is dragged.
155
+ *
156
+ * Path points are where the centreline GOES, in the wave's local space — and the folded ribbon's
157
+ * own centreline runs along z = RIBBON_Z_CENTER, not z = 0. A straight path at z = 0 sat the ribbon
158
+ * that far off its own plane.
159
+ */
160
+ function straightPath() {
161
+ const z = -8;
162
+ return [
163
+ {
164
+ x: -200,
165
+ y: 0,
166
+ z,
167
+ width: 1,
168
+ twist: 0
169
+ },
170
+ {
171
+ x: 0,
172
+ y: 0,
173
+ z,
174
+ width: 1,
175
+ twist: 0
176
+ },
177
+ {
178
+ x: 200,
179
+ y: 0,
180
+ z,
181
+ width: 1,
182
+ twist: 0
183
+ }
184
+ ];
185
+ }
186
+ //#endregion
187
+ export { arcPath, bakePathTexture, isClosedPath, samplePath, straightPath, writePathTexture };
188
+
189
+ //# sourceMappingURL=wavePath.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wavePath.js","names":[],"sources":["../../src/renderer/wavePath.ts"],"sourcesContent":["/**\n * The wave PATH: a centreline the ribbon is swept along, replacing the straight one the folded\n * geometry is born with.\n *\n * Everything else in the shape pipeline bends a ribbon whose centreline is fixed — the twists rotate\n * it, the helix carries it around an axis, the radial fan splays it — so none of them can make a\n * ribbon that changes direction more than once, crosses itself, or narrows where the author wants it\n * to. A path can, because it IS the centreline: the author moves control points and the ribbon\n * follows.\n *\n * The GPU cannot evaluate this per vertex. A frame that does not spin has to be carried ALONG the\n * curve (see {@link samplePath}), which is an integration, not a closed form. So the CPU bakes a\n * small lookup table — position, frame and width at N points along the curve — and the vertex shader\n * samples it by the vertex's own position along the length. That also makes dragging cheap: a moved\n * control point rewrites a 128×3 texture, not 80k vertices.\n */\nimport * as THREE from \"three\";\nimport type { PathPoint } from \"../config/model\";\nimport { RIBBON_Z_CENTER } from \"./WaveGeometry\";\n\n/** Samples baked into the lookup table. 128 is well past the point where a ribbon 400 units long\n * shows faceting, and the texture is still only 1.5 KB. */\nexport const PATH_SAMPLES = 128;\n\n/** Rows in the LUT: position (+width), frame normal (+the path's arc length), frame binormal\n * (+1 when the path is closed). The two extra channels are what the shader needs to carry the\n * ribbon past an open path's ends and to wrap a closed one. */\nexport const PATH_ROWS = 3;\n\n/** A path whose last point sits on its first is a closed ring — that repeat is the whole\n * declaration. One rule, shared by the sampler, the LUT and the studio. */\nexport function isClosedPath(points: PathPoint[]): boolean {\n if (points.length <= 2) return false;\n const a = points[0];\n const b = points[points.length - 1];\n return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) < 0.5;\n}\n\nconst tmpA = new THREE.Vector3();\nconst tmpB = new THREE.Vector3();\nconst tmpC = new THREE.Vector3();\n\n/** Catmull-Rom through the control points, clamped at the ends (so the curve starts and finishes\n * exactly on the first and last point rather than overshooting). */\nfunction curveOf(points: PathPoint[]): THREE.CatmullRomCurve3 {\n const vs = points.map((p) => new THREE.Vector3(p.x, p.y, p.z));\n // CLOSURE IS INFERRED, not a flag: a path whose last point sits on its first is a ring, and\n // Catmull-Rom closes it smoothly once the duplicate is dropped. Leaving it open instead would put\n // a visible kink exactly where the two ends meet, and asking for a `closed` boolean would be a\n // knob for something the points already say.\n const closed = isClosedPath(points);\n if (closed) vs.pop();\n // A two-point path is a straight line; Catmull-Rom needs three to have a tangent at the ends, so\n // duplicate into a midpoint rather than special-casing the whole sampler.\n if (vs.length === 2) vs.splice(1, 0, vs[0].clone().lerp(vs[1], 0.5));\n return new THREE.CatmullRomCurve3(vs, closed, \"catmullrom\", 0.5);\n}\n\n/** Interpolate the per-point scalars (width / twist) along the same normalized parameter the curve\n * uses, so a point's width lands where that point does. */\nfunction scalarAt(\n points: PathPoint[],\n t: number,\n key: \"width\" | \"twist\",\n fallback: number,\n): number {\n if (points.length === 1) return points[0][key] ?? fallback;\n const f = t * (points.length - 1);\n const i = Math.min(points.length - 2, Math.floor(f));\n const a = points[i][key] ?? fallback;\n const b = points[i + 1][key] ?? fallback;\n const u = f - i;\n // Smoothstep between control points: a linear ramp puts a visible crease in the ribbon's width\n // exactly at each point, which reads as a dent rather than a taper.\n return a + (b - a) * (u * u * (3 - 2 * u));\n}\n\n/** One sample of the swept frame: where the ribbon's centre is, which way its surface faces, which\n * way its width runs, and how wide it is there. */\nexport interface PathSample {\n pos: THREE.Vector3;\n /** Surface normal — the axis displacement and the fold's thickness ride. */\n normal: THREE.Vector3;\n /** Across the ribbon's width. */\n binormal: THREE.Vector3;\n width: number;\n /**\n * The roll at this sample, in DEGREES — the same units {@link PathPoint.twist} is authored in.\n *\n * It is already baked into `normal`/`binormal`, so the renderer never reads it. It is here for\n * RESAMPLING: a frame cannot be stored back into a PathPoint, so anything rebuilding a path from\n * these samples has to carry the twist across explicitly or the ribbon silently untwists.\n */\n twist: number;\n}\n\n/**\n * Sample the path into evenly spaced frames.\n *\n * Two details decide whether the result is usable:\n *\n * - ARC LENGTH, not curve parameter. Catmull-Rom runs fast through straight stretches and slow\n * through tight ones, so sampling by `t` would bunch the ribbon's strands wherever the author\n * happened to put a control point. `getSpacedPoints` walks it by distance instead, which keeps the\n * comb even however the path is dragged.\n * - PARALLEL TRANSPORT, not a Frenet frame. Frenet builds its normal from curvature, which flips\n * through every inflection and is undefined on a straight stretch — the ribbon would snap 180°\n * mid-sweep. Carrying the previous frame forward and only rotating it by the tangent's own change\n * gives a frame that never spins, which is what a physical ribbon does.\n */\nexport function samplePath(points: PathPoint[], samples = PATH_SAMPLES): PathSample[] {\n const curve = curveOf(points);\n const pts = curve.getSpacedPoints(samples - 1);\n const out: PathSample[] = [];\n\n // Seed the frame: any vector perpendicular to the first tangent will do, but picking the one\n // closest to +Y keeps an un-twisted path's surface facing the same way the un-pathed ribbon's does.\n let tangent = curve.getTangentAt(0).normalize();\n let normal = tmpA.set(0, 1, 0).clone();\n if (Math.abs(normal.dot(tangent)) > 0.99) normal.set(1, 0, 0);\n normal.sub(tmpB.copy(tangent).multiplyScalar(normal.dot(tangent))).normalize();\n\n for (let i = 0; i < samples; i++) {\n const t = i / (samples - 1);\n const nextTangent = curve.getTangentAt(Math.min(t, 1)).normalize();\n // Rotate the carried normal by the same rotation that takes the old tangent to the new one.\n const axis = tmpB.copy(tangent).cross(nextTangent);\n const sin = axis.length();\n if (sin > 1e-6) {\n const angle = Math.atan2(sin, tangent.dot(nextTangent));\n normal.applyAxisAngle(axis.divideScalar(sin), angle);\n }\n tangent = nextTangent.clone();\n // Re-orthogonalize against drift: 128 small rotations accumulate error that would otherwise\n // shear the cross-section.\n normal.sub(tmpC.copy(tangent).multiplyScalar(normal.dot(tangent))).normalize();\n\n const twistDeg = scalarAt(points, t, \"twist\", 0);\n const twist = THREE.MathUtils.degToRad(twistDeg);\n const n = normal.clone();\n if (twist !== 0) n.applyAxisAngle(tangent, twist);\n // T × N, not N × T. On a straight path that is +Z — the direction the ribbon's width already\n // runs — which is what makes a straight path the identity. N × T is −Z: it mirrored the ribbon\n // across its width, which flips the handedness of every twist on it.\n const b = tangent.clone().cross(n).normalize();\n\n out.push({\n pos: pts[i].clone(),\n normal: n,\n binormal: b,\n width: Math.max(0, scalarAt(points, t, \"width\", 1)),\n twist: twistDeg,\n });\n }\n return out;\n}\n\n/**\n * Bake the frames into an RGBA float texture the vertex shader can read: three rows of\n * {@link PATH_SAMPLES} texels — position (+width in alpha), normal (+arc length), binormal\n * (+closed flag).\n *\n * NEAREST, and the shader interpolates between neighbouring samples itself. Hardware filtering of a\n * float32 texture is not portable: WebGL2 needs OES_texture_float_linear and WebGPU the optional\n * float32-filterable feature, and without them a linearly filtered float32 texture is incomplete and\n * reads as zero — every path would collapse onto the origin. Where filtering IS available its\n * weights may be quantized (commonly to 8 bits), which alone keeps a straight path from being an\n * exact identity. A manual mix is full float precision on every device.\n */\nexport function bakePathTexture(points: PathPoint[], samples = PATH_SAMPLES): THREE.DataTexture {\n const data = new Float32Array(samples * PATH_ROWS * 4);\n writePathTexture(data, points, samples);\n const tex = new THREE.DataTexture(data, samples, PATH_ROWS, THREE.RGBAFormat, THREE.FloatType);\n tex.minFilter = THREE.NearestFilter;\n tex.magFilter = THREE.NearestFilter;\n tex.wrapS = THREE.ClampToEdgeWrapping;\n tex.wrapT = THREE.ClampToEdgeWrapping;\n tex.generateMipmaps = false;\n tex.needsUpdate = true;\n return tex;\n}\n\n/** Rewrite an existing LUT in place — what a control-point drag calls, so editing costs one small\n * texture upload per frame instead of a geometry rebuild. */\nexport function writePathTexture(\n data: Float32Array,\n points: PathPoint[],\n samples = PATH_SAMPLES,\n): void {\n const frames = samplePath(points, samples);\n // Arc length of the sampled curve: what one unit of the ribbon's normalized length spans, which\n // the shader needs to extrapolate past an open path's ends at the same rate it runs along it.\n let length = 0;\n for (let i = 1; i < samples; i++) length += frames[i].pos.distanceTo(frames[i - 1].pos);\n const closed = isClosedPath(points) ? 1 : 0;\n for (let i = 0; i < samples; i++) {\n const f = frames[i];\n let o = i * 4;\n data[o] = f.pos.x;\n data[o + 1] = f.pos.y;\n data[o + 2] = f.pos.z;\n data[o + 3] = f.width;\n o = (samples + i) * 4;\n data[o] = f.normal.x;\n data[o + 1] = f.normal.y;\n data[o + 2] = f.normal.z;\n data[o + 3] = length;\n o = (samples * 2 + i) * 4;\n data[o] = f.binormal.x;\n data[o + 1] = f.binormal.y;\n data[o + 2] = f.binormal.z;\n data[o + 3] = closed; // every texel, so the shader can read it from any one\n }\n}\n\n/**\n * Points along a circular arc in the ribbon's own plane (z = RIBBON_Z_CENTER), `turns` of a full\n * circle, so a very shallow arc is the straight ribbon barely bent — 1 closes the\n * ring (the first point is repeated, which is how a path says it is closed). The radius follows from\n * the ribbon's own 400-unit length, so the sweep neither stretches nor bunches whatever the turns.\n */\nexport function arcPath(turns: number, count = 9): PathPoint[] {\n const r = 400 / (2 * Math.PI * Math.max(Math.abs(turns), 1e-3));\n const span = 2 * Math.PI * turns;\n const out: PathPoint[] = [];\n for (let i = 0; i < count; i++) {\n const a = -span / 2 + (span * i) / (count - 1);\n out.push({ x: Math.sin(a) * r, y: r - Math.cos(a) * r, z: RIBBON_Z_CENTER });\n }\n if (Math.abs(Math.abs(turns) - 1) < 1e-6) out[out.length - 1] = { ...out[0] };\n return out;\n}\n\n/**\n * The default path for a wave that is taking one for the first time: the straight centreline it\n * already has, as three points, so entering path mode changes nothing until a point is dragged.\n *\n * Path points are where the centreline GOES, in the wave's local space — and the folded ribbon's\n * own centreline runs along z = RIBBON_Z_CENTER, not z = 0. A straight path at z = 0 sat the ribbon\n * that far off its own plane.\n */\nexport function straightPath(): PathPoint[] {\n const z = RIBBON_Z_CENTER;\n return [\n { x: -200, y: 0, z, width: 1, twist: 0 },\n { x: 0, y: 0, z, width: 1, twist: 0 },\n { x: 200, y: 0, z, width: 1, twist: 0 },\n ];\n}\n"],"mappings":"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"}
@@ -5,7 +5,11 @@ import { core_loader_d_exports } from "../core-loader.js";
5
5
  import { PosterFit } from "./poster.js";
6
6
  //#region src/shell/createWave.d.ts
7
7
  /** Why the shell showed the poster instead of a live wave. */
8
- type FallbackReason = "no-webgl" | "reduced-motion" | "save-data" | "context-lost" | "load-error";
8
+ type FallbackReason = "no-webgl" |
9
+ /** WebGL exists, but it is a software rasteriser — the poster is the better answer. Distinct from
10
+ * `"no-webgl"` on purpose: a page that wants to say "your browser cannot do this" and one that
11
+ * wants to say "this machine has no GPU" are different messages. */
12
+ "software-renderer" | "reduced-motion" | "save-data" | "context-lost" | "load-error";
9
13
  /** poster → loading → running, or → fallback (permanent poster). */
10
14
  type WaveState = "poster" | "loading" | "running" | "fallback";
11
15
  /** The heavy module fetched on upgrade. */
@@ -21,7 +25,16 @@ interface WaveOptions {
21
25
  lazy?: boolean;
22
26
  /** IntersectionObserver margin for the lazy trigger. Default "200px". */
23
27
  rootMargin?: string;
24
- /** "auto" probes WebGL (with failIfMajorPerformanceCaveat); "force" skips the probe; "off" stays a poster. */
28
+ /**
29
+ * - `"auto"` (default) — probe, and upgrade only onto a GPU. A software rasteriser (SwiftShader,
30
+ * llvmpipe) keeps the poster and reports `"software-renderer"`: it can technically run the
31
+ * wave, at around 2 fps with seconds of blocked main thread, which is worse for the page than
32
+ * the still it already has.
33
+ * - `"force"` — skip the probe entirely and upgrade regardless. This is the escape hatch if you
34
+ * genuinely want the live render on a software renderer.
35
+ * - `"off"` — stay a poster. THIS is how you decline the upgrade; `paused` does not, it keeps the
36
+ * whole renderer and only stops the frames.
37
+ */
25
38
  webgl?: "auto" | "force" | "off";
26
39
  /**
27
40
  * Which renderer backend to use.
@@ -43,7 +56,14 @@ interface WaveOptions {
43
56
  respectSaveData?: boolean;
44
57
  /** Poster→canvas crossfade duration (ms). Default 300. */
45
58
  fadeMs?: number;
46
- /** Start paused. */
59
+ /**
60
+ * Start paused.
61
+ *
62
+ * This stops FRAMES; it does not decline the upgrade. The engine is still fetched, the renderer
63
+ * still builds, `wave3d-ready` still fires, state still reaches `"running"` and the poster is
64
+ * still swapped out for a (static) canvas. If what you want is "keep the still and do nothing",
65
+ * that is `webgl: "off"`.
66
+ */
47
67
  paused?: boolean;
48
68
  onReady?(renderer: WaveRenderer): void;
49
69
  onFallback?(reason: FallbackReason): void;
@@ -1,4 +1,4 @@
1
- import { hasWebGL, hasWebGPU, prefersReducedData, prefersReducedMotion } from "./probe.js";
1
+ import { hasWebGPU, prefersReducedData, prefersReducedMotion, probeWebGL } from "./probe.js";
2
2
  import { ensurePositioned, setupPoster } from "./poster.js";
3
3
  //#region src/shell/createWave.ts
4
4
  /** The stub for WebGL-only builds: refuses, and the caller falls back to the GLSL renderer. */
@@ -99,9 +99,10 @@ function createWaveImpl(loadCore, loadGpu, container, config, options) {
99
99
  }
100
100
  function probeAndUpgrade() {
101
101
  if (aborted) return;
102
- if (webgl === "auto" && !hasWebGL()) {
103
- fallback("no-webgl");
104
- return;
102
+ if (webgl === "auto") {
103
+ const probe = probeWebGL();
104
+ if (probe === "none") return fallback("no-webgl");
105
+ if (probe === "software") return fallback("software-renderer");
105
106
  }
106
107
  upgrade();
107
108
  }
@@ -1 +1 @@
1
- {"version":3,"file":"createWave.js","names":[],"sources":["../../src/shell/createWave.ts"],"sourcesContent":["import type { StudioConfig } from \"../config/model\";\nimport type { WaveRenderer, WaveRendererOptions } from \"../renderer/WaveRenderer\";\nimport type { TiltStatus } from \"../renderer/tilt\";\nimport { hasWebGL, hasWebGPU, prefersReducedMotion, prefersReducedData } from \"./probe\";\nimport { setupPoster, ensurePositioned, type Poster, type PosterFit } from \"./poster\";\n\nexport type { PosterFit } from \"./poster\";\n\n/** Why the shell showed the poster instead of a live wave. */\nexport type FallbackReason =\n | \"no-webgl\"\n | \"reduced-motion\"\n | \"save-data\"\n | \"context-lost\"\n | \"load-error\";\n\n/** poster → loading → running, or → fallback (permanent poster). */\nexport type WaveState = \"poster\" | \"loading\" | \"running\" | \"fallback\";\n\n/** The heavy module fetched on upgrade. */\ntype CoreModule = typeof import(\"../core-loader\");\n\nexport interface WaveOptions {\n /** Poster URL / data-URI. Defaults to adopting the container's `<img data-wave3d-poster>` (SSR). */\n poster?: string;\n /** Poster `object-fit`. Default `\"fill\"` — matches the canvas (which renders edge-to-edge at the\n * container's aspect), so a poster captured at that aspect hands off with no visible jump. Use\n * `\"cover\"` to crop a different-aspect placeholder instead of stretching it. See {@link PosterFit}. */\n posterFit?: PosterFit;\n /** Wait until the container nears the viewport before fetching the engine. Default true. */\n lazy?: boolean;\n /** IntersectionObserver margin for the lazy trigger. Default \"200px\". */\n rootMargin?: string;\n /** \"auto\" probes WebGL (with failIfMajorPerformanceCaveat); \"force\" skips the probe; \"off\" stays a poster. */\n webgl?: \"auto\" | \"force\" | \"off\";\n /**\n * Which renderer backend to use.\n *\n * - `\"webgl\"` (default) — the GLSL renderer. Nothing extra is downloaded.\n * - `\"webgpu\"` — the TSL renderer, which itself falls back to a WebGL2 backend where WebGPU is\n * unavailable, so it always renders.\n * - `\"auto\"` — WebGPU where an adapter can be acquired, else the GLSL renderer.\n *\n * Anything other than `\"webgl\"` fetches a separate ~197 KB (gzipped) chunk holding three's node\n * system, so it is opt-in rather than the default. See `renderer/gpu-loader.ts`.\n */\n backend?: \"webgl\" | \"webgpu\" | \"auto\";\n /** Forward prefers-reduced-motion to the renderer (freezes to a full static frame). Default true. */\n respectReducedMotion?: boolean;\n /** With reduced motion: \"static\" upgrades to a frozen frame; \"poster\" stays a poster. Default \"static\". */\n reducedMotionBehavior?: \"static\" | \"poster\";\n /** Keep a permanent poster when the user has Save-Data on. Default true. */\n respectSaveData?: boolean;\n /** Poster→canvas crossfade duration (ms). Default 300. */\n fadeMs?: number;\n /** Start paused. */\n paused?: boolean;\n onReady?(renderer: WaveRenderer): void;\n onFallback?(reason: FallbackReason): void;\n onStateChange?(state: WaveState): void;\n /** Seam for the standalone/CDN build to supply the core synchronously (three already bundled). */\n loadCore?(): Promise<CoreModule>;\n}\n\nexport interface SnapshotOptions {\n /** Image MIME type. Default `\"image/webp\"`. */\n type?: string;\n /** Encoder quality 0–1 for lossy types. */\n quality?: number;\n /** Render with a transparent background. Default true. */\n transparent?: boolean;\n /** Render a fixed animation-time for a reproducible frame (default: the live frame). Poster\n * captures should pass `0` — the frame the wave opens on, so the file doesn't churn per capture. */\n time?: number;\n}\n\nexport interface WaveHandle {\n readonly state: WaveState;\n readonly renderer: WaveRenderer | null;\n /** Capture the current live frame as an image Blob (a poster you can host/cache). Resolves `null`\n * until the wave is running (poster / fallback / pre-upgrade) — wait for {@link WaveOptions.onReady}\n * (or the element's `wave3d-ready` event) first. */\n snapshot(options?: SnapshotOptions): Promise<Blob | null>;\n /** Merge a partial config. Staged before upgrade; after, setConfig() then refreshPlayback(). */\n set(config: Partial<StudioConfig>): void;\n /** Feed a `custom:<name>` interaction input for `custom:*` bindings. Staged (last value per name)\n * before upgrade and replayed once the renderer is live; a no-op if no binding consumes it. */\n setInteractionInput(name: string, value: number): void;\n /**\n * Explicitly ask for the device-orientation sensor. OPTIONAL, and on iOS it opens a modal\n * permission dialog — nothing calls it for you, and a decorative scene should simply go without\n * tilt on that platform rather than interrupt the reader. CALL IT FROM A USER GESTURE: iOS 13+\n * grants the sensor only from inside a tap handler.\n *\n * Before the wave has upgraded there is no renderer to ask, so the request is REMEMBERED and\n * replayed on upgrade; that replay is no longer inside the gesture, so on iOS it resolves false\n * and the reader taps again. Gate the button on {@link WaveOptions.onReady} (or the element's\n * `wave3d-ready` event) and the first tap is the only tap.\n */\n enableTilt(): Promise<boolean>;\n /** Where the tilt sensor stands. `\"prompt\"` is exactly when a tap-to-enable affordance helps. */\n tiltStatus(): TiltStatus;\n /** Take the next orientation reading as the neutral pose (the reader has changed grip). */\n recenterTilt(): void;\n play(): void;\n pause(): void;\n /** Safe to call in any state (aborts a pending upgrade, disposes a live renderer, removes the poster). */\n destroy(): void;\n}\n\n/**\n * The shell implementation. `loadCore` is an explicit parameter (not read from options) so the\n * standalone/CDN build can pass a synchronous core and NOT bundle the dynamic-import path — its\n * output stays a single file. The public {@link createWave} supplies the dynamic-import default.\n */\ntype RendererCtor = new (\n container: HTMLElement,\n config: StudioConfig,\n options: WaveRendererOptions,\n) => WaveRenderer;\n\n/**\n * Fetches the TSL/WebGPU backend.\n *\n * INJECTED rather than referenced directly, so a build that cannot code-split — the single-file\n * standalone, which the studio inlines as one Blob into exported embed HTML — can supply a loader\n * that never mentions the module. Left as a default `import()` inside this file, the bundler would\n * inline the whole node system into that artifact: measured at 419 KB gzipped against 197 KB.\n */\nexport type GpuLoader = () => Promise<{ WaveRendererGPU: RendererCtor }>;\n\n/** The stub for WebGL-only builds: refuses, and the caller falls back to the GLSL renderer. */\nexport const noGpuBackend: GpuLoader = () =>\n Promise.reject(new Error(\"This build ships the WebGL renderer only.\"));\n\nexport function createWaveImpl(\n loadCore: () => Promise<CoreModule>,\n loadGpu: GpuLoader,\n container: HTMLElement,\n config: Partial<StudioConfig>,\n options: WaveOptions,\n): WaveHandle {\n /** Pick the renderer class, fetching the TSL backend only when it is actually wanted. */\n const resolveBackend = async (): Promise<RendererCtor> => {\n const glsl = async (): Promise<RendererCtor> =>\n (await loadCore()).WaveRenderer as unknown as RendererCtor;\n if (backend === \"webgl\") return glsl();\n if (backend === \"auto\" && !(await hasWebGPU())) return glsl();\n try {\n return (await loadGpu()).WaveRendererGPU;\n } catch {\n // A missing chunk or an unusable adapter is not fatal: the GLSL renderer draws the same scene.\n return glsl();\n }\n };\n const {\n lazy = true,\n rootMargin = \"200px\",\n webgl = \"auto\",\n backend = \"webgl\",\n respectReducedMotion = true,\n reducedMotionBehavior = \"static\",\n respectSaveData = true,\n fadeMs = 300,\n } = options;\n\n let state: WaveState = \"poster\";\n let renderer: WaveRenderer | null = null;\n let staged: Partial<StudioConfig> = { ...config };\n if (options.paused !== undefined) staged.paused = options.paused;\n // Interaction inputs fed before the renderer exists — last value per name, replayed on upgrade.\n const stagedInputs = new Map<string, number>();\n /** enableTilt() called before the upgrade — replayed once the renderer exists (see WaveHandle). */\n let tiltRequested = false;\n\n let aborted = false;\n let io: IntersectionObserver | null = null;\n let lostTimer: ReturnType<typeof setTimeout> | undefined;\n let lossCount = 0;\n\n ensurePositioned(container);\n const poster: Poster | null = setupPoster(container, options.poster, options.posterFit);\n\n function setState(next: WaveState): void {\n if (state === next) return;\n state = next;\n options.onStateChange?.(next);\n }\n\n function fallback(reason: FallbackReason): void {\n setState(\"fallback\");\n poster?.show();\n options.onFallback?.(reason);\n }\n\n function onContextRestored(): void {\n clearTimeout(lostTimer); // three rebuilt the context in time; stay live\n }\n\n function onContextLost(): void {\n lossCount += 1;\n clearTimeout(lostTimer);\n if (lossCount >= 2) {\n teardownRenderer();\n fallback(\"context-lost\");\n return;\n }\n // three (WaveRenderer) tries to restore; if it hasn't within ~4s, give up to the poster.\n lostTimer = setTimeout(() => {\n teardownRenderer();\n fallback(\"context-lost\");\n }, 4000);\n }\n\n function teardownRenderer(): void {\n if (!renderer) return;\n const canvas = renderer.renderer.domElement;\n canvas.removeEventListener(\"webglcontextlost\", onContextLost);\n canvas.removeEventListener(\"webglcontextrestored\", onContextRestored);\n renderer.dispose();\n renderer = null;\n }\n\n async function upgrade(): Promise<void> {\n setState(\"loading\");\n let core: CoreModule;\n try {\n core = await loadCore();\n } catch {\n if (!aborted) fallback(\"load-error\");\n return;\n }\n if (aborted) return;\n\n const full: StudioConfig = { ...core.createDefaultConfig(), ...staged };\n const rendererOptions: WaveRendererOptions = { respectReducedMotion };\n // The TSL backend lives behind its own dynamic import so `three/webgpu` never enters the eager\n // graph. If that chunk fails to load, or WebGPU turns out to be unusable under \"auto\", fall\n // back to the GLSL renderer rather than the poster — it renders the same scene either way.\n const Backend = await resolveBackend();\n renderer = new Backend(container, full, rendererOptions);\n await renderer.init(); // no-op on WebGL; starts the backend on WebGPU\n if (aborted) {\n renderer.dispose();\n renderer = null;\n return;\n }\n const canvas = renderer.renderer.domElement;\n canvas.addEventListener(\"webglcontextlost\", onContextLost, false);\n canvas.addEventListener(\"webglcontextrestored\", onContextRestored, false);\n renderer.start();\n for (const [name, value] of stagedInputs) renderer.setInteractionInput(name, value);\n if (tiltRequested) void renderer.enableTilt();\n setState(\"running\");\n options.onReady?.(renderer);\n\n if (poster) {\n // Crossfade only after two frames, so the wave has definitely painted first.\n requestAnimationFrame(() =>\n requestAnimationFrame(() => {\n if (!aborted && renderer) poster.fadeOut(fadeMs);\n }),\n );\n }\n }\n\n function probeAndUpgrade(): void {\n if (aborted) return;\n if (webgl === \"auto\" && !hasWebGL()) {\n fallback(\"no-webgl\");\n return;\n }\n void upgrade();\n }\n\n function begin(): void {\n // Permanent-poster gates (checked before any lazy wait or engine fetch).\n if (webgl === \"off\") return; // deliberate poster-only mode — stay \"poster\", no fallback callback\n if (respectSaveData && prefersReducedData()) return fallback(\"save-data\");\n if (respectReducedMotion && reducedMotionBehavior === \"poster\" && prefersReducedMotion()) {\n return fallback(\"reduced-motion\");\n }\n if (lazy && typeof IntersectionObserver !== \"undefined\") {\n io = new IntersectionObserver(\n (entries) => {\n if (entries.some((e) => e.isIntersecting)) {\n io?.disconnect();\n io = null;\n probeAndUpgrade();\n }\n },\n { rootMargin },\n );\n io.observe(container);\n } else {\n probeAndUpgrade();\n }\n }\n\n const handle: WaveHandle = {\n get state() {\n return state;\n },\n get renderer() {\n return renderer;\n },\n snapshot(opts = {}) {\n if (!renderer) return Promise.resolve(null);\n const { type = \"image/webp\", quality, transparent = true, time } = opts;\n return renderer.captureImage(type, transparent, quality, time);\n },\n set(next) {\n if (renderer) {\n renderer.setConfig({ ...renderer.getConfig(), ...next });\n renderer.refreshPlayback(); // setConfig doesn't re-evaluate `paused` on its own\n } else {\n staged = { ...staged, ...next };\n }\n },\n setInteractionInput(name, value) {\n if (renderer) renderer.setInteractionInput(name, value);\n else stagedInputs.set(name, value);\n },\n enableTilt() {\n if (renderer) return renderer.enableTilt();\n tiltRequested = true;\n return Promise.resolve(false);\n },\n tiltStatus() {\n return renderer?.tiltStatus() ?? \"prompt\";\n },\n recenterTilt() {\n renderer?.recenterTilt();\n },\n play() {\n if (renderer) {\n renderer.getConfig().paused = false;\n renderer.refreshPlayback();\n } else {\n staged.paused = false;\n }\n },\n pause() {\n if (renderer) {\n renderer.getConfig().paused = true;\n renderer.refreshPlayback();\n } else {\n staged.paused = true;\n }\n },\n destroy() {\n aborted = true;\n io?.disconnect();\n io = null;\n clearTimeout(lostTimer);\n teardownRenderer();\n poster?.remove();\n },\n };\n\n begin();\n return handle;\n}\n\n/**\n * Mount a self-optimizing wave into a container: shows a poster immediately, then — lazily, and only\n * when the browser can actually run it — fetches the engine, builds the renderer, and crossfades in.\n * Falls back to the poster on no-WebGL / save-data / reduced-motion / context-loss / load errors.\n * No static three import: the engine arrives via a dynamic import, so the shell stays tiny.\n */\nexport function createWave(\n container: HTMLElement,\n config: Partial<StudioConfig> = {},\n options: WaveOptions = {},\n): WaveHandle {\n return createWaveImpl(\n options.loadCore ?? (() => import(\"../core-loader\")),\n () => import(\"../renderer/gpu-loader\"),\n container,\n config,\n options,\n );\n}\n\n/** The drop-in embed contract: an alias of {@link createWave}. */\nexport const mountWave = createWave;\n"],"mappings":"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"}
1
+ {"version":3,"file":"createWave.js","names":[],"sources":["../../src/shell/createWave.ts"],"sourcesContent":["import type { StudioConfig } from \"../config/model\";\nimport type { WaveRenderer, WaveRendererOptions } from \"../renderer/WaveRenderer\";\nimport type { TiltStatus } from \"../renderer/tilt\";\nimport { probeWebGL, hasWebGPU, prefersReducedMotion, prefersReducedData } from \"./probe\";\nimport { setupPoster, ensurePositioned, type Poster, type PosterFit } from \"./poster\";\n\nexport type { PosterFit } from \"./poster\";\n\n/** Why the shell showed the poster instead of a live wave. */\nexport type FallbackReason =\n | \"no-webgl\"\n /** WebGL exists, but it is a software rasteriser — the poster is the better answer. Distinct from\n * `\"no-webgl\"` on purpose: a page that wants to say \"your browser cannot do this\" and one that\n * wants to say \"this machine has no GPU\" are different messages. */\n | \"software-renderer\"\n | \"reduced-motion\"\n | \"save-data\"\n | \"context-lost\"\n | \"load-error\";\n\n/** poster → loading → running, or → fallback (permanent poster). */\nexport type WaveState = \"poster\" | \"loading\" | \"running\" | \"fallback\";\n\n/** The heavy module fetched on upgrade. */\ntype CoreModule = typeof import(\"../core-loader\");\n\nexport interface WaveOptions {\n /** Poster URL / data-URI. Defaults to adopting the container's `<img data-wave3d-poster>` (SSR). */\n poster?: string;\n /** Poster `object-fit`. Default `\"fill\"` — matches the canvas (which renders edge-to-edge at the\n * container's aspect), so a poster captured at that aspect hands off with no visible jump. Use\n * `\"cover\"` to crop a different-aspect placeholder instead of stretching it. See {@link PosterFit}. */\n posterFit?: PosterFit;\n /** Wait until the container nears the viewport before fetching the engine. Default true. */\n lazy?: boolean;\n /** IntersectionObserver margin for the lazy trigger. Default \"200px\". */\n rootMargin?: string;\n /**\n * - `\"auto\"` (default) — probe, and upgrade only onto a GPU. A software rasteriser (SwiftShader,\n * llvmpipe) keeps the poster and reports `\"software-renderer\"`: it can technically run the\n * wave, at around 2 fps with seconds of blocked main thread, which is worse for the page than\n * the still it already has.\n * - `\"force\"` — skip the probe entirely and upgrade regardless. This is the escape hatch if you\n * genuinely want the live render on a software renderer.\n * - `\"off\"` — stay a poster. THIS is how you decline the upgrade; `paused` does not, it keeps the\n * whole renderer and only stops the frames.\n */\n webgl?: \"auto\" | \"force\" | \"off\";\n /**\n * Which renderer backend to use.\n *\n * - `\"webgl\"` (default) — the GLSL renderer. Nothing extra is downloaded.\n * - `\"webgpu\"` — the TSL renderer, which itself falls back to a WebGL2 backend where WebGPU is\n * unavailable, so it always renders.\n * - `\"auto\"` — WebGPU where an adapter can be acquired, else the GLSL renderer.\n *\n * Anything other than `\"webgl\"` fetches a separate ~197 KB (gzipped) chunk holding three's node\n * system, so it is opt-in rather than the default. See `renderer/gpu-loader.ts`.\n */\n backend?: \"webgl\" | \"webgpu\" | \"auto\";\n /** Forward prefers-reduced-motion to the renderer (freezes to a full static frame). Default true. */\n respectReducedMotion?: boolean;\n /** With reduced motion: \"static\" upgrades to a frozen frame; \"poster\" stays a poster. Default \"static\". */\n reducedMotionBehavior?: \"static\" | \"poster\";\n /** Keep a permanent poster when the user has Save-Data on. Default true. */\n respectSaveData?: boolean;\n /** Poster→canvas crossfade duration (ms). Default 300. */\n fadeMs?: number;\n /**\n * Start paused.\n *\n * This stops FRAMES; it does not decline the upgrade. The engine is still fetched, the renderer\n * still builds, `wave3d-ready` still fires, state still reaches `\"running\"` and the poster is\n * still swapped out for a (static) canvas. If what you want is \"keep the still and do nothing\",\n * that is `webgl: \"off\"`.\n */\n paused?: boolean;\n onReady?(renderer: WaveRenderer): void;\n onFallback?(reason: FallbackReason): void;\n onStateChange?(state: WaveState): void;\n /** Seam for the standalone/CDN build to supply the core synchronously (three already bundled). */\n loadCore?(): Promise<CoreModule>;\n}\n\nexport interface SnapshotOptions {\n /** Image MIME type. Default `\"image/webp\"`. */\n type?: string;\n /** Encoder quality 0–1 for lossy types. */\n quality?: number;\n /** Render with a transparent background. Default true. */\n transparent?: boolean;\n /** Render a fixed animation-time for a reproducible frame (default: the live frame). Poster\n * captures should pass `0` — the frame the wave opens on, so the file doesn't churn per capture. */\n time?: number;\n}\n\nexport interface WaveHandle {\n readonly state: WaveState;\n readonly renderer: WaveRenderer | null;\n /** Capture the current live frame as an image Blob (a poster you can host/cache). Resolves `null`\n * until the wave is running (poster / fallback / pre-upgrade) — wait for {@link WaveOptions.onReady}\n * (or the element's `wave3d-ready` event) first. */\n snapshot(options?: SnapshotOptions): Promise<Blob | null>;\n /** Merge a partial config. Staged before upgrade; after, setConfig() then refreshPlayback(). */\n set(config: Partial<StudioConfig>): void;\n /** Feed a `custom:<name>` interaction input for `custom:*` bindings. Staged (last value per name)\n * before upgrade and replayed once the renderer is live; a no-op if no binding consumes it. */\n setInteractionInput(name: string, value: number): void;\n /**\n * Explicitly ask for the device-orientation sensor. OPTIONAL, and on iOS it opens a modal\n * permission dialog — nothing calls it for you, and a decorative scene should simply go without\n * tilt on that platform rather than interrupt the reader. CALL IT FROM A USER GESTURE: iOS 13+\n * grants the sensor only from inside a tap handler.\n *\n * Before the wave has upgraded there is no renderer to ask, so the request is REMEMBERED and\n * replayed on upgrade; that replay is no longer inside the gesture, so on iOS it resolves false\n * and the reader taps again. Gate the button on {@link WaveOptions.onReady} (or the element's\n * `wave3d-ready` event) and the first tap is the only tap.\n */\n enableTilt(): Promise<boolean>;\n /** Where the tilt sensor stands. `\"prompt\"` is exactly when a tap-to-enable affordance helps. */\n tiltStatus(): TiltStatus;\n /** Take the next orientation reading as the neutral pose (the reader has changed grip). */\n recenterTilt(): void;\n play(): void;\n pause(): void;\n /** Safe to call in any state (aborts a pending upgrade, disposes a live renderer, removes the poster). */\n destroy(): void;\n}\n\n/**\n * The shell implementation. `loadCore` is an explicit parameter (not read from options) so the\n * standalone/CDN build can pass a synchronous core and NOT bundle the dynamic-import path — its\n * output stays a single file. The public {@link createWave} supplies the dynamic-import default.\n */\ntype RendererCtor = new (\n container: HTMLElement,\n config: StudioConfig,\n options: WaveRendererOptions,\n) => WaveRenderer;\n\n/**\n * Fetches the TSL/WebGPU backend.\n *\n * INJECTED rather than referenced directly, so a build that cannot code-split — the single-file\n * standalone, which the studio inlines as one Blob into exported embed HTML — can supply a loader\n * that never mentions the module. Left as a default `import()` inside this file, the bundler would\n * inline the whole node system into that artifact: measured at 419 KB gzipped against 197 KB.\n */\nexport type GpuLoader = () => Promise<{ WaveRendererGPU: RendererCtor }>;\n\n/** The stub for WebGL-only builds: refuses, and the caller falls back to the GLSL renderer. */\nexport const noGpuBackend: GpuLoader = () =>\n Promise.reject(new Error(\"This build ships the WebGL renderer only.\"));\n\nexport function createWaveImpl(\n loadCore: () => Promise<CoreModule>,\n loadGpu: GpuLoader,\n container: HTMLElement,\n config: Partial<StudioConfig>,\n options: WaveOptions,\n): WaveHandle {\n /** Pick the renderer class, fetching the TSL backend only when it is actually wanted. */\n const resolveBackend = async (): Promise<RendererCtor> => {\n const glsl = async (): Promise<RendererCtor> =>\n (await loadCore()).WaveRenderer as unknown as RendererCtor;\n if (backend === \"webgl\") return glsl();\n if (backend === \"auto\" && !(await hasWebGPU())) return glsl();\n try {\n return (await loadGpu()).WaveRendererGPU;\n } catch {\n // A missing chunk or an unusable adapter is not fatal: the GLSL renderer draws the same scene.\n return glsl();\n }\n };\n const {\n lazy = true,\n rootMargin = \"200px\",\n webgl = \"auto\",\n backend = \"webgl\",\n respectReducedMotion = true,\n reducedMotionBehavior = \"static\",\n respectSaveData = true,\n fadeMs = 300,\n } = options;\n\n let state: WaveState = \"poster\";\n let renderer: WaveRenderer | null = null;\n let staged: Partial<StudioConfig> = { ...config };\n if (options.paused !== undefined) staged.paused = options.paused;\n // Interaction inputs fed before the renderer exists — last value per name, replayed on upgrade.\n const stagedInputs = new Map<string, number>();\n /** enableTilt() called before the upgrade — replayed once the renderer exists (see WaveHandle). */\n let tiltRequested = false;\n\n let aborted = false;\n let io: IntersectionObserver | null = null;\n let lostTimer: ReturnType<typeof setTimeout> | undefined;\n let lossCount = 0;\n\n ensurePositioned(container);\n const poster: Poster | null = setupPoster(container, options.poster, options.posterFit);\n\n function setState(next: WaveState): void {\n if (state === next) return;\n state = next;\n options.onStateChange?.(next);\n }\n\n function fallback(reason: FallbackReason): void {\n setState(\"fallback\");\n poster?.show();\n options.onFallback?.(reason);\n }\n\n function onContextRestored(): void {\n clearTimeout(lostTimer); // three rebuilt the context in time; stay live\n }\n\n function onContextLost(): void {\n lossCount += 1;\n clearTimeout(lostTimer);\n if (lossCount >= 2) {\n teardownRenderer();\n fallback(\"context-lost\");\n return;\n }\n // three (WaveRenderer) tries to restore; if it hasn't within ~4s, give up to the poster.\n lostTimer = setTimeout(() => {\n teardownRenderer();\n fallback(\"context-lost\");\n }, 4000);\n }\n\n function teardownRenderer(): void {\n if (!renderer) return;\n const canvas = renderer.renderer.domElement;\n canvas.removeEventListener(\"webglcontextlost\", onContextLost);\n canvas.removeEventListener(\"webglcontextrestored\", onContextRestored);\n renderer.dispose();\n renderer = null;\n }\n\n async function upgrade(): Promise<void> {\n setState(\"loading\");\n let core: CoreModule;\n try {\n core = await loadCore();\n } catch {\n if (!aborted) fallback(\"load-error\");\n return;\n }\n if (aborted) return;\n\n const full: StudioConfig = { ...core.createDefaultConfig(), ...staged };\n const rendererOptions: WaveRendererOptions = { respectReducedMotion };\n // The TSL backend lives behind its own dynamic import so `three/webgpu` never enters the eager\n // graph. If that chunk fails to load, or WebGPU turns out to be unusable under \"auto\", fall\n // back to the GLSL renderer rather than the poster — it renders the same scene either way.\n const Backend = await resolveBackend();\n renderer = new Backend(container, full, rendererOptions);\n await renderer.init(); // no-op on WebGL; starts the backend on WebGPU\n if (aborted) {\n renderer.dispose();\n renderer = null;\n return;\n }\n const canvas = renderer.renderer.domElement;\n canvas.addEventListener(\"webglcontextlost\", onContextLost, false);\n canvas.addEventListener(\"webglcontextrestored\", onContextRestored, false);\n renderer.start();\n for (const [name, value] of stagedInputs) renderer.setInteractionInput(name, value);\n if (tiltRequested) void renderer.enableTilt();\n setState(\"running\");\n options.onReady?.(renderer);\n\n if (poster) {\n // Crossfade only after two frames, so the wave has definitely painted first.\n requestAnimationFrame(() =>\n requestAnimationFrame(() => {\n if (!aborted && renderer) poster.fadeOut(fadeMs);\n }),\n );\n }\n }\n\n function probeAndUpgrade(): void {\n if (aborted) return;\n if (webgl === \"auto\") {\n // One probe, two outcomes. Software is NOT folded into \"no-webgl\": the consumer asked to be\n // told why, and \"this machine has no GPU\" is a different thing to say than \"your browser\n // cannot do this\".\n const probe = probeWebGL();\n if (probe === \"none\") return fallback(\"no-webgl\");\n if (probe === \"software\") return fallback(\"software-renderer\");\n }\n void upgrade();\n }\n\n function begin(): void {\n // Permanent-poster gates (checked before any lazy wait or engine fetch).\n if (webgl === \"off\") return; // deliberate poster-only mode — stay \"poster\", no fallback callback\n if (respectSaveData && prefersReducedData()) return fallback(\"save-data\");\n if (respectReducedMotion && reducedMotionBehavior === \"poster\" && prefersReducedMotion()) {\n return fallback(\"reduced-motion\");\n }\n if (lazy && typeof IntersectionObserver !== \"undefined\") {\n io = new IntersectionObserver(\n (entries) => {\n if (entries.some((e) => e.isIntersecting)) {\n io?.disconnect();\n io = null;\n probeAndUpgrade();\n }\n },\n { rootMargin },\n );\n io.observe(container);\n } else {\n probeAndUpgrade();\n }\n }\n\n const handle: WaveHandle = {\n get state() {\n return state;\n },\n get renderer() {\n return renderer;\n },\n snapshot(opts = {}) {\n if (!renderer) return Promise.resolve(null);\n const { type = \"image/webp\", quality, transparent = true, time } = opts;\n return renderer.captureImage(type, transparent, quality, time);\n },\n set(next) {\n if (renderer) {\n renderer.setConfig({ ...renderer.getConfig(), ...next });\n renderer.refreshPlayback(); // setConfig doesn't re-evaluate `paused` on its own\n } else {\n staged = { ...staged, ...next };\n }\n },\n setInteractionInput(name, value) {\n if (renderer) renderer.setInteractionInput(name, value);\n else stagedInputs.set(name, value);\n },\n enableTilt() {\n if (renderer) return renderer.enableTilt();\n tiltRequested = true;\n return Promise.resolve(false);\n },\n tiltStatus() {\n return renderer?.tiltStatus() ?? \"prompt\";\n },\n recenterTilt() {\n renderer?.recenterTilt();\n },\n play() {\n if (renderer) {\n renderer.getConfig().paused = false;\n renderer.refreshPlayback();\n } else {\n staged.paused = false;\n }\n },\n pause() {\n if (renderer) {\n renderer.getConfig().paused = true;\n renderer.refreshPlayback();\n } else {\n staged.paused = true;\n }\n },\n destroy() {\n aborted = true;\n io?.disconnect();\n io = null;\n clearTimeout(lostTimer);\n teardownRenderer();\n poster?.remove();\n },\n };\n\n begin();\n return handle;\n}\n\n/**\n * Mount a self-optimizing wave into a container: shows a poster immediately, then — lazily, and only\n * when the browser can actually run it — fetches the engine, builds the renderer, and crossfades in.\n * Falls back to the poster on no-WebGL / save-data / reduced-motion / context-loss / load errors.\n * No static three import: the engine arrives via a dynamic import, so the shell stays tiny.\n */\nexport function createWave(\n container: HTMLElement,\n config: Partial<StudioConfig> = {},\n options: WaveOptions = {},\n): WaveHandle {\n return createWaveImpl(\n options.loadCore ?? (() => import(\"../core-loader\")),\n () => import(\"../renderer/gpu-loader\"),\n container,\n config,\n options,\n );\n}\n\n/** The drop-in embed contract: an alias of {@link createWave}. */\nexport const mountWave = createWave;\n"],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ //#region src/shell/probe.d.ts
2
+ /**
3
+ * Whether the live renderer is a software rasteriser.
4
+ *
5
+ * `failIfMajorPerformanceCaveat` is SUPPOSED to cover this and does not: Chrome hands back a
6
+ * SwiftShader context regardless on many builds, which is how a machine with no usable GPU ends up
7
+ * running the full renderer at ~2 fps with seconds of blocked main thread — the exact case this
8
+ * exists for.
9
+ *
10
+ * Unknown means HARDWARE. WEBGL_debug_renderer_info is absent under some privacy settings, and
11
+ * reading "cannot tell" as "software" would quietly downgrade those users; they would see a poster
12
+ * forever and never know why.
13
+ */
14
+ declare function isSoftwareRenderer(gl: WebGLRenderingContext | WebGL2RenderingContext): boolean;
15
+ /**
16
+ * Synchronously test whether the browser can give us a usable WebGL context, and whether that
17
+ * context is worth upgrading to. Releases the throwaway context immediately via WEBGL_lose_context
18
+ * so probing doesn't consume one of the browser's ~16 live contexts.
19
+ *
20
+ * Returns WHY, not just yes/no, so the shell can tell a consumer "there is no WebGL here" apart
21
+ * from "there is, but it is the CPU" — those want different answers from a page.
22
+ */
23
+ declare function probeWebGL(): "ok" | "none" | "software";
24
+ /** Is there a usable context at all, software or not. */
25
+ declare function hasWebGL(): boolean;
26
+ //#endregion
27
+ export { hasWebGL, isSoftwareRenderer, probeWebGL };
28
+ //# sourceMappingURL=probe.d.ts.map
@@ -1,24 +1,71 @@
1
1
  //#region src/shell/probe.ts
2
2
  /**
3
- * Synchronously test whether the browser can give us a usable WebGL context. Uses
4
- * `failIfMajorPerformanceCaveat` so a software/blocklisted renderer (which would run the wave at a
5
- * slideshow framerate) reports as unavailable and we keep the poster. Releases the throwaway
6
- * context immediately via WEBGL_lose_context so probing doesn't consume one of the browser's ~16
7
- * live contexts.
3
+ * Renderer strings that mean "this is the CPU pretending to be a GPU". Matched case-insensitively
4
+ * against UNMASKED_RENDERER_WEBGL.
5
+ *
6
+ * Deliberately a list of known software rasterisers rather than a cleverer heuristic: the cost of a
7
+ * false positive is showing a poster to somebody with a perfectly good GPU, which is invisible to
8
+ * them and impossible to report, so the rule has to be "recognise software" and never "fail to
9
+ * recognise hardware".
8
10
  */
9
- function hasWebGL() {
10
- if (typeof document === "undefined") return false;
11
+ const SOFTWARE_RENDERERS = [
12
+ "swiftshader",
13
+ "llvmpipe",
14
+ "softpipe",
15
+ "software rasterizer",
16
+ "microsoft basic render",
17
+ "mesa offscreen",
18
+ "apple software renderer"
19
+ ];
20
+ /**
21
+ * Whether the live renderer is a software rasteriser.
22
+ *
23
+ * `failIfMajorPerformanceCaveat` is SUPPOSED to cover this and does not: Chrome hands back a
24
+ * SwiftShader context regardless on many builds, which is how a machine with no usable GPU ends up
25
+ * running the full renderer at ~2 fps with seconds of blocked main thread — the exact case this
26
+ * exists for.
27
+ *
28
+ * Unknown means HARDWARE. WEBGL_debug_renderer_info is absent under some privacy settings, and
29
+ * reading "cannot tell" as "software" would quietly downgrade those users; they would see a poster
30
+ * forever and never know why.
31
+ */
32
+ function isSoftwareRenderer(gl) {
33
+ try {
34
+ const info = gl.getExtension("WEBGL_debug_renderer_info");
35
+ if (!info) return false;
36
+ const name = String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL) ?? "").toLowerCase();
37
+ if (!name) return false;
38
+ return SOFTWARE_RENDERERS.some((s) => name.includes(s));
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+ /**
44
+ * Synchronously test whether the browser can give us a usable WebGL context, and whether that
45
+ * context is worth upgrading to. Releases the throwaway context immediately via WEBGL_lose_context
46
+ * so probing doesn't consume one of the browser's ~16 live contexts.
47
+ *
48
+ * Returns WHY, not just yes/no, so the shell can tell a consumer "there is no WebGL here" apart
49
+ * from "there is, but it is the CPU" — those want different answers from a page.
50
+ */
51
+ function probeWebGL() {
52
+ if (typeof document === "undefined") return "none";
11
53
  try {
12
54
  const canvas = document.createElement("canvas");
13
55
  const attrs = { failIfMajorPerformanceCaveat: true };
14
56
  const gl = canvas.getContext("webgl2", attrs) ?? canvas.getContext("webgl", attrs);
15
- if (!gl) return false;
57
+ if (!gl) return "none";
58
+ const software = isSoftwareRenderer(gl);
16
59
  gl.getExtension("WEBGL_lose_context")?.loseContext();
17
- return true;
60
+ return software ? "software" : "ok";
18
61
  } catch {
19
- return false;
62
+ return "none";
20
63
  }
21
64
  }
65
+ /** Is there a usable context at all, software or not. */
66
+ function hasWebGL() {
67
+ return probeWebGL() !== "none";
68
+ }
22
69
  /**
23
70
  * Whether the browser exposes a usable WebGPU adapter.
24
71
  *
@@ -46,6 +93,6 @@ function prefersReducedData() {
46
93
  return navigator.connection?.saveData === true;
47
94
  }
48
95
  //#endregion
49
- export { hasWebGL, hasWebGPU, prefersReducedData, prefersReducedMotion };
96
+ export { hasWebGL, hasWebGPU, isSoftwareRenderer, prefersReducedData, prefersReducedMotion, probeWebGL };
50
97
 
51
98
  //# sourceMappingURL=probe.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"probe.js","names":[],"sources":["../../src/shell/probe.ts"],"sourcesContent":["// WebGL capability probe for the shell. Kept dependency-free (no three) so it can run before the\n// heavy renderer chunk is fetched — the shell decides poster-vs-upgrade from this.\n\n/**\n * Synchronously test whether the browser can give us a usable WebGL context. Uses\n * `failIfMajorPerformanceCaveat` so a software/blocklisted renderer (which would run the wave at a\n * slideshow framerate) reports as unavailable and we keep the poster. Releases the throwaway\n * context immediately via WEBGL_lose_context so probing doesn't consume one of the browser's ~16\n * live contexts.\n */\nexport function hasWebGL(): boolean {\n if (typeof document === \"undefined\") return false;\n try {\n const canvas = document.createElement(\"canvas\");\n const attrs: WebGLContextAttributes = { failIfMajorPerformanceCaveat: true };\n const gl =\n canvas.getContext(\"webgl2\", attrs) ??\n (canvas.getContext(\"webgl\", attrs) as WebGLRenderingContext | null);\n if (!gl) return false;\n gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Whether the browser exposes a usable WebGPU adapter.\n *\n * Async, unlike {@link hasWebGL}: requesting an adapter is inherently asynchronous, and\n * `navigator.gpu` being present does not mean one can be acquired. Also note WebGPU is only exposed\n * to a SECURE CONTEXT — on `about:blank` or plain http, `navigator.gpu` is `undefined` even in a\n * browser that fully supports it.\n */\nexport async function hasWebGPU(): Promise<boolean> {\n const gpu = (navigator as Navigator & { gpu?: { requestAdapter(): Promise<unknown> } }).gpu;\n if (typeof navigator === \"undefined\" || !gpu) return false;\n try {\n return (await gpu.requestAdapter()) !== null;\n } catch {\n return false;\n }\n}\n\n/** True when the OS/browser is set to reduce motion. */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window !== \"undefined\" &&\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n\n/** True when the user has asked for reduced data usage (Save-Data / Data Saver). */\nexport function prefersReducedData(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const conn = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;\n return conn?.saveData === true;\n}\n"],"mappings":";;;;;;;;AAUA,SAAgB,WAAoB;CAClC,IAAI,OAAO,aAAa,aAAa,OAAO;CAC5C,IAAI;EACF,MAAM,SAAS,SAAS,cAAc,QAAQ;EAC9C,MAAM,QAAgC,EAAE,8BAA8B,KAAK;EAC3E,MAAM,KACJ,OAAO,WAAW,UAAU,KAAK,KAChC,OAAO,WAAW,SAAS,KAAK;EACnC,IAAI,CAAC,IAAI,OAAO;EAChB,GAAG,aAAa,oBAAoB,CAAC,EAAE,YAAY;EACnD,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;AAUA,eAAsB,YAA8B;CAClD,MAAM,MAAO,UAA2E;CACxF,IAAI,OAAO,cAAc,eAAe,CAAC,KAAK,OAAO;CACrD,IAAI;EACF,OAAQ,MAAM,IAAI,eAAe,MAAO;CAC1C,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,uBAAgC;CAC9C,OACE,OAAO,WAAW,eAClB,OAAO,OAAO,eAAe,cAC7B,OAAO,WAAW,kCAAkC,CAAC,CAAC;AAE1D;;AAGA,SAAgB,qBAA8B;CAC5C,IAAI,OAAO,cAAc,aAAa,OAAO;CAE7C,OADc,UAAkE,YACnE,aAAa;AAC5B"}
1
+ {"version":3,"file":"probe.js","names":[],"sources":["../../src/shell/probe.ts"],"sourcesContent":["// WebGL capability probe for the shell. Kept dependency-free (no three) so it can run before the\n// heavy renderer chunk is fetched — the shell decides poster-vs-upgrade from this.\n\n/**\n * Renderer strings that mean \"this is the CPU pretending to be a GPU\". Matched case-insensitively\n * against UNMASKED_RENDERER_WEBGL.\n *\n * Deliberately a list of known software rasterisers rather than a cleverer heuristic: the cost of a\n * false positive is showing a poster to somebody with a perfectly good GPU, which is invisible to\n * them and impossible to report, so the rule has to be \"recognise software\" and never \"fail to\n * recognise hardware\".\n */\nconst SOFTWARE_RENDERERS = [\n \"swiftshader\", // Chrome's fallback rasteriser\n \"llvmpipe\", // Mesa\n \"softpipe\", // Mesa\n \"software rasterizer\",\n \"microsoft basic render\", // the Windows adapter with no driver\n \"mesa offscreen\",\n \"apple software renderer\",\n];\n\n/**\n * Whether the live renderer is a software rasteriser.\n *\n * `failIfMajorPerformanceCaveat` is SUPPOSED to cover this and does not: Chrome hands back a\n * SwiftShader context regardless on many builds, which is how a machine with no usable GPU ends up\n * running the full renderer at ~2 fps with seconds of blocked main thread — the exact case this\n * exists for.\n *\n * Unknown means HARDWARE. WEBGL_debug_renderer_info is absent under some privacy settings, and\n * reading \"cannot tell\" as \"software\" would quietly downgrade those users; they would see a poster\n * forever and never know why.\n */\nexport function isSoftwareRenderer(gl: WebGLRenderingContext | WebGL2RenderingContext): boolean {\n try {\n const info = gl.getExtension(\"WEBGL_debug_renderer_info\");\n if (!info) return false; // cannot tell → assume hardware\n const name = String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL) ?? \"\").toLowerCase();\n if (!name) return false;\n return SOFTWARE_RENDERERS.some((s) => name.includes(s));\n } catch {\n return false;\n }\n}\n\n/**\n * Synchronously test whether the browser can give us a usable WebGL context, and whether that\n * context is worth upgrading to. Releases the throwaway context immediately via WEBGL_lose_context\n * so probing doesn't consume one of the browser's ~16 live contexts.\n *\n * Returns WHY, not just yes/no, so the shell can tell a consumer \"there is no WebGL here\" apart\n * from \"there is, but it is the CPU\" — those want different answers from a page.\n */\nexport function probeWebGL(): \"ok\" | \"none\" | \"software\" {\n if (typeof document === \"undefined\") return \"none\";\n try {\n const canvas = document.createElement(\"canvas\");\n const attrs: WebGLContextAttributes = { failIfMajorPerformanceCaveat: true };\n const gl =\n canvas.getContext(\"webgl2\", attrs) ??\n (canvas.getContext(\"webgl\", attrs) as WebGLRenderingContext | null);\n if (!gl) return \"none\";\n const software = isSoftwareRenderer(gl);\n gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n return software ? \"software\" : \"ok\";\n } catch {\n return \"none\";\n }\n}\n\n/** Is there a usable context at all, software or not. */\nexport function hasWebGL(): boolean {\n return probeWebGL() !== \"none\";\n}\n\n/**\n * Whether the browser exposes a usable WebGPU adapter.\n *\n * Async, unlike {@link hasWebGL}: requesting an adapter is inherently asynchronous, and\n * `navigator.gpu` being present does not mean one can be acquired. Also note WebGPU is only exposed\n * to a SECURE CONTEXT — on `about:blank` or plain http, `navigator.gpu` is `undefined` even in a\n * browser that fully supports it.\n */\nexport async function hasWebGPU(): Promise<boolean> {\n const gpu = (navigator as Navigator & { gpu?: { requestAdapter(): Promise<unknown> } }).gpu;\n if (typeof navigator === \"undefined\" || !gpu) return false;\n try {\n return (await gpu.requestAdapter()) !== null;\n } catch {\n return false;\n }\n}\n\n/** True when the OS/browser is set to reduce motion. */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window !== \"undefined\" &&\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n\n/** True when the user has asked for reduced data usage (Save-Data / Data Saver). */\nexport function prefersReducedData(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const conn = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;\n return conn?.saveData === true;\n}\n"],"mappings":";;;;;;;;;;AAYA,MAAM,qBAAqB;CACzB;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBAAmB,IAA6D;CAC9F,IAAI;EACF,MAAM,OAAO,GAAG,aAAa,2BAA2B;EACxD,IAAI,CAAC,MAAM,OAAO;EAClB,MAAM,OAAO,OAAO,GAAG,aAAa,KAAK,uBAAuB,KAAK,EAAE,CAAC,CAAC,YAAY;EACrF,IAAI,CAAC,MAAM,OAAO;EAClB,OAAO,mBAAmB,MAAM,MAAM,KAAK,SAAS,CAAC,CAAC;CACxD,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;AAUA,SAAgB,aAAyC;CACvD,IAAI,OAAO,aAAa,aAAa,OAAO;CAC5C,IAAI;EACF,MAAM,SAAS,SAAS,cAAc,QAAQ;EAC9C,MAAM,QAAgC,EAAE,8BAA8B,KAAK;EAC3E,MAAM,KACJ,OAAO,WAAW,UAAU,KAAK,KAChC,OAAO,WAAW,SAAS,KAAK;EACnC,IAAI,CAAC,IAAI,OAAO;EAChB,MAAM,WAAW,mBAAmB,EAAE;EACtC,GAAG,aAAa,oBAAoB,CAAC,EAAE,YAAY;EACnD,OAAO,WAAW,aAAa;CACjC,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,WAAoB;CAClC,OAAO,WAAW,MAAM;AAC1B;;;;;;;;;AAUA,eAAsB,YAA8B;CAClD,MAAM,MAAO,UAA2E;CACxF,IAAI,OAAO,cAAc,eAAe,CAAC,KAAK,OAAO;CACrD,IAAI;EACF,OAAQ,MAAM,IAAI,eAAe,MAAO;CAC1C,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,uBAAgC;CAC9C,OACE,OAAO,WAAW,eAClB,OAAO,OAAO,eAAe,cAC7B,OAAO,WAAW,kCAAkC,CAAC,CAAC;AAE1D;;AAGA,SAAgB,qBAA8B;CAC5C,IAAI,OAAO,cAAc,aAAa,OAAO;CAE7C,OADc,UAAkE,YACnE,aAAa;AAC5B"}