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.