Rendering
Colors are 0xAARRGGBB (alpha in the high byte). 2D coordinates are logical pixels;
draw calls and projection results are pixel-aligned automatically.
2D canvas
The argument of render_2d (HUD over the game) and render_gui (during screens):
module:event("render_2d", function(render)
render.rect(8, 8, 120, 24, 0x90101010, 7)
render.text("Hello", 16, 14, 9, 0xFFFFFFFF)
end)
| Function | What it does |
|---|---|
width() / height() | screen size in logical pixels |
rect(x, y, w, h, color[, radius]) | draws a filled rectangle; radius rounds the corners |
outline(x, y, w, h, color[, radius[, stroke]]) | draws a border (stroked inwards) |
shadow(x, y, w, h, color[, radius[, blur[, noise]]]) | draws a blurred rounded rectangle — a drop shadow or glow |
line(x1, y1, x2, y2, color[, stroke]) | draws a line between two points |
circle(cx, cy, r, color) | draws a filled circle |
text(str, x, y[, size[, color[, font]]]) | draws text and returns its width |
text_width(str[, size[, font]]) | returns the width the text would take, without drawing it |
font_height([size[, font]]) | returns the line height of a font |
px(v) | aligns a logical coordinate to a physical pixel |
begin(mode[, width]) / vertex(x, y[, color]) / finish() | builds a mesh of arbitrary geometry with per-vertex colors |
save() | saves the transform/clip state, returns the save count |
save_layer([x, y, w, h]) | saves state and draws into an offscreen layer |
save_layer_alpha(alpha[, x, y, w, h]) | offscreen layer composited with a uniform alpha (0..1) |
restore() | undoes the most recent save/save_layer |
restore_to(count) | restores to a count returned by save |
translate(dx, dy) | shifts subsequent drawing |
scale(sx[, sy[, pivot_x, pivot_y]]) | scales subsequent drawing (sy defaults to sx) |
rotate(degrees[, pivot_x, pivot_y]) | rotates subsequent drawing clockwise |
clip(x, y, w, h[, radius]) | intersects the clip with a (rounded) rectangle |
radius is either one number for all corners or a {tl, tr, br, bl} table:
render.rect(8, 8, 120, 24, 0x90101010, { 7, 7, 0, 0 })
shadow blurs the rectangle by blur logical pixels (default 16); noise (0..1)
adds film grain to the blur. Draw it behind the rectangle it belongs to:
render.shadow(8, 8, 120, 24, 0xA0000000, 7, 24)
render.rect(8, 8, 120, 24, 0xFF181818, 7)
The mesh builder accepts modes "points", "lines", "line_strip", "triangles"
and "triangle_strip" (case and underscores are ignored); width is the stroke
width / point size for line and point modes. A vertex color sticks for the following
vertices until changed, so gradients come from changing it per vertex:
render.begin("triangles")
render.vertex(20, 20, 0xFFFF0055)
render.vertex(80, 20, 0xFF00FF55)
render.vertex(50, 70, 0xFF0055FF)
render.finish()
Transforms and clips nest with save/restore and coordinates keep working in
logical pixels. save_layer_alpha renders everything inside into an offscreen
layer and fades it as a whole on restore — overlapping shapes don't double up
the way per-shape alpha would:
render.save_layer_alpha(0.5)
render.rect(8, 8, 60, 24, 0xFF181818, 7)
render.circle(68, 20, 12, 0xFF4FF2A6)
render.restore()
render.save()
render.rotate(45, 100, 100)
render.rect(80, 90, 40, 20, 0xFF4FF2A6, 4)
render.restore()
Anything left unbalanced at the end of a callback is restored automatically.
Default text size is 9, default font is "regular". Built-in fonts: "regular",
"medium", "consolas", "inter", "small", "icon" (icon glyphs by name —
render.text("bolt", x, y, 9, color, "icon")).
The canvas is only valid inside the callback — don't store it.
Custom fonts — fonts
| Function | What it does |
|---|---|
fonts.register(name, path[, antialias]) | loads a TTF/OTF file; the path is relative to the scripts folder (absolute works too) |
fonts.exists(name) | returns true if a font with this name is registered |
fonts.register("pixel", "fonts/pixel.ttf")
-- ...
render.text("Hi", 8, 8, 12, 0xFFFFFFFF, "pixel")
Registering the same name again replaces the font (hot-reload friendly); built-in fonts can't be replaced.
3D renderer
The argument of render_3d. Coordinates are world coordinates — the camera offset
is handled for you.
| Function | What it does |
|---|---|
camera() | returns the camera position as x, y, z |
camera_rotation() | returns the camera yaw, pitch in degrees |
camera_forward() | returns the view direction as a unit vector |
line(x1, y1, z1, x2, y2, z2, color[, width[, through_walls]]) | draws a line between two world points |
box(min_x, min_y, min_z, max_x, max_y, max_z, color[, width[, through_walls]]) | draws a box outline |
filled_box(min_x, min_y, min_z, max_x, max_y, max_z, color[, through_walls]) | draws a solid translucent box (all six faces) |
quad(x1, y1, z1, ..., x4, y4, z4, color[, through_walls]) | draws a filled quad through four world positions |
triangle(x1, y1, z1, ..., x3, y3, z3, color[, through_walls]) | draws a filled triangle through three world positions |
point(x, y, z, color[, size[, through_walls]]) | draws a point sprite (size in pixels, default 4) |
text(str, x, y, z[, color[, scale[, through_walls[, background]]]]) | camera-facing billboard text, returns the text width |
tracer(x, y, z, color[, width[, through_walls]]) | draws a line from the camera to a point |
begin(mode[, width[, through_walls]]) / vertex(x, y, z[, color]) / finish() | builds a mesh of arbitrary geometry |
begin_layer(layer) / uv(u, v) | mesh over a custom gfx.layer (see below) |
push() / pop() | matrix transform scope — coordinates become local |
origin(x, y, z) | moves the local origin to a world position |
translate(dx, dy, dz) / rotate(deg[, ax, ay, az]) / scale(sx[, sy, sz]) | local transforms (rotate axis defaults to Y) |
rotate_camera() | turns the local frame to face the camera (billboard) |
through_walls = true makes the shape visible through blocks.
module:event("render_3d", function(render)
for _, p in ipairs(world.players()) do
if not p.is_self then
local half = p.width / 2
render.box(p.x - half, p.y, p.z - half,
p.x + half, p.y + p.height, p.z + half,
0xFF4FF2A6, 2)
render.text(p.name, p.x, p.y + p.height + 0.4, p.z, 0xFFFFFFFF, 1, true)
end
end
end)
The mesh builder mirrors the 2D one: modes "lines", "line_strip", "quads",
"triangles", "triangle_strip", "triangle_fan" and "points" (case and
underscores are ignored), the vertex color sticks until changed, width is the
line width / point size for line and point modes:
render.begin("quads", nil, true)
render.vertex(x, y, z, 0x604FF2A6)
render.vertex(x + 1, y, z)
render.vertex(x + 1, y, z + 1)
render.vertex(x, y, z + 1)
render.finish()
Transforms
push/pop open a matrix transform scope — inside it all draw coordinates are
local to the transform instead of world-absolute. origin places the local origin
at a world position (the usual first call after push), rotate_camera turns the
local frame to face the camera with the exact orientation quaternion, so billboards
need no trigonometry:
module:event("render_3d", function(render)
for _, p in ipairs(world.players()) do
if not p.is_self then
render.push()
render.origin(p.x, p.y + p.height / 2, p.z)
render.rotate_camera()
render.begin_layer(glow)
render.uv(0, 0); render.vertex(-0.6, 0.6, 0, 0x904FF2A6)
render.uv(1, 0); render.vertex(0.6, 0.6, 0)
render.uv(1, 1); render.vertex(0.6, -0.6, 0)
render.uv(0, 1); render.vertex(-0.6, -0.6, 0)
render.finish()
render.pop()
end
end
end)
rotate(degrees[, ax, ay, az]) spins around an arbitrary axis (Y by default),
scale and translate work in the local frame, transforms nest with push/pop,
and anything left unbalanced is popped automatically after the callback.
Custom pipelines and textures — gfx
For everything the built-in shapes can't do: gfx compiles GPU pipelines from GLSL
source written right in the script, loads textures from files, and combines them
into layers the 3D mesh builder can draw with.
| Function | What it does |
|---|---|
gfx.pipeline{...} | compiles a pipeline from inline GLSL; raises on compile errors (log has the details) |
gfx.texture(path) | registers a PNG (relative to the scripts folder) — returns handle, width, height |
gfx.layer(pipeline[, texture]) | pipeline + texture as a drawable layer; memoized per pair |
The gfx.pipeline descriptor takes name, vertex and fragment (GLSL sources),
and optionally format ("position", "position_color", "position_tex",
"position_tex_color" — default "position_color"), mode ("quads",
"triangles", "triangle_strip", "triangle_fan", "lines", "line_strip",
"points" — default "quads"), blend ("none", "translucent",
"premultiplied", "additive", "lightning", "overlay", "glint", "invert" —
default "translucent"), depth_test ("lequal", "less", "equal", "greater",
"none" — default "lequal"; "none" draws through walls), depth_write and
cull (both default false).
Shader sources support #moj_import exactly like vanilla shaders — import
dynamictransforms.glsl and projection.glsl for the standard matrices, and
globals.glsl for GameTime. position_tex* formats expose the layer texture as
Sampler0. Create pipelines, textures and layers once at script top level; they
survive resource reloads (F3+T) and are released when the script unloads.
local pipe = gfx.pipeline{
name = "pulse",
format = "position_color",
mode = "quads",
blend = "additive",
depth_test = "none",
vertex = [[
#version 330
#moj_import <minecraft:dynamictransforms.glsl>
#moj_import <minecraft:projection.glsl>
in vec3 Position; in vec4 Color;
out vec4 vertexColor;
void main() {
gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
vertexColor = Color;
}
]],
fragment = [[
#version 330
#moj_import <minecraft:globals.glsl>
in vec4 vertexColor;
out vec4 fragColor;
void main() {
fragColor = vertexColor * (0.6 + 0.4 * sin(GameTime * 1200.0));
}
]],
}
local layer = gfx.layer(pipe)
module:event("render_3d", function(render)
for _, p in ipairs(world.players()) do
if not p.is_self then
local half = p.width / 2
render.begin_layer(layer)
render.vertex(p.x - half, p.y, p.z - half, 0x804FF2A6)
render.vertex(p.x + half, p.y, p.z - half)
render.vertex(p.x + half, p.y, p.z + half)
render.vertex(p.x - half, p.y, p.z + half)
render.finish()
end
end
end)
Textured quads work the same way — set the sticky texture coordinates with uv
before each vertex:
local pipe = gfx.pipeline{ name = "sprite", format = "position_tex_color",
vertex = VSH, fragment = FSH }
local tex = gfx.texture("sprites/marker.png")
local layer = gfx.layer(pipe, tex)
render.begin_layer(layer)
render.uv(0, 0); render.vertex(x, y + 1, z)
render.uv(1, 0); render.vertex(x + 1, y + 1, z)
render.uv(1, 1); render.vertex(x + 1, y, z)
render.uv(0, 1); render.vertex(x, y, z)
render.finish()
3D → 2D projection — projection
Converts world coordinates to screen coordinates (the same space the 2D canvas draws
in). Call from render callbacks (render_2d, render_gui, frame).
| Function | Returns |
|---|---|
projection.to_screen(x, y, z[, ignore_frustum]) | screen position sx, sy, or nil when the point is behind the camera or out of view |
projection.box(min_x, min_y, min_z, max_x, max_y, max_z) | the box on screen as x, y, w, h, or nil |
projection.entity_box(entity_id) | the entity's hitbox on screen as x, y, w, h (smoothly interpolated), or nil |
ignore_frustum = true also projects points outside the view (it only fails behind
the camera) — useful for edge-of-screen indicators.
module:event("render_2d", function(render)
for _, p in ipairs(world.players()) do
if not p.is_self then
local x, y, w, h = projection.entity_box(p.id)
if x then render.outline(x, y, w, h, 0xFF4FF2A6, 0, 1) end
end
end
end)