Skip to content

2D Lighting & Shadows

Estella lights 2D scenes with Light2D sources and ShadowCaster2D occluders. A light is just an entity with a Light2D component; a renderer receives light only when it’s drawn with a Lit-2D material. This keeps lighting opt-in and cheap — most sprites stay unlit, and the ones you want lit get a lit material.

Give an entity a Light2D component and pick a type:

import { defineSystem, Commands, Transform, Light2D, Light2DType } from 'esengine';
const addSun = defineSystem([Commands()], (cmds) => {
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Light2D, {
type: Light2DType.Directional,
color: { r: 1, g: 0.95, b: 0.8, a: 1 },
intensity: 1.2,
direction: { x: -1, y: -1 },
});
});

Light2D fields (with defaults):

Field Default Description
type Point Light2DType: Point, Directional, Ambient, or Spot.
color {1,1,1,1} Light color, RGBA 0..1.
intensity 1 Brightness multiplier.
radius 200 Falloff reach in world units (Point / Spot).
direction {0,0} Aim direction (Directional / Spot).
innerAngle 30 Spot cone inner angle, degrees (full brightness inside).
outerAngle 45 Spot cone outer angle, degrees (falls to zero by here).
shadowSoftness 0 Shadow edge softness (light-source size); 0 = hard edge.
shadowDistance 0 Directional shadow reach; 0 = no directional shadow.
enabled true Turn the light off without removing it.
Type Behavior
Directional A uniform light from one direction, like the sun — position doesn’t matter, only the aim.
Point Radiates from the entity’s position and falls off to nothing at radius.
Spot A cone from the position along direction, full-bright within innerAngle and fading to the edge at outerAngle, out to radius.
Ambient A flat base light added everywhere, regardless of position — use a low intensity so shaded areas aren’t pure black.
// A warm point light
.insert(Light2D, { type: Light2DType.Point, radius: 320, intensity: 1.5,
color: { r: 1, g: 0.7, b: 0.3, a: 1 } });
// A flashlight cone
.insert(Light2D, { type: Light2DType.Spot, direction: { x: 0, y: -1 },
innerAngle: 18, outerAngle: 34, radius: 500, intensity: 2 });
// A dim ambient fill so nothing is fully black
.insert(Light2D, { type: Light2DType.Ambient, intensity: 0.25 });

By default a Sprite draws with an unlit material and ignores every light. The simplest way to light one is the lit flag — no material, no shader:

app.world.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Sprite, { texture: hero, lit: true });

In the editor this is the Lit checkbox on the Sprite component. The sprite is lit with a flat normal — every 2D light type, falloff, and shadow works out of the box.

When you need more than the flag (a tint parameter, a normal map, custom surface logic), use a material whose shader declares the Lit-2D domain. The engine injects the lighting uniforms and helpers, and the vertex stage is optional for 2D shaders — you write only the fragment:

#pragma shader "MyLit"
#pragma version 300 es
#pragma domain Lit2D
#pragma fragment
precision mediump float;
in vec4 v_color;
in vec2 v_texCoord;
in highp vec2 v_worldPos;
uniform sampler2D u_textures[8];
out vec4 fragColor;
// #pragma domain Lit2D makes the engine inject the LightConstants block and the
// applyLighting2D() helper — you just call it with your base color and a normal.
void main() {
vec4 base = texture(u_textures[0], v_texCoord) * v_color;
vec3 N = vec3(0.0, 0.0, 1.0); // flat normal, facing the camera
vec3 lit = applyLighting2D(base.rgb, N, v_worldPos);
fragColor = vec4(lit, base.a);
}
#pragma end

In the editor, New Material (Lit) in the Content Browser creates exactly this — a ready lit material with a tint and an optional normal map. From code, compile and assign it as in Materials & Shaders:

import { Material, defineSystem, Query, Mut, Sprite } from 'esengine';
const litMat = Material.create({ shader: Material.compileShader(myLitSource) });
const applyLit = defineSystem([Query(Mut(Sprite))], (q) => {
for (const [, sprite] of q) sprite.material = litMat;
});

You can also build a lit material visually — set the Material Graph’s domain to Lit2D — see the material graph section.

The N you pass to applyLighting2D is the surface normal. Passing a constant vec3(0, 0, 1) gives flat, evenly-lit surfaces. For per-pixel relief, sample a normal map in the shader and use that as N instead — the same base color then catches highlights and shadow from each light’s direction. Add the normal texture as a #pragma param … texture on your lit material (see Materials & Shaders).

Add a ShadowCaster2D to an entity to make it block light and cast a shadow:

import { ShadowCaster2D } from 'esengine';
cmds.spawn()
.insert(Transform, { position: { x: 100, y: 0, z: 0 } })
.insert(Sprite, { /* … a lit material … */ })
.insert(ShadowCaster2D, { size: { x: 64, y: 64 } });
ShadowCaster2D field Default Description
size {32,32} The occluder’s size in world units.
enabled true Toggle shadow casting.

Shadow quality is controlled on the light, not the caster:

  • shadowSoftness — the light-source size. 0 is a crisp hard edge; larger values give a soft penumbra (a bigger, closer light casts softer shadows).
  • shadowDistance — for a Directional light, how far its shadows extend; 0 leaves a directional light shadow-free.

By default the engine renders gamma-space — colors blend as-authored, the way 2D pipelines classically have. A project can switch to the linear-light pipeline: textures decode from sRGB to linear on sample (in hardware), lights and tints blend in linear light, and the finished frame encodes back to sRGB in the final blit.

Physically-correct blending changes how light accumulates: falloffs stop crushing to black, and overlapping lights add like light instead of paint. Unlit art round-trips unchanged — decode followed by encode is an identity — so switching does not repaint your sprites; the difference shows wherever colors mix.

Linear mode is also the door to HDR: where float render targets are available (WebGPU always; WebGL2 with EXT_color_buffer_float), the post-process chain runs in half-float, so a light with intensity above 1 pushes real over-range energy into bloom and tonemap — bright lights glow.

Enable it in Project Settings → Rendering → Color space → Linear. Shaders compile against the mode, so it is fixed at engine boot — the editor prompts for a reload. The setting rides the project manifest: the viewport, Play, and every export — web, desktop, WeChat, playable — boot the same pipeline. Code-first projects pass it at app creation:

const app = createWebApp(module, { colorSpace: 'linear' });

A 2D light in the editor viewport — its reach and the shadows its casters throw

A Light2D previewed in the editor — its reach pool and the shadows the pillars cast (turn on Preview FX to light the scene in edit mode).

  • Add a light via Create… → Light, or add a Light2D component to an existing entity in the Details panel.
  • Lights are drawn as gizmos in edit mode — an icon plus the reach circle and the direction / cone — so you can aim and size them by eye. See The Editor.
  • Tune color (a color picker), intensity, radius, and the spot angles live in Details; the results update in the viewport immediately.
  • All of a light’s visual fields (color, intensity, radius, the angles, and the shadow settings) are animatable — key them in the Sequencer for flickering torches, day/night cycles, or a pulsing beacon.