Skip to content

[Bug] Change in PROJECTION_MODE creates artifacts in custom shader #9811

@kaligrafy

Description

@kaligrafy

Description

I have an animated arrows custom path layer: the arrows move along the path.

Below zoom 12, I get artifacts in the path

This is at zoom 11.99:
Image

And this is at zoom 12:
Image

I understand that the projection mode changes at zoom 12, but why does it create these problems in the shader calculation below zoom 12? Is there an easy way to fix this?

Flavors

  • Script tag
  • React
  • Python/Jupyter notebook
  • MapboxOverlay
  • GoogleMapsOverlay
  • CARTO
  • ArcGIS

Expected Behavior

I would expect that the arrow would look the same at any zoom level.

Steps to Reproduce

Here is the custom shader code:

import { DefaultProps, Layer, LayerContext, LayerExtension } from '@deck.gl/core';
import type { ShaderModule } from '@luma.gl/shadertools';
import { vec3 } from 'gl-matrix';

const uniformBlock = `\
uniform animatedArrowPathUniforms {
  float time;
  float arrowSpacing;
} animatedArrowPath;
`;

type AnimatedArrowPathProps = {
    time: number;
    arrowSpacing: number;
};

const defaultProps: DefaultProps<_AnimatedArrowPathLayerProps> = {
    time: { type: 'number', value: 0, min: 0, max: 1 },
    disableAnimation: { type: 'boolean', value: false }
};

type _AnimatedArrowPathLayerProps = {
    time: number;
    arrowSpacing: number;
    /**
     * Set to `true` to disable animation
     */
    disableAnimation: boolean;
};

export default class AnimatedArrowPathExtension extends LayerExtension {
    static extensionName = 'AnimatedArrowPathExtension';
    static layerName = 'AnimatedArrowPathLayer';
    static defaultProps = defaultProps;

    initializeState(this: Layer<_AnimatedArrowPathLayerProps>, context: LayerContext, extension: this) {
        this.getAttributeManager()?.addInstanced({
            instanceStartOffsetRatios: {
                size: 1,
                accessor: 'getPath',
                transform: extension.getStartOffsetRatios.bind(this)
            },
            instanceLengthRatios: {
                size: 1,
                accessor: 'getPath',
                transform: extension.getLengthRatios.bind(this)
            }
        });
    }

    getStartOffsetRatios(this: Layer<_AnimatedArrowPathLayerProps>, path): number[] {
        const result = [0] as number[];
        if (path === undefined || path.length < 2) {
            return result;
        }
        const positionSize = this.props.positionFormat === 'XY' ? 2 : 3;
        const isNested = Array.isArray(path[0]);
        const geometrySize = isNested ? path.length : path.length / positionSize;
        let sumLength = 0;
        let p;
        let prevP;
        for (let i = 0; i < geometrySize; i++) {
            p = isNested ? path[i] : path.slice(i * positionSize, i * positionSize + positionSize);
            p = this.projectPosition(p);
            if (i > 0) {
                const distance = vec3.dist(prevP, p);
                if (i < geometrySize - 1) {
                    result[i] = result[i - 1] + distance;
                }
                sumLength += distance;
            }
            prevP = p;
        }
        for (let i = 0, count = result.length; i < count; i++) {
            result[i] = result[i] / sumLength;
        }
        return result;
    }

    getLengthRatios(this: Layer<AnimatedArrowPathProps>, path): number[] {
        const result = [] as number[];
        if (path === undefined || path.length < 2) {
            return result;
        }
        const positionSize = this.props.positionFormat === 'XY' ? 2 : 3;
        const isNested = Array.isArray(path[0]);
        const geometrySize = isNested ? path.length : path.length / positionSize;
        let sumLength = 0;
        let p;
        let prevP = this.projectPosition(isNested ? path[0] : path.slice(0, positionSize));
        for (let i = 1; i < geometrySize; i++) {
            p = isNested ? path[i] : path.slice(i * positionSize, i * positionSize + positionSize);
            p = this.projectPosition(p);
            const distance = vec3.dist(prevP, p);
            sumLength += distance;
            result[i - 1] = distance;
            prevP = p;
        }
        for (let i = 0, count = result.length; i < count; i++) {
            result[i] = result[i] / sumLength;
        }
        result.push(result[0]); // add last point again to make sure closed paths are handled
        return result;
    }

    draw(this: Layer<_AnimatedArrowPathLayerProps>, _params: any, _extension: this) {
        const zoom = this.context.viewport?.zoom || 14;

        // Here is a good approximation of the zoom factor, based on map/zoom theory: Math.pow(2, zoom - 14)
        // Multiplier adjusts for low zoom being too fast visually even if speed was the same.
        const multiplier = (199 - 9 * zoom) / 19; // 10.0 times slower at zoom 1, equal speed at zoom 20.
        const zoomFactor = multiplier * Math.pow(2, zoom - 14);

        // Calculate animation time with seamless reset
        // Use a cycle that matches the arrow spacing to ensure seamless looping
        const arrowSpacing = this.props.arrowSpacing || 30.0; // Configurable arrow spacing (f32)

        // Calculate cycle duration to create seamless loops
        // The cycle should complete exactly when one arrow spacing cycle finishes
        const baseCycleDuration = 90;
        const seamlessCycleDuration = baseCycleDuration * arrowSpacing; // Adjust for arrow spacing pattern

        const rawTime = (performance.now() / 100) % seamlessCycleDuration;
        const normalizedTime = rawTime / (10 * seamlessCycleDuration);

        // Scale the time to match the arrow pattern
        const seamlessTime = this.props.disableAnimation ? 1 : normalizedTime * arrowSpacing;
        const animatedArrowProps: AnimatedArrowPathProps = {
            time: seamlessTime / zoomFactor,
            arrowSpacing: arrowSpacing
        };
        (this.state.model as any)?.shaderInputs.setProps({ animatedArrowPath: animatedArrowProps });
    }

    // See https://deck.gl/docs/developer-guide/custom-layers/picking for more information about picking colors
    getShaders(this: Layer<_AnimatedArrowPathLayerProps>) {
        const inject = {
            'vs:#decl': `
                in float instanceLengthRatios;
                in float instanceStartOffsetRatios;
                out float vLengthRatio;
                out float vStartOffsetRatio;
                out float vArrowPathOffset;
            `,

            'vs:#main-end': `
                vLengthRatio = instanceLengthRatios;
                vStartOffsetRatio = instanceStartOffsetRatios;
                vArrowPathOffset += animatedArrowPath.time / width.x;
            `,

            'fs:#decl': `
                in float vArrowPathOffset;
                in float vDistanceBetweenArrows;
                in float vStartOffsetRatio;
                in float vLengthRatio;
            `,

            'fs:#main-end': `
                float percentFromCenter = abs(vPathPosition.x);
                float offset = vArrowPathOffset;
                float totalLength = vPathLength / vLengthRatio;
                float startDistance = vStartOffsetRatio * totalLength;
                float distanceSoFar = startDistance + vPathPosition.y - offset + percentFromCenter;
                float arrowIndex = mod(distanceSoFar, animatedArrowPath.arrowSpacing);
                float percentOfDistanceBetweenArrows = 1.0 - arrowIndex / animatedArrowPath.arrowSpacing;
                
                // Create white border effect on the edges
                float borderWidth = 0.3; // Adjust this value to control border thickness
                float borderFactor = smoothstep(1.0 - borderWidth, 1.0, percentFromCenter);
                
                vec3 finalColor;
                if (percentOfDistanceBetweenArrows < 0.5) {
                    float percentBlack = percentOfDistanceBetweenArrows / 0.5 * 0.5;
                    finalColor = mix(vColor.rgb, vec3(0.0), percentBlack);
                } else if (percentOfDistanceBetweenArrows < 0.75) {
                    float percentWhite = (1.0 - (percentOfDistanceBetweenArrows - 0.5) * 4.0) * 0.75;
                    finalColor = mix(vColor.rgb, vec3(1.0), percentWhite);
                } else {
                    finalColor = vColor.rgb;
                }
                
                // Apply white border with antialiasing
                finalColor = mix(finalColor, vec3(1.0), borderFactor);
                
                // Required for events to work with picking: Apply deck.gl picking color filtering
                // This ensures that clicking works properly by allowing deck.gl to render picking colors
                // when in picking mode, and our custom colors when in normal rendering mode
                fragColor = picking_filterPickingColor(vec4(finalColor, 1.0));
            `
        };
        return {
            modules: [
                {
                    name: 'animatedArrowPath',
                    vs: uniformBlock,
                    fs: uniformBlock,
                    uniformTypes: {
                        time: 'f32',
                        arrowSpacing: 'f32'
                    },
                    inject
                } as ShaderModule<any>
            ]
        };
    }
}

Path configuration in DeckGlOverlay:

new PathLayer({
                id: 'trips-animated',
                data: props.trips.features,
                antialias: true,
                highPrecision: true, // does not seem to have any effect on this bug
                getPath: (feature: GeoJSON.Feature<GeoJSON.LineString, SurveyMapTripProperties>) => {
                    return feature.geometry.coordinates as Position[];
                },
                getColor: (feature: GeoJSON.Feature<GeoJSON.LineString, SurveyMapTripProperties>) => {
                    return getTripColor(feature);
                },
                getWidth: (feature: GeoJSON.Feature<GeoJSON.LineString, SurveyMapTripProperties>) => {
                    return feature.properties.active === true ? 12 : 4;
                },
                widthUnits: 'meters',
                widthMinPixels: 20,
                widthMaxPixels: 40,
                capRounded: true,
                jointRounded: true,
                pickable: true,
                onClick: (info: PickingInfo) => {
                    if (info.object && props.onTripClick) {
                        const tripUuid = info.object.properties?.tripUuid;
                        if (tripUuid) {
                            props.onTripClick(tripUuid);
                        }
                    }
                },
                extensions: [new AnimatedArrowPathExtension()],
                updateTriggers: {
                    getPath: [props.activeTripUuid],
                    getColor: [props.activeTripUuid],
                    getWidth: [props.activeTripUuid]
                }
            })

Environment

  • Framework version: @deck.gl v9.1.14 / maplibre-gl v5.7.1
  • Browser: Firefox or Chrome (same issue)
  • OS: macos

Logs

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions