• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

excaliburjs / Excalibur / 22144324370

18 Feb 2026 02:43PM UTC coverage: 88.695% (-0.04%) from 88.737%
22144324370

push

github

web-flow
perf: Impove text/font rasterization and rendering perf by reducing sizes lossless (#3687)

Issue was raised by @JSLegendDev on the discord https://discord.com/channels/1195771303215513671/1471972758136164606/1471985432568004649 

Safari doesn't do as well when re-rastering and uploading that to the GPU causing visible hitches in games which is unacceptable. This can be worked around in the current version by reducing `quality` or making the text as static as possible for unchanging glyphs.

This PR improves the texture usage and reduces the size of text needed to raster for equivalent or better quality text 

## Changes:

- Internally we use the smallest possible bitmap to represent the text
- Caching lookups are improved by using actual hashing with numbers

5413 of 7382 branches covered (73.33%)

72 of 88 new or added lines in 5 files covered. (81.82%)

1 existing line in 1 file now uncovered.

14914 of 16815 relevant lines covered (88.69%)

24426.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.81
/src/engine/graphics/font-text-instance.ts
1
import { BoundingBox } from '../collision/bounding-box';
2
import type { Color } from '../color';
3
import { ExcaliburGraphicsContextWebGL } from './context/excalibur-graphics-context-webgl';
4
import type { ExcaliburGraphicsContext } from './context/excalibur-graphics-context';
5
import type { Font } from './font';
6
import { vec, Vector } from '../math';
7
import { combineHashes, hashString } from '../util/string';
8

9
export class FontTextInstance {
10
  public canvas: HTMLCanvasElement;
11
  public ctx: CanvasRenderingContext2D;
12
  private _textFragments: { x: number; y: number; canvas: HTMLCanvasElement }[] = [];
1,053✔
13
  public dimensions: BoundingBox;
14
  public disposed: boolean = false;
1,053✔
15
  private _lastHashCode: number;
16
  /**
17
   * Maximum upward reach from baseline, in text space
18
   */
19
  private _maxAscent: number = 0;
1,053✔
20

21
  /**
22
   * Total height including all lines, in text space
23
   */
24
  private _totalHeight: number = 0;
1,053✔
25

26
  constructor(
27
    public readonly font: Font,
1,053✔
28
    public readonly text: string,
1,053✔
29
    public readonly color: Color,
1,053✔
30
    public readonly maxWidth?: number
1,053✔
31
  ) {
32
    this.canvas = document.createElement('canvas');
1,053✔
33
    const ctx = this.canvas.getContext('2d');
1,053✔
34

35
    if (!ctx) {
1,053!
36
      throw new Error('Unable to create FontTextInstance, internal canvas failed to create');
×
37
    }
38

39
    this.ctx = ctx;
1,053✔
40

41
    this.canvas.dataset.originalSrc = `text(${text}) font(${font.fontString}`;
1,053✔
42

43
    this.dimensions = this.measureText(text);
1,053✔
44

45
    this._setDimension(this.dimensions, this.ctx);
1,053✔
46
    this._lastHashCode = this.getHashCode();
1,053✔
47
  }
48

49
  measureText(text: string, maxWidth?: number): BoundingBox {
50
    if (this.disposed) {
2,215!
51
      throw Error('Accessing disposed text instance! ' + this.text);
×
52
    }
53
    let lines = null;
2,215✔
54
    if (maxWidth != null) {
2,215✔
55
      lines = this._getLinesFromText(text, maxWidth);
1✔
56
    } else {
57
      lines = text.split('\n');
2,214✔
58
    }
59

60
    this._applyFont(this.ctx); // font must be applied to the context to measure it
2,215✔
61
    let maxWidthLine = 0;
2,215✔
62
    let maxAscent = 0;
2,215✔
63
    let maxDescent = 0;
2,215✔
64
    const adjustedPadding = this.font.padding / this.font.quality;
2,215✔
65
    for (let i = 0; i < lines.length; i++) {
2,215✔
66
      const metrics = this.ctx.measureText(lines[i]);
2,366✔
67
      const width = metrics.width + adjustedPadding * 2;
2,366✔
68
      maxWidthLine = Math.max(maxWidthLine, width);
2,366✔
69
      maxAscent = Math.max(maxAscent, metrics.actualBoundingBoxAscent);
2,366✔
70
      maxDescent = Math.max(maxDescent, metrics.actualBoundingBoxDescent);
2,366✔
71
    }
72

73
    const textHeight = Math.abs(maxAscent) + Math.abs(maxDescent);
2,215✔
74
    const totalHeight = textHeight * lines.length + adjustedPadding * 2;
2,215✔
75

76
    this._maxAscent = maxAscent;
2,215✔
77
    this._totalHeight = totalHeight;
2,215✔
78

79
    // dimensions are in text space
80
    return BoundingBox.fromDimension(
2,215✔
81
      maxWidthLine,
82
      this._totalHeight,
83
      vec(this._xAnchorFromAlignment(), this._yAnchorFromBaseline()),
84
      Vector.Zero
85
    );
86
  }
87

88
  private _setDimension(textBounds: BoundingBox, bitmap: CanvasRenderingContext2D) {
89
    let lineHeightRatio = 1;
1,071✔
90
    if (this.font.lineHeight) {
1,071✔
91
      lineHeightRatio = this.font.lineHeight / this.font.size;
6✔
92
    }
93
    bitmap.canvas.width = (textBounds.width + this.font.padding * 2) * this.font.quality;
1,071✔
94
    bitmap.canvas.height = (textBounds.height + this.font.padding * 2) * this.font.quality * lineHeightRatio;
1,071✔
95
  }
96

97
  public static getHashCode(font: Font, text: string, color?: Color): number {
98
    return combineHashes(hashString(text), font.hashCode, color?.hashCode ?? 0);
2,183✔
99
  }
100

101
  public getHashCode(includeColor: boolean = true): number {
1,078✔
102
    return FontTextInstance.getHashCode(this.font, this.text, includeColor ? this.color : undefined);
1,078!
103
  }
104

105
  /**
106
   * used in measure text, should not reference final measurements like width/height
107
   */
108
  private _xAnchorFromAlignment() {
109
    // Calculate x position based on alignment
110
    let x;
111
    const ltr = this.font.direction === 'ltr';
2,240✔
112
    switch (this.font.textAlign) {
2,240!
113
      case 'left':
114
      case 'start':
115
        x = ltr ? 0 : 1;
2,240!
116
        break;
2,240✔
117
      case 'center':
NEW
118
        x = 0.5;
×
NEW
119
        break;
×
120
      case 'right':
121
      case 'end':
NEW
122
        x = ltr ? 1 : 0;
×
NEW
123
        break;
×
124
      default:
NEW
125
        x = 0;
×
126
    }
127
    return x;
2,240✔
128
  }
129

130
  /**
131
   * This is for internal positioning on the internal canvas
132
   */
133
  private _xFromAlignment() {
134
    // Calculate x position based on alignment
135
    let x;
136
    const ltr = this.font.direction === 'ltr';
18✔
137
    switch (this.font.textAlign) {
18!
138
      case 'left':
139
      case 'start':
140
        x = ltr ? 0 : this.canvas.width;
18!
141
        break;
18✔
142
      case 'center':
NEW
143
        x = this.canvas.width / 2;
×
NEW
144
        break;
×
145
      case 'right':
146
      case 'end':
NEW
147
        x = ltr ? this.canvas.width : 0;
×
NEW
148
        break;
×
149
      default:
NEW
150
        x = 0;
×
151
    }
152
    return x / this.font.quality;
18✔
153
  }
154

155
  /**
156
   * used in measure text, should not reference final measurements like width/height
157
   * https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textBaseline
158
   */
159
  private _yAnchorFromBaseline() {
160
    let startY;
161
    switch (this.font.baseAlign) {
2,240!
162
      case 'top':
163
      case 'hanging':
164
        startY = 0;
2,059✔
165
        break;
2,059✔
166
      case 'middle':
NEW
167
        startY = 0.5;
×
NEW
168
        break;
×
169
      case 'bottom':
170
      case 'ideographic':
NEW
171
        startY = 1;
×
NEW
172
        break;
×
173
      case 'alphabetic':
174
      default:
175
        // For alphabetic, position first line properly
176
        startY = this._maxAscent / this._totalHeight;
181✔
177
        break;
181✔
178
    }
179
    return startY;
2,240✔
180
  }
181

182
  protected _applyRasterProperties(ctx: CanvasRenderingContext2D) {
183
    ctx.imageSmoothingEnabled = this.font.smoothing;
18✔
184
    ctx.lineWidth = this.font.lineWidth;
18✔
185
    ctx.setLineDash(this.font.lineDash ?? ctx.getLineDash());
18!
186
    ctx.strokeStyle = this.font.strokeColor?.toString() ?? '';
18!
187
    ctx.fillStyle = this.color.toString();
18✔
188
  }
189

190
  private _applyFont(ctx: CanvasRenderingContext2D) {
191
    ctx.resetTransform();
2,233✔
192
    ctx.scale(this.font.quality, this.font.quality);
2,233✔
193
    ctx.translate(this.font.padding, this.font.padding);
2,233✔
194
    ctx.textAlign = this.font.textAlign;
2,233✔
195
    ctx.textBaseline = this.font.baseAlign;
2,233✔
196
    ctx.font = this.font.fontString;
2,233✔
197
    ctx.direction = this.font.direction;
2,233✔
198

199
    if (this.font.shadow) {
2,233✔
200
      if (this.font.shadow.color) {
41!
201
        ctx.shadowColor = this.font.shadow.color.toString();
41✔
202
      }
203
      if (this.font.shadow.blur) {
41!
204
        ctx.shadowBlur = this.font.shadow.blur;
41✔
205
      }
206
      if (this.font.shadow.offset) {
41!
207
        ctx.shadowOffsetX = this.font.shadow.offset.x;
41✔
208
        ctx.shadowOffsetY = this.font.shadow.offset.y;
41✔
209
      }
210
    }
211
  }
212

213
  private _drawText(ctx: CanvasRenderingContext2D, lines: string[], lineHeight: number): void {
214
    this._applyRasterProperties(ctx);
18✔
215
    this._applyFont(ctx);
18✔
216
    const x = this._xFromAlignment();
18✔
217
    const y = this._maxAscent;
18✔
218

219
    for (let i = 0; i < lines.length; i++) {
18✔
220
      const line = lines[i];
71✔
221
      if (this.color) {
71!
222
        ctx.fillText(line, x, y + i * lineHeight);
71✔
223
      }
224

225
      if (this.font.strokeColor) {
71!
NEW
226
        ctx.strokeText(line, x, y + i * lineHeight);
×
227
      }
228
    }
229
    // //DEBUG
230
    // document.body.appendChild(this.canvas);
231
  }
232

233
  private _splitTextBitmap(bitmap: CanvasRenderingContext2D) {
234
    const textImages: { x: number; y: number; canvas: HTMLCanvasElement }[] = [];
18✔
235
    let currentX = 0;
18✔
236
    let currentY = 0;
18✔
237
    // 4k is the max for mobile devices
238
    const width = Math.min(4096, bitmap.canvas.width);
18✔
239
    const height = Math.min(4096, bitmap.canvas.height);
18✔
240

241
    // Splits the original bitmap into 4k max chunks
242
    while (currentX < bitmap.canvas.width) {
18✔
243
      while (currentY < bitmap.canvas.height) {
19✔
244
        // create new bitmap
245
        const canvas = document.createElement('canvas');
19✔
246
        canvas.dataset.originalSrc = `fragment(${currentX},${currentY}): text(${this.text}) font(${this.font.fontString}`;
19✔
247
        canvas.width = width;
19✔
248
        canvas.height = height;
19✔
249
        const ctx = canvas.getContext('2d');
19✔
250
        if (!ctx) {
19!
251
          throw new Error('Unable to split internal FontTextInstance bitmap, failed to create internal canvas');
×
252
        }
253

254
        // draw current slice to new bitmap in < 4k chunks
255
        ctx.drawImage(bitmap.canvas, currentX, currentY, width, height, 0, 0, width, height);
19✔
256

257
        textImages.push({ x: currentX, y: currentY, canvas });
19✔
258
        currentY += height;
19✔
259
      }
260
      currentX += width;
19✔
261
      currentY = 0;
19✔
262
    }
263
    return textImages;
18✔
264
  }
265

266
  public flagDirty() {
267
    this._dirty = true;
×
268
  }
269
  private _dirty = true;
1,053✔
270
  private _ex?: ExcaliburGraphicsContext;
271
  public render(ex: ExcaliburGraphicsContext, x: number, y: number, maxWidth?: number) {
272
    if (this.disposed) {
25!
273
      throw Error('Accessing disposed text instance! ' + this.text);
×
274
    }
275
    this._ex = ex;
25✔
276
    const hashCode = this.getHashCode();
25✔
277
    if (this._lastHashCode !== hashCode) {
25!
278
      this._dirty = true;
×
279
    }
280

281
    // Calculate image chunks
282
    if (this._dirty) {
25✔
283
      this.dimensions = this.measureText(this.text, maxWidth);
18✔
284
      this._setDimension(this.dimensions, this.ctx);
18✔
285
      const lines = this._getLinesFromText(this.text, maxWidth);
18✔
286

287
      const lineHeight = !this.font.lineHeight ? (this.dimensions.height - this._maxAscent) / lines.length : this.font.lineHeight;
18✔
288

289
      // draws the text to the main bitmap
290
      this._drawText(this.ctx, lines, lineHeight);
18✔
291

292
      // clear any out old fragments
293
      if (ex instanceof ExcaliburGraphicsContextWebGL) {
18✔
294
        for (const frag of this._textFragments) {
1✔
295
          ex.textureLoader.delete(frag.canvas);
×
296
        }
297
      }
298

299
      // splits to < 4k fragments for large text
300
      this._textFragments = this._splitTextBitmap(this.ctx);
18✔
301

302
      if (ex instanceof ExcaliburGraphicsContextWebGL) {
18✔
303
        for (const frag of this._textFragments) {
1✔
304
          ex.textureLoader.load(frag.canvas, { filtering: this.font.filtering }, true);
2✔
305
        }
306
      }
307
      this._lastHashCode = hashCode;
18✔
308
      this._dirty = false;
18✔
309
    }
310

311
    const adjustedPadding = this.font.padding / this.font.quality; // text space
25✔
312
    const destWidth = this.canvas.width / this.font.quality - adjustedPadding; // text space
25✔
313
    const destHeight = this._totalHeight; // text space
25✔
314

315
    const alignmentFromAnchor = this._xAnchorFromAlignment() * destWidth + adjustedPadding;
25✔
316
    const baselineFromAnchor = this._yAnchorFromBaseline() * destHeight + adjustedPadding;
25✔
317

318
    // draws the bitmap fragments to excalibur graphics context
319
    for (const frag of this._textFragments) {
25✔
320
      ex.drawImage(
26✔
321
        // source coords are in canvas space
322
        frag.canvas,
323
        0,
324
        0,
325
        frag.canvas.width,
326
        frag.canvas.height,
327

328
        // dest coords are in text space (quality removed)
329
        frag.x / this.font.quality + x - alignmentFromAnchor,
330
        frag.y / this.font.quality + y - baselineFromAnchor,
331
        frag.canvas.width / this.font.quality,
332
        frag.canvas.height / this.font.quality
333
      );
334
    }
335
  }
336

337
  dispose() {
338
    this.disposed = true;
6✔
339
    this.dimensions = undefined as any;
6✔
340
    this.canvas = undefined as any;
6✔
341
    this.ctx = undefined as any;
6✔
342
    if (this._ex instanceof ExcaliburGraphicsContextWebGL) {
6!
343
      for (const frag of this._textFragments) {
×
344
        this._ex.textureLoader.delete(frag.canvas);
×
345
      }
346
    }
347
    this._textFragments.length = 0;
6✔
348
  }
349

350
  /**
351
   * Return array of lines split based on the \n character, and the maxWidth? constraint
352
   * @param text
353
   * @param maxWidth
354
   */
355
  private _cachedText?: string;
356
  private _cachedLines?: string[];
357
  private _cachedRenderWidth?: number;
358
  private _getLinesFromText(text: string, maxWidth?: number): string[] {
359
    if (this._cachedText === text && this._cachedRenderWidth === maxWidth && this._cachedLines?.length) {
19!
360
      return this._cachedLines;
1✔
361
    }
362

363
    const lines = text.split('\n');
18✔
364

365
    if (maxWidth == null) {
18✔
366
      return lines;
17✔
367
    }
368

369
    // If the current line goes past the maxWidth, append a new line without modifying the underlying text.
370
    for (let i = 0; i < lines.length; i++) {
1✔
371
      let line = lines[i];
5✔
372
      let newLine = '';
5✔
373
      if (this.measureText(line).width > maxWidth) {
5✔
374
        // FIXME is this width different now since we are using the glyph advance which is more accurate?
375
        while (this.measureText(line).width > maxWidth) {
4✔
376
          newLine = line[line.length - 1] + newLine;
111✔
377
          line = line.slice(0, -1); // Remove last character from line
111✔
378
        }
379

380
        // Update the array with our new values
381
        lines[i] = line;
4✔
382
        lines[i + 1] = newLine;
4✔
383
      }
384
    }
385

386
    this._cachedText = text;
1✔
387
    this._cachedLines = lines;
1✔
388
    this._cachedRenderWidth = maxWidth;
1✔
389

390
    return lines;
1✔
391
  }
392
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc