reze-engine 0.39.0 → 0.41.0

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/src/model.ts CHANGED
@@ -1141,42 +1141,6 @@ export class Model {
1141
1141
  return this.skeleton.inverseBindMatrices
1142
1142
  }
1143
1143
 
1144
- /** World-space positions of `count` vertices sampled evenly across the mesh
1145
- * under the CURRENT pose — the model's skinning replayed on the CPU for
1146
- * this one call, root transform included. Feed to Engine.emitParticles for
1147
- * dissolve/materialize effects shaped like the model itself. */
1148
- sampleSurfacePoints(count: number): Float32Array {
1149
- const skin = this.getSkinMatrices()
1150
- const joints = this.skinning.joints
1151
- const weights = this.skinning.weights
1152
- const n = this.vertexCount
1153
- const c = Math.max(1, Math.min(count, n))
1154
- const out = new Float32Array(c * 3)
1155
- const step = n / c
1156
- for (let i = 0; i < c; i++) {
1157
- const v = Math.min(n - 1, Math.floor(i * step + Math.random() * step))
1158
- const vi = v * VERTEX_STRIDE
1159
- const px = this.vertexData[vi]
1160
- const py = this.vertexData[vi + 1]
1161
- const pz = this.vertexData[vi + 2]
1162
- let x = 0
1163
- let y = 0
1164
- let z = 0
1165
- for (let j = 0; j < 4; j++) {
1166
- const w = weights[v * 4 + j] / 255
1167
- if (w === 0) continue
1168
- const m = joints[v * 4 + j] * 16
1169
- x += w * (skin[m] * px + skin[m + 4] * py + skin[m + 8] * pz + skin[m + 12])
1170
- y += w * (skin[m + 1] * px + skin[m + 5] * py + skin[m + 9] * pz + skin[m + 13])
1171
- z += w * (skin[m + 2] * px + skin[m + 6] * py + skin[m + 10] * pz + skin[m + 14])
1172
- }
1173
- out[i * 3] = x
1174
- out[i * 3 + 1] = y
1175
- out[i * 3 + 2] = z
1176
- }
1177
- return out
1178
- }
1179
-
1180
1144
  getSkinMatrices(): Float32Array {
1181
1145
  const boneCount = this.skeleton.bones.length
1182
1146
  const worldMats = this.runtimeSkeleton.worldMatrices
@@ -506,6 +506,27 @@ fn principled_sheen(NV: f32) -> f32 {
506
506
  // sheen — 0 disables. Scales the sheen diffuse add; cloth/stockings use ~0.7.
507
507
  // sheen_tint — 0 = white sheen, 1 = fully tinted by base. Multiplied by sheen,
508
508
  // so value is don't-care when sheen=0.
509
+ // The PMX sphere map, applied the way MMD applies it: the texture is a
510
+ // VIEW-SPACE lighting mask, sampled by the camera-space normal rather than by
511
+ // any UV the mesh carries, which is why it tracks the viewer and reads as a
512
+ // highlight. The material's own mode picks the operator — .sph (1) multiplies
513
+ // the shaded base, .spa (2) adds — so a graph asks for the effect and the model
514
+ // decides which it meant. Mode 0, or a material with no sphere texture (the 1×1
515
+ // white fallback is bound then), is an exact no-op.
516
+ fn pmx_sphere_map(base: vec3f, strength: f32, N: vec3f) -> vec3f {
517
+ if (material.sphereMode < 0.5) {
518
+ return base;
519
+ }
520
+ let camera_n = safe_normal((camera.view * vec4f(N, 0.0)).xyz);
521
+ let sphere_uv = clamp(camera_n.xy * 0.5 + vec2f(0.5), vec2f(0.0), vec2f(1.0));
522
+ let sphere_rgb = textureSample(sphereTexture, diffuseSampler, sphere_uv).rgb;
523
+ let amount = max(strength, 0.0);
524
+ if (material.sphereMode < 1.5) {
525
+ return mix(base, base * sphere_rgb, min(amount, 1.0));
526
+ }
527
+ return base + sphere_rgb * amount;
528
+ }
529
+
509
530
  struct PrincipledIn {
510
531
  base: vec3f,
511
532
  metallic: f32,
@@ -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,18 +79,63 @@ 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, _, 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>;
93
+ // The scene pass's own MSAA depth buffer, bound depth-only. NOT an extra
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().
99
+ @group(0) @binding(8) var depthTex: texture_depth_multisampled_2d;
100
+ // dofU[0] = (enabled, focusDistance, focusRange, aperture)
101
+ // dofU[1] = (maxBlurRadiusPx, bladeCount, sampleCount, anamorphicRatio)
102
+ // dofU[2] = (projA, projB, _, _) — z-buffer → camera-space depth inversion,
103
+ // viewZ = projB / (z - projA), rebuilt per frame because near/far
104
+ // track the camera radius. Cleared depth (1.0) inverts to the far
105
+ // plane, so empty sky reads as maximally defocused background.
106
+ @group(0) @binding(9) var<uniform> dofU: array<vec4<f32>, 3>;
69
107
 
70
108
  // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
71
109
  const FILMIC_LUT_W: f32 = 256.0;
72
110
 
111
+ fn linearDepth(coord: vec2<i32>) -> f32 {
112
+ let z = textureLoad(depthTex, coord, 0);
113
+ // projA > 1 for every valid z in [0,1], so the divisor never crosses zero.
114
+ return clamp(dofU[2].y / (z - dofU[2].x), 0.05, 100000.0);
115
+ }
116
+
117
+ /** Signed circle of confusion in device pixels — negative in front of the
118
+ * focus band, positive behind it, zero inside it. */
119
+ fn circleOfConfusion(depth: f32) -> f32 {
120
+ let focus = max(dofU[0].y, 0.05);
121
+ let halfRange = max(dofU[0].z * 0.5, 0.01);
122
+ let delta = depth - focus;
123
+ let outside = max(abs(delta) - halfRange, 0.0);
124
+ let radius = min(outside / max(depth, 0.05) * dofU[0].w * dofU[1].x, dofU[1].x);
125
+ return select(-radius, radius, delta >= 0.0);
126
+ }
127
+
128
+ /** Premultiplied scene color (HDR + bloom) at an arbitrary pixel, for the
129
+ * bokeh gather. Explicit-LOD sampling only — legal in non-uniform flow. */
130
+ fn sceneSample(coord: vec2<i32>, fullSzI: vec2<i32>, fullSz: vec2f) -> vec4f {
131
+ let p = clamp(coord, vec2<i32>(0), fullSzI - vec2<i32>(1));
132
+ let sAlpha = textureLoad(maskTex, p, 0).g;
133
+ let sHdr = textureLoad(hdrTex, p, 0).rgb / max(sAlpha, 1e-6);
134
+ let sUv = (vec2f(p) + vec2f(0.5)) / fullSz;
135
+ let sBloom = textureSampleLevel(bloomTex, bloomSamp, sUv, 0.0).rgb * viewU[1].xyz * viewU[1].w;
136
+ return vec4f((sHdr + sBloom) * sAlpha, sAlpha);
137
+ }
138
+
73
139
  fn filmic(x: f32) -> f32 {
74
140
  // Reference checkpoints (Blender 3.6 Filmic MHC, sobotka/filmic-blender
75
141
  // look_medium-high-contrast.spi1d): linear 0.18 → ~0.395, linear 1.0 → ~0.83.
@@ -81,9 +147,26 @@ fn filmic(x: f32) -> f32 {
81
147
  return textureSampleLevel(filmicLut, bloomSamp, vec2f(u, 0.5), 0.0).r;
82
148
  }
83
149
 
84
- /** Canvas size in pixels — for user background effects (aspect correction). */
150
+ /** Canvas size in pixels — for user effects (aspect correction). */
85
151
  fn bgResolution() -> vec2f { return viewU[6].zw; }
86
152
 
153
+ /** The camera's world position. */
154
+ fn bgCameraPos() -> vec3f { return viewU[10].xyz; }
155
+
156
+ /** Where in the WORLD the scene drew this pixel — the depth handed to
157
+ * foreground() turned into a place. Without it an effect can only think in
158
+ * distances from the lens, which is no use to anything that belongs somewhere:
159
+ * fog lying on the ground has to know where the ground is.
160
+ *
161
+ * depth measures along the VIEW AXIS, not along the ray, so it is divided by
162
+ * the ray's projection onto camera-forward before being walked out. At the far
163
+ * plane (nothing drawn) this lands a very long way off, which is what a sky
164
+ * should do to anything reading it. */
165
+ fn bgWorldPos(ray: vec3f, depth: f32) -> vec3f {
166
+ let axis = max(dot(normalize(ray), viewU[5].xyz), 1e-4);
167
+ return bgCameraPos() + normalize(ray) * (depth / axis);
168
+ }
169
+
87
170
  /** Color grading, applied to the tonemapped SCENE (not the background — see the
88
171
  * call site). The core is ASC CDL, the film-industry interchange standard:
89
172
  *
@@ -124,7 +207,59 @@ const COMPOSITE_BODY = /* wgsl */ `
124
207
  let intensity = viewU[1].w;
125
208
  let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
126
209
  let combined = straight + bloom;
127
- let exposed = combined * exp2(viewU[0].x);
210
+
211
+ // ── Depth of field ──
212
+ // Single-pass golden-angle gather over a polygonal (bladed) disk, in
213
+ // premultiplied HDR before tonemap. Near-field taps that see focused
214
+ // background are heavily down-weighted so a sharp subject doesn't bleed into
215
+ // a blurred foreground; the reverse leak (background bokeh over the subject
216
+ // edge) is damped less — that soft halo is what real lenses do. Scene layer
217
+ // only: the composited background (solid / 360 / effect) stays sharp, which
218
+ // is invisible while it sits at infinity behind a far-blurred stage.
219
+ var scenePm = vec4f(combined * alpha, alpha);
220
+ if (dofU[0].x > 0.5) {
221
+ let centerDepth = linearDepth(coord);
222
+ let centerCoc = circleOfConfusion(centerDepth);
223
+ let radius = abs(centerCoc);
224
+ if (radius > 0.35) {
225
+ let fullSzI = vec2<i32>(fullSz);
226
+ let sampleCount = clamp(dofU[1].z, 6.0, 24.0);
227
+ let blades = clamp(dofU[1].y, 3.0, 12.0);
228
+ let sector = 6.28318530718 / blades;
229
+ var accum = scenePm;
230
+ var weightSum = 1.0;
231
+ for (var i = 0u; i < 24u; i++) {
232
+ if (f32(i) >= sampleCount) { break; }
233
+ let fi = f32(i) + 0.5;
234
+ let ring = sqrt(fi / sampleCount);
235
+ let angle = fi * 2.39996323;
236
+ let localAngle = (fract((angle + 3.14159265359) / sector) - 0.5) * sector;
237
+ let polygonRadius = cos(3.14159265359 / blades) / max(cos(localAngle), 0.01);
238
+ var disk = vec2f(cos(angle), sin(angle)) * ring * polygonRadius;
239
+ disk.x *= max(dofU[1].w, 0.25);
240
+ let sp = coord + vec2<i32>(round(disk * radius));
241
+ let cp = clamp(sp, vec2<i32>(0), fullSzI - vec2<i32>(1));
242
+ let sampleDepth = linearDepth(cp);
243
+ let sampleCoc = circleOfConfusion(sampleDepth);
244
+ var w = 1.0;
245
+ if (centerCoc < 0.0 && sampleDepth > centerDepth + dofU[0].z) {
246
+ w *= 0.08;
247
+ } else if (centerCoc > 0.0 && sampleDepth > centerDepth + dofU[0].z * 2.0) {
248
+ w *= 0.35;
249
+ }
250
+ // A tap only contributes where its own blur circle reaches this pixel.
251
+ let sampleRadius = abs(sampleCoc);
252
+ w *= mix(0.2, 1.0, smoothstep(ring * radius - 1.0, ring * radius + 1.0, sampleRadius));
253
+ accum += sceneSample(cp, fullSzI, fullSz) * w;
254
+ weightSum += w;
255
+ }
256
+ scenePm = mix(scenePm, accum / max(weightSum, 1e-5), smoothstep(0.35, 1.75, radius));
257
+ }
258
+ }
259
+ let sceneAlpha = scenePm.a;
260
+ let sceneStraight = scenePm.rgb / max(sceneAlpha, 1e-6);
261
+
262
+ let exposed = sceneStraight * exp2(viewU[0].x);
128
263
  let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
129
264
  var disp = max(tm, vec3f(0.0));
130
265
  // Grade the SCENE only, before the display gamma. Deliberately not applied to
@@ -143,40 +278,54 @@ const COMPOSITE_BODY = /* wgsl */ `
143
278
  let bg = viewU[2];
144
279
  var bgA = select(0.0, 1.0, bg.w > 0.5);
145
280
  var bgPm = bg.rgb * bgA; // premultiplied accumulator
146
- let fxOn = viewU[6].y > 0.5;
147
- if ((bg.w > 1.5 || fxOn) COVERAGE_GATE) {
148
- // The equirect and any effect both need this pixel's world-space view ray,
149
- // rebuilt from the camera basis. The dome sits at infinity (no parallax) —
150
- // PhotoDome-style, display-only.
151
- let ndc = vec2f(fragCoord.x / fullSz.x * 2.0 - 1.0, 1.0 - fragCoord.y / fullSz.y * 2.0);
152
- let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);
281
+ // This pixel's world-space view ray, rebuilt from the camera basis — what the
282
+ // equirect samples by, and what both effect mounts navigate by. The dome sits
283
+ // at infinity (no parallax): PhotoDome-style, display-only. Hoisted out of the
284
+ // branch below because the foreground mount is past the end of it; it is pure
285
+ // arithmetic on uniforms, which every backend sinks into whatever reads it.
286
+ let ndc = vec2f(fragCoord.x / fullSz.x * 2.0 - 1.0, 1.0 - fragCoord.y / fullSz.y * 2.0);
287
+ let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);
288
+ if (BACKGROUND_COND) {
153
289
  if (bg.w > 1.5) {
154
290
  // LH world (+Z forward): longitude = atan2(x, z), Babylon-PhotoDome convention.
155
291
  let su = 0.5 + atan2(dir.x, dir.z) * 0.15915494309; // 1/(2π)
156
292
  let sv = 0.5 - asin(clamp(dir.y, -1.0, 1.0)) * 0.31830988618; // 1/π
157
293
  bgPm = textureSampleLevel(bgEquirect, bloomSamp, vec2f(su, sv), 0.0).rgb;
158
294
  }
159
- BG_EFFECT_CALL
295
+ BACKGROUND_CALL
160
296
  }
161
- return vec4f(disp * alpha + bgPm * (1.0 - alpha), alpha + bgA * (1.0 - alpha));
297
+ // The frame, premultiplied: scene over background. A var, not the return
298
+ // expression, because the foreground mount composites onto it.
299
+ var outRgb = disp * sceneAlpha + bgPm * (1.0 - sceneAlpha);
300
+ var outA = sceneAlpha + bgA * (1.0 - sceneAlpha);
301
+ FOREGROUND_CALL
302
+ return vec4f(outRgb, outA);
162
303
  }
163
304
  `
164
305
 
165
- // Base variant: no effect installed, the flag is never set — the ray block only
166
- // runs for the equirect, and there is nothing to add. (`dir` may go unused when
167
- // this compiles with mode<2 shaders; WGSL is fine with an unused let.)
168
- const NO_EFFECT_CALL = `_ = dir;`
169
-
170
306
  // uv flipped to bottom-left origin (shadertoy convention); clamped so a stray
171
307
  // effect can't push negatives/NaN into the premultiplied composite. Standard
172
- // OVER onto the base layer.
173
- const EFFECT_CALL = /* wgsl */ `
174
- if (fxOn) {
175
- let bgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
176
- let fx = clamp(background(dir, bgUv, viewU[6].x), vec4f(0.0), vec4f(1.0));
177
- bgPm = fx.rgb * fx.a + bgPm * (1.0 - fx.a);
178
- bgA = fx.a + bgA * (1.0 - fx.a);
179
- }
308
+ // OVER onto the base layer. No `if` around it: the pipeline is rebuilt per
309
+ // effect, so this text only exists in variants whose WGSL defines background().
310
+ const BACKGROUND_CALL = /* wgsl */ `
311
+ let bgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
312
+ let bgFx = clamp(background(dir, bgUv, viewU[6].x), vec4f(0.0), vec4f(1.0));
313
+ bgPm = bgFx.rgb * bgFx.a + bgPm * (1.0 - bgFx.a);
314
+ bgA = bgFx.a + bgA * (1.0 - bgFx.a);
315
+ `
316
+
317
+ // Same OVER, one layer later — onto the finished frame rather than onto the
318
+ // base. Ungated by design: a foreground runs at every pixel, including the ones
319
+ // the model covers, because covering them is the point.
320
+ const FOREGROUND_CALL = /* wgsl */ `
321
+ let fgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
322
+ // The scene's own depth, so the effect can tell what is in front of it: a
323
+ // petal compares its distance against this and lets the model take the pixel,
324
+ // and fog's alpha is nothing but a function of it. Pixels the scene never drew
325
+ // read the far plane, so distance fog closes over the backdrop too.
326
+ let fgFx = clamp(foreground(dir, fgUv, viewU[6].x, linearDepth(coord)), vec4f(0.0), vec4f(1.0));
327
+ outRgb = fgFx.rgb * fgFx.a + outRgb * (1.0 - fgFx.a);
328
+ outA = fgFx.a + outA * (1.0 - fgFx.a);
180
329
  `
181
330
 
182
331
  // Derivative builtins are illegal in non-uniform control flow (WGSL uniformity
@@ -184,31 +333,41 @@ const EFFECT_CALL = /* wgsl */ `
184
333
  // effect code that doesn't use them. Checked textually at build time.
185
334
  const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/
186
335
 
187
- /** Skip the whole background block (equirect sample + effect) behind pixels the
188
- * model fully covers — the composite multiplies the result by (1 - alpha) = 0
189
- * there anyway, and on a full-screen effect that's a third or more of the frame
190
- * (the cost Safari feels most). The equirect uses explicit-LOD sampling, which
191
- * is always legal in non-uniform flow; only derivative-using effects must keep
192
- * uniform control flow and forgo the gate. */
193
- function coverageGate(effect?: CompositeEffectSource | null): string {
194
- const gated = !effect || !USES_DERIVATIVES.test(effect.wgsl)
195
- return gated ? "&& alpha < 0.999" : ""
336
+ /** The condition on the background block (equirect sample + background effect).
337
+ *
338
+ * Two jobs. It skips the block behind pixels the model fully covers — the
339
+ * composite multiplies the result by (1 - alpha) = 0 there anyway, and on a
340
+ * full-screen effect that's a third or more of the frame (the cost Safari feels
341
+ * most). And with no background effect compiled in, it also skips the block
342
+ * entirely unless the equirect needs it.
343
+ *
344
+ * The equirect uses explicit-LOD sampling, which is always legal in non-uniform
345
+ * flow; only derivative-using effects must keep uniform control flow and forgo
346
+ * the coverage half. The test is textual over the whole file, so a foreground
347
+ * that uses fwidth costs the background its gate — conservative, and only ever
348
+ * in the direction of correctness. (The foreground mount itself sits in uniform
349
+ * flow, so derivatives are always legal there.) */
350
+ function backgroundCondition(effect?: CompositeEffectSource | null): string {
351
+ // sceneAlpha, not alpha: the bokeh gather spreads coverage, so a pixel the
352
+ // sharp scene fully covered can end up needing background behind its blur.
353
+ const coverage = "sceneAlpha < 0.999"
354
+ if (!effect?.hasBackground) return `bg.w > 1.5 && ${coverage}`
355
+ return USES_DERIVATIVES.test(effect.wgsl) ? "true" : coverage
196
356
  }
197
357
 
198
358
  export function buildCompositeShader(effect?: CompositeEffectSource | null): string {
199
- if (!effect)
200
- return (
201
- COMPOSITE_HEAD +
202
- COMPOSITE_BODY.replace("BG_EFFECT_CALL", NO_EFFECT_CALL).replace("COVERAGE_GATE", coverageGate(null))
203
- )
359
+ const body = COMPOSITE_BODY.replace("BACKGROUND_COND", backgroundCondition(effect))
360
+ .replace("BACKGROUND_CALL", effect?.hasBackground ? BACKGROUND_CALL.trim() : "")
361
+ .replace("FOREGROUND_CALL", effect?.hasForeground ? FOREGROUND_CALL.trim() : "")
362
+ if (!effect) return COMPOSITE_HEAD + body
204
363
  return (
205
364
  COMPOSITE_HEAD +
206
- "\n// ── user background effect (setBackgroundEffect) ──\n" +
365
+ "\n// ── user effect (setEffect) ──\n" +
207
366
  effect.paramsDecl +
208
367
  "\n" +
209
368
  effect.wgsl +
210
369
  "\n" +
211
- COMPOSITE_BODY.replace("BG_EFFECT_CALL", EFFECT_CALL.trim()).replace("COVERAGE_GATE", coverageGate(effect))
370
+ body
212
371
  )
213
372
  }
214
373
 
@@ -1,43 +0,0 @@
1
- import { Vec3 } from "./math";
2
- export interface ParticleEmitOptions {
3
- /** World-space anchor points, xyz-packed (see Model.sampleSurfacePoints).
4
- * One particle spawns per point. */
5
- points: Float32Array;
6
- /** "burst" flies point → scattered and fades (dissolve); "converge" flies
7
- * scattered → point and vanishes on arrival (materialize). Default burst. */
8
- mode?: "burst" | "converge";
9
- /** Seconds the full effect lasts, staggers included (default 1.6). */
10
- duration?: number;
11
- /** HDR color — components above 1 glow through bloom (default warm gold). */
12
- color?: Vec3;
13
- /** Particle radius in world units (default 0.5). */
14
- size?: number;
15
- /** Scatter distance in world units at the far end of the flight (default 14). */
16
- scatter?: number;
17
- /** Upward drift across the flight, world units (default 6). */
18
- lift?: number;
19
- /** Radians the cloud winds around its centroid across the flight (default 2). */
20
- swirl?: number;
21
- }
22
- export declare class ParticleSystem {
23
- private readonly device;
24
- private readonly pipeline;
25
- private readonly bindGroupLayout;
26
- private readonly cameraBuffer;
27
- private readonly timeBuffer;
28
- private readonly timeData;
29
- private emissions;
30
- constructor(device: GPUDevice, cameraBuffer: GPUBuffer, formats: {
31
- hdr: GPUTextureFormat;
32
- mask: GPUTextureFormat;
33
- depth: GPUTextureFormat;
34
- sampleCount: number;
35
- });
36
- /** Upload one emission. `now` is the shared clock (seconds) draw() receives. */
37
- emit(opts: ParticleEmitOptions, now: number): void;
38
- get active(): boolean;
39
- /** Record draws into the main pass (after the transparent phase). */
40
- draw(pass: GPURenderPassEncoder, now: number): void;
41
- dispose(): void;
42
- }
43
- //# sourceMappingURL=particles.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../src/particles.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAG7B,MAAM,WAAW,mBAAmB;IAClC;yCACqC;IACrC,MAAM,EAAE,YAAY,CAAA;IACpB;kFAC8E;IAC9E,IAAI,CAAC,EAAE,OAAO,GAAG,UAAU,CAAA;IAC3B,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,IAAI,CAAA;IACZ,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,+DAA+D;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,iFAAiF;IACjF,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAaD,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAmB;IAC5C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAoB;IACpD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAW;IACxC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAW;IACtC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsB;IAC/C,OAAO,CAAC,SAAS,CAAiB;gBAGhC,MAAM,EAAE,SAAS,EACjB,YAAY,EAAE,SAAS,EACvB,OAAO,EAAE;QAAE,GAAG,EAAE,gBAAgB,CAAC;QAAC,IAAI,EAAE,gBAAgB,CAAC;QAAC,KAAK,EAAE,gBAAgB,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE;IAwD1G,gFAAgF;IAChF,IAAI,CAAC,IAAI,EAAE,mBAAmB,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;IAoFlD,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED,qEAAqE;IACrE,IAAI,CAAC,IAAI,EAAE,oBAAoB,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;IAwBnD,OAAO,IAAI,IAAI;CAQhB"}