62 KiB
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 테스트 + 구현 작성
// 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 테스트 + 구현 작성
// 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 업데이트
// 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: 커밋
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 테스트 + 구현 작성
// 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 업데이트
// 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: 커밋
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 추가
// 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 작성
// 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를 재생성한다.
// 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 업데이트
// 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: 커밋
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 테스트 + 구현 작성
// 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에 모듈 추가
// crates/voltex_renderer/src/lib.rs에 추가:
pub mod obj;
- Step 3: 테스트 통과 확인
Run: cargo test -p voltex_renderer
Expected: OBJ 파서 테스트 4개 PASS
- Step 4: 커밋
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 작성
// 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에 모듈 추가
// 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: 커밋
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 작성
// 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 작성
// 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에 메시 파이프라인 추가
// 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 업데이트
// 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: 커밋
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 작성
// 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에 모듈 추가
// 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: 커밋
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 작성
# 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 워크스페이스 업데이트
# 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 작성
# 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 작성
// 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: 커밋
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)