reze-engine 0.25.0 → 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.
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 {
@@ -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
@@ -354,6 +357,8 @@ interface DrawCall {
354
357
  // Present only for material draw calls (opaque/transparent) — the grouping walk skips
355
358
  // any draw call without it.
356
359
  baseBindGroupEntries?: GPUBindGroupEntry[]
360
+ /** Material draws only: false = excluded from the shadow map (fully sheer). */
361
+ castsShadow?: boolean
357
362
  }
358
363
 
359
364
  interface PickDrawCall {
@@ -448,21 +453,30 @@ function buildAlphaSampler(
448
453
  }
449
454
  }
450
455
 
451
- /** Average texture alpha over ≤400 of the material's triangle centroids
452
- * below the threshold the material renders as a transparent-bucket draw. */
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. */
453
465
  const SHEER_ALPHA_THRESHOLD = 0.7
454
- function materialIsSheer(
466
+ const PARTIAL_TRANSLUCENT_FRAC = 0.15
467
+ function materialAlphaStats(
455
468
  verts: Float32Array,
456
469
  indices: Uint32Array,
457
470
  firstIndex: number,
458
471
  count: number,
459
472
  sampler: { a: Uint8ClampedArray; w: number; h: number } | null | undefined,
460
- ): boolean {
461
- if (!sampler) return false
473
+ ): { avg: number; translucentFrac: number } {
474
+ if (!sampler) return { avg: 1, translucentFrac: 0 }
462
475
  const triCount = Math.floor(count / 3)
463
- if (triCount === 0) return false
476
+ if (triCount === 0) return { avg: 1, translucentFrac: 0 }
464
477
  const step = Math.max(1, Math.floor(triCount / 400))
465
478
  let sum = 0
479
+ let translucent = 0
466
480
  let n = 0
467
481
  for (let t = 0; t < triCount; t += step) {
468
482
  const i0 = indices[firstIndex + t * 3]
@@ -473,10 +487,13 @@ function materialIsSheer(
473
487
  // Wrap (MMD UVs may tile), then nearest-sample the downsampled plane.
474
488
  const x = Math.min(sampler.w - 1, Math.max(0, Math.floor((u - Math.floor(u)) * sampler.w)))
475
489
  const y = Math.min(sampler.h - 1, Math.max(0, Math.floor((v - Math.floor(v)) * sampler.h)))
476
- sum += sampler.a[y * sampler.w + x]
490
+ const a = sampler.a[y * sampler.w + x]
491
+ sum += a
492
+ if (a > 8 && a < 247) translucent++
477
493
  n++
478
494
  }
479
- return n > 0 && sum / n < SHEER_ALPHA_THRESHOLD * 255
495
+ if (n === 0) return { avg: 1, translucentFrac: 0 }
496
+ return { avg: sum / n / 255, translucentFrac: translucent / n }
480
497
  }
481
498
 
482
499
  export class Engine {
@@ -509,6 +526,8 @@ export class Engine {
509
526
  // The one base shading model: ungrouped materials render this (compiled DEFAULT_GRAPH).
510
527
  // Grouped materials use their group's own compiled pipeline.
511
528
  private neutralPipeline!: GPURenderPipeline
529
+ private neutralPipelineNoDepthWrite!: GPURenderPipeline
530
+ private transparentDepthPrepassPipeline!: GPURenderPipeline
512
531
  // ── Style group runtime ──
513
532
  // Shared 256 B zero StyleUniforms buffer (group(2) binding(4)) bound by every ungrouped
514
533
  // material; grouped materials rebind to their group's own buffer (per-model, in the
@@ -897,6 +916,12 @@ export class Engine {
897
916
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
898
917
  }
899
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
+
900
925
  private rebuildCompositeBindGroup(): void {
901
926
  if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView) return
902
927
  this.compositeBindGroup = this.device.createBindGroup({
@@ -1600,6 +1625,44 @@ export class Engine {
1600
1625
  depthCompare: "less-equal",
1601
1626
  },
1602
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
+ })
1603
1666
 
1604
1667
  this.shadowLightVPBuffer = this.device.createBuffer({
1605
1668
  size: 64,
@@ -1737,6 +1800,16 @@ export class Engine {
1737
1800
  // Don’t write outline into depth buffer — stops z-fighting / black cracks vs body (MMD-style; body depth stays authoritative)
1738
1801
  depthWriteEnabled: false,
1739
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,
1740
1813
  // Skip fragments where the eye stamped stencil=EYE_VALUE. Those pixels are owned by
1741
1814
  // the see-through-hair blend (hair-over-eyes), so letting the outline's near-black
1742
1815
  // edge color overwrite them would re-introduce the dark almond we just killed.
@@ -3491,9 +3564,20 @@ export class Engine {
3491
3564
  // casting the solid shadow of an opaque sheet.
3492
3565
  const diffusePath = texLogicalPath(mat.diffuseTextureIndex)
3493
3566
  const alphaSampler = diffusePath ? this.textureAlphaCache.get(diffusePath) : null
3494
- const isTransparent =
3495
- materialAlpha < 1.0 - 0.001 ||
3496
- materialIsSheer(meshVertices, meshIndices, currentIndexOffset, indexCount, alphaSampler)
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
+ )
3497
3581
 
3498
3582
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
3499
3583
  // rare and not implemented — treated as none, like a failed load.
@@ -3540,9 +3624,15 @@ export class Engine {
3540
3624
  materialName: mat.name,
3541
3625
  groupId: null,
3542
3626
  baseBindGroupEntries,
3627
+ castsShadow: !sheer,
3543
3628
  })
3544
3629
 
3545
- 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) {
3546
3636
  const materialUniformData = new Float32Array([
3547
3637
  mat.edgeColor[0],
3548
3638
  mat.edgeColor[1],
@@ -4711,9 +4801,13 @@ export class Engine {
4711
4801
  }
4712
4802
 
4713
4803
  let pipeline: GPURenderPipeline
4804
+ let pipelineNoDepthWrite: GPURenderPipeline
4714
4805
  let overEyesPipeline: GPURenderPipeline | undefined
4715
4806
  try {
4716
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)
4717
4811
  if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true)
4718
4812
  } catch (e) {
4719
4813
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
@@ -4739,6 +4833,7 @@ export class Engine {
4739
4833
  renderClass,
4740
4834
  alphaMode,
4741
4835
  pipeline,
4836
+ pipelineNoDepthWrite,
4742
4837
  overEyesPipeline,
4743
4838
  uniformBuffer,
4744
4839
  slotMap: result.slotMap,
@@ -4802,7 +4897,9 @@ export class Engine {
4802
4897
  inst.drawCalls.sort(
4803
4898
  (a, b) => typeOrder[a.type] - typeOrder[b.type] || this.drawCallRank(inst, a) - this.drawCallRank(inst, b),
4804
4899
  )
4805
- 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
+ )
4806
4903
  }
4807
4904
 
4808
4905
  /**
@@ -4814,6 +4911,7 @@ export class Engine {
4814
4911
  renderClass: RenderClass,
4815
4912
  module: GPUShaderModule,
4816
4913
  overEyes: boolean,
4914
+ depthWrite = true,
4817
4915
  ): Promise<GPURenderPipeline> {
4818
4916
  const base = {
4819
4917
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
@@ -4824,7 +4922,7 @@ export class Engine {
4824
4922
  }
4825
4923
  const plainDepth: GPUDepthStencilState = {
4826
4924
  format: "depth24plus-stencil8",
4827
- depthWriteEnabled: true,
4925
+ depthWriteEnabled: depthWrite,
4828
4926
  depthCompare: "less-equal",
4829
4927
  }
4830
4928
  let depthStencil: GPUDepthStencilState = plainDepth
@@ -4870,6 +4968,12 @@ export class Engine {
4870
4968
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
4871
4969
  // the neutral base (ungrouped materials render the default graph).
4872
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.
4873
4977
  if (dc.groupId) {
4874
4978
  const install = inst.styleGroups.get(dc.groupId)
4875
4979
  if (install) return install.pipeline
@@ -4910,6 +5014,7 @@ export class Engine {
4910
5014
  * rebind group 0/1 correctly if needed.
4911
5015
  */
4912
5016
  private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, type: DrawCallType): void {
5017
+ if (!this.outlineEnabled) return
4913
5018
  let bound = false
4914
5019
  for (const draw of inst.drawCalls) {
4915
5020
  if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw)) continue
@@ -4940,13 +5045,36 @@ export class Engine {
4940
5045
  // and hairOverEyes (read equal). Non-stencil pipelines ignore the value.
4941
5046
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
4942
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.
4943
5052
  this.drawMaterials(pass, inst, "opaque")
4944
- this.drawOutlines(pass, inst, "opaque-outline")
4945
5053
  this.drawHairOverEyes(pass, inst)
4946
5054
  this.drawMaterials(pass, inst, "transparent")
5055
+ this.drawOutlines(pass, inst, "opaque-outline")
4947
5056
  this.drawOutlines(pass, inst, "transparent-outline")
4948
5057
  }
4949
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
+
4950
5078
  /**
4951
5079
  * Second hair pass for the see-through-hair effect. Re-draws every hair-class grouped
4952
5080
  * opaque draw with its compiled over-eyes pipeline — stencil-matched to `EYE_VALUE`,
@@ -76,14 +76,23 @@ function prelude(renderClass: RenderClass, alphaMode: AlphaMode): string {
76
76
  ? " if (alpha < hashed_alpha_threshold(input.restPos)) { discard; }"
77
77
  : " if (alpha < 0.001) { discard; }"
78
78
  const gate = renderClass === "eye" ? EYE_REAR_GATE : ""
79
+ // Double-sided shading, winding-independent: a normal pointing away from the
80
+ // camera means we're seeing the surface's other side — flip it. Only genuinely
81
+ // camera-averted fragments change (front surfaces have dot(n,v) > 0 and are
82
+ // untouched), unlike a front_facing test, which PMX's clockwise winding
83
+ // inverts. Fixes inner skirt linings (裙子白1-style flipped-normal copies)
84
+ // shading near-black through sheer outer layers. Eye is exempt: it FRONT-culls
85
+ // by design, so its visible fragments are back faces with intended normals.
86
+ const flip =
87
+ renderClass === "eye" ? "" : "\n n = select(-n, n, dot(n, v) >= 0.0);"
79
88
  return `@fragment fn fs(input: VertexOutput) -> FSOut {
80
89
  let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);
81
90
  // MMD alpha semantics: material alpha × texture alpha.
82
91
  let alpha = material.alpha * tex_s.a;
83
92
  ${discard}
84
93
 
85
- let n = safe_normal(input.normal);
86
- let v = normalize(camera.viewPos - input.worldPos);
94
+ var n = safe_normal(input.normal);
95
+ let v = normalize(camera.viewPos - input.worldPos);${flip}
87
96
  ${gate}
88
97
  let l = -light.lights[0].direction.xyz;
89
98
  let sun = light.lights[0].color.xyz * light.lights[0].color.w;