Replace the building and EnvCell detail replay with retail's exact single-pass stage result, including authored surface opacity, squared detail alpha, final-alpha clipping, and the original subset pipeline/order. Arm the ordered walk command in place to close #471, delete the replay pipelines/shaders, and advance prepared content to recipe 9. Mutation witnesses (each restored before commit): - X=a*qA: RetailDetailTextureContractTests.BothShaderFamiliesUseTheSharedOnePassSourceAndDebugPrecedesDetailSample line 174, missing materialAlpha * detail.a * detail.a. - X*=base alpha: same test line 175, forbidden baseTexel.a found. - CLIP against base alpha: EnvCellAlphaDrawSourceTests.ClipShaders_UseGreaterEqualForThePerRangeReference line 260, final-X conditional missing. - second detail draw: EnvCellAlphaDrawSourceTests.DetailOn_EveryEnvCellFamilyDrawsOnceInPlaceWithAuthoredOpacity line 105, Assert.Single saw 2 MDI calls. - straight-alpha substitution: WalkStaticStreamPopulatorTests.ImmediateBuildingDetail_RetainsOriginalFramebufferFamily line 1244, Additive first failed (only wb-mesh-alpha-1x recorded; InvAlpha also failed). - omit ordered arm: OrderPreservingSubmitterTests.PrepareThenDraw_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassInPlace line 305, expected (77,3.5), got (0,0). - omit atmospheric combine: RetailDetailTextureContractTests.BothShaderFamiliesUseTheSharedOnePassSourceAndDebugPrecedesDetailSample line 173, atmospheric shared include missing. - drop serialized opacity: ObjectMeshDataSerializerTests.SurfaceOpacity_RoundTripsBitExactlyAndDeterministically line 288, first reported 0.5 bits 1056964608 vs 1065353216. - stale detail arm: ordered adjacency test line 307, expected following ordinary (0,0), got (77,3.5). - per-frame surface map: EnvCellAlphaDrawSourceTests.ProductionWholeLeaf_WarmedScanSubmitRhiAndFilteredReplayDoNotAllocate line 178, expected 0 B, got 147456 B. Verification before commit: shader compiler 23/23; focused App 213/213; Content 75/75; Core Wb 10/10; launcher migration 6/6; Release solution build 0 warnings / 0 errors; git diff --check clean.
308 lines
16 KiB
GLSL
308 lines
16 KiB
GLSL
#version 430 core
|
||
#extension GL_ARB_shader_draw_parameters : require
|
||
|
||
layout(location = 0) in vec3 aPosition;
|
||
layout(location = 1) in vec3 aNormal;
|
||
layout(location = 2) in vec2 aTexCoord;
|
||
|
||
struct InstanceData {
|
||
mat4 transform;
|
||
};
|
||
|
||
// Campaign V slice V2 (2026-07-27): textureHandle (uvec2, a 64-bit
|
||
// GL_ARB_bindless_texture handle) became textureIndex (uint) plus an explicit
|
||
// pad word. textureIndex is a slot into the global texture table (set 2,
|
||
// injected by tools/ShaderCompiler/VulkanGlslPreamble.cs — see
|
||
// ACDREAM_TEXTURE_HANDLE/ACDREAM_SAMPLE_ARRAY) which main() below forwards to
|
||
// the fragment stage. The pad word keeps textureLayer/flags at their original
|
||
// std430 offsets (8/12), so the struct is still 16 bytes and every existing
|
||
// CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes).
|
||
struct BatchData {
|
||
uint textureIndex; // slot into the global texture table
|
||
float surfaceOpacity; // authored material alpha (not base texture alpha)
|
||
uint textureLayer; // layer in the shared WB or pooled composite array
|
||
uint flags; // reserved — N.5 dispatcher owns all blend state
|
||
// (glBlendFunc per pass). If a future phase wants
|
||
// shader-side per-batch additive flag (Decision 2
|
||
// fallback), encode it here as bit 0.
|
||
};
|
||
|
||
layout(std430, binding = 0) readonly buffer InstanceBuffer {
|
||
InstanceData Instances[];
|
||
};
|
||
|
||
// binding=1 here is the SSBO namespace — distinct from the UBO namespace.
|
||
// SceneLighting UBO also uses binding=1 in the fragment shader; GL keeps
|
||
// GL_SHADER_STORAGE_BUFFER and GL_UNIFORM_BUFFER binding tables separate.
|
||
// Task 10 dispatcher binds:
|
||
// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, instanceSsbo)
|
||
// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, batchSsbo)
|
||
// Existing SceneLightingUboBinding handles the UBO side.
|
||
layout(std430, binding = 1) readonly buffer BatchBuffer {
|
||
BatchData Batches[];
|
||
};
|
||
|
||
// S3 review fix round 1 (F5): the per-cell screen-space gl_ClipDistance gate
|
||
// (Phase U.3's binding=2 CellClip region SSBO) is deleted — the CPU-side
|
||
// routing that could ever select a non-zero slot for an instance
|
||
// (WbDrawDispatcher's per-frame clip-routing arming call) had ZERO production callers, so every
|
||
// instance has always mapped to slot 0 (no-clip) in every shipped build; a
|
||
// shader-side clip test against a table that only ever holds the reserved
|
||
// no-clip slot clips nothing. binding=3 — PER-RENDERER per-instance slot
|
||
// index, parallel to the binding=0 instance buffer and indexed by the
|
||
// IDENTICAL per-instance index (gl_BaseInstanceARB + gl_InstanceID) — stays
|
||
// declared (the CPU side still writes it, always 0) but is no longer read
|
||
// here; the instance-buffer layout it occupies is S5's to revisit.
|
||
layout(std430, binding = 3) readonly buffer ClipSlotBuf {
|
||
uint instanceClipSlot[];
|
||
};
|
||
|
||
// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting =====
|
||
// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position
|
||
// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it
|
||
// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA
|
||
// UBO set (LightManager.Tick) made a wall brighten as the camera approached
|
||
// (its torches swapping into the global top-8). Two SSBOs replace that for
|
||
// point/spot lights (the SUN + ambient still come from the SceneLighting UBO):
|
||
//
|
||
// binding=4 — GLOBAL point/spot light array, uploaded once per frame from
|
||
// LightManager.PointSnapshot. The index of a light here is stable for the frame.
|
||
// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per
|
||
// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0
|
||
// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills
|
||
// it once per entity (the set is constant across the entity's parts/tuples).
|
||
struct GlobalLight {
|
||
vec4 posAndKind;
|
||
vec4 dirAndRange;
|
||
vec4 colorAndIntensity;
|
||
vec4 coneAngleEtc;
|
||
};
|
||
layout(std430, binding = 4) readonly buffer GlobalLightBuf {
|
||
GlobalLight gLights[];
|
||
};
|
||
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
|
||
int instanceLightIdx[]; // 8 per instance; -1 = unused
|
||
};
|
||
|
||
// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0
|
||
// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the
|
||
// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun).
|
||
// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1
|
||
// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it.
|
||
layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
|
||
uint instanceIndoor[];
|
||
};
|
||
|
||
// #188: per-instance opacity multiplier, 1 per instance, parallel to the
|
||
// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0
|
||
// while a TransparentPartHook translucency fade is in flight for the
|
||
// entity/part this instance belongs to (e.g. the "fading wall" secret-
|
||
// passage doors). Multiplied against the sampled texture alpha in
|
||
// mesh_modern.frag.
|
||
layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
|
||
float instanceAlpha[];
|
||
};
|
||
|
||
// Retail SmartBox click confirmation. One vec2 per OBJECT instance, parallel
|
||
// to binding=0: x = CMaterial luminosity, y = CMaterial diffuse. Normal
|
||
// rendering is (0,1); SmartBox alternates LOW=(0,.35) and HIGH=(.99,1).
|
||
// EnvCellRenderer uses uLightingMode=1 and deliberately never reads this
|
||
// object-only binding.
|
||
layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf {
|
||
vec2 instanceSelectionLighting[];
|
||
};
|
||
|
||
layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
|
||
uint instanceDetailCategory[];
|
||
};
|
||
|
||
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;
|
||
uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
|
||
// #176 stripe-hunt isolation modes (ACDREAM_LIGHT_DEBUG, throwaway diagnostic):
|
||
// 0 = off; 1 = ambient-only vLit (all point/sun contributions killed);
|
||
// 2 = DYNAMIC point lights killed (purples + viewer fill off, statics stay);
|
||
// 3 = handled in the frag (raw vLit visualization, texture ignored).
|
||
uniform int uLightDebug;
|
||
|
||
// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
|
||
// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
|
||
// above). IDENTICAL std140 layout to mesh_modern.frag.
|
||
//
|
||
// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so
|
||
// torch/point lights Gouraud-interpolate across each triangle the way retail's
|
||
// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for
|
||
// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls;
|
||
// per-vertex spreads it into a soft, broad gradient with no hard edge.
|
||
struct Light {
|
||
vec4 posAndKind;
|
||
vec4 dirAndRange;
|
||
vec4 colorAndIntensity;
|
||
vec4 coneAngleEtc;
|
||
};
|
||
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
|
||
Light uLights[8];
|
||
vec4 uCellAmbient;
|
||
vec4 uFogParams;
|
||
vec4 uFogColor;
|
||
vec4 uCameraAndTime;
|
||
};
|
||
|
||
// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light —
|
||
// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D =
|
||
// light − vertex, used UN-normalised (length = dist); N is the unit vertex normal.
|
||
// Returns the RGB to ADD, already per-channel capped to the light's own colour.
|
||
vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
|
||
int kind = int(L.posAndKind.w);
|
||
vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised)
|
||
float distsq = dot(toL, toL);
|
||
float d = sqrt(distsq);
|
||
float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3 (static) / × 1.5 (dynamic)
|
||
if (d >= range || range <= 1e-4) return vec3(0.0);
|
||
float intensity = L.colorAndIntensity.w;
|
||
vec3 baseCol = L.colorAndIntensity.xyz;
|
||
|
||
// #143: DYNAMIC lights (viewer fill, portal, server-object lights — flagged by
|
||
// coneAngleEtc.y==1 from GlobalLightPacker) use retail's D3D hardware attenuation
|
||
// (config_hardware_light 0x0059ad30): a POINT light is given Attenuation1=1 ⇒
|
||
// att = 1/d (inverse-LINEAR), plain Lambert N·L, hard range cutoff. That spreads
|
||
// softly across the room (the portal tint, the viewer fill) instead of the static
|
||
// bake's 1/d³ distance-cube, which makes a tight concentrated pool. No per-light
|
||
// cap — D3D accumulates then saturates, which accumulateLights does via min(pointAcc,1).
|
||
if (L.coneAngleEtc.y > 0.5) {
|
||
if (uLightDebug == 2) return vec3(0.0); // #176 stripe hunt: dynamics killed
|
||
vec3 Ldir = toL / max(d, 1e-4);
|
||
float ndl = max(0.0, dot(N, Ldir));
|
||
if (ndl <= 0.0) return vec3(0.0);
|
||
if (kind == 2) { // dynamic spot: hard cos-cone gate
|
||
if (dot(-Ldir, L.dirAndRange.xyz) <= cos(L.coneAngleEtc.x * 0.5)) return vec3(0.0);
|
||
}
|
||
return (intensity * ndl / max(d, 1e-3)) * baseCol; // att = 1/d
|
||
}
|
||
|
||
// ── STATIC dat-baked lights: retail's per-vertex bake (calc_point_light 0x0059c8b0) ──
|
||
// A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the
|
||
// half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT
|
||
// mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes
|
||
// nothing (retail's hardware path). toL is un-normalised (length d).
|
||
float angular = (uLightingMode == 1)
|
||
? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake)
|
||
: max(0.0, dot(N, toL)); // plain Lambert (object/hardware)
|
||
if (angular <= 0.0) return vec3(0.0);
|
||
// NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo;
|
||
// <1 m → just d (dodge the near singularity). "Punchy near, soft far."
|
||
float norm = (distsq > 1.0) ? (distsq * d) : d;
|
||
float scale = (1.0 - d / range) * intensity * (angular / norm);
|
||
if (kind == 2) {
|
||
// Spotlight: hard-edged cos-cone gate layered on the point ramp.
|
||
vec3 Ldir = toL / max(d, 1e-4);
|
||
float cos_edge = cos(L.coneAngleEtc.x * 0.5);
|
||
float cos_l = dot(-Ldir, L.dirAndRange.xyz);
|
||
if (cos_l <= cos_edge) scale = 0.0;
|
||
}
|
||
// Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled):
|
||
// a single light can't push a channel past its colour. Summed lit clamped in frag.
|
||
return min(scale * baseCol, baseCol);
|
||
}
|
||
|
||
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
||
vec3 lit = uCellAmbient.xyz;
|
||
if (uLightDebug == 1) return lit; // #176 stripe hunt: ambient only
|
||
|
||
// SUN / directional — OBJECT path only (mode 0). retail's EnvCell path
|
||
// (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
|
||
// EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4).
|
||
// #142: within mode 0, also skip the sun for indoor objects (ParentCellId is an
|
||
// EnvCell). This mirrors retail's per-draw-stage useSunlight toggle: the interior
|
||
// stage runs useSunlightSet(0) (PView::DrawCells 0x005a49f3), so indoor objects
|
||
// get no sun even in windowed buildings where the player's frame is not sun-killed.
|
||
if (uLightingMode == 0) {
|
||
if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun
|
||
int activeLights = int(uCellAmbient.w);
|
||
for (int i = 0; i < 8; ++i) {
|
||
if (i >= activeLights) break;
|
||
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
|
||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||
float ndl = max(0.0, dot(N, Ldir));
|
||
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
|
||
}
|
||
}
|
||
}
|
||
|
||
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
|
||
// SetStaticLightingVertexColors sums the static point lights from BLACK and
|
||
// clamps the SUM to [0,1] before anything else (a baked emissive term), so a
|
||
// few warm intensity-100 torches can't push the whole pixel to white the way
|
||
// folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
|
||
// (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
|
||
vec3 pointAcc = vec3(0.0);
|
||
int base = instanceIndex * 8;
|
||
for (int k = 0; k < 8; ++k) {
|
||
int gi = instanceLightIdx[base + k];
|
||
if (gi < 0) continue;
|
||
pointAcc += pointContribution(N, worldPos, gLights[gi]);
|
||
}
|
||
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
|
||
|
||
return lit; // frag still does the final min(lit, 1.0)
|
||
}
|
||
|
||
out vec3 vNormal;
|
||
out vec2 vTexCoord;
|
||
out vec3 vWorldPos;
|
||
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
|
||
// Campaign V slice V6e: was `flat uvec2 vTextureHandle` — a raw 64-bit
|
||
// GL_ARB_bindless_texture handle handed across the stage boundary. A varying
|
||
// cannot carry a Vulkan descriptor, so what travels is the table SLOT and the
|
||
// fragment stage does the lookup (see mesh_modern.frag). Under GL the value
|
||
// sampled is bit-for-bit the one the vertex stage used to forward; the SSBO
|
||
// read simply happens one stage later, and `flat` keeps it one scalar load per
|
||
// primitive rather than per fragment.
|
||
out flat uint vTextureIndex;
|
||
out flat uint vTextureLayer;
|
||
out flat float vOpacityMultiplier; // #188
|
||
out flat vec2 vSelectionLighting;
|
||
out flat float vSurfaceOpacity;
|
||
out flat uint vBatchFlags;
|
||
out flat uint vDetailCategory;
|
||
|
||
void main() {
|
||
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
|
||
mat4 model = Instances[instanceIndex].transform;
|
||
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
|
||
vSelectionLighting = (uLightingMode == 0)
|
||
? instanceSelectionLighting[instanceIndex]
|
||
: vec2(0.0, 1.0);
|
||
|
||
vec4 worldPos = model * vec4(aPosition, 1.0);
|
||
gl_Position = uViewProjection * worldPos;
|
||
|
||
vWorldPos = worldPos.xyz;
|
||
vNormal = normalize(mat3(model) * aNormal);
|
||
vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights)
|
||
vTexCoord = aTexCoord;
|
||
|
||
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
|
||
// Campaign V slice V6e: forward the table SLOT untouched. V2 looked the
|
||
// handle up here and passed the handle; the lookup now lives at the sample
|
||
// site in mesh_modern.frag, which is the only form Vulkan can express.
|
||
vTextureIndex = b.textureIndex;
|
||
vTextureLayer = b.textureLayer;
|
||
vSurfaceOpacity = b.surfaceOpacity;
|
||
vBatchFlags = b.flags;
|
||
vDetailCategory = instanceDetailCategory[instanceIndex];
|
||
}
|