07 lighting
Directional Lights and Hemispheric Ambient
Completing the basic lighting model — and making clear how crude constant ambient really is. Sets up the motivation for ambient occlusion.
Two Kinds Of Light, One Buffer
Adding a second light variant is a type-system question, not a graphics one. And replacing the ambient constant is the cheapest visible upgrade in the whole lighting block.
The Last Lie In The Basic Lighting Model
The basic lighting model has three holes the previous two posts deliberately left open. Post 5 listed them as “what I didn’t build, yet.” This post closes two of them:
- A second kind of light — directional, like the sun, where falloff is meaningless because the source is at infinity.
- A better ambient term, because
0.05 * albedois the most honestly-fake line in the shader and every lit pixel pays it whether it’s facing the sky or the floor.
Both are small features in isolation. The reason they share a post is that they share a lesson: once your scene is data, adding new kinds of light is a type-system move, and the rendering code barely notices.
A Sun Doesn’t Live At A Position
A point light has a position. It sits somewhere in the world; pixels close to it get bright, pixels far away get dim. Attenuation is a function of distance.
A directional light doesn’t. It models a source so far away — the sun, the moon, a sky dome — that every parallel ray from it hits every surface from the same angle and with the same intensity. There is no useful position; there is only a direction the light is travelling, and a color, and an intensity. There is no distance, so there’s no falloff.
Different math. Same shading equation otherwise: dot the surface normal with the vector toward the light, scale by albedo, add a specular term. The shader edit is two lines — pick lightDir from the direction field instead of computing it from position - fragPos, and skip attenuation. The architectural question is what shape this takes on the CPU side, because the alternatives are not equally good.
The Shape Question
Three sketches of where a directional light could live:
- Option A: A second SSBO. One buffer for points, one for directionals. Two descriptor bindings, two upload paths, two shader loops. Symmetric, but the symmetry compounds — every new light kind doubles the boilerplate.
- Option B: A union struct in one buffer. One buffer, one descriptor, one loop. Each entry carries enough fields for either variant, plus a type tag the shader reads to pick which fields are live. The buffer’s layout is a tagged union.
- Option C: A polymorphic class hierarchy with virtual dispatch. Lights are objects, each one knows how to compute its own shading. Closures on the heap, vtable lookups in the loop, no clear boundary between domain and GPU layout. The OOP default that the lab is explicitly pushing back against.
The lab takes option B. The CPU type model is a discriminated union — Light = PointLight | DirectionalLight — and the only place that union collapses into a single GPU layout is the packer. Everywhere else in the pure code, the variants are distinct types and the compiler enforces that you handle each one.
public abstract record Light;
public sealed record PointLight(
Vector3 Position,
Vector3 Color,
Intensity Intensity) : Light;
public sealed record DirectionalLight(
Direction Direction,
Vector3 Color,
Intensity Intensity) : Light;
The “make illegal states unrepresentable” lens kicks in twice here:
public readonly record struct Direction
{
public Vector3 Value { get; }
private Direction(Vector3 unit) => Value = unit;
public static Direction Create(Vector3 v)
{
if (v.LengthSquared() < 1e-12f)
throw new ArgumentException("Direction cannot be the zero vector.", nameof(v));
return new Direction(Vector3.Normalize(v));
}
}
public readonly record struct Intensity
{
public float Value { get; }
private Intensity(float v) => Value = v;
public static Intensity Of(float v)
{
if (v < 0f || float.IsNaN(v))
throw new ArgumentOutOfRangeException(...);
return new Intensity(v);
}
}
A Direction value is, by construction, always unit-length and non-zero. An Intensity is, by construction, always finite and non-negative. The lighting shader reads both from the SSBO with no defensive normalize or max(0, ...) clamps — those preconditions were enforced at construction, hours of wall-clock time before the GPU sees the bytes. This is the dividend smart constructors keep paying: the further from the boundary you push the validation, the less code downstream has to defend itself.
The Packer Is The Only Place The Union Collapses
GpuLight is one [StructLayout(Sequential)] record struct with three Vector4s, picked to fit either variant cleanly:
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public readonly record struct GpuLight(
Vector4 PositionType, // xyz = position OR zero; w = type tag
Vector4 DirectionPad, // xyz = direction OR zero; w = unused
Vector4 ColorIntensity)
{
public const int TypePoint = 0;
public const int TypeDirectional = 1;
}
The packer is where the union collapses, and the pattern-match is unavoidable — by design:
public static GpuLight Pack(PointLight light) => new(
PositionType: new Vector4(light.Position, GpuLight.TypePoint),
DirectionPad: Vector4.Zero,
ColorIntensity: new Vector4(light.Color, light.Intensity.Value));
public static GpuLight Pack(DirectionalLight light) => new(
PositionType: new Vector4(0f, 0f, 0f, GpuLight.TypeDirectional),
DirectionPad: new Vector4(light.Direction.Value, 0f),
ColorIntensity: new Vector4(light.Color, light.Intensity.Value));
public static int PackInto(ReadOnlySpan<Light> lights, Span<GpuLight> destination)
{
int written = 0;
for (int i = 0; i < lights.Length; i++)
if (lights[i] is PointLight p)
destination[written++] = Pack(p);
for (int i = 0; i < lights.Length; i++)
if (lights[i] is DirectionalLight d)
destination[written++] = Pack(d);
return written;
}
Two passes is intentional: point lights first, then directional lights. That partition is part of the contract with the shader. It would compile and run if the order were arbitrary — the type tag is enough to route each entry to the right code path inside the loop — but a stable partition costs nothing, makes RenderDoc captures readable, and means the future “do all the points, then all the directionals” optimization is a one-line shader edit, not a redesign.
The shader-side type-switch is small:
if (type == LIGHT_DIRECTIONAL) {
lightDir = -normalize(L.directionPad.xyz);
attenuation = 1.0;
} else {
vec3 lightPos = L.positionType.xyz;
lightDir = normalize(lightPos - fragPos);
float dist = length(lightPos - fragPos);
attenuation = 1.0 / (1.0 + 0.09 * dist + 0.032 * dist * dist);
}
That’s the entire payload: two lines extra in the shader, one extra packing branch, one extra type definition on the CPU. The geometry pass didn’t change. The render graph didn’t change. The descriptor set didn’t change. Adding a kind of light is a domain edit that walks through one boundary.
The Ambient Lie, Slightly Less Of A Lie
The lighting shader from post 5 had this line:
vec3 ambient = 0.05 * albedo;
That’s 5% of the surface color, applied to every pixel regardless of where the surface is facing. A floor and a ceiling get the same ambient. A wall facing the sky and a wall facing dirt get the same ambient. It’s a constant pretending to be a lighting term.
The cheapest improvement is hemispheric ambient: pick a sky color, pick a ground color, blend between them based on whether the surface normal points up or down.
public sealed record HemisphericAmbient(Vector3 Sky, Vector3 Ground)
{
public static HemisphericAmbient Default { get; } = new(
Sky: new Vector3(0.50f, 0.55f, 0.60f),
Ground: new Vector3(0.15f, 0.12f, 0.10f));
}
In the shader:
float skyMix = N.y * 0.5 + 0.5;
vec3 ambientColor = mix(pc.ambientGround.rgb, pc.ambientSky.rgb, skyMix);
vec3 ambient = stripAlbedo ? ambientColor : ambientColor * albedo;
N.y is the up component of the surface normal, in [-1, 1]; remapped to [0, 1], it’s a smooth weight from “fully ground” at the bottom of a sphere to “fully sky” at the top. The pixel cost is one mix. The visual cost is a constant got replaced by a function of the surface, and the result is dramatic — a sphere with no light sources at all now reads as a sphere, with cool tones on top and warm tones at the base. That’s free shape information for one shader instruction.
It is still a lie. Real ambient light is the integral of incoming light over the hemisphere of directions visible from a surface point — bounce light, sky light, surfaces shadowing each other. Hemispheric ambient does none of that. A surface inside a closed box still gets the sky color; a corner that would be dim from occlusion gets the same ambient as a flat plane. The next milestone (M5) attacks exactly that: screen-space ambient occlusion, which estimates the visible portion of the hemisphere per pixel and modulates the ambient accordingly.
For now, hemispheric ambient is the marker post: the cheapest improvement that exposes the lie. Once the eye sees that ambient can respond to geometry, the constant version stops looking acceptable, which is exactly the motivation SSAO needs.
”Lighting Only” — A Tiny Toggle With An Opinion
The lighting debug menu picked up a “lighting only” toggle. Engaged, the shader drops the albedo factor: instead of albedo * (diffuse + specular), you see (diffuse + specular) with the surface tint stripped out. Useful for debugging — the diffuse and specular response are visible without the surface color confusing what the lighting is doing.
The opinionated bit: ambient stays on. The toggle’s contract is “no surface color anywhere,” not “no ambient.” Ambient is a property of the environment, not the material — a hemisphere of incoming light that exists whether the surface is white, red, or a checkerboard. Stripping albedo from the ambient term would mean the toggle silently changes the meaning of ambient as well as diffuse, and the resulting image would be harder to read, not easier.
bool stripAlbedo = (pc.lightingOnly == 1);
vec3 diffuseTerm = stripAlbedo
? diff * lightColor * intensity
: diff * albedo * lightColor * intensity;
// ... later ...
vec3 ambient = stripAlbedo ? ambientColor : ambientColor * albedo;
The decision lives in two ifs. There is no clever architecture making this work. Sometimes the right answer is to write down what the toggle means in a sentence and then make the code match.
Push-Constant Padding, A Footgun Worth Naming
One detail worth flagging because it cost about an hour the first time it bit me. The lighting push-constant block now looks like this:
layout(push_constant) uniform LightParams {
vec4 cameraPos;
int shadingMode;
int lightingOnly;
int lightCount;
int _pad0; // align next vec4 to 16 bytes
vec4 ambientSky;
vec4 ambientGround;
} pc;
And the C# mirror:
[StructLayout(LayoutKind.Sequential)]
public struct LightingPushConstants
{
public Vector4 CameraPos;
public int ShadingMode;
public int LightingOnly;
public int LightCount;
public int Pad0;
public Vector4 AmbientSky;
public Vector4 AmbientGround;
}
The Pad0 int is not a bug. std430 says a vec4 must be aligned to 16 bytes inside a push-constant block. C#‘s Sequential layout will happily pack three ints right against the next Vector4 because the C# rule is “natural alignment” — and Vector4’s natural alignment isn’t always 16. If the C# struct comes out at 60 bytes and the GLSL block expects 64, the bytes for ambientSky come from where Pad0 should be, and ambientSky reads as zero — which silently turns ambient off and makes the whole scene render mysteriously dark.
One explicit padding field, one comment, problem solved. This is the kind of thing the field-note genre exists for; it’s also the kind of thing that’s tempting to fix with a clever layout-emitting helper, which isn’t worth the abstraction cost for a struct edited every few months.
What The Lighting Block Built
This post closes the lighting block. Standing back from all three posts:
- Post 5: One light. Per-pixel Phong/Blinn-Phong shading, riding on the G-Buffer’s per-pixel normals. Material params packed into G-Buffer alpha slack.
- Post 6: Many point lights. Per-frame SSBO, pure CPU codec, immutable scene → packed bytes through a single boundary. Per-pixel cost goes
O(L). - Post 7: Two kinds of light. Discriminated union on the CPU collapses to a tagged union in one GPU buffer. Hemispheric ambient replaces the constant lie with a gradient lie that responds to surface orientation.
What’s still missing — load-bearing missing, not nice-to-have missing:
- Shadows. Lights illuminate through walls. The lab’s roadmap puts shadow mapping later, after the editor block makes the demo scene rich enough that shadowless lighting becomes visibly wrong.
- Real ambient. Hemispheric ambient is the floor; SSAO is the next ceiling, and it’s the entire next milestone (M5).
- Light culling. Naive
O(L)per pixel. Tiled or clustered deferred is the fix when the demo scene grows enough lights to justify it. - Energy-conserving shading. Blinn-Phong is plausibility, not physics. PBR is much later in the arc.
What I keep noticing across these three posts is how much of the work was data-shape work, not graphics work. Find slack in a layout (post 5: G-Buffer alpha). Switch to a transport that fits a variable-length thing (post 6: SSBO). Pick a tagged union over a class hierarchy (post 7: Light discriminated union). The lighting code in lighting.frag is short, mostly tutorial, and doesn’t surprise anyone. The architectural moves around it — what lives where, who owns the bytes, where the validation runs — are where the actual decisions are.
That’s the next post’s setup, in a sense. Hemispheric ambient is the cheapest possible “ambient that knows about the surface.” SSAO is “ambient that knows about the neighborhood of the surface,” and to compute it we need to read from a depth buffer the geometry pass already wrote — another data shape that, conveniently, already exists.