reze-engine 0.54.7 → 0.54.8
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 +44 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +235 -1
- package/dist/shaders/passes/cast-distance.d.ts +52 -0
- package/dist/shaders/passes/cast-distance.d.ts.map +1 -0
- package/dist/shaders/passes/cast-distance.js +269 -0
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +5 -0
- package/package.json +1 -1
- package/src/engine.ts +258 -1
- package/src/shaders/passes/cast-distance.ts +280 -0
- package/src/shaders/passes/composite.ts +5 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { EFFECT_SUBJECTS } from "../cast-layout";
|
|
2
|
+
// How far is this pixel from the cast? — the primitive behind every silhouette
|
|
3
|
+
// look, computed once and read by anyone.
|
|
4
|
+
//
|
|
5
|
+
// AN EFFECT CANNOT ANSWER THIS ITSELF, and that is the whole reason this pass
|
|
6
|
+
// exists. Distance to the nearest drawn pixel is not a function of the pixel;
|
|
7
|
+
// it is a function of every pixel around it. A shader with one pass can only go
|
|
8
|
+
// looking, and searching a disc costs O(radius^2) per pixel — measured on the
|
|
9
|
+
// first sticker-outline effect, a 9 pixel border cost 64 id samples on every
|
|
10
|
+
// background pixel and a 16 pixel one cost 96 to 128. The look everybody wants
|
|
11
|
+
// next is a wide one, and that is the direction the cost runs away in.
|
|
12
|
+
//
|
|
13
|
+
// A JUMP FLOOD costs log2(resolution) passes and answers for ANY distance. It
|
|
14
|
+
// is the same 37 million reads at 1080p whether the effect wants a 4 pixel
|
|
15
|
+
// border or a 400 pixel aura, and it is shared: two effects reading it pay for
|
|
16
|
+
// it once.
|
|
17
|
+
//
|
|
18
|
+
// NO CAP ON THE DISTANCE. A partial flood — stopping early to save a pass —
|
|
19
|
+
// would silently limit how far an effect could reach, and the engine has no
|
|
20
|
+
// business deciding that. The author writes the falloff and pays for the width
|
|
21
|
+
// they asked for in their own arithmetic, not in ours.
|
|
22
|
+
//
|
|
23
|
+
// FULL RESOLUTION, after trying not to be.
|
|
24
|
+
//
|
|
25
|
+
// Half res was tried twice and rejected on sight both times, and the reason is
|
|
26
|
+
// worth recording because the arithmetic looked fine. A half-res distance field
|
|
27
|
+
// really does place the edge to within a pixel — measured against an exact
|
|
28
|
+
// transform, 0.22 px mean and 0.68 px at the 99th percentile. What that average
|
|
29
|
+
// hides is that the error is not noise: it VARIES along the silhouette, so the
|
|
30
|
+
// border breathes in and out by a fraction of a pixel as the edge turns, and an
|
|
31
|
+
// edge that wobbles reads as jagged no matter how smoothly it is feathered.
|
|
32
|
+
// Seeding at the sub-texel centroid rather than the texel centre halved it
|
|
33
|
+
// (0.83 to 0.28 px mean on a curve) and it was still visible.
|
|
34
|
+
//
|
|
35
|
+
// So the field is the size of the frame and the seeds are exact. What that costs
|
|
36
|
+
// at 1080p is about 224 million texture reads a frame, against 47 million at
|
|
37
|
+
// half — real, and the honest price of an edge that does not crawl. It is still
|
|
38
|
+
// the same cost whatever width an effect asks for, which is the whole point: the
|
|
39
|
+
// per-pixel search this replaced cost 64 reads a pixel for a 9 px border and
|
|
40
|
+
// four times that for an 18 px one.
|
|
41
|
+
/** Whether any of this effect's source reads the field, and so whether the
|
|
42
|
+
* engine should spend the passes building it. Nothing else turns it on. */
|
|
43
|
+
export function castDistanceUsed(wgsl) {
|
|
44
|
+
return /\brzCastDistance\s*\(/.test(wgsl);
|
|
45
|
+
}
|
|
46
|
+
/** Seed and ping-pong target: the nearest seed's texel coordinate, or (-1,-1).
|
|
47
|
+
* 32-bit because these are coordinates — a half would quantise them at 2048
|
|
48
|
+
* and put the seam of that error straight through a 4K frame. */
|
|
49
|
+
export const CAST_SEED_FORMAT = "rg32float";
|
|
50
|
+
/** The resolved distance, in FIELD texels. Sampled bilinearly, so it is filtered
|
|
51
|
+
* rather than loaded, and r32float is not filterable everywhere — r16float is,
|
|
52
|
+
* and half a texel of precision on a distance is nothing. */
|
|
53
|
+
export const CAST_DIST_FORMAT = "r16float";
|
|
54
|
+
/** Per-pixel MSAA coverage of the cast, written beside the seeds and read by the
|
|
55
|
+
* resolve to place the edge inside the seed pixel. */
|
|
56
|
+
export const CAST_COVERAGE_FORMAT = "r8unorm";
|
|
57
|
+
/** The field is the size of the frame. See the note above for what half cost. */
|
|
58
|
+
export const CAST_FIELD_DIV = 1;
|
|
59
|
+
const FULLSCREEN_VS = /* wgsl */ `
|
|
60
|
+
struct VSOut { @builtin(position) pos: vec4f };
|
|
61
|
+
|
|
62
|
+
@vertex
|
|
63
|
+
fn vs(@builtin(vertex_index) i: u32) -> VSOut {
|
|
64
|
+
// One oversized triangle rather than two: no seam down the diagonal, and the
|
|
65
|
+
// rasteriser does not have to think about a shared edge.
|
|
66
|
+
var p = array<vec2f, 3>(vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0));
|
|
67
|
+
var o: VSOut;
|
|
68
|
+
o.pos = vec4f(p[i], 0.0, 1.0);
|
|
69
|
+
return o;
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
/**
|
|
73
|
+
* Pass 1 — plant a seed on every pixel the CAST drew.
|
|
74
|
+
*
|
|
75
|
+
* The ground, a stage and a media plane all write ids exactly as she does, so
|
|
76
|
+
* seeding "anything drawn" would grow a border around the floor: a rectangle
|
|
77
|
+
* round the frame rather than a sticker. Only the subject ids seed.
|
|
78
|
+
*/
|
|
79
|
+
export function buildCastSeedShader(samples) {
|
|
80
|
+
return (
|
|
81
|
+
/* wgsl */ `
|
|
82
|
+
const RZ_SUBJECTS: i32 = ${EFFECT_SUBJECTS};
|
|
83
|
+
|
|
84
|
+
const RZ_ID_SAMPLES: i32 = ${samples};
|
|
85
|
+
|
|
86
|
+
@group(0) @binding(0) var _rzIdTex: texture_multisampled_2d<u32>;
|
|
87
|
+
@group(0) @binding(1) var<storage, read> _rzCast: array<vec4f>;
|
|
88
|
+
|
|
89
|
+
// The two cast accessors this pass needs, spelled out rather than pulled in.
|
|
90
|
+
// CAST_API brings subjects, trails, anchors and their aliases with it, and a
|
|
91
|
+
// seed pass wants none of that — it asks one question about one id.
|
|
92
|
+
fn rzSubjectCount() -> i32 {
|
|
93
|
+
var n = 0;
|
|
94
|
+
for (var i = 0; i < RZ_SUBJECTS; i++) {
|
|
95
|
+
if (_rzCast[i * 3 + 2].w > 0.0) { n = i + 1; }
|
|
96
|
+
}
|
|
97
|
+
return n;
|
|
98
|
+
}
|
|
99
|
+
fn rzSubjectId(i: i32) -> u32 {
|
|
100
|
+
if (i < 0 || i >= rzSubjectCount()) { return 0u; }
|
|
101
|
+
return u32(_rzCast[i * 3 + 1].w);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
${FULLSCREEN_VS}
|
|
105
|
+
|
|
106
|
+
struct SeedOut {
|
|
107
|
+
/** Where the nearest cast pixel is, for the flood to carry. */
|
|
108
|
+
@location(0) seed: vec2f,
|
|
109
|
+
/** How much of this pixel she covers, 0..1, for the resolve to place the edge
|
|
110
|
+
* inside it. */
|
|
111
|
+
@location(1) coverage: f32,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@fragment
|
|
115
|
+
fn fs(@builtin(position) pos: vec4f) -> SeedOut {
|
|
116
|
+
// COVERAGE, NOT A YES OR NO — this is the whole reason the border used to sit
|
|
117
|
+
// badly against her.
|
|
118
|
+
//
|
|
119
|
+
// The scene draws at 4x MSAA and the frame you see is resolved, so her edge is
|
|
120
|
+
// smooth. The id attachment is deliberately NOT resolved (an averaged id
|
|
121
|
+
// belongs to nothing), so reading sample 0 gives a hard, binary, aliased
|
|
122
|
+
// silhouette — a different edge from the one she is drawn with. A border grown
|
|
123
|
+
// off that can never sit flush against her: it traces the staircase while she
|
|
124
|
+
// has none.
|
|
125
|
+
//
|
|
126
|
+
// Counting the samples recovers what MSAA already knew. Four samples give five
|
|
127
|
+
// levels of coverage, the resolve turns that into a sub-pixel zero crossing,
|
|
128
|
+
// and the border meets her where she actually ends.
|
|
129
|
+
let dim = vec2<i32>(textureDimensions(_rzIdTex));
|
|
130
|
+
let p = clamp(vec2<i32>(pos.xy), vec2<i32>(0), dim - vec2<i32>(1));
|
|
131
|
+
let n = rzSubjectCount();
|
|
132
|
+
var covered = 0.0;
|
|
133
|
+
for (var sample = 0; sample < RZ_ID_SAMPLES; sample++) {
|
|
134
|
+
let o = textureLoad(_rzIdTex, p, sample).y;
|
|
135
|
+
if (o == 0u) { continue; }
|
|
136
|
+
for (var i = 0; i < n; i++) {
|
|
137
|
+
if (o == rzSubjectId(i)) { covered += 1.0; break; }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
var out: SeedOut;
|
|
141
|
+
out.coverage = covered / f32(RZ_ID_SAMPLES);
|
|
142
|
+
// Anything she touches at all seeds, including a pixel she barely clips: a
|
|
143
|
+
// sliver is where the sub-pixel edge lives, and dropping it would put the
|
|
144
|
+
// staircase straight back.
|
|
145
|
+
// -1 is the empty marker, and every step below tests for it before believing
|
|
146
|
+
// a candidate.
|
|
147
|
+
out.seed = select(vec2f(-1.0, -1.0), pos.xy, covered > 0.0);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
`);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Pass 2 — one jump-flood step, run once per halving of the stride.
|
|
154
|
+
*
|
|
155
|
+
* Each texel asks its eight neighbours at the current stride what seed THEY
|
|
156
|
+
* know about, and keeps whichever is nearest to itself. Starting at half the
|
|
157
|
+
* field's width and halving to one, a seed reaches every texel that is nearer
|
|
158
|
+
* to it than to any other — which is the definition of the field.
|
|
159
|
+
*/
|
|
160
|
+
export function buildCastStepShader() {
|
|
161
|
+
return /* wgsl */ `
|
|
162
|
+
@group(0) @binding(0) var _rzPrev: texture_2d<f32>;
|
|
163
|
+
@group(0) @binding(1) var<uniform> _rzStep: vec4f; // (stride, _, _, _)
|
|
164
|
+
|
|
165
|
+
${FULLSCREEN_VS}
|
|
166
|
+
|
|
167
|
+
@fragment
|
|
168
|
+
fn fs(@builtin(position) pos: vec4f) -> @location(0) vec2f {
|
|
169
|
+
let me = pos.xy;
|
|
170
|
+
let dim = vec2<i32>(textureDimensions(_rzPrev));
|
|
171
|
+
let stride = i32(_rzStep.x);
|
|
172
|
+
var best = textureLoad(_rzPrev, vec2<i32>(me), 0).xy;
|
|
173
|
+
// Squared throughout: the comparison is the only thing that matters and a
|
|
174
|
+
// square root per candidate is nine of them per texel per pass.
|
|
175
|
+
var bestD = select(1.0e30, dot(best - me, best - me), best.x >= 0.0);
|
|
176
|
+
for (var dy = -1; dy <= 1; dy++) {
|
|
177
|
+
for (var dx = -1; dx <= 1; dx++) {
|
|
178
|
+
let p = clamp(vec2<i32>(me) + vec2<i32>(dx, dy) * stride, vec2<i32>(0), dim - vec2<i32>(1));
|
|
179
|
+
let c = textureLoad(_rzPrev, p, 0).xy;
|
|
180
|
+
if (c.x < 0.0) { continue; }
|
|
181
|
+
let d = dot(c - me, c - me);
|
|
182
|
+
if (d < bestD) { bestD = d; best = c; }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return best;
|
|
186
|
+
}
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Pass 3 — turn the seed coordinates into a distance.
|
|
191
|
+
*
|
|
192
|
+
* Its own pass because the READ is bilinear. Interpolating coordinates across
|
|
193
|
+
* the boundary between two seeds averages two unrelated points and lands the
|
|
194
|
+
* result on neither; interpolating the DISTANCE is the smooth, honest thing,
|
|
195
|
+
* and it is what makes a half-res field cut a full-res edge.
|
|
196
|
+
*/
|
|
197
|
+
export function buildCastResolveShader() {
|
|
198
|
+
return /* wgsl */ `
|
|
199
|
+
@group(0) @binding(0) var _rzSeed: texture_2d<f32>;
|
|
200
|
+
@group(0) @binding(1) var _rzCoverage: texture_2d<f32>;
|
|
201
|
+
|
|
202
|
+
${FULLSCREEN_VS}
|
|
203
|
+
|
|
204
|
+
@fragment
|
|
205
|
+
fn fs(@builtin(position) pos: vec4f) -> @location(0) f32 {
|
|
206
|
+
let s = textureLoad(_rzSeed, vec2<i32>(pos.xy), 0).xy;
|
|
207
|
+
// No cast on screen at all: everything is unreachably far from her, which is
|
|
208
|
+
// the answer that makes an effect draw nothing rather than everything.
|
|
209
|
+
if (s.x < 0.0) { return 1.0e30; }
|
|
210
|
+
let c = textureLoad(_rzCoverage, vec2<i32>(s), 0).x;
|
|
211
|
+
// SIGNED, WITH THE ZERO CROSSING INSIDE THE SEED PIXEL.
|
|
212
|
+
//
|
|
213
|
+
// A fully covered seed means her true edge runs about half a pixel outside its
|
|
214
|
+
// centre, so the distance to the EDGE is half a pixel less than the distance to
|
|
215
|
+
// the centre. A half-covered seed has the edge through its centre and wants no
|
|
216
|
+
// correction. A barely covered one has the edge nearly a half-pixel further in.
|
|
217
|
+
// All three are (c - 0.5).
|
|
218
|
+
//
|
|
219
|
+
// It also makes the field NEGATIVE inside her, and that is what lets an effect
|
|
220
|
+
// fade where it meets her rather than stopping dead on a texel boundary — the
|
|
221
|
+
// difference between a border that is attached and one that is merely nearby.
|
|
222
|
+
return length(s - pos.xy) - (c - 0.5);
|
|
223
|
+
}
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* `rzCastDistance(uv)`, for the effects that read it.
|
|
228
|
+
*
|
|
229
|
+
* ALWAYS COMPILED IN, even when the pass is not running — it then samples a 1x1
|
|
230
|
+
* holding a very large number, so an effect keyed on it draws nothing instead of
|
|
231
|
+
* failing to compile. Same rule the grid's accessor follows, and the id
|
|
232
|
+
* accessors before it: an author should never have to guard a name.
|
|
233
|
+
*/
|
|
234
|
+
export function castDistanceApi(group, tex, samp, scale) {
|
|
235
|
+
return /* wgsl */ `
|
|
236
|
+
@group(${group}) @binding(${tex}) var _rzCastDistTex: texture_2d<f32>;
|
|
237
|
+
@group(${group}) @binding(${samp}) var _rzCastDistSamp: sampler;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Distance from this point to the nearest pixel the CAST drew, in SCREEN pixels.
|
|
241
|
+
*
|
|
242
|
+
* ZERO ON HER, positive outside, and the crossing is sub-pixel — it comes from
|
|
243
|
+
* MSAA coverage, so it lands on the same edge she is drawn with rather than on
|
|
244
|
+
* the staircase a single sample sees. Fade an effect across zero and it attaches
|
|
245
|
+
* to her cleanly.
|
|
246
|
+
*
|
|
247
|
+
* IT IS NOT AN INTERIOR DEPTH. Every pixel she covers is a seed, so the nearest
|
|
248
|
+
* seed to a pixel deep inside her is itself: the answer there is 0, not "far
|
|
249
|
+
* in". What the coverage buys is half a pixel of sub-pixel placement at the
|
|
250
|
+
* boundary, which is exactly enough to anti-alias against her and no more — so
|
|
251
|
+
* the value inside her runs to -0.5 and no further. An effect that wants to
|
|
252
|
+
* reach INTO her wants a second flood seeded on the background, which this pass
|
|
253
|
+
* does not build. Guard on d >= 0 and fade across it.
|
|
254
|
+
*
|
|
255
|
+
* There is no ceiling. A pixel on the far side of the frame gets the real
|
|
256
|
+
* number. The ground, a stage and a media plane are not the cast and do not
|
|
257
|
+
* seed it.
|
|
258
|
+
*
|
|
259
|
+
* Screen pixels whatever the field's own resolution is, so an author writes the
|
|
260
|
+
* width they mean and never has to know how this is built. uv is the effect's
|
|
261
|
+
* own convention, origin bottom-left; the flip to texture rows happens here.
|
|
262
|
+
*/
|
|
263
|
+
fn rzCastDistance(uv: vec2f) -> f32 {
|
|
264
|
+
let flipped = vec2f(clamp(uv.x, 0.0, 1.0), 1.0 - clamp(uv.y, 0.0, 1.0));
|
|
265
|
+
let d = textureSampleLevel(_rzCastDistTex, _rzCastDistSamp, flipped, 0.0).x;
|
|
266
|
+
return d * ${scale > 0 ? scale : 1}.0;
|
|
267
|
+
}
|
|
268
|
+
`;
|
|
269
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AA0BA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BAgC+B;AAC/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,EAAE,CAIhG;AAID,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAEzG,KAAK,qBAAqB,GAAG;IAC3B,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;IACtB,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB;mFAC+E;IAC/E,GAAG,CAAC,EAAE,OAAO,CAAA;IACb;;;yEAGqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB;;8EAE0E;IAC1E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB,CAAA;AA6LD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,yqaA+H5B,CAAA;AA4LD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAQlF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAgFtE;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
|
|
@@ -4,6 +4,7 @@ import { CAST_API } from "../cast-api";
|
|
|
4
4
|
import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api";
|
|
5
5
|
import { audioApi } from "../audio-api";
|
|
6
6
|
import { idApi } from "../id-api";
|
|
7
|
+
import { castDistanceApi, CAST_FIELD_DIV } from "./cast-distance";
|
|
7
8
|
import { lyricsApi, lyricsTextApi } from "../lyrics-api";
|
|
8
9
|
import { midiApi } from "../midi-api";
|
|
9
10
|
import { gridReadApi } from "./grid";
|
|
@@ -633,6 +634,10 @@ export function buildFieldShader(effect) {
|
|
|
633
634
|
viewportApi("viewU[6].w") +
|
|
634
635
|
trailSlotsApi(effect.trailCount) +
|
|
635
636
|
idApi(effect.ids === true, 0, 23) +
|
|
637
|
+
// Distance to the cast, in SCREEN pixels: the field is half-res, so a field
|
|
638
|
+
// texel is CAST_FIELD_DIV of them and the accessor scales on the way out.
|
|
639
|
+
// An author writes the width they mean and never learns how it is built.
|
|
640
|
+
castDistanceApi(0, 26, 18, CAST_FIELD_DIV) +
|
|
636
641
|
"\n// ── user effect (setEffect) ──\n" +
|
|
637
642
|
effect.paramsDecl +
|
|
638
643
|
"\n" +
|
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -83,6 +83,16 @@ import {
|
|
|
83
83
|
buildSimShader,
|
|
84
84
|
gridEntryPoint,
|
|
85
85
|
} from "./shaders/passes/grid"
|
|
86
|
+
import {
|
|
87
|
+
buildCastResolveShader,
|
|
88
|
+
buildCastSeedShader,
|
|
89
|
+
buildCastStepShader,
|
|
90
|
+
castDistanceUsed,
|
|
91
|
+
CAST_COVERAGE_FORMAT,
|
|
92
|
+
CAST_DIST_FORMAT,
|
|
93
|
+
CAST_FIELD_DIV,
|
|
94
|
+
CAST_SEED_FORMAT,
|
|
95
|
+
} from "./shaders/passes/cast-distance"
|
|
86
96
|
import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails"
|
|
87
97
|
import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
|
|
88
98
|
import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
|
|
@@ -1337,6 +1347,9 @@ interface EffectInstance {
|
|
|
1337
1347
|
* change while an effect is installed, and the frame path should not be
|
|
1338
1348
|
* running regexes. */
|
|
1339
1349
|
readsIds: boolean
|
|
1350
|
+
/** Does this source read the distance-to-cast field? Parsed at install for the
|
|
1351
|
+
* same reason readsIds is, and it turns the whole flood on by itself. */
|
|
1352
|
+
readsCastDistance: boolean
|
|
1340
1353
|
/** Bones this source asked for, in ITS OWN declaration order. The scene table
|
|
1341
1354
|
* maps these onto shared addresses; this list is what it is rebuilt from. */
|
|
1342
1355
|
anchors: { bone: string; trail: boolean }[]
|
|
@@ -1686,6 +1699,34 @@ export class Engine {
|
|
|
1686
1699
|
string,
|
|
1687
1700
|
{ modelName: string; groupId: string; paramId: string; keys: ParamKey[]; last: ParamValue | null }
|
|
1688
1701
|
>()
|
|
1702
|
+
/**
|
|
1703
|
+
* The distance-to-cast field: seeds ping-ponged by a jump flood, then resolved.
|
|
1704
|
+
*
|
|
1705
|
+
* All null until some installed effect names rzCastDistance. Nothing is
|
|
1706
|
+
* allocated and no pass is encoded for a scene that never asks — the same
|
|
1707
|
+
* bargain the grid and the id buffer strike.
|
|
1708
|
+
*/
|
|
1709
|
+
private castSeedTextures: (GPUTexture | null)[] = [null, null]
|
|
1710
|
+
private castSeedViews: (GPUTextureView | null)[] = [null, null]
|
|
1711
|
+
private castCoverageTexture: GPUTexture | null = null
|
|
1712
|
+
private castCoverageView: GPUTextureView | null = null
|
|
1713
|
+
private castDistTexture: GPUTexture | null = null
|
|
1714
|
+
private castDistView: GPUTextureView | null = null
|
|
1715
|
+
/** 1x1 holding half-float 65504, bound whenever the pass is not running: an
|
|
1716
|
+
* effect keyed on distance then finds the cast unreachably far and draws
|
|
1717
|
+
* nothing, rather than the accessor being a name that does not exist. */
|
|
1718
|
+
private castDistFallback: GPUTexture | null = null
|
|
1719
|
+
private castDistFallbackView: GPUTextureView | null = null
|
|
1720
|
+
private castSeedPipeline: GPURenderPipeline | null = null
|
|
1721
|
+
private castStepPipeline: GPURenderPipeline | null = null
|
|
1722
|
+
private castResolvePipeline: GPURenderPipeline | null = null
|
|
1723
|
+
private castSeedBindGroup: GPUBindGroup | null = null
|
|
1724
|
+
private castStepBindGroups: GPUBindGroup[] = []
|
|
1725
|
+
private castResolveBindGroup: GPUBindGroup | null = null
|
|
1726
|
+
private castStepStrideBuffers: GPUBuffer[] = []
|
|
1727
|
+
/** Does anything installed actually read the field? Set from the effect list. */
|
|
1728
|
+
private castDistanceWanted = false
|
|
1729
|
+
|
|
1689
1730
|
private fieldBgTextures: (GPUTexture | null)[] = [null, null]
|
|
1690
1731
|
private fieldBgViews: (GPUTextureView | null)[] = [null, null]
|
|
1691
1732
|
private fieldFgTextures: (GPUTexture | null)[] = [null, null]
|
|
@@ -2795,8 +2836,149 @@ export class Engine {
|
|
|
2795
2836
|
return this.fieldPairUsed(layer) && view ? view : this.trailFallbackView
|
|
2796
2837
|
}
|
|
2797
2838
|
|
|
2839
|
+
/**
|
|
2840
|
+
* The distance field's textures, and the bind groups that walk the flood.
|
|
2841
|
+
*
|
|
2842
|
+
* Rebuilt with the field targets, because it is sized off the same swap chain
|
|
2843
|
+
* and a resize invalidates every view. Torn down entirely when nothing reads
|
|
2844
|
+
* it: the memory is two coordinate targets and a distance one at half res,
|
|
2845
|
+
* which is real, and a scene with no such effect should not hold it.
|
|
2846
|
+
*/
|
|
2847
|
+
private createCastDistanceTargets(): void {
|
|
2848
|
+
for (const t of this.castSeedTextures) t?.destroy()
|
|
2849
|
+
this.castCoverageTexture?.destroy()
|
|
2850
|
+
this.castDistTexture?.destroy()
|
|
2851
|
+
for (const b of this.castStepStrideBuffers) b.destroy()
|
|
2852
|
+
this.castSeedTextures = [null, null]
|
|
2853
|
+
this.castSeedViews = [null, null]
|
|
2854
|
+
this.castCoverageTexture = null
|
|
2855
|
+
this.castCoverageView = null
|
|
2856
|
+
this.castDistTexture = null
|
|
2857
|
+
this.castDistView = null
|
|
2858
|
+
this.castStepStrideBuffers = []
|
|
2859
|
+
this.castStepBindGroups = []
|
|
2860
|
+
this.castSeedBindGroup = null
|
|
2861
|
+
this.castResolveBindGroup = null
|
|
2862
|
+
if (!this.device || !this.castDistanceWanted || this.fieldFullW === 0) return
|
|
2863
|
+
if (!this.castSeedPipeline || !this.castStepPipeline || !this.castResolvePipeline) return
|
|
2864
|
+
|
|
2865
|
+
const w = Math.max(1, Math.ceil(this.fieldFullW / CAST_FIELD_DIV))
|
|
2866
|
+
const h = Math.max(1, Math.ceil(this.fieldFullH / CAST_FIELD_DIV))
|
|
2867
|
+
for (let i = 0; i < 2; i++) {
|
|
2868
|
+
this.castSeedTextures[i] = this.device.createTexture({
|
|
2869
|
+
label: `cast distance seeds ${i}`,
|
|
2870
|
+
size: [w, h],
|
|
2871
|
+
format: CAST_SEED_FORMAT,
|
|
2872
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
2873
|
+
})
|
|
2874
|
+
this.castSeedViews[i] = this.castSeedTextures[i]!.createView()
|
|
2875
|
+
}
|
|
2876
|
+
this.castCoverageTexture = this.device.createTexture({
|
|
2877
|
+
label: "cast coverage",
|
|
2878
|
+
size: [w, h],
|
|
2879
|
+
format: CAST_COVERAGE_FORMAT,
|
|
2880
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
2881
|
+
})
|
|
2882
|
+
this.castCoverageView = this.castCoverageTexture.createView()
|
|
2883
|
+
this.castDistTexture = this.device.createTexture({
|
|
2884
|
+
label: "cast distance",
|
|
2885
|
+
size: [w, h],
|
|
2886
|
+
format: CAST_DIST_FORMAT,
|
|
2887
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
2888
|
+
})
|
|
2889
|
+
this.castDistView = this.castDistTexture.createView()
|
|
2890
|
+
|
|
2891
|
+
// The flood starts at half the longest side and halves to one. That is what
|
|
2892
|
+
// makes it exact everywhere rather than out to some radius: every seed gets
|
|
2893
|
+
// the chance to reach every texel it is nearest to.
|
|
2894
|
+
const strides: number[] = []
|
|
2895
|
+
for (let k = 1 << Math.ceil(Math.log2(Math.max(w, h))); k >= 1; k >>= 1) strides.push(k)
|
|
2896
|
+
|
|
2897
|
+
this.castSeedBindGroup = this.device.createBindGroup({
|
|
2898
|
+
label: "cast distance seed",
|
|
2899
|
+
layout: this.castSeedPipeline.getBindGroupLayout(0),
|
|
2900
|
+
entries: [
|
|
2901
|
+
{ binding: 0, resource: this.idView! },
|
|
2902
|
+
{ binding: 1, resource: { buffer: this.castBuffer } },
|
|
2903
|
+
],
|
|
2904
|
+
})
|
|
2905
|
+
strides.forEach((stride, i) => {
|
|
2906
|
+
const buf = this.device!.createBuffer({
|
|
2907
|
+
label: `cast distance stride ${stride}`,
|
|
2908
|
+
size: 16,
|
|
2909
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2910
|
+
})
|
|
2911
|
+
this.device!.queue.writeBuffer(buf, 0, new Float32Array([stride, 0, 0, 0]))
|
|
2912
|
+
this.castStepStrideBuffers.push(buf)
|
|
2913
|
+
// Pass i reads the texture pass i-1 wrote. The seed lands in 0, so an even
|
|
2914
|
+
// pass reads 0 and writes 1.
|
|
2915
|
+
this.castStepBindGroups.push(
|
|
2916
|
+
this.device!.createBindGroup({
|
|
2917
|
+
label: `cast distance step ${stride}`,
|
|
2918
|
+
layout: this.castStepPipeline!.getBindGroupLayout(0),
|
|
2919
|
+
entries: [
|
|
2920
|
+
{ binding: 0, resource: this.castSeedViews[i % 2]! },
|
|
2921
|
+
{ binding: 1, resource: { buffer: buf } },
|
|
2922
|
+
],
|
|
2923
|
+
}),
|
|
2924
|
+
)
|
|
2925
|
+
})
|
|
2926
|
+
this.castResolveBindGroup = this.device.createBindGroup({
|
|
2927
|
+
label: "cast distance resolve",
|
|
2928
|
+
layout: this.castResolvePipeline.getBindGroupLayout(0),
|
|
2929
|
+
entries: [
|
|
2930
|
+
{ binding: 0, resource: this.castSeedViews[strides.length % 2]! },
|
|
2931
|
+
{ binding: 1, resource: this.castCoverageView! },
|
|
2932
|
+
],
|
|
2933
|
+
})
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
/**
|
|
2937
|
+
* The flood, encoded once a frame before the field pass reads it.
|
|
2938
|
+
*
|
|
2939
|
+
* Seed, then one pass per halving of the stride, then resolve to a distance.
|
|
2940
|
+
* Nothing here depends on how far any effect intends to look — that is the
|
|
2941
|
+
* whole point of paying for it in passes rather than in per-pixel search.
|
|
2942
|
+
*/
|
|
2943
|
+
private encodeCastDistance(encoder: GPUCommandEncoder): void {
|
|
2944
|
+
if (!this.castDistanceWanted || !this.castSeedBindGroup || !this.castResolveBindGroup) return
|
|
2945
|
+
const seed = encoder.beginRenderPass({
|
|
2946
|
+
label: "cast distance (seed)",
|
|
2947
|
+
colorAttachments: [
|
|
2948
|
+
{ view: this.castSeedViews[0]!, loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" },
|
|
2949
|
+
{ view: this.castCoverageView!, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" },
|
|
2950
|
+
],
|
|
2951
|
+
})
|
|
2952
|
+
seed.setPipeline(this.castSeedPipeline!)
|
|
2953
|
+
seed.setBindGroup(0, this.castSeedBindGroup)
|
|
2954
|
+
seed.draw(3)
|
|
2955
|
+
seed.end()
|
|
2956
|
+
|
|
2957
|
+
this.castStepBindGroups.forEach((group, i) => {
|
|
2958
|
+
const pass = encoder.beginRenderPass({
|
|
2959
|
+
label: "cast distance (flood)",
|
|
2960
|
+
colorAttachments: [{ view: this.castSeedViews[(i + 1) % 2]!, loadOp: "clear", clearValue: { r: -1, g: -1, b: 0, a: 0 }, storeOp: "store" }],
|
|
2961
|
+
})
|
|
2962
|
+
pass.setPipeline(this.castStepPipeline!)
|
|
2963
|
+
pass.setBindGroup(0, group)
|
|
2964
|
+
pass.draw(3)
|
|
2965
|
+
pass.end()
|
|
2966
|
+
})
|
|
2967
|
+
|
|
2968
|
+
const resolve = encoder.beginRenderPass({
|
|
2969
|
+
label: "cast distance (resolve)",
|
|
2970
|
+
colorAttachments: [{ view: this.castDistView!, loadOp: "clear", clearValue: { r: 0, g: 0, b: 0, a: 0 }, storeOp: "store" }],
|
|
2971
|
+
})
|
|
2972
|
+
resolve.setPipeline(this.castResolvePipeline!)
|
|
2973
|
+
resolve.setBindGroup(0, this.castResolveBindGroup)
|
|
2974
|
+
resolve.draw(3)
|
|
2975
|
+
resolve.end()
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2798
2978
|
private createFieldTargets(): void {
|
|
2799
2979
|
if (!this.device || this.fieldFullW === 0) return
|
|
2980
|
+
// Same swap chain, same invalidation.
|
|
2981
|
+
this.createCastDistanceTargets()
|
|
2800
2982
|
for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
|
|
2801
2983
|
const scale = Engine.FIELD_SCALES[i]
|
|
2802
2984
|
const w = Math.max(1, Math.ceil(this.fieldFullW / scale))
|
|
@@ -2857,6 +3039,7 @@ export class Engine {
|
|
|
2857
3039
|
...(this.idView ? [{ binding: 23, resource: this.idView }] : []),
|
|
2858
3040
|
{ binding: 17, resource: grid },
|
|
2859
3041
|
{ binding: 18, resource: this.simSampler },
|
|
3042
|
+
{ binding: 26, resource: this.castDistView ?? this.castDistFallbackView! },
|
|
2860
3043
|
],
|
|
2861
3044
|
})
|
|
2862
3045
|
// Per effect: the params buffer and the grid are both its own, so two
|
|
@@ -3422,6 +3605,7 @@ export class Engine {
|
|
|
3422
3605
|
// so matching against it would report every effect as a reader and the
|
|
3423
3606
|
// attachment would be stored exactly as often as before.
|
|
3424
3607
|
readsIds: /\brz(?:ObjectAt|MaterialAt)\s*\(/.test(wgsl),
|
|
3608
|
+
readsCastDistance: castDistanceUsed(wgsl),
|
|
3425
3609
|
anchors,
|
|
3426
3610
|
// The effect's own clock starts now. Per effect so that one installed
|
|
3427
3611
|
// later still gets a frame where rzGridFrame() is 0 and can seed.
|
|
@@ -3571,6 +3755,17 @@ export class Engine {
|
|
|
3571
3755
|
this.releaseTrails()
|
|
3572
3756
|
this.releaseGrid()
|
|
3573
3757
|
this.effects = instances
|
|
3758
|
+
// Turn the distance field on or off with the list that asked for it. Only
|
|
3759
|
+
// rebuilds when the answer CHANGES: the targets are half-res render
|
|
3760
|
+
// attachments and reallocating them per install would churn them for every
|
|
3761
|
+
// unrelated effect a scene adds.
|
|
3762
|
+
const wantsCastDistance = instances.some((e) => e.readsCastDistance)
|
|
3763
|
+
if (wantsCastDistance !== this.castDistanceWanted) {
|
|
3764
|
+
this.castDistanceWanted = wantsCastDistance
|
|
3765
|
+
this.createCastDistanceTargets()
|
|
3766
|
+
// The field bind groups name the distance texture, and it has just been
|
|
3767
|
+
// created or destroyed — they are rebuilt below with the new list anyway.
|
|
3768
|
+
}
|
|
3574
3769
|
// The new list's emitters need their slots before the next frame reads them.
|
|
3575
3770
|
this.allocateLightSlots()
|
|
3576
3771
|
// A rig the cap refused is worth saying out loud on the effect that asked
|
|
@@ -4207,6 +4402,10 @@ export class Engine {
|
|
|
4207
4402
|
// two 16MB writes a frame to produce the transparent black the fallback
|
|
4208
4403
|
// already is. Most scenes leave the full-res pair empty, since an effect only
|
|
4209
4404
|
// lands there by declaring #fullres.
|
|
4405
|
+
// The distance field, before anything reads it. Returns immediately when no
|
|
4406
|
+
// installed effect names rzCastDistance, which is the common case.
|
|
4407
|
+
this.encodeCastDistance(encoder)
|
|
4408
|
+
|
|
4210
4409
|
let stamped = false
|
|
4211
4410
|
for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
|
|
4212
4411
|
const bg = this.fieldBgViews[i]
|
|
@@ -5202,12 +5401,68 @@ export class Engine {
|
|
|
5202
5401
|
: []),
|
|
5203
5402
|
{ binding: 17, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d" } },
|
|
5204
5403
|
{ binding: 18, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
|
|
5404
|
+
// Distance to the cast. Always in the layout — the accessor is always
|
|
5405
|
+
// compiled, and a 1x1 stands in when the flood is not running, so an
|
|
5406
|
+
// author never has to guard the name.
|
|
5407
|
+
{ binding: 26, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d" } },
|
|
5205
5408
|
],
|
|
5206
5409
|
})
|
|
5207
5410
|
this.fieldPipelineLayout = this.device.createPipelineLayout({
|
|
5208
5411
|
bindGroupLayouts: [this.fieldBindGroupLayout],
|
|
5209
5412
|
})
|
|
5210
5413
|
|
|
5414
|
+
// ── The distance-to-cast field's three pipelines ──
|
|
5415
|
+
//
|
|
5416
|
+
// Built once, whether or not anything reads the field: they cost a shader
|
|
5417
|
+
// module each and nothing per frame, and building them lazily would put a
|
|
5418
|
+
// pipeline compile in the frame where an author first types the name.
|
|
5419
|
+
{
|
|
5420
|
+
const seedModule = this.device.createShaderModule({ label: "cast distance seed", code: buildCastSeedShader(Engine.MULTISAMPLE_COUNT) })
|
|
5421
|
+
const stepModule = this.device.createShaderModule({ label: "cast distance step", code: buildCastStepShader() })
|
|
5422
|
+
const resolveModule = this.device.createShaderModule({ label: "cast distance resolve", code: buildCastResolveShader() })
|
|
5423
|
+
this.castSeedPipeline = this.device.createRenderPipeline({
|
|
5424
|
+
label: "cast distance seed",
|
|
5425
|
+
layout: "auto",
|
|
5426
|
+
vertex: { module: seedModule, entryPoint: "vs" },
|
|
5427
|
+
fragment: {
|
|
5428
|
+
module: seedModule,
|
|
5429
|
+
entryPoint: "fs",
|
|
5430
|
+
targets: [{ format: CAST_SEED_FORMAT }, { format: CAST_COVERAGE_FORMAT }],
|
|
5431
|
+
},
|
|
5432
|
+
primitive: { topology: "triangle-list" },
|
|
5433
|
+
})
|
|
5434
|
+
this.castStepPipeline = this.device.createRenderPipeline({
|
|
5435
|
+
label: "cast distance step",
|
|
5436
|
+
layout: "auto",
|
|
5437
|
+
vertex: { module: stepModule, entryPoint: "vs" },
|
|
5438
|
+
fragment: { module: stepModule, entryPoint: "fs", targets: [{ format: CAST_SEED_FORMAT }] },
|
|
5439
|
+
primitive: { topology: "triangle-list" },
|
|
5440
|
+
})
|
|
5441
|
+
this.castResolvePipeline = this.device.createRenderPipeline({
|
|
5442
|
+
label: "cast distance resolve",
|
|
5443
|
+
layout: "auto",
|
|
5444
|
+
vertex: { module: resolveModule, entryPoint: "vs" },
|
|
5445
|
+
fragment: { module: resolveModule, entryPoint: "fs", targets: [{ format: CAST_DIST_FORMAT }] },
|
|
5446
|
+
primitive: { topology: "triangle-list" },
|
|
5447
|
+
})
|
|
5448
|
+
// 65504 is the largest half float. Bound wherever the field is not
|
|
5449
|
+
// running, so rzCastDistance answers "unreachably far" and an effect keyed
|
|
5450
|
+
// on it draws nothing at all.
|
|
5451
|
+
this.castDistFallback = this.device.createTexture({
|
|
5452
|
+
label: "cast distance fallback (1x1, far)",
|
|
5453
|
+
size: [1, 1],
|
|
5454
|
+
format: CAST_DIST_FORMAT,
|
|
5455
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
5456
|
+
})
|
|
5457
|
+
this.device.queue.writeTexture(
|
|
5458
|
+
{ texture: this.castDistFallback },
|
|
5459
|
+
new Uint16Array([0x7bff]),
|
|
5460
|
+
{ bytesPerRow: 2 },
|
|
5461
|
+
{ width: 1, height: 1 },
|
|
5462
|
+
)
|
|
5463
|
+
this.castDistFallbackView = this.castDistFallback.createView()
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5211
5466
|
this.fallbackMaterialTexture = this.device.createTexture({
|
|
5212
5467
|
label: "fallback material texture (1x1 white)",
|
|
5213
5468
|
size: [1, 1],
|
|
@@ -10884,7 +11139,9 @@ export class Engine {
|
|
|
10884
11139
|
// whose result no one is going to read.
|
|
10885
11140
|
const idAtt = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[2]
|
|
10886
11141
|
if (idAtt) {
|
|
10887
|
-
|
|
11142
|
+
// The flood seeds from the id attachment, so an effect that reads only the
|
|
11143
|
+
// DISTANCE still needs the ids kept — it just never names them itself.
|
|
11144
|
+
const idsRead = this.idDebug || this.effects.some((e) => e.readsIds || e.readsCastDistance)
|
|
10888
11145
|
idAtt.storeOp = idsRead ? "store" : "discard"
|
|
10889
11146
|
}
|
|
10890
11147
|
|