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/src/engine.ts CHANGED
@@ -481,6 +481,19 @@ export type EffectResult = {
481
481
  * the host does with an effect should come from the effect, not from a
482
482
  * second parse that can drift from it. */
483
483
  duration: number
484
+ /**
485
+ * Does this effect read the CAST — a subject, a bone anchor, a trail, the
486
+ * distance field?
487
+ *
488
+ * What decides whether aiming it at particular models means anything, and so
489
+ * whether a host should offer that control at all. Rain falls on the scene and
490
+ * a glitch is on the lens; a ribbon, a sigil or a silhouette is on somebody.
491
+ *
492
+ * Reported here for the same reason `params` is: the engine has already read
493
+ * the source to build the module, and a host re-deriving this from the same
494
+ * text is a second answer free to disagree with the one that renders.
495
+ */
496
+ readsCast: boolean
484
497
  }
485
498
 
486
499
  type CameraOptions = {
@@ -1359,6 +1372,29 @@ function paramValue(p: EffectParamDecl, given: EffectParamValue | undefined): Ef
1359
1372
  return typeof p.value === "number" ? p.value : 0
1360
1373
  }
1361
1374
 
1375
+ /**
1376
+ * Which models an effect is on, as it is kept: names, deduplicated, or null for
1377
+ * the whole cast.
1378
+ *
1379
+ * AN EMPTY LIST IS THE WHOLE CAST, not nobody. An effect on nobody is a dark
1380
+ * effect with no sign of why, and there are already two ways to say that on
1381
+ * purpose — influence 0 and a schedule with no window here. A host that filters
1382
+ * a target list down to nothing (every named model deleted, say) gets the
1383
+ * default back instead of a scene with a mystery in it.
1384
+ */
1385
+ function normalizeSubjects(list: readonly string[] | null | undefined): readonly string[] | null {
1386
+ if (!list || list.length === 0) return null
1387
+ const seen = [...new Set(list.filter((n) => typeof n === "string" && n.length > 0))]
1388
+ return seen.length === 0 ? null : seen
1389
+ }
1390
+
1391
+ /** One target set as a comparable string — `*` is the whole cast. Sorted, so two
1392
+ * effects naming the same models in a different order share one distance field
1393
+ * rather than paying for the same flood twice. */
1394
+ function castSubjectKey(subjects: readonly string[] | null): string {
1395
+ return subjects ? [...subjects].sort().join("\u0000") : "*"
1396
+ }
1397
+
1362
1398
  const EFFECT_PARAMS_BINDING = 7
1363
1399
  const EFFECT_PARAMS_BINDING_GRID = 9
1364
1400
 
@@ -1419,6 +1455,25 @@ interface EffectGrid {
1419
1455
  params: GPUBuffer | null
1420
1456
  }
1421
1457
 
1458
+ /**
1459
+ * One distance-to-cast field: the flood for ONE set of models.
1460
+ *
1461
+ * `subjects` is the key effects are matched on — null for the whole cast, which
1462
+ * is what an unaimed effect reads and what every scene had before an effect could
1463
+ * be aimed. Two effects on the same models share the field and its cost.
1464
+ */
1465
+ interface CastDistanceVariant {
1466
+ subjects: readonly string[] | null
1467
+ /** The seed mask this holds, so a frame that changed nothing writes nothing. */
1468
+ mask: number
1469
+ uniform: GPUBuffer
1470
+ data: Float32Array
1471
+ seedBind: GPUBindGroup
1472
+ texture: GPUTexture
1473
+ view: GPUTextureView
1474
+ resolveBind: GPUBindGroup
1475
+ }
1476
+
1422
1477
  /**
1423
1478
  * One effect's ribbons, or null when it declared none.
1424
1479
  *
@@ -1541,6 +1596,35 @@ interface EffectInstance {
1541
1596
  /** Does this source read the distance-to-cast field? Parsed at install for the
1542
1597
  * same reason readsIds is, and it turns the whole flood on by itself. */
1543
1598
  readsCastDistance: boolean
1599
+ /** Does this source read the cast AT ALL — a subject, an anchor, a trail, the
1600
+ * distance field? Reported back at install, because it is what decides
1601
+ * whether aiming this effect at particular models means anything: Rain falls
1602
+ * on the scene, not on anybody. */
1603
+ readsCast: boolean
1604
+ /**
1605
+ * WHICH MODELS this effect is on, by name, or null for the whole cast.
1606
+ *
1607
+ * Names rather than cast slots, because a slot is not a property of a model:
1608
+ * the cast is every visible character in load order, so hiding one moves
1609
+ * everybody after them up. Resolved to a mask in the one loop that assigns the
1610
+ * slots (see updateCastBuffer), which is what makes the two impossible to
1611
+ * disagree.
1612
+ *
1613
+ * A name that is not in the cast is simply not in the mask — a model still
1614
+ * loading, or one the scene has since removed. An effect whose every named
1615
+ * model is gone sees no subjects and draws nothing, which is the honest answer
1616
+ * and a visible one: the host's own list shows what it is aimed at.
1617
+ */
1618
+ subjects: readonly string[] | null
1619
+ /** The above as the shaders read it: one bit per cast slot, recomputed every
1620
+ * frame. 0xf — the whole cast — while `subjects` is null. */
1621
+ subjectMask: number
1622
+ /** How many subjects this effect actually HAS this frame: the mask's bits,
1623
+ * counted against the live cast. What sizes the ribbons' instance count. */
1624
+ subjectCount: number
1625
+ /** Which distance field this effect reads, indexed into castDistanceVariants.
1626
+ * Effects aimed at the same models share one flood. */
1627
+ distVariant: number
1544
1628
  /** Bones this source asked for, in ITS OWN declaration order. The scene table
1545
1629
  * maps these onto shared addresses; this list is what it is rebuilt from. */
1546
1630
  anchors: { bone: string; trail: boolean }[]
@@ -2022,8 +2106,27 @@ export class Engine {
2022
2106
  private castSeedViews: (GPUTextureView | null)[] = [null, null]
2023
2107
  private castCoverageTexture: GPUTexture | null = null
2024
2108
  private castCoverageView: GPUTextureView | null = null
2025
- private castDistTexture: GPUTexture | null = null
2026
- private castDistView: GPUTextureView | null = null
2109
+ /**
2110
+ * One field per distinct set of models — the seeds, the flood and the resolve
2111
+ * run once for each.
2112
+ *
2113
+ * WHY IT CANNOT BE FILTERED AFTERWARDS. The field answers "how far is the
2114
+ * nearest cast pixel", and that is a function of every pixel around this one.
2115
+ * Ask it about a smaller cast and the answer is a different number, not a
2116
+ * subset of the same one — the nearest pixel of the model you left out was the
2117
+ * answer, and the true distance to the one you kept is unknown. An effect on
2118
+ * one dancer would have its aura bitten out wherever the other one passed
2119
+ * nearer. So a target set is a field.
2120
+ *
2121
+ * The PING-PONG IS SHARED, and that is most of the memory: the seeds and the
2122
+ * coverage are scratch within one variant's chain, so the variants take turns
2123
+ * in them and only the resolved r16float distance is per set. What a second set
2124
+ * costs is the FLOOD — log2(resolution) full-res passes — which is the honest
2125
+ * price of the second answer and the reason nothing here tries to be clever
2126
+ * about it. A scene where every silhouette effect is aimed at the same models,
2127
+ * which is nearly all of them, builds exactly one.
2128
+ */
2129
+ private castDistanceVariants: CastDistanceVariant[] = []
2027
2130
  /** 1x1 holding half-float 65504, bound whenever the pass is not running: an
2028
2131
  * effect keyed on distance then finds the cast unreachably far and draws
2029
2132
  * nothing, rather than the accessor being a name that does not exist. */
@@ -2032,10 +2135,12 @@ export class Engine {
2032
2135
  private castSeedPipeline: GPURenderPipeline | null = null
2033
2136
  private castStepPipeline: GPURenderPipeline | null = null
2034
2137
  private castResolvePipeline: GPURenderPipeline | null = null
2035
- private castSeedBindGroup: GPUBindGroup | null = null
2036
2138
  private castStepBindGroups: GPUBindGroup[] = []
2037
- private castResolveBindGroup: GPUBindGroup | null = null
2038
2139
  private castStepStrideBuffers: GPUBuffer[] = []
2140
+ /** Which of the two seed textures the last flood pass wrote — the one every
2141
+ * variant's resolve reads. Parity of the pass count, recorded where the
2142
+ * passes are counted. */
2143
+ private castResolveReadsSeed = 0
2039
2144
  /** The visible props' object ids for the seed pass, count in slot 0. Grown in
2040
2145
  * powers of two; see writeCastSeedProps. */
2041
2146
  private castSeedPropBuffer: GPUBuffer | null = null
@@ -2347,6 +2452,9 @@ export class Engine {
2347
2452
  /** Subjects the cast actually holds, set while it is filled. The ribbons size
2348
2453
  * their instance count by this rather than by the four-subject cap. */
2349
2454
  private castSubjectCount = 0
2455
+ /** Model name → cast slot, rebuilt as the cast is written. What turns "this
2456
+ * effect is on 今汐" into a bit the shaders can read. */
2457
+ private castSlotOf = new Map<string, number>()
2350
2458
  private effects: EffectInstance[] = []
2351
2459
  /** The first installed effect, for the many places that legitimately want
2352
2460
  * "is anything installed" or the singleton API's one effect. */
@@ -3698,18 +3806,14 @@ export class Engine {
3698
3806
  private createCastDistanceTargets(): void {
3699
3807
  for (const t of this.castSeedTextures) t?.destroy()
3700
3808
  this.castCoverageTexture?.destroy()
3701
- this.castDistTexture?.destroy()
3702
3809
  for (const b of this.castStepStrideBuffers) b.destroy()
3810
+ this.releaseCastDistanceVariants()
3703
3811
  this.castSeedTextures = [null, null]
3704
3812
  this.castSeedViews = [null, null]
3705
3813
  this.castCoverageTexture = null
3706
3814
  this.castCoverageView = null
3707
- this.castDistTexture = null
3708
- this.castDistView = null
3709
3815
  this.castStepStrideBuffers = []
3710
3816
  this.castStepBindGroups = []
3711
- this.castSeedBindGroup = null
3712
- this.castResolveBindGroup = null
3713
3817
  if (!this.device || !this.castDistanceWanted || this.fieldFullW === 0) return
3714
3818
  if (!this.castSeedPipeline || !this.castStepPipeline || !this.castResolvePipeline) return
3715
3819
 
@@ -3731,13 +3835,6 @@ export class Engine {
3731
3835
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
3732
3836
  })
3733
3837
  this.castCoverageView = this.castCoverageTexture.createView()
3734
- this.castDistTexture = this.device.createTexture({
3735
- label: "cast distance",
3736
- size: [w, h],
3737
- format: CAST_DIST_FORMAT,
3738
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
3739
- })
3740
- this.castDistView = this.castDistTexture.createView()
3741
3838
 
3742
3839
  // The flood starts at half the longest side and halves to one. That is what
3743
3840
  // makes it exact everywhere rather than out to some radius: every seed gets
@@ -3745,7 +3842,6 @@ export class Engine {
3745
3842
  const strides: number[] = []
3746
3843
  for (let k = 1 << Math.ceil(Math.log2(Math.max(w, h))); k >= 1; k >>= 1) strides.push(k)
3747
3844
 
3748
- this.createCastSeedBindGroup()
3749
3845
  strides.forEach((stride, i) => {
3750
3846
  const buf = this.device!.createBuffer({
3751
3847
  label: `cast distance stride ${stride}`,
@@ -3767,19 +3863,40 @@ export class Engine {
3767
3863
  }),
3768
3864
  )
3769
3865
  })
3770
- this.castResolveBindGroup = this.device.createBindGroup({
3771
- label: "cast distance resolve",
3772
- layout: this.castResolvePipeline.getBindGroupLayout(0),
3773
- entries: [
3774
- { binding: 0, resource: this.castSeedViews[strides.length % 2]! },
3775
- { binding: 1, resource: this.castCoverageView! },
3776
- ],
3777
- })
3866
+ // WHICH seed texture the resolve reads: the one the last flood pass wrote,
3867
+ // and that is the parity of the pass count. Shared by every variant, since
3868
+ // they take turns in the same pair.
3869
+ this.castResolveReadsSeed = strides.length % 2
3870
+ this.buildCastDistanceVariants(w, h)
3871
+ }
3872
+
3873
+ /** Every variant's own resources, gone. The shared scratch is not touched:
3874
+ * a target set changing does not resize the frame. */
3875
+ private releaseCastDistanceVariants(): void {
3876
+ for (const v of this.castDistanceVariants) {
3877
+ v.texture.destroy()
3878
+ v.uniform.destroy()
3879
+ }
3880
+ this.castDistanceVariants = []
3778
3881
  }
3779
3882
 
3780
- /** The seed pass's inputs: the id attachment, the cast, and the prop ids. */
3781
- private createCastSeedBindGroup(): void {
3883
+ /**
3884
+ * One field per distinct target set among the effects that read one, and each
3885
+ * effect pointed at its own.
3886
+ *
3887
+ * Built from the effect list rather than accumulated, so a removed effect takes
3888
+ * its flood with it and the untargeted default is not kept alive by nobody.
3889
+ */
3890
+ private buildCastDistanceVariants(w: number, h: number): void {
3782
3891
  const device = this.device!
3892
+ // The seed pass reads the id attachment, so there is nothing to build before
3893
+ // the scene has one. Effects then read the 1x1 "unreachably far" fallback,
3894
+ // which is what an effect keyed on distance already sees while no flood is
3895
+ // running — and the next createFieldTargets builds the real thing.
3896
+ if (!this.idView || !this.castCoverageView) {
3897
+ this.releaseCastDistanceVariants()
3898
+ return
3899
+ }
3783
3900
  if (!this.castSeedPropBuffer) {
3784
3901
  this.castSeedPropBuffer = device.createBuffer({
3785
3902
  label: "cast distance prop seeds",
@@ -3788,15 +3905,82 @@ export class Engine {
3788
3905
  })
3789
3906
  device.queue.writeBuffer(this.castSeedPropBuffer, 0, this.castSeedPropData)
3790
3907
  }
3791
- this.castSeedBindGroup = device.createBindGroup({
3792
- label: "cast distance seed",
3793
- layout: this.castSeedPipeline!.getBindGroupLayout(0),
3794
- entries: [
3795
- { binding: 0, resource: this.idView! },
3796
- { binding: 1, resource: { buffer: this.castBuffer } },
3797
- { binding: 2, resource: { buffer: this.castSeedPropBuffer } },
3798
- ],
3799
- })
3908
+ this.releaseCastDistanceVariants()
3909
+ for (const e of this.effects) {
3910
+ if (!e.readsCastDistance) continue
3911
+ const key = castSubjectKey(e.subjects)
3912
+ const at = this.castDistanceVariants.findIndex((v) => castSubjectKey(v.subjects) === key)
3913
+ if (at >= 0) {
3914
+ e.distVariant = at
3915
+ continue
3916
+ }
3917
+ e.distVariant = this.castDistanceVariants.length
3918
+ const data = new Float32Array([0xf, 0, 0, 0])
3919
+ const uniform = device.createBuffer({
3920
+ label: `cast distance seeds (${key})`,
3921
+ size: data.byteLength,
3922
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3923
+ })
3924
+ device.queue.writeBuffer(uniform, 0, data.buffer as ArrayBuffer)
3925
+ const texture = device.createTexture({
3926
+ label: `cast distance (${key})`,
3927
+ size: [w, h],
3928
+ format: CAST_DIST_FORMAT,
3929
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
3930
+ })
3931
+ this.castDistanceVariants.push({
3932
+ subjects: e.subjects,
3933
+ // 0xf is what the buffer above was written with; the first frame's
3934
+ // updateSubjectMasks narrows it to the live cast.
3935
+ mask: 0xf,
3936
+ uniform,
3937
+ data,
3938
+ seedBind: device.createBindGroup({
3939
+ label: `cast distance seed (${key})`,
3940
+ layout: this.castSeedPipeline!.getBindGroupLayout(0),
3941
+ entries: [
3942
+ { binding: 0, resource: this.idView! },
3943
+ { binding: 1, resource: { buffer: this.castBuffer } },
3944
+ { binding: 2, resource: { buffer: this.castSeedPropBuffer } },
3945
+ { binding: 3, resource: { buffer: uniform } },
3946
+ ],
3947
+ }),
3948
+ texture,
3949
+ view: texture.createView(),
3950
+ resolveBind: device.createBindGroup({
3951
+ label: `cast distance resolve (${key})`,
3952
+ layout: this.castResolvePipeline!.getBindGroupLayout(0),
3953
+ entries: [
3954
+ { binding: 0, resource: this.castSeedViews[this.castResolveReadsSeed]! },
3955
+ { binding: 1, resource: this.castCoverageView! },
3956
+ ],
3957
+ }),
3958
+ })
3959
+ }
3960
+ }
3961
+
3962
+ /**
3963
+ * The target sets changed — an effect was aimed somewhere else.
3964
+ *
3965
+ * Rebuilds the variants and the field bind groups that name their textures.
3966
+ * Nothing recompiles: which field an effect reads is a binding, not a constant
3967
+ * in its module.
3968
+ */
3969
+ private syncCastDistanceVariants(): void {
3970
+ if (!this.device || !this.castDistanceWanted || this.fieldFullW === 0) return
3971
+ const before = this.castDistanceVariants.map((v) => castSubjectKey(v.subjects)).join("|")
3972
+ const want = [...new Set(this.effects.filter((e) => e.readsCastDistance).map((e) => castSubjectKey(e.subjects)))].join("|")
3973
+ if (before === want) return
3974
+ const w = Math.max(1, Math.ceil(this.fieldFullW / CAST_FIELD_DIV))
3975
+ const h = Math.max(1, Math.ceil(this.fieldFullH / CAST_FIELD_DIV))
3976
+ this.buildCastDistanceVariants(w, h)
3977
+ this.rebuildFieldBindGroup()
3978
+ }
3979
+
3980
+ /** The field this effect reads, or the 1x1 "unreachably far" fallback while no
3981
+ * flood is running. */
3982
+ private castDistViewFor(owner: EffectInstance): GPUTextureView {
3983
+ return this.castDistanceVariants[owner.distVariant]?.view ?? this.castDistFallbackView!
3800
3984
  }
3801
3985
 
3802
3986
  /**
@@ -3817,7 +4001,13 @@ export class Engine {
3817
4001
  this.castSeedPropData = data
3818
4002
  this.castSeedPropBuffer?.destroy()
3819
4003
  this.castSeedPropBuffer = null
3820
- this.createCastSeedBindGroup()
4004
+ // Every variant's seed group names that buffer, so they are all rebuilt —
4005
+ // at the size the frame is now, which is where the shared scratch is.
4006
+ this.buildCastDistanceVariants(
4007
+ Math.max(1, Math.ceil(this.fieldFullW / CAST_FIELD_DIV)),
4008
+ Math.max(1, Math.ceil(this.fieldFullH / CAST_FIELD_DIV)),
4009
+ )
4010
+ this.rebuildFieldBindGroup()
3821
4011
  changed = true
3822
4012
  }
3823
4013
  data[0] = n
@@ -3838,39 +4028,45 @@ export class Engine {
3838
4028
  * whole point of paying for it in passes rather than in per-pixel search.
3839
4029
  */
3840
4030
  private encodeCastDistance(encoder: GPUCommandEncoder): void {
3841
- if (!this.castDistanceWanted || !this.castSeedBindGroup || !this.castResolveBindGroup) return
4031
+ if (!this.castDistanceWanted || this.castDistanceVariants.length === 0) return
3842
4032
  this.writeCastSeedProps()
3843
- const seed = encoder.beginRenderPass({
3844
- label: "cast distance (seed)",
3845
- colorAttachments: [
3846
- { view: this.castSeedViews[0]!, loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" },
3847
- { view: this.castCoverageView!, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" },
3848
- ],
3849
- })
3850
- seed.setPipeline(this.castSeedPipeline!)
3851
- seed.setBindGroup(0, this.castSeedBindGroup)
3852
- seed.draw(3)
3853
- seed.end()
3854
-
3855
- this.castStepBindGroups.forEach((group, i) => {
3856
- const pass = encoder.beginRenderPass({
3857
- label: "cast distance (flood)",
3858
- colorAttachments: [{ view: this.castSeedViews[(i + 1) % 2]!, loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" }],
4033
+ // One chain per target set, in turn through the shared seed pair. Serial and
4034
+ // not overlapped on purpose: the second chain overwrites the first's scratch,
4035
+ // and the only thing that has to survive is the resolved distance, which is
4036
+ // each variant's own texture.
4037
+ for (const v of this.castDistanceVariants) {
4038
+ const seed = encoder.beginRenderPass({
4039
+ label: "cast distance (seed)",
4040
+ colorAttachments: [
4041
+ { view: this.castSeedViews[0]!, loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" },
4042
+ { view: this.castCoverageView!, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" },
4043
+ ],
4044
+ })
4045
+ seed.setPipeline(this.castSeedPipeline!)
4046
+ seed.setBindGroup(0, v.seedBind)
4047
+ seed.draw(3)
4048
+ seed.end()
4049
+
4050
+ this.castStepBindGroups.forEach((group, i) => {
4051
+ const pass = encoder.beginRenderPass({
4052
+ label: "cast distance (flood)",
4053
+ colorAttachments: [{ view: this.castSeedViews[(i + 1) % 2]!, loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" }],
4054
+ })
4055
+ pass.setPipeline(this.castStepPipeline!)
4056
+ pass.setBindGroup(0, group)
4057
+ pass.draw(3)
4058
+ pass.end()
3859
4059
  })
3860
- pass.setPipeline(this.castStepPipeline!)
3861
- pass.setBindGroup(0, group)
3862
- pass.draw(3)
3863
- pass.end()
3864
- })
3865
4060
 
3866
- const resolve = encoder.beginRenderPass({
3867
- label: "cast distance (resolve)",
3868
- colorAttachments: [{ view: this.castDistView!, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" }],
3869
- })
3870
- resolve.setPipeline(this.castResolvePipeline!)
3871
- resolve.setBindGroup(0, this.castResolveBindGroup)
3872
- resolve.draw(3)
3873
- resolve.end()
4061
+ const resolve = encoder.beginRenderPass({
4062
+ label: "cast distance (resolve)",
4063
+ colorAttachments: [{ view: v.view, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" }],
4064
+ })
4065
+ resolve.setPipeline(this.castResolvePipeline!)
4066
+ resolve.setBindGroup(0, v.resolveBind)
4067
+ resolve.draw(3)
4068
+ resolve.end()
4069
+ }
3874
4070
  }
3875
4071
 
3876
4072
  private createFieldTargets(): void {
@@ -3991,7 +4187,7 @@ export class Engine {
3991
4187
  ...(this.idView ? [{ binding: 23, resource: this.idView }] : []),
3992
4188
  { binding: 17, resource: grid },
3993
4189
  { binding: 18, resource: this.simSampler },
3994
- { binding: 26, resource: this.castDistView ?? this.castDistFallbackView! },
4190
+ { binding: 26, resource: this.castDistViewFor(owner) },
3995
4191
  { binding: 27, resource: this.hdrResolveTexture.createView() },
3996
4192
  { binding: 28, resource: this.simSampler },
3997
4193
  // The other effects' layers, for rzSceneFrame — see the loop below.
@@ -4323,7 +4519,7 @@ export class Engine {
4323
4519
  alias: number[],
4324
4520
  ): Promise<{ ok: true; instance: EffectInstance; warnings: string[] } | EffectResult> {
4325
4521
  const noMounts = { background: false, foreground: false }
4326
- if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts, params: [], duration: 0 }
4522
+ if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts, params: [], duration: 0, readsCast: false }
4327
4523
 
4328
4524
  // WHAT THE FILE DECLARES, read once. Everything below takes it from `d`
4329
4525
  // rather than running a regex of its own — eight parsers over one file was
@@ -4334,7 +4530,7 @@ export class Engine {
4334
4530
  // nothing to be lenient about; the old spelling lived in comments, where a
4335
4531
  // typo was indistinguishable from prose and could only ever be warned about.
4336
4532
  const parsed = parseDirectives(authored)
4337
- if (parsed.errors.length) return { ok: false, diagnostics: parsed.errors, mounts: noMounts, params: [], duration: 0 }
4533
+ if (parsed.errors.length) return { ok: false, diagnostics: parsed.errors, mounts: noMounts, params: [], duration: 0, readsCast: false }
4338
4534
  const d = parsed.directives
4339
4535
  // The compiler sees the file with its directive lines BLANKED, so every
4340
4536
  // diagnostic below still names the line the author is looking at.
@@ -4358,7 +4554,7 @@ export class Engine {
4358
4554
  return { ok: false, diagnostics: [
4359
4555
  `a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
4360
4556
  `fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
4361
- ], mounts: noMounts, params: [], duration: 0 }
4557
+ ], mounts: noMounts, params: [], duration: 0, readsCast: false }
4362
4558
  }
4363
4559
  if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
4364
4560
  const missing = [
@@ -4366,7 +4562,7 @@ export class Engine {
4366
4562
  pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
4367
4563
  pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
4368
4564
  ].filter(Boolean)
4369
- return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts, params: [], duration: 0 }
4565
+ return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts, params: [], duration: 0, readsCast: false }
4370
4566
  }
4371
4567
  // One file, one kind — for now.
4372
4568
  //
@@ -4384,7 +4580,7 @@ export class Engine {
4384
4580
  return { ok: false, diagnostics: [
4385
4581
  "an effect declares field mounts (background/foreground) or particles, not both — " +
4386
4582
  "split them into two effects",
4387
- ], mounts: noMounts, params: [], duration: 0 }
4583
+ ], mounts: noMounts, params: [], duration: 0, readsCast: false }
4388
4584
  }
4389
4585
  // lightEmit counts as a mount on its own: a pure lighting rig draws nothing
4390
4586
  // and is still an effect — it is how a scene gets stage lights without also
@@ -4400,7 +4596,7 @@ export class Engine {
4400
4596
  "the ribbon pair (trailWidth/trailShade), " +
4401
4597
  "fn lightEmit(i: u32) -> RzLight with #lights <n>, " +
4402
4598
  "or #mirror",
4403
- ], mounts: noMounts, params: [], duration: 0 }
4599
+ ], mounts: noMounts, params: [], duration: 0, readsCast: false }
4404
4600
  }
4405
4601
  const mounts = { background: hasBackground, foreground: hasForeground }
4406
4602
 
@@ -4457,7 +4653,7 @@ export class Engine {
4457
4653
  let cursor = 0
4458
4654
  for (const p of d.params) {
4459
4655
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(p.name)) {
4460
- return { ok: false, diagnostics: [`invalid param name "${p.name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration }
4656
+ return { ok: false, diagnostics: [`invalid param name "${p.name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration, readsCast: false }
4461
4657
  }
4462
4658
  // KIND comes from the declaration, never from the runtime type of the
4463
4659
  // value: a caller handing a number to a colour must not silently turn a
@@ -4514,7 +4710,7 @@ export class Engine {
4514
4710
  this.device.pushErrorScope("validation")
4515
4711
  const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
4516
4712
  const scopeErr = await this.device.popErrorScope()
4517
- if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration }
4713
+ if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration, readsCast: false }
4518
4714
 
4519
4715
  // Declared like every other mount property: by what the source says, not by
4520
4716
  // a setting somewhere else that an author cannot see from the file.
@@ -4533,7 +4729,7 @@ export class Engine {
4533
4729
  .filter((m) => m.type === "error")
4534
4730
  .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
4535
4731
  if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
4536
- if (diagnostics.length > 0) return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration }
4732
+ if (diagnostics.length > 0) return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
4537
4733
  try {
4538
4734
  fieldPipeline = await this.device.createRenderPipelineAsync({
4539
4735
  label: "field layer pipeline",
@@ -4564,7 +4760,7 @@ export class Engine {
4564
4760
  multisample: { count: 1 },
4565
4761
  })
4566
4762
  } catch (e) {
4567
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration }
4763
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false }
4568
4764
  }
4569
4765
  }
4570
4766
  let identity: GPURenderPipeline
@@ -4588,7 +4784,7 @@ export class Engine {
4588
4784
  make(true, "composite pipeline (effect, gamma!=1)"),
4589
4785
  ])
4590
4786
  } catch (e) {
4591
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration }
4787
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false }
4592
4788
  }
4593
4789
 
4594
4790
  // Built BEFORE the swap: a particle stage that fails to compile has to leave
@@ -4642,7 +4838,7 @@ export class Engine {
4642
4838
  grid?.textures[1].destroy()
4643
4839
  grid?.uniform.destroy()
4644
4840
  trails?.uniform.destroy()
4645
- return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration }
4841
+ return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
4646
4842
  }
4647
4843
  if (wantsParticles) {
4648
4844
  const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor)
@@ -4714,6 +4910,19 @@ export class Engine {
4714
4910
  // attachment would be stored exactly as often as before.
4715
4911
  readsIds: /\brz(?:ObjectAt|MaterialAt)\s*\(/.test(wgsl),
4716
4912
  readsCastDistance: castDistanceUsed(wgsl),
4913
+ // Every route to the cast, in one test, and the author's own source for
4914
+ // the reason readsIds gives. `#anchor` counts: a file declaring one asks
4915
+ // the engine to record a bone for it, whether or not the accessor is
4916
+ // spelled out in a line this matches.
4917
+ readsCast: /\brz(?:Subject|SubjectCount|SubjectId|Anchor|Trail|TrailCount|CastDistance)\s*\(/.test(wgsl) ||
4918
+ anchors.length > 0,
4919
+ // Aimed at nobody in particular — the whole cast, which is what every
4920
+ // effect did before it could be aimed. A host narrows it afterwards, the
4921
+ // way it sets influence and a schedule.
4922
+ subjects: null,
4923
+ subjectMask: 0xf,
4924
+ subjectCount: 0,
4925
+ distVariant: 0,
4717
4926
  anchors,
4718
4927
  // The effect's own clock starts now. Per effect so that one installed
4719
4928
  // later still gets a frame where rzGridFrame() is 0 and can seed.
@@ -4787,14 +4996,24 @@ export class Engine {
4787
4996
  * Null or empty clears everything.
4788
4997
  */
4789
4998
  async setEffects(
4790
- list: { wgsl: string; params?: Record<string, EffectParamValue> }[] | null,
4999
+ list:
5000
+ | {
5001
+ wgsl: string
5002
+ params?: Record<string, EffectParamValue>
5003
+ /** Which models this one is on, by name. Omitted or null = the whole
5004
+ * cast. It rides the install for the reason `params` does: an effect
5005
+ * aimed at one dancer should not spend its first frame on all four,
5006
+ * which on a ribbon or a sigil reads as a flash. */
5007
+ subjects?: readonly string[] | null
5008
+ }[]
5009
+ | null,
4791
5010
  ): Promise<EffectResult[]> {
4792
5011
  const noMounts = { background: false, foreground: false }
4793
5012
  // Clearing an engine that has nothing yet is already done; only an install
4794
5013
  // needs the device.
4795
5014
  if (!this.device) {
4796
5015
  if (!list || list.length === 0) return []
4797
- return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }]
5016
+ return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0, readsCast: false }]
4798
5017
  }
4799
5018
 
4800
5019
  const requested = list ?? []
@@ -4845,6 +5064,7 @@ export class Engine {
4845
5064
  results.push(built)
4846
5065
  continue
4847
5066
  }
5067
+ built.instance.subjects = normalizeSubjects(requested[i].subjects)
4848
5068
  instances.push(built.instance)
4849
5069
  results.push({
4850
5070
  ok: true,
@@ -4854,6 +5074,9 @@ export class Engine {
4854
5074
  // but will never fire. Same channel as the dropped-anchor note below.
4855
5075
  diagnostics: built.warnings,
4856
5076
  mounts: { background: built.instance.hasBackground, foreground: built.instance.hasForeground },
5077
+ // Whether aiming this one at particular models means anything. Read off
5078
+ // the instance the engine just built, not off a second parse.
5079
+ readsCast: built.instance.readsCast,
4857
5080
  })
4858
5081
  }
4859
5082
  // An anchor the cap refused is worth saying out loud on the effect that
@@ -4885,6 +5108,11 @@ export class Engine {
4885
5108
  this.createCastDistanceTargets()
4886
5109
  // The field bind groups name the distance texture, and it has just been
4887
5110
  // created or destroyed — they are rebuilt below with the new list anyway.
5111
+ } else {
5112
+ // The answer did not change, but WHO the fields are built from may have:
5113
+ // this list's effects are aimed wherever the install said, and the old
5114
+ // list's variants belong to effects that are gone.
5115
+ this.syncCastDistanceVariants()
4888
5116
  }
4889
5117
  // The new list's emitters need their slots before the next frame reads them.
4890
5118
  this.allocateLightSlots()
@@ -4949,10 +5177,10 @@ export class Engine {
4949
5177
  const noMounts = { background: false, foreground: false }
4950
5178
  if (wgsl === null) {
4951
5179
  await this.setEffects(null)
4952
- return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0 }
5180
+ return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0, readsCast: false }
4953
5181
  }
4954
5182
  const [result] = await this.setEffects([{ wgsl, params }])
4955
- return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts, params: [], duration: 0 }
5183
+ return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts, params: [], duration: 0, readsCast: false }
4956
5184
  }
4957
5185
 
4958
5186
  private async buildParticles(
@@ -5012,7 +5240,7 @@ export class Engine {
5012
5240
  })
5013
5241
  const uniform = this.device.createBuffer({
5014
5242
  label: "particle uniforms",
5015
- // Two vec4-sized rows: (time, dt, count, frame) and (weight, _, _, _).
5243
+ // Two vec4-sized rows: (time, dt, count, frame) and (weight, subjects, _, _).
5016
5244
  // The first was exactly full, and weight has to live in the same buffer
5017
5245
  // as the clock or a frame could draw one without the other.
5018
5246
  size: 32,
@@ -5176,7 +5404,10 @@ export class Engine {
5176
5404
  const info = await module.getCompilationInfo()
5177
5405
  const diagnostics = info.messages.filter((m) => m.type === "error").map((m) => `${m.lineNum}:${m.linePos} ${m.message}`)
5178
5406
  if (diagnostics.length) return { ok: false, diagnostics }
5179
- const data = new Float32Array(4)
5407
+ // Two vec4s: (time, base slot, count, weight) and the subject mask — an
5408
+ // emitter reads the cast through the same accessors its drawing half does,
5409
+ // so it needs the same answer about who this effect is on.
5410
+ const data = new Float32Array(8)
5180
5411
  const uniform = this.device.createBuffer({
5181
5412
  label: "light emit uniform",
5182
5413
  size: data.byteLength,
@@ -5267,6 +5498,9 @@ export class Engine {
5267
5498
  // per effect now, so every mount in one file agrees by construction.
5268
5499
  l.data[0] = this.sceneClock - e.epochScene
5269
5500
  l.data[3] = e.weight
5501
+ // Which characters this rig is on — the second vec4. A lamp that follows a
5502
+ // hand follows the hand of the model the effect is aimed at.
5503
+ l.data[4] = e.subjectMask
5270
5504
  this.device.queue.writeBuffer(l.uniform, 0, l.data.buffer as ArrayBuffer)
5271
5505
  const cp = encoder.beginComputePass({ label: "light emit" })
5272
5506
  cp.setPipeline(l.pipeline)
@@ -5294,6 +5528,9 @@ export class Engine {
5294
5528
  // state it left rather than the one it would have reached, so fading one
5295
5529
  // back in would rewind it.
5296
5530
  p.data[4] = e.weight
5531
+ // Which characters this pool spawns off — rzSubjectCount() in particleInit
5532
+ // counts these, not the cast.
5533
+ p.data[5] = e.subjectMask
5297
5534
  // Clamped: a backgrounded tab returns with a delta of whole seconds, and an
5298
5535
  // unclamped step flings every particle out of the scene in one frame.
5299
5536
  // A scheduled effect steps by the transport instead, clamped the same way
@@ -5514,11 +5751,16 @@ export class Engine {
5514
5751
  // The shader decodes [ribbon][subject][segment] with the same number out
5515
5752
  // of its uniform, so the two cannot drift: change one without the other
5516
5753
  // and ribbons land on the wrong subject rather than merely costing more.
5517
- const live = Math.max(1, this.castSubjectCount)
5754
+ // THIS EFFECT'S subjects, not the scene's: a ribbon aimed at one dancer
5755
+ // draws one ribbon. The shader decodes [ribbon][subject][segment] out of
5756
+ // the same number, and the subject it decodes is the effect's own index —
5757
+ // which is exactly what rzTrail takes.
5758
+ const live = Math.max(1, e.subjectCount)
5518
5759
  if (view === "camera") {
5519
5760
  t.data[0] = this.sceneClock - e.epochScene
5520
5761
  t.data[1] = live
5521
5762
  t.data[2] = e.weight
5763
+ t.data[3] = e.subjectMask
5522
5764
  this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
5523
5765
  }
5524
5766
  pass.setPipeline(t.pipeline)
@@ -5556,6 +5798,10 @@ export class Engine {
5556
5798
  if (!e.fieldClock) continue
5557
5799
  this.fieldClockScratch[0] = this.sceneClock - e.epochScene
5558
5800
  this.fieldClockScratch[1] = e.weight
5801
+ // Which characters this effect is on. In the clock block because that is
5802
+ // the one uniform a field mount already has per effect, and both are the
5803
+ // same kind of thing: what is true of THIS effect this frame.
5804
+ this.fieldClockScratch[2] = e.subjectMask
5559
5805
  this.device.queue.writeBuffer(e.fieldClock, 0, this.fieldClockScratch.buffer as ArrayBuffer)
5560
5806
  }
5561
5807
  // ONE PASS PER RESOLUTION, N draws each, in document order — a pair is
@@ -5759,7 +6005,9 @@ export class Engine {
5759
6005
  const read: [GPUTextureView, GPUTextureView] = [textures[0].createView(), textures[1].createView()]
5760
6006
  const uniform = this.device.createBuffer({
5761
6007
  label: "grid uniforms",
5762
- size: 16,
6008
+ // Two vec4s: (time, dt, size, frame) and the subject mask, which WGSL pads
6009
+ // out to the second one whether or not anything else joins it.
6010
+ size: 32,
5763
6011
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
5764
6012
  })
5765
6013
 
@@ -5789,7 +6037,7 @@ export class Engine {
5789
6037
  layout,
5790
6038
  binds: [bindFor(0), bindFor(1)],
5791
6039
  uniform,
5792
- data: new Float32Array(4),
6040
+ data: new Float32Array(8),
5793
6041
  parity: 0,
5794
6042
  frame: 0,
5795
6043
  params: params.buffer,
@@ -5847,6 +6095,7 @@ export class Engine {
5847
6095
  grid.data[1] = scheduled ? e.simStep : Math.min(0.1, Math.max(0, deltaTime))
5848
6096
  grid.data[2] = grid.size
5849
6097
  grid.data[3] = grid.frame++
6098
+ grid.data[4] = e.subjectMask
5850
6099
  this.device.queue.writeBuffer(grid.uniform, 0, grid.data.buffer as ArrayBuffer)
5851
6100
  const cp = encoder.beginComputePass({ label: "grid" })
5852
6101
  cp.setPipeline(grid.pipeline)
@@ -5922,6 +6171,45 @@ export class Engine {
5922
6171
  return this.effects[index]?.influence ?? 0
5923
6172
  }
5924
6173
 
6174
+ /**
6175
+ * WHICH MODELS one effect is on, by name. Null is the whole cast, which is
6176
+ * what an effect starts as and what every effect did before this existed.
6177
+ *
6178
+ * The filter lands in the INDEX SPACE the shaders read, not in the shaders: the
6179
+ * effect's rzSubjectCount() counts only the models named, and rzSubject(0),
6180
+ * rzAnchor(0, …) and rzTrail(0, …) are the first of them. So a file written
6181
+ * against the whole cast — every shipped built-in — is aimed by this without a
6182
+ * line changing, and one that hardcodes subject 0 follows the model it is aimed
6183
+ * at rather than whoever happened to load first.
6184
+ *
6185
+ * Names, because a cast SLOT is not a property of a model: the cast is every
6186
+ * visible character in load order, so hiding one moves everybody after them up
6187
+ * a slot. They resolve in the loop that assigns the slots, once a frame.
6188
+ *
6189
+ * A name the cast does not hold is simply not in the mask — a model still
6190
+ * loading, or one since removed. It is not an error and not a warning: the
6191
+ * cast changes under a scene all the time, and a target is a claim about the
6192
+ * scene rather than about this frame of it.
6193
+ *
6194
+ * Aiming an effect that reads no cast at all (rain, a glitch, a tone curve)
6195
+ * does nothing, and that is why the install reports `readsCast` — so a host
6196
+ * can decline to offer the control rather than offer one that does nothing.
6197
+ */
6198
+ setEffectSubjects(index: number, models: readonly string[] | null): void {
6199
+ const fx = this.effects[index]
6200
+ if (!fx) return
6201
+ fx.subjects = normalizeSubjects(models)
6202
+ // The distance field is built per distinct target set, so changing one may
6203
+ // add or drop a whole flood — and the field bind groups name the texture it
6204
+ // resolves into.
6205
+ if (fx.readsCastDistance) this.syncCastDistanceVariants()
6206
+ }
6207
+
6208
+ /** What one effect is aimed at, or null for the whole cast. */
6209
+ getEffectSubjects(index: number): readonly string[] | null {
6210
+ return this.effects[index]?.subjects ?? null
6211
+ }
6212
+
5925
6213
  /**
5926
6214
  * Schedule one instance: when it is alive, and how it enters and leaves.
5927
6215
  *
@@ -10660,6 +10948,39 @@ export class Engine {
10660
10948
  * It projects boneMarkerPositions, the same points the overlay draws markers
10661
10949
  * at, so the hit box cannot drift away from the circle you are aiming at.
10662
10950
  */
10951
+ /**
10952
+ * Where a world point lands on the canvas, in CSS pixels, or null when it is
10953
+ * behind the camera.
10954
+ *
10955
+ * THE SAME PROJECTION pickBone hit-tests with, deliberately: a host that draws
10956
+ * its own markers over the canvas — a DOM icon per lamp, a label on a bone —
10957
+ * has to agree with the engine about where a thing IS, and a second
10958
+ * derivation of the view-projection is a second thing to keep in step. The
10959
+ * pixels are CSS pixels relative to the canvas, which is the space a
10960
+ * MouseEvent speaks once getBoundingClientRect is subtracted, and the space
10961
+ * an absolutely positioned element wants.
10962
+ *
10963
+ * `depth` is the clip w — distance along the view axis, for sorting overlapping
10964
+ * markers front to back. Nothing here tests occlusion: a marker for something
10965
+ * buried inside geometry is usually the marker you most need to find.
10966
+ */
10967
+ worldToScreen(p: { x: number; y: number; z: number }): { x: number; y: number; depth: number } | null {
10968
+ if (!this.camera) return null
10969
+ const width = this.canvas.clientWidth
10970
+ const height = this.canvas.clientHeight
10971
+ if (width <= 0 || height <= 0) return null
10972
+ const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values
10973
+ const cw = vp[3] * p.x + vp[7] * p.y + vp[11] * p.z + vp[15]
10974
+ if (cw <= 1e-6) return null
10975
+ const cx = vp[0] * p.x + vp[4] * p.y + vp[8] * p.z + vp[12]
10976
+ const cy = vp[1] * p.x + vp[5] * p.y + vp[9] * p.z + vp[13]
10977
+ return {
10978
+ x: ((cx / cw) * 0.5 + 0.5) * width,
10979
+ y: (1 - ((cy / cw) * 0.5 + 0.5)) * height,
10980
+ depth: cw,
10981
+ }
10982
+ }
10983
+
10663
10984
  pickBone(
10664
10985
  x: number,
10665
10986
  y: number,
@@ -15484,14 +15805,20 @@ export class Engine {
15484
15805
  }
15485
15806
 
15486
15807
  /** The model at one cast slot — the subject rzSubject(index) reads. */
15487
- private castSubjectName(index: number): string | null {
15808
+ private castSubjectName(index: number, subjects: readonly string[] | null = null): string | null {
15488
15809
  let n = 0
15810
+ let local = 0
15489
15811
  let found: string | null = null
15490
15812
  this.forEachInstance((inst) => {
15491
15813
  if (found !== null || n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp || !inst.model.visible)
15492
15814
  return
15493
- if (n === index) found = inst.name
15494
15815
  n++
15816
+ // The effect's OWN index, which is what its shaders count in: an effect
15817
+ // aimed at the third dancer finds her at 0, so a #dissolve on it takes HER
15818
+ // apart rather than whoever stands at the head of the cast.
15819
+ if (subjects && !subjects.includes(inst.name)) return
15820
+ if (local === index) found = inst.name
15821
+ local++
15495
15822
  })
15496
15823
  return found
15497
15824
  }
@@ -15518,9 +15845,10 @@ export class Engine {
15518
15845
  *
15519
15846
  * Two things ask for one. A cycle set on a model (setModelDissolveCycle) runs
15520
15847
  * on the scene clock and repeats, as it always has. An effect declaring
15521
- * `#dissolve` takes subject 0 apart on ITS OWN terms: scheduled, the cycle on
15522
- * each clip's clock with her whole between clips; unscheduled, the same
15523
- * repeating cycle as before. Where both ask, the more dissolved wins — two
15848
+ * `#dissolve` takes ITS OWN subject 0 apart on its own terms — the first model
15849
+ * it is aimed at, the head of the cast when it is aimed at nobody in
15850
+ * particular: scheduled, the cycle on each clip's clock with her whole between
15851
+ * clips; unscheduled, the same repeating cycle as before. Where both ask, the more dissolved wins — two
15524
15852
  * answers about one body, and half a body is not one of them.
15525
15853
  */
15526
15854
  private evaluateDissolves(): void {
@@ -15531,7 +15859,7 @@ export class Engine {
15531
15859
  for (const fx of this.effects) {
15532
15860
  if (!fx.dissolve) continue
15533
15861
  const cycle = dissolveCycleOf(this.dissolveTimingsOf(fx, fx.dissolve))
15534
- const subject = this.castSubjectName(0)
15862
+ const subject = this.castSubjectName(0, fx.subjects)
15535
15863
  if (!cycle || !subject) continue
15536
15864
  if (fx.window && fx.window.length > 0) {
15537
15865
  transport ??= this.transportTime()
@@ -16301,9 +16629,16 @@ export class Engine {
16301
16629
  // spawned its motes off a body nobody could see, and the silhouette field
16302
16630
  // seeded on one. The cast is who is ON STAGE.
16303
16631
  let n = 0
16632
+ this.castSlotOf.clear()
16304
16633
  this.forEachInstance((inst) => {
16305
16634
  if (n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp || !inst.model.visible) return
16306
16635
  const m = inst.model
16636
+ // WHO IS IN WHICH SLOT, recorded here because here is where it is
16637
+ // decided. An effect is aimed by model name, and a name only becomes a
16638
+ // bit in a mask once this loop has said where that model sits — a slot
16639
+ // is a position in the cast, not a property of a model, and a hidden
16640
+ // model moves everybody after it up one.
16641
+ this.castSlotOf.set(inst.name, n)
16307
16642
  // The model transform is only where the model was PLACED. A motion moves
16308
16643
  // the character by animating bones, so an effect anchored to the
16309
16644
  // transform never follows anyone anywhere — it sits at the spawn point
@@ -16334,6 +16669,8 @@ export class Engine {
16334
16669
  // drawTrails. Recorded rather than recomputed: this loop is the one place
16335
16670
  // that knows how many subjects the cast actually ended up holding.
16336
16671
  this.castSubjectCount = n
16672
+ // Each effect's own view of that cast, before any mount reads it.
16673
+ this.updateSubjectMasks()
16337
16674
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
16338
16675
  // Only what an effect declared, and only while one is installed. A scene
16339
16676
  // with no effect writes nothing here at all.
@@ -16353,6 +16690,59 @@ export class Engine {
16353
16690
  }
16354
16691
  }
16355
16692
 
16693
+ /**
16694
+ * Every effect's own view of the cast, once a frame, right after the slots are
16695
+ * assigned and before any mount reads them.
16696
+ *
16697
+ * The mask is what the shaders see: CAST_API counts its bits for
16698
+ * rzSubjectCount() and walks them for rzSubject(i), so an effect aimed at the
16699
+ * third dancer alone finds her at index 0. `subjectCount` is the same number on
16700
+ * the CPU, for the one thing that needs it there — the ribbons' instance count,
16701
+ * which must agree with what the trail shader decodes or a ribbon lands on the
16702
+ * wrong body.
16703
+ *
16704
+ * Recomputed rather than cached against a version: it is four models, a map
16705
+ * lookup each, and the alternative is a cache that has to be invalidated from
16706
+ * every place a model can be added, removed, hidden or renamed.
16707
+ */
16708
+ private updateSubjectMasks(): void {
16709
+ const live = this.castSubjectCount
16710
+ const all = live === 0 ? 0 : (1 << live) - 1
16711
+ for (const e of this.effects) {
16712
+ let mask = 0xf
16713
+ if (e.subjects) {
16714
+ mask = 0
16715
+ for (const name of e.subjects) {
16716
+ const slot = this.castSlotOf.get(name)
16717
+ if (slot !== undefined) mask |= 1 << slot
16718
+ }
16719
+ }
16720
+ e.subjectMask = mask
16721
+ let n = 0
16722
+ for (let i = 0; i < live; i++) if (mask & (1 << i)) n++
16723
+ e.subjectCount = n
16724
+ }
16725
+ // The floods, one per distinct target set. Their masks are written here for
16726
+ // the same reason the effects' are: the slots have just been decided.
16727
+ for (const v of this.castDistanceVariants) {
16728
+ let mask = 0xf
16729
+ if (v.subjects) {
16730
+ mask = 0
16731
+ for (const name of v.subjects) {
16732
+ const slot = this.castSlotOf.get(name)
16733
+ if (slot !== undefined) mask |= 1 << slot
16734
+ }
16735
+ }
16736
+ // Bounded by the live cast, so a mask naming a slot nobody occupies cannot
16737
+ // seed off a stale entry in the buffer.
16738
+ const want = mask & all
16739
+ if (v.mask === want) continue
16740
+ v.mask = want
16741
+ v.data[0] = want
16742
+ this.device.queue.writeBuffer(v.uniform, 0, v.data.buffer as ArrayBuffer)
16743
+ }
16744
+ }
16745
+
16356
16746
  /**
16357
16747
  * One character's slice of the effect API's view of the cast.
16358
16748
  *