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

applyLighting2D is the shorthand for a surface that reflects nothing. The general form takes the same lights through a microfacet BRDF, and a Lit-2D shader can call it directly:

highp vec3 P = vec3(v_worldPos, 0.0);
highp vec3 lit = applyLightingPBR(
base.rgb, // albedo
N, // surface normal
P, // world position
viewDirection(P), // toward the camera, from the frame block
u_metallic, // 0 = dielectric, 1 = metal
u_roughness, // 0 = mirror, 1 = fully diffuse
u_specular, // glTF's specularFactor: 0 removes the highlight
1.0 // ambient occlusion
);

applyLighting2D(albedo, N, worldPos) is exactly this call with metallic = 0, roughness = 1 and specular = 0 — so a lit sprite keeps drawing the pixels it always did, and a surface that wants a highlight asks for one.

viewDirection(worldPos) answers with the unit vector pointing at the camera; it reads the eye position the engine puts in the frame block each frame, so an orthographic camera returns a constant and a perspective one varies per pixel.

A metal has no diffuse term at all — it only reflects. What it reflects is the scene’s ambient light, treated as an environment arriving equally from every direction, so an Ambient Light2D is what keeps a metal from being black everywhere no light happens to point. Raise its color to give metals something to show.

A flat ambient is an environment with no detail — every direction the same colour. Import an equirectangular .hdr panorama and the same light becomes one that has detail: sky above, ground below, whatever the file holds.

How What happens
Drag a .hdr onto the Content Browser The source is copied in and baked.
Put one in the project folder The editor notices and bakes it.
estella import-hdr <file> [outDir] The same bake, on the command line. --face-size <n> sets the sharpest reflection.

The bake writes two products beside the source: <name>.esenv, which holds the whole diffuse half as nine numbers — no texture at all — and <name>_env.png, a prefiltered reflection with one mip per roughness.

Point an Ambient light at the .esenv and both halves arrive: a surface takes its fill light from the direction it faces, and a metal reflects the panorama rather than one flat colour.

cmds.spawn().insert(Light2D, {
type: Light2DType.Ambient,
environment: 'assets/sky.esenv',
intensity: 1,
});

color and intensity still scale it, so an environment is tinted and dimmed the way the flat term is. A light with no environment stays exactly what it was: the coefficients are zero, and the same expression is the flat term again.

A ShadowCaster2D box (below) shadows in the XY plane, which is what a 2D scene means by a shadow. Geometry with height needs the other kind: turn on meshShadows on a Directional light and the frame renders the scene’s meshes once from that light, so a mesh standing above another darkens it.

cmds.spawn()
.insert(Light2D, {
type: Light2DType.Directional,
direction: { x: 0.5, y: 0 }, // where the rays lean
meshShadows: true,
});
  • One light per frame casts a map — the first that asks for one.
  • Only Mesh2D geometry the GPU holds casts and receives. Sprites are lit by the same light but shadow through ShadowCaster2D, which is the 2D answer.
  • Coverage follows the camera. shadowExtent fixes it to a radius instead, trading sharpness for reach.

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.