b3d-lamp
Lights you can see. b3dPointLight, b3dSpotLight and b3dAreaLight are
placed lights that come with their own fixture geometry, cast shadows where
Babylon supports it, take a gel where Babylon supports it, and can be
animated over time by the curves in light-modulation.
A bare PointLight is invisible — you get illumination with nothing emitting it,
so every scene grows a hand-rolled "glowing sphere next to the light" that then
has to be kept in sync with it. These carry the emitter with the light, and let
you turn it off or replace it when the scene has a real fixture to put there.
Demo
A dark room with a matte, patterned floor — the two things a lighting demo needs and the first version of this had neither. A glossy untextured plane reflects the lamp back at you and shows nothing about falloff; a lit scene with no shadow receivers lets every light shine straight through the objects.
Three zones so the lamps do not wash each other out: a warm lamp with hard shadows, a fluorescent that strikes and dies, and a gelled spot throwing a window pattern across a clear patch of floor. Switch them off to watch each one's decay.
import {
b3d, b3dLight, b3dPointLight, b3dSpotLight,
label3d, toggle3d, slider3d,
} from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'
import { tosi } from 'tosijs'
const { lamps } = tosi({ lamps: { on: true, geometry: true, ambient: 0.06 } })
const FLUORESCENT = {
brightness: [
{ x: 0, y: 0 }, { x: 0.06, y: 0.85 }, { x: 0.1, y: 0.05 },
{ x: 0.17, y: 1 }, { x: 0.23, y: 0.08 }, { x: 0.3, y: 0.95 },
{ x: 0.35, y: 1 }, { x: 0.45, y: 0.93 }, { x: 0.52, y: 1 },
{ x: 0.63, y: 0.9 }, { x: 0.75, y: 1 },
// THE DECAY REGION. It drops fast to an EMBER and holds there before going
// out, rather than fading straight to zero — otherwise the hue shift below
// arrives at red exactly when there is no light left to be red, and the
// whole effect is invisible. Tonio: "the light that looks red doesn't seem
// to produce red light."
{ x: 0.8, y: 0.34 }, { x: 0.95, y: 0.26 }, { x: 1, y: 0 },
],
// Reaches red EARLY in the decay, so the ember above is already red while it
// still has something to light with.
hue: [{ x: 0, y: 0.5 }, { x: 0.75, y: 0.5 }, { x: 0.85, y: 0.04 }, { x: 1, y: 0 }],
// Saturation RISES into the decay, and needs a scale above 1 to do it: the
// base #cfe8ff is only 0.19 saturated, so hue alone would give a pale orange.
saturation: [{ x: 0, y: 0.2 }, { x: 0.75, y: 0.2 }, { x: 0.9, y: 1 }, { x: 1, y: 1 }],
saturationScale: 5,
hueShiftDeg: 190,
attackEnd: 0.35, sustainEnd: 0.75,
attack: 1.3, period: 3, decay: 3.2,
}
const SOFT = {
brightness: [{ x: 0, y: 0 }, { x: 0.35, y: 1 }, { x: 0.7, y: 1 }, { x: 1, y: 0 }],
attackEnd: 0.35, sustainEnd: 0.7,
attack: 0.9, decay: 1.6,
}
preview.append(
b3d(
{
style: 'width:100%;height:100%',
scenePanel: () => [
label3d({ text: 'Lamps' }),
toggle3d({ label: 'on — watch the attack / decay', value: lamps.on }),
toggle3d({ label: 'show fixtures', value: lamps.geometry }),
slider3d({ label: 'ambient', value: lamps.ambient, min: 0, max: 0.4, step: 0.01 }),
],
sceneCreated(el, BABYLON) {
const scene = el.scene
// A DARK room, or nothing the lamps do is visible.
scene.clearColor = new BABYLON.Color4(0.02, 0.02, 0.03, 1)
// MATTE and PATTERNED. Specular black kills the mirror-highlight that
// made the old floor reflect the lamps; the checker gives falloff and
// shadow edges something to fall across.
const checker = new BABYLON.DynamicTexture('checker', 512, scene, false)
const ctx = checker.getContext()
for (let y = 0; y < 16; y++) {
for (let x = 0; x < 16; x++) {
ctx.fillStyle = (x + y) % 2 ? '#3a3f47' : '#2d323a'
ctx.fillRect(x * 32, y * 32, 32, 32)
}
}
checker.update()
checker.uScale = checker.vScale = 3
const floorMat = new BABYLON.StandardMaterial('floor', scene)
floorMat.diffuseTexture = checker
floorMat.specularColor = BABYLON.Color3.Black()
const floor = BABYLON.MeshBuilder.CreateGround('floor', { width: 34, height: 20 }, scene)
floor.material = floorMat
// Blockers, so shadows have something to be cast BY.
const props = []
const prop = (x, z, w, h, d) => {
const m = BABYLON.MeshBuilder.CreateBox('prop', { width: w, height: h, depth: d }, scene)
m.position.set(x, h / 2, z)
const mat = new BABYLON.StandardMaterial('prop', scene)
mat.diffuseColor = new BABYLON.Color3(0.62, 0.6, 0.58)
mat.specularColor = BABYLON.Color3.Black()
m.material = mat
props.push(m)
return m
}
prop(-9, 0.6, 1.4, 2.2, 1.4)
prop(-7.2, -1.6, 1, 1, 1)
prop(0, 1.2, 1.2, 1.6, 1.2)
prop(-1.6, -1.4, 0.9, 2.6, 0.9)
const ball = BABYLON.MeshBuilder.CreateSphere('prop', { diameter: 1.6 }, scene)
ball.position.set(9.5, 0.8, 1.5)
ball.material = props[0].material
props.push(ball)
// Register so every lamp's shadow generator picks them up as casters
// AND marks them as receivers.
el.register({ meshes: [floor, ...props] })
orbitCam(el, {
alpha: -Math.PI / 2, beta: Math.PI / 3.1, radius: 24,
target: [0, 1, 0], maxElevationDeg: 78,
})
},
},
b3dLight({ intensity: lamps.ambient }),
// WARM LAMP, hard shadows. Its own corner, so the shadow reads.
b3dPointLight({
x: -8, y: 4.2, z: 0, diffuse: '#ffd9a0', range: 16, intensity: 1.8,
on: lamps.on, geometry: lamps.geometry, shadows: 'on', program: SOFT,
}),
// FLUORESCENT: strikes in stutters, hums, dies red and washed out.
b3dPointLight({
x: 0, y: 4.4, z: 0, diffuse: '#cfe8ff', range: 15, intensity: 2.1,
on: lamps.on, geometry: lamps.geometry, shadows: 'on', program: FLUORESCENT,
}),
// GELLED SPOT over CLEAR floor, so the window pattern is legible.
b3dSpotLight({
x: 9, y: 8, z: -1, angle: 46, exponent: 6,
diffuse: '#fff2dc', intensity: 620, range: 24,
on: lamps.on, geometry: lamps.geometry, shadows: 'on', program: SOFT,
gelSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" fill="black"/>
<g fill="white">
<rect x="7" y="7" width="22" height="22"/>
<rect x="35" y="7" width="22" height="22"/>
<rect x="7" y="35" width="22" height="22"/>
<rect x="35" y="35" width="22" height="22"/>
</g></svg>`,
})
)
)
tosi-b3d { width: 100%; height: 100%; }
.preview { height: 100%; }
What each type can actually do
Decided by Babylon, not by us, and worth knowing before you pick:
| shadows | gel (projectionTexture) |
geometry | |
|---|---|---|---|
b3dPointLight |
✅ cube shadow map | ❌ not supported by the engine | glowing sphere |
b3dSpotLight |
✅ | ✅ native — bitmap or SVG | cone housing |
b3dAreaLight |
❌ RectAreaLight is not a ShadowLight |
❌ | emissive panel |
Rather than fake the two ❌ cases, they warn once and carry on lit. A gel faked on a point light would be a shadow-map hack with different behaviour, different cost and different bugs from the real thing — a worse outcome than the honest absence, and one you would discover late.
Geometry: on, off, or yours
geometry="on" (the default) builds a primitive sized to the light. "off"
gives you the light alone. To supply your own, either point url at a GLB — it
is canonicalized and parented like any model — or parent anything you like to
the lamp's node:
const lamp = b3dSpotLight({ intensity: 40, geometry: 'off' })
myChandelier.parent = lamp.node
The default geometry is deliberately plain and unlit (emissiveColor,
disableLighting), because a fixture that is itself shaded by the scene reads as
a grey lump exactly when its light is off — which is when you most need to see
where it is.
It TRACKS the light. The emitter's colour and brightness follow whatever the program is doing, so a fluorescent's bulb stutters as it strikes and dims to a red ember as it dies. A flickering lamp with a steadily-glowing bulb is wrong in the way that is hardest to unsee: the one part of the scene not participating in the light is the part emitting it. There is a small emissive floor so a switched-off fixture stays locatable rather than vanishing outright — which is the same concern that made it unlit to begin with.
One curve is the whole lamp
Everything time-varying comes from light-modulation: one curve per channel
(brightness, hue, saturation, range) spanning the lamp's entire
behaviour, split into three regions by two markers.
0 ─────────── attackEnd ──────────── sustainEnd ─────────── 1
| ATTACK | SUSTAIN | DECAY |
| once, on | loops while on, | once, on |
| switch-on | one pass per `period` | switch-off |
The seams cannot jump, because there is only one curve — the attack arrives at
attackEnd and the sustain starts there. It follows that the curve's value at
attackEnd IS the sustain level: there is nothing else to declare and nothing
to keep in sync.
b3dSpotLight({
y: 4, intensity: 60, diffuse: '#ffd9a0',
// A fluorescent: strikes in stutters, hums steadily, fades out and reddens.
program: {
brightness: [
{ x: 0, y: 0 }, { x: 0.08, y: 0.9 }, { x: 0.12, y: 0.05 },
{ x: 0.2, y: 1 }, { x: 0.26, y: 0.1 }, { x: 0.35, y: 0.95 },
{ x: 0.75, y: 1 }, { x: 0.9, y: 0.3 }, { x: 1, y: 0 },
],
hue: [{ x: 0, y: 0.5 }, { x: 0.75, y: 0.5 }, { x: 1, y: 0 }],
hueShiftDeg: 190,
attackEnd: 0.35, sustainEnd: 0.75,
attack: 1.2, period: 2, decay: 1.5,
},
})
Those are the same [0,1] → [0,1] curves the province editor edits, so
curve3d is already the editor for a lamp — the two markers are
the only thing it does not draw yet.
Flicker does not compose across regions, deliberately: if you want a lamp flickering as it dies, draw that into the decay region. See light-modulation for why that trade was taken and the two discontinuities it leaves.
on is what runs the program: setting it false plays the DECAY region rather
than killing the light. A lamp that has finished decaying stops doing per-frame
work altogether.
Attributes
Shared by all three unless noted.
| Attribute | Default | Description |
|---|---|---|
x / y / z |
0/3/0 |
Position |
intensity |
1 |
Brightness. With modulation, this is the MAXIMUM |
diffuse |
'#ffffff' |
Colour |
specular |
'#ffffff' |
Specular colour |
range |
10 |
Falloff distance (point / spot) |
on |
true |
false plays the DECAY region; it does not kill the light |
geometry |
'on' |
'off' for no fixture |
geometryScale |
1 |
Size multiplier for the default fixture |
url |
— | GLB fixture, in place of the primitive |
shadows |
'off' |
'on' for a shadow generator (point / spot only) |
shadowTextureSize |
0 (auto) |
Resolves against the device budget |
angle |
60 |
spot — cone angle in degrees |
exponent |
2 |
spot — falloff sharpness |
gel |
— | spot — projection texture URL (bitmap or SVG file) |
gelSvg |
— | spot — inline SVG source or an SVGElement |
width / height |
2/1 |
area — panel size |
Properties (JS only — objects, not attributes): program
(LightProgram) and node (the fixture's TransformNode,
for parenting your own geometry).