import { AmbientLight, ArcRotateCamera, PointLight, Scene, importGltf, } from "@yawn/handles"; const elements = { canvas: document.getElementById("canvas"), detail: document.getElementById("prompt-detail"), dropZone: document.getElementById("drop-zone"), filePicker: document.getElementById("file-picker"), message: document.getElementById("prompt-message"), prompt: document.getElementById("drop-prompt"), renderTime: document.getElementById("render-time"), renderValue: document.getElementById("render-value"), }; let activeViewer; let loading = false; function setPrompt(message, detail, error = false) { elements.message.textContent = message; elements.detail.textContent = detail; elements.prompt.classList.toggle("is-error", error); elements.prompt.hidden = false; } function resetPrompt() { setPrompt( "Drop a glTF file", "GLB or self-contained glTF · processed locally", ); } function replaceCanvas() { const canvas = elements.canvas.cloneNode(); const scale = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.round(elements.dropZone.clientWidth * scale)); canvas.height = Math.max( 1, Math.round(elements.dropZone.clientHeight * scale), ); elements.canvas.replaceWith(canvas); elements.canvas = canvas; return canvas; } function disposeViewer() { if (!activeViewer) return; cancelAnimationFrame(activeViewer.dirtyFrame); activeViewer.stopProfile?.(); activeViewer.camera?.detachControls(); activeViewer.scene.dispose(); activeViewer = undefined; } function markDirtyEveryFrame(viewer) { viewer.signals[5] = 1; viewer.dirtyFrame = requestAnimationFrame(() => markDirtyEveryFrame(viewer)); } function rotate([rx, ry, rz, rw], [x, y, z]) { const tx = 2 * (ry * z - rz * y); const ty = 2 * (rz * x - rx * z); const tz = 2 * (rx * y - ry * x); return [ x + rw * tx + ry * tz - rz * ty, y + rw * ty + rz * tx - rx * tz, z + rw * tz + rx * ty - ry * tx, ]; } function findBounds(scene, meshes) { const minimum = [Infinity, Infinity, Infinity]; const maximum = [-Infinity, -Infinity, -Infinity]; for (const mesh of meshes) { const bounds = scene.array("bounds").row(mesh.id); const position = Array.from(mesh.position); const rotor = Array.from(mesh.rotor); const scale = Array.from(mesh.scale); for (const x of [bounds[0], bounds[4]]) for (const y of [bounds[1], bounds[5]]) for (const z of [bounds[2], bounds[6]]) { const corner = rotate(rotor, [ x * scale[0], y * scale[1], z * scale[2], ]).map((value, lane) => value + position[lane]); for (let lane = 0; lane < 3; lane++) { minimum[lane] = Math.min(minimum[lane], corner[lane]); maximum[lane] = Math.max(maximum[lane], corner[lane]); } } } if (![...minimum, ...maximum].every(Number.isFinite)) throw new Error("The file does not contain renderable mesh bounds."); return { center: minimum.map((value, lane) => (value + maximum[lane]) / 2), extents: maximum.map((value, lane) => value - minimum[lane]), radius: Math.max( 0.01, Math.hypot(...maximum.map((value, lane) => value - minimum[lane])) / 2, ), }; } function cameraOptions(scene, meshes, canvas, bounds) { const aspect = canvas.width / canvas.height; const verticalFov = meshes.camera?.fov ?? 0.8; const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect); let alpha = 5.0861; let beta = 1.5454; if (meshes.camera?.direction) { const [x, y, z] = meshes.camera.direction; alpha = Math.atan2(x, z); beta = Math.acos(Math.max(-1, Math.min(1, y))); } else if (bounds.extents[2] < bounds.extents[0] * 0.75) { alpha = 0; beta = 1.45; } else if (bounds.extents[0] < bounds.extents[2] * 0.75) { alpha = Math.PI / 2; beta = 1.45; } const cameraDirection = [ Math.sin(beta) * Math.sin(alpha), Math.cos(beta), Math.sin(beta) * Math.cos(alpha), ]; const right = [Math.cos(alpha), 0, -Math.sin(alpha)]; const up = [ -Math.cos(beta) * Math.sin(alpha), Math.sin(beta), -Math.cos(beta) * Math.cos(alpha), ]; const tangentX = Math.tan(horizontalFov / 2); const tangentY = Math.tan(verticalFov / 2); let distance = 0; for (const mesh of meshes) { const positions = scene.geometryData(mesh.geometryId, "positions"); const position = Array.from(mesh.position); const rotor = Array.from(mesh.rotor); const scale = Array.from(mesh.scale); for (let index = 0; index < positions.length; index += 3) { const point = rotate(rotor, [ positions[index] * scale[0], positions[index + 1] * scale[1], positions[index + 2] * scale[2], ]).map((value, lane) => value + position[lane] - bounds.center[lane]); const towardCamera = point.reduce( (value, lane, index) => value + lane * cameraDirection[index], 0, ); const horizontal = Math.abs( point.reduce((value, lane, index) => value + lane * right[index], 0), ); const vertical = Math.abs( point.reduce((value, lane, index) => value + lane * up[index], 0), ); distance = Math.max( distance, towardCamera + horizontal / tangentX, towardCamera + vertical / tangentY, ); } } distance = Math.max(0.01, distance * 1.08); return { targetPosition: bounds.center, alpha, beta, radius: distance, fov: verticalFov, near: Math.max(0.001, (distance - bounds.radius) * 0.25), far: Math.max(1000, distance + bounds.radius * 10), aspect, controls: { element: canvas, pointer: true }, }; } function isGltf(file) { return /\.(?:glb|gltf)$/iu.test(file.name); } async function openFile(file) { if (loading) return; if (!file || !isGltf(file)) { setPrompt("That file is not glTF", "Drop a .glb or .gltf file.", true); return; } if (!crossOriginIsolated) { setPrompt( "Cross-origin isolation is unavailable", "This viewer needs COOP and COEP response headers.", true, ); return; } if (!navigator.gpu) { setPrompt( "WebGPU is unavailable", "Open this page in a browser with WebGPU support.", true, ); return; } loading = true; disposeViewer(); elements.renderTime.hidden = true; setPrompt("Opening model…", `${file.name} stays on this device.`); const url = URL.createObjectURL(file); let scene; try { const canvas = replaceCanvas(); scene = new Scene(canvas, { arenaBytes: 384 * 1024 * 1024, hdr: true, directToCanvas: true, clearColor: [0.025, 0.035, 0.045, 1], }); await scene.ready; const meshes = await importGltf(scene, url); if (!meshes.length) throw new Error("The file does not contain any triangle meshes."); const bounds = findBounds(scene, meshes); const radius = Math.max(bounds.radius, 0.001); const lights = [ new AmbientLight(scene, { color: [0.82, 0.9, 1], intensity: 0.22, }), new PointLight(scene, { position: [ bounds.center[0] + radius * 2.4, bounds.center[1] + radius * 3.1, bounds.center[2] + radius * 2.2, ], color: [1, 0.78, 0.56], intensity: 300, range: radius * 8, }), new PointLight(scene, { position: [ bounds.center[0] - radius * 2.1, bounds.center[1] + radius * 0.8, bounds.center[2] - radius * 1.8, ], color: [0.45, 0.72, 1], intensity: 120, range: radius * 7, }), ]; const camera = new ArcRotateCamera( scene, cameraOptions(scene, meshes, canvas, bounds), ); await Promise.all([...lights.map((light) => light.ready), camera.ready]); // Diagnostic A/B: omit queries and readback, not just the timing badge. let stopProfile; if (new URLSearchParams(location.search).get("profile") !== "off") { elements.renderValue.textContent = "— ms"; elements.renderTime.hidden = false; stopProfile = scene.core.onProfile((profile) => { elements.renderValue.textContent = `${profile.milliseconds.toFixed(2)} ms`; }); const profiling = await scene.core.setProfiler(true); if (!profiling) elements.renderTime.title = "GPU timestamp queries are not supported by this adapter."; else elements.renderTime.removeAttribute("title"); } activeViewer = { camera, scene, stopProfile, signals: scene.core.array("signals").row(0), }; markDirtyEveryFrame(activeViewer); resetPrompt(); elements.prompt.hidden = true; } catch (error) { scene?.dispose(); const message = error instanceof Error ? error.message : String(error); setPrompt("Could not open this model", message, true); } finally { URL.revokeObjectURL(url); loading = false; } } elements.prompt.addEventListener("click", () => { if (!loading) elements.filePicker.click(); }); elements.filePicker.addEventListener("change", () => { const [file] = elements.filePicker.files; void openFile(file); elements.filePicker.value = ""; }); for (const type of ["dragenter", "dragover"]) { elements.dropZone.addEventListener(type, (event) => { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; elements.dropZone.classList.add("is-dragging"); }); } elements.dropZone.addEventListener("dragleave", (event) => { if (!elements.dropZone.contains(event.relatedTarget)) elements.dropZone.classList.remove("is-dragging"); }); elements.dropZone.addEventListener("drop", (event) => { event.preventDefault(); elements.dropZone.classList.remove("is-dragging"); const [file] = event.dataTransfer?.files ?? []; void openFile(file); }); addEventListener("beforeunload", disposeViewer); resetPrompt();