Ambient occlusion (SSAO)
Corners get darker. Where a wall meets the floor, where crates are stacked,
under a ledge: real light does not reach in there, and a renderer without
ambient occlusion lights those creases exactly like the open ground beside
them. That is most of why a scene of plain boxes looks pasted together. One
attribute on <tosi-b3d> fixes it: ssao="auto".
Demo
Open the ⚙ panel and switch ssao between off and on. Watch the foot of
the walls, the gaps between the crates and the underside of the lintel.
always keeps it on inside a headset, which is how you check what it costs
there.
import { b3d, b3dSun, b3dSkybox, b3dLight, b3dBox, b3dSphere, b3dGround, select3d, slider3d } from 'tosijs-3d'
import { tosi } from 'tosijs'
const { ssaoDemo } = tosi({ ssaoDemo: { mode: 'on', strength: 1, radius: 2 } })
const stone = '#b9b2a4'
const crate = '#a0764a'
preview.append(
b3d(
{
ssao: ssaoDemo.mode,
ssaoStrength: ssaoDemo.strength,
ssaoRadius: ssaoDemo.radius,
scenePanel: () => [
select3d({ label: 'ssao', value: ssaoDemo.mode, options: ['off', 'auto', 'on', 'always'] }),
slider3d({ label: 'strength', value: ssaoDemo.strength, min: 0, max: 3, step: 0.05 }),
slider3d({ label: 'radius (m)', value: ssaoDemo.radius, min: 0.25, max: 6, step: 0.05 }),
],
sceneCreated(el, BABYLON) {
const cam = el.scene.activeCamera
// Target first: an orbit camera re-derives its position from the target.
if (cam && cam.setTarget) cam.setTarget(new BABYLON.Vector3(-0.5, 1, 0.5))
if (cam && cam.setPosition) cam.setPosition(new BABYLON.Vector3(6, 5, -10))
},
},
b3dLight({ intensity: 0.9 }),
b3dSun({ shadowTextureSize: 2048 }),
b3dSkybox({ timeOfDay: 15 }),
b3dGround({ size: 40, color: '#8f9a7a' }),
// A corner of two walls, a doorway with a lintel, steps, stacked crates.
b3dBox({ meshName: 'wall-a', width: 8, height: 3, depth: 0.5, x: 0, y: 1.5, z: 3, color: stone }),
b3dBox({ meshName: 'wall-b', width: 0.5, height: 3, depth: 6, x: -4.25, y: 1.5, z: 0.25, color: stone }),
b3dBox({ meshName: 'post-l', width: 0.5, height: 2.4, depth: 0.5, x: 2, y: 1.2, z: -1, color: stone }),
b3dBox({ meshName: 'post-r', width: 0.5, height: 2.4, depth: 0.5, x: 4, y: 1.2, z: -1, color: stone }),
b3dBox({ meshName: 'lintel', width: 3, height: 0.5, depth: 0.7, x: 3, y: 2.65, z: -1, color: stone }),
b3dBox({ meshName: 'step-1', width: 3, height: 0.25, depth: 1.5, x: -1.5, y: 0.125, z: 1.5, color: stone }),
b3dBox({ meshName: 'step-2', width: 3, height: 0.5, depth: 1, x: -1.5, y: 0.25, z: 2.1, color: stone }),
b3dBox({ meshName: 'crate-1', size: 1, x: -3.2, y: 0.5, z: -0.5, color: crate }),
b3dBox({ meshName: 'crate-2', size: 1, x: -2.1, y: 0.5, z: -0.3, ry: 12, color: crate }),
b3dBox({ meshName: 'crate-3', size: 1, x: -2.7, y: 1.5, z: -0.4, ry: -8, color: crate }),
b3dSphere({ meshName: 'ball', diameter: 1.2, x: 1, y: 0.6, z: 1.6, color: '#c8553d' })
)
)
What it costs, and why it is off by default
SSAO is not free and it is not one draw. Babylon's SSAO2RenderingPipeline
draws the opaque scene a SECOND time into a depth-and-normals buffer, then for
every pixel samples that buffer several times and blurs the result. So the
cost is one extra pass over your geometry plus a per-pixel loop.
ssao |
flat | in a headset |
|---|---|---|
off (default) |
off | off |
auto |
on if the device tier affords it (top tier only) | off |
on |
on | off |
always |
on | on |
A headset pays for it twice (two eyes) at a high per-eye resolution, so auto
and on both stay off in XR. always is there for hardware that can pay: a
PC-driven headset, or a Vision Pro. A standalone Quest-class device cannot.
Sample count and resolution come from the device budget
(PerfBudgets.ssaoSamples, ssaoRatio), so you tune the LOOK and the tier
tunes the COST.
Attributes (on <tosi-b3d>)
| Attribute | Default | Description |
|---|---|---|
ssao |
'off' |
off, auto, on or always: see the table above. LIVE |
ssaoStrength |
1 |
How dark a fully occluded crease gets. 0 is none. LIVE |
ssaoRadius |
2 |
How far, in metres, a surface looks for something occluding it. Small radii darken only tight creases; large ones shade whole alcoves. LIVE |
What is and is not occluded
Transparent meshes are left out of the depth-and-normals pass, so glass, water seen through its alpha, particles and the cloud deck neither receive nor cast occlusion. A mesh whose vertex shader moves its vertices (the vertex-animated crowd) is occluded as if standing in its rest pose.