Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/api-reference/text/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ Supported WebGPU inputs automatically use storage-backed text.

`TextRowIndexedStorageModel` stores one extra source-row index per generated glyph. This avoids shader-side row lookup by binary search at the cost of a larger generated glyph vertex record.

## Clip Rectangles

Arrow text clipping is optional. When `clipRects` is absent, renderers bind a constant disabled
rectangle and do not maintain one rectangle per text row. When supplied, the column is a
`FixedSizeList<Float32, 4>` (or a `GPUVector<'float32x4'>`) containing `[x, y, width, height]`.
Negative width disables horizontal clipping and negative height disables vertical clipping.

In `ArrowTextLayer`, rectangle values are world-space offsets from the text anchor. The layer
projects the rectangle through the active deck viewport, including independent X/Y scale and
`flipY`. `contentAlignHorizontal` and `contentAlignVertical` can keep text aligned to the visible
part of a partially off-screen rectangle; `contentCutoffPixels` hides content when the visible
rectangle becomes smaller than the requested screen-pixel threshold. Glyph atlas coordinates are
independent of this feature—the rectangle clips positioned text, not atlas sampling.

## Dictionary Path

Supported dictionary-encoded WebGPU input automatically uses compressed dictionary storage.
Expand Down
3 changes: 2 additions & 1 deletion docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ Target Release Date: Q3, 2026
- **Packed generated glyph vertex data** - Attribute text uses `expandedGlyphVertexData`, while storage text uses `compactGlyphVertexData`, reducing generated glyph buffer fan-out without folding caller-owned row/style vectors into generated records.
- **MSDF text fonts** - `@luma.gl/text` can build or load prebuilt BMFont JSON MSDF atlases, including kerning and multi-page atlas metadata, through the same `FontAtlas` format used by generated bitmap and SDF atlases.
- **GPU UTF-8 shader mapping** - Reusable text-module WGSL helpers compose sparse UTF-8 byte traversal, code point decode, and storage lookup into one-pass text compute kernels.
- **Packed text clipping** - Arrow 2D text accepts optional `FixedSizeList<Int16>[4]` clip rectangles and expands them into 8-byte per-glyph clipping attributes only when clipping is enabled.
- **View-aware Arrow text clipping** - Arrow 2D text accepts optional `FixedSizeList<Float32>[4]` clip rectangles. `ArrowTextLayer` interprets them as world-space anchor offsets, projects them through the active deck viewport, and supports visible-region alignment and pixel cutoffs; omitting `clipRects` retains a constant no-clipping fallback instead of allocating per-row data.
- **GPU-selected text** - `GPUTextSelection` filters row-indexed compact glyph records from GPU row flags, preserves original row identity, and writes the selected glyph count directly into an indirect draw command. The [GPU-Culled deck Trace](/examples/deck/gpu-culled-trace) shares one culling result between blocks, Arrow labels, and picking.

**@luma.gl/shadertools**

Expand Down
10 changes: 5 additions & 5 deletions examples/arrow/arrow-text-2d/arrow-text-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function makeArrowTextSource(
const centerRow = (labelRowCount - 1) / 2;
const positions = new Float32Array(dataset.labelCount * 2);
const clipRects = resolvedStyleColumns.clipRects
? new Int16Array(dataset.labelCount * 4)
? new Float32Array(dataset.labelCount * 4)
: undefined;
const angles = resolvedStyleColumns.angles ? new Float32Array(dataset.labelCount) : undefined;
const sizes = resolvedStyleColumns.sizes ? new Float32Array(dataset.labelCount) : undefined;
Expand Down Expand Up @@ -135,8 +135,8 @@ export function makeArrowTextSource(
const texts = makeArrowTextVector(dataset, dataset.labelCount, labelIndex => labelIndex);
const clipRectVector = clipRects
? splitArrowVectorByRows(
makeArrowFixedSizeListVector(new arrow.Int16(), 4, clipRects) as arrow.Vector<
arrow.FixedSizeList<arrow.Int16>
makeArrowFixedSizeListVector(new arrow.Float32(), 4, clipRects) as arrow.Vector<
arrow.FixedSizeList<arrow.Float32>
>,
rowChunkSize
)
Expand Down Expand Up @@ -208,7 +208,7 @@ function makeStreamingArrowTextSource(
const batchLabelCount = batchRowIndices.length * LABEL_COLUMN_COUNT;
const positions = new Float32Array(batchLabelCount * 2);
const clipRects = resolvedStyleColumns.clipRects
? new Int16Array(batchLabelCount * 4)
? new Float32Array(batchLabelCount * 4)
: undefined;
const colors =
textColorKind === 'string-colors' ? new Uint8Array(batchLabelCount * 4) : undefined;
Expand Down Expand Up @@ -264,7 +264,7 @@ function makeStreamingArrowTextSource(
return batchRowIndices[localRowIndex] * LABEL_COLUMN_COUNT + columnIndex;
}),
...(clipRects
? {clipRects: makeArrowFixedSizeListVector(new arrow.Int16(), 4, clipRects)}
? {clipRects: makeArrowFixedSizeListVector(new arrow.Float32(), 4, clipRects)}
: {}),
...(colorVector ? {colors: colorVector} : {}),
...(angles ? {angles: makeFloat32ArrowVector(angles)} : {}),
Expand Down
2 changes: 1 addition & 1 deletion examples/arrow/arrow-text-2d/control-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ export function makeArrowText2DSettingsSchema(
persist: 'none',
options: [
{label: 'None', value: 'none'},
{label: 'Row - FixedSizeList<Int16, 4>', value: 'row-clip-rects'}
{label: 'Row - FixedSizeList<Float32, 4>', value: 'row-clip-rects'}
]
}
]
Expand Down
240 changes: 240 additions & 0 deletions examples/deck/gpu-culled-trace/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
// luma.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {OrthographicView, type PickingInfo, type ViewStateChangeParameters} from '@deck.gl/core';
import {buildSdfFontAtlas} from '@luma.gl/text';
import {ArrowExamplePanelManager} from '../../arrow/arrow-example-panels';
import {makeHtmlCustomPanel} from '../../example-panels';
import {ArrowDeck} from '../arrow-deck';
import {getDeckExampleProps, type DeckExampleDeviceOptions} from '../deck-example-device';
import {GPUCulledArrowTextLayer} from './gpu-culled-arrow-text-layer';
import {GPUCulledTraceLayer} from './gpu-culled-trace-layer';
import {GPUTraceCullingEffect, type GPUTraceCullingStats} from './gpu-trace-culling-effect';
import {getTraceRow, makeDeckTraceData} from './trace-data';

const TRACE_ROW_COUNT = 25_000;
const TRACE_LANE_WINDOW = 72;
let fontAtlas: ReturnType<typeof buildSdfFontAtlas> | undefined;

type TraceViewState = {
target: [number, number, number];
zoom: number;
};

/** Creates the WebGPU-only deck.gl trace viewer. */
export function createGPUCulledTraceDeck(
parent?: HTMLDivElement,
options: DeckExampleDeviceOptions = {}
) {
const traceData = makeDeckTraceData(TRACE_ROW_COUNT);
let cullingEffect: GPUTraceCullingEffect | null = null;
let animationFrame = 0;
let lastAnimationTime = 0;
let autoScroll = true;
let viewState: TraceViewState = {target: [150, TRACE_LANE_WINDOW / 2, 0], zoom: 0};
let statsElement: HTMLElement | null = null;
let cullingStats: GPUTraceCullingStats = {
totalBlocks: traceData.count,
visibleBlocks: 0,
outsideBlocks: 0,
smallBlocks: 0,
totalGlyphs: 0,
visibleGlyphs: 0,
encodeTimeMilliseconds: 0,
graphNodeCount: 0,
logicalTransientBytes: 0,
physicalTransientBytes: 0,
transientReusePercentage: 0,
labelStatus: 'Waiting for label layer'
};
const panelManager = new ArrowExamplePanelManager({
descriptionHtml:
'<p style="margin:0;line-height:1.45">One WebGPU command graph culls trace blocks and row-indexed Arrow glyph records before deck.gl draws both layers indirectly.</p>',
settingsPanel: () =>
makeHtmlCustomPanel({
id: 'gpu-culled-trace-culling',
title: 'Culling',
html: '<div data-gpu-trace-culling-stats></div>',
onRender: root => {
statsElement = root.querySelector('[data-gpu-trace-culling-stats]');
renderCullingStats(statsElement, cullingStats);
return () => {
statsElement = null;
};
}
})
});
panelManager.setTableEntries([
{id: 'gpu-trace-text', label: 'Trace text', kind: 'source', table: traceData.textTable}
]);
panelManager.mount();

let deck: ArrowDeck<OrthographicView>;
deck = new ArrowDeck({
parent,
...getDeckExampleProps({...options, deviceType: 'webgpu'}),
views: new OrthographicView({id: 'main'}),
viewState,
controller: {
dragPan: true,
scrollZoom: {smooth: true, speed: 0.02},
doubleClickZoom: true,
touchZoom: true
},
layers: [],
effects: [],
getTooltip: info => getTooltip(traceData, info),
onViewStateChange: ({viewState: nextViewState}: ViewStateChangeParameters) => {
viewState = nextViewState as TraceViewState;
autoScroll = false;
deck.setProps({viewState});
},
onLoad: ({deck: loadedDeck, device}) => {
if (device.type !== 'webgpu') throw new Error('GPU-culled deck trace requires WebGPU');
cullingEffect = new GPUTraceCullingEffect(device, traceData, {
onStats: stats => {
cullingStats = stats;
renderCullingStats(statsElement, stats);
}
});
const resources = cullingEffect.resources;
const textLayer = new GPUCulledArrowTextLayer({
id: 'gpu-culled-trace-labels',
data: traceData.textTable,
pickable: true,
fontAtlas: getFontAtlas(),
model: 'storage-row-indexed',
positions: 'positions',
texts: 'texts',
clipRects: 'clipRects',
angles: null,
sizes: null,
pixelOffsets: null,
textAnchors: null,
alignmentBaselines: null,
color: [245, 248, 255, 255],
size: 24,
pixelOffset: [2, 0],
textAnchor: 'start',
alignmentBaseline: 'center',
contentAlignHorizontal: 'start',
contentAlignVertical: 'none',
contentCutoffPixels: [0, 0],
onDataError: error => cullingEffect?.setLabelError(error)
});
cullingEffect.setTextLayer(textLayer);
loadedDeck.setProps({
effects: [cullingEffect],
layers: [
new GPUCulledTraceLayer({
id: 'gpu-culled-trace-blocks',
data: [],
pickable: true,
spans: resources.spans,
visibleIds: resources.visibleIds,
viewUniforms: resources.viewUniforms,
drawCommands: resources.blockDrawCommands
}),
textLayer
]
});
},
onFinalize: () => {
cancelAnimationFrame(animationFrame);
panelManager.finalize();
}
});

const animate = (time: number): void => {
const labelsArePreparing = cullingEffect && cullingStats.totalGlyphs === 0;
if (
cullingEffect &&
(autoScroll || labelsArePreparing) &&
time - lastAnimationTime >= 1000 / 30
) {
lastAnimationTime = time;
if (autoScroll) {
viewState = {
...viewState,
target: [150 + ((time * 0.025) % 1000), viewState.target[1], 0]
};
deck.setProps({viewState});
}
deck.redraw('trace animation');
}
animationFrame = requestAnimationFrame(animate);
};
animationFrame = requestAnimationFrame(animate);
return deck;
}

function getTooltip(
data: ReturnType<typeof makeDeckTraceData>,
info: PickingInfo
): {html: string} | null {
const row = getTraceRow(data, info.index);
if (!row) return null;
return {
html: `<div><strong>${escapeHtml(row.name)}</strong></div>
<div>group: ${row.group}</div>
<div>start: ${row.start.toFixed(3)}</div>
<div>duration: ${row.duration.toFixed(3)}</div>
<div>lane: ${row.lane}</div>`
};
}

function getFontAtlas(): ReturnType<typeof buildSdfFontAtlas> {
fontAtlas ??= buildSdfFontAtlas({
characterSet: ' abcdefghijklmnopqrstuvwxyz0123456789-μé',
fontFamily: 'Monaco, Menlo, monospace',
fontWeight: '600',
fontSize: 48,
buffer: 5,
radius: 10
});
return fontAtlas;
}

function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, character => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return entities[character]!;
});
}

function renderCullingStats(element: HTMLElement | null, stats: GPUTraceCullingStats): void {
if (!element) return;
const glyphCounts = stats.totalGlyphs
? `${formatCount(stats.visibleGlyphs)} / ${formatCount(stats.totalGlyphs)}`
: escapeHtml(stats.labelStatus);
element.innerHTML = `<div style="display:grid;grid-template-columns:1fr auto;gap:5px 16px;font:12px/1.45 system-ui,sans-serif;font-variant-numeric:tabular-nums">
<span>Visible blocks</span><strong>${formatCount(stats.visibleBlocks)} / ${formatCount(stats.totalBlocks)}</strong>
<span>Outside view</span><strong>${formatCount(stats.outsideBlocks)}</strong>
<span>Too small (&lt;1 px)</span><strong>${formatCount(stats.smallBlocks)}</strong>
<span>Visible glyphs</span><strong>${glyphCounts}</strong>
<span>Graph nodes</span><strong>${stats.graphNodeCount}</strong>
<span>CPU graph encode</span><strong>${stats.encodeTimeMilliseconds.toFixed(2)} ms</strong>
<span>Logical scratch</span><strong>${formatBytes(stats.logicalTransientBytes)}</strong>
<span>Physical scratch</span><strong>${formatBytes(stats.physicalTransientBytes)}</strong>
<span>Transient reuse</span><strong>${stats.transientReusePercentage.toFixed(0)}%</strong>
</div>`;
}

function formatCount(value: number): string {
return new Intl.NumberFormat('en-US', {notation: 'compact', maximumFractionDigits: 1}).format(
value
);
}

function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
72 changes: 72 additions & 0 deletions examples/deck/gpu-culled-trace/gpu-culled-arrow-text-layer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// luma.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {ArrowTextLayer, type ArrowTextLayerProps} from '@deck.gl-community/arrow-layers';
import type {ArrowTextRenderer} from '@luma.gl/arrow';
import {Buffer, type RenderPass} from '@luma.gl/core';
import {Model} from '@luma.gl/engine';
import {DrawCommandBuffer} from '@luma.gl/experimental';
import type {TextStorageModel} from '@luma.gl/text/experimental';

export type GPUCulledTextDraw = {
selectedGlyphRecords: Buffer;
drawCommands: DrawCommandBuffer;
};

export type GPUCulledTextSource = {
glyphRecords: Buffer;
glyphCount: number;
recordWordLength: number;
};

export type GPUCulledTextPreparation = {
source: GPUCulledTextSource | null;
status: string;
};

/** Example-local Arrow text layer that replaces the normal glyph draw with GPU-selected records. */
export class GPUCulledArrowTextLayer extends ArrowTextLayer {
static override layerName = 'GPUCulledArrowTextLayer';
private culledDraw: GPUCulledTextDraw | null = null;

setCulledDraw(culledDraw: GPUCulledTextDraw | null): void {
this.culledDraw = culledDraw;
}

getSelectionPreparation(): GPUCulledTextPreparation {
const renderer = this.getRendererOrNull();
if (!renderer) return {source: null, status: 'Preparing Arrow text renderer'};
if (renderer.resolvedModel !== 'storage-row-indexed') {
return {
source: null,
status: `GPU row-indexed text unavailable (resolved ${renderer.resolvedModel})`
};
}
const model = renderer.model as TextStorageModel;
const renderBatch = model.storageState.renderBatches[0];
if (!renderBatch) return {source: null, status: 'Expanding Arrow glyph records on the GPU'};
if (model.storageState.renderBatches.length !== 1) {
return {source: null, status: 'Multiple text batches are not supported by this example'};
}
return {
source: {
glyphRecords: renderBatch.compactGlyphVertexData,
glyphCount: renderBatch.glyphCount,
recordWordLength: 3
},
status: 'Ready'
};
}

protected override drawTextRenderer(renderer: ArrowTextRenderer, renderPass: RenderPass): void {
if (!this.culledDraw || renderer.resolvedModel !== 'storage-row-indexed') return;
const model = renderer.model;
model.setAttributes({compactGlyphVertexData: this.culledDraw.selectedGlyphRecords});
model.setInstanceCount(0);
Model.prototype.draw.call(model, renderPass);
this.culledDraw.drawCommands.draw(renderPass, 0);
}
}

export type GPUCulledArrowTextLayerProps = ArrowTextLayerProps;
Loading
Loading