Custom Shaders with DeckGL

In previous articles, I've covered DeckGL Basics as well as showcasing some of the out-of-the-box layers.

One of the great things about DeckGL as a framework is its extensibility. If you need a visualization that's not provided for you, there are plenty of hooks to write the visualization code while still leveraging all the data loading & lifecycle management that's built into DeckGL.

In this article, I'm going to cover extending an existing layer's functionality with custom shader code. I'll be walking through creating an ETA Layer, which will be based on the existing Trips Layer, but with the modification that instead of a single color, it will take startColor and endColor props, and interpolate the color between them based on the position of the trip (the trip will turn more green as it approaches the destination).

Extending the Layer

We're going to be staring with the Trips Layer as the base, so we just need our new class to extend this layer.

class TripsEtaLayer extends TripsLayer

Since we're subclassing the layer, we only need to override the parts of the Trips Layer that we want to modify. In order to change the rendering behavior of the layer, we are just going to be modifying functions related to the shader code. So in this case, we're just going to override initializeState, getShaders and draw.

class TripsEtaLayer extends TripsLayer { initializeState(params) { super.initializeState(params); } getShaders() { const shaders = super.getShaders(); return shaders; } draw(params) { super.draw(params); } }

Setting up attributes

Since we're going to be changing the rendering behavior, we need to setup some additional state that the shaders will have access to.

First, we're going to set up a shader attribute that represents the start and end time of each trip. Since we need this information for every point that is drawn, we'll need to set the start & end time for each coordinate in the dataset. We can do this by adding a new instanced attribute to the attributeManager.

Since we're also running up against the limit of available attributes we can use (16), we can use a vec2 type to store both data points (x for start, y for end).

initializeState(params) { super.initializeState(params); const attributeManager = this.getAttributeManager(); attributeManager.addInstanced({ range: { size: 2, update: (attribute) => { const vertices = _.sum(this.props.data.map((d) => d.timestamps.length)); const ranges = new Float32Array(vertices * 2); // compute the start & end for each vertex (coordinate) let r = 0; for (let i = 0; i < this.props.data.length; i++) { const tripStart = _.first(this.props.data[i].timestamps); const tripEnd = _.last(this.props.data[i].timestamps); this.props.data[i].timestamps.forEach(() => { ranges[r++] = tripStart; ranges[r++] = tripEnd; }); } attribute.value = ranges; }, }, }); }

Next, we'll add uniforms that represent the startColor and endColor that we'll interpolate between as the trip progresses. These can be set on the params that are passed in to the draw function.

draw(params) { if (this.props.getStartColor) { params.uniforms.startColor = this.props.getStartColor; } if (this.props.getEndColor) { params.uniforms.endColor = this.props.getEndColor; } super.draw(params); }

We now have two ways of passing data from the layer: as an attribute (value that changes per coordinate) and as a uniform (value that is the same across all trips)

Writing the shaders

Up to this point, we've been setting up state that can be used by the shaders. Since we're extending the TripsLayer, we can use the existing shaders as our base.

Vertex Shader

Since DeckGL is generating the shaders dynamically, there are some nice shader hooks exposed we can take advantage of. To start, we'll use vs#decl to declare some new attributes for the vertex shader.

We'll add the range attribute mentioned above. We'll also add a progress varying field, which will used to store the progress computed from the shader. The shader code is declared as a string.

'vs:#decl': `\ // new attrs attribute float range; varying float progress; // existing attrs from TripsLayer uniform float trailLength; attribute float instanceTimestamps; attribute float instanceNextTimestamps; varying float vTime; `,

Now that we've declared the attributes, the next part is to compute the progress as part of the shader body, and store the result in he varying we declared. This is where the start time (x) and end time (y) come into play.

'vs:#main-end': `\ vTime = instanceTimestamps + (instanceNextTimestamps - instanceTimestamps) * vPathPosition.y / vPathLength; progress = (currentTime - range.x) / (range.y - range.x); `,
Fragment Shader

Similarly to how we setup the attributes for the vertex shader, we're going to do the same for the fragment shader as well.

'fs:#decl': `\ // new attrs uniform vec3 startColor; uniform vec3 endColor; varying float progress; // existing attrs from TripsLayer uniform bool fadeTrail; uniform float trailLength; uniform float currentTime; varying float vTime; `,

We've added the startTime and endTime attributes that we going to be defined on our layer instance, as well as the progress varying that is going to be passed in from the vertex shader.

We're also also going to add some fragment shader code to the body as well, this time using fs:#main-start which will place the code at the beginning of the shader (as opposed to the end).

'fs:#main-start': `\ // existing from TripsLayer - drop the segments outside of the time window if(vTime > currentTime || (fadeTrail && (vTime < currentTime - trailLength))) { discard; } // drop if no progress, or trip is complete if (progress <= 0.0 || progress > 1.0) { discard; } `

And lastly, we're going to set our interpolated color of the trip using fs:DECKGL_FILTER_COLOR. This will be the color that gets output to the canvas. So we'll be using the progress of the trip we computed in the vertex shader to determine how far along the trip is, and set the color based on the percentage value between our start and end colors.

'fs:DECKGL_FILTER_COLOR': `\ color.r = ((endColor.r - startColor.r) * progress + startColor.r) / 255.0; color.g = ((endColor.g - startColor.g) * progress + startColor.g) / 255.0; color.b = ((endColor.b - startColor.b) * progress + startColor.b) / 255.0; if (fadeTrail) { color.a *= 1.0 - (currentTime - vTime) / trailLength; } `

Now we just need to set the shaders on our class by overriding the getShaders() function.

getShaders() { const shaders = super.getShaders(); shaders.inject = { 'vs:#decl': '...', 'vs:#main-end': '...', 'fs:#decl': '...', 'fs:#main-start': '...', 'fs:DECKGL_FILTER_COLOR': '...', }; return shaders; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 import { first, last, sum } from 'lodash'; import { TripsLayer } from '@deck.gl/geo-layers'; class TripsEtaLayer extends TripsLayer { initializeState(params) { super.initializeState(params); const attributeManager = this.getAttributeManager(); attributeManager.addInstanced({ range: { size: 2, update: (attribute) => { const vertices = sum(this.props.data.map((d) => d.timestamps.length)); const ranges = new Float32Array(vertices * 2); // compute the start & end for each vertex (coordinate) let r = 0; for (let i = 0; i < this.props.data.length; i++) { const tripStart = first(this.props.data[i].timestamps); const tripEnd = last(this.props.data[i].timestamps); this.props.data[i].timestamps.forEach(() => { ranges[r++] = tripStart; ranges[r++] = tripEnd; }); } attribute.value = ranges; }, }, }); } getShaders() { const shaders = super.getShaders(); shaders.inject = { 'vs:#decl': `\ attribute vec2 range; varying float progress; uniform float currentTime; uniform float trailLength; attribute float instanceTimestamps; attribute float instanceNextTimestamps; varying float vTime; `, // compute the progress for each vertex 'vs:#main-end': `\ vTime = instanceTimestamps + (instanceNextTimestamps - instanceTimestamps) * vPathPosition.y / vPathLength; progress = (currentTime - range.x) / (range.y - range.x); `, 'fs:#decl': `\ uniform vec3 startColor; uniform vec3 endColor; varying float progress; uniform bool fadeTrail; uniform float trailLength; uniform float currentTime; varying float vTime; `, 'fs:#main-start': `\ if(vTime > currentTime || (fadeTrail && (vTime < currentTime - trailLength))) { discard; } if (progress <= 0.0 || progress > 1.0) { discard; } `, // color trip based on progress 'fs:DECKGL_FILTER_COLOR': `\ color.r = ((endColor.r - startColor.r) * progress + startColor.r) / 255.0; color.g = ((endColor.g - startColor.g) * progress + startColor.g) / 255.0; color.b = ((endColor.b - startColor.b) * progress + startColor.b) / 255.0; if (fadeTrail) { color.a *= 1.0 - (currentTime - vTime) / trailLength; } `, }; return shaders; } draw(params) { if (this.props.getStartColor) { params.uniforms.startColor = this.props.getStartColor; } if (this.props.getEndColor) { params.uniforms.endColor = this.props.getEndColor; } super.draw(params); } } export default TripsEtaLayer;

Using the Layer

Now that we've got our custom layer, we can go head and use it like any other DeckGL layer. We just need to make sure we're including the new props for getStartColor and getEndColor.

import TripsEtaLayer from './TripsEtaLayer'; const RED = [255, 0, 0]; const GREEN = [0,255,0]; new TripsEtaLayer({ id: 'trips', data: '/data/detailed-trips.json', getPath: d => d.path, getTimestamps: d => d.timestamps, getStartColor: RED, getEndColor: GREEN, getEndTime: d => d.timestamps[d.timestamps.length - 1], opacity: 0.75, widthMinPixels: 5, rounded: true, trailLength: 250, currentTime: time, }),
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 import React, { useState, useEffect } from 'react'; import DeckGL from 'deck.gl'; import { StaticMap } from 'react-map-gl'; import { assign } from 'lodash'; import TripsEtaLayer from '../deck/TripsEtaLayer'; import { isMobile } from '../util/mobile'; import config from '../config'; const mapStyle = 'mapbox://styles/mapbox/light-v9'; const mapboxApiAccessToken = config('mapboxApiAccessToken'); const initialStyle = { position: "relative", width: "100%", height: "550px", border: "1px solid black", }; const mobileStyle = { height: "300px", }; const initialViewState = { latitude: 40.71460213064598, longitude: -73.97744746237277, zoom: 11.5, minZoom: 2, maxZoom: 15, pitch: 0, bearing: 0, }; const mobileViewState = { latitude: 40.72491632450353, longitude: -73.98669446445103, zoom: 11.38228886864759, }; const RED = [255, 0, 0]; const GREEN = [0,255,0]; const DeckGLTripsETAMap = () => { const [style, setStyle] = useState(assign({}, initialStyle)); const [viewState, setViewState] = useState(initialViewState); const [time, setTime] = useState(0); useEffect(() => { if (isMobile()) { setStyle(assign({}, initialStyle, mobileStyle)) setViewState(assign({}, initialViewState, mobileViewState)) } }, []); const layers = [ new TripsEtaLayer({ id: 'trips', data: '/data/detailed-trips.json', getPath: d => d.path, getTimestamps: d => d.timestamps, getStartColor: RED, getEndColor: GREEN, opacity: 0.75, widthMinPixels: 5, rounded: true, trailLength: 250, currentTime: time, }), ]; return ( <> <DeckGL controller viewState={viewState} layers={layers} style={style} onViewStateChange={ (nextViewState) => { setViewState(nextViewState.viewState); } } > <StaticMap mapboxApiAccessToken={mapboxApiAccessToken} mapStyle={mapStyle} /> </DeckGL> <div style={{ width: '100%', marginTop: "1.5rem" }}> <input style={{ width: '100%' }} type="range" min="0" max="2486" step="0.1" value={time} onChange={(e) => { setTime(Number(e.target.value)); }} /> </div> </> ); } export default DeckGLTripsETAMap;