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