reze-engine 0.41.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 (47) hide show
  1. package/dist/engine.d.ts +31 -1
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +141 -15
  4. package/dist/graph/presets/body.js +2 -2
  5. package/dist/graph/presets/cloth_rough.js +2 -2
  6. package/dist/graph/presets/cloth_smooth.js +2 -2
  7. package/dist/graph/presets/default.js +2 -2
  8. package/dist/graph/presets/eye.js +2 -2
  9. package/dist/graph/presets/face.js +2 -2
  10. package/dist/graph/presets/hair.js +2 -2
  11. package/dist/graph/presets/metal.js +2 -2
  12. package/dist/graph/presets/stockings.js +3 -3
  13. package/dist/graph/registry.d.ts.map +1 -1
  14. package/dist/graph/registry.js +426 -10
  15. package/dist/graph/style-group.d.ts +46 -0
  16. package/dist/graph/style-group.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/shaders/agx-lut.d.ts +11 -0
  20. package/dist/shaders/agx-lut.d.ts.map +1 -0
  21. package/dist/shaders/agx-lut.js +29 -0
  22. package/dist/shaders/materials/common.d.ts +1 -1
  23. package/dist/shaders/materials/common.d.ts.map +1 -1
  24. package/dist/shaders/materials/common.js +10 -0
  25. package/dist/shaders/materials/nodes.d.ts +1 -1
  26. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  27. package/dist/shaders/materials/nodes.js +957 -576
  28. package/dist/shaders/passes/composite.d.ts.map +1 -1
  29. package/dist/shaders/passes/composite.js +57 -3
  30. package/package.json +1 -1
  31. package/src/engine.ts +165 -14
  32. package/src/graph/presets/body.ts +2 -2
  33. package/src/graph/presets/cloth_rough.ts +2 -2
  34. package/src/graph/presets/cloth_smooth.ts +2 -2
  35. package/src/graph/presets/default.ts +2 -2
  36. package/src/graph/presets/eye.ts +2 -2
  37. package/src/graph/presets/face.ts +2 -2
  38. package/src/graph/presets/hair.ts +2 -2
  39. package/src/graph/presets/metal.ts +2 -2
  40. package/src/graph/presets/stockings.ts +3 -3
  41. package/src/graph/registry.ts +439 -10
  42. package/src/graph/style-group.ts +44 -0
  43. package/src/index.ts +2 -0
  44. package/src/shaders/agx-lut.ts +30 -0
  45. package/src/shaders/materials/common.ts +10 -0
  46. package/src/shaders/materials/nodes.ts +962 -581
  47. package/src/shaders/passes/composite.ts +57 -3
@@ -1 +1 @@
1
- {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AAeA;;;;;;;;;;;;;;;;;;;;;;;;+BAwB+B;AAC/B,MAAM,MAAM,qBAAqB,GAAG;IAClC,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;CACvB,CAAA;AAoTD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAclF;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
1
+ {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AAeA;;;;;;;;;;;;;;;;;;;;;;;;+BAwB+B;AAC/B,MAAM,MAAM,qBAAqB,GAAG;IAClC,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;CACvB,CAAA;AA0WD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAclF;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
@@ -47,7 +47,7 @@ override APPLY_GAMMA: bool = true;
47
47
  // the compiled variant IS the flag.
48
48
  // viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
49
49
  // viewU[5] = (camera forward, _) — refreshed per frame while skybox/effect active.
50
- // viewU[6] = (time seconds, _, canvas width, canvas height).
50
+ // viewU[6] = (time seconds, view transform id, canvas width, canvas height).
51
51
  // viewU[7] = (grade offset.rgb, contrast); viewU[8] = (grade power.rgb, saturation);
52
52
  // viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
53
53
  // viewU[10] = (camera world position, _) — refreshed with the basis above.
@@ -67,6 +67,9 @@ override APPLY_GAMMA: bool = true;
67
67
  // track the camera radius. Cleared depth (1.0) inverts to the far
68
68
  // plane, so empty sky reads as maximally defocused background.
69
69
  @group(0) @binding(9) var<uniform> dofU: array<vec4<f32>, 3>;
70
+ // Blender's AgX, as the 57³ lookup it ships as rather than a reconstruction of
71
+ // it. Sampled in the log-encoded E-Gamut space the cube expects — see agxTransform.
72
+ @group(0) @binding(10) var agxLut: texture_3d<f32>;
70
73
 
71
74
  // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
72
75
  const FILMIC_LUT_W: f32 = 256.0;
@@ -110,6 +113,58 @@ fn filmic(x: f32) -> f32 {
110
113
  return textureSampleLevel(filmicLut, bloomSamp, vec2f(u, 0.5), 0.0).r;
111
114
  }
112
115
 
116
+ /** The sRGB display encoding — Blender's "Standard" view transform, which is what
117
+ * NPR work uses: no film curve at all, so the colours a graph computes are the
118
+ * colours that land. Not a 2.2 power law; sRGB has a linear toe. */
119
+ fn srgb_encode(x: f32) -> f32 {
120
+ let c = max(x, 0.0);
121
+ return select(1.055 * pow(c, 1.0 / 2.4) - 0.055, c * 12.92, c <= 0.0031308);
122
+ }
123
+
124
+ /**
125
+ * AgX, the chain config.ocio states for "AgX Base Rec.1886" on an sRGB display:
126
+ *
127
+ * scene-linear Rec.709 → 3x3 into FilmLight E-Gamut
128
+ * → log2 across 25 stops, [-12.47393, +12.5261] → 0..1
129
+ * → the 57³ cube
130
+ * → Rec.1886 (pure γ2.4) decoded, then re-encoded as sRGB for the swapchain
131
+ *
132
+ * The last step is not pedantry: Rec.1886 is a pure power law and sRGB has a
133
+ * linear toe, so skipping it lifts the darkest few code values.
134
+ *
135
+ * Blender interpolates the cube tetrahedrally and a GPU sampler is trilinear.
136
+ * The difference is small and confined to saturated gradients; if it ever shows,
137
+ * tetrahedral is a dozen lines here.
138
+ */
139
+ fn agxTransform(c: vec3f) -> vec3f {
140
+ let m = mat3x3<f32>(
141
+ vec3f(0.55937113, 0.07622071, 0.06552670),
142
+ vec3f(0.30478326, 0.78797183, 0.16454675),
143
+ vec3f(0.13584556, 0.13580748, 0.76992653),
144
+ );
145
+ let e = m * max(c, vec3f(0.0));
146
+ // The cube's domain: an unlit pixel would take log2 to -inf, so floor it at
147
+ // the bottom stop rather than sampling outside the LUT.
148
+ let t = clamp((log2(max(e, vec3f(1e-10))) + vec3f(12.47393)) / 25.0, vec3f(0.0), vec3f(1.0));
149
+ // Half-texel inset, so the end stops are the cube's own values and not a blend
150
+ // with the clamp-to-edge border.
151
+ let n = 57.0;
152
+ let uvw = t * ((n - 1.0) / n) + (0.5 / n);
153
+ let formed = textureSampleLevel(agxLut, bloomSamp, uvw, 0.0).rgb;
154
+ let linear = pow(max(formed, vec3f(0.0)), vec3f(2.4));
155
+ return vec3f(srgb_encode(linear.r), srgb_encode(linear.g), srgb_encode(linear.b));
156
+ }
157
+
158
+ /** Which display transform, chosen per frame at viewU[6].y (0 filmic, 1 standard, 2 agx).
159
+ * A uniform branch rather than a pipeline variant: switching is rare, and both
160
+ * arms are cheap enough that specialising the pipeline would buy nothing. */
161
+ fn viewTransform(c: vec3f) -> vec3f {
162
+ let mode = viewU[6].y;
163
+ if (mode > 1.5) { return agxTransform(c); }
164
+ if (mode > 0.5) { return vec3f(srgb_encode(c.r), srgb_encode(c.g), srgb_encode(c.b)); }
165
+ return vec3f(filmic(c.r), filmic(c.g), filmic(c.b));
166
+ }
167
+
113
168
  /** Canvas size in pixels — for user effects (aspect correction). */
114
169
  fn bgResolution() -> vec2f { return viewU[6].zw; }
115
170
 
@@ -222,8 +277,7 @@ const COMPOSITE_BODY = /* wgsl */ `
222
277
  let sceneStraight = scenePm.rgb / max(sceneAlpha, 1e-6);
223
278
 
224
279
  let exposed = sceneStraight * exp2(viewU[0].x);
225
- let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
226
- var disp = max(tm, vec3f(0.0));
280
+ var disp = max(viewTransform(exposed), vec3f(0.0));
227
281
  // Grade the SCENE only, before the display gamma. Deliberately not applied to
228
282
  // the background: it keeps a picked background color exactly as picked, and —
229
283
  // load-bearing — leaves green-screen mode's key color unshifted so chroma
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.41.0",
3
+ "version": "0.41.1",
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
@@ -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. */
@@ -320,7 +334,17 @@ export type ViewTransformOptions = {
320
334
  exposure: number
321
335
  /** After Filmic, display gamma (`pow(rgb, 1/gamma)`). */
322
336
  gamma: number
323
- 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"
324
348
  }
325
349
 
326
350
  // Matches the reference Blender project: Filmic view, Medium High Contrast look,
@@ -328,7 +352,7 @@ export type ViewTransformOptions = {
328
352
  export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
329
353
  exposure: 0.6,
330
354
  gamma: 1.0,
331
- look: "medium_high_contrast",
355
+ transform: "filmic",
332
356
  }
333
357
 
334
358
  /** Color grading applied to the tonemapped scene (ASC CDL — see grade() in
@@ -802,6 +826,8 @@ export class Engine {
802
826
  * STORE its depth, which it otherwise discards into tile memory. */
803
827
  hasForeground: boolean
804
828
  } | null = null
829
+ private agxLutTexture: GPUTexture | null = null
830
+ private agxFallbackTexture!: GPUTexture
805
831
  /** Bound at composite binding 7 when no effect (or a param-less one) is set. */
806
832
  private bgParamsDummyBuffer!: GPUBuffer
807
833
  private compositePipelineLayout!: GPUPipelineLayout
@@ -981,7 +1007,7 @@ export class Engine {
981
1007
  return {
982
1008
  exposure: partial?.exposure ?? d.exposure,
983
1009
  gamma: partial?.gamma ?? d.gamma,
984
- look: partial?.look ?? d.look,
1010
+ transform: partial?.transform ?? d.transform,
985
1011
  }
986
1012
  }
987
1013
 
@@ -1001,7 +1027,7 @@ export class Engine {
1001
1027
 
1002
1028
  getViewTransformOptions(): ViewTransformOptions {
1003
1029
  const v = this.viewTransform
1004
- return { exposure: v.exposure, gamma: v.gamma, look: v.look }
1030
+ return { exposure: v.exposure, gamma: v.gamma, transform: v.transform }
1005
1031
  }
1006
1032
 
1007
1033
  private colorGrading: ColorGradingOptions = {
@@ -1044,7 +1070,7 @@ export class Engine {
1044
1070
  const v = this.viewTransform
1045
1071
  if (patch.exposure !== undefined) v.exposure = patch.exposure
1046
1072
  if (patch.gamma !== undefined) v.gamma = patch.gamma
1047
- if (patch.look !== undefined) v.look = patch.look
1073
+ if (patch.transform !== undefined) v.transform = patch.transform
1048
1074
  if (this.device && this.compositeUniformBuffer) {
1049
1075
  this.writeCompositeViewUniforms()
1050
1076
  }
@@ -1076,8 +1102,10 @@ export class Engine {
1076
1102
  u[10] = bg?.z ?? 0
1077
1103
  // Base-layer mode only. A user effect is a separate LAYER over whichever
1078
1104
  // 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.
1105
+ // rebuilt per effect, so the compiled variant IS the flag.
1080
1106
  u[11] = this.backdropEquirectView ? 2 : bg ? 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
1081
1109
  u[26] = this.canvas.width
1082
1110
  u[27] = this.canvas.height
1083
1111
  // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
@@ -1148,6 +1176,7 @@ export class Engine {
1148
1176
  { binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
1149
1177
  { binding: 8, resource: this.depthReadView },
1150
1178
  { binding: 9, resource: { buffer: this.dofUniformBuffer } },
1179
+ { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
1151
1180
  ],
1152
1181
  })
1153
1182
  }
@@ -1638,6 +1667,37 @@ export class Engine {
1638
1667
  // smooth gradients) while still passing through every anchor (look preserved) and staying
1639
1668
  // monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
1640
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
+
1641
1701
  private bakeFilmicLut() {
1642
1702
  const anchors = [
1643
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,
@@ -1911,6 +1971,13 @@ export class Engine {
1911
1971
  // StyleUniforms for compiled graph shaders (adjust-tier sliders). Hand-written
1912
1972
  // presets simply don't declare it — a layout may carry bindings a shader ignores.
1913
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: {} },
1914
1981
  ],
1915
1982
  })
1916
1983
 
@@ -2043,6 +2110,14 @@ export class Engine {
2043
2110
 
2044
2111
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
2045
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()
2046
2121
  this.bakeFilmicLut()
2047
2122
 
2048
2123
  // Now that shadow resources exist, create the main per-frame bind group
@@ -2378,6 +2453,9 @@ export class Engine {
2378
2453
  texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
2379
2454
  },
2380
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" } },
2381
2459
  ],
2382
2460
  })
2383
2461
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -3293,7 +3371,7 @@ export class Engine {
3293
3371
  // explicit destroy). Per-model group buffers are torn down in removeModel; the shared
3294
3372
  // zero buffer is engine-owned.
3295
3373
  this.forEachInstance((inst) => {
3296
- for (const install of inst.styleGroups.values()) install.uniformBuffer.destroy()
3374
+ for (const install of inst.styleGroups.values()) this.destroyInstall(install)
3297
3375
  inst.styleGroups.clear()
3298
3376
  })
3299
3377
  this.zeroStyleBuffer?.destroy()
@@ -3442,7 +3520,7 @@ export class Engine {
3442
3520
  buf.destroy()
3443
3521
  }
3444
3522
  // Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
3445
- for (const install of inst.styleGroups.values()) install.uniformBuffer.destroy()
3523
+ for (const install of inst.styleGroups.values()) this.destroyInstall(install)
3446
3524
  this.modelInstances.delete(name)
3447
3525
  }
3448
3526
 
@@ -3524,11 +3602,58 @@ export class Engine {
3524
3602
 
3525
3603
  // Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
3526
3604
  // (the group's buffer when grouped, or the shared zero buffer when ungrouped).
3527
- 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
+ }
3528
3653
  return this.device.createBindGroup({
3529
3654
  label,
3530
3655
  layout: this.mainPerMaterialBindGroupLayout,
3531
- entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }],
3656
+ entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }, ...slots],
3532
3657
  })
3533
3658
  }
3534
3659
 
@@ -5500,7 +5625,7 @@ export class Engine {
5500
5625
  for (const [id, install] of inst.styleGroups) {
5501
5626
  if (!nextIds.has(id)) {
5502
5627
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1)
5503
- install.uniformBuffer.destroy()
5628
+ this.destroyInstall(install)
5504
5629
  inst.styleGroups.delete(id)
5505
5630
  }
5506
5631
  }
@@ -5537,7 +5662,7 @@ export class Engine {
5537
5662
  const install = inst?.styleGroups.get(groupId)
5538
5663
  if (!inst || !install) return
5539
5664
  inst.styleGroupGen.set(groupId, (inst.styleGroupGen.get(groupId) ?? 0) + 1) // discard in-flight compile
5540
- install.uniformBuffer.destroy()
5665
+ this.destroyInstall(install)
5541
5666
  inst.styleGroups.delete(groupId)
5542
5667
  this.assignDrawCallGroups(inst, this.currentClaims(inst))
5543
5668
  }
@@ -5548,7 +5673,7 @@ export class Engine {
5548
5673
  if (!inst) return
5549
5674
  for (const [id, install] of inst.styleGroups) {
5550
5675
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1)
5551
- install.uniformBuffer.destroy()
5676
+ this.destroyInstall(install)
5552
5677
  }
5553
5678
  inst.styleGroups.clear()
5554
5679
  this.assignDrawCallGroups(inst, new Map())
@@ -5648,6 +5773,9 @@ export class Engine {
5648
5773
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
5649
5774
  })
5650
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
5651
5779
  inst.styleGroups.set(group.id, {
5652
5780
  group,
5653
5781
  renderClass,
@@ -5656,9 +5784,31 @@ export class Engine {
5656
5784
  pipelineNoDepthWrite,
5657
5785
  overEyesPipeline,
5658
5786
  uniformBuffer,
5787
+ images: this.uploadGroupImages(group),
5659
5788
  slotMap: result.slotMap,
5660
5789
  signature,
5661
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()
5662
5812
  this.writeGroupDefaults(uniformBuffer, group, result.slotMap)
5663
5813
  return { ok: true, diagnostics, slotMap: result.slotMap }
5664
5814
  }
@@ -5679,6 +5829,7 @@ export class Engine {
5679
5829
  `material: ${dc.materialName}`,
5680
5830
  dc.baseBindGroupEntries,
5681
5831
  install ? install.uniformBuffer : this.zeroStyleBuffer,
5832
+ install?.images,
5682
5833
  )
5683
5834
  }
5684
5835
  this.sortDrawCalls(inst)
@@ -55,7 +55,7 @@ export const BODY_GRAPH: ShaderGraph = {
55
55
  {
56
56
  id: "principled",
57
57
  type: "principled",
58
- inputs: { metallic: 0.0, specular: 0.5, roughness: 0.3, spec_clamp: 10.0, sheen: 0.0, sheen_tint: 0.0 },
58
+ inputs: { metallic: 0.0, specular_ior_level: 0.5, roughness: 0.3, spec_clamp: 10.0, sheen_weight: 0.0, sheen_tint: 0.0 },
59
59
  },
60
60
  { id: "p_sum", type: "add_shader" },
61
61
  { id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.5 } },
@@ -92,7 +92,7 @@ export const BODY_GRAPH: ShaderGraph = {
92
92
  { from: { node: "noise_ramp", socket: "fac_out" }, to: { node: "principled_base", socket: "fac" } },
93
93
  { from: { node: "bc", socket: "color" }, to: { node: "principled_base", socket: "a" } },
94
94
  { from: { node: "bc", socket: "color" }, to: { node: "p_emit", socket: "color" } },
95
- { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base" } },
95
+ { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base_color" } },
96
96
  { from: { node: "bump", socket: "vector" }, to: { node: "principled", socket: "normal" } },
97
97
  { from: { node: "principled", socket: "color" }, to: { node: "p_sum", socket: "a" } },
98
98
  { from: { node: "p_emit", socket: "color" }, to: { node: "p_sum", socket: "b" } },
@@ -30,7 +30,7 @@ export const CLOTH_ROUGH_GRAPH: ShaderGraph = {
30
30
  {
31
31
  id: "principled",
32
32
  type: "principled",
33
- inputs: { metallic: 0.0, specular: 0.8, roughness: 0.8187, spec_clamp: 10.0, sheen: 0.0, sheen_tint: 0.0 },
33
+ inputs: { metallic: 0.0, specular_ior_level: 0.8, roughness: 0.8187, spec_clamp: 10.0, sheen_weight: 0.0, sheen_tint: 0.0 },
34
34
  },
35
35
  { id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.8999999761581421 } },
36
36
  ],
@@ -55,7 +55,7 @@ export const CLOTH_ROUGH_GRAPH: ShaderGraph = {
55
55
  { from: { node: "noise_ramp", socket: "fac_out" }, to: { node: "bump", socket: "height" } },
56
56
  { from: { node: "geo", socket: "normal" }, to: { node: "bump", socket: "normal" } },
57
57
  { from: { node: "tex", socket: "color" }, to: { node: "principled_base", socket: "color" } },
58
- { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base" } },
58
+ { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base_color" } },
59
59
  { from: { node: "bump", socket: "vector" }, to: { node: "principled", socket: "normal" } },
60
60
  { from: { node: "npr_emit", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
61
61
  { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
@@ -27,7 +27,7 @@ export const CLOTH_SMOOTH_GRAPH: ShaderGraph = {
27
27
  {
28
28
  id: "principled",
29
29
  type: "principled",
30
- inputs: { metallic: 0.0, specular: 0.8, roughness: 0.5, spec_clamp: 10.0, sheen: 0.0, sheen_tint: 0.0 },
30
+ inputs: { metallic: 0.0, specular_ior_level: 0.8, roughness: 0.5, spec_clamp: 10.0, sheen_weight: 0.0, sheen_tint: 0.0 },
31
31
  },
32
32
  { id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.8999999761581421 } },
33
33
  ],
@@ -48,7 +48,7 @@ export const CLOTH_SMOOTH_GRAPH: ShaderGraph = {
48
48
  { from: { node: "hue_004", socket: "color" }, to: { node: "npr_overlay", socket: "b" } },
49
49
  { from: { node: "npr_overlay", socket: "color" }, to: { node: "npr_emit", socket: "color" } },
50
50
  { from: { node: "tex", socket: "color" }, to: { node: "principled_base", socket: "color" } },
51
- { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base" } },
51
+ { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base_color" } },
52
52
  { from: { node: "npr_emit", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
53
53
  { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
54
54
  ],
@@ -18,13 +18,13 @@ export const DEFAULT_GRAPH: ShaderGraph = {
18
18
  {
19
19
  id: "principled",
20
20
  type: "principled",
21
- inputs: { metallic: 0.0, specular: 0.5, roughness: 0.5, spec_clamp: 10.0, sheen: 0.0, sheen_tint: 0.0 },
21
+ inputs: { metallic: 0.0, specular_ior_level: 0.5, roughness: 0.5, spec_clamp: 10.0, sheen_weight: 0.0, sheen_tint: 0.0 },
22
22
  },
23
23
  ],
24
24
  links: [
25
25
  { from: { node: "tex", socket: "color" }, to: { node: "base", socket: "a" } },
26
26
  { from: { node: "mat", socket: "color" }, to: { node: "base", socket: "b" } },
27
- { from: { node: "base", socket: "color" }, to: { node: "principled", socket: "base" } },
27
+ { from: { node: "base", socket: "color" }, to: { node: "principled", socket: "base_color" } },
28
28
  ],
29
29
  output: { node: "principled", socket: "color" },
30
30
  }
@@ -18,13 +18,13 @@ export const EYE_GRAPH: ShaderGraph = {
18
18
  {
19
19
  id: "principled",
20
20
  type: "principled",
21
- inputs: { metallic: 0.0, specular: 0.5, roughness: 0.5, spec_clamp: 1e30, sheen: 0.0, sheen_tint: 0.0 },
21
+ inputs: { metallic: 0.0, specular_ior_level: 0.5, roughness: 0.5, spec_clamp: 1e30, sheen_weight: 0.0, sheen_tint: 0.0 },
22
22
  },
23
23
  { id: "emission", type: "emission", inputs: { strength: 1.5 } },
24
24
  { id: "add", type: "add_shader" },
25
25
  ],
26
26
  links: [
27
- { from: { node: "tex", socket: "color" }, to: { node: "principled", socket: "base" } },
27
+ { from: { node: "tex", socket: "color" }, to: { node: "principled", socket: "base_color" } },
28
28
  { from: { node: "tex", socket: "color" }, to: { node: "emission", socket: "color" } },
29
29
  { from: { node: "principled", socket: "color" }, to: { node: "add", socket: "a" } },
30
30
  { from: { node: "emission", socket: "color" }, to: { node: "add", socket: "b" } },
@@ -73,7 +73,7 @@ export const FACE_GRAPH: ShaderGraph = {
73
73
  {
74
74
  id: "principled",
75
75
  type: "principled",
76
- inputs: { metallic: 0.0, specular: 0.5, roughness: 0.3, spec_clamp: 10.0, sheen: 0.0, sheen_tint: 0.0 },
76
+ inputs: { metallic: 0.0, specular_ior_level: 0.5, roughness: 0.3, spec_clamp: 10.0, sheen_weight: 0.0, sheen_tint: 0.0 },
77
77
  },
78
78
  { id: "p_sum", type: "add_shader" },
79
79
 
@@ -124,7 +124,7 @@ export const FACE_GRAPH: ShaderGraph = {
124
124
  { from: { node: "noise_ramp", socket: "fac_out" }, to: { node: "principled_base", socket: "fac" } },
125
125
  { from: { node: "bc", socket: "color" }, to: { node: "principled_base", socket: "a" } },
126
126
  { from: { node: "bc", socket: "color" }, to: { node: "p_emit", socket: "color" } },
127
- { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base" } },
127
+ { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base_color" } },
128
128
  { from: { node: "bump", socket: "vector" }, to: { node: "principled", socket: "normal" } },
129
129
  { from: { node: "principled", socket: "color" }, to: { node: "p_sum", socket: "a" } },
130
130
  { from: { node: "p_emit", socket: "color" }, to: { node: "p_sum", socket: "b" } },
@@ -44,7 +44,7 @@ export const HAIR_GRAPH: ShaderGraph = {
44
44
  {
45
45
  id: "principled",
46
46
  type: "principled",
47
- inputs: { metallic: 0.0, specular: 1.0, roughness: 0.3, spec_clamp: 10.0, sheen: 0.0, sheen_tint: 0.0 },
47
+ inputs: { metallic: 0.0, specular_ior_level: 1.0, roughness: 0.3, spec_clamp: 10.0, sheen_weight: 0.0, sheen_tint: 0.0 },
48
48
  },
49
49
  { id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.2 } },
50
50
  ],
@@ -71,7 +71,7 @@ export const HAIR_GRAPH: ShaderGraph = {
71
71
  { from: { node: "gate", socket: "value" }, to: { node: "gate_scale", socket: "a" } },
72
72
  { from: { node: "mix_shader_002", socket: "color" }, to: { node: "npr_add", socket: "a" } },
73
73
  { from: { node: "gate_scale", socket: "value" }, to: { node: "npr_add", socket: "b" } },
74
- { from: { node: "bc", socket: "color" }, to: { node: "principled", socket: "base" } },
74
+ { from: { node: "bc", socket: "color" }, to: { node: "principled", socket: "base_color" } },
75
75
  { from: { node: "npr_add", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
76
76
  { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
77
77
  ],
@@ -30,7 +30,7 @@ export const METAL_GRAPH: ShaderGraph = {
30
30
  {
31
31
  id: "principled",
32
32
  type: "principled",
33
- inputs: { metallic: 1.0, specular: 1.0, roughness: 0.3, spec_clamp: 1e30, sheen: 0.0, sheen_tint: 0.0 },
33
+ inputs: { metallic: 1.0, specular_ior_level: 1.0, roughness: 0.3, spec_clamp: 1e30, sheen_weight: 0.0, sheen_tint: 0.0 },
34
34
  },
35
35
  { id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.6967 } },
36
36
  ],
@@ -53,7 +53,7 @@ export const METAL_GRAPH: ShaderGraph = {
53
53
  { from: { node: "voro_ramp", socket: "fac_out" }, to: { node: "albedo", socket: "fac" } },
54
54
  { from: { node: "voro_ramp", socket: "fac_out" }, to: { node: "albedo", socket: "a" } },
55
55
  { from: { node: "hue_006", socket: "color" }, to: { node: "albedo", socket: "b" } },
56
- { from: { node: "albedo", socket: "color" }, to: { node: "principled", socket: "base" } },
56
+ { from: { node: "albedo", socket: "color" }, to: { node: "principled", socket: "base_color" } },
57
57
  { from: { node: "npr_emit", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
58
58
  { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
59
59
  ],
@@ -29,10 +29,10 @@ export const STOCKINGS_GRAPH: ShaderGraph = {
29
29
  type: "principled",
30
30
  inputs: {
31
31
  metallic: 0.1,
32
- specular: 1.0,
32
+ specular_ior_level: 1.0,
33
33
  roughness: 0.5,
34
34
  spec_clamp: 1e30,
35
- sheen: 0.7017999887466431,
35
+ sheen_weight: 0.7017999887466431,
36
36
  sheen_tint: 0.5,
37
37
  },
38
38
  },
@@ -48,7 +48,7 @@ export const STOCKINGS_GRAPH: ShaderGraph = {
48
48
  { from: { node: "mix_001", socket: "color" }, to: { node: "mask", socket: "a" } },
49
49
  { from: { node: "ramp_002", socket: "fac_out" }, to: { node: "mask", socket: "b" } },
50
50
  { from: { node: "tex", socket: "color" }, to: { node: "emission_hs", socket: "color" } },
51
- { from: { node: "tex", socket: "color" }, to: { node: "principled", socket: "base" } },
51
+ { from: { node: "tex", socket: "color" }, to: { node: "principled", socket: "base_color" } },
52
52
  { from: { node: "emission_hs", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
53
53
  { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
54
54
  { from: { node: "mask", socket: "color" }, to: { node: "mix_shader_001", socket: "fac" } },