From cca50c8bc29e1ac01cb52c724c566c68e2584ea7 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 20:38:54 +0900 Subject: [PATCH] docs: add Phase 4a PBR rendering implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-24-phase4a-pbr.md | 612 ++++++++++++++++++ 1 file changed, 612 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-phase4a-pbr.md diff --git a/docs/superpowers/plans/2026-03-24-phase4a-pbr.md b/docs/superpowers/plans/2026-03-24-phase4a-pbr.md new file mode 100644 index 0000000..49a4543 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-phase4a-pbr.md @@ -0,0 +1,612 @@ +# Phase 4a: PBR Rendering 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:** Cook-Torrance BRDF 기반 PBR 셰이더로 metallic/roughness 파라미터에 따라 금속, 비금속 머티리얼을 사실적으로 렌더링한다. + +**Architecture:** MaterialUniform (base_color, metallic, roughness)을 새 bind group으로 셰이더에 전달. PBR WGSL 셰이더에서 Cook-Torrance BRDF (GGX NDF + Smith geometry + Fresnel-Schlick)를 구현. 프로시저럴 구체 메시 생성기를 추가하여 PBR 데모에서 metallic/roughness 그리드를 보여준다. 기존 MeshVertex(position, normal, uv)는 변경 없음 — 노멀 맵은 Phase 4c에서 추가. + +**Tech Stack:** Rust 1.94, wgpu 28.0, WGSL + +**Spec:** `docs/superpowers/specs/2026-03-24-voltex-engine-design.md` Phase 4 (4-1. PBR 머티리얼) + +**스코프 제한:** Albedo 텍스처 + metallic/roughness 파라미터만. Normal/AO/Emissive 맵, 다중 라이트, 섀도우, IBL은 Phase 4b/4c. + +--- + +## File Structure + +``` +crates/voltex_renderer/src/ +├── material.rs # MaterialUniform + bind group layout (NEW) +├── pbr_shader.wgsl # PBR Cook-Torrance 셰이더 (NEW) +├── pbr_pipeline.rs # PBR 렌더 파이프라인 (NEW) +├── sphere.rs # 프로시저럴 구체 메시 생성 (NEW) +├── lib.rs # 모듈 re-export 업데이트 +├── vertex.rs # 기존 유지 +├── mesh.rs # 기존 유지 +├── pipeline.rs # 기존 유지 (Blinn-Phong용) +examples/ +└── pbr_demo/ # PBR 데모 (NEW) + ├── Cargo.toml + └── src/ + └── main.rs +``` + +--- + +## Task 1: MaterialUniform + 프로시저럴 구체 + +**Files:** +- Create: `crates/voltex_renderer/src/material.rs` +- Create: `crates/voltex_renderer/src/sphere.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +MaterialUniform은 PBR 파라미터를 GPU에 전달. 구체 메시 생성기는 PBR 데모에 필수. + +- [ ] **Step 1: material.rs 작성** + +```rust +// crates/voltex_renderer/src/material.rs +use bytemuck::{Pod, Zeroable}; + +/// PBR 머티리얼 파라미터 (GPU uniform) +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct MaterialUniform { + pub base_color: [f32; 4], // RGBA + pub metallic: f32, + pub roughness: f32, + pub ao: f32, // ambient occlusion (1.0 = no occlusion) + pub _padding: f32, +} + +impl MaterialUniform { + pub fn new() -> Self { + Self { + base_color: [1.0, 1.0, 1.0, 1.0], + metallic: 0.0, + roughness: 0.5, + ao: 1.0, + _padding: 0.0, + } + } + + pub fn with_params(base_color: [f32; 4], metallic: f32, roughness: f32) -> Self { + Self { + base_color, + metallic, + roughness, + ao: 1.0, + _padding: 0.0, + } + } + + /// Material bind group layout (group 2) + pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Material Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: wgpu::BufferSize::new( + std::mem::size_of::() as u64, + ), + }, + count: None, + }, + ], + }) + } +} +``` + +- [ ] **Step 2: sphere.rs 작성** + +UV sphere 생성기. sectors(경도)와 stacks(위도) 파라미터로 MeshVertex 배열 생성. + +```rust +// crates/voltex_renderer/src/sphere.rs +use crate::vertex::MeshVertex; +use std::f32::consts::PI; + +/// UV sphere 메시 데이터 생성 +pub fn generate_sphere(radius: f32, sectors: u32, stacks: u32) -> (Vec, Vec) { + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + + for i in 0..=stacks { + let stack_angle = PI / 2.0 - (i as f32) * PI / (stacks as f32); // π/2 to -π/2 + let xy = radius * stack_angle.cos(); + let z = radius * stack_angle.sin(); + + for j in 0..=sectors { + let sector_angle = (j as f32) * 2.0 * PI / (sectors as f32); + let x = xy * sector_angle.cos(); + let y = xy * sector_angle.sin(); + + let nx = x / radius; + let ny = y / radius; + let nz = z / radius; + + let u = j as f32 / sectors as f32; + let v = i as f32 / stacks as f32; + + vertices.push(MeshVertex { + position: [x, z, y], // Y-up: swap y and z + normal: [nx, nz, ny], + uv: [u, v], + }); + } + } + + // Indices + for i in 0..stacks { + for j in 0..sectors { + let first = i * (sectors + 1) + j; + let second = first + sectors + 1; + + indices.push(first); + indices.push(second); + indices.push(first + 1); + + indices.push(first + 1); + indices.push(second); + indices.push(second + 1); + } + } + + (vertices, indices) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sphere_vertex_count() { + let (verts, _) = generate_sphere(1.0, 16, 8); + // (stacks+1) * (sectors+1) = 9 * 17 = 153 + assert_eq!(verts.len(), 153); + } + + #[test] + fn test_sphere_index_count() { + let (_, indices) = generate_sphere(1.0, 16, 8); + // stacks * sectors * 6 = 8 * 16 * 6 = 768 + assert_eq!(indices.len(), 768); + } + + #[test] + fn test_sphere_normals_unit_length() { + let (verts, _) = generate_sphere(1.0, 8, 4); + for v in &verts { + let len = (v.normal[0].powi(2) + v.normal[1].powi(2) + v.normal[2].powi(2)).sqrt(); + assert!((len - 1.0).abs() < 1e-4, "Normal length: {}", len); + } + } +} +``` + +- [ ] **Step 3: lib.rs 업데이트** + +```rust +// crates/voltex_renderer/src/lib.rs에 추가: +pub mod material; +pub mod sphere; +pub use material::MaterialUniform; +pub use sphere::generate_sphere; +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `cargo test -p voltex_renderer` +Expected: 기존 10 + sphere 3 = 13개 PASS + +- [ ] **Step 5: 커밋** + +```bash +git add crates/voltex_renderer/ +git commit -m "feat(renderer): add MaterialUniform and procedural sphere generator" +``` + +--- + +## Task 2: PBR WGSL 셰이더 + +**Files:** +- Create: `crates/voltex_renderer/src/pbr_shader.wgsl` + +Cook-Torrance BRDF: +- **D (Normal Distribution Function)**: GGX/Trowbridge-Reitz +- **G (Geometry Function)**: Smith's method with Schlick-GGX +- **F (Fresnel)**: Fresnel-Schlick 근사 + +``` +f_cook_torrance = D * G * F / (4 * dot(N,V) * dot(N,L)) +``` + +셰이더 바인딩 레이아웃: +- group(0) binding(0): CameraUniform (view_proj, model, camera_pos) — dynamic offset +- group(0) binding(1): LightUniform (direction, color, ambient) +- group(1) binding(0): albedo texture +- group(1) binding(1): sampler +- group(2) binding(0): MaterialUniform (base_color, metallic, roughness, ao) — dynamic offset + +- [ ] **Step 1: pbr_shader.wgsl 작성** + +```wgsl +// crates/voltex_renderer/src/pbr_shader.wgsl +const PI: f32 = 3.14159265359; + +struct CameraUniform { + view_proj: mat4x4, + model: mat4x4, + camera_pos: vec3, +}; + +struct LightUniform { + direction: vec3, + color: vec3, + ambient_strength: f32, +}; + +struct MaterialUniform { + base_color: vec4, + metallic: f32, + roughness: f32, + ao: f32, +}; + +@group(0) @binding(0) var camera: CameraUniform; +@group(0) @binding(1) var light: LightUniform; + +@group(1) @binding(0) var t_albedo: texture_2d; +@group(1) @binding(1) var s_albedo: 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 --- + +// GGX/Trowbridge-Reitz Normal Distribution Function +fn distribution_ggx(n: vec3, h: vec3, roughness: f32) -> f32 { + let a = roughness * roughness; + let a2 = a * a; + let n_dot_h = max(dot(n, h), 0.0); + let n_dot_h2 = n_dot_h * n_dot_h; + + let denom = n_dot_h2 * (a2 - 1.0) + 1.0; + return a2 / (PI * denom * denom); +} + +// Schlick-GGX Geometry function (single direction) +fn geometry_schlick_ggx(n_dot_v: f32, roughness: f32) -> f32 { + let r = roughness + 1.0; + let k = (r * r) / 8.0; + return n_dot_v / (n_dot_v * (1.0 - k) + k); +} + +// Smith's Geometry function (both directions) +fn geometry_smith(n: vec3, v: vec3, l: vec3, roughness: f32) -> f32 { + let n_dot_v = max(dot(n, v), 0.0); + let n_dot_l = max(dot(n, l), 0.0); + let ggx1 = geometry_schlick_ggx(n_dot_v, roughness); + let ggx2 = geometry_schlick_ggx(n_dot_l, roughness); + return ggx1 * ggx2; +} + +// Fresnel-Schlick approximation +fn fresnel_schlick(cos_theta: f32, f0: vec3) -> vec3 { + return f0 + (1.0 - f0) * pow(clamp(1.0 - cos_theta, 0.0, 1.0), 5.0); +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let albedo_tex = textureSample(t_albedo, s_albedo, in.uv); + let albedo = albedo_tex.rgb * material.base_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); + + // Fresnel reflectance at normal incidence + // Non-metal: 0.04, metal: albedo color + let f0 = mix(vec3(0.04, 0.04, 0.04), albedo, metallic); + + // Directional light + let l = normalize(-light.direction); + let h = normalize(v + l); + + let n_dot_l = max(dot(n, l), 0.0); + + // Cook-Torrance BRDF + let d = 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 numerator = d * g * f; + let denominator = 4.0 * max(dot(n, v), 0.0) * n_dot_l + 0.0001; + let specular = numerator / denominator; + + // Energy conservation: diffuse + specular = 1 + let ks = f; // specular fraction + let kd = (1.0 - ks) * (1.0 - metallic); // diffuse fraction (metals have no diffuse) + + let diffuse = kd * albedo / PI; + + // Final color + let lo = (diffuse + specular) * light.color * n_dot_l; + + // Ambient (simple constant for now, IBL in Phase 4c) + let ambient = vec3(0.03, 0.03, 0.03) * albedo * ao; + + var color = ambient + lo; + + // HDR → LDR tone mapping (Reinhard) + color = color / (color + vec3(1.0, 1.0, 1.0)); + + // Gamma correction + color = pow(color, vec3(1.0 / 2.2, 1.0 / 2.2, 1.0 / 2.2)); + + return vec4(color, 1.0); +} +``` + +- [ ] **Step 2: 빌드 확인** + +Run: `cargo build -p voltex_renderer` +Expected: 빌드 성공 (셰이더는 include_str!로 참조하므로 파일만 존재하면 됨) + +- [ ] **Step 3: 커밋** + +```bash +git add crates/voltex_renderer/src/pbr_shader.wgsl +git commit -m "feat(renderer): add PBR Cook-Torrance BRDF shader" +``` + +--- + +## Task 3: PBR 파이프라인 + +**Files:** +- Create: `crates/voltex_renderer/src/pbr_pipeline.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +기존 mesh_pipeline과 비슷하지만, bind group이 3개: camera+light(0), texture(1), material(2). material은 dynamic offset 사용 (per-entity 머티리얼). + +- [ ] **Step 1: pbr_pipeline.rs 작성** + +```rust +// crates/voltex_renderer/src/pbr_pipeline.rs +use crate::vertex::MeshVertex; +use crate::gpu::DEPTH_FORMAT; + +pub fn create_pbr_pipeline( + device: &wgpu::Device, + format: wgpu::TextureFormat, + camera_light_layout: &wgpu::BindGroupLayout, + texture_layout: &wgpu::BindGroupLayout, + material_layout: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("PBR Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("pbr_shader.wgsl").into()), + }); + + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("PBR Pipeline Layout"), + bind_group_layouts: &[camera_light_layout, texture_layout, material_layout], + immediate_size: 0, + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("PBR Pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[MeshVertex::LAYOUT], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) +} +``` + +- [ ] **Step 2: lib.rs 업데이트** + +```rust +// crates/voltex_renderer/src/lib.rs에 추가: +pub mod pbr_pipeline; +pub use pbr_pipeline::create_pbr_pipeline; +``` + +- [ ] **Step 3: 빌드 확인** + +Run: `cargo build -p voltex_renderer` +Expected: 빌드 성공 + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_renderer/ +git commit -m "feat(renderer): add PBR render pipeline with 3 bind groups" +``` + +--- + +## Task 4: PBR 데모 + +**Files:** +- Create: `examples/pbr_demo/Cargo.toml` +- Create: `examples/pbr_demo/src/main.rs` +- Modify: `Cargo.toml` (워크스페이스에 추가) + +metallic(X축)과 roughness(Y축)을 변화시킨 구체 그리드를 PBR로 렌더링. + +- [ ] **Step 1: 워크스페이스 + Cargo.toml** + +workspace members에 `"examples/pbr_demo"` 추가. + +```toml +# examples/pbr_demo/Cargo.toml +[package] +name = "pbr_demo" +version = "0.1.0" +edition = "2021" + +[dependencies] +voltex_math.workspace = true +voltex_platform.workspace = true +voltex_renderer.workspace = true +voltex_ecs.workspace = true +voltex_asset.workspace = true +wgpu.workspace = true +winit.workspace = true +bytemuck.workspace = true +pollster.workspace = true +env_logger.workspace = true +log.workspace = true +``` + +- [ ] **Step 2: main.rs 작성** + +이 파일은 asset_demo의 dynamic UBO 패턴 + PBR 파이프라인을 결합한다. + +반드시 읽어야 할 파일: +1. `examples/many_cubes/src/main.rs` — dynamic UBO 패턴 +2. `crates/voltex_renderer/src/material.rs` — MaterialUniform API +3. `crates/voltex_renderer/src/sphere.rs` — generate_sphere API +4. `crates/voltex_renderer/src/pbr_pipeline.rs` — create_pbr_pipeline API +5. `crates/voltex_renderer/src/texture.rs` — GpuTexture API + +핵심 구조: +- 7x7 구체 그리드 (49개). X축: metallic 0.0→1.0, Y축: roughness 0.05→1.0 +- 각 구체는 ECS 엔티티 (Transform + MaterialIndex(usize)) +- Camera 위치: (0, 0, 25), 그리드를 정면으로 봄 +- Directional light: 위쪽-앞에서 비추는 방향 + +바인드 그룹 구성: +- group(0): CameraUniform(dynamic) + LightUniform(static) — many_cubes와 동일 +- group(1): albedo texture (white 1x1) + sampler — 기존 GpuTexture +- group(2): MaterialUniform(dynamic) — per-entity 머티리얼 + +Dynamic UBO 2개: +1. Camera UBO: per-entity model matrix (dynamic offset) +2. Material UBO: per-entity metallic/roughness (dynamic offset) + +렌더 루프: +``` +for (i, entity) in entities.iter().enumerate() { + let camera_offset = i * camera_aligned_size; + let material_offset = i * material_aligned_size; + render_pass.set_bind_group(0, &camera_light_bg, &[camera_offset as u32]); + render_pass.set_bind_group(2, &material_bg, &[material_offset as u32]); + render_pass.draw_indexed(...); +} +``` + +구체 생성: `generate_sphere(0.4, 32, 16)` — 반지름 0.4, 충분한 해상도. + +그리드 배치: 7x7, spacing 1.2 +``` +for row in 0..7 { // roughness axis + for col in 0..7 { // metallic axis + let metallic = col as f32 / 6.0; + let roughness = 0.05 + row as f32 * (0.95 / 6.0); + // position: centered grid + } +} +``` + +- [ ] **Step 3: 빌드 + 테스트** + +Run: `cargo build --workspace` +Run: `cargo test --workspace` + +- [ ] **Step 4: 실행 확인** + +Run: `cargo run -p pbr_demo` +Expected: 7x7 구체 그리드. 왼→오 metallic 증가(반사적), 아래→위 roughness 증가(거친). FPS 카메라. ESC 종료. + +- [ ] **Step 5: 커밋** + +```bash +git add Cargo.toml examples/pbr_demo/ +git commit -m "feat: add PBR demo with metallic/roughness sphere grid" +``` + +--- + +## Phase 4a 완료 기준 체크리스트 + +- [ ] `cargo build --workspace` 성공 +- [ ] `cargo test --workspace` — 모든 테스트 통과 +- [ ] PBR 셰이더: GGX NDF + Smith geometry + Fresnel-Schlick +- [ ] Reinhard 톤 매핑 + 감마 보정 +- [ ] MaterialUniform: base_color, metallic, roughness, ao +- [ ] 프로시저럴 구체: 올바른 노멀, 조절 가능한 해상도 +- [ ] `cargo run -p pbr_demo` — 구체 그리드에서 metallic/roughness 차이 시각적으로 확인 +- [ ] 기존 예제 모두 동작