Rebuild core around render graph AST and shared memory
Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
@@ -4,11 +4,11 @@ import {
|
||||
addNodeItems,
|
||||
moveAddNodeSelection,
|
||||
searchAddNodeItems,
|
||||
} from "../static/render-graph/add-node-menu.js";
|
||||
} from "../examples/render-graph-studio/render-graph/add-node-menu.js";
|
||||
import {
|
||||
createNodeIdAllocator,
|
||||
spawnRequestedNode,
|
||||
} from "../static/render-graph/node-spawn.js";
|
||||
} from "../examples/render-graph-studio/render-graph/node-spawn.js";
|
||||
|
||||
test("add-node model contains all final catalog types in application groups", () => {
|
||||
assert.equal(addNodeItems.length, 44);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
animateInstance,
|
||||
canonicalAstExample,
|
||||
classifyInstance,
|
||||
compileAndSwitch,
|
||||
computePipelineExample,
|
||||
connectFromWorker,
|
||||
createInstance,
|
||||
createVelocityColumn,
|
||||
customRenderPipelineExample,
|
||||
defaultPipelineExample,
|
||||
dropActiveGraph,
|
||||
fluentGraphExample,
|
||||
fxNodeExportExample,
|
||||
importGltf,
|
||||
jsoGraphExample,
|
||||
loadCompleteScene,
|
||||
pickNearest,
|
||||
setVelocity,
|
||||
simpleSceneShader,
|
||||
updateInstance,
|
||||
} from "../examples/cookbook/index.js";
|
||||
|
||||
test("cookbook graph recipes produce canonical addon-owned ASTs", () => {
|
||||
const canonical = canonicalAstExample();
|
||||
assert.equal(canonical.ast.id, "shared_dag");
|
||||
assert.equal(
|
||||
(canonical.source.match(/\(ref "source" "value"\)/g) ?? []).length,
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[jsoGraphExample().id, fluentGraphExample().id],
|
||||
["jso_graph", "fluent_graph"],
|
||||
);
|
||||
|
||||
const fxnode = fxNodeExportExample();
|
||||
assert.equal(fxnode.kind, "yawn-render-graph");
|
||||
assert.equal(fxnode.pipelines.render.length, 4);
|
||||
|
||||
const defaults = defaultPipelineExample();
|
||||
assert.deepEqual(
|
||||
defaults.pipelines.render.map(({ name }) => name),
|
||||
[
|
||||
"ground_plane",
|
||||
"gltf_standard",
|
||||
"gltf_standard_double_sided",
|
||||
"frame_out",
|
||||
],
|
||||
);
|
||||
assert.equal(defaults.pipelines.compute.length, 1);
|
||||
|
||||
const custom = customRenderPipelineExample();
|
||||
assert.equal(custom.pipelines.render[0].shader, simpleSceneShader);
|
||||
assert.match(simpleSceneShader, /@vertex fn vertex_main/);
|
||||
|
||||
const compute = computePipelineExample().pipelines.compute[0];
|
||||
assert.deepEqual(
|
||||
[compute.entry, compute.dispatch],
|
||||
["initialize", [4, 1, 1]],
|
||||
);
|
||||
});
|
||||
|
||||
test("cookbook graph lifecycle recipe serializes, switches, and drops", async () => {
|
||||
const calls = [];
|
||||
const core = {
|
||||
compileGraph(source) {
|
||||
calls.push(["compile", source]);
|
||||
return Promise.resolve({ compiledId: [3, 4] });
|
||||
},
|
||||
switchCompiledGraph(id) {
|
||||
calls.push(["switch", id]);
|
||||
return Promise.resolve();
|
||||
},
|
||||
switchToImmediate() {
|
||||
calls.push(["immediate"]);
|
||||
return Promise.resolve();
|
||||
},
|
||||
dropCompiledGraph(id) {
|
||||
calls.push(["drop", id]);
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
const graph = { id: "lifecycle", revision: 1, nodes: [] };
|
||||
const compiled = await compileAndSwitch(core, graph);
|
||||
await dropActiveGraph(core, compiled);
|
||||
assert.match(calls[0][1], /^\(yawn-graph 1/);
|
||||
assert.deepEqual(calls.slice(1), [
|
||||
["switch", [3, 4]],
|
||||
["immediate"],
|
||||
["drop", [3, 4]],
|
||||
]);
|
||||
});
|
||||
|
||||
test("cookbook mutation recipes use handles and SOA writes directly", async () => {
|
||||
const calls = [];
|
||||
const column = {
|
||||
write(slot, values) {
|
||||
calls.push(["velocity", slot, values]);
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
allocateArray(layout) {
|
||||
calls.push(["allocate", layout]);
|
||||
return Promise.resolve(column);
|
||||
},
|
||||
setInstanceTransform(handle, transform) {
|
||||
calls.push(["transform", handle, transform]);
|
||||
},
|
||||
setInstanceType(handle, words) {
|
||||
calls.push(["type", handle, words]);
|
||||
},
|
||||
};
|
||||
const instance = {
|
||||
handle: [7, 2],
|
||||
setTransform(transform) {
|
||||
calls.push(["wrapped-transform", transform]);
|
||||
},
|
||||
setType(words) {
|
||||
calls.push(["wrapped-type", words]);
|
||||
},
|
||||
};
|
||||
const mesh = {
|
||||
createInstance(transform) {
|
||||
calls.push(["create", transform]);
|
||||
return Promise.resolve(instance);
|
||||
},
|
||||
};
|
||||
const transform = Array.from({ length: 16 }, (_, index) => index);
|
||||
const words = Array(16).fill(1);
|
||||
|
||||
assert.equal(await createInstance(mesh, transform), instance);
|
||||
updateInstance(instance, transform, words);
|
||||
const velocity = await createVelocityColumn(core);
|
||||
setVelocity(velocity, instance, [1, 2, 3]);
|
||||
animateInstance(core, instance, transform);
|
||||
classifyInstance(core, instance, words);
|
||||
|
||||
assert.deepEqual(calls.find(([name]) => name === "allocate")[1], {
|
||||
name: "instance.velocity",
|
||||
domain: "instance",
|
||||
scalar: "f32",
|
||||
lanes: 4,
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls.find(([name]) => name === "velocity"),
|
||||
["velocity", 7, [1, 2, 3, 0]],
|
||||
);
|
||||
assert.ok(calls.some(([name]) => name === "transform"));
|
||||
assert.ok(calls.some(([name]) => name === "type"));
|
||||
});
|
||||
|
||||
test("cookbook picking and worker-to-worker recipes use public facades", async () => {
|
||||
const picked = await pickNearest(
|
||||
{
|
||||
pickRay: async () => ({
|
||||
epoch: 5,
|
||||
hits: [{ instance: [9, 3], distance: 2 }],
|
||||
}),
|
||||
},
|
||||
[0, 0, 0],
|
||||
[0, 0, -1],
|
||||
);
|
||||
assert.deepEqual(picked.hits[0].instance.handle, [9, 3]);
|
||||
|
||||
class Port extends EventTarget {
|
||||
postMessage() {}
|
||||
start() {
|
||||
queueMicrotask(() =>
|
||||
this.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { type: "soa-init", arrays: [] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
terminate() {}
|
||||
}
|
||||
const core = await connectFromWorker(new Port());
|
||||
assert.equal(core.constructor.name, "YawnCore");
|
||||
core.dispose();
|
||||
|
||||
assert.equal(typeof importGltf, "function");
|
||||
assert.equal(typeof loadCompleteScene, "function");
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createCubeGeometry, createMaterialGalleryGlb, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, loadouts, LoadoutError } from "../static/demo-loadouts.js";
|
||||
import { createCubeGeometry, createMaterialGalleryGlb, createUvSphereGeometry, encodeGeometryGlb, loadDemoLoadout, loadouts } from "../examples/render-graph-studio/demo-loadouts.js";
|
||||
|
||||
function parseGlb(buffer){const view=new DataView(buffer);assert.equal(view.getUint32(0,true),0x46546c67);assert.equal(view.getUint32(4,true),2);assert.equal(view.getUint32(8,true),buffer.byteLength);const length=view.getUint32(12,true);assert.equal(view.getUint32(16,true),0x4e4f534a);const json=JSON.parse(new TextDecoder().decode(new Uint8Array(buffer,20,length)).trim());const bin=20+length;assert.equal(view.getUint32(bin+4,true),0x004e4942);assert.equal(view.getUint32(bin,true),json.buffers[0].byteLength);return json;}
|
||||
test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i<geometry.positions.length/3));}});
|
||||
test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}});
|
||||
test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}});
|
||||
test("Phase 6 gallery is deterministic and covers core PBR shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-phase6-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"Phase 6 deterministic PBR gallery");});
|
||||
test("loadout dropdown preserves every demo scene and exposes the material gallery",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../static/index.html",import.meta.url),"utf8");for(const id of ["cubes","spheres","materials","manor","sponza"])assert.match(html,new RegExp(`<option value="${id}">`));});
|
||||
test("LFS pointers and HTTP failures are explicit and stable",async()=>{const pointer=new TextEncoder().encode("version https://git-lfs.github.com/spec/v1\noid sha256:abc\n").buffer;assert.equal(isGitLfsPointer(pointer),true);await assert.rejects(loadDemoLoadout("manor",{fetchImpl:async()=>({ok:true,arrayBuffer:async()=>pointer})}),e=>e instanceof LoadoutError&&e.code==="LOADOUT_LFS_POINTER");await assert.rejects(loadDemoLoadout("sponza",{fetchImpl:async()=>({ok:false,status:404})}),e=>e.code==="LOADOUT_HTTP"&&/404/.test(e.message));});
|
||||
test("Phase 6 gallery is deterministic and covers core PBR shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-phase6-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"PBR material gallery");});
|
||||
test("the example offers only self-contained procedural scenes",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../examples/render-graph-studio/index.html",import.meta.url),"utf8");assert.deepEqual(Object.keys(loadouts),["cubes","spheres","materials"]);for(const id of Object.keys(loadouts))assert.match(html,new RegExp(`<option value="${id}">`));await assert.rejects(loadDemoLoadout("unknown"),RangeError);});
|
||||
test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { build } from "vite";
|
||||
import { fxNodeComposition } from "../static/render-graph/catalog.js";
|
||||
import { fxNodeComposition } from "@yawn/render-graph-fxnode/catalog";
|
||||
|
||||
test("production render graph composition passes fxnode's public validator", async () => {
|
||||
const directory = await mkdtemp(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { GltfImporter } from "@yawn/gltf-import";
|
||||
import { writeSharedUpload } from "../addons/gltf-import/src/shared-upload.js";
|
||||
|
||||
class WorkerMock extends EventTarget {
|
||||
messages = [];
|
||||
terminated = false;
|
||||
postMessage(message) { this.messages.push(message); }
|
||||
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
|
||||
terminate() { this.terminated = true; }
|
||||
}
|
||||
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test("glTF addon stages fetched bytes in shared SOA and commits only metadata", async () => {
|
||||
const memory = new SharedArrayBuffer(4096);
|
||||
const descriptor = {
|
||||
id: 9,
|
||||
name: "upload.gltf",
|
||||
domain: "fixed",
|
||||
scalar: "u32",
|
||||
lanes: 4,
|
||||
stride: 16,
|
||||
length: 2,
|
||||
capacity: 2,
|
||||
controlPtr: 0,
|
||||
dataOffset: 64,
|
||||
byteLength: 32,
|
||||
layoutEpoch: 1,
|
||||
writable: true,
|
||||
};
|
||||
new Int32Array(memory, 0, 16).set([0x414f5359, 1, 9, 1, 4, 4, 2, 2, 3]);
|
||||
const array = { share: () => ({ buffer: memory, descriptor }) };
|
||||
const calls = [];
|
||||
const core = {
|
||||
async allocateArray(layout) { calls.push(["allocate", layout]); return array; },
|
||||
async commitGlbUpload(value, byteLength, options) {
|
||||
calls.push(["commit", value, byteLength, options]);
|
||||
return { meshes: [] };
|
||||
},
|
||||
};
|
||||
const worker = new WorkerMock();
|
||||
const importer = new GltfImporter(core, { workerFactory: () => worker });
|
||||
const loading = importer.load("https://example.test/scene.glb", { framing: "interior" });
|
||||
await tick();
|
||||
assert.deepEqual(worker.messages[0], {
|
||||
type: "load",
|
||||
request: 1,
|
||||
url: "https://example.test/scene.glb",
|
||||
});
|
||||
|
||||
worker.reply({ type: "allocate", request: 1, byteLength: 20 });
|
||||
await tick();
|
||||
assert.deepEqual(calls[0], ["allocate", {
|
||||
name: "upload.gltf", domain: "fixed", scalar: "u32", lanes: 4, stride: 16, length: 2,
|
||||
}]);
|
||||
assert.equal(worker.messages[1].buffer, memory);
|
||||
assert.equal(worker.messages[1].descriptor, descriptor);
|
||||
assert.equal(Object.hasOwn(worker.messages[1], "bytes"), false);
|
||||
|
||||
const bytes = Uint8Array.from({ length: 20 }, (_, index) => index + 1);
|
||||
writeSharedUpload(memory, descriptor, bytes);
|
||||
worker.reply({ type: "ready", request: 1, byteLength: bytes.byteLength });
|
||||
assert.deepEqual(await loading, { meshes: [] });
|
||||
assert.deepEqual(new Uint8Array(memory, 64, 20), bytes);
|
||||
assert.deepEqual(calls[1], ["commit", array, 20, { framing: "interior" }]);
|
||||
importer.dispose();
|
||||
assert.equal(worker.terminated, true);
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { culling } from "../static/render-graph/presets.js";
|
||||
import { culling } from "../examples/render-graph-studio/render-graph/presets.js";
|
||||
import {
|
||||
CATALOG_VERSION, GRAPH_ID, semanticCatalog, nodeDefinitions, descriptors, socketTypes,
|
||||
} from "../static/render-graph/catalog.js";
|
||||
import { adaptFxNodeSnapshot, mapAuthoringDiagnostic } from "../static/render-graph/adapter.js";
|
||||
} from "@yawn/render-graph-fxnode/catalog";
|
||||
import { adaptFxNodeSnapshot, mapAuthoringDiagnostic } from "@yawn/render-graph-fxnode";
|
||||
|
||||
const authoredNode = (id, typeId) => {
|
||||
const definition = nodeDefinitions[typeId];
|
||||
@@ -123,7 +123,7 @@ test("adapter preserves ordered multisocket links and indexed diagnostics", () =
|
||||
};
|
||||
const graph = adaptFxNodeSnapshot(raw, 2);
|
||||
const targetIndex = graph.nodes.findIndex((node) => node.id === "target");
|
||||
assert.equal(graph.schemaVersion, 3);
|
||||
assert.deepEqual([graph.kind, graph.version, graph.id], ["yawn-render-graph", 1, GRAPH_ID]);
|
||||
assert.deepEqual(graph.nodes[targetIndex].inputs.inputs, [
|
||||
{ node: "source_a", socket: "value" },
|
||||
{ node: "source_b", socket: "value" },
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
GraphAstError,
|
||||
createGraphAst,
|
||||
reference,
|
||||
serializeGraphAst,
|
||||
} from "@yawn/render-graph-ast";
|
||||
import { RenderGraph, graphFromObject, loadGraph } from "@yawn/render-graph-js";
|
||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
||||
|
||||
const node = (id, inputs = {}) => ({
|
||||
id,
|
||||
state: "enabled",
|
||||
executor: { key: "and", version: 2 },
|
||||
parameters: {},
|
||||
inputs,
|
||||
});
|
||||
|
||||
test("JSO and builder frontends export the same canonical AST", () => {
|
||||
const description = {
|
||||
id: "shared_dag",
|
||||
revision: 3,
|
||||
pipelines: {
|
||||
compute: [{
|
||||
name: "prepare",
|
||||
shader: "@compute @workgroup_size(1) fn main() {}",
|
||||
entry: "main",
|
||||
dispatch: [1, 1, 1],
|
||||
}],
|
||||
},
|
||||
nodes: [
|
||||
node("source"),
|
||||
node("left", { inputs: [reference("source", "value")] }),
|
||||
node("right", { inputs: [reference("source", "value")] }),
|
||||
],
|
||||
};
|
||||
const objectAst = graphFromObject(description);
|
||||
const builderAst = new RenderGraph("shared_dag", 3)
|
||||
.computePipeline(description.pipelines.compute[0])
|
||||
.node("source", "and", { version: 2 })
|
||||
.node("left", "and", { version: 2, inputs: description.nodes[1].inputs })
|
||||
.node("right", "and", { version: 2, inputs: description.nodes[2].inputs })
|
||||
.ast();
|
||||
|
||||
assert.deepEqual(builderAst, objectAst);
|
||||
const source = serializeGraphAst(objectAst);
|
||||
assert.equal((source.match(/\(ref "source" "value"\)/g) ?? []).length, 2);
|
||||
assert.match(source, /^\(yawn-graph 1/);
|
||||
assert.equal(source.trimEnd().endsWith("))"), true);
|
||||
});
|
||||
|
||||
test("canonical AST is immutable and rejects duplicate declarations", () => {
|
||||
const ast = createGraphAst({ id: "immutable", revision: 1, nodes: [] });
|
||||
assert.equal(Object.isFrozen(ast), true);
|
||||
assert.equal(Object.isFrozen(ast.nodes), true);
|
||||
assert.throws(
|
||||
() => createGraphAst({
|
||||
id: "duplicates",
|
||||
revision: 1,
|
||||
pipelines: {
|
||||
render: [{ name: "same", shader: "", vertexEntry: "vs_main", fragmentEntry: "fs_main" }],
|
||||
compute: [{ name: "same", shader: "", entry: "main", dispatch: [1, 1, 1] }],
|
||||
},
|
||||
nodes: [],
|
||||
}),
|
||||
error => error instanceof GraphAstError && error.code === "AST_PIPELINE_DUPLICATE",
|
||||
);
|
||||
});
|
||||
|
||||
test("JSO addon owns AST serialization and graph loading", async () => {
|
||||
const calls = [];
|
||||
const core = { compileGraph(source) { calls.push(source); return Promise.resolve({ compiledId: [1, 2] }); } };
|
||||
const description = { id: "loaded", revision: 1, nodes: [] };
|
||||
assert.deepEqual(await loadGraph(core, description), { compiledId: [1, 2] });
|
||||
assert.match(calls[0], /^\(yawn-graph 1/);
|
||||
await new RenderGraph("builder", 1).load(core);
|
||||
assert.match(calls[1], /\(id "builder"\)/);
|
||||
});
|
||||
|
||||
test("optional pipelines carry every shader and compute declaration outside core", () => {
|
||||
assert.deepEqual(defaultPipelines.render.map(({ name }) => name), [
|
||||
"ground_plane", "gltf_standard", "gltf_standard_double_sided", "frame_out",
|
||||
]);
|
||||
assert.match(defaultPipelines.render[1].shader, /@vertex/);
|
||||
assert.match(defaultPipelines.render[3].shader, /@fragment/);
|
||||
assert.match(defaultPipelines.compute[0].shader, /@compute/);
|
||||
assert.deepEqual(defaultPipelines.compute[0].dispatch, [1, 1, 1]);
|
||||
});
|
||||
@@ -1,23 +1,21 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as presets from "../static/render-graph/presets.js";
|
||||
import { descriptors } from "../static/render-graph/catalog.js";
|
||||
import * as presets from "../examples/render-graph-studio/render-graph/presets.js";
|
||||
import { descriptors } from "@yawn/render-graph-fxnode/catalog";
|
||||
|
||||
test("all presets use current schemas, versions, and one frame output", () => {
|
||||
assert.equal(Object.keys(presets.renderGraphPresets).length, 13);
|
||||
test("the JSO example is a complete canonical AST with external pipelines", () => {
|
||||
assert.deepEqual(Object.keys(presets.renderGraphPresets), ["jso"]);
|
||||
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
|
||||
assert.deepEqual([graph.schemaVersion, graph.revision], [3, 4], name);
|
||||
assert.deepEqual([graph.kind, graph.version, graph.revision], ["yawn-render-graph", 1, 1], name);
|
||||
assert.equal(new Set(graph.nodes.map((node) => node.id)).size, graph.nodes.length, name);
|
||||
assert.equal(graph.nodes.filter((node) => node.executor.key === "frame_out").length, 1, name);
|
||||
for (const node of graph.nodes)
|
||||
assert.equal(node.executor.version, descriptors[node.executor.key].version, `${name}:${node.id}`);
|
||||
assert.deepEqual(graph.pipelines.render.map(({ name }) => name), [
|
||||
"ground_plane", "gltf_standard", "gltf_standard_double_sided", "frame_out",
|
||||
]);
|
||||
assert.deepEqual(graph.pipelines.compute.map(({ name }) => name), ["initialize_scene"]);
|
||||
}
|
||||
const authoredX4 = Object.entries(presets.renderGraphPresets).flatMap(([name, graph]) =>
|
||||
graph.nodes.filter((node) => node.parameters?.texture?.sampleCount === 4)
|
||||
.map((node) => `${name}:${node.id}`));
|
||||
assert.deepEqual(authoredX4, ["msaa:msaa_hdr", "msaa:scene_depth"]);
|
||||
assert.equal(typeof presets.msaa.nodes.find((node) => node.id === "msaa_hdr")
|
||||
.parameters.texture.sampleCount, "number");
|
||||
});
|
||||
|
||||
test("presets classify demo-owned enable and material bits through type.words[0] predicates", () => {
|
||||
@@ -35,7 +33,7 @@ test("presets classify demo-owned enable and material bits through type.words[0]
|
||||
}
|
||||
});
|
||||
|
||||
test("culling adds a local-AABB expression to each material predicate", () => {
|
||||
test("the example adds a local-AABB expression to each material predicate", () => {
|
||||
const byId = Object.fromEntries(presets.culling.nodes.map((node) => [node.id, node]));
|
||||
assert.deepEqual(byId.cull.inputs, {
|
||||
mesh: [{ node: "mesh", socket: "mesh" }], localAabb: [{ node: "mesh", socket: "localAabb" }],
|
||||
@@ -45,7 +43,7 @@ test("culling adds a local-AABB expression to each material predicate", () => {
|
||||
assert.equal(byId[id].inputs.inputs.at(-1).node, "not_culled");
|
||||
});
|
||||
|
||||
test("scene pipelines directly share matching explicit color and depth targets", () => {
|
||||
test("scene pipelines directly share matching transient color and depth targets", () => {
|
||||
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
|
||||
const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node]));
|
||||
const color = byId.ground.inputs.color;
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as rendererModule from "../static/renderer-client.js";
|
||||
const { RendererClient, RendererError } = rendererModule;
|
||||
const TYPE = [0,1,2,4,8,16,32,64,128,256,512,1024,2048,4096,0x80000000,0xffffffff];
|
||||
|
||||
class WorkerMock extends EventTarget {
|
||||
messages=[]; transfers=[]; terminated=false;
|
||||
postMessage(message, transfer=[]) { this.messages.push(message); this.transfers.push(transfer); }
|
||||
terminate(){this.terminated=true;}
|
||||
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
|
||||
}
|
||||
function fixture() {
|
||||
const memory = new WebAssembly.Memory({initial:4, maximum:8, shared:true});
|
||||
const header = new Int32Array(memory.buffer, 0, 16);
|
||||
header.set([0x4e574159,2,1024,40,0,0]);
|
||||
const worker = new WorkerMock();
|
||||
const bridge = {memory,ringPtr:0,worker,freed:false,free(){this.freed=true;}};
|
||||
const client = new RendererClient(bridge);
|
||||
return {memory,header,worker,bridge,client};
|
||||
}
|
||||
async function imported(f) {
|
||||
const loading=f.client.replaceSceneGlb(new ArrayBuffer(8));
|
||||
f.worker.reply({type:"payload-ready",id:1});
|
||||
await Promise.resolve();
|
||||
f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[{handle:[7,3],defaultInstance:[8,5],defaultType:[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}});
|
||||
return (await loading)[0];
|
||||
}
|
||||
test("replaceSceneGlb is opcode 1", async()=>{const f=fixture(),pending=f.client.replaceSceneGlb(new ArrayBuffer(8));f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.equal(new Int32Array(f.memory.buffer,64,40)[1],1);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});assert.deepEqual(await pending,[]);});
|
||||
test("scene replacement carries the framing mode in opcode 1",async()=>{const f=fixture(),pending=f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"interior"});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.deepEqual([...new Int32Array(f.memory.buffer,64,40).slice(1,5)],[1,1,1,1]);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});await pending;await assert.rejects(f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"bad"}),TypeError)});
|
||||
test("writes tagged fixed-slot protocol and resolves reply", async () => {
|
||||
const f=fixture(); const mesh=await imported(f);
|
||||
assert.equal(mesh.setVisible,undefined);
|
||||
assert.equal(mesh.setType,undefined);
|
||||
assert.equal(typeof mesh.defaultInstance.setType,"function");
|
||||
const pending=mesh.defaultInstance.setType(TYPE);
|
||||
const {memory,header,worker}=f;
|
||||
assert.equal(Atomics.load(header,5),2);
|
||||
const slot=new Int32Array(memory.buffer,64+160,40);
|
||||
assert.deepEqual([...slot.slice(0,21)].map(x=>x>>>0),[2,10,2,8,5,...TYPE]);
|
||||
worker.reply({type:"reply",request:2,ok:true,code:"OK"}); await pending;
|
||||
});
|
||||
test("maps stable errors and gates destroyed instances", async () => {
|
||||
const f=fixture(), mesh=await imported(f); const {worker}=f;
|
||||
const creating=mesh.createInstance(new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]),{type:TYPE});
|
||||
worker.reply({type:"reply",request:2,ok:true,result:[4,2]}); const instance=await creating;
|
||||
assert.equal(instance.setVisible,undefined);
|
||||
const destroying=instance.destroy(); worker.reply({type:"reply",request:3,ok:true}); await destroying;
|
||||
assert.throws(()=>instance.setType(TYPE), error=>error instanceof RendererError&&error.code==="STALE_HANDLE");
|
||||
});
|
||||
test("rejects protocol mismatch", () => {
|
||||
const {memory,worker}=fixture(); new Int32Array(memory.buffer)[1]=1;
|
||||
assert.throws(()=>new RendererClient({memory,ringPtr:0,worker}), /PROTOCOL_MISMATCH/);
|
||||
});
|
||||
test("pending reply exists before ring publication", async () => {
|
||||
const f=fixture(), mesh=await imported(f); const {worker}=f;
|
||||
const pending=mesh.defaultInstance.setType(TYPE);
|
||||
worker.reply({type:"reply",request:2,ok:true});
|
||||
await pending;
|
||||
});
|
||||
test("profile snapshots have a dedicated getter", () => {
|
||||
const f=fixture(), snapshot={type:"profile-snapshot",available:true,epoch:3,passes:{forward:1.25}};
|
||||
const dispatch=globalThis.dispatchEvent; globalThis.dispatchEvent=()=>true;
|
||||
try { f.worker.reply(snapshot); assert.strictEqual(f.client.profile,snapshot); }
|
||||
finally { globalThis.dispatchEvent=dispatch; f.client.dispose(); }
|
||||
});
|
||||
test("worker failures and dispose reject every pending operation", async () => {
|
||||
const f=fixture(), mesh=await imported(f); const {worker,client,bridge}=f;
|
||||
const a=mesh.defaultInstance.setType(TYPE), b=mesh.defaultInstance.setType([...TYPE].reverse());
|
||||
worker.dispatchEvent(new Event("error"));
|
||||
await assert.rejects(a,/WORKER_ERROR/); await assert.rejects(b,/WORKER_ERROR/);
|
||||
client.dispose(); assert.equal(worker.terminated,true); assert.equal(bridge.freed,true);
|
||||
});
|
||||
test("import always releases staged payload when ring is full", async () => {
|
||||
const {header,worker,client}=fixture(); Atomics.store(header,5,1024);
|
||||
const loading=client.replaceSceneGlb(new ArrayBuffer(8));
|
||||
worker.reply({type:"payload-ready",id:1});
|
||||
await assert.rejects(loading,/RING_FULL/);
|
||||
assert.equal(worker.messages.at(-1).type,"payload-release");
|
||||
});
|
||||
test("does not export handle constructors or internal mutation methods", () => {
|
||||
const {client}=fixture();
|
||||
assert.equal(rendererModule.VISIBLE,undefined);
|
||||
assert.equal(rendererModule.Mesh,undefined);
|
||||
assert.equal(rendererModule.Instance,undefined);
|
||||
assert.equal(client._meshFlags,undefined);
|
||||
assert.equal(client._createInstance,undefined);
|
||||
});
|
||||
test("corrupt backlog closes and terminates the transport", async () => {
|
||||
const f=fixture(); Atomics.store(f.header,5,1025);
|
||||
const loading=f.client.replaceSceneGlb(new ArrayBuffer(8));
|
||||
// Payload staging must first acknowledge before enqueue sees corruption.
|
||||
f.worker.reply({type:"payload-ready",id:1});
|
||||
await assert.rejects(loading,/RING_CORRUPT/);
|
||||
assert.equal(Atomics.load(f.header,6),1);
|
||||
assert.equal(f.worker.terminated,true);
|
||||
});
|
||||
test("import rejects immediately after disposal", async () => {
|
||||
const f=fixture();
|
||||
f.client.dispose();
|
||||
await assert.rejects(f.client.replaceSceneGlb(new ArrayBuffer(8)),/DISPOSED/);
|
||||
assert.equal(f.worker.messages.length,0);
|
||||
});
|
||||
test("import rejects when disposed during asynchronous source loading", async () => {
|
||||
const f=fixture();
|
||||
const originalFetch=globalThis.fetch;
|
||||
let finishFetch;
|
||||
globalThis.fetch=()=>new Promise(resolve=>{finishFetch=resolve;});
|
||||
try {
|
||||
const loading=f.client.replaceSceneGlb("model.glb");
|
||||
f.client.dispose();
|
||||
finishFetch({arrayBuffer:async()=>new ArrayBuffer(8)});
|
||||
await assert.rejects(loading,/DISPOSED/);
|
||||
assert.equal(f.worker.messages.length,0);
|
||||
} finally {
|
||||
globalThis.fetch=originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("compile transfers payload and waits for ready before opcode 7", async()=>{
|
||||
const f=fixture(), pending=f.client.compileGraph({schemaVersion:2});
|
||||
assert.equal(f.worker.transfers[0].length,1); assert.equal(Atomics.load(f.header,5),0);
|
||||
f.worker.reply({type:"payload-ready",id:1}); await Promise.resolve();
|
||||
assert.equal(new Int32Array(f.memory.buffer,64,40)[1],7);
|
||||
f.worker.reply({type:"reply",request:1,ok:true,result:{compiledId:[2,3]}});
|
||||
assert.deepEqual(await pending,{compiledId:[2,3]});
|
||||
});
|
||||
test("flat error reply preserves structured details", async()=>{
|
||||
const f=fixture(), pending=f.client.compileGraph({}); f.worker.reply({type:"payload-ready",id:1}); await Promise.resolve();
|
||||
f.worker.reply({type:"reply",request:1,ok:false,code:"GRAPH_INVALID_ID",details:{message:"bad",path:"graphId"}});
|
||||
await assert.rejects(pending,e=>e instanceof RendererError&&e.code==="GRAPH_INVALID_ID"&&e.details.path==="graphId"&&e.message==="bad");
|
||||
});
|
||||
test("compile releases payload after success", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();f.worker.reply({type:"reply",request:1,ok:true,result:{}});await p;assert.equal(f.worker.messages.at(-1).type,"payload-release");});
|
||||
test("compile releases payload after backend error", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();f.worker.reply({type:"reply",request:1,ok:false,code:"X",details:{message:"x"}});await assert.rejects(p);assert.equal(f.worker.messages.at(-1).type,"payload-release");});
|
||||
test("compile rejects circular and BigInt JSON", async()=>{const f=fixture(),x={};x.x=x;await assert.rejects(f.client.compileGraph(x),/circular/i);await assert.rejects(f.client.compileGraph({x:1n}),e=>e.code==="GRAPH_JSON_INVALID");});
|
||||
test("compile rejects oversized encoding", async()=>{const f=fixture();await assert.rejects(f.client.compileGraph({x:"x".repeat(1024*1024)}),e=>e.code==="GRAPH_PAYLOAD_TOO_LARGE");assert.equal(f.worker.messages.length,0);});
|
||||
test("drop graph emits opcode 8 and validates id", async()=>{const f=fixture(),p=f.client.dropCompiledGraph([9,4]);const slot=new Int32Array(f.memory.buffer,64,40);assert.deepEqual([...slot.slice(1,5)],[8,1,9,4]);f.worker.reply({type:"reply",request:1,ok:true});await p;assert.throws(()=>f.client.dropCompiledGraph([1]),TypeError);});
|
||||
test("ring-full graph compile releases staged payload", async()=>{const f=fixture();Atomics.store(f.header,5,1024);const p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await assert.rejects(p,e=>e.code==="RING_FULL");assert.equal(f.worker.messages.at(-1).type,"payload-release");});
|
||||
test("disposal while graph payload is pending releases and rejects", async()=>{const f=fixture(),p=f.client.compileGraph({});f.client.dispose();await assert.rejects(p,e=>e.code==="DISPOSED");assert.equal(f.worker.messages.at(-1).type,"payload-release");});
|
||||
test("payload transfer uses the exact encoded ArrayBuffer", async()=>{const f=fixture(),graph={schemaVersion:2};const expected=new TextEncoder().encode(JSON.stringify(graph));const p=f.client.compileGraph(graph);const transferred=f.worker.transfers[0][0];assert.strictEqual(f.worker.messages[0].buffer,transferred);assert.deepEqual(new Uint8Array(transferred),expected);f.client.dispose();await assert.rejects(p);});
|
||||
test("cycle error details are preserved exactly", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();const details={message:"cycle",kind:"cycle",edges:[{from:"a",resource:{id:"r",version:0},to:"b"}]};f.worker.reply({type:"reply",request:1,ok:false,code:"GRAPH_CYCLE",details});await assert.rejects(p,e=>e.details===details&&e.details.edges[0].from==="a");});
|
||||
test("error without details leaves details undefined", async()=>{const f=fixture(),p=f.client.dropCompiledGraph([1,1]);f.worker.reply({type:"reply",request:1,ok:false,code:"STALE_GRAPH_ID"});await assert.rejects(p,e=>e instanceof RendererError&&e.details===undefined&&e.message==="STALE_GRAPH_ID");});
|
||||
test("switch methods emit exact opcode 9 words", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([9,4]);assert.deepEqual([...new Int32Array(f.memory.buffer,64,40).slice(1,7)],[9,1,1,9,4,0]);f.worker.reply({type:"reply",request:1,ok:true});await a;await new Promise(queueMicrotask);const b=f.client.switchToImmediate();assert.deepEqual([...new Int32Array(f.memory.buffer,224,40).slice(1,7)],[9,2,0,0,0,0]);f.worker.reply({type:"reply",request:2,ok:true});await b;assert.throws(()=>f.client.switchCompiledGraph([0,0]),TypeError);});
|
||||
test("graph lifecycle FIFO recovers after failure", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([1,1]),b=f.client.switchToImmediate();assert.equal(Atomics.load(f.header,5),1);f.worker.reply({type:"reply",request:1,ok:false,code:"X"});await assert.rejects(a);await new Promise(queueMicrotask);assert.equal(Atomics.load(f.header,5),2);f.worker.reply({type:"reply",request:2,ok:true});await b;});
|
||||
test("dispose rejects queued graph lifecycle calls", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([1,1]),b=f.client.switchToImmediate();f.client.dispose();await assert.rejects(a,/DISPOSED/);await assert.rejects(b,/DISPOSED/);});
|
||||
@@ -1,8 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SnapshotReader, SnapshotProtocolError } from "../static/render-data-snapshot.js";
|
||||
import { DerivedBvh } from "../static/bvh-core.js";
|
||||
import { RendererClient } from "../static/renderer-client.js";
|
||||
import { SnapshotReader, SnapshotProtocolError } from "../packages/yawn-core/src/snapshot.js";
|
||||
import { DerivedBvh } from "../addons/mesh-handles/src/bvh-core.js";
|
||||
import { YawnCore } from "../packages/yawn-core/src/index.js";
|
||||
|
||||
const align16 = value => (value + 15) & ~15;
|
||||
const componentCounts = [1, 1, 3, 3, 1, 1, 1, 1, 16, 3, 3, 16];
|
||||
@@ -108,14 +108,16 @@ class WorkerMock extends EventTarget {
|
||||
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
|
||||
}
|
||||
|
||||
test("renderer pick returns instance metadata handles and exact epoch", async () => {
|
||||
test("core picking returns protocol handles and exact epoch", async () => {
|
||||
const scene = snapshotFixture();
|
||||
const ring = 8192;
|
||||
const ringHeader = new Int32Array(scene.memory.buffer, ring, 16);
|
||||
ringHeader.set([0x4e574159, 2, 1024, 40]);
|
||||
const rendererWorker = new WorkerMock(), bvhWorker = new WorkerMock();
|
||||
const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, workerFactory: () => bvhWorker, free() {} };
|
||||
const client = new RendererClient(bridge);
|
||||
const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, pickingWorkerFactory: () => bvhWorker, free() {} };
|
||||
const client = new YawnCore(bridge);
|
||||
rendererWorker.reply({ type: "soa-init", arrays: [] });
|
||||
await client.ready;
|
||||
rendererWorker.reply({ type: "snapshot-init", controlPtr: 0, controlVersion: 1, schemaVersion: 2 });
|
||||
rendererWorker.reply({ type: "snapshot-published", epoch: 1 });
|
||||
const picking = client.pickRay([0, 0, 0], [1, 0, 0]);
|
||||
@@ -124,8 +126,7 @@ test("renderer pick returns instance metadata handles and exact epoch", async ()
|
||||
const result = await picking;
|
||||
assert.equal(result.epoch, 1);
|
||||
assert.equal(result.hits[0].distance, 2);
|
||||
assert.equal(typeof result.hits[0].instance.setType, "function");
|
||||
assert.equal(result.hits[0].instance.setVisible, undefined);
|
||||
assert.deepEqual(result.hits[0].instance, [10, 3]);
|
||||
client.dispose();
|
||||
assert.equal(bvhWorker.terminated, true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { YawnCore, RendererError } from "@yawn/core";
|
||||
import { MeshHandles } from "@yawn/mesh-handles";
|
||||
import { createGraphAst, serializeGraphAst } from "@yawn/render-graph-ast";
|
||||
|
||||
const TYPE = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 0x80000000, 0xffffffff];
|
||||
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
|
||||
class WorkerMock extends EventTarget {
|
||||
messages = [];
|
||||
transfers = [];
|
||||
terminated = false;
|
||||
|
||||
postMessage(message, transfer = []) {
|
||||
this.messages.push(message);
|
||||
this.transfers.push(transfer);
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminated = true;
|
||||
}
|
||||
|
||||
reply(data) {
|
||||
this.dispatchEvent(new MessageEvent("message", { data }));
|
||||
}
|
||||
}
|
||||
|
||||
function installArray(memory, descriptor) {
|
||||
const control = new Int32Array(memory.buffer, descriptor.controlPtr, 16);
|
||||
control.set([
|
||||
0x414f5359,
|
||||
1,
|
||||
descriptor.id,
|
||||
{ u32: 1, i32: 2, f32: 3 }[descriptor.scalar],
|
||||
descriptor.lanes,
|
||||
descriptor.stride / 4,
|
||||
descriptor.length,
|
||||
descriptor.capacity,
|
||||
{ mesh: 1, instance: 2, fixed: 3 }[descriptor.domain],
|
||||
0,
|
||||
descriptor.layoutEpoch,
|
||||
]);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const memory = new WebAssembly.Memory({ initial: 8, maximum: 16, shared: true });
|
||||
const ring = new Int32Array(memory.buffer, 0, 16);
|
||||
ring.set([0x4e574159, 2, 1024, 40, 0, 0]);
|
||||
const transform = installArray(memory, {
|
||||
id: 1, name: "instance.transform", domain: "instance", scalar: "f32", lanes: 16,
|
||||
stride: 80, length: 16, capacity: 16, controlPtr: 196608, dataOffset: 64,
|
||||
byteLength: 1280, layoutEpoch: 1, writable: true, generationGuard: "instance",
|
||||
});
|
||||
const type = installArray(memory, {
|
||||
id: 2, name: "instance.type", domain: "instance", scalar: "u32", lanes: 16,
|
||||
stride: 80, length: 16, capacity: 16, controlPtr: 198016, dataOffset: 64,
|
||||
byteLength: 1280, layoutEpoch: 1, writable: true, generationGuard: "instance",
|
||||
});
|
||||
const generation = installArray(memory, {
|
||||
id: 3, name: "instance.generation", domain: "instance", scalar: "u32", lanes: 1,
|
||||
stride: 16, length: 16, capacity: 16, controlPtr: 199424, dataOffset: 64,
|
||||
byteLength: 256, layoutEpoch: 1, writable: false,
|
||||
});
|
||||
const meshGeneration = installArray(memory, {
|
||||
id: 4, name: "mesh.generation", domain: "mesh", scalar: "u32", lanes: 1,
|
||||
stride: 16, length: 16, capacity: 16, controlPtr: 199744, dataOffset: 64,
|
||||
byteLength: 256, layoutEpoch: 1, writable: false,
|
||||
});
|
||||
const upload = installArray(memory, {
|
||||
id: 5, name: "upload.gltf", domain: "fixed", scalar: "u32", lanes: 4,
|
||||
stride: 16, length: 16, capacity: 16, controlPtr: 200064, dataOffset: 64,
|
||||
byteLength: 256, layoutEpoch: 1, writable: true,
|
||||
});
|
||||
const worker = new WorkerMock();
|
||||
const bridge = { memory, ringPtr: 0, worker, freed: false, free() { this.freed = true; } };
|
||||
const core = new YawnCore(bridge);
|
||||
worker.reply({ type: "soa-init", arrays: [transform, type, generation, meshGeneration, upload] });
|
||||
return { memory, ring, worker, bridge, core, handles: new MeshHandles(core), transform, type, generation, upload };
|
||||
}
|
||||
|
||||
async function imported(fixture) {
|
||||
const loading = fixture.core.commitGlbUpload(fixture.core.array("upload.gltf"), 8);
|
||||
fixture.worker.reply({
|
||||
type: "reply",
|
||||
request: 1,
|
||||
ok: true,
|
||||
result: {
|
||||
meshes: [{
|
||||
handle: [7, 3],
|
||||
defaultInstance: [8, 5],
|
||||
defaultType: TYPE,
|
||||
}],
|
||||
},
|
||||
});
|
||||
const [mesh] = fixture.handles.fromImportedScene(await loading);
|
||||
const generations = new Int32Array(
|
||||
fixture.memory.buffer,
|
||||
fixture.generation.controlPtr + fixture.generation.dataOffset,
|
||||
fixture.generation.byteLength / 4,
|
||||
);
|
||||
Atomics.store(generations, 8 * (fixture.generation.stride / 4), 5);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
test("core commits shared scene uploads through metadata-only opcode 1", async () => {
|
||||
const fixture = setup();
|
||||
const pending = fixture.core.commitGlbUpload(fixture.core.array("upload.gltf"), 8, { framing: "interior" });
|
||||
assert.deepEqual([...new Int32Array(fixture.memory.buffer, 64, 6)], [2, 1, 1, 5, 8, 1]);
|
||||
fixture.worker.reply({ type: "reply", request: 1, ok: true, result: { meshes: [] } });
|
||||
assert.deepEqual(await pending, { meshes: [] });
|
||||
});
|
||||
|
||||
test("mesh handles are a separate conventional facade over core commands", async () => {
|
||||
const fixture = setup();
|
||||
const mesh = await imported(fixture);
|
||||
assert.deepEqual(mesh.handle, [7, 3]);
|
||||
assert.deepEqual(mesh.defaultInstance.handle, [8, 5]);
|
||||
|
||||
const creating = mesh.createInstance(IDENTITY, { type: TYPE });
|
||||
const slot = new Int32Array(fixture.memory.buffer, 64 + 160, 40);
|
||||
assert.equal(slot[1], 3);
|
||||
fixture.worker.reply({ type: "reply", request: 2, ok: true, result: [4, 2] });
|
||||
const instance = await creating;
|
||||
assert.deepEqual(instance.handle, [4, 2]);
|
||||
});
|
||||
|
||||
test("mesh handle picking wraps core protocol handles", async () => {
|
||||
const core = {
|
||||
async pickRay() {
|
||||
return { epoch: 4, hits: [{ instance: [3, 9], distance: 2 }] };
|
||||
},
|
||||
};
|
||||
const result = await new MeshHandles(core).pickRay([0, 0, 0], [1, 0, 0]);
|
||||
assert.deepEqual(result.hits[0].instance.handle, [3, 9]);
|
||||
assert.equal(result.hits[0].distance, 2);
|
||||
});
|
||||
|
||||
test("frequent instance mutations write guarded SOA lanes without ring messages", async () => {
|
||||
const fixture = setup();
|
||||
const mesh = await imported(fixture);
|
||||
const before = Atomics.load(fixture.ring, 5);
|
||||
|
||||
mesh.defaultInstance.setType(TYPE);
|
||||
mesh.defaultInstance.setTransform(IDENTITY);
|
||||
|
||||
assert.equal(Atomics.load(fixture.ring, 5), before);
|
||||
const type = new Int32Array(
|
||||
fixture.memory.buffer,
|
||||
fixture.type.controlPtr + fixture.type.dataOffset,
|
||||
fixture.type.byteLength / 4,
|
||||
);
|
||||
const base = 8 * (fixture.type.stride / 4);
|
||||
assert.deepEqual([...type.slice(base, base + 16)].map(value => value >>> 0), TYPE);
|
||||
assert.equal(Atomics.load(type, base + 16) >>> 0, 5);
|
||||
assert.equal(Atomics.load(type, base + 17) >>> 0, 1);
|
||||
});
|
||||
|
||||
test("generation columns reject stale handles and are read-only", async () => {
|
||||
const fixture = setup();
|
||||
const mesh = await imported(fixture);
|
||||
const generations = new Int32Array(
|
||||
fixture.memory.buffer,
|
||||
fixture.generation.controlPtr + fixture.generation.dataOffset,
|
||||
fixture.generation.byteLength / 4,
|
||||
);
|
||||
Atomics.store(generations, 8 * (fixture.generation.stride / 4), 6);
|
||||
assert.throws(() => mesh.defaultInstance.setTransform(IDENTITY), error => error.code === "STALE_HANDLE");
|
||||
assert.throws(() => fixture.core.array("instance.generation").write(8, [6]), error => error.code === "SOA_READ_ONLY");
|
||||
});
|
||||
|
||||
test("custom SOA columns are allocated through the payload command", async () => {
|
||||
const fixture = setup();
|
||||
const pending = fixture.core.allocateArray({
|
||||
name: "instance.velocity", domain: "instance", scalar: "f32", lanes: 4,
|
||||
});
|
||||
await Promise.resolve();
|
||||
fixture.worker.reply({ type: "payload-ready", id: 1 });
|
||||
await Promise.resolve();
|
||||
assert.equal(new Int32Array(fixture.memory.buffer, 64, 40)[1], 11);
|
||||
const descriptor = installArray(fixture.memory, {
|
||||
id: 5, name: "instance.velocity", domain: "instance", scalar: "f32", lanes: 4,
|
||||
stride: 16, length: 16, capacity: 16, controlPtr: 200064, dataOffset: 64,
|
||||
byteLength: 256, layoutEpoch: 1, writable: true,
|
||||
});
|
||||
fixture.worker.reply({ type: "reply", request: 1, ok: true, result: descriptor });
|
||||
const array = await pending;
|
||||
array.write(3, [1, 2, 3, 4]);
|
||||
assert.deepEqual(array.read(3), [1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test("graph compilation transfers canonical S-expressions, including pipeline declarations", async () => {
|
||||
const fixture = setup();
|
||||
const graph = createGraphAst({
|
||||
id: "compute_graph",
|
||||
revision: 1,
|
||||
pipelines: {
|
||||
compute: [{
|
||||
name: "prepare",
|
||||
shader: "@compute @workgroup_size(1) fn main() {}",
|
||||
entry: "main",
|
||||
dispatch: [1, 2, 3],
|
||||
}],
|
||||
},
|
||||
nodes: [],
|
||||
});
|
||||
const pending = fixture.core.compileGraph(serializeGraphAst(graph));
|
||||
const payload = fixture.worker.messages[0];
|
||||
assert.match(new TextDecoder().decode(payload.buffer), /^\(yawn-graph 1/);
|
||||
assert.match(new TextDecoder().decode(payload.buffer), /\"dispatch\" \(array 1 2 3\)/);
|
||||
fixture.worker.reply({ type: "payload-ready", id: 1 });
|
||||
await Promise.resolve();
|
||||
fixture.worker.reply({ type: "reply", request: 1, ok: true, result: { compiledId: [2, 3] } });
|
||||
assert.deepEqual(await pending, { compiledId: [2, 3] });
|
||||
});
|
||||
|
||||
test("graph lifecycle remains FIFO and uses opcodes 8 and 9", async () => {
|
||||
const fixture = setup();
|
||||
const first = fixture.core.switchCompiledGraph([9, 4]);
|
||||
const second = fixture.core.dropCompiledGraph([2, 1]);
|
||||
assert.equal(Atomics.load(fixture.ring, 5), 1);
|
||||
fixture.worker.reply({ type: "reply", request: 1, ok: true });
|
||||
await first;
|
||||
await new Promise(queueMicrotask);
|
||||
assert.equal(Atomics.load(fixture.ring, 5), 2);
|
||||
const secondSlot = new Int32Array(fixture.memory.buffer, 64 + 160, 40);
|
||||
assert.equal(secondSlot[1], 8);
|
||||
fixture.worker.reply({ type: "reply", request: 2, ok: true });
|
||||
await second;
|
||||
});
|
||||
|
||||
test("transport failures reject pending work and dispose owned resources", async () => {
|
||||
const fixture = setup();
|
||||
const pending = fixture.core.dropCompiledGraph([1, 1]);
|
||||
fixture.worker.dispatchEvent(new Event("error"));
|
||||
await assert.rejects(pending, error => error instanceof RendererError && error.code === "WORKER_ERROR");
|
||||
assert.equal(fixture.worker.terminated, true);
|
||||
assert.equal(fixture.bridge.freed, true);
|
||||
});
|
||||
Reference in New Issue
Block a user