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.
Files changed (55) hide show
  1. package/README.md +291 -252
  2. package/dist/engine.d.ts +45 -41
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +401 -421
  5. package/dist/graph/compile.d.ts +6 -0
  6. package/dist/graph/compile.d.ts.map +1 -1
  7. package/dist/graph/compile.js +1 -1
  8. package/dist/graph/presets/body.js +1 -1
  9. package/dist/graph/presets/cloth_rough.js +1 -1
  10. package/dist/graph/presets/cloth_smooth.js +1 -1
  11. package/dist/graph/presets/default.d.ts.map +1 -1
  12. package/dist/graph/presets/default.js +14 -7
  13. package/dist/graph/presets/eye.js +1 -1
  14. package/dist/graph/presets/face.js +1 -1
  15. package/dist/graph/presets/hair.js +1 -1
  16. package/dist/graph/presets/metal.js +1 -1
  17. package/dist/graph/presets/stockings.js +1 -1
  18. package/dist/graph/registry.d.ts.map +1 -1
  19. package/dist/graph/registry.js +8 -0
  20. package/dist/graph/render-class.d.ts +16 -0
  21. package/dist/graph/render-class.d.ts.map +1 -0
  22. package/dist/graph/render-class.js +15 -0
  23. package/dist/graph/schema.d.ts +4 -5
  24. package/dist/graph/schema.d.ts.map +1 -1
  25. package/dist/graph/slots.d.ts +7 -12
  26. package/dist/graph/slots.d.ts.map +1 -1
  27. package/dist/graph/slots.js +68 -100
  28. package/dist/graph/style-group.d.ts +38 -0
  29. package/dist/graph/style-group.d.ts.map +1 -0
  30. package/dist/graph/style-group.js +6 -0
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -0
  34. package/dist/tga-loader.d.ts +7 -0
  35. package/dist/tga-loader.d.ts.map +1 -0
  36. package/dist/tga-loader.js +154 -0
  37. package/package.json +2 -2
  38. package/src/engine.ts +4488 -4479
  39. package/src/graph/compile.ts +7 -1
  40. package/src/graph/presets/body.ts +1 -1
  41. package/src/graph/presets/cloth_rough.ts +1 -1
  42. package/src/graph/presets/cloth_smooth.ts +1 -1
  43. package/src/graph/presets/default.ts +14 -7
  44. package/src/graph/presets/eye.ts +1 -1
  45. package/src/graph/presets/face.ts +1 -1
  46. package/src/graph/presets/hair.ts +1 -1
  47. package/src/graph/presets/metal.ts +1 -1
  48. package/src/graph/presets/stockings.ts +1 -1
  49. package/src/graph/registry.ts +8 -0
  50. package/src/graph/render-class.ts +33 -0
  51. package/src/graph/schema.ts +4 -6
  52. package/src/graph/slots.ts +76 -113
  53. package/src/graph/style-group.ts +39 -0
  54. package/src/index.ts +7 -1
  55. 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 feed mmd_classic; other presets bind fallbacks.
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
- // Per-slot style buffers + shared zero buffer (mmd_classic / never-styled slots).
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.modelPipeline = this.createRenderPipeline({
978
- label: "model pipeline",
979
- layout: mainPipelineLayout,
980
- shaderModule,
981
- vertexBuffers: fullVertexBuffers,
982
- fragmentTargets: sceneTargets,
983
- cullMode: "none",
984
- depthStencil: {
985
- format: "depth24plus-stencil8",
986
- depthWriteEnabled: true,
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: stockingsShaderModule,
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
- preset: "cloth_rough",
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 graph runtime: per-slot uniform buffers + swapped pipelines (pipelines
2193
- // are GC'd; buffers need explicit destroy).
2194
- this.styleOverrides.clear();
2195
- for (const buf of this.styleBuffers.values())
2196
- buf.destroy();
2197
- this.styleBuffers.clear();
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
- setMaterialPresets(modelName, presets) {
2290
- const inst = this.modelInstances.get(modelName);
2291
- if (!inst)
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
- const bindGroup = this.createMaterialBindGroup(`${prefix}material: ${mat.name}`, baseBindGroupEntries, preset);
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
- preset,
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
- preset,
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
- // see-through-hair effect requires: {non-hair, non-eye} → {eye} → {hair}.
2814
- // Eye writes stencil=EYE_VALUE; hair's pipeline stencil-tests "not equal" so
2815
- // it skips eye pixels; a follow-up hairOverEyes pass (see renderOneModel)
2816
- // re-fills those skipped pixels alpha-blended. Array.sort is stable in
2817
- // ES2019+, so within a bucket the PMX material order is preserved.
2818
- const typeOrder = {
2819
- opaque: 0,
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
- const buffer = await inst.assetReader.readBinary(logicalPath);
2876
- const imageBitmap = await createImageBitmap(new Blob([buffer]), {
2877
- premultiplyAlpha: "none",
2878
- colorSpaceConversion: "none",
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 graph API ──────────────────────────────────────────────
3405
- // Two-tier edit model: applyStyleGraph = topology tier (async compile + pipeline
3406
- // swap, previous look keeps rendering until the new pipeline is ready or on any
3407
- // error); setStyleParam = adjust tier (uniform write, instant, no pipeline touch).
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
- * Compile a style graph and swap it onto its preset slot. Fallback-on-error: the
3410
- * slot's current pipeline keeps rendering unless compilation fully succeeds.
3411
- * Slider defaults are written to the slot's StyleUniforms buffer on success.
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 applyStyleGraph(graph, opts) {
3414
- const result = compileGraph(graph, opts);
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 slot = graph.slot;
3418
- const generation = (this.styleGenerations.get(slot) ?? 0) + 1;
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 graph: ${graph.name} (${slot})`, code: result.wgsl });
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.createSlotPipeline(slot, module, false);
3443
- if (slot === "hair")
3444
- overEyesPipeline = await this.createSlotPipeline(slot, module, true);
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
- // A newer apply/reset on this slot finished (or started) while we compiled.
3451
- if (this.styleGenerations.get(slot) !== generation) {
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
- this.styleOverrides.set(slot, { pipeline, overEyesPipeline, slotMap: result.slotMap });
3456
- this.writeStyleDefaults(slot, graph, result.slotMap);
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
- /** Instant adjust-tier write: set one exposed slider on the slot's applied graph. */
3460
- setStyleParam(slot, paramId, value) {
3461
- const override = this.styleOverrides.get(slot);
3462
- const styleSlot = override?.slotMap.find((s) => s.id === paramId);
3463
- const buffer = this.styleBuffers.get(slot);
3464
- if (!styleSlot || !buffer)
3465
- return false;
3466
- if (styleSlot.kind === "float") {
3467
- if (typeof value !== "number")
3468
- return false;
3469
- const offset = styleSlot.vec4Index * 16 + ["x", "y", "z", "w"].indexOf(styleSlot.component) * 4;
3470
- this.device.queue.writeBuffer(buffer, offset, new Float32Array([value]));
3471
- }
3472
- else {
3473
- if (typeof value === "number")
3474
- return false;
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
- return true;
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
- writeStyleDefaults(slot, graph, slotMap) {
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(this.styleBuffers.get(slot), 0, data);
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
- * Slot pipeline states mirror createPipelines exactly — a compiled graph swaps the
3502
- * fragment shading of a slot, never its pass integration (stencil interplay, depth
3503
- * bias, cull mode stay slot-owned; see docs/graph-compiler-spec.md §6).
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
- createSlotPipeline(slot, module, overEyes) {
3486
+ createRenderClassPipeline(renderClass, module, overEyes) {
3506
3487
  const base = {
3507
- label: `style ${slot}${overEyes ? " (over eyes)" : ""}`,
3488
+ label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
3508
3489
  layout: this.mainPipelineLayout,
3509
3490
  vertex: { module, buffers: this.fullVertexBufferLayouts },
3510
- primitive: { cullMode: (slot === "eye" ? "front" : "none") },
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 (slot === "hair" && !overEyes) {
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 (slot === "hair" && overEyes) {
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 (slot === "eye") {
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
- 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
- if (preset === "face")
3566
- return this.facePipeline;
3567
- if (preset === "hair")
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.pipelineForPreset(draw.preset);
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 opaque
3654
- * draw using `hairOverEyesPipeline`which stencil-matches `EYE_VALUE` and runs
3655
- * the hair shader with `IS_OVER_EYES=true` so alpha is scaled to 25%. depthWriteEnabled
3656
- * is off, so the eye's depth stays authoritative for anything drawn after.
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" || draw.preset !== "hair" || !this.shouldRenderDrawCall(inst, draw))
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();