reze-engine 0.39.0 → 0.40.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.
@@ -44,10 +44,51 @@ override APPLY_GAMMA: bool = true;
44
44
  // viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
45
45
  // invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
46
46
  @group(0) @binding(6) var bgEquirect: texture_2d<f32>;
47
+ // The scene pass's own MSAA depth buffer, bound depth-only. NOT an extra
48
+ // render target: when depth of field is off the scene pass discards depth
49
+ // (TBDR tile memory never spills) and this binding is never read — the whole
50
+ // feature costs one uniform branch. When on, the pass stores depth and the
51
+ // gather below reads sample 0.
52
+ @group(0) @binding(8) var depthTex: texture_depth_multisampled_2d;
53
+ // dofU[0] = (enabled, focusDistance, focusRange, aperture)
54
+ // dofU[1] = (maxBlurRadiusPx, bladeCount, sampleCount, anamorphicRatio)
55
+ // dofU[2] = (projA, projB, _, _) — z-buffer → camera-space depth inversion,
56
+ // viewZ = projB / (z - projA), rebuilt per frame because near/far
57
+ // track the camera radius. Cleared depth (1.0) inverts to the far
58
+ // plane, so empty sky reads as maximally defocused background.
59
+ @group(0) @binding(9) var<uniform> dofU: array<vec4<f32>, 3>;
47
60
 
48
61
  // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
49
62
  const FILMIC_LUT_W: f32 = 256.0;
50
63
 
64
+ fn linearDepth(coord: vec2<i32>) -> f32 {
65
+ let z = textureLoad(depthTex, coord, 0);
66
+ // projA > 1 for every valid z in [0,1], so the divisor never crosses zero.
67
+ return clamp(dofU[2].y / (z - dofU[2].x), 0.05, 100000.0);
68
+ }
69
+
70
+ /** Signed circle of confusion in device pixels — negative in front of the
71
+ * focus band, positive behind it, zero inside it. */
72
+ fn circleOfConfusion(depth: f32) -> f32 {
73
+ let focus = max(dofU[0].y, 0.05);
74
+ let halfRange = max(dofU[0].z * 0.5, 0.01);
75
+ let delta = depth - focus;
76
+ let outside = max(abs(delta) - halfRange, 0.0);
77
+ let radius = min(outside / max(depth, 0.05) * dofU[0].w * dofU[1].x, dofU[1].x);
78
+ return select(-radius, radius, delta >= 0.0);
79
+ }
80
+
81
+ /** Premultiplied scene color (HDR + bloom) at an arbitrary pixel, for the
82
+ * bokeh gather. Explicit-LOD sampling only — legal in non-uniform flow. */
83
+ fn sceneSample(coord: vec2<i32>, fullSzI: vec2<i32>, fullSz: vec2f) -> vec4f {
84
+ let p = clamp(coord, vec2<i32>(0), fullSzI - vec2<i32>(1));
85
+ let sAlpha = textureLoad(maskTex, p, 0).g;
86
+ let sHdr = textureLoad(hdrTex, p, 0).rgb / max(sAlpha, 1e-6);
87
+ let sUv = (vec2f(p) + vec2f(0.5)) / fullSz;
88
+ let sBloom = textureSampleLevel(bloomTex, bloomSamp, sUv, 0.0).rgb * viewU[1].xyz * viewU[1].w;
89
+ return vec4f((sHdr + sBloom) * sAlpha, sAlpha);
90
+ }
91
+
51
92
  fn filmic(x: f32) -> f32 {
52
93
  // Reference checkpoints (Blender 3.6 Filmic MHC, sobotka/filmic-blender
53
94
  // look_medium-high-contrast.spi1d): linear 0.18 → ~0.395, linear 1.0 → ~0.83.
@@ -101,7 +142,59 @@ const COMPOSITE_BODY = /* wgsl */ `
101
142
  let intensity = viewU[1].w;
102
143
  let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
103
144
  let combined = straight + bloom;
104
- let exposed = combined * exp2(viewU[0].x);
145
+
146
+ // ── Depth of field ──
147
+ // Single-pass golden-angle gather over a polygonal (bladed) disk, in
148
+ // premultiplied HDR before tonemap. Near-field taps that see focused
149
+ // background are heavily down-weighted so a sharp subject doesn't bleed into
150
+ // a blurred foreground; the reverse leak (background bokeh over the subject
151
+ // edge) is damped less — that soft halo is what real lenses do. Scene layer
152
+ // only: the composited background (solid / 360 / effect) stays sharp, which
153
+ // is invisible while it sits at infinity behind a far-blurred stage.
154
+ var scenePm = vec4f(combined * alpha, alpha);
155
+ if (dofU[0].x > 0.5) {
156
+ let centerDepth = linearDepth(coord);
157
+ let centerCoc = circleOfConfusion(centerDepth);
158
+ let radius = abs(centerCoc);
159
+ if (radius > 0.35) {
160
+ let fullSzI = vec2<i32>(fullSz);
161
+ let sampleCount = clamp(dofU[1].z, 6.0, 24.0);
162
+ let blades = clamp(dofU[1].y, 3.0, 12.0);
163
+ let sector = 6.28318530718 / blades;
164
+ var accum = scenePm;
165
+ var weightSum = 1.0;
166
+ for (var i = 0u; i < 24u; i++) {
167
+ if (f32(i) >= sampleCount) { break; }
168
+ let fi = f32(i) + 0.5;
169
+ let ring = sqrt(fi / sampleCount);
170
+ let angle = fi * 2.39996323;
171
+ let localAngle = (fract((angle + 3.14159265359) / sector) - 0.5) * sector;
172
+ let polygonRadius = cos(3.14159265359 / blades) / max(cos(localAngle), 0.01);
173
+ var disk = vec2f(cos(angle), sin(angle)) * ring * polygonRadius;
174
+ disk.x *= max(dofU[1].w, 0.25);
175
+ let sp = coord + vec2<i32>(round(disk * radius));
176
+ let cp = clamp(sp, vec2<i32>(0), fullSzI - vec2<i32>(1));
177
+ let sampleDepth = linearDepth(cp);
178
+ let sampleCoc = circleOfConfusion(sampleDepth);
179
+ var w = 1.0;
180
+ if (centerCoc < 0.0 && sampleDepth > centerDepth + dofU[0].z) {
181
+ w *= 0.08;
182
+ } else if (centerCoc > 0.0 && sampleDepth > centerDepth + dofU[0].z * 2.0) {
183
+ w *= 0.35;
184
+ }
185
+ // A tap only contributes where its own blur circle reaches this pixel.
186
+ let sampleRadius = abs(sampleCoc);
187
+ w *= mix(0.2, 1.0, smoothstep(ring * radius - 1.0, ring * radius + 1.0, sampleRadius));
188
+ accum += sceneSample(cp, fullSzI, fullSz) * w;
189
+ weightSum += w;
190
+ }
191
+ scenePm = mix(scenePm, accum / max(weightSum, 1e-5), smoothstep(0.35, 1.75, radius));
192
+ }
193
+ }
194
+ let sceneAlpha = scenePm.a;
195
+ let sceneStraight = scenePm.rgb / max(sceneAlpha, 1e-6);
196
+
197
+ let exposed = sceneStraight * exp2(viewU[0].x);
105
198
  let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
106
199
  var disp = max(tm, vec3f(0.0));
107
200
  // Grade the SCENE only, before the display gamma. Deliberately not applied to
@@ -135,7 +228,7 @@ const COMPOSITE_BODY = /* wgsl */ `
135
228
  }
136
229
  BG_EFFECT_CALL
137
230
  }
138
- return vec4f(disp * alpha + bgPm * (1.0 - alpha), alpha + bgA * (1.0 - alpha));
231
+ return vec4f(disp * sceneAlpha + bgPm * (1.0 - sceneAlpha), sceneAlpha + bgA * (1.0 - sceneAlpha));
139
232
  }
140
233
  `;
141
234
  // Base variant: no effect installed, the flag is never set — the ray block only
@@ -165,7 +258,9 @@ const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/;
165
258
  * uniform control flow and forgo the gate. */
166
259
  function coverageGate(effect) {
167
260
  const gated = !effect || !USES_DERIVATIVES.test(effect.wgsl);
168
- return gated ? "&& alpha < 0.999" : "";
261
+ // sceneAlpha, not alpha: the bokeh gather spreads coverage, so a pixel the
262
+ // sharp scene fully covered can end up needing background behind its blur.
263
+ return gated ? "&& sceneAlpha < 0.999" : "";
169
264
  }
170
265
  export function buildCompositeShader(effect) {
171
266
  if (!effect)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
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
@@ -5,7 +5,6 @@ import { MORPH_COMPUTE_WGSL } from "./shaders/passes/morph"
5
5
  import { decodeTga } from "./tga-loader"
6
6
  import { VMDLoader } from "./vmd-loader"
7
7
  import { CameraAnimation } from "./camera-animation"
8
- import { ParticleSystem, type ParticleEmitOptions } from "./particles"
9
8
  import { PmxLoader } from "./pmx-loader"
10
9
  import { RezePhysics } from "./physics"
11
10
  import type { WindOptions } from "./physics/world"
@@ -276,6 +275,42 @@ export const DEFAULT_BLOOM_OPTIONS: BloomOptions = {
276
275
  clamp: 0.0,
277
276
  }
278
277
 
278
+ /** Camera depth of field — a bokeh gather in the composite pass. Costs nothing
279
+ * while disabled: the scene pass discards its depth buffer and the composite
280
+ * branch never runs. Enabled, the pass stores depth and the gather reads it. */
281
+ export type DepthOfFieldOptions = {
282
+ enabled: boolean
283
+ /** "auto" focuses the first visible character each frame — the camera-space
284
+ * depth span of its bones sets both distance and a floor on range — so a
285
+ * dancer stays sharp without anyone touching a slider. "manual" uses
286
+ * focusDistance/focusRange as given. */
287
+ focusMode: "auto" | "manual"
288
+ /** Camera-space distance to the focus plane (MMD units). */
289
+ focusDistance: number
290
+ /** Depth band that stays perfectly sharp, centered on focusDistance. In auto
291
+ * mode this is a minimum — the band never cuts into the subject. */
292
+ focusRange: number
293
+ /** Blur strength scale; 1 is a natural lens, higher is dreamier. */
294
+ aperture: number
295
+ /** Largest blur circle, in device pixels. */
296
+ maxBlurRadius: number
297
+ /** Bokeh polygon blade count (3–12); 6 is the classic hexagon. */
298
+ bladeCount: number
299
+ /** Gather tap count: 8 / 16 / 24. */
300
+ quality: "performance" | "balanced" | "cinematic"
301
+ }
302
+
303
+ export const DEFAULT_DEPTH_OF_FIELD_OPTIONS: DepthOfFieldOptions = {
304
+ enabled: false,
305
+ focusMode: "auto",
306
+ focusDistance: 25,
307
+ focusRange: 2,
308
+ aperture: 1,
309
+ maxBlurRadius: 18,
310
+ bladeCount: 6,
311
+ quality: "balanced",
312
+ }
313
+
279
314
  /** Blender Color Management / View (rendering.txt: Filmic, exposure, gamma). `look` is reserved for future curve tweaks. */
280
315
  export type ViewTransformOptions = {
281
316
  /** Stops applied before Filmic: `linear *= 2^exposure`. */
@@ -718,8 +753,6 @@ export class Engine {
718
753
  private multisampleMaskTexture!: GPUTexture
719
754
  private maskResolveTexture!: GPUTexture
720
755
  private maskResolveView!: GPUTextureView
721
- /** One-shot particle emissions (emitParticles); lazily built on first emit. */
722
- private particles: ParticleSystem | null = null
723
756
  private renderPassDescriptor!: GPURenderPassDescriptor
724
757
  private compositePassDescriptor!: GPURenderPassDescriptor
725
758
  // Two specialized composite pipelines via WGSL pipeline-override constants.
@@ -731,6 +764,12 @@ export class Engine {
731
764
  private morphComputeBindGroupLayout!: GPUBindGroupLayout
732
765
  private compositeBindGroupLayout!: GPUBindGroupLayout
733
766
  private compositeBindGroup!: GPUBindGroup
767
+ private depthOfField: DepthOfFieldOptions = { ...DEFAULT_DEPTH_OF_FIELD_OPTIONS }
768
+ private dofUniformBuffer!: GPUBuffer
769
+ private dofUniformData = new Float32Array(12)
770
+ private dofFocusScratch = new Vec3(0, 0, 0)
771
+ /** Depth-only view of the scene's MSAA depth buffer, read by the DoF gather. */
772
+ private depthReadView: GPUTextureView | null = null
734
773
  private compositeUniformBuffer!: GPUBuffer
735
774
  // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
736
775
  private readonly compositeUniformData = new Float32Array(40)
@@ -1082,7 +1121,7 @@ export class Engine {
1082
1121
  }
1083
1122
 
1084
1123
  private rebuildCompositeBindGroup(): void {
1085
- if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView) return
1124
+ if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
1086
1125
  this.compositeBindGroup = this.device.createBindGroup({
1087
1126
  label: "composite bind group",
1088
1127
  layout: this.compositeBindGroupLayout,
@@ -1095,6 +1134,8 @@ export class Engine {
1095
1134
  { binding: 5, resource: this.filmicLutView },
1096
1135
  { binding: 6, resource: this.backdropEquirectView ?? this.fallbackEquirectView },
1097
1136
  { binding: 7, resource: { buffer: this.backgroundEffect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
1137
+ { binding: 8, resource: this.depthReadView },
1138
+ { binding: 9, resource: { buffer: this.dofUniformBuffer } },
1098
1139
  ],
1099
1140
  })
1100
1141
  }
@@ -1306,6 +1347,86 @@ export class Engine {
1306
1347
  }
1307
1348
 
1308
1349
  /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
1350
+ /** Camera depth of field (see DepthOfFieldOptions). Free while disabled —
1351
+ * the scene pass only stores its depth buffer on frames the gather reads. */
1352
+ setDepthOfField(patch: Partial<DepthOfFieldOptions>): void {
1353
+ this.depthOfField = { ...this.depthOfField, ...patch }
1354
+ if (!this.device || !this.dofUniformBuffer) return
1355
+ if (this.depthOfField.enabled) {
1356
+ this.writeDepthOfFieldUniforms()
1357
+ } else {
1358
+ // One last write so the shader's uniform branch reads a clean zero.
1359
+ this.dofUniformData[0] = 0
1360
+ this.device.queue.writeBuffer(this.dofUniformBuffer, 0, this.dofUniformData)
1361
+ }
1362
+ }
1363
+
1364
+ getDepthOfField(): DepthOfFieldOptions {
1365
+ return { ...this.depthOfField }
1366
+ }
1367
+
1368
+ /** Auto-focus target: the camera-space depth span of the first visible
1369
+ * character's bones. Focus sits at the span's midpoint; the range covers the
1370
+ * span plus a margin for what bones don't reach (shoes, hair, cloth). */
1371
+ private getModelBodyFocus(): { distance: number; range: number } | null {
1372
+ if (!this.camera) return null
1373
+ const view = this.camera.getViewMatrix().values
1374
+ for (const inst of this.modelInstances.values()) {
1375
+ if (!inst.model.visible || inst.isStage) continue
1376
+ const model = inst.model
1377
+ const matrices = model.getWorldMatrices()
1378
+ if (matrices.length === 0) continue
1379
+ const scale = model.scale
1380
+ const local = this.dofFocusScratch
1381
+ let minDepth = Infinity
1382
+ let maxDepth = -Infinity
1383
+ for (const matrix of matrices) {
1384
+ const values = matrix.values
1385
+ // Bone matrices are model-space; apply the same root transform the
1386
+ // renderer bakes into skinning, then take camera-space z.
1387
+ local.setXYZ(values[12] * scale, values[13] * scale, values[14] * scale)
1388
+ Quat.rotateVecInto(model.rotation, local, local)
1389
+ const x = model.position.x + local.x
1390
+ const y = model.position.y + local.y
1391
+ const z = model.position.z + local.z
1392
+ const depth = view[2] * x + view[6] * y + view[10] * z + view[14]
1393
+ if (!Number.isFinite(depth) || depth <= this.camera.near) continue
1394
+ minDepth = Math.min(minDepth, depth)
1395
+ maxDepth = Math.max(maxDepth, depth)
1396
+ }
1397
+ if (Number.isFinite(minDepth) && Number.isFinite(maxDepth)) {
1398
+ const span = Math.max(0, maxDepth - minDepth)
1399
+ const meshMargin = Math.max(2.0, span * 0.15)
1400
+ return { distance: (minDepth + maxDepth) * 0.5, range: Math.max(2.0, span + meshMargin) }
1401
+ }
1402
+ }
1403
+ return null
1404
+ }
1405
+
1406
+ private writeDepthOfFieldUniforms(): void {
1407
+ if (!this.device || !this.dofUniformBuffer) return
1408
+ const d = this.depthOfField
1409
+ const u = this.dofUniformData
1410
+ const auto = d.focusMode === "auto" ? this.getModelBodyFocus() : null
1411
+ u[0] = d.enabled ? 1 : 0
1412
+ u[1] = auto?.distance ?? Math.max(d.focusDistance, 0.05)
1413
+ // In auto mode the authored range is a floor — the sharp band never cuts
1414
+ // into the character's own depth span.
1415
+ u[2] = Math.max(d.focusRange, auto?.range ?? 0.02, 0.02)
1416
+ u[3] = Math.max(d.aperture, 0)
1417
+ u[4] = Math.max(d.maxBlurRadius, 0)
1418
+ u[5] = Math.min(12, Math.max(3, Math.round(d.bladeCount)))
1419
+ u[6] = d.quality === "performance" ? 8 : d.quality === "cinematic" ? 24 : 16
1420
+ u[7] = 1 // anamorphic ratio, reserved (the shader clamps ≥ 0.25)
1421
+ // viewZ = projB / (z − projA), the inverse of perspectiveInto's z mapping.
1422
+ // near/far track the camera radius, so these refresh every enabled frame.
1423
+ const near = this.camera.near
1424
+ const far = this.camera.far
1425
+ u[8] = (far + near) / (far - near)
1426
+ u[9] = (-2 * near * far) / (far - near)
1427
+ this.device.queue.writeBuffer(this.dofUniformBuffer, 0, u)
1428
+ }
1429
+
1309
1430
  setBloomOptions(patch: Partial<BloomOptions>): void {
1310
1431
  const b = this.bloomSettings
1311
1432
  if (patch.enabled !== undefined) b.enabled = patch.enabled
@@ -2172,6 +2293,12 @@ export class Engine {
2172
2293
  size: 160,
2173
2294
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2174
2295
  })
2296
+ this.dofUniformBuffer = this.device.createBuffer({
2297
+ label: "depth of field uniforms",
2298
+ // 3 × vec4f — see the dofU comment in composite.ts.
2299
+ size: 48,
2300
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2301
+ })
2175
2302
  this.bgParamsDummyBuffer = this.device.createBuffer({
2176
2303
  label: "bg effect params (dummy)",
2177
2304
  size: 16,
@@ -2194,6 +2321,15 @@ export class Engine {
2194
2321
  // User background-effect params — dummy buffer when no effect is set. The
2195
2322
  // layout is explicit, so the base shader legally ignores the binding.
2196
2323
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2324
+ // The scene pass's MSAA depth, depth-only aspect — read by the DoF
2325
+ // gather. Contents are undefined while DoF is off (the scene pass
2326
+ // discards depth then), and the shader never reads it then either.
2327
+ {
2328
+ binding: 8,
2329
+ visibility: GPUShaderStage.FRAGMENT,
2330
+ texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
2331
+ },
2332
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2197
2333
  ],
2198
2334
  })
2199
2335
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -2439,10 +2575,13 @@ export class Engine {
2439
2575
  size: [width, height],
2440
2576
  sampleCount: Engine.MULTISAMPLE_COUNT,
2441
2577
  format: "depth24plus-stencil8",
2442
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
2578
+ // TEXTURE_BINDING for the DoF gather — a usage flag, not a copy; the
2579
+ // zero-cost-when-off story lives in depthStoreOp, not here.
2580
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
2443
2581
  })
2444
2582
 
2445
2583
  const depthTextureView = this.depthTexture.createView()
2584
+ this.depthReadView = this.depthTexture.createView({ aspect: "depth-only" })
2446
2585
 
2447
2586
  // storeOp="discard" on MSAA views keeps per-sample data in Apple TBDR tile memory —
2448
2587
  // only the resolveTarget (hdrResolveTexture / maskResolveView) gets written to RAM.
@@ -2923,24 +3062,6 @@ export class Engine {
2923
3062
  this.camera.fov = fov
2924
3063
  }
2925
3064
 
2926
- /** Fire a one-shot particle effect (see ParticleEmitOptions): "burst" scatters
2927
- * glowing sparks from the given world points, "converge" condenses them onto
2928
- * the points. Pair with Model.sampleSurfacePoints for dissolve/materialize
2929
- * transitions shaped like the model. Stateless GPU particles — the emission
2930
- * cleans itself up when it expires. */
2931
- emitParticles(opts: ParticleEmitOptions): void {
2932
- if (!this.device) return
2933
- if (!this.particles) {
2934
- this.particles = new ParticleSystem(this.device, this.cameraUniformBuffer, {
2935
- hdr: this.hdrFormat,
2936
- mask: Engine.BLOOM_MASK_FORMAT,
2937
- depth: "depth24plus-stencil8",
2938
- sampleCount: Engine.MULTISAMPLE_COUNT,
2939
- })
2940
- }
2941
- this.particles.emit(opts, performance.now() / 1000)
2942
- }
2943
-
2944
3065
  // Step 5: Create lighting buffers
2945
3066
  private setupLighting() {
2946
3067
  this.lightUniformBuffer = this.device.createBuffer({
@@ -3096,8 +3217,6 @@ export class Engine {
3096
3217
  this.forEachInstance((inst) => inst.model.stop())
3097
3218
  if (Engine.instance === this) Engine.instance = null
3098
3219
  if (this.camera) this.camera.detachControl()
3099
- this.particles?.dispose()
3100
- this.particles = null
3101
3220
 
3102
3221
  // Remove raycasting event listeners
3103
3222
  if (this.onRaycast) {
@@ -5093,6 +5212,14 @@ export class Engine {
5093
5212
  this.updateCameraUniforms()
5094
5213
  this.updateShadowLightVP()
5095
5214
 
5215
+ // Depth of field's entire disabled cost is this branch: depth stays in
5216
+ // TBDR tile memory (discard) unless the composite gather reads it this
5217
+ // frame. Enabled frames also refresh the uniforms — auto-focus tracks the
5218
+ // character and the depth-inversion constants track near/far.
5219
+ const dofOn = this.depthOfField.enabled
5220
+ this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = dofOn ? "store" : "discard"
5221
+ if (dofOn) this.writeDepthOfFieldUniforms()
5222
+
5096
5223
  const encoder = this.device.createCommandEncoder()
5097
5224
 
5098
5225
  // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
@@ -5139,9 +5266,6 @@ export class Engine {
5139
5266
  this.forEachInstance((inst) => {
5140
5267
  if (inst.model.visible) this.renderModelTransparentPhase(pass, inst)
5141
5268
  })
5142
- // Particle emissions last: additive light over everything, depth-tested
5143
- // against the scene, feeding the bloom mask so the sparks glow.
5144
- this.particles?.draw(pass, performance.now() / 1000)
5145
5269
  pass.end()
5146
5270
 
5147
5271
  // Bloom pyramid (EEVEE 3.6):
package/src/index.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  export {
2
2
  Engine,
3
3
  DEFAULT_BLOOM_OPTIONS,
4
+ DEFAULT_DEPTH_OF_FIELD_OPTIONS,
4
5
  DEFAULT_VIEW_TRANSFORM,
5
6
  DEFAULT_COLOR_GRADING,
6
7
  type ColorGradingOptions,
7
8
  type EngineStats,
8
9
  type EngineOptions,
9
10
  type BloomOptions,
11
+ type DepthOfFieldOptions,
10
12
  type ViewTransformOptions,
11
13
  type LoadModelFromFilesOptions,
12
14
  type MaterialPreset,
@@ -99,4 +101,3 @@ export { PmxLoader } from "./pmx-loader"
99
101
  export { CameraAnimation, type CameraPose } from "./camera-animation"
100
102
  export { RezePhysics } from "./physics"
101
103
  export type { WindOptions } from "./physics/world"
102
- export { type ParticleEmitOptions } from "./particles"
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
@@ -66,10 +66,51 @@ override APPLY_GAMMA: bool = true;
66
66
  // viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
67
67
  // invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
68
68
  @group(0) @binding(6) var bgEquirect: texture_2d<f32>;
69
+ // 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.
74
+ @group(0) @binding(8) var depthTex: texture_depth_multisampled_2d;
75
+ // dofU[0] = (enabled, focusDistance, focusRange, aperture)
76
+ // dofU[1] = (maxBlurRadiusPx, bladeCount, sampleCount, anamorphicRatio)
77
+ // dofU[2] = (projA, projB, _, _) — z-buffer → camera-space depth inversion,
78
+ // viewZ = projB / (z - projA), rebuilt per frame because near/far
79
+ // track the camera radius. Cleared depth (1.0) inverts to the far
80
+ // plane, so empty sky reads as maximally defocused background.
81
+ @group(0) @binding(9) var<uniform> dofU: array<vec4<f32>, 3>;
69
82
 
70
83
  // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
71
84
  const FILMIC_LUT_W: f32 = 256.0;
72
85
 
86
+ fn linearDepth(coord: vec2<i32>) -> f32 {
87
+ let z = textureLoad(depthTex, coord, 0);
88
+ // projA > 1 for every valid z in [0,1], so the divisor never crosses zero.
89
+ return clamp(dofU[2].y / (z - dofU[2].x), 0.05, 100000.0);
90
+ }
91
+
92
+ /** Signed circle of confusion in device pixels — negative in front of the
93
+ * focus band, positive behind it, zero inside it. */
94
+ fn circleOfConfusion(depth: f32) -> f32 {
95
+ let focus = max(dofU[0].y, 0.05);
96
+ let halfRange = max(dofU[0].z * 0.5, 0.01);
97
+ let delta = depth - focus;
98
+ let outside = max(abs(delta) - halfRange, 0.0);
99
+ let radius = min(outside / max(depth, 0.05) * dofU[0].w * dofU[1].x, dofU[1].x);
100
+ return select(-radius, radius, delta >= 0.0);
101
+ }
102
+
103
+ /** Premultiplied scene color (HDR + bloom) at an arbitrary pixel, for the
104
+ * bokeh gather. Explicit-LOD sampling only — legal in non-uniform flow. */
105
+ fn sceneSample(coord: vec2<i32>, fullSzI: vec2<i32>, fullSz: vec2f) -> vec4f {
106
+ let p = clamp(coord, vec2<i32>(0), fullSzI - vec2<i32>(1));
107
+ let sAlpha = textureLoad(maskTex, p, 0).g;
108
+ let sHdr = textureLoad(hdrTex, p, 0).rgb / max(sAlpha, 1e-6);
109
+ let sUv = (vec2f(p) + vec2f(0.5)) / fullSz;
110
+ let sBloom = textureSampleLevel(bloomTex, bloomSamp, sUv, 0.0).rgb * viewU[1].xyz * viewU[1].w;
111
+ return vec4f((sHdr + sBloom) * sAlpha, sAlpha);
112
+ }
113
+
73
114
  fn filmic(x: f32) -> f32 {
74
115
  // Reference checkpoints (Blender 3.6 Filmic MHC, sobotka/filmic-blender
75
116
  // look_medium-high-contrast.spi1d): linear 0.18 → ~0.395, linear 1.0 → ~0.83.
@@ -124,7 +165,59 @@ const COMPOSITE_BODY = /* wgsl */ `
124
165
  let intensity = viewU[1].w;
125
166
  let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
126
167
  let combined = straight + bloom;
127
- let exposed = combined * exp2(viewU[0].x);
168
+
169
+ // ── Depth of field ──
170
+ // Single-pass golden-angle gather over a polygonal (bladed) disk, in
171
+ // premultiplied HDR before tonemap. Near-field taps that see focused
172
+ // background are heavily down-weighted so a sharp subject doesn't bleed into
173
+ // a blurred foreground; the reverse leak (background bokeh over the subject
174
+ // edge) is damped less — that soft halo is what real lenses do. Scene layer
175
+ // only: the composited background (solid / 360 / effect) stays sharp, which
176
+ // is invisible while it sits at infinity behind a far-blurred stage.
177
+ var scenePm = vec4f(combined * alpha, alpha);
178
+ if (dofU[0].x > 0.5) {
179
+ let centerDepth = linearDepth(coord);
180
+ let centerCoc = circleOfConfusion(centerDepth);
181
+ let radius = abs(centerCoc);
182
+ if (radius > 0.35) {
183
+ let fullSzI = vec2<i32>(fullSz);
184
+ let sampleCount = clamp(dofU[1].z, 6.0, 24.0);
185
+ let blades = clamp(dofU[1].y, 3.0, 12.0);
186
+ let sector = 6.28318530718 / blades;
187
+ var accum = scenePm;
188
+ var weightSum = 1.0;
189
+ for (var i = 0u; i < 24u; i++) {
190
+ if (f32(i) >= sampleCount) { break; }
191
+ let fi = f32(i) + 0.5;
192
+ let ring = sqrt(fi / sampleCount);
193
+ let angle = fi * 2.39996323;
194
+ let localAngle = (fract((angle + 3.14159265359) / sector) - 0.5) * sector;
195
+ let polygonRadius = cos(3.14159265359 / blades) / max(cos(localAngle), 0.01);
196
+ var disk = vec2f(cos(angle), sin(angle)) * ring * polygonRadius;
197
+ disk.x *= max(dofU[1].w, 0.25);
198
+ let sp = coord + vec2<i32>(round(disk * radius));
199
+ let cp = clamp(sp, vec2<i32>(0), fullSzI - vec2<i32>(1));
200
+ let sampleDepth = linearDepth(cp);
201
+ let sampleCoc = circleOfConfusion(sampleDepth);
202
+ var w = 1.0;
203
+ if (centerCoc < 0.0 && sampleDepth > centerDepth + dofU[0].z) {
204
+ w *= 0.08;
205
+ } else if (centerCoc > 0.0 && sampleDepth > centerDepth + dofU[0].z * 2.0) {
206
+ w *= 0.35;
207
+ }
208
+ // A tap only contributes where its own blur circle reaches this pixel.
209
+ let sampleRadius = abs(sampleCoc);
210
+ w *= mix(0.2, 1.0, smoothstep(ring * radius - 1.0, ring * radius + 1.0, sampleRadius));
211
+ accum += sceneSample(cp, fullSzI, fullSz) * w;
212
+ weightSum += w;
213
+ }
214
+ scenePm = mix(scenePm, accum / max(weightSum, 1e-5), smoothstep(0.35, 1.75, radius));
215
+ }
216
+ }
217
+ let sceneAlpha = scenePm.a;
218
+ let sceneStraight = scenePm.rgb / max(sceneAlpha, 1e-6);
219
+
220
+ let exposed = sceneStraight * exp2(viewU[0].x);
128
221
  let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
129
222
  var disp = max(tm, vec3f(0.0));
130
223
  // Grade the SCENE only, before the display gamma. Deliberately not applied to
@@ -158,7 +251,7 @@ const COMPOSITE_BODY = /* wgsl */ `
158
251
  }
159
252
  BG_EFFECT_CALL
160
253
  }
161
- return vec4f(disp * alpha + bgPm * (1.0 - alpha), alpha + bgA * (1.0 - alpha));
254
+ return vec4f(disp * sceneAlpha + bgPm * (1.0 - sceneAlpha), sceneAlpha + bgA * (1.0 - sceneAlpha));
162
255
  }
163
256
  `
164
257
 
@@ -192,7 +285,9 @@ const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/
192
285
  * uniform control flow and forgo the gate. */
193
286
  function coverageGate(effect?: CompositeEffectSource | null): string {
194
287
  const gated = !effect || !USES_DERIVATIVES.test(effect.wgsl)
195
- return gated ? "&& alpha < 0.999" : ""
288
+ // sceneAlpha, not alpha: the bokeh gather spreads coverage, so a pixel the
289
+ // sharp scene fully covered can end up needing background behind its blur.
290
+ return gated ? "&& sceneAlpha < 0.999" : ""
196
291
  }
197
292
 
198
293
  export function buildCompositeShader(effect?: CompositeEffectSource | null): string {
@@ -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"}