Parsing OBJ

Parsing Vertices, Normals and Faces

OBJ indexes positions, uvs and normals separately, but WebGL has one index per vertex, so a loader makes one vertex per distinct v/vt/vn combination: the book's 8 corners become 24 vertices. Polygons become fans of triangles (0 1 2, 0 2 3 ...), correct for convex faces; negative indices count back from the latest vertex; v//vn omits the uv; faces without normals need computed ones (the loader below uses +z). Curves, lines and smoothing groups can be skipped.

The parsed book exploded: 8 OBJ positions became 24 vertices (one per v/vt/vn), and each quad a fan of 2 trianglesHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  .wrap { position: relative; width: 100%; max-width: 600px; }
  .wrap canvas { display: block; width: 100%; }
  #hud { position: absolute; left: 0; top: 0; pointer-events: none; }
  pre { max-width: 600px; margin: 4px 0; font: 11px monospace; }
  .names { display: flex; max-width: 600px; text-align: center; font: 11px monospace; }
  .names div { flex: 1; padding: 4px 2px; }
</style>
<div class="wrap">
  <canvas id="c" width="1200" height="520"></canvas>
</div>
<pre id="out"></pre>
<script>
// --- 4x4 matrices (column-major) ---
const m4 = {
  perspective(fovy, aspect, near, far) {
    const f = 1 / Math.tan(fovy * Math.PI / 360), d = near - far;
    return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / d, -1, 0, 0, 2 * far * near / d, 0];
  },
  lookAt(eye, target, up = [0, 1, 0]) {
    const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
    const unit = (v) => v.map((c) => c / Math.hypot(...v));
    const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
    const z = unit(eye.map((c, i) => c - target[i])), x = unit(cross(up, z)), y = cross(z, x);
    return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
  },
  identity: () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
  multiply(a, b) {
    const out = new Array(16).fill(0);
    for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
    return out;
  },
  translation: (x, y, z) => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1],
  scaling: (x, y, z) => [x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1],
  rotationY(a) { const c = Math.cos(a), s = Math.sin(a); return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]; },
  chain: (...ms) => ms.reduce((a, b) => m4.multiply(a, b)),
  apply: (m, [x, y, z]) => [0, 1, 2].map((r) => m[r] * x + m[4 + r] * y + m[8 + r] * z + m[12 + r]),
  normal(m) {
    const [a, b, c, , d, e, f, , g, h, i] = m;
    const n = [e * i - f * h, f * g - d * i, d * h - e * g, c * h - b * i, a * i - c * g, b * g - a * h, b * f - c * e, c * d - a * f, a * e - b * d];
    const det = a * n[0] + b * n[1] + c * n[2];
    return n.map((v) => v / det);
  },
};
function program(vs, fs) {
  const p = gl.createProgram();
  for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
    const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
    gl.attachShader(p, s);
  }
  gl.linkProgram(p);
  return p;
}
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');
const u = (p, n) => gl.getUniformLocation(p, n);

// --- The OBJ and MTL files, inline (a page may not fetch files) ---
const BOOK_OBJ = `# BookNest book, 0.42 x 0.98 x 0.12: cover facing +z, spine on -x, y up
mtllib booknest-book.mtl
o Book
v -0.21 -0.49 0.06
v 0.21 -0.49 0.06
v 0.21 0.49 0.06
v -0.21 0.49 0.06
v -0.21 -0.49 -0.06
v 0.21 -0.49 -0.06
v 0.21 0.49 -0.06
v -0.21 0.49 -0.06
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vn 0 0 1
vn 0 0 -1
vn -1 0 0
vn 1 0 0
vn 0 1 0
vn 0 -1 0
usemtl cover
s off
f 1/1/1 2/2/1 3/3/1 4/4/1
f 6/1/2 5/2/2 8/3/2 7/4/2
f 5/1/3 1/2/3 4/3/3 8/4/3
usemtl pages
f 2/1/4 6/2/4 7/3/4 3/4/4
f 4/1/5 3/2/5 7/3/5 8/4/5
f 5/1/6 6/2/6 2/3/6 1/4/6`;
const BOOK_MTL = `newmtl cover
Kd 0.122 0.373 0.545
Ks 0.25 0.25 0.25
Ns 60
illum 2
map_Kd booknest-cover-1.png
newmtl pages
Kd 0.95 0.93 0.86
illum 1`;
// A minimal OBJ + MTL parser: v, vt, vn, polygon faces (fanned into triangles), usemtl groups.
// Vertices come out as x y z, nx ny nz, u v.
function parseOBJ(objText, mtlText = '') {
  const lines = (text) => text.split('\n').map((line) => line.trim().split(/\s+/)).filter(([key]) => key && !key.startsWith('#'));
  const materials = {}, gray = [0.8, 0.8, 0.8];
  let material;
  for (const [key, ...args] of lines(mtlText)) {
    if (key === 'newmtl') material = materials[args[0]] = { name: args[0], Kd: gray };
    else if (/^K[adse]$/.test(key)) material[key] = args.map(Number);   // colours
    else if (/^(Ns|d|Ni|illum)$/.test(key)) material[key] = Number(args[0]);
    else if (key.startsWith('map_')) material[key] = args.at(-1);       // the file name after any options
  }
  const data = { v: [], vt: [], vn: [] }, vertices = [], indices = [], groups = [];
  const seen = new Map();                                    // "v/vt/vn" -> vertex number
  const pick = (list, i) => list[i < 0 ? list.length + i : i - 1];   // 1-based, or relative if negative
  for (const [key, ...args] of lines(objText)) {
    if (key in data) data[key].push(args.map(Number));
    else if (key === 'usemtl') groups.push({ material: materials[args[0]] ?? { name: args[0], Kd: gray }, start: indices.length });
    else if (key === 'f') {
      const corners = args.map((corner) => {
        const [v, vt, vn] = corner.split('/').map(Number);  // 'v//vn' gives vt = 0
        const id = `${pick(data.v, v)}/${vt && pick(data.vt, vt)}/${vn && pick(data.vn, vn)}`;
        if (!seen.has(id)) {                                // one vertex per distinct combination
          seen.set(id, vertices.length / 8);
          vertices.push(...pick(data.v, v), ...(vn ? pick(data.vn, vn) : [0, 0, 1]), ...(vt ? pick(data.vt, vt).slice(0, 2) : [0, 0]));
        }
        return seen.get(id);
      });
      for (let k = 1; k < corners.length - 1; k++) indices.push(corners[0], corners[k], corners[k + 1]);   // a fan
    }
  }
  if (!groups.length) groups.push({ material: { name: 'default', Kd: gray }, start: 0 });
  groups.forEach((g, i) => { g.count = (groups[i + 1]?.start ?? indices.length) - g.start; });
  return { vertices: new Float32Array(vertices), indices: new Uint16Array(indices), groups };
}

// --- Meshes: a unit box (for the wood) and the parsed book, each in a VAO ---
function meshVAO(vertices, indices) {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);
  gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
  gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0); gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12); gl.enableVertexAttribArray(1);
  gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24); gl.enableVertexAttribArray(2);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
  gl.bindVertexArray(null);
  return vao;
}
const box = (() => {
  const v = [], idx = [];
  const faces = [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
                 [[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]];
  faces.forEach(([n, a1, b1], f) => {
    for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,1]]) v.push(...[0, 1, 2].map(i => (n[i] + a * a1[i] + b * b1[i]) / 2), ...n, (a + 1) / 2, (b + 1) / 2);
    idx.push(f * 4, f * 4 + 1, f * 4 + 2, f * 4, f * 4 + 2, f * 4 + 3);
  });
  return { vertices: new Float32Array(v), indices: new Uint16Array(idx), groups: [{ start: 0, count: 36 }] };
})();
const book = parseOBJ(BOOK_OBJ, BOOK_MTL);
const boxVAO = meshVAO(box.vertices, box.indices), bookVAO = meshVAO(book.vertices, book.indices);

// --- Covers (2D canvases) and the catalog ---
const catalog = [['The Quiet Harbor', 14.99, 4.6, '#1f5f8b'], ['Patterns of the Deep Web', 39.5, 4.3, '#5b3f99'],
  ['Salt and Saffron', 24, 4.8, '#e09a10'], ['Small Steps to Big Summits', 18.75, 4.1, '#3f7d3a'],
  ["The Clockmaker's Paradox", 16.2, 4.5, '#b5452f'], ['Gardens in Glass', 21.3, 4.4, '#2a9d8f']]
  .map(([title, price, rating, color]) => ({ title, price, rating, color }));
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16) / 255);
const covers = catalog.map(({ title, color }) => {
  const c = Object.assign(document.createElement('canvas'), { width: 128, height: 192 }), g = c.getContext('2d');
  g.fillStyle = color; g.fillRect(0, 0, 128, 192);
  g.strokeStyle = '#f2ead8'; g.lineWidth = 3; g.strokeRect(8, 8, 112, 176);
  g.fillStyle = '#f2ead8'; g.font = 'bold 16px Georgia'; g.textAlign = 'center';
  g.fillText(title.split(' ').slice(0, 2).join(' '), 64, 90); g.fillText(title.split(' ').slice(2).join(' '), 64, 112);
  const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, c); gl.generateMipmap(gl.TEXTURE_2D);
  return t;
});

const lit = program(`#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aUV;
uniform mat4 uViewProjection, uModel; uniform mat3 uNormalMatrix; out vec3 vPosition, vNormal; out vec2 vUV;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = uNormalMatrix * aNormal; vUV = aUV;
  gl_Position = uViewProjection * w; }`, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV; out vec4 outColor;
uniform sampler2D uCover; uniform mat3 uNormalMatrix; uniform vec3 uColor, uLamp; uniform float uTextured;
void main() {                                                    // cover art on the +z faces
  vec3 n = normalize(vNormal), m = normalize(uNormalMatrix * vec3(0, 0, 1));
  vec3 base = mix(uColor, texture(uCover, vUV).rgb, step(0.99, dot(n, m)) * uTextured);
  outColor = vec4(base * (0.4 + 0.7 * max(dot(n, normalize(uLamp - vPosition)), 0.0)), 1.0);
}`);


// A pentagon with relative (negative) indices, to show fans and v//vn corners too.
const pentagon = parseOBJ(`v 0 0.5 0
v -0.48 0.15 0
v -0.29 -0.4 0
v 0.29 -0.4 0
v 0.48 0.15 0
vn 0 0 1
f -5//1 -4//1 -3//1 -2//1 -1//1`);
const pentagonVAO = meshVAO(pentagon.vertices, pentagon.indices);
// Explode: draw every triangle pushed out along its vertices' normal, shrunk toward its centre.
const flat = program(`#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; uniform float uPush; out vec3 vNormal;
void main() { vNormal = mat3(uModel) * aNormal; gl_Position = uViewProjection * uModel * vec4(aPosition + aNormal * uPush, 1.0); gl_PointSize = 8.0; }`,
`#version 300 es
precision mediump float; in vec3 vNormal; uniform vec3 uColor; out vec4 c;
void main() { c = vec4(uColor * (0.55 + 0.45 * max(dot(normalize(vNormal), normalize(vec3(-0.4, 0.6, 0.7))), 0.0)), 1.0); }`);
const camera = m4.multiply(m4.perspective(38, 1200 / 520, 0.1, 20), m4.lookAt([0, 0.5, 3.2], [0, 0, 0]));
const colors = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'].map(rgb);
function frame(ms) {
  const t = ms / 1000, push = 0.12 + 0.08 * Math.sin(t * 1.5);
  gl.clearColor(0.97, 0.96, 0.93, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.enable(gl.DEPTH_TEST); gl.disable(gl.CULL_FACE);
  gl.useProgram(flat);
  gl.uniformMatrix4fv(u(flat, 'uViewProjection'), false, camera);
  gl.uniform1f(u(flat, 'uPush'), push);
  let model = m4.chain(m4.translation(-0.9, 0, 0), m4.rotationY(t * 0.6 + 0.6), m4.scaling(1.5, 1.5, 1.5));
  gl.uniformMatrix4fv(u(flat, 'uModel'), false, model);
  gl.bindVertexArray(bookVAO);
  for (let tri = 0; tri < book.indices.length / 3; tri++) {         // 12 triangles: two per quad
    gl.uniform3fv(u(flat, 'uColor'), colors[Math.floor(tri / 2)].map((c) => c * (tri % 2 ? 0.8 : 1)));
    gl.drawElements(gl.TRIANGLES, 3, gl.UNSIGNED_SHORT, tri * 6);
  }
  gl.uniform3f(u(flat, 'uColor'), 0.1, 0.1, 0.1);
  gl.drawArrays(gl.POINTS, 0, book.vertices.length / 8);            // all 24 vertices
  model = m4.chain(m4.translation(1.0, 0, 0), m4.rotationY(Math.sin(t) * 0.5), m4.scaling(1.3, 1.3, 1.3));
  gl.uniformMatrix4fv(u(flat, 'uModel'), false, model);
  gl.uniform1f(u(flat, 'uPush'), 0);
  gl.bindVertexArray(pentagonVAO);
  for (let tri = 0; tri < 3; tri++) {                               // the pentagon's fan: 0 1 2, 0 2 3, 0 3 4
    gl.uniform3fv(u(flat, 'uColor'), colors[tri + 2]);
    gl.drawElements(gl.TRIANGLES, 3, gl.UNSIGNED_SHORT, tri * 6);
  }
  gl.bindVertexArray(null);
  requestAnimationFrame(frame);
}
document.getElementById('out').textContent =
  `book: 8 positions, 4 uvs, 6 normals -> ${book.vertices.length / 8} vertices, ${book.indices.length / 3} triangles, ` +
  `groups ${book.groups.map((g) => `${g.material.name} (indices ${g.start}-${g.start + g.count - 1})`).join(', ')}\n` +
  `pentagon (f -5//1 ... -1//1): ${pentagon.vertices.length / 8} vertices, fan of ${pentagon.indices.length / 3} triangles`;
requestAnimationFrame(frame);
</script>