Cesium实现更实用的3D描边效果(附源码)

3D对象描边,常用于物体的高亮显示。本文将详细介绍如何在Cesium中实现该功能:提取物体完整的边缘,用不同颜色区分可见和被遮挡部分;根据法线夹角阈值提取平面边界;支持高亮显示Entity、Primitive和3DTiles等。(文末附源码)

图1.本文实现的描边效果

1 问题描述

Cesium提供了Silhouette后期处理可以实现3D对象描边,但是描边结果并不能完全显示物体的轮廓,如图:

图2.Cesium自带描边效果

可以看出所描绘的轮廓存在如下不足:

(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处理结果的颜色和深度纹理。

效果如下图:


4.4、轮廓遮挡检测

这部分和three.js OutlinePass有点区别,主要在深度读取和比较方法不同。关键的shader代码如下:根据深度识别被遮挡部分:


其中深度compareDepth定义如下:


maskDepthTexture为4.2渲染结果深度纹理。效果如下图:

图4.遮挡检测效果 被遮挡部分颜色更浅

4.5 提取平面边界

THREE.EdgeGeometry边缘提取的核心原理是:计算相邻两个三角面的法线夹角,如果夹角小于给定阈值则认为两者在同一平面,进而删除公共边。那么在shader中如何实现呢?前文已经做好了足够的准备了,我们只需要按照 4.2 和 4.3 比较深度和透明度的方法,再实现一个比较顶点法线的方法即可。核心shader代码如下:


其中 thresholdAngle 为相邻三角面法线夹角阈值,单位为弧度,通过uniform传入。

我们还需要对 main 函数进行修改,主要是5.2中 d 的计算,有变化:


至此核心的步骤就全部完成了。想要得到上文的各个效果图,我们还需将得到的轮廓效果叠加到原始场景中。

4.6、将轮廓叠加到场景

这里简单粗暴的将两张纹理贴图的颜色做加运算。


其中lineTexture为轮廓提取节点的颜色纹理,colorTexture为原始场景的颜色纹理。

5、应用示例

本文大体把实现流程的关键细节列出来,最后我们将这些细节封装,以便在项目中应用。再贴一下封装好的接口定义(createEdgeStage.js):


5.1 效果视频



视频:展示参数设置和Entity、3D Tiles高亮显示效果

5.2、示例代码




你可能感兴趣的:(Cesium实现更实用的3D描边效果(附源码))