From 870c4122707eaea025acc7ea4941b10aa2c3debe Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 19:41:12 +0900 Subject: [PATCH] docs: add Phase 2 rendering basics implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-24-phase2-rendering-basics.md | 2084 +++++++++++++++++ 1 file changed, 2084 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-phase2-rendering-basics.md diff --git a/docs/superpowers/plans/2026-03-24-phase2-rendering-basics.md b/docs/superpowers/plans/2026-03-24-phase2-rendering-basics.md new file mode 100644 index 0000000..00495b2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-phase2-rendering-basics.md @@ -0,0 +1,2084 @@ +# Phase 2: Rendering Basics 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:** OBJ 모델을 로딩하고, BMP 텍스처를 입히고, Blinn-Phong 라이팅으로 렌더링하고, FPS 카메라로 돌려볼 수 있다. + +**Architecture:** voltex_math에 Vec2, Vec4, Mat4를 추가하고, voltex_renderer에 Mesh/OBJ 파서/카메라/텍스처/라이팅을 구현한다. Uniform 버퍼로 카메라 행렬과 라이트 데이터를 셰이더에 전달하고, Blinn-Phong WGSL 셰이더로 라이팅을 처리한다. 새 `examples/model_viewer` 앱이 모든 것을 통합한다. + +**Tech Stack:** Rust 1.94, wgpu 28.0, winit 0.30, bytemuck 1.x, pollster 0.4 + +**Spec:** `docs/superpowers/specs/2026-03-24-voltex-engine-design.md` Phase 2 섹션 + +**변경 사항 (스펙 대비):** PNG/JPG 디코더는 별도 Phase로 분리. Phase 2에서는 BMP 로더만 자체 구현한다. + +--- + +## File Structure + +``` +crates/ +├── voltex_math/src/ +│ ├── lib.rs # 모듈 re-export 업데이트 +│ ├── vec2.rs # Vec2 구현 (NEW) +│ ├── vec3.rs # 기존 유지 +│ ├── vec4.rs # Vec4 구현 (NEW) +│ └── mat4.rs # Mat4 구현 (NEW) +├── voltex_renderer/src/ +│ ├── lib.rs # 모듈 re-export 업데이트 +│ ├── gpu.rs # depth texture 생성 추가 (MODIFY) +│ ├── vertex.rs # MeshVertex 추가 (MODIFY) +│ ├── pipeline.rs # mesh pipeline 추가 (MODIFY) +│ ├── shader.wgsl # 기존 유지 (triangle용) +│ ├── mesh.rs # Mesh 구조체 + GPU 업로드 (NEW) +│ ├── obj.rs # OBJ 파서 (NEW) +│ ├── camera.rs # Camera + FpsController (NEW) +│ ├── texture.rs # BMP 로더 + GPU 텍스처 (NEW) +│ ├── light.rs # DirectionalLight + uniform (NEW) +│ └── mesh_shader.wgsl # Blinn-Phong 셰이더 (NEW) +examples/ +├── triangle/ # 기존 유지 +└── model_viewer/ # 모델 뷰어 데모 (NEW) + ├── Cargo.toml + └── src/ + └── main.rs +assets/ # 테스트용 에셋 (NEW) +└── cube.obj # 기본 큐브 모델 +``` + +--- + +## Task 1: voltex_math — Vec2, Vec4 + +**Files:** +- Create: `crates/voltex_math/src/vec2.rs` +- Create: `crates/voltex_math/src/vec4.rs` +- Modify: `crates/voltex_math/src/lib.rs` + +Vec2는 UV 좌표에 필요하고, Vec4는 동차 좌표(homogeneous coordinates)와 색상에 필요하다. + +- [ ] **Step 1: vec2.rs 테스트 + 구현 작성** + +```rust +// crates/voltex_math/src/vec2.rs +use std::ops::{Add, Sub, Mul, Neg}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Vec2 { + pub x: f32, + pub y: f32, +} + +impl Vec2 { + pub const ZERO: Self = Self { x: 0.0, y: 0.0 }; + pub const ONE: Self = Self { x: 1.0, y: 1.0 }; + + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } + + pub fn dot(self, rhs: Self) -> f32 { + self.x * rhs.x + self.y * rhs.y + } + + pub fn length_squared(self) -> f32 { + self.dot(self) + } + + pub fn length(self) -> f32 { + self.length_squared().sqrt() + } + + pub fn normalize(self) -> Self { + let len = self.length(); + Self { x: self.x / len, y: self.y / len } + } +} + +impl Add for Vec2 { + type Output = Self; + fn add(self, rhs: Self) -> Self { + Self { x: self.x + rhs.x, y: self.y + rhs.y } + } +} + +impl Sub for Vec2 { + type Output = Self; + fn sub(self, rhs: Self) -> Self { + Self { x: self.x - rhs.x, y: self.y - rhs.y } + } +} + +impl Mul for Vec2 { + type Output = Self; + fn mul(self, rhs: f32) -> Self { + Self { x: self.x * rhs, y: self.y * rhs } + } +} + +impl Neg for Vec2 { + type Output = Self; + fn neg(self) -> Self { + Self { x: -self.x, y: -self.y } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let v = Vec2::new(1.0, 2.0); + assert_eq!(v.x, 1.0); + assert_eq!(v.y, 2.0); + } + + #[test] + fn test_add() { + let a = Vec2::new(1.0, 2.0); + let b = Vec2::new(3.0, 4.0); + assert_eq!(a + b, Vec2::new(4.0, 6.0)); + } + + #[test] + fn test_dot() { + let a = Vec2::new(1.0, 2.0); + let b = Vec2::new(3.0, 4.0); + assert_eq!(a.dot(b), 11.0); + } + + #[test] + fn test_length() { + let v = Vec2::new(3.0, 4.0); + assert!((v.length() - 5.0).abs() < f32::EPSILON); + } + + #[test] + fn test_normalize() { + let v = Vec2::new(4.0, 0.0); + assert_eq!(v.normalize(), Vec2::new(1.0, 0.0)); + } +} +``` + +- [ ] **Step 2: vec4.rs 테스트 + 구현 작성** + +```rust +// crates/voltex_math/src/vec4.rs +use std::ops::{Add, Sub, Mul, Neg}; +use crate::Vec3; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Vec4 { + pub x: f32, + pub y: f32, + pub z: f32, + pub w: f32, +} + +impl Vec4 { + pub const ZERO: Self = Self { x: 0.0, y: 0.0, z: 0.0, w: 0.0 }; + pub const ONE: Self = Self { x: 1.0, y: 1.0, z: 1.0, w: 1.0 }; + + pub const fn new(x: f32, y: f32, z: f32, w: f32) -> Self { + Self { x, y, z, w } + } + + pub fn from_vec3(v: Vec3, w: f32) -> Self { + Self { x: v.x, y: v.y, z: v.z, w } + } + + pub fn xyz(self) -> Vec3 { + Vec3::new(self.x, self.y, self.z) + } + + pub fn dot(self, rhs: Self) -> f32 { + self.x * rhs.x + self.y * rhs.y + self.z * rhs.z + self.w * rhs.w + } + + pub fn length_squared(self) -> f32 { + self.dot(self) + } + + pub fn length(self) -> f32 { + self.length_squared().sqrt() + } +} + +impl Add for Vec4 { + type Output = Self; + fn add(self, rhs: Self) -> Self { + Self { x: self.x + rhs.x, y: self.y + rhs.y, z: self.z + rhs.z, w: self.w + rhs.w } + } +} + +impl Sub for Vec4 { + type Output = Self; + fn sub(self, rhs: Self) -> Self { + Self { x: self.x - rhs.x, y: self.y - rhs.y, z: self.z - rhs.z, w: self.w - rhs.w } + } +} + +impl Mul for Vec4 { + type Output = Self; + fn mul(self, rhs: f32) -> Self { + Self { x: self.x * rhs, y: self.y * rhs, z: self.z * rhs, w: self.w * rhs } + } +} + +impl Neg for Vec4 { + type Output = Self; + fn neg(self) -> Self { + Self { x: -self.x, y: -self.y, z: -self.z, w: -self.w } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let v = Vec4::new(1.0, 2.0, 3.0, 4.0); + assert_eq!(v.x, 1.0); + assert_eq!(v.w, 4.0); + } + + #[test] + fn test_from_vec3() { + let v3 = Vec3::new(1.0, 2.0, 3.0); + let v4 = Vec4::from_vec3(v3, 1.0); + assert_eq!(v4, Vec4::new(1.0, 2.0, 3.0, 1.0)); + } + + #[test] + fn test_xyz() { + let v = Vec4::new(1.0, 2.0, 3.0, 4.0); + assert_eq!(v.xyz(), Vec3::new(1.0, 2.0, 3.0)); + } + + #[test] + fn test_dot() { + let a = Vec4::new(1.0, 2.0, 3.0, 4.0); + let b = Vec4::new(5.0, 6.0, 7.0, 8.0); + assert_eq!(a.dot(b), 70.0); // 5+12+21+32 + } + + #[test] + fn test_add() { + let a = Vec4::new(1.0, 2.0, 3.0, 4.0); + let b = Vec4::new(5.0, 6.0, 7.0, 8.0); + assert_eq!(a + b, Vec4::new(6.0, 8.0, 10.0, 12.0)); + } +} +``` + +- [ ] **Step 3: lib.rs 업데이트** + +```rust +// crates/voltex_math/src/lib.rs +pub mod vec2; +pub mod vec3; +pub mod vec4; + +pub use vec2::Vec2; +pub use vec3::Vec3; +pub use vec4::Vec4; +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `cargo test -p voltex_math` +Expected: 모든 테스트 PASS (기존 10개 + Vec2 5개 + Vec4 5개 = 20개) + +- [ ] **Step 5: 커밋** + +```bash +git add crates/voltex_math/ +git commit -m "feat(math): add Vec2 and Vec4 types" +``` + +--- + +## Task 2: voltex_math — Mat4 + +**Files:** +- Create: `crates/voltex_math/src/mat4.rs` +- Modify: `crates/voltex_math/src/lib.rs` + +4x4 행렬. 카메라 View/Projection 행렬, 모델 변환에 필요하다. Column-major 저장 (wgpu/WGSL 표준). + +- [ ] **Step 1: mat4.rs 테스트 + 구현 작성** + +```rust +// crates/voltex_math/src/mat4.rs +use crate::{Vec3, Vec4}; + +/// 4x4 행렬 (column-major). wgpu/WGSL과 동일한 메모리 레이아웃. +/// cols[0] = 첫 번째 열, cols[1] = 두 번째 열, ... +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Mat4 { + pub cols: [[f32; 4]; 4], +} + +impl Mat4 { + pub const IDENTITY: Self = Self { + cols: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }; + + pub const fn from_cols(c0: [f32; 4], c1: [f32; 4], c2: [f32; 4], c3: [f32; 4]) -> Self { + Self { cols: [c0, c1, c2, c3] } + } + + /// GPU에 전달할 수 있는 f32 배열 반환 + pub fn as_slice(&self) -> &[f32; 16] { + // Safety: [f32; 4] x 4 == [f32; 16] in memory + unsafe { &*(self.cols.as_ptr() as *const [f32; 16]) } + } + + /// 행렬 곱셈 (self * rhs) + pub fn mul_mat4(&self, rhs: &Mat4) -> Mat4 { + let mut result = [[0.0f32; 4]; 4]; + for c in 0..4 { + for r in 0..4 { + result[c][r] = self.cols[0][r] * rhs.cols[c][0] + + self.cols[1][r] * rhs.cols[c][1] + + self.cols[2][r] * rhs.cols[c][2] + + self.cols[3][r] * rhs.cols[c][3]; + } + } + Mat4 { cols: result } + } + + /// 4x4 행렬 * Vec4 + pub fn mul_vec4(&self, v: Vec4) -> Vec4 { + Vec4::new( + self.cols[0][0] * v.x + self.cols[1][0] * v.y + self.cols[2][0] * v.z + self.cols[3][0] * v.w, + self.cols[0][1] * v.x + self.cols[1][1] * v.y + self.cols[2][1] * v.z + self.cols[3][1] * v.w, + self.cols[0][2] * v.x + self.cols[1][2] * v.y + self.cols[2][2] * v.z + self.cols[3][2] * v.w, + self.cols[0][3] * v.x + self.cols[1][3] * v.y + self.cols[2][3] * v.z + self.cols[3][3] * v.w, + ) + } + + /// 이동 행렬 + pub fn translation(x: f32, y: f32, z: f32) -> Self { + Self::from_cols( + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [x, y, z, 1.0], + ) + } + + /// 균등 스케일 행렬 + pub fn scale(sx: f32, sy: f32, sz: f32) -> Self { + Self::from_cols( + [sx, 0.0, 0.0, 0.0], + [0.0, sy, 0.0, 0.0], + [0.0, 0.0, sz, 0.0], + [0.0, 0.0, 0.0, 1.0], + ) + } + + /// X축 회전 (라디안) + pub fn rotation_x(angle: f32) -> Self { + let (s, c) = angle.sin_cos(); + Self::from_cols( + [1.0, 0.0, 0.0, 0.0], + [0.0, c, s, 0.0], + [0.0, -s, c, 0.0], + [0.0, 0.0, 0.0, 1.0], + ) + } + + /// Y축 회전 (라디안) + pub fn rotation_y(angle: f32) -> Self { + let (s, c) = angle.sin_cos(); + Self::from_cols( + [c, 0.0, -s, 0.0], + [0.0, 1.0, 0.0, 0.0], + [s, 0.0, c, 0.0], + [0.0, 0.0, 0.0, 1.0], + ) + } + + /// Z축 회전 (라디안) + pub fn rotation_z(angle: f32) -> Self { + let (s, c) = angle.sin_cos(); + Self::from_cols( + [c, s, 0.0, 0.0], + [-s, c, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ) + } + + /// Look-at 뷰 행렬 (오른손 좌표계) + pub fn look_at(eye: Vec3, target: Vec3, up: Vec3) -> Self { + let f = (target - eye).normalize(); // forward + let r = f.cross(up).normalize(); // right + let u = r.cross(f); // true up + + Self::from_cols( + [r.x, u.x, -f.x, 0.0], + [r.y, u.y, -f.y, 0.0], + [r.z, u.z, -f.z, 0.0], + [-r.dot(eye), -u.dot(eye), f.dot(eye), 1.0], + ) + } + + /// 원근 투영 행렬 (fov_y: 라디안, aspect: width/height) + /// wgpu NDC: x,y [-1,1], z [0,1] (왼손 depth) + pub fn perspective(fov_y: f32, aspect: f32, near: f32, far: f32) -> Self { + let f = 1.0 / (fov_y / 2.0).tan(); + let range_inv = 1.0 / (near - far); + Self::from_cols( + [f / aspect, 0.0, 0.0, 0.0], + [0.0, f, 0.0, 0.0], + [0.0, 0.0, far * range_inv, -1.0], + [0.0, 0.0, near * far * range_inv, 0.0], + ) + } + + /// 전치 행렬 + pub fn transpose(&self) -> Self { + let c = &self.cols; + Self::from_cols( + [c[0][0], c[1][0], c[2][0], c[3][0]], + [c[0][1], c[1][1], c[2][1], c[3][1]], + [c[0][2], c[1][2], c[2][2], c[3][2]], + [c[0][3], c[1][3], c[2][3], c[3][3]], + ) + } +} + +impl std::ops::Mul for Mat4 { + type Output = Mat4; + fn mul(self, rhs: Mat4) -> Mat4 { + self.mul_mat4(&rhs) + } +} + +impl std::ops::Mul for Mat4 { + type Output = Vec4; + fn mul(self, rhs: Vec4) -> Vec4 { + self.mul_vec4(rhs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Vec3; + + fn approx_eq(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-5 + } + + fn mat4_approx_eq(a: &Mat4, b: &Mat4) -> bool { + a.cols.iter().zip(b.cols.iter()) + .all(|(ca, cb)| ca.iter().zip(cb.iter()).all(|(x, y)| approx_eq(*x, *y))) + } + + #[test] + fn test_identity_mul() { + let m = Mat4::translation(1.0, 2.0, 3.0); + let result = Mat4::IDENTITY * m; + assert!(mat4_approx_eq(&result, &m)); + } + + #[test] + fn test_translation_mul_vec4() { + let m = Mat4::translation(10.0, 20.0, 30.0); + let v = Vec4::new(1.0, 2.0, 3.0, 1.0); + let result = m * v; + assert!(approx_eq(result.x, 11.0)); + assert!(approx_eq(result.y, 22.0)); + assert!(approx_eq(result.z, 33.0)); + assert!(approx_eq(result.w, 1.0)); + } + + #[test] + fn test_scale() { + let m = Mat4::scale(2.0, 3.0, 4.0); + let v = Vec4::new(1.0, 1.0, 1.0, 1.0); + let result = m * v; + assert!(approx_eq(result.x, 2.0)); + assert!(approx_eq(result.y, 3.0)); + assert!(approx_eq(result.z, 4.0)); + } + + #[test] + fn test_rotation_y_90() { + let m = Mat4::rotation_y(std::f32::consts::FRAC_PI_2); + let v = Vec4::new(1.0, 0.0, 0.0, 1.0); + let result = m * v; + assert!(approx_eq(result.x, 0.0)); + assert!(approx_eq(result.z, -1.0)); // 오른손 좌표계: +X -> -Z + } + + #[test] + fn test_look_at_origin() { + let eye = Vec3::new(0.0, 0.0, 5.0); + let target = Vec3::ZERO; + let up = Vec3::Y; + let view = Mat4::look_at(eye, target, up); + // eye에서 원점을 바라보면, 원점은 카메라 앞(−Z)에 있어야 함 + let p = view * Vec4::new(0.0, 0.0, 0.0, 1.0); + assert!(approx_eq(p.x, 0.0)); + assert!(approx_eq(p.y, 0.0)); + assert!(approx_eq(p.z, -5.0)); // 카메라 뒤쪽으로 5 + } + + #[test] + fn test_perspective_near_plane() { + let proj = Mat4::perspective( + std::f32::consts::FRAC_PI_4, + 1.0, + 0.1, + 100.0, + ); + // near plane 위의 점은 z=0으로 매핑되어야 함 + let p = proj * Vec4::new(0.0, 0.0, -0.1, 1.0); + let ndc_z = p.z / p.w; + assert!(approx_eq(ndc_z, 0.0)); + } + + #[test] + fn test_transpose() { + let m = Mat4::from_cols( + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + [13.0, 14.0, 15.0, 16.0], + ); + let t = m.transpose(); + assert_eq!(t.cols[0], [1.0, 5.0, 9.0, 13.0]); + assert_eq!(t.cols[1], [2.0, 6.0, 10.0, 14.0]); + } + + #[test] + fn test_as_slice() { + let m = Mat4::IDENTITY; + let s = m.as_slice(); + assert_eq!(s[0], 1.0); // col0[0] + assert_eq!(s[5], 1.0); // col1[1] + assert_eq!(s[10], 1.0); // col2[2] + assert_eq!(s[15], 1.0); // col3[3] + } +} +``` + +- [ ] **Step 2: lib.rs 업데이트** + +```rust +// crates/voltex_math/src/lib.rs +pub mod vec2; +pub mod vec3; +pub mod vec4; +pub mod mat4; + +pub use vec2::Vec2; +pub use vec3::Vec3; +pub use vec4::Vec4; +pub use mat4::Mat4; +``` + +- [ ] **Step 3: 테스트 통과 확인** + +Run: `cargo test -p voltex_math` +Expected: 모든 테스트 PASS (20 + 8 = 28개) + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_math/ +git commit -m "feat(math): add Mat4 with transforms, look_at, perspective" +``` + +--- + +## Task 3: voltex_renderer — MeshVertex + Mesh + depth buffer + +**Files:** +- Modify: `crates/voltex_renderer/src/vertex.rs` +- Create: `crates/voltex_renderer/src/mesh.rs` +- Modify: `crates/voltex_renderer/src/gpu.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +기존 `Vertex`(position+color)는 유지하고, 3D 렌더링용 `MeshVertex`(position+normal+uv)를 추가한다. `Mesh` 구조체로 GPU 버퍼를 관리한다. GpuContext에 depth texture를 추가한다. + +- [ ] **Step 1: vertex.rs에 MeshVertex 추가** + +```rust +// crates/voltex_renderer/src/vertex.rs — 기존 Vertex 아래에 추가 + +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct MeshVertex { + pub position: [f32; 3], + pub normal: [f32; 3], + pub uv: [f32; 2], +} + +impl MeshVertex { + pub const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + // position + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + // normal + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, + shader_location: 1, + format: wgpu::VertexFormat::Float32x3, + }, + // uv + wgpu::VertexAttribute { + offset: (std::mem::size_of::<[f32; 3]>() * 2) as wgpu::BufferAddress, + shader_location: 2, + format: wgpu::VertexFormat::Float32x2, + }, + ], + }; +} +``` + +- [ ] **Step 2: mesh.rs 작성** + +```rust +// crates/voltex_renderer/src/mesh.rs +use crate::vertex::MeshVertex; +use wgpu::util::DeviceExt; + +pub struct Mesh { + pub vertex_buffer: wgpu::Buffer, + pub index_buffer: wgpu::Buffer, + pub num_indices: u32, +} + +impl Mesh { + pub fn new(device: &wgpu::Device, vertices: &[MeshVertex], indices: &[u32]) -> Self { + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Mesh Vertex Buffer"), + contents: bytemuck::cast_slice(vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + + let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Mesh Index Buffer"), + contents: bytemuck::cast_slice(indices), + usage: wgpu::BufferUsages::INDEX, + }); + + Self { + vertex_buffer, + index_buffer, + num_indices: indices.len() as u32, + } + } +} +``` + +- [ ] **Step 3: gpu.rs에 depth texture 추가** + +`GpuContext`에 `depth_view` 필드를 추가하고, `create_depth_texture` 헬퍼를 만든다. `resize`에서도 depth texture를 재생성한다. + +```rust +// gpu.rs 수정 — 구조체에 필드 추가: +pub struct GpuContext { + pub surface: wgpu::Surface<'static>, + pub device: wgpu::Device, + pub queue: wgpu::Queue, + pub config: wgpu::SurfaceConfiguration, + pub surface_format: wgpu::TextureFormat, + pub depth_view: wgpu::TextureView, +} + +// 헬퍼 함수 추가 (impl GpuContext 밖이나 안): +pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; + +fn create_depth_texture(device: &wgpu::Device, width: u32, height: u32) -> wgpu::TextureView { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Depth Texture"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + texture.create_view(&wgpu::TextureViewDescriptor::default()) +} + +// new_async에서 depth_view 생성: +// let depth_view = create_depth_texture(&device, config.width, config.height); +// Self { ..., depth_view } + +// resize에서 depth_view 재생성: +// self.depth_view = create_depth_texture(&self.device, width, height); +``` + +- [ ] **Step 4: lib.rs 업데이트** + +```rust +// crates/voltex_renderer/src/lib.rs +pub mod gpu; +pub mod pipeline; +pub mod vertex; +pub mod mesh; + +pub use gpu::{GpuContext, DEPTH_FORMAT}; +pub use mesh::Mesh; +``` + +- [ ] **Step 5: 빌드 확인** + +Run: `cargo build -p voltex_renderer` +Expected: 빌드 성공 + +참고: 기존 `examples/triangle`은 `depth_stencil_attachment: None`을 사용하므로 depth 관련 변경에 영향 없음. 단, `GpuContext::new`의 반환값에 `depth_view`가 추가되었으므로 triangle 예제도 자동으로 이 필드를 받게 됨. + +- [ ] **Step 6: 커밋** + +```bash +git add crates/voltex_renderer/ +git commit -m "feat(renderer): add MeshVertex, Mesh, and depth buffer support" +``` + +--- + +## Task 4: voltex_renderer — OBJ 파서 + +**Files:** +- Create: `crates/voltex_renderer/src/obj.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +최소한의 OBJ 파서: `v` (position), `vn` (normal), `vt` (texcoord), `f` (face) 파싱. 삼각형 면만 지원 (quad면은 삼각형 2개로 분할). + +- [ ] **Step 1: obj.rs 테스트 + 구현 작성** + +```rust +// crates/voltex_renderer/src/obj.rs +use crate::vertex::MeshVertex; + +pub struct ObjData { + pub vertices: Vec, + pub indices: Vec, +} + +/// OBJ 파일 텍스트를 파싱하여 MeshVertex + 인덱스 배열 반환. +/// 삼각형/쿼드 면 지원. 쿼드는 삼각형 2개로 분할. +pub fn parse_obj(source: &str) -> ObjData { + let mut positions: Vec<[f32; 3]> = Vec::new(); + let mut normals: Vec<[f32; 3]> = Vec::new(); + let mut texcoords: Vec<[f32; 2]> = Vec::new(); + + let mut vertices: Vec = Vec::new(); + let mut indices: Vec = Vec::new(); + // 중복 정점 제거를 위한 맵 (v/vt/vn 인덱스 조합 → 최종 인덱스) + let mut vertex_map: std::collections::HashMap<(u32, u32, u32), u32> = std::collections::HashMap::new(); + + for line in source.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let mut parts = line.split_whitespace(); + let prefix = match parts.next() { + Some(p) => p, + None => continue, + }; + + match prefix { + "v" => { + let x: f32 = parts.next().unwrap_or("0").parse().unwrap_or(0.0); + let y: f32 = parts.next().unwrap_or("0").parse().unwrap_or(0.0); + let z: f32 = parts.next().unwrap_or("0").parse().unwrap_or(0.0); + positions.push([x, y, z]); + } + "vn" => { + let x: f32 = parts.next().unwrap_or("0").parse().unwrap_or(0.0); + let y: f32 = parts.next().unwrap_or("0").parse().unwrap_or(0.0); + let z: f32 = parts.next().unwrap_or("0").parse().unwrap_or(0.0); + normals.push([x, y, z]); + } + "vt" => { + let u: f32 = parts.next().unwrap_or("0").parse().unwrap_or(0.0); + let v: f32 = parts.next().unwrap_or("0").parse().unwrap_or(0.0); + texcoords.push([u, v]); + } + "f" => { + let face_verts: Vec<(u32, u32, u32)> = parts + .map(|token| parse_face_vertex(token)) + .collect(); + + // 삼각형 fan 분할 (tri, quad, n-gon 모두 지원) + for i in 1..face_verts.len().saturating_sub(1) { + for &fi in &[0, i, i + 1] { + let (vi, ti, ni) = face_verts[fi]; + let key = (vi, ti, ni); + let idx = if let Some(&existing) = vertex_map.get(&key) { + existing + } else { + let pos = if vi > 0 { positions[(vi - 1) as usize] } else { [0.0; 3] }; + let norm = if ni > 0 { normals[(ni - 1) as usize] } else { [0.0, 1.0, 0.0] }; + let uv = if ti > 0 { texcoords[(ti - 1) as usize] } else { [0.0; 2] }; + let new_idx = vertices.len() as u32; + vertices.push(MeshVertex { position: pos, normal: norm, uv }); + vertex_map.insert(key, new_idx); + new_idx + }; + indices.push(idx); + } + } + } + _ => {} // 무시: mtllib, usemtl, s, o, g 등 + } + } + + ObjData { vertices, indices } +} + +/// "v/vt/vn" 또는 "v//vn" 또는 "v/vt" 또는 "v" 형식 파싱 +fn parse_face_vertex(token: &str) -> (u32, u32, u32) { + let mut parts = token.split('/'); + let v: u32 = parts.next().unwrap_or("0").parse().unwrap_or(0); + let vt: u32 = parts.next().unwrap_or("").parse().unwrap_or(0); + let vn: u32 = parts.next().unwrap_or("").parse().unwrap_or(0); + (v, vt, vn) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_triangle() { + let obj = "\ +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 0.0 1.0 0.0 +vn 0.0 0.0 1.0 +f 1//1 2//1 3//1 +"; + let data = parse_obj(obj); + assert_eq!(data.vertices.len(), 3); + assert_eq!(data.indices.len(), 3); + assert_eq!(data.vertices[0].position, [0.0, 0.0, 0.0]); + assert_eq!(data.vertices[1].position, [1.0, 0.0, 0.0]); + assert_eq!(data.vertices[0].normal, [0.0, 0.0, 1.0]); + } + + #[test] + fn test_parse_quad_triangulated() { + let obj = "\ +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 1.0 1.0 0.0 +v 0.0 1.0 0.0 +vn 0.0 0.0 1.0 +f 1//1 2//1 3//1 4//1 +"; + let data = parse_obj(obj); + // quad → 2 triangles → 6 indices + assert_eq!(data.indices.len(), 6); + } + + #[test] + fn test_parse_with_uv() { + let obj = "\ +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 0.0 1.0 0.0 +vt 0.0 0.0 +vt 1.0 0.0 +vt 0.0 1.0 +vn 0.0 0.0 1.0 +f 1/1/1 2/2/1 3/3/1 +"; + let data = parse_obj(obj); + assert_eq!(data.vertices[0].uv, [0.0, 0.0]); + assert_eq!(data.vertices[1].uv, [1.0, 0.0]); + assert_eq!(data.vertices[2].uv, [0.0, 1.0]); + } + + #[test] + fn test_vertex_dedup() { + let obj = "\ +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 0.0 1.0 0.0 +vn 0.0 0.0 1.0 +f 1//1 2//1 3//1 +f 1//1 3//1 2//1 +"; + let data = parse_obj(obj); + // 같은 v/vt/vn 조합은 정점 재사용 + assert_eq!(data.vertices.len(), 3); + assert_eq!(data.indices.len(), 6); + } +} +``` + +- [ ] **Step 2: lib.rs에 모듈 추가** + +```rust +// crates/voltex_renderer/src/lib.rs에 추가: +pub mod obj; +``` + +- [ ] **Step 3: 테스트 통과 확인** + +Run: `cargo test -p voltex_renderer` +Expected: OBJ 파서 테스트 4개 PASS + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_renderer/ +git commit -m "feat(renderer): implement OBJ parser with triangle/quad support" +``` + +--- + +## Task 5: voltex_renderer — Camera + +**Files:** +- Create: `crates/voltex_renderer/src/camera.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +Camera는 위치/회전을 관리하고, view-projection 행렬을 계산한다. FpsController는 WASD + 마우스로 카메라를 조종한다. + +- [ ] **Step 1: camera.rs 작성** + +```rust +// crates/voltex_renderer/src/camera.rs +use voltex_math::{Vec3, Mat4}; + +pub struct Camera { + pub position: Vec3, + pub yaw: f32, // 라디안, Y축 회전 + pub pitch: f32, // 라디안, X축 회전 + pub fov_y: f32, // 라디안 + pub aspect: f32, + pub near: f32, + pub far: f32, +} + +impl Camera { + pub fn new(position: Vec3, aspect: f32) -> Self { + Self { + position, + yaw: 0.0, + pitch: 0.0, + fov_y: std::f32::consts::FRAC_PI_4, // 45도 + aspect, + near: 0.1, + far: 100.0, + } + } + + /// 카메라가 바라보는 방향 벡터 + pub fn forward(&self) -> Vec3 { + Vec3::new( + self.yaw.sin() * self.pitch.cos(), + self.pitch.sin(), + -self.yaw.cos() * self.pitch.cos(), + ) + } + + /// 카메라의 오른쪽 방향 벡터 + pub fn right(&self) -> Vec3 { + self.forward().cross(Vec3::Y).normalize() + } + + /// 뷰 행렬 + pub fn view_matrix(&self) -> Mat4 { + let target = self.position + self.forward(); + Mat4::look_at(self.position, target, Vec3::Y) + } + + /// 투영 행렬 + pub fn projection_matrix(&self) -> Mat4 { + Mat4::perspective(self.fov_y, self.aspect, self.near, self.far) + } + + /// view-projection 행렬 + pub fn view_projection(&self) -> Mat4 { + self.projection_matrix() * self.view_matrix() + } +} + +/// FPS 스타일 카메라 컨트롤러 +pub struct FpsController { + pub speed: f32, + pub mouse_sensitivity: f32, +} + +impl FpsController { + pub fn new() -> Self { + Self { + speed: 5.0, + mouse_sensitivity: 0.003, + } + } + + /// WASD 이동. forward/right/up은 각각 W-S, D-A, Space-Shift 입력. + pub fn process_movement( + &self, + camera: &mut Camera, + forward: f32, // +1 = W, -1 = S + right: f32, // +1 = D, -1 = A + up: f32, // +1 = Space, -1 = Shift + dt: f32, + ) { + let cam_forward = camera.forward(); + let cam_right = camera.right(); + let velocity = self.speed * dt; + + camera.position = camera.position + + cam_forward * (forward * velocity) + + cam_right * (right * velocity) + + Vec3::Y * (up * velocity); + } + + /// 마우스 이동으로 카메라 회전 + pub fn process_mouse(&self, camera: &mut Camera, dx: f64, dy: f64) { + camera.yaw += dx as f32 * self.mouse_sensitivity; + camera.pitch -= dy as f32 * self.mouse_sensitivity; + + // pitch 제한 (-89도 ~ 89도) + let limit = 89.0_f32.to_radians(); + camera.pitch = camera.pitch.clamp(-limit, limit); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_camera_default_forward() { + let cam = Camera::new(Vec3::ZERO, 1.0); + let fwd = cam.forward(); + // yaw=0, pitch=0 → forward = (0, 0, -1) + assert!((fwd.x).abs() < 1e-5); + assert!((fwd.y).abs() < 1e-5); + assert!((fwd.z + 1.0).abs() < 1e-5); + } + + #[test] + fn test_camera_yaw_90() { + let mut cam = Camera::new(Vec3::ZERO, 1.0); + cam.yaw = std::f32::consts::FRAC_PI_2; // 90도 → forward = (1, 0, 0) + let fwd = cam.forward(); + assert!((fwd.x - 1.0).abs() < 1e-5); + assert!((fwd.z).abs() < 1e-5); + } + + #[test] + fn test_fps_pitch_clamp() { + let ctrl = FpsController::new(); + let mut cam = Camera::new(Vec3::ZERO, 1.0); + // 매우 큰 마우스 이동 + ctrl.process_mouse(&mut cam, 0.0, -100000.0); + assert!(cam.pitch <= 89.0_f32.to_radians() + 1e-5); + } +} +``` + +- [ ] **Step 2: lib.rs에 모듈 추가** + +```rust +// crates/voltex_renderer/src/lib.rs에 추가: +pub mod camera; +pub use camera::{Camera, FpsController}; +``` + +- [ ] **Step 3: 테스트 통과 확인** + +Run: `cargo test -p voltex_renderer` +Expected: camera 테스트 3개 + OBJ 테스트 4개 PASS + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_renderer/ +git commit -m "feat(renderer): add Camera and FpsController" +``` + +--- + +## Task 6: voltex_renderer — Light + Blinn-Phong 셰이더 + 메시 파이프라인 + +**Files:** +- Create: `crates/voltex_renderer/src/light.rs` +- Create: `crates/voltex_renderer/src/mesh_shader.wgsl` +- Modify: `crates/voltex_renderer/src/pipeline.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +Uniform 버퍼를 사용하여 카메라 행렬과 라이트 데이터를 셰이더에 전달한다. Blinn-Phong 셰이더로 Directional Light 라이팅을 구현한다. + +- [ ] **Step 1: light.rs 작성** + +```rust +// crates/voltex_renderer/src/light.rs +use bytemuck::{Pod, Zeroable}; + +/// GPU로 전달되는 카메라 uniform 데이터 +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct CameraUniform { + pub view_proj: [[f32; 4]; 4], + pub model: [[f32; 4]; 4], + pub camera_pos: [f32; 3], + pub _padding: f32, +} + +impl CameraUniform { + pub fn new() -> Self { + Self { + view_proj: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + model: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + camera_pos: [0.0; 3], + _padding: 0.0, + } + } +} + +/// GPU로 전달되는 Directional Light uniform 데이터 +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct LightUniform { + pub direction: [f32; 3], + pub _padding1: f32, + pub color: [f32; 3], + pub ambient_strength: f32, +} + +impl LightUniform { + pub fn new() -> Self { + Self { + direction: [0.0, -1.0, -1.0], // 위에서 앞쪽으로 + _padding1: 0.0, + color: [1.0, 1.0, 1.0], + ambient_strength: 0.1, + } + } +} +``` + +- [ ] **Step 2: mesh_shader.wgsl 작성** + +```wgsl +// crates/voltex_renderer/src/mesh_shader.wgsl + +struct CameraUniform { + view_proj: mat4x4, + model: mat4x4, + camera_pos: vec3, +}; + +struct LightUniform { + direction: vec3, + color: vec3, + ambient_strength: f32, +}; + +@group(0) @binding(0) var camera: CameraUniform; +@group(0) @binding(1) var light: LightUniform; + +// 텍스처 바인드 그룹 (group 1) +@group(1) @binding(0) var t_diffuse: texture_2d; +@group(1) @binding(1) var s_diffuse: sampler; + +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 = (camera.model * vec4(model_v.normal, 0.0)).xyz; + out.clip_position = camera.view_proj * world_pos; + out.uv = model_v.uv; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let tex_color = textureSample(t_diffuse, s_diffuse, in.uv); + + let normal = normalize(in.world_normal); + let light_dir = normalize(-light.direction); + + // Ambient + let ambient = light.ambient_strength * light.color; + + // Diffuse + let diff = max(dot(normal, light_dir), 0.0); + let diffuse = diff * light.color; + + // Specular (Blinn-Phong) + let view_dir = normalize(camera.camera_pos - in.world_pos); + let half_dir = normalize(light_dir + view_dir); + let spec = pow(max(dot(normal, half_dir), 0.0), 32.0); + let specular = spec * light.color * 0.5; + + let result = (ambient + diffuse + specular) * tex_color.rgb; + return vec4(result, tex_color.a); +} +``` + +- [ ] **Step 3: pipeline.rs에 메시 파이프라인 추가** + +```rust +// crates/voltex_renderer/src/pipeline.rs에 추가 +use crate::vertex::MeshVertex; +use crate::gpu::DEPTH_FORMAT; + +pub fn create_mesh_pipeline( + device: &wgpu::Device, + format: wgpu::TextureFormat, + camera_light_layout: &wgpu::BindGroupLayout, + texture_layout: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Mesh Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("mesh_shader.wgsl").into()), + }); + + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Mesh Pipeline Layout"), + bind_group_layouts: &[camera_light_layout, texture_layout], + immediate_size: 0, + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Mesh 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 4: lib.rs 업데이트** + +```rust +// crates/voltex_renderer/src/lib.rs +pub mod gpu; +pub mod pipeline; +pub mod vertex; +pub mod mesh; +pub mod obj; +pub mod camera; +pub mod light; + +pub use gpu::{GpuContext, DEPTH_FORMAT}; +pub use mesh::Mesh; +pub use camera::{Camera, FpsController}; +pub use light::{CameraUniform, LightUniform}; +``` + +- [ ] **Step 5: 빌드 확인** + +Run: `cargo build -p voltex_renderer` +Expected: 빌드 성공 + +- [ ] **Step 6: 커밋** + +```bash +git add crates/voltex_renderer/ +git commit -m "feat(renderer): add Blinn-Phong shader, light uniforms, mesh pipeline" +``` + +--- + +## Task 7: voltex_renderer — BMP 텍스처 로더 + +**Files:** +- Create: `crates/voltex_renderer/src/texture.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +비압축 24-bit/32-bit BMP 파일을 파싱하여 RGBA 픽셀 데이터를 추출하고, wgpu Texture + BindGroup으로 업로드한다. + +- [ ] **Step 1: texture.rs 작성** + +```rust +// crates/voltex_renderer/src/texture.rs + +/// BMP 파일을 파싱하여 RGBA 픽셀 데이터 반환. +/// 비압축 24-bit (RGB) 및 32-bit (RGBA) BMP만 지원. +pub fn parse_bmp(data: &[u8]) -> Result { + if data.len() < 54 { + return Err("BMP file too small".into()); + } + if &data[0..2] != b"BM" { + return Err("Not a BMP file".into()); + } + + let pixel_offset = u32::from_le_bytes([data[10], data[11], data[12], data[13]]) as usize; + let width = i32::from_le_bytes([data[18], data[19], data[20], data[21]]); + let height = i32::from_le_bytes([data[22], data[23], data[24], data[25]]); + let bpp = u16::from_le_bytes([data[28], data[29]]); + + let compression = u32::from_le_bytes([data[30], data[31], data[32], data[33]]); + if compression != 0 { + return Err(format!("Compressed BMP not supported (compression={})", compression)); + } + + if bpp != 24 && bpp != 32 { + return Err(format!("Unsupported BMP bit depth: {}", bpp)); + } + + let w = width.unsigned_abs(); + let h = height.unsigned_abs(); + let bottom_up = height > 0; + let bytes_per_pixel = (bpp / 8) as usize; + let row_size = ((bpp as u32 * w + 31) / 32 * 4) as usize; // 4-byte aligned rows + + let mut pixels = vec![0u8; (w * h * 4) as usize]; + + for row in 0..h { + let src_row = if bottom_up { h - 1 - row } else { row }; + let src_offset = pixel_offset + (src_row as usize) * row_size; + + for col in 0..w { + let src_idx = src_offset + (col as usize) * bytes_per_pixel; + let dst_idx = ((row * w + col) * 4) as usize; + + if src_idx + bytes_per_pixel > data.len() { + return Err("BMP pixel data truncated".into()); + } + + // BMP stores BGR(A) + pixels[dst_idx] = data[src_idx + 2]; // R + pixels[dst_idx + 1] = data[src_idx + 1]; // G + pixels[dst_idx + 2] = data[src_idx]; // B + pixels[dst_idx + 3] = if bpp == 32 { data[src_idx + 3] } else { 255 }; // A + } + } + + Ok(BmpImage { width: w, height: h, pixels }) +} + +pub struct BmpImage { + pub width: u32, + pub height: u32, + pub pixels: Vec, // RGBA +} + +/// RGBA 픽셀 데이터를 wgpu 텍스처로 업로드하고, BindGroup을 반환. +pub struct GpuTexture { + pub texture: wgpu::Texture, + pub view: wgpu::TextureView, + pub sampler: wgpu::Sampler, + pub bind_group: wgpu::BindGroup, +} + +impl GpuTexture { + pub fn from_rgba( + device: &wgpu::Device, + queue: &wgpu::Queue, + width: u32, + height: u32, + pixels: &[u8], + layout: &wgpu::BindGroupLayout, + ) -> Self { + let size = wgpu::Extent3d { width, height, depth_or_array_layers: 1 }; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Diffuse Texture"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * width), + rows_per_image: Some(height), + }, + size, + ); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Texture Bind Group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + Self { texture, view, sampler, bind_group } + } + + /// 1x1 흰색 텍스처 (텍스처 없는 메시용 기본값) + pub fn white_1x1( + device: &wgpu::Device, + queue: &wgpu::Queue, + layout: &wgpu::BindGroupLayout, + ) -> Self { + Self::from_rgba(device, queue, 1, 1, &[255, 255, 255, 255], layout) + } + + /// BindGroupLayout 정의 (group 1에서 사용) + pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Texture 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, + }, + ], + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_bmp_24bit(width: u32, height: u32, pixel_bgr: [u8; 3]) -> Vec { + let row_size = ((24 * width + 31) / 32 * 4) as usize; + let pixel_data_size = row_size * height as usize; + let file_size = 54 + pixel_data_size; + + let mut data = vec![0u8; file_size]; + // Header + data[0] = b'B'; data[1] = b'M'; + data[2..6].copy_from_slice(&(file_size as u32).to_le_bytes()); + data[10..14].copy_from_slice(&54u32.to_le_bytes()); + // DIB header + data[14..18].copy_from_slice(&40u32.to_le_bytes()); // header size + data[18..22].copy_from_slice(&(width as i32).to_le_bytes()); + data[22..26].copy_from_slice(&(height as i32).to_le_bytes()); + data[26..28].copy_from_slice(&1u16.to_le_bytes()); // planes + data[28..30].copy_from_slice(&24u16.to_le_bytes()); // bpp + // compression = 0 (already zeroed) + + // Pixel data + for row in 0..height { + for col in 0..width { + let offset = 54 + (row as usize) * row_size + (col as usize) * 3; + data[offset] = pixel_bgr[0]; + data[offset + 1] = pixel_bgr[1]; + data[offset + 2] = pixel_bgr[2]; + } + } + data + } + + #[test] + fn test_parse_bmp_24bit() { + let bmp = make_bmp_24bit(2, 2, [255, 0, 0]); // BGR: blue + let img = parse_bmp(&bmp).unwrap(); + assert_eq!(img.width, 2); + assert_eq!(img.height, 2); + // BGR [255,0,0] → RGBA [0,0,255,255] + assert_eq!(img.pixels[0], 0); // R + assert_eq!(img.pixels[1], 0); // G + assert_eq!(img.pixels[2], 255); // B + assert_eq!(img.pixels[3], 255); // A + } + + #[test] + fn test_parse_bmp_not_bmp() { + let data = vec![0u8; 100]; + assert!(parse_bmp(&data).is_err()); + } + + #[test] + fn test_parse_bmp_too_small() { + let data = vec![0u8; 10]; + assert!(parse_bmp(&data).is_err()); + } +} +``` + +- [ ] **Step 2: lib.rs에 모듈 추가** + +```rust +// crates/voltex_renderer/src/lib.rs에 추가: +pub mod texture; +pub use texture::GpuTexture; +``` + +- [ ] **Step 3: 테스트 통과 확인** + +Run: `cargo test -p voltex_renderer` +Expected: BMP 테스트 3개 + 기존 테스트 PASS + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_renderer/ +git commit -m "feat(renderer): add BMP texture loader and GPU texture upload" +``` + +--- + +## Task 8: 테스트 에셋 + Model Viewer 데모 + +**Files:** +- Create: `assets/cube.obj` +- Create: `examples/model_viewer/Cargo.toml` +- Create: `examples/model_viewer/src/main.rs` +- Modify: `Cargo.toml` (워크스페이스에 model_viewer 추가) + +모든 구현을 통합하는 model_viewer 데모. OBJ 큐브를 로드하고, Blinn-Phong 라이팅으로 렌더링하고, WASD + 마우스로 카메라를 조종한다. + +- [ ] **Step 1: cube.obj 작성** + +```obj +# assets/cube.obj +# Simple cube with normals and UVs + +v -0.5 -0.5 0.5 +v 0.5 -0.5 0.5 +v 0.5 0.5 0.5 +v -0.5 0.5 0.5 +v -0.5 -0.5 -0.5 +v 0.5 -0.5 -0.5 +v 0.5 0.5 -0.5 +v -0.5 0.5 -0.5 + +vn 0.0 0.0 1.0 +vn 0.0 0.0 -1.0 +vn 1.0 0.0 0.0 +vn -1.0 0.0 0.0 +vn 0.0 1.0 0.0 +vn 0.0 -1.0 0.0 + +vt 0.0 0.0 +vt 1.0 0.0 +vt 1.0 1.0 +vt 0.0 1.0 + +# Front face +f 1/1/1 2/2/1 3/3/1 4/4/1 +# Back face +f 6/1/2 5/2/2 8/3/2 7/4/2 +# Right face +f 2/1/3 6/2/3 7/3/3 3/4/3 +# Left face +f 5/1/4 1/2/4 4/3/4 8/4/4 +# Top face +f 4/1/5 3/2/5 7/3/5 8/4/5 +# Bottom face +f 5/1/6 6/2/6 2/3/6 1/4/6 +``` + +- [ ] **Step 2: Cargo.toml 워크스페이스 업데이트** + +```toml +# Cargo.toml 루트 — members에 추가: +[workspace] +resolver = "2" +members = [ + "crates/voltex_math", + "crates/voltex_platform", + "crates/voltex_renderer", + "examples/triangle", + "examples/model_viewer", +] +``` + +- [ ] **Step 3: model_viewer Cargo.toml 작성** + +```toml +# examples/model_viewer/Cargo.toml +[package] +name = "model_viewer" +version = "0.1.0" +edition = "2021" + +[dependencies] +voltex_math.workspace = true +voltex_platform.workspace = true +voltex_renderer.workspace = true +wgpu.workspace = true +winit.workspace = true +bytemuck.workspace = true +pollster.workspace = true +env_logger.workspace = true +log.workspace = true +``` + +- [ ] **Step 4: model_viewer main.rs 작성** + +```rust +// examples/model_viewer/src/main.rs +use std::sync::Arc; +use winit::{ + application::ApplicationHandler, + event::WindowEvent, + event_loop::{ActiveEventLoop, EventLoop}, + keyboard::{KeyCode, PhysicalKey}, + window::WindowId, +}; +use voltex_math::{Vec3, Mat4}; +use voltex_platform::{VoltexWindow, WindowConfig, InputState, GameTimer}; +use voltex_renderer::{ + GpuContext, Mesh, Camera, FpsController, + CameraUniform, LightUniform, GpuTexture, + pipeline, obj, +}; +use wgpu::util::DeviceExt; + +struct ModelViewerApp { + state: Option, +} + +struct AppState { + window: VoltexWindow, + gpu: GpuContext, + mesh_pipeline: wgpu::RenderPipeline, + mesh: Mesh, + camera: Camera, + fps_controller: FpsController, + camera_uniform: CameraUniform, + camera_buffer: wgpu::Buffer, + light_uniform: LightUniform, + light_buffer: wgpu::Buffer, + camera_light_bind_group: wgpu::BindGroup, + diffuse_texture: GpuTexture, + input: InputState, + timer: GameTimer, + model_rotation: f32, +} + +fn camera_light_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Camera+Light Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }) +} + +impl ApplicationHandler for ModelViewerApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let config = WindowConfig { + title: "Voltex - Model Viewer".to_string(), + width: 1280, + height: 720, + ..Default::default() + }; + let window = VoltexWindow::new(event_loop, &config); + let gpu = GpuContext::new(window.handle.clone()); + + // OBJ 로드 + let obj_source = include_str!("../../../assets/cube.obj"); + let obj_data = obj::parse_obj(obj_source); + let mesh = Mesh::new(&gpu.device, &obj_data.vertices, &obj_data.indices); + + // Uniform 버퍼 + let camera_uniform = CameraUniform::new(); + let camera_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Camera Uniform Buffer"), + contents: bytemuck::cast_slice(&[camera_uniform]), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + let light_uniform = LightUniform::new(); + let light_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Light Uniform Buffer"), + contents: bytemuck::cast_slice(&[light_uniform]), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + // Bind group layouts + let cl_layout = camera_light_bind_group_layout(&gpu.device); + let tex_layout = GpuTexture::bind_group_layout(&gpu.device); + + let camera_light_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Camera+Light Bind Group"), + layout: &cl_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: light_buffer.as_entire_binding(), + }, + ], + }); + + // 기본 흰색 텍스처 (BMP 파일 없으면 이걸 사용) + let diffuse_texture = GpuTexture::white_1x1(&gpu.device, &gpu.queue, &tex_layout); + + // 파이프라인 + let mesh_pipeline = pipeline::create_mesh_pipeline( + &gpu.device, + gpu.surface_format, + &cl_layout, + &tex_layout, + ); + + let (w, h) = window.inner_size(); + let camera = Camera::new(Vec3::new(0.0, 1.0, 3.0), w as f32 / h as f32); + + self.state = Some(AppState { + window, + gpu, + mesh_pipeline, + mesh, + camera, + fps_controller: FpsController::new(), + camera_uniform, + camera_buffer, + light_uniform, + light_buffer, + camera_light_bind_group, + diffuse_texture, + input: InputState::new(), + timer: GameTimer::new(60), + model_rotation: 0.0, + }); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: WindowEvent, + ) { + let state = match &mut self.state { + Some(s) => s, + None => return, + }; + + match event { + WindowEvent::CloseRequested => event_loop.exit(), + + WindowEvent::KeyboardInput { + event: winit::event::KeyEvent { + physical_key: PhysicalKey::Code(key_code), + state: key_state, + .. + }, + .. + } => { + let pressed = key_state == winit::event::ElementState::Pressed; + state.input.process_key(key_code, pressed); + if key_code == KeyCode::Escape && pressed { + event_loop.exit(); + } + } + + WindowEvent::Resized(size) => { + state.gpu.resize(size.width, size.height); + if size.width > 0 && size.height > 0 { + state.camera.aspect = size.width as f32 / size.height as f32; + } + } + + WindowEvent::CursorMoved { position, .. } => { + state.input.process_mouse_move(position.x, position.y); + } + + WindowEvent::MouseInput { state: btn_state, button, .. } => { + let pressed = btn_state == winit::event::ElementState::Pressed; + state.input.process_mouse_button(button, pressed); + } + + WindowEvent::MouseWheel { delta, .. } => { + let y = match delta { + winit::event::MouseScrollDelta::LineDelta(_, y) => y, + winit::event::MouseScrollDelta::PixelDelta(pos) => pos.y as f32, + }; + state.input.process_scroll(y); + } + + WindowEvent::RedrawRequested => { + state.timer.tick(); + let dt = state.timer.frame_dt(); + + // 마우스 우클릭 드래그로 카메라 회전 + if state.input.is_mouse_button_pressed(winit::event::MouseButton::Right) { + let (dx, dy) = state.input.mouse_delta(); + state.fps_controller.process_mouse(&mut state.camera, dx, dy); + } + + // WASD 이동 + let forward = if state.input.is_key_pressed(KeyCode::KeyW) { 1.0 } + else if state.input.is_key_pressed(KeyCode::KeyS) { -1.0 } + else { 0.0 }; + let right = if state.input.is_key_pressed(KeyCode::KeyD) { 1.0 } + else if state.input.is_key_pressed(KeyCode::KeyA) { -1.0 } + else { 0.0 }; + let up = if state.input.is_key_pressed(KeyCode::Space) { 1.0 } + else if state.input.is_key_pressed(KeyCode::ShiftLeft) { -1.0 } + else { 0.0 }; + state.fps_controller.process_movement(&mut state.camera, forward, right, up, dt); + + state.input.begin_frame(); + + // 모델 자동 회전 + state.model_rotation += dt * 0.5; + let model = Mat4::rotation_y(state.model_rotation); + + // Uniform 업데이트 + state.camera_uniform.view_proj = state.camera.view_projection().cols; + state.camera_uniform.model = model.cols; + state.camera_uniform.camera_pos = [ + state.camera.position.x, + state.camera.position.y, + state.camera.position.z, + ]; + state.gpu.queue.write_buffer( + &state.camera_buffer, + 0, + bytemuck::cast_slice(&[state.camera_uniform]), + ); + + // 렌더링 + let output = match state.gpu.surface.get_current_texture() { + Ok(t) => t, + Err(wgpu::SurfaceError::Lost) => { + let (w, h) = state.window.inner_size(); + state.gpu.resize(w, h); + return; + } + Err(wgpu::SurfaceError::OutOfMemory) => { + event_loop.exit(); + return; + } + Err(_) => return, + }; + + let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = state.gpu.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("Render Encoder") }, + ); + + { + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Mesh Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.1, + g: 0.1, + b: 0.15, + a: 1.0, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &state.gpu.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); + + render_pass.set_pipeline(&state.mesh_pipeline); + render_pass.set_bind_group(0, &state.camera_light_bind_group, &[]); + render_pass.set_bind_group(1, &state.diffuse_texture.bind_group, &[]); + render_pass.set_vertex_buffer(0, state.mesh.vertex_buffer.slice(..)); + render_pass.set_index_buffer( + state.mesh.index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + render_pass.draw_indexed(0..state.mesh.num_indices, 0, 0..1); + } + + state.gpu.queue.submit(std::iter::once(encoder.finish())); + output.present(); + } + + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if let Some(state) = &self.state { + state.window.request_redraw(); + } + } +} + +fn main() { + env_logger::init(); + let event_loop = EventLoop::new().unwrap(); + let mut app = ModelViewerApp { state: None }; + event_loop.run_app(&mut app).unwrap(); +} +``` + +- [ ] **Step 5: 빌드 확인** + +Run: `cargo build -p model_viewer` +Expected: 빌드 성공 + +- [ ] **Step 6: 실행 확인** + +Run: `cargo run -p model_viewer` +Expected: 큐브가 Blinn-Phong 라이팅으로 렌더링됨. 자동 회전. 마우스 우클릭+드래그로 카메라 회전. WASD로 이동. ESC로 종료. + +- [ ] **Step 7: 커밋** + +```bash +git add Cargo.toml assets/ examples/model_viewer/ +git commit -m "feat: add model viewer demo with OBJ loading, Blinn-Phong lighting, FPS camera" +``` + +--- + +## Phase 2 완료 기준 체크리스트 + +- [ ] `cargo build --workspace` 성공 +- [ ] `cargo test --workspace` — 모든 테스트 통과 +- [ ] `cargo run -p triangle` — 기존 삼각형 데모 여전히 동작 +- [ ] `cargo run -p model_viewer` — 큐브 렌더링, 라이팅, 카메라 조작 동작 +- [ ] OBJ 파서 테스트 통과 (삼각형, 쿼드, UV, 정점 중복 제거) +- [ ] BMP 파서 테스트 통과 +- [ ] Mat4 테스트 통과 (identity, translation, rotation, look_at, perspective)