utils/FontAtlas.tstypescript
import { Deferred } from "../Deferred";
import { Rectangle } from "../Rectangle";
import { TextureAtlasNode } from "./TextureAtlas";
import type { ImageSource, WebGLTextureExt } from "../webgl/Handler";
import { Handler } from "../webgl/Handler";
import type { HTMLImageElementExt } from "./ImagesCacheManager";
//@todo: get the value from shader module
const MAX_SIZE = 11;
interface IChar {
id: number;
char: string;
width: number;
height: number;
x: number;
y: number;
chnl: number;
index: number;
page: number;
xadvance: number;
xoffset: number;
yoffset: number;
}
interface IKerning {
first: number;
second: number;
amount: number;
}
interface IFontBmParams {
common: {
scaleH: number;
scaleW: number;
};
info: {
size: number;
};
distanceField: {
distanceRange: number;
isMtsdf: boolean;
};
glyphs: IChar[];
kernings: IKerning[];
}
interface IMSDFAtlasBounds {
left: number;
bottom: number;
right: number;
top: number;
}
interface IMSDFGlyph {
index: string;
unicode?: number | string;
advance: number;
planeBounds: IMSDFAtlasBounds;
atlasBounds: IMSDFAtlasBounds;
}
interface IMSDFKerning {
index1: number;
index2: number;
advance: number;
}
export interface IMSDFAtlasParams {
atlas: {
type: string;
distanceRange: number;
distanceRangeMiddle: number;
size: number;
width: number;
height: number;
yOrigin: "top" | "bottom";
};
metrics: {
emSize: number;
lineHeight: number;
ascender: number;
descender: number;
underlineY: number;
underlineThickness: number;
};
glyphs: IMSDFGlyph[];
kerning: IMSDFKerning[];
}
class FontTextureAtlas {
public width: number;
public height: number;
public gliphSize: number;
public distanceRange: number;
public isMtsdf: boolean;
public sourceImage: HTMLImageElementExt | null;
public nodes: Map<number, FontTextureAtlasNode>;
public kernings: Record<number, Record<number, number>>;
constructor() {
this.width = 0;
this.height = 0;
this.gliphSize = 0;
this.distanceRange = 0;
this.isMtsdf = false;
this.sourceImage = null;
this.nodes = new Map<number, FontTextureAtlasNode>();
this.kernings = {};
}
public get(key: number): FontTextureAtlasNode | undefined {
return this.nodes.get(key);
}
public createTexture(img?: HTMLImageElementExt | null) {
this.sourceImage = img || null;
}
}
interface IMetrics extends IChar {
nChar: string;
nCode: number;
nWidth: number;
nHeight: number;
nAdvance: number;
nXOffset: number;
nYOffset: number;
}
class FontTextureAtlasNode extends TextureAtlasNode {
public metrics: IMetrics;
public emptySize: number;
constructor(rect: Rectangle, texCoords: number[]) {
super(rect, texCoords);
this.emptySize = 1;
this.metrics = {
id: 0,
char: "",
width: 0,
height: 0,
x: 0,
y: 0,
chnl: 0,
index: 0,
page: 0,
xadvance: 0,
xoffset: 0,
yoffset: 0,
nChar: "",
nCode: 0,
nWidth: 0,
nHeight: 0,
nAdvance: 0,
nXOffset: 0,
nYOffset: 0
};
}
}
class FontAtlas {
public atlasesArr: FontTextureAtlas[];
public samplerArr: Uint32Array;
public sdfParamsArr: Float32Array;
public textureArray: WebGLTextureExt | null;
public catalogSrc: string;
protected atlasIndexes: Record<string, number>;
protected atlasIndexesDeferred: Record<string, Deferred<number>>;
protected tokenImageSize: number;
protected _handler: Handler | null;
protected _textureArrayWidth: number;
protected _textureArrayHeight: number;
protected _textureArrayMismatchWarningShown: boolean;
protected _fontLoadWarningShown: Record<string, boolean>;
constructor(catalogSrc?: string) {
this.atlasesArr = [];
this.atlasIndexes = {};
this.atlasIndexesDeferred = {};
this.tokenImageSize = 64;
this.samplerArr = new Uint32Array(MAX_SIZE);
this.sdfParamsArr = new Float32Array(MAX_SIZE * 4);
this.textureArray = null;
this._handler = null;
this._textureArrayWidth = 0;
this._textureArrayHeight = 0;
this._textureArrayMismatchWarningShown = false;
this._fontLoadWarningShown = {};
this.catalogSrc = catalogSrc || "./";
}
public assignHandler(handler: Handler) {
this._handler = handler;
}
public getFontIndex(face: string): Promise<number> {
let fullName = this.getFullIndex(face);
// Try to load font from the directory
if (this.atlasIndexes[fullName] === undefined) {
this.loadFont(face, this.catalogSrc, `${face}.json`);
}
if (!this.atlasIndexesDeferred[fullName]) {
this.atlasIndexesDeferred[fullName] = new Deferred<number>();
}
return this.atlasIndexesDeferred[fullName].promise;
}
public getFullIndex(face: string): string {
return face.trim().toLowerCase();
}
protected _normalizeMsdfAtlasParams(data: IMSDFAtlasParams, atlasUrl?: string): IFontBmParams {
const s = data.atlas.size || 1;
const atlasType = (data.atlas.type || "").toLowerCase();
const isMtsdf = atlasType === "mtsdf";
const yOrigin = data.atlas.yOrigin || "bottom";
const isTopOrigin = yOrigin === "top";
const glyphs: IChar[] = [];
for (let i = 0; i < data.glyphs.length; i++) {
const gi = data.glyphs[i];
const rawGlyphCode =
gi.unicode != undefined ? Number(gi.unicode) : gi.index != undefined ? Number(gi.index) : i;
const glyphIndex = Number.isFinite(rawGlyphCode) ? rawGlyphCode : i;
let x = 0;
let y = 0;
let width = 0;
let height = 0;
if (gi.atlasBounds) {
width = gi.atlasBounds.right - gi.atlasBounds.left;
x = gi.atlasBounds.left;
if (isTopOrigin) {
height = gi.atlasBounds.bottom - gi.atlasBounds.top;
y = gi.atlasBounds.top;
} else {
height = gi.atlasBounds.top - gi.atlasBounds.bottom;
y = data.atlas.height - gi.atlasBounds.top;
}
}
let xoffset = 0;
let yoffset = 0;
if (gi.planeBounds) {
let planeTop: number;
let planeBottom: number;
if (isTopOrigin) {
planeTop = -gi.planeBounds.top;
planeBottom = -gi.planeBounds.bottom;
} else {
planeTop = gi.planeBounds.top;
planeBottom = gi.planeBounds.bottom;
}
width = (gi.planeBounds.right - gi.planeBounds.left) * s;
height = (planeTop - planeBottom) * s;
xoffset = gi.planeBounds.left * s;
yoffset = (1.0 - planeTop) * s;
}
const char = glyphIndex <= 0x10ffff ? String.fromCodePoint(glyphIndex) : "";
glyphs.push({
id: glyphIndex,
index: glyphIndex,
char,
width,
height,
x,
y,
chnl: 15,
page: 0,
xadvance: gi.advance * s,
xoffset,
yoffset
});
}
const kernings: IKerning[] = [];
if (data.kerning) {
for (let i = 0; i < data.kerning.length; i++) {
const ki = data.kerning[i];
const first = ki.index1;
const second = ki.index2;
kernings.push({
first,
second,
amount: ki.advance * s
});
}
}
return {
common: {
scaleH: data.atlas.height,
scaleW: data.atlas.width
},
info: {
size: s
},
distanceField: {
distanceRange: data.atlas.distanceRange,
isMtsdf
},
glyphs,
kernings
};
}
protected _applyFontDataToAtlas(atlas: FontTextureAtlas, data: IFontBmParams, index: number = 0) {
let glyphs = data.glyphs;
atlas.height = data.common.scaleH;
atlas.width = data.common.scaleW;
atlas.gliphSize = data.info.size;
atlas.distanceRange = data.distanceField.distanceRange;
atlas.isMtsdf = data.distanceField.isMtsdf;
let w = atlas.width,
h = atlas.height,
s = atlas.gliphSize;
this.sdfParamsArr[index * 4] = w;
this.sdfParamsArr[index * 4 + 1] = h;
// z is a shader flag: 1.0 for MTSDF atlas (alpha contains true SDF), 0.0 otherwise.
this.sdfParamsArr[index * 4 + 2] = atlas.isMtsdf ? 1.0 : 0.0;
this.sdfParamsArr[index * 4 + 3] = atlas.distanceRange;
atlas.nodes.clear();
for (let i = 0; i < glyphs.length; i++) {
let ci = glyphs[i];
let r = new Rectangle(ci.x, ci.y, ci.x + ci.width, ci.y + ci.height);
let tc = new Array(12);
tc[0] = r.left / w;
tc[1] = r.top / h;
tc[2] = r.left / w;
tc[3] = r.bottom / h;
tc[4] = r.right / w;
tc[5] = r.bottom / h;
tc[6] = r.right / w;
tc[7] = r.bottom / h;
tc[8] = r.right / w;
tc[9] = r.top / h;
tc[10] = r.left / w;
tc[11] = r.top / h;
let taNode = new FontTextureAtlasNode(r, tc);
const ciNorm = ci.char.normalize("NFKC");
const ciCode = ciNorm.codePointAt(0) ?? ci.id;
//taNode.metrics = ci;
let m = taNode.metrics;
m.id = ci.id;
m.char = ci.char;
m.width = ci.width;
m.height = ci.height;
m.x = ci.x;
m.y = ci.y;
m.chnl = ci.chnl;
m.index = ci.index;
m.page = ci.page;
m.xadvance = ci.xadvance;
m.xoffset = ci.xoffset;
m.yoffset = ci.yoffset;
m.nChar = ciNorm;
m.nCode = ciCode;
m.nWidth = taNode.metrics.width / s;
m.nHeight = taNode.metrics.height / s;
m.nAdvance = taNode.metrics.xadvance / s;
m.nXOffset = taNode.metrics.xoffset / s;
m.nYOffset = 1.0 - taNode.metrics.yoffset / s;
taNode.emptySize = 1;
atlas.nodes.set(ciCode, taNode);
}
atlas.kernings = {};
for (let i = 0; i < data.kernings.length; i++) {
let ki = data.kernings[i];
let first = ki.first,
second = ki.second;
if (!atlas.kernings[first]) {
atlas.kernings[first] = {};
}
atlas.kernings[first][second] = ki.amount / s;
}
}
public initFont(faceName: string, dataJson: IMSDFAtlasParams, imageBase64: string) {
let index = this.atlasesArr.length;
let fullName = this.getFullIndex(faceName);
this.atlasIndexes[fullName] = index;
let def = this.atlasIndexesDeferred[fullName];
if (!def) {
def = this.atlasIndexesDeferred[fullName] = new Deferred<number>();
}
this.samplerArr[this.atlasesArr.length] = index;
// TODO: FontTextureAtlas();
let atlas = new FontTextureAtlas();
atlas.height = 0;
atlas.width = 0;
atlas.gliphSize = 0;
atlas.distanceRange = 0;
atlas.isMtsdf = false;
atlas.kernings = {};
this.atlasesArr[index] = atlas;
this._applyFontDataToAtlas(atlas, this._normalizeMsdfAtlasParams(dataJson), index);
let img = new Image();
img.onload = () => {
this._createTexture(atlas, img);
def.resolve(index);
};
img.src = imageBase64;
}
protected _createTexture(atlas: FontTextureAtlas, img: HTMLImageElementExt) {
atlas.createTexture(img);
this._updateTextureArrayLayer(atlas);
this._handler && (this._handler.needRedraw = true);
}
protected _ensureTextureArray(width: number, height: number): boolean {
if (!this._handler || !this._handler.isWebGl2() || !this._handler.gl) {
return false;
}
if (!this.textureArray) {
let gl = this._handler.gl;
this.textureArray = gl.createTexture() as WebGLTextureExt;
this._textureArrayWidth = width;
this._textureArrayHeight = height;
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.textureArray);
gl.texImage3D(gl.TEXTURE_2D_ARRAY, 0, gl.RGBA, width, height, MAX_SIZE, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, null);
}
const isSameSize = this._textureArrayWidth === width && this._textureArrayHeight === height;
if (!isSameSize && !this._textureArrayMismatchWarningShown) {
this._textureArrayMismatchWarningShown = true;
console.warn("FontAtlas: all fonts must have identical atlas dimensions for sampler2DArray labels.");
}
return isSameSize;
}
protected _updateTextureArrayLayer(atlas: FontTextureAtlas) {
if (!this._handler || !this._handler.gl || !this._handler.isWebGl2()) {
return;
}
let index = this.atlasesArr.indexOf(atlas);
if (index === -1 || index >= MAX_SIZE) {
return;
}
if (!this._ensureTextureArray(atlas.width, atlas.height)) {
return;
}
let gl = this._handler.gl;
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.textureArray);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
const uploadSource: ImageSource = atlas.sourceImage as ImageSource;
gl.texSubImage3D(
gl.TEXTURE_2D_ARRAY,
0,
0,
0,
index,
atlas.width,
atlas.height,
1,
gl.RGBA,
gl.UNSIGNED_BYTE,
uploadSource
);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.BROWSER_DEFAULT_WEBGL);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, null);
}
public loadFont(faceName: string, srcDir: string, atlasUrl: string) {
let index = this.atlasesArr.length;
let fullName = this.getFullIndex(faceName);
const fontJsonUrl = `${srcDir}/${atlasUrl}`;
this.atlasIndexes[fullName] = index;
let def = this.atlasIndexesDeferred[fullName];
if (!def) {
def = this.atlasIndexesDeferred[fullName] = new Deferred<number>();
}
this.samplerArr[this.atlasesArr.length] = index;
let atlas = new FontTextureAtlas();
atlas.height = 0;
atlas.width = 0;
atlas.gliphSize = 0;
atlas.distanceRange = 0;
atlas.isMtsdf = false;
atlas.kernings = {};
this.atlasesArr[index] = atlas;
fetch(fontJsonUrl)
.then((response: Response) => {
if (!response.ok) {
throw Error(`Unable to load "${fontJsonUrl}"`);
}
return response.json();
})
.then((rawData: IMSDFAtlasParams) => {
const data = this._normalizeMsdfAtlasParams(rawData, atlasUrl);
this._applyFontDataToAtlas(atlas, data, index);
let img = new Image();
img.onload = () => {
this._createTexture(atlas, img);
def.resolve(index);
};
const atlasImageUrl = `${atlasUrl.slice(0, atlasUrl.length - 5)}.png`;
img.src = `${srcDir}/${atlasImageUrl}`;
img.crossOrigin = "Anonymous";
})
.catch((err) => {
if (!this._fontLoadWarningShown[fullName]) {
this._fontLoadWarningShown[fullName] = true;
console.warn(
`FontAtlas: font "${faceName}" not found or invalid (${fontJsonUrl}). Labels using this font may not render.`,
err
);
}
// Keep promise resolved to prevent unhandled rejections in label update loops.
def.resolve(index);
return { status: "error", msg: err.toString() };
});
}
}
export { FontAtlas };