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_aspectdefaults 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_xyopens 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,_45and_60keep only edges where the faces meet at a sharper angle than that.VPY3D_HARD_ALLdraws 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_meshreads 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 strokesvpy3d_draw_meshemits 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:
| Case | What happens |
|---|---|
vpy3d_draw_mesh, by default | Not 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 length | Dropped as a sliver. |
| An occluder with a corner behind the near plane | Refused (vpy3d_occl_add returns 0 and occl_refused counts it). That frame, it hides nothing. |
| The 65th occluder | Refused and counted in occl_full. Add the nearest ones first. |
| An occluder many screens wide | The 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_addstores the hit in the object's own space, so the mark turns with the object.vpy3d_marks_drawdraws 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_meshfinds the face a ray hits on any mesh, in its current dented or morphed shape. For physics bodies, usevpyp_raycastinstead.
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;
heightis the height of a capital in world units;- each font stroke becomes one stroke.
Two flags and one variant are useful:
VPY3D_TEXT_FRONThides the text when seen from behind, where it would read backwards.VPY3D_TEXT_OCCLUDEsends the text through the occluder.vpy3d_text_billboardplaces 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_blendblends two poses of the same model (same topology) intodst.
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_terraindraws a height map with its hidden lines removed, using a floating horizon. A ridge hides what is behind it. - Fog:
vpy3d_fogturns distance into brightness. Full brightness up tofull_until, nothing fromgone_at. - Shadows:
vpy3d_shadowprojects 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:
| Program | Checks |
|---|---|
aspect_check.c | A world square projects square |
mesh_check.c | Rays, marks and the LOD pick |
dent_check.c | Dents |
text3d_check.c | Text and stereo |
terrain_check.c | The floating horizon |
shade_check.c | Fog and shadows |
shape_check.c | The console's screen shape |