test(engine): add ShaderPassRenderer test (#2437)

This commit is contained in:
Ib Green 2025-08-24 14:41:13 -04:00 committed by GitHub
parent 8314ecefac
commit f256de326f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 971 additions and 468 deletions

View File

@ -13,7 +13,7 @@
import {webgpuAdapter} from '@luma.gl/webgpu';
import {makeAnimationLoop} from '@luma.gl/engine';
import AnimationLoopTemplate from './app.ts';
const animationLoop = makeAnimationLoop(AnimationLoopTemplate, {adapters: [webgpuAdapter, webgl2Adapter]});
const animationLoop = makeAnimationLoop(AnimationLoopTemplate, {adapters: [webgl2Adapter]});
animationLoop.start();
</script>
<body>

View File

@ -201,6 +201,8 @@ export {getTextureImageView, setTextureImageData} from './shadertypes/textures/t
// export {TexturePacker} from './shadertypes/textures/texture-packer'
export {type PixelData, readPixel, writePixel} from './shadertypes/textures/pixel-utils';
export {isExternalImage, getExternalImageSize} from './image-utils/image-types';
// GENERAL EXPORTS - FOR APPLICATIONS
export type {StatsManager} from './utils/stats-manager'; // TODO - should this be moved to probe.gl?

View File

@ -12,12 +12,15 @@ import {Device, Resource, Buffer, Framebuffer, Texture} from '@luma.gl/core';
* @note the two resources can be destroyed by calling `destroy()`
*/
export class Swap<T extends Resource<any>> {
id: string;
/** The current resource - usually the source for renders or computations */
current: T;
/** The next resource - usually the target/destination for transforms / computations */
next: T;
constructor(props: {current: T; next: T}) {
constructor(props: {current: T; next: T; id?: string}) {
this.id = props.id || 'swap';
this.current = props.current;
this.next = props.next;
}
@ -47,6 +50,7 @@ export class SwapFramebuffers extends Swap<Framebuffer> {
typeof colorAttachment !== 'string'
? colorAttachment
: device.createTexture({
id: `${props.id}-texture-0`,
format: colorAttachment,
usage: Texture.SAMPLE | Texture.RENDER | Texture.COPY_SRC | Texture.COPY_DST,
width,
@ -60,9 +64,9 @@ export class SwapFramebuffers extends Swap<Framebuffer> {
typeof colorAttachment !== 'string'
? colorAttachment
: device.createTexture({
id: `${props.id}-texture-1`,
format: colorAttachment,
usage:
Texture.TEXTURE | Texture.COPY_SRC | Texture.COPY_DST | Texture.RENDER_ATTACHMENT,
usage: Texture.SAMPLE | Texture.RENDER | Texture.COPY_SRC | Texture.COPY_DST,
width,
height
})

View File

@ -1,105 +1,47 @@
// luma.gl, MIT license
// Copyright (c) vis.gl contributors
import type {
TextureProps,
SamplerProps,
TextureView,
Device,
TypedArray,
TextureFormat,
ExternalImage
} from '@luma.gl/core';
import type {TextureProps, SamplerProps, TextureView, Device} from '@luma.gl/core';
import {Texture, Sampler, log} from '@luma.gl/core';
import {loadImageBitmap} from '../application-utils/load-file';
// import {loadImageBitmap} from '../application-utils/load-file';
import {uid} from '../utils/uid';
import {
// cube constants
type TextureCubeFace,
TEXTURE_CUBE_FACE_MAP,
type DynamicTextureDataProps =
| DynamicTexture1DProps
| DynamicTexture2DProps
| DynamicTexture3DProps
| DynamicTextureArrayProps
| DynamicTextureCubeProps
| DynamicTextureCubeArrayProps;
// texture slice/mip data types
type TextureSubresource,
type DynamicTexture1DProps = {dimension: '1d'; data: Promise<Texture1DData> | Texture1DData | null};
type DynamicTexture2DProps = {
dimension?: '2d';
data: Promise<Texture2DData> | Texture2DData | null;
};
type DynamicTexture3DProps = {dimension: '3d'; data: Promise<Texture3DData> | Texture3DData | null};
type DynamicTextureArrayProps = {
dimension: '2d-array';
data: Promise<TextureArrayData> | TextureArrayData | null;
};
type DynamicTextureCubeProps = {
dimension: 'cube';
data: Promise<TextureCubeData> | TextureCubeData | null;
};
type DynamicTextureCubeArrayProps = {
dimension: 'cube-array';
data: Promise<TextureCubeArrayData> | TextureCubeArrayData | null;
};
// props (dimension + data)
type TextureDataProps,
type TextureDataAsyncProps,
type DynamicTextureData = DynamicTextureProps['data'];
// combined data for different texture types
type Texture1DData,
type Texture2DData,
type Texture3DData,
type TextureArrayData,
type TextureCubeArrayData,
type TextureCubeData,
/** Names of cube texture faces */
export type TextureCubeFace = '+X' | '-X' | '+Y' | '-Y' | '+Z' | '-Z';
export const TextureCubeFaces: TextureCubeFace[] = ['+X', '-X', '+Y', '-Y', '+Z', '-Z'];
// prettier-ignore
export const TextureCubeFaceMap = {'+X': 0, '-X': 1, '+Y': 2, '-Y': 3, '+Z': 4, '-Z': 5};
// Helpers
getTextureSizeFromData,
getTexture1DSubresources,
getTexture2DSubresources,
getTexture3DSubresources,
getTextureCubeSubresources,
getTextureArraySubresources,
getTextureCubeArraySubresources
} from './texture-data';
/**
* One mip level
* Basic data structure is similar to `ImageData`
* additional optional fields can describe compressed texture data.
* Properties for a dynamic texture
*/
export type TextureImageData = {
/** WebGPU style format string. Defaults to 'rgba8unorm' */
format?: TextureFormat;
data: TypedArray;
width: number;
height: number;
compressed?: boolean;
byteLength?: number;
hasAlpha?: boolean;
};
export type TextureLevelSource = TextureImageData | ExternalImage;
/** Texture data can be one or more mip levels */
export type TextureData = TextureImageData | ExternalImage | (TextureImageData | ExternalImage)[];
/** @todo - define what data type is supported for 1D textures */
export type Texture1DData = TypedArray | TextureImageData;
/** Texture data can be one or more mip levels */
export type Texture2DData =
| TypedArray
| TextureImageData
| ExternalImage
| (TextureImageData | ExternalImage)[];
/** 6 face textures */
export type TextureCubeData = Record<TextureCubeFace, TextureData>;
/** Array of textures */
export type Texture3DData = TextureData[];
/** Array of textures */
export type TextureArrayData = TextureData[];
/** Array of 6 face textures */
export type TextureCubeArrayData = Record<TextureCubeFace, TextureData>[];
export const CubeFaces: TextureCubeFace[] = ['+X', '-X', '+Y', '-Y', '+Z', '-Z'];
/** Properties for an async texture */
export type DynamicTextureProps = Omit<TextureProps, 'data' | 'mipLevels' | 'width' | 'height'> &
DynamicTextureDataProps & {
TextureDataAsyncProps & {
/** Generate mipmaps after creating textures and setting data */
mipmaps?: boolean;
/** nipLevels can be set to 'auto' to generate max number of mipLevels */
@ -111,429 +53,349 @@ export type DynamicTextureProps = Omit<TextureProps, 'data' | 'mipLevels' | 'wid
};
/**
* It is very convenient to be able to initialize textures with promises
* This can add considerable complexity to the Texture class, and doesn't
* fit with the immutable nature of WebGPU resources.
* Instead, luma.gl offers async textures as a separate class.
* Dynamic Textures
*
* - Mipmaps - DynamicTexture can generate mipmaps for textures (WebGPU does not provide built-in mipmap generation).
*
* - Texture initialization and updates - complex textures (2d array textures, cube textures, 3d textures) need multiple images
* `DynamicTexture` provides an API that makes it easy to provide the required data.
*
* - Texture resizing - Textures are immutable in WebGPU, meaning that they cannot be resized after creation.
* DynamicTexture provides a `resize()` method that internally creates a new texture with the same parameters
* but a different size.
*
* - Async image data initialization - It is often very convenient to be able to initialize textures with promises
* returned by image or data loading functions, as it allows a callback-free linear style of programming.
*
* @note GPU Textures are quite complex objects, with many subresources and modes of usage.
* The `DynamicTexture` class allows luma.gl to provide some support for working with textures
* without accumulating excessive complexity in the core Texture class which is designed as an immutable nature of GPU resource.
*/
export class DynamicTexture {
readonly device: Device;
readonly id: string;
props: Required<Omit<DynamicTextureProps, 'data'>>;
// TODO - should we type these as possibly `null`? It will make usage harder?
// @ts-expect-error
texture: Texture;
// @ts-expect-error
sampler: Sampler;
// @ts-expect-error
view: TextureView;
/** Props with defaults resolved (except `data` which is processed separately) */
props: Readonly<Required<DynamicTextureProps>>;
/** Created resources */
private _texture: Texture | null = null;
private _sampler: Sampler | null = null;
private _view: TextureView | null = null;
/** Ready when GPU texture has been created and data (if any) uploaded */
readonly ready: Promise<Texture>;
isReady: boolean = false;
destroyed: boolean = false;
isReady = false;
destroyed = false;
protected resolveReady: () => void = () => {};
protected rejectReady: (error: Error) => void = () => {};
private resolveReady: (t: Texture) => void = () => {};
private rejectReady: (error: Error) => void = () => {};
get texture(): Texture {
if (!this._texture) throw new Error('Texture not initialized yet');
return this._texture;
}
get sampler(): Sampler {
if (!this._sampler) throw new Error('Sampler not initialized yet');
return this._sampler;
}
get view(): TextureView {
if (!this._view) throw new Error('View not initialized yet');
return this._view;
}
get [Symbol.toStringTag]() {
return 'DynamicTexture';
}
toString(): string {
return `DynamicTexture:"${this.id}"(${this.isReady ? 'ready' : 'loading'})`;
return `DynamicTexture:"${this.id}":${this.texture.width}x${this.texture.height}px:(${this.isReady ? 'ready' : 'loading...'})`;
}
constructor(device: Device, props: DynamicTextureProps) {
this.device = device;
// TODO - if we support URL strings as data...
const id = uid('dynamic-texture'); // typeof props?.data === 'string' ? props.data.slice(-20) : uid('dynamic-texture');
this.props = {...DynamicTexture.defaultProps, id, ...props};
const id = uid('dynamic-texture');
// NOTE: We avoid holding on to data to make sure it can be garbage collected.
const originalPropsWithAsyncData = props;
this.props = {...DynamicTexture.defaultProps, id, ...props, data: null};
this.id = this.props.id;
props = {...props};
// Signature: new DynamicTexture(device, {data: url})
if (typeof props?.data === 'string' && props.dimension === '2d') {
props.data = loadImageBitmap(props.data);
}
// If mipmaps are requested, we need to allocate space for them
if (props.mipmaps) {
props.mipLevels = 'auto';
}
this.ready = new Promise<Texture>((resolve, reject) => {
this.resolveReady = () => {
this.isReady = true;
resolve(this.texture);
};
this.resolveReady = resolve;
this.rejectReady = reject;
});
this.initAsync(props);
this.initAsync(originalPropsWithAsyncData);
}
async initAsync(props: DynamicTextureProps): Promise<void> {
const asyncData: DynamicTextureData = props.data;
// @ts-expect-error not clear how to convince TS that null will be returned
const data: TextureData | null = await awaitAllPromises(asyncData).then(
undefined,
this.rejectReady
);
/** @note Fire and forget; caller can await `ready` */
async initAsync(originalPropsWithAsyncData: TextureDataAsyncProps): Promise<void> {
try {
// TODO - Accept URL string for 2D: turn into ExternalImage promise
// const dataProps =
// typeof props.data === 'string' && (props.dimension ?? '2d') === '2d'
// ? ({dimension: '2d', data: loadImageBitmap(props.data)} as const)
// : {};
// Check that we haven't been destroyed while waiting for texture data to load
if (this.destroyed) {
return;
}
const propsWithSyncData = await this._loadAllData(originalPropsWithAsyncData);
this._checkNotDestroyed();
// Now we can actually create the texture
// Deduce size when not explicitly provided
// TODO - what about depth?
const deduceSize = (): {width: number; height: number} => {
if (this.props.width && this.props.height) {
return {width: this.props.width, height: this.props.height};
}
// Auto-deduce width and height if not supplied
const size =
this.props.width && this.props.height
? {width: this.props.width, height: this.props.height}
: this.getTextureDataSize(data);
if (!size) {
throw new Error('Texture size could not be determined');
}
const syncProps: TextureProps = {...size, ...props, data: undefined, mipLevels: 1};
const size = getTextureSizeFromData(propsWithSyncData);
if (size) {
return size;
}
// Auto-calculate the number of mip levels as a convenience
// TODO - Should we clamp to 1-getMipLevelCount?
const maxMips = this.device.getMipLevelCount(syncProps.width, syncProps.height);
syncProps.mipLevels = Math.max(
1,
this.props.mipLevels === 'auto' ? maxMips : Math.min(maxMips, this.props.mipLevels)
);
return {width: this.props.width || 1, height: this.props.height || 1};
};
this.texture = this.device.createTexture(syncProps);
this.sampler = this.texture.sampler;
this.view = this.texture.view;
if (props.data) {
switch (this.props.dimension) {
case '1d':
this._setTexture1DData(this.texture, data as Texture1DData);
break;
case '2d':
this._setTexture2DData(data as Texture2DData);
break;
case '3d':
this._setTexture3DData(this.texture, data as Texture3DData);
break;
case '2d-array':
this._setTextureArrayData(this.texture, data as TextureArrayData);
break;
case 'cube':
this._setTextureCubeData(this.texture, data as unknown as TextureCubeData);
break;
case 'cube-array':
this._setTextureCubeArrayData(this.texture, data as unknown as TextureCubeArrayData);
break;
const size = deduceSize();
if (!size || size.width <= 0 || size.height <= 0) {
throw new Error(`${this} size could not be determined or was zero`);
}
}
// Do we need to generate mipmaps?
if (this.props.mipmaps) {
this.generateMipmaps();
}
// Create a minimal TextureProps and validate via `satisfies`
const baseTextureProps = {
...this.props,
...size,
mipLevels: 1, // temporary; updated below
data: undefined
} satisfies TextureProps;
log.info(1, `${this} loaded`);
this.resolveReady();
// Compute mip levels (auto clamps to max)
const maxMips = this.device.getMipLevelCount(baseTextureProps.width, baseTextureProps.height);
const desired =
this.props.mipLevels === 'auto'
? maxMips
: Math.max(1, Math.min(maxMips, this.props.mipLevels ?? 1));
const finalTextureProps: TextureProps = {...baseTextureProps, mipLevels: desired};
this._texture = this.device.createTexture(finalTextureProps);
this._sampler = this.texture.sampler;
this._view = this.texture.view;
// Upload data if provided
if (propsWithSyncData.data) {
switch (propsWithSyncData.dimension) {
case '1d':
this.setTexture1DData(propsWithSyncData.data);
break;
case '2d':
this.setTexture2DData(propsWithSyncData.data);
break;
case '3d':
this.setTexture3DData(propsWithSyncData.data);
break;
case '2d-array':
this.setTextureArrayData(propsWithSyncData.data);
break;
case 'cube':
this.setTextureCubeData(propsWithSyncData.data);
break;
case 'cube-array':
this.setTextureCubeArrayData(propsWithSyncData.data);
break;
default: {
throw new Error(`Unhandled dimension ${propsWithSyncData.dimension}`);
}
}
}
if (this.props.mipmaps) {
this.generateMipmaps();
}
this.isReady = true;
this.resolveReady(this.texture);
log.info(0, `${this} created`)();
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
this.rejectReady(err);
throw err;
}
}
destroy(): void {
if (this.texture) {
this.texture.destroy();
// @ts-expect-error
this.texture = null;
if (this._texture) {
this._texture.destroy();
this._texture = null;
this._sampler = null;
this._view = null;
}
this.destroyed = true;
}
generateMipmaps(): void {
// if (this.device.type === 'webgl') {
this.texture.generateMipmapsWebGL();
// }
// Only supported via WebGL helper (luma.gl path)
if (this.device.type === 'webgl') {
this.texture.generateMipmapsWebGL();
} else {
throw new Error('Automatic mipmap generation not supported on this device');
}
}
/** Set sampler or create and set new Sampler from SamplerProps */
/** Set sampler or create one from props */
setSampler(sampler: Sampler | SamplerProps = {}): void {
this.texture.setSampler(
sampler instanceof Sampler ? sampler : this.device.createSampler(sampler)
);
this._checkReady();
const s = sampler instanceof Sampler ? sampler : this.device.createSampler(sampler);
this.texture.setSampler(s);
this._sampler = s;
}
/**
* Textures are immutable and cannot be resized after creation,
* but we can create a similar texture with the same parameters but a new size.
* @note Does not copy contents of the texture
* @note Mipmaps may need to be regenerated after resizing / setting new data
* @todo Abort pending promise and create a texture with the new size?
* Resize by cloning the underlying immutable texture.
* Does not copy contents; caller may need to re-upload and/or regenerate mips.
*/
resize(size: {width: number; height: number}): boolean {
if (!this.isReady) {
throw new Error('Cannot resize texture before it is ready');
}
this._checkReady();
if (size.width === this.texture.width && size.height === this.texture.height) {
return false;
}
const prev = this.texture;
this._texture = prev.clone(size);
this._sampler = this.texture.sampler;
this._view = this.texture.view;
if (this.texture) {
const texture = this.texture;
this.texture = texture.clone(size);
texture.destroy();
}
prev.destroy();
log.info(`${this} resized`);
return true;
}
/** Check if texture data is a typed array */
isTextureLevelData(data: TextureData): data is TextureImageData {
const typedArray = (data as TextureImageData)?.data;
return ArrayBuffer.isView(typedArray);
/** Convert cube face label to texture slice index. Index can be used with `setTexture2DData()`. */
getCubeFaceIndex(face: TextureCubeFace): number {
const index = TEXTURE_CUBE_FACE_MAP[face];
if (index === undefined) throw new Error(`Invalid cube face: ${face}`);
return index;
}
/** Get the size of the texture described by the provided TextureData */
getTextureDataSize(
data:
| TextureData
| TextureCubeData
| TextureArrayData
| TextureCubeArrayData
| TypedArray
| null
): {width: number; height: number} | null {
if (!data) {
return null;
}
if (ArrayBuffer.isView(data)) {
return null;
}
// Recurse into arrays (array of miplevels)
if (Array.isArray(data)) {
return this.getTextureDataSize(data[0]);
}
if (this.device.isExternalImage(data)) {
return this.device.getExternalImageSize(data);
}
if (data && typeof data === 'object' && data.constructor === Object) {
const textureDataArray = Object.values(data);
const untypedData = textureDataArray[0];
return {width: untypedData.width, height: untypedData.height};
}
throw new Error('texture size deduction failed');
/** Convert cube face label to texture slice index. Index can be used with `setTexture2DData()`. */
getCubeArrayFaceIndex(cubeIndex: number, face: TextureCubeFace): number {
return 6 * cubeIndex + this.getCubeFaceIndex(face);
}
/** Convert luma.gl cubemap face constants to depth index */
getCubeFaceDepth(face: TextureCubeFace): number {
// prettier-ignore
switch (face) {
case '+X': return 0;
case '-X': return 1;
case '+Y': return 2;
case '-Y': return 3;
case '+Z': return 4;
case '-Z': return 5;
default: throw new Error(face);
}
/** @note experimental: Set multiple mip levels (1D) */
setTexture1DData(data: Texture1DData): void {
this._checkReady();
if (this.texture.props.dimension !== '1d') {
throw new Error(`${this} is not 1d`);
}
const subresources = getTexture1DSubresources(data);
this._setTextureSubresources(subresources);
}
// EXPERIMENTAL
/** @note experimental: Set multiple mip levels (2D), optionally at `z`, slice (depth/array level) index */
setTexture2DData(lodData: Texture2DData, z: number = 0): void {
this._checkReady();
if (this.texture.props.dimension !== '2d') {
throw new Error(`${this} is not 2d`);
}
setTextureData(data: TextureData) {}
/** Experimental: Set multiple mip levels */
_setTexture1DData(texture: Texture, data: Texture1DData): void {
throw new Error('setTexture1DData not supported in WebGL.');
const subresources = getTexture2DSubresources(z, lodData);
this._setTextureSubresources(subresources);
}
/** Experimental: Set multiple mip levels */
_setTexture2DData(lodData: Texture2DData, depth = 0): void {
if (!this.texture) {
throw new Error('Texture not initialized');
/** 3D: multiple depth slices, each may carry multiple mip levels */
setTexture3DData(data: Texture3DData): void {
if (this.texture.props.dimension !== '3d') {
throw new Error(`${this} is not 3d`);
}
const subresources = getTexture3DSubresources(data);
this._setTextureSubresources(subresources);
}
const lodArray = this._normalizeTextureData(lodData);
// If the user provides multiple LODs, then automatic mipmap
// generation generateMipmap() should be disabled to avoid overwriting them.
if (lodArray.length > 1 && this.props.mipmaps !== false) {
log.warn(`Texture ${this.id} mipmap and multiple LODs.`)();
/** 2D array: multiple layers, each may carry multiple mip levels */
setTextureArrayData(data: TextureArrayData): void {
if (this.texture.props.dimension !== '2d-array') {
throw new Error(`${this} is not 2d-array`);
}
const subresources = getTextureArraySubresources(data);
this._setTextureSubresources(subresources);
}
for (let mipLevel = 0; mipLevel < lodArray.length; mipLevel++) {
const imageData = lodArray[mipLevel];
if (this.device.isExternalImage(imageData)) {
this.texture.copyExternalImage({image: imageData, z: depth, mipLevel, flipY: true});
} else {
this.texture.copyImageData({data: imageData.data, z: depth, mipLevel});
/** Cube: 6 faces, each may carry multiple mip levels */
setTextureCubeData(data: TextureCubeData): void {
if (this.texture.props.dimension !== 'cube') {
throw new Error(`${this} is not cube`);
}
const subresources = getTextureCubeSubresources(data);
this._setTextureSubresources(subresources);
}
/** Cube array: multiple cubes (faces×layers), each face may carry multiple mips */
private setTextureCubeArrayData(data: TextureCubeArrayData): void {
if (this.texture.props.dimension !== 'cube-array') {
throw new Error(`${this} is not cube-array`);
}
const subresources = getTextureCubeArraySubresources(data);
this._setTextureSubresources(subresources);
}
/** Sets multiple mip levels on different `z` slices (depth/array index) */
private _setTextureSubresources(subresources: TextureSubresource[]): void {
// If user supplied multiple mip levels, warn if auto-mips also requested
// if (lodArray.length > 1 && this.props.mipmaps !== false) {
// log.warn(
// `Texture ${this.id}: provided multiple LODs and also requested mipmap generation.`
// )();
// }
for (const subresource of subresources) {
const {z, mipLevel} = subresource;
switch (subresource.type) {
case 'external-image':
const {image, flipY} = subresource;
this.texture.copyExternalImage({image, z, mipLevel, flipY});
break;
case 'texture-data':
const {data} = subresource;
// TODO - we are throwing away some of the info in data.
// Did we not need it in the first place? Can we use it to validate?
this.texture.copyImageData({data: data.data, z, mipLevel});
break;
default:
throw new Error('Unsupported 2D mip-level payload');
}
}
}
/**
* Experimental: Sets 3D texture data: multiple depth slices, multiple mip levels
* @param data
*/
_setTexture3DData(texture: Texture, data: Texture3DData): void {
if (this.texture?.props.dimension !== '3d') {
throw new Error(this.id);
}
for (let depth = 0; depth < data.length; depth++) {
this._setTexture2DData(data[depth], depth);
// ------------------ helpers ------------------
/** Recursively resolve all promises in data structures */
private async _loadAllData(props: TextureDataAsyncProps): Promise<TextureDataProps> {
const syncData = await awaitAllPromises(props.data);
const dimension = (props.dimension ?? '2d') as TextureDataProps['dimension'];
return {dimension, data: syncData ?? null} as TextureDataProps;
}
private _checkNotDestroyed() {
if (this.destroyed) {
log.warn(`${this} already destroyed`);
}
}
/**
* Experimental: Set Cube texture data, multiple faces, multiple mip levels
* @todo - could support TextureCubeArray with depth
* @param data
* @param index
*/
_setTextureCubeData(texture: Texture, data: TextureCubeData): void {
if (this.texture?.props.dimension !== 'cube') {
throw new Error(this.id);
private _checkReady() {
if (!this.isReady) {
log.warn(`${this} Cannot perform this operation before ready`);
}
for (const [face, faceData] of Object.entries(data)) {
const faceDepth = CubeFaces.indexOf(face as TextureCubeFace);
this._setTexture2DData(faceData, faceDepth);
}
}
/**
* Experimental: Sets texture array data, multiple levels, multiple depth slices
* @param data
*/
_setTextureArrayData(texture: Texture, data: TextureArrayData): void {
if (this.texture?.props.dimension !== '2d-array') {
throw new Error(this.id);
}
for (let depth = 0; depth < data.length; depth++) {
this._setTexture2DData(data[depth], depth);
}
}
/**
* Experimental: Sets texture cube array, multiple faces, multiple levels, multiple mip levels
* @param data
*/
_setTextureCubeArrayData(texture: Texture, data: TextureCubeArrayData): void {
throw new Error('setTextureCubeArrayData not supported in WebGL2.');
}
/** Experimental */
_setTextureCubeFaceData(
texture: Texture,
lodData: Texture2DData,
face: TextureCubeFace,
depth: number = 0
): void {
// assert(this.props.dimension === 'cube');
// If the user provides multiple LODs, then automatic mipmap
// generation generateMipmap() should be disabled to avoid overwriting them.
if (Array.isArray(lodData) && lodData.length > 1 && this.props.mipmaps !== false) {
log.warn(`${this.id} has mipmap and multiple LODs.`)();
}
const faceDepth = TextureCubeFaces.indexOf(face);
this._setTexture2DData(lodData, faceDepth);
}
/**
* Normalize TextureData to an array of TextureImageData / ExternalImages
* @param data
* @param options
* @returns array of TextureImageData / ExternalImages
*/
_normalizeTextureData(data: Texture2DData): (TextureImageData | ExternalImage)[] {
const options: {width: number; height: number; depth: number} = this.texture;
let mipLevelArray: (TextureImageData | ExternalImage)[];
if (ArrayBuffer.isView(data)) {
mipLevelArray = [
{
// ts-expect-error does data really need to be Uint8ClampedArray?
data,
width: options.width,
height: options.height
// depth: options.depth
}
];
} else if (!Array.isArray(data)) {
mipLevelArray = [data];
} else {
mipLevelArray = data;
}
return mipLevelArray;
}
static defaultProps: Required<DynamicTextureProps> = {
...Texture.defaultProps,
dimension: '2d',
data: null,
mipmaps: false
};
}
// TODO - Remove when texture refactor is complete
/*
setCubeMapData(options: {
width: number;
height: number;
data: Record<GL, Texture2DData> | Record<TextureCubeFace, Texture2DData>;
format?: any;
type?: any;
/** @deprecated Use .data *
pixels: any;
}): void {
const {gl} = this;
const {width, height, pixels, data, format = GL.RGBA, type = GL.UNSIGNED_BYTE} = options;
// pixel data (imageDataMap) is an Object from Face to Image or Promise.
// For example:
// {
// GL.TEXTURE_CUBE_MAP_POSITIVE_X : Image-or-Promise,
// GL.TEXTURE_CUBE_MAP_NEGATIVE_X : Image-or-Promise,
// ... }
// To provide multiple level-of-details (LODs) this can be Face to Array
// of Image or Promise, like this
// {
// GL.TEXTURE_CUBE_MAP_POSITIVE_X : [Image-or-Promise-LOD-0, Image-or-Promise-LOD-1],
// GL.TEXTURE_CUBE_MAP_NEGATIVE_X : [Image-or-Promise-LOD-0, Image-or-Promise-LOD-1],
// ... }
const imageDataMap = this._getImageDataMap(pixels || data);
const resolvedFaces = WEBGLTexture.FACES.map(face => {
const facePixels = imageDataMap[face];
return Array.isArray(facePixels) ? facePixels : [facePixels];
});
this.bind();
WEBGLTexture.FACES.forEach((face, index) => {
if (resolvedFaces[index].length > 1 && this.props.mipmaps !== false) {
// If the user provides multiple LODs, then automatic mipmap
// generation generateMipmaps() should be disabled to avoid overwritting them.
log.warn(`${this.id} has mipmap and multiple LODs.`)();
}
resolvedFaces[index].forEach((image, lodLevel) => {
// TODO: adjust width & height for LOD!
if (width && height) {
gl.texImage2D(face, lodLevel, format, width, height, 0 /* border*, format, type, image);
} else {
gl.texImage2D(face, lodLevel, format, format, type, image);
}
});
});
this.unbind();
}
*/
// HELPERS
/** Resolve all promises in a nested data structure */
@ -554,3 +416,33 @@ async function awaitAllPromises(x: any): Promise<any> {
}
return x;
}
// /** @note experimental: Set multiple mip levels (2D), optionally at `z`, slice (depth/array level) index */
// setTexture2DData(lodData: Texture2DData, z: number = 0): void {
// this._checkReady();
// const lodArray = this._normalizeTexture2DData(lodData);
// // If user supplied multiple mip levels, warn if auto-mips also requested
// if (lodArray.length > 1 && this.props.mipmaps !== false) {
// log.warn(
// `Texture ${this.id}: provided multiple LODs and also requested mipmap generation.`
// )();
// }
// for (let mipLevel = 0; mipLevel < lodArray.length; mipLevel++) {
// const imageData = lodArray[mipLevel];
// if (this.device.isExternalImage(imageData)) {
// this.texture.copyExternalImage({image: imageData, z, mipLevel, flipY: true});
// } else if (this._isTextureImageData(imageData)) {
// this.texture.copyImageData({data: imageData.data, z, mipLevel});
// } else {
// throw new Error('Unsupported 2D mip-level payload');
// }
// }
// }
// /** Normalize 2D layer payload into an array of mip-level items */
// private _normalizeTexture2DData(data: Texture2DData): (TextureImageData | ExternalImage)[] {
// return Array.isArray(data) ? data : [data];
// }

View File

@ -0,0 +1,301 @@
import type {TypedArray, TextureFormat, ExternalImage} from '@luma.gl/core';
import {isExternalImage, getExternalImageSize} from '@luma.gl/core';
export type TextureImageSource = ExternalImage;
/**
* One mip level
* Basic data structure is similar to `ImageData`
* additional optional fields can describe compressed texture data.
*/
export type TextureImageData = {
/** WebGPU style format string. Defaults to 'rgba8unorm' */
format?: TextureFormat;
/** Typed Array with the bytes of the image. @note beware row byte alignment requirements */
data: TypedArray;
/** Width of the image, in pixels, @note beware row byte alignment requirements */
width: number;
/** Height of the image, in rows */
height: number;
};
/**
* A single mip-level can be initialized by data or an ImageBitmap etc
* @note in the WebGPU spec a mip-level is called a subresource
*/
export type TextureMipLevelData = TextureImageData | TextureImageSource;
/**
* Texture data for one image "slice" (which can consist of multiple miplevels)
* Thus data for one slice be a single mip level or an array of miplevels
* @note in the WebGPU spec each cross-section image in a 3D texture is called a "slice",
* in a array texture each image in the array is called an array "layer"
* luma.gl calls one image in a GPU texture a "slice" regardless of context.
*/
export type TextureSliceData = TextureMipLevelData | TextureMipLevelData[];
/** Names of cube texture faces */
export type TextureCubeFace = '+X' | '-X' | '+Y' | '-Y' | '+Z' | '-Z';
/** Array of cube texture faces. @note: index in array is the face index */
// prettier-ignore
export const TEXTURE_CUBE_FACES = ['+X', '-X', '+Y', '-Y', '+Z', '-Z'] as const satisfies readonly TextureCubeFace[];
/** Map of cube texture face names to face indexes */
// prettier-ignore
export const TEXTURE_CUBE_FACE_MAP = {'+X': 0, '-X': 1, '+Y': 2, '-Y': 3, '+Z': 4, '-Z': 5} as const satisfies Record<TextureCubeFace, number>;
/** @todo - Define what data type is supported for 1D textures. TextureImageData with height = 1 */
export type Texture1DData = TextureSliceData;
/** Texture data can be one or more mip levels */
export type Texture2DData = TextureSliceData;
/** 6 face textures */
export type TextureCubeData = Record<TextureCubeFace, TextureSliceData>;
/** Array of textures */
export type Texture3DData = TextureSliceData[];
/** Array of textures */
export type TextureArrayData = TextureSliceData[];
/** Array of 6 face textures */
export type TextureCubeArrayData = Record<TextureCubeFace, TextureSliceData>[];
type TextureData =
| Texture1DData
| Texture3DData
| TextureArrayData
| TextureCubeArrayData
| TextureCubeData;
/** Sync data props */
export type TextureDataProps =
| {dimension: '1d'; data: Texture1DData | null}
| {dimension?: '2d'; data: Texture2DData | null}
| {dimension: '3d'; data: Texture3DData | null}
| {dimension: '2d-array'; data: TextureArrayData | null}
| {dimension: 'cube'; data: TextureCubeData | null}
| {dimension: 'cube-array'; data: TextureCubeArrayData | null};
/** Async data props */
export type TextureDataAsyncProps =
| {dimension: '1d'; data?: Promise<Texture1DData> | Texture1DData | null}
| {dimension?: '2d'; data?: Promise<Texture2DData> | Texture2DData | null}
| {dimension: '3d'; data?: Promise<Texture3DData> | Texture3DData | null}
| {dimension: '2d-array'; data?: Promise<TextureArrayData> | TextureArrayData | null}
| {dimension: 'cube'; data?: Promise<TextureCubeData> | TextureCubeData | null}
| {dimension: 'cube-array'; data?: Promise<TextureCubeArrayData> | TextureCubeArrayData | null};
/** Describes data for one sub resource (one mip level of one slice (depth or array layer)) */
export type TextureSubresource = {
/** slice (depth or array layer)) */
z: number;
/** mip level (0 - max mip levels) */
mipLevel: number;
} & (
| {
type: 'external-image';
image: ExternalImage;
/** @deprecated is this an appropriate place for this flag? */
flipY?: boolean;
}
| {
type: 'texture-data';
data: TextureImageData;
}
);
/** Check if texture data is a typed array */
export function isTextureSliceData(data: TextureData): data is TextureImageData {
const typedArray = (data as TextureImageData)?.data;
return ArrayBuffer.isView(typedArray);
}
export function getFirstMipLevel(layer: TextureSliceData | null): TextureMipLevelData | null {
if (!layer) return null;
return Array.isArray(layer) ? (layer[0] ?? null) : layer;
}
export function getTextureSizeFromData(
props: TextureDataProps
): {width: number; height: number} | null {
const {dimension, data} = props;
if (!data) {
return null;
}
switch (dimension) {
case '1d': {
const mipLevel = getFirstMipLevel(data);
if (!mipLevel) return null;
const {width} = getTextureMipLevelSize(mipLevel);
return {width, height: 1};
}
case '2d': {
const mipLevel = getFirstMipLevel(data);
return mipLevel ? getTextureMipLevelSize(mipLevel) : null;
}
case '3d':
case '2d-array': {
if (!Array.isArray(data) || data.length === 0) return null;
const mipLevel = getFirstMipLevel(data[0]);
return mipLevel ? getTextureMipLevelSize(mipLevel) : null;
}
case 'cube': {
const face = (Object.keys(data)[0] as TextureCubeFace) ?? null;
if (!face) return null;
const faceData = (data as Record<TextureCubeFace, TextureSliceData>)[face];
const mipLevel = getFirstMipLevel(faceData);
return mipLevel ? getTextureMipLevelSize(mipLevel) : null;
}
case 'cube-array': {
if (!Array.isArray(data) || data.length === 0) return null;
const firstCube = data[0];
const face = (Object.keys(firstCube)[0] as TextureCubeFace) ?? null;
if (!face) return null;
const mipLevel = getFirstMipLevel(firstCube[face]);
return mipLevel ? getTextureMipLevelSize(mipLevel) : null;
}
default:
return null;
}
}
function getTextureMipLevelSize(data: TextureMipLevelData): {width: number; height: number} {
if (isExternalImage(data)) {
return getExternalImageSize(data);
}
if (typeof data === 'object' && 'width' in data && 'height' in data) {
return {width: data.width, height: data.height};
}
throw new Error('Unsupported mip-level data');
}
/** Type guard: is a mip-level `TextureImageData` (vs ExternalImage) */
function isTextureImageData(data: TextureMipLevelData): data is TextureImageData {
return (
typeof data === 'object' &&
data !== null &&
'data' in data &&
'width' in data &&
'height' in data
);
}
/** Resolve size for a single mip-level datum */
// function getTextureMipLevelSizeFromData(data: TextureMipLevelData): {
// width: number;
// height: number;
// } {
// if (this.device.isExternalImage(data)) {
// return this.device.getExternalImageSize(data);
// }
// if (this.isTextureImageData(data)) {
// return {width: data.width, height: data.height};
// }
// // Fallback (should not happen with current types)
// throw new Error('Unsupported mip-level data');
// }
/** Convert cube face label to depth index */
export function getCubeFaceIndex(face: TextureCubeFace): number {
const idx = TEXTURE_CUBE_FACE_MAP[face];
if (idx === undefined) throw new Error(`Invalid cube face: ${face}`);
return idx;
}
/** Convert cube face label to texture slice index. Index can be used with `setTexture2DData()`. */
export function getCubeArrayFaceIndex(cubeIndex: number, face: TextureCubeFace): number {
return 6 * cubeIndex + getCubeFaceIndex(face);
}
// ------------------ Upload helpers ------------------
/** Experimental: Set multiple mip levels (1D) */
export function getTexture1DSubresources(data: Texture1DData): TextureSubresource[] {
// Not supported in WebGL; left explicit
throw new Error('setTexture1DData not supported in WebGL.');
// const subresources: TextureSubresource[] = [];
// return subresources;
}
/** Normalize 2D layer payload into an array of mip-level items */
function _normalizeTexture2DData(data: Texture2DData): (TextureImageData | ExternalImage)[] {
return Array.isArray(data) ? data : [data];
}
/** Experimental: Set multiple mip levels (2D), optionally at `z` (depth/array index) */
export function getTexture2DSubresources(
slice: number,
lodData: Texture2DData
): TextureSubresource[] {
const lodArray = _normalizeTexture2DData(lodData);
const z = slice;
const subresources: TextureSubresource[] = [];
for (let mipLevel = 0; mipLevel < lodArray.length; mipLevel++) {
const imageData = lodArray[mipLevel];
if (isExternalImage(imageData)) {
subresources.push({
type: 'external-image',
image: imageData,
z,
mipLevel
});
} else if (isTextureImageData(imageData)) {
subresources.push({
type: 'texture-data',
data: imageData,
z,
mipLevel
});
} else {
throw new Error('Unsupported 2D mip-level payload');
}
}
return subresources;
}
/** 3D: multiple depth slices, each may carry multiple mip levels */
export function getTexture3DSubresources(data: Texture3DData): TextureSubresource[] {
const subresources: TextureSubresource[] = [];
for (let depth = 0; depth < data.length; depth++) {
subresources.push(...getTexture2DSubresources(depth, data[depth]));
}
return subresources;
}
/** 2D array: multiple layers, each may carry multiple mip levels */
export function getTextureArraySubresources(data: TextureArrayData): TextureSubresource[] {
const subresources: TextureSubresource[] = [];
for (let layer = 0; layer < data.length; layer++) {
subresources.push(...getTexture2DSubresources(layer, data[layer]));
}
return subresources;
}
/** Cube: 6 faces, each may carry multiple mip levels */
export function getTextureCubeSubresources(data: TextureCubeData): TextureSubresource[] {
const subresources: TextureSubresource[] = [];
for (const [face, faceData] of Object.entries(data) as [TextureCubeFace, TextureSliceData][]) {
const faceDepth = getCubeFaceIndex(face);
subresources.push(...getTexture2DSubresources(faceDepth, faceData));
}
return subresources;
}
/** Cube array: multiple cubes (faces×layers), each face may carry multiple mips */
export function getTextureCubeArraySubresources(data: TextureCubeArrayData): TextureSubresource[] {
const subresources: TextureSubresource[] = [];
data.forEach((cubeData, cubeIndex) => {
for (const [face, faceData] of Object.entries(cubeData)) {
const faceDepth = getCubeArrayFaceIndex(cubeIndex, face as TextureCubeFace);
getTexture2DSubresources(faceDepth, faceData);
}
});
return subresources;
}

View File

@ -83,14 +83,13 @@ export {Computation} from './compute/computation';
export type {
TextureCubeFace,
TextureImageData,
TextureData,
Texture1DData,
Texture2DData,
Texture3DData,
TextureCubeData,
TextureArrayData,
TextureCubeArrayData
} from './dynamic-texture/dynamic-texture';
} from './dynamic-texture/texture-data';
export type {DynamicTextureProps} from './dynamic-texture/dynamic-texture';
export {DynamicTexture} from './dynamic-texture/dynamic-texture';

View File

@ -39,19 +39,23 @@ function getFilterShaderWGSL(func: string) {
// Binding 0:1 is reserved for shader passes
// @group(0) @binding(0) var<uniform> brightnessContrast : brightnessContrastUniforms;
@group(0) @binding(1) var texture: texture_2d<f32>;
@group(0) @binding(2) var sampler: sampler;
@group(0) @binding(2) var textureSampler: sampler;
struct FragmentInputs {
@location(0) fragUV: vec2f,
@location(1) fragPosition: vec4f,
@location(2) fragCoordinate: vec4f
};
// This needs to be aligned with
// struct FragmentInputs {
// @location(0) fragUV: vec2f,
// @location(1) fragPosition: vec4f,
// @location(2) fragCoordinate: vec4f
// };
@fragment
fn fragmentMain(inputs: FragmentInputs) -> @location(0) vec4f {
let texSize = textureDimensions(texture, 0);
var fragColor = textureSample(texture, sampler, fragUV);
fragColor = ${func}(gl_FragColor, texSize, texCoord);
let fragUV = inputs.uv;
let fragCoordinate = inputs.coordinate;
let texSize = vec2f(textureDimensions(texture, 0));
var fragColor = textureSample(texture, textureSampler, fragUV);
fragColor = ${func}(fragColor, texSize, fragCoordinate);
return fragColor;
}
`;
@ -73,9 +77,9 @@ struct FragmentInputs = {
@fragment
fn fragmentMain(inputs: FragmentInputs) -> @location(0) vec4f {
let texSize = textureDimensions(texture, 0);
let texSize = vec2f(textureDimensions(texture, 0));
var fragColor = textureSample(texture, sampler, fragUV);
fragColor = ${func}(gl_FragColor, texSize, texCoord);
fragColor = ${func}(fragColor, texSize, texCoord);
return fragColor;
}
`;

View File

@ -157,6 +157,7 @@ void main() {
this.textureModel.draw(clearTexturePass);
clearTexturePass.end();
// Copy the texture contents
// const commandEncoder = this.device.createCommandEncoder();
// commandEncoder.copyTextureToTexture({
// sourceTexture: sourceTexture.texture,
@ -164,33 +165,33 @@ void main() {
// });
// commandEncoder.finish();
// let first = true;
// for (const passRenderer of this.passRenderers) {
// for (const subPassRenderer of passRenderer.subPassRenderers) {
// if (!first) {
// this.swapFramebuffers.swap();
// }
// first = false;
let first = true;
for (const passRenderer of this.passRenderers) {
for (const subPassRenderer of passRenderer.subPassRenderers) {
if (!first) {
this.swapFramebuffers.swap();
}
first = false;
// const swapBufferTexture = this.swapFramebuffers.current.colorAttachments[0].texture;
const swapBufferTexture = this.swapFramebuffers.current.colorAttachments[0].texture;
// const bindings = {
// sourceTexture: swapBufferTexture
// // texSize: [sourceTextures.width, sourceTextures.height]
// };
const bindings = {
sourceTexture: swapBufferTexture
// texSize: [sourceTextures.width, sourceTextures.height]
};
// const renderPass = this.device.beginRenderPass({
// id: 'shader-pass-renderer-run-pass',
// framebuffer: this.swapFramebuffers.next,
// clearColor: [0, 0, 0, 1],
// clearDepth: 1
// });
// subPassRenderer.render({renderPass, bindings});
// renderPass.end();
// }
// }
const renderPass = this.device.beginRenderPass({
id: 'shader-pass-renderer-run-pass',
framebuffer: this.swapFramebuffers.next,
clearColor: [0, 0, 0, 1],
clearDepth: 1
});
subPassRenderer.render({renderPass, bindings});
renderPass.end();
}
}
// this.swapFramebuffers.swap();
this.swapFramebuffers.swap();
const outputTexture = this.swapFramebuffers.current.colorAttachments[0].texture;
return outputTexture;
}

View File

@ -3,16 +3,14 @@
import test from 'tape-promise/tape';
import {getWebGLTestDevice} from '@luma.gl/test-utils';
import {AsyncTexture} from '../../src/index';
import {DynamicTexture} from '../../src/index';
// Verify that specifying mipLevels: 0 is clamped to at least 1
// See issue or commit reference for details.
test('AsyncTexture#mipLevels clamped to minimum 1', async t => {
test('DynamicTexture#mipLevels clamped to minimum 1', async t => {
const device = await getWebGLTestDevice();
const texture = new AsyncTexture(device, {
data: new Uint8Array(4),
width: 1,
height: 1,
const texture = new DynamicTexture(device, {
data: {data: new Uint8Array(4), width: 1, height: 1},
mipLevels: 0
});
await texture.ready;

View File

@ -0,0 +1,235 @@
import test from 'tape';
import {
isTextureSliceData,
getFirstMipLevel,
getTextureSizeFromData,
type TextureImageData,
type TextureDataProps,
type TextureCubeFace,
type TextureSliceData
} from '../../src/dynamic-texture/texture-data';
import type {} from '../../src/dynamic-texture/dynamic-texture';
import {isExternalImage, getExternalImageSize} from '@luma.gl/core';
test('isTextureSliceData: typed array image vs not', t => {
t.equal(
isTextureSliceData(mkImageData(4, 2)),
true,
'true for TextureImageData with typed array'
);
// Random non-image objects
t.equal(isTextureSliceData({} as any), false, 'false for random object');
t.equal(isTextureSliceData([] as any), false, 'false for array');
t.equal(isTextureSliceData(null as any), false, 'false for null');
// If environment provides an ExternalImage, ensure it returns false here
const image = maybeMakeExternalImage();
if (image) {
t.equal(isExternalImage(image), true, 'isExternalImage: external image is detected by luma');
t.equal(isTextureSliceData(image), true, 'isTextureSliceData: ExternalImage');
} else {
t.comment(
'ExternalImage test skipped (no native external image type available in this environment).'
);
}
t.end();
});
test('getFirstMipLevel: single, array, empty', t => {
const m0 = mkImageData(8, 8);
t.equal(getFirstMipLevel(m0), m0, 'returns item when single mip');
const m1 = mkImageData(4, 4);
t.equal(getFirstMipLevel([m0, m1]), m0, 'returns first when array');
t.equal(getFirstMipLevel(null), null, 'null input → null');
t.equal(getFirstMipLevel([]), null, 'empty array → null');
t.end();
});
test('getTextureSizeFromData: 1d', t => {
const props: TextureDataProps = {dimension: '1d', data: mkImageData(32, 1)};
t.deepEqual(getTextureSizeFromData(props), {width: 32, height: 1});
t.end();
});
test('getTextureSizeFromData: 2d (single & mips)', t => {
t.deepEqual(
getTextureSizeFromData({dimension: '2d', data: mkImageData(64, 32)}),
{width: 64, height: 32},
'2d single mip'
);
t.deepEqual(
getTextureSizeFromData({dimension: '2d', data: [mkImageData(64, 64), mkImageData(32, 32)]}),
{width: 64, height: 64},
'2d first mip from array'
);
t.end();
});
test('getTextureSizeFromData: 2d with ExternalImage (env-dependent)', t => {
const ext = maybeMakeExternalImage();
if (!ext) {
t.comment('Skipping 2d ExternalImage size test (no native external image available).');
t.end();
return;
}
// Sanity check: luma reports correct size
const reported = getExternalImageSize(ext);
t.ok(
reported && typeof reported.width === 'number' && typeof reported.height === 'number',
'getExternalImageSize returns width/height'
);
const props: TextureDataProps = {dimension: '2d', data: ext};
t.deepEqual(
getTextureSizeFromData(props),
reported,
'uses getExternalImageSize for ExternalImage'
);
t.end();
});
test('getTextureSizeFromData: 3d & 2d-array', t => {
const threeD = [
[mkImageData(16, 8), mkImageData(8, 4)], // depth slice 0 (with mips)
[mkImageData(16, 8)] // depth slice 1
];
t.deepEqual(
getTextureSizeFromData({dimension: '3d', data: threeD as any}),
{width: 16, height: 8},
'3d first slice, first mip'
);
const arr = [mkImageData(20, 10), mkImageData(20, 10)];
t.deepEqual(
getTextureSizeFromData({dimension: '2d-array', data: arr as any}),
{width: 20, height: 10},
'2d-array first layer, first mip'
);
t.equal(getTextureSizeFromData({dimension: '3d', data: [] as any}), null, '3d empty → null');
t.equal(
getTextureSizeFromData({dimension: '2d-array', data: [] as any}),
null,
'2d-array empty → null'
);
t.end();
});
test('getTextureSizeFromData: cube & cube-array', t => {
const cube: Record<TextureCubeFace, TextureSliceData> = {
'+X': mkImageData(128, 128),
'-X': mkImageData(128, 128),
'+Y': mkImageData(128, 128),
'-Y': mkImageData(128, 128),
'+Z': mkImageData(128, 128),
'-Z': mkImageData(128, 128)
};
t.deepEqual(
getTextureSizeFromData({dimension: 'cube', data: cube}),
{width: 128, height: 128},
'cube: picks first face first mip'
);
const cube0: Record<TextureCubeFace, TextureSliceData> = {
'+X': [mkImageData(64, 64), mkImageData(32, 32)],
'-X': mkImageData(64, 64),
'+Y': mkImageData(64, 64),
'-Y': mkImageData(64, 64),
'+Z': mkImageData(64, 64),
'-Z': mkImageData(64, 64)
};
const cube1: Record<TextureCubeFace, TextureSliceData> = {
'+X': mkImageData(32, 32),
'-X': mkImageData(32, 32),
'+Y': mkImageData(32, 32),
'-Y': mkImageData(32, 32),
'+Z': mkImageData(32, 32),
'-Z': mkImageData(32, 32)
};
t.deepEqual(
getTextureSizeFromData({dimension: 'cube-array', data: [cube0, cube1]}),
{width: 64, height: 64},
'cube-array: first cube, first face, first mip'
);
t.equal(
getTextureSizeFromData({dimension: 'cube', data: {} as any}),
null,
'cube empty map → null'
);
t.equal(
getTextureSizeFromData({dimension: 'cube-array', data: [] as any}),
null,
'cube-array empty → null'
);
t.end();
});
test('getTextureSizeFromData: invalid 2d payload throws', t => {
// When first mip is neither ExternalImage nor has width/height keys.
const bad: any = {foo: 'bar'};
t.throws(
() => getTextureSizeFromData({dimension: '2d', data: bad}),
/Unsupported mip-level data/,
'throws for unsupported mip-level data'
);
t.end();
});
// ---------------- Helpers ----------------
function mkImageData(width: number, height: number, bytes = width * height * 4): TextureImageData {
return {
data: new Uint8Array(bytes),
width,
height,
format: 'rgba8unorm'
};
}
function maybeMakeExternalImage(): any | null {
// Try to build a native "ExternalImage" that luma.gl recognizes in your env.
// Prefer ImageData (widely available in browsers, sometimes available under jsdom),
// then OffscreenCanvas/ImageBitmap if present.
try {
// ImageData path
if (typeof ImageData !== 'undefined') {
const imgData = new ImageData(4, 3);
if (isExternalImage(imgData)) {
return imgData;
}
}
// OffscreenCanvas -> transferToImageBitmap
if (
typeof OffscreenCanvas !== 'undefined' &&
typeof (globalThis as any).createImageBitmap === 'function'
) {
const oc = new OffscreenCanvas(5, 7);
const bmp = (globalThis as any).createImageBitmap(oc);
// Note: createImageBitmap may return a Promise in some envs; bail if async
if (bmp && typeof bmp.then !== 'function' && isExternalImage(bmp)) {
return bmp;
}
}
} catch {
// ignore
}
// TODO - Could add HTMLCanvasElement/HTMLImageElement branches for browser runs if desired.
return null; // environment doesnt provide a suitable external image type
}

View File

@ -15,6 +15,7 @@ import './lib/shader-factory.spec';
import './dynamic-texture/dynamic-texture.spec';
import './dynamic-texture/mip-levels.spec';
import './dynamic-texture/texture-data.spec';
import './geometry/geometries.spec';
import './geometry/geometry.spec';
@ -39,3 +40,4 @@ import './compute/swap.spec';
import './compute/buffer-transform.spec';
import './compute/texture-transform.spec';
import './compute/computation.spec';
import './passes/shader-pass-renderer.spec';

View File

@ -0,0 +1,64 @@
// luma.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import test from 'tape-promise/tape';
import {getTestDevices} from '@luma.gl/test-utils';
import {ShaderPassRenderer, DynamicTexture, ShaderInputs} from '@luma.gl/engine';
import type {ShaderPass} from '@luma.gl/shadertools';
import {Texture} from '@luma.gl/core';
const invertPass: ShaderPass = {
name: 'invert',
source: /* wgsl */ `
fn invert_filterColor_ext(color: vec4f, texSize: vec2f, texCoord: vec2f) -> vec4f {
return vec4f(1.0 - color.rgb, color.a);
}
`,
fs: /* glsl */ `
vec4 invert_filterColor_ext(vec4 color, vec2 texSize, vec2 texCoord) {
return vec4(1.0 - color.rgb, color.a);
}
`,
passes: [{filter: true}]
};
test('ShaderPassRenderer#renderToTexture', async t => {
const devices = await getTestDevices();
for (const device of devices) {
// TODO - fix, we are getting close
if (device.type === 'webgpu') {
continue; // eslint-disable-line no-continue
}
t.comment(`Testing ${device.type}`);
const sourceTexture = new DynamicTexture(device, {
id: 'source-texture',
usage: Texture.RENDER | Texture.COPY_SRC | Texture.COPY_DST,
dimension: '2d',
data: {data: new Uint8Array([255, 0, 0, 255]), width: 1, height: 1, format: 'rgba8unorm'}
});
await sourceTexture.ready;
// Sanity check
const arrayBuffer = await sourceTexture.texture.readDataAsync();
const pixels1 = new Uint8Array(arrayBuffer, 0, 4); // slice away WebGPU padding
t.deepEqual(Array.from(pixels1), [255, 0, 0, 255], 'initialization success');
const shaderInputs = new ShaderInputs({invert: invertPass});
const renderer = new ShaderPassRenderer(device, {
shaderPasses: [invertPass],
shaderInputs
});
const output = renderer.renderToTexture({sourceTexture});
t.ok(output, 'produces output texture');
const arrayBufferOut = await output!.readDataAsync();
const pixelsOut = new Uint8Array(arrayBufferOut, 0, 4); // slice away WebGPU padding
t.deepEqual(Array.from(pixelsOut), [0, 255, 255, 255], 'applies filter');
renderer.destroy();
sourceTexture.destroy();
}
t.end();
});

View File

@ -130,6 +130,7 @@ export class WebGPUTexture extends Texture {
const {width, height, depth} = this;
const options = this._normalizeCopyImageDataOptions(options_);
this.device.pushErrorScope('validation');
this.device.handle.queue.writeTexture(
// destination: GPUImageCopyTexture
{