Skip to content

Instantly share code, notes, and snippets.

@TheBigRoomXXL
Last active June 17, 2024 15:10
Show Gist options
  • Save TheBigRoomXXL/482464038b510b0a33f05f2a8a00aaab to your computer and use it in GitHub Desktop.
Save TheBigRoomXXL/482464038b510b0a33f05f2a8a00aaab to your computer and use it in GitHub Desktop.
import { EventEmitter } from "events";
/**
* Useful types:
* *************
*/
export type ImageState = {
image: HTMLImageElement;
status: "loading" | "ready" | "error" | "drawn";
x: number;
y: number;
size: number;
textureId: number;
};
export type Atlas = Record<string, Omit<ImageState, "image" | "status">>;
export const DEBOUNCE_TEXTURE_UPDATE_MS = 1000;
export class TextureManager extends EventEmitter {
static NEW_TEXTURE_EVENT = "newTexture";
private canvas: HTMLCanvasElement;
private ctx;
private frameId?: number;
private textures: ImageData[];
private textureMaxSize: number;
private textureMaxNumber: number;
private atlas: Atlas;
private images: Record<string, ImageState>;
private imageSize: number;
private imageLoaded: number;
private imagesPerTextureSide: number;
private imagesPerCanvas: number;
constructor(imageSize: number) {
if (imageSize <= 0) {
throw new Error("imageSize must be a positive number");
}
super();
this.canvas = document.createElement("canvas");
this.ctx = this.canvas.getContext("2d", {
willReadFrequently: true
}) as CanvasRenderingContext2D;
this.atlas = {};
this.textures = [this.ctx.getImageData(0, 0, 1, 1)];
this.textureMaxNumber = 10
// We create a canvas just to get a WebGL context and then remove it.
const _gl = document.createElement("canvas").getContext("webgl") as WebGLRenderingContext;
this.textureMaxSize = _gl.getParameter(_gl.MAX_TEXTURE_SIZE);
(_gl.canvas as HTMLCanvasElement).remove();
this.textureMaxSize = 4096;
this.images = {};
this.imageSize = imageSize;
this.imageLoaded = -1; // Start at -1 so that first image is 0
this.imagesPerTextureSide = Math.floor(this.textureMaxSize / this.imageSize);
this.imagesPerCanvas = this.imagesPerTextureSide * this.imagesPerTextureSide;
}
// PUBLIC API:
async registerImage(source: string) {
// Don't register the image twice
if (this.images[source] != undefined) return;
// Create image and tart loading the source asynchronously
this.loadImage(source);
}
getAtlas(): Atlas {
return this.atlas;
}
getTextures(): ImageData[] {
return this.textures;
}
// PRIVATE API
private scheduleGenerateTexture() {
if (this.frameId != undefined) return;
this.frameId = window.setTimeout(() => {
this.frameId = undefined;
this.updateTextures();
}, DEBOUNCE_TEXTURE_UPDATE_MS);
}
private loadImage(source: string) {
const image = new Image();
// We immediately add the image with a loading status to avoid registering
// it twice
this.images[source] = {
image: image,
status: "loading",
x: -1,
y: -1,
textureId: -1,
size: this.imageSize
};
// Once the image is loaded we calculate it's possition on the texture
// and set it's status to ready and schedule the next rendering
image.addEventListener(
"load",
() => {
this.imageLoaded++;
const textureId = Math.floor(this.imageLoaded / this.imagesPerCanvas);
const positionId = this.imageLoaded % this.imagesPerCanvas;
const x = (positionId % this.imagesPerTextureSide) * this.imageSize;
const y = Math.floor(positionId / this.imagesPerTextureSide) * this.imageSize;
this.images[source].x = x;
this.images[source].y = y;
this.images[source].textureId = textureId;
this.images[source].status = "ready";
this.scheduleGenerateTexture();
},
{ once: true }
);
// Errors can happen, we just log and update the status.
image.addEventListener(
"error",
() => {
console.warn("error loading image", source);
this.images[source].status = "error";
},
{ once: true }
);
image.setAttribute("crossOrigin", "use-credentials");
image.src = source;
}
private updateTextures() {
// Prepare an array of empty array with a length equal to the number of texture
const renderChunks: Array<string[]> = [];
const textureNeeded = Math.min(
Math.ceil(this.imageLoaded / this.imagesPerCanvas),
this.textureMaxNumber
)
for (let i = 0; i < textureNeeded; i++) {
renderChunks[i] = [];
}
// Find for each texture the images that need to be added
for (const key in this.images) {
if (this.images[key].status == "ready") {
console.debug("textureId vs id", textureNeeded, this.images[key].textureId);
renderChunks[this.images[key].textureId].push(key);
}
}
// Update textures that needs it
for (let i = 0; i < renderChunks.length; i++) {
if (renderChunks[i].length == 0) {
continue;
}
this.updateTexture(i, renderChunks[i]);
}
const hr = document.createElement("hr");
document.body.appendChild(hr);
this.emit(TextureManager.NEW_TEXTURE_EVENT, { atlas: this.atlas, textures: this.textures });
}
private updateTexture(textureId: number, imagesKeys: string[]) {
console.log(`updating texture ${textureId} with ${imagesKeys.length} new images`);
// 1. Set canvas to image grid size
//---------------------------------
let height = this.textureMaxSize;
let width = this.textureMaxSize;
// The last texture can be partial so we reduce it's size if possible
if (textureId >= this.textures.length - 1) {
const nbImages = this.imageLoaded % this.imagesPerCanvas;
height = Math.ceil(nbImages / this.imagesPerTextureSide) * this.imageSize;
width = Math.min(nbImages * this.imageSize, this.textureMaxSize);
}
this.canvas.height = Math.max(height, 1);
this.canvas.width = Math.max(width, 1);
// 2. Reuse current texture to only have to draw new image
//--------------------------------------------------------
// When we increase the number of texture needed there will be no texture
// to reuse so we must skip in this case
if (this.textures[textureId] != undefined) {
this.ctx.putImageData(this.textures[textureId], 0, 0);
}
// 3. Draw newly loaded images on the canvas and update there status
//------------------------------------------------------------------
imagesKeys.forEach((key) => {
this.ctx.drawImage(
this.images[key].image,
0,
0,
this.images[key].image.width,
this.images[key].image.height,
this.images[key].x,
this.images[key].y,
this.imageSize,
this.imageSize
);
this.images[key].status = "drawn";
this.atlas[key] = {
x: this.images[key].x,
y: this.images[key].y,
textureId: this.images[key].textureId,
size: this.images[key].size
};
});
// 4.Update the savec texture before it is overriden
//-------------------------------------------------
this.textures[textureId] = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
// const img = document.createElement("img");
// img.src = this.canvas.toDataURL("image/webp");
// img.classList.add("debug");
// document.body.appendChild(img);
}
isDrawable(image: HTMLImageElement) {
return image.complete && image.width > 0 && image.height > 0;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment