Amp-Thread-ID: https://ampcode.com/threads/T-01a02d1c-27fc-7761-9f51-ed97b2dd4e3e Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
310 lines
9.0 KiB
JavaScript
310 lines
9.0 KiB
JavaScript
import {
|
|
AmbientLight,
|
|
ArcRotateCamera,
|
|
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,
|
|
});
|
|
await scene.ready;
|
|
scene.array("sceneAccent").row(0).set([1, 1, 1, 1]);
|
|
|
|
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 light = new AmbientLight(scene, {
|
|
color: [1, 0.94, 0.86],
|
|
intensity: 0.28,
|
|
});
|
|
const camera = new ArcRotateCamera(
|
|
scene,
|
|
cameraOptions(scene, meshes, canvas, bounds),
|
|
);
|
|
await Promise.all([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");
|
|
scene.array("sceneAccent").row(0)[3] = 1;
|
|
}
|
|
|
|
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();
|