Lighting, shadows, and HDR/IBL

This page covers the main directional light, additional spot/point/area lights, shadow budgets, cascaded and soft sun shadows, HDR/image-based lighting, reflection probes, and baked indirect lighting. For the overall render-quality preset that controls shadow defaults, see Render quality, tone mapping, color grading. For weather-driven lighting (sun position over time of day, lightning, cloud shadows) see Weather and atmospherics.

Shadows and lights

rayrai has a fast single-main-light path for robotics workloads and a higher-quality multi-light path for authored visual scenes. The main light is a directional light and is always the cheapest shadow-casting source. Imported glTF/Blender scenes can also use additional directional, point, spot, and area-style lights.

Shadow defaults are tuned so a directional shadow is clearly visible without any per-application setup. Every preset uses a compact directional shadow ortho box (halfSize=12.5, near=0.1, far=55) and a single cascade, which keeps each shadow-map texel small enough that the resulting shadow stays crisp even with bright IBL fill. Balanced and High use brighter ambient/IBL defaults than older releases so smooth metallic surfaces do not read black under sky fill; Ultra keeps more contrast through the lowest direct ambient and environment intensity of the PBR presets and AgX tone mapping (see the preset reference in Render quality, tone mapping, color grading). Raise mainLightAmbient or lower shadowStrength for a flatter indoor look; the defaults are aimed at outdoor daylight.

By default, the main shadow center tracks a point in front of the camera and the shadow box is fixed in size. You can customize both via RayraiWindow:

// shadow center is N meters ahead of the camera (default: 10.0)
viewer.setShadowCenterOffset(12.0f);
// shadow box (default: halfSize=12.5, near=0.1, far=55.0)
viewer.setShadowOrtho(20.0f, 0.1f, 80.0f);

setRenderQualitySettings re-applies shadowCenterOffset, shadowOrthoHalfSize, shadowNear, and shadowFar from the settings, so set those fields instead when you also change quality settings later. If you need a fully custom shadow view/projection, use the lower-level raisin::Light API directly.

Additional lights are controlled explicitly and capped so the fast path stays fast. Rayrai currently supports up to RayraiWindow::kMaxAdditionalLights (16) additional lights and up to RayraiWindow::kMaxAdditionalShadowLights (8) additional shadow maps. RenderQualitySettings::shadowedLightBudget counts the shadowed lights including the main light, so additional lights get shadow maps only when it is greater than 1 (it is 1 by default and in Fast/Balanced, 2 in High/Ultra). Directional, spot, and area-style lights use 2D shadow maps. Point lights use cubemap shadow maps. Shadow framebuffer setup validates the current OpenGL context and recreates stale framebuffer/texture names when a viewer context is rebuilt; this matters for TCP-viewer lifetime, offscreen tests, and applications that create/destroy render contexts. For imported scenes, RenderQualitySettings::autoSelectImportedShadowLight can promote the strongest imported light to the main shadow caster, while the remaining shadow budget is assigned to additional lights.

raisin::RayraiWindow::AdditionalLight fill;
fill.type = raisin::LightType::DIRECTIONAL;
fill.direction = glm::normalize(glm::vec3(0.4f, -0.2f, -0.8f));
fill.diffuse = glm::vec3(0.10f, 0.12f, 0.16f);
viewer.addAdditionalLight(fill);

raisin::RayraiWindow::AdditionalLight spot;
spot.type = raisin::LightType::SPOT;
spot.position = glm::vec3(1.8f, -1.6f, 2.6f);
spot.direction = glm::normalize(glm::vec3(-1.4f, 1.0f, -1.8f));
spot.diffuse = glm::vec3(0.18f, 0.42f, 1.0f);
spot.spotInnerCos = std::cos(glm::radians(14.0f));
spot.spotOuterCos = std::cos(glm::radians(28.0f));
// Optional spotlight projector cookie, color temperature, and distance fade.
spot.projectorMap = projectorTextureId;
spot.projectorStrength = 1.0f;  // 0..1; the default 0 disables the cookie
spot.temperatureEnabled = true;
spot.temperatureKelvin = 4200.0f;
spot.distanceFadeEnabled = true;
spot.distanceFadeBegin = 18.0f;
spot.distanceFadeLength = 6.0f;
spot.castsShadows = true;
viewer.addAdditionalLight(spot);

raisin::RayraiWindow::AdditionalLight area;
area.type = raisin::LightType::AREA;
area.position = glm::vec3(0.0f, 1.8f, 2.1f);
area.diffuse = glm::vec3(0.55f, 0.65f, 0.42f);
area.radius = 1.4f;
area.areaSize = glm::vec2(1.8f, 0.9f);
viewer.addAdditionalLight(area);

viewer.clearAdditionalLights();
Warm spot from the left, cool point fill from the right, soft area light from above

Each AdditionalLight supports the basic attenuation/spot/area parameters above plus optional projector cookie texture, color-temperature override (Kelvin), distance fade for both lighting and shadow casting, and a per-light shadow toggle. The cookie needs projectorStrength > 0 and is ignored for point lights. castsShadows defaults to true, so every additional light, including imported ones, competes for the additional shadow budget. PBR meshes drawn by the compact PBR program receive neither additional-light shadows nor cookies (see the GPU capability tiers in PBR materials). updateAdditionalLight(index, light) replaces one light in place. Imported scenes can be brought in with importSceneLights(sceneFile, intensityScale), and promoteDominantAdditionalLightToMainShadowCaster() reassigns the strongest imported light to the main shadow path so the remaining shadow budget covers the rest.

The main raisin::Light returned by RayraiWindow::getLight() exposes ergonomic helpers for spotlight angles and point-light range so callers do not have to set raw cosine/attenuation fields by hand:

auto& main = viewer.getLight();
main.setSpotAngles(/*innerDeg=*/14.0f, /*outerDeg=*/28.0f);
main.setRange(/*rangeMeters=*/12.0f);  // picks constant/linear/quadratic so
                                       // intensity ~1% at 12m, and sets
                                       // radius=12 as a culling hint.

Numeric units used throughout raisin::Light: positions and radius / distanceFade* in metres; temperatureKelvin in Kelvin; setSpotAngles takes degrees (and stores the cosines internally so the raw spotInnerCos / spotOuterCos fields are still meaningful). The AdditionalLight plain-data struct uses the same field conventions, so std::cos(glm::radians(deg)) is still the manual recipe for those.

setRenderQualitySettings and setRenderQualityPreset rebuild the main light from the mainLight* and shadow* fields (it becomes directional again) and remove every additional light. Applying weather (see Weather and atmospherics), an environment sidecar, or a reflection-probe sidecar goes through the same call. Apply quality settings first and add or import lights afterwards; setAdditionalLightContributionThreshold and setDepthPrepassesEnabled change their settings without touching the lights.

With RenderQualitySettings::addViewerFillLights (on by default) every setRenderQualitySettings call also re-adds two viewer lights: a directional fill at 16% of mainLightDiffuse and a warm point rim light at (-2, -3, 3). They take two additional-light slots and cast shadows like any other additional light, so turn the flag off for authored lighting.

Shadow update cost is configurable. Dynamic scenes can update shadows every frame; static visual scenes can bake shadow maps at startup or refresh them only when light/object placement changes.

auto quality = raisin::RayraiWindow::defaultRenderQualitySettings(
  raisin::RayraiWindow::RenderQualityPreset::Ultra);
quality.updateShadowsEveryFrame = false;       // startup/on-demand shadow bake
quality.maxAdditionalLightsPerFrame = 12;      // light evaluation budget
quality.shadowedLightBudget = 5;               // main light + 4 additional maps
quality.maxPointShadowLights = 2;              // cubemap shadow budget
quality.additionalShadowResolutionScale = 0.5f;
quality.pointShadowResolutionScale = 0.5f;
viewer.setRenderQualitySettings(quality);

Use lower budgets for interactive editing or RL throughput. Use higher budgets for offline screenshots, inspection, or demos where visual fidelity is more important than frame time.

Instanced foliage and grass patches cast shadows by default, like other instanced visuals. Call InstancedVisuals::setCastsShadows(false) to opt a batch out (the choice survives later wind or grass-patch configuration), and use setShadowFoliageLodPolicy to bound the shadow-pass cost of dense vegetation.

Cascaded and soft directional shadows

Presets render the main directional light into one shadow map. Large outdoor scenes can split it into up to four cascades, and the sun can use a blocker-aware penumbra instead of the fixed PCF kernel. Both are off by default.

auto quality = viewer.getRenderQualitySettings();
quality.directionalShadowCascadeCount = 3;           // 1..4; 1 = single map
quality.directionalShadowCascadeMaxDistance = 80.0f; // metres; 0 = shadowFar
quality.directionalShadowSoftnessDegrees = 0.53f;    // sun diameter; 0 = PCF
quality.directionalShadowSoftnessMaxRadius = 8.0f;   // cap in shadow-map texels
viewer.setRenderQualitySettings(quality);

directionalShadowCascadeLambda blends uniform and logarithmic split distances; directionalShadowCascadeSplitOverrides sets explicit split distances (ignored unless they increase and stay below the far plane). Each cascade is padded for the filter footprint and snapped to a world-anchored texel grid, so it stays stable while the camera moves. Cascades also work on macOS and other GPUs limited to 16 fragment texture units.

Soft shadows search the shadow map for blockers and widen the filter with the blocker-to-receiver distance, a bounded approximation of percentage-closer soft shadows. They apply only to the main directional light, replace shadowContactHardening for it, never filter narrower than shadowPcfRadius, and are clamped to 0-4 degrees and 1-16 texels. The physical sun is about 0.53 degrees across; larger values give wider penumbrae. The extra shadow-map lookups cost GPU time. Point, spot, and area shadows keep PCF.

Additional-light range cutoff

Point, spot, and area lights normally shade every object in view. An opt-in cutoff derives a finite range for each of them from its intensity and attenuation and fades it out smoothly, so objects outside the range skip that light:

viewer.setAdditionalLightContributionThreshold(1.0e-4f);  // keeps authored lights
viewer.setAdditionalLightContributionThreshold(0.0f);     // default: no cutoff

The same value is RenderQualitySettings::additionalLightContributionThreshold (0 in every preset). The value is a total scene-linear incident-radiance budget shared by the uploaded lights, not a bound on final pixel error. Directional lights, lights without distance falloff, lights with an ambient term, and lights with a projector cookie are never cut off. rayrai/LightInfluence.hpp provides additionalLightInfluenceRange() (pass one light’s share of the budget) to inspect the range a light gets. The cutoff saves shading when lights are far apart; when their ranges overlap it only adds CPU work.

HDR, image-based lighting, and reflections

rayrai supports HDR equirectangular environments for real-time PBR preview and inspection. The HDR path is not a ray tracer; it precomputes cubemap data for environment background, diffuse irradiance, specular prefiltering, and a split-sum BRDF lookup table, then samples those textures in the PBR shader.

The simplest setup is the PbrEnvironment bundle, which packs the four GL handles (radiance cubemap, diffuse irradiance, prefiltered specular, split-sum BRDF LUT) plus the mip count and an overall strength scalar into one struct:

auto env = raisin::PbrEnvironment::loadFromHdrFile("/path/to/environment.hdr");
if (env.isComplete()) {
  visual->setPbrEnvironment(env);
}

PbrEnvironment::LoadOptions exposes the per-cubemap resolution, sample count, BRDF LUT size, and a default strength when the defaults are not appropriate. Optional parallax-corrected box projection is set via boxProjection / probePosition / boxMin / boxMax on the bundle.

The lower-level static creators are still available when callers want to manage GL handles individually:

const char* hdr = "/path/to/environment.hdr";
unsigned int env = raisin::RayraiWindow::loadHdrEquirectangularCubemap(hdr, 128, true);
unsigned int irradiance = raisin::RayraiWindow::createHdrIrradianceCubemap(hdr, 32, 64);
unsigned int prefiltered =
  raisin::RayraiWindow::createHdrPrefilteredEnvironmentCubemap(hdr, 128, 5, 64);
unsigned int brdf = raisin::RayraiWindow::createSplitSumBrdfLut(128, 128);

visual->setPbrEnvironment(env, irradiance, prefiltered, brdf, 1.0f);

Use HDR environments with visible features when inspecting reflective materials. A featureless sky or uniform studio HDR can make it hard to tell whether reflections are working. rayrai_pbr_material_grid, rayrai_pbr_texture_maps, and rayrai_visual_asset_support use HDR/image-based lighting so metallic and glossy surfaces show visible reflections while non-metallic assets remain mostly diffuse.

Materials without an environment cubemap use a procedural daylight fallback: a neutral sky term tinted by RenderQualitySettings::pbrEnvironmentLightingTint (default white) over a ground term tinted by pbrEnvironmentGroundTint, scaled by pbrEnvironmentIntensity. It does not follow the visible sky or fog colours; pbrEnvironmentSkyTint, pbrEnvironmentHorizonTint, and pbrEnvironmentHorizonStrength only tint the procedural background. HDR environments keep their captured colour. Instanced visuals with PBR materials use a hemispheric version of the same fallback and never sample environment cubemaps; Material::iblStrength and environmentMapStrength scale it. RayraiWindow::setEnvironmentBackground only draws the background; assign environment maps to visuals with setPbrEnvironment.

For scene-wide reflections, rayrai also has static reflection probe capture, local probe selection, reflection-probe sidecars, and planar ground reflection support. Planar reflections clip the reflected view at the ground plane, so geometry below the floor does not show up in them. These are real-time approximation tools: they improve visual fidelity without enabling path tracing or other slow offline rendering mechanisms. Choose lower environment resolution, fewer prefilter samples, and fewer reflection updates for fast interactive runs; increase those values for screenshots or inspection.

auto capture = raisin::ReflectionProbeCaptureSettings{};
capture.resolution = 128;
auto filter = viewer.reflectionProbeFilterSettingsForCurrentQuality();
auto probe = viewer.captureFilteredReflectionProbe(
  {0.0f, 0.0f, 1.5f}, 6.0f, 1.0f, capture, filter);
viewer.addReflectionProbe(probe);
viewer.applyNearestReflectionProbe(*visual, visualPosition);

Use captureReflectionProbeCubemapCached/captureFilteredReflectionProbeCached when the same probe position is recomputed across frames (for example, while authoring or scrubbing weather). The cache is content-addressed by capture/filter settings; call clearReflectionProbeCache() to drop it. selectReflectionProbeBlend(position) returns the weighted blend that applyNearestReflectionProbe uses internally, which is useful for debug overlays.

Authored scene sidecars can ship next to imported assets and describe reflection probes, environment/background settings, and weather. Loading and applying them is symmetric:

// Reflection probe sidecar: probes plus selection/filter quality settings.
std::string probePath;
auto probes = raisin::RayraiWindow::loadReflectionProbeSidecar(
    sceneFile, /*settingsOut=*/nullptr, &probePath);
viewer.applyReflectionProbeSidecarQualitySettings(sceneFile);

// Environment sidecar: HDR, intensity, rotation, sky tint.
std::string envPath;
auto env = raisin::RayraiWindow::loadEnvironmentSidecar(sceneFile, &envPath);
viewer.applyEnvironmentSidecarSettings(sceneFile);

// Weather sidecar: preset/settings plus local fog volumes.
std::string wxPath;
auto wx = raisin::RayraiWindow::loadWeatherSidecar(sceneFile, &wxPath);
viewer.applyWeatherSidecarSettings(sceneFile);

// Authoring helper: serialize a weather setup next to the scene file.
raisin::RayraiWindow::writeWeatherSidecar(
    "/path/to/scene.weather.json", weatherSettings, localFogVolumes);

The apply* sidecar calls go through setRenderQualitySettings (a weather sidecar that enables weather through setWeatherSettings), so they remove additional lights; apply sidecars before adding or importing lights. suggestReflectionProbePlacementFromSceneBounds() is a non-mutating helper that proposes probe positions from current scene AABBs; use it as a starting point when authoring a sidecar by hand.

Reflections, decals, irradiance volumes, and lightmaps

Beyond probes and IBL, rayrai supports projected decals, irradiance volumes, and authored lightmaps as cheap indirect-light alternatives:

  • Projected decals (addProjectedDecal) — project a textured box onto any surface inside it; supports albedo / emission / normal / ORM slots, per-decal UV scaling, and distance fade.

  • Irradiance volumes (addIrradianceVolume) — blanket a region with a constant indirect colour for authored interiors that need indirect light without a full GI bake.

  • Lightmaps — populate Material::lightmapMap from an external bake tool to drive 92_lightmap_gi-style authored interiors.

Irradiance volumes and lightmaps are evaluated only by the full PBR program; PBR meshes drawn by the compact program get neither. rayrai picks the program from the GPU’s fragment texture units; see the GPU capability tiers in PBR materials. Projected decals are a post-process pass and are not drawn on macOS or other GPUs limited to 16 fragment texture units; see Post-process effects.

// Projected sign / poster decal.
raisin::ProjectedDecal sign;
sign.center = glm::vec3(2.0f, 0.0f, 1.6f);
sign.halfExtents = glm::vec3(0.6f, 0.05f, 0.3f);
sign.color = glm::vec4(1.0f);
sign.albedoMap = posterTextureId;          // sRGB poster art
sign.emissionMap = emissiveTextureId;      // optional glow
sign.emissionEnergy = 1.5f;
sign.albedoMix = 1.0f;
sign.distanceFadeEnabled = true;
sign.distanceFadeBegin = 12.0f;
sign.distanceFadeLength = 4.0f;
viewer.addProjectedDecal(sign);

// Interior indirect light volume covering a room.
raisin::IrradianceVolume room;
room.center = glm::vec3(0.0f, 0.0f, 1.5f);
room.halfExtents = glm::vec3(4.0f, 4.0f, 1.7f);
room.color = glm::vec3(0.32f, 0.30f, 0.28f);
room.strength = 0.85f;
room.edgeFade = 0.22f;
viewer.addIrradianceVolume(room);

// Lightmap-driven authored interior.
auto floor = raisin::Material::pbr("floor", glm::vec4(1.0f),
                                   /*metallic=*/0.0f, /*roughness=*/0.55f);
floor.albedoMap = floorAlbedoMapId;
floor.lightmapMap = floorLightmapBakeId;   // second UV channel
floorVisual->setMaterialOverride(floor);

Reflection probe

Projected decals

Room with reflection probe applied to glossy surfaces Decals projected onto multiple surfaces

Irradiance volumes

Lightmap GI

Authored irradiance volumes filling indirect light Lightmap-driven GI on authored scenes

Sky visibility and baked diffuse light

Two optional world-space grids add spatial variation to indirect light, for example under a forest canopy. Both hold static data that the application bakes offline; rayrai only samples them. Upload them on the rendering thread with the renderer’s GL context current. The data is copied.

// Scalar sky visibility: 1 = open sky, 0 = fully occluded.
raisin::SkyVisibilityGrid sky;
sky.dimensions = {41, 51, 21};
sky.minimum = {-40.0f, -50.0f, -4.0f};  // centres of the outermost samples
sky.maximum = {40.0f, 50.0f, 36.0f};
sky.values = visibility;                 // x fastest, then y, then z
viewer.setSkyVisibilityGrid(sky, /*strength=*/0.65f, /*edgeFadeMeters=*/2.0f);

// Diffuse irradiance as first-order spherical harmonics per probe.
raisin::BakedIrradianceGrid bounce;
bounce.dimensions = {17, 21, 9};
bounce.minimum = {-40.0f, -50.0f, -4.0f};
bounce.maximum = {40.0f, 50.0f, 28.0f};
bounce.probes = probes;                  // one IrradianceProbe per sample
viewer.setBakedIrradianceGrid(bounce, /*strength=*/1.0f);

viewer.clearSkyVisibilityGrid();
viewer.clearBakedIrradianceGrid();

The sky-visibility grid attenuates only environment lighting (the environment map or the procedural fallback, diffuse and specular) on PBR and instanced PBR materials. Direct light, the mainLightAmbient floor, lightmaps, irradiance volumes, and the baked grid keep their own visibility. It is sampled per vertex, so coarse meshes interpolate it across each triangle. Its strength range is 0-1.

The baked grid adds light reflected from surrounding surfaces on top of the environment term. Each IrradianceProbe stores, per RGB channel, the constant and x/y/z coefficients of cosine-convolved irradiance divided by pi, in linear scene units before exposure; raisin::accumulateIrradiance and raisin::deringIrradiance in rayrai/BakedIrradiance.hpp help build them. It respects material AO and metallic, scales with Material::iblStrength, and lights the back of thin foliage through foliageTransmissionColor and foliageTransmissionStrength. Its strength range is 0-4.

Outside its bounds each grid has no effect; edgeFadeMeters fades it in at the boundary. Each axis needs 2-256 samples and a grid holds at most 2,097,152 samples. Unlike the resource-creation calls, the setters throw std::invalid_argument for invalid data and std::runtime_error when no GL context is current. Moving geometry or changing the lighting requires a new bake.