Tutorials
October 31, 2026
c++iostlobjtutorial

Fast STL and OBJ Reading in C++

Read and write STL and OBJ mesh files in C++ into indexed float/int buffers. A 1M-triangle OBJ in 6.5 ms and a welded binary STL in 42.5 ms, measured against RapidObj, tinyobjloader, stl_reader, VTK, libigl and CGAL.

Žiga Sajovic, Polydera

Also available in Python and JavaScript.

Most mesh work starts with a file. trueform reads STL (binary and ASCII) and OBJ into indexed buffers: one flat array of points, one of face indices. STL corners are welded on load, so the result is a connected mesh. It is header-only C++17. This page covers reading STL and OBJ, quads and mixed polygons, normals, texture coordinates and groups, writing files, and handing the data to your own code.

trueform GitHub Documentation

Install

Header-only. It needs C++17 and oneTBB. Add it to your project via CMake FetchContent:

FetchContent_Declare(
  trueform
  GIT_REPOSITORY https://github.com/polydera/trueform
)
FetchContent_MakeAvailable(trueform)
target_link_libraries(my_app PRIVATE tf::trueform)

Also available via conan, nuget, and pip. See the installation guide for all options.

Reading STL

#include <trueform/trueform.hpp>

auto polygons = tf::read_stl("model.stl");
// tf::polygons_buffer<int, float, 3, 3>

auto faces = polygons.faces();    // (N, 3) vertex ids
auto points = polygons.points();  // (M, 3) positions
auto mesh = polygons.polygons();  // the mesh view the rest of the library takes

tf::read_stl detects binary or ASCII. An STL file stores three corners per triangle with no sharing. The reader merges corners at the same position, so points holds each vertex once and faces indexes into it. The mesh is ready for topology queries such as connected components.

For meshes with more than 2 billion vertices, ask for 64-bit indices:

auto large = tf::read_stl<int64_t>("model.stl");

Reading OBJ

OBJ files may mix triangles, quads and larger polygons. By default tf::read_obj returns faces of any length:

auto mesh = tf::read_obj("model.obj");
// tf::polygons_buffer<int, float, 3, tf::dynamic_size>
auto first = mesh.faces()[0];     // vertex ids of face 0, any length

If you know the face size, state it. The faces then come back as a fixed-width block:

auto triangles = tf::read_obj<3>("model.obj");
// tf::polygons_buffer<int, float, 3, 3>
auto large = tf::read_obj<int64_t, 3>("model.obj");

The reader maps the file instead of copying it, so do not modify the file while it is read. OBJ indices start at 1; the buffers start at 0. The default mode reads positions only.

Quads and mixed polygons

A small quad file shows the two forms:

std::ofstream("quads.obj") << "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nv 2 0 0\nv 2 1 0\n"
                              "f 1 2 3 4\nf 2 5 6 3\n";

auto quads = tf::read_obj<4>("quads.obj");
auto [a, b, c, d] = quads.faces()[1];  // 1, 4, 5, 2

To get triangles from polygons, use tf::triangulated:

auto poly = tf::read_obj("quads.obj");
auto tris = tf::triangulated(poly.polygons());
// tris.faces().size() == 4

Normals, texture coordinates and groups

Pass tf::complete to read everything the file carries. Each unique position, texture and normal triplet becomes one vertex, so all per-vertex buffers line up:

auto f = tf::read_obj("textured.obj", tf::complete);

auto points = f.polygons.points();
auto normals = f.normals;              // aligned with points, or empty
auto textures = f.textures;            // aligned with points, or empty
auto &face_groups = f.face_groups;     // per face, index into group_names
auto &group_names = f.group_names;

A position with two different normals or texture coordinates becomes two vertices, as at a texture seam or a sharp edge. Negative (relative) indices are not supported in this mode; such a file returns an empty result.

Writing STL and OBJ

The writers take any mesh view. Your own arrays work without a copy:

std::vector<float> flat_points = {0, 0, 0, 1, 0, 0, 0, 1, 0};
std::vector<int> flat_triangles = {0, 1, 2};

auto mesh = tf::make_polygons(tf::make_faces<3>(flat_triangles),
                              tf::make_points<3>(flat_points));
bool ok = tf::write_stl(mesh, "triangle.stl");
tf::write_obj(mesh, "triangle.obj");

tf::write_stl writes binary STL and needs triangles. tf::write_obj keeps quads and mixed polygons as they are. Both return true on success.

To place a mesh as it is written, tag a transformation on the view. The data is not changed:

auto T = tf::make_transformation_from_translation(
    tf::make_vector(0.f, 0.f, 5.f));
tf::write_stl(polygons.polygons() | tf::tag(T), "moved.stl");

Working with results

The buffers own flat memory. Hand it to a renderer, a GPU upload or another library directly:

float *point_data = polygons.points_buffer().data_buffer().data();
int *face_data = polygons.faces_buffer().data_buffer().data();

polygons.polygons() goes straight into the rest of trueform: closest point, decimation, connected components, booleans.

Performance

Read times on a 1,025,766-triangle mesh, in C++. OBJ is a triangulated file read into float32 points and int32 faces. Binary STL is read and welded exactly into shared vertices. Times include opening the file, parsing, and building the output.

6.5msOBJ read1,025,766 triangles
42.5msSTL read and weld1,025,766 triangles
5.4×faster STL than stl_reader229.1 ms

Apple M4 Max 16 threads mimalloc

OBJ reader1,025,766 trianglesvs trueformtrueform6.5 ms1.0×RapidObj13.3 ms2.0×tinyobjloader66.6 ms10×CGAL736.1 ms112×libigl952.3 ms146×
STL reader1,025,766 trianglesvs trueformtrueform42.5 ms1.0×stl_reader229.1 ms5.4×VTK239.2 ms5.6×libigl294.1 ms6.9×CGAL392.8 ms9.2×

Of the readers measured, trueform is the fastest on both formats. Median of three fresh-process medians, seven samples per process. Benchmark run with RapidObj 1.1.0, tinyobjloader 2.0.0-rc.13, VTK 9.5.2, CGAL 6.2 and libigl 2.5.0.

Full benchmarks · Documentation

Cite as
@article{polydera:fast-stl-and-obj-reading-in-cpp,
  title={Fast STL and OBJ Reading in C++},
  author={Sajovic, {\v{Z}}iga, Polydera},
  year={2026},
  url={https://polydera.com/tutorials/fast-stl-and-obj-reading-in-cpp},
  organization={Polydera}
}