Documentation · Concepts
Interior lighting and HDR
A room is where a simple lighting setup shows its seams. The sun comes through the window and the lamp on the desk is on, but only one of them can cast a shadow. A chair's feet float, because nothing darkens the floor right under them. A bright window looks the same white as a white wall. This page covers the four things that fix that. Each one is off until you turn it on, so an existing scene looks exactly as it did.
A second shadowed light
scene.MaxShadowedLights = 2; // 0, 1 (the default) or 2
sun.CastsShadows = true;
sun.ShadowPriority = 10; // higher wins when more than two ask
deskLamp.CastsShadows = true;
deskLamp.ShadowPriority = 5;
Every light with CastsShadows set asks for a shadow map, and the two with the highest
ShadowPriority get one. Equal priorities keep their order in Scene.Lights, so
the choice never flickers as the camera moves. Scene.ShadowCastingLights
tells you which lights were chosen.
Each map costs another pass over whatever casts into it, so it pays to narrow that down. Light.ShadowCasterBounds is a box: only meshes that overlap it cast into that light's map. Give a desk lamp the box of its own room, and make sure the box includes the walls that should stop its light. The test uses each mesh's whole bounding box, so one large mesh that spans several rooms is always included. Split it at the doorways if that matters.
A point light's map is six maps, one for each face of a cube, so a second point light costs six times
as much as a second spot light. Maps are kept until the scene changes. If you move a light yourself, call
scene.Invalidate() afterwards, as with any other change to a light.
Contact occlusion
scene.ContactOcclusion = ContactOcclusionQuality.Low; // Off, Low or High
scene.ContactOcclusionRadius = 0.4f; // world units
scene.ContactOcclusionStrength = 1f; // 0 to 2
Shadows block light from a lamp. They do nothing about the soft darkening where a crate meets the floor or two walls meet in a corner. There, the dimness is ambient light that cannot get in. Contact occlusion finds those places from the depth of what is on screen and darkens them.
Low works at half resolution and is enough for most scenes. High uses
full resolution and twice the samples. Set ContactOcclusionRadius to roughly the size of
the gaps you want filled. A larger radius reaches further and makes wider halos.
Contact occlusion only darkens indirect light: the ambient light and the environment. Direct light from lamps is left to their shadows. Only opaque meshes count as blockers. Transparent and alpha-cutout surfaces, sprites and lines do not, because their rectangles are not solid.
It only knows what is on screen. Something just outside the frame, or hidden behind something else, darkens nothing. As an object leaves the edge of the picture, the darkening under it goes too. This is a normal limit of any screen-space effect, not a bug.
Ambient-occlusion maps changed in this release. An OcclusionTexture now
darkens ambient light as well as the environment, and darkens reflections less, depending on how rough
the surface is and the angle it is seen from. Materials with an occlusion map look different, and
right. Where contact occlusion is on as well, the darker of the two is used, so the two never stack.
Linear HDR
scene.LinearHdr = true;
scene.Exposure = 1f;
scene.WhiteBalance = Vector3.One; // red, green and blue gains
scene.HdrBloomThreshold = 1f; // how bright a pixel must be before it glows
scene.BloomIntensity = 0.15f;
scene.BloomRadius = 6f;
Without HDR, every pixel is squeezed into 0–255 as soon as it is lit. A window ten times brighter than a
white wall stores the same number as the wall, and bloom can only react to how white a pixel looks. With
LinearHdr, the scene keeps real brightness until the very end of the frame. Then it applies
exposure and white balance, and converts the picture for the display once.
Three things change visibly:
- Bloom follows brightness. An emissive strip at intensity 5 glows and a matte white
wall does not.
HdrBloomThresholdis measured in that brightness, and the olderBloomThresholdonly applies when HDR is off. - Transparency blends correctly. Half-transparent red over blue gives the purple you would expect, not a darker one.
- The background and fog respond to exposure, because they are part of the same picture now.
The result is an ordinary display image, so Avalonia composes text and panels over it as usual, and a
capture — CaptureAsync — records the same image; only
CaptureFormat.RgbaF16 asks for the linear frame from before it. Do not tone-map a capture a
second time. HDR here does not mean an HDR monitor signal: the output is always standard range.
HDR needs floating-point render targets. Where a GL context cannot provide them, the view keeps drawing
in the ordinary 8-bit mode, and RenderInfo.ColorPipelineStatus says why. On the CPU renderer,
HDR lights every pixel rather than every vertex, which costs noticeably more.
HDR environments
var sky = EnvironmentImage.FromRadiance(File.ReadAllBytes("studio.hdr"));
scene.Environment = new EnvironmentLight
{
HdrTexture = sky,
Intensity = 0.7f,
ReflectionResolution = 128, // 32 to 512, a power of two
ReflectionSamples = 128 // 32 to 512
};
An ordinary environment image is 8 bits a channel, so the sun in it is only as bright as a cloud.
EnvironmentImage holds real brightness. It reads a Radiance
.hdr file with FromRadiance, or takes floating-point pixels you already have
with FromLinearPixels. Set as HdrTexture, it replaces the older
Texture. A metal surface then reflects the sun as a hot highlight, and a rough one blurs it
properly.
ReflectionResolution sets how sharp mirror-like reflections can be, and
ReflectionSamples sets how clean rough ones are. Both cost preparation time, not frame time:
the image is filtered on the CPU, on a worker thread, the first time the scene needs it — about 90 ms at
the defaults on an Apple M3 Max, and one and a half seconds at 256 with two room probes. The frame does
not wait for it: until the filter lands, the scene draws with the environment it had before, or with the
plain sky and ground colours if it had none, and then switches. To have the first frame lit by the image,
prepare it before showing the scene:
await scene.Environment.PrepareAsync(); // or Prepare(), which waits on the calling thread
Room probes
scene.Environment.Probes.Add(new EnvironmentProbe
{
Name = "cabin",
Image = cabinImage, // the room, seen from inside
Bounds = new BoundingBox(new Vector3(-4, 0, -5), new Vector3(4, 3, 5)),
Position = new Vector3(0, 1.5f, 0), // where that image was taken
BlendDistance = 0.5f,
Priority = 10
});
scene.Invalidate();
One environment is right outdoors and wrong indoors: a polished floor in a cabin should reflect the cabin, not the sky. An EnvironmentProbe gives a box-shaped room its own environment image. Surfaces inside the box reflect that image instead of the global one. Reflections are corrected for the box's shape, so a floor reflects the near wall where the near wall actually is.
- Make the box match the room, and put
Positioninside it, where the image was taken. - Let neighbouring boxes overlap at doorways. Across
BlendDistancethe two rooms fade into each other, and outside every box the global environment takes over. - Two probes are used at a time, chosen by
Priority, not by where the camera is. In a large level, keep the two nearest rooms inProbesand swap them as the player moves. Any extra probes are named inRenderInfo.MaterialDiagnostics.
A probe's image usually comes from outside — baked once, offline. It does not update when something in the room moves, and it is not global illumination.
Capturing a room
var renderer = SceneRenderer.Create(RendererPreference.Gpu); // falls back to software
var image = await cabin.CaptureAsync(renderer, scene, new ProbeCaptureOptions
{
FaceSize = 128,
Exclude = node => node == playerRig, // don't photograph yourself
});
cabin.Replace(image);
await scene.Environment.PrepareAsync();
When there is nothing to bake from, or the room changes shape, EnvironmentProbe.CaptureAsync renders the room itself: six 90°
faces from Position, looking along each axis with the scene's own materials, lights and
exposure, folded into the equirectangular image the filter already consumes. It always renders in
linear radiance — turning Scene.LinearHdr on for this one render if the scene does not
already have it — so a capture is never clipped to display range the way the composited frame is.
Exclude leaves nodes out of the six faces: the probe's own fixture, a player's helmet, a
HUD. IncludeProbes is false by default, so the capture never sees this scene's own room
probes — a probe cannot photograph its own unfiltered self, and two probes captured in either order come
out the same. Ask for true only when a room's reflections should genuinely see a
neighbouring room's probe already in place.
A capture needs a renderer but not a window: SceneRenderer.Create always has somewhere to
run, falling back to the software renderer when no GPU device is available — the same one this page's
cost table below was measured on — so capturing on first show never depends on what the host can make a
GPU context from.
Checking what you got
Not every renderer can do everything on this page. When one cannot, it draws without the feature rather than failing, and RenderInfo says what happened:
ShadowedLights— how many shadow maps were actually drawn. An OpenGL context without spare texture slots stays at one.ContactOcclusionStatus— whether contact occlusion ran, and at what size.ColorPipelineStatus— whether the frame is HDR or 8-bit, and why.EnvironmentStatus— whether the HDR environment and probes are in use, and how much memory they take.MaterialDiagnostics— maps that could not be applied, and probes that were skipped.
To see why a surface looks the way it does, set Scene.DebugView. It shows one ingredient of the picture on its own: base colour, normals, roughness, metallic, the occlusion map, direct light, indirect light, or where the first shadowed light's shadow falls. PbrCalibration builds a reference room with known materials, two shadowed lights, a window and two adjoining spaces, and a fixed camera. Use it to compare settings, or renderers, on the same picture.
What it costs
Measured on an Apple M3 Max with Metal, at 960×540 with four samples. Treat these as proportions, not a promise for your hardware:
| Feature | Added GPU time a frame |
|---|---|
| Second shadowed light (spot) | about 0.02 ms while nothing moves |
| Contact occlusion, Low | about 0.36 ms |
| Contact occlusion, High | about 0.66 ms |
| Two room probes | about 0.15 ms |
Linear HDR added about 0.7 ms on Metal and OpenGL, counting the work of finishing the frame. The
environment's memory is ReflectionResolution × ReflectionResolution / 2 × 72 bytes for each
image: 576 KiB at the default, or 1.7 MiB with two room probes.
A capture's own cost is separate from the atlas it feeds: rendering the six faces, then filtering them into bands, on the software renderer — the floor every host has, GPU or not:
| Face size | Six faces, uncomposited | Filter into bands | Image memory |
|---|---|---|---|
| 64 | about 19 ms | about 75 ms | 384 KiB |
| 128 (default) | about 25 ms | about 73 ms | 1.5 MiB |
| 256 | about 60 ms | about 120 ms | 6 MiB |
Image memory is the equirectangular fold, 4 × FaceSize × 2 × FaceSize × 12 bytes — width by
height by three linear floats — before it is filtered into the smaller atlas above. A GPU backend
renders the six faces far faster than this; the filter, which runs the same way regardless of backend,
is the floor under any capture.
Run it
The calibration room, first with two shadowed lights and contact occlusion, then with HDR and room probes, then showing only the occlusion map:
AVA3D_SCENE="PBR calibration" AVA3D_PBR_SHADOWS=2 AVA3D_PBR_CONTACT=High \
dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE="PBR calibration" AVA3D_PBR_HDR=1 AVA3D_PBR_ENVIRONMENT=1 \
dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE="PBR calibration" AVA3D_PBR_VIEW=AmbientOcclusion \
dotnet run --project samples/Ava3D.Demo.Desktop