Path: blob/main/src/vs/editor/browser/gpu/atlas/textureAtlasSlabAllocator.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { getActiveWindow } from '../../../../base/browser/dom.js';6import { BugIndicatingError } from '../../../../base/common/errors.js';7import { NKeyMap } from '../../../../base/common/map.js';8import { ensureNonNullable } from '../gpuUtils.js';9import type { IRasterizedGlyph } from '../raster/raster.js';10import { UsagePreviewColors, type ITextureAtlasAllocator, type ITextureAtlasPageGlyph } from './atlas.js';1112export interface TextureAtlasSlabAllocatorOptions {13slabW?: number;14slabH?: number;15}1617/**18* The slab allocator is a more complex allocator that places glyphs in square slabs of a fixed19* size. Slabs are defined by a small range of glyphs sizes they can house, this places like-sized20* glyphs in the same slab which reduces wasted space.21*22* Slabs also may contain "unused" regions on the left and bottom depending on the size of the23* glyphs they include. This space is used to place very thin or short glyphs, which would otherwise24* waste a lot of space in their own slab.25*/26export class TextureAtlasSlabAllocator implements ITextureAtlasAllocator {2728private readonly _ctx: OffscreenCanvasRenderingContext2D;2930private readonly _slabs: ITextureAtlasSlab[] = [];31private readonly _activeSlabsByDims: NKeyMap<ITextureAtlasSlab, [number, number]> = new NKeyMap();3233private readonly _unusedRects: ITextureAtlasSlabUnusedRect[] = [];3435private readonly _openRegionsByHeight: Map<number, ITextureAtlasSlabUnusedRect[]> = new Map();36private readonly _openRegionsByWidth: Map<number, ITextureAtlasSlabUnusedRect[]> = new Map();3738/** A set of all glyphs allocated, this is only tracked to enable debug related functionality */39private readonly _allocatedGlyphs: Set<Readonly<ITextureAtlasPageGlyph>> = new Set();4041private _slabW: number;42private _slabH: number;43private _slabsPerRow: number;44private _slabsPerColumn: number;45private _nextIndex = 0;4647constructor(48private readonly _canvas: OffscreenCanvas,49private readonly _textureIndex: number,50options?: TextureAtlasSlabAllocatorOptions51) {52this._ctx = ensureNonNullable(this._canvas.getContext('2d', {53willReadFrequently: true54}));5556this._slabW = Math.min(57options?.slabW ?? (64 << Math.max(Math.floor(getActiveWindow().devicePixelRatio) - 1, 0)),58this._canvas.width59);60this._slabH = Math.min(61options?.slabH ?? this._slabW,62this._canvas.height63);64this._slabsPerRow = Math.floor(this._canvas.width / this._slabW);65this._slabsPerColumn = Math.floor(this._canvas.height / this._slabH);66}6768public allocate(rasterizedGlyph: IRasterizedGlyph): ITextureAtlasPageGlyph | undefined {69// Find ideal slab, creating it if there is none suitable70const glyphWidth = rasterizedGlyph.boundingBox.right - rasterizedGlyph.boundingBox.left + 1;71const glyphHeight = rasterizedGlyph.boundingBox.bottom - rasterizedGlyph.boundingBox.top + 1;7273// The glyph does not fit into the atlas page, glyphs should never be this large in practice74if (glyphWidth > this._canvas.width || glyphHeight > this._canvas.height) {75throw new BugIndicatingError('Glyph is too large for the atlas page');76}7778// The glyph does not fit into a slab79if (glyphWidth > this._slabW || glyphHeight > this._slabH) {80// Only if this is the allocator's first glyph, resize the slab size to fit the glyph.81if (this._allocatedGlyphs.size > 0) {82return undefined;83}84// Find the largest power of 2 devisor that the glyph fits into, this ensure there is no85// wasted space outside the allocated slabs.86let sizeCandidate = this._canvas.width;87while (glyphWidth < sizeCandidate / 2 && glyphHeight < sizeCandidate / 2) {88sizeCandidate /= 2;89}90this._slabW = sizeCandidate;91this._slabH = sizeCandidate;92this._slabsPerRow = Math.floor(this._canvas.width / this._slabW);93this._slabsPerColumn = Math.floor(this._canvas.height / this._slabH);94}9596// const dpr = getActiveWindow().devicePixelRatio;9798// TODO: Include font size as well as DPR in nearestXPixels calculation99100// Round slab glyph dimensions to the nearest x pixels, where x scaled with device pixel ratio101// const nearestXPixels = Math.max(1, Math.floor(dpr / 0.5));102// const nearestXPixels = Math.max(1, Math.floor(dpr));103const desiredSlabSize = {104// Nearest square number105// TODO: This can probably be optimized106// w: 1 << Math.ceil(Math.sqrt(glyphWidth)),107// h: 1 << Math.ceil(Math.sqrt(glyphHeight)),108109// Nearest x px110// w: Math.ceil(glyphWidth / nearestXPixels) * nearestXPixels,111// h: Math.ceil(glyphHeight / nearestXPixels) * nearestXPixels,112113// Round odd numbers up114// w: glyphWidth % 0 === 1 ? glyphWidth + 1 : glyphWidth,115// h: glyphHeight % 0 === 1 ? glyphHeight + 1 : glyphHeight,116117// Exact number only118w: glyphWidth,119h: glyphHeight,120};121122// Get any existing slab123let slab = this._activeSlabsByDims.get(desiredSlabSize.w, desiredSlabSize.h);124125// Check if the slab is full126if (slab) {127const glyphsPerSlab = Math.floor(this._slabW / slab.entryW) * Math.floor(this._slabH / slab.entryH);128if (slab.count >= glyphsPerSlab) {129slab = undefined;130}131}132133let dx: number | undefined;134let dy: number | undefined;135136// Search for suitable space in unused rectangles137if (!slab) {138// Only check availability for the smallest side139if (glyphWidth < glyphHeight) {140const openRegions = this._openRegionsByWidth.get(glyphWidth);141if (openRegions?.length) {142// TODO: Don't search everything?143// Search from the end so we can typically pop it off the stack144for (let i = openRegions.length - 1; i >= 0; i--) {145const r = openRegions[i];146if (r.w >= glyphWidth && r.h >= glyphHeight) {147dx = r.x;148dy = r.y;149if (glyphWidth < r.w) {150this._unusedRects.push({151x: r.x + glyphWidth,152y: r.y,153w: r.w - glyphWidth,154h: glyphHeight155});156}157r.y += glyphHeight;158r.h -= glyphHeight;159if (r.h === 0) {160if (i === openRegions.length - 1) {161openRegions.pop();162} else {163this._unusedRects.splice(i, 1);164}165}166break;167}168}169}170} else {171const openRegions = this._openRegionsByHeight.get(glyphHeight);172if (openRegions?.length) {173// TODO: Don't search everything?174// Search from the end so we can typically pop it off the stack175for (let i = openRegions.length - 1; i >= 0; i--) {176const r = openRegions[i];177if (r.w >= glyphWidth && r.h >= glyphHeight) {178dx = r.x;179dy = r.y;180if (glyphHeight < r.h) {181this._unusedRects.push({182x: r.x,183y: r.y + glyphHeight,184w: glyphWidth,185h: r.h - glyphHeight186});187}188r.x += glyphWidth;189r.w -= glyphWidth;190if (r.h === 0) {191if (i === openRegions.length - 1) {192openRegions.pop();193} else {194this._unusedRects.splice(i, 1);195}196}197break;198}199}200}201}202}203204// Create a new slab205if (dx === undefined || dy === undefined) {206if (!slab) {207if (this._slabs.length >= this._slabsPerRow * this._slabsPerColumn) {208return undefined;209}210211slab = {212x: Math.floor(this._slabs.length % this._slabsPerRow) * this._slabW,213y: Math.floor(this._slabs.length / this._slabsPerRow) * this._slabH,214entryW: desiredSlabSize.w,215entryH: desiredSlabSize.h,216count: 0217};218// Track unused regions to use for small glyphs219// +-------------+----+220// | | |221// | | | <- Unused W region222// | | |223// |-------------+----+224// | | <- Unused H region225// +------------------+226const unusedW = this._slabW % slab.entryW;227const unusedH = this._slabH % slab.entryH;228if (unusedW) {229addEntryToMapArray(this._openRegionsByWidth, unusedW, {230x: slab.x + this._slabW - unusedW,231w: unusedW,232y: slab.y,233h: this._slabH - (unusedH ?? 0)234});235}236if (unusedH) {237addEntryToMapArray(this._openRegionsByHeight, unusedH, {238x: slab.x,239w: this._slabW,240y: slab.y + this._slabH - unusedH,241h: unusedH242});243}244this._slabs.push(slab);245this._activeSlabsByDims.set(slab, desiredSlabSize.w, desiredSlabSize.h);246}247248const glyphsPerRow = Math.floor(this._slabW / slab.entryW);249dx = slab.x + Math.floor(slab.count % glyphsPerRow) * slab.entryW;250dy = slab.y + Math.floor(slab.count / glyphsPerRow) * slab.entryH;251252// Shift current row253slab.count++;254}255256// Draw glyph257this._ctx.drawImage(258rasterizedGlyph.source,259// source260rasterizedGlyph.boundingBox.left,261rasterizedGlyph.boundingBox.top,262glyphWidth,263glyphHeight,264// destination265dx,266dy,267glyphWidth,268glyphHeight269);270271// Create glyph object272const glyph: ITextureAtlasPageGlyph = {273pageIndex: this._textureIndex,274glyphIndex: this._nextIndex++,275x: dx,276y: dy,277w: glyphWidth,278h: glyphHeight,279originOffsetX: rasterizedGlyph.originOffset.x,280originOffsetY: rasterizedGlyph.originOffset.y,281fontBoundingBoxAscent: rasterizedGlyph.fontBoundingBoxAscent,282fontBoundingBoxDescent: rasterizedGlyph.fontBoundingBoxDescent,283};284285// Set the glyph286this._allocatedGlyphs.add(glyph);287288return glyph;289}290291public getUsagePreview(): Promise<Blob> {292const w = this._canvas.width;293const h = this._canvas.height;294const canvas = new OffscreenCanvas(w, h);295const ctx = ensureNonNullable(canvas.getContext('2d'));296297ctx.fillStyle = UsagePreviewColors.Unused;298ctx.fillRect(0, 0, w, h);299300let slabEntryPixels = 0;301let usedPixels = 0;302let slabEdgePixels = 0;303let restrictedPixels = 0;304const slabW = 64 << (Math.floor(getActiveWindow().devicePixelRatio) - 1);305const slabH = slabW;306307// Draw wasted underneath glyphs first308for (const slab of this._slabs) {309let x = 0;310let y = 0;311for (let i = 0; i < slab.count; i++) {312if (x + slab.entryW > slabW) {313x = 0;314y += slab.entryH;315}316ctx.fillStyle = UsagePreviewColors.Wasted;317ctx.fillRect(slab.x + x, slab.y + y, slab.entryW, slab.entryH);318319slabEntryPixels += slab.entryW * slab.entryH;320x += slab.entryW;321}322const entriesPerRow = Math.floor(slabW / slab.entryW);323const entriesPerCol = Math.floor(slabH / slab.entryH);324const thisSlabPixels = slab.entryW * entriesPerRow * slab.entryH * entriesPerCol;325slabEdgePixels += (slabW * slabH) - thisSlabPixels;326}327328// Draw glyphs329for (const g of this._allocatedGlyphs) {330usedPixels += g.w * g.h;331ctx.fillStyle = UsagePreviewColors.Used;332ctx.fillRect(g.x, g.y, g.w, g.h);333}334335// Draw unused space on side336const unusedRegions = Array.from(this._openRegionsByWidth.values()).flat().concat(Array.from(this._openRegionsByHeight.values()).flat());337for (const r of unusedRegions) {338ctx.fillStyle = UsagePreviewColors.Restricted;339ctx.fillRect(r.x, r.y, r.w, r.h);340restrictedPixels += r.w * r.h;341}342343344// Overlay actual glyphs on top345ctx.globalAlpha = 0.5;346ctx.drawImage(this._canvas, 0, 0);347ctx.globalAlpha = 1;348349return canvas.convertToBlob();350}351352public getStats(): string {353const w = this._canvas.width;354const h = this._canvas.height;355356let slabEntryPixels = 0;357let usedPixels = 0;358let slabEdgePixels = 0;359let wastedPixels = 0;360let restrictedPixels = 0;361const totalPixels = w * h;362const slabW = 64 << (Math.floor(getActiveWindow().devicePixelRatio) - 1);363const slabH = slabW;364365// Draw wasted underneath glyphs first366for (const slab of this._slabs) {367let x = 0;368let y = 0;369for (let i = 0; i < slab.count; i++) {370if (x + slab.entryW > slabW) {371x = 0;372y += slab.entryH;373}374slabEntryPixels += slab.entryW * slab.entryH;375x += slab.entryW;376}377const entriesPerRow = Math.floor(slabW / slab.entryW);378const entriesPerCol = Math.floor(slabH / slab.entryH);379const thisSlabPixels = slab.entryW * entriesPerRow * slab.entryH * entriesPerCol;380slabEdgePixels += (slabW * slabH) - thisSlabPixels;381}382383// Draw glyphs384for (const g of this._allocatedGlyphs) {385usedPixels += g.w * g.h;386}387388// Draw unused space on side389const unusedRegions = Array.from(this._openRegionsByWidth.values()).flat().concat(Array.from(this._openRegionsByHeight.values()).flat());390for (const r of unusedRegions) {391restrictedPixels += r.w * r.h;392}393394const edgeUsedPixels = slabEdgePixels - restrictedPixels;395wastedPixels = slabEntryPixels - (usedPixels - edgeUsedPixels);396397// usedPixels += slabEdgePixels - restrictedPixels;398const efficiency = usedPixels / (usedPixels + wastedPixels + restrictedPixels);399400return [401`page[${this._textureIndex}]:`,402` Total: ${totalPixels}px (${w}x${h})`,403` Used: ${usedPixels}px (${((usedPixels / totalPixels) * 100).toFixed(2)}%)`,404` Wasted: ${wastedPixels}px (${((wastedPixels / totalPixels) * 100).toFixed(2)}%)`,405`Restricted: ${restrictedPixels}px (${((restrictedPixels / totalPixels) * 100).toFixed(2)}%) (hard to allocate)`,406`Efficiency: ${efficiency === 1 ? '100' : (efficiency * 100).toFixed(2)}%`,407` Slabs: ${this._slabs.length} of ${Math.floor(this._canvas.width / slabW) * Math.floor(this._canvas.height / slabH)}`408].join('\n');409}410}411412interface ITextureAtlasSlab {413x: number;414y: number;415entryH: number;416entryW: number;417count: number;418}419420interface ITextureAtlasSlabUnusedRect {421x: number;422y: number;423w: number;424h: number;425}426427function addEntryToMapArray<K, V>(map: Map<K, V[]>, key: K, entry: V) {428let list = map.get(key);429if (!list) {430list = [];431map.set(key, list);432}433list.push(entry);434}435436437