reze-engine 0.24.1 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +5 -1
  2. package/dist/engine.d.ts +127 -1
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +422 -34
  5. package/dist/gpu-profile.d.ts +19 -0
  6. package/dist/gpu-profile.d.ts.map +1 -0
  7. package/dist/gpu-profile.js +120 -0
  8. package/dist/graph/slots.d.ts.map +1 -1
  9. package/dist/graph/slots.js +10 -2
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/physics/profile.d.ts +18 -0
  13. package/dist/physics/profile.d.ts.map +1 -0
  14. package/dist/physics/profile.js +44 -0
  15. package/dist/shaders/materials/common.d.ts +1 -1
  16. package/dist/shaders/materials/common.d.ts.map +1 -1
  17. package/dist/shaders/materials/common.js +143 -141
  18. package/dist/shaders/passes/composite.d.ts +23 -1
  19. package/dist/shaders/passes/composite.d.ts.map +1 -1
  20. package/dist/shaders/passes/composite.js +62 -16
  21. package/dist/shaders/passes/depth-prepass.d.ts +2 -0
  22. package/dist/shaders/passes/depth-prepass.d.ts.map +1 -0
  23. package/dist/shaders/passes/depth-prepass.js +56 -0
  24. package/dist/shaders/passes/ground.d.ts +1 -1
  25. package/dist/shaders/passes/ground.d.ts.map +1 -1
  26. package/dist/shaders/passes/ground.js +8 -0
  27. package/package.json +1 -1
  28. package/src/engine.ts +459 -34
  29. package/src/graph/slots.ts +11 -2
  30. package/src/index.ts +2 -0
  31. package/src/shaders/materials/common.ts +207 -205
  32. package/src/shaders/passes/composite.ts +89 -16
  33. package/src/shaders/passes/depth-prepass.ts +57 -0
  34. package/src/shaders/passes/ground.ts +8 -0
  35. package/dist/physics-debug.d.ts +0 -30
  36. package/dist/physics-debug.d.ts.map +0 -1
  37. package/dist/physics-debug.js +0 -526
  38. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  39. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  40. package/dist/shaders/passes/physics-debug.js +0 -69
package/dist/engine.js CHANGED
@@ -12,10 +12,11 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
12
12
  import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow";
13
13
  import { GROUND_SHADOW_SHADER_WGSL } from "./shaders/passes/ground";
14
14
  import { OUTLINE_SHADER_WGSL } from "./shaders/passes/outline";
15
+ import { TRANSPARENT_DEPTH_PREPASS_WGSL } from "./shaders/passes/depth-prepass";
15
16
  import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection";
16
17
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
17
18
  import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
18
- import { COMPOSITE_SHADER_WGSL } from "./shaders/passes/composite";
19
+ import { buildCompositeShader } from "./shaders/passes/composite";
19
20
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
20
21
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
21
22
  import { compileGraph } from "./graph/compile";
@@ -147,6 +148,91 @@ export const DEFAULT_ENGINE_OPTIONS = {
147
148
  camera: { distance: 26.6, target: new Vec3(0, 12.5, 0), fov: Math.PI / 4 },
148
149
  onRaycast: undefined,
149
150
  };
151
+ // ── Sheer-material detection ──────────────────────────────────────────────────
152
+ // PMX carries no "translucent" flag: a see-through veil usually has diffuse
153
+ // alpha 1.0 and does its transparency entirely in the TEXTURE's alpha channel.
154
+ // Classifying by material alpha alone put such cloth in the opaque bucket,
155
+ // where it draws in PMX order with depth writes — anything the engine draws
156
+ // after it (the hair render-class draws LAST for the eye-stencil effect) got
157
+ // depth-rejected behind the veil, so you saw the body through it but not the
158
+ // hair. These helpers measure a material's real coverage by sampling the
159
+ // texture's alpha at the material's own triangle CENTROIDS — centroids, not
160
+ // vertices, because hair-card corners sit in transparent texture margins and
161
+ // vertex sampling would misclassify hair (which must stay opaque-bucket for
162
+ // stencil interplay and shadows).
163
+ /** Downsampled alpha plane of a decoded texture (≤128², nearest-sampled). */
164
+ function buildAlphaSampler(source, rgba, width, height) {
165
+ try {
166
+ const w = Math.max(1, Math.min(128, width));
167
+ const h = Math.max(1, Math.min(128, height));
168
+ const canvas = new OffscreenCanvas(w, h);
169
+ const cx = canvas.getContext("2d", { willReadFrequently: true });
170
+ if (!cx)
171
+ return null;
172
+ if (source) {
173
+ cx.drawImage(source, 0, 0, w, h);
174
+ }
175
+ else if (rgba) {
176
+ const tmp = new OffscreenCanvas(width, height);
177
+ const tcx = tmp.getContext("2d");
178
+ if (!tcx)
179
+ return null;
180
+ tcx.putImageData(new ImageData(new Uint8ClampedArray(rgba), width, height), 0, 0);
181
+ cx.drawImage(tmp, 0, 0, w, h);
182
+ }
183
+ else {
184
+ return null;
185
+ }
186
+ const img = cx.getImageData(0, 0, w, h).data;
187
+ const a = new Uint8ClampedArray(w * h);
188
+ for (let i = 0; i < w * h; i++)
189
+ a[i] = img[i * 4 + 3];
190
+ return { a, w, h };
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ }
196
+ /** Texture-alpha statistics over ≤400 of the material's triangle centroids:
197
+ * `avg` (0..1) and `translucentFrac` — the fraction of samples that are
198
+ * neither fully opaque nor fully cut out (alpha in ~0.03..0.97). Together
199
+ * they classify two distinct things:
200
+ * sheer (avg low) — a veil: mostly see-through everywhere
201
+ * partial (translucentFrac up) — mostly-opaque cloth with sheer REGIONS,
202
+ * e.g. a lace skirt panel
203
+ * Both route to the transparent bucket; only `sheer` is excluded from the
204
+ * shadow map and outline pass. */
205
+ const SHEER_ALPHA_THRESHOLD = 0.7;
206
+ const PARTIAL_TRANSLUCENT_FRAC = 0.15;
207
+ function materialAlphaStats(verts, indices, firstIndex, count, sampler) {
208
+ if (!sampler)
209
+ return { avg: 1, translucentFrac: 0 };
210
+ const triCount = Math.floor(count / 3);
211
+ if (triCount === 0)
212
+ return { avg: 1, translucentFrac: 0 };
213
+ const step = Math.max(1, Math.floor(triCount / 400));
214
+ let sum = 0;
215
+ let translucent = 0;
216
+ let n = 0;
217
+ for (let t = 0; t < triCount; t += step) {
218
+ const i0 = indices[firstIndex + t * 3];
219
+ const i1 = indices[firstIndex + t * 3 + 1];
220
+ const i2 = indices[firstIndex + t * 3 + 2];
221
+ const u = (verts[i0 * 8 + 6] + verts[i1 * 8 + 6] + verts[i2 * 8 + 6]) / 3;
222
+ const v = (verts[i0 * 8 + 7] + verts[i1 * 8 + 7] + verts[i2 * 8 + 7]) / 3;
223
+ // Wrap (MMD UVs may tile), then nearest-sample the downsampled plane.
224
+ const x = Math.min(sampler.w - 1, Math.max(0, Math.floor((u - Math.floor(u)) * sampler.w)));
225
+ const y = Math.min(sampler.h - 1, Math.max(0, Math.floor((v - Math.floor(v)) * sampler.h)));
226
+ const a = sampler.a[y * sampler.w + x];
227
+ sum += a;
228
+ if (a > 8 && a < 247)
229
+ translucent++;
230
+ n++;
231
+ }
232
+ if (n === 0)
233
+ return { avg: 1, translucentFrac: 0 };
234
+ return { avg: sum / n / 255, translucentFrac: translucent / n };
235
+ }
150
236
  export class Engine {
151
237
  static getInstance() {
152
238
  if (!Engine.instance) {
@@ -188,12 +274,19 @@ export class Engine {
188
274
  // unchanged).
189
275
  this.hdrFormat = "rgba16float";
190
276
  // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
191
- this.compositeUniformData = new Float32Array(24);
277
+ this.compositeUniformData = new Float32Array(28);
192
278
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
193
279
  this.backgroundColor = null;
194
280
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
195
281
  this.backdropEquirectTexture = null;
196
282
  this.backdropEquirectView = null;
283
+ // User WGSL background effect (background mode 3, setBackgroundEffect). The
284
+ // composite pipelines are REBUILT with the user code injected; params live in
285
+ // their own uniform buffer so setBackgroundEffectParam is a write, not a
286
+ // recompile (the same instant tier as setStyleParam).
287
+ this.backgroundEffect = null;
288
+ /** time=0 origin for the active effect — reset each setBackgroundEffect. */
289
+ this.bgEffectEpochMs = 0;
197
290
  this.compositeBloomView = null;
198
291
  this.bloomBlitUniformData = new Float32Array(4);
199
292
  this.bloomUpsampleUniformData = new Float32Array(4);
@@ -210,6 +303,11 @@ export class Engine {
210
303
  this.pendingPick = null;
211
304
  this.modelInstances = new Map();
212
305
  this.textureCache = new Map();
306
+ // Downsampled CPU alpha channel per texture (≤128², ~16KB) — kept so materials
307
+ // can be classified as SHEER at load by sampling alpha at their own UVs (the
308
+ // GPU texture can't be read back cheaply, and PMX diffuse alpha is usually 1.0
309
+ // even for see-through cloth: the translucency lives in the texture).
310
+ this.textureAlphaCache = new Map();
213
311
  this.mipBlitPipeline = null;
214
312
  this.mipBlitSampler = null;
215
313
  this._nextDefaultModelId = 0;
@@ -239,6 +337,8 @@ export class Engine {
239
337
  };
240
338
  this.animationFrameId = null;
241
339
  this.renderLoopCallback = null;
340
+ /** Debug/diagnostic: skip every inverted-hull outline draw. */
341
+ this.outlineEnabled = true;
242
342
  /** When set, render resolution is pinned to this size instead of tracking the
243
343
  * canvas's CSS size × devicePixelRatio (see setRenderSize). */
244
344
  this.fixedRenderSize = null;
@@ -537,7 +637,12 @@ export class Engine {
537
637
  u[8] = bg?.x ?? 0;
538
638
  u[9] = bg?.y ?? 0;
539
639
  u[10] = bg?.z ?? 0;
640
+ // Base-layer mode; a user effect is a separate LAYER flagged at u[25] and
641
+ // over-composited onto whichever base is active.
540
642
  u[11] = this.backdropEquirectView ? 2 : bg ? 1 : 0;
643
+ u[25] = this.backgroundEffect ? 1 : 0;
644
+ u[26] = this.canvas.width;
645
+ u[27] = this.canvas.height;
541
646
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
542
647
  }
543
648
  /**
@@ -552,6 +657,9 @@ export class Engine {
552
657
  if (this.device && this.compositeUniformBuffer)
553
658
  this.writeCompositeViewUniforms();
554
659
  }
660
+ setOutlineEnabled(on) {
661
+ this.outlineEnabled = on;
662
+ }
555
663
  rebuildCompositeBindGroup() {
556
664
  if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView)
557
665
  return;
@@ -566,6 +674,7 @@ export class Engine {
566
674
  { binding: 4, resource: this.maskResolveView },
567
675
  { binding: 5, resource: this.filmicLutView },
568
676
  { binding: 6, resource: this.backdropEquirectView ?? this.fallbackEquirectView },
677
+ { binding: 7, resource: { buffer: this.backgroundEffect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
569
678
  ],
570
679
  });
571
680
  }
@@ -613,6 +722,165 @@ export class Engine {
613
722
  if (this.device && this.compositeUniformBuffer)
614
723
  this.writeCompositeViewUniforms();
615
724
  }
725
+ makeCompositePipeline(module, applyGamma, label) {
726
+ return this.device.createRenderPipeline({
727
+ label,
728
+ layout: this.compositePipelineLayout,
729
+ vertex: { module, entryPoint: "vs" },
730
+ fragment: {
731
+ module,
732
+ entryPoint: "fs",
733
+ constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
734
+ targets: [{ format: this.presentationFormat }],
735
+ },
736
+ primitive: { topology: "triangle-list" },
737
+ });
738
+ }
739
+ /**
740
+ * Install a WGSL background effect (shadertoy-style) as a LAYER between the
741
+ * base background and the scene: rendered per-pixel in the composite pass and
742
+ * over-composited onto whichever base is active (solid color, 360 equirect,
743
+ * or transparency) — its alpha lets the base show through, so a starfield is
744
+ * stars over the user's background color. Display-space: never affects
745
+ * lighting, bloom, or tonemapping, and is captured by offline export like any
746
+ * background.
747
+ *
748
+ * `wgsl` must define:
749
+ *
750
+ * fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
751
+ *
752
+ * where `ray` is the pixel's normalized world-space view direction (LH, +Z
753
+ * forward — what the skybox samples by), `uv` is 0..1 bottom-left origin,
754
+ * `time` is seconds since apply, and `bgResolution()` gives the canvas size.
755
+ * Return sRGB + alpha. Declared `params` arrive as `params.<name>` (number →
756
+ * f32, Vec3 → vec3f) and are later tweaked without recompiling via
757
+ * setBackgroundEffectParam.
758
+ *
759
+ * Compiles off the hot path (async pipelines): on failure the previous
760
+ * background is KEPT and diagnostics are returned with line numbers relative
761
+ * to the user's WGSL. Pass null to remove the effect.
762
+ */
763
+ async setBackgroundEffect(wgsl, params) {
764
+ if (!this.device)
765
+ return { ok: false, diagnostics: ["setBackgroundEffect requires init() to have run"] };
766
+ if (wgsl === null) {
767
+ this.backgroundEffect?.paramsBuffer?.destroy();
768
+ this.backgroundEffect = null;
769
+ const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) });
770
+ this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)");
771
+ this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)");
772
+ this.rebuildCompositeBindGroup();
773
+ this.writeCompositeViewUniforms();
774
+ return { ok: true, diagnostics: [] };
775
+ }
776
+ // ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
777
+ // Fields are emitted in declaration order; offsets follow WGSL's natural
778
+ // uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
779
+ // on both sides so no reordering is needed.
780
+ const entries = Object.entries(params ?? {});
781
+ const layout = new Map();
782
+ const fields = [];
783
+ let cursor = 0;
784
+ for (const [name, value] of entries) {
785
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
786
+ return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`] };
787
+ }
788
+ const isVec = typeof value !== "number";
789
+ const align = isVec ? 16 : 4;
790
+ const offset = Math.ceil(cursor / align) * align;
791
+ layout.set(name, { offset: offset / 4, comps: isVec ? 3 : 1 });
792
+ fields.push(` ${name}: ${isVec ? "vec3f" : "f32"},`);
793
+ cursor = offset + (isVec ? 12 : 4);
794
+ }
795
+ const paramsData = new Float32Array(Math.max(4, Math.ceil(cursor / 16) * 4));
796
+ for (const [name, value] of entries) {
797
+ const slot = layout.get(name);
798
+ if (typeof value === "number")
799
+ paramsData[slot.offset] = value;
800
+ else {
801
+ paramsData[slot.offset] = value.x;
802
+ paramsData[slot.offset + 1] = value.y;
803
+ paramsData[slot.offset + 2] = value.z;
804
+ }
805
+ }
806
+ const paramsDecl = entries.length
807
+ ? `struct BgParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: BgParams;\n`
808
+ : "";
809
+ // ── Compile with validation captured, not thrown at the console. Line
810
+ // numbers in diagnostics are rebased to the USER's source.
811
+ const source = buildCompositeShader({ wgsl, paramsDecl });
812
+ const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1;
813
+ this.device.pushErrorScope("validation");
814
+ const module = this.device.createShaderModule({ label: "composite shader (bg effect)", code: source });
815
+ const info = await module.getCompilationInfo();
816
+ const scopeErr = await this.device.popErrorScope();
817
+ const diagnostics = info.messages
818
+ .filter((m) => m.type === "error")
819
+ .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`);
820
+ if (diagnostics.length === 0 && scopeErr)
821
+ diagnostics.push(scopeErr.message);
822
+ if (diagnostics.length > 0)
823
+ return { ok: false, diagnostics };
824
+ let identity;
825
+ let gamma;
826
+ try {
827
+ const make = (applyGamma, label) => this.device.createRenderPipelineAsync({
828
+ label,
829
+ layout: this.compositePipelineLayout,
830
+ vertex: { module, entryPoint: "vs" },
831
+ fragment: {
832
+ module,
833
+ entryPoint: "fs",
834
+ constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
835
+ targets: [{ format: this.presentationFormat }],
836
+ },
837
+ primitive: { topology: "triangle-list" },
838
+ });
839
+ [identity, gamma] = await Promise.all([
840
+ make(false, "composite pipeline (bg effect, gamma=1)"),
841
+ make(true, "composite pipeline (bg effect, gamma!=1)"),
842
+ ]);
843
+ }
844
+ catch (e) {
845
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] };
846
+ }
847
+ // ── Swap — only now does the old effect (and its params buffer) go away.
848
+ this.backgroundEffect?.paramsBuffer?.destroy();
849
+ let paramsBuffer = null;
850
+ if (entries.length) {
851
+ paramsBuffer = this.device.createBuffer({
852
+ label: "bg effect params",
853
+ size: paramsData.byteLength,
854
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
855
+ });
856
+ this.device.queue.writeBuffer(paramsBuffer, 0, paramsData);
857
+ }
858
+ this.backgroundEffect = { wgsl, paramLayout: layout, paramsBuffer, paramsData };
859
+ this.compositePipelineIdentity = identity;
860
+ this.compositePipelineGamma = gamma;
861
+ this.bgEffectEpochMs = performance.now();
862
+ this.rebuildCompositeBindGroup();
863
+ this.writeCompositeViewUniforms();
864
+ return { ok: true, diagnostics: [] };
865
+ }
866
+ /** Write one background-effect param (declared at setBackgroundEffect) — a
867
+ * uniform write, no recompile; the instant tier, like setStyleParam. */
868
+ setBackgroundEffectParam(name, value) {
869
+ const fx = this.backgroundEffect;
870
+ if (!fx || !fx.paramsBuffer)
871
+ return;
872
+ const slot = fx.paramLayout.get(name);
873
+ if (!slot)
874
+ return;
875
+ if (typeof value === "number")
876
+ fx.paramsData[slot.offset] = value;
877
+ else {
878
+ fx.paramsData[slot.offset] = value.x;
879
+ fx.paramsData[slot.offset + 1] = value.y;
880
+ fx.paramsData[slot.offset + 2] = value.z;
881
+ }
882
+ this.device.queue.writeBuffer(fx.paramsBuffer, 0, fx.paramsData);
883
+ }
616
884
  /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
617
885
  setBloomOptions(patch) {
618
886
  const b = this.bloomSettings;
@@ -1052,6 +1320,44 @@ export class Engine {
1052
1320
  depthCompare: "less-equal",
1053
1321
  },
1054
1322
  });
1323
+ // Depth-write-off twin for transparent-bucket draws (see pipelineForDrawCall).
1324
+ this.neutralPipelineNoDepthWrite = this.createRenderPipeline({
1325
+ label: "neutral base pipeline (no depth write)",
1326
+ layout: mainPipelineLayout,
1327
+ shaderModule: neutralModule,
1328
+ vertexBuffers: fullVertexBuffers,
1329
+ fragmentTargets: sceneTargets,
1330
+ cullMode: "none",
1331
+ depthStencil: {
1332
+ format: "depth24plus-stencil8",
1333
+ depthWriteEnabled: false,
1334
+ depthCompare: "less-equal",
1335
+ },
1336
+ });
1337
+ // Depth-only prepass for transparent draws (see depth-prepass.ts): writes the
1338
+ // fabric's depth AFTER its color blended, so outlines drawn later are
1339
+ // occluded behind it. Color targets kept for pass compatibility, writeMask 0.
1340
+ const prepassModule = this.device.createShaderModule({
1341
+ label: "transparent depth prepass",
1342
+ code: TRANSPARENT_DEPTH_PREPASS_WGSL,
1343
+ });
1344
+ this.transparentDepthPrepassPipeline = this.device.createRenderPipeline({
1345
+ label: "transparent depth prepass",
1346
+ layout: mainPipelineLayout,
1347
+ vertex: { module: prepassModule, entryPoint: "vs", buffers: fullVertexBuffers },
1348
+ fragment: {
1349
+ module: prepassModule,
1350
+ entryPoint: "fs",
1351
+ targets: sceneTargets.map((t) => ({ format: t.format, writeMask: 0 })),
1352
+ },
1353
+ primitive: { cullMode: "none" },
1354
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
1355
+ depthStencil: {
1356
+ format: "depth24plus-stencil8",
1357
+ depthWriteEnabled: true,
1358
+ depthCompare: "less-equal",
1359
+ },
1360
+ });
1055
1361
  this.shadowLightVPBuffer = this.device.createBuffer({
1056
1362
  size: 64,
1057
1363
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -1180,6 +1486,16 @@ export class Engine {
1180
1486
  // Don’t write outline into depth buffer — stops z-fighting / black cracks vs body (MMD-style; body depth stays authoritative)
1181
1487
  depthWriteEnabled: false,
1182
1488
  depthCompare: "less-equal",
1489
+ // CONFIRMED fix (bisected live via setOutlineEnabled): hull fragments
1490
+ // carry their surface's exact depth, so against this model's paired
1491
+ // near-coplanar skirt layers the hulls WON depth ties in patches —
1492
+ // the black shapes on the dress. A small constant bias makes hulls lose
1493
+ // every tie; silhouette rims compare against the far background and are
1494
+ // unaffected. No slope term — slope explodes at silhouettes and would
1495
+ // erase the rims themselves (previous regression).
1496
+ depthBias: 4,
1497
+ depthBiasSlopeScale: 0,
1498
+ depthBiasClamp: 0,
1183
1499
  // Skip fragments where the eye stamped stencil=EYE_VALUE. Those pixels are owned by
1184
1500
  // the see-through-hair blend (hair-over-eyes), so letting the outline's near-black
1185
1501
  // edge color overwrite them would re-introduce the dark almond we just killed.
@@ -1350,9 +1666,15 @@ export class Engine {
1350
1666
  // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
1351
1667
  this.compositeUniformBuffer = this.device.createBuffer({
1352
1668
  label: "composite view uniforms",
1353
- // 6 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1354
- // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray.
1355
- size: 96,
1669
+ // 7 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1670
+ // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
1671
+ // (time, _, canvas width, canvas height) for user background effects.
1672
+ size: 112,
1673
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1674
+ });
1675
+ this.bgParamsDummyBuffer = this.device.createBuffer({
1676
+ label: "bg effect params (dummy)",
1677
+ size: 16,
1356
1678
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1357
1679
  });
1358
1680
  this.compositeBindGroupLayout = this.device.createBindGroupLayout({
@@ -1369,6 +1691,9 @@ export class Engine {
1369
1691
  { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1370
1692
  // 360 backdrop equirect (PhotoDome-style skybox) — 1×1 fallback when unset.
1371
1693
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1694
+ // User background-effect params — dummy buffer when no effect is set. The
1695
+ // layout is explicit, so the base shader legally ignores the binding.
1696
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1372
1697
  ],
1373
1698
  });
1374
1699
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -1378,27 +1703,15 @@ export class Engine {
1378
1703
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
1379
1704
  });
1380
1705
  this.fallbackEquirectView = this.fallbackEquirectTexture.createView();
1381
- const compositeShader = this.device.createShaderModule({
1382
- label: "composite shader",
1383
- code: COMPOSITE_SHADER_WGSL,
1384
- });
1385
- const compositePipelineLayout = this.device.createPipelineLayout({
1706
+ this.compositePipelineLayout = this.device.createPipelineLayout({
1386
1707
  bindGroupLayouts: [this.compositeBindGroupLayout],
1387
1708
  });
1388
- const makeCompositePipeline = (applyGamma, label) => this.device.createRenderPipeline({
1389
- label,
1390
- layout: compositePipelineLayout,
1391
- vertex: { module: compositeShader, entryPoint: "vs" },
1392
- fragment: {
1393
- module: compositeShader,
1394
- entryPoint: "fs",
1395
- constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
1396
- targets: [{ format: this.presentationFormat }],
1397
- },
1398
- primitive: { topology: "triangle-list" },
1709
+ const compositeShader = this.device.createShaderModule({
1710
+ label: "composite shader",
1711
+ code: buildCompositeShader(null),
1399
1712
  });
1400
- this.compositePipelineIdentity = makeCompositePipeline(false, "composite pipeline (gamma=1)");
1401
- this.compositePipelineGamma = makeCompositePipeline(true, "composite pipeline (gamma!=1)");
1713
+ this.compositePipelineIdentity = this.makeCompositePipeline(compositeShader, false, "composite pipeline (gamma=1)");
1714
+ this.compositePipelineGamma = this.makeCompositePipeline(compositeShader, true, "composite pipeline (gamma!=1)");
1402
1715
  // GPU vertex-morph compute pipeline (shared by all models; per-model bind groups).
1403
1716
  // Bindings: 0-4 read-only storage (base pos, CSR rowStart/colMorph/colOffset, weights),
1404
1717
  // 5 read-write storage (vertex buffer), 6 uniform (params).
@@ -2208,6 +2521,7 @@ export class Engine {
2208
2521
  if (!shared) {
2209
2522
  tex.destroy();
2210
2523
  this.textureCache.delete(path);
2524
+ this.textureAlphaCache.delete(path);
2211
2525
  }
2212
2526
  }
2213
2527
  for (const buf of inst.gpuBuffers) {
@@ -2698,12 +3012,17 @@ export class Engine {
2698
3012
  const prefix = `${inst.name}: `;
2699
3013
  // 1-based so that (0,0) = clear color = "no hit"
2700
3014
  const modelId = this.modelInstances.size + 1;
3015
+ const texLogicalPath = (texIndex) => texIndex < 0 || texIndex >= textures.length
3016
+ ? null
3017
+ : joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path));
2701
3018
  const loadTextureByIndex = async (texIndex) => {
2702
- if (texIndex < 0 || texIndex >= textures.length)
2703
- return null;
2704
- const logicalPath = joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path));
2705
- return this.createTextureFromLogicalPath(inst, logicalPath);
3019
+ const logicalPath = texLogicalPath(texIndex);
3020
+ return logicalPath ? this.createTextureFromLogicalPath(inst, logicalPath) : null;
2706
3021
  };
3022
+ // Mesh data for sheerness sampling (8 floats/vertex; uv at +6). See
3023
+ // materialIsSheer — classification happens per material below.
3024
+ const meshVertices = model.getVertices();
3025
+ const meshIndices = model.getIndices();
2707
3026
  // 頭 bone index for the eye shader's rear-view gate (-1 when absent).
2708
3027
  const headBoneIndex = model.getSkeleton().bones.findIndex((b) => b.name === "頭");
2709
3028
  let currentIndexOffset = 0;
@@ -2719,7 +3038,27 @@ export class Engine {
2719
3038
  diffuseTexture = this.fallbackMaterialTexture;
2720
3039
  }
2721
3040
  const materialAlpha = mat.diffuse[3];
2722
- const isTransparent = materialAlpha < 1.0 - 0.001;
3041
+ // Transparent bucket when the MATERIAL says so — or when the TEXTURE does
3042
+ // (sheer cloth almost always ships with diffuse alpha 1.0 and carries its
3043
+ // translucency in texture alpha). Transparent-bucket draws happen after
3044
+ // the opaque bucket (and after the late-drawn hair render-class), so a
3045
+ // veil composites over the hair behind it instead of depth-rejecting it;
3046
+ // they are also excluded from the shadow map, so sheer cloth stops
3047
+ // casting the solid shadow of an opaque sheet.
3048
+ const diffusePath = texLogicalPath(mat.diffuseTextureIndex);
3049
+ const alphaSampler = diffusePath ? this.textureAlphaCache.get(diffusePath) : null;
3050
+ const stats = materialAlphaStats(meshVertices, meshIndices, currentIndexOffset, indexCount, alphaSampler);
3051
+ const sheer = stats.avg < SHEER_ALPHA_THRESHOLD;
3052
+ const partial = !sheer && stats.translucentFrac > PARTIAL_TRANSLUCENT_FRAC;
3053
+ // Transparent bucket: drawn after the opaque bucket (and hair) with depth
3054
+ // WRITE OFF, so a lace panel folded in front of itself blends both layers
3055
+ // consistently instead of a triangle-order patchwork of single/double
3056
+ // coverage. Partial-sheer cloth still casts shadows and keeps its outline;
3057
+ // fully sheer cloth (veil) does neither.
3058
+ const isTransparent = materialAlpha < 1.0 - 0.001 || sheer || partial;
3059
+ // Load-time classification log — one line per material, cheap and
3060
+ // invaluable when a model renders wrong (bucket/outline/sheer disputes).
3061
+ console.info(`[reze] ${mat.name}: alpha=${materialAlpha.toFixed(2)} avg=${stats.avg.toFixed(2)} sheerFrac=${stats.translucentFrac.toFixed(2)} ${sheer ? "SHEER" : partial ? "PARTIAL" : "opaque"} bucket=${isTransparent ? "transparent" : "opaque"} edge=${(mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0 ? (sheer ? "skipped(sheer)" : "on") : "off"}`);
2723
3062
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
2724
3063
  // rare and not implemented — treated as none, like a failed load.
2725
3064
  let sphereMode = mat.sphereMode === 1 || mat.sphereMode === 2 ? mat.sphereMode : 0;
@@ -2759,8 +3098,14 @@ export class Engine {
2759
3098
  materialName: mat.name,
2760
3099
  groupId: null,
2761
3100
  baseBindGroupEntries,
3101
+ castsShadow: !sheer,
2762
3102
  });
2763
- if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
3103
+ // No inverted-hull outline for SHEER materials: the outline shader draws a
3104
+ // solid edgeColor silhouette (it never samples texture alpha), so a
3105
+ // see-through veil dragged a near-black hull over the cloth behind it —
3106
+ // broken black shapes that waved with physics and flickered with camera
3107
+ // angle. A solid rim on see-through fabric is wrong in principle.
3108
+ if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0 && !sheer) {
2764
3109
  const materialUniformData = new Float32Array([
2765
3110
  mat.edgeColor[0],
2766
3111
  mat.edgeColor[1],
@@ -2895,6 +3240,9 @@ export class Engine {
2895
3240
  return null;
2896
3241
  }
2897
3242
  }
3243
+ // CPU alpha sampler for sheerness classification (see textureAlphaCache).
3244
+ // Canvas 2D premultiplies RGB on readback, but the ALPHA channel is exact.
3245
+ this.textureAlphaCache.set(cacheKey, buildAlphaSampler(source, rgba, width, height));
2898
3246
  const mipLevelCount = Math.floor(Math.log2(Math.max(width, height))) + 1;
2899
3247
  const texture = this.device.createTexture({
2900
3248
  label: `texture: ${cacheKey}`,
@@ -3640,9 +3988,13 @@ export class Engine {
3640
3988
  return { ok: false, diagnostics, slotMap: result.slotMap };
3641
3989
  }
3642
3990
  let pipeline;
3991
+ let pipelineNoDepthWrite;
3643
3992
  let overEyesPipeline;
3644
3993
  try {
3645
3994
  pipeline = await this.createRenderClassPipeline(renderClass, module, false);
3995
+ // Transparent-bucket draws don't write depth (self-overlap must blend, not
3996
+ // patchwork) — same shading, different depth state.
3997
+ pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false);
3646
3998
  if (renderClass === "hair")
3647
3999
  overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true);
3648
4000
  }
@@ -3666,6 +4018,7 @@ export class Engine {
3666
4018
  renderClass,
3667
4019
  alphaMode,
3668
4020
  pipeline,
4021
+ pipelineNoDepthWrite,
3669
4022
  overEyesPipeline,
3670
4023
  uniformBuffer,
3671
4024
  slotMap: result.slotMap,
@@ -3724,14 +4077,14 @@ export class Engine {
3724
4077
  ground: 4,
3725
4078
  };
3726
4079
  inst.drawCalls.sort((a, b) => typeOrder[a.type] - typeOrder[b.type] || this.drawCallRank(inst, a) - this.drawCallRank(inst, b));
3727
- inst.shadowDrawCalls = inst.drawCalls.filter((d) => d.type === "opaque");
4080
+ inst.shadowDrawCalls = inst.drawCalls.filter((d) => (d.type === "opaque" || d.type === "transparent") && d.castsShadow === true);
3728
4081
  }
3729
4082
  /**
3730
4083
  * Render-class pipeline state. A group's compiled graph swaps the fragment shading; the
3731
4084
  * render-class owns pass integration (stencil interplay, depth bias, cull). auto = plain;
3732
4085
  * eye = stamp + front cull + bias; hair = stencil-test (+ the over-eyes variant).
3733
4086
  */
3734
- createRenderClassPipeline(renderClass, module, overEyes) {
4087
+ createRenderClassPipeline(renderClass, module, overEyes, depthWrite = true) {
3735
4088
  const base = {
3736
4089
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
3737
4090
  layout: this.mainPipelineLayout,
@@ -3741,7 +4094,7 @@ export class Engine {
3741
4094
  };
3742
4095
  const plainDepth = {
3743
4096
  format: "depth24plus-stencil8",
3744
- depthWriteEnabled: true,
4097
+ depthWriteEnabled: depthWrite,
3745
4098
  depthCompare: "less-equal",
3746
4099
  };
3747
4100
  let depthStencil = plainDepth;
@@ -3788,6 +4141,12 @@ export class Engine {
3788
4141
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
3789
4142
  // the neutral base (ungrouped materials render the default graph).
3790
4143
  pipelineForDrawCall(inst, dc) {
4144
+ // Transparent draws WRITE depth — MMD semantics: PMX triangle/material order
4145
+ // is the author's compositing order, and a fold HIDES its far side rather
4146
+ // than blending it (the far side shades dark — light-averted — so letting it
4147
+ // show through read as gray fold-shaped stains; depth-write-off made every
4148
+ // fold do that). The no-write twins stay available for a future true-OIT
4149
+ // path but are deliberately unused.
3791
4150
  if (dc.groupId) {
3792
4151
  const install = inst.styleGroups.get(dc.groupId);
3793
4152
  if (install)
@@ -3828,6 +4187,8 @@ export class Engine {
3828
4187
  * rebind group 0/1 correctly if needed.
3829
4188
  */
3830
4189
  drawOutlines(pass, inst, type) {
4190
+ if (!this.outlineEnabled)
4191
+ return;
3831
4192
  let bound = false;
3832
4193
  for (const draw of inst.drawCalls) {
3833
4194
  if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
@@ -3856,12 +4217,35 @@ export class Engine {
3856
4217
  // Single stencil-reference set covers eye (write), hair (read not-equal),
3857
4218
  // and hairOverEyes (read equal). Non-stencil pipelines ignore the value.
3858
4219
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
4220
+ // Order matters (the black-hull saga): transparent color draws don't write
4221
+ // depth (self-overlap must double-blend, not patchwork), so ALL outlines
4222
+ // draw after the transparent depth PREPASS has recorded the fabric's depth
4223
+ // — otherwise every hull behind a sheer skirt shows straight through it.
3859
4224
  this.drawMaterials(pass, inst, "opaque");
3860
- this.drawOutlines(pass, inst, "opaque-outline");
3861
4225
  this.drawHairOverEyes(pass, inst);
3862
4226
  this.drawMaterials(pass, inst, "transparent");
4227
+ this.drawOutlines(pass, inst, "opaque-outline");
3863
4228
  this.drawOutlines(pass, inst, "transparent-outline");
3864
4229
  }
4230
+ /** Depth-only re-draw of transparent-bucket materials (see depth-prepass.ts).
4231
+ * Unused while transparent draws write depth themselves (MMD parity) — kept
4232
+ * for the dormant no-write/OIT path. */
4233
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
4234
+ drawTransparentDepthPrepass(pass, inst) {
4235
+ let bound = false;
4236
+ for (const draw of inst.drawCalls) {
4237
+ if (draw.type !== "transparent" || !this.shouldRenderDrawCall(inst, draw))
4238
+ continue;
4239
+ if (!bound) {
4240
+ pass.setPipeline(this.transparentDepthPrepassPipeline);
4241
+ pass.setBindGroup(0, this.perFrameBindGroup);
4242
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
4243
+ bound = true;
4244
+ }
4245
+ pass.setBindGroup(2, draw.bindGroup);
4246
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
4247
+ }
4248
+ }
3865
4249
  /**
3866
4250
  * Second hair pass for the see-through-hair effect. Re-draws every hair-class grouped
3867
4251
  * opaque draw with its compiled over-eyes pipeline — stencil-matched to `EYE_VALUE`,
@@ -3913,7 +4297,7 @@ export class Engine {
3913
4297
  // is LEFT-HANDED (+Z forward, see Mat4.lookAtInto), so the world-space
3914
4298
  // right/up/FORWARD vectors are rows 0/1/2 of its rotation block directly
3915
4299
  // (column-major storage: row i = values[i], values[i+4], values[i+8]).
3916
- if (this.backdropEquirectView && this.compositeUniformBuffer) {
4300
+ if ((this.backdropEquirectView || this.backgroundEffect) && this.compositeUniformBuffer) {
3917
4301
  const v = viewMatrix.values;
3918
4302
  const u = this.compositeUniformData;
3919
4303
  const tanHalf = Math.tan((this.camera.fov ?? Math.PI / 4) / 2);
@@ -3930,6 +4314,10 @@ export class Engine {
3930
4314
  u[21] = v[6];
3931
4315
  u[22] = v[10];
3932
4316
  u[23] = 0;
4317
+ // Effect clock + canvas size (viewU[6]) — written on the same refresh.
4318
+ u[24] = (performance.now() - this.bgEffectEpochMs) / 1000;
4319
+ u[26] = this.canvas.width;
4320
+ u[27] = this.canvas.height;
3933
4321
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
3934
4322
  }
3935
4323
  }