☰
Cluster LOD与DAG架构:从分簇到依赖共享的渲染优化实践
2026/10/14 3:43:11 网站建设 项目流程

做了这么多年渲染优化,我最怕听到的一句话不是“这个需求做不了”,而是“我们上一个版本已经做好 LOD 了”。原因很简单:大部分 LOD 方案,只在“单个静态网格 + 固定相机距离”这个前提下成立。一旦场景里有成千上万个物体、有共享材质,又有动态切换需求,传统树形 LOD 就会暴露出结构性短板,轻则内存暴涨,重则交界处疯狂闪烁、切档跳变。

Cluster LOD 与 DAG 架构,就是针对上述问题的一套组合解法。Cluster LOD 面向网格本身,把模型按照屏幕空间误差做分簇和分层;DAG 则面向整个场景的资源依赖,用有向无环图替代简单的树,让多个 LOD 节点可以共享同一份几何数据,避免重复拷贝,也让切换关系变得可控。这篇内容适合已经接触过渲染或引擎优化、但被 LOD 各种边界情况反复折磨的开发者;如果你对两种概念还不熟,我也会从底层原理一路说到代码落地,尽量把每一步怎么想、怎么做讲清楚。

这里先把我讨论的边界说明白:下面的 Cluster LOD 指的是以三角形网格为对象、按误差驱动的几何分簇与分级渲染策略;DAG 指的是以这些簇和它们的简化层级为节点,跨越传统树状从属关系构建的依赖关系图。两者不是互相替代的关系,而是不同层面的配合,理解这一点比背十遍概念都重要。

1. 为什么传统 LOD 会卡住你

1.1 传统树形 LOD 的三条原罪

传统 LOD 做起来很简单:一个模型准备好近、中、远三个版本,运行时根据相机距离选中其中一个。这套逻辑在小场景、单体物件上非常稳,可一旦进入大世界或高密度场景,问题就一个个浮出来。

第一,基于距离的选档粒度太粗。远处的巨大建筑和近处的碎石块,投影面积完全不是一个量级,却只靠“距离”这一个维度决定切换档位。结果很常见:一个很大的平坦地面,离得远了要很久才降级,渲染开销一直居高不下;而一个密集的小物堆,距离稍远就直接切到最低档,轮廓马上塌掉,观感非常差。

第二,简化层级之间缺少过渡信息。树形 LOD 的每一级是独立预处理出来的,几何简化器只保证单级误差,不保证相邻级别之间的空间连续关系。于是相机移动时经常出现某个屋檐、某根电线杆突然缩进去或鼓出来,也就是俗称的“换皮闪烁”。想修这个问题,传统树形结构根本没用,因为节点之间的关系只有“谁是父谁是子”,没有误差相关的横向量化依据。

第三,树形结构天然排斥共享。场景里经常有大量复用对象:同一把长椅、同一棵树、同一段城墙,每套 LOD 都会复制一批顶点和索引数据。树形结构下哪怕两个父节点引用的低模完全相同,也各存一份,内存浪费非常直接。更麻烦的是,程序化生成的内容经常由多个子块拼接,子块之间共享一批低精度底座,树形结构没有办法表达这种“多对多的共享关系”。

1.2 两种架构出现的现实场景

我第一次认真思考 Cluster LOD 和 DAG,是在一个跨平台渲染项目的中期优化阶段。当时场景里有几千个中等精度的建筑白模,每个模型拆了 5 个 LOD 等级,运行内存被顶点数据压到一个非常被动的状态。更难受的是,建筑屋顶和墙体的结构复杂度差异很大,同一个距离阈值下,墙体已经简化到没法看,屋顶却依然满精度渲染。

我们要做的事情本质上是:不再为一个物件准备固定几个等级,而是把整个场景的几何体按误差需求切成小块,再根据需要动态组织这些小块的数量和精度。当时把模型导入、正常建树式 LOD 的那些步骤全部推翻,改成先做聚类分块,再做共享整合,最后用依赖图把块与块之间的关系理顺。那一版改完,内存下降了一大截,画面闪烁也基本消失。

这也是我观察到的共同规律:场景规模越大、程序化程度越高、共享资源越多,传统树形方案越吃力。Cluster LOD 解决的是“按需提供精度”的问题,DAG 解决的是“精度块怎么被复用和依赖”的问题。

1.3 先泼一盆冷水:这套方案不是银弹

讲两种架构之前,必须先把适用边界框清楚。如果你的项目只有几十个静态物件、相机移动范围很小,那我建议不要引入这套方案,成本高,收益低。Cluster LOD 和 DAG 的复杂度和收益呈现明显的拐点,通常是在一个场景里超过几百个网格、或单个网格面数超过几十万、或存在大量程序化实例复用时,收益才会显著超过成本。

动手前先评估几个问题:你的 LOD 切换是不是已经被性能问题逼到了必须精细化控制的地步?你的模型体系里是不是存在天然的共享节点?你的内容管线是不是可以接受离线预处理时间增加几倍?如果三个答案都是否,老老实实用传统树形 LOD 就够了。如果至少前两个答案是肯定的,接下来这套组合拳才值得你花时间。

2. Cluster LOD:核心设计思路与实操要点

2.1 先理解“分簇”这个动作

Cluster LOD 的核心思路,是把一个连续网格拆成若干块,每个块独立决定自己的渲染精度。拆块的依据不是“这个块属于哪个部件”,而是“从当前视点看,这个块的投影误差有多大”。误差大的块需要保留更多细节,误差小的块可以尽快降级甚至剔除,互不拖累。

这样做和传统 LOD 的区别很像以前团队分工和现在组建小队的区别。传统 LOD 是一个模型整体切换,等于要求整个团队要么全速干活、要么全体休息;Cluster LOD 是把团队拆成若干小组,某条街道上只有街角那栋楼需要精细时,就只让对应的小组保持高精度,其他小组直接划水,整体开销瞬间被压下来。

实际操作中,无向邻接图被用来表示三角面之间的连接关系,随后通过合并操作把面集合聚合到一起。每个簇内部保持连通性,这是后续实现不裂缝、不漏面的前提。你想让这个系统真正可用,聚类时就必须保证块内连续,否则运行时每帧去处理裂开的多块,性能和画质都兜不住。

2.2 聚类策略与关键参数

聚类不是把三角形均匀分组那么简单,几个因素必须同时参与加权:

  • 几何位置:空间近的三角形优先合并,这是连通性的基础。
  • 法线方向:夹角太大的面合并后会产生较大投影误差,要谨慎。
  • 材质与 UV:跨越材质边界的合并容易让纹理采样中断,运行时会出现明显接缝。
  • 顶点属性过渡:切线、柔光等连续属性在边界处断裂,也会造成可感知的光照不连续。

聚类参数里最重要的是这几个:

参数名作用我们项目里常用初始值
最大簇三角形数限制单个簇的精度上限1024~4096
簇间合并误差阈值决定何时停止合并0.5~2.0 像素
聚类权重系数调整位置/法线/UV 的优先顺序位置 0.4,法线 0.4,UV 0.2
最小簇数量避免过度拆分导致 draw call 爆炸视场景预算而定

值得注意的一点是:不要一上来就追求最优聚类,先把最大簇三角形数和误差阈值各跑一组对比,画质差异不大时,优先选择较小阈值,给运行时多留点余地。误差阈值调太大会出现“一簇崩、全簇崩”,一片低精度区域清晰可见;调太小则簇数过多,draw call 暴增,得不偿失。

2.3 屏幕空间误差才是那把尺子

Cluster LOD 里所有的简化决策,最终都要换算到一个统一的度量单位:屏幕空间像素误差。具体做法是,在预处理阶段把每一个简化操作可能引入的几何偏差,换算成从典型视距观察时的屏幕像素差异。

我习惯用下面的近似公式来快速估算一个簇的屏幕空间误差:

e_screen = (e_world * screen_height) / (2 * distance * tan(fov / 2))

公式里的 e_world 是这个簇当前几何误差(比如简化操作引起的顶点位置偏移),distance 是相机到簇中心的距离,screen_height 是渲染目标的高度,fov 是相机垂直视场角。注意这个公式没有走完整的投影管线,所以它只适合预处理阶段的估算,真正渲染时依然要以实际包围盒投影为准。

误差估算跑完以后,预处理工具会把每个簇按误差从低到高排序,并以此决定简化顺序。误差极低的簇可以先合并简化,误差高的簇保留原始精度。这样做的直接收益是:大平面、缓坡这一类“看着不累”的几何块能快速降级,而复杂结构、尖锐拐角这类视觉高频区被保护下来,整体画质主观感受会提升非常明显,计算开销却大幅下降。

这一套也顺带解释了为什么 Cluster LOD 对动态场景的适应力更强。每一帧都可以根据当前视点重新评估各簇的误差,而不是傻傻地依赖一个全局距离阈值。引擎只需维护“每个簇当前处于第几级精度”这个状态,不需要为每个物体生成一堆历史版本,逻辑简单很多。

3. DAG 架构:在依赖网络里榨取共享价值

3.1 树形结构为什么装不下真实依赖

先看一个很常见的例子。一张大地图被切成 16 个地块,每个地块有高精度版本和低精度版本。某些地块共享同一个低精度底座,比如远处的一座山脉轮廓,它跨越多个地块边界,所有地块都想把这座山的低精度版本作为远景兜底。树形结构能怎么表达?只能让每个地块各自复制一份山脉低模,既浪费内存,又可能在边界处出现完全不同的简化结果,观感割裂。

DAG 就不一样。它允许一个节点同时拥有多个父节点,也允许一个子节点被多个父节点引用。还是这座山的例子,可以让地块 A、地块 B、地块 C 全都指向同一个“山脉远景低模”节点,数据只存一份,依赖关系却清晰地记录了三处引用。这就是“共享”在架构层面的体现。

光有共享还不够,DAG 还能显式地表达依赖顺序。当你决定要加载某个高细节簇时,必须先确保它的低模基础节点已经就绪,否则会有一段时间显示空洞。DAG 的拓扑关系把这种顺序直接编码进去了,运行时只要做一次拓扑排序,就知道谁先谁后,不需要在业务代码里到处埋状态机。

3.2 DAG 的几种构造方式

DAG 不是单独存在的数据结构,它是从已有的聚类层级里提炼出来的。我的经验是从三个渠道汇入节点关系:

第一,聚类结果本身就是层次化生成的。底层是原始三角形,上一级是簇,再上一级是簇的合并,自然形成父子结构。把这些结构映射成 DAG 的节点和边,是最直接的一步。

第二,简化过程中的共享合并。两个不同的高层簇在简化后可能得到一组完全一致的三角形集合,例如两栋结构完全相同的建筑,程序化生成时共用一套低模。此时预处理工具要做一次 hash 去重,把这两个高层簇的简化输出指向同一个几何数据节点,而不是各存一份。

第三,场景级依赖。地形块、植被块、建筑块之间常有跨模块的依赖。一个建筑需要先有地形低模作为地基显示,才能安全切换高模。这种依赖关系不是从网格特征里推出来的,而是内容规则的一部分,需要单独建边。

构造 DAG 时要格外小心环的出现。理论上“有向无环图”不允许环,但实际构建中很容易在跨模块依赖时不小心形成一个循环加载依赖,比如地块 A 依赖地块 B 的远景,地块 B 又依赖地块 A 的远景。处理办法是给依赖边设定优先级级数,在低优先级依赖遇到环时,主动断开或降级为“尽力而为”的异步加载,不能让它阻塞主线。

3.3 内存与加载如何受益

DAG 带来的第一个直接收益是去重。我们在一个中等规模的程序化城市场景里统计过,场景内所有建筑的低模版本通过 DAG 共享后,几何数据内存从原始方案的 1.6 GB 降到 900 MB 左右。减掉的 700 MB 基本都来自于重复的低模拷贝。

第二个收益是加载顺序变得更加可控。树形结构只能做“后序遍历子节点”,而 DAG 可以做拓扑序加载,每个节点只等它的直接前驱就绪。这在高复杂场景中非常实用,因为你不必为了一个高精度中心点,把整个场景树全加载一遍;只需要保证它依赖的那些共享远景、基础地面先到位,加载路线就清晰且可控。

DAG 的代价也不是没有。它的遍历逻辑比树复杂,缓存一致性变差;节点引用计数维护稍有不慎就会出现悬垂引用。我的实践经验是,把 DAG 的构建放在离线预处理阶段,运行时只读取一份构建好的只读结构,不要边运行边改图。如果你发现自己要在运行时频繁增删依赖边,大概率是设计漏了什么,先回去补架构,而不是硬塞图结构。

4. 实操落地:从网格输入到运行时切换

4.1 输入数据与预处理目标

开始动手前,先把输入数据整理标准。我们当时的输入是带法线和 UV 的高精度网格,三角形数量从几万到上百万不等,场景里散布了几千个静态物件。预处理目标是生成两个东西:一个是 Cluster LOD 的簇层级描述文件,另一个是 DAG 的节点关系描述文件。两者都要求是只读的、紧凑的、可以直接被运行时反序列化。

4.2 聚类建簇的伪代码实现

聚类阶段的核心可以简化成下面这个框架:

def build_cluster_hierarchy(mesh, max_tris_per_cluster, error_threshold): adjacency = build_face_adjacency(mesh) clusters = initialize_all_faces_as_singleton(mesh) while len(clusters) > 1: # 寻找误差代价最小的可合并簇对 best_pair = None best_cost = float('inf') for a, b in find_candidate_pairs(clusters, adjacency): if cluster_size(a) + cluster_size(b) > max_tris_per_cluster: continue cost = cluster_merge_error(a, b, mesh) if cost < best_cost: best_cost = cost best_pair = (a, b) if best_pair is not None and best_cost < error_threshold: merged = merge_clusters(best_pair[0], best_pair[1]) update_adjacency(adjacency, merged) clusters.remove(best_pair[0]) clusters.remove(best_pair[1]) clusters.append(merged) else: # 误差超限,停止合并并记录当前层级 push_level_snapshot(clusters) return clusters

这段代码的主要价值是搭好了骨架。真实项目里会在几个位置加上重量级优化:候选簇对的存储不会用双层循环去扫,而是用一个优先级队列,队列里存放按当前误差排序的合并候选;每次合并后,只需要更新受影响区域的候选,不需要全图重扫。

还有一点容易被忽略:误差阈值的设定要结合目标屏幕分辨率和最远渲染距离做换算。同一个几何误差,在 1080p 下和 4K 下的体感完全不同,所以预处理不能只算一次,最好能够按不同的 rhi 配置输出多个 LOD 层级档位,运行时按设备能力选择档位。

4.3 把簇层级转成 DAG 的关键处理

得到簇层级以后,DAG 化处理的关键就是“找共享”和“定依赖”。伪代码可以这样表达:

def clusters_to_dag(cluster_levels, mesh_hash_config): dag = DAG() # 第一步:把所有层级节点旋转为 DAG 节点 for level in cluster_levels: for cluster in level: dag.add_node(node_id=cluster.id, geometry_version=cluster.version, error=cluster.screen_error) # 第二步:建立父子依赖边 for parent in cluster_levels[1:]: for child in parent.children: dag.add_edge(parent.id, child.id) # 第三步:全局去重 for node in dag.nodes: hash_value = hash_geometry(node.geometry) if hash_value in dag.geometry_pool: node.set_shared_pointer(dag.geometry_pool[hash_value]) else: dag.geometry_pool[hash_value] = node.geometry_id return dag

这个第三路去重是整个 DAG 收益的核心。不同层级的节点在简化后,三角形集合极有可能完全一致,尤其当原始物体本身具备重复结构时,这种共享更是频繁。需要注意的是,hash 只对几何数据做,不能把法线、UV 等属性坐标混进去,否则两套属性排列顺序不同,却明明等价的几何也会被当作不同数据,去重效果大打折扣。

4.4 运行时 LOD 选择的逻辑骨架

运行时我没有走“每帧全量计算”的路,那太贵了。实际落地时用了一个延迟评估的框架:每帧只评估当前可见范围内的簇,并把评估结果放进一个待处理队列,在后续几个帧内异步应用切换。

void updateClusterLOD() { for (auto &cluster : activeClusters) { // 用包围盒中心到相机距离估算屏幕误差 float screenError = projectClusterError(cluster); // 目标精度档位 = 最接近预算的档位 int targetLevel = findLevelForError(screenError, budget); if (targetLevel != cluster.currentLevel) { pendingChanges.push_back({cluster.id, targetLevel}); } } } void applyPendingChanges() { for (auto &change : pendingChanges) { if (dependencySatisfied(change.clusterId)) { switchClusterLevel(change.clusterId, change.targetLevel); } else { // 依赖未就绪时保持当前状态 requeueWithDelay(change); } } }

依赖检查这个动作就是 DAG 在运行时发挥价值的地方。每个簇要对目标档位做切换时,先查它依赖的前驱节点是否就绪。这个判断只需要查几个标志位,成本极低。如果没有 DAG,这里通常只能靠“资源加载线程”配合一堆条件变量轮流面试,写起来非常痛苦,而且很容易出现一个高模已经加载完,但它依赖的低模还不存在的诡异局面。

5. 调优对照与性能边界

5.1 先说观测指标

调优 Cluster LOD 与 DAG 时,我关注四项核心数据:帧时间、渲染三角形数、draw call 数量、以及 LOD 切换引起的几何内存占用。其中帧时间和渲染三角形数是最直观的优化目标,draw call 数量则要控制在上限内,几何内存占用是长期稳定性的保障。

我们项目里的典型优化效果,是在画质几乎无感损失的前提下,把渲染三角形数压缩了约 40%,内存占用降了 30%~45%。这个数字只代表这一类场景,不一定适用于所有项目,但它说明这套方案在大规模静态场景中的潜力是实实在在的。

5.2 主要参数对照表

参数调低效果调高效果我们的推荐策略
最大簇三角形数精度适应性强,draw call 变多draw call 少,精度切换粒度变粗先设 1024,逐步上调到满意
屏幕误差阈值更精细,内存占用上升更粗糙,可能出现可感知降质从 0.5 像素开始试,观感一变差就回调
聚类权重影响簇形态和共享率同左用一组静态场景截图做对比
去重 hash 配置共享少,内存偏高hash 冲突风险大只对几何属性做 hash

调参有个老生常谈但一定要遵守的原则:一次只动一个变量。同时调误差阈值和簇大小,出了问题你根本不知道是谁造成的,排查成本指数级上升。

5.3 性能边界的识别

DAG 的共享收益并不是无限增长的。当共享率达到某个程度后,新建的共享节点会开始把依赖关系拉得太长,加载路径变得反而更慢。我们碰到过一种情况:一个远景低模被数百个簇共同依赖,结果每次它发生更新,数百个簇都要等待同步,帧率一下子出现锯齿状波动。

解决思路也很朴素:给共享节点设定引用计数上限,超过上限的节点拆成两个等价的低模,分摊依赖压力。这看起来违背了 DAG 追求共享的本意,但有时候稍微牺牲一点内存,换取加载路径的稳定和流畅,是完全值得的。架构是工具,不是信仰。

6. 常见问题与排查方式

6.1 场景交界处持续闪烁

这是最容易被误诊的问题。有人会怀疑是简化误差太大,也有人会怀疑是相机近裁剪面过近,但做 Cluster LOD 以后,闪烁大概率来自“相邻簇精度档位不一致导致的法线跳变”。排查方法是把簇边界可视化出来,观察闪烁是否都集中在簇边界线上。如果是,就增加相邻层的误差容错,或者把边界区域的简化权重调低,让两侧簇尽量保持同一精度。

6.2 DAG 里出现环,加载卡死

我前面提过,跨模块依赖最容易引入环。排查时先把同类模块依赖剔除,再看异类依赖里是否存在互相引用。不建议运行时把环“硬解”,那是在掩盖设计问题。正确做法是在离线预处理阶段就输出一个依赖环报告,阻挡非法资源进入构建流程。

6.3 内存降不下来,共享没有生效

先查去重 hash 是否把相同几何判定成了不同数据。常见原因是 UV 或法线属性影响到了 hash,让共享失败。再查是不是每个簇保留了独立的顶点缓冲,没有走共享池。最后查示例是否过多,示例多了会让内存从“几何数据”转移到“实例数据”,两者要分开统计,调参时别混在一起看。

6.4 切换瞬间出现闪断

闪断通常是“依赖前驱未就绪时提前切换”导致的。DAG 依赖检查必须做透,前驱没有加载完成前,不允许任何后继簇做精度切换。另一个隐藏坑是异步加载线程和渲染线程共用了一个顶点数据容器,切换时没有做原子替换,出现半个簇的数据被替换。给几何数据做双缓冲可以解决大部分问题,代价是额外内存,但稳定性提升非常值得。

7. 最后再聊几句实在的

把这个架构在项目里落地后,我最深的体会是——不要试图在运行时动态修改 DAG。离线预处理阶段把一切变成只读数据,运行时只做查询和切换,稳定性和排查效率都会好很多。很多性能问题实际上是架构问题,不是代码问题,与其在渲染循环里堆优化,不如把资源组织的骨架理清楚。

还有一个小技巧,如果你刚起步,不妨先做一个“共享低模”的精简版 DAG,不急着上全量的 Cluster LOD。先把重复构建造成的浪费解决掉,再逐步引入误差驱动和簇切换,每一步都能验证收益,最后这套组合拳也会用得心中有数。

Cluster LOD 和 DAG 都不是什么新概念,难的是把它们放进真实项目里,让它们配合内容管线、加载策略和运行时预算一起工作。希望这篇拆解能给你一条清晰的路,少踩几个我当年踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询