feat(render): Phase G.1/G.2 — SceneLighting UBO + sky renderer + shader integration

Wire the existing LightManager + WorldTimeService state into visible
rendering. Every draw call (terrain, static mesh, instanced mesh, sky)
now shares one SceneLighting UBO at binding=1 carrying:
  - 8 Light slots (Directional / Point / Spot, retail hard-cutoff)
  - Ambient RGB + active light count
  - Fog start/end/mode + color + lightning flash scalar
  - Camera world position + day fraction

The CPU side (SceneLightingUbo in Core.Lighting) is a POD struct that
gets BufferSubData'd once per frame from GameWindow.OnRender. Shaders
read the block via `layout(std140, binding = 1) uniform SceneLighting`
— no per-program uniform uploads.

Shader changes:
  - mesh.frag + mesh_instanced.frag accumulate 8 dynamic lights per
    fragment using the retail no-attenuation hard-cutoff model
    (r13 §10.2 / §13.1). Sun reads slot 0; spots use hard cos-cone test.
    Additive lightning flash + linear fog layered on top. Saturate
    clamps per-channel to 1.0.
  - terrain.vert bakes AdjustPlanes sun+ambient per vertex using the
    retail MIN_FACTOR = 0.08 ambient floor (r13 §7). terrain.frag adds
    fog + flash on top of the baked vertex color.
  - mesh.vert + mesh_instanced.vert emit vWorldPos so the fragment
    stage can do per-pixel lighting against world-space positions.
  - New sky.vert / sky.frag pair — unlit, scroll-UV, camera-centered,
    with its own 0.1..1e6 far plane. Ports WorldBuilder's skybox.

SkyRenderer (new file in App/Rendering/Sky/) ports WorldBuilder's
SkyboxRenderManager verbatim for the C# idiom: zeroed view translation,
dedicated projection, depth mask off, iterate each visible SkyObject
in the day group, apply arc transform (Z rot for heading + Y rot for
arc sweep), feed TexVelocityX/Y as a scrolling UV offset, apply
per-keyframe SkyObjectReplace overrides (mesh swap + transparency +
luminosity) for overcast / dusk cloud variants.

GameWindow integration:
  - OnLoad parses Region (0x13000000) into LoadedSkyDesc and hot-swaps
    WorldTime's provider to the dat-accurate keyframes. Seeds to noon
    for offline rendering. Creates the SceneLightingUboBinding and the
    SkyRenderer.
  - OnRender: set clear color from atmosphere fog, tick WeatherSystem,
    spawn/stop rain/snow camera-local emitters on kind change, feed
    sun to LightManager (zero intensity indoors — r13 §13.7), tick
    LightManager against viewer pos, build + upload the UBO, draw
    sky before terrain, draw terrain + static + instanced using the
    shared UBO.

5 new UBO packing tests (struct sizes, slot population, 8-light cap,
directional slot 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:39:48 +02:00
parent 0df1c5b4a6
commit 9957070cab
15 changed files with 1255 additions and 91 deletions

View file

@ -1,6 +1,7 @@
#version 430 core
in vec2 vTex;
in vec3 vWorldNormal;
in vec3 vWorldPos;
out vec4 fragColor;
uniform sampler2D uDiffuse;
@ -11,35 +12,114 @@ uniform sampler2D uDiffuse;
// 2 = AlphaBlend — GL blending handles compositing; do NOT discard
// 3 = Additive — GL additive blending; do NOT discard
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
//
// Only ClipMap uses the alpha-discard path. AlphaBlend/Additive/InvAlpha
// rely entirely on the GL blend stage — discarding low-alpha fragments
// would make semi-transparent surfaces (portals, glows) fully invisible.
uniform int uTranslucencyKind;
// Phase 3a: simple directional lighting. A single sun direction + ambient term
// gives scenery and building faces enough differentiation to read as 3D instead
// of looking like paper cutouts. Hardcoded for now; a later phase can route
// light parameters through uniforms driven by the game's time-of-day.
// Sun direction tuned after Phase 3a verification: (0.4,0.3,0.8) was too
// vertical — roofs and ground both landed near peak brightness and only
// walls dropped, so the contrast was hard to read through textures. More
// oblique + lower ambient + higher diffuse = contrast ratio ~3.3x.
const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6));
const float AMBIENT = 0.25;
const float DIFFUSE = 0.75;
// ─────────────────────────────────────────────────────────────
// Phase G.1+G.2: shared scene-lighting UBO (binding = 1).
//
// Layout mirrors SceneLightingUbo in C#:
// struct Light {
// vec4 posAndKind; xyz = world pos, w = kind (0=dir,1=point,2=spot)
// vec4 dirAndRange; xyz = forward, w = range (metres, hard cutoff)
// vec4 colorAndIntensity; xyz = RGB linear, w = intensity
// vec4 coneAngleEtc; x = cone (rad), yzw = reserved
// };
// layout(std140, binding = 1) uniform SceneLighting {
// Light uLights[8];
// vec4 uCellAmbient; xyz = ambient RGB, w = active count
// vec4 uFogParams; x = start, y = end, z = flash, w = mode
// vec4 uFogColor; xyz = color
// vec4 uCameraAndTime; xyz = camera pos, w = day fraction
// };
// ─────────────────────────────────────────────────────────────
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;
};
// Retail hard-cutoff lighting equation (r13 §10.2). No distance
// attenuation inside Range; hard edge at Range; spotlights use a
// binary cos-cone test. This is deliberate — the retail "bubble of
// light" look relies on crisp boundaries.
vec3 accumulateLights(vec3 N, vec3 worldPos) {
vec3 lit = uCellAmbient.xyz;
int active = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= active) break;
int kind = int(uLights[i].posAndKind.w);
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
if (kind == 0) {
// Directional: "forward" is the light's direction vector
// pointing INTO the scene. N·(-forward) = light-facing.
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += Lcol * ndl;
} else {
// Point / spot: falloff is a HARD bubble at Range.
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; // retail: no attenuation inside Range
if (kind == 2) {
// Spotlight: hard-edged cos-cone test.
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;
}
// Linear fog (r12 §5.1): mode 1 = LINEAR, 0 = off, others reserved.
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 sampled = texture(uDiffuse, vTex);
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation).
// Blended surface types (AlphaBlend, Additive, InvAlpha) must NOT
// discard here — that would make every semi-transparent pixel invisible
// before the blend stage even runs.
if (uTranslucencyKind == 1 && sampled.a < 0.5) discard;
vec3 N = normalize(vWorldNormal);
float ndotl = max(dot(N, SUN_DIR), 0.0);
float lighting = AMBIENT + DIFFUSE * ndotl;
fragColor = vec4(sampled.rgb * lighting, sampled.a);
vec3 lit = accumulateLights(N, vWorldPos);
// Lightning flash (r12 §9) — additive cold-white pulse layered on top
// of diffuse lighting.
float flash = uFogParams.z;
lit += flash * vec3(0.6, 0.6, 0.75);
// Clamp per-channel to 1.0 — matches retail (r13 §13.1).
lit = min(lit, vec3(1.0));
vec3 rgb = sampled.rgb * lit;
// Atmospheric fog — applied after lighting.
rgb = applyFog(rgb, vWorldPos);
fragColor = vec4(rgb, sampled.a);
}

View file

@ -9,6 +9,7 @@ uniform mat4 uProjection;
out vec2 vTex;
out vec3 vWorldNormal;
out vec3 vWorldPos;
void main() {
vTex = aTex;
@ -17,5 +18,7 @@ void main() {
// scale would require the inverse transpose; we accept that as a Phase 3+
// concern.
vWorldNormal = normalize(mat3(uModel) * aNormal);
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
vec4 world = uModel * vec4(aPos, 1.0);
vWorldPos = world.xyz;
gl_Position = uProjection * uView * world;
}

View file

@ -2,7 +2,7 @@
in vec2 vTex;
in vec3 vWorldNormal;
in float vLightingFactor;
in vec3 vWorldPos;
out vec4 fragColor;
@ -18,14 +18,81 @@ uniform sampler2D uDiffuse;
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
uniform int uTranslucencyKind;
// Phase G.1+G.2: shared scene-lighting UBO (see mesh.frag for layout docs).
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 active = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= active) 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);
}
void main() {
vec4 color = texture(uDiffuse, vTex);
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation).
// Blended surface types must NOT discard here — that kills every
// semi-transparent pixel before the blend stage runs.
if (uTranslucencyKind == 1 && color.a < 0.5) discard;
// Apply pre-computed Lambert + ambient lighting factor from the vertex shader.
fragColor = vec4(color.rgb * vLightingFactor, color.a);
vec3 N = normalize(vWorldNormal);
vec3 lit = accumulateLights(N, vWorldPos);
// Lightning flash — additive scene bump.
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);
}

View file

@ -16,12 +16,10 @@ layout(location = 5) in vec4 aInstanceRow2;
layout(location = 6) in vec4 aInstanceRow3;
uniform mat4 uViewProjection;
uniform vec3 uLightDirection; // world-space light direction (points FROM sun, matching ACME)
uniform float uAmbientIntensity;
out vec2 vTex;
out vec3 vWorldNormal;
out float vLightingFactor;
out vec3 vWorldPos;
void main() {
// Reconstruct the per-instance model matrix from its four row vectors.
@ -30,11 +28,8 @@ void main() {
vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;
vWorldPos = worldPos.xyz;
// Transform normal into world space.
vWorldNormal = normalize(mat3(model) * aNormal);
vTex = aTexCoord;
// Lambert + ambient matching ACME StaticObject.vert:
// LightingFactor = max(dot(Normal, -uLightDirection), 0.0) + uAmbientIntensity;
vLightingFactor = max(dot(vWorldNormal, -uLightDirection), 0.0) + uAmbientIntensity;
}

View file

@ -0,0 +1,51 @@
#version 430 core
// Sky mesh fragment shader — sample the object's diffuse texture with
// the scrolled UVs from the vertex stage. Unlit: sky meshes ARE the
// gradient (r12 §2.2), not a surface lit by the sun.
//
// The per-keyframe replace override can dim the mesh (Transparent) or
// brighten it (Luminosity); those two floats arrive as uTransparency /
// uLuminosity uniforms.
in vec2 vTex;
out vec4 fragColor;
uniform sampler2D uDiffuse;
uniform float uTransparency; // 0 = fully visible, 1 = invisible
uniform float uLuminosity; // 1 = normal, >1 makes the mesh glow
uniform vec4 uTint; // per-object color tint (default white)
// Shared SceneLighting UBO — we only need the fog parameters to let the
// horizon band of the sky blend smoothly into the scene's fog color at
// the far edge, and the lightning flash to give storms their signature
// strobe.
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;
};
void main() {
vec4 sampled = texture(uDiffuse, vTex);
// Apply tint + luminosity. Retail's SkyObjReplace.Luminosity can push
// above 1 to make the sun mesh brighter than its texture; r12 §2.3.
vec3 rgb = sampled.rgb * uTint.rgb * uLuminosity;
// Lightning additive bump — makes the sky itself flash during storms.
rgb += uFogParams.z * vec3(0.5, 0.5, 0.55);
rgb = min(rgb, vec3(1.2)); // soft clamp to let luminosity over-bright mildly
float a = sampled.a * (1.0 - uTransparency) * uTint.a;
if (a < 0.01) discard;
fragColor = vec4(rgb, a);
}

View file

@ -0,0 +1,22 @@
#version 430 core
// Sky mesh vertex shader — each celestial object is a GfxObj mesh
// (sun billboard, cloud sheet, moon, star dome) rendered at large
// distance with depth writes disabled. The view matrix has its
// translation zeroed so the sky stays camera-centered; the projection
// matrix has a huge far plane so 1e6-metre-away sky meshes never clip.
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTex;
uniform mat4 uModel; // per-object arc transform (r12 §2.1)
uniform mat4 uSkyView; // camera view with M41..M43 = 0
uniform mat4 uSkyProjection; // near=0.1, far=1e6
uniform vec2 uUvScroll; // cumulative TexVelocityX/Y * time
out vec2 vTex;
void main() {
vTex = aTex + uUvScroll;
gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0);
}

View file

@ -1,12 +1,14 @@
#version 430 core
// Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's
// Landscape.frag, trimmed of editor-specific features (grid, brush,
// walkable-slope highlighting) and with Phase 3a/3b directional lighting
// layered on at the end.
// walkable-slope highlighting). Phase G extends this with the shared
// SceneLighting UBO driving per-vertex sun bake + fragment-stage fog
// + lightning flash.
in vec2 vBaseUV;
in vec3 vWorldNormal;
in float vLightingFactor;
in vec3 vWorldPos;
in vec3 vLightingRGB;
in vec4 vOverlay0;
in vec4 vOverlay1;
in vec4 vOverlay2;
@ -18,24 +20,34 @@ out vec4 fragColor;
uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture
uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture
uniform float xAmbient; // ambient intensity (matching ACME Landscape.frag)
// Shared scene-lighting UBO — fog + flash are consumed here; the per-vertex
// AdjustPlanes bake already incorporated sun + ambient.
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;
};
// Per-texture tiling repeat count across a cell. WorldBuilder uses
// uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per
// cell, 8 tiles across a landblock) until we wire the array. The previous
// Phase 2b/3 single-layer path tiled at ~2 per cell, so the world may read
// slightly coarser at 1.0 — tunable here if it looks wrong.
// cell, 8 tiles across a landblock).
const float TILE = 1.0;
// Three-layer alpha-weighted composite. Each terrain overlay layer
// contributes based on its own alpha mask; missing layers (h == 0) collapse
// to transparent. Lifted verbatim from WorldBuilder's Landscape.frag.
// Three-layer alpha-weighted composite.
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);
// avoid divide-by-zero when all three overlays are absent
float aRsafe = max(aR, 1e-6);
a0 = 1.0 - a0;
a1 = 1.0 - a1;
@ -82,7 +94,6 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z));
if (pRoad0.w >= 0.0) {
vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w));
// Roads use inverted alpha (the mask stores NON-road coverage).
result.a = 1.0 - a0.a;
if (h1 > 0.0 && pRoad1.w >= 0.0) {
vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w));
@ -93,9 +104,18 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
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() {
// Base color: if there's no base layer (sentinel -1) just render black
// (shouldn't happen in valid data).
vec4 baseColor = vec4(0.0);
if (vBaseTexIdx >= 0.0) {
baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx));
@ -115,9 +135,15 @@ void main() {
vec3 roadMasked = roads.rgb * roads.a;
vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
// Lighting matching ACME Landscape.frag:
// litColor = finalColor * (saturate(vLightingFactor) + xAmbient);
vec3 litColor = rgb * (clamp(vLightingFactor, 0.0, 1.0) + xAmbient);
// Apply the per-vertex baked sun+ambient.
vec3 lit = rgb * min(vLightingRGB, vec3(1.0));
fragColor = vec4(litColor, 1.0);
// Lightning flash — additive.
float flash = uFogParams.z;
lit += flash * vec3(0.6, 0.6, 0.75);
// Atmospheric fog.
lit = applyFog(lit, vWorldPos);
fragColor = vec4(lit, 1.0);
}

View file

@ -8,11 +8,28 @@ layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see bel
uniform mat4 uView;
uniform mat4 uProjection;
uniform vec3 xLightDirection; // world-space sun direction (matching ACME Landscape.vert)
// Phase G.1+G.2: sky/scene UBO. Terrain reads uLights[0] for the sun
// (slot 0 is reserved) plus uCellAmbient for outdoor ambient; the fog
// fields are consumed by the fragment stage.
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;
};
out vec2 vBaseUV;
out vec3 vWorldNormal;
out float vLightingFactor;
out vec3 vWorldPos;
out vec3 vLightingRGB; // pre-computed sun+ambient contribution for retail-style AdjustPlanes bake
// Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w".
// Negative .z means "layer not present, skip it in the fragment shader."
out vec4 vOverlay0;
@ -22,6 +39,11 @@ out vec4 vRoad0;
out vec4 vRoad1;
flat out float vBaseTexIdx;
// Retail's "ambient floor" constant from the decompiled AdjustPlanes
// path (r13 §7, DAT_00796344). Even a back-lit vertex sees at least
// this fraction of the sun color — NOT additive with ambient.
const float MIN_FACTOR = 0.08;
// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's
// 90° rotation count.
@ -56,13 +78,6 @@ void main() {
// gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
// specific order for each split direction; the table below must stay
// in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
//
// Corner labels: 0=BL (low x/y), 1=BR (high x, low y),
// 2=TR (high x/y), 3=TL (low x, high y).
// WorldBuilder assigns cell-local UV per corner:
// 0 → (0, 1) 1 → (1, 1) 2 → (1, 0) 3 → (0, 0)
// (the v axis is flipped vs. geometric convention — harmless, just a
// texture-space choice).
int vIdx = gl_VertexID % 6;
int corner = 0;
if (splitDir == 0u) {
@ -90,12 +105,20 @@ void main() {
else baseUV = vec2(0.0, 0.0);
vBaseUV = baseUV;
// Vertices are baked in world space; normals need no model transform.
vWorldPos = aPos;
vWorldNormal = normalize(aNormal);
// Lambert diffuse term matching ACME Landscape.vert:
// vLightingFactor = max(0.0, dot(vNormal, -normalize(xLightDirection)));
vLightingFactor = max(0.0, dot(vWorldNormal, -normalize(xLightDirection)));
// Retail AdjustPlanes bake (r13 §7):
// L = max(N · -sunDir, MIN_FACTOR)
// vertex.color = sun_color * L + ambient_color
//
// Slot 0 of the UBO is the sun (directional). We read its forward
// vector and pre-multiplied color, apply the ambient floor, layer
// in the scene ambient separately.
vec3 sunDir = uLights[0].dirAndRange.xyz;
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR);
vLightingRGB = sunCol * L + uCellAmbient.xyz;
float baseTex = float(aPacked0.x);
if (baseTex >= 254.0) baseTex = -1.0;