reze-engine 0.40.0 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +8 -5
  2. package/dist/engine.d.ts +87 -31
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +264 -77
  5. package/dist/graph/presets/body.js +2 -2
  6. package/dist/graph/presets/cloth_rough.js +2 -2
  7. package/dist/graph/presets/cloth_smooth.js +2 -2
  8. package/dist/graph/presets/default.js +2 -2
  9. package/dist/graph/presets/eye.js +2 -2
  10. package/dist/graph/presets/face.js +2 -2
  11. package/dist/graph/presets/hair.js +2 -2
  12. package/dist/graph/presets/metal.js +2 -2
  13. package/dist/graph/presets/stockings.js +3 -3
  14. package/dist/graph/registry.d.ts.map +1 -1
  15. package/dist/graph/registry.js +436 -10
  16. package/dist/graph/style-group.d.ts +46 -0
  17. package/dist/graph/style-group.d.ts.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/shaders/agx-lut.d.ts +11 -0
  21. package/dist/shaders/agx-lut.d.ts.map +1 -0
  22. package/dist/shaders/agx-lut.js +29 -0
  23. package/dist/shaders/materials/common.d.ts +1 -1
  24. package/dist/shaders/materials/common.d.ts.map +1 -1
  25. package/dist/shaders/materials/common.js +10 -0
  26. package/dist/shaders/materials/nodes.d.ts +1 -1
  27. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  28. package/dist/shaders/materials/nodes.js +957 -555
  29. package/dist/shaders/passes/composite.d.ts +27 -12
  30. package/dist/shaders/passes/composite.d.ts.map +1 -1
  31. package/dist/shaders/passes/composite.js +155 -48
  32. package/package.json +1 -1
  33. package/src/engine.ts +304 -84
  34. package/src/graph/presets/body.ts +2 -2
  35. package/src/graph/presets/cloth_rough.ts +2 -2
  36. package/src/graph/presets/cloth_smooth.ts +2 -2
  37. package/src/graph/presets/default.ts +2 -2
  38. package/src/graph/presets/eye.ts +2 -2
  39. package/src/graph/presets/face.ts +2 -2
  40. package/src/graph/presets/hair.ts +2 -2
  41. package/src/graph/presets/metal.ts +2 -2
  42. package/src/graph/presets/stockings.ts +3 -3
  43. package/src/graph/registry.ts +450 -10
  44. package/src/graph/style-group.ts +44 -0
  45. package/src/index.ts +4 -2
  46. package/src/shaders/agx-lut.ts +30 -0
  47. package/src/shaders/materials/common.ts +10 -0
  48. package/src/shaders/materials/nodes.ts +962 -560
  49. package/src/shaders/passes/composite.ts +182 -64
@@ -2,30 +2,51 @@
2
2
  // Bloom tint/intensity applied at combine (EEVEE treats them as combine-stage params, not prefilter).
3
3
  //
4
4
  // The shader is a TEMPLATE: buildCompositeShader() emits either the base pass or
5
- // a variant with a user background effect injected (setBackgroundEffect). The
6
- // effect is background mode 3, a sibling of the 360 equirect (mode 2) — it reuses
7
- // the same per-pixel view-ray reconstruction and composites in display space
8
- // under the scene, so it never affects lighting, bloom, or tonemapping.
5
+ // a variant with user WGSL injected at one or both effect MOUNTS (setEffect).
6
+ // The two mounts are the same idea on either side of the scene:
7
+ //
8
+ // background(...) under the scene — a sibling of the 360 equirect (mode 2),
9
+ // reusing the same per-pixel view-ray reconstruction.
10
+ // foreground(...) over the finished frame, handed the scene's depth in metres
11
+ // so it can be occluded by whatever it passes behind.
12
+ //
13
+ // Both composite in display space, so neither affects lighting, bloom, or
14
+ // tonemapping, and both are captured by offline export like any background.
9
15
 
10
- /** What a user background effect must define, documented once:
16
+ /** What user effect WGSL may define, documented once. A file declares its own
17
+ * mounts by which of these it defines — defining both is how one file is one
18
+ * weather system (dark sky behind, rain in front):
11
19
  *
12
20
  * fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
21
+ * fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
13
22
  *
14
- * - `ray` — normalized world-space view direction of this pixel (left-handed,
15
- * +Z forward; identical to what the 360 skybox samples by).
16
- * - `uv` — 0..1 across the canvas, origin bottom-left (shadertoy-style).
17
- * - `time` — seconds since the effect was applied.
23
+ * - `ray` — normalized world-space view direction of this pixel (left-handed,
24
+ * +Z forward; identical to what the 360 skybox samples by).
25
+ * - `uv` — 0..1 across the canvas, origin bottom-left (shadertoy-style).
26
+ * - `time` — seconds since the effect was applied.
27
+ * - `depth` — FOREGROUND ONLY. Camera-space distance in metres of whatever the
28
+ * scene drew at this pixel, the far plane where it drew nothing.
29
+ * Compare a particle's own distance against it and the model
30
+ * occludes it; fog needs no comparison at all, its alpha simply IS
31
+ * a function of distance.
18
32
  * - `bgResolution()` — canvas size in pixels, for aspect correction.
19
- * - declared params arrive as `params.<name>` (f32 or vec3f).
20
- * Return display-space sRGB + alpha, 0..1. The effect is a LAYER: it is
21
- * over-composited onto the base background (solid color / 360 equirect /
22
- * transparent) and sits behind the scene — alpha 0 lets the base show
23
- * through, so e.g. a starfield returns stars with a transparent sky. */
33
+ * - declared params arrive as `params.<name>` (f32 or vec3f), shared by both.
34
+ *
35
+ * Return display-space sRGB + alpha, 0..1. Both mounts are alpha-composited
36
+ * LAYERS, so alpha is what decides how much they replace: a background effect
37
+ * at alpha 1 covers the base (solid color / 360 equirect / transparent) and at
38
+ * 0 lets it through, which is how a starfield is stars over the user's color;
39
+ * a foreground at alpha 1 covers the frame. No mode flag anywhere — the alpha
40
+ * channel already says it. */
24
41
  export type CompositeEffectSource = {
25
- /** User WGSL defining `background(...)` (plus any helpers it wants). */
42
+ /** The user's WGSL verbatim: helpers plus whichever entry points it defines. */
26
43
  wgsl: string
27
- /** Codegen'd `struct BgParams {...}` + binding decl; empty when no params. */
44
+ /** Codegen'd `struct EffectParams {...}` + binding decl; empty when no params. */
28
45
  paramsDecl: string
46
+ /** Defines `fn background(...)` — mount under the scene. */
47
+ hasBackground: boolean
48
+ /** Defines `fn foreground(...)` — mount over the finished frame. */
49
+ hasForeground: boolean
29
50
  }
30
51
 
31
52
  const COMPOSITE_HEAD = /* wgsl */ `
@@ -39,7 +60,7 @@ override APPLY_GAMMA: bool = true;
39
60
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
40
61
  @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
41
62
  @group(0) @binding(2) var bloomSamp: sampler;
42
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 10>;
63
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 11>;
43
64
  // Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
44
65
  // .g = accumulated canvas alpha (what hdr.a carried before the HDR format
45
66
  // became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
@@ -58,19 +79,23 @@ override APPLY_GAMMA: bool = true;
58
79
  // viewU[2] = (background.rgb, mode) — display-space sRGB, composited UNDER the
59
80
  // scene post-tonemap. BASE-layer mode: 0 transparent (DOM shows),
60
81
  // 1 solid color, 2 = 360 equirect skybox sampled by view ray. A user
61
- // WGSL effect is a separate LAYER over the base (viewU[6].y flag).
82
+ // WGSL effect is a separate LAYER over the base — no mode of its own,
83
+ // and no on/off uniform either: the pipeline is REBUILT per effect, so
84
+ // the compiled variant IS the flag.
62
85
  // viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
63
86
  // viewU[5] = (camera forward, _) — refreshed per frame while skybox/effect active.
64
- // viewU[6] = (time seconds, effect on/off, canvas width, canvas height).
87
+ // viewU[6] = (time seconds, view transform id, canvas width, canvas height).
65
88
  // viewU[7] = (grade offset.rgb, contrast); viewU[8] = (grade power.rgb, saturation);
66
89
  // viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
90
+ // viewU[10] = (camera world position, _) — refreshed with the basis above.
67
91
  // invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
68
92
  @group(0) @binding(6) var bgEquirect: texture_2d<f32>;
69
93
  // The scene pass's own MSAA depth buffer, bound depth-only. NOT an extra
70
- // render target: when depth of field is off the scene pass discards depth
71
- // (TBDR tile memory never spills) and this binding is never read — the whole
72
- // feature costs one uniform branch. When on, the pass stores depth and the
73
- // gather below reads sample 0.
94
+ // render target: with neither depth of field nor a foreground effect active the
95
+ // scene pass discards depth (TBDR tile memory never spills) and this binding is
96
+ // never read. Either feature makes the pass store it instead, and both read
97
+ // sample 0 — the DoF gather, and linearDepth() for the depth handed to
98
+ // foreground().
74
99
  @group(0) @binding(8) var depthTex: texture_depth_multisampled_2d;
75
100
  // dofU[0] = (enabled, focusDistance, focusRange, aperture)
76
101
  // dofU[1] = (maxBlurRadiusPx, bladeCount, sampleCount, anamorphicRatio)
@@ -79,6 +104,9 @@ override APPLY_GAMMA: bool = true;
79
104
  // track the camera radius. Cleared depth (1.0) inverts to the far
80
105
  // plane, so empty sky reads as maximally defocused background.
81
106
  @group(0) @binding(9) var<uniform> dofU: array<vec4<f32>, 3>;
107
+ // Blender's AgX, as the 57³ lookup it ships as rather than a reconstruction of
108
+ // it. Sampled in the log-encoded E-Gamut space the cube expects — see agxTransform.
109
+ @group(0) @binding(10) var agxLut: texture_3d<f32>;
82
110
 
83
111
  // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
84
112
  const FILMIC_LUT_W: f32 = 256.0;
@@ -122,9 +150,78 @@ fn filmic(x: f32) -> f32 {
122
150
  return textureSampleLevel(filmicLut, bloomSamp, vec2f(u, 0.5), 0.0).r;
123
151
  }
124
152
 
125
- /** Canvas size in pixels — for user background effects (aspect correction). */
153
+ /** The sRGB display encoding — Blender's "Standard" view transform, which is what
154
+ * NPR work uses: no film curve at all, so the colours a graph computes are the
155
+ * colours that land. Not a 2.2 power law; sRGB has a linear toe. */
156
+ fn srgb_encode(x: f32) -> f32 {
157
+ let c = max(x, 0.0);
158
+ return select(1.055 * pow(c, 1.0 / 2.4) - 0.055, c * 12.92, c <= 0.0031308);
159
+ }
160
+
161
+ /**
162
+ * AgX, the chain config.ocio states for "AgX Base Rec.1886" on an sRGB display:
163
+ *
164
+ * scene-linear Rec.709 → 3x3 into FilmLight E-Gamut
165
+ * → log2 across 25 stops, [-12.47393, +12.5261] → 0..1
166
+ * → the 57³ cube
167
+ * → Rec.1886 (pure γ2.4) decoded, then re-encoded as sRGB for the swapchain
168
+ *
169
+ * The last step is not pedantry: Rec.1886 is a pure power law and sRGB has a
170
+ * linear toe, so skipping it lifts the darkest few code values.
171
+ *
172
+ * Blender interpolates the cube tetrahedrally and a GPU sampler is trilinear.
173
+ * The difference is small and confined to saturated gradients; if it ever shows,
174
+ * tetrahedral is a dozen lines here.
175
+ */
176
+ fn agxTransform(c: vec3f) -> vec3f {
177
+ let m = mat3x3<f32>(
178
+ vec3f(0.55937113, 0.07622071, 0.06552670),
179
+ vec3f(0.30478326, 0.78797183, 0.16454675),
180
+ vec3f(0.13584556, 0.13580748, 0.76992653),
181
+ );
182
+ let e = m * max(c, vec3f(0.0));
183
+ // The cube's domain: an unlit pixel would take log2 to -inf, so floor it at
184
+ // the bottom stop rather than sampling outside the LUT.
185
+ let t = clamp((log2(max(e, vec3f(1e-10))) + vec3f(12.47393)) / 25.0, vec3f(0.0), vec3f(1.0));
186
+ // Half-texel inset, so the end stops are the cube's own values and not a blend
187
+ // with the clamp-to-edge border.
188
+ let n = 57.0;
189
+ let uvw = t * ((n - 1.0) / n) + (0.5 / n);
190
+ let formed = textureSampleLevel(agxLut, bloomSamp, uvw, 0.0).rgb;
191
+ let linear = pow(max(formed, vec3f(0.0)), vec3f(2.4));
192
+ return vec3f(srgb_encode(linear.r), srgb_encode(linear.g), srgb_encode(linear.b));
193
+ }
194
+
195
+ /** Which display transform, chosen per frame at viewU[6].y (0 filmic, 1 standard, 2 agx).
196
+ * A uniform branch rather than a pipeline variant: switching is rare, and both
197
+ * arms are cheap enough that specialising the pipeline would buy nothing. */
198
+ fn viewTransform(c: vec3f) -> vec3f {
199
+ let mode = viewU[6].y;
200
+ if (mode > 1.5) { return agxTransform(c); }
201
+ if (mode > 0.5) { return vec3f(srgb_encode(c.r), srgb_encode(c.g), srgb_encode(c.b)); }
202
+ return vec3f(filmic(c.r), filmic(c.g), filmic(c.b));
203
+ }
204
+
205
+ /** Canvas size in pixels — for user effects (aspect correction). */
126
206
  fn bgResolution() -> vec2f { return viewU[6].zw; }
127
207
 
208
+ /** The camera's world position. */
209
+ fn bgCameraPos() -> vec3f { return viewU[10].xyz; }
210
+
211
+ /** Where in the WORLD the scene drew this pixel — the depth handed to
212
+ * foreground() turned into a place. Without it an effect can only think in
213
+ * distances from the lens, which is no use to anything that belongs somewhere:
214
+ * fog lying on the ground has to know where the ground is.
215
+ *
216
+ * depth measures along the VIEW AXIS, not along the ray, so it is divided by
217
+ * the ray's projection onto camera-forward before being walked out. At the far
218
+ * plane (nothing drawn) this lands a very long way off, which is what a sky
219
+ * should do to anything reading it. */
220
+ fn bgWorldPos(ray: vec3f, depth: f32) -> vec3f {
221
+ let axis = max(dot(normalize(ray), viewU[5].xyz), 1e-4);
222
+ return bgCameraPos() + normalize(ray) * (depth / axis);
223
+ }
224
+
128
225
  /** Color grading, applied to the tonemapped SCENE (not the background — see the
129
226
  * call site). The core is ASC CDL, the film-industry interchange standard:
130
227
  *
@@ -218,8 +315,7 @@ const COMPOSITE_BODY = /* wgsl */ `
218
315
  let sceneStraight = scenePm.rgb / max(sceneAlpha, 1e-6);
219
316
 
220
317
  let exposed = sceneStraight * exp2(viewU[0].x);
221
- let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
222
- var disp = max(tm, vec3f(0.0));
318
+ var disp = max(viewTransform(exposed), vec3f(0.0));
223
319
  // Grade the SCENE only, before the display gamma. Deliberately not applied to
224
320
  // the background: it keeps a picked background color exactly as picked, and —
225
321
  // load-bearing — leaves green-screen mode's key color unshifted so chroma
@@ -236,40 +332,54 @@ const COMPOSITE_BODY = /* wgsl */ `
236
332
  let bg = viewU[2];
237
333
  var bgA = select(0.0, 1.0, bg.w > 0.5);
238
334
  var bgPm = bg.rgb * bgA; // premultiplied accumulator
239
- let fxOn = viewU[6].y > 0.5;
240
- if ((bg.w > 1.5 || fxOn) COVERAGE_GATE) {
241
- // The equirect and any effect both need this pixel's world-space view ray,
242
- // rebuilt from the camera basis. The dome sits at infinity (no parallax) —
243
- // PhotoDome-style, display-only.
244
- let ndc = vec2f(fragCoord.x / fullSz.x * 2.0 - 1.0, 1.0 - fragCoord.y / fullSz.y * 2.0);
245
- let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);
335
+ // This pixel's world-space view ray, rebuilt from the camera basis — what the
336
+ // equirect samples by, and what both effect mounts navigate by. The dome sits
337
+ // at infinity (no parallax): PhotoDome-style, display-only. Hoisted out of the
338
+ // branch below because the foreground mount is past the end of it; it is pure
339
+ // arithmetic on uniforms, which every backend sinks into whatever reads it.
340
+ let ndc = vec2f(fragCoord.x / fullSz.x * 2.0 - 1.0, 1.0 - fragCoord.y / fullSz.y * 2.0);
341
+ let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);
342
+ if (BACKGROUND_COND) {
246
343
  if (bg.w > 1.5) {
247
344
  // LH world (+Z forward): longitude = atan2(x, z), Babylon-PhotoDome convention.
248
345
  let su = 0.5 + atan2(dir.x, dir.z) * 0.15915494309; // 1/(2π)
249
346
  let sv = 0.5 - asin(clamp(dir.y, -1.0, 1.0)) * 0.31830988618; // 1/π
250
347
  bgPm = textureSampleLevel(bgEquirect, bloomSamp, vec2f(su, sv), 0.0).rgb;
251
348
  }
252
- BG_EFFECT_CALL
349
+ BACKGROUND_CALL
253
350
  }
254
- return vec4f(disp * sceneAlpha + bgPm * (1.0 - sceneAlpha), sceneAlpha + bgA * (1.0 - sceneAlpha));
351
+ // The frame, premultiplied: scene over background. A var, not the return
352
+ // expression, because the foreground mount composites onto it.
353
+ var outRgb = disp * sceneAlpha + bgPm * (1.0 - sceneAlpha);
354
+ var outA = sceneAlpha + bgA * (1.0 - sceneAlpha);
355
+ FOREGROUND_CALL
356
+ return vec4f(outRgb, outA);
255
357
  }
256
358
  `
257
359
 
258
- // Base variant: no effect installed, the flag is never set — the ray block only
259
- // runs for the equirect, and there is nothing to add. (`dir` may go unused when
260
- // this compiles with mode<2 shaders; WGSL is fine with an unused let.)
261
- const NO_EFFECT_CALL = `_ = dir;`
262
-
263
360
  // uv flipped to bottom-left origin (shadertoy convention); clamped so a stray
264
361
  // effect can't push negatives/NaN into the premultiplied composite. Standard
265
- // OVER onto the base layer.
266
- const EFFECT_CALL = /* wgsl */ `
267
- if (fxOn) {
268
- let bgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
269
- let fx = clamp(background(dir, bgUv, viewU[6].x), vec4f(0.0), vec4f(1.0));
270
- bgPm = fx.rgb * fx.a + bgPm * (1.0 - fx.a);
271
- bgA = fx.a + bgA * (1.0 - fx.a);
272
- }
362
+ // OVER onto the base layer. No `if` around it: the pipeline is rebuilt per
363
+ // effect, so this text only exists in variants whose WGSL defines background().
364
+ const BACKGROUND_CALL = /* wgsl */ `
365
+ let bgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
366
+ let bgFx = clamp(background(dir, bgUv, viewU[6].x), vec4f(0.0), vec4f(1.0));
367
+ bgPm = bgFx.rgb * bgFx.a + bgPm * (1.0 - bgFx.a);
368
+ bgA = bgFx.a + bgA * (1.0 - bgFx.a);
369
+ `
370
+
371
+ // Same OVER, one layer later — onto the finished frame rather than onto the
372
+ // base. Ungated by design: a foreground runs at every pixel, including the ones
373
+ // the model covers, because covering them is the point.
374
+ const FOREGROUND_CALL = /* wgsl */ `
375
+ let fgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
376
+ // The scene's own depth, so the effect can tell what is in front of it: a
377
+ // petal compares its distance against this and lets the model take the pixel,
378
+ // and fog's alpha is nothing but a function of it. Pixels the scene never drew
379
+ // read the far plane, so distance fog closes over the backdrop too.
380
+ let fgFx = clamp(foreground(dir, fgUv, viewU[6].x, linearDepth(coord)), vec4f(0.0), vec4f(1.0));
381
+ outRgb = fgFx.rgb * fgFx.a + outRgb * (1.0 - fgFx.a);
382
+ outA = fgFx.a + outA * (1.0 - fgFx.a);
273
383
  `
274
384
 
275
385
  // Derivative builtins are illegal in non-uniform control flow (WGSL uniformity
@@ -277,33 +387,41 @@ const EFFECT_CALL = /* wgsl */ `
277
387
  // effect code that doesn't use them. Checked textually at build time.
278
388
  const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/
279
389
 
280
- /** Skip the whole background block (equirect sample + effect) behind pixels the
281
- * model fully covers — the composite multiplies the result by (1 - alpha) = 0
282
- * there anyway, and on a full-screen effect that's a third or more of the frame
283
- * (the cost Safari feels most). The equirect uses explicit-LOD sampling, which
284
- * is always legal in non-uniform flow; only derivative-using effects must keep
285
- * uniform control flow and forgo the gate. */
286
- function coverageGate(effect?: CompositeEffectSource | null): string {
287
- const gated = !effect || !USES_DERIVATIVES.test(effect.wgsl)
390
+ /** The condition on the background block (equirect sample + background effect).
391
+ *
392
+ * Two jobs. It skips the block behind pixels the model fully covers — the
393
+ * composite multiplies the result by (1 - alpha) = 0 there anyway, and on a
394
+ * full-screen effect that's a third or more of the frame (the cost Safari feels
395
+ * most). And with no background effect compiled in, it also skips the block
396
+ * entirely unless the equirect needs it.
397
+ *
398
+ * The equirect uses explicit-LOD sampling, which is always legal in non-uniform
399
+ * flow; only derivative-using effects must keep uniform control flow and forgo
400
+ * the coverage half. The test is textual over the whole file, so a foreground
401
+ * that uses fwidth costs the background its gate — conservative, and only ever
402
+ * in the direction of correctness. (The foreground mount itself sits in uniform
403
+ * flow, so derivatives are always legal there.) */
404
+ function backgroundCondition(effect?: CompositeEffectSource | null): string {
288
405
  // sceneAlpha, not alpha: the bokeh gather spreads coverage, so a pixel the
289
406
  // sharp scene fully covered can end up needing background behind its blur.
290
- return gated ? "&& sceneAlpha < 0.999" : ""
407
+ const coverage = "sceneAlpha < 0.999"
408
+ if (!effect?.hasBackground) return `bg.w > 1.5 && ${coverage}`
409
+ return USES_DERIVATIVES.test(effect.wgsl) ? "true" : coverage
291
410
  }
292
411
 
293
412
  export function buildCompositeShader(effect?: CompositeEffectSource | null): string {
294
- if (!effect)
295
- return (
296
- COMPOSITE_HEAD +
297
- COMPOSITE_BODY.replace("BG_EFFECT_CALL", NO_EFFECT_CALL).replace("COVERAGE_GATE", coverageGate(null))
298
- )
413
+ const body = COMPOSITE_BODY.replace("BACKGROUND_COND", backgroundCondition(effect))
414
+ .replace("BACKGROUND_CALL", effect?.hasBackground ? BACKGROUND_CALL.trim() : "")
415
+ .replace("FOREGROUND_CALL", effect?.hasForeground ? FOREGROUND_CALL.trim() : "")
416
+ if (!effect) return COMPOSITE_HEAD + body
299
417
  return (
300
418
  COMPOSITE_HEAD +
301
- "\n// ── user background effect (setBackgroundEffect) ──\n" +
419
+ "\n// ── user effect (setEffect) ──\n" +
302
420
  effect.paramsDecl +
303
421
  "\n" +
304
422
  effect.wgsl +
305
423
  "\n" +
306
- COMPOSITE_BODY.replace("BG_EFFECT_CALL", EFFECT_CALL.trim()).replace("COVERAGE_GATE", coverageGate(effect))
424
+ body
307
425
  )
308
426
  }
309
427