@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,291 @@
1
+ import { wavePointerFxActive, waveRipplesActive } from "./interactionGates.js";
2
+ import { WaveRenderer } from "./WaveRenderer.js";
3
+ import { floatUniform, vec2Uniform } from "./tsl/types.js";
4
+ import { ParticleFieldGPU } from "./particleFieldGPU.js";
5
+ import { makeTslUniforms } from "./tsl/uniforms.js";
6
+ import { buildWaveMaterial } from "./tsl/waveMaterial.js";
7
+ import { buildPostChain } from "./tsl/postChain.js";
8
+ import * as THREE from "three";
9
+ import { RenderPipeline, WebGPUCoordinateSystem, WebGPURenderer } from "three/webgpu";
10
+ import { pass, screenUV, texture, vec2 } from "three/tsl";
11
+ //#region src/renderer/WaveRendererGPU.ts
12
+ /**
13
+ * The WebGPU / TSL backend.
14
+ *
15
+ * Overrides only the four things that are actually backend-specific — the three renderer, the
16
+ * wave material, how a shader variant is re-selected, and the post chain. Everything else (config
17
+ * sync, camera, background, resize, interaction, capture, particles) is shared, because both
18
+ * backends expose the same `uniforms` surface. The overrides are packaged as the
19
+ * {@link withTslBackend} mixin so they can also layer over the studio's editor subclass;
20
+ * {@link WaveRendererGPU} is that mixin applied to the plain {@link WaveRenderer}.
21
+ *
22
+ * `WebGPURenderer` falls back to a WebGL2 backend on its own when WebGPU is unavailable, so this
23
+ * class is not "the WebGPU-only path" — it is the TSL path, which runs on either backend. What it
24
+ * must not do is get imported from the package entry: `three/webgpu` pulls the whole node system
25
+ * (~200 KB gzipped), so this module is only ever reached through a dynamic import.
26
+ */
27
+ /** The flag set that decides a wave's node graph — the TSL twin of `waveDefines()`. */
28
+ function variantKey(f) {
29
+ return [
30
+ f.theme,
31
+ f.loopMotion && "loop",
32
+ f.detailOctave && "detail",
33
+ f.helix && "helix",
34
+ f.twistMotion && "twist",
35
+ f.radial && "radial",
36
+ f.depthTint && "depthTint",
37
+ f.edgeFeather && "edgeFeather",
38
+ f.rungs && "rungs",
39
+ f.pointerFx && "pointer",
40
+ f.pointerRipples && "ripples",
41
+ f.webgpuClipZ && "gpuz"
42
+ ].filter(Boolean).join(",");
43
+ }
44
+ /**
45
+ * The TSL backend as a mixin, so the same override set can sit on either base: over
46
+ * {@link WaveRenderer} for embeds (the {@link WaveRendererGPU} export below), or over the studio's
47
+ * editor subclass (`studio/StudioWaveRendererGPU.ts`). That composition works because the two
48
+ * override DISJOINT hook sets — the editor overrides the camera/overlay hooks, this backend the
49
+ * renderer/material/post hooks — so layering order doesn't matter.
50
+ *
51
+ * `Base` is constrained to (and internally cast as) `typeof WaveRenderer` because TypeScript
52
+ * forbids protected-member access from a class extending a bare type parameter. The cast is sound
53
+ * for any WaveRenderer subclass, and returning `TBase` hands the composed class back with its real
54
+ * base's type (the editor API survives on the studio composition).
55
+ */
56
+ function withTslBackend(Base) {
57
+ class WithTslBackend extends Base {
58
+ post;
59
+ /**
60
+ * Lazily built, NOT a field initialiser. Subclass field initialisers run only after `super()`
61
+ * returns, and the base constructor already renders (buildWaves → resize → renderOnce), so a
62
+ * field here would still be `undefined` at first draw.
63
+ */
64
+ postUniformsCache;
65
+ /** The effect set the current chain was built for; a change rebuilds it, as the WebGL path
66
+ * inserts and removes passes. */
67
+ postFlagsKey = "";
68
+ createRenderer() {
69
+ this.ready = false;
70
+ return new WebGPURenderer({
71
+ antialias: false,
72
+ alpha: true,
73
+ powerPreference: "high-performance"
74
+ });
75
+ }
76
+ get postUniforms() {
77
+ this.postUniformsCache ??= {
78
+ uBlurAmount: floatUniform(0),
79
+ uBlurSamples: floatUniform(6),
80
+ uGrainAmount: floatUniform(0),
81
+ uBloomStrength: floatUniform(0),
82
+ uBloomRadius: floatUniform(.4),
83
+ uBloomThreshold: floatUniform(.85),
84
+ uInnerLight: floatUniform(0),
85
+ uInnerLightDensity: floatUniform(.5),
86
+ uInnerLightDecay: floatUniform(.95),
87
+ uInnerLightCenter: vec2Uniform(.5, .15),
88
+ uHalftone: floatUniform(0),
89
+ uHalftoneCell: floatUniform(6),
90
+ uHalftoneAngle: floatUniform(.4),
91
+ uHeatmap: floatUniform(0),
92
+ uHalftoneCmyk: floatUniform(0),
93
+ uHalftoneCmykCell: floatUniform(6),
94
+ uPaper: floatUniform(0),
95
+ uPaperScale: floatUniform(2),
96
+ uDitherStrength: floatUniform(0),
97
+ uDitherScale: floatUniform(2),
98
+ uDitherSteps: floatUniform(4)
99
+ };
100
+ return this.postUniformsCache;
101
+ }
102
+ /** Start the WebGPU backend, then draw the first frame. Safe to call more than once. */
103
+ async init() {
104
+ if (this.ready) return;
105
+ await this.renderer.init();
106
+ this.ready = true;
107
+ this.resize();
108
+ this.refresh();
109
+ }
110
+ /**
111
+ * True when the live backend uses [0,1] clip Z rather than [-1,1]. Read from the renderer rather
112
+ * than assumed, because WebGPURenderer silently falls back to a WebGL2 backend — in which case
113
+ * the clip convention is the WebGL one and the depth fade must NOT be remapped.
114
+ */
115
+ get webgpuClipZ() {
116
+ return this.renderer.coordinateSystem === WebGPUCoordinateSystem;
117
+ }
118
+ flagsFor(sc) {
119
+ const bindsDetail = sc?.interaction?.bindings?.some((b) => b.target === "detailAmount") ?? false;
120
+ const bindsHelix = sc?.interaction?.bindings?.some((b) => b.target.startsWith("helix")) ?? false;
121
+ const pointer = !!sc && wavePointerFxActive(this.config, sc);
122
+ return {
123
+ theme: sc?.theme === "wireframe" ? "wireframe" : "solid",
124
+ loopMotion: (this.config.loopSeconds ?? 0) > 0,
125
+ detailOctave: (sc?.detailAmount ?? 0) !== 0 || bindsDetail,
126
+ helix: (sc?.helixRadius ?? 0) !== 0 || (sc?.helixRoll ?? 0) !== 0 || bindsHelix,
127
+ twistMotion: !!sc?.twistMotion,
128
+ radial: (sc?.radialAmount ?? 0) !== 0,
129
+ depthTint: (sc?.depthTint ?? 0) > 0,
130
+ edgeFeather: (sc?.edgeFeather ?? .1) !== .1,
131
+ rungs: sc?.theme === "wireframe" && (sc.rungAmount ?? 0) > 0,
132
+ pointerFx: pointer,
133
+ pointerRipples: pointer && waveRipplesActive(this.config, sc),
134
+ webgpuClipZ: this.webgpuClipZ
135
+ };
136
+ }
137
+ /**
138
+ * An instanced-sprite particle field wired to THIS wave's uniform registry.
139
+ *
140
+ * That wiring is the whole reason this override exists: the GLSL field has to mirror the wave's
141
+ * shape and pointer uniforms into its own material every frame, whereas here the dust reads the
142
+ * ribbon's nodes directly and cannot drift out of sync with it.
143
+ */
144
+ createParticleField(wave, sc, onReady) {
145
+ const { tsl } = wave.material.userData;
146
+ const f = this.flagsFor(sc);
147
+ return new ParticleFieldGPU({
148
+ uniforms: tsl,
149
+ flags: {
150
+ loopMotion: f.loopMotion,
151
+ detailOctave: f.detailOctave,
152
+ helix: f.helix,
153
+ twistMotion: f.twistMotion,
154
+ radial: f.radial,
155
+ pointerFx: f.pointerFx,
156
+ pointerRipples: f.pointerRipples
157
+ }
158
+ }, onReady);
159
+ }
160
+ createWaveMaterial(sc) {
161
+ const u = makeTslUniforms(this.renderer.getDrawingBufferSize(new THREE.Vector2()));
162
+ const flags = this.flagsFor(sc);
163
+ const material = buildWaveMaterial(u, flags);
164
+ material.uniforms = u;
165
+ material.userData = {
166
+ variant: variantKey(flags),
167
+ tsl: u
168
+ };
169
+ return material;
170
+ }
171
+ /**
172
+ * A TSL variant is a different GRAPH, not a different define set, so a variant change rebuilds
173
+ * the material and re-points the mesh at it. The uniform registry is carried over untouched, so
174
+ * no config state is lost and no value has to be re-synced.
175
+ */
176
+ applyWaveVariant(wave, sc) {
177
+ const current = wave.material;
178
+ const flags = this.flagsFor(sc);
179
+ const key = variantKey(flags);
180
+ if (current.userData.variant === key) return false;
181
+ const rebuilt = buildWaveMaterial(current.userData.tsl, flags);
182
+ rebuilt.uniforms = current.uniforms;
183
+ rebuilt.userData = {
184
+ variant: key,
185
+ tsl: current.userData.tsl
186
+ };
187
+ rebuilt.blending = current.blending;
188
+ rebuilt.premultipliedAlpha = current.premultipliedAlpha;
189
+ wave.mesh.material = rebuilt;
190
+ wave.material = rebuilt;
191
+ current.dispose();
192
+ return false;
193
+ }
194
+ /** Which effects the config currently asks for — the node twin of applyPost()'s pass juggling. */
195
+ postFlags() {
196
+ const c = this.config;
197
+ return {
198
+ bloom: (c.bloomStrength ?? 0) > 0,
199
+ innerLight: (c.innerLight ?? 0) > 0,
200
+ halftone: (c.halftone ?? 0) > 0,
201
+ heatmap: (c.heatmap ?? 0) > 0,
202
+ halftoneCmyk: (c.halftoneCmyk ?? 0) > 0,
203
+ paperTexture: (c.paperTexture ?? 0) > 0,
204
+ dither: (c.dither ?? 0) > 0
205
+ };
206
+ }
207
+ /** Push the config into the post uniforms. Mirrors applyPost() / the per-effect apply* methods. */
208
+ syncPostUniforms() {
209
+ const c = this.config;
210
+ const u = this.postUniforms;
211
+ u.uBlurAmount.value = c.blur;
212
+ u.uGrainAmount.value = c.grain;
213
+ u.uBlurSamples.value = Math.round(c.blurSamples ?? 6);
214
+ u.uBloomStrength.value = c.bloomStrength ?? 0;
215
+ u.uBloomRadius.value = c.bloomRadius ?? .4;
216
+ u.uBloomThreshold.value = c.bloomThreshold ?? .85;
217
+ u.uInnerLight.value = c.innerLight ?? 0;
218
+ u.uInnerLightDensity.value = c.innerLightDensity ?? .5;
219
+ u.uInnerLightDecay.value = c.innerLightDecay ?? .95;
220
+ u.uInnerLightCenter.value.set(c.innerLightX ?? .5, c.innerLightY ?? .15);
221
+ u.uHalftone.value = c.halftone ?? 0;
222
+ u.uHalftoneCell.value = Math.max(2, c.halftoneCell ?? 6);
223
+ u.uHalftoneAngle.value = c.halftoneAngle ?? .4;
224
+ u.uHeatmap.value = c.heatmap ?? 0;
225
+ u.uHalftoneCmyk.value = c.halftoneCmyk ?? 0;
226
+ u.uHalftoneCmykCell.value = Math.max(2, c.halftoneCmykCell ?? 6);
227
+ u.uPaper.value = c.paperTexture ?? 0;
228
+ u.uPaperScale.value = Math.max(.5, c.paperTextureScale ?? 2);
229
+ u.uDitherStrength.value = c.dither ?? 0;
230
+ u.uDitherScale.value = Math.max(1, c.ditherScale ?? 2);
231
+ u.uDitherSteps.value = Math.max(2, Math.round(c.ditherSteps ?? 4));
232
+ }
233
+ ensurePost() {
234
+ const flags = this.postFlags();
235
+ const key = JSON.stringify(flags);
236
+ if (this.post && key !== this.postFlagsKey) this.disposePost();
237
+ if (!this.post) {
238
+ this.postFlagsKey = key;
239
+ this.post = new RenderPipeline(this.renderer);
240
+ this.post.outputColorTransform = false;
241
+ this.post.outputNode = buildPostChain(pass(this.scene, this.camera), this.postUniforms, flags);
242
+ }
243
+ return this.post;
244
+ }
245
+ renderComposed() {
246
+ for (const wave of this.waves) wave.material.userData.tsl.packed.sync();
247
+ this.syncPostUniforms();
248
+ this.ensurePost().render();
249
+ }
250
+ resizePost(w, h, dpr) {
251
+ this.renderer.setPixelRatio(dpr);
252
+ this.renderer.setSize(w, h, false);
253
+ }
254
+ disposePost() {
255
+ this.post?.dispose();
256
+ this.post = void 0;
257
+ }
258
+ /**
259
+ * WebGPU exposes no `capabilities` object; the equivalent limit lives on the adapter's device.
260
+ * Falls back to 8192, the value every WebGPU implementation is required to support, for the
261
+ * window before `init()` resolves — the base class calls this while sizing the background.
262
+ */
263
+ maxTextureSize() {
264
+ return (this.renderer.backend?.device)?.limits?.maxTextureDimension2D ?? 8192;
265
+ }
266
+ /**
267
+ * Rebuild the post chain when the background changes.
268
+ *
269
+ * A `pass()` node captures the scene's background at the point its render context is first built.
270
+ * Because the chain is created lazily on the first draw — when `scene.background` is still null —
271
+ * a background set afterwards would never appear, rendering every non-transparent preset on a
272
+ * transparent canvas. Background changes are user-driven, not per-frame, so rebuilding here is
273
+ * cheap; rebuilding every frame (which also works) is not.
274
+ */
275
+ onBackgroundChanged() {
276
+ const bg = this.scene.background;
277
+ const isTexture = !!bg && bg.isTexture === true;
278
+ this.scene.backgroundNode = isTexture ? texture(bg).sample(vec2(screenUV.x, screenUV.y.oneMinus())) : null;
279
+ this.disposePost();
280
+ }
281
+ }
282
+ return WithTslBackend;
283
+ }
284
+ /** The TSL/WebGPU renderer over the plain {@link WaveRenderer} base — what embeds reach through
285
+ * `gpu-loader.ts`. A named class (not a bare `withTslBackend(WaveRenderer)` const) so consumers
286
+ * keep `new WaveRendererGPU(...)`, `instanceof`, and a stable class name. */
287
+ var WaveRendererGPU = class extends withTslBackend(WaveRenderer) {};
288
+ //#endregion
289
+ export { WaveRendererGPU, withTslBackend };
290
+
291
+ //# sourceMappingURL=WaveRendererGPU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WaveRendererGPU.js","names":[],"sources":["../../src/renderer/WaveRendererGPU.ts"],"sourcesContent":["/**\n * The WebGPU / TSL backend.\n *\n * Overrides only the four things that are actually backend-specific — the three renderer, the\n * wave material, how a shader variant is re-selected, and the post chain. Everything else (config\n * sync, camera, background, resize, interaction, capture, particles) is shared, because both\n * backends expose the same `uniforms` surface. The overrides are packaged as the\n * {@link withTslBackend} mixin so they can also layer over the studio's editor subclass;\n * {@link WaveRendererGPU} is that mixin applied to the plain {@link WaveRenderer}.\n *\n * `WebGPURenderer` falls back to a WebGL2 backend on its own when WebGPU is unavailable, so this\n * class is not \"the WebGPU-only path\" — it is the TSL path, which runs on either backend. What it\n * must not do is get imported from the package entry: `three/webgpu` pulls the whole node system\n * (~200 KB gzipped), so this module is only ever reached through a dynamic import.\n */\nimport * as THREE from \"three\";\nimport { WebGPURenderer, RenderPipeline, WebGPUCoordinateSystem } from \"three/webgpu\";\nimport { pass, texture, screenUV, vec2 } from \"three/tsl\";\nimport type { WaveConfig } from \"../config/model\";\nimport { WaveRenderer, type WaveMaterial, type WaveParticleField } from \"./WaveRenderer\";\nimport { ParticleFieldGPU } from \"./particleFieldGPU\";\nimport { makeTslUniforms, type WaveTslUniforms } from \"./tsl/uniforms\";\nimport { buildWaveMaterial, type WaveMaterialFlags } from \"./tsl/waveMaterial\";\nimport { wavePointerFxActive, waveRipplesActive } from \"./interactionGates\";\nimport { buildPostChain, type PostChainUniforms, type PostFlags } from \"./tsl/postChain\";\nimport { floatUniform, vec2Uniform } from \"./tsl/types\";\n\n/** The flag set that decides a wave's node graph — the TSL twin of `waveDefines()`. */\nfunction variantKey(f: WaveMaterialFlags): string {\n return [\n f.theme,\n f.loopMotion && \"loop\",\n f.detailOctave && \"detail\",\n f.helix && \"helix\",\n f.twistMotion && \"twist\",\n f.radial && \"radial\",\n f.depthTint && \"depthTint\",\n f.edgeFeather && \"edgeFeather\",\n f.rungs && \"rungs\",\n f.pointerFx && \"pointer\",\n f.pointerRipples && \"ripples\",\n f.webgpuClipZ && \"gpuz\",\n ]\n .filter(Boolean)\n .join(\",\");\n}\n\ntype TslMaterial = WaveMaterial & { userData: { variant: string; tsl: WaveTslUniforms } };\n\n/**\n * The TSL backend as a mixin, so the same override set can sit on either base: over\n * {@link WaveRenderer} for embeds (the {@link WaveRendererGPU} export below), or over the studio's\n * editor subclass (`studio/StudioWaveRendererGPU.ts`). That composition works because the two\n * override DISJOINT hook sets — the editor overrides the camera/overlay hooks, this backend the\n * renderer/material/post hooks — so layering order doesn't matter.\n *\n * `Base` is constrained to (and internally cast as) `typeof WaveRenderer` because TypeScript\n * forbids protected-member access from a class extending a bare type parameter. The cast is sound\n * for any WaveRenderer subclass, and returning `TBase` hands the composed class back with its real\n * base's type (the editor API survives on the studio composition).\n */\nexport function withTslBackend<TBase extends typeof WaveRenderer>(Base: TBase): TBase {\n class WithTslBackend extends (Base as typeof WaveRenderer) {\n declare readonly renderer: THREE.WebGLRenderer & WebGPURenderer;\n private post?: RenderPipeline;\n /**\n * Lazily built, NOT a field initialiser. Subclass field initialisers run only after `super()`\n * returns, and the base constructor already renders (buildWaves → resize → renderOnce), so a\n * field here would still be `undefined` at first draw.\n */\n private postUniformsCache?: PostChainUniforms;\n /** The effect set the current chain was built for; a change rebuilds it, as the WebGL path\n * inserts and removes passes. */\n private postFlagsKey = \"\";\n\n protected override createRenderer(): THREE.WebGLRenderer {\n // Called from the BASE constructor, which is the only hook that runs early enough to stop the\n // first render: `ready` cannot be cleared from this subclass's constructor body or a field\n // initialiser, since both run after super() has already drawn a frame.\n this.ready = false;\n // No preserveDrawingBuffer: WebGPU has no such option and does not need one — the canvas is\n // configured COPY_SRC, so toBlob()/toDataURL() readback works, which is what captureImage,\n // the poster capture and the studio thumbnails rely on.\n return new WebGPURenderer({\n // antialias: false to MATCH the WebGL path, not because MSAA is unwanted. Everything there\n // renders through EffectComposer, whose render target is created without `samples` — so the\n // WebGL canvas's `antialias: true` never applies and that path is effectively unaliased.\n // Enabling MSAA here would make WebGPU quietly render smoother edges than WebGL, which is a\n // visual change disguised as a port. It is a real upgrade to opt into later, deliberately.\n antialias: false,\n alpha: true,\n powerPreference: \"high-performance\",\n }) as unknown as THREE.WebGLRenderer;\n }\n\n private get postUniforms() {\n this.postUniformsCache ??= {\n uBlurAmount: floatUniform(0),\n uBlurSamples: floatUniform(6),\n uGrainAmount: floatUniform(0),\n uBloomStrength: floatUniform(0),\n uBloomRadius: floatUniform(0.4),\n uBloomThreshold: floatUniform(0.85),\n uInnerLight: floatUniform(0),\n uInnerLightDensity: floatUniform(0.5),\n uInnerLightDecay: floatUniform(0.95),\n uInnerLightCenter: vec2Uniform(0.5, 0.15),\n uHalftone: floatUniform(0),\n uHalftoneCell: floatUniform(6),\n uHalftoneAngle: floatUniform(0.4),\n uHeatmap: floatUniform(0),\n uHalftoneCmyk: floatUniform(0),\n uHalftoneCmykCell: floatUniform(6),\n uPaper: floatUniform(0),\n uPaperScale: floatUniform(2),\n uDitherStrength: floatUniform(0),\n uDitherScale: floatUniform(2),\n uDitherSteps: floatUniform(4),\n };\n return this.postUniformsCache;\n }\n\n /** Start the WebGPU backend, then draw the first frame. Safe to call more than once. */\n override async init(): Promise<void> {\n if (this.ready) return;\n await (this.renderer as unknown as WebGPURenderer).init();\n this.ready = true;\n // The constructor's buildWaves()/resize() ran against a not-yet-drawable backend, so their\n // trailing renderOnce() was skipped. Catch up now that the backend is live.\n this.resize();\n this.refresh();\n }\n\n /**\n * True when the live backend uses [0,1] clip Z rather than [-1,1]. Read from the renderer rather\n * than assumed, because WebGPURenderer silently falls back to a WebGL2 backend — in which case\n * the clip convention is the WebGL one and the depth fade must NOT be remapped.\n */\n private get webgpuClipZ(): boolean {\n const cs = (this.renderer as unknown as { coordinateSystem: number }).coordinateSystem;\n return cs === (WebGPUCoordinateSystem as number);\n }\n\n private flagsFor(sc: WaveConfig | undefined): WaveMaterialFlags {\n const bindsDetail =\n sc?.interaction?.bindings?.some((b) => b.target === \"detailAmount\") ?? false;\n const bindsHelix =\n sc?.interaction?.bindings?.some((b) => b.target.startsWith(\"helix\")) ?? false;\n const pointer = !!sc && wavePointerFxActive(this.config, sc);\n return {\n theme: sc?.theme === \"wireframe\" ? \"wireframe\" : \"solid\",\n loopMotion: (this.config.loopSeconds ?? 0) > 0,\n detailOctave: (sc?.detailAmount ?? 0) !== 0 || bindsDetail,\n helix: (sc?.helixRadius ?? 0) !== 0 || (sc?.helixRoll ?? 0) !== 0 || bindsHelix,\n twistMotion: !!sc?.twistMotion,\n radial: (sc?.radialAmount ?? 0) !== 0,\n depthTint: (sc?.depthTint ?? 0) > 0,\n edgeFeather: (sc?.edgeFeather ?? 0.1) !== 0.1,\n rungs: sc?.theme === \"wireframe\" && (sc.rungAmount ?? 0) > 0,\n pointerFx: pointer,\n pointerRipples: pointer && waveRipplesActive(this.config, sc as WaveConfig),\n webgpuClipZ: this.webgpuClipZ,\n };\n }\n\n /**\n * An instanced-sprite particle field wired to THIS wave's uniform registry.\n *\n * That wiring is the whole reason this override exists: the GLSL field has to mirror the wave's\n * shape and pointer uniforms into its own material every frame, whereas here the dust reads the\n * ribbon's nodes directly and cannot drift out of sync with it.\n */\n protected override createParticleField(\n wave: { material: WaveMaterial },\n sc: WaveConfig,\n onReady: () => void,\n ): WaveParticleField {\n const { tsl } = (wave.material as TslMaterial).userData;\n const f = this.flagsFor(sc);\n return new ParticleFieldGPU(\n {\n uniforms: tsl,\n flags: {\n loopMotion: f.loopMotion,\n detailOctave: f.detailOctave,\n helix: f.helix,\n twistMotion: f.twistMotion,\n radial: f.radial,\n pointerFx: f.pointerFx,\n pointerRipples: f.pointerRipples,\n },\n },\n onReady,\n );\n }\n\n protected override createWaveMaterial(sc: WaveConfig | undefined): WaveMaterial {\n const u = makeTslUniforms(this.renderer.getDrawingBufferSize(new THREE.Vector2()));\n const flags = this.flagsFor(sc);\n const material = buildWaveMaterial(u, flags) as unknown as TslMaterial;\n // The shared config-sync path reaches uniforms through `material.uniforms`; the node registry\n // is that same surface, so refresh() needs no backend branch.\n material.uniforms = u as unknown as WaveMaterial[\"uniforms\"];\n material.userData = { variant: variantKey(flags), tsl: u };\n return material;\n }\n\n /**\n * A TSL variant is a different GRAPH, not a different define set, so a variant change rebuilds\n * the material and re-points the mesh at it. The uniform registry is carried over untouched, so\n * no config state is lost and no value has to be re-synced.\n */\n protected override applyWaveVariant(\n wave: { mesh: THREE.Mesh; material: WaveMaterial },\n sc: WaveConfig,\n ): boolean {\n const current = wave.material as TslMaterial;\n const flags = this.flagsFor(sc);\n const key = variantKey(flags);\n if (current.userData.variant === key) return false;\n\n const rebuilt = buildWaveMaterial(current.userData.tsl, flags) as unknown as TslMaterial;\n rebuilt.uniforms = current.uniforms;\n rebuilt.userData = { variant: key, tsl: current.userData.tsl };\n rebuilt.blending = current.blending;\n rebuilt.premultipliedAlpha = current.premultipliedAlpha;\n wave.mesh.material = rebuilt;\n wave.material = rebuilt;\n current.dispose();\n return false; // the mesh already points at a fresh material; nothing to recompile in place\n }\n\n // ---- Post chain --------------------------------------------------------------------------\n\n /** Which effects the config currently asks for — the node twin of applyPost()'s pass juggling. */\n private postFlags(): PostFlags {\n const c = this.config;\n return {\n bloom: (c.bloomStrength ?? 0) > 0,\n innerLight: (c.innerLight ?? 0) > 0,\n halftone: (c.halftone ?? 0) > 0,\n heatmap: (c.heatmap ?? 0) > 0,\n halftoneCmyk: (c.halftoneCmyk ?? 0) > 0,\n paperTexture: (c.paperTexture ?? 0) > 0,\n dither: (c.dither ?? 0) > 0,\n };\n }\n\n /** Push the config into the post uniforms. Mirrors applyPost() / the per-effect apply* methods. */\n private syncPostUniforms(): void {\n const c = this.config;\n const u = this.postUniforms;\n u.uBlurAmount.value = c.blur;\n u.uGrainAmount.value = c.grain;\n u.uBlurSamples.value = Math.round(c.blurSamples ?? 6);\n u.uBloomStrength.value = c.bloomStrength ?? 0;\n u.uBloomRadius.value = c.bloomRadius ?? 0.4;\n u.uBloomThreshold.value = c.bloomThreshold ?? 0.85;\n u.uInnerLight.value = c.innerLight ?? 0;\n u.uInnerLightDensity.value = c.innerLightDensity ?? 0.5;\n u.uInnerLightDecay.value = c.innerLightDecay ?? 0.95;\n u.uInnerLightCenter.value.set(c.innerLightX ?? 0.5, c.innerLightY ?? 0.15);\n u.uHalftone.value = c.halftone ?? 0;\n u.uHalftoneCell.value = Math.max(2, c.halftoneCell ?? 6);\n u.uHalftoneAngle.value = c.halftoneAngle ?? 0.4;\n u.uHeatmap.value = c.heatmap ?? 0;\n u.uHalftoneCmyk.value = c.halftoneCmyk ?? 0;\n u.uHalftoneCmykCell.value = Math.max(2, c.halftoneCmykCell ?? 6);\n u.uPaper.value = c.paperTexture ?? 0;\n u.uPaperScale.value = Math.max(0.5, c.paperTextureScale ?? 2);\n u.uDitherStrength.value = c.dither ?? 0;\n u.uDitherScale.value = Math.max(1, c.ditherScale ?? 2);\n u.uDitherSteps.value = Math.max(2, Math.round(c.ditherSteps ?? 4));\n }\n\n private ensurePost(): RenderPipeline {\n const flags = this.postFlags();\n const key = JSON.stringify(flags);\n if (this.post && key !== this.postFlagsKey) this.disposePost();\n if (!this.post) {\n this.postFlagsKey = key;\n this.post = new RenderPipeline(this.renderer as unknown as WebGPURenderer);\n // The chain places renderOutput() itself, so the finish-zone effects see display-space\n // colour exactly as they do after OutputPass on the WebGL path.\n this.post.outputColorTransform = false;\n this.post.outputNode = buildPostChain(\n pass(this.scene, this.camera),\n this.postUniforms,\n flags,\n ) as never;\n }\n return this.post;\n }\n\n protected override renderComposed(): void {\n // Fold each wave's array-valued uniforms into its shared packed buffer. The config sync writes\n // the logical arrays (`u.uColors.value[i].set(...)`) exactly as on the WebGL path; this is the\n // one extra step that layout needs. Cheap — ~52 vec4s per wave.\n for (const wave of this.waves) {\n (wave.material as TslMaterial).userData.tsl.packed.sync();\n }\n this.syncPostUniforms();\n this.ensurePost().render();\n }\n\n protected override resizePost(w: number, h: number, dpr: number): void {\n this.renderer.setPixelRatio(dpr);\n this.renderer.setSize(w, h, false);\n }\n\n protected override disposePost(): void {\n this.post?.dispose();\n this.post = undefined;\n }\n\n /**\n * WebGPU exposes no `capabilities` object; the equivalent limit lives on the adapter's device.\n * Falls back to 8192, the value every WebGPU implementation is required to support, for the\n * window before `init()` resolves — the base class calls this while sizing the background.\n */\n protected override maxTextureSize(): number {\n const device = (this.renderer as unknown as { backend?: { device?: GPUDevice } }).backend\n ?.device;\n return device?.limits?.maxTextureDimension2D ?? 8192;\n }\n\n /**\n * Rebuild the post chain when the background changes.\n *\n * A `pass()` node captures the scene's background at the point its render context is first built.\n * Because the chain is created lazily on the first draw — when `scene.background` is still null —\n * a background set afterwards would never appear, rendering every non-transparent preset on a\n * transparent canvas. Background changes are user-driven, not per-frame, so rebuilding here is\n * cheap; rebuilding every frame (which also works) is not.\n */\n protected override onBackgroundChanged(): void {\n // The node renderer does NOT support a plain Texture background. Background.update() handles\n // exactly three cases — null, `isColor`, and `isNode` — and anything else hits\n // \"Renderer: Unsupported background configuration.\" So the gradient / image / video backgrounds,\n // which the WebGL renderer takes as `scene.background = texture`, have to be re-expressed as a\n // node. `getBackgroundNode(scene) || scene.background` means backgroundNode wins where set, so\n // `scene.background` can be left alone for the WebGL path's benefit.\n const bg = this.scene.background as THREE.Texture | THREE.Color | null;\n const isTexture = !!bg && (bg as THREE.Texture).isTexture === true;\n // V is flipped because these background textures are CanvasTextures, which default to\n // flipY = true. The WebGL background path honours that flag when it draws the texture across\n // the screen; sampling by screenUV here does not, so the gradient lands mirrored. Costs ~1.4\n // mae on a gradient-background preset and shows up as concentric banding in a diff.\n this.scene.backgroundNode = isTexture\n ? (texture(bg as THREE.Texture).sample(vec2(screenUV.x, screenUV.y.oneMinus())) as never)\n : null;\n this.disposePost();\n }\n }\n return WithTslBackend as unknown as TBase;\n}\n\n/** The TSL/WebGPU renderer over the plain {@link WaveRenderer} base — what embeds reach through\n * `gpu-loader.ts`. A named class (not a bare `withTslBackend(WaveRenderer)` const) so consumers\n * keep `new WaveRendererGPU(...)`, `instanceof`, and a stable class name. */\nexport class WaveRendererGPU extends withTslBackend(WaveRenderer) {}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import { WaveRendererGPU } from "./WaveRendererGPU.js";
2
+ export { WaveRendererGPU };
@@ -1,4 +1,5 @@
1
- import { FRAME_H, FRAME_W, WaveRenderer, WaveRendererOptions, frameZoom, hexToLinearVec3 } from "./WaveRenderer.js";
1
+ import { TiltStatus } from "./tilt.js";
2
+ import { FRAME_H, FRAME_W, WaveMaterial, WaveParticleField, WaveRenderer, WaveRendererOptions, WaveUniforms, frameZoom, hexToLinearVec3 } from "./WaveRenderer.js";
2
3
  import { BackgroundGradientOptions, PALETTE_MAPS, PaletteMapDef, PaletteTextureOptions, buildBackgroundGradientCanvas, buildBackgroundImageCanvas, buildBackgroundMeshCanvas, buildPaletteCanvas, buildPaletteTexture, canvasToTexture, configurePaletteTexture, drawBackgroundMediaFrame, loadPaletteImage, paletteMapCanvas, paletteSignature, renderMeshGradient } from "./palette.js";
3
4
  import { buildHeroPaletteCanvas, buildHeroPaletteTexture } from "./heroPalette.js";
4
- export { BackgroundGradientOptions, FRAME_H, FRAME_W, PALETTE_MAPS, PaletteMapDef, PaletteTextureOptions, WaveRenderer, WaveRendererOptions, buildBackgroundGradientCanvas, buildBackgroundImageCanvas, buildBackgroundMeshCanvas, buildHeroPaletteCanvas, buildHeroPaletteTexture, buildPaletteCanvas, buildPaletteTexture, canvasToTexture, configurePaletteTexture, drawBackgroundMediaFrame, frameZoom, hexToLinearVec3, loadPaletteImage, paletteMapCanvas, paletteSignature, renderMeshGradient };
5
+ export { BackgroundGradientOptions, FRAME_H, FRAME_W, PALETTE_MAPS, PaletteMapDef, PaletteTextureOptions, type TiltStatus, WaveMaterial, WaveParticleField, WaveRenderer, WaveRendererOptions, WaveUniforms, buildBackgroundGradientCanvas, buildBackgroundImageCanvas, buildBackgroundMeshCanvas, buildHeroPaletteCanvas, buildHeroPaletteTexture, buildPaletteCanvas, buildPaletteTexture, canvasToTexture, configurePaletteTexture, drawBackgroundMediaFrame, frameZoom, hexToLinearVec3, loadPaletteImage, paletteMapCanvas, paletteSignature, renderMeshGradient };
@@ -1,4 +1,5 @@
1
1
  import { SceneInteractionBinding, StudioConfig, WaveInteractionBinding } from "../config/model.js";
2
+ import { TiltStatus } from "./tilt.js";
2
3
  import * as THREE from "three";
3
4
  //#region src/renderer/interaction.d.ts
4
5
  type AnyBinding = WaveInteractionBinding | SceneInteractionBinding;
@@ -42,6 +43,9 @@ declare class InteractionController {
42
43
  private readonly velNdc;
43
44
  private presence;
44
45
  private presenceTarget;
46
+ /** Whether a REAL pointer is on the element. Tracked apart from `presenceTarget` because tilt can
47
+ * raise presence too (tilt.pointer), and it must yield the moment a finger or cursor shows up. */
48
+ private pointerPresent;
45
49
  private press;
46
50
  private pressTarget;
47
51
  private pointerSpeed;
@@ -49,6 +53,10 @@ declare class InteractionController {
49
53
  private scrollPrev;
50
54
  private scrollVel;
51
55
  private appearLatched;
56
+ /** The orientation sensor, built only for a scene that declares `interaction.tilt`. */
57
+ private tilt;
58
+ private tiltX;
59
+ private tiltY;
52
60
  private readonly customInputs;
53
61
  private readonly ripples;
54
62
  private readonly fields;
@@ -70,6 +78,27 @@ declare class InteractionController {
70
78
  private spawnRipple;
71
79
  /** Advance all smoothed state by `dt` seconds. Called from the render loop with the same delta. */
72
80
  update(dt: number): void;
81
+ /**
82
+ * Build or drop the tilt sensor as the config declares it, then advance the smoothed axes. The
83
+ * sensor is created lazily and only for a scene that asked for it, so a cursor-only scene attaches
84
+ * no orientation listener at all — and a studio edit that adds or removes the block is picked up
85
+ * on the next frame, the same way the controller itself is.
86
+ */
87
+ private updateTilt;
88
+ /** The sensor, built on first use. Null when the platform has none (every desktop browser). */
89
+ private ensureTilt;
90
+ /**
91
+ * Ask for the orientation sensor, which on iOS 13+ MUST happen inside a user gesture (a tap
92
+ * handler — not a `setTimeout` or a promise chain that outlives the gesture). Resolves true once
93
+ * readings can flow. False means the platform has no sensor, the scene declares no `tilt` block,
94
+ * or the reader refused. Everywhere that needs no permission, tilt is already live and this
95
+ * simply resolves true.
96
+ */
97
+ enableTilt(): Promise<boolean>;
98
+ /** Where the sensor stands — `"prompt"` is exactly when a tap-to-enable affordance would help. */
99
+ tiltStatus(): TiltStatus;
100
+ /** Take the next reading as the neutral pose (the reader has changed grip). */
101
+ recenterTilt(): void;
73
102
  private updateBindings;
74
103
  /** Advance one binding's smoothed source value by `dt` and mark it live in `seenBindings`. */
75
104
  private advanceBinding;
@@ -1,11 +1,15 @@
1
1
  import { clamp01 } from "../util/math.js";
2
+ import { tiltActive } from "./interactionGates.js";
3
+ import { TiltSource } from "./tilt.js";
2
4
  import * as THREE from "three";
5
+ //#region src/renderer/interaction.ts
3
6
  const RIPPLE_LIFETIME = 1.5;
4
7
  const VELOCITY_TAU = .08;
5
8
  const POINTER_SPEED_REF = 4;
6
9
  const SCROLL_VELOCITY_REF = 2;
7
10
  const SCROLL_VELOCITY_TAU = .15;
8
11
  const DEFAULT_POINTER_TAU = .12;
12
+ const DEFAULT_TILT_TAU = .18;
9
13
  const DEFAULT_BINDING_TAU = .25;
10
14
  const POINTER_SPRING_ZETA = .7;
11
15
  const MIN_POINTER_TAU = .02;
@@ -132,36 +136,6 @@ const SCENE_APPLIERS = {
132
136
  a.post.uGrainAmount.value = v;
133
137
  })
134
138
  };
135
- /** The global master switch: only `scene.interaction.enabled === false` turns the whole layer off. */
136
- function notDisabled(cfg) {
137
- return cfg.interaction?.enabled !== false;
138
- }
139
- /** Whether a wave has a pointer field (hover effects, or a click ripple). */
140
- function waveHasPointerField(w) {
141
- const it = w.interaction;
142
- return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0);
143
- }
144
- /** Whether this wave has an active pointer field → its POINTER_FX shader path compiles. */
145
- function wavePointerFxActive(cfg, w) {
146
- return notDisabled(cfg) && waveHasPointerField(w);
147
- }
148
- /** Whether this wave has active click ripples → its nested POINTER_RIPPLES path compiles. */
149
- function waveRipplesActive(cfg, w) {
150
- return notDisabled(cfg) && (w.interaction?.press?.ripple ?? 0) > 0;
151
- }
152
- /** Whether ANY wave has a pointer field (so the renderer bothers writing the shared pointer uniforms). */
153
- function anyPointerFxActive(cfg) {
154
- return notDisabled(cfg) && cfg.waves.some(waveHasPointerField);
155
- }
156
- /** Whether the interaction layer should run at all (any wave interaction, or any scene binding). */
157
- function interactionActive(cfg) {
158
- if (!notDisabled(cfg)) return false;
159
- if ((cfg.interaction?.bindings?.length ?? 0) > 0) return true;
160
- return cfg.waves.some((w) => {
161
- const it = w.interaction;
162
- return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0 || (it.bindings?.length ?? 0) > 0);
163
- });
164
- }
165
139
  /**
166
140
  * Owns the one cursor's input + scroll + press/appear/custom and all smoothing. Constructed by the
167
141
  * renderer when {@link interactionActive} first turns true, disposed when it turns false. All
@@ -178,6 +152,9 @@ var InteractionController = class {
178
152
  velNdc = new THREE.Vector2();
179
153
  presence = 0;
180
154
  presenceTarget = 0;
155
+ /** Whether a REAL pointer is on the element. Tracked apart from `presenceTarget` because tilt can
156
+ * raise presence too (tilt.pointer), and it must yield the moment a finger or cursor shows up. */
157
+ pointerPresent = false;
181
158
  press = 0;
182
159
  pressTarget = 0;
183
160
  pointerSpeed = 0;
@@ -185,6 +162,10 @@ var InteractionController = class {
185
162
  scrollPrev = 0;
186
163
  scrollVel = 0;
187
164
  appearLatched = false;
165
+ /** The orientation sensor, built only for a scene that declares `interaction.tilt`. */
166
+ tilt = null;
167
+ tiltX = .5;
168
+ tiltY = .5;
188
169
  customInputs = /* @__PURE__ */ new Map();
189
170
  ripples = [];
190
171
  fields = [];
@@ -224,28 +205,33 @@ var InteractionController = class {
224
205
  }
225
206
  onPointerEnter = (e) => {
226
207
  if (this.ignore(e)) return;
208
+ this.pointerPresent = true;
227
209
  this.presenceTarget = 1;
228
210
  this.setNdcTarget(e);
229
211
  };
230
212
  onPointerMove = (e) => {
231
213
  if (this.ignore(e)) return;
232
214
  if (e.pointerType === "touch" && this.pressTarget < .5) return;
215
+ this.pointerPresent = true;
233
216
  this.presenceTarget = 1;
234
217
  this.setNdcTarget(e);
235
218
  };
236
219
  onPointerLeave = (e) => {
237
220
  if (this.ignore(e)) return;
221
+ this.pointerPresent = false;
238
222
  this.presenceTarget = 0;
239
223
  this.ndcTarget.set(0, 0);
240
224
  };
241
225
  onPointerCancel = (e) => {
242
226
  if (this.ignore(e)) return;
227
+ this.pointerPresent = false;
243
228
  this.pressTarget = 0;
244
229
  this.presenceTarget = 0;
245
230
  this.ndcTarget.set(0, 0);
246
231
  };
247
232
  onPointerDown = (e) => {
248
233
  if (this.ignore(e)) return;
234
+ this.pointerPresent = true;
249
235
  this.pressTarget = 1;
250
236
  this.presenceTarget = 1;
251
237
  this.setNdcTarget(e);
@@ -256,6 +242,7 @@ var InteractionController = class {
256
242
  if (this.ignore(e)) return;
257
243
  this.pressTarget = 0;
258
244
  if (e.pointerType === "touch") {
245
+ this.pointerPresent = false;
259
246
  this.presenceTarget = 0;
260
247
  this.ndcTarget.set(0, 0);
261
248
  }
@@ -279,6 +266,7 @@ var InteractionController = class {
279
266
  if (!cfg) return;
280
267
  const d = Math.max(dt, 0);
281
268
  const kPointer = alpha(DEFAULT_POINTER_TAU, d);
269
+ this.updateTilt(cfg, d);
282
270
  this.ndcPrev.copy(this.ndc);
283
271
  this.ndc.lerp(this.ndcTarget, kPointer);
284
272
  this.presence += (this.presenceTarget - this.presence) * kPointer;
@@ -322,6 +310,58 @@ var InteractionController = class {
322
310
  }
323
311
  this.updateBindings(cfg, d);
324
312
  }
313
+ /**
314
+ * Build or drop the tilt sensor as the config declares it, then advance the smoothed axes. The
315
+ * sensor is created lazily and only for a scene that asked for it, so a cursor-only scene attaches
316
+ * no orientation listener at all — and a studio edit that adds or removes the block is picked up
317
+ * on the next frame, the same way the controller itself is.
318
+ */
319
+ updateTilt(cfg, dt) {
320
+ if (!tiltActive(cfg)) {
321
+ if (this.tilt) {
322
+ this.tilt.dispose();
323
+ this.tilt = null;
324
+ this.tiltX = this.tiltY = .5;
325
+ }
326
+ return;
327
+ }
328
+ const src = this.ensureTilt();
329
+ if (!src) return;
330
+ const tiltCfg = cfg.interaction?.tilt;
331
+ const k = alpha(Math.max(tiltCfg?.smoothing ?? DEFAULT_TILT_TAU, 0), dt);
332
+ this.tiltX += (src.x - this.tiltX) * k;
333
+ this.tiltY += (src.y - this.tiltY) * k;
334
+ if (tiltCfg?.pointer && src.live && !this.pointerPresent) {
335
+ this.ndcTarget.set(this.tiltX * 2 - 1, -(this.tiltY * 2 - 1));
336
+ this.presenceTarget = 1;
337
+ }
338
+ }
339
+ /** The sensor, built on first use. Null when the platform has none (every desktop browser). */
340
+ ensureTilt() {
341
+ if (!this.tilt && TiltSource.supported()) this.tilt = new TiltSource(() => this.cfg()?.interaction?.tilt);
342
+ return this.tilt;
343
+ }
344
+ /**
345
+ * Ask for the orientation sensor, which on iOS 13+ MUST happen inside a user gesture (a tap
346
+ * handler — not a `setTimeout` or a promise chain that outlives the gesture). Resolves true once
347
+ * readings can flow. False means the platform has no sensor, the scene declares no `tilt` block,
348
+ * or the reader refused. Everywhere that needs no permission, tilt is already live and this
349
+ * simply resolves true.
350
+ */
351
+ async enableTilt() {
352
+ const cfg = this.cfg();
353
+ if (!cfg || !tiltActive(cfg)) return false;
354
+ return await this.ensureTilt()?.enable() ?? false;
355
+ }
356
+ /** Where the sensor stands — `"prompt"` is exactly when a tap-to-enable affordance would help. */
357
+ tiltStatus() {
358
+ if (!TiltSource.supported()) return "unsupported";
359
+ return this.tilt?.status ?? "prompt";
360
+ }
361
+ /** Take the next reading as the neutral pose (the reader has changed grip). */
362
+ recenterTilt() {
363
+ this.tilt?.recenter();
364
+ }
325
365
  updateBindings(cfg, dt) {
326
366
  const seen = this.seenBindings;
327
367
  seen.clear();
@@ -365,6 +405,8 @@ var InteractionController = class {
365
405
  case "press": return this.press;
366
406
  case "scrollVelocity": return clamp01(this.scrollVel / SCROLL_VELOCITY_REF);
367
407
  case "appear": return this.appearLatched ? 1 : 0;
408
+ case "tiltX": return this.tiltX;
409
+ case "tiltY": return this.tiltY;
368
410
  default: return this.customInputs.get(source.slice(7)) ?? 0;
369
411
  }
370
412
  }
@@ -409,8 +451,10 @@ var InteractionController = class {
409
451
  */
410
452
  settle() {
411
453
  this.presence = this.presenceTarget = 0;
454
+ this.pointerPresent = false;
412
455
  this.press = this.pressTarget = 0;
413
456
  this.pointerSpeed = 0;
457
+ this.tiltX = this.tiltY = .5;
414
458
  this.velNdc.set(0, 0);
415
459
  this.ndc.set(0, 0);
416
460
  this.ndcTarget.set(0, 0);
@@ -473,11 +517,13 @@ var InteractionController = class {
473
517
  c.removeEventListener("pointercancel", this.onPointerCancel);
474
518
  c.removeEventListener("pointerdown", this.onPointerDown);
475
519
  c.removeEventListener("pointerup", this.onPointerUp);
520
+ this.tilt?.dispose();
521
+ this.tilt = null;
476
522
  this.customInputs.clear();
477
523
  this.bindingState.clear();
478
524
  }
479
525
  };
480
526
  //#endregion
481
- export { InteractionController, SCENE_APPLIERS, WAVE_APPLIERS, anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive };
527
+ export { InteractionController, SCENE_APPLIERS, WAVE_APPLIERS };
482
528
 
483
529
  //# sourceMappingURL=interaction.js.map