diff --git a/addons/handles/src/Mesh.ts b/addons/handles/src/Mesh.ts index 2294a46..9a2e14d 100644 --- a/addons/handles/src/Mesh.ts +++ b/addons/handles/src/Mesh.ts @@ -12,18 +12,17 @@ export const VertexKinds = Object.freeze([ "indices", ] as const); export type VertexKind = (typeof VertexKinds)[number]; +export type VertexStreams = Partial>>; export type MeshOptions = NodeOptions & { geometryId?: number; material?: PBRMaterial; - vertexData?: Partial>>; + 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(); #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) { - 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, + ) { + 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, - 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(); } } diff --git a/addons/handles/src/Scene.ts b/addons/handles/src/Scene.ts index b00403e..8bb0bb0 100644 --- a/addons/handles/src/Scene.ts +++ b/addons/handles/src/Scene.ts @@ -10,10 +10,39 @@ type RowFormat = "f32" | "u32" | "i32"; type MeshLike = { id: number; geometryId: number; - indexCount: number; - vertexCount: number; faceMaterials: ReadonlyMap; }; +type VertexData = Float32Array | Uint32Array; +/** One geometry: a span of interleaved `vertices` rows plus a span of `indices` rows. */ +type Geometry = { + data: Map; + /** Serialises published mutations of this geometry's spans. */ + pending: Promise; + 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 = { + 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 = { id: number; code: string; @@ -50,6 +79,8 @@ const rows = [ ["directionalLights", 32, "f32"], ["ambientLights", 16, "f32"], ["sceneAccent", 16, "f32"], + ["vertices", 80, "f32"], + ["indices", 16, "u32"], ] as const; const compactLightLimit = 500; @@ -196,8 +227,6 @@ fn fragment(input: VertexOutput) -> @location(0) vec4 { function pbrShader( mask: number, flags: number, - hasColors: boolean, - hasUvs2: boolean, transmission: boolean, textureInfo: ArrayLike, extensionInfo: ArrayLike, @@ -293,9 +322,9 @@ fn vertex( @location(0) point: vec3, @location(1) localNormal: vec3, @location(2) uv: vec2, - ${normalTexture ? "@location(3) localTangent: vec4," : ""} - ${hasColors ? "@location(4) color: vec4," : ""} - ${hasUvs2 ? "@location(5) uv2: vec2," : ""} + @location(3) localTangent: vec4, + @location(4) color: vec4, + @location(5) uv2: vec2, @builtin(instance_index) packed: u32, ) -> VertexOutput { let instance = packed & 65535u; @@ -316,9 +345,9 @@ fn vertex( output.world = world; output.normal = normalize(rotate(rotors[instance], localNormal / scale)); 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.color = ${hasColors ? "color" : "vec4(1.0)"}; - output.uv2 = ${hasUvs2 ? "uv2" : "uv"}; + output.tangent = vec4(normalize(rotate(rotors[instance], localTangent.xyz * scale)), localTangent.w); + output.color = color; + output.uv2 = uv2; output.material = (packed >> 16u) - 1u; return output; } @@ -590,12 +619,13 @@ export class Scene { #shaders = new Map(); #effects = new Map(); #textures = new Map(); - #geometry = new Map>(); - #geometryRefs = new Map(); + #geometry = new Map(); #nextGeometry = 1; #nextTexture = 0; #graphBatchDepth = 0; #graphBatchDirty = false; + #inhibits = 0; + #batchInhibits = 0; #writeBatchDepth = 0; #writeBatchDirty = 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; - const nodes = additional.nodes ?? 0; - const materials = additional.materials ?? 0; + const counts = { + nodes: additional.nodes ?? 0, + materials: additional.materials ?? 0, + vertices: additional.vertices ?? 0, + indices: Math.ceil((additional.indices ?? 0) / 4), + }; if ( - ![nodes, materials].every( + !Object.values(counts).every( (value) => Number.isInteger(value) && value >= 0, ) ) throw new RangeError("additional"); - const nodeCapacity = this.array("nodes").rows + nodes; - const materialCapacity = this.array("materials").rows + materials; - const growth = [ - ...rows.slice(0, 6).map(([name, stride, format]) => ({ - name, - rows: nodeCapacity, - stride, - format, - })), - { - name: "materials", - 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); + const families: [keyof typeof counts, readonly (typeof rows)[number][]][] = + [ + ["nodes", rows.slice(0, 6)], + ["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, + rows: capacity, + stride, + format, + })); + }) + .filter((request) => this.core.array(request.name).rows < request.rows); if (growth.length) await this.core.createRowsBatch(growth); } @@ -828,9 +855,16 @@ export class Scene { return await operation(); } finally { this.#graphBatchDepth--; - if (!this.#graphBatchDepth && this.#graphBatchDirty) { - this.#graphBatchDirty = false; - await this.updateRenderGraph(); + if (!this.#graphBatchDepth) { + try { + if (this.#graphBatchDirty) { + this.#graphBatchDirty = false; + await this.updateRenderGraph(); + } + } finally { + for (; this.#batchInhibits; this.#batchInhibits--) + this.#releaseRender(); + } } } } @@ -838,13 +872,7 @@ export class Scene { async allocateNode() { await this.ready; const id = await this.core.allocateObject("nodes"); - const growth = rows.slice(1, 6).flatMap(([name, stride, format]) => { - const current = this.array(name); - return current.rows < id + 1 - ? [{ name, rows: id + 1, stride, format }] - : []; - }); - if (growth.length) await this.core.createRowsBatch(growth); + await this.#growSiblings("nodes", rows.slice(1, 6)); this.array("nodeRotors").write(id, [0, 0, 0, 1]); this.array("nodeScales").write(id, [1, 1, 1, 0]); this.array("nodes").write(id, [1, 0, 0, 0]); @@ -867,14 +895,24 @@ export class Scene { async allocateMaterial() { await this.ready; const id = await this.core.allocateObject("materials"); - await Promise.all([ - this.ensureRows("materialTextures", id + 1, 64, "u32"), - this.ensureRows("materialExtensions", id + 1, 112, "f32"), - this.ensureRows("materialTextureInfo", id + 1, 416, "f32"), - ]); + await this.#growSiblings("materials", rows.slice(9, 12)); 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) { if (this.#computePasses.has(pass.id)) throw new Error(`COMPUTE_PASS_EXISTS: ${pass.id}`); @@ -893,109 +931,210 @@ export class Scene { registerMesh(mesh: MeshLike) { this.#meshes.set(mesh.id, mesh); - this.#geometryRefs.set( - mesh.geometryId, - (this.#geometryRefs.get(mesh.geometryId) ?? 0) + 1, - ); + this.referenceGeometry(mesh.geometryId); return this.updateRenderGraph(); } async unregisterMesh(mesh: MeshLike) { this.#meshes.delete(mesh.id); - const references = Math.max( - 0, - (this.#geometryRefs.get(mesh.geometryId) ?? 1) - 1, - ); - 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.#geometryRefs.delete(mesh.geometryId); - } + const geometry = this.#geometry.get(mesh.geometryId)!; + geometry.references = Math.max(0, geometry.references - 1); + if (geometry.references) return this.updateRenderGraph(); + this.#geometry.delete(mesh.geometryId); + await this.ready; + await this.#inhibitRender(async () => { + await this.updateRenderGraph(); + await this.#allocateSpans(geometry, 0, 0); + }); } createGeometry() { const id = this.#nextGeometry++; - this.#geometry.set(id, new Map()); - this.#geometryRefs.set(id, 0); + this.#geometry.set(id, { + data: new Map(), + pending: Promise.resolve(), + references: 0, + vertexRow: 0, + vertexCount: 0, + indexRow: 0, + indexCount: 0, + }); return id; } - geometryReferences(id: number) { - return this.#geometryRefs.get(id) ?? 0; + geometry(id: number): Readonly { + const geometry = this.#geometry.get(id); + if (!geometry) throw new Error(`UNKNOWN_GEOMETRY: ${id}`); + return geometry; } referenceGeometry(id: number) { - this.#geometryRefs.set(id, (this.#geometryRefs.get(id) ?? 0) + 1); + this.#geometry.get(id)!.references++; } releaseGeometry(id: number) { - this.#geometryRefs.set( - id, - Math.max(0, (this.#geometryRefs.get(id) ?? 1) - 1), - ); + const geometry = this.#geometry.get(id)!; + geometry.references = Math.max(0, geometry.references - 1); } async cloneGeometry(id: number) { const clone = this.createGeometry(); - for (const [kind, data] of this.#geometry.get(id) ?? []) - await this.setVertexData( - clone, - kind, - data.slice() as Float32Array | Uint32Array, - false, - ); + const copies: Record = {}; + for (const [kind, data] of this.geometry(id).data) + copies[kind] = data.slice() as VertexData; + await this.setVertexData(clone, copies, false); 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( geometry: number, - kind: string, - source: ArrayLike, + streams: Partial>>, updateGraph = true, ) { - const components: Record = { - positions: 3, - normals: 3, - tangents: 4, - uvs: 2, - uvs2: 2, - colors: 4, - indices: 1, - }; - const width = components[kind]; - if (!width || source.length % width) - throw new RangeError(`VERTEX_DATA: ${kind}`); - const integer = kind === "indices"; - const data = integer ? Uint32Array.from(source) : Float32Array.from(source); - const name = `geometry.${geometry}.${kind}`; - const rowCount = integer ? Math.ceil(data.length / 4) : data.length / width; - const target = await this.ensureRows( - name, - rowCount, - 16, - integer ? "u32" : "f32", - ); - if (updateGraph) this.markDirty(true); - const view = target.view; - view.fill(0); - if (integer || width === 4) view.set(data); - else - for (let row = 0; row < rowCount; row++) - for (let lane = 0; lane < width; lane++) - view[row * 4 + lane] = data[row * width + lane]; - ( - this.#geometry.get(geometry) ?? - this.#geometry.set(geometry, new Map()).get(geometry)! - ).set(kind, data); - await this.core.syncRows(name, 0, rowCount); - if (updateGraph) await this.updateRenderGraph(); + const record = this.#geometry.get(geometry); + if (!record) throw new Error(`UNKNOWN_GEOMETRY: ${geometry}`); + // Validate the whole assembled geometry before touching the record or its spans. + const data = new Map(record.data); + for (const [kind, source] of Object.entries(streams)) { + if (!source) continue; + if (kind !== "indices" && !vertexLanes[kind]) + throw new RangeError(`VERTEX_DATA: ${kind}`); + data.set( + kind, + kind === "indices" + ? Uint32Array.from(source) + : Float32Array.from(source), + ); + } + const vertexCount = (data.get("positions")?.length ?? 0) / 3; + for (const [kind, [, width]] of Object.entries(vertexLanes)) { + const stream = data.get(kind); + if (stream && stream.length !== vertexCount * width) + 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(operation: () => Promise) { + 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++) + vertices[base + vertex * vertexWidth + offset + lane] = + data[vertex * width + lane]; + } + if (!record.data.has("uvs2")) { + const uvs = record.data.get("uvs"); + if (uvs) + 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) { - return this.#geometry.get(id)?.get(kind); + return this.#geometry.get(id)?.data.get(kind); } registerShader(material: ShaderLike) { @@ -1128,22 +1267,56 @@ export class Scene { const transmitted = this.array("materialExtensions").row(material)[0] > 0; return transmitted ? 1 : (flags & 3) === 2 ? 2 : 0; }; - const renderedMeshes = [...this.#meshes.values()] - .filter((mesh) => mesh.vertexCount > 0) - .sort( - (left, right) => - renderCategory(Number(this.array("meshInfo").row(left.id)[1])) - - renderCategory(Number(this.array("meshInfo").row(right.id)[1])), - ); - const hasTransmission = renderedMeshes.some((mesh) => { - const materials = new Set([ - Number(this.array("meshInfo").row(mesh.id)[1]), - ...mesh.faceMaterials.values(), - ]); - return [...materials].some( - (material) => this.array("materialExtensions").row(material)[0] > 0, - ); - }); + // One draw per run of consecutive faces sharing a material, in category order: + // opaque/mask first, then transmission, then blended, each in mesh ID order. + type ForwardDraw = { + mesh: MeshLike; + geometry: Readonly; + material: number; + firstIndex: number; + indices: number; + }; + const forwardDraws: ForwardDraw[] = []; + for (const mesh of this.#meshes.values()) { + const geometry = this.geometry(mesh.geometryId); + if (!geometry.vertexCount) continue; + 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 = hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0; const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm"; @@ -1190,7 +1363,7 @@ export class Scene { } let previous = computeIds.length ? computeIds : ["cluster-lights"]; - if (!renderedMeshes.length) { + if (!forwardDraws.length) { renderPipelines.push({ id: "empty-forward", code: clearShader, @@ -1238,412 +1411,248 @@ export class Scene { }); 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(); let firstColor = true; let firstDepth = true; let sceneColorReady = false; let forwardTarget = compositeSceneColor ? "scene-color" : "hdr"; - for (const mesh of renderedMeshes) { - const vertex = `geometry-${mesh.geometryId}-positions`; - addBuffer({ - id: vertex, - array: `geometry.${mesh.geometryId}.positions`, - usage: ["vertex"], - 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) - throw new RangeError( - `Scene handle limit: mesh ${mesh.id}, material ${material}`, - ); - const pointers = this.array("materialTextures").row(material); - const texture = (lane: number) => - pointers[lane] - ? this.#textures.get(Number(pointers[lane]) - 1) - : undefined; - const materialTextures = Array.from({ length: 13 }, (_, lane) => - lane === 2 && !hasTangents ? undefined : texture(lane), + for (const draw of forwardDraws) { + const { mesh, geometry, material } = draw; + const id = `forward-${mesh.id}-${draw.firstIndex}`; + const hasNormals = geometry.data.has("normals"); + const hasUvs = geometry.data.has("uvs"); + const hasTangents = geometry.data.has("tangents"); + if (mesh.id > 65535 || material > 65534) + throw new RangeError( + `Scene handle limit: mesh ${mesh.id}, material ${material}`, ); - const normalTexture = materialTextures[2]; - const detailed = hasNormals && hasUvs; - const flags = Math.round(this.array("materials").row(material)[11]); - const extensionInfo = this.array("materialExtensions").row(material); - const transmitted = extensionInfo[0] > 0; - let mask = detailed - ? materialTextures.reduce( - (value, current, lane) => - value | (current === undefined ? 0 : 1 << lane), - 0, - ) - : 0; - if (!transmitted) mask &= ~((1 << 5) | (1 << 6)); - else if (extensionInfo[2] <= 0) mask &= ~(1 << 6); - if (extensionInfo[8] <= 0) mask &= ~(1 << 7); - if (extensionInfo[16] <= 0) mask &= ~((1 << 8) | (1 << 9)); - if (extensionInfo[20] <= 0) mask &= ~(1 << 10); - if (extensionInfo[11] <= 0) mask &= ~(1 << 4); - 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); - let clearCompositeLayer = false; - if (transmitted && !sceneColorReady) { - sceneColorReady = true; - if (compositeSceneColor) { - forwardTarget = "hdr"; - clearCompositeLayer = true; - } else { - addTexture({ - id: "scene-color", - format: hdrFormat, - size: ["canvas", "canvas", 1], - usage: ["render", "sampled"], - transient: true, - }); - renderPipelines.push({ - id: "copy-scene-color", - code: copyShader, - vertex: { entry: "vertex" }, - fragment: { - entry: "fragment", - targets: [{ format: hdrFormat }], - }, - }); - passes.push({ - id: "copy-scene-color", - type: "render", - pipeline: "copy-scene-color", - after: previous, - bindings: [ - { group: 0, binding: 0, resource: "hdr" }, - { group: 0, binding: 1, resource: "linear" }, - ], - color: [ - { resource: "scene-color", clear: [...this.clearColor] }, - ], - draw: { vertices: 3 }, - }); - previous = ["copy-scene-color"]; - } - } - const code = detailed - ? pbrShader( - mask, - flags, - hasColors, - hasUvs2, - transmitted, - textureInfo, - extensionInfo, - ) - : basicForwardShader; - const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`; - let pipeline = forwardPipelines.get(pipelineKey); - if (!pipeline) { - pipeline = `forward-${detailed ? "pbr" : "basic"}-${forwardPipelines.size}`; - forwardPipelines.set(pipelineKey, pipeline); + const pointers = this.array("materialTextures").row(material); + const texture = (lane: number) => + pointers[lane] + ? this.#textures.get(Number(pointers[lane]) - 1) + : undefined; + const materialTextures = Array.from({ length: 13 }, (_, lane) => + lane === 2 && !hasTangents ? undefined : texture(lane), + ); + const detailed = hasNormals && hasUvs; + const flags = Math.round(this.array("materials").row(material)[11]); + const extensionInfo = this.array("materialExtensions").row(material); + const transmitted = extensionInfo[0] > 0; + let mask = detailed + ? materialTextures.reduce( + (value, current, lane) => + value | (current === undefined ? 0 : 1 << lane), + 0, + ) + : 0; + if (!transmitted) mask &= ~((1 << 5) | (1 << 6)); + else if (extensionInfo[2] <= 0) mask &= ~(1 << 6); + if (extensionInfo[8] <= 0) mask &= ~(1 << 7); + if (extensionInfo[16] <= 0) mask &= ~((1 << 8) | (1 << 9)); + if (extensionInfo[20] <= 0) mask &= ~(1 << 10); + if (extensionInfo[11] <= 0) mask &= ~(1 << 4); + if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3); + const textureInfo = this.array("materialTextureInfo").row(material); + let clearCompositeLayer = false; + if (transmitted && !sceneColorReady) { + sceneColorReady = true; + if (compositeSceneColor) { + forwardTarget = "hdr"; + clearCompositeLayer = true; + } else { + addTexture({ + id: "scene-color", + format: hdrFormat, + size: ["canvas", "canvas", 1], + usage: ["render", "sampled"], + transient: true, + }); renderPipelines.push({ - id: pipeline, - code, - vertex: { - entry: "vertex", - buffers: detailed - ? [ - { - arrayStride: 16, - attributes: [ - { - format: "float32x3", - 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, - }, - ], - }, - ], - }, + id: "copy-scene-color", + code: copyShader, + vertex: { entry: "vertex" }, fragment: { entry: "fragment", - targets: [ - { - format: hdrFormat, - ...((flags & 3) === 2 - ? { - blend: { - color: { - srcFactor: "src-alpha", - dstFactor: "one-minus-src-alpha", - operation: "add", - }, - alpha: { - srcFactor: "one", - dstFactor: "one-minus-src-alpha", - operation: "add", - }, - }, - } - : {}), - }, - ], - }, - depthStencil: { - format: "depth24plus", - depth_write_enabled: (flags & 3) !== 2 && !transmitted, - depth_compare: "less", - }, - primitive: { - topology: "triangle-list", - frontFace: "ccw", - cullMode: (flags & 4) !== 0 ? null : "back", - conservative: false, + targets: [{ format: hdrFormat }], }, }); + passes.push({ + id: "copy-scene-color", + type: "render", + pipeline: "copy-scene-color", + after: previous, + bindings: [ + { group: 0, binding: 0, resource: "hdr" }, + { group: 0, binding: 1, resource: "linear" }, + ], + color: [{ resource: "scene-color", clear: [...this.clearColor] }], + draw: { vertices: 3 }, + }); + previous = ["copy-scene-color"]; } - const instance = (mesh.id + (material + 1) * 65536) >>> 0; - const samplerTexture = materialTextures.find( - (value, lane) => value !== undefined && (mask & (1 << lane)) !== 0, - ); - passes.push({ - id, - type: "render", - pipeline, - after: previous, - bindings: [ - ...[ - "clusters", - "accent", - "node-positions", - "node-rotors", - "node-scales", - "mesh-info", - "materials", - "camera-matrices", - ].map((resource, binding) => ({ - group: 0, - binding, - resource, - })), - ...(detailed - ? [ - { group: 0, binding: 8, resource: "point-lights" }, - { group: 0, binding: 9, resource: "spot-lights" }, - { - group: 0, - binding: 10, - resource: "directional-lights", - }, - ] - : []), - ...(detailed && mask - ? [ - { - group: 1, - binding: 0, - resource: samplerTexture?.sampler - ? `${samplerTexture.id}-sampler` - : "material-linear", - }, - ...materialTextures.flatMap((value, lane) => - value && (mask & (1 << lane)) !== 0 - ? [ - { - group: 1, - binding: lane + 1, - resource: value.id, - }, - ] - : [], - ), - ] - : []), - ...(detailed && transmitted - ? [ - { group: 2, binding: 0, resource: "scene-color" }, - { group: 2, binding: 1, resource: "linear" }, - ] - : []), - ], - color: [ - { - resource: forwardTarget, - ...(clearCompositeLayer - ? { clear: [0, 0, 0, 0] } - : firstColor - ? { clear: [...this.clearColor] } - : { load: "load" }), - }, - ], - depth: { - resource: "depth", - ...(firstDepth ? { clear: 1 } : { load: "load" }), - }, - vertexBuffers: [ - { slot: 0, resource: vertex }, - ...(detailed - ? [ - { 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, - instances: 1, - firstInstance: instance, - } - : { vertices: draw.count, instances: 1, firstInstance: instance }, - }); - firstColor = false; - firstDepth = false; - previous = [id]; } + const code = detailed + ? pbrShader(mask, flags, transmitted, textureInfo, extensionInfo) + : basicForwardShader; + const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`; + let pipeline = forwardPipelines.get(pipelineKey); + if (!pipeline) { + pipeline = `forward-${detailed ? "pbr" : "basic"}-${forwardPipelines.size}`; + forwardPipelines.set(pipelineKey, pipeline); + renderPipelines.push({ + id: pipeline, + code, + vertex: { + entry: "vertex", + buffers: [ + { + arrayStride: 80, + attributes: detailed + ? vertexAttributes + : vertexAttributes.slice(0, 1), + }, + ], + }, + fragment: { + entry: "fragment", + targets: [ + { + format: hdrFormat, + ...((flags & 3) === 2 + ? { + blend: { + color: { + srcFactor: "src-alpha", + dstFactor: "one-minus-src-alpha", + operation: "add", + }, + alpha: { + srcFactor: "one", + dstFactor: "one-minus-src-alpha", + operation: "add", + }, + }, + } + : {}), + }, + ], + }, + depthStencil: { + format: "depth24plus", + depth_write_enabled: (flags & 3) !== 2 && !transmitted, + depth_compare: "less", + }, + primitive: { + topology: "triangle-list", + frontFace: "ccw", + cullMode: (flags & 4) !== 0 ? null : "back", + conservative: false, + }, + }); + } + const instance = (mesh.id + (material + 1) * 65536) >>> 0; + const samplerTexture = materialTextures.find( + (value, lane) => value !== undefined && (mask & (1 << lane)) !== 0, + ); + passes.push({ + id, + type: "render", + pipeline, + after: previous, + bindings: [ + ...[ + "clusters", + "accent", + "node-positions", + "node-rotors", + "node-scales", + "mesh-info", + "materials", + "camera-matrices", + ].map((resource, binding) => ({ + group: 0, + binding, + resource, + })), + ...(detailed + ? [ + { group: 0, binding: 8, resource: "point-lights" }, + { group: 0, binding: 9, resource: "spot-lights" }, + { + group: 0, + binding: 10, + resource: "directional-lights", + }, + ] + : []), + ...(detailed && mask + ? [ + { + group: 1, + binding: 0, + resource: samplerTexture?.sampler + ? `${samplerTexture.id}-sampler` + : "material-linear", + }, + ...materialTextures.flatMap((value, lane) => + value && (mask & (1 << lane)) !== 0 + ? [ + { + group: 1, + binding: lane + 1, + resource: value.id, + }, + ] + : [], + ), + ] + : []), + ...(detailed && transmitted + ? [ + { group: 2, binding: 0, resource: "scene-color" }, + { group: 2, binding: 1, resource: "linear" }, + ] + : []), + ], + color: [ + { + resource: forwardTarget, + ...(clearCompositeLayer + ? { clear: [0, 0, 0, 0] } + : firstColor + ? { clear: [...this.clearColor] } + : { load: "load" }), + }, + ], + depth: { + resource: "depth", + ...(firstDepth ? { clear: 1 } : { load: "load" }), + }, + vertexBuffers: [{ slot: 0, resource: "vertices" }], + indexBuffer: { resource: "indices", format: "uint32" }, + draw: { + indices: draw.indices, + firstIndex: draw.firstIndex, + baseVertex: geometry.vertexRow, + instances: 1, + firstInstance: instance, + }, + }); + firstColor = false; + firstDepth = false; + previous = [id]; } } diff --git a/addons/handles/src/importers/gltf.ts b/addons/handles/src/importers/gltf.ts index fceeb86..4adf212 100644 --- a/addons/handles/src/importers/gltf.ts +++ b/addons/handles/src/importers/gltf.ts @@ -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 () => { diff --git a/addons/handles/test/handles.test.ts b/addons/handles/test/handles.test.ts index c5db862..89baf0c 100644 --- a/addons/handles/test/handles.test.ts +++ b/addons/handles/test/handles.test.ts @@ -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 { + createScene, + forwardPasses, + latestGraph, + testCore, + triangle, +} from "./scene-helpers"; function close(actual: ArrayLike, expected: ArrayLike) { 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((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], + ], ); }); diff --git a/server/web/assets/yawn.d.ts b/server/web/assets/yawn.d.ts index e218535..09e5a29 100644 --- a/server/web/assets/yawn.d.ts +++ b/server/web/assets/yawn.d.ts @@ -87,7 +87,12 @@ declare module "@yawn/handles" { stride: number, format: "f32" | "u32" | "i32", ): Promise; - reserve(additional: { nodes?: number; materials?: number }): Promise; + reserve(additional: { + nodes?: number; + materials?: number; + vertices?: number; + indices?: number; + }): Promise; batchWrites(operation: () => T): T; batchGraphUpdates(operation: () => T | Promise): Promise; compileRenderGraph(serialized: string): Promise; @@ -149,18 +154,22 @@ declare module "@yawn/handles" { export interface MeshOptions extends NodeOptions { geometryId?: number; material?: PBRMaterial; - vertexData?: Partial>>; + vertexData?: VertexStreams; visible?: boolean; } + export type VertexStreams = Partial>>; 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): Mesh; - setVertexData(kind: VertexKind, data: ArrayLike): Promise; + setVertexData( + kind: VertexKind | VertexStreams, + data?: ArrayLike, + ): Promise; setMaterialForFaces( material: PBRMaterial, faces: number | number[],