Files
prosperon/fx_graph.cm
2025-12-24 11:44:15 -06:00

826 lines
24 KiB
Plaintext

// fx_graph.cm - Compositing graph for rendering
//
// Core node types (minimal, generic primitives):
//
// render_view
// Render a scene root with a camera into a target.
// Params:
// root - Scene tree root node (or array of drawables)
// camera - Camera object with pos, width, height, anchor, etc.
// target - Target spec: {width, height} or 'screen' or existing target
// clear_color - Optional RGBA clear color, null = no clear
// Output: {target, commands}
//
// composite
// Combine two inputs with a blend mode.
// Params:
// base - Base layer (output from another node)
// overlay - Overlay layer (output from another node)
// mode - 'over' (default), 'add', 'multiply'
// opacity - 0-1, overlay opacity
// Output: {target, commands}
//
// mask
// Apply a mask to content.
// Params:
// content - Content to mask (output from another node)
// mask - Mask source (output from another node)
// mode - 'binary' | 'alpha' (default 'alpha')
// invert - bool, invert mask
// Output: {target, commands}
//
// clip_rect
// Clip/scissor to a rectangle.
// Params:
// input - Input to clip
// rect - {x, y, width, height} in target coords
// Output: {target, commands} (same target, adds scissor command)
//
// blit
// Copy/scale an image into a target.
// Params:
// input - Source (output from another node or texture)
// target - Destination target spec or 'screen'
// dst_rect - {x, y, width, height} destination rectangle
// filter - 'nearest' | 'linear'
// Output: {target, commands}
//
// present
// Present a chosen image to the display.
// Params:
// input - Final image to present
// Output: {commands} (no target, just present command)
//
// Optimization notes:
// - Nodes track whether they need an offscreen target or can render directly
// - render_view to 'screen' skips intermediate target
// - Sequential composites can be merged when possible
// - mask uses stencil when available, falls back to RT+sample
var fx_graph = {}
fx_graph.add_node = function(type, params) {
params = params || {}
var node = {
id: this.next_id++,
type: type,
params: params,
output: {node_id: this.next_id - 1, slot: 'output'}
}
this.nodes.push(node)
return node
}
fx_graph.set_output = function(output_handle) {
this.output_node = output_handle
}
// Execute graph using backend
fx_graph.execute = function(backend) {
var sorted = this.topological_sort()
var node_outputs = {}
var all_commands = []
for (var node of sorted) {
var executor = NODE_EXECUTORS[node.type]
if (!executor) {
log.console(`fx_graph: No executor for node type: ${node.type}`)
continue
}
var resolved_params = this.resolve_inputs(node.params, node_outputs)
resolved_params._node_id = node.id
var result = executor(resolved_params, backend)
node_outputs[node.id] = result
// Collect commands from this node
if (result && result.commands) {
for (var cmd of result.commands) {
all_commands.push(cmd)
}
}
}
return {commands: all_commands}
}
fx_graph.resolve_inputs = function(params, node_outputs) {
var resolved = {}
for (var key in params) {
var value = params[key]
if (value && value.node_id != null)
resolved[key] = node_outputs[value.node_id]
else
resolved[key] = value
}
return resolved
}
fx_graph.topological_sort = function() {
// Nodes are added in dependency order by construction - you can't reference
// a node's output before the node is created. So insertion order is already
// a valid topological order.
return this.nodes
}
// ========================================================================
// NODE EXECUTORS
// ========================================================================
var NODE_EXECUTORS = {}
// render_view: Render scene tree to target
NODE_EXECUTORS.render_view = function(params, backend) {
var root = params.root
var camera = params.camera
var target_spec = params.target
var clear_color = params.clear_color
// Determine if we need an offscreen target
var needs_offscreen = target_spec != 'screen' && params._needs_offscreen != false
var target
if (target_spec == 'screen') {
target = 'screen'
} else if (target_spec && target_spec.texture) {
// Reuse existing target
target = target_spec
} else {
// Allocate render target
target = backend.get_or_create_target(
target_spec.width,
target_spec.height,
'view_' + params._node_id
)
}
// Collect drawables from scene tree
var drawables = collect_drawables(root, camera)
// Sort by layer, then by Y for depth sorting
drawables.sort((a, b) => {
return 0
if (a.layer != b.layer) return a.layer - b.layer
return b.world_y - a.world_y
})
// Build render commands
var commands = []
commands.push({cmd: 'begin_render', target: target, clear: clear_color})
commands.push({cmd: 'set_camera', camera: camera})
// Batch and emit draw commands
var batches = batch_drawables(drawables)
var current_scissor = null
for (var batch of batches) {
// Emit scissor command if changed
if (!rect_equal(current_scissor, batch.scissor)) {
commands.push({cmd: 'scissor', rect: batch.scissor})
current_scissor = batch.scissor
}
if (batch.type == 'sprite_batch') {
commands.push({
cmd: 'draw_batch',
batch_type: 'sprites',
geometry: {sprites: batch.sprites},
texture: batch.texture,
material: batch.material
})
} else if (batch.type == 'text') {
commands.push({
cmd: 'draw_text',
drawable: batch.drawable
})
} else if (batch.type == 'rect') {
commands.push({
cmd: 'draw_rect',
drawable: batch.drawable
})
} else if (batch.type == 'particles') {
commands.push({
cmd: 'draw_batch',
batch_type: 'particles',
geometry: {sprites: batch.sprites},
texture: batch.texture,
material: batch.material
})
}
}
commands.push({cmd: 'end_render'})
return {target: target, commands: commands}
}
// composite: Combine two layers
NODE_EXECUTORS.composite = function(params, backend) {
var base = params.base
var overlay = params.overlay
var mode = params.mode || 'over'
var opacity = params.opacity != null ? params.opacity : 1
// Optimization: if overlay opacity is 0, just return base
if (opacity == 0) return base
// Optimization: if base is null/empty, just return overlay
if (!base || !base.target) return overlay
// Optimization: if overlay is null/empty, just return base
if (!overlay || !overlay.target) return base
var target = backend.get_or_create_target(
base.target.width,
base.target.height,
'composite_' + params._node_id
)
// Emit composite_textures command (handled outside render pass)
var commands = []
commands.push({
cmd: 'composite_textures',
base: base.target,
overlay: overlay.target,
output: target,
mode: mode,
opacity: opacity
})
return {target: target, commands: commands}
}
// mask: Apply mask to content
NODE_EXECUTORS.mask = function(params, backend) {
var content = params.content
var mask = params.mask
var mode = params.mode || 'alpha'
var invert = params.invert || false
if (!content || !content.target) return {target: null, commands: []}
if (!mask || !mask.target) return content
var target = backend.get_or_create_target(
content.target.width,
content.target.height,
'mask_' + params._node_id
)
// Emit apply_mask command (handled via shader pass outside render pass)
var commands = []
commands.push({
cmd: 'apply_mask',
content_texture: content.target,
mask_texture: mask.target,
output: target,
mode: mode,
invert: invert
})
return {target: target, commands: commands}
}
// clip_rect: Apply scissor clipping
NODE_EXECUTORS.clip_rect = function(params, backend) {
var input = params.input
var rect = params.rect
if (!input) return {target: null, commands: []}
// Clip doesn't need a new target, just adds scissor to commands
var commands = input.commands ? input.commands.slice() : []
// Insert scissor after begin_render
var insert_idx = 0
for (var i = 0; i < commands.length; i++) {
if (commands[i].cmd == 'begin_render') {
insert_idx = i + 1
break
}
}
commands.splice(insert_idx, 0, {cmd: 'scissor', rect: rect})
// Add scissor reset before end_render
for (var i = commands.length - 1; i >= 0; i--) {
if (commands[i].cmd == 'end_render') {
commands.splice(i, 0, {cmd: 'scissor', rect: null})
break
}
}
return {target: input.target, commands: commands}
}
// blit: Copy/scale image to target
NODE_EXECUTORS.blit = function(params, backend) {
var input = params.input
var target_spec = params.target
var dst_rect = params.dst_rect
var filter = params.filter || 'nearest'
var src_target = input && input.target ? input.target : input
if (!src_target) return {target: null, commands: []}
var target
if (target_spec == 'screen') {
target = 'screen'
} else if (target_spec && target_spec.target) {
// Output reference from another node - use its target
target = target_spec.target
} else if (target_spec && target_spec.texture) {
// Already a render target
target = target_spec
} else if (target_spec && target_spec.width) {
// Target spec - use a consistent key based on the spec itself
var key = `blit_${target_spec.width}x${target_spec.height}`
target = backend.get_or_create_target(target_spec.width, target_spec.height, key)
} else {
return {target: null, commands: []}
}
var commands = []
commands.push({
cmd: 'blit',
texture: src_target,
target: target,
dst_rect: dst_rect,
filter: filter
})
return {target: target, commands: commands}
}
// present: Present to display
NODE_EXECUTORS.present = function(params, backend) {
var input = params.input
var commands = []
commands.push({cmd: 'present'})
return {commands: commands}
}
// shader_pass: Generic shader pass
NODE_EXECUTORS.shader_pass = function(params, backend) {
var input = params.input
var shader = params.shader
var uniforms = params.uniforms || {}
var output_spec = params.output
if (!input || !input.target) return {target: null, commands: []}
var src = input.target
var target
if (output_spec == 'screen') {
target = 'screen'
} else if (output_spec && output_spec.texture) {
target = output_spec
} else {
// Default to input size if not specified
var w = output_spec && output_spec.width ? output_spec.width : src.width
var h = output_spec && output_spec.height ? output_spec.height : src.height
target = backend.get_or_create_target(w, h, 'shader_' + shader + '_' + params._node_id)
}
var commands = []
commands.push({
cmd: 'shader_pass',
shader: shader,
input: src,
output: target,
uniforms: uniforms
})
return {target: target, commands: commands}
}
// ========================================================================
// SCENE TREE TRAVERSAL
// ========================================================================
function collect_drawables(node, camera, parent_tint, parent_opacity, parent_scissor, parent_pos) {
if (!node) return []
parent_tint = parent_tint || [1, 1, 1, 1]
parent_opacity = parent_opacity != null ? parent_opacity : 1
var drawables = []
// Compute absolute position
parent_pos = parent_pos || {x: 0, y: 0}
var node_pos = node.pos || {x: 0, y: 0}
var abs_x = parent_pos.x + (node_pos.x != null ? node_pos.x : (node_pos[0] || 0))
var abs_y = parent_pos.y + (node_pos.y != null ? node_pos.y : (node_pos[1] || 0))
// For recursive calls, use this node's absolute pos as parent pos
var current_pos = {x: abs_x, y: abs_y}
// Compute inherited tint/opacity
var node_tint = node.tint || node.color
var world_tint = [
parent_tint[0] * (node_tint ? (node_tint.r != null ? node_tint.r : node_tint[0] || 1) : 1),
parent_tint[1] * (node_tint ? (node_tint.g != null ? node_tint.g : node_tint[1] || 1) : 1),
parent_tint[2] * (node_tint ? (node_tint.b != null ? node_tint.b : node_tint[2] || 1) : 1),
parent_tint[3] * (node_tint ? (node_tint.a != null ? node_tint.a : node_tint[3] || 1) : 1)
]
var world_opacity = parent_opacity * (node.opacity != null ? node.opacity : 1)
// Compute effective scissor
var current_scissor = parent_scissor
if (node.scissor) {
if (parent_scissor) {
// Intersect parent and node scissor
var x1 = Math.max(parent_scissor.x, node.scissor.x)
var y1 = Math.max(parent_scissor.y, node.scissor.y)
var x2 = Math.min(parent_scissor.x + parent_scissor.width, node.scissor.x + node.scissor.width)
var y2 = Math.min(parent_scissor.y + parent_scissor.height, node.scissor.y + node.scissor.height)
current_scissor = {x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1)}
} else {
current_scissor = node.scissor
}
}
// Handle different node types
if (node.type == 'sprite' || (node.image && !node.type)) {
if (node.slice && node.tile) {
throw Error('Sprite cannot have both "slice" and "tile" parameters.')
}
var px = abs_x
var py = abs_y
var w = node.width || 1
var h = node.height || 1
var ax = node.anchor_x || 0
var ay = node.anchor_y || 0
var tint = tint_to_color(world_tint, world_opacity)
// Helper to add a sprite drawable
function add_sprite_drawable(rect, uv) {
drawables.push({
type: 'sprite',
layer: node.layer || 0,
world_y: py,
pos: {x: rect.x, y: rect.y},
image: node.image,
texture: node.texture,
width: rect.width,
height: rect.height,
anchor_x: 0,
anchor_y: 0,
uv_rect: uv,
color: tint,
material: node.material,
scissor: current_scissor
})
}
// Helper to emit tiled area
function emit_tiled(rect, uv, tile_size) {
var tx = tile_size ? (tile_size.x || tile_size) : rect.width
var ty = tile_size ? (tile_size.y || tile_size) : rect.height
var nx = number.ceiling(rect.width / tx - 0.00001)
var ny = number.ceiling(rect.height / ty - 0.00001)
for (var ix = 0; ix < nx; ix++) {
for (var iy = 0; iy < ny; iy++) {
var qw = number.min(tx, rect.width - ix * tx)
var qh = number.min(ty, rect.height - iy * ty)
var quv = {
x: uv.x,
y: uv.y,
width: uv.width * (qw / tx),
height: uv.height * (qh / ty)
}
add_sprite_drawable({x: rect.x + ix * tx, y: rect.y + iy * ty, width: qw, height: qh}, quv)
}
}
}
// Top-left of whole sprite
var x0 = px - w * ax
var y0 = py - h * ay
if (node.slice) {
// 9-Slice logic
var s = node.slice
var L = s.left != null ? s.left : (typeof s == 'number' ? s : 0)
var R = s.right != null ? s.right : (typeof s == 'number' ? s : 0)
var T = s.top != null ? s.top : (typeof s == 'number' ? s : 0)
var B = s.bottom != null ? s.bottom : (typeof s == 'number' ? s : 0)
var stretch = s.stretch != null ? s.stretch : node.stretch
var Sx = stretch != null ? (stretch.x || stretch) : w
var Sy = stretch != null ? (stretch.y || stretch) : h
// World sizes of borders
var WL = L * Sx
var WR = R * Sx
var HT = T * Sy
var HB = B * Sy
// Middle areas
var WM = w - WL - WR
var HM = h - HT - HB
// UV mid dimensions
var UM = 1 - L - R
var VM = 1 - T - B
// Natural tile sizes for middle parts
var TW = stretch != null ? UM * Sx : WM
var TH = stretch != null ? VM * Sy : HM
// TL
add_sprite_drawable({x: x0, y: y0, width: WL, height: HT}, {x:0, y:0, width: L, height: T})
// TM
emit_tiled({x: x0 + WL, y: y0, width: WM, height: HT}, {x:L, y:0, width: UM, height: T}, {x: TW, y: HT})
// TR
add_sprite_drawable({x: x0 + WL + WM, y: y0, width: WR, height: HT}, {x: 1-R, y:0, width: R, height: T})
// ML
emit_tiled({x: x0, y: y0 + HT, width: WL, height: HM}, {x:0, y:T, width: L, height: VM}, {x: WL, y: TH})
// MM
emit_tiled({x: x0 + WL, y: y0 + HT, width: WM, height: HM}, {x:L, y:T, width: UM, height: VM}, {x: TW, y: TH})
// MR
emit_tiled({x: x0 + WL + WM, y: y0 + HT, width: WR, height: HM}, {x: 1-R, y:T, width: R, height: VM}, {x: WR, y: TH})
// BL
add_sprite_drawable({x: x0, y: y0 + HT + HM, width: WL, height: HB}, {x:0, y: 1-B, width: L, height: B})
// BM
emit_tiled({x: x0 + WL, y: y0 + HT + HM, width: WM, height: HB}, {x:L, y: 1-B, width: UM, height: B}, {x: TW, y: HB})
// BR
add_sprite_drawable({x: x0 + WL + WM, y: y0 + HT + HM, width: WR, height: HB}, {x: 1-R, y: 1-B, width: R, height: B})
} else if (node.tile) {
// Full sprite tiling
emit_tiled({x: x0, y: y0, width: w, height: h}, {x:0, y:0, width: 1, height: 1}, node.tile)
} else {
// Normal sprite
drawables.push({
type: 'sprite',
layer: node.layer || 0,
world_y: py,
pos: {x: abs_x, y: abs_y},
image: node.image,
texture: node.texture,
width: w,
height: h,
anchor_x: ax,
anchor_y: ay,
color: tint,
material: node.material
})
}
}
if (node.type == 'text') {
drawables.push({
type: 'text',
layer: node.layer || 0,
world_y: abs_y,
pos: {x: abs_x, y: abs_y},
text: node.text,
font: node.font,
size: node.size,
mode: node.mode, // 'bitmap', 'sdf', or 'msdf'
sdf: node.sdf, // legacy support
outline_width: node.outline_width,
outline_color: node.outline_color,
anchor_x: node.anchor_x,
anchor_y: node.anchor_y,
color: tint_to_color(world_tint, world_opacity),
scissor: current_scissor
})
}
if (node.type == 'rect') {
drawables.push({
type: 'rect',
layer: node.layer || 0,
world_y: abs_y,
pos: {x: abs_x, y: abs_y},
width: node.width || 1,
height: node.height || 1,
color: tint_to_color(world_tint, world_opacity),
scissor: current_scissor
})
}
if (node.type == 'particles' || node.particles) {
var particles = node.particles || []
for (var p of particles) {
// Particles usually relative to emitter (node) pos
var px = p.pos ? p.pos.x : 0
var py = p.pos ? p.pos.y : 0
drawables.push({
type: 'sprite',
layer: node.layer || 0,
world_y: abs_y + py, // Sort by Y
pos: {x: abs_x + px, y: abs_y + py}, // Add parent/node pos to particle pos
image: node.image,
texture: node.texture,
width: (node.width || 1) * (p.scale || 1),
height: (node.height || 1) * (p.scale || 1),
anchor_x: 0.5,
anchor_y: 0.5,
color: p.color || tint_to_color(world_tint, world_opacity),
material: node.material,
scissor: current_scissor
})
}
}
if (node.type == 'tilemap' || node.tiles) {
// Tilemap emits multiple sprites
var tiles = node.tiles || []
var offset_x = node.offset_x || 0
var offset_y = node.offset_y || 0
var scale_x = node.scale_x || 1
var scale_y = node.scale_y || 1
for (var x = 0; x < tiles.length; x++) {
if (!tiles[x]) continue
for (var y = 0; y < tiles[x].length; y++) {
var tile = tiles[x][y]
if (!tile) continue
// Tile coords are strictly grid based + offset.
// We should add this node's position (abs_x, abs_y) to it
var world_x = abs_x + (x + offset_x) * scale_x
var world_y_pos = abs_y + (y + offset_y) * scale_y
drawables.push({
type: 'sprite',
layer: node.layer || 0,
world_y: world_y_pos,
pos: {x: world_x, y: world_y_pos},
image: tile,
texture: tile,
width: scale_x,
height: scale_y,
anchor_x: 0,
anchor_y: 0,
anchor_y: 0,
color: tint_to_color(world_tint, world_opacity),
material: node.material,
scissor: current_scissor
})
}
}
}
// Recurse children
if (node.children) {
for (var child of node.children) {
var child_drawables = collect_drawables(child, camera, world_tint, world_opacity, current_scissor, current_pos)
drawables = drawables.concat(child_drawables)
}
}
return drawables
}
function tint_to_color(tint, opacity) {
return {
r: tint[0],
g: tint[1],
b: tint[2],
a: tint[3] * opacity
}
}
// ========================================================================
// BATCHING
// ========================================================================
function batch_drawables(drawables) {
var batches = []
var current_batch = null
for (var drawable of drawables) {
if (drawable.type == 'sprite') {
var texture = drawable.texture || drawable.image
var material = drawable.material || {blend: 'alpha', sampler: 'nearest'}
var scissor = drawable.scissor
// Start new batch if texture/material/scissor changed
if (!current_batch ||
current_batch.type != 'sprite_batch' ||
current_batch.texture != texture ||
!rect_equal(current_batch.scissor, scissor) ||
!materials_equal(current_batch.material, material)) {
if (current_batch) batches.push(current_batch)
current_batch = {
type: 'sprite_batch',
texture: texture,
material: material,
scissor: scissor,
sprites: []
}
}
current_batch.sprites.push(drawable)
} else {
// Non-sprite: flush batch, add individually
if (current_batch) {
batches.push(current_batch)
current_batch = null
}
batches.push({type: drawable.type, drawable: drawable, scissor: drawable.scissor})
}
}
if (current_batch) batches.push(current_batch)
return batches
}
function rect_equal(a, b) {
if (!a && !b) return true
if (!a || !b) return false
return a.x == b.x && a.y == b.y && a.width == b.width && a.height == b.height
}
function materials_equal(a, b) {
if (!a || !b) return a == b
return a.blend == b.blend && a.sampler == b.sampler && a.shader == b.shader
}
function sprites_to_geometry(sprites) {
var vertices = []
var indices = []
var vertex_count = 0
for (var s of sprites) {
var px = s.pos.x != null ? s.pos.x : (s.pos[0] || 0)
var py = s.pos.y != null ? s.pos.y : (s.pos[1] || 0)
var w = s.width || 1
var h = s.height || 1
var ax = s.anchor_x || 0
var ay = s.anchor_y || 0
var c = s.color || {r: 1, g: 1, b: 1, a: 1}
// Apply anchor offset
var x = px - w * ax
var y = py - h * ay
// Quad vertices (pos, uv, color)
vertices.push(
{pos: [x, y], uv: [0, 0], color: c},
{pos: [x + w, y], uv: [1, 0], color: c},
{pos: [x + w, y + h], uv: [1, 1], color: c},
{pos: [x, y + h], uv: [0, 1], color: c}
)
// Two triangles
indices.push(
vertex_count, vertex_count + 1, vertex_count + 2,
vertex_count, vertex_count + 2, vertex_count + 3
)
vertex_count += 4
}
return {vertices: vertices, indices: indices}
}
// ========================================================================
// UTILITY: Fit rectangle to screen with aspect preservation
// ========================================================================
function fit_to_screen(target_spec, window_size) {
var src_aspect = target_spec.width / target_spec.height
var dst_aspect = window_size.width / window_size.height
var scale = src_aspect > dst_aspect
? window_size.width / target_spec.width
: window_size.height / target_spec.height
var w = target_spec.width * scale
var h = target_spec.height * scale
var x = (window_size.width - w) / 2
var y = (window_size.height - h) / 2
return {x: x, y: y, width: w, height: h}
}
// Export fit_to_screen for external use
fx_graph.fit_to_screen = fit_to_screen
function make_fxgraph() {
return meme(fx_graph, {
nodes: [],
output_node: null,
next_id: 0
})
}
make_fxgraph.fit_to_screen = fit_to_screen
return make_fxgraph