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>
7.7 KiB
Rendering (Stage 4 — Basic 3D Rendering)
Stage 4 turns the clear-color surface from Stage 2 into a 3D renderer: it draws meshes, placed by transforms, shaded by materials, as seen through a camera, lit by a directional light — all through a single-pass forward renderer.
Status: the rendering core (this document), the glTF importer, and the editor's 3D viewport (orbit/pan/zoom + material inspector) are all implemented. The engine paths are covered by headless GPU tests; the editor viewport is on
devawaiting the maintainer's manual sign-off before Stage 4 is marked done (tracked in PLAN.md).
All of these types live in oxide_engine::render and are re-exported from the
prelude.
The pieces
| Type | Role |
|---|---|
Vertex |
One vertex: position, normal, uv (GPU-ready, repr(C)) |
Mesh |
CPU-side indexed triangle geometry + primitive builders |
GpuMesh |
A Mesh uploaded into GPU vertex/index buffers |
Material |
PBR-lite surface: albedo, metallic, roughness |
Camera |
Perspective projection; the view comes from a Transform |
DirectionalLight / Lighting |
One sun light + an ambient term |
RenderObject |
A drawable: &GpuMesh + Material + Transform |
ForwardRenderer |
Owns the pipeline + depth buffer; draws a list of objects |
Building geometry
Meshes are built on the CPU and uploaded once. Built-in primitives cover the common prototyping shapes:
use oxide_engine::prelude::*;
let cube = Mesh::cube(); // unit cube, per-face normals
let plane = Mesh::plane(10.0); // 10×10 ground on XZ, facing +Y
let sphere = Mesh::uv_sphere(0.8, 32, 16); // radius, sectors, stacks
// Upload to the GPU (needs a `&wgpu::Device`, e.g. from `RenderCtx`/`Gpu`).
let gpu_cube: GpuMesh = cube.upload(device, "cube");
You can also build a mesh directly from Vertex + index data, and query its
object-space bounds with Mesh::bounds() (used later for culling).
Importing glTF
Static meshes load from glTF/GLB via oxide_engine::asset. The node hierarchy is
flattened into world space and each primitive becomes a GltfMesh (geometry +
PBR-lite material + transform); missing normals are generated, missing UVs default
to zero. Skinning/animation are deferred to the animation stage.
use oxide_engine::prelude::*;
let model = load_gltf("assets/models/cube.gltf")?;
let drawables: Vec<_> = model
.meshes
.iter()
.map(|m| (m.mesh.upload(device, "gltf"), m.material, m.transform))
.collect();
// Build `RenderObject`s from `drawables` and hand them to `ForwardRenderer::render`.
load_gltf_slice(&bytes) is the in-memory variant (buffers must be embedded),
used for tests and bundled assets.
Camera
A Camera holds only projection parameters (fov_y, z_near, z_far); its
position and orientation are a Transform given at render time, so
a camera can live in the scene as an entity. Use Transform::looking_at to aim
it:
let camera = Camera::default(); // 60° FOV, 0.1–1000 range
let view = Transform::looking_at(Vec3::new(4.0, 2.5, 5.0), Vec3::ZERO, Vec3::Y);
The projection uses a 0..1 NDC depth range (the wgpu/Vulkan/DX/Metal
convention), matching the depth buffer the forward renderer clears to 1.0.
Drawing a frame
The ForwardRenderer is built once for a given color target format — the
window surface format for on-screen rendering, or e.g. Rgba8Unorm offscreen.
Then each frame you hand it a list of RenderObjects:
// Once (e.g. lazily on the first frame, when the surface format is known):
let mut renderer = ForwardRenderer::new(device, ctx.surface_format);
// Each frame, inside `App::render`:
renderer.render(
device,
queue,
ctx.view, // the target view (already cleared to the clear color)
ctx.size, // (width, height) in physical pixels
&camera,
&view, // the camera's world transform
&Lighting::default(),
&[
RenderObject { mesh: &gpu_plane, material: Material::diffuse(Color::WHITE), transform: ground },
RenderObject { mesh: &gpu_cube, material: Material::diffuse(Color::RED), transform: spin },
],
);
The color attachment is loaded, not cleared, so whatever cleared the surface
beforehand (the window's clear color from Stage 2, or a clear_view call) shows
through as the background. The depth buffer is owned by the renderer, resized to
match the target, and cleared to 1.0 every call.
See the full runnable example:
cargo run -p oxide-examples --bin hello_mesh # spinning cube + sphere + ground
How it works
- One pipeline, one pass. Geometry is drawn front-to-back-agnostic; a
Depth32Floatdepth buffer withLesscompare resolves occlusion, so draw order does not affect the result. - Per-object data via dynamic uniform offsets. Globals (view-projection, camera position, light) live in one uniform buffer (bind group 0). Each object's model matrix, normal matrix, and material live in a second uniform buffer addressed with a dynamic offset (bind group 1), so an arbitrary number of objects draw from one buffer that grows as needed.
- PBR-lite shading.
shaders/lit.wgsldoes Lambert diffuse + ambient + a Blinn-Phong specular term whose sharpness comes fromroughnessand whose color comes frommetallic. It outputs linear color; an sRGB surface converts on write.
In the editor
The editor renders the active scene in a 3D viewport beneath its egui panels.
Entities become visible by carrying a MeshRenderer component (oxide_engine::render):
use oxide_engine::prelude::*;
// Make an entity render a cube with a custom material.
let e = scene.spawn("crate", Transform::from_translation(Vec3::new(2.0, 0.5, 0.0)));
scene.world_mut().insert_one(
e,
MeshRenderer::with_material(PrimitiveShape::Cube, Material::diffuse(Color::RED)),
).unwrap();
MeshRenderer names a built-in PrimitiveShape (cube/sphere/plane) rather than
embedding geometry, so it is tiny and serializable (RON) — editable from both the
inspector and, later, scripts/AI agents. The editor caches one GPU mesh per shape
and draws every MeshRenderer entity through the ForwardRenderer, with an
orbit camera (drag to orbit, right-drag to pan, scroll to zoom).
Entities are selected by clicking them in the viewport (a ray is cast against each renderable's world-space bounds) or from the hierarchy panel. The inspector edits the selection's transform (position, rotation as euler degrees, and scale) and its material (albedo / metallic / roughness — roughness controls specular-highlight sharpness, most visible on glossy/metallic surfaces).
Testing
The window/viewport halves need a human eye, but the render path itself is
verified headlessly (tests/ stage4): render to an offscreen texture and read
the pixels back to assert that lit geometry appears, the background shows through
elsewhere, and a near object occludes a farther one through the depth buffer.
Camera projection/view math has unit tests in render/camera.rs.
See also: render-context.md (surface/clear loop), conventions.md (handedness, color space), scene.md (transforms and the hierarchy that feeds object placement).