// 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'} } 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 = [] 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 = {} 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 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 = [] 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 ? 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.splice(insert_idx, 0, {cmd: 'scissor', rect: rect}) // Add scissor reset before end_render for (var i = length(commands) - 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} } // ======================================================================== // 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