// 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