CesiumJS PrimitiveAPI 高级着色入门 - 从参数化几何与 Fabric 材质到着色器 - 上篇

时间:2023-02-12 16:05:36


Primitive API 还包括 Appearance APIGeometry API 两个主要部分,是 CesiumJS 挡在原生 WebGL 接口之前的最底层图形封装接口(公开的),不公开的最底层接口是 DrawCommand 为主的 Renderer API,DC 对实时渲染管线的技术要求略高,可定制性也高,这篇还是以 Primitive API 为侧重点。

0.1. 坐标系基础

这里的“坐标系”特指 WebGL 图形渲染的坐标系。Primitive API 收到的几何数据,默认没有任何坐标系(即最基本的空间直角坐标),想要移动到地表感兴趣的地方,需要借助 ENU 转换矩阵,或者把几何顶点的坐标直接设为 EPSG:4978 坐标(即所谓通俗的“世界坐标”)。

ENU 转换矩阵,用道家八卦的说法类似“定中宫”。它能将坐标转换到这样一个 ENU 地表局部坐标系上:

  • 指定一处地表点(经纬度)为坐标原点

  • 以贴地正东方(ENU 中的 E)为正 X 轴

  • 以贴地正北方(ENU 中的 N)为正 Y 轴

  • 以地心到坐标原点的方向(即 ENU 中的 U,up)为正 Z 轴

这样一个 ENU 坐标系上的局部坐标左乘 ENU 转换矩阵后,就能得到标准的 EPSG:4978 世界坐标。

CesiumJS PrimitiveAPI 高级着色入门 - 从参数化几何与 Fabric 材质到着色器 - 上篇

GIS 中的投影坐标、经纬坐标不太适用,需要转换。

0.2. 合并批次

虽然 WebGL 支持实例绘制技术,但是 Primitive API 减少绘制调用并不是通过这个思路来的,而是尽可能地把 Vertex 数据合并,这个叫做 Batch,也就是“合并批次(并批)”。

在 CesiumJS 的 API 文档中能看到 new Primitive() 时,可以传递一个 GeometryInstance 或者 GeometryInstance 数组,而 GeometryInstance 对象又能复用具体的某个Geometry 对象,仅在几何的变换位置(通过矩阵表达)、顶点属性(Vertex Attribute)上做差异化。

CesiumJS 会在 WebWorker 中异步地拼装这些几何数据,尽可能一次性发送给底层的 Renderer,以达到尽可能少的 DC。

我没有十分精确地去确认这个并批的概念和 CesiumJS 源码中合并的过程,如有错误请指出。

1. 参数化几何

这是公开 API 的最常规用法了,你可以在官方指引文档中学习如何使用参数化几何来创建内置的几何对象:Custom Geometry and Appearance

1.1. 几何类清单

CesiumJS 内置的参数几何有如下数种:

  • 立方体(盒) - BoxGeometry & BoxOutlineGeometry

  • 矩形 - RectangleGeometry & RectangleOutlineGeometry

  • 圆形 - CircleGeometry & CircleOutlineGeometry

  • 线的缓冲区(可设定转角类型和挤出高度) - CorridorGeometry & CorridorOutlineGeometry

  • 圆柱、圆台、圆锥 - CylinderGeometry & CylinderOutlineGeometry

  • 椭圆、椭圆柱 - EllipseGeometry & EllipseOutlineGeometry

  • 椭球面 - EllipsoidGeometry & EllipsoidOutlineGeometry

  • 多边形(可挤出高度) - PolygonGeometry & PolygonOutlineGeometry

  • 多段线 - PolylineGeometry & SimplePolylineGeometry

  • 多段线等径柱体 - PolylineVolumeGeometry & PolylineVolumeOutlineGeometry

  • 球面 - SphereGeometry & SphereOutlineGeometry

  • 墙体 - WallGeometry & WallOutlineGeometry

  • 四棱台(视锥截头体) - FrustumGeometry & FrustumOutlineGeometry

  • 平面 - PlaneGeometry & PlaneOutlineGeometry

  • 共面多边形 - CoplanarPolygonGeometry & CoplanarPolygonOutlineGeometry

  • Esri I3S 专用的几何 - I3SGeometry

这里有两个特别说明:

  • 除了 I3SGeometry 比较特殊外,其它的几何对象都有其对应的边线几何对象(边线不是三角网格)

  • CoplanarPolygonGeometryPolygonGeometry 两个 API 很像,但是前者是 2018 年 1.48 后来添加的 API,适用于顶点共面的多边形;不共面的顶点在 PolygonGeometry 中可能会引起崩溃,但在这个共面多边形 API 不会(尽管可能会产生一些不可预测的三角形)。在 PolygonGeometry 出现三角形显示不正常、不完整的情况,可考虑用这个共面多边形 API;也支持挖洞。

可见 CesiumJS 对参数几何的支持是比较丰富的。

1.2. 举例

以下即两个椭球体的实例绘制示例代码:

import {
  EllipsoidGeometry,
  GeometryInstance,
  Matrix4,
  Cartesian3,
  Transforms,
  PerInstanceColorAppearance,
  Color,
  ColorGeometryInstanceAttribute,
  Primitive,
} from 'cesium'


// 只创建一个椭球体几何对象,下面会复用
const ellipsoidGeometry = new EllipsoidGeometry({
  vertexFormat: PerInstanceColorAppearance.VERTEX_FORMAT,
  radii: new Cartesian3(300000.0, 200000.0, 150000.0),
})

// 亮蓝色椭球体绘制实例
const cyanEllipsoidInstance = new GeometryInstance({
  geometry: ellipsoidGeometry,
  modelMatrix: Matrix4.multiplyByTranslation(
    Transforms.eastNorthUpToFixedFrame(
      Cartesian3.fromDegrees(-100.0, 40.0)
    ),
    new Cartesian3(0.0, 0.0, 150000.0),
    new Matrix4()
  ),
  attributes: {
    color: ColorGeometryInstanceAttribute.fromColor(Color.CYAN),
  },
})

// 橙色椭球体绘制实例
const orangeEllipsoidInstance = new GeometryInstance({
  geometry: ellipsoidGeometry,
  modelMatrix: Matrix4.multiplyByTranslation(
    Transforms.eastNorthUpToFixedFrame(
      Cartesian3.fromDegrees(-100.0, 40.0)
    ),
    new Cartesian3(0.0, 0.0, 450000.0),
    new Matrix4()
  ),
  attributes: {
    color: ColorGeometryInstanceAttribute.fromColor(Color.ORANGE),
  },
})

scene.primitives.add(
  new Primitive({
    geometryInstances: [cyanEllipsoidInstance, orangeEllipsoidInstance],
    appearance: new PerInstanceColorAppearance({
      translucent: false,
      closed: true,
    }),
  })
)

代码就不详细解释了,需要有一定的 WebGL 基础,否则对 vertexFormatattributes 等字段会有些陌生。

如下图所示:

CesiumJS PrimitiveAPI 高级着色入门 - 从参数化几何与 Fabric 材质到着色器 - 上篇

1.3. 纯手搓几何

CesiumJS 的封装能力和 API 设计能力可谓一绝,它给开发者留下了非常多层级的调用方法。除了 1.1、1.2 提到的内置几何体,假如你对 WebGL 的数据格式(VertexBuffer)能熟练应用的话,你可以使用 Geometry + GeometryAttribute 类自己创建几何体对象,查阅 Geometry 的文档,它提供了一个很简单的例子:

import { Geometry, GeometryAttribute, ComponentDatatype, PrimitiveType, BoundingSphere } from 'cesium'

const positions = new Float64Array([
  0.0, 0.0, 0.0,
  7500000.0, 0.0, 0.0,
  0.0, 7500000.0, 0.0
])

const geometry = new Geometry({
  attributes: {
    position: new GeometryAttribute({
      componentDatatype: ComponentDatatype.DOUBLE,
      componentsPerAttribute: 3,
      values: positions
    })
  },
  indices: new Uint16Array([0, 1, 1, 2, 2, 0]),
  primitiveType: PrimitiveType.LINES,
  boundingSphere: BoundingSphere.fromVertices(positions)
})

然后就可以继续创建 GeometryInstance,搭配外观、材质对象创建 Primitive 了。

这一个属于高阶用法,适用于有自定义二进制 3D 数据格式能力的读者。

这一步还没有触及 CesiumJS 的最底层,挡在 WebGL 之前的是一层非公开的 API,叫 DrawCommand,有兴趣可以自己研究。

1.4. *子线程异步生成几何

有部分参数化几何对象经过一系列逻辑运送后,是要在 WebWorker 内三角化、生成顶点缓冲的。

这小节内容比较接近源码解析,不会讲太详细。从 Primitive.prototype.update 方法中模块内函数 loadAsynchronous 看起:

Primitive.prototype.update = function (frameState) {
  /* ... */
  if (
    this._state !== PrimitiveState.COMPLETE &&
    this._state !== PrimitiveState.COMBINED
  ) {
    if (this.asynchronous) {
      loadAsynchronous(this, frameState);
    } else { /* ... */ }
  }
  /* ... */
}

在这个 loadAsynchronous 函数内,会调度一些 TaskProcessor 对象,这些 TaskProcessor 会通过 WebWorker 的消息传递来完成 Geometry 的 Vertex 创建。这个过程很复杂,就不展开了。

如果你感兴趣,打开浏览器的开发者工具,在 “源代码” 选项卡左侧的“页面”中,能看到一堆 “cesiumWorkerBootstrapper” 在运行。每一个,背后都是一个内嵌的 requirejs 在调度额外的异步模块,这些异步模块在默默地为主页面生成数据。

2. 使用材质

这一节讲 Primitive API 配套的第二个大类,Appearance + Material API,也叫外观材质 API,它允许开发者为自己的 Primitive 编写着色器。

2.1. 外观 API

CesiumJS 提供了如下几个具体的 Appearance 类:

  • MaterialAppearance - 材质外观,通用型,适用于第 1 节中大部分 Geometry

  • EllipsoidSurfaceAppearance - 上一个的子类,允许用在椭球面上的一些几何,例如 Polygon、Rectangle 等几何类型,这个外观类使用算法来表达部分顶点属性以节约数据大小

  • PerInstanceColorAppearance - 如果每个 GeometryInstance 用的是单独的颜色,可以用这个外观类,在 1.2 的例子中就用到这个类

  • PolylineMaterialAppearance - 使用材质(下一小节)来给有宽度的折线着色

  • PolylineColorAppearance - 使用逐顶点或逐线段来给有宽度的折线着色

外观类有一个抽象父类 Appearance(JavaScript 中没有抽象类,CesiumJS 也没有继承,大致意思,理解记可),上述 5 个均为它的实现类。

通常,为 Primitive 几何着色的主要职责在材质类,但是即使没有材质类,完全通过 GLSL 代码,设定外观类的顶点着色器和片元着色器(当然,要合规)也是可以完成渲染的。

下面就演示一下用 MaterialAppearance 与着色器代码实现立方体几何对象(BoxGeometry)的着色案例:

import {
  MaterialAppearance,
  Material,
  BoxGeometry,
  Matrix4,
  Cartesian3,
  Transforms,
  GeometryInstance,
  Primitive,
  VertexFormat,
} from 'cesium'

const scene = viewer.scene

// 创建 ENU 转换矩阵后,再基于 ENU 转换矩阵作 Z 轴平移 500000 * 0.5 个单位
const boxModelMatrix = Matrix4.multiplyByTranslation(
  Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(112.0, 23.0)),
  new Cartesian3(0.0, 0.0, 500000 * 0.5),
  new Matrix4()
)
// 创建 Geometry 和 Instance
const boxGeometry = BoxGeometry.fromDimensions({
  vertexFormat: VertexFormat.POSITION_NORMAL_AND_ST, // 注意这里,下面要细说
  dimensions: new Cartesian3(400000.0, 300000.0, 500000.0),
})
const boxGeometryInstance = new GeometryInstance({
  geometry: boxGeometry,
  modelMatrix: boxModelMatrix, // 应用 ENU + 平移矩阵
})

// 准备 fabric shader 材质和外观对象
const shader = `czm_material czm_getMaterial(czm_materialInput materialInput) {
  czm_material material = czm_getDefaultMaterial(materialInput);
  material.diffuse = vec3(0.8, 0.2, 0.1);
  material.specular = 3.0;
  material.shininess = 0.8;
  material.alpha = 0.6;
  return material;
}`
const appearance = new MaterialAppearance({
  material: new Material({
    fabric: {
      source: shader
    }
  }),
})

scene.primitives.add(
  new Primitive({
    geometryInstances: boxGeometryInstance,
    appearance: appearance,
  })
)

然后你就能获得一个 blingbling 的立方块:

CesiumJS PrimitiveAPI 高级着色入门 - 从参数化几何与 Fabric 材质到着色器 - 上篇

注意我在创建 BoxGeometry 时,留了一行注释:

vertexFormat: VertexFormat.POSITION_NORMAL_AND_ST,

使用 WebGL 原生接口的朋友应该知道这个,这个 VertexFormat 是指定要为参数几何体生成什么 顶点属性(VertexAttribute)。这里指定的是 POSITION_NORMAL_AND_ST,即生成的 VertexBuffer 中会包含顶点的坐标、法线、纹理坐标三个顶点属性。CesiumJS 的教程资料上说过,这个顶点格式参数,几何和外观对象要一一匹配才能兼容。

默认的,所有的 Geometry 对象都不需要传递这个,默认都是 VertexFormat.DEFAULT,也即 VertexFormat.POSITION_NORMAL_AND_ST。不妨设置成这个 POSITION_AND_NORMAL

vertexFormat: VertexFormat.POSITION_AND_NORMAL,

虽然法线影响光照,但是这里只是缺少了纹理坐标,盒子就没有 blingbling 的效果了:

CesiumJS PrimitiveAPI 高级着色入门 - 从参数化几何与 Fabric 材质到着色器 - 上篇

具体的着色逻辑不深究,但是足够说明问题:这个 vertexFormat 会影响几何体的着色效果。

还有一个与外观有关的参数,那就是 new Primitive 时的构造参数 compressVertices,这个值默认是 true,即会根据几何体的 vertexFormat 参数来决定是否压缩 VertexBuffer。

如果设为:

// ...
const boxGeometry = BoxGeometry.fromDimensions({
  vertexFormat: VertexFormat.POSITION_AND_NORMAL,
  dimensions: new Cartesian3(400000.0, 300000.0, 500000.0),
})

// ...

new Primitive({
  geometryInstances: boxGeometryInstance,
  appearance: appearance,
  compressVertices: false
})

即不压缩顶点缓冲,但是 vertexFormat 设置的格式缺少了其中某一个,比如这里就缺少了纹理坐标,那么就会出现顶点缓冲和顶点格式不匹配的情况,会出现报错:

CesiumJS PrimitiveAPI 高级着色入门 - 从参数化几何与 Fabric 材质到着色器 - 上篇

通常,使用 MaterialAppearance 能搭配大多数几何类了,也可以自己使用 Geometry + GeometryAttribute 这两个最基础的类创建出自定义的 Geometry,搭配使用。

只有极少数的情况,需要去动外观对象的两个着色器,这里先不展开,高阶用法会在第 3 节讲解。

2.2. 材质 API

CesiumJS 有自己的材质规则,叫做 Fabric 材质,全文参考文档 Fabric,在 2.3、2.4 小节会展开。

先看看直接实例化的参数。使用 new Material({}) 创建一个材质对象,除了 fabric 参数外,还需要这几个参数(有些是可选的):

  • strict: boolean,默认 false,即是否严格检查材质与 uniform、嵌套材质的匹配问题

  • translucent: boolean | (m: Material) => boolean,默认 true,为真则使用此材质的几何体允许有半透明

  • minificationFilter: TextureMinificationFilter,默认 TextureMinificationFilter.LINEAR,采样参数

  • magnificationFilter: TextureMagnificationFilter,默认 TextureMagnificationFilter.LINEAR,采样参数

fabric 参数,则是 Fabric 材质的全部内容,如果不使用内置材质类型要自己写材质的话,就需要认真研究这个 fabric 对象的参数规则了。

2.3. Fabric 材质初步 - 内置材质、材质缓存与 uniform

如几何、外观 API 一样,Material 类也给予了开发者一定的内置材质,略像简单工厂模式。只需要使用 Material.fromType() 就可以使用内置的十几种写好着色器的材质。

内置材质也是通过正经的 Fabric 对象创建的,有兴趣的可以看源码,所以内置材质也归为 Fabric 内容

列举几种基础材质和几种常见材质:

  • 常见材质 Material.fromType('Color') - 纯颜色

  • 常见材质 Material.fromType('Image') - 普通贴图

  • 基础材质 Material.fromType('DiffuseMap') - 漫反射贴图

  • 基础材质 Material.fromType('NormalMap') - 法线贴图

  • 基础材质 Material.fromType('SpecularMap') - 高光贴图

  • ...

具体的可以查看 Material 类的 API 文档,文档页面的最顶部就列举了若干种 type 对应的内置材质。fromType() 方法还可以传递第二个参数,第二个参数是这个材质所需要的 uniforms,会应用到着色器对应的 uniform 变量上。

例如,文档中对透明度贴图的 uniform 描述是这样的:

CesiumJS PrimitiveAPI 高级着色入门 - 从参数化几何与 Fabric 材质到着色器 - 上篇

你就可以通过传递这些 uniform 值,来决定着色器使用传入的 image 的哪个 channel,以及要 repeat 的程度:

const alphaMapMaterial = Material.fromType('AlphaMap', {
  image: '相对于网页运行时的图片路径;网络地址绝对路径;base64图片', // 对多种图片地址有兼容
  channel: 'a', // 使用图片的 alpha 通道,根据图片的通道数量来填写 glsl 的值,可以是 r、g、b、a 等
  repeat: {
    x: 1,
    y: 1, // 透明度贴图在 x、y 方向的重复次数  
  }
})

当然,Material 类也可以自己创建材质对象,分缓存和一次性使用两种创建方法。

new Material({
  fabric: {
    type: 'MyOwnMaterial',
    // fabric 材质对象的其它参数  
  }
  // ... 其它参数
})
// 缓存后就可以这样用:
Material.fromType('MyOwnMaterial', /* uniforms */)

new Material({
  fabric: {
    // fabric 材质对象的其它参数  
  }
  // ... 其它参数
})

区别就在 fabric.type 参数,只要有 fabric.type,第一次创建就会缓存这个 fabric 材质,第二次就可以使用 fromType() 来访问缓存的材质了,并且不再需要传递完整的 fabric 对象,只需传递 type 和新的 uniforms 参数(如果需要更新)即可。

如果不传递 fabric.type 参数,那么创建的材质对象只能在生命周期内使用,CesiumJS 不会缓存,适合一次性使用。

创建好材质对象后,可以直接修改 uniform 的值完成动态更新效果,例如:

// 赋予一个新材质
primitive.appearance.material = Material.fromType('Image')
// 在某一处动态更新贴图
primitive.appearance.material.uniforms.image = '新贴图的地址'

2.4. Fabric 材质中级(GLSL表达式、嵌套材质)

Fabric 材质规范允许在创建材质对象时,使用更细致的规则。当然可以使用完整的着色器函数代码,但是为了简单易用,CesiumJS 在“完整着色器函数”和“JavaScript API” 之间还设计了一层“GLSL表达式”来定制各个 成分组件(components,下文简称成分)

举例:

new Material({
  fabric: {
    type: 'MyComponentsMaterial',
    components: {
      diffuse: 'vec3(1.0, 0.0, 0.0)',
      specular: '0.1',
      alpha: '0.6',
    }  
  }
})

从这个 components 对象可以看出,这一个材质对象设定了三个成分:

  • diffuse,漫反射颜色,设为了 GLSL 表达式 vec3(1.0, 0.0, 0.0),即纯红色

  • specular,高光强度,设为了 0.1

  • alpha,透明度,设为了 0.6

这些都会合成到完整的着色器代码的对应分量上。

那么,这个 components 对象允许拥有哪些成分呢?这受限制于内置的 GLSL 结构体的成员:

struct czm_material {
  vec3 diffuse;
  float specular;
  float shininess;
  vec3 normal;
  vec3 emission;
  float alpha;
}

也就是说,diffuse(漫反射颜色)、specular(高光强度)、shininess(镜面反射强度)、normal(相机或眼坐标中的法线)、emission(自发光颜色)、alpha(透明度)这 6 个都可以出现在 components 对象中,其值是字符串,必须是可以赋予给 GLSL 结构体对应成员的表达式。

什么意思呢?除了上面的举例 diffuse: 'vec3(1.0, 0.0, 0.0)' 外,任意的 GLSL 内置类型、内置函数均可使用,只要是表达式均可,例如 mixcossintantexture2D(GLSL100)、texture(GLSL300)。

举例,如果你在 uniforms 中传递了一个自定义的 image 作为纹理,那么你可以在 components.diffuse 中调用 texture2D 函数对这个 image 变量进行纹理采样:

const someMaterialFabric = {
  type: 'OurDiffuseMap',
  uniforms: {
    image: 'czm_defaultImage' // 'czm_defaultImage' 是一个内置的 1x1 贴图
  },
  components: {
    diffuse: 'texture2D(image, materialInput.st).rgb'
  }
}

其中,texture(image, materialInput.st).rgbimage 就是 uniforms.imagematerialInput.st 是来自输入变量 materialInput 的纹理坐标。至于 materialInput,之后讲解 fabric.source 完整版着色器代码的用法时会介绍。

我觉得如果要写一些更复杂的表达式,不如直接进阶用法,写完整的着色器更灵活,components 适合最简单的表达式。

fabric 对象上已经介绍了 3 个成员了,即 fabric.typefabric.uniformsfabric.components,那么现在介绍第四个 —— 允许材质组合的 fabric.materials 成员。

幸运的是,官方的文档有举简单的例子,我就直接抄过来说明了:

const combineFabric = {
  type: 'MyCombineMaterial',
  materials: {
    diffuseMaterial: {
      type: 'DiffuseMap'
    },
    specularMaterial: {
      type: 'SpecularMap'
    }
  },
  components: {
    diffuse: 'diffuseMaterial.diffuse',
    specular: 'specularMaterial.specular'
  }
}

materials 中定义的两个子材质 diffuseMaterialspecularMaterial 也是满足 Fabric 规范的,这里直接用了两个内置材质(漫反射贴图材质、高光贴图材质)。定义在 materials 中,然后在 components 和将来要介绍的 fabric.source 着色器完整代码中都能用了。

例如,这里的 components.diffuse 设为了 diffuseMaterial.diffuse,实际上 diffuseMaterial 就是一个 CesiumJS 内置的 GLSL 结构体变量,在上文提过,结构体为 czm_material

子材质的 uniforms 也和普通材质的一样可以更新:

const m = Material.fromType('MyCombineMaterial')
primitive.appearance.material = m

m.materials.diffuseMaterial.uniforms.image = 'diffuseMap.png'
m.materials.specularMaterial.uniforms.image = 'specularMap.png'

通常不建议嵌套太深,容易造成性能问题。

中段小结

至此,已经介绍了 Primitive API 中的两大 API —— Geometry APIAppearance + Material API 的入门和中阶使用,并使用一些简单的代码实例辅助说明。到这里为止已经可以运用内置的几何、材质外观来做一些入门的高性能渲染了,但是未来的你一定不满足于此,那就需要更进阶的用法 —— 完整的着色器编写,去控制几何体在顶点和片元着色阶段的细节。

受限于篇幅,进阶内容于下一篇讲解。