From b09e1df878e1728b749e2cf989081cfefb7f54d5 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 20:41:02 +0900 Subject: [PATCH] feat(renderer): add PBR material, sphere generator, Cook-Torrance shader, and PBR pipeline Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/voltex_renderer/src/lib.rs | 6 + crates/voltex_renderer/src/material.rs | 51 ++++++++ crates/voltex_renderer/src/pbr_pipeline.rs | 65 ++++++++++ crates/voltex_renderer/src/pbr_shader.wgsl | 131 +++++++++++++++++++++ crates/voltex_renderer/src/sphere.rs | 90 ++++++++++++++ 5 files changed, 343 insertions(+) create mode 100644 crates/voltex_renderer/src/material.rs create mode 100644 crates/voltex_renderer/src/pbr_pipeline.rs create mode 100644 crates/voltex_renderer/src/pbr_shader.wgsl create mode 100644 crates/voltex_renderer/src/sphere.rs diff --git a/crates/voltex_renderer/src/lib.rs b/crates/voltex_renderer/src/lib.rs index 233b17e..09c7e48 100644 --- a/crates/voltex_renderer/src/lib.rs +++ b/crates/voltex_renderer/src/lib.rs @@ -6,9 +6,15 @@ pub mod texture; pub mod vertex; pub mod mesh; pub mod camera; +pub mod material; +pub mod sphere; +pub mod pbr_pipeline; pub use gpu::{GpuContext, DEPTH_FORMAT}; pub use light::{CameraUniform, LightUniform}; pub use mesh::Mesh; pub use camera::{Camera, FpsController}; pub use texture::GpuTexture; +pub use material::MaterialUniform; +pub use sphere::generate_sphere; +pub use pbr_pipeline::create_pbr_pipeline; diff --git a/crates/voltex_renderer/src/material.rs b/crates/voltex_renderer/src/material.rs new file mode 100644 index 0000000..71816c3 --- /dev/null +++ b/crates/voltex_renderer/src/material.rs @@ -0,0 +1,51 @@ +use bytemuck::{Pod, Zeroable}; + +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct MaterialUniform { + pub base_color: [f32; 4], + pub metallic: f32, + pub roughness: f32, + pub ao: f32, + pub _padding: f32, +} + +impl MaterialUniform { + pub fn new() -> Self { + Self { + base_color: [1.0, 1.0, 1.0, 1.0], + metallic: 0.0, + roughness: 0.5, + ao: 1.0, + _padding: 0.0, + } + } + + pub fn with_params(base_color: [f32; 4], metallic: f32, roughness: f32) -> Self { + Self { + base_color, + metallic, + roughness, + ao: 1.0, + _padding: 0.0, + } + } + + pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Material Bind Group Layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: wgpu::BufferSize::new( + std::mem::size_of::() as u64, + ), + }, + count: None, + }], + }) + } +} diff --git a/crates/voltex_renderer/src/pbr_pipeline.rs b/crates/voltex_renderer/src/pbr_pipeline.rs new file mode 100644 index 0000000..7a2b95f --- /dev/null +++ b/crates/voltex_renderer/src/pbr_pipeline.rs @@ -0,0 +1,65 @@ +use crate::vertex::MeshVertex; +use crate::gpu::DEPTH_FORMAT; + +pub fn create_pbr_pipeline( + device: &wgpu::Device, + format: wgpu::TextureFormat, + camera_light_layout: &wgpu::BindGroupLayout, + texture_layout: &wgpu::BindGroupLayout, + material_layout: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("PBR Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("pbr_shader.wgsl").into()), + }); + + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("PBR Pipeline Layout"), + bind_group_layouts: &[camera_light_layout, texture_layout, material_layout], + immediate_size: 0, + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("PBR Pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[MeshVertex::LAYOUT], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) +} diff --git a/crates/voltex_renderer/src/pbr_shader.wgsl b/crates/voltex_renderer/src/pbr_shader.wgsl new file mode 100644 index 0000000..43c3a6c --- /dev/null +++ b/crates/voltex_renderer/src/pbr_shader.wgsl @@ -0,0 +1,131 @@ +struct CameraUniform { + view_proj: mat4x4, + model: mat4x4, + camera_pos: vec3, +}; + +struct LightUniform { + direction: vec3, + color: vec3, + ambient_strength: f32, +}; + +struct MaterialUniform { + base_color: vec4, + metallic: f32, + roughness: f32, + ao: f32, +}; + +@group(0) @binding(0) var camera: CameraUniform; +@group(0) @binding(1) var light: LightUniform; + +@group(1) @binding(0) var t_diffuse: texture_2d; +@group(1) @binding(1) var s_diffuse: sampler; + +@group(2) @binding(0) var material: MaterialUniform; + +struct VertexInput { + @location(0) position: vec3, + @location(1) normal: vec3, + @location(2) uv: vec2, +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) world_normal: vec3, + @location(1) world_pos: vec3, + @location(2) uv: vec2, +}; + +@vertex +fn vs_main(model_v: VertexInput) -> VertexOutput { + var out: VertexOutput; + let world_pos = camera.model * vec4(model_v.position, 1.0); + out.world_pos = world_pos.xyz; + out.world_normal = (camera.model * vec4(model_v.normal, 0.0)).xyz; + out.clip_position = camera.view_proj * world_pos; + out.uv = model_v.uv; + return out; +} + +// GGX Normal Distribution Function +fn distribution_ggx(N: vec3, H: vec3, roughness: f32) -> f32 { + let a = roughness * roughness; + let a2 = a * a; + let NdotH = max(dot(N, H), 0.0); + let NdotH2 = NdotH * NdotH; + let denom_inner = NdotH2 * (a2 - 1.0) + 1.0; + let denom = 3.14159265358979 * denom_inner * denom_inner; + return a2 / denom; +} + +// Schlick-GGX geometry function (single direction) +fn geometry_schlick_ggx(NdotV: f32, roughness: f32) -> f32 { + let r = roughness + 1.0; + let k = (r * r) / 8.0; + return NdotV / (NdotV * (1.0 - k) + k); +} + +// Smith geometry function (both directions) +fn geometry_smith(N: vec3, V: vec3, L: vec3, roughness: f32) -> f32 { + let NdotV = max(dot(N, V), 0.0); + let NdotL = max(dot(N, L), 0.0); + let ggx1 = geometry_schlick_ggx(NdotV, roughness); + let ggx2 = geometry_schlick_ggx(NdotL, roughness); + return ggx1 * ggx2; +} + +// Fresnel-Schlick approximation +fn fresnel_schlick(cosTheta: f32, F0: vec3) -> vec3 { + return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let tex_color = textureSample(t_diffuse, s_diffuse, in.uv); + let albedo = material.base_color.rgb * tex_color.rgb; + let metallic = material.metallic; + let roughness = material.roughness; + let ao = material.ao; + + let N = normalize(in.world_normal); + let V = normalize(camera.camera_pos - in.world_pos); + + // F0: base reflectivity; 0.04 for dielectrics, albedo for metals + let F0 = mix(vec3(0.04, 0.04, 0.04), albedo, metallic); + + // Single directional light + let L = normalize(-light.direction); + let H = normalize(V + L); + let radiance = light.color; + + // Cook-Torrance BRDF components + let NDF = distribution_ggx(N, H, roughness); + let G = geometry_smith(N, V, L, roughness); + let F = fresnel_schlick(max(dot(H, V), 0.0), F0); + + let ks = F; + let kd = (vec3(1.0) - ks) * (1.0 - metallic); + + let numerator = NDF * G * F; + let NdotL = max(dot(N, L), 0.0); + let NdotV = max(dot(N, V), 0.0); + let denominator = 4.0 * NdotV * NdotL + 0.0001; + let specular = numerator / denominator; + + let Lo = (kd * albedo / 3.14159265358979 + specular) * radiance * NdotL; + + // Ambient term + let ambient = light.ambient_strength * light.color * albedo * ao; + + var color = ambient + Lo; + + // Reinhard tone mapping + color = color / (color + vec3(1.0)); + + // Gamma correction + color = pow(color, vec3(1.0 / 2.2)); + + return vec4(color, material.base_color.a * tex_color.a); +} diff --git a/crates/voltex_renderer/src/sphere.rs b/crates/voltex_renderer/src/sphere.rs new file mode 100644 index 0000000..fec002f --- /dev/null +++ b/crates/voltex_renderer/src/sphere.rs @@ -0,0 +1,90 @@ +use crate::vertex::MeshVertex; +use std::f32::consts::PI; + +/// Generate a UV sphere with Y-up coordinate system. +/// Returns (vertices, indices). +pub fn generate_sphere(radius: f32, sectors: u32, stacks: u32) -> (Vec, Vec) { + let mut vertices: Vec = Vec::new(); + let mut indices: Vec = Vec::new(); + + let sector_step = 2.0 * PI / sectors as f32; + let stack_step = PI / stacks as f32; + + for i in 0..=stacks { + // Stack angle from PI/2 (top) to -PI/2 (bottom) + let stack_angle = PI / 2.0 - (i as f32) * stack_step; + let xz = radius * stack_angle.cos(); + let y = radius * stack_angle.sin(); + + for j in 0..=sectors { + let sector_angle = (j as f32) * sector_step; + + let x = xz * sector_angle.cos(); + let z = xz * sector_angle.sin(); + + let position = [x, y, z]; + let normal = [x / radius, y / radius, z / radius]; + let uv = [ + j as f32 / sectors as f32, + i as f32 / stacks as f32, + ]; + + vertices.push(MeshVertex { position, normal, uv }); + } + } + + // Indices: two triangles per quad + for i in 0..stacks { + for j in 0..sectors { + let k1 = i * (sectors + 1) + j; + let k2 = k1 + sectors + 1; + + // First triangle + indices.push(k1); + indices.push(k2); + indices.push(k1 + 1); + + // Second triangle + indices.push(k1 + 1); + indices.push(k2); + indices.push(k2 + 1); + } + } + + (vertices, indices) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sphere_vertex_count() { + let sectors = 36u32; + let stacks = 18u32; + let (vertices, _) = generate_sphere(1.0, sectors, stacks); + assert_eq!(vertices.len(), ((stacks + 1) * (sectors + 1)) as usize); + } + + #[test] + fn test_sphere_index_count() { + let sectors = 36u32; + let stacks = 18u32; + let (_, indices) = generate_sphere(1.0, sectors, stacks); + assert_eq!(indices.len(), (stacks * sectors * 6) as usize); + } + + #[test] + fn test_sphere_normals_unit_length() { + let (vertices, _) = generate_sphere(1.0, 12, 8); + for v in &vertices { + let n = v.normal; + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + assert!( + (len - 1.0).abs() < 1e-5, + "Normal length {} is not unit length", + len + ); + } + } +}