@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
626 lines (491 loc) • 18.4 kB
JavaScript
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 ? "★ " : "")} 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;