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>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit f56a1eea3b
128 changed files with 40493 additions and 2 deletions
+142
View File
@@ -0,0 +1,142 @@
//! [`Camera`]: perspective projection plus view/projection matrix helpers.
//!
//! A camera holds only projection parameters; its *position* is a
//! [`Transform`] supplied at render time (so a camera can be an entity in the
//! scene). The view matrix is the inverse of that world transform.
use serde::{Deserialize, Serialize};
use crate::layer::{Layer, LayerMask};
use crate::math::{Mat4, Transform};
/// A perspective camera.
///
/// Stage 4 ships perspective projection only; orthographic and other
/// projections can be added later without changing the renderer interface.
///
/// A `Camera` is also a **reflected, addable component**: place one on an
/// entity and it becomes the scene's viewpoint, dual-editable from the editor
/// and scripts like any other component. (The runtime gathering of camera
/// entities into the render path is wired in a later stage; today the editor
/// drives its own viewport camera.)
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, crate::reflect::Reflect)]
pub struct Camera {
/// Vertical field of view, in radians.
pub fov_y: f32,
/// Near clip plane distance (> 0).
pub z_near: f32,
/// Far clip plane distance (> `z_near`).
pub z_far: f32,
/// The layers this camera renders. An entity is drawn only if its
/// [`Layer`](crate::layer::Layer) membership intersects this mask. Defaults
/// to [`LayerMask::ALL`] (sees everything) — e.g. a minimap or first-person
/// view-model camera narrows it. The host applies it when gathering objects.
pub visibility: LayerMask,
}
impl Default for Camera {
/// A 60° vertical FOV camera with a 0.11000 unit depth range that sees all
/// layers.
fn default() -> Self {
Self {
fov_y: 60_f32.to_radians(),
z_near: 0.1,
z_far: 1000.0,
visibility: LayerMask::ALL,
}
}
}
impl Camera {
/// Creates a perspective camera from a vertical FOV (radians) and clip range,
/// seeing all layers.
pub fn perspective(fov_y: f32, z_near: f32, z_far: f32) -> Self {
Self {
fov_y,
z_near,
z_far,
visibility: LayerMask::ALL,
}
}
/// Sets the layer-visibility mask (builder style).
pub fn with_visibility(mut self, visibility: LayerMask) -> Self {
self.visibility = visibility;
self
}
/// Whether this camera renders an entity with the given layer membership.
pub fn sees(&self, layer: Layer) -> bool {
layer.matches(self.visibility)
}
/// The projection matrix for a viewport of the given `aspect` (width /
/// height). Uses a reversed-Z-free, `0..1` NDC depth range (wgpu/Vulkan/
/// DX/Metal convention).
pub fn projection_matrix(&self, aspect: f32) -> Mat4 {
Mat4::perspective_rh(
self.fov_y,
aspect.max(f32::EPSILON),
self.z_near,
self.z_far,
)
}
/// The view matrix for a camera placed at `view_transform` — i.e. the
/// inverse of the camera's world transform.
pub fn view_matrix(view_transform: &Transform) -> Mat4 {
view_transform.to_matrix().inverse()
}
/// The combined view-projection matrix: `projection * view`.
pub fn view_projection(&self, aspect: f32, view_transform: &Transform) -> Mat4 {
self.projection_matrix(aspect) * Self::view_matrix(view_transform)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Vec3;
#[test]
fn visibility_filters_by_layer() {
// Default camera sees every layer.
let cam = Camera::default();
assert!(cam.sees(Layer::on(7)));
// A camera restricted to the "UI" layer (3) only sees layer-3 entities.
let ui_cam = Camera::default().with_visibility(LayerMask::layer(3));
assert!(ui_cam.sees(Layer::on(3)));
assert!(!ui_cam.sees(Layer::on(0)));
assert!(!ui_cam.sees(Layer::default())); // default layer 0
}
#[test]
fn projection_is_finite_and_depth_mapped() {
let cam = Camera::default();
let proj = cam.projection_matrix(16.0 / 9.0);
assert!(proj.is_finite());
// A point on the near plane maps to NDC z ~ 0, the far plane to ~ 1.
let near = proj.project_point3(Vec3::new(0.0, 0.0, -cam.z_near));
let far = proj.project_point3(Vec3::new(0.0, 0.0, -cam.z_far));
assert!(near.z.abs() < 1e-3, "near z = {}", near.z);
assert!((far.z - 1.0).abs() < 1e-3, "far z = {}", far.z);
}
#[test]
fn view_matrix_moves_world_into_camera_space() {
// Camera at +Z looking at the origin: the origin should sit straight
// ahead, down the camera's -Z axis.
let cam_tf = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
let view = Camera::view_matrix(&cam_tf);
let origin_in_view = view.project_point3(Vec3::ZERO);
assert!((origin_in_view.x).abs() < 1e-5);
assert!((origin_in_view.y).abs() < 1e-5);
assert!(
(origin_in_view.z + 5.0).abs() < 1e-4,
"z = {}",
origin_in_view.z
);
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Window surface rendering: swapchain configuration, resize, clear loop.
use std::sync::Arc;
use winit::window::Window;
use super::{clear_view, Gpu, RenderError};
use crate::math::Color;
use crate::window::RenderCtx;
/// Renders to a window surface.
///
/// Owns the [`Gpu`] plus the window's [`wgpu::Surface`] and its
/// configuration. Stage 2 scope: every frame is cleared to
/// [`clear_color`](Self::clear_color); draw passes come in later stages.
pub struct RenderContext {
gpu: Gpu,
surface: wgpu::Surface<'static>,
config: wgpu::SurfaceConfiguration,
clear_color: Color,
}
impl RenderContext {
/// Acquires the GPU and configures a surface for `window`.
///
/// The window is held by `Arc` so the surface (which borrows it) can be
/// `'static`, as winit hands out windows from its event loop.
///
/// To run on any device, several render backends are tried in turn — the
/// default (env-selected Vulkan/Metal/DX12), then GL, then a software
/// adapter — and the first that produces a *configurable* surface wins.
/// This is what lets the engine survive drivers that report a GPU but
/// cannot present to the window's surface (e.g. old NVIDIA on Wayland under
/// Vulkan, where `surface.configure` would otherwise fail).
pub fn new(window: Arc<Window>) -> Result<Self, RenderError> {
// (label, backend override, force a software adapter)
let attempts: [(&str, Option<wgpu::Backends>, bool); 3] = [
("default", None, false),
("GL", Some(wgpu::Backends::GL), false),
("software", None, true),
];
let mut last_err: Option<RenderError> = None;
for (i, &(label, backends, force_fallback)) in attempts.iter().enumerate() {
match Self::try_backend(&window, backends, force_fallback) {
Ok(ctx) => {
if i > 0 {
log::warn!("render backend fell back to '{label}'");
}
return Ok(ctx);
}
Err(err) => {
log::warn!("render backend '{label}' unavailable: {err}");
last_err = Some(err);
}
}
}
Err(last_err.unwrap_or(RenderError::NoWorkingBackend))
}
/// Attempts one backend: build an instance (optionally forcing `backends`),
/// create the surface, acquire an adapter/device (optionally a software
/// one), and configure the surface. Any failure returns `Err` so the caller
/// can try the next backend rather than aborting the process.
fn try_backend(
window: &Arc<Window>,
backends: Option<wgpu::Backends>,
force_fallback_adapter: bool,
) -> Result<Self, RenderError> {
let size = window.inner_size();
// The window doubles as the display handle (needed by GL/X11-style
// backends); `from_env` keeps backend/flags overridable via WGPU_*.
let mut desc =
wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone()));
if let Some(backends) = backends {
desc.backends = backends;
}
let instance = wgpu::Instance::new(desc);
let surface = instance.create_surface(window.clone())?;
let gpu = Gpu::with_instance(instance, Some(&surface), force_fallback_adapter)?;
let config = surface
.get_default_config(gpu.adapter(), size.width.max(1), size.height.max(1))
.ok_or(RenderError::UnsupportedSurface)?;
configure_surface(gpu.device(), &surface, &config)?;
log::info!(
"surface configured: {}x{} {:?} ({:?}) on {:?}",
config.width,
config.height,
config.format,
config.present_mode,
gpu.adapter().get_info().backend,
);
Ok(Self {
gpu,
surface,
config,
clear_color: Color::BLACK,
})
}
/// Reconfigures the surface for a new window size. Zero dimensions
/// (minimized window) are clamped to 1 so the surface stays valid.
pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width.max(1);
self.config.height = height.max(1);
self.surface.configure(self.gpu.device(), &self.config);
}
/// Current surface size in physical pixels.
pub fn size(&self) -> (u32, u32) {
(self.config.width, self.config.height)
}
/// The surface's texture format. Apps need this to build render pipelines
/// (or UI integrations) whose output matches the surface.
pub fn surface_format(&self) -> wgpu::TextureFormat {
self.config.format
}
/// The color the surface is cleared to each frame.
pub fn clear_color(&self) -> Color {
self.clear_color
}
/// Sets the clear color; takes effect on the next rendered frame.
pub fn set_clear_color(&mut self, color: Color) {
self.clear_color = color;
}
/// Renders one frame: acquires the next surface texture, clears it, and
/// presents. Equivalent to [`render_frame_with`](Self::render_frame_with)
/// with an empty draw hook.
pub fn render_frame(&mut self, window: &Window) -> Result<(), RenderError> {
self.render_frame_with(window, |_| {})
}
/// Renders one frame, invoking `draw` after the clear and before present.
///
/// The surface texture is acquired and cleared to
/// [`clear_color`](Self::clear_color), then `draw` is handed a
/// [`RenderCtx`] so it can record additional passes into the same view
/// (use `LoadOp::Load` to preserve the clear), and finally the frame is
/// presented.
///
/// Lost or outdated surfaces (e.g. mid-resize) are reconfigured and the
/// frame skipped; timed-out or occluded acquires skip the frame. All are
/// normal transient conditions and not reported as errors.
pub fn render_frame_with(
&mut self,
window: &Window,
draw: impl FnOnce(&RenderCtx<'_>),
) -> Result<(), RenderError> {
use wgpu::CurrentSurfaceTexture;
let frame = match self.surface.get_current_texture() {
// A suboptimal frame is still presentable; the next resize event
// reconfigures the surface anyway.
CurrentSurfaceTexture::Success(frame) | CurrentSurfaceTexture::Suboptimal(frame) => {
frame
}
CurrentSurfaceTexture::Lost | CurrentSurfaceTexture::Outdated => {
self.surface.configure(self.gpu.device(), &self.config);
return Ok(());
}
CurrentSurfaceTexture::Timeout | CurrentSurfaceTexture::Occluded => return Ok(()),
CurrentSurfaceTexture::Validation => return Err(RenderError::SurfaceValidation),
};
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
clear_view(self.gpu.device(), self.gpu.queue(), &view, self.clear_color);
let ctx = RenderCtx {
gpu: &self.gpu,
view: &view,
window,
surface_format: self.config.format,
size: (self.config.width, self.config.height),
};
draw(&ctx);
frame.present();
Ok(())
}
/// The underlying GPU handle.
pub fn gpu(&self) -> &Gpu {
&self.gpu
}
}
/// Configures `surface`, capturing any validation error instead of letting it
/// reach wgpu's default (fatal, process-aborting) error handler.
///
/// `surface.configure` returns `()` and reports failures through the device's
/// error sink, which by default panics. Wrapping it in a validation error scope
/// turns "Invalid surface" (and similar) into a recoverable [`Result`] so the
/// caller can fall back to another backend.
fn configure_surface(
device: &wgpu::Device,
surface: &wgpu::Surface<'static>,
config: &wgpu::SurfaceConfiguration,
) -> Result<(), RenderError> {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
surface.configure(device, config);
// `pop()` consumes the guard and yields any captured error. On native
// backends the future is already resolved; `block_on` just unwraps it.
if let Some(err) = pollster::block_on(scope.pop()) {
return Err(RenderError::SurfaceConfigure(err.to_string()));
}
Ok(())
}
+435
View File
@@ -0,0 +1,435 @@
//! [`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
}
+94
View File
@@ -0,0 +1,94 @@
//! GPU acquisition: instance, adapter, device, queue.
use super::RenderError;
/// A handle to the GPU: instance, adapter, and the device/queue pair every
/// rendering operation goes through.
///
/// Created either for a window surface (via [`RenderContext`]) or headless
/// with [`Gpu::headless`] for offscreen rendering and tests.
///
/// [`RenderContext`]: super::RenderContext
pub struct Gpu {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Gpu {
/// Acquires an adapter and device from an existing `instance`, preferring
/// an adapter that can present to `compatible_surface` when one is given.
///
/// `force_fallback_adapter` requests a software adapter (e.g. llvmpipe),
/// used as a last resort when no hardware adapter works.
pub(crate) fn with_instance(
instance: wgpu::Instance,
compatible_surface: Option<&wgpu::Surface<'_>>,
force_fallback_adapter: bool,
) -> Result<Self, RenderError> {
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter,
compatible_surface,
}))?;
log::info!(
"GPU adapter: {} ({:?})",
adapter.get_info().name,
adapter.get_info().backend
);
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("oxide.device"),
..Default::default()
}))?;
Ok(Self {
instance,
adapter,
device,
queue,
})
}
/// Acquires the GPU without any surface, for offscreen rendering and
/// automated tests.
///
/// Tries a hardware adapter first, then falls back to a software adapter
/// (e.g. llvmpipe) so headless rendering also works on machines without a
/// usable GPU.
pub fn headless() -> Result<Self, RenderError> {
// `from_env` keeps backend/flags overridable via WGPU_* env vars.
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
match Self::with_instance(instance, None, false) {
Ok(gpu) => Ok(gpu),
Err(hardware_err) => {
log::warn!("no hardware GPU adapter ({hardware_err}); trying software fallback");
let instance = wgpu::Instance::new(
wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
);
Self::with_instance(instance, None, true)
}
}
}
/// The wgpu instance the adapter was created from.
pub fn instance(&self) -> &wgpu::Instance {
&self.instance
}
/// The physical adapter in use.
pub fn adapter(&self) -> &wgpu::Adapter {
&self.adapter
}
/// The logical device used to create GPU resources.
pub fn device(&self) -> &wgpu::Device {
&self.device
}
/// The queue used to submit command buffers.
pub fn queue(&self) -> &wgpu::Queue {
&self.queue
}
}
+51
View File
@@ -0,0 +1,51 @@
//! [`Material`]: a PBR-lite surface description.
//!
//! Stage 4 keeps materials to the parameters the basic lit pass consumes:
//! an albedo (base) color plus metallic/roughness factors. Textures, emissive,
//! and the full PBR set arrive with the shader system in a later stage.
use serde::{Deserialize, Serialize};
use crate::math::Color;
/// A PBR-lite material: base color and metallic/roughness factors.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Material {
/// Base (albedo) color, linear RGBA.
pub albedo: Color,
/// Metalness in `[0, 1]` (0 = dielectric, 1 = metal).
pub metallic: f32,
/// Perceptual roughness in `[0, 1]` (0 = mirror, 1 = fully rough).
pub roughness: f32,
}
impl Default for Material {
/// A neutral mid-gray dielectric.
fn default() -> Self {
Self {
albedo: Color::rgb(0.8, 0.8, 0.8),
metallic: 0.0,
roughness: 0.6,
}
}
}
impl Material {
/// A matte, non-metallic material of the given color.
pub fn diffuse(albedo: Color) -> Self {
Self {
albedo,
metallic: 0.0,
roughness: 0.9,
}
}
/// A metallic material of the given color and roughness.
pub fn metal(albedo: Color, roughness: f32) -> Self {
Self {
albedo,
metallic: 1.0,
roughness: roughness.clamp(0.0, 1.0),
}
}
}
+256
View File
@@ -0,0 +1,256 @@
//! Mesh data: CPU-side [`Mesh`] geometry, its GPU upload ([`GpuMesh`]), and
//! built-in primitive builders.
//!
//! A [`Vertex`] carries position, normal, and UV — the minimal set the Stage 4
//! forward renderer needs for lit, textured-ready geometry. Meshes are built on
//! the CPU (procedurally or, later, from a GLTF import) and uploaded once into a
//! [`GpuMesh`] for drawing.
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use crate::math::{Aabb, Vec2, Vec3};
/// A single mesh vertex: position, normal, and texture coordinate.
///
/// `repr(C)` + [`Pod`] so a `&[Vertex]` can be uploaded straight into a GPU
/// vertex buffer with no per-field marshalling.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
pub struct Vertex {
/// Object-space position.
pub position: [f32; 3],
/// Object-space normal (expected unit length for correct lighting).
pub normal: [f32; 3],
/// Texture coordinate.
pub uv: [f32; 2],
}
impl Vertex {
/// Builds a vertex from math types.
pub fn new(position: Vec3, normal: Vec3, uv: Vec2) -> Self {
Self {
position: position.to_array(),
normal: normal.to_array(),
uv: uv.to_array(),
}
}
/// The `wgpu` vertex buffer layout matching this struct's fields
/// (`@location(0)` position, `@location(1)` normal, `@location(2)` uv).
pub const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![
0 => Float32x3, // position
1 => Float32x3, // normal
2 => Float32x2, // uv
],
};
}
/// CPU-side mesh geometry: an indexed triangle list.
///
/// Indices are `u32` (32-bit), so meshes are not limited to 65k vertices.
#[derive(Debug, Clone, Default)]
pub struct Mesh {
/// Vertex data.
pub vertices: Vec<Vertex>,
/// Triangle indices into [`vertices`](Self::vertices), three per triangle.
pub indices: Vec<u32>,
}
impl Mesh {
/// Creates a mesh from raw vertex and index data.
pub fn new(vertices: Vec<Vertex>, indices: Vec<u32>) -> Self {
Self { vertices, indices }
}
/// Number of triangles (index count / 3).
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
/// The axis-aligned bounds of the mesh in object space
/// ([`Aabb::EMPTY`](crate::math::Aabb) for an empty mesh).
pub fn bounds(&self) -> Aabb {
Aabb::from_points(self.vertices.iter().map(|v| Vec3::from_array(v.position)))
}
/// Uploads the mesh into GPU vertex/index buffers for drawing.
pub fn upload(&self, device: &wgpu::Device, label: &str) -> GpuMesh {
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("{label}.vertices")),
contents: bytemuck::cast_slice(&self.vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("{label}.indices")),
contents: bytemuck::cast_slice(&self.indices),
usage: wgpu::BufferUsages::INDEX,
});
GpuMesh {
vertex_buffer,
index_buffer,
index_count: self.indices.len() as u32,
}
}
/// A unit cube centered at the origin (side length 1), with per-face normals
/// and UVs (so each face is flat-shaded correctly).
pub fn cube() -> Self {
Self::box_mesh(Vec3::splat(1.0))
}
/// An axis-aligned box of the given `size` (full extents), centered at the
/// origin, with per-face normals and UVs.
pub fn box_mesh(size: Vec3) -> Self {
let h = size * 0.5;
// (normal, then the four corners CCW seen from outside)
let faces: [(Vec3, [Vec3; 4]); 6] = [
// +X
(
Vec3::X,
[
Vec3::new(h.x, -h.y, h.z),
Vec3::new(h.x, -h.y, -h.z),
Vec3::new(h.x, h.y, -h.z),
Vec3::new(h.x, h.y, h.z),
],
),
// -X
(
Vec3::NEG_X,
[
Vec3::new(-h.x, -h.y, -h.z),
Vec3::new(-h.x, -h.y, h.z),
Vec3::new(-h.x, h.y, h.z),
Vec3::new(-h.x, h.y, -h.z),
],
),
// +Y
(
Vec3::Y,
[
Vec3::new(-h.x, h.y, h.z),
Vec3::new(h.x, h.y, h.z),
Vec3::new(h.x, h.y, -h.z),
Vec3::new(-h.x, h.y, -h.z),
],
),
// -Y
(
Vec3::NEG_Y,
[
Vec3::new(-h.x, -h.y, -h.z),
Vec3::new(h.x, -h.y, -h.z),
Vec3::new(h.x, -h.y, h.z),
Vec3::new(-h.x, -h.y, h.z),
],
),
// +Z
(
Vec3::Z,
[
Vec3::new(-h.x, -h.y, h.z),
Vec3::new(h.x, -h.y, h.z),
Vec3::new(h.x, h.y, h.z),
Vec3::new(-h.x, h.y, h.z),
],
),
// -Z
(
Vec3::NEG_Z,
[
Vec3::new(h.x, -h.y, -h.z),
Vec3::new(-h.x, -h.y, -h.z),
Vec3::new(-h.x, h.y, -h.z),
Vec3::new(h.x, h.y, -h.z),
],
),
];
let uvs = [
Vec2::new(0.0, 1.0),
Vec2::new(1.0, 1.0),
Vec2::new(1.0, 0.0),
Vec2::new(0.0, 0.0),
];
let mut vertices = Vec::with_capacity(24);
let mut indices = Vec::with_capacity(36);
for (normal, corners) in faces {
let base = vertices.len() as u32;
for (corner, uv) in corners.iter().zip(uvs.iter()) {
vertices.push(Vertex::new(*corner, normal, *uv));
}
indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
}
Self::new(vertices, indices)
}
/// A flat plane of `size` units on the XZ axes, centered at the origin,
/// facing `+Y`. Useful as a ground reference.
pub fn plane(size: f32) -> Self {
let h = size * 0.5;
let n = Vec3::Y;
let vertices = vec![
Vertex::new(Vec3::new(-h, 0.0, h), n, Vec2::new(0.0, 1.0)),
Vertex::new(Vec3::new(h, 0.0, h), n, Vec2::new(1.0, 1.0)),
Vertex::new(Vec3::new(h, 0.0, -h), n, Vec2::new(1.0, 0.0)),
Vertex::new(Vec3::new(-h, 0.0, -h), n, Vec2::new(0.0, 0.0)),
];
Self::new(vertices, vec![0, 1, 2, 0, 2, 3])
}
/// A UV sphere of `radius` with `sectors` longitudinal and `stacks`
/// latitudinal divisions. Normals are the (normalized) positions.
pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Self {
use std::f32::consts::PI;
let sectors = sectors.max(3);
let stacks = stacks.max(2);
let mut vertices = Vec::new();
for i in 0..=stacks {
// From +Y pole (phi=0) to -Y pole (phi=PI).
let phi = PI * i as f32 / stacks as f32;
let (sin_phi, cos_phi) = phi.sin_cos();
for j in 0..=sectors {
let theta = 2.0 * PI * j as f32 / sectors as f32;
let (sin_theta, cos_theta) = theta.sin_cos();
let dir = Vec3::new(sin_phi * cos_theta, cos_phi, sin_phi * sin_theta);
let uv = Vec2::new(j as f32 / sectors as f32, i as f32 / stacks as f32);
vertices.push(Vertex::new(dir * radius, dir, uv));
}
}
let mut indices = Vec::new();
let row = sectors + 1;
for i in 0..stacks {
for j in 0..sectors {
let a = i * row + j;
let b = a + row;
// Two triangles per quad; skip degenerate ones at the poles.
// Vertex order is `a → a+1 → b` and `a+1 → b+1 → b`, which
// winds the quad CCW when seen from *outside* the sphere —
// the wgpu front-face convention. The previous ordering
// (`a, b, a+1` / `a+1, b, b+1`) wound them CW from outside,
// which made back-face culling eat the sphere's surface and
// showed intersecting opaque meshes through it.
if i != 0 {
indices.extend_from_slice(&[a, a + 1, b]);
}
if i != stacks - 1 {
indices.extend_from_slice(&[a + 1, b + 1, b]);
}
}
}
Self::new(vertices, indices)
}
}
/// A mesh uploaded to the GPU: vertex and index buffers ready to draw.
pub struct GpuMesh {
/// Vertex buffer, laid out per [`Vertex::LAYOUT`].
pub vertex_buffer: wgpu::Buffer,
/// `u32` index buffer.
pub index_buffer: wgpu::Buffer,
/// Number of indices to draw.
pub index_count: u32,
}
+111
View File
@@ -0,0 +1,111 @@
//! GPU rendering infrastructure.
//!
//! Stage 2 acquired a GPU ([`Gpu`]), drove a window surface ([`RenderContext`]),
//! and cleared it each frame. Stage 4 adds mesh rendering: build geometry
//! ([`Mesh`]/[`Vertex`]), upload it ([`GpuMesh`]), describe surfaces with a
//! [`Material`], place a [`Camera`], and draw through the [`ForwardRenderer`].
mod camera;
mod context;
mod forward;
mod gpu;
mod material;
mod mesh;
mod pipeline;
mod renderable;
mod ui_pass;
pub use camera::Camera;
pub use context::RenderContext;
pub use forward::{DirectionalLight, ForwardRenderer, Lighting, RenderObject, DEPTH_FORMAT};
pub use gpu::Gpu;
pub use material::Material;
pub use mesh::{GpuMesh, Mesh, Vertex};
pub use pipeline::{ClearPass, ForwardPass, FrameContext, RenderPass, RenderPipeline};
pub use renderable::{MeshRenderer, PrimitiveShape};
pub use ui_pass::{UiBatch, UiOverlayPass};
use crate::math::Color;
/// Errors produced by the rendering layer.
#[derive(Debug, thiserror::Error)]
pub enum RenderError {
/// No GPU adapter compatible with the requested surface (or headless use)
/// was found on this system.
#[error("no compatible GPU adapter found: {0}")]
NoAdapter(#[from] wgpu::RequestAdapterError),
/// The adapter was found but refused to provide a device.
#[error("failed to request GPU device: {0}")]
Device(#[from] wgpu::RequestDeviceError),
/// The window surface could not be created.
#[error("failed to create surface: {0}")]
CreateSurface(#[from] wgpu::CreateSurfaceError),
/// The adapter cannot present to the created surface.
#[error("the GPU adapter does not support presenting to this surface")]
UnsupportedSurface,
/// Configuring the surface raised a validation error. On some drivers a
/// backend reports a GPU but cannot actually present to the window surface
/// (e.g. old NVIDIA on Wayland under Vulkan); this is caught so the engine
/// can fall back to another backend instead of aborting.
#[error("surface configuration failed: {0}")]
SurfaceConfigure(String),
/// Every render backend/adapter the engine tried failed to produce a
/// working surface — no usable GPU path on this system.
#[error("no working render backend found (tried Vulkan/Metal/DX12, GL, and software)")]
NoWorkingBackend,
/// Acquiring the next frame raised a validation error — a bug in surface
/// configuration, not a transient condition.
#[error("surface frame acquisition failed validation")]
SurfaceValidation,
}
/// Records and submits a render pass that clears `view` to `color`.
///
/// This is the whole of Stage 2's rendering: both the windowed
/// [`RenderContext`] and offscreen targets (e.g. tests) clear through here.
pub fn clear_view(
device: &wgpu::Device,
queue: &wgpu::Queue,
view: &wgpu::TextureView,
color: Color,
) {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("oxide.clear"),
});
// The pass is dropped immediately: a load-op clear with no draws is all
// that is needed to fill the target.
let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("oxide.clear.pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(to_wgpu_color(color)),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
queue.submit([encoder.finish()]);
}
/// Converts the engine's [`Color`] (linear `f32`) to a [`wgpu::Color`]
/// (linear `f64`), as used by clear operations.
pub fn to_wgpu_color(color: Color) -> wgpu::Color {
wgpu::Color {
r: color.r as f64,
g: color.g as f64,
b: color.b as f64,
a: color.a as f64,
}
}
+233
View File
@@ -0,0 +1,233 @@
//! [`RenderPipeline`]: a data-driven, ordered list of composable render passes.
//!
//! Stage 4's renderer drew everything in one hardcoded pass. Stage 5 generalizes
//! that into a list of named [`RenderPass`]es that share one frame's targets and
//! run in order. A project enables only the passes it needs — this is the
//! mechanism behind *scalable fidelity*: a flat unlit/low-poly look (or a
//! stylized post effect) versus a full realistic stack, paying only for the
//! passes turned on.
//!
//! The Stage-4 forward pass is retrofitted onto this as [`ForwardPass`], so the
//! default pipeline ([`RenderPipeline::forward`]) is just `[Clear, Forward]` and
//! produces pixel-identical output. Later stages add passes (shadows,
//! post-process, overlay UI) **without touching the renderer core** — they
//! register a pass.
use crate::math::{Color, Rect, Transform, Vec2};
use super::{clear_view, Camera, ForwardRenderer, Lighting, RenderObject};
/// Everything one frame's passes operate on: the shared color target and the
/// scene view to draw.
///
/// Passes share the same `color` target (and, as the pipeline grows, depth and
/// intermediate textures), which is what makes them *composable*: a clear pass
/// fills the target, the forward pass draws into it, a future post pass reads and
/// rewrites it.
pub struct FrameContext<'a> {
/// The GPU device.
pub device: &'a wgpu::Device,
/// The GPU queue.
pub queue: &'a wgpu::Queue,
/// The color target every pass renders into.
pub color: &'a wgpu::TextureView,
/// Target size in physical pixels (the whole color target the pipeline
/// is writing into).
pub size: (u32, u32),
/// The sub-rectangle of the target that drawing is restricted to, in
/// physical pixels (`min` = upper-left, `max` = lower-right). Passes
/// configure the wgpu viewport from this and the camera uses its
/// aspect ratio for the projection.
///
/// `None` means "use the full target" — the default for headless tests
/// and for hosts that render to a whole window. The editor sets this to
/// the Viewport tab's rect from the docking shell so picking and
/// projection align with what the user sees inside the tab rather than
/// stretching across the whole window.
pub viewport_rect: Option<Rect>,
/// The background clear color (used by [`ClearPass`]).
pub clear_color: Color,
/// The camera to render from.
pub camera: &'a Camera,
/// The camera's world placement.
pub view_transform: &'a Transform,
/// Scene lighting.
pub lighting: &'a Lighting,
/// The drawables, already culled by the host (e.g. by camera
/// [`visibility`](Camera::visibility)).
pub objects: &'a [RenderObject<'a>],
}
impl FrameContext<'_> {
/// The viewport rect [`viewport_rect`](Self::viewport_rect) resolves to —
/// the explicit sub-rect when set, otherwise the full target.
pub fn resolved_viewport(&self) -> Rect {
self.viewport_rect.unwrap_or_else(|| {
Rect::from_min_size(
Vec2::ZERO,
Vec2::new(self.size.0.max(1) as f32, self.size.1.max(1) as f32),
)
})
}
}
/// One stage of the frame. Implement this to add a custom pass; register it on a
/// [`RenderPipeline`]. Passes are owned by the pipeline and run in order.
pub trait RenderPass {
/// Records this pass's GPU work for the frame.
fn run(&mut self, frame: &mut FrameContext<'_>);
}
struct PassEntry {
name: String,
enabled: bool,
pass: Box<dyn RenderPass>,
}
/// An ordered, named list of render passes.
///
/// Add passes with [`add_pass`](Self::add_pass), toggle them with
/// [`set_enabled`](Self::set_enabled), or drop them with [`remove`](Self::remove)
/// — all without touching any pass's implementation. [`render`](Self::render)
/// runs every enabled pass in order against one [`FrameContext`].
#[derive(Default)]
pub struct RenderPipeline {
passes: Vec<PassEntry>,
}
impl RenderPipeline {
/// An empty pipeline (no passes).
pub fn new() -> Self {
Self::default()
}
/// The default forward pipeline: a [`ClearPass`] followed by a
/// [`ForwardPass`]. Pixel-identical to the Stage-4 renderer's output.
pub fn forward(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let mut pipeline = Self::new();
pipeline.add_pass("clear", ClearPass);
pipeline.add_pass("forward", ForwardPass::new(device, color_format));
pipeline
}
/// Appends a named pass (enabled). Replaces any existing pass with the same
/// name, keeping its position.
pub fn add_pass(&mut self, name: impl Into<String>, pass: impl RenderPass + 'static) {
let name = name.into();
let entry = PassEntry {
name: name.clone(),
enabled: true,
pass: Box::new(pass),
};
match self.passes.iter_mut().find(|e| e.name == name) {
Some(existing) => *existing = entry,
None => self.passes.push(entry),
}
}
/// Inserts a pass before the pass named `before` (or at the end if not
/// found). Useful for slotting a post effect into a fixed position.
pub fn insert_before(
&mut self,
before: &str,
name: impl Into<String>,
pass: impl RenderPass + 'static,
) {
let entry = PassEntry {
name: name.into(),
enabled: true,
pass: Box::new(pass),
};
match self.passes.iter().position(|e| e.name == before) {
Some(index) => self.passes.insert(index, entry),
None => self.passes.push(entry),
}
}
/// Enables or disables the named pass. Returns whether it exists.
pub fn set_enabled(&mut self, name: &str, enabled: bool) -> bool {
match self.passes.iter_mut().find(|e| e.name == name) {
Some(entry) => {
entry.enabled = enabled;
true
}
None => false,
}
}
/// Removes the named pass. Returns whether it existed.
pub fn remove(&mut self, name: &str) -> bool {
let before = self.passes.len();
self.passes.retain(|e| e.name != name);
self.passes.len() != before
}
/// Whether a pass with this name is registered.
pub fn has_pass(&self, name: &str) -> bool {
self.passes.iter().any(|e| e.name == name)
}
/// The pass names in execution order.
pub fn pass_names(&self) -> impl Iterator<Item = &str> {
self.passes.iter().map(|e| e.name.as_str())
}
/// Runs every enabled pass in order against `frame`.
pub fn render(&mut self, frame: &mut FrameContext<'_>) {
for entry in &mut self.passes {
if entry.enabled {
entry.pass.run(frame);
}
}
}
}
/// A pass that clears the color target to [`FrameContext::clear_color`].
///
/// Conventionally the first pass, so later passes load over the cleared
/// background (matching the Stage-4 clear-then-draw flow).
pub struct ClearPass;
impl RenderPass for ClearPass {
fn run(&mut self, frame: &mut FrameContext<'_>) {
clear_view(frame.device, frame.queue, frame.color, frame.clear_color);
}
}
/// A pass that draws the frame's objects with the lit forward renderer.
///
/// Wraps the Stage-4 [`ForwardRenderer`]; the color target is *loaded* (so a
/// preceding [`ClearPass`] shows through), depth is managed internally.
pub struct ForwardPass {
renderer: ForwardRenderer,
}
impl ForwardPass {
/// Builds a forward pass for the given color target format.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
Self {
renderer: ForwardRenderer::new(device, color_format),
}
}
/// The wrapped renderer's color format.
pub fn color_format(&self) -> wgpu::TextureFormat {
self.renderer.color_format()
}
}
impl RenderPass for ForwardPass {
fn run(&mut self, frame: &mut FrameContext<'_>) {
self.renderer.render(
frame.device,
frame.queue,
frame.color,
frame.size,
frame.resolved_viewport(),
frame.camera,
frame.view_transform,
frame.lighting,
frame.objects,
);
}
}
+134
View File
@@ -0,0 +1,134 @@
//! Renderable scene components: [`MeshRenderer`] and [`PrimitiveShape`].
//!
//! A [`MeshRenderer`] is the component that makes a scene entity show up in the
//! 3D viewport: it pairs a mesh source with a [`Material`]. Stage 4 ships the
//! built-in [`PrimitiveShape`] source (cube/sphere/plane) — lightweight and
//! serializable, so the editor (and later scripts/AI agents) can author what an
//! entity renders. Imported meshes attach later via a mesh-asset handle.
use serde::{Deserialize, Serialize};
use super::{Material, Mesh};
use crate::math::{Aabb, Vec3};
/// A built-in primitive mesh an entity can render.
///
/// This names a shape rather than embedding vertex data, so it stays tiny,
/// serializable, and cheap to edit; the renderer resolves it to a (cached)
/// [`Mesh`]/GPU buffer.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Default,
Serialize,
Deserialize,
crate::reflect::ReflectEnum,
)]
pub enum PrimitiveShape {
/// Unit cube centered at the origin.
#[default]
Cube,
/// Unit-radius UV sphere.
Sphere,
/// A 1×1 ground plane on the XZ axes, facing `+Y`.
Plane,
}
impl PrimitiveShape {
/// All shapes, for building caches / editor menus.
pub const ALL: [PrimitiveShape; 3] = [
PrimitiveShape::Cube,
PrimitiveShape::Sphere,
PrimitiveShape::Plane,
];
/// A human-readable label.
pub fn label(self) -> &'static str {
match self {
PrimitiveShape::Cube => "Cube",
PrimitiveShape::Sphere => "Sphere",
PrimitiveShape::Plane => "Plane",
}
}
/// Builds the CPU [`Mesh`] for this shape.
pub fn mesh(self) -> Mesh {
match self {
PrimitiveShape::Cube => Mesh::cube(),
PrimitiveShape::Sphere => Mesh::uv_sphere(1.0, 32, 16),
PrimitiveShape::Plane => Mesh::plane(1.0),
}
}
/// The object-space bounds of this shape, without building a mesh — used for
/// ray-picking and culling.
pub fn local_bounds(self) -> Aabb {
let half = match self {
PrimitiveShape::Cube => Vec3::splat(0.5),
PrimitiveShape::Sphere => Vec3::ONE,
PrimitiveShape::Plane => Vec3::new(0.5, 0.0, 0.5),
};
Aabb::from_center_half_extents(Vec3::ZERO, half)
}
}
/// Component: what an entity renders.
///
/// Attach to a scene entity (via the ECS) to make it appear in a forward pass.
/// Stage 4 sources the mesh from a [`PrimitiveShape`]; the [`Material`] is
/// edited in the inspector. Both are serializable, supporting the engine's
/// dual-editable (editor + script/AI) component goal.
#[derive(
Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, crate::reflect::Reflect,
)]
pub struct MeshRenderer {
/// The mesh to draw.
pub shape: PrimitiveShape,
/// The surface material.
pub material: Material,
}
impl MeshRenderer {
/// A renderer for `shape` with the default material.
pub fn new(shape: PrimitiveShape) -> Self {
Self {
shape,
material: Material::default(),
}
}
/// A renderer for `shape` with an explicit `material`.
pub fn with_material(shape: PrimitiveShape, material: Material) -> Self {
Self { shape, material }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Color;
#[test]
fn every_shape_builds_a_nonempty_mesh() {
for shape in PrimitiveShape::ALL {
let mesh = shape.mesh();
assert!(!mesh.vertices.is_empty(), "{shape:?} has no vertices");
assert!(mesh.triangle_count() > 0, "{shape:?} has no triangles");
}
}
#[test]
fn mesh_renderer_round_trips_through_ron() {
let mr = MeshRenderer::with_material(
PrimitiveShape::Sphere,
Material::metal(Color::rgb(0.2, 0.4, 0.8), 0.25),
);
let ron = ron::to_string(&mr).unwrap();
let back: MeshRenderer = ron::from_str(&ron).unwrap();
assert_eq!(mr, back);
}
}
+69
View File
@@ -0,0 +1,69 @@
// Stage 4 forward lit shader: a single directional light with Lambert diffuse,
// ambient, and a Blinn-Phong specular term scaled by material roughness/metallic
// (PBR-lite). Output is linear color; an sRGB surface format converts on write.
struct Globals {
view_proj: mat4x4<f32>,
camera_pos: vec4<f32>, // xyz world-space camera position
light_dir: vec4<f32>, // xyz unit vector pointing TOWARD the light
light_color: vec4<f32>, // rgb light color * intensity
ambient: vec4<f32>, // rgb ambient term
};
struct ObjectData {
model: mat4x4<f32>,
normal_mtx: mat4x4<f32>, // inverse-transpose of model (3x3 in a 4x4)
albedo: vec4<f32>,
mr: vec4<f32>, // x = metallic, y = roughness
};
@group(0) @binding(0) var<uniform> globals: Globals;
@group(1) @binding(0) var<uniform> obj: ObjectData;
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) world_normal: vec3<f32>,
@location(2) uv: vec2<f32>,
};
@vertex
fn vs_main(
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
) -> VsOut {
let world = obj.model * vec4<f32>(position, 1.0);
var out: VsOut;
out.world_pos = world.xyz;
out.world_normal = (obj.normal_mtx * vec4<f32>(normal, 0.0)).xyz;
out.uv = uv;
out.clip_pos = globals.view_proj * world;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let n = normalize(in.world_normal);
let l = normalize(globals.light_dir.xyz);
let v = normalize(globals.camera_pos.xyz - in.world_pos);
let h = normalize(l + v);
let albedo = obj.albedo.rgb;
let metallic = obj.mr.x;
let roughness = clamp(obj.mr.y, 0.04, 1.0);
let ndl = max(dot(n, l), 0.0);
let ndh = max(dot(n, h), 0.0);
// Metals have no diffuse; dielectrics get a fixed 0.04 specular, metals
// tint their specular by the albedo.
let diffuse = albedo * (1.0 - metallic);
let spec_color = mix(vec3<f32>(0.04), albedo, metallic);
let spec_power = mix(8.0, 256.0, 1.0 - roughness);
let spec = spec_color * pow(ndh, spec_power) * select(0.0, 1.0, ndl > 0.0);
let direct = (diffuse * ndl + spec) * globals.light_color.rgb;
let ambient = albedo * globals.ambient.rgb;
return vec4<f32>(ambient + direct, obj.albedo.a);
}
+47
View File
@@ -0,0 +1,47 @@
// Oxide Stage-8 UI overlay shader.
//
// One vertex format covers both solid quads and glyph quads: the sentinel UV
// `(-1, -1)` marks "solid color, do not sample the atlas". This avoids
// branching on a separate flag attribute and keeps the vertex stride tight
// (32 bytes — pos2 + uv2 + color4).
struct Uniforms {
mvp: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var atlas_sampler: sampler;
struct VsIn {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
@location(2) color: vec4<f32>,
};
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
};
@vertex
fn vs_main(in: VsIn) -> VsOut {
var out: VsOut;
out.clip_pos = u.mvp * vec4<f32>(in.position, 0.0, 1.0);
out.uv = in.uv;
out.color = in.color;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
// Solid quads use the sentinel UV (-1, -1). Sampling out-of-range would
// be clamped or wrapped depending on the sampler, but we cheaply detect
// it instead so a single texture binding serves every primitive.
if (in.uv.x < 0.0) {
return in.color;
}
let alpha = textureSample(atlas, atlas_sampler, in.uv).r;
return vec4<f32>(in.color.rgb, in.color.a * alpha);
}
File diff suppressed because it is too large Load Diff