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.
@@ -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, _) — refreshed per frame while skybox/effect active.
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
- clamp(textureSampleLevel(fieldBgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)));
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,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAoK5E;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc,kjBAW1B,CAAA"}
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 finished pixel: the scene over its background, exactly as the composite
179
- * would lay it down. One call, and the answer already carries the ground's
180
- * fade, its opacity, and the background showing through both.
181
- *
182
- * This is what a FILTER wants — anything re-rendering the whole frame on a tube
183
- * or a low-resolution screen. Reaching for rzSceneDisplay and a depth test
184
- * instead rebuilds this by hand and gets it wrong in one specific way every
185
- * time: coverage is not a yes or no, and treating it as one turns every soft
186
- * edge in the scene into a hard one.
187
- *
188
- * NOT INCLUDED: depth of field, background effects, and an equirect background
189
- * (a 360 dome reads as its average rather than its texture). Foregrounds compose
190
- * after this in any case.
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 bgRgb = bg.rgb * select(0.0, 1.0, bg.w > 0.5);
196
- return rzSceneDisplay(uv) * a + bgRgb * (1.0 - a);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.55.8",
3
+ "version": "0.55.10",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
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 two parity bind groups, or null when the
1508
- * effect declares neither background nor foreground. */
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
- fieldBindGroups: [GPUBindGroup, GPUBindGroup] | null
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 = (owner: EffectInstance, grid: GPUTextureView) =>
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.fieldBindGroups = e.grid
3273
- ? [build(e, e.grid.read[0]), build(e, e.grid.read[1])]
3274
- : [build(e, this.simFallbackView), build(e, this.simFallbackView)]
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
- const entries = Object.entries(params ?? {})
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 [name, value] of entries) {
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
- const isVec = typeof value !== "number"
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 ? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length } : null
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
- targets: [
3721
- { format: "rgba16float", blend: layerBlend },
3722
- { format: "rgba16float", blend: layerBlend },
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
- fieldLayer: d.fieldLayer,
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 bg = this.fieldBgViews[i]
4707
- const fg = this.fieldFgViews[i]
4708
- if (!bg || !fg || !this.fieldPairUsed(i)) continue
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
- u[23] = 0
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, _) — refreshed per frame while skybox/effect active.
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
- clamp(textureSampleLevel(fieldBgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)));
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
  `