reze-engine 0.56.14 → 0.57.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engine.d.ts +164 -13
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +468 -109
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +30 -0
- package/dist/graph/style-group.d.ts +14 -0
- package/dist/graph/style-group.d.ts.map +1 -1
- package/dist/shaders/cast-api.d.ts +16 -1
- package/dist/shaders/cast-api.d.ts.map +1 -1
- package/dist/shaders/cast-api.js +80 -14
- package/dist/shaders/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +13 -9
- package/dist/shaders/passes/cast-distance.d.ts +8 -0
- package/dist/shaders/passes/cast-distance.d.ts.map +1 -1
- package/dist/shaders/passes/cast-distance.js +13 -1
- package/dist/shaders/passes/composite.d.ts +1 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +22 -6
- package/dist/shaders/passes/grid.d.ts.map +1 -1
- package/dist/shaders/passes/grid.js +9 -0
- package/dist/shaders/passes/particles.d.ts.map +1 -1
- package/dist/shaders/passes/particles.js +8 -6
- package/dist/shaders/passes/trails.d.ts.map +1 -1
- package/dist/shaders/passes/trails.js +16 -11
- package/package.json +1 -1
- package/src/engine.ts +550 -109
- package/src/graph/registry.ts +32 -0
- package/src/graph/style-group.ts +14 -0
- package/src/shaders/cast-api.ts +82 -14
- package/src/shaders/lights.ts +13 -9
- package/src/shaders/passes/cast-distance.ts +13 -1
- package/src/shaders/passes/composite.ts +22 -6
- package/src/shaders/passes/grid.ts +9 -0
- package/src/shaders/passes/particles.ts +8 -6
- package/src/shaders/passes/trails.ts +15 -10
package/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;
|
|
@@ -1010,7 +1053,6 @@ export class Engine {
|
|
|
1010
1053
|
*/
|
|
1011
1054
|
this.worldEquirectTexture = null;
|
|
1012
1055
|
this.worldEquirectView = null;
|
|
1013
|
-
this.worldStrength = 1;
|
|
1014
1056
|
/** A three-colour sky's irradiance SH, when the world is a gradient rather
|
|
1015
1057
|
* than a picture. An HDRI outranks it — see writeWorld. */
|
|
1016
1058
|
this.worldGradientSH = null;
|
|
@@ -1031,6 +1073,9 @@ export class Engine {
|
|
|
1031
1073
|
/** Subjects the cast actually holds, set while it is filled. The ribbons size
|
|
1032
1074
|
* their instance count by this rather than by the four-subject cap. */
|
|
1033
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();
|
|
1034
1079
|
this.effects = [];
|
|
1035
1080
|
/** Just the header, for rewriting the total without touching a record. */
|
|
1036
1081
|
this.lightHeader = new Float32Array(LIGHT_HEADER);
|
|
@@ -1659,13 +1704,15 @@ export class Engine {
|
|
|
1659
1704
|
// exposure and view transform as the scene — a sun rolls off like a sun).
|
|
1660
1705
|
// The camera basis at u[12..23] is refreshed per frame.
|
|
1661
1706
|
// In modes 2 and 3 the colour slot is dead, so mode 3 carries the world
|
|
1662
|
-
// STRENGTH in u[8] — Blender's world-strength dial,
|
|
1707
|
+
// STRENGTH in u[8] — Blender's world-strength dial, the SAME number that
|
|
1708
|
+
// scales the irradiance. A sky you can see at full brightness while it
|
|
1709
|
+
// lights at two thirds is two skies.
|
|
1663
1710
|
const bg = this.backgroundColor;
|
|
1664
1711
|
// THE BACKDROP WINS WHAT YOU SEE; the world lights regardless. With only a
|
|
1665
1712
|
// world installed it is also the sky, which is what an HDRI alone has
|
|
1666
1713
|
// always done.
|
|
1667
1714
|
const showingWorld = this.backdropEquirectView === null && this.worldEquirectView !== null;
|
|
1668
|
-
u[8] = showingWorld ? this.
|
|
1715
|
+
u[8] = showingWorld ? this.world.strength : (bg?.x ?? 0);
|
|
1669
1716
|
u[9] = bg?.y ?? 0;
|
|
1670
1717
|
u[10] = bg?.z ?? 0;
|
|
1671
1718
|
// Base-layer mode only. A user effect is a separate LAYER over whichever
|
|
@@ -2519,19 +2566,15 @@ export class Engine {
|
|
|
2519
2566
|
for (const t of this.castSeedTextures)
|
|
2520
2567
|
t?.destroy();
|
|
2521
2568
|
this.castCoverageTexture?.destroy();
|
|
2522
|
-
this.castDistTexture?.destroy();
|
|
2523
2569
|
for (const b of this.castStepStrideBuffers)
|
|
2524
2570
|
b.destroy();
|
|
2571
|
+
this.releaseCastDistanceVariants();
|
|
2525
2572
|
this.castSeedTextures = [null, null];
|
|
2526
2573
|
this.castSeedViews = [null, null];
|
|
2527
2574
|
this.castCoverageTexture = null;
|
|
2528
2575
|
this.castCoverageView = null;
|
|
2529
|
-
this.castDistTexture = null;
|
|
2530
|
-
this.castDistView = null;
|
|
2531
2576
|
this.castStepStrideBuffers = [];
|
|
2532
2577
|
this.castStepBindGroups = [];
|
|
2533
|
-
this.castSeedBindGroup = null;
|
|
2534
|
-
this.castResolveBindGroup = null;
|
|
2535
2578
|
if (!this.device || !this.castDistanceWanted || this.fieldFullW === 0)
|
|
2536
2579
|
return;
|
|
2537
2580
|
if (!this.castSeedPipeline || !this.castStepPipeline || !this.castResolvePipeline)
|
|
@@ -2554,20 +2597,12 @@ export class Engine {
|
|
|
2554
2597
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
2555
2598
|
});
|
|
2556
2599
|
this.castCoverageView = this.castCoverageTexture.createView();
|
|
2557
|
-
this.castDistTexture = this.device.createTexture({
|
|
2558
|
-
label: "cast distance",
|
|
2559
|
-
size: [w, h],
|
|
2560
|
-
format: CAST_DIST_FORMAT,
|
|
2561
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
2562
|
-
});
|
|
2563
|
-
this.castDistView = this.castDistTexture.createView();
|
|
2564
2600
|
// The flood starts at half the longest side and halves to one. That is what
|
|
2565
2601
|
// makes it exact everywhere rather than out to some radius: every seed gets
|
|
2566
2602
|
// the chance to reach every texel it is nearest to.
|
|
2567
2603
|
const strides = [];
|
|
2568
2604
|
for (let k = 1 << Math.ceil(Math.log2(Math.max(w, h))); k >= 1; k >>= 1)
|
|
2569
2605
|
strides.push(k);
|
|
2570
|
-
this.createCastSeedBindGroup();
|
|
2571
2606
|
strides.forEach((stride, i) => {
|
|
2572
2607
|
const buf = this.device.createBuffer({
|
|
2573
2608
|
label: `cast distance stride ${stride}`,
|
|
@@ -2587,18 +2622,38 @@ export class Engine {
|
|
|
2587
2622
|
],
|
|
2588
2623
|
}));
|
|
2589
2624
|
});
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
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 = [];
|
|
2598
2639
|
}
|
|
2599
|
-
/**
|
|
2600
|
-
|
|
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) {
|
|
2601
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
|
+
}
|
|
2602
2657
|
if (!this.castSeedPropBuffer) {
|
|
2603
2658
|
this.castSeedPropBuffer = device.createBuffer({
|
|
2604
2659
|
label: "cast distance prop seeds",
|
|
@@ -2607,15 +2662,83 @@ export class Engine {
|
|
|
2607
2662
|
});
|
|
2608
2663
|
device.queue.writeBuffer(this.castSeedPropBuffer, 0, this.castSeedPropData);
|
|
2609
2664
|
}
|
|
2610
|
-
this.
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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;
|
|
2619
2742
|
}
|
|
2620
2743
|
/**
|
|
2621
2744
|
* Which props seed the field this frame: every visible one.
|
|
@@ -2636,7 +2759,10 @@ export class Engine {
|
|
|
2636
2759
|
this.castSeedPropData = data;
|
|
2637
2760
|
this.castSeedPropBuffer?.destroy();
|
|
2638
2761
|
this.castSeedPropBuffer = null;
|
|
2639
|
-
|
|
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();
|
|
2640
2766
|
changed = true;
|
|
2641
2767
|
}
|
|
2642
2768
|
data[0] = n;
|
|
@@ -2659,38 +2785,44 @@ export class Engine {
|
|
|
2659
2785
|
* whole point of paying for it in passes rather than in per-pixel search.
|
|
2660
2786
|
*/
|
|
2661
2787
|
encodeCastDistance(encoder) {
|
|
2662
|
-
if (!this.castDistanceWanted ||
|
|
2788
|
+
if (!this.castDistanceWanted || this.castDistanceVariants.length === 0)
|
|
2663
2789
|
return;
|
|
2664
2790
|
this.writeCastSeedProps();
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
this.castStepBindGroups.forEach((group, i) => {
|
|
2677
|
-
const pass = encoder.beginRenderPass({
|
|
2678
|
-
label: "cast distance (flood)",
|
|
2679
|
-
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
|
+
],
|
|
2680
2802
|
});
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
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
|
+
}
|
|
2694
2826
|
}
|
|
2695
2827
|
createFieldTargets() {
|
|
2696
2828
|
if (!this.device || this.fieldFullW === 0)
|
|
@@ -2803,7 +2935,7 @@ export class Engine {
|
|
|
2803
2935
|
...(this.idView ? [{ binding: 23, resource: this.idView }] : []),
|
|
2804
2936
|
{ binding: 17, resource: grid },
|
|
2805
2937
|
{ binding: 18, resource: this.simSampler },
|
|
2806
|
-
{ binding: 26, resource: this.
|
|
2938
|
+
{ binding: 26, resource: this.castDistViewFor(owner) },
|
|
2807
2939
|
{ binding: 27, resource: this.hdrResolveTexture.createView() },
|
|
2808
2940
|
{ binding: 28, resource: this.simSampler },
|
|
2809
2941
|
// The other effects' layers, for rzSceneFrame — see the loop below.
|
|
@@ -2927,7 +3059,7 @@ export class Engine {
|
|
|
2927
3059
|
* `strength` is Blender's world-strength dial and is folded into the
|
|
2928
3060
|
* coefficients, so what lights her is what you see.
|
|
2929
3061
|
*/
|
|
2930
|
-
setWorldEquirect(source
|
|
3062
|
+
setWorldEquirect(source) {
|
|
2931
3063
|
// RETIRED, NOT DESTROYED. A frame already encoded against the old sky can
|
|
2932
3064
|
// still be in flight — the bind groups that name it are rebuilt below, but
|
|
2933
3065
|
// the command buffer holding the previous ones is submitted at the end of
|
|
@@ -2938,7 +3070,6 @@ export class Engine {
|
|
|
2938
3070
|
this.retiredSkies.push(this.worldEquirectTexture);
|
|
2939
3071
|
this.worldEquirectTexture = null;
|
|
2940
3072
|
this.worldEquirectView = null;
|
|
2941
|
-
this.worldStrength = Math.max(options?.strength ?? 1, 0);
|
|
2942
3073
|
const hadSH = this.worldSH !== null;
|
|
2943
3074
|
this.worldSH = null;
|
|
2944
3075
|
if (source && this.device) {
|
|
@@ -2993,11 +3124,13 @@ export class Engine {
|
|
|
2993
3124
|
// The sky lights the scene, not only backs it. The sun keeps the toon
|
|
2994
3125
|
// ramp — this is the ambient term, exactly where the flat world colour
|
|
2995
3126
|
// used to sit.
|
|
3127
|
+
// RAW irradiance. The World strength dial is applied where every other
|
|
3128
|
+
// reading of it is, in writeWorld — folding it in here made the sky's
|
|
3129
|
+
// brightness a function of WHEN it was installed: the dial moved
|
|
3130
|
+
// afterwards rescaled the write and not the projection, and an install
|
|
3131
|
+
// that landed mid-session carried a different strength from the same
|
|
3132
|
+
// document reopened.
|
|
2996
3133
|
this.worldSH = projectIrradianceSH({ ...source, data: source.data }, 4);
|
|
2997
|
-
if (this.worldStrength !== 1) {
|
|
2998
|
-
for (let i = 0; i < this.worldSH.length; i++)
|
|
2999
|
-
this.worldSH[i] *= this.worldStrength;
|
|
3000
|
-
}
|
|
3001
3134
|
}
|
|
3002
3135
|
if (this.worldSH || hadSH)
|
|
3003
3136
|
this.writeWorld();
|
|
@@ -3119,7 +3252,7 @@ export class Engine {
|
|
|
3119
3252
|
alias) {
|
|
3120
3253
|
const noMounts = { background: false, foreground: false };
|
|
3121
3254
|
if (!this.device)
|
|
3122
|
-
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 };
|
|
3123
3256
|
// WHAT THE FILE DECLARES, read once. Everything below takes it from `d`
|
|
3124
3257
|
// rather than running a regex of its own — eight parsers over one file was
|
|
3125
3258
|
// eight chances to disagree about what it said, and they did.
|
|
@@ -3130,7 +3263,7 @@ export class Engine {
|
|
|
3130
3263
|
// typo was indistinguishable from prose and could only ever be warned about.
|
|
3131
3264
|
const parsed = parseDirectives(authored);
|
|
3132
3265
|
if (parsed.errors.length)
|
|
3133
|
-
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 };
|
|
3134
3267
|
const d = parsed.directives;
|
|
3135
3268
|
// The compiler sees the file with its directive lines BLANKED, so every
|
|
3136
3269
|
// diagnostic below still names the line the author is looking at.
|
|
@@ -3153,7 +3286,7 @@ export class Engine {
|
|
|
3153
3286
|
return { ok: false, diagnostics: [
|
|
3154
3287
|
`a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
|
|
3155
3288
|
`fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
|
|
3156
|
-
], mounts: noMounts, params: [], duration: 0 };
|
|
3289
|
+
], mounts: noMounts, params: [], duration: 0, readsCast: false };
|
|
3157
3290
|
}
|
|
3158
3291
|
if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
|
|
3159
3292
|
const missing = [
|
|
@@ -3161,7 +3294,7 @@ export class Engine {
|
|
|
3161
3294
|
pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
|
|
3162
3295
|
pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
|
|
3163
3296
|
].filter(Boolean);
|
|
3164
|
-
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 };
|
|
3165
3298
|
}
|
|
3166
3299
|
// One file, one kind — for now.
|
|
3167
3300
|
//
|
|
@@ -3179,7 +3312,7 @@ export class Engine {
|
|
|
3179
3312
|
return { ok: false, diagnostics: [
|
|
3180
3313
|
"an effect declares field mounts (background/foreground) or particles, not both — " +
|
|
3181
3314
|
"split them into two effects",
|
|
3182
|
-
], mounts: noMounts, params: [], duration: 0 };
|
|
3315
|
+
], mounts: noMounts, params: [], duration: 0, readsCast: false };
|
|
3183
3316
|
}
|
|
3184
3317
|
// lightEmit counts as a mount on its own: a pure lighting rig draws nothing
|
|
3185
3318
|
// and is still an effect — it is how a scene gets stage lights without also
|
|
@@ -3195,7 +3328,7 @@ export class Engine {
|
|
|
3195
3328
|
"the ribbon pair (trailWidth/trailShade), " +
|
|
3196
3329
|
"fn lightEmit(i: u32) -> RzLight with #lights <n>, " +
|
|
3197
3330
|
"or #mirror",
|
|
3198
|
-
], mounts: noMounts, params: [], duration: 0 };
|
|
3331
|
+
], mounts: noMounts, params: [], duration: 0, readsCast: false };
|
|
3199
3332
|
}
|
|
3200
3333
|
const mounts = { background: hasBackground, foreground: hasForeground };
|
|
3201
3334
|
// ── Directives only some mounts honour ──
|
|
@@ -3247,7 +3380,7 @@ export class Engine {
|
|
|
3247
3380
|
let cursor = 0;
|
|
3248
3381
|
for (const p of d.params) {
|
|
3249
3382
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(p.name)) {
|
|
3250
|
-
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 };
|
|
3251
3384
|
}
|
|
3252
3385
|
// KIND comes from the declaration, never from the runtime type of the
|
|
3253
3386
|
// value: a caller handing a number to a colour must not silently turn a
|
|
@@ -3303,7 +3436,7 @@ export class Engine {
|
|
|
3303
3436
|
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source });
|
|
3304
3437
|
const scopeErr = await this.device.popErrorScope();
|
|
3305
3438
|
if (scopeErr)
|
|
3306
|
-
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 };
|
|
3307
3440
|
// Declared like every other mount property: by what the source says, not by
|
|
3308
3441
|
// a setting somewhere else that an author cannot see from the file.
|
|
3309
3442
|
const layerBlend = d.additiveLayer
|
|
@@ -3323,7 +3456,7 @@ export class Engine {
|
|
|
3323
3456
|
if (diagnostics.length === 0 && fieldScopeErr)
|
|
3324
3457
|
diagnostics.push(fieldScopeErr.message);
|
|
3325
3458
|
if (diagnostics.length > 0)
|
|
3326
|
-
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 };
|
|
3327
3460
|
try {
|
|
3328
3461
|
fieldPipeline = await this.device.createRenderPipelineAsync({
|
|
3329
3462
|
label: "field layer pipeline",
|
|
@@ -3355,7 +3488,7 @@ export class Engine {
|
|
|
3355
3488
|
});
|
|
3356
3489
|
}
|
|
3357
3490
|
catch (e) {
|
|
3358
|
-
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 };
|
|
3359
3492
|
}
|
|
3360
3493
|
}
|
|
3361
3494
|
let identity;
|
|
@@ -3379,7 +3512,7 @@ export class Engine {
|
|
|
3379
3512
|
]);
|
|
3380
3513
|
}
|
|
3381
3514
|
catch (e) {
|
|
3382
|
-
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 };
|
|
3383
3516
|
}
|
|
3384
3517
|
// Built BEFORE the swap: a particle stage that fails to compile has to leave
|
|
3385
3518
|
// the previously installed effect running, exactly as a bad composite does.
|
|
@@ -3430,7 +3563,7 @@ export class Engine {
|
|
|
3430
3563
|
grid?.textures[1].destroy();
|
|
3431
3564
|
grid?.uniform.destroy();
|
|
3432
3565
|
trails?.uniform.destroy();
|
|
3433
|
-
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 };
|
|
3434
3567
|
};
|
|
3435
3568
|
if (wantsParticles) {
|
|
3436
3569
|
const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor);
|
|
@@ -3503,6 +3636,19 @@ export class Engine {
|
|
|
3503
3636
|
// attachment would be stored exactly as often as before.
|
|
3504
3637
|
readsIds: /\brz(?:ObjectAt|MaterialAt)\s*\(/.test(wgsl),
|
|
3505
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,
|
|
3506
3652
|
anchors,
|
|
3507
3653
|
// The effect's own clock starts now. Per effect so that one installed
|
|
3508
3654
|
// later still gets a frame where rzGridFrame() is 0 and can seed.
|
|
@@ -3581,7 +3727,7 @@ export class Engine {
|
|
|
3581
3727
|
if (!this.device) {
|
|
3582
3728
|
if (!list || list.length === 0)
|
|
3583
3729
|
return [];
|
|
3584
|
-
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 }];
|
|
3585
3731
|
}
|
|
3586
3732
|
const requested = list ?? [];
|
|
3587
3733
|
if (requested.length === 0) {
|
|
@@ -3622,6 +3768,7 @@ export class Engine {
|
|
|
3622
3768
|
results.push(built);
|
|
3623
3769
|
continue;
|
|
3624
3770
|
}
|
|
3771
|
+
built.instance.subjects = normalizeSubjects(requested[i].subjects);
|
|
3625
3772
|
instances.push(built.instance);
|
|
3626
3773
|
results.push({
|
|
3627
3774
|
ok: true,
|
|
@@ -3631,6 +3778,9 @@ export class Engine {
|
|
|
3631
3778
|
// but will never fire. Same channel as the dropped-anchor note below.
|
|
3632
3779
|
diagnostics: built.warnings,
|
|
3633
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,
|
|
3634
3784
|
});
|
|
3635
3785
|
}
|
|
3636
3786
|
// An anchor the cap refused is worth saying out loud on the effect that
|
|
@@ -3663,6 +3813,12 @@ export class Engine {
|
|
|
3663
3813
|
// The field bind groups name the distance texture, and it has just been
|
|
3664
3814
|
// created or destroyed — they are rebuilt below with the new list anyway.
|
|
3665
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
|
+
}
|
|
3666
3822
|
// The new list's emitters need their slots before the next frame reads them.
|
|
3667
3823
|
this.allocateLightSlots();
|
|
3668
3824
|
// A rig the cap refused is worth saying out loud on the effect that asked
|
|
@@ -3720,10 +3876,10 @@ export class Engine {
|
|
|
3720
3876
|
const noMounts = { background: false, foreground: false };
|
|
3721
3877
|
if (wgsl === null) {
|
|
3722
3878
|
await this.setEffects(null);
|
|
3723
|
-
return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0 };
|
|
3879
|
+
return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0, readsCast: false };
|
|
3724
3880
|
}
|
|
3725
3881
|
const [result] = await this.setEffects([{ wgsl, params }]);
|
|
3726
|
-
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 };
|
|
3727
3883
|
}
|
|
3728
3884
|
async buildParticles(
|
|
3729
3885
|
/** Already stripped of directives — see compileEffect. */
|
|
@@ -3780,7 +3936,7 @@ export class Engine {
|
|
|
3780
3936
|
});
|
|
3781
3937
|
const uniform = this.device.createBuffer({
|
|
3782
3938
|
label: "particle uniforms",
|
|
3783
|
-
// Two vec4-sized rows: (time, dt, count, frame) and (weight,
|
|
3939
|
+
// Two vec4-sized rows: (time, dt, count, frame) and (weight, subjects, _, _).
|
|
3784
3940
|
// The first was exactly full, and weight has to live in the same buffer
|
|
3785
3941
|
// as the clock or a frame could draw one without the other.
|
|
3786
3942
|
size: 32,
|
|
@@ -3928,7 +4084,10 @@ export class Engine {
|
|
|
3928
4084
|
const diagnostics = info.messages.filter((m) => m.type === "error").map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
|
|
3929
4085
|
if (diagnostics.length)
|
|
3930
4086
|
return { ok: false, diagnostics };
|
|
3931
|
-
|
|
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);
|
|
3932
4091
|
const uniform = this.device.createBuffer({
|
|
3933
4092
|
label: "light emit uniform",
|
|
3934
4093
|
size: data.byteLength,
|
|
@@ -4020,6 +4179,9 @@ export class Engine {
|
|
|
4020
4179
|
// per effect now, so every mount in one file agrees by construction.
|
|
4021
4180
|
l.data[0] = this.sceneClock - e.epochScene;
|
|
4022
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;
|
|
4023
4185
|
this.device.queue.writeBuffer(l.uniform, 0, l.data.buffer);
|
|
4024
4186
|
const cp = encoder.beginComputePass({ label: "light emit" });
|
|
4025
4187
|
cp.setPipeline(l.pipeline);
|
|
@@ -4049,6 +4211,9 @@ export class Engine {
|
|
|
4049
4211
|
// state it left rather than the one it would have reached, so fading one
|
|
4050
4212
|
// back in would rewind it.
|
|
4051
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;
|
|
4052
4217
|
// Clamped: a backgrounded tab returns with a delta of whole seconds, and an
|
|
4053
4218
|
// unclamped step flings every particle out of the scene in one frame.
|
|
4054
4219
|
// A scheduled effect steps by the transport instead, clamped the same way
|
|
@@ -4267,11 +4432,16 @@ export class Engine {
|
|
|
4267
4432
|
// The shader decodes [ribbon][subject][segment] with the same number out
|
|
4268
4433
|
// of its uniform, so the two cannot drift: change one without the other
|
|
4269
4434
|
// and ribbons land on the wrong subject rather than merely costing more.
|
|
4270
|
-
|
|
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);
|
|
4271
4440
|
if (view === "camera") {
|
|
4272
4441
|
t.data[0] = this.sceneClock - e.epochScene;
|
|
4273
4442
|
t.data[1] = live;
|
|
4274
4443
|
t.data[2] = e.weight;
|
|
4444
|
+
t.data[3] = e.subjectMask;
|
|
4275
4445
|
this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer);
|
|
4276
4446
|
}
|
|
4277
4447
|
pass.setPipeline(t.pipeline);
|
|
@@ -4310,6 +4480,10 @@ export class Engine {
|
|
|
4310
4480
|
continue;
|
|
4311
4481
|
this.fieldClockScratch[0] = this.sceneClock - e.epochScene;
|
|
4312
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;
|
|
4313
4487
|
this.device.queue.writeBuffer(e.fieldClock, 0, this.fieldClockScratch.buffer);
|
|
4314
4488
|
}
|
|
4315
4489
|
// ONE PASS PER RESOLUTION, N draws each, in document order — a pair is
|
|
@@ -4507,7 +4681,9 @@ export class Engine {
|
|
|
4507
4681
|
const read = [textures[0].createView(), textures[1].createView()];
|
|
4508
4682
|
const uniform = this.device.createBuffer({
|
|
4509
4683
|
label: "grid uniforms",
|
|
4510
|
-
|
|
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,
|
|
4511
4687
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
4512
4688
|
});
|
|
4513
4689
|
this.device.pushErrorScope("validation");
|
|
@@ -4536,7 +4712,7 @@ export class Engine {
|
|
|
4536
4712
|
layout,
|
|
4537
4713
|
binds: [bindFor(0), bindFor(1)],
|
|
4538
4714
|
uniform,
|
|
4539
|
-
data: new Float32Array(
|
|
4715
|
+
data: new Float32Array(8),
|
|
4540
4716
|
parity: 0,
|
|
4541
4717
|
frame: 0,
|
|
4542
4718
|
params: params.buffer,
|
|
@@ -4595,6 +4771,7 @@ export class Engine {
|
|
|
4595
4771
|
grid.data[1] = scheduled ? e.simStep : Math.min(0.1, Math.max(0, deltaTime));
|
|
4596
4772
|
grid.data[2] = grid.size;
|
|
4597
4773
|
grid.data[3] = grid.frame++;
|
|
4774
|
+
grid.data[4] = e.subjectMask;
|
|
4598
4775
|
this.device.queue.writeBuffer(grid.uniform, 0, grid.data.buffer);
|
|
4599
4776
|
const cp = encoder.beginComputePass({ label: "grid" });
|
|
4600
4777
|
cp.setPipeline(grid.pipeline);
|
|
@@ -4669,6 +4846,45 @@ export class Engine {
|
|
|
4669
4846
|
getEffectInfluence(index) {
|
|
4670
4847
|
return this.effects[index]?.influence ?? 0;
|
|
4671
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
|
+
}
|
|
4672
4888
|
/**
|
|
4673
4889
|
* Schedule one instance: when it is alive, and how it enters and leaves.
|
|
4674
4890
|
*
|
|
@@ -8569,6 +8785,54 @@ export class Engine {
|
|
|
8569
8785
|
getModel(name) {
|
|
8570
8786
|
return this.modelInstances.get(name)?.model ?? null;
|
|
8571
8787
|
}
|
|
8788
|
+
/**
|
|
8789
|
+
* What the engine is actually about to draw, per model.
|
|
8790
|
+
*
|
|
8791
|
+
* Every model the engine holds, not every model the host thinks it holds —
|
|
8792
|
+
* which is the point. A scene that comes up brighter after an upload than the
|
|
8793
|
+
* same document does after a reload differs somewhere between the two, and
|
|
8794
|
+
* the candidates are all countable: a model left behind by a replace draws its
|
|
8795
|
+
* transparent surfaces a second time, and a group that compiled but claimed
|
|
8796
|
+
* nothing leaves its materials on the default graph. Both are invisible from
|
|
8797
|
+
* the host, which sees its own lists rather than the engine's.
|
|
8798
|
+
*
|
|
8799
|
+
* `grouped` counts draw calls bound to a style group; `ungrouped` is the rest,
|
|
8800
|
+
* on the default graph.
|
|
8801
|
+
*/
|
|
8802
|
+
/**
|
|
8803
|
+
* What the composite is about to put behind the scene, and at what level.
|
|
8804
|
+
*
|
|
8805
|
+
* `mode` is the base layer: 0 transparent, 1 a flat colour, 2 an LDR 360
|
|
8806
|
+
* picture, 3 the HDR world as scene-linear radiance. `level` is what mode 3
|
|
8807
|
+
* is multiplied by. The whole frame's brightness turns on these two, and
|
|
8808
|
+
* neither is visible from a host reading its own document — a scene that came
|
|
8809
|
+
* up brighter after an upload than after a reload of the same document is one
|
|
8810
|
+
* of the cases they answer.
|
|
8811
|
+
*/
|
|
8812
|
+
getBackgroundState() {
|
|
8813
|
+
const showingWorld = this.backdropEquirectView === null && this.worldEquirectView !== null;
|
|
8814
|
+
return {
|
|
8815
|
+
mode: this.backdropEquirectView ? 2 : showingWorld ? 3 : this.backgroundColor ? 1 : 0,
|
|
8816
|
+
level: this.world.strength,
|
|
8817
|
+
backdrop: this.backdropEquirectView !== null,
|
|
8818
|
+
world: this.worldEquirectView !== null,
|
|
8819
|
+
};
|
|
8820
|
+
}
|
|
8821
|
+
getDrawStats() {
|
|
8822
|
+
const out = [];
|
|
8823
|
+
for (const [name, inst] of this.modelInstances) {
|
|
8824
|
+
const shaded = inst.drawCalls.filter((d) => d.baseBindGroupEntries);
|
|
8825
|
+
out.push({
|
|
8826
|
+
model: name,
|
|
8827
|
+
materials: shaded.length,
|
|
8828
|
+
opaque: shaded.filter((d) => d.type === "opaque").length,
|
|
8829
|
+
transparent: shaded.filter((d) => d.type === "transparent").length,
|
|
8830
|
+
grouped: shaded.filter((d) => d.groupId !== null).length,
|
|
8831
|
+
ungrouped: shaded.filter((d) => d.groupId === null).length,
|
|
8832
|
+
});
|
|
8833
|
+
}
|
|
8834
|
+
return out;
|
|
8835
|
+
}
|
|
8572
8836
|
/**
|
|
8573
8837
|
* Hang a model from a bone of another — MMD's 外部親 (outside parent).
|
|
8574
8838
|
*
|
|
@@ -9432,13 +9696,16 @@ export class Engine {
|
|
|
9432
9696
|
install.uniformBuffer.destroy();
|
|
9433
9697
|
for (const tex of install.images ?? [])
|
|
9434
9698
|
tex?.destroy();
|
|
9699
|
+
for (const set of Object.values(install.imagesByMaterial ?? {}))
|
|
9700
|
+
for (const tex of set)
|
|
9701
|
+
tex?.destroy();
|
|
9435
9702
|
}
|
|
9436
9703
|
/** Upload a group's image maps. Sources are decoded images the host already
|
|
9437
9704
|
* holds; the engine never fetches, matching how models and motions arrive. */
|
|
9438
|
-
uploadGroupImages(group) {
|
|
9439
|
-
if (!
|
|
9705
|
+
uploadGroupImages(group, slots = group.images) {
|
|
9706
|
+
if (!slots?.length)
|
|
9440
9707
|
return undefined;
|
|
9441
|
-
return
|
|
9708
|
+
return slots.slice(0, 4).map((entry) => {
|
|
9442
9709
|
if (!entry)
|
|
9443
9710
|
return null;
|
|
9444
9711
|
const wrapped = "source" in entry;
|
|
@@ -13267,15 +13534,22 @@ export class Engine {
|
|
|
13267
13534
|
return true;
|
|
13268
13535
|
}
|
|
13269
13536
|
/** The model at one cast slot — the subject rzSubject(index) reads. */
|
|
13270
|
-
castSubjectName(index) {
|
|
13537
|
+
castSubjectName(index, subjects = null) {
|
|
13271
13538
|
let n = 0;
|
|
13539
|
+
let local = 0;
|
|
13272
13540
|
let found = null;
|
|
13273
13541
|
this.forEachInstance((inst) => {
|
|
13274
13542
|
if (found !== null || n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp || !inst.model.visible)
|
|
13275
13543
|
return;
|
|
13276
|
-
if (n === index)
|
|
13277
|
-
found = inst.name;
|
|
13278
13544
|
n++;
|
|
13545
|
+
// The effect's OWN index, which is what its shaders count in: an effect
|
|
13546
|
+
// aimed at the third dancer finds her at 0, so a #dissolve on it takes HER
|
|
13547
|
+
// apart rather than whoever stands at the head of the cast.
|
|
13548
|
+
if (subjects && !subjects.includes(inst.name))
|
|
13549
|
+
return;
|
|
13550
|
+
if (local === index)
|
|
13551
|
+
found = inst.name;
|
|
13552
|
+
local++;
|
|
13279
13553
|
});
|
|
13280
13554
|
return found;
|
|
13281
13555
|
}
|
|
@@ -13297,9 +13571,10 @@ export class Engine {
|
|
|
13297
13571
|
*
|
|
13298
13572
|
* Two things ask for one. A cycle set on a model (setModelDissolveCycle) runs
|
|
13299
13573
|
* on the scene clock and repeats, as it always has. An effect declaring
|
|
13300
|
-
* `#dissolve` takes subject 0 apart on
|
|
13301
|
-
*
|
|
13302
|
-
*
|
|
13574
|
+
* `#dissolve` takes ITS OWN subject 0 apart on its own terms — the first model
|
|
13575
|
+
* it is aimed at, the head of the cast when it is aimed at nobody in
|
|
13576
|
+
* particular: scheduled, the cycle on each clip's clock with her whole between
|
|
13577
|
+
* clips; unscheduled, the same repeating cycle as before. Where both ask, the more dissolved wins — two
|
|
13303
13578
|
* answers about one body, and half a body is not one of them.
|
|
13304
13579
|
*/
|
|
13305
13580
|
evaluateDissolves() {
|
|
@@ -13312,7 +13587,7 @@ export class Engine {
|
|
|
13312
13587
|
if (!fx.dissolve)
|
|
13313
13588
|
continue;
|
|
13314
13589
|
const cycle = dissolveCycleOf(this.dissolveTimingsOf(fx, fx.dissolve));
|
|
13315
|
-
const subject = this.castSubjectName(0);
|
|
13590
|
+
const subject = this.castSubjectName(0, fx.subjects);
|
|
13316
13591
|
if (!cycle || !subject)
|
|
13317
13592
|
continue;
|
|
13318
13593
|
if (fx.window && fx.window.length > 0) {
|
|
@@ -13348,7 +13623,19 @@ export class Engine {
|
|
|
13348
13623
|
async compileAndInstallGroup(inst, group, opts) {
|
|
13349
13624
|
const renderClass = group.renderClass ?? "auto";
|
|
13350
13625
|
const alphaMode = group.alphaMode ?? "opaque";
|
|
13351
|
-
|
|
13626
|
+
// THE MAPS ARE PART OF THE SIGNATURE, because a matching one skips the
|
|
13627
|
+
// upload below and keeps whatever the install already holds. A group can
|
|
13628
|
+
// arrive twice — once as the model lands and again once its sidecar has
|
|
13629
|
+
// decoded — and without this the second apply refreshed the definition and
|
|
13630
|
+
// silently kept the empty slots from the first.
|
|
13631
|
+
const signature = JSON.stringify({
|
|
13632
|
+
g: group.graph,
|
|
13633
|
+
rc: renderClass,
|
|
13634
|
+
am: alphaMode,
|
|
13635
|
+
o: opts?.previewNode ?? null,
|
|
13636
|
+
im: group.images?.length ?? 0,
|
|
13637
|
+
ibm: Object.keys(group.imagesByMaterial ?? {}).sort(),
|
|
13638
|
+
});
|
|
13352
13639
|
const existing = inst.styleGroups.get(group.id);
|
|
13353
13640
|
if (existing && existing.signature === signature) {
|
|
13354
13641
|
existing.group = group; // refresh def (label/materials) without recompiling
|
|
@@ -13418,6 +13705,9 @@ export class Engine {
|
|
|
13418
13705
|
mirrorPipeline,
|
|
13419
13706
|
uniformBuffer,
|
|
13420
13707
|
images: this.uploadGroupImages(group),
|
|
13708
|
+
imagesByMaterial: group.imagesByMaterial
|
|
13709
|
+
? Object.fromEntries(Object.entries(group.imagesByMaterial).map(([name, slots]) => [name, this.uploadGroupImages(group, slots) ?? []]))
|
|
13710
|
+
: undefined,
|
|
13421
13711
|
slotMap: result.slotMap,
|
|
13422
13712
|
signature,
|
|
13423
13713
|
});
|
|
@@ -13488,10 +13778,13 @@ export class Engine {
|
|
|
13488
13778
|
: dc.baseType;
|
|
13489
13779
|
if (dc.type !== type)
|
|
13490
13780
|
dc.type = type;
|
|
13491
|
-
|
|
13781
|
+
// Rebind when the INSTALL changed, not merely when the id did.
|
|
13782
|
+
const bound = install ?? null;
|
|
13783
|
+
if (dc.groupId === groupId && dc.boundInstall === bound)
|
|
13492
13784
|
continue;
|
|
13493
13785
|
dc.groupId = groupId;
|
|
13494
|
-
dc.
|
|
13786
|
+
dc.boundInstall = bound;
|
|
13787
|
+
dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, install ? install.uniformBuffer : this.zeroStyleBuffer, install?.imagesByMaterial?.[dc.materialName] ?? install?.images);
|
|
13495
13788
|
}
|
|
13496
13789
|
this.sortDrawCalls(inst);
|
|
13497
13790
|
}
|
|
@@ -14007,10 +14300,17 @@ export class Engine {
|
|
|
14007
14300
|
// spawned its motes off a body nobody could see, and the silhouette field
|
|
14008
14301
|
// seeded on one. The cast is who is ON STAGE.
|
|
14009
14302
|
let n = 0;
|
|
14303
|
+
this.castSlotOf.clear();
|
|
14010
14304
|
this.forEachInstance((inst) => {
|
|
14011
14305
|
if (n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp || !inst.model.visible)
|
|
14012
14306
|
return;
|
|
14013
14307
|
const m = inst.model;
|
|
14308
|
+
// WHO IS IN WHICH SLOT, recorded here because here is where it is
|
|
14309
|
+
// decided. An effect is aimed by model name, and a name only becomes a
|
|
14310
|
+
// bit in a mask once this loop has said where that model sits — a slot
|
|
14311
|
+
// is a position in the cast, not a property of a model, and a hidden
|
|
14312
|
+
// model moves everybody after it up one.
|
|
14313
|
+
this.castSlotOf.set(inst.name, n);
|
|
14014
14314
|
// The model transform is only where the model was PLACED. A motion moves
|
|
14015
14315
|
// the character by animating bones, so an effect anchored to the
|
|
14016
14316
|
// transform never follows anyone anywhere — it sits at the spawn point
|
|
@@ -14042,6 +14342,8 @@ export class Engine {
|
|
|
14042
14342
|
// drawTrails. Recorded rather than recomputed: this loop is the one place
|
|
14043
14343
|
// that knows how many subjects the cast actually ended up holding.
|
|
14044
14344
|
this.castSubjectCount = n;
|
|
14345
|
+
// Each effect's own view of that cast, before any mount reads it.
|
|
14346
|
+
this.updateSubjectMasks();
|
|
14045
14347
|
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
|
|
14046
14348
|
// Only what an effect declared, and only while one is installed. A scene
|
|
14047
14349
|
// with no effect writes nothing here at all.
|
|
@@ -14061,6 +14363,63 @@ export class Engine {
|
|
|
14061
14363
|
}
|
|
14062
14364
|
}
|
|
14063
14365
|
}
|
|
14366
|
+
/**
|
|
14367
|
+
* Every effect's own view of the cast, once a frame, right after the slots are
|
|
14368
|
+
* assigned and before any mount reads them.
|
|
14369
|
+
*
|
|
14370
|
+
* The mask is what the shaders see: CAST_API counts its bits for
|
|
14371
|
+
* rzSubjectCount() and walks them for rzSubject(i), so an effect aimed at the
|
|
14372
|
+
* third dancer alone finds her at index 0. `subjectCount` is the same number on
|
|
14373
|
+
* the CPU, for the one thing that needs it there — the ribbons' instance count,
|
|
14374
|
+
* which must agree with what the trail shader decodes or a ribbon lands on the
|
|
14375
|
+
* wrong body.
|
|
14376
|
+
*
|
|
14377
|
+
* Recomputed rather than cached against a version: it is four models, a map
|
|
14378
|
+
* lookup each, and the alternative is a cache that has to be invalidated from
|
|
14379
|
+
* every place a model can be added, removed, hidden or renamed.
|
|
14380
|
+
*/
|
|
14381
|
+
updateSubjectMasks() {
|
|
14382
|
+
const live = this.castSubjectCount;
|
|
14383
|
+
const all = live === 0 ? 0 : (1 << live) - 1;
|
|
14384
|
+
for (const e of this.effects) {
|
|
14385
|
+
let mask = 0xf;
|
|
14386
|
+
if (e.subjects) {
|
|
14387
|
+
mask = 0;
|
|
14388
|
+
for (const name of e.subjects) {
|
|
14389
|
+
const slot = this.castSlotOf.get(name);
|
|
14390
|
+
if (slot !== undefined)
|
|
14391
|
+
mask |= 1 << slot;
|
|
14392
|
+
}
|
|
14393
|
+
}
|
|
14394
|
+
e.subjectMask = mask;
|
|
14395
|
+
let n = 0;
|
|
14396
|
+
for (let i = 0; i < live; i++)
|
|
14397
|
+
if (mask & (1 << i))
|
|
14398
|
+
n++;
|
|
14399
|
+
e.subjectCount = n;
|
|
14400
|
+
}
|
|
14401
|
+
// The floods, one per distinct target set. Their masks are written here for
|
|
14402
|
+
// the same reason the effects' are: the slots have just been decided.
|
|
14403
|
+
for (const v of this.castDistanceVariants) {
|
|
14404
|
+
let mask = 0xf;
|
|
14405
|
+
if (v.subjects) {
|
|
14406
|
+
mask = 0;
|
|
14407
|
+
for (const name of v.subjects) {
|
|
14408
|
+
const slot = this.castSlotOf.get(name);
|
|
14409
|
+
if (slot !== undefined)
|
|
14410
|
+
mask |= 1 << slot;
|
|
14411
|
+
}
|
|
14412
|
+
}
|
|
14413
|
+
// Bounded by the live cast, so a mask naming a slot nobody occupies cannot
|
|
14414
|
+
// seed off a stale entry in the buffer.
|
|
14415
|
+
const want = mask & all;
|
|
14416
|
+
if (v.mask === want)
|
|
14417
|
+
continue;
|
|
14418
|
+
v.mask = want;
|
|
14419
|
+
v.data[0] = want;
|
|
14420
|
+
this.device.queue.writeBuffer(v.uniform, 0, v.data.buffer);
|
|
14421
|
+
}
|
|
14422
|
+
}
|
|
14064
14423
|
/**
|
|
14065
14424
|
* One character's slice of the effect API's view of the cast.
|
|
14066
14425
|
*
|