Files
Oxide/engine/src/render/forward.rs
T
Homer Simpson 9eead719b0 Import Oxide engine (Stages 0–10) under MIT license
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>
2026-07-05 20:41:02 +02:00

436 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! [`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
}