diff --git a/.gitignore b/.gitignore index e48470eff6..13d49cd8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ build/ .DS_Store yarn.lock test-results/ +.playwright-cli/ # Keep bundled code out of Git dist/ diff --git a/README.md b/README.md index a6fecc191e..9fffc5bfdb 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ The xterm.js team maintains the following addons, but anyone can build them: - [`@xterm/addon-web-fonts`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-web-fonts): Easily integrate web fonts - [`@xterm/addon-web-links`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-web-links): Adds web link detection and interaction - [`@xterm/addon-webgl`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-webgl): Renders xterm.js using a `canvas` element's webgl2 context +- [`@xterm/addon-webgpu`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-webgpu): Renders xterm.js using a `canvas` element's webgpu context ## Browser Support diff --git a/addons/addon-webgpu/LICENSE b/addons/addon-webgpu/LICENSE new file mode 100644 index 0000000000..b9dc26fe39 --- /dev/null +++ b/addons/addon-webgpu/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/addons/addon-webgpu/README.md b/addons/addon-webgpu/README.md new file mode 100644 index 0000000000..c914cedead --- /dev/null +++ b/addons/addon-webgpu/README.md @@ -0,0 +1,24 @@ +## @xterm/addon-webgpu + +An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that enables a WebGPU-based renderer. This addon requires xterm.js v4+. + +``` +npm install --save @xterm/addon-webgpu +``` + +``` +import { WebgpuAddon } from '@xterm/addon-webgpu'; + +const terminal = new Terminal(); +const addon = new WebgpuAddon(); +terminal.loadAddon(addon); +``` + +See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-webgpu/typings/addon-webgpu.d.ts) for more advanced usage. + +The browser may drop WebGPU devices for various reasons like OOM or after the system has been suspended. There is an API exposed that fires a context loss event so embedders can handle it however they wish. An easy, but suboptimal way, to handle this is by disposing of WebgpuAddon when the event fires: + +``` +const addon = new WebgpuAddon(); +addon.onContextLoss(() => addon.dispose()); +``` diff --git a/addons/addon-webgpu/package.json b/addons/addon-webgpu/package.json new file mode 100644 index 0000000000..a94a3479ba --- /dev/null +++ b/addons/addon-webgpu/package.json @@ -0,0 +1,26 @@ +{ + "name": "@xterm/addon-webgpu", + "version": "0.1.0", + "author": { + "name": "The xterm.js authors", + "url": "https://xtermjs.org/" + }, + "main": "lib/addon-webgpu.js", + "module": "lib/addon-webgpu.mjs", + "types": "typings/addon-webgpu.d.ts", + "repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-webgpu", + "license": "MIT", + "keywords": [ + "terminal", + "webgpu", + "xterm", + "xterm.js" + ], + "scripts": { + "build": "../../node_modules/.bin/tsc -p .", + "prepackage": "npm run build", + "package": "../../node_modules/.bin/webpack", + "prepublishOnly": "npm run package", + "start": "node ../../demo/start" + } +} diff --git a/addons/addon-webgpu/src/WebgpuAddon.ts b/addons/addon-webgpu/src/WebgpuAddon.ts new file mode 100644 index 0000000000..c084e07fea --- /dev/null +++ b/addons/addon-webgpu/src/WebgpuAddon.ts @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import type { ITerminalAddon, Terminal } from '@xterm/xterm'; +import type { IWebgpuAddonOptions, WebgpuAddon as IWebgpuApi } from '@xterm/addon-webgpu'; +import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; +import { ITerminal } from 'browser/Types'; +import { Disposable, toDisposable } from 'common/Lifecycle'; +import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; +import { WebgpuRenderer } from './WebgpuRenderer'; +import { Emitter, EventUtils } from 'common/Event'; +import type { IGPU } from './WebgpuTypes'; + +export class WebgpuAddon extends Disposable implements ITerminalAddon, IWebgpuApi { + private _terminal?: Terminal; + private _renderer?: WebgpuRenderer; + + private readonly _onChangeTextureAtlas = this._register(new Emitter()); + public readonly onChangeTextureAtlas = this._onChangeTextureAtlas.event; + private readonly _onAddTextureAtlasCanvas = this._register(new Emitter()); + public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event; + private readonly _onRemoveTextureAtlasCanvas = this._register(new Emitter()); + public readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event; + private readonly _onContextLoss = this._register(new Emitter()); + public readonly onContextLoss = this._onContextLoss.event; + + private readonly _customGlyphs: boolean; + private readonly _preserveDrawingBuffer?: boolean; + + constructor(options?: IWebgpuAddonOptions) { + super(); + this._customGlyphs = options?.customGlyphs ?? true; + this._preserveDrawingBuffer = options?.preserveDrawingBuffer; + } + + public activate(terminal: Terminal): void { + const core = (terminal as any)._core as ITerminal; + if (!terminal.element) { + this._register(core.onWillOpen(() => this.activate(terminal))); + return; + } + + this._terminal = terminal; + const coreService: ICoreService = core.coreService; + const optionsService: IOptionsService = core.optionsService; + + const unsafeCore = core as any; + const renderService: IRenderService = unsafeCore._renderService; + const characterJoinerService: ICharacterJoinerService = unsafeCore._characterJoinerService; + const charSizeService: ICharSizeService = unsafeCore._charSizeService; + const coreBrowserService: ICoreBrowserService = unsafeCore._coreBrowserService; + const decorationService: IDecorationService = unsafeCore._decorationService; + const themeService: IThemeService = unsafeCore._themeService; + + if (!WebgpuAddon._isWebgpuSupported()) { + renderService.setRenderer((this._terminal as any)._core._createRenderer()); + renderService.handleResize(terminal.cols, terminal.rows); + return; + } + + try { + this._renderer = this._register(new WebgpuRenderer( + terminal, + characterJoinerService, + charSizeService, + coreBrowserService, + coreService, + decorationService, + optionsService, + themeService, + this._customGlyphs, + this._preserveDrawingBuffer + )); + } catch { + renderService.setRenderer((this._terminal as any)._core._createRenderer()); + renderService.handleResize(terminal.cols, terminal.rows); + return; + } + + let isReady = false; + this._register(this._renderer.onReady(() => { + if ((this._terminal as any)._core._store._isDisposed) { + return; + } + isReady = true; + renderService.setRenderer(this._renderer!); + renderService.handleResize(terminal.cols, terminal.rows); + })); + this._register(this._renderer.onContextLoss(() => { + this._onContextLoss.fire(); + if (isReady) { + return; + } + if ((this._terminal as any)._core._store._isDisposed) { + return; + } + this._renderer?.dispose(); + this._renderer = undefined; + renderService.setRenderer((this._terminal as any)._core._createRenderer()); + renderService.handleResize(terminal.cols, terminal.rows); + })); + this._register(EventUtils.forward(this._renderer.onChangeTextureAtlas, this._onChangeTextureAtlas)); + this._register(EventUtils.forward(this._renderer.onAddTextureAtlasCanvas, this._onAddTextureAtlasCanvas)); + this._register(EventUtils.forward(this._renderer.onRemoveTextureAtlasCanvas, this._onRemoveTextureAtlasCanvas)); + + this._register(toDisposable(() => { + if ((this._terminal as any)._core._store._isDisposed) { + return; + } + const renderService: IRenderService = (this._terminal as any)._core._renderService; + renderService.setRenderer((this._terminal as any)._core._createRenderer()); + renderService.handleResize(terminal.cols, terminal.rows); + })); + } + + public get textureAtlas(): HTMLCanvasElement | undefined { + return this._renderer?.textureAtlas; + } + + public clearTextureAtlas(): void { + this._renderer?.clearTextureAtlas(); + } + + private static _isWebgpuSupported(): boolean { + return typeof navigator !== 'undefined' && !!(navigator as Navigator & { gpu?: IGPU }).gpu; + } +} diff --git a/addons/addon-webgpu/src/WebgpuGlyphRenderer.ts b/addons/addon-webgpu/src/WebgpuGlyphRenderer.ts new file mode 100644 index 0000000000..df8aa0a86c --- /dev/null +++ b/addons/addon-webgpu/src/WebgpuGlyphRenderer.ts @@ -0,0 +1,477 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IRenderDimensions } from 'browser/renderer/shared/Types'; +import { NULL_CELL_CODE } from 'common/buffer/Constants'; +import { Disposable, toDisposable } from 'common/Lifecycle'; +import { Terminal } from '@xterm/xterm'; +import { allowRescaling } from 'browser/renderer/shared/RendererUtils'; +import type { IOptionsService } from 'common/services/Services'; +import type { IRenderModel, IRasterizedGlyph, ITextureAtlas } from '../../addon-webgl/src/Types'; +import type { IGPUDevice, IGPURenderPassEncoder, IGPUSampler, IGPUTexture, IGPUTextureFormat, IGPUTextureView, IGPUBindGroup, IGPUBindGroupLayout, IGPURenderPipeline, IGPUBuffer, IGPUBindGroupLayoutEntry } from './WebgpuTypes'; +import { WebgpuBufferUsage, WebgpuShaderStage, WebgpuTextureUsage, WebgpuColorWriteMask } from './WebgpuUtils'; + +interface IVertices { + attributes: Float32Array; + count: number; +} + +const enum VertexAttribLocations { + UNIT_QUAD = 0, + OFFSET = 1, + SIZE = 2, + TEXPAGE = 3, + TEXCOORD = 4, + TEXSIZE = 5, + CELL_POSITION = 6 +} + +const INDICES_PER_CELL = 11; +const BYTES_PER_CELL = INDICES_PER_CELL * Float32Array.BYTES_PER_ELEMENT; + +// Work variables to avoid garbage collection +let $i = 0; +let $glyph: IRasterizedGlyph | undefined = undefined; +let $leftCellPadding = 0; +let $clippedPixels = 0; + +function createGlyphShaderSource(maxAtlasPages: number): string { + let textureBindings = ''; + for (let i = 0; i < maxAtlasPages; i++) { + textureBindings += `@group(0) @binding(${2 + i}) var atlasTexture${i}: texture_2d;\n`; + } + + let sampleChain = ''; + for (let i = 0; i < maxAtlasPages; i++) { + const prefix = i === 0 ? '' : 'else '; + sampleChain += `${prefix}if (page == ${i}u) { color = textureSampleLevel(atlasTexture${i}, atlasSampler, input.texCoord, 0.0); }\n`; + } + + return ` +struct Uniforms { + resolution: vec2, + _pad: vec2, +}; + +@group(0) @binding(0) var uniforms: Uniforms; +@group(0) @binding(1) var atlasSampler: sampler; +${textureBindings} + +struct VertexIn { + @location(${VertexAttribLocations.UNIT_QUAD}) unitQuad: vec2, + @location(${VertexAttribLocations.OFFSET}) offset: vec2, + @location(${VertexAttribLocations.SIZE}) size: vec2, + @location(${VertexAttribLocations.TEXPAGE}) texPage: f32, + @location(${VertexAttribLocations.TEXCOORD}) texCoord: vec2, + @location(${VertexAttribLocations.TEXSIZE}) texSize: vec2, + @location(${VertexAttribLocations.CELL_POSITION}) cellPos: vec2, +}; + +struct VertexOut { + @builtin(position) position: vec4, + @location(0) texCoord: vec2, + @location(1) @interpolate(flat) texPage: u32, +}; + +@vertex +fn vs_main(input: VertexIn) -> VertexOut { + var out: VertexOut; + let zeroToOne = (input.offset / uniforms.resolution) + input.cellPos + (input.unitQuad * input.size); + let clip = vec2(zeroToOne.x * 2.0 - 1.0, 1.0 - zeroToOne.y * 2.0); + out.position = vec4(clip, 0.0, 1.0); + out.texCoord = input.texCoord + input.unitQuad * input.texSize; + out.texPage = u32(input.texPage + 0.5); + return out; +} + +@fragment +fn fs_main(input: VertexOut) -> @location(0) vec4 { + let page = min(input.texPage, u32(${maxAtlasPages - 1})); + var color: vec4 = vec4(1.0, 0.0, 0.0, 1.0); + ${sampleChain} + return color; +} +`; +} + +interface IAtlasTexture { + texture: IGPUTexture; + view: IGPUTextureView; + version: number; + width: number; + height: number; +} + +export class WebgpuGlyphRenderer extends Disposable { + private readonly _uniformData = new Float32Array(4); + private readonly _unitQuadBuffer: IGPUBuffer; + private _instanceBuffer: IGPUBuffer | undefined; + private _instanceBufferSize: number = 0; + private readonly _sampler: IGPUSampler; + private readonly _bindGroupLayout: IGPUBindGroupLayout; + private _bindGroup: IGPUBindGroup | undefined; + private readonly _pipeline: IGPURenderPipeline; + private readonly _uniformBuffer: IGPUBuffer; + + private _atlas: ITextureAtlas | undefined; + private _atlasTextures: IAtlasTexture[] = []; + + private _vertices: IVertices = { attributes: new Float32Array(0), count: 0 }; + private _stagingAttributes: Float32Array = new Float32Array(0); + + constructor( + private readonly _terminal: Terminal, + private readonly _device: IGPUDevice, + private readonly _format: IGPUTextureFormat, + private _dimensions: IRenderDimensions, + private readonly _optionsService: IOptionsService, + private readonly _maxAtlasPages: number + ) { + super(); + const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]); + this._unitQuadBuffer = this._device.createBuffer({ + size: unitQuadVertices.byteLength, + usage: WebgpuBufferUsage.VERTEX | WebgpuBufferUsage.COPY_DST + }); + this._device.queue.writeBuffer(this._unitQuadBuffer, 0, unitQuadVertices); + + this._uniformBuffer = this._device.createBuffer({ + size: this._uniformData.byteLength, + usage: WebgpuBufferUsage.UNIFORM | WebgpuBufferUsage.COPY_DST + }); + this._updateResolutionUniform(); + + this._sampler = this._device.createSampler({ + minFilter: 'linear', + magFilter: 'linear', + mipmapFilter: 'linear', + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge' + }); + + const bindGroupEntries: IGPUBindGroupLayoutEntry[] = [ + { + binding: 0, + visibility: WebgpuShaderStage.VERTEX, + buffer: { type: 'uniform' } + }, + { + binding: 1, + visibility: WebgpuShaderStage.FRAGMENT, + sampler: { type: 'filtering' } + } + ]; + for (let i = 0; i < this._maxAtlasPages; i++) { + bindGroupEntries.push({ + binding: 2 + i, + visibility: WebgpuShaderStage.FRAGMENT, + texture: { sampleType: 'float', viewDimension: '2d' } + }); + } + + this._bindGroupLayout = this._device.createBindGroupLayout({ entries: bindGroupEntries }); + + const shaderModule = this._device.createShaderModule({ code: createGlyphShaderSource(this._maxAtlasPages) }); + const pipelineLayout = this._device.createPipelineLayout({ bindGroupLayouts: [this._bindGroupLayout] }); + + this._pipeline = this._device.createRenderPipeline({ + layout: pipelineLayout, + vertex: { + module: shaderModule, + entryPoint: 'vs_main', + buffers: [ + { + arrayStride: 2 * Float32Array.BYTES_PER_ELEMENT, + stepMode: 'vertex', + attributes: [ + { shaderLocation: VertexAttribLocations.UNIT_QUAD, offset: 0, format: 'float32x2' } + ] + }, + { + arrayStride: BYTES_PER_CELL, + stepMode: 'instance', + attributes: [ + { shaderLocation: VertexAttribLocations.OFFSET, offset: 0, format: 'float32x2' }, + { shaderLocation: VertexAttribLocations.SIZE, offset: 8, format: 'float32x2' }, + { shaderLocation: VertexAttribLocations.TEXPAGE, offset: 16, format: 'float32' }, + { shaderLocation: VertexAttribLocations.TEXCOORD, offset: 20, format: 'float32x2' }, + { shaderLocation: VertexAttribLocations.TEXSIZE, offset: 28, format: 'float32x2' }, + { shaderLocation: VertexAttribLocations.CELL_POSITION, offset: 36, format: 'float32x2' } + ] + } + ] + }, + fragment: { + module: shaderModule, + entryPoint: 'fs_main', + targets: [ + { + format: this._format, + blend: { + color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' }, + alpha: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' } + }, + writeMask: WebgpuColorWriteMask.ALL + } + ] + }, + primitive: { + topology: 'triangle-strip' + } + }); + + this._createDefaultAtlasTextures(); + this._bindGroup = this._createBindGroup(); + this.clear(); + + this._register(toDisposable(() => { + this._unitQuadBuffer.destroy?.(); + this._instanceBuffer?.destroy?.(); + this._uniformBuffer.destroy?.(); + for (const tex of this._atlasTextures) { + tex.texture.destroy?.(); + } + })); + } + + public beginFrame(): boolean { + return this._atlas ? this._atlas.beginFrame() : true; + } + + public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void { + this._updateCell(this._vertices.attributes, x, y, code, bg, fg, ext, chars, width, lastBg); + } + + private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void { + $i = (y * this._terminal.cols + x) * INDICES_PER_CELL; + + if (code === NULL_CELL_CODE || code === undefined) { + array.fill(0, $i, $i + INDICES_PER_CELL - 2); + return; + } + + if (!this._atlas) { + return; + } + + if (chars && chars.length > 1) { + $glyph = this._atlas.getRasterizedGlyphCombinedChar(chars, bg, fg, ext, false, this._terminal.element); + } else { + $glyph = this._atlas.getRasterizedGlyph(code, bg, fg, ext, false, this._terminal.element); + } + + $leftCellPadding = Math.floor((this._dimensions.device.cell.width - this._dimensions.device.char.width) / 2); + if (bg !== lastBg && $glyph.offset.x > $leftCellPadding) { + $clippedPixels = $glyph.offset.x - $leftCellPadding; + array[$i ] = -($glyph.offset.x - $clippedPixels) + this._dimensions.device.char.left; + array[$i + 1] = -$glyph.offset.y + this._dimensions.device.char.top; + array[$i + 2] = ($glyph.size.x - $clippedPixels) / this._dimensions.device.canvas.width; + array[$i + 3] = $glyph.size.y / this._dimensions.device.canvas.height; + array[$i + 4] = $glyph.texturePage; + array[$i + 5] = $glyph.texturePositionClipSpace.x + $clippedPixels / this._atlas.pages[$glyph.texturePage].canvas.width; + array[$i + 6] = $glyph.texturePositionClipSpace.y; + array[$i + 7] = $glyph.sizeClipSpace.x - $clippedPixels / this._atlas.pages[$glyph.texturePage].canvas.width; + array[$i + 8] = $glyph.sizeClipSpace.y; + } else { + array[$i ] = -$glyph.offset.x + this._dimensions.device.char.left; + array[$i + 1] = -$glyph.offset.y + this._dimensions.device.char.top; + array[$i + 2] = $glyph.size.x / this._dimensions.device.canvas.width; + array[$i + 3] = $glyph.size.y / this._dimensions.device.canvas.height; + array[$i + 4] = $glyph.texturePage; + array[$i + 5] = $glyph.texturePositionClipSpace.x; + array[$i + 6] = $glyph.texturePositionClipSpace.y; + array[$i + 7] = $glyph.sizeClipSpace.x; + array[$i + 8] = $glyph.sizeClipSpace.y; + } + + if (this._optionsService.rawOptions.rescaleOverlappingGlyphs) { + if (allowRescaling(code, width, $glyph.size.x, this._dimensions.device.cell.width)) { + array[$i + 2] = (this._dimensions.device.cell.width - 1) / this._dimensions.device.canvas.width; + } + } + } + + public clear(): void { + const terminal = this._terminal; + const newCount = terminal.cols * terminal.rows * INDICES_PER_CELL; + + if (this._vertices.count !== newCount) { + this._vertices.attributes = new Float32Array(newCount); + } else { + this._vertices.attributes.fill(0); + } + this._vertices.count = newCount; + + let i = 0; + for (let y = 0; y < terminal.rows; y++) { + for (let x = 0; x < terminal.cols; x++) { + this._vertices.attributes[i + 9] = x / terminal.cols; + this._vertices.attributes[i + 10] = y / terminal.rows; + i += INDICES_PER_CELL; + } + } + } + + public handleResize(): void { + this._updateResolutionUniform(); + this.clear(); + } + + public render(passEncoder: IGPURenderPassEncoder, renderModel: IRenderModel): void { + if (!this._atlas || !this._bindGroup) { + return; + } + + this._syncAtlasTextures(); + + let bufferLength = 0; + for (let y = 0; y < renderModel.lineLengths.length; y++) { + const si = y * this._terminal.cols * INDICES_PER_CELL; + const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL); + this._ensureStagingCapacity(bufferLength + sub.length); + this._stagingAttributes.set(sub, bufferLength); + bufferLength += sub.length; + } + + if (bufferLength === 0) { + return; + } + + this._ensureInstanceBuffer(bufferLength * Float32Array.BYTES_PER_ELEMENT); + if (!this._instanceBuffer) { + return; + } + this._device.queue.writeBuffer(this._instanceBuffer, 0, this._stagingAttributes.subarray(0, bufferLength)); + + passEncoder.setPipeline(this._pipeline); + passEncoder.setBindGroup(0, this._bindGroup); + passEncoder.setVertexBuffer(0, this._unitQuadBuffer); + passEncoder.setVertexBuffer(1, this._instanceBuffer); + passEncoder.draw(4, bufferLength / INDICES_PER_CELL, 0, 0); + } + + public setAtlas(atlas: ITextureAtlas): void { + this._atlas = atlas; + for (const tex of this._atlasTextures) { + tex.version = -1; + } + } + + public setDimensions(dimensions: IRenderDimensions): void { + this._dimensions = dimensions; + } + + private _ensureStagingCapacity(requiredLength: number): void { + if (this._stagingAttributes.length >= requiredLength) { + return; + } + const next = new Float32Array(requiredLength); + next.set(this._stagingAttributes); + this._stagingAttributes = next; + } + + private _ensureInstanceBuffer(requiredBytes: number): void { + if (this._instanceBufferSize >= requiredBytes) { + return; + } + this._instanceBuffer = this._device.createBuffer({ + size: requiredBytes, + usage: WebgpuBufferUsage.VERTEX | WebgpuBufferUsage.COPY_DST + }); + this._instanceBufferSize = requiredBytes; + } + + private _updateResolutionUniform(): void { + this._uniformData[0] = this._dimensions.device.canvas.width; + this._uniformData[1] = this._dimensions.device.canvas.height; + this._uniformData[2] = 0; + this._uniformData[3] = 0; + this._device.queue.writeBuffer(this._uniformBuffer, 0, this._uniformData); + } + + private _createDefaultAtlasTextures(): void { + const redPixel = new Uint8Array([255, 0, 0, 255]); + for (let i = 0; i < this._maxAtlasPages; i++) { + const texture = this._device.createTexture({ + size: { width: 1, height: 1, depthOrArrayLayers: 1 }, + format: 'rgba8unorm', + usage: WebgpuTextureUsage.TEXTURE_BINDING | WebgpuTextureUsage.COPY_DST | WebgpuTextureUsage.RENDER_ATTACHMENT + }); + const view = texture.createView(); + this._device.queue.writeTexture( + { texture }, + redPixel, + { bytesPerRow: 4, rowsPerImage: 1 }, + { width: 1, height: 1, depthOrArrayLayers: 1 } + ); + this._atlasTextures.push({ texture, view, version: -1, width: 1, height: 1 }); + } + } + + private _createBindGroup(): IGPUBindGroup { + const entries = [ + { + binding: 0, + resource: { buffer: this._uniformBuffer } + }, + { + binding: 1, + resource: this._sampler + } + ]; + for (let i = 0; i < this._maxAtlasPages; i++) { + entries.push({ + binding: 2 + i, + resource: this._atlasTextures[i].view + }); + } + return this._device.createBindGroup({ + layout: this._bindGroupLayout, + entries + }); + } + + private _syncAtlasTextures(): void { + if (!this._atlas) { + return; + } + + let needsBindGroup = false; + for (let i = 0; i < this._atlas.pages.length && i < this._atlasTextures.length; i++) { + const page = this._atlas.pages[i]; + if (page.canvas.width === 0 || page.canvas.height === 0) { + continue; + } + const existing = this._atlasTextures[i]; + if (existing.width !== page.canvas.width || existing.height !== page.canvas.height) { + existing.texture.destroy?.(); + const texture = this._device.createTexture({ + size: { width: page.canvas.width, height: page.canvas.height, depthOrArrayLayers: 1 }, + format: 'rgba8unorm', + usage: WebgpuTextureUsage.TEXTURE_BINDING | WebgpuTextureUsage.COPY_DST | WebgpuTextureUsage.RENDER_ATTACHMENT + }); + existing.texture = texture; + existing.view = texture.createView(); + existing.width = page.canvas.width; + existing.height = page.canvas.height; + existing.version = -1; + needsBindGroup = true; + } + + if (existing.version !== page.version) { + this._device.queue.copyExternalImageToTexture( + { source: page.canvas }, + { texture: existing.texture }, + { width: page.canvas.width, height: page.canvas.height, depthOrArrayLayers: 1 } + ); + existing.version = page.version; + } + } + + if (needsBindGroup) { + this._bindGroup = this._createBindGroup(); + } + } +} diff --git a/addons/addon-webgpu/src/WebgpuRectangleRenderer.ts b/addons/addon-webgpu/src/WebgpuRectangleRenderer.ts new file mode 100644 index 0000000000..74def17af0 --- /dev/null +++ b/addons/addon-webgpu/src/WebgpuRectangleRenderer.ts @@ -0,0 +1,404 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IRenderDimensions } from 'browser/renderer/shared/Types'; +import { IThemeService } from 'browser/services/Services'; +import { ReadonlyColorSet } from 'browser/Types'; +import { Attributes, FgFlags } from 'common/buffer/Constants'; +import { IColor } from 'common/Types'; +import { Terminal } from '@xterm/xterm'; +import { Disposable, toDisposable } from 'common/Lifecycle'; +import { RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from '../../addon-webgl/src/RenderModel'; +import { IRenderModel } from '../../addon-webgl/src/Types'; +import { expandFloat32Array } from '../../addon-webgl/src/WebglUtils'; +import type { IGPUDevice, IGPURenderPassEncoder, IGPUTextureFormat, IGPURenderPipeline, IGPUBuffer } from './WebgpuTypes'; +import { WebgpuBufferUsage, WebgpuColorWriteMask } from './WebgpuUtils'; + +const enum VertexAttribLocations { + UNIT_QUAD = 0, + POSITION = 1, + SIZE = 2, + COLOR = 3 +} + +const INDICES_PER_RECTANGLE = 8; +const BYTES_PER_RECTANGLE = INDICES_PER_RECTANGLE * Float32Array.BYTES_PER_ELEMENT; +const INITIAL_BUFFER_RECTANGLE_CAPACITY = 20 * INDICES_PER_RECTANGLE; + +class Vertices { + public attributes: Float32Array; + public count: number; + + constructor() { + this.attributes = new Float32Array(INITIAL_BUFFER_RECTANGLE_CAPACITY); + this.count = 0; + } +} + +// Work variables to avoid garbage collection +let $rgba = 0; +let $x1 = 0; +let $y1 = 0; +let $r = 0; +let $g = 0; +let $b = 0; +let $a = 0; + +function createRectangleShaderSource(): string { + return ` +struct VertexIn { + @location(${VertexAttribLocations.UNIT_QUAD}) unitQuad: vec2, + @location(${VertexAttribLocations.POSITION}) position: vec2, + @location(${VertexAttribLocations.SIZE}) size: vec2, + @location(${VertexAttribLocations.COLOR}) color: vec4, +}; + +struct VertexOut { + @builtin(position) position: vec4, + @location(0) color: vec4, +}; + +@vertex +fn vs_main(input: VertexIn) -> VertexOut { + var out: VertexOut; + let zeroToOne = input.position + (input.unitQuad * input.size); + let clip = vec2(zeroToOne.x * 2.0 - 1.0, 1.0 - zeroToOne.y * 2.0); + out.position = vec4(clip, 0.0, 1.0); + out.color = input.color; + return out; +} + +@fragment +fn fs_main(input: VertexOut) -> @location(0) vec4 { + return input.color; +} +`; +} + +export class WebgpuRectangleRenderer extends Disposable { + private readonly _pipeline: IGPURenderPipeline; + private readonly _unitQuadBuffer: IGPUBuffer; + private _instanceBuffer: IGPUBuffer | undefined; + private _instanceBufferSize: number = 0; + + private _bgFloat!: Float32Array; + private _cursorFloat!: Float32Array; + + private _vertices: Vertices = new Vertices(); + private _verticesCursor: Vertices = new Vertices(); + + constructor( + private readonly _terminal: Terminal, + private readonly _device: IGPUDevice, + private readonly _format: IGPUTextureFormat, + private _dimensions: IRenderDimensions, + private readonly _themeService: IThemeService + ) { + super(); + const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]); + this._unitQuadBuffer = this._device.createBuffer({ + size: unitQuadVertices.byteLength, + usage: WebgpuBufferUsage.VERTEX | WebgpuBufferUsage.COPY_DST + }); + this._device.queue.writeBuffer(this._unitQuadBuffer, 0, unitQuadVertices); + + const shaderModule = this._device.createShaderModule({ code: createRectangleShaderSource() }); + this._pipeline = this._device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: shaderModule, + entryPoint: 'vs_main', + buffers: [ + { + arrayStride: 2 * Float32Array.BYTES_PER_ELEMENT, + stepMode: 'vertex', + attributes: [ + { shaderLocation: VertexAttribLocations.UNIT_QUAD, offset: 0, format: 'float32x2' } + ] + }, + { + arrayStride: BYTES_PER_RECTANGLE, + stepMode: 'instance', + attributes: [ + { shaderLocation: VertexAttribLocations.POSITION, offset: 0, format: 'float32x2' }, + { shaderLocation: VertexAttribLocations.SIZE, offset: 8, format: 'float32x2' }, + { shaderLocation: VertexAttribLocations.COLOR, offset: 16, format: 'float32x4' } + ] + } + ] + }, + fragment: { + module: shaderModule, + entryPoint: 'fs_main', + targets: [ + { + format: this._format, + blend: { + color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' }, + alpha: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' } + }, + writeMask: WebgpuColorWriteMask.ALL + } + ] + }, + primitive: { + topology: 'triangle-strip' + } + }); + + this._updateCachedColors(_themeService.colors); + this._register(this._themeService.onChangeColors(e => { + this._updateCachedColors(e); + this._updateViewportRectangle(); + })); + + this._register(toDisposable(() => { + this._unitQuadBuffer.destroy?.(); + this._instanceBuffer?.destroy?.(); + })); + } + + public renderBackgrounds(passEncoder: IGPURenderPassEncoder): void { + this._renderVertices(passEncoder, this._vertices); + } + + public renderCursor(passEncoder: IGPURenderPassEncoder): void { + this._renderVertices(passEncoder, this._verticesCursor); + } + + private _renderVertices(passEncoder: IGPURenderPassEncoder, vertices: Vertices): void { + if (vertices.count === 0) { + return; + } + + const byteLength = vertices.count * INDICES_PER_RECTANGLE * Float32Array.BYTES_PER_ELEMENT; + this._ensureInstanceBuffer(byteLength); + if (!this._instanceBuffer) { + return; + } + this._device.queue.writeBuffer(this._instanceBuffer, 0, vertices.attributes.subarray(0, vertices.count * INDICES_PER_RECTANGLE)); + + passEncoder.setPipeline(this._pipeline); + passEncoder.setVertexBuffer(0, this._unitQuadBuffer); + passEncoder.setVertexBuffer(1, this._instanceBuffer); + passEncoder.draw(4, vertices.count, 0, 0); + } + + public handleResize(): void { + this._updateViewportRectangle(); + } + + public setDimensions(dimensions: IRenderDimensions): void { + this._dimensions = dimensions; + } + + private _updateCachedColors(colors: ReadonlyColorSet): void { + this._bgFloat = this._colorToFloat32Array(colors.background); + this._cursorFloat = this._colorToFloat32Array(colors.cursor); + } + + private _updateViewportRectangle(): void { + this._addRectangleFloat( + this._vertices.attributes, + 0, + 0, + 0, + this._terminal.cols * this._dimensions.device.cell.width, + this._terminal.rows * this._dimensions.device.cell.height, + this._bgFloat + ); + } + + public updateBackgrounds(model: IRenderModel): void { + const terminal = this._terminal; + const vertices = this._vertices; + + let rectangleCount = 1; + let y: number; + let x: number; + let currentStartX: number; + let currentBg: number; + let currentFg: number; + let currentInverse: boolean; + let modelIndex: number; + let bg: number; + let fg: number; + let inverse: boolean; + let offset: number; + + for (y = 0; y < terminal.rows; y++) { + currentStartX = -1; + currentBg = 0; + currentFg = 0; + currentInverse = false; + for (x = 0; x < terminal.cols; x++) { + modelIndex = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; + bg = model.cells[modelIndex + RENDER_MODEL_BG_OFFSET]; + fg = model.cells[modelIndex + RENDER_MODEL_FG_OFFSET]; + inverse = !!(fg & FgFlags.INVERSE); + if (bg !== currentBg || (fg !== currentFg && (currentInverse || inverse))) { + if (currentBg !== 0 || (currentInverse && currentFg !== 0)) { + offset = rectangleCount++ * INDICES_PER_RECTANGLE; + this._updateRectangle(vertices, offset, currentFg, currentBg, currentStartX, x, y); + } + currentStartX = x; + currentBg = bg; + currentFg = fg; + currentInverse = inverse; + } + } + if (currentBg !== 0 || (currentInverse && currentFg !== 0)) { + offset = rectangleCount++ * INDICES_PER_RECTANGLE; + this._updateRectangle(vertices, offset, currentFg, currentBg, currentStartX, terminal.cols, y); + } + } + vertices.count = rectangleCount; + } + + public updateCursor(model: IRenderModel): void { + const vertices = this._verticesCursor; + const cursor = model.cursor; + if (!cursor || cursor.style === 'block') { + vertices.count = 0; + return; + } + + let offset: number; + let rectangleCount = 0; + + if (cursor.style === 'bar' || cursor.style === 'outline') { + offset = rectangleCount++ * INDICES_PER_RECTANGLE; + this._addRectangleFloat( + vertices.attributes, + offset, + cursor.x * this._dimensions.device.cell.width, + cursor.y * this._dimensions.device.cell.height, + cursor.style === 'bar' ? cursor.dpr * cursor.cursorWidth : cursor.dpr, + this._dimensions.device.cell.height, + this._cursorFloat + ); + } + if (cursor.style === 'underline' || cursor.style === 'outline') { + offset = rectangleCount++ * INDICES_PER_RECTANGLE; + this._addRectangleFloat( + vertices.attributes, + offset, + cursor.x * this._dimensions.device.cell.width, + (cursor.y + 1) * this._dimensions.device.cell.height - cursor.dpr, + cursor.width * this._dimensions.device.cell.width, + cursor.dpr, + this._cursorFloat + ); + } + if (cursor.style === 'outline') { + offset = rectangleCount++ * INDICES_PER_RECTANGLE; + this._addRectangleFloat( + vertices.attributes, + offset, + cursor.x * this._dimensions.device.cell.width, + cursor.y * this._dimensions.device.cell.height, + cursor.width * this._dimensions.device.cell.width, + cursor.dpr, + this._cursorFloat + ); + offset = rectangleCount++ * INDICES_PER_RECTANGLE; + this._addRectangleFloat( + vertices.attributes, + offset, + (cursor.x + cursor.width) * this._dimensions.device.cell.width - cursor.dpr, + cursor.y * this._dimensions.device.cell.height, + cursor.dpr, + this._dimensions.device.cell.height, + this._cursorFloat + ); + } + + vertices.count = rectangleCount; + } + + private _updateRectangle(vertices: Vertices, offset: number, fg: number, bg: number, startX: number, endX: number, y: number): void { + if (fg & FgFlags.INVERSE) { + switch (fg & Attributes.CM_MASK) { + case Attributes.CM_P16: + case Attributes.CM_P256: + $rgba = this._themeService.colors.ansi[fg & Attributes.PCOLOR_MASK].rgba; + break; + case Attributes.CM_RGB: + $rgba = (fg & Attributes.RGB_MASK) << 8; + break; + case Attributes.CM_DEFAULT: + default: + $rgba = this._themeService.colors.foreground.rgba; + } + } else { + switch (bg & Attributes.CM_MASK) { + case Attributes.CM_P16: + case Attributes.CM_P256: + $rgba = this._themeService.colors.ansi[bg & Attributes.PCOLOR_MASK].rgba; + break; + case Attributes.CM_RGB: + $rgba = (bg & Attributes.RGB_MASK) << 8; + break; + case Attributes.CM_DEFAULT: + default: + $rgba = this._themeService.colors.background.rgba; + } + } + + if (vertices.attributes.length < offset + 4) { + vertices.attributes = expandFloat32Array(vertices.attributes, this._terminal.rows * this._terminal.cols * INDICES_PER_RECTANGLE); + } + $x1 = startX * this._dimensions.device.cell.width; + $y1 = y * this._dimensions.device.cell.height; + $r = (($rgba >> 24) & 0xFF) / 255; + $g = (($rgba >> 16) & 0xFF) / 255; + $b = (($rgba >> 8 ) & 0xFF) / 255; + $a = 1; + + this._addRectangle(vertices.attributes, offset, $x1, $y1, (endX - startX) * this._dimensions.device.cell.width, this._dimensions.device.cell.height, $r, $g, $b, $a); + } + + private _addRectangle(array: Float32Array, offset: number, x1: number, y1: number, width: number, height: number, r: number, g: number, b: number, a: number): void { + array[offset ] = x1 / this._dimensions.device.canvas.width; + array[offset + 1] = y1 / this._dimensions.device.canvas.height; + array[offset + 2] = width / this._dimensions.device.canvas.width; + array[offset + 3] = height / this._dimensions.device.canvas.height; + array[offset + 4] = r; + array[offset + 5] = g; + array[offset + 6] = b; + array[offset + 7] = a; + } + + private _addRectangleFloat(array: Float32Array, offset: number, x1: number, y1: number, width: number, height: number, color: Float32Array): void { + array[offset ] = x1 / this._dimensions.device.canvas.width; + array[offset + 1] = y1 / this._dimensions.device.canvas.height; + array[offset + 2] = width / this._dimensions.device.canvas.width; + array[offset + 3] = height / this._dimensions.device.canvas.height; + array[offset + 4] = color[0]; + array[offset + 5] = color[1]; + array[offset + 6] = color[2]; + array[offset + 7] = color[3]; + } + + private _colorToFloat32Array(color: IColor): Float32Array { + return new Float32Array([ + ((color.rgba >> 24) & 0xFF) / 255, + ((color.rgba >> 16) & 0xFF) / 255, + ((color.rgba >> 8 ) & 0xFF) / 255, + ((color.rgba ) & 0xFF) / 255 + ]); + } + + private _ensureInstanceBuffer(requiredBytes: number): void { + if (this._instanceBufferSize >= requiredBytes) { + return; + } + this._instanceBuffer = this._device.createBuffer({ + size: requiredBytes, + usage: WebgpuBufferUsage.VERTEX | WebgpuBufferUsage.COPY_DST + }); + this._instanceBufferSize = requiredBytes; + } +} diff --git a/addons/addon-webgpu/src/WebgpuRenderer.ts b/addons/addon-webgpu/src/WebgpuRenderer.ts new file mode 100644 index 0000000000..6bdc5308c7 --- /dev/null +++ b/addons/addon-webgpu/src/WebgpuRenderer.ts @@ -0,0 +1,670 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ITerminal } from 'browser/Types'; +import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types'; +import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils'; +import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'browser/services/Services'; +import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; +import { Terminal } from '@xterm/xterm'; +import { Emitter, EventUtils } from 'common/Event'; +import { addDisposableListener } from 'browser/Dom'; +import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; +import { CharData, IBufferLine, ICellData } from 'common/Types'; +import { AttributeData } from 'common/buffer/AttributeData'; +import { CellData } from 'common/buffer/CellData'; +import { Attributes, Content, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; + +import { acquireTextureAtlas, removeTerminalFromCache } from '../../addon-webgl/src/CharAtlasCache'; +import { CellColorResolver } from '../../addon-webgl/src/CellColorResolver'; +import { CursorBlinkStateManager } from '../../addon-webgl/src/CursorBlinkStateManager'; +import { observeDevicePixelDimensions } from '../../addon-webgl/src/DevicePixelObserver'; +import { RenderModel, COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_EXT_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from '../../addon-webgl/src/RenderModel'; +import { LinkRenderLayer } from '../../addon-webgl/src/renderLayer/LinkRenderLayer'; +import { IRenderLayer } from '../../addon-webgl/src/renderLayer/Types'; +import type { ITextureAtlas } from '../../addon-webgl/src/Types'; +import { TextureAtlas } from '../../addon-webgl/src/TextureAtlas'; + +import type { IGPU, IGPUCanvasContext, IGPUDevice, IGPUTextureFormat } from './WebgpuTypes'; +import { WebgpuTextureUsage } from './WebgpuUtils'; +import { WebgpuGlyphRenderer } from './WebgpuGlyphRenderer'; +import { WebgpuRectangleRenderer } from './WebgpuRectangleRenderer'; + +export class WebgpuRenderer extends Disposable implements IRenderer { + private _renderLayers: IRenderLayer[]; + private _cursorBlinkStateManager: MutableDisposable = new MutableDisposable(); + private _charAtlasDisposable = this._register(new MutableDisposable()); + private _charAtlas: ITextureAtlas | undefined; + private _devicePixelRatio: number; + private _deviceMaxTextureSize: number = 4096; + private _observerDisposable = this._register(new MutableDisposable()); + private _maxAtlasPages: number = 0; + + private _model: RenderModel = new RenderModel(); + private _workCell: ICellData = new CellData(); + private _cellColorResolver: CellColorResolver; + + private readonly _terminal: Terminal; + private readonly _canvas: HTMLCanvasElement; + private readonly _context: IGPUCanvasContext; + private _device: IGPUDevice | undefined; + private _format: IGPUTextureFormat | undefined; + private _rectangleRenderer: MutableDisposable = this._register(new MutableDisposable()); + private _glyphRenderer: MutableDisposable = this._register(new MutableDisposable()); + private _isWebgpuStateInitialized: boolean = false; + + public readonly dimensions: IRenderDimensions; + + private _core: ITerminal; + private _isAttached: boolean; + + private readonly _onChangeTextureAtlas = this._register(new Emitter()); + public readonly onChangeTextureAtlas = this._onChangeTextureAtlas.event; + private readonly _onAddTextureAtlasCanvas = this._register(new Emitter()); + public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event; + private readonly _onRemoveTextureAtlasCanvas = this._register(new Emitter()); + public readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event; + private readonly _onRequestRedraw = this._register(new Emitter()); + public readonly onRequestRedraw = this._onRequestRedraw.event; + private readonly _onReady = this._register(new Emitter()); + public readonly onReady = this._onReady.event; + private readonly _onContextLoss = this._register(new Emitter()); + public readonly onContextLoss = this._onContextLoss.event; + + constructor( + terminal: Terminal, + private readonly _characterJoinerService: ICharacterJoinerService, + private readonly _charSizeService: ICharSizeService, + private readonly _coreBrowserService: ICoreBrowserService, + private readonly _coreService: ICoreService, + private readonly _decorationService: IDecorationService, + private readonly _optionsService: IOptionsService, + private readonly _themeService: IThemeService, + private readonly _customGlyphs: boolean = true, + private readonly _preserveDrawingBuffer?: boolean + ) { + super(); + + this._terminal = terminal; + this._core = (terminal as any)._core; + + this._canvas = this._coreBrowserService.mainDocument.createElement('canvas'); + this._canvas.classList.add('xterm-webgpu'); + this._canvas.style.position = 'absolute'; + this._canvas.style.top = '0'; + this._canvas.style.left = '0'; + this._canvas.style.pointerEvents = 'none'; + + const context = this._canvas.getContext('webgpu') as unknown as IGPUCanvasContext | null; + if (!context) { + throw new Error('WebGPU not supported'); + } + this._context = context; + + this._register(this._themeService.onChangeColors(() => this._handleColorChange())); + + this._cellColorResolver = new CellColorResolver(this._terminal, this._optionsService, this._model.selection, this._decorationService, this._coreBrowserService, this._themeService); + + this._renderLayers = [ + new LinkRenderLayer(this._core.screenElement!, 2, this._terminal, this._core.linkifier!, this._coreBrowserService, this._optionsService, this._themeService) + ]; + + this.dimensions = createRenderDimensions(); + this._devicePixelRatio = this._coreBrowserService.dpr; + this._updateDimensions(); + this._updateCursorBlink(); + this._register(this._optionsService.onOptionChange(() => this._handleOptionsChanged())); + + this._observerDisposable.value = observeDevicePixelDimensions(this._canvas, this._coreBrowserService.window, (w, h) => this._setCanvasDevicePixelDimensions(w, h)); + this._register(this._coreBrowserService.onWindowChange(w => { + this._observerDisposable.value = observeDevicePixelDimensions(this._canvas, w, (width, height) => this._setCanvasDevicePixelDimensions(width, height)); + })); + + this._register(addDisposableListener(this._coreBrowserService.mainDocument, 'mousedown', () => this._cursorBlinkStateManager.value?.restartBlinkAnimation())); + + this._core.screenElement!.appendChild(this._canvas); + this._syncCanvasDimensions(); + + this._isAttached = this._core.screenElement!.isConnected; + + void this._initializeWebgpu(); + + this._register(toDisposable(() => { + for (const l of this._renderLayers) { + l.dispose(); + } + this._canvas.parentElement?.removeChild(this._canvas); + removeTerminalFromCache(this._terminal); + this._device?.destroy?.(); + })); + } + + public get textureAtlas(): HTMLCanvasElement | undefined { + return this._charAtlas?.pages[0].canvas; + } + + public clearTextureAtlas(): void { + this._charAtlas?.clearTexture(); + this._clearModel(true); + this._requestRedrawViewport(); + } + + public handleDevicePixelRatioChange(): void { + if (this._devicePixelRatio === this._coreBrowserService.dpr) { + return; + } + this._devicePixelRatio = this._coreBrowserService.dpr; + this.handleResize(this._terminal.cols, this._terminal.rows); + } + + public handleResize(_cols: number, _rows: number): void { + this._updateDimensions(); + this._model.resize(this._terminal.cols, this._terminal.rows); + + for (const l of this._renderLayers) { + l.resize(this._terminal, this.dimensions); + } + + this._syncCanvasDimensions(); + + this._rectangleRenderer.value?.setDimensions(this.dimensions); + this._rectangleRenderer.value?.handleResize(); + this._glyphRenderer.value?.setDimensions(this.dimensions); + this._glyphRenderer.value?.handleResize(); + + this._refreshCharAtlas(); + + this._clearModel(false); + + this._onRequestRedraw.fire({ start: 0, end: this._terminal.rows - 1, sync: true }); + } + + public handleCharSizeChanged(): void { + this.handleResize(this._terminal.cols, this._terminal.rows); + } + + public handleBlur(): void { + for (const l of this._renderLayers) { + l.handleBlur(this._terminal); + } + this._cursorBlinkStateManager.value?.pause(); + this._requestRedrawViewport(); + } + + public handleFocus(): void { + for (const l of this._renderLayers) { + l.handleFocus(this._terminal); + } + this._cursorBlinkStateManager.value?.resume(); + this._requestRedrawViewport(); + } + + public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void { + for (const l of this._renderLayers) { + l.handleSelectionChanged(this._terminal, start, end, columnSelectMode); + } + this._model.selection.update(this._core, start, end, columnSelectMode); + this._requestRedrawViewport(); + } + + public handleCursorMove(): void { + for (const l of this._renderLayers) { + l.handleCursorMove(this._terminal); + } + this._cursorBlinkStateManager.value?.restartBlinkAnimation(); + } + + public clear(): void { + this._clearModel(true); + for (const l of this._renderLayers) { + l.reset(this._terminal); + } + + this._cursorBlinkStateManager.value?.restartBlinkAnimation(); + this._updateCursorBlink(); + } + + public renderRows(start: number, end: number): void { + if (!this._glyphRenderer.value || !this._rectangleRenderer.value) { + return; + } + + if (!this._isAttached) { + if (this._core.screenElement?.isConnected && this._charSizeService.width && this._charSizeService.height) { + this._updateDimensions(); + this._refreshCharAtlas(); + this._isAttached = true; + } else { + return; + } + } + + for (const l of this._renderLayers) { + l.handleGridChanged(this._terminal, start, end); + } + + if (this._glyphRenderer.value.beginFrame()) { + this._clearModel(true); + this._updateModel(0, this._terminal.rows - 1); + } else { + this._updateModel(start, end); + } + + this._renderFrame(); + } + + private _handleOptionsChanged(): void { + this._updateDimensions(); + this._refreshCharAtlas(); + this._updateCursorBlink(); + } + + private _handleColorChange(): void { + this._refreshCharAtlas(); + this._clearModel(true); + } + + private _initializeWebgpuState(): void { + if (!this._device || !this._format) { + return; + } + + const maxSampled = this._device.limits?.maxSampledTexturesPerShaderStage ?? 8; + const maxPages = Math.max(1, Math.min(32, maxSampled)); + TextureAtlas.maxAtlasPages = TextureAtlas.maxAtlasPages === undefined + ? maxPages + : Math.min(TextureAtlas.maxAtlasPages, maxPages); + this._maxAtlasPages = TextureAtlas.maxAtlasPages; + + const maxTextureSize = this._device.limits?.maxTextureDimension2D ?? 4096; + TextureAtlas.maxTextureSize = TextureAtlas.maxTextureSize === undefined + ? maxTextureSize + : Math.min(TextureAtlas.maxTextureSize, maxTextureSize); + this._deviceMaxTextureSize = TextureAtlas.maxTextureSize; + + this._rectangleRenderer.value = new WebgpuRectangleRenderer(this._terminal, this._device, this._format, this.dimensions, this._themeService); + this._glyphRenderer.value = new WebgpuGlyphRenderer(this._terminal, this._device, this._format, this.dimensions, this._optionsService, this._maxAtlasPages); + this.handleCharSizeChanged(); + this._onReady.fire(); + } + + private async _initializeWebgpu(): Promise { + const gpu = (navigator as Navigator & { gpu?: IGPU }).gpu; + const adapter = await gpu?.requestAdapter(); + if (!adapter) { + this._onContextLoss.fire(); + return; + } + this._device = await adapter.requestDevice(); + if (!gpu) { + this._onContextLoss.fire(); + return; + } + this._format = gpu.getPreferredCanvasFormat(); + this._tryConfigureContext(); + + void this._device.lost.then(() => this._onContextLoss.fire()); + + this._maybeInitializeWebgpuState(); + } + + private _renderFrame(): void { + if (!this._device || !this._format || !this._glyphRenderer.value || !this._rectangleRenderer.value) { + return; + } + + const loadOp = this._preserveDrawingBuffer ? 'load' : 'clear'; + + const commandEncoder = this._device.createCommandEncoder(); + const pass = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: this._context.getCurrentTexture().createView(), + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + loadOp, + storeOp: 'store' + } + ] + }); + + this._rectangleRenderer.value.renderBackgrounds(pass); + this._glyphRenderer.value.render(pass, this._model); + if (!this._cursorBlinkStateManager.value || this._cursorBlinkStateManager.value.isCursorVisible) { + this._rectangleRenderer.value.renderCursor(pass); + } + + pass.end(); + this._device.queue.submit([commandEncoder.finish()]); + } + + private _syncCanvasDimensions(): void { + this._canvas.width = this.dimensions.device.canvas.width; + this._canvas.height = this.dimensions.device.canvas.height; + this._canvas.style.width = `${this.dimensions.css.canvas.width}px`; + this._canvas.style.height = `${this.dimensions.css.canvas.height}px`; + this._core.screenElement!.style.width = `${this.dimensions.css.canvas.width}px`; + this._core.screenElement!.style.height = `${this.dimensions.css.canvas.height}px`; + this._tryConfigureContext(); + this._maybeInitializeWebgpuState(); + } + + private _updateDimensions(): void { + if (!this._charSizeService.width || !this._charSizeService.height) { + return; + } + + this.dimensions.device.char.width = Math.floor(this._charSizeService.width * this._devicePixelRatio); + this.dimensions.device.char.height = Math.ceil(this._charSizeService.height * this._devicePixelRatio); + this.dimensions.device.cell.height = Math.floor(this.dimensions.device.char.height * this._optionsService.rawOptions.lineHeight); + this.dimensions.device.char.top = this._optionsService.rawOptions.lineHeight === 1 ? 0 : Math.round((this.dimensions.device.cell.height - this.dimensions.device.char.height) / 2); + this.dimensions.device.cell.width = this.dimensions.device.char.width + Math.round(this._optionsService.rawOptions.letterSpacing); + this.dimensions.device.char.left = Math.floor(this._optionsService.rawOptions.letterSpacing / 2); + + this.dimensions.device.canvas.height = this._terminal.rows * this.dimensions.device.cell.height; + this.dimensions.device.canvas.width = this._terminal.cols * this.dimensions.device.cell.width; + + this.dimensions.css.canvas.height = Math.round(this.dimensions.device.canvas.height / this._devicePixelRatio); + this.dimensions.css.canvas.width = Math.round(this.dimensions.device.canvas.width / this._devicePixelRatio); + + this.dimensions.css.cell.height = this.dimensions.device.cell.height / this._devicePixelRatio; + this.dimensions.css.cell.width = this.dimensions.device.cell.width / this._devicePixelRatio; + } + + private _setCanvasDevicePixelDimensions(width: number, height: number): void { + if (this._canvas.width === width && this._canvas.height === height) { + return; + } + this._canvas.width = width; + this._canvas.height = height; + this._tryConfigureContext(); + this._onRequestRedraw.fire({ start: 0, end: this._terminal.rows - 1, sync: true }); + } + + private _tryConfigureContext(): void { + if (!this._device || !this._format) { + return; + } + if (this._canvas.width === 0 || this._canvas.height === 0) { + return; + } + this._context.configure({ + device: this._device, + format: this._format, + alphaMode: 'premultiplied', + usage: WebgpuTextureUsage.RENDER_ATTACHMENT | WebgpuTextureUsage.COPY_DST + }); + } + + private _maybeInitializeWebgpuState(): void { + if (this._isWebgpuStateInitialized || !this._device || !this._format) { + return; + } + this._isWebgpuStateInitialized = true; + this._initializeWebgpuState(); + } + + private _updateCursorBlink(): void { + if (this._coreService.decPrivateModes.cursorBlink ?? this._terminal.options.cursorBlink) { + this._cursorBlinkStateManager.value = new CursorBlinkStateManager(() => { + this._requestRedrawCursor(); + }, this._coreBrowserService); + } else { + this._cursorBlinkStateManager.clear(); + } + this._requestRedrawCursor(); + } + + private _refreshCharAtlas(): void { + if (this.dimensions.device.char.width <= 0 && this.dimensions.device.char.height <= 0) { + this._isAttached = false; + return; + } + + const atlas = acquireTextureAtlas( + this._terminal, + this._optionsService.rawOptions, + this._themeService.colors, + this.dimensions.device.cell.width, + this.dimensions.device.cell.height, + this.dimensions.device.char.width, + this.dimensions.device.char.height, + this._coreBrowserService.dpr, + this._deviceMaxTextureSize, + this._customGlyphs + ); + if (this._charAtlas !== atlas) { + this._onChangeTextureAtlas.fire(atlas.pages[0].canvas); + this._charAtlasDisposable.value = combinedDisposable( + EventUtils.forward(atlas.onAddTextureAtlasCanvas, this._onAddTextureAtlasCanvas), + EventUtils.forward(atlas.onRemoveTextureAtlasCanvas, this._onRemoveTextureAtlasCanvas) + ); + } + this._charAtlas = atlas; + this._charAtlas.warmUp(); + this._glyphRenderer.value?.setAtlas(this._charAtlas); + } + + private _clearModel(clearGlyphRenderer: boolean): void { + this._model.clear(); + if (clearGlyphRenderer) { + this._glyphRenderer.value?.clear(); + } + } + + private _requestRedrawViewport(): void { + this._onRequestRedraw.fire({ start: 0, end: this._terminal.rows - 1 }); + } + + private _requestRedrawCursor(): void { + const cursorY = this._terminal.buffer.active.cursorY; + this._onRequestRedraw.fire({ start: cursorY, end: cursorY }); + } + + private _updateModel(start: number, end: number): void { + const terminal = this._core; + let cell: ICellData = this._workCell; + + let lastBg: number; + let y: number; + let row: number; + let line: IBufferLine; + let joinedRanges: [number, number][]; + let isJoined: boolean; + let skipJoinedCheckUntilX: number = 0; + let isValidJoinRange: boolean = true; + let lastCharX: number; + let range: [number, number]; + let isCursorRow: boolean; + let chars: string; + let code: number; + let width: number; + let i: number; + let x: number; + let j: number; + start = clamp(start, terminal.rows - 1, 0); + end = clamp(end, terminal.rows - 1, 0); + const cursorStyle = this._coreService.decPrivateModes.cursorStyle ?? terminal.options.cursorStyle ?? 'block'; + + const cursorY = this._terminal.buffer.active.baseY + this._terminal.buffer.active.cursorY; + const viewportRelativeCursorY = cursorY - terminal.buffer.ydisp; + const cursorX = Math.min(this._terminal.buffer.active.cursorX, terminal.cols - 1); + let lastCursorX = -1; + const isCursorVisible = + this._coreService.isCursorInitialized && + !this._coreService.isCursorHidden && + (!this._cursorBlinkStateManager.value || this._cursorBlinkStateManager.value.isCursorVisible); + this._model.cursor = undefined; + let modelUpdated = false; + + for (y = start; y <= end; y++) { + row = y + terminal.buffer.ydisp; + line = terminal.buffer.lines.get(row)!; + this._model.lineLengths[y] = 0; + isCursorRow = cursorY === row; + skipJoinedCheckUntilX = 0; + joinedRanges = this._characterJoinerService.getJoinedCharacters(row); + for (x = 0; x < terminal.cols; x++) { + lastBg = this._cellColorResolver.result.bg; + line.loadCell(x, cell); + + if (x === 0) { + lastBg = this._cellColorResolver.result.bg; + } + + isJoined = false; + isValidJoinRange = (x >= skipJoinedCheckUntilX); + + lastCharX = x; + + if (joinedRanges.length > 0 && x === joinedRanges[0][0] && isValidJoinRange) { + range = joinedRanges.shift()!; + + const firstSelectionState = this._model.selection.isCellSelected(this._terminal, range[0], row); + for (i = range[0] + 1; i < range[1]; i++) { + isValidJoinRange &&= (firstSelectionState === this._model.selection.isCellSelected(this._terminal, i, row)); + } + isValidJoinRange &&= !isCursorRow || cursorX < range[0] || cursorX >= range[1]; + if (!isValidJoinRange) { + skipJoinedCheckUntilX = range[1]; + } else { + isJoined = true; + + cell = new JoinedCellData( + cell, + line!.translateToString(true, range[0], range[1]), + range[1] - range[0] + ); + + lastCharX = range[1] - 1; + } + } + + chars = cell.getChars(); + code = cell.getCode(); + i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; + + this._cellColorResolver.resolve( + cell, + x, + row, + this.dimensions.device.cell.width, + this.dimensions.device.cell.height + ); + + if (isCursorVisible && row === cursorY) { + if (x === cursorX) { + this._model.cursor = { + x: cursorX, + y: viewportRelativeCursorY, + width: cell.getWidth(), + style: this._coreBrowserService.isFocused ? cursorStyle : terminal.options.cursorInactiveStyle, + cursorWidth: terminal.options.cursorWidth, + dpr: this._devicePixelRatio + }; + lastCursorX = cursorX + cell.getWidth() - 1; + } + if (x >= cursorX && x <= lastCursorX && + ((this._coreBrowserService.isFocused && + cursorStyle === 'block') || + (this._coreBrowserService.isFocused === false && + terminal.options.cursorInactiveStyle === 'block')) + ) { + this._cellColorResolver.result.fg = + Attributes.CM_RGB | (this._themeService.colors.cursorAccent.rgba >> 8 & Attributes.RGB_MASK); + this._cellColorResolver.result.bg = + Attributes.CM_RGB | (this._themeService.colors.cursor.rgba >> 8 & Attributes.RGB_MASK); + } + } + + if (code !== NULL_CELL_CODE) { + this._model.lineLengths[y] = x + 1; + } + + if (this._model.cells[i] === code && + this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._cellColorResolver.result.bg && + this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._cellColorResolver.result.fg && + this._model.cells[i + RENDER_MODEL_EXT_OFFSET] === this._cellColorResolver.result.ext) { + continue; + } + + modelUpdated = true; + + if (chars.length > 1) { + code |= COMBINED_CHAR_BIT_MASK; + } + + this._model.cells[i] = code; + this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg; + this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg; + this._model.cells[i + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext; + + width = cell.getWidth(); + this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, width, lastBg); + + if (isJoined) { + cell = this._workCell; + + for (x++; x <= lastCharX; x++) { + j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; + this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0); + this._model.cells[j] = NULL_CELL_CODE; + this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg; + this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg; + this._model.cells[j + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext; + } + x--; + } + } + } + if (modelUpdated) { + this._rectangleRenderer.value!.updateBackgrounds(this._model); + } + this._rectangleRenderer.value!.updateCursor(this._model); + } +} + +class JoinedCellData extends AttributeData implements ICellData { + private _width: number; + public content: number = 0; + public fg: number; + public bg: number; + public combinedData: string = ''; + + constructor(firstCell: ICellData, chars: string, width: number) { + super(); + this.fg = firstCell.fg; + this.bg = firstCell.bg; + this.combinedData = chars; + this._width = width; + } + + public isCombined(): number { + return Content.IS_COMBINED_MASK; + } + + public getWidth(): number { + return this._width; + } + + public getChars(): string { + return this.combinedData; + } + + public getCode(): number { + return 0x1FFFFF; + } + + public setFromCharData(value: CharData): void { + throw new Error('not implemented'); + } + + public getAsCharData(): CharData { + return [this.fg, this.getChars(), this.getWidth(), this.getCode()]; + } +} + +function clamp(value: number, max: number, min: number = 0): number { + return Math.max(Math.min(value, max), min); +} diff --git a/addons/addon-webgpu/src/WebgpuTypes.ts b/addons/addon-webgpu/src/WebgpuTypes.ts new file mode 100644 index 0000000000..4300b1b561 --- /dev/null +++ b/addons/addon-webgpu/src/WebgpuTypes.ts @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export type IGPUTextureFormat = string; +export type IGPUBufferUsage = number; +export type IGPUTextureUsage = number; +export type IGPUShaderStage = number; +export type IGPUBufferSource = ArrayBuffer | ArrayBufferView | ArrayBufferLike; + +export interface IGPUOrigin2D { + x?: number; + y?: number; +} + +export interface IGPUOrigin3D { + x?: number; + y?: number; + z?: number; +} + +export interface IGPUExtent3D { + width: number; + height: number; + depthOrArrayLayers?: number; +} + +export interface IGPUTextureViewDescriptor { + format?: IGPUTextureFormat; + dimension?: '2d'; + baseMipLevel?: number; + mipLevelCount?: number; + baseArrayLayer?: number; + arrayLayerCount?: number; +} + +export interface IGPUTextureView { +} + +export interface IGPUTexture { + createView(descriptor?: IGPUTextureViewDescriptor): IGPUTextureView; + destroy?(): void; +} + +export interface IGPUBuffer { + size: number; + destroy?(): void; +} + +export interface IGPUBufferDescriptor { + size: number; + usage: IGPUBufferUsage; + mappedAtCreation?: boolean; +} + +export interface IGPUTextureDescriptor { + size: IGPUExtent3D; + format: IGPUTextureFormat; + usage: IGPUTextureUsage; + dimension?: '2d'; + mipLevelCount?: number; + sampleCount?: number; +} + +export interface IGPUSamplerDescriptor { + minFilter?: 'nearest' | 'linear'; + magFilter?: 'nearest' | 'linear'; + mipmapFilter?: 'nearest' | 'linear'; + addressModeU?: 'clamp-to-edge' | 'repeat' | 'mirror-repeat'; + addressModeV?: 'clamp-to-edge' | 'repeat' | 'mirror-repeat'; +} + +export interface IGPUShaderModuleDescriptor { + code: string; +} + +export interface IGPUVertexAttribute { + format: string; + offset: number; + shaderLocation: number; +} + +export interface IGPUVertexBufferLayout { + arrayStride: number; + stepMode?: 'vertex' | 'instance'; + attributes: IGPUVertexAttribute[]; +} + +export interface IGPUVertexState { + module: IGPUShaderModule; + entryPoint: string; + buffers?: IGPUVertexBufferLayout[]; +} + +export interface IGPUColorTargetState { + format: IGPUTextureFormat; + blend?: { + color?: { srcFactor: string, dstFactor: string, operation: string }; + alpha?: { srcFactor: string, dstFactor: string, operation: string }; + }; + writeMask?: number; +} + +export interface IGPUFragmentState { + module: IGPUShaderModule; + entryPoint: string; + targets: IGPUColorTargetState[]; +} + +export interface IGPUPrimitiveState { + topology?: 'triangle-strip' | 'triangle-list'; + stripIndexFormat?: 'uint16' | 'uint32'; + cullMode?: 'none' | 'front' | 'back'; +} + +export interface IGPURenderPipelineDescriptor { + layout?: IGPUPipelineLayout | 'auto'; + vertex: IGPUVertexState; + fragment?: IGPUFragmentState; + primitive?: IGPUPrimitiveState; +} + +export interface IGPURenderPipeline { +} + +export interface IGPUSampler { +} + +export interface IGPUShaderModule { +} + +export interface IGPUBindGroup { +} + +export interface IGPUBindGroupLayout { +} + +export interface IGPUPipelineLayout { +} + +export interface IGPUBindGroupLayoutEntry { + binding: number; + visibility: IGPUShaderStage; + buffer?: { type?: 'uniform' | 'storage' | 'read-only-storage', hasDynamicOffset?: boolean, minBindingSize?: number }; + sampler?: { type?: 'filtering' | 'non-filtering' }; + texture?: { sampleType?: 'float' | 'unfilterable-float', viewDimension?: '2d', multisampled?: boolean }; + count?: number; +} + +export interface IGPUBindGroupLayoutDescriptor { + entries: IGPUBindGroupLayoutEntry[]; +} + +export interface IGPUBindGroupEntry { + binding: number; + resource: IGPUBufferBinding | IGPUSampler | IGPUTextureView | IGPUTextureView[]; +} + +export interface IGPUBindGroupDescriptor { + layout: IGPUBindGroupLayout; + entries: IGPUBindGroupEntry[]; +} + +export interface IGPUBufferBinding { + buffer: IGPUBuffer; + offset?: number; + size?: number; +} + +export interface IGPUPipelineLayoutDescriptor { + bindGroupLayouts: IGPUBindGroupLayout[]; +} + +export interface IGPUImageCopyExternalImage { + source: CanvasImageSource; + origin?: IGPUOrigin2D; + flipY?: boolean; +} + +export interface IGPUImageCopyTexture { + texture: IGPUTexture; + origin?: IGPUOrigin3D; +} + +export interface IGPUTextureDataLayout { + offset?: number; + bytesPerRow: number; + rowsPerImage?: number; +} + +export interface IGPUCanvasConfiguration { + device: IGPUDevice; + format: IGPUTextureFormat; + alphaMode?: 'opaque' | 'premultiplied'; + usage?: IGPUTextureUsage; + viewFormats?: IGPUTextureFormat[]; +} + +export interface IGPUCanvasContext { + configure(config: IGPUCanvasConfiguration): void; + getCurrentTexture(): IGPUTexture; +} + +export interface IGPUQueue { + submit(commandBuffers: IGPUCommandBuffer[]): void; + writeBuffer(buffer: IGPUBuffer, bufferOffset: number, data: IGPUBufferSource, dataOffset?: number, size?: number): void; + writeTexture(destination: IGPUImageCopyTexture, data: IGPUBufferSource, dataLayout: IGPUTextureDataLayout, size: IGPUExtent3D): void; + copyExternalImageToTexture(source: IGPUImageCopyExternalImage, destination: IGPUImageCopyTexture, copySize: IGPUExtent3D): void; +} + +export interface IGPURenderPassEncoder { + setPipeline(pipeline: IGPURenderPipeline): void; + setBindGroup(index: number, bindGroup: IGPUBindGroup): void; + setVertexBuffer(slot: number, buffer: IGPUBuffer): void; + draw(vertexCount: number, instanceCount?: number, firstVertex?: number, firstInstance?: number): void; + end(): void; +} + +export interface IGPUCommandBuffer { +} + +export interface IGPURenderPassColorAttachment { + view: IGPUTextureView; + clearValue?: { r: number, g: number, b: number, a: number }; + loadOp: 'load' | 'clear'; + storeOp: 'store' | 'discard'; +} + +export interface IGPURenderPassDescriptor { + colorAttachments: IGPURenderPassColorAttachment[]; +} + +export interface IGPUCommandEncoder { + beginRenderPass(descriptor: IGPURenderPassDescriptor): IGPURenderPassEncoder; + finish(): IGPUCommandBuffer; +} + +export interface IGPUDevice { + queue: IGPUQueue; + lost: Promise; + limits?: { maxSampledTexturesPerShaderStage?: number, maxTextureDimension2D?: number }; + createCommandEncoder(): IGPUCommandEncoder; + createBuffer(descriptor: IGPUBufferDescriptor): IGPUBuffer; + createTexture(descriptor: IGPUTextureDescriptor): IGPUTexture; + createSampler(descriptor?: IGPUSamplerDescriptor): IGPUSampler; + createShaderModule(descriptor: IGPUShaderModuleDescriptor): IGPUShaderModule; + createRenderPipeline(descriptor: IGPURenderPipelineDescriptor): IGPURenderPipeline; + createBindGroupLayout(descriptor: IGPUBindGroupLayoutDescriptor): IGPUBindGroupLayout; + createPipelineLayout(descriptor: IGPUPipelineLayoutDescriptor): IGPUPipelineLayout; + createBindGroup(descriptor: IGPUBindGroupDescriptor): IGPUBindGroup; + destroy?(): void; +} + +export interface IGPUAdapter { + requestDevice(): Promise; +} + +export interface IGPU { + requestAdapter(): Promise; + getPreferredCanvasFormat(): IGPUTextureFormat; +} diff --git a/addons/addon-webgpu/src/WebgpuUtils.ts b/addons/addon-webgpu/src/WebgpuUtils.ts new file mode 100644 index 0000000000..3bf557a602 --- /dev/null +++ b/addons/addon-webgpu/src/WebgpuUtils.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export const enum WebgpuBufferUsage { + MAP_READ = 0x0001, + MAP_WRITE = 0x0002, + COPY_SRC = 0x0004, + COPY_DST = 0x0008, + INDEX = 0x0010, + VERTEX = 0x0020, + UNIFORM = 0x0040, + STORAGE = 0x0080, + INDIRECT = 0x0100, + QUERY_RESOLVE = 0x0200 +} + +export const enum WebgpuTextureUsage { + COPY_SRC = 0x01, + COPY_DST = 0x02, + TEXTURE_BINDING = 0x04, + STORAGE_BINDING = 0x08, + RENDER_ATTACHMENT = 0x10 +} + +export const enum WebgpuShaderStage { + VERTEX = 0x1, + FRAGMENT = 0x2, + COMPUTE = 0x4 +} + +export const enum WebgpuColorWriteMask { + ALL = 0xF +} diff --git a/addons/addon-webgpu/src/tsconfig.json b/addons/addon-webgpu/src/tsconfig.json new file mode 100644 index 0000000000..257287729f --- /dev/null +++ b/addons/addon-webgpu/src/tsconfig.json @@ -0,0 +1,44 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2021", + "lib": [ + "dom", + "es2021" + ], + "rootDir": "../..", + "outDir": "../out", + "moduleResolution": "node", + "sourceMap": true, + "removeComments": true, + "paths": { + "common/*": [ + "../../../src/common/*" + ], + "browser/*": [ + "../../../src/browser/*" + ], + "@xterm/addon-webgpu": [ + "../typings/addon-webgpu.d.ts" + ] + }, + "strict": true, + "downlevelIteration": true, + "experimentalDecorators": true, + "types": [ + "../../../node_modules/@types/mocha" + ] + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ], + "references": [ + { + "path": "../../../src/common" + }, + { + "path": "../../../src/browser" + } + ] +} diff --git a/addons/addon-webgpu/test/WebgpuRenderer.test.ts b/addons/addon-webgpu/test/WebgpuRenderer.test.ts new file mode 100644 index 0000000000..83de9bfefc --- /dev/null +++ b/addons/addon-webgpu/test/WebgpuRenderer.test.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import test from '@playwright/test'; +import { ISharedRendererTestContext, injectSharedRendererTests, injectSharedRendererTestsStandalone } from '../../../test/playwright/SharedRendererTests'; +import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils'; +import { platform } from 'os'; + +let ctx: ITestContext; +let shouldSkip = false; +const ctxWrapper: ISharedRendererTestContext = { value: undefined } as any; + +test.beforeAll(async ({ browser }) => { + ctx = await createTestContext(browser); + await openTerminal(ctx); + shouldSkip = !(await ctx.page.evaluate(async () => { + const gpu = (navigator as Navigator & { gpu?: { requestAdapter?: () => Promise } }).gpu; + if (!gpu?.requestAdapter) { + return false; + } + const adapter = await gpu.requestAdapter(); + return !!adapter; + })); + if (shouldSkip) { + return; + } + ctxWrapper.value = ctx; + await ctx.page.evaluate(` + window.addon = new window.WebgpuAddon(true); + window.term.loadAddon(window.addon); + `); +}); + +test.afterAll(async () => await ctx.page.close()); + +test.describe('WebGPU Renderer Integration Tests', async () => { + test.beforeEach(() => { + if (shouldSkip) { + test.skip(true, 'WebGPU not supported'); + } + }); + + // HACK: webgpu is not supported in headless firefox on Linux + if (platform() === 'linux') { + test.skip(({ browserName }) => browserName === 'firefox'); + } + + injectSharedRendererTests(ctxWrapper); + injectSharedRendererTestsStandalone(ctxWrapper, async () => { + await ctx.page.evaluate(` + window.addon = new window.WebgpuAddon(true); + window.term.loadAddon(window.addon); + `); + }); +}); diff --git a/addons/addon-webgpu/test/playwright.config.ts b/addons/addon-webgpu/test/playwright.config.ts new file mode 100644 index 0000000000..22834be116 --- /dev/null +++ b/addons/addon-webgpu/test/playwright.config.ts @@ -0,0 +1,35 @@ +import { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + testDir: '.', + timeout: 10000, + projects: [ + { + name: 'ChromeStable', + use: { + browserName: 'chromium', + channel: 'chrome' + } + }, + { + name: 'FirefoxStable', + use: { + browserName: 'firefox' + } + }, + { + name: 'WebKit', + use: { + browserName: 'webkit' + } + } + ], + reporter: 'list', + webServer: { + command: 'npm run start', + port: 3000, + timeout: 120000, + reuseExistingServer: !process.env.CI + } +}; +export default config; diff --git a/addons/addon-webgpu/test/tsconfig.json b/addons/addon-webgpu/test/tsconfig.json new file mode 100644 index 0000000000..5e7bfecc30 --- /dev/null +++ b/addons/addon-webgpu/test/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ESNext", + "lib": [ + "dom", + "es2021" + ], + "rootDir": ".", + "outDir": "../out-test", + "sourceMap": true, + "removeComments": true, + "paths": { + "common/*": [ + "../../../src/common/*" + ], + "browser/*": [ + "../../../src/browser/*" + ] + }, + "strict": true, + "types": [ + "../../../node_modules/@types/node", + "../../../node_modules/@lunapaint/png-codec" + ] + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ], + "references": [ + { + "path": "../../../src/common" + }, + { + "path": "../../../src/browser" + }, + { + "path": "../../../test/playwright" + } + ] +} diff --git a/addons/addon-webgpu/tsconfig.json b/addons/addon-webgpu/tsconfig.json new file mode 100644 index 0000000000..2d820dd1a6 --- /dev/null +++ b/addons/addon-webgpu/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "include": [], + "references": [ + { "path": "./src" }, + { "path": "./test" } + ] +} diff --git a/addons/addon-webgpu/typings/addon-webgpu.d.ts b/addons/addon-webgpu/typings/addon-webgpu.d.ts new file mode 100644 index 0000000000..cee6a7d62e --- /dev/null +++ b/addons/addon-webgpu/typings/addon-webgpu.d.ts @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm'; + +declare module '@xterm/addon-webgpu' { + /** + * An xterm.js addon that provides hardware-accelerated rendering functionality via WebGPU. + */ + export class WebgpuAddon implements ITerminalAddon { + public textureAtlas?: HTMLCanvasElement; + + /** + * An event that is fired when the renderer loses its canvas context. + */ + public readonly onContextLoss: IEvent; + + /** + * An event that is fired when the texture atlas of the renderer changes. + */ + public readonly onChangeTextureAtlas: IEvent; + + /** + * An event that is fired when the a new page is added to the texture atlas. + */ + public readonly onAddTextureAtlasCanvas: IEvent; + + /** + * An event that is fired when the a page is removed from the texture atlas. + */ + public readonly onRemoveTextureAtlasCanvas: IEvent; + + constructor(options?: IWebgpuAddonOptions); + + /** + * Activates the addon. + * @param terminal The terminal the addon is being loaded in. + */ + public activate(terminal: Terminal): void; + + /** + * Disposes the addon. + */ + public dispose(): void; + + /** + * Clears the terminal's texture atlas and triggers a redraw. + */ + public clearTextureAtlas(): void; + } + + export interface IWebgpuAddonOptions { + /** + * Whether to draw custom glyphs instead of using the font for the following + * unicode ranges: + * + * - Box Drawing (U+2500-U+257F) + * - Block Elements (U+2580-U+259F) + * - Braille Patterns (U+2800-U+28FF) + * - Powerline Symbols (U+E0A0-U+E0D4, Private Use Area with widespread + * adoption) + * - Progress Indicators (U+EE00-U+EE0B, Private Use Area initially added in + * [Fira Code](https://github.com/tonsky/FiraCode) and later + * [Nerd Fonts](https://github.com/ryanoasis/nerd-fonts/pull/1733)). + * - Git Branch Symbols (U+F5D0-U+F60D, Private Use Area initially adopted + * in [Kitty in 2024](https://github.com/kovidgoyal/kitty/pull/7681) by + * author of [vim-flog](https://github.com/rbong/vim-flog)) + * - Symbols for Legacy Computing (U+1FB00-U+1FBFF) + * + * This will typically result in better rendering with continuous lines, + * even when line height and letter spacing is used. The default is true. + */ + customGlyphs?: boolean; + + /** + * Whether to enable the preserveDrawingBuffer flag when creating the + * rendering context. This may be useful in tests. This default is false. + */ + preserveDrawingBuffer?: boolean + } +} diff --git a/addons/addon-webgpu/webpack.config.js b/addons/addon-webgpu/webpack.config.js new file mode 100644 index 0000000000..8e3854b895 --- /dev/null +++ b/addons/addon-webgpu/webpack.config.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +const path = require('path'); + +const addonName = 'WebgpuAddon'; +const mainFile = 'addon-webgpu.js'; + +module.exports = { + entry: `./out/${addonName}.js`, + devtool: 'source-map', + module: { + rules: [ + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + resolve: { + modules: ['./node_modules'], + extensions: [ '.js' ], + alias: { + common: path.resolve('../../out/common'), + browser: path.resolve('../../out/browser'), + vs: path.resolve('../../out/vs') + } + }, + output: { + filename: mainFile, + path: path.resolve('./lib'), + library: addonName, + libraryTarget: 'umd', + // Force usage of globalThis instead of global / self. (This is cross-env compatible) + globalObject: 'globalThis', + }, + mode: 'production' +}; diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 9e5f2d2329..4facd39aec 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -144,6 +144,7 @@ if (config.addon) { "@xterm/addon-web-fonts": "./addons/addon-web-fonts/lib/addon-web-fonts.mjs", "@xterm/addon-web-links": "./addons/addon-web-links/lib/addon-web-links.mjs", "@xterm/addon-webgl": "./addons/addon-webgl/lib/addon-webgl.mjs", + "@xterm/addon-webgpu": "./addons/addon-webgpu/src/WebgpuAddon.ts", "@xterm/addon-unicode11": "./addons/addon-unicode11/lib/addon-unicode11.mjs", "@xterm/addon-unicode-graphemes": "./addons/addon-unicode-graphemes/lib/addon-unicode-graphemes.mjs", diff --git a/bin/test_integration.js b/bin/test_integration.js index 37e6aca7c5..b08071b6fd 100644 --- a/bin/test_integration.js +++ b/bin/test_integration.js @@ -33,6 +33,7 @@ const addons = [ 'web-fonts', 'web-links', 'webgl', + 'webgpu', ]; for (const addon of addons) { configs.push({ name: `addon-${addon}`, path: `addons/addon-${addon}/out-esbuild-test/playwright.config.js` }); diff --git a/demo/client/client.ts b/demo/client/client.ts index 77020680fe..0736a7e32a 100644 --- a/demo/client/client.ts +++ b/demo/client/client.ts @@ -26,6 +26,7 @@ import { AddonsWindow } from './components/window/addonsWindow'; import { CellInspectorWindow } from './components/window/cellInspectorWindow'; import { ControlBar } from './components/controlBar'; import { WebglWindow } from './components/window/webglWindow'; +import { WebgpuWindow } from './components/window/gpuWindow'; import { OptionsWindow } from './components/window/optionsWindow'; import { StyleWindow } from './components/window/styleWindow'; import { TestWindow } from './components/window/testWindow'; @@ -39,6 +40,7 @@ import { SerializeAddon } from '@xterm/addon-serialize'; import { WebFontsAddon } from '@xterm/addon-web-fonts'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebglAddon } from '@xterm/addon-webgl'; +import { WebgpuAddon } from '@xterm/addon-webgpu'; import { Unicode11Addon } from '@xterm/addon-unicode11'; import { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes'; import { AddonCollection, type AddonType, type IDemoAddon } from './types'; @@ -55,6 +57,7 @@ export interface IWindowWithTerminal extends Window { SerializeAddon?: typeof SerializeAddon; WebLinksAddon?: typeof WebLinksAddon; WebglAddon?: typeof WebglAddon; + WebgpuAddon?: typeof WebgpuAddon; Unicode11Addon?: typeof Unicode11Addon; UnicodeGraphemesAddon?: typeof UnicodeGraphemesAddon; LigaturesAddon?: typeof LigaturesAddon; @@ -70,6 +73,7 @@ let controlBar: ControlBar; let addonsWindow: AddonsWindow; let addonSearchWindow: AddonSearchWindow; let addonWebglWindow: WebglWindow; +let addonWebgpuWindow: WebgpuWindow; let optionsWindow: OptionsWindow; const addons: AddonCollection = { @@ -83,6 +87,7 @@ const addons: AddonCollection = { webFonts: { name: 'webFonts', ctor: WebFontsAddon, canChange: true }, webLinks: { name: 'webLinks', ctor: WebLinksAddon, canChange: true }, webgl: { name: 'webgl', ctor: WebglAddon, canChange: true }, + webgpu: { name: 'webgpu', ctor: WebgpuAddon, canChange: true }, unicode11: { name: 'unicode11', ctor: Unicode11Addon, canChange: true }, unicodeGraphemes: { name: 'unicodeGraphemes', ctor: UnicodeGraphemesAddon, canChange: true }, ligatures: { name: 'ligatures', ctor: LigaturesAddon, canChange: true } @@ -157,6 +162,7 @@ const disposeRecreateButtonHandler: () => void = () => { addons.ligatures.instance = undefined; addons.webLinks.instance = undefined; addons.webgl.instance = undefined; + addons.webgpu.instance = undefined; document.getElementById('dispose')!.innerHTML = 'Recreate Terminal'; } else { createTerminal(); @@ -207,6 +213,7 @@ if (document.location.pathname === '/test') { window.LigaturesAddon = LigaturesAddon; window.WebLinksAddon = WebLinksAddon; window.WebglAddon = WebglAddon; + window.WebgpuAddon = WebgpuAddon; } else { const typedTerm = createTerminal(); @@ -224,6 +231,7 @@ if (document.location.pathname === '/test') { controlBar.registerWindow(new AddonWebFontsWindow(typedTerm, addons), { afterId: 'addon-serialize', hidden: true, italics: true }); controlBar.registerWindow(new AddonWebLinksWindow(typedTerm, addons), { afterId: 'addon-web-fonts', hidden: true, italics: true }); addonWebglWindow = controlBar.registerWindow(new WebglWindow(typedTerm, addons), { afterId: 'addon-web-links', hidden: true, italics: true }); + addonWebgpuWindow = controlBar.registerWindow(new WebgpuWindow(typedTerm, addons), { afterId: 'addon-webgl', hidden: true, italics: true }); controlBar.registerWindow(new TestWindow(typedTerm, addons, { disposeRecreateButtonHandler, createNewWindowButtonHandler }), { afterId: 'options' }); actionElements = { findNext: addonSearchWindow.findNextInput, @@ -242,12 +250,24 @@ if (document.location.pathname === '/test') { controlBar.setTabVisible('addon-serialize', true); controlBar.setTabVisible('addon-web-fonts', true); controlBar.setTabVisible('addon-web-links', !!addons.webLinks.instance); - controlBar.setTabVisible('addon-webgl', true); - addonWebglWindow.setTextureAtlas(addons.webgl.instance!.textureAtlas!); - addons.webgl.instance!.onChangeTextureAtlas(e => addonWebglWindow.setTextureAtlas(e)); - addons.webgl.instance!.onAddTextureAtlasCanvas(e => addonWebglWindow.appendTextureAtlas(e)); - addons.webgl.instance!.onRemoveTextureAtlasCanvas(e => addonWebglWindow.removeTextureAtlas(e)); + controlBar.setTabVisible('addon-webgl', !!addons.webgl.instance); + controlBar.setTabVisible('addon-webgpu', !!addons.webgpu.instance); + if (addons.webgl.instance) { + addonWebglWindow.setTextureAtlas(addons.webgl.instance.textureAtlas!); + addons.webgl.instance.onChangeTextureAtlas(e => addonWebglWindow.setTextureAtlas(e)); + addons.webgl.instance.onAddTextureAtlasCanvas(e => addonWebglWindow.appendTextureAtlas(e)); + addons.webgl.instance.onRemoveTextureAtlasCanvas(e => addonWebglWindow.removeTextureAtlas(e)); + } + if (addons.webgpu.instance) { + const atlas = addons.webgpu.instance.textureAtlas; + if (atlas) { + addonWebgpuWindow.setTextureAtlas(atlas); + } + addons.webgpu.instance.onChangeTextureAtlas(e => addonWebgpuWindow.setTextureAtlas(e)); + addons.webgpu.instance.onAddTextureAtlasCanvas(e => addonWebgpuWindow.appendTextureAtlas(e)); + addons.webgpu.instance.onRemoveTextureAtlasCanvas(e => addonWebgpuWindow.removeTextureAtlas(e)); + } paddingElement.value = '0'; addDomListener(paddingElement, 'change', setPadding); addDomListener(actionElements.findNext, 'keydown', (e) => { @@ -304,8 +324,8 @@ function createTerminal(): Terminal { addons.progress.instance = new ProgressAddon(); addons.unicodeGraphemes.instance = new UnicodeGraphemesAddon(); addons.clipboard.instance = new ClipboardAddon(); - try { // try to start with webgl renderer (might throw on older safari/webkit) - addons.webgl.instance = new WebglAddon(); + try { // try to start with webgpu renderer (might throw if unsupported) + addons.webgpu.instance = new WebgpuAddon(); } catch (e) { console.warn(e); } @@ -339,18 +359,18 @@ function createTerminal(): Terminal { addons.fit.instance!.fit(); - if (addons.webgl.instance) { + if (addons.webgpu.instance) { try { - typedTerm.loadAddon(addons.webgl.instance); + typedTerm.loadAddon(addons.webgpu.instance); term.open(terminalContainer!); } catch (e) { - console.warn('error during loading webgl addon:', e); - addons.webgl.instance.dispose(); - addons.webgl.instance = undefined; + console.warn('error during loading webgpu addon:', e); + addons.webgpu.instance.dispose(); + addons.webgpu.instance = undefined; } } if (!typedTerm.element) { - // webgl loading failed for some reason, attach with DOM renderer + // webgpu loading failed for some reason, attach with DOM renderer term.open(terminalContainer!); } @@ -456,6 +476,7 @@ function initAddons(term: Terminal): void { addonWebglWindow.setTextureAtlas(addons.webgl.instance!.textureAtlas!); addons.webgl.instance!.onChangeTextureAtlas(e => addonWebglWindow.setTextureAtlas(e)); addons.webgl.instance!.onAddTextureAtlasCanvas(e => addonWebglWindow.appendTextureAtlas(e)); + addons.webgl.instance!.onRemoveTextureAtlasCanvas(e => addonWebglWindow.removeTextureAtlas(e)); }, 500); } function preDisposeWebgl(): void { @@ -464,6 +485,24 @@ function initAddons(term: Terminal): void { addons.webgl.instance!.textureAtlas.remove(); } } + function postInitWebgpu(): void { + controlBar.setTabVisible('addon-webgpu', true); + setTimeout(() => { + const atlas = addons.webgpu.instance!.textureAtlas; + if (atlas) { + addonWebgpuWindow.setTextureAtlas(atlas); + } + addons.webgpu.instance!.onChangeTextureAtlas(e => addonWebgpuWindow.setTextureAtlas(e)); + addons.webgpu.instance!.onAddTextureAtlasCanvas(e => addonWebgpuWindow.appendTextureAtlas(e)); + addons.webgpu.instance!.onRemoveTextureAtlasCanvas(e => addonWebgpuWindow.removeTextureAtlas(e)); + }, 500); + } + function preDisposeWebgpu(): void { + controlBar.setTabVisible('addon-webgpu', false); + if (addons.webgpu.instance!.textureAtlas) { + addons.webgpu.instance!.textureAtlas.remove(); + } + } (Object.keys(addons) as AddonType[]).forEach(name => { const addon = addons[name]; @@ -505,6 +544,8 @@ function initAddons(term: Terminal): void { term.loadAddon(addon.instance); if (name === 'webgl') { postInitWebgl(); + } else if (name === 'webgpu') { + postInitWebgpu(); } else if (name === 'unicode11') { term.unicode.activeVersion = '11'; } else if (name === 'unicodeGraphemes') { @@ -530,6 +571,8 @@ function initAddons(term: Terminal): void { } else { if (name === 'webgl') { preDisposeWebgl(); + } else if (name === 'webgpu') { + preDisposeWebgpu(); } else if (name === 'unicode11' || name === 'unicodeGraphemes') { term.unicode.activeVersion = '6'; } else if (name === 'search') { @@ -549,14 +592,22 @@ function initAddons(term: Terminal): void { if (name === 'ligatures') { // Recreate webgl when ligatures are toggled so texture atlas picks up any font feature // settings changes + const webglCustomGlyphsCheckbox = document.getElementById('webgl-custom-glyphs') as HTMLInputElement | null; if (addons.webgl.instance) { preDisposeWebgl(); addons.webgl.instance.dispose(); - const customGlyphsCheckbox = document.getElementById('webgl-custom-glyphs') as HTMLInputElement; - addons.webgl.instance = new addons.webgl.ctor({ customGlyphs: customGlyphsCheckbox?.checked ?? true }); + addons.webgl.instance = new addons.webgl.ctor({ customGlyphs: webglCustomGlyphsCheckbox?.checked ?? true }); term.loadAddon(addons.webgl.instance); postInitWebgl(); } + const webgpuCustomGlyphsCheckbox = document.getElementById('webgpu-custom-glyphs') as HTMLInputElement | null; + if (addons.webgpu.instance) { + preDisposeWebgpu(); + addons.webgpu.instance.dispose(); + addons.webgpu.instance = new addons.webgpu.ctor({ customGlyphs: webgpuCustomGlyphsCheckbox?.checked ?? true }); + term.loadAddon(addons.webgpu.instance); + postInitWebgpu(); + } } }); const label = document.createElement('label'); @@ -570,20 +621,28 @@ function initAddons(term: Terminal): void { wrapper.classList.add('addon'); wrapper.appendChild(label); - // Add customGlyphs sub-checkbox for webgl addon - if (name === 'webgl') { + // Add customGlyphs sub-checkbox for webgl/webgpu addons + if (name === 'webgl' || name === 'webgpu') { + const isWebgl = name === 'webgl'; const customGlyphsCheckbox = document.createElement('input') as HTMLInputElement; customGlyphsCheckbox.type = 'checkbox'; customGlyphsCheckbox.checked = true; // Default to enabled - customGlyphsCheckbox.id = 'webgl-custom-glyphs'; + customGlyphsCheckbox.id = isWebgl ? 'webgl-custom-glyphs' : 'webgpu-custom-glyphs'; addDomListener(customGlyphsCheckbox, 'change', () => { - if (addons.webgl.instance) { + if (isWebgl && addons.webgl.instance) { preDisposeWebgl(); addons.webgl.instance.dispose(); addons.webgl.instance = new addons.webgl.ctor({ customGlyphs: customGlyphsCheckbox.checked }); term.loadAddon(addons.webgl.instance); postInitWebgl(); } + if (!isWebgl && addons.webgpu.instance) { + preDisposeWebgpu(); + addons.webgpu.instance.dispose(); + addons.webgpu.instance = new addons.webgpu.ctor({ customGlyphs: customGlyphsCheckbox.checked }); + term.loadAddon(addons.webgpu.instance); + postInitWebgpu(); + } }); const customGlyphsLabel = document.createElement('label'); customGlyphsLabel.classList.add('addon'); diff --git a/demo/client/components/window/gpuWindow.ts b/demo/client/components/window/gpuWindow.ts index ac7b2c3db2..a803432fe4 100644 --- a/demo/client/components/window/gpuWindow.ts +++ b/demo/client/components/window/gpuWindow.ts @@ -6,34 +6,43 @@ import { BaseWindow } from './baseWindow'; import type { IControlWindow } from '../controlBar'; -export class GpuWindow extends BaseWindow implements IControlWindow { - public readonly id = 'gpu'; - public readonly label = 'WebGL'; +export class WebgpuWindow extends BaseWindow implements IControlWindow { + public readonly id = 'addon-webgpu'; + public readonly label = 'webgpu'; private _textureAtlasContainer!: HTMLElement; public build(container: HTMLElement): void { + const zoomId = `${this.id}-texture-atlas-zoom`; const zoomCheckbox = document.createElement('input'); zoomCheckbox.type = 'checkbox'; - zoomCheckbox.id = 'texture-atlas-zoom'; + zoomCheckbox.id = zoomId; + zoomCheckbox.classList.add('texture-atlas-zoom'); container.appendChild(zoomCheckbox); const zoomLabel = document.createElement('label'); - zoomLabel.htmlFor = 'texture-atlas-zoom'; + zoomLabel.htmlFor = zoomId; zoomLabel.textContent = 'Zoom texture atlas'; container.appendChild(zoomLabel); this._textureAtlasContainer = document.createElement('div'); - this._textureAtlasContainer.id = 'texture-atlas'; + this._textureAtlasContainer.classList.add('texture-atlas'); container.appendChild(this._textureAtlasContainer); } public setTextureAtlas(canvas: HTMLCanvasElement): void { + if (!canvas) { + this._textureAtlasContainer.replaceChildren(); + return; + } this._styleAtlasPage(canvas); this._textureAtlasContainer.replaceChildren(canvas); } public appendTextureAtlas(canvas: HTMLCanvasElement): void { + if (!canvas) { + return; + } this._styleAtlasPage(canvas); this._textureAtlasContainer.appendChild(canvas); } diff --git a/demo/client/components/window/testWindow.ts b/demo/client/components/window/testWindow.ts index 1ea6b3e679..31f0959336 100644 --- a/demo/client/components/window/testWindow.ts +++ b/demo/client/components/window/testWindow.ts @@ -679,7 +679,7 @@ Test BG-colored Erase (BCE): } function loadTest(term: Terminal, addons: AddonCollection): void { - const rendererName = addons.webgl.instance ? 'webgl' : 'dom'; + const rendererName = addons.webgpu.instance ? 'webgpu' : addons.webgl.instance ? 'webgl' : 'dom'; const testData = []; let byteCount = 0; for (let i = 0; i < 50; i++) { @@ -712,7 +712,7 @@ function loadTest(term: Terminal, addons: AddonCollection): void { } async function loadTestLongLines(term: Terminal, addons: AddonCollection): Promise { - const rendererName = addons.webgl.instance ? 'webgl' : 'dom'; + const rendererName = addons.webgpu.instance ? 'webgpu' : addons.webgl.instance ? 'webgl' : 'dom'; const testData = []; let byteCount = 0; for (let i = 0; i < 50; i++) { diff --git a/demo/client/components/window/webglWindow.ts b/demo/client/components/window/webglWindow.ts index b61be231fa..04f7e85329 100644 --- a/demo/client/components/window/webglWindow.ts +++ b/demo/client/components/window/webglWindow.ts @@ -13,18 +13,20 @@ export class WebglWindow extends BaseWindow implements IControlWindow { private _textureAtlasContainer!: HTMLElement; public build(container: HTMLElement): void { + const zoomId = `${this.id}-texture-atlas-zoom`; const zoomCheckbox = document.createElement('input'); zoomCheckbox.type = 'checkbox'; - zoomCheckbox.id = 'texture-atlas-zoom'; + zoomCheckbox.id = zoomId; + zoomCheckbox.classList.add('texture-atlas-zoom'); container.appendChild(zoomCheckbox); const zoomLabel = document.createElement('label'); - zoomLabel.htmlFor = 'texture-atlas-zoom'; + zoomLabel.htmlFor = zoomId; zoomLabel.textContent = 'Zoom texture atlas'; container.appendChild(zoomLabel); this._textureAtlasContainer = document.createElement('div'); - this._textureAtlasContainer.id = 'texture-atlas'; + this._textureAtlasContainer.classList.add('texture-atlas'); container.appendChild(this._textureAtlasContainer); } diff --git a/demo/client/tsconfig.json b/demo/client/tsconfig.json index 244f19c6ef..8c0ad94063 100644 --- a/demo/client/tsconfig.json +++ b/demo/client/tsconfig.json @@ -18,6 +18,7 @@ "@xterm/addon-web-fonts": ["../../addons/addon-web-fonts"], "@xterm/addon-web-links": ["../../addons/addon-web-links"], "@xterm/addon-webgl": ["../../addons/addon-webgl"], + "@xterm/addon-webgpu": ["../../addons/addon-webgpu"], "@xterm/addon-unicode11": ["../../addons/addon-unicode11"], "@xterm/addon-unicode-graphemes": ["../../addons/addon-unicode-graphemes"], "@xterm/addon-ligatures": ["../../addons/addon-ligatures"], diff --git a/demo/client/types.ts b/demo/client/types.ts index c73f31e209..4d89675e50 100644 --- a/demo/client/types.ts +++ b/demo/client/types.ts @@ -18,8 +18,9 @@ import type { Unicode11Addon } from '@xterm/addon-unicode11'; import type { WebFontsAddon } from '@xterm/addon-web-fonts'; import type { WebLinksAddon } from '@xterm/addon-web-links'; import type { WebglAddon } from '@xterm/addon-webgl'; +import type { WebgpuAddon } from '@xterm/addon-webgpu'; -export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'ligatures' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl'; +export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl' | 'webgpu' | 'ligatures'; export interface IDemoAddon { name: T; @@ -38,7 +39,8 @@ export interface IDemoAddon { T extends 'unicode11' ? typeof Unicode11Addon : T extends 'unicodeGraphemes' ? typeof UnicodeGraphemesAddon : T extends 'webgl' ? typeof WebglAddon : - never + T extends 'webgpu' ? typeof WebgpuAddon : + never ); instance?: ( T extends 'attach' ? AttachAddon : @@ -54,7 +56,8 @@ export interface IDemoAddon { T extends 'unicode11' ? Unicode11Addon : T extends 'unicodeGraphemes' ? UnicodeGraphemesAddon : T extends 'webgl' ? WebglAddon : - never + T extends 'webgpu' ? WebgpuAddon : + never ); } diff --git a/demo/index.css b/demo/index.css index 6880bde42c..61d2f95305 100644 --- a/demo/index.css +++ b/demo/index.css @@ -162,15 +162,15 @@ div:first-of-type.grid { border-top: none; } -#texture-atlas-zoom:checked ~ #texture-atlas canvas { +.texture-atlas-zoom:checked ~ .texture-atlas canvas { /* Zoom atlas to the width of the container*/ width: 100% !important; height: auto !important; } -#texture-atlas { +.texture-atlas { width: 100%; } -#texture-atlas canvas { +.texture-atlas canvas { image-rendering: pixelated; border: 1px solid #ccc; } diff --git a/package-lock.json b/package-lock.json index 24a3b3b07a..68c363251e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -161,6 +161,11 @@ "version": "0.19.0", "license": "MIT" }, + "addons/addon-webgpu": { + "name": "@xterm/addon-webgpu", + "version": "0.1.0", + "license": "MIT" + }, "node_modules/@acemir/cssom": { "version": "0.9.30", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", @@ -2324,6 +2329,10 @@ "resolved": "addons/addon-webgl", "link": true }, + "node_modules/@xterm/addon-webgpu": { + "resolved": "addons/addon-webgpu", + "link": true + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", diff --git a/playwright-cli.json b/playwright-cli.json new file mode 100644 index 0000000000..2b47d1a5c6 --- /dev/null +++ b/playwright-cli.json @@ -0,0 +1,8 @@ +{ + "browser": "chromium", + "chromiumSandbox": false, + "launchOptions": { + "headless": true, + "args": ["--no-sandbox"] + } +} diff --git a/tsconfig.all.json b/tsconfig.all.json index b0ed02799c..5d7ab339e4 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -19,6 +19,7 @@ { "path": "./addons/addon-unicode-graphemes" }, { "path": "./addons/addon-web-fonts" }, { "path": "./addons/addon-web-links" }, - { "path": "./addons/addon-webgl" } + { "path": "./addons/addon-webgl" }, + { "path": "./addons/addon-webgpu" } ] }