reze-engine 0.41.0 → 0.41.2

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 (48) hide show
  1. package/README.md +4 -2
  2. package/dist/engine.d.ts +31 -3
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +190 -18
  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 +426 -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 +1 -1
  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 -576
  29. package/dist/shaders/passes/composite.d.ts.map +1 -1
  30. package/dist/shaders/passes/composite.js +76 -4
  31. package/package.json +1 -1
  32. package/src/engine.ts +211 -17
  33. package/src/graph/presets/body.ts +2 -2
  34. package/src/graph/presets/cloth_rough.ts +2 -2
  35. package/src/graph/presets/cloth_smooth.ts +2 -2
  36. package/src/graph/presets/default.ts +2 -2
  37. package/src/graph/presets/eye.ts +2 -2
  38. package/src/graph/presets/face.ts +2 -2
  39. package/src/graph/presets/hair.ts +2 -2
  40. package/src/graph/presets/metal.ts +2 -2
  41. package/src/graph/presets/stockings.ts +3 -3
  42. package/src/graph/registry.ts +439 -10
  43. package/src/graph/style-group.ts +44 -0
  44. package/src/index.ts +2 -0
  45. package/src/shaders/agx-lut.ts +30 -0
  46. package/src/shaders/materials/common.ts +10 -0
  47. package/src/shaders/materials/nodes.ts +962 -581
  48. package/src/shaders/passes/composite.ts +76 -4
@@ -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;AA4XD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAclF;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
@@ -23,7 +23,7 @@ override APPLY_GAMMA: bool = true;
23
23
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
24
24
  @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
25
25
  @group(0) @binding(2) var bloomSamp: sampler;
26
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 11>;
26
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 15>;
27
27
  // Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
28
28
  // .g = accumulated canvas alpha (what hdr.a carried before the HDR format
29
29
  // became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
@@ -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,12 +113,82 @@ 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
 
116
171
  /** The camera's world position. */
117
172
  fn bgCameraPos() -> vec3f { return viewU[10].xyz; }
118
173
 
174
+ /** How many characters are in the scene, up to four. */
175
+ fn bgSubjectCount() -> i32 { return i32(viewU[10].w); }
176
+
177
+ /**
178
+ * Where a character is standing, in world space.
179
+ *
180
+ * An effect that wants to RESPOND to the cast — ripples under the feet, a glow
181
+ * that follows someone, dust kicked up where they are — needs to know where
182
+ * they are, and the ray and the depth cannot tell it: they describe the pixel,
183
+ * not the scene. This is the model's root, which for a PMX is between the feet
184
+ * on the floor, so it is already the contact point a ripple wants.
185
+ *
186
+ * Clamped rather than bounds-checked: an effect looping past the count reads the
187
+ * last subject instead of sampling whatever follows the array, which is a wrong
188
+ * ripple rather than an undefined one.
189
+ */
190
+ fn bgSubjectPos(i: i32) -> vec3f { return viewU[11 + clamp(i, 0, 3)].xyz; }
191
+
119
192
  /** Where in the WORLD the scene drew this pixel — the depth handed to
120
193
  * foreground() turned into a place. Without it an effect can only think in
121
194
  * distances from the lens, which is no use to anything that belongs somewhere:
@@ -222,8 +295,7 @@ const COMPOSITE_BODY = /* wgsl */ `
222
295
  let sceneStraight = scenePm.rgb / max(sceneAlpha, 1e-6);
223
296
 
224
297
  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));
298
+ var disp = max(viewTransform(exposed), vec3f(0.0));
227
299
  // Grade the SCENE only, before the display gamma. Deliberately not applied to
228
300
  // the background: it keeps a picked background color exactly as picked, and —
229
301
  // 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.2",
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. */
@@ -218,6 +232,14 @@ export type WorldOptions = {
218
232
 
219
233
  /** A model's scene placement — root offset baked into skinning + visibility. Serializable
220
234
  * into a scene descriptor via getModelTransform. */
235
+ /** How many character positions an effect can read (viewU[11..14]). */
236
+ const MAX_EFFECT_SUBJECTS = 4
237
+ /** Where a character IS, for an effect that follows them. センター carries a
238
+ * motion's root movement — walking, jumping — where the model transform only
239
+ * carries where the model was placed; 全ての親 is the fallback for a model that
240
+ * animates the true root instead. */
241
+ const SUBJECT_BONES = ["センター", "全ての親"]
242
+
221
243
  export type ModelTransform = {
222
244
  position: Vec3
223
245
  rotation: Quat
@@ -320,7 +342,17 @@ export type ViewTransformOptions = {
320
342
  exposure: number
321
343
  /** After Filmic, display gamma (`pow(rgb, 1/gamma)`). */
322
344
  gamma: number
323
- look: "default" | "medium_high_contrast"
345
+ /**
346
+ * Which display transform the frame is formed with.
347
+ *
348
+ * "standard" is Blender's Standard: the sRGB encoding and nothing else, which
349
+ * is what NPR and anime work uses — the colours the graph computes are the
350
+ * colours that land, with no film curve reinterpreting them. Both of the
351
+ * reference Wuthering Waves projects render this way.
352
+ *
353
+ * "filmic" is Blender 3.6's Filmic, Medium High Contrast, baked as a LUT.
354
+ */
355
+ transform: "agx" | "filmic" | "standard"
324
356
  }
325
357
 
326
358
  // Matches the reference Blender project: Filmic view, Medium High Contrast look,
@@ -328,7 +360,7 @@ export type ViewTransformOptions = {
328
360
  export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
329
361
  exposure: 0.6,
330
362
  gamma: 1.0,
331
- look: "medium_high_contrast",
363
+ transform: "filmic",
332
364
  }
333
365
 
334
366
  /** Color grading applied to the tonemapped scene (ASC CDL — see grade() in
@@ -778,7 +810,7 @@ export class Engine {
778
810
  // 11 × vec4f — see the viewU comment in composite.ts. The last one is the
779
811
  // camera's world position, which is what lets a foreground effect turn the
780
812
  // depth it is handed into a PLACE (bgWorldPos) rather than a distance.
781
- private readonly compositeUniformData = new Float32Array(44)
813
+ private readonly compositeUniformData = new Float32Array(60)
782
814
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
783
815
  private backgroundColor: Vec3 | null = null
784
816
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
@@ -802,6 +834,8 @@ export class Engine {
802
834
  * STORE its depth, which it otherwise discards into tile memory. */
803
835
  hasForeground: boolean
804
836
  } | null = null
837
+ private agxLutTexture: GPUTexture | null = null
838
+ private agxFallbackTexture!: GPUTexture
805
839
  /** Bound at composite binding 7 when no effect (or a param-less one) is set. */
806
840
  private bgParamsDummyBuffer!: GPUBuffer
807
841
  private compositePipelineLayout!: GPUPipelineLayout
@@ -981,7 +1015,7 @@ export class Engine {
981
1015
  return {
982
1016
  exposure: partial?.exposure ?? d.exposure,
983
1017
  gamma: partial?.gamma ?? d.gamma,
984
- look: partial?.look ?? d.look,
1018
+ transform: partial?.transform ?? d.transform,
985
1019
  }
986
1020
  }
987
1021
 
@@ -1001,7 +1035,7 @@ export class Engine {
1001
1035
 
1002
1036
  getViewTransformOptions(): ViewTransformOptions {
1003
1037
  const v = this.viewTransform
1004
- return { exposure: v.exposure, gamma: v.gamma, look: v.look }
1038
+ return { exposure: v.exposure, gamma: v.gamma, transform: v.transform }
1005
1039
  }
1006
1040
 
1007
1041
  private colorGrading: ColorGradingOptions = {
@@ -1044,7 +1078,7 @@ export class Engine {
1044
1078
  const v = this.viewTransform
1045
1079
  if (patch.exposure !== undefined) v.exposure = patch.exposure
1046
1080
  if (patch.gamma !== undefined) v.gamma = patch.gamma
1047
- if (patch.look !== undefined) v.look = patch.look
1081
+ if (patch.transform !== undefined) v.transform = patch.transform
1048
1082
  if (this.device && this.compositeUniformBuffer) {
1049
1083
  this.writeCompositeViewUniforms()
1050
1084
  }
@@ -1076,8 +1110,10 @@ export class Engine {
1076
1110
  u[10] = bg?.z ?? 0
1077
1111
  // Base-layer mode only. A user effect is a separate LAYER over whichever
1078
1112
  // 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.
1113
+ // rebuilt per effect, so the compiled variant IS the flag.
1080
1114
  u[11] = this.backdropEquirectView ? 2 : bg ? 1 : 0
1115
+ // Which display transform forms the frame (see viewTransform in composite.ts).
1116
+ u[25] = v.transform === "agx" ? 2 : v.transform === "standard" ? 1 : 0
1081
1117
  u[26] = this.canvas.width
1082
1118
  u[27] = this.canvas.height
1083
1119
  // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
@@ -1148,6 +1184,7 @@ export class Engine {
1148
1184
  { binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
1149
1185
  { binding: 8, resource: this.depthReadView },
1150
1186
  { binding: 9, resource: { buffer: this.dofUniformBuffer } },
1187
+ { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
1151
1188
  ],
1152
1189
  })
1153
1190
  }
@@ -1638,6 +1675,37 @@ export class Engine {
1638
1675
  // smooth gradients) while still passing through every anchor (look preserved) and staying
1639
1676
  // monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
1640
1677
  // at t=k, k=0..13 (t = log2(linear)+10). See composite.ts::filmic for the sampling map.
1678
+ /**
1679
+ * Decompress and upload Blender's AgX cube.
1680
+ *
1681
+ * Deliberately off the critical path: it is 723 KB once inflated, and a frame
1682
+ * rendered before it lands should show the scene under whatever transform is
1683
+ * already there rather than wait. Until then binding 10 holds a 1×1×1 stand-in,
1684
+ * which is only ever sampled if someone selects AgX in that window.
1685
+ */
1686
+ private async loadAgxLut(): Promise<void> {
1687
+ try {
1688
+ const packed = Uint8Array.from(atob(AGX_LUT_GZ), (ch) => ch.charCodeAt(0))
1689
+ const stream = new Blob([packed]).stream().pipeThrough(new DecompressionStream("gzip"))
1690
+ const bytes = new Uint8Array(await new Response(stream).arrayBuffer())
1691
+ const n = AGX_LUT_SIZE
1692
+ if (bytes.byteLength !== n * n * n * 4) throw new Error(`AgX LUT is ${bytes.byteLength} bytes, expected ${n ** 3 * 4}`)
1693
+ const tex = this.device.createTexture({
1694
+ label: "AgX 57³ LUT",
1695
+ size: [n, n, n],
1696
+ dimension: "3d",
1697
+ format: "rgb10a2unorm",
1698
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
1699
+ })
1700
+ // .cube order is red fastest, which is exactly a 3D texture's own layout.
1701
+ this.device.queue.writeTexture({ texture: tex }, bytes, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n])
1702
+ this.agxLutTexture = tex
1703
+ this.rebuildCompositeBindGroup()
1704
+ } catch {
1705
+ // A missing LUT costs AgX, not the renderer — the other transforms stand.
1706
+ }
1707
+ }
1708
+
1641
1709
  private bakeFilmicLut() {
1642
1710
  const anchors = [
1643
1711
  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 +1979,13 @@ export class Engine {
1911
1979
  // StyleUniforms for compiled graph shaders (adjust-tier sliders). Hand-written
1912
1980
  // presets simply don't declare it — a layout may carry bindings a shader ignores.
1913
1981
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1982
+ // Style-group image maps. A PMX material carries one image; a
1983
+ // Blender-authored look needs a lightmap or ramp beside it, and those
1984
+ // belong to the GROUP rather than to the model's own material data.
1985
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1986
+ { binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1987
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1988
+ { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1914
1989
  ],
1915
1990
  })
1916
1991
 
@@ -2043,6 +2118,14 @@ export class Engine {
2043
2118
 
2044
2119
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
2045
2120
  this.bakeBrdfLut()
2121
+ this.agxFallbackTexture = this.device.createTexture({
2122
+ label: "AgX LUT fallback",
2123
+ size: [1, 1, 1],
2124
+ dimension: "3d",
2125
+ format: "rgb10a2unorm",
2126
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
2127
+ })
2128
+ void this.loadAgxLut()
2046
2129
  this.bakeFilmicLut()
2047
2130
 
2048
2131
  // Now that shadow resources exist, create the main per-frame bind group
@@ -2337,8 +2420,9 @@ export class Engine {
2337
2420
  // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
2338
2421
  // (time, _, canvas width, canvas height) for user effects · three grade
2339
2422
  // vectors (CDL offset+contrast, power+saturation, slope+flag) · camera
2340
- // world position, for an effect placing itself in the scene.
2341
- size: 176,
2423
+ // world position, for an effect placing itself in the scene · four
2424
+ // character positions, for one that wants to respond to the cast.
2425
+ size: 240,
2342
2426
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2343
2427
  })
2344
2428
  this.dofUniformBuffer = this.device.createBuffer({
@@ -2378,6 +2462,9 @@ export class Engine {
2378
2462
  texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
2379
2463
  },
2380
2464
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2465
+ // AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
2466
+ // 1×1×1 stand-in keeps the bind group valid until it arrives.
2467
+ { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
2381
2468
  ],
2382
2469
  })
2383
2470
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -3293,7 +3380,7 @@ export class Engine {
3293
3380
  // explicit destroy). Per-model group buffers are torn down in removeModel; the shared
3294
3381
  // zero buffer is engine-owned.
3295
3382
  this.forEachInstance((inst) => {
3296
- for (const install of inst.styleGroups.values()) install.uniformBuffer.destroy()
3383
+ for (const install of inst.styleGroups.values()) this.destroyInstall(install)
3297
3384
  inst.styleGroups.clear()
3298
3385
  })
3299
3386
  this.zeroStyleBuffer?.destroy()
@@ -3442,7 +3529,7 @@ export class Engine {
3442
3529
  buf.destroy()
3443
3530
  }
3444
3531
  // Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
3445
- for (const install of inst.styleGroups.values()) install.uniformBuffer.destroy()
3532
+ for (const install of inst.styleGroups.values()) this.destroyInstall(install)
3446
3533
  this.modelInstances.delete(name)
3447
3534
  }
3448
3535
 
@@ -3524,11 +3611,58 @@ export class Engine {
3524
3611
 
3525
3612
  // Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
3526
3613
  // (the group's buffer when grouped, or the shared zero buffer when ungrouped).
3527
- private createMaterialBindGroup(label: string, baseEntries: GPUBindGroupEntry[], styleBuffer: GPUBuffer): GPUBindGroup {
3614
+ /** A group's uniform buffer and its maps have the same lifetime — freeing one
3615
+ * without the other is how a re-apply leaks GPU memory a frame at a time. */
3616
+ private destroyInstall(install: GroupInstall): void {
3617
+ install.uniformBuffer.destroy()
3618
+ for (const tex of install.images ?? []) tex?.destroy()
3619
+ }
3620
+
3621
+ /** Upload a group's image maps. Sources are decoded images the host already
3622
+ * holds; the engine never fetches, matching how models and motions arrive. */
3623
+ private uploadGroupImages(group: StyleGroup): (GPUTexture | null)[] | undefined {
3624
+ if (!group.images?.length) return undefined
3625
+ return group.images.slice(0, 4).map((entry) => {
3626
+ if (!entry) return null
3627
+ const wrapped = "source" in entry
3628
+ const src = wrapped ? entry.source : entry
3629
+ const width = Math.max(1, "naturalWidth" in src ? src.naturalWidth : src.width)
3630
+ const height = Math.max(1, "naturalHeight" in src ? src.naturalHeight : src.height)
3631
+ const tex = this.device.createTexture({
3632
+ label: `group map: ${group.id}`,
3633
+ size: [width, height],
3634
+ // Colour maps decode to linear on sample, the way material textures do;
3635
+ // data maps must not, or every threshold packed in their channels moves.
3636
+ format: wrapped && entry.srgb ? "rgba8unorm-srgb" : "rgba8unorm",
3637
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
3638
+ })
3639
+ this.device.queue.copyExternalImageToTexture(
3640
+ { source: src },
3641
+ { texture: tex, premultipliedAlpha: wrapped && entry.premultiplied === true },
3642
+ [width, height],
3643
+ )
3644
+ return tex
3645
+ })
3646
+ }
3647
+
3648
+ private createMaterialBindGroup(
3649
+ label: string,
3650
+ baseEntries: GPUBindGroupEntry[],
3651
+ styleBuffer: GPUBuffer,
3652
+ groupImages?: (GPUTexture | null)[],
3653
+ ): GPUBindGroup {
3654
+ // Every material bind group in the engine is built here, which is why the
3655
+ // group's maps are threaded through this one function rather than patched in
3656
+ // at each call site — an unset slot reads white, never stale.
3657
+ const slots: GPUBindGroupEntry[] = []
3658
+ for (let i = 0; i < 4; i++) {
3659
+ const tex = groupImages?.[i] ?? this.fallbackMaterialTexture
3660
+ slots.push({ binding: 5 + i, resource: tex.createView() })
3661
+ }
3528
3662
  return this.device.createBindGroup({
3529
3663
  label,
3530
3664
  layout: this.mainPerMaterialBindGroupLayout,
3531
- entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }],
3665
+ entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }, ...slots],
3532
3666
  })
3533
3667
  }
3534
3668
 
@@ -5500,7 +5634,7 @@ export class Engine {
5500
5634
  for (const [id, install] of inst.styleGroups) {
5501
5635
  if (!nextIds.has(id)) {
5502
5636
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1)
5503
- install.uniformBuffer.destroy()
5637
+ this.destroyInstall(install)
5504
5638
  inst.styleGroups.delete(id)
5505
5639
  }
5506
5640
  }
@@ -5537,7 +5671,7 @@ export class Engine {
5537
5671
  const install = inst?.styleGroups.get(groupId)
5538
5672
  if (!inst || !install) return
5539
5673
  inst.styleGroupGen.set(groupId, (inst.styleGroupGen.get(groupId) ?? 0) + 1) // discard in-flight compile
5540
- install.uniformBuffer.destroy()
5674
+ this.destroyInstall(install)
5541
5675
  inst.styleGroups.delete(groupId)
5542
5676
  this.assignDrawCallGroups(inst, this.currentClaims(inst))
5543
5677
  }
@@ -5548,7 +5682,7 @@ export class Engine {
5548
5682
  if (!inst) return
5549
5683
  for (const [id, install] of inst.styleGroups) {
5550
5684
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1)
5551
- install.uniformBuffer.destroy()
5685
+ this.destroyInstall(install)
5552
5686
  }
5553
5687
  inst.styleGroups.clear()
5554
5688
  this.assignDrawCallGroups(inst, new Map())
@@ -5648,6 +5782,9 @@ export class Engine {
5648
5782
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
5649
5783
  })
5650
5784
 
5785
+ // The outgoing install's maps go with it — a re-apply that changes images
5786
+ // would otherwise strand the old textures for the life of the model.
5787
+ const previousImages = inst.styleGroups.get(group.id)?.images
5651
5788
  inst.styleGroups.set(group.id, {
5652
5789
  group,
5653
5790
  renderClass,
@@ -5656,9 +5793,31 @@ export class Engine {
5656
5793
  pipelineNoDepthWrite,
5657
5794
  overEyesPipeline,
5658
5795
  uniformBuffer,
5796
+ images: this.uploadGroupImages(group),
5659
5797
  slotMap: result.slotMap,
5660
5798
  signature,
5661
5799
  })
5800
+ // Rebind this group's draw calls before the old textures go.
5801
+ //
5802
+ // assignDrawCallGroups only rebuilds a bind group when a material CHANGES
5803
+ // group, which is the wrong test here: swapping the graph on a group a
5804
+ // material already belongs to leaves its id alone while replacing the maps
5805
+ // underneath it. The draw call then kept a bind group holding the outgoing
5806
+ // textures — destroyed on the next line — so a graph swap either sampled the
5807
+ // old maps, or the fallback white where the previous graph had none (which
5808
+ // reads as a blown-out white material through any screen or add), or tripped
5809
+ // a validation error on a destroyed texture. Only a reload cleared it.
5810
+ const install = inst.styleGroups.get(group.id)
5811
+ for (const dc of inst.drawCalls) {
5812
+ if (!dc.baseBindGroupEntries || dc.groupId !== group.id) continue
5813
+ dc.bindGroup = this.createMaterialBindGroup(
5814
+ `material: ${dc.materialName}`,
5815
+ dc.baseBindGroupEntries,
5816
+ uniformBuffer,
5817
+ install?.images,
5818
+ )
5819
+ }
5820
+ for (const tex of previousImages ?? []) tex?.destroy()
5662
5821
  this.writeGroupDefaults(uniformBuffer, group, result.slotMap)
5663
5822
  return { ok: true, diagnostics, slotMap: result.slotMap }
5664
5823
  }
@@ -5679,6 +5838,7 @@ export class Engine {
5679
5838
  `material: ${dc.materialName}`,
5680
5839
  dc.baseBindGroupEntries,
5681
5840
  install ? install.uniformBuffer : this.zeroStyleBuffer,
5841
+ install?.images,
5682
5842
  )
5683
5843
  }
5684
5844
  this.sortDrawCalls(inst)
@@ -5971,6 +6131,40 @@ export class Engine {
5971
6131
  u[40] = cameraPos.x
5972
6132
  u[41] = cameraPos.y
5973
6133
  u[42] = cameraPos.z
6134
+ // Character positions (viewU[11..14]), count in viewU[10].w. Stages are
6135
+ // excluded: an effect asking where the cast is means the characters, and a
6136
+ // stage's origin is wherever its author put it, which is not a place
6137
+ // anything is standing. Four is the cap because the uniform is small and a
6138
+ // scene with five characters is not the case this serves.
6139
+ let n = 0
6140
+ this.forEachInstance((inst) => {
6141
+ if (n >= MAX_EFFECT_SUBJECTS || inst.isStage) return
6142
+ const m = inst.model
6143
+ // The model transform is only where the model was PLACED. A motion moves
6144
+ // the character by animating bones, so an effect anchored to the
6145
+ // transform never follows anyone anywhere — it sits at the spawn point
6146
+ // while they walk out of it. Composed exactly as the follow camera
6147
+ // composes it, for the same reason: bone matrices are model-space.
6148
+ let px = m.position.x
6149
+ let py = m.position.y
6150
+ let pz = m.position.z
6151
+ for (const bone of SUBJECT_BONES) {
6152
+ const pos = m.getBoneWorldPosition(bone)
6153
+ if (!pos) continue
6154
+ const sc = m.scale
6155
+ pos.setXYZ(pos.x * sc, pos.y * sc, pos.z * sc)
6156
+ Quat.rotateVecInto(m.rotation, pos, pos)
6157
+ px += pos.x
6158
+ py += pos.y
6159
+ pz += pos.z
6160
+ break
6161
+ }
6162
+ u[44 + n * 4] = px
6163
+ u[45 + n * 4] = py
6164
+ u[46 + n * 4] = pz
6165
+ n++
6166
+ })
6167
+ u[43] = n
5974
6168
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
5975
6169
  }
5976
6170
  }
@@ -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" } },