reze-engine 0.40.0 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +8 -5
  2. package/dist/engine.d.ts +87 -31
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +264 -77
  5. package/dist/graph/presets/body.js +2 -2
  6. package/dist/graph/presets/cloth_rough.js +2 -2
  7. package/dist/graph/presets/cloth_smooth.js +2 -2
  8. package/dist/graph/presets/default.js +2 -2
  9. package/dist/graph/presets/eye.js +2 -2
  10. package/dist/graph/presets/face.js +2 -2
  11. package/dist/graph/presets/hair.js +2 -2
  12. package/dist/graph/presets/metal.js +2 -2
  13. package/dist/graph/presets/stockings.js +3 -3
  14. package/dist/graph/registry.d.ts.map +1 -1
  15. package/dist/graph/registry.js +436 -10
  16. package/dist/graph/style-group.d.ts +46 -0
  17. package/dist/graph/style-group.d.ts.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/shaders/agx-lut.d.ts +11 -0
  21. package/dist/shaders/agx-lut.d.ts.map +1 -0
  22. package/dist/shaders/agx-lut.js +29 -0
  23. package/dist/shaders/materials/common.d.ts +1 -1
  24. package/dist/shaders/materials/common.d.ts.map +1 -1
  25. package/dist/shaders/materials/common.js +10 -0
  26. package/dist/shaders/materials/nodes.d.ts +1 -1
  27. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  28. package/dist/shaders/materials/nodes.js +957 -555
  29. package/dist/shaders/passes/composite.d.ts +27 -12
  30. package/dist/shaders/passes/composite.d.ts.map +1 -1
  31. package/dist/shaders/passes/composite.js +155 -48
  32. package/package.json +1 -1
  33. package/src/engine.ts +304 -84
  34. package/src/graph/presets/body.ts +2 -2
  35. package/src/graph/presets/cloth_rough.ts +2 -2
  36. package/src/graph/presets/cloth_smooth.ts +2 -2
  37. package/src/graph/presets/default.ts +2 -2
  38. package/src/graph/presets/eye.ts +2 -2
  39. package/src/graph/presets/face.ts +2 -2
  40. package/src/graph/presets/hair.ts +2 -2
  41. package/src/graph/presets/metal.ts +2 -2
  42. package/src/graph/presets/stockings.ts +3 -3
  43. package/src/graph/registry.ts +450 -10
  44. package/src/graph/style-group.ts +44 -0
  45. package/src/index.ts +4 -2
  46. package/src/shaders/agx-lut.ts +30 -0
  47. package/src/shaders/materials/common.ts +10 -0
  48. package/src/shaders/materials/nodes.ts +962 -560
  49. package/src/shaders/passes/composite.ts +182 -64
package/src/engine.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  BLOOM_DOWNSAMPLE_SHADER_WGSL,
32
32
  BLOOM_UPSAMPLE_SHADER_WGSL,
33
33
  } from "./shaders/passes/bloom"
34
+ import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut"
34
35
  import { buildCompositeShader } from "./shaders/passes/composite"
35
36
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
36
37
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
@@ -84,7 +85,17 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
84
85
  "face",
85
86
  ["顔", "颜", "顏", "脸", "臉", "かお", "face", "舌", "tongue", "牙", "牙齿", "齿", "歯", "teeth", "tooth", "口腔", "口内", "mouth", "嘴", "唇", "歯茎", "gums"],
86
87
  ],
87
- ["hair", ["前髪", "後髪", "髪", "髮", "头发", "頭髪", "もみあげ", "アホ毛", "ヘア", "hair", "ahoge", "bang"]],
88
+ // Simplified 发 is listed as compounds, never bare: it also writes 发光 (glow),
89
+ // and hair carries a renderClass, so a chance hit puts an emissive panel in the
90
+ // hair pass. Same reasoning as bare 口 being omitted from face above.
91
+ [
92
+ "hair",
93
+ [
94
+ "前髪", "後髪", "髪", "髮", "頭髪", "もみあげ", "アホ毛", "ヘア",
95
+ "头发", "前发", "后发", "长发", "短发", "发丝", "刘海", "辫", "马尾",
96
+ "hair", "ahoge", "bang",
97
+ ],
98
+ ],
88
99
  ["body", ["肌", "皮肤", "skin"]],
89
100
  ["metal", ["金属", "メタル", "metal", "earring", "耳环", "耳環"]],
90
101
  [
@@ -187,6 +198,9 @@ type GroupInstall = {
187
198
  /** hair render-class only: the stencil-matched IS_OVER_EYES=true variant. */
188
199
  overEyesPipeline?: GPURenderPipeline
189
200
  uniformBuffer: GPUBuffer
201
+ /** The group's own image maps, uploaded once per apply and owned here — the
202
+ * install destroys them when it is replaced, so a re-apply cannot leak. */
203
+ images?: (GPUTexture | null)[]
190
204
  slotMap: StyleSlot[]
191
205
  /** Serialized (graph + renderClass + alphaMode) — lets applyStyleGroups skip recompiling
192
206
  * an unchanged group. */
@@ -235,14 +249,17 @@ export type SunOptions = {
235
249
  direction?: Vec3
236
250
  }
237
251
 
238
- /** A background-effect param: number → f32, vector-like → vec3f (see
239
- * setBackgroundEffect). Structural {x,y,z} rather than the Vec3 class so
240
- * JSON-derived values (a shared scene document's params) pass straight in. */
241
- export type BackgroundEffectParamValue = number | { x: number; y: number; z: number }
242
- export type BackgroundEffectResult = {
252
+ /** An effect param: number → f32, vector-like → vec3f (see setEffect).
253
+ * Structural {x,y,z} rather than the Vec3 class so JSON-derived values (a
254
+ * shared scene document's params) pass straight in. */
255
+ export type EffectParamValue = number | { x: number; y: number; z: number }
256
+ export type EffectResult = {
243
257
  ok: boolean
244
258
  /** Compile/validation errors, line:col relative to the USER's WGSL. */
245
259
  diagnostics: string[]
260
+ /** Which mounts the WGSL declared — `fn background` / `fn foreground`. Both
261
+ * false only on a failed compile, since defining neither IS the failure. */
262
+ mounts: { background: boolean; foreground: boolean }
246
263
  }
247
264
 
248
265
  export type CameraOptions = {
@@ -317,7 +334,17 @@ export type ViewTransformOptions = {
317
334
  exposure: number
318
335
  /** After Filmic, display gamma (`pow(rgb, 1/gamma)`). */
319
336
  gamma: number
320
- look: "default" | "medium_high_contrast"
337
+ /**
338
+ * Which display transform the frame is formed with.
339
+ *
340
+ * "standard" is Blender's Standard: the sRGB encoding and nothing else, which
341
+ * is what NPR and anime work uses — the colours the graph computes are the
342
+ * colours that land, with no film curve reinterpreting them. Both of the
343
+ * reference Wuthering Waves projects render this way.
344
+ *
345
+ * "filmic" is Blender 3.6's Filmic, Medium High Contrast, baked as a LUT.
346
+ */
347
+ transform: "agx" | "filmic" | "standard"
321
348
  }
322
349
 
323
350
  // Matches the reference Blender project: Filmic view, Medium High Contrast look,
@@ -325,7 +352,7 @@ export type ViewTransformOptions = {
325
352
  export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
326
353
  exposure: 0.6,
327
354
  gamma: 1.0,
328
- look: "medium_high_contrast",
355
+ transform: "filmic",
329
356
  }
330
357
 
331
358
  /** Color grading applied to the tonemapped scene (ASC CDL — see grade() in
@@ -772,7 +799,10 @@ export class Engine {
772
799
  private depthReadView: GPUTextureView | null = null
773
800
  private compositeUniformBuffer!: GPUBuffer
774
801
  // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
775
- private readonly compositeUniformData = new Float32Array(40)
802
+ // 11 × vec4f — see the viewU comment in composite.ts. The last one is the
803
+ // camera's world position, which is what lets a foreground effect turn the
804
+ // depth it is handed into a PLACE (bgWorldPos) rather than a distance.
805
+ private readonly compositeUniformData = new Float32Array(44)
776
806
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
777
807
  private backgroundColor: Vec3 | null = null
778
808
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
@@ -780,21 +810,29 @@ export class Engine {
780
810
  private backdropEquirectView: GPUTextureView | null = null
781
811
  private fallbackEquirectTexture!: GPUTexture
782
812
  private fallbackEquirectView!: GPUTextureView
783
- // User WGSL background effect (background mode 3, setBackgroundEffect). The
784
- // composite pipelines are REBUILT with the user code injected; params live in
785
- // their own uniform buffer so setBackgroundEffectParam is a write, not a
813
+ // The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
814
+ // scene, over it, or both — whichever of background()/foreground() the code
815
+ // defines. The composite pipelines are REBUILT with the user code injected;
816
+ // params live in their own uniform buffer so setEffectParam is a write, not a
786
817
  // recompile (the same instant tier as setStyleParam).
787
- private backgroundEffect: {
818
+ private effect: {
788
819
  wgsl: string
789
820
  paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
790
821
  paramsBuffer: GPUBuffer | null
791
822
  paramsData: Float32Array<ArrayBuffer>
823
+ /** Mounted under the scene. */
824
+ hasBackground: boolean
825
+ /** Mounted over the finished frame — and the reason the scene pass has to
826
+ * STORE its depth, which it otherwise discards into tile memory. */
827
+ hasForeground: boolean
792
828
  } | null = null
829
+ private agxLutTexture: GPUTexture | null = null
830
+ private agxFallbackTexture!: GPUTexture
793
831
  /** Bound at composite binding 7 when no effect (or a param-less one) is set. */
794
832
  private bgParamsDummyBuffer!: GPUBuffer
795
833
  private compositePipelineLayout!: GPUPipelineLayout
796
- /** time=0 origin for the active effect — reset each setBackgroundEffect. */
797
- private bgEffectEpochMs = 0
834
+ /** time=0 origin for the active effect — reset each setEffect. */
835
+ private effectEpochMs = 0
798
836
  private compositeBloomView: GPUTextureView | null = null
799
837
 
800
838
  // EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
@@ -969,7 +1007,7 @@ export class Engine {
969
1007
  return {
970
1008
  exposure: partial?.exposure ?? d.exposure,
971
1009
  gamma: partial?.gamma ?? d.gamma,
972
- look: partial?.look ?? d.look,
1010
+ transform: partial?.transform ?? d.transform,
973
1011
  }
974
1012
  }
975
1013
 
@@ -989,7 +1027,7 @@ export class Engine {
989
1027
 
990
1028
  getViewTransformOptions(): ViewTransformOptions {
991
1029
  const v = this.viewTransform
992
- return { exposure: v.exposure, gamma: v.gamma, look: v.look }
1030
+ return { exposure: v.exposure, gamma: v.gamma, transform: v.transform }
993
1031
  }
994
1032
 
995
1033
  private colorGrading: ColorGradingOptions = {
@@ -1032,7 +1070,7 @@ export class Engine {
1032
1070
  const v = this.viewTransform
1033
1071
  if (patch.exposure !== undefined) v.exposure = patch.exposure
1034
1072
  if (patch.gamma !== undefined) v.gamma = patch.gamma
1035
- if (patch.look !== undefined) v.look = patch.look
1073
+ if (patch.transform !== undefined) v.transform = patch.transform
1036
1074
  if (this.device && this.compositeUniformBuffer) {
1037
1075
  this.writeCompositeViewUniforms()
1038
1076
  }
@@ -1062,10 +1100,12 @@ export class Engine {
1062
1100
  u[8] = bg?.x ?? 0
1063
1101
  u[9] = bg?.y ?? 0
1064
1102
  u[10] = bg?.z ?? 0
1065
- // Base-layer mode; a user effect is a separate LAYER flagged at u[25] and
1066
- // over-composited onto whichever base is active.
1103
+ // Base-layer mode only. A user effect is a separate LAYER over whichever
1104
+ // base is active, and needs no flag of its own: the composite pipeline is
1105
+ // rebuilt per effect, so the compiled variant IS the flag.
1067
1106
  u[11] = this.backdropEquirectView ? 2 : bg ? 1 : 0
1068
- u[25] = this.backgroundEffect ? 1 : 0
1107
+ // Which display transform forms the frame (see viewTransform in composite.ts).
1108
+ u[25] = v.transform === "agx" ? 2 : v.transform === "standard" ? 1 : 0
1069
1109
  u[26] = this.canvas.width
1070
1110
  u[27] = this.canvas.height
1071
1111
  // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
@@ -1133,9 +1173,10 @@ export class Engine {
1133
1173
  { binding: 4, resource: this.maskResolveView },
1134
1174
  { binding: 5, resource: this.filmicLutView },
1135
1175
  { binding: 6, resource: this.backdropEquirectView ?? this.fallbackEquirectView },
1136
- { binding: 7, resource: { buffer: this.backgroundEffect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
1176
+ { binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
1137
1177
  { binding: 8, resource: this.depthReadView },
1138
1178
  { binding: 9, resource: { buffer: this.dofUniformBuffer } },
1179
+ { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
1139
1180
  ],
1140
1181
  })
1141
1182
  }
@@ -1200,45 +1241,72 @@ export class Engine {
1200
1241
  }
1201
1242
 
1202
1243
  /**
1203
- * Install a WGSL background effect (shadertoy-style) as a LAYER between the
1204
- * base background and the scene: rendered per-pixel in the composite pass and
1205
- * over-composited onto whichever base is active (solid color, 360 equirect,
1206
- * or transparency) — its alpha lets the base show through, so a starfield is
1207
- * stars over the user's background color. Display-space: never affects
1208
- * lighting, bloom, or tonemapping, and is captured by offline export like any
1209
- * background.
1210
- *
1211
- * `wgsl` must define:
1244
+ * Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
1245
+ * composite pass. ONE effect per scene, and the code says where it mounts by
1246
+ * which of these it defines — either, or both in one file:
1212
1247
  *
1213
1248
  * fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
1249
+ * fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
1250
+ *
1251
+ * `background` is a LAYER between the base background and the scene,
1252
+ * over-composited onto whichever base is active (solid color, 360 equirect, or
1253
+ * transparency) — its alpha lets the base show through, so a starfield is
1254
+ * stars over the user's background color. `foreground` composites over the
1255
+ * finished frame instead, which is where rain, snow, petals and fog live, and
1256
+ * is handed `depth`: the camera-space distance in metres of whatever the scene
1257
+ * drew at that pixel (the far plane where it drew nothing). Compare a
1258
+ * particle's own distance against it and the model occludes it; fog just reads
1259
+ * it, since fog's alpha IS a function of distance.
1214
1260
  *
1215
- * where `ray` is the pixel's normalized world-space view direction (LH, +Z
1216
- * forward — what the skybox samples by), `uv` is 0..1 bottom-left origin,
1217
- * `time` is seconds since apply, and `bgResolution()` gives the canvas size.
1218
- * Return sRGB + alpha. Declared `params` arrive as `params.<name>` (number →
1219
- * f32, Vec3 → vec3f) and are later tweaked without recompiling via
1220
- * setBackgroundEffectParam.
1261
+ * `ray` is the pixel's normalized world-space view direction (LH, +Z forward —
1262
+ * what the skybox samples by), `uv` is 0..1 bottom-left origin, `time` is
1263
+ * seconds since apply, and `bgResolution()` gives the canvas size. Return sRGB
1264
+ * + alpha; alpha is the only "how much does this replace" control there is.
1265
+ * Declared `params` arrive as `params.<name>` (number → f32, Vec3 → vec3f),
1266
+ * shared by both mounts, and are later tweaked without recompiling via
1267
+ * setEffectParam.
1221
1268
  *
1222
- * Compiles off the hot path (async pipelines): on failure the previous
1223
- * background is KEPT and diagnostics are returned with line numbers relative
1224
- * to the user's WGSL. Pass null to remove the effect.
1269
+ * Both mounts are display-space: neither affects lighting, bloom or
1270
+ * tonemapping, and both are captured by offline export. A foreground makes the
1271
+ * scene pass STORE its depth buffer (it otherwise discards it into tile
1272
+ * memory) for as long as one is installed.
1273
+ *
1274
+ * Compiles off the hot path (async pipelines): on failure the previous effect
1275
+ * is KEPT and diagnostics are returned with line numbers relative to the
1276
+ * user's WGSL. Pass null to remove the effect.
1225
1277
  */
1226
- async setBackgroundEffect(
1227
- wgsl: string | null,
1228
- params?: Record<string, BackgroundEffectParamValue>,
1229
- ): Promise<BackgroundEffectResult> {
1230
- if (!this.device) return { ok: false, diagnostics: ["setBackgroundEffect requires init() to have run"] }
1278
+ async setEffect(wgsl: string | null, params?: Record<string, EffectParamValue>): Promise<EffectResult> {
1279
+ const noMounts = { background: false, foreground: false }
1280
+ if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts }
1231
1281
 
1232
1282
  if (wgsl === null) {
1233
- this.backgroundEffect?.paramsBuffer?.destroy()
1234
- this.backgroundEffect = null
1283
+ this.effect?.paramsBuffer?.destroy()
1284
+ this.effect = null
1235
1285
  const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) })
1236
1286
  this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)")
1237
1287
  this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
1238
1288
  this.rebuildCompositeBindGroup()
1239
1289
  this.writeCompositeViewUniforms()
1240
- return { ok: true, diagnostics: [] }
1290
+ return { ok: true, diagnostics: [], mounts: noMounts }
1291
+ }
1292
+
1293
+ // ── Which mounts did the author ask for? A declaration, not a setting: the
1294
+ // entry points present in the source are the ones compiled in. Matching the
1295
+ // `fn` keyword is enough to be safe against a `foreground` LOCAL or a call
1296
+ // to one — those never follow `fn`.
1297
+ const hasBackground = /\bfn\s+background\s*\(/.test(wgsl)
1298
+ const hasForeground = /\bfn\s+foreground\s*\(/.test(wgsl)
1299
+ if (!hasBackground && !hasForeground) {
1300
+ return {
1301
+ ok: false,
1302
+ diagnostics: [
1303
+ "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f " +
1304
+ "or fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f (or both)",
1305
+ ],
1306
+ mounts: noMounts,
1307
+ }
1241
1308
  }
1309
+ const mounts = { background: hasBackground, foreground: hasForeground }
1242
1310
 
1243
1311
  // ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
1244
1312
  // Fields are emitted in declaration order; offsets follow WGSL's natural
@@ -1250,7 +1318,7 @@ export class Engine {
1250
1318
  let cursor = 0
1251
1319
  for (const [name, value] of entries) {
1252
1320
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
1253
- return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`] }
1321
+ return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts }
1254
1322
  }
1255
1323
  const isVec = typeof value !== "number"
1256
1324
  const align = isVec ? 16 : 4
@@ -1270,22 +1338,22 @@ export class Engine {
1270
1338
  }
1271
1339
  }
1272
1340
  const paramsDecl = entries.length
1273
- ? `struct BgParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: BgParams;\n`
1341
+ ? `struct EffectParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: EffectParams;\n`
1274
1342
  : ""
1275
1343
 
1276
1344
  // ── Compile with validation captured, not thrown at the console. Line
1277
1345
  // numbers in diagnostics are rebased to the USER's source.
1278
- const source = buildCompositeShader({ wgsl, paramsDecl })
1346
+ const source = buildCompositeShader({ wgsl, paramsDecl, hasBackground, hasForeground })
1279
1347
  const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1
1280
1348
  this.device.pushErrorScope("validation")
1281
- const module = this.device.createShaderModule({ label: "composite shader (bg effect)", code: source })
1349
+ const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
1282
1350
  const info = await module.getCompilationInfo()
1283
1351
  const scopeErr = await this.device.popErrorScope()
1284
1352
  const diagnostics = info.messages
1285
1353
  .filter((m) => m.type === "error")
1286
1354
  .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
1287
1355
  if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
1288
- if (diagnostics.length > 0) return { ok: false, diagnostics }
1356
+ if (diagnostics.length > 0) return { ok: false, diagnostics, mounts }
1289
1357
  let identity: GPURenderPipeline
1290
1358
  let gamma: GPURenderPipeline
1291
1359
  try {
@@ -1303,37 +1371,42 @@ export class Engine {
1303
1371
  primitive: { topology: "triangle-list" },
1304
1372
  })
1305
1373
  ;[identity, gamma] = await Promise.all([
1306
- make(false, "composite pipeline (bg effect, gamma=1)"),
1307
- make(true, "composite pipeline (bg effect, gamma!=1)"),
1374
+ make(false, "composite pipeline (effect, gamma=1)"),
1375
+ make(true, "composite pipeline (effect, gamma!=1)"),
1308
1376
  ])
1309
1377
  } catch (e) {
1310
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
1378
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
1311
1379
  }
1312
1380
 
1313
1381
  // ── Swap — only now does the old effect (and its params buffer) go away.
1314
- this.backgroundEffect?.paramsBuffer?.destroy()
1382
+ this.effect?.paramsBuffer?.destroy()
1315
1383
  let paramsBuffer: GPUBuffer | null = null
1316
1384
  if (entries.length) {
1317
1385
  paramsBuffer = this.device.createBuffer({
1318
- label: "bg effect params",
1386
+ label: "effect params",
1319
1387
  size: paramsData.byteLength,
1320
1388
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1321
1389
  })
1322
1390
  this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
1323
1391
  }
1324
- this.backgroundEffect = { wgsl, paramLayout: layout, paramsBuffer, paramsData }
1392
+ this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground }
1325
1393
  this.compositePipelineIdentity = identity
1326
1394
  this.compositePipelineGamma = gamma
1327
- this.bgEffectEpochMs = performance.now()
1395
+ this.effectEpochMs = performance.now()
1328
1396
  this.rebuildCompositeBindGroup()
1329
1397
  this.writeCompositeViewUniforms()
1330
- return { ok: true, diagnostics: [] }
1398
+ return { ok: true, diagnostics: [], mounts }
1399
+ }
1400
+
1401
+ /** Which mounts the installed effect declared. Both false when none is set. */
1402
+ getEffectMounts(): { background: boolean; foreground: boolean } {
1403
+ return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false }
1331
1404
  }
1332
1405
 
1333
- /** Write one background-effect param (declared at setBackgroundEffect) — a
1334
- * uniform write, no recompile; the instant tier, like setStyleParam. */
1335
- setBackgroundEffectParam(name: string, value: BackgroundEffectParamValue): void {
1336
- const fx = this.backgroundEffect
1406
+ /** Write one effect param (declared at setEffect) — a uniform write, no
1407
+ * recompile; the instant tier, like setStyleParam. */
1408
+ setEffectParam(name: string, value: EffectParamValue): void {
1409
+ const fx = this.effect
1337
1410
  if (!fx || !fx.paramsBuffer) return
1338
1411
  const slot = fx.paramLayout.get(name)
1339
1412
  if (!slot) return
@@ -1407,7 +1480,10 @@ export class Engine {
1407
1480
  if (!this.device || !this.dofUniformBuffer) return
1408
1481
  const d = this.depthOfField
1409
1482
  const u = this.dofUniformData
1410
- const auto = d.focusMode === "auto" ? this.getModelBodyFocus() : null
1483
+ // `d.enabled &&`, because a foreground effect also drives this write (for
1484
+ // projA/projB alone) and auto-focus walks every visible character's bones —
1485
+ // work nothing would read with the gather switched off.
1486
+ const auto = d.enabled && d.focusMode === "auto" ? this.getModelBodyFocus() : null
1411
1487
  u[0] = d.enabled ? 1 : 0
1412
1488
  u[1] = auto?.distance ?? Math.max(d.focusDistance, 0.05)
1413
1489
  // In auto mode the authored range is a floor — the sharp band never cuts
@@ -1591,6 +1667,37 @@ export class Engine {
1591
1667
  // smooth gradients) while still passing through every anchor (look preserved) and staying
1592
1668
  // monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
1593
1669
  // at t=k, k=0..13 (t = log2(linear)+10). See composite.ts::filmic for the sampling map.
1670
+ /**
1671
+ * Decompress and upload Blender's AgX cube.
1672
+ *
1673
+ * Deliberately off the critical path: it is 723 KB once inflated, and a frame
1674
+ * rendered before it lands should show the scene under whatever transform is
1675
+ * already there rather than wait. Until then binding 10 holds a 1×1×1 stand-in,
1676
+ * which is only ever sampled if someone selects AgX in that window.
1677
+ */
1678
+ private async loadAgxLut(): Promise<void> {
1679
+ try {
1680
+ const packed = Uint8Array.from(atob(AGX_LUT_GZ), (ch) => ch.charCodeAt(0))
1681
+ const stream = new Blob([packed]).stream().pipeThrough(new DecompressionStream("gzip"))
1682
+ const bytes = new Uint8Array(await new Response(stream).arrayBuffer())
1683
+ const n = AGX_LUT_SIZE
1684
+ if (bytes.byteLength !== n * n * n * 4) throw new Error(`AgX LUT is ${bytes.byteLength} bytes, expected ${n ** 3 * 4}`)
1685
+ const tex = this.device.createTexture({
1686
+ label: "AgX 57³ LUT",
1687
+ size: [n, n, n],
1688
+ dimension: "3d",
1689
+ format: "rgb10a2unorm",
1690
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
1691
+ })
1692
+ // .cube order is red fastest, which is exactly a 3D texture's own layout.
1693
+ this.device.queue.writeTexture({ texture: tex }, bytes, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n])
1694
+ this.agxLutTexture = tex
1695
+ this.rebuildCompositeBindGroup()
1696
+ } catch {
1697
+ // A missing LUT costs AgX, not the renderer — the other transforms stand.
1698
+ }
1699
+ }
1700
+
1594
1701
  private bakeFilmicLut() {
1595
1702
  const anchors = [
1596
1703
  0.0028, 0.0068, 0.0151, 0.0313, 0.061, 0.112, 0.192, 0.306, 0.459, 0.631, 0.82, 0.907, 0.962, 0.989,
@@ -1864,6 +1971,13 @@ export class Engine {
1864
1971
  // StyleUniforms for compiled graph shaders (adjust-tier sliders). Hand-written
1865
1972
  // presets simply don't declare it — a layout may carry bindings a shader ignores.
1866
1973
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1974
+ // Style-group image maps. A PMX material carries one image; a
1975
+ // Blender-authored look needs a lightmap or ramp beside it, and those
1976
+ // belong to the GROUP rather than to the model's own material data.
1977
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1978
+ { binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1979
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1980
+ { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1867
1981
  ],
1868
1982
  })
1869
1983
 
@@ -1996,6 +2110,14 @@ export class Engine {
1996
2110
 
1997
2111
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
1998
2112
  this.bakeBrdfLut()
2113
+ this.agxFallbackTexture = this.device.createTexture({
2114
+ label: "AgX LUT fallback",
2115
+ size: [1, 1, 1],
2116
+ dimension: "3d",
2117
+ format: "rgb10a2unorm",
2118
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
2119
+ })
2120
+ void this.loadAgxLut()
1999
2121
  this.bakeFilmicLut()
2000
2122
 
2001
2123
  // Now that shadow resources exist, create the main per-frame bind group
@@ -2286,11 +2408,12 @@ export class Engine {
2286
2408
  // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
2287
2409
  this.compositeUniformBuffer = this.device.createBuffer({
2288
2410
  label: "composite view uniforms",
2289
- // 10 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
2411
+ // 11 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
2290
2412
  // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
2291
- // (time, _, canvas width, canvas height) for user background effects ·
2292
- // three grade vectors (CDL offset+contrast, power+saturation, slope+flag).
2293
- size: 160,
2413
+ // (time, _, canvas width, canvas height) for user effects · three grade
2414
+ // vectors (CDL offset+contrast, power+saturation, slope+flag) · camera
2415
+ // world position, for an effect placing itself in the scene.
2416
+ size: 176,
2294
2417
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2295
2418
  })
2296
2419
  this.dofUniformBuffer = this.device.createBuffer({
@@ -2330,6 +2453,9 @@ export class Engine {
2330
2453
  texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
2331
2454
  },
2332
2455
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2456
+ // AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
2457
+ // 1×1×1 stand-in keeps the bind group valid until it arrives.
2458
+ { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
2333
2459
  ],
2334
2460
  })
2335
2461
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -3010,6 +3136,13 @@ export class Engine {
3010
3136
  return this.cameraAnimation !== null
3011
3137
  }
3012
3138
 
3139
+ /** Seconds the loaded camera VMD runs for — its last keyframe — or 0 with none
3140
+ * loaded. A timeline cannot draw a lane to scale without it, and the camera's
3141
+ * length is its own: it does not have to match any model's clip. */
3142
+ getCameraVmdDuration(): number {
3143
+ return this.cameraAnimation?.duration ?? 0
3144
+ }
3145
+
3013
3146
  /** Drop the loaded camera VMD and return to orbit control. */
3014
3147
  clearCameraVmd(): void {
3015
3148
  this.cameraAnimation = null
@@ -3238,7 +3371,7 @@ export class Engine {
3238
3371
  // explicit destroy). Per-model group buffers are torn down in removeModel; the shared
3239
3372
  // zero buffer is engine-owned.
3240
3373
  this.forEachInstance((inst) => {
3241
- for (const install of inst.styleGroups.values()) install.uniformBuffer.destroy()
3374
+ for (const install of inst.styleGroups.values()) this.destroyInstall(install)
3242
3375
  inst.styleGroups.clear()
3243
3376
  })
3244
3377
  this.zeroStyleBuffer?.destroy()
@@ -3387,7 +3520,7 @@ export class Engine {
3387
3520
  buf.destroy()
3388
3521
  }
3389
3522
  // Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
3390
- for (const install of inst.styleGroups.values()) install.uniformBuffer.destroy()
3523
+ for (const install of inst.styleGroups.values()) this.destroyInstall(install)
3391
3524
  this.modelInstances.delete(name)
3392
3525
  }
3393
3526
 
@@ -3469,11 +3602,58 @@ export class Engine {
3469
3602
 
3470
3603
  // Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
3471
3604
  // (the group's buffer when grouped, or the shared zero buffer when ungrouped).
3472
- private createMaterialBindGroup(label: string, baseEntries: GPUBindGroupEntry[], styleBuffer: GPUBuffer): GPUBindGroup {
3605
+ /** A group's uniform buffer and its maps have the same lifetime — freeing one
3606
+ * without the other is how a re-apply leaks GPU memory a frame at a time. */
3607
+ private destroyInstall(install: GroupInstall): void {
3608
+ install.uniformBuffer.destroy()
3609
+ for (const tex of install.images ?? []) tex?.destroy()
3610
+ }
3611
+
3612
+ /** Upload a group's image maps. Sources are decoded images the host already
3613
+ * holds; the engine never fetches, matching how models and motions arrive. */
3614
+ private uploadGroupImages(group: StyleGroup): (GPUTexture | null)[] | undefined {
3615
+ if (!group.images?.length) return undefined
3616
+ return group.images.slice(0, 4).map((entry) => {
3617
+ if (!entry) return null
3618
+ const wrapped = "source" in entry
3619
+ const src = wrapped ? entry.source : entry
3620
+ const width = Math.max(1, "naturalWidth" in src ? src.naturalWidth : src.width)
3621
+ const height = Math.max(1, "naturalHeight" in src ? src.naturalHeight : src.height)
3622
+ const tex = this.device.createTexture({
3623
+ label: `group map: ${group.id}`,
3624
+ size: [width, height],
3625
+ // Colour maps decode to linear on sample, the way material textures do;
3626
+ // data maps must not, or every threshold packed in their channels moves.
3627
+ format: wrapped && entry.srgb ? "rgba8unorm-srgb" : "rgba8unorm",
3628
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
3629
+ })
3630
+ this.device.queue.copyExternalImageToTexture(
3631
+ { source: src },
3632
+ { texture: tex, premultipliedAlpha: wrapped && entry.premultiplied === true },
3633
+ [width, height],
3634
+ )
3635
+ return tex
3636
+ })
3637
+ }
3638
+
3639
+ private createMaterialBindGroup(
3640
+ label: string,
3641
+ baseEntries: GPUBindGroupEntry[],
3642
+ styleBuffer: GPUBuffer,
3643
+ groupImages?: (GPUTexture | null)[],
3644
+ ): GPUBindGroup {
3645
+ // Every material bind group in the engine is built here, which is why the
3646
+ // group's maps are threaded through this one function rather than patched in
3647
+ // at each call site — an unset slot reads white, never stale.
3648
+ const slots: GPUBindGroupEntry[] = []
3649
+ for (let i = 0; i < 4; i++) {
3650
+ const tex = groupImages?.[i] ?? this.fallbackMaterialTexture
3651
+ slots.push({ binding: 5 + i, resource: tex.createView() })
3652
+ }
3473
3653
  return this.device.createBindGroup({
3474
3654
  label,
3475
3655
  layout: this.mainPerMaterialBindGroupLayout,
3476
- entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }],
3656
+ entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }, ...slots],
3477
3657
  })
3478
3658
  }
3479
3659
 
@@ -5213,12 +5393,20 @@ export class Engine {
5213
5393
  this.updateShadowLightVP()
5214
5394
 
5215
5395
  // 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.
5396
+ // TBDR tile memory (discard) unless something in the composite reads it this
5397
+ // frame. Two things can — the DoF gather, and the depth handed to a
5398
+ // foreground effect — and either one makes the pass store it.
5399
+ //
5400
+ // The uniform refresh is shared for the same reason: linearDepth() inverts
5401
+ // the z-buffer with projA/projB out of dofU[2], which track the camera's
5402
+ // near/far and so must be rewritten every frame either reader is live. A
5403
+ // foreground with a stale pair would read metres from the wrong frustum. The
5404
+ // write leaves dofU[0].x at 0 while DoF is off, so refreshing it does not
5405
+ // switch the gather on.
5219
5406
  const dofOn = this.depthOfField.enabled
5220
- this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = dofOn ? "store" : "discard"
5221
- if (dofOn) this.writeDepthOfFieldUniforms()
5407
+ const depthRead = dofOn || (this.effect?.hasForeground ?? false)
5408
+ this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = depthRead ? "store" : "discard"
5409
+ if (depthRead) this.writeDepthOfFieldUniforms()
5222
5410
 
5223
5411
  const encoder = this.device.createCommandEncoder()
5224
5412
 
@@ -5437,7 +5625,7 @@ export class Engine {
5437
5625
  for (const [id, install] of inst.styleGroups) {
5438
5626
  if (!nextIds.has(id)) {
5439
5627
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1)
5440
- install.uniformBuffer.destroy()
5628
+ this.destroyInstall(install)
5441
5629
  inst.styleGroups.delete(id)
5442
5630
  }
5443
5631
  }
@@ -5474,7 +5662,7 @@ export class Engine {
5474
5662
  const install = inst?.styleGroups.get(groupId)
5475
5663
  if (!inst || !install) return
5476
5664
  inst.styleGroupGen.set(groupId, (inst.styleGroupGen.get(groupId) ?? 0) + 1) // discard in-flight compile
5477
- install.uniformBuffer.destroy()
5665
+ this.destroyInstall(install)
5478
5666
  inst.styleGroups.delete(groupId)
5479
5667
  this.assignDrawCallGroups(inst, this.currentClaims(inst))
5480
5668
  }
@@ -5485,7 +5673,7 @@ export class Engine {
5485
5673
  if (!inst) return
5486
5674
  for (const [id, install] of inst.styleGroups) {
5487
5675
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1)
5488
- install.uniformBuffer.destroy()
5676
+ this.destroyInstall(install)
5489
5677
  }
5490
5678
  inst.styleGroups.clear()
5491
5679
  this.assignDrawCallGroups(inst, new Map())
@@ -5585,6 +5773,9 @@ export class Engine {
5585
5773
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
5586
5774
  })
5587
5775
 
5776
+ // The outgoing install's maps go with it — a re-apply that changes images
5777
+ // would otherwise strand the old textures for the life of the model.
5778
+ const previousImages = inst.styleGroups.get(group.id)?.images
5588
5779
  inst.styleGroups.set(group.id, {
5589
5780
  group,
5590
5781
  renderClass,
@@ -5593,9 +5784,31 @@ export class Engine {
5593
5784
  pipelineNoDepthWrite,
5594
5785
  overEyesPipeline,
5595
5786
  uniformBuffer,
5787
+ images: this.uploadGroupImages(group),
5596
5788
  slotMap: result.slotMap,
5597
5789
  signature,
5598
5790
  })
5791
+ // Rebind this group's draw calls before the old textures go.
5792
+ //
5793
+ // assignDrawCallGroups only rebuilds a bind group when a material CHANGES
5794
+ // group, which is the wrong test here: swapping the graph on a group a
5795
+ // material already belongs to leaves its id alone while replacing the maps
5796
+ // underneath it. The draw call then kept a bind group holding the outgoing
5797
+ // textures — destroyed on the next line — so a graph swap either sampled the
5798
+ // old maps, or the fallback white where the previous graph had none (which
5799
+ // reads as a blown-out white material through any screen or add), or tripped
5800
+ // a validation error on a destroyed texture. Only a reload cleared it.
5801
+ const install = inst.styleGroups.get(group.id)
5802
+ for (const dc of inst.drawCalls) {
5803
+ if (!dc.baseBindGroupEntries || dc.groupId !== group.id) continue
5804
+ dc.bindGroup = this.createMaterialBindGroup(
5805
+ `material: ${dc.materialName}`,
5806
+ dc.baseBindGroupEntries,
5807
+ uniformBuffer,
5808
+ install?.images,
5809
+ )
5810
+ }
5811
+ for (const tex of previousImages ?? []) tex?.destroy()
5599
5812
  this.writeGroupDefaults(uniformBuffer, group, result.slotMap)
5600
5813
  return { ok: true, diagnostics, slotMap: result.slotMap }
5601
5814
  }
@@ -5616,6 +5829,7 @@ export class Engine {
5616
5829
  `material: ${dc.materialName}`,
5617
5830
  dc.baseBindGroupEntries,
5618
5831
  install ? install.uniformBuffer : this.zeroStyleBuffer,
5832
+ install?.images,
5619
5833
  )
5620
5834
  }
5621
5835
  this.sortDrawCalls(inst)
@@ -5881,7 +6095,7 @@ export class Engine {
5881
6095
  // is LEFT-HANDED (+Z forward, see Mat4.lookAtInto), so the world-space
5882
6096
  // right/up/FORWARD vectors are rows 0/1/2 of its rotation block directly
5883
6097
  // (column-major storage: row i = values[i], values[i+4], values[i+8]).
5884
- if ((this.backdropEquirectView || this.backgroundEffect) && this.compositeUniformBuffer) {
6098
+ if ((this.backdropEquirectView || this.effect) && this.compositeUniformBuffer) {
5885
6099
  const v = viewMatrix.values
5886
6100
  const u = this.compositeUniformData
5887
6101
  const tanHalf = Math.tan((this.camera.fov ?? Math.PI / 4) / 2)
@@ -5899,9 +6113,15 @@ export class Engine {
5899
6113
  u[22] = v[10]
5900
6114
  u[23] = 0
5901
6115
  // Effect clock + canvas size (viewU[6]) — written on the same refresh.
5902
- u[24] = (performance.now() - this.bgEffectEpochMs) / 1000
6116
+ u[24] = (performance.now() - this.effectEpochMs) / 1000
5903
6117
  u[26] = this.canvas.width
5904
6118
  u[27] = this.canvas.height
6119
+ // Camera world position (viewU[10]) — the other half of bgWorldPos. It
6120
+ // rides this refresh rather than writeCompositeViewUniforms because it
6121
+ // changes every frame the camera does, exactly like the basis above.
6122
+ u[40] = cameraPos.x
6123
+ u[41] = cameraPos.y
6124
+ u[42] = cameraPos.z
5905
6125
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
5906
6126
  }
5907
6127
  }