A glTF triangle

The present essay describes how to create a triangle model in glTF format. We will focus on the production of a buffer binary file with custom geometry and the description of buffer data by means of bufferViews and accessors.

Riccardo Scalco - rscalco@nextbit.it

Firstly, some references

The following text is based on the glTF tutorial written by The Khronos Group. I strongly recommend to read the tutorial, it covers many of the concepts introduced here.

This essay has to be intended as a complement to the first five chapters of the tutorial. Some arguments discussed in the tutorial are missing here, like scenes and nodes, whereas other arguments are discussed with more details. In particular, we are going to see how to get a buffer binary file storing some custom geometry data, an argument that may be difficult to grasp at first glance.

Please have a look at the Github repository for the code related to the present example, as well as for other interesting experiments with the glTF format.

Define vertices indexes and positions

We start by defining the triangle we want to draw. Let say we want the triangle with the first vertex at position (1, 0, 0), the second vertex at position (0, 1, 0) and the third vertex at position (0, 0, 0). Take a paper and a pencil and draw the triangle in a right-handed cartesian coordinate system, you will see that the face direction is counter-clockwise. The face direction is defined by the order of the three vertices that make up the triangle, as well as their apparent order on-screen, and its primary use is to allow the culling (removal) of not visible triangles on closed surfaces.

We can store the described triangle in a js object like the following:

const data = {
  indexes: [
    0,
    1,
    2
  ],
  vertices: [
    1, 0, 0,
    0, 1, 0,
    0, 0, 0
  ]
}

In order to draw the triangle from such data structure, we can interpret the data as follow: the first vertex has index indexes[0], equals to 0, and therefore its position is stored in the first 3 values of the vertices array. Similarly, the second vertex has index indexes[1], equals to 1, and therefore its position is stored in the second 3 values of the vertices array. The same holds for the third vertex.

Note that there are many possible ways to encode the wanted triangle in a data structure, the above definition is convenient because, as we will see, it resembles how data is going to be stored in buffers. The reason behind this data structure will be better understood once accessors and rendering modes will be introduced.

In the next section, we are going to store the data in a binary file.

Store data in an external binary file

Binary data is referred to by a buffer, a buffer URI may point to an external binary file or it may be a data URI that encodes the binary data directly in the glTF file.

There is not a unique way to store the data in a buffer, here we are going to use the same convention described in the tutorial. We first store indexes data as UNSIGNED_SHORT components and, with the appropriate byte offset, we later add vertices data to the buffer as FLOAT components. The code snippet below defines a function that takes data as input and writes a binary file called buffer.bin in the file system.

const fs = require('fs');

module.exports = function (data) {
  const {
    indexes,
    vertices
  } = data;

  const UNSIGNED_SHORT_BYTES = 2;
  const FLOAT_BYTES = 4;
  const verticesBytes = vertices.length * FLOAT_BYTES;
  const indexesBytes = indexes.length * UNSIGNED_SHORT_BYTES;
  const remainder = indexesBytes % FLOAT_BYTES
  const paddingBytes = remainder ? FLOAT_BYTES - remainder : 0;
  const byteLength = indexesBytes + paddingBytes + verticesBytes;
  const buf = new ArrayBuffer(byteLength);
  const dat = new DataView(buf, 0, byteLength);

  for (let i = 0; i < indexesBytes; i+= UNSIGNED_SHORT_BYTES) {
    dat.setUint16(i, indexes[i / UNSIGNED_SHORT_BYTES], true);
  }

  for (let j = indexesBytes + paddingBytes; j < byteLength; j += FLOAT_BYTES) {
    dat.setFloat32(j, vertices[(j - indexesBytes - paddingBytes) / FLOAT_BYTES], true);
  }

  // const dataURI = `data:application/gltf-buffer;base64,${Buffer.from(buf).toString('base64')}`;
  // console.log(dataURI);

  fs.writeFile('buffer.bin', Buffer.from(buf), 'binary', function(err) {
    if(err) {
      console.log(err);
    }
  });
}

File write-buffer-file.js. The exported function stores data in a buffer as described here. Note that we are going to store 3 UNSIGNED_SHORT, for a byte length of 3 * 2 = 6 bytes, and 3 * 3 FLOAT, for a byte length of 3 * 3 * 4 = 36 bytes. Adding 2 bytes (paddingBytes in the code) for the data alignment, we get a total of 44 bytes.

The commented lines would print the data URI.

The above function is meant to be used in node.js. Create the script below and run it in order to get the binary file.

#!/usr/bin/env node

const writeBufferFile = require('./write-buffer-file');
const data = {
  indexes: [
    0,
    1,
    2
  ],
  vertices: [
    1, 0, 0,
    0, 1, 0,
    0, 0, 0
  ]
}
writeBufferFile(data);

File write-buffer.js. Call this script in a terminal with node write-buffer.js.

In order to use the data encoded in the binary file, we need to appropriately describe, in the glTF file, how data is stored. In the next section, we are going to do that by means of bufferViews and accessors.

Describe data with bufferViews and accessors

In the previous section, we created a binary file containing two pieces of information: the vertices indexes and the vertices positions. With bufferViews we describe these two views of data in the buffer.

"bufferViews": [
  {
    "buffer": 0,
    "byteOffset": 0,
    "byteLength": 6,
    "target": 34963
  },
  {
    "buffer": 0,
    "byteOffset": 8,
    "byteLength": 36,
    "target": 34962
  }
],

The first view is made of 6 bytes, whereas the second view is made of 36 bytes, with a byte offset of 6 + 2 bytes (2 bytes for the alignment). Read more here.

Accessors add additional information about the type and layout of the data described in the bufferViews. In the first bufferView we have 3 elements of type SCALAR with components of type UNSIGNED_SHORT. In the second buffer we have 3 elements of type VEC3 with components of type FLOAT.

"accessors": [
  {
    "bufferView": 0,
    "byteOffset": 0,
    "componentType": 5123,
    "count": 3,
    "type": "SCALAR",
    "max": [2],
    "min": [0]
  },
  {
    "bufferView": 1,
    "byteOffset": 0,
    "componentType": 5126,
    "count": 3,
    "type": "VEC3",
    "max": [1.0, 1.0, 0.0],
    "min": [0.0, 0.0, 0.0]
  }
],

The first accessor points to the bufferView at position 0 in the bufferViews array, the second accessor instead points to the bufferView at position 1. In general, a bufferView can be referenced by multiple accessors and, when multiple accessors refer to the same bufferView, the byteOffset property describes where the data of the accessor starts.

Property componentType describes the type of the element's components (see mapping table), each element has a fixed number of components depending on its type. Element and component types are used to compute the size of element accessible by accessor.

BufferView and accessors completely describe the data stored in the buffer, now it is time to use this data.

Define the meshes

Having the data and its description, we are ready to define meshes that use the data. A mesh describes a geometric object that appears in the scene, and it is made of smaller building blocks called mesh primitive objects.

A mesh primitive may describe individual points, lines, or triangles according to the selected rendering mode. Different rendering modes will use data in different ways, as described in the OpenGL primitive documentation.

The code below defines a single mesh, made of a single primitive, with vertices indices referenced in the first accessor ("indices": 0) and vertices positions in the second accessor ("POSITION": 1).

"meshes": [
  {
    "primitives": [
      {
        "attributes": {
          "POSITION": 1
        },
        "indices": 0,
        "mode" : 4
      }
    ]
  }
],

The default mode is 4, which defines a triangle mesh where three consecutive indices describe a single triangle. In our case, we only have 3 indexes, which means we only have one triangle.

The final model

In the previous sections we described some of the parts componing the final glTF file. We discussed about bufferViews and accessors for the interpretation of the buffer data, but we missed out the parts related to scenes and nodes, well covedered in the tutorial.

You can see the full glTF file visualized below in the repository.

Note that only one face of the model is visible, by default the one with counter-clockwise direction, as result of the face culling.

Adding a bit of complexity to our example, we can easily obtain a double-faces triangle using the TRIANGLE_STRIP rendering mode. To do that, we assign "mode": 5 to our primitive and create the binary file for a data structure with "indexes": [0, 1, 2, 0]. Having now different data, we need to update the first buffer view with "byteLength": 8 and the first accessor with "count": 4.

A double-faces triangle in TRIANGLE_STRIP mode, where every group of 3 adjacent vertices forms a triangle.

The first triangle, defined by indices (0, 1, 2), has a counter-clockwise face direction. The second triangle, of indices (1, 2, 0), also has a counter-clockwise face direction but, being in TRIANGLE_STRIP mode, the face direction is reversed (read the documentation for details).

Summary

The purpose of glTF is to define a format for the efficient transfer of 3D content over networks. The previous sections are based on the first five chapters of the glTF tutorial. We discussed about the production and interpretation of a buffer binary file storing a custom geometry and we touched arguments like mesh primitives and rendering modes, key concepts for the creation of any 3D model.

Open positions in Nextbit

At Nextbit we are always looking for open minds. If you feel this is the right place for you, please contact us at contact@nextbit.it.

  • Cloud Solutions Architect
    • GCP
    • AWS
    • Java
    • Python
    • Docker
  • Full Stack Engineer
    • JS
    • Vue
    • React
    • Node
    • Java
    • Python
    • GraphQL

Get in touch

Follow us

At Nextbit we provide consulting services for customers’ proprietary projects as well as cutting edge industry specific solutions, visit our website to know more.