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.
package/dist/engine.js CHANGED
@@ -142,6 +142,14 @@ export const DEFAULT_VIEW_TRANSFORM = {
142
142
  gamma: 1.0,
143
143
  look: "medium_high_contrast",
144
144
  };
145
+ const NEUTRAL_GRADE_CHANNEL = 0.5;
146
+ export const DEFAULT_COLOR_GRADING = {
147
+ shadows: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
148
+ midtones: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
149
+ highlights: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
150
+ contrast: 1,
151
+ saturation: 1,
152
+ };
145
153
  export const DEFAULT_ENGINE_OPTIONS = {
146
154
  world: { color: new Vec3(0.4014, 0.4944, 0.647), strength: 0.3 },
147
155
  sun: { color: new Vec3(1.0, 1.0, 1.0), strength: 2.0, direction: new Vec3(-0.0873, -0.3844, 0.919) },
@@ -196,14 +204,10 @@ function buildAlphaSampler(source, rgba, width, height) {
196
204
  /** Texture-alpha statistics over ≤400 of the material's triangle centroids:
197
205
  * `avg` (0..1) and `translucentFrac` — the fraction of samples that are
198
206
  * neither fully opaque nor fully cut out (alpha in ~0.03..0.97). Together
199
- * they classify two distinct things:
200
- * sheer (avg low) — a veil: mostly see-through everywhere
201
- * partial (translucentFrac up) mostly-opaque cloth with sheer REGIONS,
202
- * e.g. a lace skirt panel
203
- * Both route to the transparent bucket; only `sheer` is excluded from the
204
- * shadow map and outline pass. */
207
+ * Bucketing itself is binary (babylon-mmd parity): ANY translucent coverage
208
+ * routes to the alpha-blend bucket. `avg` below this threshold additionally
209
+ * marks a material as fully sheer (a veil), which vetoes shadow casting. */
205
210
  const SHEER_ALPHA_THRESHOLD = 0.7;
206
- const PARTIAL_TRANSLUCENT_FRAC = 0.15;
207
211
  function materialAlphaStats(verts, indices, firstIndex, count, sampler) {
208
212
  if (!sampler)
209
213
  return { avg: 1, translucentFrac: 0 };
@@ -274,7 +278,7 @@ export class Engine {
274
278
  // unchanged).
275
279
  this.hdrFormat = "rgba16float";
276
280
  // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
277
- this.compositeUniformData = new Float32Array(28);
281
+ this.compositeUniformData = new Float32Array(40);
278
282
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
279
283
  this.backgroundColor = null;
280
284
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
@@ -337,8 +341,20 @@ export class Engine {
337
341
  };
338
342
  this.animationFrameId = null;
339
343
  this.renderLoopCallback = null;
344
+ this.colorGrading = {
345
+ shadows: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
346
+ midtones: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
347
+ highlights: new Vec3(NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL, NEUTRAL_GRADE_CHANNEL),
348
+ contrast: DEFAULT_COLOR_GRADING.contrast,
349
+ saturation: DEFAULT_COLOR_GRADING.saturation,
350
+ };
340
351
  /** Debug/diagnostic: skip every inverted-hull outline draw. */
341
- this.outlineEnabled = true;
352
+ // OFF by default — the product aesthetic. Modern high-detail models read
353
+ // better without hulls (babylon-mmd's own demos disable its outline renderer
354
+ // too), and no hull pass means no depth-tie edge cases against near-coplanar
355
+ // cloth. The full MMD-faithful machinery (interleaved per-material hulls,
356
+ // texture-alpha-modulated rims) stays in place behind setOutlineEnabled(true).
357
+ this.outlineEnabled = false;
342
358
  /** When set, render resolution is pinned to this size instead of tracking the
343
359
  * canvas's CSS size × devicePixelRatio (see setRenderSize). */
344
360
  this.fixedRenderSize = null;
@@ -601,6 +617,38 @@ export class Engine {
601
617
  const v = this.viewTransform;
602
618
  return { exposure: v.exposure, gamma: v.gamma, look: v.look };
603
619
  }
620
+ /**
621
+ * Color-grade the tonemapped scene (ASC CDL slope/offset/power + saturation).
622
+ * The background layer is deliberately left ungraded — see the call site in
623
+ * composite.ts. Uniforms-only: no pipeline rebuild, safe to call per frame
624
+ * (e.g. from a slider drag).
625
+ */
626
+ setColorGrading(patch) {
627
+ const g = this.colorGrading;
628
+ if (patch.shadows)
629
+ g.shadows = new Vec3(patch.shadows.x, patch.shadows.y, patch.shadows.z);
630
+ if (patch.midtones)
631
+ g.midtones = new Vec3(patch.midtones.x, patch.midtones.y, patch.midtones.z);
632
+ if (patch.highlights)
633
+ g.highlights = new Vec3(patch.highlights.x, patch.highlights.y, patch.highlights.z);
634
+ if (patch.contrast !== undefined)
635
+ g.contrast = patch.contrast;
636
+ if (patch.saturation !== undefined)
637
+ g.saturation = patch.saturation;
638
+ if (this.device && this.compositeUniformBuffer)
639
+ this.writeCompositeViewUniforms();
640
+ }
641
+ /** Current grade (for serialization into a scene descriptor). */
642
+ getColorGrading() {
643
+ const g = this.colorGrading;
644
+ return {
645
+ shadows: new Vec3(g.shadows.x, g.shadows.y, g.shadows.z),
646
+ midtones: new Vec3(g.midtones.x, g.midtones.y, g.midtones.z),
647
+ highlights: new Vec3(g.highlights.x, g.highlights.y, g.highlights.z),
648
+ contrast: g.contrast,
649
+ saturation: g.saturation,
650
+ };
651
+ }
604
652
  setViewTransformOptions(patch) {
605
653
  const v = this.viewTransform;
606
654
  if (patch.exposure !== undefined)
@@ -643,6 +691,31 @@ export class Engine {
643
691
  u[25] = this.backgroundEffect ? 1 : 0;
644
692
  u[26] = this.canvas.width;
645
693
  u[27] = this.canvas.height;
694
+ // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
695
+ // on the CPU, so the shader only ever sees slope/offset/power. Mid-gray is
696
+ // neutral in all three; the signed distance from it is the amount.
697
+ const g = this.colorGrading;
698
+ const off = (c) => (c - NEUTRAL_GRADE_CHANNEL) * 0.5; // ±0.25 lift
699
+ // power < 1 brightens, so midtones ABOVE neutral must lower the exponent.
700
+ const pow_ = (c) => Math.max(0.05, 1 - (c - NEUTRAL_GRADE_CHANNEL) * 1.5);
701
+ const slope = (c) => Math.max(0, 1 + (c - NEUTRAL_GRADE_CHANNEL) * 1.5);
702
+ u[28] = off(g.shadows.x);
703
+ u[29] = off(g.shadows.y);
704
+ u[30] = off(g.shadows.z);
705
+ u[31] = g.contrast;
706
+ u[32] = pow_(g.midtones.x);
707
+ u[33] = pow_(g.midtones.y);
708
+ u[34] = pow_(g.midtones.z);
709
+ u[35] = g.saturation;
710
+ u[36] = slope(g.highlights.x);
711
+ u[37] = slope(g.highlights.y);
712
+ u[38] = slope(g.highlights.z);
713
+ // Neutral grade → flag off, so the default pipeline pays nothing per pixel.
714
+ const neutral = u[28] === 0 && u[29] === 0 && u[30] === 0 &&
715
+ u[32] === 1 && u[33] === 1 && u[34] === 1 &&
716
+ u[36] === 1 && u[37] === 1 && u[38] === 1 &&
717
+ g.contrast === 1 && g.saturation === 1;
718
+ u[39] = neutral ? 0 : 1;
646
719
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
647
720
  }
648
721
  /**
@@ -1194,6 +1267,8 @@ export class Engine {
1194
1267
  attributes: [
1195
1268
  { shaderLocation: 0, offset: 0, format: "float32x3" },
1196
1269
  { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
1270
+ // uv — the outline FS alpha-tests the diffuse texture (babylon-mmd parity)
1271
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
1197
1272
  ],
1198
1273
  },
1199
1274
  {
@@ -1448,6 +1523,7 @@ export class Engine {
1448
1523
  label: "outline per-frame bind group layout",
1449
1524
  entries: [
1450
1525
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1526
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
1451
1527
  ],
1452
1528
  });
1453
1529
  // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
@@ -1455,6 +1531,7 @@ export class Engine {
1455
1531
  label: "outline per-material bind group layout",
1456
1532
  entries: [
1457
1533
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1534
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1458
1535
  ],
1459
1536
  });
1460
1537
  const outlinePipelineLayout = this.device.createPipelineLayout({
@@ -1468,7 +1545,10 @@ export class Engine {
1468
1545
  this.outlinePerFrameBindGroup = this.device.createBindGroup({
1469
1546
  label: "outline per-frame bind group",
1470
1547
  layout: this.outlinePerFrameBindGroupLayout,
1471
- entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
1548
+ entries: [
1549
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1550
+ { binding: 1, resource: this.materialSampler },
1551
+ ],
1472
1552
  });
1473
1553
  const outlineShaderModule = this.device.createShaderModule({
1474
1554
  label: "outline shaders",
@@ -1483,8 +1563,10 @@ export class Engine {
1483
1563
  cullMode: "back",
1484
1564
  depthStencil: {
1485
1565
  format: "depth24plus-stencil8",
1486
- // Don’t write outline into depth buffer — stops z-fighting / black cracks vs body (MMD-style; body depth stays authoritative)
1487
- depthWriteEnabled: false,
1566
+ // babylon-mmd draws outlines WITH depth write (its _afterRenderingMesh
1567
+ // forces setDepthWrite(true)); the constant bias below still makes
1568
+ // hulls lose depth ties against their own near-coplanar geometry.
1569
+ depthWriteEnabled: true,
1488
1570
  depthCompare: "less-equal",
1489
1571
  // CONFIRMED fix (bisected live via setOutlineEnabled): hull fragments
1490
1572
  // carry their surface's exact depth, so against this model's paired
@@ -1666,10 +1748,11 @@ export class Engine {
1666
1748
  // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
1667
1749
  this.compositeUniformBuffer = this.device.createBuffer({
1668
1750
  label: "composite view uniforms",
1669
- // 7 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1751
+ // 10 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
1670
1752
  // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
1671
- // (time, _, canvas width, canvas height) for user background effects.
1672
- size: 112,
1753
+ // (time, _, canvas width, canvas height) for user background effects ·
1754
+ // three grade vectors (CDL offset+contrast, power+saturation, slope+flag).
1755
+ size: 160,
1673
1756
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1674
1757
  });
1675
1758
  this.bgParamsDummyBuffer = this.device.createBuffer({
@@ -3038,27 +3121,25 @@ export class Engine {
3038
3121
  diffuseTexture = this.fallbackMaterialTexture;
3039
3122
  }
3040
3123
  const materialAlpha = mat.diffuse[3];
3041
- // Transparent bucket when the MATERIAL says so — or when the TEXTURE does
3042
- // (sheer cloth almost always ships with diffuse alpha 1.0 and carries its
3043
- // translucency in texture alpha). Transparent-bucket draws happen after
3044
- // the opaque bucket (and after the late-drawn hair render-class), so a
3045
- // veil composites over the hair behind it instead of depth-rejecting it;
3046
- // they are also excluded from the shadow map, so sheer cloth stops
3047
- // casting the solid shadow of an opaque sheet.
3048
3124
  const diffusePath = texLogicalPath(mat.diffuseTextureIndex);
3049
3125
  const alphaSampler = diffusePath ? this.textureAlphaCache.get(diffusePath) : null;
3050
3126
  const stats = materialAlphaStats(meshVertices, meshIndices, currentIndexOffset, indexCount, alphaSampler);
3127
+ // babylon-mmd parity (its default DepthWriteAlphaBlendingWithEvaluation
3128
+ // method): the bucket decision is BINARY. A material with ANY translucent
3129
+ // texels on its geometry is alpha-blend — drawn in PMX author order with
3130
+ // depth write ON (forceDepthWrite); everything else is opaque. The old
3131
+ // avg/frac tier system left mostly-opaque lace (translucentFrac 0.09) in
3132
+ // the opaque bucket while its sibling panels went transparent, breaking
3133
+ // the author's compositing order — the gray fold patches. The 2% floor
3134
+ // only guards against centroid-sampling noise on genuinely solid cloth.
3051
3135
  const sheer = stats.avg < SHEER_ALPHA_THRESHOLD;
3052
- const partial = !sheer && stats.translucentFrac > PARTIAL_TRANSLUCENT_FRAC;
3053
- // Transparent bucket: drawn after the opaque bucket (and hair) with depth
3054
- // WRITE OFF, so a lace panel folded in front of itself blends both layers
3055
- // consistently instead of a triangle-order patchwork of single/double
3056
- // coverage. Partial-sheer cloth still casts shadows and keeps its outline;
3057
- // fully sheer cloth (veil) does neither.
3058
- const isTransparent = materialAlpha < 1.0 - 0.001 || sheer || partial;
3136
+ const isTransparent = materialAlpha < 1.0 - 0.001 || sheer || stats.translucentFrac > 0.02;
3137
+ // Shadow casting: the PMX author's own flag (bit 0x04, cast self-shadow),
3138
+ // still vetoed for fully sheer cloth a veil must not cast a solid sheet.
3139
+ const castsShadow = (mat.edgeFlag & 0x04) !== 0 && !sheer;
3059
3140
  // Load-time classification log — one line per material, cheap and
3060
- // invaluable when a model renders wrong (bucket/outline/sheer disputes).
3061
- console.info(`[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"}`);
3141
+ // invaluable when a model renders wrong (bucket/outline/shadow disputes).
3142
+ console.info(`[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"}`);
3062
3143
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
3063
3144
  // rare and not implemented — treated as none, like a failed load.
3064
3145
  let sphereMode = mat.sphereMode === 1 || mat.sphereMode === 2 ? mat.sphereMode : 0;
@@ -3089,23 +3170,13 @@ export class Engine {
3089
3170
  // Ungrouped at load — binding(4) = zero buffer, neutral base pipeline. autoStyleGroups
3090
3171
  // / applyStyleGroups rebind grouped materials to their group's buffer + pipeline.
3091
3172
  const bindGroup = this.createMaterialBindGroup(`${prefix}material: ${mat.name}`, baseBindGroupEntries, this.zeroStyleBuffer);
3092
- const type = isTransparent ? "transparent" : "opaque";
3093
- inst.drawCalls.push({
3094
- type,
3095
- count: indexCount,
3096
- firstIndex: currentIndexOffset,
3097
- bindGroup,
3098
- materialName: mat.name,
3099
- groupId: null,
3100
- baseBindGroupEntries,
3101
- castsShadow: !sheer,
3102
- });
3103
- // No inverted-hull outline for SHEER materials: the outline shader draws a
3104
- // solid edgeColor silhouette (it never samples texture alpha), so a
3105
- // see-through veil dragged a near-black hull over the cloth behind it —
3106
- // broken black shapes that waved with physics and flickered with camera
3107
- // angle. A solid rim on see-through fabric is wrong in principle.
3108
- if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0 && !sheer) {
3173
+ // Inverted-hull outline for EVERY edge-flagged material (PMX bit 0x10) —
3174
+ // the outline FS alpha-tests the diffuse texture, so sheer fabric masks
3175
+ // its own hull where it is see-through instead of us skipping it here.
3176
+ // Drawn interleaved right after this material's color draw (babylon-mmd's
3177
+ // per-mesh afterRender outline stage) — see drawMaterials.
3178
+ let outline;
3179
+ if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
3109
3180
  const materialUniformData = new Float32Array([
3110
3181
  mat.edgeColor[0],
3111
3182
  mat.edgeColor[1],
@@ -3121,18 +3192,25 @@ export class Engine {
3121
3192
  const outlineBindGroup = this.device.createBindGroup({
3122
3193
  label: `${prefix}outline: ${mat.name}`,
3123
3194
  layout: this.outlinePerMaterialBindGroupLayout,
3124
- entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
3125
- });
3126
- const outlineType = isTransparent ? "transparent-outline" : "opaque-outline";
3127
- inst.drawCalls.push({
3128
- type: outlineType,
3129
- count: indexCount,
3130
- firstIndex: currentIndexOffset,
3131
- bindGroup: outlineBindGroup,
3132
- materialName: mat.name,
3133
- groupId: null,
3195
+ entries: [
3196
+ { binding: 0, resource: { buffer: outlineUniformBuffer } },
3197
+ { binding: 1, resource: textureView },
3198
+ ],
3134
3199
  });
3200
+ outline = { bindGroup: outlineBindGroup };
3135
3201
  }
3202
+ const type = isTransparent ? "transparent" : "opaque";
3203
+ inst.drawCalls.push({
3204
+ type,
3205
+ count: indexCount,
3206
+ firstIndex: currentIndexOffset,
3207
+ bindGroup,
3208
+ materialName: mat.name,
3209
+ groupId: null,
3210
+ baseBindGroupEntries,
3211
+ castsShadow,
3212
+ outline,
3213
+ });
3136
3214
  if (this.onRaycast) {
3137
3215
  const pickIdData = new Float32Array([modelId, materialId, 0, 0]);
3138
3216
  const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData);
@@ -3713,13 +3791,25 @@ export class Engine {
3713
3791
  sp.end();
3714
3792
  }
3715
3793
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
3794
+ // Phase order: opaque models → ground → transparent fabric.
3795
+ // The ground shader is the most expensive full-coverage draw in the frame
3796
+ // (9-tap PCF on the 4096² shadow map per pixel), so it draws AFTER the
3797
+ // opaque phase to get early-z rejected behind the body — drawing it first
3798
+ // shaded every covered pixel and measurably dropped Safari fps. It still
3799
+ // draws BEFORE the transparent phase so sheer fabric blends over the floor
3800
+ // instead of over the background with the floor depth-rejected behind it.
3716
3801
  if (hasModels)
3717
3802
  this.forEachInstance((inst) => {
3718
3803
  if (inst.model.visible)
3719
- this.renderOneModel(pass, inst);
3804
+ this.renderModelOpaquePhase(pass, inst);
3720
3805
  });
3721
3806
  if (this.hasGround)
3722
3807
  this.renderGround(pass);
3808
+ if (hasModels)
3809
+ this.forEachInstance((inst) => {
3810
+ if (inst.model.visible)
3811
+ this.renderModelTransparentPhase(pass, inst);
3812
+ });
3723
3813
  pass.end();
3724
3814
  // Bloom pyramid (EEVEE 3.6):
3725
3815
  // 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
@@ -3992,8 +4082,7 @@ export class Engine {
3992
4082
  let overEyesPipeline;
3993
4083
  try {
3994
4084
  pipeline = await this.createRenderClassPipeline(renderClass, module, false);
3995
- // Transparent-bucket draws don't write depth (self-overlap must blend, not
3996
- // patchwork) — same shading, different depth state.
4085
+ // Dormant OIT twin kept for a future order-independent-transparency path.
3997
4086
  pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false);
3998
4087
  if (renderClass === "hair")
3999
4088
  overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true);
@@ -4139,14 +4228,10 @@ export class Engine {
4139
4228
  });
4140
4229
  }
4141
4230
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
4142
- // the neutral base (ungrouped materials render the default graph).
4231
+ // the neutral base (ungrouped materials render the default graph). Transparent-bucket
4232
+ // draws use the SAME depth-write-on pipeline — babylon-mmd's forceDepthWrite
4233
+ // blending (see renderModelTransparentPhase for the trade-off record).
4143
4234
  pipelineForDrawCall(inst, dc) {
4144
- // Transparent draws WRITE depth — MMD semantics: PMX triangle/material order
4145
- // is the author's compositing order, and a fold HIDES its far side rather
4146
- // than blending it (the far side shades dark — light-averted — so letting it
4147
- // show through read as gray fold-shaped stains; depth-write-off made every
4148
- // fold do that). The no-write twins stay available for a future true-OIT
4149
- // path but are deliberately unused.
4150
4235
  if (dc.groupId) {
4151
4236
  const install = inst.styleGroups.get(dc.groupId);
4152
4237
  if (install)
@@ -4156,9 +4241,10 @@ export class Engine {
4156
4241
  }
4157
4242
  /**
4158
4243
  * Draw every material of a given type (`opaque` or `transparent`) using the main
4159
- * pipeline(s). Binds the per-frame and per-instance groups once at the top of the
4160
- * batch, then issues one draw per material. Early-outs if nothing to draw so we
4161
- * don't waste bindings when a model has no transparents, etc.
4244
+ * pipeline(s), and babylon-mmd's per-mesh outline stage each edge-flagged
4245
+ * material's inverted hull IMMEDIATELY after its color draw. Interleaving is what
4246
+ * makes outlines compose like MMD: every material drawn later in the author's
4247
+ * order covers earlier hulls, and each hull sits over everything drawn before it.
4162
4248
  */
4163
4249
  drawMaterials(pass, inst, type) {
4164
4250
  let currentPipeline = null;
@@ -4178,38 +4264,27 @@ export class Engine {
4178
4264
  }
4179
4265
  pass.setBindGroup(2, draw.bindGroup);
4180
4266
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
4181
- }
4182
- }
4183
- /**
4184
- * Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
4185
- * Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
4186
- * every batch binds its own groups from scratch — the next drawMaterials call will
4187
- * rebind group 0/1 correctly if needed.
4188
- */
4189
- drawOutlines(pass, inst, type) {
4190
- if (!this.outlineEnabled)
4191
- return;
4192
- let bound = false;
4193
- for (const draw of inst.drawCalls) {
4194
- if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
4195
- continue;
4196
- if (!bound) {
4267
+ if (draw.outline && this.outlineEnabled) {
4268
+ // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
4269
+ // layout-identical between the main and outline pipelines and stays
4270
+ // bound. Restore group 0 afterwards and force a pipeline re-set.
4197
4271
  pass.setPipeline(this.outlinePipeline);
4198
4272
  pass.setBindGroup(0, this.outlinePerFrameBindGroup);
4199
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
4200
- bound = true;
4273
+ pass.setBindGroup(2, draw.outline.bindGroup);
4274
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
4275
+ pass.setBindGroup(0, this.perFrameBindGroup);
4276
+ currentPipeline = null;
4201
4277
  }
4202
- pass.setBindGroup(2, draw.bindGroup);
4203
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
4204
4278
  }
4205
4279
  }
4206
4280
  /**
4207
- * Main-pass render sequence for one model instance:
4208
- * 1) opaque bodies 2) opaque outlines 3) transparents → 4) transparent outlines.
4209
- * Each batch binds the groups it needs, so switching between main and outline
4210
- * pipelines is self-contained (no cross-batch dependencies).
4281
+ * Main-pass render sequence for one model instance — babylon-mmd parity:
4282
+ * opaque bucket, the hair-over-eyes stencil pass, then alpha-blend materials
4283
+ * in PMX author order with depth write ON (forceDepthWrite). Outlines are not
4284
+ * a separate phase: drawMaterials draws each edge-flagged material's hull
4285
+ * right after the material itself, like MMD's per-mesh outline stage.
4211
4286
  */
4212
- renderOneModel(pass, inst) {
4287
+ setModelDrawState(pass, inst) {
4213
4288
  pass.setVertexBuffer(0, inst.vertexBuffer);
4214
4289
  pass.setVertexBuffer(1, inst.jointsBuffer);
4215
4290
  pass.setVertexBuffer(2, inst.weightsBuffer);
@@ -4217,19 +4292,26 @@ export class Engine {
4217
4292
  // Single stencil-reference set covers eye (write), hair (read not-equal),
4218
4293
  // and hairOverEyes (read equal). Non-stencil pipelines ignore the value.
4219
4294
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
4220
- // Order matters (the black-hull saga): transparent color draws don't write
4221
- // depth (self-overlap must double-blend, not patchwork), so ALL outlines
4222
- // draw after the transparent depth PREPASS has recorded the fabric's depth
4223
- // — otherwise every hull behind a sheer skirt shows straight through it.
4295
+ }
4296
+ renderModelOpaquePhase(pass, inst) {
4297
+ this.setModelDrawState(pass, inst);
4224
4298
  this.drawMaterials(pass, inst, "opaque");
4225
4299
  this.drawHairOverEyes(pass, inst);
4300
+ }
4301
+ renderModelTransparentPhase(pass, inst) {
4302
+ this.setModelDrawState(pass, inst);
4303
+ // Transparent: babylon-mmd's forceDepthWrite blending — PMX author order
4304
+ // with depth write ON. The accepted trade-off after trying every variant:
4305
+ // · depth-write ON (this): a fold hides its far side; rare view-dependent
4306
+ // double-blend seams at some angles. MMD's own known behavior.
4307
+ // · nearest-surface prepass: view-independent, but punched see-through
4308
+ // holes to whatever sat far behind a fold.
4309
+ // · depth-write OFF layering: every overlap visible everywhere — MORE
4310
+ // gray patches and texture artifacts in practice.
4226
4311
  this.drawMaterials(pass, inst, "transparent");
4227
- this.drawOutlines(pass, inst, "opaque-outline");
4228
- this.drawOutlines(pass, inst, "transparent-outline");
4229
4312
  }
4230
4313
  /** Depth-only re-draw of transparent-bucket materials (see depth-prepass.ts).
4231
- * Unused while transparent draws write depth themselves (MMD parity) — kept
4232
- * for the dormant no-write/OIT path. */
4314
+ * Dormant kept for a future order-independent-transparency path. */
4233
4315
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
4234
4316
  drawTransparentDepthPrepass(pass, inst) {
4235
4317
  let bound = false;
@@ -4291,6 +4373,10 @@ export class Engine {
4291
4373
  this.cameraMatrixData[32] = cameraPos.x;
4292
4374
  this.cameraMatrixData[33] = cameraPos.y;
4293
4375
  this.cameraMatrixData[34] = cameraPos.z;
4376
+ // Spare float after viewPos: render-target height in device px — the outline
4377
+ // shader derives the full viewport (width via projection aspect) for its
4378
+ // babylon-mmd constant-pixel edge extrusion.
4379
+ this.cameraMatrixData[35] = this.canvas.height;
4294
4380
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData);
4295
4381
  // 360 backdrop: the composite reconstructs each pixel's view ray from the
4296
4382
  // camera basis — refresh it every frame the skybox is active. The view matrix
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { Engine, DEFAULT_BLOOM_OPTIONS, DEFAULT_VIEW_TRANSFORM, type EngineStats, type EngineOptions, type BloomOptions, type ViewTransformOptions, type LoadModelFromFilesOptions, type MaterialPreset, type MaterialPresetMap, type ModelTransform, type GizmoDragEvent, type GizmoDragCallback, type GizmoDragKind, type BackgroundEffectParamValue, type BackgroundEffectResult, } from "./engine";
1
+ export { Engine, DEFAULT_BLOOM_OPTIONS, DEFAULT_VIEW_TRANSFORM, DEFAULT_COLOR_GRADING, type ColorGradingOptions, type EngineStats, type EngineOptions, type BloomOptions, type ViewTransformOptions, type LoadModelFromFilesOptions, type MaterialPreset, type MaterialPresetMap, type ModelTransform, type GizmoDragEvent, type GizmoDragCallback, type GizmoDragKind, type BackgroundEffectParamValue, type BackgroundEffectResult, } from "./engine";
2
2
  export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload";
3
3
  export { compileGraph, validateGraph, assignStyleSlots, type CompileOptions, type CompileResult, type StyleSlot, } from "./graph/compile";
4
4
  export type { ShaderGraph, GraphNode, GraphLink, ExposedParam, SocketValue, Diagnostic, } from "./graph/schema";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,MAAM,EACN,qBAAqB,EACrB,sBAAsB,EACtB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,oBAAoB,EACzB,KAAK,yBAAyB,EAC9B,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,GAC5B,MAAM,UAAU,CAAA;AACjB,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,KAAK,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AACvG,OAAO,EACL,YAAY,EACZ,aAAa,EACb,gBAAgB,EAChB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,SAAS,GACf,MAAM,iBAAiB,CAAA;AACxB,YAAY,EACV,WAAW,EACX,SAAS,EACT,SAAS,EACT,YAAY,EACZ,WAAW,EACX,UAAU,GACX,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,KAAK,KAAK,EAAE,MAAM,kBAAkB,CAAA;AAC3E,OAAO,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAC7G,YAAY,EACV,UAAU,EACV,eAAe,EACf,sBAAsB,EACtB,qBAAqB,GACtB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACjD,OAAO,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAA;AACjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAA;AAC/D,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AACnD,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAC3D,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACjD,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAC/B,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AACzC,YAAY,EACV,aAAa,EACb,oBAAoB,EACpB,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,iBAAiB,EACjB,YAAY,GACb,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AACjC,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7D,OAAO,EAAE,eAAe,EAAE,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAA;AACrE,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,MAAM,EACN,qBAAqB,EACrB,sBAAsB,EACtB,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,oBAAoB,EACzB,KAAK,yBAAyB,EAC9B,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,GAC5B,MAAM,UAAU,CAAA;AACjB,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,KAAK,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AACvG,OAAO,EACL,YAAY,EACZ,aAAa,EACb,gBAAgB,EAChB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,SAAS,GACf,MAAM,iBAAiB,CAAA;AACxB,YAAY,EACV,WAAW,EACX,SAAS,EACT,SAAS,EACT,YAAY,EACZ,WAAW,EACX,UAAU,GACX,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,KAAK,KAAK,EAAE,MAAM,kBAAkB,CAAA;AAC3E,OAAO,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAC7G,YAAY,EACV,UAAU,EACV,eAAe,EACf,sBAAsB,EACtB,qBAAqB,GACtB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACjD,OAAO,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAA;AACjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAA;AAC/D,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AACnD,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAC3D,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACjD,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAC/B,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AACzC,YAAY,EACV,aAAa,EACb,oBAAoB,EACpB,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,iBAAiB,EACjB,YAAY,GACb,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AACjC,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7D,OAAO,EAAE,eAAe,EAAE,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAA;AACrE,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { Engine, DEFAULT_BLOOM_OPTIONS, DEFAULT_VIEW_TRANSFORM, } from "./engine";
1
+ export { Engine, DEFAULT_BLOOM_OPTIONS, DEFAULT_VIEW_TRANSFORM, DEFAULT_COLOR_GRADING, } from "./engine";
2
2
  export { parsePmxFolderInput, pmxFileAtRelativePath } from "./folder-upload";
3
3
  export { compileGraph, validateGraph, assignStyleSlots, } from "./graph/compile";
4
4
  export { NODE_REGISTRY } from "./graph/registry";
@@ -1 +1 @@
1
- {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;;yEAayE;AACzE,MAAM,MAAM,qBAAqB,GAAG;IAClC,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAA;IACZ,8EAA8E;IAC9E,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AA8HD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAWlF;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
1
+ {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;;yEAayE;AACzE,MAAM,MAAM,qBAAqB,GAAG;IAClC,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAA;IACZ,8EAA8E;IAC9E,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAyKD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAelF;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
@@ -17,7 +17,7 @@ override APPLY_GAMMA: bool = true;
17
17
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
18
18
  @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
19
19
  @group(0) @binding(2) var bloomSamp: sampler;
20
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 7>;
20
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 10>;
21
21
  // Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
22
22
  // .g = accumulated canvas alpha (what hdr.a carried before the HDR format
23
23
  // became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
@@ -40,6 +40,8 @@ override APPLY_GAMMA: bool = true;
40
40
  // viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
41
41
  // viewU[5] = (camera forward, _) — refreshed per frame while skybox/effect active.
42
42
  // viewU[6] = (time seconds, effect on/off, canvas width, canvas height).
43
+ // viewU[7] = (grade offset.rgb, contrast); viewU[8] = (grade power.rgb, saturation);
44
+ // viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
43
45
  // invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
44
46
  @group(0) @binding(6) var bgEquirect: texture_2d<f32>;
45
47
 
@@ -59,6 +61,24 @@ fn filmic(x: f32) -> f32 {
59
61
 
60
62
  /** Canvas size in pixels — for user background effects (aspect correction). */
61
63
  fn bgResolution() -> vec2f { return viewU[6].zw; }
64
+
65
+ /** Color grading, applied to the tonemapped SCENE (not the background — see the
66
+ * call site). The core is ASC CDL, the film-industry interchange standard:
67
+ *
68
+ * out = (in · slope + offset) ^ power then saturation (SOP → SAT)
69
+ *
70
+ * Using the real standard rather than invented controls means a look authored
71
+ * here maps onto any grading tool. slope/offset/power are derived on the CPU
72
+ * from the UI's shadow/midtone/highlight colors (see setColorGrading), so the
73
+ * per-pixel cost is one mul-add, one pow, one lerp. */
74
+ fn grade(c: vec3f) -> vec3f {
75
+ var x = pow(max(c * viewU[9].xyz + viewU[7].xyz, vec3f(0.0)), viewU[8].xyz);
76
+ // Contrast pivots on 0.5 — display-referred midpoint, since we grade post-Filmic.
77
+ x = (x - vec3f(0.5)) * viewU[7].w + vec3f(0.5);
78
+ // Rec.709 luma, matching the ASC SAT node.
79
+ let luma = dot(x, vec3f(0.2126, 0.7152, 0.0722));
80
+ return max(mix(vec3f(luma), x, viewU[8].w), vec3f(0.0));
81
+ }
62
82
  `;
63
83
  const COMPOSITE_BODY = /* wgsl */ `
64
84
  @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
@@ -84,6 +104,13 @@ const COMPOSITE_BODY = /* wgsl */ `
84
104
  let exposed = combined * exp2(viewU[0].x);
85
105
  let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
86
106
  var disp = max(tm, vec3f(0.0));
107
+ // Grade the SCENE only, before the display gamma. Deliberately not applied to
108
+ // the background: it keeps a picked background color exactly as picked, and —
109
+ // load-bearing — leaves green-screen mode's key color unshifted so chroma
110
+ // keying still works. Skipped entirely when the grade is neutral.
111
+ if (viewU[9].w > 0.5) {
112
+ disp = grade(disp);
113
+ }
87
114
  if (APPLY_GAMMA) {
88
115
  disp = pow(disp, vec3f(viewU[0].y));
89
116
  }
@@ -94,7 +121,7 @@ const COMPOSITE_BODY = /* wgsl */ `
94
121
  var bgA = select(0.0, 1.0, bg.w > 0.5);
95
122
  var bgPm = bg.rgb * bgA; // premultiplied accumulator
96
123
  let fxOn = viewU[6].y > 0.5;
97
- if (bg.w > 1.5 || fxOn) {
124
+ if ((bg.w > 1.5 || fxOn) COVERAGE_GATE) {
98
125
  // The equirect and any effect both need this pixel's world-space view ray,
99
126
  // rebuilt from the camera basis. The dome sits at infinity (no parallax) —
100
127
  // PhotoDome-style, display-only.
@@ -126,16 +153,31 @@ const EFFECT_CALL = /* wgsl */ `
126
153
  bgA = fx.a + bgA * (1.0 - fx.a);
127
154
  }
128
155
  `;
156
+ // Derivative builtins are illegal in non-uniform control flow (WGSL uniformity
157
+ // analysis rejects the pipeline), so the coverage gate below can only wrap
158
+ // effect code that doesn't use them. Checked textually at build time.
159
+ const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/;
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) {
167
+ const gated = !effect || !USES_DERIVATIVES.test(effect.wgsl);
168
+ return gated ? "&& alpha < 0.999" : "";
169
+ }
129
170
  export function buildCompositeShader(effect) {
130
171
  if (!effect)
131
- return COMPOSITE_HEAD + COMPOSITE_BODY.replace("BG_EFFECT_CALL", NO_EFFECT_CALL);
172
+ return (COMPOSITE_HEAD +
173
+ COMPOSITE_BODY.replace("BG_EFFECT_CALL", NO_EFFECT_CALL).replace("COVERAGE_GATE", coverageGate(null)));
132
174
  return (COMPOSITE_HEAD +
133
175
  "\n// ── user background effect (setBackgroundEffect) ──\n" +
134
176
  effect.paramsDecl +
135
177
  "\n" +
136
178
  effect.wgsl +
137
179
  "\n" +
138
- COMPOSITE_BODY.replace("BG_EFFECT_CALL", EFFECT_CALL.trim()));
180
+ COMPOSITE_BODY.replace("BG_EFFECT_CALL", EFFECT_CALL.trim()).replace("COVERAGE_GATE", coverageGate(effect)));
139
181
  }
140
182
  /** Kept for compatibility with existing imports (the base, no-effect shader). */
141
183
  export const COMPOSITE_SHADER_WGSL = buildCompositeShader(null);
@@ -1,2 +1,2 @@
1
- export declare const OUTLINE_SHADER_WGSL = "\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct MaterialUniforms {\n edgeColor: vec4f,\n edgeSize: f32,\n _padding1: f32,\n _padding2: f32,\n _padding3: f32,\n};\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var<uniform> material: MaterialUniforms;\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n};\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n\n var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);\n var skinnedNrm = vec3f(0.0, 0.0, 0.0);\n for (var i = 0u; i < 4u; i++) {\n let j = joints0[i];\n let w = normalizedWeights[i];\n let m = skinMats[j];\n skinnedPos += (m * pos4) * w;\n let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);\n skinnedNrm += (r3 * normal) * w;\n }\n let worldPos = skinnedPos.xyz;\n let worldNormal = normalize(skinnedNrm);\n\n // Screen-space outline extrusion \u2014 MMD-style pixel-stable edge line.\n // 1. Project position and normal-as-direction to clip space.\n // 2. Normalize the 2D clip-space normal, aspect-compensated so \"one pixel horizontally\"\n // matches \"one pixel vertically\" (otherwise wide viewports squash the outline in X).\n // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w\n // so the perspective divide cancels out \u2192 offset stays constant in NDC regardless\n // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.\n // 4. edgeScale is in NDC-y units per PMX edgeSize. \u2248 0.006 gives ~3px at 1080p; it's\n // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.\n let viewProj = camera.projection * camera.view;\n let clipPos = viewProj * vec4f(worldPos, 1.0);\n let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;\n // projection is column-major: proj[0][0] = 1/(aspect\u00B7tan(fov/2)), proj[1][1] = 1/tan(fov/2).\n // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).\n let aspect = camera.projection[1][1] / camera.projection[0][0];\n let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));\n let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);\n let edgeScale = 0.0016;\n let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;\n output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);\n return output;\n}\n\nstruct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };\n@fragment fn fs() -> FSOut {\n var out: FSOut;\n out.color = material.edgeColor;\n out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);\n return out;\n}\n";
1
+ export declare const OUTLINE_SHADER_WGSL = "\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n // Render-target height in device pixels (engine writes it each frame);\n // width is recovered via the projection matrix's aspect.\n viewportHeight: f32,\n};\n\nstruct MaterialUniforms {\n edgeColor: vec4f,\n edgeSize: f32,\n _padding1: f32,\n _padding2: f32,\n _padding3: f32,\n};\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var edgeSampler: sampler;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var<uniform> material: MaterialUniforms;\n@group(2) @binding(1) var diffuseTexture: texture_2d<f32>;\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n};\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n\n var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);\n var skinnedNrm = vec3f(0.0, 0.0, 0.0);\n for (var i = 0u; i < 4u; i++) {\n let j = joints0[i];\n let w = normalizedWeights[i];\n let m = skinMats[j];\n skinnedPos += (m * pos4) * w;\n let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);\n skinnedNrm += (r3 * normal) * w;\n }\n let worldPos = skinnedPos.xyz;\n let worldNormal = normalize(skinnedNrm);\n\n let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);\n\n // babylon-mmd: screenNormal = normalize((view * worldNormal).xy)\n let viewNormal = (camera.view * vec4f(worldNormal, 0.0)).xyz;\n let snLen = length(viewNormal.xy);\n let screenNormal = select(vec2f(0.0, 0.0), viewNormal.xy / snLen, snLen > 1e-5);\n\n // Reference-height normalization (babylon-mmd ships this variant commented\n // out as `renderHeight = 1080`): thickness is a constant FRACTION of the\n // frame \u2014 2\u00B7edgeSize px at 1080p \u2014 so retina DPR and 4K export don't thin\n // the rims to sub-pixel. Width follows the projection aspect.\n // projection[1][1]/projection[0][0] = width/height for a symmetric frustum.\n let aspect = camera.projection[1][1] / camera.projection[0][0];\n let viewport = vec2f(1080.0 * aspect, 1080.0);\n\n // NDC offset = edgeSize \u00B7 4/viewport, \u00D7w so the perspective divide cancels:\n // constant screen thickness at any distance (babylon-mmd parity).\n let offset = screenNormal * (material.edgeSize * 4.0 / viewport) * clipPos.w;\n output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);\n output.uv = uv;\n return output;\n}\n\nstruct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };\n@fragment fn fs(input: VertexOutput) -> FSOut {\n // Rim alpha FOLLOWS the fabric's texture alpha instead of a hard alpha test:\n // MMD draws blend-material edges solid (only cutout materials alpha-test), so\n // a 0.4 discard erased the whole hull on semi-transparent cloth \u2014 stockinged\n // legs crossing lost their outline entirely. Modulating instead keeps a\n // proportional rim on sheer weave (never a solid black hull) and still\n // discards true cut-out margins like hair-card borders.\n let texA = textureSample(diffuseTexture, edgeSampler, input.uv).a;\n if (texA < 0.05) {\n discard;\n }\n var out: FSOut;\n out.color = vec4f(material.edgeColor.rgb, material.edgeColor.a * texA);\n out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);\n return out;\n}\n";
2
2
  //# sourceMappingURL=outline.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"outline.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/outline.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,mBAAmB,klGAgF/B,CAAA"}
1
+ {"version":3,"file":"outline.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/outline.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,mBAAmB,ksHAgG/B,CAAA"}