8

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

 1 year ago
source link: https://www.cnblogs.com/onsummer/p/cesium-primitive-api-tutorial-2.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client



书接上文 https://www.cnblogs.com/onsummer/p/cesium-primitive-api-tutorial.html

3. 使用 GLSL 着色器

明确一个定义,在 Primitive API 中应用着色器,实际上是给 AppearancevertexShaderSourcefragmentShaderSourceMaterial 中的 fabric.source 设置着色器代码,它们所能控制的层级不太一样。但是他们的共同目的都是为了 Geometry 服务的,它们会随着 CesiumJS 的每帧 update 过程,创建 ShaderProgram,创建 DrawCommand,最终去到 WebGL 的底层渲染中。

3.1. 为 Fabric 材质添加自定义着色代码 - Fabric 材质的本质

有了之前的 fabric.uniformsfabric.materialsfabric.components 基础,你可能迫不及待想写自定义着色器代码了。需要知道的一点是,有了 fabric.source,就不兼容 fabric.components 了,只能二选一。

关于 fabric.uniforms,它的所有键名都可以在着色器代码中作为 GLSL Uniform 变量使用;关于 fabric.materials,它的所有键名都可以在着色器代码中作为 GLSL 变量使用,也就是一个计算完成的 czm_material 结构体变量。

编写 fabric.source,实际上就是写一个函数,它必须返回一个 czm_material 结构体,且输入一些特定的、当前片元的信息:

czm_material czm_getMaterial(czm_materialInput materialInput) {
  czm_material material = czm_getDefaultMaterial(materialInput);
  // ... 一系列处理
  return material;
}

czm_material 已经在之前提及过了,它包含了实时渲染所需的一些基本材质参数。而 materialInput 这个变量,它是 czm_materialInput 类型的结构体,定义如下:

struct czm_materialInput {
  float s;
  vec2 st;
  vec3 str;
  mat3 tangentToEyeMatrix;
  vec3 positionToEyeEC;
  vec3 normalEC;
};
  • s - 一维纹理坐标

  • st - 二维纹理坐标

  • str - 三维纹理坐标。注意,materialInput.str.st 不一定就是 materialInput.st,也不能保证 materialInput.st.s == materialInput.s,例如对于椭球体而言,s 是底部到顶部的纹理坐标,st 是经纬度,str 可能是范围框的轴向值,这要参考源代码

  • tangentToEyeMatrix - 片元切线空间到眼坐标系的转换矩阵,用于法线计算等

  • positionToEyeEC - 片元坐标到观察坐标系(眼坐标系)原点的向量,模长为片元到原点的距离,单位是米,可以用于反射或者折射计算

  • normalEC - 可用于凹凸映射、反射、折射计算中的眼睛坐标系下的标准化法线

那个 czm_getDefaultMaterial 函数就是获取默认的材质结构,这个函数很简单:

czm_material czm_getDefaultMaterial(czm_materialInput materialInput) {
  czm_material material;
  material.diffuse = vec3(0.0);
  material.specular = 0.0;
  material.shininess = 1.0;
  material.normal = materialInput.normalEC;
  material.emission = vec3(0.0);
  material.alpha = 1.0;
  return material;
}

有了上面这些基础,你就可以在这个 czm_getMaterial() 函数体里写你想要的片元着色内容了,注意任意 CesiumJS 的内置变量、自动 Uniform、结构体、内置函数都可以用。

3.2. 社区实现案例 - 泛光墙体和流动线材质

参考 前端3D引擎-Cesium自定义动态材质 - 掘金

有了 3.1 的基础,我们直接参考网上的一些案例。

const polylinePulseLinkFabric = {
  type: 'PolylinePulseLink',
  uniforms: {
    color: Color.fromCssColorString('rgba(0, 255, 255, 1)'),
    speed: 0,
    image: 'http:/localhost:3000/images/bell.png', // 可以自己指定泛光墙体渐变材质
  },
  source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
    czm_material material = czm_getDefaultMaterial(materialInput);

    // 获取纹理坐标
    vec2 st = materialInput.st;
    // 对 uniforms.image 的纹理图片进行采样
    // 这里需要根据时间来采样,公式含义读者自行研究,czm_frameNumber * 0.005 * speed 就是根据内置的
    // czm_frameNumber,即当前帧数来代表大致时间
    vec4 colorImage = texture2D(image, vec2(fract((st.t - speed * czm_frameNumber * 0.005)), st.t));
    vec4 fragColor;
    fragColor.rgb = color.rgb / 1.0;
    fragColor = czm_gammaCorrect(fragColor); // 伽马校正

    material.alpha = colorImage.a * color.a;
    material.diffuse = (colorImage.rgb + color.rgb) / 2.0;
    material.emission = fragColor.rgb;
    return material;
  }`,
}

// 使用
const wallInstance = new GeometryInstance({
  geometry: WallGeometry.fromConstantHeights({
    positions: Cartesian3.fromDegreesArray([
      97.0, 43.0, 
      107.0, 43.0, 
      107.0, 40.0,
      97.0, 40.0,
      97.0, 43.0,
    ]),
    maximumHeight: 100000.0,
    vertexFormat: MaterialAppearance.VERTEX_FORMAT,
  }),
})

new Primitive({
  geometryInstances: wallInstance,
  appearance: new MaterialAppearance({
    material: new Material({ fabric: polylinePulseLinkFabric }),
  }),
})

其用到的渐变纹理可以是任意的一个横向颜色至透明的渐变 png:

1097074-20230213033730149-47138507.png

文中还介绍了 Entity 使用自定义 MaterialProperty 的方法,实际上底层也是 Material

class PolylineTrailMaterialProperty {
  // ...
  getType() {
    return 'PolylineTrail'
  }
  getValue(time, result) {
    if (!defined(result)) {
      result = {}
    }

    result.color = Property.getValueOrClonedDefault(
      this._color,
      time,
      Color.WHITE,
      result.color
    )
    result.image = this.trailImage
    result.time = ((performance.now() - this._time) % this.duration) / this.duration

    return result
  }
  // ... 其余封装参考原文
}

const shader = `czm_material czm_getMaterial(czm_materialInput materialInput) {
  czm_material material = czm_getDefaultMaterial(materialInput);

  vec2 st = materialInput.st;
  // 简化版,显然纹理采样的 time 就来自 PolylineTrailMaterialProperty 了,不需要自己控制
  vec4 colorImage = texture2D(image, vec2(fract(st.s - time), st.t));

  material.alpha = colorImage.a * color.a;
  material.diffuse = (colorImage.rgb + color.rgb) / 2.0;
  return material;
}`
// 创建一个 'PolylineTrail' 类型的材质对象,并缓存起来:
const polylineTrailMaterial = new Material({
  fabric: {
    type: 'PolylineTrail',
    uniforms: {
      color: new Color(1.0, 0.0, 0.0, 0.5),
      image: 'http:/localhost:3000/images/bell.png',
      time: 0,
    },
    source: shader,
  }
})

详细的完整封装调用就不列举了,需要有 Entity API 的使用经验,不在本篇范围。想知道 Property 是如何调用底层的,也需要自己研究 EntityAPI 的底层。

3.3. 直接定义外观对象的两个着色器

fabric.source 只能作用于材质的片元着色,当然也可以通过编写外观对象的两个着色器实现更大自由。

默认情况下,MaterialAppearance 的顶点着色器与片元着色器是这样的:

// GLSL 300 语法,顶点着色器
in vec3 position3DHigh;
in vec3 position3DLow;
in vec3 normal;
in vec2 st;
in float batchId;

out vec3 v_positionEC;
out vec3 v_normalEC;
out vec2 v_st;

void main() {
  vec4 p = czm_computePosition();

  v_positionEC = (czm_modelViewRelativeToEye * p).xyz;      // position in eye coordinates
  v_normalEC = czm_normal * normal;                         // normal in eye coordinates
  v_st = st;

  gl_Position = czm_modelViewProjectionRelativeToEye * p;
}

顶点着色器调用 czm_computePosition() 函数将 position3DHighposition3DLow 合成为 vec4 的模型坐标,然后乘以 czm_modelViewProjectionRelativeToEye 这个内置的矩阵,得到裁剪坐标。然后是片元着色器:

in vec3 v_positionEC;
in vec3 v_normalEC;
in vec2 v_st;

void main()
{
    vec3 positionToEyeEC = -v_positionEC;

    vec3 normalEC = normalize(v_normalEC);
#ifdef FACE_FORWARD
    normalEC = faceforward(normalEC, vec3(0.0, 0.0, 1.0), -normalEC);
#endif

    czm_materialInput materialInput;
    materialInput.normalEC = normalEC;
    materialInput.positionToEyeEC = positionToEyeEC;
    materialInput.st = v_st;
    czm_material material = czm_getMaterial(materialInput);

#ifdef FLAT
    out_FragColor = vec4(material.diffuse + material.emission, material.alpha);
#else
    out_FragColor = czm_phong(normalize(positionToEyeEC), material, czm_lightDirectionEC);
#endif
}

如果想完全定制 Primitive 的着色行为,需要十分熟悉你所定制的 Geometry 的 VertexBuffer,也要控制好两大着色器之间相互传递的值。

可以看得出来,Primitive API 使用的材质光照模型是冯氏(Phong)光照模型,可参考基本光照

案例就不放了,有能力的可以直接参考 CesiumJS 曾经推过的一个 3D 风场可视化的案例,它不仅自己写了一个顶点着色器、片元着色器都是自定义的 Appearance,还写了自定义的 Primitive(不是原生 Primitive,是连 DrawCommand 都自己创建的似 Primitive,似 Primitive 将在下文解释)。

3.4. *源码中如何合并着色器

这段要讲讲源码,定位到 Primitive.prototype.update() 方法:

Primitive.prototype.update = function (frameState) {
  const appearance = this.appearance;
  const material = appearance.material;
  let createRS = false;
  let createSP = false;

  // 一系列判断是否需要重新创建 ShaderProgram,会修改 createSP 的值

  if (createSP) {
    const spFunc = defaultValue(
      this._createShaderProgramFunction,
      createShaderProgram
    );
    // 默认情况下,会使用 createShaderProgram 函数创建新的 ShaderProgram
    spFunc(this, frameState, appearance);
  }
};

使用 createShaderProgram 函数会用到外观对象。

function createShaderProgram(primitive, frameState, appearance) {
  // ...

  // 装配顶点着色器
  let vs = primitive._batchTable.getVertexShaderCallback()(
    appearance.vertexShaderSource
  );
  // 从这开始,是给外观对象的片元着色器添加一系列 Buff
  vs = Primitive._appendOffsetToShader(primitive, vs);
  vs = Primitive._appendShowToShader(primitive, vs);
  vs = Primitive._appendDistanceDisplayConditionToShader(
    primitive,
    vs,
    frameState.scene3DOnly
  );
  vs = appendPickToVertexShader(vs);
  vs = Primitive._updateColorAttribute(primitive, vs, false);
  vs = modifyForEncodedNormals(primitive, vs);
  vs = Primitive._modifyShaderPosition(primitive, vs, frameState.scene3DOnly);

  // 装配片元着色器
  let fs = appearance.getFragmentShaderSource();
  fs = appendPickToFragmentShader(fs); // 为片元着色器添加 pick 所需的 vec4 颜色 in(varying) 变量

  // 生成 ShaderProgram,并予以校验匹配情况
  primitive._sp = ShaderProgram.replaceCache({
    context: context,
    shaderProgram: primitive._sp,
    vertexShaderSource: vs,
    fragmentShaderSource: fs,
    attributeLocations: attributeLocations,
  });
  validateShaderMatching(primitive._sp, attributeLocations);

  // ...
}

总之,外观的两个着色器也仅仅是 CesiumJS 这个庞大的着色器系统中的一部分,仍有非常多的状态需要添加到着色器对象(ShaderProgram)上。

可能通用的 Primitive 就是需要这么多状态附加吧,读者可以自行研究其它似 Primitive 的着色器创建过程。似 Primitive 将于本文的最后一大节说明。

4. 底层知识

4.1. 渲染状态对象

注意到一个东西:appearance.renderState,在创建外观对象时可以传入一个对象字面量:

new MaterialAppearance({
  // ...
  renderState: {},
})

也可以不传递,默认会生成这样一个对象:

{
  depthTest: {
    enabled: true,
  },
  depthMask: false,
  blending: BlendingState.ALPHA_BLEND, // 来自 BlendingState 的静态常量成员 ALPHA_BLEND
}

这个对象会记录在外观对象上,伴随着 Primitive 的更新过程,还会增增减减、修改状态值,在 Primitive 的 createRenderStates 函数中,用这个对象的即时值创建或取得缓存的 RenderState 实例,等待着在 createCommands 函数中传递给 DrawCommand

RenderState 的状态值和 WebGL 最终渲染有关,在 Context 模块的 beginDraw 函数、applyRenderState 函数中,就有大量使用渲染状态的代码(还要往里进去两三层),举例:

function applyDepthMask(gl, renderState) {
  gl.depthMask(renderState.depthMask);
}

function applyStencilMask(gl, renderState) {
  gl.stencilMask(renderState.stencilMask);
}

这两个函数就是在修改 WebGL 全局状态的值,值来自 RenderState 实例的 depthMaskstencilMask 字段。

CesiumJS 漫长的一帧的更新过程中,有两个状态对象可以关注一下,一个是挂载在 Scene 上的 帧状态 对象(FrameState 实例),另一个就是身处于各个实际三维对象上的 渲染状态 对象(RenderState 实例)。前者记录一些整装待发的资源,例如 DrawCommand 清单等,后者则为三维对象标记在实际渲染时要更改 WebGL 全局状态的状态值。两大状态的链接桥梁是 DrawCommand

还有一个贯穿于帧更新过程的状态对象:统一值对象(UniformState 实例),是 Context 的成员字段,作用同其名,用于更新要传给着色器的统一值。

4.2. 似 Primitive 对象与创建似 Primitive 对象

这一节介绍的内容将有助于理解 CesiumJS 单帧更新的核心思路。别看 CesiumJS 拥有这么多加载数据、模型的 API 类,实际上是可以根据它们在场景结构中的层级,做个简单的分类:

  • Entity 与 DataSource,高层级的数据 API,是高级的人类友好的数据格式加载封装,还能与时间关联

  • Globe 与 ImageryLayer,负责地球本身的渲染,含皮肤(影像 Provider)和肌肉(地形 Provider)

  • Primitive 家族,含本篇介绍的 Primitive,以及 glTF3DTiles 等数据

Entity 和 DataSource 实际上底层也是在调用 Primitive 家族,只不过这两个属于 Viewer;中间的 Globe 与 ImageryLayer 和最后的 Primitive 家族,属于 Scene 容器。

既然这篇是介绍的 Primitive,那么就重点介绍 Primitive 家族。

你一定注意过可以向 scene.primitives 这个 PrimitiveCollection 中添加好几种对象:ModelCesium3DTilesetPrimitiveCollection(是的,可以嵌套添加)、PointPrimitiveGroundPrimitiveClassificationPrimitive 以及本篇介绍的 Primitive 均可以,在 1.101 版本的更新中还添加了一个体素:VoxelPrimitive(仍在测试)。

我将这类 Primitive 家族类,称为 PrimitiveLike 类,即“似 Primitive”。

这些似 Primitive 有一个共同点,才能添加到 PrimitiveCollection 中,伴随着场景的单帧更新过程进入 WebGL 渲染。它们的共同点:

  • update 实例方法

  • destroy 实例方法

update 方法中,它接受 FrameState 对象传入,然后经过自己的渲染逻辑,创建出一系列的指令对象(主要是 DrawCommand),并送入帧状态对象的指令数组中,待更新完毕最终进入 WebGL 的渲染。

所以知道这些有什么用呢?

Cesium 团队是一个求稳的团队,2012 年还在内测的时候,ES5 标准才落地没多久,哪怕现在的代码也仍然是使用函数来创建类,而不是用 ES6 的 Class(尽管现在切换过去已经没什么技术难点了)。ES6 实现类继承是很简单的,但是在那个时候就比较困难了。像这种似 Primitive 的情况,ES6 来写实际上就是有一个共同的父类罢了,如果是 TypeScript,那更是可以抽象为更轻量的 interface

interface PrimitiveLike {
  update(frameState: FrameState): void
  destroy(): void
}

这构成了编写自定义 Primitive 的基础,CesiumJS 团队和 CesiumLab 核心成员 vtxf 均有一些古早的资料,告诉你如何编写自定义的 Primitive 类。我之前也写有一篇较为相近的、介绍 DrawCommand 并创建简易自定义三角形 Primitive 的文章,列举如下:

著名的 Cesium 3D 风场案例就是一个非常经典的应用:

4.3. Primitive 在 Scene 中的大致图示

如果读过我写的源码系列,应该知道 Primitive 在 Scene 的更新位置(Scene 模块下的 render 函数),简单放个图吧:

1097074-20230213033657972-680195996.jpg

这样就能大致看到在什么时候更新的 PrimitiveCollection 了。

有兴趣了解源码渲染架构的可以去补补我之前写的系列。

这两篇文章集合了我三年前的几篇不成熟的文章,我终于系统地写出了这几个内容:

  • 一般性的 Primitive API 用法,包括

    • Geometry API 的自定义几何、参数内置几何

    • Appearance + Material API 所表达的 CesiumJS Fabric 材质规范

  • 提出似 Primitive 的概念,为之后自定义 Primitive 学习挡在 WebGL 原生接口之前的最底层 API 打下基础

  • 简单思考了 CesiumJS 的着色器设计和应用

希望对读者有用。

__EOF__


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK