Files
game_engine/docs/superpowers/plans/2026-03-24-phase4b1-multi-light.md
2026-03-24 20:48:06 +09:00

16 KiB

Phase 4b-1: Multi-Light System 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, Point, Spot 3종류의 라이트를 최대 16개 동시 지원하여 PBR 셰이더에서 다중 광원 라이팅을 렌더링한다.

Architecture: 기존 단일 LightUniformLightsUniform으로 교체. GPU에 고정 크기 라이트 배열(MAX_LIGHTS=16)과 활성 라이트 수를 전달. PBR 셰이더에서 라이트 루프를 돌며 각 라이트 타입별로 radiance를 계산. Point Light는 거리 감쇠(inverse square + range clamp), Spot Light는 원뿔 각도 감쇠를 적용.

Tech Stack: Rust 1.94, wgpu 28.0, WGSL


File Structure

crates/voltex_renderer/src/
├── light.rs            # LightData, LightsUniform 교체 (MODIFY)
├── pbr_shader.wgsl     # 다중 라이트 루프 (MODIFY)
├── pbr_pipeline.rs     # 기존 유지 (bind group 변경 없음 — light는 group(0) binding(1) 그대로)
├── lib.rs              # re-export 업데이트 (MODIFY)
examples/
└── multi_light_demo/   # 다중 라이트 데모 (NEW)
    ├── Cargo.toml
    └── src/
        └── main.rs

Task 1: LightData + LightsUniform

Files:

  • Modify: crates/voltex_renderer/src/light.rs
  • Modify: crates/voltex_renderer/src/lib.rs

기존 LightUniform(단일 directional)을 유지하면서(하위 호환), 새 LightData + LightsUniform을 추가.

  • Step 1: light.rs에 새 타입 추가

기존 CameraUniform, LightUniform은 유지 (기존 예제 호환). 새 타입 추가:

// crates/voltex_renderer/src/light.rs — 기존 코드 아래에 추가

pub const MAX_LIGHTS: usize = 16;

/// 라이트 타입 상수
pub const LIGHT_DIRECTIONAL: u32 = 0;
pub const LIGHT_POINT: u32 = 1;
pub const LIGHT_SPOT: u32 = 2;

/// 개별 라이트 데이터 (GPU 전달용)
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct LightData {
    pub position: [f32; 3],
    pub light_type: u32,        // 0=directional, 1=point, 2=spot
    pub direction: [f32; 3],
    pub range: f32,             // point/spot: 최대 영향 거리
    pub color: [f32; 3],
    pub intensity: f32,
    pub inner_cone: f32,        // spot: 내부 원뿔 각도 (cos)
    pub outer_cone: f32,        // spot: 외부 원뿔 각도 (cos)
    pub _padding: [f32; 2],
}

impl LightData {
    pub fn directional(direction: [f32; 3], color: [f32; 3], intensity: f32) -> Self {
        Self {
            position: [0.0; 3],
            light_type: LIGHT_DIRECTIONAL,
            direction,
            range: 0.0,
            color,
            intensity,
            inner_cone: 0.0,
            outer_cone: 0.0,
            _padding: [0.0; 2],
        }
    }

    pub fn point(position: [f32; 3], color: [f32; 3], intensity: f32, range: f32) -> Self {
        Self {
            position,
            light_type: LIGHT_POINT,
            direction: [0.0; 3],
            range,
            color,
            intensity,
            inner_cone: 0.0,
            outer_cone: 0.0,
            _padding: [0.0; 2],
        }
    }

    pub fn spot(
        position: [f32; 3],
        direction: [f32; 3],
        color: [f32; 3],
        intensity: f32,
        range: f32,
        inner_angle_deg: f32,
        outer_angle_deg: f32,
    ) -> Self {
        Self {
            position,
            light_type: LIGHT_SPOT,
            direction,
            range,
            color,
            intensity,
            inner_cone: inner_angle_deg.to_radians().cos(),
            outer_cone: outer_angle_deg.to_radians().cos(),
            _padding: [0.0; 2],
        }
    }
}

/// 다중 라이트 uniform (고정 크기 배열)
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct LightsUniform {
    pub lights: [LightData; MAX_LIGHTS],
    pub count: u32,
    pub ambient_color: [f32; 3],
}

impl LightsUniform {
    pub fn new() -> Self {
        Self {
            lights: [LightData::directional([0.0, -1.0, 0.0], [1.0; 3], 1.0); MAX_LIGHTS],
            count: 0,
            ambient_color: [0.03, 0.03, 0.03],
        }
    }

    pub fn add_light(&mut self, light: LightData) {
        if (self.count as usize) < MAX_LIGHTS {
            self.lights[self.count as usize] = light;
            self.count += 1;
        }
    }

    pub fn clear(&mut self) {
        self.count = 0;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_light_data_size() {
        // LightData must be 16-byte aligned for WGSL array
        assert_eq!(std::mem::size_of::<LightData>() % 16, 0);
    }

    #[test]
    fn test_lights_uniform_add() {
        let mut lights = LightsUniform::new();
        lights.add_light(LightData::point([0.0, 5.0, 0.0], [1.0, 0.0, 0.0], 10.0, 20.0));
        lights.add_light(LightData::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0], 1.0));
        assert_eq!(lights.count, 2);
    }

    #[test]
    fn test_lights_uniform_max() {
        let mut lights = LightsUniform::new();
        for i in 0..20 {
            lights.add_light(LightData::point([i as f32, 0.0, 0.0], [1.0; 3], 1.0, 10.0));
        }
        assert_eq!(lights.count, MAX_LIGHTS as u32); // capped at 16
    }

    #[test]
    fn test_spot_light_cone() {
        let spot = LightData::spot(
            [0.0; 3], [0.0, -1.0, 0.0],
            [1.0; 3], 10.0, 20.0,
            15.0, 30.0,
        );
        // cos(15°) ≈ 0.9659, cos(30°) ≈ 0.8660
        assert!((spot.inner_cone - 15.0_f32.to_radians().cos()).abs() < 1e-4);
        assert!((spot.outer_cone - 30.0_f32.to_radians().cos()).abs() < 1e-4);
    }
}
  • Step 2: lib.rs 업데이트

기존 re-export에 추가:

pub use light::{LightData, LightsUniform, MAX_LIGHTS, LIGHT_DIRECTIONAL, LIGHT_POINT, LIGHT_SPOT};
  • Step 3: 테스트 통과 확인

Run: cargo test -p voltex_renderer Expected: 기존 13 + light 4 = 17개 PASS

  • Step 4: 커밋
git add crates/voltex_renderer/
git commit -m "feat(renderer): add LightData and LightsUniform for multi-light support"

Task 2: PBR 셰이더 다중 라이트

Files:

  • Modify: crates/voltex_renderer/src/pbr_shader.wgsl

기존 단일 directional light 로직을 다중 라이트 루프로 교체. 라이트 타입별 radiance 계산.

  • Step 1: pbr_shader.wgsl 교체
// crates/voltex_renderer/src/pbr_shader.wgsl
const PI: f32 = 3.14159265358979;
const MAX_LIGHTS: u32 = 16u;
const LIGHT_DIRECTIONAL: u32 = 0u;
const LIGHT_POINT: u32 = 1u;
const LIGHT_SPOT: u32 = 2u;

struct CameraUniform {
    view_proj: mat4x4<f32>,
    model: mat4x4<f32>,
    camera_pos: vec3<f32>,
};

struct LightData {
    position: vec3<f32>,
    light_type: u32,
    direction: vec3<f32>,
    range: f32,
    color: vec3<f32>,
    intensity: f32,
    inner_cone: f32,
    outer_cone: f32,
    _padding: vec2<f32>,
};

struct LightsUniform {
    lights: array<LightData, 16>,
    count: u32,
    ambient_color: vec3<f32>,
};

struct MaterialUniform {
    base_color: vec4<f32>,
    metallic: f32,
    roughness: f32,
    ao: f32,
};

@group(0) @binding(0) var<uniform> camera: CameraUniform;
@group(0) @binding(1) var<uniform> lights_uniform: LightsUniform;

@group(1) @binding(0) var t_diffuse: texture_2d<f32>;
@group(1) @binding(1) var s_diffuse: sampler;

@group(2) @binding(0) var<uniform> material: MaterialUniform;

struct VertexInput {
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
    @location(2) uv: vec2<f32>,
};

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) world_normal: vec3<f32>,
    @location(1) world_pos: vec3<f32>,
    @location(2) uv: vec2<f32>,
};

@vertex
fn vs_main(model_v: VertexInput) -> VertexOutput {
    var out: VertexOutput;
    let world_pos = camera.model * vec4<f32>(model_v.position, 1.0);
    out.world_pos = world_pos.xyz;
    out.world_normal = normalize((camera.model * vec4<f32>(model_v.normal, 0.0)).xyz);
    out.clip_position = camera.view_proj * world_pos;
    out.uv = model_v.uv;
    return out;
}

// --- PBR Functions ---

fn distribution_ggx(N: vec3<f32>, H: vec3<f32>, roughness: f32) -> f32 {
    let a = roughness * roughness;
    let a2 = a * a;
    let NdotH = max(dot(N, H), 0.0);
    let NdotH2 = NdotH * NdotH;
    let denom_inner = NdotH2 * (a2 - 1.0) + 1.0;
    return a2 / (PI * denom_inner * denom_inner);
}

fn geometry_schlick_ggx(NdotV: f32, roughness: f32) -> f32 {
    let r = roughness + 1.0;
    let k = (r * r) / 8.0;
    return NdotV / (NdotV * (1.0 - k) + k);
}

fn geometry_smith(N: vec3<f32>, V: vec3<f32>, L: vec3<f32>, roughness: f32) -> f32 {
    let NdotV = max(dot(N, V), 0.0);
    let NdotL = max(dot(N, L), 0.0);
    return geometry_schlick_ggx(NdotV, roughness) * geometry_schlick_ggx(NdotL, roughness);
}

fn fresnel_schlick(cosTheta: f32, F0: vec3<f32>) -> vec3<f32> {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

// --- Light attenuation ---

fn attenuation_point(distance: f32, range: f32) -> f32 {
    let att = 1.0 / (distance * distance + 0.0001);
    // Smooth range falloff
    let falloff = clamp(1.0 - pow(distance / range, 4.0), 0.0, 1.0);
    return att * falloff * falloff;
}

fn attenuation_spot(light: LightData, L: vec3<f32>) -> f32 {
    let theta = dot(normalize(light.direction), -L);
    let epsilon = light.inner_cone - light.outer_cone;
    return clamp((theta - light.outer_cone) / epsilon, 0.0, 1.0);
}

// --- Per-light radiance ---

fn compute_light_contribution(
    light: LightData,
    N: vec3<f32>,
    V: vec3<f32>,
    world_pos: vec3<f32>,
    F0: vec3<f32>,
    albedo: vec3<f32>,
    metallic: f32,
    roughness: f32,
) -> vec3<f32> {
    var L: vec3<f32>;
    var radiance: vec3<f32>;

    if light.light_type == LIGHT_DIRECTIONAL {
        L = normalize(-light.direction);
        radiance = light.color * light.intensity;
    } else {
        // Point or Spot
        let to_light = light.position - world_pos;
        let distance = length(to_light);
        L = to_light / distance;
        let att = attenuation_point(distance, light.range);
        radiance = light.color * light.intensity * att;

        if light.light_type == LIGHT_SPOT {
            radiance = radiance * attenuation_spot(light, L);
        }
    }

    let H = normalize(V + L);
    let NdotL = max(dot(N, L), 0.0);

    if NdotL <= 0.0 {
        return vec3<f32>(0.0);
    }

    let NDF = distribution_ggx(N, H, roughness);
    let G = geometry_smith(N, V, L, roughness);
    let F = fresnel_schlick(max(dot(H, V), 0.0), F0);

    let ks = F;
    let kd = (vec3<f32>(1.0) - ks) * (1.0 - metallic);

    let numerator = NDF * G * F;
    let NdotV = max(dot(N, V), 0.0);
    let denominator = 4.0 * NdotV * NdotL + 0.0001;
    let specular = numerator / denominator;

    return (kd * albedo / PI + specular) * radiance * NdotL;
}

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    let tex_color = textureSample(t_diffuse, s_diffuse, in.uv);
    let albedo = material.base_color.rgb * tex_color.rgb;
    let metallic = material.metallic;
    let roughness = material.roughness;
    let ao = material.ao;

    let N = normalize(in.world_normal);
    let V = normalize(camera.camera_pos - in.world_pos);

    let F0 = mix(vec3<f32>(0.04, 0.04, 0.04), albedo, metallic);

    // Accumulate light contributions
    var Lo = vec3<f32>(0.0);
    let light_count = min(lights_uniform.count, MAX_LIGHTS);
    for (var i = 0u; i < light_count; i = i + 1u) {
        Lo = Lo + compute_light_contribution(
            lights_uniform.lights[i], N, V, in.world_pos,
            F0, albedo, metallic, roughness,
        );
    }

    // Ambient
    let ambient = lights_uniform.ambient_color * albedo * ao;

    var color = ambient + Lo;

    // Reinhard tone mapping
    color = color / (color + vec3<f32>(1.0));

    // Gamma correction
    color = pow(color, vec3<f32>(1.0 / 2.2));

    return vec4<f32>(color, material.base_color.a * tex_color.a);
}
  • Step 2: 빌드 확인

Run: cargo build -p voltex_renderer Expected: 빌드 성공

중요: PBR 셰이더의 light uniform이 바뀌었으므로, 기존 pbr_demo는 컴파일은 되지만 런타임에 bind group 크기가 맞지 않아 크래시할 수 있다. pbr_demo는 Task 3에서 업데이트.

  • Step 3: 커밋
git add crates/voltex_renderer/src/pbr_shader.wgsl
git commit -m "feat(renderer): update PBR shader for multi-light with point and spot support"

Task 3: multi_light_demo + pbr_demo 수정

Files:

  • Create: examples/multi_light_demo/Cargo.toml
  • Create: examples/multi_light_demo/src/main.rs
  • Modify: examples/pbr_demo/src/main.rs (LightsUniform 사용으로 업데이트)
  • Modify: Cargo.toml (워크스페이스에 multi_light_demo 추가)

pbr_demo 수정

기존 pbr_demo가 LightUniform을 사용하고 있으므로 LightsUniform으로 교체해야 한다. 변경 최소화:

  • LightUniform::new()LightsUniform::new() + add_light(LightData::directional(...))
  • light_buffer 크기가 LightsUniform 크기로 변경
  • 나머지 로직 동일

multi_light_demo

PBR 구체 여러 개 + 다양한 색상의 Point/Spot Light로 다중 라이트를 데모.

장면 구성:

  • 바닥: 큰 큐브(scale 10x0.1x10)를 y=-0.5에 배치 (roughness 0.8, 비금속)
  • 구체 5개: 일렬 배치, 다양한 metallic/roughness
  • Point Light 4개: 빨강, 초록, 파랑, 노랑 — 구체 위에서 원형으로 공전
  • Directional Light 1개: 약한 하얀빛 (전체 조명)
  • Spot Light 1개: 바닥 중앙을 비추는 흰색

카메라: (0, 5, 10) pitch=-0.3

동적 라이트: 매 프레임 Point Light 위치를 time 기반으로 원형 궤도에서 업데이트.

dynamic UBO 패턴 사용 (many_cubes 기반). LightsUniform은 매 프레임 write_buffer로 갱신 (static, dynamic offset 없음).

파일을 작성하기 전에 반드시 읽어야 할 파일:

  1. examples/pbr_demo/src/main.rs — 수정 대상
  2. examples/many_cubes/src/main.rs — dynamic UBO 패턴
  3. crates/voltex_renderer/src/light.rs — LightData, LightsUniform API
  4. crates/voltex_renderer/src/material.rs — MaterialUniform API
  5. crates/voltex_renderer/src/sphere.rs — generate_sphere
  • Step 1: pbr_demo/main.rs 수정

핵심 변경:

  • use voltex_renderer::LightUniformuse voltex_renderer::{LightsUniform, LightData}

  • light_uniform 초기화: LightsUniform::new() + add_light(LightData::directional([-1.0, -1.0, -1.0], [1.0,1.0,1.0], 1.0))

  • light_buffer 크기: std::mem::size_of::<LightsUniform>()

  • write_buffer에 bytemuck::cast_slice(&[lights_uniform])

  • Step 2: multi_light_demo 작성

워크스페이스에 추가, Cargo.toml 작성, main.rs 작성.

구체 + 바닥 = 6개 엔티티 (간단하므로 ECS 없이 직접 관리). dynamic UBO for camera (per-entity) + material (per-entity). LightsUniform은 static (per-frame update).

  • Step 3: 빌드 + 테스트

Run: cargo build --workspace Run: cargo test --workspace

  • Step 4: 실행 확인

Run: cargo run -p pbr_demo — 여전히 동작 (단일 directional light) Run: cargo run -p multi_light_demo — 다중 색상 라이트가 구체들을 비추며 공전

  • Step 5: 커밋
git add Cargo.toml examples/pbr_demo/ examples/multi_light_demo/
git commit -m "feat: add multi-light demo with point/spot lights, update pbr_demo for LightsUniform"

Phase 4b-1 완료 기준 체크리스트

  • cargo build --workspace 성공
  • cargo test --workspace — 모든 테스트 통과
  • LightData: Directional, Point, Spot 3가지 타입 생성자
  • LightsUniform: 최대 16개 라이트 배열, add/clear
  • PBR 셰이더: 라이트 루프, Point attenuation, Spot cone falloff
  • cargo run -p pbr_demo — 기존 기능 유지 (단일 directional)
  • cargo run -p multi_light_demo — 다중 색상 Point Light 공전, Spot Light
  • 기존 예제 모두 동작 (mesh_shader.wgsl 사용하는 것들은 변경 없음)