530 lines
14 KiB
Plaintext
530 lines
14 KiB
Plaintext
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 os = use('os')
|
||
|
||
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)
|
||
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 parts = path.split(':')
|
||
var id = parts[0]
|
||
var animName = parts[1]
|
||
var frameIndex = parts[2]
|
||
|
||
// Handle the case where animName is actually a frame index (e.g., "gears:0")
|
||
if (animName != null && frameIndex == null && !isNaN(parseInt(animName))) {
|
||
frameIndex = parseInt(animName)
|
||
animName = null
|
||
}
|
||
|
||
if (!cache[id]) {
|
||
var ipath = res.find_image(id)
|
||
if (!ipath)
|
||
throw new Error(`unknown image ${id}`)
|
||
|
||
var result = create_image(ipath)
|
||
cache[id] = result
|
||
}
|
||
|
||
var cached = cache[id]
|
||
|
||
// No further path specifiers and no frame index - return the whole thing
|
||
if (!animName && frameIndex == null) return cached
|
||
|
||
// Handle frame index without animation name (e.g., "gears:0")
|
||
if (!animName && frameIndex != null) {
|
||
// If cached is a single animation (has .frames property)
|
||
if (cached.frames && Array.isArray(cached.frames)) {
|
||
var idx = parseInt(frameIndex)
|
||
if (isNaN(idx)) return cached
|
||
// Wrap the index
|
||
idx = idx % cached.frames.length
|
||
return cached.frames[idx].image
|
||
}
|
||
// If cached is a single Image, any frame index just returns the image
|
||
if (cached instanceof graphics.Image) {
|
||
return cached
|
||
}
|
||
}
|
||
|
||
// If cached is a single Image, treat it as a single-frame animation
|
||
if (cached instanceof graphics.Image) {
|
||
if (frameIndex != null) {
|
||
// For single images, any frame index just returns the image
|
||
return cached
|
||
}
|
||
// animName without frameIndex for single image - return as single-frame array
|
||
return [cached]
|
||
}
|
||
|
||
// If cached is a single animation (has .frames property)
|
||
if (cached.frames && Array.isArray(cached.frames)) {
|
||
if (frameIndex != null) {
|
||
var idx = parseInt(frameIndex)
|
||
if (isNaN(idx)) return cached
|
||
// Wrap the index
|
||
idx = idx % cached.frames.length
|
||
return cached.frames[idx].image
|
||
}
|
||
// Just animation name for single animation - return the animation
|
||
return cached
|
||
}
|
||
|
||
// If cached is an object of multiple animations
|
||
if (typeof cached == 'object' && !cached.frames) {
|
||
var anim = cached[animName]
|
||
if (!anim)
|
||
throw new Error(`animation ${animName} not found in ${id}`)
|
||
|
||
if (frameIndex != null) {
|
||
var idx = parseInt(frameIndex)
|
||
if (isNaN(idx)) return anim
|
||
|
||
if (anim instanceof graphics.Image) {
|
||
// Single image animation - any frame index returns the image
|
||
return anim
|
||
} else if (anim.frames && Array.isArray(anim.frames)) {
|
||
// Multi-frame animation - wrap the index
|
||
idx = idx % anim.frames.length
|
||
return anim.frames[idx].image
|
||
}
|
||
}
|
||
|
||
// Just animation name - return the animation
|
||
return anim
|
||
}
|
||
|
||
return cached
|
||
}
|
||
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, it’s 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) {
|
||
if (typeof path != 'string') return path
|
||
var parts = path.split('.')
|
||
var size = 16 // default size
|
||
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
|