Home / Features / Lighting and shadows
One directional sun, punctual lights imported through
KHR_lights_punctual, and environment lighting from the sky
itself. Lighting state is assembled once per frame in
Rendering/SceneLighting.cs and consumed by every pass from
one buffer, so shadows, GI and the sky cannot disagree about where the sun is.
SceneLighting holds a plain
List<LightSource> plus a constant ambient term. Once per
frame Bake turns that list into the GPU structure every pass
reads. Nothing else in the engine keeps its own copy of the lighting state.
Lighting.Ambient = new Vector4(0.12f, 0.13f, 0.16f, 1f);
Lighting.Add(new LightSource
{
Kind = LightKind.Spot,
Name = "porch",
Position = new Vector3(24f, 6f, 7.5f),
Direction = Vector3.Normalize(new Vector3(0f, -1f, -0.4f)),
Color = new Vector4(1f, 0.86f, 0.66f, 1f),
Intensity = 40f,
Range = 24f,
InnerConeAngle = 0.25f,
OuterConeAngle = 0.55f,
CastShadows = true,
Priority = 10
});
| Member | Notes |
|---|---|
Kind | Directional, Point or Spot. |
IsOpen | Default true. Switching a light off keeps it in the list and out of the bake. |
Color, Intensity | Linear colour and a scalar; intensity is in engine units, not candela. |
Position, Direction | Direction defaults to straight down. Position is ignored for directional lights. |
Range | Falloff distance for point and spot lights. |
InnerConeAngle, OuterConeAngle | Spot cone, in radians. |
CastShadows | Requests the one punctual shadow slot. Only one light gets it per frame. |
Priority | Tie-breaker when the list exceeds the GPU budget. |
SceneLightParams.MaxLights is 8. That is a hard GPU limit, not
a soft target: the uniform buffer has eight slots. When a scene has more,
Bake sorts by Priority and
takes the first eight, using the camera position to break ties by distance.
A ninth light does not warn, throw or dim — it simply is not there. If a light
matters, give it a high Priority. This is the main reason
the engine is a poor fit for scenes lit by dozens of small local lights; forward shading
with a fixed budget is the trade being made.
Lights imported from a glTF file arrive through
Model.ImportedPunctualLights and are appended by the model
itself, which means an asset carrying twelve lights will consume the whole budget on its
own. Model.LightIntensityScale and
RenderQuality.KhrLightIntensityScale exist to bring their
photometric magnitudes back into engine range.
Three sun cascades and one punctual slot share a single square depth atlas, each quadrant a tile of half the side length. Four slots, one texture, one binding — which is why the shadow pass is also the one pass that sets its own viewport.
| Knob | Meaning | When it applies |
|---|---|---|
ShadowsEnabled | Master switch; off skips the pass entirely. | Runtime |
ShadowAtlasSize | Atlas side length, D32 float. Default 2048. | Initialization |
ShadowCascadeCount | Cascade count, clamped to 2–3. | Initialization |
ShadowDistance | Farthest distance the sun casts, clamped by camera far. Default 40. | Runtime |
CascadeSplitLambda | Uniform-to-logarithmic split blend, 0.6 by default. | Runtime |
ShadowNormalOffset | Normal-offset bias in shadow texels; 0 disables. | Runtime |
ShadowSoftnessTexels | Radius of the eight-tap Vogel disk, in texels. | Runtime |
ShadowContactHardening | Narrows the filter near the contact point. Off by default. | Runtime |
ShadowStrength | Scales how dark a fully shadowed surface goes. | Runtime |
ShadowCasterHeight | How far above the cascade the light frustum is pulled back, so tall casters are not clipped. | Runtime |
ShadowDepthBias | Constant depth bias, in depth units. | Initialization |
ShadowSlopeScaledDepthBias | Slope-scaled depth bias. | Initialization |
ShadowCulling | Per-quadrant light-space caster culling. | Runtime |
ShadowAtlasReuse | Skips re-rendering a quadrant whose matrices did not change. | Runtime |
The two depth biases are marked initialization rather than runtime for a reason: D3D12 and
Vulkan read them when the pipeline state is created, so assigning them after startup
changes the property and nothing else. Set them through
RenderQuality.DefaultShadowDepthBias in the application
constructor. Normal-offset bias, by contrast, travels in the uniform buffer and can move
every frame — which is why it is the knob to reach for first.
A slowly moving sun is the classic way to make shadow edges crawl: every frame the cascade matrix changes slightly, every frame the depth texels land somewhere new, and the eye reads the resulting shimmer as noise. Two mechanisms suppress it.
ShadowLightAngleStep (0.25 by default), so
small sun movement produces no matrix change at all.
ShadowTargetStableFrames frames — eight by default.
CascadedShadow exposes the result for inspection:
MatricesStable tells you whether the guarantee currently
holds, EffectiveLightAngleStep the step actually in use, and
Epoch increments whenever the atlas contents become invalid.
The reference application puts all three on screen, because "why is my shadow crawling" is
answered by reading them.
| Member | What it tells you |
|---|---|
MaxCascades / SlotCount | 3 and 4 — three sun cascades plus one spot slot. |
CascadeViewProj, SpotViewProj | The matrices currently baked into the atlas. |
CascadeSplits, ActiveCascadeCount | Where the splits landed this frame. |
SunActive, SpotActive | Whether each kind of shadow is being rendered at all. |
CullingActive | Whether light-space caster culling ran. |
GetAtlasViewport | The quadrant rectangle for a slot, if you are debugging the atlas. |
For indirect light rather than direct, continue to global illumination and ambient occlusion.