reze-engine 0.25.1 → 0.26.0

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.26.0",
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
@@ -283,6 +283,34 @@ export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
283
283
  look: "medium_high_contrast",
284
284
  }
285
285
 
286
+ /** Color grading applied to the tonemapped scene (ASC CDL — see grade() in
287
+ * composite.ts). The three tonal controls are expressed as COLORS with
288
+ * mid-gray (0.5, 0.5, 0.5) as neutral: the direction from neutral is the hue
289
+ * you push toward, and the distance from neutral is the amount — so no
290
+ * separate strength slider is needed. Display-space sRGB, since grading runs
291
+ * after the view transform. */
292
+ export type ColorGradingOptions = {
293
+ /** Lifts/tints the dark end (CDL offset). */
294
+ shadows: Vec3
295
+ /** Bends the midtones (CDL power) — brighter above neutral, darker below. */
296
+ midtones: Vec3
297
+ /** Scales/tints the bright end (CDL slope). */
298
+ highlights: Vec3
299
+ /** Contrast about the 0.5 display pivot. 1 = neutral. */
300
+ contrast: number
301
+ /** 1 = neutral, 0 = grayscale, >1 = punchier. */
302
+ saturation: number
303
+ }
304
+
305
+ const NEUTRAL_GRADE_CHANNEL = 0.5
306
+ export const DEFAULT_COLOR_GRADING: ColorGradingOptions = {
307
+ shadows: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
308
+ midtones: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
309
+ highlights: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
310
+ contrast: 1,
311
+ saturation: 1,
312
+ }
313
+
286
314
  export type GizmoDragKind = "rotate" | "translate"
287
315
 
288
316
  export interface GizmoDragEvent {
@@ -359,6 +387,10 @@ interface DrawCall {
359
387
  baseBindGroupEntries?: GPUBindGroupEntry[]
360
388
  /** Material draws only: false = excluded from the shadow map (fully sheer). */
361
389
  castsShadow?: boolean
390
+ /** Edge-flagged materials: interleaved inverted-hull outline drawn right after
391
+ * this material with the outline pipeline. Shares this call's index range;
392
+ * own bind group (edge uniforms + diffuse texture for the alpha test). */
393
+ outline?: { bindGroup: GPUBindGroup }
362
394
  }
363
395
 
364
396
  interface PickDrawCall {
@@ -456,14 +488,10 @@ function buildAlphaSampler(
456
488
  /** Texture-alpha statistics over ≤400 of the material's triangle centroids:
457
489
  * `avg` (0..1) and `translucentFrac` — the fraction of samples that are
458
490
  * 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. */
491
+ * Bucketing itself is binary (babylon-mmd parity): ANY translucent coverage
492
+ * routes to the alpha-blend bucket. `avg` below this threshold additionally
493
+ * marks a material as fully sheer (a veil), which vetoes shadow casting. */
465
494
  const SHEER_ALPHA_THRESHOLD = 0.7
466
- const PARTIAL_TRANSLUCENT_FRAC = 0.15
467
495
  function materialAlphaStats(
468
496
  verts: Float32Array,
469
497
  indices: Uint32Array,
@@ -651,7 +679,7 @@ export class Engine {
651
679
  private compositeBindGroup!: GPUBindGroup
652
680
  private compositeUniformBuffer!: GPUBuffer
653
681
  // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
654
- private readonly compositeUniformData = new Float32Array(28)
682
+ private readonly compositeUniformData = new Float32Array(40)
655
683
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
656
684
  private backgroundColor: Vec3 | null = null
657
685
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
@@ -861,6 +889,42 @@ export class Engine {
861
889
  return { exposure: v.exposure, gamma: v.gamma, look: v.look }
862
890
  }
863
891
 
892
+ private colorGrading: ColorGradingOptions = {
893
+ shadows: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
894
+ midtones: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
895
+ highlights: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
896
+ contrast: DEFAULT_COLOR_GRADING.contrast,
897
+ saturation: DEFAULT_COLOR_GRADING.saturation,
898
+ }
899
+
900
+ /**
901
+ * Color-grade the tonemapped scene (ASC CDL slope/offset/power + saturation).
902
+ * The background layer is deliberately left ungraded — see the call site in
903
+ * composite.ts. Uniforms-only: no pipeline rebuild, safe to call per frame
904
+ * (e.g. from a slider drag).
905
+ */
906
+ setColorGrading(patch: Partial<ColorGradingOptions>): void {
907
+ const g = this.colorGrading
908
+ if (patch.shadows) g.shadows = new Vec3(patch.shadows.x, patch.shadows.y, patch.shadows.z)
909
+ if (patch.midtones) g.midtones = new Vec3(patch.midtones.x, patch.midtones.y, patch.midtones.z)
910
+ if (patch.highlights) g.highlights = new Vec3(patch.highlights.x, patch.highlights.y, patch.highlights.z)
911
+ if (patch.contrast !== undefined) g.contrast = patch.contrast
912
+ if (patch.saturation !== undefined) g.saturation = patch.saturation
913
+ if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
914
+ }
915
+
916
+ /** Current grade (for serialization into a scene descriptor). */
917
+ getColorGrading(): ColorGradingOptions {
918
+ const g = this.colorGrading
919
+ return {
920
+ shadows: new Vec3(g.shadows.x, g.shadows.y, g.shadows.z),
921
+ midtones: new Vec3(g.midtones.x, g.midtones.y, g.midtones.z),
922
+ highlights: new Vec3(g.highlights.x, g.highlights.y, g.highlights.z),
923
+ contrast: g.contrast,
924
+ saturation: g.saturation,
925
+ }
926
+ }
927
+
864
928
  setViewTransformOptions(patch: Partial<ViewTransformOptions>): void {
865
929
  const v = this.viewTransform
866
930
  if (patch.exposure !== undefined) v.exposure = patch.exposure
@@ -901,6 +965,32 @@ export class Engine {
901
965
  u[25] = this.backgroundEffect ? 1 : 0
902
966
  u[26] = this.canvas.width
903
967
  u[27] = this.canvas.height
968
+ // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
969
+ // on the CPU, so the shader only ever sees slope/offset/power. Mid-gray is
970
+ // neutral in all three; the signed distance from it is the amount.
971
+ const g = this.colorGrading
972
+ const off = (c: number) => (c - NEUTRAL_GRADE_CHANNEL) * 0.5 // ±0.25 lift
973
+ // power < 1 brightens, so midtones ABOVE neutral must lower the exponent.
974
+ const pow_ = (c: number) => Math.max(0.05, 1 - (c - NEUTRAL_GRADE_CHANNEL) * 1.5)
975
+ const slope = (c: number) => Math.max(0, 1 + (c - NEUTRAL_GRADE_CHANNEL) * 1.5)
976
+ u[28] = off(g.shadows.x)
977
+ u[29] = off(g.shadows.y)
978
+ u[30] = off(g.shadows.z)
979
+ u[31] = g.contrast
980
+ u[32] = pow_(g.midtones.x)
981
+ u[33] = pow_(g.midtones.y)
982
+ u[34] = pow_(g.midtones.z)
983
+ u[35] = g.saturation
984
+ u[36] = slope(g.highlights.x)
985
+ u[37] = slope(g.highlights.y)
986
+ u[38] = slope(g.highlights.z)
987
+ // Neutral grade → flag off, so the default pipeline pays nothing per pixel.
988
+ const neutral =
989
+ u[28] === 0 && u[29] === 0 && u[30] === 0 &&
990
+ u[32] === 1 && u[33] === 1 && u[34] === 1 &&
991
+ u[36] === 1 && u[37] === 1 && u[38] === 1 &&
992
+ g.contrast === 1 && g.saturation === 1
993
+ u[39] = neutral ? 0 : 1
904
994
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
905
995
  }
906
996
 
@@ -917,7 +1007,12 @@ export class Engine {
917
1007
  }
918
1008
 
919
1009
  /** Debug/diagnostic: skip every inverted-hull outline draw. */
920
- private outlineEnabled = true
1010
+ // OFF by default — the product aesthetic. Modern high-detail models read
1011
+ // better without hulls (babylon-mmd's own demos disable its outline renderer
1012
+ // too), and no hull pass means no depth-tie edge cases against near-coplanar
1013
+ // cloth. The full MMD-faithful machinery (interleaved per-material hulls,
1014
+ // texture-alpha-modulated rims) stays in place behind setOutlineEnabled(true).
1015
+ private outlineEnabled = false
921
1016
  setOutlineEnabled(on: boolean): void {
922
1017
  this.outlineEnabled = on
923
1018
  }
@@ -1493,6 +1588,8 @@ export class Engine {
1493
1588
  attributes: [
1494
1589
  { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat },
1495
1590
  { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat },
1591
+ // uv — the outline FS alpha-tests the diffuse texture (babylon-mmd parity)
1592
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat },
1496
1593
  ],
1497
1594
  },
1498
1595
  {
@@ -1758,6 +1855,7 @@ export class Engine {
1758
1855
  label: "outline per-frame bind group layout",
1759
1856
  entries: [
1760
1857
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1858
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
1761
1859
  ],
1762
1860
  })
1763
1861
  // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
@@ -1765,6 +1863,7 @@ export class Engine {
1765
1863
  label: "outline per-material bind group layout",
1766
1864
  entries: [
1767
1865
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1866
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1768
1867
  ],
1769
1868
  })
1770
1869
 
@@ -1780,7 +1879,10 @@ export class Engine {
1780
1879
  this.outlinePerFrameBindGroup = this.device.createBindGroup({
1781
1880
  label: "outline per-frame bind group",
1782
1881
  layout: this.outlinePerFrameBindGroupLayout,
1783
- entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
1882
+ entries: [
1883
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1884
+ { binding: 1, resource: this.materialSampler },
1885
+ ],
1784
1886
  })
1785
1887
 
1786
1888
  const outlineShaderModule = this.device.createShaderModule({
@@ -1797,8 +1899,10 @@ export class Engine {
1797
1899
  cullMode: "back",
1798
1900
  depthStencil: {
1799
1901
  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,
1902
+ // babylon-mmd draws outlines WITH depth write (its _afterRenderingMesh
1903
+ // forces setDepthWrite(true)); the constant bias below still makes
1904
+ // hulls lose depth ties against their own near-coplanar geometry.
1905
+ depthWriteEnabled: true,
1802
1906
  depthCompare: "less-equal",
1803
1907
  // CONFIRMED fix (bisected live via setOutlineEnabled): hull fragments
1804
1908
  // carry their surface's exact depth, so against this model's paired
@@ -1991,10 +2095,11 @@ export class Engine {
1991
2095
  // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
1992
2096
  this.compositeUniformBuffer = this.device.createBuffer({
1993
2097
  label: "composite view uniforms",
1994
- // 7 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
2098
+ // 10 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1995
2099
  // (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,
2100
+ // (time, _, canvas width, canvas height) for user background effects ·
2101
+ // three grade vectors (CDL offset+contrast, power+saturation, slope+flag).
2102
+ size: 160,
1998
2103
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1999
2104
  })
2000
2105
  this.bgParamsDummyBuffer = this.device.createBuffer({
@@ -3555,28 +3660,26 @@ export class Engine {
3555
3660
  }
3556
3661
 
3557
3662
  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
3663
  const diffusePath = texLogicalPath(mat.diffuseTextureIndex)
3566
3664
  const alphaSampler = diffusePath ? this.textureAlphaCache.get(diffusePath) : null
3567
3665
  const stats = materialAlphaStats(meshVertices, meshIndices, currentIndexOffset, indexCount, alphaSampler)
3666
+ // babylon-mmd parity (its default DepthWriteAlphaBlendingWithEvaluation
3667
+ // method): the bucket decision is BINARY. A material with ANY translucent
3668
+ // texels on its geometry is alpha-blend — drawn in PMX author order with
3669
+ // depth write ON (forceDepthWrite); everything else is opaque. The old
3670
+ // avg/frac tier system left mostly-opaque lace (translucentFrac 0.09) in
3671
+ // the opaque bucket while its sibling panels went transparent, breaking
3672
+ // the author's compositing order — the gray fold patches. The 2% floor
3673
+ // only guards against centroid-sampling noise on genuinely solid cloth.
3568
3674
  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
3675
+ const isTransparent = materialAlpha < 1.0 - 0.001 || sheer || stats.translucentFrac > 0.02
3676
+ // Shadow casting: the PMX author's own flag (bit 0x04, cast self-shadow),
3677
+ // still vetoed for fully sheer cloth a veil must not cast a solid sheet.
3678
+ const castsShadow = (mat.edgeFlag & 0x04) !== 0 && !sheer
3576
3679
  // Load-time classification log — one line per material, cheap and
3577
- // invaluable when a model renders wrong (bucket/outline/sheer disputes).
3680
+ // invaluable when a model renders wrong (bucket/outline/shadow disputes).
3578
3681
  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"}`,
3682
+ `[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
3683
  )
3581
3684
 
3582
3685
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
@@ -3615,24 +3718,13 @@ export class Engine {
3615
3718
  this.zeroStyleBuffer,
3616
3719
  )
3617
3720
 
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) {
3721
+ // Inverted-hull outline for EVERY edge-flagged material (PMX bit 0x10) —
3722
+ // the outline FS alpha-tests the diffuse texture, so sheer fabric masks
3723
+ // its own hull where it is see-through instead of us skipping it here.
3724
+ // Drawn interleaved right after this material's color draw (babylon-mmd's
3725
+ // per-mesh afterRender outline stage) — see drawMaterials.
3726
+ let outline: DrawCall["outline"]
3727
+ if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
3636
3728
  const materialUniformData = new Float32Array([
3637
3729
  mat.edgeColor[0],
3638
3730
  mat.edgeColor[1],
@@ -3648,19 +3740,27 @@ export class Engine {
3648
3740
  const outlineBindGroup = this.device.createBindGroup({
3649
3741
  label: `${prefix}outline: ${mat.name}`,
3650
3742
  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,
3743
+ entries: [
3744
+ { binding: 0, resource: { buffer: outlineUniformBuffer } },
3745
+ { binding: 1, resource: textureView },
3746
+ ],
3661
3747
  })
3748
+ outline = { bindGroup: outlineBindGroup }
3662
3749
  }
3663
3750
 
3751
+ const type: DrawCallType = isTransparent ? "transparent" : "opaque"
3752
+ inst.drawCalls.push({
3753
+ type,
3754
+ count: indexCount,
3755
+ firstIndex: currentIndexOffset,
3756
+ bindGroup,
3757
+ materialName: mat.name,
3758
+ groupId: null,
3759
+ baseBindGroupEntries,
3760
+ castsShadow,
3761
+ outline,
3762
+ })
3763
+
3664
3764
  if (this.onRaycast) {
3665
3765
  const pickIdData = new Float32Array([modelId, materialId, 0, 0])
3666
3766
  const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData)
@@ -4509,11 +4609,22 @@ export class Engine {
4509
4609
  }
4510
4610
 
4511
4611
  const pass = encoder.beginRenderPass(this.renderPassDescriptor)
4612
+ // Phase order: opaque models → ground → transparent fabric.
4613
+ // The ground shader is the most expensive full-coverage draw in the frame
4614
+ // (9-tap PCF on the 4096² shadow map per pixel), so it draws AFTER the
4615
+ // opaque phase to get early-z rejected behind the body — drawing it first
4616
+ // shaded every covered pixel and measurably dropped Safari fps. It still
4617
+ // draws BEFORE the transparent phase so sheer fabric blends over the floor
4618
+ // instead of over the background with the floor depth-rejected behind it.
4512
4619
  if (hasModels)
4513
4620
  this.forEachInstance((inst) => {
4514
- if (inst.model.visible) this.renderOneModel(pass, inst)
4621
+ if (inst.model.visible) this.renderModelOpaquePhase(pass, inst)
4515
4622
  })
4516
4623
  if (this.hasGround) this.renderGround(pass)
4624
+ if (hasModels)
4625
+ this.forEachInstance((inst) => {
4626
+ if (inst.model.visible) this.renderModelTransparentPhase(pass, inst)
4627
+ })
4517
4628
  pass.end()
4518
4629
 
4519
4630
  // Bloom pyramid (EEVEE 3.6):
@@ -4805,8 +4916,7 @@ export class Engine {
4805
4916
  let overEyesPipeline: GPURenderPipeline | undefined
4806
4917
  try {
4807
4918
  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.
4919
+ // Dormant OIT twin kept for a future order-independent-transparency path.
4810
4920
  pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false)
4811
4921
  if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true)
4812
4922
  } catch (e) {
@@ -4966,14 +5076,10 @@ export class Engine {
4966
5076
  }
4967
5077
 
4968
5078
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
4969
- // the neutral base (ungrouped materials render the default graph).
5079
+ // the neutral base (ungrouped materials render the default graph). Transparent-bucket
5080
+ // draws use the SAME depth-write-on pipeline — babylon-mmd's forceDepthWrite
5081
+ // blending (see renderModelTransparentPhase for the trade-off record).
4970
5082
  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
5083
  if (dc.groupId) {
4978
5084
  const install = inst.styleGroups.get(dc.groupId)
4979
5085
  if (install) return install.pipeline
@@ -4983,9 +5089,10 @@ export class Engine {
4983
5089
 
4984
5090
  /**
4985
5091
  * 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.
5092
+ * pipeline(s), and babylon-mmd's per-mesh outline stage each edge-flagged
5093
+ * material's inverted hull IMMEDIATELY after its color draw. Interleaving is what
5094
+ * makes outlines compose like MMD: every material drawn later in the author's
5095
+ * order covers earlier hulls, and each hull sits over everything drawn before it.
4989
5096
  */
4990
5097
  private drawMaterials(pass: GPURenderPassEncoder, inst: ModelInstance, type: "opaque" | "transparent"): void {
4991
5098
  let currentPipeline: GPURenderPipeline | null = null
@@ -5004,61 +5111,58 @@ export class Engine {
5004
5111
  }
5005
5112
  pass.setBindGroup(2, draw.bindGroup)
5006
5113
  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) {
5114
+ if (draw.outline && this.outlineEnabled) {
5115
+ // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
5116
+ // layout-identical between the main and outline pipelines and stays
5117
+ // bound. Restore group 0 afterwards and force a pipeline re-set.
5022
5118
  pass.setPipeline(this.outlinePipeline)
5023
5119
  pass.setBindGroup(0, this.outlinePerFrameBindGroup)
5024
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
5025
- bound = true
5120
+ pass.setBindGroup(2, draw.outline.bindGroup)
5121
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
5122
+ pass.setBindGroup(0, this.perFrameBindGroup)
5123
+ currentPipeline = null
5026
5124
  }
5027
- pass.setBindGroup(2, draw.bindGroup)
5028
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
5029
5125
  }
5030
5126
  }
5031
5127
 
5032
5128
  /**
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).
5129
+ * Main-pass render sequence for one model instance — babylon-mmd parity:
5130
+ * opaque bucket, the hair-over-eyes stencil pass, then alpha-blend materials
5131
+ * in PMX author order with depth write ON (forceDepthWrite). Outlines are not
5132
+ * a separate phase: drawMaterials draws each edge-flagged material's hull
5133
+ * right after the material itself, like MMD's per-mesh outline stage.
5037
5134
  */
5038
- private renderOneModel(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5135
+ private setModelDrawState(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5039
5136
  pass.setVertexBuffer(0, inst.vertexBuffer)
5040
5137
  pass.setVertexBuffer(1, inst.jointsBuffer)
5041
5138
  pass.setVertexBuffer(2, inst.weightsBuffer)
5042
5139
  pass.setIndexBuffer(inst.indexBuffer, "uint32")
5043
-
5044
5140
  // Single stencil-reference set covers eye (write), hair (read not-equal),
5045
5141
  // and hairOverEyes (read equal). Non-stencil pipelines ignore the value.
5046
5142
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
5143
+ }
5047
5144
 
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.
5145
+ private renderModelOpaquePhase(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5146
+ this.setModelDrawState(pass, inst)
5052
5147
  this.drawMaterials(pass, inst, "opaque")
5053
5148
  this.drawHairOverEyes(pass, inst)
5149
+ }
5150
+
5151
+ private renderModelTransparentPhase(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5152
+ this.setModelDrawState(pass, inst)
5153
+ // Transparent: babylon-mmd's forceDepthWrite blending — PMX author order
5154
+ // with depth write ON. The accepted trade-off after trying every variant:
5155
+ // · depth-write ON (this): a fold hides its far side; rare view-dependent
5156
+ // double-blend seams at some angles. MMD's own known behavior.
5157
+ // · nearest-surface prepass: view-independent, but punched see-through
5158
+ // holes to whatever sat far behind a fold.
5159
+ // · depth-write OFF layering: every overlap visible everywhere — MORE
5160
+ // gray patches and texture artifacts in practice.
5054
5161
  this.drawMaterials(pass, inst, "transparent")
5055
- this.drawOutlines(pass, inst, "opaque-outline")
5056
- this.drawOutlines(pass, inst, "transparent-outline")
5057
5162
  }
5058
5163
 
5059
5164
  /** 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. */
5165
+ * Dormant kept for a future order-independent-transparency path. */
5062
5166
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
5063
5167
  protected drawTransparentDepthPrepass(pass: GPURenderPassEncoder, inst: ModelInstance): void {
5064
5168
  let bound = false
@@ -5119,6 +5223,10 @@ export class Engine {
5119
5223
  this.cameraMatrixData[32] = cameraPos.x
5120
5224
  this.cameraMatrixData[33] = cameraPos.y
5121
5225
  this.cameraMatrixData[34] = cameraPos.z
5226
+ // Spare float after viewPos: render-target height in device px — the outline
5227
+ // shader derives the full viewport (width via projection aspect) for its
5228
+ // babylon-mmd constant-pixel edge extrusion.
5229
+ this.cameraMatrixData[35] = this.canvas.height
5122
5230
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
5123
5231
 
5124
5232
  // 360 backdrop: the composite reconstructs each pixel's view ray from the
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ export {
2
2
  Engine,
3
3
  DEFAULT_BLOOM_OPTIONS,
4
4
  DEFAULT_VIEW_TRANSFORM,
5
+ DEFAULT_COLOR_GRADING,
6
+ type ColorGradingOptions,
5
7
  type EngineStats,
6
8
  type EngineOptions,
7
9
  type BloomOptions,