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/src/engine.ts CHANGED
@@ -22,6 +22,7 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut"
22
22
  import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow"
23
23
  import { GROUND_SHADOW_SHADER_WGSL } from "./shaders/passes/ground"
24
24
  import { OUTLINE_SHADER_WGSL } from "./shaders/passes/outline"
25
+ import { TRANSPARENT_DEPTH_PREPASS_WGSL } from "./shaders/passes/depth-prepass"
25
26
  import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection"
26
27
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo"
27
28
  import {
@@ -29,7 +30,7 @@ import {
29
30
  BLOOM_DOWNSAMPLE_SHADER_WGSL,
30
31
  BLOOM_UPSAMPLE_SHADER_WGSL,
31
32
  } from "./shaders/passes/bloom"
32
- import { COMPOSITE_SHADER_WGSL } from "./shaders/passes/composite"
33
+ import { buildCompositeShader } from "./shaders/passes/composite"
33
34
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
34
35
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
35
36
  import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
@@ -172,6 +173,8 @@ type GroupInstall = {
172
173
  renderClass: RenderClass
173
174
  alphaMode: AlphaMode
174
175
  pipeline: GPURenderPipeline
176
+ /** Depth-write-off twin, used when this group's material draws in the transparent bucket. */
177
+ pipelineNoDepthWrite: GPURenderPipeline
175
178
  /** hair render-class only: the stencil-matched IS_OVER_EYES=true variant. */
176
179
  overEyesPipeline?: GPURenderPipeline
177
180
  uniformBuffer: GPUBuffer
@@ -223,6 +226,16 @@ export type SunOptions = {
223
226
  direction?: Vec3
224
227
  }
225
228
 
229
+ /** A background-effect param: number → f32, vector-like → vec3f (see
230
+ * setBackgroundEffect). Structural {x,y,z} rather than the Vec3 class so
231
+ * JSON-derived values (a shared scene document's params) pass straight in. */
232
+ export type BackgroundEffectParamValue = number | { x: number; y: number; z: number }
233
+ export type BackgroundEffectResult = {
234
+ ok: boolean
235
+ /** Compile/validation errors, line:col relative to the USER's WGSL. */
236
+ diagnostics: string[]
237
+ }
238
+
226
239
  export type CameraOptions = {
227
240
  /** Orbit distance from target. */
228
241
  distance?: number
@@ -344,6 +357,8 @@ interface DrawCall {
344
357
  // Present only for material draw calls (opaque/transparent) — the grouping walk skips
345
358
  // any draw call without it.
346
359
  baseBindGroupEntries?: GPUBindGroupEntry[]
360
+ /** Material draws only: false = excluded from the shadow map (fully sheer). */
361
+ castsShadow?: boolean
347
362
  }
348
363
 
349
364
  interface PickDrawCall {
@@ -392,6 +407,95 @@ interface GpuMorph {
392
407
  dispatchNeeded: boolean
393
408
  }
394
409
 
410
+ // ── Sheer-material detection ──────────────────────────────────────────────────
411
+ // PMX carries no "translucent" flag: a see-through veil usually has diffuse
412
+ // alpha 1.0 and does its transparency entirely in the TEXTURE's alpha channel.
413
+ // Classifying by material alpha alone put such cloth in the opaque bucket,
414
+ // where it draws in PMX order with depth writes — anything the engine draws
415
+ // after it (the hair render-class draws LAST for the eye-stencil effect) got
416
+ // depth-rejected behind the veil, so you saw the body through it but not the
417
+ // hair. These helpers measure a material's real coverage by sampling the
418
+ // texture's alpha at the material's own triangle CENTROIDS — centroids, not
419
+ // vertices, because hair-card corners sit in transparent texture margins and
420
+ // vertex sampling would misclassify hair (which must stay opaque-bucket for
421
+ // stencil interplay and shadows).
422
+
423
+ /** Downsampled alpha plane of a decoded texture (≤128², nearest-sampled). */
424
+ function buildAlphaSampler(
425
+ source: ImageBitmap | null,
426
+ rgba: Uint8Array | null,
427
+ width: number,
428
+ height: number,
429
+ ): { a: Uint8ClampedArray; w: number; h: number } | null {
430
+ try {
431
+ const w = Math.max(1, Math.min(128, width))
432
+ const h = Math.max(1, Math.min(128, height))
433
+ const canvas = new OffscreenCanvas(w, h)
434
+ const cx = canvas.getContext("2d", { willReadFrequently: true })
435
+ if (!cx) return null
436
+ if (source) {
437
+ cx.drawImage(source, 0, 0, w, h)
438
+ } else if (rgba) {
439
+ const tmp = new OffscreenCanvas(width, height)
440
+ const tcx = tmp.getContext("2d")
441
+ if (!tcx) return null
442
+ tcx.putImageData(new ImageData(new Uint8ClampedArray(rgba), width, height), 0, 0)
443
+ cx.drawImage(tmp, 0, 0, w, h)
444
+ } else {
445
+ return null
446
+ }
447
+ const img = cx.getImageData(0, 0, w, h).data
448
+ const a = new Uint8ClampedArray(w * h)
449
+ for (let i = 0; i < w * h; i++) a[i] = img[i * 4 + 3]
450
+ return { a, w, h }
451
+ } catch {
452
+ return null
453
+ }
454
+ }
455
+
456
+ /** Texture-alpha statistics over ≤400 of the material's triangle centroids:
457
+ * `avg` (0..1) and `translucentFrac` — the fraction of samples that are
458
+ * neither fully opaque nor fully cut out (alpha in ~0.03..0.97). Together
459
+ * they classify two distinct things:
460
+ * sheer (avg low) — a veil: mostly see-through everywhere
461
+ * partial (translucentFrac up) — mostly-opaque cloth with sheer REGIONS,
462
+ * e.g. a lace skirt panel
463
+ * Both route to the transparent bucket; only `sheer` is excluded from the
464
+ * shadow map and outline pass. */
465
+ const SHEER_ALPHA_THRESHOLD = 0.7
466
+ const PARTIAL_TRANSLUCENT_FRAC = 0.15
467
+ function materialAlphaStats(
468
+ verts: Float32Array,
469
+ indices: Uint32Array,
470
+ firstIndex: number,
471
+ count: number,
472
+ sampler: { a: Uint8ClampedArray; w: number; h: number } | null | undefined,
473
+ ): { avg: number; translucentFrac: number } {
474
+ if (!sampler) return { avg: 1, translucentFrac: 0 }
475
+ const triCount = Math.floor(count / 3)
476
+ if (triCount === 0) return { avg: 1, translucentFrac: 0 }
477
+ const step = Math.max(1, Math.floor(triCount / 400))
478
+ let sum = 0
479
+ let translucent = 0
480
+ let n = 0
481
+ for (let t = 0; t < triCount; t += step) {
482
+ const i0 = indices[firstIndex + t * 3]
483
+ const i1 = indices[firstIndex + t * 3 + 1]
484
+ const i2 = indices[firstIndex + t * 3 + 2]
485
+ const u = (verts[i0 * 8 + 6] + verts[i1 * 8 + 6] + verts[i2 * 8 + 6]) / 3
486
+ const v = (verts[i0 * 8 + 7] + verts[i1 * 8 + 7] + verts[i2 * 8 + 7]) / 3
487
+ // Wrap (MMD UVs may tile), then nearest-sample the downsampled plane.
488
+ const x = Math.min(sampler.w - 1, Math.max(0, Math.floor((u - Math.floor(u)) * sampler.w)))
489
+ const y = Math.min(sampler.h - 1, Math.max(0, Math.floor((v - Math.floor(v)) * sampler.h)))
490
+ const a = sampler.a[y * sampler.w + x]
491
+ sum += a
492
+ if (a > 8 && a < 247) translucent++
493
+ n++
494
+ }
495
+ if (n === 0) return { avg: 1, translucentFrac: 0 }
496
+ return { avg: sum / n / 255, translucentFrac: translucent / n }
497
+ }
498
+
395
499
  export class Engine {
396
500
  private static instance: Engine | null = null
397
501
 
@@ -422,6 +526,8 @@ export class Engine {
422
526
  // The one base shading model: ungrouped materials render this (compiled DEFAULT_GRAPH).
423
527
  // Grouped materials use their group's own compiled pipeline.
424
528
  private neutralPipeline!: GPURenderPipeline
529
+ private neutralPipelineNoDepthWrite!: GPURenderPipeline
530
+ private transparentDepthPrepassPipeline!: GPURenderPipeline
425
531
  // ── Style group runtime ──
426
532
  // Shared 256 B zero StyleUniforms buffer (group(2) binding(4)) bound by every ungrouped
427
533
  // material; grouped materials rebind to their group's own buffer (per-model, in the
@@ -545,7 +651,7 @@ export class Engine {
545
651
  private compositeBindGroup!: GPUBindGroup
546
652
  private compositeUniformBuffer!: GPUBuffer
547
653
  // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
548
- private readonly compositeUniformData = new Float32Array(24)
654
+ private readonly compositeUniformData = new Float32Array(28)
549
655
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
550
656
  private backgroundColor: Vec3 | null = null
551
657
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
@@ -553,6 +659,21 @@ export class Engine {
553
659
  private backdropEquirectView: GPUTextureView | null = null
554
660
  private fallbackEquirectTexture!: GPUTexture
555
661
  private fallbackEquirectView!: GPUTextureView
662
+ // User WGSL background effect (background mode 3, setBackgroundEffect). The
663
+ // composite pipelines are REBUILT with the user code injected; params live in
664
+ // their own uniform buffer so setBackgroundEffectParam is a write, not a
665
+ // recompile (the same instant tier as setStyleParam).
666
+ private backgroundEffect: {
667
+ wgsl: string
668
+ paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
669
+ paramsBuffer: GPUBuffer | null
670
+ paramsData: Float32Array<ArrayBuffer>
671
+ } | null = null
672
+ /** Bound at composite binding 7 when no effect (or a param-less one) is set. */
673
+ private bgParamsDummyBuffer!: GPUBuffer
674
+ private compositePipelineLayout!: GPUPipelineLayout
675
+ /** time=0 origin for the active effect — reset each setBackgroundEffect. */
676
+ private bgEffectEpochMs = 0
556
677
  private compositeBloomView: GPUTextureView | null = null
557
678
 
558
679
  // EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
@@ -627,6 +748,11 @@ export class Engine {
627
748
  private materialSampler!: GPUSampler
628
749
  private fallbackMaterialTexture!: GPUTexture
629
750
  private textureCache = new Map<string, GPUTexture>()
751
+ // Downsampled CPU alpha channel per texture (≤128², ~16KB) — kept so materials
752
+ // can be classified as SHEER at load by sampling alpha at their own UVs (the
753
+ // GPU texture can't be read back cheaply, and PMX diffuse alpha is usually 1.0
754
+ // even for see-through cloth: the translucency lives in the texture).
755
+ private textureAlphaCache = new Map<string, { a: Uint8ClampedArray; w: number; h: number } | null>()
630
756
  private mipBlitPipeline: GPURenderPipeline | null = null
631
757
  private mipBlitSampler: GPUSampler | null = null
632
758
  private _nextDefaultModelId = 0
@@ -769,7 +895,12 @@ export class Engine {
769
895
  u[8] = bg?.x ?? 0
770
896
  u[9] = bg?.y ?? 0
771
897
  u[10] = bg?.z ?? 0
898
+ // Base-layer mode; a user effect is a separate LAYER flagged at u[25] and
899
+ // over-composited onto whichever base is active.
772
900
  u[11] = this.backdropEquirectView ? 2 : bg ? 1 : 0
901
+ u[25] = this.backgroundEffect ? 1 : 0
902
+ u[26] = this.canvas.width
903
+ u[27] = this.canvas.height
773
904
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
774
905
  }
775
906
 
@@ -785,6 +916,12 @@ export class Engine {
785
916
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
786
917
  }
787
918
 
919
+ /** Debug/diagnostic: skip every inverted-hull outline draw. */
920
+ private outlineEnabled = true
921
+ setOutlineEnabled(on: boolean): void {
922
+ this.outlineEnabled = on
923
+ }
924
+
788
925
  private rebuildCompositeBindGroup(): void {
789
926
  if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView) return
790
927
  this.compositeBindGroup = this.device.createBindGroup({
@@ -798,6 +935,7 @@ export class Engine {
798
935
  { binding: 4, resource: this.maskResolveView },
799
936
  { binding: 5, resource: this.filmicLutView },
800
937
  { binding: 6, resource: this.backdropEquirectView ?? this.fallbackEquirectView },
938
+ { binding: 7, resource: { buffer: this.backgroundEffect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
801
939
  ],
802
940
  })
803
941
  }
@@ -846,6 +984,168 @@ export class Engine {
846
984
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
847
985
  }
848
986
 
987
+ private makeCompositePipeline(module: GPUShaderModule, applyGamma: boolean, label: string): GPURenderPipeline {
988
+ return this.device.createRenderPipeline({
989
+ label,
990
+ layout: this.compositePipelineLayout,
991
+ vertex: { module, entryPoint: "vs" },
992
+ fragment: {
993
+ module,
994
+ entryPoint: "fs",
995
+ constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
996
+ targets: [{ format: this.presentationFormat }],
997
+ },
998
+ primitive: { topology: "triangle-list" },
999
+ })
1000
+ }
1001
+
1002
+ /**
1003
+ * Install a WGSL background effect (shadertoy-style) as a LAYER between the
1004
+ * base background and the scene: rendered per-pixel in the composite pass and
1005
+ * over-composited onto whichever base is active (solid color, 360 equirect,
1006
+ * or transparency) — its alpha lets the base show through, so a starfield is
1007
+ * stars over the user's background color. Display-space: never affects
1008
+ * lighting, bloom, or tonemapping, and is captured by offline export like any
1009
+ * background.
1010
+ *
1011
+ * `wgsl` must define:
1012
+ *
1013
+ * fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
1014
+ *
1015
+ * where `ray` is the pixel's normalized world-space view direction (LH, +Z
1016
+ * forward — what the skybox samples by), `uv` is 0..1 bottom-left origin,
1017
+ * `time` is seconds since apply, and `bgResolution()` gives the canvas size.
1018
+ * Return sRGB + alpha. Declared `params` arrive as `params.<name>` (number →
1019
+ * f32, Vec3 → vec3f) and are later tweaked without recompiling via
1020
+ * setBackgroundEffectParam.
1021
+ *
1022
+ * Compiles off the hot path (async pipelines): on failure the previous
1023
+ * background is KEPT and diagnostics are returned with line numbers relative
1024
+ * to the user's WGSL. Pass null to remove the effect.
1025
+ */
1026
+ async setBackgroundEffect(
1027
+ wgsl: string | null,
1028
+ params?: Record<string, BackgroundEffectParamValue>,
1029
+ ): Promise<BackgroundEffectResult> {
1030
+ if (!this.device) return { ok: false, diagnostics: ["setBackgroundEffect requires init() to have run"] }
1031
+
1032
+ if (wgsl === null) {
1033
+ this.backgroundEffect?.paramsBuffer?.destroy()
1034
+ this.backgroundEffect = null
1035
+ const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) })
1036
+ this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)")
1037
+ this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
1038
+ this.rebuildCompositeBindGroup()
1039
+ this.writeCompositeViewUniforms()
1040
+ return { ok: true, diagnostics: [] }
1041
+ }
1042
+
1043
+ // ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
1044
+ // Fields are emitted in declaration order; offsets follow WGSL's natural
1045
+ // uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
1046
+ // on both sides so no reordering is needed.
1047
+ const entries = Object.entries(params ?? {})
1048
+ const layout = new Map<string, { offset: number; comps: 1 | 3 }>()
1049
+ const fields: string[] = []
1050
+ let cursor = 0
1051
+ for (const [name, value] of entries) {
1052
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
1053
+ return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`] }
1054
+ }
1055
+ const isVec = typeof value !== "number"
1056
+ const align = isVec ? 16 : 4
1057
+ const offset = Math.ceil(cursor / align) * align
1058
+ layout.set(name, { offset: offset / 4, comps: isVec ? 3 : 1 })
1059
+ fields.push(` ${name}: ${isVec ? "vec3f" : "f32"},`)
1060
+ cursor = offset + (isVec ? 12 : 4)
1061
+ }
1062
+ const paramsData = new Float32Array(Math.max(4, Math.ceil(cursor / 16) * 4))
1063
+ for (const [name, value] of entries) {
1064
+ const slot = layout.get(name)!
1065
+ if (typeof value === "number") paramsData[slot.offset] = value
1066
+ else {
1067
+ paramsData[slot.offset] = value.x
1068
+ paramsData[slot.offset + 1] = value.y
1069
+ paramsData[slot.offset + 2] = value.z
1070
+ }
1071
+ }
1072
+ const paramsDecl = entries.length
1073
+ ? `struct BgParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: BgParams;\n`
1074
+ : ""
1075
+
1076
+ // ── Compile with validation captured, not thrown at the console. Line
1077
+ // numbers in diagnostics are rebased to the USER's source.
1078
+ const source = buildCompositeShader({ wgsl, paramsDecl })
1079
+ const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1
1080
+ this.device.pushErrorScope("validation")
1081
+ const module = this.device.createShaderModule({ label: "composite shader (bg effect)", code: source })
1082
+ const info = await module.getCompilationInfo()
1083
+ const scopeErr = await this.device.popErrorScope()
1084
+ const diagnostics = info.messages
1085
+ .filter((m) => m.type === "error")
1086
+ .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
1087
+ if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
1088
+ if (diagnostics.length > 0) return { ok: false, diagnostics }
1089
+ let identity: GPURenderPipeline
1090
+ let gamma: GPURenderPipeline
1091
+ try {
1092
+ const make = (applyGamma: boolean, label: string) =>
1093
+ this.device.createRenderPipelineAsync({
1094
+ label,
1095
+ layout: this.compositePipelineLayout,
1096
+ vertex: { module, entryPoint: "vs" },
1097
+ fragment: {
1098
+ module,
1099
+ entryPoint: "fs",
1100
+ constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
1101
+ targets: [{ format: this.presentationFormat }],
1102
+ },
1103
+ primitive: { topology: "triangle-list" },
1104
+ })
1105
+ ;[identity, gamma] = await Promise.all([
1106
+ make(false, "composite pipeline (bg effect, gamma=1)"),
1107
+ make(true, "composite pipeline (bg effect, gamma!=1)"),
1108
+ ])
1109
+ } catch (e) {
1110
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
1111
+ }
1112
+
1113
+ // ── Swap — only now does the old effect (and its params buffer) go away.
1114
+ this.backgroundEffect?.paramsBuffer?.destroy()
1115
+ let paramsBuffer: GPUBuffer | null = null
1116
+ if (entries.length) {
1117
+ paramsBuffer = this.device.createBuffer({
1118
+ label: "bg effect params",
1119
+ size: paramsData.byteLength,
1120
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1121
+ })
1122
+ this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
1123
+ }
1124
+ this.backgroundEffect = { wgsl, paramLayout: layout, paramsBuffer, paramsData }
1125
+ this.compositePipelineIdentity = identity
1126
+ this.compositePipelineGamma = gamma
1127
+ this.bgEffectEpochMs = performance.now()
1128
+ this.rebuildCompositeBindGroup()
1129
+ this.writeCompositeViewUniforms()
1130
+ return { ok: true, diagnostics: [] }
1131
+ }
1132
+
1133
+ /** Write one background-effect param (declared at setBackgroundEffect) — a
1134
+ * uniform write, no recompile; the instant tier, like setStyleParam. */
1135
+ setBackgroundEffectParam(name: string, value: BackgroundEffectParamValue): void {
1136
+ const fx = this.backgroundEffect
1137
+ if (!fx || !fx.paramsBuffer) return
1138
+ const slot = fx.paramLayout.get(name)
1139
+ if (!slot) return
1140
+ if (typeof value === "number") fx.paramsData[slot.offset] = value
1141
+ else {
1142
+ fx.paramsData[slot.offset] = value.x
1143
+ fx.paramsData[slot.offset + 1] = value.y
1144
+ fx.paramsData[slot.offset + 2] = value.z
1145
+ }
1146
+ this.device.queue.writeBuffer(fx.paramsBuffer, 0, fx.paramsData)
1147
+ }
1148
+
849
1149
  /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
850
1150
  setBloomOptions(patch: Partial<BloomOptions>): void {
851
1151
  const b = this.bloomSettings
@@ -1325,6 +1625,44 @@ export class Engine {
1325
1625
  depthCompare: "less-equal",
1326
1626
  },
1327
1627
  })
1628
+ // Depth-write-off twin for transparent-bucket draws (see pipelineForDrawCall).
1629
+ this.neutralPipelineNoDepthWrite = this.createRenderPipeline({
1630
+ label: "neutral base pipeline (no depth write)",
1631
+ layout: mainPipelineLayout,
1632
+ shaderModule: neutralModule,
1633
+ vertexBuffers: fullVertexBuffers,
1634
+ fragmentTargets: sceneTargets,
1635
+ cullMode: "none",
1636
+ depthStencil: {
1637
+ format: "depth24plus-stencil8",
1638
+ depthWriteEnabled: false,
1639
+ depthCompare: "less-equal",
1640
+ },
1641
+ })
1642
+ // Depth-only prepass for transparent draws (see depth-prepass.ts): writes the
1643
+ // fabric's depth AFTER its color blended, so outlines drawn later are
1644
+ // occluded behind it. Color targets kept for pass compatibility, writeMask 0.
1645
+ const prepassModule = this.device.createShaderModule({
1646
+ label: "transparent depth prepass",
1647
+ code: TRANSPARENT_DEPTH_PREPASS_WGSL,
1648
+ })
1649
+ this.transparentDepthPrepassPipeline = this.device.createRenderPipeline({
1650
+ label: "transparent depth prepass",
1651
+ layout: mainPipelineLayout,
1652
+ vertex: { module: prepassModule, entryPoint: "vs", buffers: fullVertexBuffers as GPUVertexBufferLayout[] },
1653
+ fragment: {
1654
+ module: prepassModule,
1655
+ entryPoint: "fs",
1656
+ targets: sceneTargets.map((t) => ({ format: (t as GPUColorTargetState).format, writeMask: 0 })),
1657
+ },
1658
+ primitive: { cullMode: "none" },
1659
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
1660
+ depthStencil: {
1661
+ format: "depth24plus-stencil8",
1662
+ depthWriteEnabled: true,
1663
+ depthCompare: "less-equal",
1664
+ },
1665
+ })
1328
1666
 
1329
1667
  this.shadowLightVPBuffer = this.device.createBuffer({
1330
1668
  size: 64,
@@ -1462,6 +1800,16 @@ export class Engine {
1462
1800
  // Don’t write outline into depth buffer — stops z-fighting / black cracks vs body (MMD-style; body depth stays authoritative)
1463
1801
  depthWriteEnabled: false,
1464
1802
  depthCompare: "less-equal",
1803
+ // CONFIRMED fix (bisected live via setOutlineEnabled): hull fragments
1804
+ // carry their surface's exact depth, so against this model's paired
1805
+ // near-coplanar skirt layers the hulls WON depth ties in patches —
1806
+ // the black shapes on the dress. A small constant bias makes hulls lose
1807
+ // every tie; silhouette rims compare against the far background and are
1808
+ // unaffected. No slope term — slope explodes at silhouettes and would
1809
+ // erase the rims themselves (previous regression).
1810
+ depthBias: 4,
1811
+ depthBiasSlopeScale: 0,
1812
+ depthBiasClamp: 0,
1465
1813
  // Skip fragments where the eye stamped stencil=EYE_VALUE. Those pixels are owned by
1466
1814
  // the see-through-hair blend (hair-over-eyes), so letting the outline's near-black
1467
1815
  // edge color overwrite them would re-introduce the dark almond we just killed.
@@ -1643,9 +1991,15 @@ export class Engine {
1643
1991
  // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
1644
1992
  this.compositeUniformBuffer = this.device.createBuffer({
1645
1993
  label: "composite view uniforms",
1646
- // 6 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1647
- // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray.
1648
- size: 96,
1994
+ // 7 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1995
+ // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
1996
+ // (time, _, canvas width, canvas height) for user background effects.
1997
+ size: 112,
1998
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1999
+ })
2000
+ this.bgParamsDummyBuffer = this.device.createBuffer({
2001
+ label: "bg effect params (dummy)",
2002
+ size: 16,
1649
2003
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1650
2004
  })
1651
2005
  this.compositeBindGroupLayout = this.device.createBindGroupLayout({
@@ -1662,6 +2016,9 @@ export class Engine {
1662
2016
  { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1663
2017
  // 360 backdrop equirect (PhotoDome-style skybox) — 1×1 fallback when unset.
1664
2018
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
2019
+ // User background-effect params — dummy buffer when no effect is set. The
2020
+ // layout is explicit, so the base shader legally ignores the binding.
2021
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1665
2022
  ],
1666
2023
  })
1667
2024
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -1672,29 +2029,15 @@ export class Engine {
1672
2029
  })
1673
2030
  this.fallbackEquirectView = this.fallbackEquirectTexture.createView()
1674
2031
 
2032
+ this.compositePipelineLayout = this.device.createPipelineLayout({
2033
+ bindGroupLayouts: [this.compositeBindGroupLayout],
2034
+ })
1675
2035
  const compositeShader = this.device.createShaderModule({
1676
2036
  label: "composite shader",
1677
- code: COMPOSITE_SHADER_WGSL,
1678
- })
1679
-
1680
- const compositePipelineLayout = this.device.createPipelineLayout({
1681
- bindGroupLayouts: [this.compositeBindGroupLayout],
2037
+ code: buildCompositeShader(null),
1682
2038
  })
1683
- const makeCompositePipeline = (applyGamma: boolean, label: string): GPURenderPipeline =>
1684
- this.device.createRenderPipeline({
1685
- label,
1686
- layout: compositePipelineLayout,
1687
- vertex: { module: compositeShader, entryPoint: "vs" },
1688
- fragment: {
1689
- module: compositeShader,
1690
- entryPoint: "fs",
1691
- constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
1692
- targets: [{ format: this.presentationFormat }],
1693
- },
1694
- primitive: { topology: "triangle-list" },
1695
- })
1696
- this.compositePipelineIdentity = makeCompositePipeline(false, "composite pipeline (gamma=1)")
1697
- this.compositePipelineGamma = makeCompositePipeline(true, "composite pipeline (gamma!=1)")
2039
+ this.compositePipelineIdentity = this.makeCompositePipeline(compositeShader, false, "composite pipeline (gamma=1)")
2040
+ this.compositePipelineGamma = this.makeCompositePipeline(compositeShader, true, "composite pipeline (gamma!=1)")
1698
2041
 
1699
2042
  // GPU vertex-morph compute pipeline (shared by all models; per-model bind groups).
1700
2043
  // Bindings: 0-4 read-only storage (base pos, CSR rowStart/colMorph/colOffset, weights),
@@ -2613,6 +2956,7 @@ export class Engine {
2613
2956
  if (!shared) {
2614
2957
  tex.destroy()
2615
2958
  this.textureCache.delete(path)
2959
+ this.textureAlphaCache.delete(path)
2616
2960
  }
2617
2961
  }
2618
2962
  for (const buf of inst.gpuBuffers) {
@@ -3181,11 +3525,18 @@ export class Engine {
3181
3525
  // 1-based so that (0,0) = clear color = "no hit"
3182
3526
  const modelId = this.modelInstances.size + 1
3183
3527
 
3528
+ const texLogicalPath = (texIndex: number): string | null =>
3529
+ texIndex < 0 || texIndex >= textures.length
3530
+ ? null
3531
+ : joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path))
3184
3532
  const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
3185
- if (texIndex < 0 || texIndex >= textures.length) return null
3186
- const logicalPath = joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path))
3187
- return this.createTextureFromLogicalPath(inst, logicalPath)
3533
+ const logicalPath = texLogicalPath(texIndex)
3534
+ return logicalPath ? this.createTextureFromLogicalPath(inst, logicalPath) : null
3188
3535
  }
3536
+ // Mesh data for sheerness sampling (8 floats/vertex; uv at +6). See
3537
+ // materialIsSheer — classification happens per material below.
3538
+ const meshVertices = model.getVertices()
3539
+ const meshIndices = model.getIndices()
3189
3540
 
3190
3541
  // 頭 bone index for the eye shader's rear-view gate (-1 when absent).
3191
3542
  const headBoneIndex = model.getSkeleton().bones.findIndex((b) => b.name === "頭")
@@ -3204,7 +3555,29 @@ export class Engine {
3204
3555
  }
3205
3556
 
3206
3557
  const materialAlpha = mat.diffuse[3]
3207
- const isTransparent = materialAlpha < 1.0 - 0.001
3558
+ // Transparent bucket when the MATERIAL says so — or when the TEXTURE does
3559
+ // (sheer cloth almost always ships with diffuse alpha 1.0 and carries its
3560
+ // translucency in texture alpha). Transparent-bucket draws happen after
3561
+ // the opaque bucket (and after the late-drawn hair render-class), so a
3562
+ // veil composites over the hair behind it instead of depth-rejecting it;
3563
+ // they are also excluded from the shadow map, so sheer cloth stops
3564
+ // casting the solid shadow of an opaque sheet.
3565
+ const diffusePath = texLogicalPath(mat.diffuseTextureIndex)
3566
+ const alphaSampler = diffusePath ? this.textureAlphaCache.get(diffusePath) : null
3567
+ const stats = materialAlphaStats(meshVertices, meshIndices, currentIndexOffset, indexCount, alphaSampler)
3568
+ const sheer = stats.avg < SHEER_ALPHA_THRESHOLD
3569
+ const partial = !sheer && stats.translucentFrac > PARTIAL_TRANSLUCENT_FRAC
3570
+ // Transparent bucket: drawn after the opaque bucket (and hair) with depth
3571
+ // WRITE OFF, so a lace panel folded in front of itself blends both layers
3572
+ // consistently instead of a triangle-order patchwork of single/double
3573
+ // coverage. Partial-sheer cloth still casts shadows and keeps its outline;
3574
+ // fully sheer cloth (veil) does neither.
3575
+ const isTransparent = materialAlpha < 1.0 - 0.001 || sheer || partial
3576
+ // Load-time classification log — one line per material, cheap and
3577
+ // invaluable when a model renders wrong (bucket/outline/sheer disputes).
3578
+ console.info(
3579
+ `[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"}`,
3580
+ )
3208
3581
 
3209
3582
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
3210
3583
  // rare and not implemented — treated as none, like a failed load.
@@ -3251,9 +3624,15 @@ export class Engine {
3251
3624
  materialName: mat.name,
3252
3625
  groupId: null,
3253
3626
  baseBindGroupEntries,
3627
+ castsShadow: !sheer,
3254
3628
  })
3255
3629
 
3256
- if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
3630
+ // No inverted-hull outline for SHEER materials: the outline shader draws a
3631
+ // solid edgeColor silhouette (it never samples texture alpha), so a
3632
+ // see-through veil dragged a near-black hull over the cloth behind it —
3633
+ // broken black shapes that waved with physics and flickered with camera
3634
+ // angle. A solid rim on see-through fabric is wrong in principle.
3635
+ if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0 && !sheer) {
3257
3636
  const materialUniformData = new Float32Array([
3258
3637
  mat.edgeColor[0],
3259
3638
  mat.edgeColor[1],
@@ -3403,6 +3782,10 @@ export class Engine {
3403
3782
  }
3404
3783
  }
3405
3784
 
3785
+ // CPU alpha sampler for sheerness classification (see textureAlphaCache).
3786
+ // Canvas 2D premultiplies RGB on readback, but the ALPHA channel is exact.
3787
+ this.textureAlphaCache.set(cacheKey, buildAlphaSampler(source, rgba, width, height))
3788
+
3406
3789
  const mipLevelCount = Math.floor(Math.log2(Math.max(width, height))) + 1
3407
3790
  const texture = this.device.createTexture({
3408
3791
  label: `texture: ${cacheKey}`,
@@ -4418,9 +4801,13 @@ export class Engine {
4418
4801
  }
4419
4802
 
4420
4803
  let pipeline: GPURenderPipeline
4804
+ let pipelineNoDepthWrite: GPURenderPipeline
4421
4805
  let overEyesPipeline: GPURenderPipeline | undefined
4422
4806
  try {
4423
4807
  pipeline = await this.createRenderClassPipeline(renderClass, module, false)
4808
+ // Transparent-bucket draws don't write depth (self-overlap must blend, not
4809
+ // patchwork) — same shading, different depth state.
4810
+ pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false)
4424
4811
  if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true)
4425
4812
  } catch (e) {
4426
4813
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
@@ -4446,6 +4833,7 @@ export class Engine {
4446
4833
  renderClass,
4447
4834
  alphaMode,
4448
4835
  pipeline,
4836
+ pipelineNoDepthWrite,
4449
4837
  overEyesPipeline,
4450
4838
  uniformBuffer,
4451
4839
  slotMap: result.slotMap,
@@ -4509,7 +4897,9 @@ export class Engine {
4509
4897
  inst.drawCalls.sort(
4510
4898
  (a, b) => typeOrder[a.type] - typeOrder[b.type] || this.drawCallRank(inst, a) - this.drawCallRank(inst, b),
4511
4899
  )
4512
- inst.shadowDrawCalls = inst.drawCalls.filter((d) => d.type === "opaque")
4900
+ inst.shadowDrawCalls = inst.drawCalls.filter(
4901
+ (d) => (d.type === "opaque" || d.type === "transparent") && d.castsShadow === true,
4902
+ )
4513
4903
  }
4514
4904
 
4515
4905
  /**
@@ -4521,6 +4911,7 @@ export class Engine {
4521
4911
  renderClass: RenderClass,
4522
4912
  module: GPUShaderModule,
4523
4913
  overEyes: boolean,
4914
+ depthWrite = true,
4524
4915
  ): Promise<GPURenderPipeline> {
4525
4916
  const base = {
4526
4917
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
@@ -4531,7 +4922,7 @@ export class Engine {
4531
4922
  }
4532
4923
  const plainDepth: GPUDepthStencilState = {
4533
4924
  format: "depth24plus-stencil8",
4534
- depthWriteEnabled: true,
4925
+ depthWriteEnabled: depthWrite,
4535
4926
  depthCompare: "less-equal",
4536
4927
  }
4537
4928
  let depthStencil: GPUDepthStencilState = plainDepth
@@ -4577,6 +4968,12 @@ export class Engine {
4577
4968
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
4578
4969
  // the neutral base (ungrouped materials render the default graph).
4579
4970
  private pipelineForDrawCall(inst: ModelInstance, dc: DrawCall): GPURenderPipeline {
4971
+ // Transparent draws WRITE depth — MMD semantics: PMX triangle/material order
4972
+ // is the author's compositing order, and a fold HIDES its far side rather
4973
+ // than blending it (the far side shades dark — light-averted — so letting it
4974
+ // show through read as gray fold-shaped stains; depth-write-off made every
4975
+ // fold do that). The no-write twins stay available for a future true-OIT
4976
+ // path but are deliberately unused.
4580
4977
  if (dc.groupId) {
4581
4978
  const install = inst.styleGroups.get(dc.groupId)
4582
4979
  if (install) return install.pipeline
@@ -4617,6 +5014,7 @@ export class Engine {
4617
5014
  * rebind group 0/1 correctly if needed.
4618
5015
  */
4619
5016
  private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, type: DrawCallType): void {
5017
+ if (!this.outlineEnabled) return
4620
5018
  let bound = false
4621
5019
  for (const draw of inst.drawCalls) {
4622
5020
  if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw)) continue
@@ -4647,13 +5045,36 @@ export class Engine {
4647
5045
  // and hairOverEyes (read equal). Non-stencil pipelines ignore the value.
4648
5046
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
4649
5047
 
5048
+ // Order matters (the black-hull saga): transparent color draws don't write
5049
+ // depth (self-overlap must double-blend, not patchwork), so ALL outlines
5050
+ // draw after the transparent depth PREPASS has recorded the fabric's depth
5051
+ // — otherwise every hull behind a sheer skirt shows straight through it.
4650
5052
  this.drawMaterials(pass, inst, "opaque")
4651
- this.drawOutlines(pass, inst, "opaque-outline")
4652
5053
  this.drawHairOverEyes(pass, inst)
4653
5054
  this.drawMaterials(pass, inst, "transparent")
5055
+ this.drawOutlines(pass, inst, "opaque-outline")
4654
5056
  this.drawOutlines(pass, inst, "transparent-outline")
4655
5057
  }
4656
5058
 
5059
+ /** Depth-only re-draw of transparent-bucket materials (see depth-prepass.ts).
5060
+ * Unused while transparent draws write depth themselves (MMD parity) — kept
5061
+ * for the dormant no-write/OIT path. */
5062
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
5063
+ protected drawTransparentDepthPrepass(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5064
+ let bound = false
5065
+ for (const draw of inst.drawCalls) {
5066
+ if (draw.type !== "transparent" || !this.shouldRenderDrawCall(inst, draw)) continue
5067
+ if (!bound) {
5068
+ pass.setPipeline(this.transparentDepthPrepassPipeline)
5069
+ pass.setBindGroup(0, this.perFrameBindGroup)
5070
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
5071
+ bound = true
5072
+ }
5073
+ pass.setBindGroup(2, draw.bindGroup)
5074
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
5075
+ }
5076
+ }
5077
+
4657
5078
  /**
4658
5079
  * Second hair pass for the see-through-hair effect. Re-draws every hair-class grouped
4659
5080
  * opaque draw with its compiled over-eyes pipeline — stencil-matched to `EYE_VALUE`,
@@ -4705,7 +5126,7 @@ export class Engine {
4705
5126
  // is LEFT-HANDED (+Z forward, see Mat4.lookAtInto), so the world-space
4706
5127
  // right/up/FORWARD vectors are rows 0/1/2 of its rotation block directly
4707
5128
  // (column-major storage: row i = values[i], values[i+4], values[i+8]).
4708
- if (this.backdropEquirectView && this.compositeUniformBuffer) {
5129
+ if ((this.backdropEquirectView || this.backgroundEffect) && this.compositeUniformBuffer) {
4709
5130
  const v = viewMatrix.values
4710
5131
  const u = this.compositeUniformData
4711
5132
  const tanHalf = Math.tan((this.camera.fov ?? Math.PI / 4) / 2)
@@ -4722,6 +5143,10 @@ export class Engine {
4722
5143
  u[21] = v[6]
4723
5144
  u[22] = v[10]
4724
5145
  u[23] = 0
5146
+ // Effect clock + canvas size (viewU[6]) — written on the same refresh.
5147
+ u[24] = (performance.now() - this.bgEffectEpochMs) / 1000
5148
+ u[26] = this.canvas.width
5149
+ u[27] = this.canvas.height
4725
5150
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
4726
5151
  }
4727
5152
  }