reze-engine 0.17.0 → 0.18.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 (66) hide show
  1. package/README.md +252 -250
  2. package/dist/engine.d.ts +36 -1
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +258 -15
  5. package/dist/gpu-profile.d.ts +19 -0
  6. package/dist/gpu-profile.d.ts.map +1 -0
  7. package/dist/gpu-profile.js +120 -0
  8. package/dist/graph/compile.d.ts +34 -0
  9. package/dist/graph/compile.d.ts.map +1 -0
  10. package/dist/graph/compile.js +299 -0
  11. package/dist/graph/presets/body.d.ts +3 -0
  12. package/dist/graph/presets/body.d.ts.map +1 -0
  13. package/dist/graph/presets/body.js +100 -0
  14. package/dist/graph/presets/cloth_rough.d.ts +3 -0
  15. package/dist/graph/presets/cloth_rough.d.ts.map +1 -0
  16. package/dist/graph/presets/cloth_rough.js +61 -0
  17. package/dist/graph/presets/cloth_smooth.d.ts +3 -0
  18. package/dist/graph/presets/cloth_smooth.d.ts.map +1 -0
  19. package/dist/graph/presets/cloth_smooth.js +53 -0
  20. package/dist/graph/presets/default.d.ts +3 -0
  21. package/dist/graph/presets/default.d.ts.map +1 -0
  22. package/dist/graph/presets/default.js +20 -0
  23. package/dist/graph/presets/hair.d.ts +3 -0
  24. package/dist/graph/presets/hair.d.ts.map +1 -0
  25. package/dist/graph/presets/hair.js +79 -0
  26. package/dist/graph/presets/metal.d.ts +3 -0
  27. package/dist/graph/presets/metal.d.ts.map +1 -0
  28. package/dist/graph/presets/metal.js +58 -0
  29. package/dist/graph/presets/stockings.d.ts +3 -0
  30. package/dist/graph/presets/stockings.d.ts.map +1 -0
  31. package/dist/graph/presets/stockings.js +54 -0
  32. package/dist/graph/registry.d.ts +28 -0
  33. package/dist/graph/registry.d.ts.map +1 -0
  34. package/dist/graph/registry.js +322 -0
  35. package/dist/graph/schema.d.ts +66 -0
  36. package/dist/graph/schema.d.ts.map +1 -0
  37. package/dist/graph/schema.js +6 -0
  38. package/dist/graph/slots.d.ts +14 -0
  39. package/dist/graph/slots.d.ts.map +1 -0
  40. package/dist/graph/slots.js +123 -0
  41. package/dist/index.d.ts +11 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +9 -0
  44. package/dist/physics/profile.d.ts +18 -0
  45. package/dist/physics/profile.d.ts.map +1 -0
  46. package/dist/physics/profile.js +44 -0
  47. package/package.json +43 -42
  48. package/src/engine.ts +301 -20
  49. package/src/graph/compile.ts +342 -0
  50. package/src/graph/presets/body.ts +103 -0
  51. package/src/graph/presets/cloth_rough.ts +64 -0
  52. package/src/graph/presets/cloth_smooth.ts +56 -0
  53. package/src/graph/presets/default.ts +23 -0
  54. package/src/graph/presets/hair.ts +82 -0
  55. package/src/graph/presets/metal.ts +61 -0
  56. package/src/graph/presets/stockings.ts +57 -0
  57. package/src/graph/registry.ts +351 -0
  58. package/src/graph/schema.ts +60 -0
  59. package/src/graph/slots.ts +145 -0
  60. package/src/index.ts +54 -28
  61. package/dist/physics-debug.d.ts +0 -30
  62. package/dist/physics-debug.d.ts.map +0 -1
  63. package/dist/physics-debug.js +0 -526
  64. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  65. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  66. package/dist/shaders/passes/physics-debug.js +0 -69
package/src/engine.ts CHANGED
@@ -39,6 +39,8 @@ import {
39
39
  import { COMPOSITE_SHADER_WGSL } from "./shaders/passes/composite"
40
40
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
41
41
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
42
+ import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
43
+ import type { Diagnostic, StyleGraph } from "./graph/schema"
42
44
 
43
45
  // Material preset dispatch. Consumers supply a MaterialPresetMap assigning material names
44
46
  // to presets; unmapped materials fall back to "default" (Principled BSDF).
@@ -52,7 +54,13 @@ export type MaterialPreset =
52
54
  | "metal"
53
55
  | "cloth_smooth"
54
56
  | "cloth_rough"
55
- | "mmd_classic"
57
+
58
+ // What a draw call actually resolves to. "mmd_classic" is the automatic
59
+ // authentic-MMD fallback for materials no preset map or name heuristic
60
+ // covers — deliberately NOT part of MaterialPreset, so consumer-side
61
+ // exhaustive Record<MaterialPreset, …> maps and switches don't have to
62
+ // carry an option users never assign.
63
+ export type ResolvedMaterialPreset = MaterialPreset | "mmd_classic"
56
64
 
57
65
  export type MaterialPresetMap = Partial<Record<MaterialPreset, string[]>>
58
66
 
@@ -102,7 +110,7 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
102
110
  ],
103
111
  ]
104
112
 
105
- function resolvePreset(materialName: string, map: MaterialPresetMap | undefined): MaterialPreset {
113
+ function resolvePreset(materialName: string, map: MaterialPresetMap | undefined): ResolvedMaterialPreset {
106
114
  if (map) {
107
115
  for (const [preset, names] of Object.entries(map)) {
108
116
  if (names && names.includes(materialName)) return preset as MaterialPreset
@@ -117,6 +125,33 @@ function resolvePreset(materialName: string, map: MaterialPresetMap | undefined)
117
125
  return "mmd_classic"
118
126
  }
119
127
 
128
+ // Map a WGSL compile-error line back to the graph node whose `let` produced it —
129
+ // the compiler tags every generated line with a trailing `// @node:<id>` marker.
130
+ function nodeIdForWgslLine(wgsl: string, lineNum: number): string | undefined {
131
+ const lines = wgsl.split("\n")
132
+ for (let i = Math.min(lineNum - 1, lines.length - 1); i >= 0; i--) {
133
+ const m = lines[i].match(/\/\/ @node:([a-z0-9_]+)/)
134
+ if (m) return m[1]
135
+ }
136
+ return undefined
137
+ }
138
+
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 = {
142
+ pipeline: GPURenderPipeline
143
+ /** Hair slot only: the stencil-matched IS_OVER_EYES=true variant. */
144
+ overEyesPipeline?: GPURenderPipeline
145
+ 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[]
153
+ }
154
+
120
155
  export type RaycastCallback = (
121
156
  modelName: string,
122
157
  material: string | null,
@@ -259,7 +294,10 @@ interface DrawCall {
259
294
  firstIndex: number
260
295
  bindGroup: GPUBindGroup
261
296
  materialName: string
262
- preset: MaterialPreset
297
+ 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).
300
+ baseBindGroupEntries?: GPUBindGroupEntry[]
263
301
  }
264
302
 
265
303
  interface PickDrawCall {
@@ -327,6 +365,7 @@ export class Engine {
327
365
  private lightData = new Float32Array(64)
328
366
  private lightCount = 0
329
367
  private resizeObserver: ResizeObserver | null = null
368
+ private resizePending = false
330
369
  private depthTexture!: GPUTexture
331
370
  private modelPipeline!: GPURenderPipeline
332
371
  private facePipeline!: GPURenderPipeline
@@ -339,6 +378,21 @@ export class Engine {
339
378
  private hairOverEyesPipeline!: GPURenderPipeline
340
379
  private stockingsPipeline!: GPURenderPipeline
341
380
  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>()
386
+ 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.
393
+ private mainPipelineLayout!: GPUPipelineLayout
394
+ private sceneTargets!: GPUColorTargetState[]
395
+ private fullVertexBufferLayouts!: GPUVertexBufferLayout[]
342
396
  // 1×64 vertical ramp for shared-toon materials: lit (top) → soft shadow
343
397
  // tone (bottom). Stand-in for MMD's toon01–10.bmp, which we can't ship.
344
398
  private defaultToonRampTexture!: GPUTexture
@@ -1049,6 +1103,8 @@ export class Engine {
1049
1103
  },
1050
1104
  }
1051
1105
  const sceneTargets: GPUColorTargetState[] = [standardBlend, maskBlend]
1106
+ this.sceneTargets = sceneTargets
1107
+ this.fullVertexBufferLayouts = fullVertexBuffers
1052
1108
 
1053
1109
  const shaderModule = this.device.createShaderModule({
1054
1110
  label: "default model shader",
@@ -1135,9 +1191,40 @@ export class Engine {
1135
1191
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1136
1192
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1137
1193
  { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1194
+ // StyleUniforms for compiled graph shaders (adjust-tier sliders). Hand-written
1195
+ // presets simply don't declare it — a layout may carry bindings a shader ignores.
1196
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1138
1197
  ],
1139
1198
  })
1140
1199
 
1200
+ // Per-slot style buffers + shared zero buffer (mmd_classic / never-styled slots).
1201
+ this.zeroStyleBuffer = this.device.createBuffer({
1202
+ label: "style uniforms (zero)",
1203
+ size: 256,
1204
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1205
+ })
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
+
1141
1228
  const mainPipelineLayout = this.device.createPipelineLayout({
1142
1229
  label: "main pipeline layout",
1143
1230
  bindGroupLayouts: [
@@ -1146,6 +1233,7 @@ export class Engine {
1146
1233
  this.mainPerMaterialBindGroupLayout,
1147
1234
  ],
1148
1235
  })
1236
+ this.mainPipelineLayout = mainPipelineLayout
1149
1237
 
1150
1238
  // perFrameBindGroup is created after shadow resources below
1151
1239
 
@@ -1794,9 +1882,16 @@ export class Engine {
1794
1882
  })
1795
1883
  }
1796
1884
 
1797
- // Step 3: Setup canvas resize handling
1885
+ // Step 3: Setup canvas resize handling.
1886
+ // The observer only flags the resize; render() applies it at the top of the next
1887
+ // frame. Resizing inside the RO callback (post-layout) clears the canvas buffer
1888
+ // after the frame's rAF draw already ran, so during continuous drags (resizable
1889
+ // panels) every paint showed a stale-aspect or cleared buffer — one frame behind,
1890
+ // reading as laggy/stretchy. Flag-and-apply keeps resize + redraw in one frame.
1798
1891
  private setupResize() {
1799
- this.resizeObserver = new ResizeObserver(() => this.handleResize())
1892
+ this.resizeObserver = new ResizeObserver(() => {
1893
+ this.resizePending = true
1894
+ })
1800
1895
  this.resizeObserver.observe(this.canvas)
1801
1896
  this.handleResize()
1802
1897
 
@@ -2470,6 +2565,13 @@ export class Engine {
2470
2565
  this.resizeObserver.disconnect()
2471
2566
  this.resizeObserver = null
2472
2567
  }
2568
+
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()
2574
+ this.zeroStyleBuffer?.destroy()
2473
2575
  }
2474
2576
 
2475
2577
  async loadModel(path: string): Promise<Model>
@@ -2576,10 +2678,28 @@ export class Engine {
2576
2678
  if (!inst) return
2577
2679
  inst.materialPresets = presets
2578
2680
  for (const dc of inst.drawCalls) {
2579
- dc.preset = resolvePreset(dc.materialName, presets)
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)
2580
2687
  }
2581
2688
  }
2582
2689
 
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)!
2696
+ return this.device.createBindGroup({
2697
+ label,
2698
+ layout: this.mainPerMaterialBindGroupLayout,
2699
+ entries: [...baseEntries, { binding: 4, resource: { buffer: styleBuffer } }],
2700
+ })
2701
+ }
2702
+
2583
2703
  setMaterialVisible(modelName: string, materialName: string, visible: boolean): void {
2584
2704
  const inst = this.modelInstances.get(modelName)
2585
2705
  if (!inst) return
@@ -3100,20 +3220,17 @@ export class Engine {
3100
3220
  const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, mat, sphereMode, headBoneIndex)
3101
3221
  inst.gpuBuffers.push(materialUniformBuffer)
3102
3222
 
3223
+ const preset = resolvePreset(mat.name, inst.materialPresets)
3103
3224
  const textureView = diffuseTexture.createView()
3104
- const bindGroup = this.device.createBindGroup({
3105
- label: `${prefix}material: ${mat.name}`,
3106
- layout: this.mainPerMaterialBindGroupLayout,
3107
- entries: [
3108
- { binding: 0, resource: textureView },
3109
- { binding: 1, resource: { buffer: materialUniformBuffer } },
3110
- { binding: 2, resource: (toonTexture ?? this.fallbackMaterialTexture).createView() },
3111
- { binding: 3, resource: (sphereTexture ?? this.fallbackMaterialTexture).createView() },
3112
- ],
3113
- })
3225
+ const baseBindGroupEntries: GPUBindGroupEntry[] = [
3226
+ { binding: 0, resource: textureView },
3227
+ { binding: 1, resource: { buffer: materialUniformBuffer } },
3228
+ { binding: 2, resource: (toonTexture ?? this.fallbackMaterialTexture).createView() },
3229
+ { binding: 3, resource: (sphereTexture ?? this.fallbackMaterialTexture).createView() },
3230
+ ]
3231
+ const bindGroup = this.createMaterialBindGroup(`${prefix}material: ${mat.name}`, baseBindGroupEntries, preset)
3114
3232
 
3115
3233
  const type: DrawCallType = isTransparent ? "transparent" : "opaque"
3116
- const preset = resolvePreset(mat.name, inst.materialPresets)
3117
3234
  inst.drawCalls.push({
3118
3235
  type,
3119
3236
  count: indexCount,
@@ -3121,6 +3238,7 @@ export class Engine {
3121
3238
  bindGroup,
3122
3239
  materialName: mat.name,
3123
3240
  preset,
3241
+ baseBindGroupEntries,
3124
3242
  })
3125
3243
 
3126
3244
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
@@ -3180,7 +3298,7 @@ export class Engine {
3180
3298
  "transparent-outline": 3,
3181
3299
  ground: 4,
3182
3300
  }
3183
- const presetRank = (p: MaterialPreset): number => (p === "hair" ? 2 : p === "eye" ? 1 : 0)
3301
+ const presetRank = (p: ResolvedMaterialPreset): number => (p === "hair" ? 2 : p === "eye" ? 1 : 0)
3184
3302
  inst.drawCalls.sort((a, b) => {
3185
3303
  const ta = typeOrder[a.type] - typeOrder[b.type]
3186
3304
  if (ta !== 0) return ta
@@ -3889,6 +4007,11 @@ export class Engine {
3889
4007
  render() {
3890
4008
  if (!this.multisampleTexture || !this.camera || !this.device) return
3891
4009
 
4010
+ if (this.resizePending) {
4011
+ this.resizePending = false
4012
+ this.handleResize()
4013
+ }
4014
+
3892
4015
  const currentTime = performance.now()
3893
4016
  const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
3894
4017
  this.lastFrameTime = currentTime
@@ -4021,7 +4144,165 @@ export class Engine {
4021
4144
  }
4022
4145
  }
4023
4146
 
4024
- private pipelineForPreset(preset: MaterialPreset): GPURenderPipeline {
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).
4151
+
4152
+ /**
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.
4156
+ */
4157
+ async applyStyleGraph(graph: StyleGraph, opts?: CompileOptions): Promise<ApplyStyleResult> {
4158
+ const result = compileGraph(graph, opts)
4159
+ if (!result.ok) return { ok: false, diagnostics: result.diagnostics, slotMap: result.slotMap }
4160
+
4161
+ const slot = graph.slot
4162
+ const generation = (this.styleGenerations.get(slot) ?? 0) + 1
4163
+ this.styleGenerations.set(slot, generation)
4164
+
4165
+ this.device.pushErrorScope("validation")
4166
+ const module = this.device.createShaderModule({ label: `style graph: ${graph.name} (${slot})`, code: result.wgsl })
4167
+ const info = await module.getCompilationInfo()
4168
+ const scopeError = await this.device.popErrorScope()
4169
+ const diagnostics = [...result.diagnostics]
4170
+ for (const msg of info.messages) {
4171
+ if (msg.type !== "error") continue
4172
+ diagnostics.push({
4173
+ severity: "error",
4174
+ nodeId: nodeIdForWgslLine(result.wgsl, msg.lineNum),
4175
+ message: `WGSL: ${msg.message}`,
4176
+ })
4177
+ }
4178
+ if (diagnostics.some((d) => d.severity === "error") || scopeError) {
4179
+ if (scopeError && !diagnostics.some((d) => d.severity === "error"))
4180
+ diagnostics.push({ severity: "error", message: `WGSL: ${scopeError.message}` })
4181
+ return { ok: false, diagnostics, slotMap: result.slotMap }
4182
+ }
4183
+
4184
+ let pipeline: GPURenderPipeline
4185
+ let overEyesPipeline: GPURenderPipeline | undefined
4186
+ try {
4187
+ pipeline = await this.createSlotPipeline(slot, module, false)
4188
+ if (slot === "hair") overEyesPipeline = await this.createSlotPipeline(slot, module, true)
4189
+ } catch (e) {
4190
+ diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
4191
+ return { ok: false, diagnostics, slotMap: result.slotMap }
4192
+ }
4193
+
4194
+ // A newer apply/reset on this slot finished (or started) while we compiled.
4195
+ if (this.styleGenerations.get(slot) !== generation) {
4196
+ diagnostics.push({ severity: "warning", message: "superseded by a newer edit — result discarded" })
4197
+ return { ok: false, diagnostics, slotMap: result.slotMap }
4198
+ }
4199
+
4200
+ this.styleOverrides.set(slot, { pipeline, overEyesPipeline, slotMap: result.slotMap })
4201
+ this.writeStyleDefaults(slot, graph, result.slotMap)
4202
+ return { ok: true, diagnostics, slotMap: result.slotMap }
4203
+ }
4204
+
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))
4218
+ }
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)
4226
+ }
4227
+
4228
+ private writeStyleDefaults(slot: MaterialPreset, graph: StyleGraph, slotMap: StyleSlot[]): void {
4229
+ const data = new Float32Array(64) // 16 vec4f
4230
+ for (const styleSlot of slotMap) {
4231
+ const param = graph.params?.find((p) => p.id === styleSlot.id)
4232
+ if (!param) continue
4233
+ const base = styleSlot.vec4Index * 4
4234
+ if (styleSlot.kind === "float" && typeof param.default === "number") {
4235
+ data[base + ["x", "y", "z", "w"].indexOf(styleSlot.component!)] = param.default
4236
+ } else if (styleSlot.kind === "color" && typeof param.default !== "number") {
4237
+ data.set(param.default.slice(0, 3), base)
4238
+ }
4239
+ }
4240
+ this.device.queue.writeBuffer(this.styleBuffers.get(slot)!, 0, data)
4241
+ }
4242
+
4243
+ /**
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).
4247
+ */
4248
+ private createSlotPipeline(slot: MaterialPreset, module: GPUShaderModule, overEyes: boolean): Promise<GPURenderPipeline> {
4249
+ const base = {
4250
+ label: `style ${slot}${overEyes ? " (over eyes)" : ""}`,
4251
+ layout: this.mainPipelineLayout,
4252
+ vertex: { module, buffers: this.fullVertexBufferLayouts },
4253
+ primitive: { cullMode: (slot === "eye" ? "front" : "none") as GPUCullMode },
4254
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
4255
+ }
4256
+ const plainDepth: GPUDepthStencilState = {
4257
+ format: "depth24plus-stencil8",
4258
+ depthWriteEnabled: true,
4259
+ depthCompare: "less-equal",
4260
+ }
4261
+ let depthStencil: GPUDepthStencilState = plainDepth
4262
+ let constants: Record<string, number> | undefined
4263
+ if (slot === "hair" && !overEyes) {
4264
+ depthStencil = {
4265
+ ...plainDepth,
4266
+ stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
4267
+ stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
4268
+ stencilReadMask: 0xff,
4269
+ stencilWriteMask: 0,
4270
+ }
4271
+ } else if (slot === "hair" && overEyes) {
4272
+ constants = { IS_OVER_EYES: 1 }
4273
+ depthStencil = {
4274
+ format: "depth24plus-stencil8",
4275
+ depthWriteEnabled: false,
4276
+ depthCompare: "less-equal",
4277
+ stencilFront: { compare: "equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
4278
+ stencilBack: { compare: "equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
4279
+ stencilReadMask: 0xff,
4280
+ stencilWriteMask: 0,
4281
+ }
4282
+ } else if (slot === "eye") {
4283
+ depthStencil = {
4284
+ ...plainDepth,
4285
+ depthBias: -0.00005,
4286
+ depthBiasSlopeScale: 0.0,
4287
+ depthBiasClamp: 0.0,
4288
+ stencilFront: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" },
4289
+ stencilBack: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" },
4290
+ stencilReadMask: 0xff,
4291
+ stencilWriteMask: 0xff,
4292
+ }
4293
+ }
4294
+ return this.device.createRenderPipelineAsync({
4295
+ ...base,
4296
+ fragment: { module, constants, targets: this.sceneTargets },
4297
+ depthStencil,
4298
+ })
4299
+ }
4300
+
4301
+ 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
4025
4306
  if (preset === "face") return this.facePipeline
4026
4307
  if (preset === "hair") return this.hairPipeline
4027
4308
  if (preset === "cloth_smooth") return this.clothSmoothPipeline
@@ -4115,7 +4396,7 @@ export class Engine {
4115
4396
  for (const draw of inst.drawCalls) {
4116
4397
  if (draw.type !== "opaque" || draw.preset !== "hair" || !this.shouldRenderDrawCall(inst, draw)) continue
4117
4398
  if (!bound) {
4118
- pass.setPipeline(this.hairOverEyesPipeline)
4399
+ pass.setPipeline(this.styleOverrides.get("hair")?.overEyesPipeline ?? this.hairOverEyesPipeline)
4119
4400
  pass.setBindGroup(0, this.perFrameBindGroup)
4120
4401
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
4121
4402
  bound = true