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