Skip to main content

Lighting Systems

Illuminating Your Game World

Bring your games to life with dynamic lighting! Master different light types, implement real-time shadows, and create atmospheric effects using advanced lighting techniques like normal mapping and deferred rendering! ๐Ÿ’ก๐ŸŒŸ๐Ÿ”ฆ

Understanding Lighting

๐Ÿ’ก The Light Physics Analogy

Think of lighting like how light works in the real world:

Four spheres in a row showing how lighting decomposes into three additive terms. Left sphere: uniform dim blue, labelled Ambient with the caption constant base. Second sphere: smooth blue gradient, bright at the upper-left and fading to dark, labelled Diffuse with the formula max of N dot L. Third sphere: dark with a small bright white highlight at the upper-left, labelled Specular with the formula max of R dot V to the n. Fourth sphere combines all three: ambient floor with diffuse falloff and a specular peak, labelled Final shading. A sun glyph at the upper-left with a directional ray indicates the light source.
Lighting on a surface decomposes into three additive terms: ambient (constant base from indirect light), diffuse (cosine of the angle between surface normal and light direction), and specular (a sharp highlight where view and reflection align). The PBR fragment shader below evaluates a more elaborate version of the same idea โ€” Lambertian diffuse modulated by Fresnel, and Cook-Torrance microfacet specular โ€” but the conceptual decomposition is the same.

Lighting Implementation in Python/ModernGL

import moderngl
import numpy as np
import pygame
from pygame.locals import *
import math
from typing import Any

class LightingSystem:
    """Advanced lighting system for 3D games"""
    def __init__(self, ctx: moderngl.Context) -> None:
        self.ctx: moderngl.Context = ctx
        self.lights: list["Light"] = []
        self.max_lights: int = 32
        
        # Create lighting shader
        self.shader: moderngl.Program = self.create_lighting_shader()
        
    def create_lighting_shader(self) -> moderngl.Program:
        """Create advanced lighting shader with PBR support"""
        vertex_shader = '''
        #version 330
        
        in vec3 in_position;
        in vec3 in_normal;
        in vec2 in_texcoord;
        in vec3 in_tangent;
        
        out vec3 v_position;
        out vec3 v_normal;
        out vec2 v_texcoord;
        out vec3 v_tangent;
        out vec3 v_bitangent;
        out vec3 v_world_pos;
        out vec4 v_shadow_coord;
        
        uniform mat4 u_model;
        uniform mat4 u_view;
        uniform mat4 u_projection;
        uniform mat4 u_light_space_matrix;
        
        void main() {
            vec4 world_pos = u_model * vec4(in_position, 1.0);
            v_world_pos = world_pos.xyz;
            v_position = (u_view * world_pos).xyz;
            
            // Transform normals
            mat3 normal_matrix = transpose(inverse(mat3(u_model)));
            v_normal = normalize(normal_matrix * in_normal);
            v_tangent = normalize(normal_matrix * in_tangent);
            v_bitangent = cross(v_normal, v_tangent);
            
            v_texcoord = in_texcoord;
            
            // Shadow mapping
            v_shadow_coord = u_light_space_matrix * world_pos;
            
            gl_Position = u_projection * u_view * world_pos;
        }
        '''
        
        fragment_shader = '''
        #version 330
        
        in vec3 v_position;
        in vec3 v_normal;
        in vec2 v_texcoord;
        in vec3 v_tangent;
        in vec3 v_bitangent;
        in vec3 v_world_pos;
        in vec4 v_shadow_coord;
        
        out vec4 f_color;
        
        // Material properties
        uniform vec3 u_albedo;
        uniform float u_metallic;
        uniform float u_roughness;
        uniform float u_ao;
        
        // Camera
        uniform vec3 u_camera_pos;
        
        // Lights
        struct Light {
            int type; // 0=dir, 1=point, 2=spot
            vec3 position;
            vec3 direction;
            vec3 color;
            float intensity;
            float range;
        };
        
        uniform Light u_lights[32];
        uniform int u_light_count;
        uniform vec3 u_ambient_color;
        
        const float PI = 3.14159265359;
        
        // PBR calculations
        vec3 calculatePBR(vec3 albedo, float metallic, float roughness,
                         vec3 normal, vec3 viewDir, vec3 lightDir, 
                         vec3 radiance) {
            vec3 halfwayDir = normalize(viewDir + lightDir);
            
            // Fresnel
            vec3 F0 = mix(vec3(0.04), albedo, metallic);
            float cosTheta = max(dot(halfwayDir, viewDir), 0.0);
            vec3 F = F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
            
            // Distribution
            float NDF = pow(max(dot(normal, halfwayDir), 0.0), 2.0 / (roughness * roughness) - 2.0);
            NDF /= PI * roughness * roughness;
            
            // Geometry
            float k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
            float NdotV = max(dot(normal, viewDir), 0.0);
            float NdotL = max(dot(normal, lightDir), 0.0);
            float G = (NdotV / (NdotV * (1.0 - k) + k)) * 
                     (NdotL / (NdotL * (1.0 - k) + k));
            
            // BRDF
            vec3 numerator = NDF * G * F;
            float denominator = 4.0 * NdotV * NdotL + 0.001;
            vec3 specular = numerator / denominator;
            
            vec3 kS = F;
            vec3 kD = vec3(1.0) - kS;
            kD *= 1.0 - metallic;
            
            return (kD * albedo / PI + specular) * radiance * NdotL;
        }
        
        void main() {
            vec3 normal = normalize(v_normal);
            vec3 viewDir = normalize(u_camera_pos - v_world_pos);
            
            vec3 color = vec3(0.0);
            
            // Process each light
            for(int i = 0; i < u_light_count; i++) {
                Light light = u_lights[i];
                
                vec3 lightDir;
                float attenuation = 1.0;
                
                if(light.type == 0) { // Directional
                    lightDir = normalize(-light.direction);
                } else if(light.type == 1) { // Point
                    lightDir = normalize(light.position - v_world_pos);
                    float distance = length(light.position - v_world_pos);
                    attenuation = 1.0 / (1.0 + 0.09 * distance + 
                                       0.032 * distance * distance);
                } else if(light.type == 2) { // Spot
                    lightDir = normalize(light.position - v_world_pos);
                    float distance = length(light.position - v_world_pos);
                    float theta = dot(lightDir, normalize(-light.direction));
                    float intensity = smoothstep(0.9, 0.95, theta);
                    attenuation = intensity / (distance * distance);
                }
                
                vec3 radiance = light.color * light.intensity * attenuation;
                
                color += calculatePBR(u_albedo, u_metallic, u_roughness,
                                     normal, viewDir, lightDir, radiance);
            }
            
            // Ambient lighting
            vec3 ambient = u_ambient_color * u_albedo * u_ao;
            color += ambient;
            
            // Tone mapping and gamma correction
            color = color / (color + vec3(1.0));
            color = pow(color, vec3(1.0/2.2));
            
            f_color = vec4(color, 1.0);
        }
        '''
        
        return self.ctx.program(
            vertex_shader=vertex_shader,
            fragment_shader=fragment_shader
        )

class Light:
    """Base light class"""
    def __init__(self, light_type: str = 'point') -> None:
        self.type: str = light_type
        self.position: np.ndarray = np.array([0.0, 10.0, 0.0], dtype='f4')
        self.direction: np.ndarray = np.array([0.0, -1.0, 0.0], dtype='f4')
        self.color: np.ndarray = np.array([1.0, 1.0, 1.0], dtype='f4')
        self.intensity: float = 1.0
        self.range: float = 50.0
        self.cast_shadow: bool = False

class DirectionalLight(Light):
    """Directional light (sun/moon)"""
    def __init__(self) -> None:
        super().__init__('directional')
        self.direction = np.array([-0.5, -0.7, -0.5], dtype='f4')
        self.cast_shadow = True

class PointLight(Light):
    """Point light source"""
    def __init__(self, position: Any) -> None:
        super().__init__('point')
        self.position = np.array(position, dtype='f4')
        self.attenuation: np.ndarray = np.array([1.0, 0.09, 0.032], dtype='f4')

class SpotLight(Light):
    """Spot light source"""
    def __init__(self, position: Any, direction: Any) -> None:
        super().__init__('spot')
        self.position = np.array(position, dtype='f4')
        self.direction = np.array(direction, dtype='f4')
        self.inner_cone: float = math.radians(30)
        self.outer_cone: float = math.radians(45)

Shadow Mapping

from typing import Any

class ShadowMapper:
    """Shadow mapping system"""
    def __init__(self, ctx: moderngl.Context, size: int = 2048) -> None:
        self.ctx: moderngl.Context = ctx
        self.size: int = size
        
        # Create shadow map texture
        self.shadow_texture: moderngl.Texture = ctx.depth_texture((size, size))
        self.shadow_fbo: moderngl.Framebuffer = ctx.framebuffer(
            depth_attachment=self.shadow_texture
        )
        
        # Shadow pass shader
        self.shadow_shader: moderngl.Program = self.create_shadow_shader()
    
    def create_shadow_shader(self) -> moderngl.Program:
        """Create depth-only shader for shadow mapping"""
        vertex_shader = '''
        #version 330
        
        in vec3 in_position;
        
        uniform mat4 u_light_space_matrix;
        uniform mat4 u_model;
        
        void main() {
            gl_Position = u_light_space_matrix * u_model * vec4(in_position, 1.0);
        }
        '''
        
        fragment_shader = '''
        #version 330
        
        void main() {
            // OpenGL handles depth writing automatically
        }
        '''
        
        return self.ctx.program(
            vertex_shader=vertex_shader,
            fragment_shader=fragment_shader
        )
    
    def render_shadow_map(self, scene: Any, light: Any) -> None:
        """Render scene from light's perspective"""
        self.shadow_fbo.use()
        self.ctx.viewport = (0, 0, self.size, self.size)
        self.shadow_fbo.clear()
        
        # Render all shadow-casting objects
        for obj in scene.objects:
            if obj.cast_shadow:
                self.shadow_shader['u_model'].value = obj.model_matrix
                obj.render(self.shadow_shader)
        
        # Reset viewport
        self.ctx.screen.use()
Three-section comparison of single-tap versus nine-tap shadow sampling. Left section: a three by three grid of depth-map texels around a fragment, with five cells lit (pale yellow, marked 1) and four cells in shadow (deep slate, marked 0); the center cell is outlined in teal as the fragment being shaded. Middle section: an arrow with the calculation zero plus zero plus one plus zero plus one plus one plus zero plus one plus one divided by nine equals 0.56, the soft fractional shadow value. Right section: two horizontal swatches comparing edges. The top swatch (1-tap, hard) snaps abruptly from dark to light at a single boundary marked with a dashed amber line. The bottom swatch (9-tap, soft) transitions smoothly through a gradient โ€” the same scene rendered with a soft penumbra.
The depth-only shader above gives you one comparison per fragment: either fully in shadow or fully lit, which produces hard, jagged shadow edges. Percentage Closer Filtering (PCF) extends that test by sampling the center texel plus its 8 neighbours and averaging the 9 binary results. The fractional value softens the boundary into a penumbra a few texels wide โ€” a small per-fragment cost for a much more believable shadow.

Best Practices

โšก Lighting Tips

Key Takeaways

๐Ÿ‹๏ธโ€โ™‚๏ธ Practice Exercise

๐Ÿ‹๏ธโ€โ™‚๏ธ Exercise 1: Four Lights, One Pixel โ€” Multi-Light Additive Composition, Per-Light-Type Falloff, Ambient Floor in One Pygame Window

Objective: Build a runnable pygame program (~95 lines) that distills the lesson's GLSL fragment-shader pipeline into a 2D pygame demo so each architectural discipline is visible per frame. The window is 1088ร—480 split into a 768ร—480 lit world (a gray checkerboard ground that serves as the diffuse layer) and a 320px sidebar. Four light sources composit additively into a per-pixel lightmap surface that is then BLEND_RGB_MULT-blended onto the diffuse layer to produce the final lit scene โ€” the 2D analog of the lesson's GLSL fragment-shader main() that sums per-light radiance into one accumulator and then multiplies by albedo. Three orthogonal multi-light disciplines are visible per frame: (a) Multi-light additive composition โ€” the lightmap starts at the AMBIENT base color (e.g., (15, 18, 28) cool moonlight) and each enabled light contributes via pygame.BLEND_RGB_ADD, the exact 2D analog of the lesson's `for(int i = 0; i < u_light_count; i++) color += calculatePBR(...)` summing each light into the same accumulator. Light is additive at per-pixel scope (two flashlights on the same wall give brighter wall, not 'red OR blue chosen by priority'), so the right composition operator is sum, not max-of-N or priority-pick. (b) Per-light-type falloff function โ€” the demo runs four kinds of light, each with the lesson's matching falloff: AMBIENT is a constant RGB floor (no distance term, no direction term โ€” it just IS), DIRECTIONAL SUN provides constant illumination across the entire scene (a pre-tinted full-window fill, no falloff because the sun is 'infinitely far' so all pixels see the same intensity), POINT LIGHTS use the lesson's exact attenuation `1.0 / (1.0 + 0.018*d + 0.0008*d*d)` (the lesson's 0.09 / 0.032 constants retuned for 2D pixel scale where 1 unit โ‰ˆ 1 pixel), pre-rendered once at startup as circular RGB splats blitted at light position with BLEND_RGB_ADD, and SPOT LIGHT combines the lesson's cone-angle smoothstep `intensity = smoothstep(0.9, 0.95, theta)` (where theta = dot(light_dir_to_pixel, light_facing_dir)) with point-light distance attenuation, rebuilt per frame because facing tracks the mouse cursor relative to the WASD-controlled player. Different falloff functions encode different physical situations โ€” candle/torch is point, sun is directional, flashlight is spot โ€” and the lesson's shader literally branches on `light.type == 0/1/2` to apply the right one, mirrored here by four separate code paths each gated on a toggle flag. (c) Ambient term as constant base added LAST, not multiplied โ€” the lightmap starts filled with AMBIENT before any other light is added, modeling the lesson's `color += ambient` as the LAST line of the fragment-shader main() (after the per-light loop). Ambient is the 'sky scatter / indirect bounce' floor that prevents pure-black areas where no point/spot light reaches, providing visual reference (the world is still visible outside any light's range) AND a global mood knob (cool dim ambient = moonlit night, warm bright ambient = sunny day, near-zero ambient = dungeon-with-explicit-lights atmosphere). Adding ambient (color += ambient) keeps it independent of direct-light intensity; multiplying ambient against the per-light sum would couple the two so that doubling ambient also doubles every direct light โ€” the additive form is what makes ambient a clean designer-tunable floor without side effects on the lights. Keys 1/2/3/4/5 toggle each light type ON/OFF independently so each discipline's contribution is directly visible: turn AMBIENT off and outside areas snap to pure black; turn DIRECTIONAL off and the global brightness drops uniformly; turn POINT 1 off and the warm-orange pool disappears; turn POINT 2 off and the cool-blue pool disappears; turn SPOT off and the mouse-aimed cone is gone. WASD moves the player (the spot light's origin); the mouse cursor sets the spot's facing direction. Sidebar shows: keybinding hints, live RGB values sampled from the lightmap at the cursor position (the per-pixel additive sum visible as concrete numbers), per-light enabled/disabled checkboxes, player position, and FPS. Cross-references chat-64 particle_effects (additive composition shape โ€” chat-64 had N short-lived particles each contributing +RGB to a temp surface with BLEND_RGB_ADD; chat-65 has M long-lived light sources each contributing +RGB to a lightmap with the same BLEND_RGB_ADD operator at frame-stable per-light scope), chat-49 polish_tweening (the smoothstep cone gate for the spot light is the lesson's GLSL `smoothstep(edge0, edge1, x)` applied to dot-product cosine โ€” same easing-on-t shape as polish_tweening's eased-progress reshape, applied at per-pixel cone-falloff scope rather than per-tween-progress scope), chat-46 platformer_camera (the lesson's quadratic point-light attenuation `1 / (1 + Kl*d + Kq*dยฒ)` is the same approach-toward-a-target shape as platformer_camera's smooth-follow exponential decay โ€” both produce the visual feel of 'falls off as distance grows' via a smooth math function rather than a hard step), and chat-43 game_mathematics_vectors (the spot light's facing direction comes from `face = (mouse - player).normalize()` and theta = facing.dot(light_dir_to_pixel) is the same dot-product-as-front/behind-classifier from the chat-43 vectors lesson, applied here at cone-membership scope where theta > 0.9 means 'pixel is within ~26ยฐ of the spot's facing direction'). OPENS the graphics module 0/5 โ†’ 1/5 partial at chat-65 M1 (4 graphics lessons remain: postprocessing / procedural / shaders / ui_hud); module-completeness stays 10/13 since graphics doesn't close in one chat.

Instructions:

  1. Set up the 1088ร—480 pygame window (768ร—480 lit world + 320ร—480 sidebar) with a 60 FPS clock and a small monospace font for the sidebar HUD.
  2. Pre-render the gray checkerboard ground surface at module scope (32-pixel cells alternating between two gray shades) so the lightmap has a diffuse layer to multiply against โ€” the visible cell pattern proves the lighting is multiplicative, not just being drawn directly to screen.
  3. Pre-render two POINT LIGHT splats (warm orange (255, 130, 60) and cool blue (60, 140, 255)) at startup as 400ร—400 RGB surfaces using a per-pixel loop that evaluates the lesson's quadratic falloff `1.0 / (1.0 + 0.018*d + 0.0008*d*d)` (with a small floor subtraction so the splat fades to 0 at the radius edge); set_colorkey((0, 0, 0)) so transparent border pixels don't pollute the BLEND_RGB_ADD blits.
  4. Each frame, build the lightmap surface fresh: fill with AMBIENT (or pure black if ambient is toggled off), then BLEND_RGB_ADD-blit the directional sun fill (a pre-tinted full-window surface that adds a constant warmth to every pixel), then BLEND_RGB_ADD-blit each enabled point light splat at its position, then BLEND_RGB_ADD-blit the spot light surface.
  5. The spot light surface is rebuilt per frame because facing tracks the mouse: compute face = (mouse - player).normalize(), then loop over the spot's bounding box and set each pixel via cone = smoothstep(0.9, 0.95, theta) where theta = dot(facing, pixel_direction_from_player), multiplied by the same point-light distance attenuation โ€” only pixels with theta >= 0.9 (within the cone) and d < SPOT_RAD (within range) light up.
  6. Composite the final scene by copying the ground surface and BLEND_RGB_MULT-blitting the lightmap onto it: lightmap RGB values in [0, 255] become a per-channel multiplier where 255 means 'full diffuse pass-through' and 0 means 'pure black'. The lit ground is then blitted to (0, 0) on the main screen.
  7. Render the sidebar at (WORLD_W, 0): keybinding hints, the lightmap RGB value sampled at the cursor (proving the additive sum is concrete), per-light enable/disable lines, player position, and FPS.
  8. Toggle each light independently (1=ambient, 2=sun, 3=point1, 4=point2, 5=spot) and verify the contribution is visually isolable: ambient off makes outside areas pure black; sun off drops global brightness; each point light off removes its colored pool; spot off removes the mouse-aimed cone.
๐Ÿ’ก Hint

The pre-rendered point-light splat is the key efficiency move: per-pixel attenuation math runs once at startup (a 400ร—400 = 160k cell loop that takes < 1 second), and each frame the splat is just a fast BLEND_RGB_ADD blit. The spot light has to rebuild per frame because facing changes, but using a 2-pixel stride in the bounding-box loop (range(0, SPOT_RAD*2, 2) and pygame.draw.rect for 2ร—2 cells) keeps it under 1ms per frame. For the cone gate, smoothstep(edge0, edge1, x) is `t = clamp((x - edge0) / (edge1 - edge0), 0, 1); return t*t*(3 - 2*t)` โ€” the standard cubic Hermite smoothstep from the GLSL spec that the lesson's shader uses verbatim. Use BLEND_RGB_ADD (not BLEND_ADD which is a pre-pygame-2 alias) for the lightmap composition and BLEND_RGB_MULT for the final groundโ€‰ร—โ€‰lightmap product. The sample swatch in the sidebar (a small rect filled with the cursor's lightmap color) is a good sanity check โ€” hover over a point-light's center and the swatch shows that light's color saturated; hover over an unlit area with only ambient enabled and the swatch shows the dim ambient floor.

โœ… Example Solution
import math, pygame
pygame.init()

WORLD_W, WORLD_H, SIDE_W = 768, 480, 320
SCREEN_W = WORLD_W + SIDE_W
screen = pygame.display.set_mode((SCREEN_W, WORLD_H))
clock = pygame.time.Clock()
font = pygame.font.SysFont('consolas', 13)

# --- Lesson's GLSL falloff translated to 2D Python ---
def point_attenuation(d: float) -> float:
    return 1.0 / (1.0 + 0.018 * d + 0.0008 * d * d)

def make_point_splat(radius: int, color: tuple[int, int, int]) -> pygame.Surface:
    surf = pygame.Surface((radius * 2, radius * 2))
    surf.set_colorkey((0, 0, 0))
    for x in range(radius * 2):
        for y in range(radius * 2):
            d = math.hypot(x - radius, y - radius)
            if d > radius: continue
            a = max(0.0, point_attenuation(d) - 0.04)
            r = min(255, int(color[0] * a))
            g = min(255, int(color[1] * a))
            b = min(255, int(color[2] * a))
            if r or g or b: surf.set_at((x, y), (r, g, b))
    return surf

AMBIENT = (15, 18, 28)
SUN = (50, 42, 28)
P1_POS, P1_RAD = (240, 200), 200
P2_POS, P2_RAD = (560, 320), 200
SPOT_RAD = 240
P1 = make_point_splat(P1_RAD, (255, 130, 60))
P2 = make_point_splat(P2_RAD, (60, 140, 255))

ground = pygame.Surface((WORLD_W, WORLD_H))
for ty in range(0, WORLD_H, 32):
    for tx in range(0, WORLD_W, 32):
        c = 110 if (tx // 32 + ty // 32) % 2 == 0 else 80
        pygame.draw.rect(ground, (c, c, c), (tx, ty, 32, 32))

flags = {'ambient': True, 'sun': True, 'p1': True, 'p2': True, 'spot': True}
player = pygame.Vector2(380, 240)
running = True
while running:
    dt = clock.tick(60) / 1000.0
    mx, my = pygame.mouse.get_pos()
    for ev in pygame.event.get():
        if ev.type == pygame.QUIT: running = False
        if ev.type == pygame.KEYDOWN:
            if ev.key == pygame.K_1: flags['ambient'] = not flags['ambient']
            if ev.key == pygame.K_2: flags['sun'] = not flags['sun']
            if ev.key == pygame.K_3: flags['p1'] = not flags['p1']
            if ev.key == pygame.K_4: flags['p2'] = not flags['p2']
            if ev.key == pygame.K_5: flags['spot'] = not flags['spot']
    keys = pygame.key.get_pressed()
    spd = 200 * dt
    if keys[pygame.K_a]: player.x -= spd
    if keys[pygame.K_d]: player.x += spd
    if keys[pygame.K_w]: player.y -= spd
    if keys[pygame.K_s]: player.y += spd
    player.x = max(0, min(WORLD_W, player.x))
    player.y = max(0, min(WORLD_H, player.y))

    lightmap = pygame.Surface((WORLD_W, WORLD_H))
    lightmap.fill(AMBIENT if flags['ambient'] else (0, 0, 0))
    if flags['sun']:
        sun = pygame.Surface((WORLD_W, WORLD_H)); sun.fill(SUN)
        lightmap.blit(sun, (0, 0), special_flags=pygame.BLEND_RGB_ADD)
    if flags['p1']:
        lightmap.blit(P1, (P1_POS[0] - P1_RAD, P1_POS[1] - P1_RAD), special_flags=pygame.BLEND_RGB_ADD)
    if flags['p2']:
        lightmap.blit(P2, (P2_POS[0] - P2_RAD, P2_POS[1] - P2_RAD), special_flags=pygame.BLEND_RGB_ADD)
    if flags['spot']:
        spot = pygame.Surface((SPOT_RAD * 2, SPOT_RAD * 2))
        face = pygame.Vector2(mx - player.x, my - player.y)
        if face.length() > 0:
            face = face.normalize()
            for x in range(0, SPOT_RAD * 2, 2):
                for y in range(0, SPOT_RAD * 2, 2):
                    dx, dy = x - SPOT_RAD, y - SPOT_RAD
                    d = math.hypot(dx, dy)
                    if d > SPOT_RAD or d < 1: continue
                    theta = (dx / d) * face.x + (dy / d) * face.y
                    if theta < 0.9: continue
                    t = min(1.0, max(0.0, (theta - 0.9) / 0.05))
                    cone = t * t * (3 - 2 * t)
                    a = max(0.0, point_attenuation(d) - 0.04) * cone
                    pygame.draw.rect(spot, (min(255, int(255*a)), min(255, int(240*a)), min(255, int(220*a))), (x, y, 2, 2))
        spot.set_colorkey((0, 0, 0))
        lightmap.blit(spot, (int(player.x) - SPOT_RAD, int(player.y) - SPOT_RAD), special_flags=pygame.BLEND_RGB_ADD)

    final = ground.copy()
    final.blit(lightmap, (0, 0), special_flags=pygame.BLEND_RGB_MULT)
    screen.fill((10, 10, 12))
    screen.blit(final, (0, 0))
    pygame.draw.rect(screen, (25, 28, 36), (WORLD_W, 0, SIDE_W, WORLD_H))
    sx, sy = max(0, min(WORLD_W - 1, mx)), max(0, min(WORLD_H - 1, my))
    sample = lightmap.get_at((sx, sy))[:3]
    lines = [
        '1/2/3/4/5 toggle ambient/sun/p1/p2/spot',
        'WASD: player    Mouse: spot facing',
        '',
        'Lightmap @ cursor RGB: %3d %3d %3d' % sample,
        '',
        '[%s] ambient   base RGB floor' % ('X' if flags['ambient'] else ' '),
        '[%s] direct sun constant fill' % ('X' if flags['sun'] else ' '),
        '[%s] point 1   warm orange' % ('X' if flags['p1'] else ' '),
        '[%s] point 2   cool blue' % ('X' if flags['p2'] else ' '),
        '[%s] spot      mouse-aimed' % ('X' if flags['spot'] else ' '),
        '',
        'Player: %d, %d' % (player.x, player.y),
        'FPS: %d' % int(clock.get_fps()),
    ]
    for i, line in enumerate(lines):
        screen.blit(font.render(line, True, (220, 225, 235)), (WORLD_W + 16, 16 + i * 18))
    pygame.draw.rect(screen, sample, (WORLD_W + 16, 320, 200, 32))
    pygame.draw.rect(screen, (90, 95, 110), (WORLD_W + 16, 320, 200, 32), 1)
    pygame.display.flip()

pygame.quit()

๐ŸŽฏ Quick Quiz

Question 1: In the lesson's GLSL fragment shader, the main() function loops over u_lights[0..u_light_count] and writes `color += calculatePBR(...)` inside the loop โ€” each light's computed contribution is added into the SAME `color` accumulator that will be written to the framebuffer. The 2D pygame demo mirrors this with BLEND_RGB_ADD blits onto a shared lightmap surface. Which statement most accurately describes WHY this additive composition is the right operator for combining N light contributions into one final per-pixel result, rather than (for example) taking the maximum of the N contributions or sorting lights by intensity and picking the top one?

Question 2: The lesson's GLSL fragment shader contains an `if (light.type == 0) ... else if (light.type == 1) ... else if (light.type == 2) ...` branch that applies a different per-light-type formula: directional lights use `lightDir = normalize(-light.direction)` with attenuation = 1.0; point lights use `attenuation = 1.0 / (1.0 + 0.09*distance + 0.032*distance*distance)`; spot lights additionally multiply by `intensity = smoothstep(0.9, 0.95, theta)` where theta is the dot product of light direction and cone-axis direction. The 2D pygame demo encodes the same three-way distinction via separate code paths for ambient/sun/point/spot. Which statement most accurately describes WHY the per-light-type branching exists in the shader, rather than (for example) a single universal falloff function applied to every light?

Question 3: The lesson's GLSL fragment shader main() ends with two final lines AFTER the per-light loop: `vec3 ambient = u_ambient_color * u_albedo * u_ao;` then `color += ambient;`. The 2D pygame demo mirrors this with `lightmap.fill(AMBIENT)` as the very first step before any direct-light blit, with key 1 toggling ambient ON/OFF. Which statement most accurately describes WHY ambient is added (color += ambient) rather than (for example) being applied only when no direct light reached a pixel, or being multiplied with the direct-light sum?

What's Next?

Now that you understand lighting systems, next we'll explore post-processing effects to add the final polish to your visuals!