
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 Drag0 ~ 0.3越大手感越沉预测偏差也越大Gravity Scale0.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 GetComponentRigidbody2D(); 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 ListVector2 buffer new ListVector2(256); public static ListVector2 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 ListVector2()每帧就是一次堆分配60 帧下来 GC 压力很快就上来了。移动端上这种分配尤其要避免。用静态 List 缓存用完清空配合后面说的采样环节整个预测流程可以实现零 GC。截断点的偏移。撞击点加了一个沿法线方向的微小偏移是为了让最后一个点不要和障碍物表面完全重叠否则看起来会像陷进去了。3.3 弧长均匀采样让云团圈圈间距一致到这一步我们手里有一串密集的轨迹点间距大概是speed * dt飞得快的时候间距很大飞得慢的时候挤成一团。如果直接按固定间隔取点比如每 3 个取 1 个飞得快的地方点会很稀飞得慢的地方点会密到糊成一团。愤怒的小鸟那种视觉效果点与点之间的弧长是基本均匀的。所以需要一步按弧长重采样public static void SampleByArcLength( ListVector2 source, ListVector2 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 ListSpriteRenderer pool new ListSpriteRenderer(32); private readonly ListVector2 sampled new ListVector2(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(ListVector2 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 LayerobstacleMask 里不含这个 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 字段五分钟就接上了风吹效果的预测这个复用价值还是挺高的。最后提一个我最近在琢磨的方向把预测轨迹和关卡设计工具打通。让策划在编辑器里拖拽发射点的时候直接看到一组不同力度对应的轨迹族就能很直观地判断这个关卡的上手难度是不是太高。这个工具还没做完但初步试下来对关卡迭代速度的提升挺明显的。如果有做弹射玩法的朋友感兴趣可以顺着这个思路试试。