Render continuously and optimize the glTF viewer

Remove vsync and dirty-flag frame gating, optimize PBR sampling and lighting, add a direct-to-canvas loadout, and preserve camera state on viewer resize.

Add regression coverage and update rendering documentation.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b382-1e79-712b-abd1-d29201c9bb5e
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-09-18 08:58:27 +00:00
co-authored by heaust
parent bc957cf40d
commit 4784ad7e87
16 changed files with 573 additions and 178 deletions
+53 -17
View File
@@ -19,6 +19,7 @@ const elements = {
let activeViewer;
let loading = false;
let resizeTimer;
function setPrompt(message, detail, error = false) {
elements.message.textContent = message;
@@ -34,14 +35,17 @@ function resetPrompt() {
);
}
function canvasSize() {
const scale = Math.min(devicePixelRatio || 1, 2);
return [
Math.max(1, Math.round(elements.dropZone.clientWidth * scale)),
Math.max(1, Math.round(elements.dropZone.clientHeight * scale)),
];
}
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),
);
[canvas.width, canvas.height] = canvasSize();
elements.canvas.replaceWith(canvas);
elements.canvas = canvas;
return canvas;
@@ -49,18 +53,12 @@ function replaceCanvas() {
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);
@@ -189,7 +187,7 @@ function isGltf(file) {
return /\.(?:glb|gltf)$/iu.test(file.name);
}
async function openFile(file) {
async function openFile(file, cameraState) {
if (loading) return;
if (!file || !isGltf(file)) {
setPrompt("That file is not glTF", "Drop a .glb or .gltf file.", true);
@@ -224,6 +222,7 @@ async function openFile(file) {
scene = new Scene(canvas, {
arenaBytes: 384 * 1024 * 1024,
hdr: true,
directToCanvas: true,
clearColor: [0.025, 0.035, 0.045, 1],
});
await scene.ready;
@@ -262,7 +261,13 @@ async function openFile(file) {
];
const camera = new ArcRotateCamera(
scene,
cameraOptions(scene, meshes, canvas, bounds),
cameraState
? {
...cameraState,
aspect: canvas.width / canvas.height,
controls: { element: canvas, pointer: true },
}
: cameraOptions(scene, meshes, canvas, bounds),
);
await Promise.all([...lights.map((light) => light.ready), camera.ready]);
@@ -278,12 +283,11 @@ async function openFile(file) {
else elements.renderTime.removeAttribute("title");
activeViewer = {
file,
camera,
scene,
stopProfile,
signals: scene.core.array("signals").row(0),
};
markDirtyEveryFrame(activeViewer);
resetPrompt();
elements.prompt.hidden = true;
} catch (error) {
@@ -293,9 +297,37 @@ async function openFile(file) {
} finally {
URL.revokeObjectURL(url);
loading = false;
scheduleResize();
}
}
function scheduleResize() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (loading || !activeViewer) return;
const [width, height] = canvasSize();
if (elements.canvas.width === width && elements.canvas.height === height)
return;
const { file, camera, scene } = activeViewer;
const row = scene.core.array("cameras").row(camera.cameraId);
// Core's surface size is fixed at initialization. Recreate only after resizing settles,
// preserving orbit, pan, zoom and clipping rather than asking for the file again.
void openFile(file, {
alpha: camera.alpha,
beta: camera.beta,
radius: camera.radius,
targetPosition: Array.from(row.subarray(16, 19)),
fov: camera.fov,
near: camera.near,
far: camera.far,
});
}, 200);
}
const resizeObserver = new ResizeObserver(scheduleResize);
resizeObserver.observe(elements.dropZone);
addEventListener("resize", scheduleResize);
elements.prompt.addEventListener("click", () => {
if (!loading) elements.filePicker.click();
});
@@ -326,5 +358,9 @@ elements.dropZone.addEventListener("drop", (event) => {
void openFile(file);
});
addEventListener("beforeunload", disposeViewer);
addEventListener("beforeunload", () => {
clearTimeout(resizeTimer);
resizeObserver.disconnect();
disposeViewer();
});
resetPrompt();