fx graph
This commit is contained in:
383
compositor.cm
383
compositor.cm
@@ -1,329 +1,188 @@
|
||||
// compositor.cm - High-level compositing API built on fx_graph
|
||||
//
|
||||
// Provides a simple interface for common rendering patterns:
|
||||
// - World view with camera
|
||||
// - UI overlay
|
||||
// - Masking
|
||||
// - Post-effects
|
||||
//
|
||||
// Usage:
|
||||
// var comp = compositor()
|
||||
// comp.set_world(scene_root, world_camera)
|
||||
// comp.set_ui(ui_root, ui_camera)
|
||||
// comp.set_mask(mask_root) // optional
|
||||
// var cmds = comp.render(backend, window_size)
|
||||
|
||||
var fx_graph = use('fx_graph')
|
||||
|
||||
var compositor = {}
|
||||
|
||||
// High-level API: Set what to render
|
||||
compositor.set_world = function(root_or_fn, camera) {
|
||||
// Set the world view (main game scene)
|
||||
compositor.set_world = function(root_or_fn, camera, target_spec) {
|
||||
this.world_view = {
|
||||
root: typeof root_or_fn == 'function' ? root_or_fn : (_ => root_or_fn),
|
||||
camera: camera
|
||||
root: typeof root_or_fn == 'function' ? root_or_fn : function() { return root_or_fn },
|
||||
camera: camera,
|
||||
target: target_spec || this.world_target_spec
|
||||
}
|
||||
}
|
||||
|
||||
compositor.set_ui = function(root_or_fn, camera) {
|
||||
// Set the UI overlay
|
||||
compositor.set_ui = function(root_or_fn, camera, target_spec) {
|
||||
this.ui_view = {
|
||||
root: typeof root_or_fn == 'function' ? root_or_fn : (_ => root_or_fn),
|
||||
camera: camera
|
||||
root: typeof root_or_fn == 'function' ? root_or_fn : function() { return root_or_fn },
|
||||
camera: camera,
|
||||
target: target_spec || this.ui_target_spec
|
||||
}
|
||||
}
|
||||
|
||||
// High-level API: Masking
|
||||
compositor.set_world_mask = function(mask_root_or_fn, invert) {
|
||||
if (!mask_root_or_fn) {
|
||||
this.world_mask = null
|
||||
// Set a mask for the world view
|
||||
compositor.set_mask = function(content_root_or_fn, mask_root_or_fn, opts) {
|
||||
opts = opts || {}
|
||||
if (!content_root_or_fn || !mask_root_or_fn) {
|
||||
this.mask_config = null
|
||||
return
|
||||
}
|
||||
this.world_mask = {
|
||||
root: typeof mask_root_or_fn == 'function' ? mask_root_or_fn : (_ => mask_root_or_fn),
|
||||
invert: !!invert
|
||||
this.mask_config = {
|
||||
content: typeof content_root_or_fn == 'function' ? content_root_or_fn : function() { return content_root_or_fn },
|
||||
mask: typeof mask_root_or_fn == 'function' ? mask_root_or_fn : function() { return mask_root_or_fn },
|
||||
invert: opts.invert || false,
|
||||
mode: opts.mode || 'alpha'
|
||||
}
|
||||
}
|
||||
|
||||
// High-level API: Effects (recipes)
|
||||
compositor.add_emissive_lighting = function(opts) {
|
||||
this.recipes.push({
|
||||
type: 'emissive_lighting',
|
||||
tag: opts.tag,
|
||||
bloom_radius: opts.bloom_radius || 50,
|
||||
bloom_intensity: opts.bloom_intensity || 1.5,
|
||||
light_color: opts.light_color || [1, 1, 1, 1]
|
||||
})
|
||||
// Add a named layer for more complex compositing
|
||||
compositor.add_layer = function(name, opts) {
|
||||
this.layers[name] = {
|
||||
root: typeof opts.root == 'function' ? opts.root : function() { return opts.root },
|
||||
camera: opts.camera,
|
||||
target: opts.target || this.world_target_spec,
|
||||
clear_color: opts.clear_color,
|
||||
blend: opts.blend || 'over',
|
||||
opacity: opts.opacity != null ? opts.opacity : 1
|
||||
}
|
||||
}
|
||||
|
||||
compositor.add_post_effect = function(effect_name, params) {
|
||||
this.recipes.push({
|
||||
type: 'post_effect',
|
||||
effect: effect_name,
|
||||
params: params || {}
|
||||
})
|
||||
compositor.remove_layer = function(name) {
|
||||
delete this.layers[name]
|
||||
}
|
||||
|
||||
compositor.add_layer_effect = function(opts) {
|
||||
this.recipes.push({
|
||||
type: 'layer_effect',
|
||||
layers: opts.layers,
|
||||
effect: opts.effect,
|
||||
params: opts.params || {}
|
||||
})
|
||||
}
|
||||
|
||||
// Main render entry point
|
||||
compositor.present = function(window_size) {
|
||||
// Step 1: Build node graph from recipes
|
||||
// Build and execute the render graph
|
||||
compositor.render = function(backend, window_size) {
|
||||
var graph = this.build_graph(window_size)
|
||||
|
||||
// Step 2: Execute graph (backend-agnostic)
|
||||
var result = graph.execute(this.backend)
|
||||
|
||||
// Step 3: Backend returns final commands
|
||||
return result.commands
|
||||
return backend.render(graph, window_size)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// GRAPH BUILDER: Expands recipes into node graph
|
||||
// ========================================================================
|
||||
|
||||
// Build the fx_graph from current configuration
|
||||
compositor.build_graph = function(window_size) {
|
||||
var graph = fx_graph()
|
||||
var fit = fx_graph.fit_to_screen
|
||||
|
||||
// Get scene trees (call functions if needed)
|
||||
// Get scene trees
|
||||
var world_root = this.world_view ? this.world_view.root() : null
|
||||
var ui_root = this.ui_view ? this.ui_view.root() : null
|
||||
|
||||
if (!world_root && !ui_root) {
|
||||
// Nothing to render
|
||||
return graph
|
||||
}
|
||||
var outputs = []
|
||||
|
||||
var current_world_output = null
|
||||
|
||||
// Render world
|
||||
if (world_root) {
|
||||
// Render world view
|
||||
if (world_root && this.world_view) {
|
||||
var world_node = graph.add_node('render_view', {
|
||||
root: world_root,
|
||||
camera: this.world_view.camera,
|
||||
target_spec: this.world_target_spec
|
||||
target: this.world_view.target,
|
||||
clear_color: this.world_view.camera.background || {r: 0, g: 0, b: 0, a: 1}
|
||||
})
|
||||
current_world_output = world_node.output
|
||||
outputs.push({output: world_node.output, target_spec: this.world_view.target, name: 'world'})
|
||||
}
|
||||
|
||||
// Render additional layers
|
||||
for (var name in this.layers) {
|
||||
var layer = this.layers[name]
|
||||
var layer_root = layer.root()
|
||||
if (!layer_root) continue
|
||||
|
||||
// Apply masking if configured
|
||||
if (this.world_mask) {
|
||||
var mask_root = this.world_mask.root()
|
||||
var layer_node = graph.add_node('render_view', {
|
||||
root: layer_root,
|
||||
camera: layer.camera,
|
||||
target: layer.target,
|
||||
clear_color: layer.clear_color || {r: 0, g: 0, b: 0, a: 0}
|
||||
})
|
||||
outputs.push({output: layer_node.output, target_spec: layer.target, blend: layer.blend, opacity: layer.opacity, name: name})
|
||||
}
|
||||
|
||||
// Handle masking if configured
|
||||
if (this.mask_config) {
|
||||
var content_root = this.mask_config.content()
|
||||
var mask_root = this.mask_config.mask()
|
||||
|
||||
if (content_root && mask_root) {
|
||||
var content_node = graph.add_node('render_view', {
|
||||
root: content_root,
|
||||
camera: this.world_view ? this.world_view.camera : this.ui_view.camera,
|
||||
target: this.world_target_spec,
|
||||
clear_color: {r: 0, g: 0, b: 0, a: 0}
|
||||
})
|
||||
|
||||
var mask_node = graph.add_node('render_view', {
|
||||
root: mask_root,
|
||||
camera: this.world_view.camera,
|
||||
target_spec: this.world_target_spec,
|
||||
camera: this.world_view ? this.world_view.camera : this.ui_view.camera,
|
||||
target: this.world_target_spec,
|
||||
clear_color: {r: 0, g: 0, b: 0, a: 0}
|
||||
})
|
||||
|
||||
var masked_node = graph.add_node('mask', {
|
||||
content: current_world_output,
|
||||
content: content_node.output,
|
||||
mask: mask_node.output,
|
||||
invert: this.world_mask.invert
|
||||
mode: this.mask_config.mode,
|
||||
invert: this.mask_config.invert
|
||||
})
|
||||
current_world_output = masked_node.output
|
||||
}
|
||||
|
||||
// Expand recipes that affect world
|
||||
for (var recipe of this.recipes) {
|
||||
if (recipe.type == 'emissive_lighting') {
|
||||
current_world_output = this.expand_emissive_lighting(graph, world_root, current_world_output, recipe)
|
||||
}
|
||||
if (recipe.type == 'layer_effect') {
|
||||
current_world_output = this.expand_layer_effect(graph, world_root, current_world_output, recipe)
|
||||
}
|
||||
|
||||
outputs.push({output: masked_node.output, target_spec: this.world_target_spec, name: 'masked'})
|
||||
}
|
||||
}
|
||||
|
||||
// Render UI
|
||||
var ui_output = null
|
||||
if (ui_root) {
|
||||
if (ui_root && this.ui_view) {
|
||||
var ui_node = graph.add_node('render_view', {
|
||||
root: ui_root,
|
||||
camera: this.ui_view.camera,
|
||||
target_spec: this.ui_target_spec,
|
||||
target: this.ui_view.target,
|
||||
clear_color: {r: 0, g: 0, b: 0, a: 0}
|
||||
})
|
||||
ui_output = ui_node.output
|
||||
outputs.push({output: ui_node.output, target_spec: this.ui_view.target, name: 'ui'})
|
||||
}
|
||||
|
||||
// Apply post-effects to world
|
||||
for (var recipe of this.recipes) {
|
||||
if (recipe.type == 'post_effect' && current_world_output) {
|
||||
var post_node = graph.add_node(recipe.effect, {
|
||||
input: current_world_output,
|
||||
params: recipe.params
|
||||
})
|
||||
current_world_output = post_node.output
|
||||
}
|
||||
}
|
||||
|
||||
// Final composition to screen
|
||||
var screen_node = graph.add_node('screen_composite', {
|
||||
world: current_world_output,
|
||||
ui: ui_output,
|
||||
window_size: window_size,
|
||||
world_target_spec: this.world_target_spec,
|
||||
ui_target_spec: this.ui_target_spec
|
||||
// Blit all outputs to screen
|
||||
var screen_clear = graph.add_node('render_view', {
|
||||
root: {type: 'group', children: []},
|
||||
camera: {pos: [0, 0], width: window_size.width, height: window_size.height, anchor: [0, 0], ortho: true},
|
||||
target: 'screen',
|
||||
clear_color: {r: 0, g: 0, b: 0, a: 1}
|
||||
})
|
||||
|
||||
graph.set_output(screen_node.output)
|
||||
for (var out of outputs) {
|
||||
var dst_rect = fit(out.target_spec, window_size)
|
||||
graph.add_node('blit', {
|
||||
input: out.output,
|
||||
target: 'screen',
|
||||
dst_rect: dst_rect,
|
||||
filter: out.target_spec.filter || 'nearest'
|
||||
})
|
||||
}
|
||||
|
||||
// Present
|
||||
var present_node = graph.add_node('present', {})
|
||||
graph.set_output(present_node.output)
|
||||
|
||||
return graph
|
||||
}
|
||||
|
||||
// Expand emissive_lighting recipe into nodes
|
||||
compositor.expand_emissive_lighting = function(graph, world_root, current_output, recipe) {
|
||||
// Extract tagged sprites
|
||||
var emissive_root = this.extract_tagged(world_root, recipe.tag)
|
||||
if (!emissive_root || !emissive_root.children || !emissive_root.children.length)
|
||||
return current_output
|
||||
|
||||
// Render emissive objects
|
||||
var emissive_node = graph.add_node('render_view', {
|
||||
root: emissive_root,
|
||||
camera: this.world_view.camera,
|
||||
target_spec: this.world_target_spec,
|
||||
clear_color: {r: 0, g: 0, b: 0, a: 0}
|
||||
})
|
||||
|
||||
// Apply bloom
|
||||
var bloom_node = graph.add_node('bloom', {
|
||||
input: emissive_node.output,
|
||||
radius: recipe.bloom_radius,
|
||||
intensity: recipe.bloom_intensity
|
||||
})
|
||||
|
||||
// Composite additively onto world
|
||||
var composite_node = graph.add_node('composite', {
|
||||
base: current_output,
|
||||
overlay: bloom_node.output,
|
||||
mode: 'add'
|
||||
})
|
||||
|
||||
return composite_node.output
|
||||
}
|
||||
|
||||
compositor.expand_layer_effect = function(graph, world_root, current_output, recipe) {
|
||||
var layer_root = this.extract_layers(world_root, recipe.layers)
|
||||
if (!layer_root || !layer_root.children || !layer_root.children.length)
|
||||
return current_output
|
||||
|
||||
var layer_node = graph.add_node('render_view', {
|
||||
root: layer_root,
|
||||
camera: this.world_view.camera,
|
||||
target_spec: this.world_target_spec,
|
||||
clear_color: {r: 0, g: 0, b: 0, a: 0}
|
||||
})
|
||||
|
||||
var effect_node = graph.add_node(recipe.effect, {
|
||||
input: layer_node.output,
|
||||
params: recipe.params
|
||||
})
|
||||
|
||||
var composite_node = graph.add_node('composite', {
|
||||
base: current_output,
|
||||
overlay: effect_node.output,
|
||||
mode: 'over'
|
||||
})
|
||||
|
||||
return composite_node.output
|
||||
}
|
||||
|
||||
// Helper: Extract nodes with specific tag
|
||||
compositor.extract_tagged = function(root, tag) {
|
||||
var results = []
|
||||
|
||||
function walk(node) {
|
||||
if (!node) return
|
||||
if (node.tags && node.tags.indexOf(tag) >= 0)
|
||||
results.push(node)
|
||||
if (node.children)
|
||||
for (var child of node.children) walk(child)
|
||||
}
|
||||
|
||||
walk(root)
|
||||
return {type: 'group', children: results}
|
||||
}
|
||||
|
||||
// Helper: Extract nodes on specific layers
|
||||
compositor.extract_layers = function(root, layers) {
|
||||
var results = []
|
||||
var layer_set = {}
|
||||
for (var l of layers) layer_set[l] = true
|
||||
|
||||
function walk(node) {
|
||||
if (!node) return
|
||||
if (node.layer != null && layer_set[node.layer])
|
||||
results.push(node)
|
||||
if (node.children)
|
||||
for (var child of node.children) walk(child)
|
||||
}
|
||||
|
||||
walk(root)
|
||||
return {type: 'group', children: results}
|
||||
}
|
||||
|
||||
return function() {
|
||||
return meme(compositor, {
|
||||
world_view: null,
|
||||
ui_view: null,
|
||||
mask_config: null,
|
||||
layers: {},
|
||||
world_target_spec: {width: 1280, height: 720, filter: 'nearest'},
|
||||
ui_target_spec: {width: 640, height: 360, filter: 'nearest'},
|
||||
world_mask: null,
|
||||
recipes: [],
|
||||
backend: null // Set during init
|
||||
ui_target_spec: {width: 640, height: 360, filter: 'nearest'}
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// USAGE EXAMPLE
|
||||
// ========================================================================
|
||||
|
||||
var compositor = new Compositor()
|
||||
|
||||
function game_startup() {
|
||||
// Scene trees (just data)
|
||||
function build_world() {
|
||||
return {
|
||||
type: 'group',
|
||||
children: [
|
||||
{type: 'sprite', pos: [0, 0], texture: 'bg.png', layer: 0},
|
||||
{type: 'sprite', pos: [100, 100], texture: 'player.png', layer: 5},
|
||||
|
||||
// Bullets emit light
|
||||
...bullets.map(b => ({
|
||||
type: 'sprite',
|
||||
pos: b.pos,
|
||||
texture: 'bullet.png',
|
||||
layer: 5,
|
||||
tags: ['emissive']
|
||||
}))
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function build_ui() {
|
||||
return {
|
||||
type: 'group',
|
||||
children: [
|
||||
{type: 'sprite', pos: [10, 10], texture: 'health.png'},
|
||||
{type: 'text', pos: [10, 30], text: `Score: ${score}`, font: 'main', size: 16}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Configure compositor (high-level, no backend knowledge)
|
||||
compositor.set_world(build_world, camera)
|
||||
compositor.set_ui(build_ui, hudcam)
|
||||
|
||||
// Add effects (declarative recipes)
|
||||
compositor.add_emissive_lighting({tag: 'emissive', bloom_radius: 50})
|
||||
compositor.add_post_effect('crt', {scanlines: 0.5})
|
||||
|
||||
// Initialize backend (see Part 2)
|
||||
compositor.backend = new SDL3GPUBackend()
|
||||
|
||||
loop()
|
||||
}
|
||||
|
||||
function loop() {
|
||||
update(1/60)
|
||||
|
||||
// ONE LINE - no backend knowledge
|
||||
var cmds = compositor.present({width: 1280, height: 720})
|
||||
|
||||
// Backend executes commands (see Part 4)
|
||||
compositor.backend.execute(cmds)
|
||||
|
||||
$delay(loop, 1/60)
|
||||
}
|
||||
139
core.cm
Normal file
139
core.cm
Normal file
@@ -0,0 +1,139 @@
|
||||
// core.cm - Minimal entry point for prosperon rendering
|
||||
//
|
||||
// Usage:
|
||||
// var core = use('prosperon/core')
|
||||
// core.start({
|
||||
// width: 1280,
|
||||
// height: 720,
|
||||
// title: "My Game",
|
||||
// update: function(dt) { ... },
|
||||
// render: function() { return graph }
|
||||
// })
|
||||
|
||||
var video = use('sdl3/video')
|
||||
var events = use('sdl3/events')
|
||||
var time_mod = use('time')
|
||||
|
||||
var core = {}
|
||||
|
||||
// Private state
|
||||
var _running = false
|
||||
var _config = null
|
||||
var _backend = null
|
||||
var _window = null
|
||||
var _last_time = 0
|
||||
var _framerate = 60
|
||||
|
||||
// Start the application
|
||||
core.start = function(config) {
|
||||
_config = config
|
||||
_framerate = config.framerate || 60
|
||||
|
||||
// Initialize SDL GPU backend
|
||||
var sdl_gpu = use('sdl_gpu')
|
||||
_backend = sdl_gpu
|
||||
|
||||
var init_result = _backend.init({
|
||||
width: config.width || 1280,
|
||||
height: config.height || 720,
|
||||
title: config.title || "Prosperon"
|
||||
})
|
||||
|
||||
if (!init_result) {
|
||||
log.console("core: Failed to initialize backend")
|
||||
return false
|
||||
}
|
||||
|
||||
_window = _backend.get_window()
|
||||
_running = true
|
||||
_last_time = time_mod.number()
|
||||
|
||||
// Start main loop
|
||||
_main_loop()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Stop the application
|
||||
core.stop = function() {
|
||||
_running = false
|
||||
}
|
||||
|
||||
// Get window size
|
||||
core.window_size = function() {
|
||||
return _backend.get_window_size()
|
||||
}
|
||||
|
||||
// Get backend for direct access
|
||||
core.backend = function() {
|
||||
return _backend
|
||||
}
|
||||
|
||||
// Main loop
|
||||
function _main_loop() {
|
||||
if (!_running) return
|
||||
|
||||
var now = time_mod.number()
|
||||
var dt = now - _last_time
|
||||
_last_time = now
|
||||
|
||||
// Process events
|
||||
var evts = []
|
||||
var ev
|
||||
while ((ev = events.poll()) != null) {
|
||||
evts.push(ev)
|
||||
}
|
||||
|
||||
var win_size = _backend.get_window_size()
|
||||
|
||||
for (var ev of evts) {
|
||||
if (ev.type == 'quit') {
|
||||
_running = false
|
||||
$stop()
|
||||
return
|
||||
}
|
||||
if (ev.type == 'window_pixel_size_changed') {
|
||||
win_size.width = ev.data1
|
||||
win_size.height = ev.data2
|
||||
if (_backend.set_window_size) {
|
||||
_backend.set_window_size(ev.width, ev.height)
|
||||
}
|
||||
}
|
||||
if (_config.input) {
|
||||
_config.input(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// Update
|
||||
if (_config.update) {
|
||||
_config.update(dt)
|
||||
}
|
||||
|
||||
// Render
|
||||
if (_config.render) {
|
||||
var graph = _config.render()
|
||||
if (graph) {
|
||||
if (_config.debug== 'graph') {
|
||||
log.console(graph)
|
||||
$stop()
|
||||
return
|
||||
}
|
||||
var dbg = _config.debug == 'cmd'
|
||||
_backend.execute_graph(graph, win_size, dbg)
|
||||
if (dbg) {
|
||||
$stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule next frame
|
||||
var frame_time = 1 / _framerate
|
||||
var elapsed = time_mod.number() - now
|
||||
var delay = frame_time - elapsed
|
||||
if (delay < 0) delay = 0
|
||||
|
||||
$delay(_main_loop, delay)
|
||||
}
|
||||
|
||||
return core
|
||||
612
fx_graph.cm
612
fx_graph.cm
@@ -1,62 +1,114 @@
|
||||
// fx_graph
|
||||
// fx_graph.cm - Compositing graph for rendering
|
||||
//
|
||||
// Core node types (minimal, generic primitives):
|
||||
//
|
||||
// render_view
|
||||
// Render a scene root with a camera into a target.
|
||||
// Params:
|
||||
// root - Scene tree root node (or array of drawables)
|
||||
// camera - Camera object with pos, width, height, anchor, etc.
|
||||
// target - Target spec: {width, height} or 'screen' or existing target
|
||||
// clear_color - Optional RGBA clear color, null = no clear
|
||||
// Output: {target, commands}
|
||||
//
|
||||
// composite
|
||||
// Combine two inputs with a blend mode.
|
||||
// Params:
|
||||
// base - Base layer (output from another node)
|
||||
// overlay - Overlay layer (output from another node)
|
||||
// mode - 'over' (default), 'add', 'multiply'
|
||||
// opacity - 0-1, overlay opacity
|
||||
// Output: {target, commands}
|
||||
//
|
||||
// mask
|
||||
// Apply a mask to content.
|
||||
// Params:
|
||||
// content - Content to mask (output from another node)
|
||||
// mask - Mask source (output from another node)
|
||||
// mode - 'binary' | 'alpha' (default 'alpha')
|
||||
// invert - bool, invert mask
|
||||
// Output: {target, commands}
|
||||
//
|
||||
// clip_rect
|
||||
// Clip/scissor to a rectangle.
|
||||
// Params:
|
||||
// input - Input to clip
|
||||
// rect - {x, y, width, height} in target coords
|
||||
// Output: {target, commands} (same target, adds scissor command)
|
||||
//
|
||||
// blit
|
||||
// Copy/scale an image into a target.
|
||||
// Params:
|
||||
// input - Source (output from another node or texture)
|
||||
// target - Destination target spec or 'screen'
|
||||
// dst_rect - {x, y, width, height} destination rectangle
|
||||
// filter - 'nearest' | 'linear'
|
||||
// Output: {target, commands}
|
||||
//
|
||||
// present
|
||||
// Present a chosen image to the display.
|
||||
// Params:
|
||||
// input - Final image to present
|
||||
// Output: {commands} (no target, just present command)
|
||||
//
|
||||
// Optimization notes:
|
||||
// - Nodes track whether they need an offscreen target or can render directly
|
||||
// - render_view to 'screen' skips intermediate target
|
||||
// - Sequential composites can be merged when possible
|
||||
// - mask uses stencil when available, falls back to RT+sample
|
||||
|
||||
var fx_graph = {}
|
||||
|
||||
// Add a node to the graph
|
||||
fx_graph.add_node = function(type, params) {
|
||||
params = params || {}
|
||||
var node = {
|
||||
id: this.next_id++,
|
||||
type: type,
|
||||
params: params || {},
|
||||
output: {node_id: this.next_id - 1, slot: 'output'} // Output handle
|
||||
params: params,
|
||||
output: {node_id: this.next_id - 1, slot: 'output'}
|
||||
}
|
||||
this.nodes.push(node)
|
||||
return node
|
||||
}
|
||||
|
||||
// Set final output
|
||||
fx_graph.set_output = function(output_handle) {
|
||||
this.output_node = output_handle
|
||||
}
|
||||
|
||||
// Execute graph using backend
|
||||
fx_graph.execute = function(backend) {
|
||||
// Topological sort (simple version - assumes DAG)
|
||||
var sorted = this.topological_sort()
|
||||
|
||||
// Execute each node
|
||||
var node_outputs = {} // node_id -> output data
|
||||
var node_outputs = {}
|
||||
var all_commands = []
|
||||
|
||||
for (var node of sorted) {
|
||||
var executor = NODE_EXECUTORS[node.type]
|
||||
if (!executor) {
|
||||
console.error(`No executor for node type: ${node.type}`)
|
||||
log.console(`fx_graph: No executor for node type: ${node.type}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve input handles to actual data
|
||||
var resolved_params = this.resolve_inputs(node.params, node_outputs)
|
||||
resolved_params._node_id = node.id
|
||||
|
||||
// Execute node (backend-specific)
|
||||
var result = executor(resolved_params, backend)
|
||||
|
||||
// Store output
|
||||
node_outputs[node.id] = result
|
||||
|
||||
// Collect commands from this node
|
||||
if (result && result.commands) {
|
||||
for (var cmd of result.commands) {
|
||||
all_commands.push(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return final output
|
||||
if (this.output_node)
|
||||
return node_outputs[this.output_node.node_id]
|
||||
|
||||
return {commands: []}
|
||||
return {commands: all_commands}
|
||||
}
|
||||
|
||||
// Resolve input handles to actual data
|
||||
fx_graph.resolve_inputs = function(params, node_outputs) {
|
||||
var resolved = {}
|
||||
for (var key in params) {
|
||||
var value = params[key]
|
||||
// If value is an output handle, resolve it
|
||||
if (value && value.node_id != null)
|
||||
resolved[key] = node_outputs[value.node_id]
|
||||
else
|
||||
@@ -65,59 +117,89 @@ fx_graph.resolve_inputs = function(params, node_outputs) {
|
||||
return resolved
|
||||
}
|
||||
|
||||
// Simple topological sort
|
||||
fx_graph.topological_sort = function() {
|
||||
// For now, just return nodes in order (assumes order == dependency order)
|
||||
// Real implementation would build dependency graph and sort properly
|
||||
// Nodes are added in dependency order by construction - you can't reference
|
||||
// a node's output before the node is created. So insertion order is already
|
||||
// a valid topological order.
|
||||
return this.nodes
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// NODE EXECUTORS (Backend-agnostic logic)
|
||||
// NODE EXECUTORS
|
||||
// ========================================================================
|
||||
// Each executor produces a RenderTarget or command list
|
||||
|
||||
var NODE_EXECUTORS = {}
|
||||
|
||||
// render_view: Traverse scene tree, collect drawables, render to target
|
||||
// render_view: Render scene tree to target
|
||||
NODE_EXECUTORS.render_view = function(params, backend) {
|
||||
var root = params.root
|
||||
var camera = params.camera
|
||||
var target_spec = params.target_spec
|
||||
var target_spec = params.target
|
||||
var clear_color = params.clear_color
|
||||
|
||||
// Allocate render target
|
||||
var target = backend.get_or_create_target(
|
||||
target_spec.width,
|
||||
target_spec.height,
|
||||
params.node_id || 'view'
|
||||
)
|
||||
// Determine if we need an offscreen target
|
||||
var needs_offscreen = target_spec != 'screen' && params._needs_offscreen != false
|
||||
|
||||
var target
|
||||
if (target_spec == 'screen') {
|
||||
target = 'screen'
|
||||
} else if (target_spec && target_spec.texture) {
|
||||
// Reuse existing target
|
||||
target = target_spec
|
||||
} else {
|
||||
// Allocate render target
|
||||
target = backend.get_or_create_target(
|
||||
target_spec.width,
|
||||
target_spec.height,
|
||||
'view_' + params._node_id
|
||||
)
|
||||
}
|
||||
|
||||
// Collect drawables from scene tree
|
||||
var drawables = collect_drawables(root, camera, [1,1,1,1], 1)
|
||||
var drawables = collect_drawables(root, camera)
|
||||
|
||||
// Sort drawables (SORTING HAPPENS HERE)
|
||||
// Sort by layer, then by Y for depth sorting
|
||||
drawables.sort((a, b) => {
|
||||
// First by layer
|
||||
return 0
|
||||
if (a.layer != b.layer) return a.layer - b.layer
|
||||
// Then by Y for depth sorting within layer
|
||||
return b.world_y - a.world_y
|
||||
})
|
||||
|
||||
// Batch drawables by material
|
||||
var batches = batch_drawables(drawables)
|
||||
|
||||
// Convert to render commands (backend-agnostic)
|
||||
// Build render 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)
|
||||
for (var batch of batches) {
|
||||
commands.push({
|
||||
cmd: 'draw_batch',
|
||||
geometry: batch.geometry,
|
||||
material: batch.material
|
||||
})
|
||||
if (batch.type == 'sprite_batch') {
|
||||
commands.push({
|
||||
cmd: 'draw_batch',
|
||||
batch_type: 'sprites',
|
||||
geometry: {sprites: batch.sprites},
|
||||
texture: batch.texture,
|
||||
material: batch.material
|
||||
})
|
||||
} else if (batch.type == 'text') {
|
||||
commands.push({
|
||||
cmd: 'draw_text',
|
||||
drawable: batch.drawable
|
||||
})
|
||||
} else if (batch.type == 'rect') {
|
||||
commands.push({
|
||||
cmd: 'draw_rect',
|
||||
drawable: batch.drawable
|
||||
})
|
||||
} else if (batch.type == 'particles') {
|
||||
commands.push({
|
||||
cmd: 'draw_batch',
|
||||
batch_type: 'particles',
|
||||
geometry: {sprites: batch.sprites},
|
||||
texture: batch.texture,
|
||||
material: batch.material
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
commands.push({cmd: 'end_render'})
|
||||
@@ -125,213 +207,311 @@ NODE_EXECUTORS.render_view = function(params, backend) {
|
||||
return {target: target, commands: commands}
|
||||
}
|
||||
|
||||
// composite: Combine two layers
|
||||
NODE_EXECUTORS.composite = function(params, backend) {
|
||||
var base = params.base
|
||||
var overlay = params.overlay
|
||||
var mode = params.mode || 'over'
|
||||
var opacity = params.opacity != null ? params.opacity : 1
|
||||
|
||||
// Optimization: if overlay opacity is 0, just return base
|
||||
if (opacity == 0) return base
|
||||
|
||||
// Optimization: if base is null/empty, just return overlay
|
||||
if (!base || !base.target) return overlay
|
||||
|
||||
// Optimization: if overlay is null/empty, just return base
|
||||
if (!overlay || !overlay.target) return base
|
||||
|
||||
var target = backend.get_or_create_target(
|
||||
base.target.width,
|
||||
base.target.height,
|
||||
'composite_' + params._node_id
|
||||
)
|
||||
|
||||
// Emit composite_textures command (handled outside render pass)
|
||||
var commands = []
|
||||
commands.push({
|
||||
cmd: 'composite_textures',
|
||||
base: base.target,
|
||||
overlay: overlay.target,
|
||||
output: target,
|
||||
mode: mode,
|
||||
opacity: opacity
|
||||
})
|
||||
|
||||
return {target: target, commands: commands}
|
||||
}
|
||||
|
||||
// mask: Apply mask to content
|
||||
NODE_EXECUTORS.mask = function(params, backend) {
|
||||
var content = params.content
|
||||
var mask = params.mask
|
||||
var invert = params.invert
|
||||
var mode = params.mode || 'alpha'
|
||||
var invert = params.invert || false
|
||||
|
||||
if (!content || !content.target) return {target: null, commands: []}
|
||||
if (!mask || !mask.target) return content
|
||||
|
||||
var target = backend.get_or_create_target(
|
||||
content.target.width,
|
||||
content.target.height,
|
||||
'mask_' + params.node_id
|
||||
'mask_' + params._node_id
|
||||
)
|
||||
|
||||
// Emit apply_mask command (handled via shader pass outside render pass)
|
||||
var commands = []
|
||||
commands.push({cmd: 'begin_render', target: target})
|
||||
commands.push({
|
||||
cmd: 'apply_mask',
|
||||
content_texture: content.target,
|
||||
mask_texture: mask.target,
|
||||
output: target,
|
||||
mode: mode,
|
||||
invert: invert
|
||||
})
|
||||
commands.push({cmd: 'end_render'})
|
||||
|
||||
return {target: target, commands: commands}
|
||||
}
|
||||
|
||||
// bloom: Blur bright pixels
|
||||
NODE_EXECUTORS.bloom = function(params, backend) {
|
||||
// clip_rect: Apply scissor clipping
|
||||
NODE_EXECUTORS.clip_rect = function(params, backend) {
|
||||
var input = params.input
|
||||
var radius = params.params.radius || params.radius || 10
|
||||
var intensity = params.params.intensity || params.intensity || 1.0
|
||||
var rect = params.rect
|
||||
|
||||
// Allocate temp targets for multi-pass
|
||||
var bright = backend.get_or_create_target(input.target.width, input.target.height, 'bloom_bright')
|
||||
var blurred = backend.get_or_create_target(input.target.width, input.target.height, 'bloom_blur')
|
||||
var result = backend.get_or_create_target(input.target.width, input.target.height, 'bloom_result')
|
||||
if (!input) return {target: null, commands: []}
|
||||
|
||||
var commands = []
|
||||
// Clip doesn't need a new target, just adds scissor to commands
|
||||
var commands = input.commands ? input.commands.slice() : []
|
||||
|
||||
// Pass 1: Extract bright pixels
|
||||
commands.push({cmd: 'begin_render', target: bright})
|
||||
commands.push({
|
||||
cmd: 'shader_pass',
|
||||
shader: 'threshold',
|
||||
input: input.target,
|
||||
params: {threshold: 0.5}
|
||||
})
|
||||
commands.push({cmd: 'end_render'})
|
||||
|
||||
// Pass 2: Blur
|
||||
commands.push({cmd: 'begin_render', target: blurred})
|
||||
commands.push({
|
||||
cmd: 'shader_pass',
|
||||
shader: 'gaussian_blur',
|
||||
input: bright,
|
||||
params: {radius: radius}
|
||||
})
|
||||
commands.push({cmd: 'end_render'})
|
||||
|
||||
// Pass 3: Composite
|
||||
commands.push({cmd: 'begin_render', target: result})
|
||||
commands.push({
|
||||
cmd: 'shader_pass',
|
||||
shader: 'add_textures',
|
||||
inputs: [input.target, blurred],
|
||||
params: {intensity: intensity}
|
||||
})
|
||||
commands.push({cmd: 'end_render'})
|
||||
|
||||
return {target: result, commands: commands}
|
||||
}
|
||||
|
||||
// composite: Combine two textures
|
||||
NODE_EXECUTORS.composite = function(params, backend) {
|
||||
var base = params.base
|
||||
var overlay = params.overlay
|
||||
var mode = params.mode || 'over'
|
||||
|
||||
var target = backend.get_or_create_target(base.target.width, base.target.height, 'composite')
|
||||
|
||||
var commands = []
|
||||
commands.push({cmd: 'begin_render', target: target})
|
||||
commands.push({
|
||||
cmd: 'composite',
|
||||
base_texture: base.target,
|
||||
overlay_texture: overlay.target,
|
||||
mode: mode
|
||||
})
|
||||
commands.push({cmd: 'end_render'})
|
||||
|
||||
return {target: target, commands: commands}
|
||||
}
|
||||
|
||||
// crt: CRT filter post-effect
|
||||
NODE_EXECUTORS.crt = function(params, backend) {
|
||||
var input = params.input
|
||||
var scanlines = params.params.scanlines || 0.5
|
||||
var curvature = params.params.curvature || 0.1
|
||||
|
||||
var target = backend.get_or_create_target(input.target.width, input.target.height, 'crt')
|
||||
|
||||
var commands = []
|
||||
commands.push({cmd: 'begin_render', target: target})
|
||||
commands.push({
|
||||
cmd: 'shader_pass',
|
||||
shader: 'crt_filter',
|
||||
input: input.target,
|
||||
params: {scanlines: scanlines, curvature: curvature}
|
||||
})
|
||||
commands.push({cmd: 'end_render'})
|
||||
|
||||
return {target: target, commands: commands}
|
||||
}
|
||||
|
||||
// screen_composite: Final blit to screen with aspect fitting
|
||||
NODE_EXECUTORS.screen_composite = function(params, backend) {
|
||||
var world = params.world
|
||||
var ui = params.ui
|
||||
var window_size = params.window_size
|
||||
|
||||
var commands = []
|
||||
commands.push({cmd: 'begin_render', target: 'screen'})
|
||||
commands.push({cmd: 'clear', color: {r: 0, g: 0, b: 0, a: 1}})
|
||||
|
||||
if (world) {
|
||||
var world_rect = fit_to_screen(params.world_target_spec, window_size)
|
||||
commands.push({
|
||||
cmd: 'blit',
|
||||
texture: world.target,
|
||||
dst_rect: world_rect,
|
||||
filter: params.world_target_spec.filter
|
||||
})
|
||||
// Insert scissor after begin_render
|
||||
var insert_idx = 0
|
||||
for (var i = 0; i < commands.length; i++) {
|
||||
if (commands[i].cmd == 'begin_render') {
|
||||
insert_idx = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (ui) {
|
||||
var ui_rect = fit_to_screen(params.ui_target_spec, window_size)
|
||||
commands.push({
|
||||
cmd: 'blit',
|
||||
texture: ui.target,
|
||||
dst_rect: ui_rect,
|
||||
filter: params.ui_target_spec.filter
|
||||
})
|
||||
commands.splice(insert_idx, 0, {cmd: 'scissor', rect: rect})
|
||||
|
||||
// Add scissor reset before end_render
|
||||
for (var i = commands.length - 1; i >= 0; i--) {
|
||||
if (commands[i].cmd == 'end_render') {
|
||||
commands.splice(i, 0, {cmd: 'scissor', rect: null})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
commands.push({cmd: 'end_render'})
|
||||
return {target: input.target, commands: commands}
|
||||
}
|
||||
|
||||
// blit: Copy/scale image to target
|
||||
NODE_EXECUTORS.blit = function(params, backend) {
|
||||
var input = params.input
|
||||
var target_spec = params.target
|
||||
var dst_rect = params.dst_rect
|
||||
var filter = params.filter || 'nearest'
|
||||
|
||||
var src_target = input && input.target ? input.target : input
|
||||
if (!src_target) return {target: null, commands: []}
|
||||
|
||||
var target
|
||||
if (target_spec == 'screen') {
|
||||
target = 'screen'
|
||||
} else if (target_spec && target_spec.target) {
|
||||
// Output reference from another node - use its target
|
||||
target = target_spec.target
|
||||
} else if (target_spec && target_spec.texture) {
|
||||
// Already a render target
|
||||
target = target_spec
|
||||
} else if (target_spec && target_spec.width) {
|
||||
// Target spec - use a consistent key based on the spec itself
|
||||
var key = `blit_${target_spec.width}x${target_spec.height}`
|
||||
target = backend.get_or_create_target(target_spec.width, target_spec.height, key)
|
||||
} else {
|
||||
return {target: null, commands: []}
|
||||
}
|
||||
|
||||
var commands = []
|
||||
commands.push({
|
||||
cmd: 'blit',
|
||||
texture: src_target,
|
||||
target: target,
|
||||
dst_rect: dst_rect,
|
||||
filter: filter
|
||||
})
|
||||
|
||||
return {target: target, commands: commands}
|
||||
}
|
||||
|
||||
// present: Present to display
|
||||
NODE_EXECUTORS.present = function(params, backend) {
|
||||
var input = params.input
|
||||
|
||||
var commands = []
|
||||
commands.push({cmd: 'present'})
|
||||
|
||||
return {commands: commands}
|
||||
}
|
||||
|
||||
// shader_pass: Generic shader pass
|
||||
NODE_EXECUTORS.shader_pass = function(params, backend) {
|
||||
var input = params.input
|
||||
var shader = params.shader
|
||||
var uniforms = params.uniforms || {}
|
||||
var output_spec = params.output
|
||||
|
||||
if (!input || !input.target) return {target: null, commands: []}
|
||||
|
||||
var src = input.target
|
||||
var target
|
||||
|
||||
if (output_spec == 'screen') {
|
||||
target = 'screen'
|
||||
} else if (output_spec && output_spec.texture) {
|
||||
target = output_spec
|
||||
} else {
|
||||
// Default to input size if not specified
|
||||
var w = output_spec && output_spec.width ? output_spec.width : src.width
|
||||
var h = output_spec && output_spec.height ? output_spec.height : src.height
|
||||
target = backend.get_or_create_target(w, h, 'shader_' + shader + '_' + params._node_id)
|
||||
}
|
||||
|
||||
var commands = []
|
||||
commands.push({
|
||||
cmd: 'shader_pass',
|
||||
shader: shader,
|
||||
input: src,
|
||||
output: target,
|
||||
uniforms: uniforms
|
||||
})
|
||||
|
||||
return {target: target, commands: commands}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SCENE TREE TRAVERSAL (Backend-agnostic)
|
||||
// SCENE TREE TRAVERSAL
|
||||
// ========================================================================
|
||||
|
||||
function collect_drawables(node, camera, parent_tint, parent_opacity) {
|
||||
if (!node) return []
|
||||
|
||||
parent_tint = parent_tint || [1, 1, 1, 1]
|
||||
parent_opacity = parent_opacity != null ? parent_opacity : 1
|
||||
|
||||
var drawables = []
|
||||
|
||||
// Compute inherited tint/opacity
|
||||
var node_tint = node.tint || node.color
|
||||
var world_tint = [
|
||||
parent_tint[0] * (node.tint ? node.tint[0] : 1),
|
||||
parent_tint[1] * (node.tint ? node.tint[1] : 1),
|
||||
parent_tint[2] * (node.tint ? node.tint[2] : 1),
|
||||
parent_tint[3] * (node.tint ? node.tint[3] : 1)
|
||||
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)
|
||||
|
||||
// If drawable, add it
|
||||
if (node.type == 'sprite') {
|
||||
// Handle different node types
|
||||
if (node.type == 'sprite' || (node.image && !node.type)) {
|
||||
var pos = node.pos || {x: 0, y: 0}
|
||||
drawables.push({
|
||||
type: 'sprite',
|
||||
layer: node.layer || 0,
|
||||
world_y: node.pos ? node.pos[1] : 0,
|
||||
pos: node.pos || [0, 0],
|
||||
world_y: pos.y != null ? pos.y : (pos[1] || 0),
|
||||
pos: pos,
|
||||
image: node.image,
|
||||
texture: node.texture,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
color: multiply_color(node.color || {r:1,g:1,b:1,a:1}, world_tint, world_opacity),
|
||||
width: node.width || 1,
|
||||
height: node.height || 1,
|
||||
anchor_x: node.anchor_x || 0,
|
||||
anchor_y: node.anchor_y || 0,
|
||||
color: tint_to_color(world_tint, world_opacity),
|
||||
material: node.material
|
||||
})
|
||||
}
|
||||
|
||||
if (node.type == 'text') {
|
||||
var pos = node.pos || {x: 0, y: 0}
|
||||
drawables.push({
|
||||
type: 'text',
|
||||
layer: node.layer || 0,
|
||||
world_y: node.pos ? node.pos[1] : 0,
|
||||
pos: node.pos || [0, 0],
|
||||
world_y: pos.y != null ? pos.y : (pos[1] || 0),
|
||||
pos: pos,
|
||||
text: node.text,
|
||||
font: node.font,
|
||||
size: node.size,
|
||||
color: multiply_color(node.color || {r:1,g:1,b:1,a:1}, world_tint, world_opacity)
|
||||
color: tint_to_color(world_tint, world_opacity)
|
||||
})
|
||||
}
|
||||
|
||||
if (node.type == 'rect') {
|
||||
var pos = node.pos || {x: 0, y: 0}
|
||||
drawables.push({
|
||||
type: 'rect',
|
||||
layer: node.layer || 0,
|
||||
world_y: node.pos ? node.pos[1] : 0,
|
||||
pos: node.pos || [0, 0],
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
color: multiply_color(node.color || {r:1,g:1,b:1,a:1}, world_tint, world_opacity)
|
||||
world_y: pos.y != null ? pos.y : (pos[1] || 0),
|
||||
pos: pos,
|
||||
width: node.width || 1,
|
||||
height: node.height || 1,
|
||||
color: tint_to_color(world_tint, world_opacity)
|
||||
})
|
||||
}
|
||||
|
||||
if (node.type == 'particles' || node.particles) {
|
||||
var particles = node.particles || []
|
||||
for (var p of particles) {
|
||||
drawables.push({
|
||||
type: 'sprite',
|
||||
layer: node.layer || 0,
|
||||
world_y: p.pos ? p.pos.y : 0,
|
||||
pos: p.pos || {x: 0, y: 0},
|
||||
image: node.image,
|
||||
texture: node.texture,
|
||||
width: (node.width || 1) * (p.scale || 1),
|
||||
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),
|
||||
material: node.material
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type == 'tilemap' || node.tiles) {
|
||||
// Tilemap emits multiple sprites
|
||||
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
|
||||
|
||||
for (var x = 0; x < tiles.length; x++) {
|
||||
if (!tiles[x]) continue
|
||||
for (var y = 0; y < tiles[x].length; y++) {
|
||||
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
|
||||
|
||||
drawables.push({
|
||||
type: 'sprite',
|
||||
layer: node.layer || 0,
|
||||
world_y: world_y_pos,
|
||||
pos: {x: world_x, y: world_y_pos},
|
||||
image: tile,
|
||||
texture: tile,
|
||||
width: scale_x,
|
||||
height: scale_y,
|
||||
anchor_x: 0,
|
||||
anchor_y: 0,
|
||||
color: tint_to_color(world_tint, world_opacity),
|
||||
material: node.material
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse children
|
||||
if (node.children) {
|
||||
for (var child of node.children) {
|
||||
@@ -343,17 +523,17 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) {
|
||||
return drawables
|
||||
}
|
||||
|
||||
function multiply_color(color, tint, opacity) {
|
||||
function tint_to_color(tint, opacity) {
|
||||
return {
|
||||
r: color.r * tint[0],
|
||||
g: color.g * tint[1],
|
||||
b: color.b * tint[2],
|
||||
a: color.a * tint[3] * opacity
|
||||
r: tint[0],
|
||||
g: tint[1],
|
||||
b: tint[2],
|
||||
a: tint[3] * opacity
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// BATCHING (Backend-agnostic, but sprite-specific)
|
||||
// BATCHING
|
||||
// ========================================================================
|
||||
|
||||
function batch_drawables(drawables) {
|
||||
@@ -362,11 +542,12 @@ function batch_drawables(drawables) {
|
||||
|
||||
for (var drawable of drawables) {
|
||||
if (drawable.type == 'sprite') {
|
||||
var texture = drawable.texture
|
||||
var texture = drawable.texture || drawable.image
|
||||
var material = drawable.material || {blend: 'alpha', sampler: 'nearest'}
|
||||
|
||||
// Start new batch if texture/material changed
|
||||
if (!current_batch ||
|
||||
current_batch.type != 'sprite_batch' ||
|
||||
current_batch.texture != texture ||
|
||||
!materials_equal(current_batch.material, material)) {
|
||||
if (current_batch) batches.push(current_batch)
|
||||
@@ -380,7 +561,7 @@ function batch_drawables(drawables) {
|
||||
|
||||
current_batch.sprites.push(drawable)
|
||||
} else {
|
||||
// Non-sprite drawable: flush batch, add individually
|
||||
// Non-sprite: flush batch, add individually
|
||||
if (current_batch) {
|
||||
batches.push(current_batch)
|
||||
current_batch = null
|
||||
@@ -391,16 +572,11 @@ function batch_drawables(drawables) {
|
||||
|
||||
if (current_batch) batches.push(current_batch)
|
||||
|
||||
// Convert sprite batches to geometry
|
||||
for (var batch of batches) {
|
||||
if (batch.type == 'sprite_batch')
|
||||
batch.geometry = sprites_to_geometry(batch.sprites)
|
||||
}
|
||||
|
||||
return batches
|
||||
}
|
||||
|
||||
function materials_equal(a, b) {
|
||||
if (!a || !b) return a == b
|
||||
return a.blend == b.blend && a.sampler == b.sampler && a.shader == b.shader
|
||||
}
|
||||
|
||||
@@ -410,24 +586,30 @@ function sprites_to_geometry(sprites) {
|
||||
var vertex_count = 0
|
||||
|
||||
for (var s of sprites) {
|
||||
var x = s.pos[0]
|
||||
var y = s.pos[1]
|
||||
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 w = s.width || 1
|
||||
var h = s.height || 1
|
||||
var c = s.color
|
||||
var ax = s.anchor_x || 0
|
||||
var ay = s.anchor_y || 0
|
||||
var c = s.color || {r: 1, g: 1, b: 1, a: 1}
|
||||
|
||||
// Apply anchor offset
|
||||
var x = px - w * ax
|
||||
var y = py - h * ay
|
||||
|
||||
// Quad vertices (pos, uv, color)
|
||||
vertices.push(
|
||||
{pos: [x, y], uv: [0, 0], color: c},
|
||||
{pos: [x+w, y], uv: [1, 0], color: c},
|
||||
{pos: [x+w, y+h], uv: [1, 1], color: c},
|
||||
{pos: [x, y+h], uv: [0, 1], color: c}
|
||||
{pos: [x + w, y], uv: [1, 0], color: c},
|
||||
{pos: [x + w, y + h], uv: [1, 1], color: c},
|
||||
{pos: [x, y + h], uv: [0, 1], color: c}
|
||||
)
|
||||
|
||||
// Two triangles
|
||||
indices.push(
|
||||
vertex_count, vertex_count+1, vertex_count+2,
|
||||
vertex_count, vertex_count+2, vertex_count+3
|
||||
vertex_count, vertex_count + 1, vertex_count + 2,
|
||||
vertex_count, vertex_count + 2, vertex_count + 3
|
||||
)
|
||||
vertex_count += 4
|
||||
}
|
||||
@@ -435,13 +617,17 @@ function sprites_to_geometry(sprites) {
|
||||
return {vertices: vertices, indices: indices}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// UTILITY: Fit rectangle to screen with aspect preservation
|
||||
// ========================================================================
|
||||
|
||||
function fit_to_screen(target_spec, window_size) {
|
||||
var src_aspect = target_spec.width / target_spec.height
|
||||
var dst_aspect = window_size.width / window_size.height
|
||||
|
||||
var scale = src_aspect > dst_aspect ?
|
||||
window_size.width / target_spec.width :
|
||||
window_size.height / target_spec.height
|
||||
var scale = src_aspect > dst_aspect
|
||||
? window_size.width / target_spec.width
|
||||
: window_size.height / target_spec.height
|
||||
|
||||
var w = target_spec.width * scale
|
||||
var h = target_spec.height * scale
|
||||
@@ -451,10 +637,16 @@ function fit_to_screen(target_spec, window_size) {
|
||||
return {x: x, y: y, width: w, height: h}
|
||||
}
|
||||
|
||||
return function() {
|
||||
// Export fit_to_screen for external use
|
||||
fx_graph.fit_to_screen = fit_to_screen
|
||||
function make_fxgraph() {
|
||||
return meme(fx_graph, {
|
||||
nodes:[],
|
||||
nodes: [],
|
||||
output_node: null,
|
||||
next_id: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
make_fxgraph.fit_to_screen = fit_to_screen
|
||||
|
||||
return make_fxgraph
|
||||
|
||||
@@ -17,6 +17,7 @@ Resources.scripts = ["js"]
|
||||
Resources.images = ["qoi", "png", "gif", "jpg", "jpeg", "ase", "aseprite"]
|
||||
Resources.sounds = ["wav", "flac", "mp3", "qoa"]
|
||||
Resources.fonts = ["ttf"]
|
||||
Resources.lib = [".so", ".dll", ".dylib"]
|
||||
|
||||
// Helper function: get extension from path in lowercase (e.g., "image.png" -> "png")
|
||||
function getExtension(path) {
|
||||
|
||||
1880
sdl_gpu.cm
1880
sdl_gpu.cm
File diff suppressed because it is too large
Load Diff
15
shaders/msl/blit.frag.msl
Normal file
15
shaders/msl/blit.frag.msl
Normal file
@@ -0,0 +1,15 @@
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct FragmentIn {
|
||||
float4 position [[position]];
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
fragment float4 fragment_main(
|
||||
FragmentIn in [[stage_in]],
|
||||
texture2d<float> tex [[texture(0)]],
|
||||
sampler samp [[sampler(0)]]
|
||||
) {
|
||||
return tex.sample(samp, in.uv);
|
||||
}
|
||||
19
shaders/msl/blit.vert.msl
Normal file
19
shaders/msl/blit.vert.msl
Normal file
@@ -0,0 +1,19 @@
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct VertexIn {
|
||||
float2 position [[attribute(0)]];
|
||||
float2 uv [[attribute(1)]];
|
||||
};
|
||||
|
||||
struct VertexOut {
|
||||
float4 position [[position]];
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
vertex VertexOut vertex_main(VertexIn in [[stage_in]]) {
|
||||
VertexOut out;
|
||||
out.position = float4(in.position, 0.0, 1.0);
|
||||
out.uv = in.uv;
|
||||
return out;
|
||||
}
|
||||
34
shaders/msl/blur.frag.msl
Normal file
34
shaders/msl/blur.frag.msl
Normal file
@@ -0,0 +1,34 @@
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct Uniforms {
|
||||
float2 direction; // (1,0) for horizontal, (0,1) for vertical
|
||||
float2 texel_size; // 1.0 / texture_size
|
||||
};
|
||||
|
||||
struct FragmentIn {
|
||||
float4 position [[position]];
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
// 9-tap Gaussian blur
|
||||
fragment float4 fragment_main(
|
||||
FragmentIn in [[stage_in]],
|
||||
constant Uniforms &uniforms [[buffer(0)]],
|
||||
texture2d<float> tex [[texture(0)]],
|
||||
sampler samp [[sampler(0)]]
|
||||
) {
|
||||
float2 offset = uniforms.direction * uniforms.texel_size;
|
||||
|
||||
// Gaussian weights for 9 samples
|
||||
float weights[5] = {0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216};
|
||||
|
||||
float4 result = tex.sample(samp, in.uv) * weights[0];
|
||||
|
||||
for (int i = 1; i < 5; i++) {
|
||||
result += tex.sample(samp, in.uv + offset * float(i)) * weights[i];
|
||||
result += tex.sample(samp, in.uv - offset * float(i)) * weights[i];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
63
shaders/msl/crt.frag.msl
Normal file
63
shaders/msl/crt.frag.msl
Normal file
@@ -0,0 +1,63 @@
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct Uniforms {
|
||||
float curvature;
|
||||
float scanline_intensity;
|
||||
float vignette;
|
||||
float padding;
|
||||
float2 resolution;
|
||||
float2 padding2;
|
||||
};
|
||||
|
||||
struct FragmentIn {
|
||||
float4 position [[position]];
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
float2 curve_uv(float2 uv, float curvature) {
|
||||
uv = uv * 2.0 - 1.0;
|
||||
float2 offset = abs(uv.yx) / float2(curvature, curvature);
|
||||
uv = uv + uv * offset * offset;
|
||||
uv = uv * 0.5 + 0.5;
|
||||
return uv;
|
||||
}
|
||||
|
||||
fragment float4 fragment_main(
|
||||
FragmentIn in [[stage_in]],
|
||||
constant Uniforms &uniforms [[buffer(0)]],
|
||||
texture2d<float> tex [[texture(0)]],
|
||||
sampler samp [[sampler(0)]]
|
||||
) {
|
||||
float2 uv = in.uv;
|
||||
|
||||
// Apply curvature
|
||||
if (uniforms.curvature > 0.0) {
|
||||
uv = curve_uv(uv, 6.0 / uniforms.curvature);
|
||||
}
|
||||
|
||||
// Check if outside screen after curvature
|
||||
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
|
||||
return float4(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
float4 color = tex.sample(samp, uv);
|
||||
|
||||
// Scanlines
|
||||
float scanline = sin(uv.y * uniforms.resolution.y * 3.14159) * 0.5 + 0.5;
|
||||
scanline = pow(scanline, 1.5);
|
||||
color.rgb *= 1.0 - uniforms.scanline_intensity * (1.0 - scanline);
|
||||
|
||||
// Vignette
|
||||
float2 vig_uv = uv * (1.0 - uv.yx);
|
||||
float vig = vig_uv.x * vig_uv.y * 15.0;
|
||||
vig = pow(vig, uniforms.vignette);
|
||||
color.rgb *= vig;
|
||||
|
||||
// Slight RGB shift for chromatic aberration
|
||||
float2 offset = (uv - 0.5) * 0.002;
|
||||
color.r = tex.sample(samp, uv + offset).r;
|
||||
color.b = tex.sample(samp, uv - offset).b;
|
||||
|
||||
return color;
|
||||
}
|
||||
40
shaders/msl/mask.frag.msl
Normal file
40
shaders/msl/mask.frag.msl
Normal file
@@ -0,0 +1,40 @@
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct Uniforms {
|
||||
float invert;
|
||||
float mode; // 0 = alpha, 1 = binary
|
||||
float2 padding;
|
||||
};
|
||||
|
||||
struct FragmentIn {
|
||||
float4 position [[position]];
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
fragment float4 fragment_main(
|
||||
FragmentIn in [[stage_in]],
|
||||
constant Uniforms &uniforms [[buffer(0)]],
|
||||
texture2d<float> content_tex [[texture(0)]],
|
||||
texture2d<float> mask_tex [[texture(1)]],
|
||||
sampler samp [[sampler(0)]]
|
||||
) {
|
||||
float4 content = content_tex.sample(samp, in.uv);
|
||||
float4 mask = mask_tex.sample(samp, in.uv);
|
||||
|
||||
// Get mask value (use alpha channel)
|
||||
float mask_value = mask.a;
|
||||
|
||||
// Binary mode: threshold at 0.5
|
||||
if (uniforms.mode > 0.5) {
|
||||
mask_value = mask_value > 0.5 ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
// Invert if requested
|
||||
if (uniforms.invert > 0.5) {
|
||||
mask_value = 1.0 - mask_value;
|
||||
}
|
||||
|
||||
// Apply mask to content alpha
|
||||
return float4(content.rgb, content.a * mask_value);
|
||||
}
|
||||
17
shaders/msl/sprite2d.frag.msl
Normal file
17
shaders/msl/sprite2d.frag.msl
Normal file
@@ -0,0 +1,17 @@
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct FragmentIn {
|
||||
float4 position [[position]];
|
||||
float2 uv;
|
||||
float4 color;
|
||||
};
|
||||
|
||||
fragment float4 fragment_main(
|
||||
FragmentIn in [[stage_in]],
|
||||
texture2d<float> tex [[texture(0)]],
|
||||
sampler samp [[sampler(0)]]
|
||||
) {
|
||||
float4 tex_color = tex.sample(samp, in.uv);
|
||||
return tex_color * in.color;
|
||||
}
|
||||
29
shaders/msl/sprite2d.vert.msl
Normal file
29
shaders/msl/sprite2d.vert.msl
Normal file
@@ -0,0 +1,29 @@
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct Uniforms {
|
||||
float4x4 projection;
|
||||
};
|
||||
|
||||
struct VertexIn {
|
||||
float2 position [[attribute(0)]];
|
||||
float2 uv [[attribute(1)]];
|
||||
float4 color [[attribute(2)]];
|
||||
};
|
||||
|
||||
struct VertexOut {
|
||||
float4 position [[position]];
|
||||
float2 uv;
|
||||
float4 color;
|
||||
};
|
||||
|
||||
vertex VertexOut vertex_main(
|
||||
VertexIn in [[stage_in]],
|
||||
constant Uniforms &uniforms [[buffer(0)]]
|
||||
) {
|
||||
VertexOut out;
|
||||
out.position = uniforms.projection * float4(in.position, 0.0, 1.0);
|
||||
out.uv = in.uv;
|
||||
out.color = in.color;
|
||||
return out;
|
||||
}
|
||||
31
shaders/msl/threshold.frag.msl
Normal file
31
shaders/msl/threshold.frag.msl
Normal file
@@ -0,0 +1,31 @@
|
||||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct Uniforms {
|
||||
float threshold;
|
||||
float intensity;
|
||||
float2 padding;
|
||||
};
|
||||
|
||||
struct FragmentIn {
|
||||
float4 position [[position]];
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
fragment float4 fragment_main(
|
||||
FragmentIn in [[stage_in]],
|
||||
constant Uniforms &uniforms [[buffer(0)]],
|
||||
texture2d<float> tex [[texture(0)]],
|
||||
sampler samp [[sampler(0)]]
|
||||
) {
|
||||
float4 color = tex.sample(samp, in.uv);
|
||||
|
||||
// Calculate luminance
|
||||
float luma = dot(color.rgb, float3(0.299, 0.587, 0.114));
|
||||
|
||||
// Extract bright pixels above threshold
|
||||
float brightness = max(0.0, luma - uniforms.threshold);
|
||||
|
||||
// Return bright pixels multiplied by intensity
|
||||
return float4(color.rgb * brightness * uniforms.intensity, color.a);
|
||||
}
|
||||
Reference in New Issue
Block a user