All SDK docs

vpy3d — 3D

Meshes with hidden-line removal, a camera and projection, solids that hide each other, dents, shot marks, text in the world, stereo, level of detail and terrain.

vpy3d is a small 3D layer on top of libvpy's stroke buffer. It provides:

  • meshes with hidden-line removal within each mesh;
  • a camera, a projection and a screen clip;
  • a silhouette occluder so that one solid can hide another.

It is integer-only and deterministic. Add $(VPY_C_SDK)/vpy3d.c to UVM2_SRCS. The header (vpy3d.h) is the full reference. This page describes how the pieces fit together.

Transforms, camera and projection

typedef struct { int32_t m[9]; int32_t t[3]; } vpy_xf;   /* Q14 rotation + translation */
 
vpy_xf vpy3d_identity(void);
vpy_xf vpy3d_mul(const vpy_xf *a, const vpy_xf *b);      /* apply b, then a */
vpy_xf vpy3d_rot_x(int ang), vpy3d_rot_y(int ang), vpy3d_rot_z(int ang);
vpy_xf vpy3d_translate(int32_t x, int32_t y, int32_t z);
 
int  vpy3d_look_at(int32_t ex,int32_t ey,int32_t ez, int32_t tx,int32_t ty,int32_t tz,
                   int32_t ux,int32_t uy,int32_t uz);     /* up: usually 0,1,0 */
void vpy3d_eye(int32_t *out);                             /* where the camera is */
void vpy3d_set_focal(int32_t f), vpy3d_set_near(int32_t n), vpy3d_set_clip(int32_t h);
void vpy3d_set_clip_xy(int32_t hx,int32_t hy);            /* window per axis; default 15500 square */
void vpy3d_set_aspect(int32_t num,int32_t den);           /* x scale; default 1/1 */
int  vpy3d_h_half_angle(void), vpy3d_v_half_angle(void); /* field of view, Q14 units */
 
void vpy3d_line_world(int32_t ax,int32_t ay,int32_t az, int32_t bx,int32_t by,int32_t bz,int br);
void vpy3d_line_cam(const int32_t *a, const int32_t *b, int br);

vpy3d_set_focal sets the field of view: a bigger value gives a narrower view. The default, 28000, is about 58° across the default 15500 clip. To find out what is actually on screen, read vpy3d_h_half_angle and vpy3d_v_half_angle rather than hard-coding an angle.

Aspect and the visible window

One unit is the same size on both axes, and the glass is portrait. Measured on a console with the geometry_card example, a 16000-unit square is square on the glass to within about 10%. The visible window was about ±18000 by ±20500. So the portrait shape belongs in the window, not in the scale.

  • Aspect: vpy3d_set_aspect defaults to 1/1. Change it only for a console whose size pots are off.
  • Clip: the default is the 15500 square that every vpy3d game has been composed in. vpy3d_set_clip_xy opens the taller window.
  • Per console: a console calibrated with ASPECT / WIN X / WIN Y in the calibration screen has its aspect applied automatically, unless the game sets its own. Its visible window is used only if the game calls vpy3d_use_console_window(), because a wider window changes what a game composed in the 15500 square shows.

Meshes

Build a mesh once at start-up, then draw it every frame:

static vpy_mesh cube;
 
vpy3d_mesh_begin(&cube);
int v[8];
/* v[i] = vpy3d_vertex(x, y, z); for the eight corners */
vpy3d_quad(v[0], v[1], v[2], v[3]);   /* ... six faces */
vpy3d_mesh_end(VPY3D_HARD_45);        /* crease threshold */
 
/* each frame */
vpy_xf at = vpy3d_translate(0, 0, 4000);
vpy3d_draw_mesh(&cube, &at, 100);
  • Winding: wind each face's vertices anticlockwise as seen from outside. If a model comes out inside-out, the winding is the reason.
  • Crease threshold: this sets which shared edges between faces are drawn. VPY3D_HARD_30, _45 and _60 keep only edges where the faces meet at a sharper angle than that. VPY3D_HARD_ALL draws every edge as a full wireframe.
  • Plates: vpy3d_mesh_open(m, 1) marks a flat plate rather than a solid, so its outline is always drawn.
  • Compiled meshes: vpy3d_load_mesh reads a compiled mesh asset.

Occlusion between solids

Hidden lines are removed within a mesh, never between two meshes. There is no depth buffer, so a second solid in front of the first is simply not there: you see through it. Any game with two solids on screen has this problem until it uses the occluder.

The occluder works with silhouettes. A convex solid's silhouette is the convex hull of its projected corners. A line behind it is drawn minus the part inside the hull, which is exact for convex occluders.

void vpy3d_occl_reset(void);                                   /* once a frame */
int  vpy3d_occl_add(const int32_t (*corners)[3],int n);        /* 3..8 world corners */
int  vpy3d_occl_add_mesh(const vpy_mesh *m,const vpy_xf *place);   /* up to 8 vertices */
void vpy3d_occl_line(int32_t ax,int32_t ay,int32_t az, int32_t bx,int32_t by,int32_t bz,int br);
void vpy3d_occl_line_cam(const int32_t *a,const int32_t *b,int br);
void vpy3d_set_mesh_occlusion(int on);                         /* cut draw_mesh too; default off */
int  vpy3d_occl_count(void);

There is no depth test. A silhouette means "behind" only because of the order you draw in:

vpy3d_occl_reset();
/* draw the nearest solid, THEN add it */
vpy3d_draw_mesh(&m, &at, 100);
vpy3d_occl_add_mesh(&m, &at);          /* after, never before */
/* draw the next one, near to far, each line through vpy3d_occl_line */

The rules:

  • Draw near to far. Draw each solid, then add it as an occluder.
  • Moving solids need nothing special. Occluders are rebuilt every frame, so solids that move, turn or swap depth order just work.
  • Turn on mesh cutting if you need it. With vpy3d_set_mesh_occlusion(1), the strokes vpy3d_draw_mesh emits are cut like any other line, after the mesh's own hidden-line removal.
  • Treat equal depths as a group. Draw everything at the same depth, then add all of it. Otherwise the first one drawn bites a piece out of its neighbour.
  • Lines in front are safe. A line that is entirely nearer than an occluder's nearest corner is never cut by it.

With no occluder added, vpy3d_occl_line is vpy3d_line_world plus one comparison, so a game can send every stroke through it.

What it does not cut

Each of these looks like a broken occluder:

CaseWhat happens
vpy3d_draw_mesh, by defaultNot cut unless vpy3d_set_mesh_occlusion(1) is on. Off by default because a game may rely on a mesh never being cut.
A visible piece shorter than 1/48 of the line's screen lengthDropped as a sliver.
An occluder with a corner behind the near planeRefused (vpy3d_occl_add returns 0 and occl_refused counts it). That frame, it hides nothing.
The 65th occluderRefused and counted in occl_full. Add the nearest ones first.
An occluder many screens wideThe projection saturates and the hull arithmetic overflows. Clamp it to the visible window before adding it.

A line with one end behind the near plane is cut: it is clipped to the near plane first, and the rest is tested.

vpy3d_stats() counts all of this per frame (zero the counters with vpy3d_reset_counts()). occl_cut counts lines that lost a part, and it is the proof that the occluder ran. If solids are behind silhouettes on screen and occl_cut is zero, your drawing order is wrong, not the occluder.

vpyent does this ordering for you every frame.

Dents

int  vpy3d_mesh_copy(vpy_mesh *dst,const vpy_mesh *src);       /* again = reset, no pool */
void vpy3d_mesh_dent(vpy_mesh *m,int32_t px,int32_t py,int32_t pz,
                     int32_t dx,int32_t dy,int32_t dz,int32_t depth,int32_t radius);
void vpy3d_world_to_model(const vpy_xf *place,int32_t wx,int32_t wy,int32_t wz,
                          int32_t *mx,int32_t *my,int32_t *mz);

Everything drawn with a mesh shares it, so an object that can be dented needs its own copy first (vpy3d_mesh_copy). Copying into the same copy again resets it without using more pool, which is how a game recycles objects.

vpy3d_mesh_dent pushes in the vertices near a point and then works out the creases again, so a flat face shows the fold. But a face can only bend where it has vertices: give a dentable box an extra vertex in the middle of each face (it still draws like a plain box). Dents only change the drawing. A physics body keeps its shape.

Shot marks and ray casts

int  vpy3d_ray_mesh(const vpy_mesh *m,const vpy_xf *place,int32_t ox,int32_t oy,int32_t oz,
                    int32_t dx,int32_t dy,int32_t dz,int32_t max_dist,vpy3d_hit *out);  /* face or -1 */
void vpy3d_marks_clear(vpy3d_marks *mk);                       /* one vpy3d_marks per object */
void vpy3d_marks_add(vpy3d_marks *mk,const vpy_xf *place,int32_t x,int32_t y,int32_t z,
                     int32_t nx,int32_t ny,int32_t nz);
int  vpy3d_marks_draw(const vpy3d_marks *mk,const vpy_xf *place,int32_t radius,int br,
                      int style);                              /* VPY3D_MARK_RING / _CRACK */

On the tube, a dent alone can't be seen on an object a few millimetres across. A small bright ring on the face that was hit can.

  • vpy3d_marks_add stores the hit in the object's own space, so the mark turns with the object.
  • vpy3d_marks_draw draws rings or cracks and skips faces turned away from the camera. Call it after drawing the object and before adding the object as an occluder. When a full set gets a new mark, it drops its oldest one.
  • vpy3d_ray_mesh finds the face a ray hits on any mesh, in its current dented or morphed shape. For physics bodies, use vpyp_raycast instead.

Text in the world

int  vpy3d_text(const char *s,const vpy_xf *place,int32_t height,int br,int flags);
int  vpy3d_text_billboard(const char *s,int32_t x,int32_t y,int32_t z,int32_t height,int br,int flags);
                                         /* VPY3D_TEXT_OCCLUDE | _CENTRE | _FRONT */

vpy3d_text draws the same vector font PRINT_TEXT uses, on a plane placed by a vpy_xf:

  • x runs along the text and y up the letters, read from the plane's −z side;
  • height is the height of a capital in world units;
  • each font stroke becomes one stroke.

Two flags and one variant are useful:

  • VPY3D_TEXT_FRONT hides the text when seen from behind, where it would read backwards.
  • VPY3D_TEXT_OCCLUDE sends the text through the occluder.
  • vpy3d_text_billboard places the text at a point and keeps it square to the camera, which makes a label over an object.

Stereo

void vpy3d_set_stereo(int eye,int32_t half_separation,int32_t converge);  /* eye -1 / +1; 0 = off */

This moves the camera half the separation to one side, looking parallel, and shifts the picture so that the convergence plane has no parallax. Anything nearer than that plane comes out of the screen. Draw the scene once per eye.

This is the picture half of 3D Imager support. The driver for the goggles (the wheel's speed and its sync) is not in the SDK yet.

Level of detail and morphing

int32_t vpy3d_screen_size(int32_t x,int32_t y,int32_t z,int32_t radius);
int  vpy3d_lod_pick(const vpy_xf *place,int32_t radius,const int32_t *min_size,int n);  /* -1: none */
int  vpy3d_draw_lod(const vpy_mesh *const *meshes,const int32_t *min_size,int n,
                    const vpy_xf *place,int32_t radius,int br);
int  vpy3d_mesh_blend(vpy_mesh *dst,const vpy_mesh *a,const vpy_mesh *b,int32_t t_q14);
  • Level of detail: give a list of versions of a model, each with the minimum screen size it needs. The call draws the most detailed version that the object's current size on screen still deserves, or nothing if it's too small for any of them. On this hardware, strokes are the budget, so a far-away ship drawn with 12 edges instead of 60 is a real saving.
  • Morphing: vpy3d_mesh_blend blends two poses of the same model (same topology) into dst.

Terrain, fog and shadows

int  vpy3d_terrain(const int16_t *h,int cols,int rows,int32_t x0,int32_t z0,int32_t cell,int br);
int  vpy3d_fog(int br,int32_t x,int32_t y,int32_t z,int32_t full_until,int32_t gone_at);
int  vpy3d_shadow(const int32_t (*corners)[3],int n,int32_t lx,int32_t ly,int32_t lz,
                  int32_t floor_y,int br);
  • Terrain: vpy3d_terrain draws a height map with its hidden lines removed, using a floating horizon. A ridge hides what is behind it.
  • Fog: vpy3d_fog turns distance into brightness. Full brightness up to full_until, nothing from gone_at.
  • Shadows: vpy3d_shadow projects the hull of a set of corners onto the floor, from a light.

Checks

The host programs in sdk/vpy-c/tools/ check vpy3d against its header:

ProgramChecks
aspect_check.cA world square projects square
mesh_check.cRays, marks and the LOD pick
dent_check.cDents
text3d_check.cText and stereo
terrain_check.cThe floating horizon
shade_check.cFog and shadows
shape_check.cThe console's screen shape