sprite vert

This commit is contained in:
2025-12-29 20:46:42 -06:00
parent 837659fcdc
commit 813d4e771c
8 changed files with 511 additions and 866 deletions

View File

@@ -244,13 +244,7 @@ clay.layout = function(fn, size) {
}
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
return meme(base_config, ...configs)
}
function push_node(configs, contain_mode) {

View File

@@ -1,120 +1,108 @@
// compositor.cm - High-level compositing API
// compositor.cm - Unified Compositor (Rewritten)
//
// The compositor orchestrates renderers (film2d, forward3d, imgui...) to produce a final image.
// It handles:
// - Renderer selection per layer (film2d, forward3d, imgui, retro3d...)
// - Camera + viewport binding per pass
// - Target ownership (composition root, fixed resolution, effect islands)
// - Presentation modes (stretch, letterbox, integer_scale, etc.)
// - Global post effects (CRT, etc.)
// - Layer stacking order
// The compositor is the SINGLE OWNER of all effects.
// It takes scene descriptions and produces abstract render plans.
//
// Architecture:
// Scene Tree (Retained) -> Compositor (Effect orchestration) -> Render Plan -> Backend
//
// The compositor does NOT know about sprites/tilemaps/text - that's renderer territory.
// It only knows about plates, targets, viewports, and post-processing.
// It only knows about plates, targets, viewports, effects, and post-processing.
// ========================================================================
// PRESENTATION MODES (how child targets map into parent targets)
// ========================================================================
// "disabled" : 1:1 mapping, no scaling
// "stretch" : stretch to fill parent rect
// "letterbox" : fit by largest dimension; bars in clear color
// "overscan" : fit by smallest dimension; overflow clipped
// "integer_scale" : scale by integer multiples; nearest sampling
// ========================================================================
// BLEND MODES (backend-agnostic)
// ========================================================================
// "replace" : overwrite destination
// "over" : alpha over (default)
// "add" : additive
// ========================================================================
// RENDERER CAPABILITIES (queried during compilation)
// ========================================================================
// Each renderer advertises what it can do inline vs requiring an island:
// - supports_mask_stencil: can do hard masks via stencil (no target needed)
// - supports_mask_alpha: can do soft masks (needs target)
// - supports_bloom: can do bloom effect
// - supports_blur: can do blur effect
var effects_mod = use('effects')
var compositor = {}
// Default renderer capabilities (conservative - requires islands for everything)
var DEFAULT_CAPS = {
supports_mask_stencil: true,
supports_mask_alpha: true,
supports_bloom: true,
supports_blur: true
// Presentation modes
compositor.PRESENTATION = {
DISABLED: 'disabled',
STRETCH: 'stretch',
LETTERBOX: 'letterbox',
OVERSCAN: 'overscan',
INTEGER_SCALE: 'integer_scale'
}
// ========================================================================
// COMPILATION: Turn composition tree into render plan
// ========================================================================
// Blend modes
compositor.BLEND = {
REPLACE: 'replace',
OVER: 'over',
ADD: 'add'
}
// Compile a composition into a render plan
// comp: composition description (the DSL)
// renderers: map of renderer_type -> renderer module
// backend: the render backend (sdl_gpu, etc.)
// Compile a composition tree into a render plan
compositor.compile = function(comp, renderers, backend) {
var ctx = {
renderers: renderers,
backend: backend,
passes: [],
targets: {},
persistent_targets: {},
target_counter: 0,
imgui_node: null
screen_size: null,
target_size: null,
// Target allocation
alloc_target: function(w, h, 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}
},
// 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}
}
}
// Determine window/screen size from backend
ctx.screen_size = backend.get_window_size() || {width: 1280, height: 720}
// 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
// Compile the composition tree
var result = compile_node(comp, ctx, null, ctx.screen_size)
// 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)
return {
passes: ctx.passes,
targets: ctx.targets,
final_output: result.output
persistent_targets: ctx.persistent_targets,
screen_size: ctx.screen_size
}
}
// Compile a single node in the composition tree
function compile_node(node, ctx, parent_target, parent_size) {
// Compile a single node
function _compile_node(node, ctx, parent_target, parent_size) {
if (!node) return {output: null}
var node_type = node.type
// Determine if this node owns a target
var owns_target = false
var target = parent_target
var target_size = parent_size
// Target ownership rules:
// 1. Composition root (target: "screen")
// 2. Node has explicit resolution
// 3. Node has effects that require isolation
var target_size = {w: parent_size.w || parent_size.width, h: parent_size.h || parent_size.height}
// Determine target ownership
if (node.target == 'screen') {
owns_target = true
target = 'screen'
target_size = ctx.screen_size
target_size = {w: ctx.screen_size.w, h: ctx.screen_size.h}
} else if (node.resolution) {
owns_target = true
target_size = {width: node.resolution.w || node.resolution.width || ctx.screen_size.width, height: node.resolution.h || node.resolution.height || ctx.screen_size.height}
target = allocate_target(ctx, target_size, node.name || 'res_target')
} else if (node.effects && effects_require_island(node.effects, ctx)) {
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')
} else if (node.effects && _effects_require_target(node.effects, ctx)) {
owns_target = true
target_size = parent_size || ctx.screen_size
target = allocate_target(ctx, target_size, node.name || 'effect_island')
} else if (node.pos) {
owns_target = true
target_size = parent_size || ctx.screen_size
target = allocate_target(ctx, target_size, node.name || 'translated_target')
target = ctx.alloc_target(target_size.w, target_size.h, node.name || 'effect_target')
}
// Handle clear for target owners
ctx.target_size = target_size
// Handle clear
if (owns_target && node.clear) {
ctx.passes.push({
type: 'clear',
@@ -123,21 +111,14 @@ function compile_node(node, ctx, parent_target, parent_size) {
})
}
// Process based on node type
// Process by type
if (node_type == 'composition') {
// Root composition - process layers
return compile_composition(node, ctx, target, target_size)
return _compile_composition(node, ctx, target, target_size)
} else if (node_type == 'group') {
// Group with potential effects - process layers
return compile_group(node, ctx, target, target_size, parent_target, parent_size)
} else if (is_renderer_type(node_type)) {
// Renderer layer (film2d, forward3d, imgui, etc.)
return compile_renderer_layer(node, ctx, target, target_size, parent_target, parent_size)
return _compile_group(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') {
if (ctx.imgui_node) {
throw new Error("Only one imgui node is allowed in a composition")
}
ctx.imgui_node = node
ctx.passes.push({
type: 'imgui',
target: target,
@@ -151,57 +132,49 @@ function compile_node(node, ctx, parent_target, parent_size) {
return {output: target}
}
// Compile a composition (root or nested)
function compile_composition(node, ctx, target, target_size) {
// Compile composition root
function _compile_composition(node, ctx, target, target_size) {
var layers = node.layers || []
for (var i = 0; i < layers.length; i++) {
var layer = layers[i]
var is_first = (i == 0)
// Set default blend based on position
if (!layer.blend) {
layer.blend = is_first ? 'replace' : 'over'
layer.blend = (i == 0) ? 'replace' : 'over'
}
compile_node(layer, ctx, target, target_size)
_compile_node(layer, ctx, target, target_size)
}
return {output: target}
}
// Compile a group (may have effects)
function compile_group(node, ctx, target, target_size, parent_target, parent_size) {
// Compile group with effects
function _compile_group(node, ctx, target, target_size, parent_target, parent_size) {
var layers = node.layers || []
var original_target = target
// Process child layers into this target
// Process child layers
for (var i = 0; i < layers.length; i++) {
var layer = layers[i]
var is_first = (i == 0)
if (!layer.blend) {
layer.blend = is_first ? 'replace' : 'over'
layer.blend = (i == 0) ? 'replace' : 'over'
}
compile_node(layer, ctx, target, target_size)
_compile_node(layer, ctx, target, target_size)
}
// Apply effects if any
if (node.effects) {
for (var effect of node.effects) {
effect._node_id = node.name || `node_${ctx.target_counter}`
target = compile_effect(effect, ctx, target, target_size)
// Apply effects - this is where the compositor owns all effect logic
if (node.effects && node.effects.length > 0) {
for (var j = 0; j < node.effects.length; j++) {
var effect = node.effects[j]
target = _compile_effect(effect, ctx, target, target_size, node.name || ('group_' + ctx.target_counter))
}
}
// If we allocated our own target (or effects changed target), composite back to parent
// 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 is screen, use blit_to_screen pass type
if (parent_target == 'screen') {
ctx.passes.push({
type: 'blit_to_screen',
@@ -228,28 +201,28 @@ function compile_group(node, ctx, target, target_size, parent_target, parent_siz
return {output: target}
}
// Compile a renderer layer (film2d, forward3d, etc.)
function compile_renderer_layer(node, ctx, target, target_size, parent_target, parent_size) {
// Compile renderer layer (film2d, forward3d, 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 type: ${renderer_type}`)
log.console(`compositor: Unknown renderer: ${renderer_type}`)
return {output: target}
}
// Determine if this layer owns its own 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 = {width: node.resolution.w, height: node.resolution.h}
layer_target = allocate_target(ctx, layer_size, node.name || renderer_type + '_target')
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')
}
// Handle clear for target owners
// Clear if we own target
if (owns_target && node.clear) {
ctx.passes.push({
type: 'clear',
@@ -270,7 +243,7 @@ function compile_renderer_layer(node, ctx, target, target_size, parent_target, p
clear: owns_target ? node.clear : null
})
// If we have our own target, composite back to parent
// Composite back to parent
if (owns_target && parent_target) {
var presentation = node.presentation || 'disabled'
var blend = node.blend || 'over'
@@ -290,290 +263,128 @@ function compile_renderer_layer(node, ctx, target, target_size, parent_target, p
return {output: layer_target}
}
// Compile an effect
function compile_effect(effect, ctx, input_target, target_size) {
// 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_type == 'crt') {
var output = allocate_target(ctx, target_size, 'crt_output')
ctx.passes.push({
type: 'shader_pass',
shader: 'crt',
input: input_target,
output: output,
uniforms: {
curvature: effect.curvature || 0.1,
scanline_intensity: effect.scanline_intensity || 0.3,
vignette: effect.vignette || 0.2,
resolution: [target_size.width, target_size.height]
}
})
return output
if (!effect_def) {
log.console(`compositor: Unknown effect: ${effect_type}`)
return input_target
}
if (effect_type == 'bloom') {
return compile_bloom_effect(effect, ctx, input_target, target_size)
}
if (effect_type == 'mask') {
return compile_mask_effect(effect, ctx, input_target, target_size)
}
if (effect_type == 'blur') {
return compile_blur_effect(effect, ctx, input_target, target_size)
}
if (effect_type == 'accumulator') {
return compile_accumulator_effect(effect, ctx, input_target, target_size)
}
return input_target
}
// Compile bloom effect (threshold -> blur passes -> composite)
function compile_bloom_effect(effect, ctx, input_target, target_size) {
var threshold = effect.threshold || 0.8
var intensity = effect.intensity || 1.0
var blur_passes = effect.blur_passes || 3
// Threshold pass
var threshold_output = allocate_target(ctx, target_size, 'bloom_threshold')
ctx.passes.push({
type: 'shader_pass',
shader: 'threshold',
input: input_target,
output: threshold_output,
uniforms: {threshold: threshold, intensity: intensity}
})
// Blur passes
var blur_src = threshold_output
var texel_size = [1/target_size.width, 1/target_size.height]
for (var i = 0; i < blur_passes; i++) {
// Horizontal blur
var blur_h = allocate_target(ctx, target_size, 'bloom_blur_h_' + i)
ctx.passes.push({
type: 'shader_pass',
shader: 'blur',
input: blur_src,
output: blur_h,
uniforms: {direction: [2, 0], texel_size: texel_size}
})
// Vertical blur
var blur_v = allocate_target(ctx, target_size, 'bloom_blur_v_' + i)
ctx.passes.push({
type: 'shader_pass',
shader: 'blur',
input: blur_h,
output: blur_v,
uniforms: {direction: [0, 2], texel_size: texel_size}
})
blur_src = blur_v
}
// Composite bloom back onto input
var output = allocate_target(ctx, target_size, 'bloom_output')
ctx.passes.push({
type: 'composite_textures',
base: input_target,
overlay: blur_src,
output: output,
mode: 'add'
})
return output
}
// Compile mask effect
// Optimization: use stencil for hard masks (soft: false), texture for soft masks
function compile_mask_effect(effect, ctx, input_target, target_size) {
var soft = effect.soft || false
var source = effect.source
var mode = effect.mode || 'alpha'
var space = effect.space || 'local'
var invert = effect.invert || false
// For hard masks, we could use stencil (no extra target needed)
// For now, always use texture mask approach for simplicity
// TODO: implement stencil path for hard masks
if (!soft) {
// Hard mask - could use stencil, but for now use texture
// This is where stencil optimization would go
}
// Texture mask approach (works for both soft and hard)
var mask_target = allocate_target(ctx, target_size, 'mask_source')
// Render mask source to its own target
ctx.passes.push({
type: 'render_mask_source',
source: source,
target: mask_target,
target_size: target_size,
space: space
})
// Apply mask
var output = allocate_target(ctx, target_size, 'masked_output')
ctx.passes.push({
type: 'apply_mask',
content: input_target,
mask: mask_target,
output: output,
mode: mode,
invert: invert
})
return output
}
// Compile blur effect
function compile_blur_effect(effect, ctx, input_target, target_size) {
var passes = effect.passes || 2
var texel_size = [1/target_size.width, 1/target_size.height]
var src = input_target
for (var i = 0; i < passes; i++) {
var blur_h = allocate_target(ctx, target_size, 'blur_h_' + i)
ctx.passes.push({
type: 'shader_pass',
shader: 'blur',
input: src,
output: blur_h,
uniforms: {direction: [2, 0], texel_size: texel_size}
})
var blur_v = allocate_target(ctx, target_size, 'blur_v_' + i)
ctx.passes.push({
type: 'shader_pass',
shader: 'blur',
input: blur_h,
output: blur_v,
uniforms: {direction: [0, 2], texel_size: texel_size}
})
src = blur_v
}
return src
}
// Compile accumulator effect (motion blur)
function compile_accumulator_effect(effect, ctx, input_target, target_size) {
var decay = effect.decay != null ? effect.decay : 0.9
var node_id = effect._node_id || ctx.target_counter++
// Create persistent targets for ping-ponging
// We use stable keys based on node_id to ensure they persist across frames in the backend
var accum_prev_key = `accum_prev_${node_id}`
var accum_curr_key = `accum_curr_${node_id}`
var accum_prev = {type: 'target', key: accum_prev_key, width: target_size.width, height: target_size.height, persistent: true}
var accum_curr = {type: 'target', key: accum_curr_key, width: target_size.width, height: target_size.height, persistent: true}
// Register them in the plan so the backend knows to create them
ctx.targets[accum_prev_key] = {width: target_size.width, height: target_size.height, name: accum_prev_key, persistent: true}
ctx.targets[accum_curr_key] = {width: target_size.width, height: target_size.height, name: accum_curr_key, persistent: true}
// Accumulation pass: curr = max(input, prev * decay)
ctx.passes.push({
type: 'shader_pass',
shader: 'accumulator',
input: input_target,
extra_inputs: [accum_prev],
output: accum_curr,
uniforms: {decay: decay}
})
// Feedback pass: copy curr back to prev for next frame
ctx.passes.push({
type: 'composite',
source: accum_curr,
dest: accum_prev,
source_size: target_size,
dest_size: target_size,
presentation: 'disabled'
})
return accum_curr
}
// ========================================================================
// HELPERS
// ========================================================================
function allocate_target(ctx, size, name) {
name = name || 'target'
size = size || ctx.screen_size || {width: 1280, height: 720}
var w = size.width || (size.w) || 1280
var h = size.height || (size.h) || 720
var key = name + '_' + text(ctx.target_counter++)
ctx.targets[key] = {
width: w,
height: h,
name: key
}
return {type: 'target', key: key, width: w, height: h}
}
function is_renderer_type(type) {
return type == 'film2d' || type == 'forward3d' || type == 'imgui' || type == 'retro3d' || type == 'simple2d'
}
function effects_require_island(effects, ctx) {
// Check if any effect requires an offscreen target
for (var effect of effects) {
var type = effect.type
// Most effects require islands
if (type == 'bloom' || type == 'blur' || type == 'crt') return true
// Mask requires island unless we can use stencil
if (type == 'mask') {
var soft = effect.soft || false
if (soft) return true
// Hard mask could use stencil - check renderer caps
// For now, always require island
return true
// 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)
},
get_persistent_target: function(key, w, h) {
return ctx.get_persistent_target(node_id + '_' + key, w, h)
}
}
// Allocate output target
var output = ctx.alloc_target(target_size.w, target_size.h, effect_type + '_out')
// Build effect passes
var effect_passes = effect_def.build_passes(input_target, output, effect, effect_ctx)
// Convert effect passes to compositor passes
for (var i = 0; i < effect_passes.length; i++) {
var ep = effect_passes[i]
ctx.passes.push(_convert_effect_pass(ep, ctx))
}
return output
}
// Convert effect pass to compositor pass format
function _convert_effect_pass(ep, ctx) {
switch (ep.type) {
case 'shader':
if (!ep.input && !ep.inputs) {
ep.input = ep.inputs[0]
}
return {
type: 'shader_pass',
shader: ep.shader,
input: ep.input || ep.inputs[0],
extra_inputs: ep.inputs ? ep.inputs.slice(1) : [],
output: ep.output,
uniforms: ep.uniforms
}
case 'composite':
return {
type: 'composite_textures',
base: ep.base,
overlay: ep.overlay,
output: ep.output,
mode: ep.blend || 'over'
}
case 'blit':
return {
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,
presentation: 'disabled'
}
case 'render_subtree':
return {
type: 'render_mask_source',
source: ep.root,
target: ep.output,
target_size: {w: ep.output.w, h: ep.output.h},
space: ep.space || 'local'
}
default:
return ep
}
}
// Check if effects require a target
function _effects_require_target(effects, ctx) {
for (var i = 0; i < effects.length; i++) {
var effect = effects[i]
if (effects_mod.requires_target(effect.type)) return true
}
return false
}
// ========================================================================
// PRESENTATION HELPERS
// ========================================================================
// Check if type is a renderer
function _is_renderer_type(type) {
return type == 'film2d' || type == 'forward3d' || type == 'imgui' || type == 'retro3d' || type == 'simple2d'
}
// Calculate destination rect for presenting source into dest
// Calculate presentation rect
compositor.calculate_presentation_rect = function(source_size, dest_size, mode) {
var sw = source_size.width
var sh = source_size.height
var dw = dest_size.width
var dh = dest_size.height
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
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}
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 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 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}
@@ -588,22 +399,24 @@ compositor.calculate_presentation_rect = function(source_size, dest_size, mode)
return {x: (dw - w) / 2, y: (dh - h) / 2, width: w, height: h}
}
// Default: no scaling
return {x: 0, y: 0, width: sw, height: sh}
}
// ========================================================================
// PLAN EXECUTION
// ========================================================================
// Execute a compiled render plan
compositor.execute = function(plan, renderers, backend) {
var target_cache = {}
// Pre-allocate all targets
// Pre-allocate targets
for (var key in plan.targets) {
var spec = plan.targets[key]
target_cache[key] = backend.get_or_create_target(spec.width, spec.height, key)
target_cache[key] = backend.get_or_create_target(spec.w, spec.h, 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)
}
}
// Resolve target references
@@ -616,18 +429,21 @@ compositor.execute = function(plan, renderers, backend) {
// Execute passes
var commands = []
for (var pass of plan.passes) {
var pass_commands = execute_pass(pass, renderers, backend, resolve_target)
for (var cmd of pass_commands) {
commands.push(cmd)
for (var i = 0; i < plan.passes.length; i++) {
var pass = plan.passes[i]
var pass_cmds = _execute_pass(pass, renderers, backend, resolve_target, compositor)
for (var j = 0; j < pass_cmds.length; j++) {
commands.push(pass_cmds[j])
}
}
return {commands: commands}
}
function execute_pass(pass, renderers, backend, resolve_target) {
// Execute a single pass
function _execute_pass(pass, renderers, backend, resolve_target, comp) {
var commands = []
var source
switch (pass.type) {
case 'clear':
@@ -640,7 +456,7 @@ function execute_pass(pass, renderers, backend, resolve_target) {
var renderer = renderers[pass.renderer]
if (renderer && renderer.render) {
var target = resolve_target(pass.target)
var render_result = renderer.render({
var result = renderer.render({
root: pass.root,
camera: pass.camera,
target: target,
@@ -648,9 +464,9 @@ function execute_pass(pass, renderers, backend, resolve_target) {
blend: pass.blend,
clear: pass.clear
}, backend)
if (render_result && render_result.commands) {
for (var cmd of render_result.commands) {
commands.push(cmd)
if (result && result.commands) {
for (var i = 0; i < result.commands.length; i++) {
commands.push(result.commands[i])
}
}
}
@@ -659,10 +475,10 @@ function execute_pass(pass, renderers, backend, resolve_target) {
case 'shader_pass':
var input = resolve_target(pass.input)
var output = resolve_target(pass.output)
var extra_inputs = []
var extra = []
if (pass.extra_inputs) {
for (var t of pass.extra_inputs) {
extra_inputs.push(resolve_target(t))
for (var i = 0; i < pass.extra_inputs.length; i++) {
extra.push(resolve_target(pass.extra_inputs[i]))
}
}
commands.push({
@@ -670,15 +486,15 @@ function execute_pass(pass, renderers, backend, resolve_target) {
shader: pass.shader,
input: input,
output: output,
extra_inputs: extra_inputs,
extra_inputs: extra,
uniforms: pass.uniforms
})
break
case 'composite':
var source = resolve_target(pass.source)
case 'composite': {
source = resolve_target(pass.source)
var dest = resolve_target(pass.dest)
var rect = compositor.calculate_presentation_rect(pass.source_size, pass.dest_size, pass.presentation)
var rect = comp.calculate_presentation_rect(pass.source_size, pass.dest_size, pass.presentation)
if (pass.pos) {
rect.x += (pass.pos.x || 0)
rect.y += (pass.pos.y || 0)
@@ -691,11 +507,12 @@ function execute_pass(pass, renderers, backend, resolve_target) {
dst_rect: rect,
filter: filter
})
}
break
case 'blit_to_screen':
var source = resolve_target(pass.source)
var rect = compositor.calculate_presentation_rect(pass.source_size, pass.dest_size, pass.presentation)
case 'blit_to_screen': {
source = resolve_target(pass.source)
var rect = comp.calculate_presentation_rect(pass.source_size, pass.dest_size, pass.presentation)
if (pass.pos) {
rect.x += (pass.pos.x || 0)
rect.y += (pass.pos.y || 0)
@@ -708,9 +525,10 @@ function execute_pass(pass, renderers, backend, resolve_target) {
dst_rect: rect,
filter: filter
})
}
break
case 'composite_textures':
case 'composite_textures': {
var base = resolve_target(pass.base)
var overlay = resolve_target(pass.overlay)
var output = resolve_target(pass.output)
@@ -721,9 +539,10 @@ function execute_pass(pass, renderers, backend, resolve_target) {
output: output,
mode: pass.mode || 'over'
})
}
break
case 'apply_mask':
case 'apply_mask': {
var content = resolve_target(pass.content)
var mask = resolve_target(pass.mask)
var output = resolve_target(pass.output)
@@ -735,37 +554,38 @@ function execute_pass(pass, renderers, backend, resolve_target) {
mode: pass.mode,
invert: pass.invert
})
}
break
case 'imgui':
case 'imgui': {
commands.push({
cmd: 'imgui',
draw: pass.draw,
rect: pass.rect,
target: resolve_target(pass.target)
})
}
break
case 'render_mask_source':
// This would render the mask source node
// For now, delegate to film2d renderer
case 'render_mask_source': {
var target = resolve_target(pass.target)
var renderer = renderers['film2d']
if (renderer && renderer.render) {
var render_result = renderer.render({
var result = renderer.render({
root: pass.source,
camera: {pos: [0, 0], width: pass.target_size.width, height: pass.target_size.height, anchor: [0, 0], ortho: true},
camera: {pos: [0, 0], width: pass.target_size.w, height: pass.target_size.h, anchor: [0, 0], ortho: true},
target: target,
target_size: pass.target_size,
blend: 'replace',
clear: {r: 0, g: 0, b: 0, a: 0}
}, backend)
if (render_result && render_result.commands) {
for (var cmd of render_result.commands) {
commands.push(cmd)
if (result && result.commands) {
for (var i = 0; i < result.commands.length; i++) {
commands.push(result.commands[i])
}
}
}
}
break
}

444
film2d.cm
View File

@@ -1,38 +1,25 @@
// film2d.cm - 2D Scene Renderer
// film2d.cm - 2D Scene Renderer (Rewritten)
//
// Handles scene tree traversal, sorting, batching, and draw command emission.
// This is the "how to draw a plate" module - it knows about sprites, tilemaps,
// text, particles, etc.
// This is the "how to draw a 2D plate" module.
//
// The compositor calls this renderer with (root, camera, target, viewport, defaults)
// The compositor calls this renderer with (root, camera, target, target_size)
// and it produces draw commands.
//
// Scene-level effects (bloom, mask, blur on groups) are handled here:
// - Effects that can be done inline (stencil mask) are done without extra targets
// - Effects that need isolation (bloom, soft mask) create "islands" - render subtree
// to intermediate target, apply effect, composite back
//
// This module does NOT know about:
// - Global post effects (CRT over entire composition - that's compositor territory)
// - Other renderers (forward3d, imgui, etc.)
// This module does NOT handle effects - that's compositor territory.
// It only knows about sprites, tilemaps, text, particles, and rects.
var film2d = {}
// Renderer capabilities - what this renderer can do inline vs requiring islands
// Renderer capabilities
film2d.capabilities = {
supports_mask_stencil: true, // Hard masks via stencil (no target needed)
supports_mask_alpha: true, // Soft masks (needs target)
supports_bloom: true, // Bloom effect (needs target)
supports_blur: true // Blur effect (needs target)
supports_mask_stencil: true,
supports_mask_alpha: true,
supports_bloom: true,
supports_blur: true
}
// ========================================================================
// MAIN RENDER FUNCTION
// ========================================================================
// Render a scene tree to a target
// params: {root, camera, target, target_size, blend, clear}
// backend: the render backend (sdl_gpu, etc.)
// Main render function
film2d.render = function(params, backend) {
var root = params.root
var camera = params.camera
@@ -42,36 +29,31 @@ film2d.render = function(params, backend) {
if (!root) return {commands: []}
// Context for effect processing
var ctx = {
backend: backend,
camera: camera,
target_size: target_size,
commands: [],
target_counter: 0
}
// Process scene tree, handling effects on groups
var drawables = process_scene_tree(root, camera, ctx)
// Collect all drawables from scene tree
var drawables = _collect_drawables(root, camera, null, null, null, null)
// Sort by layer, then by Y for depth sorting
log.console(drawables.length)
drawables.sort(function(a, b) {
if (a.layer != b.layer) return a.layer - b.layer
var difflayer = a.layer - b.layer
if (difflayer != 0) return difflayer
return b.world_y - a.world_y
})
// Build render commands
var commands = ctx.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 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)) {
for (var i = 0; i < batches.length; i++) {
var batch = batches[i]
// Emit scissor if changed
if (!_rect_equal(current_scissor, batch.scissor)) {
commands.push({cmd: 'scissor', rect: batch.scissor})
current_scissor = batch.scissor
}
@@ -102,15 +84,6 @@ film2d.render = function(params, backend) {
texture: batch.texture,
material: batch.material
})
} else if (batch.type == 'blit_target') {
// Effect island result - blit the target texture
commands.push({
cmd: 'blit',
texture: batch.drawable.target,
target: target,
dst_rect: {x: 0, y: 0, width: batch.drawable.width, height: batch.drawable.height},
filter: 'linear'
})
}
}
@@ -119,275 +92,20 @@ film2d.render = function(params, backend) {
return {target: target, commands: commands}
}
// Process scene tree, handling effects on groups
// Returns drawables for the current level, may emit commands for effect islands
function process_scene_tree(node, camera, ctx, parent_tint, parent_opacity, parent_scissor, parent_pos) {
// Collect drawables from scene tree
function _collect_drawables(node, camera, parent_tint, parent_opacity, parent_scissor, parent_pos) {
if (!node) return []
// Check if this node has effects that need special handling
if (node.effects && node.effects.length > 0) {
return process_effect_group(node, camera, ctx, parent_tint, parent_opacity, parent_scissor, parent_pos)
}
// No effects - collect drawables normally
return collect_drawables(node, camera, parent_tint, parent_opacity, parent_scissor, parent_pos, ctx)
}
// Process a group with effects - creates an "island" and applies effects sequentially
function process_effect_group(node, camera, ctx, parent_tint, parent_opacity, parent_scissor, parent_pos) {
var effects = node.effects
var backend = ctx.backend
var target_size = ctx.target_size
// 1. Render all children to an initial content target
var current_target = backend.get_or_create_target(target_size.width, target_size.height, 'effect_start_' + ctx.target_counter++)
var child_drawables = collect_children_drawables(node, camera, parent_tint, parent_opacity, parent_scissor, parent_pos, ctx)
render_drawables_to_target(child_drawables, current_target, camera, ctx)
// 2. Apply effects sequentially, each one consuming the previous target and producing a new one
for (var effect of effects) {
var effect_type = effect.type
if (effect_type == 'bloom') {
current_target = apply_bloom_effect(current_target, effect, ctx)
} else if (effect_type == 'mask') {
current_target = apply_mask_effect(current_target, effect, ctx)
} else if (effect_type == 'blur') {
current_target = apply_blur_effect(current_target, effect, ctx)
}
}
// 3. Return a single drawable that blits the final result
return [{
type: 'blit_target',
layer: node.layer || 0,
world_y: 0,
target: current_target,
width: target_size.width,
height: target_size.height
}]
}
// Helper: Render a list of drawables to a specific target
function render_drawables_to_target(drawables, target, camera, ctx) {
ctx.commands.push({cmd: 'begin_render', target: target, clear: {r: 0, g: 0, b: 0, a: 0}})
ctx.commands.push({cmd: 'set_camera', camera: camera})
var batches = batch_drawables(drawables)
for (var batch of batches) {
if (batch.type == 'sprite_batch') {
ctx.commands.push({
cmd: 'draw_batch',
batch_type: 'sprites',
geometry: {sprites: batch.sprites},
texture: batch.texture,
material: batch.material
})
} else if (batch.type == 'text') {
ctx.commands.push({cmd: 'draw_text', drawable: batch.drawable})
} else if (batch.type == 'particles') {
ctx.commands.push({
cmd: 'draw_batch',
batch_type: 'particles',
geometry: {sprites: batch.sprites},
texture: batch.texture,
material: batch.material
})
} else if (batch.type == 'blit_target') {
ctx.commands.push({
cmd: 'blit',
texture: batch.drawable.target,
target: target,
dst_rect: {x: 0, y: 0, width: batch.drawable.width, height: batch.drawable.height},
filter: 'linear'
})
}
}
ctx.commands.push({cmd: 'end_render'})
}
// Apply bloom effect to a source target, returning a new target
function apply_bloom_effect(src_target, effect, ctx) {
var backend = ctx.backend
var target_size = ctx.target_size
var threshold_target = backend.get_or_create_target(target_size.width, target_size.height, 'bloom_threshold_' + ctx.target_counter++)
var blur_target_a = backend.get_or_create_target(target_size.width, target_size.height, 'bloom_blur_a_' + ctx.target_counter++)
var blur_target_b = backend.get_or_create_target(target_size.width, target_size.height, 'bloom_blur_b_' + ctx.target_counter++)
var output_target = backend.get_or_create_target(target_size.width, target_size.height, 'bloom_output_' + ctx.target_counter++)
// Threshold pass
ctx.commands.push({
cmd: 'shader_pass',
shader: 'threshold',
input: src_target,
output: threshold_target,
uniforms: {threshold: effect.threshold || 0.8, intensity: effect.intensity || 1.0}
})
// Blur passes
var blur_passes = effect.blur_passes || 3
var texel_size = [1/target_size.width, 1/target_size.height]
var blur_src = threshold_target
for (var i = 0; i < blur_passes; i++) {
ctx.commands.push({
cmd: 'shader_pass',
shader: 'blur',
input: blur_src,
output: blur_target_a,
uniforms: {direction: [2, 0], texel_size: texel_size}
})
ctx.commands.push({
cmd: 'shader_pass',
shader: 'blur',
input: blur_target_a,
output: blur_target_b,
uniforms: {direction: [0, 2], texel_size: texel_size}
})
blur_src = blur_target_b
}
// Composite bloom back onto source
ctx.commands.push({
cmd: 'composite_textures',
base: src_target,
overlay: blur_src,
output: output_target,
mode: 'add'
})
return output_target
}
// Apply mask effect to a source target, returning a new target
function apply_mask_effect(src_target, effect, ctx) {
var backend = ctx.backend
var target_size = ctx.target_size
var camera = ctx.camera
var mask_target = backend.get_or_create_target(target_size.width, target_size.height, 'mask_source_' + ctx.target_counter++)
var output_target = backend.get_or_create_target(target_size.width, target_size.height, 'mask_output_' + ctx.target_counter++)
// Render mask source
var mask_source = effect.source
if (mask_source) {
var mask_drawables = collect_drawables(mask_source, camera, null, null, null, null, ctx)
render_drawables_to_target(mask_drawables, mask_target, camera, ctx)
}
// Apply mask
ctx.commands.push({
cmd: 'apply_mask',
content_texture: src_target,
mask_texture: mask_target,
output: output_target,
mode: effect.mode || 'alpha',
invert: effect.invert || false
})
return output_target
}
// Apply blur effect to a source target, returning a new target
function apply_blur_effect(src_target, effect, ctx) {
var backend = ctx.backend
var target_size = ctx.target_size
var blur_target_a = backend.get_or_create_target(target_size.width, target_size.height, 'blur_a_' + ctx.target_counter++)
var blur_target_b = backend.get_or_create_target(target_size.width, target_size.height, 'blur_b_' + ctx.target_counter++)
// Blur passes
var blur_passes = effect.passes || 2
var texel_size = [1/target_size.width, 1/target_size.height]
var blur_src = src_target
var blur_dst = blur_target_a
for (var i = 0; i < blur_passes; i++) {
// Horizontal blur
ctx.commands.push({
cmd: 'shader_pass',
shader: 'blur',
input: blur_src,
output: blur_dst,
uniforms: {direction: [2, 0], texel_size: texel_size}
})
// Swap targets
var tmp = blur_src
blur_src = blur_dst
blur_dst = (blur_dst == blur_target_a) ? blur_target_b : blur_target_a
// Vertical blur
ctx.commands.push({
cmd: 'shader_pass',
shader: 'blur',
input: blur_src,
output: blur_dst,
uniforms: {direction: [0, 2], texel_size: texel_size}
})
tmp = blur_src
blur_src = blur_dst
blur_dst = tmp
}
return blur_src
}
// Collect drawables from children only (not the node itself)
function collect_children_drawables(node, camera, parent_tint, parent_opacity, parent_scissor, parent_pos, ctx) {
var drawables = []
parent_tint = parent_tint || [1, 1, 1, 1]
parent_opacity = parent_opacity != null ? parent_opacity : 1
parent_pos = parent_pos || {x: 0, y: 0}
// Compute node position
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))
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)
// Recurse children
if (node.children) {
for (var child of node.children) {
var child_drawables = process_scene_tree(child, camera, ctx, world_tint, world_opacity, parent_scissor, current_pos)
for (var d of child_drawables) drawables.push(d)
}
}
return drawables
}
// ========================================================================
// SCENE TREE TRAVERSAL
// ========================================================================
function collect_drawables(node, camera, parent_tint, parent_opacity, parent_scissor, parent_pos, ctx) {
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))
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)
var current_pos = {x: abs_x, y: abs_y}
// Compute inherited tint/opacity
@@ -404,7 +122,6 @@ function collect_drawables(node, camera, parent_tint, parent_opacity, parent_sci
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)
@@ -417,8 +134,10 @@ function collect_drawables(node, camera, parent_tint, parent_opacity, parent_sci
// Handle different node types
if (node.type == 'sprite' || (node.image && !node.type)) {
var sprite_drawables = collect_sprite(node, abs_x, abs_y, world_tint, world_opacity, current_scissor)
for (var d of sprite_drawables) drawables.push(d)
var sprite_drawables = _collect_sprite(node, abs_x, abs_y, world_tint, world_opacity, current_scissor)
for (var i = 0; i < sprite_drawables.length; i++) {
drawables.push(sprite_drawables[i])
}
}
if (node.type == 'text') {
@@ -436,7 +155,7 @@ function collect_drawables(node, camera, parent_tint, parent_opacity, parent_sci
outline_color: node.outline_color,
anchor_x: node.anchor_x,
anchor_y: node.anchor_y,
color: tint_to_color(world_tint, world_opacity),
color: _tint_to_color(world_tint, world_opacity),
scissor: current_scissor
})
}
@@ -449,14 +168,15 @@ function collect_drawables(node, camera, parent_tint, parent_opacity, parent_sci
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) {
for (var i = 0; i < particles.length; i++) {
var p = particles[i]
var px = p.pos ? p.pos.x : 0
var py = p.pos ? p.pos.y : 0
@@ -471,7 +191,7 @@ function collect_drawables(node, camera, parent_tint, parent_opacity, parent_sci
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),
color: p.color || _tint_to_color(world_tint, world_opacity),
material: node.material,
scissor: current_scissor
})
@@ -479,20 +199,19 @@ function collect_drawables(node, camera, parent_tint, parent_opacity, parent_sci
}
if (node.type == 'tilemap' || node.tiles) {
var tile_drawables = collect_tilemap(node, abs_x, abs_y, world_tint, world_opacity, current_scissor)
for (var d of tile_drawables) drawables.push(d)
var tile_drawables = _collect_tilemap(node, abs_x, abs_y, world_tint, world_opacity, current_scissor)
for (var i = 0; i < tile_drawables.length; i++) {
drawables.push(tile_drawables[i])
}
}
// Recurse children - use process_scene_tree if ctx available (handles effects)
// Recurse children
if (node.children) {
for (var child of node.children) {
var child_drawables
if (ctx) {
child_drawables = process_scene_tree(child, camera, ctx, world_tint, world_opacity, current_scissor, current_pos)
} else {
child_drawables = collect_drawables(child, camera, world_tint, world_opacity, current_scissor, current_pos, null)
for (var i = 0; i < node.children.length; i++) {
var child_drawables = _collect_drawables(node.children[i], camera, world_tint, world_opacity, current_scissor, current_pos)
for (var j = 0; j < child_drawables.length; j++) {
drawables.push(child_drawables[j])
}
for (var d of child_drawables) drawables.push(d)
}
}
@@ -500,7 +219,7 @@ function collect_drawables(node, camera, parent_tint, parent_opacity, parent_sci
}
// Collect sprite drawables (handles slice and tile modes)
function collect_sprite(node, abs_x, abs_y, world_tint, world_opacity, current_scissor) {
function _collect_sprite(node, abs_x, abs_y, world_tint, world_opacity, current_scissor) {
var drawables = []
if (node.slice && node.tile) {
@@ -511,9 +230,8 @@ function collect_sprite(node, abs_x, abs_y, world_tint, world_opacity, current_s
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)
var tint = _tint_to_color(world_tint, world_opacity)
// Helper to add a sprite drawable
function add_sprite(rect, uv) {
drawables.push({
type: 'sprite',
@@ -533,7 +251,6 @@ function collect_sprite(node, abs_x, abs_y, world_tint, world_opacity, current_s
})
}
// 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
@@ -558,12 +275,11 @@ function collect_sprite(node, abs_x, abs_y, world_tint, world_opacity, current_s
}
}
// Top-left of whole sprite
var x0 = abs_x - w * ax
var y0 = abs_y - h * ay
if (node.slice) {
// 9-Slice logic
// 9-slice
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)
@@ -571,51 +287,36 @@ function collect_sprite(node, abs_x, abs_y, world_tint, world_opacity, current_s
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
// TL, TM, TR
add_sprite({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({x: x0 + WL + WM, y: y0, width: WR, height: HT}, {x: 1-R, y:0, width: R, height: T})
// ML
// ML, MM, MR
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
// BL, BM, BR
add_sprite({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({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
@@ -640,14 +341,14 @@ function collect_sprite(node, abs_x, abs_y, world_tint, world_opacity, current_s
}
// Collect tilemap drawables
function collect_tilemap(node, abs_x, abs_y, world_tint, world_opacity, current_scissor) {
function _collect_tilemap(node, abs_x, abs_y, world_tint, world_opacity, current_scissor) {
var drawables = []
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
var tint = tint_to_color(world_tint, world_opacity)
var tint = _tint_to_color(world_tint, world_opacity)
for (var x = 0; x < tiles.length; x++) {
if (!tiles[x]) continue
@@ -679,7 +380,7 @@ function collect_tilemap(node, abs_x, abs_y, world_tint, world_opacity, current_
return drawables
}
function tint_to_color(tint, opacity) {
function _tint_to_color(tint, opacity) {
return {
r: tint[0],
g: tint[1],
@@ -688,37 +389,36 @@ function tint_to_color(tint, opacity) {
}
}
// ========================================================================
// BATCHING
// ========================================================================
function batch_drawables(drawables) {
// Batch drawables for efficient rendering
function _batch_drawables(drawables) {
var batches = []
var current_batch = null
for (var drawable of drawables) {
var mat = {blend: 'alpha', sampler: 'nearest'}
array.for(drawables, drawable => {
if (drawable.type == 'sprite') {
var texture = drawable.texture || drawable.image
var material = drawable.material || {blend: 'alpha', sampler: 'nearest'}
var material = drawable.material || mat
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)) {
// Check if can merge with current batch
if (current_batch &&
current_batch.type == 'sprite_batch' &&
current_batch.texture == texture &&
_rect_equal(current_batch.scissor, scissor) &&
_materials_equal(current_batch.material, material)) {
current_batch.sprites.push(drawable)
} else {
if (current_batch) batches.push(current_batch)
current_batch = {
type: 'sprite_batch',
texture: texture,
material: material,
scissor: scissor,
sprites: []
sprites: [drawable]
}
}
current_batch.sprites.push(drawable)
} else {
// Non-sprite: flush batch, add individually
if (current_batch) {
@@ -727,20 +427,20 @@ function batch_drawables(drawables) {
}
batches.push({type: drawable.type, drawable: drawable, scissor: drawable.scissor})
}
}
})
if (current_batch) batches.push(current_batch)
return batches
}
function rect_equal(a, b) {
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) {
function _materials_equal(a, b) {
if (!a || !b) return a == b
return a.blend == b.blend && a.sampler == b.sampler && a.shader == b.shader
}

View File

@@ -1200,6 +1200,91 @@ JSC_CCALL(geometry_weave,
return result;
)
JSC_CCALL(geometry_sprite_vertices,
JSValue sprite = argv[0];
// Get sprite properties
double x, y, w, h, u0, v0, u1, v1;
JSValue x_val = JS_GetPropertyStr(js, sprite, "x");
JSValue y_val = JS_GetPropertyStr(js, sprite, "y");
JSValue w_val = JS_GetPropertyStr(js, sprite, "w");
JSValue h_val = JS_GetPropertyStr(js, sprite, "h");
JSValue u0_val = JS_GetPropertyStr(js, sprite, "u0");
JSValue v0_val = JS_GetPropertyStr(js, sprite, "v0");
JSValue u1_val = JS_GetPropertyStr(js, sprite, "u1");
JSValue v1_val = JS_GetPropertyStr(js, sprite, "v1");
JSValue c_val = JS_GetPropertyStr(js, sprite, "c");
JS_ToFloat64(js, &x, x_val);
JS_ToFloat64(js, &y, y_val);
JS_ToFloat64(js, &w, w_val);
JS_ToFloat64(js, &h, h_val);
JS_ToFloat64(js, &u0, u0_val);
JS_ToFloat64(js, &v0, v0_val);
JS_ToFloat64(js, &u1, u1_val);
JS_ToFloat64(js, &v1, v1_val);
HMM_Vec4 c = {1.0f, 1.0f, 1.0f, 1.0f};
if (!JS_IsNull(c_val)) {
c = js2color(js, c_val);
}
JS_FreeValue(js, x_val);
JS_FreeValue(js, y_val);
JS_FreeValue(js, w_val);
JS_FreeValue(js, h_val);
JS_FreeValue(js, u0_val);
JS_FreeValue(js, v0_val);
JS_FreeValue(js, u1_val);
JS_FreeValue(js, v1_val);
JS_FreeValue(js, c_val);
// 4 vertices * 8 floats per vertex (x, y, u, v, r, g, b, a)
float vertex_data[4 * 8];
// v0: bottom-left
vertex_data[0] = x;
vertex_data[1] = y;
vertex_data[2] = u0;
vertex_data[3] = v1; // Flip V
vertex_data[4] = c.r;
vertex_data[5] = c.g;
vertex_data[6] = c.b;
vertex_data[7] = c.a;
// v1: bottom-right
vertex_data[8] = x + w;
vertex_data[9] = y;
vertex_data[10] = u1;
vertex_data[11] = v1; // Flip V
vertex_data[12] = c.r;
vertex_data[13] = c.g;
vertex_data[14] = c.b;
vertex_data[15] = c.a;
// v2: top-right
vertex_data[16] = x + w;
vertex_data[17] = y + h;
vertex_data[18] = u1;
vertex_data[19] = v0; // Flip V
vertex_data[20] = c.r;
vertex_data[21] = c.g;
vertex_data[22] = c.b;
vertex_data[23] = c.a;
// v3: top-left
vertex_data[24] = x;
vertex_data[25] = y + h;
vertex_data[26] = u0;
vertex_data[27] = v0; // Flip V
vertex_data[28] = c.r;
vertex_data[29] = c.g;
vertex_data[30] = c.b;
vertex_data[31] = c.a;
return js_new_blob_stoned_copy(js, vertex_data, sizeof(vertex_data));
)
static const JSCFunctionListEntry js_geometry_funcs[] = {
MIST_FUNC_DEF(geometry, rect_intersection, 2),
MIST_FUNC_DEF(geometry, rect_intersects, 2),
@@ -1213,6 +1298,7 @@ static const JSCFunctionListEntry js_geometry_funcs[] = {
MIST_FUNC_DEF(geometry, rect_transform, 2),
MIST_FUNC_DEF(geometry, tilemap_to_data, 1),
MIST_FUNC_DEF(geometry, sprites_to_data, 1),
MIST_FUNC_DEF(geometry, sprite_vertices, 1),
MIST_FUNC_DEF(geometry, transform_xy_blob, 2),
MIST_FUNC_DEF(gpu, tile, 4),
MIST_FUNC_DEF(gpu, slice9, 4),

View File

@@ -158,7 +158,7 @@ function create_image(path){
def bytes = io.slurp(path);
var ext = path.split('.').pop()
let raw = decode_image(bytes, ext);
var raw = decode_image(bytes, ext);
/* ── Case A: single surface (from make_texture) ────────────── */
if(raw && raw.width && raw.pixels && !isa(raw, array)) {

View File

@@ -9,7 +9,6 @@ var io = use('cellfs')
var geometry = use('geometry')
var blob = use('blob')
var imgui = use('imgui')
var utf8 = use('utf8')
var json = use('json')
var os = use('os')
@@ -223,7 +222,7 @@ function make_shader(sh_file)
{
var file = `shaders/${shader_type}/${sh_file}.${shader_type}`
if (shader_cache[file]) return shader_cache[file]
var refl = json.decode(utf8.decode(io.slurp(`shaders/reflection/${sh_file}.json`)))
var refl = json.decode(text(io.slurp(`shaders/reflection/${sh_file}.json`)))
var shader = {
code: io.slurp(file),

View File

@@ -647,6 +647,7 @@ function _create_gpu_texture(w, h, pixels) {
function _load_image_file(path) {
var bytes = io.slurp(path)
var decoded
if (!bytes) return null
var ext = path.split('.').pop().toLowerCase()
@@ -663,14 +664,14 @@ function _load_image_file(path) {
surface = qoi.decode(bytes)
break
case 'gif':
var decoded = gif.decode(bytes)
decoded = gif.decode(bytes)
if (decoded && decoded.frames && decoded.frames.length > 0) {
surface = decoded.frames[0]
}
break
case 'ase':
case 'aseprite':
var decoded = aseprite.decode(bytes)
decoded = aseprite.decode(bytes)
if (decoded && decoded.frames && decoded.frames.length > 0) {
surface = decoded.frames[0]
}
@@ -786,19 +787,21 @@ function _build_sprite_vertices(sprites, camera) {
var vertices_per_sprite = 4
var indices_per_sprite = 6
var vertex_data = new blob_mod(sprites.length * vertices_per_sprite * floats_per_vertex * 4)
var index_data = new blob_mod(sprites.length * indices_per_sprite * 2)
var vertex_data = new blob_mod(sprites.length * vertices_per_sprite * floats_per_vertex * 32)
var index_data = geometry.make_quad_indices(sprites.length)
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 white = {r: 1, g: 1, b: 1, a: 1}
array.for(sprites, s => {
var px = s.pos.x
var py = s.pos.y
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}
var c = s.color || white
// Apply anchor
var x = px - w * ax
@@ -810,57 +813,10 @@ function _build_sprite_vertices(sprites, camera) {
var u1 = s.uv_rect ? (s.uv_rect.x + s.uv_rect.width) : 1
var v1 = s.uv_rect ? (s.uv_rect.y + s.uv_rect.height) : 1
// Quad vertices (bottom-left, bottom-right, top-right, top-left)
// v0: bottom-left
vertex_data.wf(x)
vertex_data.wf(y)
vertex_data.wf(u0)
vertex_data.wf(v1) // Flip V
vertex_data.wf(c.r)
vertex_data.wf(c.g)
vertex_data.wf(c.b)
vertex_data.wf(c.a)
// v1: bottom-right
vertex_data.wf(x + w)
vertex_data.wf(y)
vertex_data.wf(u1)
vertex_data.wf(v1) // Flip V
vertex_data.wf(c.r)
vertex_data.wf(c.g)
vertex_data.wf(c.b)
vertex_data.wf(c.a)
// v2: top-right
vertex_data.wf(x + w)
vertex_data.wf(y + h)
vertex_data.wf(u1)
vertex_data.wf(v0) // Flip V
vertex_data.wf(c.r)
vertex_data.wf(c.g)
vertex_data.wf(c.b)
vertex_data.wf(c.a)
// v3: top-left
vertex_data.wf(x)
vertex_data.wf(y + h)
vertex_data.wf(u0)
vertex_data.wf(v0) // Flip V
vertex_data.wf(c.r)
vertex_data.wf(c.g)
vertex_data.wf(c.b)
vertex_data.wf(c.a)
// Indices (two triangles)
index_data.w16(vertex_count + 0)
index_data.w16(vertex_count + 1)
index_data.w16(vertex_count + 2)
index_data.w16(vertex_count + 0)
index_data.w16(vertex_count + 2)
index_data.w16(vertex_count + 3)
vertex_count += 4
}
// call to implement
var verts = geometry.sprite_vertices({x,y,w,h,u0,v0,u1,v1,c})
vertex_data.write_blob(verts)
})
return {
vertices: stone(vertex_data),
@@ -1042,6 +998,7 @@ function _execute_commands(commands, window_size) {
var current_target = null
var current_camera = null
var pending_draws = []
var target
// Cache swapchain texture for the duration of this command buffer
var _swapchain_tex = null
@@ -1067,7 +1024,7 @@ function _execute_commands(commands, window_size) {
}
// Start new pass
var target = cmd.target
target = cmd.target
var clear = cmd.clear
if (target == 'screen') {
@@ -1209,7 +1166,7 @@ function _execute_commands(commands, window_size) {
imgui_mod.prepare(cmd_buffer)
// Restart pass to the same target for rendering
var target = cmd.target
target = cmd.target
var swap_tex = null
if (target == 'screen') {
swap_tex = get_swapchain_tex()

101
sprite.cm
View File

@@ -1,6 +1,10 @@
// sprite
// sprite.cm - Sprite node factory
//
// Returns a function that creates sprite instances via meme()
var sprite = {
pos: {x:0, y:0},
type: 'sprite',
pos: null,
layer: 0,
image: null,
width: 1,
@@ -9,10 +13,95 @@ var sprite = {
anchor_y: 0,
scale_x: 1,
scale_y: 1,
color: {r:1, g:1, b:1, a:1},
animation: null, // 'walk', 'attack', etc
color: null,
uv_rect: null,
slice: null,
tile: null,
material: null,
animation: null,
frame: 0,
opacity: 1
opacity: 1,
// Dirty tracking
dirty: 7, // DIRTY.ALL
// Cached geometry (for retained mode)
geom_cache: null,
// Setters that mark dirty
set_pos: function(x, y) {
if (!this.pos) this.pos = {x: 0, y: 0}
if (this.pos.x == x && this.pos.y == y) return this
this.pos.x = x
this.pos.y = y
this.dirty |= 1 // TRANSFORM
return this
},
set_image: function(img) {
if (this.image == img) return this
this.image = img
this.dirty |= 2 // CONTENT
return this
},
set_size: function(w, h) {
if (this.width == w && this.height == h) return this
this.width = w
this.height = h
this.dirty |= 2 // CONTENT
return this
},
set_anchor: function(x, y) {
if (this.anchor_x == x && this.anchor_y == y) return this
this.anchor_x = x
this.anchor_y = y
this.dirty |= 1 // TRANSFORM
return this
},
set_color: function(r, g, b, a) {
if (!this.color) this.color = {r: 1, g: 1, b: 1, a: 1}
if (this.color.r == r && this.color.g == g && this.color.b == b && this.color.a == a) return this
this.color.r = r
this.color.g = g
this.color.b = b
this.color.a = a
this.dirty |= 2 // CONTENT
return this
},
set_opacity: function(o) {
if (this.opacity == o) return this
this.opacity = o
this.dirty |= 2 // CONTENT
return this
}
}
return sprite
// Factory function
return function(props) {
var s = meme(sprite)
s.pos = {x: 0, y: 0}
s.color = {r: 1, g: 1, b: 1, a: 1}
s.dirty = 7
if (props) {
for (var k in props) {
if (k == 'pos' && props.pos) {
s.pos.x = props.pos.x || 0
s.pos.y = props.pos.y || 0
} else if (k == 'color' && props.color) {
s.color.r = props.color.r != null ? props.color.r : 1
s.color.g = props.color.g != null ? props.color.g : 1
s.color.b = props.color.b != null ? props.color.b : 1
s.color.a = props.color.a != null ? props.color.a : 1
} else {
s[k] = props[k]
}
}
}
return s
}