/**
* LittleJS Drawing System
* - Hybrid rendering with both Canvas2D and WebGL support
* - Optimized tile sheet sprite rendering using WebGL batching
* - Primitive drawing for polygons, ellipses, and lines
* - Tile-based rendering with TileInfo and TextureInfo classes
* - Text rendering with custom fonts and ImageFont support
* - Color and additive color blending for effects
* - Rotation, mirroring, and scaling transformations
* - Camera system with position, scale, and rotation
* - Multiple canvas support (main, WebGL, work canvases)
* - Gradient fills and outlined shapes
* - Image manipulation and color tinting
*
* Rendering Architecture:
* - glCanvas: WebGL canvas for accelerated sprite batch rendering
* - mainCanvas: Canvas2D overlay for text, UI, and custom drawing
* - All draw functions default to WebGL when enabled, can force Canvas2D with useWebGL parameter
*
* @namespace Draw
*/
'use strict';
/** The primary 2D canvas visible to the user
* @type {HTMLCanvasElement}
* @memberof Draw */
let mainCanvas;
/** 2d context for mainCanvas
* - Scaled by canvasPixelRatio, so drawing to it is in css pixels
* - getImageData and putImageData ignore that scale and work in backing store
* pixels, so use workReadCanvas to read pixels back instead of this
* @type {CanvasRenderingContext2D}
* @memberof Draw */
let mainContext;
/** The default 2d context to use for drawing, usually mainContext
* @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D}
* @memberof Draw */
let drawContext;
/** Offscreen canvas that can be used for image processing
* @type {OffscreenCanvas}
* @memberof Draw */
let workCanvas;
/** Offscreen canvas that can be used for image processing
* @type {OffscreenCanvasRenderingContext2D}
* @memberof Draw */
let workContext;
/** Offscreen canvas with willReadFrequently that can be used for image processing
* @type {OffscreenCanvas}
* @memberof Draw */
let workReadCanvas;
/** Offscreen canvas with willReadFrequently that can be used for image processing
* @type {OffscreenCanvasRenderingContext2D}
* @memberof Draw */
let workReadContext;
/** Extra canvas to composite behind the engine canvases when combining canvases
* Set by plugins that render to their own canvas below the LittleJS canvases
* @type {HTMLCanvasElement}
* @memberof Draw */
let backgroundCanvas;
/** The size of the main canvas (and other secondary canvases) in css pixels
* - This is the screen space coordinate system, matching mousePos
* - With canvasPixelRatio set the backing store is larger than this
* @type {Vector2}
* @memberof Draw */
let mainCanvasSize = vec2();
/** Array containing texture info for batch rendering system
* @type {Array<TextureInfo>}
* @memberof Draw */
let textureInfos = [];
/** Keeps track of how many draw calls there were each frame for debugging
* @type {number}
* @memberof Draw */
let drawCount = 0;
/** Keeps track of how many primitives were drawn each frame for debugging
* A single draw call can render many primitives (e.g. a WebGL sprite batch).
* @type {number}
* @memberof Draw */
let primitiveCount = 0;
// internal predicates for tint short-circuiting in canvas2D draw paths
// isWhite ignores alpha because alpha is applied via globalAlpha, not multiply
// isBlack includes alpha so additive colors that only contribute alpha are not skipped
/** @ignore
* @param {Color} c */
function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; }
/** @ignore
* @param {Color} c */
function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
///////////////////////////////////////////////////////////////////////////////
/**
* Create a tile info object using a grid based system
* - This can take vecs or floats for easier use and conversion
* - If an index is passed in, the tile size and index will determine the position
* @param {Vector2|number} [index=0] - Index of the tile in 1d or 2d form
* @param {Vector2|number} [size] - Size of tile in pixels
* @param {TextureInfo|number} [texture] - Texture index or info to use
* @param {number} [padding] - How many pixels padding around tiles
* @param {number} [bleed] - How many pixels smaller to draw tiles
* @return {TileInfo}
* @example
* tile(2) // a tile at index 2 using the default tile size of 16
* tile(5, 8) // a tile at index 5 using a tile size of 8
* tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3
* tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10)
* @memberof Draw */
function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed)
{
ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number');
ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
ASSERT(isNumber(texture) || texture instanceof TextureInfo, 'texture must be a number or TextureInfo');
ASSERT(isNumber(padding), 'padding must be a number');
if (typeof size === 'number')
{
// if size is a number, make it a vector
ASSERT(size > 0);
size = new Vector2(size, size);
}
// create tile info object
const textureInfo = typeof texture === 'number' ?
textureInfos[texture] : texture;
if (headlessMode && !textureInfo?.size.x)
return new TileInfo(new Vector2, size.copy(), textureInfo, padding, bleed); // no image loaded, no place in it
ASSERT(textureInfo instanceof TextureInfo, 'tile texture is not loaded');
ASSERT(textureInfo.size.x > 0, 'tile texture is not loaded');
// get the position of the tile
const sizePaddedX = size.x + padding*2;
const sizePaddedY = size.y + padding*2;
let x, y;
if (typeof index === 'number')
{
const cols = textureInfo.size.x / sizePaddedX |0;
x = index % cols;
y = index / cols |0;
}
else
{
x = index.x;
y = index.y;
}
const pos = new Vector2(x*sizePaddedX + padding, y*sizePaddedY + padding);
return new TileInfo(pos, size, textureInfo, padding, bleed);
}
/**
* Tile Info - Stores info about how to draw a tile
* @memberof Draw
*/
class TileInfo
{
/** Create a tile info object
* @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels
* @param {Vector2} [size] - Size of tile in pixels
* @param {TextureInfo} [textureInfo] - Texture info to use
* @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
* @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
* @param {number} [columns] - How many frames per row for frame(), 0 to keep frames on a single row
*/
constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed, columns=0)
{
/** @property {Vector2} - Top left corner of tile in pixels */
this.pos = pos.copy();
/** @property {Vector2} - Size of tile in pixels */
this.size = size.copy();
/** @property {number} - How many pixels padding around tiles */
this.padding = padding;
/** @property {TextureInfo} - The texture info for this tile */
this.textureInfo = textureInfo;
/** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
this.bleed = bleed;
/** @property {number} - How many frames per row for frame(), 0 to keep frames on a single row */
this.columns = columns;
}
/** Returns a copy of this tile offset by a vector
* @param {Vector2} offset - Offset to apply in pixels
* @return {TileInfo}
*/
offset(offset)
{ return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed, this.columns); }
/** Returns a copy of this tile offset by a number of animation frames
* Frames wrap down to the next row if columns is set
* @param {number} frame - Offset to apply in animation frames
* @return {TileInfo}
*/
frame(frame)
{
ASSERT(typeof frame === 'number');
const w = this.size.x + this.padding*2;
const h = this.size.y + this.padding*2;
const x = (this.columns ? frame % this.columns : frame) * w;
const y = (this.columns ? frame / this.columns | 0 : 0) * h;
ASSERT(!this.textureInfo || this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
ASSERT(!this.textureInfo || this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!');
return this.offset(new Vector2(x, y));
}
/** Set how many frames per row this tile uses, so frame() can wrap
* @param {number} [columns] - Frames per row, 0 to keep frames on a single row
* @return {TileInfo}
*/
setColumns(columns=0)
{
ASSERT(isNumber(columns) && columns >= 0, 'columns must be a number >= 0');
this.columns = columns;
return this;
}
/**
* Returns a tile info for an index using this tile's size, texture and padding as reference
* - the index counts from the texture's origin like tile(), for a sprite packed in a texture sheet use frame()
* @param {Vector2|number} [index=0]
* @return {TileInfo}
*/
index(index)
{ return tile(index, this.size, this.textureInfo, this.padding, this.bleed).setColumns(this.columns); }
/**
* Set this tile to use a full image in a texture info
* @param {TextureInfo} [textureInfo]
* @return {TileInfo}
*/
setFullImage(textureInfo=this.textureInfo)
{
this.textureInfo = textureInfo;
this.pos = new Vector2;
this.size = textureInfo.size.copy();
this.bleed = this.padding = this.columns = 0;
return this;
}
}
/**
* Texture Info - Stores info about each texture
* @memberof Draw
*/
class TextureInfo
{
/**
* Create a TextureInfo, called automatically by the engine
* @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image
* @param {boolean} [useWebGL] - Should use WebGL if available?
* @param {boolean|Array<number>} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)? Or the WebGL
* modes across and down, like [gl.CLAMP_TO_EDGE, gl.MIRRORED_REPEAT], as a glTF sampler gives them
*/
constructor(image, useWebGL=true, wrap=false)
{
/** @property {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} - image source */
this.image = image;
/** @property {Vector2} - size of the image */
this.size = image ? vec2(image.width, image.height) : vec2();
/** @property {Vector2} - inverse of the size, cached for rendering */
this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2();
/** @property {WebGLTexture|undefined} - WebGL texture
* @type {WebGLTexture|undefined} */
this.glTexture = undefined;
/** @property {boolean|Array<number>} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE, or the WebGL modes
* across and down
* @type {boolean|Array<number>} */
this.wrap = wrap;
useWebGL && this.createWebGLTexture();
}
/** Creates the WebGL texture, updates if already created */
createWebGLTexture()
{
const image = this.image;
if (image?.width) // the image may have been resized since
{
this.size = vec2(image.width, image.height);
this.sizeInverse = vec2(1/image.width, 1/image.height);
}
glRegisterTextureInfo(this);
}
/** Destroys the WebGL texture */
destroyWebGLTexture() { glUnregisterTextureInfo(this); }
/** Check if the texture is webgl enabled
* @return {boolean} */
hasWebGL() { return !!this.glTexture; }
/** Set the wrap mode for this texture
* @param {boolean|Array<number>} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE, or the WebGL modes across
* and down */
setWrap(wrap=true)
{
this.wrap = wrap;
glSetTextureWrap(this.glTexture, wrap);
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* @callback SpriteAnimationEndCallback - Called once when a SpriteAnimation's play ends
* @return {void}
* @memberof Draw
*/
/**
* SpriteAnimation - Steps a tile through its frames over time: looping, once, or there and back
* - Driven by the engine time like a Timer, so it pauses with the game and needs no update call
* - Read tileInfo each frame for the frame to draw, from an object's update or before a drawTile
* - loop, play and pingPong each start over from the first frame; stop holds the current one
* - play(onEnd) calls onEnd once the play ends, on the first read after it, since there is no update to call it
* - Frames follow each other along the row, as tileInfo.frame counts them
* - SpriteAnimator switches between a character's animations by name
* @example
* const walk = new SpriteAnimation(tile(0, 16), 4, .1); // four frames, a tenth of a second each
* const attack = new SpriteAnimation(tile(4, 16), 3, .05).play(); // once, then holds the last frame
* // in update: this.tileInfo = (attack.isDone ? walk : attack).tileInfo;
* @memberof Draw
*/
class SpriteAnimation
{
/** Create an animation over a run of frames, looping from the start
* @param {TileInfo} tileInfo - The first frame
* @param {number} frameCount - How many frames, one or more
* @param {number} [frameTime] - Seconds each frame shows for */
constructor(tileInfo, frameCount, frameTime=.1)
{
ASSERT(tileInfo instanceof TileInfo, 'the first frame must be a TileInfo');
ASSERT(frameCount >= 1 && frameTime > 0, 'an animation needs at least one frame and a positive frame time');
/** @property {TileInfo} - The first frame, the others follow it along the row */
this.firstTile = tileInfo;
/** @property {number} - How many frames */
this.frameCount = frameCount;
/** @property {number} - Seconds each frame shows for */
this.frameTime = frameTime;
/** @property {number} - Rate multiplier, 2 plays twice as fast; set it before starting */
this.speed = 1;
/** @property {string} - How it runs: 'loop', 'once' or 'pingPong', set by loop, play and pingPong */
this.mode = 'loop';
/** @property {number} - Engine time it started at */
this.startTime = time;
/** @property {number|undefined} - The frame held by stop, undefined while running
* @type {number|undefined} */
this.heldFrame = undefined;
/** @property {SpriteAnimationEndCallback|undefined} - Called once when a play ends, on the first read after it
* @type {SpriteAnimationEndCallback|undefined} */
this.onEnd = undefined;
}
/** Start over from the first frame and repeat forever
* @return {SpriteAnimation} */
loop() { return this.restart('loop'); }
/** Start over from the first frame, run through once and hold the last frame, last to first at a negative speed
* @param {function():void} [onEnd] - Called once when it ends, on the first read of it after that
* @return {SpriteAnimation} */
play(onEnd)
{
this.restart('once');
this.onEnd = onEnd;
return this;
}
/** Start over from the first frame and run there and back forever
* @return {SpriteAnimation} */
pingPong() { return this.restart('pingPong'); }
/** Hold the current frame
* @return {SpriteAnimation} */
stop() { this.heldFrame = this.frame; return this; }
/** Start over from the first frame in a mode, with no end callback
* @param {string} [mode] - 'loop', 'once' or 'pingPong', the current mode when left out
* @return {SpriteAnimation} */
restart(mode=this.mode)
{
this.mode = mode;
this.startTime = time;
this.heldFrame = undefined;
this.onEnd = undefined;
return this;
}
/** How many frames have gone by since the start, fractional
* @return {number} */
get elapsedFrames() { return (time - this.startTime) * this.speed / this.frameTime; }
/** The frame showing now, 0 to frameCount-1
* @return {number} */
get frame()
{
this.isDone; // a read after a play ends calls onEnd, which may start it over
if (this.heldFrame !== undefined)
return this.heldFrame;
const n = this.frameCount, f = floor(this.elapsedFrames);
if (this.mode == 'once') // backward it counts down from the last frame, showing it from the start
return clamp(this.speed < 0 ? n - 1 + ceil(this.elapsedFrames) : f, 0, n - 1);
if (this.mode == 'loop')
return mod(f, n);
const period = max(2 * n - 2, 1), k = mod(f, period); // there and back, the ends once each
return k < n ? k : period - k;
}
/** The tile of the frame showing now
* @return {TileInfo} */
get tileInfo() { return this.firstTile.frame(this.frame); }
/** True once a play has shown its last frame for its time, the first read after that calls onEnd
* @return {boolean} */
get isDone()
{
const done = this.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount;
if (done && this.onEnd)
{
const onEnd = this.onEnd;
this.onEnd = undefined; // once, and it can start a new play with a callback of its own
onEnd();
}
return done;
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* SpriteAnimator - A character's animations by name, like idle, walk and attack, and the one showing now
* - Each clip is a SpriteAnimation and keeps its own mode, loop, play or pingPong
* - set starts a clip over only when it changes or a play of it has ended, so it can be called every update
* - Read tileInfo each frame for the frame to draw, as with a SpriteAnimation
* @example
* const hero = new SpriteAnimator({
* idle: new SpriteAnimation(tile(0, 16), 2, .4),
* walk: new SpriteAnimation(tile(2, 16), 4, .1),
* attack: new SpriteAnimation(tile(6, 16), 3, .05).play(),
* });
* hero.set('attack', ()=> hero.set('idle')); // back to idle when the attack ends
* // in update: this.tileInfo = hero.tileInfo;
* @memberof Draw
*/
class SpriteAnimator
{
/** Create an animator from its clips, showing the first one
* @param {Object<string, SpriteAnimation>} clips - The clips by name */
constructor(clips)
{
const names = Object.keys(clips);
ASSERT(names.length > 0, 'an animator needs at least one clip');
/** @property {Object<string, SpriteAnimation>} - The clips by name */
this.clips = clips;
/** @property {string} - The name of the clip showing now */
this.name = names[0];
}
/** Show a clip, starting it over in its own mode unless it is showing and has not ended
* @param {string} name - The clip's name
* @param {function():void} [onEnd] - Called once when a play clip ends, as with SpriteAnimation.play
* @return {SpriteAnimator} */
set(name, onEnd)
{
ASSERT(this.clips[name], 'no clip named ' + name);
if (name == this.name && !this.clip.isDone)
return this;
this.name = name;
this.clip.restart();
this.clip.onEnd = onEnd;
return this;
}
/** The clip showing now
* @return {SpriteAnimation} */
get clip() { return this.clips[this.name]; }
/** The tile of the frame showing now
* @return {TileInfo} */
get tileInfo() { return this.clip.tileInfo; }
/** The frame of the clip showing now
* @return {number} */
get frame() { return this.clip.frame; }
/** True once the clip showing now is a play that has ended
* @return {boolean} */
get isDone() { return this.clip.isDone; }
}
///////////////////////////////////////////////////////////////////////////////
/**
* Shader - A custom fragment shader for objects and draws, 2D or 3D
* - Write a mainImage function in the post processing style, the renderer wraps it with its own program
* - It gives the surface color, then the object's color and additive color apply in 2D, and the lighting,
* shadows and fog in 3D; set emissive to 1 on a 3D object for the snippet's color to be final
* - Set it as obj.shader, or use setShader for 2D draws and render3D.shader for 3D draws
* - Draws that share a Shader share a batch; with no Shader set nothing changes
* - In 2D it shades textured draws, untextured ones like drawRect draw as they are
* - A render target, like a tile layer drawn in WebGL, holds premultiplied color, and so does every image when
* tilesPixelated is false; there iChannel0 reads premultiplied texels and the snippet's color is taken as
* premultiplied too. premultipliedTexture is true there, so a snippet that changes the alpha, or makes a see
* through color of its own, scales the rgb with it: `if (premultipliedTexture) c.rgb *= k;`
* - Compiled once per renderer by the first draw that needs it; a bad snippet throws with the GLSL log in debug
* - Make each Shader once, at init, and share it; every one made lives for the session with its programs
* - Names in both renderers: iChannel0 the texture, iTime, iResolution, premultipliedTexture, and localUV, 0 to 1
* across the sprite or the mesh's own uv
* - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor,
* lightCount, lights[i], lightColors[i] and shadow()
* - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes
* parts of a surface still shadows with the whole of it
* @example
* const fade = new Shader(`
* void mainImage(out vec4 c, vec2 uv)
* {
* c = texture(iChannel0, uv);
* float fade = .5 + .5*sin(iTime);
* c.a *= fade;
* if (premultipliedTexture) c.rgb *= fade; // a tile layer, or any image with tilesPixelated false
* }`);
* obj.shader = fade;
* @memberof Draw
*/
class Shader
{
/** Create a shader from a fragment snippet that defines void mainImage(out vec4 c, vec2 uv)
* @param {string} fragmentCode */
constructor(fragmentCode)
{
ASSERT(isStringLike(fragmentCode) && String(fragmentCode).includes('mainImage'), 'a Shader needs fragment code that defines mainImage');
/** @property {string} - The mainImage snippet */
this.fragmentCode = String(fragmentCode);
/** @property {WebGLProgram|undefined} - The 2D program, compiled by the first draw that needs it, read only
* @type {WebGLProgram|undefined} */
this.program = undefined;
/** @property {WebGLProgram|undefined} - The 3D program, compiled by the 3D plugin the same way, read only
* @type {WebGLProgram|undefined} */
this.program3D = undefined;
glShaderObjects.push(this); // a lost context drops the programs of every one
}
}
///////////////////////////////////////////////////////////////////////////////
// Drawing functions
/** Draw textured tile centered in world space
* @param {Vector2} pos - Center of the tile in world space
* @param {Vector2} [size=vec2(1)] - Size of the tile in world space
* @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined
* @param {Color} [color=WHITE] - Color to modulate with
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [mirror] - Is image flipped along the Y axis?
* @param {Color} [additiveColor] - Additive color to be applied if any
* @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
* @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size are in screen space?
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
* @memberof Draw */
function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isVector2(size), 'size must be a vec2');
ASSERT(isColor(color), 'color is invalid');
ASSERT(isNumber(angle), 'angle must be a number');
ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
const textureInfo = tileInfo?.textureInfo;
if (textureInfo && !(tileInfo.size.x && tileInfo.size.y))
return; // a tile with no area draws nothing, like a sprite still loading
const bleed = tileInfo?.bleed ?? 0;
if (useWebGL && glEnable)
{
ASSERT(!!glContext, 'WebGL is not enabled!');
if (screenSpace)
{
if (glSkipScreenSpace) return;
[pos, size, angle] = screenToWorldTransform(pos, size, angle);
}
if (textureInfo)
{
ASSERT(!!textureInfo.glTexture, 'texture has no WebGL texture, draw it with useWebGL false');
// calculate uvs and render
const sizeInverse = textureInfo.sizeInverse;
const x = tileInfo.pos.x * sizeInverse.x;
const y = tileInfo.pos.y * sizeInverse.y;
const w = tileInfo.size.x * sizeInverse.x;
const h = tileInfo.size.y * sizeInverse.y;
glSetTexture(textureInfo.glTexture);
if (bleed)
{
const bleedX = sizeInverse.x*bleed;
const bleedY = sizeInverse.y*bleed;
glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle,
x + bleedX, y + bleedY,
x - bleedX + w, y - bleedY + h,
color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
}
else
{
glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle,
x, y, x + w, y + h,
color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
}
}
else
{
// untextured: color plus additive in one color, as the Canvas2D path below does
const combined = additiveColor ? color.add(additiveColor) : color;
glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt());
}
}
else
{
// normal canvas 2D rendering method (slower)
++drawCount;
++primitiveCount;
drawCanvas2D(pos, size, angle, mirror, (context)=>
{
if (textureInfo)
{
// un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
context.scale(1, -1);
// calculate uvs and render
const x = tileInfo.pos.x, y = tileInfo.pos.y;
const w = tileInfo.size.x, h = tileInfo.size.y;
drawImageColor(context, textureInfo.image, x, y, w, h, -.5, -.5, 1, 1, color, additiveColor, bleed);
}
else
{
// if no tile info, use untextured rect (Y-symmetric, no compensation needed)
const c = additiveColor ? color.add(additiveColor) : color;
context.fillStyle = c.toString();
context.fillRect(-.5, -.5, 1, 1);
}
}, screenSpace, context);
}
}
/** Draw colored rect centered on pos
* @param {Vector2} pos
* @param {Vector2} [size=vec2(1)]
* @param {Color} [color=WHITE]
* @param {number} [angle]
* @param {boolean} [useWebGL=glEnable]
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
* @memberof Draw */
function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
{
drawTile(pos, size, undefined, color, angle, false, undefined, useWebGL, screenSpace, context);
}
/** Draw a rect centered on pos with a gradient from top to bottom
* @param {Vector2} pos
* @param {Vector2} [size=vec2(1)]
* @param {Color} [colorTop=WHITE]
* @param {Color} [colorBottom=CLEAR_WHITE]
* @param {number} [angle]
* @param {boolean} [useWebGL=glEnable]
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
* @memberof Draw */
function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isVector2(size), 'size must be a vec2');
ASSERT(isColor(colorTop) && isColor(colorBottom), 'color is invalid');
ASSERT(isNumber(angle), 'angle must be a number');
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
if (useWebGL && glEnable)
{
ASSERT(!!glContext, 'WebGL is not enabled!');
if (screenSpace)
{
if (glSkipScreenSpace) return;
[pos, size, angle] = screenToWorldTransform(pos, size, angle);
}
// build 4 corner points for the rectangle
const points = [], colors = [];
const halfSizeX = size.x/2, halfSizeY = size.y/2;
const colorTopInt = colorTop.rgbaInt();
const colorBottomInt = colorBottom.rgbaInt();
const c = cos(-angle), s = sin(-angle);
for (let i=4; i--;)
{
const x = i & 1 ? halfSizeX : -halfSizeX;
const y = i & 2 ? halfSizeY : -halfSizeY;
const rx = x * c - y * s;
const ry = x * s + y * c;
const color = i & 2 ? colorTopInt : colorBottomInt;
points.push(vec2(pos.x + rx, pos.y + ry));
colors.push(color);
}
glDrawColoredPoints(points, colors);
}
else
{
// normal canvas 2D rendering method (slower)
++drawCount;
++primitiveCount;
drawCanvas2D(pos, size, angle, false, (context)=>
{
// gradient endpoints are flipped to match the Y flip inside drawCanvas2D
const gradient = context.createLinearGradient(0, .5, 0, -.5);
gradient.addColorStop(0, colorTop.toString());
gradient.addColorStop(1, colorBottom.toString());
context.fillStyle = gradient;
context.fillRect(-.5, -.5, 1, 1);
}, screenSpace, context);
}
}
/** Draw a texture tiled (wrapped) across a rectangle in world space.
* Useful for backgrounds, repeating patterns, and seamless fills.
* The whole texture is tiled — sub-region (TileInfo) wrapping is not supported.
* @param {Vector2} pos - Center of the rect in world space
* @param {Vector2} size - Size of the rect in world space
* @param {Vector2} wrapCount - How many times the texture repeats (x, y)
* @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
* @param {Color} [color=WHITE] - Color to modulate with
* @param {number} [angle=0] - Angle to rotate by
* @param {Color} [additiveColor] - Additive color to be applied if any
* @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
* @param {boolean} [screenSpace=drawScreenSpace] - Are pos and size in screen space?
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
* @memberof Draw */
function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
angle=0, additiveColor, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isVector2(size), 'size must be a vec2');
ASSERT(isVector2(wrapCount), 'wrapCount must be a vec2');
ASSERT(isColor(color), 'color is invalid');
ASSERT(isNumber(angle), 'angle must be a number');
ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
ASSERT(!(texture instanceof TileInfo),
'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo');
// only a context passed in is drawn to in headless mode, before the texture lookup, textureInfos is empty there
if (headlessMode && !context) return;
// resolve texture argument: TextureInfo or index
const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture;
ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded');
ASSERT(textureInfo.size.x > 0, 'texture not loaded');
ASSERT(textureInfo.wrap,
'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first');
if (useWebGL && glEnable)
{
ASSERT(!!glContext, 'WebGL is not enabled!');
if (screenSpace)
{
if (glSkipScreenSpace) return;
[pos, size, angle] = screenToWorldTransform(pos, size, angle);
}
glSetTexture(textureInfo.glTexture);
glDraw(pos.x, pos.y, size.x, size.y, angle,
0, 0, wrapCount.x, wrapCount.y,
color.rgbaInt(), additiveColor && additiveColor.rgbaInt());
return;
}
// Canvas2D path — increment counts here (WebGL counts via glFlush)
++drawCount;
++primitiveCount;
if (!screenSpace)
{
pos = worldToScreen(pos);
size = size.scale(cameraScale);
angle -= cameraAngle;
}
// pick image source: raw, or tinted bake. Match drawImageColor's
// "no tint needed" predicate so behavior stays consistent.
const noTint = !canvasColorTiles ||
isWhite(color) && (!additiveColor || isBlack(additiveColor));
// alpha is baked into pixels by bakeTintedImage's additive branch;
// in that case globalAlpha must NOT also apply color.a
const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
const source = noTint
? textureInfo.image
: bakeTintedImage(textureInfo.image, color, additiveColor);
context = context || drawContext;
context.save();
context.translate(pos.x + .5, pos.y + .5);
context.rotate(angle);
context.globalAlpha = alphaBaked ? 1 : color.a;
const pattern = context.createPattern(source, 'repeat');
// map pattern-source pixels into user space so the rect contains
// wrapCount.x × wrapCount.y repeats
const m = new DOMMatrix()
.translate(-size.x/2, -size.y/2)
.scale(size.x / (wrapCount.x * source.width),
size.y / (wrapCount.y * source.height));
pattern.setTransform(m);
context.fillStyle = pattern;
context.fillRect(-size.x/2, -size.y/2, size.x, size.y);
context.globalAlpha = 1;
context.restore();
}
/** Draw connected lines between a series of points
* @param {Array<Vector2>} points
* @param {number} [width]
* @param {Color} [color=WHITE]
* @param {boolean} [wrap] - Should the last point connect to the first?
* @param {Vector2} [pos=vec2()] - Offset to apply
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [useWebGL=glEnable]
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
* @memberof Draw */
function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
ASSERT(isArray(points), 'points must be an array');
ASSERT(isNumber(width), 'width must be a number');
ASSERT(isColor(color), 'color is invalid');
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isNumber(angle), 'angle must be a number');
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
if (useWebGL && glEnable)
{
ASSERT(!!glContext, 'WebGL is not enabled!');
let size = vec2(1);
if (screenSpace)
{
if (glSkipScreenSpace) return;
[pos, size, angle] = screenToWorldTransform(pos, size, angle);
}
glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, size.x, size.y, angle, wrap);
}
else
{
// normal canvas 2D rendering method (slower)
++drawCount;
++primitiveCount;
drawCanvas2D(pos, vec2(1), angle, false, (context)=>
{
// Canvas2D ignores a width of 0 or below and keeps the last one, WebGL draws none for 0
// and a negative width as its size, the outline turned inside out
if (!width) return;
context.strokeStyle = color.toString();
context.lineWidth = abs(width);
context.beginPath();
for (let i=0; i<points.length; ++i)
{
const point = points[i];
context.lineTo(point.x, point.y);
}
wrap && context.closePath();
context.stroke();
}, screenSpace, context);
}
}
/** Draw colored line between two points
* @param {Vector2} posA
* @param {Vector2} posB
* @param {number} [width]
* @param {Color} [color=WHITE]
* @param {Vector2} [pos=vec2()] - Offset to apply
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [useWebGL=glEnable]
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
* @memberof Draw */
function drawLine(posA, posB, width=.1, color=WHITE, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
const size = vec2(width, halfDelta.length()*2);
const middle = posA.add(halfDelta);
pos = pos.add(angle ? middle.rotate(screenSpace ? -angle : angle) : middle); // screen y is down, so it turns back
if (screenSpace)
halfDelta.y *= -1; // flip angle Y if screen space
angle += halfDelta.angle();
drawRect(pos, size, color, angle, useWebGL, screenSpace, context);
}
/** Draw colored regular polygon using passed in number of sides
* @param {Vector2} pos
* @param {Vector2} [size=vec2(1)]
* @param {number} [sides]
* @param {Color} [color=WHITE]
* @param {number} [lineWidth]
* @param {Color} [lineColor=BLACK]
* @param {number} [angle]
* @param {boolean} [useWebGL=glEnable]
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
* @memberof Draw */
function drawRegularPoly(pos, size=vec2(1), sides=3, color=WHITE, lineWidth=0, lineColor=BLACK, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
ASSERT(isVector2(size), 'size must be a vec2');
ASSERT(isNumber(sides), 'sides must be a number');
// build regular polygon points
const points = [];
const sizeX = size.x/2, sizeY = size.y/2;
for (let i=sides; i--;)
{
const a = (i/sides)*PI*2;
points.push(vec2(sin(a)*sizeX, cos(a)*sizeY));
}
drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context);
}
/** Draw colored polygon using passed in points
* - WebGL fills a polygon whose edges do not cross, concave or not; a self crossing one, like a star through its
* outer points, fills wrong there, so draw it as its simple outline or in parts
* @param {Array<Vector2>} points - Array of Vector2 points
* @param {Color} [color=WHITE]
* @param {number} [lineWidth]
* @param {Color} [lineColor=BLACK]
* @param {Vector2} [pos=vec2()] - Offset to apply
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [useWebGL=glEnable]
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
* @memberof Draw */
function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context=undefined)
{
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isArray(points), 'points must be an array');
ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
ASSERT(isNumber(angle), 'angle must be a number');
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
if (useWebGL && glEnable)
{
ASSERT(!!glContext, 'WebGL is not enabled!');
let size = vec2(1);
if (screenSpace)
{
if (glSkipScreenSpace) return;
[pos, size, angle] = screenToWorldTransform(pos, size, angle);
}
glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, size.x, size.y, angle);
if (lineWidth > 0)
glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, size.x, size.y, angle);
}
else
{
++drawCount;
++primitiveCount;
drawCanvas2D(pos, vec2(1), angle, false, context=>
{
context.fillStyle = color.toString();
context.beginPath();
for (const point of points)
context.lineTo(point.x, point.y);
context.closePath();
context.fill();
if (lineWidth > 0) // as in WebGL, Canvas2D would stroke a width below 0 with the last one
{
context.strokeStyle = lineColor.toString();
context.lineWidth = lineWidth;
context.stroke();
}
}, screenSpace, context);
}
}
// the unit rings drawEllipse fills with in WebGL, one for each side count
const drawEllipseRings = new Map;
/** Draw colored ellipse using passed in point
* @param {Vector2} pos
* @param {Vector2} [size=vec2(1)] - Width and height diameter
* @param {Color} [color=WHITE]
* @param {number} [angle]
* @param {number} [lineWidth]
* @param {Color} [lineColor=BLACK]
* @param {boolean} [useWebGL=glEnable]
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
* @memberof Draw */
function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isVector2(size), 'size must be a vec2');
ASSERT(isColor(color) && isColor(lineColor), 'color is invalid');
ASSERT(isNumber(angle), 'angle must be a number');
ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
ASSERT(lineWidth >= 0, 'lineWidth must be a positive value or 0');
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
// clamp line width to prevent artifacts, a negative size draws mirrored
lineWidth = clamp(lineWidth, 0, min(abs(size.x), abs(size.y)));
if (useWebGL && glEnable)
{
const sides = glCircleSides;
if (lineWidth > 0)
{
// draw as a regular polygon, the outline is made from the points at their size
drawRegularPoly(pos, size, sides, color, lineWidth, lineColor, angle, useWebGL, screenSpace, context);
return;
}
// a fill is a unit ring scaled, made once for each side count and already in strip order
let ring = drawEllipseRings.get(sides);
if (!ring)
{
const points = [];
for (let i=sides; i--;)
{
const a = (i/sides)*PI*2;
points.push(vec2(sin(a), cos(a)));
}
drawEllipseRings.set(sides, ring = glPolyStrip(points));
}
if (screenSpace)
{
if (glSkipScreenSpace) return;
[pos, size, angle] = screenToWorldTransform(pos, size, angle);
}
glDrawPointsTransform(ring, color.rgbaInt(), pos.x, pos.y, size.x/2, size.y/2, angle, false);
}
else
{
++drawCount;
++primitiveCount;
drawCanvas2D(pos, vec2(1), angle, false, context=>
{
context.fillStyle = color.toString();
context.beginPath();
context.ellipse(0, 0, abs(size.x)/2, abs(size.y)/2, 0, 0, 9); // it throws on a negative radius
context.fill();
if (lineWidth > 0)
{
context.strokeStyle = lineColor.toString();
context.lineWidth = lineWidth;
context.stroke();
}
}, screenSpace, context);
}
}
/** Draw colored circle using passed in point
* @param {Vector2} pos
* @param {number} [size=1] - Diameter
* @param {Color} [color=WHITE]
* @param {number} [lineWidth=0]
* @param {Color} [lineColor=BLACK]
* @param {boolean} [useWebGL=glEnable]
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
* @memberof Draw */
function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
ASSERT(isNumber(size), 'size must be a number');
drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context);
}
let drawEllipseGradientOffset = 0;
/** Draw an ellipse filled with a radial gradient from the center to the rim
* - Best when batched with other untextured polys
* - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
* - Stacking gradients at the exact same position may show a faint vertical artifact
* @param {Vector2} pos
* @param {Vector2} [size=vec2(1)] - Width and height diameter
* @param {Color} [colorInner=WHITE]
* @param {Color} [colorOuter=CLEAR_WHITE]
* @param {number} [angle]
* @param {boolean} [useWebGL=glEnable]
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
* @memberof Draw */
function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isVector2(size), 'size must be a vec2');
ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid');
ASSERT(isNumber(angle), 'angle must be a number');
ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode');
if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
if (useWebGL && glEnable)
{
ASSERT(!!glContext, 'WebGL is not enabled!');
if (screenSpace)
{
if (glSkipScreenSpace) return;
[pos, size, angle] = screenToWorldTransform(pos, size, angle);
}
// fan as tristrip; rotate the boundary vertex by one slice per call
// so back-to-back gradients at the same position have their hole
// (from gpu edge-rule on the boundary line-degen) at different rim
// verts and don't visibly stack
const sides = glCircleSides;
const radiusX = size.x/2, radiusY = size.y/2;
const innerInt = colorInner.rgbaInt();
const outerInt = colorOuter.rgbaInt();
const offset = drawEllipseGradientOffset++;
const c = cos(-angle), s = sin(-angle);
const rim = (a) =>
{
const lx = sin(a)*radiusX, ly = cos(a)*radiusY;
return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c);
};
const startA = (offset%sides)/sides*PI*2;
const points = [rim(startA)];
const colors = [outerInt];
for (let i=sides; i--;)
{
const a = ((i+offset)%sides)/sides*PI*2;
points.push(pos);
colors.push(innerInt);
points.push(rim(a));
colors.push(outerInt);
}
glDrawColoredPoints(points, colors);
}
else
{
// normal canvas 2D rendering method (slower)
++drawCount;
++primitiveCount;
drawCanvas2D(pos, size, angle, false, (context)=>
{
const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5);
gradient.addColorStop(0, colorInner.toString());
gradient.addColorStop(1, colorOuter.toString());
context.fillStyle = gradient;
context.beginPath();
context.ellipse(0, 0, .5, .5, 0, 0, 9);
context.fill();
}, screenSpace, context);
}
}
/** Draw a circle filled with a radial gradient from the center to the rim
* - Best when batched with other untextured polys
* - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
* - Stacking gradients at the exact same position may show a faint vertical artifact
* @param {Vector2} pos
* @param {number} [size=1] - Diameter
* @param {Color} [colorInner=WHITE]
* @param {Color} [colorOuter=CLEAR_WHITE]
* @param {boolean} [useWebGL=glEnable]
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
* @memberof Draw */
function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
ASSERT(isNumber(size), 'size must be a number');
drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context);
}
/**
* @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
* @memberof Draw
*/
/** Draw directly to a 2d canvas context in world space.
* The Y axis is flipped so world-Y-up coordinates render right-side up
* (matches the WebGL path). Callers whose drawing depends on Y direction
* (e.g. linear gradients) should flip their own Y endpoints accordingly.
* @param {Vector2} pos
* @param {Vector2} size
* @param {number} [angle]
* @param {boolean} [mirror]
* @param {Canvas2DDrawFunction} [drawFunction] - Needed, marked optional only because the ones before it are
* @param {boolean} [screenSpace=drawScreenSpace]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
* @memberof Draw */
function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpace=drawScreenSpace, context=drawContext)
{
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isVector2(size), 'size must be a vec2');
ASSERT(isNumber(angle), 'angle must be a number');
ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function');
if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
if (!screenSpace)
{
pos = worldToScreen(pos);
size = size.scale(cameraScale);
angle -= cameraAngle;
}
context.save();
context.translate(pos.x+.5, pos.y+.5);
context.rotate(angle);
context.scale(mirror ? -size.x : size.x, -size.y);
drawFunction(context);
context.restore();
}
///////////////////////////////////////////////////////////////////////////////
// Text Drawing Functions
/** Draw text on main canvas in world space
* Automatically splits new lines into rows
* @param {string|number} text
* @param {Vector2} pos
* @param {number} [size]
* @param {Color} [color=WHITE]
* @param {number} [lineWidth]
* @param {Color} [lineColor=BLACK]
* @param {'left'|'center'|'right'} [textAlign='center']
* @param {string} [font=fontDefault]
* @param {string} [fontStyle]
* @param {number} [maxWidth]
* @param {number} [angle]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
* @memberof Draw */
function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
{
// convert to screen space
pos = worldToScreen(pos);
size *= cameraScale;
lineWidth *= cameraScale;
if (maxWidth !== undefined)
maxWidth *= cameraScale;
angle -= cameraAngle;
drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
}
/** Draw text in screen space
* Automatically splits new lines into rows
* @param {string|number} text
* @param {Vector2} pos
* @param {number} size
* @param {Color} [color=WHITE]
* @param {number} [lineWidth]
* @param {Color} [lineColor=BLACK]
* @param {'left'|'center'|'right'} [textAlign]
* @param {string} [font=fontDefault]
* @param {string} [fontStyle]
* @param {number} [maxWidth]
* @param {number} [angle] - Clockwise, like the other screen space draws
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext]
* @memberof Draw */
function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
{
ASSERT(isStringLike(text), 'text must be a string');
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isNumber(size), 'size must be a number');
ASSERT(isColor(color), 'color must be a color');
ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
ASSERT(isColor(lineColor), 'lineColor must be a color');
ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
ASSERT(isStringLike(font), 'font must be a string');
ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
ASSERT(isNumber(angle), 'angle must be a number');
if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
const lines = (text+'').split('\n');
// save before style mutations so caller's context state is preserved
context.save();
context.fillStyle = color.toString();
context.strokeStyle = lineColor.toString();
context.lineWidth = lineWidth;
context.textAlign = textAlign;
context.font = fontStyle + ' ' + size + 'px '+ font;
context.textBaseline = 'middle';
context.translate(pos.x + .5, pos.y + .5); // a screen position is the center of a pixel, as for every other draw
context.rotate(angle);
let yOffset = -(lines.length-1) * size/2; // center vertically
lines.forEach(line=>
{
lineWidth && context.strokeText(line, 0, yOffset, maxWidth);
context.fillText(line, 0, yOffset, maxWidth);
yOffset += size;
});
context.restore();
}
///////////////////////////////////////////////////////////////////////////////
// Drawing utilities
/** Load a texture at a specific index after engineInit, the images passed to engineInit load this way
* @param {number} textureIndex - Index to store the texture at, an unused one
* @param {string} [src] - Image source path
* @return {Promise<TextureInfo>} Resolves to the texture info once the image loads, or fails to with a warning
* @memberof Draw */
async function loadTexture(textureIndex, src)
{
ASSERT(isNumber(textureIndex), 'textureIndex must be a number');
// engineInit's empty placeholder in slot 0, when it was given no images, or a load that failed can be replaced
const old = textureInfos[textureIndex];
ASSERT(!old?.size.x, 'textureIndex is already loaded!');
old?.destroyWebGLTexture();
ASSERT(!src || isStringLike(src), 'image src must be a string');
const image = new Image;
if (src)
{
await engineAddLoad(new Promise(resolve => // startup waits for it
{
image.onload = resolve;
image.onerror = ()=>
{
console.warn('failed to load image: ' + src); // in release too, like the WebGL warning
resolve();
};
image.crossOrigin = 'anonymous';
image.src = src;
}));
}
return textureInfos[textureIndex] = new TextureInfo(image);
}
/** Convert from screen to world space coordinates
* @param {Vector2} screenPos
* @return {Vector2}
* @memberof Draw */
function screenToWorld(screenPos)
{
ASSERT(isVector2(screenPos), 'screenPos must be a vec2');
let x = (screenPos.x - mainCanvasSize.x/2 + .5) / cameraScale;
let y = (screenPos.y - mainCanvasSize.y/2 + .5) / -cameraScale;
if (cameraAngle)
{
// apply camera rotation
const c = cos(-cameraAngle), s = sin(-cameraAngle);
const xr = x * c - y * s, yr = x * s + y * c;
x = xr; y = yr;
}
return new Vector2(x + cameraPos.x, y + cameraPos.y);
}
/** Convert from world to screen space coordinates
* @param {Vector2} worldPos
* @return {Vector2}
* @memberof Draw */
function worldToScreen(worldPos)
{
ASSERT(isVector2(worldPos), 'worldPos must be a vec2');
let x = worldPos.x - cameraPos.x;
let y = worldPos.y - cameraPos.y;
if (cameraAngle)
{
// apply inverse camera rotation
const c = cos(cameraAngle), s = sin(cameraAngle);
const xr = x * c - y * s, yr = x * s + y * c;
x = xr; y = yr;
}
return new Vector2
(
x * cameraScale + mainCanvasSize.x/2 - .5,
y * -cameraScale + mainCanvasSize.y/2 - .5
);
}
/** Convert from screen to world space coordinates for a directional vector (no translation)
* @param {Vector2} screenDelta
* @return {Vector2}
* @memberof Draw */
function screenToWorldDelta(screenDelta)
{
ASSERT(isVector2(screenDelta), 'screenDelta must be a vec2');
let x = screenDelta.x / cameraScale;
let y = screenDelta.y / -cameraScale;
if (cameraAngle)
{
// apply camera rotation
const c = cos(-cameraAngle), s = sin(-cameraAngle);
const xr = x * c - y * s, yr = x * s + y * c;
x = xr; y = yr;
}
return new Vector2(x, y);
}
/** Convert from world to screen space coordinates for a directional vector (no translation)
* @param {Vector2} worldDelta
* @return {Vector2}
* @memberof Draw */
function worldToScreenDelta(worldDelta)
{
ASSERT(isVector2(worldDelta), 'worldDelta must be a vec2');
let x = worldDelta.x;
let y = worldDelta.y;
if (cameraAngle)
{
// apply inverse camera rotation
const c = cos(cameraAngle), s = sin(cameraAngle);
const xr = x * c - y * s, yr = x * s + y * c;
x = xr; y = yr;
}
return new Vector2(x * cameraScale, y * -cameraScale);
}
/** Convert screen space transform to world space
* @param {Vector2} screenPos
* @param {Vector2} screenSize
* @param {number} [screenAngle]
* @return {[Vector2, Vector2, number]} - [pos, size, angle]
* @memberof Draw */
function screenToWorldTransform(screenPos, screenSize, screenAngle=0)
{
ASSERT(isVector2(screenPos), 'screenPos must be a vec2');
ASSERT(isVector2(screenSize), 'screenSize must be a vec2');
ASSERT(isNumber(screenAngle), 'screenAngle must be a number');
return [
screenToWorld(screenPos),
screenSize.scale(1/cameraScale),
screenAngle + cameraAngle
];
}
/** Get the size of the camera window in world space
* @return {Vector2}
* @memberof Draw */
function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
/** Padding for each side of a rectangle, the sides left out are 0
* @typedef {Object} CameraFitSides
* @property {number} [top]
* @property {number} [right]
* @property {number} [bottom]
* @property {number} [left]
* @memberof Draw */
/** Fit the camera to a rectangle in world space by setting cameraPos and cameraScale
* - worldMargin pads the content rectangle in world units, so the gap scales with the content on resize
* - screenInset reserves space in screen pixels on each viewport edge (for example a HUD band) and
* re-centers the content away from that edge, so the reserved band stays a fixed pixel size on resize
* - worldMargin and screenInset may each be a number for all sides, a Vector2 (x=left/right, y=top/bottom),
* or an object with any of {top, right, bottom, left}
* @param {Vector2} center - Center of the rectangle in world space
* @param {Vector2} size - Size of the rectangle in world space
* @param {number|Vector2|CameraFitSides} [worldMargin] - World space padding added around the content rectangle
* @param {number|Vector2|CameraFitSides} [screenInset] - Screen space padding in pixels reserved on each viewport edge
* @return {number} - The new camera scale
* @memberof Draw */
function cameraFit(center, size, worldMargin, screenInset)
{
ASSERT(isVector2(center), 'center must be a vec2');
ASSERT(isVector2(size), 'size must be a vec2');
// pad the content
const margin = padSides(worldMargin);
const inset = padSides(screenInset);
const worldW = size.x + margin.left + margin.right;
const worldH = size.y + margin.top + margin.bottom;
const viewW = mainCanvasSize.x - inset.left - inset.right;
const viewH = mainCanvasSize.y - inset.top - inset.bottom;
// bail on a degenerate rect or viewport rather than NaN the camera
if (!(worldW > 0 && worldH > 0 && viewW > 0 && viewH > 0))
return cameraScale;
// scale to fit the padded content, measured along the camera's axes when it is turned
const c = cos(cameraAngle), s = sin(cameraAngle);
const fitW = abs(c) * worldW + abs(s) * worldH, fitH = abs(s) * worldW + abs(c) * worldH;
cameraScale = min(viewW / fitW, viewH / fitH);
// calculate offset vectors, the inset is on the screen, so it turns back into the world with the camera
const marginVector = vec2(margin.right - margin.left, margin.top - margin.bottom).scale(.5);
const ix = (inset.right - inset.left) * .5 / cameraScale, iy = (inset.top - inset.bottom) * .5 / cameraScale;
const insetVector = vec2(ix * c + iy * s, iy * c - ix * s);
// apply the offsets and return camera scale
cameraPos = center.add(marginVector).add(insetVector);
return cameraScale;
function padSides(p)
{
// normalize a padding option to {top, right, bottom, left}
if (p === undefined || isNumber(p))
p = vec2(p);
if (isVector2(p))
return { top: p.y, right: p.x, bottom: p.y, left: p.x };
return {
top: p.top || 0,
right: p.right || 0,
bottom: p.bottom || 0,
left: p.left || 0,
};
}
}
/** Check if a box, point, or circle is on screen with a circle test
* If size is a Vector2, uses the length as diameter
* This can be used to cull offscreen objects from render or update
* @param {Vector2} pos - world space position
* @param {Vector2|number} [size] - world space size or diameter
* @return {boolean}
* @memberof Draw */
function isOnScreen(pos, size=0)
{
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number');
// cameraScale of 0 collapses world coords; nothing is visible
if (!cameraScale) return false;
// circle on screen test, worldToScreen inlined in doubled screen units so it compares against the full canvas size
let x = pos.x - cameraPos.x;
let y = pos.y - cameraPos.y;
if (cameraAngle)
{
// apply inverse camera rotation
const c = cos(cameraAngle), s = sin(cameraAngle);
const xr = x * c - y * s, yr = x * s + y * c;
x = xr; y = yr;
}
x *= cameraScale*2; y *= -cameraScale*2;
if (size instanceof Vector2)
size = size.length(); // use length of vector as diameter
size *= cameraScale;
// check against screen bounds
const w = mainCanvasSize.x, h = mainCanvasSize.y;
return x + size > -w && x - size < w &&
y + size > -h && y - size < h;
}
/** Enable additive blending
* @param {boolean} [additive]
* @memberof Draw */
function setAdditiveBlendMode(additive=true)
{
glAdditive = additive;
drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over';
}
/** Set the Shader that 2D draws use from now on, none for the engine's own
* - The object render loop sets each object's own shader, so this is for draws in gameRender and gameRenderPost
* @param {Shader} [shader]
* @memberof Draw */
function setShader(shader)
{
ASSERT(!shader || shader instanceof Shader, 'shader must be a Shader');
glCustomShader = shader || undefined; // null is no shader too, so it batches with none
}
/** Set an extra canvas to composite behind the engine canvases when combining
* Plugins that insert their own canvas below the LittleJS canvases should set
* this so it appears in screenshots and video capture
* @param {HTMLCanvasElement} [canvas]
* @memberof Draw */
function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
/** Combines LittleJS canvases onto the main canvas
* This is necessary for things like screenshots and video
* @memberof Draw */
function combineCanvases()
{
// this composites raw canvases so it works in backing store pixels,
// mainCanvasSize is css pixels and would throw away resolution
const w = mainCanvas.width, h = mainCanvas.height;
workCanvas.width = w;
workCanvas.height = h;
// remove background alpha — explicit fillStyle so a previous caller
// leaving workContext.fillStyle transparent can't silently no-op this
workContext.fillStyle = '#000';
workContext.fillRect(0,0,w,h);
if (backgroundCanvas)
workContext.drawImage(backgroundCanvas, 0, 0, w, h);
glCopyToContext(workContext);
workContext.drawImage(mainCanvas, 0, 0);
// draw back 1:1, mainContext is scaled to css pixels
mainContext.save();
mainContext.setTransform(1, 0, 0, 1, 0, 0);
mainContext.drawImage(workCanvas, 0, 0);
mainContext.restore();
}
// tint straight color pixels in place, rgb times color, plus additiveColor with alpha when it adds anything;
// returns true when color.a went into the alpha, so it is not applied again as globalAlpha
function tintImageData(data, color, additiveColor)
{
if (additiveColor && !isBlack(additiveColor))
{
// multiply + additive (slower), color.a is baked into the alpha channel here
const colorMultiply = [color.r, color.g, color.b, color.a];
const colorAdd = [additiveColor.r * 255, additiveColor.g * 255,
additiveColor.b * 255, additiveColor.a * 255];
for (let i = 0; i < data.length; ++i)
data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0;
return true;
}
// RGB only, faster — alpha left intact for the caller
for (let i = 0; i < data.length; i+=4)
{
data[i ] *= color.r;
data[i+1] *= color.g;
data[i+2] *= color.b;
}
return false;
}
// Internal: bake a color/additive-color tint into workReadCanvas at the
// image's native resolution. Returns the work canvas, suitable for
// passing to context.createPattern. Used by drawTextureWrapped's
// Canvas2D path. Caller is responsible for short-circuiting when no
// tint is needed (i.e. color is white and additiveColor is black/none).
function bakeTintedImage(image, color, additiveColor)
{
const w = image.width|0, h = image.height|0;
workReadCanvas.width = w;
workReadCanvas.height = h;
workReadContext.drawImage(image, 0, 0);
const imageData = workReadContext.getImageData(0, 0, w, h);
tintImageData(imageData.data, color, additiveColor);
workReadContext.putImageData(imageData, 0, 0);
return workReadCanvas;
}
/** Internal: draw an image with color and additive color applied in Canvas2D, drawTile calls it
* This is slower than normal drawImage when color is applied
* @ignore
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context
* @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image
* @param {number} sx
* @param {number} sy
* @param {number} sWidth
* @param {number} sHeight
* @param {number} dx
* @param {number} dy
* @param {number} dWidth
* @param {number} dHeight
* @param {Color} color
* @param {Color} [additiveColor]
* @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding
* @memberof Draw */
function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
{
const sx2 = bleed;
const sy2 = bleed;
sWidth = max(1,sWidth|0);
sHeight = max(1,sHeight|0);
const sWidth2 = sWidth - 2*bleed;
const sHeight2 = sHeight - 2*bleed;
if (!canvasColorTiles || isWhite(color) && (!additiveColor || isBlack(additiveColor)))
{
// white tint and nothing added, the texels are drawn as they are
context.globalAlpha = color.a;
context.drawImage(image, sx+sx2, sy+sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
context.globalAlpha = 1;
}
else
{
// copy to offscreen canvas
workReadCanvas.width = sWidth;
workReadCanvas.height = sHeight;
workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight);
// tint image using offscreen work context
const imageData = workReadContext.getImageData(0, 0, sWidth, sHeight);
const alphaBaked = tintImageData(imageData.data, color, additiveColor);
workReadContext.putImageData(imageData, 0, 0);
if (!alphaBaked) context.globalAlpha = color.a;
context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight);
if (!alphaBaked) context.globalAlpha = 1;
}
}
/** Returns true if fullscreen mode is active
* @return {boolean}
* @memberof Draw */
function isFullscreen() { return !!document.fullscreenElement; }
/** Toggle fullscreen mode
* @memberof Draw */
function toggleFullscreen()
{
const rootElement = mainCanvas.parentElement;
if (isFullscreen())
{
if (document.exitFullscreen)
document.exitFullscreen();
}
else if (rootElement.requestFullscreen)
rootElement.requestFullscreen();
}
/** Set the cursor style
* @param {string} [cursorStyle] - CSS cursor style (auto, none, crosshair, etc)
* @memberof Draw */
function setCursor(cursorStyle = 'auto')
{
const rootElement = mainCanvas.parentElement;
rootElement.style.cursor = cursorStyle;
}
///////////////////////////////////////////////////////////////////////////////
/** Engine font image, 8x8 font provided by the engine
* @type {ImageFont}
* @memberof Draw */
let engineImageFont;
/**
* Image Font Object - Draw text by using tiles in an image
* - 96 characters (from space to tilde) are stored in an image
* - A 8x8 default engine font is supplied for general use
* - This system is WebGL enabled for fast text rendering
* - Fonts can also be colored and scaled along each axis
*
* @memberof Draw
* @example
* // use built in font
* const font = engineImageFont;
*
* // draw text
* font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50), 16);
*/
class ImageFont
{
/** Create an image font
* @param {TileInfo} tileInfo - Tile info of first character in font
*/
constructor(tileInfo)
{
ASSERT(!!tileInfo, 'tileInfo is required for ImageFont');
/** @property {TileInfo} - Tile info for the font */
this.tileInfo = tileInfo; // kept, not copied, so a loadSprite tile filled in once it loads is seen
}
/** Draw text in world space using the image font
* - The text stays upright and ignores cameraAngle, each glyph is snapped to whole screen pixels to keep it crisp
* @param {string|number} text
* @param {Vector2} pos
* @param {Vector2|number} [size]
* @param {boolean} [center=true] - center each line on pos, and the lines of multi-line text around it
* @param {Color} [color=WHITE]
* @param {boolean} [useWebGL=glEnable]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
*/
drawText(text, pos, size=1, center, color, useWebGL, context)
{
ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
if (typeof size === 'number')
{
// if size is a number, make it a vector
ASSERT(size > 0);
size *= cameraScale;
size = new Vector2(size, size);
}
else
size = size.scale(cameraScale);
// world text, only drawn through screen space, so it still casts in a pass that skips screen space draws
const skip = glSkipScreenSpace;
glSkipScreenSpace = false;
this.drawTextScreen(text, worldToScreen(pos), size, center, color, useWebGL, context);
glSkipScreenSpace = skip;
}
/** Draw text in screen space using the image font
* @param {string|number} text
* @param {Vector2} pos
* @param {Vector2|number} size
* @param {boolean} [center] - center each line on pos, and the lines of multi-line text around it
* @param {Color} [color=WHITE]
* @param {boolean} [useWebGL=glEnable]
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
*/
drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context)
{
ASSERT(isStringLike(text), 'text must be a string');
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number');
ASSERT(typeof center === 'boolean', 'center must be a boolean, the color comes after it, unlike drawText');
ASSERT(isColor(color), 'color must be a color');
// if size is a number, make it a vector
const glyphSize = typeof size === 'number' ? new Vector2(size, size) : size;
// precache objects for drawing, a copy of the tile info each glyph moves, the font's own stays put
const drawPos = new Vector2;
const tileInfo = this.tileInfo.frame(0);
// draw each line of text, centered vertically like drawTextScreen when center is set
const lines = (text+'').split('\n');
const centerOffsetY = center ? (lines.length-1) * glyphSize.y / 2 : 0;
lines.forEach((line, j)=>
{
const centerOffset = center ? (line.length-1) * glyphSize.x / 2 : 0;
for (let i=line.length; i--;)
{
// get the glyph, out of range characters use the last one
const charCode = line.charCodeAt(i);
this.getGlyphPos(charCode < 32 || charCode > 127 ? 95 : charCode - 32, tileInfo.pos);
// snap the glyph edges to whole pixels
// tiles are drawn from their center, so snapping the center
// to a whole pixel puts the edges on half pixels when the
// size is even, and a row or column of the glyph then has
// no pixel center inside it and is not rasterized at all
// ceil picks the nearest aligned position, breaking ties
// downward to match how this used to truncate
drawPos.x = ceil(pos.x + i * glyphSize.x - centerOffset - glyphSize.x/2) + glyphSize.x/2 - .5;
drawPos.y = ceil(pos.y + j * glyphSize.y - centerOffsetY - glyphSize.y/2) + glyphSize.y/2 - .5;
drawTile(drawPos, glyphSize, tileInfo, color, 0, false, undefined, useWebGL, true, context);
}
});
}
/** Get where a glyph sits in the texture: counted in the font's own columns when its tile has them, like a font
* packed by loadSprite, otherwise along the texture's grid from the font's first tile, the way tile() lays it out
* @param {number} index - Glyph number, 0 is the space and the characters follow in ASCII order
* @param {Vector2} [pos] - Written into and returned, for a loop that places many
* @return {Vector2} */
getGlyphPos(index, pos=new Vector2)
{
const t = this.tileInfo, padding = t.padding;
const w = t.size.x + padding*2, h = t.size.y + padding*2;
if (t.columns)
return pos.set(t.pos.x + index % t.columns * w, t.pos.y + (index / t.columns |0) * h);
const columns = t.textureInfo.size.x / w |0;
const glyph = ((t.pos.y - padding) / h |0) * columns + ((t.pos.x - padding) / w |0) + index;
return pos.set(glyph % columns * w + padding, (glyph / columns |0) * h + padding);
}
}
// load engine font, called automatically on startup
async function imageFontInit()
{
const image = new Image;
await new Promise(resolve =>
{
image.onerror = image.onload = resolve;
image.crossOrigin = 'anonymous';
image.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAeAQMAAABnrVXaAAAABlBMVEUAAAD///+l2Z/dAAAAAXRSTlMAQObYZgAAAjpJREFUOMu9kzFu2zAUhn+CAROgqrk+B2l0BWYxMjlXeYaAtFtbdA1sGgHqRQfI0CNkSG5AwYB0BQ8d5Bsomwah6CPVeGg6tEPzAxLwyI+P78cP4u9lNO9OoMKnLMOobG5020/yaj/MrRcCGh1gBbyiLTPJEYaIiom5KM9Jq7KgynMGtb6L4GL4MF2H4LQKCXTvDVw2I4MsgZT7QLExdiutH+D08VOP3INXRrWX1/mmpbkNgAPYRVANb4xpcegYvhiNbIXauQICEjBuYLfMakaakWQeXxiZ0VDtuJCKs3ztMV59QtsHJNcRxDzfdL21ty3PrfIcXTN+E+GFAv6T5nbT9jd50/WFxb5ksdAv49qS6ouymG66ji08UMT6moykYLAo+V0j23GN4m829ZySAD5K7QsBfQTvOG8eE+gTeGYRAmnNAubN3hf5Zv9tJWDHp/VTuaSm7SN4fyINQqaNO3RMVxvpSPXnOChnRNvFcGY0gnwiPswYwTKVPE0zVtX3mTEIOoFzaqLrGuJaV+Uqumb71fVk/VoOH3cdLNQP/FHi8hV0CQNoqBZsUPlLPMsdCJro9QAaQQ0woDy9BJm0eTxCFnO9srcYlhNVlfR2EyTrph1uUtbUtAJifwRgrKuYdXVHeb0YI3QpawohQHkloI3J5FuVwI5ORxC9k2Tuz9Ir1IjgeIPGMHYkAZe2RuYkmWFmt3gGbTPOmBUWVTmRmHtGrfpzG/yuQNOKa6gBB/WA9khitPgl6/GP+gl2Af6tCbvaygAAAABJRU5ErkJggg==';
});
const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0;
const textureInfo = new TextureInfo(image);
const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed);
engineImageFont = new ImageFont(tileInfo);
}