zinc:3d — 3D on the CPU, and on the GPU where there is one
plugins/3d renders simple 3D scenes on the CPU, on every target that runs the 2D rasterizer, and on the GPU on macOS (see
GPU renderer). The scene graph, camera and math are Zinc code (plugins/3d/index.ts); transform, lighting, clipping and
rasterization are C++ (plugins/3d/native/render3d.host.cpp). plugins/three draws through zinc:3d, so it gets both. A frame is rendered into a runtime image (raster::dyn_wrap) that
render() adds to the frame as an ordinary image command (like gfx.drawImage, nearest-neighbour when scaled), so 2D
drawing and zinc:ui compose over it.
import { onFrame, width, height, image, drawText, font } from 'zinc:gfx';
import { Scene, Camera, Node, Material, Light, cube, render } from 'zinc:3d';
const scene = new Scene();
scene.lights.push(new Light(-0.5, -1, -0.6, 0xffffff));
const crate = new Material(0xffffff);
crate.texture = image('crate.png'); // baked asset, or gfx.createImage() for a runtime texture
const box = scene.add(new Node(cube(1), crate));
const camera = new Camera().setPosition(0, 1.5, 4).lookAt(0, 0, 0);
let t = 0;
onFrame((dt: number) => {
t += dt;
box.setRotation(0, t, 0);
const tris = render(scene, camera, 0, 0, width(), height()); // 3D view
drawText(font('sans', 12), 8, 8, `${tris} triangles`, 0xffffff, 255, 0); // 2D over it
});
Examples: examples/3d/cubes (textured cubes with a baked PNG and a runtime image painted with zinc:gfx, a Gouraud
torus, a flat-shaded sphere, a textured floor, fps overlay) and examples/3d/model (an OBJ model with vertex colours
and computed normals on a checkered floor; A toggles orthographic, B flat shading).
API
math (flat number[], column-major) | mat4(), mat4Multiply(out, a, b), mat4Compose(out, pos, quat, scale), mat4LookAt(out, eye, target, up), vec3Dot/Cross/Normalize, quatFromEuler(x, y, z, out), quatFromAxisAngle(ax, ay, az, angle), quatMultiply(a, b), rgb(r, g, b) |
Mesh | new Mesh(positions, indices, normals, uvs, colors) (empty normals: smooth normals computed; uvs/colors may be empty), vertices, triangles, dispose(). Counter-clockwise front faces, uv origin at the image’s top-left, textures repeat |
| primitives | cube(size), sphere(radius, segments, rings), plane(width, depth, sx, sz) (texture repeats per cell), torus(major, minor, segments, sides) |
| OBJ | parseObj(text), loadObj(asset) (from zinc:assets): v (+ optional r g b), vt, vn, f with any polygon size and negative indices; mtl, groups and smoothing groups are ignored. Plain f a b c files use the v list as is; v/t/n faces build one vertex per distinct reference through a string map (much more memory while parsing: on ESP32 prefer plain faces) |
| .zmesh | loadModel(asset) (ZN-441): a model the asset build made for zinc:3d (a model rule with "format": "zmesh", docs/reports/zinc-next-assets.md “Models”: assets/ship.glb becomes the entry ship.zmesh) becomes a Node whose children are its parts. Each part is a mesh in the renderer’s own layout (float positions and normals, uvs, 0xRRGGBB colours, 16-bit indices: include/zn/zmesh.h), handed to the renderer as it is (Mesh(..., zmesh, at): a copy, no parsing; the GPU renderer uploads the arrays as they are), with its material (base colour and opacity, texture, double sided, unlit, blending, flat shading without normals) and its texture (an image entry beside the model, through the texture importer). The scene is flattened at build time (no node hierarchy, animations or skins); LODs are entries of their own (ship.lod1.zmesh) |
| glTF | loadGltf(asset) from zinc:3d/gltf (ZN-658): a .gltf (with its .bin and image files) or .glb from the assets or the file system becomes a Node whose children are the default scene’s roots, with their hierarchy (TRS or matrix). Meshes: triangles, strips and fans with positions, normals (none: flat shading), uvs and vertex colours, split in meshes of 65535 vertices when larger (scans); materials: base colour, its texture (embedded images decoded at run time, image files in the assets baked), double sided, KHR_materials_unlit. Skins, animations and other extensions are named in a warning; a file that requires an extension the reader does not know is an error; what gltfpack writes reads too (KHR_mesh_quantization, EXT_meshopt_compression, KHR_texture_transform, ZN-441). It reads through the three plugin’s glTF reader (decisions D25, D72) and is a module of its own: a program that does not import it links no glTF code (+194 KB when it does) |
Material | new Material(color); color, texture (image id, -1 none), vertexColors, shading (Shading.Smooth Gouraud, Shading.Flat, Shading.Unlit), doubleSided, specular (highlight colour, 0 none) and shininess (Blinn-Phong exponent, 30), filter (Filter.Default the renderer’s filter option, Filter.Nearest, Filter.Linear, Filter.Mipmap: how the GPU samples the texture; the software renderer always takes the nearest texel), emissive (light the material gives by itself, 0xRRGGBB, 0 none: added to the light it receives, before its texture), blend (Blend.Opaque, Blend.Alpha over what is behind with opacity 0..1, Blend.Add added to it: glows, light pools, sparks; blended nodes are drawn after the opaque ones in scene order, depth tested, without hiding what comes after) |
Light | new Light(dx, dy, dz, color): directional, the direction light travels; up to 4 per scene, plus scene.ambient |
Node | new Node(mesh, material) (both may be null: a group), position, rotation (quaternion), scale, setPosition/setRotation(euler)/setScale, add(child), visible, world (updated by render) |
Scene | a root Node with background, ambient, lights |
Camera | position, target, up, fov (degrees), near, far, ortho + height (world units), pixelRatio (pixels rendered per logical pixel; 0, the default, is the window’s pixel scale on the GPU, sharp on a Retina screen, and 1 in software), setPosition, lookAt, dispose() (frees its render target) |
render(scene, camera, x, y, w, h) | renders into the camera’s target and draws it in the screen box; returns the triangles rasterized |
Each camera owns its render target, so split screens use one camera per view. Shading: lighting is computed per vertex
(Gouraud) or per face (flat) as base × (ambient + Σ max(0, −n·L) × light); textures are modulated by it. The scene
graph works in any number profile: under fx12 (PS1 profile) the world matrices are composed in Q20.12 in Zinc, then the
C++ side works in float.
Rasterizer
- vertices transformed and lit once per draw (float), outcodes against 6 planes; triangles fully outside one plane are dropped, the others are clipped (Sutherland–Hodgman) against the near and far planes and a guard band of ±2 viewports;
- backface culling in screen space (
doubleSideddisables it); - blending and emissive (ZN-696): a blended triangle is depth tested and writes no depth; over:
(c × a + d × (255 − a)) / 255, added:d + c × a / 255, clamped, on both renderers (the GPU’s frame equals the software one on tests/data/zinc3d_glow). Opaque draws keep their code: on the GPU the emissive and blended draws use shader variants of their own; - highlights (ZN-563.04): Blinn-Phong with three.js’s
BRDF_BlinnPhongfactor,specular × lightColour × n·l × (shininess/2 + 1)/4 × (n·h)^shininessadded to the lit colour per vertex (per pixel on the GPU); a cube’s flat faces barely show them in software; - scanline rasterization, top-left rule at pixel centres, edges and attributes stepped in 16.16 fixed point;
- depth: NDC z in Q30, stored in a 16- or 32-bit z-buffer;
- texturing is perspective-correct every
subdivpixels (exact u/w, v/w, 1/w at the run ends, affine inside a run): 16 is visually exact at these resolutions, larger values trade accuracy for fewer divisions; - optional 2x2 ordered dither to RGB565 (for 16-bit panels).
Four inner loops are instantiated (textured or not × Gouraud or constant colour).
GPU renderer
plugins/3d/native/render3d.gl.cpp (ZN-563) implements the same native interface on OpenGL 3.2 core (macOS) and is written against the
GLES 3 subset; it includes the software renderer, which stays the fallback and the oracle. It reproduces the software shading:
- one vertex array per mesh (position, normal, uv, colour interleaved; the software renderer builds the mesh, normals included, then its
arrays are freed), one program per kind of material: unlit (three’s
MeshBasicMaterial) or lit (Lambert: ambient + 4 directional lights per vertex, clamped, like the software renderer), smooth or flat (the face normal from the derivatives of the world position), textured or not (nearest texel, repeat), vertex colours or not, highlights or not (per pixel, from the world normal and the eye position); the uniforms are the model and model-view-projection matrices, the colour and the lights (once per program and frame); - depth test, back faces culled unless double-sided, colour interpolated in screen space (
noperspective) like the software spans; - the target is a framebuffer read back into the same runtime image, presented the same way. The read is asynchronous (a pixel buffer per
target, mapped one frame later: the image shows the previous frame, as
gl.zincPresentdoes); deterministic runs read synchronously (ZINC_GL_PRESENT=sync|asyncchooses, as for WebGL). The shaders write blue first so that the RGBA bytes read back are the runtime’s0x00RRGGBBwords: aGL_BGRAread made the driver convert on the CPU, synchronously (2.5 ms at 800x500 on the Intel Mac); render()returns the triangles submitted (three.js’srenderer.infocounts so), the software renderer those drawn after culling.
Not done yet: the GLES 2 path (no vertex arrays) and contexts on Linux, the Pi and the web (follow-up tasks).
Options and memory
zinc.json: "plugins": { "3d": { ... } }, or per target under targets.<id>.plugins.3d.
| option | default | meaning |
|---|---|---|
scale | 0 | internal resolution divisor (software renderer; 0 = auto: the smallest divisor, 1 to 8, that keeps the picture under 400,000 pixels, so 1920x1080 is drawn at 640x360; a deterministic run keeps 1): a w×h box renders at w/scale×h/scale and is scaled up with nearest-neighbour sampling (the cheap 16.16 path of the 2D rasterizer) |
zbits | 0 | 16 or 32; 0 = 16 on ESP32, 32 elsewhere. 16 bits with near 0.1 / far 100 resolves ~1.5 cm at 10 units |
dither | false | 2x2 ordered dither to RGB565 |
subdiv | 16 | perspective correction step in pixels |
renderer | auto | software, gpu, or auto: the GPU where the target has the backend (macOS) unless the run is deterministic (ZINC_DETERMINISTIC: the goldens are the software frames). ZINC_3D=software|gpu overrides it; without a GPU context the software renderer draws |
msaa | 4 | GPU only: samples per pixel of the multisampled frame (0 or 1: none; at most what the context offers). ZINC_3D_MSAA overrides it |
filter | mipmap | GPU only: how textures are sampled when a material does not say: nearest, linear, or mipmap (trilinear, mipmaps built at the first use). ZINC_3D_FILTER overrides it; three’s Texture.minFilter / magFilter choose per texture (three.js’s defaults: mipmaps) |
Memory per camera, allocated on the first render and on resize, freed by camera.dispose():
colour 4 × W × H (the runtime image) + depth 2 or 4 × W × H, with W = w / scale, H = h / scale.
| view | colour | z16 | z32 |
|---|---|---|---|
| 160x120 (320x240, scale 2) | 75 KiB | 37.5 KiB | 75 KiB |
| 320x240 | 300 KiB | 150 KiB | 300 KiB |
| 640x360 (1280x720, scale 2) | 900 KiB | 450 KiB | 900 KiB |
Also: meshes 24 B/vertex (+8 uv, +4 colour) + 2 B/index (at most 65535 vertices per mesh), a per-vertex scratch of
37 B × the largest mesh, and a 0x00RRGGBB copy of each baked image used as a texture (4 × w × h, made on first
use; runtime images are sampled in place). Buffers come from system memory (hal_alloc) first and fall back to the
Zinc heap, which on ESP32 owns the largest free block; when both are full the program panics with
out of memory (heap budget …).
ESP32 without PSRAM: the Zinc heap gets about 94 KiB (the largest free block after ESP-IDF, the display bands and
FreeRTOS) and system memory is fragmented into blocks of ~13 KiB, so a 320x240 screen takes scale: 3 (a 106x80
view: 33 KiB colour + 17 KiB z16). scale: 2 (160x120, 112 KiB) does not fit.
Targets and performance
Measured on an Apple M1 Pro, macOS release build (-O2), average of 300 renders (render() only: clear, transform,
raster; the machine was loaded by other builds, so the ranges are the spread of several runs; the 2D compositor’s scaling and presentation are not included):
| scene | view | ms / render |
|---|---|---|
examples/3d/cubes (6 textured cubes, torus, sphere, textured floor: 698 triangles submitted) | 1280x720 | 1.88 |
| 640x480 | 0.64–0.70 | |
320x240 (--profile ps1, fx12) | 0.22 | |
examples/3d/model (OBJ rocket 536 triangles + 200-triangle floor) | 640x480 | 0.55–0.59 |
320x240 (--profile ps1, fx12) | 0.17 |
GPU renderer, Intel Mac (x86_64, Intel GPU), AOT, 800x500, CPU time of a frame (animation + renderer.render, the read included), 240 frames
after 60 (bench/three_many.ts, bench/qjs/three_many.mjs):
| scene | software | GPU | real three.js r186 on QuickJS (zinc’s WebGL 2) |
|---|---|---|---|
examples/three/cubes (5 cubes, torus, floor) | 0.51 ms / render | 0.36 ms / render | |
1000 cubes, 10 Lambert materials, 2 lights (bench/three_many.ts) | 3.18 ms | 2.43 ms headless; 2.9 ms and 60 fps in a window | 73.3 ms (70.6 render + 2.6 read) |
| target | status | expected |
|---|---|---|
| macos, linux | yes (macOS measured above; linux builds in docker) | |
| rpi1 | builds (docker) and runs under QEMU user mode (2.95 ms at 320x240 on an emulated ARMv6: not a Pi timing) | estimate for a Pi 1 (ARM1176, 700 MHz, VFP): 10–15 ms for the cubes scene at 320x240, so scale: 2 at 640x480 or scale: 4 at 1280x720 for 30+ fps |
| esp32 | builds (ESP-IDF v6.0, f32) and runs in Espressif QEMU with the ST7789 driver in checksum mode (debug: 2): both examples at scale: 3, ~700 triangles per frame, 8 ms per render in QEMU (not a hardware timing) | estimate (240 MHz LX6, single-precision FPU) for 106x80 (scale: 3, z16, dither): 8–15 ms per render; a full 320x240 ST7789 frame over 40 MHz SPI costs ~31 ms on its own |
| wasm | builds (emscripten); not run in a browser | |
| ps1 profile (fx12) | yes on macOS (--profile ps1): the facade compiles and runs in Q20.12; the C++ side uses float, which the real PS1 would emulate in software | |
| ps1, ps2 targets | not enabled (no render3d.ps1.cpp / .ps2.cpp; the PS1 target has no FPU, see follow-ups) | |
| sim | metadata only: meshes, targets and images exist, render() returns the triangles submitted (no culling), no pixels |
Not verified: timings on a real Pi 1 or ESP32 (no hardware), the wasm page in a browser, dithering on a physical
RGB565 panel. Checked: screenshots on macOS (f64 and --profile ps1), tests/conformance/scene3d.ts (primitives, OBJ
parsing, transforms and a few rendered frames; sim, macOS, f32 and fx12 profiles give the same output).
Limits and follow-ups
- lighting uses the model matrix’s 3x3 for normals (exact for rotations and uniform scale only); back faces of double-sided materials are lit with the front normal;
- no transparency, fog, mipmapping or texture filtering (nearest texel); no shadows;
- one texture per material; OBJ materials (
mtllib/usemtl) are ignored; - a PS1 build would want fixed-point transforms (GTE-style) instead of soft float.