城镇和营地需要热闹感。路人走动、商贩招呼、卫兵巡逻、同伴闲聊,都会让世界更可信。但如果每个 NPC 都每帧寻路、检测玩家、播放复杂动画、更新头顶 UI,低端设备很快顶不住。热闹不等于每个路人都用主角级成本运行。
Godot 的 Node 很容易实例化大量 NPC,问题也因此容易被拖到后期才暴露。NPC 群体需要预算调度:近处 NPC 完整更新,中距离降频,远处只保留动画或占位,离屏暂停非必要逻辑。CrowdBudgetScheduler 的目标,是让城镇看起来活着,但不会让每个路人每帧思考。
项目里的真实问题
一个主城场景里有 80 个 NPC。开发机跑得动,移动端进入主城后 CPU 飙升。Profiler 显示大量 _process、NavigationAgent 更新和交互扫描。很多 NPC 离玩家很远或被建筑挡住,却仍然每帧检查玩家距离、更新对话气泡、跑行为状态。
简单关闭远处 NPC 会让城市突然变空;全部保留又太贵。需要根据距离、可见性、重要性和任务状态给 NPC 分级。任务 NPC、可交互商人和当前镜头内 NPC 保持高优先级;普通路人可以降频或使用简化动画。
设计目标
- 分级更新:NPC 按距离、可见性、重要性进入不同更新档位。
- 预算稳定:每帧 AI、寻路、交互扫描和 UI 更新都有上限。
- 表现不断裂:降级时保持基本动画和位置可信,不突然消失。
- 任务优先:任务相关和玩家交互中的 NPC 不被普通预算挤掉。
目标不是把一个小功能做成庞大平台,而是让它进入真实项目后仍然可维护。Godot 的 Node、信号和 Resource 很适合快速验证,但功能一旦要覆盖多个页面、多个平台和多次版本更新,就必须把状态、配置、失败路径和观测方式拆清楚。下面的方案都围绕一个原则:业务脚本提交意图,系统层做决策,表现层只消费快照。
推荐架构
flowchart TD
A["NPC注册"] --> B["CrowdBudgetScheduler"]
B --> C["距离分级"]
B --> D["Tick预算"]
B --> E["动画降级"]
B --> F["交互保活"]
C --> G["状态快照"]
D --> G
E --> G
F --> G
G --> H["UI反馈/日志/回滚"]
这张图里的模块可以按项目规模合并。小团队可以用一个 Autoload 管理,大团队可以拆成配置 Resource、Service、ViewModel 和调试面板。关键是调用方向要稳定:场景和 UI 不直接修改底层状态,而是提交意图并订阅快照。这样测试、灰度和回滚才有抓手。
关键实现细节
每个 NPC 注册到 CrowdBudgetScheduler,声明 importance、role、can_sleep、requires_interaction、current_task_relevance。调度器每隔一段时间根据玩家位置、相机可见性和任务状态计算 tier:active、reduced、ambient、sleeping。
active NPC 每帧或高频更新,适合任务目标、近处交互对象、当前对话对象。reduced NPC 降低 AI tick 和寻路频率,ambient NPC 只播放低成本动画或沿预设路径移动,sleeping NPC 暂停逻辑。
交互扫描要保活。即使某个商人处于 reduced,只要玩家接近交互范围,就必须提升到 active。不要因为降频导致交互提示晚出现。
动画降级要自然。远处路人可以降低动画更新频率、关闭 IK、减少表情和头部朝向,但不要突然 T pose 或停在奇怪姿势。
失败处理和恢复路径
NPC tier 切换太频繁会造成抖动。需要滞后距离和最短停留时间。
任务 NPC 被错误降级会导致任务无法推进。任务系统应能给 NPC 加强制 active token。
离屏暂停后再回来,NPC 位置不能跳得太夸张。ambient 路径可以用时间推进估算位置,而不是完全冻结。
数据契约和协作接口
NPCBudgetProfile 定义各 tier 的 tick interval、navigation interval、animation quality、ui_enabled。
任务和对话系统可以请求 priority token,调度器按 token 提升 NPC tier。
调试 Overlay 显示每个 NPC 当前 tier、上次 tick 耗时和被提升原因。
GDScript 接口草图
class_name CrowdBudgetScheduler
extends Node
signal snapshot_changed(snapshot: Dictionary)
signal rejected(reason: String, payload: Dictionary)
var _snapshot := {}
var _op_version := 0
func apply_intent(intent: Dictionary) -> void:
_op_version += 1
var version := _op_version
_snapshot = {"phase": "checking", "intent": intent}
emit_signal("snapshot_changed", _snapshot)
_execute(intent, func(result: Dictionary):
if version != _op_version:
return
if not result.get("accepted", false):
emit_signal("rejected", result.get("reason", "unknown"), result)
return
_snapshot = result
emit_signal("snapshot_changed", _snapshot)
)
func snapshot() -> Dictionary:
return _snapshot.duplicate(true)
接口草图保留了版本号,是因为很多客户端问题来自异步乱序:玩家快速切换页面、网络请求晚返回、资源加载被取消后又完成。如果旧结果可以覆盖新状态,问题会非常隐蔽。实际项目里还要补超时、取消、错误码和日志字段。
分阶段落地
第一阶段把普通路人注册到调度器,先做距离分级和 tick 降频。
第二阶段接入任务优先级、交互保活和可见性判断。
第三阶段优化动画降级、路径估算和调试 Overlay。
自动化验证和人工验收
主城 80 个 NPC 下记录 CPU 耗时和各 tier 数量。
玩家快速接近商人,交互提示不能明显延迟。
任务 NPC 在远处仍能保持必要状态,不被 sleeping。
NPC 在边界来回切换时 tier 不抖动。
观测指标
- 各 tier NPC 数量和平均 tick 耗时。
- 每帧 AI、寻路、交互扫描预算使用率。
- NPC tier 切换次数。
- 交互提示延迟和任务 NPC 被降级次数。
指标不一定全部进入正式服。开发包可以显示完整调试面板,内测包采样关键计数,正式包只保留错误码和聚合趋势。指标的目的不是制造报表,而是让一次异常能被定位到具体阶段、具体配置和具体玩家路径。
上线前检查清单
- NPC 注册时声明 role、importance 和 sleep 策略。
- tier 切换有滞后和最短停留时间。
- 任务和对话能申请 active token。
- 远处动画有低成本表现,不突然消失。
- 调试 Overlay 能显示 tier 和提升原因。
检查清单要随着事故复盘不断更新。每次问题暴露后,都问它是否能变成自动检查、灰度指标或人工验收步骤。能沉淀下来的经验,才会在下一次版本里真正保护团队。
工程落地补充
NPC 预算还要和剧情镜头协作。一个普通路人在日常状态下可以 reduced,但如果过场镜头要拍到他,他必须临时提升动画和表情质量。镜头系统可以申请 presentation token,结束后释放。这样不会为了偶发镜头让所有路人长期高成本运行。
存档恢复时也要注意。睡眠中的 NPC 不一定有完整运行时状态,但玩家保存和读档后,他的位置、任务状态和是否已交互必须一致。背景路人的闲逛路径可以近似恢复,任务 NPC 的状态则必须精确。
配置版本也很重要。系统上线后,配置会跟着内容迭代不断变化:新增步骤、新增音频规则、新增安全区 profile、新增商品或新增目标类型。每份配置都应该有 version 和 lastmod,客户端日志里记录当前版本。出现问题时,团队能知道玩家使用的是哪一版配置,而不是只看到一个模糊的功能名。
调试入口要从第一版就准备。不要等问题出现后再临时加日志。开发包至少能显示当前快照、最近一次意图、失败原因和配置来源。QA 报告如果能带上这四个信息,排查效率会比只发截图高很多。对于 UI 类系统,最好能在截图角落显示关键 id,例如 step_id、marker_id、quote_id 或 target_id。
团队协作边界
这类系统通常不是单个程序能独立定完的。策划需要确认规则和文案,美术或 UI 需要确认表现,QA 需要确认验收脚本,服务端或平台同学需要确认接口边界。建议在文章对应的系统落地时,把“谁能改配置、谁能发开关、谁负责看指标”写在 README 或内部文档里。
同时要约定变更流程。新增一个教程步骤、新增一种购买错误码、新增一个目标类型、新增一个音频 ducking 规则,都应该有最小验收样本。没有样本的配置变更,很容易在下一次内容更新时破坏既有路径。把样本保留下来,后续自动化才能逐步建立。
案例复盘
一次主城优化中,团队把所有远处 NPC 的 process 关闭,帧率提升明显,但玩家靠近商人时提示慢半秒出现。复盘后发现交互扫描也被停掉。修复方式是把交互范围作为提升条件,玩家进入预热半径时先激活交互组件,再恢复完整 AI。性能和体验都保住了。
灰度验收脚本
灰度验收可以在主城固定路线跑三圈,记录不同设备上 active、reduced、ambient、sleeping 的数量和帧耗时。QA 同时检查路人是否突然停止、商人是否可交互、任务 NPC 是否稳定出现。
验收边界补充
验收时还要检查声音和气泡。NPC 逻辑降级后,闲聊气泡和脚步声也应降级,否则视觉上省了 CPU,听觉和 UI 仍然在刷存在感。群体预算要覆盖整套表现。
每次验收都要同时看成功路径和失败路径。成功路径证明功能能跑,失败路径证明系统不会把玩家带进不可理解的状态。对于这类客户端系统,最容易漏测的往往不是主流程,而是取消、超时、配置缺失、目标失效、切场景和重进游戏。把这些边界做成固定脚本,后续内容扩展时才能继续复用。
另外,验收结果要能落到文件或截图里。只说“体感还行”不够,至少要有关键状态快照、调试面板截图或日志片段。系统越复杂,越需要可保存的证据。这样下一次同类问题出现时,团队能对比前后行为,而不是重新凭记忆讨论。
小团队接入版本
小团队可以先只做距离降频,不必马上接复杂可见性。把普通路人的 AI tick 从每帧改成 0.2 秒或 0.5 秒一次,就能获得明显收益。后续再加任务 token 和动画降级。
交付边界
交付标准是城镇热闹感保留,CPU 成本稳定,关键 NPC 不被预算策略误伤。NPC 群体优化不是让城市变空,而是让重要对象获得足够预算。
结语
热闹城镇不能靠每个 NPC 每帧思考。Godot 客户端把 NPC 更新做成预算调度后,路人、商人、任务目标和背景角色就能按重要性分配成本,让城市既可信又跑得动。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。