Add persistent render graph loadouts, grouped node authoring, code-appending apply behavior, explicit playground lighting, and resilient worker startup. Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
251 lines
7.7 KiB
TypeScript
251 lines
7.7 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 { 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,
|
|
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 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();
|
|
});
|