Sketchfab

Publishing Models with Sketchfab

Sketchfab 2,761 hosts 3D models and shows them in a WebGL viewer you embed with an <iframe>. It began in 2011 as ShowWebGL and has belonged to Epic Games since July 2021. You upload a model (OBJ with its MTL, FBX and glTF are common), set materials, lighting and annotations in its editor, and paste the embed code. For control from your own page, the Viewer API (sketchfab-viewer-1.12.1.js) wraps the iframe: new Sketchfab(iframe).init(uid, { success }) returns an API object that starts the viewer, moves the camera, toggles parts and takes screenshots, the basis of product configurators. The Basic plan is free; Pro costs US$15 a month and Premium US$79 a month, billed yearly, and the paid plans add private models, larger uploads and white-label embedding without the Sketchfab logo. It suits showing a finished model with no code at all: BookNest could publish the OBJ book of HUDs, OBJ and Matrix Stacks there.

What an embedded model viewer does, in raw WebGL: a turntable book with numbered annotations that follow the model and an annotation listHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  .wrap { position: relative; max-width: 600px; }
  canvas { display: block; width: 100%; }
  .pin { position: absolute; width: 20px; height: 20px; margin: -10px 0 0 -10px; border-radius: 50%; background: #1b2733; color: #fff;
         font: bold 11px/20px system-ui; text-align: center; border: 2px solid #fff; box-shadow: 0 1px 3px #0006; }
  .pin.behind { opacity: 0.35; }
  ol { max-width: 600px; margin: 6px 0; padding-left: 26px; }
</style>
<div class="wrap" id="wrap"><canvas id="c" width="600" height="320"></canvas></div>
<ol id="list"></ol>
<script>
// Annotations: a point on the model, a title and a note, like a hosted viewer's annotation editor.
const notes = [[[0.0, 0.25, 0.14], [0, 0, 1], 'Front cover', 'Screen-printed title: The Quiet Harbor'],
  [[-0.5, 0.0, 0.0], [-1, 0, 0], 'Spine', 'Cloth-bound, gold foil lettering'],
  [[0.15, 0.7, 0.03], [0, 1, 0], 'Page edges', 'Deckled edge, 320 pages']];
document.getElementById('list').innerHTML = notes.map(([, , title, text]) => `<li><b>${title}</b>: ${text}</li>`).join('');
const pins = notes.map((_, i) => {
  const pin = document.createElement('div'); pin.className = 'pin'; pin.textContent = i + 1;
  document.getElementById('wrap').append(pin); return pin;
});
// The cover, painted with the 2D canvas API.
const art = document.createElement('canvas'); art.width = 256; art.height = 360;
const g = art.getContext('2d');
const sky = g.createLinearGradient(0, 0, 0, 360); sky.addColorStop(0, '#16324f'); sky.addColorStop(1, '#e8a33c');
g.fillStyle = sky; g.fillRect(0, 0, 256, 360); g.fillStyle = '#0d1b2a'; g.fillRect(0, 270, 256, 90);
g.fillStyle = '#f6e7c8'; g.beginPath(); g.arc(170, 120, 34, 0, 7); g.fill();
g.font = 'bold 30px Georgia'; g.textAlign = 'center'; g.fillText('The Quiet', 128, 312); g.fillText('Harbor', 128, 344);
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aUV;
uniform mat4 uMVP, uModel; out vec3 vNormal; out vec2 vUV; out float vFace;
void main() { vNormal = mat3(uModel) * aNormal; vUV = aUV; vFace = aNormal.z;
  gl_Position = uMVP * vec4(aPosition, 1); }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform sampler2D uCover; in vec3 vNormal; in vec2 vUV; in float vFace; out vec4 c;
void main() {
  vec3 blue = vec3(0.1, 0.2, 0.32), paper = vec3(0.86, 0.8, 0.68) * (0.9 + 0.1 * step(0.5, fract(vUV.y * 60.0)));   // page lines
  vec3 base = vFace > 0.5 ? texture(uCover, vUV).rgb : vFace < -0.5 || vUV.x < 0.0 ? blue : paper;   // back and spine blue
  float light = 0.45 + 0.55 * max(dot(normalize(vNormal), normalize(vec3(0.4, 0.7, 0.8))), 0.0);
  c = vec4(base * light, 1); }`]]) {
  const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
// A book: 1.0 x 1.4 x 0.28 box; the spine face (-x) gets a negative u so the shader paints it cover-blue.
const size = [0.5, 0.7, 0.14], data = [];
for (const [n, u, v] of [[[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]]])
  for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
    data.push(...[0, 1, 2].map((i) => (n[i] + a * u[i] + b * v[i]) * size[i]), ...n, n[0] < 0 ? -1 : (a + 1) / 2, (b + 1) / 2);
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
[[0, 3, 0], [1, 3, 12], [2, 2, 24]].forEach(([loc, n, off]) => { gl.vertexAttribPointer(loc, n, gl.FLOAT, false, 32, off); gl.enableVertexAttribArray(loc); });
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, art);
gl.generateMipmap(gl.TEXTURE_2D);
gl.enable(gl.DEPTH_TEST);
const mul = (p, q) => { const o = 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++) o[c * 4 + r] += p[k * 4 + r] * q[c * 4 + k]; return o; };
const f = 1 / Math.tan(0.4), aspect = 600 / 320;
const proj = [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, -1.02, -1, 0, 0, -0.2, 0];
const view = [1, 0, 0, 0, 0, 0.94, 0.34, 0, 0, -0.34, 0.94, 0, 0, -0.05, -2.6, 1];   // camera at (0, 0.93, 2.43), looking down
function frame(now) {
  const t = 0.2 + now / 8000, c = Math.cos(t), s = Math.sin(t);                     // slow turntable spin
  const model = [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1], mvp = mul(proj, mul(view, model));
  gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uMVP'), false, mvp);
  gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uModel'), false, model);
  gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.drawArrays(gl.TRIANGLES, 0, 36);
  notes.forEach(([p, n], i) => {                                  // project each annotation point to the page
    const clip = [0, 1, 2, 3].map((r) => mvp[r] * p[0] + mvp[4 + r] * p[1] + mvp[8 + r] * p[2] + mvp[12 + r]);
    const turn = (v) => [c * v[0] + s * v[2], v[1], -s * v[0] + c * v[2]], q = turn(p), m = turn(n);
    const facing = [0, 0.93, 2.43].reduce((sum, eye, k) => sum + (eye - q[k]) * m[k], 0) > 0.05;   // faces the camera?
    Object.assign(pins[i].style, { left: (clip[0] / clip[3] * 0.5 + 0.5) * 100 + '%', top: (0.5 - clip[1] / clip[3] * 0.5) * 100 + '%' });
    pins[i].classList.toggle('behind', !facing);
  });
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>