docs: add Phase 4a PBR rendering implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
612
docs/superpowers/plans/2026-03-24-phase4a-pbr.md
Normal file
612
docs/superpowers/plans/2026-03-24-phase4a-pbr.md
Normal file
@@ -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::<MaterialUniform>() 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<MeshVertex>, Vec<u32>) {
|
||||||
|
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<f32>,
|
||||||
|
model: mat4x4<f32>,
|
||||||
|
camera_pos: vec3<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LightUniform {
|
||||||
|
direction: vec3<f32>,
|
||||||
|
color: vec3<f32>,
|
||||||
|
ambient_strength: 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> light: LightUniform;
|
||||||
|
|
||||||
|
@group(1) @binding(0) var t_albedo: texture_2d<f32>;
|
||||||
|
@group(1) @binding(1) var s_albedo: 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 ---
|
||||||
|
|
||||||
|
// GGX/Trowbridge-Reitz Normal Distribution Function
|
||||||
|
fn distribution_ggx(n: vec3<f32>, h: vec3<f32>, 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<f32>, v: vec3<f32>, l: vec3<f32>, 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<f32>) -> vec3<f32> {
|
||||||
|
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<f32> {
|
||||||
|
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<f32>(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<f32>(0.03, 0.03, 0.03) * albedo * ao;
|
||||||
|
|
||||||
|
var color = ambient + lo;
|
||||||
|
|
||||||
|
// HDR → LDR tone mapping (Reinhard)
|
||||||
|
color = color / (color + vec3<f32>(1.0, 1.0, 1.0));
|
||||||
|
|
||||||
|
// Gamma correction
|
||||||
|
color = pow(color, vec3<f32>(1.0 / 2.2, 1.0 / 2.2, 1.0 / 2.2));
|
||||||
|
|
||||||
|
return vec4<f32>(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 차이 시각적으로 확인
|
||||||
|
- [ ] 기존 예제 모두 동작
|
||||||
Reference in New Issue
Block a user