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.
- package/dist/engine.d.ts +164 -13
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +468 -109
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +30 -0
- package/dist/graph/style-group.d.ts +14 -0
- package/dist/graph/style-group.d.ts.map +1 -1
- package/dist/shaders/cast-api.d.ts +16 -1
- package/dist/shaders/cast-api.d.ts.map +1 -1
- package/dist/shaders/cast-api.js +80 -14
- package/dist/shaders/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +13 -9
- package/dist/shaders/passes/cast-distance.d.ts +8 -0
- package/dist/shaders/passes/cast-distance.d.ts.map +1 -1
- package/dist/shaders/passes/cast-distance.js +13 -1
- package/dist/shaders/passes/composite.d.ts +1 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +22 -6
- package/dist/shaders/passes/grid.d.ts.map +1 -1
- package/dist/shaders/passes/grid.js +9 -0
- package/dist/shaders/passes/particles.d.ts.map +1 -1
- package/dist/shaders/passes/particles.js +8 -6
- package/dist/shaders/passes/trails.d.ts.map +1 -1
- package/dist/shaders/passes/trails.js +16 -11
- package/package.json +1 -1
- package/src/engine.ts +550 -109
- package/src/graph/registry.ts +32 -0
- package/src/graph/style-group.ts +14 -0
- package/src/shaders/cast-api.ts +82 -14
- package/src/shaders/lights.ts +13 -9
- package/src/shaders/passes/cast-distance.ts +13 -1
- package/src/shaders/passes/composite.ts +22 -6
- package/src/shaders/passes/grid.ts +9 -0
- package/src/shaders/passes/particles.ts +8 -6
- 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
|
-
|
|
2016
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
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
|
-
/**
|
|
3770
|
-
|
|
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.
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
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
|
-
|
|
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 ||
|
|
4031
|
+
if (!this.castDistanceWanted || this.castDistanceVariants.length === 0) return
|
|
3831
4032
|
this.writeCastSeedProps()
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
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
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 (!
|
|
11100
|
-
return
|
|
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
|
|
15458
|
-
*
|
|
15459
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|