# 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:** 기존 단일 `LightUniform`을 `LightsUniform`으로 교체. 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은 유지 (기존 예제 호환). 새 타입 추가: ```rust // 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::() % 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에 추가: ```rust 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: 커밋** ```bash 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 교체** ```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, model: mat4x4, camera_pos: vec3, }; struct LightData { position: vec3, light_type: u32, direction: vec3, range: f32, color: vec3, intensity: f32, inner_cone: f32, outer_cone: f32, _padding: vec2, }; struct LightsUniform { lights: array, count: u32, ambient_color: vec3, }; struct MaterialUniform { base_color: vec4, metallic: f32, roughness: f32, ao: f32, }; @group(0) @binding(0) var camera: CameraUniform; @group(0) @binding(1) var lights_uniform: LightsUniform; @group(1) @binding(0) var t_diffuse: texture_2d; @group(1) @binding(1) var s_diffuse: sampler; @group(2) @binding(0) var material: MaterialUniform; struct VertexInput { @location(0) position: vec3, @location(1) normal: vec3, @location(2) uv: vec2, }; struct VertexOutput { @builtin(position) clip_position: vec4, @location(0) world_normal: vec3, @location(1) world_pos: vec3, @location(2) uv: vec2, }; @vertex fn vs_main(model_v: VertexInput) -> VertexOutput { var out: VertexOutput; let world_pos = camera.model * vec4(model_v.position, 1.0); out.world_pos = world_pos.xyz; out.world_normal = normalize((camera.model * vec4(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, H: vec3, 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, V: vec3, L: vec3, 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) -> vec3 { 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 { 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, V: vec3, world_pos: vec3, F0: vec3, albedo: vec3, metallic: f32, roughness: f32, ) -> vec3 { var L: vec3; var radiance: vec3; 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(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(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 { 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(0.04, 0.04, 0.04), albedo, metallic); // Accumulate light contributions var Lo = vec3(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(1.0)); // Gamma correction color = pow(color, vec3(1.0 / 2.2)); return vec4(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: 커밋** ```bash 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::LightUniform` → `use 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::()` - 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: 커밋** ```bash 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 사용하는 것들은 변경 없음)