diff --git a/docs/api-reference/text/README.md b/docs/api-reference/text/README.md index a261bfab31..22ad9f86f8 100644 --- a/docs/api-reference/text/README.md +++ b/docs/api-reference/text/README.md @@ -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` (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. diff --git a/docs/whats-new.md b/docs/whats-new.md index d7f4ee1186..2009f2e913 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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[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[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** diff --git a/examples/arrow/arrow-text-2d/arrow-text-data.ts b/examples/arrow/arrow-text-2d/arrow-text-data.ts index 1ee1049372..40c63eced4 100644 --- a/examples/arrow/arrow-text-2d/arrow-text-data.ts +++ b/examples/arrow/arrow-text-2d/arrow-text-data.ts @@ -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; @@ -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 + makeArrowFixedSizeListVector(new arrow.Float32(), 4, clipRects) as arrow.Vector< + arrow.FixedSizeList >, rowChunkSize ) @@ -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; @@ -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)} : {}), diff --git a/examples/arrow/arrow-text-2d/control-panel.ts b/examples/arrow/arrow-text-2d/control-panel.ts index 7d3fd80734..4891db29b3 100644 --- a/examples/arrow/arrow-text-2d/control-panel.ts +++ b/examples/arrow/arrow-text-2d/control-panel.ts @@ -339,7 +339,7 @@ export function makeArrowText2DSettingsSchema( persist: 'none', options: [ {label: 'None', value: 'none'}, - {label: 'Row - FixedSizeList', value: 'row-clip-rects'} + {label: 'Row - FixedSizeList', value: 'row-clip-rects'} ] } ] diff --git a/examples/deck/gpu-culled-trace/app.ts b/examples/deck/gpu-culled-trace/app.ts new file mode 100644 index 0000000000..069b291252 --- /dev/null +++ b/examples/deck/gpu-culled-trace/app.ts @@ -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 | 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: + '

One WebGPU command graph culls trace blocks and row-indexed Arrow glyph records before deck.gl draws both layers indirectly.

', + settingsPanel: () => + makeHtmlCustomPanel({ + id: 'gpu-culled-trace-culling', + title: 'Culling', + html: '
', + 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; + 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, + info: PickingInfo +): {html: string} | null { + const row = getTraceRow(data, info.index); + if (!row) return null; + return { + html: `
${escapeHtml(row.name)}
+
group: ${row.group}
+
start: ${row.start.toFixed(3)}
+
duration: ${row.duration.toFixed(3)}
+
lane: ${row.lane}
` + }; +} + +function getFontAtlas(): ReturnType { + 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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + 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 = `
+ Visible blocks${formatCount(stats.visibleBlocks)} / ${formatCount(stats.totalBlocks)} + Outside view${formatCount(stats.outsideBlocks)} + Too small (<1 px)${formatCount(stats.smallBlocks)} + Visible glyphs${glyphCounts} + Graph nodes${stats.graphNodeCount} + CPU graph encode${stats.encodeTimeMilliseconds.toFixed(2)} ms + Logical scratch${formatBytes(stats.logicalTransientBytes)} + Physical scratch${formatBytes(stats.physicalTransientBytes)} + Transient reuse${stats.transientReusePercentage.toFixed(0)}% +
`; +} + +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`; +} diff --git a/examples/deck/gpu-culled-trace/gpu-culled-arrow-text-layer.ts b/examples/deck/gpu-culled-trace/gpu-culled-arrow-text-layer.ts new file mode 100644 index 0000000000..b5e5c86264 --- /dev/null +++ b/examples/deck/gpu-culled-trace/gpu-culled-arrow-text-layer.ts @@ -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; diff --git a/examples/deck/gpu-culled-trace/gpu-culled-trace-layer.ts b/examples/deck/gpu-culled-trace/gpu-culled-trace-layer.ts new file mode 100644 index 0000000000..ca800b92d0 --- /dev/null +++ b/examples/deck/gpu-culled-trace/gpu-culled-trace-layer.ts @@ -0,0 +1,178 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Layer, type LayerContext, type LayerProps, type PickingInfo} from '@deck.gl/core'; +import {Buffer, type RenderPass} from '@luma.gl/core'; +import {Model} from '@luma.gl/engine'; +import {DrawCommandBuffer} from '@luma.gl/experimental'; + +export type GPUCulledTraceLayerProps = LayerProps & { + spans: Buffer; + visibleIds: Buffer; + viewUniforms: Buffer; + drawCommands: DrawCommandBuffer; +}; + +type GPUCulledTraceLayerState = {model: Model | null; renderUniforms: Buffer | null}; + +const TRACE_BLEND_PARAMETERS = { + depthWriteEnabled: false, + blend: true, + blendColorOperation: 'add', + blendAlphaOperation: 'add', + blendColorSrcFactor: 'src-alpha', + blendColorDstFactor: 'one-minus-src-alpha', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one-minus-src-alpha' +} as const; + +const TRACE_LAYER_WGSL = /* wgsl */ ` +struct TraceSpan { + start: f32, + duration: f32, + lane: u32, + group: u32, +}; + +struct ViewUniforms { timeMin: f32, timeMax: f32, laneMin: f32, laneMax: f32 }; +struct RenderUniforms { pickingActive: f32, opacity: f32, _padding: vec2 }; + +@group(0) @binding(auto) var spans: array; +@group(0) @binding(auto) var visibleIds: array; +@group(0) @binding(auto) var viewUniforms: ViewUniforms; +@group(0) @binding(auto) var renderUniforms: RenderUniforms; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec4, + @location(1) @interpolate(flat) pickingColor: vec3, +}; + +fn getCorner(vertexIndex: u32) -> vec2 { + let corners = array, 6>( + vec2(0.0, 0.08), vec2(1.0, 0.08), vec2(0.0, 0.92), + vec2(0.0, 0.92), vec2(1.0, 0.08), vec2(1.0, 0.92) + ); + return corners[vertexIndex]; +} + +fn getGroupColor(group: u32) -> vec3 { + let colors = array, 3>( + vec3(0.30, 0.78, 1.00), + vec3(0.74, 0.46, 1.00), + vec3(1.00, 0.63, 0.22) + ); + return colors[min(group, 2u)]; +} + +fn encodePickingColor(rowIndex: u32) -> vec3 { + let colorIndex = rowIndex + 1u; + return vec3( + f32(colorIndex % 256u), + f32((colorIndex / 256u) % 256u), + f32((colorIndex / 65536u) % 256u) + ) / 255.0; +} + +@vertex fn vertexMain( + @builtin(vertex_index) vertexIndex: u32, + @builtin(instance_index) instanceIndex: u32 +) -> VertexOutput { + let rowIndex = visibleIds[instanceIndex]; + let span = spans[rowIndex]; + let corner = getCorner(vertexIndex); + let worldPosition = vec2( + span.start + span.duration * corner.x, + f32(span.lane) + corner.y + ); + let viewSize = max( + vec2(viewUniforms.timeMax - viewUniforms.timeMin, viewUniforms.laneMax - viewUniforms.laneMin), + vec2(0.0001) + ); + let normalized = (worldPosition - vec2(viewUniforms.timeMin, viewUniforms.laneMin)) / viewSize; + var output: VertexOutput; + output.position = vec4(normalized.x * 2.0 - 1.0, 1.0 - normalized.y * 2.0, 0.0, 1.0); + output.color = vec4(getGroupColor(span.group), 0.92); + output.pickingColor = encodePickingColor(rowIndex); + return output; +} + +@fragment fn fragmentMain(input: VertexOutput) -> @location(0) vec4 { + if (renderUniforms.pickingActive > 0.5) { + return vec4(input.pickingColor, 1.0); + } + return vec4(input.color.rgb, input.color.a * renderUniforms.opacity); +}`; + +/** WebGPU-only deck layer that replays a GPU-written indirect span draw. */ +export class GPUCulledTraceLayer extends Layer { + static override layerName = 'GPUCulledTraceLayer'; + static override defaultProps = {parameters: TRACE_BLEND_PARAMETERS}; + + override getAttributeManager() { + return null; + } + + override setShaderModuleProps(): void {} + + override initializeState({device}: LayerContext): void { + if (device.type !== 'webgpu') throw new Error('GPUCulledTraceLayer requires WebGPU'); + const renderUniforms = device.createBuffer({ + id: `${this.id}-render-uniforms`, + byteLength: 16, + usage: Buffer.UNIFORM | Buffer.COPY_DST + }); + const model = new Model(device, { + id: `${this.id}-model`, + source: TRACE_LAYER_WGSL, + topology: 'triangle-list', + isInstanced: true, + vertexCount: 6, + instanceCount: 0, + bufferLayout: [], + bindings: { + spans: this.props.spans, + visibleIds: this.props.visibleIds, + viewUniforms: this.props.viewUniforms, + renderUniforms + }, + parameters: TRACE_BLEND_PARAMETERS + }); + this.setState({model, renderUniforms} satisfies GPUCulledTraceLayerState); + } + + override getModels(): Model[] { + const model = (this.state as GPUCulledTraceLayerState).model; + return model ? [model] : []; + } + + override draw({ + renderPass, + shaderModuleProps + }: { + renderPass: RenderPass; + shaderModuleProps?: {picking?: {isActive?: number}}; + }): void { + const {model, renderUniforms} = this.state as GPUCulledTraceLayerState; + if (!model || !renderUniforms) return; + renderUniforms.write( + new Float32Array([shaderModuleProps?.picking?.isActive ?? 0, this.props.opacity ?? 1, 0, 0]) + ); + model.setInstanceCount(0); + model.draw(renderPass); + this.props.drawCommands.draw(renderPass, 0); + } + + override getPickingInfo({info}: {info: PickingInfo}): PickingInfo { + return info; + } + + override finalizeState(context: LayerContext): void { + const state = this.state as GPUCulledTraceLayerState; + state.model?.destroy(); + state.renderUniforms?.destroy(); + this.setState({model: null, renderUniforms: null} satisfies GPUCulledTraceLayerState); + super.finalizeState(context); + } +} diff --git a/examples/deck/gpu-culled-trace/gpu-trace-culling-effect.ts b/examples/deck/gpu-culled-trace/gpu-trace-culling-effect.ts new file mode 100644 index 0000000000..12d9106505 --- /dev/null +++ b/examples/deck/gpu-culled-trace/gpu-trace-culling-effect.ts @@ -0,0 +1,457 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Effect, EffectContext} from '@deck.gl/core'; +import {Buffer, type Device} from '@luma.gl/core'; +import {Computation} from '@luma.gl/engine'; +import { + DrawCommandBuffer, + GPUCommandGraph, + GPUCompaction, + GPUTextSelection, + type CompiledGPUCommandGraph +} from '@luma.gl/experimental'; +import type {DeckTraceData} from './trace-data'; +import {GPUCulledArrowTextLayer, type GPUCulledTextSource} from './gpu-culled-arrow-text-layer'; + +const UINT32_BYTE_LENGTH = Uint32Array.BYTES_PER_ELEMENT; +const MINIMUM_BLOCK_PIXELS = 1; + +type EffectResources = { + spans: Buffer; + visibleIds: Buffer; + rowFlags: Buffer; + cullingCounts: Buffer; + viewUniforms: Buffer; + blockDrawCommands: DrawCommandBuffer; +}; + +type TextSelectionResources = { + source: GPUCulledTextSource; + selectedGlyphIds: Buffer; + selectedGlyphRecords: Buffer; + drawCommands: DrawCommandBuffer; +}; + +export type GPUTraceCullingStats = { + totalBlocks: number; + visibleBlocks: number; + outsideBlocks: number; + smallBlocks: number; + totalGlyphs: number; + visibleGlyphs: number; + encodeTimeMilliseconds: number; + graphNodeCount: number; + logicalTransientBytes: number; + physicalTransientBytes: number; + transientReusePercentage: number; + labelStatus: string; +}; + +/** Coordinates one culling graph shared by the deck block and Arrow label layers. */ +export class GPUTraceCullingEffect implements Effect { + readonly id = 'gpu-trace-culling-effect'; + readonly props = {}; + readonly useInPicking = true; + readonly resources: EffectResources; + + private readonly device: Device; + private readonly count: number; + private compiled: CompiledGPUCommandGraph | null = null; + private textSelection: TextSelectionResources | null = null; + private textLayer: GPUCulledArrowTextLayer | null = null; + private labelStatus = 'Waiting for label layer'; + private readonly onStats?: (stats: GPUTraceCullingStats) => void; + private visibleBlocks = 0; + private outsideBlocks = 0; + private smallBlocks = 0; + private visibleGlyphs = 0; + private encodeTimeMilliseconds = 0; + private frameIndex = 0; + private statsReadPending = false; + + constructor( + device: Device, + data: DeckTraceData, + options: {onStats?: (stats: GPUTraceCullingStats) => void} = {} + ) { + if (device.type !== 'webgpu') throw new Error('GPUTraceCullingEffect requires WebGPU'); + this.device = device; + this.count = data.count; + this.onStats = options.onStats; + this.resources = { + spans: device.createBuffer({ + id: 'deck-gpu-trace-spans', + data: data.spans, + usage: Buffer.STORAGE | Buffer.COPY_DST + }), + visibleIds: device.createBuffer({ + id: 'deck-gpu-trace-visible-ids', + byteLength: data.count * UINT32_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }), + rowFlags: device.createBuffer({ + id: 'deck-gpu-trace-row-flags', + byteLength: data.count * UINT32_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }), + cullingCounts: device.createBuffer({ + id: 'deck-gpu-trace-culling-counts', + byteLength: UINT32_BYTE_LENGTH * 2, + usage: Buffer.STORAGE | Buffer.COPY_DST | Buffer.COPY_SRC + }), + viewUniforms: device.createBuffer({ + id: 'deck-gpu-trace-view-uniforms', + byteLength: 32, + usage: Buffer.UNIFORM | Buffer.COPY_DST + }), + blockDrawCommands: new DrawCommandBuffer(device, { + id: 'deck-gpu-trace-block-draw', + type: 'draw', + commands: [{vertexCount: 6, instanceCount: 0}] + }) + }; + this.rebuildGraph(); + } + + setup(_context: EffectContext): void {} + + setTextLayer(textLayer: GPUCulledArrowTextLayer): void { + this.textLayer = textLayer; + this.labelStatus = 'Preparing Arrow text renderer'; + this.publishStats(); + } + + setLabelError(error: unknown): void { + this.labelStatus = error instanceof Error ? error.message : String(error); + this.publishStats(); + } + + preRender(opts: Parameters[0]): void { + const viewport = opts.viewports[0]; + if (!viewport) return; + const topLeft = viewport.unproject([0, 0]); + const bottomRight = viewport.unproject([viewport.width, viewport.height]); + const timeMinimum = Math.min(topLeft[0]!, bottomRight[0]!); + const timeMaximum = Math.max(topLeft[0]!, bottomRight[0]!); + const laneMinimum = Math.min(topLeft[1]!, bottomRight[1]!); + const laneMaximum = Math.max(topLeft[1]!, bottomRight[1]!); + this.resources.viewUniforms.write( + new Float32Array([ + timeMinimum, + timeMaximum, + laneMinimum, + laneMaximum, + (timeMaximum - timeMinimum) / viewport.width, + (laneMaximum - laneMinimum) / viewport.height, + MINIMUM_BLOCK_PIXELS, + 0 + ]) + ); + + const textLayer = this.textLayer; + const preparation = textLayer?.getSelectionPreparation(); + const source = preparation?.source ?? null; + this.labelStatus = preparation?.status ?? this.labelStatus; + if (source && source.glyphRecords !== this.textSelection?.source.glyphRecords) { + this.createTextSelection(source); + } + textLayer?.setCulledDraw( + this.textSelection + ? { + selectedGlyphRecords: this.textSelection.selectedGlyphRecords, + drawCommands: this.textSelection.drawCommands + } + : null + ); + + const encodeStart = performance.now(); + this.resources.cullingCounts.write(new Uint32Array(2)); + this.compiled?.encode(this.device.commandEncoder, {parameters: undefined}); + this.encodeTimeMilliseconds = performance.now() - encodeStart; + this.frameIndex++; + if (this.frameIndex % 30 === 0) void this.sampleStats(); + } + + cleanup(_context: EffectContext): void { + this.compiled?.destroy(); + this.destroyTextSelection(); + this.resources.blockDrawCommands.destroy(); + this.resources.spans.destroy(); + this.resources.visibleIds.destroy(); + this.resources.rowFlags.destroy(); + this.resources.cullingCounts.destroy(); + this.resources.viewUniforms.destroy(); + this.textLayer = null; + } + + private createTextSelection(source: GPUCulledTextSource): void { + this.destroyTextSelection(); + const recordByteLength = source.recordWordLength * UINT32_BYTE_LENGTH; + this.textSelection = { + source, + selectedGlyphIds: this.device.createBuffer({ + id: 'deck-gpu-trace-selected-glyph-ids', + byteLength: Math.max(source.glyphCount * UINT32_BYTE_LENGTH, UINT32_BYTE_LENGTH), + usage: Buffer.STORAGE | Buffer.COPY_SRC + }), + selectedGlyphRecords: this.device.createBuffer({ + id: 'deck-gpu-trace-selected-glyph-records', + byteLength: Math.max(source.glyphCount * recordByteLength, recordByteLength), + usage: Buffer.STORAGE | Buffer.VERTEX | Buffer.COPY_SRC + }), + drawCommands: new DrawCommandBuffer(this.device, { + id: 'deck-gpu-trace-label-draw', + type: 'draw', + commands: [{vertexCount: 6, instanceCount: 0}] + }) + }; + this.rebuildGraph(); + } + + private rebuildGraph(): void { + this.compiled?.destroy(); + const graph = new GPUCommandGraph(this.device, {id: 'deck-gpu-trace-graph'}); + const spans = importBuffer(graph, 'spans', this.resources.spans); + const visibleIds = importBuffer(graph, 'visible-ids', this.resources.visibleIds); + const rowFlags = importBuffer(graph, 'row-flags', this.resources.rowFlags); + const cullingCounts = importBuffer(graph, 'culling-counts', this.resources.cullingCounts); + const viewUniforms = importBuffer(graph, 'view-uniforms', this.resources.viewUniforms); + const blockCommands = importBuffer( + graph, + 'block-draw-commands', + this.resources.blockDrawCommands.buffer + ); + const sourceIdsBuffer = graph.createTransientBuffer({ + id: 'source-ids', + byteLength: this.count * UINT32_BYTE_LENGTH, + usage: Buffer.STORAGE + }); + const sourceIds = graph.createDataView(sourceIdsBuffer, { + format: 'uint32', + length: this.count + }); + const rowFlagView = graph.createDataView(rowFlags, {format: 'uint32', length: this.count}); + const visibleIdView = graph.createDataView(visibleIds, { + format: 'uint32', + length: this.count + }); + const blockCount = graph.createDataView(blockCommands, { + format: 'uint32', + length: 1, + byteOffset: this.resources.blockDrawCommands.getInstanceCountByteOffset(0) + }); + + addVisibilityPass(graph, { + count: this.count, + spans, + viewUniforms, + sourceIds, + rowFlags: rowFlagView, + cullingCounts + }); + new GPUCompaction({ + id: 'visible-block-compaction', + input: sourceIds, + flags: rowFlagView, + output: visibleIdView, + count: blockCount + }).addToGraph(graph); + + if (this.textSelection) { + const {source, selectedGlyphIds, selectedGlyphRecords, drawCommands} = this.textSelection; + const glyphRecords = importBuffer(graph, 'glyph-records', source.glyphRecords); + const selectedIds = importBuffer(graph, 'selected-glyph-ids', selectedGlyphIds); + const selectedRecords = importBuffer(graph, 'selected-glyph-records', selectedGlyphRecords); + const textCommands = importBuffer(graph, 'text-draw-commands', drawCommands.buffer); + const glyphRows = graph.createDataView(glyphRecords, { + format: 'uint32', + length: source.glyphCount, + byteOffset: UINT32_BYTE_LENGTH * 2, + byteStride: source.recordWordLength * UINT32_BYTE_LENGTH + }); + new GPUTextSelection({ + id: 'visible-text-selection', + glyphRows, + rowFlags: rowFlagView, + output: graph.createDataView(selectedIds, { + format: 'uint32', + length: source.glyphCount + }), + count: graph.createDataView(textCommands, { + format: 'uint32', + length: 1, + byteOffset: drawCommands.getInstanceCountByteOffset(0) + }), + sourceRecords: graph.createDataView(glyphRecords, { + format: 'uint32', + length: source.glyphCount * source.recordWordLength + }), + outputRecords: graph.createDataView(selectedRecords, { + format: 'uint32', + length: source.glyphCount * source.recordWordLength + }), + recordWordLength: source.recordWordLength + }).addToGraph(graph); + } + this.compiled = graph.compile(); + this.publishStats(); + } + + private async sampleStats(): Promise { + if (this.statsReadPending) return; + this.statsReadPending = true; + const textSelection = this.textSelection; + try { + const blockBytes = await this.resources.blockDrawCommands.buffer.readAsync( + this.resources.blockDrawCommands.getInstanceCountByteOffset(0), + UINT32_BYTE_LENGTH + ); + this.visibleBlocks = new Uint32Array(blockBytes.buffer, blockBytes.byteOffset, 1)[0]!; + const cullingCountBytes = await this.resources.cullingCounts.readAsync( + 0, + UINT32_BYTE_LENGTH * 2 + ); + const cullingCounts = new Uint32Array( + cullingCountBytes.buffer, + cullingCountBytes.byteOffset, + 2 + ); + this.outsideBlocks = cullingCounts[0]!; + this.smallBlocks = cullingCounts[1]!; + if (textSelection) { + const glyphBytes = await textSelection.drawCommands.buffer.readAsync( + textSelection.drawCommands.getInstanceCountByteOffset(0), + UINT32_BYTE_LENGTH + ); + if (this.textSelection === textSelection) { + this.visibleGlyphs = new Uint32Array(glyphBytes.buffer, glyphBytes.byteOffset, 1)[0]!; + } + } + this.publishStats(); + } finally { + this.statsReadPending = false; + } + } + + private publishStats(): void { + const graphStats = this.compiled?.stats; + this.onStats?.({ + totalBlocks: this.count, + visibleBlocks: this.visibleBlocks, + outsideBlocks: this.outsideBlocks, + smallBlocks: this.smallBlocks, + totalGlyphs: this.textSelection?.source.glyphCount ?? 0, + visibleGlyphs: this.visibleGlyphs, + encodeTimeMilliseconds: this.encodeTimeMilliseconds, + graphNodeCount: graphStats?.nodeOrder.length ?? 0, + logicalTransientBytes: graphStats?.logicalTransientBytes ?? 0, + physicalTransientBytes: graphStats?.physicalTransientBytes ?? 0, + transientReusePercentage: graphStats?.reusePercentage ?? 0, + labelStatus: this.labelStatus + }); + } + + private destroyTextSelection(): void { + if (!this.textSelection) return; + this.textSelection.selectedGlyphIds.destroy(); + this.textSelection.selectedGlyphRecords.destroy(); + this.textSelection.drawCommands.destroy(); + this.textSelection = null; + } +} + +function importBuffer(graph: GPUCommandGraph, id: string, buffer: Buffer) { + return graph.importBuffer({id, byteLength: buffer.byteLength, usage: buffer.usage}, buffer); +} + +function addVisibilityPass( + graph: GPUCommandGraph, + props: { + count: number; + spans: ReturnType; + viewUniforms: ReturnType; + sourceIds: ReturnType['createDataView']>; + rowFlags: ReturnType['createDataView']>; + cullingCounts: ReturnType; + } +): void { + const source = /* wgsl */ ` +struct TraceSpan { start: f32, duration: f32, lane: u32, group: u32 }; +struct ViewUniforms { + timeMin: f32, + timeMax: f32, + laneMin: f32, + laneMax: f32, + timeUnitsPerPixel: f32, + laneUnitsPerPixel: f32, + minimumBlockPixels: f32, + padding: f32 +}; +struct CullingCounts { outside: atomic, tooSmall: atomic }; +const SPAN_COUNT: u32 = ${props.count}u; +@group(0) @binding(0) var spans: array; +@group(0) @binding(1) var viewUniforms: ViewUniforms; +@group(0) @binding(2) var sourceIds: array; +@group(0) @binding(3) var rowFlags: array; +@group(0) @binding(4) var cullingCounts: CullingCounts; +@compute @workgroup_size(256) fn main(@builtin(global_invocation_id) globalId: vec3) { + let rowIndex = globalId.x; + if (rowIndex >= SPAN_COUNT) { return; } + let span = spans[rowIndex]; + let timeVisible = span.start + span.duration >= viewUniforms.timeMin && + span.start <= viewUniforms.timeMax; + let lane = f32(span.lane); + let laneVisible = lane + 1.0 >= viewUniforms.laneMin && lane <= viewUniforms.laneMax; + let largeEnough = span.duration >= + viewUniforms.timeUnitsPerPixel * viewUniforms.minimumBlockPixels && + 1.0 >= viewUniforms.laneUnitsPerPixel * viewUniforms.minimumBlockPixels; + let insideView = timeVisible && laneVisible; + if (!insideView) { + atomicAdd(&cullingCounts.outside, 1u); + } else if (!largeEnough) { + atomicAdd(&cullingCounts.tooSmall, 1u); + } + sourceIds[rowIndex] = rowIndex; + rowFlags[rowIndex] = select(0u, 1u, insideView && largeEnough); +}`; + graph.addComputePass({ + id: 'trace-visibility', + resources: [ + {buffer: props.spans, usage: 'storage-read'}, + {buffer: props.viewUniforms, usage: 'uniform'}, + {buffer: props.sourceIds, usage: 'storage-write'}, + {buffer: props.rowFlags, usage: 'storage-write'}, + {buffer: props.cullingCounts, usage: 'storage-write'} + ], + compile: ({device}) => { + const computation = new Computation(device, { + id: 'trace-visibility', + source, + shaderLayout: { + bindings: [ + {name: 'spans', type: 'storage', group: 0, location: 0}, + {name: 'viewUniforms', type: 'uniform', group: 0, location: 1}, + {name: 'sourceIds', type: 'storage', group: 0, location: 2}, + {name: 'rowFlags', type: 'storage', group: 0, location: 3}, + {name: 'cullingCounts', type: 'storage', group: 0, location: 4} + ] + } + }); + return { + encode: ({computePass, getBuffer}) => { + computation.setBindings({ + spans: getBuffer(props.spans), + viewUniforms: getBuffer(props.viewUniforms), + sourceIds: getBuffer(props.sourceIds), + rowFlags: getBuffer(props.rowFlags), + cullingCounts: getBuffer(props.cullingCounts) + }); + computation.dispatch(computePass, Math.ceil(props.count / 256)); + }, + destroy: () => computation.destroy() + }; + } + }); +} diff --git a/examples/deck/gpu-culled-trace/index.html b/examples/deck/gpu-culled-trace/index.html new file mode 100644 index 0000000000..6177c3f9e6 --- /dev/null +++ b/examples/deck/gpu-culled-trace/index.html @@ -0,0 +1,13 @@ + + + +
+ WebGPU · 25K spans · drag/scroll to inspect +
+
+
+
+ diff --git a/examples/deck/gpu-culled-trace/package.json b/examples/deck/gpu-culled-trace/package.json new file mode 100644 index 0000000000..32d9798b09 --- /dev/null +++ b/examples/deck/gpu-culled-trace/package.json @@ -0,0 +1,27 @@ +{ + "name": "luma.gl-examples-deck-gpu-culled-trace", + "version": "9.3.0", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "build": "tsc && vite build", + "serve": "vite preview" + }, + "dependencies": { + "@deck.gl-community/arrow-layers": "workspace:*", + "@deck.gl/core": "9.3.4", + "@luma.gl/arrow": "~9.3.0", + "@luma.gl/core": "~9.3.0", + "@luma.gl/engine": "~9.3.0", + "@luma.gl/experimental": "~9.3.0", + "@luma.gl/shadertools": "~9.3.0", + "@luma.gl/text": "~9.3.0", + "@luma.gl/webgpu": "~9.3.0", + "apache-arrow": "^17.0.0" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vite": "^8.0.0" + } +} diff --git a/examples/deck/gpu-culled-trace/trace-data.ts b/examples/deck/gpu-culled-trace/trace-data.ts new file mode 100644 index 0000000000..70f0340036 --- /dev/null +++ b/examples/deck/gpu-culled-trace/trace-data.ts @@ -0,0 +1,93 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {makeArrowFixedSizeListVector} from '@luma.gl/arrow'; +import * as arrow from 'apache-arrow'; +import { + makeTraceGroups, + TRACE_GROUPS, + type TraceGroupName +} from '../../experimental/gpu-trace-viewer/trace-data'; + +export type DeckTraceData = { + count: number; + spans: Uint32Array; + textTable: arrow.Table; + names: string[]; +}; + +/** Builds one globally indexed span table shared by blocks, labels, and picking. */ +export function makeDeckTraceData(count: number): DeckTraceData { + const groups = makeTraceGroups(count); + const spans = new Uint32Array(count * 4); + const positions = new Float32Array(count * 2); + const clipRects = new Float32Array(count * 4); + const names = new Array(count); + const spanFloats = new Float32Array(spans.buffer); + let rowIndex = 0; + + for (const group of groups) { + const groupFloats = new Float32Array( + group.data.buffer, + group.data.byteOffset, + group.data.length + ); + for (let groupRowIndex = 0; groupRowIndex < group.count; groupRowIndex++, rowIndex++) { + const sourceWordOffset = groupRowIndex * 4; + const destinationWordOffset = rowIndex * 4; + const start = groupFloats[sourceWordOffset]!; + const duration = groupFloats[sourceWordOffset + 1]!; + const lane = group.data[sourceWordOffset + 2]!; + spanFloats[destinationWordOffset] = start; + spanFloats[destinationWordOffset + 1] = duration; + spans[destinationWordOffset + 2] = lane; + spans[destinationWordOffset + 3] = group.groupIndex; + positions[rowIndex * 2] = start; + positions[rowIndex * 2 + 1] = lane + 0.5; + clipRects[destinationWordOffset] = 0; + clipRects[destinationWordOffset + 1] = -0.5; + clipRects[destinationWordOffset + 2] = duration; + clipRects[destinationWordOffset + 3] = 1; + names[rowIndex] = makeTraceName(group.name, rowIndex); + } + } + + return { + count, + spans, + names, + textTable: new arrow.Table({ + positions: makeArrowFixedSizeListVector(new arrow.Float32(), 2, positions), + texts: arrow.vectorFromArray(names, new arrow.Utf8()), + clipRects: makeArrowFixedSizeListVector(new arrow.Float32(), 4, clipRects) + }) + }; +} + +export function getTraceRow( + data: DeckTraceData, + rowIndex: number +): { + name: string; + group: TraceGroupName; + start: number; + duration: number; + lane: number; +} | null { + if (rowIndex < 0 || rowIndex >= data.count) return null; + const wordOffset = rowIndex * 4; + const floats = new Float32Array(data.spans.buffer, data.spans.byteOffset, data.spans.length); + return { + name: data.names[rowIndex]!, + group: TRACE_GROUPS[data.spans[wordOffset + 3]!]!, + start: floats[wordOffset]!, + duration: floats[wordOffset + 1]!, + lane: data.spans[wordOffset + 2]! + }; +} + +function makeTraceName(group: TraceGroupName, rowIndex: number): string { + const prefix = group === 'compute' ? 'μtask' : group === 'network' ? 'café' : 'parse'; + return `${group} ${prefix}-${String(rowIndex).padStart(6, '0')}`; +} diff --git a/examples/deck/gpu-culled-trace/tsconfig.json b/examples/deck/gpu-culled-trace/tsconfig.json new file mode 100644 index 0000000000..b0bddb4d38 --- /dev/null +++ b/examples/deck/gpu-culled-trace/tsconfig.json @@ -0,0 +1 @@ +{"extends": "../../../examples/arrow/arrow-text-2d/tsconfig.json", "include": ["."]} diff --git a/examples/deck/gpu-culled-trace/vite.config.ts b/examples/deck/gpu-culled-trace/vite.config.ts new file mode 100644 index 0000000000..f21008c1cd --- /dev/null +++ b/examples/deck/gpu-culled-trace/vite.config.ts @@ -0,0 +1,18 @@ +import {defineConfig} from 'vite'; + +export default defineConfig({ + resolve: { + alias: { + '@deck.gl-community/arrow-layers': __dirname + '/../../../modules/arrow-layers/src', + '@luma.gl/arrow': __dirname + '/../../../modules/arrow/src', + '@math.gl/geoarrow': __dirname + '/../../../modules/geoarrow/src', + '@luma.gl/core': __dirname + '/../../../modules/core/src', + '@luma.gl/engine': __dirname + '/../../../modules/engine/src', + '@luma.gl/experimental': __dirname + '/../../../modules/experimental/src', + '@luma.gl/shadertools': __dirname + '/../../../modules/shadertools/src', + '@luma.gl/tables': __dirname + '/../../../modules/tables/src', + '@luma.gl/text': __dirname + '/../../../modules/text/src' + } + }, + server: {open: true} +}); diff --git a/examples/experimental/text-space-crawl/atlas-text-renderer.ts b/examples/experimental/text-space-crawl/atlas-text-renderer.ts index bf52b18482..1f5fff110d 100644 --- a/examples/experimental/text-space-crawl/atlas-text-renderer.ts +++ b/examples/experimental/text-space-crawl/atlas-text-renderer.ts @@ -10,6 +10,7 @@ import { import type {CommandEncoder, Device, RenderPass} from '@luma.gl/core'; import type {ShaderModule} from '@luma.gl/shadertools'; import {measureFontAtlasText, type FontAtlas, type TextGlyphLayout} from '@luma.gl/text'; +import type {TextAttributeModel} from '@luma.gl/text/experimental'; import type {Text3DBounds} from '@luma.gl/text/text-3d'; import * as arrow from 'apache-arrow'; import { @@ -192,7 +193,7 @@ export class AtlasTextRenderer implements TextSpaceCrawlRenderer { function makeAtlasTextTable(textRows: readonly string[], fontAtlas: FontAtlas): AtlasTextTable { const positions = new Float32Array(textRows.length * 2); - const clipRects = new Int16Array(textRows.length * DISABLED_CLIP_RECT.length); + const clipRects = new Float32Array(textRows.length * DISABLED_CLIP_RECT.length); const colors = new Uint8Array(textRows.length * OPAQUE_WHITE.length); for (const [rowIndex, textRow] of textRows.entries()) { const metrics = measureFontAtlasText(textRow, fontAtlas); @@ -204,7 +205,7 @@ function makeAtlasTextTable(textRows: readonly string[], fontAtlas: FontAtlas): return new arrow.Table({ positions: makeArrowFixedSizeListVector(new arrow.Float32(), 2, positions), texts: arrow.vectorFromArray([...textRows], new arrow.Utf8()) as arrow.Vector, - clipRects: makeArrowFixedSizeListVector(new arrow.Int16(), 4, clipRects), + clipRects: makeArrowFixedSizeListVector(new arrow.Float32(), 4, clipRects), colors: makeArrowFixedSizeListVector(new arrow.Uint8(), 4, colors) }); } @@ -266,10 +267,10 @@ function measureAtlasTextBounds( } function getTextGlyphLayout(textRenderer: ArrowTextRenderer): TextGlyphLayout { - if (!('glyphLayout' in textRenderer.model)) { + if (!('attributeState' in textRenderer.model)) { throw new Error('Atlas text crawl requires the attribute text model'); } - return textRenderer.model.glyphLayout; + return (textRenderer.model as TextAttributeModel).attributeState.glyphLayout; } function extendBounds(bounds: Text3DBounds, point: [number, number, number]): void { diff --git a/modules/arrow-layers/src/layers/arrow-layer-types.ts b/modules/arrow-layers/src/layers/arrow-layer-types.ts index f7ea04a50d..2ad525c561 100644 --- a/modules/arrow-layers/src/layers/arrow-layer-types.ts +++ b/modules/arrow-layers/src/layers/arrow-layer-types.ts @@ -62,6 +62,12 @@ struct DeckArrowViewportUniforms { center: vec2, worldToClipScale: vec2, pixelToClipScale: vec2, + worldToPixelScale: vec2, + viewportSize: vec2, + contentCutoffPixels: vec2, + contentAlign: vec2, + flipY: u32, + _padding: u32, }; @group(0) @binding(auto) var deckArrowViewport: DeckArrowViewportUniforms; @@ -73,16 +79,102 @@ fn deck_projectPosition(position: vec3) -> vec4 { 1.0 ); } + +fn deck_getContentAlignmentOffset( + anchor: f32, + extent: f32, + clipStart: f32, + clipEnd: f32, + mode: u32 +) -> f32 { + if (clipEnd < clipStart) { return 0.0; } + if (mode == 1u) { return max(-(anchor + clipStart), 0.0); } + if (mode == 2u) { + let visibleStart = max(0.0, anchor + clipStart); + let visibleEnd = min(extent, anchor + clipEnd); + return select(0.0, (visibleStart + visibleEnd) * 0.5 - anchor, visibleStart < visibleEnd); + } + if (mode == 3u) { return min(extent - (anchor + clipEnd), 0.0); } + return 0.0; +} + +fn deck_getTextAnchorScreen(anchorClip: vec4) -> vec2 { + let normalized = anchorClip.xy / anchorClip.w; + return vec2(normalized.x + 1.0, 1.0 - normalized.y) * 0.5 * + deckArrowViewport.viewportSize; +} + +fn deck_getTextClipRectPixels(clipRect: vec4) -> vec4 { + var origin = clipRect.xy * deckArrowViewport.worldToPixelScale; + let size = clipRect.zw * deckArrowViewport.worldToPixelScale; + if (deckArrowViewport.flipY != 0u) { origin.y = -origin.y - size.y; } + return vec4(origin, size); +} + +fn deck_getTextContentOffset(anchorClip: vec4, clipRect: vec4) -> vec2 { + let anchor = deck_getTextAnchorScreen(anchorClip); + let rect = deck_getTextClipRectPixels(clipRect); + return vec2( + deck_getContentAlignmentOffset( + anchor.x, + deckArrowViewport.viewportSize.x, + rect.x, + rect.x + rect.z, + deckArrowViewport.contentAlign.x + ), + -deck_getContentAlignmentOffset( + anchor.y, + deckArrowViewport.viewportSize.y, + -rect.y - rect.w, + -rect.y, + deckArrowViewport.contentAlign.y + ) + ); +} + +fn deck_isTextContentVisible( + pixelOffset: vec2, + anchorClip: vec4, + clipRect: vec4 +) -> bool { + let anchor = deck_getTextAnchorScreen(anchorClip); + let rect = deck_getTextClipRectPixels(clipRect); + if (rect.z >= 0.0) { + if (pixelOffset.x < rect.x || pixelOffset.x > rect.x + rect.z) { return false; } + let visibleStart = max(anchor.x + rect.x, 0.0); + let visibleEnd = min(anchor.x + rect.x + rect.z, deckArrowViewport.viewportSize.x); + if (visibleEnd - visibleStart < deckArrowViewport.contentCutoffPixels.x) { return false; } + } + if (rect.w >= 0.0) { + if (pixelOffset.y < rect.y || pixelOffset.y > rect.y + rect.w) { return false; } + let visibleStart = max(anchor.y - rect.y - rect.w, 0.0); + let visibleEnd = min(anchor.y - rect.y, deckArrowViewport.viewportSize.y); + if (visibleEnd - visibleStart < deckArrowViewport.contentCutoffPixels.y) { return false; } + } + return true; +} `, uniformTypes: { center: 'vec2', worldToClipScale: 'vec2', - pixelToClipScale: 'vec2' + pixelToClipScale: 'vec2', + worldToPixelScale: 'vec2', + viewportSize: 'vec2', + contentCutoffPixels: 'vec2', + contentAlign: 'vec2', + flipY: 'u32', + _padding: 'u32' }, defaultUniforms: { center: [0, 0], worldToClipScale: [1, -1], - pixelToClipScale: [1, 1] + pixelToClipScale: [1, 1], + worldToPixelScale: [1, 1], + viewportSize: [1, 1], + contentCutoffPixels: [0, 0], + contentAlign: [0, 0], + flipY: 1, + _padding: 0 } } as const satisfies ShaderModule; @@ -97,7 +189,12 @@ export function setDeckArrowViewport( zoomX?: number; zoomY?: number; flipY?: boolean; - } + }, + textOptions: { + contentCutoffPixels?: readonly [number, number]; + contentAlignHorizontal?: 'none' | 'start' | 'center' | 'end'; + contentAlignVertical?: 'none' | 'start' | 'center' | 'end'; + } = {} ): void { const zoomX = viewport.zoomX ?? viewport.zoom ?? 0; const zoomY = viewport.zoomY ?? viewport.zoom ?? 0; @@ -109,11 +206,33 @@ export function setDeckArrowViewport( (2 * 2 ** zoomX) / Math.max(viewport.width, 1), ((viewport.flipY === false ? 2 : -2) * 2 ** zoomY) / Math.max(viewport.height, 1) ], - pixelToClipScale: [2 / Math.max(viewport.width, 1), 2 / Math.max(viewport.height, 1)] + pixelToClipScale: [2 / Math.max(viewport.width, 1), 2 / Math.max(viewport.height, 1)], + worldToPixelScale: [2 ** zoomX, 2 ** zoomY], + viewportSize: [viewport.width, viewport.height], + contentCutoffPixels: textOptions.contentCutoffPixels ?? [0, 0], + contentAlign: [ + getDeckArrowContentAlign(textOptions.contentAlignHorizontal), + getDeckArrowContentAlign(textOptions.contentAlignVertical) + ], + flipY: viewport.flipY === false ? 0 : 1, + _padding: 0 } }); } +function getDeckArrowContentAlign(value: 'none' | 'start' | 'center' | 'end' | undefined): number { + switch (value) { + case 'start': + return 1; + case 'center': + return 2; + case 'end': + return 3; + default: + return 0; + } +} + /** Arrow row identity attached to deck.gl picking results. */ export type ArrowLayerPickingInfo = PickingInfo & { arrow?: { diff --git a/modules/arrow-layers/src/layers/arrow-text-layer.ts b/modules/arrow-layers/src/layers/arrow-text-layer.ts index 506526410e..19a203e819 100644 --- a/modules/arrow-layers/src/layers/arrow-text-layer.ts +++ b/modules/arrow-layers/src/layers/arrow-text-layer.ts @@ -30,6 +30,7 @@ import {ShaderInputs, type Model} from '@luma.gl/engine'; import type {GPUTextData} from '@luma.gl/text'; import { makeTextGlyphAlphaGlsl, + supportsGpuTextExpansion, type TextGlyphAlphaShaderSettings } from '@luma.gl/text/experimental'; import {GPUVector, type GPURecordBatchSourceInfo, type VertexList} from '@luma.gl/tables'; @@ -43,6 +44,8 @@ import { } from './arrow-layer-types'; import type {ArrowLayerPickingInfo} from './arrow-layer-types'; import { + DECK_TEXT_ROW_INDEXED_STORAGE_SHADER_LAYOUT, + DECK_TEXT_ROW_INDEXED_STORAGE_WGSL, DECK_TEXT_STORAGE_SHADER_LAYOUT, DECK_TEXT_STORAGE_WGSL } from './arrow-text-storage-shaders'; @@ -82,7 +85,7 @@ in ivec2 glyphOffsets; in uvec4 glyphFrames; in uint glyphPages; in uint rowIndices; -in ivec4 glyphClipRects; +in vec4 glyphClipRects; in vec4 colors; in float angles; in float sizes; @@ -99,6 +102,8 @@ layout(std140) uniform textViewportUniforms { float textSdfThreshold; float textSdfSmoothing; float textMsdfDistanceRange; + vec2 contentCutoffPixels; + vec2 contentAlign; } textViewport; uniform highp sampler2DArray fontAtlasTexture; @@ -124,22 +129,16 @@ vec2 getCorner(int vertexIndex) { return vec2(0.0, 1.0); } -bool isGlyphVertexClipped(vec2 glyphVertexOffset, ivec4 clipRect) { - if (clipRect.z >= 0) { - float clipMinX = float(clipRect.x); - float clipMaxX = clipMinX + float(clipRect.z); - if (glyphVertexOffset.x < clipMinX || glyphVertexOffset.x > clipMaxX) { - return true; - } - } - if (clipRect.w >= 0) { - float clipMinY = float(clipRect.y); - float clipMaxY = clipMinY + float(clipRect.w); - if (glyphVertexOffset.y < clipMinY || glyphVertexOffset.y > clipMaxY) { - return true; - } +float getContentAlignmentOffset(float anchor, float extent, float start, float end, float mode) { + if (end < start) return 0.0; + if (mode == 1.0) return max(-(anchor + start), 0.0); + if (mode == 2.0) { + float visibleStart = max(0.0, anchor + start); + float visibleEnd = min(extent, anchor + end); + return visibleStart < visibleEnd ? (visibleStart + visibleEnd) * 0.5 - anchor : 0.0; } - return false; + if (mode == 3.0) return min(extent - (anchor + end), 0.0); + return 0.0; } void main() { @@ -153,16 +152,53 @@ void main() { vec2 glyphPixelOffset = glyphVertexOffset * textViewport.glyphWorldScale * (rowSize / 32.0); mat2 rowRotation = mat2(cos(rowAngle), sin(rowAngle), -sin(rowAngle), cos(rowAngle)); glyphPixelOffset = rowRotation * glyphPixelOffset + pixelOffsets; - vec4 clipPosition = project_position_to_clipspace(vec3(positions, 0.0), vec3(0.0), vec3(0.0)); + glyphPixelOffset.y *= -1.0; + vec4 anchorPosition = project_position_to_clipspace(vec3(positions, 0.0), vec3(0.0), vec3(0.0)); + vec2 anchorScreen = vec2( + anchorPosition.x / anchorPosition.w + 1.0, + 1.0 - anchorPosition.y / anchorPosition.w + ) * 0.5 * project.viewportSize / project.devicePixelRatio; + vec2 clipOrigin = project_size_to_pixel(glyphClipRects.xy); + vec2 clipSize = project_size_to_pixel(glyphClipRects.zw); + vec2 viewportPixels = project.viewportSize / project.devicePixelRatio; + vec2 contentOffset = vec2( + getContentAlignmentOffset( + anchorScreen.x, + viewportPixels.x, + clipOrigin.x, + clipOrigin.x + clipSize.x, + textViewport.contentAlign.x + ), + -getContentAlignmentOffset( + anchorScreen.y, + viewportPixels.y, + -clipOrigin.y - clipSize.y, + -clipOrigin.y, + textViewport.contentAlign.y + ) + ); + glyphPixelOffset += contentOffset; + vec4 clipPosition = anchorPosition; clipPosition.xy += project_pixel_size_to_clipspace(glyphPixelOffset) * clipPosition.w; vec2 atlasSize = vec2(textureSize(fontAtlasTexture, 0).xy); - vec2 atlasCorner = vec2(corner.x, 1.0 - corner.y); - vec2 atlasPixel = glyphFrame.xy + atlasCorner * glyphSize; - - gl_Position = textViewport.clippingEnabled > 0.5 && - isGlyphVertexClipped(glyphVertexOffset, glyphClipRects) - ? vec4(0.0) - : clipPosition; + vec2 atlasPixel = glyphFrame.xy + corner * glyphSize; + + bool visible = true; + if (textViewport.clippingEnabled > 0.5 && glyphClipRects.z >= 0.0) { + visible = glyphPixelOffset.x >= clipOrigin.x && + glyphPixelOffset.x <= clipOrigin.x + clipSize.x; + float visibleStart = max(anchorScreen.x + clipOrigin.x, 0.0); + float visibleEnd = min(anchorScreen.x + clipOrigin.x + clipSize.x, viewportPixels.x); + visible = visible && visibleEnd - visibleStart >= textViewport.contentCutoffPixels.x; + } + if (textViewport.clippingEnabled > 0.5 && glyphClipRects.w >= 0.0) { + visible = visible && glyphPixelOffset.y >= clipOrigin.y && + glyphPixelOffset.y <= clipOrigin.y + clipSize.y; + float visibleStart = max(anchorScreen.y - clipOrigin.y - clipSize.y, 0.0); + float visibleEnd = min(anchorScreen.y - clipOrigin.y, viewportPixels.y); + visible = visible && visibleEnd - visibleStart >= textViewport.contentCutoffPixels.y; + } + gl_Position = visible ? clipPosition : vec4(0.0); geometry.position = gl_Position; geometry.pickingColor = encodeDeckPickingColor(int(rowIndices)); DECKGL_FILTER_GL_POSITION(gl_Position, geometry); @@ -187,6 +223,8 @@ layout(std140) uniform textViewportUniforms { float textSdfThreshold; float textSdfSmoothing; float textMsdfDistanceRange; + vec2 contentCutoffPixels; + vec2 contentAlign; } textViewport; uniform highp sampler2DArray fontAtlasTexture; @@ -222,7 +260,7 @@ struct TextVertexInputs { @location(2) glyphFrames: vec4, @location(3) glyphPages: u32, @location(4) rowIndices: u32, - @location(5) glyphClipRects: vec4, + @location(5) glyphClipRects: vec4, @location(6) colors: vec4, @location(7) angles: f32, @location(8) sizes: f32, @@ -247,20 +285,6 @@ fn getCorner(vertexIndex: u32) -> vec2 { return vec2(0.0, 1.0); } -fn isGlyphVertexClipped(glyphVertexOffset: vec2, clipRect: vec4) -> bool { - if (clipRect.z >= 0) { - let clipMinX = f32(clipRect.x); - let clipMaxX = clipMinX + f32(clipRect.z); - if (glyphVertexOffset.x < clipMinX || glyphVertexOffset.x > clipMaxX) { return true; } - } - if (clipRect.w >= 0) { - let clipMinY = f32(clipRect.y); - let clipMaxY = clipMinY + f32(clipRect.w); - if (glyphVertexOffset.y < clipMinY || glyphVertexOffset.y > clipMaxY) { return true; } - } - return false; -} - @vertex fn vertexMain(inputs: TextVertexInputs, @builtin(vertex_index) vertexIndex: u32) -> TextVertexOutputs { var outputs: TextVertexOutputs; @@ -273,22 +297,21 @@ fn vertexMain(inputs: TextVertexInputs, @builtin(vertex_index) vertexIndex: u32) var glyphPixelOffset = glyphVertexOffset * 0.36 * (inputs.sizes / 32.0); let rowRotation = mat2x2(cos(rowAngle), sin(rowAngle), -sin(rowAngle), cos(rowAngle)); glyphPixelOffset = rowRotation * glyphPixelOffset + inputs.pixelOffsets; - var clipPosition = deck_projectPosition(vec3(inputs.positions, 0.0)); + glyphPixelOffset.y *= -1.0; + let anchorPosition = deck_projectPosition(vec3(inputs.positions, 0.0)); + glyphPixelOffset += deck_getTextContentOffset(anchorPosition, inputs.glyphClipRects); + var clipPosition = anchorPosition; clipPosition.x += glyphPixelOffset.x * deckArrowViewport.pixelToClipScale.x * clipPosition.w; clipPosition.y += glyphPixelOffset.y * deckArrowViewport.pixelToClipScale.y * clipPosition.w; let atlasSize = vec2(textureDimensions(fontAtlasTexture)); - let atlasCorner = vec2(corner.x, 1.0 - corner.y); - let atlasPixel = glyphFrame.xy + atlasCorner * glyphSize; + let atlasPixel = glyphFrame.xy + corner * glyphSize; - outputs.position = clipPosition; + let visible = deck_isTextContentVisible(glyphPixelOffset, anchorPosition, inputs.glyphClipRects); + outputs.position = select(vec4(0.0), clipPosition, visible); outputs.textureCoordinate = atlasPixel / atlasSize; outputs.color = inputs.colors; outputs.pickingColor = deck_encodePickingColor(inputs.rowIndices); - outputs.visible = select( - 1.0, - 0.0, - isGlyphVertexClipped(glyphVertexOffset, inputs.glyphClipRects) - ); + outputs.visible = 1.0; outputs.atlasPage = inputs.glyphPages; return outputs; } @@ -318,7 +341,7 @@ const DECK_TEXT_SHADER_LAYOUT: ShaderLayout = { {name: 'glyphFrames', location: 2, type: 'vec4', stepMode: 'instance'}, {name: 'glyphPages', location: 3, type: 'u32', stepMode: 'instance'}, {name: 'rowIndices', location: 4, type: 'u32', stepMode: 'instance'}, - {name: 'glyphClipRects', location: 5, type: 'vec4', stepMode: 'instance'}, + {name: 'glyphClipRects', location: 5, type: 'vec4', stepMode: 'instance'}, {name: 'colors', location: 6, type: 'vec4', stepMode: 'instance'}, {name: 'angles', location: 7, type: 'f32', stepMode: 'instance'}, {name: 'sizes', location: 8, type: 'f32', stepMode: 'instance'}, @@ -332,6 +355,12 @@ export type ArrowTextLayerProps = Omit & Omit & { /** Constant color, color source, or color source with an explicit null replacement. */ color?: ArrowTextColorInput; + /** Minimum visible clip rectangle in screen pixels before text is hidden. */ + contentCutoffPixels?: [number, number]; + /** Deck-style horizontal alignment within the visible portion of a clip rectangle. */ + contentAlignHorizontal?: 'none' | 'start' | 'center' | 'end'; + /** Deck-style vertical alignment within the visible portion of a clip rectangle. */ + contentAlignVertical?: 'none' | 'start' | 'center' | 'end'; }; type ArrowTextLayerState = { @@ -349,7 +378,10 @@ export class ArrowTextLayer extends Layer { static override layerName = 'ArrowTextLayer'; static override defaultProps = { data: {type: 'object', value: null, optional: true}, - parameters: DECK_ARROW_ALPHA_BLEND_PARAMETERS + parameters: DECK_ARROW_ALPHA_BLEND_PARAMETERS, + contentCutoffPixels: {type: 'array', value: [0, 0]}, + contentAlignHorizontal: 'none', + contentAlignVertical: 'none' }; override getAttributeManager() { @@ -393,7 +425,10 @@ export class ArrowTextLayer extends Layer { props.model !== oldProps.model || props.color !== oldProps.color || props.angle !== oldProps.angle || - props.size !== oldProps.size; + props.size !== oldProps.size || + props.pixelOffset !== oldProps.pixelOffset || + props.textAnchor !== oldProps.textAnchor || + props.alignmentBaseline !== oldProps.alignmentBaseline; if (sourceChanged) { state.sourceInitialized = true; @@ -408,7 +443,10 @@ export class ArrowTextLayer extends Layer { model: props.model, fontAtlas: props.fontAtlas, angle: props.angle, - size: props.size + size: props.size, + pixelOffset: props.pixelOffset, + textAnchor: props.textAnchor, + alignmentBaseline: props.alignmentBaseline }); } } @@ -424,7 +462,11 @@ export class ArrowTextLayer extends Layer { return; } if (renderer.model.device.type === 'webgpu') { - setDeckArrowViewport(renderer.model, context.viewport); + setDeckArrowViewport(renderer.model, context.viewport, { + contentCutoffPixels: this.props.contentCutoffPixels, + contentAlignHorizontal: this.props.contentAlignHorizontal, + contentAlignVertical: this.props.contentAlignVertical + }); } else { renderer.shaderInputs.setProps({ textViewport: { @@ -436,10 +478,23 @@ export class ArrowTextLayer extends Layer { glyphWorldScale: 0.36, time: 0, clippingEnabled: this.props.clipRects === null ? 0 : 1, - colorsEnabled: getArrowLayerInputSource(this.props.color, isArrowLayerColor) ? 1 : 0 + colorsEnabled: getArrowLayerInputSource(this.props.color, isArrowLayerColor) ? 1 : 0, + contentCutoffPixels: this.props.contentCutoffPixels ?? [0, 0], + contentAlign: [ + getContentAlignEnum(this.props.contentAlignHorizontal), + getContentAlignEnum(this.props.contentAlignVertical) + ] } } as never); } + this.drawTextRenderer(renderer, renderPass); + } + + /** Draw hook for WebGPU-only example layers that replace the normal instance draw. */ + protected drawTextRenderer( + renderer: ArrowTextRenderer, + renderPass: Parameters['draw']>[0]['renderPass'] + ): void { renderer.draw(renderPass); } @@ -539,14 +594,23 @@ export class ArrowTextLayer extends Layer { color: colorNullValue, model, attributeShaderLayout: DECK_TEXT_SHADER_LAYOUT, - storageShaderLayout: DECK_TEXT_STORAGE_SHADER_LAYOUT, + storageShaderLayout: + model === 'storage-row-indexed' + ? DECK_TEXT_ROW_INDEXED_STORAGE_SHADER_LAYOUT + : DECK_TEXT_STORAGE_SHADER_LAYOUT, modelProps: { + ...effectiveProps.modelProps, ...this.getShaders( this.context.device.type === 'webgpu' ? { shaderAssembler: this.context.shaderAssembler, modules: [deckArrowViewport, picking], - source: model === 'storage' ? DECK_TEXT_STORAGE_WGSL : DECK_TEXT_WGSL + source: + model === 'storage-row-indexed' + ? DECK_TEXT_ROW_INDEXED_STORAGE_WGSL + : model === 'storage' + ? DECK_TEXT_STORAGE_WGSL + : DECK_TEXT_WGSL } : { shaderAssembler: this.context.shaderAssembler, @@ -556,9 +620,18 @@ export class ArrowTextLayer extends Layer { } ), shaderInputs: this.context.device.type === 'webgpu' ? new ShaderInputs({}) : undefined, - bufferLayout: constantStyle.bufferLayout, - attributes: constantStyle.attributes, - constantAttributes: constantStyle.constantAttributes + bufferLayout: [ + ...(effectiveProps.modelProps?.bufferLayout ?? []), + ...constantStyle.bufferLayout + ], + attributes: { + ...effectiveProps.modelProps?.attributes, + ...constantStyle.attributes + }, + constantAttributes: { + ...effectiveProps.modelProps?.constantAttributes, + ...constantStyle.constantAttributes + } }, onDataBatch: update => this.handleDataBatch(update, props.onDataBatch) }; @@ -692,7 +765,7 @@ export class ArrowTextLayer extends Layer { onDataBatch?.(update); } - private getRendererOrNull(): ArrowTextRenderer | null { + protected getRendererOrNull(): ArrowTextRenderer | null { return (this.state as ArrowTextLayerState | undefined)?.renderer ?? null; } @@ -722,23 +795,39 @@ export class ArrowTextLayer extends Layer { function resolveDeckTextModel( device: Device, model: ArrowTextLayerProps['model'] = 'auto' -): 'attribute' | 'storage' { - if (model === 'storage' && device.type !== 'webgpu') { +): 'attribute' | 'storage' | 'storage-row-indexed' { + if ((model === 'storage' || model === 'storage-row-indexed') && device.type !== 'webgpu') { throw new Error('ArrowTextLayer storage rendering requires WebGPU'); } + const storageSupported = supportsGpuTextExpansion(device); if (model === 'auto') { - return device.type === 'webgpu' ? 'storage' : 'attribute'; + return storageSupported ? 'storage' : 'attribute'; } - if (model !== 'attribute' && model !== 'storage') { + if (model !== 'attribute' && model !== 'storage' && model !== 'storage-row-indexed') { throw new Error(`ArrowTextLayer does not support the ${model} model`); } - return model; + return (model === 'storage' || model === 'storage-row-indexed') && !storageSupported + ? 'attribute' + : model; } function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } +function getContentAlignEnum(value: 'none' | 'start' | 'center' | 'end' | undefined): number { + switch (value) { + case 'start': + return 1; + case 'center': + return 2; + case 'end': + return 3; + default: + return 0; + } +} + type DeckTextConstantStyle = { bufferLayout: BufferLayout[]; attributes: Record; @@ -774,9 +863,9 @@ function createDeckTextConstantStyle( name: 'constantTextClipRect', byteStride: 0, stepMode: 'instance', - attributes: [{attribute: 'glyphClipRects', format: 'sint32x4', byteOffset: 0}] + attributes: [{attribute: 'glyphClipRects', format: 'float32x4', byteOffset: 0}] }, - new Int32Array([0, 0, -1, -1]) + new Float32Array([0, 0, -1, -1]) ); } if (!hasColorSource) { diff --git a/modules/arrow-layers/src/layers/arrow-text-storage-shaders.ts b/modules/arrow-layers/src/layers/arrow-text-storage-shaders.ts index 59a65834ec..b2b7e423b5 100644 --- a/modules/arrow-layers/src/layers/arrow-text-storage-shaders.ts +++ b/modules/arrow-layers/src/layers/arrow-text-storage-shaders.ts @@ -14,6 +14,15 @@ export const DECK_TEXT_STORAGE_SHADER_LAYOUT = { bindings: [] } satisfies ShaderLayout; +/** Row-indexed variant used when each compact glyph record carries its original source row. */ +export const DECK_TEXT_ROW_INDEXED_STORAGE_SHADER_LAYOUT = { + attributes: [ + ...DECK_TEXT_STORAGE_SHADER_LAYOUT.attributes, + {name: 'glyphRowIndices', location: 2, type: 'u32', stepMode: 'instance'} + ], + bindings: [] +} satisfies ShaderLayout; + /** Deck-projected shader for the luma.gl storage text model. */ export const DECK_TEXT_STORAGE_WGSL = /* wgsl */ ` ${DECK_ARROW_WGSL_COLOR_UTILS} @@ -25,7 +34,7 @@ ${DECK_ARROW_WGSL_COLOR_UTILS} @group(0) @binding(auto) var textRowAngles: array; @group(0) @binding(auto) var textRowSizes: array; @group(0) @binding(auto) var textRowPixelOffsets: array>; -@group(0) @binding(auto) var textRowClipRects: array>; +@group(0) @binding(auto) var textRowClipRects: array>; @group(0) @binding(auto) var textRowGlyphStarts: array; @group(0) @binding(auto) var textGlyphFrames: array>; @@ -78,35 +87,6 @@ fn getStorageTextCorner(vertexIndex: u32) -> vec2 { return vec2(0.0, 1.0); } -fn unpackLowInt16(word: u32) -> i32 { - return i32(word << 16u) >> 16; -} - -fn unpackHighInt16(word: u32) -> i32 { - return i32(word) >> 16; -} - -fn unpackStorageTextClipRect(words: vec2) -> vec4 { - return vec4( - unpackLowInt16(words.x), - unpackHighInt16(words.x), - unpackLowInt16(words.y), - unpackHighInt16(words.y) - ); -} - -fn isStorageTextVertexClipped(glyphVertexOffset: vec2, clipRect: vec4) -> bool { - if (clipRect.z >= 0) { - let clipMaxX = f32(clipRect.x + clipRect.z); - if (glyphVertexOffset.x < f32(clipRect.x) || glyphVertexOffset.x > clipMaxX) { return true; } - } - if (clipRect.w >= 0) { - let clipMaxY = f32(clipRect.y + clipRect.w); - if (glyphVertexOffset.y < f32(clipRect.y) || glyphVertexOffset.y > clipMaxY) { return true; } - } - return false; -} - fn unpackStorageTextColor(colorWord: u32) -> vec4 { return vec4( f32(colorWord & 255u), @@ -168,25 +148,25 @@ fn vertexMain( if (textStorageStyleConfig.useRowPixelOffsets != 0u) { pixelOffset = textRowPixelOffsets[rowStorageIndex]; } - let glyphPixelOffset = + var glyphPixelOffset = rotateStorageTextOffset(glyphVertexOffset * (textSize / 32.0), angleDegrees) * 0.36 + pixelOffset; - var clipPosition = deck_projectPosition(vec3(textRowPositions[rowStorageIndex], 0.0)); + glyphPixelOffset.y *= -1.0; + let anchorPosition = deck_projectPosition(vec3(textRowPositions[rowStorageIndex], 0.0)); + let clipRect = textRowClipRects[rowStorageIndex]; + glyphPixelOffset += deck_getTextContentOffset(anchorPosition, clipRect); + var clipPosition = anchorPosition; clipPosition.x += glyphPixelOffset.x * deckArrowViewport.pixelToClipScale.x * clipPosition.w; clipPosition.y += glyphPixelOffset.y * deckArrowViewport.pixelToClipScale.y * clipPosition.w; var visible = true; if (textStorageStyleConfig.hasClipRects != 0u) { - visible = !isStorageTextVertexClipped( - glyphVertexOffset, - unpackStorageTextClipRect(textRowClipRects[rowIndex]) - ); + visible = deck_isTextContentVisible(glyphPixelOffset, anchorPosition, clipRect); } - let atlasCorner = vec2(corner.x, 1.0 - corner.y); - let atlasPixel = glyphFrame.xy + atlasCorner * glyphFrame.zw; + let atlasPixel = glyphFrame.xy + corner * glyphFrame.zw; var outputs: TextStorageVertexOutputs; - outputs.position = clipPosition; + outputs.position = select(vec4(0.0), clipPosition, visible); outputs.textureCoordinate = atlasPixel / vec2(textureDimensions(fontAtlasTexture)); outputs.color = textStorageStyleConfig.constantColor; if (textStorageStyleConfig.useRowColors != 0u) { @@ -195,7 +175,7 @@ fn vertexMain( outputs.pickingColor = deck_encodePickingColor( textStorageStyleConfig.batchRowIndexBase + rowIndex ); - outputs.visible = select(0.0, 1.0, visible); + outputs.visible = 1.0; outputs.atlasPage = inputs.glyphIndices.y; return outputs; } @@ -217,3 +197,13 @@ fn fragmentMain(inputs: TextStorageVertexOutputs) -> @location(0) vec4 { ); } `; + +/** Deck storage shader that reads the source row directly from each generated glyph record. */ +export const DECK_TEXT_ROW_INDEXED_STORAGE_WGSL = DECK_TEXT_STORAGE_WGSL.replace( + '@location(1) glyphIndices: vec2,', + `@location(1) glyphIndices: vec2, + @location(2) glyphRowIndices: u32,` +).replace( + 'let rowIndex = findStorageTextRowIndex(glyphIndex);', + 'let rowIndex = inputs.glyphRowIndices;' +); diff --git a/modules/arrow/src/arrow/renderers/text/conversion/convert-arrow-text-to-attribute.ts b/modules/arrow/src/arrow/renderers/text/conversion/convert-arrow-text-to-attribute.ts index 30b48fdc5d..98863a6a40 100644 --- a/modules/arrow/src/arrow/renderers/text/conversion/convert-arrow-text-to-attribute.ts +++ b/modules/arrow/src/arrow/renderers/text/conversion/convert-arrow-text-to-attribute.ts @@ -26,7 +26,7 @@ export type ArrowTextConversionColumns = { positions?: string; /** Source column containing plain or dictionary-encoded UTF-8 labels. */ text?: string; - /** Optional source column containing `FixedSizeList[4]` clip rectangles. */ + /** Optional source column containing deck-style `FixedSizeList[4]` clip rectangles. */ clipRects?: string; /** Optional source column containing packed row or character RGBA8 colors. */ colors?: string; diff --git a/modules/arrow/src/arrow/renderers/text/conversion/convert-arrow-text-vectors.ts b/modules/arrow/src/arrow/renderers/text/conversion/convert-arrow-text-vectors.ts index a0ffa1fada..0989f227de 100644 --- a/modules/arrow/src/arrow/renderers/text/conversion/convert-arrow-text-vectors.ts +++ b/modules/arrow/src/arrow/renderers/text/conversion/convert-arrow-text-vectors.ts @@ -103,7 +103,7 @@ const EXPANDED_GLYPH_VERTEX_DATA = 'expandedGlyphVertexData'; const COMPACT_GLYPH_VERTEX_BYTE_STRIDE = Uint32Array.BYTES_PER_ELEMENT * 2; const EXPANDED_GLYPH_VERTEX_BYTE_STRIDE = Uint32Array.BYTES_PER_ELEMENT * 5; const CLIPPED_EXPANDED_GLYPH_VERTEX_BYTE_STRIDE = - EXPANDED_GLYPH_VERTEX_BYTE_STRIDE + Int16Array.BYTES_PER_ELEMENT * 4; + EXPANDED_GLYPH_VERTEX_BYTE_STRIDE + Float32Array.BYTES_PER_ELEMENT * 4; const DEFAULT_TEXT_STORAGE_COLOR: [number, number, number, number] = [0, 0, 0, 255]; const DEFAULT_TEXT_STORAGE_ANGLE = 0; const DEFAULT_TEXT_STORAGE_SIZE = 32; @@ -160,7 +160,7 @@ export type ArrowTextSourceVectors = { /** Optional CPU per-row alignment baseline enum values. */ alignmentBaselines?: Vector; /** Optional CPU packed clip rectangles aligned with label rows. */ - clipRects?: Vector>; + clipRects?: Vector>; }; /** CPU Arrow vectors still needed by storage-backed text expansion. */ @@ -168,7 +168,7 @@ export type ArrowTextStorageSourceVectors = { /** CPU plain or dictionary-encoded UTF-8 labels used for storage glyph expansion. */ texts: ArrowUtf8TextVector; /** Optional CPU packed clip rectangles aligned with label rows. */ - clipRects?: Vector>; + clipRects?: Vector>; }; /** @@ -307,7 +307,7 @@ export type ArrowTextDictionaryStorageSourceVectors = { /** CPU UTF-8 labels validated as dictionary-encoded before compressed glyph layout. */ texts: ArrowUtf8TextVector; /** Optional CPU packed clip rectangles aligned with label rows. */ - clipRects?: Vector>; + clipRects?: Vector>; }; /** Arrow-to-dictionary-storage conversion input consumed by layer/data-conversion code. */ @@ -352,7 +352,7 @@ type AnyTextStorageInputProps = export function buildArrowTextGlyphTable(props: { labelTable: Table; texts: ArrowUtf8TextVector; - clipRects?: Vector>; + clipRects?: Vector>; fontAtlas: FontAtlas; lineHeight?: number; characterSet?: Set; @@ -449,7 +449,7 @@ export function buildArrowTextGlyphTable(props: { type ResolvedArrowTextInputs = { labelTable: Table; texts: ArrowUtf8TextVector; - clipRects?: Vector>; + clipRects?: Vector>; }; function resolveArrowTextInputs(props: ArrowTextModelProps): ResolvedArrowTextInputs { @@ -503,7 +503,7 @@ export function resolveArrowTextBatchInputs( texts: getArrowTextSourceBatch(sourceVectors.texts, 'texts', batchIndex), clipRects: sourceVectors.clipRects ? (getArrowTextSourceBatch(sourceVectors.clipRects, 'clipRects', batchIndex) as Vector< - FixedSizeList + FixedSizeList >) : undefined }; @@ -699,7 +699,7 @@ function assertSourceVectorMatchesGPUVector( type ResolvedTextStorageBatchRowInputs = { batchRowIndexBase: number; rowStorageIndexBase: number; - clipRects?: Vector>; + clipRects?: Vector>; clipRectsBuffer?: TextStorageBuffer; positionsBuffer: TextStorageBuffer; colorsBuffer?: TextStorageBuffer; @@ -743,7 +743,7 @@ function resolveArrowTextStorageInputs( texts: getArrowTextSourceBatch(sourceVectors.texts, 'texts', batchIndex), clipRects: sourceVectors.clipRects ? (getArrowTextSourceBatch(sourceVectors.clipRects, 'clipRects', batchIndex) as Vector< - FixedSizeList + FixedSizeList >) : undefined, positionsBuffer: positionsData.buffer, @@ -784,7 +784,7 @@ function resolveArrowTextDictionaryStorageInputs( texts: getArrowTextSourceBatch(sourceVectors.texts, 'texts', batchIndex), clipRects: sourceVectors.clipRects ? (getArrowTextSourceBatch(sourceVectors.clipRects, 'clipRects', batchIndex) as Vector< - FixedSizeList + FixedSizeList >) : undefined, positionsBuffer: positionsData.buffer, @@ -915,7 +915,7 @@ function assertStorageVectorTypes(props: ArrowTextStorageInputProps): void { throw new Error('ArrowTextStorageModel alignmentBaselines must be GPUVector'); } const clipRects = props.sourceVectors.clipRects; - assertClipRects(clipRects as Vector> | undefined, props.texts.length); + assertClipRects(clipRects as Vector> | undefined, props.texts.length); } function assertGPUVectorTextStorageInputTypes(props: GPUVectorTextStorageInputProps): void { @@ -985,10 +985,10 @@ function getGPUVectorTextStorageClipRectsBuffer( } if ( clipRectsData.byteOffset !== 0 || - clipRectsData.byteStride !== Int16Array.BYTES_PER_ELEMENT * 4 + clipRectsData.byteStride !== Float32Array.BYTES_PER_ELEMENT * 4 ) { throw new Error( - 'TextStorageModel GPUVector clipRects batches must be zero-offset packed Int16x4 buffers' + 'TextStorageModel GPUVector clipRects batches must be zero-offset packed Float32x4 buffers' ); } return clipRectsData.buffer; @@ -1123,7 +1123,7 @@ function assertTextDictionaryStorageVectorTypes(props: ArrowTextDictionaryStorag throw new Error('ArrowTextDictionaryStorageModel alignmentBaselines must be GPUVector'); } const clipRects = props.sourceVectors.clipRects; - assertClipRects(clipRects as Vector> | undefined, props.texts.length); + assertClipRects(clipRects as Vector> | undefined, props.texts.length); } function assertTextDictionaryStorageSourceVectorAlignment( @@ -1374,10 +1374,10 @@ export function createExpandedGlyphVertexData( } { const {glyphLayout} = glyphTable; const glyphClipRects = glyphTable.table.getChild(GLYPH_CLIP_RECTS_COLUMN) as Vector< - FixedSizeList + FixedSizeList > | null; const glyphClipRectValues = glyphClipRects - ? (getArrowVectorBufferSource(glyphClipRects) as Int16Array) + ? (getArrowVectorBufferSource(glyphClipRects) as Float32Array) : undefined; const byteStride = glyphClipRectValues ? CLIPPED_EXPANDED_GLYPH_VERTEX_BYTE_STRIDE @@ -1387,6 +1387,7 @@ export function createExpandedGlyphVertexData( const int16Values = new Int16Array(arrayBuffer); const uint16Values = new Uint16Array(arrayBuffer); const uint32Values = new Uint32Array(arrayBuffer); + const float32Values = new Float32Array(arrayBuffer); const rowIndices = makeGlyphRowIndices(glyphLayout.startIndices, rowIndexBase); for ( @@ -1397,6 +1398,7 @@ export function createExpandedGlyphVertexData( const batchGlyphIndex = glyphIndex - generatedBufferBatch.recordStart; const recordInt16Index = (batchGlyphIndex * byteStride) / Int16Array.BYTES_PER_ELEMENT; const recordUint32Index = (batchGlyphIndex * byteStride) / Uint32Array.BYTES_PER_ELEMENT; + const recordFloat32Index = (batchGlyphIndex * byteStride) / Float32Array.BYTES_PER_ELEMENT; const glyphOffsetIndex = glyphIndex * 2; const glyphFrameIndex = glyphIndex * 4; @@ -1411,10 +1413,10 @@ export function createExpandedGlyphVertexData( if (glyphClipRectValues) { const glyphClipRectIndex = glyphIndex * 4; - int16Values[recordInt16Index + 10] = glyphClipRectValues[glyphClipRectIndex]; - int16Values[recordInt16Index + 11] = glyphClipRectValues[glyphClipRectIndex + 1]; - int16Values[recordInt16Index + 12] = glyphClipRectValues[glyphClipRectIndex + 2]; - int16Values[recordInt16Index + 13] = glyphClipRectValues[glyphClipRectIndex + 3]; + float32Values[recordFloat32Index + 5] = glyphClipRectValues[glyphClipRectIndex]; + float32Values[recordFloat32Index + 6] = glyphClipRectValues[glyphClipRectIndex + 1]; + float32Values[recordFloat32Index + 7] = glyphClipRectValues[glyphClipRectIndex + 2]; + float32Values[recordFloat32Index + 8] = glyphClipRectValues[glyphClipRectIndex + 3]; } } @@ -1447,7 +1449,7 @@ export function createExpandedGlyphVertexData( if (glyphClipRectValues && shaderAttributeNames.has(GLYPH_CLIP_RECTS_COLUMN)) { attributes.push({ attribute: GLYPH_CLIP_RECTS_COLUMN, - format: 'sint16x4', + format: 'float32x4', byteOffset: EXPANDED_GLYPH_VERTEX_BYTE_STRIDE }); } @@ -1745,7 +1747,9 @@ export function createArrowTextStorageState( rowTextAnchorsBuffer: getComputeTextStorageBuffer(rowState.rowTextAnchorsBuffer), rowAlignmentBaselinesBuffer: getComputeTextStorageBuffer( rowState.rowAlignmentBaselinesBuffer - ) + ), + useRowTextAnchors: Boolean(props.textAnchors), + useRowAlignmentBaselines: Boolean(props.alignmentBaselines) } } ); @@ -2356,7 +2360,7 @@ function assertColumnAvailable(table: Table, columnName: string): void { } function assertClipRects( - clipRects: Vector> | undefined, + clipRects: Vector> | undefined, labelCount: number ): void { if (!clipRects) { @@ -2368,9 +2372,9 @@ function assertClipRects( if ( !DataType.isFixedSizeList(clipRects.type) || clipRects.type.listSize !== 4 || - !(clipRects.type.children[0]?.type instanceof Int16) + !(clipRects.type.children[0]?.type instanceof Float32) ) { - throw new Error('ArrowTextModel clipRects must be FixedSizeList[4]'); + throw new Error('ArrowTextModel clipRects must be FixedSizeList[4]'); } } @@ -2468,7 +2472,7 @@ function createTextStorageDefaultBuffers( const clipRectsVector = createTextStorageOwnedGpuVector( device, `${id}-default-row-clip-rects`, - makeArrowFixedSizeListVector(new Uint32(), 2, new Uint32Array(2)) + makeArrowFixedSizeListVector(new Float32(), 4, new Float32Array([0, 0, -1, -1])) ); return { colorsBuffer: getTextStorageGpuVectorBuffer(colorsVector), @@ -2485,7 +2489,7 @@ function createTextStorageDefaultBuffers( Float32Array.BYTES_PER_ELEMENT * 2 + Uint32Array.BYTES_PER_ELEMENT + Uint32Array.BYTES_PER_ELEMENT + - Uint32Array.BYTES_PER_ELEMENT * 2, + Float32Array.BYTES_PER_ELEMENT * 4, ownedResources: [ colorsVector, anglesVector, @@ -2513,9 +2517,9 @@ function createTextStorageBatchRowState( device, `${props.id || 'text-storage-model'}-row-clip-rects-${batchInput.batchRowIndexBase}`, makeArrowFixedSizeListVector( - new Uint32(), - 2, - packedClipRects.byteLength > 0 ? packedClipRects : new Uint32Array(2) + new Float32(), + 4, + packedClipRects.byteLength > 0 ? packedClipRects : new Float32Array(4) ) ) : undefined; @@ -2830,7 +2834,9 @@ export function appendArrowTextStorageStateBatches( rowTextAnchorsBuffer: getComputeTextStorageBuffer(rowState.rowTextAnchorsBuffer), rowAlignmentBaselinesBuffer: getComputeTextStorageBuffer( rowState.rowAlignmentBaselinesBuffer - ) + ), + useRowTextAnchors: Boolean(props.textAnchors), + useRowAlignmentBaselines: Boolean(props.alignmentBaselines) } } ); @@ -3167,27 +3173,10 @@ function getTextAtlasTexture( }; } -/** Pack signed `[x, y, width, height]` Int16 clip rows into two uint32 words per row. */ -export function packTextStorageClipRects(clipRects: Vector>): Uint32Array { +/** Copies deck-style `[x, y, width, height]` Float32 clip rows into packed GPU storage. */ +export function packTextStorageClipRects(clipRects: Vector>): Float32Array { assertClipRects(clipRects, clipRects.length); - const clipRectValues = getArrowVectorBufferSource(clipRects) as Int16Array; - const packedClipRects = new Uint32Array(clipRects.length * 2); - for (let rowIndex = 0; rowIndex < clipRects.length; rowIndex++) { - const valueIndex = rowIndex * 4; - packedClipRects[rowIndex * 2] = packSignedInt16Pair( - clipRectValues[valueIndex], - clipRectValues[valueIndex + 1] - ); - packedClipRects[rowIndex * 2 + 1] = packSignedInt16Pair( - clipRectValues[valueIndex + 2], - clipRectValues[valueIndex + 3] - ); - } - return packedClipRects; -} - -function packSignedInt16Pair(lowerValue: number, upperValue: number): number { - return ((upperValue & 0xffff) << 16) | (lowerValue & 0xffff); + return new Float32Array(getArrowVectorBufferSource(clipRects) as Float32Array); } function getTextAnchorEnum(textAnchor: AnyTextStorageInputProps['textAnchor']): number { diff --git a/modules/arrow/src/arrow/renderers/text/conversion/make-gpu-text-data-from-arrow.ts b/modules/arrow/src/arrow/renderers/text/conversion/make-gpu-text-data-from-arrow.ts index 7431232ef2..2e0ab69da3 100644 --- a/modules/arrow/src/arrow/renderers/text/conversion/make-gpu-text-data-from-arrow.ts +++ b/modules/arrow/src/arrow/renderers/text/conversion/make-gpu-text-data-from-arrow.ts @@ -161,10 +161,13 @@ export function resolveGPUTextStrategy( const hasCharacterColors = Boolean( props.sourceVectors.colors && arrow.DataType.isList(props.sourceVectors.colors.type) ); + const alignmentBufferCount = + Number(Boolean(props.sourceVectors.textAnchors)) + + Number(Boolean(props.sourceVectors.alignmentBaselines)); const supportsStorage = device.type === 'webgpu' && supportsVertexStorageBuffers(device, TEXT_STORAGE_VERTEX_STORAGE_BUFFER_COUNT) && - supportsGpuTextExpansion(device); + supportsGpuTextExpansion(device, alignmentBufferCount); const supportsDictionary = device.type === 'webgpu' && supportsVertexStorageBuffers(device, TEXT_DICTIONARY_VERTEX_STORAGE_BUFFER_COUNT); diff --git a/modules/arrow/src/arrow/renderers/text/renderers/arrow-text-renderer.ts b/modules/arrow/src/arrow/renderers/text/renderers/arrow-text-renderer.ts index 63ee542190..2ae61b1421 100644 --- a/modules/arrow/src/arrow/renderers/text/renderers/arrow-text-renderer.ts +++ b/modules/arrow/src/arrow/renderers/text/renderers/arrow-text-renderer.ts @@ -90,6 +90,12 @@ export type ArrowTextRendererProps = ArrowTextSourceVectorSelectors & { angle?: number; /** Constant fallback row text size used when no row size vector is present. */ size?: number; + /** Constant fallback pixel offset used when no row pixel-offset vector is present. */ + pixelOffset?: [number, number]; + /** Constant fallback horizontal anchor used when no row anchor vector is present. */ + textAnchor?: 'start' | 'middle' | 'end'; + /** Constant fallback vertical baseline used when no row baseline vector is present. */ + alignmentBaseline?: 'center' | 'top' | 'bottom'; /** Attribute-model shader layout override used after Arrow source preparation. */ attributeShaderLayout?: ShaderLayout; /** Storage-model shader layout override used after Arrow source preparation. */ @@ -114,6 +120,7 @@ export type ArrowTextRendererProps = ArrowTextSourceVectorSelectors & { | 'bufferLayout' | 'attributes' | 'constantAttributes' + | 'bindings' | 'shaderAssembler' >; }; @@ -819,7 +826,10 @@ export class ArrowTextRenderer extends GPURenderable< parameters: DEFAULT_RENDER_PARAMETERS, color, angle, - size + size, + pixelOffset: props.pixelOffset, + textAnchor: props.textAnchor, + alignmentBaseline: props.alignmentBaseline }; if (modelKind === 'dictionary') { @@ -883,9 +893,12 @@ export class ArrowTextRenderer extends GPURenderable< ): ArrowTextRendererResolvedModel { const hasCharacterColors = isArrowTextCharacterColorType(data.sourceVectors.colors?.type); const hasTextDictionary = arrow.DataType.isDictionary(data.sourceVectors.texts.type); + const alignmentBufferCount = + Number(Boolean(data.sourceVectors.textAnchors)) + + Number(Boolean(data.sourceVectors.alignmentBaselines)); const supportsTextStorage = supportsVertexStorageBuffers(this.device, TEXT_STORAGE_VERTEX_STORAGE_BUFFER_COUNT) && - supportsGpuTextExpansion(this.device); + supportsGpuTextExpansion(this.device, alignmentBufferCount); const supportsTextDictionary = supportsVertexStorageBuffers( this.device, TEXT_DICTIONARY_VERTEX_STORAGE_BUFFER_COUNT @@ -1453,7 +1466,22 @@ function hasArrowTextConstantStylePropsChanged( return ( (props.color !== undefined && !areTextColorsEqual(props.color, previousProps.color)) || (props.angle !== undefined && props.angle !== previousProps.angle) || - (props.size !== undefined && props.size !== previousProps.size) + (props.size !== undefined && props.size !== previousProps.size) || + (props.pixelOffset !== undefined && + !areTextVec2Equal(props.pixelOffset, previousProps.pixelOffset)) || + (props.textAnchor !== undefined && props.textAnchor !== previousProps.textAnchor) || + (props.alignmentBaseline !== undefined && + props.alignmentBaseline !== previousProps.alignmentBaseline) + ); +} + +function areTextVec2Equal( + value: [number, number] | undefined, + otherValue: [number, number] | undefined +): boolean { + return ( + value === otherValue || + Boolean(value && otherValue && value[0] === otherValue[0] && value[1] === otherValue[1]) ); } diff --git a/modules/arrow/src/arrow/renderers/text/renderers/arrow-text-shaders.ts b/modules/arrow/src/arrow/renderers/text/renderers/arrow-text-shaders.ts index 5a97cd6a47..22f7782b53 100644 --- a/modules/arrow/src/arrow/renderers/text/renderers/arrow-text-shaders.ts +++ b/modules/arrow/src/arrow/renderers/text/renderers/arrow-text-shaders.ts @@ -59,7 +59,7 @@ export function configureArrowTextShaderAssembler( export const STREAMING_TEXT_INPUT_SHADER_LAYOUT = { attributes: [ {name: 'positions', location: 0, type: 'vec2', stepMode: 'instance'}, - {name: 'clipRects', location: 1, type: 'vec4', stepMode: 'instance'}, + {name: 'clipRects', location: 1, type: 'vec4', stepMode: 'instance'}, {name: 'colors', location: 2, type: 'vec4', stepMode: 'instance'}, {name: 'angles', location: 3, type: 'f32', stepMode: 'instance'}, {name: 'sizes', location: 4, type: 'f32', stepMode: 'instance'}, @@ -79,7 +79,7 @@ export const TEXT_SHADER_LAYOUT = { {name: 'glyphFrames', location: 2, type: 'vec4', stepMode: 'instance'}, {name: 'glyphPages', location: 3, type: 'u32', stepMode: 'instance'}, {name: 'rowIndices', location: 4, type: 'u32', stepMode: 'instance'}, - {name: 'glyphClipRects', location: 5, type: 'vec4', stepMode: 'instance'}, + {name: 'glyphClipRects', location: 5, type: 'vec4', stepMode: 'instance'}, {name: 'colors', location: 6, type: 'vec4', stepMode: 'instance'} ], bindings: [] @@ -130,7 +130,7 @@ struct VertexInputs { @location(2) glyphFrames : vec4, @location(3) glyphPages : u32, @location(4) rowIndices : u32, - @location(5) glyphClipRects : vec4, + @location(5) glyphClipRects : vec4, @location(6) colors : vec4, }; @@ -158,17 +158,17 @@ fn getCorner(vertexIndex : u32) -> vec2 { return vec2(0.0, 1.0); } -fn isGlyphVertexClipped(glyphVertexOffset : vec2, clipRect : vec4) -> bool { +fn isGlyphVertexClipped(glyphVertexOffset : vec2, clipRect : vec4) -> bool { if (clipRect.z >= 0) { - let clipMinX = f32(clipRect.x); - let clipMaxX = clipMinX + f32(clipRect.z); + let clipMinX = clipRect.x; + let clipMaxX = clipMinX + clipRect.z; if (glyphVertexOffset.x < clipMinX || glyphVertexOffset.x > clipMaxX) { return true; } } if (clipRect.w >= 0) { - let clipMinY = f32(clipRect.y); - let clipMaxY = clipMinY + f32(clipRect.w); + let clipMinY = clipRect.y; + let clipMaxY = clipMinY + clipRect.w; if (glyphVertexOffset.y < clipMinY || glyphVertexOffset.y > clipMaxY) { return true; } @@ -257,7 +257,7 @@ struct TextViewportUniforms { @group(0) @binding(auto) var textRowAngles : array; @group(0) @binding(auto) var textRowSizes : array; @group(0) @binding(auto) var textRowPixelOffsets : array>; -@group(0) @binding(auto) var textRowClipRects : array>; +@group(0) @binding(auto) var textRowClipRects : array>; @group(0) @binding(auto) var textRowGlyphStarts : array; @group(0) @binding(auto) var textGlyphFrames : array>; @@ -330,26 +330,21 @@ fn unpackHighInt16(word : u32) -> i32 { return i32(word) >> 16; } -fn unpackClipRect(words : vec2) -> vec4 { - return vec4( - unpackLowInt16(words.x), - unpackHighInt16(words.x), - unpackLowInt16(words.y), - unpackHighInt16(words.y) - ); +fn unpackClipRect(words : vec4) -> vec4 { + return words; } -fn isGlyphVertexClipped(glyphVertexOffset : vec2, clipRect : vec4) -> bool { +fn isGlyphVertexClipped(glyphVertexOffset : vec2, clipRect : vec4) -> bool { if (clipRect.z >= 0) { - let clipMinX = f32(clipRect.x); - let clipMaxX = clipMinX + f32(clipRect.z); + let clipMinX = clipRect.x; + let clipMaxX = clipMinX + clipRect.z; if (glyphVertexOffset.x < clipMinX || glyphVertexOffset.x > clipMaxX) { return true; } } if (clipRect.w >= 0) { - let clipMinY = f32(clipRect.y); - let clipMaxY = clipMinY + f32(clipRect.w); + let clipMinY = clipRect.y; + let clipMaxY = clipMinY + clipRect.w; if (glyphVertexOffset.y < clipMinY || glyphVertexOffset.y > clipMaxY) { return true; } @@ -504,7 +499,7 @@ struct TextViewportUniforms { @group(0) @binding(auto) var textRowAngles : array; @group(0) @binding(auto) var textRowSizes : array; @group(0) @binding(auto) var textRowPixelOffsets : array>; -@group(0) @binding(auto) var textRowClipRects : array>; +@group(0) @binding(auto) var textRowClipRects : array>; // Dictionary model memory: row buffers hold styling and row starts, dictionary // buffers hold shared glyph runs, and visible glyph occurrences are implicit // draw instances. There is no per-visible-glyph occurrence/vertex buffer. @@ -583,26 +578,21 @@ fn unpackHighInt16(word : u32) -> i32 { return i32(word) >> 16; } -fn unpackClipRect(words : vec2) -> vec4 { - return vec4( - unpackLowInt16(words.x), - unpackHighInt16(words.x), - unpackLowInt16(words.y), - unpackHighInt16(words.y) - ); +fn unpackClipRect(words : vec4) -> vec4 { + return words; } -fn isGlyphVertexClipped(glyphVertexOffset : vec2, clipRect : vec4) -> bool { +fn isGlyphVertexClipped(glyphVertexOffset : vec2, clipRect : vec4) -> bool { if (clipRect.z >= 0) { - let clipMinX = f32(clipRect.x); - let clipMaxX = clipMinX + f32(clipRect.z); + let clipMinX = clipRect.x; + let clipMaxX = clipMinX + clipRect.z; if (glyphVertexOffset.x < clipMinX || glyphVertexOffset.x > clipMaxX) { return true; } } if (clipRect.w >= 0) { - let clipMinY = f32(clipRect.y); - let clipMaxY = clipMinY + f32(clipRect.w); + let clipMinY = clipRect.y; + let clipMaxY = clipMinY + clipRect.w; if (glyphVertexOffset.y < clipMinY || glyphVertexOffset.y > clipMaxY) { return true; } @@ -783,7 +773,7 @@ in ivec2 glyphOffsets; in uvec4 glyphFrames; in uint glyphPages; in uint rowIndices; -in ivec4 glyphClipRects; +in vec4 glyphClipRects; in vec4 colors; layout(std140) uniform textViewportUniforms { @@ -813,17 +803,17 @@ vec2 getCorner(int vertexIndex) { return vec2(0.0, 1.0); } -bool isGlyphVertexClipped(vec2 glyphVertexOffset, ivec4 clipRect) { +bool isGlyphVertexClipped(vec2 glyphVertexOffset, vec4 clipRect) { if (clipRect.z >= 0) { - float clipMinX = float(clipRect.x); - float clipMaxX = clipMinX + float(clipRect.z); + float clipMinX = clipRect.x; + float clipMaxX = clipMinX + clipRect.z; if (glyphVertexOffset.x < clipMinX || glyphVertexOffset.x > clipMaxX) { return true; } } if (clipRect.w >= 0) { - float clipMinY = float(clipRect.y); - float clipMaxY = clipMinY + float(clipRect.w); + float clipMinY = clipRect.y; + float clipMaxY = clipMinY + clipRect.w; if (glyphVertexOffset.y < clipMinY || glyphVertexOffset.y > clipMaxY) { return true; } @@ -957,6 +947,8 @@ export type TextViewportUniforms = { textSdfThreshold: number; textSdfSmoothing: number; textMsdfDistanceRange: number; + contentCutoffPixels?: [number, number]; + contentAlign?: [number, number]; }; export const textViewport: ShaderModule = { @@ -971,7 +963,23 @@ export const textViewport: ShaderModule = { textFontRenderMode: 'f32', textSdfThreshold: 'f32', textSdfSmoothing: 'f32', - textMsdfDistanceRange: 'f32' + textMsdfDistanceRange: 'f32', + contentCutoffPixels: 'vec2', + contentAlign: 'vec2' + }, + defaultUniforms: { + cameraOffset: [0, 0], + viewportScale: [1, 1], + glyphWorldScale: 1, + time: 0, + clippingEnabled: 0, + colorsEnabled: 0, + textFontRenderMode: 0, + textSdfThreshold: 0.5, + textSdfSmoothing: 0.1, + textMsdfDistanceRange: 0, + contentCutoffPixels: [0, 0], + contentAlign: [0, 0] } }; diff --git a/modules/arrow/src/arrow/renderers/text/source/arrow-text-source-mapping.ts b/modules/arrow/src/arrow/renderers/text/source/arrow-text-source-mapping.ts index 5bc55d9034..8a46303683 100644 --- a/modules/arrow/src/arrow/renderers/text/source/arrow-text-source-mapping.ts +++ b/modules/arrow/src/arrow/renderers/text/source/arrow-text-source-mapping.ts @@ -33,7 +33,7 @@ export type ArrowTextSourceVectorSelectors = { texts?: string | ArrowUtf8TextVector; /** Clip rectangles. Defaults to `clipRects`; `null` disables them. */ clipRects?: OptionalArrowTextColumnSelector< - import('apache-arrow').FixedSizeList + import('apache-arrow').FixedSizeList >; /** Row/character colors. Defaults to `colors`; `null` disables them. */ colors?: OptionalArrowTextColumnSelector; @@ -54,7 +54,7 @@ export type ArrowTextSourceVectorSelectors = { export type ArrowTextMappedSourceVectors = { positions: Vector>; texts: ArrowUtf8TextVector; - clipRects?: Vector>; + clipRects?: Vector>; colors?: Vector; angles?: Vector; sizes?: Vector; diff --git a/modules/arrow/test/arrow/arrow-text-renderer.spec.ts b/modules/arrow/test/arrow/arrow-text-renderer.spec.ts index 6caacec3a8..f50d2bb69a 100644 --- a/modules/arrow/test/arrow/arrow-text-renderer.spec.ts +++ b/modules/arrow/test/arrow/arrow-text-renderer.spec.ts @@ -243,7 +243,7 @@ test('ArrowTextRenderer keeps auto text on attributes below compact compute limi const originalMaxStorageBuffersPerShaderStage = device.limits.maxStorageBuffersPerShaderStage; Object.defineProperty(device.limits, 'maxStorageBuffersPerShaderStage', { configurable: true, - value: 8 + value: 6 }); try { const renderer = await ArrowTextRenderer.create(device, { diff --git a/modules/arrow/test/arrow/arrow-text-source-mapping.node.spec.ts b/modules/arrow/test/arrow/arrow-text-source-mapping.node.spec.ts index 58be76c0ee..0c73948a28 100644 --- a/modules/arrow/test/arrow/arrow-text-source-mapping.node.spec.ts +++ b/modules/arrow/test/arrow/arrow-text-source-mapping.node.spec.ts @@ -108,9 +108,9 @@ function makeArrowTextSourceVectors() { textAnchors: arrow.vectorFromArray([0, 1], new arrow.Uint8()), alignmentBaselines: arrow.vectorFromArray([0, 2], new arrow.Uint8()), clipRects: makeArrowFixedSizeListVector( - new arrow.Int16(), + new arrow.Float32(), 4, - new Int16Array([0, 0, 8, 8, 0, 0, 8, 8]) + new Float32Array([0, 0, 8, 8, 0, 0, 8, 8]) ) }; } diff --git a/modules/experimental/src/gpu-primitives/gpu-text-selection.ts b/modules/experimental/src/gpu-primitives/gpu-text-selection.ts new file mode 100644 index 0000000000..7f094e4f4d --- /dev/null +++ b/modules/experimental/src/gpu-primitives/gpu-text-selection.ts @@ -0,0 +1,309 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Binding} from '@luma.gl/core'; +import {Computation} from '@luma.gl/engine'; +import {GPUCommandGraph, type GraphDataView} from './gpu-command-graph'; +import {GPUCompaction} from './gpu-compaction'; +import {createTransientView, getViewBinding, getViewElementOffset} from './graph-data-view-utils'; + +const WORKGROUP_SIZE = 256; + +/** Inputs and outputs for graph-native glyph selection from source-row visibility. */ +export type GPUTextSelectionProps = { + /** Prefix for generated graph nodes and transient resources. */ + id?: string; + /** Source row index for every prepared glyph. Strided uint32 views are supported. */ + glyphRows: GraphDataView<'uint32'>; + /** Packed uint32 visibility flags indexed by source row. */ + rowFlags: GraphDataView<'uint32'>; + /** Packed destination receiving selected source glyph indices. */ + output: GraphDataView<'uint32'>; + /** One uint32 destination receiving the selected glyph count. */ + count: GraphDataView<'uint32'>; + /** Optional packed source glyph records, expressed as uint32 words. */ + sourceRecords?: GraphDataView<'uint32'>; + /** Optional packed destination records in selected-glyph order. */ + outputRecords?: GraphDataView<'uint32'>; + /** Number of uint32 words in one glyph record. Inferred when omitted. */ + recordWordLength?: number; +}; + +/** + * Selects prepared glyphs whose source rows are visible. + * + * The primitive preserves glyph order and writes the result count directly to caller-owned GPU + * storage, including an indirect draw command's `instanceCount` field. + */ +export class GPUTextSelection { + readonly id: string; + readonly glyphRows: GraphDataView<'uint32'>; + readonly rowFlags: GraphDataView<'uint32'>; + readonly output: GraphDataView<'uint32'>; + readonly count: GraphDataView<'uint32'>; + readonly sourceRecords?: GraphDataView<'uint32'>; + readonly outputRecords?: GraphDataView<'uint32'>; + readonly recordWordLength: number; + + constructor(props: GPUTextSelectionProps) { + this.id = props.id ?? 'gpu-text-selection'; + this.glyphRows = props.glyphRows; + this.rowFlags = props.rowFlags; + this.output = props.output; + this.count = props.count; + this.sourceRecords = props.sourceRecords; + this.outputRecords = props.outputRecords; + if (this.output.length < this.glyphRows.length) { + throw new Error(`${this.id} output must contain at least glyphRows.length rows`); + } + if (this.count.length < 1) { + throw new Error(`${this.id} count must contain one uint32 row`); + } + if (Boolean(this.sourceRecords) !== Boolean(this.outputRecords)) { + throw new Error(`${this.id} sourceRecords and outputRecords must be supplied together`); + } + const inferredRecordWordLength = + this.sourceRecords && this.glyphRows.length > 0 + ? this.sourceRecords.length / this.glyphRows.length + : 0; + this.recordWordLength = props.recordWordLength ?? inferredRecordWordLength; + if (this.sourceRecords) { + if (!Number.isSafeInteger(this.recordWordLength) || this.recordWordLength < 1) { + throw new Error(`${this.id} recordWordLength must be a positive integer`); + } + const requiredWordCount = this.glyphRows.length * this.recordWordLength; + if ( + this.sourceRecords.length < requiredWordCount || + this.outputRecords!.length < requiredWordCount + ) { + throw new Error(`${this.id} record buffers are smaller than the glyph record stream`); + } + if ( + this.sourceRecords.byteStride !== Uint32Array.BYTES_PER_ELEMENT || + this.outputRecords!.byteStride !== Uint32Array.BYTES_PER_ELEMENT + ) { + throw new Error(`${this.id} record buffers must be packed uint32 words`); + } + } + } + + addToGraph(graph: GPUCommandGraph): void { + for (const view of [ + this.glyphRows, + this.rowFlags, + this.output, + this.count, + this.sourceRecords, + this.outputRecords + ]) { + if (!view) continue; + if (view.buffer.graph !== graph) { + throw new Error(`${this.id} views must belong to the target graph`); + } + if (view.format !== 'uint32') { + throw new Error(`${this.id} views must use uint32 format`); + } + } + + if (this.glyphRows.length === 0) { + addClearTextSelectionCount(graph, this.id, this.count); + return; + } + + const glyphIds = createTransientView( + graph, + `${this.id}-glyph-ids`, + 'uint32', + this.glyphRows.length + ); + const glyphFlags = createTransientView( + graph, + `${this.id}-glyph-flags`, + 'uint32', + this.glyphRows.length + ); + addGlyphVisibilityPass(graph, this, glyphIds, glyphFlags); + new GPUCompaction({ + id: `${this.id}-compaction`, + input: glyphIds, + flags: glyphFlags, + output: this.output, + count: this.count + }).addToGraph(graph); + if (this.sourceRecords && this.outputRecords) { + addGatherGlyphRecordsPass(graph, this); + } + } +} + +function addGatherGlyphRecordsPass( + graph: GPUCommandGraph, + selection: GPUTextSelection +): void { + const sourceRecords = selection.sourceRecords!; + const outputRecords = selection.outputRecords!; + const passId = `${selection.id}-gather-records`; + const sourceOffset = sourceRecords.byteOffset / Uint32Array.BYTES_PER_ELEMENT; + const outputOffset = outputRecords.byteOffset / Uint32Array.BYTES_PER_ELEMENT; + const selectedOffset = selection.output.byteOffset / Uint32Array.BYTES_PER_ELEMENT; + const countOffset = selection.count.byteOffset / Uint32Array.BYTES_PER_ELEMENT; + const selectedStride = selection.output.byteStride / Uint32Array.BYTES_PER_ELEMENT; + const source = /* wgsl */ ` +const GLYPH_COUNT: u32 = ${selection.glyphRows.length}u; +const RECORD_WORD_LENGTH: u32 = ${selection.recordWordLength}u; +const SOURCE_OFFSET: u32 = ${sourceOffset}u; +const OUTPUT_OFFSET: u32 = ${outputOffset}u; +const SELECTED_OFFSET: u32 = ${selectedOffset}u; +const SELECTED_STRIDE: u32 = ${selectedStride}u; +const COUNT_OFFSET: u32 = ${countOffset}u; +@group(0) @binding(0) var sourceRecords: array; +@group(0) @binding(1) var selectedGlyphIds: array; +@group(0) @binding(2) var selectedCount: array; +@group(0) @binding(3) var outputRecords: array; + +@compute @workgroup_size(${WORKGROUP_SIZE}) fn main( + @builtin(global_invocation_id) globalId: vec3 +) { + let selectedIndex = globalId.x; + if (selectedIndex >= GLYPH_COUNT || selectedIndex >= selectedCount[COUNT_OFFSET]) { return; } + let sourceIndex = selectedGlyphIds[SELECTED_OFFSET + selectedIndex * SELECTED_STRIDE]; + for (var wordIndex = 0u; wordIndex < RECORD_WORD_LENGTH; wordIndex++) { + outputRecords[OUTPUT_OFFSET + selectedIndex * RECORD_WORD_LENGTH + wordIndex] = + sourceRecords[SOURCE_OFFSET + sourceIndex * RECORD_WORD_LENGTH + wordIndex]; + } +}`; + graph.addComputePass({ + id: passId, + resources: [ + {buffer: sourceRecords, usage: 'storage-read'}, + {buffer: selection.output, usage: 'storage-read'}, + {buffer: selection.count, usage: 'storage-read'}, + {buffer: outputRecords, usage: 'storage-write'} + ], + compile: ({device}) => { + const computation = new Computation(device, { + id: passId, + source, + shaderLayout: { + bindings: [ + {name: 'sourceRecords', type: 'storage', group: 0, location: 0}, + {name: 'selectedGlyphIds', type: 'storage', group: 0, location: 1}, + {name: 'selectedCount', type: 'storage', group: 0, location: 2}, + {name: 'outputRecords', type: 'storage', group: 0, location: 3} + ] + } + }); + return { + encode: ({computePass, getBuffer}) => { + computation.setBindings({ + sourceRecords: getBuffer(sourceRecords), + selectedGlyphIds: getBuffer(selection.output), + selectedCount: getBuffer(selection.count), + outputRecords: getBuffer(outputRecords) + }); + computation.dispatch(computePass, Math.ceil(selection.glyphRows.length / WORKGROUP_SIZE)); + }, + destroy: () => computation.destroy() + }; + } + }); +} + +function addGlyphVisibilityPass( + graph: GPUCommandGraph, + selection: GPUTextSelection, + glyphIds: GraphDataView<'uint32'>, + glyphFlags: GraphDataView<'uint32'> +): void { + const passId = `${selection.id}-visibility`; + const source = /* wgsl */ ` +const GLYPH_COUNT: u32 = ${selection.glyphRows.length}u; +const ROW_COUNT: u32 = ${selection.rowFlags.length}u; +const GLYPH_ROW_OFFSET: u32 = ${getViewElementOffset(selection.glyphRows)}u; +const GLYPH_ROW_STRIDE: u32 = ${selection.glyphRows.byteStride / Uint32Array.BYTES_PER_ELEMENT}u; +const ROW_FLAG_OFFSET: u32 = ${getViewElementOffset(selection.rowFlags)}u; +const ROW_FLAG_STRIDE: u32 = ${selection.rowFlags.byteStride / Uint32Array.BYTES_PER_ELEMENT}u; +@group(0) @binding(0) var glyphRows: array; +@group(0) @binding(1) var rowFlags: array; +@group(0) @binding(2) var glyphIds: array; +@group(0) @binding(3) var glyphFlags: array; + +@compute @workgroup_size(${WORKGROUP_SIZE}) fn main( + @builtin(global_invocation_id) globalId: vec3 +) { + let glyphIndex = globalId.x; + if (glyphIndex >= GLYPH_COUNT) { return; } + let rowIndex = glyphRows[GLYPH_ROW_OFFSET + glyphIndex * GLYPH_ROW_STRIDE]; + glyphIds[glyphIndex] = glyphIndex; + glyphFlags[glyphIndex] = 0u; + if (rowIndex < ROW_COUNT) { + glyphFlags[glyphIndex] = rowFlags[ROW_FLAG_OFFSET + rowIndex * ROW_FLAG_STRIDE]; + } +}`; + graph.addComputePass({ + id: passId, + resources: [ + {buffer: selection.glyphRows, usage: 'storage-read'}, + {buffer: selection.rowFlags, usage: 'storage-read'}, + {buffer: glyphIds, usage: 'storage-write'}, + {buffer: glyphFlags, usage: 'storage-write'} + ], + compile: ({device}) => { + const computation = new Computation(device, { + id: passId, + source, + shaderLayout: { + bindings: [ + {name: 'glyphRows', type: 'storage', group: 0, location: 0}, + {name: 'rowFlags', type: 'storage', group: 0, location: 1}, + {name: 'glyphIds', type: 'storage', group: 0, location: 2}, + {name: 'glyphFlags', type: 'storage', group: 0, location: 3} + ] + } + }); + return { + encode: ({computePass, getBuffer}) => { + computation.setBindings({ + glyphRows: getViewBinding(selection.glyphRows, getBuffer), + rowFlags: getViewBinding(selection.rowFlags, getBuffer), + glyphIds: getViewBinding(glyphIds, getBuffer), + glyphFlags: getViewBinding(glyphFlags, getBuffer) + }); + computation.dispatch(computePass, Math.ceil(selection.glyphRows.length / WORKGROUP_SIZE)); + }, + destroy: () => computation.destroy() + }; + } + }); +} + +function addClearTextSelectionCount( + graph: GPUCommandGraph, + id: string, + count: GraphDataView<'uint32'> +): void { + graph.addComputePass({ + id: `${id}-clear-count`, + resources: [{buffer: count, usage: 'storage-write'}], + compile: ({device}) => { + const computation = new Computation(device, { + id: `${id}-clear-count`, + source: `const COUNT_OFFSET: u32 = ${getViewElementOffset(count)}u; +@group(0) @binding(0) var count: array; +@compute @workgroup_size(1) fn main() { count[COUNT_OFFSET] = 0u; }`, + shaderLayout: { + bindings: [{name: 'count', type: 'storage', group: 0, location: 0}] + } + }); + return { + encode: ({computePass, getBuffer}) => { + const bindings: Record = {count: getViewBinding(count, getBuffer)}; + computation.setBindings(bindings); + computation.dispatch(computePass, 1); + }, + destroy: () => computation.destroy() + }; + } + }); +} diff --git a/modules/experimental/src/gpu-primitives/index.ts b/modules/experimental/src/gpu-primitives/index.ts index 0fce4b1633..24b9cd16cb 100644 --- a/modules/experimental/src/gpu-primitives/index.ts +++ b/modules/experimental/src/gpu-primitives/index.ts @@ -42,6 +42,8 @@ export {GPUScan} from './gpu-scan'; export type {GPUScanInput, GPUScanProps} from './gpu-scan'; export {GPUCompaction} from './gpu-compaction'; export type {GPUCompactionInput, GPUCompactionProps} from './gpu-compaction'; +export {GPUTextSelection} from './gpu-text-selection'; +export type {GPUTextSelectionProps} from './gpu-text-selection'; export {GPUSort} from './gpu-sort'; export type {GPUSortAlgorithm, GPUSortDirection, GPUSortProps} from './gpu-sort'; diff --git a/modules/experimental/test/gpu-primitives/gpu-command-graph.spec.ts b/modules/experimental/test/gpu-primitives/gpu-command-graph.spec.ts index bbd300ba8e..d91c5cb17b 100644 --- a/modules/experimental/test/gpu-primitives/gpu-command-graph.spec.ts +++ b/modules/experimental/test/gpu-primitives/gpu-command-graph.spec.ts @@ -5,7 +5,13 @@ import test from '@luma.gl/devtools-extensions/tape-test-utils'; import {Buffer, type Device, Texture} from '@luma.gl/core'; import {DynamicBuffer, Model} from '@luma.gl/engine'; -import {DrawCommandBuffer, GPUCommandGraph, GPUCompaction, GPUScan} from '@luma.gl/experimental'; +import { + DrawCommandBuffer, + GPUCommandGraph, + GPUCompaction, + GPUScan, + GPUTextSelection +} from '@luma.gl/experimental'; import {GPUData, GPUVector} from '@luma.gl/tables'; import {getNullTestDevice, getWebGPUTestDevice} from '@luma.gl/test-utils'; @@ -549,6 +555,116 @@ test('GPUCompaction preserves selected order and writes indirect instance count' t.end(); }); +test('GPUTextSelection gathers selected row-indexed glyph records and indirect count', async t => { + const device = await getWebGPUTestDevice(); + if (!device) { + t.comment('WebGPU is not available'); + t.end(); + return; + } + const records = new Uint32Array([10, 100, 2, 11, 101, 0, 12, 102, 1, 13, 103, 2, 14, 104, 0]); + const rowFlags = new Uint32Array([1, 0, 1]); + const recordBuffer = device.createBuffer({ + data: records, + usage: Buffer.STORAGE | Buffer.COPY_DST + }); + const rowFlagBuffer = device.createBuffer({ + data: rowFlags, + usage: Buffer.STORAGE | Buffer.COPY_DST + }); + const selectedIdBuffer = device.createBuffer({ + byteLength: 5 * Uint32Array.BYTES_PER_ELEMENT, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }); + const selectedRecordBuffer = device.createBuffer({ + byteLength: records.byteLength, + usage: Buffer.STORAGE | Buffer.COPY_SRC + }); + const drawCommands = new DrawCommandBuffer(device, { + type: 'draw', + commands: [{vertexCount: 6, instanceCount: 0}] + }); + const graph = new GPUCommandGraph(device, {id: 'text-selection-test'}); + const recordsHandle = graph.importBuffer( + {id: 'records', byteLength: recordBuffer.byteLength, usage: recordBuffer.usage}, + recordBuffer + ); + const rowFlagsHandle = graph.importBuffer( + {id: 'row-flags', byteLength: rowFlagBuffer.byteLength, usage: rowFlagBuffer.usage}, + rowFlagBuffer + ); + const selectedIdsHandle = graph.importBuffer( + { + id: 'selected-ids', + byteLength: selectedIdBuffer.byteLength, + usage: selectedIdBuffer.usage + }, + selectedIdBuffer + ); + const selectedRecordsHandle = graph.importBuffer( + { + id: 'selected-records', + byteLength: selectedRecordBuffer.byteLength, + usage: selectedRecordBuffer.usage + }, + selectedRecordBuffer + ); + new GPUTextSelection({ + glyphRows: graph.createDataView(recordsHandle, { + format: 'uint32', + length: 5, + byteOffset: 2 * Uint32Array.BYTES_PER_ELEMENT, + byteStride: 3 * Uint32Array.BYTES_PER_ELEMENT + }), + rowFlags: graph.createDataView(rowFlagsHandle, {format: 'uint32', length: 3}), + output: graph.createDataView(selectedIdsHandle, {format: 'uint32', length: 5}), + count: graph.importGPUData('selected-count', drawCommands.getInstanceCountData(0)), + sourceRecords: graph.createDataView(recordsHandle, { + format: 'uint32', + length: records.length + }), + outputRecords: graph.createDataView(selectedRecordsHandle, { + format: 'uint32', + length: records.length + }), + recordWordLength: 3 + }).addToGraph(graph); + const compiled = graph.compile(); + const commandEncoder = device.createCommandEncoder({id: 'text-selection-test-encoder'}); + compiled.encode(commandEncoder, {parameters: undefined}); + device.submit(commandEncoder.finish()); + + const selectedIdBytes = await selectedIdBuffer.readAsync(); + t.deepEqual( + Array.from(new Uint32Array(selectedIdBytes.buffer, selectedIdBytes.byteOffset, 4)), + [0, 1, 3, 4], + 'selection preserves original glyph order' + ); + const selectedRecordBytes = await selectedRecordBuffer.readAsync(); + t.deepEqual( + Array.from(new Uint32Array(selectedRecordBytes.buffer, selectedRecordBytes.byteOffset, 12)), + [10, 100, 2, 11, 101, 0, 13, 103, 2, 14, 104, 0], + 'selected compact records retain original row ids' + ); + const countBytes = await drawCommands.buffer.readAsync( + drawCommands.getInstanceCountByteOffset(0), + Uint32Array.BYTES_PER_ELEMENT + ); + t.equal( + new Uint32Array(countBytes.buffer, countBytes.byteOffset, 1)[0], + 4, + 'selection writes exact indirect glyph count' + ); + + compiled.destroy(); + recordBuffer.destroy(); + rowFlagBuffer.destroy(); + selectedIdBuffer.destroy(); + selectedRecordBuffer.destroy(); + drawCommands.destroy(); + t.end(); +}); + test('GPUCompaction handles empty, none, all, alternating, and random masks', async t => { const device = await getWebGPUTestDevice(); if (!device) { diff --git a/modules/text/src/text-2d/atlas/text-utils.ts b/modules/text/src/text-2d/atlas/text-utils.ts index 0e53c60d86..699a95e2f6 100644 --- a/modules/text/src/text-2d/atlas/text-utils.ts +++ b/modules/text/src/text-2d/atlas/text-utils.ts @@ -14,7 +14,7 @@ export type Character = { anchorY: number; /** Glyph quad origin relative to the current pen. Defaults to `anchorX`. */ layoutOffsetX?: number; - /** Glyph quad vertical offset relative to the line baseline. Defaults to zero. */ + /** Glyph quad top offset relative to the line baseline. Defaults to zero. */ layoutOffsetY?: number; advance: number; }; diff --git a/modules/text/src/text-2d/model-utils/gpu-text-expansion.ts b/modules/text/src/text-2d/model-utils/gpu-text-expansion.ts index b3e5feaec2..a515205c71 100644 --- a/modules/text/src/text-2d/model-utils/gpu-text-expansion.ts +++ b/modules/text/src/text-2d/model-utils/gpu-text-expansion.ts @@ -123,16 +123,17 @@ export type GpuTextAlignmentExpansionOptions = { lineHeight?: number; }; -/** Storage bindings required by compact glyph expansion compute. */ -export const GPU_TEXT_EXPANSION_STORAGE_BUFFER_COUNT = 9; +/** Storage bindings required by compact glyph expansion compute before optional row alignment. */ +export const GPU_TEXT_EXPANSION_STORAGE_BUFFER_COUNT = 7; /** Storage bindings required by UTF-8 glyph expansion compute. */ export const GPU_UTF8_TEXT_EXPANSION_STORAGE_BUFFER_COUNT = 10; /** Whether the device can run compact glyph expansion compute. */ -export function supportsGpuTextExpansion(device: Device): boolean { +export function supportsGpuTextExpansion(device: Device, alignmentBufferCount = 0): boolean { return ( device.type === 'webgpu' && - device.limits.maxStorageBuffersPerShaderStage >= GPU_TEXT_EXPANSION_STORAGE_BUFFER_COUNT + device.limits.maxStorageBuffersPerShaderStage >= + GPU_TEXT_EXPANSION_STORAGE_BUFFER_COUNT + alignmentBufferCount ); } @@ -297,6 +298,63 @@ const GPU_EXPANDED_TEXT_COMPUTE_SHADER_LAYOUT: ShaderLayout = { attributes: [] }; +function specializeGpuExpandedTextCompute( + source: string, + useRowTextAnchors: boolean, + useRowAlignmentBaselines: boolean +): {source: string; shaderLayout: ShaderLayout} { + let specializedSource = source; + const omittedBindings = new Set(); + if (!useRowTextAnchors) { + specializedSource = specializedSource + .replace('@group(0) @binding(7) var textRowTextAnchors : array;\n', '') + .replace( + `fn getTextAnchor(rowIndex: u32) -> u32 { + let rowStorageIndex = rowIndex + u32(max(textExpansionConfig[3], 0)); + if (textExpansionConfig[5] != 0) { + let packedAnchorWord = textRowTextAnchors[rowStorageIndex >> 2u]; + return (packedAnchorWord >> ((rowStorageIndex & 3u) * 8u)) & 0xffu; + } + return u32(max(textExpansionConfig[4], 0)); +}`, + `fn getTextAnchor(rowIndex: u32) -> u32 { + return u32(max(textExpansionConfig[4], 0)); +}` + ); + omittedBindings.add('textRowTextAnchors'); + } + if (!useRowAlignmentBaselines) { + specializedSource = specializedSource + .replace( + '@group(0) @binding(8) var textRowAlignmentBaselines : array;\n', + '' + ) + .replace( + `fn getAlignmentBaseline(rowIndex: u32) -> u32 { + let rowStorageIndex = rowIndex + u32(max(textExpansionConfig[3], 0)); + if (textExpansionConfig[7] != 0) { + let packedBaselineWord = textRowAlignmentBaselines[rowStorageIndex >> 2u]; + return (packedBaselineWord >> ((rowStorageIndex & 3u) * 8u)) & 0xffu; + } + return u32(max(textExpansionConfig[6], 0)); +}`, + `fn getAlignmentBaseline(rowIndex: u32) -> u32 { + return u32(max(textExpansionConfig[6], 0)); +}` + ); + omittedBindings.add('textRowAlignmentBaselines'); + } + return { + source: specializedSource, + shaderLayout: { + ...GPU_EXPANDED_TEXT_COMPUTE_SHADER_LAYOUT, + bindings: GPU_EXPANDED_TEXT_COMPUTE_SHADER_LAYOUT.bindings.filter( + binding => !omittedBindings.has(binding.name) + ) + } + }; +} + const GPU_UTF8_MAP_BINDING_OPTIONS = { rowByteRanges: 'textRowByteRanges', utf8Bytes: 'textUtf8Bytes', @@ -971,15 +1029,23 @@ export function dispatchGpuExpandedTextCompute( labelCount: number; alignment: Required< Pick - >; + > & + Pick; } ): void { - const computation = new Computation(device, { - id: `${options.id || 'gpu-expanded-text-model'}-compute`, - source: state.generated.hasGlyphRowIndices + const useRowTextAnchors = state.alignment.useRowTextAnchors ?? false; + const useRowAlignmentBaselines = state.alignment.useRowAlignmentBaselines ?? false; + const specializedCompute = specializeGpuExpandedTextCompute( + state.generated.hasGlyphRowIndices ? GPU_EXPANDED_TEXT_COMPUTE_WITH_ROW_INDICES_SOURCE : GPU_EXPANDED_TEXT_COMPUTE_SOURCE, - shaderLayout: GPU_EXPANDED_TEXT_COMPUTE_SHADER_LAYOUT, + useRowTextAnchors, + useRowAlignmentBaselines + ); + const computation = new Computation(device, { + id: `${options.id || 'gpu-expanded-text-model'}-compute`, + source: specializedCompute.source, + shaderLayout: specializedCompute.shaderLayout, bindings: { textGlyphRanges: state.compactInput.glyphRangesBuffer, textGlyphIds: state.compactInput.glyphIdsBuffer, @@ -988,8 +1054,10 @@ export function dispatchGpuExpandedTextCompute( textGlyphKernings: state.glyphKernings.buffer, textExpansionConfig: state.compactInput.expansionConfigBuffer, generatedGlyphVertices: state.generated.compactGlyphVertexData, - textRowTextAnchors: state.alignment.rowTextAnchorsBuffer, - textRowAlignmentBaselines: state.alignment.rowAlignmentBaselinesBuffer + ...(useRowTextAnchors ? {textRowTextAnchors: state.alignment.rowTextAnchorsBuffer} : {}), + ...(useRowAlignmentBaselines + ? {textRowAlignmentBaselines: state.alignment.rowAlignmentBaselinesBuffer} + : {}) } }); if (state.glyphCount > 0 && state.labelCount > 0) { diff --git a/modules/text/src/text-2d/model-utils/text-model-props.ts b/modules/text/src/text-2d/model-utils/text-model-props.ts index f2895b9d21..fa163cae8f 100644 --- a/modules/text/src/text-2d/model-utils/text-model-props.ts +++ b/modules/text/src/text-2d/model-utils/text-model-props.ts @@ -65,7 +65,7 @@ export const TEXT_ATTRIBUTE_GPU_INPUT_SCHEMA = [ columnName: 'clipRects', kind: 'positions', required: false, - formats: ['sint16x4'] + formats: ['float32x4'] } ] as const satisfies GPUInputSchema; @@ -123,7 +123,7 @@ export const TEXT_STORAGE_GPU_INPUT_SCHEMA = [ columnName: 'clipRects', kind: 'positions', required: false, - formats: ['sint16x4'] + formats: ['float32x4'] } ] as const satisfies GPUInputSchema; @@ -162,7 +162,7 @@ export interface TextInputProps extends ModelProps { * Optional GPU packed per-label clip rectangles `[x, y, width, height]`. * Negative width or height disables clipping on that axis. */ - clipRects?: GPUVector<'sint16x4'>; + clipRects?: GPUVector<'float32x4'>; /** Normalized atlas-backed font consumed by text layout and rendering. */ fontAtlas: FontAtlas; /** Multiplier applied to the atlas font size for one-line baseline layout. */ diff --git a/modules/text/src/text-2d/model-utils/text-shaders.ts b/modules/text/src/text-2d/model-utils/text-shaders.ts index b3ca306c4c..b58beebb0a 100644 --- a/modules/text/src/text-2d/model-utils/text-shaders.ts +++ b/modules/text/src/text-2d/model-utils/text-shaders.ts @@ -34,7 +34,7 @@ export const COMPACT_GLYPH_VERTEX_BYTE_STRIDE = Uint32Array.BYTES_PER_ELEMENT * export const ROW_INDEXED_COMPACT_GLYPH_VERTEX_BYTE_STRIDE = Uint32Array.BYTES_PER_ELEMENT * 3; export const EXPANDED_GLYPH_VERTEX_BYTE_STRIDE = Uint32Array.BYTES_PER_ELEMENT * 4; export const CLIPPED_EXPANDED_GLYPH_VERTEX_BYTE_STRIDE = - EXPANDED_GLYPH_VERTEX_BYTE_STRIDE + Int16Array.BYTES_PER_ELEMENT * 4; + EXPANDED_GLYPH_VERTEX_BYTE_STRIDE + Float32Array.BYTES_PER_ELEMENT * 4; export const INVALID_DICTIONARY_INDEX = 0xffffffff; export const DEFAULT_TEXT_SHADER_LAYOUT: ShaderLayout = { @@ -50,7 +50,7 @@ export const DEFAULT_TEXT_SHADER_LAYOUT: ShaderLayout = { export const DEFAULT_CLIPPED_TEXT_SHADER_LAYOUT: ShaderLayout = { attributes: [ ...DEFAULT_TEXT_SHADER_LAYOUT.attributes, - {name: GLYPH_CLIP_RECTS_COLUMN, location: 4, type: 'vec4', stepMode: 'instance'} + {name: GLYPH_CLIP_RECTS_COLUMN, location: 4, type: 'vec4', stepMode: 'instance'} ], bindings: [] }; @@ -118,7 +118,7 @@ in vec2 positions; in ivec2 glyphOffsets; in uvec4 glyphFrames; in uint glyphPages; -in ivec4 glyphClipRects; +in vec4 glyphClipRects; out vec2 vTextureCoordinate; flat out uint vAtlasPage; @@ -134,17 +134,17 @@ vec2 getCorner(int vertexIndex) { return vec2(0.0, 1.0); } -bool isGlyphVertexClipped(vec2 glyphVertexOffset, ivec4 clipRect) { +bool isGlyphVertexClipped(vec2 glyphVertexOffset, vec4 clipRect) { if (clipRect.z >= 0) { - float clipMinX = float(clipRect.x); - float clipMaxX = clipMinX + float(clipRect.z); + float clipMinX = clipRect.x; + float clipMaxX = clipMinX + clipRect.z; if (glyphVertexOffset.x < clipMinX || glyphVertexOffset.x > clipMaxX) { return true; } } if (clipRect.w >= 0) { - float clipMinY = float(clipRect.y); - float clipMaxY = clipMinY + float(clipRect.w); + float clipMinY = clipRect.y; + float clipMaxY = clipMinY + clipRect.w; if (glyphVertexOffset.y < clipMinY || glyphVertexOffset.y > clipMaxY) { return true; } @@ -199,7 +199,7 @@ export const DEFAULT_TEXT_STORAGE_INDEXED_SOURCE = /* wgsl */ ` @group(0) @binding(auto) var textRowAngles : array; @group(0) @binding(auto) var textRowSizes : array; @group(0) @binding(auto) var textRowPixelOffsets : array>; -@group(0) @binding(auto) var textRowClipRects : array>; +@group(0) @binding(auto) var textRowClipRects : array>; @group(0) @binding(auto) var textRowGlyphStarts : array; @group(0) @binding(auto) var textGlyphFrames : array>; @@ -267,26 +267,21 @@ fn unpackHighInt16(word: u32) -> i32 { return i32(word) >> 16; } -fn unpackClipRect(words: vec2) -> vec4 { - return vec4( - unpackLowInt16(words.x), - unpackHighInt16(words.x), - unpackLowInt16(words.y), - unpackHighInt16(words.y) - ); +fn unpackClipRect(words: vec4) -> vec4 { + return words; } -fn isGlyphVertexClipped(glyphVertexOffset: vec2, clipRect: vec4) -> bool { +fn isGlyphVertexClipped(glyphVertexOffset: vec2, clipRect: vec4) -> bool { if (clipRect.z >= 0) { - let clipMinX = f32(clipRect.x); - let clipMaxX = clipMinX + f32(clipRect.z); + let clipMinX = clipRect.x; + let clipMaxX = clipMinX + clipRect.z; if (glyphVertexOffset.x < clipMinX || glyphVertexOffset.x > clipMaxX) { return true; } } if (clipRect.w >= 0) { - let clipMinY = f32(clipRect.y); - let clipMaxY = clipMinY + f32(clipRect.w); + let clipMinY = clipRect.y; + let clipMaxY = clipMinY + clipRect.w; if (glyphVertexOffset.y < clipMinY || glyphVertexOffset.y > clipMaxY) { return true; } @@ -410,7 +405,7 @@ export const DEFAULT_TEXT_DICTIONARY_STORAGE_SOURCE = /* wgsl */ ` @group(0) @binding(auto) var textRowAngles : array; @group(0) @binding(auto) var textRowSizes : array; @group(0) @binding(auto) var textRowPixelOffsets : array>; -@group(0) @binding(auto) var textRowClipRects : array>; +@group(0) @binding(auto) var textRowClipRects : array>; // textRowDictionaryRecords[row] = (dictionary value index, row glyph start). // The extra terminal record stores rowCount/invalid and total visible glyphs. @group(0) @binding(auto) var textRowDictionaryRecords : array>; @@ -484,26 +479,21 @@ fn unpackHighInt16(word: u32) -> i32 { return i32(word) >> 16; } -fn unpackClipRect(words: vec2) -> vec4 { - return vec4( - unpackLowInt16(words.x), - unpackHighInt16(words.x), - unpackLowInt16(words.y), - unpackHighInt16(words.y) - ); +fn unpackClipRect(words: vec4) -> vec4 { + return words; } -fn isGlyphVertexClipped(glyphVertexOffset: vec2, clipRect: vec4) -> bool { +fn isGlyphVertexClipped(glyphVertexOffset: vec2, clipRect: vec4) -> bool { if (clipRect.z >= 0) { - let clipMinX = f32(clipRect.x); - let clipMaxX = clipMinX + f32(clipRect.z); + let clipMinX = clipRect.x; + let clipMaxX = clipMinX + clipRect.z; if (glyphVertexOffset.x < clipMinX || glyphVertexOffset.x > clipMaxX) { return true; } } if (clipRect.w >= 0) { - let clipMinY = f32(clipRect.y); - let clipMaxY = clipMinY + f32(clipRect.w); + let clipMinY = clipRect.y; + let clipMaxY = clipMinY + clipRect.w; if (glyphVertexOffset.y < clipMinY || glyphVertexOffset.y > clipMaxY) { return true; } diff --git a/modules/text/test/text-2d/convert-arrow-text-vectors.spec.ts b/modules/text/test/text-2d/convert-arrow-text-vectors.spec.ts index ba662034f0..afffed2b45 100644 --- a/modules/text/test/text-2d/convert-arrow-text-vectors.spec.ts +++ b/modules/text/test/text-2d/convert-arrow-text-vectors.spec.ts @@ -133,9 +133,9 @@ test('buildArrowTextGlyphTable expands character color lists per glyph', t => { test('buildArrowTextGlyphTable expands packed clip rectangles per glyph', t => { const clipRects = makeArrowFixedSizeListVector( - new arrow.Int16(), + new arrow.Float32(), 4, - new Int16Array([0, 1, 12, -1, 3, 4, -1, 9]) + new Float32Array([0, 1, 12, -1, 3, 4, -1, 9]) ); const result = buildArrowTextGlyphTable({ labelTable: makeLabelTable(), @@ -145,7 +145,9 @@ test('buildArrowTextGlyphTable expands packed clip rectangles per glyph', t => { }); t.deepEqual( - Array.from(result.table.getChild('glyphClipRects')!.data[0]!.children[0]!.values as Int16Array), + Array.from( + result.table.getChild('glyphClipRects')!.data[0]!.children[0]!.values as Float32Array + ), [0, 1, 12, -1, 0, 1, 12, -1, 3, 4, -1, 9], 'packed i16x4 clip rectangles repeat for each generated glyph' ); @@ -222,15 +224,19 @@ test('convertArrowTextToAttribute uploads packed colors as normalized vectors', t.end(); }); -test('packTextStorageClipRects preserves signed Int16 clip lanes', t => { +test('packTextStorageClipRects preserves Float32 world-space clip lanes', t => { const packedClipRects = packTextStorageClipRects( - makeArrowFixedSizeListVector(new arrow.Int16(), 4, new Int16Array([0, 1, 12, -1, -4, 8, -1, 9])) + makeArrowFixedSizeListVector( + new arrow.Float32(), + 4, + new Float32Array([0, 1, 12, -1, -4, 8, -1, 9]) + ) ); t.deepEqual( - Array.from(packedClipRects).flatMap(unpackSignedInt16Pair), + Array.from(packedClipRects), [0, 1, 12, -1, -4, 8, -1, 9], - 'packed storage rows decode to original signed clip lanes' + 'storage rows retain original world-space clip lanes' ); t.end(); }); @@ -1555,10 +1561,6 @@ function makeTextColorListVector( >; } -function unpackSignedInt16Pair(word: number): [number, number] { - return [toSignedInt16(word & 0xffff), toSignedInt16((word >>> 16) & 0xffff)]; -} - function packSignedInt16Pair(lowerValue: number, upperValue: number): number { return ((lowerValue & 0xffff) | ((upperValue & 0xffff) << 16)) >>> 0; } @@ -1572,7 +1574,3 @@ function resolveTestStorageBuffer(buffer: unknown): unknown { function packUint16Pair(lowerValue: number, upperValue: number): number { return ((lowerValue & 0xffff) | ((upperValue & 0xffff) << 16)) >>> 0; } - -function toSignedInt16(value: number): number { - return value & 0x8000 ? value - 0x10000 : value; -} diff --git a/modules/text/test/text-2d/font-atlas-builders.spec.ts b/modules/text/test/text-2d/font-atlas-builders.spec.ts index dfce1d81da..27e9bea83d 100644 --- a/modules/text/test/text-2d/font-atlas-builders.spec.ts +++ b/modules/text/test/text-2d/font-atlas-builders.spec.ts @@ -74,3 +74,25 @@ test('browser font builders cache and incrementally extend atlases', t => { t.deepEqual(Object.keys(extendedAtlas.mapping), ['A', 'B', 'C'], 'extension adds new glyphs'); t.end(); }); + +test('browser font builders align glyphs to a shared baseline', t => { + if (!isBrowser()) { + t.end(); + return; + } + + for (const [label, fontAtlas] of [ + [ + 'bitmap', + buildBitmapFontAtlas(createFontAtlasSettings('descender-bitmap', {characterSet: 'ag'})) + ], + ['SDF', buildSdfFontAtlas(createFontAtlasSettings('descender-sdf', {characterSet: 'ag'}))] + ] as const) { + t.ok( + (fontAtlas.mapping.g?.layoutOffsetY ?? 0) + (fontAtlas.mapping.g?.height ?? 0) > + (fontAtlas.mapping.a?.layoutOffsetY ?? 0) + (fontAtlas.mapping.a?.height ?? 0), + `${label} g extends below the a baseline` + ); + } + t.end(); +}); diff --git a/modules/text/test/text-2d/text-model-gpu-inputs.node.spec.ts b/modules/text/test/text-2d/text-model-gpu-inputs.node.spec.ts index dec9039ddd..3c4263e1df 100644 --- a/modules/text/test/text-2d/text-model-gpu-inputs.node.spec.ts +++ b/modules/text/test/text-2d/text-model-gpu-inputs.node.spec.ts @@ -59,7 +59,7 @@ test('2D text models declare flat source-mappable GPU inputs', t => { columnName: 'clipRects', kind: 'positions', required: false, - formats: ['sint16x4'] + formats: ['float32x4'] } ]); t.deepEqual(TEXT_STORAGE_GPU_INPUT_SCHEMA, [ @@ -115,7 +115,7 @@ test('2D text models declare flat source-mappable GPU inputs', t => { columnName: 'clipRects', kind: 'positions', required: false, - formats: ['sint16x4'] + formats: ['float32x4'] } ]); t.deepEqual(TEXT_DICTIONARY_GPU_INPUT_SCHEMA, [ diff --git a/modules/text/test/text-2d/text-utils.spec.ts b/modules/text/test/text-2d/text-utils.spec.ts index e1bb051f96..dd7e181a8b 100644 --- a/modules/text/test/text-2d/text-utils.spec.ts +++ b/modules/text/test/text-2d/text-utils.spec.ts @@ -37,3 +37,31 @@ test('text-2d mapping helpers preserve deck-compatible packing behavior', t => { }); t.end(); }); + +test('text-2d mapping aligns glyphs to a shared baseline', t => { + const {mapping} = buildMapping({ + characterSet: new Set('ag'), + measureText: character => ({ + advance: 4, + width: 4, + ascent: character === 'g' ? 2 : 3, + descent: character === 'g' ? 2 : 1 + }), + buffer: 1, + maxCanvasWidth: 32 + }); + + t.equal(mapping.a?.layoutOffsetY, -3, 'non-descender top is offset by its ascent'); + t.equal(mapping.g?.layoutOffsetY, -2, 'descender top is offset by its ascent'); + t.equal( + (mapping.a?.layoutOffsetY ?? 0) + (mapping.a?.height ?? 0), + 1, + 'non-descender bottom uses its descent' + ); + t.equal( + (mapping.g?.layoutOffsetY ?? 0) + (mapping.g?.height ?? 0), + 2, + 'descender extends below the shared baseline' + ); + t.end(); +}); diff --git a/scripts/examples-typecheck.mjs b/scripts/examples-typecheck.mjs index c5bec4bd4b..c3b127058f 100644 --- a/scripts/examples-typecheck.mjs +++ b/scripts/examples-typecheck.mjs @@ -21,6 +21,7 @@ const SUPPORTED_EXAMPLE_WORKSPACES = new Set([ 'arrow/arrow-polygons', 'experimental/gpu-frustum-culling', 'experimental/gpu-trace-viewer', + 'deck/gpu-culled-trace', 'experimental/gpu-sort', 'experimental/video-texture', 'experimental/webxr-kaleidoscope', diff --git a/website/content/examples/deck/gpu-culled-trace.mdx b/website/content/examples/deck/gpu-culled-trace.mdx new file mode 100644 index 0000000000..1fafa29639 --- /dev/null +++ b/website/content/examples/deck/gpu-culled-trace.mdx @@ -0,0 +1,8 @@ +--- +title: GPU-Culled Trace with Arrow Text +sidebar_label: GPU-Culled Trace +--- + +import {DeckGPUCulledTraceExample} from '@site/src/examples'; + + diff --git a/website/content/examples/table-of-contents.json b/website/content/examples/table-of-contents.json index 867645d468..f706bd5191 100644 --- a/website/content/examples/table-of-contents.json +++ b/website/content/examples/table-of-contents.json @@ -138,7 +138,8 @@ "items": [ "deck/arrow-path-layer", "deck/arrow-polygon-layer", - "deck/arrow-text-layer" + "deck/arrow-text-layer", + "deck/gpu-culled-trace" ] } ] diff --git a/website/content/sidebar-examples.js b/website/content/sidebar-examples.js index 27aecaf56a..32200e4f11 100644 --- a/website/content/sidebar-examples.js +++ b/website/content/sidebar-examples.js @@ -106,7 +106,8 @@ const sidebars = { items: [ 'deck/arrow-path-layer', 'deck/arrow-polygon-layer', - 'deck/arrow-text-layer' + 'deck/arrow-text-layer', + 'deck/gpu-culled-trace' ] } ] diff --git a/website/src/examples.tsx b/website/src/examples.tsx index 6d47522ada..d4f5ceae35 100644 --- a/website/src/examples.tsx +++ b/website/src/examples.tsx @@ -93,6 +93,7 @@ import TransformApp from '../../examples/tutorials/transform/app'; import {createArrowPathLayerDeck} from '../../examples/deck/arrow-path-layer/app'; import {createArrowPolygonLayerDeck} from '../../examples/deck/arrow-polygon-layer/app'; import {createArrowTextLayerDeck} from '../../examples/deck/arrow-text-layer/app'; +import {createGPUCulledTraceDeck} from '../../examples/deck/gpu-culled-trace/app'; const exampleConfig = {}; @@ -115,6 +116,7 @@ type CreateDeckExample = ( type DeckArrowLayerPanelProps = { id: string; title: string; + devices?: Array<'webgpu' | 'webgl2'>; }; function makeDeckArrowLayerInfoPanel({id, title}: DeckArrowLayerPanelProps) { @@ -125,7 +127,7 @@ function makeDeckArrowLayerInfoPanel({id, title}: DeckArrowLayerPanelProps) { }); } -function DeckArrowLayerPanel({id, title}: DeckArrowLayerPanelProps) { +function DeckArrowLayerPanel({id, title, devices = ['webgpu', 'webgl2']}: DeckArrowLayerPanelProps) { const panel = useMemo( () => makeDeckArrowLayerInfoPanel({id, title}), [id, title] @@ -160,7 +162,7 @@ function DeckArrowLayerPanel({id, title}: DeckArrowLayerPanelProps) { panel={panel} /> @@ -288,6 +290,24 @@ export const DeckArrowTextLayerExample: React.FC = ( /> ); +export const DeckGPUCulledTraceExample: React.FC = ({ + embedded = false +}) => ( + +); + type DeckArrowLayerExampleId = 'path' | 'polygon' | 'text'; const DECK_ARROW_LAYER_DOC_EXAMPLES: Array<{ diff --git a/yarn.lock b/yarn.lock index 02b6d8b318..b87cb6c350 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16070,6 +16070,25 @@ __metadata: languageName: unknown linkType: soft +"luma.gl-examples-deck-gpu-culled-trace@workspace:examples/deck/gpu-culled-trace": + version: 0.0.0-use.local + resolution: "luma.gl-examples-deck-gpu-culled-trace@workspace:examples/deck/gpu-culled-trace" + dependencies: + "@deck.gl-community/arrow-layers": "workspace:*" + "@deck.gl/core": "npm:9.3.4" + "@luma.gl/arrow": "npm:~9.3.0" + "@luma.gl/core": "npm:~9.3.0" + "@luma.gl/engine": "npm:~9.3.0" + "@luma.gl/experimental": "npm:~9.3.0" + "@luma.gl/shadertools": "npm:~9.3.0" + "@luma.gl/text": "npm:~9.3.0" + "@luma.gl/webgpu": "npm:~9.3.0" + apache-arrow: "npm:^17.0.0" + typescript: "npm:^5.9.3" + vite: "npm:^8.0.0" + languageName: unknown + linkType: soft + "luma.gl-examples-experimental-a-buffer@workspace:examples/experimental/a-buffer": version: 0.0.0-use.local resolution: "luma.gl-examples-experimental-a-buffer@workspace:examples/experimental/a-buffer"