468 lines
14 KiB
Plaintext
468 lines
14 KiB
Plaintext
var film2d = {}
|
|
var backend = null
|
|
|
|
film2d.set_backend = function(b) {
|
|
backend = b
|
|
}
|
|
|
|
var next_id = 1
|
|
var registry = {} // id -> drawable
|
|
var group_index = {} // group_name -> [id, id, ...]
|
|
var plane_index = {} // plane_name -> [id, id, ...]
|
|
|
|
// Resolve sprite dimensions and UV based on fit mode
|
|
// fit: 'fill' | 'contain' | 'cover' | 'none'
|
|
// - fill: stretch to exactly match box (distort)
|
|
// - contain: fit inside box, preserve aspect (letterbox)
|
|
// - cover: fill box, preserve aspect (crop)
|
|
// - none: use native pixel size (or pixels_per_tile driven)
|
|
function _resolve_sprite_fit(sprite) {
|
|
if (!backend || !backend.get_texture_info) return sprite
|
|
|
|
var img = sprite.texture || sprite.image
|
|
if (!img) return sprite
|
|
|
|
var tex_info = backend.get_texture_info(img)
|
|
if (!tex_info || !tex_info.width || !tex_info.height) return sprite
|
|
|
|
var tex_w = tex_info.width
|
|
var tex_h = tex_info.height
|
|
var tex_aspect = tex_w / tex_h
|
|
|
|
var target_w = sprite.width
|
|
var target_h = sprite.height
|
|
var fit = sprite.fit || 'none'
|
|
|
|
// If one dimension is null, derive from aspect ratio
|
|
if (target_w == null && target_h != null) {
|
|
target_w = target_h * tex_aspect
|
|
sprite.width = target_w
|
|
sprite.height = target_h
|
|
return sprite
|
|
}
|
|
if (target_h == null && target_w != null) {
|
|
target_h = target_w / tex_aspect
|
|
sprite.width = target_w
|
|
sprite.height = target_h
|
|
return sprite
|
|
}
|
|
|
|
// Both null - use native size (1 pixel = 1 unit, or could use pixels_per_tile)
|
|
if (target_w == null && target_h == null) {
|
|
sprite.width = tex_w
|
|
sprite.height = tex_h
|
|
return sprite
|
|
}
|
|
|
|
// Both dimensions specified - apply fit mode
|
|
var box_aspect = target_w / target_h
|
|
|
|
if (fit == 'fill') {
|
|
// Stretch to fill - no changes needed, just use target dimensions
|
|
sprite.width = target_w
|
|
sprite.height = target_h
|
|
return sprite
|
|
}
|
|
|
|
if (fit == 'contain') {
|
|
// Fit inside box, preserve aspect (letterbox)
|
|
var scale
|
|
if (tex_aspect > box_aspect) {
|
|
// Image wider than box - constrain by width
|
|
scale = target_w / tex_w
|
|
} else {
|
|
// Image taller than box - constrain by height
|
|
scale = target_h / tex_h
|
|
}
|
|
sprite.width = tex_w * scale
|
|
sprite.height = tex_h * scale
|
|
return sprite
|
|
}
|
|
|
|
if (fit == 'cover') {
|
|
// Fill box, preserve aspect (crop via UV)
|
|
var fit_ax = sprite.fit_anchor_x != null ? sprite.fit_anchor_x : 0.5
|
|
var fit_ay = sprite.fit_anchor_y != null ? sprite.fit_anchor_y : 0.5
|
|
|
|
var scale_w = target_w / tex_w
|
|
var scale_h = target_h / tex_h
|
|
var scale = max(scale_w, scale_h)
|
|
|
|
// Compute visible portion of texture in UV space
|
|
var visible_w = target_w / scale
|
|
var visible_h = target_h / scale
|
|
|
|
// UV rect (0-1 space)
|
|
var uv_w = visible_w / tex_w
|
|
var uv_h = visible_h / tex_h
|
|
var uv_x = (1 - uv_w) * fit_ax
|
|
var uv_y = (1 - uv_h) * fit_ay
|
|
|
|
sprite.width = target_w
|
|
sprite.height = target_h
|
|
sprite.uv_rect = {x: uv_x, y: uv_y, width: uv_w, height: uv_h}
|
|
return sprite
|
|
}
|
|
|
|
// fit == 'none' - use native size
|
|
sprite.width = tex_w
|
|
sprite.height = tex_h
|
|
return sprite
|
|
}
|
|
|
|
film2d.register = function(drawable) {
|
|
var id = text(next_id++)
|
|
drawable._id = id
|
|
registry[id] = drawable
|
|
|
|
// Index by plane
|
|
var plane = drawable.plane || 'default'
|
|
if (!plane_index[plane]) plane_index[plane] = []
|
|
plane_index[plane].push(id)
|
|
|
|
// Index by groups (effect routing only)
|
|
var groups = drawable.groups || []
|
|
for (var i = 0; i < length(groups); i++) {
|
|
var g = groups[i]
|
|
if (!group_index[g]) group_index[g] = []
|
|
group_index[g].push(id)
|
|
}
|
|
|
|
return id
|
|
}
|
|
|
|
film2d.unregister = function(id) {
|
|
var id_str = text(id)
|
|
var drawable = registry[id_str]
|
|
if (!drawable) return
|
|
|
|
// Remove from plane index
|
|
var plane = drawable.plane || 'default'
|
|
if (plane_index[plane]) {
|
|
var idx = find(plane_index[plane], id_str)
|
|
if (idx != null) plane_index[plane].splice(idx, 1)
|
|
}
|
|
|
|
// Remove from group indices
|
|
var groups = drawable.groups || []
|
|
for (var i = 0; i < length(groups); i++) {
|
|
var g = groups[i]
|
|
if (group_index[g]) {
|
|
var idx = find(group_index[g], id_str)
|
|
if (idx != null) group_index[g].splice(idx, 1)
|
|
}
|
|
}
|
|
|
|
delete registry[id_str]
|
|
}
|
|
|
|
film2d.index_group = function(id, group) {
|
|
if (!group_index[group]) group_index[group] = []
|
|
if (search(group_index[group], text(id)) == null)
|
|
group_index[group].push(text(id))
|
|
}
|
|
|
|
film2d.unindex_group = function(id, group) {
|
|
if (!group_index[group]) return
|
|
var idx = search(group_index[group], text(id))
|
|
if (idx != null) group_index[group].splice(idx, 1)
|
|
}
|
|
|
|
film2d.reindex = function(id, old_groups, new_groups) {
|
|
for (var i = 0; i < length(old_groups); i++)
|
|
film2d.unindex_group(id, old_groups[i])
|
|
for (var i = 0; i < length(new_groups); i++)
|
|
film2d.index_group(id, new_groups[i])
|
|
}
|
|
|
|
film2d.get = function(id) {
|
|
return registry[text(id)]
|
|
}
|
|
|
|
// Query by plane and/or group - returns array of drawables
|
|
film2d.query = function(selector) {
|
|
var result = []
|
|
|
|
// Query by plane (primary selection)
|
|
if (selector.plane) {
|
|
var ids = plane_index[selector.plane] || []
|
|
for (var i = 0; i < length(ids); i++) {
|
|
var d = registry[ids[i]]
|
|
if (d && d.visible != false) {
|
|
// If also filtering by group, check membership
|
|
if (selector.group) {
|
|
var groups = d.groups || []
|
|
if (search(groups, selector.group) != null) result.push(d)
|
|
} else {
|
|
result.push(d)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Query by group only (for effect routing)
|
|
if (selector.group) {
|
|
var ids = group_index[selector.group] || []
|
|
for (var i = 0; i < length(ids); i++) {
|
|
var d = registry[ids[i]]
|
|
if (d && d.visible != false) result.push(d)
|
|
}
|
|
return result
|
|
}
|
|
|
|
if (selector.groups) {
|
|
var seen = {}
|
|
for (var g = 0; g < length(selector.groups); g++) {
|
|
var ids = group_index[selector.groups[g]] || []
|
|
for (var i = 0; i < length(ids); i++) {
|
|
if (!seen[ids[i]]) {
|
|
seen[ids[i]] = true
|
|
var d = registry[ids[i]]
|
|
if (d && d.visible != false) result.push(d)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// All drawables
|
|
var draws = array(registry, id => registry[id])
|
|
result = array(result, filter(draws, d => d.visible != false))
|
|
return result
|
|
}
|
|
|
|
// Get all groups a drawable belongs to
|
|
film2d.get_groups = function(id) {
|
|
var d = registry[text(id)]
|
|
return d ? (d.groups || []) : []
|
|
}
|
|
|
|
// List all known groups
|
|
film2d.all_groups = function() {
|
|
var groups = []
|
|
arrfor(array(group_index), g => {
|
|
if (length(group_index[g]) > 0) groups.push(g)
|
|
})
|
|
return groups
|
|
}
|
|
|
|
// Render function - takes drawables directly, no tree traversal
|
|
film2d.render = function(params, render_backend) {
|
|
backend = render_backend
|
|
|
|
var drawables = params.drawables || []
|
|
var camera = params.camera
|
|
var target = params.target
|
|
var target_size = params.target_size
|
|
var clear_color = params.clear
|
|
var layer_sort = params.layer_sort || {} // layer(text) -> "y" or "explicit"
|
|
|
|
if (length(drawables) == 0) return { commands: [] }
|
|
|
|
function _y_sort_key(d) {
|
|
if (!d || !d.pos) return 0
|
|
var y = d.pos.y || 0
|
|
var h = d.height || 0
|
|
var ay = d.anchor_y
|
|
if (ay == null) ay = 0.5
|
|
return y + h * (1 - ay) // "pos at anchor" -> "feet y"
|
|
}
|
|
|
|
// Deterministic "explicit" order anchor (keep if you want _id tie-break behavior)
|
|
drawables = sort(drawables, "_id")
|
|
|
|
// Bucket drawables by layer
|
|
var buckets = {}
|
|
for (var i = 0; i < length(drawables); i++) {
|
|
var d = drawables[i]
|
|
var layer_key = text(d.layer)
|
|
var b = buckets[layer_key]
|
|
if (!b) {
|
|
b = []
|
|
buckets[layer_key] = b
|
|
}
|
|
b.push(d)
|
|
}
|
|
|
|
// Sort layers numerically (keys are text)
|
|
var layers = array(buckets) // text keys
|
|
var layer_nums = array(layers, k => number(k))
|
|
layers = sort(layers, layer_nums)
|
|
|
|
// Merge buckets, y-sorting buckets that request it
|
|
var y_down = camera && camera.y_down == true
|
|
var sorted_drawables = []
|
|
|
|
for (var li = 0; li < length(layers); li++) {
|
|
var layer_key = layers[li]
|
|
var b = buckets[layer_key]
|
|
|
|
var mode = layer_sort[layer_key] || "explicit"
|
|
if (mode == "y") {
|
|
var keys = array(b, d => _y_sort_key(d))
|
|
b = sort(b, keys) // ascending feet-y
|
|
if (!y_down) b = reverse(b) // y_up => smaller y draws later => reverse
|
|
}
|
|
|
|
for (var j = 0; j < length(b); j++) sorted_drawables.push(b[j])
|
|
}
|
|
|
|
drawables = sorted_drawables
|
|
|
|
var commands = []
|
|
commands.push({ cmd: "begin_render", target: target, clear: clear_color, target_size: target_size })
|
|
commands.push({ cmd: "set_camera", camera: camera })
|
|
|
|
var batches = _batch_drawables(drawables)
|
|
|
|
for (var i = 0; i < length(batches); i++) {
|
|
var batch = batches[i]
|
|
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 == "mesh2d_batch")
|
|
commands.push({ cmd: "draw_mesh2d", meshes: batch.meshes, texture: batch.texture, material: batch.material })
|
|
else if (batch.type == "text")
|
|
commands.push({ cmd: "draw_text", drawable: batch.drawable })
|
|
else if (batch.type == "texture_ref")
|
|
commands.push({ cmd: "draw_texture_ref", drawable: batch.drawable })
|
|
else if (batch.type == "shape")
|
|
commands.push({ cmd: "draw_shape", drawable: batch.drawable })
|
|
}
|
|
|
|
commands.push({ cmd: "end_render" })
|
|
return { commands: commands }
|
|
}
|
|
|
|
function _batch_drawables(drawables) {
|
|
var batches = []
|
|
var current = null
|
|
var default_mat = {blend: 'alpha', sampler: 'nearest'}
|
|
|
|
for (var i = 0; i < length(drawables); i++) {
|
|
var d = drawables[i]
|
|
|
|
if (d.type == 'sprite') {
|
|
// Resolve fit mode (computes final width/height/uv_rect)
|
|
_resolve_sprite_fit(d)
|
|
|
|
var tex = d.texture || d.image
|
|
var mat = d.material || {blend: 'alpha', sampler: d.filter || 'nearest'}
|
|
|
|
if (current && current.type == 'sprite_batch' && current.texture == tex && _mat_eq(current.material, mat)) {
|
|
current.sprites.push(d)
|
|
} else {
|
|
if (current) batches.push(current)
|
|
current = {type: 'sprite_batch', texture: tex, material: mat, sprites: [d]}
|
|
}
|
|
} else if (d.type == 'particles') {
|
|
// Convert particles to sprites
|
|
var tex = d.texture || d.image
|
|
var mat = d.material || default_mat
|
|
var particles = d.particles || []
|
|
var emitter_opacity = d.opacity != null ? d.opacity : 1
|
|
var emitter_tint = d.tint || {r: 1, g: 1, b: 1, a: 1}
|
|
|
|
for (var p = 0; p < length(particles); p++) {
|
|
var part = particles[p]
|
|
var pc = part.color || {r: 1, g: 1, b: 1, a: 1}
|
|
var sprite = {
|
|
type: 'sprite',
|
|
pos: part.pos,
|
|
width: (d.width || 16) * (part.scale || 1),
|
|
height: (d.height || 16) * (part.scale || 1),
|
|
anchor_x: 0.5,
|
|
anchor_y: 0.5,
|
|
color: pc,
|
|
opacity: emitter_opacity,
|
|
tint: emitter_tint
|
|
}
|
|
|
|
if (current && current.type == 'sprite_batch' && current.texture == tex && _mat_eq(current.material, mat)) {
|
|
current.sprites.push(sprite)
|
|
} else {
|
|
if (current) batches.push(current)
|
|
current = {type: 'sprite_batch', texture: tex, material: mat, sprites: [sprite]}
|
|
}
|
|
}
|
|
} else if (d.type == 'tilemap') {
|
|
// Expand tilemap to sprites
|
|
var tiles = d.tiles || []
|
|
var tile_w = d.tile_width || 1
|
|
var tile_h = d.tile_height || 1
|
|
var off_x = d.offset_x || 0
|
|
var off_y = d.offset_y || 0
|
|
var tilemap_opacity = d.opacity != null ? d.opacity : 1
|
|
var tilemap_tint = d.tint || {r: 1, g: 1, b: 1, a: 1}
|
|
|
|
for (var x = 0; x < length(tiles); x++) {
|
|
if (!tiles[x]) continue
|
|
for (var y = 0; y < length(tiles[x]); y++) {
|
|
var img = tiles[x][y]
|
|
if (!img) continue
|
|
|
|
var wx = (x + off_x) * tile_w
|
|
var wy = (y + off_y) * tile_h
|
|
|
|
// Center anchor for sprite
|
|
var sprite = {
|
|
type: 'sprite',
|
|
image: img,
|
|
pos: {x: wx + tile_w/2, y: wy + tile_h/2},
|
|
width: tile_w,
|
|
height: tile_h,
|
|
anchor_x: 0.5,
|
|
anchor_y: 0.5,
|
|
color: {r: 1, g: 1, b: 1, a: 1},
|
|
opacity: tilemap_opacity,
|
|
tint: tilemap_tint
|
|
}
|
|
|
|
// Batching
|
|
var tex = img
|
|
var mat = default_mat
|
|
if (current && current.type == 'sprite_batch' && current.texture == tex && _mat_eq(current.material, mat)) {
|
|
current.sprites.push(sprite)
|
|
} else {
|
|
if (current) batches.push(current)
|
|
current = {type: 'sprite_batch', texture: tex, material: mat, sprites: [sprite]}
|
|
}
|
|
}
|
|
}
|
|
} else if (d.type == 'mesh2d') {
|
|
// Mesh2d drawables - arbitrary triangle meshes (for lines, ropes, etc)
|
|
var tex = d.texture || d.image
|
|
var mat = d.material || {blend: d.blend || 'alpha', sampler: d.filter || 'linear'}
|
|
|
|
if (current && current.type == 'mesh2d_batch' && current.texture == tex && _mat_eq(current.material, mat)) {
|
|
current.meshes.push(d)
|
|
} else {
|
|
if (current) batches.push(current)
|
|
current = {type: 'mesh2d_batch', texture: tex, material: mat, meshes: [d]}
|
|
}
|
|
} else if (d.type == 'shape') {
|
|
// Shapes are rendered individually (each has unique SDF params)
|
|
if (current) {
|
|
batches.push(current)
|
|
current = null
|
|
}
|
|
batches.push({type: 'shape', drawable: d})
|
|
} else {
|
|
if (current) {
|
|
batches.push(current)
|
|
current = null
|
|
}
|
|
batches.push({type: d.type, drawable: d})
|
|
}
|
|
}
|
|
|
|
if (current) batches.push(current)
|
|
return batches
|
|
}
|
|
|
|
function _mat_eq(a, b) {
|
|
if (!a || !b) return a == b
|
|
return a.blend == b.blend && a.sampler == b.sampler
|
|
}
|
|
|
|
return film2d |