#version 430 core #extension GL_ARB_bindless_texture : require in vec3 vNormal; in vec2 vTexCoord; in vec3 vWorldPos; in flat uvec2 vTextureHandle; in flat uint vTextureLayer; // uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test): // 0 = opaque pass — discard fragments with alpha < 0.95 // (lets the depth write succeed for solid pixels) // 1 = translucent pass — covers AlphaBlend / Additive / InvAlpha; // discard alpha >= 0.95 (already drawn opaque) and // alpha < 0.05 (skip empty fragments — large // transparent overdraw cost otherwise) uniform int uRenderPass; // SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1. struct Light { vec4 posAndKind; vec4 dirAndRange; vec4 colorAndIntensity; vec4 coneAngleEtc; }; layout(std140, binding = 1) uniform SceneLighting { Light uLights[8]; vec4 uCellAmbient; vec4 uFogParams; vec4 uFogColor; vec4 uCameraAndTime; }; vec3 accumulateLights(vec3 N, vec3 worldPos) { vec3 lit = uCellAmbient.xyz; int activeLights = int(uCellAmbient.w); for (int i = 0; i < 8; ++i) { if (i >= activeLights) break; int kind = int(uLights[i].posAndKind.w); vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; if (kind == 0) { vec3 Ldir = -uLights[i].dirAndRange.xyz; float ndl = max(0.0, dot(N, Ldir)); lit += Lcol * ndl; } else { vec3 toL = uLights[i].posAndKind.xyz - worldPos; float d = length(toL); float range = uLights[i].dirAndRange.w; if (d < range && range > 1e-3) { vec3 Ldir = toL / max(d, 1e-4); float ndl = max(0.0, dot(N, Ldir)); float atten = 1.0; if (kind == 2) { float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); atten *= (cos_l > cos_edge) ? 1.0 : 0.0; } lit += Lcol * ndl * atten; } } } return lit; } vec3 applyFog(vec3 lit, vec3 worldPos) { int mode = int(uFogParams.w); if (mode == 0) return lit; float d = length(worldPos - uCameraAndTime.xyz); float fogStart = uFogParams.x; float fogEnd = uFogParams.y; float span = max(1e-3, fogEnd - fogStart); float fog = clamp((d - fogStart) / span, 0.0, 1.0); return mix(lit, uFogColor.xyz, fog); } out vec4 FragColor; void main() { sampler2DArray tex = sampler2DArray(vTextureHandle); vec4 color = texture(tex, vec3(vTexCoord, float(vTextureLayer))); // Two-pass alpha-test (N.5 Decision 2). // A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE // derives the MSAA sample mask from it — ClipMap foliage edges become smooth. // Discard only fully-transparent (α < 0.05); the GPU handles coverage masking. if (uRenderPass == 0) { if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C) } else { // 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); vec3 lit = accumulateLights(N, vWorldPos); // Lightning flash — additive scene bump (matches mesh_instanced.frag). lit += uFogParams.z * vec3(0.6, 0.6, 0.75); // Retail clamp per-channel to 1.0 (r13 §13.1). lit = min(lit, vec3(1.0)); vec3 rgb = color.rgb * lit; rgb = applyFog(rgb, vWorldPos); FragColor = vec4(rgb, color.a); }