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