• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / deck.gl / 30034610298

23 Jul 2026 06:38PM UTC coverage: 83.368% (-0.004%) from 83.372%
30034610298

Pull #10470

github

web-flow
Merge ffc254f93 into 76547e500
Pull Request #10470: feat(layers): port BitmapLayer to WebGPU

8160 of 10276 branches covered (79.41%)

Branch coverage included in aggregate %.

5 of 6 new or added lines in 2 files covered. (83.33%)

14522 of 16931 relevant lines covered (85.77%)

18948.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.68
/modules/layers/src/bitmap-layer/bitmap-layer.ts
1
// deck.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import {
6
  Layer,
7
  color as colorModule,
8
  project32,
9
  picking,
10
  CoordinateSystem,
11
  LayerProps,
12
  PickingInfo,
13
  GetPickingInfoParams,
14
  UpdateParameters,
15
  Color,
16
  TextureSource,
17
  Position,
18
  DefaultProps
19
} from '@deck.gl/core';
20
import {Model} from '@luma.gl/engine';
21
import type {SamplerProps, Texture} from '@luma.gl/core';
22
import {lngLatToWorld} from '@math.gl/web-mercator';
23

24
import createMesh from './create-mesh';
25

26
import {bitmapUniforms, BitmapProps} from './bitmap-layer-uniforms';
27
import source from './bitmap-layer.wgsl';
28
import vs from './bitmap-layer-vertex';
29
import fs from './bitmap-layer-fragment';
30

31
const defaultProps: DefaultProps<BitmapLayerProps> = {
5✔
32
  image: {type: 'image', value: null, async: true},
33
  bounds: {type: 'array', value: [1, 0, 0, 1], compare: true},
34
  _imageCoordinateSystem: 'default',
35

36
  desaturate: {type: 'number', min: 0, max: 1, value: 0},
37
  // More context: because of the blending mode we're using for ground imagery,
38
  // alpha is not effective when blending the bitmap layers with the base map.
39
  // Instead we need to manually dim/blend rgb values with a background color.
40
  transparentColor: {type: 'color', value: [0, 0, 0, 0]},
41
  tintColor: {type: 'color', value: [255, 255, 255]},
42

43
  textureParameters: {type: 'object', ignore: true, value: null}
44
};
45

46
/** All properties supported by BitmapLayer. */
47
export type BitmapLayerProps = _BitmapLayerProps & LayerProps;
48
export type BitmapBoundingBox =
49
  | [left: number, bottom: number, right: number, top: number]
50
  | [Position, Position, Position, Position];
51

52
/** Properties added by BitmapLayer. */
53
type _BitmapLayerProps = {
54
  data: never;
55
  /**
56
   * The image to display.
57
   *
58
   * @default null
59
   */
60
  image?: string | TextureSource | null;
61

62
  /**
63
   * Supported formats:
64
   *  - Coordinates of the bounding box of the bitmap `[left, bottom, right, top]`
65
   *  - Coordinates of four corners of the bitmap, should follow the sequence of `[[left, bottom], [left, top], [right, top], [right, bottom]]`.
66
   *   Each position could optionally contain a third component `z`.
67
   * @default [1, 0, 0, 1]
68
   */
69
  bounds?: BitmapBoundingBox;
70

71
  /**
72
   * > Note: this prop is experimental.
73
   *
74
   * Specifies how image coordinates should be geographically interpreted.
75
   * @default COORDINATE_SYSTEM.DEFAULT
76
   */
77
  _imageCoordinateSystem?: CoordinateSystem;
78

79
  /**
80
   * The desaturation of the bitmap. Between `[0, 1]`.
81
   * @default 0
82
   */
83
  desaturate?: number;
84

85
  /**
86
   * The color to use for transparent pixels, in `[r, g, b, a]`.
87
   * @default [0, 0, 0, 0]
88
   */
89
  transparentColor?: Color;
90

91
  /**
92
   * The color to tint the bitmap by, in `[r, g, b]`.
93
   * @default [255, 255, 255]
94
   */
95
  tintColor?: Color;
96

97
  /** Customize the [texture parameters](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/texParameter). */
98
  textureParameters?: SamplerProps | null;
99
};
100

101
export type BitmapLayerPickingInfo = PickingInfo<
102
  null,
103
  {
104
    bitmap: {
105
      /** Size of the original image */
106
      size: {
107
        width: number;
108
        height: number;
109
      };
110
      /** Hovered pixel uv in 0-1 range */
111
      uv: [number, number];
112
      /** Hovered pixel in the original image */
113
      pixel: [number, number];
114
    } | null;
115
  }
116
>;
117

118
/** Render a bitmap at specified boundaries. */
119
export default class BitmapLayer<ExtraPropsT extends {} = {}> extends Layer<
120
  ExtraPropsT & Required<_BitmapLayerProps>
121
> {
122
  static layerName = 'BitmapLayer';
5✔
123
  static defaultProps = defaultProps;
5✔
124

125
  state!: {
126
    disablePicking?: boolean;
127
    model?: Model;
128
    mesh?: any;
129
    coordinateConversion: number;
130
    bounds: [number, number, number, number];
131
  };
132

133
  getShaders() {
134
    const isWebGPU = this.context.device.type === 'webgpu';
8✔
135

136
    return super.getShaders({
8✔
137
      ...(isWebGPU && {source}),
8!
138
      vs,
139
      fs,
140
      modules: isWebGPU
8!
141
        ? [colorModule, project32, picking, bitmapUniforms]
142
        : [project32, picking, bitmapUniforms]
143
    });
144
  }
145

146
  initializeState() {
147
    const attributeManager = this.getAttributeManager()!;
8✔
148

149
    const noAlloc = true;
8✔
150

151
    attributeManager.add({
8✔
152
      indices: {
153
        size: 1,
154
        isIndexed: true,
155
        update: attribute => (attribute.value = this.state.mesh.indices),
7✔
156
        noAlloc
157
      },
158
      positions: {
159
        size: 3,
160
        type: 'float64',
161
        fp64: this.use64bitPositions(),
162
        update: attribute => (attribute.value = this.state.mesh.positions),
9✔
163
        noAlloc
164
      },
165
      texCoords: {
166
        size: 2,
167
        update: attribute => (attribute.value = this.state.mesh.texCoords),
7✔
168
        noAlloc
169
      }
170
    });
171
  }
172

173
  updateState({props, oldProps, changeFlags}: UpdateParameters<this>): void {
174
    // setup model first
175
    const attributeManager = this.getAttributeManager()!;
16✔
176

177
    if (changeFlags.extensionsChanged) {
16✔
178
      this.state.model?.destroy();
8✔
179
      this.state.model = this._getModel();
8✔
180
      attributeManager.invalidateAll();
8✔
181
    }
182

183
    if (props.bounds !== oldProps.bounds) {
16✔
184
      const oldMesh = this.state.mesh;
10✔
185
      const mesh = this._createMesh();
10✔
186
      this.state.model!.setVertexCount(mesh.vertexCount);
10✔
187
      for (const key in mesh) {
10✔
188
        if (oldMesh && oldMesh[key] !== mesh[key]) {
40✔
189
          attributeManager.invalidate(key);
2✔
190
        }
191
      }
192
      this.setState({mesh, ...this._getCoordinateUniforms()});
10✔
193
    } else if (props._imageCoordinateSystem !== oldProps._imageCoordinateSystem) {
6✔
194
      this.setState(this._getCoordinateUniforms());
4✔
195
    }
196
  }
197

198
  getPickingInfo(params: GetPickingInfoParams): BitmapLayerPickingInfo {
199
    const {image} = this.props;
2✔
200
    const info = params.info as BitmapLayerPickingInfo;
2✔
201

202
    if (!info.color || !image) {
2✔
203
      info.bitmap = null;
1✔
204
      return info;
1✔
205
    }
206

207
    const {width, height} = image as Texture;
1✔
208

209
    // Picking color doesn't represent object index in this layer
210
    info.index = 0;
1✔
211

212
    // Calculate uv and pixel in bitmap
213
    const uv = unpackUVsFromRGB(info.color);
1✔
214

215
    info.bitmap = {
1✔
216
      size: {width, height},
217
      uv,
218
      pixel: [Math.floor(uv[0] * width), Math.floor(uv[1] * height)]
219
    };
220

221
    return info;
1✔
222
  }
223

224
  // Override base Layer multi-depth picking logic
225
  disablePickingIndex() {
226
    this.setState({disablePicking: true});
×
227
  }
228

229
  restorePickingColors() {
230
    this.setState({disablePicking: false});
×
231
  }
232

233
  protected _updateAutoHighlight(info) {
234
    super._updateAutoHighlight({
2✔
235
      ...info,
236
      color: this.encodePickingColor(0)
237
    });
238
  }
239

240
  protected _createMesh() {
241
    const {bounds} = this.props;
10✔
242

243
    let normalizedBounds = bounds;
10✔
244
    // bounds as [minX, minY, maxX, maxY]
245
    if (isRectangularBounds(bounds)) {
10✔
246
      /*
247
        (minX0, maxY3) ---- (maxX2, maxY3)
248
               |                  |
249
               |                  |
250
               |                  |
251
        (minX0, minY1) ---- (maxX2, minY1)
252
     */
253
      normalizedBounds = [
8✔
254
        [bounds[0], bounds[1]],
255
        [bounds[0], bounds[3]],
256
        [bounds[2], bounds[3]],
257
        [bounds[2], bounds[1]]
258
      ];
259
    }
260

261
    return createMesh(normalizedBounds, this.context.viewport.resolution);
10✔
262
  }
263

264
  protected _getModel(): Model {
265
    /*
266
      0,0 --- 1,0
267
       |       |
268
      0,1 --- 1,1
269
    */
270
    const bufferLayout =
271
      this.context.device.type === 'webgpu'
8!
272
        ? this.getAttributeManager()!
273
            .getBufferLayouts({isInstanced: false})
274
            // WebGPU index buffers are bound separately from vertex buffer layouts.
NEW
275
            .filter(layout => layout.name !== 'indices')
×
276
        : this.getAttributeManager()!.getBufferLayouts();
277

278
    return new Model(this.context.device, {
8✔
279
      ...this.getShaders(),
280
      id: this.props.id,
281
      bufferLayout,
282
      topology: 'triangle-list',
283
      isInstanced: false
284
    });
285
  }
286

287
  draw(opts) {
288
    const {shaderModuleProps} = opts;
16✔
289
    const {model, coordinateConversion, bounds, disablePicking} = this.state;
16✔
290
    const {image, desaturate, transparentColor, tintColor} = this.props;
16✔
291

292
    if (shaderModuleProps.picking.isActive && disablePicking) {
16!
293
      return;
×
294
    }
295

296
    // // TODO fix zFighting
297
    // Render the image
298
    if (image && model) {
16✔
299
      const bitmapProps: BitmapProps = {
2✔
300
        bitmapTexture: image as Texture,
301
        bounds,
302
        coordinateConversion,
303
        desaturate,
304
        tintColor: tintColor.slice(0, 3).map(x => x / 255) as [number, number, number],
6✔
305
        transparentColor: transparentColor.map(x => x / 255) as [number, number, number, number]
8✔
306
      };
307
      model.shaderInputs.setProps({bitmap: bitmapProps});
2✔
308
      model.draw(this.context.renderPass);
2✔
309
    }
310
  }
311

312
  _getCoordinateUniforms() {
313
    let {_imageCoordinateSystem: imageCoordinateSystem} = this.props;
14✔
314
    if (imageCoordinateSystem !== 'default') {
14✔
315
      const {bounds} = this.props;
6✔
316
      if (!isRectangularBounds(bounds)) {
6✔
317
        throw new Error('_imageCoordinateSystem only supports rectangular bounds');
1✔
318
      }
319

320
      // The default behavior (linearly interpolated tex coords)
321
      const defaultImageCoordinateSystem = this.context.viewport.resolution
5✔
322
        ? 'lnglat'
323
        : 'cartesian';
324
      imageCoordinateSystem = imageCoordinateSystem === 'lnglat' ? 'lnglat' : 'cartesian';
6✔
325

326
      if (imageCoordinateSystem === 'lnglat' && defaultImageCoordinateSystem === 'cartesian') {
6✔
327
        // LNGLAT in Mercator, e.g. display LNGLAT-encoded image in WebMercator projection
328
        return {coordinateConversion: -1, bounds};
2✔
329
      }
330
      if (imageCoordinateSystem === 'cartesian' && defaultImageCoordinateSystem === 'lnglat') {
3✔
331
        // Mercator in LNGLAT, e.g. display WebMercator encoded image in Globe projection
332
        const bottomLeft = lngLatToWorld([bounds[0], bounds[1]]);
3✔
333
        const topRight = lngLatToWorld([bounds[2], bounds[3]]);
3✔
334
        return {
1✔
335
          coordinateConversion: 1,
336
          bounds: [bottomLeft[0], bottomLeft[1], topRight[0], topRight[1]]
337
        };
338
      }
339
    }
340
    return {
10✔
341
      coordinateConversion: 0,
342
      bounds: [0, 0, 0, 0]
343
    };
344
  }
345
}
346

347
/**
348
 * Decode uv floats from rgb bytes where b contains 4-bit fractions of uv
349
 * @param {number[]} color
350
 * @returns {number[]} uvs
351
 * https://stackoverflow.com/questions/30242013/glsl-compressing-packing-multiple-0-1-colours-var4-into-a-single-var4-variab
352
 */
353
function unpackUVsFromRGB(color: Uint8Array): [number, number] {
354
  const [u, v, fracUV] = color;
1✔
355
  const vFrac = (fracUV & 0xf0) / 256;
1✔
356
  const uFrac = (fracUV & 0x0f) / 16;
1✔
357
  return [(u + uFrac) / 256, (v + vFrac) / 256];
1✔
358
}
359

360
function isRectangularBounds(
361
  bounds: [number, number, number, number] | [Position, Position, Position, Position]
362
): bounds is [number, number, number, number] {
363
  return Number.isFinite(bounds[0]);
16✔
364
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc