2085 lines
62 KiB
Markdown
2085 lines
62 KiB
Markdown
# 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<f32> 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<f32> 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<Vec4> 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::<MeshVertex>() 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<MeshVertex>,
|
||
pub indices: Vec<u32>,
|
||
}
|
||
|
||
/// 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<MeshVertex> = Vec::new();
|
||
let mut indices: Vec<u32> = 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<f32>,
|
||
model: mat4x4<f32>,
|
||
camera_pos: vec3<f32>,
|
||
};
|
||
|
||
struct LightUniform {
|
||
direction: vec3<f32>,
|
||
color: vec3<f32>,
|
||
ambient_strength: f32,
|
||
};
|
||
|
||
@group(0) @binding(0) var<uniform> camera: CameraUniform;
|
||
@group(0) @binding(1) var<uniform> light: LightUniform;
|
||
|
||
// 텍스처 바인드 그룹 (group 1)
|
||
@group(1) @binding(0) var t_diffuse: texture_2d<f32>;
|
||
@group(1) @binding(1) var s_diffuse: sampler;
|
||
|
||
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 = (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;
|
||
}
|
||
|
||
@fragment
|
||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||
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<f32>(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<BmpImage, String> {
|
||
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<u8>, // 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<u8> {
|
||
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<AppState>,
|
||
}
|
||
|
||
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)
|