Compare commits
7
Commits
e7546e4b78
...
bc957cf40d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc957cf40d | ||
|
|
a4f3eeddd6 | ||
|
|
f324ff6bb2 | ||
|
|
92638e6c9f | ||
|
|
31d3ae21ff | ||
|
|
49c80a209e | ||
|
|
dda602c4a2 |
@@ -4,7 +4,7 @@ export type GraphBuffer = {
|
||||
id: string;
|
||||
array: string;
|
||||
usage?: string[];
|
||||
sync?: "frame" | "loadout";
|
||||
sync?: "frame" | "manual";
|
||||
};
|
||||
|
||||
export type GraphTexture = {
|
||||
|
||||
+27
-36
@@ -12,18 +12,17 @@ export const VertexKinds = Object.freeze([
|
||||
"indices",
|
||||
] as const);
|
||||
export type VertexKind = (typeof VertexKinds)[number];
|
||||
export type VertexStreams = Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||
export type MeshOptions = NodeOptions & {
|
||||
geometryId?: number;
|
||||
material?: PBRMaterial;
|
||||
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||
vertexData?: VertexStreams;
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
/** A renderable Node; clones share geometry until either clone mutates vertex data. */
|
||||
export class Mesh extends Node {
|
||||
geometryId: number;
|
||||
vertexCount = 0;
|
||||
indexCount = 0;
|
||||
instanceOf = -1;
|
||||
readonly faceMaterials = new Map<number, number>();
|
||||
#registered = false;
|
||||
@@ -34,13 +33,6 @@ export class Mesh extends Node {
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
if (options.material) await options.material.ready;
|
||||
await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32");
|
||||
this.vertexCount =
|
||||
Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) /
|
||||
3;
|
||||
this.indexCount = Number(
|
||||
scene.geometryData(this.geometryId, "indices")?.length ?? 0,
|
||||
);
|
||||
this.instanceOf =
|
||||
options.geometryId === undefined ? this.id : this.geometryId;
|
||||
scene
|
||||
@@ -51,8 +43,8 @@ export class Mesh extends Node {
|
||||
options.visible === false ? 0 : 1,
|
||||
this.instanceOf,
|
||||
]);
|
||||
for (const [kind, data] of Object.entries(options.vertexData ?? {}))
|
||||
await this.#setVertexData(kind as VertexKind, data!, false);
|
||||
if (options.vertexData)
|
||||
await this.#setVertexData(options.vertexData, false);
|
||||
this.#writeBounds();
|
||||
this.#registered = true;
|
||||
await scene.registerMesh(this);
|
||||
@@ -60,6 +52,14 @@ export class Mesh extends Node {
|
||||
});
|
||||
}
|
||||
|
||||
get vertexCount() {
|
||||
return this.scene.geometry(this.geometryId).vertexCount;
|
||||
}
|
||||
|
||||
get indexCount() {
|
||||
return this.scene.geometry(this.geometryId).indexCount;
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
if (this.id < 0) throw new Error("Await mesh.ready before reading it");
|
||||
return this.scene.array("meshInfo").row(this.id)[2] !== 0;
|
||||
@@ -87,20 +87,22 @@ export class Mesh extends Node {
|
||||
return new Mesh(this.scene, { ...options, geometryId: this.geometryId });
|
||||
}
|
||||
|
||||
async setVertexData(kind: VertexKind, data: ArrayLike<number>) {
|
||||
if (!VertexKinds.includes(kind))
|
||||
throw new TypeError(`Unknown vertex kind: ${kind}`);
|
||||
/** Replaces one stream, or several at once, repacking the geometry a single time. */
|
||||
async setVertexData(
|
||||
kind: VertexKind | VertexStreams,
|
||||
data?: ArrayLike<number>,
|
||||
) {
|
||||
const streams = typeof kind === "string" ? { [kind]: data } : kind;
|
||||
for (const name of Object.keys(streams))
|
||||
if (!VertexKinds.includes(name as VertexKind))
|
||||
throw new TypeError(`Unknown vertex kind: ${name}`);
|
||||
await this.ready;
|
||||
await this.#setVertexData(kind, data, true);
|
||||
await this.#setVertexData(streams, true);
|
||||
return this;
|
||||
}
|
||||
|
||||
async #setVertexData(
|
||||
kind: VertexKind,
|
||||
data: ArrayLike<number>,
|
||||
makeUnique: boolean,
|
||||
) {
|
||||
if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) {
|
||||
async #setVertexData(streams: VertexStreams, makeUnique: boolean) {
|
||||
if (makeUnique && this.scene.geometry(this.geometryId).references > 1) {
|
||||
const original = this.geometryId;
|
||||
this.geometryId = await this.scene.cloneGeometry(original);
|
||||
this.scene.releaseGeometry(original);
|
||||
@@ -117,10 +119,8 @@ export class Mesh extends Node {
|
||||
this.id,
|
||||
]);
|
||||
}
|
||||
if (kind === "positions") this.vertexCount = data.length / 3;
|
||||
if (kind === "indices") this.indexCount = data.length;
|
||||
await this.scene.setVertexData(this.geometryId, kind, data, makeUnique);
|
||||
if (kind === "positions") this.#writeBounds();
|
||||
await this.scene.setVertexData(this.geometryId, streams, makeUnique);
|
||||
if (streams.positions) this.#writeBounds();
|
||||
}
|
||||
|
||||
async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) {
|
||||
@@ -128,19 +128,11 @@ export class Mesh extends Node {
|
||||
if (material.scene !== this.scene)
|
||||
throw new Error("Material must belong to the same Scene");
|
||||
const list = Array.isArray(faces) ? faces : [faces];
|
||||
const maximum = Math.max(...list);
|
||||
const array = await this.scene.ensureRows(
|
||||
`mesh.${this.id}.faceMaterials`,
|
||||
maximum + 1,
|
||||
16,
|
||||
"u32",
|
||||
);
|
||||
this.scene.markDirty(true);
|
||||
for (const face of list) {
|
||||
if (!Number.isInteger(face) || face < 0) throw new RangeError("face");
|
||||
array.row(face)[0] = material.id + 1;
|
||||
this.faceMaterials.set(face, material.id);
|
||||
}
|
||||
this.scene.markDirty(true);
|
||||
await this.scene.updateRenderGraph();
|
||||
return this;
|
||||
}
|
||||
@@ -168,7 +160,6 @@ export class Mesh extends Node {
|
||||
this.#registered = false;
|
||||
await this.scene.unregisterMesh(this);
|
||||
}
|
||||
await this.scene.core.deleteRows(`mesh.${this.id}.faceMaterials`);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
+644
-543
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,20 @@ export async function importGltf(scene: Scene, url: string | URL) {
|
||||
await scene.reserve({
|
||||
nodes: result.primitives.length,
|
||||
materials: result.materials.length,
|
||||
vertices: result.primitives.reduce(
|
||||
(total: number, primitive: any) => total + primitive.positions.length / 3,
|
||||
0,
|
||||
),
|
||||
// Each geometry's index span is padded to whole rows of four.
|
||||
indices: result.primitives.reduce(
|
||||
(total: number, primitive: any) =>
|
||||
total +
|
||||
Math.ceil(
|
||||
(primitive.indices?.length ?? primitive.positions.length / 3) / 4,
|
||||
) *
|
||||
4,
|
||||
0,
|
||||
),
|
||||
});
|
||||
let textures: Texture[] = [];
|
||||
const imported = await scene.batchGraphUpdates(async () => {
|
||||
|
||||
@@ -129,6 +129,7 @@ function deserialize(serialized: string) {
|
||||
|
||||
export class YawnCore {
|
||||
readonly ready = Promise.resolve();
|
||||
readonly features = new Set(["timestamp-query", "indirect-first-instance"]);
|
||||
readonly graphs: any[] = [];
|
||||
readonly serializedGraphs: string[] = [];
|
||||
readonly loadouts: string[] = [];
|
||||
@@ -171,11 +172,11 @@ export class YawnCore {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
allocateObject(name: string) {
|
||||
allocateObject(name: string, count = 1) {
|
||||
const id = this.#nextObjects.get(name) ?? 0;
|
||||
this.#nextObjects.set(name, id + 1);
|
||||
this.#nextObjects.set(name, id + count);
|
||||
const rows = this.array(name);
|
||||
rows.resize(id + 1);
|
||||
rows.resize(id + count);
|
||||
return Promise.resolve(id);
|
||||
}
|
||||
|
||||
@@ -183,6 +184,10 @@ export class YawnCore {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
syncRows() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
array(name: string) {
|
||||
const rows = this.#arrays.get(name);
|
||||
if (!rows) throw new Error(`UNKNOWN_ARRAY: ${name}`);
|
||||
|
||||
@@ -6,7 +6,13 @@ 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";
|
||||
import {
|
||||
bucketDraws,
|
||||
createScene,
|
||||
latestGraph,
|
||||
testCore,
|
||||
triangle,
|
||||
} from "./scene-helpers";
|
||||
|
||||
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
|
||||
assert.equal(actual.length, expected.length);
|
||||
@@ -139,6 +145,13 @@ test("Mesh packs geometry and bounds, then clones with copy-on-write", async ()
|
||||
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;
|
||||
@@ -155,9 +168,81 @@ test("Mesh packs geometry and bounds, then clones with copy-on-write", async ()
|
||||
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("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],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FXAA } from "../src/PostProcesses/FXAA";
|
||||
import { PBRMaterial } from "../src/materials/PBRMaterial";
|
||||
import { Texture } from "../src/materials/Texture";
|
||||
import {
|
||||
bucketDraws,
|
||||
createScene,
|
||||
detailedMesh,
|
||||
forwardPasses,
|
||||
@@ -91,15 +92,16 @@ test("materials with identical shader features share one pipeline", async () =>
|
||||
await Promise.all([firstMesh.ready, secondMesh.ready]);
|
||||
|
||||
const graph = latestGraph(scene);
|
||||
const passes = forwardPasses(graph);
|
||||
const draws = bucketDraws(scene, graph);
|
||||
assert.equal(pbrPipelines(graph).length, 1);
|
||||
assert.equal(passes.length, 2);
|
||||
// One indirect bucket pass holds both meshes: same pipeline and bind set.
|
||||
assert.equal(forwardPasses(graph).length, 1);
|
||||
assert.deepEqual(
|
||||
passes.map((pass: any) => pass.draw.firstInstance & 0xffff),
|
||||
draws.map((draw) => draw.firstInstance & 0xffff),
|
||||
[firstMesh.id, secondMesh.id],
|
||||
);
|
||||
assert.deepEqual(
|
||||
passes.map((pass: any) => (pass.draw.firstInstance >>> 16) - 1),
|
||||
draws.map((draw) => (draw.firstInstance >>> 16) - 1),
|
||||
[firstMaterial.id, secondMaterial.id],
|
||||
);
|
||||
});
|
||||
@@ -315,7 +317,107 @@ test("batchGraphUpdates compiles once for multiple mesh registrations", async ()
|
||||
});
|
||||
|
||||
assert.equal(testCore(scene).graphs.length - before, 1);
|
||||
assert.equal(forwardPasses(latestGraph(scene)).length, 2);
|
||||
assert.equal(forwardPasses(latestGraph(scene)).length, 1);
|
||||
assert.equal(bucketDraws(scene, latestGraph(scene)).length, 2);
|
||||
});
|
||||
|
||||
test("draw buckets persist per bind set, zero their inactive tail, and stay blocked while compiling", async () => {
|
||||
const scene = await createScene();
|
||||
const core = testCore(scene);
|
||||
const plain = new PBRMaterial(scene);
|
||||
const texture = new Texture(scene, { size: [1, 1, 1] });
|
||||
await texture.ready;
|
||||
const textured = new PBRMaterial(scene, { baseColorTexture: texture });
|
||||
await Promise.all([plain.ready, textured.ready]);
|
||||
const meshes: Mesh[] = [];
|
||||
for (let index = 0; index < 3; index++)
|
||||
meshes.push(await detailedMesh(scene, plain).ready);
|
||||
|
||||
// Three plain meshes fill one bucket; the array grew geometrically from 1 → 2 → 4 rows.
|
||||
let graph = latestGraph(scene);
|
||||
const bucket = graph.resources.buffers.find((buffer: any) =>
|
||||
buffer.id.startsWith("draws-"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ usage: bucket.usage, sync: bucket.sync },
|
||||
{ usage: ["indirect"], sync: "manual" },
|
||||
);
|
||||
assert.equal(core.array(bucket.array).rows, 4);
|
||||
assert.equal(bucketDraws(scene, graph).length, 3);
|
||||
assert.equal(forwardPasses(graph).length, 1);
|
||||
|
||||
// Moving one mesh to a textured material creates a second bucket; the first keeps two rows
|
||||
// and its freed tail is zero (instanceCount 0) so the GPU skips it.
|
||||
meshes[1].material = textured;
|
||||
await meshes[1].ready;
|
||||
await scene.updateRenderGraph();
|
||||
graph = latestGraph(scene);
|
||||
assert.equal(forwardPasses(graph).length, 2);
|
||||
const draws = bucketDraws(scene, graph);
|
||||
assert.deepEqual(
|
||||
draws.map((draw) => [draw.pass, draw.firstInstance & 0xffff]),
|
||||
[
|
||||
["forward-bucket-0", meshes[0].id],
|
||||
["forward-bucket-0", meshes[2].id],
|
||||
["forward-bucket-1", meshes[1].id],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...core.array(bucket.array).view.subarray(16, 32)],
|
||||
Array(16).fill(0),
|
||||
);
|
||||
|
||||
// Back to plain: the second bucket vanishes from the graph, the first regains its row.
|
||||
meshes[1].material = plain;
|
||||
await meshes[1].ready;
|
||||
await scene.updateRenderGraph();
|
||||
graph = latestGraph(scene);
|
||||
assert.equal(forwardPasses(graph).length, 1);
|
||||
assert.equal(bucketDraws(scene, graph).length, 3);
|
||||
|
||||
// A compile that starts after an earlier switch cleared signal 6 re-asserts it before
|
||||
// touching bucket rows, so no frame renders the old graph with new bucket contents.
|
||||
const signals = core.array("signals").row(0);
|
||||
const switchLoadout = core.switchLoadout.bind(core);
|
||||
let observed = -1;
|
||||
core.switchLoadout = async (id) => {
|
||||
await switchLoadout(id);
|
||||
signals[6] = 0;
|
||||
core.switchLoadout = async (next) => {
|
||||
observed = signals[6];
|
||||
await switchLoadout(next);
|
||||
};
|
||||
};
|
||||
await Promise.all([scene.updateRenderGraph(), scene.updateRenderGraph()]);
|
||||
assert.equal(observed, 1);
|
||||
core.switchLoadout = switchLoadout;
|
||||
});
|
||||
|
||||
test("opaque buckets render into the composited layer ahead of transmission", async () => {
|
||||
const scene = await createScene();
|
||||
const opaque = new PBRMaterial(scene);
|
||||
const glass = new PBRMaterial(scene, { transmission: 0.9 });
|
||||
await Promise.all([opaque.ready, glass.ready]);
|
||||
const solid = detailedMesh(scene, opaque);
|
||||
const window = detailedMesh(scene, glass);
|
||||
await Promise.all([solid.ready, window.ready]);
|
||||
|
||||
const passes = forwardPasses(latestGraph(scene));
|
||||
assert.deepEqual(
|
||||
passes.map((pass: any) => [
|
||||
pass.id,
|
||||
pass.color[0].resource,
|
||||
Boolean(pass.indirect),
|
||||
]),
|
||||
[
|
||||
["forward-bucket-0", "scene-color", true],
|
||||
[
|
||||
`forward-${window.id}-${scene.geometry(window.geometryId).indexRow * 4}`,
|
||||
"hdr",
|
||||
false,
|
||||
],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("render graph snapshots expose isolated scene-owned declarations", async () => {
|
||||
|
||||
@@ -53,3 +53,29 @@ export function detailedMesh(
|
||||
) {
|
||||
return new Mesh(scene, { material, vertexData });
|
||||
}
|
||||
|
||||
/** Decodes the active indirect rows of every forward bucket pass, in pass order. */
|
||||
export function bucketDraws(scene: Scene, graph: any) {
|
||||
return forwardPasses(graph)
|
||||
.filter((pass: any) => pass.indirect)
|
||||
.flatMap((pass: any) => {
|
||||
const buffer = graph.resources.buffers.find(
|
||||
(buffer: any) => buffer.id === pass.indirect.resource,
|
||||
);
|
||||
const view = scene.core.array(buffer.array).view;
|
||||
const draws = [];
|
||||
for (let row = 0; row * 8 < view.length; row++) {
|
||||
const [indices, instances, firstIndex, baseVertex, firstInstance] =
|
||||
view.subarray(row * 8, row * 8 + 5);
|
||||
if (instances)
|
||||
draws.push({
|
||||
pass: pass.id,
|
||||
indices,
|
||||
firstIndex,
|
||||
baseVertex,
|
||||
firstInstance,
|
||||
});
|
||||
}
|
||||
return draws;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ uses headed Chromium on a private Xvfb display and SwiftShader WebGPU at a fixed
|
||||
The first run downloads roughly 215 MB of GLBs into
|
||||
`~/.cache/yawn/gltf-sample-models`. Set `YAWN_GLTF_FIXTURES` to use another
|
||||
cache directory, or `YAWN_SCREENSHOT_MODELS=Box,BoomBox` to run a subset.
|
||||
`YAWN_SCREENSHOT_BASELINES` points at another reference directory (for example
|
||||
one captured from a known-good commit on a machine whose software renderer does
|
||||
not match the reviewed references), and `YAWN_SCREENSHOT_SETTLE_MS` (default
|
||||
1200) is how long the viewer gets to settle after the model loads before the
|
||||
capture; raise it on slow software renderers.
|
||||
Failed full-size captures and visual diffs are written to `output/`.
|
||||
|
||||
To intentionally replace references after reviewing full-size renders, run:
|
||||
|
||||
@@ -25,7 +25,10 @@ import { promisify } from "node:util";
|
||||
const execute = promisify(execFile);
|
||||
const directory = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(directory, "../../../..");
|
||||
const baselines = join(directory, "baselines");
|
||||
const baselines = resolve(
|
||||
process.env.YAWN_SCREENSHOT_BASELINES ?? join(directory, "baselines"),
|
||||
);
|
||||
const settleMilliseconds = Number(process.env.YAWN_SCREENSHOT_SETTLE_MS ?? 1200);
|
||||
const output = resolve(
|
||||
process.env.YAWN_SCREENSHOT_OUTPUT ?? join(directory, "output"),
|
||||
);
|
||||
@@ -44,7 +47,7 @@ const update = process.env.UPDATE_SCREENSHOTS === "1";
|
||||
const session = `yawn-gltf-screenshots-${process.pid}`;
|
||||
const temporary = await mkdtemp(join(tmpdir(), "yawn-gltf-screenshots-"));
|
||||
const imageSize = { width: 250, height: 180 };
|
||||
const allModels = (await readdir(baselines))
|
||||
const allModels = (await readdir(join(directory, "baselines")))
|
||||
.filter((name) => name.endsWith(".png") && name !== "initial.png")
|
||||
.map((name) => basename(name, ".png"))
|
||||
.sort()
|
||||
@@ -366,7 +369,7 @@ for (const model of models) {
|
||||
);
|
||||
await browser("upload", "#file-picker", model.path);
|
||||
await browser("wait", "--fn", "window.__yawnScreenshotReady === true");
|
||||
await browser("wait", "1200");
|
||||
await browser("wait", String(settleMilliseconds));
|
||||
|
||||
const state = await browser(
|
||||
"eval",
|
||||
|
||||
+51
-19
@@ -1,6 +1,8 @@
|
||||
const views = Object.freeze({ f32: Float32Array, u32: Uint32Array, i32: Int32Array });
|
||||
|
||||
export class SharedRows {
|
||||
#view;
|
||||
|
||||
constructor(buffer, descriptor) {
|
||||
if (!(buffer instanceof SharedArrayBuffer)) throw new TypeError("ROWS_DESCRIPTOR");
|
||||
this.buffer = buffer;
|
||||
@@ -11,6 +13,8 @@ export class SharedRows {
|
||||
if (!views[descriptor.format] || descriptor.name !== (this.descriptor?.name ?? descriptor.name))
|
||||
throw new TypeError("ROWS_DESCRIPTOR");
|
||||
this.descriptor = Object.freeze(descriptor);
|
||||
const View = views[descriptor.format];
|
||||
this.#view = new View(this.buffer, descriptor.offset, descriptor.bytes / View.BYTES_PER_ELEMENT);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -18,10 +22,8 @@ export class SharedRows {
|
||||
get rows() { return this.descriptor.rows; }
|
||||
get stride() { return this.descriptor.stride; }
|
||||
get format() { return this.descriptor.format; }
|
||||
get view() {
|
||||
const View = views[this.format];
|
||||
return new View(this.buffer, this.descriptor.offset, this.descriptor.bytes / View.BYTES_PER_ELEMENT);
|
||||
}
|
||||
/** One typed view over the whole array; replaced only when the descriptor changes. */
|
||||
get view() { return this.#view; }
|
||||
|
||||
row(index) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= this.rows) throw new RangeError("ROW_RANGE");
|
||||
@@ -48,6 +50,8 @@ export class YawnCore {
|
||||
#pending = new Map();
|
||||
#profileListeners = new Set();
|
||||
#next = 1;
|
||||
/** Optional WebGPU features the device has (WebGPU spelling); populated once `ready` resolves. */
|
||||
features = new Set();
|
||||
|
||||
constructor(canvas, { arenaBytes = 64 * 1024 * 1024, debug = false, workerFactory } = {}) {
|
||||
if (!canvas) throw new TypeError("canvas is required");
|
||||
@@ -62,6 +66,7 @@ export class YawnCore {
|
||||
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
|
||||
this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(async result => {
|
||||
this.#buffer = result.buffer;
|
||||
this.features = new Set(result.features);
|
||||
for (const descriptor of result.rows) this.#arrays.set(
|
||||
descriptor.name,
|
||||
new SharedRows(this.#buffer, descriptor),
|
||||
@@ -72,23 +77,17 @@ export class YawnCore {
|
||||
|
||||
async createRows({ name, rows, stride, format }) {
|
||||
await this.ready;
|
||||
const descriptor = await this.#request("create-rows", { name, rows, stride, format });
|
||||
const array = this.#arrays.get(name)?.update(descriptor)
|
||||
?? new SharedRows(this.#buffer, descriptor);
|
||||
this.#arrays.set(name, array);
|
||||
return array;
|
||||
const result = await this.#request("create-rows", { name, rows, stride, format });
|
||||
return this.#adopt(result.rows, result.commit ? [name] : []);
|
||||
}
|
||||
|
||||
async createRowsBatch(rows) {
|
||||
await this.ready;
|
||||
if (!Array.isArray(rows) || !rows.length) throw new TypeError("ROWS");
|
||||
const descriptors = await this.#request("create-rows-batch", { rows });
|
||||
return descriptors.map(descriptor => {
|
||||
const array = this.#arrays.get(descriptor.name)?.update(descriptor)
|
||||
?? new SharedRows(this.#buffer, descriptor);
|
||||
this.#arrays.set(descriptor.name, array);
|
||||
return array;
|
||||
});
|
||||
const result = await this.#request("create-rows-batch", { rows });
|
||||
const arrays = result.rows.map(descriptor => this.#adopt(descriptor));
|
||||
this.#commit(result.commit);
|
||||
return arrays;
|
||||
}
|
||||
|
||||
async deleteRows(name) {
|
||||
@@ -97,18 +96,51 @@ export class YawnCore {
|
||||
this.#arrays.delete(name);
|
||||
}
|
||||
|
||||
async allocateObject(name) {
|
||||
/** Reserves `count` contiguous rows and resolves with the first row ID. */
|
||||
async allocateObject(name, count = 1) {
|
||||
await this.ready;
|
||||
const { id, rows } = await this.#request("allocate-object", { name });
|
||||
this.#arrays.get(name).update(rows);
|
||||
const { id, rows, commit } = await this.#request("allocate-object", { name, count });
|
||||
this.#adopt(rows, commit ? [name] : []);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a descriptor the worker returned. When the rows moved, this thread owns the copy:
|
||||
* the old bytes are copied synchronously into the new region and the worker is told to commit
|
||||
* before anything else can be requested, so no write to the array is ever lost.
|
||||
*/
|
||||
#adopt(descriptor, commit = []) {
|
||||
const current = this.#arrays.get(descriptor.name);
|
||||
if (!current) {
|
||||
const array = new SharedRows(this.#buffer, descriptor);
|
||||
this.#arrays.set(descriptor.name, array);
|
||||
return array;
|
||||
}
|
||||
if (descriptor.offset !== current.descriptor.offset) {
|
||||
const { offset, bytes } = current.descriptor;
|
||||
new Uint8Array(this.#buffer, descriptor.offset, bytes).set(new Uint8Array(this.#buffer, offset, bytes));
|
||||
}
|
||||
current.update(descriptor);
|
||||
this.#commit(commit);
|
||||
return current;
|
||||
}
|
||||
|
||||
#commit(names) {
|
||||
if (!names.length) return;
|
||||
this.#request("commit-rows", { names }).catch(error => this.#fail(error.code));
|
||||
}
|
||||
|
||||
async deleteObject(name, id) {
|
||||
await this.ready;
|
||||
return this.#request("delete-object", { name, id });
|
||||
}
|
||||
|
||||
/** Uploads rows `first .. first + count` of `name` to every active GPU buffer bound to that array. */
|
||||
async syncRows(name, first, count) {
|
||||
await this.ready;
|
||||
return this.#request("sync-rows", { name, first, count });
|
||||
}
|
||||
|
||||
async compileGraph(serialized) {
|
||||
await this.ready;
|
||||
return this.#request("compile-graph", { serialized });
|
||||
|
||||
+108
-31
@@ -1,28 +1,46 @@
|
||||
//! The arena and allocator compile natively for `cargo test`; everything touching WebGPU is wasm-only.
|
||||
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::cell::RefCell;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::rc::Rc;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use serde::Deserialize;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "render_graph/compiler.rs"]
|
||||
mod compiler;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "renderer/wgpu.rs"]
|
||||
mod gpu;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "render_graph/gpu_resource.rs"]
|
||||
mod gpu_resource;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "render_graph/render_graph.rs"]
|
||||
mod graph;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "renderer/render.rs"]
|
||||
mod render;
|
||||
mod render_data;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "render_graph/store.rs"]
|
||||
mod store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use gpu::Wgpu;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use render::RenderLoop;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use render_data::RenderData;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use store::Store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(Deserialize)]
|
||||
struct RowRequest {
|
||||
name: String,
|
||||
@@ -31,6 +49,7 @@ struct RowRequest {
|
||||
format: String,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
pub struct Core {
|
||||
data: Rc<RefCell<RenderData>>,
|
||||
@@ -39,6 +58,7 @@ pub struct Core {
|
||||
render: Rc<RenderLoop>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
impl Core {
|
||||
#[wasm_bindgen(constructor)]
|
||||
@@ -67,6 +87,14 @@ impl Core {
|
||||
serde_json::to_string(&self.data.borrow().descriptors()).unwrap()
|
||||
}
|
||||
|
||||
/// Optional WebGPU features the device was created with (for example `indirect-first-instance`).
|
||||
pub fn features(&self) -> String {
|
||||
let gpu = self.gpu.borrow();
|
||||
serde_json::to_string(&gpu.as_ref().map(|gpu| &gpu.features[..]).unwrap_or(&[])).unwrap()
|
||||
}
|
||||
|
||||
/// Creates or grows an array. When `commit` is set the rows moved: the caller must copy the old
|
||||
/// bytes into the returned descriptor and send `commit_rows` before any other request.
|
||||
pub fn create_rows(
|
||||
&self,
|
||||
name: String,
|
||||
@@ -74,20 +102,18 @@ impl Core {
|
||||
stride: u32,
|
||||
format: String,
|
||||
) -> Result<String, JsError> {
|
||||
let rows = self
|
||||
let (rows, moved) = self
|
||||
.data
|
||||
.borrow_mut()
|
||||
.create_rows(name, rows, stride, format)
|
||||
.map_err(JsError::new)?;
|
||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
||||
self.store
|
||||
.borrow_mut()
|
||||
.refresh_rows(&rows.name, gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
if !moved {
|
||||
self.refresh_rows(&rows.name)?;
|
||||
}
|
||||
Ok(serde_json::to_string(&rows).unwrap())
|
||||
Ok(serde_json::json!({ "rows": rows, "commit": moved }).to_string())
|
||||
}
|
||||
|
||||
/// Like `create_rows` for several arrays; `commit` lists the arrays that moved.
|
||||
pub fn create_rows_batch(&self, source: &str) -> Result<String, JsError> {
|
||||
let requests: Vec<RowRequest> =
|
||||
serde_json::from_str(source).map_err(|_| JsError::new("ROWS"))?;
|
||||
@@ -97,25 +123,66 @@ impl Core {
|
||||
let refresh = requests
|
||||
.iter()
|
||||
.any(|request| self.store.borrow().uses_rows(&request.name));
|
||||
let descriptors = {
|
||||
let results = {
|
||||
let mut data = self.data.borrow_mut();
|
||||
requests
|
||||
let results = requests
|
||||
.into_iter()
|
||||
.map(|request| {
|
||||
data.create_rows(request.name, request.rows, request.stride, request.format)
|
||||
.map_err(JsError::new)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
if refresh {
|
||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
||||
self.store
|
||||
.borrow_mut()
|
||||
.refresh(gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
.collect::<Result<Vec<_>, _>>();
|
||||
if results.is_err() {
|
||||
data.discard_pending();
|
||||
}
|
||||
results.map_err(JsError::new)?
|
||||
};
|
||||
let commit = results
|
||||
.iter()
|
||||
.filter(|(_, moved)| *moved)
|
||||
.map(|(rows, _)| rows.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if refresh && results.iter().any(|(_, moved)| !moved) {
|
||||
self.refresh()?;
|
||||
}
|
||||
Ok(serde_json::to_string(&descriptors).unwrap())
|
||||
let descriptors = results
|
||||
.into_iter()
|
||||
.map(|(rows, _)| rows)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(serde_json::json!({ "rows": descriptors, "commit": commit }).to_string())
|
||||
}
|
||||
|
||||
/// Publishes relocations the caller finished copying, then rebuilds the GPU side once.
|
||||
pub fn commit_rows(&self, source: &str) -> Result<(), JsError> {
|
||||
let names: Vec<String> = serde_json::from_str(source).map_err(|_| JsError::new("ROWS"))?;
|
||||
self.data
|
||||
.borrow_mut()
|
||||
.commit(&names)
|
||||
.map_err(JsError::new)?;
|
||||
if names.iter().any(|name| self.store.borrow().uses_rows(name)) {
|
||||
self.refresh()?;
|
||||
}
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_rows(&self, name: &str) -> Result<(), JsError> {
|
||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
||||
self.store
|
||||
.borrow_mut()
|
||||
.refresh_rows(name, gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh(&self) -> Result<(), JsError> {
|
||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
||||
self.store
|
||||
.borrow_mut()
|
||||
.refresh(gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_rows(&self, name: String) -> Result<(), JsError> {
|
||||
@@ -128,27 +195,23 @@ impl Core {
|
||||
.map_err(JsError::new)
|
||||
}
|
||||
|
||||
pub fn allocate_object(&self, name: &str) -> Result<String, JsError> {
|
||||
let (id, grew) = self
|
||||
/// Reserves rows; `commit` is set when the array moved, exactly like `create_rows`.
|
||||
pub fn allocate_object(&self, name: &str, count: u32) -> Result<String, JsError> {
|
||||
let (id, grew, moved) = self
|
||||
.data
|
||||
.borrow_mut()
|
||||
.allocate_object(name)
|
||||
.allocate_object(name, count)
|
||||
.map_err(JsError::new)?;
|
||||
let rows = self
|
||||
.data
|
||||
.borrow()
|
||||
.rows(name)
|
||||
.staged(name)
|
||||
.ok_or_else(|| JsError::new("ROWS_UNKNOWN"))?
|
||||
.clone();
|
||||
if grew {
|
||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
||||
self.store
|
||||
.borrow_mut()
|
||||
.refresh_rows(name, gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
}
|
||||
if grew && !moved {
|
||||
self.refresh_rows(name)?;
|
||||
}
|
||||
Ok(serde_json::json!({ "id": id, "rows": rows }).to_string())
|
||||
Ok(serde_json::json!({ "id": id, "rows": rows, "commit": moved }).to_string())
|
||||
}
|
||||
|
||||
pub fn delete_object(&self, name: &str, id: u32) -> Result<(), JsError> {
|
||||
@@ -160,6 +223,20 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uploads rows `first .. first + count` of `name` to every active GPU buffer bound to it.
|
||||
pub fn sync_rows(&self, name: &str, first: u32, count: u32) -> Result<(), JsError> {
|
||||
let gpu = self.gpu.borrow();
|
||||
let gpu = gpu
|
||||
.as_ref()
|
||||
.ok_or_else(|| JsError::new("WEBGPU_UNINITIALIZED"))?;
|
||||
self.store
|
||||
.borrow()
|
||||
.sync_rows(name, first, count, gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compile_graph(&self, source: &str) -> Result<String, JsError> {
|
||||
let graph = compiler::compile(source).map_err(JsError::new)?;
|
||||
Ok(self.store.borrow_mut().save(graph))
|
||||
|
||||
+171
-100
@@ -1,10 +1,68 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
const ALIGNMENT: u32 = 64;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
/// First-fit interval allocator over `u32` offsets; adjacent free ranges coalesce on release.
|
||||
#[derive(Default)]
|
||||
struct FreeList(BTreeMap<u32, u32>);
|
||||
|
||||
impl FreeList {
|
||||
fn allocate(&mut self, size: u32, align: u32) -> Option<u32> {
|
||||
let (block, block_size, offset) = self.0.iter().find_map(|(&block, &block_size)| {
|
||||
let offset = block.checked_add(align - 1)? & !(align - 1);
|
||||
(offset.checked_add(size)? <= block + block_size).then_some((block, block_size, offset))
|
||||
})?;
|
||||
self.0.remove(&block);
|
||||
if offset > block {
|
||||
self.0.insert(block, offset - block);
|
||||
}
|
||||
let end = offset + size;
|
||||
if end < block + block_size {
|
||||
self.0.insert(end, block + block_size - end);
|
||||
}
|
||||
Some(offset)
|
||||
}
|
||||
|
||||
/// Claims exactly `[offset, offset + size)` when that range is entirely free.
|
||||
fn take(&mut self, offset: u32, size: u32) -> bool {
|
||||
let Some((&block, &block_size)) = self.0.range(..=offset).next_back() else {
|
||||
return false;
|
||||
};
|
||||
if offset + size > block + block_size {
|
||||
return false;
|
||||
}
|
||||
self.0.remove(&block);
|
||||
if offset > block {
|
||||
self.0.insert(block, offset - block);
|
||||
}
|
||||
let end = offset + size;
|
||||
if end < block + block_size {
|
||||
self.0.insert(end, block + block_size - end);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn release(&mut self, mut offset: u32, mut size: u32) {
|
||||
if let Some((&previous, &previous_size)) = self.0.range(..offset).next_back() {
|
||||
if previous + previous_size == offset {
|
||||
self.0.remove(&previous);
|
||||
offset = previous;
|
||||
size += previous_size;
|
||||
}
|
||||
}
|
||||
if let Some((&next, &next_size)) = self.0.range(offset..).next() {
|
||||
if offset + size == next {
|
||||
self.0.remove(&next);
|
||||
size += next_size;
|
||||
}
|
||||
}
|
||||
self.0.insert(offset, size);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Rows {
|
||||
pub name: String,
|
||||
pub rows: u32,
|
||||
@@ -14,20 +72,22 @@ pub struct Rows {
|
||||
pub bytes: u32,
|
||||
}
|
||||
|
||||
/// Row spans handed out by `allocate_object`, keyed by their first row.
|
||||
#[derive(Default)]
|
||||
struct Slots {
|
||||
active: HashSet<u32>,
|
||||
free: Vec<u32>,
|
||||
next: u32,
|
||||
free: FreeList,
|
||||
spans: HashMap<u32, u32>,
|
||||
}
|
||||
|
||||
pub struct RenderData {
|
||||
arena: Box<[u8]>,
|
||||
arena_start: usize,
|
||||
base: u32,
|
||||
free: BTreeMap<u32, u32>,
|
||||
free: FreeList,
|
||||
rows: HashMap<String, Rows>,
|
||||
slots: HashMap<String, Slots>,
|
||||
/// Relocated descriptors the writer has not committed yet; `rows` keeps the old storage live.
|
||||
pending: HashMap<String, Rows>,
|
||||
}
|
||||
|
||||
impl RenderData {
|
||||
@@ -42,22 +102,25 @@ impl RenderData {
|
||||
arena,
|
||||
arena_start: aligned - pointer,
|
||||
base: aligned as u32,
|
||||
free: BTreeMap::from([(0, capacity)]),
|
||||
free: FreeList(BTreeMap::from([(0, capacity)])),
|
||||
rows: HashMap::new(),
|
||||
slots: HashMap::new(),
|
||||
pending: HashMap::new(),
|
||||
};
|
||||
data.create_rows("signals".into(), 1, 32, "f32".into())?;
|
||||
data.write_signal(5, 1.0);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Creates or grows an array. The flag is set when the rows moved: the returned descriptor
|
||||
/// is then staged and the writer must copy the old bytes into it and `commit` it.
|
||||
pub fn create_rows(
|
||||
&mut self,
|
||||
name: String,
|
||||
rows: u32,
|
||||
stride: u32,
|
||||
format: String,
|
||||
) -> Result<Rows, &'static str> {
|
||||
) -> Result<(Rows, bool), &'static str> {
|
||||
if name.is_empty()
|
||||
|| rows == 0
|
||||
|| stride < 16
|
||||
@@ -71,12 +134,19 @@ impl RenderData {
|
||||
return Err("ROWS");
|
||||
}
|
||||
if rows <= current.rows {
|
||||
return Ok(current);
|
||||
return Ok((current, false));
|
||||
}
|
||||
if name == "signals" {
|
||||
return Err("ROWS_BUILTIN");
|
||||
}
|
||||
return self.grow_rows(current, rows);
|
||||
let added = rows - current.rows;
|
||||
let (descriptor, moved) = self.grow_rows(current, rows)?;
|
||||
self.slots
|
||||
.get_mut(&descriptor.name)
|
||||
.unwrap()
|
||||
.free
|
||||
.release(descriptor.rows - added, added);
|
||||
return Ok((descriptor, moved));
|
||||
}
|
||||
let bytes = rows.checked_mul(stride).ok_or("ARENA_OOM")?;
|
||||
let offset = self.reserve(bytes)?;
|
||||
@@ -91,18 +161,23 @@ impl RenderData {
|
||||
bytes,
|
||||
};
|
||||
self.rows.insert(name, descriptor.clone());
|
||||
self.slots.insert(descriptor.name.clone(), Slots::default());
|
||||
Ok(descriptor)
|
||||
let mut slots = Slots::default();
|
||||
slots.free.release(0, rows);
|
||||
self.slots.insert(descriptor.name.clone(), slots);
|
||||
Ok((descriptor, false))
|
||||
}
|
||||
|
||||
pub fn delete_rows(&mut self, name: &str) -> Result<(), &'static str> {
|
||||
if name == "signals" {
|
||||
return Err("ROWS_BUILTIN");
|
||||
}
|
||||
if self.pending.contains_key(name) {
|
||||
return Err("ROWS_PENDING");
|
||||
}
|
||||
if self
|
||||
.slots
|
||||
.get(name)
|
||||
.is_some_and(|slots| !slots.active.is_empty())
|
||||
.is_some_and(|slots| !slots.spans.is_empty())
|
||||
{
|
||||
return Err("ROWS_ACTIVE");
|
||||
}
|
||||
@@ -128,75 +203,111 @@ impl RenderData {
|
||||
Some(&self.arena[start..start + rows.bytes as usize])
|
||||
}
|
||||
|
||||
pub fn allocate_object(&mut self, name: &str) -> Result<(u32, bool), &'static str> {
|
||||
/// Reserves `count` contiguous rows and returns the first one; grows geometrically when full.
|
||||
/// The growth flags mirror `create_rows`: `(grew, moved)`.
|
||||
pub fn allocate_object(
|
||||
&mut self,
|
||||
name: &str,
|
||||
count: u32,
|
||||
) -> Result<(u32, bool, bool), &'static str> {
|
||||
if name == "signals" {
|
||||
return Err("ROWS_BUILTIN");
|
||||
}
|
||||
let slots = self.slots.get(name).ok_or("ROWS_UNKNOWN")?;
|
||||
let reused = slots.free.last().copied();
|
||||
let id = reused.unwrap_or(slots.next);
|
||||
let next = id.checked_add(1).ok_or("OBJECT_LIMIT")?;
|
||||
let capacity = self.rows[name].rows;
|
||||
let grew = id >= capacity;
|
||||
if grew {
|
||||
let descriptor = self.rows[name].clone();
|
||||
self.grow_rows(descriptor, next)?;
|
||||
if count == 0 {
|
||||
return Err("ROWS");
|
||||
}
|
||||
let slots = self.slots.get_mut(name).unwrap();
|
||||
if reused.is_some() {
|
||||
slots.free.pop();
|
||||
} else {
|
||||
slots.next = next;
|
||||
let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?;
|
||||
let mut grew = false;
|
||||
let mut moved = false;
|
||||
let id = match slots.free.allocate(count, 1) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let descriptor = self.rows[name].clone();
|
||||
let rows = descriptor
|
||||
.rows
|
||||
.checked_mul(2)
|
||||
.ok_or("OBJECT_LIMIT")?
|
||||
.max(descriptor.rows.checked_add(count).ok_or("OBJECT_LIMIT")?);
|
||||
let old_rows = descriptor.rows;
|
||||
moved = self.grow_rows(descriptor, rows)?.1;
|
||||
grew = true;
|
||||
let slots = self.slots.get_mut(name).unwrap();
|
||||
slots.free.release(old_rows, rows - old_rows);
|
||||
slots.free.allocate(count, 1).unwrap()
|
||||
}
|
||||
};
|
||||
self.slots.get_mut(name).unwrap().spans.insert(id, count);
|
||||
Ok((id, grew, moved))
|
||||
}
|
||||
|
||||
/// The descriptor the writer should use: staged rows win over committed ones.
|
||||
pub fn staged(&self, name: &str) -> Option<&Rows> {
|
||||
self.pending.get(name).or_else(|| self.rows.get(name))
|
||||
}
|
||||
|
||||
/// Publishes staged relocations after the writer copied the old bytes into them.
|
||||
pub fn commit(&mut self, names: &[String]) -> Result<(), &'static str> {
|
||||
for name in names {
|
||||
let staged = self.pending.remove(name).ok_or("ROWS_PENDING")?;
|
||||
let old = self.rows.insert(name.clone(), staged).unwrap();
|
||||
self.release(old.offset - self.base, old.bytes);
|
||||
}
|
||||
if !self.pending.is_empty() {
|
||||
return Err("ROWS_PENDING");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops every staged relocation, for example when a batch fails half-way.
|
||||
pub fn discard_pending(&mut self) {
|
||||
for staged in std::mem::take(&mut self.pending).into_values() {
|
||||
self.release(staged.offset - self.base, staged.bytes);
|
||||
let rows = self.rows[&staged.name].rows;
|
||||
let slots = self.slots.get_mut(&staged.name).unwrap();
|
||||
assert!(slots.free.take(rows, staged.rows - rows));
|
||||
}
|
||||
slots.active.insert(id);
|
||||
Ok((id, grew))
|
||||
}
|
||||
|
||||
pub fn delete_object(&mut self, name: &str, id: u32) -> Result<(), &'static str> {
|
||||
let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?;
|
||||
if !slots.active.remove(&id) {
|
||||
return Err("OBJECT_UNKNOWN");
|
||||
}
|
||||
let count = slots.spans.remove(&id).ok_or("OBJECT_UNKNOWN")?;
|
||||
slots.free.release(id, count);
|
||||
let rows = self.rows[name].clone();
|
||||
let start = self.arena_start + (rows.offset - self.base + id * rows.stride) as usize;
|
||||
self.arena[start..start + rows.stride as usize].fill(0);
|
||||
slots.free.push(id);
|
||||
self.arena[start..start + (count * rows.stride) as usize].fill(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn grow_rows(&mut self, mut descriptor: Rows, rows: u32) -> Result<Rows, &'static str> {
|
||||
/// Extends in place when the next arena block is free; otherwise stages a zeroed region for
|
||||
/// the writer to fill and `commit`, because only the writer knows when its last write landed.
|
||||
fn grow_rows(&mut self, mut descriptor: Rows, rows: u32) -> Result<(Rows, bool), &'static str> {
|
||||
if self.pending.contains_key(&descriptor.name) {
|
||||
return Err("ROWS_PENDING");
|
||||
}
|
||||
let bytes = rows.checked_mul(descriptor.stride).ok_or("ARENA_OOM")?;
|
||||
let old_offset = descriptor.offset - self.base;
|
||||
let old_end = old_offset + descriptor.bytes;
|
||||
let extra = bytes - descriptor.bytes;
|
||||
if self
|
||||
.free
|
||||
.get(&old_end)
|
||||
.is_some_and(|available| *available >= extra)
|
||||
{
|
||||
let available = self.free.remove(&old_end).unwrap();
|
||||
if available > extra {
|
||||
self.free.insert(old_end + extra, available - extra);
|
||||
}
|
||||
self.arena[self.arena_start + old_end as usize
|
||||
..self.arena_start + (old_end + extra) as usize]
|
||||
.fill(0);
|
||||
} else {
|
||||
let moved = !self.free.take(old_end, extra);
|
||||
if moved {
|
||||
let offset = self.reserve(bytes)?;
|
||||
let start = self.arena_start + offset as usize;
|
||||
self.arena[start..start + bytes as usize].fill(0);
|
||||
self.arena.copy_within(
|
||||
self.arena_start + old_offset as usize..self.arena_start + old_end as usize,
|
||||
start,
|
||||
);
|
||||
self.release(old_offset, descriptor.bytes);
|
||||
descriptor.offset = self.base + offset;
|
||||
} else {
|
||||
self.arena[self.arena_start + old_end as usize
|
||||
..self.arena_start + (old_end + extra) as usize]
|
||||
.fill(0);
|
||||
}
|
||||
descriptor.rows = rows;
|
||||
descriptor.bytes = bytes;
|
||||
self.rows
|
||||
.insert(descriptor.name.clone(), descriptor.clone());
|
||||
Ok(descriptor)
|
||||
if moved {
|
||||
&mut self.pending
|
||||
} else {
|
||||
&mut self.rows
|
||||
}
|
||||
.insert(descriptor.name.clone(), descriptor.clone());
|
||||
Ok((descriptor, moved))
|
||||
}
|
||||
|
||||
pub fn update_signals(&mut self, delta: f32, frame: u32, elapsed: f32, fps: u32) {
|
||||
@@ -244,54 +355,14 @@ impl RenderData {
|
||||
}
|
||||
|
||||
fn reserve(&mut self, bytes: u32) -> Result<u32, &'static str> {
|
||||
let (block, block_bytes, offset) = self
|
||||
.free
|
||||
.iter()
|
||||
.find_map(|(&block, &block_bytes)| {
|
||||
let offset = align(block)?;
|
||||
(offset.checked_add(bytes)? <= block.checked_add(block_bytes)?).then_some((
|
||||
block,
|
||||
block_bytes,
|
||||
offset,
|
||||
))
|
||||
})
|
||||
.ok_or("ARENA_OOM")?;
|
||||
self.free.remove(&block);
|
||||
if offset > block {
|
||||
self.free.insert(block, offset - block);
|
||||
}
|
||||
let end = offset + bytes;
|
||||
let block_end = block + block_bytes;
|
||||
if end < block_end {
|
||||
self.free.insert(end, block_end - end);
|
||||
}
|
||||
Ok(offset)
|
||||
self.free.allocate(bytes, ALIGNMENT).ok_or("ARENA_OOM")
|
||||
}
|
||||
|
||||
fn release(&mut self, mut offset: u32, mut bytes: u32) {
|
||||
if let Some((&previous, &previous_bytes)) = self.free.range(..offset).next_back() {
|
||||
if previous + previous_bytes == offset {
|
||||
self.free.remove(&previous);
|
||||
offset = previous;
|
||||
bytes += previous_bytes;
|
||||
}
|
||||
}
|
||||
if let Some((&next, &next_bytes)) = self.free.range(offset..).next() {
|
||||
if offset + bytes == next {
|
||||
self.free.remove(&next);
|
||||
bytes += next_bytes;
|
||||
}
|
||||
}
|
||||
self.free.insert(offset, bytes);
|
||||
fn release(&mut self, offset: u32, bytes: u32) {
|
||||
self.free.release(offset, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
fn align(value: u32) -> Option<u32> {
|
||||
value
|
||||
.checked_add(ALIGNMENT - 1)
|
||||
.map(|value| value & !(ALIGNMENT - 1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "render_data_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -30,3 +30,119 @@ fn skip_and_bundle_signals_hold_dirty_work() {
|
||||
data.loadout_ready();
|
||||
assert!(data.begin_render());
|
||||
}
|
||||
|
||||
fn rows_of(data: &RenderData, name: &str) -> u32 {
|
||||
data.rows(name).unwrap().rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_reuse_coalesced_gaps_before_growing() {
|
||||
let mut data = RenderData::new(4096).unwrap();
|
||||
data.create_rows("v".into(), 8, 16, "f32".into()).unwrap();
|
||||
|
||||
assert_eq!(data.allocate_object("v", 2).unwrap(), (0, false, false));
|
||||
assert_eq!(data.allocate_object("v", 3).unwrap(), (2, false, false));
|
||||
assert_eq!(data.allocate_object("v", 3).unwrap(), (5, false, false));
|
||||
assert_eq!(rows_of(&data, "v"), 8);
|
||||
|
||||
// Freeing the two middle spans leaves one coalesced hole of 6 rows.
|
||||
data.delete_object("v", 2).unwrap();
|
||||
data.delete_object("v", 5).unwrap();
|
||||
assert_eq!(data.delete_object("v", 5), Err("OBJECT_UNKNOWN"));
|
||||
assert_eq!(data.allocate_object("v", 6).unwrap(), (2, false, false));
|
||||
assert_eq!(rows_of(&data, "v"), 8);
|
||||
|
||||
// A span that does not fit doubles capacity instead of growing to the exact need; the only
|
||||
// array in the arena extends in place, so nothing is staged.
|
||||
assert_eq!(data.allocate_object("v", 1).unwrap(), (8, true, false));
|
||||
assert_eq!(rows_of(&data, "v"), 16);
|
||||
assert_eq!(data.allocate_object("v", 40).unwrap(), (9, true, false));
|
||||
assert_eq!(rows_of(&data, "v"), 56);
|
||||
assert_eq!(data.allocate_object("v", 0), Err("ROWS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_span_zeroes_every_row_and_keeps_neighbours() {
|
||||
let mut data = RenderData::new(4096).unwrap();
|
||||
data.create_rows("v".into(), 4, 16, "u32".into()).unwrap();
|
||||
let (first, ..) = data.allocate_object("v", 2).unwrap();
|
||||
let (second, ..) = data.allocate_object("v", 1).unwrap();
|
||||
let base = (data.rows("v").unwrap().offset - data.base) as usize + data.arena_start;
|
||||
data.arena[base..base + 48].fill(0xAB);
|
||||
|
||||
data.delete_object("v", first).unwrap();
|
||||
assert!(data.bytes("v").unwrap()[..32].iter().all(|byte| *byte == 0));
|
||||
assert!(data.bytes("v").unwrap()[32..48]
|
||||
.iter()
|
||||
.all(|byte| *byte == 0xAB));
|
||||
assert_eq!(data.delete_rows("v"), Err("ROWS_ACTIVE"));
|
||||
data.delete_object("v", second).unwrap();
|
||||
data.delete_rows("v").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relocation_is_staged_until_the_writer_commits() {
|
||||
let mut data = RenderData::new(4096).unwrap();
|
||||
data.create_rows("a".into(), 1, 16, "u32".into()).unwrap();
|
||||
data.create_rows("b".into(), 1, 16, "u32".into()).unwrap();
|
||||
assert_eq!(data.allocate_object("a", 1).unwrap(), (0, false, false));
|
||||
let old = data.rows("a").unwrap().clone();
|
||||
let base = (old.offset - data.base) as usize + data.arena_start;
|
||||
data.arena[base..base + 16].fill(0xCD);
|
||||
|
||||
// "b" sits right behind "a", so growing "a" must move it: the worker stages a zeroed region
|
||||
// and keeps rendering from (and the arena owning) the old bytes.
|
||||
let (staged, moved) = data.create_rows("a".into(), 5, 16, "u32".into()).unwrap();
|
||||
assert!(moved);
|
||||
assert_eq!(staged.rows, 5);
|
||||
assert_ne!(staged.offset, old.offset);
|
||||
assert_eq!(data.rows("a").unwrap().offset, old.offset);
|
||||
assert_eq!(data.staged("a").unwrap().offset, staged.offset);
|
||||
assert_eq!(
|
||||
data.create_rows("a".into(), 6, 16, "u32".into()),
|
||||
Err("ROWS_PENDING")
|
||||
);
|
||||
assert_eq!(data.delete_rows("a"), Err("ROWS_PENDING"));
|
||||
assert!(!data.free.take(old.offset - data.base, old.bytes));
|
||||
|
||||
// The writer keeps writing the old rows until it copies them over and commits.
|
||||
data.arena[base + 4..base + 8].fill(0xEF);
|
||||
let target = (staged.offset - data.base) as usize + data.arena_start;
|
||||
data.arena.copy_within(base..base + 16, target);
|
||||
data.commit(&["a".into()]).unwrap();
|
||||
assert_eq!(data.rows("a").unwrap().offset, staged.offset);
|
||||
assert_eq!(
|
||||
&data.bytes("a").unwrap()[..8],
|
||||
&[0xCD, 0xCD, 0xCD, 0xCD, 0xEF, 0xEF, 0xEF, 0xEF]
|
||||
);
|
||||
assert!(data.bytes("a").unwrap()[16..].iter().all(|byte| *byte == 0));
|
||||
assert!(data.free.take(old.offset - data.base, old.bytes));
|
||||
assert_eq!(data.commit(&["a".into()]), Err("ROWS_PENDING"));
|
||||
|
||||
// Newly staged rows already feed the span allocator; the writer owns the staged region.
|
||||
data.create_rows("c".into(), 1, 16, "u32".into()).unwrap();
|
||||
assert_eq!(data.allocate_object("a", 4).unwrap(), (1, false, false));
|
||||
let (id, grew, moved) = data.allocate_object("a", 1).unwrap();
|
||||
assert_eq!((id, grew, moved), (5, true, true));
|
||||
assert_eq!(data.staged("a").unwrap().rows, 10);
|
||||
assert_eq!(data.rows("a").unwrap().rows, 5);
|
||||
data.commit(&["a".into()]).unwrap();
|
||||
assert_eq!(rows_of(&data, "a"), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarding_a_staged_relocation_restores_capacity() {
|
||||
let mut data = RenderData::new(4096).unwrap();
|
||||
data.create_rows("a".into(), 2, 16, "u32".into()).unwrap();
|
||||
data.create_rows("b".into(), 1, 16, "u32".into()).unwrap();
|
||||
assert_eq!(data.allocate_object("a", 2).unwrap(), (0, false, false));
|
||||
let (staged, moved) = data.create_rows("a".into(), 8, 16, "u32".into()).unwrap();
|
||||
assert!(moved);
|
||||
|
||||
data.discard_pending();
|
||||
assert_eq!(rows_of(&data, "a"), 2);
|
||||
assert!(data.free.take(staged.offset - data.base, staged.bytes));
|
||||
// Rows 2..8 must not be handed out: they only existed in the discarded region.
|
||||
assert_eq!(data.allocate_object("a", 1).unwrap(), (2, true, false));
|
||||
assert_eq!(rows_of(&data, "a"), 4);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use serde_json::Value;
|
||||
use crate::gpu::Wgpu;
|
||||
use crate::graph::{Binding, Execution, Extent, Pass, RenderGraph, RenderPipeline, Texture};
|
||||
use crate::render_data::RenderData;
|
||||
use crate::store::Loadout;
|
||||
|
||||
pub struct GpuResources {
|
||||
pub buffers: HashMap<String, GpuBuffer>,
|
||||
@@ -38,6 +39,7 @@ pub struct ProfileMap {
|
||||
pub struct GpuBuffer {
|
||||
pub buffer: wgpu::Buffer,
|
||||
pub source: String,
|
||||
pub usage: wgpu::BufferUsages,
|
||||
pub sync_each_frame: bool,
|
||||
}
|
||||
|
||||
@@ -65,28 +67,48 @@ pub enum GpuPass {
|
||||
}
|
||||
|
||||
impl GpuResources {
|
||||
/// Builds the GPU side of `graph`. Buffers whose source array, size, and usage are unchanged
|
||||
/// and pipelines whose declaration is unchanged are taken over from `previous` instead of being
|
||||
/// recreated and re-uploaded; bind groups and bundles are always rebuilt.
|
||||
pub fn activate(
|
||||
graph: &RenderGraph,
|
||||
gpu: &Wgpu,
|
||||
data: &RenderData,
|
||||
previous: Option<&Self>,
|
||||
previous: Option<&Loadout>,
|
||||
) -> Result<Self, String> {
|
||||
let mut buffers = HashMap::new();
|
||||
for source in &graph.resources.buffers {
|
||||
let rows = data.rows(&source.array).ok_or("GRAPH_ARRAY_UNKNOWN")?;
|
||||
let buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(&source.id),
|
||||
size: u64::from(rows.bytes.max(4)),
|
||||
usage: buffer_usage(&source.usage)?,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
gpu.queue
|
||||
.write_buffer(&buffer, 0, data.bytes(&source.array).unwrap());
|
||||
let size = u64::from(rows.bytes.max(4));
|
||||
let usage = buffer_usage(&source.usage)?;
|
||||
let reused = previous
|
||||
.and_then(|previous| previous.resources.buffers.get(&source.id))
|
||||
.filter(|buffer| {
|
||||
buffer.source == source.array
|
||||
&& buffer.usage == usage
|
||||
&& buffer.buffer.size() == size
|
||||
})
|
||||
.map(|buffer| buffer.buffer.clone());
|
||||
let buffer = match reused {
|
||||
Some(buffer) => buffer,
|
||||
None => {
|
||||
let buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(&source.id),
|
||||
size,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
gpu.queue
|
||||
.write_buffer(&buffer, 0, data.bytes(&source.array).unwrap());
|
||||
buffer
|
||||
}
|
||||
};
|
||||
buffers.insert(
|
||||
source.id.clone(),
|
||||
GpuBuffer {
|
||||
buffer,
|
||||
source: source.array.clone(),
|
||||
usage,
|
||||
sync_each_frame: source.sync == "frame",
|
||||
},
|
||||
);
|
||||
@@ -102,11 +124,12 @@ impl GpuResources {
|
||||
let key = source.key()?;
|
||||
if !source.transient {
|
||||
if let Some(texture) = previous
|
||||
.and_then(|resources| {
|
||||
resources
|
||||
.and_then(|previous| {
|
||||
previous
|
||||
.resources
|
||||
.texture_slots
|
||||
.get(&source.id)
|
||||
.map(|slot| &resources.textures[*slot])
|
||||
.map(|slot| &previous.resources.textures[*slot])
|
||||
})
|
||||
.filter(|texture| texture.key == key)
|
||||
{
|
||||
@@ -147,7 +170,23 @@ impl GpuResources {
|
||||
.render
|
||||
.iter()
|
||||
.map(|source| {
|
||||
create_render_pipeline(source, gpu).map(|pipeline| (source.id.clone(), pipeline))
|
||||
let reused = previous.and_then(|previous| {
|
||||
previous
|
||||
.graph
|
||||
.pipelines
|
||||
.render
|
||||
.iter()
|
||||
.find(|candidate| candidate.same_as(source))
|
||||
.and_then(|candidate| {
|
||||
previous.resources.render_pipelines.get(&candidate.id)
|
||||
})
|
||||
.cloned()
|
||||
});
|
||||
match reused {
|
||||
Some(pipeline) => Ok(pipeline),
|
||||
None => create_render_pipeline(source, gpu),
|
||||
}
|
||||
.map(|pipeline| (source.id.clone(), pipeline))
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||
let compute_pipelines = graph
|
||||
@@ -155,6 +194,21 @@ impl GpuResources {
|
||||
.compute
|
||||
.iter()
|
||||
.map(|source| {
|
||||
let reused = previous.and_then(|previous| {
|
||||
previous
|
||||
.graph
|
||||
.pipelines
|
||||
.compute
|
||||
.iter()
|
||||
.find(|candidate| candidate.same_as(source))
|
||||
.and_then(|candidate| {
|
||||
previous.resources.compute_pipelines.get(&candidate.id)
|
||||
})
|
||||
.cloned()
|
||||
});
|
||||
if let Some(pipeline) = reused {
|
||||
return Ok::<_, String>((source.id.clone(), pipeline));
|
||||
}
|
||||
let module = gpu
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -171,7 +225,7 @@ impl GpuResources {
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
Ok::<_, String>((source.id.clone(), pipeline))
|
||||
Ok((source.id.clone(), pipeline))
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||
|
||||
@@ -212,22 +266,14 @@ impl GpuResources {
|
||||
for execution in &graph.executions {
|
||||
let compiled = match execution {
|
||||
Execution::Render(passes) => {
|
||||
let (bundle, draws) = resources.render_bundle(graph, passes, gpu)?;
|
||||
let (bundle, draws) = resources.render_bundle(graph, passes, gpu, data)?;
|
||||
let first = &graph.passes[passes[0]].id;
|
||||
let last = &graph.passes[*passes.last().unwrap()].id;
|
||||
GpuPass::Render {
|
||||
label: if passes.len() == 1 {
|
||||
graph.passes[passes[0]].id.clone()
|
||||
} else if passes
|
||||
.iter()
|
||||
.all(|index| graph.passes[*index].id.starts_with("forward-"))
|
||||
{
|
||||
format!("Forward ({draws} draws)")
|
||||
} else if passes
|
||||
.iter()
|
||||
.all(|index| graph.passes[*index].id.starts_with("depth-"))
|
||||
{
|
||||
format!("Depth ({draws} draws)")
|
||||
format!("{first} ({draws} draws)")
|
||||
} else {
|
||||
format!("Render ({draws} draws)")
|
||||
format!("{first} … {last} ({draws} draws)")
|
||||
},
|
||||
first: passes[0],
|
||||
last: *passes.last().unwrap(),
|
||||
@@ -259,6 +305,28 @@ impl GpuResources {
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
/// Copies rows `first .. first + count` of a source array into every GPU buffer bound to it.
|
||||
pub fn sync_rows(
|
||||
&self,
|
||||
name: &str,
|
||||
first: u32,
|
||||
count: u32,
|
||||
gpu: &Wgpu,
|
||||
data: &RenderData,
|
||||
) -> Result<(), String> {
|
||||
let rows = data.rows(name).ok_or("ROWS_UNKNOWN")?;
|
||||
let end = first
|
||||
.checked_add(count)
|
||||
.filter(|end| *end <= rows.rows)
|
||||
.ok_or("ROW_RANGE")?;
|
||||
let start = (first * rows.stride) as usize;
|
||||
let bytes = &data.bytes(name).unwrap()[start..(end * rows.stride) as usize];
|
||||
for buffer in self.buffers.values().filter(|buffer| buffer.source == name) {
|
||||
gpu.queue.write_buffer(&buffer.buffer, start as u64, bytes);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn texture_view(&self, id: &str) -> Option<&wgpu::TextureView> {
|
||||
self.texture_slots
|
||||
.get(id)
|
||||
@@ -405,6 +473,7 @@ impl GpuResources {
|
||||
graph: &RenderGraph,
|
||||
passes: &[usize],
|
||||
gpu: &Wgpu,
|
||||
data: &RenderData,
|
||||
) -> Result<(wgpu::RenderBundle, usize), String> {
|
||||
let pass = &graph.passes[passes[0]];
|
||||
let declaration = graph
|
||||
@@ -441,9 +510,8 @@ impl GpuResources {
|
||||
let mut previous_pipeline = None;
|
||||
let mut previous_bindings: Option<&[Binding]> = None;
|
||||
let mut draws = 0;
|
||||
let mut at = 0;
|
||||
while at < passes.len() {
|
||||
let pass = &graph.passes[passes[at]];
|
||||
for &at in passes {
|
||||
let pass = &graph.passes[at];
|
||||
let pipeline = self
|
||||
.render_pipelines
|
||||
.get(&pass.pipeline)
|
||||
@@ -469,7 +537,7 @@ impl GpuResources {
|
||||
.buffer;
|
||||
encoder.set_vertex_buffer(binding.slot, buffer.slice(binding.offset..));
|
||||
}
|
||||
if let Some(binding) = &pass.index_buffer {
|
||||
let indexed = if let Some(binding) = &pass.index_buffer {
|
||||
let buffer = &self
|
||||
.buffers
|
||||
.get(&binding.resource)
|
||||
@@ -479,26 +547,53 @@ impl GpuResources {
|
||||
buffer.slice(binding.offset..),
|
||||
parse(&binding.format, "GRAPH_INDEX_FORMAT")?,
|
||||
);
|
||||
let mut instances = pass.draw.instances;
|
||||
while at + 1 < passes.len()
|
||||
&& can_instance(pass, &graph.passes[passes[at + 1]], instances)
|
||||
{
|
||||
at += 1;
|
||||
instances += graph.passes[passes[at]].draw.instances;
|
||||
}
|
||||
encoder.draw_indexed(
|
||||
pass.draw.first_index..pass.draw.first_index + pass.draw.indices,
|
||||
pass.draw.base_vertex,
|
||||
pass.draw.first_instance..pass.draw.first_instance + instances,
|
||||
);
|
||||
true
|
||||
} else {
|
||||
encoder.draw(
|
||||
pass.draw.first_vertex..pass.draw.first_vertex + pass.draw.vertices,
|
||||
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
|
||||
);
|
||||
false
|
||||
};
|
||||
match (&pass.indirect, indexed) {
|
||||
(Some(indirect), indexed) => {
|
||||
let buffer = self
|
||||
.buffers
|
||||
.get(&indirect.resource)
|
||||
.ok_or("GRAPH_RESOURCE_UNKNOWN")?;
|
||||
let rows = data.rows(&buffer.source).ok_or("GRAPH_ARRAY_UNKNOWN")?;
|
||||
let end = match indirect.count {
|
||||
Some(count) => indirect.first.checked_add(count),
|
||||
None => Some(rows.rows),
|
||||
}
|
||||
.filter(|end| indirect.first <= *end && *end <= rows.rows)
|
||||
.ok_or("GRAPH_INDIRECT")?;
|
||||
if rows.stride < if indexed { 20 } else { 16 } {
|
||||
return Err("GRAPH_INDIRECT".into());
|
||||
}
|
||||
let count = end - indirect.first;
|
||||
for row in indirect.first..end {
|
||||
let offset = u64::from(row) * u64::from(rows.stride);
|
||||
if indexed {
|
||||
encoder.draw_indexed_indirect(&buffer.buffer, offset);
|
||||
} else {
|
||||
encoder.draw_indirect(&buffer.buffer, offset);
|
||||
}
|
||||
}
|
||||
draws += count as usize;
|
||||
}
|
||||
(None, true) => {
|
||||
encoder.draw_indexed(
|
||||
pass.draw.first_index..pass.draw.first_index + pass.draw.indices,
|
||||
pass.draw.base_vertex,
|
||||
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
|
||||
);
|
||||
draws += 1;
|
||||
}
|
||||
(None, false) => {
|
||||
encoder.draw(
|
||||
pass.draw.first_vertex..pass.draw.first_vertex + pass.draw.vertices,
|
||||
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
|
||||
);
|
||||
draws += 1;
|
||||
}
|
||||
}
|
||||
draws += 1;
|
||||
at += 1;
|
||||
}
|
||||
Ok((
|
||||
encoder.finish(&wgpu::RenderBundleDescriptor {
|
||||
@@ -554,18 +649,6 @@ impl GpuResources {
|
||||
}
|
||||
}
|
||||
|
||||
fn can_instance(first: &Pass, next: &Pass, instances: u32) -> bool {
|
||||
first.pipeline == next.pipeline
|
||||
&& first.bindings == next.bindings
|
||||
&& first.vertex_buffers == next.vertex_buffers
|
||||
&& first.index_buffer == next.index_buffer
|
||||
&& first.draw.indices != 0
|
||||
&& first.draw.indices == next.draw.indices
|
||||
&& first.draw.first_index == next.draw.first_index
|
||||
&& first.draw.base_vertex == next.draw.base_vertex
|
||||
&& first.draw.first_instance + instances == next.draw.first_instance
|
||||
}
|
||||
|
||||
impl GpuPass {
|
||||
fn label(&self) -> &str {
|
||||
match self {
|
||||
|
||||
@@ -85,7 +85,7 @@ pub struct PipelineDeclarations {
|
||||
pub compute: Vec<ComputePipeline>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RenderPipeline {
|
||||
pub id: String,
|
||||
@@ -102,7 +102,7 @@ pub struct RenderPipeline {
|
||||
pub multisample: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct ComputePipeline {
|
||||
pub id: String,
|
||||
pub code: String,
|
||||
@@ -110,7 +110,7 @@ pub struct ComputePipeline {
|
||||
pub entry: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct VertexStage {
|
||||
#[serde(default = "vertex_entry")]
|
||||
pub entry: String,
|
||||
@@ -127,7 +127,7 @@ impl Default for VertexStage {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[derive(Clone, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VertexBuffer {
|
||||
pub array_stride: u64,
|
||||
@@ -137,7 +137,7 @@ pub struct VertexBuffer {
|
||||
pub attributes: Vec<VertexAttribute>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VertexAttribute {
|
||||
pub format: String,
|
||||
@@ -145,7 +145,7 @@ pub struct VertexAttribute {
|
||||
pub shader_location: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize, PartialEq)]
|
||||
pub struct FragmentStage {
|
||||
#[serde(default = "fragment_entry")]
|
||||
pub entry: String,
|
||||
@@ -162,7 +162,7 @@ impl Default for FragmentStage {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FragmentTarget {
|
||||
pub format: String,
|
||||
@@ -192,10 +192,23 @@ pub struct Pass {
|
||||
pub index_buffer: Option<IndexBinding>,
|
||||
#[serde(default)]
|
||||
pub draw: Draw,
|
||||
pub indirect: Option<IndirectBinding>,
|
||||
#[serde(default = "dispatch")]
|
||||
pub dispatch: [u32; 3],
|
||||
}
|
||||
|
||||
/// Draw commands read from rows of an `indirect` buffer: one command per row starting at
|
||||
/// `first`, `count` rows (default: every row to the end of the array). The command occupies the
|
||||
/// first 16 bytes (non-indexed) or 20 bytes (indexed) of each row; the remainder of the row is
|
||||
/// free for the caller.
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct IndirectBinding {
|
||||
pub resource: String,
|
||||
#[serde(default)]
|
||||
pub first: u32,
|
||||
pub count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct Binding {
|
||||
#[serde(default)]
|
||||
@@ -344,6 +357,17 @@ impl RenderGraph {
|
||||
"compute" => _ = compute_pipelines.insert(pass.pipeline.clone()),
|
||||
_ => return Err("GRAPH_PASS"),
|
||||
}
|
||||
if let Some(indirect) = &pass.indirect {
|
||||
let usable = self
|
||||
.resources
|
||||
.buffers
|
||||
.iter()
|
||||
.find(|buffer| buffer.id == indirect.resource)
|
||||
.is_some_and(|buffer| buffer.usage.iter().any(|usage| usage == "indirect"));
|
||||
if !usable || pass.draw != Draw::default() {
|
||||
return Err("GRAPH_INDIRECT");
|
||||
}
|
||||
}
|
||||
for id in pass.resources() {
|
||||
used.insert(id.to_owned());
|
||||
lifetimes
|
||||
@@ -495,7 +519,7 @@ impl RenderGraph {
|
||||
.resources
|
||||
.buffers
|
||||
.iter()
|
||||
.any(|buffer| !matches!(buffer.sync.as_str(), "frame" | "loadout"))
|
||||
.any(|buffer| !matches!(buffer.sync.as_str(), "frame" | "manual"))
|
||||
{
|
||||
return Err("GRAPH_BUFFER_SYNC");
|
||||
}
|
||||
@@ -532,10 +556,29 @@ impl Pass {
|
||||
.iter()
|
||||
.map(|value| value.resource.as_str()),
|
||||
)
|
||||
.chain(self.indirect.iter().map(|value| value.resource.as_str()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderPipeline {
|
||||
/// Same GPU pipeline regardless of declaration ID.
|
||||
pub(crate) fn same_as(&self, other: &Self) -> bool {
|
||||
self.code == other.code
|
||||
&& self.vertex == other.vertex
|
||||
&& self.fragment == other.fragment
|
||||
&& self.primitive == other.primitive
|
||||
&& self.depth_stencil == other.depth_stencil
|
||||
&& self.multisample == other.multisample
|
||||
}
|
||||
}
|
||||
|
||||
impl ComputePipeline {
|
||||
pub(crate) fn same_as(&self, other: &Self) -> bool {
|
||||
self.code == other.code && self.entry == other.entry
|
||||
}
|
||||
}
|
||||
|
||||
impl Texture {
|
||||
pub(crate) fn key(&self) -> Result<String, &'static str> {
|
||||
let mut usage = self.usage.clone();
|
||||
|
||||
+16
-12
@@ -26,12 +26,7 @@ impl Store {
|
||||
|
||||
pub fn switch(&mut self, id: &str, gpu: &Wgpu, data: &RenderData) -> Result<(), String> {
|
||||
let graph = self.graphs.get(id).ok_or("GRAPH_UNKNOWN")?.clone();
|
||||
let mut resources = GpuResources::activate(
|
||||
&graph,
|
||||
gpu,
|
||||
data,
|
||||
self.active.as_ref().map(|active| &active.resources),
|
||||
)?;
|
||||
let mut resources = GpuResources::activate(&graph, gpu, data, self.active.as_ref())?;
|
||||
for (name, levels) in &self.texture_sources {
|
||||
for (mip_level, image) in levels {
|
||||
if resources.needs_upload(name, *mip_level) {
|
||||
@@ -72,6 +67,20 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_rows(
|
||||
&self,
|
||||
name: &str,
|
||||
first: u32,
|
||||
count: u32,
|
||||
gpu: &Wgpu,
|
||||
data: &RenderData,
|
||||
) -> Result<(), String> {
|
||||
match &self.active {
|
||||
Some(active) => active.resources.sync_rows(name, first, count, gpu, data),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_mut(&mut self) -> Option<&mut Loadout> {
|
||||
self.active.as_mut()
|
||||
}
|
||||
@@ -104,12 +113,7 @@ impl Store {
|
||||
return Ok(());
|
||||
};
|
||||
let graph = active.graph.clone();
|
||||
let mut resources = GpuResources::activate(
|
||||
&graph,
|
||||
gpu,
|
||||
data,
|
||||
self.active.as_ref().map(|active| &active.resources),
|
||||
)?;
|
||||
let mut resources = GpuResources::activate(&graph, gpu, data, self.active.as_ref())?;
|
||||
for (name, levels) in &self.texture_sources {
|
||||
for (mip_level, image) in levels {
|
||||
if resources.needs_upload(name, *mip_level) {
|
||||
|
||||
+21
-1
@@ -8,9 +8,19 @@ pub struct Wgpu {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub timestamp_queries: bool,
|
||||
/// Optional features the device was created with, in WebGPU spelling.
|
||||
pub features: Vec<&'static str>,
|
||||
pub adapter: String,
|
||||
}
|
||||
|
||||
const OPTIONAL_FEATURES: [(wgpu::Features, &str); 2] = [
|
||||
(wgpu::Features::TIMESTAMP_QUERY, "timestamp-query"),
|
||||
(
|
||||
wgpu::Features::INDIRECT_FIRST_INSTANCE,
|
||||
"indirect-first-instance",
|
||||
),
|
||||
];
|
||||
|
||||
impl Wgpu {
|
||||
pub async fn new(canvas: web_sys::OffscreenCanvas) -> Result<Self, String> {
|
||||
let width = canvas.width();
|
||||
@@ -33,7 +43,12 @@ impl Wgpu {
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "WEBGPU_UNAVAILABLE")?;
|
||||
let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY;
|
||||
let required_features = OPTIONAL_FEATURES
|
||||
.iter()
|
||||
.fold(wgpu::Features::empty(), |features, (feature, _)| {
|
||||
features | *feature
|
||||
})
|
||||
& adapter.features();
|
||||
let info = adapter.get_info();
|
||||
let adapter_name = if info.name.is_empty() {
|
||||
format!("{:?} · {:?}", info.device_type, info.backend)
|
||||
@@ -65,6 +80,11 @@ impl Wgpu {
|
||||
width,
|
||||
height,
|
||||
timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY),
|
||||
features: OPTIONAL_FEATURES
|
||||
.iter()
|
||||
.filter(|(feature, _)| required_features.contains(*feature))
|
||||
.map(|(_, name)| *name)
|
||||
.collect(),
|
||||
adapter: adapter_name,
|
||||
})
|
||||
}
|
||||
|
||||
+25
-3
@@ -2,10 +2,13 @@ import initWasm, { Core } from "./pkg/yawn_core.js";
|
||||
|
||||
let core;
|
||||
let profileTimer;
|
||||
// While a relocation is staged, every request except its commit waits here.
|
||||
let awaitingCommit = false;
|
||||
const queue = [];
|
||||
|
||||
const fail = code => { throw new Error(code); };
|
||||
|
||||
addEventListener("message", async ({ data: message }) => {
|
||||
async function handle(message) {
|
||||
try {
|
||||
let result;
|
||||
if (message?.type === "init") {
|
||||
@@ -15,25 +18,35 @@ addEventListener("message", async ({ data: message }) => {
|
||||
await core.initialize(message.canvas);
|
||||
const buffer = wasm.memory.buffer;
|
||||
if (!(buffer instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED");
|
||||
result = { buffer, rows: JSON.parse(core.rows()) };
|
||||
result = { buffer, rows: JSON.parse(core.rows()), features: JSON.parse(core.features()) };
|
||||
} else {
|
||||
if (!core) fail("UNINITIALIZED");
|
||||
switch (message?.type) {
|
||||
case "create-rows":
|
||||
result = JSON.parse(core.create_rows(message.name, message.rows, message.stride, message.format));
|
||||
awaitingCommit = result.commit;
|
||||
break;
|
||||
case "create-rows-batch":
|
||||
result = JSON.parse(core.create_rows_batch(JSON.stringify(message.rows)));
|
||||
awaitingCommit = result.commit.length > 0;
|
||||
break;
|
||||
case "commit-rows":
|
||||
awaitingCommit = false;
|
||||
core.commit_rows(JSON.stringify(message.names));
|
||||
break;
|
||||
case "delete-rows":
|
||||
core.delete_rows(message.name);
|
||||
break;
|
||||
case "allocate-object":
|
||||
result = JSON.parse(core.allocate_object(message.name));
|
||||
result = JSON.parse(core.allocate_object(message.name, message.count ?? 1));
|
||||
awaitingCommit = result.commit;
|
||||
break;
|
||||
case "delete-object":
|
||||
core.delete_object(message.name, message.id);
|
||||
break;
|
||||
case "sync-rows":
|
||||
core.sync_rows(message.name, message.first ?? 0, message.count);
|
||||
break;
|
||||
case "compile-graph":
|
||||
result = core.compile_graph(message.serialized);
|
||||
break;
|
||||
@@ -71,4 +84,13 @@ addEventListener("message", async ({ data: message }) => {
|
||||
} catch (error) {
|
||||
postMessage({ request: message?.request, error: error?.message ?? "CORE_ERROR" });
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener("message", async ({ data: message }) => {
|
||||
if (awaitingCommit && message?.type !== "commit-rows") {
|
||||
queue.push(message);
|
||||
return;
|
||||
}
|
||||
await handle(message);
|
||||
while (!awaitingCommit && queue.length) await handle(queue.shift());
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ const { buffer, rows } = await request(
|
||||
|
||||
Requests are `{ type: string, request: any, ...fields }`. Success is `{ request, result }`; void operations have `result: undefined`. Failure is `{ request, error: string }`. The worker merely echoes `request`, so use unique values. Requests can complete out of order. An unknown message type returns `MESSAGE`; all non-init operations before init return `UNINITIALIZED`.
|
||||
|
||||
`init` is one-shot per worker. Its `canvas` **must** be an `OffscreenCanvas` and must appear in the transfer list. `arenaBytes` reaches a Rust `u32`; use an integer from 64 through `2^32 - 64` in practical JS calls. The result contains the shared WASM memory buffer and all initial row descriptors (currently `signals`). Do not transfer the `SharedArrayBuffer`.
|
||||
`init` is one-shot per worker. Its `canvas` **must** be an `OffscreenCanvas` and must appear in the transfer list. `arenaBytes` reaches a Rust `u32`; use an integer from 64 through `2^32 - 64` in practical JS calls. The result contains the shared WASM memory buffer, all initial row descriptors (currently `signals`), and the optional device `features` that were granted. Do not transfer the `SharedArrayBuffer`.
|
||||
|
||||
## Shutdown
|
||||
|
||||
|
||||
@@ -25,11 +25,15 @@ function rowFor(buffer, d, index) {
|
||||
}
|
||||
```
|
||||
|
||||
The buffer stays the same, but a growing row array may relocate. Replace the cached descriptor—and recreate cached views—after every `create-rows`, batch result, and `allocate-object` result. `allocate-object` always returns `{ id, rows: descriptor }`, even without growth. Never infer an address from a prior descriptor.
|
||||
The buffer stays the same, but a growing row array may relocate. Replace the cached descriptor—and recreate cached views—after every `create-rows`, batch result, and `allocate-object` result. `allocate-object` always returns `{ id, rows: descriptor, commit }`, even without growth. Never infer an address from a prior descriptor.
|
||||
|
||||
### Relocation is owned by the writer
|
||||
|
||||
The worker never copies rows. When growth cannot extend in place it reserves a zeroed region, returns that descriptor with `commit: true` (`create-rows`, `allocate-object`) or the moved names in `commit` (`create-rows-batch`), and keeps rendering from—and the arena owning—the old rows. The main thread is the only writer, so it is the only party that knows its last write landed: on receiving the result it copies the old bytes into the new region, swaps its descriptor, and sends `commit-rows { names }`. The worker then publishes the new descriptor, frees the old rows, and rebuilds the GPU buffers bound to them. Until that commit arrives the worker queues every other request, so a staged relocation is a short, exclusive transaction and no SAB write made at any moment is lost. `index.js` does all of this inside `createRows`, `createRowsBatch`, and `allocateObject`; a raw client must copy and commit synchronously in the same task that received the result.
|
||||
|
||||
## Slots
|
||||
|
||||
Creating rows establishes capacity but does not allocate objects. `allocate-object` returns a zero-based row ID. IDs increase until capacity is exhausted; allocation then grows to `id + 1`. Deleted IDs are kept in a LIFO free list and reused. `delete-object` zeroes the entire row and releases the ID. It rejects an inactive/duplicate ID. A row array cannot be deleted while any IDs are active, while `signals` cannot be allocated or deleted, and an array used by the active graph cannot be deleted.
|
||||
Creating rows establishes capacity but does not allocate objects. `allocate-object` reserves `count` contiguous rows (default 1) and returns the zero-based first row as the ID. Spans are placed first-fit into the lowest free gap, so deleted rows are reused before capacity grows; when nothing fits, capacity doubles (or grows to fit the span, whichever is larger) so repeated allocations rarely relocate. Rows added by an explicit `create-rows` growth join the same free list. `delete-object` zeroes the whole span and releases it. It rejects an inactive/duplicate ID. A row array cannot be deleted while any IDs are active, while `signals` cannot be allocated or deleted, and an array used by the active graph cannot be deleted.
|
||||
|
||||
Capacity does not shrink. Deletion makes descriptors/views invalid for application use even though stale bytes may remain in memory.
|
||||
|
||||
@@ -73,8 +77,10 @@ async function replaceBundle(mutate, compileAndSwitch) {
|
||||
|
||||
The signals are invalidation state, not a lock or transaction boundary. If multiple threads can write one logical update, coordinate those writers so core cannot observe a partially updated value. Core marks lane 5 after object deletion, texture upload, and a successful switch, but direct SAB writes do not. Always publish lane 5 after direct data writes.
|
||||
|
||||
## Frame-sync versus loadout-sync
|
||||
## Frame-sync versus manual-sync
|
||||
|
||||
A graph buffer's `sync` defaults to `"frame"`. Before each actual render, the full named row array is copied SAB → GPU, so ordinary value edits only require `sabDirty`.
|
||||
A graph buffer's `sync` defaults to `"frame"`. Before each actual render, the full named row array is copied SAB → GPU, so ordinary value edits only require `sabDirty`. Use it for small, frequently changing arrays (transforms, lights, draw commands).
|
||||
|
||||
`sync: "loadout"` is copied only when GPU resources are activated: on `switch-loadout`, and when core refreshes an active graph because a used row array grows/is recreated. Direct edits afterward do **not** reach that GPU buffer. To change loadout-synced content reliably, set `bundleDirty`, mutate, and switch a compiled replacement loadout (the replacement may use a newly compiled graph with the same schema). Structural changes affecting buffer size, bindings, offsets, draw ranges, passes, pipelines, or attachment/resource declarations also require bundle invalidation and a graph/loadout switch. This distinction matters because render commands are precompiled into render bundles.
|
||||
`sync: "manual"` is copied in full only when its GPU buffer is created: on the first `switch-loadout` that declares it, and whenever the source array grows/relocates or the declaration's usage changes. Later SAB edits reach the GPU only through `sync-rows { name, first, count }`, which uploads that row range into every active buffer bound to the array and marks the frame dirty. Use it for large, mostly static arrays such as vertex and index data: write the rows, then `sync-rows` exactly that span.
|
||||
|
||||
GPU resources survive graph switches when nothing about them changed: a buffer is reused when its declaration ID keeps the same source array, byte size, and usage; a render or compute pipeline is reused when a previous declaration had identical code and state (IDs may differ); non-transient textures with the same descriptor are kept. Bind groups and render bundles are always rebuilt. Structural changes affecting bindings, offsets, draw ranges, passes, pipelines, or attachment/resource declarations require bundle invalidation and a graph/loadout switch because render commands are precompiled into render bundles.
|
||||
|
||||
@@ -23,12 +23,13 @@ Worker failures are string codes in `{ request, error }`. GPU/browser validation
|
||||
| `ROWS_BUILTIN` | Attempt to grow/delete/allocate from `signals`. |
|
||||
| `ROWS_UNKNOWN` | Row name does not exist (also possible during frame upload). |
|
||||
| `ROWS_ACTIVE` | Delete blocked by active object IDs or active graph reference. |
|
||||
| `ROWS_PENDING` | A relocation is staged and uncommitted: growing or deleting that array again, or a `commit-rows` that names a non-staged array or leaves one staged. |
|
||||
| `ARENA_OOM` | Byte multiplication overflow or no aligned free arena block. |
|
||||
| `OBJECT_LIMIT` | Slot ID cannot increment past u32. |
|
||||
| `OBJECT_UNKNOWN` | Delete ID is not active for that row array. |
|
||||
| `FPS` | FPS exceeds 1000. |
|
||||
|
||||
On row growth, consume the returned descriptor before the next access. Batch creation is not rollback-safe. Do not retry allocation blindly after an uncertain transport outcome: it may allocate a second slot.
|
||||
On row growth, consume the returned descriptor before the next access and commit relocations synchronously. In-place batch creations are not rolled back on a later failure. Do not retry allocation blindly after an uncertain transport outcome: it may allocate a second slot.
|
||||
|
||||
## Graph parse and planning
|
||||
|
||||
@@ -40,7 +41,8 @@ On row growth, consume the returned descriptor before the next access. Batch cre
|
||||
| `GRAPH_DEPENDENCY` / `GRAPH_CYCLE` | Missing `after` ID / dependency cycle. |
|
||||
| `GRAPH_RESOURCE` | Empty/duplicate resource ID or texture planning serialization failure. |
|
||||
| `GRAPH_PIPELINE` | Empty/duplicate/missing or wrong-kind pipeline. |
|
||||
| `GRAPH_BUFFER_SYNC` | Buffer sync is not `frame` or `loadout`. |
|
||||
| `GRAPH_BUFFER_SYNC` | Buffer sync is not `frame` or `manual`. |
|
||||
| `GRAPH_INDIRECT` | Indirect pass also sets `draw`, names a buffer without `indirect` usage, has a row stride below 16/20 bytes, or its row range exceeds the array. |
|
||||
| `GRAPH_UNKNOWN` | Switch ID has not been compiled/stored. |
|
||||
| `GRAPH_ARRAY_UNKNOWN` | Buffer declaration names no existing row array at activation. |
|
||||
| `GRAPH_RESOURCE_UNKNOWN` | Pass binding or vertex/index resource cannot be resolved. |
|
||||
|
||||
@@ -23,7 +23,7 @@ Resource IDs must be nonempty and unique across buffers, textures, and samplers.
|
||||
{ id: string required,
|
||||
array: string required, // existing shared-row name at switch time
|
||||
usage: string[] = [],
|
||||
sync: "frame" | "loadout" = "frame" }
|
||||
sync: "frame" | "manual" = "frame" }
|
||||
```
|
||||
|
||||
Allowed usage names: `uniform`, `storage`, `vertex`, `index`, `indirect`, `copySrc`. `COPY_DST` is always added. Include every way the graph uses the buffer. The allocation is at least 4 bytes, but data copied is the row descriptor's full `bytes`.
|
||||
@@ -96,6 +96,7 @@ Pass {
|
||||
vertexBuffers: VertexBinding[] = [],
|
||||
indexBuffer?: IndexBinding,
|
||||
draw: Draw = {},
|
||||
indirect?: IndirectBinding,
|
||||
dispatch: [u32,u32,u32] = [1,1,1]
|
||||
}
|
||||
Binding { group: u32 = 0, binding: u32 required, resource: string required,
|
||||
@@ -106,9 +107,12 @@ DepthAttachment { resource: string required, clear: number = 1,
|
||||
load: "clear"|"load" = "clear", store: "store"|"discard" = "store" }
|
||||
VertexBinding { slot: u32 = 0, resource: string required, offset: u64 = 0 }
|
||||
IndexBinding { resource: string required, format: "uint16"|"uint32" = "uint32", offset: u64 = 0 }
|
||||
IndirectBinding { resource: string required, first: u32 = 0, count?: u32 }
|
||||
Draw { vertices: u32 = 3, indices: u32 = 0, instances: u32 = 1,
|
||||
firstVertex: u32 = 0, firstIndex: u32 = 0, baseVertex: i32 = 0,
|
||||
firstInstance: u32 = 0 }
|
||||
```
|
||||
|
||||
Render passes use `draw`; when `indexBuffer` exists, the indexed path uses `indices` (so set it nonzero), `firstIndex`, and `baseVertex`. Otherwise it uses `vertices` and `firstVertex`. Both use instance fields. Compute passes use `dispatch`. A pipeline ID is validated against the pass kind when the loadout is built. Binding offset/size and vertex/index offsets are bytes and must satisfy WebGPU alignment and range rules. Attachments require matching usages and formats; sampled/storage bindings likewise require matching texture usage, while WGSL determines binding type and visibility.
|
||||
|
||||
`indirect` replaces `draw` (which must stay at its defaults) with one draw command per row of a buffer declared with `indirect` usage: rows `first .. first + count`, where `count` defaults to every row through the end of the array at activation time. Each row begins with the WebGPU command — `[vertexCount, instanceCount, firstVertex, firstInstance]` for non-indexed passes, `[indexCount, instanceCount, firstIndex, baseVertex: i32, firstInstance]` when `indexBuffer` exists — so the row stride must be at least 16 or 20 bytes; the rest of the row is caller-owned. A zero `instanceCount` skips the row cheaply, so callers can keep spare rows and toggle draws without recompiling. Nonzero `firstInstance` requires the device feature `indirect-first-instance` (see `init` result `features`); without it WebGPU silently draws nothing for that command.
|
||||
|
||||
@@ -4,12 +4,14 @@ All fields below are top-level beside `type` and `request`. Except for `init` an
|
||||
|
||||
| `type` | Fields | Transfer list | Success `result` | Constraints / effects |
|
||||
|---|---|---|---|---|
|
||||
| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[] }` | Once only; initializes WASM/WebGPU. |
|
||||
| `create-rows` | `name`, `rows`, `stride`, `format` | — | descriptor | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. |
|
||||
| `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | descriptor array in input order | Nonempty. Sequential, **not transactional**: earlier creations can survive a later failure. Active GPU resources refresh once after successful batch. |
|
||||
| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[], features: string[] }` | Once only; initializes WASM/WebGPU. `features` lists optional device features that were available (`timestamp-query`, `indirect-first-instance`). |
|
||||
| `create-rows` | `name`, `rows`, `stride`, `format` | — | `{ rows: descriptor, commit: boolean }` | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. `commit: true` means the rows moved: copy the old bytes into the descriptor and send `commit-rows`. |
|
||||
| `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | `{ rows: descriptor[] (input order), commit: string[] }` | Nonempty. Sequential; in-place creations/growths survive a later failure, staged relocations are discarded. `commit` lists the arrays that moved. Active GPU resources refresh once for in-place growth. |
|
||||
| `commit-rows` | `names: string[]` | — | `undefined` | Publishes staged relocations after the caller copied them. Must name every staged array. Frees the old rows, refreshes bound GPU buffers once, sets `sabDirty`. While a relocation is staged the worker queues every other request until this arrives. |
|
||||
| `delete-rows` | `name` | — | `undefined` | Not `signals`, not active slots, not referenced by active graph. |
|
||||
| `allocate-object` | `name` | — | `{ id, rows: descriptor }` | Not `signals`; may grow/relocate. |
|
||||
| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must currently be active; row is zeroed. |
|
||||
| `allocate-object` | `name`, `count: u32 = 1` | — | `{ id, rows: descriptor, commit: boolean }` | Not `signals`; `count` > 0; reserves `count` contiguous rows starting at `id`; may grow. `commit` as in `create-rows`. |
|
||||
| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must be the first row of an active span; the whole span is zeroed. |
|
||||
| `sync-rows` | `name`, `first: u32 = 0`, `count: u32` | — | `undefined` | Uploads rows `first .. first + count` of the array into every active GPU buffer bound to it (any `sync`) and sets `sabDirty`. `ROW_RANGE` when the span exceeds the array. No-op without an active loadout. |
|
||||
| `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. |
|
||||
| `switch-loadout` | `id` | — | `undefined` | Builds GPU resources and activates stored graph. Clears `bundleDirty`, sets `sabDirty`. |
|
||||
| `upload-texture` | `name`, `mipLevel: u32`, `image: ImageBitmap` | `[image]` | `undefined` | Uploads immediately if active graph has the texture and caches source for future switches. Source extent must fit destination/mip. Sets dirty. |
|
||||
@@ -39,7 +41,7 @@ When enabled and supported, the worker polls every 250 ms and may send an unsoli
|
||||
}
|
||||
```
|
||||
|
||||
One pass timing corresponds to a compiled execution; compatible adjacent render passes may be merged and labeled together. Samples are throttled and asynchronous, so they are diagnostics, not one event per frame. Disabling clears pending published statistics and the worker polling interval.
|
||||
One pass timing corresponds to a compiled execution; compatible adjacent render passes are merged into one execution labelled `first … last (N draws)`. Samples are throttled and asynchronous, so they are diagnostics, not one event per frame. Disabling clears pending published statistics and the worker polling interval.
|
||||
|
||||
## Texture lifecycle
|
||||
|
||||
|
||||
Vendored
+14
-5
@@ -87,7 +87,12 @@ declare module "@yawn/handles" {
|
||||
stride: number,
|
||||
format: "f32" | "u32" | "i32",
|
||||
): Promise<SharedRows>;
|
||||
reserve(additional: { nodes?: number; materials?: number }): Promise<void>;
|
||||
reserve(additional: {
|
||||
nodes?: number;
|
||||
materials?: number;
|
||||
vertices?: number;
|
||||
indices?: number;
|
||||
}): Promise<void>;
|
||||
batchWrites<T>(operation: () => T): T;
|
||||
batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>;
|
||||
compileRenderGraph(serialized: string): Promise<string>;
|
||||
@@ -149,18 +154,22 @@ declare module "@yawn/handles" {
|
||||
export interface MeshOptions extends NodeOptions {
|
||||
geometryId?: number;
|
||||
material?: PBRMaterial;
|
||||
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||
vertexData?: VertexStreams;
|
||||
visible?: boolean;
|
||||
}
|
||||
export type VertexStreams = Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||
export class Mesh extends Node {
|
||||
constructor(scene: Scene, options?: MeshOptions);
|
||||
geometryId: number;
|
||||
vertexCount: number;
|
||||
indexCount: number;
|
||||
readonly vertexCount: number;
|
||||
readonly indexCount: number;
|
||||
isVisible: boolean;
|
||||
material: PBRMaterial;
|
||||
clone(options?: Omit<MeshOptions, "geometryId" | "vertexData">): Mesh;
|
||||
setVertexData(kind: VertexKind, data: ArrayLike<number>): Promise<this>;
|
||||
setVertexData(
|
||||
kind: VertexKind | VertexStreams,
|
||||
data?: ArrayLike<number>,
|
||||
): Promise<this>;
|
||||
setMaterialForFaces(
|
||||
material: PBRMaterial,
|
||||
faces: number | number[],
|
||||
|
||||
Reference in New Issue
Block a user