506 lines
18 KiB
Markdown
506 lines
18 KiB
Markdown
# 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<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 스테이지 없음.
|
|
|
|
```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::<crate::shadow::ShadowPassUniform>() 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<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에 추가:**
|
|
```wgsl
|
|
@location(3) light_space_pos: vec4<f32>,
|
|
```
|
|
|
|
**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>) -> 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 적용:**
|
|
```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 사용하는 것들) 영향 없음
|