docs: add Phase 4c normal mapping + IBL implementation plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 21:16:39 +09:00
parent 5f962f376e
commit 88fabf2905

View File

@@ -0,0 +1,599 @@
# Phase 4c: Normal Mapping + Simple IBL 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:** Normal map으로 표면 디테일을 추가하고, 프로시저럴 환경광(간이 IBL) + BRDF LUT로 roughness에 따른 반사 차이를 시각적으로 확인할 수 있게 한다.
**Architecture:** MeshVertex에 tangent 벡터를 추가하여 TBN 행렬을 구성하고, PBR 셰이더에서 normal map을 샘플링한다. IBL은 큐브맵 없이 프로시저럴 sky 함수로 환경광을 계산하고, CPU에서 생성한 BRDF LUT로 split-sum 근사를 수행한다. 나중에 프로시저럴 sky를 실제 HDR 큐브맵으로 교체하면 full IBL이 된다.
**Tech Stack:** Rust 1.94, wgpu 28.0, WGSL
---
## File Structure
```
crates/voltex_renderer/src/
├── vertex.rs # MeshVertex에 tangent 추가 (MODIFY)
├── obj.rs # tangent 계산 추가 (MODIFY)
├── sphere.rs # tangent 계산 추가 (MODIFY)
├── brdf_lut.rs # CPU BRDF LUT 생성 (NEW)
├── ibl.rs # IBL bind group + dummy resources (NEW)
├── pbr_shader.wgsl # normal mapping + IBL (MODIFY)
├── pbr_pipeline.rs # group(4) IBL bind group (MODIFY)
├── shadow_shader.wgsl # vertex layout 변경 반영 (MODIFY)
├── lib.rs # re-export 업데이트 (MODIFY)
examples/
└── ibl_demo/ # Normal map + IBL 데모 (NEW)
├── Cargo.toml
└── src/
└── main.rs
```
---
## Task 1: MeshVertex tangent + 계산
**Files:**
- Modify: `crates/voltex_renderer/src/vertex.rs`
- Modify: `crates/voltex_renderer/src/obj.rs`
- Modify: `crates/voltex_renderer/src/sphere.rs`
- Modify: `crates/voltex_renderer/src/shadow_shader.wgsl`
MeshVertex에 `tangent: [f32; 4]`를 추가 (w=handedness, +1 or -1). OBJ 파서와 sphere 생성기에서 tangent를 계산.
- [ ] **Step 1: vertex.rs — MeshVertex에 tangent 추가**
```rust
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct MeshVertex {
pub position: [f32; 3],
pub normal: [f32; 3],
pub uv: [f32; 2],
pub tangent: [f32; 4], // xyz = tangent direction, w = handedness (+1 or -1)
}
```
LAYOUT에 tangent attribute 추가:
```rust
// location 3, Float32x4, offset after uv
wgpu::VertexAttribute {
offset: (std::mem::size_of::<[f32; 3]>() * 2 + std::mem::size_of::<[f32; 2]>()) as wgpu::BufferAddress,
shader_location: 3,
format: wgpu::VertexFormat::Float32x4,
},
```
- [ ] **Step 2: obj.rs — tangent 계산 추가**
OBJ 파서 후처리로 Mikktspace-like tangent 계산. `parse_obj` 끝에서 삼각형별로 tangent를 계산하고 정점에 누적:
```rust
/// 인덱스 배열의 삼각형들로부터 tangent 벡터를 계산.
/// UV 기반으로 tangent/bitangent를 구하고, 정점에 누적 후 정규화.
pub fn compute_tangents(vertices: &mut [MeshVertex], indices: &[u32]) {
// 각 삼각형에 대해:
// edge1 = v1.pos - v0.pos, edge2 = v2.pos - v0.pos
// duv1 = v1.uv - v0.uv, duv2 = v2.uv - v0.uv
// f = 1.0 / (duv1.x * duv2.y - duv2.x * duv1.y)
// tangent = f * (duv2.y * edge1 - duv1.y * edge2)
// bitangent = f * (-duv2.x * edge1 + duv1.x * edge2)
// 누적 후 정규화, handedness = sign(dot(cross(N, T), B))
}
```
`parse_obj` 끝에서 `compute_tangents(&mut vertices, &indices)` 호출.
- [ ] **Step 3: sphere.rs — tangent 계산 추가**
UV sphere에서 tangent는 해석적으로 계산 가능:
- tangent 방향 = longitude 방향의 접선 (sector angle의 미분)
- `tx = -sin(sector_angle), tz = cos(sector_angle)` (Y-up에서)
- handedness w = 1.0
`generate_sphere`에서 각 정점 생성 시 tangent를 직접 계산.
- [ ] **Step 4: shadow_shader.wgsl — vertex input에 tangent 추가**
shadow shader도 MeshVertex를 사용하므로 VertexInput에 tangent를 추가해야 빌드가 됨:
```wgsl
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) tangent: vec4<f32>,
};
```
vertex shader는 tangent를 사용하지 않고 position만 변환 — 기존과 동일.
- [ ] **Step 5: 빌드 + 테스트**
Run: `cargo build -p voltex_renderer`
Run: `cargo test -p voltex_renderer`
Expected: 기존 테스트 통과. OBJ 테스트에서 tangent 필드가 추가된 MeshVertex를 확인.
참고: 기존 OBJ 테스트는 position/normal/uv만 검증하므로 tangent 추가로 깨지지 않음. sphere 테스트는 vertex count/index count만 확인하므로 OK.
- [ ] **Step 6: 커밋**
```bash
git add crates/voltex_renderer/
git commit -m "feat(renderer): add tangent to MeshVertex with computation in OBJ parser and sphere generator"
```
---
## Task 2: BRDF LUT + IBL 리소스
**Files:**
- Create: `crates/voltex_renderer/src/brdf_lut.rs`
- Create: `crates/voltex_renderer/src/ibl.rs`
- Modify: `crates/voltex_renderer/src/lib.rs`
BRDF LUT는 split-sum 근사의 핵심. NdotV(x축)와 roughness(y축)에 대한 scale+bias를 2D 텍스처로 저장.
- [ ] **Step 1: brdf_lut.rs — CPU에서 BRDF LUT 생성**
```rust
// crates/voltex_renderer/src/brdf_lut.rs
/// BRDF LUT 생성 (256x256, RG16Float or RGBA8 근사)
/// x축 = NdotV (0..1), y축 = roughness (0..1)
/// 출력: (scale, bias) per texel → R=scale, G=bias
pub fn generate_brdf_lut(size: u32) -> Vec<[f32; 2]> {
let mut data = vec![[0.0f32; 2]; (size * size) as usize];
for y in 0..size {
let roughness = (y as f32 + 0.5) / size as f32;
for x in 0..size {
let n_dot_v = (x as f32 + 0.5) / size as f32;
let (scale, bias) = integrate_brdf(n_dot_v.max(0.001), roughness.max(0.001));
data[(y * size + x) as usize] = [scale, bias];
}
}
data
}
/// Hammersley sequence (low-discrepancy)
fn radical_inverse_vdc(mut bits: u32) -> f32 {
bits = (bits << 16) | (bits >> 16);
bits = ((bits & 0x55555555) << 1) | ((bits & 0xAAAAAAAA) >> 1);
bits = ((bits & 0x33333333) << 2) | ((bits & 0xCCCCCCCC) >> 2);
bits = ((bits & 0x0F0F0F0F) << 4) | ((bits & 0xF0F0F0F0) >> 4);
bits = ((bits & 0x00FF00FF) << 8) | ((bits & 0xFF00FF00) >> 8);
bits as f32 * 2.3283064365386963e-10 // 1/2^32
}
fn hammersley(i: u32, n: u32) -> [f32; 2] {
[i as f32 / n as f32, radical_inverse_vdc(i)]
}
/// GGX importance sampling
fn importance_sample_ggx(xi: [f32; 2], roughness: f32) -> [f32; 3] {
let a = roughness * roughness;
let phi = 2.0 * std::f32::consts::PI * xi[0];
let cos_theta = ((1.0 - xi[1]) / (1.0 + (a * a - 1.0) * xi[1])).sqrt();
let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
[phi.cos() * sin_theta, phi.sin() * sin_theta, cos_theta]
}
/// Numerical integration of BRDF for given NdotV and roughness
fn integrate_brdf(n_dot_v: f32, roughness: f32) -> (f32, f32) {
let v = [
(1.0 - n_dot_v * n_dot_v).sqrt(), // sin
0.0,
n_dot_v, // cos
];
let n = [0.0f32, 0.0, 1.0];
let mut scale = 0.0f32;
let mut bias = 0.0f32;
let sample_count = 1024u32;
for i in 0..sample_count {
let xi = hammersley(i, sample_count);
let h = importance_sample_ggx(xi, roughness);
// L = 2 * dot(V, H) * H - V
let v_dot_h = (v[0] * h[0] + v[1] * h[1] + v[2] * h[2]).max(0.0);
let l = [
2.0 * v_dot_h * h[0] - v[0],
2.0 * v_dot_h * h[1] - v[1],
2.0 * v_dot_h * h[2] - v[2],
];
let n_dot_l = l[2].max(0.0); // dot(N, L) where N = (0,0,1)
let n_dot_h = h[2].max(0.0);
if n_dot_l > 0.0 {
let g = geometry_smith_ibl(n_dot_v, n_dot_l, roughness);
let g_vis = (g * v_dot_h) / (n_dot_h * n_dot_v).max(0.001);
let fc = (1.0 - v_dot_h).powi(5);
scale += g_vis * (1.0 - fc);
bias += g_vis * fc;
}
}
(scale / sample_count as f32, bias / sample_count as f32)
}
fn geometry_smith_ibl(n_dot_v: f32, n_dot_l: f32, roughness: f32) -> f32 {
let a = roughness;
let k = (a * a) / 2.0; // IBL uses k = a^2/2 (not (a+1)^2/8)
let g1 = n_dot_v / (n_dot_v * (1.0 - k) + k);
let g2 = n_dot_l / (n_dot_l * (1.0 - k) + k);
g1 * g2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_brdf_lut_dimensions() {
let lut = generate_brdf_lut(64);
assert_eq!(lut.len(), 64 * 64);
}
#[test]
fn test_brdf_lut_values_in_range() {
let lut = generate_brdf_lut(32);
for [scale, bias] in &lut {
assert!(*scale >= 0.0 && *scale <= 1.5, "scale out of range: {}", scale);
assert!(*bias >= 0.0 && *bias <= 1.5, "bias out of range: {}", bias);
}
}
#[test]
fn test_hammersley() {
let h = hammersley(0, 16);
assert_eq!(h[0], 0.0);
}
}
```
- [ ] **Step 2: ibl.rs — IBL 리소스 관리**
```rust
// crates/voltex_renderer/src/ibl.rs
use crate::brdf_lut::generate_brdf_lut;
pub const BRDF_LUT_SIZE: u32 = 256;
/// IBL 리소스 (BRDF LUT 텍스처)
pub struct IblResources {
pub brdf_lut_texture: wgpu::Texture,
pub brdf_lut_view: wgpu::TextureView,
pub brdf_lut_sampler: wgpu::Sampler,
}
impl IblResources {
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
// Generate BRDF LUT on CPU
let lut_data = generate_brdf_lut(BRDF_LUT_SIZE);
// Convert [f32; 2] to RGBA8 (R=scale*255, G=bias*255, B=0, A=255)
let mut pixels = vec![0u8; (BRDF_LUT_SIZE * BRDF_LUT_SIZE * 4) as usize];
for (i, [scale, bias]) in lut_data.iter().enumerate() {
pixels[i * 4] = (scale.clamp(0.0, 1.0) * 255.0) as u8;
pixels[i * 4 + 1] = (bias.clamp(0.0, 1.0) * 255.0) as u8;
pixels[i * 4 + 2] = 0;
pixels[i * 4 + 3] = 255;
}
let size = wgpu::Extent3d {
width: BRDF_LUT_SIZE,
height: BRDF_LUT_SIZE,
depth_or_array_layers: 1,
};
let brdf_lut_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("BRDF LUT"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm, // NOT sRGB
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &brdf_lut_texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * BRDF_LUT_SIZE),
rows_per_image: Some(BRDF_LUT_SIZE),
},
size,
);
let brdf_lut_view = brdf_lut_texture.create_view(&wgpu::TextureViewDescriptor::default());
let brdf_lut_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("BRDF LUT Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
brdf_lut_texture,
brdf_lut_view,
brdf_lut_sampler,
}
}
/// IBL bind group layout (group 4)
/// binding 0: BRDF LUT texture
/// binding 1: BRDF LUT sampler
pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("IBL 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::Float { filterable: true },
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
})
}
pub fn create_bind_group(
&self,
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("IBL Bind Group"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&self.brdf_lut_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.brdf_lut_sampler),
},
],
})
}
}
```
- [ ] **Step 3: lib.rs 업데이트**
```rust
pub mod brdf_lut;
pub mod ibl;
pub use ibl::IblResources;
```
- [ ] **Step 4: 테스트 통과**
Run: `cargo test -p voltex_renderer`
Expected: 기존 + brdf_lut 3개 PASS
- [ ] **Step 5: 커밋**
```bash
git add crates/voltex_renderer/
git commit -m "feat(renderer): add BRDF LUT generator and IBL resources"
```
---
## Task 3: PBR 셰이더 Normal Map + IBL 통합
**Files:**
- Modify: `crates/voltex_renderer/src/pbr_shader.wgsl`
- Modify: `crates/voltex_renderer/src/pbr_pipeline.rs`
### PBR 셰이더 변경
1. **VertexInput에 tangent 추가:**
```wgsl
@location(3) tangent: vec4<f32>,
```
2. **VertexOutput에 tangent/bitangent 추가:**
```wgsl
@location(4) world_tangent: vec3<f32>,
@location(5) world_bitangent: vec3<f32>,
```
3. **vs_main에서 TBN 계산:**
```wgsl
let T = normalize((camera.model * vec4<f32>(model_v.tangent.xyz, 0.0)).xyz);
let B = cross(out.world_normal, T) * model_v.tangent.w;
out.world_tangent = T;
out.world_bitangent = B;
```
4. **group(1) 확장 — normal map texture 추가:**
```wgsl
@group(1) @binding(2) var t_normal: texture_2d<f32>;
@group(1) @binding(3) var s_normal: sampler;
```
기존 group(1) bind group layout도 normal map 바인딩을 추가해야 함. 하지만 이것은 기존 GpuTexture의 layout을 변경하는 것이라 영향이 큼.
**대안:** normal map을 material bind group(group 2)에 추가하거나, 별도 bind group 사용.
**가장 간단한 접근:** group(1)에 normal map 추가. texture bind group layout을 확장. 기존 예제에서는 normal map에 1x1 "flat blue" 텍스처 ((128, 128, 255, 255) = (0,0,1) normal) 사용.
5. **group(4) IBL 바인딩:**
```wgsl
@group(4) @binding(0) var t_brdf_lut: texture_2d<f32>;
@group(4) @binding(1) var s_brdf_lut: sampler;
```
6. **프로시저럴 환경 함수:**
```wgsl
fn sample_environment(direction: vec3<f32>, roughness: f32) -> vec3<f32> {
let t = direction.y * 0.5 + 0.5;
let sky = mix(vec3<f32>(0.05, 0.05, 0.08), vec3<f32>(0.3, 0.5, 0.9), t);
let horizon = vec3<f32>(0.6, 0.6, 0.5);
let ground = vec3<f32>(0.1, 0.08, 0.06);
var env: vec3<f32>;
if direction.y > 0.0 {
env = mix(horizon, sky, pow(direction.y, 0.4));
} else {
env = mix(horizon, ground, pow(-direction.y, 0.4));
}
// Roughness → blur (lerp toward average)
let avg = (sky + horizon + ground) / 3.0;
return mix(env, avg, roughness * roughness);
}
```
7. **fs_main에서 IBL ambient 교체:**
기존:
```wgsl
let ambient = lights_uniform.ambient_color * albedo * ao;
```
새:
```wgsl
let NdotV = max(dot(N, V), 0.0);
let R = reflect(-V, N);
// Diffuse IBL
let irradiance = sample_environment(N, 1.0);
let diffuse_ibl = kd_ambient * albedo * irradiance;
// Specular IBL
let prefiltered = sample_environment(R, roughness);
let brdf_sample = textureSample(t_brdf_lut, s_brdf_lut, vec2<f32>(NdotV, roughness));
let F_env = F0 * brdf_sample.r + vec3<f32>(brdf_sample.g);
let specular_ibl = prefiltered * F_env;
let ambient = (diffuse_ibl + specular_ibl) * ao;
```
여기서 `kd_ambient = (1.0 - F_env_avg) * (1.0 - metallic)` — 에너지 보존.
### PBR 파이프라인 변경
`create_pbr_pipeline``ibl_layout: &wgpu::BindGroupLayout` 파라미터 추가:
```rust
pub fn create_pbr_pipeline(
device, format,
camera_light_layout,
texture_layout, // group(1): now includes normal map
material_layout,
shadow_layout,
ibl_layout, // NEW: group(4)
) -> wgpu::RenderPipeline
```
bind_group_layouts: `&[camera_light, texture, material, shadow, ibl]`
### Texture bind group layout 확장
GpuTexture::bind_group_layout을 수정하거나 새 함수를 만들어 normal map도 포함하도록:
```rust
// 기존 (bindings 0-1): albedo texture + sampler
// 새로 추가 (bindings 2-3): normal map texture + sampler
```
기존 예제 호환을 위해 `texture_with_normal_bind_group_layout(device)` 새 함수를 만들고, 기존 `bind_group_layout`은 유지.
- [ ] **Step 1: pbr_shader.wgsl 수정**
- [ ] **Step 2: pbr_pipeline.rs 수정**
- [ ] **Step 3: texture.rs에 normal map 포함 bind group layout 추가**
- [ ] **Step 4: 빌드 확인**`cargo build -p voltex_renderer`
- [ ] **Step 5: 커밋**
```bash
git add crates/voltex_renderer/
git commit -m "feat(renderer): add normal mapping and procedural IBL to PBR shader"
```
---
## Task 4: 기존 예제 수정 + IBL Demo
**Files:**
- Modify: `examples/pbr_demo/src/main.rs`
- Modify: `examples/multi_light_demo/src/main.rs`
- Modify: `examples/shadow_demo/src/main.rs`
- Create: `examples/ibl_demo/Cargo.toml`
- Create: `examples/ibl_demo/src/main.rs`
- Modify: `Cargo.toml`
### 기존 예제 수정
모든 PBR 예제에:
1. `create_pbr_pipeline``ibl_layout` 파라미터 추가
2. IblResources 생성, IBL bind group 생성
3. Normal map: "flat blue" 1x1 텍스처 (128, 128, 255, 255) 사용
4. texture bind group에 normal map 추가
5. render pass에 IBL bind group (group 4) 설정
### ibl_demo
7x7 구체 그리드 (pbr_demo와 유사하지만 IBL 효과가 보임):
- metallic X축, roughness Y축
- IBL이 켜져 있으므로 roughness 차이가 확연히 보임
- smooth metallic 구체는 환경을 반사, rough 구체는 blurry 반사
- Camera: (0, 0, 12)
반드시 읽어야 할 파일: pbr_demo/src/main.rs (기반), shadow_demo/src/main.rs (shadow bind group 패턴)
- [ ] **Step 1: 기존 예제 수정 (pbr_demo, multi_light_demo, shadow_demo)**
- [ ] **Step 2: ibl_demo 작성**
- [ ] **Step 3: 빌드 + 테스트**
- [ ] **Step 4: 실행 확인**`cargo run -p ibl_demo`
- [ ] **Step 5: 커밋**
```bash
git add Cargo.toml examples/
git commit -m "feat: add IBL demo with normal mapping and procedural environment lighting"
```
---
## Phase 4c 완료 기준 체크리스트
- [ ] `cargo build --workspace` 성공
- [ ] `cargo test --workspace` — 모든 테스트 통과
- [ ] MeshVertex에 tangent 포함, OBJ/sphere에서 자동 계산
- [ ] PBR 셰이더: TBN 행렬로 normal map 샘플링
- [ ] BRDF LUT: CPU 생성, 256x256 텍스처
- [ ] 프로시저럴 IBL: sky gradient + roughness-based blur
- [ ] `cargo run -p ibl_demo` — roughness 차이가 시각적으로 확연히 보임
- [ ] 기존 예제 모두 동작 (flat normal map + IBL)