reze-engine 0.25.1 → 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.
@@ -1,11 +1,24 @@
1
- // MMD-style screen-space outline via normal-extrusion in clip space.
2
- // Aspect-compensated so pixel thickness stays stable across viewport sizes.
1
+ // MMD-style inverted-hull outline, ported from babylon-mmd's mmdOutline shader
2
+ // (the reference implementation whose output matches MMD):
3
+ // 1. Extrude along the VIEW-SPACE normal's XY, normalized — a pure screen
4
+ // direction, so rims never smear toward the camera at grazing angles.
5
+ // 2. Offset in clip space by edgeSize · 4/viewport · w. The ×w cancels the
6
+ // perspective divide → CONSTANT screen thickness of exactly
7
+ // 2·edgeSize device pixels (babylon: `screenNormal / (viewport*0.25) *
8
+ // offset * projectedPosition.w`). PMX edgeSize ~0.3–1.0 ⇒ fine 0.6–2px
9
+ // rims, matching MMD instead of our former chunky constants.
10
+ // 3. The FRAGMENT stage samples the material's own diffuse texture and
11
+ // MODULATES the rim's alpha by it (discarding only near-zero cut-out
12
+ // margins) — sheer fabric gets a proportional rim, never a solid black
13
+ // hull, without dropping the author's edge flag.
3
14
  export const OUTLINE_SHADER_WGSL = /* wgsl */ `
4
15
  struct CameraUniforms {
5
16
  view: mat4x4f,
6
17
  projection: mat4x4f,
7
18
  viewPos: vec3f,
8
- _padding: f32,
19
+ // Render-target height in device pixels (engine writes it each frame);
20
+ // width is recovered via the projection matrix's aspect.
21
+ viewportHeight: f32,
9
22
  };
10
23
 
11
24
  struct MaterialUniforms {
@@ -17,16 +30,20 @@ struct MaterialUniforms {
17
30
  };
18
31
 
19
32
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
33
+ @group(0) @binding(1) var edgeSampler: sampler;
20
34
  @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
21
35
  @group(2) @binding(0) var<uniform> material: MaterialUniforms;
36
+ @group(2) @binding(1) var diffuseTexture: texture_2d<f32>;
22
37
 
23
38
  struct VertexOutput {
24
39
  @builtin(position) position: vec4f,
40
+ @location(0) uv: vec2f,
25
41
  };
26
42
 
27
43
  @vertex fn vs(
28
44
  @location(0) position: vec3f,
29
45
  @location(1) normal: vec3f,
46
+ @location(2) uv: vec2f,
30
47
  @location(3) joints0: vec4<u32>,
31
48
  @location(4) weights0: vec4<f32>
32
49
  ) -> VertexOutput {
@@ -50,33 +67,43 @@ struct VertexOutput {
50
67
  let worldPos = skinnedPos.xyz;
51
68
  let worldNormal = normalize(skinnedNrm);
52
69
 
53
- // Screen-space outline extrusion MMD-style pixel-stable edge line.
54
- // 1. Project position and normal-as-direction to clip space.
55
- // 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
56
- // matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
57
- // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
58
- // so the perspective divide cancels out offset stays constant in NDC regardless
59
- // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
60
- // 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
61
- // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
62
- let viewProj = camera.projection * camera.view;
63
- let clipPos = viewProj * vec4f(worldPos, 1.0);
64
- let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
65
- // projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
66
- // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
70
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
71
+
72
+ // babylon-mmd: screenNormal = normalize((view * worldNormal).xy)
73
+ let viewNormal = (camera.view * vec4f(worldNormal, 0.0)).xyz;
74
+ let snLen = length(viewNormal.xy);
75
+ let screenNormal = select(vec2f(0.0, 0.0), viewNormal.xy / snLen, snLen > 1e-5);
76
+
77
+ // Reference-height normalization (babylon-mmd ships this variant commented
78
+ // out as \`renderHeight = 1080\`): thickness is a constant FRACTION of the
79
+ // frame 2·edgeSize px at 1080p — so retina DPR and 4K export don't thin
80
+ // the rims to sub-pixel. Width follows the projection aspect.
81
+ // projection[1][1]/projection[0][0] = width/height for a symmetric frustum.
67
82
  let aspect = camera.projection[1][1] / camera.projection[0][0];
68
- let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
69
- let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
70
- let edgeScale = 0.0016;
71
- let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
83
+ let viewport = vec2f(1080.0 * aspect, 1080.0);
84
+
85
+ // NDC offset = edgeSize · 4/viewport, ×w so the perspective divide cancels:
86
+ // constant screen thickness at any distance (babylon-mmd parity).
87
+ let offset = screenNormal * (material.edgeSize * 4.0 / viewport) * clipPos.w;
72
88
  output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
89
+ output.uv = uv;
73
90
  return output;
74
91
  }
75
92
 
76
93
  struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
77
- @fragment fn fs() -> FSOut {
94
+ @fragment fn fs(input: VertexOutput) -> FSOut {
95
+ // Rim alpha FOLLOWS the fabric's texture alpha instead of a hard alpha test:
96
+ // MMD draws blend-material edges solid (only cutout materials alpha-test), so
97
+ // a 0.4 discard erased the whole hull on semi-transparent cloth — stockinged
98
+ // legs crossing lost their outline entirely. Modulating instead keeps a
99
+ // proportional rim on sheer weave (never a solid black hull) and still
100
+ // discards true cut-out margins like hair-card borders.
101
+ let texA = textureSample(diffuseTexture, edgeSampler, input.uv).a;
102
+ if (texA < 0.05) {
103
+ discard;
104
+ }
78
105
  var out: FSOut;
79
- out.color = material.edgeColor;
106
+ out.color = vec4f(material.edgeColor.rgb, material.edgeColor.a * texA);
80
107
  out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
81
108
  return out;
82
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.25.1",
3
+ "version": "0.25.2",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -173,7 +173,7 @@ type GroupInstall = {
173
173
  renderClass: RenderClass
174
174
  alphaMode: AlphaMode
175
175
  pipeline: GPURenderPipeline
176
- /** Depth-write-off twin, used when this group's material draws in the transparent bucket. */
176
+ /** Depth-write-off twin dormant, kept for a future OIT path. */
177
177
  pipelineNoDepthWrite: GPURenderPipeline
178
178
  /** hair render-class only: the stencil-matched IS_OVER_EYES=true variant. */
179
179
  overEyesPipeline?: GPURenderPipeline
@@ -359,6 +359,10 @@ interface DrawCall {
359
359
  baseBindGroupEntries?: GPUBindGroupEntry[]
360
360
  /** Material draws only: false = excluded from the shadow map (fully sheer). */
361
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 }
362
366
  }
363
367
 
364
368
  interface PickDrawCall {
@@ -456,14 +460,10 @@ function buildAlphaSampler(
456
460
  /** Texture-alpha statistics over ≤400 of the material's triangle centroids:
457
461
  * `avg` (0..1) and `translucentFrac` — the fraction of samples that are
458
462
  * 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. */
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. */
465
466
  const SHEER_ALPHA_THRESHOLD = 0.7
466
- const PARTIAL_TRANSLUCENT_FRAC = 0.15
467
467
  function materialAlphaStats(
468
468
  verts: Float32Array,
469
469
  indices: Uint32Array,
@@ -917,7 +917,12 @@ export class Engine {
917
917
  }
918
918
 
919
919
  /** Debug/diagnostic: skip every inverted-hull outline draw. */
920
- private outlineEnabled = true
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
921
926
  setOutlineEnabled(on: boolean): void {
922
927
  this.outlineEnabled = on
923
928
  }
@@ -1493,6 +1498,8 @@ export class Engine {
1493
1498
  attributes: [
1494
1499
  { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
1495
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 },
1496
1503
  ],
1497
1504
  },
1498
1505
  {
@@ -1758,6 +1765,7 @@ export class Engine {
1758
1765
  label: "outline per-frame bind group layout",
1759
1766
  entries: [
1760
1767
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1768
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
1761
1769
  ],
1762
1770
  })
1763
1771
  // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
@@ -1765,6 +1773,7 @@ export class Engine {
1765
1773
  label: "outline per-material bind group layout",
1766
1774
  entries: [
1767
1775
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1776
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1768
1777
  ],
1769
1778
  })
1770
1779
 
@@ -1780,7 +1789,10 @@ export class Engine {
1780
1789
  this.outlinePerFrameBindGroup = this.device.createBindGroup({
1781
1790
  label: "outline per-frame bind group",
1782
1791
  layout: this.outlinePerFrameBindGroupLayout,
1783
- entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
1792
+ entries: [
1793
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1794
+ { binding: 1, resource: this.materialSampler },
1795
+ ],
1784
1796
  })
1785
1797
 
1786
1798
  const outlineShaderModule = this.device.createShaderModule({
@@ -1797,8 +1809,10 @@ export class Engine {
1797
1809
  cullMode: "back",
1798
1810
  depthStencil: {
1799
1811
  format: "depth24plus-stencil8",
1800
- // Don’t write outline into depth buffer — stops z-fighting / black cracks vs body (MMD-style; body depth stays authoritative)
1801
- 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,
1802
1816
  depthCompare: "less-equal",
1803
1817
  // CONFIRMED fix (bisected live via setOutlineEnabled): hull fragments
1804
1818
  // carry their surface's exact depth, so against this model's paired
@@ -3555,28 +3569,26 @@ export class Engine {
3555
3569
  }
3556
3570
 
3557
3571
  const materialAlpha = mat.diffuse[3]
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
3572
  const diffusePath = texLogicalPath(mat.diffuseTextureIndex)
3566
3573
  const alphaSampler = diffusePath ? this.textureAlphaCache.get(diffusePath) : null
3567
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.
3568
3583
  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
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
3576
3588
  // Load-time classification log — one line per material, cheap and
3577
- // invaluable when a model renders wrong (bucket/outline/sheer disputes).
3589
+ // invaluable when a model renders wrong (bucket/outline/shadow disputes).
3578
3590
  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"}`,
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"}`,
3580
3592
  )
3581
3593
 
3582
3594
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
@@ -3615,24 +3627,13 @@ export class Engine {
3615
3627
  this.zeroStyleBuffer,
3616
3628
  )
3617
3629
 
3618
- const type: DrawCallType = isTransparent ? "transparent" : "opaque"
3619
- inst.drawCalls.push({
3620
- type,
3621
- count: indexCount,
3622
- firstIndex: currentIndexOffset,
3623
- bindGroup,
3624
- materialName: mat.name,
3625
- groupId: null,
3626
- baseBindGroupEntries,
3627
- castsShadow: !sheer,
3628
- })
3629
-
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) {
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"]
3636
+ if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
3636
3637
  const materialUniformData = new Float32Array([
3637
3638
  mat.edgeColor[0],
3638
3639
  mat.edgeColor[1],
@@ -3648,19 +3649,27 @@ export class Engine {
3648
3649
  const outlineBindGroup = this.device.createBindGroup({
3649
3650
  label: `${prefix}outline: ${mat.name}`,
3650
3651
  layout: this.outlinePerMaterialBindGroupLayout,
3651
- entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
3652
- })
3653
- const outlineType: DrawCallType = isTransparent ? "transparent-outline" : "opaque-outline"
3654
- inst.drawCalls.push({
3655
- type: outlineType,
3656
- count: indexCount,
3657
- firstIndex: currentIndexOffset,
3658
- bindGroup: outlineBindGroup,
3659
- materialName: mat.name,
3660
- groupId: null,
3652
+ entries: [
3653
+ { binding: 0, resource: { buffer: outlineUniformBuffer } },
3654
+ { binding: 1, resource: textureView },
3655
+ ],
3661
3656
  })
3657
+ outline = { bindGroup: outlineBindGroup }
3662
3658
  }
3663
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
+
3664
3673
  if (this.onRaycast) {
3665
3674
  const pickIdData = new Float32Array([modelId, materialId, 0, 0])
3666
3675
  const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData)
@@ -4509,11 +4518,22 @@ export class Engine {
4509
4518
  }
4510
4519
 
4511
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.
4512
4528
  if (hasModels)
4513
4529
  this.forEachInstance((inst) => {
4514
- if (inst.model.visible) this.renderOneModel(pass, inst)
4530
+ if (inst.model.visible) this.renderModelOpaquePhase(pass, inst)
4515
4531
  })
4516
4532
  if (this.hasGround) this.renderGround(pass)
4533
+ if (hasModels)
4534
+ this.forEachInstance((inst) => {
4535
+ if (inst.model.visible) this.renderModelTransparentPhase(pass, inst)
4536
+ })
4517
4537
  pass.end()
4518
4538
 
4519
4539
  // Bloom pyramid (EEVEE 3.6):
@@ -4805,8 +4825,7 @@ export class Engine {
4805
4825
  let overEyesPipeline: GPURenderPipeline | undefined
4806
4826
  try {
4807
4827
  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.
4828
+ // Dormant OIT twin kept for a future order-independent-transparency path.
4810
4829
  pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false)
4811
4830
  if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true)
4812
4831
  } catch (e) {
@@ -4966,14 +4985,10 @@ export class Engine {
4966
4985
  }
4967
4986
 
4968
4987
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
4969
- // 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).
4970
4991
  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.
4977
4992
  if (dc.groupId) {
4978
4993
  const install = inst.styleGroups.get(dc.groupId)
4979
4994
  if (install) return install.pipeline
@@ -4983,9 +4998,10 @@ export class Engine {
4983
4998
 
4984
4999
  /**
4985
5000
  * Draw every material of a given type (`opaque` or `transparent`) using the main
4986
- * pipeline(s). Binds the per-frame and per-instance groups once at the top of the
4987
- * batch, then issues one draw per material. Early-outs if nothing to draw so we
4988
- * 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.
4989
5005
  */
4990
5006
  private drawMaterials(pass: GPURenderPassEncoder, inst: ModelInstance, type: "opaque" | "transparent"): void {
4991
5007
  let currentPipeline: GPURenderPipeline | null = null
@@ -5004,61 +5020,58 @@ export class Engine {
5004
5020
  }
5005
5021
  pass.setBindGroup(2, draw.bindGroup)
5006
5022
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
5007
- }
5008
- }
5009
-
5010
- /**
5011
- * Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
5012
- * Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
5013
- * every batch binds its own groups from scratch — the next drawMaterials call will
5014
- * rebind group 0/1 correctly if needed.
5015
- */
5016
- private drawOutlines(pass: GPURenderPassEncoder, inst: ModelInstance, type: DrawCallType): void {
5017
- if (!this.outlineEnabled) return
5018
- let bound = false
5019
- for (const draw of inst.drawCalls) {
5020
- if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw)) continue
5021
- 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.
5022
5027
  pass.setPipeline(this.outlinePipeline)
5023
5028
  pass.setBindGroup(0, this.outlinePerFrameBindGroup)
5024
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
5025
- 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
5026
5033
  }
5027
- pass.setBindGroup(2, draw.bindGroup)
5028
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
5029
5034
  }
5030
5035
  }
5031
5036
 
5032
5037
  /**
5033
- * Main-pass render sequence for one model instance:
5034
- * 1) opaque bodies 2) opaque outlines 3) transparents → 4) transparent outlines.
5035
- * Each batch binds the groups it needs, so switching between main and outline
5036
- * 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.
5037
5043
  */
5038
- private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5044
+ private setModelDrawState(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5039
5045
  pass.setVertexBuffer(0, inst.vertexBuffer)
5040
5046
  pass.setVertexBuffer(1, inst.jointsBuffer)
5041
5047
  pass.setVertexBuffer(2, inst.weightsBuffer)
5042
5048
  pass.setIndexBuffer(inst.indexBuffer, "uint32")
5043
-
5044
5049
  // Single stencil-reference set covers eye (write), hair (read not-equal),
5045
5050
  // and hairOverEyes (read equal). Non-stencil pipelines ignore the value.
5046
5051
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
5052
+ }
5047
5053
 
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.
5054
+ private renderModelOpaquePhase(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5055
+ this.setModelDrawState(pass, inst)
5052
5056
  this.drawMaterials(pass, inst, "opaque")
5053
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.
5054
5070
  this.drawMaterials(pass, inst, "transparent")
5055
- this.drawOutlines(pass, inst, "opaque-outline")
5056
- this.drawOutlines(pass, inst, "transparent-outline")
5057
5071
  }
5058
5072
 
5059
5073
  /** 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. */
5074
+ * Dormant kept for a future order-independent-transparency path. */
5062
5075
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
5063
5076
  protected drawTransparentDepthPrepass(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5064
5077
  let bound = false
@@ -5119,6 +5132,10 @@ export class Engine {
5119
5132
  this.cameraMatrixData[32] = cameraPos.x
5120
5133
  this.cameraMatrixData[33] = cameraPos.y
5121
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
5122
5139
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
5123
5140
 
5124
5141
  // 360 backdrop: the composite reconstructs each pixel's view ray from the
@@ -117,7 +117,7 @@ const COMPOSITE_BODY = /* wgsl */ `
117
117
  var bgA = select(0.0, 1.0, bg.w > 0.5);
118
118
  var bgPm = bg.rgb * bgA; // premultiplied accumulator
119
119
  let fxOn = viewU[6].y > 0.5;
120
- if (bg.w > 1.5 || fxOn) {
120
+ if ((bg.w > 1.5 || fxOn) COVERAGE_GATE) {
121
121
  // The equirect and any effect both need this pixel's world-space view ray,
122
122
  // rebuilt from the camera basis. The dome sits at infinity (no parallax) —
123
123
  // PhotoDome-style, display-only.
@@ -152,8 +152,28 @@ const EFFECT_CALL = /* wgsl */ `
152
152
  }
153
153
  `
154
154
 
155
+ // Derivative builtins are illegal in non-uniform control flow (WGSL uniformity
156
+ // analysis rejects the pipeline), so the coverage gate below can only wrap
157
+ // effect code that doesn't use them. Checked textually at build time.
158
+ const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/
159
+
160
+ /** Skip the whole background block (equirect sample + effect) behind pixels the
161
+ * model fully covers — the composite multiplies the result by (1 - alpha) = 0
162
+ * there anyway, and on a full-screen effect that's a third or more of the frame
163
+ * (the cost Safari feels most). The equirect uses explicit-LOD sampling, which
164
+ * is always legal in non-uniform flow; only derivative-using effects must keep
165
+ * uniform control flow and forgo the gate. */
166
+ function coverageGate(effect?: CompositeEffectSource | null): string {
167
+ const gated = !effect || !USES_DERIVATIVES.test(effect.wgsl)
168
+ return gated ? "&& alpha < 0.999" : ""
169
+ }
170
+
155
171
  export function buildCompositeShader(effect?: CompositeEffectSource | null): string {
156
- if (!effect) return COMPOSITE_HEAD + COMPOSITE_BODY.replace("BG_EFFECT_CALL", NO_EFFECT_CALL)
172
+ if (!effect)
173
+ return (
174
+ COMPOSITE_HEAD +
175
+ COMPOSITE_BODY.replace("BG_EFFECT_CALL", NO_EFFECT_CALL).replace("COVERAGE_GATE", coverageGate(null))
176
+ )
157
177
  return (
158
178
  COMPOSITE_HEAD +
159
179
  "\n// ── user background effect (setBackgroundEffect) ──\n" +
@@ -161,7 +181,7 @@ export function buildCompositeShader(effect?: CompositeEffectSource | null): str
161
181
  "\n" +
162
182
  effect.wgsl +
163
183
  "\n" +
164
- COMPOSITE_BODY.replace("BG_EFFECT_CALL", EFFECT_CALL.trim())
184
+ COMPOSITE_BODY.replace("BG_EFFECT_CALL", EFFECT_CALL.trim()).replace("COVERAGE_GATE", coverageGate(effect))
165
185
  )
166
186
  }
167
187