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/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"
@@ -236,14 +235,17 @@ export type SunOptions = {
236
235
  direction?: Vec3
237
236
  }
238
237
 
239
- /** A background-effect param: number → f32, vector-like → vec3f (see
240
- * setBackgroundEffect). Structural {x,y,z} rather than the Vec3 class so
241
- * JSON-derived values (a shared scene document's params) pass straight in. */
242
- export type BackgroundEffectParamValue = number | { x: number; y: number; z: number }
243
- export type BackgroundEffectResult = {
238
+ /** An effect param: number → f32, vector-like → vec3f (see setEffect).
239
+ * Structural {x,y,z} rather than the Vec3 class so JSON-derived values (a
240
+ * shared scene document's params) pass straight in. */
241
+ export type EffectParamValue = number | { x: number; y: number; z: number }
242
+ export type EffectResult = {
244
243
  ok: boolean
245
244
  /** Compile/validation errors, line:col relative to the USER's WGSL. */
246
245
  diagnostics: string[]
246
+ /** Which mounts the WGSL declared — `fn background` / `fn foreground`. Both
247
+ * false only on a failed compile, since defining neither IS the failure. */
248
+ mounts: { background: boolean; foreground: boolean }
247
249
  }
248
250
 
249
251
  export type CameraOptions = {
@@ -276,6 +278,42 @@ export const DEFAULT_BLOOM_OPTIONS: BloomOptions = {
276
278
  clamp: 0.0,
277
279
  }
278
280
 
281
+ /** Camera depth of field — a bokeh gather in the composite pass. Costs nothing
282
+ * while disabled: the scene pass discards its depth buffer and the composite
283
+ * branch never runs. Enabled, the pass stores depth and the gather reads it. */
284
+ export type DepthOfFieldOptions = {
285
+ enabled: boolean
286
+ /** "auto" focuses the first visible character each frame — the camera-space
287
+ * depth span of its bones sets both distance and a floor on range — so a
288
+ * dancer stays sharp without anyone touching a slider. "manual" uses
289
+ * focusDistance/focusRange as given. */
290
+ focusMode: "auto" | "manual"
291
+ /** Camera-space distance to the focus plane (MMD units). */
292
+ focusDistance: number
293
+ /** Depth band that stays perfectly sharp, centered on focusDistance. In auto
294
+ * mode this is a minimum — the band never cuts into the subject. */
295
+ focusRange: number
296
+ /** Blur strength scale; 1 is a natural lens, higher is dreamier. */
297
+ aperture: number
298
+ /** Largest blur circle, in device pixels. */
299
+ maxBlurRadius: number
300
+ /** Bokeh polygon blade count (3–12); 6 is the classic hexagon. */
301
+ bladeCount: number
302
+ /** Gather tap count: 8 / 16 / 24. */
303
+ quality: "performance" | "balanced" | "cinematic"
304
+ }
305
+
306
+ export const DEFAULT_DEPTH_OF_FIELD_OPTIONS: DepthOfFieldOptions = {
307
+ enabled: false,
308
+ focusMode: "auto",
309
+ focusDistance: 25,
310
+ focusRange: 2,
311
+ aperture: 1,
312
+ maxBlurRadius: 18,
313
+ bladeCount: 6,
314
+ quality: "balanced",
315
+ }
316
+
279
317
  /** Blender Color Management / View (rendering.txt: Filmic, exposure, gamma). `look` is reserved for future curve tweaks. */
280
318
  export type ViewTransformOptions = {
281
319
  /** Stops applied before Filmic: `linear *= 2^exposure`. */
@@ -718,8 +756,6 @@ export class Engine {
718
756
  private multisampleMaskTexture!: GPUTexture
719
757
  private maskResolveTexture!: GPUTexture
720
758
  private maskResolveView!: GPUTextureView
721
- /** One-shot particle emissions (emitParticles); lazily built on first emit. */
722
- private particles: ParticleSystem | null = null
723
759
  private renderPassDescriptor!: GPURenderPassDescriptor
724
760
  private compositePassDescriptor!: GPURenderPassDescriptor
725
761
  // Two specialized composite pipelines via WGSL pipeline-override constants.
@@ -731,9 +767,18 @@ export class Engine {
731
767
  private morphComputeBindGroupLayout!: GPUBindGroupLayout
732
768
  private compositeBindGroupLayout!: GPUBindGroupLayout
733
769
  private compositeBindGroup!: GPUBindGroup
770
+ private depthOfField: DepthOfFieldOptions = { ...DEFAULT_DEPTH_OF_FIELD_OPTIONS }
771
+ private dofUniformBuffer!: GPUBuffer
772
+ private dofUniformData = new Float32Array(12)
773
+ private dofFocusScratch = new Vec3(0, 0, 0)
774
+ /** Depth-only view of the scene's MSAA depth buffer, read by the DoF gather. */
775
+ private depthReadView: GPUTextureView | null = null
734
776
  private compositeUniformBuffer!: GPUBuffer
735
777
  // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
736
- private readonly compositeUniformData = new Float32Array(40)
778
+ // 11 × vec4f — see the viewU comment in composite.ts. The last one is the
779
+ // camera's world position, which is what lets a foreground effect turn the
780
+ // depth it is handed into a PLACE (bgWorldPos) rather than a distance.
781
+ private readonly compositeUniformData = new Float32Array(44)
737
782
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
738
783
  private backgroundColor: Vec3 | null = null
739
784
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
@@ -741,21 +786,27 @@ export class Engine {
741
786
  private backdropEquirectView: GPUTextureView | null = null
742
787
  private fallbackEquirectTexture!: GPUTexture
743
788
  private fallbackEquirectView!: GPUTextureView
744
- // User WGSL background effect (background mode 3, setBackgroundEffect). The
745
- // composite pipelines are REBUILT with the user code injected; params live in
746
- // their own uniform buffer so setBackgroundEffectParam is a write, not a
789
+ // The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
790
+ // scene, over it, or both — whichever of background()/foreground() the code
791
+ // defines. The composite pipelines are REBUILT with the user code injected;
792
+ // params live in their own uniform buffer so setEffectParam is a write, not a
747
793
  // recompile (the same instant tier as setStyleParam).
748
- private backgroundEffect: {
794
+ private effect: {
749
795
  wgsl: string
750
796
  paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
751
797
  paramsBuffer: GPUBuffer | null
752
798
  paramsData: Float32Array<ArrayBuffer>
799
+ /** Mounted under the scene. */
800
+ hasBackground: boolean
801
+ /** Mounted over the finished frame — and the reason the scene pass has to
802
+ * STORE its depth, which it otherwise discards into tile memory. */
803
+ hasForeground: boolean
753
804
  } | null = null
754
805
  /** Bound at composite binding 7 when no effect (or a param-less one) is set. */
755
806
  private bgParamsDummyBuffer!: GPUBuffer
756
807
  private compositePipelineLayout!: GPUPipelineLayout
757
- /** time=0 origin for the active effect — reset each setBackgroundEffect. */
758
- private bgEffectEpochMs = 0
808
+ /** time=0 origin for the active effect — reset each setEffect. */
809
+ private effectEpochMs = 0
759
810
  private compositeBloomView: GPUTextureView | null = null
760
811
 
761
812
  // EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
@@ -1023,10 +1074,10 @@ export class Engine {
1023
1074
  u[8] = bg?.x ?? 0
1024
1075
  u[9] = bg?.y ?? 0
1025
1076
  u[10] = bg?.z ?? 0
1026
- // Base-layer mode; a user effect is a separate LAYER flagged at u[25] and
1027
- // over-composited onto whichever base is active.
1077
+ // Base-layer mode only. A user effect is a separate LAYER over whichever
1078
+ // base is active, and needs no flag of its own: the composite pipeline is
1079
+ // rebuilt per effect, so the compiled variant IS the flag. u[25] is spare.
1028
1080
  u[11] = this.backdropEquirectView ? 2 : bg ? 1 : 0
1029
- u[25] = this.backgroundEffect ? 1 : 0
1030
1081
  u[26] = this.canvas.width
1031
1082
  u[27] = this.canvas.height
1032
1083
  // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
@@ -1082,7 +1133,7 @@ export class Engine {
1082
1133
  }
1083
1134
 
1084
1135
  private rebuildCompositeBindGroup(): void {
1085
- if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView) return
1136
+ if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
1086
1137
  this.compositeBindGroup = this.device.createBindGroup({
1087
1138
  label: "composite bind group",
1088
1139
  layout: this.compositeBindGroupLayout,
@@ -1094,7 +1145,9 @@ export class Engine {
1094
1145
  { binding: 4, resource: this.maskResolveView },
1095
1146
  { binding: 5, resource: this.filmicLutView },
1096
1147
  { binding: 6, resource: this.backdropEquirectView ?? this.fallbackEquirectView },
1097
- { binding: 7, resource: { buffer: this.backgroundEffect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
1148
+ { binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
1149
+ { binding: 8, resource: this.depthReadView },
1150
+ { binding: 9, resource: { buffer: this.dofUniformBuffer } },
1098
1151
  ],
1099
1152
  })
1100
1153
  }
@@ -1159,45 +1212,72 @@ export class Engine {
1159
1212
  }
1160
1213
 
1161
1214
  /**
1162
- * Install a WGSL background effect (shadertoy-style) as a LAYER between the
1163
- * base background and the scene: rendered per-pixel in the composite pass and
1164
- * over-composited onto whichever base is active (solid color, 360 equirect,
1165
- * or transparency) — its alpha lets the base show through, so a starfield is
1166
- * stars over the user's background color. Display-space: never affects
1167
- * lighting, bloom, or tonemapping, and is captured by offline export like any
1168
- * background.
1169
- *
1170
- * `wgsl` must define:
1215
+ * Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
1216
+ * composite pass. ONE effect per scene, and the code says where it mounts by
1217
+ * which of these it defines — either, or both in one file:
1171
1218
  *
1172
1219
  * fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
1220
+ * fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
1173
1221
  *
1174
- * where `ray` is the pixel's normalized world-space view direction (LH, +Z
1175
- * forward — what the skybox samples by), `uv` is 0..1 bottom-left origin,
1176
- * `time` is seconds since apply, and `bgResolution()` gives the canvas size.
1177
- * Return sRGB + alpha. Declared `params` arrive as `params.<name>` (number →
1178
- * f32, Vec3 → vec3f) and are later tweaked without recompiling via
1179
- * setBackgroundEffectParam.
1222
+ * `background` is a LAYER between the base background and the scene,
1223
+ * over-composited onto whichever base is active (solid color, 360 equirect, or
1224
+ * transparency) — its alpha lets the base show through, so a starfield is
1225
+ * stars over the user's background color. `foreground` composites over the
1226
+ * finished frame instead, which is where rain, snow, petals and fog live, and
1227
+ * is handed `depth`: the camera-space distance in metres of whatever the scene
1228
+ * drew at that pixel (the far plane where it drew nothing). Compare a
1229
+ * particle's own distance against it and the model occludes it; fog just reads
1230
+ * it, since fog's alpha IS a function of distance.
1180
1231
  *
1181
- * Compiles off the hot path (async pipelines): on failure the previous
1182
- * background is KEPT and diagnostics are returned with line numbers relative
1183
- * to the user's WGSL. Pass null to remove the effect.
1232
+ * `ray` is the pixel's normalized world-space view direction (LH, +Z forward —
1233
+ * what the skybox samples by), `uv` is 0..1 bottom-left origin, `time` is
1234
+ * seconds since apply, and `bgResolution()` gives the canvas size. Return sRGB
1235
+ * + alpha; alpha is the only "how much does this replace" control there is.
1236
+ * Declared `params` arrive as `params.<name>` (number → f32, Vec3 → vec3f),
1237
+ * shared by both mounts, and are later tweaked without recompiling via
1238
+ * setEffectParam.
1239
+ *
1240
+ * Both mounts are display-space: neither affects lighting, bloom or
1241
+ * tonemapping, and both are captured by offline export. A foreground makes the
1242
+ * scene pass STORE its depth buffer (it otherwise discards it into tile
1243
+ * memory) for as long as one is installed.
1244
+ *
1245
+ * Compiles off the hot path (async pipelines): on failure the previous effect
1246
+ * is KEPT and diagnostics are returned with line numbers relative to the
1247
+ * user's WGSL. Pass null to remove the effect.
1184
1248
  */
1185
- async setBackgroundEffect(
1186
- wgsl: string | null,
1187
- params?: Record<string, BackgroundEffectParamValue>,
1188
- ): Promise<BackgroundEffectResult> {
1189
- if (!this.device) return { ok: false, diagnostics: ["setBackgroundEffect requires init() to have run"] }
1249
+ async setEffect(wgsl: string | null, params?: Record<string, EffectParamValue>): Promise<EffectResult> {
1250
+ const noMounts = { background: false, foreground: false }
1251
+ if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts }
1190
1252
 
1191
1253
  if (wgsl === null) {
1192
- this.backgroundEffect?.paramsBuffer?.destroy()
1193
- this.backgroundEffect = null
1254
+ this.effect?.paramsBuffer?.destroy()
1255
+ this.effect = null
1194
1256
  const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) })
1195
1257
  this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)")
1196
1258
  this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
1197
1259
  this.rebuildCompositeBindGroup()
1198
1260
  this.writeCompositeViewUniforms()
1199
- return { ok: true, diagnostics: [] }
1261
+ return { ok: true, diagnostics: [], mounts: noMounts }
1262
+ }
1263
+
1264
+ // ── Which mounts did the author ask for? A declaration, not a setting: the
1265
+ // entry points present in the source are the ones compiled in. Matching the
1266
+ // `fn` keyword is enough to be safe against a `foreground` LOCAL or a call
1267
+ // to one — those never follow `fn`.
1268
+ const hasBackground = /\bfn\s+background\s*\(/.test(wgsl)
1269
+ const hasForeground = /\bfn\s+foreground\s*\(/.test(wgsl)
1270
+ if (!hasBackground && !hasForeground) {
1271
+ return {
1272
+ ok: false,
1273
+ diagnostics: [
1274
+ "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f " +
1275
+ "or fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f (or both)",
1276
+ ],
1277
+ mounts: noMounts,
1278
+ }
1200
1279
  }
1280
+ const mounts = { background: hasBackground, foreground: hasForeground }
1201
1281
 
1202
1282
  // ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
1203
1283
  // Fields are emitted in declaration order; offsets follow WGSL's natural
@@ -1209,7 +1289,7 @@ export class Engine {
1209
1289
  let cursor = 0
1210
1290
  for (const [name, value] of entries) {
1211
1291
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
1212
- return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`] }
1292
+ return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts }
1213
1293
  }
1214
1294
  const isVec = typeof value !== "number"
1215
1295
  const align = isVec ? 16 : 4
@@ -1229,22 +1309,22 @@ export class Engine {
1229
1309
  }
1230
1310
  }
1231
1311
  const paramsDecl = entries.length
1232
- ? `struct BgParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: BgParams;\n`
1312
+ ? `struct EffectParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: EffectParams;\n`
1233
1313
  : ""
1234
1314
 
1235
1315
  // ── Compile with validation captured, not thrown at the console. Line
1236
1316
  // numbers in diagnostics are rebased to the USER's source.
1237
- const source = buildCompositeShader({ wgsl, paramsDecl })
1317
+ const source = buildCompositeShader({ wgsl, paramsDecl, hasBackground, hasForeground })
1238
1318
  const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1
1239
1319
  this.device.pushErrorScope("validation")
1240
- const module = this.device.createShaderModule({ label: "composite shader (bg effect)", code: source })
1320
+ const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
1241
1321
  const info = await module.getCompilationInfo()
1242
1322
  const scopeErr = await this.device.popErrorScope()
1243
1323
  const diagnostics = info.messages
1244
1324
  .filter((m) => m.type === "error")
1245
1325
  .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
1246
1326
  if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
1247
- if (diagnostics.length > 0) return { ok: false, diagnostics }
1327
+ if (diagnostics.length > 0) return { ok: false, diagnostics, mounts }
1248
1328
  let identity: GPURenderPipeline
1249
1329
  let gamma: GPURenderPipeline
1250
1330
  try {
@@ -1262,37 +1342,42 @@ export class Engine {
1262
1342
  primitive: { topology: "triangle-list" },
1263
1343
  })
1264
1344
  ;[identity, gamma] = await Promise.all([
1265
- make(false, "composite pipeline (bg effect, gamma=1)"),
1266
- make(true, "composite pipeline (bg effect, gamma!=1)"),
1345
+ make(false, "composite pipeline (effect, gamma=1)"),
1346
+ make(true, "composite pipeline (effect, gamma!=1)"),
1267
1347
  ])
1268
1348
  } catch (e) {
1269
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
1349
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
1270
1350
  }
1271
1351
 
1272
1352
  // ── Swap — only now does the old effect (and its params buffer) go away.
1273
- this.backgroundEffect?.paramsBuffer?.destroy()
1353
+ this.effect?.paramsBuffer?.destroy()
1274
1354
  let paramsBuffer: GPUBuffer | null = null
1275
1355
  if (entries.length) {
1276
1356
  paramsBuffer = this.device.createBuffer({
1277
- label: "bg effect params",
1357
+ label: "effect params",
1278
1358
  size: paramsData.byteLength,
1279
1359
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1280
1360
  })
1281
1361
  this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
1282
1362
  }
1283
- this.backgroundEffect = { wgsl, paramLayout: layout, paramsBuffer, paramsData }
1363
+ this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground }
1284
1364
  this.compositePipelineIdentity = identity
1285
1365
  this.compositePipelineGamma = gamma
1286
- this.bgEffectEpochMs = performance.now()
1366
+ this.effectEpochMs = performance.now()
1287
1367
  this.rebuildCompositeBindGroup()
1288
1368
  this.writeCompositeViewUniforms()
1289
- return { ok: true, diagnostics: [] }
1369
+ return { ok: true, diagnostics: [], mounts }
1370
+ }
1371
+
1372
+ /** Which mounts the installed effect declared. Both false when none is set. */
1373
+ getEffectMounts(): { background: boolean; foreground: boolean } {
1374
+ return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false }
1290
1375
  }
1291
1376
 
1292
- /** Write one background-effect param (declared at setBackgroundEffect) — a
1293
- * uniform write, no recompile; the instant tier, like setStyleParam. */
1294
- setBackgroundEffectParam(name: string, value: BackgroundEffectParamValue): void {
1295
- const fx = this.backgroundEffect
1377
+ /** Write one effect param (declared at setEffect) — a uniform write, no
1378
+ * recompile; the instant tier, like setStyleParam. */
1379
+ setEffectParam(name: string, value: EffectParamValue): void {
1380
+ const fx = this.effect
1296
1381
  if (!fx || !fx.paramsBuffer) return
1297
1382
  const slot = fx.paramLayout.get(name)
1298
1383
  if (!slot) return
@@ -1306,6 +1391,89 @@ export class Engine {
1306
1391
  }
1307
1392
 
1308
1393
  /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
1394
+ /** Camera depth of field (see DepthOfFieldOptions). Free while disabled —
1395
+ * the scene pass only stores its depth buffer on frames the gather reads. */
1396
+ setDepthOfField(patch: Partial<DepthOfFieldOptions>): void {
1397
+ this.depthOfField = { ...this.depthOfField, ...patch }
1398
+ if (!this.device || !this.dofUniformBuffer) return
1399
+ if (this.depthOfField.enabled) {
1400
+ this.writeDepthOfFieldUniforms()
1401
+ } else {
1402
+ // One last write so the shader's uniform branch reads a clean zero.
1403
+ this.dofUniformData[0] = 0
1404
+ this.device.queue.writeBuffer(this.dofUniformBuffer, 0, this.dofUniformData)
1405
+ }
1406
+ }
1407
+
1408
+ getDepthOfField(): DepthOfFieldOptions {
1409
+ return { ...this.depthOfField }
1410
+ }
1411
+
1412
+ /** Auto-focus target: the camera-space depth span of the first visible
1413
+ * character's bones. Focus sits at the span's midpoint; the range covers the
1414
+ * span plus a margin for what bones don't reach (shoes, hair, cloth). */
1415
+ private getModelBodyFocus(): { distance: number; range: number } | null {
1416
+ if (!this.camera) return null
1417
+ const view = this.camera.getViewMatrix().values
1418
+ for (const inst of this.modelInstances.values()) {
1419
+ if (!inst.model.visible || inst.isStage) continue
1420
+ const model = inst.model
1421
+ const matrices = model.getWorldMatrices()
1422
+ if (matrices.length === 0) continue
1423
+ const scale = model.scale
1424
+ const local = this.dofFocusScratch
1425
+ let minDepth = Infinity
1426
+ let maxDepth = -Infinity
1427
+ for (const matrix of matrices) {
1428
+ const values = matrix.values
1429
+ // Bone matrices are model-space; apply the same root transform the
1430
+ // renderer bakes into skinning, then take camera-space z.
1431
+ local.setXYZ(values[12] * scale, values[13] * scale, values[14] * scale)
1432
+ Quat.rotateVecInto(model.rotation, local, local)
1433
+ const x = model.position.x + local.x
1434
+ const y = model.position.y + local.y
1435
+ const z = model.position.z + local.z
1436
+ const depth = view[2] * x + view[6] * y + view[10] * z + view[14]
1437
+ if (!Number.isFinite(depth) || depth <= this.camera.near) continue
1438
+ minDepth = Math.min(minDepth, depth)
1439
+ maxDepth = Math.max(maxDepth, depth)
1440
+ }
1441
+ if (Number.isFinite(minDepth) && Number.isFinite(maxDepth)) {
1442
+ const span = Math.max(0, maxDepth - minDepth)
1443
+ const meshMargin = Math.max(2.0, span * 0.15)
1444
+ return { distance: (minDepth + maxDepth) * 0.5, range: Math.max(2.0, span + meshMargin) }
1445
+ }
1446
+ }
1447
+ return null
1448
+ }
1449
+
1450
+ private writeDepthOfFieldUniforms(): void {
1451
+ if (!this.device || !this.dofUniformBuffer) return
1452
+ const d = this.depthOfField
1453
+ const u = this.dofUniformData
1454
+ // `d.enabled &&`, because a foreground effect also drives this write (for
1455
+ // projA/projB alone) and auto-focus walks every visible character's bones —
1456
+ // work nothing would read with the gather switched off.
1457
+ const auto = d.enabled && d.focusMode === "auto" ? this.getModelBodyFocus() : null
1458
+ u[0] = d.enabled ? 1 : 0
1459
+ u[1] = auto?.distance ?? Math.max(d.focusDistance, 0.05)
1460
+ // In auto mode the authored range is a floor — the sharp band never cuts
1461
+ // into the character's own depth span.
1462
+ u[2] = Math.max(d.focusRange, auto?.range ?? 0.02, 0.02)
1463
+ u[3] = Math.max(d.aperture, 0)
1464
+ u[4] = Math.max(d.maxBlurRadius, 0)
1465
+ u[5] = Math.min(12, Math.max(3, Math.round(d.bladeCount)))
1466
+ u[6] = d.quality === "performance" ? 8 : d.quality === "cinematic" ? 24 : 16
1467
+ u[7] = 1 // anamorphic ratio, reserved (the shader clamps ≥ 0.25)
1468
+ // viewZ = projB / (z − projA), the inverse of perspectiveInto's z mapping.
1469
+ // near/far track the camera radius, so these refresh every enabled frame.
1470
+ const near = this.camera.near
1471
+ const far = this.camera.far
1472
+ u[8] = (far + near) / (far - near)
1473
+ u[9] = (-2 * near * far) / (far - near)
1474
+ this.device.queue.writeBuffer(this.dofUniformBuffer, 0, u)
1475
+ }
1476
+
1309
1477
  setBloomOptions(patch: Partial<BloomOptions>): void {
1310
1478
  const b = this.bloomSettings
1311
1479
  if (patch.enabled !== undefined) b.enabled = patch.enabled
@@ -2165,11 +2333,18 @@ export class Engine {
2165
2333
  // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
2166
2334
  this.compositeUniformBuffer = this.device.createBuffer({
2167
2335
  label: "composite view uniforms",
2168
- // 10 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
2336
+ // 11 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
2169
2337
  // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
2170
- // (time, _, canvas width, canvas height) for user background effects ·
2171
- // three grade vectors (CDL offset+contrast, power+saturation, slope+flag).
2172
- size: 160,
2338
+ // (time, _, canvas width, canvas height) for user effects · three grade
2339
+ // vectors (CDL offset+contrast, power+saturation, slope+flag) · camera
2340
+ // world position, for an effect placing itself in the scene.
2341
+ size: 176,
2342
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2343
+ })
2344
+ this.dofUniformBuffer = this.device.createBuffer({
2345
+ label: "depth of field uniforms",
2346
+ // 3 × vec4f — see the dofU comment in composite.ts.
2347
+ size: 48,
2173
2348
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2174
2349
  })
2175
2350
  this.bgParamsDummyBuffer = this.device.createBuffer({
@@ -2194,6 +2369,15 @@ export class Engine {
2194
2369
  // User background-effect params — dummy buffer when no effect is set. The
2195
2370
  // layout is explicit, so the base shader legally ignores the binding.
2196
2371
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2372
+ // The scene pass's MSAA depth, depth-only aspect — read by the DoF
2373
+ // gather. Contents are undefined while DoF is off (the scene pass
2374
+ // discards depth then), and the shader never reads it then either.
2375
+ {
2376
+ binding: 8,
2377
+ visibility: GPUShaderStage.FRAGMENT,
2378
+ texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
2379
+ },
2380
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2197
2381
  ],
2198
2382
  })
2199
2383
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -2439,10 +2623,13 @@ export class Engine {
2439
2623
  size: [width, height],
2440
2624
  sampleCount: Engine.MULTISAMPLE_COUNT,
2441
2625
  format: "depth24plus-stencil8",
2442
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
2626
+ // TEXTURE_BINDING for the DoF gather — a usage flag, not a copy; the
2627
+ // zero-cost-when-off story lives in depthStoreOp, not here.
2628
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
2443
2629
  })
2444
2630
 
2445
2631
  const depthTextureView = this.depthTexture.createView()
2632
+ this.depthReadView = this.depthTexture.createView({ aspect: "depth-only" })
2446
2633
 
2447
2634
  // storeOp="discard" on MSAA views keeps per-sample data in Apple TBDR tile memory —
2448
2635
  // only the resolveTarget (hdrResolveTexture / maskResolveView) gets written to RAM.
@@ -2871,6 +3058,13 @@ export class Engine {
2871
3058
  return this.cameraAnimation !== null
2872
3059
  }
2873
3060
 
3061
+ /** Seconds the loaded camera VMD runs for — its last keyframe — or 0 with none
3062
+ * loaded. A timeline cannot draw a lane to scale without it, and the camera's
3063
+ * length is its own: it does not have to match any model's clip. */
3064
+ getCameraVmdDuration(): number {
3065
+ return this.cameraAnimation?.duration ?? 0
3066
+ }
3067
+
2874
3068
  /** Drop the loaded camera VMD and return to orbit control. */
2875
3069
  clearCameraVmd(): void {
2876
3070
  this.cameraAnimation = null
@@ -2923,24 +3117,6 @@ export class Engine {
2923
3117
  this.camera.fov = fov
2924
3118
  }
2925
3119
 
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
3120
  // Step 5: Create lighting buffers
2945
3121
  private setupLighting() {
2946
3122
  this.lightUniformBuffer = this.device.createBuffer({
@@ -3096,8 +3272,6 @@ export class Engine {
3096
3272
  this.forEachInstance((inst) => inst.model.stop())
3097
3273
  if (Engine.instance === this) Engine.instance = null
3098
3274
  if (this.camera) this.camera.detachControl()
3099
- this.particles?.dispose()
3100
- this.particles = null
3101
3275
 
3102
3276
  // Remove raycasting event listeners
3103
3277
  if (this.onRaycast) {
@@ -5093,6 +5267,22 @@ export class Engine {
5093
5267
  this.updateCameraUniforms()
5094
5268
  this.updateShadowLightVP()
5095
5269
 
5270
+ // Depth of field's entire disabled cost is this branch: depth stays in
5271
+ // TBDR tile memory (discard) unless something in the composite reads it this
5272
+ // frame. Two things can — the DoF gather, and the depth handed to a
5273
+ // foreground effect — and either one makes the pass store it.
5274
+ //
5275
+ // The uniform refresh is shared for the same reason: linearDepth() inverts
5276
+ // the z-buffer with projA/projB out of dofU[2], which track the camera's
5277
+ // near/far and so must be rewritten every frame either reader is live. A
5278
+ // foreground with a stale pair would read metres from the wrong frustum. The
5279
+ // write leaves dofU[0].x at 0 while DoF is off, so refreshing it does not
5280
+ // switch the gather on.
5281
+ const dofOn = this.depthOfField.enabled
5282
+ const depthRead = dofOn || (this.effect?.hasForeground ?? false)
5283
+ this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = depthRead ? "store" : "discard"
5284
+ if (depthRead) this.writeDepthOfFieldUniforms()
5285
+
5096
5286
  const encoder = this.device.createCommandEncoder()
5097
5287
 
5098
5288
  // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
@@ -5139,9 +5329,6 @@ export class Engine {
5139
5329
  this.forEachInstance((inst) => {
5140
5330
  if (inst.model.visible) this.renderModelTransparentPhase(pass, inst)
5141
5331
  })
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
5332
  pass.end()
5146
5333
 
5147
5334
  // Bloom pyramid (EEVEE 3.6):
@@ -5757,7 +5944,7 @@ export class Engine {
5757
5944
  // is LEFT-HANDED (+Z forward, see Mat4.lookAtInto), so the world-space
5758
5945
  // right/up/FORWARD vectors are rows 0/1/2 of its rotation block directly
5759
5946
  // (column-major storage: row i = values[i], values[i+4], values[i+8]).
5760
- if ((this.backdropEquirectView || this.backgroundEffect) && this.compositeUniformBuffer) {
5947
+ if ((this.backdropEquirectView || this.effect) && this.compositeUniformBuffer) {
5761
5948
  const v = viewMatrix.values
5762
5949
  const u = this.compositeUniformData
5763
5950
  const tanHalf = Math.tan((this.camera.fov ?? Math.PI / 4) / 2)
@@ -5775,9 +5962,15 @@ export class Engine {
5775
5962
  u[22] = v[10]
5776
5963
  u[23] = 0
5777
5964
  // Effect clock + canvas size (viewU[6]) — written on the same refresh.
5778
- u[24] = (performance.now() - this.bgEffectEpochMs) / 1000
5965
+ u[24] = (performance.now() - this.effectEpochMs) / 1000
5779
5966
  u[26] = this.canvas.width
5780
5967
  u[27] = this.canvas.height
5968
+ // Camera world position (viewU[10]) — the other half of bgWorldPos. It
5969
+ // rides this refresh rather than writeCompositeViewUniforms because it
5970
+ // changes every frame the camera does, exactly like the basis above.
5971
+ u[40] = cameraPos.x
5972
+ u[41] = cameraPos.y
5973
+ u[42] = cameraPos.z
5781
5974
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
5782
5975
  }
5783
5976
  }
@@ -146,6 +146,17 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
146
146
  contextOutputs: { color: "material.diffuseColor" },
147
147
  },
148
148
 
149
+ // The PMX material's sphere map, which is where an MMD model keeps its
150
+ // highlights — every PMX ships one, and hair without it reads flat. The mode
151
+ // is the material's own (.sph multiplies the shaded base, .spa adds a
152
+ // highlight), so a graph asks for the effect and the model decides which; a
153
+ // material with no sphere texture is an exact no-op.
154
+ sphere_map: {
155
+ inputs: { base: C([0, 0, 0], true), strength: F(1) },
156
+ outputs: { color: "color" },
157
+ emit: (a) => `pmx_sphere_map(${a.base}, ${a.strength}, n)`,
158
+ },
159
+
149
160
  // ── Literals as nodes (for editor ergonomics; inlined literals work too) ──
150
161
  value: { inputs: { value: F(0) }, outputs: { value: "float" }, emit: (a) => a.value },
151
162
  rgb: { inputs: { color: C() }, outputs: { color: "color" }, emit: (a) => a.color },
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,
@@ -15,8 +17,8 @@ export {
15
17
  type GizmoDragEvent,
16
18
  type GizmoDragCallback,
17
19
  type GizmoDragKind,
18
- type BackgroundEffectParamValue,
19
- type BackgroundEffectResult,
20
+ type EffectParamValue,
21
+ type EffectResult,
20
22
  } from "./engine"
21
23
  export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload"
22
24
  export {
@@ -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"