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:
2026-03-24 20:41:02 +09:00
parent cca50c8bc2
commit b09e1df878
5 changed files with 343 additions and 0 deletions

View File

@@ -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;

View 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,
}],
})
}
}

View 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,
})
}

View 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);
}

View 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
);
}
}
}