reze-engine 0.17.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/engine.d.ts +34 -0
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +258 -15
  4. package/dist/gpu-profile.d.ts +19 -0
  5. package/dist/gpu-profile.d.ts.map +1 -0
  6. package/dist/gpu-profile.js +120 -0
  7. package/dist/graph/compile.d.ts +34 -0
  8. package/dist/graph/compile.d.ts.map +1 -0
  9. package/dist/graph/compile.js +299 -0
  10. package/dist/graph/presets/body.d.ts +3 -0
  11. package/dist/graph/presets/body.d.ts.map +1 -0
  12. package/dist/graph/presets/body.js +100 -0
  13. package/dist/graph/presets/cloth_rough.d.ts +3 -0
  14. package/dist/graph/presets/cloth_rough.d.ts.map +1 -0
  15. package/dist/graph/presets/cloth_rough.js +61 -0
  16. package/dist/graph/presets/cloth_smooth.d.ts +3 -0
  17. package/dist/graph/presets/cloth_smooth.d.ts.map +1 -0
  18. package/dist/graph/presets/cloth_smooth.js +53 -0
  19. package/dist/graph/presets/default.d.ts +3 -0
  20. package/dist/graph/presets/default.d.ts.map +1 -0
  21. package/dist/graph/presets/default.js +20 -0
  22. package/dist/graph/presets/eye.d.ts +3 -0
  23. package/dist/graph/presets/eye.d.ts.map +1 -0
  24. package/dist/graph/presets/eye.js +30 -0
  25. package/dist/graph/presets/face.d.ts +3 -0
  26. package/dist/graph/presets/face.d.ts.map +1 -0
  27. package/dist/graph/presets/face.js +125 -0
  28. package/dist/graph/presets/hair.d.ts +3 -0
  29. package/dist/graph/presets/hair.d.ts.map +1 -0
  30. package/dist/graph/presets/hair.js +79 -0
  31. package/dist/graph/presets/metal.d.ts +3 -0
  32. package/dist/graph/presets/metal.d.ts.map +1 -0
  33. package/dist/graph/presets/metal.js +58 -0
  34. package/dist/graph/presets/stockings.d.ts +3 -0
  35. package/dist/graph/presets/stockings.d.ts.map +1 -0
  36. package/dist/graph/presets/stockings.js +54 -0
  37. package/dist/graph/registry.d.ts +28 -0
  38. package/dist/graph/registry.d.ts.map +1 -0
  39. package/dist/graph/registry.js +322 -0
  40. package/dist/graph/schema.d.ts +66 -0
  41. package/dist/graph/schema.d.ts.map +1 -0
  42. package/dist/graph/schema.js +6 -0
  43. package/dist/graph/slots.d.ts +14 -0
  44. package/dist/graph/slots.d.ts.map +1 -0
  45. package/dist/graph/slots.js +155 -0
  46. package/dist/index.d.ts +13 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +11 -0
  49. package/dist/physics/profile.d.ts +18 -0
  50. package/dist/physics/profile.d.ts.map +1 -0
  51. package/dist/physics/profile.js +44 -0
  52. package/package.json +2 -1
  53. package/src/engine.ts +290 -15
  54. package/src/graph/compile.ts +342 -0
  55. package/src/graph/presets/body.ts +103 -0
  56. package/src/graph/presets/cloth_rough.ts +64 -0
  57. package/src/graph/presets/cloth_smooth.ts +56 -0
  58. package/src/graph/presets/default.ts +23 -0
  59. package/src/graph/presets/eye.ts +33 -0
  60. package/src/graph/presets/face.ts +136 -0
  61. package/src/graph/presets/hair.ts +82 -0
  62. package/src/graph/presets/metal.ts +61 -0
  63. package/src/graph/presets/stockings.ts +57 -0
  64. package/src/graph/registry.ts +351 -0
  65. package/src/graph/schema.ts +60 -0
  66. package/src/graph/slots.ts +178 -0
  67. package/src/index.ts +27 -0
  68. package/dist/physics-debug.d.ts +0 -30
  69. package/dist/physics-debug.d.ts.map +0 -1
  70. package/dist/physics-debug.js +0 -526
  71. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  72. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  73. package/dist/shaders/passes/physics-debug.js +0 -69
@@ -0,0 +1,44 @@
1
+ // Per-phase wall-clock accumulator. Off by default — when `enabled` is false,
2
+ // begin()/end() short-circuit so production has zero overhead. Toggle via
3
+ // `RezePhysics.setProfileEnabled(true)` and read with `getProfile()`.
4
+ class PhysicsProfiler {
5
+ constructor() {
6
+ this.enabled = false;
7
+ this.sums = Object.create(null);
8
+ this.counts = Object.create(null);
9
+ }
10
+ begin() {
11
+ return this.enabled ? performance.now() : 0;
12
+ }
13
+ end(label, t0) {
14
+ if (!this.enabled)
15
+ return;
16
+ const dt = performance.now() - t0;
17
+ this.sums[label] = (this.sums[label] ?? 0) + dt;
18
+ this.counts[label] = (this.counts[label] ?? 0) + 1;
19
+ }
20
+ // Direct accumulator for durations not measured via begin()/end() — used by
21
+ // GPU timestamp readback, which arrives async after the frame has ended.
22
+ record(label, ms) {
23
+ if (!this.enabled)
24
+ return;
25
+ this.sums[label] = (this.sums[label] ?? 0) + ms;
26
+ this.counts[label] = (this.counts[label] ?? 0) + 1;
27
+ }
28
+ // Snapshot since last reset(). avgMs is per call; totalMs is the cumulative
29
+ // window cost — divide by elapsed wall time to get a "% of frame budget".
30
+ snapshot() {
31
+ const out = {};
32
+ for (const k in this.sums) {
33
+ const total = this.sums[k];
34
+ const calls = this.counts[k];
35
+ out[k] = { avgMs: calls > 0 ? total / calls : 0, calls, totalMs: total };
36
+ }
37
+ return out;
38
+ }
39
+ reset() {
40
+ this.sums = Object.create(null);
41
+ this.counts = Object.create(null);
42
+ }
43
+ }
44
+ export const profiler = new PhysicsProfiler();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -22,6 +22,7 @@
22
22
  "scripts": {
23
23
  "build": "tsc",
24
24
  "dev": "tsc --watch",
25
+ "test": "node --import ./tests/register.mjs --test tests/*.test.mjs",
25
26
  "prepublishOnly": "npm run build"
26
27
  },
27
28
  "keywords": [
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).
@@ -123,6 +125,33 @@ function resolvePreset(materialName: string, map: MaterialPresetMap | undefined)
123
125
  return "mmd_classic"
124
126
  }
125
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
+
126
155
  export type RaycastCallback = (
127
156
  modelName: string,
128
157
  material: string | null,
@@ -266,6 +295,9 @@ interface DrawCall {
266
295
  bindGroup: GPUBindGroup
267
296
  materialName: string
268
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[]
269
301
  }
270
302
 
271
303
  interface PickDrawCall {
@@ -333,6 +365,7 @@ export class Engine {
333
365
  private lightData = new Float32Array(64)
334
366
  private lightCount = 0
335
367
  private resizeObserver: ResizeObserver | null = null
368
+ private resizePending = false
336
369
  private depthTexture!: GPUTexture
337
370
  private modelPipeline!: GPURenderPipeline
338
371
  private facePipeline!: GPURenderPipeline
@@ -345,6 +378,21 @@ export class Engine {
345
378
  private hairOverEyesPipeline!: GPURenderPipeline
346
379
  private stockingsPipeline!: GPURenderPipeline
347
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[]
348
396
  // 1×64 vertical ramp for shared-toon materials: lit (top) → soft shadow
349
397
  // tone (bottom). Stand-in for MMD's toon01–10.bmp, which we can't ship.
350
398
  private defaultToonRampTexture!: GPUTexture
@@ -1055,6 +1103,8 @@ export class Engine {
1055
1103
  },
1056
1104
  }
1057
1105
  const sceneTargets: GPUColorTargetState[] = [standardBlend, maskBlend]
1106
+ this.sceneTargets = sceneTargets
1107
+ this.fullVertexBufferLayouts = fullVertexBuffers
1058
1108
 
1059
1109
  const shaderModule = this.device.createShaderModule({
1060
1110
  label: "default model shader",
@@ -1141,9 +1191,40 @@ export class Engine {
1141
1191
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1142
1192
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1143
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" } },
1144
1197
  ],
1145
1198
  })
1146
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
+
1147
1228
  const mainPipelineLayout = this.device.createPipelineLayout({
1148
1229
  label: "main pipeline layout",
1149
1230
  bindGroupLayouts: [
@@ -1152,6 +1233,7 @@ export class Engine {
1152
1233
  this.mainPerMaterialBindGroupLayout,
1153
1234
  ],
1154
1235
  })
1236
+ this.mainPipelineLayout = mainPipelineLayout
1155
1237
 
1156
1238
  // perFrameBindGroup is created after shadow resources below
1157
1239
 
@@ -1800,9 +1882,16 @@ export class Engine {
1800
1882
  })
1801
1883
  }
1802
1884
 
1803
- // 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.
1804
1891
  private setupResize() {
1805
- this.resizeObserver = new ResizeObserver(() => this.handleResize())
1892
+ this.resizeObserver = new ResizeObserver(() => {
1893
+ this.resizePending = true
1894
+ })
1806
1895
  this.resizeObserver.observe(this.canvas)
1807
1896
  this.handleResize()
1808
1897
 
@@ -2476,6 +2565,13 @@ export class Engine {
2476
2565
  this.resizeObserver.disconnect()
2477
2566
  this.resizeObserver = null
2478
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()
2479
2575
  }
2480
2576
 
2481
2577
  async loadModel(path: string): Promise<Model>
@@ -2582,10 +2678,28 @@ export class Engine {
2582
2678
  if (!inst) return
2583
2679
  inst.materialPresets = presets
2584
2680
  for (const dc of inst.drawCalls) {
2585
- 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)
2586
2687
  }
2587
2688
  }
2588
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
+
2589
2703
  setMaterialVisible(modelName: string, materialName: string, visible: boolean): void {
2590
2704
  const inst = this.modelInstances.get(modelName)
2591
2705
  if (!inst) return
@@ -3106,20 +3220,17 @@ export class Engine {
3106
3220
  const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, mat, sphereMode, headBoneIndex)
3107
3221
  inst.gpuBuffers.push(materialUniformBuffer)
3108
3222
 
3223
+ const preset = resolvePreset(mat.name, inst.materialPresets)
3109
3224
  const textureView = diffuseTexture.createView()
3110
- const bindGroup = this.device.createBindGroup({
3111
- label: `${prefix}material: ${mat.name}`,
3112
- layout: this.mainPerMaterialBindGroupLayout,
3113
- entries: [
3114
- { binding: 0, resource: textureView },
3115
- { binding: 1, resource: { buffer: materialUniformBuffer } },
3116
- { binding: 2, resource: (toonTexture ?? this.fallbackMaterialTexture).createView() },
3117
- { binding: 3, resource: (sphereTexture ?? this.fallbackMaterialTexture).createView() },
3118
- ],
3119
- })
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)
3120
3232
 
3121
3233
  const type: DrawCallType = isTransparent ? "transparent" : "opaque"
3122
- const preset = resolvePreset(mat.name, inst.materialPresets)
3123
3234
  inst.drawCalls.push({
3124
3235
  type,
3125
3236
  count: indexCount,
@@ -3127,6 +3238,7 @@ export class Engine {
3127
3238
  bindGroup,
3128
3239
  materialName: mat.name,
3129
3240
  preset,
3241
+ baseBindGroupEntries,
3130
3242
  })
3131
3243
 
3132
3244
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
@@ -3895,6 +4007,11 @@ export class Engine {
3895
4007
  render() {
3896
4008
  if (!this.multisampleTexture || !this.camera || !this.device) return
3897
4009
 
4010
+ if (this.resizePending) {
4011
+ this.resizePending = false
4012
+ this.handleResize()
4013
+ }
4014
+
3898
4015
  const currentTime = performance.now()
3899
4016
  const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016
3900
4017
  this.lastFrameTime = currentTime
@@ -4027,7 +4144,165 @@ export class Engine {
4027
4144
  }
4028
4145
  }
4029
4146
 
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
+
4030
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
4031
4306
  if (preset === "face") return this.facePipeline
4032
4307
  if (preset === "hair") return this.hairPipeline
4033
4308
  if (preset === "cloth_smooth") return this.clothSmoothPipeline
@@ -4121,7 +4396,7 @@ export class Engine {
4121
4396
  for (const draw of inst.drawCalls) {
4122
4397
  if (draw.type !== "opaque" || draw.preset !== "hair" || !this.shouldRenderDrawCall(inst, draw)) continue
4123
4398
  if (!bound) {
4124
- pass.setPipeline(this.hairOverEyesPipeline)
4399
+ pass.setPipeline(this.styleOverrides.get("hair")?.overEyesPipeline ?? this.hairOverEyesPipeline)
4125
4400
  pass.setBindGroup(0, this.perFrameBindGroup)
4126
4401
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
4127
4402
  bound = true