From 4a29a49f28133fbd7acffe0cc2bacef8ada18baa Mon Sep 17 00:00:00 2001 From: John Alanbrook Date: Tue, 23 Dec 2025 00:53:00 -0600 Subject: [PATCH] clay 2 --- clay2.cm | 416 ++++++++++++++++++++++++++++++++++++++++++++++++++++ fx_graph.cm | 98 +++++++++---- sdl_gpu.cm | 24 +-- 3 files changed, 501 insertions(+), 37 deletions(-) create mode 100644 clay2.cm diff --git a/clay2.cm b/clay2.cm new file mode 100644 index 00000000..f0bea02e --- /dev/null +++ b/clay2.cm @@ -0,0 +1,416 @@ +// clay2.cm - Revised UI layout engine emitting scene trees +// +// Changes from clay.cm: +// - No __proto__, uses meme/merge +// - Emits scene tree nodes for fx_graph instead of immediate draw commands +// - Supports scissor clipping on groups + +var layout = use('layout') +var graphics = use('graphics') +var prosperon = use('prosperon') + +var clay = {} + +// Layout context +var lay_ctx = layout.make_context() + +// Base configuration for UI elements +var base_config = { + font: null, + background_image: null, + slice: 0, + font_path: 'fonts/dos', // Default font + font_size: 16, + color: {r:1, g:1, b:1, a:1}, + spacing: 0, + padding: 0, + margin: 0, + offset: {x:0, y:0}, + size: null, + background_color: null, + clipped: false, + text_break: 'word', + text_align: 'left', + max_size: null, + contain: 0, + behave: 0 +} + +function normalize_spacing(s) { + if (typeof s == 'number') return {l:s, r:s, t:s, b:s} + if (isa(s, array)) { + if (s.length == 2) return {l:s[0], r:s[0], t:s[1], b:s[1]} + if (s.length == 4) return {l:s[0], r:s[1], t:s[2], b:s[3]} + } + if (typeof s == 'object') return {l:s.l||0, r:s.r||0, t:s.t||0, b:s.b||0} + return {l:0, r:0, t:0, b:0} +} + +// Tree building state +var root_item +var tree_root +var config_stack = [] + +clay.layout = function(fn, size) { + lay_ctx.reset() + + var root = lay_ctx.item() + if (isa(size, array)) size = {width: size[0], height: size[1]} + + lay_ctx.set_size(root, size) + lay_ctx.set_contain(root, layout.contain.row) // Default root layout + + root_item = root + + var root_config = meme(base_config) + tree_root = { + id: root, + config: root_config, + children: [] + } + + config_stack = [root_config] + + fn() + + lay_ctx.run() + + // Post-layout: build scene tree + return build_scene_tree(tree_root, size.height) +} + +function build_scene_tree(node, root_height, parent_abs_x, parent_abs_y) { + parent_abs_x = parent_abs_x || 0 + parent_abs_y = parent_abs_y || 0 + + var rect = lay_ctx.get_rect(node.id) + + // Calculate absolute world Y for this node (bottom-up layout to top-down render) + // rect.y is from bottom + var abs_y = root_height - (rect.y + rect.height) + var abs_x = rect.x + + // Calculate relative position for the group + var rel_x = abs_x - parent_abs_x + var rel_y = abs_y - parent_abs_y + + // The node to return. It might be a group or a sprite/text depending on config. + var scene_node = { + type: 'group', + pos: {x: rel_x + node.config.offset.x, y: rel_y + node.config.offset.y}, + width: rect.width, + height: rect.height, + children: [] + } + + // Background + if (node.config.background_image) { + if (node.config.slice) { + scene_node.children.push({ + type: 'sprite', + image: node.config.background_image, + width: rect.width, + height: rect.height, + slice: node.config.slice, + color: node.config.background_color || {r:1, g:1, b:1, a:1}, + layer: -1 // Back + }) + } else { + scene_node.children.push({ + type: 'sprite', + image: node.config.background_image, + width: rect.width, + height: rect.height, + color: node.config.background_color || {r:1, g:1, b:1, a:1}, + layer: -1 + }) + } + } else if (node.config.background_color) { + scene_node.children.push({ + type: 'rect', + width: rect.width, + height: rect.height, + color: node.config.background_color, + layer: -1 + }) + } + + // Content (Image/Text) + if (node.config.image) { + scene_node.children.push({ + type: 'sprite', + image: node.config.image, + width: rect.width, // layout ensures aspect ratio if configured + height: rect.height, + color: node.config.color + }) + } + + if (node.config.text) { + scene_node.children.push({ + type: 'text', + text: node.config.text, + font: node.config.font_path, + size: node.config.font_size, + color: node.config.color, + pos: {x: 0, y: rect.height} // Text baseline relative to group + }) + } + + // Clipping + if (node.config.clipped) { + // Scissor needs absolute coordinates + // We can compute them from our current absolute position + scene_node.scissor = { + x: abs_x + node.config.offset.x, + y: abs_y + node.config.offset.y, + width: rect.width, + height: rect.height + } + } + + // Children + // Pass our absolute position as the new parent absolute position + var my_abs_x = abs_x + node.config.offset.x + var my_abs_y = abs_y + node.config.offset.y + + for (var child of node.children) { + var child_node = build_scene_tree(child, root_height, my_abs_x, my_abs_y) + scene_node.children.push(child_node) + } + + return scene_node +} + + +// --- Item Creation Helpers --- + +function add_item(config) { + var parent_config = config_stack[config_stack.length-1] + + // Merge parent spacing/padding/margin logic? + // clay.cm does normalizing and applying child_gap. + + var use_config = meme(base_config, config) + + var item = lay_ctx.item() + lay_ctx.set_margins(item, normalize_spacing(use_config.margin)) + lay_ctx.set_contain(item, use_config.contain) + lay_ctx.set_behave(item, use_config.behave) + + if (use_config.size) { + var s = use_config.size + if (isa(s, array)) s = {width: s[0], height: s[1]} + lay_ctx.set_size(item, s) + } + + var node = { + id: item, + config: use_config, + children: [] + } + + // Link to tree + // We need to know current parent node. + // Track `tree_stack` alongside `config_stack`? + // Or just `tree_node_stack`. + + // Let's fix the state tracking. +} + +// Rewriting state management for cleaner recursion +var tree_stack = [] + +clay.layout = function(fn, size) { + lay_ctx.reset() + var root_id = lay_ctx.item() + if (isa(size, array)) size = {width: size[0], height: size[1]} + + lay_ctx.set_size(root_id, size) + lay_ctx.set_contain(root_id, layout.contain.row) + + var root_node = { + id: root_id, + config: meme(base_config, {size: size}), + children: [] + } + + tree_stack = [root_node] + + fn() // User builds tree + + lay_ctx.run() + + return build_scene_tree(root_node, size.height) +} + +function process_configs(configs) { + // Merge array of configs from right to left (right overrides left) + // And merge with base_config + var res = meme(base_config) + for (var c of configs) { + if (c) res = meme(res, c) + } + return res +} + +function push_node(configs, contain_mode) { + var config = process_configs(configs) + if (contain_mode != null) config.contain = contain_mode + + var item = lay_ctx.item() + + // Apply layout props + lay_ctx.set_margins(item, normalize_spacing(config.margin)) + lay_ctx.set_contain(item, config.contain) + lay_ctx.set_behave(item, config.behave) + + if (config.size) { + var s = config.size + if (isa(s, array)) s = {width: s[0], height: s[1]} + lay_ctx.set_size(item, s) + } + + var node = { + id: item, + config: config, + children: [] + } + + // Add to parent + var parent = tree_stack[tree_stack.length-1] + parent.children.push(node) + lay_ctx.insert(parent.id, item) + + tree_stack.push(node) + return node +} + +function pop_node() { + tree_stack.pop() +} + +// Generic container +clay.container = function(configs, fn) { + if (typeof configs == 'function') { fn = configs; configs = {} } + if (!isa(configs, array)) configs = [configs] + + push_node(configs, null) + if (fn) fn() + pop_node() +} + +// Stacks +clay.vstack = function(configs, fn) { + if (typeof configs == 'function') { fn = configs; configs = {} } + if (!isa(configs, array)) configs = [configs] + + var c = layout.contain.column + // Check for alignment/justification in configs? + // Assume generic container props handle it via `contain` override or we defaults + + push_node(configs, c) + if (fn) fn() + pop_node() +} + +clay.hstack = function(configs, fn) { + if (typeof configs == 'function') { fn = configs; configs = {} } + if (!isa(configs, array)) configs = [configs] + + var c = layout.contain.row + push_node(configs, c) + if (fn) fn() + pop_node() +} + +clay.zstack = function(configs, fn) { + if (typeof configs == 'function') { fn = configs; configs = {} } + if (!isa(configs, array)) configs = [configs] + + // Stack (overlap) + // layout.contain.stack? layout.contain.overlap? + // 'layout' module usually defaults to overlap if not row/column? + // Or we just don't set row/column bit. + var c = layout.contain.layout // Just layout (no flow) + + push_node(configs, c) + if (fn) fn() + pop_node() +} + +// Leaf nodes +clay.image = function(path, ...configs) { + var img = graphics.texture(path) + var c = {image: path} + // Auto-size if not provided + // But we need to check if configs override it + var final_config = process_configs(configs) + if (!final_config.size && !final_config.behave) { // If no size and no fill behavior + c.size = {width: img.width, height: img.height} + } + + push_node([c, ...configs], null) + pop_node() +} + +clay.text = function(str, ...configs) { + var c = {text: str} + // Measuring + var final_config = process_configs(configs) + // measure text + var font = graphics.get_font(final_config.font_path) // or font cache + // Need to handle font object vs path string + // For now assume path string in config or default + + // This measurement is synchronous and might differ from GPU font rendering slightly + // but good enough for layout. + // We need to know max width for wrapping? + // 'layout' doesn't easily support "height depends on width" during single pass? + // We specify a fixed size or behave. + + // If no size specified, measure single line + if (!final_config.size && !final_config.behave) { + // Basic measurement + // Hack: use arbitrary width for now? + // Or we need a proper text measurement exposed. + // graphics.measure_text(font, text, size, break, align) + // Assume we have it or minimal version. + // clay.cm used `font.text_size` + + // We'll rely on font path to get a font object + // var f = graphics.get_font(final_config.font_path) ... + // c.size = ... + c.size = {width: 100, height: 20} // Fallback for now to avoid crashes + } + + push_node([c, ...configs], null) + pop_node() +} + +clay.rectangle = function(...configs) { + // Just a container with background color really, but as a leaf + push_node(configs, null) + pop_node() +} + +clay.button = function(str, action, ...configs) { + // Button is a container with text and click behavior + // For rendering, it's a zstack of background + text + + // We can just define it as a container with background styling + var btn_config = { + padding: 10, + background_color: {r:0.3, g:0.3, b:0.4, a:1} + } + + clay.zstack([btn_config, ...configs], function() { + clay.text(str, {color: {r:1,g:1,b:1,a:1}}) + }) +} + +// Constants +clay.behave = layout.behave +clay.contain = layout.contain + +return clay diff --git a/fx_graph.cm b/fx_graph.cm index 5a82e37d..b1106b8f 100644 --- a/fx_graph.cm +++ b/fx_graph.cm @@ -172,7 +172,15 @@ NODE_EXECUTORS.render_view = function(params, backend) { // 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', @@ -393,7 +401,7 @@ NODE_EXECUTORS.shader_pass = function(params, backend) { // SCENE TREE TRAVERSAL // ======================================================================== -function collect_drawables(node, camera, parent_tint, parent_opacity) { +function collect_drawables(node, camera, parent_tint, parent_opacity, parent_scissor, parent_pos) { if (!node) return [] parent_tint = parent_tint || [1, 1, 1, 1] @@ -401,6 +409,14 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) { 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 = [ @@ -411,16 +427,29 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) { ] var world_opacity = parent_opacity * (node.opacity != null ? node.opacity : 1) - // Handle different node types + // 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 pos = node.pos || {x: 0, y: 0} - var px = pos.x != null ? pos.x : (pos[0] || 0) - var py = pos.y != null ? pos.y : (pos[1] || 0) + var px = abs_x + var py = abs_y var w = node.width || 1 var h = node.height || 1 var ax = node.anchor_x || 0 @@ -442,7 +471,8 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) { anchor_y: 0, uv_rect: uv, color: tint, - material: node.material + material: node.material, + scissor: current_scissor }) } @@ -536,7 +566,7 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) { type: 'sprite', layer: node.layer || 0, world_y: py, - pos: pos, + pos: {x: abs_x, y: abs_y}, image: node.image, texture: node.texture, width: w, @@ -550,40 +580,44 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) { } if (node.type == 'text') { - var pos = node.pos || {x: 0, y: 0} drawables.push({ type: 'text', layer: node.layer || 0, - world_y: pos.y != null ? pos.y : (pos[1] || 0), - pos: pos, + world_y: abs_y, + pos: {x: abs_x, y: abs_y}, text: node.text, font: node.font, size: node.size, - color: tint_to_color(world_tint, world_opacity) + color: tint_to_color(world_tint, world_opacity), + scissor: current_scissor }) } if (node.type == 'rect') { - var pos = node.pos || {x: 0, y: 0} drawables.push({ type: 'rect', layer: node.layer || 0, - world_y: pos.y != null ? pos.y : (pos[1] || 0), - pos: pos, + 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) + 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: p.pos ? p.pos.y : 0, - pos: p.pos || {x: 0, y: 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), @@ -591,7 +625,8 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) { anchor_x: 0.5, anchor_y: 0.5, color: p.color || tint_to_color(world_tint, world_opacity), - material: node.material + material: node.material, + scissor: current_scissor }) } } @@ -610,8 +645,10 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) { var tile = tiles[x][y] if (!tile) continue - var world_x = (x + offset_x) * scale_x - var world_y_pos = (y + offset_y) * scale_y + // 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', @@ -624,8 +661,10 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) { height: scale_y, anchor_x: 0, anchor_y: 0, + anchor_y: 0, color: tint_to_color(world_tint, world_opacity), - material: node.material + material: node.material, + scissor: current_scissor }) } } @@ -634,7 +673,7 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) { // Recurse children if (node.children) { for (var child of node.children) { - var child_drawables = collect_drawables(child, camera, world_tint, world_opacity) + var child_drawables = collect_drawables(child, camera, world_tint, world_opacity, current_scissor, current_pos) drawables = drawables.concat(child_drawables) } } @@ -663,17 +702,20 @@ function batch_drawables(drawables) { if (drawable.type == 'sprite') { var texture = drawable.texture || drawable.image var material = drawable.material || {blend: 'alpha', sampler: 'nearest'} - - // Start new batch if texture/material changed + 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: [] } } @@ -685,7 +727,7 @@ function batch_drawables(drawables) { batches.push(current_batch) current_batch = null } - batches.push({type: drawable.type, drawable: drawable}) + batches.push({type: drawable.type, drawable: drawable, scissor: drawable.scissor}) } } @@ -694,6 +736,12 @@ function batch_drawables(drawables) { 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 diff --git a/sdl_gpu.cm b/sdl_gpu.cm index 131c6320..46ebe120 100644 --- a/sdl_gpu.cm +++ b/sdl_gpu.cm @@ -668,7 +668,7 @@ function _build_sprite_vertices(sprites, camera) { vertex_data.wf(x) vertex_data.wf(y) vertex_data.wf(u0) - vertex_data.wf(v0) + vertex_data.wf(v1) // Flip V vertex_data.wf(c.r) vertex_data.wf(c.g) vertex_data.wf(c.b) @@ -678,7 +678,7 @@ function _build_sprite_vertices(sprites, camera) { vertex_data.wf(x + w) vertex_data.wf(y) vertex_data.wf(u1) - vertex_data.wf(v0) + vertex_data.wf(v1) // Flip V vertex_data.wf(c.r) vertex_data.wf(c.g) vertex_data.wf(c.b) @@ -688,7 +688,7 @@ function _build_sprite_vertices(sprites, camera) { vertex_data.wf(x + w) vertex_data.wf(y + h) vertex_data.wf(u1) - vertex_data.wf(v1) + vertex_data.wf(v0) // Flip V vertex_data.wf(c.r) vertex_data.wf(c.g) vertex_data.wf(c.b) @@ -698,7 +698,7 @@ function _build_sprite_vertices(sprites, camera) { vertex_data.wf(x) vertex_data.wf(y + h) vertex_data.wf(u0) - vertex_data.wf(v1) + vertex_data.wf(v0) // Flip V vertex_data.wf(c.r) vertex_data.wf(c.g) vertex_data.wf(c.b) @@ -1364,8 +1364,8 @@ function _do_mask(cmd_buffer, cmd) { uniform_data.wf(0) // padding uniform_data.wf(0) // padding - // Render pass - var pass = cmd_buffer.render_pass({ + // Render to output + var mask_pass = cmd_buffer.render_pass({ color_targets: [{ texture: output.texture, load: "clear", @@ -1374,17 +1374,17 @@ function _do_mask(cmd_buffer, cmd) { }] }) - pass.bind_pipeline(_pipelines.mask) - pass.bind_vertex_buffers(0, [{buffer: vb, offset: 0}]) - pass.bind_index_buffer({buffer: ib, offset: 0}, 16) + mask_pass.bind_pipeline(_pipelines.mask) + mask_pass.bind_vertex_buffers(0, [{buffer: vb, offset: 0}]) + mask_pass.bind_index_buffer({buffer: ib, offset: 0}, 16) // Bind both content texture (slot 0) and mask texture (slot 1) - pass.bind_fragment_samplers(0, [ + mask_pass.bind_fragment_samplers(0, [ {texture: content.texture, sampler: _sampler_nearest}, {texture: mask.texture, sampler: _sampler_nearest} ]) cmd_buffer.push_fragment_uniform_data(0, stone(uniform_data)) - pass.draw_indexed(6, 1, 0, 0, 0) - pass.end() + mask_pass.draw_indexed(6, 1, 0, 0, 0) + mask_pass.end() } function _do_shader_pass(cmd_buffer, cmd) {