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,186 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user