# 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](../PLAN.md)). All of these types live in `oxide_engine::render` and are re-exported from the [prelude](getting-started.md). ## 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 | [`Vertex`]: ../engine/src/render/mesh.rs [`Mesh`]: ../engine/src/render/mesh.rs [`GpuMesh`]: ../engine/src/render/mesh.rs [`Material`]: ../engine/src/render/material.rs [`Camera`]: ../engine/src/render/camera.rs [`DirectionalLight`]: ../engine/src/render/forward.rs [`Lighting`]: ../engine/src/render/forward.rs [`RenderObject`]: ../engine/src/render/forward.rs [`ForwardRenderer`]: ../engine/src/render/forward.rs ## Building geometry Meshes are built on the CPU and uploaded once. Built-in primitives cover the common prototyping shapes: ```rust 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. ```rust 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`](scene.md) given at render time, so a camera can live in the scene as an entity. Use `Transform::looking_at` to aim it: ```rust 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 `RenderObject`s: ```rust // 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: ```sh 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`](../engine/src/render/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`): ```rust 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](render-context.md) (surface/clear loop), [conventions.md](conventions.md) (handedness, color space), [scene.md](scene.md) (transforms and the hierarchy that feeds object placement).