2D lighting

Real lights in 2D scenes: lanterns that light the wall behind them, pillars that cast hard shadows, flames that glow and bloom, normal maps that give painted art relief, and sprite effects (hit flash, dissolve, heat haze). It works the same on Metal (Mac) and OpenGL (Steam Deck, Windows).

Everything is opt-in. A scene without these components draws exactly as before.

The four components

ComponentWhat it is
light_2dA light: point (a lantern), spot (a lamp aimed down), directional (moon or sun), ambient (adds base light).
occluder_2dBlocks light: box, polygon or sprite (traced from the sprite's outline). Shadow-casting lights cast hard shadows behind it.
material_2dHow a sprite, tilemap or backdrop answers light: unlit, lit (painted art) or lit_pixel (pixel art). Also its normal map, emissive mask and effects.
lighting_2dThe scene's settings, one per scene (usually on the camera): ambient light, time of day, bloom, lit_by_default, light budgets.

Night in an existing level

entity Camera #1 {
  camera_2d { view_height 720  follow "Juno" }
  lighting_2d { ambient (0.3, 0.34, 0.6)  ambient_intensity 0.42  lit_by_default true  bloom 1 }
}

entity Torch_1 #60 {
  transform { position (320, 250, 0) }
  sprite { image "2D/juno_flow/castle_torch.png"  pivot (0.5, 0, 0)  pixels_per_unit 2  layer 3 }
  material_2d { emissive "auto"  emissive_intensity 1.4 }
  light_2d { color (1, 0.7, 0.4)  intensity 1.7  radius 420  flicker 0.35  shadows true  offset (0, 62, 0) }
}

entity Pillar_1 #61 {
  transform { position (480, 192, 0) }
  sprite { image "2D/juno_flow/castle_pillar.png"  pivot (0.5, 0, 0)  pixels_per_unit 2  layer 2 }
  occluder_2d { shape "sprite" }
}

lit_by_default true lights everything that has no material_2d (sprites, tiles, backdrops), except HUD sprites and additive glows. The AI builder does all of this in one step with the light_2d_scene tool (preset night, lights at every torch, lantern, lamp, candle, fire, window or crystal, occluders on pillars, crates, walls, rocks and trees).

Lights

  • Falloff: radius is how far it reaches; falloff 1 is linear, 2 soft (default), 4 a tight pool.
  • Height: how high above the picture the light hangs. Low lights rake across normal maps (strong relief).
  • Spot: direction is the aim, cone the full angle, softness the edge.
  • Directional: direction points toward the light; elevation is degrees above the picture.
  • Flicker: flicker 0...1 and flicker_speed (noise changes per second, capped at 3). Smooth deterministic noise changes intensity by at most 12%; the pool radius stays steady. It runs on the 2D clock, so replays match.
  • Budgets: at most 16 lights on screen (lighting_2d.max_lights) and 4 with shadows (max_shadowed). Over budget, lower priority drops first, then dimmer and farther lights. critical true sorts first (the hardware ceiling still applies). Outgoing light and shadow slots fade for 0.3 seconds before reuse; new occupants fade in for 0.3 seconds. Each lit quad adds up its 8 strongest lights on Metal and its 4 strongest on GL (Deck standard), with the same fade before replacing a local slot. Paused editor camera changes use presentation time for these fades; gameplay stays frozen.
  • Layers: light_2d.mask, material_2d.light_mask and occluder_2d.mask are bit masks. A light lights and is blocked only by things that share a bit.

Day and night

lighting_2d { time_of_day 18.5 } drives the ambient light and a sun or moon from the clock: noon is white daylight, dusk orange, midnight blue. Scripts move it (game.lighting2d({ time_of_day: t })) to run a day.

Materials

  • Normal maps: put hero.normal.png next to hero.png (same sheet layout; red +x right, green +y up, blue toward the viewer). Or set normal "auto", which makes one from the art: the outline rounds over bevel pixels and the art's shading becomes relief (relief). The limits of auto: it can't know real shape. Flat paint becomes a pillow, dark paint reads as a groove, and hair and cloth folds come out wrong. Painted or baked maps are better. Normals turn with the sprite and mirror with flip_x, flip_y and negative scale.
  • Emissive: hero.emissive.png next to the art (rgb is the glow colour, black is none), or emissive "auto" (prefers the baked sibling map; otherwise saturated warm pixels and near-white hot spots; emissive_threshold sets how bright). The limits of auto: white paint and highlights glow too, and dark embers don't. Glowing parts stay bright at night and bloom (material_2d.bloom, lighting_2d.bloom, bloom_radius). The rest of the sprite stays dark.
  • lit_pixel: light is worked out once per art pixel and stepped into bands (default 4). Pixel art stays crisp under moving lights. lit is smooth, for painted art.
  • Shine: specular and shininess (it shows best with a normal map).
  • A lit material under white ambient light and no lights draws the art exactly as painted.

Effects (scripts, the feel lane, the AI)

ScriptSwift (Effects2D)Effect
self.flash([1,1,1], 0.12)Effects2D.flash(world, e, color:, seconds:, strength:)Hit flash: full at once, eases out.
self.dissolve(0.9)Effects2D.dissolve(world, e, seconds:, color:)Burns away from a noise pattern with a glowing edge, and stays gone.
self.appear(0.6)Effects2D.appear(world, e, seconds:, color:)The reverse: burns in.
self.heat(6, 2)Effects2D.heat(world, e, amount:, seconds:)Heat-shimmer pulse.
self.clearEffects()Effects2D.clear(world, e)Removes its timed effects.

They work on any 2D object, with or without a material_2d, and play on the 2D clock (the same in replays). The same values are fields (material_2d.flash, dissolve, heat) for sequences and keyframes. A sprite with material_2d { heat 8 } becomes a heat haze: it bends what's behind it instead of drawing itself. Put a soft sprite over a fire for this.

Scripts can read and write any 2D lighting field: torch.setField("light_2d.intensity", 0), torch.field("light_2d.color").

Drawing order (both backends)

  1. Shadow mask: occluder shadow volumes, one channel per shadowed light.
  2. Scene: plain quads as before; lit quads in linear light (ambient plus lights, normal maps, shadows); emissive and dissolve edges also go to a glow target; heat quads go to a distortion target.
  3. Bloom: the glow at half and quarter size, blurred.
  4. Present: heat distortion, bloom, then the camera's grade, palette and pixel scaling.

Set lighting_2d { shadow_style "drop" penumbra 4 } for top-down scenes. Drop shadows use a sprite's body footprint when available, and occluder_2d.height (zero uses sprite/shape height). Projection depends on the light height and stops at 75% of the object height, even when a light is below it. Drop shadows always get a soft edge; penumbra increases it. Side-view scenes keep shadow_style "volume"; set penumbra above zero for filtered soft edges. The filter widens with light distance.

world_2d_check warns when enabled shadowed lights exceed max_shadowed. The night planner keeps existing lights, avoids adding a second light within 24 units of a glow, and skips roof motifs unless the roof object itself is named as a light. Top-down worlds get drop shadows from the preset.

Known limits: filtering is a nine-sample approximation, not area-light ray tracing. An occluder standing in another occluder's shadow is lit on its own face. A light inside an occluder shadows everything around it. Bloom comes only from emissive parts.

For a timed change, use game.lighting2d({time_of_day: 5.8, over: 3}). The clock takes the shorter path through midnight and finishes at the requested hour. Enabling or disabling time-of-day also fades the sky's contribution, so a scene that began with fixed ambient light does not jump on the first tick.

Arcwright Beta 2 (0.4.0) · generated from the engine source (25e375c86)Something wrong or missing? Tell us
Download Arcwright free