下载 App
开发者日志01:用 GDScript 撑起上万个同屏角色
修改于10/2115 浏览综合
项目开工时定了三条约束,后面几乎所有技术决策都是从这里推出来的:
低配也要流畅。1920×1080 下平均 60 FPS,1% 低帧不低于 45 FPS。同屏有上千个近景角色、上万个远景实例,还有几百个动态对象。 工期短,只有一个程序。系统数量要少,耦合要低,改动要能快速验证。 美术只出白色剪影。明暗、起伏、晃动、饱和度全部交给着色器。 第一条直接否掉了"一个对象一个节点"的写法。几千个节点各自跑 _physics_process,光是 GDScript 的调用开销就超出预算。所以大批量对象一律是"结构数组存数据 + MultiMesh 画出来",节点数量和对象数量无关。
- 分层:逻辑层不认识场景树 scripts/ logic/ 纯逻辑:继承 RefCounted,只吃参数、吐结果 systems/ Node:持有逻辑模型,把数据画出来 ui/ 界面 autoload/ Config / Assets / Settings / Run config/ BalanceConfig 资源定义 logic/ 下的类有三条硬规矩:继承 RefCounted,不访问任何节点,不读全局单例(配置以参数传入)。这样它们可以在 headless 模式下直接 new() 出来测,结果也完全确定。
systems/ 下的节点只做两件事:在物理帧里把输入交给逻辑模型,在渲染帧里把模型的数组写进 GPU 缓冲。
固定的更新顺序 Godot 的节点处理顺序取决于场景树结构,改一下节点位置就可能改变行为。这里不依赖它:关卡控制器 LevelController 在自己的 _physics_process 里按固定顺序调用各个系统,其他系统节点不写 _physics_process。物理帧固定 60 Hz,所有逻辑都在物理帧里推进;_process 只负责表现(写缓冲、更新 uniform、相机、HUD)。
暂停用的是 SceneTree.paused 加分层的 process_mode:世界节点是 PAUSABLE,控制器、音乐和界面是 ALWAYS。不同的暂停场景共用这一套机制。
场景文件只有 4 行 scenes/ 下的 .tscn 只有一个挂着脚本的根节点,整棵树都在代码里搭。好处有三个:diff 清楚,不会出现 .tscn 合并冲突,每个节点为什么存在都能在代码里找到出处。
新关卡通过继承 LevelController 并覆盖几个钩子方法实现,是标准的模板方法模式:
搭建本关的地面与场景道具;返回远景实例不放置的地面矩形。
func _build_arena() -> Array[Rect2]: ...
本关的完成条件,每个物理帧在各系统之后调用。
func _tick_goal(dt: float) -> bool: ...
相机跟随的地面目标点。
func _camera_target() -> Vector2: ...
每个渲染帧最后调用,更新本关特有的表现。
func _render_arena(_delta: float, _real_dt: float) -> void: ... 3. 数据布局:结构数组 + 空闲栈 动态对象池是典型的 SoA(Structure of Arrays):每个属性一条 Packed 数组,按槽位下标对齐。容量在 setup() 里一次分配好,运行期间不再扩容。
var alive: PackedByteArray = PackedByteArray() var pos: PackedVector2Array = PackedVector2Array() var hp: PackedFloat32Array = PackedFloat32Array() var flash: PackedFloat32Array = PackedFloat32Array()
……
var _free: PackedInt32Array = PackedInt32Array()
func spawn(p: Vector2, hp_value: float, look: float = 0.5) -> int: if _free.is_empty(): return -1 var i: int = _free[_free.size() - 1] _free.resize(_free.size() - 1) alive[i] = 1 # …… 空闲槽位用一个栈管理,分配和释放都是 O(1)。初始化时按倒序压栈,前几次分配拿到的是低位下标,活跃对象自然集中在数组前部。相比一个对象一个 RefCounted,这样没有对象分配,没有引用计数,也不需要逐个释放。
- 空间哈希:每帧用计数排序重建 所有邻近查询都走自己写的均匀网格,没有用物理引擎。需求只是"圆和圆的距离判断",物理引擎在这里太重,而且结果不好单测。
网格用的是类似 CSR(压缩稀疏行)的布局:cell_start 长度为单元数 + 1,记录每个单元在 cell_items 里的起点;cell_items 是按单元排好序的点下标。重建是两遍扫描加一次前缀和,复杂度 O(N):
节选自 SpatialGrid.rebuild()
第一遍:统计每个单元的点数
for i: int in n: var p: Vector2 = points[i] var c: int = cell_y(p.y) * cols + cell_x(p.x) _cell_of[i] = c _counts[c] += 1
前缀和:每个单元在 cell_items 里的起点
var running: int = 0 for c: int in cols * rows: cell_start[c] = running running += _counts[c] cell_start[cols * rows] = running
第二遍:按单元把点下标写进去
_counts.fill(0) for i: int in n: var c: int = _cell_of[i] cell_items[cell_start[c] + _counts[c]] = i _counts[c] += 1 查询结果写进一个复用的成员数组 result,只有前 result_count 个有效,查询过程不分配内存。区域外的点归入最近的边缘单元,不会越界。
它的单元测试很直接:随机单元尺寸(0.3 到 3 米)、随机点集(故意放一部分在区域外)、随机查询圆,20 轮 × 30 次查询,结果必须和暴力遍历逐个相同。
- 人群模拟:只算醒着的人 近景人群是上千个代理的分离 + 回流模拟。如果每帧对所有人做邻域分离,开销和总人数成正比。实际上绝大多数人站在原位一动不动,所以引入了休眠:
偏离原位不到 1 毫米、速度接近 0、又不在焦点附近的代理不参与计算。 醒着的人挤到睡着的人时,把对方标记为 2(本帧刚醒),加入活动列表,下一帧正常参与计算。标记值和 1 区分开,避免同一对被处理两次。 两个醒着的人只由编号小的一方处理这一对。 这样每帧的计算量只和"被扰动的人数"有关,总人数可以放得很大。
回到原位用的是临界阻尼弹簧。配置里写的是直观的"从 2 米外回到 0.1 米以内的用时 T",运行时反算角频率。临界阻尼从静止出发时,偏移比为 (1 + ωt)·e^(−ωt),令它等于 0.05,解得 ωT ≈ 4.7439:
临界阻尼弹簧满足 (1 + x)·e^(−x) = 0.05 时的 x,用于由"回流目标用时"反算角频率。
const RETURN_X: float = 4.7439
setup()
omega = RETURN_X / cfg.return_time
_return_home():半隐式欧拉
var v: Vector2 = vel[i] + (-w2 * offset - damping * vel[i]) * dt vel[i] = v pos[i] += v * dt 两个小细节:
两点完全重合时没有分离方向。这里按编号以黄金角取一个固定方向(Vector2.from_angle(index * 2.39996323)),既避免除零,相邻编号的方向也天然错开。 分离阶段复用上一帧末尾重建的网格。每帧位移只有几厘米,候选单元基本不变,省掉一次重建。 开发机实测,450 个代理全部处于活动状态时,人群模拟每帧平均 0.6 ms,1% 最差 1.9 ms,在设计预算(2 ms)以内。













