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/dist/engine.js CHANGED
@@ -17,6 +17,7 @@ import { TRANSPARENT_DEPTH_PREPASS_WGSL } from "./shaders/passes/depth-prepass";
17
17
  import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection";
18
18
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
19
19
  import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
20
+ import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
20
21
  import { buildCompositeShader } from "./shaders/passes/composite";
21
22
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
22
23
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
@@ -46,7 +47,17 @@ const PRESET_NAME_HINTS = [
46
47
  "face",
47
48
  ["顔", "颜", "顏", "脸", "臉", "かお", "face", "舌", "tongue", "牙", "牙齿", "齿", "歯", "teeth", "tooth", "口腔", "口内", "mouth", "嘴", "唇", "歯茎", "gums"],
48
49
  ],
49
- ["hair", ["前髪", "後髪", "髪", "髮", "头发", "頭髪", "もみあげ", "アホ毛", "ヘア", "hair", "ahoge", "bang"]],
50
+ // Simplified is listed as compounds, never bare: it also writes 发光 (glow),
51
+ // and hair carries a renderClass, so a chance hit puts an emissive panel in the
52
+ // hair pass. Same reasoning as bare 口 being omitted from face above.
53
+ [
54
+ "hair",
55
+ [
56
+ "前髪", "後髪", "髪", "髮", "頭髪", "もみあげ", "アホ毛", "ヘア",
57
+ "头发", "前发", "后发", "长发", "短发", "发丝", "刘海", "辫", "马尾",
58
+ "hair", "ahoge", "bang",
59
+ ],
60
+ ],
50
61
  ["body", ["肌", "皮肤", "skin"]],
51
62
  ["metal", ["金属", "メタル", "metal", "earring", "耳环", "耳環"]],
52
63
  [
@@ -159,7 +170,7 @@ export const DEFAULT_DEPTH_OF_FIELD_OPTIONS = {
159
170
  export const DEFAULT_VIEW_TRANSFORM = {
160
171
  exposure: 0.6,
161
172
  gamma: 1.0,
162
- look: "medium_high_contrast",
173
+ transform: "filmic",
163
174
  };
164
175
  const NEUTRAL_GRADE_CHANNEL = 0.5;
165
176
  export const DEFAULT_COLOR_GRADING = {
@@ -302,19 +313,24 @@ export class Engine {
302
313
  /** Depth-only view of the scene's MSAA depth buffer, read by the DoF gather. */
303
314
  this.depthReadView = null;
304
315
  // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
305
- this.compositeUniformData = new Float32Array(40);
316
+ // 11 × vec4f — see the viewU comment in composite.ts. The last one is the
317
+ // camera's world position, which is what lets a foreground effect turn the
318
+ // depth it is handed into a PLACE (bgWorldPos) rather than a distance.
319
+ this.compositeUniformData = new Float32Array(44);
306
320
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
307
321
  this.backgroundColor = null;
308
322
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
309
323
  this.backdropEquirectTexture = null;
310
324
  this.backdropEquirectView = null;
311
- // User WGSL background effect (background mode 3, setBackgroundEffect). The
312
- // composite pipelines are REBUILT with the user code injected; params live in
313
- // their own uniform buffer so setBackgroundEffectParam is a write, not a
325
+ // The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
326
+ // scene, over it, or both whichever of background()/foreground() the code
327
+ // defines. The composite pipelines are REBUILT with the user code injected;
328
+ // params live in their own uniform buffer so setEffectParam is a write, not a
314
329
  // recompile (the same instant tier as setStyleParam).
315
- this.backgroundEffect = null;
316
- /** time=0 origin for the active effect — reset each setBackgroundEffect. */
317
- this.bgEffectEpochMs = 0;
330
+ this.effect = null;
331
+ this.agxLutTexture = null;
332
+ /** time=0 origin for the active effect — reset each setEffect. */
333
+ this.effectEpochMs = 0;
318
334
  this.compositeBloomView = null;
319
335
  this.bloomBlitUniformData = new Float32Array(4);
320
336
  this.bloomUpsampleUniformData = new Float32Array(4);
@@ -649,7 +665,7 @@ export class Engine {
649
665
  return {
650
666
  exposure: partial?.exposure ?? d.exposure,
651
667
  gamma: partial?.gamma ?? d.gamma,
652
- look: partial?.look ?? d.look,
668
+ transform: partial?.transform ?? d.transform,
653
669
  };
654
670
  }
655
671
  /** Current bloom settings (Blender names; tint is a copied `Vec3`). */
@@ -667,7 +683,7 @@ export class Engine {
667
683
  }
668
684
  getViewTransformOptions() {
669
685
  const v = this.viewTransform;
670
- return { exposure: v.exposure, gamma: v.gamma, look: v.look };
686
+ return { exposure: v.exposure, gamma: v.gamma, transform: v.transform };
671
687
  }
672
688
  /**
673
689
  * Color-grade the tonemapped scene (ASC CDL slope/offset/power + saturation).
@@ -707,8 +723,8 @@ export class Engine {
707
723
  v.exposure = patch.exposure;
708
724
  if (patch.gamma !== undefined)
709
725
  v.gamma = patch.gamma;
710
- if (patch.look !== undefined)
711
- v.look = patch.look;
726
+ if (patch.transform !== undefined)
727
+ v.transform = patch.transform;
712
728
  if (this.device && this.compositeUniformBuffer) {
713
729
  this.writeCompositeViewUniforms();
714
730
  }
@@ -737,10 +753,12 @@ export class Engine {
737
753
  u[8] = bg?.x ?? 0;
738
754
  u[9] = bg?.y ?? 0;
739
755
  u[10] = bg?.z ?? 0;
740
- // Base-layer mode; a user effect is a separate LAYER flagged at u[25] and
741
- // over-composited onto whichever base is active.
756
+ // Base-layer mode only. A user effect is a separate LAYER over whichever
757
+ // base is active, and needs no flag of its own: the composite pipeline is
758
+ // rebuilt per effect, so the compiled variant IS the flag.
742
759
  u[11] = this.backdropEquirectView ? 2 : bg ? 1 : 0;
743
- u[25] = this.backgroundEffect ? 1 : 0;
760
+ // Which display transform forms the frame (see viewTransform in composite.ts).
761
+ u[25] = v.transform === "agx" ? 2 : v.transform === "standard" ? 1 : 0;
744
762
  u[26] = this.canvas.width;
745
763
  u[27] = this.canvas.height;
746
764
  // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
@@ -799,9 +817,10 @@ export class Engine {
799
817
  { binding: 4, resource: this.maskResolveView },
800
818
  { binding: 5, resource: this.filmicLutView },
801
819
  { binding: 6, resource: this.backdropEquirectView ?? this.fallbackEquirectView },
802
- { binding: 7, resource: { buffer: this.backgroundEffect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
820
+ { binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
803
821
  { binding: 8, resource: this.depthReadView },
804
822
  { binding: 9, resource: { buffer: this.dofUniformBuffer } },
823
+ { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
805
824
  ],
806
825
  });
807
826
  }
@@ -864,42 +883,71 @@ export class Engine {
864
883
  });
865
884
  }
866
885
  /**
867
- * Install a WGSL background effect (shadertoy-style) as a LAYER between the
868
- * base background and the scene: rendered per-pixel in the composite pass and
869
- * over-composited onto whichever base is active (solid color, 360 equirect,
870
- * or transparency) — its alpha lets the base show through, so a starfield is
871
- * stars over the user's background color. Display-space: never affects
872
- * lighting, bloom, or tonemapping, and is captured by offline export like any
873
- * background.
874
- *
875
- * `wgsl` must define:
886
+ * Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
887
+ * composite pass. ONE effect per scene, and the code says where it mounts by
888
+ * which of these it defines either, or both in one file:
876
889
  *
877
890
  * fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
891
+ * fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
892
+ *
893
+ * `background` is a LAYER between the base background and the scene,
894
+ * over-composited onto whichever base is active (solid color, 360 equirect, or
895
+ * transparency) — its alpha lets the base show through, so a starfield is
896
+ * stars over the user's background color. `foreground` composites over the
897
+ * finished frame instead, which is where rain, snow, petals and fog live, and
898
+ * is handed `depth`: the camera-space distance in metres of whatever the scene
899
+ * drew at that pixel (the far plane where it drew nothing). Compare a
900
+ * particle's own distance against it and the model occludes it; fog just reads
901
+ * it, since fog's alpha IS a function of distance.
878
902
  *
879
- * where `ray` is the pixel's normalized world-space view direction (LH, +Z
880
- * forward — what the skybox samples by), `uv` is 0..1 bottom-left origin,
881
- * `time` is seconds since apply, and `bgResolution()` gives the canvas size.
882
- * Return sRGB + alpha. Declared `params` arrive as `params.<name>` (number
883
- * f32, Vec3 vec3f) and are later tweaked without recompiling via
884
- * setBackgroundEffectParam.
903
+ * `ray` is the pixel's normalized world-space view direction (LH, +Z forward —
904
+ * what the skybox samples by), `uv` is 0..1 bottom-left origin, `time` is
905
+ * seconds since apply, and `bgResolution()` gives the canvas size. Return sRGB
906
+ * + alpha; alpha is the only "how much does this replace" control there is.
907
+ * Declared `params` arrive as `params.<name>` (number f32, Vec3 vec3f),
908
+ * shared by both mounts, and are later tweaked without recompiling via
909
+ * setEffectParam.
885
910
  *
886
- * Compiles off the hot path (async pipelines): on failure the previous
887
- * background is KEPT and diagnostics are returned with line numbers relative
888
- * to the user's WGSL. Pass null to remove the effect.
911
+ * Both mounts are display-space: neither affects lighting, bloom or
912
+ * tonemapping, and both are captured by offline export. A foreground makes the
913
+ * scene pass STORE its depth buffer (it otherwise discards it into tile
914
+ * memory) for as long as one is installed.
915
+ *
916
+ * Compiles off the hot path (async pipelines): on failure the previous effect
917
+ * is KEPT and diagnostics are returned with line numbers relative to the
918
+ * user's WGSL. Pass null to remove the effect.
889
919
  */
890
- async setBackgroundEffect(wgsl, params) {
920
+ async setEffect(wgsl, params) {
921
+ const noMounts = { background: false, foreground: false };
891
922
  if (!this.device)
892
- return { ok: false, diagnostics: ["setBackgroundEffect requires init() to have run"] };
923
+ return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts };
893
924
  if (wgsl === null) {
894
- this.backgroundEffect?.paramsBuffer?.destroy();
895
- this.backgroundEffect = null;
925
+ this.effect?.paramsBuffer?.destroy();
926
+ this.effect = null;
896
927
  const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) });
897
928
  this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)");
898
929
  this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)");
899
930
  this.rebuildCompositeBindGroup();
900
931
  this.writeCompositeViewUniforms();
901
- return { ok: true, diagnostics: [] };
932
+ return { ok: true, diagnostics: [], mounts: noMounts };
902
933
  }
934
+ // ── Which mounts did the author ask for? A declaration, not a setting: the
935
+ // entry points present in the source are the ones compiled in. Matching the
936
+ // `fn` keyword is enough to be safe against a `foreground` LOCAL or a call
937
+ // to one — those never follow `fn`.
938
+ const hasBackground = /\bfn\s+background\s*\(/.test(wgsl);
939
+ const hasForeground = /\bfn\s+foreground\s*\(/.test(wgsl);
940
+ if (!hasBackground && !hasForeground) {
941
+ return {
942
+ ok: false,
943
+ diagnostics: [
944
+ "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f " +
945
+ "or fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f (or both)",
946
+ ],
947
+ mounts: noMounts,
948
+ };
949
+ }
950
+ const mounts = { background: hasBackground, foreground: hasForeground };
903
951
  // ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
904
952
  // Fields are emitted in declaration order; offsets follow WGSL's natural
905
953
  // uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
@@ -910,7 +958,7 @@ export class Engine {
910
958
  let cursor = 0;
911
959
  for (const [name, value] of entries) {
912
960
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
913
- return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`] };
961
+ return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts };
914
962
  }
915
963
  const isVec = typeof value !== "number";
916
964
  const align = isVec ? 16 : 4;
@@ -931,14 +979,14 @@ export class Engine {
931
979
  }
932
980
  }
933
981
  const paramsDecl = entries.length
934
- ? `struct BgParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: BgParams;\n`
982
+ ? `struct EffectParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: EffectParams;\n`
935
983
  : "";
936
984
  // ── Compile with validation captured, not thrown at the console. Line
937
985
  // numbers in diagnostics are rebased to the USER's source.
938
- const source = buildCompositeShader({ wgsl, paramsDecl });
986
+ const source = buildCompositeShader({ wgsl, paramsDecl, hasBackground, hasForeground });
939
987
  const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1;
940
988
  this.device.pushErrorScope("validation");
941
- const module = this.device.createShaderModule({ label: "composite shader (bg effect)", code: source });
989
+ const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source });
942
990
  const info = await module.getCompilationInfo();
943
991
  const scopeErr = await this.device.popErrorScope();
944
992
  const diagnostics = info.messages
@@ -947,7 +995,7 @@ export class Engine {
947
995
  if (diagnostics.length === 0 && scopeErr)
948
996
  diagnostics.push(scopeErr.message);
949
997
  if (diagnostics.length > 0)
950
- return { ok: false, diagnostics };
998
+ return { ok: false, diagnostics, mounts };
951
999
  let identity;
952
1000
  let gamma;
953
1001
  try {
@@ -964,36 +1012,40 @@ export class Engine {
964
1012
  primitive: { topology: "triangle-list" },
965
1013
  });
966
1014
  [identity, gamma] = await Promise.all([
967
- make(false, "composite pipeline (bg effect, gamma=1)"),
968
- make(true, "composite pipeline (bg effect, gamma!=1)"),
1015
+ make(false, "composite pipeline (effect, gamma=1)"),
1016
+ make(true, "composite pipeline (effect, gamma!=1)"),
969
1017
  ]);
970
1018
  }
971
1019
  catch (e) {
972
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] };
1020
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts };
973
1021
  }
974
1022
  // ── Swap — only now does the old effect (and its params buffer) go away.
975
- this.backgroundEffect?.paramsBuffer?.destroy();
1023
+ this.effect?.paramsBuffer?.destroy();
976
1024
  let paramsBuffer = null;
977
1025
  if (entries.length) {
978
1026
  paramsBuffer = this.device.createBuffer({
979
- label: "bg effect params",
1027
+ label: "effect params",
980
1028
  size: paramsData.byteLength,
981
1029
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
982
1030
  });
983
1031
  this.device.queue.writeBuffer(paramsBuffer, 0, paramsData);
984
1032
  }
985
- this.backgroundEffect = { wgsl, paramLayout: layout, paramsBuffer, paramsData };
1033
+ this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground };
986
1034
  this.compositePipelineIdentity = identity;
987
1035
  this.compositePipelineGamma = gamma;
988
- this.bgEffectEpochMs = performance.now();
1036
+ this.effectEpochMs = performance.now();
989
1037
  this.rebuildCompositeBindGroup();
990
1038
  this.writeCompositeViewUniforms();
991
- return { ok: true, diagnostics: [] };
1039
+ return { ok: true, diagnostics: [], mounts };
1040
+ }
1041
+ /** Which mounts the installed effect declared. Both false when none is set. */
1042
+ getEffectMounts() {
1043
+ return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false };
992
1044
  }
993
- /** Write one background-effect param (declared at setBackgroundEffect) — a
994
- * uniform write, no recompile; the instant tier, like setStyleParam. */
995
- setBackgroundEffectParam(name, value) {
996
- const fx = this.backgroundEffect;
1045
+ /** Write one effect param (declared at setEffect) — a uniform write, no
1046
+ * recompile; the instant tier, like setStyleParam. */
1047
+ setEffectParam(name, value) {
1048
+ const fx = this.effect;
997
1049
  if (!fx || !fx.paramsBuffer)
998
1050
  return;
999
1051
  const slot = fx.paramLayout.get(name);
@@ -1073,7 +1125,10 @@ export class Engine {
1073
1125
  return;
1074
1126
  const d = this.depthOfField;
1075
1127
  const u = this.dofUniformData;
1076
- const auto = d.focusMode === "auto" ? this.getModelBodyFocus() : null;
1128
+ // `d.enabled &&`, because a foreground effect also drives this write (for
1129
+ // projA/projB alone) and auto-focus walks every visible character's bones —
1130
+ // work nothing would read with the gather switched off.
1131
+ const auto = d.enabled && d.focusMode === "auto" ? this.getModelBodyFocus() : null;
1077
1132
  u[0] = d.enabled ? 1 : 0;
1078
1133
  u[1] = auto?.distance ?? Math.max(d.focusDistance, 0.05);
1079
1134
  // In auto mode the authored range is a floor — the sharp band never cuts
@@ -1247,6 +1302,38 @@ export class Engine {
1247
1302
  // smooth gradients) while still passing through every anchor (look preserved) and staying
1248
1303
  // monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
1249
1304
  // at t=k, k=0..13 (t = log2(linear)+10). See composite.ts::filmic for the sampling map.
1305
+ /**
1306
+ * Decompress and upload Blender's AgX cube.
1307
+ *
1308
+ * Deliberately off the critical path: it is 723 KB once inflated, and a frame
1309
+ * rendered before it lands should show the scene under whatever transform is
1310
+ * already there rather than wait. Until then binding 10 holds a 1×1×1 stand-in,
1311
+ * which is only ever sampled if someone selects AgX in that window.
1312
+ */
1313
+ async loadAgxLut() {
1314
+ try {
1315
+ const packed = Uint8Array.from(atob(AGX_LUT_GZ), (ch) => ch.charCodeAt(0));
1316
+ const stream = new Blob([packed]).stream().pipeThrough(new DecompressionStream("gzip"));
1317
+ const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
1318
+ const n = AGX_LUT_SIZE;
1319
+ if (bytes.byteLength !== n * n * n * 4)
1320
+ throw new Error(`AgX LUT is ${bytes.byteLength} bytes, expected ${n ** 3 * 4}`);
1321
+ const tex = this.device.createTexture({
1322
+ label: "AgX 57³ LUT",
1323
+ size: [n, n, n],
1324
+ dimension: "3d",
1325
+ format: "rgb10a2unorm",
1326
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
1327
+ });
1328
+ // .cube order is red fastest, which is exactly a 3D texture's own layout.
1329
+ this.device.queue.writeTexture({ texture: tex }, bytes, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n]);
1330
+ this.agxLutTexture = tex;
1331
+ this.rebuildCompositeBindGroup();
1332
+ }
1333
+ catch {
1334
+ // A missing LUT costs AgX, not the renderer — the other transforms stand.
1335
+ }
1336
+ }
1250
1337
  bakeFilmicLut() {
1251
1338
  const anchors = [
1252
1339
  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,
@@ -1492,6 +1579,13 @@ export class Engine {
1492
1579
  // StyleUniforms for compiled graph shaders (adjust-tier sliders). Hand-written
1493
1580
  // presets simply don't declare it — a layout may carry bindings a shader ignores.
1494
1581
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1582
+ // Style-group image maps. A PMX material carries one image; a
1583
+ // Blender-authored look needs a lightmap or ramp beside it, and those
1584
+ // belong to the GROUP rather than to the model's own material data.
1585
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1586
+ { binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1587
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1588
+ { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1495
1589
  ],
1496
1590
  });
1497
1591
  // Shared zero StyleUniforms buffer — bound by every ungrouped material; grouped
@@ -1619,6 +1713,14 @@ export class Engine {
1619
1713
  this.shadowMapDepthView = this.shadowMapTexture.createView();
1620
1714
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
1621
1715
  this.bakeBrdfLut();
1716
+ this.agxFallbackTexture = this.device.createTexture({
1717
+ label: "AgX LUT fallback",
1718
+ size: [1, 1, 1],
1719
+ dimension: "3d",
1720
+ format: "rgb10a2unorm",
1721
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
1722
+ });
1723
+ void this.loadAgxLut();
1622
1724
  this.bakeFilmicLut();
1623
1725
  // Now that shadow resources exist, create the main per-frame bind group
1624
1726
  this.perFrameBindGroup = this.device.createBindGroup({
@@ -1891,11 +1993,12 @@ export class Engine {
1891
1993
  // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
1892
1994
  this.compositeUniformBuffer = this.device.createBuffer({
1893
1995
  label: "composite view uniforms",
1894
- // 10 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1996
+ // 11 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1895
1997
  // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
1896
- // (time, _, canvas width, canvas height) for user background effects ·
1897
- // three grade vectors (CDL offset+contrast, power+saturation, slope+flag).
1898
- size: 160,
1998
+ // (time, _, canvas width, canvas height) for user effects · three grade
1999
+ // vectors (CDL offset+contrast, power+saturation, slope+flag) · camera
2000
+ // world position, for an effect placing itself in the scene.
2001
+ size: 176,
1899
2002
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1900
2003
  });
1901
2004
  this.dofUniformBuffer = this.device.createBuffer({
@@ -1935,6 +2038,9 @@ export class Engine {
1935
2038
  texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
1936
2039
  },
1937
2040
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2041
+ // AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
2042
+ // 1×1×1 stand-in keeps the bind group valid until it arrives.
2043
+ { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
1938
2044
  ],
1939
2045
  });
1940
2046
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -2536,6 +2642,12 @@ export class Engine {
2536
2642
  hasCameraVmd() {
2537
2643
  return this.cameraAnimation !== null;
2538
2644
  }
2645
+ /** Seconds the loaded camera VMD runs for — its last keyframe — or 0 with none
2646
+ * loaded. A timeline cannot draw a lane to scale without it, and the camera's
2647
+ * length is its own: it does not have to match any model's clip. */
2648
+ getCameraVmdDuration() {
2649
+ return this.cameraAnimation?.duration ?? 0;
2650
+ }
2539
2651
  /** Drop the loaded camera VMD and return to orbit control. */
2540
2652
  clearCameraVmd() {
2541
2653
  this.cameraAnimation = null;
@@ -2740,7 +2852,7 @@ export class Engine {
2740
2852
  // zero buffer is engine-owned.
2741
2853
  this.forEachInstance((inst) => {
2742
2854
  for (const install of inst.styleGroups.values())
2743
- install.uniformBuffer.destroy();
2855
+ this.destroyInstall(install);
2744
2856
  inst.styleGroups.clear();
2745
2857
  });
2746
2858
  this.zeroStyleBuffer?.destroy();
@@ -2866,7 +2978,7 @@ export class Engine {
2866
2978
  }
2867
2979
  // Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
2868
2980
  for (const install of inst.styleGroups.values())
2869
- install.uniformBuffer.destroy();
2981
+ this.destroyInstall(install);
2870
2982
  this.modelInstances.delete(name);
2871
2983
  }
2872
2984
  getModelNames() {
@@ -2948,11 +3060,50 @@ export class Engine {
2948
3060
  }
2949
3061
  // Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
2950
3062
  // (the group's buffer when grouped, or the shared zero buffer when ungrouped).
2951
- createMaterialBindGroup(label, baseEntries, styleBuffer) {
3063
+ /** A group's uniform buffer and its maps have the same lifetime — freeing one
3064
+ * without the other is how a re-apply leaks GPU memory a frame at a time. */
3065
+ destroyInstall(install) {
3066
+ install.uniformBuffer.destroy();
3067
+ for (const tex of install.images ?? [])
3068
+ tex?.destroy();
3069
+ }
3070
+ /** Upload a group's image maps. Sources are decoded images the host already
3071
+ * holds; the engine never fetches, matching how models and motions arrive. */
3072
+ uploadGroupImages(group) {
3073
+ if (!group.images?.length)
3074
+ return undefined;
3075
+ return group.images.slice(0, 4).map((entry) => {
3076
+ if (!entry)
3077
+ return null;
3078
+ const wrapped = "source" in entry;
3079
+ const src = wrapped ? entry.source : entry;
3080
+ const width = Math.max(1, "naturalWidth" in src ? src.naturalWidth : src.width);
3081
+ const height = Math.max(1, "naturalHeight" in src ? src.naturalHeight : src.height);
3082
+ const tex = this.device.createTexture({
3083
+ label: `group map: ${group.id}`,
3084
+ size: [width, height],
3085
+ // Colour maps decode to linear on sample, the way material textures do;
3086
+ // data maps must not, or every threshold packed in their channels moves.
3087
+ format: wrapped && entry.srgb ? "rgba8unorm-srgb" : "rgba8unorm",
3088
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
3089
+ });
3090
+ this.device.queue.copyExternalImageToTexture({ source: src }, { texture: tex, premultipliedAlpha: wrapped && entry.premultiplied === true }, [width, height]);
3091
+ return tex;
3092
+ });
3093
+ }
3094
+ createMaterialBindGroup(label, baseEntries, styleBuffer, groupImages) {
3095
+ // Every material bind group in the engine is built here, which is why the
3096
+ // group's maps are threaded through this one function rather than patched in
3097
+ // at each call site — an unset slot reads white, never stale.
3098
+ const slots = [];
3099
+ for (let i = 0; i < 4; i++) {
3100
+ const tex = groupImages?.[i] ?? this.fallbackMaterialTexture;
3101
+ slots.push({ binding: 5 + i, resource: tex.createView() });
3102
+ }
2952
3103
  return this.device.createBindGroup({
2953
3104
  label,
2954
3105
  layout: this.mainPerMaterialBindGroupLayout,
2955
- entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }],
3106
+ entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }, ...slots],
2956
3107
  });
2957
3108
  }
2958
3109
  setMaterialVisible(modelName, materialName, visible) {
@@ -4305,12 +4456,20 @@ export class Engine {
4305
4456
  this.updateCameraUniforms();
4306
4457
  this.updateShadowLightVP();
4307
4458
  // Depth of field's entire disabled cost is this branch: depth stays in
4308
- // TBDR tile memory (discard) unless the composite gather reads it this
4309
- // frame. Enabled frames also refresh the uniforms auto-focus tracks the
4310
- // character and the depth-inversion constants track near/far.
4459
+ // TBDR tile memory (discard) unless something in the composite reads it this
4460
+ // frame. Two things can the DoF gather, and the depth handed to a
4461
+ // foreground effect — and either one makes the pass store it.
4462
+ //
4463
+ // The uniform refresh is shared for the same reason: linearDepth() inverts
4464
+ // the z-buffer with projA/projB out of dofU[2], which track the camera's
4465
+ // near/far and so must be rewritten every frame either reader is live. A
4466
+ // foreground with a stale pair would read metres from the wrong frustum. The
4467
+ // write leaves dofU[0].x at 0 while DoF is off, so refreshing it does not
4468
+ // switch the gather on.
4311
4469
  const dofOn = this.depthOfField.enabled;
4312
- this.renderPassDescriptor.depthStencilAttachment.depthStoreOp = dofOn ? "store" : "discard";
4313
- if (dofOn)
4470
+ const depthRead = dofOn || (this.effect?.hasForeground ?? false);
4471
+ this.renderPassDescriptor.depthStencilAttachment.depthStoreOp = depthRead ? "store" : "discard";
4472
+ if (depthRead)
4314
4473
  this.writeDepthOfFieldUniforms();
4315
4474
  const encoder = this.device.createCommandEncoder();
4316
4475
  // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
@@ -4523,7 +4682,7 @@ export class Engine {
4523
4682
  for (const [id, install] of inst.styleGroups) {
4524
4683
  if (!nextIds.has(id)) {
4525
4684
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1);
4526
- install.uniformBuffer.destroy();
4685
+ this.destroyInstall(install);
4527
4686
  inst.styleGroups.delete(id);
4528
4687
  }
4529
4688
  }
@@ -4557,7 +4716,7 @@ export class Engine {
4557
4716
  if (!inst || !install)
4558
4717
  return;
4559
4718
  inst.styleGroupGen.set(groupId, (inst.styleGroupGen.get(groupId) ?? 0) + 1); // discard in-flight compile
4560
- install.uniformBuffer.destroy();
4719
+ this.destroyInstall(install);
4561
4720
  inst.styleGroups.delete(groupId);
4562
4721
  this.assignDrawCallGroups(inst, this.currentClaims(inst));
4563
4722
  }
@@ -4568,7 +4727,7 @@ export class Engine {
4568
4727
  return;
4569
4728
  for (const [id, install] of inst.styleGroups) {
4570
4729
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1);
4571
- install.uniformBuffer.destroy();
4730
+ this.destroyInstall(install);
4572
4731
  }
4573
4732
  inst.styleGroups.clear();
4574
4733
  this.assignDrawCallGroups(inst, new Map());
@@ -4657,6 +4816,9 @@ export class Engine {
4657
4816
  size: 256,
4658
4817
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
4659
4818
  });
4819
+ // The outgoing install's maps go with it — a re-apply that changes images
4820
+ // would otherwise strand the old textures for the life of the model.
4821
+ const previousImages = inst.styleGroups.get(group.id)?.images;
4660
4822
  inst.styleGroups.set(group.id, {
4661
4823
  group,
4662
4824
  renderClass,
@@ -4665,9 +4827,28 @@ export class Engine {
4665
4827
  pipelineNoDepthWrite,
4666
4828
  overEyesPipeline,
4667
4829
  uniformBuffer,
4830
+ images: this.uploadGroupImages(group),
4668
4831
  slotMap: result.slotMap,
4669
4832
  signature,
4670
4833
  });
4834
+ // Rebind this group's draw calls before the old textures go.
4835
+ //
4836
+ // assignDrawCallGroups only rebuilds a bind group when a material CHANGES
4837
+ // group, which is the wrong test here: swapping the graph on a group a
4838
+ // material already belongs to leaves its id alone while replacing the maps
4839
+ // underneath it. The draw call then kept a bind group holding the outgoing
4840
+ // textures — destroyed on the next line — so a graph swap either sampled the
4841
+ // old maps, or the fallback white where the previous graph had none (which
4842
+ // reads as a blown-out white material through any screen or add), or tripped
4843
+ // a validation error on a destroyed texture. Only a reload cleared it.
4844
+ const install = inst.styleGroups.get(group.id);
4845
+ for (const dc of inst.drawCalls) {
4846
+ if (!dc.baseBindGroupEntries || dc.groupId !== group.id)
4847
+ continue;
4848
+ dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, uniformBuffer, install?.images);
4849
+ }
4850
+ for (const tex of previousImages ?? [])
4851
+ tex?.destroy();
4671
4852
  this.writeGroupDefaults(uniformBuffer, group, result.slotMap);
4672
4853
  return { ok: true, diagnostics, slotMap: result.slotMap };
4673
4854
  }
@@ -4686,7 +4867,7 @@ export class Engine {
4686
4867
  if (dc.groupId === groupId)
4687
4868
  continue;
4688
4869
  dc.groupId = groupId;
4689
- dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, install ? install.uniformBuffer : this.zeroStyleBuffer);
4870
+ dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, install ? install.uniformBuffer : this.zeroStyleBuffer, install?.images);
4690
4871
  }
4691
4872
  this.sortDrawCalls(inst);
4692
4873
  }
@@ -4938,7 +5119,7 @@ export class Engine {
4938
5119
  // is LEFT-HANDED (+Z forward, see Mat4.lookAtInto), so the world-space
4939
5120
  // right/up/FORWARD vectors are rows 0/1/2 of its rotation block directly
4940
5121
  // (column-major storage: row i = values[i], values[i+4], values[i+8]).
4941
- if ((this.backdropEquirectView || this.backgroundEffect) && this.compositeUniformBuffer) {
5122
+ if ((this.backdropEquirectView || this.effect) && this.compositeUniformBuffer) {
4942
5123
  const v = viewMatrix.values;
4943
5124
  const u = this.compositeUniformData;
4944
5125
  const tanHalf = Math.tan((this.camera.fov ?? Math.PI / 4) / 2);
@@ -4956,9 +5137,15 @@ export class Engine {
4956
5137
  u[22] = v[10];
4957
5138
  u[23] = 0;
4958
5139
  // Effect clock + canvas size (viewU[6]) — written on the same refresh.
4959
- u[24] = (performance.now() - this.bgEffectEpochMs) / 1000;
5140
+ u[24] = (performance.now() - this.effectEpochMs) / 1000;
4960
5141
  u[26] = this.canvas.width;
4961
5142
  u[27] = this.canvas.height;
5143
+ // Camera world position (viewU[10]) — the other half of bgWorldPos. It
5144
+ // rides this refresh rather than writeCompositeViewUniforms because it
5145
+ // changes every frame the camera does, exactly like the basis above.
5146
+ u[40] = cameraPos.x;
5147
+ u[41] = cameraPos.y;
5148
+ u[42] = cameraPos.z;
4962
5149
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
4963
5150
  }
4964
5151
  }
@@ -52,7 +52,7 @@ export const BODY_GRAPH = {
52
52
  {
53
53
  id: "principled",
54
54
  type: "principled",
55
- inputs: { metallic: 0.0, specular: 0.5, roughness: 0.3, spec_clamp: 10.0, sheen: 0.0, sheen_tint: 0.0 },
55
+ inputs: { metallic: 0.0, specular_ior_level: 0.5, roughness: 0.3, spec_clamp: 10.0, sheen_weight: 0.0, sheen_tint: 0.0 },
56
56
  },
57
57
  { id: "p_sum", type: "add_shader" },
58
58
  { id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.5 } },
@@ -89,7 +89,7 @@ export const BODY_GRAPH = {
89
89
  { from: { node: "noise_ramp", socket: "fac_out" }, to: { node: "principled_base", socket: "fac" } },
90
90
  { from: { node: "bc", socket: "color" }, to: { node: "principled_base", socket: "a" } },
91
91
  { from: { node: "bc", socket: "color" }, to: { node: "p_emit", socket: "color" } },
92
- { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base" } },
92
+ { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base_color" } },
93
93
  { from: { node: "bump", socket: "vector" }, to: { node: "principled", socket: "normal" } },
94
94
  { from: { node: "principled", socket: "color" }, to: { node: "p_sum", socket: "a" } },
95
95
  { from: { node: "p_emit", socket: "color" }, to: { node: "p_sum", socket: "b" } },