1919 */
2020
2121import type { Layer , UpdateParameters } from '@deck.gl/core'
22- import { COGLayer } from '@developmentseed/deck.gl-geotiff'
22+ import { COGLayer , addAlphaChannel } from '@developmentseed/deck.gl-geotiff'
2323// `moduleResolution: "node"` in tsconfig.json does not read package exports
2424// maps, so tsc cannot resolve the `./gpu-modules` subpath export. The import
2525// below works correctly at runtime (Node.js, Playwright, Webpack all honour
@@ -107,57 +107,78 @@ function lutToImageData(lut: Uint8ClampedArray): ImageData {
107107 return new ImageData ( lut , 256 , 1 )
108108}
109109
110+ type TileData = {
111+ texture : any
112+ /** 'single' → float value in color.r (colormap path); 'rgb' → RGBA displayed directly. */
113+ mode : 'single' | 'rgb'
114+ width : number
115+ height : number
116+ byteLength : number
117+ } | null
118+
110119/**
111- * Custom tile loader for single-band COGs. Uploads the band as a single-channel
112- * `r32float` GPU texture so the RAW pixel value lands in `color.r` for the
113- * downstream LinearRescale + Colormap modules.
120+ * Custom tile loader for COGs. Two paths, chosen by band count:
114121 *
115- * This deliberately replaces `@developmentseed/deck.gl-geotiff`'s default
116- * `inferRenderPipeline`, whose auto-inference currently only supports unsigned
117- * integer COGs (it throws `non-unsigned integers not yet supported` for float,
118- * SampleFormat 3 — the common case for scientific single-band rasters). By
119- * supplying our own `getTileData` + `renderTile`, `COGLayer._parseGeoTIFF`
120- * skips that inference entirely.
122+ * - **Single-band** (the primary case): upload the band as a single-channel
123+ * `r32float` texture so the RAW value lands in `color.r` for the downstream
124+ * FilterNaN + LinearRescale + Colormap modules. Nearest sampling (float
125+ * linear filtering is not guaranteed in WebGL2).
126+ * - **Multi-band uint (RGB/RGBA)**: upload as `rgba8unorm` and display the
127+ * colour directly via CreateTexture (no colormap/rescale). This is the
128+ * multi-band case #158 defers — supported here as a passthrough so true-colour
129+ * COGs (e.g. Sentinel-2 TCI) render client-side. Assumes 8-bit samples.
121130 *
122- * Values of any numeric type are converted to Float32 and uploaded as
123- * `r32float`, so a single rescale (in raw data units) is correct for float and
124- * integer COGs alike. Sampled with nearest filtering (float-linear filtering is
125- * not guaranteed in WebGL2).
131+ * Supplying this `getTileData` (+ a `renderTile`) makes `COGLayer._parseGeoTIFF`
132+ * skip its default `inferRenderPipeline`, whose auto-inference throws for float
133+ * COGs (`non-unsigned integers not yet supported`, SampleFormat 3).
126134 *
127135 * @param image A `GeoTIFF` (full-res) or `Overview`, selected per-zoom by
128- * COGLayer's `_getTileDataCallback` wrapper.
136+ * COGLayer's `_getTileDataCallback` wrapper — this is what makes
137+ * higher zooms load finer overviews.
129138 * @param options `{ device, x, y, signal, pool }` supplied by the wrapper.
130139 */
131140async function cogGetTileData (
132141 image : any ,
133142 options : { device : any ; x : number ; y : number ; signal ?: AbortSignal ; pool ?: any }
134- ) : Promise < { texture : any ; width : number ; height : number ; byteLength : number } | null > {
143+ ) : Promise < TileData > {
135144 const { device, x, y, signal, pool } = options
136- const tile = await image . fetchTile ( x , y , { boundless : false , pool, signal } )
137- const array = tile ?. array
145+ let array = ( await image . fetchTile ( x , y , { boundless : false , pool, signal } ) ) ?. array
138146 if ( ! array ) return null
139147 const width = array . width
140148 const height = array . height
141- // Single-band: prefer flat `data`; fall back to first band for band-separate.
142- let src = array . data
143- if ( array . layout === 'band-separate' && array . bands && array . bands . length ) {
144- src = array . bands [ 0 ]
149+ const samples =
150+ ( array . bands && array . bands . length ) ||
151+ Math . max ( 1 , Math . round ( array . data . length / ( width * height ) ) )
152+
153+ if ( samples === 1 ) {
154+ const src =
155+ array . layout === 'band-separate' && array . bands && array . bands . length
156+ ? array . bands [ 0 ]
157+ : array . data
158+ const f32 = src instanceof Float32Array ? src : Float32Array . from ( src )
159+ const texture = device . createTexture ( {
160+ data : f32 ,
161+ format : 'r32float' ,
162+ width,
163+ height,
164+ mipmaps : false ,
165+ sampler : { minFilter : 'nearest' , magFilter : 'nearest' , addressModeU : 'clamp-to-edge' , addressModeV : 'clamp-to-edge' } ,
166+ } )
167+ return { texture, mode : 'single' , width, height, byteLength : f32 . byteLength }
145168 }
146- const f32 = src instanceof Float32Array ? src : Float32Array . from ( src )
169+
170+ // Multi-band: WebGL2 has no RGB-only format, so pad RGB → RGBA.
171+ if ( samples === 3 ) array = addAlphaChannel ( array )
172+ const u8 = array . data instanceof Uint8Array ? array . data : Uint8Array . from ( array . data )
147173 const texture = device . createTexture ( {
148- data : f32 ,
149- format : 'r32float ' ,
174+ data : u8 ,
175+ format : 'rgba8unorm ' ,
150176 width,
151177 height,
152178 mipmaps : false ,
153- sampler : {
154- minFilter : 'nearest' ,
155- magFilter : 'nearest' ,
156- addressModeU : 'clamp-to-edge' ,
157- addressModeV : 'clamp-to-edge' ,
158- } ,
179+ sampler : { minFilter : 'linear' , magFilter : 'linear' , addressModeU : 'clamp-to-edge' , addressModeV : 'clamp-to-edge' } ,
159180 } )
160- return { texture, width, height, byteLength : f32 . byteLength }
181+ return { texture, mode : 'rgb' , width, height, byteLength : u8 . byteLength }
161182}
162183
163184/** Truthy placeholder so COGLayer._parseGeoTIFF skips the (float-unsupported)
@@ -182,6 +203,22 @@ const FilterNaN = {
182203 } ,
183204}
184205
206+ /**
207+ * GPU module for the multi-band RGB path: discards fully-black pixels. Satellite
208+ * true-colour COGs (e.g. Sentinel-2 TCI) encode nodata as exact black (0,0,0) —
209+ * most visibly the rotated UTM tile edges — so this leaves them transparent
210+ * rather than painting black over the basemap. Heuristic: also discards any
211+ * legitimately pure-black pixel (rare in true-colour imagery).
212+ */
213+ const FilterBlack = {
214+ name : 'filter-black' ,
215+ inject : {
216+ 'fs:DECKGL_FILTER_COLOR' : `
217+ if (color.r == 0.0 && color.g == 0.0 && color.b == 0.0) { discard; }
218+ ` ,
219+ } ,
220+ }
221+
185222// ---------------------------------------------------------------------------
186223// ColormappedCOGLayer
187224// ---------------------------------------------------------------------------
@@ -264,11 +301,21 @@ export class ColormappedCOGLayer extends COGLayer<any> {
264301
265302 return ( data : any ) => {
266303 if ( ! data || ! data . texture ) return null
304+ // Multi-band RGB: display the colour directly (no colormap/rescale);
305+ // discard black nodata edges → transparent.
306+ if ( data . mode === 'rgb' ) {
307+ return {
308+ renderPipeline : [
309+ { module : CreateTexture , props : { textureName : data . texture } } ,
310+ { module : FilterBlack } ,
311+ ] ,
312+ }
313+ }
314+ // Single-band: raw value in color.r → discard NaN → rescale → colormap.
267315 return composeColormapPipeline (
268316 {
269317 renderPipeline : [
270318 { module : CreateTexture , props : { textureName : data . texture } } ,
271- // Discard NaN nodata (oceans/fill) → transparent.
272319 { module : FilterNaN } ,
273320 ] ,
274321 } ,
0 commit comments