Skip to content

3D

The panel has a small 3D pipeline built in. Your script describes the scene (a camera, lights, meshes and what they are made of) and the panel does the per-pixel work. At 64 × 64 a low-polygon scene runs comfortably.

local cube, paint
local angle = 0
function _init()
cube = gfx.cube()
paint = gfx.material{ color = {255, 140, 60} }
gfx.projection(mat4.perspective(50, gfx.width() / gfx.height(), 0.1, 20))
gfx.view(mat4.look_at(0, 1.5, 3.5, 0, 0, 0, 0, 1, 0))
gfx.ambient(40, 40, 60)
gfx.light(1, { dir = {0.4, -1, -0.3}, color = {255, 240, 220} })
end
function _update(dt)
angle = angle + dt
end
function _draw()
gfx.cls(0, 0, 10)
gfx.draw(cube, paint, mat4.rotate_y(angle) * mat4.rotate_x(angle * 0.7))
end

Four ideas are at work:

  1. A mesh is the shape: gfx.cube().
  2. A material is how its surface looks: gfx.material{ ... }.
  3. The camera is two matrices: a projection (the lens) and a view (where it stands and what it looks at).
  4. gfx.draw(mesh, material, model) puts the mesh into the scene. The model matrix places, turns and scales it.

Build meshes, textures and materials once, in _init. Only the matrices change per frame.

  • Y is up. The camera looks down the negative Z axis. The world is right-handed, as in OpenGL.
  • A triangle faces you when its corners go counter-clockwise.
  • Angles are radians, except the field of view of mat4.perspective, which is degrees.
  • Colours are 0 to 255, as in the 2D calls.
Call Result
mat4.identity() No change
mat4.translate(x, y, z) Move
mat4.scale(x, y, z) Resize
mat4.rotate_x(a), mat4.rotate_y(a), mat4.rotate_z(a) Turn around an axis
mat4.perspective(fov, aspect, near, far) A lens with a field of view in degrees
mat4.ortho(left, right, bottom, top, near, far) A lens without perspective
mat4.look_at(ex, ey, ez, tx, ty, tz, ux, uy, uz) A camera at e, looking at t, with u as up
a * b The matrix that applies b first, then a
m:transform(x, y, z) Where the matrix sends a point; returns x, y, z, w

Read a chain of matrices from right to left: mat4.translate(2, 0, 0) * mat4.rotate_y(a) turns the object in place, then moves it.

Three shapes are built in, each centred on the origin and about one unit across:

Call Shape
gfx.cube() A cube
gfx.sphere(segments) A sphere; segments is 3 to 32, 12 if left out
gfx.plane(divisions) A flat square facing up; divisions is 1 to 31

Your own mesh is a table of flat number lists:

local triangle, paint
function _init()
triangle = gfx.mesh{
pos = { -1, -1, 0, 1, -1, 0, 0, 1, 0 }, -- three numbers per corner
color = { 255, 0, 0, 0, 255, 0, 0, 0, 255 }, -- optional
index = { 1, 2, 3 }, -- corners per triangle, counted from 1
}
paint = gfx.material{ shading = "unlit", cull = "none" }
gfx.projection(mat4.perspective(60, 1, 0.1, 10))
gfx.view(mat4.look_at(0, 0, 3, 0, 0, 0, 0, 1, 0))
end
function _draw()
gfx.cls()
gfx.draw(triangle, paint)
end
Field Contents
pos Required. Three numbers per vertex
normal Three per vertex; needed for smooth lighting
uv Two per vertex; texture coordinates
color Three per vertex, 0 to 255
index Which vertices make each triangle, counted from 1. Without it, vertices are used in order
mode "triangles" (the default), "lines" or "points"

A mesh holds up to 1,024 vertices and cannot be changed after it is made.

paint = gfx.material{ color = {255, 200, 160}, shading = "smooth" }
Field Values Default
color {r, g, b} White
shading "smooth" (lit per vertex), "flat" (lit per triangle), "unlit" "smooth"
texture A texture; its colours are multiplied by color None
cull "back", "front", "none": which faces to skip "back"
depth "on", "read" (test but do not write), "off" "on"
blend "none", "alpha", "add" "none"
alpha 0 to 255, used by blend = "alpha" 255

material:set{ ... } changes fields later, with the same names.

Call Effect
gfx.ambient(r, g, b) Light that reaches every surface. White until you set it, so a scene with no lights is still visible
gfx.light(i, { dir = {x, y, z}, color = {r, g, b} }) Light i (1 to 4) shining along a direction, like the sun
gfx.light(i, { pos = {x, y, z}, range = r, color = {r, g, b} }) A lamp at a point, fading out at range
gfx.light(i, nil) Turns light i off
gfx.fog(near, far, r, g, b) Fades surfaces to a colour between two distances; gfx.fog() turns it off

Lighting is diffuse only: surfaces facing a light are brighter. There are no highlights or shadows.

A texture is a small image written as text: four hex digits per pixel in the panel’s 16-bit colour format, rows from the top. Sides are powers of two up to 64.

local checker, floor_paint, ground
function _init()
checker = gfx.texture(2, 2, "FFFF" .. "2104" .. "2104" .. "FFFF")
floor_paint = gfx.material{ texture = checker, shading = "unlit" }
ground = gfx.plane(4)
gfx.projection(mat4.perspective(60, 1, 0.1, 20))
gfx.view(mat4.look_at(0, 1, 2, 0, 0, 0, 0, 1, 0))
end
function _draw()
gfx.cls()
gfx.draw(ground, floor_paint, mat4.scale(4, 1, 4))
end

gfx.rgb(r, g, b) gives the number for a colour; string.format("%04X", gfx.rgb(255, 0, 0)) gives its four digits. A fourth argument, {r, g, b}, makes that colour transparent.

Everything draws onto the same canvas in the order you call it. Draw the scene, then a score or a frame around it with gfx.rectfill and gfx.line.

Two calls help when combining them:

  • gfx.project(x, y, z) returns the canvas x, y (and depth) of a point in the world, for placing a label over an object.
  • gfx.clear_depth() forgets what has been drawn so far in 3D, so a second layer (a cockpit, a held item) always draws in front.
Limit Value
Vertices per mesh 1,024
Triangles per frame 2,048
Pixels filled per frame 16 times the canvas
Lights 4
Texture size 64 × 64

Past the per-frame limits, gfx.draw stops the script with an error.