Files
yawn/addons/handles/test/handles.test.ts
T
Ampandheaust bc957cf40d handles: indirect draw buckets for opaque and mask meshes
Opaque/mask draws sharing a pipeline and bind set become rows of one
persistent `draws.<id>` indirect array (32-byte indexed commands,
geometric capacity, inactive tail zeroed) drawn by a single graph pass,
so the graph holds one pass per bucket instead of one per mesh.
Transmission and blend draws stay direct and ordered after the buckets.
Scene requires the indirect-first-instance feature. Compiles re-assert
the bundle block when they start, since an earlier switch in the queue
clears the one set at enqueue time.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b2dd-db32-712d-99ce-12ce4a86d979
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
2026-09-18 07:14:37 +00:00

336 lines
11 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 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();
});