handles: one interleaved vertices/indices arena

Geometry becomes a span in a single 80-byte interleaved `vertices` array
plus a span in a single `indices` array, allocated through the core's
span allocator instead of one arena array per mesh attribute. Draws are
always indexed with baseVertex; face-material overrides emit one draw per
run of same-material faces rather than one per face. Vertex data is
validated as a whole before any span is touched, same-geometry mutations
are serialised, and render signal 4 is held across a span change until
the graph reflecting it is published. Sibling arrays grow to the primary
array's capacity in one batch; reserve() takes vertex/index totals.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b2dd-db32-712d-99ce-12ce4a86d979
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-09-18 07:00:36 +00:00
co-authored by heaust
parent 92638e6c9f
commit f324ff6bb2
5 changed files with 699 additions and 591 deletions
+27 -36
View File
@@ -12,18 +12,17 @@ export const VertexKinds = Object.freeze([
"indices", "indices",
] as const); ] as const);
export type VertexKind = (typeof VertexKinds)[number]; export type VertexKind = (typeof VertexKinds)[number];
export type VertexStreams = Partial<Record<VertexKind, ArrayLike<number>>>;
export type MeshOptions = NodeOptions & { export type MeshOptions = NodeOptions & {
geometryId?: number; geometryId?: number;
material?: PBRMaterial; material?: PBRMaterial;
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>; vertexData?: VertexStreams;
visible?: boolean; visible?: boolean;
}; };
/** A renderable Node; clones share geometry until either clone mutates vertex data. */ /** A renderable Node; clones share geometry until either clone mutates vertex data. */
export class Mesh extends Node { export class Mesh extends Node {
geometryId: number; geometryId: number;
vertexCount = 0;
indexCount = 0;
instanceOf = -1; instanceOf = -1;
readonly faceMaterials = new Map<number, number>(); readonly faceMaterials = new Map<number, number>();
#registered = false; #registered = false;
@@ -34,13 +33,6 @@ export class Mesh extends Node {
const nodeReady = this.ready; const nodeReady = this.ready;
this.ready = nodeReady.then(async () => { this.ready = nodeReady.then(async () => {
if (options.material) await options.material.ready; 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 = this.instanceOf =
options.geometryId === undefined ? this.id : this.geometryId; options.geometryId === undefined ? this.id : this.geometryId;
scene scene
@@ -51,8 +43,8 @@ export class Mesh extends Node {
options.visible === false ? 0 : 1, options.visible === false ? 0 : 1,
this.instanceOf, this.instanceOf,
]); ]);
for (const [kind, data] of Object.entries(options.vertexData ?? {})) if (options.vertexData)
await this.#setVertexData(kind as VertexKind, data!, false); await this.#setVertexData(options.vertexData, false);
this.#writeBounds(); this.#writeBounds();
this.#registered = true; this.#registered = true;
await scene.registerMesh(this); 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() { get isVisible() {
if (this.id < 0) throw new Error("Await mesh.ready before reading it"); if (this.id < 0) throw new Error("Await mesh.ready before reading it");
return this.scene.array("meshInfo").row(this.id)[2] !== 0; 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 }); return new Mesh(this.scene, { ...options, geometryId: this.geometryId });
} }
async setVertexData(kind: VertexKind, data: ArrayLike<number>) { /** Replaces one stream, or several at once, repacking the geometry a single time. */
if (!VertexKinds.includes(kind)) async setVertexData(
throw new TypeError(`Unknown vertex kind: ${kind}`); 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.ready;
await this.#setVertexData(kind, data, true); await this.#setVertexData(streams, true);
return this; return this;
} }
async #setVertexData( async #setVertexData(streams: VertexStreams, makeUnique: boolean) {
kind: VertexKind, if (makeUnique && this.scene.geometry(this.geometryId).references > 1) {
data: ArrayLike<number>,
makeUnique: boolean,
) {
if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) {
const original = this.geometryId; const original = this.geometryId;
this.geometryId = await this.scene.cloneGeometry(original); this.geometryId = await this.scene.cloneGeometry(original);
this.scene.releaseGeometry(original); this.scene.releaseGeometry(original);
@@ -117,10 +119,8 @@ export class Mesh extends Node {
this.id, this.id,
]); ]);
} }
if (kind === "positions") this.vertexCount = data.length / 3; await this.scene.setVertexData(this.geometryId, streams, makeUnique);
if (kind === "indices") this.indexCount = data.length; if (streams.positions) this.#writeBounds();
await this.scene.setVertexData(this.geometryId, kind, data, makeUnique);
if (kind === "positions") this.#writeBounds();
} }
async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) { async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) {
@@ -128,19 +128,11 @@ export class Mesh extends Node {
if (material.scene !== this.scene) if (material.scene !== this.scene)
throw new Error("Material must belong to the same Scene"); throw new Error("Material must belong to the same Scene");
const list = Array.isArray(faces) ? faces : [faces]; 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) { for (const face of list) {
if (!Number.isInteger(face) || face < 0) throw new RangeError("face"); if (!Number.isInteger(face) || face < 0) throw new RangeError("face");
array.row(face)[0] = material.id + 1;
this.faceMaterials.set(face, material.id); this.faceMaterials.set(face, material.id);
} }
this.scene.markDirty(true);
await this.scene.updateRenderGraph(); await this.scene.updateRenderGraph();
return this; return this;
} }
@@ -168,7 +160,6 @@ export class Mesh extends Node {
this.#registered = false; this.#registered = false;
await this.scene.unregisterMesh(this); await this.scene.unregisterMesh(this);
} }
await this.scene.core.deleteRows(`mesh.${this.id}.faceMaterials`);
await super.dispose(); await super.dispose();
} }
} }
+347 -338
View File
@@ -10,10 +10,39 @@ type RowFormat = "f32" | "u32" | "i32";
type MeshLike = { type MeshLike = {
id: number; id: number;
geometryId: number; geometryId: number;
indexCount: number;
vertexCount: number;
faceMaterials: ReadonlyMap<number, number>; faceMaterials: ReadonlyMap<number, number>;
}; };
type VertexData = Float32Array | Uint32Array;
/** One geometry: a span of interleaved `vertices` rows plus a span of `indices` rows. */
type Geometry = {
data: Map<string, VertexData>;
/** Serialises published mutations of this geometry's spans. */
pending: Promise<unknown>;
references: number;
vertexRow: number;
vertexCount: number;
indexRow: number;
indexCount: number;
};
/** Interleaved vertex row: 20 floats = 80 bytes; lanes match the pipeline attribute offsets. */
const vertexLanes: Record<string, [offset: number, width: number]> = {
positions: [0, 3],
normals: [4, 3],
uvs: [8, 2],
uvs2: [10, 2],
tangents: [12, 4],
colors: [16, 4],
};
const vertexWidth = 20;
const vertexAttributes = [
{ format: "float32x3", offset: 0, shaderLocation: 0 },
{ format: "float32x3", offset: 16, shaderLocation: 1 },
{ format: "float32x2", offset: 32, shaderLocation: 2 },
{ format: "float32x4", offset: 48, shaderLocation: 3 },
{ format: "float32x4", offset: 64, shaderLocation: 4 },
{ format: "float32x2", offset: 40, shaderLocation: 5 },
];
type ShaderLike = { type ShaderLike = {
id: number; id: number;
code: string; code: string;
@@ -50,6 +79,8 @@ const rows = [
["directionalLights", 32, "f32"], ["directionalLights", 32, "f32"],
["ambientLights", 16, "f32"], ["ambientLights", 16, "f32"],
["sceneAccent", 16, "f32"], ["sceneAccent", 16, "f32"],
["vertices", 80, "f32"],
["indices", 16, "u32"],
] as const; ] as const;
const compactLightLimit = 500; const compactLightLimit = 500;
@@ -196,8 +227,6 @@ fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
function pbrShader( function pbrShader(
mask: number, mask: number,
flags: number, flags: number,
hasColors: boolean,
hasUvs2: boolean,
transmission: boolean, transmission: boolean,
textureInfo: ArrayLike<number>, textureInfo: ArrayLike<number>,
extensionInfo: ArrayLike<number>, extensionInfo: ArrayLike<number>,
@@ -293,9 +322,9 @@ fn vertex(
@location(0) point: vec3<f32>, @location(0) point: vec3<f32>,
@location(1) localNormal: vec3<f32>, @location(1) localNormal: vec3<f32>,
@location(2) uv: vec2<f32>, @location(2) uv: vec2<f32>,
${normalTexture ? "@location(3) localTangent: vec4<f32>," : ""} @location(3) localTangent: vec4<f32>,
${hasColors ? "@location(4) color: vec4<f32>," : ""} @location(4) color: vec4<f32>,
${hasUvs2 ? "@location(5) uv2: vec2<f32>," : ""} @location(5) uv2: vec2<f32>,
@builtin(instance_index) packed: u32, @builtin(instance_index) packed: u32,
) -> VertexOutput { ) -> VertexOutput {
let instance = packed & 65535u; let instance = packed & 65535u;
@@ -316,9 +345,9 @@ fn vertex(
output.world = world; output.world = world;
output.normal = normalize(rotate(rotors[instance], localNormal / scale)); output.normal = normalize(rotate(rotors[instance], localNormal / scale));
output.uv = uv; output.uv = uv;
output.tangent = ${normalTexture ? "vec4(normalize(rotate(rotors[instance], localTangent.xyz * scale)), localTangent.w)" : "vec4(1.0, 0.0, 0.0, 1.0)"}; output.tangent = vec4(normalize(rotate(rotors[instance], localTangent.xyz * scale)), localTangent.w);
output.color = ${hasColors ? "color" : "vec4(1.0)"}; output.color = color;
output.uv2 = ${hasUvs2 ? "uv2" : "uv"}; output.uv2 = uv2;
output.material = (packed >> 16u) - 1u; output.material = (packed >> 16u) - 1u;
return output; return output;
} }
@@ -590,12 +619,13 @@ export class Scene {
#shaders = new Map<number, ShaderLike>(); #shaders = new Map<number, ShaderLike>();
#effects = new Map<string, PostProcessState>(); #effects = new Map<string, PostProcessState>();
#textures = new Map<number, TextureState>(); #textures = new Map<number, TextureState>();
#geometry = new Map<number, Map<string, Float32Array | Uint32Array>>(); #geometry = new Map<number, Geometry>();
#geometryRefs = new Map<number, number>();
#nextGeometry = 1; #nextGeometry = 1;
#nextTexture = 0; #nextTexture = 0;
#graphBatchDepth = 0; #graphBatchDepth = 0;
#graphBatchDirty = false; #graphBatchDirty = false;
#inhibits = 0;
#batchInhibits = 0;
#writeBatchDepth = 0; #writeBatchDepth = 0;
#writeBatchDirty = false; #writeBatchDirty = false;
#writeBatchBundleDirty = false; #writeBatchBundleDirty = false;
@@ -774,50 +804,47 @@ export class Scene {
}); });
} }
async reserve(additional: { nodes?: number; materials?: number }) { /**
* Grows the shared arrays ahead of a bulk load so the objects created afterwards never
* relocate them: `nodes`/`materials` count objects, `vertices`/`indices` count elements.
*/
async reserve(additional: {
nodes?: number;
materials?: number;
vertices?: number;
indices?: number;
}) {
await this.ready; await this.ready;
const nodes = additional.nodes ?? 0; const counts = {
const materials = additional.materials ?? 0; nodes: additional.nodes ?? 0,
materials: additional.materials ?? 0,
vertices: additional.vertices ?? 0,
indices: Math.ceil((additional.indices ?? 0) / 4),
};
if ( if (
![nodes, materials].every( !Object.values(counts).every(
(value) => Number.isInteger(value) && value >= 0, (value) => Number.isInteger(value) && value >= 0,
) )
) )
throw new RangeError("additional"); throw new RangeError("additional");
const nodeCapacity = this.array("nodes").rows + nodes; const families: [keyof typeof counts, readonly (typeof rows)[number][]][] =
const materialCapacity = this.array("materials").rows + materials; [
const growth = [ ["nodes", rows.slice(0, 6)],
...rows.slice(0, 6).map(([name, stride, format]) => ({ ["materials", rows.slice(8, 12)],
["vertices", rows.slice(18, 19)],
["indices", rows.slice(19, 20)],
];
const growth = families
.flatMap(([primary, members]) => {
const capacity = this.core.array(primary).rows + counts[primary];
return members.map(([name, stride, format]) => ({
name, name,
rows: nodeCapacity, rows: capacity,
stride, stride,
format, format,
})), }));
{ })
name: "materials", .filter((request) => this.core.array(request.name).rows < request.rows);
rows: materialCapacity,
stride: 48,
format: "f32" as const,
},
{
name: "materialTextures",
rows: materialCapacity,
stride: 64,
format: "u32" as const,
},
{
name: "materialExtensions",
rows: materialCapacity,
stride: 112,
format: "f32" as const,
},
{
name: "materialTextureInfo",
rows: materialCapacity,
stride: 416,
format: "f32" as const,
},
].filter((request) => this.array(request.name).rows < request.rows);
if (growth.length) await this.core.createRowsBatch(growth); if (growth.length) await this.core.createRowsBatch(growth);
} }
@@ -828,23 +855,24 @@ export class Scene {
return await operation(); return await operation();
} finally { } finally {
this.#graphBatchDepth--; this.#graphBatchDepth--;
if (!this.#graphBatchDepth && this.#graphBatchDirty) { if (!this.#graphBatchDepth) {
try {
if (this.#graphBatchDirty) {
this.#graphBatchDirty = false; this.#graphBatchDirty = false;
await this.updateRenderGraph(); await this.updateRenderGraph();
} }
} finally {
for (; this.#batchInhibits; this.#batchInhibits--)
this.#releaseRender();
}
}
} }
} }
async allocateNode() { async allocateNode() {
await this.ready; await this.ready;
const id = await this.core.allocateObject("nodes"); const id = await this.core.allocateObject("nodes");
const growth = rows.slice(1, 6).flatMap(([name, stride, format]) => { await this.#growSiblings("nodes", rows.slice(1, 6));
const current = this.array(name);
return current.rows < id + 1
? [{ name, rows: id + 1, stride, format }]
: [];
});
if (growth.length) await this.core.createRowsBatch(growth);
this.array("nodeRotors").write(id, [0, 0, 0, 1]); this.array("nodeRotors").write(id, [0, 0, 0, 1]);
this.array("nodeScales").write(id, [1, 1, 1, 0]); this.array("nodeScales").write(id, [1, 1, 1, 0]);
this.array("nodes").write(id, [1, 0, 0, 0]); this.array("nodes").write(id, [1, 0, 0, 0]);
@@ -867,14 +895,24 @@ export class Scene {
async allocateMaterial() { async allocateMaterial() {
await this.ready; await this.ready;
const id = await this.core.allocateObject("materials"); const id = await this.core.allocateObject("materials");
await Promise.all([ await this.#growSiblings("materials", rows.slice(9, 12));
this.ensureRows("materialTextures", id + 1, 64, "u32"),
this.ensureRows("materialExtensions", id + 1, 112, "f32"),
this.ensureRows("materialTextureInfo", id + 1, 416, "f32"),
]);
return id; return id;
} }
/** Grows the arrays indexed by `primary`'s IDs to its (geometric) capacity in one batch. */
async #growSiblings(
primary: string,
siblings: readonly (readonly [string, number, RowFormat])[],
) {
const capacity = this.core.array(primary).rows;
const growth = siblings.flatMap(([name, stride, format]) =>
this.core.array(name).rows < capacity
? [{ name, rows: capacity, stride, format }]
: [],
);
if (growth.length) await this.core.createRowsBatch(growth);
}
addComputePass(pass: ComputePass) { addComputePass(pass: ComputePass) {
if (this.#computePasses.has(pass.id)) if (this.#computePasses.has(pass.id))
throw new Error(`COMPUTE_PASS_EXISTS: ${pass.id}`); throw new Error(`COMPUTE_PASS_EXISTS: ${pass.id}`);
@@ -893,109 +931,210 @@ export class Scene {
registerMesh(mesh: MeshLike) { registerMesh(mesh: MeshLike) {
this.#meshes.set(mesh.id, mesh); this.#meshes.set(mesh.id, mesh);
this.#geometryRefs.set( this.referenceGeometry(mesh.geometryId);
mesh.geometryId,
(this.#geometryRefs.get(mesh.geometryId) ?? 0) + 1,
);
return this.updateRenderGraph(); return this.updateRenderGraph();
} }
async unregisterMesh(mesh: MeshLike) { async unregisterMesh(mesh: MeshLike) {
this.#meshes.delete(mesh.id); this.#meshes.delete(mesh.id);
const references = Math.max( const geometry = this.#geometry.get(mesh.geometryId)!;
0, geometry.references = Math.max(0, geometry.references - 1);
(this.#geometryRefs.get(mesh.geometryId) ?? 1) - 1, if (geometry.references) return this.updateRenderGraph();
);
this.#geometryRefs.set(mesh.geometryId, references);
await this.updateRenderGraph();
if (!references) {
for (const kind of this.#geometry.get(mesh.geometryId)?.keys() ?? [])
await this.core.deleteRows(`geometry.${mesh.geometryId}.${kind}`);
this.#geometry.delete(mesh.geometryId); this.#geometry.delete(mesh.geometryId);
this.#geometryRefs.delete(mesh.geometryId); await this.ready;
} await this.#inhibitRender(async () => {
await this.updateRenderGraph();
await this.#allocateSpans(geometry, 0, 0);
});
} }
createGeometry() { createGeometry() {
const id = this.#nextGeometry++; const id = this.#nextGeometry++;
this.#geometry.set(id, new Map()); this.#geometry.set(id, {
this.#geometryRefs.set(id, 0); data: new Map(),
pending: Promise.resolve(),
references: 0,
vertexRow: 0,
vertexCount: 0,
indexRow: 0,
indexCount: 0,
});
return id; return id;
} }
geometryReferences(id: number) { geometry(id: number): Readonly<Geometry> {
return this.#geometryRefs.get(id) ?? 0; const geometry = this.#geometry.get(id);
if (!geometry) throw new Error(`UNKNOWN_GEOMETRY: ${id}`);
return geometry;
} }
referenceGeometry(id: number) { referenceGeometry(id: number) {
this.#geometryRefs.set(id, (this.#geometryRefs.get(id) ?? 0) + 1); this.#geometry.get(id)!.references++;
} }
releaseGeometry(id: number) { releaseGeometry(id: number) {
this.#geometryRefs.set( const geometry = this.#geometry.get(id)!;
id, geometry.references = Math.max(0, geometry.references - 1);
Math.max(0, (this.#geometryRefs.get(id) ?? 1) - 1),
);
} }
async cloneGeometry(id: number) { async cloneGeometry(id: number) {
const clone = this.createGeometry(); const clone = this.createGeometry();
for (const [kind, data] of this.#geometry.get(id) ?? []) const copies: Record<string, VertexData> = {};
await this.setVertexData( for (const [kind, data] of this.geometry(id).data)
clone, copies[kind] = data.slice() as VertexData;
kind, await this.setVertexData(clone, copies, false);
data.slice() as Float32Array | Uint32Array,
false,
);
return clone; return clone;
} }
/**
* Replaces the given attribute streams of a geometry, repacks its interleaved `vertices` span
* and `indices` span (synthesised as `0..n` when absent), and uploads exactly those rows.
*/
async setVertexData( async setVertexData(
geometry: number, geometry: number,
kind: string, streams: Partial<Record<string, ArrayLike<number>>>,
source: ArrayLike<number>,
updateGraph = true, updateGraph = true,
) { ) {
const components: Record<string, number> = { const record = this.#geometry.get(geometry);
positions: 3, if (!record) throw new Error(`UNKNOWN_GEOMETRY: ${geometry}`);
normals: 3, // Validate the whole assembled geometry before touching the record or its spans.
tangents: 4, const data = new Map(record.data);
uvs: 2, for (const [kind, source] of Object.entries(streams)) {
uvs2: 2, if (!source) continue;
colors: 4, if (kind !== "indices" && !vertexLanes[kind])
indices: 1,
};
const width = components[kind];
if (!width || source.length % width)
throw new RangeError(`VERTEX_DATA: ${kind}`); throw new RangeError(`VERTEX_DATA: ${kind}`);
const integer = kind === "indices"; data.set(
const data = integer ? Uint32Array.from(source) : Float32Array.from(source); kind,
const name = `geometry.${geometry}.${kind}`; kind === "indices"
const rowCount = integer ? Math.ceil(data.length / 4) : data.length / width; ? Uint32Array.from(source)
const target = await this.ensureRows( : Float32Array.from(source),
name,
rowCount,
16,
integer ? "u32" : "f32",
); );
if (updateGraph) this.markDirty(true); }
const view = target.view; const vertexCount = (data.get("positions")?.length ?? 0) / 3;
view.fill(0); for (const [kind, [, width]] of Object.entries(vertexLanes)) {
if (integer || width === 4) view.set(data); const stream = data.get(kind);
else if (stream && stream.length !== vertexCount * width)
for (let row = 0; row < rowCount; row++) throw new RangeError(`VERTEX_DATA: ${kind}`);
}
const indices = data.get("indices");
const source = streams.indices;
if (indices && source)
for (let index = 0; index < indices.length; index++)
if (source[index] !== indices[index] || indices[index] >= vertexCount)
throw new RangeError(`VERTEX_DATA: indices[${index}]`);
const indexCount = indices?.length ?? vertexCount;
await this.ready;
if (!updateGraph) {
record.data = data;
await this.#allocateSpans(record, vertexCount, indexCount);
await this.#packGeometry(record);
return;
}
const pending = record.pending.then(async () => {
this.markDirty(true);
record.data = data;
await this.#allocateSpans(record, vertexCount, indexCount);
await this.#packGeometry(record);
await this.updateRenderGraph();
});
record.pending = pending.catch(() => undefined);
await this.#inhibitRender(() => pending);
}
/**
* Holds render signal 4 across a structural mutation until the graph reflecting it is
* published, so an older loadout switch clearing signal 6 cannot expose stale spans.
*/
async #inhibitRender<T>(operation: () => Promise<T>) {
if (!this.#inhibits++) this.#signals![4] = 1;
try {
return await operation();
} finally {
if (this.#graphBatchDepth) this.#batchInhibits++;
else this.#releaseRender();
}
}
#releaseRender() {
if (--this.#inhibits) return;
this.#signals![4] = 0;
this.#signals![5] = 1;
}
/** Packs the geometry's CPU streams into its `vertices`/`indices` spans and uploads them. */
async #packGeometry(record: Geometry) {
const { vertexCount, indexCount } = record;
const indices = record.data.get("indices");
if (vertexCount) {
const vertices = this.core.array("vertices").view as Float32Array;
const base = record.vertexRow * vertexWidth;
vertices.fill(0, base, base + vertexCount * vertexWidth);
for (let vertex = 0; vertex < vertexCount; vertex++) {
const at = base + vertex * vertexWidth;
vertices[at + 12] = 1;
vertices[at + 15] = 1;
vertices.fill(1, at + 16, at + 20);
}
for (const [kind, [offset, width]] of Object.entries(vertexLanes)) {
const data = record.data.get(kind);
if (!data || data.length !== vertexCount * width) continue;
for (let vertex = 0; vertex < vertexCount; vertex++)
for (let lane = 0; lane < width; lane++) for (let lane = 0; lane < width; lane++)
view[row * 4 + lane] = data[row * width + lane]; vertices[base + vertex * vertexWidth + offset + lane] =
( data[vertex * width + lane];
this.#geometry.get(geometry) ?? }
this.#geometry.set(geometry, new Map()).get(geometry)! if (!record.data.has("uvs2")) {
).set(kind, data); const uvs = record.data.get("uvs");
await this.core.syncRows(name, 0, rowCount); if (uvs)
if (updateGraph) await this.updateRenderGraph(); for (let vertex = 0; vertex < vertexCount; vertex++) {
const at = base + vertex * vertexWidth;
vertices[at + 10] = uvs[vertex * 2];
vertices[at + 11] = uvs[vertex * 2 + 1];
}
}
const indexView = this.core.array("indices").view as Uint32Array;
const indexBase = record.indexRow * 4;
if (indices) indexView.set(indices, indexBase);
else
for (let index = 0; index < indexCount; index++)
indexView[indexBase + index] = index;
await this.core.syncRows("vertices", record.vertexRow, vertexCount);
await this.core.syncRows(
"indices",
record.indexRow,
Math.ceil(indexCount / 4),
);
}
}
/** Re-reserves the geometry's spans when their sizes change; zero counts free them. */
async #allocateSpans(
geometry: Geometry,
vertexCount: number,
indexCount: number,
) {
const indexRows = Math.ceil(indexCount / 4);
const currentIndexRows = Math.ceil(geometry.indexCount / 4);
if (vertexCount !== geometry.vertexCount) {
if (geometry.vertexCount)
await this.core.deleteObject("vertices", geometry.vertexRow);
geometry.vertexRow = vertexCount
? await this.core.allocateObject("vertices", vertexCount)
: 0;
geometry.vertexCount = vertexCount;
}
if (indexRows !== currentIndexRows) {
if (currentIndexRows)
await this.core.deleteObject("indices", geometry.indexRow);
geometry.indexRow = indexRows
? await this.core.allocateObject("indices", indexRows)
: 0;
}
geometry.indexCount = indexCount;
} }
geometryData(id: number, kind: string) { geometryData(id: number, kind: string) {
return this.#geometry.get(id)?.get(kind); return this.#geometry.get(id)?.data.get(kind);
} }
registerShader(material: ShaderLike) { registerShader(material: ShaderLike) {
@@ -1128,22 +1267,56 @@ export class Scene {
const transmitted = this.array("materialExtensions").row(material)[0] > 0; const transmitted = this.array("materialExtensions").row(material)[0] > 0;
return transmitted ? 1 : (flags & 3) === 2 ? 2 : 0; return transmitted ? 1 : (flags & 3) === 2 ? 2 : 0;
}; };
const renderedMeshes = [...this.#meshes.values()] // One draw per run of consecutive faces sharing a material, in category order:
.filter((mesh) => mesh.vertexCount > 0) // opaque/mask first, then transmission, then blended, each in mesh ID order.
.sort( type ForwardDraw = {
(left, right) => mesh: MeshLike;
renderCategory(Number(this.array("meshInfo").row(left.id)[1])) - geometry: Readonly<Geometry>;
renderCategory(Number(this.array("meshInfo").row(right.id)[1])), material: number;
); firstIndex: number;
const hasTransmission = renderedMeshes.some((mesh) => { indices: number;
const materials = new Set([ };
Number(this.array("meshInfo").row(mesh.id)[1]), const forwardDraws: ForwardDraw[] = [];
...mesh.faceMaterials.values(), for (const mesh of this.#meshes.values()) {
]); const geometry = this.geometry(mesh.geometryId);
return [...materials].some( if (!geometry.vertexCount) continue;
(material) => this.array("materialExtensions").row(material)[0] > 0, const fallback = Number(this.array("meshInfo").row(mesh.id)[1]);
); if (!mesh.faceMaterials.size) {
forwardDraws.push({
mesh,
geometry,
material: fallback,
firstIndex: geometry.indexRow * 4,
indices: geometry.indexCount,
}); });
continue;
}
const faces = Math.floor(geometry.indexCount / 3);
let start = 0;
let material = mesh.faceMaterials.get(0) ?? fallback;
for (let face = 1; face <= faces; face++) {
const next =
face < faces ? (mesh.faceMaterials.get(face) ?? fallback) : -1;
if (next === material) continue;
forwardDraws.push({
mesh,
geometry,
material,
firstIndex: geometry.indexRow * 4 + start * 3,
indices: (face - start) * 3,
});
start = face;
material = next;
}
}
forwardDraws.sort(
(left, right) =>
renderCategory(left.material) - renderCategory(right.material) ||
left.mesh.id - right.mesh.id,
);
const hasTransmission = forwardDraws.some(
(draw) => this.array("materialExtensions").row(draw.material)[0] > 0,
);
const compositeSceneColor = const compositeSceneColor =
hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0; hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0;
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm"; const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
@@ -1190,7 +1363,7 @@ export class Scene {
} }
let previous = computeIds.length ? computeIds : ["cluster-lights"]; let previous = computeIds.length ? computeIds : ["cluster-lights"];
if (!renderedMeshes.length) { if (!forwardDraws.length) {
renderPipelines.push({ renderPipelines.push({
id: "empty-forward", id: "empty-forward",
code: clearShader, code: clearShader,
@@ -1238,74 +1411,29 @@ export class Scene {
}); });
previous = ["scene-color-clear"]; previous = ["scene-color-clear"];
} }
addBuffer({
id: "vertices",
array: "vertices",
usage: ["vertex"],
sync: "manual",
});
addBuffer({
id: "indices",
array: "indices",
usage: ["index"],
sync: "manual",
});
const forwardPipelines = new Map<string, string>(); const forwardPipelines = new Map<string, string>();
let firstColor = true; let firstColor = true;
let firstDepth = true; let firstDepth = true;
let sceneColorReady = false; let sceneColorReady = false;
let forwardTarget = compositeSceneColor ? "scene-color" : "hdr"; let forwardTarget = compositeSceneColor ? "scene-color" : "hdr";
for (const mesh of renderedMeshes) { for (const draw of forwardDraws) {
const vertex = `geometry-${mesh.geometryId}-positions`; const { mesh, geometry, material } = draw;
addBuffer({ const id = `forward-${mesh.id}-${draw.firstIndex}`;
id: vertex, const hasNormals = geometry.data.has("normals");
array: `geometry.${mesh.geometryId}.positions`, const hasUvs = geometry.data.has("uvs");
usage: ["vertex"], const hasTangents = geometry.data.has("tangents");
sync: "manual",
});
const normal = `geometry-${mesh.geometryId}-normals`;
const uv = `geometry-${mesh.geometryId}-uvs`;
const uv2 = `geometry-${mesh.geometryId}-uvs2`;
const tangent = `geometry-${mesh.geometryId}-tangents`;
const color = `geometry-${mesh.geometryId}-colors`;
const hasNormals = !!this.geometryData(mesh.geometryId, "normals");
const hasUvs = !!this.geometryData(mesh.geometryId, "uvs");
const hasUvs2 = !!this.geometryData(mesh.geometryId, "uvs2");
const hasTangents = !!this.geometryData(mesh.geometryId, "tangents");
const hasColors = !!this.geometryData(mesh.geometryId, "colors");
for (const [present, id, kind] of [
[hasNormals, normal, "normals"],
[hasUvs, uv, "uvs"],
[hasUvs2, uv2, "uvs2"],
[hasTangents, tangent, "tangents"],
[hasColors, color, "colors"],
] as const)
if (present)
addBuffer({
id,
array: `geometry.${mesh.geometryId}.${kind}`,
usage: ["vertex"],
sync: "manual",
});
const indexed = mesh.indexCount > 0;
if (indexed)
addBuffer({
id: `geometry-${mesh.geometryId}-indices`,
array: `geometry.${mesh.geometryId}.indices`,
usage: ["index"],
sync: "manual",
});
const draws =
indexed && mesh.faceMaterials.size
? Array.from(
{ length: Math.floor(mesh.indexCount / 3) },
(_, face) => ({
face,
count: 3,
firstIndex: face * 3,
material: mesh.faceMaterials.get(face),
}),
)
: [
{
face: -1,
count: indexed ? mesh.indexCount : mesh.vertexCount,
firstIndex: 0,
material: undefined,
},
];
for (const draw of draws) {
const id = `forward-${mesh.id}-${draw.face}`;
const material =
draw.material ?? Number(this.array("meshInfo").row(mesh.id)[1]);
if (mesh.id > 65535 || material > 65534) if (mesh.id > 65535 || material > 65534)
throw new RangeError( throw new RangeError(
`Scene handle limit: mesh ${mesh.id}, material ${material}`, `Scene handle limit: mesh ${mesh.id}, material ${material}`,
@@ -1318,7 +1446,6 @@ export class Scene {
const materialTextures = Array.from({ length: 13 }, (_, lane) => const materialTextures = Array.from({ length: 13 }, (_, lane) =>
lane === 2 && !hasTangents ? undefined : texture(lane), lane === 2 && !hasTangents ? undefined : texture(lane),
); );
const normalTexture = materialTextures[2];
const detailed = hasNormals && hasUvs; const detailed = hasNormals && hasUvs;
const flags = Math.round(this.array("materials").row(material)[11]); const flags = Math.round(this.array("materials").row(material)[11]);
const extensionInfo = this.array("materialExtensions").row(material); const extensionInfo = this.array("materialExtensions").row(material);
@@ -1337,11 +1464,6 @@ export class Scene {
if (extensionInfo[20] <= 0) mask &= ~(1 << 10); if (extensionInfo[20] <= 0) mask &= ~(1 << 10);
if (extensionInfo[11] <= 0) mask &= ~(1 << 4); if (extensionInfo[11] <= 0) mask &= ~(1 << 4);
if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3); if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3);
const tangentSlot = normalTexture ? 3 : -1;
const colorSlot = hasColors ? 3 + Number(!!normalTexture) : -1;
const uv2Slot = hasUvs2
? 3 + Number(!!normalTexture) + Number(hasColors)
: -1;
const textureInfo = this.array("materialTextureInfo").row(material); const textureInfo = this.array("materialTextureInfo").row(material);
let clearCompositeLayer = false; let clearCompositeLayer = false;
if (transmitted && !sceneColorReady) { if (transmitted && !sceneColorReady) {
@@ -1375,24 +1497,14 @@ export class Scene {
{ group: 0, binding: 0, resource: "hdr" }, { group: 0, binding: 0, resource: "hdr" },
{ group: 0, binding: 1, resource: "linear" }, { group: 0, binding: 1, resource: "linear" },
], ],
color: [ color: [{ resource: "scene-color", clear: [...this.clearColor] }],
{ resource: "scene-color", clear: [...this.clearColor] },
],
draw: { vertices: 3 }, draw: { vertices: 3 },
}); });
previous = ["copy-scene-color"]; previous = ["copy-scene-color"];
} }
} }
const code = detailed const code = detailed
? pbrShader( ? pbrShader(mask, flags, transmitted, textureInfo, extensionInfo)
mask,
flags,
hasColors,
hasUvs2,
transmitted,
textureInfo,
extensionInfo,
)
: basicForwardShader; : basicForwardShader;
const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`; const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`;
let pipeline = forwardPipelines.get(pipelineKey); let pipeline = forwardPipelines.get(pipelineKey);
@@ -1404,91 +1516,12 @@ export class Scene {
code, code,
vertex: { vertex: {
entry: "vertex", entry: "vertex",
buffers: detailed buffers: [
? [
{ {
arrayStride: 16, arrayStride: 80,
attributes: [ attributes: detailed
{ ? vertexAttributes
format: "float32x3", : vertexAttributes.slice(0, 1),
offset: 0,
shaderLocation: 0,
},
],
},
{
arrayStride: 16,
attributes: [
{
format: "float32x3",
offset: 0,
shaderLocation: 1,
},
],
},
{
arrayStride: 16,
attributes: [
{
format: "float32x2",
offset: 0,
shaderLocation: 2,
},
],
},
...(normalTexture
? [
{
arrayStride: 16,
attributes: [
{
format: "float32x4",
offset: 0,
shaderLocation: 3,
},
],
},
]
: []),
...(hasColors
? [
{
arrayStride: 16,
attributes: [
{
format: "float32x4",
offset: 0,
shaderLocation: 4,
},
],
},
]
: []),
...(hasUvs2
? [
{
arrayStride: 16,
attributes: [
{
format: "float32x2",
offset: 0,
shaderLocation: 5,
},
],
},
]
: []),
]
: [
{
arrayStride: 16,
attributes: [
{
format: "float32x3",
offset: 0,
shaderLocation: 0,
},
],
}, },
], ],
}, },
@@ -1607,45 +1640,21 @@ export class Scene {
resource: "depth", resource: "depth",
...(firstDepth ? { clear: 1 } : { load: "load" }), ...(firstDepth ? { clear: 1 } : { load: "load" }),
}, },
vertexBuffers: [ vertexBuffers: [{ slot: 0, resource: "vertices" }],
{ slot: 0, resource: vertex }, indexBuffer: { resource: "indices", format: "uint32" },
...(detailed draw: {
? [ indices: draw.indices,
{ slot: 1, resource: normal },
{ slot: 2, resource: uv },
...(normalTexture
? [{ slot: tangentSlot, resource: tangent }]
: []),
...(hasColors
? [{ slot: colorSlot, resource: color }]
: []),
...(hasUvs2 ? [{ slot: uv2Slot, resource: uv2 }] : []),
]
: []),
],
...(indexed
? {
indexBuffer: {
resource: `geometry-${mesh.geometryId}-indices`,
format: "uint32",
},
}
: {}),
draw: indexed
? {
indices: draw.count,
firstIndex: draw.firstIndex, firstIndex: draw.firstIndex,
baseVertex: geometry.vertexRow,
instances: 1, instances: 1,
firstInstance: instance, firstInstance: instance,
} },
: { vertices: draw.count, instances: 1, firstInstance: instance },
}); });
firstColor = false; firstColor = false;
firstDepth = false; firstDepth = false;
previous = [id]; previous = [id];
} }
} }
}
for (const material of this.#shaders.values()) { for (const material of this.#shaders.values()) {
const id = `shader-${material.id}`; const id = `shader-${material.id}`;
+14
View File
@@ -45,6 +45,20 @@ export async function importGltf(scene: Scene, url: string | URL) {
await scene.reserve({ await scene.reserve({
nodes: result.primitives.length, nodes: result.primitives.length,
materials: result.materials.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[] = []; let textures: Texture[] = [];
const imported = await scene.batchGraphUpdates(async () => { const imported = await scene.batchGraphUpdates(async () => {
+89 -4
View File
@@ -6,7 +6,13 @@ import { ArcRotateCamera } from "../src/camera/ArcRotateCamera";
import { PointLight } from "../src/lights/PointLight"; import { PointLight } from "../src/lights/PointLight";
import { PBRMaterial } from "../src/materials/PBRMaterial"; import { PBRMaterial } from "../src/materials/PBRMaterial";
import type { Texture } from "../src/materials/Texture"; import type { Texture } from "../src/materials/Texture";
import { createScene, testCore, triangle } from "./scene-helpers"; import {
createScene,
forwardPasses,
latestGraph,
testCore,
triangle,
} from "./scene-helpers";
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) { function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
assert.equal(actual.length, expected.length); 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); assert.equal(mesh.indexCount, 3);
close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions); close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions);
close(scene.array("bounds").row(mesh.id), [-1, -1, 0, 0, 1, 1, 0, 0]); 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] }); const clone = mesh.clone({ position: [2, 0, 0] });
await clone.ready; 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); await clone.setMaterialForFaces(firstMaterial, 0);
assert.equal(clone.materialId, secondMaterial.id); assert.equal(clone.materialId, secondMaterial.id);
assert.equal(clone.faceMaterials.get(0), firstMaterial.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 = forwardPasses(latestGraph(scene)).map((pass: any) => pass.draw);
assert.deepEqual(
draws.map((draw: any) => [draw.baseVertex, draw.firstIndex, draw.indices]),
[
[moved.vertexRow, moved.indexRow * 4, 6],
[before.second.vertexRow, before.second.indexRow * 4, 3],
],
); );
}); });
+14 -5
View File
@@ -87,7 +87,12 @@ declare module "@yawn/handles" {
stride: number, stride: number,
format: "f32" | "u32" | "i32", format: "f32" | "u32" | "i32",
): Promise<SharedRows>; ): 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; batchWrites<T>(operation: () => T): T;
batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>; batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>;
compileRenderGraph(serialized: string): Promise<string>; compileRenderGraph(serialized: string): Promise<string>;
@@ -149,18 +154,22 @@ declare module "@yawn/handles" {
export interface MeshOptions extends NodeOptions { export interface MeshOptions extends NodeOptions {
geometryId?: number; geometryId?: number;
material?: PBRMaterial; material?: PBRMaterial;
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>; vertexData?: VertexStreams;
visible?: boolean; visible?: boolean;
} }
export type VertexStreams = Partial<Record<VertexKind, ArrayLike<number>>>;
export class Mesh extends Node { export class Mesh extends Node {
constructor(scene: Scene, options?: MeshOptions); constructor(scene: Scene, options?: MeshOptions);
geometryId: number; geometryId: number;
vertexCount: number; readonly vertexCount: number;
indexCount: number; readonly indexCount: number;
isVisible: boolean; isVisible: boolean;
material: PBRMaterial; material: PBRMaterial;
clone(options?: Omit<MeshOptions, "geometryId" | "vertexData">): Mesh; 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( setMaterialForFaces(
material: PBRMaterial, material: PBRMaterial,
faces: number | number[], faces: number | number[],