397 lines
11 KiB
Plaintext
397 lines
11 KiB
Plaintext
// fx_graph.cm - Low-level compositing graph primitives
|
|
//
|
|
// This module provides the low-level pass primitives for compositing.
|
|
// It does NOT know about scene trees, sprites, tilemaps, etc. - that's renderer territory.
|
|
//
|
|
// Core node types:
|
|
//
|
|
// render_view
|
|
// Render a scene using a renderer (film2d, etc.) into a target.
|
|
// Params:
|
|
// root - Scene tree root node
|
|
// camera - Camera object
|
|
// target - Target spec: {width, height} or 'screen' or existing target
|
|
// clear_color - Optional RGBA clear color
|
|
// renderer - Renderer module to use (defaults to film2d)
|
|
// 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}
|
|
//
|
|
// 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}
|
|
//
|
|
// shader_pass
|
|
// Apply a shader effect (blur, threshold, crt, etc.)
|
|
// Params:
|
|
// shader - Shader name ('blur', 'threshold', 'crt')
|
|
// input - Input texture/target
|
|
// output - Output target spec
|
|
// uniforms - Shader uniforms
|
|
// Output: {target, commands}
|
|
//
|
|
// present
|
|
// Present to display.
|
|
// Params:
|
|
// input - Final image to present
|
|
// Output: {commands}
|
|
|
|
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'}
|
|
}
|
|
push(this.nodes, 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 = []
|
|
|
|
arrfor(sorted, function(node) {
|
|
var executor = NODE_EXECUTORS[node.type]
|
|
if (!executor) {
|
|
log.console(`fx_graph: No executor for node type: ${node.type}`)
|
|
return
|
|
}
|
|
|
|
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) {
|
|
all_commands = array(all_commands, result.commands)
|
|
}
|
|
})
|
|
|
|
return {commands: all_commands}
|
|
}
|
|
|
|
fx_graph.resolve_inputs = function(params, node_outputs) {
|
|
var resolved = {}
|
|
arrfor(array(params), key => {
|
|
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 using a renderer
|
|
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
|
|
var renderer = params.renderer
|
|
|
|
// Determine target
|
|
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
|
|
)
|
|
}
|
|
|
|
// Get target size
|
|
var target_size = target == 'screen'
|
|
? backend.get_window_size()
|
|
: {width: target.width, height: target.height}
|
|
|
|
// Use renderer if provided, otherwise use default film2d
|
|
if (renderer && renderer.render) {
|
|
return renderer.render({
|
|
root: root,
|
|
camera: camera,
|
|
target: target,
|
|
target_size: target_size,
|
|
clear: clear_color
|
|
}, backend)
|
|
}
|
|
|
|
// Fallback: use film2d module
|
|
var film2d = use('prosperon/film2d')
|
|
return film2d.render({
|
|
root: root,
|
|
camera: camera,
|
|
target: target,
|
|
target_size: target_size,
|
|
clear: clear_color
|
|
}, backend)
|
|
}
|
|
|
|
// 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 = []
|
|
push(commands, {
|
|
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 = []
|
|
push(commands, {
|
|
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 ? array(input.commands) : []
|
|
|
|
// Insert scissor after begin_render
|
|
var insert_idx = 0
|
|
for (var i = 0; i < length(commands); i++) {
|
|
if (commands[i].cmd == 'begin_render') {
|
|
insert_idx = i + 1
|
|
break
|
|
}
|
|
}
|
|
|
|
commands = array(array(array(commands, 0, insert_idx), [{cmd: 'scissor', rect: rect}]), array(commands, insert_idx))
|
|
|
|
// Add scissor reset before end_render
|
|
for (var i = length(commands) - 1; i >= 0; i--) {
|
|
if (commands[i].cmd == 'end_render') {
|
|
commands = array(array(array(commands, 0, i), [{cmd: 'scissor', rect:null}]) ,array(commands, i+1))
|
|
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 = []
|
|
push(commands, {
|
|
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 = []
|
|
push(commands, {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 = []
|
|
push(commands, {
|
|
cmd: 'shader_pass',
|
|
shader: shader,
|
|
input: src,
|
|
output: target,
|
|
uniforms: uniforms
|
|
})
|
|
|
|
return {target: target, commands: commands}
|
|
}
|
|
|
|
// ========================================================================
|
|
// 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
|