Wavefront .obj files are one of the most common formats of 3D files you can find online. They are not that hard to parse the most common forms so let's parse one. It will hopefully provide a useful example for parsing 3D formats in general.
Disclaimer: This .OBJ parser is not meant to be exhaustive or flawless or handle every .OBJ file. Rather it's meant as an exercise to walk through handling what we run into on the way. That said, if you run into big issues and solutions a comment at the bottom might be helpful for others if they choose to use this code.
The best documentation I've found for the .OBJ format is here. Though this page links to many other documents including what appears to the original docs.
Let's look a simple example. Here is a cube.obj file exported from blender's default scene.
# Blender v2.80 (sub 75) OBJ File: ''
# www.blender.org
mtllib cube.mtl
o Cube
v 1.000000 1.000000 -1.000000
v 1.000000 -1.000000 -1.000000
v 1.000000 1.000000 1.000000
v 1.000000 -1.000000 1.000000
v -1.000000 1.000000 -1.000000
v -1.000000 -1.000000 -1.000000
v -1.000000 1.000000 1.000000
v -1.000000 -1.000000 1.000000
vt 0.375000 0.000000
vt 0.625000 0.000000
vt 0.625000 0.250000
vt 0.375000 0.250000
vt 0.375000 0.250000
vt 0.625000 0.250000
vt 0.625000 0.500000
vt 0.375000 0.500000
vt 0.625000 0.750000
vt 0.375000 0.750000
vt 0.625000 0.750000
vt 0.625000 1.000000
vt 0.375000 1.000000
vt 0.125000 0.500000
vt 0.375000 0.500000
vt 0.375000 0.750000
vt 0.125000 0.750000
vt 0.625000 0.500000
vt 0.875000 0.500000
vt 0.875000 0.750000
vn 0.0000 1.0000 0.0000
vn 0.0000 0.0000 1.0000
vn -1.0000 0.0000 0.0000
vn 0.0000 -1.0000 0.0000
vn 1.0000 0.0000 0.0000
vn 0.0000 0.0000 -1.0000
usemtl Material
s off
f 1/1/1 5/2/1 7/3/1 3/4/1
f 4/5/2 3/6/2 7/7/2 8/8/2
f 8/8/3 7/7/3 5/9/3 6/10/3
f 6/10/4 2/11/4 4/12/4 8/13/4
f 2/14/5 1/15/5 3/16/5 4/17/5
f 6/18/6 5/19/6 1/20/6 2/11/6
Without even looking at the documentation we can probably figure
out that lines that start with v
are positions, lines that
start withvt
are texture coordinates, and lines that start
with vn
are normals. What's left is to figure out the rest.
It appears .OBJ files are text files so the first thing we need to do is load a text file. Fortunately in 2020 that's super easy if we use async/await.
async function main() {
...
const response = await fetch('resources/models/cube/cube.obj');
const text = await response.text();
Next it looks like we can parse it one line at time and that each line is in the form of
keyword data data data data ...
where the first thing on the line is a keyword and data
is separated by spaces. Lines that start with #
are comments.
So, let's set up some code to parse each line, skip blank lines and comments and then call some function based on the keyword
+function parseOBJ(text) {
+
+ const keywords = {
+ };
+
+ const keywordRE = /(\w*)(?: )*(.*)/;
+ const lines = text.split('\n');
+ for (let lineNo = 0; lineNo < lines.length; ++lineNo) {
+ const line = lines[lineNo].trim();
+ if (line === '' || line.startsWith('#')) {
+ continue;
+ }
+ const m = keywordRE.exec(line);
+ if (!m) {
+ continue;
+ }
+ const [, keyword, unparsedArgs] = m;
+ const parts = line.split(/\s+/).slice(1);
+ const handler = keywords[keyword];
+ if (!handler) {
+ console.warn('unhandled keyword:', keyword, 'at line', lineNo + 1);
+ continue;
+ }
+ handler(parts, unparsedArgs);
+ }
}
Some things to note: We trim each line to remove leading and trailing
spaces. I have no idea if this is needed but I think it can't hurt.
We split the line by white space using /\s+/
. Again I have no idea if
this is needed. Can there be more than one space between data? Can
there be tabs? No idea but it seemed safer to assume there is variation
out there given it's a text format.
Otherwise we pull out the first part as the keyword and then look up a function for that keyword and call it passing it the data after the keyword. So now we just need to fill in those functions.
We guessed the v
, vt
, and vn
data above. The docs say f
stands for "face" or polygon where each piece of data are
indices into the positions, texture coordinates, and normals.
The indices are 1 based if positive or relative to the number of vertices parsed so far if negative. The order of the indices are position/texcoord/normal and that all except the position are optional so
f 1 2 3 # indices for positions only
f 1/1 2/2 3/3 # indices for positions and texcoords
f 1/1/1 2/2/2 3/3/3 # indices for positions, texcoords, and normals
f 1//1 2//2 3//3 # indices for positions and normals
f
can have more than 3 vertices, for example 4 for a quad
We know that WebGL can only draw triangles so we need to convert
the data to triangles. It does not say if a face can have more
than 4 vertices nor does it says if the face must be convex or
if it can be concave. For now lets assume they are concave.
Also, in general, in WebGL we don't use a different index for positions, texcoords, and normals. Instead a "webgl vertex" is the combination of all data for that vertex. So for example to draw a cube WebGL requires 36 vertices, each face is 2 triangles, each triangle is 3 vertices. 6 faces 2 triangles 3 vertices per triangle is 36. Even though there are only 8 unique positions, 6 unique normals, and who knows for texture coordinates. So, we'll need to read the face vertex indices and generate a "webgl vertex" that is the combination of data of all 3 things. *
So given all that it looks like we can parse these parts as follows
function parseOBJ(text) {
+ // because indices are base 1 let's just fill in the 0th data
+ const objPositions = [[0, 0, 0]];
+ const objTexcoords = [[0, 0]];
+ const objNormals = [[0, 0, 0]];
+
+ // same order as `f` indices
+ const objVertexData = [
+ objPositions,
+ objTexcoords,
+ objNormals,
+ ];
+
+ // same order as `f` indices
+ let webglVertexData = [
+ [], // positions
+ [], // texcoords
+ [], // normals
+ ];
+
+ function addVertex(vert) {
+ const ptn = vert.split('/');
+ ptn.forEach((objIndexStr, i) => {
+ if (!objIndexStr) {
+ return;
+ }
+ const objIndex = parseInt(objIndexStr);
+ const index = objIndex + (objIndex >= 0 ? 0 : objVertexData[i].length);
+ webglVertexData[i].push(...objVertexData[i][index]);
+ });
+ }
+
const keywords = {
+ v(parts) {
+ objPositions.push(parts.map(parseFloat));
+ },
+ vn(parts) {
+ objNormals.push(parts.map(parseFloat));
+ },
+ vt(parts) {
+ objTexcoords.push(parts.map(parseFloat));
+ },
+ f(parts) {
+ const numTriangles = parts.length - 2;
+ for (let tri = 0; tri < numTriangles; ++tri) {
+ addVertex(parts[0]);
+ addVertex(parts[tri + 1]);
+ addVertex(parts[tri + 2]);
+ }
+ },
};
The code above creates 3 arrays to hold the positions, texcoords, and
normals parsed from the object file. It also creates 3 arrays to hold
the same for WebGL. These are put in arrays as well in the same order
as the f
indices to make it easy to reference when we parse f
.
In other words an f
line like
f 1/2/3 4/5/6 7/8/9
One of those parts 4/5/6
is saying "use position 4" for this face vertex, "use
texcoord 5" and "use normal 6" but by putting the position, texcoord, and normal
arrays themselves in an array, the objVertexData
array, we can simplify that to
"use item n of objData i for webglData i" which lets us make the code simpler.
At end of our function we return the data we've built up
...
return {
position: webglVertexData[0],
texcoord: webglVertexData[1],
normal: webglVertexData[2],
};
}
All that's left to do is draw the data. First we'll use a variation of the shaders from the article on directional lighting.
const vs = `
attribute vec4 a_position;
attribute vec3 a_normal;
uniform mat4 u_projection;
uniform mat4 u_view;
uniform mat4 u_world;
varying vec3 v_normal;
void main() {
gl_Position = u_projection * u_view * u_world * a_position;
v_normal = mat3(u_world) * a_normal;
}
`;
const fs = `
precision mediump float;
varying vec3 v_normal;
uniform vec4 u_diffuse;
uniform vec3 u_lightDirection;
void main () {
vec3 normal = normalize(v_normal);
float fakeLight = dot(u_lightDirection, normal) * .5 + .5;
gl_FragColor = vec4(u_diffuse.rgb * fakeLight, u_diffuse.a);
}
`;
Then, using the code from the article on less code more fun first we'll load our data
async function main() {
// Get A WebGL context
/** @type {HTMLCanvasElement} */
const canvas = document.querySelector("#canvas");
const gl = canvas.getContext("webgl");
if (!gl) {
return;
}
... shaders ...
// compiles and links the shaders, looks up attribute and uniform locations
const meshProgramInfo = webglUtils.createProgramInfo(gl, [vs, fs]);
const data = await loadOBJ('resources/models/cube/cube.obj');
// Because data is just named arrays like this
//
// {
// position: [...],
// texcoord: [...],
// normal: [...],
// }
//
// and because those names match the attributes in our vertex
// shader we can pass it directly into `createBufferInfoFromArrays`
// from the article "less code more fun".
// create a buffer for each array by calling
// gl.createBuffer, gl.bindBuffer, gl.bufferData
const bufferInfo = webglUtils.createBufferInfoFromArrays(gl, data);
and then we'll draw it
const cameraTarget = [0, 0, 0];
const cameraPosition = [0, 0, 4];
const zNear = 0.1;
const zFar = 50;
function degToRad(deg) {
return deg * Math.PI / 180;
}
function render(time) {
time *= 0.001; // convert to seconds
webglUtils.resizeCanvasToDisplaySize(gl.canvas);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
const fieldOfViewRadians = degToRad(60);
const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
const projection = m4.perspective(fieldOfViewRadians, aspect, zNear, zFar);
const up = [0, 1, 0];
// Compute the camera's matrix using look at.
const camera = m4.lookAt(cameraPosition, cameraTarget, up);
// Make a view matrix from the camera matrix.
const view = m4.inverse(camera);
const sharedUniforms = {
u_lightDirection: m4.normalize([-1, 3, 5]),
u_view: view,
u_projection: projection,
};
gl.useProgram(meshProgramInfo.program);
// calls gl.uniform
webglUtils.setUniforms(meshProgramInfo, sharedUniforms);
// calls gl.bindBuffer, gl.enableVertexAttribArray, gl.vertexAttribPointer
webglUtils.setBuffersAndAttributes(gl, meshProgramInfo, bufferInfo);
// calls gl.uniform
webglUtils.setUniforms(meshProgramInfo, {
u_world: m4.yRotation(time),
u_diffuse: [1, 0.7, 0.5, 1],
});
// calls gl.drawArrays or gl.drawElements
webglUtils.drawBufferInfo(gl, bufferInfo);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
}
And with that we can see our cube is loaded and drawing
We also see some messages about unhandled keywords. What are those for?
usemtl
is the most important of these. It specifies that all geometry that
appears after uses a specific material. For example if you had a model of a car
you probably want transparent windows and chrome bumpers. The windows are
transparent and the bumpers are
reflective so they need to be drawn differently
than the body of the car. The usemtl
tag marks this separation of parts.
Because we'll need to draw each of these parts separately let's fix
the code so that each time we see a usemtl
we'll start a new set of webgl
data.
First let's make some code that starts a new webgl data if we don't already have some
function parseOBJ(text) {
// because indices are base 1 let's just fill in the 0th data
const objPositions = [[0, 0, 0]];
const objTexcoords = [[0, 0]];
const objNormals = [[0, 0, 0]];
// same order as `f` indices
const objVertexData = [
objPositions,
objTexcoords,
objNormals,
];
// same order as `f` indices
let webglVertexData = [
[], // positions
[], // texcoords
[], // normals
];
+ const geometries = [];
+ let geometry;
+ let material = 'default';
+
+ function newGeometry() {
+ // If there is an existing geometry and it's
+ // not empty then start a new one.
+ if (geometry && geometry.data.position.length) {
+ geometry = undefined;
+ }
+ }
+
+ function setGeometry() {
+ if (!geometry) {
+ const position = [];
+ const texcoord = [];
+ const normal = [];
+ webglVertexData = [
+ position,
+ texcoord,
+ normal,
+ ];
+ geometry = {
+ material,
+ data: {
+ position,
+ texcoord,
+ normal,
+ },
+ };
+ geometries.push(geometry);
+ }
+ }
...
and then let's call those in the correct places when
handling our keywords including adding the o
keyword
function.
...
const keywords = {
v(parts) {
objPositions.push(parts.map(parseFloat));
},
vn(parts) {
objNormals.push(parts.map(parseFloat));
},
vt(parts) {
objTexcoords.push(parts.map(parseFloat));
},
f(parts) {
+ setGeometry();
const numTriangles = parts.length - 2;
for (let tri = 0; tri < numTriangles; ++tri) {
addVertex(parts[0]);
addVertex(parts[tri + 1]);
addVertex(parts[tri + 2]);
}
},
+ usemtl(parts, unparsedArgs) {
+ material = unparsedArgs;
+ newGeometry();
+ },
};
...
The usemtl
keyword is not required so if there is no usemtl
in the file
we still want geometry so in the f
handler we call setGeometry
which will start some if there was no usemtl
keyword in the file before
that point.
Otherwise at the end we'll return geometries
which
is an array of objects, each of which contain name
and data
.
...
- return {
- position: webglVertexData[0],
- texcoord: webglVertexData[1],
- normal: webglVertexData[2],
- };
+ return geometries;
}
While we're here we should also handle the case where texcoords or normals are missing and just not include them.
+ // remove any arrays that have no entries.
+ for (const geometry of geometries) {
+ geometry.data = Object.fromEntries(
+ Object.entries(geometry.data).filter(([, array]) => array.length > 0));
+ }
return {
materialLibs,
geometries,
};
}
Continuing with keywords, According to the official spec,
mtllib
specifies separate file(s) that contains material info. Unfortunately that
doesn't seem to match reality because filenames can have spaces them and the .OBJ format
provides no way to escape spaces or quote arguments. Ideally they should have used a well defined format
like json or xml or yaml or something that solves this issue but in their defense .OBJ is older
than any of those formats.
We handle loading the file latter. For now let's just add it on to our loader so we can reference it later.
function parseOBJ(text) {
...
+ const materialLibs = [];
...
const keywords = {
...
+ mtllib(parts, unparsedArgs) {
+ materialLibs.push(unparsedArgs);
+ },
...
};
- return geometries;
+ return {
+ materialLibs,
+ geometries,
+ };
}
o
specifies the following items belong to the named "object". It's
not really clear how to use this. Can we have a file with just o
but no usemtl
? Let's assume yes.
function parseOBJ(text) {
...
let material = 'default';
+ let object = 'default';
...
function setGeometry() {
if (!geometry) {
const position = [];
const texcoord = [];
const normal = [];
webglVertexData = [
position,
texcoord,
normal,
];
geometry = {
+ object,
material,
data: {
position,
texcoord,
normal,
},
};
geometries.push(geometry);
}
}
const keywords = {
...
+ o(parts, unparsedArgs) {
+ object = unparsedArgs;
+ newGeometry();
+ },
...
};
s
specifies a smoothing group. I think smoothing groups is
mostly something we can ignore. Usually they are used in a modeling
program to auto generate vertex normals. A vertex normal is computed
first computing the normal of each face which is easy using the cross product
which we covered in the article on cameras.
Then, for any vertex we can average all the faces it shares. But, if we
want a hard edge sometimes we need to be able to tell the system to
ignore a face. Smoothing groups let you designate which faces will get
included when computing vertex normals. As for computing vertex normals
for geometry in general you can look in the article on lathing for one example.
For our case let's just ignore them. It suspect most .obj files have normals internally and so probably don't need smoothing groups. They keep them around for modeling packages incase you want to edit and regenerate normals.
+ const noop = () => {};
const keywords = {
...
+ s: noop,
...
};
One more keyword we haven't seen yet is g
for group. It's basically
just meta data. Objects can belong to more than one group.
Because it will appear in the next file we try let's add support here
even though we won't actually use the data.
function parseOBJ(text) {
...
+ let groups = ['default'];
...
function setGeometry() {
if (!geometry) {
const position = [];
const texcoord = [];
const normal = [];
webglVertexData = [
position,
texcoord,
normal,
];
geometry = {
object,
+ groups,
material,
data: {
position,
texcoord,
normal,
},
};
geometries.push(geometry);
}
}
...
const keywords = {
...
+ g(parts) {
+ groups = parts;
+ newGeometry()
+ },
...
};
Now that we're creating multiple sets of geometry we need to change
our setup code to create WebGLBuffers
for each one. We'll also create
a random color so hopefully it's easy to see the different parts.
- const response = await fetch('resources/models/cube/cube.obj');
+ const response = await fetch('resources/models/chair/chair.obj');
const text = await response.text();
- const data = parseOBJ(text);
+ const obj = parseOBJ(text);
+ const parts = obj.geometries.map(({data}) => {
// Because data is just named arrays like this
//
// {
// position: [...],
// texcoord: [...],
// normal: [...],
// }
//
// and because those names match the attributes in our vertex
// shader we can pass it directly into `createBufferInfoFromArrays`
// from the article "less code more fun".
// create a buffer for each array by calling
// gl.createBuffer, gl.bindBuffer, gl.bufferData
const bufferInfo = webglUtils.createBufferInfoFromArrays(gl, data);
+ return {
+ material: {
+ u_diffuse: [Math.random(), Math.random(), Math.random(), 1],
+ },
+ bufferInfo,
+ };
+ });
I switched from loading a cube to loading this CC-BY 4.0 chair by haytonm I found on Sketchfab
To render we just need to loop over the parts
function render(time) {
...
gl.useProgram(meshProgramInfo.program);
// calls gl.uniform
webglUtils.setUniforms(meshProgramInfo, sharedUniforms);
+ // compute the world matrix once since all parts
+ // are at the same space.
+ const u_world = m4.yRotation(time);
+
+ for (const {bufferInfo, material} of parts) {
// calls gl.bindBuffer, gl.enableVertexAttribArray, gl.vertexAttribPointer
webglUtils.setBuffersAndAttributes(gl, meshProgramInfo, bufferInfo);
// calls gl.uniform
webglUtils.setUniforms(meshProgramInfo, {
- u_world: m4.yRotation(time),
- u_diffuse: [1, 0.7, 0.5, 1],
+ u_world,
+ u_diffuse: material.u_diffuse,
});
// calls gl.drawArrays or gl.drawElements
webglUtils.drawBufferInfo(gl, bufferInfo);
+ }
...
and that kinda works
Wouldn't it be nice if we could try to center the object?
To do that we need to compute the extents which is the minimum and maximum vertex positions. So first we can make a function that given positions will figure out the min and max positions
function getExtents(positions) {
const min = positions.slice(0, 3);
const max = positions.slice(0, 3);
for (let i = 3; i < positions.length; i += 3) {
for (let j = 0; j < 3; ++j) {
const v = positions[i + j];
min[j] = Math.min(v, min[j]);
max[j] = Math.max(v, max[j]);
}
}
return {min, max};
}
and then we can loop over all the parts of our geometry and get the extents for all parts
function getGeometriesExtents(geometries) {
return geometries.reduce(({min, max}, {data}) => {
const minMax = getExtents(data.position);
return {
min: min.map((min, ndx) => Math.min(minMax.min[ndx], min)),
max: max.map((max, ndx) => Math.max(minMax.max[ndx], max)),
};
}, {
min: Array(3).fill(Number.POSITIVE_INFINITY),
max: Array(3).fill(Number.NEGATIVE_INFINITY),
});
}
Then we can use that to compute how far to translate the object so its center is at the origin and a distance from the origin to place the camera so hopefully we can see all of it.
- const cameraTarget = [0, 0, 0];
- const cameraPosition = [0, 0, 4];
- const zNear = 0.1;
- const zFar = 50;
+ const extents = getGeometriesExtents(obj.geometries);
+ const range = m4.subtractVectors(extents.max, extents.min);
+ // amount to move the object so its center is at the origin
+ const objOffset = m4.scaleVector(
+ m4.addVectors(
+ extents.min,
+ m4.scaleVector(range, 0.5)),
+ -1);
+ const cameraTarget = [0, 0, 0];
+ // figure out how far away to move the camera so we can likely
+ // see the object.
+ const radius = m4.length(range) * 1.2;
+ const cameraPosition = m4.addVectors(cameraTarget, [
+ 0,
+ 0,
+ radius,
+ ]);
+ // Set zNear and zFar to something hopefully appropriate
+ // for the size of this object.
+ const zNear = radius / 100;
+ const zFar = radius * 3;
Above we also set zNear
and zFar
to something that hopefully shows the object
well.
We just need to use the objOffset
to translate the object to the origin
// compute the world matrix once since all parts
// are at the same space.
-const u_world = m4.yRotation(time);
+let u_world = m4.yRotation(time);
+u_world = m4.translate(u_world, ...objOffset);
and with that the object is centered.
Looking around the net it turns out there are non-standard versions of .OBJ files that include vertex colors. To do this they tack on extra values to each vertex position so instead of
v <x> <y> <z>
its
v <x> <y> <z> <red> <green> <blue>
It's not clear if there is also an optional alpha at end of that.
I looked around and found this Book - Vertex chameleon study by Oleaf License: CC-BY-NC that uses vertex colors.
Let's see if we can add in support to our parser to handle the vertex colors.
We need to add stuff for colors everywhere we had position, normals, and texcoords
function parseOBJ(text) {
// because indices are base 1 let's just fill in the 0th data
const objPositions = [[0, 0, 0]];
const objTexcoords = [[0, 0]];
const objNormals = [[0, 0, 0]];
+ const objColors = [[0, 0, 0]];
// same order as `f` indices
const objVertexData = [
objPositions,
objTexcoords,
objNormals,
+ objColors,
];
// same order as `f` indices
let webglVertexData = [
[], // positions
[], // texcoords
[], // normals
+ [], // colors
];
...
function setGeometry() {
if (!geometry) {
const position = [];
const texcoord = [];
const normal = [];
+ const color = [];
webglVertexData = [
position,
texcoord,
normal,
+ color,
];
geometry = {
object,
groups,
material,
data: {
position,
texcoord,
normal,
+ color,
},
};
geometries.push(geometry);
}
}
Then unfortunately actually parsing makes the code a little less generic.
const keywords = {
v(parts) {
- objPositions.push(parts.map(parseFloat));
+ // if there are more than 3 values here they are vertex colors
+ if (parts.length > 3) {
+ objPositions.push(parts.slice(0, 3).map(parseFloat));
+ objColors.push(parts.slice(3).map(parseFloat));
+ } else {
+ objPositions.push(parts.map(parseFloat));
+ }
},
...
};
Then when we read a f
face line we call addVertex
. We'll need to grab
the vertex colors here
function addVertex(vert) {
const ptn = vert.split('/');
ptn.forEach((objIndexStr, i) => {
if (!objIndexStr) {
return;
}
const objIndex = parseInt(objIndexStr);
const index = objIndex + (objIndex >= 0 ? 0 : objVertexData[i].length);
webglVertexData[i].push(...objVertexData[i][index]);
+ // if this is the position index (index 0) and we parsed
+ // vertex colors then copy the vertex colors to the webgl vertex color data
+ if (i === 0 && objColors.length > 1) {
+ geometry.data.color.push(...objColors[index]);
+ }
});
}
Now we need to change our shaders to use vertex colors
const vs = `
attribute vec4 a_position;
attribute vec3 a_normal;
+attribute vec4 a_color;
uniform mat4 u_projection;
uniform mat4 u_view;
uniform mat4 u_world;
varying vec3 v_normal;
+varying vec4 v_color;
void main() {
gl_Position = u_projection * u_view * u_world * a_position;
v_normal = mat3(u_world) * a_normal;
+ v_color = a_color;
}
`;
const fs = `
precision mediump float;
varying vec3 v_normal;
+varying vec4 v_color;
uniform vec4 u_diffuse;
uniform vec3 u_lightDirection;
void main () {
vec3 normal = normalize(v_normal);
float fakeLight = dot(u_lightDirection, normal) * .5 + .5;
- gl_FragColor = vec4(u_diffuse.rgb * fakeLight, u_diffuse.a);
+ vec4 diffuse = u_diffuse * v_color;
+ gl_FragColor = vec4(diffuse.rgb * fakeLight, diffuse.a);
}
`;
Like I mentioned above I have no idea if this non-standard version of .OBJ
can include alpha values for each vertex color. Our helper library has been automatically taking the data we pass it and making buffers for
us. It guesses how many components there are per element in the data. For data with a name
that contains the string position
or normal
it assumes 3 components per element.
For a name that contains texcoord
it assumes 2 components per element. For everything
else it assumes 4 components per element. That means if our colors are only r, g, b,
3 components per element, we need to tell it so it doesn't guess 4.
const parts = obj.geometries.map(({data}) => {
// Because data is just named arrays like this
//
// {
// position: [...],
// texcoord: [...],
// normal: [...],
// }
//
// and because those names match the attributes in our vertex
// shader we can pass it directly into `createBufferInfoFromArrays`
// from the article "less code more fun".
+ if (data.position.length === data.color.length) {
+ // it's 3. The our helper library assumes 4 so we need
+ // to tell it there are only 3.
+ data.color = { numComponents: 3, data: data.color };
+ }
// create a buffer for each array by calling
// gl.createBuffer, gl.bindBuffer, gl.bufferData
const bufferInfo = webglUtils.createBufferInfoFromArrays(gl, data);
return {
material: {
u_diffuse: [Math.random(), Math.random(), Math.random(), 1],
},
bufferInfo,
};
});
We probably also want to still handle the more common case when there are no vertex colors. On the first article as well as other articles we covered that an attribute usually gets its value from a buffer. But, we can also make attributes just be constant. An attribute that is turned off uses a constant value. Example
gl.disableVertexAttribArray(someAttributeLocation); // use a constant value
const value = [1, 2, 3, 4];
gl.vertexAttrib4fv(someAttributeLocation, value); // the constant value to use
Our helper library handles this for us if
we set the data for that attribute to {value: [1, 2, 3, 4]}
. So, we can
check if there are no vertex colors then if so set the vertex color attribute
to constant white.
const parts = obj.geometries.map(({data}) => {
// Because data is just named arrays like this
//
// {
// position: [...],
// texcoord: [...],
// normal: [...],
// }
//
// and because those names match the attributes in our vertex
// shader we can pass it directly into `createBufferInfoFromArrays`
// from the article "less code more fun".
+ if (data.color) {
if (data.position.length === data.color.length) {
// it's 3. The our helper library assumes 4 so we need
// to tell it there are only 3.
data.color = { numComponents: 3, data: data.color };
}
+ } else {
+ // there are no vertex colors so just use constant white
+ data.color = { value: [1, 1, 1, 1] };
+ }
...
});
We also can't use a random color per part any more
const parts = obj.geometries.map(({data}) => {
...
// create a buffer for each array by calling
// gl.createBuffer, gl.bindBuffer, gl.bufferData
const bufferInfo = webglUtils.createBufferInfoFromArrays(gl, data);
return {
material: {
- u_diffuse: [Math.random(), Math.random(), Math.random(), 1],
+ u_diffuse: [1, 1, 1, 1],
},
bufferInfo,
};
});
And we that we're able to load an .OBJ file with vertex colors.
As for parsing and using materials see the next article
You can go read more about the .obj format. There are tons of features the code above doesn't support. Also, the code has not been tested on very many .obj files so maybe there are lurking bugs. That said, I suspect the majority of .obj files online only use the features shown above so I suspect it's probably a useful example.
Example: the vt
keyword can have 3 values per entry instead of just 2. 3 values
would be for 3D textures which is not common so I didn't bother. If you did pass it
a file with 3D texture coordinates you'd have to change the shaders to handle 3D
textures and the code that generates WebGLBuffers
(calls createBufferInfoFromArrays
)
to tell it it's 3 components per UV coordinate.
I have no idea if some f
keywords can have 3 entries
and others only 2 in the same file. If that's possible the code above doesn't
handle it.
The code also assumes that if vertex positions have x, y, z they all have x, y, z. If there are files out there where some vertex positions have x, y, z, others have only x, y, and still others have x, y, z, r, g, b then we'd have to refractor.
The code above puts the data for position, texcoord, normal in separate buffers. You could put them in one buffer by either interleaving them pos,uv,nrm,pos,uv,nrm,... but you'd then need to change how the attributes are setup to pass in strides and offsets.
Extending that you could even put the data for all the parts in the same buffers where as currently it's one buffer per data type per part.
I left those out because I don't think it's that important and because it would clutter the example.
The code above expands the vertices into flat lists of triangles. We could have reindexed
the vertices. Especially if we put all vertex data in a single buffer or at least a single
buffer per type but shared across parts then basically for each f
keyword you convert
the indices to positive numbers (translate the negative numbers to the correct positive index),
and then the set of the numbers is an id for that vertex. So you can store an id to index
map to help look up the indices.
const idToIndexMap = {}
const webglIndices = [];
function addVertex(vert) {
const ptn = vert.split('/');
// first convert all the indices to positive indices
const indices = ptn.forEach((objIndexStr, i) => {
if (!objIndexStr) {
return;
}
const objIndex = parseInt(objIndexStr);
return objIndex + (objIndex >= 0 ? 0 : objVertexData[i].length);
});
// now see that particular combination of position,texcoord,normal
// already exists
const id = indices.join(',');
let vertIndex = idToIndexMap[id];
if (!vertIndex) {
// No. Add it.
vertIndex = webglVertexData[0].length / 3;
idToIndexMap[id] = vertexIndex;
indices.forEach((index, i) => {
if (index !== undefined) {
webglVertexData[i].push(...objVertexData[i][index]);
}
});
}
webglIndices.push(vertexIndex);
}
Or you could just manually re-index if you think it's important.
The code as written assumes normals exists. Like we did for the lathe example we could generate normals if they don't exist, taking into account smoothing groups if we want. Or we could also use different shaders that either don't use normals or compute normals.
Honestly you should not use .OBJ files IMO. I mostly wrote this as an example. If you can pull vertex data out of a file you can write importers for any format.
Problems with .OBJ files include
no support for lights or cameras
That might be okay because maybe you're loading a bunch of parts (like trees, bushes, rocks for a landscape) and you don't need cameras or lights. Still it's nice to have the option if you want to load entire scenes as some artist created them.
No hierarchy, No scene graph
If you want to load a car ideally you'd like to be able to turn the wheels and have them spin around their centers. This is impossible with .OBJ because .OBJ contains no scene graph. Better formats include that data which is much more useful if you want to be able to orient parts, slide a window, open a door, move the legs of a character, etc...
no support for animation or skinning
We went over skinning elsewhere but .OBJ provides no data for skinning and no data for animation. Again that might be okay for your needs but I'd prefer one format that handles more.
.OBJ doesn't support more modern materials.
Materials are usually pretty engine specific but lately there is at least some agreement on physically based rendering materials. .OBJ doesn't support that AFAIK.
.OBJ requires parsing
Unless you're making a generic viewer for users to upload .OBJ files into it the best practice is to use a format that requires as little parsing as possible. .GLTF is a format designed for WebGL. It uses JSON so you can just load it in. For binary data it uses formats that are ready to load into the GPU directly, no need to parse numbers into arrays most of the time.
You can see an example of loading a .GLTF file in the article on skinning.
If you have .OBJ files you want to use the best practice would be to convert them to some other format first, offline, and then use the better format on your page.