fix(render): close #52 — lifestone visible (alpha-test + cull + uDrawIDOffset)

Three root causes regressed the Holtburg lifestone since the WB rendering
migration (Phase N.5 retirement amendment, commit dcae2b6, 2026-05-08).
All confirmed via temporary [LIFESTONE-DIAG] instrumentation and visually
verified by the user through the +Acdream test character.

1. **Alpha-test discard** in mesh_modern.frag transparent pass killed
   high-α pixels of dat-flagged transparent surfaces. Native AC
   transparent surfaces routinely include effectively-opaque pixels —
   e.g. the lifestone crystal core (surface 0x080011DE) — that compose
   correctly under (SrcAlpha, 1-SrcAlpha) blending. The original N.5
   §2 rationale ("high-α belongs in opaque pass") doesn't hold for
   surfaces flagged transparent at the dat level: those pixels can't
   reach the opaque pass at all. Fix: remove `α >= 0.95 discard` from
   the transparent pass, keep `α < 0.05 discard` as a fragment-cost
   optimization (skip totally-empty pixels).

2. **Cull state** for the transparent pass was unset by
   WbDrawDispatcher after the N.5 retirement amendment deleted
   StaticMeshRenderer.cs (which had the Phase 9.2 setup at commit
   6f1971a, 2026-04-11). Closed-shell translucents — lifestone crystal,
   glow gems — need GL_CULL_FACE + GL_BACK + GL_CCW in the transparent
   pass; otherwise back faces composite over front faces in iteration
   order under DepthMask(false). Fix: re-establish Phase 9.2's exact
   GL state setup at the top of Phase 8.

3. **uDrawIDOffset uniform** was missing from mesh_modern.vert.
   gl_DrawIDARB resets to 0 at the start of each
   glMultiDrawElementsIndirect call, so the transparent pass — which
   begins later in the indirect buffer — was fetching
   Batches[0..transparentCount) instead of its actual section at
   Batches[opaqueCount..end). The lifestone crystal ended up reading
   the FIRST OPAQUE batch's TextureHandle every frame; as the camera
   moved and the front-to-back opaque sort reordered which group
   landed at BatchData[0], the crystal's apparent texture flickered to
   whatever sat first — typically the player character's body parts.
   Fix: add `uniform int uDrawIDOffset` to the vertex shader, change
   Batches[gl_DrawIDARB] → Batches[uDrawIDOffset + gl_DrawIDARB], and
   set the uniform per-pass in WbDrawDispatcher (0 for opaque,
   _opaqueDrawCount for transparent). Mirrors WorldBuilder's
   BaseObjectRenderManager.cs line 845.

Tests: 1688/1696 passing (8 pre-existing physics/input failures
unchanged). N.5b conformance sentinel 94/94 clean.

Visual: Holtburg lifestone now renders with the spinning blue crystal
correctly composed over the pedestal. Other transparent content (glass,
particle effects, NPC clothing) is unaffected — the same uniform fix
applies globally and is correct for all transparent draws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 15:49:05 +02:00
parent c111312e13
commit e40159f4d6
3 changed files with 69 additions and 3 deletions

View file

@ -86,8 +86,24 @@ void main() {
if (uRenderPass == 0) {
if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C)
} else {
if (color.a >= 0.95) discard; // transparent pass
if (color.a < 0.05) discard; // skip totally-empty
// Transparent pass.
//
// Phase Post-A.5 (ISSUE #52, 2026-05-10): do NOT discard α≥0.95 here.
// Native AC transparent-flagged surfaces routinely include
// effectively-opaque pixels — e.g. the Holtburg lifestone crystal core
// (surface 0x080011DE) which the spawn manifest classifies as
// transparent (batch.IsTransparent=True) but whose decoded texture
// alpha lands ≥0.95 across the visible surface. Those pixels still
// compose correctly under (SrcAlpha, 1-SrcAlpha) alpha-blending, so
// discarding them here threw away the whole crystal. The original
// N.5 §2 rationale (high-α fragments belong in the opaque pass) does
// not apply when the SURFACE is dat-flagged transparent — those
// pixels can't reach the opaque pass at all.
//
// Keep the α<0.05 short-circuit as a fragment-cost optimization
// (skip fully-empty pixels — saves blend bandwidth on alpha-keyed
// sprites with large transparent margins).
if (color.a < 0.05) discard;
}
vec3 N = normalize(vNormal);

View file

@ -39,6 +39,21 @@ layout(std430, binding = 1) readonly buffer BatchBuffer {
uniform mat4 uViewProjection;
// Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[].
// gl_DrawIDARB resets to 0 at the start of each glMultiDrawElementsIndirect
// call, so the transparent pass — which begins later in the indirect buffer
// — was fetching Batches[0..transparentCount) instead of its actual section
// at Batches[opaqueCount..end). The lifestone crystal (a transparent draw)
// ended up reading the FIRST OPAQUE batch's TextureHandle every frame. As
// the camera moved and the opaque front-to-back sort reordered which group
// landed at BatchData[0], the lifestone's apparent texture flickered to
// whatever was first — frequently the player character's body parts.
//
// WbDrawDispatcher.Draw sets this to 0 before the opaque MDI call and to
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
uniform int uDrawIDOffset;
out vec3 vNormal;
out vec2 vTexCoord;
out vec3 vWorldPos;
@ -56,7 +71,7 @@ void main() {
vNormal = normalize(mat3(model) * aNormal);
vTexCoord = aTexCoord;
BatchData b = Batches[gl_DrawIDARB];
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
vTextureHandle = b.textureHandle;
vTextureLayer = b.textureLayer;
}

View file

@ -544,6 +544,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// (no MSAA) skip the unnecessary GL state change.
if (AlphaToCoverage) _gl.Enable(EnableCap.SampleAlphaToCoverage);
_shader.SetInt("uRenderPass", 0);
// Phase Post-A.5 (ISSUE #52, 2026-05-10): opaque section of
// Batches[] starts at index 0. See uDrawIDOffset comment in
// mesh_modern.vert for why this is needed.
_shader.SetInt("uDrawIDOffset", 0);
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque);
_gl.MultiDrawElementsIndirect(
@ -562,6 +566,37 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
_gl.DepthMask(false);
// Phase Post-A.5 (ISSUE #52, 2026-05-10): transparent section of
// Batches[] starts at index _opaqueDrawCount. Without this offset,
// each transparent draw reads BatchData[0..transparentCount) — the
// OPAQUE section — and the lifestone crystal's apparent texture
// flickers to whatever opaque batch sorted first that frame. See
// uDrawIDOffset comment in mesh_modern.vert.
_shader.SetInt("uDrawIDOffset", _opaqueDrawCount);
// Phase Post-A.5 (ISSUE #52, 2026-05-10): re-establish Phase 9.2's
// back-face cull setup. The legacy StaticMeshRenderer had this
// (commit 6f1971a, 2026-04-11) until the N.5 retirement amendment
// (commit dcae2b6, 2026-05-08) deleted that renderer; the new
// WbDrawDispatcher never inherited the cull-face state.
//
// Closed-shell translucent meshes — lifestone crystal, glow gems,
// any convex blended mesh — NEED back-face culling in the
// translucent pass. Without it, back faces composite OVER front
// faces in arbitrary iteration order, because DepthMask(false)
// means nothing records depth within the translucent set. The
// result is the user-visible "one face missing, see into the
// hollow interior" + frame-to-frame color flicker as rotation
// shifts the triangle order.
//
// Our fan triangulation emits pos-side polygons as (0, i, i+1) —
// CCW in standard OpenGL conventions — so GL_BACK + CCW-front is
// the correct state. Matches WorldBuilder's per-batch CullMode
// handling. Neg-side polygons (rare on translucent AC content)
// use reversed winding and get culled here, matching the opaque
// pass and the original Phase 9.2 fix's known limitation.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_shader.SetInt("uRenderPass", 1);
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent);
_gl.MultiDrawElementsIndirect(