Lux Goes 3D

For ten months Lux has been a 2D tool. Circles, rectangles, paths, textures, feedback, shaders, everything that reached the screen went through Vello and stayed flat. That was the plan (ship one dimension well before the other), but it was also a ceiling I could feel closing in every time I wrote a node that secretly wanted a Z coordinate.

Phase 9 is about lifting it.

This post is the groundwork. Not yet the boxes and spheres. Not yet the materials and lights. Not yet the 10,000 cubes in a single draw call. Just: can we put a triangle on the screen that came from the 3D pipeline instead of the 2D one. That’s the whole bar.

By the end of the session, the answer is yes, and it took 74 files.

What needed to exist

A 3D renderer isn’t “draw a triangle.” It’s draw a triangle with a mesh type that plugins can create, a handle type that survives a frame, a pool that owns the GPU buffers, a pipeline cache that doesn’t recompile shaders every frame, a scene descriptor that carries the camera and clear colour, a shader that reads a uniform block for the view-projection matrix, and a terminal node that puts it all into one render pass.

None of that existed.

Three new pin types

lux-core gained three types that will show up in every 3D post from here on:

PinType::Mesh        // a handle into the mesh pool
PinType::Camera      // view + projection matrices
PinType::Material    // surface descriptor (colors, shader fragment)

The Mesh type is the important one. It’s an opaque MeshHandle(u64). Plugins don’t touch vertex data directly. They ask the MeshBuilder on ProcessContext to upload positions, normals, UVs, and indices, and get a handle back. Same discipline the texture pool uses: data goes GPU side at allocation time, and plugins shuffle handles across wires after that.

The 40-byte PinValue invariant still holds. Mesh wraps a u64, and the other two box themselves. I was worried for about ten minutes that a Camera with two 4×4 matrices would blow the budget, and then I remembered what Box<T> was for.

The mesh pool

lux-render::mesh_pool is structurally a copy of the texture pool from the GPU texture pipeline post. Handle allocation, free-list reuse, frame-based eviction, mark_in_use to keep entries alive. Plus one extra trick.

When a mesh is uploaded with .persistent(), the pool computes a blake3 hash of (positions || normals || uvs || tangents || indices) and checks whether an identical mesh already lives in the pool. If it does, the new handle aliases onto the existing entry instead of re-uploading. A Box node that calls its mesh builder every frame with the same size causes exactly zero re-uploads after frame one, even though from its own point of view it’s uploading every frame.

Same bytes, same handle. Free for the common case, which is the case that matters.

Plugins never see the interleaved vertex layout. They hand over parallel arrays (positions: Vec<[f32; 3]>, normals: Vec<[f32; 3]>, uvs: Vec<[f32; 2]>) and the pool interleaves into a 48-byte Vertex struct on upload. Keeping the on-GPU layout private means I can change it later (add tangents, switch to fp16 normals, pack UVs) without touching a single plugin.

The forward pass

render_3d is the new render pass. Given a scene with a camera, a clear colour, and a list of DrawItem (one mesh + one material + one model matrix each), it clears the color target, clears the depth target to 1.0, and then for each draw item: fetches the vertex and index buffers from the pool, warms the pipeline cache for the mesh’s vertex layout and the material’s class, binds the frame uniform (view + projection), binds the draw uniform (model + colour), and issues one indexed draw call.

The pipeline cache is keyed by a compound key covering material class, color format, depth format, topology, blend mode, cull mode, and a flag for whether the draw is instanced. Every unique combination compiles once and gets stored. Two boxes with the same material? One pipeline. A box and a sphere with the same material? Still one pipeline. The first frame pays the compile cost; every frame after is a HashMap lookup.

For now there’s only one meaningful material class, Unlit, with the rest of the enum sitting empty as forward-looking placeholders. Lambert and Phong land in a later post.

The plugin

lux-scene-core is the new plugin crate. Four nodes, all of them minimal by design.

Triangle3D emits a hardcoded equilateral triangle mesh. Radius 0.6. CCW winding. XY plane. One output pin called mesh. Its own source file describes it as “the smallest possible 3D primitive, intended as a sanity check for the vertex/index buffer pipeline, not for production use.” It exists so that every part of the pipeline has something to test against, and it will get deleted the moment Box works in the next post. For now it is the only source of 3D geometry in the entire project, which means a single off-by-one in its mesh builder takes the whole phase down. No pressure.

UnlitMaterial outputs a Material with a single RGBA colour. Zero lighting math. Every pixel gets the same value through the fragment shader. The hello world of surface descriptors.

OrthoCamera outputs a Camera with an orthographic projection. Identity view. Things at the origin are visible; things at (1000, 0, 0) are not. Good enough for the first frame.

RenderScene is the terminal. Takes mesh, material, camera, width, height, and clear_color. Allocates a Rgba8UnormSrgb color target and a Depth32Float depth target on first use, caches the handles across frames, and pushes a Render3D op into the texture engine’s op stream. Its outputs are two texture handles that wire straight into TextureToLayer and onto the screen through the 2D pipeline from the layer↔texture bridge. The 3D world ends at a texture; the 2D world begins there. They meet at one wire.

The demo graph now wires all four together: Triangle3D → RenderScene(UnlitMaterial, OrthoCamera) → TextureToLayer → output. Four nodes. One triangle. First frame from the 3D pipeline.

The lifecycle contract

One design choice in this post is going to matter for every 3D node from now on, so I’m going to pin it down before it starts causing bugs.

GPU handles in Lux are transient. They’re valid only within a single editor session and must never be serialized. A mesh handle from yesterday’s saved project is meaningless today. A handle from before an undo is meaningless after it. A handle from before a plugin hot-reload is meaningless after it.

That means every stateful GPU node has to be able to re-upload its data on the first process() call after three specific events: project save and load, undo and redo, and hot-reload. The canonical pattern is:

fn process(&mut self, ctx: &mut ProcessContext) {
    if self.mesh.is_invalid() {
        self.mesh = ctx.mesh_builder()
            .positions(...)
            .indices(...)
            .persistent()
            .build();
    }
    ctx.mark_mesh_in_use(self.mesh);
    ctx.output("mesh", self.mesh);
}

Check invalid, re-upload if so, mark in use every frame. Miss any of the three events and your mesh disappears ~180 frames later when the pool’s frame-based sweep reclaims entries that haven’t been touched lately. Exactly the kind of bug that doesn’t show up in a unit test and hits you the first time you save a real project and reopen it. I wrote the contract down in the lux-core::mesh module docs so every future 3D primitive has one place to look when something vanishes on reload.

What it feels like

Running cargo run --bin lux for the first time after this session, the thing that shows up on screen is a dark triangle on a slightly darker background. Ugly. Unlit. Orthographic. Aliased at the edges in a way I’ll have to fix later. It came from the 3D pipeline.

‘Hello triangle, from the new 3D pipeline’

That’s the whole bar, and it passes.

Boxes, spheres, cameras you can actually point at things, and the cleanup pass that rolled back half the decisions in this post land in the next one. For now the hello world of 3D is exactly what hello worlds are supposed to be: three vertices and a shader that looks up one colour. The rest of Phase 9 is going to make me miss how simple this one was.

← Back to devlog