@dopaminefx/effect-ripple 0.1.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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * GLSL ES 3.00 source for **Ripple** — Dopamine's tactile "droplet in a still
3
+ * pool" acknowledge effect.
4
+ *
5
+ * Governing metaphor: a single drop strikes a calm water surface at the action
6
+ * point (`uOrigin`). Concentric WAVES expand outward, and each travelling
7
+ * wavefront REFRACTS bright caustic light that dances across the UI as the ring
8
+ * passes; behind the front, the surface settles back to still. It reads as
9
+ * water + light: physical, tactile, anchored.
10
+ *
11
+ * This is a deliberate DIVERGENCE from Solarbloom's volumetric radial bloom.
12
+ * Solarbloom is a soft glowing CORE that fills outward and lingers; Ripple has
13
+ * NO bright core — its light lives entirely on thin, moving RING crests and the
14
+ * caustic sparkle they refract. The motion is a set of discrete expanding
15
+ * annuli, not a swelling blob.
16
+ *
17
+ * Layers, summed as light (canvas is black, `mix-blend-mode: screen`, so black
18
+ * == no change, bright == cast light onto the page beneath):
19
+ * 1. WAVEFIELD — a sum of `uRings` radially-travelling cosine waves whose
20
+ * phase = k*r - w*t, launched in a quick stagger from the origin. The
21
+ * surface height + its radial gradient (slope) drive everything else.
22
+ * 2. CAUSTICS — the wave SLOPE bends light: bright filaments form where the
23
+ * curved surface focuses light (|slope| high, near crests), animated as the
24
+ * rings travel. This is the dancing light the brief describes.
25
+ * 3. CREST GLINT — a thin specular highlight riding the leading crest of each
26
+ * ring (the wet "shine" of the moving wavefront).
27
+ * 4. SETTLE — the whole field is gated by a radial wavefront envelope so light
28
+ * only appears where a ring currently is, then the pool goes still.
29
+ *
30
+ * Reward timing: uAmp (held-breath envelope) gates global brightness — a quick
31
+ * expanding swell then a gentle settle. Pure function of uTimeS (frame-perfect,
32
+ * cheap under SwiftShader: analytic waves + noise, single pass).
33
+ *
34
+ * whimsy == uStyle:
35
+ * 0 = photoreal smooth refraction — soft caustics, continuous crests, smooth
36
+ * OKLCH colour drift across the rings.
37
+ * 1 = stylized: hard concentric CEL rings (posterized crest bands), posterized
38
+ * caustics (chunky light cells), and the motion snaps "on twos" (the
39
+ * pass-runner already steps the clock; we also quantize the wave phase so
40
+ * the rings advance in discrete, posed jumps).
41
+ */
42
+
43
+ import {
44
+ GLSL_CONSTANTS,
45
+ GLSL_DITHER,
46
+ GLSL_FBM,
47
+ GLSL_HASH,
48
+ GLSL_PALETTE_MIX,
49
+ GLSL_TONEMAP_ACES,
50
+ } from "@dopaminefx/core";
51
+
52
+ /**
53
+ * Max concurrent expanding rings. Single source of truth for the loop cap: it is
54
+ * BOTH the GLSL `#define MAX_RINGS` (interpolated below) and the integer-clamp
55
+ * const the `.dope` mapping references (passed to the loader as `MAX_RINGS`).
56
+ */
57
+ export const MAX_RINGS = 7;
58
+
59
+ export const RIPPLE_VERTEX_SRC = /* glsl */ `#version 300 es
60
+ void main() {
61
+ // Single full-screen triangle from gl_VertexID — no vertex buffers needed.
62
+ vec2 pos = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
63
+ gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
64
+ }`;
65
+
66
+ export const RIPPLE_FRAGMENT_SRC = /* glsl */ `#version 300 es
67
+ precision highp float;
68
+ out vec4 fragColor;
69
+
70
+ uniform vec2 uResolution; // device pixels
71
+ uniform vec2 uOrigin; // drop point, gl coords (y up)
72
+ uniform float uAmp; // envelope amplitude (peaks > 1)
73
+ uniform float uLife; // whole-effect progress 0..1
74
+ uniform float uTimeS; // elapsed seconds (snapped "on twos" by style)
75
+ uniform float uExposure;
76
+ uniform float uAmplitude; // wave height (intensity)
77
+ uniform float uRings; // number of concentric wavefronts launched
78
+ uniform float uWavelength; // crest spacing as a fraction of min viewport dim
79
+ uniform float uSpeed; // wave propagation speed (fraction of minDim / s)
80
+ uniform float uCaustic; // 0..1 caustic-light brightness (intensity)
81
+ uniform float uSeed; // per-fire hash offset
82
+ uniform float uStyle; // 0..1 photoreal smooth refraction -> cel rings (whimsy)
83
+ uniform float uShadow; // 0 = light pass (screen), 1 = shadow pass (multiply)
84
+ uniform vec2 uShadowOffset; // device-px offset of the cast silhouette (away from light)
85
+ uniform float uShadowSoft; // penumbra softness in device px (blur tap radius)
86
+ uniform float uShadowStrength;// 0..1 max darkening of the multiply layer
87
+ uniform vec3 uC0; // crest core color
88
+ uniform vec3 uC1; // mid
89
+ uniform vec3 uC2; // caustic accent
90
+
91
+ #define MAX_RINGS ${MAX_RINGS}
92
+ ${GLSL_CONSTANTS}
93
+ ${GLSL_HASH}
94
+ ${GLSL_FBM}
95
+ ${GLSL_PALETTE_MIX}
96
+ ${GLSL_TONEMAP_ACES}
97
+ ${GLSL_DITHER}
98
+
99
+ // A travelling ring's launch time as a fraction of life. The drop strikes at
100
+ // t=0 and successive rings (the secondary swells of a real impact) follow in a
101
+ // stagger wide enough that, at any instant, the rings sit at clearly DIFFERENT
102
+ // radii — a family of distinct sizes rippling out, not bunched near-duplicates.
103
+ float ringLaunch(int i){
104
+ return float(i) * 0.12;
105
+ }
106
+
107
+ // The wave surface as a function of normalized radius rn (= r / minDim) and the
108
+ // life clock. Returns height in h; the radial SLOPE (dHeight/dr) in slope;
109
+ // and a 0..1 wavefront ENVELOPE in front (1 where a ring currently is, 0 in
110
+ // the still water ahead/behind). Shared by the light pass and the shadow so the
111
+ // cast occlusion tracks exactly the troughs that are drawn.
112
+ //
113
+ // Each ring is a radially-expanding wave packet: a cosine carrier (phase =
114
+ // k*r - w*t) under a gaussian envelope that travels outward at uSpeed and
115
+ // spreads/decays as 1/sqrt(r) (energy conservation on an expanding circle).
116
+ void waveField(float rn, out float h, out float slope, out float front){
117
+ h = 0.0; slope = 0.0; front = 0.0;
118
+ float k = TAU / max(uWavelength, 0.001); // angular wavenumber (per rn)
119
+ float w = k * uSpeed; // angular frequency
120
+ int rings = int(clamp(uRings, 0.0, float(MAX_RINGS)) + 0.5);
121
+ for (int i = 0; i < MAX_RINGS; i++) {
122
+ if (i >= rings) break;
123
+ float t0 = ringLaunch(i);
124
+ float age = uLife - t0; // 0..(1-t0)
125
+ if (age <= 0.0) continue;
126
+ // Front radius travels outward; the packet starts tight and SWELLS markedly as
127
+ // the ring expands, so each ring visibly changes size as it travels out (and an
128
+ // older ring is both farther AND fatter than a younger one).
129
+ float front_r = uSpeed * age; // expected crest of this ring
130
+ float width = uWavelength * (1.0 + 2.6 * age); // packet half-extent (grows as it expands)
131
+ float d = rn - front_r; // signed distance to the front
132
+ float pkt = exp(-(d * d) / (2.0 * width * width));
133
+ if (pkt < 0.002) continue;
134
+ // Amplitude fades CONTINUOUSLY as the ring ages/expands (not just a late cutoff),
135
+ // so each crest dims steadily as it grows — on top of the 1/sqrt(r) spreading.
136
+ float decay = pow(max(1.0 - age, 0.0), 1.3);
137
+ // 1/sqrt(r) spreading (clamped near the origin so the drop isn't a spike).
138
+ float spread = 1.0 / sqrt(max(rn, uWavelength * 0.5));
139
+ // On the cel end, quantize the carrier phase so the rings advance "on twos"
140
+ // (discrete posed crests) instead of sliding smoothly.
141
+ float phase = k * rn - w * uLife;
142
+ float qstep = TAU * 0.5;
143
+ float qphase = floor(phase / qstep) * qstep;
144
+ phase = mix(phase, qphase, uStyle * 0.85);
145
+ float amp = uAmplitude * pkt * decay * spread;
146
+ h += amp * cos(phase);
147
+ // d(h)/d(rn): carrier derivative dominates (the steep part that bends light).
148
+ slope += -amp * k * sin(phase);
149
+ front = max(front, pkt * decay);
150
+ }
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // SHADOW silhouette — the wave TROUGHS cast a faint soft occlusion (a real
155
+ // rippled surface dimples the light it sits in). We sample the wave height at
156
+ // the offset shadow point and darken where the surface dips below rest (h < 0),
157
+ // gated by the wavefront envelope so still water casts nothing. Kept subtle.
158
+ float rippleOcclusion(vec2 frag){
159
+ float minDim = min(uResolution.x, uResolution.y);
160
+ float rn = length(frag - uOrigin) / minDim;
161
+ float h, slope, front;
162
+ waveField(rn, h, slope, front);
163
+ float trough = max(-h, 0.0); // depth below rest
164
+ return clamp(trough * 2.2 * front * uAmp, 0.0, 1.0);
165
+ }
166
+
167
+ vec4 rippleShadowColor(vec2 frag){
168
+ vec2 sp = frag - uShadowOffset;
169
+ float soft = uShadowSoft;
170
+ float occ = rippleOcclusion(sp);
171
+ occ += rippleOcclusion(sp + vec2( soft, 0.0));
172
+ occ += rippleOcclusion(sp + vec2(-soft, 0.0));
173
+ occ += rippleOcclusion(sp + vec2(0.0, soft));
174
+ occ += rippleOcclusion(sp + vec2(0.0, -soft));
175
+ occ /= 5.0;
176
+ // Troughs are a faint dimple, so cap the darkening well below full strength.
177
+ float dark = clamp(occ, 0.0, 1.0) * uShadowStrength * 0.5;
178
+ vec3 tint = mix(vec3(1.0), 0.6 + 0.4 * normalize(uC0 + 1e-3), 0.2);
179
+ vec3 mul = mix(vec3(1.0), tint, dark);
180
+ return vec4(mul, 1.0);
181
+ }
182
+
183
+ void main(){
184
+ vec2 frag = gl_FragCoord.xy;
185
+ vec2 res = uResolution;
186
+ float minDim = min(res.x, res.y);
187
+
188
+ if (uShadow > 0.5) {
189
+ fragColor = rippleShadowColor(frag);
190
+ return;
191
+ }
192
+
193
+ vec3 col = vec3(0.0);
194
+ vec2 rel = frag - uOrigin;
195
+ float r = length(rel);
196
+ float rn = r / minDim; // normalized radius
197
+ vec2 rdir = rel / max(r, 1e-3); // outward unit (toward rim)
198
+
199
+ // ---- The wave surface at this fragment. ----
200
+ float h, slope, front;
201
+ waveField(rn, h, slope, front);
202
+
203
+ float gain = uAmp * uExposure;
204
+
205
+ // Colour register: hue drifts gently OUTWARD across the rings (OKLCH palette
206
+ // C0->C1->C2), so each expanding crest reads as a slightly different light —
207
+ // unique per fire (the palette is seeded). A touch of slow temporal drift +
208
+ // tiny fbm break keeps it alive without going rainbow.
209
+ float tcol = clamp(rn / (uWavelength * float(MAX_RINGS) * 0.9), 0.0, 1.0);
210
+ tcol = fract(tcol + uTimeS * 0.04 + fbm(rel / minDim * 5.0 + uSeed) * 0.06);
211
+ vec3 ringCol = paletteMix(tcol);
212
+
213
+ // ---- 1. CRESTS: the bright wet ridge of each travelling wavefront. ----
214
+ // Light lives on the positive crests (h > 0), masked to where a ring is.
215
+ float crest = smoothstep(0.0, uAmplitude * 0.5, h) * front;
216
+ col += ringCol * crest * gain * 0.9;
217
+
218
+ // ---- 2. CAUSTICS: the wave SLOPE refracts/focuses light. A curved surface
219
+ // bends parallel light into bright filaments; |slope| peaks on the steep
220
+ // flanks between crest and trough, so the caustic web sits BETWEEN the rings
221
+ // and dances as they travel. Sharpened to thin, bright lines. ----
222
+ float foc = abs(slope);
223
+ float caustic = pow(clamp(foc / (uAmplitude * 1.2 + 1e-3), 0.0, 1.0), 1.8);
224
+ // A little noise breaks the caustic into a living, glittering web.
225
+ float glit = 0.6 + 0.6 * fbm(rel / minDim * 22.0 - uTimeS * 0.5 + uSeed);
226
+ caustic *= glit * front;
227
+ // The accent hue carries the caustic light (a brighter, whiter highlight on top).
228
+ col += mix(uC2, vec3(1.0), 0.35) * caustic * uCaustic * gain * 1.3;
229
+
230
+ // ---- 3. CREST GLINT: a thin specular line riding each leading crest. ----
231
+ float glint = smoothstep(0.85, 1.0, front) * smoothstep(uAmplitude * 0.55, uAmplitude * 0.9, h);
232
+ col += vec3(1.0) * glint * gain * 0.5 * (0.5 + 0.5 * uCaustic);
233
+
234
+ // ---- Tone + finishing ----
235
+ col = tonemapACES(col * 0.95);
236
+
237
+ // ---- Non-photoreal pass: cel rings + posterized caustics (whimsy). ----
238
+ // Toward the cel end the smooth refraction becomes hard concentric BANDS: the
239
+ // crest mask is thresholded into a flat ring, and the caustic web is posterized
240
+ // into chunky light cells. The phase quantization in waveField already steps
241
+ // the rings "on twos"; here we flatten their tone.
242
+ if (uStyle > 0.001) {
243
+ // Hard ring: a flat band where the crest is strong, with a brighter inner core.
244
+ float band = smoothstep(0.18, 0.30, crest);
245
+ float core = smoothstep(0.45, 0.60, crest);
246
+ vec3 celRing = clamp(ringCol * 1.3, 0.0, 1.2) * band
247
+ + clamp(uC0 * 1.6 + 0.1, 0.0, 1.3) * core;
248
+ // Posterize the caustic light into 2 chunky levels (Ben-Day-ish cells),
249
+ // and keep only the BRIGHT cells (drop the dim wash so the cel read stays
250
+ // clean white-on-dark rings instead of a muddy mid-tone field).
251
+ float caus = clamp(caustic * uCaustic, 0.0, 1.0);
252
+ float causQ = step(0.5, caus) * 0.6 + step(0.8, caus) * 0.4;
253
+ vec3 celCaustic = mix(uC2, vec3(1.0), 0.5) * causQ;
254
+ vec3 cel = (celRing + celCaustic) * gain;
255
+ col = mix(col, cel, uStyle);
256
+ }
257
+
258
+ // Ordered dither (~1/255) to kill banding the screen blend reveals; faded out
259
+ // toward the cel end where hard bands are intended.
260
+ col = ditherAdd(col, frag, uTimeS, 1.0 - uStyle);
261
+
262
+ fragColor = vec4(max(col, 0.0), 1.0);
263
+ }`;
@@ -0,0 +1,359 @@
1
+ {
2
+ "fmt": "dopamine-effect",
3
+ "v": "1.0.0",
4
+ "id": "dopamine.acknowledge.ripple",
5
+ "meta": {
6
+ "name": "Ripple",
7
+ "description": "A droplet in a still pool: concentric waves expand from the action point and refract bright caustic light that dances across the UI, then the surface settles.",
8
+ "tags": [
9
+ "acknowledge",
10
+ "tactile",
11
+ "water"
12
+ ]
13
+ },
14
+ "controls": {
15
+ "mood": {
16
+ "type": "enum",
17
+ "default": "celebratory",
18
+ "options": [
19
+ "serene",
20
+ "celebratory",
21
+ "electric"
22
+ ],
23
+ "ui": "segmented"
24
+ },
25
+ "intensity": {
26
+ "type": "scalar",
27
+ "default": 0.7,
28
+ "min": 0,
29
+ "max": 1,
30
+ "step": 0.01,
31
+ "ui": "slider"
32
+ },
33
+ "whimsy": {
34
+ "type": "scalar",
35
+ "default": 0.5,
36
+ "min": 0,
37
+ "max": 1,
38
+ "step": 0.01,
39
+ "ui": "slider"
40
+ },
41
+ "seed": {
42
+ "type": "int",
43
+ "default": null,
44
+ "nullable": true
45
+ },
46
+ "origin": {
47
+ "type": "point",
48
+ "default": "center"
49
+ },
50
+ "target": {
51
+ "type": "selector",
52
+ "default": "document.body"
53
+ }
54
+ },
55
+ "baselines": {
56
+ "serene": {
57
+ "durationMs": 2400,
58
+ "lightness": 0.86,
59
+ "chroma": 0.08,
60
+ "hueCenter": 220,
61
+ "hueRange": 110,
62
+ "amplitude": 0.42,
63
+ "rings": 3,
64
+ "wavelength": 0.16,
65
+ "speed": 0.34,
66
+ "caustic": 0.55
67
+ },
68
+ "celebratory": {
69
+ "durationMs": 1800,
70
+ "lightness": 0.82,
71
+ "chroma": 0.15,
72
+ "hueCenter": 190,
73
+ "hueRange": 280,
74
+ "amplitude": 0.5,
75
+ "rings": 4,
76
+ "wavelength": 0.13,
77
+ "speed": 0.42,
78
+ "caustic": 0.75
79
+ },
80
+ "electric": {
81
+ "durationMs": 1300,
82
+ "lightness": 0.8,
83
+ "chroma": 0.22,
84
+ "hueCenter": 175,
85
+ "hueRange": 160,
86
+ "amplitude": 0.58,
87
+ "rings": 5,
88
+ "wavelength": 0.11,
89
+ "speed": 0.52,
90
+ "caustic": 0.95
91
+ }
92
+ },
93
+ "palette": {
94
+ "model": "oklch",
95
+ "space": "linear-srgb",
96
+ "generator": "golden-angle",
97
+ "goldenAngleDeg": 137.50776405003785,
98
+ "stops": 3,
99
+ "hueSpread": 0.55,
100
+ "lightness": {
101
+ "baseline": "lightness",
102
+ "perStop": [
103
+ 0,
104
+ 0.06,
105
+ -0.05
106
+ ]
107
+ },
108
+ "chroma": {
109
+ "from": {
110
+ "mul": [
111
+ {
112
+ "baseline": "chroma"
113
+ },
114
+ {
115
+ "lerp": [
116
+ "intensity",
117
+ 0.7,
118
+ 1.5
119
+ ]
120
+ }
121
+ ]
122
+ },
123
+ "perStop": [
124
+ 0,
125
+ 0.02,
126
+ -0.01
127
+ ]
128
+ },
129
+ "seed": {
130
+ "deterministic": true,
131
+ "source": "controls.seed",
132
+ "prng": "mulberry32"
133
+ },
134
+ "perMood": {
135
+ "serene": {
136
+ "hueCenter": 220,
137
+ "hueRange": 110,
138
+ "lightness": 0.86,
139
+ "chroma": 0.08
140
+ },
141
+ "celebratory": {
142
+ "hueCenter": 190,
143
+ "hueRange": 280,
144
+ "lightness": 0.82,
145
+ "chroma": 0.15
146
+ },
147
+ "electric": {
148
+ "hueCenter": 175,
149
+ "hueRange": 160,
150
+ "lightness": 0.8,
151
+ "chroma": 0.22
152
+ }
153
+ }
154
+ },
155
+ "tempo": {
156
+ "durationMs": {
157
+ "from": {
158
+ "round": {
159
+ "mul": [
160
+ {
161
+ "baseline": "durationMs"
162
+ },
163
+ {
164
+ "lerp": [
165
+ "intensity",
166
+ 1.1,
167
+ 0.9
168
+ ]
169
+ }
170
+ ]
171
+ }
172
+ }
173
+ },
174
+ "frame": {
175
+ "amp": {
176
+ "envelope": [
177
+ {
178
+ "input": "life"
179
+ },
180
+ {
181
+ "param": "overshoot"
182
+ }
183
+ ]
184
+ },
185
+ "extras": {}
186
+ },
187
+ "reducedMotion": {
188
+ "peakMs": 280,
189
+ "holdMs": 380
190
+ }
191
+ },
192
+ "render": {
193
+ "params": {
194
+ "exposure": {
195
+ "type": "float",
196
+ "from": {
197
+ "lerp": [
198
+ "intensity",
199
+ 0.85,
200
+ 1.5
201
+ ]
202
+ }
203
+ },
204
+ "amplitude": {
205
+ "type": "float",
206
+ "from": {
207
+ "mul": [
208
+ {
209
+ "baseline": "amplitude"
210
+ },
211
+ {
212
+ "lerp": [
213
+ "intensity",
214
+ 0.7,
215
+ 1.35
216
+ ]
217
+ }
218
+ ]
219
+ }
220
+ },
221
+ "rings": {
222
+ "type": "int",
223
+ "from": {
224
+ "round": {
225
+ "add": [
226
+ {
227
+ "baseline": "rings"
228
+ },
229
+ {
230
+ "lerp": [
231
+ "intensity",
232
+ 0,
233
+ 2
234
+ ]
235
+ }
236
+ ]
237
+ }
238
+ },
239
+ "clampMax": "MAX_RINGS",
240
+ "clampMin": "MIN_RINGS"
241
+ },
242
+ "wavelength": {
243
+ "type": "float",
244
+ "from": {
245
+ "baseline": "wavelength"
246
+ }
247
+ },
248
+ "speed": {
249
+ "type": "float",
250
+ "from": {
251
+ "mul": [
252
+ {
253
+ "baseline": "speed"
254
+ },
255
+ {
256
+ "lerp": [
257
+ "intensity",
258
+ 0.92,
259
+ 1.12
260
+ ]
261
+ }
262
+ ]
263
+ }
264
+ },
265
+ "caustic": {
266
+ "type": "float",
267
+ "from": {
268
+ "mul": [
269
+ {
270
+ "baseline": "caustic"
271
+ },
272
+ {
273
+ "lerp": [
274
+ "intensity",
275
+ 0.6,
276
+ 1.3
277
+ ]
278
+ }
279
+ ]
280
+ }
281
+ },
282
+ "overshoot": {
283
+ "type": "float",
284
+ "from": {
285
+ "lerp": [
286
+ "intensity",
287
+ 0.7,
288
+ 1.4
289
+ ]
290
+ }
291
+ },
292
+ "style": {
293
+ "type": "float",
294
+ "from": {
295
+ "control": "whimsy"
296
+ }
297
+ }
298
+ },
299
+ "shadowHeightFrac": {
300
+ "min": [
301
+ {
302
+ "add": [
303
+ {
304
+ "mul": [
305
+ {
306
+ "param": "wavelength"
307
+ },
308
+ {
309
+ "param": "rings"
310
+ },
311
+ 0.6
312
+ ]
313
+ },
314
+ {
315
+ "mul": [
316
+ {
317
+ "param": "amplitude"
318
+ },
319
+ 0.3
320
+ ]
321
+ }
322
+ ]
323
+ },
324
+ 1
325
+ ]
326
+ },
327
+ "consts": {
328
+ "MAX_RINGS": 7,
329
+ "MIN_RINGS": 2
330
+ },
331
+ "config": {
332
+ "usesOrigin": true
333
+ },
334
+ "backends": {
335
+ "webgl2": {
336
+ "stage": "fullscreen-triangle",
337
+ "blend": "screen",
338
+ "shader": {
339
+ "program": "ripple"
340
+ }
341
+ }
342
+ },
343
+ "fallbackOrder": [
344
+ "webgl2"
345
+ ]
346
+ },
347
+ "binding": {
348
+ "note": "CROSS-PLATFORM uniform-binding contract. Which render.params are NOT shader uniforms, the seed-keyed scatter field, the per-frame/host extras, and the texture samplers — one source of truth for the web u<Name> list, the Swift struct + packer, and the MSL struct. SHIPS in the portable .dope (the runtime derives its uniform bindings from it); the toolchain consumes it too for the Metal struct codegen. Only `x-build`, `slug` and `kind` stay toolchain-only.",
349
+ "excludeParams": [
350
+ "style",
351
+ "overshoot",
352
+ "durationMs"
353
+ ],
354
+ "scatterKey": "rippleSeed",
355
+ "scatterWeb": "uSeed",
356
+ "extras": [],
357
+ "samplers": []
358
+ }
359
+ }