diff --git a/compositor.cm b/compositor.cm index 61270765..e8cbcb02 100644 --- a/compositor.cm +++ b/compositor.cm @@ -25,21 +25,53 @@ compositor.compile = function(config) { if (config.clear) ctx.passes.push({type: 'clear', target: 'screen', color: config.clear}) - // Process each layer - var layers = config.layers || [] - for (var i = 0; i < layers.length; i++) - compile_layer(layers[i], ctx, group_effects) + // Process each plane (supports both 'planes' and legacy 'layers' key) + var planes = config.planes || config.layers || [] + for (var i = 0; i < planes.length; 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_layer(layer, ctx, group_effects) { - var group = layer.group - var res = layer.resolution || ctx.screen_size - var camera = layer.camera +function compile_imgui_layer(layer, ctx) { + ctx.passes.push({ + 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' - // Get all sprites in this group - var all_sprites = film2d.query({group: group}) + // Build set of groups used as masks (these should not be drawn directly) + var mask_groups = {} + for (var gname in group_effects) { + var effects = group_effects[gname].effects || [] + for (var e = 0; e < effects.length; 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 < plane_config.drawables.length; i++) + all_sprites.push(plane_config.drawables[i]) + } // Find which sprites belong to groups with effects var effect_groups = {} // group_name -> {sprites: [], effects: []} @@ -47,10 +79,20 @@ function compile_layer(layer, ctx, group_effects) { for (var i = 0; i < all_sprites.length; i++) { var s = all_sprites[i] - var assigned = false - - // Check if sprite belongs to any effect group var sprite_groups = s.groups || [] + var assigned = false + var is_mask_only = sprite_groups.length > 0 + + // First pass: check if sprite has any non-mask group + for (var g = 0; g < sprite_groups.length; 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 < sprite_groups.length; g++) { var gname = sprite_groups[g] if (group_effects[gname]) { @@ -62,15 +104,16 @@ function compile_layer(layer, ctx, group_effects) { } } - if (!assigned) base_sprites.push(s) + // Add to base sprites if not assigned to effect group and not mask-only + if (!assigned && !is_mask_only) base_sprites.push(s) } - // Allocate layer target - var layer_target = ctx.alloc(res.width, res.height, layer.name) + // Allocate plane target + var plane_target = ctx.alloc(res.width, res.height, plane_config.name) - // Clear layer - if (layer.clear) - ctx.passes.push({type: 'clear', target: layer_target, color: layer.clear}) + // Clear plane + if (plane_config.clear) + ctx.passes.push({type: 'clear', target: plane_target, color: plane_config.clear}) // Render each effect group to temp target, apply effects, composite back for (var gname in effect_groups) { @@ -87,6 +130,7 @@ function compile_layer(layer, ctx, group_effects) { camera: camera, target: group_target, target_size: res, + layer_sort: layer_sort, clear: {r: 0, g: 0, b: 0, a: 0} }) @@ -94,14 +138,14 @@ function compile_layer(layer, ctx, group_effects) { var current = group_target for (var e = 0; e < eg.effects.length; e++) { var effect = eg.effects[e] - current = apply_effect(effect, current, res, gname, group_effects) + current = apply_effect(ctx, effect, current, res, camera, gname, plane_name, group_effects) } - // Composite result to layer + // Composite result to plane ctx.passes.push({ type: 'composite', source: current, - dest: layer_target, + dest: plane_target, source_size: res, dest_size: res, blend: 'over' @@ -115,32 +159,33 @@ function compile_layer(layer, ctx, group_effects) { renderer: 'film2d', drawables: base_sprites, camera: camera, - target: layer_target, + target: plane_target, target_size: res, + layer_sort: layer_sort, clear: null // Don't clear, blend on top }) } - // Composite layer to screen + // Composite plane to screen ctx.passes.push({ type: 'blit_to_screen', - source: layer_target, + source: plane_target, source_size: res, dest_size: ctx.screen_size, - presentation: layer.presentation || 'stretch' + presentation: plane_config.presentation || 'stretch' }) } -function apply_effect(effect, input, size, hint, group_effects) { - var output = alloc(size.width, size.height, hint + '_' + effect.type) +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 = alloc(size.width, size.height, hint + '_bright') - var blur1 = alloc(size.width, size.height, hint + '_blur1') - var blur2 = alloc(size.width, size.height, hint + '_blur2') + 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 - passes.push({ + ctx.passes.push({ type: 'shader_pass', shader: 'threshold', input: input, @@ -152,34 +197,35 @@ function apply_effect(effect, input, size, hint, group_effects) { var blur_passes = effect.blur_passes || 2 var blur_in = bright for (var p = 0; p < blur_passes; p++) { - passes.push({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}}}) - passes.push({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}}}) + ctx.passes.push({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}}}) + ctx.passes.push({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 - passes.push({type: 'composite_textures', base: input, overlay: blur2, output: output, mode: 'add'}) + ctx.passes.push({type: 'composite_textures', base: input, overlay: blur2, output: output, mode: 'add'}) } else if (effect.type == 'mask') { var mask_group = effect.mask_group - var mask_sprites = film2d.query({group: 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 (mask_sprites.length > 0) { - var mask_target = alloc(size.width, size.height, hint + '_mask') + var mask_target = ctx.alloc(size.width, size.height, hint + '_mask') // Render mask - passes.push({ + ctx.passes.push({ type: 'render', renderer: 'film2d', drawables: mask_sprites, - camera: null, // Same camera as parent? Need to pass this + camera: camera, target: mask_target, target_size: size, clear: {r: 0, g: 0, b: 0, a: 0} }) // Apply mask - passes.push({ + ctx.passes.push({ type: 'apply_mask', content: input, mask: mask_target, @@ -189,11 +235,11 @@ function apply_effect(effect, input, size, hint, group_effects) { }) } else { // No mask sprites, pass through - passes.push({type: 'blit', source: input, dest: output}) + ctx.passes.push({type: 'blit', source: input, dest: output}) } } else { // Unknown effect, pass through - passes.push({type: 'blit', source: input, dest: output}) + ctx.passes.push({type: 'blit', source: input, dest: output}) } return output @@ -230,6 +276,7 @@ compositor.execute = function(plan) { 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 < result.commands.length; c++) @@ -280,10 +327,25 @@ compositor.execute = function(plan) { 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) + commands.push({ + cmd: 'blit', + texture: src, + target: dst, + dst_rect: {x: 0, y: 0, width: dst.width, height: dst.height} + }) + } else if (pass.type == 'imgui') { + commands.push({ + cmd: 'imgui', + target: resolve(pass.target), + draw: pass.draw + }) } } - return {commands: commands} + return {commands: commands, plan: plan} } function _calc_presentation(src, dst, mode) { diff --git a/core.cm b/core.cm index 482b4b44..06430591 100644 --- a/core.cm +++ b/core.cm @@ -13,6 +13,7 @@ var video = use('sdl3/video') var events = use('sdl3/input') var time_mod = use('time') +var debug_imgui = use('debug_imgui') var core = {} @@ -48,7 +49,7 @@ core.start = function(config) { _window = _backend.get_window() - if (config.imgui && imgui.init) { + if ((config.imgui || config.editor) && imgui.init) { imgui.init(_window, _backend.get_device()) } @@ -75,12 +76,19 @@ core.window_size = function() { core.backend = function() { return _backend } +// FPS tracking +var _fps_samples = [] +var _fps_sample_count = 60 +var _current_fps = 0 +var _frame_time_ms = 0 // Main loop function _main_loop() { + var frame_start = time_mod.number() + if (!_running) return - var now = time_mod.number() + var now = frame_start var dt = now - _last_time _last_time = now @@ -90,7 +98,7 @@ function _main_loop() { var win_size = _backend.get_window_size() for (var ev of evts) { - if (_config.imgui) { + if (_config.imgui || _config.editor) { imgui.process_event(ev) } @@ -117,9 +125,12 @@ function _main_loop() { _config.update(dt) } + var imgui_mod = use('imgui') + var debug_imgui = use('debug_imgui') + // ImGui Frame - if (_config.imgui) { - imgui.newframe() + if (_config.imgui || _config.editor) { + imgui_mod.newframe() } // Render @@ -133,12 +144,24 @@ function _main_loop() { } var dbg = _config.debug == 'cmd' + // Build stats for debug_imgui + var stats = { + fps: _current_fps, + frame_time_ms: _frame_time_ms + } + // Handle both compositor result ({commands: [...]}) and fx_graph (graph object) if (render_result.commands) { - if (_config.imgui) { + if (_config.imgui || _config.editor) { render_result.commands.push({ cmd: 'imgui', - draw: _config.imgui, + draw: function(ui) { + if (_config.imgui) _config.imgui(ui) + if (_config.editor) { + debug_imgui.render(ui, null, render_result.plan, stats) + _config.editor(ui) + } + }, target: 'screen' }) } @@ -156,9 +179,26 @@ function _main_loop() { } } + // Measure actual frame work time (excluding delay) + var frame_end = time_mod.number() + var actual_frame_time = frame_end - frame_start + + // Track FPS based on actual work time + _frame_time_ms = actual_frame_time * 1000 + _fps_samples.push(actual_frame_time) + if (_fps_samples.length > _fps_sample_count) { + _fps_samples.shift() + } + var avg_frame_time = 0 + for (var i = 0; i < _fps_samples.length; i++) { + avg_frame_time += _fps_samples[i] + } + avg_frame_time = avg_frame_time / _fps_samples.length + _current_fps = avg_frame_time > 0 ? 1 / avg_frame_time : 0 + // Schedule next frame var frame_time = 1 / _framerate - var elapsed = time_mod.number() - now + var elapsed = frame_end - frame_start var delay = frame_time - elapsed if (delay < 0) delay = 0 diff --git a/debug_imgui.cm b/debug_imgui.cm index 0250fa3b..f89a445e 100644 --- a/debug_imgui.cm +++ b/debug_imgui.cm @@ -8,11 +8,13 @@ var debug_imgui = {} +var json = use('json') + // State var _show_scene_tree = false var _show_render_graph = false var _show_effects = false -var _show_stats = false +var _show_stats = true var _show_targets = false var _selected_node = null diff --git a/film2d.cm b/film2d.cm index 3485d22f..8865da10 100644 --- a/film2d.cm +++ b/film2d.cm @@ -3,13 +3,20 @@ var film2d = {} var next_id = 1 var registry = {} // id -> drawable var group_index = {} // group_name -> [id, id, ...] +var plane_index = {} // plane_name -> [id, id, ...] film2d.register = function(drawable) { var id = text(next_id++) drawable._id = id registry[id] = drawable - var groups = drawable.groups || ['default'] + // Index by plane + var plane = drawable.plane || 'default' + if (!plane_index[plane]) plane_index[plane] = [] + plane_index[plane].push(id) + + // Index by groups (effect routing only) + var groups = drawable.groups || [] for (var i = 0; i < groups.length; i++) { var g = groups[i] if (!group_index[g]) group_index[g] = [] @@ -24,6 +31,14 @@ film2d.unregister = function(id) { var drawable = registry[id_str] if (!drawable) return + // Remove from plane index + var plane = drawable.plane || 'default' + if (plane_index[plane]) { + var idx = plane_index[plane].indexOf(id_str) + if (idx >= 0) plane_index[plane].splice(idx, 1) + } + + // Remove from group indices var groups = drawable.groups || [] for (var i = 0; i < groups.length; i++) { var g = groups[i] @@ -59,10 +74,29 @@ film2d.get = function(id) { return registry[text(id)] } -// Query by group - returns array of drawables +// Query by plane and/or group - returns array of drawables film2d.query = function(selector) { var result = [] + // Query by plane (primary selection) + if (selector.plane) { + var ids = plane_index[selector.plane] || [] + for (var i = 0; i < ids.length; i++) { + var d = registry[ids[i]] + if (d && d.visible != false) { + // If also filtering by group, check membership + if (selector.group) { + var groups = d.groups || [] + if (groups.indexOf(selector.group) >= 0) result.push(d) + } else { + result.push(d) + } + } + } + return result + } + + // Query by group only (for effect routing) if (selector.group) { var ids = group_index[selector.group] || [] for (var i = 0; i < ids.length; i++) { @@ -116,14 +150,43 @@ film2d.render = function(params, backend) { var target = params.target var target_size = params.target_size var clear_color = params.clear + var layer_sort = params.layer_sort || {} // layer -> 'y' or 'explicit' if (drawables.length == 0) return {commands: []} + + function _y_sort_key(d) { + if (!d || !d.pos) return 0 + var y = d.pos.y || 0 + var h = d.height || 0 + var ay = d.anchor_y + if (ay == null) ay = 0.5 + // Convert "pos.y at anchor" -> "feet y" + return y + h * (1 - ay) + } - // Sort by layer, then Y + // Sort by layer, then optionally by Y based on layer_sort policy drawables.sort(function(a, b) { - var dl = (a.layer || 0) - (b.layer || 0) + var al = a.layer || 0 + var bl = b.layer || 0 + var dl = al - bl if (dl != 0) return dl - return (b.pos.y || 0) - (a.pos.y || 0) + + var sort_mode = layer_sort[text(al)] || 'explicit' + if (sort_mode == 'y') { + var ay = _y_sort_key(a) + var by = _y_sort_key(b) + + // Make this explicit instead of guessing + var y_down = camera && camera.y_down == true + + // If y_down: bigger y is lower on screen => should draw later (on top) + // If y_up: smaller y is lower on screen => should draw later (on top) + if (ay != by) return y_down ? (ay - by) : (by - ay) + } + + var aid = a._id || 0 + var bid = b._id || 0 + return aid < bid ? -1 : 1 }) var commands = [] @@ -164,6 +227,71 @@ function _batch_drawables(drawables) { if (current) batches.push(current) current = {type: 'sprite_batch', texture: tex, material: mat, sprites: [d]} } + } else if (d.type == 'particles') { + // Convert particles to sprites + var tex = d.texture || d.image + var mat = d.material || default_mat + var particles = d.particles || [] + + for (var p = 0; p < particles.length; p++) { + var part = particles[p] + var sprite = { + type: 'sprite', + pos: part.pos, + width: (d.width || 16) * (part.scale || 1), + height: (d.height || 16) * (part.scale || 1), + anchor_x: 0.5, + anchor_y: 0.5, + color: part.color || {r: 1, g: 1, b: 1, a: 1} + } + + if (current && current.type == 'sprite_batch' && current.texture == tex && _mat_eq(current.material, mat)) { + current.sprites.push(sprite) + } else { + if (current) batches.push(current) + current = {type: 'sprite_batch', texture: tex, material: mat, sprites: [sprite]} + } + } + } else if (d.type == 'tilemap') { + // Expand tilemap to sprites + var tiles = d.tiles || [] + var tile_w = d.tile_width || 1 + var tile_h = d.tile_height || 1 + var off_x = d.offset_x || 0 + var off_y = d.offset_y || 0 + + for (var x = 0; x < tiles.length; x++) { + if (!tiles[x]) continue + for (var y = 0; y < tiles[x].length; y++) { + var img = tiles[x][y] + if (!img) continue + + var wx = (x + off_x) * tile_w + var wy = (y + off_y) * tile_h + + // Center anchor for sprite + var sprite = { + type: 'sprite', + image: img, + pos: {x: wx + tile_w/2, y: wy + tile_h/2}, + width: tile_w, + height: tile_h, + anchor_x: 0.5, + anchor_y: 0.5, + color: {r: 1, g: 1, b: 1, a: 1} + } + + // Batching + var tex = img + var mat = default_mat + if (current && current.type == 'sprite_batch' && current.texture == tex && _mat_eq(current.material, mat)) { + current.sprites.push(sprite) + } else { + if (current) batches.push(current) + current = {type: 'sprite_batch', texture: tex, material: mat, sprites: [sprite]} + } + } + } } else { if (current) { batches.push(current) diff --git a/particles2d.cm b/particles2d.cm index b5940efb..77364564 100644 --- a/particles2d.cm +++ b/particles2d.cm @@ -116,12 +116,13 @@ function lerp(a, b, t) { return a + (b - a) * t } var factory = function(props) { var defaults = { type: 'particles', + pos: {x: 0, y: 0}, image: null, width: 16, height: 16, plane: 'default', layer: 0, - tags: [], + groups: [], particles: [] } diff --git a/sdl_gpu.cm b/sdl_gpu.cm index 1e8a3852..58941537 100644 --- a/sdl_gpu.cm +++ b/sdl_gpu.cm @@ -1627,9 +1627,8 @@ function _do_blit(cmd_buffer, cmd, current_target, get_swapchain_tex) { var pass = cmd_buffer.render_pass({ color_targets: [{ texture: swap_tex, - load: "clear", - store: "store", - clear_color: {r: 0, g: 0, b: 0, a: 1} + load: "load", // Load existing content to blend layers properly + store: "store" }] }) diff --git a/sprite.cm b/sprite.cm index 30035d8a..9aa39137 100644 --- a/sprite.cm +++ b/sprite.cm @@ -52,8 +52,9 @@ return function(props) { rotation: 0, color: {r: 1, g: 1, b: 1, a: 1}, opacity: 1, + plane: 'default', layer: 0, - groups: ['default'], + groups: [], visible: true } @@ -62,7 +63,7 @@ return function(props) { for (var k in props) data[k] = props[k] // Ensure groups is array - if (!data.groups) data.groups = ['default'] + if (!data.groups) data.groups = [] if (is_text(data.groups)) data.groups = [data.groups] var s = meme(sprite_proto, data) diff --git a/text2d.cm b/text2d.cm index fd1ccd07..ef34f51f 100644 --- a/text2d.cm +++ b/text2d.cm @@ -31,14 +31,14 @@ return function(props) { pos: {x: 0, y: 0}, plane: 'default', layer: 0, + groups: [], font: "fonts/dos", size: 16, color: {r: 1, g: 1, b: 1, a: 1}, mode: null, sdf: null, outline_width: null, - outline_color: null, - tags: [] + outline_color: null } var data = {} diff --git a/tilemap2d.cm b/tilemap2d.cm index a698f90c..85fea88d 100644 --- a/tilemap2d.cm +++ b/tilemap2d.cm @@ -32,7 +32,7 @@ var tilemap = { } } -stone(tilemap) +//stone(tilemap) // Factory function - auto-registers with film2d return function(props) { @@ -43,9 +43,9 @@ return function(props) { offset_y: 0, plane: 'default', layer: 0, + groups: [], tile_width: 1, - tile_height: 1, - tags: [] + tile_height: 1 } var data = {} @@ -53,7 +53,7 @@ return function(props) { for(var k in props) data[k] = props[k] var newtilemap = meme(tilemap, data) - newtilemap.tiles = [] // Initialize tiles +// newtilemap.tiles = [] // Initialize tiles film2d.register(newtilemap) return newtilemap }