Cesium原理篇:5最长的一帧之影像
如果把地球比做一个人,地形就相当于这个人的骨骼,而影像就相当于这个人的外表了。之前的几个系列,我们全面的介绍了Cesium的地形内容,详见:
- Cesium原理篇:1最长的一帧之渲染调度
- Cesium原理篇:2最长的一帧之网格划分
- Cesium原理篇:3最长的一帧之地形(1)
- Cesium原理篇:3最长的一帧之地形(2:高度图)
- Cesium原理篇:3最长的一帧之地形(3:STK)
- Cesium原理篇:3最长的一帧之地形(4:重采样)
有了前面的“骨骼”,下面我们详细介绍一下影像篇的调度,以及最终如何结合地形的数据完成渲染的过程。
类关系概述
和TerrainProvider的类关系相似,ImageryProvider的创建也是从Globe类开始的。不过,在Cesium中,一个Globe只有一个TerrainProvider,而可以有多个ImageryProvider,比如Bing的, 天地图的,还有文字注记的,甚至在加上局部范围,自定义的Provider,在实际中,这种使用场景是很常见的,就想一个人,只有一副骨架,但可以搭配多件衣服一个道理。因此,在Globe中提供了ImageryLayerCollection成员,用来管理多个ImageryProvider。
对于ImageryProvider,Cesium还做了一层封装,通过ImageryLayer来封装不同的Provider,Provider用来负责切片数据的下载,工作的成果则通过ImageryLayer来管理,比如计算需要的瓦片数据,发送切片请求,判断是否在缓存中已经有了Imagery(切片数据),对数据进行动态投影的换算,切片数据创建对应纹理等,都是ImageryLayer来完成的。
最后就落到了Imagery,每一个瓦片对应一个Imagery,自己把自己的事情做好(动态投影,创建纹理),维护好自身的状态,不给组织添麻烦。
综上所述,大概的类关系如下:
创建Imagery
有了上面的初始化过程后,我们开始讨论地球网格调度的过程,Cesium是以地形Tile为标准来调度的。针对每一个地形Tile,提供prepareNewTile方法来创建地形和影像的Tile,地形的我们之前在《Cesium原理篇:3最长的一帧之地形(1) 》已经详细讨论过了,如下是影像部分的代码:
// 请求地球网格 function prepareNewTile(tile, terrainProvider, imageryLayerCollection) { // 地形部分呢代码…… // 遍历imageryLayerCollection中对应的ImageryProvider for (var i = 0, len = imageryLayerCollection.length; i < len; ++i) { var layer = imageryLayerCollection.get(i); if (layer.show) { // 通过Provider·创建对应Tile的Imagery layer._createTileImagerySkeletons(tile, terrainProvider); } } }
这里就有一个问题,也就是地形的坐标系和影像坐标系可能不一致的情况。之前我们提到过,地形数据一般都是WGS84,而基本上,所有在线数据都是墨卡托投影。这样,地形的Tile(XYZ)和影像的Tile(XYZ)就不是一一对应的关系了。而_createTileImagerySkeletons函数就是来计算这个映射关系,确定每一个地形的tile所对应哪些Imagery Tile。如果地形和影像的坐标系是一致的,那地形和影像Tile是1:1的对应关系,如果两者不一致,则需要额外处理了。伪代码逻辑如下:
ImageryLayer.prototype._createTileImagerySkeletons = function(tile, terrainProvider, insertionPoint) { // 获取当前地形Tile的有效的经纬度范围 var rectangle = Rectangle.intersection(tile.rectangle, imageryBounds, tileImageryBoundsScratch); // 获取该影像服务的投影坐标,WGS84 or Mercator var imageryTilingScheme = imageryProvider.tilingScheme; // 计算地形Tile有效范围的西北(左上角) 对应影像的XY序号 var northwestTileCoordinates = imageryTilingScheme.positionToTileXY(Rectangle.northwest(rectangle), imageryLevel); // 计算地形Tile有效范围的东南(右下角) 对应影像的XY序号 var southeastTileCoordinates = imageryTilingScheme.positionToTileXY(Rectangle.southeast(rectangle), imageryLevel); // 通过两个for循环,遍历TileCoordinates,也就获取到该地形Tile所需要的影像切片了 for ( var i = northwestTileCoordinates.x; i <= southeastTileCoordinates.x; i++) { for ( var j = northwestTileCoordinates.y; j <= southeastTileCoordinates.y; j++) { // 判断该影像切片是否已经创建了 // 因为有可能出现相邻两个地形的Tile,一个需要影像切片的上半部分,一个需要下半部分 var imagery = this.getImageryFromCache(i, j, imageryLevel, imageryRectangle); // 引用计数,将需要的imagery绑定到对应的GlobeSurfaceTile上 surfaceTile.imagery.splice(insertionPoint, 0, new TileImagery(imagery, texCoordsRectangle)); } } }
这样,我们就获取了需要的影像切片,接着就是下载,创建纹理,纠偏,足够幸运的话,最终会渲染到屏幕上,这个逻辑的代码实现如下:
// TileImagery调用Imagery实现影像切片的相关调度 TileImagery.prototype.processStateMachine = function(tile, frameState) { var loadingImagery = this.loadingImagery; loadingImagery.processStateMachine(frameState); } // 基于状态的影像数据调度 Imagery.prototype.processStateMachine = function(frameState) { // 如果该影像切片没有下载,则下载 if (this.state === ImageryState.UNLOADED) { this.state = ImageryState.TRANSITIONING; this.imageryLayer._requestImagery(this); } // 下载后创建对应的纹理 if (this.state === ImageryState.RECEIVED) { this.state = ImageryState.TRANSITIONING; this.imageryLayer._createTexture(frameState.context, this); } // 进行投影换算,纠偏 if (this.state === ImageryState.TEXTURE_LOADED) { this.state = ImageryState.TRANSITIONING; this.imageryLayer._reprojectTexture(frameState, this); } };
ReprojectTexture
这里代码都比较容易理解,着重讲一下这个投影转换的过程,先看如下两个图:
前者是WSG84,后者是墨卡托下对应地球全幅的效果,可见前者长宽比是2:1,而后者是1:1.因此,总体来说,如果对两者做四叉树剖分,前者需要先竖直切两半(X方向),剩下的都一样(Y方向)。这样,动态投影的过程可以粗略的认为就是把下面这张图拉伸成上面这个图的过程。
如果大家对动态投影有一定了解的话,应该知道这个过程的计算量是很大的,而我们毕竟是JS的应用,对此Cesium采用了两个策略,一是简化数据,将这个256*256简化为2*64大小,类似扫描行来矫正,二是通过Shader,通过GPU RTT的方式,从硬件上来实现高效转换。具体的实现函数是reprojectToGeographic,Cesium做了很详细的解释,为何最终选择这种方式,比如对移动平台的考虑等,有兴趣的可以看一下源码,这里仅给出最终position和纹理uv的计算过程,最终在shader中就是将图片当前position对应的位置,赋予纹理中对应uv的像素值。
// position var positions = new Float32Array(2 * 64 * 2); var index = 0; for (var j = 0; j < 64; ++j) { var y = j / 63.0; positions[index++] = 0.0; positions[index++] = y; positions[index++] = 1.0; positions[index++] = y; } // 经纬度下对应的uv值 for (var webMercatorTIndex = 0; webMercatorTIndex < 64; ++webMercatorTIndex) { var fraction = webMercatorTIndex / 63.0; var latitude = CesiumMath.lerp(south, north, fraction); sinLatitude = Math.sin(latitude); var mercatorY = 0.5 * Math.log((1.0 + sinLatitude) / (1.0 - sinLatitude)); var mercatorFraction = (mercatorY - southMercatorY) * oneOverMercatorHeight; webMercatorT[outputIndex++] = mercatorFraction; webMercatorT[outputIndex++] = mercatorFraction; }
换句话说,通过上面的转换算法,对关键点构成三角网,其他的点在片元中插值,这样生成一张新的纹理(RTT),将经过坐标系转换的纹理替换之前原始的墨卡托纹理。这里回答之前的一个情况:如果地形也是采用Mercator(只有默认的EllipsoidTerrainProvider可以选择这种坐标系),影像也是Mercator,这样就不需要投影转换,性能上应该会更好吧。理论上确实如此,但实际上,通过代码,Cesium并没有考虑过这种情况,所以只要判断影像不是WGS84的,统一都做了一次转换。换个角度来说,我发现即使不做投影转换,肉眼看上去,效果上并没有什么差别。
DrawCommandsForTile
讲到这,终于到了这一帧的最后时刻,历尽千辛万苦,百般阻挠,强壮了我的骨骼,滋润了我的肌肤后,终于进入了渲染环节。
Cesium的渲染都是通过DrawCommand来完成,这一块的理解需要对Render模块有一个认识,所以这里也不打算展开讲。简单的说,主要是VertexArray来绑定VBO(地形数据),通过uniformMap来传递顶点和片元着色器的参数,而通过dayTextures将该Tile对应的多个影响纹理传入到Shader中。下面,主要介绍一下多个纹理叠加和水面的实现。
多重纹理
为了考虑多重纹理的可能,Cesium在GlobeSurfaceShaderSet.prototype.getShaderProgram中用一个笨方法来处理:
var computeDayColor = '\ vec4 computeDayColor(vec4 initialColor, vec2 textureCoordinates)\n\ {\n\ vec4 color = initialColor;\n'; for (var i = 0; i < numberOfDayTextures; ++i) { computeDayColor += '\ color = sampleAndBlend(\n\ color,\n\ u_dayTextures[' + i + '],\n\ textureCoordinates,\n\ u_dayTextureTexCoordsRectangle[' + i + '],\n\ u_dayTextureTranslationAndScale[' + i + '],\n\ ' + (applyAlpha ? 'u_dayTextureAlpha[' + i + ']' : '1.0') + ',\n\ ' + (applyBrightness ? 'u_dayTextureBrightness[' + i + ']' : '0.0') + ',\n\ ' + (applyContrast ? 'u_dayTextureContrast[' + i + ']' : '0.0') + ',\n\ ' + (applyHue ? 'u_dayTextureHue[' + i + ']' : '0.0') + ',\n\ ' + (applySaturation ? 'u_dayTextureSaturation[' + i + ']' : '0.0') + ',\n\ ' + (applyGamma ? 'u_dayTextureOneOverGamma[' + i + ']' : '0.0') + '\n\ );\n'; } computeDayColor += '\ return color;\n\ }';
半自动植入计算computeDayColor的方法,其中,sampleAndBlend是shader中自带的函数,通过这些参数来获取纹理对应位置的颜色,而computeDayColor本身就是一个for循环,实现该位置下多个颜色的叠加,这样做的好处是里面的参数很多,而且不是定长的,所以避开了传参的麻烦。只要了解了这个过程,我们在看GlobeFS.glsl就简单多了:
vec4 color = computeDayColor(u_initialColor, clamp(v_textureCoordinates, 0.0, 1.0));
轻松一句话,实现了多重纹理叠加,处理起来也方便很多,看来笨也有笨的智慧。当然,这里还有一个纹理纠偏的处理。有可能一个地形切片各占两个影像切片的一部分,这样,纹理对应地形切片的起始点就会有一个偏移和缩放的处理,保质两者匹配吻合。
ImageryLayer.prototype._calculateTextureTranslationAndScale = function(tile, tileImagery) { var imageryRectangle = tileImagery.readyImagery.rectangle; var terrainRectangle = tile.rectangle; var terrainWidth = terrainRectangle.width; var terrainHeight = terrainRectangle.height; var scaleX = terrainWidth / imageryRectangle.width; var scaleY = terrainHeight / imageryRectangle.height; // xy为偏移,zw为缩放 return new Cartesian4( scaleX * (terrainRectangle.west - imageryRectangle.west) / terrainWidth, scaleY * (terrainRectangle.south - imageryRectangle.south) / terrainHeight, scaleX, scaleY); }; // 片元中纹理计算公式 vec2 textureCoordinates = tileTextureCoordinates * scale + translation;
水面
坦白说,这块我也是一知半解,里面有两个关键的参数,waterMask和oceanNormalMap,时间是根据czm_frameNumber来模拟的。坦白说,这部分代码的物理原理我还真不清楚,最终就是各类反射光的叠加,不多说废话了,等以后有机会再说吧。该方法可参考:
vec4 computeWaterColor(vec3 positionEyeCoordinates, vec2 textureCoordinates, mat3 enuToEye, vec4 imageryColor, float maskValue)
至此,最长的一帧之Cesium告一段落,个人尽力详细介绍了Cesium整个球在渲染过程中的相关细节,希望对大家会有所收获。后面,会继续在应用,原理上继续深入的学习,研究和分享关于Cesium的个人见解。