第一篇章:Cesium体渲染概述
引言
随着空间数据和三维地理信息的日益复杂,3D可视化和空间分析变得越来越重要。Cesium作为一个开源的Web端三维地球可视化引擎,提供了强大的工具集,使得用户能够在浏览器中加载、渲染和交互操作各种类型的空间数据。而在这些数据中,体积数据(Voxel Data)作为一种常见的三维数据形式,成为了很多应用场景中的关键组成部分。
体渲染(Voxel Rendering)是一种渲染体积数据的技术,它与传统的网格渲染不同,通过使用体素(Voxel)来表示和渲染三维空间中的数据。体素类似于像素,但它是一个三维的单元格,用于表示空间中的一小块区域。通过体渲染,Cesium能够有效地可视化大规模的体积数据,例如地形、气象数据、三维地图等。
本篇章将介绍Cesium体渲染的基本原理,重点讲解体渲染管线的各个阶段,包括数据加载、着色器更新、裁剪与视口判断、渲染命令的生成等,同时也会展示一些具体的代码示例,帮助开发者理解如何在Cesium中实现高效的体渲染。
TilesBuilder: TilesBuilder提供一个高效、兼容、优化的数据转换工具,一站式完成数据转换、数据发布、数据预览操作。
体渲染基础
体渲染(Voxel Rendering)是渲染三维体积数据的一种技术。与传统的网格渲染(Mesh Rendering)相比,体渲染使用体素(Voxel)来表示三维空间中的数据。体素通常是一个立方体或类似的单元,它能够表示一个三维空间元素。每个体素包含一定的属性值,如颜色、密度、强度等,这些属性在渲染过程中被用来计算物体的外观。
在Cesium中,体渲染技术被广泛应用于地形数据的可视化、体积数据的展示以及其他空间分析任务。例如,地形数据的表示通常通过体积数据来实现,这使得Cesium能够呈现更为丰富和准确的三维地球视图。
Cesium体渲染管线
Cesium的体渲染过程包括多个关键阶段,涉及数据初始化、着色器更新、体素数据处理、裁剪与渲染命令生成等。下面将详细介绍这些阶段,并结合具体代码示例进行解释。
1. 数据准备与初始化
在Cesium中,体渲染通常需要从一个数据提供者(provider)获取数据,并根据数据的状态进行初始化。数据提供者可以是一个体素数据集、一个栅格图像,或者其他空间数据源。在初次渲染时,Cesium会进行初始化操作,例如加载体素数据、设置变换矩阵等。
VoxelPrimitive.prototype.update = function (frameState) {
const provider = this._provider;
const context = frameState.context;
// 仅在第一次调用时执行初始化
if (!this._ready) {
initFromProvider(this, provider, context);
// 在第一次渲染后标记为已准备好
frameState.afterRender.push(() => {
this._ready = true;
return true;
});
// 返回,直到下一帧再开始渲染
return;
}
};
这段代码展示了如何在第一次渲染时初始化数据,并在初始化完成后标记体素数据为“已准备好”,确保后续的渲染流程能够顺利进行。
2. 着色器更新与处理
体渲染的一个关键技术是自定义着色器。每一帧渲染时,Cesium会检查体素数据的变化,如果数据发生变化,需要重新编译和更新着色器。具体代码如下:
if (this._shaderDirty) {
buildVoxelDrawCommands(this, context);
this._shaderDirty = false;
}
当体素数据发生变化时,_shaderDirty标志会被设置为true,表示需要重新构建渲染命令并更新着色器。通过这一过程,Cesium能够保证每一帧渲染的数据和着色效果都能与最新的状态一致。
3. 体素数据更新
在体渲染中,体素数据可能会发生变化,因此每一帧都需要检查数据是否需要更新。以下代码检查体素数据是否已更改,并进行更新操作:
const shapeDirty = checkTransformAndBounds(this, provider);
const shape = this._shape;
if (shapeDirty) {
this._shapeVisible = updateShapeAndTransforms(this, shape, provider);
if (checkShapeDefines(this, shape)) {
this._shaderDirty = true; // 标记着色器为脏
}
}
这段代码在每一帧中检查体素形状和变换是否发生变化。如果数据发生了变化,标记着色器为脏(_shaderDirty = true),表示需要重新构建渲染命令。
4. 裁剪与视口判断
为了提升渲染性能,Cesium会在渲染前进行裁剪操作,剔除视口外的体素数据。具体代码如下:
const ndcAabb = orientedBoundingBoxToNdcAabb(
orientedBoundingBox,
transformPositionWorldToProjection,
scratchNdcAabb,
);
const offscreen =
ndcAabb.x === +1.0 ||
ndcAabb.y === +1.0 ||
ndcAabb.z === -1.0 ||
ndcAabb.w === -1.0;
if (offscreen) {
return; // 如果体素超出视口,跳过渲染
}
这段代码通过计算体素的AABB(轴对齐包围盒)在NDC空间中的位置,判断体素是否在视口内。如果超出视口范围,则跳过渲染,避免不必要的计算。
5. 渲染命令与提交
一旦所有更新操作完成,Cesium会生成渲染命令,并将其提交给渲染管线进行渲染。渲染命令包含着色器、纹理、变换矩阵等信息,用于控制如何绘制体素数据。
const command = frameState.passes.pick
? this._drawCommandPick
: frameState.passes.pickVoxel
? this._drawCommandPickVoxel
: this._drawCommand;
command.boundingVolume = shape.boundingSphere;
frameState.commandList.push(command);
根据当前渲染模式,选择合适的绘制命令,并将其添加到commandList中,等待渲染执行。
6. 调试与优化
Cesium还提供了调试功能,帮助开发者在渲染过程中监控和优化渲染效果。例如,使用debugDraw函数绘制体素数据的边界框:
if (this._debugDraw) {
debugDraw(this, frameState); // 绘制调试信息
}
调试功能允许开发者实时查看渲染过程中的信息,帮助检测问题并优化性能。
7.完整代码
VoxelPrimitive.prototype.update = function (frameState) {
const provider = this._provider;
// 更新自定义着色器,处理可能的纹理uniforms
this._customShader.update(frameState);
// 从已经准备好的provider初始化,仅在第一次调用时执行
const context = frameState.context;
if (!this._ready) {
initFromProvider(this, provider, context);
// 在第一次渲染后标记为已准备好,确保订阅的事件能在第一次渲染后处理
frameState.afterRender.push(() => {
this._ready = true;
return true;
});
// 返回,直到下一帧再开始渲染
return;
}
// 更新竖直夸张(通常用于增强地形或体积的视觉效果)
updateVerticalExaggeration(this, frameState);
// 检查形状是否发生变化,通常在每一帧都需要重新计算
const shapeDirty = checkTransformAndBounds(this, provider);
const shape = this._shape;
if (shapeDirty) {
this._shapeVisible = updateShapeAndTransforms(this, shape, provider);
if (checkShapeDefines(this, shape)) {
this._shaderDirty = true; // 如果形状定义发生变化,标记着色器为脏
}
}
if (!this._shapeVisible) {
return; // 如果形状不可见,跳过渲染
}
// 更新遍历并准备渲染
const keyframeLocation = getKeyframeLocation(
provider.timeIntervalCollection,
this._clock,
);
const traversal = this._traversal;
const sampleCountOld = traversal._sampleCount;
traversal.update(
frameState,
keyframeLocation,
shapeDirty, // 是否需要重新计算边界
this._disableUpdate, // 是否暂停更新
);
// 如果采样数量发生变化,标记着色器为脏
if (sampleCountOld !== traversal._sampleCount) {
this._shaderDirty = true;
}
if (!traversal.isRenderable(traversal.rootNode)) {
return; // 如果遍历不可渲染,跳过渲染
}
// 如果启用了调试绘制,绘制调试信息(例如边界框)
if (this._debugDraw) {
debugDraw(this, frameState);
}
if (this._disableRender) {
return; // 如果禁用了渲染,跳过渲染
}
// 检查是否使用了对数深度
if (this._useLogDepth !== frameState.useLogDepth) {
this._useLogDepth = frameState.useLogDepth;
this._shaderDirty = true;
}
// 检查裁剪平面是否发生变化
const clippingPlanesChanged = updateClippingPlanes(this, frameState);
if (clippingPlanesChanged) {
this._shaderDirty = true;
}
// 更新叶节点纹理的uniform
const leafNodeTexture = traversal.leafNodeTexture;
const uniforms = this._uniforms;
if (defined(leafNodeTexture)) {
uniforms.octreeLeafNodeTexture = traversal.leafNodeTexture;
uniforms.octreeLeafNodeTexelSizeUv = Cartesian2.clone(
traversal.leafNodeTexelSizeUv,
uniforms.octreeLeafNodeTexelSizeUv,
);
uniforms.octreeLeafNodeTilesPerRow = traversal.leafNodeTilesPerRow;
}
// 如果着色器标记为脏,重新构建绘制命令
if (this._shaderDirty) {
buildVoxelDrawCommands(this, context);
this._shaderDirty = false;
}
// 计算NDC空间的AABB,以便进行裁剪("scissor"操作)
const transformPositionWorldToProjection =
context.uniformState.viewProjection;
const orientedBoundingBox = shape.orientedBoundingBox;
const ndcAabb = orientedBoundingBoxToNdcAabb(
orientedBoundingBox,
transformPositionWorldToProjection,
scratchNdcAabb,
);
// 如果对象超出视口范围,则不渲染
const offscreen =
ndcAabb.x === +1.0 ||
ndcAabb.y === +1.0 ||
ndcAabb.z === -1.0 ||
ndcAabb.w === -1.0;
if (offscreen) {
return;
}
// 准备渲染:更新可能每帧变化的uniform
uniforms.ndcSpaceAxisAlignedBoundingBox = Cartesian4.clone(
ndcAabb,
uniforms.ndcSpaceAxisAlignedBoundingBox,
);
// 更新视图变换矩阵
const transformPositionViewToWorld = context.uniformState.inverseView;
uniforms.transformPositionViewToUv = Matrix4.multiplyTransformation(
this._transformPositionWorldToUv,
transformPositionViewToWorld,
uniforms.transformPositionViewToUv,
);
const transformPositionWorldToView = context.uniformState.view;
uniforms.transformPositionUvToView = Matrix4.multiplyTransformation(
transformPositionWorldToView,
this._transformPositionUvToWorld,
uniforms.transformPositionUvToView,
);
// 更新方向变换矩阵
const transformDirectionViewToWorld =
context.uniformState.inverseViewRotation;
uniforms.transformDirectionViewToLocal = Matrix3.multiply(
this._transformDirectionWorldToLocal,
transformDirectionViewToWorld,
uniforms.transformDirectionViewToLocal,
);
uniforms.transformNormalLocalToWorld = Matrix3.clone(
this._transformNormalLocalToWorld,
uniforms.transformNormalLocalToWorld,
);
// 更新摄像机位置
const cameraPositionWorld = frameState.camera.positionWC;
uniforms.cameraPositionUv = Matrix4.multiplyByPoint(
this._transformPositionWorldToUv,
cameraPositionWorld,
uniforms.cameraPositionUv,
);
// 设置步长(用于体素渲染)
uniforms.stepSize = this._stepSizeMultiplier;
// 根据当前渲染模式选择绘制命令
const command = frameState.passes.pick
? this._drawCommandPick
: frameState.passes.pickVoxel
? this._drawCommandPickVoxel
: this._drawCommand;
// 设置命令的包围球体(用于剔除)
command.boundingVolume = shape.boundingSphere;
// 将渲染命令推入命令列表
frameState.commandList.push(command);
};
主要步骤总结:
- 初始化与着色器更新:检查并初始化来自 provider 的数据,并更新自定义着色器。
- 形状更新与检查:检查和更新体素形状、变换及边界框。
- 遍历更新:更新遍历信息,判断是否需要重新渲染。
- 调试绘制与裁剪:在调试模式下绘制边界框,并根据 NDC 空间的 AABB 判断对象是否超出视口。
- 计算与更新渲染矩阵:更新视图和投影矩阵、方向矩阵、摄像机矩阵等。
- 选择并推送渲染命令:根据渲染模式选择合适的绘制命令并推入渲染命令队列。
体渲染的关键技术
体渲染的实现依赖于多项技术,包括自定义着色器、数据裁剪、变换矩阵的更新等。具体来说,以下技术在体渲染中尤为重要:
- 自定义着色器:体素渲染依赖于自定义的着色器来处理三维体积数据的可视化。
- 裁剪与剔除:通过裁剪操作避免渲染视口外的体素数据,提高渲染性能。
- 变换矩阵与摄像机:不断更新视图和投影矩阵,确保体素数据正确地映射到屏幕上。
- 多级细节与分层渲染:根据物体与摄像机的距离动态选择不同的细节级别,减少远距离体素的渲染计算。
总结
Cesium中的体渲染是一个复杂但高度优化的过程,涉及从数据加载到渲染命令生成的各个环节。通过自定义着色器、精确的裁剪和高效的数据更新策略,Cesium能够渲染出高质量的体积数据,广泛应用于地形可视化、气象模拟等领域。
在后续的篇章中,后面再说。
TilesBuilder: TilesBuilder提供一个高效、兼容、优化的数据转换工具,一站式完成数据转换、数据发布、数据预览操作。