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/src/engine.ts CHANGED
@@ -41,6 +41,22 @@ import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
41
41
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
42
42
  import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
43
43
  import type { Diagnostic, StyleGraph } from "./graph/schema"
44
+ import type { AlphaMode, RenderClass } from "./graph/render-class"
45
+ import type {
46
+ ApplyStyleGroupResult,
47
+ ApplyStyleGroupsResult,
48
+ GroupDiagnostic,
49
+ StyleGroup,
50
+ } from "./graph/style-group"
51
+ import { DEFAULT_GRAPH } from "./graph/presets/default"
52
+ import { FACE_GRAPH } from "./graph/presets/face"
53
+ import { HAIR_GRAPH } from "./graph/presets/hair"
54
+ import { BODY_GRAPH } from "./graph/presets/body"
55
+ import { EYE_GRAPH } from "./graph/presets/eye"
56
+ import { STOCKINGS_GRAPH } from "./graph/presets/stockings"
57
+ import { METAL_GRAPH } from "./graph/presets/metal"
58
+ import { CLOTH_SMOOTH_GRAPH } from "./graph/presets/cloth_smooth"
59
+ import { CLOTH_ROUGH_GRAPH } from "./graph/presets/cloth_rough"
44
60
 
45
61
  // Material preset dispatch. Consumers supply a MaterialPresetMap assigning material names
46
62
  // to presets; unmapped materials fall back to "default" (Principled BSDF).
@@ -125,6 +141,22 @@ function resolvePreset(materialName: string, map: MaterialPresetMap | undefined)
125
141
  return "mmd_classic"
126
142
  }
127
143
 
144
+ // Default-group recipe per preset label: the shipped graph + its natural pass-integration
145
+ // (renderClass, alphaMode). This is the auto-default-groups mapping (docs §8) — the same
146
+ // label→integration knowledge the old fixed slots encoded, now producing editable groups.
147
+ // mmd_classic has no entry — those materials stay ungrouped (hand-shader path).
148
+ const PRESET_GROUP_INFO: Partial<Record<MaterialPreset, { graph: StyleGraph; renderClass: RenderClass; alphaMode: AlphaMode }>> = {
149
+ default: { graph: DEFAULT_GRAPH, renderClass: "auto", alphaMode: "opaque" },
150
+ face: { graph: FACE_GRAPH, renderClass: "auto", alphaMode: "opaque" },
151
+ hair: { graph: HAIR_GRAPH, renderClass: "hair", alphaMode: "opaque" },
152
+ body: { graph: BODY_GRAPH, renderClass: "auto", alphaMode: "opaque" },
153
+ eye: { graph: EYE_GRAPH, renderClass: "eye", alphaMode: "opaque" },
154
+ stockings: { graph: STOCKINGS_GRAPH, renderClass: "auto", alphaMode: "hashed" },
155
+ metal: { graph: METAL_GRAPH, renderClass: "auto", alphaMode: "opaque" },
156
+ cloth_smooth: { graph: CLOTH_SMOOTH_GRAPH, renderClass: "auto", alphaMode: "opaque" },
157
+ cloth_rough: { graph: CLOTH_ROUGH_GRAPH, renderClass: "auto", alphaMode: "opaque" },
158
+ }
159
+
128
160
  // Map a WGSL compile-error line back to the graph node whose `let` produced it —
129
161
  // the compiler tags every generated line with a trailing `// @node:<id>` marker.
130
162
  function nodeIdForWgslLine(wgsl: string, lineNum: number): string | undefined {
@@ -136,20 +168,21 @@ function nodeIdForWgslLine(wgsl: string, lineNum: number): string | undefined {
136
168
  return undefined
137
169
  }
138
170
 
139
- // An applied style graph on a preset slot: the swapped pipeline(s) plus the
140
- // slider→UBO map that setStyleParam consults for instant uniform writes.
141
- type StyleOverride = {
171
+ // A compiled + installed style group on a model: the swapped pipeline(s), the group's
172
+ // StyleUniforms buffer, the slider→UBO map setStyleParam consults, and the resolved
173
+ // render-class (draw-order + over-eyes participation). Keyed per-model by group id.
174
+ type GroupInstall = {
175
+ group: StyleGroup
176
+ renderClass: RenderClass
177
+ alphaMode: AlphaMode
142
178
  pipeline: GPURenderPipeline
143
- /** Hair slot only: the stencil-matched IS_OVER_EYES=true variant. */
179
+ /** hair render-class only: the stencil-matched IS_OVER_EYES=true variant. */
144
180
  overEyesPipeline?: GPURenderPipeline
181
+ uniformBuffer: GPUBuffer
145
182
  slotMap: StyleSlot[]
146
- }
147
-
148
- /** Result of Engine.applyStyleGraph — compile/pipeline diagnostics + slider slot map. */
149
- export type ApplyStyleResult = {
150
- ok: boolean
151
- diagnostics: Diagnostic[]
152
- slotMap: StyleSlot[]
183
+ /** Serialized (graph + renderClass + alphaMode) — lets applyStyleGroups skip recompiling
184
+ * an unchanged group. */
185
+ signature: string
153
186
  }
154
187
 
155
188
  export type RaycastCallback = (
@@ -295,8 +328,13 @@ interface DrawCall {
295
328
  bindGroup: GPUBindGroup
296
329
  materialName: string
297
330
  preset: ResolvedMaterialPreset
298
- // Bindings 0–3 kept so the bind group can be rebuilt when setMaterialPresets moves
299
- // this material to a different slot (binding 4 must follow the slot's style buffer).
331
+ // Style group this material belongs to, or null (ungrouped hand-shader path).
332
+ // Outline/ground draw calls are never grouped and leave this null.
333
+ groupId: string | null
334
+ // Bindings 0–3 kept so the bind group can be rebuilt when the material's group changes
335
+ // (binding 4 must follow the group's style buffer, or the zero buffer when ungrouped).
336
+ // Present only for material draw calls (opaque/transparent) — the grouping walk skips
337
+ // any draw call without it.
300
338
  baseBindGroupEntries?: GPUBindGroupEntry[]
301
339
  }
302
340
 
@@ -329,6 +367,13 @@ interface ModelInstance {
329
367
  physics: RezePhysics | null
330
368
  vertexBufferNeedsUpdate: boolean
331
369
  gpuMorph: GpuMorph | null
370
+ // Style groups applied to this model: group id → compiled install.
371
+ styleGroups: Map<string, GroupInstall>
372
+ // Material name → group id (each material in ≤1 group). Drives draw-call assignment.
373
+ materialToGroup: Map<string, string>
374
+ // Per-group compile generation — an async compile finishing after a newer edit/remove
375
+ // on the same id is discarded (stale-write guard).
376
+ styleGroupGen: Map<string, number>
332
377
  }
333
378
 
334
379
  // Per-model GPU vertex-morph compute state. Present only for models with vertex morphs.
@@ -378,18 +423,12 @@ export class Engine {
378
423
  private hairOverEyesPipeline!: GPURenderPipeline
379
424
  private stockingsPipeline!: GPURenderPipeline
380
425
  private mmdClassicPipeline!: GPURenderPipeline
381
- // ── Style graph runtime (graph/ compiler output applied to preset slots) ──
382
- // Per-slot 256 B StyleUniforms buffers (group(2) binding(4)). Created eagerly so
383
- // material bind groups can reference them at model-load time regardless of whether
384
- // a graph is ever applied; mmd_classic materials bind the shared zero buffer.
385
- private styleBuffers = new Map<MaterialPreset, GPUBuffer>()
426
+ // ── Style group runtime ──
427
+ // Shared 256 B zero StyleUniforms buffer (group(2) binding(4)) bound by every ungrouped
428
+ // material; grouped materials rebind to their group's own buffer (per-model, in the
429
+ // ModelInstance's styleGroups map). See docs/style-groups-spec.md §6.
386
430
  private zeroStyleBuffer!: GPUBuffer
387
- // Applied graphs: slot swapped pipeline(s) + the slider→UBO map for setStyleParam.
388
- private styleOverrides = new Map<MaterialPreset, StyleOverride>()
389
- // Per-slot generation counter — an async compile that finishes after a newer
390
- // applyStyleGraph/resetStyleSlot call on the same slot is discarded (stale guard).
391
- private styleGenerations = new Map<MaterialPreset, number>()
392
- // Stashed at createPipelines so applyStyleGraph can rebuild slot pipelines later.
431
+ // Stashed at createPipelines so group pipelines can be compiled later.
393
432
  private mainPipelineLayout!: GPUPipelineLayout
394
433
  private sceneTargets!: GPUColorTargetState[]
395
434
  private fullVertexBufferLayouts!: GPUVertexBufferLayout[]
@@ -1197,33 +1236,13 @@ export class Engine {
1197
1236
  ],
1198
1237
  })
1199
1238
 
1200
- // Per-slot style buffers + shared zero buffer (mmd_classic / never-styled slots).
1239
+ // Shared zero StyleUniforms buffer bound by every ungrouped material; grouped
1240
+ // materials rebind binding(4) to their group's own buffer (per model, per group).
1201
1241
  this.zeroStyleBuffer = this.device.createBuffer({
1202
1242
  label: "style uniforms (zero)",
1203
1243
  size: 256,
1204
1244
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1205
1245
  })
1206
- const ALL_PRESETS: MaterialPreset[] = [
1207
- "default",
1208
- "face",
1209
- "hair",
1210
- "body",
1211
- "eye",
1212
- "stockings",
1213
- "metal",
1214
- "cloth_smooth",
1215
- "cloth_rough",
1216
- ]
1217
- for (const preset of ALL_PRESETS) {
1218
- this.styleBuffers.set(
1219
- preset,
1220
- this.device.createBuffer({
1221
- label: `style uniforms (${preset})`,
1222
- size: 256,
1223
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1224
- }),
1225
- )
1226
- }
1227
1246
 
1228
1247
  const mainPipelineLayout = this.device.createPipelineLayout({
1229
1248
  label: "main pipeline layout",
@@ -2509,6 +2528,7 @@ export class Engine {
2509
2528
  bindGroup: this.groundShadowBindGroup!,
2510
2529
  materialName: "Ground",
2511
2530
  preset: "cloth_rough",
2531
+ groupId: null,
2512
2532
  }
2513
2533
  }
2514
2534
 
@@ -2566,11 +2586,13 @@ export class Engine {
2566
2586
  this.resizeObserver = null
2567
2587
  }
2568
2588
 
2569
- // Style graph runtime: per-slot uniform buffers + swapped pipelines (pipelines
2570
- // are GC'd; buffers need explicit destroy).
2571
- this.styleOverrides.clear()
2572
- for (const buf of this.styleBuffers.values()) buf.destroy()
2573
- this.styleBuffers.clear()
2589
+ // Style group runtime: per-group uniform buffers (pipelines are GC'd; buffers need
2590
+ // explicit destroy). Per-model group buffers are torn down in removeModel; the shared
2591
+ // zero buffer is engine-owned.
2592
+ this.forEachInstance((inst) => {
2593
+ for (const install of inst.styleGroups.values()) install.uniformBuffer.destroy()
2594
+ inst.styleGroups.clear()
2595
+ })
2574
2596
  this.zeroStyleBuffer?.destroy()
2575
2597
  }
2576
2598
 
@@ -2629,6 +2651,8 @@ export class Engine {
2629
2651
  for (const buf of inst.gpuBuffers) {
2630
2652
  buf.destroy()
2631
2653
  }
2654
+ // Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
2655
+ for (const install of inst.styleGroups.values()) install.uniformBuffer.destroy()
2632
2656
  this.modelInstances.delete(name)
2633
2657
  }
2634
2658
 
@@ -2677,22 +2701,15 @@ export class Engine {
2677
2701
  const inst = this.modelInstances.get(modelName)
2678
2702
  if (!inst) return
2679
2703
  inst.materialPresets = presets
2680
- for (const dc of inst.drawCalls) {
2681
- const preset = resolvePreset(dc.materialName, presets)
2682
- if (preset === dc.preset) continue
2683
- dc.preset = preset
2684
- // Rebind so binding(4) follows the new slot's style buffer.
2685
- if (dc.baseBindGroupEntries)
2686
- dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, preset)
2687
- }
2704
+ // Only updates the ungrouped fallback preset; grouped materials render via their
2705
+ // group regardless. binding(4) is unaffected (ungrouped always binds the zero
2706
+ // buffer; grouped bindings are owned by applyStyleGroups).
2707
+ for (const dc of inst.drawCalls) dc.preset = resolvePreset(dc.materialName, presets)
2688
2708
  }
2689
2709
 
2690
- private createMaterialBindGroup(
2691
- label: string,
2692
- baseEntries: GPUBindGroupEntry[],
2693
- preset: ResolvedMaterialPreset,
2694
- ): GPUBindGroup {
2695
- const styleBuffer = preset === "mmd_classic" ? this.zeroStyleBuffer : this.styleBuffers.get(preset)!
2710
+ // Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
2711
+ // (the group's buffer when grouped, or the shared zero buffer when ungrouped).
2712
+ private createMaterialBindGroup(label: string, baseEntries: GPUBindGroupEntry[], styleBuffer: GPUBuffer): GPUBindGroup {
2696
2713
  return this.device.createBindGroup({
2697
2714
  label,
2698
2715
  layout: this.mainPerMaterialBindGroupLayout,
@@ -2941,6 +2958,9 @@ export class Engine {
2941
2958
  physics,
2942
2959
  vertexBufferNeedsUpdate: false,
2943
2960
  gpuMorph,
2961
+ styleGroups: new Map(),
2962
+ materialToGroup: new Map(),
2963
+ styleGroupGen: new Map(),
2944
2964
  }
2945
2965
  await this.setupMaterialsForInstance(inst)
2946
2966
  this.modelInstances.set(name, inst)
@@ -3228,7 +3248,12 @@ export class Engine {
3228
3248
  { binding: 2, resource: (toonTexture ?? this.fallbackMaterialTexture).createView() },
3229
3249
  { binding: 3, resource: (sphereTexture ?? this.fallbackMaterialTexture).createView() },
3230
3250
  ]
3231
- const bindGroup = this.createMaterialBindGroup(`${prefix}material: ${mat.name}`, baseBindGroupEntries, preset)
3251
+ // Ungrouped at load — binding(4) = zero buffer. applyStyleGroups rebinds grouped ones.
3252
+ const bindGroup = this.createMaterialBindGroup(
3253
+ `${prefix}material: ${mat.name}`,
3254
+ baseBindGroupEntries,
3255
+ this.zeroStyleBuffer,
3256
+ )
3232
3257
 
3233
3258
  const type: DrawCallType = isTransparent ? "transparent" : "opaque"
3234
3259
  inst.drawCalls.push({
@@ -3238,6 +3263,7 @@ export class Engine {
3238
3263
  bindGroup,
3239
3264
  materialName: mat.name,
3240
3265
  preset,
3266
+ groupId: null,
3241
3267
  baseBindGroupEntries,
3242
3268
  })
3243
3269
 
@@ -3267,6 +3293,7 @@ export class Engine {
3267
3293
  bindGroup: outlineBindGroup,
3268
3294
  materialName: mat.name,
3269
3295
  preset,
3296
+ groupId: null,
3270
3297
  })
3271
3298
  }
3272
3299
 
@@ -3285,29 +3312,14 @@ export class Engine {
3285
3312
  currentIndexOffset += indexCount
3286
3313
  }
3287
3314
 
3288
- // Sort so the opaque bucket is emitted in the order the stencil-based
3289
- // see-through-hair effect requires: {non-hair, non-eye} → {eye} → {hair}.
3290
- // Eye writes stencil=EYE_VALUE; hair's pipeline stencil-tests "not equal" so
3291
- // it skips eye pixels; a follow-up hairOverEyes pass (see renderOneModel)
3292
- // re-fills those skipped pixels alpha-blended. Array.sort is stable in
3293
- // ES2019+, so within a bucket the PMX material order is preserved.
3294
- const typeOrder: Record<DrawCallType, number> = {
3295
- opaque: 0,
3296
- "opaque-outline": 1,
3297
- transparent: 2,
3298
- "transparent-outline": 3,
3299
- ground: 4,
3300
- }
3301
- const presetRank = (p: ResolvedMaterialPreset): number => (p === "hair" ? 2 : p === "eye" ? 1 : 0)
3302
- inst.drawCalls.sort((a, b) => {
3303
- const ta = typeOrder[a.type] - typeOrder[b.type]
3304
- if (ta !== 0) return ta
3305
- return presetRank(a.preset) - presetRank(b.preset)
3306
- })
3307
-
3308
- for (const d of inst.drawCalls) {
3309
- if (d.type === "opaque") inst.shadowDrawCalls.push(d)
3310
- }
3315
+ // Sort so the opaque bucket is emitted in the order the stencil-based see-through-hair
3316
+ // effect requires: {non-hair, non-eye} → {eye} → {hair}. Eye writes stencil=EYE_VALUE;
3317
+ // hair stencil-tests "not equal" and skips eye pixels; the follow-up hairOverEyes pass
3318
+ // re-fills them alpha-blended. sortDrawCalls also (re)builds shadowDrawCalls. All draws
3319
+ // are ungrouped at setup, so the rank comes from the preset; applyStyleGroups re-sorts
3320
+ // by render-class when groups are assigned. Array.sort is stable → PMX order preserved
3321
+ // within a bucket.
3322
+ this.sortDrawCalls(inst)
3311
3323
  }
3312
3324
 
3313
3325
  private createMaterialUniformBuffer(
@@ -4144,36 +4156,201 @@ export class Engine {
4144
4156
  }
4145
4157
  }
4146
4158
 
4147
- // ─── Style graph API ──────────────────────────────────────────────
4148
- // Two-tier edit model: applyStyleGraph = topology tier (async compile + pipeline
4149
- // swap, previous look keeps rendering until the new pipeline is ready or on any
4150
- // error); setStyleParam = adjust tier (uniform write, instant, no pipeline touch).
4159
+ // ─── Style group API ──────────────────────────────────────────────
4160
+ // Two-tier edits: applyStyleGroups/upsert = topology (async compile + pipeline swap,
4161
+ // fallback-on-error, per-group stale guard); setStyleParam = adjust (instant uniform
4162
+ // write). Overlay-first: grouped materials render via their group's compiled graph;
4163
+ // ungrouped ones keep the hand-written preset path. See docs/style-groups-spec.md.
4164
+
4165
+ /** Read a model's current style groups (including auto-created defaults) for editor
4166
+ * round-trip. The host owns group state; this is bootstrap/read, not a second store. */
4167
+ getStyleGroups(modelName: string): StyleGroup[] {
4168
+ const inst = this.modelInstances.get(modelName)
4169
+ if (!inst) return []
4170
+ return [...inst.styleGroups.values()].map((g) => g.group)
4171
+ }
4172
+
4173
+ /**
4174
+ * Create default style groups from the model's material→preset resolution
4175
+ * (`setMaterialPresets` map first, then name hints), so a freshly loaded model comes up
4176
+ * editably grouped with zero interaction. Materials resolving to `mmd_classic` (no map
4177
+ * entry, no hint) stay ungrouped. The returned promise resolves after grouping AND the
4178
+ * async graph compiles, so `getStyleGroups` is populated the moment it resolves. Opt-in:
4179
+ * call it explicitly (typically after `setMaterialPresets`); a bare load stays ungrouped
4180
+ * / byte-identical to before.
4181
+ */
4182
+ async autoStyleGroups(modelName: string): Promise<ApplyStyleGroupsResult> {
4183
+ const inst = this.modelInstances.get(modelName)
4184
+ if (!inst) return { ok: false, groups: [], unknownMaterials: [], conflicts: [] }
4185
+
4186
+ const buckets = new Map<MaterialPreset, string[]>()
4187
+ for (const dc of inst.drawCalls) {
4188
+ if (!dc.baseBindGroupEntries) continue // material draw calls only (skip outlines)
4189
+ const preset = resolvePreset(dc.materialName, inst.materialPresets)
4190
+ if (preset === "mmd_classic") continue // unmatched → ungrouped
4191
+ const arr = buckets.get(preset) ?? []
4192
+ if (!arr.includes(dc.materialName)) arr.push(dc.materialName)
4193
+ buckets.set(preset, arr)
4194
+ }
4195
+
4196
+ const groups: StyleGroup[] = []
4197
+ for (const [preset, materials] of buckets) {
4198
+ const info = PRESET_GROUP_INFO[preset]
4199
+ if (!info) continue
4200
+ groups.push({
4201
+ id: preset,
4202
+ label: info.graph.name,
4203
+ materials,
4204
+ graph: info.graph,
4205
+ renderClass: info.renderClass,
4206
+ alphaMode: info.alphaMode,
4207
+ })
4208
+ }
4209
+ return this.applyStyleGroups(modelName, groups)
4210
+ }
4151
4211
 
4152
4212
  /**
4153
- * Compile a style graph and swap it onto its preset slot. Fallback-on-error: the
4154
- * slot's current pipeline keeps rendering unless compilation fully succeeds.
4155
- * Slider defaults are written to the slot's StyleUniforms buffer on success.
4213
+ * Replace a model's full style-group set. Unchanged groups (same graph + renderClass +
4214
+ * alphaMode) keep their pipeline; new/changed ones compile and swap; removed ones are
4215
+ * torn down and their materials revert to the ungrouped hand-shader path. A group whose
4216
+ * compile fails is not installed and its materials stay ungrouped (fallback-on-error).
4156
4217
  */
4157
- async applyStyleGraph(graph: StyleGraph, opts?: CompileOptions): Promise<ApplyStyleResult> {
4158
- const result = compileGraph(graph, opts)
4218
+ async applyStyleGroups(modelName: string, groups: StyleGroup[]): Promise<ApplyStyleGroupsResult> {
4219
+ const inst = this.modelInstances.get(modelName)
4220
+ if (!inst) return { ok: false, groups: [], unknownMaterials: [], conflicts: [] }
4221
+
4222
+ // Whole-set validation: material claims (last group in array order wins), unknowns.
4223
+ const modelMaterials = new Set(inst.drawCalls.map((d) => d.materialName))
4224
+ const claimed = new Map<string, string>()
4225
+ const conflicts = new Set<string>()
4226
+ const unknownMaterials = new Set<string>()
4227
+ for (const g of groups) {
4228
+ for (const m of g.materials) {
4229
+ if (!modelMaterials.has(m)) unknownMaterials.add(m)
4230
+ if (claimed.has(m)) conflicts.add(m)
4231
+ claimed.set(m, g.id)
4232
+ }
4233
+ }
4234
+
4235
+ // Tear down installs no longer present.
4236
+ const nextIds = new Set(groups.map((g) => g.id))
4237
+ for (const [id, install] of inst.styleGroups) {
4238
+ if (!nextIds.has(id)) {
4239
+ inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1)
4240
+ install.uniformBuffer.destroy()
4241
+ inst.styleGroups.delete(id)
4242
+ }
4243
+ }
4244
+
4245
+ const groupResults: GroupDiagnostic[] = []
4246
+ for (const g of groups) {
4247
+ const r = await this.compileAndInstallGroup(inst, g)
4248
+ groupResults.push({ groupId: g.id, diagnostics: r.diagnostics, ok: r.ok })
4249
+ }
4250
+
4251
+ this.assignDrawCallGroups(inst, claimed)
4252
+ return {
4253
+ ok: groupResults.every((r) => r.ok),
4254
+ groups: groupResults,
4255
+ unknownMaterials: [...unknownMaterials],
4256
+ conflicts: [...conflicts],
4257
+ }
4258
+ }
4259
+
4260
+ /** Add or replace a single style group by id. `opts` may carry a `previewNode` for the
4261
+ * editor's node-output preview workflow. */
4262
+ async upsertStyleGroup(modelName: string, group: StyleGroup, opts?: CompileOptions): Promise<ApplyStyleGroupResult> {
4263
+ const inst = this.modelInstances.get(modelName)
4264
+ if (!inst)
4265
+ return { ok: false, diagnostics: [{ severity: "error", message: `unknown model "${modelName}"` }], slotMap: [] }
4266
+ const r = await this.compileAndInstallGroup(inst, group, opts)
4267
+ this.assignDrawCallGroups(inst, this.currentClaims(inst))
4268
+ return r
4269
+ }
4270
+
4271
+ /** Remove a style group; its materials revert to the ungrouped hand-shader path. */
4272
+ removeStyleGroup(modelName: string, groupId: string): void {
4273
+ const inst = this.modelInstances.get(modelName)
4274
+ const install = inst?.styleGroups.get(groupId)
4275
+ if (!inst || !install) return
4276
+ inst.styleGroupGen.set(groupId, (inst.styleGroupGen.get(groupId) ?? 0) + 1) // discard in-flight compile
4277
+ install.uniformBuffer.destroy()
4278
+ inst.styleGroups.delete(groupId)
4279
+ this.assignDrawCallGroups(inst, this.currentClaims(inst))
4280
+ }
4281
+
4282
+ /** Clear all style groups on a model — every material returns to the hand-shader path. */
4283
+ resetStyleGroups(modelName: string): void {
4284
+ const inst = this.modelInstances.get(modelName)
4285
+ if (!inst) return
4286
+ for (const [id, install] of inst.styleGroups) {
4287
+ inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1)
4288
+ install.uniformBuffer.destroy()
4289
+ }
4290
+ inst.styleGroups.clear()
4291
+ this.assignDrawCallGroups(inst, new Map())
4292
+ }
4293
+
4294
+ /** Instant adjust-tier write: set one exposed slider on a group's applied graph. */
4295
+ setStyleParam(
4296
+ modelName: string,
4297
+ groupId: string,
4298
+ paramId: string,
4299
+ value: number | [number, number, number],
4300
+ ): boolean {
4301
+ const install = this.modelInstances.get(modelName)?.styleGroups.get(groupId)
4302
+ const styleSlot = install?.slotMap.find((s) => s.id === paramId)
4303
+ if (!install || !styleSlot) return false
4304
+ if (styleSlot.kind === "float") {
4305
+ if (typeof value !== "number") return false
4306
+ const offset = styleSlot.vec4Index * 16 + ["x", "y", "z", "w"].indexOf(styleSlot.component!) * 4
4307
+ this.device.queue.writeBuffer(install.uniformBuffer, offset, new Float32Array([value]))
4308
+ } else {
4309
+ if (typeof value === "number") return false
4310
+ this.device.queue.writeBuffer(install.uniformBuffer, styleSlot.vec4Index * 16, new Float32Array(value))
4311
+ }
4312
+ return true
4313
+ }
4314
+
4315
+ // Materials claimed by the model's currently-installed groups (for upsert/remove paths;
4316
+ // applyStyleGroups derives claims from its input array instead).
4317
+ private currentClaims(inst: ModelInstance): Map<string, string> {
4318
+ const claimed = new Map<string, string>()
4319
+ for (const install of inst.styleGroups.values())
4320
+ for (const m of install.group.materials) claimed.set(m, install.group.id)
4321
+ return claimed
4322
+ }
4323
+
4324
+ // Compile a group's graph → WGSL → pipeline(s), install keyed by group id. Reuses the
4325
+ // install (pipeline + uniform buffer) when the graph/integration is byte-unchanged.
4326
+ private async compileAndInstallGroup(
4327
+ inst: ModelInstance,
4328
+ group: StyleGroup,
4329
+ opts?: CompileOptions,
4330
+ ): Promise<ApplyStyleGroupResult> {
4331
+ const renderClass = group.renderClass ?? "auto"
4332
+ const alphaMode = group.alphaMode ?? "opaque"
4333
+ const signature = JSON.stringify({ g: group.graph, rc: renderClass, am: alphaMode, o: opts?.previewNode ?? null })
4334
+ const existing = inst.styleGroups.get(group.id)
4335
+ if (existing && existing.signature === signature) {
4336
+ existing.group = group // refresh def (label/materials) without recompiling
4337
+ return { ok: true, diagnostics: [], slotMap: existing.slotMap }
4338
+ }
4339
+
4340
+ const result = compileGraph(group.graph, { ...opts, renderClass, alphaMode })
4159
4341
  if (!result.ok) return { ok: false, diagnostics: result.diagnostics, slotMap: result.slotMap }
4160
4342
 
4161
- const slot = graph.slot
4162
- const generation = (this.styleGenerations.get(slot) ?? 0) + 1
4163
- this.styleGenerations.set(slot, generation)
4343
+ const generation = (inst.styleGroupGen.get(group.id) ?? 0) + 1
4344
+ inst.styleGroupGen.set(group.id, generation)
4164
4345
 
4165
4346
  this.device.pushErrorScope("validation")
4166
- const module = this.device.createShaderModule({ label: `style graph: ${graph.name} (${slot})`, code: result.wgsl })
4347
+ const module = this.device.createShaderModule({ label: `style group: ${group.id} (${renderClass})`, code: result.wgsl })
4167
4348
  const info = await module.getCompilationInfo()
4168
4349
  const scopeError = await this.device.popErrorScope()
4169
4350
  const diagnostics = [...result.diagnostics]
4170
4351
  for (const msg of info.messages) {
4171
4352
  if (msg.type !== "error") continue
4172
- diagnostics.push({
4173
- severity: "error",
4174
- nodeId: nodeIdForWgslLine(result.wgsl, msg.lineNum),
4175
- message: `WGSL: ${msg.message}`,
4176
- })
4353
+ diagnostics.push({ severity: "error", nodeId: nodeIdForWgslLine(result.wgsl, msg.lineNum), message: `WGSL: ${msg.message}` })
4177
4354
  }
4178
4355
  if (diagnostics.some((d) => d.severity === "error") || scopeError) {
4179
4356
  if (scopeError && !diagnostics.some((d) => d.severity === "error"))
@@ -4184,51 +4361,66 @@ export class Engine {
4184
4361
  let pipeline: GPURenderPipeline
4185
4362
  let overEyesPipeline: GPURenderPipeline | undefined
4186
4363
  try {
4187
- pipeline = await this.createSlotPipeline(slot, module, false)
4188
- if (slot === "hair") overEyesPipeline = await this.createSlotPipeline(slot, module, true)
4364
+ pipeline = await this.createRenderClassPipeline(renderClass, module, false)
4365
+ if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true)
4189
4366
  } catch (e) {
4190
4367
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
4191
4368
  return { ok: false, diagnostics, slotMap: result.slotMap }
4192
4369
  }
4193
4370
 
4194
- // A newer apply/reset on this slot finished (or started) while we compiled.
4195
- if (this.styleGenerations.get(slot) !== generation) {
4371
+ // Stale guard: a newer compile/remove for this id happened while we awaited.
4372
+ if (inst.styleGroupGen.get(group.id) !== generation) {
4196
4373
  diagnostics.push({ severity: "warning", message: "superseded by a newer edit — result discarded" })
4197
4374
  return { ok: false, diagnostics, slotMap: result.slotMap }
4198
4375
  }
4199
4376
 
4200
- this.styleOverrides.set(slot, { pipeline, overEyesPipeline, slotMap: result.slotMap })
4201
- this.writeStyleDefaults(slot, graph, result.slotMap)
4377
+ const uniformBuffer =
4378
+ existing?.uniformBuffer ??
4379
+ this.device.createBuffer({
4380
+ label: `style uniforms: ${group.id}`,
4381
+ size: 256,
4382
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
4383
+ })
4384
+
4385
+ inst.styleGroups.set(group.id, {
4386
+ group,
4387
+ renderClass,
4388
+ alphaMode,
4389
+ pipeline,
4390
+ overEyesPipeline,
4391
+ uniformBuffer,
4392
+ slotMap: result.slotMap,
4393
+ signature,
4394
+ })
4395
+ this.writeGroupDefaults(uniformBuffer, group, result.slotMap)
4202
4396
  return { ok: true, diagnostics, slotMap: result.slotMap }
4203
4397
  }
4204
4398
 
4205
- /** Instant adjust-tier write: set one exposed slider on the slot's applied graph. */
4206
- setStyleParam(slot: MaterialPreset, paramId: string, value: number | [number, number, number]): boolean {
4207
- const override = this.styleOverrides.get(slot)
4208
- const styleSlot = override?.slotMap.find((s) => s.id === paramId)
4209
- const buffer = this.styleBuffers.get(slot)
4210
- if (!styleSlot || !buffer) return false
4211
- if (styleSlot.kind === "float") {
4212
- if (typeof value !== "number") return false
4213
- const offset = styleSlot.vec4Index * 16 + ["x", "y", "z", "w"].indexOf(styleSlot.component!) * 4
4214
- this.device.queue.writeBuffer(buffer, offset, new Float32Array([value]))
4215
- } else {
4216
- if (typeof value === "number") return false
4217
- this.device.queue.writeBuffer(buffer, styleSlot.vec4Index * 16, new Float32Array(value))
4399
+ // Rebind each material draw call to its (successfully-installed) group's uniform buffer,
4400
+ // or the zero buffer when ungrouped, then re-sort by render-class draw order.
4401
+ private assignDrawCallGroups(inst: ModelInstance, claimed: Map<string, string>): void {
4402
+ inst.materialToGroup.clear()
4403
+ for (const dc of inst.drawCalls) {
4404
+ if (!dc.baseBindGroupEntries) continue // outlines/ground are never grouped
4405
+ const wantId = claimed.get(dc.materialName)
4406
+ const install = wantId ? inst.styleGroups.get(wantId) : undefined
4407
+ const groupId = install ? wantId! : null
4408
+ if (groupId) inst.materialToGroup.set(dc.materialName, groupId)
4409
+ if (dc.groupId === groupId) continue
4410
+ dc.groupId = groupId
4411
+ dc.bindGroup = this.createMaterialBindGroup(
4412
+ `material: ${dc.materialName}`,
4413
+ dc.baseBindGroupEntries,
4414
+ install ? install.uniformBuffer : this.zeroStyleBuffer,
4415
+ )
4218
4416
  }
4219
- return true
4220
- }
4221
-
4222
- /** Remove an applied style graph — the slot returns to its built-in preset shader. */
4223
- resetStyleSlot(slot: MaterialPreset): void {
4224
- this.styleGenerations.set(slot, (this.styleGenerations.get(slot) ?? 0) + 1)
4225
- this.styleOverrides.delete(slot)
4417
+ this.sortDrawCalls(inst)
4226
4418
  }
4227
4419
 
4228
- private writeStyleDefaults(slot: MaterialPreset, graph: StyleGraph, slotMap: StyleSlot[]): void {
4420
+ private writeGroupDefaults(buffer: GPUBuffer, group: StyleGroup, slotMap: StyleSlot[]): void {
4229
4421
  const data = new Float32Array(64) // 16 vec4f
4230
4422
  for (const styleSlot of slotMap) {
4231
- const param = graph.params?.find((p) => p.id === styleSlot.id)
4423
+ const param = group.graph.params?.find((p) => p.id === styleSlot.id)
4232
4424
  if (!param) continue
4233
4425
  const base = styleSlot.vec4Index * 4
4234
4426
  if (styleSlot.kind === "float" && typeof param.default === "number") {
@@ -4237,20 +4429,46 @@ export class Engine {
4237
4429
  data.set(param.default.slice(0, 3), base)
4238
4430
  }
4239
4431
  }
4240
- this.device.queue.writeBuffer(this.styleBuffers.get(slot)!, 0, data)
4432
+ this.device.queue.writeBuffer(buffer, 0, data)
4433
+ }
4434
+
4435
+ // Draw-order rank within a bucket: eye stamps before hair reads. A grouped call ranks by
4436
+ // its group's render-class; an ungrouped call by its preset (the hand-shader path).
4437
+ private drawCallRank(inst: ModelInstance, dc: DrawCall): number {
4438
+ const rc = dc.groupId ? (inst.styleGroups.get(dc.groupId)?.renderClass ?? "auto") : undefined
4439
+ if (rc) return rc === "hair" ? 2 : rc === "eye" ? 1 : 0
4440
+ return dc.preset === "hair" ? 2 : dc.preset === "eye" ? 1 : 0
4441
+ }
4442
+
4443
+ private sortDrawCalls(inst: ModelInstance): void {
4444
+ const typeOrder: Record<DrawCallType, number> = {
4445
+ opaque: 0,
4446
+ "opaque-outline": 1,
4447
+ transparent: 2,
4448
+ "transparent-outline": 3,
4449
+ ground: 4,
4450
+ }
4451
+ inst.drawCalls.sort(
4452
+ (a, b) => typeOrder[a.type] - typeOrder[b.type] || this.drawCallRank(inst, a) - this.drawCallRank(inst, b),
4453
+ )
4454
+ inst.shadowDrawCalls = inst.drawCalls.filter((d) => d.type === "opaque")
4241
4455
  }
4242
4456
 
4243
4457
  /**
4244
- * Slot pipeline states mirror createPipelines exactly — a compiled graph swaps the
4245
- * fragment shading of a slot, never its pass integration (stencil interplay, depth
4246
- * bias, cull mode stay slot-owned; see docs/graph-compiler-spec.md §6).
4458
+ * Render-class pipeline state. A group's compiled graph swaps the fragment shading; the
4459
+ * render-class owns pass integration (stencil interplay, depth bias, cull). auto = plain;
4460
+ * eye = stamp + front cull + bias; hair = stencil-test (+ the over-eyes variant).
4247
4461
  */
4248
- private createSlotPipeline(slot: MaterialPreset, module: GPUShaderModule, overEyes: boolean): Promise<GPURenderPipeline> {
4462
+ private createRenderClassPipeline(
4463
+ renderClass: RenderClass,
4464
+ module: GPUShaderModule,
4465
+ overEyes: boolean,
4466
+ ): Promise<GPURenderPipeline> {
4249
4467
  const base = {
4250
- label: `style ${slot}${overEyes ? " (over eyes)" : ""}`,
4468
+ label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
4251
4469
  layout: this.mainPipelineLayout,
4252
4470
  vertex: { module, buffers: this.fullVertexBufferLayouts },
4253
- primitive: { cullMode: (slot === "eye" ? "front" : "none") as GPUCullMode },
4471
+ primitive: { cullMode: (renderClass === "eye" ? "front" : "none") as GPUCullMode },
4254
4472
  multisample: { count: Engine.MULTISAMPLE_COUNT },
4255
4473
  }
4256
4474
  const plainDepth: GPUDepthStencilState = {
@@ -4260,7 +4478,7 @@ export class Engine {
4260
4478
  }
4261
4479
  let depthStencil: GPUDepthStencilState = plainDepth
4262
4480
  let constants: Record<string, number> | undefined
4263
- if (slot === "hair" && !overEyes) {
4481
+ if (renderClass === "hair" && !overEyes) {
4264
4482
  depthStencil = {
4265
4483
  ...plainDepth,
4266
4484
  stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
@@ -4268,7 +4486,7 @@ export class Engine {
4268
4486
  stencilReadMask: 0xff,
4269
4487
  stencilWriteMask: 0,
4270
4488
  }
4271
- } else if (slot === "hair" && overEyes) {
4489
+ } else if (renderClass === "hair" && overEyes) {
4272
4490
  constants = { IS_OVER_EYES: 1 }
4273
4491
  depthStencil = {
4274
4492
  format: "depth24plus-stencil8",
@@ -4279,7 +4497,7 @@ export class Engine {
4279
4497
  stencilReadMask: 0xff,
4280
4498
  stencilWriteMask: 0,
4281
4499
  }
4282
- } else if (slot === "eye") {
4500
+ } else if (renderClass === "eye") {
4283
4501
  depthStencil = {
4284
4502
  ...plainDepth,
4285
4503
  depthBias: -0.00005,
@@ -4298,11 +4516,17 @@ export class Engine {
4298
4516
  })
4299
4517
  }
4300
4518
 
4519
+ // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
4520
+ // the built-in preset (hand-shader) pipeline.
4521
+ private pipelineForDrawCall(inst: ModelInstance, dc: DrawCall): GPURenderPipeline {
4522
+ if (dc.groupId) {
4523
+ const install = inst.styleGroups.get(dc.groupId)
4524
+ if (install) return install.pipeline
4525
+ }
4526
+ return this.pipelineForPreset(dc.preset)
4527
+ }
4528
+
4301
4529
  private pipelineForPreset(preset: ResolvedMaterialPreset): GPURenderPipeline {
4302
- // Applied style graph wins over the built-in preset shader (mmd_classic is never
4303
- // in the override map — it's not a stylable slot).
4304
- const override = this.styleOverrides.get(preset as MaterialPreset)
4305
- if (override) return override.pipeline
4306
4530
  if (preset === "face") return this.facePipeline
4307
4531
  if (preset === "hair") return this.hairPipeline
4308
4532
  if (preset === "cloth_smooth") return this.clothSmoothPipeline
@@ -4331,7 +4555,7 @@ export class Engine {
4331
4555
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
4332
4556
  bound = true
4333
4557
  }
4334
- const pipeline = this.pipelineForPreset(draw.preset)
4558
+ const pipeline = this.pipelineForDrawCall(inst, draw)
4335
4559
  if (pipeline !== currentPipeline) {
4336
4560
  pass.setPipeline(pipeline)
4337
4561
  currentPipeline = pipeline
@@ -4386,26 +4610,41 @@ export class Engine {
4386
4610
  }
4387
4611
 
4388
4612
  /**
4389
- * Second hair pass for the see-through-hair effect. Re-draws every hair opaque
4390
- * draw using `hairOverEyesPipeline`which stencil-matches `EYE_VALUE` and runs
4391
- * the hair shader with `IS_OVER_EYES=true` so alpha is scaled to 25%. depthWriteEnabled
4392
- * is off, so the eye's depth stays authoritative for anything drawn after.
4613
+ * Second hair pass for the see-through-hair effect. Re-draws every hair-class opaque
4614
+ * draw with its over-eyes pipeline — stencil-matched to `EYE_VALUE`, `IS_OVER_EYES=true`
4615
+ * (25% alpha), depth-write off. Covers both grouped hair-class draws (their compiled
4616
+ * over-eyes pipeline) and ungrouped hair-preset draws (the hand `hairOverEyesPipeline`).
4393
4617
  */
4394
4618
  private drawHairOverEyes(pass: GPURenderPassEncoder, inst: ModelInstance): void {
4395
4619
  let bound = false
4620
+ let currentPipeline: GPURenderPipeline | null = null
4396
4621
  for (const draw of inst.drawCalls) {
4397
- if (draw.type !== "opaque" || draw.preset !== "hair" || !this.shouldRenderDrawCall(inst, draw)) continue
4622
+ if (draw.type !== "opaque" || !this.shouldRenderDrawCall(inst, draw)) continue
4623
+ const overEyes = this.overEyesPipelineFor(inst, draw)
4624
+ if (!overEyes) continue
4398
4625
  if (!bound) {
4399
- pass.setPipeline(this.styleOverrides.get("hair")?.overEyesPipeline ?? this.hairOverEyesPipeline)
4400
4626
  pass.setBindGroup(0, this.perFrameBindGroup)
4401
4627
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
4402
4628
  bound = true
4403
4629
  }
4630
+ if (overEyes !== currentPipeline) {
4631
+ pass.setPipeline(overEyes)
4632
+ currentPipeline = overEyes
4633
+ }
4404
4634
  pass.setBindGroup(2, draw.bindGroup)
4405
4635
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
4406
4636
  }
4407
4637
  }
4408
4638
 
4639
+ // The over-eyes pipeline for a hair draw call, or null if it isn't hair-class.
4640
+ private overEyesPipelineFor(inst: ModelInstance, dc: DrawCall): GPURenderPipeline | null {
4641
+ if (dc.groupId) {
4642
+ const install = inst.styleGroups.get(dc.groupId)
4643
+ return install?.renderClass === "hair" ? (install.overEyesPipeline ?? null) : null
4644
+ }
4645
+ return dc.preset === "hair" ? this.hairOverEyesPipeline : null
4646
+ }
4647
+
4409
4648
  private updateCameraUniforms() {
4410
4649
  const viewMatrix = this.camera.getViewMatrix()
4411
4650
  const projectionMatrix = this.camera.getProjectionMatrix()