A 3D software rendering pipeline in pure Go — it rasterizes triangles on the
CPU through all the classic stages and ships a demo that renders a rotating,
flat-shaded cube to an animated GIF. It has since grown into a small interactive
3D tool: an optional, build-tag-gated viewer (-tags sdl) adds a real-time SDL3
window with an orbit camera, OBJ/glTF model loading, textures, and a second
GPU backend (SDL_GPU / Vulkan) held to the CPU rasterizer by parity tests.
The renderer core and the GIF path stay zero third-party imports (standard
library only). The only outside dependencies are confined to where they are
imported: SDL3 behind the sdl build tag (platform, cmd/viewer) and the
pure-Go glTF reader (qmuntal/gltf) in asset/gltf alone — so the default
go build ./... pulls glTF into the module graph, but the core and GIF packages
import nothing third-party.
# Render the demo (writes cube.gif in the current directory)
go run ./cmd/cube -w 480 -h 480 -fps 30 -dur 4s -out cube.gif
# Run the test suite
go test ./...Flags: -w, -h (size), -fps, -dur (duration / one full turn), -fov
(vertical FOV, degrees), -out (GIF path), -workers (fill goroutines; 0 = auto
= GOMAXPROCS, 1 = serial), -axes (draw world + object axes: X red, Y green,
Z blue).
A real-time, resizable window with an orbit camera that can render the built-in
cube or a loaded model. It lives behind the sdl build tag, so SDL3 (a cgo-free
purego binding, github.com/Zyko0/go-sdl3) is pulled only here; the default
build and the GIF exporter stay free of it.
# Built-in cube
go run -tags sdl ./cmd/viewer -w 800 -h 600 -fps 60 -fov 50 -stats
# Load a model (OBJ, glTF or GLB); the camera auto-frames it
go run -tags sdl ./cmd/viewer -model model.obj
# Supply an albedo texture for an OBJ that has no material
go run -tags sdl ./cmd/viewer -model mesh.obj -texture albedo.png
# Render with the GPU backend (SDL_GPU/Vulkan) instead of the CPU rasterizer
go run -tags sdl ./cmd/viewer -backend gpu -model model.objFlags: -model (.obj/.gltf/.glb; empty = cube), -texture (albedo for an
OBJ without a material), -backend (software = CPU rasterizer, gpu =
SDL_GPU/Vulkan), -stats, -axes (draw axes at startup), -fullscreen,
-workers (software backend only), and -gpu-info / -gpu-driver /
-gpu-low-power (GPU backend only — see Which GPU is it
using?).
Transparency comes from the file. glTF states it outright (alphaMode,
alphaCutoff, baseColorFactor's alpha, doubleSided). MTL cannot, so OBJ
infers it: a d/Tr dissolve below 1 blends, and otherwise a map_Kd that
actually uses its alpha channel is treated as a cut-out sheet — which is what
makes a sprite atlas' transparent surround disappear instead of painting the
black that is usually stored under it.
Controls: drag to orbit, scroll to zoom, middle-drag (or shift+drag) to pan, F1 toggles the FPS/frame-time overlay, F2 toggles the world axes, F3 toggles each object's local axes, Escape or the close button quits. The framebuffer tracks the window's pixel size, so resizing stays crisp and undistorted (HiDPI included).
The -stats overlay (on by default) shows two numbers, e.g. 60 FPS 3.0 ms. The
FPS is wall-clock; the ms is the per-frame work time, so it reflects the
real rasterizer cost — the approximate uncapped throughput is 1000/ms, even when
the FPS is pinned by something else.
In a window the FPS is usually pinned by the desktop compositor pacing Present to
the display's refresh (often a dynamic 40–60 Hz on laptops), not by -fps — so the
FPS reads the refresh while the ms stays the true measurement. To see raw
throughput in the FPS number too, run with -fullscreen: it bypasses the compositor.
The framebuffer then covers the whole screen, so the per-frame ms rises with the
larger pixel count.
The software backend draws the overlay onto the framebuffer; the GPU backend cannot mix the 2D debug-text API with its swapchain, so it shows the same numbers in the window title instead.
-backend gpu renders through SDL_GPU, requesting SPIR-V so SDL selects its
Vulkan driver on every OS. It needs a Vulkan driver at runtime (building never
does — the GLSL shaders in platform/shaders/src/ are compiled offline to the
committed, embedded .spv blobs). The GPU path mirrors the CPU rasterizer's
conventions exactly — same projection, winding, culling, alpha rules, and a single
sRGB encode at output in linear-working shaders — and is pinned to the CPU
oracle by parity tests (go test -tags sdl ./platform/, which skip when no
device is present).
What the CPU rasterizer chooses per triangle is baked into a pipeline object on
the GPU, so the mesh pipeline exists in four variants (blended × double-sided) and
is rebound per object; a cut-out is a shader discard, and blending rides the
hardware's src-over on the sRGB target — which the spec defines to happen in
linear space, matching the CPU's arithmetic.
# Report the driver SDL picked, the drivers available, and the adapter's name
go run -tags sdl ./cmd/viewer -backend gpu -gpu-infoSDL_GPU Driver: Vulkan
Vulkan Device: llvmpipe (LLVM 21.1.8, 256 bits)
Vulkan Driver: llvmpipe Mesa 26.0.3-1ubuntu1 (LLVM 21.1.8)
GPU: driver "vulkan" (requested auto; available: vulkan), low-power hint: false
The Vulkan Device: line is SDL's own log — -gpu-info turns it on by setting
SDL_LOGGING=gpu=info before SDL starts. It is worth reading: a device named
llvmpipe or lavapipe is Mesa's software rasterizer, so the "GPU" backend
would be running on the CPU through Vulkan.
Selecting one happens at two levels:
- kenderer —
-gpu-driverpicks the SDL driver and-gpu-low-powerasks SDL for a low-power adapter, e.g. a laptop's integrated GPU instead of its discrete one. Those two hints are all SDL_GPU exposes: it deliberately abstracts physical adapters away, and the SDL 3.4 call that does name one (SDL_GetGPUDeviceProperties) has no wrapper in the Go binding. - the graphics driver, through its own environment variables, which is where
finer control lives. With Mesa,
MESA_VK_DEVICE_SELECT=listenumerates the adapters andMESA_VK_DEVICE_SELECT=vendorID:deviceIDpins one;VK_DRIVER_FILES=/usr/share/vulkan/icd.d/<name>_icd.jsonpins the driver itself at the loader level; NVIDIA's PRIME offload takes__NV_PRIME_RENDER_OFFLOAD=1 __VK_LAYER_NV_optimus=NVIDIA_only.
Note that available is not the same as usable. kenderer ships SPIR-V shaders
only, so a driver that wants another format is listed but rejected:
$ viewer.exe -backend gpu -gpu-driver direct3d12
... create GPU device with driver "direct3d12": SDL_HINT_GPU_DRIVER direct3d12
unsupported! — that driver exists here but does not accept SPIR-V shaders, which
is all kenderer ships today (cross-compiling to DXIL/MSL is F4.4); available:
direct3d12 (no SPIR-V), vulkan
SDL words both "no such driver" and "that driver cannot take your shaders" the
same way, so kenderer tells them apart and marks the unusable ones (no SPIR-V)
in every list it prints. Making D3D12 and Metal usable is F4.4.
The SDL3 binding is cgo-free (purego), so the viewer cross-compiles like any other pure-Go program — no toolchain, no SDL headers, no libraries:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -tags sdl -o viewer.exe ./cmd/viewerThe GPU backend is the same binary everywhere; what changes is which drivers SDL
has on the host, so a Windows build reports direct3d12, vulkan where a Linux
one reports just vulkan.
- Embedded (recommended): the viewer imports
github.com/Zyko0/go-sdl3/bin/binsdl, which writes the bundled SDL3 library at runtime —defer binsdl.Load().Unload(). - From a path: load a system/local SDL3 instead with
sdl.LoadLibrary(sdl.Path())(needsSDL3.dll/libSDL3.so/.dylibreachable).
pipeline.Renderer.Render runs, per object:
- Vertex transform — model, view (
LookAt) and perspective projection combined into MVP; positions go to clip space, normals via the normal matrix. - Clipping — Sutherland–Hodgman against all six clip-space frustum planes
(
w ± x,w ± y,w ± z ≥ 0); the result is re-triangulated as a fan. - Perspective divide — clip → NDC.
- Viewport — NDC → screen (Y flipped, depth →
[0,1]). - Backface culling — by screen-space signed area; a double-sided object skips it and gets the normals of its back faces flipped instead.
- Rasterization — edge functions with a top-left fill rule
(
raster.DrawTriangle). - Z-buffer — perspective-correct (linear-in-screen) depth test.
- Shading — directional Lambert in linear RGB, with an optional albedo
texture; per object, flat (face normal) or smooth (interpolated normals →
Phong). A fragment also carries an alpha: cut-out materials discard it below a
cutoff, blended ones composite over the frame in linear space without writing
depth. After the fill, a serial line pass draws
Scene.Linesand each object's local axes (depth-tested, not depth-written).
Objects are drawn in scene.DrawOrder: opaque and cut-out first, then blended
back to front. Both backends read that one function, so a result that depends on
draw order cannot drift between them.
| Package | Responsibility |
|---|---|
math3d |
Vec2/3/4, Mat3/4, transforms, quaternions, projection, viewport |
geometry |
Vertex, Mesh, NewCube |
framebuffer |
color + depth render target (display-agnostic) |
texture |
Texture (linear-RGB + straight alpha: Nearest/Bilinear × Repeat/Clamp) |
shading |
Shader, Lambert, Material (+ AlphaMode), perspective-correct combine, sRGB |
raster |
triangle + line rasterization, coverage, depth test, blending |
scene |
Camera, Transform, Object, Scene, Lines/axes, DrawOrder |
pipeline |
stage orchestration + frustum clipping (+ Resize) |
present |
Presenter interface + stdlib animated-GIF backend |
asset |
Model, zero-dep OBJ + MTL loader (LoadOBJ, one Model per material) |
asset/gltf |
glTF/GLB loader via qmuntal/gltf (LoadGLTF) |
input |
backend-agnostic real-time input state (no SDL) |
camera |
OrbitCamera (drag/zoom/pan) writing a scene.Camera |
cmd/cube |
GIF demo entry point |
platform |
SDL3 window + frame loop + Backend seam (software / gpu) — build tag sdl |
cmd/viewer |
interactive viewer entry point — build tag sdl |
Dependencies form a DAG with math3d at the bottom. The sdl-tagged packages
(platform, cmd/viewer) are the only ones that pull SDL3 and are excluded from
the default build; only asset/gltf pulls the glTF reader. Nothing in the core
imports SDL or a presentation backend. Rendering sits behind platform.Backend:
the CPU rasterizer is the reference/oracle, the SDL_GPU backend is held to it by
parity tests.
- Handedness: right-handed, OpenGL-style. +X right, +Y up, camera looks down −Z.
- Matrices: row-major storage (
[4][4]float64,m[row][col]), column-vector convention (v' = M·v), composed right-to-left (MVP = P·V·M). - Rotation:
scene.Transform.Rotationis amath3d.Quat(QuatFromEuler == Rz·Ry·Rx); the zero value is identity-safe. - Depth: projection maps view z
[-near,-far] → NDC [-1,1]; the viewport maps that to window z[0,1](0 = near). The Z-buffer clears to+Infand a fragment passes whenz < stored. Window z is linear in screen space, so its barycentric interpolation is exact — no perspective correction for depth. - Perspective-correct attributes: UV/normal/color/world-pos are interpolated
with
1/w(shading.CombineFragment), unlike depth. - Winding / culling: CCW = front. After the Y-flipping viewport, front faces have negative screen-space signed area; the top-left fill rule keeps shared edges watertight.
- Normals & shading mode: normals are transformed by the inverse-transpose of
the model's upper-left 3×3. Shading is per object (
Object.Smooth): flat replaces the vertex normals with one world-space face normal computed on the original pre-clip triangle; smooth keeps the interpolated normals (Phong). Imported meshes load smooth; the procedural cube stays flat. - Color: all shading is done in linear RGB;
shading.ToRGBAapplies the sRGB encode once, at output. Albedo textures are decoded sRGB→linear on load; glTF base-color factors and vertex colors are already linear. - GPU parity: the SDL_GPU backend uses
math3d.PerspectiveZO(z∈[0,1], provably equal to the CPU's window z) and lets SDL normalize NDC and do the sRGB encode in hardware, so it matches the CPU oracle pixel-for-pixel within tolerance (pinned byplatformtests under-tags sdl).
The architecture is built to grow without touching the core:
- More meshes: add constructors to
geometryor loaders toasset. - More shading: implement new
shading.Shaders; the varyings (UV, normal, world position) are already interpolated perspective-correctly. - New backends: implement
platform.Backend; the CPU rasterizer is the parity oracle a new backend is measured against. - New output formats: add a
present.Presenter(e.g. a PNG-sequence or raw-framebuffer writer) that consumes the sameFrameFunc; the core never imports it.
Next on the roadmap (F5): a live scene graph, frustum culling, and multi-light support.