reze-engine 0.56.14 → 0.57.1

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 (35) hide show
  1. package/dist/engine.d.ts +164 -13
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +468 -109
  4. package/dist/graph/registry.d.ts.map +1 -1
  5. package/dist/graph/registry.js +30 -0
  6. package/dist/graph/style-group.d.ts +14 -0
  7. package/dist/graph/style-group.d.ts.map +1 -1
  8. package/dist/shaders/cast-api.d.ts +16 -1
  9. package/dist/shaders/cast-api.d.ts.map +1 -1
  10. package/dist/shaders/cast-api.js +80 -14
  11. package/dist/shaders/lights.d.ts.map +1 -1
  12. package/dist/shaders/lights.js +13 -9
  13. package/dist/shaders/passes/cast-distance.d.ts +8 -0
  14. package/dist/shaders/passes/cast-distance.d.ts.map +1 -1
  15. package/dist/shaders/passes/cast-distance.js +13 -1
  16. package/dist/shaders/passes/composite.d.ts +1 -1
  17. package/dist/shaders/passes/composite.d.ts.map +1 -1
  18. package/dist/shaders/passes/composite.js +22 -6
  19. package/dist/shaders/passes/grid.d.ts.map +1 -1
  20. package/dist/shaders/passes/grid.js +9 -0
  21. package/dist/shaders/passes/particles.d.ts.map +1 -1
  22. package/dist/shaders/passes/particles.js +8 -6
  23. package/dist/shaders/passes/trails.d.ts.map +1 -1
  24. package/dist/shaders/passes/trails.js +16 -11
  25. package/package.json +1 -1
  26. package/src/engine.ts +550 -109
  27. package/src/graph/registry.ts +32 -0
  28. package/src/graph/style-group.ts +14 -0
  29. package/src/shaders/cast-api.ts +82 -14
  30. package/src/shaders/lights.ts +13 -9
  31. package/src/shaders/passes/cast-distance.ts +13 -1
  32. package/src/shaders/passes/composite.ts +22 -6
  33. package/src/shaders/passes/grid.ts +9 -0
  34. package/src/shaders/passes/particles.ts +8 -6
  35. package/src/shaders/passes/trails.ts +15 -10
@@ -217,6 +217,38 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
217
217
  // PMX material's diffuse color (the authored base tint). Multiply the diffuse texture
218
218
  // by this for the MMD-correct base — untextured materials carry their color here, so a
219
219
  // texture-only base would render them white.
220
+ // WHAT THE SURFACE IS MADE OF, when whoever wrote the model said so.
221
+ //
222
+ // PMX carries a specular colour its own renderer barely uses, and a stage
223
+ // converted out of a game engine packs its material's (metal, roughness,
224
+ // occlusion) there — the same triple that game's property map holds, averaged
225
+ // per material. One shared look can then read it and a whole stage keeps ONE
226
+ // pipeline, where a look per material was what cost a garden its frame rate.
227
+ //
228
+ // A hand-authored PMX leaves it at whatever its exporter wrote, so a graph
229
+ // that reads this is choosing to trust the model. Nothing else does.
230
+ // How opaque the PMX said this material is, before its texture's alpha is
231
+ // multiplied in. A graph that computes its own opacity still wants it: glass
232
+ // is see-through by the amount its author chose and a mirror at a grazing
233
+ // angle, and without this every pane in a stage gets the same transparency
234
+ // whatever its material says.
235
+ material_alpha: {
236
+ inputs: {},
237
+ outputs: { value: "float" },
238
+ contextOutputs: { value: "material.alpha" },
239
+ },
240
+
241
+ material_specular: {
242
+ inputs: {},
243
+ outputs: { color: "color" },
244
+ contextOutputs: { color: "material.specular" },
245
+ },
246
+ material_shininess: {
247
+ inputs: {},
248
+ outputs: { value: "float" },
249
+ contextOutputs: { value: "material.shininess" },
250
+ },
251
+
220
252
  material_diffuse: {
221
253
  inputs: {},
222
254
  outputs: { color: "color" },
@@ -36,6 +36,20 @@ export type StyleGroup = {
36
36
  * no-op rather than a failure.
37
37
  */
38
38
  images?: (GroupImage | GroupImageSource | null)[]
39
+ /**
40
+ * The same slots, but PER MATERIAL — for a group whose members each bring
41
+ * their own maps.
42
+ *
43
+ * `images` belongs to the LOOK and is right when a look carries its own ramp
44
+ * or detail map. A converted stage is the other case: twenty-seven props, one
45
+ * shading model, and a different normal map on every one of them. Keyed here,
46
+ * they share a pipeline and differ only in what the bind group points at —
47
+ * the alternative is a group per material, which is a pipeline per material,
48
+ * which is what cost a garden stage its frame rate.
49
+ *
50
+ * A material with no entry falls back to `images`, and then to 1×1 white.
51
+ */
52
+ imagesByMaterial?: Record<string, (GroupImage | GroupImageSource | null)[]>
39
53
  }
40
54
 
41
55
  export type GroupImageSource = ImageBitmap | HTMLImageElement | HTMLCanvasElement
@@ -15,17 +15,22 @@
15
15
  // that did not exist. Baking them makes this a constant string, which is what
16
16
  // lets both families share it without either one deciding the shape.
17
17
  //
18
- // WHAT A HOST MUST SUPPLY. Three names, and deliberately only three:
18
+ // WHAT A HOST MUST SUPPLY. Four names, and deliberately only four:
19
19
  //
20
- // _rzCast the buffer, at whatever binding the module puts it on
21
- // _rzSlot(i) the effect's local slot → the scene's, from its alias
22
- // rzSubjectCount() how many subjects are live
20
+ // _rzCast the buffer, at whatever binding the module puts it on
21
+ // _rzSlot(i) the effect's local slot → the scene's, from its alias
22
+ // _rzCastLive() how many subjects the SCENE holds
23
+ // _rzSubjectMask() which of them THIS EFFECT applies to, one bit per slot
23
24
  //
24
- // The last is a host's because the two families genuinely disagree on it: the
25
+ // The third is a host's because the two families genuinely disagree on it: the
25
26
  // field module reads a count the engine wrote into the view uniform, and the
26
27
  // particle module scans the buffer, because it has no view uniform to read.
27
28
  // They agree in value. Unifying them would be a behaviour change to every
28
29
  // shipped effect for no gain, so the seam stays and is named here instead.
30
+ //
31
+ // The fourth is a host's because every mount carries it in a uniform of its own
32
+ // — the field clock's z, the particle and trail pads, the grid's and the light
33
+ // emitter's blocks — and each of those is written once a frame by the engine.
29
34
 
30
35
  import { EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, EFFECT_SUBJECT_VEC4S } from "./cast-layout"
31
36
 
@@ -82,17 +87,59 @@ struct RzAnchor {
82
87
  valid: bool,
83
88
  }
84
89
 
90
+ /**
91
+ * How many characters THIS EFFECT is on, up to four.
92
+ *
93
+ * Not how many the scene holds. An effect applies to the whole cast unless the
94
+ * scene says otherwise (Engine.setEffectSubjects), and where it says otherwise
95
+ * this counts only the models named — so an effect aimed at one dancer sees one
96
+ * subject, at index 0, and an author who wrote a loop over the cast gets the
97
+ * targeting for free.
98
+ *
99
+ * Which is the whole design: the FILTER IS IN THE INDEX SPACE, not in the
100
+ * shaders. There is no mask an effect can read, because an effect asking "am I
101
+ * allowed on this one" is an effect that can get the answer wrong, and 18 of the
102
+ * shipped built-ins would have had to be rewritten to ask at all.
103
+ */
104
+ fn rzSubjectCount() -> i32 {
105
+ let live = _rzCastLive();
106
+ let mask = _rzSubjectMask();
107
+ var n = 0;
108
+ for (var i = 0; i < live; i++) {
109
+ if ((mask & (1u << u32(i))) != 0u) { n++; }
110
+ }
111
+ return n;
112
+ }
113
+
114
+ /** This effect's i-th subject → the scene's slot, or -1 past its own count.
115
+ * Every accessor below goes through it, which is what keeps one effect's
116
+ * subject 1 from being another's. */
117
+ fn _rzSubjectSlot(i: i32) -> i32 {
118
+ if (i < 0) { return -1; }
119
+ let live = _rzCastLive();
120
+ let mask = _rzSubjectMask();
121
+ var n = 0;
122
+ for (var s = 0; s < live; s++) {
123
+ if ((mask & (1u << u32(s))) == 0u) { continue; }
124
+ if (n == i) { return s; }
125
+ n++;
126
+ }
127
+ return -1;
128
+ }
129
+
85
130
  /** Which model this is, stable across a scene — for per-subject variation. */
86
131
  fn rzSubjectId(i: i32) -> u32 {
87
- if (i < 0 || i >= rzSubjectCount()) { return 0u; }
88
- return u32(_rzCast[i * ${EFFECT_SUBJECT_VEC4S} + 1].w);
132
+ let g = _rzSubjectSlot(i);
133
+ if (g < 0) { return 0u; }
134
+ return u32(_rzCast[g * ${EFFECT_SUBJECT_VEC4S} + 1].w);
89
135
  }
90
136
 
91
137
  fn rzSubject(i: i32) -> RzSubject {
92
138
  var s: RzSubject;
93
- s.valid = i >= 0 && i < rzSubjectCount();
139
+ let g = _rzSubjectSlot(i);
140
+ s.valid = g >= 0;
94
141
  if (!s.valid) { return s; }
95
- let b = i * ${EFFECT_SUBJECT_VEC4S};
142
+ let b = g * ${EFFECT_SUBJECT_VEC4S};
96
143
  s.root = _rzCast[b].xyz;
97
144
  s.dissolve = _rzCast[b].w;
98
145
  s.center = _rzCast[b + 1].xyz;
@@ -113,8 +160,9 @@ fn rzAnchor(subject: i32, slot: i32) -> RzAnchor {
113
160
  var a: RzAnchor;
114
161
  a.valid = false;
115
162
  let g = _rzSlot(slot);
116
- if (subject < 0 || subject >= rzSubjectCount() || g < 0 || g >= RZ_MAX_ANCHORS) { return a; }
117
- let b = ${EFFECT_SUBJECTS * EFFECT_SUBJECT_VEC4S} + (g * ${EFFECT_SUBJECTS} + subject) * 3;
163
+ let s = _rzSubjectSlot(subject);
164
+ if (s < 0 || g < 0 || g >= RZ_MAX_ANCHORS) { return a; }
165
+ let b = ${EFFECT_SUBJECTS * EFFECT_SUBJECT_VEC4S} + (g * ${EFFECT_SUBJECTS} + s) * 3;
118
166
  a.valid = _rzCast[b].w > 0.5;
119
167
  a.pos = _rzCast[b].xyz;
120
168
  a.vel = _rzCast[b + 1].xyz;
@@ -133,15 +181,35 @@ fn rzAnchor(subject: i32, slot: i32) -> RzAnchor {
133
181
  */
134
182
  fn rzTrailCount(subject: i32, slot: i32) -> i32 {
135
183
  let g = _rzSlot(slot);
136
- if (subject < 0 || subject >= rzSubjectCount() || g < 0 || g >= RZ_MAX_ANCHORS) { return 0; }
137
- return i32(_rzCast[${EFFECT_SUBJECTS * EFFECT_SUBJECT_VEC4S} + (g * ${EFFECT_SUBJECTS} + subject) * 3 + 2].w);
184
+ let s = _rzSubjectSlot(subject);
185
+ if (s < 0 || g < 0 || g >= RZ_MAX_ANCHORS) { return 0; }
186
+ return i32(_rzCast[${EFFECT_SUBJECTS * EFFECT_SUBJECT_VEC4S} + (g * ${EFFECT_SUBJECTS} + s) * 3 + 2].w);
138
187
  }
139
188
 
140
189
  /** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */
141
190
  fn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {
142
191
  let n = rzTrailCount(subject, slot);
143
192
  if (i < 0 || i >= n) { return vec4f(0.0); }
144
- let base = ${EFFECT_TRAIL_BASE} + (_rzSlot(slot) * ${EFFECT_SUBJECTS} + subject) * RZ_TRAIL_SAMPLES;
193
+ let base = ${EFFECT_TRAIL_BASE} + (_rzSlot(slot) * ${EFFECT_SUBJECTS} + _rzSubjectSlot(subject)) * RZ_TRAIL_SAMPLES;
145
194
  return _rzCast[base + i];
146
195
  }
147
196
  `
197
+
198
+ /**
199
+ * Which subjects this effect applies to, as WGSL — the second of the two names a
200
+ * mount owes CAST_API (`_rzCastLive` is the first, and a mount with a view
201
+ * uniform already has it from EFFECT_SCENE_API).
202
+ *
203
+ * One bit per cast slot. An EXPRESSION rather than a value: it resolves per
204
+ * draw, out of a uniform the engine writes once a frame, so a module that baked
205
+ * it would stop following a target being changed or a model being hidden.
206
+ */
207
+ export const subjectMaskApi = (mask: string): string => /* wgsl */ `
208
+ fn _rzSubjectMask() -> u32 { return ${mask}; }
209
+ `
210
+
211
+ /** Every subject the scene has. For a mount that is not an effect — the
212
+ * composite's own module, where nothing user-written lands and the accessors
213
+ * exist only so the shared API compiles — and the value the engine writes for
214
+ * an effect the scene has not aimed at anybody in particular. */
215
+ export const CAST_MASK_ALL = "0xfu"
@@ -1,4 +1,5 @@
1
1
  import { SCENE_TAP_STUB } from "./scene-tap"
2
+ import { subjectMaskApi } from "./cast-api"
2
3
  // Positional lights, as data — the sibling of the cast, audio and score
3
4
  // interfaces, and shaped like them: one shared buffer, read through accessors,
4
5
  // never touched directly.
@@ -149,15 +150,18 @@ export function buildLightEmitShader(
149
150
  // read_write HERE and read-only in the material shaders. Different passes, so
150
151
  // the two never coexist: this compute runs before the scene pass that reads it.
151
152
  @group(0) @binding(0) var<storage, read_write> _rzLightsOut: array<f32>;
152
- // (time, base slot, count, weight) — see buildLightEmitShader on why the base
153
- // is here and not in the text.
154
- @group(0) @binding(1) var<uniform> _rzLightU: vec4f;
153
+ // [0] (time, base slot, count, weight) — see buildLightEmitShader on why the
154
+ // base is here and not in the text — and [1].x the subject mask, which is the
155
+ // only thing the second vec4 is for: an emitter reads the cast through the same
156
+ // accessors its drawing half does, so a rig aimed at one dancer must see one.
157
+ @group(0) @binding(1) var<uniform> _rzLightU: array<vec4f, 2>;
155
158
  // The camera block and the cast — the two buffers the scene API reads. Same
156
159
  // contents the field and grid modules bind, so an effect's lightEmit sees the
157
160
  // scene exactly as its drawing half does.
158
161
  @group(0) @binding(2) var<uniform> viewU: array<vec4<f32>, 15>;
159
162
  @group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
160
163
  ${sceneApi}
164
+ ${subjectMaskApi("u32(_rzLightU[1].x)")}
161
165
  ${SCENE_TAP_STUB}
162
166
  // Audio and score at 4 and 5, the same bindings the particle and trail modules
163
167
  // put them on. A lamp that pulses on the beat or lights on a note is the whole
@@ -171,7 +175,7 @@ ${lyricsApi(0, 6)}
171
175
  // grows a lamp at its tip compiles its ribbon code in this module too. The math
172
176
  // helpers and the Particle struct are NOT repeated — they arrive with the scene
173
177
  // API above, and a second copy is a redefinition error in engine code.
174
- ${clockApi("_rzLightU.x", "0.0")}
178
+ ${clockApi("_rzLightU[0].x", "0.0")}
175
179
  // Canvas height — the same number the drawing modules read out of their camera
176
180
  // struct, both written from canvas.height, so this name means ONE value in
177
181
  // every module. Verified against the writers, not assumed: cameraMatrixData[35]
@@ -192,12 +196,12 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
192
196
  let i = gid.x;
193
197
  // The dispatch is sized to the count, but a workgroup is 64 wide and the
194
198
  // count rarely is — the tail must not write into the next effect's slots.
195
- if (i >= u32(_rzLightU.z)) { return; }
199
+ if (i >= u32(_rzLightU[0].z)) { return; }
196
200
  // Time is a PARAMETER, not an rzTime() call: this same source compiles
197
201
  // inside the field, particle, trail and grid modules, and those already
198
202
  // define rzTime differently or not at all. A parameter needs nothing from
199
203
  // the module it lands in, which is why the field mounts take theirs too.
200
- let l = lightEmit(i, _rzLightU.x);
204
+ let l = lightEmit(i, _rzLightU[0].x);
201
205
  // SANITIZED at the one write site, because lightEmit is HOSTED USER CODE and
202
206
  // this buffer feeds every fragment of every material: one NaN position would
203
207
  // poison the whole frame, and WGSL leaves max(NaN, 0) indeterminate, so it
@@ -211,8 +215,8 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
211
215
  // Weight rides along with the sanitisation, which is already the one place
212
216
  // this buffer is written: a lamp at half weight is half as bright, and one
213
217
  // at zero never reaches here because the dispatch is skipped.
214
- let c = select(vec3f(0.0), max(l.color * l.intensity, vec3f(0.0)), finite) * _rzLightU.w;
215
- let b = ${LIGHT_HEADER}u + (u32(_rzLightU.y) + i) * ${LIGHT_STRIDE}u;
218
+ let c = select(vec3f(0.0), max(l.color * l.intensity, vec3f(0.0)), finite) * _rzLightU[0].w;
219
+ let b = ${LIGHT_HEADER}u + (u32(_rzLightU[0].y) + i) * ${LIGHT_STRIDE}u;
216
220
  _rzLightsOut[b] = select(0.0, l.pos.x, finite);
217
221
  _rzLightsOut[b + 1u] = select(0.0, l.pos.y, finite);
218
222
  _rzLightsOut[b + 2u] = select(0.0, l.pos.z, finite);
@@ -220,7 +224,7 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
220
224
  // weight above it. Scaling it with the fade would shrink a dimming lamp's
221
225
  // reach, which is a different thing than dimming it; zeroing it at nothing
222
226
  // is what lets a material's distance cull drop the slot entirely.
223
- _rzLightsOut[b + 3u] = select(0.0, max(l.radius, 0.0), finite && _rzLightU.w > 0.0);
227
+ _rzLightsOut[b + 3u] = select(0.0, max(l.radius, 0.0), finite && _rzLightU[0].w > 0.0);
224
228
  // Colour carries intensity, exactly as the CPU writer stores it — one product,
225
229
  // one place, so the two producers cannot disagree about what a slot means.
226
230
  _rzLightsOut[b + 4u] = c.x;
@@ -86,6 +86,14 @@ fn vs(@builtin(vertex_index) i: u32) -> VSOut {
86
86
  * HOLE in the seeds wherever it covers her, and the field grows into it from
87
87
  * her side, so the border ran round the inside of the prop instead of the
88
88
  * outside.
89
+ *
90
+ * WHICH SUBJECTS SEED IS A MASK, because an effect can be aimed at some of the
91
+ * cast and this field is how a screen-space look knows where anybody is. The
92
+ * distance to a model left out of the mask is not "large", it is a different
93
+ * question — so a field is built per distinct target set rather than filtered
94
+ * afterwards. The props are not masked: a prop belongs to whoever is holding it,
95
+ * and the engine does not know who that is. It seeds either way, which is the
96
+ * side that keeps a border whole.
89
97
  */
90
98
  export function buildCastSeedShader(samples: number): string {
91
99
  return (
@@ -99,6 +107,8 @@ const RZ_ID_SAMPLES: i32 = ${samples};
99
107
  /** The visible props' object ids, count first. Props are not subjects, so the
100
108
  * cast buffer does not carry them. */
101
109
  @group(0) @binding(2) var<storage, read> _rzSeedProps: array<u32>;
110
+ /** (subject mask, _, _, _) — which cast slots this field is built from. */
111
+ @group(0) @binding(3) var<uniform> _rzSeedU: vec4f;
102
112
 
103
113
  // The two cast accessors this pass needs, spelled out rather than pulled in.
104
114
  // CAST_API brings subjects, trails, anchors and their aliases with it, and a
@@ -114,9 +124,11 @@ fn rzSubjectId(i: i32) -> u32 {
114
124
  if (i < 0 || i >= rzSubjectCount()) { return 0u; }
115
125
  return u32(_rzCast[i * ${EFFECT_SUBJECT_VEC4S} + 1].w);
116
126
  }
117
- /** Does this object seed the field: a subject, or a prop. */
127
+ /** Does this object seed the field: a MASKED subject, or a prop. */
118
128
  fn rzSeeds(o: u32, subjects: i32) -> bool {
129
+ let mask = u32(_rzSeedU.x);
119
130
  for (var i = 0; i < subjects; i++) {
131
+ if ((mask & (1u << u32(i))) == 0u) { continue; }
120
132
  if (o == rzSubjectId(i)) { return true; }
121
133
  }
122
134
  let props = min(_rzSeedProps[0], arrayLength(&_rzSeedProps) - 1u);
@@ -1,7 +1,7 @@
1
1
  import { RZ_LIGHT_STRUCT_WGSL } from "../lights"
2
2
  import { sceneTapApi } from "../scene-tap"
3
3
  import { anchorAliasWgsl } from "../anchor-table"
4
- import { CAST_API } from "../cast-api"
4
+ import { CAST_API, CAST_MASK_ALL, subjectMaskApi } from "../cast-api"
5
5
  import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api"
6
6
  import { EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES } from "../cast-layout"
7
7
  import { audioApi } from "../audio-api"
@@ -337,8 +337,9 @@ fn rzResolution() -> vec2f { return viewU[6].zw; }
337
337
  /** The camera's world position. */
338
338
  fn rzCameraPos() -> vec3f { return viewU[10].xyz; }
339
339
 
340
- /** How many characters are in the scene, up to four. */
341
- fn rzSubjectCount() -> i32 { return i32(viewU[10].w); }
340
+ /** How many characters the SCENE holds, up to four — the number the cast api
341
+ * narrows to this effect's own subjects. See subjectMaskApi. */
342
+ fn _rzCastLive() -> i32 { return i32(viewU[10].w); }
342
343
 
343
344
  /**
344
345
  * A world point as the camera sees it: xy the uv it lands on, z its distance
@@ -407,8 +408,14 @@ fn bgSubjectCount() -> i32 { return rzSubjectCount(); }
407
408
  * Clamped rather than bounds-checked: an effect looping past the count reads the
408
409
  * last subject instead of sampling whatever follows the array, which is a wrong
409
410
  * ripple rather than an undefined one.
411
+ *
412
+ * THE INDEX IS THE EFFECT'S OWN, like every other cast accessor — Dry Ice, Water,
413
+ * Holy Light and Bloody Ash all read this one and all of them should place their
414
+ * fog, their ripple and their shaft under the model the scene aimed them at. The
415
+ * mapping is the identity for an effect aimed at nobody in particular, which is
416
+ * every scene published before one could be aimed.
410
417
  */
411
- fn rzSubjectHip(i: i32) -> vec3f { return viewU[11 + clamp(i, 0, 3)].xyz; }
418
+ fn rzSubjectHip(i: i32) -> vec3f { return viewU[11 + clamp(_rzSubjectSlot(i), 0, 3)].xyz; }
412
419
 
413
420
  fn bgSubjectPos(i: i32) -> vec3f { return rzSubjectHip(i); }
414
421
 
@@ -659,7 +666,13 @@ export function buildCompositeShader(effect?: CompositeEffectSource | null): str
659
666
  // The composite is STATIC either way now: the user's code compiles in the
660
667
  // field module alone, and the composite only decides whether to sample it.
661
668
  return COMPOSITE_HEAD +
662
- EFFECT_SCENE_API + anchorAliasWgsl(effect?.alias ?? []) + audioApi(0, 13) + midiApi(0, 19) + lyricsApi(0, 24) + body
669
+ EFFECT_SCENE_API +
670
+ subjectMaskApi(CAST_MASK_ALL) +
671
+ anchorAliasWgsl(effect?.alias ?? []) +
672
+ audioApi(0, 13) +
673
+ midiApi(0, 19) +
674
+ lyricsApi(0, 24) +
675
+ body
663
676
  }
664
677
 
665
678
  /**
@@ -691,6 +704,9 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
691
704
  return (
692
705
  COMPOSITE_HEAD +
693
706
  EFFECT_SCENE_API +
707
+ // Which characters this effect is on, out of its own clock block — see the
708
+ // declaration of _rzFieldClock below for why that buffer is per effect.
709
+ subjectMaskApi("u32(_rzFieldClock.z)") +
694
710
  anchorAliasWgsl(effect.alias ?? []) +
695
711
  audioApi(0, 13) +
696
712
  midiApi(0, 19) +
@@ -727,7 +743,7 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
727
743
  * whose lightEmit read its own epoch disagreed with its own background()
728
744
  * about what time it was. One buffer per effect, one answer.
729
745
  */
730
- @group(0) @binding(22) var<uniform> _rzFieldClock: vec4f; // (time, weight, _, _)
746
+ @group(0) @binding(22) var<uniform> _rzFieldClock: vec4f; // (time, weight, subject mask, _)
731
747
 
732
748
  /** A filter's own straight-alpha output over the premultiplied layers it read
733
749
  * (the layer blend state, restated) and the additive form of the same. */
@@ -3,6 +3,7 @@ import { lyricsApi } from "../lyrics-api"
3
3
  import { anchorAliasWgsl } from "../anchor-table"
4
4
  import { midiApi } from "../midi-api"
5
5
  import { EFFECT_SCENE_API } from "./composite"
6
+ import { subjectMaskApi } from "../cast-api"
6
7
  import { SCENE_TAP_STUB } from "../scene-tap"
7
8
  import { type CastLayout } from "./particles"
8
9
  import { clockApi, trailSlotsApi, viewportApi } from "./hosted-api"
@@ -91,6 +92,11 @@ struct SimU {
91
92
  dt: f32,
92
93
  size: f32,
93
94
  frame: f32,
95
+ /** Which cast slots this effect is on, one bit each — see subjectMaskApi. */
96
+ mask: f32,
97
+ _pad0: f32,
98
+ _pad1: f32,
99
+ _pad2: f32,
94
100
  }
95
101
  `
96
102
 
@@ -140,6 +146,9 @@ fn rzGridPrev(uv: vec2f) -> vec4f {
140
146
  }
141
147
  ` +
142
148
  EFFECT_SCENE_API +
149
+ // Which characters this kernel is on. A wake follows the feet it was aimed
150
+ // at, and a grid the whole cast walks through is the default.
151
+ subjectMaskApi("u32(su.mask)") +
143
152
  SCENE_TAP_STUB +
144
153
  // The alias _rzSlot, which the scene API's accessors route through. Every
145
154
  // module embedding EFFECT_SCENE_API must splice this; the sim module was
@@ -4,7 +4,7 @@ import { audioApi } from "../audio-api"
4
4
  import { lyricsApi } from "../lyrics-api"
5
5
  import { anchorAliasWgsl } from "../anchor-table"
6
6
  import { midiApi } from "../midi-api"
7
- import { CAST_API } from "../cast-api"
7
+ import { CAST_API, subjectMaskApi } from "../cast-api"
8
8
  import { EFFECT_SUBJECT_VEC4S } from "../cast-layout"
9
9
  import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api"
10
10
  import { sceneIdFieldWgsl, sceneIdPadWgsl } from "./scene-contract"
@@ -54,12 +54,12 @@ export type CastLayout = {
54
54
  * a kernel that displaces fog has to know where the dancer's feet are. */
55
55
  function castApi(cast: CastLayout): string {
56
56
  return (
57
- // rzSubjectCount FIRST, because CAST_API is written against it and this
58
- // module has no view uniform to read the engine's count out of. Scanning
59
- // for a subject whose bounding sphere has a radius is the same answer by a
57
+ // _rzCastLive FIRST, because CAST_API is written against it and this module
58
+ // has no view uniform to read the engine's count out of. Scanning for a
59
+ // subject whose bounding sphere has a radius is the same answer by a
60
60
  // different route — the seam is named at the top of cast-api.ts.
61
61
  `
62
- fn rzSubjectCount() -> i32 {
62
+ fn _rzCastLive() -> i32 {
63
63
  var n = 0;
64
64
  for (var i = 0; i < RZ_SUBJECTS; i++) {
65
65
  if (_rzCast[i * ${EFFECT_SUBJECT_VEC4S} + 2].w > 0.0) { n = i + 1; }
@@ -67,6 +67,7 @@ fn rzSubjectCount() -> i32 {
67
67
  return n;
68
68
  }
69
69
  ` +
70
+ subjectMaskApi("u32(pu.subjects)") +
70
71
  CAST_API +
71
72
  trailSlotsApi(cast.trailCount) +
72
73
  anchorAliasWgsl(cast.alias)
@@ -111,7 +112,8 @@ struct ParticleU {
111
112
  frame: u32,
112
113
  /** The effect's evaluated influence, applied at the one output site below. */
113
114
  weight: f32,
114
- _pad0: f32,
115
+ /** Which cast slots this effect is on, one bit each — see subjectMaskApi. */
116
+ subjects: f32,
115
117
  _pad1: f32,
116
118
  _pad2: f32,
117
119
  }
@@ -4,7 +4,7 @@ import { audioApi } from "../audio-api"
4
4
  import { lyricsApi } from "../lyrics-api"
5
5
  import { anchorAliasWgsl, ribbonSlotWgsl } from "../anchor-table"
6
6
  import { midiApi } from "../midi-api"
7
- import { CAST_API } from "../cast-api"
7
+ import { CAST_API, subjectMaskApi } from "../cast-api"
8
8
  import { EFFECT_SUBJECT_VEC4S } from "../cast-layout"
9
9
  import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api"
10
10
  import { sceneIdFieldWgsl, sceneIdPadWgsl } from "./scene-contract"
@@ -94,6 +94,7 @@ export function buildTrailShader(
94
94
  },
95
95
  ): string {
96
96
  return (
97
+ subjectMaskApi("u32(tu.mask)") +
97
98
  CAST_API +
98
99
  trailSlotsApi(src.slots) +
99
100
  EFFECT_MATH_API +
@@ -129,17 +130,21 @@ struct CameraU {
129
130
  }
130
131
  struct TrailU {
131
132
  time: f32,
132
- // How many subjects the cast ACTUALLY holds this frame, not the four the
133
- // layout has room for. The instance count is sized by this on the CPU and
134
- // decoded by it here, and the two must use the same number or the flattened
135
- // [ribbon][subject][segment] index lands on the wrong ribbon. It is a uniform
136
- // rather than the RZ_SUBJECTS constant precisely because it changes when a
137
- // model is added or removed, and recompiling every trail shader for that
138
- // would be absurd.
133
+ // How many subjects THIS EFFECT ribbons this frame, not the four the layout
134
+ // has room for and not the whole cast. The instance count is sized by this on
135
+ // the CPU and decoded by it here, and the two must use the same number or the
136
+ // flattened [ribbon][subject][segment] index lands on the wrong ribbon. It is
137
+ // a uniform rather than the RZ_SUBJECTS constant precisely because it changes
138
+ // when a model is added, removed, hidden or taken off this effect's list, and
139
+ // recompiling every trail shader for that would be absurd.
140
+ //
141
+ // The subject it decodes is the effect's OWN index, which is what rzTrail
142
+ // takes — so a ribbon aimed at one dancer draws one ribbon, on her.
139
143
  subjects: f32,
140
144
  /** The effect's evaluated influence, applied at the one output site below. */
141
145
  weight: f32,
142
- _pad2: f32,
146
+ /** Which cast slots this effect is on, one bit each — see subjectMaskApi. */
147
+ mask: f32,
143
148
  }
144
149
  ${src.paramsDecl}
145
150
  @group(0) @binding(0) var<storage, read> _rzCast: array<vec4f>;
@@ -163,7 +168,7 @@ fn rzProject(p: vec3f) -> vec3f {
163
168
  return vec3f(clip.xy / w * 0.5 + 0.5, clip.w);
164
169
  }
165
170
  fn rzCamPos() -> vec3f { return cam.camPos; }
166
- fn rzSubjectCount() -> i32 {
171
+ fn _rzCastLive() -> i32 {
167
172
  var n = 0;
168
173
  for (var i = 0; i < RZ_SUBJECTS; i++) {
169
174
  if (_rzCast[i * ${EFFECT_SUBJECT_VEC4S} + 2].w > 0.0) { n = i + 1; }