An incredibly fast, pure LuaJIT game creation framework.
This is very experimental, so it's not ready to replace your love2d usage, not yet.
Functionally identical code running on both results in 5x faster speeds on lupa compared to love2d.
Sorted by impact
- LuaJIT is used throughout the entire process. No Lua C Api overhead.
- It is written from scratch, so everything can be scrutinized and optimized.
- Vulkan is used.
Everything goes through the draw object your app is handed in draw(self, draw).
draw:setColor(1, 0.5, 0.2, 1) -- r, g, b, a (alpha optional, defaults to 1)
draw:rect(x, y, w, h)The default view is orthographic with y = 0 at the bottom of the window,
and the frame starts from a clear colour of 0.1 grey. Set your own with
draw:setClearColor(r, g, b) -- a sky, or the backdrop of a 2D scene.
draw:setCamera({
position = { x = 0, y = 6, z = 18 },
target = { x = 0, y = 0, z = 0 },
up = { x = 0, y = 1, z = 0 }, -- optional, this is the default
fov = math.pi / 3, -- optional, radians
near = 0.1, -- optional
far = 1000, -- optional
})
draw:setLighting({ -- optional; unlit by default
ambient = { 0.16, 0.19, 0.28 }, -- the floor the world is lit by
lights = {
{ dir = { x = -0.4, y = -1, z = -0.3 }, color = { 1, 0.95, 0.88 } },
{ pos = { x = 3, y = 2, z = 0 }, radius = 12, color = { 1, 0.6, 0.2 } },
},
})
draw:setColor(0.9, 0.5, 0.2, 1)
draw:cube(x, y, z, size)
draw:sphere(x, y, z, radius [, segments])
draw:plane(x, y, z, width, depth) -- horizontal, facing +Y
draw:clearLighting()
draw:setOrtho() -- back to the 2D viewA light carrying a dir shines the same way everywhere, like a sun; one carrying a
pos and a radius sits at a place and reaches as far as the radius. Up to
limits.lights (8) of them light a frame. The table is read rather than kept, so
one can be built once and changed in place between frames:
lighting.lights[1].color = { 0.2, 0.2, 0.3 }
draw:setLighting(lighting)Primitives batch into the same draw call as 2D rects, so a scene is one
vkCmdDrawIndexed no matter how many objects it holds.
draw:pushModel()
draw:translate(x, y, z)
draw:rotate(angle, axisX, axisY, axisZ) -- axis defaults to +Y
draw:scale(s) -- or (sx, sy, sz)
draw:cube(0, 0, 0, 1)
draw:popModel()
draw:resetModel() -- clear the whole stackThe transform is applied on the CPU as vertices are written, in this order:
world = model * (localVertex * scale) + primitivePosition
so the position argument is not rotated -- rotate(...) then cube(5, 0, 0, 1)
spins the cube in place rather than orbiting it. Put the position inside the
transform (translate(5, 0, 0) then cube(0, 0, 0, 1)) if you want it to orbit.
A light casts shadows when shadows is set on it. The scene is drawn from the light
into a map of packed depth, which the main pass samples:
draw:setLighting({
lights = {
{ dir = { x = -0.4, y = -1, z = -0.3 }, shadows = true },
-- or, to tune it:
{ dir = { x = -0.4, y = -1, z = -0.3 },
shadows = { distance = 96, cascades = 4, filter = 2 } },
},
})One map over a view that reaches a long way is either blocky or short-sighted: the same texels have to cover the ground at arm's length and the hills on the horizon. So the map is a cascade of them. The view is cut into slices, each slice is drawn into its own map through a box fitted to it, and a fragment is shadowed by the map of the slice it is in:
shadows = { distance = 96, cascades = 4 }distance |
how far shadows reach, in world units; past it a surface is lit |
cascades |
how many maps that is split into, 1 to limits.shadowCascades (4) |
split |
0 to 1, how much the splits favour the near view over an even split |
blend |
share of each cascade's range blended into the next, to hide the join |
filter |
width of the shadow filter, in texels; higher is softer |
bias |
depth offset that keeps a surface from shadowing itself |
normalBias |
shadow texels a sample is pushed along its own normal |
The splits default to a blend of an even split and a logarithmic one, so the nearest
cascade covers a few units and the last one covers distance. A map is 1024 texels
square (limits.shadowSize), so four cascades over 128 units put four times the
texels on a block at arm's length that one map over the same distance would. Two
things make the texels stay where they are: each box is fitted around the bounding
sphere of its slice, which is the same size however the camera is turned, and its
centre is moved to the nearest whole texel. A map whose texels keep still does not
crawl when the camera moves.
The filter is nine taps in a square around the sample, weighted so the middle counts for most of it. Nine is where a hard edge of a staircase becomes a line without the noise a rotated pattern puts on a flat surface.
draw:setLighting({
ambient = { 0.25, 0.25, 0.3 },
lights = { { dir = sun } },
fog = { color = sky, near = 300, far = 420 },
})fog fades what is drawn into color between near and far, squared so the fade
is gentle at first, and is what keeps a view that ends at a distance from ending in a
line. far = 0 is no fade at all, and near defaults to a third of far.
The cost is one pass over the frame's geometry per cascade, with a vertex shader that only transforms. A scene that draws a view of a hundred chunks can say which of them each cascade reaches, by drawing its casters nearest first and naming how many of them each one draws:
draw:setLighting({ lights = { { dir = sun, shadows = { distance = 128 } } } })
for index, chunk in ipairs(casters) do -- nearest first
draw:mesh(chunk.mesh, chunk.x, 0, chunk.z)
end
draw:endShadowCasters({ 9, 25, 60, 121 }) -- what each cascade drawsdraw.shadowSplits says how far each cascade reaches, and is there as soon as the
lighting is set, so the counts can be worked out while the casters are drawn. Records
drawn after the call are still in the frame, they are just not in any map. A point
light never casts, because that would be six maps per cascade.
A frame that asks for shadows and never calls this draws every record into every
cascade, so a scene drawn as one pass pays for it once per cascade. lupa prints a
warning the first time that happens in a run. Calling it is what makes the cost one pass
over the casters per cascade rather than one pass over the whole frame per cascade, so
it is worth doing even when every record casts:
draw:endShadowCasters() -- everything drawn so far casts, and nothing after it doesPast that, cascades is the knob with the most cost in it, because the whole bill is
per cascade: four cascades of a scene is four passes over its casters and four maps'
worth of rasterization. A view that does not need the far end sharp is cheaper with two
or three than with four, and distance cuts the geometry each of them draws.
clearLighting() turns the shadows off with the lights, and tools/shadowprobe.lua
measures what a change to any of this did to a scene: acne on a lit surface, how wide
the fade at the edge of a shadow is, and whether an occluder 60 units away casts at
all.
A frame draws one projection, so a 3D scene and a 2D HUD need two. Drawing state,
the transform block and the lighting are all per frame, and beginOverlay()
starts a second pass that has its own of each: screen space in the 2D view, unlit,
and drawn on top of whatever the camera left, without testing depth against it.
draw:setCamera({ position = { 0, 4, 12 }, target = { 0, 0, 0 } })
draw:setLighting({ lights = { { dir = { 0, -1, 0 }, shadows = true } } })
draw:cube(0, 1, 0, 2) -- 3D, lit, shadowed
draw:beginOverlay()
draw:setColor(1, 1, 1, 1)
draw:rect(10, 10, 40, 8) -- a HUD bar in screen pixelsEverything drawn before beginOverlay() makes up the scene, including the shadow
pass; everything after it is the overlay. draw:instanceCount and
draw:drawRecordCount cover the whole frame, draw.sceneRecords how much of it
is the scene.
A mesh built with a stride of 9 puts a texture tile at the end of every vertex -- the
index of a tile in the array, or -1 to sample whatever setTexture selected. The uv
of such a vertex is how many tiles across the face is, so a face that spans several
blocks repeats its tile instead of stretching it, and one mesh can hold every kind of
block a chunk is made of.
-- x, y, z, nx, ny, nz, u, v, tile
assets:mesh({
0, 0, 0, 0, 1, 0, 0, 1, 3,
4, 0, 0, 0, 1, 0, 4, 1, 3, -- four tiles across
4, 0, 4, 0, 1, 0, 4, 0, 3,
0, 0, 4, 0, 1, 0, 0, 0, 3,
}, { 0, 1, 2, 0, 2, 3 }, 9)Every sample of a wrapped tile is kept half a texel inside its image, because the texel next to an image belongs to the layer and holds nothing: without that, the join between two blocks of one face lets the background through.
A mesh is handed in as floats and stored as 16 bytes: a position in 16 bits of the mesh's own size, the normal in three signed normalized bytes with the tile in the fourth, and a uv in 16 bits of the mesh's own range. What those integers are worth travels with the instance that draws the mesh, so it is the size of the mesh that decides, and a mesh keeps detail down to about a 32767th of itself however big it is:
| mesh | one stored position unit |
|---|---|
| a 16 unit chunk | 0.5 mm |
| a 4 unit car | 0.12 mm |
| a 1000 unit level | 3 cm |
The scale is computed once, when the mesh is uploaded, and a rebuilt mesh measures itself again. The upshot is that a whole view of the world costs about 4800 vertices and 42 KB a chunk, and a car model costs a third of what it did.
Setting a texture is state; drawing is separate. There is no draw:image:
local tex = assets:image("assets/player.png") -- PNG, cached by path
draw:setTexture(tex)
draw:rect(x, y, tex.width, tex.height) -- one quad, whole texture
draw:cube(x, y, z, size) -- or any 3D primitive
draw:clearTexture() -- back to flat colourImages are read and written with lupa.png, which handles 8 bit greyscale, RGB and RGBA
PNGs and refuses anything else rather than guessing:
local width, height, pixels = png.decode(data) -- rgba8, a row at a time from the top
local data = png.encode(width, height, pixels) -- 8 bit RGBA, no interlacingReading a file and handing its bytes back is the caller's business, which is what
assets:image does with png.decode.
draw:setTextureRect picks which part of the texture maps onto each shape. It is
per draw call, so a sprite sheet can be sliced into as many cells as you like
in one frame:
draw:setTexture(sheet)
draw:setTextureRect(0.25, 0, 0.5, 0.5) -- one cell
draw:rect(x, y, 64, 64)
draw:setTextureRect(0, 0, 8, 4) -- tile 8 by 4
draw:rect(x, y, 256, 128)draw:setColor tints, as it does for untextured shapes. Images live in a single
texture array, so textured and untextured geometry still batches into one draw
call. A PNG larger than MAX_TEXTURE_WIDTH x MAX_TEXTURE_HEIGHT is
box-filtered down to fit.
Every texture is given the smaller levels a sampler reads when an image covers fewer
pixels than it has texels (limits.textureMips, four of them), and they are filtered
row by row when rows says an image is a stack of tiles:
local tex = assets:texture(w, h, pixels, {
rows = 3, -- three tiles stacked, filtered one tile at a time
emission = 0.4, -- that much of the texture's own colour added to what lights it
cutout = true, -- texels under half an alpha are not drawn at all
})emission is what makes a texture glow in the dark without anything lighting it, and
cutout is what makes it possible to have holes in a block: a leaf is drawn with the
opaque geometry with its gaps discarded, so what is behind it shows through. rows
matters because filtering across the join between two tiles would make a distant block
average towards the colour of all its faces at once, which reads as the world changing
colour with distance.
Meshes are resources, so they are built through assets alongside textures,
and drawn through draw:
local mesh = assets:obj("assets/torus.obj") -- Wavefront .obj, cached
-- or build one directly: 8 floats per vertex -- x, y, z, nx, ny, nz, u, v --
-- followed by 0-based triangle indices
local tri = assets:mesh({
0, 0, 0, 0, 0, 1, 0, 0,
1, 0, 0, 0, 0, 1, 1, 0,
0, 1, 0, 0, 0, 1, 0, 1,
}, { 0, 1, 2 })
draw:setTexture(tex)
draw:pushModel()
draw:translate(x, y, z)
draw:rotate(angle, 0, 1, 0)
draw:mesh(mesh, 0, 0, 0, scale)
draw:popModel()Meshes take vertex colours, the current texture and the model matrix exactly like the built-in primitives, and batch into the same draw call.
A mesh that changes -- a chunk of a voxel world, a procedurally built shape -- is
rebuilt in place with mesh:update(vertices, indices). It keeps its place in the
arena while the new geometry fits there, and the next frame draws it:
mesh:update({ ...8 floats per vertex... }, { 0, 1, 2 })A type that is no longer drawn should be updated with an empty table rather than dropped, so it stops being drawn without leaving its space in the arena behind.
A mesh the game is finished with -- a chunk that has been unloaded, a level that
has been left -- hands its room back with draw:releaseMesh(mesh), and the next
mesh placed takes that room instead of growing the arena. Without the call the
room stays reserved, because a draw cannot know whether a mesh will come back:
draw:releaseMesh(mesh) -- drawn again later, it is uploaded afreshA game that knows how much geometry it is going to draw can hold the room up front
with draw:reserveMeshSpace(vertexBytes, indexBytes). Growing an arena re-uploads
every mesh already in it, so reserving before anything is placed is what keeps a
world that streams in from hitching when it turns around:
draw:reserveMeshSpace(64 * 1024 * 1024, 16 * 1024 * 1024)The .obj loader handles positions, UVs, normals and n-gon faces (fan
triangulated). Files without normals get flat per-triangle normals so lighting
still works.
- One projection and one shader per frame. There is a single
viewProjuniform, so a frame is drawn either with the 2D ortho or with a camera, not both. Mixing a 3D world with a 2D HUD needs per-batch state. - Textures are a fixed array of
MAX_TEXTURESlayers, eachMAX_TEXTURE_WIDTHxMAX_TEXTURE_HEIGHT, allocated up front. - The mesh arenas grow, and never shrink. A mesh that is released leaves room behind for the next one, so a world that streams in and out settles at a size rather than growing forever, but the room is only given up when the arena is full.
- Mesh uploads cost one submission per frame, not one per mesh: everything written before a frame is recorded goes to the GPU in one batch. A game that rebuilds a thousand meshes in a frame pays for one of them.
- Shadows are one light deep. One map is rendered per cascade per frame, from the first light that asks for it, so a second light asking is lit by the map of the first rather than one of its own -- and a point light never casts.
- Loads and texture levels are built on the CPU: a texture gets four levels, box filtered when it is added, and there is no blit to make more.
if input:isHeld("w") then -- held down this frame
if input:wasPressed("escape") then -- went down this frame
if input:wasReleased("space") then -- came up this frame
if input:modifiers().ctrl then -- a modifier another key was pressed with
local x, y = input:mousePosition()
local dx, dy = input:mouseMotion()
if input:isMouseHeld("left") then
input:setCursorGrab("locked") -- "locked", "contain" or "none"A modifier name covers either key that carries it, so isHeld("ctrl") is either
control key and isHeld("left-ctrl") is only the left one. A modifier that is held
on its own is a key like any other, and one that is held while another key is pressed
and released is reported by input:modifiers(), which is what a game that sprints on
Ctrl wants.
lupa.now() is seconds from the monotonic clock, and the dt a frame is given comes
from it, clamped to a tenth of a second so a stall does not move the world on without
the game seeing it:
function App:update(dt, input)
self.time = self.time + dt * speed -- seconds, not frames
endos.clock() is the processor time the whole process has burned, driver threads
included, so it runs ahead of the wall clock on a frame that waits on the GPU; use
lupa.now() for anything a player would see move.
lde test runs the suite in tests/. Rendering runs headless: a frame is drawn
into an offscreen target and read back, so the drawing API is checked by pixels
rather than by eye.
STYLE.md is the guide this code is written to: full LuaCATS typing, cached FFI
types, and comments only where they record an invariant that would otherwise look
like a mistake.
Set up lde.
lde add lupa

