diff --git a/modules/arrow-layers/src/layers/arrow-layer-input.ts b/modules/arrow-layers/src/layers/arrow-layer-input.ts index 588d89de72..9740415ab0 100644 --- a/modules/arrow-layers/src/layers/arrow-layer-input.ts +++ b/modules/arrow-layers/src/layers/arrow-layer-input.ts @@ -3,10 +3,16 @@ // Copyright (c) vis.gl contributors import { + canConvertColors, + convertArrowColors, + convertColors, getArrowPaths, getArrowRecordBatchAsyncIterator, + readArrowGPUVectorAsync, + type ArrowColorType, type ArrowRecordBatchSource } from '@luma.gl/arrow'; +import type {Device} from '@luma.gl/core'; import {GPUVector, type GPUVectorFormat} from '@luma.gl/tables'; import {RecordBatch, Table, type DataType, type Vector} from 'apache-arrow'; @@ -56,6 +62,59 @@ export function assertArrowLayerGPUVector( } } +/** Validates a caller-owned color vector that is normalized already or convertible by luma.gl. */ +export function assertArrowLayerColorGPUVector( + ownerName: string, + vector: GPUVector, + expectedLength?: number +): void { + if (vector.format === 'vertex-list') { + assertArrowLayerGPUVector(ownerName, vector, ['vertex-list'], expectedLength); + return; + } + if (!canConvertColors(vector)) { + throw new Error( + `${ownerName} GPUVector.format "${vector.format ?? 'undefined'}" must be a convertible fixed-width RGB/RGBA format or vertex-list` + ); + } + assertArrowLayerGPUVector(ownerName, vector, [vector.format!], expectedLength); +} + +/** Returns normalized RGBA8 colors, preserving caller ownership when conversion is unnecessary. */ +export async function convertArrowLayerColorGPUVector( + device: Device, + vector: GPUVector, + name: string +): Promise<{ + vector: GPUVector<'unorm8x4' | 'vertex-list'>; + converted: boolean; +}> { + if (vector.format === 'unorm8x4' || vector.format === 'vertex-list') { + return { + vector: vector as GPUVector<'unorm8x4' | 'vertex-list'>, + converted: false + }; + } + return {vector: await convertColors(device, vector, {name}), converted: true}; +} + +/** Normalizes one supported fixed-width Arrow RGB/RGBA vector to Uint8 RGBA rows. */ +export async function convertArrowLayerColorVector( + device: Device, + vector: Vector, + name: string +): Promise { + if (!canConvertColors(vector)) { + throw new Error(`Arrow color vector type ${vector.type} is not convertible to Uint8 RGBA`); + } + const converted = await convertArrowColors(device, vector as Vector, {name}); + try { + return await readArrowGPUVectorAsync(converted); + } finally { + converted.destroy(); + } +} + function hasNullRows(nullBitmap: Uint8Array, length: number): boolean { for (let rowIndex = 0; rowIndex < length; rowIndex++) { if ((nullBitmap[rowIndex >> 3] & (1 << (rowIndex & 7))) === 0) { diff --git a/modules/arrow-layers/src/layers/arrow-path-layer.ts b/modules/arrow-layers/src/layers/arrow-path-layer.ts index 83f65f0da4..e03e2ecd23 100644 --- a/modules/arrow-layers/src/layers/arrow-path-layer.ts +++ b/modules/arrow-layers/src/layers/arrow-path-layer.ts @@ -17,6 +17,7 @@ import { getArrowRecordBatchAsyncIterator, readArrowGPUVectorAsync, resolveArrowPathSourceVectors, + type ArrowColorType, type ArrowPathSourceVectors, type ArrowPathSourceVectorSelectors, type ArrowRecordBatchSource, @@ -35,7 +36,7 @@ import { GPUVector, PathAttributeModel, PathStorageModel, - type VertexList + type GPUVectorFormat } from '@luma.gl/tables'; import { DataType, @@ -57,7 +58,10 @@ import { type ArrowLayerPickingInfo } from './arrow-layer-types'; import { + assertArrowLayerColorGPUVector, assertArrowLayerGPUVector, + convertArrowLayerColorGPUVector, + convertArrowLayerColorVector, getArrowLayerInputNullValue, getArrowLayerInputSource, hasArrowLayerColumn, @@ -69,11 +73,10 @@ import { } from './arrow-layer-input'; type ArrowPathColor = [number, number, number, number]; -type ArrowPathRowColorType = FixedSizeList; -type ArrowPathVertexColorType = List; +type ArrowPathRowColorType = ArrowColorType; +type ArrowPathVertexColorType = List>; type ArrowPathColorSource = ArrowLayerColumnSource< - ArrowPathRowColorType | ArrowPathVertexColorType, - 'unorm8x4' | VertexList<'unorm8x4'> + ArrowPathRowColorType | ArrowPathVertexColorType >; type ArrowPathWidthSource = ArrowLayerColumnSource; @@ -424,6 +427,7 @@ type ArrowPathLayerBatch = { rowIndexOffset: number; rowCount: number; temporalBuffer: Buffer | null; + convertedColorVector: GPUVector | null; }; /** Deck-facing props for an Arrow-backed path layer. */ @@ -640,10 +644,7 @@ export class ArrowPathLayer extends Layer { widthNullValue ); if (isArrowLayerGPUVector(colorSource)) { - assertArrowLayerGPUVector('ArrowPathLayer color', colorSource, [ - 'unorm8x4', - 'vertex-list' - ]); + assertArrowLayerColorGPUVector('ArrowPathLayer color', colorSource); } if (isArrowLayerGPUVector(widthSource)) { assertArrowLayerGPUVector('ArrowPathLayer width', widthSource, ['float32']); @@ -656,18 +657,28 @@ export class ArrowPathLayer extends Layer { model === 'storage' && isArrowLayerGPUVector(widthSource) ? undefined : await this.resolvePathStyleSource(widthSource, rowIndexOffset, recordBatch?.numRows); - const sourceVectors = fillNullablePathStyleVectors( + let sourceVectors = fillNullablePathStyleVectors( resolveArrowPathSourceVectors(model === 'storage' ? PathStorageModel : PathAttributeModel, { data: recordBatch, selectors: { paths: props.paths, - colors: colorSelector ?? null, + colors: (colorSelector ?? null) as ArrowPathSourceVectorSelectors['colors'], widths: widthSelector ?? null } }), colorNullValue, widthNullValue ); + if (sourceVectors.colors && !DataType.isList(sourceVectors.colors.type)) { + sourceVectors = { + ...sourceVectors, + colors: (await convertArrowLayerColorVector( + this.context.device, + sourceVectors.colors, + `${props.id}-batch-${batchIndex}-arrow-colors` + )) as NonNullable + }; + } const prepared = await ArrowPathRenderer.convertToGPUVectors( this.context.device, sourceVectors, @@ -682,10 +693,32 @@ export class ArrowPathLayer extends Layer { return; } - const preparedColorColumn = - model === 'storage' && isArrowLayerGPUVector(colorSource) - ? getPathStorageGPUVectorBatch(colorSource, batchIndex, sourceVectors.paths.length) - : prepared.colors; + let convertedColorVector: GPUVector | null = null; + let preparedColorColumn = prepared.colors; + if (model === 'storage' && isArrowLayerGPUVector(colorSource)) { + try { + const sourceColorBatch = getPathStorageGPUVectorBatch( + colorSource, + batchIndex, + sourceVectors.paths.length + ); + const normalized = await convertArrowLayerColorGPUVector( + this.context.device, + sourceColorBatch, + `${props.id}-batch-${batchIndex}-colors` + ); + preparedColorColumn = normalized.vector; + convertedColorVector = normalized.converted ? normalized.vector : null; + } catch (error) { + prepared.destroy(); + throw error; + } + if (!this.isActiveLoad(loadVersion)) { + convertedColorVector?.destroy(); + prepared.destroy(); + return; + } + } const preparedWidthColumn = model === 'storage' && isArrowLayerGPUVector(widthSource) ? getPathStorageGPUVectorBatch(widthSource, batchIndex, sourceVectors.paths.length) @@ -748,12 +781,14 @@ export class ArrowPathLayer extends Layer { } } catch (error) { destroyBuffers(constantStyle.buffers); + convertedColorVector?.destroy(); prepared.destroy(); throw error; } if (!this.isActiveLoad(loadVersion)) { renderModel.destroy(); destroyBuffers(constantStyle.buffers); + convertedColorVector?.destroy(); prepared.destroy(); return; } @@ -766,7 +801,8 @@ export class ArrowPathLayer extends Layer { batchIndex, rowIndexOffset, rowCount, - temporalBuffer: constantStyle.temporalBuffer + temporalBuffer: constantStyle.temporalBuffer, + convertedColorVector }; const batches = [...this.getLayerState().batches, batch]; this.setState({batches, loadVersion, sourceInitialized: true}); @@ -807,7 +843,20 @@ export class ArrowPathLayer extends Layer { const cache = this.getLayerState().gpuVectorSourceCache; let arrowVectorPromise = cache.get(source); if (!arrowVectorPromise) { - arrowVectorPromise = readArrowGPUVectorAsync(source); + arrowVectorPromise = (async () => { + const normalized = await convertArrowLayerColorGPUVector( + this.context.device, + source, + `${this.id}-colors` + ); + try { + return await readArrowGPUVectorAsync(normalized.vector); + } finally { + if (normalized.converted) { + normalized.vector.destroy(); + } + } + })(); cache.set(source, arrowVectorPromise); } const arrowVector = await arrowVectorPromise; @@ -905,9 +954,11 @@ function hasArrowDataNulls(data: Data): boolean { return data.nullCount > 0 || data.children.some(hasArrowDataNulls); } -function getPathStorageGPUVectorBatch< - Format extends 'unorm8x4' | VertexList<'unorm8x4'> | 'float32' ->(vector: GPUVector, batchIndex: number, expectedRowCount: number): GPUVector { +function getPathStorageGPUVectorBatch( + vector: GPUVector, + batchIndex: number, + expectedRowCount: number +): GPUVector { const useWholeVector = vector.data.length === 1 && batchIndex === 0 && vector.length === expectedRowCount; const data = useWholeVector ? vector.data[0] : vector.data[batchIndex]; @@ -1085,6 +1136,7 @@ function destroyPathBatches(batches: ArrowPathLayerBatch[]): void { for (const batch of batches) { batch.model.destroy(); destroyBuffers(batch.constantBuffers); + batch.convertedColorVector?.destroy(); batch.prepared.destroy(); } } diff --git a/modules/arrow-layers/src/layers/arrow-polygon-layer.ts b/modules/arrow-layers/src/layers/arrow-polygon-layer.ts index 6e2d8f5332..a9bd5c6cd6 100644 --- a/modules/arrow-layers/src/layers/arrow-polygon-layer.ts +++ b/modules/arrow-layers/src/layers/arrow-polygon-layer.ts @@ -15,6 +15,7 @@ import { import { ArrowPolygonRenderer, readArrowGPUVectorAsync, + type ArrowColorType, type ArrowPolygonRendererDataBatchUpdate, type ArrowPolygonRendererProps } from '@luma.gl/arrow'; @@ -31,12 +32,13 @@ import type {ArrowLayerPickingInfo} from './arrow-layer-types'; import { GPUVector, POLYGON_STORAGE_SHADER_LAYOUT, - type GPURecordBatchSourceInfo, - type VertexList + type GPURecordBatchSourceInfo } from '@luma.gl/tables'; -import type {Vector} from 'apache-arrow'; +import {Vector} from 'apache-arrow'; import { - assertArrowLayerGPUVector, + assertArrowLayerColorGPUVector, + convertArrowLayerColorGPUVector, + convertArrowLayerColorVector, getArrowLayerInputNullValue, getArrowLayerInputSource, inspectArrowLayerColumn, @@ -48,7 +50,8 @@ import { type ArrowPolygonColor = [number, number, number, number]; type ArrowPolygonColorSource = | Exclude - | GPUVector<'unorm8x4' | VertexList<'unorm8x4'>>; + | Vector + | GPUVector; type ArrowPolygonResolvedColorSource = { value: Exclude | null; }; @@ -299,13 +302,12 @@ export class ArrowPolygonLayer extends Layer { ...this.getRendererModelProps(props), data: props.data, polygons: props.polygons, - colors: - resolvedColorSource?.value ?? + colors: (resolvedColorSource?.value ?? (resolvedColorSource ? null : isArrowLayerGPUVector(colorSource) ? null - : (colorSource ?? null)), + : (colorSource ?? null))) as ArrowPolygonRendererProps['colors'], tessellated: props.tessellated, color: getArrowLayerInputNullValue(props.color, DEFAULT_POLYGON_COLOR, isArrowLayerColor), center: props.center, @@ -330,6 +332,29 @@ export class ArrowPolygonLayer extends Layer { const state = this.getLayerState(); const colorResolveVersion = state.colorResolveVersion + 1; state.colorResolveVersion = colorResolveVersion; + if (colorSource instanceof Vector) { + renderer.setProps(this.getRendererProps(props, {value: null})); + void convertArrowLayerColorVector(this.context.device, colorSource, `${this.id}-colors`) + .then(colorVector => { + if ( + this.getRendererOrNull() === renderer && + this.getLayerState().colorResolveVersion === colorResolveVersion + ) { + renderer.setProps( + this.getRendererProps(props, {value: colorVector as Vector as never}) + ); + this.watchRendererPipeline(renderer); + this.setNeedsRedraw(); + } + }) + .catch(error => { + if (this.getLayerState().colorResolveVersion === colorResolveVersion) { + if (props.onDataError) props.onDataError(error); + else this.raiseError(toError(error), `resolving Arrow polygon color in ${this}`); + } + }); + return; + } if (!isArrowLayerGPUVector(colorSource)) { if (typeof colorSource === 'string') { if (!props.data) { @@ -380,14 +405,22 @@ export class ArrowPolygonLayer extends Layer { } const polygonRowCount = props.polygons && typeof props.polygons !== 'string' ? props.polygons.length : undefined; - assertArrowLayerGPUVector( - 'ArrowPolygonLayer color', - colorSource, - ['unorm8x4', 'vertex-list'], - polygonRowCount - ); + assertArrowLayerColorGPUVector('ArrowPolygonLayer color', colorSource, polygonRowCount); renderer.setProps({data: null}); - void readArrowGPUVectorAsync(colorSource) + void (async () => { + const normalized = await convertArrowLayerColorGPUVector( + this.context.device, + colorSource, + `${this.id}-colors` + ); + try { + return await readArrowGPUVectorAsync(normalized.vector); + } finally { + if (normalized.converted) { + normalized.vector.destroy(); + } + } + })() .then(colorVector => { if ( this.getRendererOrNull() === renderer && diff --git a/modules/arrow-layers/src/layers/arrow-text-layer.ts b/modules/arrow-layers/src/layers/arrow-text-layer.ts index 506526410e..37e0ca1393 100644 --- a/modules/arrow-layers/src/layers/arrow-text-layer.ts +++ b/modules/arrow-layers/src/layers/arrow-text-layer.ts @@ -14,8 +14,8 @@ import { } from '@deck.gl/core'; import { ArrowTextRenderer, - prepareArrowTextInputFromData, readArrowGPUVectorAsync, + type ArrowColorType, type ArrowTextRendererDataBatchUpdate, type ArrowTextRendererProps } from '@luma.gl/arrow'; @@ -30,10 +30,11 @@ 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'; -import type {Vector} from 'apache-arrow'; +import {GPUVector, type GPURecordBatchSourceInfo} from '@luma.gl/tables'; +import {Vector} from 'apache-arrow'; import { deckArrowViewport, DECK_ARROW_ALPHA_BLEND_PARAMETERS, @@ -47,7 +48,9 @@ import { DECK_TEXT_STORAGE_WGSL } from './arrow-text-storage-shaders'; import { - assertArrowLayerGPUVector, + assertArrowLayerColorGPUVector, + convertArrowLayerColorGPUVector, + convertArrowLayerColorVector, getArrowLayerInputNullValue, getArrowLayerInputSource, inspectArrowLayerColumn, @@ -59,7 +62,8 @@ import { type ArrowTextColor = [number, number, number, number]; type ArrowTextColorSource = | Exclude - | GPUVector<'unorm8x4' | VertexList<'unorm8x4'>>; + | Vector + | GPUVector; /** Constant, Arrow column, or GPU column accepted by ArrowTextLayer.color. */ export type ArrowTextColorInput = ArrowLayerInput; @@ -507,18 +511,17 @@ export class ArrowTextLayer extends Layer { colorSource = undefined; } } + if (colorSource instanceof Vector) { + colorSource = (await convertArrowLayerColorVector( + this.context.device, + colorSource, + `${this.id}-colors` + )) as Vector; + } if (isArrowLayerGPUVector(colorSource)) { this.assertTextColorGPUVector(colorSource, effectiveProps); } - const directGPUColor = - model === 'storage' && - isArrowLayerGPUVector(colorSource) && - colorSource.format === 'unorm8x4' - ? colorSource - : undefined; - const resolvedColorSource = directGPUColor - ? undefined - : await this.resolveTextColorSource(colorSource, effectiveProps); + const resolvedColorSource = await this.resolveTextColorSource(colorSource, effectiveProps); const colorNullValue = getArrowLayerInputNullValue( props.color, DEFAULT_TEXT_COLOR, @@ -535,7 +538,7 @@ export class ArrowTextLayer extends Layer { : createEmptyDeckTextConstantStyle(); const rendererProps: ArrowTextRendererProps = { ...effectiveProps, - colors: resolvedColorSource ?? null, + colors: (resolvedColorSource ?? null) as ArrowTextRendererProps['colors'], color: colorNullValue, model, attributeShaderLayout: DECK_TEXT_SHADER_LAYOUT, @@ -562,19 +565,7 @@ export class ArrowTextLayer extends Layer { }, onDataBatch: update => this.handleDataBatch(update, props.onDataBatch) }; - const renderer = directGPUColor - ? ArrowTextRenderer.createFromPreparedInput( - this.context.device, - {...rendererProps, colors: null}, - { - ...(await prepareArrowTextInputFromData(this.context.device, { - ...rendererProps, - colors: null - })), - colors: directGPUColor - } - ) - : await ArrowTextRenderer.create(this.context.device, rendererProps); + const renderer = await ArrowTextRenderer.create(this.context.device, rendererProps); let ownedTextData = [ ...renderer.transferTextDataOwnership(({data, removed}) => { for (const previousData of removed) { @@ -637,7 +628,20 @@ export class ArrowTextLayer extends Layer { const cache = this.getLayerState().gpuVectorSourceCache; let arrowVectorPromise = cache.get(source); if (!arrowVectorPromise) { - arrowVectorPromise = readArrowGPUVectorAsync(source); + arrowVectorPromise = (async () => { + const normalized = await convertArrowLayerColorGPUVector( + this.context.device, + source, + `${this.id}-colors` + ); + try { + return await readArrowGPUVectorAsync(normalized.vector); + } finally { + if (normalized.converted) { + normalized.vector.destroy(); + } + } + })(); cache.set(source, arrowVectorPromise); } return (await arrowVectorPromise) as Exclude; @@ -662,12 +666,7 @@ export class ArrowTextLayer extends Layer { } const textRowCount = props.positions && typeof props.positions !== 'string' ? props.positions.length : undefined; - assertArrowLayerGPUVector( - 'ArrowTextLayer color', - source, - ['unorm8x4', 'vertex-list'], - textRowCount - ); + assertArrowLayerColorGPUVector('ArrowTextLayer color', source, textRowCount); } private handleDataBatch( @@ -726,13 +725,14 @@ function resolveDeckTextModel( if (model === 'storage' && 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') { throw new Error(`ArrowTextLayer does not support the ${model} model`); } - return model; + return model === 'storage' && !storageSupported ? 'attribute' : model; } function toError(error: unknown): Error { diff --git a/modules/arrow-layers/test/layers/arrow-layers.spec.ts b/modules/arrow-layers/test/layers/arrow-layers.spec.ts index cf0fc78d7b..b465c7c011 100644 --- a/modules/arrow-layers/test/layers/arrow-layers.spec.ts +++ b/modules/arrow-layers/test/layers/arrow-layers.spec.ts @@ -4,13 +4,13 @@ import {Deck, OrthographicView, type Layer, type PickingInfo} from '@deck.gl/core'; import {ArrowPathLayer, ArrowPolygonLayer, ArrowTextLayer} from '@deck.gl-community/arrow-layers'; -import {makeGPUVectorFromArrow} from '@luma.gl/arrow'; +import {makeArrowFixedSizeListVector, makeGPUVectorFromArrow} from '@luma.gl/arrow'; import test from '@luma.gl/devtools-extensions/tape-test-utils'; import type {Device} from '@luma.gl/core'; import type {Model} from '@luma.gl/engine'; import {buildBitmapFontAtlas} from '@luma.gl/text'; import {getWebGPUTestDevice} from '@luma.gl/test-utils'; -import {Table, vectorFromArray, type RecordBatch} from 'apache-arrow'; +import {Float32, Table, Vector, vectorFromArray, type RecordBatch} from 'apache-arrow'; import {afterAll} from 'vitest'; import { makeArrowLineRecordBatches, @@ -336,10 +336,14 @@ test('ArrowPathLayer storage draws streamed batches incrementally and preserves ); const recordBatches = makeArrowLineRecordBatches(source); t.equal(recordBatches.length, 2, 'test source contains two batches'); - const callerColorVector = makeGPUVectorFromArrow(device, source.sourceVectors.colors!, { - name: 'caller-path-colors', - format: 'unorm8x4' - }); + const callerColorVector = makeGPUVectorFromArrow( + device, + makeFloat32ColorVector(source.sourceVectors.colors!), + { + name: 'caller-path-colors', + format: 'float32x4' + } + ); const callerColorBuffers = callerColorVector.data.map(data => data.buffer); let releaseSecondBatch = () => {}; const secondBatchReady = new Promise(resolve => { @@ -410,10 +414,14 @@ test('Arrow polygon and text layers render storage-backed WebGPU models', async } const polygonSource = makeArrowPolygonExampleData('10k-stream', 'polygon', 'row-colors'); const polygonBatch = polygonSource.recordBatches[0]!; - const polygonColorVector = makeGPUVectorFromArrow(device, polygonBatch.getChild('colors')!, { - name: 'caller-polygon-colors', - format: 'unorm8x4' - }); + const polygonColorVector = makeGPUVectorFromArrow( + device, + makeFloat32ColorVector(polygonBatch.getChild('colors')!), + { + name: 'caller-polygon-colors', + format: 'float32x4' + } + ); let polygonDataError: unknown; const polygonLayer = new ArrowPolygonLayer({ id: 'arrow-polygons-storage-test', @@ -443,10 +451,14 @@ test('Arrow polygon and text layers render storage-backed WebGPU models', async 'string-colors', {clipRects: true, angles: true, sizes: true} ); - const textColorVector = makeGPUVectorFromArrow(device, textSource.colors!.slice(190, 210), { - name: 'caller-text-colors', - format: 'unorm8x4' - }); + const textColorVector = makeGPUVectorFromArrow( + device, + makeFloat32ColorVector(textSource.colors!.slice(190, 210)), + { + name: 'caller-text-colors', + format: 'float32x4' + } + ); let textDataError: unknown; const textLayer = new ArrowTextLayer({ id: 'arrow-text-storage-test', @@ -459,6 +471,7 @@ test('Arrow polygon and text layers render storage-backed WebGPU models', async sizes: textSource.sizes?.slice(190, 210), pixelOffsets: null, model: 'storage', + fontAtlas: TEXT_FONT_ATLAS, characterSet: ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/-', onDataError: error => { textDataError = error; @@ -483,6 +496,7 @@ test('Arrow polygon and text layers render storage-backed WebGPU models', async angle: 4, size: 36, model: 'storage', + fontAtlas: TEXT_FONT_ATLAS, characterSet: ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/-', onDataError: error => { dictionaryTextDataError = error; @@ -518,10 +532,13 @@ test('Arrow polygon and text layers render storage-backed WebGPU models', async for (const {layer, initialViewState, getError} of cases) { deck.setProps({layers: [layer], viewState: initialViewState}); try { - const model = await waitForLayerModel(layer, getError); + const model = await waitForDrawableLayerModel(layer, getError); await waitForPipeline(model); t.equal(model.device.type, 'webgpu', `${layer.id} uses WebGPU storage`); - t.ok(model.instanceCount > 0, `${layer.id} has drawable storage-backed instances`); + t.ok( + model.instanceCount > 0 || model.vertexCount > 0, + `${layer.id} has drawable storage-backed geometry` + ); } finally { deck.setProps({layers: []}); } @@ -576,6 +593,22 @@ async function pickFirstLayerObject( } } +function makeFloat32ColorVector(colors: Vector) { + let sourceRowOffset = 0; + const data = colors.data.map(sourceData => { + const values = new Float32Array(sourceData.length * 4); + for (let rowIndex = 0; rowIndex < sourceData.length; rowIndex++) { + const color = Array.from(colors.get(sourceRowOffset + rowIndex) as Iterable); + for (let componentIndex = 0; componentIndex < 4; componentIndex++) { + values[rowIndex * 4 + componentIndex] = color[componentIndex] / 255; + } + } + sourceRowOffset += sourceData.length; + return makeArrowFixedSizeListVector(new Float32(), 4, values).data[0]; + }); + return new Vector(data); +} + function createTestDeck(device?: Device): {deck: Deck; parent: HTMLDivElement} { const parent = document.createElement('div'); parent.style.width = `${TEST_VIEWPORT_WIDTH}px`; @@ -629,6 +662,33 @@ async function waitForLayerModel( throw new Error(`${layer.id} did not create a draw model`); } +async function waitForDrawableLayerModel( + layer: Layer, + getError: () => unknown = () => undefined +): Promise { + const timeout = Date.now() + TEST_MODEL_TIMEOUT_MILLISECONDS; + while (Date.now() < timeout) { + const model = layer.getModels()[0]; + if (model && (model.instanceCount > 0 || model.vertexCount > 0)) { + return model; + } + const error = getError(); + if (error) { + throw error; + } + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + const state = layer.state as { + renderer?: {getMetrics?: () => {rowCount?: number}}; + } | null; + const rowCount = state?.renderer?.getMetrics?.().rowCount; + const instanceCount = layer.getModels()[0]?.instanceCount; + const vertexCount = layer.getModels()[0]?.vertexCount; + throw new Error( + `${layer.id} did not create a drawable model (rows=${rowCount ?? 'unknown'}, instances=${instanceCount ?? 'missing'}, vertices=${vertexCount ?? 'missing'})` + ); +} + async function waitForModelCount( layer: Layer, modelCount: number, diff --git a/modules/arrow/package.json b/modules/arrow/package.json index 2b84b827dc..8434cf5fea 100644 --- a/modules/arrow/package.json +++ b/modules/arrow/package.json @@ -43,6 +43,7 @@ "dependencies": { "@luma.gl/core": "~9.3.0", "@luma.gl/engine": "~9.3.0", + "@luma.gl/gpgpu": "~9.3.0", "@luma.gl/shadertools": "~9.3.0", "@luma.gl/tables": "~9.3.0", "@luma.gl/text": "~9.3.0", diff --git a/modules/arrow/src/arrow/arrow-colors.ts b/modules/arrow/src/arrow/arrow-colors.ts new file mode 100644 index 0000000000..869a5437c2 --- /dev/null +++ b/modules/arrow/src/arrow/arrow-colors.ts @@ -0,0 +1,173 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Device, VertexFormat} from '@luma.gl/core'; +import { + convertColorData, + GPUDataEvaluator, + GPUVectorEvaluator, + type ColorInputFormat +} from '@luma.gl/gpgpu'; +import {GPUData, GPUVector, type GPUVectorBufferProps} from '@luma.gl/tables'; +import {DataType, Field, FixedSizeList, Float16, Float32, Uint8, Vector} from 'apache-arrow'; +import {makeGPUVectorFromArrow} from './gpu/arrow-gpu-table-adapters'; + +export type ArrowColorType = FixedSizeList | FixedSizeList | FixedSizeList; +type ArrowUint8ColorType = FixedSizeList; + +type ConvertColorsProps = { + name?: string; + inputFormat?: ColorInputFormat; +}; + +type ConvertArrowColorsProps = { + name?: string; + bufferProps?: GPUVectorBufferProps; +}; + +const ARROW_UINT8_COLOR_TYPE = new FixedSizeList( + 4, + new Field('value', new Uint8(), false) +) as ArrowUint8ColorType; + +/** Converts fixed-width GPU RGB/RGBA chunks to materialized normalized Uint8 RGBA chunks. */ +export async function convertColors( + device: Device, + colors: GPUVector, + props: ConvertColorsProps = {} +): Promise> { + const source = GPUVectorEvaluator.fromGPUVector(colors); + const converted = source.mapGPUData(data => + convertColorData(data, {inputFormat: props.inputFormat}) + ); + const evaluated = await converted.evaluate(device, { + name: props.name ?? 'colors', + format: 'unorm8x4' + }); + const data = evaluated.data.map( + chunk => + new GPUData<'unorm8x4'>({ + buffer: chunk.buffer, + dataType: ARROW_UINT8_COLOR_TYPE, + format: 'unorm8x4', + length: chunk.length, + stride: 4, + byteOffset: chunk.byteOffset, + byteStride: 4, + rowByteLength: 4, + ownsBuffer: true + }) + ); + + return new GPUVector({ + type: 'data', + name: evaluated.name, + dataType: ARROW_UINT8_COLOR_TYPE, + format: 'unorm8x4', + data, + ownsData: true + }); +} + +/** Returns true when an Arrow vector or fixed-width GPUVector can be converted to Uint8 RGBA. */ +export function canConvertColors(colors: GPUVector | Vector): boolean { + try { + if (colors instanceof GPUVector) { + for (const data of colors.data) { + const evaluator = GPUDataEvaluator.fromGPUData(data); + try { + getColorInputFormat(evaluator); + } finally { + evaluator.destroy(); + } + } + } else { + validateArrowColorType(colors.type, 'canConvertColors'); + } + return true; + } catch { + return false; + } +} + +/** Uploads Arrow RGB/RGBA chunks unchanged, converts each chunk, and returns Uint8 RGBA rows. */ +export async function convertArrowColors( + device: Device, + colors: Vector, + props: ConvertArrowColorsProps = {} +): Promise> { + validateArrowColorType(colors.type, 'convertArrowColors'); + const inputFormat = getArrowColorInputFormat(colors.type); + const source = makeGPUVectorFromArrow(device, colors, { + ...props.bufferProps, + name: props.name ? `${props.name}-source` : 'colors-source', + format: getArrowColorGPUFormat(inputFormat), + preserveDataChunks: true + }); + + try { + return await convertColors(device, source, { + name: props.name ?? 'colors', + inputFormat + }); + } finally { + source.destroy(); + } +} + +function getColorInputFormat(colors: GPUDataEvaluator): ColorInputFormat { + const format = `${colors.type}x${colors.size}`; + switch (format) { + case 'uint8x3': + case 'uint8x4': + case 'float16x3': + case 'float16x4': + case 'float32x3': + case 'float32x4': + return format; + default: + throw new Error(`convertColors unsupported input format ${format}`); + } +} + +function getArrowColorInputFormat(type: ArrowColorType): ColorInputFormat { + const childType = type.children[0].type; + const scalarType = + childType instanceof Uint8 + ? 'uint8' + : childType instanceof Float16 + ? 'float16' + : childType instanceof Float32 + ? 'float32' + : null; + if (!scalarType || (type.listSize !== 3 && type.listSize !== 4)) { + throw new Error(`convertArrowColors unsupported input type ${type}`); + } + return `${scalarType}x${type.listSize}`; +} + +function validateArrowColorType( + type: DataType, + functionName: string +): asserts type is ArrowColorType { + const childType = DataType.isFixedSizeList(type) ? type.children[0].type : null; + const supportedChildType = + childType instanceof Uint8 || childType instanceof Float16 || childType instanceof Float32; + if (!DataType.isFixedSizeList(type) || !supportedChildType || ![3, 4].includes(type.listSize)) { + throw new Error(`${functionName}: Arrow data type ${type} is not supported`); + } +} + +function getArrowColorGPUFormat(inputFormat: ColorInputFormat): VertexFormat { + switch (inputFormat) { + case 'uint8x3': + return 'uint8x3-webgl'; + case 'float16x3': + // VertexFormat has no float16x3 entry. This adapter uses the byte-identical Uint16x3 + // storage layout while inputFormat retains the Float16 interpretation for conversion. + return 'uint16x3-webgl'; + default: + return inputFormat; + } +} diff --git a/modules/arrow/src/index.ts b/modules/arrow/src/index.ts index 45ef8dfe85..b0cf597016 100644 --- a/modules/arrow/src/index.ts +++ b/modules/arrow/src/index.ts @@ -37,6 +37,12 @@ export { type PreparedArrowMatrixGPUVector, type ConvertArrowMatrixToGPUVectorOptions } from './arrow/vectors/arrow-matrix-gpu-vector'; +export { + canConvertColors, + convertColors, + convertArrowColors, + type ArrowColorType +} from './arrow/arrow-colors'; export { getArrowMatrixVectorInfo, makeArrowMatrixVector, diff --git a/modules/arrow/test/arrow/arrow-colors.node.spec.ts b/modules/arrow/test/arrow/arrow-colors.node.spec.ts new file mode 100644 index 0000000000..eaf2c06d73 --- /dev/null +++ b/modules/arrow/test/arrow/arrow-colors.node.spec.ts @@ -0,0 +1,144 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import test from '@luma.gl/devtools-extensions/tape-test-utils'; +import { + canConvertColors, + convertArrowColors, + getArrowFixedSizeListValues, + makeArrowFixedSizeListVector, + readArrowGPUVectorAsync +} from '@luma.gl/arrow'; +import {backendRegistry} from '@luma.gl/gpgpu'; +import * as cpuBackend from '@luma.gl/gpgpu/operations/cpu'; +import {NullDevice} from '@luma.gl/test-utils'; +import * as arrow from 'apache-arrow'; + +backendRegistry.add('null', cpuBackend); + +test('convertArrowColors uploads Uint8 RGB/RGBA rows and returns a Uint8 RGBA GPUVector', async t => { + const device = new NullDevice({}); + const rgb = makeArrowFixedSizeListVector( + new arrow.Uint8(), + 3, + new Uint8Array([255, 128, 0, 1, 2, 3]) + ); + const rgba = makeArrowFixedSizeListVector( + new arrow.Uint8(), + 4, + new Uint8Array([10, 20, 30, 40, 50, 60, 70, 80]) + ); + + const rgbResult = await convertArrowColors(device, rgb, {name: 'rgb-colors'}); + const rgbaResult = await convertArrowColors(device, rgba, {name: 'rgba-colors'}); + + t.equal(rgbResult.name, 'rgb-colors', 'sets requested vector name'); + t.equal(rgbResult.stride, 4, 'returns RGBA stride'); + t.equal(rgbResult.byteStride, 4, 'returns tightly packed byte stride'); + t.equal(rgbResult.rowByteLength, 4, 'returns tightly packed row byte length'); + const rgbResultType = rgbResult.dataType as arrow.FixedSizeList; + t.ok(arrow.DataType.isFixedSizeList(rgbResultType), 'returns FixedSizeList type'); + t.equal(rgbResultType.listSize, 4, 'returns four-channel rows'); + t.ok(rgbResultType.children[0].type instanceof arrow.Uint8, 'returns Uint8 child values'); + t.deepEqual( + getArrowFixedSizeListValues(await readArrowGPUVectorAsync(rgbResult)), + new Uint8Array([255, 128, 0, 255, 1, 2, 3, 255]), + 'expands Uint8 RGB alpha to 255' + ); + t.deepEqual( + getArrowFixedSizeListValues(await readArrowGPUVectorAsync(rgbaResult)), + new Uint8Array([10, 20, 30, 40, 50, 60, 70, 80]), + 'preserves Uint8 RGBA values' + ); + + rgbResult.destroy(); + rgbaResult.destroy(); + device.destroy(); + t.end(); +}); + +test('convertArrowColors clips Float32 rows and returns a Uint8 RGBA GPUVector', async t => { + const device = new NullDevice({}); + const colors = makeArrowFixedSizeListVector( + new arrow.Float32(), + 4, + new Float32Array([-1, 0, 0.5, 1, 0.25, 0.75, 1.5, 0.1]) + ); + + const result = await convertArrowColors(device, colors); + + t.deepEqual( + getArrowFixedSizeListValues(await readArrowGPUVectorAsync(result)), + new Uint8Array([0, 0, 128, 255, 64, 191, 255, 26]), + 'clips to [0, 1], scales by 255, and rounds' + ); + + result.destroy(); + device.destroy(); + t.end(); +}); + +test('convertArrowColors decodes Float16 RGB rows and returns a Uint8 RGBA GPUVector', async t => { + const device = new NullDevice({}); + const colors = makeArrowFixedSizeListVector( + new arrow.Float16(), + 3, + new Uint16Array([ + 0xbc00, // -1 + 0x0000, // 0 + 0x3800, // 0.5 + 0x3c00, // 1 + 0x3e00, // 1.5 + 0x2e66 // ~0.1 + ]) + ); + + const result = await convertArrowColors(device, colors); + + t.deepEqual( + getArrowFixedSizeListValues(await readArrowGPUVectorAsync(result)), + new Uint8Array([0, 0, 128, 255, 255, 255, 25, 255]), + 'decodes Float16 values, clips, scales, and expands alpha' + ); + + result.destroy(); + device.destroy(); + t.end(); +}); + +test('convertArrowColors rejects unsupported color vectors', async t => { + const device = new NullDevice({}); + const createBuffer = device.createBuffer.bind(device); + let createBufferCallCount = 0; + device.createBuffer = (props => { + createBufferCallCount++; + return createBuffer(props); + }) as typeof device.createBuffer; + const badSize = makeArrowFixedSizeListVector(new arrow.Float32(), 2, new Float32Array([0, 1])); + const badType = makeArrowFixedSizeListVector(new arrow.Uint16(), 4, new Uint16Array(4)); + const goodType = makeArrowFixedSizeListVector(new arrow.Float32(), 3, new Float32Array(3)); + + t.ok(canConvertColors(goodType), 'accepts supported color vectors'); + t.notOk(canConvertColors(badSize), 'rejects unsupported color vector sizes'); + t.notOk(canConvertColors(badType), 'rejects unsupported color vector scalar types'); + await expectRejects(t, convertArrowColors(device, badSize), /not supported/); + await expectRejects(t, convertArrowColors(device, badType), /not supported/); + t.equal(createBufferCallCount, 0, 'rejects invalid vectors before uploading'); + + device.destroy(); + t.end(); +}); + +async function expectRejects( + t: {ok: (value: boolean, message?: string) => void}, + promise: Promise, + pattern: RegExp +): Promise { + try { + await promise; + t.ok(false, `Expected rejection matching ${pattern}`); + } catch (error) { + t.ok(error instanceof Error && pattern.test(error.message), `Rejects with ${pattern}`); + } +} diff --git a/modules/arrow/tsconfig.json b/modules/arrow/tsconfig.json index 2ead55c4a2..a82e9ee489 100644 --- a/modules/arrow/tsconfig.json +++ b/modules/arrow/tsconfig.json @@ -11,6 +11,7 @@ {"path": "../core"}, {"path": "../engine"}, {"path": "../geoarrow"}, + {"path": "../gpgpu"}, {"path": "../tables"}, {"path": "../text"} ] diff --git a/modules/gpgpu/src/index.ts b/modules/gpgpu/src/index.ts index 27d29691ef..706c415249 100644 --- a/modules/gpgpu/src/index.ts +++ b/modules/gpgpu/src/index.ts @@ -36,6 +36,12 @@ export { tan } from './operations/arithmetic'; export {extent} from './operations/extent'; +export {convertColorData} from './operations/convert-colors'; +export type { + ColorInputFormat, + ConvertColorsInputs, + ConvertColorsProps +} from './operations/convert-colors'; export {dot} from './operations/dot'; export {equalAll} from './operations/equal-all'; export {interleave} from './operations/interleave'; diff --git a/modules/gpgpu/src/operations/convert-colors.ts b/modules/gpgpu/src/operations/convert-colors.ts new file mode 100644 index 0000000000..93ba0ef7d2 --- /dev/null +++ b/modules/gpgpu/src/operations/convert-colors.ts @@ -0,0 +1,76 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import { + getGPUDataEvaluator, + GPUDataEvaluator, + type GPUDataEvaluatorInput +} from '../operation/gpu-data-evaluator'; +import {Operation} from '../operation/operation'; + +export type ColorInputFormat = + | 'uint8x3' + | 'uint8x4' + | 'float16x3' + | 'float16x4' + | 'float32x3' + | 'float32x4'; + +export type ConvertColorsProps = { + inputFormat?: ColorInputFormat; +}; + +export type ConvertColorsInputs = { + source: GPUDataEvaluator; + inputFormat: ColorInputFormat; +}; + +class ConvertColorsOperation extends Operation { + name = 'convertColors'; + + output: GPUDataEvaluator; + + constructor(source: GPUDataEvaluator, inputFormat: ColorInputFormat) { + super({source, inputFormat}); + + this.output = new GPUDataEvaluator({ + id: `convertColors(${source})`, + type: 'uint8', + size: 4, + normalized: true, + format: 'unorm8x4', + length: source.length, + source: this + }); + } + + toString(): string { + return `convertColors(${this.inputs.source})`; + } +} + +/** Converts one fixed-width GPU data range to normalized Uint8 RGBA values. */ +export function convertColorData( + source: GPUDataEvaluatorInput, + props: ConvertColorsProps = {} +): GPUDataEvaluator { + const sourceEvaluator = getGPUDataEvaluator(source); + const inputFormat = props.inputFormat ?? getColorInputFormat(sourceEvaluator); + return new ConvertColorsOperation(sourceEvaluator, inputFormat).output; +} + +function getColorInputFormat(source: GPUDataEvaluator): ColorInputFormat { + const format = `${source.type}x${source.size}`; + switch (format) { + case 'uint8x3': + case 'uint8x4': + case 'float16x3': + case 'float16x4': + case 'float32x3': + case 'float32x4': + return format; + default: + throw new Error(`convertColors unsupported input format ${format}`); + } +} diff --git a/modules/gpgpu/src/operations/cpu/convert-colors.ts b/modules/gpgpu/src/operations/cpu/convert-colors.ts new file mode 100644 index 0000000000..f70e8c6433 --- /dev/null +++ b/modules/gpgpu/src/operations/cpu/convert-colors.ts @@ -0,0 +1,56 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {fromHalfFloat} from '@luma.gl/shadertools'; +import type {TypedArray} from '@math.gl/types'; +import type {OperationHandler} from '../../operation/operation'; +import type {ConvertColorsInputs} from '../convert-colors'; + +export const convertColors: OperationHandler = async ({ + inputs, + output, + target +}) => { + const {source, inputFormat} = inputs; + const sourceValue = await source.ensureCPUValue(); + const result = new output.ValueType(output.length * output.size); + const inputSize = inputFormat.endsWith('x3') ? 3 : 4; + + for (let rowIndex = 0; rowIndex < output.length; rowIndex++) { + const inputOffset = + source.offset / source.ValueType.BYTES_PER_ELEMENT + + rowIndex * (source.stride / source.ValueType.BYTES_PER_ELEMENT); + const outputOffset = rowIndex * 4; + + result[outputOffset] = convertColorChannel(sourceValue, inputOffset, inputFormat); + result[outputOffset + 1] = convertColorChannel(sourceValue, inputOffset + 1, inputFormat); + result[outputOffset + 2] = convertColorChannel(sourceValue, inputOffset + 2, inputFormat); + result[outputOffset + 3] = + inputSize === 4 ? convertColorChannel(sourceValue, inputOffset + 3, inputFormat) : 255; + } + + target.write(result); + return {success: true, value: result}; +}; + +function convertColorChannel(values: TypedArray, index: number, inputFormat: string): number { + if (inputFormat.startsWith('uint8')) { + return values[index]; + } + + const value = inputFormat.startsWith('float16') ? getFloat16Value(values, index) : values[index]; + return Math.round(Math.min(Math.max(value, 0), 1) * 255); +} + +function getFloat16Value(values: TypedArray, index: number): number { + const Float16ArrayConstructor = getNativeFloat16ArrayConstructor(); + if (Float16ArrayConstructor && values.constructor === Float16ArrayConstructor) { + return values[index]; + } + return fromHalfFloat(values[index]); +} + +function getNativeFloat16ArrayConstructor(): unknown { + return (globalThis as typeof globalThis & {Float16Array?: unknown}).Float16Array; +} diff --git a/modules/gpgpu/src/operations/cpu/index.ts b/modules/gpgpu/src/operations/cpu/index.ts index a4a53b77e5..cb6c0dba9a 100644 --- a/modules/gpgpu/src/operations/cpu/index.ts +++ b/modules/gpgpu/src/operations/cpu/index.ts @@ -1,4 +1,5 @@ import {arithmetic} from './arithmetic'; +import {convertColors} from './convert-colors'; import {extent} from './extent'; import {fround} from './fround'; import {gather} from './gather'; @@ -14,6 +15,7 @@ import {swizzle} from './swizzle'; /** CPU fallback backend for built-in GPGPU operations. Registered by default. */ export { arithmetic, + convertColors, dot, equalAll, extent, diff --git a/modules/gpgpu/src/operations/webgl/convert-colors.ts b/modules/gpgpu/src/operations/webgl/convert-colors.ts new file mode 100644 index 0000000000..05af83db09 --- /dev/null +++ b/modules/gpgpu/src/operations/webgl/convert-colors.ts @@ -0,0 +1,7 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// WebGL has no compute shader path for byte-addressed color conversion. Reuse the CPU handler, +// which reads the source buffer when necessary and writes the converted bytes to the WebGL buffer. +export {convertColors} from '../cpu/convert-colors'; diff --git a/modules/gpgpu/src/operations/webgl/index.ts b/modules/gpgpu/src/operations/webgl/index.ts index 5e04ff3555..863c00ac4f 100644 --- a/modules/gpgpu/src/operations/webgl/index.ts +++ b/modules/gpgpu/src/operations/webgl/index.ts @@ -1,4 +1,5 @@ import {arithmetic} from './arithmetic'; +import {convertColors} from './convert-colors'; import {extent} from './extent'; import {interleave} from './interleave'; import {fround} from './fround'; @@ -14,6 +15,7 @@ import {swizzle} from './swizzle'; /** WebGL backend for built-in GPGPU operations, implemented with transform feedback. */ export { arithmetic, + convertColors, dot, equalAll, extent, diff --git a/modules/gpgpu/src/operations/webgpu/convert-colors.ts b/modules/gpgpu/src/operations/webgpu/convert-colors.ts new file mode 100644 index 0000000000..3af717c69a --- /dev/null +++ b/modules/gpgpu/src/operations/webgpu/convert-colors.ts @@ -0,0 +1,144 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Computation} from '@luma.gl/engine'; +import type {OperationHandler} from '../../operation/operation'; +import type {ConvertColorsInputs} from '../convert-colors'; + +const WORKGROUP_SIZE = 64; +const GPGPU_OPERATION_STATS = 'GPGPU Operation Counts'; +const COMPUTATION_RUNS = 'Computation Runs'; + +export const convertColors: OperationHandler = async ({ + inputs, + output, + target +}) => { + const {source, inputFormat} = inputs; + const computation = new Computation(target.device, { + source: getConvertColorsWGSL(inputFormat, source.offset, source.stride, output.length), + shaderLayout: { + bindings: [ + {name: 'source', type: 'storage' as const, group: 0, location: 0}, + {name: 'result', type: 'storage' as const, group: 0, location: 1} + ] + } + }); + + computation.setBindings({ + source: source.buffer, + result: target + }); + + const computePass = target.device.beginComputePass({}); + target.device.statsManager.getStats(GPGPU_OPERATION_STATS).get(COMPUTATION_RUNS).incrementCount(); + computation.dispatch(computePass, Math.ceil(output.length / WORKGROUP_SIZE)); + computePass.end(); + target.device.submit(); + computation.destroy(); + return {success: true}; +}; + +function getConvertColorsWGSL( + inputFormat: ConvertColorsInputs['inputFormat'], + byteOffset: number, + byteStride: number, + length: number +): string { + return /* wgsl */ `\ +@group(0) @binding(0) var source: array; +@group(0) @binding(1) var result: array; + +// Treat the storage binding as raw 32-bit words so one shader can address packed Uint8, Float16, +// and Float32 rows with arbitrary supported strides. The float readers decode IEEE bit patterns; +// they do not numerically convert integer values to floats. + +fn readByte(byteIndex: u32) -> u32 { + let word = source[byteIndex / 4u]; + let shift = (byteIndex % 4u) * 8u; + return (word >> shift) & 0xffu; +} + +fn readUint8(byteIndex: u32) -> f32 { + return f32(readByte(byteIndex)) / 255.0; +} + +fn readFloat16Bits(byteIndex: u32) -> f32 { + let word = source[byteIndex / 4u]; + let values = unpack2x16float(word); + return select(values.x, values.y, byteIndex % 4u == 2u); +} + +fn readFloat32Bits(byteIndex: u32) -> f32 { + return bitcast(source[byteIndex / 4u]); +} + +fn readColor(rowIndex: u32) -> vec4 { + let rowByteOffset = ${byteOffset}u + rowIndex * ${byteStride}u; +${getReadColorWGSL(inputFormat)} +} + +@compute @workgroup_size(${WORKGROUP_SIZE}) fn main( + @builtin(global_invocation_id) id: vec3 +) { + let rowIndex = id.x; + if (rowIndex >= ${length}u) { + return; + } + + result[rowIndex] = pack4x8unorm(readColor(rowIndex)); +} +`; +} + +function getReadColorWGSL(inputFormat: ConvertColorsInputs['inputFormat']): string { + switch (inputFormat) { + case 'uint8x3': + return ` return vec4( + readUint8(rowByteOffset), + readUint8(rowByteOffset + 1u), + readUint8(rowByteOffset + 2u), + 1.0 + );`; + case 'uint8x4': + return ` return vec4( + readUint8(rowByteOffset), + readUint8(rowByteOffset + 1u), + readUint8(rowByteOffset + 2u), + readUint8(rowByteOffset + 3u) + );`; + case 'float16x3': + return ` return vec4( + readFloat16Bits(rowByteOffset), + readFloat16Bits(rowByteOffset + 2u), + readFloat16Bits(rowByteOffset + 4u), + 1.0 + );`; + case 'float16x4': + return ` return vec4( + readFloat16Bits(rowByteOffset), + readFloat16Bits(rowByteOffset + 2u), + readFloat16Bits(rowByteOffset + 4u), + readFloat16Bits(rowByteOffset + 6u) + );`; + case 'float32x3': + return ` return vec4( + readFloat32Bits(rowByteOffset), + readFloat32Bits(rowByteOffset + 4u), + readFloat32Bits(rowByteOffset + 8u), + 1.0 + );`; + case 'float32x4': + return ` return vec4( + readFloat32Bits(rowByteOffset), + readFloat32Bits(rowByteOffset + 4u), + readFloat32Bits(rowByteOffset + 8u), + readFloat32Bits(rowByteOffset + 12u) + );`; + default: { + const unreachable: never = inputFormat; + throw new Error(`Unsupported color input format ${unreachable}`); + } + } +} diff --git a/modules/gpgpu/src/operations/webgpu/index.ts b/modules/gpgpu/src/operations/webgpu/index.ts index 3747c5b027..bdfcd1d1b4 100644 --- a/modules/gpgpu/src/operations/webgpu/index.ts +++ b/modules/gpgpu/src/operations/webgpu/index.ts @@ -1,4 +1,5 @@ import {arithmetic} from './arithmetic'; +import {convertColors} from './convert-colors'; import {dot} from './dot'; import {equalAll} from './equal-all'; import {extent} from './extent'; @@ -14,6 +15,7 @@ import {swizzle} from './swizzle'; /** WebGPU backend handlers. */ export { arithmetic, + convertColors, dot, equalAll, extent, diff --git a/modules/gpgpu/test/operations/convert-colors.node.spec.ts b/modules/gpgpu/test/operations/convert-colors.node.spec.ts new file mode 100644 index 0000000000..8f8950a491 --- /dev/null +++ b/modules/gpgpu/test/operations/convert-colors.node.spec.ts @@ -0,0 +1,157 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Buffer, type Device, type VertexFormat} from '@luma.gl/core'; +import {convertColorData, type ColorInputFormat} from '@luma.gl/gpgpu'; +import {GPUData, GPUDataView} from '@luma.gl/tables'; +import type {TypedArray} from '@math.gl/types'; +import {beforeEach, describe, expect, test} from 'vitest'; +import {getTestDevice} from './fixtures'; + +type ColorSource = { + input: GPUData | GPUDataView; + destroy: () => void; +}; + +type ConvertColorsTestCase = { + name: string; + source: (device: Device) => ColorSource; + inputFormat?: ColorInputFormat; + expected: number[]; +}; + +for (const deviceType of ['cpu', 'webgpu'] as const) { + describe(`GPGPU#convertColorData#execute:${deviceType}`, () => { + let device: Device | null; + + beforeEach(async () => { + device = await getTestDevice(deviceType); + }); + + const TEST_CASES: ConvertColorsTestCase[] = [ + { + name: 'GPUData uint8x3 expands alpha', + source: device => + makeGPUDataSource(device, new Uint8Array([255, 128, 0, 1, 2, 3]), 'uint8x3-webgl'), + expected: [255, 128, 0, 255, 1, 2, 3, 255] + }, + { + name: 'GPUData uint8x4 preserves alpha', + source: device => + makeGPUDataSource(device, new Uint8Array([10, 20, 30, 40, 50, 60, 70, 80]), 'uint8x4'), + expected: [10, 20, 30, 40, 50, 60, 70, 80] + }, + { + name: 'GPUDataView float32x3 clips and expands alpha', + source: device => + makeStridedGPUDataViewSource( + device, + new Float32Array([99, -1, 0, 0.5, 99, 0.25, 0.75, 1.5]), + 'float32x3', + 4, + 16 + ), + expected: [0, 0, 128, 255, 64, 191, 255, 255] + }, + { + name: 'GPUData float32x4 clips and preserves alpha', + source: device => + makeGPUDataSource( + device, + new Float32Array([-1, 0, 0.5, 1, 0.25, 0.75, 1.5, 0.1]), + 'float32x4' + ), + expected: [0, 0, 128, 255, 64, 191, 255, 26] + }, + { + name: 'GPUData float16x3 storage decodes, clips, and expands alpha', + source: device => + makeGPUDataSource( + device, + new Uint16Array([0xbc00, 0x0000, 0x3800, 0x3c00, 0x3e00, 0x2e66]), + 'uint16x3-webgl' + ), + inputFormat: 'float16x3', + expected: [0, 0, 128, 255, 255, 255, 25, 255] + }, + { + name: 'GPUData float16x4 decodes, clips, and preserves alpha', + source: device => + makeGPUDataSource( + device, + new Uint16Array([0xbc00, 0x0000, 0x3800, 0x3c00, 0x3400, 0x3a00, 0x3e00, 0x2e66]), + 'float16x4' + ), + expected: [0, 0, 128, 255, 64, 191, 255, 25] + } + ]; + + for (const testCase of TEST_CASES) { + test(testCase.name, async t => { + if (!device) { + t.skip(`${deviceType} not available`); + return; + } + + const source = testCase.source(device); + const result = convertColorData(source.input, {inputFormat: testCase.inputFormat}); + await result.evaluate(device); + + expect(Array.from(await result.readValue())).toEqual(testCase.expected); + expect(result.format).toBe('unorm8x4'); + expect(result.size).toBe(4); + expect(result.stride).toBe(4); + + result.destroy(); + source.destroy(); + }); + } + }); +} + +function makeGPUDataSource(device: Device, values: TypedArray, format: VertexFormat): ColorSource { + const buffer = device.createBuffer({ + usage: Buffer.VERTEX | Buffer.STORAGE | Buffer.COPY_DST | Buffer.COPY_SRC, + data: values + }); + const data = new GPUData({ + buffer, + format, + length: values.byteLength / getFormatByteLength(format), + ownsBuffer: true + }); + return {input: data, destroy: () => data.destroy()}; +} + +function makeStridedGPUDataViewSource( + device: Device, + values: TypedArray, + format: VertexFormat, + byteOffset: number, + byteStride: number +): ColorSource { + const buffer = device.createBuffer({ + usage: Buffer.VERTEX | Buffer.STORAGE | Buffer.COPY_DST | Buffer.COPY_SRC, + data: values + }); + const view = new GPUDataView({buffer, format, length: 2, byteOffset, byteStride}); + return {input: view, destroy: () => buffer.destroy()}; +} + +function getFormatByteLength(format: VertexFormat): number { + switch (format) { + case 'uint8x3-webgl': + return 3; + case 'uint8x4': + return 4; + case 'uint16x3-webgl': + return 6; + case 'float16x4': + return 8; + case 'float32x4': + return 16; + default: + throw new Error(`Unhandled test format ${format}`); + } +} diff --git a/yarn.lock b/yarn.lock index 02b6d8b318..e3777f945e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4629,6 +4629,7 @@ __metadata: dependencies: "@luma.gl/core": "npm:~9.3.0" "@luma.gl/engine": "npm:~9.3.0" + "@luma.gl/gpgpu": "npm:~9.3.0" "@luma.gl/shadertools": "npm:~9.3.0" "@luma.gl/tables": "npm:~9.3.0" "@luma.gl/text": "npm:~9.3.0"