reze-engine 0.55.8 → 0.55.10
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 +22 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +193 -27
- package/dist/shaders/passes/composite.d.ts +5 -0
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +26 -4
- package/dist/shaders/scene-tap.d.ts +1 -1
- package/dist/shaders/scene-tap.d.ts.map +1 -1
- package/dist/shaders/scene-tap.js +57 -16
- package/package.json +1 -1
- package/src/engine.ts +205 -29
- package/src/shaders/passes/composite.ts +31 -4
- package/src/shaders/scene-tap.ts +62 -16
|
@@ -117,7 +117,8 @@ override APPLY_GAMMA: bool = true;
|
|
|
117
117
|
// and no on/off uniform either: the pipeline is REBUILT per effect, so
|
|
118
118
|
// the compiled variant IS the flag.
|
|
119
119
|
// viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
|
|
120
|
-
// viewU[5] = (camera forward,
|
|
120
|
+
// viewU[5] = (camera forward, half pair consumed by a filter this frame) — refreshed
|
|
121
|
+
// per frame while skybox/effect active.
|
|
121
122
|
// viewU[6] = (time seconds, view transform id, canvas width, canvas height).
|
|
122
123
|
// viewU[7] = (grade offset.rgb, contrast); viewU[8] = (grade power.rgb, saturation);
|
|
123
124
|
// viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
|
|
@@ -573,7 +574,8 @@ const BACKGROUND_CALL = /* wgsl */ `
|
|
|
573
574
|
// straight form it replaced.
|
|
574
575
|
let bgFx = rzFieldMerge(
|
|
575
576
|
clamp(textureSampleLevel(fieldBgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)),
|
|
576
|
-
|
|
577
|
+
// The half pair, unless a filter consumed it this frame (viewU[5].w).
|
|
578
|
+
clamp(textureSampleLevel(fieldBgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)) * (1.0 - viewU[5].w));
|
|
577
579
|
bgPm = bgFx.rgb + bgPm * (1.0 - bgFx.a);
|
|
578
580
|
bgA = bgFx.a + bgA * (1.0 - bgFx.a);
|
|
579
581
|
`;
|
|
@@ -584,7 +586,7 @@ const FOREGROUND_CALL = /* wgsl */ `
|
|
|
584
586
|
// Premultiplied, as the background layer above — same reason.
|
|
585
587
|
let fgFx = rzFieldMerge(
|
|
586
588
|
clamp(textureSampleLevel(fieldFgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)),
|
|
587
|
-
clamp(textureSampleLevel(fieldFgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)));
|
|
589
|
+
clamp(textureSampleLevel(fieldFgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)) * (1.0 - viewU[5].w));
|
|
588
590
|
outRgb = fgFx.rgb + outRgb * (1.0 - fgFx.a);
|
|
589
591
|
outA = fgFx.a + outA * (1.0 - fgFx.a);
|
|
590
592
|
`;
|
|
@@ -635,6 +637,16 @@ export function buildFieldShader(effect) {
|
|
|
635
637
|
const fgLine = effect.hasForeground
|
|
636
638
|
? "out.fg = clamp(foreground(dir, uv, _rzFieldClock.x, linearDepth(vec2<i32>(min(fx, fullSz - 1.0)))), vec4f(0.0), vec4f(1.0));"
|
|
637
639
|
: "";
|
|
640
|
+
// A FILTER read the other effects' layers through rzSceneFrame and now
|
|
641
|
+
// carries them: its own output goes over (or adds to) what they drew, and
|
|
642
|
+
// the pipeline writes the result with blending off. Weight has already
|
|
643
|
+
// scaled its alpha, so a filter faded to nothing passes the layers through
|
|
644
|
+
// untouched.
|
|
645
|
+
const carry = effect.filter
|
|
646
|
+
? effect.additiveLayer
|
|
647
|
+
? "out.bg = _rzCarryAdd(out.bg, _rzLayerBg(uv));\n out.fg = _rzCarryAdd(out.fg, _rzLayerFg(uv));"
|
|
648
|
+
: "out.bg = _rzCarryOver(out.bg, _rzLayerBg(uv));\n out.fg = _rzCarryOver(out.fg, _rzLayerFg(uv));"
|
|
649
|
+
: "";
|
|
638
650
|
return (COMPOSITE_HEAD +
|
|
639
651
|
EFFECT_SCENE_API +
|
|
640
652
|
anchorAliasWgsl(effect.alias ?? []) +
|
|
@@ -656,7 +668,7 @@ export function buildFieldShader(effect) {
|
|
|
656
668
|
// texel is CAST_FIELD_DIV of them and the accessor scales on the way out.
|
|
657
669
|
// An author writes the width they mean and never learns how it is built.
|
|
658
670
|
castDistanceApi(0, 26, 18, CAST_FIELD_DIV) +
|
|
659
|
-
sceneTapApi(0, 27, 28) +
|
|
671
|
+
sceneTapApi(0, 27, 28, [29, 30, 31, 32]) +
|
|
660
672
|
"\n// ── user effect (setEffect) ──\n" +
|
|
661
673
|
effect.paramsDecl +
|
|
662
674
|
"\n" +
|
|
@@ -675,6 +687,15 @@ export function buildFieldShader(effect) {
|
|
|
675
687
|
*/
|
|
676
688
|
@group(0) @binding(22) var<uniform> _rzFieldClock: vec4f; // (time, weight, _, _)
|
|
677
689
|
|
|
690
|
+
/** A filter's own straight-alpha output over the premultiplied layers it read
|
|
691
|
+
* (the layer blend state, restated) and the additive form of the same. */
|
|
692
|
+
fn _rzCarryOver(own: vec4f, under: vec4f) -> vec4f {
|
|
693
|
+
return vec4f(own.rgb * own.a + under.rgb * (1.0 - own.a), own.a + under.a * (1.0 - own.a));
|
|
694
|
+
}
|
|
695
|
+
fn _rzCarryAdd(own: vec4f, under: vec4f) -> vec4f {
|
|
696
|
+
return vec4f(own.rgb * own.a + under.rgb, own.a + under.a);
|
|
697
|
+
}
|
|
698
|
+
|
|
678
699
|
@vertex fn fieldVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
679
700
|
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
680
701
|
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
@@ -704,6 +725,7 @@ struct FieldOut {
|
|
|
704
725
|
// Scaling colour as well would fade as the square.
|
|
705
726
|
out.bg.a *= _rzFieldClock.y;
|
|
706
727
|
out.fg.a *= _rzFieldClock.y;
|
|
728
|
+
${carry}
|
|
707
729
|
return out;
|
|
708
730
|
}
|
|
709
731
|
`);
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* Clamp-to-edge is what stops a warped offset wrapping the far side of the
|
|
44
44
|
* screen into the picture.
|
|
45
45
|
*/
|
|
46
|
-
export declare function sceneTapApi(group: number, tex: number, samp: number): string;
|
|
46
|
+
export declare function sceneTapApi(group: number, tex: number, samp: number, layers: readonly [number, number, number, number]): string;
|
|
47
47
|
/**
|
|
48
48
|
* The same names, for the modules with no finished scene to give.
|
|
49
49
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scene-tap.d.ts","sourceRoot":"","sources":["../../src/shaders/scene-tap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,WAAW,
|
|
1
|
+
{"version":3,"file":"scene-tap.d.ts","sourceRoot":"","sources":["../../src/shaders/scene-tap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,GAChD,MAAM,CA6MR;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc,kjBAW1B,CAAA"}
|
|
@@ -43,10 +43,36 @@
|
|
|
43
43
|
* Clamp-to-edge is what stops a warped offset wrapping the far side of the
|
|
44
44
|
* screen into the picture.
|
|
45
45
|
*/
|
|
46
|
-
export function sceneTapApi(group, tex, samp) {
|
|
46
|
+
export function sceneTapApi(group, tex, samp, layers) {
|
|
47
47
|
return /* wgsl */ `
|
|
48
48
|
@group(${group}) @binding(${tex}) var _rzSceneTex: texture_2d<f32>;
|
|
49
49
|
@group(${group}) @binding(${samp}) var _rzSceneSamp: sampler;
|
|
50
|
+
// The field layers as the other effects left them — see rzSceneFrame. Bound to
|
|
51
|
+
// the pair the others drew into for a filter, and to a 1x1 transparent for
|
|
52
|
+
// everything else: an effect cannot sample the pair it is drawing into.
|
|
53
|
+
@group(${group}) @binding(${layers[0]}) var _rzLayerBgFull: texture_2d<f32>;
|
|
54
|
+
@group(${group}) @binding(${layers[1]}) var _rzLayerFgFull: texture_2d<f32>;
|
|
55
|
+
@group(${group}) @binding(${layers[2]}) var _rzLayerBgHalf: texture_2d<f32>;
|
|
56
|
+
@group(${group}) @binding(${layers[3]}) var _rzLayerFgHalf: texture_2d<f32>;
|
|
57
|
+
|
|
58
|
+
/** Premultiplied OVER, top in front — the composite's rzFieldMerge, restated so
|
|
59
|
+
* this file keeps importing nothing. A resolution boundary is a layer
|
|
60
|
+
* boundary: full res over half, exactly as the composite reads them. */
|
|
61
|
+
fn _rzLayerOver(top: vec4f, bot: vec4f) -> vec4f {
|
|
62
|
+
return vec4f(top.rgb + bot.rgb * (1.0 - top.a), top.a + bot.a * (1.0 - top.a));
|
|
63
|
+
}
|
|
64
|
+
fn _rzLayerBg(uv: vec2f) -> vec4f {
|
|
65
|
+
let p = _rzTapUv(uv);
|
|
66
|
+
return _rzLayerOver(
|
|
67
|
+
clamp(textureSampleLevel(_rzLayerBgFull, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)),
|
|
68
|
+
clamp(textureSampleLevel(_rzLayerBgHalf, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)));
|
|
69
|
+
}
|
|
70
|
+
fn _rzLayerFg(uv: vec2f) -> vec4f {
|
|
71
|
+
let p = _rzTapUv(uv);
|
|
72
|
+
return _rzLayerOver(
|
|
73
|
+
clamp(textureSampleLevel(_rzLayerFgFull, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)),
|
|
74
|
+
clamp(textureSampleLevel(_rzLayerFgHalf, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)));
|
|
75
|
+
}
|
|
50
76
|
|
|
51
77
|
/** uv as the mount gives it (y=0 at the BOTTOM, the shadertoy convention the
|
|
52
78
|
* composite documents) turned into a texture coordinate (y=0 at the top).
|
|
@@ -175,25 +201,40 @@ fn rzSceneDisplay(uv: vec2f) -> vec3f {
|
|
|
175
201
|
}
|
|
176
202
|
|
|
177
203
|
/**
|
|
178
|
-
* The
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
204
|
+
* The frame as it stands: the scene over its background, and both layers of
|
|
205
|
+
* every other field effect, laid down in the order the composite would lay
|
|
206
|
+
* them. One call, and the answer already carries the ground's fade, its
|
|
207
|
+
* opacity, the background showing through both, and whatever the other
|
|
208
|
+
* effects drew — a waveform, a caption, a glow.
|
|
209
|
+
*
|
|
210
|
+
* CALLING THIS MAKES THE EFFECT A FILTER. A filter runs after every other
|
|
211
|
+
* field effect, at full resolution, and what it returns replaces the layers it
|
|
212
|
+
* read: its own output is composed over them in its pass (buildFieldShader),
|
|
213
|
+
* so nothing drawn before it is lost and nothing is drawn twice. Two filters
|
|
214
|
+
* run in document order, each reading the one before. The layers are a
|
|
215
|
+
* pass-order fact — a filter reads the pair the others drew into while it
|
|
216
|
+
* draws into a page of its own (renderFieldPass).
|
|
217
|
+
*
|
|
218
|
+
* This is what a FILTER wants — anything re-rendering the whole frame on a
|
|
219
|
+
* tube, a low-resolution screen, or a card. Reaching for rzSceneDisplay and a
|
|
220
|
+
* depth test instead rebuilds this by hand and gets it wrong in one specific
|
|
221
|
+
* way every time: coverage is not a yes or no, and treating it as one turns
|
|
222
|
+
* every soft edge in the scene into a hard one.
|
|
223
|
+
*
|
|
224
|
+
* NOT INCLUDED: depth of field, and an equirect background (a 360 dome reads
|
|
225
|
+
* as its average rather than its texture).
|
|
191
226
|
*/
|
|
192
227
|
fn rzSceneFrame(uv: vec2f) -> vec3f {
|
|
193
228
|
let a = clamp(rzSceneAlpha(uv), 0.0, 1.0);
|
|
194
229
|
let bg = viewU[2];
|
|
195
|
-
let
|
|
196
|
-
|
|
230
|
+
let bgA = select(0.0, 1.0, bg.w > 0.5);
|
|
231
|
+
// The background layer over the base, the scene over both, the foreground
|
|
232
|
+
// layer over the frame — the composite's own order.
|
|
233
|
+
let lb = _rzLayerBg(uv);
|
|
234
|
+
let under = lb.rgb + bg.rgb * bgA * (1.0 - lb.a);
|
|
235
|
+
let frame = rzSceneDisplay(uv) * a + under * (1.0 - a);
|
|
236
|
+
let lf = _rzLayerFg(uv);
|
|
237
|
+
return lf.rgb + frame * (1.0 - lf.a);
|
|
197
238
|
}
|
|
198
239
|
|
|
199
240
|
/**
|
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -1278,6 +1278,25 @@ const TEXTURE_EXTENSION_GUESSES = [".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dd
|
|
|
1278
1278
|
* below it. The GRID reaches 8 on its own, so it takes the next one up rather
|
|
1279
1279
|
* than everything else moving to accommodate one mount.
|
|
1280
1280
|
*/
|
|
1281
|
+
/**
|
|
1282
|
+
* One parameter's value for the uniform: what the caller passed, or what the
|
|
1283
|
+
* source declared.
|
|
1284
|
+
*
|
|
1285
|
+
* A colour is written `#rrggbb` in the directive and reaches the GPU as three
|
|
1286
|
+
* floats. Plain byte scaling, NOT an sRGB decode — the shader multiplies these
|
|
1287
|
+
* against a texture that is itself sampled as sRGB, so decoding here would
|
|
1288
|
+
* darken every authored colour by the transfer curve twice.
|
|
1289
|
+
*/
|
|
1290
|
+
function paramValue(p: EffectParamDecl, given: EffectParamValue | undefined): EffectParamValue {
|
|
1291
|
+
if (given !== undefined) return given
|
|
1292
|
+
if (p.kind === "color" && typeof p.value === "string") {
|
|
1293
|
+
const n = parseInt(p.value.slice(1), 16)
|
|
1294
|
+
return { x: ((n >> 16) & 255) / 255, y: ((n >> 8) & 255) / 255, z: (n & 255) / 255 }
|
|
1295
|
+
}
|
|
1296
|
+
if (Array.isArray(p.value)) return { x: p.value[0], y: p.value[1], z: p.value[2] }
|
|
1297
|
+
return typeof p.value === "number" ? p.value : 0
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1281
1300
|
const EFFECT_PARAMS_BINDING = 7
|
|
1282
1301
|
const EFFECT_PARAMS_BINDING_GRID = 9
|
|
1283
1302
|
|
|
@@ -1504,10 +1523,16 @@ interface EffectInstance {
|
|
|
1504
1523
|
* for the same reason `layout` is: `bind` is rebuilt on a buffer swap. */
|
|
1505
1524
|
params: GPUBuffer | null
|
|
1506
1525
|
} | null
|
|
1507
|
-
/** The field mount's pipeline and its
|
|
1508
|
-
*
|
|
1526
|
+
/** The field mount's pipeline and its bind groups, or null when the effect
|
|
1527
|
+
* declares neither background nor foreground. */
|
|
1509
1528
|
fieldPipeline: GPURenderPipeline | null
|
|
1510
|
-
|
|
1529
|
+
/** Two for a plain effect, one per grid parity. Eight for a filter — which
|
|
1530
|
+
* page it reads, whether it absorbs the half pair, grid parity — in
|
|
1531
|
+
* Engine.filterBindIndex order. */
|
|
1532
|
+
fieldBindGroups: GPUBindGroup[] | null
|
|
1533
|
+
/** Calls rzSceneFrame: runs after every other field effect, at full
|
|
1534
|
+
* resolution, reading their layers and carrying them forward. */
|
|
1535
|
+
filter: boolean
|
|
1511
1536
|
/** Which resolution pair this effect draws into: 0 full, 1 half. Its own
|
|
1512
1537
|
* declaration, not the scene's — see Engine.FIELD_SCALES. */
|
|
1513
1538
|
fieldLayer: number
|
|
@@ -1913,6 +1938,13 @@ export class Engine {
|
|
|
1913
1938
|
private fieldBgViews: (GPUTextureView | null)[] = [null, null]
|
|
1914
1939
|
private fieldFgTextures: (GPUTexture | null)[] = [null, null]
|
|
1915
1940
|
private fieldFgViews: (GPUTextureView | null)[] = [null, null]
|
|
1941
|
+
/** The filter's page: a second full-res pair, so a filter can read the pair
|
|
1942
|
+
* the other effects drew into while it draws. Held only while a filter is
|
|
1943
|
+
* installed — see ensureFilterPage. */
|
|
1944
|
+
private fieldPageBgTexture: GPUTexture | null = null
|
|
1945
|
+
private fieldPageFgTexture: GPUTexture | null = null
|
|
1946
|
+
private fieldPageBgView: GPUTextureView | null = null
|
|
1947
|
+
private fieldPageFgView: GPUTextureView | null = null
|
|
1916
1948
|
/** One per scale: the field shader reconstructs the full-res pixel it stands
|
|
1917
1949
|
* in for, so each pass needs its own (w, h, fullW, fullH). */
|
|
1918
1950
|
private fieldUniformBuffers: GPUBuffer[] = []
|
|
@@ -3221,6 +3253,55 @@ export class Engine {
|
|
|
3221
3253
|
new Float32Array([w, h, this.fieldFullW, this.fieldFullH]),
|
|
3222
3254
|
)
|
|
3223
3255
|
}
|
|
3256
|
+
this.ensureFilterPage()
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
/**
|
|
3260
|
+
* The filter's page, held only while a filter is installed.
|
|
3261
|
+
*
|
|
3262
|
+
* A full-res rgba16f pair is real memory — two 16MB targets at 1080p, four
|
|
3263
|
+
* times that at 4K — and most scenes never install a filter. Created here
|
|
3264
|
+
* and on resize, released the moment the last filter goes. Sized off the
|
|
3265
|
+
* full pair, which is what a filter reads and what the composite reads back.
|
|
3266
|
+
*/
|
|
3267
|
+
private ensureFilterPage(): void {
|
|
3268
|
+
const wanted = !!this.device && this.fieldFullW > 0 && this.effects.some((e) => e.filter)
|
|
3269
|
+
const w = Math.max(1, this.fieldFullW)
|
|
3270
|
+
const h = Math.max(1, this.fieldFullH)
|
|
3271
|
+
if (wanted && this.fieldPageBgTexture?.width === w && this.fieldPageBgTexture.height === h) return
|
|
3272
|
+
this.fieldPageBgTexture?.destroy()
|
|
3273
|
+
this.fieldPageFgTexture?.destroy()
|
|
3274
|
+
this.fieldPageBgTexture = null
|
|
3275
|
+
this.fieldPageFgTexture = null
|
|
3276
|
+
this.fieldPageBgView = null
|
|
3277
|
+
this.fieldPageFgView = null
|
|
3278
|
+
if (!wanted) return
|
|
3279
|
+
const make = (label: string) =>
|
|
3280
|
+
this.device.createTexture({
|
|
3281
|
+
label,
|
|
3282
|
+
size: [w, h],
|
|
3283
|
+
format: "rgba16float",
|
|
3284
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
3285
|
+
})
|
|
3286
|
+
this.fieldPageBgTexture = make("field layer page (background)")
|
|
3287
|
+
this.fieldPageFgTexture = make("field layer page (foreground)")
|
|
3288
|
+
this.fieldPageBgView = this.fieldPageBgTexture.createView()
|
|
3289
|
+
this.fieldPageFgView = this.fieldPageFgTexture.createView()
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
/** Filters drawn this frame — the predicate renderFieldPass runs them by,
|
|
3293
|
+
* shared with the composite uniform that says the half pair was consumed. */
|
|
3294
|
+
private filtersDrawn(): number {
|
|
3295
|
+
if (!this.fieldPageBgView || !this.fieldPageFgView) return 0
|
|
3296
|
+
let n = 0
|
|
3297
|
+
for (const e of this.effects) if (e.filter && e.fieldPipeline && e.fieldBindGroups && e.weight > 0) n++
|
|
3298
|
+
return n
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
/** Which of a filter's eight bind groups: the page it reads (0 the plain
|
|
3302
|
+
* pair, 1 the filter page), whether it absorbs the half pair, grid parity. */
|
|
3303
|
+
private static filterBindIndex(readPage: 0 | 1, absorbHalf: boolean, gridParity: number): number {
|
|
3304
|
+
return (readPage * 2 + (absorbHalf ? 1 : 0)) * 2 + gridParity
|
|
3224
3305
|
}
|
|
3225
3306
|
|
|
3226
3307
|
/**
|
|
@@ -3236,7 +3317,11 @@ export class Engine {
|
|
|
3236
3317
|
// Captured, so the null guards above survive into the closure.
|
|
3237
3318
|
const depth = this.depthReadView
|
|
3238
3319
|
const bloom = this.compositeBloomView
|
|
3239
|
-
const build = (
|
|
3320
|
+
const build = (
|
|
3321
|
+
owner: EffectInstance,
|
|
3322
|
+
grid: GPUTextureView,
|
|
3323
|
+
layers: readonly [GPUTextureView, GPUTextureView, GPUTextureView, GPUTextureView],
|
|
3324
|
+
) =>
|
|
3240
3325
|
this.device.createBindGroup({
|
|
3241
3326
|
label: "field layer bind group",
|
|
3242
3327
|
layout: this.fieldBindGroupLayout,
|
|
@@ -3259,6 +3344,11 @@ export class Engine {
|
|
|
3259
3344
|
{ binding: 26, resource: this.castDistView ?? this.castDistFallbackView! },
|
|
3260
3345
|
{ binding: 27, resource: this.hdrResolveTexture.createView() },
|
|
3261
3346
|
{ binding: 28, resource: this.simSampler },
|
|
3347
|
+
// The other effects' layers, for rzSceneFrame — see the loop below.
|
|
3348
|
+
{ binding: 29, resource: layers[0] },
|
|
3349
|
+
{ binding: 30, resource: layers[1] },
|
|
3350
|
+
{ binding: 31, resource: layers[2] },
|
|
3351
|
+
{ binding: 32, resource: layers[3] },
|
|
3262
3352
|
{ binding: 2, resource: this.bloomSampler },
|
|
3263
3353
|
{ binding: 5, resource: this.filmicLutView },
|
|
3264
3354
|
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
@@ -3268,10 +3358,27 @@ export class Engine {
|
|
|
3268
3358
|
})
|
|
3269
3359
|
// Per effect: the params buffer and the grid are both its own, so two
|
|
3270
3360
|
// effects cannot share a bind group even when everything else matches.
|
|
3361
|
+
// What a filter reads, by variant. A plain effect draws into the pair it
|
|
3362
|
+
// would otherwise read, so it gets the transparent 1x1 at every layer slot
|
|
3363
|
+
// and rzSceneFrame answers the scene alone.
|
|
3364
|
+
const none = this.trailFallbackView
|
|
3365
|
+
const pairA: [GPUTextureView, GPUTextureView] = [this.fieldBgViews[0] ?? none, this.fieldFgViews[0] ?? none]
|
|
3366
|
+
const page: [GPUTextureView, GPUTextureView] = [this.fieldPageBgView ?? none, this.fieldPageFgView ?? none]
|
|
3367
|
+
const half: [GPUTextureView, GPUTextureView] = [this.fieldBgViews[1] ?? none, this.fieldFgViews[1] ?? none]
|
|
3271
3368
|
for (const e of this.effects) {
|
|
3272
|
-
e.
|
|
3273
|
-
|
|
3274
|
-
|
|
3369
|
+
const grids = e.grid ? [e.grid.read[0], e.grid.read[1]] : [this.simFallbackView, this.simFallbackView]
|
|
3370
|
+
if (!e.filter) {
|
|
3371
|
+
e.fieldBindGroups = grids.map((g) => build(e, g, [none, none, none, none]))
|
|
3372
|
+
continue
|
|
3373
|
+
}
|
|
3374
|
+
// Eight, in filterBindIndex order: read page × absorbs half × grid parity.
|
|
3375
|
+
const groups: GPUBindGroup[] = []
|
|
3376
|
+
for (const read of [pairA, page]) {
|
|
3377
|
+
for (const h of [[none, none], half] as [GPUTextureView, GPUTextureView][]) {
|
|
3378
|
+
for (const g of grids) groups.push(build(e, g, [read[0], read[1], h[0], h[1]]))
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
e.fieldBindGroups = groups
|
|
3275
3382
|
}
|
|
3276
3383
|
}
|
|
3277
3384
|
|
|
@@ -3627,19 +3734,35 @@ export class Engine {
|
|
|
3627
3734
|
// Fields are emitted in declaration order; offsets follow WGSL's natural
|
|
3628
3735
|
// uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
|
|
3629
3736
|
// on both sides so no reordering is needed.
|
|
3630
|
-
|
|
3737
|
+
//
|
|
3738
|
+
// THE SOURCE DECIDES THE STRUCT, and the caller only supplies values.
|
|
3739
|
+
//
|
|
3740
|
+
// This used to read `Object.entries(params ?? {})`, which made the struct a
|
|
3741
|
+
// property of the CALL rather than of the shader: install the same source
|
|
3742
|
+
// without a params object and every `params.X` in it became an unresolved
|
|
3743
|
+
// identifier. Two callers installing one effect two ways is how an effect
|
|
3744
|
+
// compiled in the scene and failed in the editor that was editing it.
|
|
3745
|
+
//
|
|
3746
|
+
// The directives are already parsed — `d.params` is returned in the result —
|
|
3747
|
+
// so they are what the struct is built from. A value the caller passes wins
|
|
3748
|
+
// over the declared default; a name it passes that the source never declared
|
|
3749
|
+
// is ignored, because a field nothing reads is a binding nothing reads.
|
|
3750
|
+
const entries: [string, EffectParamValue][] = d.params.map((p) => [p.name, paramValue(p, params?.[p.name])])
|
|
3631
3751
|
const layout = new Map<string, { offset: number; comps: 1 | 3 }>()
|
|
3632
3752
|
const fields: string[] = []
|
|
3633
3753
|
let cursor = 0
|
|
3634
|
-
for (const
|
|
3635
|
-
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
3636
|
-
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration }
|
|
3754
|
+
for (const p of d.params) {
|
|
3755
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(p.name)) {
|
|
3756
|
+
return { ok: false, diagnostics: [`invalid param name "${p.name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration }
|
|
3637
3757
|
}
|
|
3638
|
-
|
|
3758
|
+
// KIND comes from the declaration, never from the runtime type of the
|
|
3759
|
+
// value: a caller handing a number to a colour must not silently turn a
|
|
3760
|
+
// vec3f field into an f32 and shift every field after it.
|
|
3761
|
+
const isVec = p.kind !== "float"
|
|
3639
3762
|
const align = isVec ? 16 : 4
|
|
3640
3763
|
const offset = Math.ceil(cursor / align) * align
|
|
3641
|
-
layout.set(name, { offset: offset / 4, comps: isVec ? 3 : 1 })
|
|
3642
|
-
fields.push(` ${name}: ${isVec ? "vec3f" : "f32"},`)
|
|
3764
|
+
layout.set(p.name, { offset: offset / 4, comps: isVec ? 3 : 1 })
|
|
3765
|
+
fields.push(` ${p.name}: ${isVec ? "vec3f" : "f32"},`)
|
|
3643
3766
|
cursor = offset + (isVec ? 12 : 4)
|
|
3644
3767
|
}
|
|
3645
3768
|
const paramsData = new Float32Array(Math.max(4, Math.ceil(cursor / 16) * 4))
|
|
@@ -3675,8 +3798,14 @@ export class Engine {
|
|
|
3675
3798
|
const gridSize = gridEntryPoint(wgsl) ? Math.min(d.grid || 256, GRID_MAX) : 0
|
|
3676
3799
|
// `alias` goes in: a field effect reads bones through _rzSlot exactly as a
|
|
3677
3800
|
// particle one does, and it was the only module never handed the mapping.
|
|
3801
|
+
// A FILTER by what it calls, like every other mount property. rzSceneFrame
|
|
3802
|
+
// answers the frame with the other effects' layers in it, which is only
|
|
3803
|
+
// possible from a pass that runs after theirs — see renderFieldPass.
|
|
3804
|
+
const filter = (hasBackground || hasForeground) && /\brzSceneFrame\s*\(/.test(wgsl)
|
|
3678
3805
|
const fieldEffect =
|
|
3679
|
-
hasBackground || hasForeground
|
|
3806
|
+
hasBackground || hasForeground
|
|
3807
|
+
? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length, filter, additiveLayer: d.additiveLayer }
|
|
3808
|
+
: null
|
|
3680
3809
|
const source = buildCompositeShader(fieldEffect)
|
|
3681
3810
|
this.device.pushErrorScope("validation")
|
|
3682
3811
|
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
|
|
@@ -3717,10 +3846,15 @@ export class Engine {
|
|
|
3717
3846
|
// the premultiplication happens here, and the composite reads it
|
|
3718
3847
|
// back knowing that. With one effect over a cleared target the
|
|
3719
3848
|
// result is identical to the replace it used to do.
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3849
|
+
// A filter composes over the layers in its own shader and writes
|
|
3850
|
+
// the result outright: blending would layer it onto a page that
|
|
3851
|
+
// holds nothing.
|
|
3852
|
+
targets: filter
|
|
3853
|
+
? [{ format: "rgba16float" }, { format: "rgba16float" }]
|
|
3854
|
+
: [
|
|
3855
|
+
{ format: "rgba16float", blend: layerBlend },
|
|
3856
|
+
{ format: "rgba16float", blend: layerBlend },
|
|
3857
|
+
],
|
|
3724
3858
|
},
|
|
3725
3859
|
primitive: { topology: "triangle-list" },
|
|
3726
3860
|
multisample: { count: 1 },
|
|
@@ -3902,7 +4036,10 @@ export class Engine {
|
|
|
3902
4036
|
// against 4.5ms). It is the right call for a soft additive glow, which
|
|
3903
4037
|
// upsamples invisibly — and it is now a claim an author makes about
|
|
3904
4038
|
// their own effect rather than a fate that befalls one.
|
|
3905
|
-
|
|
4039
|
+
// A filter holds every other effect's pixels, so it holds them at full
|
|
4040
|
+
// resolution whatever it declared for itself.
|
|
4041
|
+
fieldLayer: filter ? 0 : d.fieldLayer,
|
|
4042
|
+
filter,
|
|
3906
4043
|
fieldPipeline,
|
|
3907
4044
|
fieldClock,
|
|
3908
4045
|
// Filled by rebuildFieldBindGroup below, which needs the instance to
|
|
@@ -4078,6 +4215,7 @@ export class Engine {
|
|
|
4078
4215
|
// fieldLayer when the instance is built, and both target pairs exist for
|
|
4079
4216
|
// the life of the surface. What used to be a scene-wide decision made here
|
|
4080
4217
|
// is now each effect's own.
|
|
4218
|
+
this.ensureFilterPage()
|
|
4081
4219
|
this.rebuildFieldBindGroup()
|
|
4082
4220
|
this.rebuildCompositeBindGroup()
|
|
4083
4221
|
this.writeCompositeViewUniforms()
|
|
@@ -4701,17 +4839,31 @@ export class Engine {
|
|
|
4701
4839
|
// installed effect names rzCastDistance, which is the common case.
|
|
4702
4840
|
this.encodeCastDistance(encoder)
|
|
4703
4841
|
|
|
4842
|
+
// FILTERS RUN LAST, each in a pass of its own, because each reads the pair
|
|
4843
|
+
// the others drew into — a texture no pass may sample while drawing into
|
|
4844
|
+
// it. So the full-res pair is two, the plain pair A and the filter's page,
|
|
4845
|
+
// and the passes alternate between them. The composite reads A and is not
|
|
4846
|
+
// rebuilt per frame, so the chain is arranged to END on A: with an odd
|
|
4847
|
+
// number of filters the plain effects draw into the page instead, and the
|
|
4848
|
+
// last filter lands where the composite looks. The half pair is read by
|
|
4849
|
+
// the first filter and consumed — viewU[5].w tells the composite so.
|
|
4850
|
+
const filters = drawn.filter((e) => e.filter)
|
|
4851
|
+
const pageA: [GPUTextureView | null, GPUTextureView | null] = [this.fieldBgViews[0], this.fieldFgViews[0]]
|
|
4852
|
+
const pageB: [GPUTextureView | null, GPUTextureView | null] = [this.fieldPageBgView, this.fieldPageFgView]
|
|
4853
|
+
const canFilter = filters.length > 0 && !!pageB[0] && !!pageB[1]
|
|
4854
|
+
const plainFull = canFilter && filters.length % 2 === 1 ? pageB : pageA
|
|
4855
|
+
const attach = (views: [GPUTextureView | null, GPUTextureView | null]): GPURenderPassColorAttachment[] => [
|
|
4856
|
+
{ view: views[0]!, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
4857
|
+
{ view: views[1]!, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
4858
|
+
]
|
|
4704
4859
|
let stamped = false
|
|
4705
4860
|
for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
|
|
4706
|
-
const
|
|
4707
|
-
|
|
4708
|
-
if (!
|
|
4861
|
+
const target: [GPUTextureView | null, GPUTextureView | null] =
|
|
4862
|
+
i === 0 ? plainFull : [this.fieldBgViews[i], this.fieldFgViews[i]]
|
|
4863
|
+
if (!target[0] || !target[1] || !this.fieldPairUsed(i)) continue
|
|
4709
4864
|
const pass = encoder.beginRenderPass({
|
|
4710
4865
|
label: `field layer (${Engine.FIELD_SCALES[i] === 1 ? "full" : "half"})`,
|
|
4711
|
-
colorAttachments:
|
|
4712
|
-
{ view: bg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
4713
|
-
{ view: fg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
4714
|
-
],
|
|
4866
|
+
colorAttachments: attach(target),
|
|
4715
4867
|
// One query pair is reserved for "field", and it goes to the first pair
|
|
4716
4868
|
// that actually runs — full res when something declared #fullres, half
|
|
4717
4869
|
// otherwise. Pinning it to i === 0 would have measured a pass that, now
|
|
@@ -4720,7 +4872,7 @@ export class Engine {
|
|
|
4720
4872
|
})
|
|
4721
4873
|
stamped = true
|
|
4722
4874
|
for (const e of drawn) {
|
|
4723
|
-
if (e.fieldLayer !== i) continue
|
|
4875
|
+
if (e.fieldLayer !== i || e.filter) continue
|
|
4724
4876
|
pass.setPipeline(e.fieldPipeline!)
|
|
4725
4877
|
// The grid this effect just wrote — after its parity flip, the one at
|
|
4726
4878
|
// `parity`. Per effect, so two grids never read each other's frame.
|
|
@@ -4729,6 +4881,23 @@ export class Engine {
|
|
|
4729
4881
|
}
|
|
4730
4882
|
pass.end()
|
|
4731
4883
|
}
|
|
4884
|
+
if (!canFilter) return
|
|
4885
|
+
// Each filter reads what the previous pass left and writes the other page.
|
|
4886
|
+
// The half pair goes to the first one, and only when something drew into
|
|
4887
|
+
// it this frame — an unused pair is never cleared and holds stale pixels.
|
|
4888
|
+
let read = plainFull
|
|
4889
|
+
filters.forEach((e, k) => {
|
|
4890
|
+
const write = read === pageA ? pageB : pageA
|
|
4891
|
+
const pass = encoder.beginRenderPass({ label: "field layer (filter)", colorAttachments: attach(write) })
|
|
4892
|
+
pass.setPipeline(e.fieldPipeline!)
|
|
4893
|
+
pass.setBindGroup(
|
|
4894
|
+
0,
|
|
4895
|
+
e.fieldBindGroups![Engine.filterBindIndex(read === pageA ? 0 : 1, k === 0 && this.fieldPairUsed(1), e.grid?.parity ?? 0)],
|
|
4896
|
+
)
|
|
4897
|
+
pass.draw(3)
|
|
4898
|
+
pass.end()
|
|
4899
|
+
read = write
|
|
4900
|
+
})
|
|
4732
4901
|
}
|
|
4733
4902
|
|
|
4734
4903
|
/** The trail bind group holds the depth view, which a resize recreates. */
|
|
@@ -5716,6 +5885,11 @@ export class Engine {
|
|
|
5716
5885
|
// why it may not borrow the one at 18.
|
|
5717
5886
|
{ binding: 27, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
5718
5887
|
{ binding: 28, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
5888
|
+
// The other effects' layers, for a filter's rzSceneFrame (scene-tap.ts).
|
|
5889
|
+
{ binding: 29, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
5890
|
+
{ binding: 30, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
5891
|
+
{ binding: 31, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
5892
|
+
{ binding: 32, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
5719
5893
|
// THE VIEW TRANSFORM'S OWN RESOURCES. viewTransform() lives in the
|
|
5720
5894
|
// header both this module and the composite share, but its lookups did
|
|
5721
5895
|
// not: an effect calling it compiled and then failed at pipeline
|
|
@@ -14216,7 +14390,9 @@ export class Engine {
|
|
|
14216
14390
|
u[20] = v[2]
|
|
14217
14391
|
u[21] = v[6]
|
|
14218
14392
|
u[22] = v[10]
|
|
14219
|
-
|
|
14393
|
+
// A drawn filter consumed the half pair (renderFieldPass); the composite
|
|
14394
|
+
// skips it rather than laying it down a second time.
|
|
14395
|
+
u[23] = this.filtersDrawn() > 0 ? 1 : 0
|
|
14220
14396
|
// Effect clock + canvas size (viewU[6]) — written on the same refresh.
|
|
14221
14397
|
// The effect clock, on the SCENE's time rather than the wall's.
|
|
14222
14398
|
//
|
|
@@ -96,6 +96,11 @@ type CompositeEffectSource = {
|
|
|
96
96
|
hasBackground: boolean
|
|
97
97
|
/** Defines `fn foreground(...)` — mount over the finished frame. */
|
|
98
98
|
hasForeground: boolean
|
|
99
|
+
/** Calls rzSceneFrame — a FILTER, which carries the layers it read forward
|
|
100
|
+
* (buildFieldShader) and so draws with blending off. */
|
|
101
|
+
filter?: boolean
|
|
102
|
+
/** `#layer additive`: how a filter's own output joins the layers it carries. */
|
|
103
|
+
additiveLayer?: boolean
|
|
99
104
|
/** Grid resolution when the effect declared `#grid`, else 0. */
|
|
100
105
|
gridSize: number
|
|
101
106
|
/** Whether the scene pass carries the id attachment, so the field module can
|
|
@@ -146,7 +151,8 @@ override APPLY_GAMMA: bool = true;
|
|
|
146
151
|
// and no on/off uniform either: the pipeline is REBUILT per effect, so
|
|
147
152
|
// the compiled variant IS the flag.
|
|
148
153
|
// viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
|
|
149
|
-
// viewU[5] = (camera forward,
|
|
154
|
+
// viewU[5] = (camera forward, half pair consumed by a filter this frame) — refreshed
|
|
155
|
+
// per frame while skybox/effect active.
|
|
150
156
|
// viewU[6] = (time seconds, view transform id, canvas width, canvas height).
|
|
151
157
|
// viewU[7] = (grade offset.rgb, contrast); viewU[8] = (grade power.rgb, saturation);
|
|
152
158
|
// viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
|
|
@@ -605,7 +611,8 @@ const BACKGROUND_CALL = /* wgsl */ `
|
|
|
605
611
|
// straight form it replaced.
|
|
606
612
|
let bgFx = rzFieldMerge(
|
|
607
613
|
clamp(textureSampleLevel(fieldBgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)),
|
|
608
|
-
|
|
614
|
+
// The half pair, unless a filter consumed it this frame (viewU[5].w).
|
|
615
|
+
clamp(textureSampleLevel(fieldBgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)) * (1.0 - viewU[5].w));
|
|
609
616
|
bgPm = bgFx.rgb + bgPm * (1.0 - bgFx.a);
|
|
610
617
|
bgA = bgFx.a + bgA * (1.0 - bgFx.a);
|
|
611
618
|
`
|
|
@@ -617,7 +624,7 @@ const FOREGROUND_CALL = /* wgsl */ `
|
|
|
617
624
|
// Premultiplied, as the background layer above — same reason.
|
|
618
625
|
let fgFx = rzFieldMerge(
|
|
619
626
|
clamp(textureSampleLevel(fieldFgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)),
|
|
620
|
-
clamp(textureSampleLevel(fieldFgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)));
|
|
627
|
+
clamp(textureSampleLevel(fieldFgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)) * (1.0 - viewU[5].w));
|
|
621
628
|
outRgb = fgFx.rgb + outRgb * (1.0 - fgFx.a);
|
|
622
629
|
outA = fgFx.a + outA * (1.0 - fgFx.a);
|
|
623
630
|
`
|
|
@@ -670,6 +677,16 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
|
|
|
670
677
|
const fgLine = effect.hasForeground
|
|
671
678
|
? "out.fg = clamp(foreground(dir, uv, _rzFieldClock.x, linearDepth(vec2<i32>(min(fx, fullSz - 1.0)))), vec4f(0.0), vec4f(1.0));"
|
|
672
679
|
: ""
|
|
680
|
+
// A FILTER read the other effects' layers through rzSceneFrame and now
|
|
681
|
+
// carries them: its own output goes over (or adds to) what they drew, and
|
|
682
|
+
// the pipeline writes the result with blending off. Weight has already
|
|
683
|
+
// scaled its alpha, so a filter faded to nothing passes the layers through
|
|
684
|
+
// untouched.
|
|
685
|
+
const carry = effect.filter
|
|
686
|
+
? effect.additiveLayer
|
|
687
|
+
? "out.bg = _rzCarryAdd(out.bg, _rzLayerBg(uv));\n out.fg = _rzCarryAdd(out.fg, _rzLayerFg(uv));"
|
|
688
|
+
: "out.bg = _rzCarryOver(out.bg, _rzLayerBg(uv));\n out.fg = _rzCarryOver(out.fg, _rzLayerFg(uv));"
|
|
689
|
+
: ""
|
|
673
690
|
return (
|
|
674
691
|
COMPOSITE_HEAD +
|
|
675
692
|
EFFECT_SCENE_API +
|
|
@@ -692,7 +709,7 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
|
|
|
692
709
|
// texel is CAST_FIELD_DIV of them and the accessor scales on the way out.
|
|
693
710
|
// An author writes the width they mean and never learns how it is built.
|
|
694
711
|
castDistanceApi(0, 26, 18, CAST_FIELD_DIV) +
|
|
695
|
-
sceneTapApi(0, 27, 28) +
|
|
712
|
+
sceneTapApi(0, 27, 28, [29, 30, 31, 32]) +
|
|
696
713
|
"\n// ── user effect (setEffect) ──\n" +
|
|
697
714
|
effect.paramsDecl +
|
|
698
715
|
"\n" +
|
|
@@ -711,6 +728,15 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
|
|
|
711
728
|
*/
|
|
712
729
|
@group(0) @binding(22) var<uniform> _rzFieldClock: vec4f; // (time, weight, _, _)
|
|
713
730
|
|
|
731
|
+
/** A filter's own straight-alpha output over the premultiplied layers it read
|
|
732
|
+
* (the layer blend state, restated) and the additive form of the same. */
|
|
733
|
+
fn _rzCarryOver(own: vec4f, under: vec4f) -> vec4f {
|
|
734
|
+
return vec4f(own.rgb * own.a + under.rgb * (1.0 - own.a), own.a + under.a * (1.0 - own.a));
|
|
735
|
+
}
|
|
736
|
+
fn _rzCarryAdd(own: vec4f, under: vec4f) -> vec4f {
|
|
737
|
+
return vec4f(own.rgb * own.a + under.rgb, own.a + under.a);
|
|
738
|
+
}
|
|
739
|
+
|
|
714
740
|
@vertex fn fieldVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
715
741
|
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
716
742
|
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
@@ -740,6 +766,7 @@ struct FieldOut {
|
|
|
740
766
|
// Scaling colour as well would fade as the square.
|
|
741
767
|
out.bg.a *= _rzFieldClock.y;
|
|
742
768
|
out.fg.a *= _rzFieldClock.y;
|
|
769
|
+
${carry}
|
|
743
770
|
return out;
|
|
744
771
|
}
|
|
745
772
|
`
|