Skip to content

Add Light2D sources, pick a LightEffect preset, animate lights, and tune per-tile slot allocation for dense scenes.

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.

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 preset
flatland.setLighting(new DefaultLightEffect())
// Add a point light
const 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 illumination
flatland.add(new Light2D({
type: 'ambient',
color: 0x181828,
intensity: 0.3,
}))
// Sprites receive lighting by default
const sprite = new Sprite2D({ texture })
flatland.add(sprite)
function animate() {
flatland.render(renderer)
requestAnimationFrame(animate)
}
animate()
Light2D typesfour emission models — same intensity, different reachPointdistance · decayradiates from a position,distance-based falloffDirectionaldirectionparallel rays, like sunlight— no positionSpotangle · penumbradistancecone from a position,soft-edgedAmbientintensityuniform fill, affects alllit sprites equally
The four Light2D types and their emission shapes — point (radial falloff), directional (parallel rays), spot (soft-edged cone), and ambient (uniform fill)

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)
})

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,
})

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)
})

Uniform illumination — affects all lit sprites equally:

const ambient = new Light2D({
type: 'ambient',
color: 0x222233,
intensity: 0.3,
})

@three-flatland/presets ships a lighting preset for the recommended rendering path:

PresetTiledShadowsGIBest For
DefaultLightEffectYesSDF (WIP)NoRecommended — scales well

DefaultLightEffect supports normal-based diffuse shading when a normal channel is provided (see Normal Providers below).

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 bands
lighting.pixelSize = 4 // Pixel-snap light sampling
lighting.glowRadius = 2 // Broad secondary falloff
lighting.glowIntensity = 0.3
lighting.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.8
lighting.shadowBias = 0.5
lighting.shadowMaxDistance = 300

For the full DefaultLightEffect schema (uniforms vs compile-time constants, removed fields, the categoryQuotas accessor), see the Pass Effects guide.

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
)
}

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
})
PropertyTypeDefaultDescription
lightType'point' | 'directional' | 'spot' | 'ambient''point'Light type
colorColorRepresentation0xffffffLight color
intensitynumber1Brightness multiplier
distancenumber0Max range (0 = no cutoff)
decaynumber2Attenuation exponent
directionVector2 | [number, number][0, -1]Direction (spot/directional)
anglenumberMath.PI/4Cone angle in radians (spot)
penumbranumber0Soft edge 0-1 (spot)
enabledbooleantrueToggle without removing
castsShadowbooleantrueWhether this light traces shadows
importancenumber1Tile-rank score multiplier (Forward+)
categorystring | undefinedundefinedBucket tag for fill-light quotas
position2DVector2(0, 0)2D position helper

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.

// Hero — full per-light SDF shadow trace, never evicted by fills
const 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 quota
const 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.

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 bucket
for (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.

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 instance
lighting.forwardPlus.setFillQuota('slime', 4)
console.log(lighting.forwardPlus.getFillQuota('slime')) // → 4
lighting.forwardPlus.resetFillQuotas() // back to default 2

DefaultLightEffect 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.

  • Slime-quota above ~6 hits diminishing returns. At 4 the visible “fill got evicted” checkerboard is already rare; at 6 it’s basically gone. Going higher costs shader iterations per fragment in saturated tiles without meaningfully improving the picture.
  • Torch importance 10 is 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 intensity indirectly affects who survives in saturated tiles.
  • Fills are cheap to leave on. light.enabled = false is a zero-cost cull (rejected before per-tile upload), but a castsShadow: false fill 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.

DefaultLightEffect supports normal-based directional diffuse shading. To enable it, add a normal provider effect to your sprites.

Uses a pre-baked normal map texture for higher-quality normals:

import { NormalMapProvider } from '@three-flatland/presets'
const provider = new NormalMapProvider()
provider.normalMap = myNormalMapTexture
sprite.addEffect(provider)

To produce a normal-map texture from a sprite sheet or LDtk tileset, see the Baked Normal PipelineSpriteSheetLoader 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).

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).

  • 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 DefaultLightEffect schema