Lighting and Shading in 2D

Learning Objectives: By the end of this lesson, you will be able to:

  • Simulate realistic 2D lighting effects using shaders
  • Implement gradient-based shading techniques for depth illusion
  • Create convincing specular highlights in 2D environments
  • Understand the mathematical principles behind 2D lighting models

1. Welcome and Introduction (5 minutes)

Icebreaker Activity

Have you ever noticed how a flat drawing can suddenly look three-dimensional with the right shading? Think about classic 2D games like Street Fighter or modern indie games like Hollow Knight - they create incredible depth and atmosphere despite being fundamentally “flat.” The secret is in how they simulate lighting! Even though 2D graphics don’t have actual depth, we can trick the eye into perceiving volume, mood, and dimensionality through clever lighting techniques. What’s your favorite 2D game that creates amazing atmosphere through lighting?

Lesson Overview

  • Duration: 90 minutes
  • Prerequisites: Understanding of textures, basic shader syntax, vector mathematics
  • Materials Needed: Shader development environment, sample sprite textures, normal map examples

2. Learning Objectives and Session Outline (5 minutes)

What We’ll Cover Today:

  1. 2D Lighting Fundamentals - How lighting works in flat graphics
  2. Gradient-Based Shading - Creating depth through color transitions
  3. Point Light Systems - Simulating light sources in 2D space
  4. Specular Highlights - Adding surface reflection effects
  5. Atmospheric Lighting - Ambient lighting and mood creation

Success Criteria:

By the end of this session, you should be able to implement basic 2D lighting systems, create convincing shading effects, and understand how to use lighting to enhance the visual appeal of 2D graphics.

3. Foundation Knowledge (15-20 minutes)

Core Concepts

2D Lighting vs 3D Lighting

Unlike 3D graphics where lighting calculations use surface normals and actual geometry, 2D lighting is primarily about creating the illusion of depth and volume on flat surfaces.

Real-World Analogy: Think of 2D lighting like stage lighting in theater. The actors (sprites) are real 3D objects, but they’re typically lit from the front to create specific moods and focus attention. Similarly, 2D lighting creates atmosphere and directs the player’s attention while working within the constraints of flat graphics.

Key Differences:

  • 3D Lighting: Uses surface normals, geometry depth, and complex light transport
  • 2D Lighting: Uses position-based calculations, gradient overlays, and artistic interpretation
  • 2D Advantage: More artistic control, predictable results, lower computational cost

Types of 2D Lighting

Ambient Lighting:

  • Uniform illumination across the entire scene
  • Sets the overall mood and base brightness
  • Can be colored to create different atmospheres (warm sunset, cold moonlight)

Directional Lighting:

  • Simulates distant light sources (sun, moon)
  • Creates consistent shadows and highlights across the scene
  • Often combined with gradient shading

Point Lighting:

  • Light sources with specific positions (torches, lamps, magic spells)
  • Intensity decreases with distance (attenuation)
  • Can cast colored light and create dynamic effects

Spot Lighting:

  • Directional light with limited spread (flashlights, laser beams)
  • Useful for gameplay mechanics and dramatic effects

Key Terminology

  • Attenuation: The decrease in light intensity over distance
  • Diffuse Lighting: Light that scatters evenly across a surface
  • Specular Reflection: Mirror-like reflection that creates highlights
  • Normal Map: A texture that simulates surface detail for lighting calculations
  • Light Falloff: How quickly light intensity decreases with distance
  • Rim Lighting: Lighting that emphasizes the edges of objects

4. Detailed Explanation and Examples (25-30 minutes)

Mathematical Foundations of 2D Lighting

Distance-Based Attenuation

The fundamental principle of 2D point lighting is that light intensity decreases with distance:

// Basic distance attenuation
float distance = length(lightPosition - fragmentPosition);
float attenuation = 1.0 / (1.0 + distance * distance);

This formula ensures that light intensity drops off realistically, following an inverse square law similar to real-world physics.

Gradient-Based Shading Techniques

Linear Gradients:

// Horizontal gradient for directional lighting
float lightIntensity = dot(normalize(lightDirection), vec2(1.0, 0.0));
lightIntensity = lightIntensity * 0.5 + 0.5; // Remap from [-1,1] to [0,1]

Radial Gradients:

// Circular light falloff
vec2 lightVector = lightPosition - fragmentPosition;
float distance = length(lightVector);
float radius = lightRadius;
float intensity = 1.0 - smoothstep(0.0, radius, distance);

Normal-Based Lighting (2.5D)

Even in 2D, we can simulate surface normals to create more convincing lighting:

// Simulated normal for flat surface with edge highlighting
vec2 normal = normalize(fragmentPosition - spriteCenter);
float lightAngle = dot(normal, lightDirection);
float lighting = max(0.0, lightAngle);

Practical Applications

Character Lighting:

  • Rim lighting to make characters stand out from backgrounds
  • Dynamic lighting for magical abilities or power-ups
  • Mood lighting for dramatic cutscenes

Environment Lighting:

  • Day/night cycles with changing ambient light
  • Dungeon exploration with torch-based lighting
  • Weather effects (lightning flashes, sunset glow)

UI and Interface:

  • Button highlighting and interactive feedback
  • Screen transitions with lighting effects
  • Particle systems with light emission

Common Misconceptions

  1. Misconception: “2D lighting is just overlaying gradients” Reality: Effective 2D lighting requires understanding of light behavior and careful mathematical implementation

  2. Misconception: “Normal maps don’t work in 2D graphics” Reality: Normal maps can create convincing surface detail even on flat sprites

  3. Misconception: “2D lighting is always simpler than 3D” Reality: While computationally simpler, creating convincing 2D lighting requires significant artistic skill

5. Live Coding Demonstration (20-25 minutes)

Example 1: Basic Point Light System

// Simple point light shader
precision mediump float;

varying vec2 v_uv;
varying vec2 v_worldPos; // World position of the fragment

uniform sampler2D u_texture;
uniform vec2 u_lightPos;     // Light position in world space
uniform vec3 u_lightColor;   // Light color (RGB)
uniform float u_lightRadius; // Maximum light radius
uniform float u_lightIntensity;

void main() {
    // Sample the base texture
    vec4 texColor = texture2D(u_texture, v_uv);
    
    // Calculate distance from light
    vec2 lightVector = u_lightPos - v_worldPos;
    float distance = length(lightVector);
    
    // Calculate attenuation
    float attenuation = 1.0 - smoothstep(0.0, u_lightRadius, distance);
    attenuation *= u_lightIntensity;
    
    // Apply lighting
    vec3 litColor = texColor.rgb * u_lightColor * attenuation;
    
    gl_FragColor = vec4(litColor, texColor.a);
}

Explanation:

  • v_worldPos: Fragment position in world coordinates (passed from vertex shader)
  • smoothstep(0.0, u_lightRadius, distance): Creates smooth light falloff
  • texColor.rgb * u_lightColor * attenuation: Multiplies base color by light influence

Example 2: Directional Lighting with Gradients

// Directional lighting shader
precision mediump float;

varying vec2 v_uv;
uniform sampler2D u_texture;
uniform vec2 u_lightDirection; // Normalized light direction
uniform vec3 u_lightColor;
uniform float u_ambientStrength;
uniform vec3 u_ambientColor;

void main() {
    vec4 texColor = texture2D(u_texture, v_uv);
    
    // Create pseudo-normal from UV coordinates
    vec2 normal = normalize(v_uv - vec2(0.5, 0.5));
    
    // Calculate directional lighting
    float lightDot = dot(normal, u_lightDirection);
    float diffuse = max(0.0, lightDot);
    
    // Combine ambient and diffuse lighting
    vec3 ambient = u_ambientColor * u_ambientStrength;
    vec3 lighting = ambient + (u_lightColor * diffuse);
    
    vec3 finalColor = texColor.rgb * lighting;
    gl_FragColor = vec4(finalColor, texColor.a);
}

Example 3: Specular Highlights

// Specular highlight shader
precision mediump float;

varying vec2 v_uv;
varying vec2 v_worldPos;

uniform sampler2D u_texture;
uniform vec2 u_lightPos;
uniform vec2 u_viewPos;      // Camera/viewer position
uniform vec3 u_lightColor;
uniform float u_shininess;   // Specular power
uniform float u_specularStrength;

void main() {
    vec4 texColor = texture2D(u_texture, v_uv);
    
    // Light and view vectors
    vec2 lightDir = normalize(u_lightPos - v_worldPos);
    vec2 viewDir = normalize(u_viewPos - v_worldPos);
    
    // Calculate reflection vector (simplified for 2D)
    vec2 reflectDir = reflect(-lightDir, vec2(0.0, 1.0)); // Assume upward normal
    
    // Specular calculation
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), u_shininess);
    vec3 specular = u_specularStrength * spec * u_lightColor;
    
    // Combine with base color
    vec3 finalColor = texColor.rgb + specular;
    gl_FragColor = vec4(finalColor, texColor.a);
}

Example 4: Multi-Light System

// Multiple light sources shader
precision mediump float;

varying vec2 v_uv;
varying vec2 v_worldPos;

uniform sampler2D u_texture;

// Light arrays (up to 4 lights)
uniform vec2 u_lightPositions[4];
uniform vec3 u_lightColors[4];
uniform float u_lightRadii[4];
uniform int u_numLights;

uniform vec3 u_ambientColor;
uniform float u_ambientStrength;

void main() {
    vec4 texColor = texture2D(u_texture, v_uv);
    
    // Start with ambient lighting
    vec3 totalLight = u_ambientColor * u_ambientStrength;
    
    // Add each point light
    for(int i = 0; i < 4; i++) {
        if(i >= u_numLights) break;
        
        vec2 lightVector = u_lightPositions[i] - v_worldPos;
        float distance = length(lightVector);
        
        // Attenuation
        float attenuation = 1.0 - smoothstep(0.0, u_lightRadii[i], distance);
        
        // Add this light's contribution
        totalLight += u_lightColors[i] * attenuation;
    }
    
    vec3 finalColor = texColor.rgb * totalLight;
    gl_FragColor = vec4(finalColor, texColor.a);
}

Debugging Demonstration

// Debug lighting calculations by visualizing intermediate values
precision mediump float;

varying vec2 v_uv;
varying vec2 v_worldPos;
uniform vec2 u_lightPos;
uniform float u_lightRadius;

void main() {
    // Visualize distance from light
    float distance = length(u_lightPos - v_worldPos);
    float normalizedDistance = distance / u_lightRadius;
    
    // Show distance as grayscale (white = close, black = far)
    vec3 debugColor = vec3(1.0 - normalizedDistance);
    
    gl_FragColor = vec4(debugColor, 1.0);
}

6. Hands-On Practice (30-40 minutes)

Exercise 1: Basic Point Light (10 minutes)

Objective: Create a simple point light that illuminates a textured sprite

Instructions:

  1. Implement distance-based attenuation
  2. Add color tinting to the light
  3. Make the light position controllable via uniforms

Starter Code:

precision mediump float;

varying vec2 v_uv;
varying vec2 v_worldPos;

uniform sampler2D u_texture;
uniform vec2 u_lightPos;
// Add your uniform declarations here

void main() {
    vec4 texColor = texture2D(u_texture, v_uv);
    
    // Calculate lighting here
    
    gl_FragColor = vec4(/* your result */, texColor.a);
}

Success Criteria:

  • Light affects texture brightness based on distance
  • Light has a colored tint
  • Light smoothly fades to darkness at edges

Exercise 2: Ambient + Directional Lighting (15 minutes)

Objective: Combine ambient lighting with directional lighting for realistic shading

Requirements:

  • Implement ambient lighting that provides base illumination
  • Add directional lighting that creates gradual shading
  • Allow control of both ambient strength and light direction
  • Blend the two lighting types effectively

Challenges:

  • Make the light direction animatable (rotating sun effect)
  • Add different colors for ambient and directional light
  • Create a “golden hour” lighting preset

Exercise 3: Interactive Multi-Light Scene (15 minutes)

Objective: Create a scene with multiple controllable light sources

Advanced Challenge:

  • Support at least 2-3 different colored lights
  • Each light should have individual radius and intensity controls
  • Lights should blend additively
  • Add one animated light that moves in a pattern

Extension Ideas:

  • Implement different light types (point, directional, spot)
  • Add light flickering effects for torches
  • Create a day/night cycle system

7. Common Pitfalls and Troubleshooting (10 minutes)

Frequent Mistakes

  1. Problem: Lighting appears too harsh or too subtle Cause: Incorrect attenuation curves or missing gamma correction Solution: Use smoothstep() for gentler falloffs and consider gamma-corrected blending

  2. Problem: Multiple lights create unrealistic brightness Cause: Additive blending without proper energy conservation Solution: Normalize light contributions or use more sophisticated blending modes

  3. Problem: Performance drops with multiple lights Cause: Too many lights processed per pixel or inefficient distance calculations Solution: Use light culling, limit active lights, or implement deferred lighting

Debugging Tips

  • Visualize light influence: Render light attenuation as grayscale to debug falloff curves
  • Test with pure colors: Use red, green, blue lights to easily identify blending issues
  • Start simple: Begin with single lights before adding complexity
  • Check coordinate systems: Ensure world positions are correctly transformed

Performance Optimization

// Optimized multi-light shader with early termination
precision mediump float;

varying vec2 v_worldPos;
uniform vec2 u_lightPositions[4];
uniform float u_lightRadii[4];
uniform vec3 u_lightColors[4];

void main() {
    vec3 totalLight = vec3(0.0);
    
    for(int i = 0; i < 4; i++) {
        float distance = length(u_lightPositions[i] - v_worldPos);
        
        // Early termination for distant lights
        if(distance > u_lightRadii[i]) continue;
        
        float attenuation = 1.0 - distance / u_lightRadii[i];
        totalLight += u_lightColors[i] * attenuation;
    }
    
    gl_FragColor = vec4(totalLight, 1.0);
}

8. Best Practices and Professional Tips (5 minutes)

Artistic Guidelines

  • Maintain consistent light direction: Establish a primary light source for scene coherence
  • Use warm/cool contrast: Warm lights with cool shadows create visual interest
  • Consider color temperature: Different light sources have different color characteristics
  • Avoid pure black shadows: Real shadows are rarely completely black

Technical Optimization

// Efficient lighting structure
struct Light {
    vec2 position;
    vec3 color;
    float radius;
    float intensity;
};

// Use uniform buffer objects for many lights
uniform LightData {
    Light lights[MAX_LIGHTS];
    int activeLights;
} u_lightData;

Industry Applications

  • Mobile Games: Simplified lighting models for performance
  • Indie Games: Stylized lighting for artistic expression
  • UI/UX: Subtle lighting effects for depth and hierarchy
  • Web Applications: Interactive lighting for engagement

Future Learning Paths

  • Advanced Topics: Shadow casting, light volumes, global illumination
  • Artistic Studies: Color theory, cinematography, traditional art lighting
  • Technical Depth: Physically-based rendering principles, light transport

9. Summary and Review (5 minutes)

Key Takeaways

  1. 2D lighting creates depth illusion through mathematical simulation of light behavior
  2. Distance-based attenuation is fundamental to realistic point lighting
  3. Gradient techniques can simulate complex surface lighting effects
  4. Multiple light blending requires careful consideration of energy conservation

Quick Review Questions

  1. What is the primary difference between 2D and 3D lighting calculations?
  2. How does the smoothstep() function improve light attenuation?
  3. Why might you use normal maps in 2D graphics?
  4. What are the advantages of 2D lighting over 3D lighting?

Answers:

  1. 2D lighting focuses on position-based calculations and artistic interpretation rather than surface normals and geometry
  2. smoothstep() creates smooth, natural-looking falloff curves instead of harsh linear transitions
  3. Normal maps can simulate surface detail and improve lighting realism even on flat sprites
  4. More artistic control, predictable results, lower computational cost, easier to implement

For Instructors

Teaching Tips

  • Use visual comparisons: Show before/after examples of scenes with and without proper lighting
  • Encourage experimentation: Let students play with different light colors and positions
  • Connect to art principles: Reference traditional art concepts like chiaroscuro and atmospheric perspective
  • Provide performance context: Explain when to use different lighting techniques based on platform constraints

Common Student Questions

  • “How many lights can I use?” Depends on platform and performance requirements; start with 2-4 for mobile
  • “Should I use normal maps for 2D sprites?” For detailed effects, yes; for simple graphics, probably not necessary
  • “How do I make lighting look more artistic?” Study reference material from games and films you admire

Assessment Ideas

  • Lighting mood boards: Students create different atmospheric scenes using lighting
  • Performance challenges: Optimize lighting systems for specific frame rate targets
  • Artistic recreation: Recreate lighting from famous 2D games or artwork

Adaptation Notes

  • For beginners: Focus on single light sources before introducing complexity
  • For advanced students: Introduce shadow casting and more sophisticated light transport
  • Online delivery: Ensure students can share screens to show real-time lighting effects
  • Time management: Theory (25 min), demonstrations (25 min), practice (30 min), review (10 min)