playcanvas
Version:
PlayCanvas WebGL game engine
1,022 lines (1,020 loc) • 79.1 kB
JavaScript
import { Debug } from '../../../core/debug.js';
import { string } from '../../../core/string.js';
import { math } from '../../../core/math/math.js';
import { Color } from '../../../core/math/color.js';
import { Vec2 } from '../../../core/math/vec2.js';
import { BoundingBox } from '../../../core/shape/bounding-box.js';
import { SEMANTIC_POSITION, SEMANTIC_TEXCOORD0, SEMANTIC_COLOR, SEMANTIC_ATTR8, SEMANTIC_ATTR9, TYPE_FLOAT32 } from '../../../platform/graphics/constants.js';
import { VertexIterator } from '../../../platform/graphics/vertex-iterator.js';
import { GraphNode } from '../../../scene/graph-node.js';
import { MeshInstance } from '../../../scene/mesh-instance.js';
import { Model } from '../../../scene/model.js';
import { Mesh } from '../../../scene/mesh.js';
import { LocalizedAsset } from '../../asset/asset-localized.js';
import { I18n } from '../../i18n/i18n.js';
import { FONT_MSDF, FONT_BITMAP } from '../../font/constants.js';
import { Markup } from './markup.js';
/**
* @import { CanvasFont } from '../../../framework/font/canvas-font.js'
* @import { Font } from '../../../framework/font/font.js'
*/ class MeshInfo {
constructor(){
// number of symbols
this.count = 0;
// number of quads created
this.quad = 0;
// number of quads on specific line
this.lines = {};
// float array for positions
this.positions = [];
// float array for normals
this.normals = [];
// float array for UVs
this.uvs = [];
// float array for vertex colors
this.colors = [];
// float array for indices
this.indices = [];
// float array for outline
this.outlines = [];
// float array for shadows
this.shadows = [];
// pc.MeshInstance created from this MeshInfo
this.meshInstance = null;
}
}
/**
* Creates a new text mesh object from the supplied vertex information and topology.
*
* @param {object} device - The graphics device used to manage the mesh.
* @param {MeshInfo} [meshInfo] - An object that specifies optional inputs for the function as follows:
* @returns {Mesh} A new Mesh constructed from the supplied vertex and triangle data.
* @ignore
*/ function createTextMesh(device, meshInfo) {
const mesh = new Mesh(device);
mesh.setPositions(meshInfo.positions);
mesh.setNormals(meshInfo.normals);
mesh.setColors32(meshInfo.colors);
mesh.setUvs(0, meshInfo.uvs);
mesh.setIndices(meshInfo.indices);
mesh.setVertexStream(SEMANTIC_ATTR8, meshInfo.outlines, 3, undefined, TYPE_FLOAT32, false);
mesh.setVertexStream(SEMANTIC_ATTR9, meshInfo.shadows, 3, undefined, TYPE_FLOAT32, false);
mesh.update();
return mesh;
}
const LINE_BREAK_CHAR = /^[\r\n]$/;
const WHITESPACE_CHAR = /^[ \t]$/;
const WORD_BOUNDARY_CHAR = /^[ \t\-]|\u200b$/; // NB \u200b is zero width space
const ALPHANUMERIC_CHAR = /^[a-z0-9]$/i;
// 1100—11FF Hangul Jamo
// 3000—303F CJK Symbols and Punctuation \
// 3130—318F Hangul Compatibility Jamo -- grouped
// 4E00—9FFF CJK Unified Ideographs /
// A960—A97F Hangul Jamo Extended-A
// AC00—D7AF Hangul Syllables
// D7B0—D7FF Hangul Jamo Extended-B
const CJK_CHAR = /^[\u1100-\u11ff]|[\u3000-\u9fff\ua960-\ua97f]|[\uac00-\ud7ff]$/;
const NO_LINE_BREAK_CJK_CHAR = /^[〕〉》」』】〙〗〟ヽヾーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻]$/;
// unicode bidi control characters https://en.wikipedia.org/wiki/Unicode_control_characters
const CONTROL_CHARS = [
'\u200B',
'\u061C',
'\u200E',
'\u200F',
'\u202A',
'\u202B',
'\u202C',
'\u202D',
'\u202E',
'\u2066',
'\u2067',
'\u2068',
'\u2069'
];
// glyph data to use for missing control characters
const CONTROL_GLYPH_DATA = {
width: 0,
height: 0,
xadvance: 0,
xoffset: 0,
yoffset: 0
};
const colorTmp = new Color();
const vec2Tmp = new Vec2();
const _tempColor = new Color();
class TextElement {
constructor(element){
this._element = element;
this._system = element.system;
this._entity = element.entity;
// public
this._text = ''; // the original user-defined text
this._symbols = []; // array of visible symbols with unicode processing and markup removed
this._colorPalette = []; // per-symbol color palette
this._outlinePalette = []; // per-symbol outline color/thickness palette
this._shadowPalette = []; // per-symbol shadow color/offset palette
this._symbolColors = null; // per-symbol color indexes. only set for text with markup.
this._symbolOutlineParams = null; // per-symbol outline color/thickness indexes. only set for text with markup.
this._symbolShadowParams = null; // per-symbol shadow color/offset indexes. only set for text with markup.
/** @type {string} */ this._i18nKey = null;
this._fontAsset = new LocalizedAsset(this._system.app);
this._fontAsset.disableLocalization = true;
this._fontAsset.on('load', this._onFontLoad, this);
this._fontAsset.on('change', this._onFontChange, this);
this._fontAsset.on('remove', this._onFontRemove, this);
/** @type {Font | CanvasFont} */ this._font = null;
this._color = new Color(1, 1, 1, 1);
this._colorUniform = new Float32Array(3);
this._spacing = 1;
this._fontSize = 32;
this._fontMinY = 0;
this._fontMaxY = 0;
// the font size that is set directly by the fontSize setter
this._originalFontSize = 32;
this._maxFontSize = 32;
this._minFontSize = 8;
this._autoFitWidth = false;
this._autoFitHeight = false;
this._maxLines = -1;
this._lineHeight = 32;
this._scaledLineHeight = 32;
this._wrapLines = false;
this._drawOrder = 0;
this._alignment = new Vec2(0.5, 0.5);
this._autoWidth = true;
this._autoHeight = true;
this.width = 0;
this.height = 0;
// private
this._node = new GraphNode();
this._model = new Model();
this._model.graph = this._node;
this._entity.addChild(this._node);
this._meshInfo = [];
this._material = null;
this._aabbDirty = true;
this._aabb = new BoundingBox();
this._noResize = false; // flag used to disable resizing events
this._currentMaterialType = null; // save the material type (screenspace or not) to prevent overwriting
this._maskedMaterialSrc = null; // saved material that was assigned before element was masked
this._rtlReorder = false;
this._unicodeConverter = false;
this._rtl = false; // true when the current text is RTL
this._outlineColor = new Color(0, 0, 0, 1);
this._outlineColorUniform = new Float32Array(4);
this._outlineThicknessScale = 0.2; // 0.2 coefficient to map editor range of 0 - 1 to shader value
this._outlineThickness = 0.0;
this._shadowColor = new Color(0, 0, 0, 1);
this._shadowColorUniform = new Float32Array(4);
this._shadowOffsetScale = 0.005; // maps the editor scale value to shader scale
this._shadowOffset = new Vec2(0, 0);
this._shadowOffsetUniform = new Float32Array(2);
this._enableMarkup = false;
// initialize based on screen
this._onScreenChange(this._element.screen);
// start listening for element events
element.on('resize', this._onParentResize, this);
element.on('set:screen', this._onScreenChange, this);
element.on('screen:set:screenspace', this._onScreenSpaceChange, this);
element.on('set:draworder', this._onDrawOrderChange, this);
element.on('set:pivot', this._onPivotChange, this);
this._system.app.i18n.on(I18n.EVENT_CHANGE, this._onLocaleSet, this);
this._system.app.i18n.on('data:add', this._onLocalizationData, this);
this._system.app.i18n.on('data:remove', this._onLocalizationData, this);
// substring render range
this._rangeStart = 0;
this._rangeEnd = 0;
}
destroy() {
this._setMaterial(null); // clear material from mesh instances
if (this._model) {
this._element.removeModelFromLayers(this._model);
this._model.destroy();
this._model = null;
}
this._fontAsset.destroy();
this.font = null;
this._element.off('resize', this._onParentResize, this);
this._element.off('set:screen', this._onScreenChange, this);
this._element.off('screen:set:screenspace', this._onScreenSpaceChange, this);
this._element.off('set:draworder', this._onDrawOrderChange, this);
this._element.off('set:pivot', this._onPivotChange, this);
this._system.app.i18n.off(I18n.EVENT_CHANGE, this._onLocaleSet, this);
this._system.app.i18n.off('data:add', this._onLocalizationData, this);
this._system.app.i18n.off('data:remove', this._onLocalizationData, this);
}
_onParentResize(width, height) {
if (this._noResize) return;
if (this._font) this._updateText();
}
_onScreenChange(screen) {
if (screen) {
this._updateMaterial(screen.screen.screenSpace);
} else {
this._updateMaterial(false);
}
}
_onScreenSpaceChange(value) {
this._updateMaterial(value);
}
_onDrawOrderChange(order) {
this._drawOrder = order;
if (this._model) {
for(let i = 0, len = this._model.meshInstances.length; i < len; i++){
this._model.meshInstances[i].drawOrder = order;
}
}
}
_onPivotChange(pivot) {
if (this._font) {
this._updateText();
}
}
_onLocaleSet(locale) {
if (!this._i18nKey) return;
// if the localized font is different
// then the current font and the localized font
// is not yet loaded then reset the current font and wait
// until the localized font is loaded to see the updated text
if (this.fontAsset) {
const asset = this._system.app.assets.get(this.fontAsset);
if (!asset || !asset.resource || asset.resource !== this._font) {
this.font = null;
}
}
this._resetLocalizedText();
}
_onLocalizationData(locale, messages) {
if (this._i18nKey && messages[this._i18nKey]) {
this._resetLocalizedText();
}
}
_resetLocalizedText() {
this._setText(this._system.app.i18n.getText(this._i18nKey));
}
_setText(text) {
if (this.unicodeConverter) {
const unicodeConverterFunc = this._system.getUnicodeConverter();
if (unicodeConverterFunc) {
text = unicodeConverterFunc(text);
} else {
console.warn('Element created with unicodeConverter option but no unicodeConverter function registered');
}
}
if (this._text !== text) {
if (this._font) {
this._updateText(text);
}
this._text = text;
}
}
_updateText(text) {
let tags;
if (text === undefined) text = this._text;
// get the list of symbols
// NOTE: we must normalize text here in order to be consistent with the number of
// symbols returned from the bidi algorithm. If we don't, then in some cases bidi
// returns a different number of RTL codes to what we expect.
// NOTE: IE doesn't support string.normalize(), so we must check for its existence
// before invoking.
this._symbols = string.getSymbols(text.normalize ? text.normalize('NFC') : text);
// handle null string
if (this._symbols.length === 0) {
this._symbols = [
' '
];
}
// extract markup
if (this._enableMarkup) {
const results = Markup.evaluate(this._symbols);
this._symbols = results.symbols;
// NOTE: if results.tags is null, we assign [] to increase
// probability of batching. So, if a user want to use as less
// WebGL buffers memory as possible they can just disable markups.
tags = results.tags || [];
}
// handle LTR vs RTL ordering
if (this._rtlReorder) {
const rtlReorderFunc = this._system.app.systems.element.getRtlReorder();
if (rtlReorderFunc) {
const results = rtlReorderFunc(this._symbols);
this._rtl = results.rtl;
// reorder symbols according to unicode reorder mapping
this._symbols = results.mapping.map(function(v) {
return this._symbols[v];
}, this);
// reorder tags if they exist, according to unicode reorder mapping
if (tags) {
tags = results.mapping.map((v)=>{
return tags[v];
});
}
} else {
console.warn('Element created with rtlReorder option but no rtlReorder function registered');
}
} else {
this._rtl = false;
}
const getColorThicknessHash = (color, thickness)=>{
return `${color.toString(true).toLowerCase()}:${thickness.toFixed(2)}`;
};
const getColorOffsetHash = (color, offset)=>{
return `${color.toString(true).toLowerCase()}:${offset.x.toFixed(2)}:${offset.y.toFixed(2)}`;
};
// resolve color, outline, and shadow tags
if (tags) {
const paletteMap = {};
const outlinePaletteMap = {};
const shadowPaletteMap = {};
// store fallback color in the palette
this._colorPalette = [
Math.round(this._color.r * 255),
Math.round(this._color.g * 255),
Math.round(this._color.b * 255)
];
this._outlinePalette = [
Math.round(this._outlineColor.r * 255),
Math.round(this._outlineColor.g * 255),
Math.round(this._outlineColor.b * 255),
Math.round(this._outlineColor.a * 255),
Math.round(this._outlineThickness * 255)
];
this._shadowPalette = [
Math.round(this._shadowColor.r * 255),
Math.round(this._shadowColor.g * 255),
Math.round(this._shadowColor.b * 255),
Math.round(this._shadowColor.a * 255),
Math.round(this._shadowOffset.x * 127),
Math.round(this._shadowOffset.y * 127)
];
this._symbolColors = [];
this._symbolOutlineParams = [];
this._symbolShadowParams = [];
paletteMap[this._color.toString(false).toLowerCase()] = 0;
outlinePaletteMap[getColorThicknessHash(this._outlineColor, this._outlineThickness)] = 0;
shadowPaletteMap[getColorOffsetHash(this._shadowColor, this._shadowOffset)] = 0;
for(let i = 0, len = this._symbols.length; i < len; ++i){
const tag = tags[i];
let color = 0;
// get markup coloring
if (tag && tag.color && tag.color.value) {
const c = tag.color.value;
// resolve color dictionary names
// TODO: implement the dictionary of colors
// if (colorDict.hasOwnProperty(c)) {
// c = dict[c];
// }
// convert hex color
if (c.length === 7 && c[0] === '#') {
const hex = c.substring(1).toLowerCase();
if (paletteMap.hasOwnProperty(hex)) {
// color is already in the palette
color = paletteMap[hex];
} else {
if (/^[0-9a-f]{6}$/.test(hex)) {
// new color
color = this._colorPalette.length / 3;
paletteMap[hex] = color;
this._colorPalette.push(parseInt(hex.substring(0, 2), 16));
this._colorPalette.push(parseInt(hex.substring(2, 4), 16));
this._colorPalette.push(parseInt(hex.substring(4, 6), 16));
}
}
}
}
this._symbolColors.push(color);
let outline = 0;
// get markup outline
if (tag && tag.outline && (tag.outline.attributes.color || tag.outline.attributes.thickness)) {
let color = tag.outline.attributes.color ? colorTmp.fromString(tag.outline.attributes.color) : this._outlineColor;
let thickness = Number(tag.outline.attributes.thickness);
if (Number.isNaN(color.r) || Number.isNaN(color.g) || Number.isNaN(color.b) || Number.isNaN(color.a)) {
color = this._outlineColor;
}
if (Number.isNaN(thickness)) {
thickness = this._outlineThickness;
}
const outlineHash = getColorThicknessHash(color, thickness);
if (outlinePaletteMap.hasOwnProperty(outlineHash)) {
// outline parameters is already in the palette
outline = outlinePaletteMap[outlineHash];
} else {
// new outline parameter index, 5 ~ (r, g, b, a, thickness)
outline = this._outlinePalette.length / 5;
outlinePaletteMap[outlineHash] = outline;
this._outlinePalette.push(Math.round(color.r * 255), Math.round(color.g * 255), Math.round(color.b * 255), Math.round(color.a * 255), Math.round(thickness * 255));
}
}
this._symbolOutlineParams.push(outline);
let shadow = 0;
// get markup shadow
if (tag && tag.shadow && (tag.shadow.attributes.color || tag.shadow.attributes.offset || tag.shadow.attributes.offsetX || tag.shadow.attributes.offsetY)) {
let color = tag.shadow.attributes.color ? colorTmp.fromString(tag.shadow.attributes.color) : this._shadowColor;
const off = Number(tag.shadow.attributes.offset);
const offX = Number(tag.shadow.attributes.offsetX);
const offY = Number(tag.shadow.attributes.offsetY);
if (Number.isNaN(color.r) || Number.isNaN(color.g) || Number.isNaN(color.b) || Number.isNaN(color.a)) {
color = this._shadowColor;
}
const offset = vec2Tmp.set(!Number.isNaN(offX) ? offX : !Number.isNaN(off) ? off : this._shadowOffset.x, !Number.isNaN(offY) ? offY : !Number.isNaN(off) ? off : this._shadowOffset.y);
const shadowHash = getColorOffsetHash(color, offset);
if (shadowPaletteMap.hasOwnProperty(shadowHash)) {
// shadow parameters is already in the palette
shadow = shadowPaletteMap[shadowHash];
} else {
// new shadow parameter index, 6 ~ (r, g, b, a, offset.x, offset.y)
shadow = this._shadowPalette.length / 6;
shadowPaletteMap[shadowHash] = shadow;
this._shadowPalette.push(Math.round(color.r * 255), Math.round(color.g * 255), Math.round(color.b * 255), Math.round(color.a * 255), Math.round(offset.x * 127), Math.round(offset.y * 127));
}
}
this._symbolShadowParams.push(shadow);
}
} else {
// no tags, therefore no per-symbol colors
this._colorPalette = [];
this._symbolColors = null;
this._symbolOutlineParams = null;
this._symbolShadowParams = null;
}
this._updateMaterialEmissive();
this._updateMaterialOutline();
this._updateMaterialShadow();
const charactersPerTexture = this._calculateCharsPerTexture();
let removedModel = false;
const element = this._element;
const screenSpace = element._isScreenSpace();
const screenCulled = element._isScreenCulled();
const visibleFn = function(camera) {
return element.isVisibleForCamera(camera);
};
for(let i = 0, len = this._meshInfo.length; i < len; i++){
const l = charactersPerTexture[i] || 0;
const meshInfo = this._meshInfo[i];
if (meshInfo.count !== l) {
if (!removedModel) {
element.removeModelFromLayers(this._model);
removedModel = true;
}
meshInfo.count = l;
meshInfo.positions.length = meshInfo.normals.length = l * 3 * 4;
meshInfo.indices.length = l * 3 * 2;
meshInfo.uvs.length = l * 2 * 4;
meshInfo.colors.length = l * 4 * 4;
meshInfo.outlines.length = l * 4 * 3;
meshInfo.shadows.length = l * 4 * 3;
// destroy old mesh
if (meshInfo.meshInstance) {
this._removeMeshInstance(meshInfo.meshInstance);
}
// if there are no letters for this mesh continue
if (l === 0) {
meshInfo.meshInstance = null;
continue;
}
// set up indices and normals whose values don't change when we call _updateMeshes
for(let v = 0; v < l; v++){
// create index and normal arrays since they don't change
// if the length doesn't change
meshInfo.indices[v * 3 * 2 + 0] = v * 4;
meshInfo.indices[v * 3 * 2 + 1] = v * 4 + 1;
meshInfo.indices[v * 3 * 2 + 2] = v * 4 + 3;
meshInfo.indices[v * 3 * 2 + 3] = v * 4 + 2;
meshInfo.indices[v * 3 * 2 + 4] = v * 4 + 3;
meshInfo.indices[v * 3 * 2 + 5] = v * 4 + 1;
meshInfo.normals[v * 4 * 3 + 0] = 0;
meshInfo.normals[v * 4 * 3 + 1] = 0;
meshInfo.normals[v * 4 * 3 + 2] = -1;
meshInfo.normals[v * 4 * 3 + 3] = 0;
meshInfo.normals[v * 4 * 3 + 4] = 0;
meshInfo.normals[v * 4 * 3 + 5] = -1;
meshInfo.normals[v * 4 * 3 + 6] = 0;
meshInfo.normals[v * 4 * 3 + 7] = 0;
meshInfo.normals[v * 4 * 3 + 8] = -1;
meshInfo.normals[v * 4 * 3 + 9] = 0;
meshInfo.normals[v * 4 * 3 + 10] = 0;
meshInfo.normals[v * 4 * 3 + 11] = -1;
}
const mesh = createTextMesh(this._system.app.graphicsDevice, meshInfo);
const mi = new MeshInstance(mesh, this._material, this._node);
mi.name = `Text Element: ${this._entity.name}`;
mi.castShadow = false;
mi.receiveShadow = false;
mi.cull = !screenSpace;
mi.screenSpace = screenSpace;
mi.drawOrder = this._drawOrder;
if (screenCulled) {
mi.cull = true;
mi.isVisibleFunc = visibleFn;
}
this._setTextureParams(mi, this._font.textures[i]);
mi.setParameter('material_emissive', this._colorUniform);
mi.setParameter('material_opacity', this._color.a);
mi.setParameter('font_sdfIntensity', this._font.intensity);
mi.setParameter('font_pxrange', this._getPxRange(this._font));
mi.setParameter('font_textureWidth', this._font.data.info.maps[i].width);
mi.setParameter('outline_color', this._outlineColorUniform);
mi.setParameter('outline_thickness', this._outlineThicknessScale * this._outlineThickness);
mi.setParameter('shadow_color', this._shadowColorUniform);
if (this._symbolShadowParams) {
this._shadowOffsetUniform[0] = 0;
this._shadowOffsetUniform[1] = 0;
} else {
const ratio = -this._font.data.info.maps[i].width / this._font.data.info.maps[i].height;
this._shadowOffsetUniform[0] = this._shadowOffsetScale * this._shadowOffset.x;
this._shadowOffsetUniform[1] = ratio * this._shadowOffsetScale * this._shadowOffset.y;
}
mi.setParameter('shadow_offset', this._shadowOffsetUniform);
meshInfo.meshInstance = mi;
this._model.meshInstances.push(mi);
}
}
// after creating new meshes
// re-apply masking stencil params
if (this._element.maskedBy) {
this._element._setMaskedBy(this._element.maskedBy);
}
if (removedModel && this._element.enabled && this._entity.enabled) {
this._element.addModelToLayers(this._model);
}
this._updateMeshes();
// update render range
this._rangeStart = 0;
this._rangeEnd = this._symbols.length;
this._updateRenderRange();
}
_removeMeshInstance(meshInstance) {
meshInstance.destroy();
const idx = this._model.meshInstances.indexOf(meshInstance);
if (idx !== -1) {
this._model.meshInstances.splice(idx, 1);
}
}
_setMaterial(material) {
this._material = material;
if (this._model) {
for(let i = 0, len = this._model.meshInstances.length; i < len; i++){
const mi = this._model.meshInstances[i];
mi.material = material;
}
}
}
_updateMaterial(screenSpace) {
const element = this._element;
const screenCulled = element._isScreenCulled();
const visibleFn = function(camera) {
return element.isVisibleForCamera(camera);
};
const msdf = this._font && this._font.type === FONT_MSDF;
this._material = this._system.getTextElementMaterial(screenSpace, msdf, this._enableMarkup);
if (this._model) {
for(let i = 0, len = this._model.meshInstances.length; i < len; i++){
const mi = this._model.meshInstances[i];
mi.cull = !screenSpace;
mi.material = this._material;
mi.screenSpace = screenSpace;
if (screenCulled) {
mi.cull = true;
mi.isVisibleFunc = visibleFn;
} else {
mi.isVisibleFunc = null;
}
}
}
}
_updateMaterialEmissive() {
if (this._symbolColors) {
// when per-vertex coloring is present, disable material emissive color
this._colorUniform[0] = 1;
this._colorUniform[1] = 1;
this._colorUniform[2] = 1;
} else {
_tempColor.linear(this._color);
this._colorUniform[0] = _tempColor.r;
this._colorUniform[1] = _tempColor.g;
this._colorUniform[2] = _tempColor.b;
}
}
_updateMaterialOutline() {
if (this._symbolOutlineParams) {
// when per-vertex outline is present, disable material outline uniforms
this._outlineColorUniform[0] = 0;
this._outlineColorUniform[1] = 0;
this._outlineColorUniform[2] = 0;
this._outlineColorUniform[3] = 1;
} else {
_tempColor.linear(this._outlineColor);
this._outlineColorUniform[0] = _tempColor.r;
this._outlineColorUniform[1] = _tempColor.g;
this._outlineColorUniform[2] = _tempColor.b;
this._outlineColorUniform[3] = _tempColor.a;
}
}
_updateMaterialShadow() {
if (this._symbolOutlineParams) {
// when per-vertex shadow is present, disable material shadow uniforms
this._shadowColorUniform[0] = 0;
this._shadowColorUniform[1] = 0;
this._shadowColorUniform[2] = 0;
this._shadowColorUniform[3] = 0;
} else {
_tempColor.linear(this._shadowColor);
this._shadowColorUniform[0] = _tempColor.r;
this._shadowColorUniform[1] = _tempColor.g;
this._shadowColorUniform[2] = _tempColor.b;
this._shadowColorUniform[3] = _tempColor.a;
}
}
// char is space, tab, or dash
_isWordBoundary(char) {
return WORD_BOUNDARY_CHAR.test(char);
}
_isValidNextChar(nextchar) {
return nextchar !== null && !NO_LINE_BREAK_CJK_CHAR.test(nextchar);
}
// char is a CJK character and next character is a CJK boundary
_isNextCJKBoundary(char, nextchar) {
return CJK_CHAR.test(char) && (WORD_BOUNDARY_CHAR.test(nextchar) || ALPHANUMERIC_CHAR.test(nextchar));
}
// next character is a CJK character that can be a whole word
_isNextCJKWholeWord(nextchar) {
return CJK_CHAR.test(nextchar);
}
_updateMeshes() {
const json = this._font.data;
const self = this;
const minFont = Math.min(this._minFontSize, this._maxFontSize);
const maxFont = this._maxFontSize;
const autoFit = this._shouldAutoFit();
if (autoFit) {
this._fontSize = this._maxFontSize;
}
const MAGIC = 32;
const l = this._symbols.length;
let _x = 0; // cursors
let _y = 0;
let _z = 0;
let _xMinusTrailingWhitespace = 0;
let lines = 1;
let wordStartX = 0;
let wordStartIndex = 0;
let lineStartIndex = 0;
let numWordsThisLine = 0;
let numCharsThisLine = 0;
let numBreaksThisLine = 0;
const splitHorizontalAnchors = Math.abs(this._element.anchor.x - this._element.anchor.z) >= 0.0001;
let maxLineWidth = this._element.calculatedWidth;
if (this.autoWidth && !splitHorizontalAnchors || !this._wrapLines) {
maxLineWidth = Number.POSITIVE_INFINITY;
}
let fontMinY = 0;
let fontMaxY = 0;
let char, data, quad, nextchar;
function breakLine(symbols, lineBreakIndex, lineBreakX) {
self._lineWidths.push(Math.abs(lineBreakX));
// in rtl mode lineStartIndex will usually be larger than lineBreakIndex and we will
// need to adjust the start / end indices when calling symbols.slice()
const sliceStart = lineStartIndex > lineBreakIndex ? lineBreakIndex + 1 : lineStartIndex;
const sliceEnd = lineStartIndex > lineBreakIndex ? lineStartIndex + 1 : lineBreakIndex;
const chars = symbols.slice(sliceStart, sliceEnd);
// Remove line breaks from line.
// Line breaks would only be there for the final line
// when we reach the maxLines limit.
// TODO: We could possibly not do this and just let lines have
// new lines in them. Apart from being a bit weird it should not affect
// the rendered text.
if (numBreaksThisLine) {
let i = chars.length;
while(i-- && numBreaksThisLine > 0){
if (LINE_BREAK_CHAR.test(chars[i])) {
chars.splice(i, 1);
numBreaksThisLine--;
}
}
}
self._lineContents.push(chars.join(''));
_x = 0;
_y -= self._scaledLineHeight;
lines++;
numWordsThisLine = 0;
numCharsThisLine = 0;
numBreaksThisLine = 0;
wordStartX = 0;
lineStartIndex = lineBreakIndex;
}
let retryUpdateMeshes = true;
while(retryUpdateMeshes){
retryUpdateMeshes = false;
// if auto-fitting then scale the line height
// according to the current fontSize value relative to the max font size
if (autoFit) {
this._scaledLineHeight = this._lineHeight * this._fontSize / (this._maxFontSize || 0.0001);
} else {
this._scaledLineHeight = this._lineHeight;
}
this.width = 0;
this.height = 0;
this._lineWidths = [];
this._lineContents = [];
_x = 0;
_y = 0;
_z = 0;
_xMinusTrailingWhitespace = 0;
lines = 1;
wordStartX = 0;
wordStartIndex = 0;
lineStartIndex = 0;
numWordsThisLine = 0;
numCharsThisLine = 0;
numBreaksThisLine = 0;
const scale = this._fontSize / MAGIC;
// scale max font extents
fontMinY = this._fontMinY * scale;
fontMaxY = this._fontMaxY * scale;
for(let i = 0; i < this._meshInfo.length; i++){
this._meshInfo[i].quad = 0;
this._meshInfo[i].lines = {};
}
// per-vertex color
let color_r = 255;
let color_g = 255;
let color_b = 255;
// per-vertex outline parameters
let outline_color_rg = 255 + 255 * 256;
let outline_color_ba = 255 + 255 * 256;
let outline_thickness = 0;
// per-vertex shadow parameters
let shadow_color_rg = 255 + 255 * 256;
let shadow_color_ba = 255 + 255 * 256;
let shadow_offset_xy = 127 + 127 * 256;
// In left-to-right mode we loop through the symbols from start to end.
// In right-to-left mode we loop through the symbols from end to the beginning
// in order to wrap lines in the correct order
for(let i = 0; i < l; i++){
char = this._symbols[i];
nextchar = i + 1 >= l ? null : this._symbols[i + 1];
// handle line break
const isLineBreak = LINE_BREAK_CHAR.test(char);
if (isLineBreak) {
numBreaksThisLine++;
// If we are not line wrapping then we should be ignoring maxlines
if (!this._wrapLines || this._maxLines < 0 || lines < this._maxLines) {
breakLine(this._symbols, i, _xMinusTrailingWhitespace);
wordStartIndex = i + 1;
lineStartIndex = i + 1;
}
continue;
}
let x = 0;
let y = 0;
let advance = 0;
let quadsize = 1;
let dataScale, size;
data = json.chars[char];
// handle missing glyph
if (!data) {
if (CONTROL_CHARS.indexOf(char) !== -1) {
// handle unicode control characters
data = CONTROL_GLYPH_DATA;
} else {
// otherwise use space character
if (json.chars[' ']) {
data = json.chars[' '];
} else {
// eslint-disable-next-line no-unreachable-loop
for(const key in json.chars){
data = json.chars[key];
break;
}
}
if (!json.missingChars) {
json.missingChars = new Set();
}
if (!json.missingChars.has(char)) {
console.warn(`Character '${char}' is missing from the font ${json.info.face}`);
json.missingChars.add(char);
}
}
}
if (data) {
let kerning = 0;
if (numCharsThisLine > 0) {
const kernTable = this._font.data.kerning;
if (kernTable) {
const kernLeft = kernTable[string.getCodePoint(this._symbols[i - 1]) || 0];
if (kernLeft) {
kerning = kernLeft[string.getCodePoint(this._symbols[i]) || 0] || 0;
}
}
}
dataScale = data.scale || 1;
size = (data.width + data.height) / 2;
quadsize = scale * size / dataScale;
advance = (data.xadvance + kerning) * scale;
x = (data.xoffset - kerning) * scale;
y = data.yoffset * scale;
} else {
console.error(`Couldn't substitute missing character: '${char}'`);
}
const isWhitespace = WHITESPACE_CHAR.test(char);
const meshInfoId = data && data.map || 0;
const ratio = -this._font.data.info.maps[meshInfoId].width / this._font.data.info.maps[meshInfoId].height;
const meshInfo = this._meshInfo[meshInfoId];
const candidateLineWidth = _x + this._spacing * advance;
// If we've exceeded the maximum line width, move everything from the beginning of
// the current word onwards down onto a new line.
if (candidateLineWidth > maxLineWidth && numCharsThisLine > 0 && !isWhitespace) {
if (this._maxLines < 0 || lines < this._maxLines) {
// Handle the case where a line containing only a single long word needs to be
// broken onto multiple lines.
if (numWordsThisLine === 0) {
wordStartIndex = i;
breakLine(this._symbols, i, _xMinusTrailingWhitespace);
} else {
// Move back to the beginning of the current word.
const backtrack = Math.max(i - wordStartIndex, 0);
if (this._meshInfo.length <= 1) {
meshInfo.lines[lines - 1] -= backtrack;
meshInfo.quad -= backtrack;
} else {
// We should only backtrack the quads that were in the word from this same texture
// We will have to update N number of mesh infos as a result (all textures used in the word in question)
const backtrackStart = wordStartIndex;
const backtrackEnd = i;
for(let j = backtrackStart; j < backtrackEnd; j++){
const backChar = this._symbols[j];
const backCharData = json.chars[backChar];
const backMeshInfo = this._meshInfo[backCharData && backCharData.map || 0];
backMeshInfo.lines[lines - 1] -= 1;
backMeshInfo.quad -= 1;
}
}
i -= backtrack + 1;
breakLine(this._symbols, wordStartIndex, wordStartX);
continue;
}
}
}
quad = meshInfo.quad;
meshInfo.lines[lines - 1] = quad;
let left = _x - x;
let right = left + quadsize;
const bottom = _y - y;
const top = bottom + quadsize;
if (this._rtl) {
// rtl text will be flipped vertically before rendering and here we
// account for the mis-alignment that would be introduced. shift is calculated
// as the difference between the glyph's left and right offset.
const shift = quadsize - x - this._spacing * advance - x;
left -= shift;
right -= shift;
}
meshInfo.positions[quad * 4 * 3 + 0] = left;
meshInfo.positions[quad * 4 * 3 + 1] = bottom;
meshInfo.positions[quad * 4 * 3 + 2] = _z;
meshInfo.positions[quad * 4 * 3 + 3] = right;
meshInfo.positions[quad * 4 * 3 + 4] = bottom;
meshInfo.positions[quad * 4 * 3 + 5] = _z;
meshInfo.positions[quad * 4 * 3 + 6] = right;
meshInfo.positions[quad * 4 * 3 + 7] = top;
meshInfo.positions[quad * 4 * 3 + 8] = _z;
meshInfo.positions[quad * 4 * 3 + 9] = left;
meshInfo.positions[quad * 4 * 3 + 10] = top;
meshInfo.positions[quad * 4 * 3 + 11] = _z;
this.width = Math.max(this.width, candidateLineWidth);
// scale font size if autoFitWidth is true and the width is larger than the calculated width
let fontSize;
if (this._shouldAutoFitWidth() && this.width > this._element.calculatedWidth) {
fontSize = Math.floor(this._element.fontSize * this._element.calculatedWidth / (this.width || 0.0001));
fontSize = math.clamp(fontSize, minFont, maxFont);
if (fontSize !== this._element.fontSize) {
this._fontSize = fontSize;
retryUpdateMeshes = true;
break;
}
}
this.height = Math.max(this.height, fontMaxY - (_y + fontMinY));
// scale font size if autoFitHeight is true and the height is larger than the calculated height
if (this._shouldAutoFitHeight() && this.height > this._element.calculatedHeight) {
// try 1 pixel smaller for fontSize and iterate
fontSize = math.clamp(this._fontSize - 1, minFont, maxFont);
if (fontSize !== this._element.fontSize) {
this._fontSize = fontSize;
retryUpdateMeshes = true;
break;
}
}
// advance cursor (for RTL we move left)
_x += this._spacing * advance;
// For proper alignment handling when a line wraps _on_ a whitespace character,
// we need to keep track of the width of the line without any trailing whitespace
// characters. This applies to both single whitespaces and also multiple sequential
// whitespaces.
if (!isWhitespace) {
_xMinusTrailingWhitespace = _x;
}
if (this._isWordBoundary(char) || this._isValidNextChar(nextchar) && (this._isNextCJKBoundary(char, nextchar) || this._isNextCJKWholeWord(nextchar))) {
numWordsThisLine++;
wordStartX = _xMinusTrailingWhitespace;
wordStartIndex = i + 1;
}
numCharsThisLine++;
const uv = this._getUv(char);
meshInfo.uvs[quad * 4 * 2 + 0] = uv[0];
meshInfo.uvs[quad * 4 * 2 + 1] = 1.0 - uv[1];
meshInfo.uvs[quad * 4 * 2 + 2] = uv[2];
meshInfo.uvs[quad * 4 * 2 + 3] = 1.0 - uv[1];
meshInfo.uvs[quad * 4 * 2 + 4] = uv[2];
meshInfo.uvs[quad * 4 * 2 + 5] = 1.0 - uv[3];
meshInfo.uvs[quad * 4 * 2 + 6] = uv[0];
meshInfo.uvs[quad * 4 * 2 + 7] = 1.0 - uv[3];
// set per-vertex color
if (this._symbolColors) {
const colorIdx = this._symbolColors[i] * 3;
color_r = this._colorPalette[colorIdx];
color_g = this._colorPalette[colorIdx + 1];
color_b = this._colorPalette[colorIdx + 2];
}
meshInfo.colors[quad * 4 * 4 + 0] = color_r;
meshInfo.colors[quad * 4 * 4 + 1] = color_g;
meshInfo.colors[quad * 4 * 4 + 2] = color_b;
meshInfo.colors[quad * 4 * 4 + 3] = 255;
meshInfo.colors[quad * 4 * 4 + 4] = color_r;
meshInfo.colors[quad * 4 * 4 + 5] = color_g;
meshInfo.colors[quad * 4 * 4 + 6] = color_b;
meshInfo.colors[quad * 4 * 4 + 7] = 255;
meshInfo.colors[quad * 4 * 4 + 8] = color_r;
meshInfo.colors[quad * 4 * 4 + 9] = color_g;
meshInfo.colors[quad * 4 * 4 + 10] = color_b;
meshInfo.colors[quad * 4 * 4 + 11] = 255;
meshInfo.colors[quad * 4 * 4 + 12] = color_r;
meshInfo.colors[quad * 4 * 4 + 13] = color_g;
meshInfo.colors[quad * 4 * 4 + 14] = color_b;
meshInfo.colors[quad * 4 * 4 + 15] = 255;
// set per-vertex outline parameters
if (this._symbolOutlineParams) {
const outlineIdx = this._symbolOutlineParams[i] * 5;
outline_color_rg = this._outlinePalette[outlineIdx] + this._outlinePalette[outlineIdx + 1] * 256;
outline_color_ba = this._outlinePalette[outlineIdx + 2] + this._outlinePalette[outlineIdx + 3] * 256;
outline_thickness = this._outlinePalette[outlineIdx + 4];
}
meshInfo.outlines[quad * 4 * 3 + 0] = outline_color_rg;
meshInfo.outlines[quad * 4 * 3 + 1] = outline_color_ba;
meshInfo.outlines[quad * 4 * 3 + 2] = outline_thickness;
meshInfo.outlines[quad * 4 * 3 + 3] = outline_color_rg;
meshInfo.outlines[quad * 4 * 3 + 4] = outline_color_ba;
meshInfo.outlines[quad * 4 * 3 + 5] = outline_thickness;
meshInfo.outlines[quad * 4 * 3 + 6] = outline_color_rg;
meshInfo.outlines[quad * 4 * 3 + 7] = outline_color_ba;
meshInfo.outlines[quad * 4 * 3 + 8] = outline_thickness;
meshInfo.outlines[quad * 4 * 3 + 9] = outline_color_rg;
meshInfo.outlines[quad * 4 * 3 + 10] = outline_color_ba;
meshInfo.outlines[quad * 4 * 3 + 11] = outline_thickness;
// set per-vertex shadow parameters
if (this._symbolShadowParams) {
const shadowIdx = this._symbolShadowParams[i] * 6;
shadow_color_rg = this._shadowPalette[shadowIdx] + this._shadowPalette[shadowIdx + 1] * 256;
shadow_color_ba = this._shadowPalette[shadowIdx + 2] + this._shadowPalette[shadowIdx + 3] * 256;
shadow_offset_xy = this._shadowPalette[shadowIdx + 4] + 127 + Math.round(ratio * this._shadowPalette[shadowIdx + 5] + 127) * 256;
}
meshInfo.shadows[quad * 4 * 3 + 0] = shadow_color_rg;
meshInfo.shadows[quad * 4 * 3 + 1] = shadow_color_ba;
meshInfo.shadows[quad * 4 * 3 + 2] = shadow_offset_xy;
meshInfo.shadows[quad * 4 * 3 + 3] = shadow_color_rg;
meshInfo.shadows[quad * 4 * 3 + 4] = shadow_color_ba;
meshInfo.shadows[quad * 4 * 3 + 5] = shadow_offset_xy;
meshInfo.shadows[quad * 4 * 3 + 6] = shadow_color_rg;
meshInfo.shadows[quad * 4 * 3 + 7] = shadow_color_ba;
meshInfo.shadows[quad * 4 * 3 + 8] = shadow_offset_xy;
meshInfo.shadows[quad * 4 * 3 + 9] = shadow_color_rg;
meshInfo.shadows[quad * 4 * 3 + 10] = shadow_color_ba;
meshInfo.shadows[quad * 4 * 3 + 11] = shadow_offset_xy;
meshInfo.quad++;
}
if (retryUpdateMeshes) {
continue;
}
// As we only break lines when the text becomes too wide for the container,
// there will almost always be some leftover text on the final line which has
// not yet been pushed to _lineContents.
if (lineStartIndex < l) {
breakLine(this._symbols, l, _x);
}
}
// force autoWidth / autoHeight change to update width/height of element
this._noResize = true;
this.autoWidth = this._autoWidth;
this.autoHeight = this._autoHeight;
this._noResize = false;
// offset for pivot and alignment
const hp = this._element.pivot.x;
const vp = this._element.pivot.y;
const ha = this._alignment.x;
const va = this._alignment.y;
for(let i = 0; i < this._meshInfo.length; i++){
if (this._meshInfo[i].count === 0) continue;
let prevQuad = 0;
for(const line in this._meshInfo[i].lines){
const index = this._meshInfo[i].lines[line];
const lw = this._lineWidths[parseInt(line, 10)];
const hoffset = -hp * this._element.calculatedWidth + ha * (this._element.calculatedWidth - lw) * (this._rtl ? -1 : 1);