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>
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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 },
|
||||
);
|
||||
}
|
||||