Wavefront OBJ, a plain-text format from Wavefront Technologies' Advanced Visualizer, is the lowest common denominator of 3D exchange. Blender 5.2 4,391 exports it (File > Export > Wavefront) with options for triangulation, material groups, MTL PBR extensions and axes (Blender is Z up; choose -Z Forward, Y Up). BookNest's book is small enough to write by hand, a 0.42 x 0.98 x 0.12 box with its spine on -x:
# 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
# front, back and spine, then fore-edge, top and bottom: position/uv/normal indices
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/6v, vt and vn lines are positions, uvs and normals, numbered from 1. An f line lists a polygon's corners as position/uv/normal, counterclockwise from outside; mtllib names the material file and usemtl switches material.
<!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 style="display:flex;gap:8px;max-width:600px">
<div class="wrap" style="flex:1"><canvas id="c" width="600" height="600"></canvas></div>
<pre id="src" style="flex:1;margin:0;max-height:300px;overflow:auto;font-size:10px;background:#fff;border:1px solid #ddd;padding:4px"></pre>
</div>
<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);
}`);
// Colour each face by the f line it came from: the parser fans every quad into 2 triangles (6 indices).
const faceColors = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'];
let fLine = 0;
document.getElementById('src').innerHTML = BOOK_OBJ.split('\n').map((line) => {
const safe = line.replace(/</g, '<');
if (line.startsWith('f ')) return `<b style="color:${faceColors[fLine++]}">${safe}</b>`;
if (line.startsWith('#')) return `<span style="color:#888">${safe}</span>`;
return safe;
}).join('\n');
const camera = m4.multiply(m4.perspective(35, 1, 0.1, 20), m4.lookAt([0, 0.2, 3.0], [0, 0, 0]));
function frame(ms) {
const t = ms / 1000;
const model = m4.chain(m4.rotationY(t * 0.8), [1, 0, 0, 0, 0, Math.cos(0.35), Math.sin(0.35), 0, 0, -Math.sin(0.35), Math.cos(0.35), 0, 0, 0, 0, 1], m4.scaling(1.4, 1.4, 1.4));
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(lit);
gl.uniformMatrix4fv(u(lit, 'uViewProjection'), false, camera);
gl.uniformMatrix4fv(u(lit, 'uModel'), false, model);
gl.uniformMatrix3fv(u(lit, 'uNormalMatrix'), false, m4.normal(model));
gl.uniform3f(u(lit, 'uLamp'), -1, 2, 3);
gl.uniform1f(u(lit, 'uTextured'), 0);
gl.bindVertexArray(bookVAO);
for (let f = 0; f < 6; f++) { // one draw per f line, 6 indices each
gl.uniform3fv(u(lit, 'uColor'), rgb(faceColors[f]));
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, f * 6 * 2);
}
gl.bindVertexArray(null);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>