18 KiB
Phase 4b-2: Directional Light Shadow Map Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Directional Light에서 단일 Shadow Map을 생성하고, PBR 셰이더에서 그림자를 샘플링하여 PCF 소프트 섀도우를 렌더링한다.
Architecture: 2-pass 렌더링: (1) Shadow pass — 라이트 시점의 orthographic 투영으로 씬을 depth-only 텍스처에 렌더링, (2) Color pass — 기존 PBR 렌더링 + shadow map 샘플링. ShadowMap 구조체가 depth 텍스처와 라이트 VP 행렬을 관리. PBR 셰이더의 새 bind group(3)으로 shadow map + shadow uniform을 전달. 3x3 PCF로 소프트 섀도우.
Tech Stack: Rust 1.94, wgpu 28.0, WGSL
File Structure
crates/voltex_renderer/src/
├── shadow.rs # ShadowMap, ShadowUniform, shadow depth texture (NEW)
├── shadow_shader.wgsl # Depth-only vertex shader for shadow pass (NEW)
├── shadow_pipeline.rs # Depth-only render pipeline (NEW)
├── pbr_shader.wgsl # Shadow sampling + PCF 추가 (MODIFY)
├── pbr_pipeline.rs # group(3) shadow bind group 추가 (MODIFY)
├── lib.rs # re-export 업데이트 (MODIFY)
examples/
└── shadow_demo/ # 섀도우 데모 (NEW)
├── Cargo.toml
└── src/
└── main.rs
Task 1: ShadowMap + Shadow Depth Shader + Shadow Pipeline
Files:
- Create:
crates/voltex_renderer/src/shadow.rs - Create:
crates/voltex_renderer/src/shadow_shader.wgsl - Create:
crates/voltex_renderer/src/shadow_pipeline.rs - Modify:
crates/voltex_renderer/src/lib.rs
shadow.rs
// crates/voltex_renderer/src/shadow.rs
use bytemuck::{Pod, Zeroable};
pub const SHADOW_MAP_SIZE: u32 = 2048;
pub const SHADOW_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// Shadow map에 필요한 GPU 리소스
pub struct ShadowMap {
pub texture: wgpu::Texture,
pub view: wgpu::TextureView,
pub sampler: wgpu::Sampler,
}
impl ShadowMap {
pub fn new(device: &wgpu::Device) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shadow Map"),
size: wgpu::Extent3d {
width: SHADOW_MAP_SIZE,
height: SHADOW_MAP_SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: SHADOW_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
// Comparison sampler for hardware-assisted shadow comparison
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shadow Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
compare: Some(wgpu::CompareFunction::LessEqual),
..Default::default()
});
Self { texture, view, sampler }
}
/// Shadow bind group layout (group 3)
/// binding 0: shadow depth texture (comparison)
/// binding 1: shadow comparison sampler
/// binding 2: ShadowUniform (light VP + params)
pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shadow Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
view_dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Depth,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
})
}
pub fn create_bind_group(
&self,
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
shadow_uniform_buffer: &wgpu::Buffer,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shadow Bind Group"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&self.view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: shadow_uniform_buffer.as_entire_binding(),
},
],
})
}
}
/// Shadow pass에 필요한 uniform (light view-projection 행렬)
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct ShadowUniform {
pub light_view_proj: [[f32; 4]; 4],
pub shadow_map_size: f32,
pub shadow_bias: f32,
pub _padding: [f32; 2],
}
impl ShadowUniform {
pub fn new() -> Self {
Self {
light_view_proj: [
[1.0,0.0,0.0,0.0],
[0.0,1.0,0.0,0.0],
[0.0,0.0,1.0,0.0],
[0.0,0.0,0.0,1.0],
],
shadow_map_size: SHADOW_MAP_SIZE as f32,
shadow_bias: 0.005,
_padding: [0.0; 2],
}
}
}
/// Shadow pass용 per-object uniform (light VP * model)
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct ShadowPassUniform {
pub light_vp_model: [[f32; 4]; 4],
}
shadow_shader.wgsl
Shadow pass에서 사용하는 depth-only 셰이더. Fragment output 없음 — depth만 기록.
// crates/voltex_renderer/src/shadow_shader.wgsl
struct ShadowPassUniform {
light_vp_model: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> shadow_pass: ShadowPassUniform;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
};
@vertex
fn vs_main(model_v: VertexInput) -> @builtin(position) vec4<f32> {
return shadow_pass.light_vp_model * vec4<f32>(model_v.position, 1.0);
}
shadow_pipeline.rs
Depth-only 렌더 파이프라인. Fragment 스테이지 없음.
// crates/voltex_renderer/src/shadow_pipeline.rs
use crate::vertex::MeshVertex;
use crate::shadow::SHADOW_FORMAT;
pub fn create_shadow_pipeline(
device: &wgpu::Device,
shadow_pass_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shadow Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shadow_shader.wgsl").into()),
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Shadow Pipeline Layout"),
bind_group_layouts: &[shadow_pass_layout],
immediate_size: 0,
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shadow Pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[MeshVertex::LAYOUT],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: None, // depth-only
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Front), // front-face culling reduces peter-panning
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: SHADOW_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState {
constant: 2, // depth bias 로 shadow acne 방지
slope_scale: 2.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
})
}
/// Shadow pass용 bind group layout (group 0: ShadowPassUniform)
pub fn shadow_pass_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shadow Pass BGL"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: true,
min_binding_size: wgpu::BufferSize::new(
std::mem::size_of::<crate::shadow::ShadowPassUniform>() as u64,
),
},
count: None,
},
],
})
}
lib.rs 업데이트
pub mod shadow;
pub mod shadow_pipeline;
pub use shadow::{ShadowMap, ShadowUniform, ShadowPassUniform, SHADOW_MAP_SIZE, SHADOW_FORMAT};
pub use shadow_pipeline::{create_shadow_pipeline, shadow_pass_bind_group_layout};
- Step 1: 위 파일들 모두 작성
- Step 2: 빌드 확인 —
cargo build -p voltex_renderer - Step 3: 커밋
git add crates/voltex_renderer/
git commit -m "feat(renderer): add ShadowMap, shadow depth shader, and shadow pipeline"
Task 2: PBR 셰이더 + 파이프라인에 Shadow 통합
Files:
- Modify:
crates/voltex_renderer/src/pbr_shader.wgsl - Modify:
crates/voltex_renderer/src/pbr_pipeline.rs
PBR 셰이더에 group(3) shadow bind group을 추가하고, directional light의 그림자를 PCF로 샘플링.
pbr_shader.wgsl 변경
기존 코드에 다음을 추가:
Uniforms (group 3):
struct ShadowUniform {
light_view_proj: mat4x4<f32>,
shadow_map_size: f32,
shadow_bias: f32,
};
@group(3) @binding(0) var t_shadow: texture_depth_2d;
@group(3) @binding(1) var s_shadow: sampler_comparison;
@group(3) @binding(2) var<uniform> shadow: ShadowUniform;
VertexOutput에 추가:
@location(3) light_space_pos: vec4<f32>,
Vertex shader에서 light space position 계산:
out.light_space_pos = shadow.light_view_proj * world_pos;
Shadow sampling 함수:
fn calculate_shadow(light_space_pos: vec4<f32>) -> f32 {
// Perspective divide
let proj_coords = light_space_pos.xyz / light_space_pos.w;
// NDC → shadow map UV: x [-1,1]→[0,1], y [-1,1]→[0,1] (flip y)
let shadow_uv = vec2<f32>(
proj_coords.x * 0.5 + 0.5,
-proj_coords.y * 0.5 + 0.5,
);
let current_depth = proj_coords.z;
// Out of shadow map bounds → no shadow
if shadow_uv.x < 0.0 || shadow_uv.x > 1.0 || shadow_uv.y < 0.0 || shadow_uv.y > 1.0 {
return 1.0;
}
if current_depth > 1.0 || current_depth < 0.0 {
return 1.0;
}
// 3x3 PCF
let texel_size = 1.0 / shadow.shadow_map_size;
var shadow_val = 0.0;
for (var x = -1; x <= 1; x++) {
for (var y = -1; y <= 1; y++) {
let offset = vec2<f32>(f32(x), f32(y)) * texel_size;
shadow_val += textureSampleCompare(
t_shadow, s_shadow,
shadow_uv + offset,
current_depth - shadow.shadow_bias,
);
}
}
return shadow_val / 9.0;
}
Fragment shader에서 directional light에 shadow 적용:
// compute_light_contribution 안에서, directional light일 때만:
// radiance *= shadow_factor
실제로는 compute_light_contribution 함수에 shadow_factor 파라미터를 추가하거나, fragment shader에서 directional light(첫 번째 라이트)에만 shadow를 적용.
가장 간단한 접근: fs_main에서 라이트 루프 전에 shadow를 계산하고, directional light(type==0)의 contribution에만 곱함.
pbr_pipeline.rs 변경
create_pbr_pipeline의 bind_group_layouts에 shadow layout 추가:
bind_group_layouts: &[camera_light_layout, texture_layout, material_layout, shadow_layout],
함수 시그니처에 shadow_layout: &wgpu::BindGroupLayout 파라미터 추가.
주의: 이 변경은 기존 pbr_demo, multi_light_demo 예제에 영향을 줌. 해당 예제들은 Task 3에서 shadow bind group을 추가해야 함. 또는 "no shadow" 용 dummy bind group을 사용.
- Step 1: pbr_shader.wgsl 수정 — shadow uniforms, vertex output, sampling, PCF
- Step 2: pbr_pipeline.rs 수정 — shadow bind group layout 추가
- Step 3: 빌드 확인 —
cargo build -p voltex_renderer - Step 4: 커밋
git add crates/voltex_renderer/
git commit -m "feat(renderer): integrate shadow map sampling with PCF into PBR shader"
Task 3: 기존 예제 수정 + Shadow Demo
Files:
- Modify:
examples/pbr_demo/src/main.rs— dummy shadow bind group 추가 - Modify:
examples/multi_light_demo/src/main.rs— dummy shadow bind group 추가 - Create:
examples/shadow_demo/Cargo.toml - Create:
examples/shadow_demo/src/main.rs - Modify:
Cargo.toml(워크스페이스에 shadow_demo 추가)
pbr_demo, multi_light_demo 수정
PBR pipeline이 이제 shadow bind group(group 3)을 요구하므로, shadow가 필요 없는 예제에는 dummy shadow bind group을 제공:
- 1x1 depth texture (값 1.0 = 그림자 없음)
- 또는 ShadowMap::new()로 생성 후 모든 depth = 1.0인 상태로 둠 (cleared가 0이면 모두 그림자 → 안 됨)
더 간단한 접근: ShadowMap을 생성하되, shadow pass를 실행하지 않음. shadow map은 초기값(cleared) 상태. PBR 셰이더의 calculate_shadow에서 current_depth > 1.0이면 shadow=1.0(그림자 없음)을 반환하므로, clear된 상태면 그림자 없는 것과 동일.
실제로는 depth texture clear value가 0.0이므로 비교 시 모든 곳이 그림자가 됨. 이를 방지하려면:
- shadow uniform의 light_view_proj를 identity로 두면 모든 점의 z가 양수(~원래 위치)가 되어 depth=0과 비교 시 항상 "밝음"이 됨
또는 더 간단하게: shadow bias를 매우 크게 설정 (10.0) → 항상 밝게.
가장 깔끔한 해결: shader에서 shadow.shadow_map_size == 0.0이면 shadow 비활성(return 1.0). Dummy에서 shadow_map_size=0으로 설정.
shadow_demo
장면:
- 바닥 평면: 큰 큐브 (scale 15x0.1x15), y=-0.5, roughness 0.8
- 구체 3개 + 큐브 2개: 바닥 위에 배치
- Directional Light: 방향 (-1, -2, -1) normalized, 위에서 비스듬히
렌더링 루프:
-
Shadow pass:
- 라이트 VP 행렬 계산:
Mat4::look_at(light_pos, target, up) * Mat4::orthographic(...) - 필요: orthographic 투영 함수 (Mat4에 추가하거나 inline으로)
- Shadow pipeline으로 모든 오브젝트를 shadow map에 렌더링
- per-object: ShadowPassUniform { light_vp * model }
- 라이트 VP 행렬 계산:
-
Color pass:
- ShadowUniform { light_view_proj, shadow_map_size, shadow_bias } write
- PBR pipeline으로 렌더링 (shadow bind group 포함)
카메라: (5, 8, 12), pitch=-0.4
필요: Mat4::orthographic 추가
voltex_math의 Mat4에 orthographic 투영 함수가 필요:
pub fn orthographic(left: f32, right: f32, bottom: f32, top: f32, near: f32, far: f32) -> Self
wgpu NDC (z: [0,1]):
col0: [2/(r-l), 0, 0, 0]
col1: [0, 2/(t-b), 0, 0]
col2: [0, 0, 1/(near-far), 0] // note: reversed for wgpu z[0,1]
col3: [-(r+l)/(r-l), -(t+b)/(t-b), near/(near-far), 1]
이것은 shadow_demo에서 inline으로 구현하거나, voltex_math의 Mat4에 추가할 수 있다. Mat4에 추가하는 것이 재사용성이 좋다.
- Step 1: voltex_math/mat4.rs에 orthographic 추가 + 테스트
- Step 2: pbr_demo, multi_light_demo에 dummy shadow bind group 추가
- Step 3: shadow_demo 작성
- Step 4: 빌드 + 테스트
- Step 5: 실행 확인 —
cargo run -p shadow_demo - Step 6: 커밋
git add Cargo.toml crates/voltex_math/ examples/
git commit -m "feat: add shadow demo with directional light shadow mapping and PCF"
Phase 4b-2 완료 기준 체크리스트
cargo build --workspace성공cargo test --workspace— 모든 테스트 통과- Shadow map: 2048x2048 depth texture, comparison sampler
- Shadow pass: depth-only pipeline, front-face culling, depth bias
- PBR 셰이더: shadow map 샘플링 + 3x3 PCF
cargo run -p shadow_demo— 바닥에 오브젝트 그림자 보임cargo run -p pbr_demo— 그림자 없이 동작 (dummy shadow)cargo run -p multi_light_demo— 그림자 없이 동작- 기존 예제 (mesh_shader 사용하는 것들) 영향 없음