背景:可见性裁剪为什么会变成真实问题
横版关卡做压力测试时,帧率掉得很奇怪。屏幕里只有三四个敌人,Profiler 却显示 _process 调用数量接近两百。原因是关卡设计把整张地图的机关、敌人、环境动画都提前放进场景树,镜头外的风车仍然在转,远处敌人仍然寻路,离屏粒子还在模拟。玩家看不见,但 CPU 和 GPU 都在付账。美术一开始以为只能靠减少对象数量解决,后来我们用 Godot 的可见性通知、处理开关和分层加载,把同一张图的低端机帧率稳定了下来。
可见性裁剪不是简单地把看不见的节点隐藏。隐藏 visible = false 可能影响渲染,但不一定停止 _process、物理、AI、音效和粒子。真正的优化要按对象职责拆分:纯视觉对象离屏后可以停动画;敌人离屏后可能保留粗粒度 AI,但暂停骨骼和复杂检测;机关离屏后要看它是否影响全局状态;联网玩法里的对象即使离屏也不能停止状态同步。客户端要建立一张“离屏后还能做什么”的表,而不是全局一刀切。
flowchart TD
A["对象进入关卡"] --> B["注册可见性监视器"]
B --> C{是否在扩展视口内}
C -- "可见或即将可见" --> D["Full Mode: 渲染/动画/AI/碰撞"]
C -- "离屏但影响玩法" --> E["Logic Mode: 低频逻辑/无渲染动画"]
C -- "离屏且无影响" --> F["Sleep Mode: 停 process/粒子/音效"]
E --> C
F --> C
D --> C
先定义扩展视口,而不是只看屏幕边缘
如果对象刚进入屏幕才唤醒,玩家会看到明显的 pop-in:敌人突然开始走路,火把突然亮起,粒子第一帧从零喷出。我们用的是“扩展视口”概念,在相机可见区域外再加一圈缓冲。对象进入缓冲区就提前恢复动画和 AI,真正进入屏幕时已经处于自然状态。缓冲大小要按速度决定:飞行道具和高速敌人需要更大边界,静态装饰可以小一点。这个策略比单纯依赖屏幕可见回调更平滑,也更符合关卡节奏。
在实际执行里,我会把这一点写进开发任务,而不是只留在口头约定里。负责功能的人需要说明输入数据从哪里来、失败时 UI 怎么退回、日志里能看到哪些字段、测试要如何复现。可见性裁剪相关的问题通常不会在第一天爆发,它会在几轮需求叠加后变成难以定位的偶现缺陷。提前把规则落到代码和检查清单里,成本很低,收益却会在后续版本里持续出现。
处理开关要分层关闭
Godot 节点有多个开关:set_process(false)、set_physics_process(false)、动画暂停、碰撞层关闭、粒子 emitting 关闭、AudioStreamPlayer 停止。优化时不能只关一个。比如一个离屏敌人停止了 _process,但 AnimationPlayer 仍在跑,Skeleton 或 SpriteFrames 仍然更新;又比如隐藏节点后碰撞体仍然参与检测,物理开销还在。我们把对象状态拆成 Full、Logic、Sleep 三档。Full 代表完整表现,Logic 只保留必要玩法状态,Sleep 则尽量关闭所有副作用。每个对象类自己实现切档细节,关卡管理器只负责判断距离和可见性。
在实际执行里,我会把这一点写进开发任务,而不是只留在口头约定里。负责功能的人需要说明输入数据从哪里来、失败时 UI 怎么退回、日志里能看到哪些字段、测试要如何复现。可见性裁剪相关的问题通常不会在第一天爆发,它会在几轮需求叠加后变成难以定位的偶现缺陷。提前把规则落到代码和检查清单里,成本很低,收益却会在后续版本里持续出现。
不要暂停会影响公平性的逻辑
有些对象离屏后不能睡。比如追踪弹虽然看不见,但它会在几秒后飞回屏幕;移动平台虽然离屏,但玩家可能站在它影响范围内;联网对战里的敌方角色离屏后仍要接收同步,只是可以降低插值和动画成本。我们给对象加了 culling_policy:visual_only、local_logic、global_logic、networked。visual_only 可以完全睡;local_logic 可以降频;global_logic 只关视觉;networked 则不能停网络状态接收。这个字段由玩法脚本或关卡配置决定,避免优化代码擅自改变游戏规则。
在实际执行里,我会把这一点写进开发任务,而不是只留在口头约定里。负责功能的人需要说明输入数据从哪里来、失败时 UI 怎么退回、日志里能看到哪些字段、测试要如何复现。可见性裁剪相关的问题通常不会在第一天爆发,它会在几轮需求叠加后变成难以定位的偶现缺陷。提前把规则落到代码和检查清单里,成本很低,收益却会在后续版本里持续出现。
粒子和音效是容易被忽略的开销
离屏粒子很容易浪费资源,尤其是循环环境特效。GPUParticles2D 即使不在屏幕内,也可能继续维护状态。我们的做法是环境粒子离屏进入 Sleep 时停止 emitting,回到缓冲区时先预热或使用短淡入,避免突然出现。音效也类似,远处循环声可以停掉,靠近时再根据距离淡入。需要注意的是,剧情或节奏音效不能因为相机离开就被砍掉,所以音频对象也要有策略字段。性能优化不能破坏玩家感知上的连续性。
在实际执行里,我会把这一点写进开发任务,而不是只留在口头约定里。负责功能的人需要说明输入数据从哪里来、失败时 UI 怎么退回、日志里能看到哪些字段、测试要如何复现。可见性裁剪相关的问题通常不会在第一天爆发,它会在几轮需求叠加后变成难以定位的偶现缺陷。提前把规则落到代码和检查清单里,成本很低,收益却会在后续版本里持续出现。
用 Profiler 证明裁剪有效
可见性裁剪很容易做成“看起来合理但收益不明”。每次改动后,我们会在同一张压力地图上记录三组数据:总节点数、活跃 process 数、物理步耗时、渲染耗时和最差帧。最有价值的是活跃 process 数,因为它能直接说明离屏对象是否真的安静下来。如果对象数量没变但活跃更新从两百降到五十,优化方向就对了。再结合截图和录像确认没有 pop-in、音效断裂、机关停摆,才能算通过。
在实际执行里,我会把这一点写进开发任务,而不是只留在口头约定里。负责功能的人需要说明输入数据从哪里来、失败时 UI 怎么退回、日志里能看到哪些字段、测试要如何复现。可见性裁剪相关的问题通常不会在第一天爆发,它会在几轮需求叠加后变成难以定位的偶现缺陷。提前把规则落到代码和检查清单里,成本很低,收益却会在后续版本里持续出现。
可见性裁剪的边界
不要把裁剪系统写成黑盒。策划和关卡设计需要知道哪些对象会睡眠,哪些不会。我们在编辑器工具里给对象显示一个小标签:Full Only、Can Sleep、Logic Keep、Network Keep。这样设计关卡时就能预估成本,也能解释为什么某些对象离屏后仍然消耗。Godot 给了我们足够灵活的节点和可见性 API,真正决定质量的是规则是否清晰、数据是否可查、异常是否能被测试复现。
在实际执行里,我会把这一点写进开发任务,而不是只留在口头约定里。负责功能的人需要说明输入数据从哪里来、失败时 UI 怎么退回、日志里能看到哪些字段、测试要如何复现。可见性裁剪相关的问题通常不会在第一天爆发,它会在几轮需求叠加后变成难以定位的偶现缺陷。提前把规则落到代码和检查清单里,成本很低,收益却会在后续版本里持续出现。
裁剪策略要从对象类型开始
同样是离屏对象,处理方式完全不同。装饰灯笼可以停动画和粒子;巡逻敌人可以降低 AI 频率但保留巡逻路径;机关门可能需要保持开关状态;远处掉落物可以暂停旋转但不能消失;剧情触发器即使看不见,也要等待玩家进入范围。我们给对象类型建立默认策略,再允许个别实例覆盖。这样关卡设计不会每放一个对象都重新思考,也不会因为程序全局优化导致玩法变化。
策略配置最好能在编辑器里看到。比如对象旁边显示 Sleep、Logic、Always 三种标记。设计师看到 Always 太多,就知道这段关卡成本高;程序看到某个纯装饰对象被标成 Always,也能及时纠正。性能优化如果只藏在代码里,很难和内容生产协作。
离屏切换要避免抖动
对象在屏幕边缘来回进出时,如果每次都立刻 Full 和 Sleep 切换,会出现动画抖动、音效断续和 CPU 尖峰。我们使用滞回区间:进入可见缓冲区时唤醒,离开更远的睡眠边界时才睡。进入和退出的距离不同,就能避免相机轻微移动导致状态频繁翻转。对高速相机,还可以加最短保持时间,例如唤醒后至少保持 1 秒。
这个逻辑对粒子尤其重要。粒子系统反复 start/stop 很容易产生视觉跳变。环境粒子可以在离屏后延迟几秒停止,回到缓冲区时提前预热。对不需要精确连续的装饰效果,玩家不会察觉;对需要连续轨迹的玩法效果,则不要用普通裁剪策略。
物理对象的睡眠要谨慎
关闭物理处理不等于关闭碰撞。Godot 的碰撞体如果仍在物理世界里,可能继续参与检测。对于离屏敌人,我们通常不直接移除碰撞,而是看它是否可能影响玩家。如果玩家离得很远,敌人的近战检测、视野 RayCast 可以关闭;但地形、平台、全局机关碰撞不能随便关。掉落物如果要被玩家远程吸附,也不能完全睡。
一个实用做法是把对象拆成表现节点和逻辑节点。表现节点负责 Sprite、AnimationPlayer、Particles;逻辑节点负责状态、位置、必要碰撞。离屏时先关表现,再按策略降低逻辑。这样不会因为隐藏视觉而误伤玩法。节点结构稍微多一层,但职责清楚。
与关卡流式加载配合
可见性裁剪解决的是“在树里的对象要不要更新”,不是“所有对象都提前进树”。大地图仍然需要分段加载。我们会先按区域加载控制节点数量,再对已加载区域内的对象做可见性裁剪。否则一张巨大的地图哪怕所有离屏对象都 Sleep,场景树遍历、内存和资源引用仍然很重。裁剪和流式加载是两层预算:区域决定存在,裁剪决定活跃。
区域加载还可以给裁剪提供上下文。比如相邻区域即将进入时,可以提前把关键敌人从 Sleep 提到 Logic,让它们位置和状态自然推进;真正进屏前再 Full。这样玩家不会看到“区域刚加载,所有东西从第一帧开始动”的廉价感。
QA 要用固定镜头路线压测
裁剪优化需要可重复验证。我们会录一条固定镜头路线,或者写一个调试脚本让相机按路径移动。每次优化后,用同一路线记录活跃对象数量、最差帧、对象状态切换次数。只在编辑器里随便跑一圈,很难比较。固定路线还能暴露边缘问题:某个转角是否 pop-in,某个机关是否离屏后停住,某段音效是否切断。
测试时不要只看平均帧率。裁剪切换如果集中在某一帧,平均帧率可能很好,但玩家会感到卡顿。记录状态切换峰值和资源加载峰值更重要。必要时把唤醒分摊到多帧,尤其是同时进入视野的敌人和特效很多时。
一个容易忽略的边界:相机外但 UI 内
有些对象虽然世界坐标离开相机,但它们的状态会投射到 UI 上,例如小地图红点、屏幕边缘方向提示、任务追踪箭头。裁剪系统如果只看渲染,会把这些对象睡掉,导致 UI 信息消失。我们后来把“是否需要 UI 表现”也纳入策略。敌人离屏后可以关闭骨骼和粒子,但如果它被任务标记追踪,就保留低频位置更新,让小地图和箭头继续工作。这个低频更新不需要每帧做,半秒或一秒一次就够。
这类边界说明,裁剪不是渲染部门自己的事。UI、任务、音频、AI 都可能依赖对象状态。做优化前,先列出对象对外输出:屏幕表现、声音、小地图、任务条件、碰撞、网络同步。只有确认某个输出不再需要,才能关闭对应模块。否则帧率提高了,玩法信息却丢了,玩家会觉得系统不可靠。
结语
这类系统在 Godot 里往往不是“某个 API 会不会用”的问题,而是边界有没有提前说清楚。节点、资源、平台能力和业务状态都很灵活,灵活就意味着团队需要给它们加上可维护的秩序。我的经验是,先把生命周期、输入输出、失败路径和调试信息写明,再去追求抽象优雅。这样项目进入频繁迭代期时,新增需求不会把旧功能挤得变形,排查问题的人也能从日志、结构和约定里找到线索。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。