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:
Amp
2026-08-24 10:51:26 +00:00
co-authored by heaust
parent df8b69e09d
commit 951449db0b
99 changed files with 4004 additions and 376 deletions
+220
View File
@@ -0,0 +1,220 @@
type RowFormat = "f32" | "u32" | "i32";
type RowDescriptor = {
name: string;
rows: number;
stride: number;
format: RowFormat;
};
const views = {
f32: Float32Array,
u32: Uint32Array,
i32: Int32Array,
};
export class SharedRows {
readonly name: string;
rows: number;
readonly stride: number;
readonly format: RowFormat;
buffer: SharedArrayBuffer;
constructor({ name, rows, stride, format }: RowDescriptor) {
this.name = name;
this.rows = rows;
this.stride = stride;
this.format = format;
this.buffer = new SharedArrayBuffer(rows * stride);
}
resize(rows: number) {
if (rows <= this.rows) return this;
const previous = this.view;
this.rows = rows;
this.buffer = new SharedArrayBuffer(rows * this.stride);
this.view.set(previous);
return this;
}
get view() {
const View = views[this.format];
return new View(this.buffer);
}
row(index: number) {
if (!Number.isInteger(index) || index < 0 || index >= this.rows)
throw new RangeError(`ROW_RANGE: ${this.name}[${index}]`);
const width = this.stride / views[this.format].BYTES_PER_ELEMENT;
return this.view.subarray(index * width, (index + 1) * width);
}
write(index: number, values: ArrayLike<number>) {
const row = this.row(index);
if (values.length !== row.length) throw new RangeError("ROW_WIDTH");
row.set(values);
return this;
}
}
function deserialize(serialized: string) {
let offset = 0;
const skip = () => {
while (/\s/u.test(serialized[offset] ?? "")) offset++;
};
const take = (character: string) => {
skip();
if (serialized[offset] !== character)
throw new SyntaxError(`Expected ${character} at ${offset}`);
offset++;
};
const word = () => {
skip();
const start = offset;
while (offset < serialized.length && !/[\s()]/u.test(serialized[offset]))
offset++;
return serialized.slice(start, offset);
};
const value = (): any => {
skip();
if (serialized[offset] === '"') {
const start = offset++;
while (offset < serialized.length) {
if (serialized[offset] === "\\") offset += 2;
else if (serialized[offset++] === '"') break;
}
return JSON.parse(serialized.slice(start, offset));
}
if (serialized[offset] !== "(") {
const token = word();
if (token === "true") return true;
if (token === "false") return false;
if (token === "null") return null;
const number = Number(token);
if (!Number.isNaN(number)) return number;
throw new SyntaxError(`Unknown value ${token}`);
}
take("(");
const tag = word();
if (tag === "array") {
const result = [];
skip();
while (serialized[offset] !== ")") result.push(value());
take(")");
return result;
}
if (tag === "object") {
const result: Record<string, unknown> = {};
skip();
while (serialized[offset] !== ")") {
take("(");
if (word() !== "field") throw new SyntaxError("Expected field");
const key = value();
result[key] = value();
take(")");
skip();
}
take(")");
return result;
}
if (tag === "yawn-graph") {
if (word() !== "1") throw new SyntaxError("Graph version");
const result = value();
take(")");
return result;
}
throw new SyntaxError(`Unknown list ${tag}`);
};
return value();
}
export class YawnCore {
readonly ready = Promise.resolve();
readonly graphs: any[] = [];
readonly serializedGraphs: string[] = [];
readonly loadouts: string[] = [];
readonly uploadedTextures: { name: string; mipLevel: number }[] = [];
fps?: number;
disposed = false;
#arrays = new Map<string, SharedRows>();
#nextObjects = new Map<string, number>();
constructor() {
this.#arrays.set(
"signals",
new SharedRows({ name: "signals", rows: 1, stride: 32, format: "f32" }),
);
}
createRows(descriptor: RowDescriptor) {
const current = this.#arrays.get(descriptor.name);
if (current) {
if (
current.stride !== descriptor.stride ||
current.format !== descriptor.format
)
throw new Error(`ROW_LAYOUT: ${descriptor.name}`);
return Promise.resolve(current.resize(descriptor.rows));
}
const rows = new SharedRows(descriptor);
this.#arrays.set(descriptor.name, rows);
return Promise.resolve(rows);
}
createRowsBatch(descriptors: RowDescriptor[]) {
return Promise.all(
descriptors.map((descriptor) => this.createRows(descriptor)),
);
}
deleteRows(name: string) {
this.#arrays.delete(name);
return Promise.resolve();
}
allocateObject(name: string) {
const id = this.#nextObjects.get(name) ?? 0;
this.#nextObjects.set(name, id + 1);
const rows = this.array(name);
rows.resize(id + 1);
return Promise.resolve(id);
}
deleteObject() {
return Promise.resolve();
}
array(name: string) {
const rows = this.#arrays.get(name);
if (!rows) throw new Error(`UNKNOWN_ARRAY: ${name}`);
return rows;
}
compileGraph(serialized: string) {
this.serializedGraphs.push(serialized);
this.graphs.push(deserialize(serialized));
return Promise.resolve(`loadout-${this.graphs.length}`);
}
switchLoadout(id: string) {
this.loadouts.push(id);
return Promise.resolve();
}
setFps(fps: number) {
this.fps = fps;
return Promise.resolve();
}
uploadTexture(name: string, _image: unknown, mipLevel = 0) {
this.uploadedTextures.push({ name, mipLevel });
return Promise.resolve();
}
deleteTexture() {
return Promise.resolve();
}
dispose() {
this.disposed = true;
}
}
+247
View File
@@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Mesh } from "../src/Mesh";
import { Node } from "../src/Node";
import { ArcRotateCamera } from "../src/camera/ArcRotateCamera";
import { PointLight } from "../src/lights/PointLight";
import { PBRMaterial } from "../src/materials/PBRMaterial";
import type { Texture } from "../src/materials/Texture";
import { createScene, testCore, triangle } from "./scene-helpers";
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
assert.equal(actual.length, expected.length);
for (let index = 0; index < actual.length; index++)
assert.ok(
Math.abs(actual[index] - expected[index]) < 1e-5,
`${actual[index]} != ${expected[index]} at ${index}`,
);
}
test("PBRMaterial packs factors, extensions, texture pointers, and UV transforms", async () => {
const scene = await createScene();
const textures = Array.from(
{ length: 13 },
(_, id) => ({ id, ready: Promise.resolve() }) as unknown as Texture,
);
const material = new PBRMaterial(scene, {
baseColor: [0.1, 0.2, 0.3, 0.4],
metallic: 0.5,
roughness: 0.6,
emissive: [0.7, 0.8, 0.9],
normalScale: 0.75,
alphaCutoff: 0.25,
flags: 6,
baseColorTexture: textures[0],
metallicRoughnessTexture: textures[1],
normalTexture: textures[2],
emissiveTexture: textures[3],
occlusionTexture: textures[4],
transmissionTexture: textures[5],
thicknessTexture: textures[6],
clearcoatTexture: textures[7],
iridescenceTexture: textures[8],
iridescenceThicknessTexture: textures[9],
anisotropyTexture: textures[10],
specularTexture: textures[11],
specularColorTexture: textures[12],
transmission: 0.8,
ior: 1.6,
thickness: 0.4,
attenuationDistance: 3,
attenuationColor: [0.1, 0.2, 0.3],
emissiveStrength: 4,
clearcoat: 0.5,
clearcoatRoughness: 0.6,
specular: 0.7,
occlusionStrength: 0.8,
sheenColor: [0.9, 0.1, 0.2],
sheenRoughness: 0.3,
iridescence: 0.4,
iridescenceIor: 1.4,
iridescenceThicknessMinimum: 120,
iridescenceThicknessMaximum: 380,
anisotropy: 0.5,
anisotropyRotation: 0.6,
specularExtensionColor: [0.7, 0.8, 0.9],
textureInfos: [
{
offset: [0.1, 0.2],
scale: [0.3, 0.4],
rotation: 0.5,
texCoord: 1,
},
],
});
await material.ready;
close(
scene.array("materials").row(material.id),
[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.75, 0.25, 6],
);
assert.deepEqual(
Array.from(scene.array("materialTextures").row(material.id)),
[...Array.from({ length: 13 }, (_, index) => index + 1), 0, 0, 0],
);
close(
scene.array("materialExtensions").row(material.id),
[
0.8, 1.6, 0.4, 3, 0.1, 0.2, 0.3, 4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.1, 0.2,
0.3, 0.4, 1.4, 120, 380, 0.5, 0.6, 0, 0, 0.7, 0.8, 0.9, 0,
],
);
const transforms = scene.array("materialTextureInfo").row(material.id);
close(transforms.subarray(0, 8), [
0.1,
0.2,
0.3,
0.4,
Math.sin(0.5),
Math.cos(0.5),
1,
0,
]);
close(transforms.subarray(8, 16), [0, 0, 1, 1, 0, 1, 0, 0]);
});
test("hot material setters write shared rows without rebuilding the graph", async () => {
const scene = await createScene();
const material = new PBRMaterial(scene);
await material.ready;
const core = testCore(scene);
const compiled = core.graphs.length;
core.array("signals").row(0).fill(0);
material.baseColor = [0.2, 0.3, 0.4, 0.5];
material.metallic = 0.6;
material.roughness = 0.7;
material.emissive = [0.8, 0.9, 1];
close(
scene.array("materials").row(material.id),
[0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1, 0.5, 0],
);
assert.equal(core.array("signals").row(0)[5], 1);
assert.equal(core.graphs.length, compiled);
});
test("Mesh packs geometry and bounds, then clones with copy-on-write", async () => {
const scene = await createScene();
const firstMaterial = new PBRMaterial(scene);
const secondMaterial = new PBRMaterial(scene, { baseColor: [1, 0, 0, 1] });
await Promise.all([firstMaterial.ready, secondMaterial.ready]);
const mesh = new Mesh(scene, {
material: firstMaterial,
vertexData: triangle,
});
await mesh.ready;
assert.equal(mesh.vertexCount, 3);
assert.equal(mesh.indexCount, 3);
close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions);
close(scene.array("bounds").row(mesh.id), [-1, -1, 0, 0, 1, 1, 0, 0]);
const clone = mesh.clone({ position: [2, 0, 0] });
await clone.ready;
assert.equal(clone.geometryId, mesh.geometryId);
await clone.setVertexData("positions", [0, 0, 0, 2, 0, 0, 0, 3, 0]);
assert.notEqual(clone.geometryId, mesh.geometryId);
close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions);
close(
scene.geometryData(clone.geometryId, "positions")!,
[0, 0, 0, 2, 0, 0, 0, 3, 0],
);
clone.material = secondMaterial;
await clone.setMaterialForFaces(firstMaterial, 0);
assert.equal(clone.materialId, secondMaterial.id);
assert.equal(clone.faceMaterials.get(0), firstMaterial.id);
assert.equal(
scene.array(`mesh.${clone.id}.faceMaterials`).row(0)[0],
firstMaterial.id + 1,
);
});
test("Node, ArcRotateCamera, and light handles update their shared rows", async () => {
const scene = await createScene();
const target = new Node(scene, { position: [1, 2, 3], scale: [2, 3, 4] });
await target.ready;
target.translate([1, -1, 2]).rotateZ(Math.PI);
close(target.position, [2, 1, 5]);
close(target.scale, [2, 3, 4]);
close(target.rotor, [0, 0, 1, 0]);
const child = new Node(scene, { parent: target });
await child.ready;
assert.equal(child.parentId, target.id);
child.enabled = false;
assert.equal(scene.array("nodes").row(child.id)[0], 0);
const camera = new ArcRotateCamera(scene, {
target,
alpha: 0,
beta: Math.PI / 2,
radius: 5,
});
await camera.ready;
close(camera.position, [2, 1, 10]);
assert.equal(scene.array("cameraMatrices").row(camera.cameraId)[19], 1);
camera.radius = 3;
close(camera.position, [2, 1, 8]);
const light = new PointLight(scene, {
position: [4, 5, 6],
color: [0.2, 0.4, 0.6],
intensity: 7,
range: 8,
});
await light.ready;
close(
scene.array("pointLights").row(light.id),
[0.2, 0.4, 0.6, 7, 8, 1, 0, 0],
);
light.intensity = 9;
light.range = 10;
close(scene.array("pointLights").row(light.id).subarray(3, 5), [9, 10]);
});
test("ArcRotateCamera pans one projected world pixel per pointer pixel", async () => {
const listeners = new Map<string, Set<(event: any) => void>>();
const element = {
clientWidth: 400,
clientHeight: 200,
addEventListener(type: string, listener: (event: any) => void) {
const registered = listeners.get(type) ?? new Set();
registered.add(listener);
listeners.set(type, registered);
},
removeEventListener(type: string, listener: (event: any) => void) {
listeners.get(type)?.delete(listener);
},
setPointerCapture() {},
} as unknown as HTMLElement;
const dispatch = (type: string, event: Record<string, unknown>) => {
for (const listener of listeners.get(type) ?? [])
listener({ preventDefault() {}, ...event });
};
const scene = await createScene();
const camera = new ArcRotateCamera(scene, {
alpha: 0,
beta: Math.PI / 2,
radius: 10,
fov: Math.PI / 2,
aspect: 2,
controls: { element },
});
await camera.ready;
dispatch("pointerdown", {
button: 2,
clientX: 100,
clientY: 100,
pointerId: 1,
});
dispatch("pointermove", { clientX: 120, clientY: 110 });
close(camera.position, [-2, 1, 10]);
camera.detachControls();
});
+145
View File
@@ -0,0 +1,145 @@
import assert from "node:assert/strict";
import test from "node:test";
import { importGltf } from "../src/importers/gltf";
import { createScene, latestGraph, testCore } from "./scene-helpers";
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
assert.equal(actual.length, expected.length);
for (let index = 0; index < actual.length; index++)
assert.ok(Math.abs(actual[index] - expected[index]) < 1e-5);
}
test("importGltf hydrates worker output into one batched scene update", async () => {
const previousWorker = globalThis.Worker;
const previousImageBitmap = globalThis.ImageBitmap;
const previousCreateImageBitmap = globalThis.createImageBitmap;
class MockImageBitmap {
readonly width = 1;
readonly height = 1;
}
const workerResult = {
camera: { direction: [0, 0, 1], fov: 1.1 },
textures: [
{
image: new MockImageBitmap(),
sampler: {
magFilter: "linear",
minFilter: "linear",
mipmapFilter: "linear",
addressModeU: "repeat",
addressModeV: "repeat",
},
},
],
materials: [
{
baseColor: [0.2, 0.4, 0.6, 1],
metallic: 0.3,
roughness: 0.7,
flags: 0,
baseColorTexture: 0,
metallicRoughnessTexture: -1,
normalTexture: -1,
emissiveTexture: -1,
occlusionTexture: -1,
transmissionTexture: -1,
thicknessTexture: -1,
clearcoatTexture: -1,
iridescenceTexture: -1,
iridescenceThicknessTexture: -1,
anisotropyTexture: -1,
specularTexture: -1,
specularColorTexture: -1,
},
],
primitives: [
{
positions: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]),
uvs: new Float32Array([0, 0, 1, 0, 0, 1]),
indices: new Uint32Array([0, 1, 2]),
material: 0,
position: [2, 3, 4],
rotor: [0, 0, 0, 1],
scale: [1, 1, 1],
},
],
lights: [
{
type: "point",
color: [1, 0.5, 0.25],
intensity: 6,
range: 12,
position: [4, 5, 6],
},
],
};
class MockWorker {
#listeners = new Map<string, (event: any) => void>();
addEventListener(type: string, listener: (event: any) => void) {
this.#listeners.set(type, listener);
}
postMessage(message: { request: number }) {
queueMicrotask(() =>
this.#listeners.get("message")?.({
data: { request: message.request, result: workerResult },
}),
);
}
}
Object.defineProperties(globalThis, {
Worker: { configurable: true, value: MockWorker },
ImageBitmap: { configurable: true, value: MockImageBitmap },
createImageBitmap: {
configurable: true,
value: async () => new MockImageBitmap(),
},
});
try {
const scene = await createScene();
const core = testCore(scene);
const compiled = core.graphs.length;
const meshes = await importGltf(scene, "https://example.test/model.glb");
assert.equal(meshes.length, 1);
assert.deepEqual(meshes.camera, workerResult.camera);
assert.deepEqual(meshes[0].position.toArray(), [2, 3, 4]);
assert.deepEqual(
Array.from(scene.geometryData(meshes[0].geometryId, "indices")!),
[0, 1, 2],
);
close(
scene.array("materials").row(meshes[0].materialId).slice(0, 6),
[0.2, 0.4, 0.6, 1, 0.3, 0.7],
);
assert.equal(
scene.array("materialTextures").row(meshes[0].materialId)[0],
1,
);
assert.deepEqual(
Array.from(scene.array("pointLights").row(1)),
[1, 0.5, 0.25, 6, 12, 1, 0, 0],
);
const texture = latestGraph(scene).resources.textures.find(
(resource: any) => resource.id.startsWith("texture-"),
);
assert.equal(texture.format, "rgba8unorm-srgb");
assert.equal(core.uploadedTextures.length, 1);
assert.equal(core.graphs.length - compiled, 1);
} finally {
for (const [name, previous] of [
["Worker", previousWorker],
["ImageBitmap", previousImageBitmap],
["createImageBitmap", previousCreateImageBitmap],
] as const)
if (previous === undefined) delete (globalThis as any)[name];
else
Object.defineProperty(globalThis, name, {
configurable: true,
value: previous,
});
}
});
+359
View File
@@ -0,0 +1,359 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadGltf } from "../src/importers/worker";
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
assert.equal(actual.length, expected.length);
for (let index = 0; index < actual.length; index++)
assert.ok(
Math.abs(actual[index] - expected[index]) < 1e-5,
`${actual[index]} != ${expected[index]} at ${index}`,
);
}
function dataUrl(type: string, value: Uint8Array | string) {
return `data:${type};base64,${Buffer.from(value).toString("base64")}`;
}
function binaryBuilder() {
let bytes = new Uint8Array();
return {
append(source: ArrayBufferView, alignment: number) {
const input = new Uint8Array(
source.buffer,
source.byteOffset,
source.byteLength,
);
const offset = Math.ceil(bytes.length / alignment) * alignment;
const next = new Uint8Array(offset + input.length);
next.set(bytes);
next.set(input, offset);
bytes = next;
return { byteOffset: offset, byteLength: input.length };
},
get bytes() {
return bytes;
},
};
}
function glb(document: object, binary: Uint8Array) {
const json = new TextEncoder().encode(JSON.stringify(document));
const jsonLength = Math.ceil(json.length / 4) * 4;
const binaryLength = Math.ceil(binary.length / 4) * 4;
const result = new Uint8Array(12 + 8 + jsonLength + 8 + binaryLength);
const view = new DataView(result.buffer);
view.setUint32(0, 0x46546c67, true);
view.setUint32(4, 2, true);
view.setUint32(8, result.length, true);
view.setUint32(12, jsonLength, true);
view.setUint32(16, 0x4e4f534a, true);
result.fill(0x20, 20, 20 + jsonLength);
result.set(json, 20);
const binaryHeader = 20 + jsonLength;
view.setUint32(binaryHeader, binaryLength, true);
view.setUint32(binaryHeader + 4, 0x004e4942, true);
result.set(binary, binaryHeader + 8);
return result;
}
test("JSON glTF parsing covers accessors, transforms, materials, lights, and cameras", async () => {
const binary = binaryBuilder();
const positions = binary.append(
new Float32Array([0, 0, 0, 99, 1, 0, 0, 99, 0, 1, 0, 99]),
4,
);
const uvs = binary.append(new Uint16Array([0, 0, 65_535, 0, 0, 65_535]), 2);
const colors = binary.append(
new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255]),
1,
);
const indices = binary.append(new Uint16Array([0, 1, 2]), 2);
const sparseIndices = binary.append(new Uint8Array([1]), 1);
const sparsePositions = binary.append(new Float32Array([2, 0, 0]), 4);
const texture = { index: 0 };
const document = {
asset: { version: "2.0" },
extensions: {
KHR_lights_punctual: {
lights: [
{
type: "spot",
color: [0.2, 0.4, 0.6],
intensity: 7,
range: 8,
spot: { innerConeAngle: 0.1, outerConeAngle: 0.5 },
},
],
},
},
buffers: [
{
byteLength: binary.bytes.length,
uri: dataUrl("application/octet-stream", binary.bytes),
},
],
bufferViews: [
{ buffer: 0, ...positions, byteStride: 16 },
{ buffer: 0, ...uvs },
{ buffer: 0, ...colors },
{ buffer: 0, ...indices },
{ buffer: 0, ...sparseIndices },
{ buffer: 0, ...sparsePositions },
],
accessors: [
{
bufferView: 0,
componentType: 5126,
count: 3,
type: "VEC3",
sparse: {
count: 1,
indices: { bufferView: 4, componentType: 5121 },
values: { bufferView: 5 },
},
},
{
bufferView: 1,
componentType: 5123,
normalized: true,
count: 3,
type: "VEC2",
},
{
bufferView: 2,
componentType: 5121,
normalized: true,
count: 3,
type: "VEC3",
},
{
bufferView: 3,
componentType: 5123,
count: 3,
type: "SCALAR",
},
],
images: [
{
uri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
],
samplers: [
{ magFilter: 9728, minFilter: 9984, wrapS: 33071, wrapT: 33648 },
],
textures: [{ source: 0, sampler: 0 }],
materials: [
{
alphaMode: "BLEND",
doubleSided: true,
normalTexture: { ...texture, scale: 0.75 },
emissiveTexture: texture,
emissiveFactor: [0.4, 0.5, 0.6],
occlusionTexture: { ...texture, strength: 0.7 },
extensions: {
KHR_materials_unlit: {},
KHR_materials_pbrSpecularGlossiness: {
diffuseFactor: [0.1, 0.2, 0.3, 0.4],
specularFactor: [0.5, 0.6, 0.7],
glossinessFactor: 0.75,
diffuseTexture: {
...texture,
extensions: {
KHR_texture_transform: {
offset: [0.1, 0.2],
scale: [0.3, 0.4],
rotation: 0.5,
texCoord: 1,
},
},
},
specularGlossinessTexture: texture,
},
KHR_materials_transmission: {
transmissionFactor: 0.8,
transmissionTexture: texture,
},
KHR_materials_volume: {
thicknessFactor: 0.9,
thicknessTexture: texture,
attenuationDistance: 3,
attenuationColor: [0.2, 0.3, 0.4],
},
KHR_materials_clearcoat: {
clearcoatFactor: 0.45,
clearcoatRoughnessFactor: 0.25,
clearcoatTexture: texture,
},
KHR_materials_sheen: {
sheenColorFactor: [0.3, 0.2, 0.1],
sheenRoughnessFactor: 0.35,
},
KHR_materials_specular: {
specularFactor: 0.55,
specularColorFactor: [0.7, 0.8, 0.9],
specularTexture: texture,
specularColorTexture: texture,
},
KHR_materials_iridescence: {
iridescenceFactor: 0.65,
iridescenceIor: 1.4,
iridescenceThicknessMinimum: 120,
iridescenceThicknessMaximum: 360,
iridescenceTexture: texture,
iridescenceThicknessTexture: texture,
},
KHR_materials_anisotropy: {
anisotropyStrength: 0.75,
anisotropyRotation: 0.15,
anisotropyTexture: texture,
},
KHR_materials_emissive_strength: { emissiveStrength: 4 },
KHR_materials_ior: { ior: 1.6 },
},
},
],
meshes: [
{
primitives: [
{
attributes: {
POSITION: 0,
TEXCOORD_0: 1,
TEXCOORD_1: 1,
COLOR_0: 2,
},
indices: 3,
material: 0,
},
],
},
],
cameras: [{ type: "perspective", perspective: { yfov: 1.1 } }],
nodes: [
{
mesh: 0,
camera: 0,
translation: [5, 0, 0],
scale: [-1, 1, 1],
extensions: { KHR_lights_punctual: { light: 0 } },
},
],
};
const previousCreateImageBitmap = globalThis.createImageBitmap;
Object.defineProperty(globalThis, "createImageBitmap", {
configurable: true,
value: async () => ({ width: 1, height: 1 }),
});
try {
const result = await loadGltf(
dataUrl("model/gltf+json", JSON.stringify(document)),
);
assert.equal(result.primitives.length, 1);
const primitive = result.primitives[0];
close(primitive.positions, [5, 0, 0, 3, 0, 0, 5, 1, 0]);
assert.deepEqual(Array.from(primitive.indices), [0, 2, 1]);
close(primitive.normals, [0, 0, 1, 0, 0, 1, 0, 0, 1]);
close(primitive.uvs, [0, 0, 1, 0, 0, 1]);
close(primitive.uvs2, [0, 0, 1, 0, 0, 1]);
close(primitive.colors, [1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1]);
assert.equal(primitive.tangents.length, 12);
assert.equal(Array.from(primitive.tangents).every(Number.isFinite), true);
const material = result.materials[0];
close(material.baseColor, [0.1, 0.2, 0.3, 0.4]);
assert.equal(material.metallic, 0);
assert.equal(material.roughness, 0.25);
assert.equal(material.flags, 30);
assert.equal(material.transmission, 0.8);
assert.equal(material.ior, 1.6);
assert.equal(material.thickness, 0.9);
assert.equal(material.attenuationDistance, 3);
close(material.attenuationColor, [0.2, 0.3, 0.4]);
assert.equal(material.emissiveStrength, 4);
assert.equal(material.clearcoat, 0.45);
assert.equal(material.clearcoatRoughness, 0.25);
assert.equal(material.specular, 0.55);
close(material.specularColor, [0.5, 0.6, 0.7]);
close(material.specularExtensionColor, [0.7, 0.8, 0.9]);
assert.equal(material.iridescence, 0.65);
assert.equal(material.anisotropy, 0.75);
assert.deepEqual(
[
material.baseColorTexture,
material.metallicRoughnessTexture,
material.normalTexture,
material.emissiveTexture,
material.occlusionTexture,
material.transmissionTexture,
material.thicknessTexture,
material.clearcoatTexture,
material.iridescenceTexture,
material.iridescenceThicknessTexture,
material.anisotropyTexture,
material.specularTexture,
material.specularColorTexture,
],
Array(13).fill(0),
);
assert.deepEqual(material.textureInfos[0], {
texture: 0,
texCoord: 1,
offset: [0.1, 0.2],
scale: [0.3, 0.4],
rotation: 0.5,
});
assert.equal(result.textures.length, 1);
assert.deepEqual(result.textures[0].sampler, {
magFilter: "nearest",
minFilter: "nearest",
mipmapFilter: "nearest",
addressModeU: "clamp-to-edge",
addressModeV: "mirror-repeat",
});
assert.deepEqual(result.camera, { direction: [0, 0, 1], fov: 1.1 });
assert.equal(result.lights.length, 1);
const { rotor, ...light } = result.lights[0];
assert.deepEqual(light, {
type: "spot",
color: [0.2, 0.4, 0.6],
intensity: 7,
range: 8,
innerAngle: 0.1,
outerAngle: 0.5,
position: [5, 0, 0],
});
close(rotor, [0, 0, 0, 1]);
} finally {
if (previousCreateImageBitmap)
Object.defineProperty(globalThis, "createImageBitmap", {
configurable: true,
value: previousCreateImageBitmap,
});
else delete (globalThis as any).createImageBitmap;
}
});
test("binary GLB parsing consumes JSON and BIN chunks", async () => {
const binary = new Uint8Array(
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer,
);
const document = {
asset: { version: "2.0" },
buffers: [{ byteLength: binary.length }],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: binary.length }],
accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
};
const result = await loadGltf(
dataUrl("model/gltf-binary", glb(document, binary)),
);
assert.equal(result.primitives.length, 1);
close(result.primitives[0].positions, [0, 0, 0, 1, 0, 0, 0, 1, 0]);
close(result.primitives[0].normals, [0, 0, 1, 0, 0, 1, 0, 0, 1]);
assert.deepEqual(Array.from(result.primitives[0].indices), [0, 1, 2]);
assert.deepEqual(result.materials, []);
assert.deepEqual(result.textures, []);
});
+46
View File
@@ -0,0 +1,46 @@
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { build } from "esbuild";
const directory = fileURLToPath(new URL(".", import.meta.url));
const mock = join(directory, "core-mock.ts");
const output = await mkdtemp(join(tmpdir(), "yawn-handles-tests-"));
try {
const entries = (await readdir(directory))
.filter((name) => name.endsWith(".test.ts"))
.sort()
.map((name) => join(directory, name));
await build({
entryPoints: entries,
outdir: output,
entryNames: "[name]",
outExtension: { ".js": ".mjs" },
bundle: true,
format: "esm",
platform: "node",
target: "node20",
sourcemap: "inline",
plugins: [
{
name: "mock-yawn-core",
setup(build) {
build.onResolve({ filter: /^@yawn\/core$/ }, () => ({ path: mock }));
},
},
],
});
const tests = entries.map((entry) =>
join(output, `${basename(entry, ".ts")}.mjs`),
);
const result = spawnSync(process.execPath, ["--test", ...tests], {
stdio: "inherit",
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
} finally {
await rm(output, { force: true, recursive: true });
}
+275
View File
@@ -0,0 +1,275 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Mesh } from "../src/Mesh";
import { FXAA } from "../src/PostProcesses/FXAA";
import { PBRMaterial } from "../src/materials/PBRMaterial";
import { Texture } from "../src/materials/Texture";
import {
createScene,
detailedMesh,
forwardPasses,
latestGraph,
pbrPipelines,
renderPipeline,
testCore,
triangle,
} from "./scene-helpers";
if (typeof globalThis.ImageBitmap === "undefined")
Object.defineProperty(globalThis, "ImageBitmap", {
value: class ImageBitmap {},
});
test("empty scenes clear without drawing placeholder geometry", async () => {
const scene = await createScene();
const emptyPass = latestGraph(scene).passes.find(
(pass: any) => pass.id === "forward-empty",
);
assert.equal(emptyPass.draw.vertices, 0);
});
test("opaque PBR graph fuses the background and compacts sparse lights", async () => {
const scene = await createScene();
const material = new PBRMaterial(scene);
await material.ready;
const mesh = detailedMesh(scene, material);
await mesh.ready;
const graph = latestGraph(scene);
const pipeline = pbrPipelines(graph)[0];
const forward = forwardPasses(graph)[0];
const present = renderPipeline(graph, "present");
const clusters = graph.pipelines.compute.find(
(candidate: any) => candidate.id === "cluster-lights",
);
assert.equal(pipeline.primitive.frontFace, "ccw");
assert.equal(
graph.passes.some((pass: any) => pass.id === "studio-background"),
false,
);
assert.match(present.code, /fn studioBackground\(/u);
assert.deepEqual(forward.color[0].clear, [0, 0, 0, 0]);
assert.match(pipeline.code, /@interpolate\(flat\) material: u32/u);
assert.match(pipeline.code, /materials\[input\.material \* 3u\]/u);
assert.match(clusters.code, /clusters\[11u \+ pointCount\] = i/u);
assert.match(clusters.code, /clusters\[512u \+ spotCount\] = i/u);
assert.match(pipeline.code, /let pointCount = clusters\[10\]/u);
assert.match(pipeline.code, /pointCount <= 500u/u);
assert.match(pipeline.code, /arrayLength\(&pointLights\) \/ 2u/u);
assert.match(pipeline.code, /let spotCount = clusters\[511\]/u);
assert.match(pipeline.code, /spotCount <= 500u/u);
});
test("materials with identical shader features share one pipeline", async () => {
const scene = await createScene();
const firstMaterial = new PBRMaterial(scene, { roughness: 0.2 });
const secondMaterial = new PBRMaterial(scene, { roughness: 0.8 });
await Promise.all([firstMaterial.ready, secondMaterial.ready]);
const firstMesh = detailedMesh(scene, firstMaterial);
const secondMesh = detailedMesh(scene, secondMaterial);
await Promise.all([firstMesh.ready, secondMesh.ready]);
const graph = latestGraph(scene);
const passes = forwardPasses(graph);
assert.equal(pbrPipelines(graph).length, 1);
assert.equal(passes.length, 2);
assert.deepEqual(
passes.map((pass: any) => pass.draw.firstInstance & 0xffff),
[firstMesh.id, secondMesh.id],
);
assert.deepEqual(
passes.map((pass: any) => (pass.draw.firstInstance >>> 16) - 1),
[firstMaterial.id, secondMaterial.id],
);
});
test("transmission renders directly into a composited layer with five taps", async () => {
const scene = await createScene();
const material = new PBRMaterial(scene, {
transmission: 0.9,
thickness: 0.25,
attenuationDistance: 2,
});
await material.ready;
const mesh = detailedMesh(scene, material);
await mesh.ready;
const graph = latestGraph(scene);
const pipeline = pbrPipelines(graph)[0];
const present = renderPipeline(graph, "present");
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
assert.equal(
graph.passes.some((pass: any) => pass.id === "studio-background"),
true,
);
assert.equal(
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
false,
);
assert.equal(
presentPass.bindings.some(
(binding: any) => binding.resource === "scene-color",
),
true,
);
assert.match(present.code, /textureSample\(scene, sourceSampler/u);
assert.equal(
pipeline.code.match(/textureSampleLevel\(sceneColor/g)?.length,
5,
);
assert.match(pipeline.code, /transmitted \*= pow/u);
});
test("post-processing keeps the conservative transmission copy", async () => {
const scene = await createScene();
const material = new PBRMaterial(scene, { transmission: 1 });
await material.ready;
const mesh = detailedMesh(scene, material);
await mesh.ready;
const effect = new FXAA(scene);
await effect.ready;
const graph = latestGraph(scene);
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
assert.equal(
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
true,
);
assert.equal(
presentPass.bindings.some(
(binding: any) => binding.resource === "scene-color",
),
false,
);
assert.equal(
graph.passes.some((pass: any) => pass.pipeline.startsWith("post-fxaa-")),
true,
);
});
test("PBR generation eliminates inactive features and specializes active ones", async () => {
const scene = await createScene();
const textures = Array.from(
{ length: 13 },
() => new Texture(scene, { size: [1, 1, 1] }),
);
await Promise.all(textures.map((texture) => texture.ready));
const textureOptions = {
baseColorTexture: textures[0],
metallicRoughnessTexture: textures[1],
normalTexture: textures[2],
emissiveTexture: textures[3],
occlusionTexture: textures[4],
transmissionTexture: textures[5],
thicknessTexture: textures[6],
clearcoatTexture: textures[7],
iridescenceTexture: textures[8],
iridescenceThicknessTexture: textures[9],
anisotropyTexture: textures[10],
specularTexture: textures[11],
specularColorTexture: textures[12],
};
const active = new PBRMaterial(scene, {
...textureOptions,
transmission: 1,
thickness: 0.5,
attenuationDistance: 2,
clearcoat: 0.7,
sheenColor: [0.2, 0.3, 0.4],
iridescence: 0.8,
anisotropy: 0.6,
textureInfos: Array.from({ length: 13 }, (_, slot) => ({
texCoord: slot === 1 ? 1 : 0,
})),
});
const unlit = new PBRMaterial(scene, { ...textureOptions, flags: 8 });
await Promise.all([active.ready, unlit.ready]);
const activeMesh = detailedMesh(scene, active);
const unlitMesh = detailedMesh(scene, unlit);
await Promise.all([activeMesh.ready, unlitMesh.ready]);
const pipelines = pbrPipelines(latestGraph(scene));
const activeCode = pipelines.find((pipeline: any) =>
pipeline.code.includes("transmissionAmount"),
).code;
const unlitCode = pipelines.find((pipeline: any) =>
pipeline.code.includes("color = base.rgb + emissive"),
).code;
for (const binding of [
"baseTexture",
"materialTexture",
"normalTexture",
"emissiveTexture",
"occlusionTexture",
"transmissionTexture",
"thicknessTexture",
"clearcoatTexture",
"iridescenceTexture",
"iridescenceThicknessTexture",
"anisotropyTexture",
"specularTexture",
"specularColorTexture",
])
assert.match(activeCode, new RegExp(`var ${binding}: texture_2d`));
assert.match(
activeCode,
/textureSample\(baseTexture, materialSampler, input\.uv\)/u,
);
assert.match(
activeCode,
/textureSample\(materialTexture, materialSampler, input\.uv2\)/u,
);
assert.match(activeCode, /let anisotropyAmount/u);
assert.match(activeCode, /let iridescenceAmount/u);
assert.match(activeCode, /let clearcoatFresnel/u);
assert.match(activeCode, /let sheen =/u);
assert.match(unlitCode, /var baseTexture: texture_2d/u);
assert.match(unlitCode, /var emissiveTexture: texture_2d/u);
assert.doesNotMatch(unlitCode, /var normalTexture: texture_2d/u);
assert.doesNotMatch(unlitCode, /var clearcoatTexture: texture_2d/u);
assert.doesNotMatch(unlitCode, /let clearcoatFresnel/u);
});
test("alpha blending uses source-over and disables depth writes", async () => {
const scene = await createScene();
const material = new PBRMaterial(scene, { flags: 2 });
await material.ready;
const mesh = detailedMesh(scene, material);
await mesh.ready;
const pipeline = pbrPipelines(latestGraph(scene))[0];
assert.deepEqual(pipeline.fragment.targets[0].blend, {
color: {
srcFactor: "src-alpha",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
alpha: {
srcFactor: "one",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
});
assert.equal(pipeline.depthStencil.depth_write_enabled, false);
});
test("batchGraphUpdates compiles once for multiple mesh registrations", async () => {
const scene = await createScene();
const material = new PBRMaterial(scene);
await material.ready;
const before = testCore(scene).graphs.length;
await scene.batchGraphUpdates(async () => {
const first = new Mesh(scene, { material, vertexData: triangle });
const second = new Mesh(scene, { material, vertexData: triangle });
await Promise.all([first.ready, second.ready]);
});
assert.equal(testCore(scene).graphs.length - before, 1);
assert.equal(forwardPasses(latestGraph(scene)).length, 2);
});
+53
View File
@@ -0,0 +1,53 @@
import { Mesh } from "../src/Mesh";
import { Scene } from "../src/Scene";
import type { PBRMaterial } from "../src/materials/PBRMaterial";
import type { YawnCore } from "./core-mock";
export const triangle = {
positions: [-1, -1, 0, 1, -1, 0, 0, 1, 0],
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1],
tangents: [1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1],
uvs: [0, 0, 1, 0, 0.5, 1],
uvs2: [0.1, 0.1, 0.9, 0.1, 0.5, 0.9],
colors: [1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1],
indices: [0, 1, 2],
};
export async function createScene() {
const scene = new Scene({} as HTMLCanvasElement);
await scene.ready;
return scene;
}
export function testCore(scene: Scene) {
return scene.core as unknown as YawnCore;
}
export function latestGraph(scene: Scene): any {
return testCore(scene).graphs.at(-1);
}
export function renderPipeline(graph: any, id: string) {
return graph.pipelines.render.find((pipeline: any) => pipeline.id === id);
}
export function pbrPipelines(graph: any) {
return graph.pipelines.render.filter((pipeline: any) =>
pipeline.id.startsWith("forward-pbr-"),
);
}
export function forwardPasses(graph: any) {
return graph.passes.filter(
(pass: any) =>
pass.type === "render" && pass.pipeline.startsWith("forward-"),
);
}
export function detailedMesh(
scene: Scene,
material: PBRMaterial,
vertexData: Record<string, ArrayLike<number>> = triangle,
) {
return new Mesh(scene, { material, vertexData });
}
+18
View File
@@ -0,0 +1,18 @@
# glTF screenshot tests
`npm run test:handles:screenshots` renders the viewer's empty state and every
glTF-Binary model in the pinned Khronos glTF Sample Models revision. The suite
uses headed Chromium on a private Xvfb display and SwiftShader WebGPU at a fixed
1000×720 viewport. It checks the viewer state, browser diagnostics, and a
250×180 downsample against each reviewed reference image.
The first run downloads roughly 215 MB of GLBs into
`~/.cache/yawn/gltf-sample-models`. Set `YAWN_GLTF_FIXTURES` to use another
cache directory, or `YAWN_SCREENSHOT_MODELS=Box,BoomBox` to run a subset.
Failed full-size captures and visual diffs are written to `output/`.
To intentionally replace references after reviewing full-size renders, run:
```sh
UPDATE_SCREENSHOTS=1 npm run test:handles:screenshots
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+391
View File
@@ -0,0 +1,391 @@
import assert from "node:assert/strict";
import { execFile, spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import { createServer } from "node:net";
import {
access,
copyFile,
mkdir,
mkdtemp,
open,
readFile,
readdir,
rename,
rm,
stat,
} from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { after, before, test } from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execute = promisify(execFile);
const directory = dirname(fileURLToPath(import.meta.url));
const root = resolve(directory, "../../../..");
const baselines = join(directory, "baselines");
const output = resolve(
process.env.YAWN_SCREENSHOT_OUTPUT ?? join(directory, "output"),
);
const revision = "d7a3cc8e51d7c573771ae77a57f16b0662a905c6";
const fixtureDirectory = resolve(
process.env.YAWN_GLTF_FIXTURES ??
join(homedir(), ".cache", "yawn", "gltf-sample-models", revision),
);
const selected = new Set(
(process.env.YAWN_SCREENSHOT_MODELS ?? "")
.split(",")
.map((name) => name.trim())
.filter(Boolean),
);
const update = process.env.UPDATE_SCREENSHOTS === "1";
const session = `yawn-gltf-screenshots-${process.pid}`;
const temporary = await mkdtemp(join(tmpdir(), "yawn-gltf-screenshots-"));
const imageSize = { width: 250, height: 180 };
const allModels = (await readdir(baselines))
.filter((name) => name.endsWith(".png") && name !== "initial.png")
.map((name) => basename(name, ".png"))
.sort()
.map((name) => ({
name,
source: name === "XmpMetadataRoundedCube" ? "xmp.glb" : `${name}.glb`,
path: join(fixtureDirectory, `${name}.glb`),
}));
const models = allModels.filter(
({ name }) => selected.size === 0 || selected.has(name),
);
let serverProcess;
let serverOutput = "";
let url = process.env.YAWN_VISUAL_URL?.replace(/\/+$/u, "");
function run(name, args, options = {}) {
return execute(name, args, {
cwd: root,
maxBuffer: 10 * 1024 * 1024,
timeout: 120_000,
...options,
});
}
async function browser(...args) {
const { stdout } = await run(
process.env.AGENT_BROWSER_BIN ?? "agent-browser",
["--session", session, "--json", ...args],
);
const result = JSON.parse(stdout.trim());
if (!result.success) throw new Error(result.error ?? "agent-browser failed");
return result.data;
}
async function availablePort() {
const server = createServer();
await new Promise((accept, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", accept);
});
const address = server.address();
assert(address && typeof address !== "string");
await new Promise((accept, reject) =>
server.close((error) => (error ? reject(error) : accept())),
);
return address.port;
}
async function startServer() {
if (url) return;
const binary = join(temporary, "yawn-server");
await run("go", ["build", "-o", binary, "./server"]);
const port = await availablePort();
serverProcess = spawn(binary, [], {
cwd: root,
env: {
...process.env,
PORT: String(port),
YAWN_DATABASE: join(temporary, "yawn.db"),
},
stdio: ["ignore", "pipe", "pipe"],
});
const record = (chunk) => {
serverOutput = `${serverOutput}${chunk}`.slice(-20_000);
};
serverProcess.stdout.on("data", record);
serverProcess.stderr.on("data", record);
url = `http://localhost:${port}`;
const deadline = Date.now() + 600_000;
while (Date.now() < deadline) {
if (serverProcess.exitCode !== null)
throw new Error(`Yawn server exited early:\n${serverOutput}`);
try {
if ((await fetch(`${url}/healthz`)).status === 204) return;
} catch {
// The package build happens before the server begins listening.
}
await new Promise((accept) => setTimeout(accept, 250));
}
throw new Error(`Yawn server did not become ready:\n${serverOutput}`);
}
async function validGlb(path) {
try {
const info = await stat(path);
if (info.size < 12) return false;
const file = await open(path, "r");
try {
const header = Buffer.alloc(12);
await file.read(header, 0, header.length, 0);
return (
header.toString("ascii", 0, 4) === "glTF" &&
header.readUInt32LE(4) === 2 &&
header.readUInt32LE(8) === info.size
);
} finally {
await file.close();
}
} catch {
return false;
}
}
async function download(model) {
if (await validGlb(model.path)) return;
await mkdir(fixtureDirectory, { recursive: true });
const part = `${model.path}.${process.pid}.part`;
const source = new URL(
`${encodeURIComponent(model.name)}/glTF-Binary/${encodeURIComponent(model.source)}`,
`https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/${revision}/2.0/`,
);
process.stdout.write(`Downloading ${model.name}…\n`);
const response = await fetch(source);
if (!response.ok || !response.body)
throw new Error(`Could not download ${source}: HTTP ${response.status}`);
try {
await pipeline(Readable.fromWeb(response.body), createWriteStream(part));
} catch (error) {
await rm(part, { force: true });
throw error;
}
if (!(await validGlb(part))) {
await rm(part, { force: true });
throw new Error(`${model.name} did not download as a valid GLB`);
}
await rename(part, model.path);
}
async function prepareModels() {
let next = 0;
await Promise.all(
Array.from({ length: Math.min(4, models.length) }, async () => {
while (next < models.length) await download(models[next++]);
}),
);
}
async function imageBytes(path) {
const raw = join(temporary, `${basename(path)}-${Math.random()}.rgb`);
await run("magick", [
path,
"-filter",
"box",
"-resize",
`${imageSize.width}x${imageSize.height}!`,
"-depth",
"8",
`rgb:${raw}`,
]);
const bytes = await readFile(raw);
await rm(raw, { force: true });
return bytes;
}
async function compareScreenshot(name, actual) {
const expected = join(baselines, `${name}.png`);
if (update) {
await run("magick", [
actual,
"-filter",
"box",
"-resize",
`${imageSize.width}x${imageSize.height}!`,
expected,
]);
return;
}
await access(expected);
const [actualBytes, expectedBytes] = await Promise.all([
imageBytes(actual),
imageBytes(expected),
]);
assert.equal(actualBytes.length, expectedBytes.length);
let absoluteError = 0;
let differentPixels = 0;
for (let index = 0; index < actualBytes.length; index += 3) {
let squaredError = 0;
for (let channel = 0; channel < 3; channel++) {
const difference = Math.abs(
actualBytes[index + channel] - expectedBytes[index + channel],
);
absoluteError += difference;
squaredError += difference * difference;
}
if (Math.sqrt(squaredError / 3) / 255 > 0.1) differentPixels++;
}
const meanError = absoluteError / actualBytes.length / 255;
const differentRatio = differentPixels / (actualBytes.length / 3);
if (meanError <= 0.01 && differentRatio <= 0.02) return;
await mkdir(output, { recursive: true });
const saved = join(output, `${name}-actual.png`);
const difference = join(output, `${name}-diff.png`);
await copyFile(actual, saved);
await run("magick", [
expected,
"(",
actual,
"-filter",
"box",
"-resize",
`${imageSize.width}x${imageSize.height}!`,
")",
"-compose",
"difference",
"-composite",
"-auto-level",
"-filter",
"point",
"-resize",
"640x460",
difference,
]);
assert.fail(
`${name} differs from its reference: mean error ${(meanError * 100).toFixed(2)}%, ` +
`${(differentRatio * 100).toFixed(2)}% changed pixels; see ${saved}`,
);
}
async function capture(name) {
const path = join(temporary, `${name}.png`);
await browser("screenshot", path);
await compareScreenshot(name, path);
}
async function diagnostics() {
const { errors } = await browser("errors");
const { messages } = await browser("console");
assert.deepEqual(errors, []);
assert.deepEqual(messages, []);
await browser("errors", "--clear");
await browser("console", "--clear");
}
async function saveFailure(name) {
try {
await mkdir(output, { recursive: true });
await browser("screenshot", join(output, `${name}-failure.png`));
} catch {
// Preserve the original test failure if the browser cannot capture.
}
}
before(
async () => {
assert.equal(
allModels.length,
72,
"expected all 72 reviewed GLB baselines",
);
assert.equal(
selected.size === 0 || models.length === selected.size,
true,
"YAWN_SCREENSHOT_MODELS contains an unknown model",
);
await prepareModels();
await startServer();
await browser("--webgpu", "--headed", "open", `${url}/view/gltf`);
await browser("set", "viewport", "1000", "720", "1");
},
{ timeout: 900_000 },
);
after(async () => {
try {
await browser("close");
} catch {
// The browser may already be gone after an initialization failure.
}
if (serverProcess && serverProcess.exitCode === null) serverProcess.kill();
await rm(temporary, { force: true, recursive: true });
});
test("viewer starts with the local-file prompt", async () => {
const state = await browser(
"eval",
`({
isolated: crossOriginIsolated,
webgpu: Boolean(navigator.gpu),
promptHidden: document.querySelector("#drop-prompt").hidden,
prompt: document.querySelector("#prompt-message").textContent,
renderHidden: document.querySelector("#render-time").hidden
})`,
);
assert.deepEqual(state.result, {
isolated: true,
webgpu: true,
promptHidden: false,
prompt: "Drop a glTF file",
renderHidden: true,
});
await diagnostics();
await capture("initial");
});
for (const model of models) {
test(
`renders ${model.name}`,
async () => {
try {
await browser("errors", "--clear");
await browser("console", "--clear");
await browser(
"eval",
`(() => {
window.__yawnScreenshotReady = false;
const prompt = document.querySelector("#drop-prompt");
const observer = new MutationObserver(() => {
if (prompt.hidden) {
window.__yawnScreenshotReady = true;
observer.disconnect();
}
});
observer.observe(prompt, { attributes: true, attributeFilter: ["hidden"] });
})()`,
);
await browser("upload", "#file-picker", model.path);
await browser("wait", "--fn", "window.__yawnScreenshotReady === true");
await browser("wait", "1200");
const state = await browser(
"eval",
`({
promptHidden: document.querySelector("#drop-prompt").hidden,
renderHidden: document.querySelector("#render-time").hidden,
render: document.querySelector("#render-value").textContent
})`,
);
assert.equal(state.result.promptHidden, true);
assert.equal(state.result.renderHidden, false);
assert.match(state.result.render, /^(?:—|\d+(?:\.\d+)?) ms$/u);
await diagnostics();
await capture(model.name);
} catch (error) {
await saveFailure(model.name);
throw error;
}
},
{ timeout: 120_000 },
);
}