b3d-ambient

The stuff in the air, or in the water. Bubbles and motes when you're under; rain, snow, dust or drifting seeds when you're not.

One component, several presets, and one trick that makes all of them work:

The emitter box follows the camera. The particles do not.

Particles are spawned in a box around your head and then live in world space — so rain falls past you, motes drift by you, bubbles rise away from you. Emit them in your local frame instead and they travel with you like dandruff on the lens, which is the single most common way ambient particles are got wrong.

The box has a hole in the middle, and it matters: a box centred on the camera will happily give birth to a particle on your face, and a few-centimetre sprite half a metre from the lens is a big soft blob covering a chunk of the screen. That's not a mote, that's a smudge. Nothing spawns inside the preset's near radius (drifting in close later is fine — it's being born there that reads as dirt on the lens). Particles also fade in, not just out: born at full alpha they blink into existence, which reads as sensor noise rather than as dust.

Because the box follows you, an endless snowstorm costs a fixed number of particles no matter how big the world is. Nothing grows, nothing allocates.

It switches off rather than thinning out

An ambient effect is garnish, and garnish plays by one rule:

An effect that can't be itself switches OFF. It does not thin out.

Forty raindrops is not light rain — it's a rendering bug wearing rain's clothes. So you don't set a count; you ask. Each effect asks for the capacity its look needs and declares a minCount below which it would be a lie, and the scene divides one shared pool (ambient-budget, sized from the measured device tier) between everyone who wants some. Effects thin together while they can all stay honest; the moment someone would drop under its floor, that one is switched off (lowest priority first) and its budget goes to the survivors. Better honest rain and no motes than two half-truths.

The pool is shared because ambient effects compete — rain, dust and motes can each be individually affordable and still cook the frame together. And if the frame stays over budget anyway, the scene shrinks the pool and effects drop out on their own. That ratchet is one-way: ambient that pops back in the moment the frame recovers, then out again at the next tree, is its own broken promise.

It arrives with the water, not on top of it

where: 'underwater' doesn't switch on at the surface — its emission ramps with depth using the same band() the fog uses (see atmosphere). Submerge and the bubbles arrive as the water does. Popping a cloud of bubbles into existence at a plane is the particle version of the fog "thunk", and we already fixed that once.

Demo — dive under

Fly down into the sea (W/S pitch, R/Q throttle). The fog closes in, the light dims, and the water fills with rising bubbles and drifting motes — and it all fades back out as you break the surface.

import { b3d, b3dAircraft, b3dAmbient, b3dWater, b3dFog, b3dLibrary, b3dLight, b3dSkybox, b3dGround, gameController, inputFocus } from 'tosijs-3d'
import { demoSun } from 'demo-utils'

// A submersible scout. The `groundY: -40` is what makes this demo WORK: an aircraft's floor
// defaults to 0 — which here is EXACTLY the water surface — so by default you hit the waterline
// and stop, and the underwater life is unreachable. Drop the floor to the real seabed and you can
// DIVE. Start low and hover so the surface is right there: left trigger down to submerge.
const scout = b3dAircraft({
  library: 'vehicles', meshName: 'scout',
  player: true, y: 9, groundY: -40, vtolSpeed: 6, maxSpeed: 30,
})

const scene = b3d(
  { gamepad: true },
  b3dLight({ y: 1, intensity: 0.6 }),
  demoSun(),
  b3dSkybox({ timeOfDay: 11 }),
  b3dFog({ start: 200, end: 1200, color: '#bfd9f2' }),
  b3dGround({ meshName: 'ground_nocast', width: 2000, height: 2000, color: '#4a5f3e', y: -40 }),
  b3dWater({ y: 0, width: 2000, height: 2000 }),
  b3dLibrary({ url: '/test-2.glb', type: 'vehicles' }),

  // Above the surface: dust motes in the air (visible right away). Below it: bubbles rising +
  // plankton drifting. Both RAMP with depth (the same `band` the fog uses), so diving through the
  // surface is a smooth handoff — the air empties, the sea fills with life. No `count` — it adapts.
  b3dAmbient({ preset: 'motes', where: 'above', radius: 12 }),
  b3dAmbient({ preset: 'bubbles', where: 'underwater', radius: 12 }),
  b3dAmbient({ preset: 'motes', where: 'underwater', radius: 12, color: '#2c4a58' }),

  inputFocus(gameController(), scout),
)
preview.append(scene)
tosi-b3d { width: 100%; height: 100%; }

Presets

preset what it is
motes Dust or plankton hanging in the light — near-weightless, slow drift. The cheapest way to make air (or water) feel like a substance rather than a vacuum
bubbles Rise, wobble, and speed up as they go. Underwater only, really
rain Fast, stretched, near-vertical. Add wind and it slants
snow Slow, wide, wandering
dust Blown horizontally — the wind made visible
leaves Two-sided quads that tumble and blow — real oriented geometry, not a billboard, so they flip edge-on and show their back. windX/windZ stream them downwind

Attributes

Attribute Default Description
preset 'motes' motes / bubbles / rain / snow / dust / leaves
where 'always' always / underwater / above — emission ramps with depth, it doesn't switch
count auto Capacity to ASK for (auto = what the preset's look needs). You may not get it — the scene divides a shared pool
minCount auto Below this the effect is a lie, so it switches off instead. auto = the preset's floor (rain needs density; a few motes still read fine as motes)
minTier 'low' Never run below this device tier, at any budget
priority 0 Higher survives longer when the pool is squeezed. Shed lowest-first
radius 18 Size of the box around the camera that particles spawn in
rate 0 Particles/sec (0 = derive from the preset)
color '' Override the preset's colour
size 0 Scale the preset's sprite size
windX 0 World wind (rain slants, dust blows)
windZ 0
disabled false Stop emitting