Amp-Thread-ID: https://ampcode.com/threads/T-01a0b382-1e79-712b-abd1-d29201c9bb5e Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
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<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]);
|
|
// 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<void>((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<number, FrameRequestCallback>();
|
|
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<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 });
|
|
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);
|
|
});
|