☰
Unity 2D射击游戏子弹系统从零搭建与性能优化实战
2026/10/15 11:50:01 网站建设 项目流程

1. 从零搭建前的核心决策:为什么你的子弹系统一开始就容易写歪

做2D游戏开发的人,几乎都绕不开"玩家发射子弹"这个功能。看起来简单——按下键、生成一个物体、给它一个速度、飞出去、碰到东西消失。但真正动手写过的人都知道,这个功能是新手代码质量的分水岭。我见过太多项目,子弹系统写了三遍还在返工,原因不是技术难,而是一开始就没想清楚几件事。

先说一个反直觉的结论:子弹系统的难点从来不在"发射"这个动作本身,而在于"子弹的生命周期管理"和"发射逻辑与角色控制的解耦"。大多数教程教你Instantiate一个预制体、给个Rigidbody2D速度就完事了,跑起来确实能动,但一旦你要加冷却时间、多种子弹类型、对象池、后坐力、弹道散射,那套代码就会变成一团乱麻。

这篇文章面向的读者是:已经会用Unity基础操作、知道什么是GameObject和Component,但还没系统写过射击系统的开发者。我会从架构决策讲到具体代码,从最朴素的实现一步步推到生产级别的方案,把每个选择背后的"为什么"讲透。你跟着走完,拿到的不只是一段能跑的代码,而是一套能直接搬进项目里的射击系统骨架。

在动手之前,先把几个关键决策摆出来,这些决策会直接影响你后面写的每一行代码:

决策点选项A选项B推荐场景
子弹移动方式Transform位移Rigidbody2D速度需要物理碰撞选B,纯弹幕选A
子弹生成方式直接Instantiate对象池原型阶段用A,正式项目用B
发射逻辑归属挂在玩家身上独立发射器组件单一武器用前者,多武器用后者
碰撞检测OnTriggerEnter2D射线检测高速子弹用射线,普通速度用触发器
输入响应Update里GetKeyDown事件驱动简单项目用前者,复杂输入用后者

这张表不是让你现在就选,而是让你心里有个数——后面每一节我都会展开讲这些选择背后的逻辑。很多人写子弹系统翻车,就是因为拿着原型阶段的方案去扛正式项目的需求,等到发现问题时,代码已经耦合得改不动了。

还有一个容易被忽略的前置问题:你的游戏是俯视角还是横版?这决定了子弹的朝向计算方式。横版游戏里子弹基本沿水平方向飞,朝向处理简单;俯视角游戏里子弹要朝着鼠标或摇杆方向飞,涉及角度计算和向量归一化。我在下面会以俯视角为主来讲,因为它的通用性更强,横版的情况只需要把方向向量固定成Vector2.right乘以朝向符号即可。

提示:在开始写代码前,先在场景里手动摆一个子弹预制体,确认它的Sprite、Collider、Rigidbody2D配置正确。我踩过的坑是代码没问题但预制体上忘了加Collider,排查了半小时才发现是美术资源的问题。

2. 子弹预制体的配置细节:那些教程不会告诉你的参数陷阱

2.1 Rigidbody2D的Gravity Scale必须归零

这是最基础但也最容易被忽略的一点。新建的Rigidbody2D默认Gravity Scale是1,意味着你的子弹会受到重力影响往下掉。横版游戏里子弹会画出一条抛物线,俯视角游戏里子弹会莫名其妙往下偏。把它设成0,子弹才会沿直线飞行。

但这里有个进阶问题:如果你想要子弹有轻微的追踪效果或者弧线弹道呢?那就不能简单归零了。我的做法是保留一个很小的重力值(比如0.05),配合Drag参数来模拟空气阻力,这样子弹会有一个自然的减速弧线。这种细节在弹幕游戏里特别有用,能让弹道看起来不那么"死板"。

Collision Detection这个参数也值得说。默认是Discrete(离散检测),对于速度较快的子弹,可能会出现"穿透"现象——子弹在一帧内从敌人前面飞到了后面,碰撞检测完全没触发。解决办法是改成Continuous(连续检测)。代价是性能开销增加,所以只对子弹这种高速小物体开启,不要全局开。

2.2 Collider2D选Trigger还是非Trigger

这个问题困扰过很多人。简单说:如果你希望子弹碰到敌人时产生物理反弹、推动效果,用非Trigger;如果你只想要"碰到就触发事件",用Trigger。

绝大多数射击游戏用的是Trigger,因为子弹不需要真的把敌人"撞飞",只需要检测到碰撞然后扣血、播放特效、销毁自己。用Trigger的好处是不会产生意外的物理交互——比如子弹把敌人推得满地跑,或者子弹之间互相碰撞导致弹道混乱。

但Trigger有个坑:两个Trigger之间默认不会触发碰撞事件,除非至少一方有Rigidbody2D。所以子弹必须有Rigidbody2D,敌人可以没有。这个规则我当初理解了很久才转过弯来,本质上是Unity物理引擎的设计逻辑——它需要一个"主动方"来驱动碰撞检测。

2.3 子弹的层级与碰撞矩阵

项目稍微大一点,场景里就会有玩家、敌人、子弹、道具、墙壁等各种物体。如果不设置Layer和Physics2D碰撞矩阵,就会出现子弹打到自己人、子弹撞到道具触发奇怪逻辑的问题。

我的建议是至少分出这几个Layer:Player、Enemy、PlayerBullet、EnemyBullet、Wall。然后在Project Settings > Physics 2D > Layer Collision Matrix里,取消勾选PlayerBullet和Player的碰撞、PlayerBullet和PlayerBullet的碰撞。这样子弹就不会打到玩家自己,也不会子弹互撞。

注意:碰撞矩阵的配置是全局的,改完之后所有场景都生效。如果你有特殊场景需要子弹打玩家(比如PVP模式),那就不能靠Layer来区分,得在代码里判断碰撞对象的Tag或组件。

2.4 预制体结构的组织方式

一个干净的子弹预制体应该长这样:

Bullet (GameObject) ├── SpriteRenderer // 视觉表现 ├── Rigidbody2D // 物理驱动 ├── CircleCollider2D // 碰撞检测(Trigger) ├── BulletController // 自定义脚本 └── TrailRenderer // 拖尾特效(可选) └── ParticleSystem // 命中特效(可选,或单独做预制体)

把命中特效单独做成预制体而不是挂在子弹上,是因为子弹销毁时特效需要独立存活。如果特效是子弹的子物体,子弹一销毁特效就没了,你根本看不到爆炸效果。这个坑我在第一个项目里踩得结结实实。

3. 发射逻辑的代码实现:从最朴素版本到可扩展架构

3.1 最朴素的实现与它的三个致命问题

先看一段大多数人第一次写出来的代码:

public class PlayerShoot : MonoBehaviour { public GameObject bulletPrefab; public float bulletSpeed = 10f; void Update() { if (Input.GetKeyDown(KeyCode.Space)) { GameObject bullet = Instantiate(bulletPrefab, transform.position, Quaternion.identity); bullet.GetComponent<Rigidbody2D>().velocity = transform.right * bulletSpeed; } } }

这段代码能跑,但藏着三个问题。第一,没有冷却时间,玩家按住空格每帧都会发射,子弹像喷泉一样涌出来。第二,方向写死了,transform.right意味着子弹永远朝玩家朝向的右边飞,俯视角游戏里完全不能用。第三,没有对象池,每次发射都Instantiate,每次销毁都Destroy,在移动端或者弹幕密集的场景里,GC(垃圾回收)会周期性卡顿。

我当初就是拿着这段代码去做了个弹幕游戏,结果子弹一多帧率就掉到20,排查了半天才发现是频繁的实例化和销毁导致的。后来改成对象池,帧率直接稳在60。

3.2 加入冷却机制:为什么用计时器而不是协程

冷却时间的实现方式有两种常见写法。一种是用协程Coroutine配合WaitForSeconds,另一种是用一个float计时器在Update里累加。我强烈推荐后者,原因有三:

  • 协程在对象禁用或销毁时行为不可控,容易出现"冷却还没结束但协程被中断"的情况
  • 计时器方式更容易调试,你可以在Inspector里实时看到剩余冷却时间
  • 计时器方式更容易扩展,比如加上"冷却缩减"属性时只需要改一个乘数

具体实现:

private float nextFireTime = 0f; public float fireInterval = 0.2f; void Update() { if (Input.GetKey(KeyCode.Space) && Time.time >= nextFireTime) { Fire(); nextFireTime = Time.time + fireInterval; } }

这里用Time.time而不是自己累加deltaTime,是因为Time.time是引擎维护的全局时间,不受帧率影响,也不会有累加误差。用GetKey而不是GetKeyDown是为了支持"按住连发",如果你只想要单发,换成GetKeyDown即可。

3.3 方向计算:从鼠标位置到单位向量

俯视角游戏里,子弹方向应该指向鼠标位置。计算逻辑是:鼠标世界坐标减去玩家世界坐标,得到方向向量,再归一化。

Vector3 mouseWorld = Camera.main.ScreenToWorldPoint(Input.mousePosition); Vector2 direction = (mouseWorld - transform.position).normalized;

这里有个必须注意的点:ScreenToWorldPoint返回的Vector3的z值取决于摄像机的near clip plane,如果你直接拿它做2D计算,z值会干扰结果。所以要么把z手动设为0,要么在相减后只取Vector2部分。我习惯在相减之后直接.normalized,因为Vector2的隐式转换会丢掉z值。

还有一个细节:当鼠标位置和玩家位置重合时,方向向量是零向量,归一化会返回零向量,子弹就飞不出去。虽然实际游戏中这种情况很少,但为了健壮性,可以加一个判断:

if (direction.sqrMagnitude < 0.01f) return;

用sqrMagnitude而不是magnitude是为了避免开方运算,虽然性能差异微乎其微,但这是个好习惯。

3.4 子弹脚本的职责划分

子弹脚本应该只做三件事:移动、检测碰撞、超时销毁。不要把"扣血""加分""播放音效"这些逻辑写进子弹脚本里,否则子弹就和具体的游戏规则耦合了,换个游戏就没法复用。

public class BulletController : MonoBehaviour { public float speed = 10f; public float lifeTime = 3f; private Vector2 moveDirection; public void SetDirection(Vector2 dir) { moveDirection = dir.normalized; } void Start() { Destroy(gameObject, lifeTime); } void Update() { transform.Translate(moveDirection * speed * Time.deltaTime, Space.World); } void OnTriggerEnter2D(Collider2D other) { // 只负责发出"我命中了"的信号,具体处理交给监听方 var damageable = other.GetComponent<IDamageable>(); if (damageable != null) { damageable.TakeDamage(10); } Destroy(gameObject); } }

这里用IDamageable接口而不是直接找Enemy组件,是为了让子弹不依赖具体的敌人类型。任何实现了这个接口的物体都能被子弹命中,扩展性大大提升。这个设计思路是我从一次重构中总结出来的——当时游戏里有五种敌人,子弹脚本里写了一长串if-else判断类型,后来加了第六种敌人时又得改子弹脚本,烦不胜烦。

4. 对象池的引入时机与实现:什么时候该上,什么时候不该上

4.1 判断是否需要对象池的量化标准

不是所有项目都需要对象池。如果你的游戏每秒发射不超过5发子弹,子弹存活时间不超过2秒,那同时存在的子弹最多10个,Instantiate和Destroy的开销完全可以接受。但如果你做的是弹幕游戏,每秒发射几十发,那对象池就是必须的。

一个简单的判断方法:在Profiler里看GC Alloc。如果每次发射子弹都有几百字节的GC分配,且发射频率很高,那就该上对象池了。我的一般经验是,同屏动态物体超过50个,就值得考虑对象池。

4.2 一个够用又不复杂的对象池实现

网上有很多通用对象池框架,功能强大但代码量大。对于子弹这种单一类型的对象,我推荐写一个专用的轻量池:

public class BulletPool : MonoBehaviour { public static BulletPool Instance; public GameObject bulletPrefab; private Queue<GameObject> pool = new Queue<GameObject>(); void Awake() { Instance = this; } public GameObject GetBullet() { if (pool.Count > 0) { GameObject bullet = pool.Dequeue(); bullet.SetActive(true); return bullet; } return Instantiate(bulletPrefab); } public void ReturnBullet(GameObject bullet) { bullet.SetActive(false); pool.Enqueue(bullet); } }

子弹脚本里把Destroy(gameObject)换成BulletPool.Instance.ReturnBullet(gameObject),发射时把Instantiate换成BulletPool.Instance.GetBullet()。

但这里有个坑:从池里取出的子弹,它的状态是上次销毁时的状态。比如上次子弹飞了一半被回收,它的位置、旋转、速度都还保留着。所以取出后必须重置这些状态。我通常会在子弹脚本里加一个OnEnable方法来做重置:

void OnEnable() { // 重置状态 transform.rotation = Quaternion.identity; // 重新开始生命周期计时 CancelInvoke(); Invoke(nameof(ReturnToPool), lifeTime); }

用Invoke而不是Destroy(gameObject, lifeTime),是因为对象池里的子弹不能被真正销毁。OnEnable会在每次对象被激活时调用,正好用来做重置。

4.3 对象池的容量控制与溢出处理

对象池不是越大越好。如果池里存了1000个子弹但实际最多只用50个,那950个就是纯内存浪费。我的做法是设一个上限,比如200个,超过上限的子弹在回收时直接Destroy而不是入池:

public void ReturnBullet(GameObject bullet) { if (pool.Count >= maxPoolSize) { Destroy(bullet); return; } bullet.SetActive(false); pool.Enqueue(bullet); }

这样池的容量会自然稳定在实际需要的数量附近,既不会频繁实例化,也不会无限增长。

提示:在编辑器里调试对象池时,可以给池加一个[SerializeField]的计数器,在Inspector里实时看到池中子弹数量。这个小小的调试信息帮我省了很多猜测的时间。

5. 进阶玩法:散射、后坐力与弹道偏移的工程实现

5.1 散射角度的数学原理

很多射击游戏里,子弹不是精确地朝一个方向飞,而是有一个随机偏移,这叫散射。实现方式是把方向向量旋转一个随机角度:

float spreadAngle = Random.Range(-spread, spread); Vector2 spreadDirection = Quaternion.Euler(0, 0, spreadAngle) * direction;

这里Quaternion.Euler接受的是角度值,返回一个旋转四元数,乘以方向向量就得到旋转后的方向。注意Quaternion和Vector2相乘时,Unity会自动处理类型转换,结果是一个旋转后的Vector3,需要转回Vector2。

散射角度的大小直接影响手感。太小(比如1度)几乎感觉不到,太大(比如30度)子弹就乱飞了。我的经验值是:手枪类武器2-5度,霰弹枪类15-25度,机枪类5-10度。这个数值需要反复试玩调整,没有标准答案。

5.2 多发子弹的扇形排列

霰弹枪一次发射多颗子弹,这些子弹在扇形范围内均匀分布。计算方式是:

int bulletCount = 5; float totalAngle = 30f; float startAngle = -totalAngle / 2f; float angleStep = totalAngle / (bulletCount - 1); for (int i = 0; i < bulletCount; i++) { float angle = startAngle + angleStep * i; Vector2 dir = Quaternion.Euler(0, 0, angle) * baseDirection; FireBullet(dir); }

这里有个边界情况:如果bulletCount是1,angleStep会除以0。所以要么加判断,要么保证bulletCount >= 2。我一般会在方法开头加一个bulletCount = Mathf.Max(1, bulletCount)来兜底。

5.3 后坐力与屏幕震动

后坐力是让玩家角色在射击时向后位移一小段距离,增加打击感。实现很简单:给玩家施加一个与射击方向相反的力,或者直接位移:

Vector2 recoilDir = -direction * recoilForce; transform.position += (Vector3)recoilDir * Time.deltaTime;

但直接位移会破坏物理系统,如果玩家有Rigidbody2D,应该用AddForce:

rb.AddForce(-direction * recoilForce, ForceMode2D.Impulse);

屏幕震动则是给摄像机加一个短暂的随机偏移。这个功能建议单独写一个CameraShake脚本,通过事件或直接调用来触发。不要在子弹脚本里直接操作摄像机,那样耦合太深。

5.4 弹道预测线的绘制

有些游戏会在玩家瞄准时画一条预测弹道线,帮助玩家判断子弹会飞到哪里。用LineRenderer实现:

LineRenderer line; int segments = 20; float timeStep = 0.05f; void DrawTrajectory(Vector2 startPos, Vector2 direction, float speed) { line.positionCount = segments; for (int i = 0; i < segments; i++) { float t = i * timeStep; Vector2 pos = startPos + direction * speed * t; line.SetPosition(i, pos); } }

如果子弹有重力影响,还要加上重力项:pos += 0.5f * Physics2D.gravity * t * t。这个预测线在抛物线弹道的游戏里特别有用,能让玩家直观地看到子弹会落在哪。

6. 常见Bug的排查链路与修复方案

6.1 子弹不飞:从组件到代码的逐层排查

子弹发射后不动,这是最常见的问题。排查顺序应该是:

  1. 检查Rigidbody2D的Body Type——如果是Static,子弹不会移动。改成Dynamic或Kinematic。
  2. 检查速度赋值时机——如果在Instantiate之后立刻赋值velocity,但子弹的Rigidbody2D还没初始化,赋值可能无效。解决办法是在子弹脚本的Start里赋值,或者用SetDirection方法在实例化后调用。
  3. 检查Gravity Scale——如果重力太大,子弹可能刚飞出去就被重力拉下来了。
  4. 检查代码是否执行——加个Debug.Log确认发射方法被调用了。

我遇到过一次特别隐蔽的情况:子弹的Rigidbody2D的Simulated属性被意外勾掉了,导致物理系统完全忽略这个物体。这个属性在Inspector里不太显眼,排查了很久才发现。

6.2 子弹穿透敌人:连续碰撞检测与射线补偿

高速子弹穿透敌人,根本原因是离散碰撞检测的采样频率不够。假设子弹速度是50单位/秒,帧率60fps,那一帧子弹移动0.83单位。如果敌人的碰撞体厚度小于0.83单位,就可能被跳过。

解决方案有两个。方案一是把子弹的Collision Detection改成Continuous,让物理引擎在两次物理更新之间做插值检测。方案二是在子弹的Update里用Physics2D.Raycast手动检测上一帧位置到当前位置之间有没有敌人:

Vector2 currentPos = transform.position; RaycastHit2D hit = Physics2D.Raycast(lastPos, moveDirection, Vector2.Distance(lastPos, currentPos), enemyLayer); if (hit.collider != null) { // 处理命中 } lastPos = currentPos;

方案二更可控,但代码量大。我的建议是先用方案一,如果还有穿透再加方案二。

6.3 对象池子弹的"幽灵碰撞"

从对象池取出的子弹,如果上一轮的生命周期还没结束就被回收了,可能会出现"幽灵碰撞"——子弹明明已经回收了,但它的碰撞事件还在触发。原因是Invoke或协程没有在回收时取消。

解决办法是在子弹回收时取消所有待执行的Invoke和协程:

void OnDisable() { CancelInvoke(); StopAllCoroutines(); }

OnDisable在对象被禁用时调用,正好用来做清理。这个坑我在对象池刚上线时踩过,表现为敌人莫名其妙掉血,排查了半天才发现是回收的子弹还在触发碰撞。

6.4 冷却时间在暂停时继续计时

如果游戏有暂停功能,用Time.time做冷却计时会出问题——暂停时Time.time还在走,恢复后冷却已经结束了。解决办法是改用Time.unscaledTime或者自己维护一个只在游戏运行时累加的计时器。

private float cooldownTimer = 0f; void Update() { if (cooldownTimer > 0) cooldownTimer -= Time.deltaTime; if (Input.GetKey(KeyCode.Space) && cooldownTimer <= 0) { Fire(); cooldownTimer = fireInterval; } }

用Time.deltaTime累减的方式,暂停时deltaTime为0,计时器自然就停了。这是最省心的方案。

7. 性能优化与移动端适配的实战经验

7.1 子弹数量对帧率的影响曲线

我在一个中端移动设备上做过测试,同屏子弹数量从10增加到200,帧率的变化大致是这样的:

同屏子弹数帧率(无对象池)帧率(有对象池)
106060
505560
1004258
2002552

可以看到,对象池在子弹数量多的时候效果非常明显。但即使有对象池,200个子弹时帧率还是有所下降,因为每个子弹的Update、碰撞检测、渲染都是开销。这时候就需要更激进的优化。

7.2 用Job System或ECS处理海量子弹

如果子弹数量真的到了几百上千,传统的MonoBehaviour方案就力不从心了。这时候可以考虑用DOTS(面向数据的技术栈)或者Job System来批量处理子弹的移动和碰撞。但这套技术的学习曲线很陡,不建议在项目初期就上。我的建议是先用传统方案把游戏做出来,确认玩法有趣之后,再针对性能瓶颈做优化。

一个折中的方案是合并子弹的Update调用。写一个BulletManager,统一管理所有子弹的移动,而不是每个子弹自己Update。这样能减少MonoBehaviour的调用开销:

public class BulletManager : MonoBehaviour { private List<BulletController> activeBullets = new List<BulletController>(); void Update() { for (int i = activeBullets.Count - 1; i >= 0; i--) { activeBullets[i].Tick(Time.deltaTime); } } }

子弹脚本里去掉Update,改成公开的Tick方法。这个改动不大,但在子弹多的时候能省下不少开销。

7.3 移动端的触控发射方案

移动端没有键盘鼠标,发射逻辑要改成触控。常见方案有两种:虚拟摇杆加自动射击,或者点击屏幕朝点击方向射击。

虚拟摇杆方案需要一个摇杆UI,摇杆的方向既控制移动也控制射击方向,配合自动射击(只要摇杆有输入就持续发射)。这种方案适合双摇杆射击游戏。

点击方案更简单:玩家点击屏幕某个位置,子弹朝那个位置飞。实现上和鼠标方案一样,只是把Input.mousePosition换成Input.GetTouch(0).position。

if (Input.touchCount > 0) { Touch touch = Input.GetTouch(0); if (touch.phase == TouchPhase.Began) { Vector3 worldPos = Camera.main.ScreenToWorldPoint(touch.position); Vector2 dir = (worldPos - transform.position).normalized; Fire(dir); } }

移动端还要注意触摸区域和UI的冲突。如果玩家点击的是UI按钮,不应该触发射击。解决办法是用EventSystem.current.IsPointerOverGameObject()判断当前触摸是否在UI上。

7.4 子弹的渲染优化

每个子弹都是一个SpriteRenderer,200个子弹就是200个Draw Call。如果子弹用的是同一张图集,Unity的合批机制会把它们合并成少数几个Draw Call。但如果子弹有不同的材质或Shader,合批就会失效。

优化建议:所有子弹共用同一个材质,通过MaterialPropertyBlock来设置每颗子弹的颜色等属性,而不是给每颗子弹创建独立的材质实例。这个技巧在需要子弹有不同颜色时特别有用。

注意:MaterialPropertyBlock虽然能避免材质实例化,但它会打断SRP Batcher的合批。在URP管线里,如果子弹数量不是特别多,直接用材质实例反而可能更快。具体用哪种方案,建议在Profiler里实测对比。

8. 写在最后:几个我反复踩过才记住的细节

关于子弹系统的实现,代码层面的东西上面基本讲完了。最后分享几个我在实际项目中反复踩过、后来才形成肌肉记忆的细节。

第一个是子弹的初始位置。不要直接用玩家的transform.position,因为玩家通常有一个较大的碰撞体,子弹从中心生成会在飞出玩家碰撞体之前就触发碰撞。正确的做法是从玩家朝向方向的边缘生成,或者给子弹加一个短暂的"无敌时间",在飞出玩家碰撞体之前忽略碰撞。我一般用后者,在子弹脚本里加一个spawnTime,0.05秒内不检测碰撞。

第二个是子弹的旋转。2D游戏里子弹的Sprite通常有一个朝向,如果子弹飞行的方向和Sprite的朝向不一致,看起来会很别扭。解决办法是在设置方向的同时设置旋转:

float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg; transform.rotation = Quaternion.Euler(0, 0, angle);

Atan2返回的是弧度,乘以Rad2Deg转成角度。这个公式我写过无数次,但每次都要查一下参数顺序——是Atan2(y, x)不是Atan2(x, y),搞反了子弹就会朝错误的方向旋转。

第三个是音效的播放时机。射击音效应该在发射的瞬间播放,而不是在子弹生成后播放。如果音效挂在子弹上,子弹被对象池回收时音效会被打断。正确做法是把音效播放放在发射逻辑里,用一个独立的AudioSource来播放。

这些细节单独看都很小,但组合起来就决定了你的射击手感是"能用"还是"舒服"。游戏开发里,手感往往就是这些不起眼的地方堆出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询