课程目录 / GameBase / Lesson 01

GameBase · Lesson 01

Entity 并不只是一个有坐标的对象。我们直接让角色运动、产生新实体、标记销毁,再逐帧检查:输入、物理、表现、生命周期与内存释放分别由谁管理。

这次以 Deepnight 的 gameBase 为研究对象。蓝色 Hero 和橙色测试实体是课程独立编写的简化模型;下方会明确标出与原版 Haxe 的相同点及区别。

① 亲手控制一个 Entity

📱 手机:按住下面的左右按钮移动;点「跳跃」或轻点游戏画面起跳;也可以切换 AI 控制。

电脑:A/D 或 ←/→ 移动;空格/W/↑ 跳跃。青色虚线和半透明影子都标记真实逻辑位置,蓝色实心角色才是 Sprite 显示位置。

操作数据快照准备中…
专题

为什么 30Hz 物理可以显示出 60Hz 的平滑运动?

Deepnight 的 Sprite 内插,和 Unity / UE 的做法到底有什么关系?先理解同一个位置为何能有两种“正确”的坐标,再观察外推预测错误。

先认清三个概念

权威物理位置

FixedUpdate 已经计算出的实体位置。碰撞和玩法判断以它为准。

渲染位置

Sprite / Transform 最终在屏幕显示的位置。可以用不同算法计算,但不应反过来修改权威物理状态。

插值系数 α

表示固定更新时间段内的进度,通常是 0~1。不是“Update 的位置”和“FixedUpdate 的位置”各占多少权重。

关键事实:当物理更新频率较低时,画面可能重复显示同一个物理坐标。插值只解决视觉阶梯感,并不会让碰撞检测真的变成 60 次/秒。

四个角色同时动:同一物理轨迹,四种显示方案

这里不是孤立的点或进度条。四个窗口的角色都使用同一份 30Hz 物理位置与速度,由同一个 60Hz 渲染时钟驱动;不同的只有 Sprite 的绘制坐标。先选「撞墙急停」,观察外推是否冲过障碍。

A · 不插值响应直接,30Hz 位置跳变
显示 = 当前物理位置
C · Smoothstep每个物理步缓入缓出
显示 = lerp(前一个, 当前, 3α² − 2α³)
D · 外推预测响应领先 · 可能穿墙
显示 = 当前 + 速度 × 剩余时间

青色虚线框:当前权威物理位置橙色虚线:前一个物理点蓝色角色:真正画出来的位置

演示自动播放中。数值说明保持静止;按「查看当前计算」才更新,以免频繁重排页面。

注意:四个画面是共享物理轨迹的教学渲染,不是四套独立物理模拟,也不是 Deepnight 原版可执行文件。按「单步物理」时会先暂停,再推进同一个教学物理步;画面不断更新不会修改碰撞状态。

可选进阶:六种算法的精确数值计算(手动调整 α)

同一份物理结果,六种显示坐标

这是一个独立的公式讲解,并不声称在运行 Deepnight 原版游戏。它使用同样的物理样本计算多种算法,方便在手机上直接比较。

当前物理位置—
Deepnight 显示位置—
外推预测显示位置—

图里金色虚线是当前已经知道的真实物理位置。外推落在它前面不代表物理真的向前执行了;「未来结果」只是用于说明预测会不会出错的预设场景。初学时只选“撞墙停下”并拖 α,就够了。

四类常见方法,别把名字相似的算法混在一起

None / 直接显示

RenderX = CurrentX。无插值延迟,但物理步之间图片停住,再跳到下一位置。

Linear Interpolation / 线性内插(Lerp)

RenderX = PreviousX + (CurrentX − PreviousX) × α。用两个已知端点,视觉轨迹简单、不会超过端点;代价是稍微滞后。Deepnight 选的就是它。

Smoothstep / Hermite 曲线内插

Smoothstep 让一段的头尾速度趋向 0;Hermite 还可使用端点切线/速度,让轨迹更符合期望运动。但参数错误可能产生过冲,逐物理步机械套 Smoothstep 也容易每步重新加减速。这两种不是作者当前 Entity 的默认位置插值。

Extrapolation / 外推(预测未来)

RenderX ≈ CurrentX + Velocity × Δt。看起来领先、不必等下一次物理采样,但撞墙、急停、转向、受力都会使预测失准,并在真实更新后纠正。Deepnight 的 sprX/sprY 没使用外推。

进阶:为什么逐帧 Lerp(currentDisplay, target, 0.2) 不等于物理插值?

这种逐渲染帧“追目标”的写法属于指数平滑/缓动跟随:结果取决于历史显示值和系数(若未做帧率归一还取决于 FPS),且通常不能保证在固定时间到达目标。Deepnight 用的是确定端点和确定时间系数的 Lerp,而不是每帧做 spr.x = lerp(spr.x, attachX, 0.2)。这两者不能因为都调用 lerp() 就视为同一种调度算法。

Deepnight 的真实实现:Sprite 层自己计算

下面直接摘出冻结的 gameBase/src/game/Entity.hx、deepnightLibs/src/dn/Process.hx 关键语句。这里不是教学 JS 的伪代码。

// Entity.hx:在本 Entity 中保存上次 FixedUpdate 开始时的位置
var lastFixedUpdateX = 0.;
var interpolateSprPos = true;

// 每次 fixedUpdate() 一开始:
updateLastFixedUpdatePos(); // lastFixedUpdateX = attachX

// 读取当前“应该显示的 Sprite X”:
return interpolateSprPos
  ? M.lerp(lastFixedUpdateX, (cx+xr)*Const.GRID,
           game.getFixedUpdateAccuRatio())
  : (cx+xr)*Const.GRID;

// postUpdate():最终同步到 Heaps 的渲染对象
spr.x = sprX;
spr.y = sprY;

// dn.Process.hx:
return _fixedUpdateAccu / (getDefaultFrameRate() / FIXED_UPDATE_FPS);

具体执行顺序:物理更新前记录旧坐标 → 固定步推进碰撞、速度与坐标 → 普通画面帧按剩余累计时间算 α → 在 postUpdate() 写给 h2d Sprite。

重要边界:attachX、cx/xr 与碰撞仍是权威数据,插值只影响 spr.x/y。作者在手动瞬移的 setPosPixel()/setPosCase() 等路径也会同步 lastFixedUpdateX/Y,避免把大跨度瞬移显示成慢慢滑动。

为何这样做:GameBase 的 Entity 是作者自己的游戏对象,物理主要是自定义网格与速度积分,不依赖一个托管所有物体姿态的 Rigidbody 引擎。因此在 Entity 的渲染同步处加一个很小的 Lerp,就能以低复杂度获得平滑视觉,也方便按实体选择开关。

源码:原版 Entity.hx · 原版 dn.Process 所属库。

和 Unity / UE 的真正差别:不是数学优劣,而是管理层次

Deepnight GameBase

原版 Entity 自己记录旧物理位置,用 M.lerp(..., α) 求 Sprite X/Y,后处理时写到 spr.x/y。主要是自定义 2D Entity 的位置显示;不包含通用 Rigidbody 姿态/旋转/网络预测框架。

Unity Rigidbody / Rigidbody2D

None / Interpolate / Extrapolate 是物理组件提供的配置;Interpolate 用过去的两次物理姿态(位置、旋转)来平滑显示,通常延迟一个物理更新;Extrapolate 用当前速度推测下一帧。Deepnight 的 Lerp 与 Unity Interpolate 数学思想一致,只是 Unity 在物理系统里管理这一过程。Unity 官方手册

UE / Chaos 物理

UE 的物理 Sub-stepping 是将物理计算分成更小步以改善精度和稳定性,不能误称它就是 Sprite 插值。异步物理可以使用物理结果插值;网络角色的 CharacterMovementComponent 还会处理平滑服务器修正的位置。这是不同来源的时间差。UE Sub-stepping 官方说明 · UE CharacterMovement 文档

怎么选择:自己写小型 2D 网格物理,Deepnight 这种“物理数据与 Sprite 显示分离”的办法轻量、容易调试;Unity 有 Rigidbody 就优先研究官方插值开关;UE 要先确认你遇到的是物理子步问题、异步物理同步,还是多人网络平滑,而不是先找一个万能的 Interpolation 开关。

不要混淆另外两件事:相机跟随平滑是另一个显示层问题,可能放大或掩盖角色抖动;碰撞、射线和回放仍应依据权威物理位置,不能把预测的 Sprite 位置写回真实模拟。

进阶:为什么 Unity 官方还提醒 Physics.SyncTransforms?

启用 Rigidbody 的物理插值/外推时,物理系统会管理对应的 Transform。若你绕过物理操作直接改了 Transform,Unity 文档提醒需要注意 Physics.SyncTransforms() 的同步语义。这个“渲染姿态与物理姿态可能不同”的问题,和 Deepnight 将 spr.x 与 attachX 分开,本质上都体现了权威物理状态与显示状态不必相同。

02

刚才那一帧到底发生了什么?

游戏画面仍会流畅运行,但这里显示的是一次捕获的帧快照,不会每秒更新 60 次。点击「捕获当前帧」,或先「暂停自动运行」再「单步」,才能更新观察结果。

正在准备第一份快照…

画面帧0
物理帧0
MoveX0
Jump松开

这里的阶段来自刚才小游戏实际执行过的教学逻辑,不是另外启动的独立时间轴。

为什么 InputFrame 与 Entity 物理更新不一定同频?

在这个实验里,玩家/AI 输入每画面帧采样(60Hz),物理每两帧计算一次(30Hz)。这样的输入采样和物理解耦后,角色可以统一接受 AI、手柄或键盘动作,但只在物理步长中推进位置。原版 Game 同样有 fixedUpdate() 与 update() 等不同阶段;本课将具体顺序简化为可观察模型,不宣称逐条复刻 dn.Process 的内部调度。

03

创建、destroy 和 dispose 是三件事

创建的橙色实体会进入 ALL;请求销毁后先标记到 GC,帧末才统一释放。你可以暂停游戏观察“标记后尚未释放”的短暂状态。

ALL 中对象1
待清理 GC0
已 dispose0
总创建次数1
新建对象后即加入 ALL。destroy() 是申请删除;真正释放资源需要等 Game 进行垃圾收集。
销毁的性能和风险

本教学版本的 ALL/GC 是普通 JavaScript 数组;原版使用 FixedArray<Entity>,默认容量为 1024。原版 destroy() 只设 destroyed 并加入 GC;Game.garbageCollectEntities() 再遍历 GC 调用 dispose,清除资源,并由实体从 ALL 移除。延期清理减少在遍历更新对象时直接移除的干扰,但必须保证清理阶段一定执行,且重复 destroy 不要重复排入。

+

坐标到底为什么分成 cx 和 xr?

这里显示上面 Hero 在最近一次捕获快照时的坐标。该课程选用每格 48px 做示意;原版格子大小由 Const.GRID 配置。

格子编号 cx0
格内比例 xr0
逻辑像素 x0
插值画面 x0

x = (cx + xr) × GRID

当角色从 3 格走向 4 格时,cx 加一,而 xr 回到 0 附近;逻辑位置连续。可把它类比 Unity 的逻辑坐标与 Render 插值位置,但不是所有游戏都必须采用格内拆分。

04

对照真正的 GameBase 源码

以下代码与解释以固定版本的原作者仓库为依据;课程的 JS 物理、AI、按钮都只是独立教学实现。

Entity.hx(固定版本) · Game.hx(固定版本) · SamplePlayer.hx · 课程笔记

1. 原作者究竟把生命周期交给谁?
// Entity.hx:关键代码节选
public static var ALL = new FixedArray<Entity>(1024);
public static var GC = new FixedArray<Entity>(ALL.maxSize);

public final function destroy() {
    if( !destroyed ) {
        destroyed = true;
        GC.push(this);
    }
}

// Game.hx:帧末
for(e in Entity.GC)
    e.dispose();
Entity.GC.empty();

这里的代码经过少量排版简化,但流程与原版一致。Entity.dispose() 会从 ALL 移除实体,并释放 Sprite、Cooldown 等持有的资源。

2. 同一个 Entity 为什么有这么多更新函数?

preUpdate() 更新游戏时间/真实时间冷却、状态影响和持续动作;fixedUpdate() 处理实体移动及速度;frameUpdate() 提供常规逐帧逻辑;postUpdate() 同步 Sprite 的显示坐标与形变;finalUpdate() 记录上一次显示位置。Game 负责调用这些阶段并在结束时清理。

3. 为什么一个 Entity 的内存成本可能比想象中大?

原版构造时同时创建 cd 和 ucd 两个独立的 Cooldown,并创建 RecyclablePool(15) 用于持续动作,还初始化速度集合、Sprite 和矩阵等。所用 deepnightLibs 的 Cooldown 默认会预先创建 512 个计时器实例:按这个实现计算,每个 Entity 会先持有两个计时器池,共计 1024 个 CdInst(不是说一定同时有 1024 个正在计时)。这是值得结合实际实体数量做内存审计的地方。

4. 玩家与 AI 共用输入应设计在哪里?

GameBase 的 SamplePlayer 在 preUpdate() 查询 ControllerAccess,把 walkSpeed 暂存起来,再在 fixedUpdate() 应用到物理。它没有统一的 AI InputSource。本课增加一个可切换的模拟输入源,只为验证我们讨论的“真人与 AI 都生成 MoveX/Jump,再进入相同运动机制”;这是我们提出的架构延伸,而非作者原版功能。

05

一课三问

选答案后会立即看到解释。

完成标记