This commit is contained in:
2026-01-01 22:01:58 -06:00
parent b61b85c3a8
commit 249b78d141
11 changed files with 2379 additions and 634 deletions

View File

@@ -4,10 +4,12 @@
// It takes scene descriptions and produces abstract render plans.
//
// Architecture:
// Scene Tree (Retained) -> Compositor (Effect orchestration) -> Render Plan -> Backend
// Composition Tree (Structure) -> Compositor -> Render Plan -> Backend
//
// The compositor does NOT know about sprites/tilemaps/text - that's renderer territory.
// It only knows about plates, targets, viewports, effects, and post-processing.
// Changes:
// - No longer iterates scene/node trees.
// - Relies on 'resolve(ctx)' callbacks to get flat drawable lists.
// - Handles groups by filtering drawables and re-injecting texture_refs.
var effects_mod = use('effects')
@@ -42,29 +44,30 @@ compositor.compile = function(comp, renderers, backend) {
target_size: null,
// Target allocation
alloc_target: function(w, h, hint) {
alloc_target: function(width, height, hint) {
var key = (hint || 'target') + '_' + text(this.target_counter++)
this.targets[key] = {w: w, h: h, key: key}
return {type: 'target', key: key, w: w, h: h}
this.targets[key] = {width: width, height: height, key: key}
return {type: 'target', key: key, width: width, height: height}
},
// Persistent target (survives across frames)
get_persistent_target: function(key, width, height) {
if (!this.persistent_targets[key]) {
this.persistent_targets[key] = {width: width, height: height, key: key, persistent: true}
}
return {type: 'target', key: key, width: width, height: height, persistent: true}
},
// Persistent target (survives across frames)
get_persistent_target: function(key, w, h) {
if (!this.persistent_targets[key]) {
this.persistent_targets[key] = {w: w, h: h, key: key, persistent: true}
}
return {type: 'target', key: key, w: w, h: h, persistent: true}
// Helper to resolve drawables from a node (layer/plane)
resolve_drawables: function(node) {
if (node.drawables) return node.drawables
if (node.resolve) return node.resolve(this)
return []
}
}
// Get screen size from backend
ctx.screen_size = backend.get_window_size ? backend.get_window_size() : {width: 1280, height: 720}
if (!ctx.screen_size.width) ctx.screen_size.width = 1280
if (!ctx.screen_size.height) ctx.screen_size.height = 720
// Normalize to w/h
ctx.screen_size.w = ctx.screen_size.width
ctx.screen_size.h = ctx.screen_size.height
// Compile the tree
_compile_node(comp, ctx, null, ctx.screen_size)
@@ -80,24 +83,24 @@ compositor.compile = function(comp, renderers, backend) {
// Compile a single node
function _compile_node(node, ctx, parent_target, parent_size) {
if (!node) return {output: null}
var node_type = node.type
var owns_target = false
var target = parent_target
var target_size = {w: parent_size.w || parent_size.width, h: parent_size.h || parent_size.height}
var target_size = {width: parent_size.width, height: parent_size.height}
// Determine target ownership
if (node.target == 'screen') {
owns_target = true
target = 'screen'
target_size = {w: ctx.screen_size.w, h: ctx.screen_size.h}
target_size = {width: ctx.screen_size.width, height: ctx.screen_size.height}
} else if (node.resolution) {
owns_target = true
target_size = {w: node.resolution.w || node.resolution.width, h: node.resolution.h || node.resolution.height}
target = ctx.alloc_target(target_size.w, target_size.h, node.name || 'res_target')
target_size = {width: node.resolution.width, height: node.resolution.height}
target = ctx.alloc_target(target_size.width, target_size.height, node.name || 'res_target')
} else if (node.effects && _effects_require_target(node.effects, ctx)) {
owns_target = true
target = ctx.alloc_target(target_size.w, target_size.h, node.name || 'effect_target')
target = ctx.alloc_target(target_size.width, target_size.height, node.name || 'effect_target')
}
ctx.target_size = target_size
@@ -115,7 +118,7 @@ function _compile_node(node, ctx, parent_target, parent_size) {
if (node_type == 'composition') {
return _compile_composition(node, ctx, target, target_size)
} else if (node_type == 'group') {
return _compile_group(node, ctx, target, target_size, parent_target, parent_size)
return _compile_group_layer(node, ctx, target, target_size, parent_target, parent_size)
} else if (_is_renderer_type(node_type)) {
return _compile_renderer(node, ctx, target, target_size, parent_target, parent_size)
} else if (node_type == 'imgui') {
@@ -147,8 +150,8 @@ function _compile_composition(node, ctx, target, target_size) {
return {output: target}
}
// Compile group with effects
function _compile_group(node, ctx, target, target_size, parent_target, parent_size) {
// Compile group layer (folder of layers)
function _compile_group_layer(node, ctx, target, target_size, parent_target, parent_size) {
var layers = node.layers || []
var original_target = target
@@ -161,7 +164,7 @@ function _compile_group(node, ctx, target, target_size, parent_target, parent_si
_compile_node(layer, ctx, target, target_size)
}
// Apply effects - this is where the compositor owns all effect logic
// Apply effects
if (node.effects && node.effects.length > 0) {
for (var j = 0; j < node.effects.length; j++) {
var effect = node.effects[j]
@@ -170,58 +173,32 @@ function _compile_group(node, ctx, target, target_size, parent_target, parent_si
}
// Composite back to parent if needed
var needs_composite = (original_target != parent_target || target != original_target) && parent_target
if (needs_composite) {
var presentation = node.presentation || 'disabled'
var blend = node.blend || 'over'
if (parent_target == 'screen') {
ctx.passes.push({
type: 'blit_to_screen',
source: target,
source_size: target_size,
dest_size: parent_size,
presentation: presentation,
pos: node.pos
})
} else {
ctx.passes.push({
type: 'composite',
source: target,
dest: parent_target,
source_size: target_size,
dest_size: parent_size,
presentation: presentation,
blend: blend,
pos: node.pos
})
}
}
_composite_back(ctx, target, target_size, original_target, parent_target, parent_size, node)
return {output: target}
}
// Compile renderer layer (film2d, forward3d, etc.)
// Compile renderer layer (film2d, etc.)
function _compile_renderer(node, ctx, target, target_size, parent_target, parent_size) {
var renderer_type = node.type
var renderer = ctx.renderers[renderer_type]
if (!renderer) {
log.console(`compositor: Unknown renderer: ${renderer_type}`)
return {output: target}
}
var layer_target = target
var layer_size = target_size
var owns_target = false
// Check for resolution override
if (node.resolution) {
owns_target = true
layer_size = {w: node.resolution.w, h: node.resolution.h}
layer_target = ctx.alloc_target(layer_size.w, layer_size.h, node.name || renderer_type + '_target')
layer_size = {width: node.resolution.width, height: node.resolution.height}
layer_target = ctx.alloc_target(layer_size.width, layer_size.height, node.name || renderer_type + '_target')
}
// Clear if we own target
if (owns_target && node.clear) {
ctx.passes.push({
@@ -230,63 +207,227 @@ function _compile_renderer(node, ctx, target, target_size, parent_target, parent
color: node.clear
})
}
// 1. Resolve ALL drawables for this plane
var all_drawables = ctx.resolve_drawables(node)
// Emit render pass
// 2. Identify Group Memberships and Subtractions
var groups = node.groups || []
var consumed_indices = {} // Set of indices in all_drawables that are consumed
// If we have groups, we need to process them
for (var i = 0; i < groups.length; i++) {
var group = groups[i]
var group_drawables = []
// Resolve group selection
if (group.select) {
// Selector logic
/*
Simple selector: { tags: ['tag1'] } or { handles: [...] }
*/
if (group.select.tags) {
var tags = group.select.tags
for (var k = 0; k < all_drawables.length; k++) {
var d = all_drawables[k]
// Check if 'd' has any of the tags
// If 'd' is a handle, it has .tags array
// If 'd' is a struct, it might have .tags?
// Assuming all filterable items are handles for now or have tags prop
if (d.tags || (d.has_tag)) {
var match = false
for (var t = 0; t < tags.length; t++) {
if (d.has_tag && d.has_tag(tags[t])) { match = true; break }
if (d.tags && d.tags.indexOf && d.tags.indexOf(tags[t]) >= 0) { match = true; break }
}
if (match) {
group_drawables.push(d)
consumed_indices[k] = true
}
}
}
}
} else {
// Ask group to resolve itself if it has a custom callback?
if (group.resolve) {
// If group resolves explicit list, we need to match them to base list to remove them
// Or just trust the group list and try to remove by reference
var gd = group.resolve(ctx)
group_drawables = gd
// Mark as consumed by reference check? O(N*M) - risky.
// Assume ID check if handles.
for (var g = 0; g < gd.length; g++) {
var item = gd[g]
var id = item._id || item.id
if (id) {
for (var k = 0; k < all_drawables.length; k++) {
if (all_drawables[k]._id == id || all_drawables[k].id == id) {
consumed_indices[k] = true
break
}
}
} else {
// Ref equality fallback
var idx = all_drawables.indexOf(item)
if (idx >= 0) consumed_indices[idx] = true
}
}
}
}
// Render Group
if (group_drawables.length > 0) {
var group_target = ctx.alloc_target(layer_size.width, layer_size.height, (group.name||'group') + '_content')
var group_out = group_target
// Render content
ctx.passes.push({
type: 'render',
renderer: renderer_type,
drawables: group_drawables,
camera: node.camera,
target: group_target,
target_size: layer_size,
blend: 'replace',
clear: {r:0,g:0,b:0,a:0}
})
// Apply effects
if (group.effects) {
for (var e = 0; e < group.effects.length; e++) {
group_out = _compile_effect(group.effects[e], ctx, group_out, layer_size, group.name)
}
}
// Insert result as texture_ref into MAIN list
// We need to inject it. We can add it to 'all_drawables' but mark it as NOT consumed?
// No, 'all_drawables' iteration is done.
// We'll construct the 'final_drawables' list.
// Create texture_ref drawable.
// Note: we can't create a 'handle' easily here without film2d's help, BUT film2d.render accepts raw structs.
var tex_ref = {
type: 'texture_ref',
texture_target: group_out,
pos: {x: 0, y: 0}, // Full screen quad usually? Or group bounds? User: "Ship full-plane first".
width: layer_size.width,
height: layer_size.height,
layer: group.output_layer || 0,
blend: 'over', // Textures usually blend over
world_y: (group.pos ? group.pos.y : 0) // Sorting? "Group is atomic in ordering... Insert at output_layer"
}
// We defer adding this to the final list until after filtering
group.generated_ref = tex_ref
}
}
// 3. Construct Final List
var final_drawables = []
for (var k = 0; k < all_drawables.length; k++) {
if (!consumed_indices[k]) {
final_drawables.push(all_drawables[k])
}
}
// Add generated refs
for (var i = 0; i < groups.length; i++) {
if (groups[i].generated_ref) {
final_drawables.push(groups[i].generated_ref)
}
}
// 4. Main Render Pass
ctx.passes.push({
type: 'render',
renderer: renderer_type,
root: node.root,
drawables: final_drawables,
camera: node.camera,
target: layer_target,
target_size: layer_size,
blend: node.blend || 'over',
clear: owns_target ? node.clear : null
})
// Composite back to parent
if (owns_target && parent_target) {
_composite_back(ctx, layer_target, layer_size, target, parent_target, parent_size, node)
return {output: layer_target}
}
function _composite_back(ctx, current_target, current_size, original_target, parent_target, parent_size, node) {
var needs_composite = (original_target != parent_target || current_target != original_target) && parent_target
if (needs_composite) {
var presentation = node.presentation || 'disabled'
var blend = node.blend || 'over'
ctx.passes.push({
type: 'composite',
source: layer_target,
dest: parent_target,
source_size: layer_size,
dest_size: parent_size,
presentation: presentation,
blend: blend,
pos: node.pos
})
if (parent_target == 'screen') {
ctx.passes.push({
type: 'blit_to_screen',
source: current_target,
source_size: current_size,
dest_size: parent_size,
presentation: presentation,
pos: node.pos
})
} else {
ctx.passes.push({
type: 'composite',
source: current_target,
dest: parent_target,
source_size: current_size,
dest_size: parent_size,
presentation: presentation,
blend: blend,
pos: node.pos
})
}
}
return {output: layer_target}
}
// Compile an effect using the effect registry
function _compile_effect(effect, ctx, input_target, target_size, node_id) {
var effect_type = effect.type
var effect_def = effects_mod.get(effect_type)
if (!effect_def) {
log.console(`compositor: Unknown effect: ${effect_type}`)
return input_target
}
// Build effect context
var effect_ctx = {
backend: ctx.backend,
target_size: {w: target_size.w, h: target_size.h},
alloc_target: function(w, h, hint) {
return ctx.alloc_target(w, h, hint)
target_size: {width: target_size.width, height: target_size.height},
alloc_target: function(width, height, hint) {
return ctx.alloc_target(width, height, hint)
},
get_persistent_target: function(key, w, h) {
return ctx.get_persistent_target(node_id + '_' + key, w, h)
get_persistent_target: function(key, width, height) {
return ctx.get_persistent_target(node_id + '_' + key, width, height)
},
// Allows effects to resolve sources (for masks)
resolve_source: function(source_def) {
// If source is "tags", iterate all drawables of the current renderer?
// This is tricky. Masks need access to the plane's drawables.
// Simplified: The mask effect in 'paladin.ce' has a 'source' handle directly.
// We can wrap it in a drawable list.
if (source_def.type == 'handle') {
return [source_def]
}
if (source_def.tags) {
// We don't have easy access to the full list here unless we passed it.
// For now, assume mask sources are explicit handles or handle lists passed in the effect config.
return []
}
// If source is a handle object (has _id)
if (source_def._id || source_def.type) return [source_def]
return []
}
}
// Allocate output target
var output = ctx.alloc_target(target_size.w, target_size.h, effect_type + '_out')
var output = ctx.alloc_target(target_size.width, target_size.height, effect_type + '_out')
// Build effect passes
var effect_passes = effect_def.build_passes(input_target, output, effect, effect_ctx)
@@ -304,14 +445,19 @@ function _compile_effect(effect, ctx, input_target, target_size, node_id) {
function _convert_effect_pass(ep, ctx) {
switch (ep.type) {
case 'shader':
if (!ep.input && !ep.inputs) {
ep.input = ep.inputs[0]
// Handle both single input and multiple inputs array
var primary_input = ep.input
var extra = []
if (is_array(ep.inputs) && ep.inputs.length > 0) {
if (!primary_input) primary_input = ep.inputs[0]
extra = ep.inputs.slice(1)
}
return {
type: 'shader_pass',
shader: ep.shader,
input: ep.input || ep.inputs[0],
extra_inputs: ep.inputs ? ep.inputs.slice(1) : [],
input: primary_input,
extra_inputs: extra,
output: ep.output,
uniforms: ep.uniforms
}
@@ -328,16 +474,22 @@ function _convert_effect_pass(ep, ctx) {
type: 'composite',
source: ep.source,
dest: ep.dest,
source_size: ep.source.w ? {w: ep.source.w, h: ep.source.h} : ctx.target_size,
dest_size: ep.dest.w ? {w: ep.dest.w, h: ep.dest.h} : ctx.target_size,
source_size: ep.source.width ? {width: ep.source.width, height: ep.source.height} : ctx.target_size,
dest_size: ep.dest.width ? {width: ep.dest.width, height: ep.dest.height} : ctx.target_size,
presentation: 'disabled'
}
case 'render_subtree':
// This is now "render_drawables"
// Ensure drawables is an array because film2d expects array
var list = ep.root
if (!list) list = []
else if (!is_array(list)) list = [list]
return {
type: 'render_mask_source',
source: ep.root,
drawables: list,
target: ep.output,
target_size: {w: ep.output.w, h: ep.output.h},
target_size: {width: ep.output.width, height: ep.output.height},
space: ep.space || 'local'
}
default:
@@ -356,40 +508,40 @@ function _effects_require_target(effects, ctx) {
// Check if type is a renderer
function _is_renderer_type(type) {
return type == 'film2d' || type == 'forward3d' || type == 'imgui' || type == 'retro3d' || type == 'simple2d'
return type == 'film2d' || type == 'forward3d' || type == 'retro3d' || type == 'simple2d'
}
// Calculate presentation rect
compositor.calculate_presentation_rect = function(source_size, dest_size, mode) {
var sw = source_size.w || source_size.width
var sh = source_size.h || source_size.height
var dw = dest_size.w || dest_size.width
var dh = dest_size.h || dest_size.height
var sw = source_size.width
var sh = source_size.height
var dw = dest_size.width
var dh = dest_size.height
if (mode == 'disabled') {
return {x: 0, y: 0, width: number.min(sw, dw), height: number.min(sh, dh)}
}
if (mode == 'stretch') {
return {x: 0, y: 0, width: dw, height: dh}
}
var src_aspect = sw / sh
var dst_aspect = dw / dh
if (mode == 'letterbox') {
var scale = src_aspect > dst_aspect ? dw / sw : dh / sh
var w = sw * scale
var h = sh * scale
return {x: (dw - w) / 2, y: (dh - h) / 2, width: w, height: h}
}
if (mode == 'overscan') {
var scale = src_aspect > dst_aspect ? dh / sh : dw / sw
var w = sw * scale
var h = sh * scale
return {x: (dw - w) / 2, y: (dh - h) / 2, width: w, height: h}
}
if (mode == 'integer_scale') {
var scale_x = number.floor(dw / sw)
var scale_y = number.floor(dh / sh)
@@ -398,24 +550,24 @@ compositor.calculate_presentation_rect = function(source_size, dest_size, mode)
var h = sh * scale
return {x: (dw - w) / 2, y: (dh - h) / 2, width: w, height: h}
}
return {x: 0, y: 0, width: sw, height: sh}
}
// Execute a compiled render plan
compositor.execute = function(plan, renderers, backend) {
var target_cache = {}
// Pre-allocate targets
for (var key in plan.targets) {
var spec = plan.targets[key]
target_cache[key] = backend.get_or_create_target(spec.w, spec.h, key)
target_cache[key] = backend.get_or_create_target(spec.width, spec.height, key)
}
for (var key in plan.persistent_targets) {
var spec = plan.persistent_targets[key]
if (!target_cache[key]) {
target_cache[key] = backend.get_or_create_target(spec.w, spec.h, key)
target_cache[key] = backend.get_or_create_target(spec.width, spec.height, key)
}
}
@@ -444,26 +596,28 @@ compositor.execute = function(plan, renderers, backend) {
function _execute_pass(pass, renderers, backend, resolve_target, comp) {
var commands = []
var source
switch (pass.type) {
case 'clear':
var target = resolve_target(pass.target)
commands.push({cmd: 'begin_render', target: target, clear: pass.color})
commands.push({cmd: 'end_render'})
break
case 'render':
var renderer = renderers[pass.renderer]
if (renderer && renderer.render) {
var target = resolve_target(pass.target)
// Pass drawables directly
var result = renderer.render({
root: pass.root,
drawables: pass.drawables,
camera: pass.camera,
target: target,
target_size: pass.target_size,
blend: pass.blend,
clear: pass.clear
}, backend)
if (result && result.commands) {
for (var i = 0; i < result.commands.length; i++) {
commands.push(result.commands[i])
@@ -569,11 +723,11 @@ function _execute_pass(pass, renderers, backend, resolve_target, comp) {
case 'render_mask_source': {
var target = resolve_target(pass.target)
var renderer = renderers['film2d']
var renderer = renderers['film2d'] // Assume film2d for now
if (renderer && renderer.render) {
var result = renderer.render({
root: pass.source,
camera: {pos: [0, 0], width: pass.target_size.w, height: pass.target_size.h, anchor: [0, 0], ortho: true},
drawables: pass.drawables, // Was 'root'
camera: {pos: {x: 0, y: 0}, width: pass.target_size.width, height: pass.target_size.height, anchor: {x: 0, y: 0}, ortho: true},
target: target,
target_size: pass.target_size,
blend: 'replace',
@@ -588,7 +742,7 @@ function _execute_pass(pass, renderers, backend, resolve_target, comp) {
}
break
}
return commands
}