Skip to content

Surfaces

CPU pixel buffers with per-pixel primitives and indexed textures, uploaded to a paint as an image.

A surface is a CPU pixel buffer you draw into pixel by pixel, then hand to a paint as an image. The canvas draws shapes; a surface draws pixels, which is what you want for procedural effects, plots, custom overlays and pixel art. The inner loops run outside Lua: your script decides what to draw, the surface fills the pixels.

local screen = surface.new(160, 90)

module:event("render_2d", function(render)
    screen:clear(0xFF101418)
    local t = os.clock()
    for x = 0, 159 do
        local h = 45 + math.floor(math.sin(x * 0.08 + t * 2) * 25)
        screen:line(x, 89, x, h, 0xFF3A7BD5)
    end
    screen:circle(80, 30, 12, 0xFFFFC857, true)
    local image = screen:image()
    if image then
        render.rect(20, 20, 480, 270, render.paint():image(image, 20, 20, 480, 270))
    end
end)

Drawing

Colors are plain 0xAARRGGBB and every primitive alpha-blends over what is already there.

Function Does
width(), height() Buffer size in pixels
clear([argb]) Fills every pixel, fully transparent without an argument
pixel(x, y[, argb]) Writes one pixel, or reads it without a color (nil off-buffer)
rect(x, y, w, h, argb) Filled rect
line(x1, y1, x2, y2, argb) Bresenham line
circle(x, y, r, argb[, fill]) Outline, or a disc with fill
draw(src, x, y[, w, h]) Another surface, nearest-neighbour scaled
tint(argb) Blends a color over everything
image() Uploads and returns an image handle

A new surface starts fully transparent and the alpha channel survives the upload, so clearing to a translucent color (or not clearing at all) composites over the game like any other paint, with no opaque backdrop needed.

draw reads from another surface, so buffers compose: rasterize a sprite once into its own small surface, then stamp it into the screen buffer every frame at whatever size you need.

Indexed textures

A surface can also rasterize indexed textures, where texels hold palette indices (0..255, -1 transparent) and a palette turns them into pixels. Swapping palettes recolors a whole frame without touching a pixel.

Call Does
surface:palette(bytes[, index]) 256-color palette from 768 * n RGB bytes; index picks one inside the blob
surface:colormaps(bytes) Light ramps: 256 * n bytes, where entry light * 256 + texel remaps an index
surface:lights() How many light levels the current ramp has, 1 without one

Every textured primitive takes a light argument, the ramp row to look the texel up in. 0 is normally the brightest.

Function Does
column(x, y0, y1, tex, col, frac, step[, light]) Vertical texture run
span(y, x0, x1, tex, u, v, du, dv[, light]) Horizontal texture run
blit(x, y, w, h, tex[, light[, flip]]) Whole texture, nearest-neighbour scaled

column walks the texture down one screen column, where frac is the texture row at y0 and step the row delta per screen pixel. span walks it across one row, where u, v are the coordinates at x0, stepped by du, dv. Both wrap around the texture, skip transparent texels and clip to the buffer, so off-screen ends cost nothing.

Loaders

Loader Builds
surface.rgba(w, h, bytes) A surface from w * h * 4 raw RGBA bytes
surface.patch(bytes) Masked column-format indexed picture, keeping its sprite offsets
surface.flat(bytes[, size]) Raw row-major square of indices, 64×64 by default, which is what span samples
surface.composite(w, h, parts) Patches pasted into one texture

parts is a list of { patch = <texture>, x = <integer>, y = <integer> }, and texels no patch covers stay transparent. column and blit take any texture; span needs a row-major one from surface.flat.

local patch = surface.patch(bytes)
local w, h = patch:width(), patch:height()
local ox, oy = patch:offset()

Uploading

surface:image() copies the buffer to the GPU and returns an image handle sampled nearest, so pixels stay crisp. Call it from a render callback, since it needs the render thread, and only when the pixels changed: the handle keeps showing the last upload, so a buffer you redraw at 30 Hz inside a 240 Hz render loop uploads 30 times, not 240.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close