var effects_mod = use('effects') var backend = use('sdl_gpu') var film2d = use('film2d') var compositor = {} // Compile compositor config into render plan compositor.compile = function(config) { var ctx = { passes: [], targets: {}, counter: 0, screen_size: backend.get_window_size ? backend.get_window_size() : {width: 1280, height: 720}, alloc: function(w, h, hint) { var key = (hint || 't') + '_' + text(this.counter++) this.targets[key] = {width: w, height: h, key: key} return {type: 'target', key: key, width: w, height: h} } } var group_effects = config.group_effects || {} // Clear screen if (config.clear) push(ctx.passes, {type: 'clear', target: 'screen', color: config.clear}) // Process each plane (supports both 'planes' and legacy 'layers' key) var planes = config.planes || config.layers || [] for (var i = 0; i < length(planes); i++) { var plane = planes[i] var type = plane.type || 'film2d' if (type == 'imgui') { compile_imgui_layer(plane, ctx) } else { compile_plane(plane, ctx, group_effects) } } return {passes: ctx.passes, targets: ctx.targets, screen_size: ctx.screen_size} } function compile_imgui_layer(layer, ctx) { push(ctx.passes, { type: 'imgui', target: 'screen', draw: layer.draw }) } function compile_plane(plane_config, ctx, group_effects) { var plane_name = plane_config.plane || plane_config.name var res = plane_config.resolution || ctx.screen_size var camera = plane_config.camera var layer_sort = plane_config.layer_sort || {} // layer -> 'y' or 'explicit' // Build set of groups used as masks (these should not be drawn directly) var mask_groups = {} arrfor(array(group_effects), gname => { var effects = group_effects[gname].effects || [] for (var e = 0; e < length(effects); e++) { if (effects[e].type == 'mask' && effects[e].mask_group) mask_groups[effects[e].mask_group] = true } }) // Get all sprites in this plane var all_sprites = film2d.query({plane: plane_name}) // Add manual drawables if (plane_config.drawables) { for (var i = 0; i < length(plane_config.drawables); i++) push(all_sprites, plane_config.drawables[i]) } // Find which sprites belong to groups with effects var effect_groups = {} // group_name -> {sprites: [], effects: []} var base_sprites = [] for (var i = 0; i < length(all_sprites); i++) { var s = all_sprites[i] var sprite_groups = s.groups || [] var assigned = false var is_mask_only = length(sprite_groups) > 0 // First pass: check if sprite has any non-mask group for (var g = 0; g < length(sprite_groups); g++) { var gname = sprite_groups[g] if (!mask_groups[gname]) { is_mask_only = false break } } // Second pass: assign to effect groups for (var g = 0; g < length(sprite_groups); g++) { var gname = sprite_groups[g] if (group_effects[gname]) { if (!effect_groups[gname]) effect_groups[gname] = {sprites: [], effects: group_effects[gname].effects} push(effect_groups[gname].sprites, s) assigned = true break // Only assign to first matching effect group } } // Add to base sprites if not assigned to effect group and not mask-only if (!assigned && !is_mask_only) push(base_sprites, s) } // Allocate plane target var plane_target = ctx.alloc(res.width, res.height, plane_config.name) // Clear plane if (plane_config.clear) push(ctx.passes, {type: 'clear', target: plane_target, color: plane_config.clear}) // Render each effect group to temp target, apply effects, composite back arrfor(array(effect_groups), gname => { var eg = effect_groups[gname] if (length(eg.sprites) == 0) return var group_target = ctx.alloc(res.width, res.height, gname + '_content') // Render group content push(ctx.passes, { type: 'render', renderer: 'film2d', drawables: eg.sprites, camera: camera, target: group_target, target_size: res, layer_sort: layer_sort, clear: {r: 0, g: 0, b: 0, a: 0} }) // Apply effects var current = group_target for (var e = 0; e < length(eg.effects); e++) { var effect = eg.effects[e] current = apply_effect(ctx, effect, current, res, camera, gname, plane_name, group_effects) } // Composite result to plane push(ctx.passes, { type: 'composite', source: current, dest: plane_target, source_size: res, dest_size: res, blend: 'over' }) }) // Render base sprites (no effects) if (length(base_sprites) > 0) { push(ctx.passes, { type: 'render', renderer: 'film2d', drawables: base_sprites, camera: camera, target: plane_target, target_size: res, layer_sort: layer_sort, clear: null // Don't clear, blend on top }) } // Composite plane to screen push(ctx.passes, { type: 'blit_to_screen', source: plane_target, source_size: res, dest_size: ctx.screen_size, presentation: plane_config.presentation || 'stretch' }) } function apply_effect(ctx, effect, input, size, camera, hint, current_plane, group_effects) { var output = ctx.alloc(size.width, size.height, hint + '_' + effect.type) if (effect.type == 'bloom') { var bright = ctx.alloc(size.width, size.height, hint + '_bright') var blur1 = ctx.alloc(size.width, size.height, hint + '_blur1') var blur2 = ctx.alloc(size.width, size.height, hint + '_blur2') // Threshold push(ctx.passes, { type: 'shader_pass', shader: 'threshold', input: input, output: bright, uniforms: {threshold: effect.threshold || 0.8, intensity: effect.intensity || 1} }) // Blur passes var blur_passes = effect.blur_passes || 2 var blur_in = bright for (var p = 0; p < blur_passes; p++) { push(ctx.passes, {type: 'shader_pass', shader: 'blur', input: blur_in, output: blur1, uniforms: {direction: {x: 1, y: 0}, texel_size: {x: 1/size.width, y: 1/size.height}}}) push(ctx.passes, {type: 'shader_pass', shader: 'blur', input: blur1, output: blur2, uniforms: {direction: {x: 0, y: 1}, texel_size: {x: 1/size.width, y: 1/size.height}}}) blur_in = blur2 } // Composite bloom push(ctx.passes, {type: 'composite_textures', base: input, overlay: blur2, output: output, mode: 'add'}) } else if (effect.type == 'mask') { var mask_group = effect.mask_group // Query masks within the same plane to avoid cross-plane mask issues var mask_sprites = film2d.query({group: mask_group, plane: current_plane}) if (length(mask_sprites) > 0) { var mask_target = ctx.alloc(size.width, size.height, hint + '_mask') // Render mask push(ctx.passes, { type: 'render', renderer: 'film2d', drawables: mask_sprites, camera: camera, target: mask_target, target_size: size, clear: {r: 0, g: 0, b: 0, a: 0} }) // Apply mask push(ctx.passes, { type: 'apply_mask', content: input, mask: mask_target, output: output, mode: effect.channel || 'alpha', invert: effect.invert || false }) } else { // No mask sprites, pass through push(ctx.passes, {type: 'blit', source: input, dest: output}) } } else { // Unknown effect, pass through push(ctx.passes, {type: 'blit', source: input, dest: output}) } return output } // Execute compiled plan compositor.execute = function(plan) { var cache = {} arrfor(array(plan.targets), key => { var spec = plan.targets[key] cache[key] = backend.get_or_create_target(spec.width, spec.height, key) }) function resolve(t) { if (t == 'screen') return 'screen' if (t && t.type == 'target') return cache[t.key] return t } var commands = [] for (var i = 0; i < length(plan.passes); i++) { var pass = plan.passes[i] if (pass.type == 'clear') { var target = resolve(pass.target) push(commands, {cmd: 'begin_render', target: target, clear: pass.color}) push(commands, {cmd: 'end_render'}) } else if (pass.type == 'render') { var result = film2d.render({ drawables: pass.drawables, camera: pass.camera, target: resolve(pass.target), target_size: pass.target_size, layer_sort: pass.layer_sort || {}, clear: pass.clear }, backend) for (var c = 0; c < length(result.commands); c++) push(commands, result.commands[c]) } else if (pass.type == 'shader_pass') { push(commands, { cmd: 'shader_pass', shader: pass.shader, input: resolve(pass.input), output: resolve(pass.output), uniforms: pass.uniforms }) } else if (pass.type == 'composite_textures') { push(commands, { cmd: 'composite_textures', base: resolve(pass.base), overlay: resolve(pass.overlay), output: resolve(pass.output), mode: pass.mode }) } else if (pass.type == 'apply_mask') { push(commands, { cmd: 'apply_mask', content_texture: resolve(pass.content), mask_texture: resolve(pass.mask), output: resolve(pass.output), mode: pass.mode, invert: pass.invert }) } else if (pass.type == 'composite') { push(commands, { cmd: 'blit', texture: resolve(pass.source), target: resolve(pass.dest), dst_rect: {x: 0, y: 0, width: pass.dest_size.width, height: pass.dest_size.height} }) } else if (pass.type == 'blit_to_screen') { var rect = _calc_presentation(pass.source_size, pass.dest_size, pass.presentation) push(commands, { cmd: 'blit', texture: resolve(pass.source), target: 'screen', dst_rect: rect, filter: pass.presentation == 'integer_scale' ? 'nearest' : 'linear' }) } else if (pass.type == 'blit') { var src = resolve(pass.source) var dst = resolve(pass.dest) push(commands, { cmd: 'blit', texture: src, target: dst, dst_rect: {x: 0, y: 0, width: dst.width, height: dst.height} }) } else if (pass.type == 'imgui') { push(commands, { cmd: 'imgui', target: resolve(pass.target), draw: pass.draw }) } } return {commands: commands, plan: plan} } function _calc_presentation(src, dst, mode) { if (mode == 'stretch') return {x: 0, y: 0, width: dst.width, height: dst.height} if (mode == 'integer_scale') { var sx = floor(dst.width / src.width) var sy = floor(dst.height / src.height) var s = max(1, min(sx, sy)) var w = src.width * s var h = src.height * s return {x: (dst.width - w) / 2, y: (dst.height - h) / 2, width: w, height: h} } // letterbox var scale = min(dst.width / src.width, dst.height / src.height) var w = src.width * scale var h = src.height * scale return {x: (dst.width - w) / 2, y: (dst.height - h) / 2, width: w, height: h} } return compositor