Add local glTF viewer and visual coverage
Amp-Thread-ID: https://ampcode.com/threads/T-01a02d1c-27fc-7761-9f51-ed97b2dd4e3e Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
+3
-1
@@ -35,7 +35,7 @@ func newApplication(store *playgroundStore, packages map[string]memoryAsset, mon
|
||||
return nil, err
|
||||
}
|
||||
pages := make(map[string][]byte)
|
||||
for _, name := range []string{"home.html", "docs.html", "playground.html"} {
|
||||
for _, name := range []string{"home.html", "docs.html", "playground.html", "gltf.html"} {
|
||||
content, err := webFiles.ReadFile("web/" + name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -63,6 +63,8 @@ func (app *application) routes() http.Handler {
|
||||
mux.HandleFunc("/docs/", app.servePage("docs.html"))
|
||||
mux.HandleFunc("/playground", app.servePlayground)
|
||||
mux.HandleFunc("/playground/", app.servePlayground)
|
||||
mux.HandleFunc("/view/gltf", app.servePage("gltf.html"))
|
||||
mux.HandleFunc("/view/gltf/", app.servePage("gltf.html"))
|
||||
mux.HandleFunc("/", app.serveHome)
|
||||
return app.headers(mux)
|
||||
}
|
||||
|
||||
@@ -144,3 +144,24 @@ func TestSavedPlaygroundPageRoute(t *testing.T) {
|
||||
t.Fatalf("content type = %q", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGltfViewerPageRoute(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/view/gltf", nil)
|
||||
response := httptest.NewRecorder()
|
||||
testApplication(t).routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if contentType := response.Header().Get("Content-Type"); contentType != "text/html; charset=utf-8" {
|
||||
t.Fatalf("content type = %q", contentType)
|
||||
}
|
||||
for _, content := range []string{
|
||||
`"@yawn/core": "/pkg/core.js"`,
|
||||
`"@yawn/handles": "/pkg/handles.js"`,
|
||||
"Drop a glTF file",
|
||||
} {
|
||||
if !bytes.Contains(response.Body.Bytes(), []byte(content)) {
|
||||
t.Fatalf("viewer page does not contain %q", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
:root {
|
||||
color: #d7e9f8;
|
||||
background: #06111d;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
.viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer {
|
||||
position: relative;
|
||||
background: #06111d;
|
||||
}
|
||||
|
||||
.viewer::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid transparent;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
.viewer.is-dragging::after {
|
||||
border-color: #35a9ed;
|
||||
background: rgb(20 112 168 / 9%);
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.render-time {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
color: #718da2;
|
||||
border: 1px solid #23435a;
|
||||
border-radius: 7px;
|
||||
background: rgb(8 25 39 / 82%);
|
||||
box-shadow: 0 8px 24px rgb(0 8 15 / 18%);
|
||||
font:
|
||||
700 9px/1 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
monospace;
|
||||
letter-spacing: 0.08em;
|
||||
pointer-events: none;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.render-time[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.render-time strong {
|
||||
color: #8bd5fb;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.drop-prompt {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
min-width: min(360px, calc(100% - 36px));
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
padding: 28px 32px;
|
||||
transform: translate(-50%, -50%);
|
||||
color: inherit;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.drop-prompt:hover,
|
||||
.drop-prompt:focus-visible,
|
||||
.viewer.is-dragging .drop-prompt {
|
||||
border-color: #25577a;
|
||||
background: rgb(11 41 65 / 72%);
|
||||
box-shadow: 0 18px 50px rgb(0 10 18 / 22%);
|
||||
}
|
||||
|
||||
.drop-prompt[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.viewer.is-dragging .drop-prompt {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
display: grid;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 7px;
|
||||
place-items: center;
|
||||
color: #54bdf6;
|
||||
border: 1px solid #25577a;
|
||||
border-radius: 13px;
|
||||
background: #0b2941;
|
||||
box-shadow: 0 0 34px rgb(46 171 238 / 15%);
|
||||
font:
|
||||
700 19px/1 ui-monospace,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.drop-prompt strong {
|
||||
color: #c5d9e8;
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.drop-prompt small {
|
||||
color: #698297;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.drop-prompt.is-error .drop-icon {
|
||||
color: #f0a5a5;
|
||||
border-color: #6f3540;
|
||||
background: #351b27;
|
||||
box-shadow: 0 0 34px rgb(205 78 91 / 12%);
|
||||
}
|
||||
|
||||
.drop-prompt.is-error strong {
|
||||
color: #e8b5b5;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.render-time {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.drop-prompt {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
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();
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#06111d" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Open a local glTF or GLB file in the Yawn WebGPU viewer."
|
||||
/>
|
||||
<title>glTF Viewer — Yawn</title>
|
||||
<link rel="stylesheet" href="/assets/gltf.css" />
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@yawn/core": "/pkg/core.js",
|
||||
"@yawn/handles": "/pkg/handles.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/assets/gltf.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="drop-zone" class="viewer" aria-label="Yawn glTF viewer">
|
||||
<canvas id="canvas" aria-label="3D model preview"></canvas>
|
||||
|
||||
<output id="render-time" class="render-time" hidden>
|
||||
<span>Render</span>
|
||||
<strong id="render-value">— ms</strong>
|
||||
</output>
|
||||
|
||||
<button id="drop-prompt" class="drop-prompt" type="button">
|
||||
<span class="drop-icon" aria-hidden="true">↓</span>
|
||||
<strong id="prompt-message">Drop a glTF file</strong>
|
||||
<small id="prompt-detail"
|
||||
>GLB or self-contained glTF · processed locally</small
|
||||
>
|
||||
</button>
|
||||
<input
|
||||
id="file-picker"
|
||||
type="file"
|
||||
accept=".glb,.gltf,model/gltf-binary,model/gltf+json"
|
||||
hidden
|
||||
/>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user