MIT License NuGet

Home / Features / Lighting and shadows

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.

Lights are a list, baked once a frame

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
});
MemberNotes
KindDirectional, Point or Spot.
IsOpenDefault true. Switching a light off keeps it in the list and out of the bake.
Color, IntensityLinear colour and a scalar; intensity is in engine units, not candela.
Position, DirectionDirection defaults to straight down. Position is ignored for directional lights.
RangeFalloff distance for point and spot lights.
InnerConeAngle, OuterConeAngleSpot cone, in radians.
CastShadowsRequests the one punctual shadow slot. Only one light gets it per frame.
PriorityTie-breaker when the list exceeds the GPU budget.

Eight lights, and what happens to the ninth

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.

This will surprise you once

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.

Cascaded shadow maps

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.

KnobMeaningWhen it applies
ShadowsEnabledMaster switch; off skips the pass entirely.Runtime
ShadowAtlasSizeAtlas side length, D32 float. Default 2048.Initialization
ShadowCascadeCountCascade count, clamped to 2–3.Initialization
ShadowDistanceFarthest distance the sun casts, clamped by camera far. Default 40.Runtime
CascadeSplitLambdaUniform-to-logarithmic split blend, 0.6 by default.Runtime
ShadowNormalOffsetNormal-offset bias in shadow texels; 0 disables.Runtime
ShadowSoftnessTexelsRadius of the eight-tap Vogel disk, in texels.Runtime
ShadowContactHardeningNarrows the filter near the contact point. Off by default.Runtime
ShadowStrengthScales how dark a fully shadowed surface goes.Runtime
ShadowCasterHeightHow far above the cascade the light frustum is pulled back, so tall casters are not clipped.Runtime
ShadowDepthBiasConstant depth bias, in depth units.Initialization
ShadowSlopeScaledDepthBiasSlope-scaled depth bias.Initialization
ShadowCullingPer-quadrant light-space caster culling.Runtime
ShadowAtlasReuseSkips 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.

Cascade stability

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.

  • Angular quantisation. The light direction is snapped to a grid controlled by ShadowLightAngleStep (0.25 by default), so small sun movement produces no matrix change at all.
  • Texel snapping. The light-space translation is rounded to whole shadow texels, sized so the cascade matrices stay bitwise identical for 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.

MemberWhat it tells you
MaxCascades / SlotCount3 and 4 — three sun cascades plus one spot slot.
CascadeViewProj, SpotViewProjThe matrices currently baked into the atlas.
CascadeSplits, ActiveCascadeCountWhere the splits landed this frame.
SunActive, SpotActiveWhether each kind of shadow is being rendered at all.
CullingActiveWhether light-space caster culling ran.
GetAtlasViewportThe quadrant rectangle for a slot, if you are debugging the atlas.

Known gaps

  • Partial Punctual shadows. One slot, one light. A scene with two shadow-casting spots gets one shadow.
  • Absent Point-light shadow cubes. The punctual slot is a single projection, so omnidirectional shadow casting is not available.
  • Absent Baked lightmaps. All lighting is dynamic. That is a feature for changing scenes and a cost for static ones.

For indirect light rather than direct, continue to global illumination and ambient occlusion.