import { SharedProfile } from "./profile.js"; 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; this.update(descriptor); } update(descriptor) { 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; } get name() { return this.descriptor.name; } get rows() { return this.descriptor.rows; } get stride() { return this.descriptor.stride; } get format() { return this.descriptor.format; } /** 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"); const width = this.stride / views[this.format].BYTES_PER_ELEMENT; return this.view.subarray(index * width, (index + 1) * width); } read(index) { return Array.from(this.row(index)); } write(index, values) { const row = this.row(index); if (!values || values.length !== row.length) throw new RangeError("ROW_WIDTH"); row.set(values); return this; } share() { return { buffer: this.buffer, descriptor: this.descriptor }; } } export class YawnCore { #worker; #buffer; #arrays = new Map(); #pending = new Map(); #profileListeners = new Set(); #profile = new SharedProfile(); #profileTimer; #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"); this.#worker = workerFactory?.() ?? new Worker(new URL("./worker.js", import.meta.url), { type: "module", name: "yawn-core", }); this.#worker.addEventListener("message", ({ data }) => this.#message(data)); this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR")); this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR")); this.#worker.start?.(); const offscreen = canvas.transferControlToOffscreen?.() ?? canvas; this.ready = this.#request("init", { canvas: offscreen, arenaBytes, profileBuffer: this.#profile.buffer }, [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), ); if (debug) await this.#request("set-profiler", { enabled: true }); }); } async createRows({ name, rows, stride, format }) { await this.ready; 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 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) { await this.ready; await this.#request("delete-rows", { name }); this.#arrays.delete(name); } /** Reserves `count` contiguous rows and resolves with the first row ID. */ async allocateObject(name, count = 1) { await this.ready; 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 }); } async switchLoadout(id) { await this.ready; return this.#request("switch-loadout", { id }); } async uploadTexture(name, image, mipLevel = 0) { await this.ready; if ( typeof name !== "string" || !(image instanceof ImageBitmap) || !Number.isInteger(mipLevel) || mipLevel < 0 ) throw new TypeError("TEXTURE_SOURCE"); return this.#request( "upload-texture", { name, image, mipLevel }, [image], ); } async deleteTexture(name) { await this.ready; return this.#request("delete-texture", { name }); } async play() { await this.ready; return this.#request("play"); } async pause() { await this.ready; return this.#request("pause"); } async setFps(fps) { await this.ready; return this.#request("set-fps", { fps }); } async setProfiler(enabled) { await this.ready; return this.#request("set-profiler", { enabled: Boolean(enabled) }); } onProfile(listener) { if (typeof listener !== "function") throw new TypeError("PROFILE_LISTENER"); this.#profileListeners.add(listener); if (!this.#profileTimer) this.#profileTimer = setInterval(() => { const stats = this.#profile.read(); if (stats) for (const listener of this.#profileListeners) listener(stats); }, 100); return () => { this.#profileListeners.delete(listener); if (!this.#profileListeners.size) { clearInterval(this.#profileTimer); this.#profileTimer = undefined; } }; } array(name) { const array = this.#arrays.get(name); if (!array) throw new Error(`UNKNOWN_ARRAY: ${name}`); return array; } #request(type, payload = {}, transfer = []) { const request = this.#next++; return new Promise((resolve, reject) => { this.#pending.set(request, { resolve, reject }); this.#worker.postMessage({ type, request, ...payload }, transfer); }); } #message(message) { const pending = this.#pending.get(message?.request); if (!pending) return; this.#pending.delete(message.request); if (message.error) pending.reject(Object.assign(new Error(message.error), { code: message.error })); else pending.resolve(message.result); } #fail(code) { for (const { reject } of this.#pending.values()) reject(Object.assign(new Error(code), { code })); this.#pending.clear(); } dispose() { this.#fail("DISPOSED"); clearInterval(this.#profileTimer); this.#profileListeners.clear(); this.#worker.terminate(); } }