简介:面向Cesium开发者与三维GIS学习者,资源包围绕在Cesium中高效渲染大量雪松树这一需求,提供基于LOD(细节层次)优化的一整套模型与配置。包内共36个文件,包含10个gltf三维模型、10个bin几何数据文件、11个png贴图、4个jpg预览图及1个json元数据配置文件,覆盖2至1024等不同细节层级的雪松模型与纹理,整体仅3.44MB,便于直接引入Cesium项目或用于LOD性能验证。素材种类完整,包含多级细节模型与对应纹理,配合Cesium的LOD机制可在不同观察距离下自动切换模型精度,平衡渲染效率与视觉效果。适合需要实现大规模植被渲染、或学习Cesium LOD机制的开发者参考。目前已有1105人学习下载,目录结构清晰,是可直接复用的大规模树木渲染素材包。
1. 雪松一多就卡?Cesium 里先分清渲染瓶颈
往 Cesium 的地球上种树,最直觉的做法是对着坐标数组挨个new Cesium.Model。树少于一百棵看不出问题,一旦 5000 棵雪松撒上地形,帧率立刻崩掉。根因不是模型文件大,而是每棵树都是独立绘制单元,CPU 在每一帧都要切换材质、绑定顶点缓冲,帧时间全耗在状态切换里,GPU 反而在空等。
几千棵树和几十万棵树其实是两个工程问题:几千棵适合用实例化渲染(GPU Instancing),同一种网格只上传一次 GPU,每棵树只保留一个 4×4 变换矩阵和差异颜色;几十万棵则要把树木烘焙成 3D Tiles,让 LOD 和瓦片调度接管。下文顺着这两条路展开,覆盖模型准备、地形高度采样、分布算法和调试手段。
做智慧园区、山林防火或城市孪生绿化填充的开发者,可以按下面的 Cesium 1.10x 常见写法直接复现。代码里如果遇到类名和你本地版本不一致,切回 ModelInstanceCollection 再看一遍参数结构即可。
2. 用 Cesium InstancedMeshCollection 让 5000 棵雪松共享一个网格
2.1 为什么不能给每棵树挂一个 Cesium.Model
Cesium 的 Model 是对 glTF 的完整封装,自带节点树、材质、动画、包围球和拾取。这层封装用起来方便,代价是每一棵树都要维护一份独立的顶点缓冲、索引缓冲和 draw call 列表。
一棵常见的雪松 glTF 通常分树干、两层针叶材质,跑起来至少 5 到 10 次 draw call。5000 棵树就是 2.5 万到 5 万次 draw call,绝大多数 Cesium 场景在几千次 draw call 时帧率已经吃紧。加上阴影贴图和标注图层,帧时间很容易冲破 30ms。很多人遇到的“3D 地球滚动出现崩溃”,其中相当一部分是 draw call 和顶点缓冲同时冲高,GPU 提交队列被打爆导致的。
实例化渲染的做法是反过来的:几何体只在 GPU 里存一份,后续通过 per-instance 数据(变换矩阵、颜色、标志位)告诉 GPU“在别处再画一遍”。雪松的网格可以做得细,因为只存一份;树与树的差异体现在矩阵和颜色上,而不是几何上。Cesium 后来把 ModelInstanceCollection 重构升级为 InstancedMeshCollection,API 上也是这个思路。
2.2 最小可运行代码:一棵雪松网格 + N 个实例矩阵
下面的代码是完整可跑的最小骨架。建议先放 20 个点验证模型尺寸、朝向和颜色,再替换成正式点位。
import * as Cesium from 'cesium' const viewer = new Cesium.Viewer('cesiumContainer', { terrainProvider: await Cesium.createWorldTerrainAsync(), }) // 雪松模型建议用 glb:单文件,没有外部 bin,请求少 // 早期版本类名是 ModelInstanceCollection,参数结构类似 const imc = new Cesium.InstancedMeshCollection({ url: '/models/cedar.glb', instanceModelMatrix: Cesium.Matrix4.IDENTITY, instances: [], }) const testPoints = [ [120.101, 30.201, 10], [120.102, 30.202, 10], [120.103, 30.203, 12], ] for (const [lng, lat, height] of testPoints) { const position = Cesium.Cartesian3.fromDegrees(lng, lat, height) // ENU 矩阵让模型坐标系的 Z 轴贴到该点地表法线方向 const modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(position) imc.instances.push( new Cesium.InstancedMesh({ modelMatrix: modelMatrix, // 同一网格,用实例颜色做深浅变化,alpha 先固定 color: Cesium.Color.fromRandom({ alpha: 0.5, minimumRed: 0.2, minimumGreen: 0.3, minimumBlue: 0.1, }), }) ) } // InstancedMeshCollection 本身是一个 Primitive viewer.scene.primitives.add(imc)这段代码的结构是三层:InstancedMeshCollection是整个批次的容器,它作为 Primitive 被加进场景,负责在渲染时把内部所有InstancedMesh合并成少数几次绘制;url指定雪松网格来源,一个批次只能绑定一个网格;每个InstancedMesh只描述一棵树的位置(通过 modelMatrix)和颜色,不复制几何体。
提示:
instanceModelMatrix是所有实例共享的基准矩阵。如果建造雪松时没把树根放在原点,这里一次修正,不要在循环里反复拼接矩阵。
参数上最常被忽略的是 color 的 alpha。InstancedMesh.color是乘到纹理上的,alpha 设为 0.5 相当于半透明模型,叠加排序问题后会出现树冠闪烁。先从 20 个点开始,把 alpha 固定为 0.5。之后做成千上万棵时,alpha 固定为 1 更稳,透明效果交给纹理自身。
2.3 雪松模型准备:原点在树根、单文件 glb、单位用米
glTF 默认 Y 轴向上,Cesium 地球表面则是 Z 轴沿法线方向,二者靠eastNorthUpToFixedFrame完成变换。实际项目里最常出问题的是模型原点不对:很多下载来的雪松模型原点是包围盒中心甚至悬在树冠上方,种到地形上以后半截埋进土里,或者整棵树飘在空中。
我一般会先在 Blender 里打开模型,把 3D 光标放到树干底部中心,然后 Object -> Set Origin -> Origin to 3D Cursor,再 Ctrl+A 应用旋转和缩放,最后导出 glb 并勾选纹理压缩(KTX2)。单位必须确认是米:Cesium 的坐标系统全部按米计算,一个以厘米为单位的 glb 会把 5 米高的雪松变成 500 米高的巨树,而且这种问题在屏幕上非常难定位,看起来只是远处一片异常色块。
2.4 三种渲染方案选型表
| 维度 | 逐棵 Cesium.Model | InstancedMeshCollection | 3D Tiles 烘焙 |
|---|---|---|---|
| 实现成本 | 最低,适合几十棵 | 中,需理解矩阵 | 高,需离线预处理 |
| 1000 棵的 draw call | 数千到数万 | 一次或几次 | 由瓦片调度控制 |
| 动态增删树木 | 方便 | 可以,重建批次成本高 | 基本不适合 |
| 适合规模 | 0~200 | 200~数万 | 数万到百万 |
| 加载延迟 | 极低 | 低 | 高,适合离线场景 |
几百棵树用逐棵 Model 也可以跑,200 到几千棵是 InstancedMeshCollection 最舒服的区间。树超过两三万,实例化虽然把 draw call 降下来了,但显存、拾取和调度还是交给 3D Tiles 更划算,具体路线放在第 4 章的 4.3 节。
3. 让雪松长在 Cesium 地形上:高度采样与分布算法
3.1 用 sampleTerrainMostDetailed 采样真实海拔
上一章的测试点用写死的经纬度和高度。真实场景里,手里通常只有一块范围和密度值,树必须长在地形表面而不是固定海拔。Cesium 提供的地形采样 API 里,sampleTerrainMostDetailed会请求多层级地形并给出比较接近真实地表的高度。
const terrainProvider = viewer.terrainProvider // 只有经纬度,高度先写 0 const cartographics = rawPoints.map((p) => Cesium.Cartographic.fromDegrees(p.lng, p.lat, 0) ) // 采样越细耗时越长,只对最终种树点做一次 const updated = await Cesium.sampleTerrainMostDetailed( terrainProvider, cartographics ) const treePositions = updated.map((c) => Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, c.height) )这里最隐蔽的坑是异步。sampleTerrainMostDetailed返回 Promise,忘了 await 的话,后面拿到的全是 height=0,整片树会种到椭球面上,看起来像泡在水里。第二个坑是坐标系:如果公司 GIS 图层是 3857(Web 墨卡托)导出的,点位要先从米制投影换算回经纬度,再交给Cartographic.fromDegrees。否则 Cesium 里会出现经典的 3857 数据“飘移”——树的经纬度没变,但位置在地表上偏移一大截。这个换算建议在服务端或 shp 导入阶段完成,不要放浏览器里每帧做。
注意:
sampleTerrainMostDetailed会解析较多瓦片,不要放在相机移动回调里反复调用。一次场景加载做一次,结果缓存成数组。
3.2 泊松盘采样:让雪松成林,而不是成芝麻
地形高度拿到之后,下一个问题是树点从哪里来。均匀随机撒点是最省事但效果最差的做法:树间距忽远忽近,排列得像噪点,山坡上两棵树互相穿插,空地上又出现一条稀疏带。放大了以后,一眼就能看出是程序生成的假森林。
我常用的办法是泊松盘采样(Poisson Disk Sampling),保证任意两棵树的距离不小于设定值,同时保持随机感。下面是一份适合在前端运行的简化实现,点集在平面矩形内生成,再映射到经纬度。
function poissonDiskSampling(width, height, radius, maxAttempts = 30) { const cellSize = radius / Math.sqrt(2) const grid = new Map() const result = [] const active = [] const keyOf = (x, y) => `${Math.floor(x / cellSize)},${Math.floor(y / cellSize)}` const putPoint = (x, y) => { const p = { x, y } grid.set(keyOf(x, y), p) result.push(p) active.push(p) } putPoint(width * Math.random(), height * Math.random()) while (active.length) { const pivot = active[Math.floor(Math.random() * active.length)] let found = false for (let i = 0; i < maxAttempts; i++) { const angle = Math.random() * Math.PI * 2 const dist = radius + Math.random() * radius const px = pivot.x + Math.cos(angle) * dist const py = pivot.y + Math.sin(angle) * dist if (px < 0 || py < 0 || px > width || py > height) continue const gx = Math.floor(px / cellSize) const gy = Math.floor(py / cellSize) let ok = true // 只查周边 3x3 个格子,不需要和全部点比 for (let dx = -1; dx <= 1 && ok; dx++) { for (let dy = -1; dy <= 1 && ok; dy++) { const neighbor = grid.get(`${gx + dx},${gy + dy}`) if (neighbor) { const d2 = (neighbor.x - px) ** 2 + (neighbor.y - py) ** 2 if (d2 < radius * radius) ok = false } } } if (ok) { putPoint(px, py) found = true break } } if (!found) active.splice(active.indexOf(pivot), 1) } return result } // 1km x 1km 区域,最小树间距 8m const pts = poissonDiskSampling(1000, 1000, 8)这个实现的性能核心是grid哈希表:平面被切成边长为cellSize的小格子,新点只检查周围 3×3 格子里有没有更近的点,不需要和全部已有点比较。生成 5000 个点通常只需要几十毫秒,放在页面加载阶段做一次即可。
把平面点映射到经纬度时,取区域中心点,用纬度方向约 111320 米/度、经度方向乘以 cos(lat) 做线性换算。如果覆盖范围跨越好几公里,线性映射会变形,正规做法是先把平面坐标投影回目标坐标系再转经纬度;单片植被场景通常只有几公里范围,线性映射足够。
3.3 树高、树冠和颜色的随机参数表
雪松的轮廓非常鲜明,不加变化直接铺一片会显得很“假”。实例化方案可以免费拿到 per-instance 变化,只要给每棵树分配几组随机参数。
| 参数 | 建议范围 | 作用 |
|---|---|---|
| 树高倍率 | 0.75 ~ 1.35 | 打破等高铁杆效应 |
| 树冠缩放 | 0.8 ~ 1.2 | 树冠交错,不出现整齐网格感 |
| 颜色亮度 | 0.85 ~ 1.0 | 模拟向阳面与背阴面的色差 |
| 最小间距 | 6 ~ 10 米 | 由泊松盘半径控制,过挤会穿模 |
| 地形附加偏移 | 0 ~ 1 米 | 让树根不完全落在同一高度 |
树高倍率可以乘到实例矩阵的缩放上,或者放在 glTF 节点的 scale 字段里。颜色建议用Cesium.Color.fromHsl(random(), 0.45, lightness, 1.0)约束在绿色系,fromRandom可能随机出奇怪的紫红色,在植被场景里一眼穿帮。
参数里最容易忽略的是地形附加偏移。DEM 采样高度和真实地表之间总有误差,直接把树根贴到采样高度,近距离看会有小范围悬空或陷土。给每棵树加 0.3 到 1 米的随机抬高,视觉上反而更自然,因为地表本来就有草层。
4. 大规模雪松场景的 3 个必调参数与低配方案
4.1 实例颜色、alpha 裁剪与半透明排序
雪松的针叶通常用带 alpha 通道的纹理表现疏密,这类纹理在 Cesium 里会诱发排序问题。整个模型如果被当作半透明物体渲染,GPU 需要按深度做透明排序,5000 棵树会让排序负担成倍上涨。
正确做法是把针叶材质做成 alpha 裁剪而不是 alpha 混合。glTF 里把材质 alphaMode 设为 MASK,alphaCutoff 设为 0.5 左右。Cesium 会把这部分走不透明渲染路径,正常写深度、正常做深度测试,跳过透明排序开销。
注意:
InstancedMesh.color的 alpha 通道会乘到模型上,树多了以后建议固定为 1,只在 RGB 上随机。透明效果交给纹理自己的 alpha 通道,不要在 per-instance 层次上做半透明。
4.2 动态光照与阴影的取舍
雪松针叶密集,整体视觉偏暗,阴影贴图一开,等于把所有树再画一遍,开销接近翻倍。Cesium 的 shadowMap 有几个参数按经验最值得先调:
viewer.shadowMap.enabled = true viewer.shadowMap.size = 1024 viewer.shadowMap.maximumDistance = 120 viewer.shadowMap.normalOffset = falsemaximumDistance是最关键的参数:它对应“相机周边加载低精度”的思路,远处树影在屏幕上几乎不可见,不需要交给昂贵的阴影相机。把它压到 120~200 米,阴影开销立刻回到可接受范围,近处树影依然有层次。
如果是做动态光照,比如早晚太阳角度变化,要清楚 Cesium 默认光照模型主要针对太阳方向光,点光源不会自动照亮普通 glTF 材质。常见做法是给树一张统一的暗部贴图,或者干脆接受场景整体光照,不要试图用InstancedMesh.color做逐树动画——那会触发整批实例缓冲的 CPU 上传,性能比阴影还差。
4.3 相机 LOD、可见距离与 3D Tiles 兜底
树量超过两三万后,实例化会碰到两个上限:所有实例矩阵常驻 GPU,显存线性增长;远处树在屏幕上不足几个像素,渲染它们没有意义。
第一个手段是给实例批次设置可见距离。Primitive 上有distanceDisplayCondition,把雪松限制在 1500 米外不渲染;更平滑的做法是配合scaleByDistance做距离渐变。第二种手段是把最终树点烘焙进 3D Tiles,让 Cesium 按屏幕空间误差自动装载和卸载瓦片。
3D Tiles 兜底的大致路线是:离线把雪松模型按实例化瓦片组织,靠近相机用精度高的小瓦片,远景用低模代理。这个方案调试成本明显更高,但几十万棵树的地球场景里几乎只有这条路能走通,做智慧园区和城市孪生时很常见。
| 配置项 | 5000 棵推荐 | 50000 棵以上 |
|---|---|---|
| 渲染方案 | InstancedMeshCollection | 3D Tiles 烘焙 |
| 阴影距离 | 120m | 关闭或 60m |
| 树纹理 | 512×512 KTX2 | 256×256 |
| 最小间距 | 6~8m | 由业务数据决定 |
| 可见距离 | 1500m 截断 | 交给瓦片调度 |
纹理尺寸是有意压低的。树冠在屏幕上属于高频纹理,512 和 1024 在 20 米外几乎看不出差别,显存消耗却差四倍。大量树的场景,瓶颈往往不是 draw call 而是显存带宽。
5. 雪松数量上不去:Cesium 场景调试与崩溃排查
5.1 打开 Scene 调试开关看批次是否合并
实例化写了、树也出来了,但帧率还是上不去,第一件事是确认批次有没有真的合并。Cesium 的 Scene 自带一组调试开关,不用装额外工具就能看到渲染队列的变化。
viewer.scene.debugShowFrustums = true viewer.scene.debugShowGlobeDepth = true viewer.scene.debugShowFrustumPlanes = true开启后,整片雪松如果只出现少数几个彩色视锥线框,说明视锥剔除和批次合并都正常。如果每棵树都单独跳出包围球线框,说明实例化没生效,最常见的原因是循环里仍然在用多个 Model,或者错误地把每棵树加到不同的 Primitive 分组里。
性能数据用 Chrome DevTools 的 Performance 面板录制,看 GPU frame 区间。主线程的 Cesium 块里如果频繁出现createVertexArray,说明实例数据在上传和销毁,问题不在渲染本身,而在数据更新策略。
5.2 崩溃排查的两种固定动作
先做二分法缩点位。从 20 个点开始逐批翻倍,同时盯住 Chrome 任务管理器里的 GPU 显存。帧率线性下降是负载问题;突然白屏或崩溃,多数是显存耗尽或矩阵里混入 NaN。高度采样失败返回 undefined 再乘进矩阵就是 NaN,建议在 push 实例前加一条Number.isFinite断言。
第二种是批次重建陷阱。原地往imc.instances里 push 再删除,会让集合在下一帧全量重建,次数一多就会出现卡顿甚至崩溃。正确做法是维护业务层数据,需要变更时整体 new 一个 collection 替换旧的,而不是原地改实例数组。
最后补一个视角问题:雪松在缓坡上可以硬贴 ENU 矩阵,但陡坡上树干会垂直坡面生长,整片林子看起来像倒伏。采样地形后计算局部坡度,坡度大于 25 度时把树的默认方向向重力反方向回拉一点,或者干脆不种。这一步不做,性能再好,整片雪松林从正射视角看过去还是“浮”的。
本文还有配套的精品资源,点击获取