cesium加载SEM纹理合并

在使用 Cesium 进行三维可视化开发时,纹理处理是影响应用性能和视觉质量的关键因素。尤其是在加载大规模三维模型时,如何有效管理和优化纹理资源变得尤为重要。本文将详细介绍纹理合并(Texture Atlas)技术在 Cesium 中的实现,以及如何通过这一技术显著提升渲染性能。

为什么需要纹理合并?

是因为这个一个 cityjson 里边有多张图片,而一个 cityjson 是一个 drawcommand, 里边只能创建一个 Texture 对象,需要把多张图合并成一张大图传入着色器里边

但是这种方法也不太好,图片大的时候就会占的显存多,应该使用纹理压缩技术,例如 KTX 压缩

纹理合并的实现原理

纹理合并的核心思想是将多个独立的纹理图像拼接到一个更大的”纹理图集”(Texture Atlas)中,并相应地调整 UV 坐标,使模型仍能正确引用到对应的纹理区域。

纹理合并示意图

实现这一过程主要包括以下几个步骤:

  1. 加载所有原始纹理图像
  2. 计算合并后的纹理尺寸
  3. 为每个原始纹理分配在合并纹理中的位置(偏移量)
  4. 更新模型的 UV 坐标以匹配新布局
  5. 创建合并纹理并渲染模型

接下来,我们将通过代码详细解析这一过程。

代码实现详解

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
);
};

这个函数协调了整个纹理合并流程:

  1. 加载和处理图像数据
  2. 计算合并纹理的尺寸
  3. 计算每个原始纹理在合并纹理中的偏移量
  4. 更新 UV 坐标
  5. 创建并渲染使用合并纹理的基元(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,
// ...其他属性
});

最后,这部分代码:

  1. 创建合并纹理
  2. 将每个原始纹理复制到合并纹理的正确位置
  3. 生成纹理的 mipmap(用于不同距离的细节级别)
  4. 创建着色器程序和统一变量映射
  5. 创建绘制命令以渲染使用合并纹理的模型

深入理解 vTexture.copyFrom 方法

在纹理合并的整个过程中,vTexture.copyFrom() 方法是最核心的操作,它实际执行了将多个图像”拼接”到一个大纹理中的过程。让我们深入了解这个方法的工作原理、参数和性能特性。

copyFrom 方法详解

1
2
3
4
5
vTexture.copyFrom({
source: item.image,
xOffset: item.xOffset,
yOffset: item.yOffset,
});

参数解析

  1. source: 源图像数据,可以是多种类型:

    • HTMLImageElement: 常规的图像元素
    • HTMLCanvasElement: Canvas 元素
    • HTMLVideoElement: 视频元素
    • ImageData: 原始图像数据
    • TypedArray: 类型化数组
  2. xOffset/yOffset: 目标位置偏移量,指定源图像应该复制到目标纹理的哪个位置(以像素为单位)。

工作原理

当调用copyFrom方法时,Cesium 在底层执行以下操作:

  1. 获取 WebGL 上下文
  2. 绑定目标纹理
  3. 使用gl.texSubImage2D将源图像数据复制到目标纹理的指定位置
  4. 保留纹理的其余部分不变
1
2
3
4
5
6
7
8
9
10
11
// Cesium内部实现的简化版本
gl.bindTexture(gl.TEXTURE_2D, textureId);
gl.texSubImage2D(
gl.TEXTURE_2D, // 目标
0, // mipmap级别
xOffset, // X偏移
yOffset, // Y偏移
gl.RGBA, // 格式
gl.UNSIGNED_BYTE, // 类型
source // 源数据
);

性能优势

copyFrom方法比传统的 Canvas 合并方法有几个显著的性能优势:

  1. 直接 GPU 操作: 它直接在 GPU 内存中执行复制,避免了 CPU 与 GPU 之间的大量数据传输
  2. 避免创建中间 Canvas: 不需要先在 Canvas 上绘制然后再将整个 Canvas 上传到 GPU
  3. 增量更新能力: 可以只更新纹理的一部分,而不需要重新上传整个纹理
  4. 内存效率: 直接操作已分配的纹理内存,不需要额外的临时缓冲区

使用示例

以下是一个更完整的例子,展示了如何利用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,
});
});

// 生成mipmap
texture.generateMipmap();

return {
texture: texture,
positions: positions,
};
}

总结

纹理合并是 Cesium 应用中一项强大的性能优化技术。通过将多个小纹理合并为一个大纹理,可以显著减少绘制调用次数、优化内存使用、提高批处理效率,并最终提升应用的整体性能和用户体验。

作者

wangyawei

发布于

2025-03-21

更新于

2026-09-11

许可协议