From 8237004d5c948f2fbf8bbc659dca9b52109b6df3 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 9 Jul 2026 15:51:27 -0700 Subject: [PATCH 1/6] Add WebGL atlas page overflow repros --- .../test/WebglAtlasOverflow.test.ts | 486 ++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 addons/addon-webgl/test/WebglAtlasOverflow.test.ts diff --git a/addons/addon-webgl/test/WebglAtlasOverflow.test.ts b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts new file mode 100644 index 000000000..5ab1961bf --- /dev/null +++ b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts @@ -0,0 +1,486 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IImage32, decodePng } from '@lunapaint/png-codec'; +import test, { expect } from '@playwright/test'; +import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm'; +import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl'; +import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils'; + +type CellSignature = number[]; +type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal; +type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon; + +interface ITestTextureAtlasConstructor { + maxAtlasPages: number | undefined; + maxTextureSize: number | undefined; +} + +interface ITestAtlasPage { + canvas: HTMLCanvasElement; +} + +interface ITestTextureAtlas { + constructor: ITestTextureAtlasConstructor; + pages: ITestAtlasPage[]; + pageLayoutVersion: number; + _overflowSizePage?: ITestAtlasPage; + _textureSize: number; +} + +interface ITestGlyphRenderer { + _atlasTextures: unknown[]; +} + +interface ITestRenderer { + _charAtlas?: ITestTextureAtlas; + _glyphRenderer?: { + value?: ITestGlyphRenderer; + }; + dimensions: { + device: { + cell: { + width: number; + }; + }; + }; +} + +interface ITestRenderService { + _renderer?: { + value?: ITestRenderer; + }; +} + +interface ITestTerminal extends Terminal { + _core?: { + _renderService?: ITestRenderService; + }; +} + +interface ITestWebglAddon extends WebglAddon { + _renderer?: ITestRenderer; +} + +interface IAtlasEventStats { + additions: number; + removals: number; + maxPages: number; +} + +interface ITestWindow extends Window { + Terminal: TestTerminalConstructor; + WebglAddon: TestWebglAddonConstructor; + term: ITestTerminal; + termB?: ITestTerminal; + addon?: ITestWebglAddon; + addonB?: ITestWebglAddon; + atlasEventStats?: IAtlasEventStats; +} + +interface IAtlasLimits { + maxAtlasPages: number; + maxTextureSize: number; +} + +interface IAtlasState { + pages: number; + atlasTextures: number; + pageLayoutVersion: number; + overflowPageCreated: boolean; + additions: number; + removals: number; + maxPages: number; +} + +const NORMAL_HEADER = ' NORMAL_REF_0123456789'; +const POST_EVICTION_TEXT = ' AFTER_EVICTION_0123456789'; +const NORMAL_HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const POST_EVICTION_COLS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; +const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen'; +const TERM_SELECTOR = '#terminal-container .xterm-screen'; + +async function loadWebglStrict(ctx: ITestContext): Promise { + await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + w.addon = new w.WebglAddon({ preserveDrawingBuffer: true }); + w.term.loadAddon(w.addon); + }); + const isWebglRenderer = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + return !!w.addon && w.term._core?._renderService?._renderer?.value === w.addon._renderer; + }); + expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true); +} + +async function createTerminalB(ctx: ITestContext, cols: number): Promise { + await ctx.page.evaluate(columnCount => { + const w = window as unknown as ITestWindow; + w.termB?.dispose(); + document.getElementById('terminal-container-b')?.remove(); + const element = document.createElement('div'); + element.id = 'terminal-container-b'; + document.body.appendChild(element); + w.termB = new w.Terminal({ cols: columnCount, rows: 24, allowProposedApi: true }); + w.termB.open(element); + w.addonB = new w.WebglAddon({ preserveDrawingBuffer: true }); + w.termB.loadAddon(w.addonB); + }, cols); +} + +async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise { + await ctx.page.evaluate(value => new Promise(resolve => { + const w = window as unknown as ITestWindow; + if (!w.termB) { + throw new Error('Terminal B must be created before writing to it'); + } + const disposable = w.termB.onRender(() => { + disposable.dispose(); + resolve(); + }); + w.termB.write(value); + }), data); +} + +async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise { + const renderPromise = new Promise(resolve => { + const disposable = ctx.proxy.onRender(() => { + disposable.dispose(); + resolve(); + }); + }); + await ctx.proxy.write(data); + await renderPromise; +} + +async function refreshAndWaitForRender(ctx: ITestContext, terminal: 'term' | 'termB'): Promise { + await ctx.page.evaluate(name => new Promise(resolve => { + const w = window as unknown as ITestWindow; + const term = w[name]; + if (!term) { + throw new Error(`${name} must exist before refreshing it`); + } + const disposable = term.onRender(() => { + disposable.dispose(); + resolve(); + }); + term.refresh(0, term.rows - 1); + }), terminal); +} + +async function configureAtlasLimits(ctx: ITestContext): Promise { + const limits = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas; + if (!atlas || atlas.constructor.maxAtlasPages === undefined || atlas.constructor.maxTextureSize === undefined) { + return undefined; + } + const result = { + maxAtlasPages: atlas.constructor.maxAtlasPages, + maxTextureSize: atlas.constructor.maxTextureSize + }; + atlas.constructor.maxAtlasPages = 4; + atlas.constructor.maxTextureSize = 512; + return result; + }); + expect(limits, 'TextureAtlas limits must be initialized').toBeDefined(); + return limits!; +} + +async function restoreAtlasLimits(ctx: ITestContext, limits: IAtlasLimits | undefined): Promise { + if (!limits) { + return; + } + await ctx.page.evaluate(original => { + const w = window as unknown as ITestWindow; + const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas; + if (atlas) { + atlas.constructor.maxAtlasPages = original.maxAtlasPages; + atlas.constructor.maxTextureSize = original.maxTextureSize; + } + }, limits); +} + +async function installAtlasEventStats(ctx: ITestContext): Promise { + const installed = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas; + if (!atlas || !w.addon) { + return false; + } + const stats: IAtlasEventStats = { + additions: 0, + removals: 0, + maxPages: atlas.pages.length + }; + w.atlasEventStats = stats; + w.addon.onAddTextureAtlasCanvas(() => { + stats.additions++; + stats.maxPages = Math.max(stats.maxPages, atlas.pages.length); + }); + w.addon.onRemoveTextureAtlasCanvas(() => stats.removals++); + return true; + }); + expect(installed, 'atlas event tracking must be installed').toBe(true); +} + +async function getAtlasState(ctx: ITestContext): Promise { + const state = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const renderer = w.term._core?._renderService?._renderer?.value; + const atlas = renderer?._charAtlas; + const glyphRenderer = renderer?._glyphRenderer?.value; + const stats = w.atlasEventStats; + if (!atlas || !glyphRenderer || !stats) { + return undefined; + } + return { + pages: atlas.pages.length, + atlasTextures: glyphRenderer._atlasTextures.length, + pageLayoutVersion: atlas.pageLayoutVersion, + overflowPageCreated: !!atlas._overflowSizePage, + additions: stats.additions, + removals: stats.removals, + maxPages: stats.maxPages + }; + }); + expect(state, 'atlas state must be available').toBeDefined(); + return state!; +} + +function generateUniqueGlyphFlood(count: number, cols: number, offset: number): string { + const base = 0x4E00; + const range = 0x9FFF - base; + const perRow = Math.max(1, Math.floor(cols / 2)); + let result = ''; + for (let i = 0; i < count; i++) { + result += String.fromCodePoint(base + ((offset + i) % range)); + if ((i + 1) % perRow === 0 && i + 1 < count) { + result += '\r\n'; + } + } + return result; +} + +async function captureRowSignatures(ctx: ITestContext, selector: string, cols: number, row: number, sampleCols: number[], debugName: string): Promise { + const screenshotOptions = process.env.DEBUG ? { path: `out-esbuild-test/playwright/${debugName}.png` } : undefined; + const buffer = await ctx.page.locator(selector).screenshot(screenshotOptions); + const frame = { + cols, + rows: 24, + decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image + }; + return sampleCols.map(col => cellSignature(frame, col, row)); +} + +function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature { + const grid = 4; + const data = frame.decoded.data; + const imageWidth = frame.decoded.width; + const cellWidth = frame.decoded.width / frame.cols; + const cellHeight = frame.decoded.height / frame.rows; + const x0 = (col - 1) * cellWidth; + const y0 = (row - 1) * cellHeight; + const sums = new Array(grid * grid * 4).fill(0); + const counts = new Array(grid * grid).fill(0); + const startX = Math.max(0, Math.floor(x0)); + const startY = Math.max(0, Math.floor(y0)); + const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth)); + const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight)); + + for (let y = startY; y < endY; y++) { + const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid))); + for (let x = startX; x < endX; x++) { + const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid))); + const sub = gy * grid + gx; + const i = (y * imageWidth + x) * 4; + sums[sub * 4] += data[i]; + sums[sub * 4 + 1] += data[i + 1]; + sums[sub * 4 + 2] += data[i + 2]; + sums[sub * 4 + 3] += data[i + 3]; + counts[sub]++; + } + } + + const signature = new Array(grid * grid * 4).fill(0); + for (let sub = 0; sub < counts.length; sub++) { + if (counts[sub] > 0) { + signature[sub * 4] = sums[sub * 4] / counts[sub]; + signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub]; + signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub]; + signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub]; + } + } + return signature; +} + +function signatureDiff(actual: CellSignature, reference: CellSignature): number { + let diff = 0; + for (let i = 0; i < actual.length; i++) { + diff = Math.max(diff, Math.abs(actual[i] - reference[i])); + } + return diff; +} + +function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void { + const diff = signatureDiff(actual, reference); + expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14); +} + +test.describe('atlas page overflow (#6038)', () => { + test.skip(({ browserName }) => browserName !== 'chromium'); + test.describe.configure({ timeout: 90000 }); + + test('evicts normal pages when the page cap cannot be reduced by merging', async ({ browser }) => { + const ctx = await createTestContext(browser); + const errors: string[] = []; + const onError = (error: Error): void => { errors.push(error.message); }; + let limits: IAtlasLimits | undefined; + ctx.page.on('pageerror', onError); + try { + await openTerminal(ctx, { cols: 80, rows: 24 }); + await loadWebglStrict(ctx); + limits = await configureAtlasLimits(ctx); + + await openTerminal(ctx, { cols: 80, rows: 24 }); + await loadWebglStrict(ctx); + await createTerminalB(ctx, 80); + await installAtlasEventStats(ctx); + + const atlasShared = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + return w.term._core?._renderService?._renderer?.value?._charAtlas === w.termB?._core?._renderService?._renderer?.value?._charAtlas; + }); + expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true); + + await writeToBAndWaitForRender(ctx, `\x1b[H${NORMAL_HEADER}\x1b[2;1H${POST_EVICTION_TEXT}`); + const headerReference = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 1, NORMAL_HEADER_COLS, 'atlas-overflow-header-reference'); + const postReference = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 2, POST_EVICTION_COLS, 'atlas-overflow-post-reference'); + await writeToBAndWaitForRender(ctx, '\x1b[2;1H\x1b[2K'); + + const initialState = await getAtlasState(ctx); + const glyphsPerChunk = 23 * 40 - 1; + for (let chunk = 0; chunk < 24; chunk++) { + await ctx.proxy.write('\x1b[H\x1b[2J' + generateUniqueGlyphFlood(glyphsPerChunk, 80, chunk * glyphsPerChunk)); + await ctx.page.waitForTimeout(50); + const state = await getAtlasState(ctx); + if (state.removals > 0 || state.pages > state.atlasTextures || errors.length > 0) { + break; + } + } + + const evictionState = await getAtlasState(ctx); + expect(evictionState.removals, 'normal atlas pages must be evicted at the page cap').toBeGreaterThan(0); + expect(evictionState.pageLayoutVersion, 'eviction must invalidate renderer models').toBeGreaterThan(initialState.pageLayoutVersion); + expect(evictionState.maxPages, 'atlas pages must stay within renderer texture capacity').toBeLessThanOrEqual(evictionState.atlasTextures); + expect(errors, `renderer must not throw during atlas eviction: ${errors[0] ?? ''}`).toEqual([]); + + await writeToBAndWaitForRender(ctx, `\x1b[2;1H${POST_EVICTION_TEXT}`); + const termBHeader = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + return w.termB?.buffer.active.getLine(0)?.translateToString(true); + }); + expect(termBHeader, 'terminal B buffer must still contain the header').toBe(NORMAL_HEADER); + + const headerAfter = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 1, NORMAL_HEADER_COLS, 'atlas-overflow-header-after'); + const postAfter = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 2, POST_EVICTION_COLS, 'atlas-overflow-post-after'); + for (let i = 0; i < NORMAL_HEADER_COLS.length; i++) { + expectSignatureMatches(headerAfter[i], headerReference[i], `existing header col ${NORMAL_HEADER_COLS[i]} changed after eviction`); + } + for (let i = 0; i < POST_EVICTION_COLS.length; i++) { + expectSignatureMatches(postAfter[i], postReference[i], `new text col ${POST_EVICTION_COLS[i]} changed after eviction`); + } + + const stableState = await getAtlasState(ctx); + await refreshAndWaitForRender(ctx, 'termB'); + const refreshedState = await getAtlasState(ctx); + expect(refreshedState.pageLayoutVersion, 'an unchanged full refresh must not evict again').toBe(stableState.pageLayoutVersion); + expect(refreshedState.removals, 'an unchanged full refresh must not remove more pages').toBe(stableState.removals); + } finally { + ctx.page.off('pageerror', onError); + await restoreAtlasLimits(ctx, limits).catch(() => {}); + await ctx.page.close(); + } + }); + + test('evicts before adding an oversized glyph page at the page cap', async ({ browser }) => { + const ctx = await createTestContext(browser); + const errors: string[] = []; + const onError = (error: Error): void => { errors.push(error.message); }; + let limits: IAtlasLimits | undefined; + ctx.page.on('pageerror', onError); + try { + await openTerminal(ctx, { cols: 160, rows: 24 }); + await loadWebglStrict(ctx); + limits = await configureAtlasLimits(ctx); + + await openTerminal(ctx, { cols: 160, rows: 24 }); + await loadWebglStrict(ctx); + await installAtlasEventStats(ctx); + + let state = await getAtlasState(ctx); + let offset = 0; + for (let chunk = 0; chunk < 120 && state.pages < state.atlasTextures; chunk++) { + const glyphCount = state.pages < state.atlasTextures - 1 ? 200 : 25; + await ctx.proxy.write('\x1b[H\x1b[2J' + generateUniqueGlyphFlood(glyphCount, 160, offset)); + await ctx.page.waitForTimeout(50); + offset += glyphCount; + state = await getAtlasState(ctx); + } + expect(state.pages, 'normal atlas pages must reach the texture capacity before the oversized glyph').toBe(state.atlasTextures); + expect(state.overflowPageCreated, 'normal glyph filling must not create the oversized page').toBe(false); + expect(errors, `renderer must not fail while filling normal pages: ${errors[0] ?? ''}`).toEqual([]); + + await writeAndWaitForRender(ctx, '\x1b[H\x1b[2K\x1b[24;1H'); + const sampleCols = [1, 8, 16, 24, 32]; + const blankSignatures = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-blank'); + const joinedLength = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const renderer = w.term._core?._renderService?._renderer?.value; + const atlas = renderer?._charAtlas; + if (!renderer || !atlas) { + throw new Error('renderer and atlas must exist'); + } + const cellWidth = renderer.dimensions.device.cell.width; + const length = Math.min(w.term.cols - 1, Math.ceil(atlas._textureSize / cellWidth) + 32); + if (length * cellWidth <= atlas._textureSize) { + throw new Error('joined glyph must be wider than a normal atlas page'); + } + const marker = 'W'.repeat(length); + w.term.registerCharacterJoiner(text => text.startsWith(marker) ? [[0, length]] : []); + return length; + }); + + const beforeOversized = await getAtlasState(ctx); + await ctx.proxy.write(`\x1b[H${'W'.repeat(joinedLength)}`); + await ctx.page.waitForTimeout(50); + const oversizedState = await getAtlasState(ctx); + expect(oversizedState.overflowPageCreated, 'the joined glyph must use the oversized atlas page').toBe(true); + expect(oversizedState.removals, 'normal pages must be evicted before adding the oversized page').toBeGreaterThan(beforeOversized.removals); + expect(oversizedState.maxPages, 'oversized page creation must stay within renderer texture capacity').toBeLessThanOrEqual(oversizedState.atlasTextures); + expect(oversizedState.pageLayoutVersion, 'oversized eviction must invalidate renderer models').toBeGreaterThan(beforeOversized.pageLayoutVersion); + expect(errors, `renderer must not throw while adding the oversized page: ${errors[0] ?? ''}`).toEqual([]); + + const wideSignatures = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-after'); + for (let i = 0; i < sampleCols.length; i++) { + expect(signatureDiff(wideSignatures[i], blankSignatures[i]), `wide glyph col ${sampleCols[i]} must render visible pixels`).toBeGreaterThan(14); + } + + const stableState = await getAtlasState(ctx); + await refreshAndWaitForRender(ctx, 'term'); + const refreshedState = await getAtlasState(ctx); + expect(refreshedState.pageLayoutVersion, 'oversized glyph refresh must settle without another eviction').toBe(stableState.pageLayoutVersion); + expect(refreshedState.removals, 'oversized glyph refresh must not remove more pages').toBe(stableState.removals); + const wideAfterRefresh = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-refreshed'); + for (let i = 0; i < sampleCols.length; i++) { + expectSignatureMatches(wideAfterRefresh[i], wideSignatures[i], `wide glyph col ${sampleCols[i]} changed after refresh`); + } + } finally { + ctx.page.off('pageerror', onError); + await restoreAtlasLimits(ctx, limits).catch(() => {}); + await ctx.page.close(); + } + }); +}); From 2b982bc39c74cfec389d51084587a3c39235a0fb Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 9 Jul 2026 15:54:33 -0700 Subject: [PATCH 2/6] Prevent WebGL atlas page overflow --- addons/addon-webgl/src/GlyphRenderer.ts | 8 +++- addons/addon-webgl/src/TextureAtlas.ts | 63 +++++++++++++++---------- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index 720427723..c8ab2f344 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -101,6 +101,7 @@ export class GlyphRenderer extends Disposable { private _atlas: ITextureAtlas | undefined; private _lastSeenPageLayoutVersion: number = -1; + private _pageOverflowWarned: boolean = false; private _activeBuffer: number = 0; private readonly _vertices: IVertices = { count: 0, @@ -375,7 +376,12 @@ 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++) { + 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); } diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 5368c9b46..fdcb612de 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -152,7 +152,7 @@ export class TextureAtlas implements ITextureAtlas { // microtask to ensure it does not interrupt textures that will be rendered in the current // 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)) { + if (TextureAtlas.maxAtlasPages && this._pages.length >= TextureAtlas.maxAtlasPages) { // Find the set of the largest 4 images, below the maximum size, with the highest // percentages used const pagesBySize = this._pages.filter(e => { @@ -180,31 +180,27 @@ export class TextureAtlas implements ITextureAtlas { // 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; + this._evictAllPages(); + } else { + 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); + + // Request the model to be cleared to refresh all texture pages. + this._pageLayoutVersion++; + this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } - - 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); - - // Request the model to be cleared to refresh all texture pages. - this._pageLayoutVersion++; - this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } // All new atlas pages are created small as they are highly dynamic @@ -254,6 +250,20 @@ export class TextureAtlas implements ITextureAtlas { } } + 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._pageLayoutVersion++; + } + 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); } @@ -811,6 +821,9 @@ export class TextureAtlas implements ITextureAtlas { // Create a new page for oversized glyphs as they come up if (rasterizedGlyph.size.x > this._textureSize) { if (!this._overflowSizePage) { + if (TextureAtlas.maxAtlasPages && this._pages.length >= TextureAtlas.maxAtlasPages) { + this._evictAllPages(); + } this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize); this.pages.push(this._overflowSizePage); From 6791a0abea88187e13698abcb727205c53794cc9 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Mon, 13 Jul 2026 10:58:05 -0700 Subject: [PATCH 3/6] Log to measure eviction time --- addons/addon-webgl/src/TextureAtlas.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index fdcb612de..f15f3cec1 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -251,6 +251,8 @@ export class TextureAtlas implements ITextureAtlas { } private _evictAllPages(): void { + const startTime = performance.now(); + const pageCount = this._pages.length; for (const page of this._pages) { this._onRemoveTextureAtlasCanvas.fire(page.canvas); page.canvas.remove(); @@ -262,6 +264,7 @@ export class TextureAtlas implements ITextureAtlas { this._cacheMapCombined.clear(); this._didWarmUp = false; this._pageLayoutVersion++; + this._logService.debug(`Evicted ${pageCount} WebGL atlas pages in ${(performance.now() - startTime).toFixed(2)}ms`); } public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph { From 06ce754fcc9d9cceaf8193ffb83979b874b4ebd3 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Mon, 13 Jul 2026 11:10:08 -0700 Subject: [PATCH 4/6] Don't mess with formatting --- addons/addon-webgl/src/TextureAtlas.ts | 45 ++++++++++++++------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index f15f3cec1..436aa553b 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -152,7 +152,7 @@ export class TextureAtlas implements ITextureAtlas { // microtask to ensure it does not interrupt textures that will be rendered in the current // 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 >= TextureAtlas.maxAtlasPages) { + 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 // percentages used const pagesBySize = this._pages.filter(e => { @@ -181,26 +181,31 @@ export class TextureAtlas implements ITextureAtlas { // effectively reduce page count and merging would cause issues. if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) { this._evictAllPages(); - } else { - 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); - - // Request the model to be cleared to refresh all texture pages. - this._pageLayoutVersion++; - this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); + const newPage = new AtlasPage(this._document, this._textureSize); + this._pages.push(newPage); + this._activePages.push(newPage); + this._onAddTextureAtlasCanvas.fire(newPage.canvas); + return newPage; } + + 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); + + // Request the model to be cleared to refresh all texture pages. + this._pageLayoutVersion++; + this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } // All new atlas pages are created small as they are highly dynamic From 2ae6e3a6031a3358fe355b316dcefa5a23179d2e Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Mon, 13 Jul 2026 11:23:39 -0700 Subject: [PATCH 5/6] Useful comments. --- addons/addon-webgl/src/GlyphRenderer.ts | 1 + addons/addon-webgl/src/TextureAtlas.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index c8ab2f344..742f0879f 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -376,6 +376,7 @@ 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. + // Clamp defensively in case the atlas unexpectedly exceeds texture capacity. const pageCount = Math.min(this._atlas.pages.length, this._atlasTextures.length); if (this._atlas.pages.length > this._atlasTextures.length && !this._pageOverflowWarned) { this._pageOverflowWarned = true; diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 436aa553b..40d82df49 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -180,6 +180,7 @@ export class TextureAtlas implements ITextureAtlas { // 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)) { + // Evict instead of adding a page beyond the renderer's texture capacity. this._evictAllPages(); const newPage = new AtlasPage(this._document, this._textureSize); this._pages.push(newPage); @@ -829,6 +830,7 @@ export class TextureAtlas implements ITextureAtlas { // Create a new page for oversized glyphs as they come up if (rasterizedGlyph.size.x > this._textureSize) { if (!this._overflowSizePage) { + // Make room for the oversized page without exceeding texture capacity. if (TextureAtlas.maxAtlasPages && this._pages.length >= TextureAtlas.maxAtlasPages) { this._evictAllPages(); } From 2ea67f9ea8e050f59f25773fdbe14633bdd73801 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Mon, 13 Jul 2026 11:49:25 -0700 Subject: [PATCH 6/6] Clean up WebGL atlas overflow coverage --- addons/addon-webgl/src/TextureAtlas.ts | 4 ++-- addons/addon-webgl/test/WebglAtlasOverflow.test.ts | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 40d82df49..dba1ad474 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -204,7 +204,7 @@ export class TextureAtlas implements ITextureAtlas { // Add the new merged page to the end this.pages.push(mergedPage); - // Request the model to be cleared to refresh all texture pages. + // Invalidate renderer models so all texture pages are refreshed. this._pageLayoutVersion++; this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } @@ -837,7 +837,7 @@ export class TextureAtlas implements ITextureAtlas { this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize); this.pages.push(this._overflowSizePage); - // Request the model to be cleared to refresh all texture pages. + // Invalidate renderer models so all texture pages are refreshed. this._pageLayoutVersion++; this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas); } diff --git a/addons/addon-webgl/test/WebglAtlasOverflow.test.ts b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts index 5ab1961bf..1252c81cf 100644 --- a/addons/addon-webgl/test/WebglAtlasOverflow.test.ts +++ b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts @@ -65,7 +65,6 @@ interface ITestWebglAddon extends WebglAddon { } interface IAtlasEventStats { - additions: number; removals: number; maxPages: number; } @@ -90,7 +89,6 @@ interface IAtlasState { atlasTextures: number; pageLayoutVersion: number; overflowPageCreated: boolean; - additions: number; removals: number; maxPages: number; } @@ -211,13 +209,11 @@ async function installAtlasEventStats(ctx: ITestContext): Promise { return false; } const stats: IAtlasEventStats = { - additions: 0, removals: 0, maxPages: atlas.pages.length }; w.atlasEventStats = stats; w.addon.onAddTextureAtlasCanvas(() => { - stats.additions++; stats.maxPages = Math.max(stats.maxPages, atlas.pages.length); }); w.addon.onRemoveTextureAtlasCanvas(() => stats.removals++); @@ -241,7 +237,6 @@ async function getAtlasState(ctx: ITestContext): Promise { atlasTextures: glyphRenderer._atlasTextures.length, pageLayoutVersion: atlas.pageLayoutVersion, overflowPageCreated: !!atlas._overflowSizePage, - additions: stats.additions, removals: stats.removals, maxPages: stats.maxPages };