gltf
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
| Function | Does |
|---|---|
document() -> Document | An 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.
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; }; // extrasThe 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.
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");| Type | Fields |
|---|---|
Scene | name, nodes (its roots) |
Node | name, parent, children, mesh, skin, camera, light, has_matrix, matrix: math3d.Mat4, trs: math3d.Trs, weights, instancing |
Mesh | name, primitives, weights (default morph weights) |
Primitive | attributes, indices, material, mode: Mode, targets (morph targets, each with its attributes) |
Attribute | name (POSITION, NORMAL, TANGENT, TEXCOORD_0, COLOR_0, JOINTS_0, WEIGHTS_0…), accessor |
Accessor | name, buffer_view, byte_offset, component_type: ComponentType, normalized, count, kind: AccessorType, min, max, sparse |
BufferView | name, buffer, byte_offset, byte_length, byte_stride (0: packed), target |
Buffer | name, uri, byte_length, data, attached |
Skin | name, inverse_bind_matrices, skeleton, joints |
Camera | name, kind: CameraKind, aspect_ratio, yfov, znear, zfar (null: infinite), xmag, ymag |
Light | name, 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
| Function | Does |
|---|---|
p.attribute(name) -> ?i64 | The 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) -> i64 | An accessor as f32 into out; how many values |
d.read_u32(acc, out) -> i64 | An accessor of unsigned integers (joints, indices) as u32 |
d.read_indices(p, out) -> i64 | A 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_mat4 | A VEC2, VEC3, VEC4 or MAT4 accessor into a []math3d.Vec2 (Vec3, Vec4, Quat, Mat4); how many elements |
a.bounds() -> ?geom.Aabb3 | A 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.
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.
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: Vec4andbase_color_texture;metallic_factor,roughness_factorandmetallic_roughness_texture;normal_texture(itsscaleis the normal scale),occlusion_texture(itsscaleis the strength),emissive_textureandemissive_factor: Vec3;alpha_mode: AlphaMode(Opaque,Mask,Blend),alpha_cutoffanddouble_sided.
The KHR_materials extensions add more fields, each with the extension’s default when absent:
| Extension | Fields |
|---|---|
| emissive_strength | emissive_strength |
| unlit | unlit |
| ior | ior |
| transmission | transmission_factor, transmission_texture |
| volume | thickness_factor, thickness_texture, attenuation_distance, attenuation_color |
| specular | specular_factor, specular_texture, specular_color_factor, specular_color_texture |
| clearcoat | clearcoat_factor, clearcoat_texture, clearcoat_roughness_factor, clearcoat_roughness_texture, clearcoat_normal_texture |
| sheen | sheen_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.
| Function | Does |
|---|---|
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) |
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
| Function | Does |
|---|---|
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) -> i64 | A 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.
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:
| Function | Does |
|---|---|
d.clip(anim) -> Clip | The animation’s keys read and checked; free it |
c.duration | The 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) -> i64 | A 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.
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 adata:URI that is not base64.Corrupt: a malformed file, withlast_error()saying where (for exampleaccessors[3]: 4 elements need 52 bytes of bufferView 1, which has 48).Missing: a buffer is not attached yet.Mismatch:read_u32of a float accessor.OutTooSmall:outhas less room thanvalues().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: