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>
367 lines
11 KiB
JavaScript
367 lines
11 KiB
JavaScript
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;
|
|
let resizeTimer;
|
|
|
|
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 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();
|
|
[canvas.width, canvas.height] = canvasSize();
|
|
elements.canvas.replaceWith(canvas);
|
|
elements.canvas = canvas;
|
|
return canvas;
|
|
}
|
|
|
|
function disposeViewer() {
|
|
if (!activeViewer) return;
|
|
activeViewer.stopProfile?.();
|
|
activeViewer.camera?.detachControls();
|
|
activeViewer.scene.dispose();
|
|
activeViewer = undefined;
|
|
}
|
|
|
|
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, cameraState) {
|
|
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,
|
|
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]);
|
|
|
|
elements.renderValue.textContent = "— ms";
|
|
elements.renderTime.hidden = false;
|
|
const 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 = {
|
|
file,
|
|
camera,
|
|
scene,
|
|
stopProfile,
|
|
};
|
|
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;
|
|
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();
|
|
});
|
|
|
|
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", () => {
|
|
clearTimeout(resizeTimer);
|
|
resizeObserver.disconnect();
|
|
disposeViewer();
|
|
});
|
|
resetPrompt();
|