19 KiB
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 추가
#[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 추가:
// 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를 계산하고 정점에 누적:
/// 인덱스 배열의 삼각형들로부터 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를 추가해야 빌드가 됨:
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: 커밋
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 생성
// 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 리소스 관리
// 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 업데이트
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: 커밋
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 셰이더 변경
- VertexInput에 tangent 추가:
@location(3) tangent: vec4<f32>,
- VertexOutput에 tangent/bitangent 추가:
@location(4) world_tangent: vec3<f32>,
@location(5) world_bitangent: vec3<f32>,
- vs_main에서 TBN 계산:
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;
- group(1) 확장 — normal map texture 추가:
@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) 사용.
- group(4) IBL 바인딩:
@group(4) @binding(0) var t_brdf_lut: texture_2d<f32>;
@group(4) @binding(1) var s_brdf_lut: sampler;
- 프로시저럴 환경 함수:
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);
}
- fs_main에서 IBL ambient 교체:
기존:
let ambient = lights_uniform.ambient_color * albedo * ao;
새:
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 파라미터 추가:
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도 포함하도록:
// 기존 (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: 커밋
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 예제에:
create_pbr_pipeline에ibl_layout파라미터 추가- IblResources 생성, IBL bind group 생성
- Normal map: "flat blue" 1x1 텍스처 (128, 128, 255, 255) 사용
- texture bind group에 normal map 추가
- 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: 커밋
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)