Files
game_engine/docs/superpowers/plans/2026-03-24-phase4c-normalmap-ibl.md
2026-03-24 21:16:39 +09:00

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 셰이더 변경

  1. VertexInput에 tangent 추가:
@location(3) tangent: vec4<f32>,
  1. VertexOutput에 tangent/bitangent 추가:
@location(4) world_tangent: vec3<f32>,
@location(5) world_bitangent: vec3<f32>,
  1. 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;
  1. 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) 사용.

  1. group(4) IBL 바인딩:
@group(4) @binding(0) var t_brdf_lut: texture_2d<f32>;
@group(4) @binding(1) var s_brdf_lut: sampler;
  1. 프로시저럴 환경 함수:
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);
}
  1. 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_pipelineibl_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 예제에:

  1. create_pbr_pipelineibl_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: 커밋
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)