UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

626 lines (491 loc) 18.4 kB
import { assert } from "../../../../core/assert.js"; import { float_to_uint8 } from "../../../../core/binary/float_to_uint8.js"; import LineBuilder from "../../../../core/codegen/LineBuilder.js"; import { rgb2hex } from "../../../../core/color/hex/rgb2hex.js"; import { okhsv_to_linear_srgb } from "../../../../core/color/oklab/okhsv_to_linear_srgb.js"; import { graphviz_escape_string } from "../../../../core/graph/format/graphviz/graphviz_escape_string.js"; import { lerp } from "../../../../core/math/lerp.js"; import { computeStringHash } from "../../../../core/primitives/strings/computeStringHash.js"; import { RenderPassBuilder } from "./RenderPassBuilder.js"; import { RenderPassNode } from "./RenderPassNode.js"; import { RenderPassResources } from "./RenderPassResources.js"; import { ResourceEntry } from "./ResourceEntry.js"; import { ResourceNode } from "./ResourceNode.js"; /** * Based on the Frostbite's GDC paper "FrameGraph: Extensible Rendering Architecture in Frostbite" by Yuriy O'Donnell * @example * const graph = new RenderGraph("My Graph"); * * const pass_data = {}; * const pass = graph.add("GBuffer Pass", pass_data, (data, resources, context) => { * * context.beginRenderPass({ * depth_attachment: resources.get(data.depth), * color_attachments:[ * resources.get(data.normal), * resources.get(data.albedo), * ] * }); * * for (const renderable in renderables){ * drawMesh(context, renderable.mesh, renderable.material); * } * * context.endRenderPass(); * * }); * * pass_data.depth = pass.create("depth", {...}); * pass_data.albedo = pass.create("albedo", {...}); * pass_data.normal = pass.create("normal", {...}); * * graph.compile(); * graph.execute(context); * */ export class RenderGraph { /** * Human-readable name, used for debugging and UI primarily. * There is no uniqueness guarantee * @type {string} */ name = ""; /** * * @type {RenderPassNode[]} * @private */ __pass_nodes = []; /** * * @type {ResourceNode[]} * @private */ __resource_nodes = []; /** * * @type {ResourceEntry[]} * @private */ __resource_registry = []; /** * * @param {string} name */ constructor(name = "") { assert.isString(name, 'name'); this.name = name; } /** * * @param {number} id Resource Node ID * @returns {ResourceEntry} */ getResourceEntry(id) { const node = this.getResourceNode(id); const registry = this.__resource_registry; const entry = registry[node.resource_id]; assert.defined(entry, 'entry'); return entry; } /** * * @param {number} id * @returns {ResourceNode} */ getResourceNode(id) { assert.isNonNegativeInteger(id, 'id'); const nodes = this.__resource_nodes; const node_count = nodes.length; for (let i = 0; i < node_count; i++) { const node = nodes[i]; if (node.id === id) { return node; } } throw new Error(`Resource Node ${id} not found`); } /** * @template T * @param {number} id resource ID * @returns {T} */ getDescriptor(id) { return this.getResourceEntry(id).resource_descriptor; } /** * @template T * @param {string} name * @param {T} descriptor * @returns {number} */ create_resource(name, descriptor) { assert.isString(name, 'name'); assert.defined(descriptor, 'descriptor'); assert.notNull(descriptor, 'descriptor'); assert.isObject(descriptor, 'descriptor'); const resource = this._createResourceEntry(descriptor); return this._createResourceNode(name, resource.resource_id).id; } /** * @template T * @param {T} descriptor * @return {ResourceEntry} * @private */ _createResourceEntry(descriptor) { assert.defined(descriptor, 'descriptor'); assert.notNull(descriptor, 'descriptor'); const entry = new ResourceEntry(); entry.resource_id = this.__resource_registry.length; entry.resource_descriptor = descriptor; this.__resource_registry.push(entry); return entry; } /** * * @param {string} name * @param {number} resource_id * @return {ResourceNode} * @private */ _createResourceNode(name, resource_id) { assert.isString(name, 'name'); assert.isNonNegativeInteger(resource_id, 'resource_id'); const n = new ResourceNode(); const id = this.__resource_nodes.length; n.id = id; n.name = name; n.resource_id = resource_id; this.__resource_nodes[id] = n; return n; } /** * * @param {number} node_id * @returns {number} ID of the cloned resource node */ clone_resource(node_id) { const node = this.getResourceNode(node_id); const entry = this.__resource_registry[node.resource_id]; entry.resource_version++; const clone_node = new ResourceNode(); clone_node.id = this.__resource_nodes.length; clone_node.name = node.name; clone_node.resource_id = node.resource_id; clone_node.version = entry.resource_version; this.__resource_nodes.push(clone_node); return clone_node.id; } /** * @template T * @param {string} name * @param {ResourceDescriptor<T>} description * @param {T} resource * @returns {number} */ import_resource(name, description, resource) { const record = this._createResourceEntry(description); record.resource = resource; record.imported = true; return this._createResourceNode(name, record.resource_id).id; } /** * @returns {boolean} * @param id */ is_valid_resource(id) { const node = this.getResourceNode(id); const record = this.getResourceEntry(id); return node.version === record.resource_version; } /** * Add a new pass to the graph. * @template T * @param {string} name * @param {T} data * @param {function(data:T, resources: RenderPassResources, context:IRenderContext):void} execute * @returns {RenderPassBuilder} */ add(name, data, execute) { assert.isString(name, 'name'); assert.defined(data, 'data'); assert.notNull(data, 'data'); assert.isObject(data, 'data'); assert.isFunction(execute, 'execute'); const pass_nodes = this.__pass_nodes; const builder = new RenderPassBuilder(); const node = new RenderPassNode(); node.id = pass_nodes.length; node.name = name; node.execute = execute; pass_nodes.push(node); builder.init(this, node); node.data = data; return builder; } /** * Perform validation, useful for debugging * Typically done before compilation * @param {function(problem:string):*} problem_consumer * @param {*} [problem_consumer_context] thisArg for `problem_consumer` * @returns {boolean} */ validate(problem_consumer, problem_consumer_context) { // TODO implement // TODO check for "read before write" return true; } compile() { const pass_nodes = this.__pass_nodes; // wire resources of passes const pass_node_count = pass_nodes.length; const resource_nodes = this.__resource_nodes; for (let i = 0; i < pass_node_count; i++) { const pass = pass_nodes[i]; // mask pass as having number of references equal to number of resources it writes to pass.ref_count = pass.resource_writes.length; for (const id of pass.resource_reads) { const read_node = resource_nodes[id]; read_node.ref_count++; } for (const id of pass.resource_writes) { const written_node = resource_nodes[id]; written_node.producer = pass; } } // perform culling /** * * @type {ResourceNode[]} */ const unreferenced_resources = []; for (const node of resource_nodes) { if (node.ref_count === 0) { unreferenced_resources.push(node); } } while (unreferenced_resources.length > 0) { const unreferenced_resource = unreferenced_resources.pop(); const producer = unreferenced_resource.producer; if (producer === null || producer.has_side_effects) { continue; } assert.greaterThanOrEqual(producer.ref_count, 1, 'must have at least one reference'); // Why? // disconnect unreferenced resource from producer producer.ref_count--; if (producer.ref_count === 0) { // producer is no longer referenced for (const id of producer.resource_reads) { const node = resource_nodes[id]; // disconnect producer from all its reads (upstream) node.ref_count--; if (node.ref_count === 0) { // resource is no longer referenced unreferenced_resources.push(node); } } } } // calculate lifetimes for (let i = 0; i < pass_node_count; i++) { const pass = pass_nodes[i]; if (pass.ref_count === 0) { // unused pass continue; } for (const id of pass.resource_creates) { const entry = this.getResourceEntry(id); entry.producer = pass; entry.last = pass; } for (const id of pass.resource_writes) { this.getResourceEntry(id).last = pass; } for (const id of pass.resource_reads) { this.getResourceEntry(id).last = pass; } } } /** * * @param {IRenderContext} context */ execute(context) { assert.defined(context, 'context'); assert.notNull(context, 'context'); assert.isObject(context, 'context'); const pass_nodes = this.__pass_nodes; const pass_node_count = pass_nodes.length; for (let i = 0; i < pass_node_count; i++) { const node = pass_nodes[i]; if (!node.can_execute()) { continue; } const node_created_resources = node.resource_creates; for (const id of node_created_resources) { // allocate resource this.getResourceEntry(id).create(context.resource_manager); } const resources = new RenderPassResources(); resources.init(this, node); // execute pass node.execute(node.data, resources, context); const resource_entries = this.__resource_registry; const resource_entry_count = resource_entries.length; for (let entry_index = 0; entry_index < resource_entry_count; entry_index++) { const entry = resource_entries[entry_index]; if (entry.last === node && entry.isTransient()) { // this was the last user of the resource and the resource is transient (no external usage) entry.destroy(context.resource_manager); } } } } /** * Should only call after {@link compile} * * Can be visualized with this tool: https://skaarj1989.github.io/FrameGraph/ * * @see https://github.com/skaarj1989/FrameGraph/blob/viewer/JsonWriter.hpp * @return {{passes: [], resources: []}} */ exportToJson() { const resources = []; this.__resource_registry.forEach((entry, entry_id) => { const node = this.getResourceNode(entry.resource_id); const j = { id: entry_id, name: node.name, transient: entry.isTransient(), }; const description = entry.resource_descriptor?.toString(); if (description) { j.description = description; } if (entry.producer !== null) { j.createdBy = entry.producer.id; } const readers = []; const writers = []; this.__pass_nodes.forEach(pass => { if (pass.reads(entry.resource_id)) { readers.push(pass.id); } if (pass.writes(entry.resource_id)) { writers.push(pass.id); } }) if (readers.length > 0) { j.readers = readers; } if (writers.length > 0) { j.writers = writers; } resources[entry_id] = j; }) return { passes: this.__pass_nodes.map(node => { return { id: node.id, name: node.name, culled: !node.can_execute(), reads: node.resource_reads, writes: node.resource_writes, } }), resources } } /** * Export the graph diagram in GraphViz DOT format. * Useful for debugging. * @see https://en.wikipedia.org/wiki/DOT_(graph_description_language) * @return {string} */ exportToDot() { const out = new LineBuilder(); out.add("digraph FrameGraph {"); out.indent(); out.add("graph [style=invis, rankdir=\"TB\" ordering=out, splines=spline]"); out.add("node [shape=record, fontname=\"helvetica\", fontsize=10, margin=\"0.2,0.03\"]"); // -- Define pass nodes out.add(""); out.add("# Pass Nodes"); for (const node of this.__pass_nodes) { out.add(`P${node.id} [label=<{ {<B>${node.name}</B>} | {${(node.has_side_effects ? "&#x2605; " : "")} Refs: ${node.ref_count}<BR/> Index: ${node.id}} }> style=\"rounded,filled\", fillcolor=${(node.ref_count > 0 || node.has_side_effects) ? "orange" : "lightgray"}]`); } // -- Define resource nodes /** * * @param {string} type * @returns {number} */ function type_to_color(type) { const hash_32 = computeStringHash(type); const hash_16 = (hash_32 & 0xffff) ^ (hash_32 >>> 16); return hash_16 / 0xffff; } /** * * @param {ResourceEntry} entry * @return {string} */ function entry_to_background_color(entry) { let color_hue= type_to_color(entry.resource_descriptor?.type ?? ""); // remap hue away from red color_hue = lerp(0.10, 0.90, color_hue); const saturation = entry.isImported() ? 0.2 : 0.07; const color_rgb = []; okhsv_to_linear_srgb(color_rgb, color_hue, saturation, 1) const hex = rgb2hex(...color_rgb.map(float_to_uint8)); return `#${hex}`; } out.add(""); out.add("# Resource Nodes"); for (const node of this.__resource_nodes) { const entry = this.__resource_registry[node.resource_id]; const type = graphviz_escape_string(entry.resource_descriptor?.type ?? ""); const block_title = `${entry.isImported() ? "↪ " : ""}${node.name}`; const block_tooltip = graphviz_escape_string(entry.toString()); out.add(`R${entry.resource_id}_${node.version} [label=<{ {<B>${block_title}</B>${node.version > 0 ? ` <FONT>v${(node.version+1)}</FONT>` : ""}<BR/>${type}} | {Index: ${entry.resource_id}<BR/> Refs : ${node.ref_count} } }> style=filled, fillcolor="${entry_to_background_color(entry)}" tooltip="${block_tooltip}"]`); } // -- Each pass node points to the resource that it writes out.add(""); out.add("# Resource Writes"); for (const node of this.__pass_nodes) { out.add(`P${node.id} -> {`); out.indent(); for (const id of node.resource_writes) { const written = this.__resource_nodes[id]; out.add(`R${written.resource_id}_${written.version} `); } out.dedent(); out.add("} [color=orangered]"); } // -- Each resource node points to the pass where it's consumed out.add(""); out.add("# Resource Reads"); for (const node of this.__resource_nodes) { out.add(`R${node.resource_id}_${node.version} -> {`); out.indent(); // find all readers of this resource node for (const pass of this.__pass_nodes) { for (const id of pass.resource_reads) if (id === node.id) { out.add(`P${pass.id} `); } } out.dedent(); out.add("} [color=olivedrab3]"); } out.dedent(); out.add("}"); return out.build(); } } /** * @readonly * @type {boolean} */ RenderGraph.prototype.isRenderGraph = true;