This guide covers the practical side of 2D lighting: adding Light2D sources, picking a LightEffect preset, animating and dragging lights, and tuning per-tile slot allocation for dense scenes. For the mental model behind Forward+ tiling, the slot budget, and the SDF shadow pass, see 2D Lighting.
Basic Setup
Section titled “Basic Setup”import { WebGPURenderer } from 'three/webgpu'import { Flatland, Light2D, Sprite2D } from 'three-flatland'import { DefaultLightEffect } from '@three-flatland/presets'
const renderer = new WebGPURenderer({ antialias: false })await renderer.init()document.body.appendChild(renderer.domElement)
const flatland = new Flatland({ viewSize: 400, clearColor: 0x050508 })
// Activate lighting — choose a presetflatland.setLighting(new DefaultLightEffect())
// Add a point lightconst torch = new Light2D({ type: 'point', position: [0, 50], color: 0xff6600, intensity: 2.0, distance: 250, decay: 2,})flatland.add(torch)
// Add ambient light for base illuminationflatland.add(new Light2D({ type: 'ambient', color: 0x181828, intensity: 0.3,}))
// Sprites receive lighting by defaultconst sprite = new Sprite2D({ texture })flatland.add(sprite)
function animate() { flatland.render(renderer) requestAnimationFrame(animate)}animate()import { Suspense, useRef, useEffect } from 'react'import { Canvas, extend, useFrame, useThree } from '@react-three/fiber/webgpu'import type { WebGPURenderer } from 'three/webgpu'import { Flatland, Light2D, Sprite2D, attachLighting,} from 'three-flatland/react'import { DefaultLightEffect } from '@three-flatland/presets'import '@three-flatland/presets/react'
extend({ Flatland, Sprite2D, Light2D, DefaultLightEffect })
function Scene() { const flatlandRef = useRef<Flatland>(null) const { renderer, size } = useThree()
useEffect(() => { flatlandRef.current?.resize(size.width, size.height) }, [size.width, size.height])
useFrame(() => { flatlandRef.current?.render(renderer as unknown as WebGPURenderer) }, { phase: 'render' })
return ( <flatland ref={flatlandRef} viewSize={400} clearColor={0x050508}> {/* Attach lighting effect to Flatland */} <defaultLightEffect attach={attachLighting} />
{/* Light sources */} <light2D lightType="point" position={[0, 50, 0]} color={0xff6600} intensity={2.0} distance={250} decay={2} /> <light2D lightType="ambient" color={0x181828} intensity={0.3} />
{/* Sprites receive lighting by default */} <sprite2D texture={texture} /> </flatland> )}
export default function App() { return ( <Canvas renderer={{ antialias: false }}> <Suspense fallback={null}> <Scene /> </Suspense> </Canvas> )}Light2D Types
Section titled “Light2D Types”Point Light
Section titled “Point Light”Radiates light from a position with distance-based falloff:
const torch = new Light2D({ type: 'point', position: [100, 100], // World position [x, y] color: 'orange', // Any Three.js ColorRepresentation intensity: 1.5, // Brightness multiplier distance: 200, // Max light distance (0 = no cutoff) decay: 2, // Attenuation exponent (2 = quadratic)})Directional Light
Section titled “Directional Light”Parallel rays from a direction (like sunlight):
const sun = new Light2D({ type: 'directional', direction: [1, -1], // Light direction (normalized automatically) color: 0xffffcc, intensity: 0.8,})Spot Light
Section titled “Spot Light”Cone of light from a position in a direction:
const spotlight = new Light2D({ type: 'spot', position: [0, 100], direction: [0, -1], // Points downward color: 0xffffff, intensity: 2.0, distance: 300, angle: Math.PI / 6, // 30-degree cone penumbra: 0.2, // Soft edge (0-1)})Ambient Light
Section titled “Ambient Light”Uniform illumination — affects all lit sprites equally:
const ambient = new Light2D({ type: 'ambient', color: 0x222233, intensity: 0.3,})LightEffect Presets
Section titled “LightEffect Presets”@three-flatland/presets ships a lighting preset for the recommended rendering path:
| Preset | Tiled | Shadows | GI | Best For |
|---|---|---|---|---|
DefaultLightEffect | Yes | SDF (WIP) | No | Recommended — scales well |
DefaultLightEffect supports normal-based diffuse shading when a normal channel is provided (see Normal Providers below).
DefaultLightEffect
Section titled “DefaultLightEffect”Forward+ tiled lighting — the recommended preset. Uses per-tile light culling for O(lights per tile) per fragment. Full feature set: bands, pixel-snapping, glow, rim lighting, and shadow support.
import { DefaultLightEffect } from '@three-flatland/presets'
const lighting = new DefaultLightEffect()flatland.setLighting(lighting)
// Optional stylization (zero-cost uniform updates)lighting.bands = 4 // Cel-shading bandslighting.pixelSize = 4 // Pixel-snap light samplinglighting.glowRadius = 2 // Broad secondary fallofflighting.glowIntensity = 0.3lighting.lightHeight = 0.75 // Simulated Z-height for diffuse (0-2)lighting.rimIntensity = 0.3 // Edge highlights from light direction
// Shadow parameters (SDF pipeline)lighting.shadowStrength = 0.8lighting.shadowBias = 0.5lighting.shadowMaxDistance = 300For the full DefaultLightEffect schema (uniforms vs compile-time constants, removed fields, the categoryQuotas accessor), see the Pass Effects guide.
Animating Lights
Section titled “Animating Lights”Update Light2D properties at any time — they sync to the GPU automatically each frame:
let flickerTimer = 0
function animate(delta: number) { flickerTimer += delta
// Combine sine waves for organic flicker const flicker = 1 + Math.sin(flickerTimer * 15) * 0.1 + Math.sin(flickerTimer * 23) * 0.05
torch.intensity = 1.2 * flicker
// Slight position wobble torch.position.set( baseX + Math.sin(flickerTimer * 7) * 2, baseY + Math.sin(flickerTimer * 11) * 1, 0 )}Draggable Lights
Section titled “Draggable Lights”Make lights interactive by converting screen coordinates to world space:
let draggingLight: Light2D | null = null
canvas.addEventListener('mousedown', (e) => { const worldPos = screenToWorld(e.clientX, e.clientY) for (const light of flatland.lights) { if (light.position2D.distanceTo(worldPos) < 20) { draggingLight = light break } }})
canvas.addEventListener('mousemove', (e) => { if (draggingLight) { draggingLight.position2D = screenToWorld(e.clientX, e.clientY) }})
canvas.addEventListener('mouseup', () => { draggingLight = null})Light2D Properties
Section titled “Light2D Properties”| Property | Type | Default | Description |
|---|---|---|---|
lightType | 'point' | 'directional' | 'spot' | 'ambient' | 'point' | Light type |
color | ColorRepresentation | 0xffffff | Light color |
intensity | number | 1 | Brightness multiplier |
distance | number | 0 | Max range (0 = no cutoff) |
decay | number | 2 | Attenuation exponent |
direction | Vector2 | [number, number] | [0, -1] | Direction (spot/directional) |
angle | number | Math.PI/4 | Cone angle in radians (spot) |
penumbra | number | 0 | Soft edge 0-1 (spot) |
enabled | boolean | true | Toggle without removing |
castsShadow | boolean | true | Whether this light traces shadows |
importance | number | 1 | Tile-rank score multiplier (Forward+) |
category | string | undefined | undefined | Bucket tag for fill-light quotas |
position2D | Vector2 | (0, 0) | 2D position helper |
Shadow Casting, Importance & Categories
Section titled “Shadow Casting, Importance & Categories”Three Light2D fields control how lights compete for per-tile slots in dense scenes: castsShadow splits lights into heroes and fills, importance biases the rank score, and category buckets fills into independent quotas. They do nothing in small scenes; they make dense scenes readable. For why the slot budget exists and when these knobs start to matter, see Tiled lighting and the slot budget.
castsShadow — the hero / fill split
Section titled “castsShadow — the hero / fill split”// Hero — full per-light SDF shadow trace, never evicted by fillsconst torch = new Light2D({ type: 'point', color: 0xff6600, intensity: 1.8, distance: 140, decay: 2, // castsShadow defaults to true})
// Fill — skips the SDF trace, competes only inside its category quotaconst slimeGlow = new Light2D({ type: 'point', color: 0x33ff66, intensity: 0.25, distance: 40, decay: 2, castsShadow: false,})Heroes (castsShadow: true, the default) compete only with each other for tile slots. Forward+ runs the hero pass first and reserves slots before fills are even considered — they can’t be evicted by fills regardless of count.
Fills (castsShadow: false) skip the per-light SDF sphere-trace entirely on the GPU and compete only within their own category bucket. The 32-tap trace is the dominant per-light cost in dense scenes; flipping cosmetic lights to fills is often the single biggest perf win available.
Recommendation: torches, key lights, and anything where the shape of the shadow matters → heroes. Slime auras, particle glows, magic VFX, ambient shimmer → fills.
importance — bias tile-slot allocation
Section titled “importance — bias tile-slot allocation”const torch = new Light2D({ /* ... */ importance: 10, // 10× weight against importance-1 fills in slot ranking})importance: number defaults to 1 (neutral). It’s a multiplicative bias on the tile-rank score during slot allocation. Use importance: 10 on hero lights you want resilient to dense fill clusters; 10 is plenty in practice, numbers above ~50 don’t measurably improve outcomes.
importance is score-only — it never appears in the fragment shader’s illuminance math. A light’s appearance once it’s in a tile slot is identical regardless of its importance value.
category — independent quotas for fill clusters
Section titled “category — independent quotas for fill clusters”Light2D.category: string | undefined is an opaque tag, hashed via djb2 to one of FILL_CATEGORY_COUNT (4) buckets at the property setter — no per-frame hash cost; the cost is paid once per unique string and cached for every Light2D that shares it.
// 1000 slime fills, all sharing one quota bucketfor (const slime of slimes) { flatland.add(new Light2D({ type: 'point', color: 0x33ff66, intensity: 0.25, distance: 40, decay: 2, castsShadow: false, category: 'slime', }))}In React the same fields are JSX props:
<light2D lightType="point" color={0xff6600} intensity={1.8} distance={140} decay={2} importance={10}/>
<light2D lightType="point" color={0x33ff66} intensity={0.25} distance={40} decay={2} castsShadow={false} category="slime"/>Each unique category string maps to one of 4 buckets, and Forward+ enforces a per-tile, per-category quota — 200 slime lights in one tile can’t crowd out the water shimmer next to them. Lights without a category share bucket 0 with all other un-tagged fills. Heroes (castsShadow: true) ignore category entirely.
Bucket count is 4. A 5th distinct category collides into one of the first 4 — graceful degradation, not an error. If you have more than 4 cosmetic light types, pick the 4 most visually distinct ones to bucket separately and let the rest collide.
Per-category quota tuning
Section titled “Per-category quota tuning”The default per-category quota is MAX_FILL_LIGHTS_PER_TILE (2) slots per tile. To raise or lower it:
// Imperative — direct call on the ForwardPlusLighting instancelighting.forwardPlus.setFillQuota('slime', 4)console.log(lighting.forwardPlus.getFillQuota('slime')) // → 4lighting.forwardPlus.resetFillQuotas() // back to default 2DefaultLightEffect exposes a declarative sugar accessor that’s nicer for JSX:
<defaultLightEffect attach={attachLighting} categoryQuotas={{ slime: 4 }}/>Setting categoryQuotas is the authoritative quota state, not a delta. Each assignment first calls forwardPlus.resetFillQuotas() to clear all four buckets back to default, then re-applies the record. Going from { slime: 4 } → {} actually drops the slime quota back to 2 — without the reset, stale per-bucket overrides would leak across re-renders in surprising ways.
Range: 0..MAX_LIGHTS_PER_TILE (clamped). Setting a quota to 0 disables that bucket entirely (those fills contribute nothing). Setting it above 16 doesn’t help — 16 is the per-tile slot total.
In the examples/react/lighting/ demo, the slime-quota slider drives categoryQuotas={{ slime: props.slimeQuota }} — drag it from 0 (slime lights dark) through 2 (default — visible checkerboarding in dense clusters) up to 16 (every slime in range contributes, at a per-fragment cost) and watch the artifact shift in real time.
Tuning notes
Section titled “Tuning notes”- Slime-quota above ~6 hits diminishing returns. At
4the visible “fill got evicted” checkerboard is already rare; at6it’s basically gone. Going higher costs shader iterations per fragment in saturated tiles without meaningfully improving the picture. - Torch importance
10is plenty. Numbers above ~50 don’t matter unless your fills also have non-1 importance, in which case the relative ratio is what matters, not the absolute value. - Score-ranked eviction within a quota is intuitive. When a category’s quota is full and another fill of that category enters range, the closer / brighter one wins the slot. Tweaking individual fill
intensityindirectly affects who survives in saturated tiles. - Fills are cheap to leave on.
light.enabled = falseis a zero-cost cull (rejected before per-tile upload), but acastsShadow: falsefill that’s just dim and far away is also nearly free — Forward+ won’t even rank it into a tile it can’t reach. Don’t over-engineer toggling.
Normal Providers
Section titled “Normal Providers”DefaultLightEffect supports normal-based directional diffuse shading. To enable it, add a normal provider effect to your sprites.
NormalMapProvider
Section titled “NormalMapProvider”Uses a pre-baked normal map texture for higher-quality normals:
import { NormalMapProvider } from '@three-flatland/presets'
const provider = new NormalMapProvider()provider.normalMap = myNormalMapTexturesprite.addEffect(provider)To produce a normal-map texture from a sprite sheet or LDtk tileset, see the Baked Normal Pipeline — SpriteSheetLoader and LDtkLoader both accept normals: true to expose a co-registered normalMap on the loaded asset. The atlas’s blue channel encodes per-fragment elevation, which DefaultLightEffect consumes for elevation-aware lighting. (See Loaders for the runtime side and the Baking guide for the offline flatland-bake workflow.)
Without a normal provider, lighting still works — surfaces are treated as flat (facing the camera).
Runtime bake fallback
Section titled “Runtime bake fallback”NormalMapProvider is fed by a baked .normal.png atlas. The high-level
loaders (SpriteSheetLoader({ normals: true }), LDtkLoader with normals: true)
handle the fallback for you: if the baked sibling is missing or its descriptor
hash is stale, the loader bakes the atlas in memory on first load (lazy-importing
the baker, so the cost is paid only when needed) and emits a dev-mode warning
pointing at npx flatland-bake normal.
For assets whose normal map is produced in the browser by design
(procedurally varied tilesets, throwaway prototypes, asset bundles where
shipping a sidecar isn’t worth the bytes), pass forceRuntime: true on
the loader. The in-memory bake runs on every load; the probe and “no
baked sibling” warn are skipped. You still get the same normal-map data
— the flag just commits to “the browser is where it’s produced for this
asset.” Mirrors SlugFontLoader.forceRuntime; one flag across every
baked-asset loader. Not a dev-iteration knob — the default path already
handles iteration (probe → bake on miss + warn pointing at
flatland-bake).
Next Steps
Section titled “Next Steps”- 2D Lighting — The mental model: Forward+ tiling, the slot budget, and the SDF shadow pass
- Lighting Example — Interactive demo with draggable lights and a live slime-quota slider
- Shadows — How the SDF occlusion pass and per-light tracing fit together
- Pass Effects — Post-processing, plus the full
DefaultLightEffectschema