feat(renderer): add PBR material, sphere generator, Cook-Torrance shader, and PBR pipeline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
51
crates/voltex_renderer/src/material.rs
Normal file
51
crates/voltex_renderer/src/material.rs
Normal file
@@ -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::<MaterialUniform>() as u64,
|
||||
),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
65
crates/voltex_renderer/src/pbr_pipeline.rs
Normal file
65
crates/voltex_renderer/src/pbr_pipeline.rs
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
131
crates/voltex_renderer/src/pbr_shader.wgsl
Normal file
131
crates/voltex_renderer/src/pbr_shader.wgsl
Normal file
@@ -0,0 +1,131 @@
|
||||
struct CameraUniform {
|
||||
view_proj: mat4x4<f32>,
|
||||
model: mat4x4<f32>,
|
||||
camera_pos: vec3<f32>,
|
||||
};
|
||||
|
||||
struct LightUniform {
|
||||
direction: vec3<f32>,
|
||||
color: vec3<f32>,
|
||||
ambient_strength: f32,
|
||||
};
|
||||
|
||||
struct MaterialUniform {
|
||||
base_color: vec4<f32>,
|
||||
metallic: f32,
|
||||
roughness: f32,
|
||||
ao: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> camera: CameraUniform;
|
||||
@group(0) @binding(1) var<uniform> light: LightUniform;
|
||||
|
||||
@group(1) @binding(0) var t_diffuse: texture_2d<f32>;
|
||||
@group(1) @binding(1) var s_diffuse: sampler;
|
||||
|
||||
@group(2) @binding(0) var<uniform> material: MaterialUniform;
|
||||
|
||||
struct VertexInput {
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) world_normal: vec3<f32>,
|
||||
@location(1) world_pos: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(model_v: VertexInput) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
let world_pos = camera.model * vec4<f32>(model_v.position, 1.0);
|
||||
out.world_pos = world_pos.xyz;
|
||||
out.world_normal = (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;
|
||||
}
|
||||
|
||||
// GGX Normal Distribution Function
|
||||
fn distribution_ggx(N: vec3<f32>, H: vec3<f32>, 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<f32>, V: vec3<f32>, L: vec3<f32>, 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<f32>) -> vec3<f32> {
|
||||
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<f32> {
|
||||
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<f32>(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<f32>(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<f32>(1.0));
|
||||
|
||||
// Gamma correction
|
||||
color = pow(color, vec3<f32>(1.0 / 2.2));
|
||||
|
||||
return vec4<f32>(color, material.base_color.a * tex_color.a);
|
||||
}
|
||||
90
crates/voltex_renderer/src/sphere.rs
Normal file
90
crates/voltex_renderer/src/sphere.rs
Normal file
@@ -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<MeshVertex>, Vec<u32>) {
|
||||
let mut vertices: Vec<MeshVertex> = Vec::new();
|
||||
let mut indices: Vec<u32> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user