Cesium深入浅出之3dtiles渲染【转】


引子

接触Cesium一年有余了,期间靠胡吃海塞吸收了很多有用的、没用的知识和技术,感觉有点消化不良,今天终于有时间来梳理一下了。之前一直搞二维的,对三维技术只能算是半路出家,不敢写太深的原理性文章,以免误人子弟,但写写心得还是可以的。我想写一个Cesium深入浅出系列,即将深刻的道理用浅显的语言表述出来,纵观大部分的技术类文章,应该没几个能真正的做到这一点吧。所以,虽然深入浅出被用滥了,但我依然选择了它。我希望我的文章不管入的有多深,但出来一定是很浅的。这让我想到了白居易,写诗追求浅显易懂,不止是让文人墨客品鉴,也要让市井百姓能轻易读懂,他的诗反而因此更加广为流传,这就是所谓意境大于形式吧。那么闲话不多说了,让我们来深深的入吧!

预期效果

使用三维白模直接加载到Cesium中的效果太过朴素,就算是根据属性设置不同的颜色还是丑,归根结底是颜色渲染太单一,我们可以使用渐变色来渲染一下,效果会立马提升一个档次,再配合泛光效果,可以出来点数字城市的意思。先上一张效果图:

实现原理

先声明一下,以下内容可能仅适配小白,如感不适,敬请绕道。

言归正传,如果想要让单调的白模变得不那么单调,我们首先想到的是采用贴图的方式。这种方式很简单,只要找一张渐变色的贴图贴上就可以了,不过缺点也是显而易见的,贴图的工作是要在数据处理的时候就要做的,一旦装修好了也就定型了,如果你想再恢复成毛坯,对不起了您呐,再去处理一遍数据。所以我们不想要这种先天的效果,而是通过后天努力去实现。可是要怎么实现呢?为了更好地解释原理,我们有必要做一点功课了。

Entity

在Cesium中,数据按加载方式可以笼统地分为两种类型,即Entity和Primitive。Entity即实体,引用API原文:Entity instances aggregate multiple forms of visualization into a single high-level object,意思是说实体的实例是将多种形式的可视化效果聚合到一个高级的对象中,这个高级对象就是Entity。请注意了,官方大大说了这个东西很高级,高级意味着更实用、更好用,但同时也意味着体积会更臃肿,占用更多的系统资源,稍微做过点功课的童鞋知道,海量数据是不能用Entity方式来加载的,它能卡爆你的内存。如果想要高性能,那么你还得了解一下Primitive。

Primitive

Primitive,即图元,Mesh的基本单位,这个是图形学里面的解释,Cesium中的Primitive感觉就英文的字面意思,我暂时叫它原始体吧,既然是原始的,那说明这种格式更接近于底层,比实体有着更高的性能。当然了它的缺点就是易用性差点,有过经验的小伙伴都知道,Primitive用起来要比Entity麻烦一点。

Material

上面我们为什么要先介绍数据类型呢,那是因为不同的数据类型贴材质的方式是不同的。下面来介绍下材质,即Material,就像我们装修房子用的装修材料一样,客厅地板用瓷砖材质,卧室用木地板材质,墙面用油漆材质,当然咯,你也可以用墙纸,类似我们前面提到的贴图。查看API之后我们知道,Cesium中的材质有不少,不过先不着急研究哪个材质适合我们,我们要先来研究一下怎么把材质贴到模型上。

Entity方式

 1  // 创建一个有洞的多边形,并填充蓝色材质
 2  var polygon = viewer.entities.add({
 3    name: "Blue polygon with holes",
 4    polygon: {
 5      hierarchy: {
 6        positions: Cesium.Cartesian3.fromDegreesArray([
 7          -99.0, 30.0,
 8          -85.0, 30.0,
 9          -85.0, 40.0,
10         -99.0, 40.0,
11       ]),
12       holes: [{
13           positions: Cesium.Cartesian3.fromDegreesArray([
14             -97.0, 31.0,
15             -97.0, 39.0,
16             -87.0, 39.0,
17             -87.0, 31.0,
18           ])
19       }]
20     },
21     material: Cesium.Color.BLUE.withAlpha(0.5),
22     height: 0,
23     outline: true
24   }
25 });
26 
27 // 改变材质
28 // 方式一:通过类型创建材质
29 polygon.material = Cesium.Material.fromType('Color');
30 polygon.material.uniforms.color = new Cesium.Color(1.0, 1.0, 0.0, 1.0);
31 
32 // 方式二:创建一个默认材质
33 polygon.material = new Cesium.Material();
34 
35 // 方式三:通过Fabric方式
36 polygon.material = new Cesium.Material({
37     fabric : {
38         type : 'Color',
39         uniforms : {
40             color : new Cesium.Color(1.0, 1.0, 0.0, 1.0)
41         }
42     }
43 });

Primitive方式

 1 // 画一个椭圆形,并使用西洋棋盘材质填充
 2 var instance = new Cesium.GeometryInstance({
 3   geometry : new Cesium.EllipseGeometry({
 4       center : Cesium.Cartesian3.fromDegrees(-100.0, 20.0),
 5       semiMinorAxis : 500000.0,
 6       semiMajorAxis : 1000000.0,
 7       rotation : Cesium.Math.PI_OVER_FOUR,
 8       vertexFormat : Cesium.VertexFormat.POSITION_AND_ST
 9   }),
10   id : 'ellipse'
11 });
12 scene.primitives.add(new Cesium.Primitive({
13   geometryInstances : instance,
14   appearance : new Cesium.EllipsoidSurfaceAppearance({
15     material : Cesium.Material.fromType('Checkerboard')
16   })
17 }));

上述代码只是很简单的例子,不过也基本能说明材质是如何应用到模型上的。那么现在问题来了,3dtiles究竟属于哪种数据类型?感觉哪种都不像,怎么办?看来我们得继续做点功课了。

Cesium3DTileset

Cesium3DTileset,即3dtiles在Cesium中的数据表现形式。现在看一下3dtiles的数据是怎么加载的。

 1 var tileset = scene.primitives.add(new Cesium.Cesium3DTileset({
 2      url : 'http://localhost:8002/tilesets/Seattle/tileset.json',
 3      skipLevelOfDetail : true,
 4      baseScreenSpaceError : 1024,
 5      skipScreenSpaceErrorFactor : 16,
 6      skipLevels : 1,
 7      immediatelyLoadDesiredLevelOfDetail : false,
 8      loadSiblings : false,
 9      cullWithChildrenBounds : true
10 }));

原来3dtiles也是通过Primitive方式加载的啊,但是仔细看上面代码,并没有像普通Primitive那样设置材质。这也印证了上面提到过的疑问,它看似Primitive又不像Primitive,又翻了一遍API,发现确实没有设置材质的入口,仅有类似专题图的设置样式的入口。

 1 tileset.style = new Cesium.Cesium3DTileStyle({
 2    color : {
 3        conditions : [
 4            ['${Height} >= 100', 'color("purple", 0.5)'],
 5            ['${Height} >= 50', 'color("red")'],
 6            ['true', 'color("blue")']
 7        ]
 8    },
 9    show : '${Height} > 0',
10    meta : {
11        description : '"Building id ${id} has height ${Height}."'
12    }
13 });

通过设置样式可以改变3dtiles颜色,甚至可以根据属性为模型设置不同的颜色,这让我们的白模稍微好看了一点,但也仅此而已了,我们想实现的是渐变颜色,而不是单调的纯色。我估计到了这里大部分小白就懵逼了,因为这个问题连谷哥和度娘都不能告诉你答案,难道我们就这样放弃了?不能够,去问问大牛吧,然后大牛很不屑的甩你一个词:shader!好吧,shader是what,不懂WebGL的小白又双叒叕得去做功课了。

Shader

shader,即着色器,分为顶点着色器(Vertex Shader)、片元着色器(Fragment Shader)、几何着色器(Geometry shader)、计算着色器(Compute shader)、细分曲面着色器(Tessellation or hull shader),其中可编程的是顶点着色器和片元着色器。至于它们的定义网上可以找到很多,但对于小白来讲看完还是一脸懵逼,我们需要一种通俗易懂的解释,这才符合深入浅出的精髓。在知乎上找到一段解释,感觉还不错:

当我们在屏幕上绘制或显示一些物体时,这些物体的显示形式是图元(Primitive)或者网格(Mesh),比如游戏中一个几何模型角色或一个贴在网格上的纹理角色,比如我们做阴影效果时先绘制网格再计算阴影,比如一个发射物体发射前需要先绘制该物体外形网格。这些物体都可归结为网格,它可被分解为图元,即图元是网格的基本单位。图元有三角形、直线或点。当我们在屏幕上画一个三角形时,首先要绘制顶点,因为网格由顶点组成,此时就要用到顶点着色器(Vertex shader),将需要到顶点信息给顶点着色器,以显示顶点信息;其次是在这些顶点组成的区域之间填充颜色,此时用到像素着色器(Pixel shader)或片元着色器(Fragment shader),片段(Fragment)有助于定义像素的最终颜色。

简单来说渲染流程如下:顶点数据(Vertices) > 顶点着色器(Vertex Shader) > 图元装配(Assembly) > 几何着色器(Geometry Shader) > 光栅化(Rasterization) > 片元着色器(Fragment Shader) > 逐片元处理(Per-Fragment Operations) > 帧缓冲(FrameBuffer),最后经过双缓冲的交换(SwapBuffer),渲染内容就显示到了屏幕上。从流程中我们可以看到,顶点着色器之后是图元装配,  图元装配通俗讲就是把图形放置到坐标系中。在片元着色器之前是   光栅化,光栅化是将图形投影到屏幕上,把图形栅格化成一个个的像素点,一个像素点也就是一个片元。在片元着色器之后是逐片元处理,  逐片元处理即填充颜色。再说白一点,顶点着色器负责坐标位置,片元着色器负责填充颜色。好吧,这个说法不一定很严谨,但一定足够浅显,如果我说的不对也欢迎拍砖。

终极原理

看到这里,小伙伴们肯定着急了,都说了那么多了,原理到底是个啥,究竟该如何下手呢。其实原理就是着色器编程,但是Cesium并没有为3dtiles的着色器编程的入口,那么只有一个办法了,那就是改源码。改源码似乎不是个多好的方案,源码那么复杂,看着头疼,而且每次更新版本都得再改一次,但是从另一个角度来讲,使用开源平台怎么能不会改源码呢,这也是必备技能吧。其实需要改的地方很简单,找到着色器编程部分,插入我们想要的语句就可以了。

具体实现

先说一下我修改的源码版本,Cesium-1.68\Build\CesiumUnminified\Cesium.js,因为是直接引用Cesium进行编程的,所以是选用的Build版的,如果是Source版的应该大同小异,可以自行研究。

要修改的地方共计4处,都是位于function generateTechnique$1(gltf, material, materialIndex, generatedMaterialValues, primitiveByMaterial, options) 方法里面,我会给出修改的行数,但只能做参考,我也给出了上下文,可以使用关键字来搜索。

第一处和第二处:约第117579行

 1 vertexShader += 'attribute vec3 a_position;\n';
 2 if (hasNormals) {
 3     vertexShader += 'varying vec3 v_positionEC;\n';
 4 }
 5 
 6 // 第一处添加
 7 vertexShader += 'varying vec3 v_helsing_position;\n';
 8 
 9 // Morph Target Weighting
10 vertexShaderMain += '    vec3 weightedPosition = a_position;\n';
11 if (hasNormals) {
12     vertexShaderMain += '    vec3 weightedNormal = a_normal;\n';
13     // 第二处添加
14     vertexShaderMain += '    v_helsing_position = a_position;\n';
15 }

第三处:约第117825行

 1 fragmentShader += '#ifdef USE_IBL_LIGHTING \n';
 2 fragmentShader += 'uniform vec2 gltf_iblFactor; \n';
 3 fragmentShader += '#endif \n';
 4 fragmentShader += '#ifdef USE_CUSTOM_LIGHT_COLOR \n';
 5 fragmentShader += 'uniform vec3 gltf_lightColor; \n';
 6 fragmentShader += '#endif \n';
 7 
 8 // 第三处添加
 9 fragmentShader += 'varying vec3 v_helsing_position;\n';
10 
11 fragmentShader += 'void main(void) \n{\n';
12 fragmentShader += fragmentShaderMain;

第四处:约第118135行

 1 if (defined(alphaMode)) {
 2     if (alphaMode === 'MASK') {
 3         fragmentShader += '    if (baseColorWithAlpha.a < u_alphaCutoff) {\n';
 4         fragmentShader += '        discard;\n';
 5         fragmentShader += '    }\n';
 6         fragmentShader += '    gl_FragColor = vec4(color, 1.0);\n';
 7     } else if (alphaMode === 'BLEND') {
 8         fragmentShader += '    gl_FragColor = vec4(color, baseColorWithAlpha.a);\n';
 9     } else {
10         fragmentShader += '    gl_FragColor = vec4(color, 1.0);\n';
11     }
12 } else {
13     fragmentShader += '    gl_FragColor = vec4(color, 1.0);\n';
14 }
15 
16 // 第四处添加
17 fragmentShader += '    float helsing_p = v_helsing_position.z / 20.0;\n';
18 fragmentShader += '    gl_FragColor *= vec4(helsing_p, helsing_p, helsing_p, 1.0);\n';
19 
20 fragmentShader += '}\n';

改完之后看看效果吧,一定很有成就感吧。什么?跟效果图不一样,出来的是黑白颜色的?没关系,还记得上面提到的样式么,修改成你想要的任何颜色吧。

还是原理

本想此篇到此就结束了,但细想一下,上面并没有介绍为什么要那么改,不讲原理直接讲操作就是耍流氓啊。没有WebGL基础的小伙伴看到上面的代码略微有点懵圈,貌似能看懂,又好像看不懂,其实那是着色器语言(GLSL),下面再简单了解一下着色器语言。由于内容较多,就不深入展开理论来讲了,就以上述的代码做例子,逐行解释一下。

第一处

 1 vertexShader += 'varying vec3 v_helsing_position;\n'; 

从名字我们知道vertexShader是顶点着色器,这是源码中已经定义好的变量,用来存储顶点着色器的代码,我们在其中插入了一行定义语句。varying是修饰符,它的主要作用是顶点着色器和片元着色器之间的数据传递,比如颜色或纹理坐标,而且片元着色器只能以只读的方式使用这个变量,不能修改其中的数据。简单来讲就是,顶点着色器定义,片元着色器使用。同为修饰符的还有const、attribute、uniform、centorid varying、invariant、in、out、inout等,后面涉及到了再讲。vec3是基本类型中的一种,在GLSL中除了在大部分编程语言中常见的int、float、bool等基本类型外,还有一些自己特有的类型,如vec2、vec3、vec4、mat2、mat3、mat4、sampler2D等。vec3指的是三维浮点数向量,定义方式如vec3 v = vec3(1.0, 1.0, 1.0)。什么?你不知道什么叫向量?那你初中物理一定没好好学。向量在物理学中称作矢量,在数学中称作向量,向量是指既有大小又有方向的量,如速度、 加速度、力、位移等;与之对应的是标量,又称为称“无向量”,是只具有数值大小而没有方向的量。从上述定义的变量名可以看出,我们定义的这个变量是用来存储坐标信息的。

第二处

 1 vertexShaderMain += ' v_helsing_position = a_position;\n'; 

这一处没什么好讲的,就是给我们定义的变量传值,把坐标信息传递过来。

第三处

 1 fragmentShader += 'varying vec3 v_helsing_position;\n'; 

有了上面的经验,根据变量名我们知道现在正在修改的是片元着色器。上面讲过了,varying修饰符的作用是,顶点着色器定义,片元着色器使用。所以在这里我们要再定义一次变量,好给片元着色器调用。

第四处

1 fragmentShader += '    float helsing_p = v_helsing_position.z / 20.0;\n';
2 fragmentShader += '    gl_FragColor *= vec4(helsing_p, helsing_p, helsing_p, 1.0);\n';

这是最后一处了,也是核心代码所在地了。在片元着色器中添加上这几行代码就能实现篇首的效果图,是不是很神奇?欢迎收看走近科学,下面让我带你们揭秘这些神奇的符号。

上面一股脑儿出现了好多神秘符号,有必要把它们都罗列出来,搞懂之后再看逻辑了。

基础类型:

float:浮点型标量。

vec4:四维浮点数向量。

内置变量:

gl_FragColor:表示当前片元的颜色,是vec4 类型的,变量名是以gl_开头的,我们可以想到它是GL的内置变量。

czm_frameNumber:看到名字是以czm_开头的,有童鞋就举手了:它是Cesium内置变量!是的,没错。Cesium也内置了很多变量,都是以czm_开头的,这些都是可以直接使用的,想了解更多的小伙伴请点这里。czm_frameNumber是float类型的,从字面理解就是当前的帧数,类似动画的帧。

内置函数:

fract(x):获取x的小数部分。

sina(angle):正弦函数,单位是弧度。

abs(x):返回x的绝对值。

clamp(x, minVal, maxVal):使返回值限制在minVal和maxVal之间,即min(max(x, minVal), maxVal)。

step(edge, x):如果x

代码解释:

先看第二句代码,gl_FragColor *= vec4(helsing_p, helsing_p, helsing_p, 1.0),这句其实很好理解,就是给gl_FragColor赋值,也就是给当前片元颜色赋值,p的值通过前面两句求出。

再看第一句代码,float helsing_p = v_helsing_position.z / 20.0,我们可以看出p值是跟高度(z值)有关的,模型上体现出来的效果就是楼宇越高越亮。我们看到了这里的高度除以了20,为什么要除以20呢?这其实是个经验值,它是根据平均楼宇的高度而定的,如果你的模型是农村地区的一片小平房,就要把这个值调低了,要不然就是灰蒙蒙的一片了。

小结

终于写完了!我们最后来回顾一下,看看是不是非常简单,几行代码就搞定了所谓很牛X的功能。正所谓难了不会,会了不难。当然最要紧的是掌握原理,知其然也要知其所以然。另外,好多网友跟我说修改源码的方式不太灵活,尤其是Cesium版本更新一次就得改一次,好像确实有点麻烦哦。好在万能的群友解决了这个问题,不过因为不是我的原创我就不在这里发了。我的文章目的还是要让人了解些原理,只要掌握了原理就可以不拘泥于实现方式了。