arkenidar / graphic

A from-scratch software 3-D renderer written in Lua — no GPU, no graphics library math, just pixels.

Backends: Love2D main.lua & LuaJIT + SDL2 app.lua · Shared core: common.lua algebra.lua

1 · Rendering Pipeline Overview

Every frame, the renderer executes these stages in order:

Transform
rotate · translate
Backface Cull
discard hidden faces
Shading
Gouraud per-vertex
Perspective
3-D → 2-D screen
Rasterise
fill pixels + z-test
Display
flush pixel buffer

The renderer is entirely software: every pixel is computed in Lua and written to an in-memory buffer (ImageData for Love2D; direct pointer write to SDL_Surface.pixels for LuaJIT). The buffer is uploaded to the window once per frame in a single call.

2 · 3-D Transformations common.lua

Translation

Moving a point by an offset — the simplest transform:

translation P′ = ( P.x + dx, P.y + dy, P.z + dz )

Rotation around the Y axis

Rotating a point in the XZ plane (turning left/right) uses the 2-D rotation formula applied to the X and Z components while Y stays fixed:

Y-axis rotation by angle θ x′ = cos(θ) · x − sin(θ) · z
z′ = cos(θ) · z + sin(θ) · x
y′ = y (unchanged)

Intuition: imagine the unit circle in the XZ plane. A point at angle φ from the X-axis moves to angle φ+θ after rotation. cos/sin give the new X and Z components of that rotated direction.

function point_rotate_y(point, radiants)
  return point_rotate_axes('xz', point, radiants)
end

-- Generic: rotate two named axes of a point by angle radiants
function point_rotate_axes(axes, point_in, radiants)
  local one, two = axes[1], axes[2]
  point_out[one] = math.cos(radiants)*point_in[one] - math.sin(radiants)*point_in[two]
  point_out[two] = math.cos(radiants)*point_in[two] + math.sin(radiants)*point_in[one]
end

3 · Perspective Projection common.lua · perspective()

Real eyes (and cameras) see distant objects as smaller. Perspective projection maps a 3-D point to a 2-D screen position by dividing by depth (−z, because the scene is in front of the camera at negative Z).

perspective projection (pinhole camera) xscreen = (x − cx) · focal / (−z) + cx
yscreen = (y − cy) · focal / (−z) + cy

focal (= 200) is the focal length: it controls the field of view. Larger focal → narrower FOV, more telephoto-like. (cx, cy) (= 150, 150) is the screen centre, so objects on the optical axis project to the middle of the window.

inv_z: the renderer pre-computes inv_z = 1 / (−z) per vertex and stores it. This same value is later reused for perspective-correct UV interpolation — so the division is paid only once.

4 · Backface Culling common.lua · draw()

A closed mesh (like a head) always has faces pointing toward the camera and faces pointing away. The away-faces are invisible and can be skipped entirely — typically ~50% of all triangles.

The test: z-component of the face normal

In world space the camera is at the origin looking along −Z. A face is front-facing if its normal has a positive Z component (pointing toward +Z, toward camera). The Z component of the cross product of two edges gives exactly this:

face normal z-component (2D cross product of edges in XY) nz = (v1.x − v0.x)(v2.y − v0.y) − (v1.y − v0.y)(v2.x − v0.x)

front-facing ⟺ nz ≥ 0

The ≥ 0 (not > 0) is intentional: edge-on faces (nz = 0) like a horizontal floor plane are included rather than silently dropped. Backface culling runs before shading and perspective, so culled triangles pay no further cost at all.

5 · Lighting & Shading common.lua · shading_smooth_preset1()

Lambertian (diffuse) reflection

A surface lit by a directional light reflects more light the more directly it faces the light. This is captured by the dot product of the surface normal and the direction toward the light:

Lambert's cosine law intensity = max( 0, N̂ · L̂ )

is the unit surface normal. is the unit vector pointing toward the light. The dot product equals cos(θ) where θ is the angle between them. Clamping to 0 means the face receives no negative light when it points away.

Ambient light

Real scenes have indirect light bouncing from all surfaces. We fake this with a constant ambient term added to every surface:

final vertex color color = clamp( Σlights(max(0, N̂·L̂) · surface_color) + ambient )

Gouraud shading

Instead of computing lighting once per triangle (flat shading), Gouraud shading computes it per vertex using the vertex's own normal, then smoothly interpolates the resulting colors across the face during rasterisation. This gives smooth curvature on coarse meshes at low cost.

The renderer uses 3 lights simultaneously (top-left-back, right-front, top-down) plus ambient at intensity 0.4, all accumulated per vertex.

6 · Z-Buffer Depth Testing common.lua · depth_buffer

When two triangles overlap on screen, the closer one should win. The z-buffer stores the depth of the closest fragment seen so far for each pixel, and a new fragment is drawn only if it is closer (higher z, since the scene uses negative Z for depth):

depth test draw pixel ⟺ znew > depth_buffer[py][px]

The buffer is initialised to −∞ each frame, so the very first fragment at any pixel always wins. After drawing, the buffer is updated with the new depth. The buffer is allocated once at module scope and reset with a fill loop — avoiding the 90 000 table-slot re-allocation that would otherwise occur each frame.

7 · Barycentric Coordinates algebra.lua

Barycentric coordinates are the language of triangle interpolation. Given triangle vertices A, B, C, any point P inside the triangle can be written as:

barycentric decomposition P = rA · A + rB · B + rC · C
rA + rB + rC = 1, each weight in [0, 1]

Geometrically, rA is the fraction of the triangle's area in the sub-triangle formed by P, B, C (opposite to A), and similarly for rB, rC. When P is at vertex A: rA=1, rB=rC=0.

Formula used in this renderer

pre-computed per polygon (barycentric_coords_precalculated_for_polygon) common = (B.y−C.y)·(A.x−C.x) + (C.x−B.x)·(A.y−C.y) ← signed area × 2

ax = B.y−C.y, ay = C.x−B.x
bx = C.y−A.y, by = A.x−C.x
cx = C.x, cy = C.y

per pixel rA_num = ax·(px−cx) + ay·(py−cy)
rB_num = bx·(px−cx) + by·(py−cy)
rC_num = common − rA_num − rB_num

rA = rA_num / common, rB = rB_num / common, rC = rC_num / common

Any per-vertex value V (depth z, RGB color, UV texture coordinates) is then interpolated across the face:

attribute interpolation V(P) = rA · VA + rB · VB + rC · VC

8 · Incremental (Scanline) Rasterisation common.lua · draw()

Naively evaluating the barycentric formula costs 4 multiplications + 4 additions per pixel. The incremental trick cuts this to 3 additions.

Why it works

Along a scanline (fixed py, increasing px), barycentric numerators are linear functions of px. When px increases by 1:

scanline deltas rA_num → rA_num + ax ← Δ per pixel = ax (constant!)
rB_num → rB_num + bx ← Δ per pixel = bx
rC_num → rC_num + drc ← drc = −(ax + bx), pre-computed

Implementation

-- Seed at the left edge of each scanline:
local ra_num = ax*(x_min - pre.cx) + pre.ay*(py - pre.cy)
local rb_num = bx*(x_min - pre.cx) + pre.by*(py - pre.cy)
local rc_num = pre.common - ra_num - rb_num

for px = x_min, x_max do
  -- Inside test: all numerators ≥ 0 (assuming common > 0 from CCW winding)
  if ra_num >= 0 and rb_num >= 0 and rc_num >= 0 then
    -- Divide ONLY for inside pixels (the minority):
    local ra = ra_num * inv_common
    local rb = rb_num * inv_common
    local rc = rc_num * inv_common   -- == 1 - ra - rb
    -- ... depth, color, UV interpolation using ra/rb/rc ...
  end

  -- Step: 3 additions, no multiplications:
  ra_num = ra_num + ax
  rb_num = rb_num + bx
  rc_num = rc_num + drc
end
Deferred division: for most pixels in the bounding box the point is outside the triangle. The division by common (expensive) is skipped for those — only the sign of the numerators matters for the inside test.

9 · Perspective-Correct UV Mapping common.lua · draw() · uv_interpolate_precalc()

Texture coordinates (UV) cannot be linearly interpolated in screen space. Because perspective projection compresses distant parts of a surface, naive linear UV interpolation "swims" as the mesh rotates — a classic artefact.

The fix: interpolate U/Z and 1/Z, then divide

Quantities that are linear in screen space are u/z, v/z, and 1/z (recall inv_z = 1/(−z) is stored per vertex by perspective()). Interpolate those with barycentric weights, then divide:

perspective-correct UV interpolation inv_z(P) = rA · inv_zA + rB · inv_zB + rC · inv_zC

u(P) = ( rA·uA·inv_zA + rB·uB·inv_zB + rC·uC·inv_zC ) / inv_z(P)
v(P) = ( rA·vA·inv_zA + rB·vB·inv_zB + rC·vC·inv_zC ) / inv_z(P)

10 · Pixel Buffer & Backends main.lua · app.lua

Writing one pixel at a time through a graphics API (one SDL_FillRect or love.graphics.rectangle per pixel) incurs enormous per-call overhead. Both backends instead accumulate all pixels into memory and flush once:

BackendWrite per pixelFlush once per frame
SDL2 / LuaJIT uint8_t* pointer write via FFI SDL_UpdateWindowSurface()
Love2D ImageData:setPixel() Image:replacePixels() + love.graphics.draw()

The SDL2 path uses the same ffi.cast("uint8_t*", surface.pixels) pattern as texture sampling and writes the packed pixel directly — either as a 32-bit word (4-byte surfaces) or three sequential bytes (24-bit surfaces).

11 · Performance Summary

OptimisationTechniqueGain
Pixel buffer Write to memory, flush once per frame Eliminates ~90k API calls/frame
Depth buffer reuse Allocate once, reset with fill loop Eliminates ~90k table allocs/frame
Hoist closures halfplane, inside_polygon → module-level locals Enables JIT compilation of inner loop
Backface culling nz ≥ 0 test before shading/perspective ~50% fewer triangles processed
Incremental barycentric Seed + add per scanline step 4 mul+add → 3 add per pixel
Unified interpolation One barycentric pass for inside+z+color+UV Eliminates 2–3× redundant barycentric + table allocs
Reuse pixel tables pixel_rgb, pixel_xy, pixel_point at module scope Zero per-pixel allocations in hot path
MeshTrianglesFPS (SDL2 / LuaJIT)
cube.obj12~18
teapot.obj992~52
head.obj7 586~9–12

12 · File Map

FileRole
common.luaScene setup, transform, shading, perspective, backface cull, rasteriser
algebra.luaVectors, cross/dot product, normals, barycentric coordinates
loader.luaOBJ and STL mesh file parser
main.luaLove2D backend: window, ImageData pixel buffer, event loop
app.luaLuaJIT + SDL2 backend: FFI, window surface, event loop
assets/OBJ meshes, BMP textures