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.