Fix atlas page overflow: merge same-size pairs, evict as last resort

TextureAtlas._createNewPage only merged exactly-4 same-size page sets; with a
heterogeneous size mix (e.g. 1024+512+512+512) it pushed past maxAtlasPages
instead. GlyphRenderer's GL texture array and fragment-shader sampler array
are sized to exactly maxAtlasPages, so the next render threw TypeError:
Cannot read properties of undefined (reading 'version') and rendering froze
(single-terminal breakage under long heavy-CLI sessions, vscode#322756).

- Generalize merge selection to the largest same-size group of 2-4 pages
  (preferring 4, then larger sizes); AtlasPage already supported non-quad
  merges. Net page count never exceeds the cap.
- Derive merging page indexes via _pages.indexOf instead of glyphs[0]
  .texturePage, which is empty-page safe.
- Last resort when no same-size pair exists below max texture size (only
  possible when almost all pages are at max size): evict all pages; glyphs
  re-rasterize on demand and models rebuild via pagesVersion.
- Defensive clamp in GlyphRenderer.render so any future overflow renders
  untextured glyphs and logs a warning instead of throwing mid-frame.

WebglAtlasOverflow now passes (was 4x TypeError); full webgl suite 75/75.
This commit is contained in:
Anthony Kim
2026-07-04 13:01:13 -07:00
parent 8481bfd8dd
commit f9b043274d
2 changed files with 77 additions and 41 deletions
+10 -2
View File
@@ -101,6 +101,7 @@ export class GlyphRenderer extends Disposable {
private _atlas: ITextureAtlas | undefined;
private _lastAtlasPagesVersion: number = -1;
private _pageOverflowWarned: boolean = false;
private _activeBuffer: number = 0;
private readonly _vertices: IVertices = {
count: 0,
@@ -376,8 +377,15 @@ export class GlyphRenderer extends Disposable {
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
// monotonic, so a page object swap at the same index (which happens after a page merge)
// is detected by the same comparison.
for (let i = 0; i < this._atlas.pages.length; i++) {
// is detected by the same comparison. The page count is clamped to the fixed texture
// array size so an atlas page overflow degrades (untextured glyphs) instead of throwing
// mid-frame; the atlas is responsible for never exceeding maxAtlasPages.
const pageCount = Math.min(this._atlas.pages.length, this._atlasTextures.length);
if (this._atlas.pages.length > this._atlasTextures.length && !this._pageOverflowWarned) {
this._pageOverflowWarned = true;
this._logService.warn(`Atlas page count (${this._atlas.pages.length}) exceeds the renderer's texture capacity (${this._atlasTextures.length}), some glyphs will not render correctly`);
}
for (let i = 0; i < pageCount; i++) {
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
this._bindAtlasPageTexture(gl, this._atlas, i);
}
+67 -39
View File
@@ -161,7 +161,7 @@ export class TextureAtlas implements ITextureAtlas {
// animation frame which would result in blank rendered areas. This is actually not that
// expensive relative to drawing the glyphs, so there is no need to wait for an idle callback.
if (TextureAtlas.maxAtlasPages && this._pages.length >= Math.max(4, TextureAtlas.maxAtlasPages)) {
// Find the set of the largest 4 images, below the maximum size, with the highest
// Find the set of the largest images, below the maximum size, with the highest
// percentages used
const pagesBySize = this._pages.filter(e => {
return e.canvas.width * 2 <= (TextureAtlas.maxTextureSize || Constants.FORCED_MAX_TEXTURE_SIZE);
@@ -171,48 +171,55 @@ export class TextureAtlas implements ITextureAtlas {
}
return b.percentageUsed - a.percentageUsed;
});
let sameSizeI = -1;
let size = 0;
for (let i = 0; i < pagesBySize.length; i++) {
if (pagesBySize[i].canvas.width !== size) {
sameSizeI = i;
size = pagesBySize[i].canvas.width;
} else if (i - sameSizeI === 3) {
break;
// Find the largest group (up to 4) of same-sized pages, preferring larger page sizes. A
// group of 2 or 3 still reduces (or at worst maintains) the page count net of the new page
// pushed below; growing past the cap is never an option as GlyphRenderer's texture and
// shader sampler arrays are sized to exactly maxAtlasPages (exceeding them crashes render).
let bestGroupStart = -1;
let bestGroupSize = 0;
let groupStart = 0;
for (let i = 1; i <= pagesBySize.length; i++) {
if (i === pagesBySize.length || pagesBySize[i].canvas.width !== pagesBySize[groupStart].canvas.width) {
const groupSize = Math.min(4, i - groupStart);
if (groupSize > bestGroupSize) {
bestGroupSize = groupSize;
bestGroupStart = groupStart;
if (groupSize === 4) {
break;
}
}
groupStart = i;
}
}
// Gather details of the merge
const mergingPages = pagesBySize.slice(sameSizeI, sameSizeI + 4);
if (bestGroupSize >= 2) {
// Gather details of the merge
const mergingPages = pagesBySize.slice(bestGroupStart, bestGroupStart + bestGroupSize);
const sortedMergingPagesIndexes = mergingPages.map(e => this._pages.indexOf(e)).sort((a, b) => a - b);
const mergedPageIndex = this.pages.length - mergingPages.length;
// Only proceed with merge if we have exactly 4 same-sized pages. If not, we cannot
// effectively reduce page count and merging would cause issues.
if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) {
const newPage = new AtlasPage(this._document, this._textureSize);
this._pages.push(newPage);
this._activePages.push(newPage);
this._onAddTextureAtlasCanvas.fire(newPage.canvas);
return newPage;
// Merge into the new page
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
mergedPage.version = ++AtlasPage.nextVersion;
// Delete the pages, shifting glyph texture pages as needed
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
this._deletePage(sortedMergingPagesIndexes[i]);
}
// Add the new merged page to the end
this.pages.push(mergedPage);
// Invalidate models so all texture pages are refreshed.
this._pagesVersion++;
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
} else {
// No two same-sized pages below the maximum size exist, so merging cannot reduce the
// page count. Evict all pages rather than exceed the renderers' fixed texture/sampler
// capacity; glyphs re-rasterize on demand and models rebuild via pagesVersion.
this._evictAllPages();
}
const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b);
const mergedPageIndex = this.pages.length - mergingPages.length;
// Merge into the new page
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
mergedPage.version = ++AtlasPage.nextVersion;
// Delete the pages, shifting glyph texture pages as needed
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
this._deletePage(sortedMergingPagesIndexes[i]);
}
// Add the new merged page to the end
this.pages.push(mergedPage);
// Invalidate models so all texture pages are refreshed.
this._pagesVersion++;
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
}
// All new atlas pages are created small as they are highly dynamic
@@ -262,6 +269,27 @@ export class TextureAtlas implements ITextureAtlas {
}
}
/**
* Removes every page and clears the glyph caches. This is the last resort when the page cap is
* reached but no same-sized pages exist to merge: the page count must never exceed
* maxAtlasPages because renderers size their texture and shader sampler arrays to exactly
* that. Glyphs re-rasterize on demand and renderers rebuild their models via
* {@link pagesVersion}.
*/
private _evictAllPages(): void {
for (const page of this._pages) {
this._onRemoveTextureAtlasCanvas.fire(page.canvas);
page.canvas.remove();
}
this._pages.length = 0;
this._activePages.length = 0;
this._overflowSizePage = undefined;
this._cacheMap.clear();
this._cacheMapCombined.clear();
this._didWarmUp = false;
this._pagesVersion++;
}
public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph {
return this._getFromCacheMap(this._cacheMapCombined, chars, bg, fg, ext, restrictToCellHeight, domContainer);
}
@@ -1103,7 +1131,7 @@ class AtlasPage {
sourcePages[2]._usedPixels +
sourcePages[3]._usedPixels;
} else {
// fallback for non quadmerges (should never be used)
// 2- and 3-page merges, used when no 4-page same-size set exists at the page cap
for (let i = 0; i < sourcePages.length; ++i) {
this._glyphs = this._glyphs.concat(sourcePages[i].glyphs);
this._usedPixels += sourcePages[i]._usedPixels;