reze-engine 0.18.1 → 0.20.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/README.md +291 -252
- package/dist/engine.d.ts +45 -41
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +401 -421
- 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.d.ts.map +1 -1
- package/dist/graph/presets/default.js +14 -7
- 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/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +8 -0
- 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/dist/tga-loader.d.ts +7 -0
- package/dist/tga-loader.d.ts.map +1 -0
- package/dist/tga-loader.js +154 -0
- package/package.json +2 -2
- package/src/engine.ts +4488 -4479
- 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 +14 -7
- 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/registry.ts +8 -0
- 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/src/tga-loader.ts +154 -0
package/dist/engine.js
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
import { Camera } from "./camera";
|
|
2
2
|
import { Mat4, Quat, Vec3 } from "./math";
|
|
3
3
|
import { MORPH_COMPUTE_WGSL } from "./shaders/passes/morph";
|
|
4
|
+
import { decodeTga } from "./tga-loader";
|
|
4
5
|
import { PmxLoader } from "./pmx-loader";
|
|
5
6
|
import { RezePhysics } from "./physics";
|
|
6
7
|
import { createFetchAssetReader, createFileMapAssetReader, deriveBasePathFromPmxPath, fileListToMap, findFirstPmxFileInList, joinAssetPath, normalizeAssetPath, } from "./asset-reader";
|
|
7
|
-
import { DEFAULT_SHADER_WGSL } from "./shaders/materials/default";
|
|
8
|
-
import { FACE_SHADER_WGSL } from "./shaders/materials/face";
|
|
9
|
-
import { HAIR_SHADER_WGSL } from "./shaders/materials/hair";
|
|
10
|
-
import { CLOTH_SMOOTH_SHADER_WGSL } from "./shaders/materials/cloth_smooth";
|
|
11
|
-
import { CLOTH_ROUGH_SHADER_WGSL } from "./shaders/materials/cloth_rough";
|
|
12
|
-
import { METAL_SHADER_WGSL } from "./shaders/materials/metal";
|
|
13
|
-
import { BODY_SHADER_WGSL } from "./shaders/materials/body";
|
|
14
|
-
import { EYE_SHADER_WGSL } from "./shaders/materials/eye";
|
|
15
|
-
import { STOCKINGS_SHADER_WGSL } from "./shaders/materials/stockings";
|
|
16
|
-
import { MMD_CLASSIC_SHADER_WGSL } from "./shaders/materials/mmd_classic";
|
|
17
8
|
import { BRDF_LUT_SIZE, BRDF_LUT_BAKE_WGSL } from "./shaders/dfg_lut";
|
|
18
9
|
import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
|
|
19
10
|
import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow";
|
|
@@ -26,6 +17,15 @@ import { COMPOSITE_SHADER_WGSL } from "./shaders/passes/composite";
|
|
|
26
17
|
import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
|
|
27
18
|
import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
|
|
28
19
|
import { compileGraph } from "./graph/compile";
|
|
20
|
+
import { DEFAULT_GRAPH } from "./graph/presets/default";
|
|
21
|
+
import { FACE_GRAPH } from "./graph/presets/face";
|
|
22
|
+
import { HAIR_GRAPH } from "./graph/presets/hair";
|
|
23
|
+
import { BODY_GRAPH } from "./graph/presets/body";
|
|
24
|
+
import { EYE_GRAPH } from "./graph/presets/eye";
|
|
25
|
+
import { STOCKINGS_GRAPH } from "./graph/presets/stockings";
|
|
26
|
+
import { METAL_GRAPH } from "./graph/presets/metal";
|
|
27
|
+
import { CLOTH_SMOOTH_GRAPH } from "./graph/presets/cloth_smooth";
|
|
28
|
+
import { CLOTH_ROUGH_GRAPH } from "./graph/presets/cloth_rough";
|
|
29
29
|
// Substring hints mapping common PMX material names (JP/CN/EN) to presets,
|
|
30
30
|
// tried when a material isn't in the model's explicit MaterialPresetMap.
|
|
31
31
|
// Ordered: more specific families first (靴下 must hit stockings before 靴
|
|
@@ -87,6 +87,21 @@ function resolvePreset(materialName, map) {
|
|
|
87
87
|
}
|
|
88
88
|
return "mmd_classic";
|
|
89
89
|
}
|
|
90
|
+
// Default-group recipe per preset label: the shipped graph + its natural pass-integration
|
|
91
|
+
// (renderClass, alphaMode). This is the auto-default-groups mapping (docs §8) — the same
|
|
92
|
+
// label→integration knowledge the old fixed slots encoded, now producing editable groups.
|
|
93
|
+
// mmd_classic has no entry — those materials stay ungrouped (hand-shader path).
|
|
94
|
+
const PRESET_GROUP_INFO = {
|
|
95
|
+
default: { graph: DEFAULT_GRAPH, renderClass: "auto", alphaMode: "opaque" },
|
|
96
|
+
face: { graph: FACE_GRAPH, renderClass: "auto", alphaMode: "opaque" },
|
|
97
|
+
hair: { graph: HAIR_GRAPH, renderClass: "hair", alphaMode: "opaque" },
|
|
98
|
+
body: { graph: BODY_GRAPH, renderClass: "auto", alphaMode: "opaque" },
|
|
99
|
+
eye: { graph: EYE_GRAPH, renderClass: "eye", alphaMode: "opaque" },
|
|
100
|
+
stockings: { graph: STOCKINGS_GRAPH, renderClass: "auto", alphaMode: "hashed" },
|
|
101
|
+
metal: { graph: METAL_GRAPH, renderClass: "auto", alphaMode: "opaque" },
|
|
102
|
+
cloth_smooth: { graph: CLOTH_SMOOTH_GRAPH, renderClass: "auto", alphaMode: "opaque" },
|
|
103
|
+
cloth_rough: { graph: CLOTH_ROUGH_GRAPH, renderClass: "auto", alphaMode: "opaque" },
|
|
104
|
+
};
|
|
90
105
|
// Map a WGSL compile-error line back to the graph node whose `let` produced it —
|
|
91
106
|
// the compiler tags every generated line with a trailing `// @node:<id>` marker.
|
|
92
107
|
function nodeIdForWgslLine(wgsl, lineNum) {
|
|
@@ -133,16 +148,6 @@ export class Engine {
|
|
|
133
148
|
this.lightCount = 0;
|
|
134
149
|
this.resizeObserver = null;
|
|
135
150
|
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
151
|
this.selectedMaterial = null;
|
|
147
152
|
// ─── Transform gizmo ───────────────────────────────────────────────
|
|
148
153
|
this.selectedBone = null;
|
|
@@ -860,46 +865,6 @@ export class Engine {
|
|
|
860
865
|
const sceneTargets = [standardBlend, maskBlend];
|
|
861
866
|
this.sceneTargets = sceneTargets;
|
|
862
867
|
this.fullVertexBufferLayouts = fullVertexBuffers;
|
|
863
|
-
const shaderModule = this.device.createShaderModule({
|
|
864
|
-
label: "default model shader",
|
|
865
|
-
code: DEFAULT_SHADER_WGSL,
|
|
866
|
-
});
|
|
867
|
-
const faceShaderModule = this.device.createShaderModule({
|
|
868
|
-
label: "face NPR shader",
|
|
869
|
-
code: FACE_SHADER_WGSL,
|
|
870
|
-
});
|
|
871
|
-
const hairShaderModule = this.device.createShaderModule({
|
|
872
|
-
label: "hair NPR shader",
|
|
873
|
-
code: HAIR_SHADER_WGSL,
|
|
874
|
-
});
|
|
875
|
-
const clothSmoothShaderModule = this.device.createShaderModule({
|
|
876
|
-
label: "cloth smooth NPR shader",
|
|
877
|
-
code: CLOTH_SMOOTH_SHADER_WGSL,
|
|
878
|
-
});
|
|
879
|
-
const clothRoughShaderModule = this.device.createShaderModule({
|
|
880
|
-
label: "cloth rough NPR shader",
|
|
881
|
-
code: CLOTH_ROUGH_SHADER_WGSL,
|
|
882
|
-
});
|
|
883
|
-
const metalShaderModule = this.device.createShaderModule({
|
|
884
|
-
label: "metal NPR shader",
|
|
885
|
-
code: METAL_SHADER_WGSL,
|
|
886
|
-
});
|
|
887
|
-
const bodyShaderModule = this.device.createShaderModule({
|
|
888
|
-
label: "body NPR shader",
|
|
889
|
-
code: BODY_SHADER_WGSL,
|
|
890
|
-
});
|
|
891
|
-
const eyeShaderModule = this.device.createShaderModule({
|
|
892
|
-
label: "eye shader",
|
|
893
|
-
code: EYE_SHADER_WGSL,
|
|
894
|
-
});
|
|
895
|
-
const stockingsShaderModule = this.device.createShaderModule({
|
|
896
|
-
label: "stockings NPR shader",
|
|
897
|
-
code: STOCKINGS_SHADER_WGSL,
|
|
898
|
-
});
|
|
899
|
-
const mmdClassicShaderModule = this.device.createShaderModule({
|
|
900
|
-
label: "mmd classic shader",
|
|
901
|
-
code: MMD_CLASSIC_SHADER_WGSL,
|
|
902
|
-
});
|
|
903
868
|
// group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
|
|
904
869
|
this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
905
870
|
label: "main per-frame bind group layout",
|
|
@@ -927,7 +892,8 @@ export class Engine {
|
|
|
927
892
|
],
|
|
928
893
|
});
|
|
929
894
|
// group 2: per-material (textures + material uniforms) — bound per draw call.
|
|
930
|
-
// Toon + sphere slots
|
|
895
|
+
// Toon + sphere texture slots (bindings 2/3) are reserved for future sphere/toon graph
|
|
896
|
+
// nodes; graphs that don't read them just bind the 1×1 white fallback.
|
|
931
897
|
this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
|
|
932
898
|
label: "main per-material bind group layout",
|
|
933
899
|
entries: [
|
|
@@ -940,30 +906,13 @@ export class Engine {
|
|
|
940
906
|
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
941
907
|
],
|
|
942
908
|
});
|
|
943
|
-
//
|
|
909
|
+
// Shared zero StyleUniforms buffer — bound by every ungrouped material; grouped
|
|
910
|
+
// materials rebind binding(4) to their group's own buffer (per model, per group).
|
|
944
911
|
this.zeroStyleBuffer = this.device.createBuffer({
|
|
945
912
|
label: "style uniforms (zero)",
|
|
946
913
|
size: 256,
|
|
947
914
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
948
915
|
});
|
|
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
916
|
const mainPipelineLayout = this.device.createPipelineLayout({
|
|
968
917
|
label: "main pipeline layout",
|
|
969
918
|
bindGroupLayouts: [
|
|
@@ -974,179 +923,18 @@ export class Engine {
|
|
|
974
923
|
});
|
|
975
924
|
this.mainPipelineLayout = mainPipelineLayout;
|
|
976
925
|
// perFrameBindGroup is created after shadow resources below
|
|
977
|
-
this
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
depthCompare: "less-equal",
|
|
988
|
-
},
|
|
989
|
-
});
|
|
990
|
-
this.facePipeline = this.createRenderPipeline({
|
|
991
|
-
label: "face NPR pipeline",
|
|
992
|
-
layout: mainPipelineLayout,
|
|
993
|
-
shaderModule: faceShaderModule,
|
|
994
|
-
vertexBuffers: fullVertexBuffers,
|
|
995
|
-
fragmentTargets: sceneTargets,
|
|
996
|
-
cullMode: "none",
|
|
997
|
-
depthStencil: {
|
|
998
|
-
format: "depth24plus-stencil8",
|
|
999
|
-
depthWriteEnabled: true,
|
|
1000
|
-
depthCompare: "less-equal",
|
|
1001
|
-
},
|
|
1002
|
-
});
|
|
1003
|
-
// Hair opaque: stencil != EYE_VALUE so fragments on top of eyes are skipped entirely —
|
|
1004
|
-
// depth and color stay as the eye wrote them; the follow-up hairOverEyesPipeline then
|
|
1005
|
-
// draws those skipped fragments alpha-blended so the eye reads through the hair.
|
|
1006
|
-
this.hairPipeline = this.createRenderPipeline({
|
|
1007
|
-
label: "hair NPR pipeline",
|
|
1008
|
-
layout: mainPipelineLayout,
|
|
1009
|
-
shaderModule: hairShaderModule,
|
|
1010
|
-
vertexBuffers: fullVertexBuffers,
|
|
1011
|
-
fragmentTargets: sceneTargets,
|
|
1012
|
-
cullMode: "none",
|
|
1013
|
-
depthStencil: {
|
|
1014
|
-
format: "depth24plus-stencil8",
|
|
1015
|
-
depthWriteEnabled: true,
|
|
1016
|
-
depthCompare: "less-equal",
|
|
1017
|
-
stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
|
|
1018
|
-
stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
|
|
1019
|
-
stencilReadMask: 0xff,
|
|
1020
|
-
stencilWriteMask: 0,
|
|
1021
|
-
},
|
|
1022
|
-
});
|
|
1023
|
-
// Eye-over-hair (the see-through-hair effect, inverted): after hair draws
|
|
1024
|
-
// normally, the eye re-draws at 75% alpha with its depth pushed
|
|
1025
|
-
// EYE_SEE_THROUGH_RANGE world units toward the camera, so it only shows
|
|
1026
|
-
// through occluders within that window (bangs), never through the far
|
|
1027
|
-
// side of the head. Stencil == EYE_VALUE confines it to pixels where the
|
|
1028
|
-
// eye's primary draw passed depth — a hand in front of the face never
|
|
1029
|
-
// stamps, so it still occludes fully.
|
|
1030
|
-
// Hair-over-eyes: same shader with IS_OVER_EYES=true so alpha is scaled to 25% at compile time.
|
|
1031
|
-
// Only fragments where eye stencil == EYE_VALUE pass; depth test still culls fragments
|
|
1032
|
-
// that are further from camera than the eye, so hair behind the eye never shows through.
|
|
1033
|
-
// depthWriteEnabled=false keeps the eye's depth authoritative for anything drawn after.
|
|
1034
|
-
this.hairOverEyesPipeline = this.device.createRenderPipeline({
|
|
1035
|
-
label: "hair over eyes pipeline",
|
|
1036
|
-
layout: mainPipelineLayout,
|
|
1037
|
-
vertex: { module: hairShaderModule, buffers: fullVertexBuffers },
|
|
1038
|
-
fragment: {
|
|
1039
|
-
module: hairShaderModule,
|
|
1040
|
-
constants: { IS_OVER_EYES: 1 },
|
|
1041
|
-
targets: sceneTargets,
|
|
1042
|
-
},
|
|
1043
|
-
primitive: { cullMode: "none" },
|
|
1044
|
-
depthStencil: {
|
|
1045
|
-
format: "depth24plus-stencil8",
|
|
1046
|
-
depthWriteEnabled: false,
|
|
1047
|
-
depthCompare: "less-equal",
|
|
1048
|
-
stencilFront: { compare: "equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
|
|
1049
|
-
stencilBack: { compare: "equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
|
|
1050
|
-
stencilReadMask: 0xff,
|
|
1051
|
-
stencilWriteMask: 0,
|
|
1052
|
-
},
|
|
1053
|
-
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
1054
|
-
});
|
|
1055
|
-
this.clothSmoothPipeline = this.createRenderPipeline({
|
|
1056
|
-
label: "cloth smooth NPR pipeline",
|
|
1057
|
-
layout: mainPipelineLayout,
|
|
1058
|
-
shaderModule: clothSmoothShaderModule,
|
|
1059
|
-
vertexBuffers: fullVertexBuffers,
|
|
1060
|
-
fragmentTargets: sceneTargets,
|
|
1061
|
-
cullMode: "none",
|
|
1062
|
-
depthStencil: {
|
|
1063
|
-
format: "depth24plus-stencil8",
|
|
1064
|
-
depthWriteEnabled: true,
|
|
1065
|
-
depthCompare: "less-equal",
|
|
1066
|
-
},
|
|
1067
|
-
});
|
|
1068
|
-
this.mmdClassicPipeline = this.createRenderPipeline({
|
|
1069
|
-
label: "mmd classic pipeline",
|
|
1070
|
-
layout: mainPipelineLayout,
|
|
1071
|
-
shaderModule: mmdClassicShaderModule,
|
|
1072
|
-
vertexBuffers: fullVertexBuffers,
|
|
1073
|
-
fragmentTargets: sceneTargets,
|
|
1074
|
-
cullMode: "none",
|
|
1075
|
-
depthStencil: {
|
|
1076
|
-
format: "depth24plus-stencil8",
|
|
1077
|
-
depthWriteEnabled: true,
|
|
1078
|
-
depthCompare: "less-equal",
|
|
1079
|
-
},
|
|
1080
|
-
});
|
|
1081
|
-
this.clothRoughPipeline = this.createRenderPipeline({
|
|
1082
|
-
label: "cloth rough NPR pipeline",
|
|
1083
|
-
layout: mainPipelineLayout,
|
|
1084
|
-
shaderModule: clothRoughShaderModule,
|
|
1085
|
-
vertexBuffers: fullVertexBuffers,
|
|
1086
|
-
fragmentTargets: sceneTargets,
|
|
1087
|
-
cullMode: "none",
|
|
1088
|
-
depthStencil: {
|
|
1089
|
-
format: "depth24plus-stencil8",
|
|
1090
|
-
depthWriteEnabled: true,
|
|
1091
|
-
depthCompare: "less-equal",
|
|
1092
|
-
},
|
|
1093
|
-
});
|
|
1094
|
-
this.metalPipeline = this.createRenderPipeline({
|
|
1095
|
-
label: "metal NPR pipeline",
|
|
1096
|
-
layout: mainPipelineLayout,
|
|
1097
|
-
shaderModule: metalShaderModule,
|
|
1098
|
-
vertexBuffers: fullVertexBuffers,
|
|
1099
|
-
fragmentTargets: sceneTargets,
|
|
1100
|
-
cullMode: "none",
|
|
1101
|
-
depthStencil: {
|
|
1102
|
-
format: "depth24plus-stencil8",
|
|
1103
|
-
depthWriteEnabled: true,
|
|
1104
|
-
depthCompare: "less-equal",
|
|
1105
|
-
},
|
|
1106
|
-
});
|
|
1107
|
-
this.bodyPipeline = this.createRenderPipeline({
|
|
1108
|
-
label: "body NPR pipeline",
|
|
1109
|
-
layout: mainPipelineLayout,
|
|
1110
|
-
shaderModule: bodyShaderModule,
|
|
1111
|
-
vertexBuffers: fullVertexBuffers,
|
|
1112
|
-
fragmentTargets: sceneTargets,
|
|
1113
|
-
cullMode: "none",
|
|
1114
|
-
depthStencil: {
|
|
1115
|
-
format: "depth24plus-stencil8",
|
|
1116
|
-
depthWriteEnabled: true,
|
|
1117
|
-
depthCompare: "less-equal",
|
|
1118
|
-
},
|
|
1119
|
-
});
|
|
1120
|
-
// Eye: stamps stencil = EYE_VALUE on every fragment it writes. Later hair passes read
|
|
1121
|
-
// this stamp to split into "draw normally (not over eye)" vs "draw alpha-blended".
|
|
1122
|
-
// cullMode="front" + small negative depthBias is the MMD post-alpha-eye trick: only the
|
|
1123
|
-
// back half of the eye sphere renders, it passes depth against the face (via bias) when
|
|
1124
|
-
// viewed from the front, and it gets culled when viewed from behind — so eye fragments
|
|
1125
|
-
// can't leak through the back of the head without needing a per-model skull occluder.
|
|
1126
|
-
this.eyePipeline = this.createRenderPipeline({
|
|
1127
|
-
label: "eye pipeline",
|
|
1128
|
-
layout: mainPipelineLayout,
|
|
1129
|
-
shaderModule: eyeShaderModule,
|
|
1130
|
-
vertexBuffers: fullVertexBuffers,
|
|
1131
|
-
fragmentTargets: sceneTargets,
|
|
1132
|
-
cullMode: "front",
|
|
1133
|
-
depthStencil: {
|
|
1134
|
-
format: "depth24plus-stencil8",
|
|
1135
|
-
depthWriteEnabled: true,
|
|
1136
|
-
depthCompare: "less-equal",
|
|
1137
|
-
depthBias: -0.00005,
|
|
1138
|
-
depthBiasSlopeScale: 0.0,
|
|
1139
|
-
depthBiasClamp: 0.0,
|
|
1140
|
-
stencilFront: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" },
|
|
1141
|
-
stencilBack: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" },
|
|
1142
|
-
stencilReadMask: 0xff,
|
|
1143
|
-
stencilWriteMask: 0xff,
|
|
1144
|
-
},
|
|
1145
|
-
});
|
|
1146
|
-
this.stockingsPipeline = this.createRenderPipeline({
|
|
1147
|
-
label: "stockings NPR pipeline",
|
|
926
|
+
// Ungrouped materials render this neutral base — the compiled DEFAULT_GRAPH (diffuse
|
|
927
|
+
// texture x material color -> Principled BSDF). Grouped materials use their group's
|
|
928
|
+
// compiled pipeline instead. This is the single base shading model; the per-preset
|
|
929
|
+
// hand shaders are retired in favor of graphs.
|
|
930
|
+
const neutral = compileGraph(DEFAULT_GRAPH, { renderClass: "auto", alphaMode: "opaque" });
|
|
931
|
+
if (!neutral.ok)
|
|
932
|
+
throw new Error("failed to compile the neutral default graph");
|
|
933
|
+
const neutralModule = this.device.createShaderModule({ label: "neutral base (default graph)", code: neutral.wgsl });
|
|
934
|
+
this.neutralPipeline = this.createRenderPipeline({
|
|
935
|
+
label: "neutral base pipeline",
|
|
1148
936
|
layout: mainPipelineLayout,
|
|
1149
|
-
shaderModule:
|
|
937
|
+
shaderModule: neutralModule,
|
|
1150
938
|
vertexBuffers: fullVertexBuffers,
|
|
1151
939
|
fragmentTargets: sceneTargets,
|
|
1152
940
|
cullMode: "none",
|
|
@@ -2142,7 +1930,7 @@ export class Engine {
|
|
|
2142
1930
|
firstIndex: 0,
|
|
2143
1931
|
bindGroup: this.groundShadowBindGroup,
|
|
2144
1932
|
materialName: "Ground",
|
|
2145
|
-
|
|
1933
|
+
groupId: null,
|
|
2146
1934
|
};
|
|
2147
1935
|
}
|
|
2148
1936
|
updateLightBuffer() {
|
|
@@ -2189,12 +1977,14 @@ export class Engine {
|
|
|
2189
1977
|
this.resizeObserver.disconnect();
|
|
2190
1978
|
this.resizeObserver = null;
|
|
2191
1979
|
}
|
|
2192
|
-
// Style
|
|
2193
|
-
//
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
1980
|
+
// Style group runtime: per-group uniform buffers (pipelines are GC'd; buffers need
|
|
1981
|
+
// explicit destroy). Per-model group buffers are torn down in removeModel; the shared
|
|
1982
|
+
// zero buffer is engine-owned.
|
|
1983
|
+
this.forEachInstance((inst) => {
|
|
1984
|
+
for (const install of inst.styleGroups.values())
|
|
1985
|
+
install.uniformBuffer.destroy();
|
|
1986
|
+
inst.styleGroups.clear();
|
|
1987
|
+
});
|
|
2198
1988
|
this.zeroStyleBuffer?.destroy();
|
|
2199
1989
|
}
|
|
2200
1990
|
async loadModel(nameOrPath, pathOrOptions) {
|
|
@@ -2246,6 +2036,9 @@ export class Engine {
|
|
|
2246
2036
|
for (const buf of inst.gpuBuffers) {
|
|
2247
2037
|
buf.destroy();
|
|
2248
2038
|
}
|
|
2039
|
+
// Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
|
|
2040
|
+
for (const install of inst.styleGroups.values())
|
|
2041
|
+
install.uniformBuffer.destroy();
|
|
2249
2042
|
this.modelInstances.delete(name);
|
|
2250
2043
|
}
|
|
2251
2044
|
getModelNames() {
|
|
@@ -2286,23 +2079,9 @@ export class Engine {
|
|
|
2286
2079
|
const boneIndex = inst.model.getSkeleton().bones.findIndex((b) => b.name === boneName);
|
|
2287
2080
|
this.selectedBone = boneIndex >= 0 ? { modelName, boneName, boneIndex } : null;
|
|
2288
2081
|
}
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
return;
|
|
2293
|
-
inst.materialPresets = presets;
|
|
2294
|
-
for (const dc of inst.drawCalls) {
|
|
2295
|
-
const preset = resolvePreset(dc.materialName, presets);
|
|
2296
|
-
if (preset === dc.preset)
|
|
2297
|
-
continue;
|
|
2298
|
-
dc.preset = preset;
|
|
2299
|
-
// Rebind so binding(4) follows the new slot's style buffer.
|
|
2300
|
-
if (dc.baseBindGroupEntries)
|
|
2301
|
-
dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, preset);
|
|
2302
|
-
}
|
|
2303
|
-
}
|
|
2304
|
-
createMaterialBindGroup(label, baseEntries, preset) {
|
|
2305
|
-
const styleBuffer = preset === "mmd_classic" ? this.zeroStyleBuffer : this.styleBuffers.get(preset);
|
|
2082
|
+
// Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
|
|
2083
|
+
// (the group's buffer when grouped, or the shared zero buffer when ungrouped).
|
|
2084
|
+
createMaterialBindGroup(label, baseEntries, styleBuffer) {
|
|
2306
2085
|
return this.device.createBindGroup({
|
|
2307
2086
|
label,
|
|
2308
2087
|
layout: this.mainPerMaterialBindGroupLayout,
|
|
@@ -2513,10 +2292,12 @@ export class Engine {
|
|
|
2513
2292
|
pickPerInstanceBindGroup,
|
|
2514
2293
|
pickDrawCalls: [],
|
|
2515
2294
|
hiddenMaterials: new Set(),
|
|
2516
|
-
materialPresets: undefined,
|
|
2517
2295
|
physics,
|
|
2518
2296
|
vertexBufferNeedsUpdate: false,
|
|
2519
2297
|
gpuMorph,
|
|
2298
|
+
styleGroups: new Map(),
|
|
2299
|
+
materialToGroup: new Map(),
|
|
2300
|
+
styleGroupGen: new Map(),
|
|
2520
2301
|
};
|
|
2521
2302
|
await this.setupMaterialsForInstance(inst);
|
|
2522
2303
|
this.modelInstances.set(name, inst);
|
|
@@ -2749,7 +2530,6 @@ export class Engine {
|
|
|
2749
2530
|
}
|
|
2750
2531
|
const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, mat, sphereMode, headBoneIndex);
|
|
2751
2532
|
inst.gpuBuffers.push(materialUniformBuffer);
|
|
2752
|
-
const preset = resolvePreset(mat.name, inst.materialPresets);
|
|
2753
2533
|
const textureView = diffuseTexture.createView();
|
|
2754
2534
|
const baseBindGroupEntries = [
|
|
2755
2535
|
{ binding: 0, resource: textureView },
|
|
@@ -2757,7 +2537,9 @@ export class Engine {
|
|
|
2757
2537
|
{ binding: 2, resource: (toonTexture ?? this.fallbackMaterialTexture).createView() },
|
|
2758
2538
|
{ binding: 3, resource: (sphereTexture ?? this.fallbackMaterialTexture).createView() },
|
|
2759
2539
|
];
|
|
2760
|
-
|
|
2540
|
+
// Ungrouped at load — binding(4) = zero buffer, neutral base pipeline. autoStyleGroups
|
|
2541
|
+
// / applyStyleGroups rebind grouped materials to their group's buffer + pipeline.
|
|
2542
|
+
const bindGroup = this.createMaterialBindGroup(`${prefix}material: ${mat.name}`, baseBindGroupEntries, this.zeroStyleBuffer);
|
|
2761
2543
|
const type = isTransparent ? "transparent" : "opaque";
|
|
2762
2544
|
inst.drawCalls.push({
|
|
2763
2545
|
type,
|
|
@@ -2765,7 +2547,7 @@ export class Engine {
|
|
|
2765
2547
|
firstIndex: currentIndexOffset,
|
|
2766
2548
|
bindGroup,
|
|
2767
2549
|
materialName: mat.name,
|
|
2768
|
-
|
|
2550
|
+
groupId: null,
|
|
2769
2551
|
baseBindGroupEntries,
|
|
2770
2552
|
});
|
|
2771
2553
|
if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
|
|
@@ -2793,7 +2575,7 @@ export class Engine {
|
|
|
2793
2575
|
firstIndex: currentIndexOffset,
|
|
2794
2576
|
bindGroup: outlineBindGroup,
|
|
2795
2577
|
materialName: mat.name,
|
|
2796
|
-
|
|
2578
|
+
groupId: null,
|
|
2797
2579
|
});
|
|
2798
2580
|
}
|
|
2799
2581
|
if (this.onRaycast) {
|
|
@@ -2809,30 +2591,14 @@ export class Engine {
|
|
|
2809
2591
|
}
|
|
2810
2592
|
currentIndexOffset += indexCount;
|
|
2811
2593
|
}
|
|
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
|
-
}
|
|
2594
|
+
// Sort so the opaque bucket is emitted in the order the stencil-based see-through-hair
|
|
2595
|
+
// effect requires: {non-hair, non-eye} → {eye} → {hair}. Eye writes stencil=EYE_VALUE;
|
|
2596
|
+
// hair stencil-tests "not equal" and skips eye pixels; the follow-up hairOverEyes pass
|
|
2597
|
+
// re-fills them alpha-blended. sortDrawCalls also (re)builds shadowDrawCalls. All draws
|
|
2598
|
+
// are ungrouped at setup, so the rank comes from the preset; applyStyleGroups re-sorts
|
|
2599
|
+
// by render-class when groups are assigned. Array.sort is stable → PMX order preserved
|
|
2600
|
+
// within a bucket.
|
|
2601
|
+
this.sortDrawCalls(inst);
|
|
2836
2602
|
}
|
|
2837
2603
|
createMaterialUniformBuffer(label, mat, sphereMode, headBoneIndex) {
|
|
2838
2604
|
// Matches the WGSL MaterialUniforms struct in common.ts — 64 bytes
|
|
@@ -2871,33 +2637,66 @@ export class Engine {
|
|
|
2871
2637
|
if (cached) {
|
|
2872
2638
|
return cached;
|
|
2873
2639
|
}
|
|
2640
|
+
let buffer;
|
|
2874
2641
|
try {
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
});
|
|
2880
|
-
const mipLevelCount = Math.floor(Math.log2(Math.max(imageBitmap.width, imageBitmap.height))) + 1;
|
|
2881
|
-
const texture = this.device.createTexture({
|
|
2882
|
-
label: `texture: ${cacheKey}`,
|
|
2883
|
-
size: [imageBitmap.width, imageBitmap.height],
|
|
2884
|
-
format: "rgba8unorm-srgb",
|
|
2885
|
-
mipLevelCount,
|
|
2886
|
-
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
2887
|
-
});
|
|
2888
|
-
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
|
|
2889
|
-
imageBitmap.width,
|
|
2890
|
-
imageBitmap.height,
|
|
2891
|
-
]);
|
|
2892
|
-
if (mipLevelCount > 1)
|
|
2893
|
-
this.generateMipmaps(texture, mipLevelCount);
|
|
2894
|
-
this.textureCache.set(cacheKey, texture);
|
|
2895
|
-
inst.textureCacheKeys.push(cacheKey);
|
|
2896
|
-
return texture;
|
|
2897
|
-
}
|
|
2898
|
-
catch {
|
|
2642
|
+
buffer = await inst.assetReader.readBinary(logicalPath);
|
|
2643
|
+
}
|
|
2644
|
+
catch (e) {
|
|
2645
|
+
console.warn(`[reze] texture read failed: ${logicalPath}`, e instanceof Error ? e.message : e);
|
|
2899
2646
|
return null;
|
|
2900
2647
|
}
|
|
2648
|
+
// Decode to either an ImageBitmap (web-native formats) or raw RGBA (TGA). .tga skips
|
|
2649
|
+
// straight to the TGA decoder (createImageBitmap can't read it); other extensions try
|
|
2650
|
+
// the browser decoder first, then fall back to TGA in case a .spa/.sph/etc. is TGA
|
|
2651
|
+
// under its extension. Every failure is logged and soft — never throws to the caller.
|
|
2652
|
+
let source = null;
|
|
2653
|
+
let rgba = null;
|
|
2654
|
+
let width;
|
|
2655
|
+
let height;
|
|
2656
|
+
const isTga = logicalPath.toLowerCase().endsWith(".tga");
|
|
2657
|
+
if (!isTga) {
|
|
2658
|
+
try {
|
|
2659
|
+
source = await createImageBitmap(new Blob([buffer]), { premultiplyAlpha: "none", colorSpaceConversion: "none" });
|
|
2660
|
+
}
|
|
2661
|
+
catch {
|
|
2662
|
+
source = null; // not a browser-native image — try TGA below
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
if (source) {
|
|
2666
|
+
width = source.width;
|
|
2667
|
+
height = source.height;
|
|
2668
|
+
}
|
|
2669
|
+
else {
|
|
2670
|
+
try {
|
|
2671
|
+
const img = decodeTga(buffer);
|
|
2672
|
+
rgba = img.rgba;
|
|
2673
|
+
width = img.width;
|
|
2674
|
+
height = img.height;
|
|
2675
|
+
}
|
|
2676
|
+
catch (e) {
|
|
2677
|
+
console.warn(`[reze] texture decode failed (unsupported format?): ${logicalPath}`, e instanceof Error ? e.message : e);
|
|
2678
|
+
return null;
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
const mipLevelCount = Math.floor(Math.log2(Math.max(width, height))) + 1;
|
|
2682
|
+
const texture = this.device.createTexture({
|
|
2683
|
+
label: `texture: ${cacheKey}`,
|
|
2684
|
+
size: [width, height],
|
|
2685
|
+
format: "rgba8unorm-srgb",
|
|
2686
|
+
mipLevelCount,
|
|
2687
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
2688
|
+
});
|
|
2689
|
+
if (source) {
|
|
2690
|
+
this.device.queue.copyExternalImageToTexture({ source }, { texture }, [width, height]);
|
|
2691
|
+
}
|
|
2692
|
+
else {
|
|
2693
|
+
this.device.queue.writeTexture({ texture }, rgba, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]);
|
|
2694
|
+
}
|
|
2695
|
+
if (mipLevelCount > 1)
|
|
2696
|
+
this.generateMipmaps(texture, mipLevelCount);
|
|
2697
|
+
this.textureCache.set(cacheKey, texture);
|
|
2698
|
+
inst.textureCacheKeys.push(cacheKey);
|
|
2699
|
+
return texture;
|
|
2901
2700
|
}
|
|
2902
2701
|
// Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
|
|
2903
2702
|
// re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
|
|
@@ -3401,35 +3200,191 @@ export class Engine {
|
|
|
3401
3200
|
sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
3402
3201
|
}
|
|
3403
3202
|
}
|
|
3404
|
-
// ─── Style
|
|
3405
|
-
// Two-tier
|
|
3406
|
-
//
|
|
3407
|
-
//
|
|
3203
|
+
// ─── Style group API ──────────────────────────────────────────────
|
|
3204
|
+
// Two-tier edits: applyStyleGroups/upsert = topology (async compile + pipeline swap,
|
|
3205
|
+
// fallback-on-error, per-group stale guard); setStyleParam = adjust (instant uniform
|
|
3206
|
+
// write). Overlay-first: grouped materials render via their group's compiled graph;
|
|
3207
|
+
// ungrouped ones keep the hand-written preset path. See docs/style-groups-spec.md.
|
|
3208
|
+
/** Read a model's current style groups (including auto-created defaults) for editor
|
|
3209
|
+
* round-trip. The host owns group state; this is bootstrap/read, not a second store. */
|
|
3210
|
+
getStyleGroups(modelName) {
|
|
3211
|
+
const inst = this.modelInstances.get(modelName);
|
|
3212
|
+
if (!inst)
|
|
3213
|
+
return [];
|
|
3214
|
+
return [...inst.styleGroups.values()].map((g) => g.group);
|
|
3215
|
+
}
|
|
3408
3216
|
/**
|
|
3409
|
-
*
|
|
3410
|
-
*
|
|
3411
|
-
*
|
|
3217
|
+
* Create default style groups from each material's resolved style category — `overrides`
|
|
3218
|
+
* (material name → category) first, then the built-in JP/CN/EN name hints. So a
|
|
3219
|
+
* standard-named model auto-groups with no overrides; a custom-named one passes a map for
|
|
3220
|
+
* the materials the hints miss. Unmatched materials (no override, no hint) stay ungrouped
|
|
3221
|
+
* (neutral default). The category picks the default graph + render-class + alpha-mode.
|
|
3222
|
+
* Resolves after grouping AND the async compiles, so `getStyleGroups` is then populated.
|
|
3412
3223
|
*/
|
|
3413
|
-
async
|
|
3414
|
-
const
|
|
3224
|
+
async autoStyleGroups(modelName, overrides) {
|
|
3225
|
+
const inst = this.modelInstances.get(modelName);
|
|
3226
|
+
if (!inst)
|
|
3227
|
+
return { ok: false, groups: [], unknownMaterials: [], conflicts: [] };
|
|
3228
|
+
const buckets = new Map();
|
|
3229
|
+
for (const dc of inst.drawCalls) {
|
|
3230
|
+
if (!dc.baseBindGroupEntries)
|
|
3231
|
+
continue; // material draw calls only (skip outlines)
|
|
3232
|
+
const preset = resolvePreset(dc.materialName, overrides);
|
|
3233
|
+
if (preset === "mmd_classic")
|
|
3234
|
+
continue; // unmatched → ungrouped
|
|
3235
|
+
const arr = buckets.get(preset) ?? [];
|
|
3236
|
+
if (!arr.includes(dc.materialName))
|
|
3237
|
+
arr.push(dc.materialName);
|
|
3238
|
+
buckets.set(preset, arr);
|
|
3239
|
+
}
|
|
3240
|
+
const groups = [];
|
|
3241
|
+
for (const [preset, materials] of buckets) {
|
|
3242
|
+
const info = PRESET_GROUP_INFO[preset];
|
|
3243
|
+
if (!info)
|
|
3244
|
+
continue;
|
|
3245
|
+
groups.push({
|
|
3246
|
+
id: preset,
|
|
3247
|
+
label: info.graph.name,
|
|
3248
|
+
materials,
|
|
3249
|
+
graph: info.graph,
|
|
3250
|
+
renderClass: info.renderClass,
|
|
3251
|
+
alphaMode: info.alphaMode,
|
|
3252
|
+
});
|
|
3253
|
+
}
|
|
3254
|
+
return this.applyStyleGroups(modelName, groups);
|
|
3255
|
+
}
|
|
3256
|
+
/**
|
|
3257
|
+
* Replace a model's full style-group set. Unchanged groups (same graph + renderClass +
|
|
3258
|
+
* alphaMode) keep their pipeline; new/changed ones compile and swap; removed ones are
|
|
3259
|
+
* torn down and their materials revert to the ungrouped hand-shader path. A group whose
|
|
3260
|
+
* compile fails is not installed and its materials stay ungrouped (fallback-on-error).
|
|
3261
|
+
*/
|
|
3262
|
+
async applyStyleGroups(modelName, groups) {
|
|
3263
|
+
const inst = this.modelInstances.get(modelName);
|
|
3264
|
+
if (!inst)
|
|
3265
|
+
return { ok: false, groups: [], unknownMaterials: [], conflicts: [] };
|
|
3266
|
+
// Whole-set validation: material claims (last group in array order wins), unknowns.
|
|
3267
|
+
const modelMaterials = new Set(inst.drawCalls.map((d) => d.materialName));
|
|
3268
|
+
const claimed = new Map();
|
|
3269
|
+
const conflicts = new Set();
|
|
3270
|
+
const unknownMaterials = new Set();
|
|
3271
|
+
for (const g of groups) {
|
|
3272
|
+
for (const m of g.materials) {
|
|
3273
|
+
if (!modelMaterials.has(m))
|
|
3274
|
+
unknownMaterials.add(m);
|
|
3275
|
+
if (claimed.has(m))
|
|
3276
|
+
conflicts.add(m);
|
|
3277
|
+
claimed.set(m, g.id);
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
// Tear down installs no longer present.
|
|
3281
|
+
const nextIds = new Set(groups.map((g) => g.id));
|
|
3282
|
+
for (const [id, install] of inst.styleGroups) {
|
|
3283
|
+
if (!nextIds.has(id)) {
|
|
3284
|
+
inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1);
|
|
3285
|
+
install.uniformBuffer.destroy();
|
|
3286
|
+
inst.styleGroups.delete(id);
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
const groupResults = [];
|
|
3290
|
+
for (const g of groups) {
|
|
3291
|
+
const r = await this.compileAndInstallGroup(inst, g);
|
|
3292
|
+
groupResults.push({ groupId: g.id, diagnostics: r.diagnostics, ok: r.ok });
|
|
3293
|
+
}
|
|
3294
|
+
this.assignDrawCallGroups(inst, claimed);
|
|
3295
|
+
return {
|
|
3296
|
+
ok: groupResults.every((r) => r.ok),
|
|
3297
|
+
groups: groupResults,
|
|
3298
|
+
unknownMaterials: [...unknownMaterials],
|
|
3299
|
+
conflicts: [...conflicts],
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
/** Add or replace a single style group by id. `opts` may carry a `previewNode` for the
|
|
3303
|
+
* editor's node-output preview workflow. */
|
|
3304
|
+
async upsertStyleGroup(modelName, group, opts) {
|
|
3305
|
+
const inst = this.modelInstances.get(modelName);
|
|
3306
|
+
if (!inst)
|
|
3307
|
+
return { ok: false, diagnostics: [{ severity: "error", message: `unknown model "${modelName}"` }], slotMap: [] };
|
|
3308
|
+
const r = await this.compileAndInstallGroup(inst, group, opts);
|
|
3309
|
+
this.assignDrawCallGroups(inst, this.currentClaims(inst));
|
|
3310
|
+
return r;
|
|
3311
|
+
}
|
|
3312
|
+
/** Remove a style group; its materials revert to the ungrouped hand-shader path. */
|
|
3313
|
+
removeStyleGroup(modelName, groupId) {
|
|
3314
|
+
const inst = this.modelInstances.get(modelName);
|
|
3315
|
+
const install = inst?.styleGroups.get(groupId);
|
|
3316
|
+
if (!inst || !install)
|
|
3317
|
+
return;
|
|
3318
|
+
inst.styleGroupGen.set(groupId, (inst.styleGroupGen.get(groupId) ?? 0) + 1); // discard in-flight compile
|
|
3319
|
+
install.uniformBuffer.destroy();
|
|
3320
|
+
inst.styleGroups.delete(groupId);
|
|
3321
|
+
this.assignDrawCallGroups(inst, this.currentClaims(inst));
|
|
3322
|
+
}
|
|
3323
|
+
/** Clear all style groups on a model — every material returns to the hand-shader path. */
|
|
3324
|
+
resetStyleGroups(modelName) {
|
|
3325
|
+
const inst = this.modelInstances.get(modelName);
|
|
3326
|
+
if (!inst)
|
|
3327
|
+
return;
|
|
3328
|
+
for (const [id, install] of inst.styleGroups) {
|
|
3329
|
+
inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1);
|
|
3330
|
+
install.uniformBuffer.destroy();
|
|
3331
|
+
}
|
|
3332
|
+
inst.styleGroups.clear();
|
|
3333
|
+
this.assignDrawCallGroups(inst, new Map());
|
|
3334
|
+
}
|
|
3335
|
+
/** Instant adjust-tier write: set one exposed slider on a group's applied graph. */
|
|
3336
|
+
setStyleParam(modelName, groupId, paramId, value) {
|
|
3337
|
+
const install = this.modelInstances.get(modelName)?.styleGroups.get(groupId);
|
|
3338
|
+
const styleSlot = install?.slotMap.find((s) => s.id === paramId);
|
|
3339
|
+
if (!install || !styleSlot)
|
|
3340
|
+
return false;
|
|
3341
|
+
if (styleSlot.kind === "float") {
|
|
3342
|
+
if (typeof value !== "number")
|
|
3343
|
+
return false;
|
|
3344
|
+
const offset = styleSlot.vec4Index * 16 + ["x", "y", "z", "w"].indexOf(styleSlot.component) * 4;
|
|
3345
|
+
this.device.queue.writeBuffer(install.uniformBuffer, offset, new Float32Array([value]));
|
|
3346
|
+
}
|
|
3347
|
+
else {
|
|
3348
|
+
if (typeof value === "number")
|
|
3349
|
+
return false;
|
|
3350
|
+
this.device.queue.writeBuffer(install.uniformBuffer, styleSlot.vec4Index * 16, new Float32Array(value));
|
|
3351
|
+
}
|
|
3352
|
+
return true;
|
|
3353
|
+
}
|
|
3354
|
+
// Materials claimed by the model's currently-installed groups (for upsert/remove paths;
|
|
3355
|
+
// applyStyleGroups derives claims from its input array instead).
|
|
3356
|
+
currentClaims(inst) {
|
|
3357
|
+
const claimed = new Map();
|
|
3358
|
+
for (const install of inst.styleGroups.values())
|
|
3359
|
+
for (const m of install.group.materials)
|
|
3360
|
+
claimed.set(m, install.group.id);
|
|
3361
|
+
return claimed;
|
|
3362
|
+
}
|
|
3363
|
+
// Compile a group's graph → WGSL → pipeline(s), install keyed by group id. Reuses the
|
|
3364
|
+
// install (pipeline + uniform buffer) when the graph/integration is byte-unchanged.
|
|
3365
|
+
async compileAndInstallGroup(inst, group, opts) {
|
|
3366
|
+
const renderClass = group.renderClass ?? "auto";
|
|
3367
|
+
const alphaMode = group.alphaMode ?? "opaque";
|
|
3368
|
+
const signature = JSON.stringify({ g: group.graph, rc: renderClass, am: alphaMode, o: opts?.previewNode ?? null });
|
|
3369
|
+
const existing = inst.styleGroups.get(group.id);
|
|
3370
|
+
if (existing && existing.signature === signature) {
|
|
3371
|
+
existing.group = group; // refresh def (label/materials) without recompiling
|
|
3372
|
+
return { ok: true, diagnostics: [], slotMap: existing.slotMap };
|
|
3373
|
+
}
|
|
3374
|
+
const result = compileGraph(group.graph, { ...opts, renderClass, alphaMode });
|
|
3415
3375
|
if (!result.ok)
|
|
3416
3376
|
return { ok: false, diagnostics: result.diagnostics, slotMap: result.slotMap };
|
|
3417
|
-
const
|
|
3418
|
-
|
|
3419
|
-
this.styleGenerations.set(slot, generation);
|
|
3377
|
+
const generation = (inst.styleGroupGen.get(group.id) ?? 0) + 1;
|
|
3378
|
+
inst.styleGroupGen.set(group.id, generation);
|
|
3420
3379
|
this.device.pushErrorScope("validation");
|
|
3421
|
-
const module = this.device.createShaderModule({ label: `style
|
|
3380
|
+
const module = this.device.createShaderModule({ label: `style group: ${group.id} (${renderClass})`, code: result.wgsl });
|
|
3422
3381
|
const info = await module.getCompilationInfo();
|
|
3423
3382
|
const scopeError = await this.device.popErrorScope();
|
|
3424
3383
|
const diagnostics = [...result.diagnostics];
|
|
3425
3384
|
for (const msg of info.messages) {
|
|
3426
3385
|
if (msg.type !== "error")
|
|
3427
3386
|
continue;
|
|
3428
|
-
diagnostics.push({
|
|
3429
|
-
severity: "error",
|
|
3430
|
-
nodeId: nodeIdForWgslLine(result.wgsl, msg.lineNum),
|
|
3431
|
-
message: `WGSL: ${msg.message}`,
|
|
3432
|
-
});
|
|
3387
|
+
diagnostics.push({ severity: "error", nodeId: nodeIdForWgslLine(result.wgsl, msg.lineNum), message: `WGSL: ${msg.message}` });
|
|
3433
3388
|
}
|
|
3434
3389
|
if (diagnostics.some((d) => d.severity === "error") || scopeError) {
|
|
3435
3390
|
if (scopeError && !diagnostics.some((d) => d.severity === "error"))
|
|
@@ -3439,52 +3394,61 @@ export class Engine {
|
|
|
3439
3394
|
let pipeline;
|
|
3440
3395
|
let overEyesPipeline;
|
|
3441
3396
|
try {
|
|
3442
|
-
pipeline = await this.
|
|
3443
|
-
if (
|
|
3444
|
-
overEyesPipeline = await this.
|
|
3397
|
+
pipeline = await this.createRenderClassPipeline(renderClass, module, false);
|
|
3398
|
+
if (renderClass === "hair")
|
|
3399
|
+
overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true);
|
|
3445
3400
|
}
|
|
3446
3401
|
catch (e) {
|
|
3447
3402
|
diagnostics.push({ severity: "error", message: `pipeline creation failed: ${e.message}` });
|
|
3448
3403
|
return { ok: false, diagnostics, slotMap: result.slotMap };
|
|
3449
3404
|
}
|
|
3450
|
-
//
|
|
3451
|
-
if (
|
|
3405
|
+
// Stale guard: a newer compile/remove for this id happened while we awaited.
|
|
3406
|
+
if (inst.styleGroupGen.get(group.id) !== generation) {
|
|
3452
3407
|
diagnostics.push({ severity: "warning", message: "superseded by a newer edit — result discarded" });
|
|
3453
3408
|
return { ok: false, diagnostics, slotMap: result.slotMap };
|
|
3454
3409
|
}
|
|
3455
|
-
|
|
3456
|
-
|
|
3410
|
+
const uniformBuffer = existing?.uniformBuffer ??
|
|
3411
|
+
this.device.createBuffer({
|
|
3412
|
+
label: `style uniforms: ${group.id}`,
|
|
3413
|
+
size: 256,
|
|
3414
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
3415
|
+
});
|
|
3416
|
+
inst.styleGroups.set(group.id, {
|
|
3417
|
+
group,
|
|
3418
|
+
renderClass,
|
|
3419
|
+
alphaMode,
|
|
3420
|
+
pipeline,
|
|
3421
|
+
overEyesPipeline,
|
|
3422
|
+
uniformBuffer,
|
|
3423
|
+
slotMap: result.slotMap,
|
|
3424
|
+
signature,
|
|
3425
|
+
});
|
|
3426
|
+
this.writeGroupDefaults(uniformBuffer, group, result.slotMap);
|
|
3457
3427
|
return { ok: true, diagnostics, slotMap: result.slotMap };
|
|
3458
3428
|
}
|
|
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));
|
|
3429
|
+
// Rebind each material draw call to its (successfully-installed) group's uniform buffer,
|
|
3430
|
+
// or the zero buffer when ungrouped, then re-sort by render-class draw order.
|
|
3431
|
+
assignDrawCallGroups(inst, claimed) {
|
|
3432
|
+
inst.materialToGroup.clear();
|
|
3433
|
+
for (const dc of inst.drawCalls) {
|
|
3434
|
+
if (!dc.baseBindGroupEntries)
|
|
3435
|
+
continue; // outlines/ground are never grouped
|
|
3436
|
+
const wantId = claimed.get(dc.materialName);
|
|
3437
|
+
const install = wantId ? inst.styleGroups.get(wantId) : undefined;
|
|
3438
|
+
const groupId = install ? wantId : null;
|
|
3439
|
+
if (groupId)
|
|
3440
|
+
inst.materialToGroup.set(dc.materialName, groupId);
|
|
3441
|
+
if (dc.groupId === groupId)
|
|
3442
|
+
continue;
|
|
3443
|
+
dc.groupId = groupId;
|
|
3444
|
+
dc.bindGroup = this.createMaterialBindGroup(`material: ${dc.materialName}`, dc.baseBindGroupEntries, install ? install.uniformBuffer : this.zeroStyleBuffer);
|
|
3476
3445
|
}
|
|
3477
|
-
|
|
3478
|
-
}
|
|
3479
|
-
/** Remove an applied style graph — the slot returns to its built-in preset shader. */
|
|
3480
|
-
resetStyleSlot(slot) {
|
|
3481
|
-
this.styleGenerations.set(slot, (this.styleGenerations.get(slot) ?? 0) + 1);
|
|
3482
|
-
this.styleOverrides.delete(slot);
|
|
3446
|
+
this.sortDrawCalls(inst);
|
|
3483
3447
|
}
|
|
3484
|
-
|
|
3448
|
+
writeGroupDefaults(buffer, group, slotMap) {
|
|
3485
3449
|
const data = new Float32Array(64); // 16 vec4f
|
|
3486
3450
|
for (const styleSlot of slotMap) {
|
|
3487
|
-
const param = graph.params?.find((p) => p.id === styleSlot.id);
|
|
3451
|
+
const param = group.graph.params?.find((p) => p.id === styleSlot.id);
|
|
3488
3452
|
if (!param)
|
|
3489
3453
|
continue;
|
|
3490
3454
|
const base = styleSlot.vec4Index * 4;
|
|
@@ -3495,19 +3459,36 @@ export class Engine {
|
|
|
3495
3459
|
data.set(param.default.slice(0, 3), base);
|
|
3496
3460
|
}
|
|
3497
3461
|
}
|
|
3498
|
-
this.device.queue.writeBuffer(
|
|
3462
|
+
this.device.queue.writeBuffer(buffer, 0, data);
|
|
3463
|
+
}
|
|
3464
|
+
// Draw-order rank within a bucket: eye stamps before hair reads. Purely from the group's
|
|
3465
|
+
// render-class — ungrouped materials are neutral (rank 0, no stencil interplay).
|
|
3466
|
+
drawCallRank(inst, dc) {
|
|
3467
|
+
const rc = dc.groupId ? (inst.styleGroups.get(dc.groupId)?.renderClass ?? "auto") : "auto";
|
|
3468
|
+
return rc === "hair" ? 2 : rc === "eye" ? 1 : 0;
|
|
3469
|
+
}
|
|
3470
|
+
sortDrawCalls(inst) {
|
|
3471
|
+
const typeOrder = {
|
|
3472
|
+
opaque: 0,
|
|
3473
|
+
"opaque-outline": 1,
|
|
3474
|
+
transparent: 2,
|
|
3475
|
+
"transparent-outline": 3,
|
|
3476
|
+
ground: 4,
|
|
3477
|
+
};
|
|
3478
|
+
inst.drawCalls.sort((a, b) => typeOrder[a.type] - typeOrder[b.type] || this.drawCallRank(inst, a) - this.drawCallRank(inst, b));
|
|
3479
|
+
inst.shadowDrawCalls = inst.drawCalls.filter((d) => d.type === "opaque");
|
|
3499
3480
|
}
|
|
3500
3481
|
/**
|
|
3501
|
-
*
|
|
3502
|
-
*
|
|
3503
|
-
*
|
|
3482
|
+
* Render-class pipeline state. A group's compiled graph swaps the fragment shading; the
|
|
3483
|
+
* render-class owns pass integration (stencil interplay, depth bias, cull). auto = plain;
|
|
3484
|
+
* eye = stamp + front cull + bias; hair = stencil-test (+ the over-eyes variant).
|
|
3504
3485
|
*/
|
|
3505
|
-
|
|
3486
|
+
createRenderClassPipeline(renderClass, module, overEyes) {
|
|
3506
3487
|
const base = {
|
|
3507
|
-
label: `style ${
|
|
3488
|
+
label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
|
|
3508
3489
|
layout: this.mainPipelineLayout,
|
|
3509
3490
|
vertex: { module, buffers: this.fullVertexBufferLayouts },
|
|
3510
|
-
primitive: { cullMode: (
|
|
3491
|
+
primitive: { cullMode: (renderClass === "eye" ? "front" : "none") },
|
|
3511
3492
|
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
3512
3493
|
};
|
|
3513
3494
|
const plainDepth = {
|
|
@@ -3517,7 +3498,7 @@ export class Engine {
|
|
|
3517
3498
|
};
|
|
3518
3499
|
let depthStencil = plainDepth;
|
|
3519
3500
|
let constants;
|
|
3520
|
-
if (
|
|
3501
|
+
if (renderClass === "hair" && !overEyes) {
|
|
3521
3502
|
depthStencil = {
|
|
3522
3503
|
...plainDepth,
|
|
3523
3504
|
stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
|
|
@@ -3526,7 +3507,7 @@ export class Engine {
|
|
|
3526
3507
|
stencilWriteMask: 0,
|
|
3527
3508
|
};
|
|
3528
3509
|
}
|
|
3529
|
-
else if (
|
|
3510
|
+
else if (renderClass === "hair" && overEyes) {
|
|
3530
3511
|
constants = { IS_OVER_EYES: 1 };
|
|
3531
3512
|
depthStencil = {
|
|
3532
3513
|
format: "depth24plus-stencil8",
|
|
@@ -3538,7 +3519,7 @@ export class Engine {
|
|
|
3538
3519
|
stencilWriteMask: 0,
|
|
3539
3520
|
};
|
|
3540
3521
|
}
|
|
3541
|
-
else if (
|
|
3522
|
+
else if (renderClass === "eye") {
|
|
3542
3523
|
depthStencil = {
|
|
3543
3524
|
...plainDepth,
|
|
3544
3525
|
depthBias: -0.00005,
|
|
@@ -3556,31 +3537,15 @@ export class Engine {
|
|
|
3556
3537
|
depthStencil,
|
|
3557
3538
|
});
|
|
3558
3539
|
}
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
return this.hairPipeline;
|
|
3569
|
-
if (preset === "cloth_smooth")
|
|
3570
|
-
return this.clothSmoothPipeline;
|
|
3571
|
-
if (preset === "cloth_rough")
|
|
3572
|
-
return this.clothRoughPipeline;
|
|
3573
|
-
if (preset === "metal")
|
|
3574
|
-
return this.metalPipeline;
|
|
3575
|
-
if (preset === "body")
|
|
3576
|
-
return this.bodyPipeline;
|
|
3577
|
-
if (preset === "eye")
|
|
3578
|
-
return this.eyePipeline;
|
|
3579
|
-
if (preset === "stockings")
|
|
3580
|
-
return this.stockingsPipeline;
|
|
3581
|
-
if (preset === "mmd_classic")
|
|
3582
|
-
return this.mmdClassicPipeline;
|
|
3583
|
-
return this.modelPipeline;
|
|
3540
|
+
// Pipeline for a material draw call: its group's compiled pipeline when grouped, else
|
|
3541
|
+
// the neutral base (ungrouped materials render the default graph).
|
|
3542
|
+
pipelineForDrawCall(inst, dc) {
|
|
3543
|
+
if (dc.groupId) {
|
|
3544
|
+
const install = inst.styleGroups.get(dc.groupId);
|
|
3545
|
+
if (install)
|
|
3546
|
+
return install.pipeline;
|
|
3547
|
+
}
|
|
3548
|
+
return this.neutralPipeline;
|
|
3584
3549
|
}
|
|
3585
3550
|
/**
|
|
3586
3551
|
* Draw every material of a given type (`opaque` or `transparent`) using the main
|
|
@@ -3599,7 +3564,7 @@ export class Engine {
|
|
|
3599
3564
|
pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
|
|
3600
3565
|
bound = true;
|
|
3601
3566
|
}
|
|
3602
|
-
const pipeline = this.
|
|
3567
|
+
const pipeline = this.pipelineForDrawCall(inst, draw);
|
|
3603
3568
|
if (pipeline !== currentPipeline) {
|
|
3604
3569
|
pass.setPipeline(pipeline);
|
|
3605
3570
|
currentPipeline = pipeline;
|
|
@@ -3650,26 +3615,41 @@ export class Engine {
|
|
|
3650
3615
|
this.drawOutlines(pass, inst, "transparent-outline");
|
|
3651
3616
|
}
|
|
3652
3617
|
/**
|
|
3653
|
-
* Second hair pass for the see-through-hair effect. Re-draws every hair
|
|
3654
|
-
* draw
|
|
3655
|
-
*
|
|
3656
|
-
*
|
|
3618
|
+
* Second hair pass for the see-through-hair effect. Re-draws every hair-class grouped
|
|
3619
|
+
* opaque draw with its compiled over-eyes pipeline — stencil-matched to `EYE_VALUE`,
|
|
3620
|
+
* `IS_OVER_EYES=true` (25% alpha), depth-write off. Ungrouped materials are neutral and
|
|
3621
|
+
* never participate.
|
|
3657
3622
|
*/
|
|
3658
3623
|
drawHairOverEyes(pass, inst) {
|
|
3659
3624
|
let bound = false;
|
|
3625
|
+
let currentPipeline = null;
|
|
3660
3626
|
for (const draw of inst.drawCalls) {
|
|
3661
|
-
if (draw.type !== "opaque" ||
|
|
3627
|
+
if (draw.type !== "opaque" || !this.shouldRenderDrawCall(inst, draw))
|
|
3628
|
+
continue;
|
|
3629
|
+
const overEyes = this.overEyesPipelineFor(inst, draw);
|
|
3630
|
+
if (!overEyes)
|
|
3662
3631
|
continue;
|
|
3663
3632
|
if (!bound) {
|
|
3664
|
-
pass.setPipeline(this.styleOverrides.get("hair")?.overEyesPipeline ?? this.hairOverEyesPipeline);
|
|
3665
3633
|
pass.setBindGroup(0, this.perFrameBindGroup);
|
|
3666
3634
|
pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
|
|
3667
3635
|
bound = true;
|
|
3668
3636
|
}
|
|
3637
|
+
if (overEyes !== currentPipeline) {
|
|
3638
|
+
pass.setPipeline(overEyes);
|
|
3639
|
+
currentPipeline = overEyes;
|
|
3640
|
+
}
|
|
3669
3641
|
pass.setBindGroup(2, draw.bindGroup);
|
|
3670
3642
|
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
3671
3643
|
}
|
|
3672
3644
|
}
|
|
3645
|
+
// The over-eyes pipeline for a hair-class grouped draw call, or null. Ungrouped
|
|
3646
|
+
// materials are neutral — no see-through pass.
|
|
3647
|
+
overEyesPipelineFor(inst, dc) {
|
|
3648
|
+
if (!dc.groupId)
|
|
3649
|
+
return null;
|
|
3650
|
+
const install = inst.styleGroups.get(dc.groupId);
|
|
3651
|
+
return install?.renderClass === "hair" ? (install.overEyesPipeline ?? null) : null;
|
|
3652
|
+
}
|
|
3673
3653
|
updateCameraUniforms() {
|
|
3674
3654
|
const viewMatrix = this.camera.getViewMatrix();
|
|
3675
3655
|
const projectionMatrix = this.camera.getProjectionMatrix();
|