import { YawnCore } from "@yawn/core"; import type { ComputePass, GraphBuffer, GraphSampler, GraphTexture, } from "./ComputePass"; type RowFormat = "f32" | "u32" | "i32"; type MeshLike = { id: number; geometryId: number; indexCount: number; vertexCount: number; faceMaterials: ReadonlyMap; }; type ShaderLike = { id: number; code: string; vertexEntry: string; fragmentEntry: string; }; type PostProcessState = { id: string; kind: string; options: Record }; type TextureState = GraphTexture & { number: number; source?: string | ImageBitmap }; const rows = [ ["nodes", 16, "u32"], ["nodePositions", 16, "f32"], ["nodeQuaternions", 16, "f32"], ["nodeScales", 16, "f32"], ["meshInfo", 16, "u32"], ["bounds", 32, "f32"], ["cameras", 80, "f32"], ["materials", 48, "f32"], ["materialTextures", 32, "u32"], ["pointLights", 32, "f32"], ["rectAreaLights", 48, "f32"], ["spotLights", 32, "f32"], ["directionalLights", 32, "f32"], ["ambientLights", 16, "f32"], ["sceneAccent", 16, "f32"], ] as const; const clusterShader = /* wgsl */ ` @group(0) @binding(0) var pointLights: array>; @group(0) @binding(1) var rectLights: array>; @group(0) @binding(2) var spotLights: array>; @group(0) @binding(3) var directionalLights: array>; @group(0) @binding(4) var ambientLights: array>; @group(0) @binding(5) var clusters: array; @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3) { if (id.x != 0u) { return; } var count = 0u; var light = vec3(0.0); for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) { let enabled = pointLights[i * 2u + 1u].y; count += select(0u, 1u, enabled != 0.0); light += pointLights[i * 2u].rgb * pointLights[i * 2u].a * enabled * 0.02; } for (var i = 0u; i < arrayLength(&rectLights) / 3u; i++) { let enabled = rectLights[i * 3u + 1u].z; count += select(0u, 1u, enabled != 0.0); light += rectLights[i * 3u].rgb * rectLights[i * 3u].a * enabled * 0.02; } for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) { let enabled = spotLights[i * 2u + 1u].w; count += select(0u, 1u, enabled != 0.0); light += spotLights[i * 2u].rgb * spotLights[i * 2u].a * enabled * 0.02; } for (var i = 0u; i < arrayLength(&directionalLights) / 2u; i++) { let enabled = directionalLights[i * 2u + 1u].x; count += select(0u, 1u, enabled != 0.0); light += directionalLights[i * 2u].rgb * directionalLights[i * 2u].a * enabled * 0.1; } for (var i = 0u; i < arrayLength(&ambientLights); i++) { count += select(0u, 1u, ambientLights[i].a != 0.0); light += ambientLights[i].rgb * ambientLights[i].a; } clusters[0] = count; clusters[1] = bitcast(light.r); clusters[2] = bitcast(light.g); clusters[3] = bitcast(light.b); }`; const forwardShader = /* wgsl */ ` struct Accent { color: vec4 } struct VertexOutput { @builtin(position) position: vec4, @location(0) normal: vec3, @location(1) @interpolate(flat) mesh: u32, @location(2) @interpolate(flat) material: u32, } @group(0) @binding(0) var clusters: array; @group(0) @binding(1) var accent: Accent; @group(0) @binding(2) var positions: array>; @group(0) @binding(3) var quaternions: array>; @group(0) @binding(4) var scales: array>; @group(0) @binding(5) var meshInfo: array; @group(0) @binding(6) var materials: array>; @group(0) @binding(7) var cameras: array>; fn rotate(q: vec4, value: vec3) -> vec3 { return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.w * value); } @vertex fn vertex(@location(0) point: vec3, @builtin(instance_index) packed: u32) -> VertexOutput { let instance = packed & 65535u; let visible = meshInfo[instance * 4u + 2u]; let transformed = rotate(quaternions[instance], point * scales[instance].xyz) + positions[instance].xyz; var clip = vec4(transformed, 1.0); if (cameras[2].w != 0.0) { let cameraNode = u32(cameras[1].x); let inverse = vec4(-quaternions[cameraNode].xyz, quaternions[cameraNode].w); let view = rotate(inverse, transformed - positions[cameraNode].xyz); if (cameras[1].y == 1.0) { let size = max(cameras[1].z, 0.0001); clip = vec4(view.x / (size * cameras[0].y * 0.5), view.y / (size * 0.5), -view.z / cameras[0].w, 1.0); } else { let focal = 1.0 / tan(cameras[0].x * 0.5); let depth = (-view.z * cameras[0].w - cameras[0].z * cameras[0].w) / (cameras[0].w - cameras[0].z); clip = vec4(view.x * focal / cameras[0].y, view.y * focal, depth, -view.z); } } var output: VertexOutput; output.position = select(vec4(2.0, 2.0, 2.0, 1.0), clip, visible != 0u); output.normal = normalize(rotate(quaternions[instance], vec3(0.0, 0.0, 1.0))); output.mesh = instance; output.material = packed >> 16u; return output; } @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { let fallback = meshInfo[input.mesh * 4u + 1u]; let material = select(fallback, input.material - 1u, input.material != 0u); let base = materials[material * 3u]; let properties = materials[material * 3u + 1u]; let clusterLight = vec3(bitcast(clusters[1]), bitcast(clusters[2]), bitcast(clusters[3])); let light = vec3(0.12 + max(dot(input.normal, normalize(vec3(0.4, 0.7, 0.6))), 0.0) * 0.75) + clusterLight; let clustered = min(f32(clusters[0]) * 0.002, 0.05); let color = base.rgb * (light + clustered) * accent.color.rgb * mix(1.0, 1.1, properties.x); return vec4(color, base.a); }`; const emptyForwardShader = /* wgsl */ ` struct Accent { color: vec4 } @group(0) @binding(0) var accent: Accent; struct VertexOutput { @builtin(position) position: vec4 } @vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput { let points = array(vec2(-0.72, -0.6), vec2(0.72, -0.6), vec2(0.0, 0.72)); var output: VertexOutput; output.position = vec4(points[index], 0.0, 1.0); return output; } @fragment fn fragment() -> @location(0) vec4 { return vec4(accent.color.rgb, 1.0); }`; const fullscreenVertex = /* wgsl */ ` struct VertexOutput { @builtin(position) position: vec4, @location(0) uv: vec2, } @vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput { let points = array(vec2(-1.0, -3.0), vec2(3.0, 1.0), vec2(-1.0, 1.0)); var output: VertexOutput; output.position = vec4(points[index], 0.0, 1.0); output.uv = points[index] * vec2(0.5, -0.5) + vec2(0.5); return output; }`; function effectFragment(kind: string, options: Record) { const amount = Number(options.amount ?? options.exposure ?? 1); const safeAmount = Number.isFinite(amount) ? amount : 1; const body: Record = { ssao: `let value = textureSample(source, sourceSampler, input.uv); return vec4(value.rgb * ${Math.max(0, 1 - safeAmount * 0.2)}, value.a);`, fxaa: `let size = vec2(textureDimensions(source)); let pixel = 1.0 / size; let center = textureSample(source, sourceSampler, input.uv); let around = textureSample(source, sourceSampler, input.uv + vec2(pixel.x, 0.0)) + textureSample(source, sourceSampler, input.uv - vec2(pixel.x, 0.0)) + textureSample(source, sourceSampler, input.uv + vec2(0.0, pixel.y)) + textureSample(source, sourceSampler, input.uv - vec2(0.0, pixel.y)); return mix(center, around * 0.25, 0.35);`, colorGrading: `let value = textureSample(source, sourceSampler, input.uv); return vec4(pow(max(value.rgb * ${safeAmount}, vec3(0.0)), vec3(1.0 / 2.2)), value.a);`, dynamicExposure: `let value = textureSample(source, sourceSampler, input.uv); return vec4(value.rgb * ${safeAmount}, value.a);`, silhouette: `let size = vec2(textureDimensions(source)); let pixel = 1.0 / size; let value = textureSample(source, sourceSampler, input.uv); let edge = length(value.rgb - textureSample(source, sourceSampler, input.uv + pixel).rgb); return vec4(mix(value.rgb, vec3(0.0), smoothstep(0.08, 0.2, edge)), value.a);`, edges: `let size = vec2(textureDimensions(source)); let pixel = 1.0 / size; let value = textureSample(source, sourceSampler, input.uv); let dx = length(value.rgb - textureSample(source, sourceSampler, input.uv + vec2(pixel.x, 0.0)).rgb); let dy = length(value.rgb - textureSample(source, sourceSampler, input.uv + vec2(0.0, pixel.y)).rgb); return vec4(vec3(max(dx, dy) * ${safeAmount}), value.a);`, }; return `${fullscreenVertex} @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var sourceSampler: sampler; @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { ${body[kind] ?? "return textureSample(source, sourceSampler, input.uv);"} }`; } function presentShader(toneMap: string) { const tone = toneMap === "reinhard" ? "color / (color + vec3(1.0))" : toneMap === "linear" ? "clamp(color, vec3(0.0), vec3(1.0))" : "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))"; return `${fullscreenVertex} @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var sourceSampler: sampler; @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { let value = textureSample(source, sourceSampler, input.uv); let color = value.rgb; return vec4(${tone}, value.a); }`; } function encode(value: unknown): string { if (value === null || typeof value === "boolean" || typeof value === "number") return String(value); if (typeof value === "string") return JSON.stringify(value); if (Array.isArray(value)) return `(array${value.map((item) => ` ${encode(item)}`).join("")})`; if (value && value.constructor === Object) return `(object${Object.keys(value as object).sort().map((key) => ` (field ${JSON.stringify(key)} ${encode((value as Record)[key])})`).join("")})`; throw new TypeError("Render graph values must be plain data"); } function serialize(graph: object) { return `(yawn-graph 1 ${encode(graph)})`; } /** The conventional single-loadout scene layer; hot values always remain direct SAB writes. */ export class Scene { readonly core: YawnCore; readonly ready: Promise; readonly hdr: boolean; #graphUpdates = Promise.resolve(); #computePasses = new Map(); #meshes = new Map(); #shaders = new Map(); #effects = new Map(); #textures = new Map(); #geometry = new Map>(); #geometryRefs = new Map(); #nextGeometry = 1; #nextTexture = 0; constructor(canvas: HTMLCanvasElement, options: { arenaBytes?: number; fps?: number; hdr?: boolean } = {}) { this.hdr = options.hdr ?? true; this.core = new YawnCore(canvas, { arenaBytes: options.arenaBytes }); this.ready = this.#initialize(options.fps ?? 60); } async #initialize(fps: number) { await this.core.ready; for (const [name, stride, format] of rows) await this.core.createRows({ name, rows: 1, stride, format }); await this.core.createRows({ name: "clusters", rows: 256, stride: 16, format: "u32" }); this.core.array("nodeQuaternions").write(0, [0, 0, 0, 1]); this.core.array("nodeScales").write(0, [1, 1, 1, 0]); this.core.array("sceneAccent").write(0, [0.28, 0.72, 1, 1]); const material = await this.core.allocateObject("materials"); this.core.array("materials").write(material, [1, 1, 1, 1, 0, 0.7, 0, 0, 0, 0, 1, 0.5]); await this.core.setFps(fps); await this.#compileRenderGraph(); return this; } array(name: string) { return this.core.array(name); } async ensureRows(name: string, rowCount: number, stride: number, format: RowFormat) { await this.core.ready; try { const current = this.core.array(name); if (current.stride !== stride || current.format !== format) throw new Error(`ROW_LAYOUT: ${name}`); if (current.rows >= rowCount) return current; } catch (error) { if (!(error instanceof Error) || !error.message.startsWith("UNKNOWN_ARRAY")) throw error; } return this.core.createRows({ name, rows: Math.max(1, rowCount), stride, format }); } 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); this.array("nodeQuaternions").write(id, [0, 0, 0, 1]); this.array("nodeScales").write(id, [1, 1, 1, 0]); this.array("nodes").write(id, [1, 0, 0, 0]); return id; } async releaseNode(id: number) { for (const name of ["nodes", "nodePositions", "nodeQuaternions", "nodeScales", "meshInfo", "bounds"]) this.array(name).row(id).fill(0); await this.core.deleteObject("nodes", id); } async allocateMaterial() { await this.ready; const id = await this.core.allocateObject("materials"); await this.ensureRows("materialTextures", id + 1, 32, "u32"); return id; } addComputePass(pass: ComputePass) { if (this.#computePasses.has(pass.id)) throw new Error(`COMPUTE_PASS_EXISTS: ${pass.id}`); this.#computePasses.set(pass.id, pass); pass.attach(this); return this.updateRenderGraph(); } removeComputePass(pass: ComputePass | string) { const id = typeof pass === "string" ? pass : pass.id; const existing = this.#computePasses.get(id); existing?.attach(undefined); this.#computePasses.delete(id); return this.updateRenderGraph(); } registerMesh(mesh: MeshLike) { this.#meshes.set(mesh.id, mesh); this.#geometryRefs.set(mesh.geometryId, (this.#geometryRefs.get(mesh.geometryId) ?? 0) + 1); 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); } } createGeometry() { const id = this.#nextGeometry++; this.#geometry.set(id, new Map()); this.#geometryRefs.set(id, 0); return id; } geometryReferences(id: number) { return this.#geometryRefs.get(id) ?? 0; } referenceGeometry(id: number) { this.#geometryRefs.set(id, (this.#geometryRefs.get(id) ?? 0) + 1); } releaseGeometry(id: number) { this.#geometryRefs.set(id, Math.max(0, (this.#geometryRefs.get(id) ?? 1) - 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); return clone; } async setVertexData(geometry: number, kind: string, source: ArrayLike, updateGraph = true) { const components: Record = { positions: 3, normals: 3, tangents: 4, uvs: 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"); target.view.fill(0); if (integer) target.view.set(data); else for (let row = 0; row < rowCount; row++) target.row(row).set(data.subarray(row * width, (row + 1) * width)); (this.#geometry.get(geometry) ?? this.#geometry.set(geometry, new Map()).get(geometry)!) .set(kind, data); if (updateGraph) await this.updateRenderGraph(); } geometryData(id: number, kind: string) { return this.#geometry.get(id)?.get(kind); } registerShader(material: ShaderLike) { this.#shaders.set(material.id, material); return this.updateRenderGraph(); } unregisterShader(id: number) { this.#shaders.delete(id); return this.updateRenderGraph(); } registerTexture(texture: Omit) { const number = this.#nextTexture++; this.#textures.set(number, { ...texture, number }); return { number, ready: this.updateRenderGraph() }; } unregisterTexture(number: number) { this.#textures.delete(number); return this.updateRenderGraph(); } setPostProcess(effect: PostProcessState, enabled: boolean) { if (enabled) this.#effects.set(effect.id, effect); else this.#effects.delete(effect.id); return this.updateRenderGraph(); } updateRenderGraph() { const update = this.#graphUpdates.then(async () => { await this.ready; await this.#compileRenderGraph(); }); this.#graphUpdates = update.catch(() => undefined); return update; } async #compileRenderGraph() { const buffers = new Map(); const textures = new Map(); const samplers = new Map(); const computePipelines: object[] = []; const renderPipelines: object[] = []; const passes: object[] = []; const addBuffer = (value: GraphBuffer) => buffers.set(value.id, value); const addTexture = (value: GraphTexture) => textures.set(value.id, value); const addSampler = (value: GraphSampler) => samplers.set(value.id, value); for (const [id, array] of [ ["point-lights", "pointLights"], ["rect-lights", "rectAreaLights"], ["spot-lights", "spotLights"], ["directional-lights", "directionalLights"], ["ambient-lights", "ambientLights"], ["clusters", "clusters"], ]) addBuffer({ id, array, usage: ["storage"] }); addBuffer({ id: "accent", array: "sceneAccent", usage: ["uniform"] }); computePipelines.push({ id: "cluster-lights", code: clusterShader, entry: "main" }); passes.push({ id: "cluster-lights", type: "compute", pipeline: "cluster-lights", dispatch: [4, 1, 1], bindings: ["point-lights", "rect-lights", "spot-lights", "directional-lights", "ambient-lights", "clusters"] .map((resource, binding) => ({ group: 0, binding, resource })), }); for (const pass of this.#computePasses.values()) { pass.buffers.forEach(addBuffer); pass.textures.forEach(addTexture); pass.samplers.forEach(addSampler); computePipelines.push({ id: pass.id, code: pass.code, entry: pass.entry }); passes.push({ id: pass.id, type: "compute", pipeline: pass.id, after: pass.after.length ? pass.after : ["cluster-lights"], bindings: pass.bindings, dispatch: pass.dispatch, }); } const computeIds = [...this.#computePasses.keys()]; const renderedMeshes = [...this.#meshes.values()].filter((mesh) => mesh.vertexCount > 0); const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm"; addTexture({ id: "hdr", format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: false }); addSampler({ id: "linear", magFilter: "linear", minFilter: "linear" }); let previous = computeIds.length ? computeIds : ["cluster-lights"]; if (!renderedMeshes.length) { renderPipelines.push({ id: "empty-forward", code: emptyForwardShader, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] } }); passes.push({ id: "forward-empty", type: "render", pipeline: "empty-forward", after: previous, bindings: [{ group: 0, binding: 0, resource: "accent" }], color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }], draw: { vertices: 3 }, }); previous = ["forward-empty"]; } else { for (const [id, array] of [ ["node-positions", "nodePositions"], ["node-quaternions", "nodeQuaternions"], ["node-scales", "nodeScales"], ["mesh-info", "meshInfo"], ["materials", "materials"], ["cameras", "cameras"], ]) addBuffer({ id, array, usage: ["storage"] }); renderPipelines.push({ id: "forward-pbr", code: forwardShader, vertex: { entry: "vertex", buffers: [{ arrayStride: 16, attributes: [{ format: "float32x3", offset: 0, shaderLocation: 0 }] }] }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] }, }); let firstRender = true; for (const mesh of renderedMeshes) { const vertex = `geometry-${mesh.geometryId}-positions`; addBuffer({ id: vertex, array: `geometry.${mesh.geometryId}.positions`, usage: ["vertex"] }); const indexed = mesh.indexCount > 0; if (indexed) addBuffer({ id: `geometry-${mesh.geometryId}-indices`, array: `geometry.${mesh.geometryId}.indices`, usage: ["index"] }); 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}`; if (mesh.id > 65535 || (draw.material ?? 0) > 65534) throw new RangeError("Scene handle limit"); const instance = (mesh.id + (draw.material === undefined ? 0 : (draw.material + 1) * 65536)) >>> 0; passes.push({ id, type: "render", pipeline: "forward-pbr", after: previous, bindings: ["clusters", "accent", "node-positions", "node-quaternions", "node-scales", "mesh-info", "materials", "cameras"] .map((resource, binding) => ({ group: 0, binding, resource })), color: [{ resource: "hdr", ...(firstRender ? { clear: [0.015, 0.025, 0.05, 1] } : { load: "load" }) }], vertexBuffers: [{ slot: 0, resource: vertex }], ...(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 }, }); firstRender = false; previous = [id]; } } } for (const material of this.#shaders.values()) { const id = `shader-${material.id}`; renderPipelines.push({ id, code: material.code, vertex: { entry: material.vertexEntry }, fragment: { entry: material.fragmentEntry, targets: [{ format: hdrFormat }] }, }); passes.push({ id, type: "render", pipeline: id, after: previous, color: [{ resource: "hdr", load: "load" }], draw: { vertices: 3 } }); previous = [id]; } let input = "hdr"; for (const [index, effect] of [...this.#effects.values()].entries()) { const output = `post-${index}`; const pipeline = `post-${effect.id}`; addTexture({ id: output, format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: true }); renderPipelines.push({ id: pipeline, code: effectFragment(effect.kind, effect.options), vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] } }); passes.push({ id: pipeline, type: "render", pipeline, after: previous, bindings: [{ group: 0, binding: 0, resource: input }, { group: 0, binding: 1, resource: "linear" }], color: [{ resource: output, clear: [0, 0, 0, 1] }], draw: { vertices: 3 }, }); input = output; previous = [pipeline]; } for (const { number, source: _, ...texture } of this.#textures.values()) { addTexture(texture); const id = `retain-texture-${number}`; computePipelines.push({ id, code: "@group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var output: array; @compute @workgroup_size(1) fn main() { output[1] = textureDimensions(source).x; }", entry: "main", }); passes.push({ id, type: "compute", pipeline: id, after: ["cluster-lights"], dispatch: [1, 1, 1], bindings: [{ group: 0, binding: 0, resource: texture.id }, { group: 0, binding: 1, resource: "clusters" }], }); } const toneMap = String([...this.#effects.values()].find((effect) => effect.kind === "colorGrading")?.options.toneMap ?? "aces"); renderPipelines.push({ id: "present", code: presentShader(toneMap), vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: "canvas" }] } }); passes.push({ id: "present", type: "render", pipeline: "present", after: previous, bindings: [{ group: 0, binding: 0, resource: input }, { group: 0, binding: 1, resource: "linear" }], color: [{ resource: "canvas", clear: [0, 0, 0, 1] }], draw: { vertices: 3 }, }); const graph = { id: "scene", resources: { buffers: [...buffers.values()], textures: [...textures.values()], samplers: [...samplers.values()].map(({ id, ...descriptor }) => ({ id, descriptor })), }, pipelines: { render: renderPipelines, compute: computePipelines }, passes, }; const id = await this.core.compileGraph(serialize(graph)); await this.core.switchLoadout(id); } dispose() { this.core.dispose(); } }