Deck.gl自定义图层难吗?Shader代码怎么写?

GIS基础理论
Dr.GIS
wowwwai GIS研习社 · 工具流程与项目排障

引言:很多做 WebGIS 可视化的同学都会问:Deck.gl自定义图层难吗?Shader代码怎么写? 如果只是调用 ScatterplotLayer、GeoJsonLayer 这类内置图层,deck.gl 上手并不难;真正卡住人的,通常是业务需要“内置图层做不到”的效果,比如按像元值渲染网格、做动态流线、显示自定义符号、在地图上叠加特殊热力效果。这时就会接触到 deck.gl 自定义图层和 Shader 代码。

本文以 GIS 场景为主,讲清楚 deck.gl 自定义图层的基本思路、Shader 代码应该写在哪里、如何把地理坐标传到 GPU,以及初学者最容易踩的坑。你不需要先成为图形学专家,但需要理解 WebGL 渲染管线里几个核心概念。

deck.gl自定义图层 Shader代码怎么写 WebGIS渲染流程示意图
deck.gl 自定义图层的核心流程:数据进入 Layer,属性传给 GPU,Shader 负责顶点位置和像素颜色。

背景:为什么 GIS 项目会需要 deck.gl 自定义图层

deck.gl 是 Uber 开源的 WebGL 可视化框架,常用于海量点、线、面、轨迹、网格和三维地理数据渲染。对于一般 GIS 项目,内置图层已经能解决很多问题,例如:

  • GeoJsonLayer 渲染行政区、道路、地块。
  • ScatterplotLayer 渲染 POI、监测站、车辆位置。
  • PathLayer 渲染轨迹、管线、河流。
  • HeatmapLayer 做点密度热力图。
  • TileLayer 加载切片服务或栅格瓦片。

但当需求变得更具体时,内置图层可能不够用。例如:

  • 要根据风速风向绘制动态流场,而不是普通线段。
  • 要让每个地块边界出现自定义闪烁扫描效果。
  • 要在地图上渲染自定义网格,每个网格颜色由业务公式实时计算。
  • 要实现特定行业符号,比如雷达覆盖扇区、通信基站方向瓣、污染扩散范围。
  • 要把外部模型、纹理、数据字段和地图坐标结合起来渲染。

这些需求通常就需要 deck.gl 自定义图层。自定义图层的本质,是你自己定义数据如何变成 GPU 可以绘制的几何体,并用 Shader 控制最终显示效果。

原理:deck.gl 自定义图层和 Shader 到底是什么关系

理解 deck.gl 自定义图层,先记住一句话:Layer 管数据和生命周期,Shader 管 GPU 上怎么画。

在 deck.gl 中,一个图层通常包含以下几部分:

  • props:图层参数,例如数据源、颜色字段、大小字段、透明度等。
  • state:图层内部状态,例如 WebGL 模型、属性管理器、纹理等。
  • attributes:传给 GPU 的顶点属性,例如位置、颜色、半径、索引等。
  • vertex shader:顶点着色器,主要决定每个顶点在屏幕上的位置。
  • fragment shader:片元着色器,主要决定每个像素最终显示什么颜色。

如果用 GIS 语言来理解,deck.gl 自定义图层做了三件事:

  1. 把地理数据,例如经纬度、线坐标、面坐标,整理成 GPU 能读的数组。
  2. 通过 deck.gl 提供的投影函数,把经纬度转换到屏幕坐标或裁剪空间。
  3. 通过 Shader 控制图形的位置、颜色、透明度、纹理和动态效果。

初学者最容易误解的一点是:Shader 不是用来读取 GeoJSON 的。GeoJSON、接口数据、业务字段应该在 JavaScript 层解析,Shader 只处理已经传入 GPU 的数值属性。

步骤:从一个最小 deck.gl 自定义图层开始

下面用一个简化示例说明如何写 deck.gl 自定义图层。这个图层的目标很简单:传入一组经纬度点,在地图上绘制固定大小的彩色点。实际项目中,你可以在这个基础上扩展为网格、符号、流场或行业专题图层。

1. 准备数据结构

假设你的 GIS 点数据已经转换为 JavaScript 数组:

const points = [
  {position: [116.391, 39.907], value: 10},
  {position: [121.473, 31.230], value: 30},
  {position: [113.264, 23.129], value: 60}
];

这里的 position 是经纬度坐标,value 是业务字段。真实项目中,它可以来自 GeoJSON、PostGIS 接口、矢量瓦片解析结果或实时数据流。

2. 定义自定义 Layer 类

deck.gl 自定义图层通常继承 Layer。下面是一个最小结构:

import {Layer} from '@deck.gl/core';
import {Model, Geometry} from '@luma.gl/engine';

const vertexShader = `
#define SHADER_NAME custom-point-layer-vertex-shader

attribute vec2 positions;
attribute float values;

uniform float pointSize;

varying float vValue;

void main(void) {
  vec3 positionCommon = project_position(vec3(positions, 0.0));
  vec4 positionClipspace = project_common_position_to_clipspace(positionCommon);

  gl_Position = positionClipspace;
  gl_PointSize = pointSize;
  vValue = values;
}
`;

const fragmentShader = `
#define SHADER_NAME custom-point-layer-fragment-shader

precision highp float;

varying float vValue;

void main(void) {
  float t = clamp(vValue / 100.0, 0.0, 1.0);
  vec3 color = mix(vec3(0.0, 0.4, 1.0), vec3(1.0, 0.1, 0.0), t);

  float dist = distance(gl_PointCoord, vec2(0.5));
  if (dist > 0.5) {
    discard;
  }

  gl_FragColor = vec4(color, 0.85);
}
`;

export default class CustomPointLayer extends Layer {
  initializeState() {
    const attributeManager = this.getAttributeManager();

    attributeManager.add({
      positions: {
        size: 2,
        accessor: 'getPosition'
      },
      values: {
        size: 1,
        accessor: 'getValue'
      }
    });

    this.setState({model: this._getModel()});
  }

  _getModel() {
    const gl = this.context.gl;

    return new Model(gl, {
      id: this.props.id,
      vs: vertexShader,
      fs: fragmentShader,
      geometry: new Geometry({
        drawMode: gl.POINTS,
        vertexCount: this.props.data.length
      }),
      modules: ['project32']
    });
  }

  draw({uniforms}) {
    const {model} = this.state;
    const {pointSize = 12} = this.props;

    model.setUniforms({
      ...uniforms,
      pointSize
    });

    model.draw();
  }
}

CustomPointLayer.layerName = 'CustomPointLayer';

CustomPointLayer.defaultProps = {
  getPosition: {type: 'accessor', value: d => d.position},
  getValue: {type: 'accessor', value: d => d.value},
  pointSize: 12
};

这段代码展示了 deck.gl 自定义图层最核心的结构:initializeState 里创建属性和模型,draw 里把参数传给 Shader 并执行绘制。

3. 在 Deck 中使用自定义图层

写好自定义图层后,使用方式和内置图层类似:

import {Deck} from '@deck.gl/core';
import {MapView} from '@deck.gl/core';
import CustomPointLayer from './CustomPointLayer';

const deck = new Deck({
  initialViewState: {
    longitude: 116.391,
    latitude: 39.907,
    zoom: 4,
    pitch: 0,
    bearing: 0
  },
  controller: true,
  views: new MapView(),
  layers: [
    new CustomPointLayer({
      id: 'custom-point-layer',
      data: points,
      getPosition: d => d.position,
      getValue: d => d.value,
      pointSize: 16
    })
  ]
});

如果你已经在项目中使用 Mapbox、MapLibre、React 或 Vue,思路是一样的:把自定义 Layer 放到 layers 数组里,让 deck.gl 负责统一管理视图状态和渲染。

步骤:Shader 代码怎么写才不容易出错

1. vertex shader 负责坐标位置

在 GIS 场景中,vertex shader 最关键的问题是坐标转换。经纬度不能直接当成屏幕像素使用,必须经过 deck.gl 的投影模块处理。

常见写法是:

vec3 positionCommon = project_position(vec3(positions, 0.0));
vec4 positionClipspace = project_common_position_to_clipspace(positionCommon);
gl_Position = positionClipspace;

这里的 project_positionproject_common_position_to_clipspace 来自 deck.gl 的投影模块。它们帮助你把地理坐标转换到 WebGL 裁剪空间。否则图形可能完全不显示,或者显示在错误位置。

2. fragment shader 负责颜色和像素效果

fragment shader 控制每个像素的颜色。比如根据业务值生成蓝到红的渐变:

float t = clamp(vValue / 100.0, 0.0, 1.0);
vec3 color = mix(vec3(0.0, 0.4, 1.0), vec3(1.0, 0.1, 0.0), t);
gl_FragColor = vec4(color, 0.85);

如果绘制点符号,还可以利用 gl_PointCoord 把默认方形点裁剪成圆形:

float dist = distance(gl_PointCoord, vec2(0.5));
if (dist > 0.5) {
  discard;
}

discard 表示丢弃当前像素。这个技巧常用于圆点、光晕、雷达圈、渐变符号等效果。

3. attribute、uniform、varying 分别做什么

类型 作用 GIS 示例
attribute 每个要素或顶点不同的数据 坐标、数值、分类编码、半径
uniform 整次绘制共享的参数 透明度、统一缩放系数、时间变量、色带范围
varying 从 vertex shader 传到 fragment shader 的中间变量 把业务值传给片元着色器用于配色

一个实用判断方法是:如果每个点的值不同,用 attribute;如果整个图层共用一个值,用 uniform;如果 vertex shader 算出来的东西还要给 fragment shader 用,用 varying

常见坑:deck.gl 自定义图层为什么不显示

1. 坐标顺序写反

WebGIS 中最常见的错误是把 [longitude, latitude] 写成 [latitude, longitude]。deck.gl 默认地理坐标一般使用经纬度顺序,也就是:

[116.391, 39.907]

不是:

[39.907, 116.391]

如果坐标顺序反了,图层可能跑到海外、极区,或者完全看不到。

2. 没有使用 deck.gl 投影模块

如果 vertex shader 直接写:

gl_Position = vec4(positions, 0.0, 1.0);

这通常是错误的。经纬度不是 WebGL 裁剪空间坐标。GIS 自定义图层应使用 deck.gl 提供的投影函数,并确保模型加载了对应模块。

3. attribute size 和数据不匹配

如果坐标是二维经纬度,positionssize 通常是 2;如果带高程,可能是 3。size 写错会导致 GPU 读取数据错位,表现为图形位置异常、颜色异常或直接不显示。

4. drawMode 和几何结构不匹配

如果你要画点,用 gl.POINTS;要画线,可能需要 gl.LINES 或把线转换成三角形带;要画面,通常需要三角剖分后用 gl.TRIANGLES

很多 GIS 面数据不能直接丢给 Shader 画。Polygon 通常需要先转成三角网格,尤其是带洞、多部件、多环的面。

5. 透明度和深度测试导致结果被遮挡

三维场景或多图层叠加时,如果打开深度测试,后绘制的图层不一定覆盖前面的图层。透明对象也可能出现排序问题。遇到图层忽隐忽现,可以检查:

  • 是否需要关闭 depth test。
  • 图层绘制顺序是否正确。
  • 透明度是否过低。
  • 是否被底图或三维对象遮挡。

方法比较:内置图层、CompositeLayer 和自定义 Shader 怎么选

方法 适合场景 优点 限制
内置图层 常规点线面、热力、路径、瓦片 开发快、稳定、文档多 样式和渲染逻辑受限制
CompositeLayer 组合多个现有图层形成业务图层 不必直接写 Shader,适合封装业务 底层效果仍受内置图层限制
继承 Layer 写自定义 Shader 特殊符号、动态效果、自定义网格、行业可视化 自由度最高,性能潜力大 需要理解 WebGL、Shader 和 deck.gl 生命周期
外部 WebGL 引擎混合 复杂三维模型、粒子系统、仿真动画 能力更强 和地图相机、坐标系统集成更复杂

实际项目中,不建议一开始就写自定义 Shader。更稳妥的路线是:

  1. 先确认内置图层能否满足 80% 需求。
  2. 如果只是组合多个图层,优先写 CompositeLayer
  3. 如果必须控制 GPU 渲染细节,再继承 Layer 写 Shader。
  4. 如果涉及大量粒子、三维模型或仿真,再考虑更底层的 WebGL 或 three.js 集成。

检查清单:写 deck.gl 自定义图层前先确认这些问题

  • 数据类型:是点、线、面、网格、瓦片,还是实时流数据?
  • 坐标系统:输入数据是否为 WGS84 经纬度?是否需要从其他坐标系转换?
  • 几何结构:GPU 最终绘制的是点、线,还是三角形?
  • 字段映射:哪些字段需要作为 attribute?哪些参数适合作为 uniform?
  • 投影处理:vertex shader 是否使用了 deck.gl 的投影函数?
  • 更新频率:数据是静态、定时刷新,还是每帧变化?
  • 性能瓶颈:瓶颈在数据解析、网络传输、CPU 计算,还是 GPU 绘制?
  • 调试方式:是否准备了最小数据集,用 3 到 10 个要素先验证显示结果?
  • 降级方案:低端显卡或移动端是否需要关闭部分特效?

FAQ:关于 deck.gl 自定义图层和 Shader 的常见问题

Q1:deck.gl 自定义图层难吗?

如果目标只是封装现有图层,不难,可以用 CompositeLayer。如果要自己写 Shader,难点主要在 WebGL 基础、坐标投影、GPU 数据组织和调试方式。对 GIS 开发者来说,最重要的是先弄清楚数据如何从经纬度变成屏幕上的图形。

Q2:Shader 代码必须从零写吗?

不一定。很多情况下可以参考 deck.gl 官方图层源码,复用已有模块和写法。建议从最简单的点图层开始,只写位置和颜色,然后逐步增加大小、透明度、纹理、动画时间等参数。

Q3:GeoJSON 能不能直接传进 Shader?

不能直接传。Shader 处理的是数值数组、纹理和 uniform 参数。GeoJSON 应该先在 JavaScript 层解析为坐标、索引、属性数组,再通过 deck.gl 的 attribute 机制传给 GPU。

Q4:为什么我的自定义图层在地图缩放后位置不对?

通常是坐标投影处理不正确,或者没有使用 deck.gl 的投影函数。GIS 图层必须随着视图状态变化重新计算屏幕位置,不能把经纬度当作固定像素坐标使用。

Q5:自定义图层能渲染 PostGIS 查询结果吗?

可以。PostGIS 查询结果通常通过接口返回 GeoJSON、MVT 或自定义 JSON。前端拿到数据后,需要转换成自定义图层需要的数组结构,例如坐标数组、属性数组和索引数组。Shader 不关心数据来自 PostGIS、文件还是实时接口,只关心传入 GPU 的数值。

Q6:deck.gl 自定义图层适合做大数据量 GIS 可视化吗?

适合,但前提是数据组织合理。大数据量场景要重点考虑切片、分层加载、二进制数据传输、属性压缩和视野裁剪。不要把所有 GeoJSON 一次性加载到浏览器后再指望 Shader 解决全部性能问题。

结论:先用内置图层验证,再用 Shader 解决特殊渲染问题

Deck.gl自定义图层难吗?Shader代码怎么写? 这个问题的答案是:入门不算难,但不能跳过基本原理。对于 GIS 读者来说,关键不是背 WebGL 语法,而是理解数据、坐标、属性和 Shader 之间的关系。

如果你的需求只是普通点线面渲染,优先使用 deck.gl 内置图层;如果是多个图层组合,优先使用 CompositeLayer;只有当业务效果确实需要控制 GPU 渲染细节时,再写真正的 deck.gl 自定义图层。

实践建议是:先用 3 个点写通最小图层,再加字段配色,再加动态参数,最后再处理真实 GIS 数据。这样排错成本最低,也更容易把 Shader 代码写得稳定、可维护。