The MTL file defines each usemtl material: Kd diffuse color, Ks specular, Ns shininess, illum the lighting model and map_Kd a texture, matching Specular Lighting and Fog's Blinn-Phong uniforms (d adds opacity):
# BookNest book materials
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 1Paths are relative to the MTL file. The page loads both files with fetch(), which needs a web server (npx serve), since browsers refuse it on file:// pages.
<!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="440"></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);
}`);
// Three material files for the same OBJ: the book's own, a glossy red one without a texture, a matte one.
const variants = [BOOK_MTL,
`newmtl cover\nKd 0.71 0.27 0.18\nKs 0.8 0.8 0.8\nNs 200\nillum 2\nnewmtl pages\nKd 0.95 0.93 0.86\nillum 1`,
`newmtl cover\nKd 0.25 0.49 0.23\nKs 0.05 0.05 0.05\nNs 4\nillum 2\nmap_Kd booknest-cover-4.png\nnewmtl pages\nKd 0.8 0.76 0.66\nillum 1`];
const parsed = variants.map((mtl) => parseOBJ(BOOK_OBJ, mtl));
const textures = { 'booknest-cover-1.png': covers[0], 'booknest-cover-4.png': covers[3] }; // map_Kd names -> textures
const phong = 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; out vec3 vPosition, vNormal; out vec2 vUV;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = mat3(uModel) * aNormal; vUV = aUV; gl_Position = uViewProjection * w; }`,
`#version 300 es
precision highp float; in vec3 vPosition, vNormal; in vec2 vUV; out vec4 c;
uniform vec3 uKd, uKs, uEye, uLamp; uniform float uNs; uniform bool uTextured; uniform sampler2D uMap;
void main() {
vec3 n = normalize(vNormal), l = normalize(uLamp - vPosition), v = normalize(uEye - vPosition);
vec3 kd = uTextured ? texture(uMap, vUV).rgb : uKd; // map_Kd replaces Kd
float diffuse = max(dot(n, l), 0.0);
float spec = diffuse > 0.0 ? pow(max(dot(n, normalize(l + v)), 0.0), max(uNs, 1.0)) : 0.0;
c = vec4(kd * (0.3 + 0.8 * diffuse) + uKs * spec, 1.0); // illum 2: diffuse + specular
}`);
const vaos = parsed.map((p) => meshVAO(p.vertices, p.indices));
const eye = [0, 0.3, 3.4];
const camera = m4.multiply(m4.perspective(36, 1200 / 440, 0.1, 20), m4.lookAt(eye, [0, 0, 0]));
function frame(ms) {
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.enable(gl.CULL_FACE);
gl.useProgram(phong);
gl.uniformMatrix4fv(u(phong, 'uViewProjection'), false, camera);
gl.uniform3fv(u(phong, 'uEye'), eye); gl.uniform3f(u(phong, 'uLamp'), 1.5, 1.5, 2.5);
parsed.forEach((p, i) => {
const model = m4.chain(m4.translation((i - 1) * 1.3, 0, 0), m4.rotationY(-0.5 + 0.5 * Math.sin(ms * 0.0009 + i)), m4.scaling(1.6, 1.6, 1.6));
gl.uniformMatrix4fv(u(phong, 'uModel'), false, model);
gl.bindVertexArray(vaos[i]);
for (const { material, start, count } of p.groups) { // one draw per usemtl group
gl.uniform3fv(u(phong, 'uKd'), material.Kd);
gl.uniform3fv(u(phong, 'uKs'), material.Ks || [0, 0, 0]);
gl.uniform1f(u(phong, 'uNs'), material.Ns || 1);
gl.uniform1i(u(phong, 'uTextured'), material.map_Kd ? 1 : 0);
gl.bindTexture(gl.TEXTURE_2D, textures[material.map_Kd] || null);
gl.drawElements(gl.TRIANGLES, count, gl.UNSIGNED_SHORT, start * 2);
}
});
gl.bindVertexArray(null);
requestAnimationFrame(frame);
}
document.getElementById('out').textContent = parsed.map((p, i) => {
const m = p.groups[0].material;
return `book ${i + 1} cover: Kd ${m.Kd.join(' ')} | Ks ${(m.Ks || []).join(' ')} | Ns ${m.Ns} | map_Kd ${m.map_Kd || '-'}`;
}).join('\n');
requestAnimationFrame(frame);
</script>