reze-engine 0.57.0 → 0.57.2

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.
package/dist/engine.js CHANGED
@@ -626,6 +626,28 @@ function paramValue(p, given) {
626
626
  return { x: p.value[0], y: p.value[1], z: p.value[2] };
627
627
  return typeof p.value === "number" ? p.value : 0;
628
628
  }
629
+ /**
630
+ * Which models an effect is on, as it is kept: names, deduplicated, or null for
631
+ * the whole cast.
632
+ *
633
+ * AN EMPTY LIST IS THE WHOLE CAST, not nobody. An effect on nobody is a dark
634
+ * effect with no sign of why, and there are already two ways to say that on
635
+ * purpose — influence 0 and a schedule with no window here. A host that filters
636
+ * a target list down to nothing (every named model deleted, say) gets the
637
+ * default back instead of a scene with a mystery in it.
638
+ */
639
+ function normalizeSubjects(list) {
640
+ if (!list || list.length === 0)
641
+ return null;
642
+ const seen = [...new Set(list.filter((n) => typeof n === "string" && n.length > 0))];
643
+ return seen.length === 0 ? null : seen;
644
+ }
645
+ /** One target set as a comparable string — `*` is the whole cast. Sorted, so two
646
+ * effects naming the same models in a different order share one distance field
647
+ * rather than paying for the same flood twice. */
648
+ function castSubjectKey(subjects) {
649
+ return subjects ? [...subjects].sort().join("\u0000") : "*";
650
+ }
629
651
  const EFFECT_PARAMS_BINDING = 7;
630
652
  const EFFECT_PARAMS_BINDING_GRID = 9;
631
653
  /**
@@ -809,8 +831,27 @@ export class Engine {
809
831
  this.castSeedViews = [null, null];
810
832
  this.castCoverageTexture = null;
811
833
  this.castCoverageView = null;
812
- this.castDistTexture = null;
813
- this.castDistView = null;
834
+ /**
835
+ * One field per distinct set of models — the seeds, the flood and the resolve
836
+ * run once for each.
837
+ *
838
+ * WHY IT CANNOT BE FILTERED AFTERWARDS. The field answers "how far is the
839
+ * nearest cast pixel", and that is a function of every pixel around this one.
840
+ * Ask it about a smaller cast and the answer is a different number, not a
841
+ * subset of the same one — the nearest pixel of the model you left out was the
842
+ * answer, and the true distance to the one you kept is unknown. An effect on
843
+ * one dancer would have its aura bitten out wherever the other one passed
844
+ * nearer. So a target set is a field.
845
+ *
846
+ * The PING-PONG IS SHARED, and that is most of the memory: the seeds and the
847
+ * coverage are scratch within one variant's chain, so the variants take turns
848
+ * in them and only the resolved r16float distance is per set. What a second set
849
+ * costs is the FLOOD — log2(resolution) full-res passes — which is the honest
850
+ * price of the second answer and the reason nothing here tries to be clever
851
+ * about it. A scene where every silhouette effect is aimed at the same models,
852
+ * which is nearly all of them, builds exactly one.
853
+ */
854
+ this.castDistanceVariants = [];
814
855
  /** 1x1 holding half-float 65504, bound whenever the pass is not running: an
815
856
  * effect keyed on distance then finds the cast unreachably far and draws
816
857
  * nothing, rather than the accessor being a name that does not exist. */
@@ -819,10 +860,12 @@ export class Engine {
819
860
  this.castSeedPipeline = null;
820
861
  this.castStepPipeline = null;
821
862
  this.castResolvePipeline = null;
822
- this.castSeedBindGroup = null;
823
863
  this.castStepBindGroups = [];
824
- this.castResolveBindGroup = null;
825
864
  this.castStepStrideBuffers = [];
865
+ /** Which of the two seed textures the last flood pass wrote — the one every
866
+ * variant's resolve reads. Parity of the pass count, recorded where the
867
+ * passes are counted. */
868
+ this.castResolveReadsSeed = 0;
826
869
  /** The visible props' object ids for the seed pass, count in slot 0. Grown in
827
870
  * powers of two; see writeCastSeedProps. */
828
871
  this.castSeedPropBuffer = null;
@@ -1030,6 +1073,9 @@ export class Engine {
1030
1073
  /** Subjects the cast actually holds, set while it is filled. The ribbons size
1031
1074
  * their instance count by this rather than by the four-subject cap. */
1032
1075
  this.castSubjectCount = 0;
1076
+ /** Model name → cast slot, rebuilt as the cast is written. What turns "this
1077
+ * effect is on 今汐" into a bit the shaders can read. */
1078
+ this.castSlotOf = new Map();
1033
1079
  this.effects = [];
1034
1080
  /** Just the header, for rewriting the total without touching a record. */
1035
1081
  this.lightHeader = new Float32Array(LIGHT_HEADER);
@@ -2520,19 +2566,15 @@ export class Engine {
2520
2566
  for (const t of this.castSeedTextures)
2521
2567
  t?.destroy();
2522
2568
  this.castCoverageTexture?.destroy();
2523
- this.castDistTexture?.destroy();
2524
2569
  for (const b of this.castStepStrideBuffers)
2525
2570
  b.destroy();
2571
+ this.releaseCastDistanceVariants();
2526
2572
  this.castSeedTextures = [null, null];
2527
2573
  this.castSeedViews = [null, null];
2528
2574
  this.castCoverageTexture = null;
2529
2575
  this.castCoverageView = null;
2530
- this.castDistTexture = null;
2531
- this.castDistView = null;
2532
2576
  this.castStepStrideBuffers = [];
2533
2577
  this.castStepBindGroups = [];
2534
- this.castSeedBindGroup = null;
2535
- this.castResolveBindGroup = null;
2536
2578
  if (!this.device || !this.castDistanceWanted || this.fieldFullW === 0)
2537
2579
  return;
2538
2580
  if (!this.castSeedPipeline || !this.castStepPipeline || !this.castResolvePipeline)
@@ -2555,20 +2597,12 @@ export class Engine {
2555
2597
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
2556
2598
  });
2557
2599
  this.castCoverageView = this.castCoverageTexture.createView();
2558
- this.castDistTexture = this.device.createTexture({
2559
- label: "cast distance",
2560
- size: [w, h],
2561
- format: CAST_DIST_FORMAT,
2562
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
2563
- });
2564
- this.castDistView = this.castDistTexture.createView();
2565
2600
  // The flood starts at half the longest side and halves to one. That is what
2566
2601
  // makes it exact everywhere rather than out to some radius: every seed gets
2567
2602
  // the chance to reach every texel it is nearest to.
2568
2603
  const strides = [];
2569
2604
  for (let k = 1 << Math.ceil(Math.log2(Math.max(w, h))); k >= 1; k >>= 1)
2570
2605
  strides.push(k);
2571
- this.createCastSeedBindGroup();
2572
2606
  strides.forEach((stride, i) => {
2573
2607
  const buf = this.device.createBuffer({
2574
2608
  label: `cast distance stride ${stride}`,
@@ -2588,18 +2622,38 @@ export class Engine {
2588
2622
  ],
2589
2623
  }));
2590
2624
  });
2591
- this.castResolveBindGroup = this.device.createBindGroup({
2592
- label: "cast distance resolve",
2593
- layout: this.castResolvePipeline.getBindGroupLayout(0),
2594
- entries: [
2595
- { binding: 0, resource: this.castSeedViews[strides.length % 2] },
2596
- { binding: 1, resource: this.castCoverageView },
2597
- ],
2598
- });
2625
+ // WHICH seed texture the resolve reads: the one the last flood pass wrote,
2626
+ // and that is the parity of the pass count. Shared by every variant, since
2627
+ // they take turns in the same pair.
2628
+ this.castResolveReadsSeed = strides.length % 2;
2629
+ this.buildCastDistanceVariants(w, h);
2630
+ }
2631
+ /** Every variant's own resources, gone. The shared scratch is not touched:
2632
+ * a target set changing does not resize the frame. */
2633
+ releaseCastDistanceVariants() {
2634
+ for (const v of this.castDistanceVariants) {
2635
+ v.texture.destroy();
2636
+ v.uniform.destroy();
2637
+ }
2638
+ this.castDistanceVariants = [];
2599
2639
  }
2600
- /** The seed pass's inputs: the id attachment, the cast, and the prop ids. */
2601
- createCastSeedBindGroup() {
2640
+ /**
2641
+ * One field per distinct target set among the effects that read one, and each
2642
+ * effect pointed at its own.
2643
+ *
2644
+ * Built from the effect list rather than accumulated, so a removed effect takes
2645
+ * its flood with it and the untargeted default is not kept alive by nobody.
2646
+ */
2647
+ buildCastDistanceVariants(w, h) {
2602
2648
  const device = this.device;
2649
+ // The seed pass reads the id attachment, so there is nothing to build before
2650
+ // the scene has one. Effects then read the 1x1 "unreachably far" fallback,
2651
+ // which is what an effect keyed on distance already sees while no flood is
2652
+ // running — and the next createFieldTargets builds the real thing.
2653
+ if (!this.idView || !this.castCoverageView) {
2654
+ this.releaseCastDistanceVariants();
2655
+ return;
2656
+ }
2603
2657
  if (!this.castSeedPropBuffer) {
2604
2658
  this.castSeedPropBuffer = device.createBuffer({
2605
2659
  label: "cast distance prop seeds",
@@ -2608,15 +2662,83 @@ export class Engine {
2608
2662
  });
2609
2663
  device.queue.writeBuffer(this.castSeedPropBuffer, 0, this.castSeedPropData);
2610
2664
  }
2611
- this.castSeedBindGroup = device.createBindGroup({
2612
- label: "cast distance seed",
2613
- layout: this.castSeedPipeline.getBindGroupLayout(0),
2614
- entries: [
2615
- { binding: 0, resource: this.idView },
2616
- { binding: 1, resource: { buffer: this.castBuffer } },
2617
- { binding: 2, resource: { buffer: this.castSeedPropBuffer } },
2618
- ],
2619
- });
2665
+ this.releaseCastDistanceVariants();
2666
+ for (const e of this.effects) {
2667
+ if (!e.readsCastDistance)
2668
+ continue;
2669
+ const key = castSubjectKey(e.subjects);
2670
+ const at = this.castDistanceVariants.findIndex((v) => castSubjectKey(v.subjects) === key);
2671
+ if (at >= 0) {
2672
+ e.distVariant = at;
2673
+ continue;
2674
+ }
2675
+ e.distVariant = this.castDistanceVariants.length;
2676
+ const data = new Float32Array([0xf, 0, 0, 0]);
2677
+ const uniform = device.createBuffer({
2678
+ label: `cast distance seeds (${key})`,
2679
+ size: data.byteLength,
2680
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2681
+ });
2682
+ device.queue.writeBuffer(uniform, 0, data.buffer);
2683
+ const texture = device.createTexture({
2684
+ label: `cast distance (${key})`,
2685
+ size: [w, h],
2686
+ format: CAST_DIST_FORMAT,
2687
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
2688
+ });
2689
+ this.castDistanceVariants.push({
2690
+ subjects: e.subjects,
2691
+ // 0xf is what the buffer above was written with; the first frame's
2692
+ // updateSubjectMasks narrows it to the live cast.
2693
+ mask: 0xf,
2694
+ uniform,
2695
+ data,
2696
+ seedBind: device.createBindGroup({
2697
+ label: `cast distance seed (${key})`,
2698
+ layout: this.castSeedPipeline.getBindGroupLayout(0),
2699
+ entries: [
2700
+ { binding: 0, resource: this.idView },
2701
+ { binding: 1, resource: { buffer: this.castBuffer } },
2702
+ { binding: 2, resource: { buffer: this.castSeedPropBuffer } },
2703
+ { binding: 3, resource: { buffer: uniform } },
2704
+ ],
2705
+ }),
2706
+ texture,
2707
+ view: texture.createView(),
2708
+ resolveBind: device.createBindGroup({
2709
+ label: `cast distance resolve (${key})`,
2710
+ layout: this.castResolvePipeline.getBindGroupLayout(0),
2711
+ entries: [
2712
+ { binding: 0, resource: this.castSeedViews[this.castResolveReadsSeed] },
2713
+ { binding: 1, resource: this.castCoverageView },
2714
+ ],
2715
+ }),
2716
+ });
2717
+ }
2718
+ }
2719
+ /**
2720
+ * The target sets changed — an effect was aimed somewhere else.
2721
+ *
2722
+ * Rebuilds the variants and the field bind groups that name their textures.
2723
+ * Nothing recompiles: which field an effect reads is a binding, not a constant
2724
+ * in its module.
2725
+ */
2726
+ syncCastDistanceVariants() {
2727
+ if (!this.device || !this.castDistanceWanted || this.fieldFullW === 0)
2728
+ return;
2729
+ const before = this.castDistanceVariants.map((v) => castSubjectKey(v.subjects)).join("|");
2730
+ const want = [...new Set(this.effects.filter((e) => e.readsCastDistance).map((e) => castSubjectKey(e.subjects)))].join("|");
2731
+ if (before === want)
2732
+ return;
2733
+ const w = Math.max(1, Math.ceil(this.fieldFullW / CAST_FIELD_DIV));
2734
+ const h = Math.max(1, Math.ceil(this.fieldFullH / CAST_FIELD_DIV));
2735
+ this.buildCastDistanceVariants(w, h);
2736
+ this.rebuildFieldBindGroup();
2737
+ }
2738
+ /** The field this effect reads, or the 1x1 "unreachably far" fallback while no
2739
+ * flood is running. */
2740
+ castDistViewFor(owner) {
2741
+ return this.castDistanceVariants[owner.distVariant]?.view ?? this.castDistFallbackView;
2620
2742
  }
2621
2743
  /**
2622
2744
  * Which props seed the field this frame: every visible one.
@@ -2637,7 +2759,10 @@ export class Engine {
2637
2759
  this.castSeedPropData = data;
2638
2760
  this.castSeedPropBuffer?.destroy();
2639
2761
  this.castSeedPropBuffer = null;
2640
- this.createCastSeedBindGroup();
2762
+ // Every variant's seed group names that buffer, so they are all rebuilt —
2763
+ // at the size the frame is now, which is where the shared scratch is.
2764
+ this.buildCastDistanceVariants(Math.max(1, Math.ceil(this.fieldFullW / CAST_FIELD_DIV)), Math.max(1, Math.ceil(this.fieldFullH / CAST_FIELD_DIV)));
2765
+ this.rebuildFieldBindGroup();
2641
2766
  changed = true;
2642
2767
  }
2643
2768
  data[0] = n;
@@ -2660,38 +2785,44 @@ export class Engine {
2660
2785
  * whole point of paying for it in passes rather than in per-pixel search.
2661
2786
  */
2662
2787
  encodeCastDistance(encoder) {
2663
- if (!this.castDistanceWanted || !this.castSeedBindGroup || !this.castResolveBindGroup)
2788
+ if (!this.castDistanceWanted || this.castDistanceVariants.length === 0)
2664
2789
  return;
2665
2790
  this.writeCastSeedProps();
2666
- const seed = encoder.beginRenderPass({
2667
- label: "cast distance (seed)",
2668
- colorAttachments: [
2669
- { view: this.castSeedViews[0], loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" },
2670
- { view: this.castCoverageView, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" },
2671
- ],
2672
- });
2673
- seed.setPipeline(this.castSeedPipeline);
2674
- seed.setBindGroup(0, this.castSeedBindGroup);
2675
- seed.draw(3);
2676
- seed.end();
2677
- this.castStepBindGroups.forEach((group, i) => {
2678
- const pass = encoder.beginRenderPass({
2679
- label: "cast distance (flood)",
2680
- colorAttachments: [{ view: this.castSeedViews[(i + 1) % 2], loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" }],
2791
+ // One chain per target set, in turn through the shared seed pair. Serial and
2792
+ // not overlapped on purpose: the second chain overwrites the first's scratch,
2793
+ // and the only thing that has to survive is the resolved distance, which is
2794
+ // each variant's own texture.
2795
+ for (const v of this.castDistanceVariants) {
2796
+ const seed = encoder.beginRenderPass({
2797
+ label: "cast distance (seed)",
2798
+ colorAttachments: [
2799
+ { view: this.castSeedViews[0], loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" },
2800
+ { view: this.castCoverageView, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" },
2801
+ ],
2681
2802
  });
2682
- pass.setPipeline(this.castStepPipeline);
2683
- pass.setBindGroup(0, group);
2684
- pass.draw(3);
2685
- pass.end();
2686
- });
2687
- const resolve = encoder.beginRenderPass({
2688
- label: "cast distance (resolve)",
2689
- colorAttachments: [{ view: this.castDistView, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" }],
2690
- });
2691
- resolve.setPipeline(this.castResolvePipeline);
2692
- resolve.setBindGroup(0, this.castResolveBindGroup);
2693
- resolve.draw(3);
2694
- resolve.end();
2803
+ seed.setPipeline(this.castSeedPipeline);
2804
+ seed.setBindGroup(0, v.seedBind);
2805
+ seed.draw(3);
2806
+ seed.end();
2807
+ this.castStepBindGroups.forEach((group, i) => {
2808
+ const pass = encoder.beginRenderPass({
2809
+ label: "cast distance (flood)",
2810
+ colorAttachments: [{ view: this.castSeedViews[(i + 1) % 2], loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" }],
2811
+ });
2812
+ pass.setPipeline(this.castStepPipeline);
2813
+ pass.setBindGroup(0, group);
2814
+ pass.draw(3);
2815
+ pass.end();
2816
+ });
2817
+ const resolve = encoder.beginRenderPass({
2818
+ label: "cast distance (resolve)",
2819
+ colorAttachments: [{ view: v.view, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" }],
2820
+ });
2821
+ resolve.setPipeline(this.castResolvePipeline);
2822
+ resolve.setBindGroup(0, v.resolveBind);
2823
+ resolve.draw(3);
2824
+ resolve.end();
2825
+ }
2695
2826
  }
2696
2827
  createFieldTargets() {
2697
2828
  if (!this.device || this.fieldFullW === 0)
@@ -2804,7 +2935,7 @@ export class Engine {
2804
2935
  ...(this.idView ? [{ binding: 23, resource: this.idView }] : []),
2805
2936
  { binding: 17, resource: grid },
2806
2937
  { binding: 18, resource: this.simSampler },
2807
- { binding: 26, resource: this.castDistView ?? this.castDistFallbackView },
2938
+ { binding: 26, resource: this.castDistViewFor(owner) },
2808
2939
  { binding: 27, resource: this.hdrResolveTexture.createView() },
2809
2940
  { binding: 28, resource: this.simSampler },
2810
2941
  // The other effects' layers, for rzSceneFrame — see the loop below.
@@ -3121,7 +3252,7 @@ export class Engine {
3121
3252
  alias) {
3122
3253
  const noMounts = { background: false, foreground: false };
3123
3254
  if (!this.device)
3124
- return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts, params: [], duration: 0 };
3255
+ return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts, params: [], duration: 0, readsCast: false };
3125
3256
  // WHAT THE FILE DECLARES, read once. Everything below takes it from `d`
3126
3257
  // rather than running a regex of its own — eight parsers over one file was
3127
3258
  // eight chances to disagree about what it said, and they did.
@@ -3132,7 +3263,7 @@ export class Engine {
3132
3263
  // typo was indistinguishable from prose and could only ever be warned about.
3133
3264
  const parsed = parseDirectives(authored);
3134
3265
  if (parsed.errors.length)
3135
- return { ok: false, diagnostics: parsed.errors, mounts: noMounts, params: [], duration: 0 };
3266
+ return { ok: false, diagnostics: parsed.errors, mounts: noMounts, params: [], duration: 0, readsCast: false };
3136
3267
  const d = parsed.directives;
3137
3268
  // The compiler sees the file with its directive lines BLANKED, so every
3138
3269
  // diagnostic below still names the line the author is looking at.
@@ -3155,7 +3286,7 @@ export class Engine {
3155
3286
  return { ok: false, diagnostics: [
3156
3287
  `a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
3157
3288
  `fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
3158
- ], mounts: noMounts, params: [], duration: 0 };
3289
+ ], mounts: noMounts, params: [], duration: 0, readsCast: false };
3159
3290
  }
3160
3291
  if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
3161
3292
  const missing = [
@@ -3163,7 +3294,7 @@ export class Engine {
3163
3294
  pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
3164
3295
  pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
3165
3296
  ].filter(Boolean);
3166
- return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts, params: [], duration: 0 };
3297
+ return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts, params: [], duration: 0, readsCast: false };
3167
3298
  }
3168
3299
  // One file, one kind — for now.
3169
3300
  //
@@ -3181,7 +3312,7 @@ export class Engine {
3181
3312
  return { ok: false, diagnostics: [
3182
3313
  "an effect declares field mounts (background/foreground) or particles, not both — " +
3183
3314
  "split them into two effects",
3184
- ], mounts: noMounts, params: [], duration: 0 };
3315
+ ], mounts: noMounts, params: [], duration: 0, readsCast: false };
3185
3316
  }
3186
3317
  // lightEmit counts as a mount on its own: a pure lighting rig draws nothing
3187
3318
  // and is still an effect — it is how a scene gets stage lights without also
@@ -3197,7 +3328,7 @@ export class Engine {
3197
3328
  "the ribbon pair (trailWidth/trailShade), " +
3198
3329
  "fn lightEmit(i: u32) -> RzLight with #lights <n>, " +
3199
3330
  "or #mirror",
3200
- ], mounts: noMounts, params: [], duration: 0 };
3331
+ ], mounts: noMounts, params: [], duration: 0, readsCast: false };
3201
3332
  }
3202
3333
  const mounts = { background: hasBackground, foreground: hasForeground };
3203
3334
  // ── Directives only some mounts honour ──
@@ -3249,7 +3380,7 @@ export class Engine {
3249
3380
  let cursor = 0;
3250
3381
  for (const p of d.params) {
3251
3382
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(p.name)) {
3252
- return { ok: false, diagnostics: [`invalid param name "${p.name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration };
3383
+ return { ok: false, diagnostics: [`invalid param name "${p.name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration, readsCast: false };
3253
3384
  }
3254
3385
  // KIND comes from the declaration, never from the runtime type of the
3255
3386
  // value: a caller handing a number to a colour must not silently turn a
@@ -3305,7 +3436,7 @@ export class Engine {
3305
3436
  const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source });
3306
3437
  const scopeErr = await this.device.popErrorScope();
3307
3438
  if (scopeErr)
3308
- return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration };
3439
+ return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration, readsCast: false };
3309
3440
  // Declared like every other mount property: by what the source says, not by
3310
3441
  // a setting somewhere else that an author cannot see from the file.
3311
3442
  const layerBlend = d.additiveLayer
@@ -3325,7 +3456,7 @@ export class Engine {
3325
3456
  if (diagnostics.length === 0 && fieldScopeErr)
3326
3457
  diagnostics.push(fieldScopeErr.message);
3327
3458
  if (diagnostics.length > 0)
3328
- return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration };
3459
+ return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false };
3329
3460
  try {
3330
3461
  fieldPipeline = await this.device.createRenderPipelineAsync({
3331
3462
  label: "field layer pipeline",
@@ -3357,7 +3488,7 @@ export class Engine {
3357
3488
  });
3358
3489
  }
3359
3490
  catch (e) {
3360
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration };
3491
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false };
3361
3492
  }
3362
3493
  }
3363
3494
  let identity;
@@ -3381,7 +3512,7 @@ export class Engine {
3381
3512
  ]);
3382
3513
  }
3383
3514
  catch (e) {
3384
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration };
3515
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false };
3385
3516
  }
3386
3517
  // Built BEFORE the swap: a particle stage that fails to compile has to leave
3387
3518
  // the previously installed effect running, exactly as a bad composite does.
@@ -3432,7 +3563,7 @@ export class Engine {
3432
3563
  grid?.textures[1].destroy();
3433
3564
  grid?.uniform.destroy();
3434
3565
  trails?.uniform.destroy();
3435
- return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration };
3566
+ return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false };
3436
3567
  };
3437
3568
  if (wantsParticles) {
3438
3569
  const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor);
@@ -3505,6 +3636,19 @@ export class Engine {
3505
3636
  // attachment would be stored exactly as often as before.
3506
3637
  readsIds: /\brz(?:ObjectAt|MaterialAt)\s*\(/.test(wgsl),
3507
3638
  readsCastDistance: castDistanceUsed(wgsl),
3639
+ // Every route to the cast, in one test, and the author's own source for
3640
+ // the reason readsIds gives. `#anchor` counts: a file declaring one asks
3641
+ // the engine to record a bone for it, whether or not the accessor is
3642
+ // spelled out in a line this matches.
3643
+ readsCast: /\brz(?:Subject|SubjectCount|SubjectId|Anchor|Trail|TrailCount|CastDistance)\s*\(/.test(wgsl) ||
3644
+ anchors.length > 0,
3645
+ // Aimed at nobody in particular — the whole cast, which is what every
3646
+ // effect did before it could be aimed. A host narrows it afterwards, the
3647
+ // way it sets influence and a schedule.
3648
+ subjects: null,
3649
+ subjectMask: 0xf,
3650
+ subjectCount: 0,
3651
+ distVariant: 0,
3508
3652
  anchors,
3509
3653
  // The effect's own clock starts now. Per effect so that one installed
3510
3654
  // later still gets a frame where rzGridFrame() is 0 and can seed.
@@ -3583,7 +3727,7 @@ export class Engine {
3583
3727
  if (!this.device) {
3584
3728
  if (!list || list.length === 0)
3585
3729
  return [];
3586
- return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }];
3730
+ return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0, readsCast: false }];
3587
3731
  }
3588
3732
  const requested = list ?? [];
3589
3733
  if (requested.length === 0) {
@@ -3624,6 +3768,7 @@ export class Engine {
3624
3768
  results.push(built);
3625
3769
  continue;
3626
3770
  }
3771
+ built.instance.subjects = normalizeSubjects(requested[i].subjects);
3627
3772
  instances.push(built.instance);
3628
3773
  results.push({
3629
3774
  ok: true,
@@ -3633,6 +3778,9 @@ export class Engine {
3633
3778
  // but will never fire. Same channel as the dropped-anchor note below.
3634
3779
  diagnostics: built.warnings,
3635
3780
  mounts: { background: built.instance.hasBackground, foreground: built.instance.hasForeground },
3781
+ // Whether aiming this one at particular models means anything. Read off
3782
+ // the instance the engine just built, not off a second parse.
3783
+ readsCast: built.instance.readsCast,
3636
3784
  });
3637
3785
  }
3638
3786
  // An anchor the cap refused is worth saying out loud on the effect that
@@ -3665,6 +3813,12 @@ export class Engine {
3665
3813
  // The field bind groups name the distance texture, and it has just been
3666
3814
  // created or destroyed — they are rebuilt below with the new list anyway.
3667
3815
  }
3816
+ else {
3817
+ // The answer did not change, but WHO the fields are built from may have:
3818
+ // this list's effects are aimed wherever the install said, and the old
3819
+ // list's variants belong to effects that are gone.
3820
+ this.syncCastDistanceVariants();
3821
+ }
3668
3822
  // The new list's emitters need their slots before the next frame reads them.
3669
3823
  this.allocateLightSlots();
3670
3824
  // A rig the cap refused is worth saying out loud on the effect that asked
@@ -3722,10 +3876,10 @@ export class Engine {
3722
3876
  const noMounts = { background: false, foreground: false };
3723
3877
  if (wgsl === null) {
3724
3878
  await this.setEffects(null);
3725
- return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0 };
3879
+ return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0, readsCast: false };
3726
3880
  }
3727
3881
  const [result] = await this.setEffects([{ wgsl, params }]);
3728
- return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts, params: [], duration: 0 };
3882
+ return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts, params: [], duration: 0, readsCast: false };
3729
3883
  }
3730
3884
  async buildParticles(
3731
3885
  /** Already stripped of directives — see compileEffect. */
@@ -3782,7 +3936,7 @@ export class Engine {
3782
3936
  });
3783
3937
  const uniform = this.device.createBuffer({
3784
3938
  label: "particle uniforms",
3785
- // Two vec4-sized rows: (time, dt, count, frame) and (weight, _, _, _).
3939
+ // Two vec4-sized rows: (time, dt, count, frame) and (weight, subjects, _, _).
3786
3940
  // The first was exactly full, and weight has to live in the same buffer
3787
3941
  // as the clock or a frame could draw one without the other.
3788
3942
  size: 32,
@@ -3930,7 +4084,10 @@ export class Engine {
3930
4084
  const diagnostics = info.messages.filter((m) => m.type === "error").map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
3931
4085
  if (diagnostics.length)
3932
4086
  return { ok: false, diagnostics };
3933
- const data = new Float32Array(4);
4087
+ // Two vec4s: (time, base slot, count, weight) and the subject mask — an
4088
+ // emitter reads the cast through the same accessors its drawing half does,
4089
+ // so it needs the same answer about who this effect is on.
4090
+ const data = new Float32Array(8);
3934
4091
  const uniform = this.device.createBuffer({
3935
4092
  label: "light emit uniform",
3936
4093
  size: data.byteLength,
@@ -4022,6 +4179,9 @@ export class Engine {
4022
4179
  // per effect now, so every mount in one file agrees by construction.
4023
4180
  l.data[0] = this.sceneClock - e.epochScene;
4024
4181
  l.data[3] = e.weight;
4182
+ // Which characters this rig is on — the second vec4. A lamp that follows a
4183
+ // hand follows the hand of the model the effect is aimed at.
4184
+ l.data[4] = e.subjectMask;
4025
4185
  this.device.queue.writeBuffer(l.uniform, 0, l.data.buffer);
4026
4186
  const cp = encoder.beginComputePass({ label: "light emit" });
4027
4187
  cp.setPipeline(l.pipeline);
@@ -4051,6 +4211,9 @@ export class Engine {
4051
4211
  // state it left rather than the one it would have reached, so fading one
4052
4212
  // back in would rewind it.
4053
4213
  p.data[4] = e.weight;
4214
+ // Which characters this pool spawns off — rzSubjectCount() in particleInit
4215
+ // counts these, not the cast.
4216
+ p.data[5] = e.subjectMask;
4054
4217
  // Clamped: a backgrounded tab returns with a delta of whole seconds, and an
4055
4218
  // unclamped step flings every particle out of the scene in one frame.
4056
4219
  // A scheduled effect steps by the transport instead, clamped the same way
@@ -4269,11 +4432,16 @@ export class Engine {
4269
4432
  // The shader decodes [ribbon][subject][segment] with the same number out
4270
4433
  // of its uniform, so the two cannot drift: change one without the other
4271
4434
  // and ribbons land on the wrong subject rather than merely costing more.
4272
- const live = Math.max(1, this.castSubjectCount);
4435
+ // THIS EFFECT'S subjects, not the scene's: a ribbon aimed at one dancer
4436
+ // draws one ribbon. The shader decodes [ribbon][subject][segment] out of
4437
+ // the same number, and the subject it decodes is the effect's own index —
4438
+ // which is exactly what rzTrail takes.
4439
+ const live = Math.max(1, e.subjectCount);
4273
4440
  if (view === "camera") {
4274
4441
  t.data[0] = this.sceneClock - e.epochScene;
4275
4442
  t.data[1] = live;
4276
4443
  t.data[2] = e.weight;
4444
+ t.data[3] = e.subjectMask;
4277
4445
  this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer);
4278
4446
  }
4279
4447
  pass.setPipeline(t.pipeline);
@@ -4312,6 +4480,10 @@ export class Engine {
4312
4480
  continue;
4313
4481
  this.fieldClockScratch[0] = this.sceneClock - e.epochScene;
4314
4482
  this.fieldClockScratch[1] = e.weight;
4483
+ // Which characters this effect is on. In the clock block because that is
4484
+ // the one uniform a field mount already has per effect, and both are the
4485
+ // same kind of thing: what is true of THIS effect this frame.
4486
+ this.fieldClockScratch[2] = e.subjectMask;
4315
4487
  this.device.queue.writeBuffer(e.fieldClock, 0, this.fieldClockScratch.buffer);
4316
4488
  }
4317
4489
  // ONE PASS PER RESOLUTION, N draws each, in document order — a pair is
@@ -4509,7 +4681,9 @@ export class Engine {
4509
4681
  const read = [textures[0].createView(), textures[1].createView()];
4510
4682
  const uniform = this.device.createBuffer({
4511
4683
  label: "grid uniforms",
4512
- size: 16,
4684
+ // Two vec4s: (time, dt, size, frame) and the subject mask, which WGSL pads
4685
+ // out to the second one whether or not anything else joins it.
4686
+ size: 32,
4513
4687
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
4514
4688
  });
4515
4689
  this.device.pushErrorScope("validation");
@@ -4538,7 +4712,7 @@ export class Engine {
4538
4712
  layout,
4539
4713
  binds: [bindFor(0), bindFor(1)],
4540
4714
  uniform,
4541
- data: new Float32Array(4),
4715
+ data: new Float32Array(8),
4542
4716
  parity: 0,
4543
4717
  frame: 0,
4544
4718
  params: params.buffer,
@@ -4597,6 +4771,7 @@ export class Engine {
4597
4771
  grid.data[1] = scheduled ? e.simStep : Math.min(0.1, Math.max(0, deltaTime));
4598
4772
  grid.data[2] = grid.size;
4599
4773
  grid.data[3] = grid.frame++;
4774
+ grid.data[4] = e.subjectMask;
4600
4775
  this.device.queue.writeBuffer(grid.uniform, 0, grid.data.buffer);
4601
4776
  const cp = encoder.beginComputePass({ label: "grid" });
4602
4777
  cp.setPipeline(grid.pipeline);
@@ -4671,6 +4846,45 @@ export class Engine {
4671
4846
  getEffectInfluence(index) {
4672
4847
  return this.effects[index]?.influence ?? 0;
4673
4848
  }
4849
+ /**
4850
+ * WHICH MODELS one effect is on, by name. Null is the whole cast, which is
4851
+ * what an effect starts as and what every effect did before this existed.
4852
+ *
4853
+ * The filter lands in the INDEX SPACE the shaders read, not in the shaders: the
4854
+ * effect's rzSubjectCount() counts only the models named, and rzSubject(0),
4855
+ * rzAnchor(0, …) and rzTrail(0, …) are the first of them. So a file written
4856
+ * against the whole cast — every shipped built-in — is aimed by this without a
4857
+ * line changing, and one that hardcodes subject 0 follows the model it is aimed
4858
+ * at rather than whoever happened to load first.
4859
+ *
4860
+ * Names, because a cast SLOT is not a property of a model: the cast is every
4861
+ * visible character in load order, so hiding one moves everybody after them up
4862
+ * a slot. They resolve in the loop that assigns the slots, once a frame.
4863
+ *
4864
+ * A name the cast does not hold is simply not in the mask — a model still
4865
+ * loading, or one since removed. It is not an error and not a warning: the
4866
+ * cast changes under a scene all the time, and a target is a claim about the
4867
+ * scene rather than about this frame of it.
4868
+ *
4869
+ * Aiming an effect that reads no cast at all (rain, a glitch, a tone curve)
4870
+ * does nothing, and that is why the install reports `readsCast` — so a host
4871
+ * can decline to offer the control rather than offer one that does nothing.
4872
+ */
4873
+ setEffectSubjects(index, models) {
4874
+ const fx = this.effects[index];
4875
+ if (!fx)
4876
+ return;
4877
+ fx.subjects = normalizeSubjects(models);
4878
+ // The distance field is built per distinct target set, so changing one may
4879
+ // add or drop a whole flood — and the field bind groups name the texture it
4880
+ // resolves into.
4881
+ if (fx.readsCastDistance)
4882
+ this.syncCastDistanceVariants();
4883
+ }
4884
+ /** What one effect is aimed at, or null for the whole cast. */
4885
+ getEffectSubjects(index) {
4886
+ return this.effects[index]?.subjects ?? null;
4887
+ }
4674
4888
  /**
4675
4889
  * Schedule one instance: when it is alive, and how it enters and leaves.
4676
4890
  *
@@ -9019,6 +9233,41 @@ export class Engine {
9019
9233
  * It projects boneMarkerPositions, the same points the overlay draws markers
9020
9234
  * at, so the hit box cannot drift away from the circle you are aiming at.
9021
9235
  */
9236
+ /**
9237
+ * Where a world point lands on the canvas, in CSS pixels, or null when it is
9238
+ * behind the camera.
9239
+ *
9240
+ * THE SAME PROJECTION pickBone hit-tests with, deliberately: a host that draws
9241
+ * its own markers over the canvas — a DOM icon per lamp, a label on a bone —
9242
+ * has to agree with the engine about where a thing IS, and a second
9243
+ * derivation of the view-projection is a second thing to keep in step. The
9244
+ * pixels are CSS pixels relative to the canvas, which is the space a
9245
+ * MouseEvent speaks once getBoundingClientRect is subtracted, and the space
9246
+ * an absolutely positioned element wants.
9247
+ *
9248
+ * `depth` is the clip w — distance along the view axis, for sorting overlapping
9249
+ * markers front to back. Nothing here tests occlusion: a marker for something
9250
+ * buried inside geometry is usually the marker you most need to find.
9251
+ */
9252
+ worldToScreen(p) {
9253
+ if (!this.camera)
9254
+ return null;
9255
+ const width = this.canvas.clientWidth;
9256
+ const height = this.canvas.clientHeight;
9257
+ if (width <= 0 || height <= 0)
9258
+ return null;
9259
+ const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values;
9260
+ const cw = vp[3] * p.x + vp[7] * p.y + vp[11] * p.z + vp[15];
9261
+ if (cw <= 1e-6)
9262
+ return null;
9263
+ const cx = vp[0] * p.x + vp[4] * p.y + vp[8] * p.z + vp[12];
9264
+ const cy = vp[1] * p.x + vp[5] * p.y + vp[9] * p.z + vp[13];
9265
+ return {
9266
+ x: ((cx / cw) * 0.5 + 0.5) * width,
9267
+ y: (1 - ((cy / cw) * 0.5 + 0.5)) * height,
9268
+ depth: cw,
9269
+ };
9270
+ }
9022
9271
  pickBone(x, y, options = {}) {
9023
9272
  if (!this.camera)
9024
9273
  return null;
@@ -13320,15 +13569,22 @@ export class Engine {
13320
13569
  return true;
13321
13570
  }
13322
13571
  /** The model at one cast slot — the subject rzSubject(index) reads. */
13323
- castSubjectName(index) {
13572
+ castSubjectName(index, subjects = null) {
13324
13573
  let n = 0;
13574
+ let local = 0;
13325
13575
  let found = null;
13326
13576
  this.forEachInstance((inst) => {
13327
13577
  if (found !== null || n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp || !inst.model.visible)
13328
13578
  return;
13329
- if (n === index)
13330
- found = inst.name;
13331
13579
  n++;
13580
+ // The effect's OWN index, which is what its shaders count in: an effect
13581
+ // aimed at the third dancer finds her at 0, so a #dissolve on it takes HER
13582
+ // apart rather than whoever stands at the head of the cast.
13583
+ if (subjects && !subjects.includes(inst.name))
13584
+ return;
13585
+ if (local === index)
13586
+ found = inst.name;
13587
+ local++;
13332
13588
  });
13333
13589
  return found;
13334
13590
  }
@@ -13350,9 +13606,10 @@ export class Engine {
13350
13606
  *
13351
13607
  * Two things ask for one. A cycle set on a model (setModelDissolveCycle) runs
13352
13608
  * on the scene clock and repeats, as it always has. An effect declaring
13353
- * `#dissolve` takes subject 0 apart on ITS OWN terms: scheduled, the cycle on
13354
- * each clip's clock with her whole between clips; unscheduled, the same
13355
- * repeating cycle as before. Where both ask, the more dissolved wins — two
13609
+ * `#dissolve` takes ITS OWN subject 0 apart on its own terms — the first model
13610
+ * it is aimed at, the head of the cast when it is aimed at nobody in
13611
+ * particular: scheduled, the cycle on each clip's clock with her whole between
13612
+ * clips; unscheduled, the same repeating cycle as before. Where both ask, the more dissolved wins — two
13356
13613
  * answers about one body, and half a body is not one of them.
13357
13614
  */
13358
13615
  evaluateDissolves() {
@@ -13365,7 +13622,7 @@ export class Engine {
13365
13622
  if (!fx.dissolve)
13366
13623
  continue;
13367
13624
  const cycle = dissolveCycleOf(this.dissolveTimingsOf(fx, fx.dissolve));
13368
- const subject = this.castSubjectName(0);
13625
+ const subject = this.castSubjectName(0, fx.subjects);
13369
13626
  if (!cycle || !subject)
13370
13627
  continue;
13371
13628
  if (fx.window && fx.window.length > 0) {
@@ -14078,10 +14335,17 @@ export class Engine {
14078
14335
  // spawned its motes off a body nobody could see, and the silhouette field
14079
14336
  // seeded on one. The cast is who is ON STAGE.
14080
14337
  let n = 0;
14338
+ this.castSlotOf.clear();
14081
14339
  this.forEachInstance((inst) => {
14082
14340
  if (n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp || !inst.model.visible)
14083
14341
  return;
14084
14342
  const m = inst.model;
14343
+ // WHO IS IN WHICH SLOT, recorded here because here is where it is
14344
+ // decided. An effect is aimed by model name, and a name only becomes a
14345
+ // bit in a mask once this loop has said where that model sits — a slot
14346
+ // is a position in the cast, not a property of a model, and a hidden
14347
+ // model moves everybody after it up one.
14348
+ this.castSlotOf.set(inst.name, n);
14085
14349
  // The model transform is only where the model was PLACED. A motion moves
14086
14350
  // the character by animating bones, so an effect anchored to the
14087
14351
  // transform never follows anyone anywhere — it sits at the spawn point
@@ -14113,6 +14377,8 @@ export class Engine {
14113
14377
  // drawTrails. Recorded rather than recomputed: this loop is the one place
14114
14378
  // that knows how many subjects the cast actually ended up holding.
14115
14379
  this.castSubjectCount = n;
14380
+ // Each effect's own view of that cast, before any mount reads it.
14381
+ this.updateSubjectMasks();
14116
14382
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
14117
14383
  // Only what an effect declared, and only while one is installed. A scene
14118
14384
  // with no effect writes nothing here at all.
@@ -14132,6 +14398,63 @@ export class Engine {
14132
14398
  }
14133
14399
  }
14134
14400
  }
14401
+ /**
14402
+ * Every effect's own view of the cast, once a frame, right after the slots are
14403
+ * assigned and before any mount reads them.
14404
+ *
14405
+ * The mask is what the shaders see: CAST_API counts its bits for
14406
+ * rzSubjectCount() and walks them for rzSubject(i), so an effect aimed at the
14407
+ * third dancer alone finds her at index 0. `subjectCount` is the same number on
14408
+ * the CPU, for the one thing that needs it there — the ribbons' instance count,
14409
+ * which must agree with what the trail shader decodes or a ribbon lands on the
14410
+ * wrong body.
14411
+ *
14412
+ * Recomputed rather than cached against a version: it is four models, a map
14413
+ * lookup each, and the alternative is a cache that has to be invalidated from
14414
+ * every place a model can be added, removed, hidden or renamed.
14415
+ */
14416
+ updateSubjectMasks() {
14417
+ const live = this.castSubjectCount;
14418
+ const all = live === 0 ? 0 : (1 << live) - 1;
14419
+ for (const e of this.effects) {
14420
+ let mask = 0xf;
14421
+ if (e.subjects) {
14422
+ mask = 0;
14423
+ for (const name of e.subjects) {
14424
+ const slot = this.castSlotOf.get(name);
14425
+ if (slot !== undefined)
14426
+ mask |= 1 << slot;
14427
+ }
14428
+ }
14429
+ e.subjectMask = mask;
14430
+ let n = 0;
14431
+ for (let i = 0; i < live; i++)
14432
+ if (mask & (1 << i))
14433
+ n++;
14434
+ e.subjectCount = n;
14435
+ }
14436
+ // The floods, one per distinct target set. Their masks are written here for
14437
+ // the same reason the effects' are: the slots have just been decided.
14438
+ for (const v of this.castDistanceVariants) {
14439
+ let mask = 0xf;
14440
+ if (v.subjects) {
14441
+ mask = 0;
14442
+ for (const name of v.subjects) {
14443
+ const slot = this.castSlotOf.get(name);
14444
+ if (slot !== undefined)
14445
+ mask |= 1 << slot;
14446
+ }
14447
+ }
14448
+ // Bounded by the live cast, so a mask naming a slot nobody occupies cannot
14449
+ // seed off a stale entry in the buffer.
14450
+ const want = mask & all;
14451
+ if (v.mask === want)
14452
+ continue;
14453
+ v.mask = want;
14454
+ v.data[0] = want;
14455
+ this.device.queue.writeBuffer(v.uniform, 0, v.data.buffer);
14456
+ }
14457
+ }
14135
14458
  /**
14136
14459
  * One character's slice of the effect API's view of the cast.
14137
14460
  *