feat(render): implement Campaign AR and terrain fidelity

This commit is contained in:
Erik 2026-08-22 13:13:29 +02:00
parent 99cf26e00c
commit 7a5f96ede5
368 changed files with 50611 additions and 950 deletions

View file

@ -0,0 +1,17 @@
#version 430 core
layout(location = 0) in vec2 vUv;
layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl"
void main()
{
vec2 stepUv = uPackParams0.xy;
vec3 value = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb * 0.227027;
value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv + stepUv * 1.384615).rgb * 0.316216;
value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv - stepUv * 1.384615).rgb * 0.316216;
value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv + stepUv * 3.230769).rgb * 0.070270;
value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv - stepUv * 3.230769).rgb * 0.070270;
oColor = vec4(value, 1.0);
}

View file

@ -0,0 +1,10 @@
#version 430 core
layout(location = 0) out vec2 vUv;
void main()
{
vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
vUv = vec2(triangle.x, 1.0 - triangle.y);
}

View file

@ -0,0 +1,23 @@
#version 430 core
layout(location = 0) in vec2 vUv;
layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl"
void main()
{
vec3 scene = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb
+ ACDREAM_SAMPLE_2D(uTextureIndexB, vUv).rgb;
if (uPackParams0.w > 0.5)
scene += ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb;
float brightness = dot(scene, vec3(0.2126, 0.7152, 0.0722));
float threshold = uPackParams0.y;
float knee = max(uPackParams0.z, 0.0001);
float soft = clamp((brightness - threshold + knee) / (2.0 * knee), 0.0, 1.0);
soft = soft * soft;
float contribution = max(brightness - threshold, 0.0) + soft * knee;
contribution /= max(brightness, 0.0001);
vec3 bloom = scene * contribution * uPackParams0.x;
oColor = vec4(bloom, 1.0);
}

View file

@ -0,0 +1,10 @@
#version 430 core
layout(location = 0) out vec2 vUv;
void main()
{
vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
vUv = vec2(triangle.x, 1.0 - triangle.y);
}

View file

@ -0,0 +1,34 @@
#ifndef ACDREAM_ATMOSPHERIC_COMMON_GLSL
#define ACDREAM_ATMOSPHERIC_COMMON_GLSL
// Render-pack shader ABI v1. These declarations are the byte-level SSOT for
// AtmosphericFrameUniforms (set 3/binding 5, 160 bytes) and
// AtmosphericPackPassUniforms (set 3/binding 7). Binding 6 is intentionally
// reserved for directional-shadow data in directional_shadow_common.glsl.
layout(std140, ACDREAM_PACK_UBO_SET binding = 5) uniform AtmosphericFrame {
vec4 uAtmosphereSunScreen; // 0: uv.xy, ray strength, elevation degrees
vec4 uAtmosphereSunColor; // 16: authored linear rgb, policy multiplier
vec4 uAtmosphereViewport; // 32: width, height, reciprocal width/height
vec4 uAtmosphereWeather; // 48: kind, intensity, delta seconds, outdoor
vec4 uAtmosphereSunDirection; // 64: surface-to-sun xyz, authored brightness
vec4 uAtmospherePolicy; // 80: day group, group factor, shadow/shaft elevation factors
mat4 uAtmosphereInverseViewProjection; // 96: screen/depth to world
};
layout(std140, ACDREAM_PACK_UBO_SET binding = 7) uniform PackPass {
vec4 uPackParams0; // 0
vec4 uPackParams1; // 16
vec4 uPackParams2; // 32
vec4 uPackParams3; // 48
};
// FusedAtmosphericPostProcess PackPass ABI (opt-in Low preset only):
// sun-rays: Params1 = (enabled, logical mask width, mask height, 0)
// filmic: Params1.z = enabled; Params2 = bloom extraction parameters;
// Params3.xy = logical bloom texel step
layout(std140, ACDREAM_PACK_UBO_SET binding = 8) uniform PackSettings {
vec4 uPackSettings[16]; // 64 declaration-order scalar setting slots
};
#endif

View file

@ -0,0 +1,88 @@
#version 430 core
layout(location = 0) in vec2 vUv;
layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl"
vec3 acesFitted(vec3 value)
{
const float a = 2.51;
const float b = 0.03;
const float c = 2.43;
const float d = 0.59;
const float e = 0.14;
return clamp((value * (a * value + b)) / (value * (c * value + d) + e), 0.0, 1.0);
}
vec3 sampleBloom(vec2 uv)
{
return ACDREAM_SAMPLE_2D(uTextureIndexB, uv).rgb;
}
vec3 lowFusedScene(vec2 uv)
{
vec3 scene = ACDREAM_SAMPLE_2D(uTextureIndexA, uv).rgb
+ ACDREAM_SAMPLE_2D(uTextureIndexB, uv).rgb;
if (uPackParams2.w > 0.5)
scene += ACDREAM_SAMPLE_2D(uTextureIndexC, uv).rgb;
return scene;
}
vec3 lowFusedBloomExtract(vec3 scene)
{
float brightness = dot(scene, vec3(0.2126, 0.7152, 0.0722));
float threshold = uPackParams2.y;
float knee = max(uPackParams2.z, 0.0001);
float soft = clamp((brightness - threshold + knee) / (2.0 * knee), 0.0, 1.0);
soft = soft * soft;
float contribution = max(brightness - threshold, 0.0) + soft * knee;
contribution /= max(brightness, 0.0001);
return scene * contribution * uPackParams2.x;
}
vec3 lowFusedBloom(vec3 centerScene)
{
const float offsets[5] = float[5](
-3.230769, -1.384615, 0.0, 1.384615, 3.230769);
const float weights[5] = float[5](
0.070270, 0.316216, 0.227027, 0.316216, 0.070270);
vec3 bloom = vec3(0.0);
for (int y = 0; y < 5; ++y) {
for (int x = 0; x < 5; ++x) {
vec3 scene = x == 2 && y == 2
? centerScene
: lowFusedScene(vUv + vec2(offsets[x], offsets[y]) * uPackParams3.xy);
bloom += lowFusedBloomExtract(scene) * (weights[x] * weights[y]);
}
}
return bloom;
}
void main()
{
vec3 hdr;
if (uPackParams1.z > 0.5) {
vec3 scene = lowFusedScene(vUv);
hdr = scene + lowFusedBloom(scene);
}
else {
hdr = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb
+ sampleBloom(vUv)
+ ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb;
if (uPackParams1.y > 0.5)
hdr += ACDREAM_SAMPLE_2D(uTextureIndexD, vUv).rgb;
}
vec3 exposed = max(hdr * uPackParams0.x, vec3(0.0));
vec3 linearClamped = clamp(exposed, 0.0, 1.0);
vec3 color = mix(linearClamped, acesFitted(exposed), clamp(uPackParams1.x, 0.0, 1.0));
float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
color = mix(vec3(luminance), color, uPackParams0.y);
color = (color - 0.5) * uPackParams0.z + 0.5;
vec2 centered = vUv * 2.0 - 1.0;
float vignette = smoothstep(1.25, 0.25, dot(centered, centered));
color *= mix(1.0, vignette, clamp(uPackParams0.w, 0.0, 1.0));
oColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}

View file

@ -0,0 +1,10 @@
#version 430 core
layout(location = 0) out vec2 vUv;
void main()
{
vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
vUv = vec2(triangle.x, 1.0 - triangle.y);
}

View file

@ -0,0 +1,14 @@
#version 430 core
layout(location = 0) in vec2 vUv;
layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl"
void main()
{
float depth = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).r;
float unobstructedSky = smoothstep(0.9975, 0.99995, depth);
float enabled = uAtmosphereSunScreen.z * uAtmosphereWeather.w;
oColor = vec4(vec3(unobstructedSky * enabled), 1.0);
}

View file

@ -0,0 +1,12 @@
#version 430 core
layout(location = 0) out vec2 vUv;
void main()
{
vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
// Vulkan's negative viewport preserves GL world winding; flip the sampled
// image coordinate once here so row zero remains the screen top.
vUv = vec2(triangle.x, 1.0 - triangle.y);
}

View file

@ -0,0 +1,58 @@
#version 430 core
layout(location = 0) in vec2 vUv;
layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl"
float lowFusedMaskTexel(ivec2 coordinate, vec2 maskSize)
{
ivec2 maximum = ivec2(maskSize) - ivec2(1);
ivec2 clampedCoordinate = clamp(coordinate, ivec2(0), maximum);
vec2 depthUv = (vec2(clampedCoordinate) + vec2(0.5)) / maskSize;
float depth = ACDREAM_SAMPLE_2D(uTextureIndexA, depthUv).r;
float unobstructedSky = smoothstep(0.9975, 0.99995, depth);
float enabled = uAtmosphereSunScreen.z * uAtmosphereWeather.w;
// Match the removed RGBA8_UNORM mask attachment before filtering it.
return round(clamp(unobstructedSky * enabled, 0.0, 1.0) * 255.0) / 255.0;
}
float sampleLowFusedMask(vec2 uv)
{
vec2 maskSize = uPackParams1.yz;
vec2 texel = uv * maskSize - vec2(0.5);
ivec2 lower = ivec2(floor(texel));
vec2 fraction = fract(texel);
float topLeft = lowFusedMaskTexel(lower, maskSize);
float topRight = lowFusedMaskTexel(lower + ivec2(1, 0), maskSize);
float bottomLeft = lowFusedMaskTexel(lower + ivec2(0, 1), maskSize);
float bottomRight = lowFusedMaskTexel(lower + ivec2(1, 1), maskSize);
return mix(
mix(topLeft, topRight, fraction.x),
mix(bottomLeft, bottomRight, fraction.x),
fraction.y);
}
void main()
{
const int SampleCount = 48;
float decay = uPackParams0.x;
float weight = uPackParams0.y;
float density = uPackParams0.z;
vec2 delta = (vUv - uAtmosphereSunScreen.xy) * (density / float(SampleCount));
vec2 sampleUv = vUv;
float illumination = 1.0;
float sum = 0.0;
for (int i = 0; i < SampleCount; ++i) {
sampleUv -= delta;
if (any(lessThan(sampleUv, vec2(0.0))) || any(greaterThan(sampleUv, vec2(1.0))))
break;
float mask = uPackParams1.x > 0.5
? sampleLowFusedMask(sampleUv)
: ACDREAM_SAMPLE_2D(uTextureIndexA, sampleUv).r;
sum += mask * illumination;
illumination *= decay;
}
vec3 rays = uAtmosphereSunColor.rgb * (sum * weight / float(SampleCount));
oColor = vec4(rays, 1.0);
}

View file

@ -0,0 +1,10 @@
#version 430 core
layout(location = 0) out vec2 vUv;
void main()
{
vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
vUv = vec2(triangle.x, 1.0 - triangle.y);
}

View file

@ -0,0 +1,67 @@
#version 430 core
layout(location = 0) in vec2 vUv;
layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl"
#include "directional_shadow_common.glsl"
vec3 reconstructWorld(vec2 uv, float depth)
{
vec4 clip = vec4(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0, depth, 1.0);
vec4 world = uAtmosphereInverseViewProjection * clip;
return world.xyz / max(abs(world.w), 1e-6);
}
float directionalVisibility(vec3 worldPosition)
{
// Volumetric shafts are a sun effect. A moon-oriented shadow map remains
// valid for world receivers but must not occlude the authored sun ray.
if (uint(round(uShadowLightDirectionAndSource.w)) != 1u)
return 1.0;
uint cascadeCount = clamp(uShadowTextureAndFlags.y, 1u, 4u);
vec3 surfaceToSun = normalize(uShadowLightDirectionAndSource.xyz);
vec3 biasedPosition = worldPosition
+ surfaceToSun * max(uShadowBiasMeters.x, 0.0);
for (uint cascade = 0u; cascade < cascadeCount; ++cascade) {
vec4 clip = uShadowWorldToClip[cascade] * vec4(biasedPosition, 1.0);
vec3 ndc = clip.xyz / max(abs(clip.w), 1e-6);
vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
if (all(greaterThanEqual(uv, vec2(0.0)))
&& all(lessThanEqual(uv, vec2(1.0)))
&& ndc.z >= 0.0 && ndc.z <= 1.0) {
float stored = ACDREAM_SAMPLE_ARRAY(
uShadowTextureAndFlags.x,
vec3(uv, float(cascade))).r;
float visible = ndc.z <= stored ? 1.0 : 0.0;
return mix(1.0, visible, clamp(uShadowControl.x, 0.0, 1.0));
}
}
return 1.0;
}
void main()
{
float sceneDepth = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).r;
if (sceneDepth >= 0.999999 || uPackParams0.w <= 0.0) {
oColor = vec4(0.0);
return;
}
vec3 nearWorld = reconstructWorld(vUv, 0.0);
vec3 sceneWorld = reconstructWorld(vUv, sceneDepth);
int steps = clamp(int(uPackParams0.z + 0.5), 1, 64);
float lit = 0.0;
for (int step = 0; step < 64; ++step) {
if (step >= steps)
break;
float t = (float(step) + 0.5) / float(steps);
lit += directionalVisibility(mix(nearWorld, sceneWorld, t));
}
float integrated = lit / float(steps);
float extinction = 1.0 - exp(-uPackParams0.x * length(sceneWorld - nearWorld));
vec3 color = uAtmosphereSunColor.rgb
* (integrated * extinction * uPackParams0.y);
oColor = vec4(max(color, vec3(0.0)), 1.0);
}

View file

@ -0,0 +1,10 @@
#version 430 core
layout(location = 0) out vec2 vUv;
void main()
{
vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
vUv = vec2(triangle.x, 1.0 - triangle.y);
}

View file

@ -0,0 +1,15 @@
#ifndef ACDREAM_DIRECTIONAL_SHADOW_COMMON_GLSL
#define ACDREAM_DIRECTIONAL_SHADOW_COMMON_GLSL
// Render-pack shader ABI v1, set 3/binding 6. This is the byte-level SSOT for
// DirectionalShadowUniforms (std140, 336 bytes).
layout(std140, ACDREAM_PACK_UBO_SET binding = 6) uniform DirectionalShadow {
mat4 uShadowWorldToClip[4]; // 0, 64, 128, 192
vec4 uShadowSplitFarMeters; // 256
vec4 uShadowControl; // 272: strength, softness, reach m, blend m
vec4 uShadowBiasMeters; // 288: constant, slope, normal, caster pad
uvec4 uShadowTextureAndFlags; // 304: set2 slot, count, resolution, flags
vec4 uShadowLightDirectionAndSource; // 320: surface-to-light xyz, source kind
};
#endif

View file

@ -0,0 +1,176 @@
#ifndef ACDREAM_DIRECTIONAL_SHADOW_RECEIVER_GLSL
#define ACDREAM_DIRECTIONAL_SHADOW_RECEIVER_GLSL
#include "directional_shadow_common.glsl"
// Receiver policy shared byte-for-byte by terrain and world meshes. Cascade
// choice is camera-distance based; the terminal blend band is measured in
// world metres, so a projection/FOV change cannot move the seam.
int acdreamShadowCascade(float cameraDistanceMeters) {
int count = int(uShadowTextureAndFlags.y);
for (int i = 0; i < count; ++i) {
if (cameraDistanceMeters <= uShadowSplitFarMeters[i])
return i;
}
return -1;
}
// The X/Y clip gradients are the reciprocal orthographic half extent. Their
// ratio therefore gives the exact relative texel footprint without extending
// the pinned v1 uniform block. The CPU publishes the conservative far-cascade
// bias; inner maps scale it down instead of visibly detaching nearby feet,
// foliage, and building edges from their receivers.
float acdreamShadowBiasScale(int cascade) {
int farCascade = max(int(uShadowTextureAndFlags.y) - 1, 0);
mat4 cascadeMatrix = uShadowWorldToClip[cascade];
mat4 farMatrix = uShadowWorldToClip[farCascade];
float cascadeDensity = 0.5 * (
length(vec3(cascadeMatrix[0][0], cascadeMatrix[1][0], cascadeMatrix[2][0]))
+ length(vec3(cascadeMatrix[0][1], cascadeMatrix[1][1], cascadeMatrix[2][1])));
float farDensity = 0.5 * (
length(vec3(farMatrix[0][0], farMatrix[1][0], farMatrix[2][0]))
+ length(vec3(farMatrix[0][1], farMatrix[1][1], farMatrix[2][1])));
return clamp(farDensity / max(cascadeDensity, 1e-7), 0.0, 1.0);
}
// One textureGather returns the four depth texels surrounding the continuous
// receiver coordinate. Comparing first and then interpolating is true
// bilinear percentage-closer filtering; interpolating depth before comparing
// would create false blockers at discontinuities.
float acdreamShadowBilinearCompare(
int cascade,
vec2 uv,
float receiverDepth)
{
float resolution = max(float(uShadowTextureAndFlags.z), 1.0);
vec2 texelPosition = uv * resolution - vec2(0.5);
vec2 blend = fract(texelPosition);
vec4 gatheredDepth = textureGather(
ACDREAM_TEXTURE(uShadowTextureAndFlags.x),
vec3(uv, float(cascade)),
0);
vec4 compared = step(vec4(receiverDepth), gatheredDepth);
float lower = mix(compared.w, compared.z, blend.x);
float upper = mix(compared.x, compared.y, blend.x);
return mix(lower, upper, blend.y);
}
float acdreamShadowCascadePcf(
int cascade,
vec3 receiverWorldPosition,
vec3 worldNormal,
vec3 surfaceToLight)
{
float ndl = clamp(dot(worldNormal, surfaceToLight), 0.0, 1.0);
vec3 cascadeBiasMeters = max(
uShadowBiasMeters.xyz * acdreamShadowBiasScale(cascade),
vec3(0.001));
float depthBiasMeters = cascadeBiasMeters.x
+ cascadeBiasMeters.y * (1.0 - ndl);
vec3 biasedWorldPosition = receiverWorldPosition
+ worldNormal * cascadeBiasMeters.z
+ surfaceToLight * depthBiasMeters;
vec4 shadowClip = uShadowWorldToClip[cascade]
* vec4(biasedWorldPosition, 1.0);
vec3 shadowNdc = shadowClip.xyz / max(abs(shadowClip.w), 1e-7);
// Vulkan records these maps through a negative-height viewport so the
// conventional GL-up clip coordinate samples the top-left-origin image.
vec2 uv = vec2(
shadowNdc.x * 0.5 + 0.5,
0.5 - shadowNdc.y * 0.5);
float receiverDepth = shadowNdc.z;
if (uv.x <= 0.0 || uv.x >= 1.0
|| uv.y <= 0.0 || uv.y >= 1.0
|| receiverDepth <= 0.0 || receiverDepth >= 1.0)
return 1.0;
int radius = int((uShadowTextureAndFlags.w >> 8u) & 0xFu);
radius = clamp(radius, 0, 2);
// Weather changes the physical softness envelope without changing the
// preset's bounded kernel. Bilinear PCF removes sub-texel stair stepping;
// the higher presets place four/nine bilinear lobes in separable tent
// patterns instead of issuing nine/twenty-five blocky nearest comparisons.
float texel = 1.0 / max(float(uShadowTextureAndFlags.z), 1.0);
float softness = max(uShadowControl.y, 1.0);
if (radius == 0)
return acdreamShadowBilinearCompare(cascade, uv, receiverDepth);
float lit = 0.0;
float weightSum = 0.0;
if (radius == 1) {
for (int y = 0; y < 2; ++y) {
for (int x = 0; x < 2; ++x) {
vec2 offset = (vec2(x, y) - vec2(0.5))
* softness * texel;
lit += acdreamShadowBilinearCompare(
cascade, uv + offset, receiverDepth);
weightSum += 1.0;
}
}
} else {
for (int y = -1; y <= 1; ++y) {
for (int x = -1; x <= 1; ++x) {
float weight = float(2 - abs(x)) * float(2 - abs(y));
vec2 offset = vec2(x, y) * 1.5 * softness * texel;
lit += acdreamShadowBilinearCompare(
cascade, uv + offset, receiverDepth) * weight;
weightSum += weight;
}
}
}
return lit / max(weightSum, 1.0);
}
float acdreamDirectionalShadowVisibility(
vec3 receiverWorldPosition,
vec3 worldNormal,
vec3 cameraWorldPosition,
vec3 surfaceToLight)
{
if ((uShadowTextureAndFlags.w & 1u) == 0u)
return 1.0;
float cameraDistanceMeters = length(
receiverWorldPosition - cameraWorldPosition);
if (cameraDistanceMeters > uShadowControl.z)
return 1.0;
int cascade = acdreamShadowCascade(cameraDistanceMeters);
if (cascade < 0)
return 1.0;
float visibility = acdreamShadowCascadePcf(
cascade,
receiverWorldPosition,
worldNormal,
surfaceToLight);
int cascadeCount = int(uShadowTextureAndFlags.y);
if (cascade + 1 < cascadeCount) {
float split = uShadowSplitFarMeters[cascade];
float widthMeters = max(uShadowControl.w, 1e-4);
float blend = smoothstep(
max(0.0, split - widthMeters),
split,
cameraDistanceMeters);
if (blend > 0.0) {
float nextVisibility = acdreamShadowCascadePcf(
cascade + 1,
receiverWorldPosition,
worldNormal,
surfaceToLight);
visibility = mix(visibility, nextVisibility, blend);
}
}
// Fade the terminal cascade over the same world-metre band used for
// inter-cascade transitions. This removes the moving ring/pop at maximum
// reach while leaving every caster and both/three/four cascades intact.
float reachFade = 1.0 - smoothstep(
max(0.0, uShadowControl.z - max(uShadowControl.w, 1e-4)),
uShadowControl.z,
cameraDistanceMeters);
float shadowWeight = clamp(uShadowControl.x, 0.0, 1.0) * reachFade;
return mix(1.0, visibility, shadowWeight);
}
#endif

View file

@ -0,0 +1,4 @@
#version 460 core
void main() {
}

View file

@ -0,0 +1,16 @@
#version 460 core
#include "directional_shadow_common.glsl"
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in uvec4 aPacked0;
layout(location = 3) in uvec4 aPacked1;
layout(location = 4) in uvec4 aPacked2;
layout(location = 5) in uvec4 aPacked3;
uniform int uRenderPass;
void main() {
gl_Position = uShadowWorldToClip[uRenderPass] * vec4(aPosition, 1.0);
}

View file

@ -0,0 +1,4 @@
#version 460 core
void main() {
}

View file

@ -0,0 +1,15 @@
#version 460 core
#extension GL_EXT_multiview : require
#include "directional_shadow_common.glsl"
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in uvec4 aPacked0;
layout(location = 3) in uvec4 aPacked1;
layout(location = 4) in uvec4 aPacked2;
layout(location = 5) in uvec4 aPacked3;
void main() {
gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * vec4(aPosition, 1.0);
}

View file

@ -0,0 +1,14 @@
#version 460 core
#extension GL_ARB_bindless_texture : require
in vec2 vShadowTexCoord;
flat in uint vShadowTextureIndex;
flat in uint vShadowTextureLayer;
void main() {
vec4 texel = ACDREAM_SAMPLE_ARRAY(
vShadowTextureIndex,
vec3(vShadowTexCoord, float(vShadowTextureLayer)));
if (texel.a < 0.05)
discard;
}

View file

@ -0,0 +1,41 @@
#version 460 core
#extension GL_ARB_shader_draw_parameters : require
#include "directional_shadow_common.glsl"
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
struct InstanceData { mat4 transform; };
struct BatchData {
uint textureIndex;
uint _pad;
uint textureLayer;
uint flags;
};
layout(std430, binding = 0) readonly buffer InstanceBuffer {
InstanceData Instances[];
};
layout(std430, binding = 1) readonly buffer BatchBuffer {
BatchData Batches[];
};
uniform int uDrawIDOffset;
uniform int uRenderPass;
out vec2 vShadowTexCoord;
flat out uint vShadowTextureIndex;
flat out uint vShadowTextureLayer;
void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0);
gl_Position = uShadowWorldToClip[uRenderPass] * worldPosition;
BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB];
vShadowTexCoord = aTexCoord;
vShadowTextureIndex = batch.textureIndex;
vShadowTextureLayer = batch.textureLayer;
}

View file

@ -0,0 +1,14 @@
#version 460 core
#extension GL_ARB_bindless_texture : require
in vec2 vShadowTexCoord;
flat in uint vShadowTextureIndex;
flat in uint vShadowTextureLayer;
void main() {
vec4 texel = ACDREAM_SAMPLE_ARRAY(
vShadowTextureIndex,
vec3(vShadowTexCoord, float(vShadowTextureLayer)));
if (texel.a < 0.05)
discard;
}

View file

@ -0,0 +1,38 @@
#version 460 core
#extension GL_ARB_shader_draw_parameters : require
#extension GL_EXT_multiview : require
#include "directional_shadow_common.glsl"
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
struct InstanceData { mat4 transform; };
struct BatchData {
uint textureIndex;
uint _pad;
uint textureLayer;
uint flags;
};
layout(std430, binding = 0) readonly buffer InstanceBuffer {
InstanceData Instances[];
};
layout(std430, binding = 1) readonly buffer BatchBuffer {
BatchData Batches[];
};
uniform int uDrawIDOffset;
out vec2 vShadowTexCoord;
flat out uint vShadowTextureIndex;
flat out uint vShadowTextureLayer;
void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0);
gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * worldPosition;
BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB];
vShadowTexCoord = aTexCoord;
vShadowTextureIndex = batch.textureIndex;
vShadowTextureLayer = batch.textureLayer;
}

View file

@ -0,0 +1,4 @@
#version 460 core
void main() {
}

View file

@ -0,0 +1,21 @@
#version 460 core
#extension GL_ARB_shader_draw_parameters : require
#include "directional_shadow_common.glsl"
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
struct InstanceData { mat4 transform; };
layout(std430, binding = 0) readonly buffer InstanceBuffer {
InstanceData Instances[];
};
uniform int uRenderPass;
void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0);
gl_Position = uShadowWorldToClip[uRenderPass] * worldPosition;
}

View file

@ -0,0 +1,4 @@
#version 460 core
void main() {
}

View file

@ -0,0 +1,20 @@
#version 460 core
#extension GL_ARB_shader_draw_parameters : require
#extension GL_EXT_multiview : require
#include "directional_shadow_common.glsl"
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
struct InstanceData { mat4 transform; };
layout(std430, binding = 0) readonly buffer InstanceBuffer {
InstanceData Instances[];
};
void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0);
gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * worldPosition;
}

View file

@ -0,0 +1,130 @@
#version 430 core
#extension GL_ARB_bindless_texture : require
in vec3 vNormal;
in vec2 vTexCoord;
in vec3 vWorldPos;
in vec3 vAmbientLocalLit;
in vec3 vDirectionalLit;
// Campaign V slice V6e: the table slot, not the bindless handle — see
// mesh_modern.vert. The lookup moved here because a Vulkan varying cannot
// carry a descriptor.
in flat uint vTextureIndex;
in flat uint vTextureLayer;
in flat float vOpacityMultiplier; // #188
in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse
in flat uint vReceivesDirectionalShadow;
#include "directional_shadow_receiver.glsl"
// 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;
uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
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;
};
// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match
// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight"
// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/
// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed
// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload.
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() {
vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, 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;
}
// Only the authored outdoor directional term is shadowed. Ambient, local
// lights and material selection pulses remain exactly on the retail path.
vec3 surfaceToLight = normalize(uShadowLightDirectionAndSource.xyz);
float directionalVisibility = vReceivesDirectionalShadow != 0u
? acdreamDirectionalShadowVisibility(
vWorldPos,
normalize(vNormal),
uCameraAndTime.xyz,
surfaceToLight)
: 1.0;
vec3 sceneLit = vAmbientLocalLit
+ vDirectionalLit * directionalVisibility;
vec3 lit = vec3(vSelectionLighting.x)
+ vSelectionLighting.y * sceneLit;
// #176 stripe-hunt mode 3: show the raw per-vertex light field (texture
// ignored). Stripes visible HERE = a vertex-lighting artifact; absent =
// the pattern comes from texture/per-pixel machinery. Throwaway diagnostic.
if (uLightDebug == 3) {
FragColor = vec4(min(lit, vec3(1.0)), 1.0);
return;
}
// 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);
// #188: multiply the FINAL alpha only — the discard thresholds above stay
// keyed on the raw sampled color.a, so the last few frames of a fade
// (multiplier crossing under 0.05) still ramp smoothly toward zero rather
// than popping invisible early against the discard cutoff.
FragColor = vec4(rgb, color.a * vOpacityMultiplier);
}

View file

@ -0,0 +1,363 @@
#version 430 core
#extension GL_ARB_shader_draw_parameters : require
#include "directional_shadow_common.glsl"
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
uint _pad; // keeps textureLayer/flags at offsets 8/12
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[];
};
// === Phase U.3: per-cell screen-space clip gate (gl_ClipDistance) =============
// Two SSBOs add the clip mechanism without disturbing binding=0/1 above.
//
// binding=2 — SHARED per-frame clip regions, one CellClip per "slot". Uploaded
// ONCE per frame by ClipFrame.UploadShared (shared across WbDrawDispatcher +
// EnvCellRenderer). Slot 0 is RESERVED = no-clip (count 0 ⇒ every plane passes).
//
// 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). instanceClipSlot[i] selects which
// CellClip region instance i is clipped against. Default all-zeros in U.3 ⇒
// every instance maps to slot 0 ⇒ no clipping ⇒ identical render to pre-U.3.
//
// CellClip std430 layout (144 bytes/slot): a uint count + 3 pad uints (16 bytes)
// then vec4 planes[8] (8 × 16 = 128 bytes). vec4 array stride is 16 under std430.
// ClipFrame on the CPU side lays out the bytes to match exactly (verified by
// ClipFrameLayoutTests). A clip-space vertex is INSIDE iff dot(plane, gl_Position)
// >= 0 for every active plane (see ClipPlaneSet for the plane convention).
struct CellClip {
uint count;
uint _p0;
uint _p1;
uint _p2;
vec4 planes[8];
};
layout(std430, binding = 2) readonly buffer ClipRegionBuf {
CellClip clipRegions[];
};
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[];
};
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
// alongside gl_Position. The array is sized 8 to match the CellClip plane budget
// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
// GL_CLIP_DISTANCE0..7 once at startup; unused planes are set to +1.0 below so
// they pass everything (no clipping) when the slot's count < 8.
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
uniform mat4 uViewProjection;
// Absolute transform prefix in the shared shadow/world pose arena. Every
// parallel per-instance array remains local to this submission, so only the
// transform lookup keeps the absolute index.
uniform uint uTextureIndexB;
// 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 accumulateAmbientLocalLights(
vec3 N,
vec3 worldPos,
int instanceIndex,
out vec3 directionalLit)
{
vec3 lit = uCellAmbient.xyz;
directionalLit = vec3(0.0);
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 = normalize(uShadowLightDirectionAndSource.xyz);
float ndl = max(0.0, dot(N, Ldir));
directionalLit += 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 vAmbientLocalLit; // authored ambient + capped local/point lights
out vec3 vDirectionalLit; // authored outdoor directional sun, shadowable
// 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 uint vReceivesDirectionalShadow;
void main() {
int transformIndex = gl_BaseInstanceARB + gl_InstanceID;
int instanceIndex = transformIndex - int(uTextureIndexB);
mat4 model = Instances[transformIndex].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;
// Phase U.3: per-instance clip gate. instanceClipSlot is indexed by the
// SAME instanceIndex used for the binding=0 transform above, so the slot
// travels with the instance through the MDI BaseInstance offsets. Slot 0
// (the U.3 default) has count 0 ⇒ the second loop sets all 8 distances to
// +1.0 ⇒ nothing is clipped.
uint _slot = instanceClipSlot[instanceIndex];
CellClip _c = clipRegions[_slot];
for (uint i = 0u; i < _c.count; ++i)
gl_ClipDistance[i] = dot(_c.planes[i], gl_Position);
for (uint i = _c.count; i < 8u; ++i)
gl_ClipDistance[i] = 1.0;
vWorldPos = worldPos.xyz;
vNormal = normalize(mat3(model) * aNormal);
vAmbientLocalLit = accumulateAmbientLocalLights(
vNormal,
vWorldPos,
instanceIndex,
vDirectionalLit);
// EnvCell-parented objects keep the authored indoor result. The separate
// EnvCell shell renderer never selects this receiver variant at all.
vReceivesDirectionalShadow = (uLightingMode == 0
&& instanceIndoor[instanceIndex] == 0u)
? 1u
: 0u;
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;
}

View file

@ -0,0 +1,42 @@
#version 430 core
#extension GL_ARB_bindless_texture : require
in vec2 vBaseUv;
in vec2 vDetailUv;
in float vDetailFade;
in flat uint vBaseTextureIndex;
in flat uint vBaseTextureLayer;
in flat uint vBatchFlags;
in flat uint vDetailCategory;
uniform uint uTextureIndexA; // category detail texture, layer 0
out vec4 FragColor;
void main() {
// Object command replays may contain ordinary instances; only building
// shells survive. Bit 0 means this command came through retail's built-mesh
// DrawMesh path. Unlike the land-polygon path, RenderMeshSubset receives
// curr_detail_surface for every built-mesh material subset.
if (vDetailCategory == 0u || (vBatchFlags & 1u) == 0u)
discard;
if (vDetailFade <= 0.0)
discard;
vec4 base = ACDREAM_SAMPLE_ARRAY(
vBaseTextureIndex,
vec3(vBaseUv, float(vBaseTextureLayer)));
if (base.a < 0.05)
discard;
vec4 detail = ACDREAM_SAMPLE_ARRAY(
uTextureIndexA,
vec3(vDetailUv, 0.0));
// Pipeline blend is retail's DstColor + OneMinusSrcAlpha. Scaling both
// source colour and alpha makes fade=0 exactly neutral while fade=1 keeps
// retail's measured factor: dest * (detail.rgb + 1 - detail.a).
FragColor = vec4(
detail.rgb * vDetailFade,
detail.a * vDetailFade);
}

View file

@ -0,0 +1,94 @@
#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;
};
struct BatchData {
uint textureIndex;
uint _pad;
uint textureLayer;
uint flags;
};
layout(std430, binding = 0) readonly buffer InstanceBuffer {
InstanceData Instances[];
};
layout(std430, binding = 1) readonly buffer BatchBuffer {
BatchData Batches[];
};
struct CellClip {
uint count;
uint _p0;
uint _p1;
uint _p2;
vec4 planes[8];
};
layout(std430, binding = 2) readonly buffer ClipRegionBuf {
CellClip clipRegions[];
};
layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[];
};
// Object renderer only: 1 for a retail building shell, 0 for ordinary
// scenery/creatures/players. EnvCellRenderer sets uParamB=0 and ignores the
// value, but still binds one valid word because Vulkan sees this static use.
layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
uint instanceDetailCategory[];
};
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
uniform mat4 uViewProjection;
uniform int uDrawIDOffset;
uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena
uniform float uParamA; // detail UV tiling
uniform float uParamB; // 1 = require building instance, 0 = EnvCell category
out vec2 vBaseUv;
out vec2 vDetailUv;
out float vDetailFade;
out flat uint vBaseTextureIndex;
out flat uint vBaseTextureLayer;
out flat uint vBatchFlags;
out flat uint vDetailCategory;
void main() {
int transformIndex = gl_BaseInstanceARB + gl_InstanceID;
int instanceIndex = transformIndex - int(uTextureIndexB);
vec4 worldPos = Instances[transformIndex].transform * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;
uint slot = instanceClipSlot[instanceIndex];
CellClip clip = clipRegions[slot];
for (uint i = 0u; i < clip.count; ++i)
gl_ClipDistance[i] = dot(clip.planes[i], gl_Position);
for (uint i = clip.count; i < 8u; ++i)
gl_ClipDistance[i] = 1.0;
// System.Numerics' perspective projection used by every gameplay camera
// makes clip.w the positive view-space depth. Retail get_alpha_for_z uses
// that same metric in metres: 255 through 10 m, linearly to 0 at 50 m.
float positiveViewDepthMetres = gl_Position.w;
vDetailFade = clamp((50.0 - positiveViewDepthMetres) / 40.0, 0.0, 1.0);
vBaseUv = aTexCoord;
vDetailUv = aTexCoord * uParamA;
BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB];
vBaseTextureIndex = batch.textureIndex;
vBaseTextureLayer = batch.textureLayer;
vBatchFlags = batch.flags;
vDetailCategory = uParamB > 0.5
? instanceDetailCategory[instanceIndex]
: 1u;
}

View file

@ -1,6 +1,102 @@
{
"note": "Campaign V slice V6c. Regenerate with tools/compile-shaders.ps1.",
"shaders": [
{
"name": "atmospheric_bloom_blur",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "4b22872f61b462cdbc82d4af38c7693212bc7446c7c68faa32dd5585b08c43ae",
"compiled": true
}
]
},
{
"name": "atmospheric_bloom_downsample",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "a01f09dfcc62acc5376f6e61be6aa2e8c5b0506550c0fa318f4434cbf3e6a17a",
"compiled": true
}
]
},
{
"name": "atmospheric_filmic",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "240d2fe5e13e3850ceb79f178f1c248c71875fdc1973ff8cab1c274660ebbc4b",
"compiled": true
}
]
},
{
"name": "atmospheric_sun_occlusion",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "c21f381f2de05afc4415b2e348884a3cf6440da279b57d9a05a5d6ae5e9ac453",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "bfbc8c508b21dec84b21b2760877bfcb736c4f233e8557f6d1f8b83600683d5e",
"compiled": true
}
]
},
{
"name": "atmospheric_sun_rays",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "13875d9f6fd28f1049d1f94741db13086f72cc7170659eb73c369b4c99b00a4f",
"compiled": true
}
]
},
{
"name": "atmospheric_volumetric",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "8d6177707a95cf0230881bbfb7467c0591632eb67dce3c758596c83cafb14ffc",
"compiled": true
}
]
},
{
"name": "debug_line",
"vulkanReady": true,
@ -17,6 +113,134 @@
}
]
},
{
"name": "directional_shadow_terrain",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "e863894aa1d66508daad86806af2b2a3509088109754c491a3607477f78ed644",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3",
"compiled": true
}
]
},
{
"name": "directional_shadow_terrain_multiview",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "2f7d7bba3aa19a4891c30000e29c0bb84a2afdc26a44248d4c48bd0ffed8a04a",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3",
"compiled": true
}
]
},
{
"name": "directional_shadow_world_cutout",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "8331583c1d63ee59b3f7898e25b2e9730df98fd9da28273661caad948fb46ae5",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "cd9c404a9379715061358cb18a99b9310acbdd5062f0ad982ee9a37cd9f99922",
"compiled": true
}
]
},
{
"name": "directional_shadow_world_cutout_multiview",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "a4bb3b86283af310a22d3943dd1afadb8a72b42c9e5501efc9abf2b5124a1446",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "cd9c404a9379715061358cb18a99b9310acbdd5062f0ad982ee9a37cd9f99922",
"compiled": true
}
]
},
{
"name": "directional_shadow_world_opaque",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "c2586df5f09518c40d052bbacdfff2c00a82b1eefec4868749d0580571198067",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3",
"compiled": true
}
]
},
{
"name": "directional_shadow_world_opaque_multiview",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "35b4d524153623691ab523a049212aa35551f96c169c28c08f62d93e31d9e68d",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3",
"compiled": true
}
]
},
{
"name": "mesh_atmospheric",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "d4f8bc12ee84379ced84f5b703cf8be95798a23a7063773e214bc1eb6ebbb65e",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "62b001a72080ea74bdaef0fb2a4ed92533feaeab76187ce4215813c6327432e7",
"compiled": true
}
]
},
{
"name": "mesh_detail",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "0273312da9fefb5084d3aee120f33c542e1adcfb846e6c7b3fc2b068c1348fb3",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "1fbc3ffb12d260cbe0d111551f28c69f15754ba5e5e11fe3ec43747bebef4883",
"compiled": true
}
]
},
{
"name": "mesh_modern",
"vulkanReady": true,
@ -97,6 +321,22 @@
}
]
},
{
"name": "terrain_atmospheric",
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "06258c7ead0e123740e325c802156987ed17dabe355d591d0e90facb420b8e04",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "9927abe9cc4fb83429d3e2ec8ee8d13f0b1936cebf484a4e529dcfdab431c3b8",
"compiled": true
}
]
},
{
"name": "terrain_modern",
"vulkanReady": true,

View file

@ -0,0 +1,208 @@
#version 460 core
#extension GL_ARB_bindless_texture : require
// Phase N.5b: terrain fragment shader on the modern bindless dispatcher.
// Math identical to terrain.frag (Phase 3c per-cell maskBlend3 +
// Phase G fog + lightning flash).
//
// Texture reads go through ACDREAM_SAMPLE_ARRAY, which
// tools/ShaderCompiler/VulkanGlslPreamble.cs injects to index the set-2
// descriptor array. GL is deleted (Campaign V slice V11); before that this
// macro expanded (via the now-deleted common.glsl) to the uvec2-handle +
// sampler2DArray-constructor pattern this shader used on that arm — the
// documented "always works" form per the ARB_bindless_texture spec, and the
// one that avoided the GL_INVALID_OPERATION the alternative (`uniform
// sampler2DArray` set via glProgramUniformHandleARB) produced on at least one
// driver in practice. The extension requirement above is dropped for Vulkan
// by the compiler's preamble, where it would be an error rather than a no-op.
in vec2 vBaseUV;
in vec3 vWorldNormal;
in vec3 vWorldPos;
in vec3 vAmbientLocalLit;
in vec3 vDirectionalLit;
in vec4 vOverlay0;
in vec4 vOverlay1;
in vec4 vOverlay2;
in vec4 vRoad0;
in vec4 vRoad1;
flat in float vBaseTexIdx;
out vec4 fragColor;
// Campaign V slice V2b (2026-07-27): uTerrainHandle/uAlphaHandle (uvec2, raw
// ARB_bindless_texture handles) became uTextureIndexA/uTextureIndexB (slots
// into the global texture table, ACDREAM_TEXTURE_HANDLE injected by
// tools/ShaderCompiler/VulkanGlslPreamble.cs). Named to match the pinned
// GpuPushConstants.TextureIndexA/B fields so V4d's
// move to push constants is a rename, not a redesign — there is no
// push-constant plumbing yet, so these stay plain uniforms for now.
uniform uint uTextureIndexA;
uniform uint uTextureIndexB;
// Campaign V slice V6f-3: the two atlases are sampled through the
// dialect-neutral table read instead of a GL sampler-from-handle constructor.
// `sampler2DArray(handle)` is a GL_ARB_bindless_texture form with no Vulkan
// equivalent — Vulkan's table is an opaque descriptor array in set 2, and there
// is no handle to construct a sampler from. ACDREAM_SAMPLE_ARRAY asks the
// question both dialects can answer ("sample table slot N at these
// coordinates") and expands to the right thing on each.
//
// A SAMPLING macro, not a sampler-returning one, for the reason
// VulkanGlslPreamble.cs records: under Vulkan the expansion carries `nonuniformEXT` on the indexing
// expression, and binding the result to a local sampler2DArray first is exactly
// where an implementation may drop that qualifier. The old `#define uTerrain
// sampler2DArray(...)` was that shape textually, so keeping it would have
// reintroduced the hazard at every use site.
#define sampleTerrain(uvw) ACDREAM_SAMPLE_ARRAY(uTextureIndexA, uvw)
#define sampleAlpha(uvw) ACDREAM_SAMPLE_ARRAY(uTextureIndexB, uvw)
// Campaign V slice V6f-2: the 36 per-layer tiling factors moved out of a loose
// `uniform float uTexTiling[36]` and into the uniform buffer GpuBindingModel
// reserved binding 3 for. Vulkan GLSL has no default uniform block, so the loose
// array was unspellable there, and at 144 bytes of payload it cannot ride the
// 96-byte push-constant block either. A uniform buffer is the only legal home,
// and the same declaration is legal in both dialects.
//
// The ELEMENT TYPE is deliberately unchanged. std140 pads every array element to
// 16 bytes, so the block is 576 bytes rather than 144, and packing four floats
// per vec4 would be tighter — but it would also change every use site below.
// Keeping `float uTexTiling[36]` means `uTexTiling[int(layer)]` reads exactly as
// it did before, so this commit's pixel gate is measuring the move to a uniform
// buffer and nothing else. TerrainTextureTilingTable.UniformBufferBytes and the
// stride beside it are the CPU half of this layout.
layout(std140, ACDREAM_UBO_SET binding = 3) uniform TerrainTiling {
float uTexTiling[36];
};
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;
};
#include "directional_shadow_receiver.glsl"
// Retail TexMerge::CopyAndTile (0x00503580) and TexMerge::Merge
// (0x005038C0) pass TerrainTex::tex_tiling to every terrain source before
// the cell-scale alpha mask is applied. The atlas stores that value by layer.
float terrainTiling(float layer) {
return uTexTiling[int(layer)];
}
vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
float a0 = h0 == 0.0 ? 1.0 : t0.a;
float a1 = h1 == 0.0 ? 1.0 : t1.a;
float a2 = h2 == 0.0 ? 1.0 : t2.a;
float aR = 1.0 - (a0 * a1 * a2);
float aRsafe = max(aR, 1e-6);
a0 = 1.0 - a0;
a1 = 1.0 - a1;
a2 = 1.0 - a2;
vec3 r0 = (a0 * t0.rgb + (1.0 - a0) * a1 * t1.rgb + (1.0 - a1) * a2 * t2.rgb);
return vec4(r0 / aRsafe, aR);
}
vec4 combineOverlays(vec2 baseUV, vec4 pOverlay0, vec4 pOverlay1, vec4 pOverlay2) {
float h0 = pOverlay0.z < 0.0 ? 0.0 : 1.0;
float h1 = pOverlay1.z < 0.0 ? 0.0 : 1.0;
float h2 = pOverlay2.z < 0.0 ? 0.0 : 1.0;
vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0);
if (h0 > 0.0) {
t0 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay0.z), pOverlay0.z));
if (pOverlay0.w >= 0.0) {
vec4 a = sampleAlpha(vec3(pOverlay0.xy, pOverlay0.w));
t0.a = a.a;
}
}
if (h1 > 0.0) {
t1 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay1.z), pOverlay1.z));
if (pOverlay1.w >= 0.0) {
vec4 a = sampleAlpha(vec3(pOverlay1.xy, pOverlay1.w));
t1.a = a.a;
}
}
if (h2 > 0.0) {
t2 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay2.z), pOverlay2.z));
if (pOverlay2.w >= 0.0) {
vec4 a = sampleAlpha(vec3(pOverlay2.xy, pOverlay2.w));
t2.a = a.a;
}
}
return maskBlend3(t0, t1, t2, h0, h1, h2);
}
vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
float h0 = pRoad0.z < 0.0 ? 0.0 : 1.0;
float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0;
vec4 result = vec4(0.0);
if (h0 > 0.0) {
result = sampleTerrain(vec3(baseUV * terrainTiling(pRoad0.z), pRoad0.z));
if (pRoad0.w >= 0.0) {
vec4 a0 = sampleAlpha(vec3(pRoad0.xy, pRoad0.w));
result.a = 1.0 - a0.a;
if (h1 > 0.0 && pRoad1.w >= 0.0) {
vec4 a1 = sampleAlpha(vec3(pRoad1.xy, pRoad1.w));
result.a = 1.0 - (a0.a * a1.a);
}
}
}
return result;
}
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);
}
void main() {
vec4 baseColor = vec4(0.0);
if (vBaseTexIdx >= 0.0) {
baseColor = sampleTerrain(vec3(vBaseUV * terrainTiling(vBaseTexIdx), vBaseTexIdx));
}
vec4 overlays = vec4(0.0);
if (vOverlay0.z >= 0.0)
overlays = combineOverlays(vBaseUV, vOverlay0, vOverlay1, vOverlay2);
vec4 roads = vec4(0.0);
if (vRoad0.z >= 0.0)
roads = combineRoad(vBaseUV, vRoad0, vRoad1);
vec3 baseMasked = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a));
vec3 ovlMasked = overlays.rgb * (overlays.a * (1.0 - roads.a));
vec3 roadMasked = roads.rgb * roads.a;
vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
vec3 surfaceToLight = normalize(uShadowLightDirectionAndSource.xyz);
float directionalVisibility = acdreamDirectionalShadowVisibility(
vWorldPos,
normalize(vWorldNormal),
uCameraAndTime.xyz,
surfaceToLight);
vec3 lighting = vAmbientLocalLit
+ vDirectionalLit * directionalVisibility;
vec3 lit = rgb * min(lighting, vec3(1.0));
float flash = uFogParams.z;
lit += flash * vec3(0.6, 0.6, 0.75);
lit = applyFog(lit, vWorldPos);
fragColor = vec4(lit, 1.0);
}

View file

@ -0,0 +1,197 @@
#version 460 core
#extension GL_ARB_bindless_texture : require
#include "directional_shadow_common.glsl"
// Phase N.5b: terrain shader on the modern bindless dispatcher.
// Math identical to terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes
// lighting). The only structural change is the version + bindless extension
// — sampler access in the fragment stage is unchanged at the GLSL level.
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in uvec4 aPacked0;
layout(location = 3) in uvec4 aPacked1;
layout(location = 4) in uvec4 aPacked2;
layout(location = 5) in uvec4 aPacked3;
// Campaign V slice V6f-1: uView/uProjection converged into the single
// uViewProjection that GpuPushConstants already carries, so terrain can be
// expressed in Vulkan GLSL at all — two loose mat4 uniforms are 128 bytes and
// cannot both fit the pinned 96-byte push block, and Vulkan GLSL has no default
// uniform block to hold them loose. The product is now formed on the CPU
// (camera.View * camera.Projection) instead of per vertex here; the two are the
// same transform, and System.Numerics' row-vector layout uploaded untransposed
// reads in GLSL as the transpose, so (View*Proj)^T == Proj^T * View^T is exactly
// the uProjection * uView this replaced.
uniform mat4 uViewProjection;
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;
};
// === Phase U.3: terrain screen-space clip gate (OutsideView region) ===========
// Terrain is a single global region (the OutsideView), so it needs one set of
// clip planes, not a per-instance slot table like the mesh shader. A std140 UBO
// at binding=2 carries it. The UBO binding namespace is distinct from the SSBO
// binding namespace, so this does NOT collide with the mesh shader's SSBO
// binding=2 — and within THIS shader binding=1 (SceneLighting) is the only other
// UBO, leaving binding=2 free. uTerrainClipCount == 0 (the U.3 default) ungates
// terrain entirely (the second loop sets all 8 distances to +1.0). Uploaded by
// ClipFrame.UploadShared each frame; TerrainModernRenderer binds it before draw.
//
// Campaign V slice V6i-2: ACDREAM_UBO_SET is what puts this in set 1 under the
// Vulkan dialect and expands to nothing under GL. Omitting it left the block at
// set 0 binding 2, which the storage layout declares as a STORAGE buffer — see
// plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred.
// sky.vert declares the SAME block correctly and is the precedent.
layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip {
int uTerrainClipCount;
vec4 uTerrainClipPlanes[8];
};
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal.
// Sized 8 to match GL_MAX_CLIP_DISTANCES >= 8. Host enables GL_CLIP_DISTANCE0..7
// once at startup; unused planes are set to +1.0 below so they pass everything.
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
out vec2 vBaseUV;
out vec3 vWorldNormal;
out vec3 vWorldPos;
out vec3 vAmbientLocalLit;
out vec3 vDirectionalLit;
out vec4 vOverlay0;
out vec4 vOverlay1;
out vec4 vOverlay2;
out vec4 vRoad0;
out vec4 vRoad1;
flat out float vBaseTexIdx;
// Retail's N·L floor from FUN_00532440 lines 2119/2138/2157/2176 at
// chunk_00530000.c (AdjustPlanes). The decompile reads:
// if (fVar3 < DAT_00796344) fVar3 = DAT_00796344;
// applied to the clamped Lambert result BEFORE it's multiplied into
// dirColor. DAT_00796344's exact literal isn't pinned by the decompile
// but every other "floor" use in retail clamps negatives to zero (the
// physically-correct Lambert half-space). Our previous 0.08 was a
// defensive guess from early acdream days that made back-lit terrain
// visibly brighter than retail (user-observed 2026-04-24 "acdream
// warmer / less blue than retail"). Reverting to 0.0 matches retail
// per the decompile and lets ambient fill in the back side.
// Cross-ref: docs/research/2026-04-24-lambert-brightness-split.md.
const float MIN_FACTOR = 0.0;
vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) {
float texIdx = float(texIdxU);
float alphaIdx = float(alphaIdxU);
if (texIdx >= 254.0) texIdx = -1.0;
if (alphaIdx >= 254.0) alphaIdx = -1.0;
vec2 rotatedUV = baseUV;
if (rotIdx == 1u) rotatedUV = vec2(1.0 - baseUV.y, baseUV.x);
else if (rotIdx == 2u) rotatedUV = vec2(1.0 - baseUV.x, 1.0 - baseUV.y);
else if (rotIdx == 3u) rotatedUV = vec2( baseUV.y, 1.0 - baseUV.x);
return vec4(rotatedUV.x, rotatedUV.y, texIdx, alphaIdx);
}
void main() {
// Unpack rotation fields from aPacked3. Bit layout (data3):
// .x (byte 0): bits 0-1 rotBase (unused), 2-3 rotOvl0, 4-5 rotOvl1, 6-7 rotOvl2
// .y (byte 1): bits 0-1 rotRd0 (= data3 bit 8-9),
// bits 2-3 rotRd1 (= data3 bit 10-11),
// bit 4 splitDir (= data3 bit 12)
uint rotOvl0 = (aPacked3.x >> 2u) & 3u;
uint rotOvl1 = (aPacked3.x >> 4u) & 3u;
uint rotOvl2 = (aPacked3.x >> 6u) & 3u;
uint rotRd0 = aPacked3.y & 3u;
uint rotRd1 = (aPacked3.y >> 2u) & 3u;
uint splitDir= (aPacked3.y >> 4u) & 1u;
// Derive which of the 4 cell corners this vertex represents from
// gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
// specific order for each split direction; the tables below must stay
// in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
// 2026-04-21 fix: geometry re-derived to match ACE's ConstructPolygons
// convention. SWtoNE (cut BL→TR, y=x diagonal) now maps to the {BL,BR,TR}
// + {BL,TR,TL} triangle pair; SEtoNW (cut BR→TL, x+y=1 diagonal) maps to
// {BL,BR,TL} + {BR,TR,TL}.
int vIdx = gl_VertexID % 6;
int corner = 0;
if (splitDir == 0u) {
// SWtoNE order: BL, BR, TR, BL, TR, TL → corners 0, 1, 2, 0, 2, 3
if (vIdx == 0) corner = 0;
else if (vIdx == 1) corner = 1;
else if (vIdx == 2) corner = 2;
else if (vIdx == 3) corner = 0;
else if (vIdx == 4) corner = 2;
else corner = 3;
} else {
// SEtoNW order: BL, BR, TL, BR, TR, TL → corners 0, 1, 3, 1, 2, 3
if (vIdx == 0) corner = 0;
else if (vIdx == 1) corner = 1;
else if (vIdx == 2) corner = 3;
else if (vIdx == 3) corner = 1;
else if (vIdx == 4) corner = 2;
else corner = 3;
}
vec2 baseUV;
if (corner == 0) baseUV = vec2(0.0, 1.0);
else if (corner == 1) baseUV = vec2(1.0, 1.0);
else if (corner == 2) baseUV = vec2(1.0, 0.0);
else baseUV = vec2(0.0, 0.0);
vBaseUV = baseUV;
vWorldPos = aPos;
vWorldNormal = normalize(aNormal);
// Retail AdjustPlanes bake (terrain.vert:124-134 — identical math).
vec3 surfaceToLight = normalize(uShadowLightDirectionAndSource.xyz);
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
float L = max(dot(vWorldNormal, surfaceToLight), MIN_FACTOR);
// Preserve retail's authored lighting values, but keep the outdoor
// directional term separate so the receiver shadows no ambient/local light.
vAmbientLocalLit = uCellAmbient.xyz;
vDirectionalLit = sunCol * L;
float baseTex = float(aPacked0.x);
if (baseTex >= 254.0) baseTex = -1.0;
vBaseTexIdx = baseTex;
vOverlay0 = unpackOverlayLayer(aPacked0.z, aPacked0.w, rotOvl0, baseUV);
vOverlay1 = unpackOverlayLayer(aPacked1.x, aPacked1.y, rotOvl1, baseUV);
vOverlay2 = unpackOverlayLayer(aPacked1.z, aPacked1.w, rotOvl2, baseUV);
vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV);
vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, baseUV);
// Retail zFightTerrainAdjust (acclient_2013_pseudo_c.txt:1120769 = 0.00999999978,
// applied per terrain vertex inside ACRender::landPolysDraw at line 702254,
// address 006b6402). Render terrain 1 cm below its physical Z so coplanar
// building floors win the depth test. Physics path is unaffected — it reads
// the un-nudged heightmap via TerrainSurface.SampleZ.
// Closes issue #100; supersedes the hiddenTerrainCells cell-collapse hack.
vec3 terrainPos = vec3(aPos.xy, aPos.z - 0.01);
gl_Position = uViewProjection * vec4(terrainPos, 1.0);
// Phase U.3: terrain clip gate against the single OutsideView region. With
// uTerrainClipCount == 0 (U.3 default) the first loop is skipped and the
// second sets all 8 distances to +1.0 ⇒ no clipping ⇒ identical terrain.
for (int i = 0; i < uTerrainClipCount; ++i)
gl_ClipDistance[i] = dot(uTerrainClipPlanes[i], gl_Position);
for (int i = uTerrainClipCount; i < 8; ++i)
gl_ClipDistance[i] = 1.0;
}