All docs

3D in VPy

Meshes and scenes in perspective with hidden-line removal, a camera, occluders, fog, text in the world, effects, skeletons and soft bodies — the 3D builtins of VPy.

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.

Target3D builtins
uvm2yes
rp2350yes, when the program is built through C ([rp2350] backend = "c" in the .vpyproj)
m6809, pitrexcompile 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 = centred

Units and transforms

QuantityUnit
World coordinates32-bit integers, in the meshes' own unit
Angles4096 per turn
Brightness0..127
RotationsQ14 (16384 = 1.0)
Screen positions from PROJECTDeflection 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).

FunctionDescription
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

FunctionDescription
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.

FunctionDescription
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.

FunctionDescription
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

FunctionDescription
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.

FunctionDescription
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 on

Hits and damage marks

FunctionDescription
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

FunctionDescription
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.

FunctionDescription
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.

FunctionDescription
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.

FunctionDescription
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 .vclip gives 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 as POSE_<p>, CLIP_<c>, JOINT_<j>.
  • A .vskel gives its bones (T_BONES), turns (T_ROTS, T_TURNS), variants, meshes per bone (T_BONE_MESHES), sockets, and T_MESHES: each mesh's MESH("name") slot.
  • Names are upper-cased. Poses, clips and sets are numbered alphabetically, so read everything by name.
  • A .vpy module of the same name wins; a .vclip and a .vskel of 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:

DefineWhat
VPY3D_POOL_*The mesh pools
VPYX_MARK_SETSDamage-mark sets (default 4)
VPYFX_MAXEffect pieces
VPYSOFT_MAX_POINTS, VPYSOFT_MAX_SPRINGSSoft-body points and springs
VPYB_MAX_BONESBones
VPYX_SKELETONSHow many skeletons (2 by default)
VPYX_LAYERS_OSCompile 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 .vclip format 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.

The C side has both; see vpy3d and Animation.