这次我们来看一个非常具体的问题:在 Unity 里做一颗炮弹,点一下鼠标把它打出去。很多开发者会在搜索里带上“蓝图”,这里必须先纠正一个概念——Unity 没有 Unreal Engine 那种“蓝图(Blueprint)”可视化脚本系统。如果你从 UE5 切过来,你要找的对应技术是两种:C# 脚本,或者 Unity Visual Scripting(原 Bolt,Unity 自带插件)。这篇文章不会只给你一段能跑的代码,而是把“炮弹发射”这件事从预制体、物理力学、瞄准、射速、批量发射到内存优化完整拆一遍,并且附带可视化脚本“类蓝图”版本。适合正在学 Unity 物理系统的初级开发,也适合从 UE5 蓝图转 Unity 的动作游戏开发。
先说结论,这套方案的落地核心就一句话:给炮弹挂上 Rigidbody,在发射点 Instantiate 一个预制体,然后用速度或冲量把它推出去。后面所有瞄准、射速、爆炸、对象池,都围绕这句话展开。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 项目类型 | Unity 3D 炮弹发射功能模块,单机/原型项目可直接套 |
| 术语对应 | UE 蓝图对应 Unity C# 脚本或 Unity Visual Scripting(原 Bolt) |
| 主要功能 | 炮弹发射、方向瞄准、射速限制、批量散射、命中爆炸、自动清理、对象池复用 |
| 依赖组件 | Rigidbody、Collider、Transform、Physics 物理系统 |
| 最低环境 | Unity 2021 LTS 或更高版本,3D 模板工程即可 |
| 是否支持可视化脚本 | 支持,安装 Package Manager 里的 Visual Scripting 插件 |
| 是否适合多人游戏 | 不适合直接做网络同步,需要自行补 RPC 和位置预测 |
| 适合人群 | Unity 初级开发者、UE5 蓝图转 Unity 的开发者、射击玩法原型开发 |
| 需要的外部资源 | 炮弹模型、爆炸粒子、音效可选,可以先用自带几何体 |
这个模块本身不绑定任何付费资源,也不要求专用显卡或特殊硬件。Unity 编辑器自带的基础几何体(Sphere、Capsule、Cube)就能完成整个流程测试。
2. 适用场景与使用边界
炮弹发射模块适合这些场景:
- 单机射击玩法原型,验证子弹手感、弹道、命中反馈。
- 塔防项目里炮塔发射炮弹,配合射线检测判断命中。
- 教学演示,讲清楚 UnityEngine.Physics 和 Rigidbody 的工作流程。
- 从 UE5 转到 Unity 后,用同一个“蓝图式”思维快速落地功能。
不适合直接承担这些职责:
- 高并发网络射击游戏的核心战斗逻辑,网络同步、延迟补偿、命中验证需要另做系统性设计。
- 需要精确弹道预测的超远距离射击,炮弹刚体受重力影响后会下坠,需要额外做弹道模型。
- 对物理性能要求极高的移动端大场景,炮弹数量过大时必须走对象池,否则 GC 卡顿会非常明显。
还有一条必须单独说:如果你在自己的项目里放了别人的模型、贴图、音效、特效素材,务必确认授权范围。尤其是公开发布的项目,不要直接拿商业资源包里的美术素材打包分发。这不是技术问题,是合规问题,但很多新手在这里翻车。
3. 环境准备与 Unity 项目搭建
3.1 安装 Unity Editor
先去 Unity 官网下载 Unity Hub。安装完成后,在 Unity Hub 的 Installs 页面装一个 LTS 版本,例如 2021.3、2022.3 或者最新的 Unity 6。这里不要求某个固定版本,只要不是太老的 2019/2020,物理 API 差异不大。
安装时确保勾选以下模块:
- 对应平台的 Build Support,比如 Windows 或 Android。
- 如果本机没有 Visual Studio,可以勾选 Visual Studio 组件,也可以后来自行安装 VS Code 或 Rider。
3.2 创建 3D 项目
打开 Unity Hub,选择 New Project,模板选择 3D Core。给项目起一个名字,比如CannonDemo。项目创建后,默认场景里会有一个相机和一个平行光,这两个就够做测试了。
3.3 物理系统检查
在菜单栏打开Edit -> Project Settings -> Physics,确认默认重力是(0, -9.8, 0)。这个重力值决定炮弹开火后会不会下坠。如果不想让炮弹飞抛物线,可以新建一个不带重力的物理材质,或者在炮弹 Rigidbody 上把Use Gravity关闭。
调试阶段建议把 Scene 视图右侧的 Gizmos 打开,后面用Debug.DrawRay画射线时能直观看到方向。
4. 场景搭建与炮弹预制体设计
4.1 搭建炮管
在场景中创建一个空物体,命名为Cannon。在它下面创建一个 Cylinder 作为炮管,旋转(90, 0, 0),让圆柱的轴线朝向世界坐标 Z 轴正方向。再把 Cylinder 的 Y 轴缩放调大一点,做成细长炮管形状。
在炮管口创建一个空物体,命名为FirePoint,把它拖到炮管最前端。这个空物体就是后面炮弹的出生点。把场景里的Cannon物体选中,给它添加一个 C# 脚本CannonShooter,后面我们把发射逻辑写在这个脚本里。
4.2 创建弹丸预制体
在 Assets 目录下新建Prefabs文件夹。在场景中创建一个 Sphere,把它拖拽到Prefabs文件夹,生成预制体,命名为Shell。然后对Shell预制体做以下设置:
- 添加 Rigidbody 组件。
- 调整 Collider 大小,让碰撞体包裹住球体。
- 如果需要直线飞行,关闭
Use Gravity;如果需要抛射弹道,保持开启。 - 收起场景里的原始球体,或者直接删除,只保留预制体用于实例化。
弹丸本身最好创建单独的材质球,改成显眼的红色或黄色。这样发射多颗炮弹时,视觉上容易分清每颗炮弹的轨迹。
4.3 初始化发射脚本参数
在Cannon物体上找到CannonShooter脚本组件,把场景中的FirePoint拖到firePoint字段,把预制体Shell拖到shellPrefab字段。发射速度和射速可以先保持默认值。
5. C# 炮弹发射脚本核心实现
这一段是最核心的,直接复制可以用。
using UnityEngine; public class CannonShooter : MonoBehaviour { [Header("引用")] public Transform firePoint; public GameObject shellPrefab; [Header("发射参数")] public float launchSpeed = 30f; public float fireInterval = 0.5f; private float lastFireTime = -999f; void Update() { if (Input.GetButtonDown("Fire1") && Time.time >= lastFireTime + fireInterval) { Fire(); lastFireTime = Time.time; } } void Fire() { if (firePoint == null || shellPrefab == null) { Debug.LogWarning("CannonShooter: firePoint 或 shellPrefab 未赋值"); return; } GameObject shell = Instantiate(shellPrefab, firePoint.position, firePoint.rotation); Rigidbody rb = shell.GetComponent<Rigidbody>(); if (rb != null) { // Unity 6 新项目推荐 linearVelocity,旧版本用 velocity 一样 rb.linearVelocity = firePoint.forward * launchSpeed; } } }firePoint.forward是炮管当前朝向的单位向量。把它乘上速度值,就得到一个方向和大小都正确的速度向量。这里选择直接赋值linearVelocity,比AddForce更可控。
注意Rigidbody.velocity在 Unity 6 中会提示使用linearVelocity,两者物理效果一致,但新项目建议直接用新 API。
5.1 用 AddForce 实现“真实炮的后坐力感”
有些场景希望炮弹刚发射时受到一个瞬间冲击力,同时受重力影响,这时候可以用ForceMode.Impulse:
rb.AddForce(firePoint.forward * launchSpeed, ForceMode.Impulse);这个方式和直接赋值 velocity 有区别。ForceMode.Impulse会按照公式“力 × 时间 = 质量 × 速度变化”来换算,因此炮弹质量越大,实际速度越慢。如果你希望炮弹速度快且不受质量影响,还是直接赋值linearVelocity更稳定。
两种方式的选择原则:
| 方式 | 行为特点 | 适合场景 |
|---|---|---|
| rb.linearVelocity = direction * speed | 无视质量,精确控制初速度 | 射击感优先,弹道可预期 |
| rb.AddForce(direction * forceValue, ForceMode.Impulse) | 质量影响最终速度,有物理推动感 | 需要和后坐力、爆炸冲击关联时 |
| rb.AddForce(direction * forceValue, ForceMode.Force) | 持续施力,非瞬时 | 追求持续加速效果,不常用在炮弹上 |
5.2 避免炮弹出生位置错误
一个常见错误是把Instantiate的参数写成Vector3.zero,这样炮弹会从世界原点生成。必须使用firePoint.position和firePoint.rotation。另外,Instantiate的最后一个参数是旋转,如果用Quaternion.identity,炮弹会保持预制体原始旋转,不会跟随炮管朝向。
如果炮管是一个复杂的层级结构,firePoint应该放在炮口末端。发射瞬间,炮弹的初始位置和旋转都用在参数里赋值,不要在生成后再手动转一次。
6. 用 Unity Visual Scripting 复刻“蓝图”流程
如果你就是冲着“蓝图”这两个字来的,这部分要仔细看。Unity 官方提供的可视化脚本系统叫 Visual Scripting,最早叫 Bolt,Unity 2021 之后集成在 Package Manager 里。
6.1 安装 Visual Scripting
打开Window -> Package Manager,左上角包来源选择 “Unity Registry”,在搜索框里输入Visual Scripting,点击 Install。安装完成后,在 Project Settings 里会多出 Visual Scripting 配置项。
6.2 创建可复用的 Script Machine
在Cannon物体上添加组件Script Machine。组件类型选择Macro,新建一个 Macro 资产保存到 Assets 目录。双击打开后,进入节点图编辑界面。这里的逻辑和 UE5 蓝图是非常相似的:左边是事件节点,中间连数据流,右边是成员调用节点。
下面用文字描述一套完整节点连接流程,你也可以在搜索框里直接输入节点名查找:
On Button Down (Fire1) | Einheit 1: Instantiate Object | 参数: shellPrefab, firePoint.position, firePoint.rotation | Einheit 2: Get Component (Rigidbody) | 输入: Instantiate 返回的 GameObject | Einheit 3: Set Rigidbody.linearVelocity | 输入: firePoint.forward * launchSpeed | Einheit 4: On Collision Enter (Shell) | Einheit 5: Destroy事件节点选择Input Events -> On Button Down。节点搜索时输入Instantiate就能找到 Object Instantiate,输入Get Component找到组件获取节点,输入linearVelocity可以找到刚体速度赋值节点。如果搜索不到,可以直接在节点图上创建Get Rigidbody然后展开组件成员列表查找linearVelocity。
这就是 Unity 里的类蓝图方案。但说实话,炮弹发射这种逻辑,节点连起来比代码多而且不容易调试。我的建议是:学习原理可以看看可视化脚本,实际项目强烈推荐 C#。可视化脚本适合完全不写代码的策划做交互原型,不适合维护复杂战斗逻辑。
7. 瞄准、射速与批量发射控制
7.1 让炮管跟随鼠标方向瞄准
现在炮弹能打直线了,但要打中目标,必须让炮管朝向目标点。最简单的方法是:鼠标点击屏幕上的位置,发射一条从相机出发的射线,碰撞到地面或目标后,让炮管转向那个命中点。
void Update() { AimAtMouse(); // 原来的开火逻辑 } void AimAtMouse() { Camera cam = Camera.main; Ray ray = cam.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 500f)) { Vector3 target = hit.point; Vector3 direction = target - firePoint.position; if (direction.sqrMagnitude > 0.001f) { transform.rotation = Quaternion.LookRotation(direction); } Debug.DrawRay(firePoint.position, direction, Color.red); } }代码里的Physics.Raycast是 Unity 物理系统的核心接口,理解这行就能理解绝大多数射击游戏里“鼠标朝向哪里就打哪里”的实现方式。Debug.DrawRay在 Scene 视图里画红色射线,方便你确认瞄准方向。
7.2 设置射速,限制开火频率
射速限制有两种写法。Update 里用Time.time判断时间差最简单,适合单发;如果要做连续开火的发射循环,用协程更直观:
IEnumerator AutoFireLoop() { while (true) { Fire(); yield return new WaitForSeconds(fireInterval); } } void StartAutoFire() { StartCoroutine(AutoFireLoop()); }协程版本适合按住鼠标连续发射,配合 Input.GetButton(“Fire1”) 判断。要停止时用StopAllCoroutines()。
7.3 批量发射与散射
导弹发射系统经常要做“一次打一片”的效果。给炮弹方向加随机角度偏移,就能实现散射。
void BurstFire(int count, float spreadAngle) { for (int i = 0; i < count; i++) { Vector3 direction = firePoint.forward; float randomAngle = Random.Range(-spreadAngle, spreadAngle); direction = Quaternion.Euler(0f, randomAngle, 0f) * direction; GameObject shell = Instantiate(shellPrefab, firePoint.position, firePoint.rotation); shell.GetComponent<Rigidbody>().linearVelocity = direction * launchSpeed; } }注意Quaternion.Euler(0, randomAngle, 0) * direction表示在水平方向绕 Y 轴旋转随机角度。如果要做垂直散射,把旋转改成Quaternion.Euler(randomAngle, 0, 0)。这个操作本质是把向量绕某个轴旋转,属于向量旋转的标准用法。
批量发射最容易踩的坑是:同一帧创建太多炮弹,导致物理引擎计算压力瞬间升高。炮弹数量超过 20 颗时,建议做对象池。
8. 命中检测、爆炸效果与对象池
8.1 炮弹碰撞时触发爆炸并销毁
给Shell预制体挂上脚本Shell.cs,在OnCollisionEnter中处理命中。这是 Unity 物理系统最常用的生命周期回调。
using UnityEngine; public class Shell : MonoBehaviour { public GameObject hitEffect; public float lifeTime = 5f; void OnEnable() { CancelInvoke(); Invoke(nameof(Expire), lifeTime); } void OnCollisionEnter(Collision collision) { Explode(); } void Explode() { if (hitEffect != null) { Instantiate(hitEffect, transform.position, Quaternion.identity); } Destroy(gameObject); } void Expire() { Destroy(gameObject); } }炮弹飞行时间超过lifeTime后会被强制销毁,避免炮弹飞出地图后不清理。命中墙、地面、敌人时调用Explode,生成爆炸特效并销毁炮弹本体。
8.2 爆炸粒子特效的内存泄漏处理
这里要重点提醒:很多项目在爆炸粒子这里出现惊人的内存泄漏。原因是每次爆炸都生成一个 Particle System,但 Particle System 播放完之后不会自动销毁,即使脚本销毁了炮弹,特效物体仍然挂在场景里。长期运行后,场景里堆了几十个不可见的特效物体。
处理办法是给每个 Particle System 的 Main 模块设置 Stop Action。把 Stop Action 从 None 改成Disable或Destroy。如果使用Destroy,粒子播放结束后系统自动销毁游戏对象,不需要额外写清理代码。如果还需要对象池,自己负责回收,就选Disable,再把特效物体扔回池里。
8.3 用对象池减少临时生成开销
发射 100 发炮弹,就 Instantiate 100 次;炮弹销毁时再次触发 GC。频繁创建和销毁是射击游戏卡顿的最大来源之一。对象池的意思是:提前生成固定数量的炮弹,命中后不销毁,而是隐藏回收,下次发射时重新取出使用。
using System.Collections.Generic; using UnityEngine; public class ShellPool : MonoBehaviour { public static ShellPool Instance; public GameObject shellPrefab; public int poolSize = 30; private Queue<GameObject> pool = new Queue<GameObject>(); void Awake() { Instance = this; for (int i = 0; i < poolSize; i++) { GameObject shell = Instantiate(shellPrefab); shell.SetActive(false); pool.Enqueue(shell); } } public GameObject GetShell(Vector3 position, Quaternion rotation) { GameObject shell = pool.Count > 0 ? pool.Dequeue() : Instantiate(shellPrefab); shell.transform.SetPositionAndRotation(position, rotation); shell.SetActive(true); return shell; } public void ReturnShell(GameObject shell) { shell.SetActive(false); pool.Enqueue(shell); } }配合Shell.cs,在Explode方法里把Destroy(gameObject)改为ShellPool.Instance.ReturnShell(gameObject)。这样炮弹不会销毁,而是回收。注意对象池的shellPrefab必须是带 Rigidbody 的预制体,并且每次取出后要重置位置、速度、状态,避免上一次的残留速度叠加到这次发射上。
9. 资源占用与性能观察
炮弹系统的性能瓶颈主要看三个地方:
第一,Instantiate和Destroy的调用频率。每次生成和销毁都会分配和释放内存,高频触发会产生 GC Alloc,导致帧率尖刺。
第二,Rigidbody 数量。Unity 物理引擎每帧都要处理所有活动 Rigidbody 的碰撞检测。场景里同时存在 50 颗炮弹、30 个特效、20 个碎片,物理开销会明显上升。炮弹数量多时开启 Rigidbody 的 Interpolate 选项可以用,但不要开太多。
第三,粒子特效数量。每个 Particle System 独立计算粒子更新。爆炸特效建议一次爆炸只允许一个特效物体存在,叠加型爆炸特效很容易把移动端 GPU 打满。
性能观察方式:
- 打开
Window -> Analysis -> Profiler,选择 CPU 模块,看Physics.Simulate和Instantiate的耗时。 - 打开
Window -> Analysis -> Physics Debugger,查看碰撞体数量和激活 Rigidbody 数量。 - 在 Game 视图右上角点击 Stats,可以快速看到 Batches、Tris 和 Verts 的变化。
如果发现固定帧率骤降,优先怀疑两个原因:同一帧里炮弹数量过大,或者粒子特效没有及时销毁。解决办法依次是:限制开火频率、增加对象池、粒子特效复用。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决建议 |
|---|---|---|---|
| 炮弹生成后完全不动 | 预制体没有 Rigidbody;或未给 Rigidbody 赋值速度 | 检查 Shell 预制体组件列表 | 添加 Rigidbody;确认rb.linearVelocity被赋值 |
| 炮弹飞出去但方向不对 | firePoint.forward指向非期望方向;炮管层级旋转异常 | 在 Fire 方法里加Debug.DrawRay(firePoint.position, firePoint.forward * 5f) | 调整炮管旋转,或使用transform.forward代替 |
| 点击鼠标没有开火 | 输入事件被 UI 阻挡;firePoint 未赋值 | 在 Fire 里输出 Debug.Log | 检查 Input.GetButtonDown 和 EventSystem 是否拦截 |
| 连续发射间隔无效 | 时间判断写错,或协程循环未停止 | 打印 Time.time 和 lastFireTime | 统一用Time.time >= lastFireTime + fireInterval判断 |
| 炮弹飞行时穿过墙壁 | Rigidbody 移动速度太快,碰撞体一帧跨过碰撞面 | 观察 Velocity 数值 | 增大 Collider 厚度,开启 Continuous 碰撞检测模式 |
| 特效物体不清空导致卡顿 | Particle System 的 Stop Action 为 None | 打开粒子主模块查看 | 将 Stop Action 改为 Destroy 或 Disable,使用对象池回收 |
| Unity 6 中 velocity 提示过时 | API 名称变更 | 查看代码警告 | 新写法改用linearVelocity,旧版本保持velocity |
| 场景里炮弹数量过多 | 高频发射且无销毁/回收 | 在 Hierarchy 面板搜 Shell | 加对象池,限制发射频率 |
还有一种容易被忽略的情况:如果炮弹同时有多个 Collider,会触发多次OnCollisionEnter。解决方式是在预制体上保留一个 Collider,不要为了“看起来碰撞更大”叠多个碰撞体。
11. 最佳实践与合规建议
- 第一步先做最小验证:用 Sphere 预制体 + 一个 Cube 目标,确认炮弹能飞、能命中,再往里面加特效和音效。不要一上来把枪口火光、烟雾、震动全堆进去,出了问题很难判断是哪一步错了。
- 炮弹预制体单独放
Assets/Prefabs/Shell/目录,爆炸特效放Assets/Prefabs/Effects/,脚本按功能分文件夹。模型、贴图、材质不要在同一个平级目录里堆几十个文件。 - 炮弹和特效尽量全部走对象池。简单项目可以先用 Destroy,但一旦进入批量发射阶段,必须改造为回收机制。
- 物理碰撞面要分 Layer。炮弹 Layer 设为
Projectile,墙壁和玩家设为World。在 Project Settings 的 Physics 碰撞矩阵里,把 Projectile 和 World 设置为碰撞,Projectile 和同层 Projectile 尽量不碰撞,否则多颗炮弹互相弹开,行为会变得很怪。 - 多人项目中,炮弹发射是客户端表现还是服务端判定,必须提前想清楚。最安全的方式是:客户端只发请求,服务端跑模拟、做命中判定,再广播结果。不要直接在客户端
Instantiate一颗炮弹就觉得多人同步完成。 - 使用任何外部美术资源、音效、字体、代码包之前,确认许可协议是否允许商用。尤其是从免费资源网站下载的资源,有些只允许个人学习,不能打包进商业游戏发布。
整套流程跑通后,它就是一枚“普通但不失控”的炮弹。你需要单独做的扩展还有很多:伤害数值、击退效果、爆炸范围衰减、后坐力反馈、音效播放、命中布娃娃、弹道轨迹拖尾。
12. 总结与下一步
“Unity 蓝图炮弹发射”这个搜索词,其实是一个需求,不是一个现成功能。真正的实现路径我已经在上面给出了:如果你把“蓝图”理解为 UE5 的可视化脚本,那么 Unity 对应的就是 Visual Scripting;但任何需要长期维护的战斗功能,最终的归宿都是 C# 脚本和 Rigidbody 物理系统。
这个方案最值得你先跑通的一个验证点是:只用一个 Sphere 预制体、一段发射脚本,让它撞击到一个 Cube 上并触发打印日志。这个测试通过,后面加爆炸粒子、敌人伤害、音效都在同一个物理回调模型上扩展。
最容易踩的坑有两个:一个是忘记给炮弹挂 Rigidbody,导致它不会动;另一个是发射频率太高,产生大量瞬时对象,导致帧率断崖波动。这两个问题分别记住:没有刚体就没有物理行为,没有对象池就没有稳定帧率。
下一步建议按这个顺序继续做:先改对象池,再给炮弹加爆炸粒子,然后外加瞄准射线,最后做一套简单的伤害系统。把这四条链起来,你就等于完成了 90% 的射击玩法原型代码。