VPy can draw 3D: meshes (.vmesh) and scenes (.vscene) in perspective, with hidden-line removal, occluders and fog. The builtins call the SDK's vpy3d — the same 3D layer C games use — and the compiler links it into the game when the program uses any of them.
| Target | 3D builtins |
|---|---|
uvm2 | yes |
rp2350 | yes, when the program is built through C ([rp2350] backend = "c" in the .vpyproj) |
m6809, pitrex | compile error |
On m6809 and pitrex, a .vec with z coordinates and DRAW_VECTOR_3D gives simple 3D (see VPy Language). The meshes and scenes themselves are made in the 3D Editors.
A complete example
A mesh at assets/meshes/ship.vmesh, turning in front of the camera, with its name floating above it. Build it with target = "uvm2" in the .vpyproj.
META TITLE = "SHIP"
pose: i32 = [0] * 12 # where the ship is
spin: i32 = [0] * 12 # how it is turned
angle = 0
def main():
LOAD_MESH("ship")
CAMERA_LOOK_AT(0, 0, -2000, 0, 0, 0)
def loop():
angle = (angle + 16) & 4095 # 4096 = one turn
XF_ROT_Y(spin, angle)
XF_TRANSLATE(pose, 0, 0, 0)
XF_MUL(pose, pose, spin) # spin first, then move
DRAW_MESH("ship", pose, 110)
TEXT_BILLBOARD("SHIP", 0, 700, 0, 200, 80, 2) # 2 = centredUnits and transforms
| Quantity | Unit |
|---|---|
| World coordinates | 32-bit integers, in the meshes' own unit |
| Angles | 4096 per turn |
| Brightness | 0..127 |
| Rotations | Q14 (16384 = 1.0) |
Screen positions from PROJECT | Deflection units (VPy units ×127) |
A transform is a global i32 array of 12 elements: a Q14 rotation, then a translation. Declare one per placement you need ([0] * 12 is the short form).
| Function | Description |
|---|---|
XF_IDENTITY(xf) | xf = no rotation, no move |
XF_TRANSLATE(xf, x, y, z) | xf = a move by (x, y, z) |
XF_ROT_X(xf, angle) / XF_ROT_Y(xf, angle) / XF_ROT_Z(xf, angle) | xf = a rotation about one axis |
XF_MUL(dst, a, b) | dst = b then a (dst may be a or b) |
XF_POINT(out, xf, x, y, z) | out[0..2] = the point moved by xf (out: an i32 array of 3) |
XF_TO_MODEL(out, xf, x, y, z) | The reverse: the world point in xf's own frame |
SIN_Q14(angle) / COS_Q14(angle) | Sine and cosine, 4096 per turn, -16384..16384 |
ATAN2_Q14(y, x) | The angle of (x, y), 4096 per turn (0..4095) |
MUL_SHR(a, b, shift) | (a * b) >> shift with a 64-bit product, for Q14 maths |
Camera and projection
| Function | Description |
|---|---|
CAMERA_LOOK_AT(ex, ey, ez, tx, ty, tz[, ux, uy, uz]) | Camera at the eye looking at the target; (ux, uy, uz) is the world direction that points up on screen (default +y; tilt it to bank). Returns 0 if eye and target coincide |
CAMERA_SET(xf) / CAMERA_GET(xf) | Set the world→camera transform / read it into xf |
CAMERA_FOCAL(f) | Field of view: default 28000, about 58°; bigger = narrower |
CAMERA_NEAR(z) | Nothing nearer than z is drawn (default 600) |
CAMERA_CLIP(half_x[, half_y]) | Half the visible window in deflection units (default 15500 square) |
PROJECT(out, x, y, z) | Where a world point lands on screen (out: an i32 array of 2). Returns 0 if it is behind the camera |
PROJECT_CAM(out, x, y, z) | The same for a point already in camera space (x right, y up, z ahead) |
SCREEN_SIZE(x, y, z, radius) | How big a ball of radius at that point looks, in deflection units |
To bank the view, pass SIN_Q14(roll), COS_Q14(roll), 0 as the up direction.
Meshes
A mesh must be loaded before it is drawn. Meshes are named by string literal and must exist: a missing one is a compile error.
| Function | Description |
|---|---|
LOAD_MESH("name") | Load name.vmesh into the mesh pools. Returns 0 if the pools are full |
DRAW_MESH("name", xf, brightness) | Draw a loaded mesh placed by xf |
MESH("name") | The mesh's slot, as a number |
MESH_OCCLUSION(on) | 1: meshes are cut by the solids drawn before them in the frame; 0 (default): meshes are drawn whole |
MESH_POOL_MARK() / MESH_POOL_RELEASE() | Remember where the pools are / give back every mesh loaded since (load them again before drawing them) |
Slots. Every mesh argument also takes a slot, so a function can be handed the mesh it draws:
def figure(p, body):
DRAW_MESH(body, p, 100)
def loop():
figure(pose, MESH("scout"))If LOAD_MESH returns 0, raise VPY3D_POOL_* in the project's [uvm2] defs.
Occluders and lines
Occluders are invisible solids that hide whatever is drawn after them. Draw near to far.
| Function | Description |
|---|---|
OCCL_RESET() | Forget the occluders — once per frame, before adding the frame's |
OCCL_MESH("name", xf) | Add a loaded mesh placed by xf as an occluder |
OCCL_ADD(corners, n) | Add a convex occluder of n (3..8) corners; corners is an i32 array of n points (x, y, z) |
OCCL_LINE(ax, ay, az, bx, by, bz, brightness) | A world line hidden by the occluders added so far |
LINE_3D(ax, ay, az, bx, by, bz, brightness) | A line in world space |
LINE_CAM(ax, ay, az, bx, by, bz, brightness) | A line in camera space: gunsights and overlays that turn with the view |
A .vmesh can also carry its own occluders, which go into the table when the mesh is drawn (see 3D Editors).
Fog
| Function | Description |
|---|---|
FOG(brightness, x, y, z, full_until, gone_at) | brightness faded by the distance of (x, y, z) from the camera: full up to full_until, 0 from gone_at |
SCENERY_FOG(full_until, gone_at) | The same fade for the lines of .vmesh scenery (0, 0 = off) |
br = FOG(110, x, y, z, 3000, 17500)
DRAW_MESH("ship", pose, br)Scenes
A .vscene arranges .vmesh pieces: placed, repeated, following the player's position, with fog and groups.
| Function | Description |
|---|---|
LOAD_SCENE("name") | Load name.vscene and the meshes it places. Returns 0 on failure |
DRAW_SCENE("name", x, y, z, brightness[, groups]) | Draw the scene at position (x, y, z) — what its follow items move with; groups is the bit mask its group items test |
DRAW_SCENE_AT("name", xf, x, y, z, brightness[, groups]) | The same, the whole scene placed by the transform xf (moved and turned) |
dist = 0
def main():
LOAD_SCENE("tunnel")
CAMERA_LOOK_AT(0, 0, 0, 0, 0, 1000)
def loop():
dist += 40
DRAW_SCENE("tunnel", 0, 0, dist, 90, 1) # group bit 0 onHits and damage marks
| Function | Description |
|---|---|
RAY_MESH(out, "mesh", xf, ox, oy, oz, dx, dy, dz, max_dist) | The first face of a loaded mesh placed by xf that the ray meets within max_dist: its index, or -1. out (an i32 array of 7) gets the hit point, the face's normal (Q14) and the distance |
MARKS_CLEAR(k) | Forget damage-mark set k (0..3) |
MARKS_ADD(k, xf, x, y, z, nx, ny, nz) | Add a hit at (x, y, z) with face normal (nx, ny, nz) on the object placed by xf — what RAY_MESH gives. Marks turn and move with the object |
MARKS_DRAW(k, xf, radius, brightness[, style]) | Draw set k: style 0 a scorch ring, 1 a crack. Draw right after the object, before adding it as an occluder |
More than four mark sets: raise VPYX_MARK_SETS in [uvm2] defs.
hit: i32 = [0] * 7
def shoot(ox, oy, oz, dx, dy, dz):
if RAY_MESH(hit, "ship", pose, ox, oy, oz, dx, dy, dz, 20000) >= 0:
MARKS_ADD(0, pose, hit[0], hit[1], hit[2], hit[3], hit[4], hit[5])Text and drawings in the world
| Function | Description |
|---|---|
TEXT_BILLBOARD(text, x, y, z, height, brightness[, flags]) | Text at (x, y, z) facing the camera, height world units tall. flags: 1 hidden by the occluders, 2 centred, 4 only seen from the front |
TEXT_3D(text, xf, height, brightness[, flags]) | Text on the plane placed by xf (its x along the text, y up) |
LABEL_NUM(text, n) | text followed by n in decimal, as a string; four can be alive at once |
VEC_3D("name", xf, scale, brightness[, flags]) | A .vec drawn on the plane placed by xf, its units times scale |
text is a string literal, or a variable or parameter holding one:
TEXT_BILLBOARD(LABEL_NUM("SCORE ", score), 0, 900, 4000, 250, 90, 2)Effects (FX_*)
Short-lived pieces that fly, fall, bounce on a floor and fade. Spawn them, then call FX_STEP() and FX_DRAW() once per frame. Velocities are units per second, gravity units per second², life in frames. Each spawn returns how many pieces it made.
| Function | Description |
|---|---|
FX_RESET() | No pieces, no floor, no gravity |
FX_SEED(seed) / FX_RATE(steps_per_second) / FX_BUDGET(strokes) | Random seed; simulation rate (default 50); strokes per frame at most (default 160) |
FX_GRAVITY(gx, gy, gz) / FX_FLOOR(on, y, bounce_q8) | Gravity; a floor at height y (256 = a perfect bounce) |
FX_SPARK(x, y, z, vx, vy, vz, life, brightness) | One spark |
FX_BURST(x, y, z, vx, vy, vz, count, speed, life, brightness) | count sparks thrown out from a point |
FX_STICK(ax, ay, az, bx, by, bz, vx, vy, vz, spin, life, brightness) | A spinning line segment |
FX_LINE(ax, ay, az, bx, by, bz, life, brightness) | A line that stays put and fades |
FX_RING(cx, cy, cz, nx, ny, nz, r0, speed, segments, life, brightness) | A ring growing from radius r0 |
FX_SHATTER("mesh", xf, vx, vy, vz, cx, cy, cz, speed, spin, life, brightness) | A loaded mesh broken into its edges, flying out |
FX_DISINTEGRATE("mesh", xf, cx, cy, cz, per_edge, speed, spin, wave, life, brightness) | A mesh coming apart in a wave |
FX_ASSEMBLE("mesh", xf, per_edge, scatter, frames, stagger, brightness) | Pieces flying in to form the mesh; returns a group |
FX_ASSEMBLED(group) / FX_RELEASE(group) | Is the group in place / let its pieces go |
FX_STEP() / FX_DRAW([occlude]) / FX_DRAW2D() | Move the pieces / draw them in 3D (1 = hidden by the occluders) / draw 2D pieces |
def explode():
FX_SHATTER("ship", pose, 0, 0, 0, 0, 0, 0,
2200, 1024, 45, 120)
def loop():
FX_STEP()
FX_DRAW(1)Skeletons (BONE_*)
Bones in a tree, each with an optional mesh, posed by rotations or by IK. A skeleton is a number, 0 or 1 by default; bone -1 in BONE_ROT / BONE_ROT_MUL is the root.
| Function | Description |
|---|---|
BONE_INIT(sk) | Empty skeleton sk |
BONE_ADD(sk, parent, ox, oy, oz[, "mesh"]) | A bone at an offset from its parent's joint (parent -1 = a root); returns its index, -1 if refused |
BONE_ROT(sk, bone, ax, ay, az, angle) | The bone's rotation about an axis, 4096 per turn |
BONE_ROT_MUL(sk, bone, ax, ay, az, angle) | Compose a second rotation onto it |
BONE_POSE(sk, x, y, z) | Place the root and work out every bone (call after changing rotations) |
BONE_IK(sk, upper, tx, ty, tz, px, py, pz) | Bend the two bones below upper to reach (tx, ty, tz), the knee towards (px, py, pz); 1 if it reached. BONE_POSE again afterwards |
BONE_XF(xf, sk, bone) / BONE_POS(out, sk, bone) | A bone's transform / its joint in the world |
BONE_DRAW(sk, brightness[, what]) | Draw it: 1 meshes (default), 2 lines, 3 both |
Soft bodies (SOFT_*)
Cloth sheets and blobs of points and springs. Each one made is a body number; call SOFT_STEP() and SOFT_DRAW(body, …) once per frame.
| Function | Description |
|---|---|
SOFT_RESET() | No bodies |
SOFT_RATE(n) / SOFT_ITERATIONS(n) / SOFT_DAMPING(q8) / SOFT_BUDGET(strokes) | Simulation rate, solver iterations (default 8), damping (default 254 of 256), strokes per draw |
SOFT_GRAVITY(gx, gy, gz) / SOFT_FLOOR(on, y, friction_q8) | Gravity; a floor at height y |
SOFT_CLOTH(x, y, z, w, h, cell, stiff_q8) | A w × h sheet of points cell apart; returns the body |
SOFT_BLOB(x, y, z, n, radius, stiff_q8, pressure_q8) | A ring of n points that keeps its area |
SOFT_MESH("mesh", x, y, z, stiff_q8) | A loaded mesh made soft |
SOFT_PIN(body, i, x, y, z) / SOFT_UNPIN(body, i) | Hold point i / let it go |
SOFT_PUSH(body, vx, vy, vz) | Add a velocity to every point |
SOFT_POINT(out, body, i) / SOFT_POINTS(body) | Where point i is / how many points |
SOFT_FREE(body) | Remove the body |
SOFT_STEP() / SOFT_DRAW(body, brightness[, occlude]) | Move every body / draw one |
Skeletons and clips as constant tables
A .vskel skeleton or a .vclip library of poses and clips is imported like a module, with no .vpy file behind it. The compiler reads the asset and turns it into constants:
import humanoid # assets/skeletons/**/humanoid.vskel
import moves # assets/clips/**/moves.vclip, and its skeleton
def loop():
twist = moves.T_POSES[moves.POSE_GUARD][moves.JOINT_TWIST]- A
.vclipgives its poses (T_POSES[N_POSES][N_JOINTS]), clips (T_CLIPS,T_KEYS), properties, marks and sets (T_SET_CLIPS,T_SET_POSES), with names such asPOSE_<p>,CLIP_<c>,JOINT_<j>. - A
.vskelgives its bones (T_BONES), turns (T_ROTS,T_TURNS), variants, meshes per bone (T_BONE_MESHES), sockets, andT_MESHES: each mesh'sMESH("name")slot. - Names are upper-cased. Poses, clips and sets are numbered alphabetically, so read everything by name.
- A
.vpymodule of the same name wins; a.vclipand a.vskelof one name are an error.
Your program builds the character with one BONE_ADD per bone and turns it each frame from these tables. Both formats are described in 3D Editors.
Memory and pool sizes
Pool sizes go in the project's [uvm2] defs:
| Define | What |
|---|---|
VPY3D_POOL_* | The mesh pools |
VPYX_MARK_SETS | Damage-mark sets (default 4) |
VPYFX_MAX | Effect pieces |
VPYSOFT_MAX_POINTS, VPYSOFT_MAX_SPRINGS | Soft-body points and springs |
VPYB_MAX_BONES | Bones |
VPYX_SKELETONS | How many skeletons (2 by default) |
VPYX_LAYERS_OS | Compile effects, skeletons and cloth for size instead of speed |
A .um2 runs from SRAM, where code and data share 512 KB, so a big game may need VPYX_LAYERS_OS. A program's variables live in the 16 KB the runtime reserves at the top of SRAM: scalars in about 2.9 KB, array data in 12 KB. Running out of either is a compile error.
Not available from VPy yet
- Terrain
- Bone animation clips: the
.vclipformat and its editor exist, and VPy reads clips as constant tables, but there are no builtins that play a clip on a skeleton yet. Interpolate between poses in your own code.