9eead719b0
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
436 lines
17 KiB
Rust
436 lines
17 KiB
Rust
//! [`ForwardRenderer`]: a single-pass forward renderer with a depth buffer and
|
||
//! one directional light.
|
||
//!
|
||
//! Stage 4 scope: draw a list of [`RenderObject`]s (each a [`GpuMesh`] +
|
||
//! [`Material`] + [`Transform`]) through the lit shader, into a caller-provided
|
||
//! color target, using an owned depth texture. Shadows, multiple lights, and
|
||
//! post-processing arrive in later stages.
|
||
|
||
use std::num::NonZeroU64;
|
||
|
||
use bytemuck::{Pod, Zeroable};
|
||
use glam::Mat3;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use super::mesh::{GpuMesh, Vertex};
|
||
use super::{Camera, Material};
|
||
use crate::math::{Color, Transform, Vec3, Vec4};
|
||
|
||
/// Depth buffer format used by the forward pass.
|
||
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
||
|
||
/// A directional light: parallel rays with a travel `direction`.
|
||
///
|
||
/// Also a **reflected, addable component**: drop one on an entity to author a
|
||
/// sun/key light in the scene, dual-editable from the editor and scripts.
|
||
/// (Gathering light entities into the forward pass is a later-stage wiring; the
|
||
/// renderer currently takes its [`Lighting`] directly.)
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, crate::reflect::Reflect)]
|
||
pub struct DirectionalLight {
|
||
/// The direction the light travels (does not need to be normalized).
|
||
pub direction: Vec3,
|
||
/// Light color.
|
||
pub color: Color,
|
||
/// Scalar intensity multiplier.
|
||
pub intensity: f32,
|
||
}
|
||
|
||
impl Default for DirectionalLight {
|
||
fn default() -> Self {
|
||
Self {
|
||
direction: Vec3::new(-0.5, -1.0, -0.35),
|
||
color: Color::WHITE,
|
||
intensity: 1.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scene lighting for a forward pass: one directional light plus an ambient term.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct Lighting {
|
||
/// The single directional (sun) light.
|
||
pub light: DirectionalLight,
|
||
/// Flat ambient color added everywhere (cheap fill light).
|
||
pub ambient: Color,
|
||
}
|
||
|
||
impl Default for Lighting {
|
||
fn default() -> Self {
|
||
Self {
|
||
light: DirectionalLight::default(),
|
||
ambient: Color::rgb(0.08, 0.08, 0.10),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One drawable: a GPU mesh placed by `transform` and shaded with `material`.
|
||
pub struct RenderObject<'a> {
|
||
/// The mesh to draw.
|
||
pub mesh: &'a GpuMesh,
|
||
/// Its surface material.
|
||
pub material: Material,
|
||
/// World placement.
|
||
pub transform: Transform,
|
||
}
|
||
|
||
#[repr(C)]
|
||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||
struct GlobalsUniform {
|
||
view_proj: [[f32; 4]; 4],
|
||
camera_pos: [f32; 4],
|
||
light_dir: [f32; 4],
|
||
light_color: [f32; 4],
|
||
ambient: [f32; 4],
|
||
}
|
||
|
||
#[repr(C)]
|
||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||
struct ObjectUniform {
|
||
model: [[f32; 4]; 4],
|
||
normal_mtx: [[f32; 4]; 4],
|
||
albedo: [f32; 4],
|
||
mr: [f32; 4],
|
||
}
|
||
|
||
/// A forward renderer owning its pipeline, depth buffer, and uniform storage.
|
||
pub struct ForwardRenderer {
|
||
pipeline: wgpu::RenderPipeline,
|
||
globals_buffer: wgpu::Buffer,
|
||
globals_bind_group: wgpu::BindGroup,
|
||
object_layout: wgpu::BindGroupLayout,
|
||
object_buffer: wgpu::Buffer,
|
||
object_bind_group: wgpu::BindGroup,
|
||
/// Per-object stride: `size_of::<ObjectUniform>` rounded up to the device's
|
||
/// minimum dynamic-uniform-buffer offset alignment.
|
||
object_stride: u64,
|
||
object_capacity: u32,
|
||
depth: Option<DepthTarget>,
|
||
color_format: wgpu::TextureFormat,
|
||
}
|
||
|
||
struct DepthTarget {
|
||
view: wgpu::TextureView,
|
||
width: u32,
|
||
height: u32,
|
||
}
|
||
|
||
impl ForwardRenderer {
|
||
/// Builds the renderer for a given color target format (e.g. the surface
|
||
/// format for a window, or `Rgba8Unorm` for offscreen rendering).
|
||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("oxide.forward.lit"),
|
||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/lit.wgsl").into()),
|
||
});
|
||
|
||
let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||
label: Some("oxide.forward.globals_layout"),
|
||
entries: &[wgpu::BindGroupLayoutEntry {
|
||
binding: 0,
|
||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: false,
|
||
min_binding_size: NonZeroU64::new(std::mem::size_of::<GlobalsUniform>() as u64),
|
||
},
|
||
count: None,
|
||
}],
|
||
});
|
||
|
||
let object_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||
label: Some("oxide.forward.object_layout"),
|
||
entries: &[wgpu::BindGroupLayoutEntry {
|
||
binding: 0,
|
||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: true,
|
||
min_binding_size: NonZeroU64::new(std::mem::size_of::<ObjectUniform>() as u64),
|
||
},
|
||
count: None,
|
||
}],
|
||
});
|
||
|
||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("oxide.forward.pipeline_layout"),
|
||
bind_group_layouts: &[Some(&globals_layout), Some(&object_layout)],
|
||
immediate_size: 0,
|
||
});
|
||
|
||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("oxide.forward.pipeline"),
|
||
layout: Some(&pipeline_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &shader,
|
||
entry_point: Some("vs_main"),
|
||
compilation_options: Default::default(),
|
||
buffers: &[Vertex::LAYOUT],
|
||
},
|
||
primitive: wgpu::PrimitiveState {
|
||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||
strip_index_format: None,
|
||
front_face: wgpu::FrontFace::Ccw,
|
||
cull_mode: Some(wgpu::Face::Back),
|
||
unclipped_depth: false,
|
||
polygon_mode: wgpu::PolygonMode::Fill,
|
||
conservative: false,
|
||
},
|
||
depth_stencil: Some(wgpu::DepthStencilState {
|
||
format: DEPTH_FORMAT,
|
||
depth_write_enabled: Some(true),
|
||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||
stencil: wgpu::StencilState::default(),
|
||
bias: wgpu::DepthBiasState::default(),
|
||
}),
|
||
multisample: wgpu::MultisampleState::default(),
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &shader,
|
||
entry_point: Some("fs_main"),
|
||
compilation_options: Default::default(),
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format: color_format,
|
||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||
write_mask: wgpu::ColorWrites::ALL,
|
||
})],
|
||
}),
|
||
multiview_mask: None,
|
||
cache: None,
|
||
});
|
||
|
||
let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||
label: Some("oxide.forward.globals"),
|
||
size: std::mem::size_of::<GlobalsUniform>() as u64,
|
||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||
mapped_at_creation: false,
|
||
});
|
||
let globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||
label: Some("oxide.forward.globals_bg"),
|
||
layout: &globals_layout,
|
||
entries: &[wgpu::BindGroupEntry {
|
||
binding: 0,
|
||
resource: globals_buffer.as_entire_binding(),
|
||
}],
|
||
});
|
||
|
||
let object_stride = align_up(
|
||
std::mem::size_of::<ObjectUniform>() as u64,
|
||
device.limits().min_uniform_buffer_offset_alignment as u64,
|
||
);
|
||
let object_capacity = 16;
|
||
let (object_buffer, object_bind_group) =
|
||
create_object_storage(device, &object_layout, object_stride, object_capacity);
|
||
|
||
Self {
|
||
pipeline,
|
||
globals_buffer,
|
||
globals_bind_group,
|
||
object_layout,
|
||
object_buffer,
|
||
object_bind_group,
|
||
object_stride,
|
||
object_capacity,
|
||
depth: None,
|
||
color_format,
|
||
}
|
||
}
|
||
|
||
/// The color target format this renderer was built for.
|
||
pub fn color_format(&self) -> wgpu::TextureFormat {
|
||
self.color_format
|
||
}
|
||
|
||
/// Renders `objects` into `target` (whose full physical size is
|
||
/// `width`×`height`) as seen by `camera` placed at `view_transform`, lit
|
||
/// by `lighting`. Drawing is restricted to `viewport_rect` (a sub-
|
||
/// rectangle of the target), and the projection uses that rect's aspect
|
||
/// ratio.
|
||
///
|
||
/// The color target is *loaded* (not cleared) so a clear pass run before
|
||
/// this — e.g. the window's clear color — shows through as the background;
|
||
/// the depth buffer is cleared to 1.0 each call.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn render(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
target: &wgpu::TextureView,
|
||
(width, height): (u32, u32),
|
||
viewport_rect: crate::math::Rect,
|
||
camera: &Camera,
|
||
view_transform: &Transform,
|
||
lighting: &Lighting,
|
||
objects: &[RenderObject<'_>],
|
||
) {
|
||
let (width, height) = (width.max(1), height.max(1));
|
||
// Clamp the viewport rect to the target so wgpu doesn't complain.
|
||
let vp_w = viewport_rect.width().max(1.0).min(width as f32);
|
||
let vp_h = viewport_rect.height().max(1.0).min(height as f32);
|
||
let vp_x = viewport_rect.min.x.max(0.0).min(width as f32 - vp_w);
|
||
let vp_y = viewport_rect.min.y.max(0.0).min(height as f32 - vp_h);
|
||
|
||
// Depth must match the full color target's dimensions (the
|
||
// attachment binding requires that). Pixels outside `set_viewport`
|
||
// are never written, so the extra depth is wasted memory but never
|
||
// incorrect.
|
||
self.ensure_depth(device, width, height);
|
||
self.ensure_object_capacity(device, objects.len() as u32);
|
||
|
||
// Globals — aspect comes from the viewport rect, not the target.
|
||
let aspect = vp_w / vp_h;
|
||
let view_proj = camera.view_projection(aspect, view_transform);
|
||
let to_light = (-lighting.light.direction).normalize_or_zero();
|
||
let lc = lighting.light.color;
|
||
let amb = lighting.ambient;
|
||
let globals = GlobalsUniform {
|
||
view_proj: view_proj.to_cols_array_2d(),
|
||
camera_pos: view_transform.translation.extend(1.0).to_array(),
|
||
light_dir: to_light.extend(0.0).to_array(),
|
||
light_color: (Vec4::new(lc.r, lc.g, lc.b, 1.0) * lighting.light.intensity).to_array(),
|
||
ambient: Vec4::new(amb.r, amb.g, amb.b, 1.0).to_array(),
|
||
};
|
||
queue.write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
|
||
|
||
// Per-object uniforms.
|
||
for (i, obj) in objects.iter().enumerate() {
|
||
let model = obj.transform.to_matrix();
|
||
let normal_mtx = Mat3::from_mat4(model).inverse().transpose();
|
||
let normal_mtx4 = [
|
||
normal_mtx.x_axis.extend(0.0).to_array(),
|
||
normal_mtx.y_axis.extend(0.0).to_array(),
|
||
normal_mtx.z_axis.extend(0.0).to_array(),
|
||
[0.0, 0.0, 0.0, 1.0],
|
||
];
|
||
let a = obj.material.albedo;
|
||
let uniform = ObjectUniform {
|
||
model: model.to_cols_array_2d(),
|
||
normal_mtx: normal_mtx4,
|
||
albedo: [a.r, a.g, a.b, a.a],
|
||
mr: [obj.material.metallic, obj.material.roughness, 0.0, 0.0],
|
||
};
|
||
queue.write_buffer(
|
||
&self.object_buffer,
|
||
i as u64 * self.object_stride,
|
||
bytemuck::bytes_of(&uniform),
|
||
);
|
||
}
|
||
|
||
let depth_view = &self.depth.as_ref().expect("depth ensured above").view;
|
||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||
label: Some("oxide.forward.encoder"),
|
||
});
|
||
{
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("oxide.forward.pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: target,
|
||
resolve_target: None,
|
||
depth_slice: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||
view: depth_view,
|
||
depth_ops: Some(wgpu::Operations {
|
||
load: wgpu::LoadOp::Clear(1.0),
|
||
store: wgpu::StoreOp::Store,
|
||
}),
|
||
stencil_ops: None,
|
||
}),
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
multiview_mask: None,
|
||
});
|
||
// Restrict drawing to the host's viewport sub-rect. Pixels
|
||
// outside this rectangle keep whatever the prior pass (e.g.
|
||
// ClearPass or the window clear) wrote there.
|
||
pass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0);
|
||
pass.set_pipeline(&self.pipeline);
|
||
pass.set_bind_group(0, &self.globals_bind_group, &[]);
|
||
for (i, obj) in objects.iter().enumerate() {
|
||
let offset = (i as u64 * self.object_stride) as u32;
|
||
pass.set_bind_group(1, &self.object_bind_group, &[offset]);
|
||
pass.set_vertex_buffer(0, obj.mesh.vertex_buffer.slice(..));
|
||
pass.set_index_buffer(obj.mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||
pass.draw_indexed(0..obj.mesh.index_count, 0, 0..1);
|
||
}
|
||
}
|
||
queue.submit([encoder.finish()]);
|
||
}
|
||
|
||
fn ensure_depth(&mut self, device: &wgpu::Device, width: u32, height: u32) {
|
||
let stale = match &self.depth {
|
||
Some(d) => d.width != width || d.height != height,
|
||
None => true,
|
||
};
|
||
if stale {
|
||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some("oxide.forward.depth"),
|
||
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,
|
||
view_formats: &[],
|
||
});
|
||
self.depth = Some(DepthTarget {
|
||
view: texture.create_view(&wgpu::TextureViewDescriptor::default()),
|
||
width,
|
||
height,
|
||
});
|
||
}
|
||
}
|
||
|
||
fn ensure_object_capacity(&mut self, device: &wgpu::Device, needed: u32) {
|
||
if needed > self.object_capacity {
|
||
let capacity = needed.next_power_of_two();
|
||
let (buffer, bind_group) =
|
||
create_object_storage(device, &self.object_layout, self.object_stride, capacity);
|
||
self.object_buffer = buffer;
|
||
self.object_bind_group = bind_group;
|
||
self.object_capacity = capacity;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Allocates the per-object uniform buffer (`capacity` slots of `stride` bytes)
|
||
/// and a dynamic-offset bind group over it.
|
||
fn create_object_storage(
|
||
device: &wgpu::Device,
|
||
layout: &wgpu::BindGroupLayout,
|
||
stride: u64,
|
||
capacity: u32,
|
||
) -> (wgpu::Buffer, wgpu::BindGroup) {
|
||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||
label: Some("oxide.forward.objects"),
|
||
size: stride * capacity as u64,
|
||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||
mapped_at_creation: false,
|
||
});
|
||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||
label: Some("oxide.forward.object_bg"),
|
||
layout,
|
||
entries: &[wgpu::BindGroupEntry {
|
||
binding: 0,
|
||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||
buffer: &buffer,
|
||
offset: 0,
|
||
size: NonZeroU64::new(std::mem::size_of::<ObjectUniform>() as u64),
|
||
}),
|
||
}],
|
||
});
|
||
(buffer, bind_group)
|
||
}
|
||
|
||
/// Rounds `value` up to the next multiple of `align` (a power of two).
|
||
fn align_up(value: u64, align: u64) -> u64 {
|
||
let align = align.max(1);
|
||
value.div_ceil(align) * align
|
||
}
|