| name | forge-gobo-spotlight |
| description | Add a projected-texture (gobo/cookie) spotlight with cone falloff, shadow map, and pattern projection to an SDL GPU project |
Add a spotlight with inner/outer cone angles, projected gobo texture, and a
single 2D shadow map. The spotlight's view-projection matrix serves triple
duty — shadow mapping, gobo UV projection, and cone masking. Based on
GPU Lesson 24.
When to use
- You need a focused light source with a defined cone (not omnidirectional)
- You want to project a pattern (window, foliage, abstract) through a light
- You need shadows from a spotlight (simpler than point light cube maps)
- You are building theatrical, cinematic, or stage-style lighting
- You already have an HDR rendering pipeline and Blinn-Phong lighting
Key API calls
SDL_CreateGPUTexture — shadow depth map (D32_FLOAT, 2D, SAMPLER | DEPTH_STENCIL_TARGET)
SDL_CreateGPUTexture — gobo texture (R8G8B8A8_UNORM, 2D, SAMPLER)
SDL_CreateGPUSampler — NEAREST + CLAMP for shadow, LINEAR + CLAMP for gobo
SDL_CreateGPUGraphicsPipeline — shadow pipeline (depth-only, no color targets)
SDL_BeginGPURenderPass — shadow pass with depth-only attachment
SDL_BindGPUFragmentSamplers — bind diffuse + shadow + gobo textures in scene pass
Correct order
- Define spotlight parameters — position, target, inner/outer cone angles, color, intensity
- Compute light view-projection —
mat4_look_at(pos, target, up) + mat4_perspective(2 * outer_angle, 1.0, near, far)
- Create shadow depth texture — D32_FLOAT, same resolution for shadow map (e.g. 1024x1024)
- Create shadow sampler — NEAREST filter, CLAMP_TO_EDGE
- Load gobo texture — grayscale PNG as UNORM (not sRGB), LINEAR sampler, CLAMP_TO_EDGE
- Create shadow pipeline — vertex shader transforms by light_mvp, empty fragment shader, depth-only
- Create scene pipeline — increase
num_samplers to include shadow + gobo (e.g. 3 total)
- Each frame:
a. Shadow pass: render shadow casters from spotlight's perspective (depth-only)
b. Scene pass: bind shadow map + gobo texture, fragment shader applies cone + gobo + shadow
Key concepts
- Spotlight cone:
smoothstep(cos_outer, cos_inner, cos_angle) — cosine decreases with angle, so outer < inner
- Gobo projection: light_vp transforms world pos → clip → NDC → UV for gobo texture sampling
- Triple-duty matrix: the same light_vp handles shadow mapping, gobo UV mapping, and cone bounds
- Single 2D shadow map: a spotlight's perspective frustum captures everything in one pass (unlike cube maps for point lights)
- UNORM gobo texture: the gobo is a linear attenuation mask, not a color — use UNORM to avoid sRGB gamma
- Cone masking via UV bounds: fragments projecting outside [0,1] UV get zero light via
step() functions
Common mistakes
- Using sRGB format for the gobo texture — the gobo is a light attenuation mask sampled linearly. sRGB applies an unwanted gamma curve. Use
R8G8B8A8_UNORM.
- Wrong smoothstep parameter order — cosine is decreasing, so
smoothstep(cos_outer, cos_inner, ...) with outer < inner. Swapping them inverts the cone.
- Forgetting the Y-flip in UV remapping — texture V increases downward; NDC Y increases upward. Always flip:
gobo_uv.y = 1.0 - gobo_uv.y.
- Shadow acne from insufficient bias — add a small constant bias (0.002) when comparing depths in the shadow test.
- Including the light source model as a shadow caster — the searchlight/lamp model sits at the light position and would block its own light. Exclude it from the shadow pass.
- FOV not matching outer cone angle — the shadow/projection FOV must be
2 * outer_angle to cover the full spotlight cone. Too narrow clips shadows; too wide wastes resolution.
Spotlight definition
#define SPOT_INNER_DEG 20.0f
#define SPOT_OUTER_DEG 30.0f
#define SPOT_INTENSITY 5.0f
#define SPOT_NEAR 0.5f
#define SPOT_FAR 30.0f
#define SHADOW_MAP_SIZE 1024
vec3 spot_pos = vec3_create(6.0f, 5.0f, 4.0f);
vec3 spot_target = vec3_create(0.0f, 0.0f, 0.0f);
vec3 spot_up = vec3_create(0.0f, 1.0f, 0.0f);
mat4 light_view = mat4_look_at(spot_pos, spot_target, spot_up);
float outer_rad = SPOT_OUTER_DEG * FORGE_DEG2RAD;
mat4 light_proj = mat4_perspective(2.0f * outer_rad, 1.0f, SPOT_NEAR, SPOT_FAR);
mat4 light_vp = mat4_multiply(light_proj, light_view);
vec3 spot_dir = vec3_normalize(vec3_sub(spot_target, spot_pos));
Shadow depth texture
SDL_GPUTextureCreateInfo ti;
SDL_zero(ti);
ti.type = SDL_GPU_TEXTURETYPE_2D;
ti.format = SDL_GPU_TEXTUREFORMAT_D32_FLOAT;
ti.width = SHADOW_MAP_SIZE;
ti.height = SHADOW_MAP_SIZE;
ti.layer_count_or_depth = 1;
ti.num_levels = 1;
ti.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER
| SDL_GPU_TEXTUREUSAGE_DEPTH_STENCIL_TARGET;
SDL_GPUTexture *shadow_depth = SDL_CreateGPUTexture(device, &ti);
Gobo texture loading
SDL_GPUTextureCreateInfo tex_info;
SDL_zero(tex_info);
tex_info.type = SDL_GPU_TEXTURETYPE_2D;
tex_info.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM;
tex_info.width = w;
tex_info.height = h;
tex_info.layer_count_or_depth = 1;
tex_info.num_levels = 1;
tex_info.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER;
SDL_GPUSamplerCreateInfo si;
SDL_zero(si);
si.min_filter = SDL_GPU_FILTER_LINEAR;
si.mag_filter = SDL_GPU_FILTER_LINEAR;
si.address_mode_u = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
si.address_mode_v = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
si.address_mode_w = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
Fragment shader — spotlight with gobo and shadow
/* Direction from spotlight to fragment. */
float3 to_frag = world_pos - spot_pos;
float dist = length(to_frag);
float3 L_frag = to_frag / dist;
/* Cone falloff. */
float cos_angle = dot(L_frag, normalize(spot_dir));
float cone = smoothstep(cos_outer, cos_inner, cos_angle);
if (cone > 0.0)
{
/* Transform fragment into spotlight clip space. */
float4 light_clip = mul(light_vp, float4(world_pos, 1.0));
float3 light_ndc = light_clip.xyz / light_clip.w;
/* Gobo texture projection — remap NDC to UV space. */
float2 gobo_uv = light_ndc.xy * 0.5 + 0.5;
gobo_uv.y = 1.0 - gobo_uv.y;
/* Cone masking — clamp to spotlight bounds. */
float in_bounds = step(0.0, gobo_uv.x) * step(gobo_uv.x, 1.0) *
step(0.0, gobo_uv.y) * step(gobo_uv.y, 1.0);
/* Sample gobo pattern (grayscale). */
float gobo = gobo_tex.Sample(gobo_smp, gobo_uv).r;
/* Shadow test with PCF. */
float shadow = sample_shadow(light_ndc, texel_size);
/* Blinn-Phong from spotlight. */
float3 L = normalize(spot_pos - world_pos);
float NdotL = max(dot(N, L), 0.0);
float3 H = normalize(L + V);
float NdotH = max(dot(N, H), 0.0);
/* Quadratic attenuation. */
float atten = 1.0 / (1.0 + 0.09 * dist + 0.032 * dist * dist);
total_light += (albedo * NdotL + spec) * cone * gobo * shadow *
in_bounds * atten * spot_intensity * spot_color;
}
Shadow pass — depth-only render
SDL_GPUDepthStencilTargetInfo shadow_depth_info;
SDL_zero(shadow_depth_info);
shadow_depth_info.texture = shadow_depth_texture;
shadow_depth_info.load_op = SDL_GPU_LOADOP_CLEAR;
shadow_depth_info.store_op = SDL_GPU_STOREOP_STORE;
shadow_depth_info.clear_depth = 1.0f;
SDL_GPURenderPass *shadow_pass = SDL_BeginGPURenderPass(
cmd, NULL, 0, &shadow_depth_info);
SDL_BindGPUGraphicsPipeline(shadow_pass, shadow_pipeline);
draw_model_shadow(shadow_pass, cmd, &truck, &truck_mat, &light_vp);
for (int i = 0; i < BOX_COUNT; i++) {
draw_model_shadow(shadow_pass, cmd, &box, &box_mat[i], &light_vp);
}
SDL_EndGPURenderPass(shadow_pass);
Scene pass texture bindings
SDL_GPUTextureSamplerBinding tex_binds[3];
tex_binds[0] = (SDL_GPUTextureSamplerBinding){
.texture = diffuse_tex, .sampler = sampler };
tex_binds[1] = (SDL_GPUTextureSamplerBinding){
.texture = shadow_depth_texture, .sampler = shadow_sampler };
tex_binds[2] = (SDL_GPUTextureSamplerBinding){
.texture = gobo_texture, .sampler = gobo_sampler };
SDL_BindGPUFragmentSamplers(pass, 0, tex_binds, 3);