reze-engine 0.25.0 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js CHANGED
@@ -12,6 +12,7 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
12
12
  import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow";
13
13
  import { GROUND_SHADOW_SHADER_WGSL } from "./shaders/passes/ground";
14
14
  import { OUTLINE_SHADER_WGSL } from "./shaders/passes/outline";
15
+ import { TRANSPARENT_DEPTH_PREPASS_WGSL } from "./shaders/passes/depth-prepass";
15
16
  import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection";
16
17
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
17
18
  import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
@@ -192,17 +193,22 @@ function buildAlphaSampler(source, rgba, width, height) {
192
193
  return null;
193
194
  }
194
195
  }
195
- /** Average texture alpha over ≤400 of the material's triangle centroids
196
- * below the threshold the material renders as a transparent-bucket draw. */
196
+ /** Texture-alpha statistics over ≤400 of the material's triangle centroids:
197
+ * `avg` (0..1) and `translucentFrac` the fraction of samples that are
198
+ * neither fully opaque nor fully cut out (alpha in ~0.03..0.97). Together
199
+ * Bucketing itself is binary (babylon-mmd parity): ANY translucent coverage
200
+ * routes to the alpha-blend bucket. `avg` below this threshold additionally
201
+ * marks a material as fully sheer (a veil), which vetoes shadow casting. */
197
202
  const SHEER_ALPHA_THRESHOLD = 0.7;
198
- function materialIsSheer(verts, indices, firstIndex, count, sampler) {
203
+ function materialAlphaStats(verts, indices, firstIndex, count, sampler) {
199
204
  if (!sampler)
200
- return false;
205
+ return { avg: 1, translucentFrac: 0 };
201
206
  const triCount = Math.floor(count / 3);
202
207
  if (triCount === 0)
203
- return false;
208
+ return { avg: 1, translucentFrac: 0 };
204
209
  const step = Math.max(1, Math.floor(triCount / 400));
205
210
  let sum = 0;
211
+ let translucent = 0;
206
212
  let n = 0;
207
213
  for (let t = 0; t < triCount; t += step) {
208
214
  const i0 = indices[firstIndex + t * 3];
@@ -213,10 +219,15 @@ function materialIsSheer(verts, indices, firstIndex, count, sampler) {
213
219
  // Wrap (MMD UVs may tile), then nearest-sample the downsampled plane.
214
220
  const x = Math.min(sampler.w - 1, Math.max(0, Math.floor((u - Math.floor(u)) * sampler.w)));
215
221
  const y = Math.min(sampler.h - 1, Math.max(0, Math.floor((v - Math.floor(v)) * sampler.h)));
216
- sum += sampler.a[y * sampler.w + x];
222
+ const a = sampler.a[y * sampler.w + x];
223
+ sum += a;
224
+ if (a > 8 && a < 247)
225
+ translucent++;
217
226
  n++;
218
227
  }
219
- return n > 0 && sum / n < SHEER_ALPHA_THRESHOLD * 255;
228
+ if (n === 0)
229
+ return { avg: 1, translucentFrac: 0 };
230
+ return { avg: sum / n / 255, translucentFrac: translucent / n };
220
231
  }
221
232
  export class Engine {
222
233
  static getInstance() {
@@ -322,6 +333,13 @@ export class Engine {
322
333
  };
323
334
  this.animationFrameId = null;
324
335
  this.renderLoopCallback = null;
336
+ /** Debug/diagnostic: skip every inverted-hull outline draw. */
337
+ // OFF by default — the product aesthetic. Modern high-detail models read
338
+ // better without hulls (babylon-mmd's own demos disable its outline renderer
339
+ // too), and no hull pass means no depth-tie edge cases against near-coplanar
340
+ // cloth. The full MMD-faithful machinery (interleaved per-material hulls,
341
+ // texture-alpha-modulated rims) stays in place behind setOutlineEnabled(true).
342
+ this.outlineEnabled = false;
325
343
  /** When set, render resolution is pinned to this size instead of tracking the
326
344
  * canvas's CSS size × devicePixelRatio (see setRenderSize). */
327
345
  this.fixedRenderSize = null;
@@ -640,6 +658,9 @@ export class Engine {
640
658
  if (this.device && this.compositeUniformBuffer)
641
659
  this.writeCompositeViewUniforms();
642
660
  }
661
+ setOutlineEnabled(on) {
662
+ this.outlineEnabled = on;
663
+ }
643
664
  rebuildCompositeBindGroup() {
644
665
  if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView)
645
666
  return;
@@ -1174,6 +1195,8 @@ export class Engine {
1174
1195
  attributes: [
1175
1196
  { shaderLocation: 0, offset: 0, format: "float32x3" },
1176
1197
  { shaderLocation: 1, offset: 3 * 4, format: "float32x3" },
1198
+ // uv — the outline FS alpha-tests the diffuse texture (babylon-mmd parity)
1199
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" },
1177
1200
  ],
1178
1201
  },
1179
1202
  {
@@ -1300,6 +1323,44 @@ export class Engine {
1300
1323
  depthCompare: "less-equal",
1301
1324
  },
1302
1325
  });
1326
+ // Depth-write-off twin for transparent-bucket draws (see pipelineForDrawCall).
1327
+ this.neutralPipelineNoDepthWrite = this.createRenderPipeline({
1328
+ label: "neutral base pipeline (no depth write)",
1329
+ layout: mainPipelineLayout,
1330
+ shaderModule: neutralModule,
1331
+ vertexBuffers: fullVertexBuffers,
1332
+ fragmentTargets: sceneTargets,
1333
+ cullMode: "none",
1334
+ depthStencil: {
1335
+ format: "depth24plus-stencil8",
1336
+ depthWriteEnabled: false,
1337
+ depthCompare: "less-equal",
1338
+ },
1339
+ });
1340
+ // Depth-only prepass for transparent draws (see depth-prepass.ts): writes the
1341
+ // fabric's depth AFTER its color blended, so outlines drawn later are
1342
+ // occluded behind it. Color targets kept for pass compatibility, writeMask 0.
1343
+ const prepassModule = this.device.createShaderModule({
1344
+ label: "transparent depth prepass",
1345
+ code: TRANSPARENT_DEPTH_PREPASS_WGSL,
1346
+ });
1347
+ this.transparentDepthPrepassPipeline = this.device.createRenderPipeline({
1348
+ label: "transparent depth prepass",
1349
+ layout: mainPipelineLayout,
1350
+ vertex: { module: prepassModule, entryPoint: "vs", buffers: fullVertexBuffers },
1351
+ fragment: {
1352
+ module: prepassModule,
1353
+ entryPoint: "fs",
1354
+ targets: sceneTargets.map((t) => ({ format: t.format, writeMask: 0 })),
1355
+ },
1356
+ primitive: { cullMode: "none" },
1357
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
1358
+ depthStencil: {
1359
+ format: "depth24plus-stencil8",
1360
+ depthWriteEnabled: true,
1361
+ depthCompare: "less-equal",
1362
+ },
1363
+ });
1303
1364
  this.shadowLightVPBuffer = this.device.createBuffer({
1304
1365
  size: 64,
1305
1366
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -1390,6 +1451,7 @@ export class Engine {
1390
1451
  label: "outline per-frame bind group layout",
1391
1452
  entries: [
1392
1453
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1454
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
1393
1455
  ],
1394
1456
  });
1395
1457
  // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
@@ -1397,6 +1459,7 @@ export class Engine {
1397
1459
  label: "outline per-material bind group layout",
1398
1460
  entries: [
1399
1461
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1462
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1400
1463
  ],
1401
1464
  });
1402
1465
  const outlinePipelineLayout = this.device.createPipelineLayout({
@@ -1410,7 +1473,10 @@ export class Engine {
1410
1473
  this.outlinePerFrameBindGroup = this.device.createBindGroup({
1411
1474
  label: "outline per-frame bind group",
1412
1475
  layout: this.outlinePerFrameBindGroupLayout,
1413
- entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
1476
+ entries: [
1477
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1478
+ { binding: 1, resource: this.materialSampler },
1479
+ ],
1414
1480
  });
1415
1481
  const outlineShaderModule = this.device.createShaderModule({
1416
1482
  label: "outline shaders",
@@ -1425,9 +1491,21 @@ export class Engine {
1425
1491
  cullMode: "back",
1426
1492
  depthStencil: {
1427
1493
  format: "depth24plus-stencil8",
1428
- // Don’t write outline into depth buffer — stops z-fighting / black cracks vs body (MMD-style; body depth stays authoritative)
1429
- depthWriteEnabled: false,
1494
+ // babylon-mmd draws outlines WITH depth write (its _afterRenderingMesh
1495
+ // forces setDepthWrite(true)); the constant bias below still makes
1496
+ // hulls lose depth ties against their own near-coplanar geometry.
1497
+ depthWriteEnabled: true,
1430
1498
  depthCompare: "less-equal",
1499
+ // CONFIRMED fix (bisected live via setOutlineEnabled): hull fragments
1500
+ // carry their surface's exact depth, so against this model's paired
1501
+ // near-coplanar skirt layers the hulls WON depth ties in patches —
1502
+ // the black shapes on the dress. A small constant bias makes hulls lose
1503
+ // every tie; silhouette rims compare against the far background and are
1504
+ // unaffected. No slope term — slope explodes at silhouettes and would
1505
+ // erase the rims themselves (previous regression).
1506
+ depthBias: 4,
1507
+ depthBiasSlopeScale: 0,
1508
+ depthBiasClamp: 0,
1431
1509
  // Skip fragments where the eye stamped stencil=EYE_VALUE. Those pixels are owned by
1432
1510
  // the see-through-hair blend (hair-over-eyes), so letting the outline's near-black
1433
1511
  // edge color overwrite them would re-introduce the dark almond we just killed.
@@ -2970,17 +3048,25 @@ export class Engine {
2970
3048
  diffuseTexture = this.fallbackMaterialTexture;
2971
3049
  }
2972
3050
  const materialAlpha = mat.diffuse[3];
2973
- // Transparent bucket when the MATERIAL says so — or when the TEXTURE does
2974
- // (sheer cloth almost always ships with diffuse alpha 1.0 and carries its
2975
- // translucency in texture alpha). Transparent-bucket draws happen after
2976
- // the opaque bucket (and after the late-drawn hair render-class), so a
2977
- // veil composites over the hair behind it instead of depth-rejecting it;
2978
- // they are also excluded from the shadow map, so sheer cloth stops
2979
- // casting the solid shadow of an opaque sheet.
2980
3051
  const diffusePath = texLogicalPath(mat.diffuseTextureIndex);
2981
3052
  const alphaSampler = diffusePath ? this.textureAlphaCache.get(diffusePath) : null;
2982
- const isTransparent = materialAlpha < 1.0 - 0.001 ||
2983
- materialIsSheer(meshVertices, meshIndices, currentIndexOffset, indexCount, alphaSampler);
3053
+ const stats = materialAlphaStats(meshVertices, meshIndices, currentIndexOffset, indexCount, alphaSampler);
3054
+ // babylon-mmd parity (its default DepthWriteAlphaBlendingWithEvaluation
3055
+ // method): the bucket decision is BINARY. A material with ANY translucent
3056
+ // texels on its geometry is alpha-blend — drawn in PMX author order with
3057
+ // depth write ON (forceDepthWrite); everything else is opaque. The old
3058
+ // avg/frac tier system left mostly-opaque lace (translucentFrac 0.09) in
3059
+ // the opaque bucket while its sibling panels went transparent, breaking
3060
+ // the author's compositing order — the gray fold patches. The 2% floor
3061
+ // only guards against centroid-sampling noise on genuinely solid cloth.
3062
+ const sheer = stats.avg < SHEER_ALPHA_THRESHOLD;
3063
+ const isTransparent = materialAlpha < 1.0 - 0.001 || sheer || stats.translucentFrac > 0.02;
3064
+ // Shadow casting: the PMX author's own flag (bit 0x04, cast self-shadow),
3065
+ // still vetoed for fully sheer cloth — a veil must not cast a solid sheet.
3066
+ const castsShadow = (mat.edgeFlag & 0x04) !== 0 && !sheer;
3067
+ // Load-time classification log — one line per material, cheap and
3068
+ // invaluable when a model renders wrong (bucket/outline/shadow disputes).
3069
+ 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"}`);
2984
3070
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
2985
3071
  // rare and not implemented — treated as none, like a failed load.
2986
3072
  let sphereMode = mat.sphereMode === 1 || mat.sphereMode === 2 ? mat.sphereMode : 0;
@@ -3011,16 +3097,12 @@ export class Engine {
3011
3097
  // Ungrouped at load — binding(4) = zero buffer, neutral base pipeline. autoStyleGroups
3012
3098
  // / applyStyleGroups rebind grouped materials to their group's buffer + pipeline.
3013
3099
  const bindGroup = this.createMaterialBindGroup(`${prefix}material: ${mat.name}`, baseBindGroupEntries, this.zeroStyleBuffer);
3014
- const type = isTransparent ? "transparent" : "opaque";
3015
- inst.drawCalls.push({
3016
- type,
3017
- count: indexCount,
3018
- firstIndex: currentIndexOffset,
3019
- bindGroup,
3020
- materialName: mat.name,
3021
- groupId: null,
3022
- baseBindGroupEntries,
3023
- });
3100
+ // Inverted-hull outline for EVERY edge-flagged material (PMX bit 0x10) —
3101
+ // the outline FS alpha-tests the diffuse texture, so sheer fabric masks
3102
+ // its own hull where it is see-through instead of us skipping it here.
3103
+ // Drawn interleaved right after this material's color draw (babylon-mmd's
3104
+ // per-mesh afterRender outline stage) — see drawMaterials.
3105
+ let outline;
3024
3106
  if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
3025
3107
  const materialUniformData = new Float32Array([
3026
3108
  mat.edgeColor[0],
@@ -3037,18 +3119,25 @@ export class Engine {
3037
3119
  const outlineBindGroup = this.device.createBindGroup({
3038
3120
  label: `${prefix}outline: ${mat.name}`,
3039
3121
  layout: this.outlinePerMaterialBindGroupLayout,
3040
- entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
3041
- });
3042
- const outlineType = isTransparent ? "transparent-outline" : "opaque-outline";
3043
- inst.drawCalls.push({
3044
- type: outlineType,
3045
- count: indexCount,
3046
- firstIndex: currentIndexOffset,
3047
- bindGroup: outlineBindGroup,
3048
- materialName: mat.name,
3049
- groupId: null,
3122
+ entries: [
3123
+ { binding: 0, resource: { buffer: outlineUniformBuffer } },
3124
+ { binding: 1, resource: textureView },
3125
+ ],
3050
3126
  });
3127
+ outline = { bindGroup: outlineBindGroup };
3051
3128
  }
3129
+ const type = isTransparent ? "transparent" : "opaque";
3130
+ inst.drawCalls.push({
3131
+ type,
3132
+ count: indexCount,
3133
+ firstIndex: currentIndexOffset,
3134
+ bindGroup,
3135
+ materialName: mat.name,
3136
+ groupId: null,
3137
+ baseBindGroupEntries,
3138
+ castsShadow,
3139
+ outline,
3140
+ });
3052
3141
  if (this.onRaycast) {
3053
3142
  const pickIdData = new Float32Array([modelId, materialId, 0, 0]);
3054
3143
  const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData);
@@ -3629,13 +3718,25 @@ export class Engine {
3629
3718
  sp.end();
3630
3719
  }
3631
3720
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
3721
+ // Phase order: opaque models → ground → transparent fabric.
3722
+ // The ground shader is the most expensive full-coverage draw in the frame
3723
+ // (9-tap PCF on the 4096² shadow map per pixel), so it draws AFTER the
3724
+ // opaque phase to get early-z rejected behind the body — drawing it first
3725
+ // shaded every covered pixel and measurably dropped Safari fps. It still
3726
+ // draws BEFORE the transparent phase so sheer fabric blends over the floor
3727
+ // instead of over the background with the floor depth-rejected behind it.
3632
3728
  if (hasModels)
3633
3729
  this.forEachInstance((inst) => {
3634
3730
  if (inst.model.visible)
3635
- this.renderOneModel(pass, inst);
3731
+ this.renderModelOpaquePhase(pass, inst);
3636
3732
  });
3637
3733
  if (this.hasGround)
3638
3734
  this.renderGround(pass);
3735
+ if (hasModels)
3736
+ this.forEachInstance((inst) => {
3737
+ if (inst.model.visible)
3738
+ this.renderModelTransparentPhase(pass, inst);
3739
+ });
3639
3740
  pass.end();
3640
3741
  // Bloom pyramid (EEVEE 3.6):
3641
3742
  // 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
@@ -3904,9 +4005,12 @@ export class Engine {
3904
4005
  return { ok: false, diagnostics, slotMap: result.slotMap };
3905
4006
  }
3906
4007
  let pipeline;
4008
+ let pipelineNoDepthWrite;
3907
4009
  let overEyesPipeline;
3908
4010
  try {
3909
4011
  pipeline = await this.createRenderClassPipeline(renderClass, module, false);
4012
+ // Dormant OIT twin — kept for a future order-independent-transparency path.
4013
+ pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false);
3910
4014
  if (renderClass === "hair")
3911
4015
  overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true);
3912
4016
  }
@@ -3930,6 +4034,7 @@ export class Engine {
3930
4034
  renderClass,
3931
4035
  alphaMode,
3932
4036
  pipeline,
4037
+ pipelineNoDepthWrite,
3933
4038
  overEyesPipeline,
3934
4039
  uniformBuffer,
3935
4040
  slotMap: result.slotMap,
@@ -3988,14 +4093,14 @@ export class Engine {
3988
4093
  ground: 4,
3989
4094
  };
3990
4095
  inst.drawCalls.sort((a, b) => typeOrder[a.type] - typeOrder[b.type] || this.drawCallRank(inst, a) - this.drawCallRank(inst, b));
3991
- inst.shadowDrawCalls = inst.drawCalls.filter((d) => d.type === "opaque");
4096
+ inst.shadowDrawCalls = inst.drawCalls.filter((d) => (d.type === "opaque" || d.type === "transparent") && d.castsShadow === true);
3992
4097
  }
3993
4098
  /**
3994
4099
  * Render-class pipeline state. A group's compiled graph swaps the fragment shading; the
3995
4100
  * render-class owns pass integration (stencil interplay, depth bias, cull). auto = plain;
3996
4101
  * eye = stamp + front cull + bias; hair = stencil-test (+ the over-eyes variant).
3997
4102
  */
3998
- createRenderClassPipeline(renderClass, module, overEyes) {
4103
+ createRenderClassPipeline(renderClass, module, overEyes, depthWrite = true) {
3999
4104
  const base = {
4000
4105
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
4001
4106
  layout: this.mainPipelineLayout,
@@ -4005,7 +4110,7 @@ export class Engine {
4005
4110
  };
4006
4111
  const plainDepth = {
4007
4112
  format: "depth24plus-stencil8",
4008
- depthWriteEnabled: true,
4113
+ depthWriteEnabled: depthWrite,
4009
4114
  depthCompare: "less-equal",
4010
4115
  };
4011
4116
  let depthStencil = plainDepth;
@@ -4050,7 +4155,9 @@ export class Engine {
4050
4155
  });
4051
4156
  }
4052
4157
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
4053
- // the neutral base (ungrouped materials render the default graph).
4158
+ // the neutral base (ungrouped materials render the default graph). Transparent-bucket
4159
+ // draws use the SAME depth-write-on pipeline — babylon-mmd's forceDepthWrite
4160
+ // blending (see renderModelTransparentPhase for the trade-off record).
4054
4161
  pipelineForDrawCall(inst, dc) {
4055
4162
  if (dc.groupId) {
4056
4163
  const install = inst.styleGroups.get(dc.groupId);
@@ -4061,9 +4168,10 @@ export class Engine {
4061
4168
  }
4062
4169
  /**
4063
4170
  * Draw every material of a given type (`opaque` or `transparent`) using the main
4064
- * pipeline(s). Binds the per-frame and per-instance groups once at the top of the
4065
- * batch, then issues one draw per material. Early-outs if nothing to draw so we
4066
- * don't waste bindings when a model has no transparents, etc.
4171
+ * pipeline(s), and babylon-mmd's per-mesh outline stage each edge-flagged
4172
+ * material's inverted hull IMMEDIATELY after its color draw. Interleaving is what
4173
+ * makes outlines compose like MMD: every material drawn later in the author's
4174
+ * order covers earlier hulls, and each hull sits over everything drawn before it.
4067
4175
  */
4068
4176
  drawMaterials(pass, inst, type) {
4069
4177
  let currentPipeline = null;
@@ -4083,36 +4191,27 @@ export class Engine {
4083
4191
  }
4084
4192
  pass.setBindGroup(2, draw.bindGroup);
4085
4193
  pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
4086
- }
4087
- }
4088
- /**
4089
- * Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
4090
- * Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
4091
- * every batch binds its own groups from scratch — the next drawMaterials call will
4092
- * rebind group 0/1 correctly if needed.
4093
- */
4094
- drawOutlines(pass, inst, type) {
4095
- let bound = false;
4096
- for (const draw of inst.drawCalls) {
4097
- if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
4098
- continue;
4099
- if (!bound) {
4194
+ if (draw.outline && this.outlineEnabled) {
4195
+ // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
4196
+ // layout-identical between the main and outline pipelines and stays
4197
+ // bound. Restore group 0 afterwards and force a pipeline re-set.
4100
4198
  pass.setPipeline(this.outlinePipeline);
4101
4199
  pass.setBindGroup(0, this.outlinePerFrameBindGroup);
4102
- pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
4103
- bound = true;
4200
+ pass.setBindGroup(2, draw.outline.bindGroup);
4201
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
4202
+ pass.setBindGroup(0, this.perFrameBindGroup);
4203
+ currentPipeline = null;
4104
4204
  }
4105
- pass.setBindGroup(2, draw.bindGroup);
4106
- pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
4107
4205
  }
4108
4206
  }
4109
4207
  /**
4110
- * Main-pass render sequence for one model instance:
4111
- * 1) opaque bodies 2) opaque outlines 3) transparents → 4) transparent outlines.
4112
- * Each batch binds the groups it needs, so switching between main and outline
4113
- * pipelines is self-contained (no cross-batch dependencies).
4208
+ * Main-pass render sequence for one model instance — babylon-mmd parity:
4209
+ * opaque bucket, the hair-over-eyes stencil pass, then alpha-blend materials
4210
+ * in PMX author order with depth write ON (forceDepthWrite). Outlines are not
4211
+ * a separate phase: drawMaterials draws each edge-flagged material's hull
4212
+ * right after the material itself, like MMD's per-mesh outline stage.
4114
4213
  */
4115
- renderOneModel(pass, inst) {
4214
+ setModelDrawState(pass, inst) {
4116
4215
  pass.setVertexBuffer(0, inst.vertexBuffer);
4117
4216
  pass.setVertexBuffer(1, inst.jointsBuffer);
4118
4217
  pass.setVertexBuffer(2, inst.weightsBuffer);
@@ -4120,11 +4219,41 @@ export class Engine {
4120
4219
  // Single stencil-reference set covers eye (write), hair (read not-equal),
4121
4220
  // and hairOverEyes (read equal). Non-stencil pipelines ignore the value.
4122
4221
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
4222
+ }
4223
+ renderModelOpaquePhase(pass, inst) {
4224
+ this.setModelDrawState(pass, inst);
4123
4225
  this.drawMaterials(pass, inst, "opaque");
4124
- this.drawOutlines(pass, inst, "opaque-outline");
4125
4226
  this.drawHairOverEyes(pass, inst);
4227
+ }
4228
+ renderModelTransparentPhase(pass, inst) {
4229
+ this.setModelDrawState(pass, inst);
4230
+ // Transparent: babylon-mmd's forceDepthWrite blending — PMX author order
4231
+ // with depth write ON. The accepted trade-off after trying every variant:
4232
+ // · depth-write ON (this): a fold hides its far side; rare view-dependent
4233
+ // double-blend seams at some angles. MMD's own known behavior.
4234
+ // · nearest-surface prepass: view-independent, but punched see-through
4235
+ // holes to whatever sat far behind a fold.
4236
+ // · depth-write OFF layering: every overlap visible everywhere — MORE
4237
+ // gray patches and texture artifacts in practice.
4126
4238
  this.drawMaterials(pass, inst, "transparent");
4127
- this.drawOutlines(pass, inst, "transparent-outline");
4239
+ }
4240
+ /** Depth-only re-draw of transparent-bucket materials (see depth-prepass.ts).
4241
+ * Dormant — kept for a future order-independent-transparency path. */
4242
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
4243
+ drawTransparentDepthPrepass(pass, inst) {
4244
+ let bound = false;
4245
+ for (const draw of inst.drawCalls) {
4246
+ if (draw.type !== "transparent" || !this.shouldRenderDrawCall(inst, draw))
4247
+ continue;
4248
+ if (!bound) {
4249
+ pass.setPipeline(this.transparentDepthPrepassPipeline);
4250
+ pass.setBindGroup(0, this.perFrameBindGroup);
4251
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
4252
+ bound = true;
4253
+ }
4254
+ pass.setBindGroup(2, draw.bindGroup);
4255
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
4256
+ }
4128
4257
  }
4129
4258
  /**
4130
4259
  * Second hair pass for the see-through-hair effect. Re-draws every hair-class grouped
@@ -4171,6 +4300,10 @@ export class Engine {
4171
4300
  this.cameraMatrixData[32] = cameraPos.x;
4172
4301
  this.cameraMatrixData[33] = cameraPos.y;
4173
4302
  this.cameraMatrixData[34] = cameraPos.z;
4303
+ // Spare float after viewPos: render-target height in device px — the outline
4304
+ // shader derives the full viewport (width via projection aspect) for its
4305
+ // babylon-mmd constant-pixel edge extrusion.
4306
+ this.cameraMatrixData[35] = this.canvas.height;
4174
4307
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData);
4175
4308
  // 360 backdrop: the composite reconstructs each pixel's view ray from the
4176
4309
  // camera basis — refresh it every frame the skybox is active. The view matrix
@@ -1 +1 @@
1
- {"version":3,"file":"slots.d.ts","sourceRoot":"","sources":["../../src/graph/slots.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAiD5D,eAAO,MAAM,mBAAmB,gHAG/B,CAAA;AAuDD;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,WAAW,EACxB,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,MAAM,EACd,oBAAoB,EAAE,OAAO,GAC5B,MAAM,CAYR"}
1
+ {"version":3,"file":"slots.d.ts","sourceRoot":"","sources":["../../src/graph/slots.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAiD5D,eAAO,MAAM,mBAAmB,gHAG/B,CAAA;AAgED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,WAAW,EACxB,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,MAAM,EACd,oBAAoB,EAAE,OAAO,GAC5B,MAAM,CAYR"}
@@ -67,14 +67,22 @@ function prelude(renderClass, alphaMode) {
67
67
  ? " if (alpha < hashed_alpha_threshold(input.restPos)) { discard; }"
68
68
  : " if (alpha < 0.001) { discard; }";
69
69
  const gate = renderClass === "eye" ? EYE_REAR_GATE : "";
70
+ // Double-sided shading, winding-independent: a normal pointing away from the
71
+ // camera means we're seeing the surface's other side — flip it. Only genuinely
72
+ // camera-averted fragments change (front surfaces have dot(n,v) > 0 and are
73
+ // untouched), unlike a front_facing test, which PMX's clockwise winding
74
+ // inverts. Fixes inner skirt linings (裙子白1-style flipped-normal copies)
75
+ // shading near-black through sheer outer layers. Eye is exempt: it FRONT-culls
76
+ // by design, so its visible fragments are back faces with intended normals.
77
+ const flip = renderClass === "eye" ? "" : "\n n = select(-n, n, dot(n, v) >= 0.0);";
70
78
  return `@fragment fn fs(input: VertexOutput) -> FSOut {
71
79
  let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);
72
80
  // MMD alpha semantics: material alpha × texture alpha.
73
81
  let alpha = material.alpha * tex_s.a;
74
82
  ${discard}
75
83
 
76
- let n = safe_normal(input.normal);
77
- let v = normalize(camera.viewPos - input.worldPos);
84
+ var n = safe_normal(input.normal);
85
+ let v = normalize(camera.viewPos - input.worldPos);${flip}
78
86
  ${gate}
79
87
  let l = -light.lights[0].direction.xyz;
80
88
  let sun = light.lights[0].color.xyz * light.lights[0].color.w;
@@ -1,5 +1,5 @@
1
1
  export declare const COMMON_BINDINGS_WGSL = "\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\n// Per-material uniforms. Every material binds this layout even if it ignores fields;\n// the engine keeps one bind group layout across all material pipelines. The PMX\n// classic-material fields (ambient/specular/shininess) are carried for graph nodes that\n// want them; most graphs read only diffuseColor + alpha.\nstruct MaterialUniforms {\n diffuseColor: vec3f, // PMX diffuse rgb \u2014 the material_diffuse node reads this\n alpha: f32, // 0 \u2192 discard; <1 \u2192 transparent draw call\n ambient: vec3f, // PMX ambient rgb\n shininess: f32, // PMX specular power\n specular: vec3f, // PMX specular rgb\n sphereMode: f32, // 0 none \u00B7 1 multiply (sph) \u00B7 2 add (spa)\n // Skeleton index of the \u982D (head) bone, or -1. Lets the eye shader gate\n // the post-alpha-eye stencil by camera-vs-face hemisphere.\n headBoneIndex: f32,\n _pad0: f32,\n _pad1: f32,\n _pad2: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n // Bind-pose object-space position (the raw pre-skin vertex attribute). Procedural\n // textures (noise bump, sparkle, Generated-coord gradients) key off this instead of\n // worldPos so the pattern rides with the surface \u2014 otherwise the mesh swims through a\n // world-static noise field under any skinning deformation or root (\u30BB\u30F3\u30BF\u30FC) motion.\n // At rest skinMats are identity so restPos == worldPos, which is why existing noise-\n // scale constants stay valid without retuning.\n @location(3) restPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n// binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n// Reserved for future sphere/toon graph nodes; graphs that don't read them get the\n// 1\u00D71 white fallback bound here.\n@group(2) @binding(2) var toonTexture: texture_2d<f32>;\n@group(2) @binding(3) var sphereTexture: texture_2d<f32>;\n\n// Four-bone blended normals can cancel to ~zero on physics-driven parts\n// (opposing bone rotations at 50/50 weights) \u2014 normalize(0) is 0/0 = NaN,\n// which poisons the whole shading stack and flashes through bloom. Fall\n// back to up for degenerate normals instead.\nfn safe_normal(nIn: vec3f) -> vec3f {\n let l2 = dot(nIn, nIn);\n if (l2 < 1e-12) { return vec3f(0.0, 1.0, 0.0); }\n return nIn * inverseSqrt(l2);\n}\n\n";
2
- export declare const SAMPLE_SHADOW_WGSL = "\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\n";
2
+ export declare const SAMPLE_SHADOW_WGSL = "\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 4096.0;\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\n";
3
3
  export declare const COMMON_VS_WGSL = "\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 let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n output.restPos = position;\n return output;\n}\n\n";
4
4
  export declare const COMMON_FS_OUT_WGSL = "\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: vec4f,\n};\n\n";
5
5
  export declare const COMMON_MATERIAL_PRELUDE_WGSL: string;
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AAsBA,eAAO,MAAM,oBAAoB,8pGA+EhC,CAAC;AAOF,eAAO,MAAM,kBAAkB,4xCAsB9B,CAAC;AAQF,eAAO,MAAM,cAAc,s+BA8B1B,CAAC;AAsBF,eAAO,MAAM,kBAAkB,0FAO9B,CAAC;AAMF,eAAO,MAAM,4BAA4B,QACwC,CAAA"}
1
+ {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AAsBA,eAAO,MAAM,oBAAoB,8pGA+EhC,CAAC;AASF,eAAO,MAAM,kBAAkB,4xCAsB9B,CAAC;AAQF,eAAO,MAAM,cAAc,s+BA8B1B,CAAC;AAsBF,eAAO,MAAM,kBAAkB,0FAO9B,CAAC;AAMF,eAAO,MAAM,4BAA4B,QACwC,CAAA"}