简介:Unity作为跨平台游戏开发的主流引擎,为3D游戏开发提供了从场景搭建、角色控制到渲染优化的一体化解决方案。在移动端游戏项目中,开发者需掌握角色控制器、物理碰撞、虚拟摇杆交互以及URP渲染管线等核心技术,同时通过光照烘焙、对象池和纹理压缩等手段保障手机端的流畅体验。这种工程化开发思路不仅能支撑完整的游戏玩法闭环,还能锻炼需求分析、模块解耦与性能调优的综合能力,在计算机专业的毕业设计中极具实践价值。从闯关玩法设计到Android打包发布,一个面向移动端的3D闯关手游项目,能够系统性检验独立开发者的全栈工程素养。本文以Unity 2021.3 LTS为技术栈,完整剖析了一款3D闯关类手机游戏从立项规划、架构设计、核心功能实现到移动端适配优化与答辩交付的全过程,为同类毕业设计选题提供可直接复用的实践参考。 作为计算机专业的学生,每年到了毕业季,总有一批人盯着“游戏开发”方向的题目发愁——选个什么题目既能让答辩老师觉得有技术含量,又能在有限的时间里真正做完?站在我个人的毕业设计经历上,如果让我再选一次,我仍然会毫不犹豫地推荐“基于Unity的3D闯关类手机游戏”这个方向。它不是最惊艳的选题,但绝对是最稳、最能完整展现工程能力、也最容易在答辩现场演示出效果的选择。这篇文章我会把整个项目从选题、玩法规划、核心功能拆解、移动端适配优化到打包答辩的全部过程展开来讲,既有原理层面的说明,也有可直接复用的代码和配置,希望能帮到正在做同类题目的同学,也是对我当年毕设过程的一次完整复盘。
1. 选题与立项:为什么“Unity 3D闯关手游”是一个适合毕设的题目
1.1 从课程作业到毕业设计的差距在哪里
很多同学在大二大三都写过Unity小游戏,比如一个“打砖块”、一个“跑酷”,两三周就能交差。但毕业设计和课程作业有一个本质区别:毕业设计要证明你具备独立完成一个相对完整系统的能力。一个“能跑起来”的Demo远远不够,你需要有需求分析、架构设计、核心模块实现、测试验证、性能优化、打包部署这一整条链路。
“3D闯关类手机游戏”这个题目恰好能覆盖这条链路上的所有环节:
- 玩法层:闯关类游戏天然包含关卡设计、玩家角色、敌人/机关、胜利失败条件、进度存档等完整游戏逻辑;
- 技术层:3D场景管理、角色控制、相机跟随、碰撞检测、UI系统、资源加载、移动端触屏适配、性能优化;
- 工程层:数据驱动的关卡配置、模块解耦、代码组织、版本管理、打包构建。
这些正好是一个合格毕业设计应该体现的东西,而且每一项都能在论文里找到对应的章节去写,完全不用担心“论文写不出来”的问题。
1.2 目标平台与技术栈的确定
确定题目后,第一件事不是打开Unity开始拖场景,而是先把技术选型定下来。
引擎版本:我当时选的是Unity 2021.3 LTS。LTS(Long Term Support)版本意味着稳定、社区资料多、遇到问题容易搜到解决方案。不要为了尝鲜用最新版本,毕设周期内引擎大版本更新带来的兼容性问题不值得浪费时间去踩。
渲染管线:3D手机游戏我建议直接用Universal Render Pipeline(URP)。URP在手机端的性能表现远好于内置渲染管线,而且是Unity官方持续维护的方向。设置路径是Project Settings -> Graphics -> Scriptable Render Pipeline Settings,创建URP资源后指定即可。这个选择会在后面做光照烘焙和性能优化时省下大量时间。
目标平台:以Android为主。原因很简单——打包方便、真机测试门槛低、答辩时只要有一台Android手机就能现场演示。如果时间充裕,iOS上跑通一次作为加分项,但不要作为必选项。
输入系统:Unity现在推的Input System Package新输入系统确实强大,但毕业设计项目我反而建议用旧版Input Manager。原因有两个:一是网上绝大多数教程和资料基于旧版输入系统,遇到问题容易查;二是新输入系统的学习成本对毕设来说不是必需的。我这里不是说新输入系统不好,而是从项目风险控制的角度,旧版对中小型项目完全够用。
1.3 功能需求清单:写进任务书的每一件事都要能落地
确定选题和平台后,需要把功能需求写成清单。这个清单不仅是给导师看的任务书,更是你自己后续开发的“验收标准”。我在毕设里把功能拆成了五个模块:
| 功能模块 | 具体内容 | 优先级 | 验收标准 |
|---|---|---|---|
| 玩家控制 | 虚拟摇杆操控移动、跳跃、冲刺 | P0 | 触屏操作灵敏,无漂移,角色不穿模 |
| 场景与关卡 | 不少于5个关卡,含平台、障碍、机关 | P0 | 每个关卡有独立配置,可通关 |
| 敌人与战斗 | 巡逻敌人、远程攻击型敌人、Boss | P1 | 玩家可击败敌人,受击有反馈 |
| UI与进度 | 主菜单、暂停、结算、关卡解锁、本地存档 | P0 | 关卡进度退出后保留 |
| 音效与特效 | 操作反馈、BGM、粒子特效 | P2 | 不影响性能,画面美观 |
这里要特别提醒:P0项在开发阶段必须做完做稳,P1项做不出来可以从需求上降级,P2项实在没时间可以砍掉。毕设不是商业项目,不需要所有功能都做到完美,但核心体验必须完整。我当时就把一个“双段跳”的P1功能从需求里删掉了,答辩时完全没人追问——但如果你连主菜单都做得不顺畅,答辩会很被动。
1.4 项目风险评估:工期和难点的提前预判
很多同学毕设翻车,不是因为不努力,而是因为对难度和工期预估不足。我列一下这类项目最常见的风险点:
风险一:3D美术资源缺乏。自己建模不现实,网上下载的质量参差不齐。解决办法是统一使用Unity Asset Store的资源,选择同一套风格的素材包,比如免费的”Unity Particle Pack”、”Polygon Starter Pack”等。不要混用5套不同风格的免费素材,否则画面会非常杂乱,答辩观感差。
风险二:手机端性能问题。开发时在PC上跑得很流畅,打包到手机上一卡一卡的。解决办法是从第一天就按照移动端的性能标准来做,具体方法我在第4章详细展开。
风险三:导师要求“需要有创新点”。这个要提前做好话术准备。闯关游戏本身不算创新,但你可以在某个具体机制上加入自己的想法,比如“按关卡主题动态改变重力方向”、“可拖拽方块改变关卡地形”等。哪怕实现得粗糙,只要能在答辩时说清楚设计思路和实现方案,就能满足“创新”要求。
2. 玩法规划与工程架构:动手编码前的两件隐形工作
2.1 核心玩法循环与关卡节奏设计
闯关类游戏的核心玩法循环很明确:观察环境 -> 做出操作 -> 获得反馈 -> 到达终点。听起来简单,但要在3D场景里让这个循环有趣,关卡节奏设计才是关键。
我在做关卡规划时,把每个关卡拆成了三段式节奏:
- 引子段(0~30%):地形简单,给玩家建立操作信心。前两个平台间距宽、无敌人,让玩家完成“跳跃”、“收集”这类基础动作。
- 展开段(30%~70%):引入障碍和敌人。可能是移动平台、发射火焰的机关、巡逻的敌人。玩家需要将跳跃、躲避、攻击组合起来使用。
- 高潮段(70%~100%):综合挑战。多个系统同时作用,比如“在移动平台上躲避远程攻击,同时收集三枚钥匙才能开启终点门”。
这个节奏设计其实不用做得很复杂,我用一张Excel表,每个关卡一行,列出平台数量、机关类型、敌人类型、预计通关时间,就把5个关卡全部规划完了。完全不写代码,纯数据规划,这比直接在Unity里边搭边想要高效得多。
2.2 场景管理与模块划分原则
Unity项目的场景结构设计,直接决定了后续开发和维护的效率。我在毕设中采用了三层场景架构:
场景0:Boot(启动场景) - 空的场景,只挂一个启动管理器 - 负责读取全局配置、初始化SDK、显示Loading画面 - 然后异步加载主菜单场景 场景1:MainMenu(主菜单) - 主界面、设置、关卡选择、开发者信息 场景2:Level_XX(游戏关卡场景) - 每个关卡一个独立场景 - 包含场景专属的游戏对象和关卡配置这种拆分的好处是场景职责单一,关卡迭代互不影响。我一个关卡打包时出了问题,其他关卡完全不受影响。缺点是多了一次异步场景切换,但加载一个小关卡场景的耗时完全在可接受范围内。
对应的,代码模块也按职责拆分成四个命名空间:
GameCore:游戏的核心数据模型(玩家状态、关卡数据、存档数据)GameLogic:具体业务逻辑(玩家控制器、敌人AI、机关触发)GameUI:UI面板和UI控制器GameUtility:工具函数和扩展方法
模块间的依赖方向是单向的:UI层依赖Logic层,Logic层依赖Core层,Core层不依赖任何上层。这样后续写论文画系统架构图的时候非常清晰,直接对应这张模块划分。
2.3 数据驱动的关卡配置:从硬编码到可编辑
如果每个关卡的平台位置、机关参数都写成硬编码,那每调整一次数值都要改代码、重新编译,开发效率极低。我用的是ScriptableObject + 关卡管理器的方案。
具体做法是创建一个关卡配置类:
using UnityEngine; using System.Collections.Generic; [CreateAssetMenu(fileName = "LevelConfig", menuName = "Game/LevelConfig")] public class LevelConfig : ScriptableObject { public int levelId; public string levelName; public string sceneName; public Vector3 playerSpawnPosition; public float timeLimit; // 限时通关设置,0表示不限时 public int targetCollectCount; // 需要收集的道具数量 public List<EnemyGroupConfig> enemyGroups; // 敌人配置 public List<PlatformGroupConfig> platformGroups; // 平台配置 } [System.Serializable] public class EnemyGroupConfig { public EnemyType enemyType; public int count; public Vector3 centerPosition; public float patrolRange; } [System.Serializable] public class PlatformGroupConfig { public GameObject platformPrefab; public Vector3 position; public Vector3 scale; public bool isMoving; public Vector3 moveOffset; public float moveSpeed; }然后在场景里挂一个LevelManager,根据配置在场景加载时动态实例化平台和敌人:
using UnityEngine; public class LevelManager : MonoBehaviour { public LevelConfig levelConfig; private GameObject platformRoot; private GameObject enemyRoot; private void Awake() { platformRoot = new GameObject("Platforms"); enemyRoot = new GameObject("Enemies"); SpawnPlatforms(); SpawnEnemies(); } private void SpawnPlatforms() { foreach (var group in levelConfig.platformGroups) { GameObject pf = Instantiate(group.platformPrefab, group.position, Quaternion.identity, platformRoot.transform); pf.transform.localScale = group.scale; if (group.isMoving) { var mover = pf.AddComponent<MovingPlatform>(); mover.offset = group.moveOffset; mover.speed = group.moveSpeed; } } } private void SpawnEnemies() { /* 类似实现省略 */ } }这个方案的收益是策划调整关卡不需要碰代码,关卡的数值修改全部集中在配置资源中。我在毕设期间每天调关卡节奏,基本只改几个配置的数字,重新进Play就能看到效果,开发体验非常顺。
3. 核心功能实现:角色控制、镜头跟随与机关交互
3.1 角色控制器选型:Character Controller还是自写Rigidbody
这是Unity开发里一个经典问题。3D游戏中角色移动,通常有两条路:
方案A:Character Controller组件。Unity提供的胶囊碰撞器式控制器,封装了与地面碰撞的物理逻辑。优点是代码简单,移动直接使用controller.Move(),不会出现角色被物理弹飞的情况;缺点是物理交互弱,如果你需要角色被爆炸推开、被滚石撞击倒地,Character Controller需要自己写额外逻辑。
方案B:Rigidbody刚体驱动。通过rigidbody.AddForce()或rigidbody.velocity控制角色。优点是物理模拟真实,适合需要与场景对象发生复杂物理交互的游戏;缺点是调参难度大,可能在地形边缘发生抖动、顶撞等鬼畜行为。
我在这个项目中选择了方案A:Character Controller。理由很直白:我的毕设玩法的核心是跳跃闯关,需要的是稳定、精确、可控的操作反馈,而不是逼真的物理仿真。物理的真实感在游戏设计里并不是第一位的,手感才是。
Character Controller的移动代码是这个项目里改动最频繁、也最需要用心调的地方:
using UnityEngine; [RequireComponent(typeof(CharacterController))] public class PlayerController : MonoBehaviour { [Header("移动参数")] public float moveSpeed = 6f; public float jumpHeight = 1.8f; public float gravity = 20f; public float sprintMultiplier = 1.6f; private CharacterController controller; private Vector3 moveDirection; private Vector3 verticalVelocity; private float jumpVelocityForHeight; private void Awake() { controller = GetComponent<CharacterController>(); // 根据期望的跳跃高度反算初始跳跃速度:v = sqrt(2 * g * h) jumpVelocityForHeight = Mathf.Sqrt(2 * gravity * jumpHeight); } public void Move(Vector2 inputAxis, bool jumpPressed, bool sprint) { if (controller.isGrounded) { verticalVelocity.y = -1f; // 保持贴地 } // 水平移动:跟随相机朝向 Vector3 forward = Camera.main.transform.forward; forward.y = 0f; forward.Normalize(); Vector3 right = new Vector3(forward.z, 0f, -forward.x); Vector3 desiredMove = (right * inputAxis.x + forward * inputAxis.y).normalized; float curSpeed = sprint ? moveSpeed * sprintMultiplier : moveSpeed; moveDirection = desiredMove * curSpeed; // 跳跃逻辑 if (jumpPressed && controller.isGrounded) { verticalVelocity.y = jumpVelocityForHeight; } // 重力计算 verticalVelocity.y -= gravity * Time.deltaTime; moveDirection.y = verticalVelocity.y; controller.Move(moveDirection * Time.deltaTime); // 面向移动方向 if (desiredMove.magnitude > 0.1f) { transform.rotation = Quaternion.LookRotation(desiredMove); } } }这里有个细节值得说明:为什么跳跃高度不直接设置一个垂直速度,而是用Mathf.Sqrt(2 * gravity * jumpHeight)反向计算?因为直接用速度很难直观知道角色会跳多高,而用“跳跃高度”这个单位来配置,设计师能直接在地形上对照评估跳不跳得上去。这虽然是一个很小的设计细节,但体现了“用数据和参数驱动玩法”的工程思维,答辩时被我拿来作为亮点讲了一下。
3.2 移动端触屏输入的读取与平滑处理
移动端触屏输入和PC键盘鼠标完全是两回事。PC上按方向键是离散的、立刻响应;手机摇杆是持续的、需要平滑过渡的。
我封装了一个虚拟摇杆组件,基于Unity UI的Image实现。核心原理是:在屏幕左下角区域放置一个半透明的摇杆底,玩家按下后摇杆头跟随手指移动,松开后回到中心。摇杆的归一化输出就是角色的输入轴。
using UnityEngine; using UnityEngine.EventSystems; public class VirtualJoystick : MonoBehaviour, IDragHandler, IPointerDownHandler, IPointerUpHandler { [SerializeField] private RectTransform joystickHandle; [SerializeField] private bool touchAnywhere = false; // 是否支持任意位置触碰生成摇杆 private Vector2 originPosition; private float handleRadius = 60f; private Vector2 outputVector; private void Start() { originPosition = ((RectTransform)transform).anchoredPosition; } public void OnPointerDown(PointerEventData eventData) { if (touchAnywhere) { ((RectTransform)transform).anchoredPosition = eventData.position; originPosition = eventData.position; joystickHandle.anchoredPosition = Vector2.zero; } OnDrag(eventData); } public void OnDrag(PointerEventData eventData) { Vector2 direction = eventData.position - originPosition; outputVector = Vector2.ClampMagnitude(direction, handleRadius) / handleRadius; joystickHandle.anchoredPosition = outputVector * handleRadius; } public void OnPointerUp(PointerEventData eventData) { outputVector = Vector2.zero; joystickHandle.anchoredPosition = Vector2.zero; ((RectTransform)transform).anchoredPosition = originPosition; } public Vector2 GetInputVector() { return outputVector; } }这段代码还有个细节,就是touchAnywhere开关:开启后玩家手指按在屏幕左半侧任意位置都会生成摇杆,这种交互在手机上体验更好,但实现时要注意EventSystem的射线检测和Button点击事件的冲突。我的做法是用一个全屏的RaycastTarget半透明Image放在UI最底层作为摇杆的拾取区域,然后在OnPointerDown时根据点击位置左侧还是右侧来决定是否交给摇杆处理。
输入平滑也是手机上非常重要的一环。摇杆的输出值直接给角色控制器,会出现移动生硬的问题。我给输入轴加了一个简单的平滑函数:
public class InputSmoother { private float currentX; private float currentY; private float smoothSpeed = 12f; public Vector2 Update(Vector2 rawInput, float deltaTime) { currentX = Mathf.Lerp(currentX, rawInput.x, smoothSpeed * deltaTime); currentY = Mathf.Lerp(currentY, rawInput.y, smoothSpeed * deltaTime); AISKMathf clamping: if (Mathf.Abs(currentX) < 0.05f) currentX = 0f; if (Mathf.Abs(currentY) < 0.05f) currentY = 0f; return new Vector2(currentX, currentY); } }为什么用Lerp而不是MoveTowards?因为Lerp的平滑效果是“先快后慢”,接近目标时自然减速,模拟摇杆回中的物理感,手感更接近主机平台上的摇杆体验;而MoveTowards是匀速直线归位,虽然响应快,但缺乏跟手感和细腻度。
3.3 摄像机跟随与穿墙避让
3D闯关游戏摄像机常用的是第三人称跟随视角,最简单的实现是把摄像机位置设为角色位置加上一个固定偏移:
transform.position = player.position + offset; transform.LookAt(player.position + lookOffset);但这种写法在角色站在墙边或窄通道时会穿模——摄像机直接怼进墙壁里,画面被挡住。我用的方案是射线检测 + 摄像机避让偏移。
核心思想:角色位置向摄像机预设位置发一条射线,如果途中碰到了场景障碍物,就把摄像机实际位置拉近到射线可用的最近点:
using UnityEngine; public class ThirdPersonCamera : MonoBehaviour { public Transform target; public float distance = 8f; public float height = 3f; public float smoothTime = 0.15f; public float minDistance = 1.5f; public LayerMask obstacleMask; private Vector3 refVelocity; private void LateUpdate() { Vector3 desiredPos = target.position - target.forward * distance + Vector3.up * height; // 从角色头部朝向相机期望位置进行射线检测 Vector3 origin = target.position + Vector3.up * 1.6f; Vector3 direction = desiredPos - origin; float maxDist = direction.magnitude; direction.Normalize(); RaycastHit hit; if (Physics.Raycast(origin, direction, out hit, maxDist, obstacleMask)) { // 如果被障碍物挡住,把相机拉近到距离障碍物前面一点的位置 float safeDist = Mathf.Max(hit.distance - 0.3f, minDistance); desiredPos = origin + direction * safeDist; } transform.position = Vector3.SmoothDamp( transform.position, desiredPos, ref refVelocity, smoothTime ); transform.LookAt(target.position + Vector3.up * 1.2f); } }这段代码避让效果很好,但有一个容易忽略的细节:射线检测的LayerMask一定要只包含场景静态障碍物层,不要包含角色自己的碰撞体。如果不加LayerMask,射线打中角色自己的胶囊体,摄像机会永远被限制在离角色1.5米的位置,画面就会非常奇怪。
3.4 机关、触发器与关卡状态管理
闯关游戏中“机关”是最常见的玩法元素,它们在Unity里的本质就是碰撞触发器 + 可响应的组件。
我定义了一个抽象基类LevelTrigger,所有机关(旋转刀刃、升降平台、喷射火焰、目标传送门)都继承自它:
using UnityEngine; public abstract class LevelTrigger : MonoBehaviour { [Header("触发设置")] public TriggerAction actionOnPlayerEnter = TriggerAction.None; public bool canRepeat = false; public KeyCode debugKey; // 用于PC调试 protected bool hasTriggered = false; protected bool isReady = true; protected virtual void OnTriggerEnter(Collider other) { if (!isReady) return; if (!other.CompareTag("Player")) return; if (!hasTriggered || canRepeat) { hasTriggered = true; ExecuteTrigger(other); } } protected abstract void ExecuteTrigger(Collider player); public abstract void ResetTrigger(); // 关卡重置时调用 } public enum TriggerAction { None, DealDamage, LaunchPlayer, OpenGate, PlayAnimation, ActivateMovingPlatform }比如“发射火焰”的机关,继承后重写ExecuteTrigger:
public class FlameEmitter : LevelTrigger { public GameObject flameObject; public float activeTime = 2f; private Coroutine flameCoroutine; protected override void ExecuteTrigger(Collider player) { if (flameCoroutine != null) StopCoroutine(flameCoroutine); flameCoroutine = StartCoroutine(FlameCycle()); } private IEnumerator FlameCycle() { flameObject.SetActive(true); yield return new WaitForSeconds(activeTime); flameObject.SetActive(false); } public override void ResetTrigger() { StopAllCoroutines(); flameObject.SetActive(false); isReady = true; hasTriggered = false; } }关卡状态管理也是容易写乱的地方。我的做法是定义了一个简单的关卡状态机:
public enum LevelState { Loading, Playing, Paused, Completed, Failed }状态的变化由LevelManager统一控制,玩家掉出地图、血量归零、收集完目标道具、到达终点门,都会调用LevelManager.ChangeState()来状态迁移。这样一个全局状态机保证了UI层的显示逻辑不会乱,比如暂停状态绝对不会弹出通关结算面板。
4. 移动端适配与性能优化:手机端和PC端完全是两回事
4.1 UI适配:Canvas缩放、安全区和多分辨率
在做PC游戏时,UI直接用固定像素位置通常没什么问题。但手机屏幕从720p到1440p,宽高比从16:9到19.5:9再到刘海屏,如果不做适配,UI会变形、被刘海遮挡、按钮跑到屏幕外面。
我的UI适配方案分三步:
第一步:Canvas Scaler设置。每个UI Canvas上挂CanvasScaler组件,UI Scale Mode选择Scale With Screen Size,参考分辨率设成1080 x 1920,Screen Match Mode设为Match Width Or Height,Match值设为0.5。这个值是宽度和高度匹配的权重,0.5意味着在宽高比变化时取一个折中方案,保证UI元素整体缩放自然。
第二步:安全区适配。针对刘海屏和挖孔屏,需要读取设备的安全区数据来调整顶层UI的边距。我把这段逻辑封装成一个工具类:
using UnityEngine; using UnityEngine.UI; public static class SafeAreaAdapter { public static void ApplySafeArea(RectTransform target) { Rect safeArea = Screen.safeArea; Vector2 anchorMin = safeArea.position; Vector2 anchorMax = safeArea.position + safeArea.size; anchorMin.x /= Screen.width; anchorMin.y /= Screen.height; anchorMax.x /= Screen.width; anchorMax.y /= Screen.height; target.anchorMin = anchorMin; target.anchorMax = anchorMax; } }在主面板的Awake里调用一次即可。注意多个面板应该共用一个基础安全区Anchor,而不是每个面板单独适配,否则维护起来很痛苦。
第三步:多用Anchor和布局组件,少用绝对坐标。比如“跳跃按钮”固定放在右下角,正确方式是Anchor预设到右下角,然后用相对偏移定位;暂停按钮放在左上角同理。这样做的好处是不同分辨率下按钮始终在屏幕的“相对位置”。
4.2 渲染性能预算:DrawCall、阴影、光照烘焙
手机GPU的渲染能力比PC差一大截,你以为的“全分辨率 + 实时灯光 + 动态阴影 + 全屏泛光”很炫酷,上手机后可能就是10帧卡成PPT。我在项目里给渲染性能定了一条硬性预算:
| 性能指标 | PC开发机 | Android手机目标 |
|---|---|---|
| 帧率 | >100 FPS | ≥30 FPS |
| DrawCall | 无所谓 | ≤60 |
| 内存 | 无所谓 | ≤350MB |
| 模型面数 | 无所谓 | 每屏幕≤30万三角面 |
要达到这个预算,最重要的手段是光照烘焙。场景中凡是静态的物体(地板、墙壁、静态平台)全部标记为Static,在Window -> Rendering -> Lighting设置里,关闭实时场景光,启用Baked GI。URP下烘焙出的光照贴图能显著提升画面质感,同时几乎不消耗运行时性能。唯一的代价是烘焙时间,我的场景不大,每关烘焙大概需要1~2分钟,完全可接受。
动态物体(角色、可移动平台、敌人)不能参与烘焙,这时要控制动态阴影的数量。我干脆直接关闭了大部分动态阴影——只保留主方向光对角色投射阴影,其他光源不投阴影。在URP的管线资源里把Shadow Distance调成10米,超过10米的物体不计算阴影。牺牲的是阴影远距离精度,换来的却是帧率大幅提升,这个取舍在手机端非常值。
另一个容易忽略的点是UI与3D场景之间的层级顺序。UI的每张Image都是一个DrawCall,我尽量减少UI面板里的Image数量。比如一个纯色背景板直接开一像素白色Image然后拉伸,不要用一张几百KB的大图。
4.3 内存与资源管理:对象池、纹理压缩与设备兼容
手机端内存管理是一件只有被坑过才会重视的事。我踩过的最深刻的一个坑是:每切换一次关卡,内存占用持续上涨,玩到第五关游戏直接闪退。原因是场景卸载时,程序集里静态引用持有的GameObject没有被释放。
解决这个问题的首要是养成好习惯——场景间传递的引用不要用static字段直接持有实例,而要用ScriptableObject这种Unity管理的资源来存数据。另一个攻坚手段是对象池。
闯关手游中敌人死亡、火焰喷射、道具拾取这些对象会频繁创建和销毁。每次Instantiate和Destroy都会触发GC和内存分配,在手机上累积起来就是卡顿和掉帧。我封装了一个简易对象池:
using System.Collections.Generic; using UnityEngine; public class ObjectPool : MonoBehaviour { private Dictionary<GameObject, Queue<GameObject>> poolDict = new(); public GameObject Spawn(GameObject prefab, Vector3 position, Quaternion rotation) { if (!poolDict.TryGetValue(prefab, out var queue) || queue.Count == 0) { return Instantiate(prefab, position, rotation); } GameObject obj = queue.Dequeue(); obj.transform.position = position; obj.transform.rotation = rotation; obj.SetActive(true); return obj; } public void Despawn(GameObject obj, GameObject prefab) { obj.SetActive(false); if (!poolDict.TryGetValue(prefab, out var queue)) { queue = new Queue<GameObject>(); poolDict[prefab] = queue; } queue.Enqueue(obj); } }注意Despawn时要指定对应的prefab,否则不同类型的对象会混在一起拿错。更严谨的做法是用一个PoolKey标识对象类型,但毕设阶段用prefab本身作key够用了。
纹理压缩上,我导入的素材纹理尽量设置成Android的ASTC_4x4或ETC2格式,这两个是Android平台的主流压缩纹理格式。在Asset Importer的Android标签页里选择Override for Android -> Format -> ASTC 4x4即可。另外关闭纹理的No MipMap选项会产生内存浪费,我会为大多数2D UI纹理关闭MipMap,而3D模型贴图保留MipMap以避免远处闪烁。
4.4 Profiler真机分析:用数据代替感觉
很多同学优化性能靠“感觉”——感觉这个场景卡,那就把树砍掉;感觉阴影费性能,那就全关。这种屁股决定脑袋的优化方式既浪费时间也没有数据支撑。
Unity的Profiler是解决性能问题的标准工具。我建议在开发中后期,经常用Android真机Profiler分析性能瓶颈。连上USB线,打开Unity的Window -> Analysis -> Profiler,点击顶部电脑图标选择AndroidPlayer,就能远程看到真机上的CPU、GPU、内存和渲染统计。
我的性能排查经验是:
- 先看CPU Main Thread耗时。如果CPU单帧耗时超过33ms,说明逻辑或动画计算慢,查看
PlayerLoop -> Update里哪个脚本占时间最长。 - 再看Render线程耗时。如果渲染时间高而CPU低,说明GPU压力大,典型特征是DrawCall高或OverDraw严重,重点查半透明物体的数量。
- 最后看Native内存。如果内存接近操作系统上限,关注纹理内存和音频内存,用Profiler的
Memory -> Texture面板按内存占用排序,找出最大的贴图。
我在一次真机测试中发现,某个关卡掉帧到20FPS,用Profiler排查后定位到是一段粒子特效的Trail Material产生了大量OverDraw。解决方案是把粒子大小缩小、粒子数减半,重新测帧率回到52FPS。这种可量化的优化成果,写进论文里也是很好的数据支撑。
5. 开发过程的踩坑记录与排查复盘
5.1 场景切换后Input失效的诡异问题
我在开发中遇到过一个特别诡异的问题:从主菜单进入第一个关卡后,虚拟摇杆完全没反应,但UI按钮的点击事件正常。退出关卡回到菜单再进一次,摇杆又突然好用了。
排查过程:
- 最开始怀疑是输入组件被
SetActive(false)了。检查层级结构,发现VirtualJoystick在场景中是激活状态,并未被关闭。 - 再用Debug.Log在
OnPointerDown里打印,发现点击时根组件根本没收到事件。 - 查看EventSystem的
RaycastAll结果,发现打在了其他UI元素上——有一个透明的FullscreenBlocker面板盖在摇杆上面,它是在菜单场景中常驻的过渡遮罩,场景切换到关卡场景时忘记销毁了。
问题根因找到了:菜单场景的过渡遮罩没有销毁,挡住了一次点击。解决办法是在切场景前统一清理全局UI面板。这个问题的启示是:场景切换时的单例和全局UI管理必须有统一的生命周期,否则各种残留组件会互相干扰。
5.2 UI穿透问题:为什么点击了按钮却同时移动了角色
这个问题的场景是:玩家打开暂停菜单,点击“返回主菜单”按钮的同时,游戏场景里的虚拟摇杆也在响应同一根手指的输入。表现为“一边打开菜单,一边角色在跑”。
根本原因:EventSystem的UI射线检测对每个Canvas和UI元素是独立处理的,多个UI区域都能同时接收输入事件。当手按在按钮上,事件先被按钮面板的GraphicRaycaster截获,此时指针事件也会穿透到底层的摇杆区域。
解决办法是我在可交互UI(暂停界面、设置界面)打开时,显式屏蔽游戏操作输入:
public class UIManager : MonoBehaviour { [SerializeField] private Canvas pauseCanvas; [SerializeField] private PlayerController player; [SerializeField] private VirtualJoystick joystick; public void ShowPauseMenu() { Time.timeScale = 0f; player.enabled = false; joystick.gameObject.SetActive(false); pauseCanvas.enabled = true; } public void HidePauseMenu() { Time.timeScale = 1f; player.enabled = true; joystick.gameObject.SetActive(true); pauseCanvas.enabled = false; } }这里有个细节:我用了Time.timeScale = 0f而不是只禁用玩家控制器,这样场景中所有依赖deltaTime的动画、机关会一并暂停,避免出现“菜单开着,背景的火焰喷射还在继续”的BUG。
5.3 移动端打开游戏黑屏但有声音
这是一个非常典型的移动端专属问题。PC上正常,Android打包后,启动画面出来之后屏幕是黑的,但背景音乐能正常播放,说明游戏逻辑在跑,只是渲染出不来。
排查过程:
- 先怀疑
Camera没跟随——但即使没跟随,画面也不该全黑。 - 再怀疑灯光烘焙文件没打包进APK——因为场景中如果没有Lightmap数据,物体是纯黑色。强制改回实时灯光,场景就能正常显示,证明不是这个原因。
- 最终定位到渲染管线资源未正确加载。我用URP时,在
Project Settings中指定了管线资源,但用了Quality Settings里某个不匹配的层级,导致Android平台上实际没有找到对应的URP Asset。
这个问题的标准解法是:在Player Settings -> Quality里,为所有质量等级统一指定同一个URP Renderer Asset。同时确认Graphics Settings -> Scriptable Render Pipeline Settings已经赋值。打包前在Build Settings的Player Settings里检查一遍这些配置,不要想当然以为项目默认配置会带过去。
5.4 构建包体过大的根源追踪
毕设要求APK包体尽量在100MB以内,我第一版打包出来直接接近300MB。后来用Build Report工具检查,发现大头分布:
- 53%:StreamingAssets里的几个测试用的大纹理(美术素材直接拷贝进去的,没走导入压缩)
- 22%:Pico SDK相关插件(我当时测试AR能力时引入了一堆没用的SDK)
- 15%:Shader变体过多
解决方案:
- 移除不再使用的SDK(只保留Android原生工具集)。
- 对Asset Store美术素材统一压缩处理,模型贴图导入时按移动端格式压缩。
- 在
Graphics Settings -> Shader Striping里开启Strip Unused Variants,关闭不用的Shader变体。
打包优化后的APK从300MB降到了85MB左右。打包前一定要看Build Report,它能告诉你是哪项资源吃了空间。
6. 交付与答辩准备:构建APK、录制演示视频与论文映射
6.1 Android构建配置与签名
Unity构建Android APK,真正要操心的是签名配置。用Unity内置的签名只能在项目调试阶段用,如果要发布给手机装APK,必须自己创建一个Keystore。
操作路径是Edit -> Project Settings -> Player -> Android Tab -> Publishing Settings -> Keystore Manager。创建好后,每次构建用同一个Keystore,这样后续覆盖安装不会出现签名冲突。
还有一个容易被忽略的选项:Build App Bundle(Google Play)和Build APK的区别。毕业设计演示直接选APK即可,不要选AAB,否则你得到的是一个.aab文件,手机上根本装不了。
6.2 演示视频和答辩材料准备
答辩现场可能会出现“我的手机和投影仪连接不上”、“现场网络不好”等各种意外。最稳妥的做法是提前录制一段分章节的游戏演示视频:
- 第1段:主菜单到第一关,展示操作方式
- 第2段:中期关卡,展示机关互动和敌人AI
- 第3段:Boss关,展示综合玩法
视频用手机竖屏录制或Unity的Recorder窗口录制,导出为1080p MP4。答辩时如果现场演示失败,直接播放视频,同时口头讲解设计与实现细节,完全不影响评分。
6.3 论文中的系统架构图怎么画才专业
毕业论文里的系统架构图,不要画成“用户 -> UI -> 游戏 -> 数据库”这样的幼儿园结构,要体现模块化设计。我的建议是按照“表现层 -> 逻辑层 -> 核心数据层 -> 资源层”四层结构来画:
- 表现层:UI面板、特效、音效、动画控制器
- 逻辑层:玩家控制、敌人AI、机关系统、关卡管理、存档系统
- 核心数据层:配置类、玩家数据模型、关卡数据、存档读写
- 资源层:Prefab、Shader、Resources/AssetBundle、纹理材质
每层之间用单向箭头连接,标注依赖关系。这张图要和你第2章的工程项目结构保持一致,答辩老师一定会盯着图问细节。
7. 从毕设到作品集:我建议你在交付后做的三件事
第一件事,起一个好记的项目名字并制作独立的icon和加载画面。答辩评分的第一印象往往来自启动画面,一套统一的视觉风格能让评委对你的专业感产生好感。这个视觉素材可以来自免费素材站,但一定要从第一屏就统一调性,不要默认Unity的全屏Logo界面。
第二件事,为每个关卡录一段20秒的“核心亮点展示”短视频。这不仅是答辩备用材料,也是你求职/考研复试时作品集里的绝佳内容。我在研究生复试时就是拿这个毕设的视频片段加代码截图展示自己的实践能力,效果非常直接。
第三件事,把项目的版本历史做一次完整的梳理和总结,写进Git提交记录并导出README。很多同学开发过程中没有养成commit习惯,到答辩前发现代码像个大杂烩。从第一天开始用Git维护,每次做完一个功能就提交一次,这样你在写论文的“开发过程”章节时,可以对照提交记录复盘每一步的设计决策,写出来的内容更有说服力。
我在完成这个毕设后最大的感受是:一个“看起来普通”的闯关手游,真正做出来、优化好、讲清楚,并不容易;但这份完整经历教会你的工程思维,远比这个游戏本身值钱。
如果你正准备开始这个题目,请相信这是一条选对了方向的路。别急着追求炫酷,先让一个简单的关卡跑通,再逐步加系统和优化。一边做一边记录问题,到了答辩的时候,这些真实的踩坑记录就是你最好的护城河。
本文还有配套的精品资源,点击获取