Skip to content

Lighting a scene

Orblit lights are stated in real photometric units, and the camera has a real exposure. That’s more to learn than an arbitrary 0–1 brightness, and it pays for itself the first time a value transfers in from somewhere else.

return OrblitLight(
key: 1,
kind: OrblitLightKind.directional,
direction: Vector3(-0.4, -1, -0.6)..normalize(),
intensity: 100000, // lux
);
Kind Unit A realistic value
directional lux 100,000 for full daylight, 400 for heavy overcast
point lumens 1,600 for a bright domestic bulb, 450 for a dim one
spot lumens As above, concentrated into a cone

Filament honours one directional light per scene. A second one gets reported back through onSceneNotes rather than silently dropped, so a scene that has quietly lost its second sun will say so.

The camera is a camera:

return OrblitCamera(
position: Vector3(4, 3, 6),
target: Vector3.zero(),
aperture: 16, // f/16
shutterSpeed: 1 / 125, // seconds
sensitivity: 100, // ISO
);

Those defaults are the “sunny 16” rule, and they’re correct for a scene lit at 100,000 lux. Between them they decide how bright the image is, exactly as they would on a real camera.

The practical upshot: a black scene has two ends you can fix it from. Either the lights are too dim, or the exposure is set for daylight and you’re lighting a room. An interior at 300 lux wants ISO 800 and f/2.8, not a sun turned down to 300.

return OrblitLight(
key: 2,
kind: OrblitLightKind.spot,
position: Vector3(0, 4, 0),
direction: Vector3(0, -1, 0),
intensity: 1600,
falloffRadius: 10, // metres, beyond which it contributes nothing
innerConeAngle: 0.5, // radians, full brightness inside this
outerConeAngle: 0.6, // radians, nothing outside it. The gap is the
// soft edge between the two.
);

falloffRadius is a culling distance as much as a physical one. It’s what lets the renderer decide a light can’t affect an object without shading it, so setting it far larger than the light actually reaches costs you performance and buys you no picture.

sourceRadius controls how soft the shadow is. A physically larger source casts a softer shadow, which is why a strip light’s shadows are soft and a bare bulb’s are hard.

A single sun on a black background looks like a single sun on a black background. What makes a render look photographed is all the light coming from everywhere else: the sky, the ground, the walls.

return OrblitScene(
camera: OrblitCamera(position: Vector3(4, 3, 6), target: Vector3.zero()),
objects: const [],
environment: const OrblitEnvironment(
radiance: '/path/to/env_ibl.ktx',
skybox: '/path/to/env_skybox.ktx',
intensity: 30000, // lux
rotation: 0.5, // radians, to turn the environment
),
);

Both files are baked from an equirectangular .hdr or .exr by Filament’s cmgen. The engine’s tool/bake_environment.sh runs it at the sizes the renderer itself would choose:

Terminal window
tool/bake_environment.sh env.hdr out

That writes out/env_ibl.ktx and out/env_skybox.ktx.

If you’d rather not bake, name the picture and the renderer filters it while the scene runs:

return OrblitScene(
camera: OrblitCamera(position: Vector3(4, 3, 6), target: Vector3.zero()),
objects: const [],
environment: const OrblitEnvironment.fromImage(
'/path/to/env.hdr', // or .exr, or a name given to OrblitResources
intensity: 30000,
),
);

That costs something once per picture. On an M4 Pro with a 2K picture, the decode takes about 70 ms for an .hdr and 150 ms for a ZIP-compressed .exr, off the drawing thread, then there’s one frame of a few milliseconds to upload it and one frame of 20 to 55 ms to filter it. In a browser the decode runs on a Web Worker. Naming the same picture again filters nothing. Compared with a cmgen bake of the same picture, the frame is a third of a level out on average.

Until it’s ready, the scene is lit by its flat ambient, and anything wrong with the picture (missing, damaged, too big for the device, not twice as wide as it is tall) turns up in the scene notes under environment and skybox. A device that can’t filter on the GPU filters a smaller one on the CPU.

So which? Bake what ships: it’s fixed before anyone runs it, and a launch reads two small files and filters nothing. Name the picture when you can’t know it in advance, such as an HDR a player picked, or while you’re still trying environments on.

Shadows are per-light, and off by default on populations, because a hundred thousand shadow casters is rarely what anyone had in mind.

OrblitLight(..., castShadows: true)
OrblitObject(..., castShadows: true, receiveShadows: true)

Quality is set on the scene’s pipeline rather than per light. kind chooses the filter, from sharp, soft, area or variance, and cascades splits the directional light’s shadow map by distance, so more of it gets spent on what’s near the camera:

return OrblitScene(
camera: OrblitCamera(position: Vector3(4, 3, 6), target: Vector3.zero()),
objects: const [],
pipeline: OrblitPipeline(
shadows: OrblitShadows(
kind: OrblitShadowKind.soft,
cascades: 4, // more of the shadow map spent near the camera
mapSize: 2048,
),
),
);

orblit_light exists for the other direction. An artist states a fitting in watts, metres and degrees, and it converts that to the lumens, lux and radians the renderer takes.

Use it whenever a light is authored rather than computed. Doing the conversion separately in the editor and in the runtime is how the two end up disagreeing, which is why both of them call this package instead of each doing the sum.