There is an unspoken rite of passage for every browser game developer. You build a lovely top-down dungeon crawler, populate it with sprites, add some basic movement maths, and then realise it looks utterly flat. It looks like digital felt pinned to a corkboard.
Naturally, you decide to add dynamic, real-time shadows. How hard can drawing dark lines be?
Three days later, you are staring blankly at linear algebra textbooks, your browser tab has consumed four gigabytes of RAM, and your laptop sounds like a jet engine preparing for takeoff. Dynamic 2D lighting in WebGL looks effortless in celebrated indie web titles, but under the hood, it is a delicate dance between CPU geometry sorting and GPU fragment wrangling.
Here is how real-time dynamic shadows work in the browser, dissecting the two prevailing architectures dominating modern indie web development: Visibility Polygons via Raycasting and 1D Shadow Maps via Fragment Shaders.
What Are 2D Dynamic Shadows in WebGL?
Direct Definition: In 2D WebGL game development, dynamic shadows are real-time lighting calculations that project occlusion volumes from 2D light emitters around static or dynamic geometry (occluders). Developers achieve this either geometrically by constructing a 2D visibility polygon using sorted raycasting, or via the GPU by sampling occluder distance maps rendered into polar coordinate textures.
Dynamic shadows turn a functional web game into an atmospheric playground. Whether you are crafting a claustrophobic web-based stealth game or a neon-drenched retro arcade shooter, dynamic illumination gives players instant spatial awareness and depth perception.
[ Light Source (L) ]
/ | \
/ | \ (Raycasting sweeps 360°)
/ [ Wall ] \
/ / \ \
[Lit Floor] /Shadow\ [Lit Floor]
Approach 1: Visibility Polygons via Raycasting (The Vector Route)
The classical approach popularized by computational geometry tutorials (most notably documented by Amit Patel on Red Blob Games) focuses on building a visibility polygon.
The Core Logic
1. Extract Edges: Collect all line segments representing walls or obstacles in the scene.
2. Sweep Angles: Project rays from the light source to every vertex (endpoint) in the scene. To prevent ray collision bugs, cast three rays per vertex: one directly at the vertex angle $\theta$, one at $\theta - 0.0001$, and one at $\theta + 0.0001$.
3. Ray-Line Intersections: Calculate the nearest intersection for each ray against all wall segments.
4. Sort and Fan: Sort the intersection points circularly by angle around the light source.
5. Draw the Mesh: Send these sorted points to WebGL as a TRIANGLE_FAN or convert them into a standard index buffer. Everything inside the fan is lit; everything outside sits in shadow.
// Sorting ray intersections by angle around the light origin
points.sort((a, b) => {
const angleA = Math.atan2(a.y - light.y, a.x - light.x);
const angleB = Math.atan2(b.y - light.y, b.x - light.x);
return angleA - angleB;
});
Strengths & Trade-offs
- The Good: Razor-sharp edges, infinite resolution scaling, and perfectly crisp silhouettes regardless of zoom level.
- The Bad: The CPU bears the brunt of the work. If your scene contains 300 wall segments and four moving torches, your naive raycasting checks will hit $O(N^2)$ checks per frame, choking Javascript's single-threaded runtime. You need spatial partitioning (like a Quadtree or BVH) to keep frame rates locked at 60 FPS.
Approach 2: 1D Shadow Maps via Fragment Shaders (The GPU Route)
If you follow gamedev discussions on GitHub or technical YouTube breakdowns, you will see a rapid shift towards polar coordinate 1D shadow mapping. This pipeline offloads everything to the GPU, operating similarly to 3D shadow mapping, but squashed down into two dimensions.
The Pipeline Steps
1. Occlusion Buffer: Render your scene’s colliders as solid black silhouettes against a white background onto an off-screen framebuffer (Render Texture).
2. Polar Reduction (Shadow Map): Run a fragment shader that samples this texture and unrolls it into a 1D strip (e.g., $1024 \times 1$ pixels). The horizontal axis represents the angle ($\theta \in [0, 2\pi]$), and the stored pixel value represents the normalized distance to the nearest occluder.
3. Lighting Pass: When rendering the light circle, the fragment shader reads its own angle relative to the light source, samples the 1D shadow map, and compares its distance to the stored blocker distance. If the fragment is further away than the occluder, it shades it dark.
// Fragment Shader: Lighting pass sampling a 1D shadow map
precision mediump float;
varying vec2 vUV;
uniform sampler2D uShadowMap; // 1D distance lookup texture
uniform vec2 uLightPos;
void main() {
vec2 offset = vUV - vec2(0.5);
float distanceToLight = length(offset);
float angle = atan(offset.y, offset.x);
// Normalise angle from [-PI, PI] to [0.0, 1.0] UV space
float normAngle = (angle / 6.2831853) + 0.5;
float closestBlocker = texture2D(uShadowMap, vec2(normAngle, 0.5)).r;
if (distanceToLight > closestBlocker) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.6); // Cast shadow
} else {
float attenuation = 1.0 - smoothstep(0.0, 0.5, distanceToLight);
gl_FragColor = vec4(vec3(attenuation), 1.0); // Smooth falloff
}
}
Technical Comparison: Which Method Fits Your Game?
| Architectural Factor | Visibility Polygon (Raycasting) | 1D Polar Shadow Map (Fragment Shader) |
|---|---|---|
| Primary Workload | CPU (Maths & ray intersections) | GPU (Rasterization & render passes) |
| Shadow Edges | Mathematically sharp | Pixellated unless blurred / filtered |
| Penumbra / Soft Shadows | Difficult (requires multi-source blending) | Trivial (box blur or Poisson disk filtering) |
| Complex Geometry | Scales poorly without Quadtrees | Handled easily (can cast shadows from detailed sprites) |
| Draw Call Overhead | Low (single custom mesh draw) | Moderate (requires multi-pass framebuffers) |
| Ideal Game Genre | Tile-based top-down roguelikes | Fast action shooters, complex organic shapes |
Practical Optimisation Tips for the Browser
- Avoid Context Thrashing: Do not swap framebuffer bindings more than necessary. If your game has eight light sources, pack all light computations into a pooled render pass rather than creating and destroying WebGL framebuffers mid-loop.
- Embrace Soft Penumbras: Pure black, hard-edged shadows tend to look harsh. If using visibility polygons, assign vertex colours with fading alphas to produce smooth outward gradients. If using shaders, add a simple 5-tap Gaussian blur over your 1D shadow map.
- Keep Wall Segment Counts Sane: For raycasting, merge contiguous tilemap grid edges into continuous line segments during map generation. Turning four adjacent walls into one continuous line reduces your ray checks by up to 75%.
Building 2D dynamic shadows in WebGL is one of the most rewarding milestones in browser game development. Choose raycasting if you want vector-sharp precision with zero texture overhead, or embrace polar fragment shaders if you want moody, atmospheric soft shadows with complex sprite geometry. Either way, your flat 2D world will never look dull again.