@wave3d/core 0.8.0 → 0.9.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 (71) hide show
  1. package/README.md +3 -1
  2. package/dist/config/model.d.ts +48 -2
  3. package/dist/config/model.js +8 -1
  4. package/dist/config/model.js.map +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/renderer/WaveRenderer.d.ts +142 -10
  7. package/dist/renderer/WaveRenderer.js +209 -53
  8. package/dist/renderer/WaveRenderer.js.map +1 -1
  9. package/dist/renderer/WaveRendererGPU.js +291 -0
  10. package/dist/renderer/WaveRendererGPU.js.map +1 -0
  11. package/dist/renderer/gpu-loader.js +2 -0
  12. package/dist/renderer/index.d.ts +3 -2
  13. package/dist/renderer/interaction.d.ts +29 -0
  14. package/dist/renderer/interaction.js +77 -31
  15. package/dist/renderer/interaction.js.map +1 -1
  16. package/dist/renderer/interactionGates.js +59 -0
  17. package/dist/renderer/interactionGates.js.map +1 -0
  18. package/dist/renderer/particleField.d.ts +1 -59
  19. package/dist/renderer/particleField.js +49 -29
  20. package/dist/renderer/particleField.js.map +1 -1
  21. package/dist/renderer/particleFieldGPU.js +141 -0
  22. package/dist/renderer/particleFieldGPU.js.map +1 -0
  23. package/dist/renderer/tilt.d.ts +17 -0
  24. package/dist/renderer/tilt.js +124 -0
  25. package/dist/renderer/tilt.js.map +1 -0
  26. package/dist/renderer/tsl/color.js +129 -0
  27. package/dist/renderer/tsl/color.js.map +1 -0
  28. package/dist/renderer/tsl/noise.js +83 -0
  29. package/dist/renderer/tsl/noise.js.map +1 -0
  30. package/dist/renderer/tsl/packedArray.js +76 -0
  31. package/dist/renderer/tsl/packedArray.js.map +1 -0
  32. package/dist/renderer/tsl/particleMaterial.js +133 -0
  33. package/dist/renderer/tsl/particleMaterial.js.map +1 -0
  34. package/dist/renderer/tsl/particleUniforms.js +44 -0
  35. package/dist/renderer/tsl/particleUniforms.js.map +1 -0
  36. package/dist/renderer/tsl/pointerField.js +73 -0
  37. package/dist/renderer/tsl/pointerField.js.map +1 -0
  38. package/dist/renderer/tsl/post.js +63 -0
  39. package/dist/renderer/tsl/post.js.map +1 -0
  40. package/dist/renderer/tsl/postChain.js +56 -0
  41. package/dist/renderer/tsl/postChain.js.map +1 -0
  42. package/dist/renderer/tsl/postEffects.js +221 -0
  43. package/dist/renderer/tsl/postEffects.js.map +1 -0
  44. package/dist/renderer/tsl/types.js +28 -0
  45. package/dist/renderer/tsl/types.js.map +1 -0
  46. package/dist/renderer/tsl/uniforms.js +152 -0
  47. package/dist/renderer/tsl/uniforms.js.map +1 -0
  48. package/dist/renderer/tsl/waveMaterial.js +185 -0
  49. package/dist/renderer/tsl/waveMaterial.js.map +1 -0
  50. package/dist/renderer/tsl/waveShape.js +108 -0
  51. package/dist/renderer/tsl/waveShape.js.map +1 -0
  52. package/dist/shell/createWave.d.ts +29 -0
  53. package/dist/shell/createWave.js +39 -11
  54. package/dist/shell/createWave.js.map +1 -1
  55. package/dist/shell/probe.js +18 -1
  56. package/dist/shell/probe.js.map +1 -1
  57. package/dist/standalone/wave3d.standalone.js +1811 -1594
  58. package/dist/standalone/wave3d.standalone.webgpu.js +36647 -0
  59. package/dist/standalone.d.ts +10 -3
  60. package/dist/standalone.js +10 -3
  61. package/dist/standalone.js.map +1 -1
  62. package/dist/studio/StudioWaveRenderer.d.ts +4 -0
  63. package/dist/studio/StudioWaveRenderer.js +9 -0
  64. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  65. package/dist/studio/StudioWaveRendererGPU.js +18 -0
  66. package/dist/studio/StudioWaveRendererGPU.js.map +1 -0
  67. package/dist/studio/index.d.ts +12 -2
  68. package/dist/studio/index.js +14 -1
  69. package/dist/studio/index.js.map +1 -0
  70. package/package.json +12 -3
  71. package/skills/wave3d/SKILL.md +29 -2
@@ -0,0 +1,141 @@
1
+ import { SHAPE_INDEX, buildParticleAttributes, loadSpriteTexture, setLinear } from "./particleField.js";
2
+ import { makeParticleUniforms } from "./tsl/particleUniforms.js";
3
+ import { buildParticleMaterial } from "./tsl/particleMaterial.js";
4
+ import * as THREE from "three";
5
+ //#region src/renderer/particleFieldGPU.ts
6
+ /**
7
+ * The particle field on the TSL backend.
8
+ *
9
+ * Same public surface as {@link ParticleField} — `object`, `sync`, `frame`, `configure`, `dispose`
10
+ * — and the same seeded buffers, so a field is byte-identical between backends before it ever
11
+ * reaches a shader. Two things differ, both forced by WebGPU:
12
+ *
13
+ * - The field is a `THREE.Sprite` with `count`, not a `THREE.Points`. WebGPU point primitives are
14
+ * fixed at one pixel, so `gl_PointSize` has no equivalent; three's PointsNodeMaterial honours a
15
+ * size only on a Sprite, drawing instanced quads.
16
+ * - There is no uniform MIRRORING. The GLSL field copies the owning wave's shape and pointer
17
+ * uniforms across to its own material every frame; here the graph reads the wave's registry
18
+ * directly, so the dust and the ribbon are literally driven by the same nodes.
19
+ */
20
+ var ParticleFieldGPU = class {
21
+ host;
22
+ onReady;
23
+ /** The scene object. A Sprite, not Points — see the class note. */
24
+ object = new THREE.Sprite();
25
+ uniforms = makeParticleUniforms();
26
+ material;
27
+ /** Layout signature — only these rebuild the seeded buffers; the rest are live uniforms. */
28
+ sig = "";
29
+ attrs;
30
+ count = 0;
31
+ sprite;
32
+ spriteUrl = "";
33
+ spriteFailedUrl = "";
34
+ disposed = false;
35
+ /** The variant the current material was built for; a change rebuilds the graph. */
36
+ variant = "";
37
+ constructor(host, onReady) {
38
+ this.host = host;
39
+ this.onReady = onReady;
40
+ this.object.frustumCulled = false;
41
+ this.object.renderOrder = 10;
42
+ this.object.count = 0;
43
+ this.object.matrixAutoUpdate = false;
44
+ }
45
+ /** Reconcile to `cfg`, rebuilding the seeded buffers only when the layout signature changed. */
46
+ sync(cfg, loopSeconds) {
47
+ const count = Math.max(0, Math.floor(cfg.count));
48
+ const edgeBias = cfg.edgeBias ?? 1;
49
+ const bias = cfg.bias ?? 0;
50
+ const sig = `${count}|${cfg.seed}|${edgeBias}|${bias}`;
51
+ if (sig !== this.sig) {
52
+ this.sig = sig;
53
+ const { aSeed, aRnd, aUv } = buildParticleAttributes(count, cfg.seed, edgeBias, bias);
54
+ this.attrs = {
55
+ aSeed,
56
+ aRnd,
57
+ aUv
58
+ };
59
+ this.count = count;
60
+ this.object.count = count;
61
+ this.variant = "";
62
+ }
63
+ const u = this.uniforms;
64
+ u.uLoopSeconds.value = loopSeconds;
65
+ u.uLife.value = cfg.life ?? 6;
66
+ u.uPartSpeed.value = cfg.speed ?? 1;
67
+ u.uSize.value = cfg.size;
68
+ u.uSizeJitter.value = cfg.sizeJitter ?? 0;
69
+ u.uTwinkle.value = cfg.twinkle ?? 0;
70
+ u.uDrift.value = cfg.drift ?? 0;
71
+ u.uRise.value = cfg.rise ?? 0;
72
+ u.uSwirl.value = cfg.swirl ?? 0;
73
+ u.uWander.value = cfg.wander ?? 0;
74
+ u.uShape.value = SHAPE_INDEX[cfg.shape ?? "glitter"] ?? 0;
75
+ u.uPartShove.value = cfg.pointerShove ?? 1;
76
+ setLinear(u.uColor.value, cfg.color ?? "#ffcf8a");
77
+ setLinear(u.uColor2.value, cfg.color2 ?? cfg.color ?? "#ffcf8a");
78
+ this.syncSprite(cfg.shape === "sprite" ? cfg.spriteUrl ?? "" : "");
79
+ this.ensureMaterial();
80
+ }
81
+ /** Push the per-frame frame basis (the camera can move, so this runs every frame). */
82
+ frame(f, _pixelRatio) {
83
+ this.uniforms.uCenter.value.copy(f.center);
84
+ this.uniforms.uRight.value.copy(f.right);
85
+ this.uniforms.uUp.value.copy(f.up);
86
+ }
87
+ /** Bind the owning wave's world matrix and shed cadence. The shape and pointer uniforms need no
88
+ * mirroring on this backend — the graph reads the wave's registry directly. */
89
+ configure(shape) {
90
+ this.uniforms.uShedModel.value.copy(shape.matrixWorld);
91
+ this.uniforms.uShedSpeed.value = shape.speed;
92
+ this.uniforms.uShedSeed.value = shape.seed;
93
+ this.ensureMaterial();
94
+ }
95
+ /** Build (or rebuild) the node graph when the variant it was compiled for no longer applies. */
96
+ ensureMaterial() {
97
+ if (!this.attrs || this.count === 0) return;
98
+ const flags = {
99
+ ...this.host.flags,
100
+ sprite: !!this.sprite
101
+ };
102
+ const key = `${this.sig}|${JSON.stringify(flags)}`;
103
+ if (key === this.variant) return;
104
+ this.variant = key;
105
+ this.material?.dispose();
106
+ this.material = buildParticleMaterial(this.host.uniforms, this.uniforms, this.attrs, flags, this.sprite ?? null);
107
+ this.object.material = this.material;
108
+ }
109
+ syncSprite(url) {
110
+ if (url === this.spriteUrl) return;
111
+ this.spriteUrl = url;
112
+ if (this.sprite) {
113
+ this.sprite.dispose();
114
+ this.sprite = void 0;
115
+ this.variant = "";
116
+ }
117
+ if (!url || url === this.spriteFailedUrl) return;
118
+ loadSpriteTexture(url, (tex) => {
119
+ if (this.disposed || this.spriteUrl !== url) return;
120
+ this.sprite = tex;
121
+ this.variant = "";
122
+ this.ensureMaterial();
123
+ this.onReady?.();
124
+ }, () => {
125
+ this.spriteFailedUrl = url;
126
+ });
127
+ }
128
+ setTime(t) {
129
+ this.uniforms.uTime.value = t;
130
+ }
131
+ dispose() {
132
+ this.disposed = true;
133
+ this.material?.dispose();
134
+ this.sprite?.dispose();
135
+ this.object.geometry.dispose();
136
+ }
137
+ };
138
+ //#endregion
139
+ export { ParticleFieldGPU };
140
+
141
+ //# sourceMappingURL=particleFieldGPU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"particleFieldGPU.js","names":[],"sources":["../../src/renderer/particleFieldGPU.ts"],"sourcesContent":["/**\n * The particle field on the TSL backend.\n *\n * Same public surface as {@link ParticleField} — `object`, `sync`, `frame`, `configure`, `dispose`\n * — and the same seeded buffers, so a field is byte-identical between backends before it ever\n * reaches a shader. Two things differ, both forced by WebGPU:\n *\n * - The field is a `THREE.Sprite` with `count`, not a `THREE.Points`. WebGPU point primitives are\n * fixed at one pixel, so `gl_PointSize` has no equivalent; three's PointsNodeMaterial honours a\n * size only on a Sprite, drawing instanced quads.\n * - There is no uniform MIRRORING. The GLSL field copies the owning wave's shape and pointer\n * uniforms across to its own material every frame; here the graph reads the wave's registry\n * directly, so the dust and the ribbon are literally driven by the same nodes.\n */\nimport * as THREE from \"three\";\nimport type { PointsNodeMaterial } from \"three/webgpu\";\nimport type { ParticlesConfig } from \"../config/model\";\nimport {\n buildParticleAttributes,\n loadSpriteTexture,\n setLinear,\n DEFAULT_COLOR,\n SHAPE_INDEX,\n type ParticleFrame,\n} from \"./particleField\";\nimport { makeParticleUniforms, type ParticleTslUniforms } from \"./tsl/particleUniforms\";\nimport {\n buildParticleMaterial,\n type ParticleMaterialFlags,\n type ParticleAttributeArrays,\n} from \"./tsl/particleMaterial\";\nimport type { WaveTslUniforms } from \"./tsl/uniforms\";\n\n/** What the owning wave contributes: its uniform registry and the shape variant its graph uses. */\nexport interface ParticleHost {\n uniforms: WaveTslUniforms;\n flags: Omit<ParticleMaterialFlags, \"sprite\">;\n}\n\nexport class ParticleFieldGPU {\n /** The scene object. A Sprite, not Points — see the class note. */\n readonly object = new THREE.Sprite();\n private readonly uniforms: ParticleTslUniforms = makeParticleUniforms();\n private material?: PointsNodeMaterial;\n /** Layout signature — only these rebuild the seeded buffers; the rest are live uniforms. */\n private sig = \"\";\n private attrs?: ParticleAttributeArrays;\n private count = 0;\n private sprite?: THREE.CanvasTexture;\n private spriteUrl = \"\";\n private spriteFailedUrl = \"\";\n private disposed = false;\n /** The variant the current material was built for; a change rebuilds the graph. */\n private variant = \"\";\n\n constructor(\n private readonly host: ParticleHost,\n private readonly onReady?: () => void,\n ) {\n this.object.frustumCulled = false; // positions are shader-computed; the quad is a dummy\n this.object.renderOrder = 10; // after the waves (0..5)\n this.object.count = 0;\n // The sprite's own transform must stay identity: the emitter computes WORLD positions, exactly\n // as the GLSL does, and PointsNodeMaterial feeds positionNode through modelViewMatrix.\n this.object.matrixAutoUpdate = false;\n }\n\n /** Reconcile to `cfg`, rebuilding the seeded buffers only when the layout signature changed. */\n sync(cfg: ParticlesConfig, loopSeconds: number): void {\n const count = Math.max(0, Math.floor(cfg.count));\n const edgeBias = cfg.edgeBias ?? 1;\n const bias = cfg.bias ?? 0;\n const sig = `${count}|${cfg.seed}|${edgeBias}|${bias}`;\n if (sig !== this.sig) {\n this.sig = sig;\n const { aSeed, aRnd, aUv } = buildParticleAttributes(count, cfg.seed, edgeBias, bias);\n this.attrs = { aSeed, aRnd, aUv };\n this.count = count;\n this.object.count = count;\n this.variant = \"\"; // the attribute nodes are baked into the graph, so it has to be rebuilt\n }\n\n const u = this.uniforms;\n u.uLoopSeconds.value = loopSeconds;\n u.uLife.value = cfg.life ?? 6;\n u.uPartSpeed.value = cfg.speed ?? 1;\n u.uSize.value = cfg.size;\n u.uSizeJitter.value = cfg.sizeJitter ?? 0;\n u.uTwinkle.value = cfg.twinkle ?? 0;\n u.uDrift.value = cfg.drift ?? 0;\n u.uRise.value = cfg.rise ?? 0;\n u.uSwirl.value = cfg.swirl ?? 0;\n u.uWander.value = cfg.wander ?? 0;\n u.uShape.value = SHAPE_INDEX[cfg.shape ?? \"glitter\"] ?? 0;\n u.uPartShove.value = cfg.pointerShove ?? 1;\n setLinear(u.uColor.value, cfg.color ?? DEFAULT_COLOR);\n setLinear(u.uColor2.value, cfg.color2 ?? cfg.color ?? DEFAULT_COLOR);\n this.syncSprite(cfg.shape === \"sprite\" ? (cfg.spriteUrl ?? \"\") : \"\");\n this.ensureMaterial();\n }\n\n /** Push the per-frame frame basis (the camera can move, so this runs every frame). */\n frame(f: ParticleFrame, _pixelRatio: number): void {\n // _pixelRatio is deliberately unused: three's PointsNodeMaterial multiplies sizeNode by the\n // device pixel ratio itself, so applying it here too would square the scaling.\n this.uniforms.uCenter.value.copy(f.center);\n this.uniforms.uRight.value.copy(f.right);\n this.uniforms.uUp.value.copy(f.up);\n }\n\n /** Bind the owning wave's world matrix and shed cadence. The shape and pointer uniforms need no\n * mirroring on this backend — the graph reads the wave's registry directly. */\n configure(shape: {\n /** Present for signature parity with the GLSL field; unused here — see the class note. */\n defines?: Record<string, string>;\n /** Ditto: this backend reads the wave's registry directly rather than copying from it. */\n uniforms?: Record<string, { value: unknown }>;\n matrixWorld: THREE.Matrix4;\n speed: number;\n seed: number;\n }): void {\n this.uniforms.uShedModel.value.copy(shape.matrixWorld);\n this.uniforms.uShedSpeed.value = shape.speed;\n this.uniforms.uShedSeed.value = shape.seed;\n this.ensureMaterial();\n }\n\n /** Build (or rebuild) the node graph when the variant it was compiled for no longer applies. */\n private ensureMaterial(): void {\n if (!this.attrs || this.count === 0) return;\n const flags: ParticleMaterialFlags = { ...this.host.flags, sprite: !!this.sprite };\n const key = `${this.sig}|${JSON.stringify(flags)}`;\n if (key === this.variant) return;\n this.variant = key;\n this.material?.dispose();\n this.material = buildParticleMaterial(\n this.host.uniforms,\n this.uniforms,\n this.attrs,\n flags,\n this.sprite ?? null,\n );\n this.object.material = this.material as unknown as THREE.SpriteMaterial;\n }\n\n private syncSprite(url: string): void {\n if (url === this.spriteUrl) return;\n this.spriteUrl = url;\n if (this.sprite) {\n this.sprite.dispose();\n this.sprite = undefined;\n this.variant = \"\"; // the sampler disappears from the graph\n }\n if (!url || url === this.spriteFailedUrl) return;\n loadSpriteTexture(\n url,\n (tex) => {\n if (this.disposed || this.spriteUrl !== url) return;\n this.sprite = tex;\n this.variant = \"\"; // the sampler appears in the graph\n this.ensureMaterial();\n this.onReady?.(); // a paused / settled renderer would otherwise never draw it\n },\n () => {\n this.spriteFailedUrl = url;\n },\n );\n }\n\n setTime(t: number): void {\n this.uniforms.uTime.value = t;\n }\n\n dispose(): void {\n this.disposed = true;\n this.material?.dispose();\n this.sprite?.dispose();\n this.object.geometry.dispose();\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAuCA,IAAa,mBAAb,MAA8B;CAiBT;CACA;;CAhBnB,SAAkB,IAAI,MAAM,OAAO;CACnC,WAAiD,qBAAqB;CACtE;;CAEA,MAAc;CACd;CACA,QAAgB;CAChB;CACA,YAAoB;CACpB,kBAA0B;CAC1B,WAAmB;;CAEnB,UAAkB;CAElB,YACE,MACA,SACA;EAFiB,KAAA,OAAA;EACA,KAAA,UAAA;EAEjB,KAAK,OAAO,gBAAgB;EAC5B,KAAK,OAAO,cAAc;EAC1B,KAAK,OAAO,QAAQ;EAGpB,KAAK,OAAO,mBAAmB;CACjC;;CAGA,KAAK,KAAsB,aAA2B;EACpD,MAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,IAAI,KAAK,CAAC;EAC/C,MAAM,WAAW,IAAI,YAAY;EACjC,MAAM,OAAO,IAAI,QAAQ;EACzB,MAAM,MAAM,GAAG,MAAM,GAAG,IAAI,KAAK,GAAG,SAAS,GAAG;EAChD,IAAI,QAAQ,KAAK,KAAK;GACpB,KAAK,MAAM;GACX,MAAM,EAAE,OAAO,MAAM,QAAQ,wBAAwB,OAAO,IAAI,MAAM,UAAU,IAAI;GACpF,KAAK,QAAQ;IAAE;IAAO;IAAM;GAAI;GAChC,KAAK,QAAQ;GACb,KAAK,OAAO,QAAQ;GACpB,KAAK,UAAU;EACjB;EAEA,MAAM,IAAI,KAAK;EACf,EAAE,aAAa,QAAQ;EACvB,EAAE,MAAM,QAAQ,IAAI,QAAQ;EAC5B,EAAE,WAAW,QAAQ,IAAI,SAAS;EAClC,EAAE,MAAM,QAAQ,IAAI;EACpB,EAAE,YAAY,QAAQ,IAAI,cAAc;EACxC,EAAE,SAAS,QAAQ,IAAI,WAAW;EAClC,EAAE,OAAO,QAAQ,IAAI,SAAS;EAC9B,EAAE,MAAM,QAAQ,IAAI,QAAQ;EAC5B,EAAE,OAAO,QAAQ,IAAI,SAAS;EAC9B,EAAE,QAAQ,QAAQ,IAAI,UAAU;EAChC,EAAE,OAAO,QAAQ,YAAY,IAAI,SAAS,cAAc;EACxD,EAAE,WAAW,QAAQ,IAAI,gBAAgB;EACzC,UAAU,EAAE,OAAO,OAAO,IAAI,SAAA,SAAsB;EACpD,UAAU,EAAE,QAAQ,OAAO,IAAI,UAAU,IAAI,SAAA,SAAsB;EACnE,KAAK,WAAW,IAAI,UAAU,WAAY,IAAI,aAAa,KAAM,EAAE;EACnE,KAAK,eAAe;CACtB;;CAGA,MAAM,GAAkB,aAA2B;EAGjD,KAAK,SAAS,QAAQ,MAAM,KAAK,EAAE,MAAM;EACzC,KAAK,SAAS,OAAO,MAAM,KAAK,EAAE,KAAK;EACvC,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,EAAE;CACnC;;;CAIA,UAAU,OAQD;EACP,KAAK,SAAS,WAAW,MAAM,KAAK,MAAM,WAAW;EACrD,KAAK,SAAS,WAAW,QAAQ,MAAM;EACvC,KAAK,SAAS,UAAU,QAAQ,MAAM;EACtC,KAAK,eAAe;CACtB;;CAGA,iBAA+B;EAC7B,IAAI,CAAC,KAAK,SAAS,KAAK,UAAU,GAAG;EACrC,MAAM,QAA+B;GAAE,GAAG,KAAK,KAAK;GAAO,QAAQ,CAAC,CAAC,KAAK;EAAO;EACjF,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,KAAK,UAAU,KAAK;EAC/C,IAAI,QAAQ,KAAK,SAAS;EAC1B,KAAK,UAAU;EACf,KAAK,UAAU,QAAQ;EACvB,KAAK,WAAW,sBACd,KAAK,KAAK,UACV,KAAK,UACL,KAAK,OACL,OACA,KAAK,UAAU,IACjB;EACA,KAAK,OAAO,WAAW,KAAK;CAC9B;CAEA,WAAmB,KAAmB;EACpC,IAAI,QAAQ,KAAK,WAAW;EAC5B,KAAK,YAAY;EACjB,IAAI,KAAK,QAAQ;GACf,KAAK,OAAO,QAAQ;GACpB,KAAK,SAAS,KAAA;GACd,KAAK,UAAU;EACjB;EACA,IAAI,CAAC,OAAO,QAAQ,KAAK,iBAAiB;EAC1C,kBACE,MACC,QAAQ;GACP,IAAI,KAAK,YAAY,KAAK,cAAc,KAAK;GAC7C,KAAK,SAAS;GACd,KAAK,UAAU;GACf,KAAK,eAAe;GACpB,KAAK,UAAU;EACjB,SACM;GACJ,KAAK,kBAAkB;EACzB,CACF;CACF;CAEA,QAAQ,GAAiB;EACvB,KAAK,SAAS,MAAM,QAAQ;CAC9B;CAEA,UAAgB;EACd,KAAK,WAAW;EAChB,KAAK,UAAU,QAAQ;EACvB,KAAK,QAAQ,QAAQ;EACrB,KAAK,OAAO,SAAS,QAAQ;CAC/B;AACF"}
@@ -0,0 +1,17 @@
1
+ //#region src/renderer/tilt.d.ts
2
+ /**
3
+ * Where the sensor stands. Nothing here obliges an app to act: tilt that never becomes `live` is a
4
+ * scene that reads 0.5 on both axes, which is the same scene as one that never mentioned tilt.
5
+ * - `unsupported` — no DeviceOrientationEvent at all (desktop browsers).
6
+ * - `prompt` — the platform HAS a sensor but gates it behind a permission dialog (iOS), and this
7
+ * source will not open one. Tilt stays inert unless the page explicitly calls
8
+ * {@link TiltSource.enable} from a gesture. Reading this as "show a permission button" is a
9
+ * choice, not a requirement, and usually the wrong one.
10
+ * - `denied` — the reader refused; asking again in the same page load does nothing.
11
+ * - `listening` — attached, still waiting for the first reading.
12
+ * - `live` — readings are arriving and the sources are moving.
13
+ */
14
+ type TiltStatus = "unsupported" | "prompt" | "denied" | "listening" | "live";
15
+ //#endregion
16
+ export { TiltStatus };
17
+ //# sourceMappingURL=tilt.d.ts.map
@@ -0,0 +1,124 @@
1
+ import { clamp01 } from "../util/math.js";
2
+ //#region src/renderer/tilt.ts
3
+ const DEFAULT_RANGE = 25;
4
+ const DEG2RAD = Math.PI / 180;
5
+ /** Shortest-way-round delta for an angle that wraps at ±180 (`beta`), so passing the wrap point
6
+ * reads as a small move instead of a 360° jump. `gamma` spans only ±90 and mirrors past vertical,
7
+ * which the range clamp already absorbs — it is deliberately left alone. */
8
+ function wrap180(deg) {
9
+ return deg - 360 * Math.round(deg / 360);
10
+ }
11
+ /** How far the page is rotated from the device's natural orientation, in degrees. */
12
+ function screenAngle() {
13
+ const angle = window.screen?.orientation?.angle;
14
+ return typeof angle === "number" ? angle : 0;
15
+ }
16
+ /**
17
+ * The device-orientation reading behind the `tiltX` / `tiltY` sources: normalized 0..1 with 0.5 at
18
+ * the neutral pose. Constructed by {@link InteractionController} only when the scene carries an
19
+ * `interaction.tilt` block, so a scene without one attaches no listener and touches no sensor.
20
+ */
21
+ var TiltSource = class TiltSource {
22
+ cfg;
23
+ /** Normalized 0..1, 0.5 = the neutral pose. Read every frame; the controller smooths them. */
24
+ x = .5;
25
+ y = .5;
26
+ baseBeta = null;
27
+ baseGamma = null;
28
+ attached = false;
29
+ denied = false;
30
+ reading = false;
31
+ constructor(cfg) {
32
+ this.cfg = cfg;
33
+ if (TiltSource.supported() && !TiltSource.needsPermission()) this.attach();
34
+ }
35
+ static supported() {
36
+ return typeof window !== "undefined" && typeof window.DeviceOrientationEvent !== "undefined";
37
+ }
38
+ /** True where the sensor needs {@link enable} called from a user gesture (iOS 13+). */
39
+ static needsPermission() {
40
+ if (!TiltSource.supported()) return false;
41
+ return typeof window.DeviceOrientationEvent.requestPermission === "function";
42
+ }
43
+ get status() {
44
+ if (!TiltSource.supported()) return "unsupported";
45
+ if (this.denied) return "denied";
46
+ if (!this.attached) return "prompt";
47
+ return this.reading ? "live" : "listening";
48
+ }
49
+ /** True once a real reading has landed — the point from which the values mean anything. */
50
+ get live() {
51
+ return this.reading;
52
+ }
53
+ /**
54
+ * Start listening, asking the platform's permission first where that is required. MUST be called
55
+ * from inside a user gesture on iOS. Resolves true when the sensor is (or already was) attached.
56
+ *
57
+ * A rejected request is NOT treated as a refusal: iOS throws when the call didn't come from a
58
+ * gesture, and latching that as `denied` would kill a button that is merely wired up wrong.
59
+ * Only an explicit non-"granted" answer sticks.
60
+ *
61
+ * Calling this at all is a decision. On iOS it opens a modal permission dialog, so it belongs to
62
+ * pages where tilt is the point — an interactive piece a reader came to play with — not to a
63
+ * decorative background, which should simply not have tilt on that platform.
64
+ */
65
+ async enable() {
66
+ if (!TiltSource.supported() || this.denied) return false;
67
+ if (this.attached) return true;
68
+ const ctor = window.DeviceOrientationEvent;
69
+ if (typeof ctor.requestPermission === "function") try {
70
+ if (await ctor.requestPermission() !== "granted") {
71
+ this.denied = true;
72
+ return false;
73
+ }
74
+ } catch {
75
+ return false;
76
+ }
77
+ this.attach();
78
+ return true;
79
+ }
80
+ /** Take the next reading as the new neutral pose. For when the reader has visibly changed grip —
81
+ * stood up, put the phone down flat — and the old centre no longer matches how they're holding it. */
82
+ recenter() {
83
+ this.baseBeta = null;
84
+ this.baseGamma = null;
85
+ }
86
+ attach() {
87
+ if (this.attached) return;
88
+ this.attached = true;
89
+ window.addEventListener("deviceorientation", this.onOrientation, { passive: true });
90
+ }
91
+ onOrientation = (e) => {
92
+ const { beta, gamma } = e;
93
+ if (beta === null || gamma === null || !Number.isFinite(beta) || !Number.isFinite(gamma)) return;
94
+ if (this.baseBeta === null || this.baseGamma === null) {
95
+ this.baseBeta = beta;
96
+ this.baseGamma = gamma;
97
+ }
98
+ const dGamma = gamma - this.baseGamma;
99
+ const dBeta = wrap180(beta - this.baseBeta);
100
+ const a = -screenAngle() * DEG2RAD;
101
+ const cos = Math.cos(a);
102
+ const sin = Math.sin(a);
103
+ const sx = dGamma * cos - dBeta * sin;
104
+ const sy = dGamma * sin + dBeta * cos;
105
+ const cfg = this.cfg();
106
+ const range = Math.max(cfg?.range ?? DEFAULT_RANGE, 1);
107
+ const nx = clamp01(.5 + sx / (2 * range));
108
+ const ny = clamp01(.5 + sy / (2 * range));
109
+ this.x = cfg?.invertX ? 1 - nx : nx;
110
+ this.y = cfg?.invertY ? 1 - ny : ny;
111
+ this.reading = true;
112
+ };
113
+ dispose() {
114
+ if (this.attached) window.removeEventListener("deviceorientation", this.onOrientation);
115
+ this.attached = false;
116
+ this.reading = false;
117
+ this.x = .5;
118
+ this.y = .5;
119
+ }
120
+ };
121
+ //#endregion
122
+ export { TiltSource };
123
+
124
+ //# sourceMappingURL=tilt.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tilt.js","names":[],"sources":["../../src/renderer/tilt.ts"],"sourcesContent":["// Device tilt as an interaction input: the phone's own orientation, normalized into the same 0..1\n// shape the pointer sources already speak, so `tiltX` / `tiltY` drop into a binding anywhere\n// `pointerX` / `pointerY` would. Lives beside interaction.ts in renderer/ and imports only\n// ../config/model + ../util/math (depcruise keeps this layer below shell/studio).\n//\n// Raw `deviceorientation` is unusable as a binding source for three reasons, and this module is\n// those three fixes. Everything else — smoothing, per-binding response — belongs to the\n// InteractionController, which already owns all input smoothing; this only produces the reading.\n//\n// 1. NOBODY HOLDS A PHONE FLAT. `beta` rests around 40-60° in a normal grip, not 0, so a raw angle\n// would peg every binding at one end before the user moved at all. The first accepted reading\n// becomes the neutral pose and every later one is a delta from it (\"however you were already\n// holding it is centre\"); recenter() re-arms that when the user settles into a new grip.\n// 2. THE AXES TURN WITH THE SCREEN. `beta` / `gamma` are DEVICE axes, so in landscape they have\n// swapped roles relative to what the reader sees. Each reading is rotated by the screen angle,\n// which is what keeps `tiltX` meaning \"toward the right edge of the page\" in every orientation.\n// 3. iOS DEGRADES SILENTLY. Safari 13+ gates the sensor behind a modal permission dialog that only\n// DeviceOrientationEvent.requestPermission(), called from a user gesture, can open. This source\n// never opens it on its own: on that platform it stays dormant at its rest value and the page\n// renders exactly as it would with no tilt at all. A decorative effect is not worth interrupting\n// a reader with a permission dialog, and a prompt nobody asked for is worse than an effect\n// nobody notices is missing. enable() is the explicit opt-in for a page that has decided the\n// trade is worth it. Everywhere else there is no gate and tilt simply attaches.\nimport type { TiltConfig } from \"../config/model\";\nimport { clamp01 } from \"../util/math\";\n\nconst DEFAULT_RANGE = 25; // degrees away from neutral that reach the 0 / 1 ends\nconst DEG2RAD = Math.PI / 180;\n\n/** iOS 13+ adds a static `requestPermission` to the constructor; the DOM lib doesn't type it. */\ninterface PermissionGatedEvent {\n requestPermission?: () => Promise<\"granted\" | \"denied\" | \"default\">;\n}\n\n/**\n * Where the sensor stands. Nothing here obliges an app to act: tilt that never becomes `live` is a\n * scene that reads 0.5 on both axes, which is the same scene as one that never mentioned tilt.\n * - `unsupported` — no DeviceOrientationEvent at all (desktop browsers).\n * - `prompt` — the platform HAS a sensor but gates it behind a permission dialog (iOS), and this\n * source will not open one. Tilt stays inert unless the page explicitly calls\n * {@link TiltSource.enable} from a gesture. Reading this as \"show a permission button\" is a\n * choice, not a requirement, and usually the wrong one.\n * - `denied` — the reader refused; asking again in the same page load does nothing.\n * - `listening` — attached, still waiting for the first reading.\n * - `live` — readings are arriving and the sources are moving.\n */\nexport type TiltStatus = \"unsupported\" | \"prompt\" | \"denied\" | \"listening\" | \"live\";\n\n/** Shortest-way-round delta for an angle that wraps at ±180 (`beta`), so passing the wrap point\n * reads as a small move instead of a 360° jump. `gamma` spans only ±90 and mirrors past vertical,\n * which the range clamp already absorbs — it is deliberately left alone. */\nfunction wrap180(deg: number): number {\n return deg - 360 * Math.round(deg / 360);\n}\n\n/** How far the page is rotated from the device's natural orientation, in degrees. */\nfunction screenAngle(): number {\n const angle = window.screen?.orientation?.angle;\n return typeof angle === \"number\" ? angle : 0;\n}\n\n/**\n * The device-orientation reading behind the `tiltX` / `tiltY` sources: normalized 0..1 with 0.5 at\n * the neutral pose. Constructed by {@link InteractionController} only when the scene carries an\n * `interaction.tilt` block, so a scene without one attaches no listener and touches no sensor.\n */\nexport class TiltSource {\n /** Normalized 0..1, 0.5 = the neutral pose. Read every frame; the controller smooths them. */\n x = 0.5;\n y = 0.5;\n\n private baseBeta: number | null = null;\n private baseGamma: number | null = null;\n private attached = false;\n private denied = false;\n private reading = false;\n\n constructor(private readonly cfg: () => TiltConfig | undefined) {\n // Where no gesture is required (Android/Chrome), tilt behaves like every other input and is\n // simply live; only the gated platforms wait for enable().\n if (TiltSource.supported() && !TiltSource.needsPermission()) this.attach();\n }\n\n static supported(): boolean {\n return typeof window !== \"undefined\" && typeof window.DeviceOrientationEvent !== \"undefined\";\n }\n\n /** True where the sensor needs {@link enable} called from a user gesture (iOS 13+). */\n static needsPermission(): boolean {\n if (!TiltSource.supported()) return false;\n const ctor = window.DeviceOrientationEvent as unknown as PermissionGatedEvent;\n return typeof ctor.requestPermission === \"function\";\n }\n\n get status(): TiltStatus {\n if (!TiltSource.supported()) return \"unsupported\";\n if (this.denied) return \"denied\";\n if (!this.attached) return \"prompt\";\n return this.reading ? \"live\" : \"listening\";\n }\n\n /** True once a real reading has landed — the point from which the values mean anything. */\n get live(): boolean {\n return this.reading;\n }\n\n /**\n * Start listening, asking the platform's permission first where that is required. MUST be called\n * from inside a user gesture on iOS. Resolves true when the sensor is (or already was) attached.\n *\n * A rejected request is NOT treated as a refusal: iOS throws when the call didn't come from a\n * gesture, and latching that as `denied` would kill a button that is merely wired up wrong.\n * Only an explicit non-\"granted\" answer sticks.\n *\n * Calling this at all is a decision. On iOS it opens a modal permission dialog, so it belongs to\n * pages where tilt is the point — an interactive piece a reader came to play with — not to a\n * decorative background, which should simply not have tilt on that platform.\n */\n async enable(): Promise<boolean> {\n if (!TiltSource.supported() || this.denied) return false;\n if (this.attached) return true;\n const ctor = window.DeviceOrientationEvent as unknown as PermissionGatedEvent;\n if (typeof ctor.requestPermission === \"function\") {\n try {\n if ((await ctor.requestPermission()) !== \"granted\") {\n this.denied = true;\n return false;\n }\n } catch {\n return false; // not from a gesture (or no sensor) — retryable, so leave the status at \"prompt\"\n }\n }\n this.attach();\n return true;\n }\n\n /** Take the next reading as the new neutral pose. For when the reader has visibly changed grip —\n * stood up, put the phone down flat — and the old centre no longer matches how they're holding it. */\n recenter(): void {\n this.baseBeta = null;\n this.baseGamma = null;\n }\n\n private attach(): void {\n if (this.attached) return;\n this.attached = true;\n window.addEventListener(\"deviceorientation\", this.onOrientation, { passive: true });\n }\n\n private readonly onOrientation = (e: DeviceOrientationEvent): void => {\n const { beta, gamma } = e;\n // Some browsers fire the event with null angles before the sensor has a fix (and headless\n // environments fire nulls forever): those carry no orientation, so they are not a reading.\n if (beta === null || gamma === null || !Number.isFinite(beta) || !Number.isFinite(gamma))\n return;\n if (this.baseBeta === null || this.baseGamma === null) {\n this.baseBeta = beta;\n this.baseGamma = gamma;\n }\n const dGamma = gamma - this.baseGamma;\n const dBeta = wrap180(beta - this.baseBeta);\n\n // Device axes → screen axes. The page is rotated `screenAngle()` from the device, so rotating\n // the tilt vector by MINUS that angle lands it in what the reader is actually looking at.\n const a = -screenAngle() * DEG2RAD;\n const cos = Math.cos(a);\n const sin = Math.sin(a);\n const sx = dGamma * cos - dBeta * sin;\n const sy = dGamma * sin + dBeta * cos;\n\n const cfg = this.cfg();\n const range = Math.max(cfg?.range ?? DEFAULT_RANGE, 1);\n const nx = clamp01(0.5 + sx / (2 * range));\n const ny = clamp01(0.5 + sy / (2 * range));\n this.x = cfg?.invertX ? 1 - nx : nx;\n this.y = cfg?.invertY ? 1 - ny : ny;\n this.reading = true;\n };\n\n dispose(): void {\n if (this.attached) window.removeEventListener(\"deviceorientation\", this.onOrientation);\n this.attached = false;\n this.reading = false;\n this.x = 0.5;\n this.y = 0.5;\n }\n}\n"],"mappings":";;AA0BA,MAAM,gBAAgB;AACtB,MAAM,UAAU,KAAK,KAAK;;;;AAwB1B,SAAS,QAAQ,KAAqB;CACpC,OAAO,MAAM,MAAM,KAAK,MAAM,MAAM,GAAG;AACzC;;AAGA,SAAS,cAAsB;CAC7B,MAAM,QAAQ,OAAO,QAAQ,aAAa;CAC1C,OAAO,OAAO,UAAU,WAAW,QAAQ;AAC7C;;;;;;AAOA,IAAa,aAAb,MAAa,WAAW;CAWO;;CAT7B,IAAI;CACJ,IAAI;CAEJ,WAAkC;CAClC,YAAmC;CACnC,WAAmB;CACnB,SAAiB;CACjB,UAAkB;CAElB,YAAY,KAAoD;EAAnC,KAAA,MAAA;EAG3B,IAAI,WAAW,UAAU,KAAK,CAAC,WAAW,gBAAgB,GAAG,KAAK,OAAO;CAC3E;CAEA,OAAO,YAAqB;EAC1B,OAAO,OAAO,WAAW,eAAe,OAAO,OAAO,2BAA2B;CACnF;;CAGA,OAAO,kBAA2B;EAChC,IAAI,CAAC,WAAW,UAAU,GAAG,OAAO;EAEpC,OAAO,OADM,OAAO,uBACD,sBAAsB;CAC3C;CAEA,IAAI,SAAqB;EACvB,IAAI,CAAC,WAAW,UAAU,GAAG,OAAO;EACpC,IAAI,KAAK,QAAQ,OAAO;EACxB,IAAI,CAAC,KAAK,UAAU,OAAO;EAC3B,OAAO,KAAK,UAAU,SAAS;CACjC;;CAGA,IAAI,OAAgB;EAClB,OAAO,KAAK;CACd;;;;;;;;;;;;;CAcA,MAAM,SAA2B;EAC/B,IAAI,CAAC,WAAW,UAAU,KAAK,KAAK,QAAQ,OAAO;EACnD,IAAI,KAAK,UAAU,OAAO;EAC1B,MAAM,OAAO,OAAO;EACpB,IAAI,OAAO,KAAK,sBAAsB,YACpC,IAAI;GACF,IAAK,MAAM,KAAK,kBAAkB,MAAO,WAAW;IAClD,KAAK,SAAS;IACd,OAAO;GACT;EACF,QAAQ;GACN,OAAO;EACT;EAEF,KAAK,OAAO;EACZ,OAAO;CACT;;;CAIA,WAAiB;EACf,KAAK,WAAW;EAChB,KAAK,YAAY;CACnB;CAEA,SAAuB;EACrB,IAAI,KAAK,UAAU;EACnB,KAAK,WAAW;EAChB,OAAO,iBAAiB,qBAAqB,KAAK,eAAe,EAAE,SAAS,KAAK,CAAC;CACpF;CAEA,iBAAkC,MAAoC;EACpE,MAAM,EAAE,MAAM,UAAU;EAGxB,IAAI,SAAS,QAAQ,UAAU,QAAQ,CAAC,OAAO,SAAS,IAAI,KAAK,CAAC,OAAO,SAAS,KAAK,GACrF;EACF,IAAI,KAAK,aAAa,QAAQ,KAAK,cAAc,MAAM;GACrD,KAAK,WAAW;GAChB,KAAK,YAAY;EACnB;EACA,MAAM,SAAS,QAAQ,KAAK;EAC5B,MAAM,QAAQ,QAAQ,OAAO,KAAK,QAAQ;EAI1C,MAAM,IAAI,CAAC,YAAY,IAAI;EAC3B,MAAM,MAAM,KAAK,IAAI,CAAC;EACtB,MAAM,MAAM,KAAK,IAAI,CAAC;EACtB,MAAM,KAAK,SAAS,MAAM,QAAQ;EAClC,MAAM,KAAK,SAAS,MAAM,QAAQ;EAElC,MAAM,MAAM,KAAK,IAAI;EACrB,MAAM,QAAQ,KAAK,IAAI,KAAK,SAAS,eAAe,CAAC;EACrD,MAAM,KAAK,QAAQ,KAAM,MAAM,IAAI,MAAM;EACzC,MAAM,KAAK,QAAQ,KAAM,MAAM,IAAI,MAAM;EACzC,KAAK,IAAI,KAAK,UAAU,IAAI,KAAK;EACjC,KAAK,IAAI,KAAK,UAAU,IAAI,KAAK;EACjC,KAAK,UAAU;CACjB;CAEA,UAAgB;EACd,IAAI,KAAK,UAAU,OAAO,oBAAoB,qBAAqB,KAAK,aAAa;EACrF,KAAK,WAAW;EAChB,KAAK,UAAU;EACf,KAAK,IAAI;EACT,KAAK,IAAI;CACX;AACF"}
@@ -0,0 +1,129 @@
1
+ import "../../config/model.js";
2
+ import { simplexNoise } from "./noise.js";
3
+ import { Break, Fn, If, Loop, atan, clamp, cos, cross, dot, float, fract, length, max, mix, pow, radians, select, sin, vec2, vec3 } from "three/tsl";
4
+ //#region src/renderer/tsl/color.ts
5
+ /**
6
+ * Colour: the palette/gradient sampler and the colour grade, in TSL.
7
+ *
8
+ * The port of the `colorFns` GLSL chunk in `../shaders.ts`, shared by both fragment themes exactly
9
+ * as it is there. Uniform-count loops become `Loop` + `Break` rather than JS-level unrolling,
10
+ * because the counts (`uColorCount`, `uMeshPointCount`) are live uniforms the studio changes without
11
+ * rebuilding the material — unrolling them would force a graph rebuild on every stop added.
12
+ */
13
+ const PI = Math.PI;
14
+ /** `(v - 0.5) * a + 0.5` */
15
+ const contrastFn = (v, a) => v.sub(.5).mul(a).add(.5);
16
+ /** Pull `color` toward its luminance by `factor`. */
17
+ const desaturate = (color, factor) => mix(color, vec3(dot(vec3(.299, .587, .114), color)), factor);
18
+ /** Rotate hue by `shift` RADIANS about the grey axis (the GLSL takes radians here too). */
19
+ const hueShift = (color, shift) => {
20
+ const g = vec3(.57735);
21
+ const proj = g.mul(dot(g, color));
22
+ const u = color.sub(proj);
23
+ const w = cross(g, u);
24
+ return u.mul(cos(shift)).add(w.mul(sin(shift))).add(proj);
25
+ };
26
+ /**
27
+ * Interpolate the gradient stops by their positions (`uColorPos` sorted ascending).
28
+ *
29
+ * Faithful to the GLSL, which does NOT break once it finds the bracketing stop — it keeps
30
+ * overwriting for every qualifying `i`, so the LAST qualifying stop wins. That is what makes
31
+ * out-of-order or coincident stops behave the way the studio's presets expect.
32
+ */
33
+ function grad(u, t) {
34
+ const uu = clamp(t, 0, 1).toVar();
35
+ const col = u.uColors.el(0).toVar("gradCol");
36
+ Loop({
37
+ start: 0,
38
+ end: 7,
39
+ type: "int"
40
+ }, ({ i }) => {
41
+ If(float(i).greaterThanEqual(float(u.uColorCount).sub(1)), () => {
42
+ Break();
43
+ });
44
+ const p0 = u.uColorPos.el(i).toVar();
45
+ const p1 = u.uColorPos.el(float(i).add(1)).toVar();
46
+ If(uu.greaterThanEqual(p0), () => {
47
+ const k = clamp(uu.sub(p0).div(max(p1.sub(p0), 1e-5)), 0, 1);
48
+ col.assign(mix(u.uColors.el(i), u.uColors.el(float(i).add(1)), k));
49
+ });
50
+ });
51
+ return col;
52
+ }
53
+ /**
54
+ * iOS-style 2D colour field: each control point contributes an inverse-distance weight, and
55
+ * normalising the sum fills the whole surface without dark seams.
56
+ */
57
+ function meshGradient(u, uvIn) {
58
+ const colorSum = vec3(0).toVar("meshColorSum");
59
+ const weightSum = float(0).toVar("meshWeightSum");
60
+ const exponent = mix(4.8, 1.35, clamp(u.uMeshSoftness, 0, 1)).toVar();
61
+ Loop({
62
+ start: 0,
63
+ end: 8,
64
+ type: "int"
65
+ }, ({ i }) => {
66
+ If(float(i).greaterThanEqual(float(u.uMeshPointCount)), () => {
67
+ Break();
68
+ });
69
+ const influence = max(u.uMeshPointInfluence.el(i), .05);
70
+ const d = length(uvIn.sub(u.uMeshPointPos.el(i))).div(influence);
71
+ const weight = float(1).div(pow(max(d, .012), exponent).add(.002));
72
+ colorSum.addAssign(u.uMeshPointColor.el(i).mul(weight));
73
+ weightSum.addAssign(weight);
74
+ });
75
+ return colorSum.div(max(weightSum, 1e-4));
76
+ }
77
+ /**
78
+ * Map a surface uv to the 0–1 gradient coordinate per gradient type. `uGradShift` adds a
79
+ * low-frequency simplex warp so the colour varies in 2D — a 2D palette feel rather than flat bands.
80
+ */
81
+ function gradCoord(u, uvIn) {
82
+ const warp = u.uGradShift.mul(simplexNoise(uvIn.mul(1.6).add(4))).toVar();
83
+ const radial = clamp(length(uvIn.sub(.5)).mul(2).add(warp), 0, 1);
84
+ const conic = fract(atan(uvIn.y.sub(.5), uvIn.x.sub(.5)).div(2 * PI).add(.5).add(warp));
85
+ const dir = vec2(sin(u.uGradAngle), cos(u.uGradAngle));
86
+ const linear = clamp(dot(uvIn.sub(.5), dir).add(.5).add(warp), 0, 1);
87
+ return select(u.uGradType.equal(1), radial, select(u.uGradType.equal(2), conic, linear));
88
+ }
89
+ /**
90
+ * One base-colour sample for the whole surface: rotate/scale/offset the raw-palette uv, then pick
91
+ * the mesh field / baked 2D texture / procedural stops by mode. The raw palette is sampled by
92
+ * (uv.x, uv.y) directly; the stops-generated texture goes through `gradCoord` so its angle, type
93
+ * and warp still apply.
94
+ */
95
+ function waveBaseColor(u, uvIn) {
96
+ const gc = gradCoord(u, uvIn).toVar();
97
+ const mediaCos = cos(u.uPaletteRotation);
98
+ const mediaSin = sin(u.uPaletteRotation);
99
+ const rel = uvIn.sub(.5).toVar();
100
+ const mediaUv = vec2(mediaCos.mul(rel.x).add(mediaSin.mul(rel.y)), mediaSin.negate().mul(rel.x).add(mediaCos.mul(rel.y))).mul(u.uPaletteScale).add(.5).add(u.uPaletteOffset);
101
+ const puv = select(u.uPaletteRaw.greaterThan(.5), clamp(mediaUv, vec2(0), vec2(1)), vec2(gc, clamp(uvIn.y, 0, 1)));
102
+ const sampled = u.uPalette.sample(puv).rgb;
103
+ const proceduralOrTexture = select(u.uUsePalette.greaterThan(.5), sampled, grad(u, gc));
104
+ return select(u.uGradType.equal(3), meshGradient(u, uvIn), proceduralOrTexture);
105
+ }
106
+ /** The shared colour grade: contrast → desaturate → hue rotate (uHueShift is in DEGREES). */
107
+ function applyColorGrade(u, c) {
108
+ const contrasted = contrastFn(c, u.uContrast);
109
+ const desaturated = desaturate(contrasted, float(1).sub(u.uSaturation));
110
+ return hueShift(desaturated, radians(u.uHueShift));
111
+ }
112
+ /** Striation helpers shared with the solid theme. */
113
+ const parabola = /*@__PURE__*/ Fn(([x, k]) => pow(x.mul(4).mul(float(1).sub(x)), k)).setLayout({
114
+ name: "wave_parabola",
115
+ type: "float",
116
+ inputs: [{
117
+ name: "x",
118
+ type: "float"
119
+ }, {
120
+ name: "k",
121
+ type: "float"
122
+ }]
123
+ });
124
+ /** `mapLinear(v, a, b, c, d)` — the GLSL helper of the same name. */
125
+ const mapLinear = (v, a, b, c, d) => float(c).add(v.sub(a).mul((d - c) / (b - a)));
126
+ //#endregion
127
+ export { applyColorGrade, hueShift, mapLinear, parabola, waveBaseColor };
128
+
129
+ //# sourceMappingURL=color.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"color.js","names":[],"sources":["../../../src/renderer/tsl/color.ts"],"sourcesContent":["/**\n * Colour: the palette/gradient sampler and the colour grade, in TSL.\n *\n * The port of the `colorFns` GLSL chunk in `../shaders.ts`, shared by both fragment themes exactly\n * as it is there. Uniform-count loops become `Loop` + `Break` rather than JS-level unrolling,\n * because the counts (`uColorCount`, `uMeshPointCount`) are live uniforms the studio changes without\n * rebuilding the material — unrolling them would force a graph rebuild on every stop added.\n */\nimport {\n Fn,\n Loop,\n If,\n Break,\n float,\n vec2,\n vec3,\n cos,\n sin,\n atan,\n dot,\n cross,\n mix,\n max,\n clamp,\n fract,\n length,\n pow,\n radians,\n select,\n} from \"three/tsl\";\nimport { simplexNoise } from \"./noise\";\nimport { MAX_COLORS, MAX_MESH_POINTS } from \"../../config/model\";\nimport type { FloatNode, Vec2Node, Vec3Node } from \"./types\";\nimport type { WaveTslUniforms } from \"./uniforms\";\n\n// The GLSL writes the literal 3.14159265359; it and Math.PI round to the same float32, so the\n// shader sees an identical value either way.\nconst PI = Math.PI;\n\n/** `(v - 0.5) * a + 0.5` */\nconst contrastFn = (v: Vec3Node, a: FloatNode): Vec3Node => v.sub(0.5).mul(a).add(0.5);\n\n/** Pull `color` toward its luminance by `factor`. */\nconst desaturate = (color: Vec3Node, factor: FloatNode): Vec3Node =>\n mix(color, vec3(dot(vec3(0.299, 0.587, 0.114), color)), factor);\n\n/** Rotate hue by `shift` RADIANS about the grey axis (the GLSL takes radians here too). */\nexport const hueShift = (color: Vec3Node, shift: FloatNode): Vec3Node => {\n const g = vec3(0.57735);\n const proj = g.mul(dot(g, color));\n const u = color.sub(proj);\n const w = cross(g, u);\n return u\n .mul(cos(shift))\n .add(w.mul(sin(shift)))\n .add(proj);\n};\n\n/**\n * Interpolate the gradient stops by their positions (`uColorPos` sorted ascending).\n *\n * Faithful to the GLSL, which does NOT break once it finds the bracketing stop — it keeps\n * overwriting for every qualifying `i`, so the LAST qualifying stop wins. That is what makes\n * out-of-order or coincident stops behave the way the studio's presets expect.\n */\nfunction grad(u: WaveTslUniforms, t: FloatNode): Vec3Node {\n const uu = clamp(t, 0, 1).toVar();\n const col = u.uColors.el(0).toVar(\"gradCol\");\n Loop({ start: 0, end: MAX_COLORS - 1, type: \"int\" }, ({ i }) => {\n If(float(i).greaterThanEqual(float(u.uColorCount).sub(1)), () => {\n Break();\n });\n const p0 = u.uColorPos.el(i).toVar();\n const p1 = u.uColorPos.el(float(i).add(1)).toVar();\n If(uu.greaterThanEqual(p0), () => {\n const k = clamp(uu.sub(p0).div(max(p1.sub(p0), 1e-5)), 0, 1);\n col.assign(mix(u.uColors.el(i), u.uColors.el(float(i).add(1)), k));\n });\n });\n return col;\n}\n\n/**\n * iOS-style 2D colour field: each control point contributes an inverse-distance weight, and\n * normalising the sum fills the whole surface without dark seams.\n */\nfunction meshGradient(u: WaveTslUniforms, uvIn: Vec2Node): Vec3Node {\n const colorSum = vec3(0).toVar(\"meshColorSum\");\n const weightSum = float(0).toVar(\"meshWeightSum\");\n const exponent = mix(4.8, 1.35, clamp(u.uMeshSoftness, 0, 1)).toVar();\n Loop({ start: 0, end: MAX_MESH_POINTS, type: \"int\" }, ({ i }) => {\n If(float(i).greaterThanEqual(float(u.uMeshPointCount)), () => {\n Break();\n });\n const influence = max(u.uMeshPointInfluence.el(i), 0.05);\n const d = length(uvIn.sub(u.uMeshPointPos.el(i))).div(influence);\n const weight = float(1).div(pow(max(d, 0.012), exponent).add(0.002));\n colorSum.addAssign(u.uMeshPointColor.el(i).mul(weight));\n weightSum.addAssign(weight);\n });\n return colorSum.div(max(weightSum, 0.0001));\n}\n\n/**\n * Map a surface uv to the 0–1 gradient coordinate per gradient type. `uGradShift` adds a\n * low-frequency simplex warp so the colour varies in 2D — a 2D palette feel rather than flat bands.\n */\nfunction gradCoord(u: WaveTslUniforms, uvIn: Vec2Node): FloatNode {\n const warp = u.uGradShift.mul(simplexNoise(uvIn.mul(1.6).add(4.0))).toVar();\n const radial = clamp(length(uvIn.sub(0.5)).mul(2.0).add(warp), 0, 1);\n const conic = fract(\n atan(uvIn.y.sub(0.5), uvIn.x.sub(0.5))\n .div(2.0 * PI)\n .add(0.5)\n .add(warp),\n );\n const dir = vec2(sin(u.uGradAngle), cos(u.uGradAngle));\n const linear = clamp(dot(uvIn.sub(0.5), dir).add(0.5).add(warp), 0, 1);\n return select(u.uGradType.equal(1), radial, select(u.uGradType.equal(2), conic, linear));\n}\n\n/**\n * One base-colour sample for the whole surface: rotate/scale/offset the raw-palette uv, then pick\n * the mesh field / baked 2D texture / procedural stops by mode. The raw palette is sampled by\n * (uv.x, uv.y) directly; the stops-generated texture goes through `gradCoord` so its angle, type\n * and warp still apply.\n */\nexport function waveBaseColor(u: WaveTslUniforms, uvIn: Vec2Node): Vec3Node {\n const gc = gradCoord(u, uvIn).toVar();\n const mediaCos = cos(u.uPaletteRotation);\n const mediaSin = sin(u.uPaletteRotation);\n const rel = uvIn.sub(0.5).toVar();\n const rotated = vec2(\n mediaCos.mul(rel.x).add(mediaSin.mul(rel.y)),\n mediaSin.negate().mul(rel.x).add(mediaCos.mul(rel.y)),\n );\n const mediaUv = rotated.mul(u.uPaletteScale).add(0.5).add(u.uPaletteOffset);\n const puv = select(\n u.uPaletteRaw.greaterThan(0.5),\n clamp(mediaUv, vec2(0), vec2(1)),\n vec2(gc, clamp(uvIn.y, 0, 1)),\n );\n const sampled = u.uPalette.sample(puv).rgb;\n const proceduralOrTexture = select(u.uUsePalette.greaterThan(0.5), sampled, grad(u, gc));\n return select(u.uGradType.equal(3), meshGradient(u, uvIn), proceduralOrTexture);\n}\n\n/** The shared colour grade: contrast → desaturate → hue rotate (uHueShift is in DEGREES). */\nexport function applyColorGrade(u: WaveTslUniforms, c: Vec3Node): Vec3Node {\n const contrasted = contrastFn(c, u.uContrast);\n const desaturated = desaturate(contrasted, float(1).sub(u.uSaturation));\n return hueShift(desaturated, radians(u.uHueShift));\n}\n\n/** Striation helpers shared with the solid theme. */\nexport const parabola = /*@__PURE__*/ Fn(([x, k]: [FloatNode, FloatNode]) =>\n pow(x.mul(4.0).mul(float(1).sub(x)), k),\n).setLayout({\n name: \"wave_parabola\",\n type: \"float\",\n inputs: [\n { name: \"x\", type: \"float\" },\n { name: \"k\", type: \"float\" },\n ],\n});\n\n/** `mapLinear(v, a, b, c, d)` — the GLSL helper of the same name. */\nexport const mapLinear = (v: FloatNode, a: number, b: number, c: number, d: number): FloatNode =>\n float(c).add(v.sub(a).mul((d - c) / (b - a)));\n"],"mappings":"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"}
@@ -0,0 +1,83 @@
1
+ import { asFloat, asUVec2 } from "./types.js";
2
+ import { Fn, cos, dot, float, floatBitsToUint, floor, max, sin, step, uint, uintBitsToFloat, vec2, vec3 } from "three/tsl";
3
+ //#region src/renderer/tsl/noise.ts
4
+ /**
5
+ * The simplex noise the wave is built from, in TSL.
6
+ *
7
+ * A direct port of the `simplex2d` GLSL chunk in `../shaders.ts` — an xxHash-seeded unit-vector
8
+ * gradient feeding a Gustavson simplex. Keep the two in lockstep: every wave's silhouette comes out
9
+ * of this function, so a divergence here shows up as a different shape on every preset, not as a
10
+ * subtle shading difference. `parity/` is what catches that.
11
+ *
12
+ * The hash needs integer bit ops on the float's raw bits. Those exist in TSL as `floatBitsToUint` /
13
+ * `uintBitsToFloat` and compile to both WGSL (`bitcast<u32>`) and GLSL ES 3.00 (`floatBitsToUint`),
14
+ * so the same graph serves the WebGPU and WebGL backends.
15
+ */
16
+ /** xxHash32 over the two float lanes, returned as a float in [0,1). */
17
+ const xxhash = /*@__PURE__*/ Fn(([x]) => {
18
+ const t = asUVec2(floatBitsToUint(x)).toVar();
19
+ const h = uint(3266489917).mul(t.x).add(uint(374761401)).toVar();
20
+ h.assign(h.shiftLeft(uint(17)).bitOr(h.shiftRight(uint(15))).mul(uint(668265263)));
21
+ h.addAssign(uint(3266489917).mul(t.y));
22
+ h.assign(h.shiftLeft(uint(17)).bitOr(h.shiftRight(uint(15))).mul(uint(668265263)));
23
+ h.assign(h.bitXor(h.shiftRight(uint(15))));
24
+ h.mulAssign(uint(2246822519));
25
+ h.assign(h.bitXor(h.shiftRight(uint(13))));
26
+ h.mulAssign(uint(3266489917));
27
+ h.assign(h.bitXor(h.shiftRight(uint(16))));
28
+ return asFloat(uintBitsToFloat(h.shiftRight(uint(9)).bitOr(uint(1065353216)))).sub(1);
29
+ }).setLayout({
30
+ name: "wave_xxhash",
31
+ type: "float",
32
+ inputs: [{
33
+ name: "x",
34
+ type: "vec2"
35
+ }]
36
+ });
37
+ /** A unit vector whose angle is the hashed cell — the simplex gradient. */
38
+ const gradHash = /*@__PURE__*/ Fn(([x]) => {
39
+ const k = xxhash(x).mul(6.283185307);
40
+ return vec2(cos(k), sin(k));
41
+ }).setLayout({
42
+ name: "wave_gradHash",
43
+ type: "vec2",
44
+ inputs: [{
45
+ name: "x",
46
+ type: "vec2"
47
+ }]
48
+ });
49
+ const K1 = .366025404;
50
+ const K2 = .211324865;
51
+ /** 2D simplex noise, output roughly in [-1,1]. The GLSL twin is `simplexNoise` in `../shaders.ts`. */
52
+ const simplexNoise = /*@__PURE__*/ Fn(([p]) => {
53
+ const i = floor(p.add(p.x.add(p.y).mul(K1))).toVar();
54
+ const a = p.sub(i).add(i.x.add(i.y).mul(K2)).toVar();
55
+ const m = step(a.y, a.x);
56
+ const o = vec2(m, float(1).sub(m)).toVar();
57
+ const b = a.sub(o).add(K2).toVar();
58
+ const c = a.sub(1).add(2 * K2).toVar();
59
+ const h = max(float(.5).sub(vec3(dot(a, a), dot(b, b), dot(c, c))), 0).toVar();
60
+ return dot(h.mul(h).mul(h).mul(vec3(dot(a, gradHash(i)), dot(b, gradHash(i.add(o))), dot(c, gradHash(i.add(1))))), vec3(32.99));
61
+ }).setLayout({
62
+ name: "wave_simplexNoise",
63
+ type: "float",
64
+ inputs: [{
65
+ name: "p",
66
+ type: "vec2"
67
+ }]
68
+ });
69
+ /** The cheap value hash behind the optional grain overlay (`grainHash` in `../shaders.ts`). */
70
+ const grainHash = /*@__PURE__*/ Fn(([p]) => {
71
+ return sin(dot(p, vec2(12.9898, 78.233))).mul(43758.5453).fract();
72
+ }).setLayout({
73
+ name: "wave_grainHash",
74
+ type: "float",
75
+ inputs: [{
76
+ name: "p",
77
+ type: "vec2"
78
+ }]
79
+ });
80
+ //#endregion
81
+ export { grainHash, simplexNoise };
82
+
83
+ //# sourceMappingURL=noise.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"noise.js","names":[],"sources":["../../../src/renderer/tsl/noise.ts"],"sourcesContent":["/**\n * The simplex noise the wave is built from, in TSL.\n *\n * A direct port of the `simplex2d` GLSL chunk in `../shaders.ts` — an xxHash-seeded unit-vector\n * gradient feeding a Gustavson simplex. Keep the two in lockstep: every wave's silhouette comes out\n * of this function, so a divergence here shows up as a different shape on every preset, not as a\n * subtle shading difference. `parity/` is what catches that.\n *\n * The hash needs integer bit ops on the float's raw bits. Those exist in TSL as `floatBitsToUint` /\n * `uintBitsToFloat` and compile to both WGSL (`bitcast<u32>`) and GLSL ES 3.00 (`floatBitsToUint`),\n * so the same graph serves the WebGPU and WebGL backends.\n */\nimport { asFloat, asUVec2, type Vec2Node } from \"./types\";\nimport {\n Fn,\n float,\n uint,\n uintBitsToFloat,\n floatBitsToUint,\n vec2,\n vec3,\n cos,\n sin,\n floor,\n step,\n max,\n dot,\n} from \"three/tsl\";\n\n/** xxHash32 over the two float lanes, returned as a float in [0,1). */\nconst xxhash = /*@__PURE__*/ Fn(([x]: [Vec2Node]) => {\n const t = asUVec2(floatBitsToUint(x)).toVar();\n // Every step below is a u32 op; both targets wrap on overflow, which is what the hash relies on.\n const h = uint(0xc2b2ae3d).mul(t.x).add(uint(0x165667b9)).toVar();\n h.assign(\n h\n .shiftLeft(uint(17))\n .bitOr(h.shiftRight(uint(15)))\n .mul(uint(0x27d4eb2f)),\n );\n h.addAssign(uint(0xc2b2ae3d).mul(t.y));\n h.assign(\n h\n .shiftLeft(uint(17))\n .bitOr(h.shiftRight(uint(15)))\n .mul(uint(0x27d4eb2f)),\n );\n h.assign(h.bitXor(h.shiftRight(uint(15))));\n h.mulAssign(uint(0x85ebca77));\n h.assign(h.bitXor(h.shiftRight(uint(13))));\n h.mulAssign(uint(0xc2b2ae3d));\n h.assign(h.bitXor(h.shiftRight(uint(16))));\n // Take the top mantissa bits into the exponent of 1.0 → a float in [1,2), then shift to [0,1).\n return asFloat(uintBitsToFloat(h.shiftRight(uint(9)).bitOr(uint(0x3f800000)))).sub(1.0);\n}).setLayout({\n name: \"wave_xxhash\",\n type: \"float\",\n inputs: [{ name: \"x\", type: \"vec2\" }],\n});\n\n/** A unit vector whose angle is the hashed cell — the simplex gradient. */\nconst gradHash = /*@__PURE__*/ Fn(([x]: [Vec2Node]) => {\n const k = xxhash(x).mul(6.283185307);\n return vec2(cos(k), sin(k));\n}).setLayout({\n name: \"wave_gradHash\",\n type: \"vec2\",\n inputs: [{ name: \"x\", type: \"vec2\" }],\n});\n\nconst K1 = 0.366025404; // (sqrt(3)-1)/2\nconst K2 = 0.211324865; // (3-sqrt(3))/6\n\n/** 2D simplex noise, output roughly in [-1,1]. The GLSL twin is `simplexNoise` in `../shaders.ts`. */\nexport const simplexNoise = /*@__PURE__*/ Fn(([p]: [Vec2Node]) => {\n const i = floor(p.add(p.x.add(p.y).mul(K1))).toVar();\n const a = p.sub(i).add(i.x.add(i.y).mul(K2)).toVar();\n const m = step(a.y, a.x);\n const o = vec2(m, float(1.0).sub(m)).toVar();\n const b = a.sub(o).add(K2).toVar();\n const c = a\n .sub(1.0)\n .add(2.0 * K2)\n .toVar();\n const h = max(float(0.5).sub(vec3(dot(a, a), dot(b, b), dot(c, c))), 0.0).toVar();\n const n = h\n .mul(h)\n .mul(h)\n .mul(vec3(dot(a, gradHash(i)), dot(b, gradHash(i.add(o))), dot(c, gradHash(i.add(1.0)))));\n return dot(n, vec3(32.99)); // analytic normalisation (= 2916*sqrt(2)/125)\n}).setLayout({\n name: \"wave_simplexNoise\",\n type: \"float\",\n inputs: [{ name: \"p\", type: \"vec2\" }],\n});\n\n/** The cheap value hash behind the optional grain overlay (`grainHash` in `../shaders.ts`). */\nexport const grainHash = /*@__PURE__*/ Fn(([p]: [Vec2Node]) => {\n return sin(dot(p, vec2(12.9898, 78.233)))\n .mul(43758.5453)\n .fract();\n}).setLayout({\n name: \"wave_grainHash\",\n type: \"float\",\n inputs: [{ name: \"p\", type: \"vec2\" }],\n});\n"],"mappings":"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"}