CesiumJS PrimitiveAPI 高階著色入門

2023-02-12 18:00:25


Primitive API 還包括 Appearance APIGeometry API 兩個主要部分,是 CesiumJS 擋在原生 WebGL 介面之前的最底層圖形封裝介面(公開的),不公開的最底層介面是 DrawCommand 為主的 Renderer API,DC 對實時渲染管線的技術要求略高,可客製化性也高,這篇還是以 Primitive API 為側重點。

0. 基礎

0.1. 座標系基礎

這裡的「座標系」特指 WebGL 圖形渲染的座標系。Primitive API 收到的幾何資料,預設沒有任何座標系(即最基本的空間直角座標),想要移動到地表感興趣的地方,需要藉助 ENU 轉換矩陣,或者把幾何頂點的座標直接設為 EPSG:4978 座標(即所謂通俗的「世界座標」)。

ENU 轉換矩陣,用道家八卦的說法類似「定中宮」。它能將座標轉換到這樣一個 ENU 地表區域性座標系上:

  • 指定一處地表點(經緯度)為座標原點

  • 以貼地正東方(ENU 中的 E)為正 X 軸

  • 以貼地正北方(ENU 中的 N)為正 Y 軸

  • 以地心到座標原點的方向(即 ENU 中的 U,up)為正 Z 軸

這樣一個 ENU 座標系上的區域性座標左乘 ENU 轉換矩陣後,就能得到標準的 EPSG:4978 世界座標。

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 等欄位會有些陌生。

如下圖所示:

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 的立方塊:

注意我在建立 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 的效果了:

具體的著色邏輯不深究,但是足夠說明問題:這個 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 設定的格式缺少了其中某一個,比如這裡就缺少了紋理座標,那麼就會出現頂點緩衝和頂點格式不匹配的情況,會出現報錯:

通常,使用 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 描述是這樣的:

你就可以通過傳遞這些 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 的入門和中階使用,並使用一些簡單的程式碼範例輔助說明。到這裡為止已經可以運用內建的幾何、材質外觀來做一些入門的高效能渲染了,但是未來的你一定不滿足於此,那就需要更進階的用法 —— 完整的著色器編寫,去控制幾何體在頂點和片元著色階段的細節。

受限於篇幅,進階內容於下一篇講解。