reze-engine 0.25.0 → 0.25.2

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 — dormant, kept for a future OIT path. */
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,12 @@ 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
362
+ /** Edge-flagged materials: interleaved inverted-hull outline drawn right after
363
+ * this material with the outline pipeline. Shares this call's index range;
364
+ * own bind group (edge uniforms + diffuse texture for the alpha test). */
365
+ outline?: { bindGroup: GPUBindGroup }
357
366
  }
358
367
 
359
368
  interface PickDrawCall {
@@ -448,21 +457,26 @@ function buildAlphaSampler(
448
457
  }
449
458
  }
450
459
 
451
- /** Average texture alpha over ≤400 of the material's triangle centroids
452
- * below the threshold the material renders as a transparent-bucket draw. */
460
+ /** Texture-alpha statistics over ≤400 of the material's triangle centroids:
461
+ * `avg` (0..1) and `translucentFrac` the fraction of samples that are
462
+ * neither fully opaque nor fully cut out (alpha in ~0.03..0.97). Together
463
+ * Bucketing itself is binary (babylon-mmd parity): ANY translucent coverage
464
+ * routes to the alpha-blend bucket. `avg` below this threshold additionally
465
+ * marks a material as fully sheer (a veil), which vetoes shadow casting. */
453
466
  const SHEER_ALPHA_THRESHOLD = 0.7
454
- function materialIsSheer(
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,17 @@ 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
+ // OFF by default — the product aesthetic. Modern high-detail models read
921
+ // better without hulls (babylon-mmd's own demos disable its outline renderer
922
+ // too), and no hull pass means no depth-tie edge cases against near-coplanar
923
+ // cloth. The full MMD-faithful machinery (interleaved per-material hulls,
924
+ // texture-alpha-modulated rims) stays in place behind setOutlineEnabled(true).
925
+ private outlineEnabled = false
926
+ setOutlineEnabled(on: boolean): void {
927
+ this.outlineEnabled = on
928
+ }
929
+
900
930
  private rebuildCompositeBindGroup(): void {
901
931
  if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView) return
902
932
  this.compositeBindGroup = this.device.createBindGroup({
@@ -1468,6 +1498,8 @@ export class Engine {
1468
1498
  attributes: [
1469
1499
  { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
1470
1500
  { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
1501
+ // uv — the outline FS alpha-tests the diffuse texture (babylon-mmd parity)
1502
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
1471
1503
  ],
1472
1504
  },
1473
1505
  {
@@ -1600,6 +1632,44 @@ export class Engine {
1600
1632
  depthCompare: "less-equal",
1601
1633
  },
1602
1634
  })
1635
+ // Depth-write-off twin for transparent-bucket draws (see pipelineForDrawCall).
1636
+ this.neutralPipelineNoDepthWrite = this.createRenderPipeline({
1637
+ label: "neutral base pipeline (no depth write)",
1638
+ layout: mainPipelineLayout,
1639
+ shaderModule: neutralModule,
1640
+ vertexBuffers: fullVertexBuffers,
1641
+ fragmentTargets: sceneTargets,
1642
+ cullMode: "none",
1643
+ depthStencil: {
1644
+ format: "depth24plus-stencil8",
1645
+ depthWriteEnabled: false,
1646
+ depthCompare: "less-equal",
1647
+ },
1648
+ })
1649
+ // Depth-only prepass for transparent draws (see depth-prepass.ts): writes the
1650
+ // fabric's depth AFTER its color blended, so outlines drawn later are
1651
+ // occluded behind it. Color targets kept for pass compatibility, writeMask 0.
1652
+ const prepassModule = this.device.createShaderModule({
1653
+ label: "transparent depth prepass",
1654
+ code: TRANSPARENT_DEPTH_PREPASS_WGSL,
1655
+ })
1656
+ this.transparentDepthPrepassPipeline = this.device.createRenderPipeline({
1657
+ label: "transparent depth prepass",
1658
+ layout: mainPipelineLayout,
1659
+ vertex: { module: prepassModule, entryPoint: "vs", buffers: fullVertexBuffers as GPUVertexBufferLayout[] },
1660
+ fragment: {
1661
+ module: prepassModule,
1662
+ entryPoint: "fs",
1663
+ targets: sceneTargets.map((t) => ({ format: (t as GPUColorTargetState).format, writeMask: 0 })),
1664
+ },
1665
+ primitive: { cullMode: "none" },
1666
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
1667
+ depthStencil: {
1668
+ format: "depth24plus-stencil8",
1669
+ depthWriteEnabled: true,
1670
+ depthCompare: "less-equal",
1671
+ },
1672
+ })
1603
1673
 
1604
1674
  this.shadowLightVPBuffer = this.device.createBuffer({
1605
1675
  size: 64,
@@ -1695,6 +1765,7 @@ export class Engine {
1695
1765
  label: "outline per-frame bind group layout",
1696
1766
  entries: [
1697
1767
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1768
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
1698
1769
  ],
1699
1770
  })
1700
1771
  // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
@@ -1702,6 +1773,7 @@ export class Engine {
1702
1773
  label: "outline per-material bind group layout",
1703
1774
  entries: [
1704
1775
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1776
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1705
1777
  ],
1706
1778
  })
1707
1779
 
@@ -1717,7 +1789,10 @@ export class Engine {
1717
1789
  this.outlinePerFrameBindGroup = this.device.createBindGroup({
1718
1790
  label: "outline per-frame bind group",
1719
1791
  layout: this.outlinePerFrameBindGroupLayout,
1720
- entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
1792
+ entries: [
1793
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1794
+ { binding: 1, resource: this.materialSampler },
1795
+ ],
1721
1796
  })
1722
1797
 
1723
1798
  const outlineShaderModule = this.device.createShaderModule({
@@ -1734,9 +1809,21 @@ export class Engine {
1734
1809
  cullMode: "back",
1735
1810
  depthStencil: {
1736
1811
  format: "depth24plus-stencil8",
1737
- // Don’t write outline into depth buffer — stops z-fighting / black cracks vs body (MMD-style; body depth stays authoritative)
1738
- depthWriteEnabled: false,
1812
+ // babylon-mmd draws outlines WITH depth write (its _afterRenderingMesh
1813
+ // forces setDepthWrite(true)); the constant bias below still makes
1814
+ // hulls lose depth ties against their own near-coplanar geometry.
1815
+ depthWriteEnabled: true,
1739
1816
  depthCompare: "less-equal",
1817
+ // CONFIRMED fix (bisected live via setOutlineEnabled): hull fragments
1818
+ // carry their surface's exact depth, so against this model's paired
1819
+ // near-coplanar skirt layers the hulls WON depth ties in patches —
1820
+ // the black shapes on the dress. A small constant bias makes hulls lose
1821
+ // every tie; silhouette rims compare against the far background and are
1822
+ // unaffected. No slope term — slope explodes at silhouettes and would
1823
+ // erase the rims themselves (previous regression).
1824
+ depthBias: 4,
1825
+ depthBiasSlopeScale: 0,
1826
+ depthBiasClamp: 0,
1740
1827
  // Skip fragments where the eye stamped stencil=EYE_VALUE. Those pixels are owned by
1741
1828
  // the see-through-hair blend (hair-over-eyes), so letting the outline's near-black
1742
1829
  // edge color overwrite them would re-introduce the dark almond we just killed.
@@ -3482,18 +3569,27 @@ export class Engine {
3482
3569
  }
3483
3570
 
3484
3571
  const materialAlpha = mat.diffuse[3]
3485
- // Transparent bucket when the MATERIAL says so — or when the TEXTURE does
3486
- // (sheer cloth almost always ships with diffuse alpha 1.0 and carries its
3487
- // translucency in texture alpha). Transparent-bucket draws happen after
3488
- // the opaque bucket (and after the late-drawn hair render-class), so a
3489
- // veil composites over the hair behind it instead of depth-rejecting it;
3490
- // they are also excluded from the shadow map, so sheer cloth stops
3491
- // casting the solid shadow of an opaque sheet.
3492
3572
  const diffusePath = texLogicalPath(mat.diffuseTextureIndex)
3493
3573
  const alphaSampler = diffusePath ? this.textureAlphaCache.get(diffusePath) : null
3494
- const isTransparent =
3495
- materialAlpha < 1.0 - 0.001 ||
3496
- materialIsSheer(meshVertices, meshIndices, currentIndexOffset, indexCount, alphaSampler)
3574
+ const stats = materialAlphaStats(meshVertices, meshIndices, currentIndexOffset, indexCount, alphaSampler)
3575
+ // babylon-mmd parity (its default DepthWriteAlphaBlendingWithEvaluation
3576
+ // method): the bucket decision is BINARY. A material with ANY translucent
3577
+ // texels on its geometry is alpha-blend — drawn in PMX author order with
3578
+ // depth write ON (forceDepthWrite); everything else is opaque. The old
3579
+ // avg/frac tier system left mostly-opaque lace (translucentFrac 0.09) in
3580
+ // the opaque bucket while its sibling panels went transparent, breaking
3581
+ // the author's compositing order — the gray fold patches. The 2% floor
3582
+ // only guards against centroid-sampling noise on genuinely solid cloth.
3583
+ const sheer = stats.avg < SHEER_ALPHA_THRESHOLD
3584
+ const isTransparent = materialAlpha < 1.0 - 0.001 || sheer || stats.translucentFrac > 0.02
3585
+ // Shadow casting: the PMX author's own flag (bit 0x04, cast self-shadow),
3586
+ // still vetoed for fully sheer cloth — a veil must not cast a solid sheet.
3587
+ const castsShadow = (mat.edgeFlag & 0x04) !== 0 && !sheer
3588
+ // Load-time classification log — one line per material, cheap and
3589
+ // invaluable when a model renders wrong (bucket/outline/shadow disputes).
3590
+ console.info(
3591
+ `[reze] ${mat.name}: alpha=${materialAlpha.toFixed(2)} avg=${stats.avg.toFixed(2)} translucentFrac=${stats.translucentFrac.toFixed(2)} bucket=${isTransparent ? "transparent" : "opaque"} castsShadow=${castsShadow} edge=${(mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0 ? "on" : "off"}`,
3592
+ )
3497
3593
 
3498
3594
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
3499
3595
  // rare and not implemented — treated as none, like a failed load.
@@ -3531,17 +3627,12 @@ export class Engine {
3531
3627
  this.zeroStyleBuffer,
3532
3628
  )
3533
3629
 
3534
- const type: DrawCallType = isTransparent ? "transparent" : "opaque"
3535
- inst.drawCalls.push({
3536
- type,
3537
- count: indexCount,
3538
- firstIndex: currentIndexOffset,
3539
- bindGroup,
3540
- materialName: mat.name,
3541
- groupId: null,
3542
- baseBindGroupEntries,
3543
- })
3544
-
3630
+ // Inverted-hull outline for EVERY edge-flagged material (PMX bit 0x10) —
3631
+ // the outline FS alpha-tests the diffuse texture, so sheer fabric masks
3632
+ // its own hull where it is see-through instead of us skipping it here.
3633
+ // Drawn interleaved right after this material's color draw (babylon-mmd's
3634
+ // per-mesh afterRender outline stage) — see drawMaterials.
3635
+ let outline: DrawCall["outline"]
3545
3636
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
3546
3637
  const materialUniformData = new Float32Array([
3547
3638
  mat.edgeColor[0],
@@ -3558,19 +3649,27 @@ export class Engine {
3558
3649
  const outlineBindGroup = this.device.createBindGroup({
3559
3650
  label: `${prefix}outline: ${mat.name}`,
3560
3651
  layout: this.outlinePerMaterialBindGroupLayout,
3561
- entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
3562
- })
3563
- const outlineType: DrawCallType = isTransparent ? "transparent-outline" : "opaque-outline"
3564
- inst.drawCalls.push({
3565
- type: outlineType,
3566
- count: indexCount,
3567
- firstIndex: currentIndexOffset,
3568
- bindGroup: outlineBindGroup,
3569
- materialName: mat.name,
3570
- groupId: null,
3652
+ entries: [
3653
+ { binding: 0, resource: { buffer: outlineUniformBuffer } },
3654
+ { binding: 1, resource: textureView },
3655
+ ],
3571
3656
  })
3657
+ outline = { bindGroup: outlineBindGroup }
3572
3658
  }
3573
3659
 
3660
+ const type: DrawCallType = isTransparent ? "transparent" : "opaque"
3661
+ inst.drawCalls.push({
3662
+ type,
3663
+ count: indexCount,
3664
+ firstIndex: currentIndexOffset,
3665
+ bindGroup,
3666
+ materialName: mat.name,
3667
+ groupId: null,
3668
+ baseBindGroupEntries,
3669
+ castsShadow,
3670
+ outline,
3671
+ })
3672
+
3574
3673
  if (this.onRaycast) {
3575
3674
  const pickIdData = new Float32Array([modelId, materialId, 0, 0])
3576
3675
  const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData)
@@ -4419,11 +4518,22 @@ export class Engine {
4419
4518
  }
4420
4519
 
4421
4520
  const pass = encoder.beginRenderPass(this.renderPassDescriptor)
4521
+ // Phase order: opaque models → ground → transparent fabric.
4522
+ // The ground shader is the most expensive full-coverage draw in the frame
4523
+ // (9-tap PCF on the 4096² shadow map per pixel), so it draws AFTER the
4524
+ // opaque phase to get early-z rejected behind the body — drawing it first
4525
+ // shaded every covered pixel and measurably dropped Safari fps. It still
4526
+ // draws BEFORE the transparent phase so sheer fabric blends over the floor
4527
+ // instead of over the background with the floor depth-rejected behind it.
4422
4528
  if (hasModels)
4423
4529
  this.forEachInstance((inst) => {
4424
- if (inst.model.visible) this.renderOneModel(pass, inst)
4530
+ if (inst.model.visible) this.renderModelOpaquePhase(pass, inst)
4425
4531
  })
4426
4532
  if (this.hasGround) this.renderGround(pass)
4533
+ if (hasModels)
4534
+ this.forEachInstance((inst) => {
4535
+ if (inst.model.visible) this.renderModelTransparentPhase(pass, inst)
4536
+ })
4427
4537
  pass.end()
4428
4538
 
4429
4539
  // Bloom pyramid (EEVEE 3.6):
@@ -4711,9 +4821,12 @@ export class Engine {
4711
4821
  }
4712
4822
 
4713
4823
  let pipeline: GPURenderPipeline
4824
+ let pipelineNoDepthWrite: GPURenderPipeline
4714
4825
  let overEyesPipeline: GPURenderPipeline | undefined
4715
4826
  try {
4716
4827
  pipeline = await this.createRenderClassPipeline(renderClass, module, false)
4828
+ // Dormant OIT twin — kept for a future order-independent-transparency path.
4829
+ pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false)
4717
4830
  if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true)
4718
4831
  } catch (e) {
4719
4832
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
@@ -4739,6 +4852,7 @@ export class Engine {
4739
4852
  renderClass,
4740
4853
  alphaMode,
4741
4854
  pipeline,
4855
+ pipelineNoDepthWrite,
4742
4856
  overEyesPipeline,
4743
4857
  uniformBuffer,
4744
4858
  slotMap: result.slotMap,
@@ -4802,7 +4916,9 @@ export class Engine {
4802
4916
  inst.drawCalls.sort(
4803
4917
  (a, b) => typeOrder[a.type] - typeOrder[b.type] || this.drawCallRank(inst, a) - this.drawCallRank(inst, b),
4804
4918
  )
4805
- inst.shadowDrawCalls = inst.drawCalls.filter((d) => d.type === "opaque")
4919
+ inst.shadowDrawCalls = inst.drawCalls.filter(
4920
+ (d) => (d.type === "opaque" || d.type === "transparent") && d.castsShadow === true,
4921
+ )
4806
4922
  }
4807
4923
 
4808
4924
  /**
@@ -4814,6 +4930,7 @@ export class Engine {
4814
4930
  renderClass: RenderClass,
4815
4931
  module: GPUShaderModule,
4816
4932
  overEyes: boolean,
4933
+ depthWrite = true,
4817
4934
  ): Promise<GPURenderPipeline> {
4818
4935
  const base = {
4819
4936
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
@@ -4824,7 +4941,7 @@ export class Engine {
4824
4941
  }
4825
4942
  const plainDepth: GPUDepthStencilState = {
4826
4943
  format: "depth24plus-stencil8",
4827
- depthWriteEnabled: true,
4944
+ depthWriteEnabled: depthWrite,
4828
4945
  depthCompare: "less-equal",
4829
4946
  }
4830
4947
  let depthStencil: GPUDepthStencilState = plainDepth
@@ -4868,7 +4985,9 @@ export class Engine {
4868
4985
  }
4869
4986
 
4870
4987
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
4871
- // the neutral base (ungrouped materials render the default graph).
4988
+ // the neutral base (ungrouped materials render the default graph). Transparent-bucket
4989
+ // draws use the SAME depth-write-on pipeline — babylon-mmd's forceDepthWrite
4990
+ // blending (see renderModelTransparentPhase for the trade-off record).
4872
4991
  private pipelineForDrawCall(inst: ModelInstance, dc: DrawCall): GPURenderPipeline {
4873
4992
  if (dc.groupId) {
4874
4993
  const install = inst.styleGroups.get(dc.groupId)
@@ -4879,9 +4998,10 @@ export class Engine {
4879
4998
 
4880
4999
  /**
4881
5000
  * Draw every material of a given type (`opaque` or `transparent`) using the main
4882
- * pipeline(s). Binds the per-frame and per-instance groups once at the top of the
4883
- * batch, then issues one draw per material. Early-outs if nothing to draw so we
4884
- * don't waste bindings when a model has no transparents, etc.
5001
+ * pipeline(s), and babylon-mmd's per-mesh outline stage each edge-flagged
5002
+ * material's inverted hull IMMEDIATELY after its color draw. Interleaving is what
5003
+ * makes outlines compose like MMD: every material drawn later in the author's
5004
+ * order covers earlier hulls, and each hull sits over everything drawn before it.
4885
5005
  */
4886
5006
  private drawMaterials(pass: GPURenderPassEncoder, inst: ModelInstance, type: "opaque" | "transparent"): void {
4887
5007
  let currentPipeline: GPURenderPipeline | null = null
@@ -4900,51 +5020,72 @@ export class Engine {
4900
5020
  }
4901
5021
  pass.setBindGroup(2, draw.bindGroup)
4902
5022
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
4903
- }
4904
- }
4905
-
4906
- /**
4907
- * Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
4908
- * Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
4909
- * every batch binds its own groups from scratch — the next drawMaterials call will
4910
- * rebind group 0/1 correctly if needed.
4911
- */
4912
- private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, type: DrawCallType): void {
4913
- let bound = false
4914
- for (const draw of inst.drawCalls) {
4915
- if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw)) continue
4916
- if (!bound) {
5023
+ if (draw.outline && this.outlineEnabled) {
5024
+ // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
5025
+ // layout-identical between the main and outline pipelines and stays
5026
+ // bound. Restore group 0 afterwards and force a pipeline re-set.
4917
5027
  pass.setPipeline(this.outlinePipeline)
4918
5028
  pass.setBindGroup(0, this.outlinePerFrameBindGroup)
4919
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
4920
- bound = true
5029
+ pass.setBindGroup(2, draw.outline.bindGroup)
5030
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
5031
+ pass.setBindGroup(0, this.perFrameBindGroup)
5032
+ currentPipeline = null
4921
5033
  }
4922
- pass.setBindGroup(2, draw.bindGroup)
4923
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
4924
5034
  }
4925
5035
  }
4926
5036
 
4927
5037
  /**
4928
- * Main-pass render sequence for one model instance:
4929
- * 1) opaque bodies 2) opaque outlines 3) transparents → 4) transparent outlines.
4930
- * Each batch binds the groups it needs, so switching between main and outline
4931
- * pipelines is self-contained (no cross-batch dependencies).
5038
+ * Main-pass render sequence for one model instance — babylon-mmd parity:
5039
+ * opaque bucket, the hair-over-eyes stencil pass, then alpha-blend materials
5040
+ * in PMX author order with depth write ON (forceDepthWrite). Outlines are not
5041
+ * a separate phase: drawMaterials draws each edge-flagged material's hull
5042
+ * right after the material itself, like MMD's per-mesh outline stage.
4932
5043
  */
4933
- private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5044
+ private setModelDrawState(pass: GPURenderPassEncoder, inst: ModelInstance): void {
4934
5045
  pass.setVertexBuffer(0, inst.vertexBuffer)
4935
5046
  pass.setVertexBuffer(1, inst.jointsBuffer)
4936
5047
  pass.setVertexBuffer(2, inst.weightsBuffer)
4937
5048
  pass.setIndexBuffer(inst.indexBuffer, "uint32")
4938
-
4939
5049
  // Single stencil-reference set covers eye (write), hair (read not-equal),
4940
5050
  // and hairOverEyes (read equal). Non-stencil pipelines ignore the value.
4941
5051
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
5052
+ }
4942
5053
 
5054
+ private renderModelOpaquePhase(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5055
+ this.setModelDrawState(pass, inst)
4943
5056
  this.drawMaterials(pass, inst, "opaque")
4944
- this.drawOutlines(pass, inst, "opaque-outline")
4945
5057
  this.drawHairOverEyes(pass, inst)
5058
+ }
5059
+
5060
+ private renderModelTransparentPhase(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5061
+ this.setModelDrawState(pass, inst)
5062
+ // Transparent: babylon-mmd's forceDepthWrite blending — PMX author order
5063
+ // with depth write ON. The accepted trade-off after trying every variant:
5064
+ // · depth-write ON (this): a fold hides its far side; rare view-dependent
5065
+ // double-blend seams at some angles. MMD's own known behavior.
5066
+ // · nearest-surface prepass: view-independent, but punched see-through
5067
+ // holes to whatever sat far behind a fold.
5068
+ // · depth-write OFF layering: every overlap visible everywhere — MORE
5069
+ // gray patches and texture artifacts in practice.
4946
5070
  this.drawMaterials(pass, inst, "transparent")
4947
- this.drawOutlines(pass, inst, "transparent-outline")
5071
+ }
5072
+
5073
+ /** Depth-only re-draw of transparent-bucket materials (see depth-prepass.ts).
5074
+ * Dormant — kept for a future order-independent-transparency path. */
5075
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
5076
+ protected drawTransparentDepthPrepass(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5077
+ let bound = false
5078
+ for (const draw of inst.drawCalls) {
5079
+ if (draw.type !== "transparent" || !this.shouldRenderDrawCall(inst, draw)) continue
5080
+ if (!bound) {
5081
+ pass.setPipeline(this.transparentDepthPrepassPipeline)
5082
+ pass.setBindGroup(0, this.perFrameBindGroup)
5083
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
5084
+ bound = true
5085
+ }
5086
+ pass.setBindGroup(2, draw.bindGroup)
5087
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
5088
+ }
4948
5089
  }
4949
5090
 
4950
5091
  /**
@@ -4991,6 +5132,10 @@ export class Engine {
4991
5132
  this.cameraMatrixData[32] = cameraPos.x
4992
5133
  this.cameraMatrixData[33] = cameraPos.y
4993
5134
  this.cameraMatrixData[34] = cameraPos.z
5135
+ // Spare float after viewPos: render-target height in device px — the outline
5136
+ // shader derives the full viewport (width via projection aspect) for its
5137
+ // babylon-mmd constant-pixel edge extrusion.
5138
+ this.cameraMatrixData[35] = this.canvas.height
4994
5139
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
4995
5140
 
4996
5141
  // 360 backdrop: the composite reconstructs each pixel's view ray from the
@@ -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;