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:

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.

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:

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:

FeatureAdded GPU time a frame
Second shadowed light (spot)about 0.02 ms while nothing moves
Contact occlusion, Lowabout 0.36 ms
Contact occlusion, Highabout 0.66 ms
Two room probesabout 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 sizeSix faces, uncompositedFilter into bandsImage memory
64about 19 msabout 75 ms384 KiB
128 (default)about 25 msabout 73 ms1.5 MiB
256about 60 msabout 120 ms6 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