1. 先想清楚:轨迹预测到底解决什么问题
做过 Unity 2D 游戏的人大概都有这个体验:弹射类玩法第一版做出来,物理跑得挺正常,小鸟能飞、能砸、能触发碰撞,测试的同学也挑不出 Bug。可一旦把这段录屏发给没玩过的人看,对方的第一句话通常是"我不知道该往哪扔"。这个反馈很有代表性——物理运动本身没问题,问题在于玩家无法预判物理运动的结果。轨迹预测这套东西,解决的从来不是"能不能飞",而是"玩家敢不敢下手"。
愤怒的小鸟那串白色云团圈圈,是整个弹射玩法里信息密度最高的一个 UI,它把一条连续的物理曲线翻译成了玩家能一眼读懂的离散点阵。这篇文章围绕 Unity 2D 游戏里物理运动曲线轨迹预测的完整实现展开,从数学底子讲到引擎偏差,从拖拽输入讲到云团圈圈的渲染方案,也会把我在实际做弹射玩法时踩过的坑摊开来说。不管你是刚接触 2D 游戏开发的新手,还是已经做过几款小游戏、想把弹射手感再抠细一点的老手,这套思路都能直接拿去改。
有一点必须先讲清楚:轨迹预测在代码层面是一条完全独立的支线,它不参与真实物理模拟。真实飞行由 Rigidbody2D 驱动,预测曲线由我们自己算。这两条路径必须在参数上严格对齐,否则画出来的点和实际飞出来的轨迹会出现肉眼可见的偏差——这是这套系统里 90% 的坑的来源,后面会专门拿出一节来讲。
1.1 玩家体验层面的缺口到底是什么
先拆一下玩家的实际操作流程。手指按在小鸟上,往反方向拖,松手。整个过程大概 1.5 秒,玩家需要在这 1.5 秒内完成三件事:确定方向、确定力度、确认落点。方向和力度是连续输入,玩家的手本身就是个模糊控制器,没法精确到小数点后两位。所以真正决定体验的,是落点确认环节的反馈速度。
没有轨迹预测的时候,玩家只能靠试错。第一发扔歪了,第二发凭记忆修正,第三发再修。三发过去,猪没砸到,耐心先耗完了。关卡设计师精心摆的多米诺结构,玩家根本没看到就被劝退了。反过来,有了轨迹点阵,玩家在拖拽阶段就能看到"如果我松手,这条曲线大概会从哪个位置落下",试错成本从"三发子弹"压缩到"一次拖拽"。
这里有个容易被忽略的细节:预测曲线不需要绝对精确。玩家不需要它精确到像素级,只需要它能表达"高一点还是低一点、远一点还是近一点"。我见过有团队为了追求 100% 精确,把预测系统做得极其复杂,结果玩家体验反而更差——因为曲线每帧都在小幅抖动,看起来像系统不稳定。所以这套系统的目标应该定成:偏差控制在玩家感知阈值以内,视觉表现稳定不抖。
1.2 三条技术路线怎么选
在动手写代码之前,得先决定用哪种方式算预测点。我实测过三种,各有各的适用场景。
第一种是解析公式法。抛物线有闭式解,position = startPos + velocity * t + 0.5 * gravity * t²,一行代码就能算任意时刻的位置。优点是不占性能、结果绝对平滑、不依赖物理步长。缺点是它只对"无阻尼、恒定重力、无碰撞"的运动成立,一旦 Rigidbody2D 上挂了一个非零的 Linear Drag,解析解就开始偏离真实轨迹,时间越长偏得越多。
第二种是数值步进法。自己写一个 for 循环,按固定步长一步步积分速度和位置,每一帧的积分方式尽量模仿物理引擎。优点是能处理阻尼、能处理重力缩放、能在循环里插碰撞检测。缺点是要和引擎的实际积分顺序对齐,对齐不好就有系统性偏差。
第三种是引擎同步模拟法。用Physics2D.Simulate()手动步进物理世界,或者造一个"影子刚体"跑模拟。优点是精度最高,理论上和真实飞行完全一致。缺点是它会影响整个世界,性能开销大,而且在 Auto Simulation 开着的时候调用会打架。
我的选择是第二种,数值步进法,理由很实际:它能在精度和性能之间取一个够用的平衡点,而且碰撞截断的实现最自然。至于精度损失,通过把参数全部走同一份配置来源,实测偏差可以压到很小的范围。
1.3 这套系统的整体模块划分
确定路线之后,代码结构其实很清晰,我一般拆成四个模块:
- 输入与发射控制器:负责处理拖拽、把拖拽向量映射成初速度、松手时给刚体赋速度。
- 预测计算器:一个纯计算的静态类,输入一组参数,输出一串 Vector2 轨迹点。
- 采样与点集管理:把密集的原始轨迹点按弧长重新采样,保证云团点间距均匀。
- 渲染层:用对象池管理一圈小圆点 Sprite,或者用 LineRenderer 的平铺纹理实现虚线效果。
数据流是单向的:输入 → 初速度 → 预测点集 → 采样 → 渲染。没有反向依赖,没有事件回传,这也意味着任何一个环节都能单独替换。比如你后面想把云团方案换成 LineRenderer 方案,只需要动渲染层,前面三个模块完全不用改。
这种拆分方式还有个好处:调试的时候可以只开预测不发射。我在开发阶段会给 Launcher 加一个开关,让它只更新预测曲线但不真正发射刚体,这样就能一边拖一边观察点的分布,非常顺手。
2. 物理预测的数学底子与引擎偏差
很多人卡在这里不是因为代码写不出来,而是因为算出来的东西和实际飞出来的东西对不上,然后开始怀疑人生。其实偏差的根源是可以一个个定位的,只是需要先把物理引擎在 2D 模式下到底怎么算搞明白。
2.1 抛体运动解析解能不能直接用
先说解析解。不考虑任何阻尼时,质量为 m 的物体在恒定重力场下的运动方程是:
v(t) = v0 + g·t p(t) = p0 + v0·t + 0.5·g·t²这个公式非常干净,代码写起来也简单:
// 无阻尼情况下的解析解,仅用于理解原理 Vector2 Evaluate(Vector2 p0, Vector2 v0, Vector2 g, float t) { return p0 + v0 * t + 0.5f * g * (t * t); }问题是,Unity 的 Rigidbody2D 默认情况下的 Linear Drag 是 0,但重力缩放 gravityScale 默认是 1,如果你把它改成 0.8 做"月亮重力"效果,解析解里的 g 必须同步乘上这个系数。更麻烦的是,很多项目为了让发射后的小鸟不至于飞太远,会把 Linear Drag 设成 0.1 到 0.3 之间的值,这时候解析解就开始失效了。
我做过一个实测:速度 15、重力 -9.81、Linear Drag 设 0.2,用解析解算 1.5 秒后的落点,和实际刚体的落点差了大概 0.6 个世界单位。在 2D 弹射游戏里,0.6 个单位差不多是一个小猪的宽度——正好是"打中"和"擦边过"的差距。所以结论很明确:只要 Linear Drag 不为 0,解析解就不能直接用。
那如果项目坚持要用解析解呢?也有办法,就是把 Linear Drag 设成 0,用 gravityScale 来调手感,用速度上限来限制飞行距离。我确实见过有团队这么做,好处是预测精度绝对可靠,代价是手感调节的维度少了一个。
2.2 Rigidbody2D 的阻尼积分把解析解打歪了
Unity 的 2D 物理底层用的是 Box2D 那一套积分方式。关于线性阻尼,官方文档给出的实现是每一步速度按下面的比例衰减:
v = v * 1 / (1 + linearDrag * dt)注意这是乘性衰减,不是"每帧减去一个固定量"。这意味着阻尼的效果是非线性的:速度越快,衰减掉的绝对量越大;速度接近 0 的时候,衰减几乎可以忽略。这跟空气阻力的真实物理规律是吻合的,也是解析解搞不定的原因——它没法用一个简单的时间函数表达出来。
所以步进模拟里,正确的顺序应该是先把重力加速度累加到速度上,再做阻尼衰减,最后用速度更新位置:
vel += gravity * dt; // 受力阶段 vel /= (1f + linearDrag * dt); // 阻尼阶段 pos += vel * dt; // 位置更新这个顺序不能随便换。如果先做阻尼再加重力,结果会有细微差别,积累几十步之后就是肉眼可见的偏移。我在最开始写预测器的时候就是顺序搞反了,调了整整一个下午才发现问题。
提示:不同 Unity 版本对物理实现对内部细节做过调整,如果你在做长距离预测(比如超过 3 秒的飞行),建议用手动发射同一参数对比一次,确认偏差在可接受范围内再上线。
还有一个隐藏得更深的点:物理计算的迭代次数。Project Settings 里的 Velocity Iterations 和 Position Iterations 会影响碰撞求解的结果,进而影响刚体在接触后的速度。轨迹预测在碰撞前是准的,一旦发生碰撞就没法简单预测了——这也是为什么预测曲线应该在碰撞点截断,后面讲。
2.3 重力、重力缩放与固定步长的联动
重力这块有三个变量需要理清关系:Physics2D.gravity(全局重力)、Rigidbody2D.gravityScale(个体倍数)、Time.fixedDeltaTime(物理步长)。
预测时用的重力加速度应该是这两者的乘积:
Vector2 gravity = Physics2D.gravity * body.gravityScale;这一点看起来平平无奇,但我踩过坑。有些项目会给不同的物体设置不同的 gravityScale,比如羽毛道具用 0.3、铁球用 2.0,如果预测器里写死了Physics2D.gravity,那羽毛的预测曲线会飞得比实际远一大截。
Time.fixedDeltaTime的问题更需要警惕。默认是 0.02 秒,也就是 50Hz。预测时的步长如果直接用它,那么预测的时间分辨率就和物理引擎一致,结果最贴合。但如果你把步长改大(比如 0.04)来省性能,累积误差会上升;改小(0.01)精度更高但计算量翻倍。
我一般的做法是:预测步长固定等于Time.fixedDeltaTime,步数取 60 到 90 步,对应 1.2 到 1.8 秒的飞行时间。这个区间覆盖了绝大多数弹射玩法的实际飞行时长,性能开销也完全可以接受。至于更远的距离,一般不靠预测,靠关卡设计把发射台和目标的距离控制住。
| 参数 | 建议取值 | 说明 |
|---|---|---|
| 预测步长 | Time.fixedDeltaTime | 与物理引擎保持一致,减少系统性偏差 |
| 预测步数 | 60 ~ 90 | 覆盖 1.2 ~ 1.8 秒飞行,够用且不浪费 |
| Linear Drag | 0 ~ 0.3 | 越大手感越"沉",预测偏差也越大 |
| Gravity Scale | 0.8 ~ 1.5 | 用于调节飞行弧线高度,比调重力更灵活 |
| 发射速度上限 | 12 ~ 20 | 超过这个值容易穿透薄碰撞体 |
再补一句关于插值的。Rigidbody2D 上的 Interpolate 选项只影响渲染位置的平滑,不影响物理计算。但预测点如果是按物理位置算的,而小鸟的视觉位置被插值"推后"了半帧,看起来就会有点不同步。这个差异极小,通常在高速飞行时看不出来,如果在意的话,把小鸟的插值关掉即可。
3. 核心代码落地:从拖拽输入到轨迹点集
理论讲完,开始上代码。我按模块的顺序来,每个模块都能独立测试。先说明一下后面代码的环境假设:Unity 2021 LTS 及以上版本、内置渲染管线、2D 物理、输入用传统的Input类(换成 Input System 或者移动端 Touch,思路完全一样)。
3.1 拖拽输入与发射速度映射
拖拽这块的核心是方向取反和距离映射。玩家往后拉,小鸟往前飞,所以发射方向是anchor - dragPos。力度方面,用拖拽距离除以最大拖拽距离得到一个 0 到 1 的系数,再乘以最大发射速度。
using UnityEngine; [RequireComponent(typeof(Rigidbody2D))] public class SlingshotLauncher : MonoBehaviour { [Header("发射台锚点(世界坐标)")] [SerializeField] private Transform anchor; [Header("力度参数")] [SerializeField] private float maxDragRadius = 2.0f; // 最大拖拽半径 [SerializeField] private float maxLaunchSpeed = 16f; // 最大发射速度 [SerializeField] private float minLaunchSpeed = 2f; // 最小发射速度,防止轻点一下就飞 [Header("状态")] [SerializeField] private bool isDragging; private Rigidbody2D rb; private Camera mainCam; private Vector2 currentDragPos; public bool IsDragging => isDragging; public Vector2 LaunchVelocity { get; private set; } public Vector2 Origin => anchor.position; private void Awake() { rb = GetComponent<Rigidbody2D>(); mainCam = Camera.main; rb.bodyType = RigidbodyType2D.Kinematic; // 拖拽期间不接受物理 } private void Update() { if (Input.GetMouseButtonDown(0) && !isDragging) { TryBeginDrag(); } else if (isDragging && Input.GetMouseButton(0)) { UpdateDrag(); } else if (isDragging && Input.GetMouseButtonUp(0)) { Release(); } } private void TryBeginDrag() { // 把鼠标位置转到世界坐标,注意 z 值要取到物体所在平面的深度 Vector3 mouseWorld = mainCam.ScreenToWorldPoint(Input.mousePosition); mouseWorld.z = anchor.position.z; // 只允许在物体附近开始拖拽,避免误触 if (Vector2.Distance(mouseWorld, anchor.position) > 1.2f) return; isDragging = true; currentDragPos = anchor.position; } private void UpdateDrag() { Vector3 mouseWorld = mainCam.ScreenToWorldPoint(Input.mousePosition); mouseWorld.z = anchor.position.z; Vector2 offset = (Vector2)mouseWorld - (Vector2)anchor.position; // 限制在最大半径内,超出就钳制到圆周上 offset = Vector2.ClampMagnitude(offset, maxDragRadius); currentDragPos = (Vector2)anchor.position + offset; transform.position = currentDragPos; // 力度系数 float t = offset.magnitude / maxDragRadius; float speed = Mathf.Lerp(minLaunchSpeed, maxLaunchSpeed, t); Vector2 dir = (Vector2)anchor.position - currentDragPos; // 反向 LaunchVelocity = dir.sqrMagnitude > 0.0001f ? dir.normalized * speed : Vector2.zero; } private void Release() { isDragging = false; rb.bodyType = RigidbodyType2D.Dynamic; rb.velocity = LaunchVelocity; // 直接赋速度,比 AddForce 更可控 } }这里有两个决定值得展开说。
第一,为什么用rb.velocity而不是AddForce。AddForce 产生的实际速度是力 / 质量,如果你的小鸟质量不是 1,那预测器里就得再乘一遍质量,很容易漏。直接赋速度就没有这层耦合,而且预测曲线里用的速度和实际发射速度是同一个值,天然对齐。代价是失去了"不同质量手感不同"这个特性,不过弹射玩法本来也不太需要这个。
第二,为什么拖拽期间把刚体设成 Kinematic。如果保持 Dynamic,拖拽的时候小鸟会受到重力往下掉,位置一直在变,预测的起点也跟着抖。设成 Kinematic 之后,位置完全由脚本控制,起点稳定,曲线就稳。松手时再切回 Dynamic 并赋速度。
注意:
ScreenToWorldPoint返回的 z 值默认取的是摄像机到近裁剪面的距离,如果你直接用它当世界坐标,物体会跑到摄像机背后去。一定要手动把 z 改成目标物体所在平面的深度,这是新手最容易翻车的地方之一。
3.2 预测器的步进模拟实现
预测器我用一个静态类来做,纯计算,不继承 MonoBehaviour,方便单元测试和复用。输入参数用 struct 打包,避免一长串函数参数。
using System.Collections.Generic; using UnityEngine; public struct TrajectoryParams { public Vector2 startPos; public Vector2 startVel; public float gravityScale; public float linearDrag; public float step; // 通常传 Time.fixedDeltaTime public int maxSteps; // 最大步数 public float probeRadius; // 碰撞探测半径 public LayerMask obstacleMask; } public static class TrajectoryPredictor { // 复用同一个 List,避免每帧产生 GC private static readonly List<Vector2> buffer = new List<Vector2>(256); public static List<Vector2> Predict(in TrajectoryParams p) { buffer.Clear(); Vector2 gravity = Physics2D.gravity * p.gravityScale; Vector2 pos = p.startPos; Vector2 vel = p.startVel; float dt = p.step; buffer.Add(pos); for (int i = 0; i < p.maxSteps; i++) { // 顺序:受力 -> 阻尼 -> 位置,必须和引擎保持一致 vel += gravity * dt; vel /= (1f + p.linearDrag * dt); Vector2 next = pos + vel * dt; Vector2 delta = next - pos; float dist = delta.magnitude; if (dist > 1e-4f) { Vector2 dir = delta / dist; RaycastHit2D hit = Physics2D.CircleCast( pos, p.probeRadius, dir, dist, p.obstacleMask); if (hit.collider != null) { // 撞到东西,把撞击点作为最后一个点,然后截断 buffer.Add(hit.point + hit.normal * p.probeRadius * 0.1f); break; } } buffer.Add(next); pos = next; // 掉出屏幕下方太远就没必要继续算了 if (pos.y < -50f) break; } return buffer; } }几个实现细节值得说明。
用 CircleCast 而不是 Raycast。小鸟是有体积的,用射线检测的话,预测曲线会穿过很薄的物体边缘,看起来像穿模。用 CircleCast 并传入小鸟的碰撞半径,能让预测的截断点更贴近真实碰撞位置。
in关键字修饰结构体参数。这是个小的性能优化,避免了值拷贝。参数不算多的时候影响不大,但既然写了就写好。
buffer 复用。这是整套系统里最容易忽视的性能点。如果在 Predict 里new List<Vector2>(),每帧就是一次堆分配,60 帧下来 GC 压力很快就上来了。移动端上这种分配尤其要避免。用静态 List 缓存,用完清空,配合后面说的采样环节,整个预测流程可以实现零 GC。
截断点的偏移。撞击点加了一个沿法线方向的微小偏移,是为了让最后一个点不要和障碍物表面完全重叠,否则看起来会像陷进去了。
3.3 弧长均匀采样:让云团圈圈间距一致
到这一步,我们手里有一串密集的轨迹点,间距大概是speed * dt,飞得快的时候间距很大,飞得慢的时候挤成一团。如果直接按固定间隔取点(比如每 3 个取 1 个),飞得快的地方点会很稀,飞得慢的地方点会密到糊成一团。愤怒的小鸟那种视觉效果,点与点之间的弧长是基本均匀的。
所以需要一步按弧长重采样:
public static void SampleByArcLength( List<Vector2> source, List<Vector2> result, float spacing) { result.Clear(); if (source.Count < 2) return; result.Add(source[0]); float carried = 0f; // 上一段剩余的长度 for (int i = 1; i < source.Count; i++) { Vector2 a = source[i - 1]; Vector2 b = source[i]; float segLen = Vector2.Distance(a, b); if (segLen < 1e-5f) continue; float travelled = 0f; // carried 表示从这一段的哪个位置开始找下一个采样点 float cursor = spacing - carried; while (cursor <= segLen) { float t = cursor / segLen; result.Add(Vector2.Lerp(a, b, t)); cursor += spacing; } carried = (carried + segLen) % spacing; } }这段的逻辑是维护一个"已走过的长度"游标,每到spacing的整数倍就放一个点,跨线段的时候把余量带过去。这样无论原始点的密度如何,输出的点间距都是均匀的。
spacing的取值需要根据关卡尺寸和屏幕分辨率来定。我一般的经验值是0.4 到 0.6 个世界单位,配合美术做的直径 0.15 左右的圆点贴图,视觉上比较舒服。如果关卡的地图特别大,可以适当放大。
实操心得:间距不要太小。我试过 0.2 的间距,结果一条曲线画出四五十个点,屏幕上一片白色,反而看不清落点。后来改成 0.45,点数降到 15 个左右,可读性一下就上来了。点的数量控制在 12 到 20 个之间是比较好读的区间。
3.4 云团圈圈的两种渲染方案
点集拿到手,接下来就是画。这里有两个方案,我都实现过,各有取舍。
方案一:对象池 + Sprite 点阵。预先创建 30 个圆点 SpriteRenderer 塞进池子,每帧根据采样结果设置位置和数量,多出来的隐藏。优点是可以单独控制每一个点的缩放和透明度,能做出"越远越淡越小"的渐变效果,视觉上更接近原版。缺点是对象数量多,DrawCall 会上去,虽然 2D 场景可以用 Sprite Atlas 合批,但 SpriteRenderer 之间的合批条件是渲染顺序连续、材质相同。
using System.Collections.Generic; using UnityEngine; public class TrajectoryDots : MonoBehaviour { [SerializeField] private SpriteRenderer dotPrefab; [SerializeField] private int poolSize = 32; [SerializeField] private float minScale = 0.35f; // 最后一个点的缩放 [SerializeField] private float maxScale = 1.0f; // 第一个点的缩放 [SerializeField] private float spacing = 0.45f; private readonly List<SpriteRenderer> pool = new List<SpriteRenderer>(32); private readonly List<Vector2> sampled = new List<Vector2>(64); private void Awake() { for (int i = 0; i < poolSize; i++) { var dot = Instantiate(dotPrefab, transform); dot.gameObject.SetActive(false); pool.Add(dot); } } public void Show(List<Vector2> rawPoints) { TrajectoryPredictor.SampleByArcLength(rawPoints, sampled, spacing); int count = Mathf.Min(sampled.Count, pool.Count); for (int i = 0; i < pool.Count; i++) { if (i < count) { var dot = pool[i]; dot.gameObject.SetActive(true); dot.transform.position = sampled[i]; // 越靠后越小,做出"逐渐消散"的感觉 float t = count <= 1 ? 0f : (float)i / (count - 1); float s = Mathf.Lerp(maxScale, minScale, t); dot.transform.localScale = Vector3.one * s; var c = dot.color; c.a = Mathf.Lerp(1f, 0.45f, t); dot.color = c; } else { pool[i].gameObject.SetActive(false); } } } public void Hide() { for (int i = 0; i < pool.Count; i++) pool[i].gameObject.SetActive(false); } }方案二:LineRenderer + 平铺纹理。给 LineRenderer 一张圆点贴图,把textureMode设成Tile,材质的贴图 Wrap Mode 设成 Repeat,线就会自动把圆点沿着长度方向平铺。这个方案的 DrawCall 只有 1,性能极好,但缺点是点的间距由贴图的平铺比例控制,不跟随弧长,曲线拐弯的地方点会挤在一起。另外线是连续的带子,看起来更像"虚线"而不是"独立的圈圈"。
// LineRenderer 方案的关键设置 lineRenderer.textureMode = LineTextureMode.Tile; lineRenderer.alignment = LineAlignment.TransformZ; lineRenderer.numCapVertices = 0; lineRenderer.numCornerVertices = 0; // material 的贴图需要在导入设置里把 Wrap Mode 改成 Repeat lineRenderer.material.mainTextureScale = new Vector2(1f / spacing, 1f);我的最终选择是方案一。原因很直接:弹射玩法的核心目标是让玩家看清落点,独立圆点的可读性明显优于连续虚线,尤其是当曲线接近抛物线顶点、方向变化剧烈的区域,独立点的间距一致性更好。LineRenderer 方案我会用在一些辅助场景,比如道具的飞行预览这种不太需要精细阅读的地方。
4. 让预测"看起来对":碰撞截断与视觉打磨
计算正确和看起来正确是两件事。前面几节保证了数值上的准确性,这一节处理的是"玩家眼睛看到的东西对不对"。
4.1 轨迹撞到障碍物就停
这是必做的一环。如果预测曲线穿过了前面的石块继续往后延伸,玩家会认为"可以打穿",结果实际飞行撞在石头上,体验立刻崩塌。
实现方式就是在步进循环里做 CircleCast(前面代码里已经写进去了)。但有几点需要细化:
探测半径的选择。这个半径应该等于小鸟碰撞体的实际半径。太大会让曲线在离障碍物还有一段距离时就停住,太小又会穿过薄墙。如果你的小鸟碰撞体是 CircleCollider2D,直接读它的radius乘上lossyScale。如果是 BoxCollider2D,可以用一个近似的外接圆半径。
要不要忽略小鸟自己。如果小鸟本身也在 obstacleMask 里,第一次 CircleCast 就会命中自己。要么把小鸟单独放到一个 Layer,要么在发射前把小鸟移出检测范围。我用的是前者,给小鸟单独一个 "Projectile" Layer,obstacleMask 里不含这个 Layer。
撞到地面算不算截断。通常算。但如果地面很矮、曲线本来就会落到地面,截断点正好就是落点,这反而是玩家最想知道的信息。这时候可以让最后一个点稍微放大一点,或者换个颜色,强调"这里落地"。
忽略可破坏物的检测时机。如果关卡里有可以被砸碎的木板,预测时它还没碎,所以曲线会在木板处截断。这没问题,玩家能接受"这一发会打在木板上"。真正麻烦的是移动平台,这种情况下预测只在发射瞬间准,之后就不准了——这种关卡要么不做移动障碍,要么在预测里不考虑它们,靠关卡引导玩家理解。
4.2 排序层、分辨率与摄像机适配
排序层(Sorting Layer)要理清。云团圈圈必须画在小鸟和场景道具的上层,否则会被树木挡住。但也不能画在 UI 之上,因为暂停菜单弹出来的时候曲线还在飘就很奇怪。我的做法是在 Sorting Layers 里插入一个专门给轨迹点用的层,位置在角色之上、UI 之下。
分辨率适配。这是 2D 游戏的老问题。如果你的摄像机用固定正交尺寸(比如 Orthographic Size = 5),那世界单位在屏幕上的像素密度是固定的,圆点的大小在不同分辨率下比例一致,不会有问题。但如果摄像机尺寸随宽高比动态调整,就要保证点的大小跟着变。
我一般会把摄像机的正交尺寸固定下来,然后按目标宽高比来算所需的垂直尺寸:
// 假设设计分辨率是 1080x1920,正交尺寸按宽度锁定 float designAspect = 1080f / 1920f; float currentAspect = (float)Screen.width / Screen.height; if (currentAspect < designAspect) { // 屏幕更窄,按宽度适配,垂直方向要放大 cam.orthographicSize = designOrthoSize * (designAspect / currentAspect); } else { cam.orthographicSize = designOrthoSize; }这样能保证在窄屏设备上,左右两侧的内容不会被裁掉。代价是画面上下会多出一些空间,需要美术在设计时留边。
点的世界尺寸要不要跟着分辨率缩放。如果摄像机正交尺寸会变,那么同一个世界尺寸的圆点,在屏幕上占的像素数就会变。窄屏设备上正交尺寸被放大,圆点看起来就变小了。解决办法有两种:一是把点的缩放按当前正交尺寸 / 设计正交尺寸的比例同步放大;二是干脆让点的大小在世界空间中自适应,用一个参考宽度来计算。前者实现简单,后者更稳定,我一般用后者。
4.3 发射瞬间的衔接处理
松手那一瞬间有几个细节,处理不好会有明显的割裂感。
曲线要立刻隐藏,不能淡出。我见过有项目给轨迹点做了 0.2 秒的淡出动画,结果小鸟已经飞出去了,点还在原地飘,看着像 bug。松手瞬间直接Hide()是最干净的。
小鸟的速度赋值要在同一帧完成。如果拖拽用 Update 处理,赋速度也在 Update 里,而物理在 FixedUpdate 里跑,那么赋值到实际生效之间会有一个时间差。对普通玩法影响不大,但如果你的预测曲线是在松手前的最后一帧画的,而小鸟的实际起始位置和预测起点差了半帧的重力位移,曲线起点会有轻微错位。
解决方式是把发射逻辑放到 FixedUpdate 里,或者用一个标志位在 FixedUpdate 里消费:
private bool pendingLaunch; private Vector2 pendingVelocity; private void FixedUpdate() { if (pendingLaunch) { pendingLaunch = false; rb.bodyType = RigidbodyType2D.Dynamic; rb.velocity = pendingVelocity; } }发射后立刻恢复刚体状态。记得把 Kinematic 切回 Dynamic,这个如果不切,小鸟会停在原地不动,然后你会盯着屏幕怀疑自己的代码是不是没执行。
加一点点随机扰动。这一点看项目需求。有些团队会故意给发射速度加 1% 到 2% 的随机偏移,让同一发不总是完全相同的结果,增加一点变化。但要注意,加了扰动之后预测曲线就不完全准了。我的做法是不加扰动,靠关卡的其他元素来制造变化。
5. 常见问题与排查实录
这一节是我做弹射玩法过程中真实遇到过的问题,按出现频率排序。
5.1 预测曲线和实际飞行对不上
这是最普遍的问题,原因通常有五个。
原因一:预测参数和刚体参数不是同一个来源。这是最常见也是最蠢的一个。预测器里的 linearDrag 写的是 0.15,刚体上 Inspector 里手动改成了 0.2,然后你就开始怀疑人生。解决办法是让预测器直接读刚体的实际参数:rb.drag、rb.gravityScale,不要把数值硬编码。
var p = new TrajectoryParams { startPos = rb.position, startVel = launchVelocity, gravityScale = rb.gravityScale, linearDrag = rb.drag, step = Time.fixedDeltaTime, maxSteps = 80, probeRadius = projectileRadius, obstacleMask = obstacleMask };原因二:重力方向被改过。有些横版游戏会把Physics2D.gravity改成(0, -20)或者(-5, -9.81)来做特殊效果。这时候如果预测器里用的是硬编码的 -9.81,偏差会非常大。
原因三:速度被物理材质影响。如果小鸟身上挂了 Physics Material 2D 并且设置了很高的 friction,发射瞬间和发射台的摩擦会消耗一部分速度。解决办法是把小鸟和发射台设置成互相忽略碰撞,或者干脆把刚体在发射前设成 Kinematic。
原因四:接触偏移(Contact Offset)。Rigidbody2D 的碰撞在真正接触前就有一个小的偏移量,这个偏移会让实际碰撞位置比几何表面早一点点。误差很小,通常在 0.01 到 0.05 个世界单位之间,肉眼看不出来。如果你追求极致精度,可以把这个值调小,但会增加穿透风险。
原因五:物理模拟模式不同。Project Settings 里 Physics 2D 的 Simulation Mode 如果设成 Update 而不是 FixedUpdate,物理会在每帧更新时步进,步长不固定,预测就很难严格对齐。做弹射玩法建议保持默认的 FixedUpdate 模式。
5.2 性能与 GC 问题
移动端上这个问题会被放大。我在一部中端安卓机上测过,如果 Predict 里每帧new List,加上采样时又new一次,每帧大概产生 4KB 的垃圾。60 帧下来 240KB/秒,几秒钟触发一次 GC,表现是画面每隔一阵就卡一下。虽然单次卡顿只有几毫秒,但在弹射瞄准这种需要精细操作的时刻,卡一下非常影响手感。
优化的手段有三个层次:
第一层:所有 List 都用静态缓存或成员变量复用。这个前面代码已经做了。
第二层:不要每帧都重算。如果玩家拖拽的位置没变(鼠标没动),就没必要重新计算。用一个阈值判断,位置变化超过 0.01 个单位才重算。
第三层:降采样预计算。如果你的预测步数是 90 步,其实没必要每一步都存。可以在循环里每隔 2 步存一次,最后采样时再插值。这样内存占用减半,采样时的遍历量也减半。
还有一点关于对象池:不要用 Instantiate 和 Destroy 反复创建销毁圆点。这个是最基础的优化,但在小项目里经常被忽略,因为开发阶段感觉不出问题,等到关卡里同时有好几个可拖拽物体,每个都在创建销毁,问题就暴露了。
5.3 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 曲线整体偏移,方向正确 | 预测用重力或阻尼与刚体不一致 | 检查rb.drag、rb.gravityScale、Physics2D.gravity |
| 曲线比实际飞得远 | 预测没考虑阻尼,或刚体阻尼更高 | 打印对比两组参数 |
| 曲线比实际飞得近 | 预测里的速度小于实际发射速度 | 检查是否有质量、力的换算遗漏 |
| 曲线起点不在小鸟位置 | Update 和 FixedUpdate 时序差异 | 把发射逻辑放到 FixedUpdate |
| 轨迹点抖动 | 每帧重新计算导致位置跳变 | 加变化阈值,或对点做平滑插值 |
| 轨迹穿过障碍物 | 探测半径太小或用了 Raycast | 改用 CircleCast 并调大半径 |
| 曲线在障碍物前就停住 | 探测半径过大 | 减小半径到接近碰撞体尺寸 |
| 轨迹点被场景遮挡 | Sorting Layer 顺序不对 | 单独建一个高层级的 Sorting Layer |
| 屏幕窄的设备上点变小 | 摄像机正交尺寸动态变化 | 把点的大小按正交尺寸比例缩放 |
| 移动端卡顿 | 每帧堆分配 | 用 List 缓存,减少计算频率 |
5.4 一个容易被忽略的细节:点的顺序
这个问题我觉得值得单独说一下。对象池拿出来复用的时候,点的显示顺序应该从近到远,这样能让"越远越淡"的渐变自然。但如果你的采样结果里,撞到障碍物截断的那个点被追加在最后,并且它的缩放继续按渐变减小,看起来就会有点怪——最后一个点特别小,反而看不清落点在哪。
我的处理是把最后一个点单独提出来,给它一个稍微大一点的缩放和一个更亮的颜色,作为"落点标记"。这个改动很小,但玩家对落点的判断速度会明显提升。实测下来,加了落点强调之后,测试者的第一次命中率大概能提升两成左右——当然这个数据样本很小,只是我个人的观察。
另外,如果你想让曲线在接近落点时逐渐变细,也可以在采样之后对点做一次位置上的微调,让后半段的点稍微向轨迹法线方向聚拢一点,做出"尾巴收拢"的效果。这个纯属美术层面的偏好,看项目风格。
6. 几个从坑里爬出来之后记下的经验
前面把该讲的都讲完了,最后分享几条我觉得比较有价值的个人体会。
第一条是关于参数集中管理。我现在的做法是建一个 ScriptableObject,把重力缩放、拖拽半径、最大发射速度、预测步数、点间距这些全部放进去,预测器和刚体都从这个配置读取。好处是策划可以直接在 Inspector 里调手感,不用改代码,也不会出现两处参数不一致的问题。这个改动看起来很小,但省掉了我后面大量的对接沟通成本。
第二条是关于可视化调试。我在开发阶段会画三条线:预测曲线(白点)、实际飞行的历史轨迹(红色 LineRenderer)、以及历史轨迹在预测步数时刻的对应点。三条线叠在一起看,偏差一目了然。这个调试工具我用了很久,后来干脆留在了正式版本里,用开发者模式开启,遇到玩家反馈"手感不对"的时候能快速定位。
第三条是关于别过度追求精确。前面提过一次,这里再强调一下。我最初做这套系统的时候,花了很多时间想让预测和实际 100% 吻合,后来发现玩家根本分辨不出 0.05 个单位的偏差,但能明显分辨出点阵的抖动、闪烁、间距不均。视觉稳定性比数值精度重要得多。如果你的预测曲线很平滑很好看,偏差只要在半个身位以内,绝大多数玩家都感觉不到。
第四条是关于移动端的触控适配。手指比鼠标粗得多,拖拽开始的位置判断距离要放宽,我一般从 1.2 个单位放到 2 个单位。同时拖拽半径也要重新调,因为手指移动的物理距离对应的屏幕区域有限,原来的半径可能拉不满。这块一定要在真机上试,编辑器里怎么调都不准。
第五条是关于预留扩展点。这套预测器如果参数设计得干净,后面加"多次弹跳预测""道具影响下的轨迹变化"都不是难事。但前提是预测器的输入参数是一个结构体,而不是散落在各处的一堆变量。我在第二个项目里复用第一版的代码,只改了参数结构加了一个 windForce 字段,五分钟就接上了风吹效果的预测,这个复用价值还是挺高的。
最后提一个我最近在琢磨的方向:把预测轨迹和关卡设计工具打通。让策划在编辑器里拖拽发射点的时候,直接看到一组不同力度对应的轨迹族,就能很直观地判断这个关卡的上手难度是不是太高。这个工具还没做完,但初步试下来,对关卡迭代速度的提升挺明显的。如果有做弹射玩法的朋友感兴趣,可以顺着这个思路试试。