3D对象描边,常用于物体的高亮显示。本文将详细介绍如何在Cesium中实现该功能:提取物体完整的边缘,用不同颜色区分可见和被遮挡部分;根据法线夹角阈值提取平面边界;支持高亮显示Entity、Primitive和3DTiles等。(文末附源码)
1 问题描述
Cesium提供了Silhouette后期处理可以实现3D对象描边,但是描边结果并不能完全显示物体的轮廓,如图:
可以看出所描绘的轮廓存在如下不足:
(1)无法提取被遮挡部分的轮廓;
(2)未被遮挡部分的轮廓不完整。
熟悉three.js的小伙伴可能已经想起了OutlinePass的效果,今天我们就来将这个效果拿到Cesium中实现,不过我们要增加点难度,把THREE.EdgeGeometry的效果也拿过来,并且不修改几何体,实现更加实用的3D描边效果。
2 本文目标
Cesium后期处理中同时实现:
(1)three.js OutlinePass的描边效果,即提取物体完整的边界,包括被遮挡的部分,同时可以选择用不同颜色区分可见和被遮挡部分;
(2)three.js EdgeGeometry的描边效果,即根据法线夹角阈值提取平面边界。
(3)支持高亮显示Cesium.Entity、Cesium.Primitive以及Cesium 3D Tiles要素。
3 关键技术
在开始实现本文目标效果之前,先介绍一下两个关键技术:
(1)Cesium后期处理技术;
(2)Cesium多次渲染技术
3.1 Cesium后期处理
我们先通过几段代码了解一下Cesium后期处理开发流程。
3.1.1 创建一个基本的后期处理节点
创建一个后期处理节点的JavaScript代码如下:
其中singleStage.frag为片段着色器代码文件(直接 import 是因为作者使用Mesh-3D Engine的命令行工具作为开发环境,服务端自动转成js文件并返回js代码),内容如下:
这个处理节点没有做任何加工处理,只是简单的复制场景。从中可以看出,一个基本的后期处理节点包含两大部分: (1) fragmentShader 片段着色器代码:对每一帧的渲染结果进行加工处理。简单解释一下内置变量:
colorTexture 整个场景的颜色纹理或者前一个后期处理结果颜色纹理;
colorTextureDimensions 颜色纹理尺寸,x为宽度,y为高度;
提示: Cesium会对每一个名为xxx、类型为Cesium.Texture的uniform变量都增加一个名为xxxDimensions的uniform变量。用来传递纹理尺寸。
depthTexture 整个场景的深度纹理或者前一个后期处理结果深度纹理;
(2) uniforms 向fragmentShader中传递外部数据。可传递的数据类型分为两大类:
常量类:布尔(Boolean),字符串(String),向量(Cesium.CartesianX),矩阵(Cesium.MatrixX)
注意:字符串类型的uniform值可以是:图片路径;前续节点名称,用以访问节点的颜色纹理。
回调函数:返回值类型同常量类。在后续的文章中会用到这类uniform。
3.1.2、创建多个后处理节点
有些效果可能需要多次对场景进行加工、合成才能实现。Cesium提供PostProcessStageComposite类用来解决此类需求。示意代码如下:
参数inputPreviousStageTexture值解释:
true:stages中各节点的colorTexture为其前一节点的颜色纹理;
false:stages所有节点的colorTexture相同(不考虑uniforms中自定义colorTexture的情况)。
3.1.3、设置选中对象
Cesium后期处理两个类都提供selected属性,用来生成选中对象id查询纹理。selected接受的对象只有一个要求:包含pickId或者pickIds属性。凡是可以通过Cesium.Scene pick方法拾取到的对象,都可以找到对应的pickId,反过来,如果想要被pick到,也需要再创建DrawCommand的时候生成pickId。底层不同对象构建pickId的逻辑差别很大,导致获取pickId的方法也不尽相同。
Entity和Primitive 从picked.primitive._pickIds查找;
3D Tiles要素 已单体化的要素:picked.pickId;未单体化的瓦片:picked.content._model._pickIds;
3.2、Cesium多次渲染
后期处理技术允许我们对场景渲染结果进行加工处理,但是有些情况(比如SMAA、MSAA抗锯齿算法,以及接下来我们要实现的轮廓提取等),我们需要对场景全部或者部分对象进行临时修改、显隐控制并重新渲染。Cesium并没有提供现成的技术,所以我们需要动手去实现,这部分涉及Cesium底层渲染技术,这里不展开介绍,有必要的话可以专门写一篇来补充,这里列出接口定义和使用示例。
3.2.1、实现功能
在Cesium主渲染流程完成后,指定后期处理节点(stage)开始前,将选中的对象或者没有被选中的对象渲染到缓冲区,然后在该后期处理节点通过texture属性获取当前通道的颜色数据,通过depthTexture获取深度数据。
3.2.2、接口定义(TypeScript)
关键参数说明:
renderType 设置需要渲染的对象:
all——当前渲染队列中的所有绘图命令
selected——渲染队列中被选中的对象关联的绘图命令,只过滤用整个对象内所有几何体的所有顶点pickId都相同的情况,如果将pickId写入几何体的顶点则需要手动在shader中过滤(通过调用czm_selected()来判断是否为选中对象)
unselected——渲染队列中的所有未被选中的对象关联的绘图命令
stage 设置绑定的后期处理节点。必须绑定后期处理节点,否则渲染通道将不会被调用执行渲染工作,也无法获取depthTexture和exture。
texture 获取颜色纹理。请在uniform回调函数中获取,因为纹理在后期处理节点的update被调用时才会生成,提前获取不到。
depthTexture 获取深度纹理。
shaderRedefine 指定shader重定义方式,即指示在shader代码追加到绘图命令本身的shader之后,如何执行绘图逻辑:
add——并且调用原始的main函数,执行默认绘图逻辑,追加部分的绘图逻辑;
replace——不调用原始的main函数,只执行追加部分的绘图逻辑。
vertexShader 追加的顶点着色器代码,可选。
fragmentShader 追加的片元着色器代码,可选。
内置的预编译定义(在顶点和片元着色器中都可以访问):
HAS_NORMAL 指示顶点着色器中存在名为normal的属性。
HAS_V_NORMAL 指示顶点属性中不存在为normal的属性,但存在名为v_normal的varying变量。
内置的函数czm_selected:识别当前像元是否为选中对象。
3.2.3、使用示例
创建后期渲染通道
片段着色代码(renderPass.frag):
javascript代码:
在后期处理节点中使用
片元着色器代码(readMaskPass.frag):
javascript代码:
4、实现过程
准备这么久,终于到了正文了。实现过程整体分为以下几步:
(1)渲染整个场景,获得场景颜色和深度;
(2)渲染被选中对象,将法线保存到颜色纹理,同时得到该对象深度纹理;
(3)提取选中对象轮廓;
(4)将轮廓叠加到场景。
其中关键的步骤是(2)和(3),而(3)中的Shader代码几乎可以直接照搬three.js OutlinePass的代码,相对容易;(2)是比较困难,难点在于如何只渲染被选中对象,这就用到了上文介绍的Cesium多次渲染技术了。下面分步详细介绍。
4.1、渲染整个场景
这一步在创建场景之后Cesium就一直进行,我们只需要创建好场景即可。
4.2、渲染选中对象
这一步决定了我们实现的效果有别与Cesium自带的描边算法,因为边缘识别算法原理基本是一样的,差别在于输入的场景颜色和深度。
首先为提取外边界的做准备,片元着色器只需要通过czm_selected来判断是否为被选中对象(要素),如果不是则discard即可;其次我们还需要提取平面边界,即提取所有由近似处于同一平面的三角面组成的多边形边界,为此我们将法线保存到颜色纹理中。
顶点着色器如下(normalDepth.vert):
片元着色器代码如下(normalDepth.frag):
创建CesiumRenderPass实例,这里不需要外部参数,JavaScript代码如下:
4.3、提取选中对象轮廓
我们先来实现仅提取对象边缘的算法,不做遮挡检测,所有边缘使用同一颜色描绘。
这部分的shader代码直接取自 three.js OutlinePass 的 getEdgeDetectionMaterial 函数,只对uniform做点处理。关键代码如下(singleEdgeColor.frag):
其中c1、c2、c3、c4为当前像元按线宽(outlineWidth)偏移一定像元之后采样到的颜色,颜色的r、g、b三通道保存的是选中对象的法线数据,a通道为场景透明度,a大于0则是选中对象内部,a为0表示选中对象外部。
关键的JavaScript代码如下:
maskTexture和maskDepthTexture分别是4.2处理结果的颜色和深度纹理。
效果如下图:
这部分和three.js OutlinePass有点区别,主要在深度读取和比较方法不同。关键的shader代码如下:根据深度识别被遮挡部分:
其中深度compareDepth定义如下:
maskDepthTexture为4.2渲染结果深度纹理。效果如下图:
4.5 提取平面边界
THREE.EdgeGeometry边缘提取的核心原理是:计算相邻两个三角面的法线夹角,如果夹角小于给定阈值则认为两者在同一平面,进而删除公共边。那么在shader中如何实现呢?前文已经做好了足够的准备了,我们只需要按照 4.2 和 4.3 比较深度和透明度的方法,再实现一个比较顶点法线的方法即可。核心shader代码如下:
其中 thresholdAngle 为相邻三角面法线夹角阈值,单位为弧度,通过uniform传入。
我们还需要对 main 函数进行修改,主要是5.2中 d 的计算,有变化:
至此核心的步骤就全部完成了。想要得到上文的各个效果图,我们还需将得到的轮廓效果叠加到原始场景中。
4.6、将轮廓叠加到场景
这里简单粗暴的将两张纹理贴图的颜色做加运算。
其中lineTexture为轮廓提取节点的颜色纹理,colorTexture为原始场景的颜色纹理。
5、应用示例
本文大体把实现流程的关键细节列出来,最后我们将这些细节封装,以便在项目中应用。再贴一下封装好的接口定义(createEdgeStage.js):