Skip to content

BitmapLayer renders opaque black for the documented plain-object image form — createTexture reads width/height from image.data #10371

Description

@heeje-cho

Description

BitmapLayer's docs list a plain object {data: <Uint8Array>, width, height} as a supported image value. In deck.gl 9.x this form produces a texture that samples as opaque black (not transparent — it actively paints black over everything beneath the layer's bounds).

Root cause in modules/core/src/utils/texture.ts (createTexture, still present on master as of 2026-06-11):

const {width, height} = image.data;          // <- for the plain-object form,
const texture = device.createTexture({       //    image.data is the raw typed
  ...image,                                  //    array: width/height are
  sampler: {},                              //    undefined
  mipLevels: device.getMipLevelCount(width, height)   // <- garbage mip count
});

Browser image objects (ImageData, ImageBitmap, canvas, …) are unaffected because they get wrapped as image = {data: browserImage} first, so image.data.width/height exist. Only the plain-object path reads dimensions from the wrong level: the object itself carries width/height, its data does not. Combined with the default mipmap-filtering sampler (mipmapFilter: 'linear'), the resulting texture is incomplete and samples as (0,0,0,1).

Reproduction (deck.gl 9.3.3, self-contained)

Two BitmapLayers, byte-identical RGBA checkerboards — left uses the documented plain-object form, right wraps the same array in an ImageData:

<script src="https://unpkg.com/deck.gl@9.3.3/dist.min.js"></script>
<body style="margin:0;background:#234">
<script>
  const W = 64;
  const data = new Uint8ClampedArray(W * W * 4);
  for (let i = 0; i < W * W; i++) {
    const x = i % W, y = (i / W) | 0;
    data.set([255, ((x >> 3) + (y >> 3)) % 2 ? 255 : 0, 0, 255], i * 4);
  }
  new deck.Deck({
    parent: document.body,
    initialViewState: {longitude: 0, latitude: 0, zoom: 3},
    layers: [
      new deck.BitmapLayer({id: 'plain-object-BUG', bounds: [-22, -10, -2, 10],
        image: {data, width: W, height: W}}),
      new deck.BitmapLayer({id: 'imagedata-OK', bounds: [2, -10, 22, 10],
        image: new ImageData(data, W, W)}),
    ],
  });
</script>
</body>

Result: left tile solid black, right tile renders the checkerboard.

Measured via Playwright pixel readback (Chromium 141.0.7390.37, macOS):

Mode GL renderer plain-object half ImageData half
headed ANGLE Metal (Apple M3 Pro) mean RGB (0,0,0) — black (133,67,0) — renders
headless SwiftShader (Vulkan) mean RGB (0,0,0) — black (134,67,0) — renders

Expected behavior

The documented plain-object form renders identically to the equivalent ImageData.

Suggested fix

Read dimensions from the level that has them:

const {width, height} = image.data.width != null ? image.data : image;

(or validate/short-circuit mipLevels when dimensions are unavailable).

Additional context

  • Environment: deck.gl 9.3.3, Chromium 141, macOS 15 (Apple M3 Pro); bug code path unchanged on current master.
  • In a larger app (MapboxOverlay interleaved with MapLibre), the same bug manifested only on real GPUs (ANGLE Metal) while headless SwiftShader rendered the texture correctly through that code path — so headless CI and screenshot harnesses showed a working layer while every real device showed black. Worth keeping in mind for regression coverage: a pixel test of this minimal repro does catch it headless.
  • Related but distinct: [Feat] Regression: cannot disable mipmap generation in 9.1 #9464 (no way to disable mipmap generation — same createTexture, different defect).
  • Workaround for affected apps: wrap raw RGBA in ImageData before handing it to BitmapLayer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions