@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
328 lines (261 loc) • 8.29 kB
JavaScript
import { AbstractTextureAtlas } from "./AbstractTextureAtlas.js";
class PatchRecord {
constructor() {
/**
*
* @type {AtlasPatch|null}
*/
this.patch = null;
/**
* When was the patch last used
* @type {number}
*/
this.last_use_time = 0;
}
}
/**
* Adding and removing elements to atlas can be costly. It can trigger repack, it may require splatting or cleaning.
* This abstraction allows us to bypass that most of the time by caching unused patches and just reusing them in the future
* if they are requested again.
*
* The caching strategy here is a little unusual. The texture atlas has a certain size, and the cache will use as much of
* the atlas space as it can. Meaning that if there is not much unused space - the cache will be very small
* And if only a small part of the atlas is area is used by "live" patches, the cache can be quite large
*/
export class CachingTextureAtlas extends AbstractTextureAtlas {
/**
*
* @param {AbstractTextureAtlas} atlas
*/
constructor({
atlas
}) {
super();
/**
*
* @type {PatchRecord[]}
* @private
*/
this.__cached_patches = [];
/**
*
* @type {TextureAtlas}
* @private
*/
this.__atals = atlas;
}
/**
* Finds an entry in the cache to evict
* @returns {number}
* @private
*/
__find_eviction_target() {
let best_date = Infinity;
let best_node = -1;
const records = this.__cached_patches;
const n = records.length;
for (let i = 0; i < n; i++) {
const record = records[i];
const date = record.last_use_time;
if (date < best_date) {
best_node = i;
best_date = date;
}
}
return best_node;
}
/**
* Finds an entry in the cache that could be replaced with an incoming element
* @private
* @returns {number}
* @param {number} width
* @param {number} height
*/
__find_replacement_target(width, height) {
// find the smallest element in area that is least recently used in cache
let best_area = Infinity;
let best_date = Infinity;
let best_node = -1;
const records = this.__cached_patches;
const n = records.length;
for (let i = 0; i < n; i++) {
const record = records[i];
const packing = record.patch.packing;
const w = packing.x1 - packing.x0;
const h = packing.y1 - packing.y0;
if (w < width || h < height) {
// too small
continue;
}
const area = w * h;
if (area > best_area) {
// prefer smaller patches to evict, as they are cheaper to pull back later on
continue;
}
const date = record.last_use_time;
if (date >= best_date) {
// prefer patches that have been in cache the longest (the oldest records)
continue;
}
// found a better candidate, update
best_area = area;
best_date = date;
best_node = i;
}
return best_node;
}
/**
* Will evict specified element from cache
* @private
* @param {number} index
*/
__evict(index) {
const records = this.__cached_patches;
const record = records[index];
records.splice(index, 1);
this.__atals.remove(record.patch);
}
/**
* Evict all cached elements, purging the cache
* @private
*/
__evict_all() {
for (let i = this.__cached_patches.length - 1; i > 0; i--) {
this.__evict(i);
}
}
/**
* Free up space in cache to accommodate certain area
* @private
* @param {number} width
* @param {number} height
* @returns {boolean} true if enough space was freed up to fit specified area, false otherwise
*/
__evict_for(width, height) {
if (this.__cached_patches.length <= 0) {
// nothing to evict
return false;
}
const target = this.__find_replacement_target(width, height);
if (target !== -1) {
// evicted just one element
this.__evict(target);
return true;
}
while (!this.__atals.can_pack(width, height)) {
const victim = this.__find_eviction_target();
if (victim === -1) {
return false;
}
this.__evict(victim);
}
return true;
}
update() {
try {
this.__atals.update();
} catch (e) {
// failed to perform update
if (this.__cached_patches.length > 0) {
// has some caches patches, lets drop them
this.__evict_all();
// try to update again
this.__atals.update();
} else {
// just re-throw
throw e;
}
}
}
/**
*
* @returns {AbstractTextureAtlas}
*/
get atlas() {
return this.__atals;
}
/**
*
* @return {Sampler2D}
*/
get sampler() {
return this.__atals.sampler;
}
reset() {
this.__cached_patches.splice(0, this.__cached_patches.length);
this.__atals.reset();
}
/**
*
* @param {Sampler2D} sampler
* @returns {number}
* @private
*/
__find_cache_record_index(sampler) {
const records = this.__cached_patches;
const n = records.length;
for (let i = 0; i < n; i++) {
if (records[i].patch.sampler === sampler) {
return i;
}
}
return -1;
}
/**
*
* @param {AtlasPatch} patch
* @returns {number}
* @private
*/
__find_cache_record_index_by_patch(patch) {
const records = this.__cached_patches;
const n = records.length;
for (let i = 0; i < n; i++) {
if (records[i].patch === patch) {
return i;
}
}
return -1;
}
add(sampler, padding = 4) {
const existing_patch_index = this.__find_cache_record_index(sampler);
const records = this.__cached_patches;
if (existing_patch_index !== -1) {
// cache hit
const record = records[existing_patch_index];
// remove from cache
records.splice(existing_patch_index, 1);
// return patch
return record.patch;
}
const w = sampler.width + padding * 2;
const h = sampler.height + padding * 2;
if (records.length > 0 && !this.__atals.can_pack(w, h)) {
// new element will not fit, lets try to evict some cache to try and accommodate it
// note that eviction will purge the entire cache in case that this patch doesn't fit
this.__evict_for(w, h);
}
return this.__atals.add(sampler, padding);
}
/**
*
* @param {AtlasPatch} patch
*/
remove(patch) {
// validate presence of patch in the atlas
if (!this.__atals.contains(patch)) {
return false;
}
const index = this.__find_cache_record_index_by_patch(patch);
if (index !== -1) {
// already in the cache
return false;
}
// push to cache, let eviction logic handle the rest
const record = new PatchRecord();
record.last_use_time = performance.now();
record.patch = patch;
this.__cached_patches.push(record);
return true;
}
}