diff --git a/docs/superpowers/plans/2026-03-24-phase4b2-shadows.md b/docs/superpowers/plans/2026-03-24-phase4b2-shadows.md new file mode 100644 index 0000000..c6e1fa3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-phase4b2-shadows.md @@ -0,0 +1,505 @@ +# 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 + +```rust +// 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만 기록. + +```wgsl +// crates/voltex_renderer/src/shadow_shader.wgsl + +struct ShadowPassUniform { + light_vp_model: mat4x4, +}; + +@group(0) @binding(0) var shadow_pass: ShadowPassUniform; + +struct VertexInput { + @location(0) position: vec3, + @location(1) normal: vec3, + @location(2) uv: vec2, +}; + +@vertex +fn vs_main(model_v: VertexInput) -> @builtin(position) vec4 { + return shadow_pass.light_vp_model * vec4(model_v.position, 1.0); +} +``` + +### shadow_pipeline.rs + +Depth-only 렌더 파이프라인. Fragment 스테이지 없음. + +```rust +// 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::() as u64, + ), + }, + count: None, + }, + ], + }) +} +``` + +### lib.rs 업데이트 + +```rust +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: 커밋** + +```bash +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):** +```wgsl +struct ShadowUniform { + light_view_proj: mat4x4, + 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 shadow: ShadowUniform; +``` + +**VertexOutput에 추가:** +```wgsl +@location(3) light_space_pos: vec4, +``` + +**Vertex shader에서 light space position 계산:** +```wgsl +out.light_space_pos = shadow.light_view_proj * world_pos; +``` + +**Shadow sampling 함수:** +```wgsl +fn calculate_shadow(light_space_pos: vec4) -> 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( + 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(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 적용:** +```wgsl +// 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 추가: +```rust +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: 커밋** + +```bash +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, 위에서 비스듬히 + +렌더링 루프: +1. 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 } + +2. 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 투영 함수가 필요: +```rust +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: 커밋** + +```bash +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 사용하는 것들) 영향 없음