1
Rendering
Jaroslav Beneš edited this page 2026-08-08 18:11:58 +02:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 dev awaiting 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.11000 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 Depth32Float depth buffer with Less compare 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.wgsl does Lambert diffuse + ambient + a Blinn-Phong specular term whose sharpness comes from roughness and whose color comes from metallic. 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).