feat: Handle GeoTIFF transparency masks#309
Conversation
There was a problem hiding this comment.
Pull request overview
Adds end-to-end support for GeoTIFF per-dataset nodata masks by fetching/decoding the mask alongside tile data and applying it in the Deck.gl raster shader pipeline to discard invalid pixels.
Changes:
- Fetch mask tiles (when present) in parallel with data tiles and attach the decoded mask to
RasterArray. - Add 1-bit (bit-packed) decoding support to the GeoTIFF decoder path to handle common mask encodings.
- Introduce a new
MaskTextureGPU shader module and update the render pipeline to upload/apply the mask texture automatically.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/geotiff/src/fetch.ts | Fetches and decodes mask tiles; attaches mask to returned raster arrays. |
| packages/geotiff/src/decode.ts | Adds bit-packed (1-bit) unpacking to support mask decoding. |
| packages/geotiff/src/array.ts | Updates mask semantics documentation (non-zero valid, 0 invalid). |
| packages/deck.gl-raster/src/gpu-modules/mask-texture.ts | New shader module that discards fragments when mask indicates invalid pixels. |
| packages/deck.gl-raster/src/gpu-modules/index.ts | Exports the new MaskTexture module. |
| packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts | Uploads mask textures and injects masking into the render pipeline when present. |
Comments suppressed due to low confidence (1)
packages/geotiff/src/fetch.ts:110
- When
boundless: falseis used,clipToImageBoundstrimsdata/bandsto match the clippedwidth/height, but it does not clipmask. With masks now being populated, edge tiles will returnarray.width/heightsmaller thantileWidth/tileHeightwhilearray.maskremains full-tile length, breaking the stated contract (mask.length === width*height) and causing mask upload/rendering issues.clipToImageBoundsshould clipmaskin the same way it clips pixel data.
const array: RasterArray = {
...decodedPixels,
count: samplesPerPixel,
height: self.tileHeight,
width: self.tileWidth,
mask,
transform: tileTransform,
crs: self.crs,
nodata: self.nodata,
};
return {
x,
y,
array: boundless === false ? clipToImageBounds(self, x, y, array) : array,
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (geotiff.maskImage !== null) { | ||
| renderPipeline.push({ | ||
| module: MaskTexture, | ||
| props: { | ||
| maskTexture: (data: TextureDataT) => data.mask as Texture, | ||
| }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
MaskTexture is added to the pipeline when geotiff.maskImage !== null, but TextureDataT.mask is optional and the module props use a forced cast (data.mask as Texture). If a mask tile can't be fetched/decoded (e.g. zero-byte tile, missing tile, decode failure), this will attempt to bind undefined to a sampler2D and can break rendering. Consider making mask non-optional when a mask IFD exists, or conditionally injecting MaskTexture per-tile only when a mask texture is actually present.
Change list
MaskTexturemodule for masking out invalid pixels on the GPU.render-pipelineto automatically apply masking if it existsNotes
Closes #196 Closes #168
Testing with https://maxar-opendata.s3.us-west-2.amazonaws.com/events/kentucky-flooding-7-29-2022/ard/17/031133010311/2021-07-03/10300100C12A9500-visual.tif
Before:
After:

Screen.Recording.2026-03-05.at.2.43.33.PM.mov