mesh3d guide

← mesh3d · API reference · source

mesh3d is 3D geometry as plain data: Vec2 / Vec3 / Vec4 and a column-major Mat4 with the operations a renderer needs, a Mesh of vertices and triangles that index them, and a Scene that pairs meshes and materials through nodes with transforms.

It touches no GPU and writes no files. graphics draws a mesh, glb writes a scene to a .glb that Blender or three.js open, and both read the types defined here — which is why they live in a package of their own.

use mesh3d::*;
fn main() {
  floats = mesh_to_floats(cube());
  println("cube    : {len(floats)} floats for GL, {len(cube().triangles)} triangles");
  assert(len(floats) == 216, "12 triangles x 3 vertices x 6 floats (position, normal)");

Every section below is a function, and main calls each one: a section nothing calls still compiles, but its assertions never run.

  composition_order();
  a_mesh_is_built_by_index();
  a_scene_shares_by_index();
  the_camera_field_is_in_degrees();
}

That prints:

  cube    : 216 floats for GL, 12 triangles

mesh_to_floats expands every triangle into its three vertices, position then normal, so the buffer goes to a GL upload as it is. mesh_to_floats_uv adds the two texture coordinates, eight floats per vertex.

The one that bites: mat4_mul(a, b) applies b first

Matrices are column-major, as OpenGL and glTF store them, and a product applies its RIGHT operand first. So mat4_mul(mat4_translate(…), mat4_scale(…)) SCALES and then translates — the usual order for placing an object — and swapping the operands moves the object by a scaled distance instead.

fn composition_order() {
  scale_then_move = mat4_mul(mat4_translate(0.0, 1.0, 0.0), mat4_scale(2.0, 2.0, 2.0));
  move_then_scale = mat4_mul(mat4_scale(2.0, 2.0, 2.0), mat4_translate(0.0, 1.0, 0.0));
  p = mat4_transform(scale_then_move, vec3(1.0, 0.0, 0.0));
  q = mat4_transform(move_then_scale, vec3(1.0, 0.0, 0.0));
  assert(p.x == 2.0 && p.y == 1.0, "scaled to 2, then lifted by 1");
  assert(q.x == 2.0 && q.y == 2.0, "lifted by 1, then the lift scaled too");
  println("order   : mat4_mul(a, b) applies b first");
}

A mesh is built by index

add_vertex answers the index of the vertex it added, and add_triangle takes three of those indices. A vertex two faces share is added once and named twice — and the indices are not checked, so an index past the last vertex is a mesh some consumer will misread.

fn a_mesh_is_built_by_index() {
  m = mesh("quad");
  up = vec3(0.0, 1.0, 0.0);
  a = m.add_vertex(vertex(vec3(0.0, 0.0, 0.0), up, vec2(0.0, 0.0)));
  b = m.add_vertex(vertex(vec3(1.0, 0.0, 0.0), up, vec2(1.0, 0.0)));
  c = m.add_vertex(vertex(vec3(1.0, 0.0, 1.0), up, vec2(1.0, 1.0)));
  d = m.add_vertex(vertex(vec3(0.0, 0.0, 1.0), up, vec2(0.0, 1.0)));
  m.add_triangle(a, b, c);
  m.add_triangle(a, c, d);
  assert(len(m.vertices) == 4 && len(m.triangles) == 2, "four vertices, two triangles sharing two");
  assert(len(mesh_to_floats_uv(m)) == 48, "but the GL buffer expands them: 6 vertices x 8 floats");
  println("mesh    : 4 vertices, 2 triangles, 48 floats with UVs");
}

A scene shares by index

A scene holds three index spaces — meshes, materials, nodes — and every add_* answers the index the rest of the scene names that thing by. A node pairs one mesh and one material with a transform, so two crates are two nodes naming the same mesh, never two copies of it.

fn a_scene_shares_by_index() {
  sc = scene("hall");
  box = sc.add_mesh(cube());
  brass = sc.add_material(material_color("brass", 0.8, 0.6, 0.2));
  sc.add_node(node_at("crate a", box, brass, mat4_translate(-1.0, 0.0, 0.0)));
  sc.add_node(node_at("crate b", box, brass, mat4_translate(1.0, 0.0, 0.0)));
  assert(len(sc.meshes) == 1 && len(sc.nodes) == 2, "one mesh, placed twice");
  println("scene   : one mesh, two nodes naming it");
}

The camera's field is in degrees

camera stores its vertical field of view in DEGREES (60 by default), while mat4_perspective takes RADIANS — and nothing between them converts. A camera is data here: the code that builds the projection owns the conversion.

fn the_camera_field_is_in_degrees() {
  cam = camera("main");
  proj = mat4_perspective(cam.fov * PI / 180.0, 16.0 / 9.0, cam.near, cam.far);
  assert(cam.fov == 60.0, "degrees");
  assert(proj.m[0] > 0.9 && proj.m[0] < 1.0, "a 60 degree field over 16:9 gives about 0.97");
  println("camera  : fov in degrees, perspective in radians");
}

Where to go next