Files
cell/scripts/graphics.cm
2025-07-15 15:06:06 -05:00

450 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

var graphics = this
graphics[cell.DOC] = `
Provides functionality for loading and managing images, fonts, textures, and sprite meshes.
Includes both JavaScript and C-implemented routines for creating geometry buffers, performing
rectangle packing, etc.
`
var renderer_actor = arg?.[0] || null
var io = use('io')
var time = use('time')
var res = use('resources')
var json = use('json')
var GPU = Symbol()
var CPU = Symbol()
var LASTUSE = Symbol()
var LOADING = Symbol()
var cache = {}
var pending_gpu_loads = []
graphics.setup = function(renderer)
{
renderer_actor = renderer
// Process any pending GPU loads
if (renderer_actor && pending_gpu_loads.length > 0) {
log.console(`Processing ${pending_gpu_loads.length} pending GPU loads`)
for (var img of pending_gpu_loads) {
img.loadGPU()
}
pending_gpu_loads = []
}
// Also process any cached images that need GPU loading
if (renderer_actor) {
for (var key in cache) {
var img = cache[key]
if (img instanceof graphics.Image && img.cpu && img.gpu == 0) {
img.loadGPU()
}
}
}
}
// Image constructor function
graphics.Image = function(surfaceData) {
// Initialize properties
this.cpu = surfaceData || null;
this.gpu = 0;
this.texture = 0;
this.surface = this.cpu;
this.width = surfaceData?.width || 0;
this.height = surfaceData?.height || 0;
this.rect = {x:0, y:0, width:this.width, height:this.height};
this[LOADING] = false;
this[LASTUSE] = time.number();
// Load GPU texture if renderer is available, otherwise queue it
if (renderer_actor && this.cpu) {
this.loadGPU();
} else if (this.cpu) {
// Queue for later GPU loading when renderer is available
pending_gpu_loads.push(this)
}
}
graphics.Image.prototype.loadGPU = function() {
if (!this[LOADING] && renderer_actor && this.cpu) {
this[LOADING] = true;
var self = this;
send(renderer_actor, {
kind: "renderer",
op: "loadTexture",
data: this.cpu
}, function(response) {
if (response.error) {
log.error("Failed to load texture:")
log.error(response.error)
self[LOADING] = false;
} else {
// Store the full response as texture (has width/height)
self.texture = response;
// Store just the ID as gpu
self.gpu = response.id || response;
decorate_rect_px(self);
self[LOADING] = false;
}
});
}
}
// Add methods to prototype
graphics.Image.prototype.unload_gpu = function() {
this.gpu = 0;
this.texture = 0;
}
graphics.Image.prototype.unload_cpu = function() {
this.cpu = null;
this.surface = null;
}
function calc_image_size(img) {
if (!img.rect) return
if (img.texture) {
return [img.texture.width * img.rect.width, img.texture.height * img.rect.height]
} else if (img.cpu) {
return [img.cpu.width * img.rect.width, img.cpu.height * img.rect.height]
}
return [0, 0]
}
function decorate_rect_px(img) {
// default UV rect is the whole image if none supplied
img.rect ??= {x:0, y:0, width:1, height:1} // [u0,v0,uw,vh] in 0-1
var width = 0, height = 0;
if (img.texture) {
width = img.texture.width;
height = img.texture.height;
} else if (img.cpu) {
width = img.cpu.width;
height = img.cpu.height;
} else {
return;
}
// store pixel-space version: [x, y, w, h] in texels
img.rect_px = {
x:Math.round(img.rect.x * width),
y:Math.round(img.rect.y * height),
width:Math.round(img.rect.width * width),
height:Math.round(img.rect.height * height)
}
}
function make_handle(obj)
{
var img = new graphics.Image(obj);
decorate_rect_px(img);
return img;
}
function wrapSurface(surf, maybeRect){
def h = make_handle(surf);
if(maybeRect) h.rect = maybeRect; /* honour frame sub-rect */
return h;
}
function wrapFrames(arr){ /* [{surface,time,rect}, …] → [{image,time}] */
return arr.map(f => {
// Handle both surface objects and objects with surface property
var surf = f.surface || f;
return {
image: wrapSurface(surf),
time: f.time || 0,
rect: f.rect /* keep for reference */
}
});
}
function makeAnim(frames, loop=true){
return { frames, loop }
}
function decode_image(bytes, ext)
{
switch(ext) {
case 'gif': return graphics.make_gif(bytes) // returns array of surfaces
case 'ase':
case 'aseprite': return graphics.make_aseprite(bytes)
default: return graphics.make_texture(bytes) // returns single surface
}
}
function create_image(path){
try{
def bytes = io.slurpbytes(path);
let raw = decode_image(bytes, path.ext());
/* ── Case A: single surface (from make_texture) ────────────── */
if(raw && raw.width && raw.pixels && !Array.isArray(raw)) {
return new graphics.Image(raw)
}
/* ── Case B: array of surfaces (from make_gif) ────────────── */
if(Array.isArray(raw)) {
// Single frame GIF returns array with one surface
if(raw.length == 1 && !raw[0].time) {
return new graphics.Image(raw[0])
}
// Multiple frames - create animation
return makeAnim(wrapFrames(raw), true);
}
/* ── Case C: ASE helpers returned { animName:{frames,loop}, … } or single frame ── */
if(typeof raw == 'object' && !raw.width) {
// Check if it's a single surface from ASE (single frame, no tags)
if(raw.surface) {
return new graphics.Image(raw.surface)
}
// Check if it's an untagged animation (multiple frames, no tags)
// This happens when ASE has no tags but multiple frames
if(raw.frames && Array.isArray(raw.frames) && raw.loop != null) {
log.console(`[Graphics] Loading untagged ASE animation with ${raw.frames.length} frames`)
return makeAnim(wrapFrames(raw.frames), !!raw.loop);
}
// Multiple named animations from ASE (with tags)
def anims = {};
for(def [name, anim] of Object.entries(raw)){
if(anim && Array.isArray(anim.frames))
anims[name] = makeAnim(wrapFrames(anim.frames), !!anim.loop);
else if(anim && anim.surface)
anims[name] = new graphics.Image(anim.surface);
}
if(Object.keys(anims).length) return anims;
}
throw new Error('Unsupported image structure from decoder');
}catch(e){
log.error(`Error loading image ${path}: ${e.message}`);
throw e;
}
}
var image = {}
image.dimensions = function() {
var width = 0, height = 0;
if (this.texture) {
width = this.texture.width;
height = this.texture.height;
} else if (this.cpu) {
width = this.cpu.width;
height = this.cpu.height;
}
return [width, height].scale([this.rect[2], this.rect[3]])
}
image.dimensions[cell.DOC] = `
:return: A 2D array [width, height] that is the scaled size of this image (texture size * rect size).
`
var spritesheet
var sheet_frames = []
var sheetsize = 1024
/**
Pack multiple images into a single texture sheet for efficiency.
Currently unimplemented (returns immediately).
*/
function pack_into_sheet(images) {
return
// This code is currently disabled with an immediate return.
// Implementation details commented out below.
}
graphics.is_image = function(obj) {
if (obj.texture && obj.rect) return true
}
graphics.is_image[cell.DOC] = `
:param obj: An object to check.
:return: True if 'obj' has a .texture and a .rect property, indicating it's an image object.
`
graphics.texture_from_data = function(data)
{
if (!(data instanceof ArrayBuffer)) return null
var image = graphics.make_texture(data);
var img = make_handle(image)
if (renderer_actor) img.gpu;
return img;
}
graphics.from_surface = function(id, surf)
{
return make_handle(surf)
}
graphics.from = function(id, data)
{
if (typeof id != 'string')
throw new Error('Expected a string ID')
if (data instanceof ArrayBuffer)
return graphics.texture_from_data(data)
}
graphics.texture = function texture(path) {
if (path instanceof graphics.Image) return path
if (typeof path != 'string')
throw new Error('need a string for graphics.texture')
var id = path //.split(':')[0]
if (cache[id]) return cache[id]
var ipath = res.find_image(id)
if (!ipath)
throw new Error(`unknown image ${id}`)
var result = create_image(ipath)
cache[id] = result
return result // Can be Image, animation, or collection of animations
}
graphics.texture[cell.DOC] = `
:param path: A string path to an image file or an already-loaded image object.
:return: An image object with {surface, texture, frames?, etc.} depending on the format.
Load or retrieve a cached image, converting it into a GPU texture. If 'path' is already an object, its returned directly.
`
graphics.texture.total_size = function() {
var size = 0
// Not yet implemented, presumably sum of (texture.width * texture.height * 4) for images in RAM
return size
}
graphics.texture.total_size[cell.DOC] = `
:return: The total estimated memory size of all cached textures in RAM, in bytes. (Not yet implemented.)
`
graphics.texture.total_vram = function() {
var vram = 0
// Not yet implemented, presumably sum of GPU memory usage
return vram
}
graphics.texture.total_vram[cell.DOC] = `
:return: The total estimated GPU memory usage of all cached textures, in bytes. (Not yet implemented.)
`
graphics.tex_hotreload = function tex_hotreload(file) {
// Extract just the filename without path and extension
var basename = file.split('/').pop().split('.')[0]
// Check if this basename exists in our cache
if (!(basename in cache)) return
// Find the full path for this image
var fullpath = res.find_image(basename)
if (!fullpath) return
var img = create_image(fullpath)
var oldimg = cache[basename]
// Preserve the GPU texture ID if it exists
var oldGPU = oldimg.gpu
// Update the CPU surface data
oldimg.cpu = img.cpu
oldimg.surface = img.cpu
// Clear GPU texture to force reload
oldimg.gpu = 0
oldimg.texture = 0
oldimg[LOADING] = false
// Update dimensions
if (img.cpu) {
oldimg.width = img.cpu.width
oldimg.height = img.cpu.height
oldimg.rect = {x:0, y:0, width:img.cpu.width, height:img.cpu.height}
decorate_rect_px(oldimg)
}
// If the texture was on GPU, trigger reload
if (oldGPU && renderer_actor) {
oldimg.loadGPU()
}
}
graphics.tex_hotreload[cell.DOC] = `
:param file: The file path that was changed on disk.
:return: None
Reload the image for the given file, updating the cached copy in memory and GPU.
`
/**
Merges specific properties from nv into ov, using an array of property names.
*/
function merge_objects(ov, nv, arr) {
arr.forEach(x => ov[x] = nv[x])
}
/**
Unimplemented function for creating a spritesheet out of multiple images.
*/
function make_spritesheet(paths, width, height) {
return
}
/**
Stores previously loaded fonts. Keyed by e.g. "path.ttf.16" -> fontObject.
*/
var fontcache = {}
var datas = []
graphics.get_font = function get_font(path, size) {
var parts = path.split('.')
if (!isNaN(parts[1])) {
path = parts[0]
size = Number(parts[1])
}
var fullpath = res.find_font(path)
if (!fullpath) throw new Error(`Cannot load font ${path}`)
var fontstr = `${fullpath}.${size}`
if (fontcache[fontstr]) return fontcache[fontstr]
var data = io.slurpbytes(fullpath)
var font = graphics.make_font(data,size)
// Load font texture via renderer actor (async)
if (renderer_actor) {
send(renderer_actor, {
kind: "renderer",
op: "loadTexture",
data: font.surface
}, function(response) {
if (response.error) {
log.error("Failed to load font texture:", response.error);
} else {
font.texture = response;
}
});
}
fontcache[fontstr] = font
return font
}
graphics.queue_sprite_mesh = function(queue) {
var sprites = queue.filter(x => x.type == 'sprite')
if (sprites.length == 0) return []
var mesh = graphics.make_sprite_mesh(sprites)
for (var i = 0; i < sprites.length; i++) {
sprites[i].mesh = mesh
sprites[i].first_index = i*6
sprites[i].num_indices = 6
}
return [mesh.pos, mesh.uv, mesh.color, mesh.indices]
}
return graphics