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.
- package/dist/engine.d.ts +34 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +258 -15
- package/dist/gpu-profile.d.ts +19 -0
- package/dist/gpu-profile.d.ts.map +1 -0
- package/dist/gpu-profile.js +120 -0
- package/dist/graph/compile.d.ts +34 -0
- package/dist/graph/compile.d.ts.map +1 -0
- package/dist/graph/compile.js +299 -0
- package/dist/graph/presets/body.d.ts +3 -0
- package/dist/graph/presets/body.d.ts.map +1 -0
- package/dist/graph/presets/body.js +100 -0
- package/dist/graph/presets/cloth_rough.d.ts +3 -0
- package/dist/graph/presets/cloth_rough.d.ts.map +1 -0
- package/dist/graph/presets/cloth_rough.js +61 -0
- package/dist/graph/presets/cloth_smooth.d.ts +3 -0
- package/dist/graph/presets/cloth_smooth.d.ts.map +1 -0
- package/dist/graph/presets/cloth_smooth.js +53 -0
- package/dist/graph/presets/default.d.ts +3 -0
- package/dist/graph/presets/default.d.ts.map +1 -0
- package/dist/graph/presets/default.js +20 -0
- package/dist/graph/presets/eye.d.ts +3 -0
- package/dist/graph/presets/eye.d.ts.map +1 -0
- package/dist/graph/presets/eye.js +30 -0
- package/dist/graph/presets/face.d.ts +3 -0
- package/dist/graph/presets/face.d.ts.map +1 -0
- package/dist/graph/presets/face.js +125 -0
- package/dist/graph/presets/hair.d.ts +3 -0
- package/dist/graph/presets/hair.d.ts.map +1 -0
- package/dist/graph/presets/hair.js +79 -0
- package/dist/graph/presets/metal.d.ts +3 -0
- package/dist/graph/presets/metal.d.ts.map +1 -0
- package/dist/graph/presets/metal.js +58 -0
- package/dist/graph/presets/stockings.d.ts +3 -0
- package/dist/graph/presets/stockings.d.ts.map +1 -0
- package/dist/graph/presets/stockings.js +54 -0
- package/dist/graph/registry.d.ts +28 -0
- package/dist/graph/registry.d.ts.map +1 -0
- package/dist/graph/registry.js +322 -0
- package/dist/graph/schema.d.ts +66 -0
- package/dist/graph/schema.d.ts.map +1 -0
- package/dist/graph/schema.js +6 -0
- package/dist/graph/slots.d.ts +14 -0
- package/dist/graph/slots.d.ts.map +1 -0
- package/dist/graph/slots.js +155 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/physics/profile.d.ts +18 -0
- package/dist/physics/profile.d.ts.map +1 -0
- package/dist/physics/profile.js +44 -0
- package/package.json +2 -1
- package/src/engine.ts +290 -15
- package/src/graph/compile.ts +342 -0
- package/src/graph/presets/body.ts +103 -0
- package/src/graph/presets/cloth_rough.ts +64 -0
- package/src/graph/presets/cloth_smooth.ts +56 -0
- package/src/graph/presets/default.ts +23 -0
- package/src/graph/presets/eye.ts +33 -0
- package/src/graph/presets/face.ts +136 -0
- package/src/graph/presets/hair.ts +82 -0
- package/src/graph/presets/metal.ts +61 -0
- package/src/graph/presets/stockings.ts +57 -0
- package/src/graph/registry.ts +351 -0
- package/src/graph/schema.ts +60 -0
- package/src/graph/slots.ts +178 -0
- package/src/index.ts +27 -0
- package/dist/physics-debug.d.ts +0 -30
- package/dist/physics-debug.d.ts.map +0 -1
- package/dist/physics-debug.js +0 -526
- package/dist/shaders/passes/physics-debug.d.ts +0 -2
- package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
- 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.
|
|
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(() =>
|
|
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
|
-
|
|
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
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
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
|