Model formats: OBJ, STL, PLY, glTF#

No mesh-format module ships with pperl, and there is no maintained CPAN one to fall back on. That is less of a problem than it sounds: OBJ and STL are small enough to parse directly, and this chapter gives working readers and writers for both.

What the four differ on#

Geometry

Materials

Scene tree

Animation

Encoding

Indexed

OBJ

yes

via .mtl

no

no

text

yes

STL

yes

no

no

no

text + binary

no

PLY

yes

vertex colour

no

no

text + binary

yes

glTF

yes

PBR

yes

yes

JSON + binary

yes

OBJ for interchange of static geometry, STL for 3D printing, PLY for scanned point and vertex data, glTF for anything with a scene, materials or animation.

OBJ#

Line-oriented text. v a position, vt a texture coordinate, vn a normal, f a face referring to them.

sub write_obj {
    my ($path, $v, $i) = @_;
    open my $fh, '>', $path or die "$path: $!";
    print $fh "# cube, side 2\n";
    printf $fh "v %g %g %g\n", @$_ for @$v;
    for (my $k = 0; $k < @$i; $k += 3) {
        # OBJ indices are 1-BASED
        printf $fh "f %d %d %d\n", map { $_ + 1 } @{$i}[$k, $k+1, $k+2];
    }
    close $fh;
}

The cube becomes 21 lines: a comment, 8 positions, 12 faces.

# cube, side 2
v -1 -1 -1
v 1 -1 -1
v 1 1 -1
...
f 1 6 5

Reading is where the traps are, and there are four:

sub read_obj {
    my ($path) = @_;
    open my $fh, '<', $path or die "$path: $!";
    my (@v, @f);
    while (my $line = <$fh>) {
        next if $line =~ /^\s*(#|$)/;
        my @tok = split ' ', $line;
        my $kw = shift @tok;
        if ($kw eq 'v') { push @v, [ @tok[0..2] ] }
        elsif ($kw eq 'f') {
            # A face may have MORE than three vertices. Fan-triangulate.
            # Each token may be v, v/vt, v//vn or v/vt/vn: take the first.
            my @vi = map { my ($n) = split m{/}, $_; $n } @tok;
            # Indices are 1-based; a NEGATIVE index counts back from the
            # current end of the vertex list.
            @vi = map { $_ < 0 ? @v + $_ : $_ - 1 } @vi;
            push @f, [ $vi[0], $vi[$_], $vi[$_ + 1] ] for 1 .. $#vi - 1;
        }
    }
    close $fh;
    return (\@v, \@f);
}
  1. Indices are 1-based. Off-by-one here is the most common OBJ bug.

  2. A negative index is relative, counting back from the vertices read so far, which is how generators emit streaming output.

  3. A face can have any number of vertices. Quads are common and n-gons occur; both need triangulating. Fan triangulation is correct for convex faces only, which in practice OBJ faces are.

  4. A face token has up to three slots, v/vt/vn, and vt may be empty as v//vn. Splitting on / and taking the first field is enough when only positions are wanted.

Round-tripping the cube reads back 8 vertices and 12 triangles, and a single 4-vertex face fans correctly:

read 8 vertices, 12 triangles
one 4-vertex face -> 2 triangles: [0 1 2] [0 2 3]

Symptom: a loaded model is missing every second triangle, or shows stray triangles connecting distant parts. Cause: n-gon faces triangulated as if they were triangles, or the 1-based offset applied twice.

Materials live in a sibling .mtl file named by a mtllib line, with usemtl switching material between faces. Colours, texture map filenames and shininess go there; geometry never does.

STL#

The 3D-printing format. Binary STL is a fixed-size record layout, which makes it the clearest pack exercise in this guide:

sub write_stl {
    my ($path, $v, $i) = @_;
    open my $fh, '>', $path or die "$path: $!";
    binmode $fh;                               # REQUIRED: binary format
    print $fh "\0" x 80;                       # 80-byte header, ignored
    print $fh pack "V", @$i / 3;               # uint32 triangle count
    for (my $k = 0; $k < @$i; $k += 3) {
        my ($a, $b, $c) = @{$v}[ @{$i}[$k, $k+1, $k+2] ];
        my $n = v_norm(v_cross(v_sub($b,$a), v_sub($c,$a)));
        # 12 little-endian floats then a uint16: 50 bytes per triangle.
        print $fh pack "f<12 v", @$n, @$a, @$b, @$c, 0;
    }
    close $fh;
}

The size is completely determined: 80 header bytes, 4 count bytes, then 50 per triangle.

file size: 684 bytes (84 + 50 * 12 = 684)

Reading is the same layout in reverse, and the result shows STL’s defining property:

read 12 triangles
vertex records: 36, distinct positions: 8

STL has no index buffer. Every triangle carries its three positions in full, so a cube’s 8 corners arrive as 36 vertex records. Weld them (the meshes chapter has the code) before doing anything that needs adjacency, or the manifold check will report every edge as used once.

Four more facts worth having:

  • The format is little-endian regardless of host.

  • The per-triangle normal is frequently zero or wrong. Recompute it from the winding rather than trusting it.

  • The trailing uint16 is nominally an attribute byte count and is normally 0. Some tools abuse it to store colour; there is no standard.

  • An ASCII variant exists (solid/facet normal/outer loop), equivalent in content and larger on disk. How much larger depends entirely on coordinate precision: this cube, whose coordinates are single digits, comes out 1471 bytes against 684, a factor of 2.2, while a scanned model with six significant figures per axis runs far higher. Detect the variant by checking whether the file begins with solid and its size does not match 84 + 50 * n; testing the first five bytes alone is not enough, because a binary file’s 80-byte header can begin with them too.

PLY#

Header-driven: the header declares the elements and their properties, and the body follows that declaration.

ply
format ascii 1.0
element vertex 8
property float x
property float y
property float z
element face 12
property list uchar int vertex_indices
end_header

The parser reads the header, builds a record description from it, then reads that many records. That indirection is the point: PLY carries arbitrary per-vertex properties, so scanner output with confidence values, colours and normals per point needs no format extension. Both ASCII and binary bodies are declared by the format line.

glTF#

The modern interchange format, and the one to target for anything with structure. A .gltf is JSON describing nodes, meshes, materials, cameras and animations, referring to binary buffers for the bulk data; a .glb packs the same JSON and buffers into one file.

Reading it fully is a project rather than a page, but the shape is worth knowing because it explains the design: accessors describe typed views into bufferViews, which are ranges of buffers. Vertex positions, indices and animation keyframes are all accessors over the same byte range mechanism, so a loader implements the accessor logic once.

glTF is where PBR material parameters, a scene hierarchy and skinned animation actually live. OBJ and STL carry none of those, and no amount of convention makes them.

Choosing#

  • Writing geometry for another tool to read: OBJ, unless it needs materials or a hierarchy.

  • Sending a solid to a printer or a slicer: STL, welded on the way back in.

  • Point clouds or per-vertex measured data: PLY.

  • A scene, materials, or animation: glTF, and nothing else.

Next#

Geometry can now be stored and loaded. The next chapter constructs it: extrude, revolve, subdivision and boolean operations.