diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index 1145dc7..bbcc958 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -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); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index 02f46d9..2b6131f 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -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; } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 6cd34f0..cb27f87 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -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(