reze-engine 0.56.14 → 0.57.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/engine.d.ts +164 -13
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +468 -109
  4. package/dist/graph/registry.d.ts.map +1 -1
  5. package/dist/graph/registry.js +30 -0
  6. package/dist/graph/style-group.d.ts +14 -0
  7. package/dist/graph/style-group.d.ts.map +1 -1
  8. package/dist/shaders/cast-api.d.ts +16 -1
  9. package/dist/shaders/cast-api.d.ts.map +1 -1
  10. package/dist/shaders/cast-api.js +80 -14
  11. package/dist/shaders/lights.d.ts.map +1 -1
  12. package/dist/shaders/lights.js +13 -9
  13. package/dist/shaders/passes/cast-distance.d.ts +8 -0
  14. package/dist/shaders/passes/cast-distance.d.ts.map +1 -1
  15. package/dist/shaders/passes/cast-distance.js +13 -1
  16. package/dist/shaders/passes/composite.d.ts +1 -1
  17. package/dist/shaders/passes/composite.d.ts.map +1 -1
  18. package/dist/shaders/passes/composite.js +22 -6
  19. package/dist/shaders/passes/grid.d.ts.map +1 -1
  20. package/dist/shaders/passes/grid.js +9 -0
  21. package/dist/shaders/passes/particles.d.ts.map +1 -1
  22. package/dist/shaders/passes/particles.js +8 -6
  23. package/dist/shaders/passes/trails.d.ts.map +1 -1
  24. package/dist/shaders/passes/trails.js +16 -11
  25. package/package.json +1 -1
  26. package/src/engine.ts +550 -109
  27. package/src/graph/registry.ts +32 -0
  28. package/src/graph/style-group.ts +14 -0
  29. package/src/shaders/cast-api.ts +82 -14
  30. package/src/shaders/lights.ts +13 -9
  31. package/src/shaders/passes/cast-distance.ts +13 -1
  32. package/src/shaders/passes/composite.ts +22 -6
  33. package/src/shaders/passes/grid.ts +9 -0
  34. package/src/shaders/passes/particles.ts +8 -6
  35. package/src/shaders/passes/trails.ts +15 -10
package/src/engine.ts CHANGED
@@ -308,6 +308,8 @@ type GroupInstall = {
308
308
  /** The group's own image maps, uploaded once per apply and owned here — the
309
309
  * install destroys them when it is replaced, so a re-apply cannot leak. */
310
310
  images?: (GPUTexture | null)[]
311
+ /** Per-material overrides of the above; same ownership. */
312
+ imagesByMaterial?: Record<string, (GPUTexture | null)[]>
311
313
  slotMap: StyleSlot[]
312
314
  /** Serialized (graph + renderClass + alphaMode) — lets applyStyleGroups skip recompiling
313
315
  * an unchanged group. */
@@ -479,6 +481,19 @@ export type EffectResult = {
479
481
  * the host does with an effect should come from the effect, not from a
480
482
  * second parse that can drift from it. */
481
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
482
497
  }
483
498
 
484
499
  type CameraOptions = {
@@ -679,6 +694,14 @@ interface DrawCall {
679
694
  // Style group this material belongs to, or null (ungrouped → neutral base pipeline).
680
695
  // Outline/ground draw calls are never grouped and leave this null.
681
696
  groupId: string | null
697
+ /** The install this draw call's bind group was actually built from.
698
+ *
699
+ * The id is not enough to decide a rebind: re-applying the SAME group id
700
+ * after an edit compiles a NEW install — its own uniform buffer, its own
701
+ * uploaded images — and destroys the old one. Comparing ids alone left the
702
+ * draw call bound to what was just thrown away, which is why a stage looked
703
+ * wrong on upload (two applies) and right after a refresh (one). */
704
+ boundInstall?: object | null
682
705
  // Bindings 0–3 kept so the bind group can be rebuilt when the material's group changes
683
706
  // (binding 4 must follow the group's style buffer, or the zero buffer when ungrouped).
684
707
  // Present only for material draw calls (opaque/transparent) — the grouping walk skips
@@ -1349,6 +1372,29 @@ function paramValue(p: EffectParamDecl, given: EffectParamValue | undefined): Ef
1349
1372
  return typeof p.value === "number" ? p.value : 0
1350
1373
  }
1351
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
+
1352
1398
  const EFFECT_PARAMS_BINDING = 7
1353
1399
  const EFFECT_PARAMS_BINDING_GRID = 9
1354
1400
 
@@ -1409,6 +1455,25 @@ interface EffectGrid {
1409
1455
  params: GPUBuffer | null
1410
1456
  }
1411
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
+
1412
1477
  /**
1413
1478
  * One effect's ribbons, or null when it declared none.
1414
1479
  *
@@ -1531,6 +1596,35 @@ interface EffectInstance {
1531
1596
  /** Does this source read the distance-to-cast field? Parsed at install for the
1532
1597
  * same reason readsIds is, and it turns the whole flood on by itself. */
1533
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
1534
1628
  /** Bones this source asked for, in ITS OWN declaration order. The scene table
1535
1629
  * maps these onto shared addresses; this list is what it is rebuilt from. */
1536
1630
  anchors: { bone: string; trail: boolean }[]
@@ -2012,8 +2106,27 @@ export class Engine {
2012
2106
  private castSeedViews: (GPUTextureView | null)[] = [null, null]
2013
2107
  private castCoverageTexture: GPUTexture | null = null
2014
2108
  private castCoverageView: GPUTextureView | null = null
2015
- private castDistTexture: GPUTexture | null = null
2016
- 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[] = []
2017
2130
  /** 1x1 holding half-float 65504, bound whenever the pass is not running: an
2018
2131
  * effect keyed on distance then finds the cast unreachably far and draws
2019
2132
  * nothing, rather than the accessor being a name that does not exist. */
@@ -2022,10 +2135,12 @@ export class Engine {
2022
2135
  private castSeedPipeline: GPURenderPipeline | null = null
2023
2136
  private castStepPipeline: GPURenderPipeline | null = null
2024
2137
  private castResolvePipeline: GPURenderPipeline | null = null
2025
- private castSeedBindGroup: GPUBindGroup | null = null
2026
2138
  private castStepBindGroups: GPUBindGroup[] = []
2027
- private castResolveBindGroup: GPUBindGroup | null = null
2028
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
2029
2144
  /** The visible props' object ids for the seed pass, count in slot 0. Grown in
2030
2145
  * powers of two; see writeCastSeedProps. */
2031
2146
  private castSeedPropBuffer: GPUBuffer | null = null
@@ -2315,7 +2430,6 @@ export class Engine {
2315
2430
  */
2316
2431
  private worldEquirectTexture: GPUTexture | null = null
2317
2432
  private worldEquirectView: GPUTextureView | null = null
2318
- private worldStrength = 1
2319
2433
  /** A three-colour sky's irradiance SH, when the world is a gradient rather
2320
2434
  * than a picture. An HDRI outranks it — see writeWorld. */
2321
2435
  private worldGradientSH: Float32Array | null = null
@@ -2338,6 +2452,9 @@ export class Engine {
2338
2452
  /** Subjects the cast actually holds, set while it is filled. The ribbons size
2339
2453
  * their instance count by this rather than by the four-subject cap. */
2340
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>()
2341
2458
  private effects: EffectInstance[] = []
2342
2459
  /** The first installed effect, for the many places that legitimately want
2343
2460
  * "is anything installed" or the singleton API's one effect. */
@@ -2740,13 +2857,15 @@ export class Engine {
2740
2857
  // exposure and view transform as the scene — a sun rolls off like a sun).
2741
2858
  // The camera basis at u[12..23] is refreshed per frame.
2742
2859
  // In modes 2 and 3 the colour slot is dead, so mode 3 carries the world
2743
- // STRENGTH in u[8] — Blender's world-strength dial, default 1.
2860
+ // STRENGTH in u[8] — Blender's world-strength dial, the SAME number that
2861
+ // scales the irradiance. A sky you can see at full brightness while it
2862
+ // lights at two thirds is two skies.
2744
2863
  const bg = this.backgroundColor
2745
2864
  // THE BACKDROP WINS WHAT YOU SEE; the world lights regardless. With only a
2746
2865
  // world installed it is also the sky, which is what an HDRI alone has
2747
2866
  // always done.
2748
2867
  const showingWorld = this.backdropEquirectView === null && this.worldEquirectView !== null
2749
- u[8] = showingWorld ? this.worldStrength : (bg?.x ?? 0)
2868
+ u[8] = showingWorld ? this.world.strength : (bg?.x ?? 0)
2750
2869
  u[9] = bg?.y ?? 0
2751
2870
  u[10] = bg?.z ?? 0
2752
2871
  // Base-layer mode only. A user effect is a separate LAYER over whichever
@@ -3687,18 +3806,14 @@ export class Engine {
3687
3806
  private createCastDistanceTargets(): void {
3688
3807
  for (const t of this.castSeedTextures) t?.destroy()
3689
3808
  this.castCoverageTexture?.destroy()
3690
- this.castDistTexture?.destroy()
3691
3809
  for (const b of this.castStepStrideBuffers) b.destroy()
3810
+ this.releaseCastDistanceVariants()
3692
3811
  this.castSeedTextures = [null, null]
3693
3812
  this.castSeedViews = [null, null]
3694
3813
  this.castCoverageTexture = null
3695
3814
  this.castCoverageView = null
3696
- this.castDistTexture = null
3697
- this.castDistView = null
3698
3815
  this.castStepStrideBuffers = []
3699
3816
  this.castStepBindGroups = []
3700
- this.castSeedBindGroup = null
3701
- this.castResolveBindGroup = null
3702
3817
  if (!this.device || !this.castDistanceWanted || this.fieldFullW === 0) return
3703
3818
  if (!this.castSeedPipeline || !this.castStepPipeline || !this.castResolvePipeline) return
3704
3819
 
@@ -3720,13 +3835,6 @@ export class Engine {
3720
3835
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
3721
3836
  })
3722
3837
  this.castCoverageView = this.castCoverageTexture.createView()
3723
- this.castDistTexture = this.device.createTexture({
3724
- label: "cast distance",
3725
- size: [w, h],
3726
- format: CAST_DIST_FORMAT,
3727
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
3728
- })
3729
- this.castDistView = this.castDistTexture.createView()
3730
3838
 
3731
3839
  // The flood starts at half the longest side and halves to one. That is what
3732
3840
  // makes it exact everywhere rather than out to some radius: every seed gets
@@ -3734,7 +3842,6 @@ export class Engine {
3734
3842
  const strides: number[] = []
3735
3843
  for (let k = 1 << Math.ceil(Math.log2(Math.max(w, h))); k >= 1; k >>= 1) strides.push(k)
3736
3844
 
3737
- this.createCastSeedBindGroup()
3738
3845
  strides.forEach((stride, i) => {
3739
3846
  const buf = this.device!.createBuffer({
3740
3847
  label: `cast distance stride ${stride}`,
@@ -3756,19 +3863,40 @@ export class Engine {
3756
3863
  }),
3757
3864
  )
3758
3865
  })
3759
- this.castResolveBindGroup = this.device.createBindGroup({
3760
- label: "cast distance resolve",
3761
- layout: this.castResolvePipeline.getBindGroupLayout(0),
3762
- entries: [
3763
- { binding: 0, resource: this.castSeedViews[strides.length % 2]! },
3764
- { binding: 1, resource: this.castCoverageView! },
3765
- ],
3766
- })
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)
3767
3871
  }
3768
3872
 
3769
- /** The seed pass's inputs: the id attachment, the cast, and the prop ids. */
3770
- private createCastSeedBindGroup(): void {
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 = []
3881
+ }
3882
+
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 {
3771
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
+ }
3772
3900
  if (!this.castSeedPropBuffer) {
3773
3901
  this.castSeedPropBuffer = device.createBuffer({
3774
3902
  label: "cast distance prop seeds",
@@ -3777,15 +3905,82 @@ export class Engine {
3777
3905
  })
3778
3906
  device.queue.writeBuffer(this.castSeedPropBuffer, 0, this.castSeedPropData)
3779
3907
  }
3780
- this.castSeedBindGroup = device.createBindGroup({
3781
- label: "cast distance seed",
3782
- layout: this.castSeedPipeline!.getBindGroupLayout(0),
3783
- entries: [
3784
- { binding: 0, resource: this.idView! },
3785
- { binding: 1, resource: { buffer: this.castBuffer } },
3786
- { binding: 2, resource: { buffer: this.castSeedPropBuffer } },
3787
- ],
3788
- })
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!
3789
3984
  }
3790
3985
 
3791
3986
  /**
@@ -3806,7 +4001,13 @@ export class Engine {
3806
4001
  this.castSeedPropData = data
3807
4002
  this.castSeedPropBuffer?.destroy()
3808
4003
  this.castSeedPropBuffer = null
3809
- 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()
3810
4011
  changed = true
3811
4012
  }
3812
4013
  data[0] = n
@@ -3827,39 +4028,45 @@ export class Engine {
3827
4028
  * whole point of paying for it in passes rather than in per-pixel search.
3828
4029
  */
3829
4030
  private encodeCastDistance(encoder: GPUCommandEncoder): void {
3830
- if (!this.castDistanceWanted || !this.castSeedBindGroup || !this.castResolveBindGroup) return
4031
+ if (!this.castDistanceWanted || this.castDistanceVariants.length === 0) return
3831
4032
  this.writeCastSeedProps()
3832
- const seed = encoder.beginRenderPass({
3833
- label: "cast distance (seed)",
3834
- colorAttachments: [
3835
- { view: this.castSeedViews[0]!, loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" },
3836
- { view: this.castCoverageView!, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" },
3837
- ],
3838
- })
3839
- seed.setPipeline(this.castSeedPipeline!)
3840
- seed.setBindGroup(0, this.castSeedBindGroup)
3841
- seed.draw(3)
3842
- seed.end()
3843
-
3844
- this.castStepBindGroups.forEach((group, i) => {
3845
- const pass = encoder.beginRenderPass({
3846
- label: "cast distance (flood)",
3847
- 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()
3848
4059
  })
3849
- pass.setPipeline(this.castStepPipeline!)
3850
- pass.setBindGroup(0, group)
3851
- pass.draw(3)
3852
- pass.end()
3853
- })
3854
4060
 
3855
- const resolve = encoder.beginRenderPass({
3856
- label: "cast distance (resolve)",
3857
- colorAttachments: [{ view: this.castDistView!, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" }],
3858
- })
3859
- resolve.setPipeline(this.castResolvePipeline!)
3860
- resolve.setBindGroup(0, this.castResolveBindGroup)
3861
- resolve.draw(3)
3862
- 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
+ }
3863
4070
  }
3864
4071
 
3865
4072
  private createFieldTargets(): void {
@@ -3980,7 +4187,7 @@ export class Engine {
3980
4187
  ...(this.idView ? [{ binding: 23, resource: this.idView }] : []),
3981
4188
  { binding: 17, resource: grid },
3982
4189
  { binding: 18, resource: this.simSampler },
3983
- { binding: 26, resource: this.castDistView ?? this.castDistFallbackView! },
4190
+ { binding: 26, resource: this.castDistViewFor(owner) },
3984
4191
  { binding: 27, resource: this.hdrResolveTexture.createView() },
3985
4192
  { binding: 28, resource: this.simSampler },
3986
4193
  // The other effects' layers, for rzSceneFrame — see the loop below.
@@ -4113,7 +4320,7 @@ export class Engine {
4113
4320
  * `strength` is Blender's world-strength dial and is folded into the
4114
4321
  * coefficients, so what lights her is what you see.
4115
4322
  */
4116
- setWorldEquirect(source: HdrImage | null, options?: { strength?: number }): void {
4323
+ setWorldEquirect(source: HdrImage | null): void {
4117
4324
  // RETIRED, NOT DESTROYED. A frame already encoded against the old sky can
4118
4325
  // still be in flight — the bind groups that name it are rebuilt below, but
4119
4326
  // the command buffer holding the previous ones is submitted at the end of
@@ -4123,7 +4330,6 @@ export class Engine {
4123
4330
  if (this.worldEquirectTexture) this.retiredSkies.push(this.worldEquirectTexture)
4124
4331
  this.worldEquirectTexture = null
4125
4332
  this.worldEquirectView = null
4126
- this.worldStrength = Math.max(options?.strength ?? 1, 0)
4127
4333
  const hadSH = this.worldSH !== null
4128
4334
  this.worldSH = null
4129
4335
  if (source && this.device) {
@@ -4183,10 +4389,13 @@ export class Engine {
4183
4389
  // The sky lights the scene, not only backs it. The sun keeps the toon
4184
4390
  // ramp — this is the ambient term, exactly where the flat world colour
4185
4391
  // used to sit.
4392
+ // RAW irradiance. The World strength dial is applied where every other
4393
+ // reading of it is, in writeWorld — folding it in here made the sky's
4394
+ // brightness a function of WHEN it was installed: the dial moved
4395
+ // afterwards rescaled the write and not the projection, and an install
4396
+ // that landed mid-session carried a different strength from the same
4397
+ // document reopened.
4186
4398
  this.worldSH = projectIrradianceSH({ ...source, data: source.data }, 4)
4187
- if (this.worldStrength !== 1) {
4188
- for (let i = 0; i < this.worldSH.length; i++) this.worldSH[i] *= this.worldStrength
4189
- }
4190
4399
  }
4191
4400
  if (this.worldSH || hadSH) this.writeWorld()
4192
4401
  // The MATERIAL groups too, not only the composite's: a surface that
@@ -4310,7 +4519,7 @@ export class Engine {
4310
4519
  alias: number[],
4311
4520
  ): Promise<{ ok: true; instance: EffectInstance; warnings: string[] } | EffectResult> {
4312
4521
  const noMounts = { background: false, foreground: false }
4313
- 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 }
4314
4523
 
4315
4524
  // WHAT THE FILE DECLARES, read once. Everything below takes it from `d`
4316
4525
  // rather than running a regex of its own — eight parsers over one file was
@@ -4321,7 +4530,7 @@ export class Engine {
4321
4530
  // nothing to be lenient about; the old spelling lived in comments, where a
4322
4531
  // typo was indistinguishable from prose and could only ever be warned about.
4323
4532
  const parsed = parseDirectives(authored)
4324
- 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 }
4325
4534
  const d = parsed.directives
4326
4535
  // The compiler sees the file with its directive lines BLANKED, so every
4327
4536
  // diagnostic below still names the line the author is looking at.
@@ -4345,7 +4554,7 @@ export class Engine {
4345
4554
  return { ok: false, diagnostics: [
4346
4555
  `a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
4347
4556
  `fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
4348
- ], mounts: noMounts, params: [], duration: 0 }
4557
+ ], mounts: noMounts, params: [], duration: 0, readsCast: false }
4349
4558
  }
4350
4559
  if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
4351
4560
  const missing = [
@@ -4353,7 +4562,7 @@ export class Engine {
4353
4562
  pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
4354
4563
  pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
4355
4564
  ].filter(Boolean)
4356
- 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 }
4357
4566
  }
4358
4567
  // One file, one kind — for now.
4359
4568
  //
@@ -4371,7 +4580,7 @@ export class Engine {
4371
4580
  return { ok: false, diagnostics: [
4372
4581
  "an effect declares field mounts (background/foreground) or particles, not both — " +
4373
4582
  "split them into two effects",
4374
- ], mounts: noMounts, params: [], duration: 0 }
4583
+ ], mounts: noMounts, params: [], duration: 0, readsCast: false }
4375
4584
  }
4376
4585
  // lightEmit counts as a mount on its own: a pure lighting rig draws nothing
4377
4586
  // and is still an effect — it is how a scene gets stage lights without also
@@ -4387,7 +4596,7 @@ export class Engine {
4387
4596
  "the ribbon pair (trailWidth/trailShade), " +
4388
4597
  "fn lightEmit(i: u32) -> RzLight with #lights <n>, " +
4389
4598
  "or #mirror",
4390
- ], mounts: noMounts, params: [], duration: 0 }
4599
+ ], mounts: noMounts, params: [], duration: 0, readsCast: false }
4391
4600
  }
4392
4601
  const mounts = { background: hasBackground, foreground: hasForeground }
4393
4602
 
@@ -4444,7 +4653,7 @@ export class Engine {
4444
4653
  let cursor = 0
4445
4654
  for (const p of d.params) {
4446
4655
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(p.name)) {
4447
- 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 }
4448
4657
  }
4449
4658
  // KIND comes from the declaration, never from the runtime type of the
4450
4659
  // value: a caller handing a number to a colour must not silently turn a
@@ -4501,7 +4710,7 @@ export class Engine {
4501
4710
  this.device.pushErrorScope("validation")
4502
4711
  const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
4503
4712
  const scopeErr = await this.device.popErrorScope()
4504
- 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 }
4505
4714
 
4506
4715
  // Declared like every other mount property: by what the source says, not by
4507
4716
  // a setting somewhere else that an author cannot see from the file.
@@ -4520,7 +4729,7 @@ export class Engine {
4520
4729
  .filter((m) => m.type === "error")
4521
4730
  .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
4522
4731
  if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
4523
- 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 }
4524
4733
  try {
4525
4734
  fieldPipeline = await this.device.createRenderPipelineAsync({
4526
4735
  label: "field layer pipeline",
@@ -4551,7 +4760,7 @@ export class Engine {
4551
4760
  multisample: { count: 1 },
4552
4761
  })
4553
4762
  } catch (e) {
4554
- 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 }
4555
4764
  }
4556
4765
  }
4557
4766
  let identity: GPURenderPipeline
@@ -4575,7 +4784,7 @@ export class Engine {
4575
4784
  make(true, "composite pipeline (effect, gamma!=1)"),
4576
4785
  ])
4577
4786
  } catch (e) {
4578
- 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 }
4579
4788
  }
4580
4789
 
4581
4790
  // Built BEFORE the swap: a particle stage that fails to compile has to leave
@@ -4629,7 +4838,7 @@ export class Engine {
4629
4838
  grid?.textures[1].destroy()
4630
4839
  grid?.uniform.destroy()
4631
4840
  trails?.uniform.destroy()
4632
- 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 }
4633
4842
  }
4634
4843
  if (wantsParticles) {
4635
4844
  const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor)
@@ -4701,6 +4910,19 @@ export class Engine {
4701
4910
  // attachment would be stored exactly as often as before.
4702
4911
  readsIds: /\brz(?:ObjectAt|MaterialAt)\s*\(/.test(wgsl),
4703
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,
4704
4926
  anchors,
4705
4927
  // The effect's own clock starts now. Per effect so that one installed
4706
4928
  // later still gets a frame where rzGridFrame() is 0 and can seed.
@@ -4774,14 +4996,24 @@ export class Engine {
4774
4996
  * Null or empty clears everything.
4775
4997
  */
4776
4998
  async setEffects(
4777
- 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,
4778
5010
  ): Promise<EffectResult[]> {
4779
5011
  const noMounts = { background: false, foreground: false }
4780
5012
  // Clearing an engine that has nothing yet is already done; only an install
4781
5013
  // needs the device.
4782
5014
  if (!this.device) {
4783
5015
  if (!list || list.length === 0) return []
4784
- 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 }]
4785
5017
  }
4786
5018
 
4787
5019
  const requested = list ?? []
@@ -4832,6 +5064,7 @@ export class Engine {
4832
5064
  results.push(built)
4833
5065
  continue
4834
5066
  }
5067
+ built.instance.subjects = normalizeSubjects(requested[i].subjects)
4835
5068
  instances.push(built.instance)
4836
5069
  results.push({
4837
5070
  ok: true,
@@ -4841,6 +5074,9 @@ export class Engine {
4841
5074
  // but will never fire. Same channel as the dropped-anchor note below.
4842
5075
  diagnostics: built.warnings,
4843
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,
4844
5080
  })
4845
5081
  }
4846
5082
  // An anchor the cap refused is worth saying out loud on the effect that
@@ -4872,6 +5108,11 @@ export class Engine {
4872
5108
  this.createCastDistanceTargets()
4873
5109
  // The field bind groups name the distance texture, and it has just been
4874
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()
4875
5116
  }
4876
5117
  // The new list's emitters need their slots before the next frame reads them.
4877
5118
  this.allocateLightSlots()
@@ -4936,10 +5177,10 @@ export class Engine {
4936
5177
  const noMounts = { background: false, foreground: false }
4937
5178
  if (wgsl === null) {
4938
5179
  await this.setEffects(null)
4939
- return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0 }
5180
+ return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0, readsCast: false }
4940
5181
  }
4941
5182
  const [result] = await this.setEffects([{ wgsl, params }])
4942
- 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 }
4943
5184
  }
4944
5185
 
4945
5186
  private async buildParticles(
@@ -4999,7 +5240,7 @@ export class Engine {
4999
5240
  })
5000
5241
  const uniform = this.device.createBuffer({
5001
5242
  label: "particle uniforms",
5002
- // Two vec4-sized rows: (time, dt, count, frame) and (weight, _, _, _).
5243
+ // Two vec4-sized rows: (time, dt, count, frame) and (weight, subjects, _, _).
5003
5244
  // The first was exactly full, and weight has to live in the same buffer
5004
5245
  // as the clock or a frame could draw one without the other.
5005
5246
  size: 32,
@@ -5163,7 +5404,10 @@ export class Engine {
5163
5404
  const info = await module.getCompilationInfo()
5164
5405
  const diagnostics = info.messages.filter((m) => m.type === "error").map((m) => `${m.lineNum}:${m.linePos} ${m.message}`)
5165
5406
  if (diagnostics.length) return { ok: false, diagnostics }
5166
- 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)
5167
5411
  const uniform = this.device.createBuffer({
5168
5412
  label: "light emit uniform",
5169
5413
  size: data.byteLength,
@@ -5254,6 +5498,9 @@ export class Engine {
5254
5498
  // per effect now, so every mount in one file agrees by construction.
5255
5499
  l.data[0] = this.sceneClock - e.epochScene
5256
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
5257
5504
  this.device.queue.writeBuffer(l.uniform, 0, l.data.buffer as ArrayBuffer)
5258
5505
  const cp = encoder.beginComputePass({ label: "light emit" })
5259
5506
  cp.setPipeline(l.pipeline)
@@ -5281,6 +5528,9 @@ export class Engine {
5281
5528
  // state it left rather than the one it would have reached, so fading one
5282
5529
  // back in would rewind it.
5283
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
5284
5534
  // Clamped: a backgrounded tab returns with a delta of whole seconds, and an
5285
5535
  // unclamped step flings every particle out of the scene in one frame.
5286
5536
  // A scheduled effect steps by the transport instead, clamped the same way
@@ -5501,11 +5751,16 @@ export class Engine {
5501
5751
  // The shader decodes [ribbon][subject][segment] with the same number out
5502
5752
  // of its uniform, so the two cannot drift: change one without the other
5503
5753
  // and ribbons land on the wrong subject rather than merely costing more.
5504
- 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)
5505
5759
  if (view === "camera") {
5506
5760
  t.data[0] = this.sceneClock - e.epochScene
5507
5761
  t.data[1] = live
5508
5762
  t.data[2] = e.weight
5763
+ t.data[3] = e.subjectMask
5509
5764
  this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
5510
5765
  }
5511
5766
  pass.setPipeline(t.pipeline)
@@ -5543,6 +5798,10 @@ export class Engine {
5543
5798
  if (!e.fieldClock) continue
5544
5799
  this.fieldClockScratch[0] = this.sceneClock - e.epochScene
5545
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
5546
5805
  this.device.queue.writeBuffer(e.fieldClock, 0, this.fieldClockScratch.buffer as ArrayBuffer)
5547
5806
  }
5548
5807
  // ONE PASS PER RESOLUTION, N draws each, in document order — a pair is
@@ -5746,7 +6005,9 @@ export class Engine {
5746
6005
  const read: [GPUTextureView, GPUTextureView] = [textures[0].createView(), textures[1].createView()]
5747
6006
  const uniform = this.device.createBuffer({
5748
6007
  label: "grid uniforms",
5749
- 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,
5750
6011
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
5751
6012
  })
5752
6013
 
@@ -5776,7 +6037,7 @@ export class Engine {
5776
6037
  layout,
5777
6038
  binds: [bindFor(0), bindFor(1)],
5778
6039
  uniform,
5779
- data: new Float32Array(4),
6040
+ data: new Float32Array(8),
5780
6041
  parity: 0,
5781
6042
  frame: 0,
5782
6043
  params: params.buffer,
@@ -5834,6 +6095,7 @@ export class Engine {
5834
6095
  grid.data[1] = scheduled ? e.simStep : Math.min(0.1, Math.max(0, deltaTime))
5835
6096
  grid.data[2] = grid.size
5836
6097
  grid.data[3] = grid.frame++
6098
+ grid.data[4] = e.subjectMask
5837
6099
  this.device.queue.writeBuffer(grid.uniform, 0, grid.data.buffer as ArrayBuffer)
5838
6100
  const cp = encoder.beginComputePass({ label: "grid" })
5839
6101
  cp.setPipeline(grid.pipeline)
@@ -5909,6 +6171,45 @@ export class Engine {
5909
6171
  return this.effects[index]?.influence ?? 0
5910
6172
  }
5911
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
+
5912
6213
  /**
5913
6214
  * Schedule one instance: when it is alive, and how it enters and leaves.
5914
6215
  *
@@ -10173,6 +10474,56 @@ export class Engine {
10173
10474
  return this.modelInstances.get(name)?.model ?? null
10174
10475
  }
10175
10476
 
10477
+ /**
10478
+ * What the engine is actually about to draw, per model.
10479
+ *
10480
+ * Every model the engine holds, not every model the host thinks it holds —
10481
+ * which is the point. A scene that comes up brighter after an upload than the
10482
+ * same document does after a reload differs somewhere between the two, and
10483
+ * the candidates are all countable: a model left behind by a replace draws its
10484
+ * transparent surfaces a second time, and a group that compiled but claimed
10485
+ * nothing leaves its materials on the default graph. Both are invisible from
10486
+ * the host, which sees its own lists rather than the engine's.
10487
+ *
10488
+ * `grouped` counts draw calls bound to a style group; `ungrouped` is the rest,
10489
+ * on the default graph.
10490
+ */
10491
+ /**
10492
+ * What the composite is about to put behind the scene, and at what level.
10493
+ *
10494
+ * `mode` is the base layer: 0 transparent, 1 a flat colour, 2 an LDR 360
10495
+ * picture, 3 the HDR world as scene-linear radiance. `level` is what mode 3
10496
+ * is multiplied by. The whole frame's brightness turns on these two, and
10497
+ * neither is visible from a host reading its own document — a scene that came
10498
+ * up brighter after an upload than after a reload of the same document is one
10499
+ * of the cases they answer.
10500
+ */
10501
+ getBackgroundState(): { mode: 0 | 1 | 2 | 3; level: number; backdrop: boolean; world: boolean } {
10502
+ const showingWorld = this.backdropEquirectView === null && this.worldEquirectView !== null
10503
+ return {
10504
+ mode: this.backdropEquirectView ? 2 : showingWorld ? 3 : this.backgroundColor ? 1 : 0,
10505
+ level: this.world.strength,
10506
+ backdrop: this.backdropEquirectView !== null,
10507
+ world: this.worldEquirectView !== null,
10508
+ }
10509
+ }
10510
+
10511
+ getDrawStats(): { model: string; materials: number; opaque: number; transparent: number; grouped: number; ungrouped: number }[] {
10512
+ const out = []
10513
+ for (const [name, inst] of this.modelInstances) {
10514
+ const shaded = inst.drawCalls.filter((d) => d.baseBindGroupEntries)
10515
+ out.push({
10516
+ model: name,
10517
+ materials: shaded.length,
10518
+ opaque: shaded.filter((d) => d.type === "opaque").length,
10519
+ transparent: shaded.filter((d) => d.type === "transparent").length,
10520
+ grouped: shaded.filter((d) => d.groupId !== null).length,
10521
+ ungrouped: shaded.filter((d) => d.groupId === null).length,
10522
+ })
10523
+ }
10524
+ return out
10525
+ }
10526
+
10176
10527
  /**
10177
10528
  * Hang a model from a bone of another — MMD's 外部親 (outside parent).
10178
10529
  *
@@ -11091,13 +11442,14 @@ export class Engine {
11091
11442
  private destroyInstall(install: GroupInstall): void {
11092
11443
  install.uniformBuffer.destroy()
11093
11444
  for (const tex of install.images ?? []) tex?.destroy()
11445
+ for (const set of Object.values(install.imagesByMaterial ?? {})) for (const tex of set) tex?.destroy()
11094
11446
  }
11095
11447
 
11096
11448
  /** Upload a group's image maps. Sources are decoded images the host already
11097
11449
  * holds; the engine never fetches, matching how models and motions arrive. */
11098
- private uploadGroupImages(group: StyleGroup): (GPUTexture | null)[] | undefined {
11099
- if (!group.images?.length) return undefined
11100
- return group.images.slice(0, 4).map((entry) => {
11450
+ private uploadGroupImages(group: StyleGroup, slots = group.images): (GPUTexture | null)[] | undefined {
11451
+ if (!slots?.length) return undefined
11452
+ return slots.slice(0, 4).map((entry) => {
11101
11453
  if (!entry) return null
11102
11454
  const wrapped = "source" in entry
11103
11455
  const src = wrapped ? entry.source : entry
@@ -15420,14 +15772,20 @@ export class Engine {
15420
15772
  }
15421
15773
 
15422
15774
  /** The model at one cast slot — the subject rzSubject(index) reads. */
15423
- private castSubjectName(index: number): string | null {
15775
+ private castSubjectName(index: number, subjects: readonly string[] | null = null): string | null {
15424
15776
  let n = 0
15777
+ let local = 0
15425
15778
  let found: string | null = null
15426
15779
  this.forEachInstance((inst) => {
15427
15780
  if (found !== null || n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp || !inst.model.visible)
15428
15781
  return
15429
- if (n === index) found = inst.name
15430
15782
  n++
15783
+ // The effect's OWN index, which is what its shaders count in: an effect
15784
+ // aimed at the third dancer finds her at 0, so a #dissolve on it takes HER
15785
+ // apart rather than whoever stands at the head of the cast.
15786
+ if (subjects && !subjects.includes(inst.name)) return
15787
+ if (local === index) found = inst.name
15788
+ local++
15431
15789
  })
15432
15790
  return found
15433
15791
  }
@@ -15454,9 +15812,10 @@ export class Engine {
15454
15812
  *
15455
15813
  * Two things ask for one. A cycle set on a model (setModelDissolveCycle) runs
15456
15814
  * on the scene clock and repeats, as it always has. An effect declaring
15457
- * `#dissolve` takes subject 0 apart on ITS OWN terms: scheduled, the cycle on
15458
- * each clip's clock with her whole between clips; unscheduled, the same
15459
- * repeating cycle as before. Where both ask, the more dissolved wins — two
15815
+ * `#dissolve` takes ITS OWN subject 0 apart on its own terms — the first model
15816
+ * it is aimed at, the head of the cast when it is aimed at nobody in
15817
+ * particular: scheduled, the cycle on each clip's clock with her whole between
15818
+ * clips; unscheduled, the same repeating cycle as before. Where both ask, the more dissolved wins — two
15460
15819
  * answers about one body, and half a body is not one of them.
15461
15820
  */
15462
15821
  private evaluateDissolves(): void {
@@ -15467,7 +15826,7 @@ export class Engine {
15467
15826
  for (const fx of this.effects) {
15468
15827
  if (!fx.dissolve) continue
15469
15828
  const cycle = dissolveCycleOf(this.dissolveTimingsOf(fx, fx.dissolve))
15470
- const subject = this.castSubjectName(0)
15829
+ const subject = this.castSubjectName(0, fx.subjects)
15471
15830
  if (!cycle || !subject) continue
15472
15831
  if (fx.window && fx.window.length > 0) {
15473
15832
  transport ??= this.transportTime()
@@ -15504,7 +15863,19 @@ export class Engine {
15504
15863
  ): Promise<ApplyStyleGroupResult> {
15505
15864
  const renderClass = group.renderClass ?? "auto"
15506
15865
  const alphaMode = group.alphaMode ?? "opaque"
15507
- const signature = JSON.stringify({ g: group.graph, rc: renderClass, am: alphaMode, o: opts?.previewNode ?? null })
15866
+ // THE MAPS ARE PART OF THE SIGNATURE, because a matching one skips the
15867
+ // upload below and keeps whatever the install already holds. A group can
15868
+ // arrive twice — once as the model lands and again once its sidecar has
15869
+ // decoded — and without this the second apply refreshed the definition and
15870
+ // silently kept the empty slots from the first.
15871
+ const signature = JSON.stringify({
15872
+ g: group.graph,
15873
+ rc: renderClass,
15874
+ am: alphaMode,
15875
+ o: opts?.previewNode ?? null,
15876
+ im: group.images?.length ?? 0,
15877
+ ibm: Object.keys(group.imagesByMaterial ?? {}).sort(),
15878
+ })
15508
15879
  const existing = inst.styleGroups.get(group.id)
15509
15880
  if (existing && existing.signature === signature) {
15510
15881
  existing.group = group // refresh def (label/materials) without recompiling
@@ -15578,6 +15949,11 @@ export class Engine {
15578
15949
  mirrorPipeline,
15579
15950
  uniformBuffer,
15580
15951
  images: this.uploadGroupImages(group),
15952
+ imagesByMaterial: group.imagesByMaterial
15953
+ ? Object.fromEntries(
15954
+ Object.entries(group.imagesByMaterial).map(([name, slots]) => [name, this.uploadGroupImages(group, slots) ?? []]),
15955
+ )
15956
+ : undefined,
15581
15957
  slotMap: result.slotMap,
15582
15958
  signature,
15583
15959
  })
@@ -15649,13 +16025,16 @@ export class Engine {
15649
16025
  ? "opaque"
15650
16026
  : dc.baseType
15651
16027
  if (dc.type !== type) dc.type = type
15652
- if (dc.groupId === groupId) continue
16028
+ // Rebind when the INSTALL changed, not merely when the id did.
16029
+ const bound = install ?? null
16030
+ if (dc.groupId === groupId && dc.boundInstall === bound) continue
15653
16031
  dc.groupId = groupId
16032
+ dc.boundInstall = bound
15654
16033
  dc.bindGroup = this.createMaterialBindGroup(
15655
16034
  `material: ${dc.materialName}`,
15656
16035
  dc.baseBindGroupEntries,
15657
16036
  install ? install.uniformBuffer : this.zeroStyleBuffer,
15658
- install?.images,
16037
+ install?.imagesByMaterial?.[dc.materialName] ?? install?.images,
15659
16038
  )
15660
16039
  }
15661
16040
  this.sortDrawCalls(inst)
@@ -16217,9 +16596,16 @@ export class Engine {
16217
16596
  // spawned its motes off a body nobody could see, and the silhouette field
16218
16597
  // seeded on one. The cast is who is ON STAGE.
16219
16598
  let n = 0
16599
+ this.castSlotOf.clear()
16220
16600
  this.forEachInstance((inst) => {
16221
16601
  if (n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp || !inst.model.visible) return
16222
16602
  const m = inst.model
16603
+ // WHO IS IN WHICH SLOT, recorded here because here is where it is
16604
+ // decided. An effect is aimed by model name, and a name only becomes a
16605
+ // bit in a mask once this loop has said where that model sits — a slot
16606
+ // is a position in the cast, not a property of a model, and a hidden
16607
+ // model moves everybody after it up one.
16608
+ this.castSlotOf.set(inst.name, n)
16223
16609
  // The model transform is only where the model was PLACED. A motion moves
16224
16610
  // the character by animating bones, so an effect anchored to the
16225
16611
  // transform never follows anyone anywhere — it sits at the spawn point
@@ -16250,6 +16636,8 @@ export class Engine {
16250
16636
  // drawTrails. Recorded rather than recomputed: this loop is the one place
16251
16637
  // that knows how many subjects the cast actually ended up holding.
16252
16638
  this.castSubjectCount = n
16639
+ // Each effect's own view of that cast, before any mount reads it.
16640
+ this.updateSubjectMasks()
16253
16641
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
16254
16642
  // Only what an effect declared, and only while one is installed. A scene
16255
16643
  // with no effect writes nothing here at all.
@@ -16269,6 +16657,59 @@ export class Engine {
16269
16657
  }
16270
16658
  }
16271
16659
 
16660
+ /**
16661
+ * Every effect's own view of the cast, once a frame, right after the slots are
16662
+ * assigned and before any mount reads them.
16663
+ *
16664
+ * The mask is what the shaders see: CAST_API counts its bits for
16665
+ * rzSubjectCount() and walks them for rzSubject(i), so an effect aimed at the
16666
+ * third dancer alone finds her at index 0. `subjectCount` is the same number on
16667
+ * the CPU, for the one thing that needs it there — the ribbons' instance count,
16668
+ * which must agree with what the trail shader decodes or a ribbon lands on the
16669
+ * wrong body.
16670
+ *
16671
+ * Recomputed rather than cached against a version: it is four models, a map
16672
+ * lookup each, and the alternative is a cache that has to be invalidated from
16673
+ * every place a model can be added, removed, hidden or renamed.
16674
+ */
16675
+ private updateSubjectMasks(): void {
16676
+ const live = this.castSubjectCount
16677
+ const all = live === 0 ? 0 : (1 << live) - 1
16678
+ for (const e of this.effects) {
16679
+ let mask = 0xf
16680
+ if (e.subjects) {
16681
+ mask = 0
16682
+ for (const name of e.subjects) {
16683
+ const slot = this.castSlotOf.get(name)
16684
+ if (slot !== undefined) mask |= 1 << slot
16685
+ }
16686
+ }
16687
+ e.subjectMask = mask
16688
+ let n = 0
16689
+ for (let i = 0; i < live; i++) if (mask & (1 << i)) n++
16690
+ e.subjectCount = n
16691
+ }
16692
+ // The floods, one per distinct target set. Their masks are written here for
16693
+ // the same reason the effects' are: the slots have just been decided.
16694
+ for (const v of this.castDistanceVariants) {
16695
+ let mask = 0xf
16696
+ if (v.subjects) {
16697
+ mask = 0
16698
+ for (const name of v.subjects) {
16699
+ const slot = this.castSlotOf.get(name)
16700
+ if (slot !== undefined) mask |= 1 << slot
16701
+ }
16702
+ }
16703
+ // Bounded by the live cast, so a mask naming a slot nobody occupies cannot
16704
+ // seed off a stale entry in the buffer.
16705
+ const want = mask & all
16706
+ if (v.mask === want) continue
16707
+ v.mask = want
16708
+ v.data[0] = want
16709
+ this.device.queue.writeBuffer(v.uniform, 0, v.data.buffer as ArrayBuffer)
16710
+ }
16711
+ }
16712
+
16272
16713
  /**
16273
16714
  * One character's slice of the effect API's view of the cast.
16274
16715
  *