在使用 Cesium 进行三维可视化开发时,纹理处理是影响应用性能和视觉质量的关键因素。尤其是在加载大规模三维模型时,如何有效管理和优化纹理资源变得尤为重要。本文将详细介绍纹理合并(Texture Atlas)技术在 Cesium 中的实现,以及如何通过这一技术显著提升渲染性能。
为什么需要纹理合并? 是因为这个一个 cityjson 里边有多张图片,而一个 cityjson 是一个 drawcommand, 里边只能创建一个 Texture 对象,需要把多张图合并成一张大图传入着色器里边
但是这种方法也不太好,图片大的时候就会占的显存多,应该使用纹理压缩技术,例如 KTX 压缩
纹理合并的实现原理 纹理合并的核心思想是将多个独立的纹理图像拼接到一个更大的”纹理图集”(Texture Atlas)中,并相应地调整 UV 坐标,使模型仍能正确引用到对应的纹理区域。
实现这一过程主要包括以下几个步骤:
加载所有原始纹理图像
计算合并后的纹理尺寸
为每个原始纹理分配在合并纹理中的位置(偏移量)
更新模型的 UV 坐标以匹配新布局
创建合并纹理并渲染模型
接下来,我们将通过代码详细解析这一过程。
代码实现详解 1. 整体流程 首先,让我们看看整个纹理合并过程的主要函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 SemModel .prototype .renderMoreDetialModel = async function ( key, rtcCenter, newVertices, newFaceIds, imgList, imageIds, textures, attributes, surfaceType, coordSystem, lod ) { const { imageSizes, imageData } = await this .calculateImageData (imageIds, imgList); const textureSize = this .calculateTextureSize (imageSizes, 3 ); this .calculateImageOffset (imageSizes, imageData); const mergeTextureUVs = this .updateTextureUvs (textures, textureSize, imageData); this .createCityPrimitive ( key, newFaceIds, newVertices, mergeTextureUVs, imageData, textureSize, attributes, surfaceType, rtcCenter, coordSystem, lod ); };
这个函数协调了整个纹理合并流程:
加载和处理图像数据
计算合并纹理的尺寸
计算每个原始纹理在合并纹理中的偏移量
更新 UV 坐标
创建并渲染使用合并纹理的基元(Primitive)
2. 加载图像数据 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 SemModel .prototype .calculateImageData = async function (imageIds, imgList ) { const imageSizes = []; const imageData = []; for (let i = 0 ; i < imageIds.length ; i++) { const imageId = imageIds[i]; const buffer = imgList[imageId]; const image = await this .getImage (buffer, imageIds); imageSizes.push ({ width : image.width , height : image.height , }); imageData.push ({ id : imageId, image : image, buffer : buffer, width : image.width , height : image.height , xOffset : 0 , yOffset : 0 , }); } return { imageSizes, imageData }; }; SemModel .prototype .getImage = async function (arrayBuffer ) { return new Promise ((resolve, reject ) => { const blob = new Blob ([arrayBuffer]); const img = new Image (); img.onload = () => { resolve (img); }; img.onerror = reject; const url = URL .createObjectURL (blob); img.src = url; }); };
这部分代码负责:
从二进制数据加载图像
收集图像尺寸信息
准备用于合并纹理的基础数据结构
需要注意的是,图像加载是异步的,使用了 Promise 和 async/await 处理。
3. 计算纹理尺寸 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 SemModel .prototype .calculateTextureSize = function (images, maxPerRow ) { let totalWidth = 0 ; let totalHeight = 0 ; let currentRowWidth = 0 ; let currentRowHeight = 0 ; images.forEach ((image, index ) => { currentRowWidth += image.width ; currentRowHeight = Math .max (currentRowHeight, image.height ); if ((index + 1 ) % maxPerRow === 0 || index === images.length - 1 ) { totalWidth = Math .max (totalWidth, currentRowWidth); totalHeight += currentRowHeight; currentRowWidth = 0 ; currentRowHeight = 0 ; } }); return { width : totalWidth, height : totalHeight }; };
这个函数计算合并后的纹理尺寸:
将原始纹理排列成网格(每行最多maxPerRow个纹理)
计算每行的宽度和高度
汇总得到最终的纹理尺寸
这种网格布局是纹理合并的常用策略,它平衡了宽度和高度,避免了纹理过细过长。
4. 计算纹理偏移量 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 SemModel .prototype .calculateImageOffset = function (imageSizes, imageData ) { let xOffset = 0 ; let yOffset = 0 ; let currentRowWidth = 0 ; let currentRowHeight = 0 ; imageSizes.forEach ((image, index ) => { imageData[index].xOffset = xOffset; imageData[index].yOffset = yOffset; currentRowWidth += image.width ; xOffset += image.width ; currentRowHeight = Math .max (currentRowHeight, image.height ); if ((index + 1 ) % 3 === 0 || index === imageSizes.length - 1 ) { yOffset += currentRowHeight; xOffset = 0 ; currentRowWidth = 0 ; currentRowHeight = 0 ; } }); };
这个函数为每个原始纹理分配在合并纹理中的位置:
从左上角开始,水平排列纹理
每行最多 3 个纹理(与前面的maxPerRow对应)
记录每个纹理的xOffset和yOffset,这将用于后续的 UV 坐标计算和纹理创建
5. 更新 UV 坐标 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 SemModel .prototype .updateTextureUvs = function (textures, textureSize, imageData ) { const textureData = textures.data ; const mergeTextureUVs = []; const { width : textureWidth, height : textureHeight } = textureSize; for (let i = 0 ; i < textureData.length ; i++) { const texture = textureData[i]; const imageId = texture.index ; const uvs = texture.uvs ; const index = imageData.findIndex (item => item.id === imageId); if (!~index) { continue ; } const imageObj = imageData[index]; const { width, height, xOffset, yOffset } = imageObj; const newUv = [ uvs[0 ] * (width / textureWidth) + xOffset / textureWidth, uvs[1 ] * (height / textureHeight) + yOffset / textureHeight, ]; mergeTextureUVs.push (newUv[0 ], newUv[1 ]); } return mergeTextureUVs; };
这个函数执行最关键的 UV 坐标转换:
将原始纹理的 UV 坐标(范围 0-1)转换为合并纹理中的相对坐标
考虑了原始纹理的大小比例和在合并纹理中的偏移量
这确保了模型在使用合并纹理时仍能正确映射到对应的纹理区域
6. 创建纹理并设置着色器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 createTexture (imageData ) { const context = this ._viewer .scene .context ; const vTexture = new Cesium .Texture ({ context : context, width : this .textureSize .width , height : this .textureSize .height , }); if (!imageData.length ) { return ; } imageData.forEach ((item ) => { vTexture.copyFrom ({ source : item.image , xOffset : item.xOffset , yOffset : item.yOffset , }); }); vTexture.generateMipmap (); this ._texture = vTexture; } const uniformMap = { image : () => { return Cesium .defined (this ._texture ) ? this ._texture : this ._viewer .scene .context .defaultTexture ; }, }; const shaderProgram = Cesium .ShaderProgram .fromCache ({ context : context, vertexShaderSource : vertexShaderSource, fragmentShaderSource : fragmentShaderSource, attributeLocations : attributeLocations, }); this .command = new Cesium .DrawCommand ({ shaderProgram : shaderProgram, uniformMap : uniformMap, });
最后,这部分代码:
创建合并纹理
将每个原始纹理复制到合并纹理的正确位置
生成纹理的 mipmap(用于不同距离的细节级别)
创建着色器程序和统一变量映射
创建绘制命令以渲染使用合并纹理的模型
深入理解 vTexture.copyFrom 方法 在纹理合并的整个过程中,vTexture.copyFrom() 方法是最核心的操作,它实际执行了将多个图像”拼接”到一个大纹理中的过程。让我们深入了解这个方法的工作原理、参数和性能特性。
copyFrom 方法详解 1 2 3 4 5 vTexture.copyFrom ({ source : item.image , xOffset : item.xOffset , yOffset : item.yOffset , });
参数解析
source : 源图像数据,可以是多种类型:
HTMLImageElement: 常规的图像元素
HTMLCanvasElement: Canvas 元素
HTMLVideoElement: 视频元素
ImageData: 原始图像数据
TypedArray: 类型化数组
xOffset/yOffset : 目标位置偏移量,指定源图像应该复制到目标纹理的哪个位置(以像素为单位)。
工作原理 当调用copyFrom方法时,Cesium 在底层执行以下操作:
获取 WebGL 上下文
绑定目标纹理
使用gl.texSubImage2D将源图像数据复制到目标纹理的指定位置
保留纹理的其余部分不变
1 2 3 4 5 6 7 8 9 10 11 gl.bindTexture (gl.TEXTURE_2D , textureId); gl.texSubImage2D ( gl.TEXTURE_2D , 0 , xOffset, yOffset, gl.RGBA , gl.UNSIGNED_BYTE , source );
性能优势 copyFrom方法比传统的 Canvas 合并方法有几个显著的性能优势:
直接 GPU 操作 : 它直接在 GPU 内存中执行复制,避免了 CPU 与 GPU 之间的大量数据传输
避免创建中间 Canvas : 不需要先在 Canvas 上绘制然后再将整个 Canvas 上传到 GPU
增量更新能力 : 可以只更新纹理的一部分,而不需要重新上传整个纹理
内存效率 : 直接操作已分配的纹理内存,不需要额外的临时缓冲区
使用示例 以下是一个更完整的例子,展示了如何利用copyFrom创建动态纹理图集:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 function createDynamicTextureAtlas (images ) { const texture = new Cesium .Texture ({ context : viewer.scene .context , width : atlasWidth, height : atlasHeight, pixelFormat : Cesium .PixelFormat .RGBA , pixelDatatype : Cesium .PixelDatatype .UNSIGNED_BYTE , }); let x = 0 , y = 0 , rowHeight = 0 ; const positions = []; images.forEach (image => { if (x + image.width > atlasWidth) { x = 0 ; y += rowHeight; rowHeight = 0 ; } positions.push ({ image : image, x : x, y : y, }); x += image.width ; rowHeight = Math .max (rowHeight, image.height ); }); positions.forEach (pos => { texture.copyFrom ({ source : pos.image , xOffset : pos.x , yOffset : pos.y , }); }); texture.generateMipmap (); return { texture : texture, positions : positions, }; }
总结 纹理合并是 Cesium 应用中一项强大的性能优化技术。通过将多个小纹理合并为一个大纹理,可以显著减少绘制调用次数、优化内存使用、提高批处理效率,并最终提升应用的整体性能和用户体验。