Local lights
A lantern, a torch, a forge or a flash lights what is round it. Mount a <LocalLight> for each one, with as many as the scene needs. The engine lights the ones that light the view most with a fixed pool of three.js point lights, the quality level’s localLights, and the rest wait. Every player’s page lights them: “local” means a light that lights what is near it, as Unreal names it, not a light only one player sees.
three compiles every lit material again when the scene’s count of point lights changes, so a raw <pointLight> that mounts after the first frame stalls the frame while every shader links. The pool’s count stays put, so a local light that comes or goes compiles nothing. In development, the engine warns when a light of the scene’s own joins or leaves after the scene’s first frame, and names the light.
Mount a light on an object
Section titled “Mount a light on an object”Mount <LocalLight> under the object it lights from, in a view. It rides the object, it lights nothing while an object above it is hidden, and it goes when the view unmounts. A room never mounts it.
For a light no view holds, or one whose values change each frame, such as a flash that fades, register it with registerLocalLight(world, options) from a view, with the world useWorld returns. Call the Disposer it returns to take the light off. The options are a LocalLightOptions record you keep. The engine reads it each frame, so move the light by writing its position, and change it by writing its other values. Set intensity to 0 to put a light out for a while, which also frees its place in the pool.
The values
Section titled “The values”A local light takes three’s point light’s own values: color, intensity in candela, distance and decay. On <LocalLight>, position is an offset from the object it is mounted under; in a registration it is where the light stands in the world. LocalLightOptions and LocalLightProps hold each value and its default. A lantern starts well at the sample’s intensity of 6 and distance of 6.
How the pool picks
Section titled “How the pool picks”Each frame the pool scores every light by how much of the view it lights: its brightness times about the share of the screen its reach covers. A light whose reach is out of view scores nothing, so a bright lamp behind the camera never takes a lantern’s light in front of it. The reach is distance, or nearer where the light has fallen under about 1% of the engine’s sun.
The highest scores get the lights, with these rules against flicker:
- A light keeps its place for at least 0.5 s, and a newcomer takes it only by scoring a quarter more.
- A light that loses its place fades out over 0.15 s, and the light that waited for it fades in over the same.
- A light that starts to ask while a place is free shines at once, so a flash keeps its peak.
- A light the game takes off, puts out or moves out of view goes dark at once.
The pool belongs to the world, so two worlds on one page never compete for lights. A local light is a point light, and it casts no shadow.
The first light compiles once
Section titled “The first light compiles once”The pool makes its point lights when the world registers its first local light, and keeps them while the world lasts, lit or not. A game with no local light pays nothing for them. That first registration compiles the lit shaders once. A scene’s lanterns register as the scene mounts, so the compile lands under the loading screen.
When a scene’s first light comes later, such as a torch a player crafts, mount a <LocalLight intensity={0} /> with the scene. It stands the pool from the start, and the torch compiles nothing when it lights. In development, the engine warns when the world’s first local light comes after the scene’s first frame.
The pool’s size
Section titled “The pool’s size”The quality level’s localLights sets how many point lights the pool holds, more at each level up, and none at Minimum. A change of level compiles the lit shaders once.
Each point light costs every lit pixel, whether or not it shines. The test scene is thirty lanterns on a night road, at 1920x1080 on an RTX 2070. There, High’s six lights cost about 1.2 ms of the GPU’s frame against none. Low’s two cost about 0.1 ms. The figures are the least contended of eight alternating rounds on a machine other work shared, so read them as estimates. A phone’s GPU pays several times as much for each light.
Unity’s URP forward path lights each object with its brightest additional lights, 4 by default and 8 at most. Godot’s Mobile and Compatibility renderers take 8 point lights per mesh. Unreal’s mobile renderer once took 4 movable local lights per object, and clusters them since 5.1. Each keeps its shaders’ shape fixed and picks the lights that matter. This pool picks per world and per view rather than per object, because three’s WebGL renderer hands every lit program the same lights.
See the pool
Section titled “See the pool”The devtools’ Frame panel shows how many of the pool’s lights shine, such as 2/4 local lights, and how many lights in view wait. Hover the row for where each one stands. spawnite play dump prints the same under localLights.
Sample
Section titled “Sample”The engine’s tests run this file: a forge’s view with a warm light over its coals, and a blast whose light fades over 0.6 s.
import { useFrame } from "@react-three/fiber";import { useLayoutEffect, useMemo } from "react";import { Vector3 } from "three";import { LocalLight, registerLocalLight, useWorld } from "@spawnite/engine";
/** A forge's view: its hearth, and a warm light above the coals that * lights the ground round it whenever the forge is among the lights that * light the view most. */export function ForgeView() { return ( <group> <mesh position={[0, 0.45, 0]}> <boxGeometry args={[1.5, 0.9, 1.3]} /> <meshStandardMaterial color="#6f7378" /> </mesh> <LocalLight position={[0, 1.4, 0.2]} color="#ff8a3a" intensity={6} distance={6} /> </group> );}
/** A flash that lights the ground where a spell lands and fades over * `seconds`: one registration, written each frame. Mount it in a view * for each blast. */export function BlastLight({ at, seconds = 0.6,}: { at: readonly [number, number, number]; seconds?: number;}) { const world = useWorld(); const [x, y, z] = at; const light = useMemo( () => ({ position: new Vector3(x, y, z), color: "#9fd4ff", intensity: 60, distance: 10, decay: 1.5, age: 0, }), [x, y, z], ); useLayoutEffect(() => registerLocalLight(world, light), [world, light]); useFrame((_, delta) => { light.age = Math.min(seconds, light.age + delta); const left = 1 - light.age / seconds; light.intensity = 60 * left * left; }); return null;}