reze-engine 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/engine.d.ts +45 -28
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +339 -144
  4. package/dist/graph/compile.d.ts +6 -0
  5. package/dist/graph/compile.d.ts.map +1 -1
  6. package/dist/graph/compile.js +1 -1
  7. package/dist/graph/presets/body.js +1 -1
  8. package/dist/graph/presets/cloth_rough.js +1 -1
  9. package/dist/graph/presets/cloth_smooth.js +1 -1
  10. package/dist/graph/presets/default.js +1 -1
  11. package/dist/graph/presets/eye.js +1 -1
  12. package/dist/graph/presets/face.js +1 -1
  13. package/dist/graph/presets/hair.js +1 -1
  14. package/dist/graph/presets/metal.js +1 -1
  15. package/dist/graph/presets/stockings.js +1 -1
  16. package/dist/graph/render-class.d.ts +16 -0
  17. package/dist/graph/render-class.d.ts.map +1 -0
  18. package/dist/graph/render-class.js +15 -0
  19. package/dist/graph/schema.d.ts +4 -5
  20. package/dist/graph/schema.d.ts.map +1 -1
  21. package/dist/graph/slots.d.ts +7 -12
  22. package/dist/graph/slots.d.ts.map +1 -1
  23. package/dist/graph/slots.js +68 -100
  24. package/dist/graph/style-group.d.ts +38 -0
  25. package/dist/graph/style-group.d.ts.map +1 -0
  26. package/dist/graph/style-group.js +6 -0
  27. package/dist/index.d.ts +3 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -0
  30. package/package.json +1 -1
  31. package/src/engine.ts +395 -156
  32. package/src/graph/compile.ts +7 -1
  33. package/src/graph/presets/body.ts +1 -1
  34. package/src/graph/presets/cloth_rough.ts +1 -1
  35. package/src/graph/presets/cloth_smooth.ts +1 -1
  36. package/src/graph/presets/default.ts +1 -1
  37. package/src/graph/presets/eye.ts +1 -1
  38. package/src/graph/presets/face.ts +1 -1
  39. package/src/graph/presets/hair.ts +1 -1
  40. package/src/graph/presets/metal.ts +1 -1
  41. package/src/graph/presets/stockings.ts +1 -1
  42. package/src/graph/render-class.ts +33 -0
  43. package/src/graph/schema.ts +4 -6
  44. package/src/graph/slots.ts +76 -113
  45. package/src/graph/style-group.ts +39 -0
  46. package/src/index.ts +7 -1
package/dist/engine.js CHANGED
@@ -26,6 +26,15 @@ import { COMPOSITE_SHADER_WGSL } from "./shaders/passes/composite";
26
26
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
27
27
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
28
28
  import { compileGraph } from "./graph/compile";
29
+ import { DEFAULT_GRAPH } from "./graph/presets/default";
30
+ import { FACE_GRAPH } from "./graph/presets/face";
31
+ import { HAIR_GRAPH } from "./graph/presets/hair";
32
+ import { BODY_GRAPH } from "./graph/presets/body";
33
+ import { EYE_GRAPH } from "./graph/presets/eye";
34
+ import { STOCKINGS_GRAPH } from "./graph/presets/stockings";
35
+ import { METAL_GRAPH } from "./graph/presets/metal";
36
+ import { CLOTH_SMOOTH_GRAPH } from "./graph/presets/cloth_smooth";
37
+ import { CLOTH_ROUGH_GRAPH } from "./graph/presets/cloth_rough";
29
38
  // Substring hints mapping common PMX material names (JP/CN/EN) to presets,
30
39
  // tried when a material isn't in the model's explicit MaterialPresetMap.
31
40
  // Ordered: more specific families first (靴下 must hit stockings before 靴
@@ -87,6 +96,21 @@ function resolvePreset(materialName, map) {
87
96
  }
88
97
  return "mmd_classic";
89
98
  }
99
+ // Default-group recipe per preset label: the shipped graph + its natural pass-integration
100
+ // (renderClass, alphaMode). This is the auto-default-groups mapping (docs §8) — the same
101
+ // label→integration knowledge the old fixed slots encoded, now producing editable groups.
102
+ // mmd_classic has no entry — those materials stay ungrouped (hand-shader path).
103
+ const PRESET_GROUP_INFO = {
104
+ default: { graph: DEFAULT_GRAPH, renderClass: "auto", alphaMode: "opaque" },
105
+ face: { graph: FACE_GRAPH, renderClass: "auto", alphaMode: "opaque" },
106
+ hair: { graph: HAIR_GRAPH, renderClass: "hair", alphaMode: "opaque" },
107
+ body: { graph: BODY_GRAPH, renderClass: "auto", alphaMode: "opaque" },
108
+ eye: { graph: EYE_GRAPH, renderClass: "eye", alphaMode: "opaque" },
109
+ stockings: { graph: STOCKINGS_GRAPH, renderClass: "auto", alphaMode: "hashed" },
110
+ metal: { graph: METAL_GRAPH, renderClass: "auto", alphaMode: "opaque" },
111
+ cloth_smooth: { graph: CLOTH_SMOOTH_GRAPH, renderClass: "auto", alphaMode: "opaque" },
112
+ cloth_rough: { graph: CLOTH_ROUGH_GRAPH, renderClass: "auto", alphaMode: "opaque" },
113
+ };
90
114
  // Map a WGSL compile-error line back to the graph node whose `let` produced it —
91
115
  // the compiler tags every generated line with a trailing `// @node:<id>` marker.
92
116
  function nodeIdForWgslLine(wgsl, lineNum) {
@@ -133,16 +157,6 @@ export class Engine {
133
157
  this.lightCount = 0;
134
158
  this.resizeObserver = null;
135
159
  this.resizePending = false;
136
- // ── Style graph runtime (graph/ compiler output applied to preset slots) ──
137
- // Per-slot 256 B StyleUniforms buffers (group(2) binding(4)). Created eagerly so
138
- // material bind groups can reference them at model-load time regardless of whether
139
- // a graph is ever applied; mmd_classic materials bind the shared zero buffer.
140
- this.styleBuffers = new Map();
141
- // Applied graphs: slot → swapped pipeline(s) + the slider→UBO map for setStyleParam.
142
- this.styleOverrides = new Map();
143
- // Per-slot generation counter — an async compile that finishes after a newer
144
- // applyStyleGraph/resetStyleSlot call on the same slot is discarded (stale guard).
145
- this.styleGenerations = new Map();
146
160
  this.selectedMaterial = null;
147
161
  // ─── Transform gizmo ───────────────────────────────────────────────
148
162
  this.selectedBone = null;
@@ -940,30 +954,13 @@ export class Engine {
940
954
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
941
955
  ],
942
956
  });
943
- // Per-slot style buffers + shared zero buffer (mmd_classic / never-styled slots).
957
+ // Shared zero StyleUniforms buffer bound by every ungrouped material; grouped
958
+ // materials rebind binding(4) to their group's own buffer (per model, per group).
944
959
  this.zeroStyleBuffer = this.device.createBuffer({
945
960
  label: "style uniforms (zero)",
946
961
  size: 256,
947
962
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
948
963
  });
949
- const ALL_PRESETS = [
950
- "default",
951
- "face",
952
- "hair",
953
- "body",
954
- "eye",
955
- "stockings",
956
- "metal",
957
- "cloth_smooth",
958
- "cloth_rough",
959
- ];
960
- for (const preset of ALL_PRESETS) {
961
- this.styleBuffers.set(preset, this.device.createBuffer({
962
- label: `style uniforms (${preset})`,
963
- size: 256,
964
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
965
- }));
966
- }
967
964
  const mainPipelineLayout = this.device.createPipelineLayout({
968
965
  label: "main pipeline layout",
969
966
  bindGroupLayouts: [
@@ -2143,6 +2140,7 @@ export class Engine {
2143
2140
  bindGroup: this.groundShadowBindGroup,
2144
2141
  materialName: "Ground",
2145
2142
  preset: "cloth_rough",
2143
+ groupId: null,
2146
2144
  };
2147
2145
  }
2148
2146
  updateLightBuffer() {
@@ -2189,12 +2187,14 @@ export class Engine {
2189
2187
  this.resizeObserver.disconnect();
2190
2188
  this.resizeObserver = null;
2191
2189
  }
2192
- // Style graph runtime: per-slot uniform buffers + swapped pipelines (pipelines
2193
- // are GC'd; buffers need explicit destroy).
2194
- this.styleOverrides.clear();
2195
- for (const buf of this.styleBuffers.values())
2196
- buf.destroy();
2197
- this.styleBuffers.clear();
2190
+ // Style group runtime: per-group uniform buffers (pipelines are GC'd; buffers need
2191
+ // explicit destroy). Per-model group buffers are torn down in removeModel; the shared
2192
+ // zero buffer is engine-owned.
2193
+ this.forEachInstance((inst) => {
2194
+ for (const install of inst.styleGroups.values())
2195
+ install.uniformBuffer.destroy();
2196
+ inst.styleGroups.clear();
2197
+ });
2198
2198
  this.zeroStyleBuffer?.destroy();
2199
2199
  }
2200
2200
  async loadModel(nameOrPath, pathOrOptions) {
@@ -2246,6 +2246,9 @@ export class Engine {
2246
2246
  for (const buf of inst.gpuBuffers) {
2247
2247
  buf.destroy();
2248
2248
  }
2249
+ // Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
2250
+ for (const install of inst.styleGroups.values())
2251
+ install.uniformBuffer.destroy();
2249
2252
  this.modelInstances.delete(name);
2250
2253
  }
2251
2254
  getModelNames() {
@@ -2291,18 +2294,15 @@ export class Engine {
2291
2294
  if (!inst)
2292
2295
  return;
2293
2296
  inst.materialPresets = presets;
2294
- for (const dc of inst.drawCalls) {
2295
- const preset = resolvePreset(dc.materialName, presets);
2296
- if (preset === dc.preset)
2297
- continue;
2298
- dc.preset = preset;
2299
- // Rebind so binding(4) follows the new slot's style buffer.
2300
- if (dc.baseBindGroupEntries)
2301
- dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, preset);
2302
- }
2303
- }
2304
- createMaterialBindGroup(label, baseEntries, preset) {
2305
- const styleBuffer = preset === "mmd_classic" ? this.zeroStyleBuffer : this.styleBuffers.get(preset);
2297
+ // Only updates the ungrouped fallback preset; grouped materials render via their
2298
+ // group regardless. binding(4) is unaffected (ungrouped always binds the zero
2299
+ // buffer; grouped bindings are owned by applyStyleGroups).
2300
+ for (const dc of inst.drawCalls)
2301
+ dc.preset = resolvePreset(dc.materialName, presets);
2302
+ }
2303
+ // Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
2304
+ // (the group's buffer when grouped, or the shared zero buffer when ungrouped).
2305
+ createMaterialBindGroup(label, baseEntries, styleBuffer) {
2306
2306
  return this.device.createBindGroup({
2307
2307
  label,
2308
2308
  layout: this.mainPerMaterialBindGroupLayout,
@@ -2517,6 +2517,9 @@ export class Engine {
2517
2517
  physics,
2518
2518
  vertexBufferNeedsUpdate: false,
2519
2519
  gpuMorph,
2520
+ styleGroups: new Map(),
2521
+ materialToGroup: new Map(),
2522
+ styleGroupGen: new Map(),
2520
2523
  };
2521
2524
  await this.setupMaterialsForInstance(inst);
2522
2525
  this.modelInstances.set(name, inst);
@@ -2757,7 +2760,8 @@ export class Engine {
2757
2760
  { binding: 2, resource: (toonTexture ?? this.fallbackMaterialTexture).createView() },
2758
2761
  { binding: 3, resource: (sphereTexture ?? this.fallbackMaterialTexture).createView() },
2759
2762
  ];
2760
- const bindGroup = this.createMaterialBindGroup(`${prefix}material: ${mat.name}`, baseBindGroupEntries, preset);
2763
+ // Ungrouped at load — binding(4) = zero buffer. applyStyleGroups rebinds grouped ones.
2764
+ const bindGroup = this.createMaterialBindGroup(`${prefix}material: ${mat.name}`, baseBindGroupEntries, this.zeroStyleBuffer);
2761
2765
  const type = isTransparent ? "transparent" : "opaque";
2762
2766
  inst.drawCalls.push({
2763
2767
  type,
@@ -2766,6 +2770,7 @@ export class Engine {
2766
2770
  bindGroup,
2767
2771
  materialName: mat.name,
2768
2772
  preset,
2773
+ groupId: null,
2769
2774
  baseBindGroupEntries,
2770
2775
  });
2771
2776
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
@@ -2794,6 +2799,7 @@ export class Engine {
2794
2799
  bindGroup: outlineBindGroup,
2795
2800
  materialName: mat.name,
2796
2801
  preset,
2802
+ groupId: null,
2797
2803
  });
2798
2804
  }
2799
2805
  if (this.onRaycast) {
@@ -2809,30 +2815,14 @@ export class Engine {
2809
2815
  }
2810
2816
  currentIndexOffset += indexCount;
2811
2817
  }
2812
- // Sort so the opaque bucket is emitted in the order the stencil-based
2813
- // see-through-hair effect requires: {non-hair, non-eye} → {eye} → {hair}.
2814
- // Eye writes stencil=EYE_VALUE; hair's pipeline stencil-tests "not equal" so
2815
- // it skips eye pixels; a follow-up hairOverEyes pass (see renderOneModel)
2816
- // re-fills those skipped pixels alpha-blended. Array.sort is stable in
2817
- // ES2019+, so within a bucket the PMX material order is preserved.
2818
- const typeOrder = {
2819
- opaque: 0,
2820
- "opaque-outline": 1,
2821
- transparent: 2,
2822
- "transparent-outline": 3,
2823
- ground: 4,
2824
- };
2825
- const presetRank = (p) => (p === "hair" ? 2 : p === "eye" ? 1 : 0);
2826
- inst.drawCalls.sort((a, b) => {
2827
- const ta = typeOrder[a.type] - typeOrder[b.type];
2828
- if (ta !== 0)
2829
- return ta;
2830
- return presetRank(a.preset) - presetRank(b.preset);
2831
- });
2832
- for (const d of inst.drawCalls) {
2833
- if (d.type === "opaque")
2834
- inst.shadowDrawCalls.push(d);
2835
- }
2818
+ // Sort so the opaque bucket is emitted in the order the stencil-based see-through-hair
2819
+ // effect requires: {non-hair, non-eye} → {eye} → {hair}. Eye writes stencil=EYE_VALUE;
2820
+ // hair stencil-tests "not equal" and skips eye pixels; the follow-up hairOverEyes pass
2821
+ // re-fills them alpha-blended. sortDrawCalls also (re)builds shadowDrawCalls. All draws
2822
+ // are ungrouped at setup, so the rank comes from the preset; applyStyleGroups re-sorts
2823
+ // by render-class when groups are assigned. Array.sort is stable → PMX order preserved
2824
+ // within a bucket.
2825
+ this.sortDrawCalls(inst);
2836
2826
  }
2837
2827
  createMaterialUniformBuffer(label, mat, sphereMode, headBoneIndex) {
2838
2828
  // Matches the WGSL MaterialUniforms struct in common.ts — 64 bytes
@@ -3401,35 +3391,192 @@ export class Engine {
3401
3391
  sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
3402
3392
  }
3403
3393
  }
3404
- // ─── Style graph API ──────────────────────────────────────────────
3405
- // Two-tier edit model: applyStyleGraph = topology tier (async compile + pipeline
3406
- // swap, previous look keeps rendering until the new pipeline is ready or on any
3407
- // error); setStyleParam = adjust tier (uniform write, instant, no pipeline touch).
3394
+ // ─── Style group API ──────────────────────────────────────────────
3395
+ // Two-tier edits: applyStyleGroups/upsert = topology (async compile + pipeline swap,
3396
+ // fallback-on-error, per-group stale guard); setStyleParam = adjust (instant uniform
3397
+ // write). Overlay-first: grouped materials render via their group's compiled graph;
3398
+ // ungrouped ones keep the hand-written preset path. See docs/style-groups-spec.md.
3399
+ /** Read a model's current style groups (including auto-created defaults) for editor
3400
+ * round-trip. The host owns group state; this is bootstrap/read, not a second store. */
3401
+ getStyleGroups(modelName) {
3402
+ const inst = this.modelInstances.get(modelName);
3403
+ if (!inst)
3404
+ return [];
3405
+ return [...inst.styleGroups.values()].map((g) => g.group);
3406
+ }
3407
+ /**
3408
+ * Create default style groups from the model's material→preset resolution
3409
+ * (`setMaterialPresets` map first, then name hints), so a freshly loaded model comes up
3410
+ * editably grouped with zero interaction. Materials resolving to `mmd_classic` (no map
3411
+ * entry, no hint) stay ungrouped. The returned promise resolves after grouping AND the
3412
+ * async graph compiles, so `getStyleGroups` is populated the moment it resolves. Opt-in:
3413
+ * call it explicitly (typically after `setMaterialPresets`); a bare load stays ungrouped
3414
+ * / byte-identical to before.
3415
+ */
3416
+ async autoStyleGroups(modelName) {
3417
+ const inst = this.modelInstances.get(modelName);
3418
+ if (!inst)
3419
+ return { ok: false, groups: [], unknownMaterials: [], conflicts: [] };
3420
+ const buckets = new Map();
3421
+ for (const dc of inst.drawCalls) {
3422
+ if (!dc.baseBindGroupEntries)
3423
+ continue; // material draw calls only (skip outlines)
3424
+ const preset = resolvePreset(dc.materialName, inst.materialPresets);
3425
+ if (preset === "mmd_classic")
3426
+ continue; // unmatched → ungrouped
3427
+ const arr = buckets.get(preset) ?? [];
3428
+ if (!arr.includes(dc.materialName))
3429
+ arr.push(dc.materialName);
3430
+ buckets.set(preset, arr);
3431
+ }
3432
+ const groups = [];
3433
+ for (const [preset, materials] of buckets) {
3434
+ const info = PRESET_GROUP_INFO[preset];
3435
+ if (!info)
3436
+ continue;
3437
+ groups.push({
3438
+ id: preset,
3439
+ label: info.graph.name,
3440
+ materials,
3441
+ graph: info.graph,
3442
+ renderClass: info.renderClass,
3443
+ alphaMode: info.alphaMode,
3444
+ });
3445
+ }
3446
+ return this.applyStyleGroups(modelName, groups);
3447
+ }
3408
3448
  /**
3409
- * Compile a style graph and swap it onto its preset slot. Fallback-on-error: the
3410
- * slot's current pipeline keeps rendering unless compilation fully succeeds.
3411
- * Slider defaults are written to the slot's StyleUniforms buffer on success.
3449
+ * Replace a model's full style-group set. Unchanged groups (same graph + renderClass +
3450
+ * alphaMode) keep their pipeline; new/changed ones compile and swap; removed ones are
3451
+ * torn down and their materials revert to the ungrouped hand-shader path. A group whose
3452
+ * compile fails is not installed and its materials stay ungrouped (fallback-on-error).
3412
3453
  */
3413
- async applyStyleGraph(graph, opts) {
3414
- const result = compileGraph(graph, opts);
3454
+ async applyStyleGroups(modelName, groups) {
3455
+ const inst = this.modelInstances.get(modelName);
3456
+ if (!inst)
3457
+ return { ok: false, groups: [], unknownMaterials: [], conflicts: [] };
3458
+ // Whole-set validation: material claims (last group in array order wins), unknowns.
3459
+ const modelMaterials = new Set(inst.drawCalls.map((d) => d.materialName));
3460
+ const claimed = new Map();
3461
+ const conflicts = new Set();
3462
+ const unknownMaterials = new Set();
3463
+ for (const g of groups) {
3464
+ for (const m of g.materials) {
3465
+ if (!modelMaterials.has(m))
3466
+ unknownMaterials.add(m);
3467
+ if (claimed.has(m))
3468
+ conflicts.add(m);
3469
+ claimed.set(m, g.id);
3470
+ }
3471
+ }
3472
+ // Tear down installs no longer present.
3473
+ const nextIds = new Set(groups.map((g) => g.id));
3474
+ for (const [id, install] of inst.styleGroups) {
3475
+ if (!nextIds.has(id)) {
3476
+ inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1);
3477
+ install.uniformBuffer.destroy();
3478
+ inst.styleGroups.delete(id);
3479
+ }
3480
+ }
3481
+ const groupResults = [];
3482
+ for (const g of groups) {
3483
+ const r = await this.compileAndInstallGroup(inst, g);
3484
+ groupResults.push({ groupId: g.id, diagnostics: r.diagnostics, ok: r.ok });
3485
+ }
3486
+ this.assignDrawCallGroups(inst, claimed);
3487
+ return {
3488
+ ok: groupResults.every((r) => r.ok),
3489
+ groups: groupResults,
3490
+ unknownMaterials: [...unknownMaterials],
3491
+ conflicts: [...conflicts],
3492
+ };
3493
+ }
3494
+ /** Add or replace a single style group by id. `opts` may carry a `previewNode` for the
3495
+ * editor's node-output preview workflow. */
3496
+ async upsertStyleGroup(modelName, group, opts) {
3497
+ const inst = this.modelInstances.get(modelName);
3498
+ if (!inst)
3499
+ return { ok: false, diagnostics: [{ severity: "error", message: `unknown model "${modelName}"` }], slotMap: [] };
3500
+ const r = await this.compileAndInstallGroup(inst, group, opts);
3501
+ this.assignDrawCallGroups(inst, this.currentClaims(inst));
3502
+ return r;
3503
+ }
3504
+ /** Remove a style group; its materials revert to the ungrouped hand-shader path. */
3505
+ removeStyleGroup(modelName, groupId) {
3506
+ const inst = this.modelInstances.get(modelName);
3507
+ const install = inst?.styleGroups.get(groupId);
3508
+ if (!inst || !install)
3509
+ return;
3510
+ inst.styleGroupGen.set(groupId, (inst.styleGroupGen.get(groupId) ?? 0) + 1); // discard in-flight compile
3511
+ install.uniformBuffer.destroy();
3512
+ inst.styleGroups.delete(groupId);
3513
+ this.assignDrawCallGroups(inst, this.currentClaims(inst));
3514
+ }
3515
+ /** Clear all style groups on a model — every material returns to the hand-shader path. */
3516
+ resetStyleGroups(modelName) {
3517
+ const inst = this.modelInstances.get(modelName);
3518
+ if (!inst)
3519
+ return;
3520
+ for (const [id, install] of inst.styleGroups) {
3521
+ inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1);
3522
+ install.uniformBuffer.destroy();
3523
+ }
3524
+ inst.styleGroups.clear();
3525
+ this.assignDrawCallGroups(inst, new Map());
3526
+ }
3527
+ /** Instant adjust-tier write: set one exposed slider on a group's applied graph. */
3528
+ setStyleParam(modelName, groupId, paramId, value) {
3529
+ const install = this.modelInstances.get(modelName)?.styleGroups.get(groupId);
3530
+ const styleSlot = install?.slotMap.find((s) => s.id === paramId);
3531
+ if (!install || !styleSlot)
3532
+ return false;
3533
+ if (styleSlot.kind === "float") {
3534
+ if (typeof value !== "number")
3535
+ return false;
3536
+ const offset = styleSlot.vec4Index * 16 + ["x", "y", "z", "w"].indexOf(styleSlot.component) * 4;
3537
+ this.device.queue.writeBuffer(install.uniformBuffer, offset, new Float32Array([value]));
3538
+ }
3539
+ else {
3540
+ if (typeof value === "number")
3541
+ return false;
3542
+ this.device.queue.writeBuffer(install.uniformBuffer, styleSlot.vec4Index * 16, new Float32Array(value));
3543
+ }
3544
+ return true;
3545
+ }
3546
+ // Materials claimed by the model's currently-installed groups (for upsert/remove paths;
3547
+ // applyStyleGroups derives claims from its input array instead).
3548
+ currentClaims(inst) {
3549
+ const claimed = new Map();
3550
+ for (const install of inst.styleGroups.values())
3551
+ for (const m of install.group.materials)
3552
+ claimed.set(m, install.group.id);
3553
+ return claimed;
3554
+ }
3555
+ // Compile a group's graph → WGSL → pipeline(s), install keyed by group id. Reuses the
3556
+ // install (pipeline + uniform buffer) when the graph/integration is byte-unchanged.
3557
+ async compileAndInstallGroup(inst, group, opts) {
3558
+ const renderClass = group.renderClass ?? "auto";
3559
+ const alphaMode = group.alphaMode ?? "opaque";
3560
+ const signature = JSON.stringify({ g: group.graph, rc: renderClass, am: alphaMode, o: opts?.previewNode ?? null });
3561
+ const existing = inst.styleGroups.get(group.id);
3562
+ if (existing && existing.signature === signature) {
3563
+ existing.group = group; // refresh def (label/materials) without recompiling
3564
+ return { ok: true, diagnostics: [], slotMap: existing.slotMap };
3565
+ }
3566
+ const result = compileGraph(group.graph, { ...opts, renderClass, alphaMode });
3415
3567
  if (!result.ok)
3416
3568
  return { ok: false, diagnostics: result.diagnostics, slotMap: result.slotMap };
3417
- const slot = graph.slot;
3418
- const generation = (this.styleGenerations.get(slot) ?? 0) + 1;
3419
- this.styleGenerations.set(slot, generation);
3569
+ const generation = (inst.styleGroupGen.get(group.id) ?? 0) + 1;
3570
+ inst.styleGroupGen.set(group.id, generation);
3420
3571
  this.device.pushErrorScope("validation");
3421
- const module = this.device.createShaderModule({ label: `style graph: ${graph.name} (${slot})`, code: result.wgsl });
3572
+ const module = this.device.createShaderModule({ label: `style group: ${group.id} (${renderClass})`, code: result.wgsl });
3422
3573
  const info = await module.getCompilationInfo();
3423
3574
  const scopeError = await this.device.popErrorScope();
3424
3575
  const diagnostics = [...result.diagnostics];
3425
3576
  for (const msg of info.messages) {
3426
3577
  if (msg.type !== "error")
3427
3578
  continue;
3428
- diagnostics.push({
3429
- severity: "error",
3430
- nodeId: nodeIdForWgslLine(result.wgsl, msg.lineNum),
3431
- message: `WGSL: ${msg.message}`,
3432
- });
3579
+ diagnostics.push({ severity: "error", nodeId: nodeIdForWgslLine(result.wgsl, msg.lineNum), message: `WGSL: ${msg.message}` });
3433
3580
  }
3434
3581
  if (diagnostics.some((d) => d.severity === "error") || scopeError) {
3435
3582
  if (scopeError && !diagnostics.some((d) => d.severity === "error"))
@@ -3439,52 +3586,61 @@ export class Engine {
3439
3586
  let pipeline;
3440
3587
  let overEyesPipeline;
3441
3588
  try {
3442
- pipeline = await this.createSlotPipeline(slot, module, false);
3443
- if (slot === "hair")
3444
- overEyesPipeline = await this.createSlotPipeline(slot, module, true);
3589
+ pipeline = await this.createRenderClassPipeline(renderClass, module, false);
3590
+ if (renderClass === "hair")
3591
+ overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true);
3445
3592
  }
3446
3593
  catch (e) {
3447
3594
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${e.message}` });
3448
3595
  return { ok: false, diagnostics, slotMap: result.slotMap };
3449
3596
  }
3450
- // A newer apply/reset on this slot finished (or started) while we compiled.
3451
- if (this.styleGenerations.get(slot) !== generation) {
3597
+ // Stale guard: a newer compile/remove for this id happened while we awaited.
3598
+ if (inst.styleGroupGen.get(group.id) !== generation) {
3452
3599
  diagnostics.push({ severity: "warning", message: "superseded by a newer edit — result discarded" });
3453
3600
  return { ok: false, diagnostics, slotMap: result.slotMap };
3454
3601
  }
3455
- this.styleOverrides.set(slot, { pipeline, overEyesPipeline, slotMap: result.slotMap });
3456
- this.writeStyleDefaults(slot, graph, result.slotMap);
3602
+ const uniformBuffer = existing?.uniformBuffer ??
3603
+ this.device.createBuffer({
3604
+ label: `style uniforms: ${group.id}`,
3605
+ size: 256,
3606
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3607
+ });
3608
+ inst.styleGroups.set(group.id, {
3609
+ group,
3610
+ renderClass,
3611
+ alphaMode,
3612
+ pipeline,
3613
+ overEyesPipeline,
3614
+ uniformBuffer,
3615
+ slotMap: result.slotMap,
3616
+ signature,
3617
+ });
3618
+ this.writeGroupDefaults(uniformBuffer, group, result.slotMap);
3457
3619
  return { ok: true, diagnostics, slotMap: result.slotMap };
3458
3620
  }
3459
- /** Instant adjust-tier write: set one exposed slider on the slot's applied graph. */
3460
- setStyleParam(slot, paramId, value) {
3461
- const override = this.styleOverrides.get(slot);
3462
- const styleSlot = override?.slotMap.find((s) => s.id === paramId);
3463
- const buffer = this.styleBuffers.get(slot);
3464
- if (!styleSlot || !buffer)
3465
- return false;
3466
- if (styleSlot.kind === "float") {
3467
- if (typeof value !== "number")
3468
- return false;
3469
- const offset = styleSlot.vec4Index * 16 + ["x", "y", "z", "w"].indexOf(styleSlot.component) * 4;
3470
- this.device.queue.writeBuffer(buffer, offset, new Float32Array([value]));
3471
- }
3472
- else {
3473
- if (typeof value === "number")
3474
- return false;
3475
- this.device.queue.writeBuffer(buffer, styleSlot.vec4Index * 16, new Float32Array(value));
3621
+ // Rebind each material draw call to its (successfully-installed) group's uniform buffer,
3622
+ // or the zero buffer when ungrouped, then re-sort by render-class draw order.
3623
+ assignDrawCallGroups(inst, claimed) {
3624
+ inst.materialToGroup.clear();
3625
+ for (const dc of inst.drawCalls) {
3626
+ if (!dc.baseBindGroupEntries)
3627
+ continue; // outlines/ground are never grouped
3628
+ const wantId = claimed.get(dc.materialName);
3629
+ const install = wantId ? inst.styleGroups.get(wantId) : undefined;
3630
+ const groupId = install ? wantId : null;
3631
+ if (groupId)
3632
+ inst.materialToGroup.set(dc.materialName, groupId);
3633
+ if (dc.groupId === groupId)
3634
+ continue;
3635
+ dc.groupId = groupId;
3636
+ dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, install ? install.uniformBuffer : this.zeroStyleBuffer);
3476
3637
  }
3477
- return true;
3638
+ this.sortDrawCalls(inst);
3478
3639
  }
3479
- /** Remove an applied style graph — the slot returns to its built-in preset shader. */
3480
- resetStyleSlot(slot) {
3481
- this.styleGenerations.set(slot, (this.styleGenerations.get(slot) ?? 0) + 1);
3482
- this.styleOverrides.delete(slot);
3483
- }
3484
- writeStyleDefaults(slot, graph, slotMap) {
3640
+ writeGroupDefaults(buffer, group, slotMap) {
3485
3641
  const data = new Float32Array(64); // 16 vec4f
3486
3642
  for (const styleSlot of slotMap) {
3487
- const param = graph.params?.find((p) => p.id === styleSlot.id);
3643
+ const param = group.graph.params?.find((p) => p.id === styleSlot.id);
3488
3644
  if (!param)
3489
3645
  continue;
3490
3646
  const base = styleSlot.vec4Index * 4;
@@ -3495,19 +3651,38 @@ export class Engine {
3495
3651
  data.set(param.default.slice(0, 3), base);
3496
3652
  }
3497
3653
  }
3498
- this.device.queue.writeBuffer(this.styleBuffers.get(slot), 0, data);
3654
+ this.device.queue.writeBuffer(buffer, 0, data);
3655
+ }
3656
+ // Draw-order rank within a bucket: eye stamps before hair reads. A grouped call ranks by
3657
+ // its group's render-class; an ungrouped call by its preset (the hand-shader path).
3658
+ drawCallRank(inst, dc) {
3659
+ const rc = dc.groupId ? (inst.styleGroups.get(dc.groupId)?.renderClass ?? "auto") : undefined;
3660
+ if (rc)
3661
+ return rc === "hair" ? 2 : rc === "eye" ? 1 : 0;
3662
+ return dc.preset === "hair" ? 2 : dc.preset === "eye" ? 1 : 0;
3663
+ }
3664
+ sortDrawCalls(inst) {
3665
+ const typeOrder = {
3666
+ opaque: 0,
3667
+ "opaque-outline": 1,
3668
+ transparent: 2,
3669
+ "transparent-outline": 3,
3670
+ ground: 4,
3671
+ };
3672
+ inst.drawCalls.sort((a, b) => typeOrder[a.type] - typeOrder[b.type] || this.drawCallRank(inst, a) - this.drawCallRank(inst, b));
3673
+ inst.shadowDrawCalls = inst.drawCalls.filter((d) => d.type === "opaque");
3499
3674
  }
3500
3675
  /**
3501
- * Slot pipeline states mirror createPipelines exactly — a compiled graph swaps the
3502
- * fragment shading of a slot, never its pass integration (stencil interplay, depth
3503
- * bias, cull mode stay slot-owned; see docs/graph-compiler-spec.md §6).
3676
+ * Render-class pipeline state. A group's compiled graph swaps the fragment shading; the
3677
+ * render-class owns pass integration (stencil interplay, depth bias, cull). auto = plain;
3678
+ * eye = stamp + front cull + bias; hair = stencil-test (+ the over-eyes variant).
3504
3679
  */
3505
- createSlotPipeline(slot, module, overEyes) {
3680
+ createRenderClassPipeline(renderClass, module, overEyes) {
3506
3681
  const base = {
3507
- label: `style ${slot}${overEyes ? " (over eyes)" : ""}`,
3682
+ label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
3508
3683
  layout: this.mainPipelineLayout,
3509
3684
  vertex: { module, buffers: this.fullVertexBufferLayouts },
3510
- primitive: { cullMode: (slot === "eye" ? "front" : "none") },
3685
+ primitive: { cullMode: (renderClass === "eye" ? "front" : "none") },
3511
3686
  multisample: { count: Engine.MULTISAMPLE_COUNT },
3512
3687
  };
3513
3688
  const plainDepth = {
@@ -3517,7 +3692,7 @@ export class Engine {
3517
3692
  };
3518
3693
  let depthStencil = plainDepth;
3519
3694
  let constants;
3520
- if (slot === "hair" && !overEyes) {
3695
+ if (renderClass === "hair" && !overEyes) {
3521
3696
  depthStencil = {
3522
3697
  ...plainDepth,
3523
3698
  stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
@@ -3526,7 +3701,7 @@ export class Engine {
3526
3701
  stencilWriteMask: 0,
3527
3702
  };
3528
3703
  }
3529
- else if (slot === "hair" && overEyes) {
3704
+ else if (renderClass === "hair" && overEyes) {
3530
3705
  constants = { IS_OVER_EYES: 1 };
3531
3706
  depthStencil = {
3532
3707
  format: "depth24plus-stencil8",
@@ -3538,7 +3713,7 @@ export class Engine {
3538
3713
  stencilWriteMask: 0,
3539
3714
  };
3540
3715
  }
3541
- else if (slot === "eye") {
3716
+ else if (renderClass === "eye") {
3542
3717
  depthStencil = {
3543
3718
  ...plainDepth,
3544
3719
  depthBias: -0.00005,
@@ -3556,12 +3731,17 @@ export class Engine {
3556
3731
  depthStencil,
3557
3732
  });
3558
3733
  }
3734
+ // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
3735
+ // the built-in preset (hand-shader) pipeline.
3736
+ pipelineForDrawCall(inst, dc) {
3737
+ if (dc.groupId) {
3738
+ const install = inst.styleGroups.get(dc.groupId);
3739
+ if (install)
3740
+ return install.pipeline;
3741
+ }
3742
+ return this.pipelineForPreset(dc.preset);
3743
+ }
3559
3744
  pipelineForPreset(preset) {
3560
- // Applied style graph wins over the built-in preset shader (mmd_classic is never
3561
- // in the override map — it's not a stylable slot).
3562
- const override = this.styleOverrides.get(preset);
3563
- if (override)
3564
- return override.pipeline;
3565
3745
  if (preset === "face")
3566
3746
  return this.facePipeline;
3567
3747
  if (preset === "hair")
@@ -3599,7 +3779,7 @@ export class Engine {
3599
3779
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
3600
3780
  bound = true;
3601
3781
  }
3602
- const pipeline = this.pipelineForPreset(draw.preset);
3782
+ const pipeline = this.pipelineForDrawCall(inst, draw);
3603
3783
  if (pipeline !== currentPipeline) {
3604
3784
  pass.setPipeline(pipeline);
3605
3785
  currentPipeline = pipeline;
@@ -3650,26 +3830,41 @@ export class Engine {
3650
3830
  this.drawOutlines(pass, inst, "transparent-outline");
3651
3831
  }
3652
3832
  /**
3653
- * Second hair pass for the see-through-hair effect. Re-draws every hair opaque
3654
- * draw using `hairOverEyesPipeline`which stencil-matches `EYE_VALUE` and runs
3655
- * the hair shader with `IS_OVER_EYES=true` so alpha is scaled to 25%. depthWriteEnabled
3656
- * is off, so the eye's depth stays authoritative for anything drawn after.
3833
+ * Second hair pass for the see-through-hair effect. Re-draws every hair-class opaque
3834
+ * draw with its over-eyes pipeline — stencil-matched to `EYE_VALUE`, `IS_OVER_EYES=true`
3835
+ * (25% alpha), depth-write off. Covers both grouped hair-class draws (their compiled
3836
+ * over-eyes pipeline) and ungrouped hair-preset draws (the hand `hairOverEyesPipeline`).
3657
3837
  */
3658
3838
  drawHairOverEyes(pass, inst) {
3659
3839
  let bound = false;
3840
+ let currentPipeline = null;
3660
3841
  for (const draw of inst.drawCalls) {
3661
- if (draw.type !== "opaque" || draw.preset !== "hair" || !this.shouldRenderDrawCall(inst, draw))
3842
+ if (draw.type !== "opaque" || !this.shouldRenderDrawCall(inst, draw))
3843
+ continue;
3844
+ const overEyes = this.overEyesPipelineFor(inst, draw);
3845
+ if (!overEyes)
3662
3846
  continue;
3663
3847
  if (!bound) {
3664
- pass.setPipeline(this.styleOverrides.get("hair")?.overEyesPipeline ?? this.hairOverEyesPipeline);
3665
3848
  pass.setBindGroup(0, this.perFrameBindGroup);
3666
3849
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
3667
3850
  bound = true;
3668
3851
  }
3852
+ if (overEyes !== currentPipeline) {
3853
+ pass.setPipeline(overEyes);
3854
+ currentPipeline = overEyes;
3855
+ }
3669
3856
  pass.setBindGroup(2, draw.bindGroup);
3670
3857
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
3671
3858
  }
3672
3859
  }
3860
+ // The over-eyes pipeline for a hair draw call, or null if it isn't hair-class.
3861
+ overEyesPipelineFor(inst, dc) {
3862
+ if (dc.groupId) {
3863
+ const install = inst.styleGroups.get(dc.groupId);
3864
+ return install?.renderClass === "hair" ? (install.overEyesPipeline ?? null) : null;
3865
+ }
3866
+ return dc.preset === "hair" ? this.hairOverEyesPipeline : null;
3867
+ }
3673
3868
  updateCameraUniforms() {
3674
3869
  const viewMatrix = this.camera.getViewMatrix();
3675
3870
  const projectionMatrix = this.camera.getProjectionMatrix();