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 { bucketDraws, createScene, latestGraph, testCore, triangle, } from "./scene-helpers"; function close(actual: ArrayLike, expected: ArrayLike) { 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]); // Vertex 1 lives in the shared interleaved array: position, normal, uv, uv2, tangent, colour. const geometry = scene.geometry(mesh.geometryId); close( scene.array("vertices").row(geometry.vertexRow + 1), [1, -1, 0, 0, 0, 0, 1, 0, 1, 0, 0.9, 0.1, 1, 0, 0, 1, 0, 1, 0, 1], ); close(scene.array("indices").row(geometry.indexRow), [0, 1, 2, 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); }); test("geometry spans validate before mutating, move intact, and inhibit rendering until published", async () => { const scene = await createScene(); const material = new PBRMaterial(scene); await material.ready; const first = new Mesh(scene, { material, vertexData: triangle }); const second = new Mesh(scene, { material, vertexData: triangle }); await Promise.all([first.ready, second.ready]); const core = testCore(scene); const before = { graphs: core.graphs.length, first: { ...scene.geometry(first.geometryId) }, second: { ...scene.geometry(second.geometryId) }, }; await assert.rejects( first.setVertexData("indices", [0, 1, 3]), /VERTEX_DATA: indices\[2\]/u, ); await assert.rejects( first.setVertexData("indices", [0, 1, 1.5]), /VERTEX_DATA: indices\[2\]/u, ); await assert.rejects( first.setVertexData("normals", [0, 0, 1]), /VERTEX_DATA: normals/u, ); assert.deepEqual({ ...scene.geometry(first.geometryId) }, before.first); close(scene.geometryData(first.geometryId, "indices")!, [0, 1, 2]); assert.equal(core.graphs.length, before.graphs); // Hold the older loadout switch open while the first mesh grows past its span. let releaseSwitch!: () => void; const switching = new Promise((resolve) => (releaseSwitch = resolve)); const switchLoadout = core.switchLoadout.bind(core); core.switchLoadout = (id) => switching.then(() => switchLoadout(id)); const older = scene.updateRenderGraph(); const grown = first.setVertexData({ positions: [0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0], normals: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], tangents: Array(16).fill(0), uvs: [0, 0, 1, 0, 0, 1, 1, 1], uvs2: [0, 0, 1, 0, 0, 1, 1, 1], colors: Array(16).fill(1), indices: [0, 1, 2, 2, 1, 3], }); const signals = core.array("signals").row(0); await new Promise((resolve) => setTimeout(resolve)); assert.equal(signals[4], 1); assert.equal(core.loadouts.length, before.graphs); releaseSwitch(); await older; assert.equal(signals[4], 1); core.switchLoadout = switchLoadout; await grown; assert.equal(signals[4], 0); assert.equal(signals[5], 1); const moved = scene.geometry(first.geometryId); assert.equal(moved.vertexCount, 4); assert.notEqual(moved.vertexRow, before.first.vertexRow); assert.deepEqual({ ...scene.geometry(second.geometryId) }, before.second); close( scene.array("vertices").row(before.second.vertexRow + 1), [1, -1, 0, 0, 0, 0, 1, 0, 1, 0, 0.9, 0.1, 1, 0, 0, 1, 0, 1, 0, 1], ); close(scene.array("indices").row(moved.indexRow), [0, 1, 2, 2]); const draws = bucketDraws(scene, latestGraph(scene)); assert.deepEqual( draws.map((draw) => [draw.baseVertex, draw.firstIndex, draw.indices]), [ [moved.vertexRow, moved.indexRow * 4, 6], [before.second.vertexRow, before.second.indexRow * 4, 3], ], ); }); 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, radius: 2, }); await light.ready; close( scene.array("pointLights").row(light.id), [0.2, 0.4, 0.6, 7, 8, 1, 2, 0], ); light.intensity = 9; light.range = 10; light.radius = 3; close(scene.array("pointLights").row(light.id).subarray(3, 5), [9, 10]); assert.equal(scene.array("pointLights").row(light.id)[6], 3); }); test("ArcRotateCamera damps pan, orbit and zoom with reversed vertical orbit", async (t) => { let now = 0; let next = 1; const frames = new Map(); const originalRequest = globalThis.requestAnimationFrame; const originalCancel = globalThis.cancelAnimationFrame; t.mock.method(performance, "now", () => now); globalThis.requestAnimationFrame = (callback) => { const id = next++; frames.set(id, callback); return id; }; globalThis.cancelAnimationFrame = (id) => { frames.delete(id); }; t.after(() => { globalThis.requestAnimationFrame = originalRequest; globalThis.cancelAnimationFrame = originalCancel; }); const advance = (ms: number) => { now += ms; const callbacks = [...frames.values()]; frames.clear(); for (const callback of callbacks) callback(now); }; const listeners = new Map 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) => { 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 }); dispatch("pointerup", {}); advance(80); close(camera.position, [-2 * (1 - Math.exp(-1)), 1 - Math.exp(-1), 10]); advance(1600); close(camera.position, [-2, 1, 10]); advance(16); assert.equal(frames.size, 0); const orbit = () => { dispatch("pointerdown", { button: 0, clientX: 100, clientY: 100, pointerId: 1, }); dispatch("pointermove", { clientX: 140, clientY: 120 }); dispatch("pointerup", {}); }; orbit(); advance(40); const released = camera.alpha; advance(40); assert.ok(camera.alpha < released); const pose = [camera.alpha, camera.beta]; close(pose, [ -0.2 * (1 - Math.exp(-1)), Math.PI / 2 - 0.1 * (1 - Math.exp(-1)), ]); camera.detachControls(); camera.alpha = 0; camera.beta = Math.PI / 2; camera.attachControls({ element }); orbit(); advance(80); close([camera.alpha, camera.beta], pose); dispatch("wheel", { deltaY: 100 }); advance(80); close([camera.radius], [10 * Math.exp(0.1 * (1 - Math.exp(-1)))]); camera.detachControls(); assert.equal(frames.size, 0); });