gltf

tape
import gltf from "gltf";
import math3d from "math3d";

let file: string = fs.read_file("helmet.glb") or { return 1; };
var doc: gltf.Document = gltf.document();
defer doc.free();
doc.parse(file) or {
    io.println("cannot read it: {err}: {doc.last_error()}");
    return 1;
};
// a .gltf names its buffers by URI: load them and hand them over
var bins: [4]string;
var k: i64 = 0;
for (e in doc.external_buffers()) {
    let name: string = encoding.percent_decode(e.uri) or { return 1; };
    bins[k] = fs.read_file("models/{name}") or { return 1; };
    doc.attach(e.index, bins[k]) or { return 1; };
    k = k + 1;
}
// a primitive's vertices and indices, into your arrays
let prim: *const gltf.Primitive = &doc.meshes[0].primitives[0];
let pos: i64 = prim.attribute("POSITION") or { return 1; };
var positions: [65536]f32;
let n: i64 = doc.read_f32(pos, positions[0..]) or { return 1; };     // x, y, z per vertex
var indices: [65536]u32;
let ni: i64 = doc.read_indices(prim, indices[0..]) or { return 1; };

gltf reads glTF 2.0, the standard format for 3D models and scenes, from .gltf (JSON) and .glb (binary) files. It never reads a file itself: you hand it the bytes, and for a .gltf whose buffers live in other files, those files’ bytes too. Every index and byte range in the file is checked as it is parsed, so a damaged or hostile file is refused with an error naming what is wrong instead of crashing a later read. Vectors, rotations and matrices are math3d’s types; vertex data comes out as plain f32 and u32 arrays, ready for a GPU buffer.

Reading a file

FunctionDoes
document() -> DocumentAn empty document; free it
d.parse(data)Read a .gltf or .glb file; data must outlive the document
d.last_error()What the last failed parse, attach or clip found wrong
d.external_buffers()The buffers named by URI, not attached yet: ExternalBuffer { index; uri; }
d.attach(i, bytes)Hand over buffer i’s bytes (they must outlive the document)
d.complete()Every buffer is there
d.json_text()The JSON, for extras and extensions this module does not read
d.free()Give everything back

A GLB’s binary chunk and buffers in data: URIs are ready as soon as parse returns. A URI is relative to the .gltf file and percent-encoded (my%20mesh.bin): decode it with encoding.percent_decode. Reading an accessor whose buffer is not attached yet gives Missing.

tape
for (e in doc.external_buffers()) {
    let path: string = encoding.percent_decode(e.uri) or { return 1; };
    io.println("needs buffer {e.index}: {path}");
}
if (!doc.complete()) { return 1; }
let meta: json.Value = json.parse(doc.json_text()) or { return 1; };   // extras

The document

Document has one array per glTF array: scenes, nodes, meshes, accessors, buffer_views, buffers, materials, textures, samplers, images, skins, animations, cameras, lights; also asset (version, min_version, generator, copyright), scene (the default scene, or null), extensions_used and extensions_required. A field the file leaves out holds the specification’s default, and an index that may be absent is a ?i64.

tape
let root: i64 = doc.scenes[doc.scene or { 0 }].nodes[0];
let node: gltf.Node = doc.nodes[root];
io.println("{node.name}: mesh {node.mesh != null}, {node.children.len} children");
TypeFields
Scenename, nodes (its roots)
Nodename, parent, children, mesh, skin, camera, light, has_matrix, matrix: math3d.Mat4, trs: math3d.Trs, weights, instancing
Meshname, primitives, weights (default morph weights)
Primitiveattributes, indices, material, mode: Mode, targets (morph targets, each with its attributes)
Attributename (POSITION, NORMAL, TANGENT, TEXCOORD_0, COLOR_0, JOINTS_0, WEIGHTS_0…), accessor
Accessorname, buffer_view, byte_offset, component_type: ComponentType, normalized, count, kind: AccessorType, min, max, sparse
BufferViewname, buffer, byte_offset, byte_length, byte_stride (0: packed), target
Buffername, uri, byte_length, data, attached
Skinname, inverse_bind_matrices, skeleton, joints
Cameraname, kind: CameraKind, aspect_ratio, yfov, znear, zfar (null: infinite), xmag, ymag
Lightname, kind: LightKind (Directional, Point, Spot), rgb, intensity, range, inner_cone_angle, outer_cone_angle

Mode is Points, Lines, LineLoop, LineStrip, Triangles, TriangleStrip or TriangleFan. ComponentType is I8, U8, I16, U16, U32 or F32 (size() gives its bytes). AccessorType is Scalar, Vec2..Vec4 or Mat2..Mat4 (components() gives 1 to 16). Node.matrix is the node’s local transform whichever way the file gives it (n.local() too), and Node.trs is its translation, rotation and scale (the matrix decomposed when has_matrix).

Vertex data

FunctionDoes
p.attribute(name) -> ?i64The accessor of a primitive’s attribute
d.vertex_count(p)The primitive’s vertices
a.values()An accessor’s count times its components: the room a read needs
d.read_f32(acc, out) -> i64An accessor as f32 into out; how many values
d.read_u32(acc, out) -> i64An accessor of unsigned integers (joints, indices) as u32
d.read_indices(p, out) -> i64A primitive’s indices, or 0..n for one without; each checked against the vertex count
d.read_vec2(acc, out), read_vec3, read_vec4, read_quat, read_mat4A VEC2, VEC3, VEC4 or MAT4 accessor into a []math3d.Vec2 (Vec3, Vec4, Quat, Mat4); how many elements
a.bounds() -> ?geom.Aabb3A VEC3 accessor’s min and max as a box (POSITION always has them); null without them
d.view_bytes(v)A buffer view’s bytes

The reads take care of the buffer view’s stride, sparse substitutions and every component type. A normalized integer becomes 0..1 or -1..1 as glTF says. A plain integer keeps its value: quantized positions (KHR_mesh_quantization) come out as written, for the node’s scale to place. Matrices come out column-major, without their padding.

tape
let uv: i64 = prim.attribute("TEXCOORD_0") or { return 1; };
let room: i64 = doc.accessors[uv].values();          // count * 2
let p: *u8 = mem.alloc_or_trap(room * 4);
defer mem.free(p, room * 4);
doc.read_f32(uv, (p as *f32)[0..room]) or { return 1; };

The typed reads give the same values as read_f32 (normalization and sparse included), one math3d value an element, for code that works on the CPU: collision meshes, bounds, instance transforms. out needs room for count elements. An accessor of another type is Mismatch. A math3d.Vec3 takes 16 bytes, so read_vec3 is not a GPU layout: upload read_f32’s packed floats instead.

tape
var normals: [4096]math3d.Vec3;
let nv: i64 = doc.read_vec3(prim.attribute("NORMAL") or { return 1; }, normals[0..]) or { return 1; };
let box: geom.Aabb3 = doc.accessors[pos].bounds() or { return 1; };

Materials and textures

A Material is glTF’s metallic-roughness PBR:

  • base_color_factor: Vec4 and base_color_texture;
  • metallic_factor, roughness_factor and metallic_roughness_texture;
  • normal_texture (its scale is the normal scale), occlusion_texture (its scale is the strength), emissive_texture and emissive_factor: Vec3;
  • alpha_mode: AlphaMode (Opaque, Mask, Blend), alpha_cutoff and double_sided.

The KHR_materials extensions add more fields, each with the extension’s default when absent:

ExtensionFields
emissive_strengthemissive_strength
unlitunlit
iorior
transmissiontransmission_factor, transmission_texture
volumethickness_factor, thickness_texture, attenuation_distance, attenuation_color
specularspecular_factor, specular_texture, specular_color_factor, specular_color_texture
clearcoatclearcoat_factor, clearcoat_texture, clearcoat_roughness_factor, clearcoat_roughness_texture, clearcoat_normal_texture
sheensheen_color_factor, sheen_color_texture, sheen_roughness_factor, sheen_roughness_texture

A TextureInfo holds index (into textures), tex_coord (which TEXCOORD_n), scale and transform (KHR_texture_transform: offset, rotation, scale, tex_coord). t.matrix() gives that transform as a math3d.Mat3 for the UV: uv' = m * (u, v, 1).

A Texture names a sampler and an image source. The extensions add other images: source_ktx2 (KHR_texture_basisu), source_webp (EXT_texture_webp) and source_dds (MSFT_texture_dds). A Sampler has mag_filter and min_filter (?Filter: null leaves the choice to the renderer) and wrap_s and wrap_t (Wrap). An Image has a uri, a mime_type and a buffer_view.

FunctionDoes
d.image_bytes(i)An embedded image’s bytes (data: URI or buffer view); Missing for one in its own file (uri: load it yourself)
tape
let m: gltf.Material = doc.materials[prim.material or { 0 }];
let ti: gltf.TextureInfo = m.base_color_texture or { return 0; };
let src: i64 = doc.textures[ti.index].source or { return 0; };
let png: []const u8 = doc.image_bytes(src) or { return 1; };
var s: gfx.Surface = image.decode(png) or { return 1; };
defer s.free();

KTX2 and DDS images go to texture, PNG, JPEG and WebP to image.

Transforms and skins

FunctionDoes
d.rest_pose(pose)Every node’s TRS as the file gives it, into a []math3d.Trs
d.world_matrices(pose, out)Every node’s world matrix (math3d.Mat4) for a pose
d.joint_matrices(skin, world, out) -> i64A skin’s joint matrices: each joint’s world matrix times its inverse bind matrix
c.projection(aspect)A camera’s projection in math3d’s clip space (y up, depth 0..1)

pose and out hold one entry per node; the functions trap when they are shorter.

tape
var pose: [64]math3d.Trs;
var world: [64]math3d.Mat4;
doc.rest_pose(pose[0..]);
doc.world_matrices(pose[0..], world[0..]);
var joints: [64]math3d.Mat4;
let nj: i64 = doc.joint_matrices(0, world[0..], joints[0..]) or { return 1; };
let proj: math3d.Mat4 = doc.cameras[0].projection(16.0 / 9.0);

Animation

An Animation has channels (sampler, node, path: TargetPath, one of Translation, Rotation, Scale or Weights) and samplers (input, output, interpolation: Interpolation, one of Linear, Step or CubicSpline). To play one, read its keys into a Clip once:

FunctionDoes
d.clip(anim) -> ClipThe animation’s keys read and checked; free it
c.durationThe last key’s time, in seconds
c.apply(t, pose)Every translation, rotation and scale channel at time t (seconds) into pose
c.sample_vec3(ch, t)A translation or scale channel’s value
c.sample_quat(ch, t)A rotation channel’s value
c.sample_weights(ch, t, out) -> i64A weights channel’s morph weights
c.free()Give the keys back

Times are in seconds, glTF’s unit: t here, duration and the file’s key times alike. curve’s tracks count in milliseconds instead, so convert when you mix the two (ms / 1000.0). Before the first key a channel holds the first value, and after the last key the last value. To loop, pass t % c.duration. Rotations are slerped, and cubic splines use the tangents the file gives.

tape
var walk: gltf.Clip = doc.clip(0) or { return 1; };
defer walk.free();
doc.rest_pose(pose[0..]);
walk.apply(math3d.fmod(seconds, walk.duration), pose[0..]);
doc.world_matrices(pose[0..], world[0..]);

Extensions

These extensions are read, and a file may require them: KHR_materials_emissive_strength, _unlit, _ior, _transmission, _volume, _specular, _clearcoat, _sheen; KHR_texture_transform, KHR_texture_basisu, EXT_texture_webp, MSFT_texture_dds; KHR_lights_punctual; KHR_mesh_quantization; EXT_mesh_gpu_instancing (Node.instancing: the per-instance TRANSLATION, ROTATION and SCALE accessors). The list is SUPPORTED_EXTENSIONS. A file that requires any other extension is refused as Unsupported, with last_error() naming it. Two such extensions are KHR_draco_mesh_compression and EXT_meshopt_compression: decompress those files first (gltfpack -noq, gltf-transform). An extension that is only used is ignored, and its JSON is still there in json_text().

Errors

GltfError is one of:

  • NotGltf: neither JSON nor GLB.
  • Unsupported: glTF 1.0, a minVersion above 2.0, a required extension that is not read, or a data: URI that is not base64.
  • Corrupt: a malformed file, with last_error() saying where (for example accessors[3]: 4 elements need 52 bytes of bufferView 1, which has 48).
  • Missing: a buffer is not attached yet.
  • Mismatch: read_u32 of a float accessor.
  • OutTooSmall: out has less room than values().
  • OutOfMemory.

These are mistakes in the calling code, and they trap: an index that isn’t an accessor, image, skin or animation; attach of a buffer that does not exist; sampling a channel with the wrong function.

Last modified: