Files
tinygltf/web/main.js
Syoyo Fujita a040827363 Add Emscripten WASM build and three.js web demo
web/ compiles the v3 C runtime with emcc (Emscripten SDK from
~/work/emsdk by default) and ships a simple browser demo that loads
glTF/GLB files via drag & drop and renders them with three.js:

- loader.c: C bridge exporting flattened primitives, materials,
  textures (bufferView + data-URI paths) and the scene graph to JS
- main.js/index.html/style.css: three.js viewer (OrbitControls,
  PBR materials, textures, node hierarchy, primitive modes)
- gen_sample.py: generates a small self-contained textured Cube.glb
  sample from tracked assets (no large binaries committed)
- Makefile: emcc build (MODULARIZE + ES6, no filesystem), sample
  generation and local http server targets

Verified end-to-end: native bridge tests, Node harness exercising the
WASM exports (parse, geometry, images, clear) and HTTP serving.
2026-07-31 21:42:31 +09:00

310 lines
11 KiB
JavaScript

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js';
import createTinyGLTF from './tinygltf_v3.js';
const statusEl = document.getElementById('status');
const dropZone = document.getElementById('drop-zone');
const viewerEl = document.getElementById('viewer');
const fileInput = document.getElementById('file-input');
const sampleBtn = document.getElementById('sample-btn');
function setStatus(msg) {
statusEl.textContent = msg;
statusEl.title = msg;
}
let Module = null;
let renderer = null;
let scene = null;
let camera = null;
let controls = null;
/* ---------------------------------------------------------------- */
/* Scene construction */
/* ---------------------------------------------------------------- */
// tg3w_prim layout in bytes: material, mode, vertex_count, index_count,
// pos_offset, nrm_offset, uv_offset, idx_offset (all 4-byte ints).
function readPrim(i) {
const p = Module._tg3w_prim(i);
const get = (off) => Module.getValue(p + off, 'i32');
return {
material: get(0),
mode: get(4),
vertexCount: get(8),
indexCount: get(12),
posOffset: get(16),
nrmOffset: get(20),
uvOffset: get(24),
idxOffset: get(28),
};
}
function copyF32(offsetFloats, count) {
if (!offsetFloats || !count) return null;
return Module.HEAPF32.subarray(offsetFloats, offsetFloats + count).slice();
}
function makeTexture(texIdx) {
const src = Module._tg3w_texture_source(texIdx);
if (src < 0) return null;
let blobPromise = null;
const bv = Module._tg3w_image_buffer_view(src);
if (bv >= 0) {
const ptr = Module._tg3w_image_bytes(src);
const size = Module._tg3w_image_size(src);
if (!ptr || !size) return null;
const bytes = Module.HEAPU8.slice(ptr, ptr + size);
const mime = Module.UTF8ToString(Module._tg3w_image_mime(src)) || 'image/png';
blobPromise = Promise.resolve(new Blob([bytes], { type: mime }));
} else {
const uriPtr = Module._tg3w_image_uri(src);
if (!uriPtr) return null;
const uri = Module.UTF8ToString(uriPtr);
const m = /^data:([^;,]+)(;base64)?,(.*)$/s.exec(uri);
if (!m) return null; // external file URI — not resolvable without FS
const bytes = m[2]
? Uint8Array.from(atob(m[3]), (c) => c.charCodeAt(0))
: new TextEncoder().encode(decodeURIComponent(m[3]));
blobPromise = Promise.resolve(new Blob([bytes], { type: m[1] }));
}
return blobPromise.then((blob) => createImageBitmap(blob)).then((bmp) => {
const tex = new THREE.CanvasTexture(bmp);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
});
}
function makeMaterial(mi) {
const color = copyF32(Module._tg3w_material_base_color(mi) / 4, 4);
const metalness = Module._tg3w_material_metallic(mi);
const roughness = Module._tg3w_material_roughness(mi);
const alphaMode = Module._tg3w_material_alpha_mode(mi);
const alphaCutoff = Module._tg3w_material_alpha_cutoff(mi);
const doubleSided = Module._tg3w_material_double_sided(mi);
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color(color[0], color[1], color[2]),
metalness,
roughness,
transparent: alphaMode === 2,
alphaTest: alphaMode === 1 ? alphaCutoff : 0,
side: doubleSided ? THREE.DoubleSide : THREE.FrontSide,
});
const texIdx = Module._tg3w_material_base_color_texture(mi);
if (texIdx >= 0) {
return makeTexture(texIdx).then((tex) => {
if (tex) mat.map = tex;
return mat;
});
}
return Promise.resolve(mat);
}
// Convert triangle strip/fan index buffers to plain triangles.
function triangulate(indices, mode) {
if (mode === 4) return indices;
const out = [];
const n = indices.length;
if (mode === 5) { // TRIANGLE_STRIP
for (let i = 2; i < n; i++) {
if (i % 2 === 0) out.push(indices[i - 2], indices[i - 1], indices[i]);
else out.push(indices[i - 1], indices[i - 2], indices[i]);
}
} else if (mode === 6) { // TRIANGLE_FAN
for (let i = 2; i < n; i++) out.push(indices[0], indices[i - 1], indices[i]);
}
return out;
}
function makePrimitive(i) {
const p = readPrim(i);
const positions = copyF32(p.posOffset, p.vertexCount * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
if (p.nrmOffset >= 0) {
geometry.setAttribute('normal', new THREE.BufferAttribute(copyF32(p.nrmOffset, p.vertexCount * 3), 3));
}
if (p.uvOffset >= 0) {
geometry.setAttribute('uv', new THREE.BufferAttribute(copyF32(p.uvOffset, p.vertexCount * 2), 2));
}
if (p.indexCount) {
const raw = Module.HEAPU32.subarray(p.idxOffset, p.idxOffset + p.indexCount).slice();
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(triangulate(raw, p.mode)), 1));
}
return makeMaterial(p.material).then((mat) => {
if (p.mode === 0) return new THREE.Points(geometry, mat);
if (p.mode === 1 || p.mode === 2 || p.mode === 3) {
const cls = p.mode === 1 ? THREE.Line : p.mode === 2 ? THREE.LineLoop : THREE.LineSegments;
return new cls(geometry, new THREE.LineBasicMaterial({ color: mat.color }));
}
return new THREE.Mesh(geometry, mat);
});
}
function buildNodeGraph(nodeIndices, parent) {
const jobs = [];
const visit = (nodeIdx, parentGroup) => {
const trs = copyF32(Module._tg3w_node_trs(nodeIdx) / 4, 10);
const group = new THREE.Group();
group.position.set(trs[0], trs[1], trs[2]);
group.quaternion.set(trs[3], trs[4], trs[5], trs[6]);
group.scale.set(trs[7], trs[8], trs[9]);
const meshIdx = Module._tg3w_node_mesh(nodeIdx);
if (meshIdx >= 0) {
const start = Module._tg3w_mesh_prim_start(meshIdx);
const count = Module._tg3w_mesh_prim_count(meshIdx);
for (let k = 0; k < count; k++) {
jobs.push(makePrimitive(start + k).then((mesh) => {
mesh.name = `mesh${meshIdx}[${k}]`;
group.add(mesh);
}));
}
}
const childCount = Module._tg3w_node_child_count(nodeIdx);
const childrenPtr = Module._tg3w_node_children(nodeIdx);
for (let c = 0; c < childCount; c++) {
const childIdx = Module.getValue(childrenPtr + c * 4, 'i32');
visit(childIdx, group);
}
parentGroup.add(group);
};
for (const n of nodeIndices) visit(n, parent);
return Promise.all(jobs).then(() => parent);
}
async function buildScene() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x14161a);
scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 1.0));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(5, 8, 6);
scene.add(dirLight);
const root = new THREE.Group();
scene.add(root);
const sceneCount = Module._tg3w_scene_count();
let nodes = [];
const defaultScene = Module._tg3w_default_scene();
if (defaultScene >= 0 && defaultScene < sceneCount) {
const n = Module._tg3w_scene_node_count(defaultScene);
const p = Module._tg3w_scene_nodes(defaultScene);
for (let i = 0; i < n; i++) nodes.push(Module.getValue(p + i * 4, 'i32'));
} else {
for (let i = 0; i < Module._tg3w_node_count(); i++) nodes.push(i);
}
await buildNodeGraph(nodes, root);
// Frame the camera on the geometry.
const box = new THREE.Box3().setFromObject(root);
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3()).length() || 1;
camera.position.copy(center).add(new THREE.Vector3(size, size * 0.8, size));
camera.lookAt(center);
controls.target.copy(center);
controls.update();
}
viewerEl.hidden = false;
dropZone.hidden = true;
setStatus(`Rendered: ${Module._tg3w_prim_count()} primitives, ${Module._tg3w_node_count()} nodes`);
}
/* ---------------------------------------------------------------- */
/* Loading */
/* ---------------------------------------------------------------- */
async function loadModel(bytes) {
setStatus('Parsing with tinygltf v3 (C/WASM)…');
const ptr = Module._malloc(bytes.length);
Module.HEAPU8.set(bytes, ptr);
const rc = Module._tg3w_parse(ptr, bytes.length);
Module._free(ptr);
if (rc !== 0) {
const first = Module.UTF8ToString(Module._tg3w_error_message(0));
const last = Module.UTF8ToString(Module._tg3w_last_error());
setStatus(`Parse failed (rc=${rc}): ${first || last}`);
return;
}
const warn = Module._tg3w_error_count();
await buildScene();
if (warn > 0) {
setStatus(`Loaded with ${warn} warning(s) — see console`);
for (let i = 0; i < warn; i++) {
console.warn(`[tg3] ${Module.UTF8ToString(Module._tg3w_error_message(i))}`);
}
}
}
async function handleFile(file) {
if (!file) return;
const buf = await file.arrayBuffer();
await loadModel(new Uint8Array(buf));
}
/* ---------------------------------------------------------------- */
/* Setup */
/* ---------------------------------------------------------------- */
async function init() {
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(viewerEl.clientWidth, viewerEl.clientHeight);
viewerEl.appendChild(renderer.domElement);
camera = new THREE.PerspectiveCamera(50, viewerEl.clientWidth / viewerEl.clientHeight, 0.01, 10000);
controls = new OrbitControls(camera, renderer.domElement);
window.addEventListener('resize', () => {
const w = viewerEl.clientWidth;
const h = viewerEl.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
renderer.setAnimationLoop(() => {
if (scene) renderer.render(scene, camera);
});
try {
Module = await createTinyGLTF();
} catch (e) {
setStatus(`Failed to load WASM: ${e}`);
return;
}
setStatus('WASM ready — drop a .glb/.gltf file');
fileInput.addEventListener('change', () => handleFile(fileInput.files[0]));
sampleBtn.addEventListener('click', async () => {
try {
const res = await fetch('Cube.glb');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadModel(new Uint8Array(await res.arrayBuffer()));
} catch (e) {
setStatus(`Sample load failed: ${e}`);
}
});
sampleBtn.hidden = false;
for (const ev of ['dragenter', 'dragover']) {
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
}
for (const ev of ['dragleave', 'drop']) {
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); });
}
dropZone.addEventListener('drop', (e) => {
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) handleFile(f);
});
}
init();