Last active
June 17, 2024 15:10
-
-
Save TheBigRoomXXL/482464038b510b0a33f05f2a8a00aaab to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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