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.
The three kinds
Section titled “The three kinds”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.
Exposure
Section titled “Exposure”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.
Point and spot detail
Section titled “Point and spot detail”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.
Image-based lighting
Section titled “Image-based lighting”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:
tool/bake_environment.sh env.hdr outThat writes out/env_ibl.ktx and out/env_skybox.ktx.
Straight from the picture
Section titled “Straight from the picture”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
Section titled “Shadows”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, ), ),);Authoring in artist units
Section titled “Authoring in artist units”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.
