☰
从零入门Unity:用滚球吃金币Demo打通移动、碰撞、相机、UI与打包
2026/9/30 6:15:10 网站建设 项目流程

入行这些年,被问得最多的一个问题不是“Unity难不难”,而是“我该从哪一步开始”。我的答案一直没变过:别先啃系统教程,先用Unity做一个小Demo出来。这个Demo不需要多漂亮,也不需要什么美术资源,能用键盘把一个球推着在地上跑、撞到会消失的金币能加分、镜头能稳稳跟住它,最后能打包成一个能双击运行的程序,这一整套流程走完,你就已经跨过了入门Unity最难的那道坎。下面这篇内容,我把自己带新人时反复用过的这套流程完整写出来,包括每一步为什么这么做、参数大概给多少、以及那些教程里基本不会讲但一定会踩的坑,适合完全零基础、想快速上手Unity并做出第一个可玩Demo的人。全文围绕一个“滚球吃金币”的小案例展开,但里面涉及的场景组织、物理参数、脚本生命周期、打包排错思路,换成任何玩法都通用。

1. 入门Unity最快的路径不是“学完再做”,而是“做坏了再修”

1.1 为什么大多数人卡在“看完一套教程还是不会”

我刚带新人的时候,习惯性先扔一堆教程链接,结果发现三周过去,对方还是不敢新建工程。问题不在教程质量,而在于看教程是一件被动的事:视频里鼠标点哪个菜单、参数填多少,你都能看懂,但你自己面对一个空场景的时候,脑子里是没有任何“下一步该干嘛”的肌肉记忆的。Unity的知识密度很高,同时又高度耦合——一个最简单的“球往前滚”就牵扯到 Transform 的位置更新、Rigidbody 的物理模拟、Collider 的碰撞形状、输入读取方式、以及 Update 和 FixedUpdate 两个生命周期函数的分工。你单独去背其中任何一项都记不住,只有把它们串在一个能跑起来的东西上,记忆才会生根。所以你的第一目标不是“学完”,而是“让它动起来”,哪怕动得很丑。动起来之后,你才有具体的问题可问:为什么球会卡在地上?为什么镜头会抖?为什么金币撞不动?这些问题本身就是最好的教材。

1.2 我要求这个Demo必须亲手实现的五件事

我不建议一上来就追求“完整游戏”,但也不能只做一个“按一下键方块动一下”的空壳,那样学到的东西太薄。我一般会让新人把下面五件事全部亲手实现一遍,任何一步查文档可以,直接抄完整代码不行:

序号要实现的功能实际覆盖的Unity知识点
1键盘推着球在地面上滚动Transform、Rigidbody、输入读取、FixedUpdate
2球碰到金币,金币消失Collider、Is Trigger、Tag、OnTriggerEnter
3相机始终跟着球LateUpdate、位置偏移、插值跟随
4屏幕左上角显示分数和提示Canvas、TextMeshPro、Canvas Scaler
5按 R 键重置,打包成 exe 双击可玩场景重载、Build Settings、Player Settings

你会发现这五件事几乎没有一行是“为了炫技”的,全都是任何一个商业项目里都会用到的地基。尤其第 5 条,很多人学了一个月都没打过一次包,等到真要交付时才发现在编辑器里跑得好好的东西,打包出来是黑屏的——这种事我见过太多次。

1.3 顺序不能乱:为什么是“移动 → 碰撞 → 相机 → UI → 打包”

这个顺序不是随便排的,它是一条依赖链。先有移动,你才能验证物理参数是否合理,球会不会穿墙、会不会飘;有了移动和地面,相机跟随才有意义,否则你对着一个不动的球调跟随是调不出手感的;有了球和金币的碰撞,分数才有来源,UI 才有东西可显示;有了一套闭环的玩法,打包才值得做,因为你要验证的是“这个循环能不能脱离编辑器运行”。很多新人反过来,先花两天搭一个漂亮的场景,摆了一堆模型,然后才开始写移动,结果一移动发现地面没有碰撞体,全部推倒重来。先把最脏最核心的逻辑跑通,再考虑美化,这是我这些年一直坚持的顺序。

2. 环境这一关:Unity安装、版本选择与工程目录的隐性成本

2.1 Unity Hub与版本选择:LTS、渲染管线与“装少了要重装”

现在的 Unity 都通过 Unity Hub 来管理,官网下载 Hub 之后,在 Hub 的 Installs 标签页里点 Add 安装编辑器版本。这里第一个决策就是版本。我的建议是优先选带 LTS 后缀的长期支持版,比如 2022.3 LTS 或者 Unity 6 的 LTS 分支,原因是 LTS 版本的 API 稳定、文档齐全、第三方插件适配得最好,遇到问题在社区里搜到的答案也最多。不要一上来就装最新的技术预览版,那种版本经常几周一个小更新,改掉一些 API 或者改掉编辑器行为,对新手来说是纯粹的干扰。安装界面里的模块勾选是最容易被忽略也最要命的一步,默认只勾了当前平台的支持,等你哪天想打安卓包或者网页包,就得回 Hub 里补装,而补装有时会因为版本对不上导致编译报错。

我一般会勾的模块:

  • Windows Build Support (IL2CPP):做桌面 demo 必装,注意括号里这个 IL2CPP 是打包后端,和 Mono 后端不一样,后面排错章节会细讲。
  • Android Build Support(含 OpenJDK、Android SDK & NDK Tools):就算暂时不做手机游戏,勾上也不亏,装起来慢但省得以后再等。
  • WebGL Build Support:想做网页 demo 或者后面接小游戏平台的话必装。
  • Documentation:本地文档,断网时救命,其实比在线文档快很多。
  • 语言包:中文界面包,个人建议别装。原因很实在——你将来搜报错、看教程、读 API 名字,全是英文的,用一个中文界面的编辑器去对照英文文档,反而增加认知负担。

安装路径这一步千万别偷懒。Unity 的安装目录和之后的工程目录,一律用纯英文、不带空格的路径,比如D:\Unity\Hub、D:\Projects\BallDemo。中文路径和带空格路径引发的问题非常隐蔽,通常不是立刻报错,而是某些第三方工具、某些命令行编译步骤、某些 Gradle 打包环节莫名其妙失败,排查起来能耗掉你一整天。这不是迷信,是路径转义和编码在不同工具链里处理不一致导致的经典问题。

2.2 新建工程的模板选择:3D Built-in还是URP

打开 Hub 的 Projects 页面点 New Project,会看到一排模板。新手最容易纠结的就是 3D 和 3D (URP) 到底选哪个。

简单说,Built-in 渲染管线是 Unity 老的内置管线,配置简单、不挑材质、随便从 Asset Store 下载的素材丢进去就能用;URP 是可编程渲染管线,画面效果更好、移动端性能更可控,但材质必须用 URP 专用的 Shader,你从别处拿来的老材质球导进来会变成刺眼的粉红色——这是因为 Shader 找不到,不是模型坏了。入门 Demo 我建议直接用3D (Built-in)模板,把精力集中在玩法逻辑上,不要一上来就被渲染管线、Shader Graph、后处理体积这些东西分散注意力。等你把 Demo 跑通、想优化画面的时候,再新建一个 URP 工程把脚本搬过去,那时候你就有判断力了。

另外提醒一句,Unity 6 之后新建工程默认就是 URP,如果你确实想用内置管线,注意看模板名字,别点错了。工程名字和保存路径同样遵守英文规则。

2.3 目录结构与版本管理:哪些文件夹能删、哪些不能动

新建完工程,Assets 里空空如也,工程根目录下却有一堆文件夹,很多新人第一反应是“这些是不是垃圾,可以删掉腾空间”。我把它们分成三类:

文件夹作用能否删除是否要提交到版本库
Assets你所有的场景、脚本、模型、材质不能删必须提交
ProjectSettings工程的各项全局设置,含输入、物理、质量、标签不能删必须提交
Packages依赖包清单 manifest.json不能删必须提交
Library导入资源后的缓存和数据库可以删,重开会自动重建不要提交
Temp / Obj / Logs临时文件、编译中间产物、日志可以删不要提交
Build / Builds打包输出目录可以删不要提交

Library 文件夹的体积通常比整个工程大好几倍,这也是为什么用 Git 管理 Unity 工程必须写.gitignore。如果你不忽略它,几万个二进制小文件会把仓库撑爆,每次提交都要等半天。另外强烈建议在 Edit → Project Settings → Editor 里把 Asset Serialization 设成 Force Text、Version Control 的 Mode 设成 Visible Meta Files,这样场景和预制体在版本库里的差异是可读的文本,多人协作时冲突也好解决。Meta 文件千万别删,每个资源旁边那个.meta记录的是它的 GUID 和导入设置,删了之后引用关系就断了,场景里会出现一堆 Missing 的对象。

2.4 我遇到的三个环境坑:中文路径、杀软、首次导入

第一个坑是路径,前面说过了,但我要补充一种更隐蔽的情况:工程路径是英文,但你引用的某个资源包或者 SDK 解压在中文目录下,一样会出问题。

第二个坑是杀毒软件和实时防护。Unity 导入资源和编译脚本的时候会产生海量临时文件读写,某些实时防护会把每个文件都扫一遍,结果就是首次导入一个中等规模的工程能卡到你以为死机了。做法很简单,把 Unity 安装目录、工程目录、以及系统的临时目录加入白名单,导入速度能肉眼可见地变快。这个经验是我在一台新机器上折腾了大半天才总结出来的,当时一度以为是硬盘坏了。

第三个坑是首次打开工程的等待时间。Unity 第一次打开工程要对所有资源做导入和编译,如果你的硬盘是机械盘,等十几分钟都正常,这时候别乱点,别以为卡死了就强杀进程,强杀很容易让 Library 缓存处于半损坏状态,下次打开报一堆莫名其妙的错。真遇到这种怀疑缓存损坏的情况,稳妥的做法是关掉 Unity,删掉 Library 文件夹,重新打开让它重建——这个操作能解决新手期一大半的玄学报错。

3. 搭出可玩的场景:从地面、球体到物理参数

3.1 场景最小清单与层级组织

空场景默认只有两样东西:Main Camera 和 Directional Light。我们要在它基础上加几个物体。全部通过 Hierarchy 面板右键 → 3D Object 创建,不需要任何外部素材:

  • Plane(地面):默认尺寸是 10×10 单位,我们把它 Scale 设成 (5, 1, 5),就变成 50×50 的活动区域,对一个小 Demo 来说够用了。
  • Sphere(球,也就是玩家):Scale 设成 (0.5, 0.5, 0.5),这样球的直径是 1 个世界单位,半径 0.5,跟地面的距离关系好算。
  • Cube(金币):Scale 设成 (0.4, 0.4, 0.4),压扁一点更像金币,或者干脆用 Cylinder 转 90 度更形象。
  • Canvas(UI 层):用来放分数文本和提示文字。

关于层级组织,我给新人的第一个“专业习惯”建议就是:不要把所有东西平铺在 Hierarchy 根目录下。点右键 → Create Empty 建几个空物体当文件夹,命名成_Environment、_Player、_Pickups、_UI,把对应的东西拖进去。这看起来是小事,但等你场景里有上百个对象的时候,找东西的效率差距是数量级的。另外我会把玩家对象命名成Player,而不是默认的Sphere,因为你的代码里GameObject.Find("Sphere")这种写法一旦改名就全废了,从一开始就用语义化命名,能省掉后面大量重命名和改代码的工作。

3.2 Rigidbody + Collider:一次穿模引出的物理理解

这一步是真正的核心。选中球,Inspector 里点 Add Component 添加Rigidbody(刚体)。加完你会看到一堆参数,我按重要性排一下:

  • Mass(质量):默认 1,小 Demo 不用改,它影响的是受力后的加速度和碰撞时动量交换的比例。
  • Drag(阻力):默认 0。如果你希望球松开按键后能慢慢停下来,设 0.5 到 1 之间比较自然;设成 0 的话球会一直滚。
  • Angular Drag(角阻力):控制旋转的衰减,默认 0.05,可以不动。
  • Use Gravity:保持勾选,球才会落地。
  • Constraints(约束):把 Freeze Rotation 的 X 和 Z 勾上。这一步非常关键,不勾的话球会像真球一样到处乱转,你的前进方向会被自身的旋转带着跑偏,操控会变得极其难受。

然后是 Collider。Sphere 默认自带 Sphere Collider,Plane 自带 Mesh Collider,所以碰撞本身是通的。但这里有一个新手必踩的问题:为什么我的球会穿墙 / 掉出地面?原因通常有三个。第一,地面用了 Plane 但被 Scale 改了之后,Mesh Collider 在极端缩放下的表现会不稳定,正确做法是给地面换成一个 Box Collider,或者用一个 Cube 压扁当作地面。第二,你把移动逻辑写在了 Update 里直接改 Transform.position,而物理计算在 FixedUpdate 里以固定步长运行,两者不同步的时候就会出现视觉上的穿透。第三,速度太快,物体在两帧之间直接跨过了薄薄的碰撞体,这叫“隧穿”,解决办法是把碰撞体加厚,或者把 Rigidbody 的 Collision Detection 从 Discrete 改成 Continuous。我当年第一次做跑酷 Demo,角色高速撞墙直接穿过去,查了两个小时才定位到是碰撞检测模式的问题。

顺便说一下物理时间步长。Edit → Project Settings → Time 里的 Fixed Timestep 默认是 0.02,也就是每秒 50 次物理更新。这个值调小会让物理更精确但更耗性能,调大会让物理更省但更容易漏检测。小 Demo 用默认值就行,知道有这么个开关就够了。

3.3 光照与阴影:为什么新手画面总是“糊”和“死黑”

场景搭好之后,很多人会发现画面不对劲:要么暗得看不清,要么影子糊成一团马赛克。这两个问题几乎每个新人都遇到过。

“死黑”的根源一般不在灯光,而在环境光。打开 Window → Rendering → Lighting 面板,看 Environment 那一栏的 Ambient Source。默认是 Skybox,会从天空盒取环境色,画面是亮的。如果你不小心把它改成了 Color 并且颜色调得很暗,整个场景就会像停电一样。入门阶段就保持 Skybox 不要动。

“影子糊”的根源在阴影分辨率和阴影距离。Directional Light 的 Inspector 里,Shadow Type 建议用 Soft Shadows;下面的 Quality Settings 里找到 Shadows 一栏,Shadow Distance 默认是 150(不同版本可能不同),意思是只有离相机 150 单位内的物体才投影,超出范围就没有影子。如果你的场景很大但物体很集中,把 Shadow Distance 调小到 50 左右,同等分辨率预算下阴影会清晰很多——这是个非常实用的调优技巧,本质是把有限的阴影贴图分辨率集中用在玩家附近。另外 Shadow Resolution 从 Medium 提到 High 或 Very High,边缘的锯齿感会明显改善,代价是多一点性能开销,桌面 demo 完全承受得起。

我的经验是先调 Shadow Distance,再调 Shadow Resolution,最后才考虑 Soft/Hard 的切换。顺序反了容易反复折腾。

3.4 材质与预制体:把金币做成可复用的Prefab

现在的球和金币都是默认的灰白材质,很难看。在 Project 面板右键 → Create → Material,建两个材质球,命名成Mat_Player和Mat_Coin,Base Map 里选个颜色,直接把材质球拖到场景物体上就能生效。

更重要的一个动作是把金币做成 Prefab(预制体)。做法很简单:把 Hierarchy 里的金币拖到 Project 面板里,它会变成一个蓝色图标的资源,然后你可以把场景里那个删掉。以后你要放金币,直接从 Project 拖出来,或者用代码Instantiate(coinPrefab, position, Quaternion.identity)动态生成。为什么要这么做?因为 Prefab 是“一处修改、处处生效”的。等你调好了金币的碰撞体大小、Tag、材质、以及挂在它上面的脚本,之后复制一百个都带着这些设置。我见过新人手动复制六十个金币,然后发现 Tag 忘了加,只能一个一个点开改,那是真的痛苦。

这里提前说一个后面会用到的设置:金币需要打一个 Tag。在 Inspector 顶部的 Tag 下拉里选 Add Tag,新建一个叫Coin的标签,然后给金币预制体选上。这个 Tag 是脚本判断碰撞对象的依据,忘了加会导致后面代码完全不起作用。

4. 把玩法写进脚本:输入、移动、相机跟随与计分的实现细节

4.1 Update还是FixedUpdate:位移为什么必须乘Time.deltaTime

先建脚本。在 Project 面板右键 → Create → C# Script,命名PlayerController,拖到球上。打开后先删掉默认的Update空函数以外的东西,写成自己的逻辑。

新手第一个疑问通常是:Update 和 FixedUpdate 到底用哪个?我的判断标准很简单:只要你的操作对象带 Rigidbody,位移和力的施加就写在 FixedUpdate 里。原因是物理引擎按固定时间步长计算,而 FixedUpdate 正好在这个节拍上被调用;Update 的调用频率跟着帧率走,帧率一波动,你在 Update 里施加的力就会不一致,表现出来就是有时走得很顺有时突然一顿。

具体怎么写移动,我给两个方案。方案 A 是直接给速度:读取输入得到方向和大小,然后赋值给刚体的 velocity,这样响应最干脆,适合街机手感。方案 B 是施加力:用rb.AddForce(direction * force),手感更“物理”,球会有惯性、会滑,看起来真实但不好控制。入门我建议先用方案 B,体会一下物理引擎在替你做什么,手感不合适再切 A。

一个必须解释清楚的点:为什么Time.deltaTime这个系数不能省。deltaTime是“上一帧到这一帧用了多少秒”。如果你写成transform.position += Vector3.forward * speed,那么速度的单位是“每帧米数”,帧率 60 的时候每秒走 60 个 speed,帧率 30 的时候就只走 30 个 speed——同一台机器上跑得快,换台机器就变慢。乘上Time.deltaTime之后,变成了“每秒米数”,物理含义就正确了。在 FixedUpdate 里严格来说要用Time.fixedDeltaTime,不过因为固定步长是恒定的,两者在默认配置下数值一样,所以很多教程混着用也没出问题。但你要知道这个区别,面试里被问到就是个加分项。

输入读取这里还有个坑:Unity 有新旧两套输入系统。老的是Input.GetAxis("Horizontal"),新的是 Input System 包(需要在 Package Manager 里装,然后 Player Settings 里切换 Active Input Handling)。入门 Demo 用老的Input类就够,项目设置里保持默认即可,别折腾。如果你装了 Input System 包之后发现所有Input.GetAxis都报错或者无响应,那就是 Active Input Handling 被切成了 Input System Package,改回 Both 就好了,这个坑我替人排查过至少五次。

4.2 相机跟随的三种写法和各自代价

相机跟随是新手 Demo 观感好坏的分水岭。我按从笨到好的顺序说三种写法,你可以逐个试。

写法一:把相机拖成玩家的子物体。最简单,一行代码都不用写。但问题立刻出现:球是会滚动的,球一转,作为子物体的相机跟着一起转,整个画面天旋地转,玩十秒就晕。这种写法只适合你的玩家对象永远不旋转的情况,比如某些俯视角游戏。

写法二:在 LateUpdate 里手动同步位置。新建脚本CameraFollow挂到 Main Camera 上,在LateUpdate里执行transform.position = target.position + offset;。为什么是 LateUpdate 而不是 Update?因为 Unity 的调用顺序是 Update 全部跑完之后才跑 LateUpdate,把相机放在最后更新,就能保证它读到的是本帧所有物体移动完之后的最新位置。如果你写在 Update 里,相机和玩家在同一帧里争抢更新顺序,就会出现画面抖动或者相机落后一帧的感觉。

写法三:带插值的平滑跟随。直接在 LateUpdate 里硬赋值,相机是“焊死”在玩家身上的,玩家一动相机就动,看起来有点生硬。更进一步是用Vector3.SmoothDamp或者Vector3.Lerp做插值,让相机有个轻微的追赶感。下面是我常用的一段结构:

public class CameraFollow : MonoBehaviour { public Transform target; public Vector3 offset = new Vector3(0, 6, -8); public float smoothTime = 0.15f; private Vector3 velocity = Vector3.zero; void LateUpdate() { Vector3 desired = target.position + offset; transform.position = Vector3.SmoothDamp(transform.position, desired, ref velocity, smoothTime); transform.LookAt(target); } }

三种写法的对比如下:

写法代码量手感适用场景
父子关系无生硬且有旋转污染固定视角、玩家不旋转
LateUpdate 硬跟随极少精准但机械需要精确操作的平台跳跃
SmoothDamp 插值少顺滑有质感第三人称、跑酷、大多数 demo

smoothTime这个参数别乱给,0.1 到 0.3 之间比较自然,给到 0.5 以上相机会像喝醉了一样晃。这个值本质上描述的是“追上目标大约需要多久”,它的单位和帧率无关,所以换设备也不会走样,这一点比直接乘个 0.1 的插值系数要可靠得多。

4.3 触发检测与UI计分:OnTriggerEnter为什么不触发

金币的拾取逻辑看起来是最简单的,但它是新手遇到“代码明明写了却没反应”最多的地方。先把两个东西准备好:金币的 Collider 勾上Is Trigger,球上必须有Rigidbody和 Collider。然后给金币写一个脚本:

public class Coin : MonoBehaviour { void OnTriggerEnter(Collider other) { if (other.CompareTag("Player")) { GameManager.Instance.AddScore(1); Destroy(gameObject); } } }

这段代码跑不起来的常见原因我列一下,基本覆盖了九成情况:

  • Tag 没设或者拼错。CompareTag("Player")里的字符串必须是 Tag 列表里真实存在的,大小写敏感。玩家对象忘了打 Player 标签是头号原因。
  • 双方都没有 Rigidbody。触发检测的规则是:至少有一方带 Rigidbody,且碰撞双方至少一方是 Trigger。如果你的球是纯 Transform 移动、没有刚体,那这两个 Collider 只会物理阻挡,永远不会触发 OnTriggerEnter。
  • Rigidbody 勾了 Is Kinematic。运动学刚体不参与物理模拟,也不触发常规的触发回调(较新版本里可以用OnTriggerEnter配合 Kinematic 的 Contact Pairs 模式,但那是进阶话题),新手阶段保持不勾。
  • 忘了在金币的 Trigger 上同时勾掉Is Trigger之外的东西,比如把 Collider 的Enabled取消了,那它连触发都不会发。
  • 脚本没挂到金币上,或者挂到了金币的子物体上但 Collider 在父物体上,这样回调不会按你预期触发。

至于 UI,Canvas 建好之后默认是 Screen Space - Overlay 模式,这是最省事的模式,UI 永远画在所有 3D 内容之上。Canvas 下面的 Canvas Scaler 组件记得把 UI Scale Mode 设成Scale With Screen Size,Reference Resolution 填 1920×1080,Match 拉到 0.5。这样不管你的窗口拉成什么比例,UI 都会等比缩放,不会出现换个分辨率字就糊成一团的情况。文本建议用 TextMeshPro(菜单里叫 Text - TextMeshPro),第一次创建会弹窗让你 Import TMP Essentials,点一下导入就好,不导入的话文本框是空的。TMP 的优点是放大不糊,因为它用的是有向距离场字体。

分数更新的写法有个小技巧:不要每帧去改文本,而是只在分数变化的时候更新一次,用一个事件或者直接调用 UpdateScore 方法。每帧赋值文本会触发不必要的 UI 重建,虽然小 Demo 感知不到,但这是应该养成的习惯。

4.4 让代码更“专业”的几个特性:SerializeField、Header、平台宏

这段是给已经跑通流程、想让代码看起来不那么“新手味”的人看的。C# 的特性(Attribute)在 Unity 里用得非常多,几个我几乎每个脚本都会用的:

  • [SerializeField]:让私有字段也能在 Inspector 里显示和调参,同时保持外部代码无法访问。比起把字段全设成 public,这个写法封装性更好,也避免了误改。
  • [Header("移动参数")]:在 Inspector 里给一组字段加个标题,参数一多的时候非常救命。
  • [Range(0, 10)]:把 float 变成滑条,调参时手感好很多。
  • [Tooltip("说明文字")]:鼠标悬停在字段上显示提示,协作时价值很高。
  • [RequireComponent(typeof(Rigidbody))]:挂在物体上时自动确保有刚体,少了就自动加,防止忘加组件导致空引用。

还有一个绕不开的话题是平台宏。你写的代码在编辑器里跑得好好的,打包之后可能就报错,因为某些 API 只存在于编辑器。标准做法是用条件编译:

#if UNITY_EDITOR Debug.Log("这段只在编辑器里执行"); #endif #if UNITY_ANDROID // 安卓专属逻辑 #elif UNITY_WEBGL // 网页平台专属逻辑 #endif

常见的宏有UNITY_EDITOR、UNITY_ANDROID、UNITY_IOS、UNITY_WEBGL、UNITY_STANDALONE_WIN。你也可以在 Player Settings → Other Settings → Scripting Define Symbols 里自定义宏,用来做“正式版 / 调试版”的功能开关。这个技巧在做 SDK 接入的时候几乎是标配,我从第一次接触就再也没放下过。

5. 从能跑到像个作品:打包、分辨率与打包后的排错

5.1 打包前的检查清单与Build Settings

编辑器里跑通不代表能交付。点 File → Build Settings 打开打包面板,这个面板里有几件事必须确认,我做成清单:

  1. Scenes In Build 里要有你的场景。这是黑屏的头号原因。很多人下意识以为“我打开的就是这个场景”,但打包器只打列表里勾选的场景,列表是空的就会打出一个只有默认空场景的包,运行起来当然是黑的。
  2. Platform 选对。桌面端选 Windows、Mac、Linux,网页选 WebGL。切换平台会触发一次全量资源重新导入,第一次会慢,耐心等。
  3. Player Settings 里的 Company Name 和 Product Name。这两个会影响存档路径和窗口标题,随意填也行,但别留默认值。
  4. Resolution and Presentation。Default Is Full Screen 建议关掉,默认分辨率设成 1280×720,这样双击运行时是个窗口,方便调试。
  5. Quality 设置。如果打包后画面明显比编辑器差,通常是 Quality Level 被设成了最低档,去 Quality 面板确认一下。

设置好之后点 Build,选一个输出目录(同样英文路径),等进度条走完,你会得到一个 exe 加一个 Data 文件夹。这两个东西必须放在一起才能运行,单独把 exe 拷走是打不开的,这一点新人经常犯错,交付时把 exe 拷到别人电脑上,对方双击报错说缺文件。

5.2 打包后黑屏、报错、体积过大的排查链路

我把打包后最常见的问题和排查顺序整理成一张表,按从上到下的顺序查,效率最高:

现象最可能的原因排查动作
启动就是黑屏场景没加进 Build Settings打开打包面板看列表
黑屏但能听到声音相机被删了或 Camera 组件被禁用检查场景里的 Main Camera
一启动就崩,无窗口脚本里用了编辑器专属 API 没做条件编译用#if UNITY_EDITOR包起来
报“找不到资源”用了Application.dataPath之类的编辑器路径改用 StreamingAssets 或 Resources
某些材质变粉色Shader 被打包剥离掉了在 Graphics 设置里加 Always Included Shaders
打包体积几个 GLibrary 或测试资源被一起打进去了检查 Assets 里有没有冗余素材
WebGL 打开是白屏直接用 file:// 打开,浏览器安全策略拦截起一个本地 HTTP 服务访问

WebGL 那一条特别值得说。很多人打完 WebGL 包,双击 index.html 打开发现是白的,以为是打包失败,其实是被浏览器的同源策略拦住了,必须通过 HTTP 服务来访问。你可以用 Python 一行命令起个临时服务:在输出目录下执行python -m http.server 8000,然后浏览器访问localhost:8000就能看到。

5.3 分辨率与画面适配:Canvas Scaler与多平台差异

分辨率的坑在“编辑器里好看、打包后变形”这个场景下最集中。桌面端还好,玩家可以自己拉窗口;一旦涉及安卓、iOS 或者网页,屏幕比例千奇百怪,从 4:3 到 21:9 都有。前面提到的 Canvas Scaler 设成 Scale With Screen Size 是必须的,Match 值决定缩放时偏向宽度还是高度:0 是完全按宽度适配、1 是按高度适配、0.5 是折中。横屏游戏一般 0.5,竖屏游戏可以偏向高度。

对于 3D 内容本身,相机是透视投影的话,屏幕比例变化会改变可视范围——宽屏能看到更多左右,窄屏能看到更多上下。如果你希望不管什么比例,玩法区域都完整可见,可以在相机上写一小段脚本,根据当前宽高比动态调整 Camera 的 fieldOfView。这是很多商业项目的做法,思路是:先按一个基准比例算出“应该看到多少垂直范围”,然后反推当前的 FOV。这个技巧在竞技类游戏里几乎是标配,因为屏幕比例带来的视野优势是实打实的不公平。

5.4 GameAssembly.dll、混淆与IL2CPP的取舍

打包目录里你会看到一个叫 GameAssembly.dll 的文件,体积还不小。这个文件是干什么的?它只有在使用 IL2CPP 作为脚本后端时才会生成。简单说,Unity 的托管代码有两种打包后端:Mono 会把 C# 代码编译成 Assembly-CSharp.dll,这是一个标准的 .NET 程序集,用现成的反编译工具就能还原出接近源码的东西;IL2CPP 则是先把 C# 代码翻译成 C++,再用平台的原生编译器编译成机器码,最终逻辑就落在 GameAssembly.dll 里。反编译原生代码的难度比反编译 .NET 程序集高得多。

所以如果你的项目对代码保护有要求,打包时就应该把 Scripting Backend 从 Mono 切成 IL2CPP。代价是打包时间明显变长,尤其是大项目,可能从几分钟变成几十分钟,而且构建过程中会生成一堆中间 C++ 文件,占用大量磁盘空间。另一个副作用是 IL2CPP 对反射和动态代码生成的限制更严,某些依赖反射的库需要额外配置。我个人的取舍是:Demo 和内部工具用 Mono,正式发布的产品用 IL2CPP。如果需要更强的保护,还有专门的代码混淆工具,思路是打乱类名和方法名,让反编译结果难以阅读,但混淆配置不当会导致反射失效,属于双刃剑,要留足够的测试时间。

6. 这个Demo能延伸到哪里:几个我实际跟过的方向

6.1 程序化生成:用Mathf.PerlinNoise把平地变成地形

Demo 跑通之后,最自然的第一个扩展就是把那块平面换成像样的地形。Unity 自带Mathf.PerlinNoise(x, y),这是一个柏林噪声函数,输入坐标返回 0 到 1 之间的值,而且相邻位置的返回值是连续变化的,不会跳变,特别适合生成自然起伏。

用法上有两个必须知道的坑。第一,PerlinNoise在整数坐标上的返回值基本恒定在 0.5 附近,所以你不能直接把物体坐标当参数传进去,要先乘一个频率系数(比如 0.1)把采样点拉开。第二,单层噪声看起来很有规律,像海浪一样重复,想更自然要叠加多个八度:低频决定大地形轮廓,高频叠加细节,每层比上一层频率翻倍、振幅减半。这个思路是所有程序化地形的基础,掌握了之后你能拿它生成云、生成洞穴、做随机扰动,用途非常广。

6.2 数字孪生与地图类项目:Cesium for Unity这类方案的思路

如果你做 Demo 不只是为了玩,而是想往数字孪生或者地图可视化方向走,那接下来会接触到把真实地理数据搬进 Unity 的思路。这类方案的核心是:用地理坐标系(经纬度加高程)转换到 Unity 的直角坐标系,然后按相机位置动态加载不同精度的瓦片数据,远了加载粗糙的、近了加载精细的,这样才不至于把整个地球的模型一次性塞进内存。离线场景下还需要自己准备瓦片数据源和坐标转换参数,或者用专门的地图组件来加载本地地图切片。

这条路的技术栈比做游戏 Demo 复杂不少,但底层的东西你已经会了:坐标系转换无非是矩阵运算,动态加载无非是触发检测加资源实例化,性能优化无非是 LOD 和对象池。所以我经常跟人说,不要小看那个滚球 Demo,它练的是通用的基本功。

6.3 硬件交互与串口通信:让Demo连上真实设备

另一个有意思的方向是让 Unity 跟外部设备通信。比如你有一个外接的传感器或者控制板,想通过串口把数据读进 Unity 里驱动画面。C# 本身有System.IO.Ports.SerialPort这个类,但在 Unity 里用它有几个注意事项。

第一,串口读取是阻塞式的,绝对不能在主线程里循环读,否则整个游戏会卡死。正确做法是开一个后台线程专门读串口,把读到的数据塞进一个线程安全的队列里,然后在 Update 里从队列取出来消费。第二,System.IO.Ports的可用性跟脚本后端有关,Mono 后端下支持比较完整,IL2CPP 下在某些平台会有限制,WebGL 平台则完全不支持——因为浏览器里根本不存在串口的原生接口。第三,串口参数必须和硬件一致:波特率、数据位、停止位、校验位,任何一项对不上,读到的就是乱码。我调试这类问题的时候,习惯先用一个通用的串口调试工具确认硬件在正常发数据,再去写 Unity 那边的代码,这样能把问题范围砍掉一半。

6.4 小游戏平台打包与视频播放的坑

最后说一个这两年问得很多的场景:把 Unity 项目发布到小游戏平台。这条路的基本思路是先把项目打成 WebGL 包,再用平台提供的转换工具把 WebGL 产物转换成小游戏能识别的包体结构。听起来只是换了个打包方式,实际上有几个硬性限制要提前知道。

最典型的就是视频播放。Unity 自带的 VideoPlayer 组件在网页和小游戏环境里支持得很有限,很多平台根本跑不起来,标准做法是换成平台提供的媒体组件,通过脚本调用平台接口来播放,UI 层用一个透明的区域占位。这意味着你的视频播放逻辑在不同平台要写两套,最好在项目早期就把播放器抽象成一个接口,桌面端用 VideoPlayer 实现,小游戏端用平台组件实现,上层调用不变。这种事如果等到项目后期才发现,改造成本会很高。

另外就是包体大小和内存。小游戏平台的包体通常有严格限制,首次加载的包越小越好,所以资源要做分包、要做按需加载,贴图要压缩,音频要选低比特率。这些优化在桌面端可有可无,在小游戏端是生死线。我个人的习惯是每做完一个阶段性功能就打一次包看体积,而不是等全部做完再优化,因为体积失控往往是几十个小决定累积的结果,越晚发现越难拆。

这个滚球 Demo 的意义大概就在这里:它是最小的,但每一个变量——物理、相机、UI、打包、平台限制——未来都会被放大到真实的项目里。

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

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

立即咨询