jjzjj

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

岭南灯火 2023-03-28 原文


书接上文 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:

效果:

文中还介绍了 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 函数),简单放个图吧:

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

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

文末小结

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

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

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

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

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

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

希望对读者有用。

有关CesiumJS PrimitiveAPI 高级着色入门 - 从参数化几何与 Fabric 材质到着色器 - 下篇的更多相关文章

  1. ruby-on-rails - 如何在 ruby​​ 中使用两个参数异步运行 exe? - 2

    exe应该在我打开页面时运行。异步进程需要运行。有什么方法可以在ruby​​中使用两个参数异步运行exe吗?我已经尝试过ruby​​命令-system()、exec()但它正在等待过程完成。我需要用参数启动exe,无需等待进程完成是否有任何ruby​​gems会支持我的问题? 最佳答案 您可以使用Process.spawn和Process.wait2:pid=Process.spawn'your.exe','--option'#Later...pid,status=Process.wait2pid您的程序将作为解释器的子进程执行。除

  2. ruby - RSpec - 使用测试替身作为 block 参数 - 2

    我有一些Ruby代码,如下所示:Something.createdo|x|x.foo=barend我想编写一个测试,它使用double代替block参数x,这样我就可以调用:x_double.should_receive(:foo).with("whatever").这可能吗? 最佳答案 specify'something'dox=doublex.should_receive(:foo=).with("whatever")Something.should_receive(:create).and_yield(x)#callthere

  3. ruby - 如何在 Ruby 中拆分参数字符串 Bash 样式? - 2

    我正在为一个项目制作一个简单的shell,我希望像在Bash中一样解析参数字符串。foobar"helloworld"fooz应该变成:["foo","bar","helloworld","fooz"]等等。到目前为止,我一直在使用CSV::parse_line,将列分隔符设置为""和.compact输出。问题是我现在必须选择是要支持单引号还是双引号。CSV不支持超过一个分隔符。Python有一个名为shlex的模块:>>>shlex.split("Test'helloworld'foo")['Test','helloworld','foo']>>>shlex.split('Test"

  4. ruby - 检查方法参数的类型 - 2

    我不确定传递给方法的对象的类型是否正确。我可能会将一个字符串传递给一个只能处理整数的函数。某种运行时保证怎么样?我看不到比以下更好的选择:defsomeFixNumMangler(input)raise"wrongtype:integerrequired"unlessinput.class==FixNumother_stuffend有更好的选择吗? 最佳答案 使用Kernel#Integer在使用之前转换输入的方法。当无法以任何合理的方式将输入转换为整数时,它将引发ArgumentError。defmy_method(number)

  5. ruby-on-rails - 在默认方法参数中使用 .reverse_merge 或 .merge - 2

    两者都可以defsetup(options={})options.reverse_merge:size=>25,:velocity=>10end和defsetup(options={}){:size=>25,:velocity=>10}.merge(options)end在方法的参数中分配默认值。问题是:哪个更好?您更愿意使用哪一个?在性能、代码可读性或其他方面有什么不同吗?编辑:我无意中添加了bang(!)...并不是要询问nobang方法与bang方法之间的区别 最佳答案 我倾向于使用reverse_merge方法:option

  6. ruby - 定义方法参数的条件 - 2

    我有一个只接受一个参数的方法:defmy_method(number)end如果使用number调用方法,我该如何引发错误??通常,我如何定义方法参数的条件?比如我想在调用的时候报错:my_method(1) 最佳答案 您可以添加guard在函数的开头,如果参数无效则引发异常。例如:defmy_method(number)failArgumentError,"Inputshouldbegreaterthanorequalto2"ifnumbereputse.messageend#=>Inputshouldbegreaterthano

  7. ruby - rails 3 redirect_to 将参数传递给命名路由 - 2

    我没有找到太多关于如何执行此操作的信息,尽管有很多关于如何使用像这样的redirect_to将参数传递给重定向的建议:action=>'something',:controller=>'something'在我的应用程序中,我在路由文件中有以下内容match'profile'=>'User#show'我的表演Action是这样的defshow@user=User.find(params[:user])@title=@user.first_nameend重定向发生在同一个用户Controller中,就像这样defregister@title="Registration"@user=Use

  8. ruby - 字符串文字中的转义状态作为 `String#tr` 的参数 - 2

    对于作为String#tr参数的单引号字符串文字中反斜杠的转义状态,我觉得有些神秘。你能解释一下下面三个例子之间的对比吗?我特别不明白第二个。为了避免复杂化,我在这里使用了'd',在双引号中转义时不会改变含义("\d"="d")。'\\'.tr('\\','x')#=>"x"'\\'.tr('\\d','x')#=>"\\"'\\'.tr('\\\d','x')#=>"x" 最佳答案 在tr中转义tr的第一个参数非常类似于正则表达式中的括号字符分组。您可以在表达式的开头使用^来否定匹配(替换任何不匹配的内容)并使用例如a-f来匹配一

  9. ruby-on-rails - 如何生成传递一些自定义参数的 `link_to` URL? - 2

    我正在使用RubyonRails3.0.9,我想生成一个传递一些自定义参数的link_toURL。也就是说,有一个articles_path(www.my_web_site_name.com/articles)我想生成如下内容:link_to'Samplelinktitle',...#HereIshouldimplementthecode#=>'http://www.my_web_site_name.com/articles?param1=value1¶m2=value2&...我如何编写link_to语句“alàRubyonRailsWay”以实现该目的?如果我想通过传递一些

  10. 旋转矩阵的几何意义 - 2

    点向量坐标矩阵的几何意义介绍旋转矩阵的几何含义之前,先介绍一下点向量坐标矩阵的几何含义点:在一维空间下就是一个标量,如同一条直线上,以任意某一个位置为0点,以一定的尺度间隔为1,2,3...,相反方向为-1,-2,-3...;如此就形成了一维坐标系,这时候任何一个点都可以用一个数值表示,如点p1=5,即即从原点出发沿着x轴正方向移动5个尺度;点p2=-3,负方向移动3个尺度;     在一维坐标系上过原点做垂直于一维坐标系的直线,则形成了二维坐标系,此时描述一个点需要两个数值来表示点p3=(3,2),即从原点出发沿着x轴正方向移动3个尺度,在此基础上沿着y轴正方向移动两个尺度的位置就是点p3。

随机推荐