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:
@@ -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.1–1000 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user