gltf

Reader for glTF 2.0.gltf JSON and .glb binary containers. Pure Elixir, one dependency (jason), no NIFs.

glTF is the interchange format essentially every 3D tool can write, which makes it the practical way to get geometry into or out of an Elixir service. This library reads one. It does not render, and it does not fetch anything over the network.

{:ok, gltf} = Gltf.read("model.glb")
Gltf.info(gltf)
#=> %{version: "2.0", generator: "Khronos glTF Blender I/O", meshes: 1, nodes: 2, ...}
{:ok, positions} = Gltf.attribute(gltf, 0, 0, "POSITION")
#=> [[-0.5, -0.5, 0.5], [0.5, -0.5, 0.5], ...]
{:ok, indices} = Gltf.indices(gltf, 0, 0)
#=> [0, 1, 2, 3, 2, 1, ...]

Installation

def deps do
[{:gltf, "~> 0.1"}]
end

Don't box a million floats

For anything vertex-sized, take the bytes:

{:ok, bytes, layout} = Gltf.attribute_binary(gltf, 0, 0, "POSITION")
layout
#=> %{type: "VEC3", component: :float32, element_size: 12, count: 24, ...}

bytes is tightly packed, little-endian, de-interleaved, with any sparse block applied — the form a GPU buffer, a file, or a socket wants. Building three million boxed floats only to hand them straight back out is work nobody asked for.

The parts that are actually hard

A glTF reader is a weekend project right up until it meets a real file. These are the four places quick implementations go wrong, and all four are covered by tests against the Khronos sample assets rather than only against fixtures written here.

Interleaved byteStride. A buffer view may weave several attributes together so the GPU can read a whole vertex in one pass. Reading elements back to back then decodes a mixture of positions and normals — which looks like plausible geometry, not like a crash. Khronos publishes Box and BoxInterleaved: the same cube in both layouts. This library decodes them to identical geometry, and the test asserts exactly that.

Sparse accessors. An accessor may override individual elements by index, possibly with no base buffer view at all — in which case the base is defined to be zeros, not an error and not an empty list. Ignoring a sparse block gives a model that is silently wrong.

Normalised integers.normalized: true means an integer component encodes a fraction: unsigned onto 0.0..1.0, signed onto -1.0..1.0 with the most negative value clamped, so -128 and -127 both give -1.0. Return the raw integers and colours come out in the hundreds.

Non-finite floats. The BEAM has no float term for NaN or infinity, so <<v::little-float-32>> = <<0, 0, 0xC0, 0x7F>> does not raise — it fails to match, and a reader that does not expect that dies on a file every other viewer opens. Exporters emit them; a NaN normal is a routine artefact of a degenerate triangle. They decode to :nan, :infinity and :neg_infinity.

Scene graph

Node transforms compose as T * R * S, in that order, and an explicit matrix wins when a node has one. Get the order wrong and a model looks correct until something is both rotated and non-uniformly scaled, at which point it shears.

{:ok, transforms} = Gltf.world_transforms(gltf)
Gltf.Node.transform_point(transforms[1], {0.0, 1.0, 0.0})
#=> {0.0, 0.0, -1.0}

Matrices are column-major flat lists of 16 numbers — the same order glTF stores them in, so a matrix read from a file needs no rearranging.

The node graph is a forest by specification, but nothing in a file enforces it. A node listing an ancestor as its child makes naive traversal loop until the machine dies, so world_transforms/2 tracks the chain it is descending and reports the cycle instead.

Reading someone else's file

A glTF document is data, usually downloaded, and "uri" is a string its author chose. "../../../etc/passwd" is a valid one.

External buffers are therefore confined to the asset's own directory. Pass allow_outside_base: true to follow a symlinked asset library on purpose, or external: false to refuse sibling files entirely. A URI with a scheme — http:, file: — is refused rather than fetched: a parser that opens sockets is a parser you cannot point at untrusted input. Fetch it yourself and pass :buffers.

Scope

In: the GLB container, the JSON document graph, accessor decoding, buffer resolution, node transforms, and validation with errors that say which index of what went wrong.

Out: rendering, mesh processing, and writing. Draco and meshopt compression are out too — both are substantial codecs, and the honest options are shelling out or not pretending.

Extension objects are preserved in the document but not interpreted. Gltf.info/1 reports extensions_required, which a caller should check before trusting the geometry: a file requiring KHR_draco_mesh_compression parses fine here and its meshes are compressed nonsense.

Prior art

eagl loads glTF, but bundled inside an OpenGL library aimed at native desktop rendering through :wx. This is the standalone parser — no renderer, no windowing, no GPU.

Testing

mix test # hand-built fixtures, always run
test/corpus/fetch.sh # download the Khronos samples
mix test # now also runs corpus_test.exs

The sample assets are fetched rather than committed: they are third-party files under their own licences, and a parser should be checked against the canonical copies rather than a snapshot that quietly drifts. Without them the corpus tests skip and a fresh checkout still runs green.

ply reads the other 3D format worth having — Stanford Polygon, which is what Gaussian-splat trainers emit. splat_tools turns those into web assets.

Licence

MIT.