尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity3d游戏开发语言怎么选:C#、C++、Lua与热更新定位

Unity3d游戏开发语言怎么选:C#、C++、Lua与热更新定位 上个星期有个做机械建模的朋友来问我说他想把手里的模型做成小游戏玩一玩搜了一圈资料越搜越乱有人让他学 C#有人说游戏开发得用 C 才专业还有人建议直接上 Lua 做热更新甚至有人推荐他去学 Python 写逻辑。他最后问了我一句——Unity3d 游戏开发到底用哪个语言更好这个问题本身问得不够精确所以才会得到一堆看似矛盾的答案。Unity3d 游戏开发这件事里语言不是一个位置而是好几个位置脚本写逻辑是一个位置引擎内核和原生插件是一个位置资源管线和构建工具是一个位置Shader 与数据层又是另外的位置。不同位置对语言的取舍逻辑完全不同混在一起聊永远吵不出结果。这篇文章我打算按位置来拆把 C#、C、Lua、Python 甚至 SQL 各自该待的地方讲清楚再落到具体项目体量和发布平台上给你一套能直接照着做决定的判断标准。不管你是刚下载完 Unity3d 准备做第一个小游戏的新手还是从 UE5、Godot 那边转过来的人都能找到自己需要的部分。1. 这个问题得拆成三层来问否则永远吵不出结果1.1 脚本逻辑层C# 是唯一答案没有并列选项先给结论在 Unity 里写游戏逻辑C# 是唯一的官方脚本语言。不是最好也不是之一是唯一。Unity 官方早已停止支持 UnityScript也就是那个伪装成 JavaScript 的方言和 Boo现在的 Unity 编辑器里你新建脚本出来的就是.cs文件。为什么这件事没有讨论空间因为脚本语言在引擎里不是自由的它必须满足几个硬条件能被运行时的虚拟机或编译产物支持、能低成本地和引擎内部的 C 对象互操作、能被序列化系统识别并保存在场景文件里、能被编辑器在编译期做反射和检视面板绘制。这四个条件叠加起来能选的方案就只剩一个了。你当然可以在 Unity 里塞别的语言解释器比如自己集成一个 Lua 虚拟机但那是在 C# 之上再叠一层不是用 Lua 替代 C#。我见过不少新人一上手就陷进语言选择的焦虑里实际上这个决策成本为零——写逻辑就是 C#直接开始学就行省下来的时间够你多写两个功能了。1.2 引擎内核与原生插件层C 的地盘但和你日常关系不大那为什么总有人说游戏开发要用 C这话没错只是说的不是同一个位置。Unity 引擎本体、渲染管线、物理引擎、动画系统的核心部分都是 C 写的但那是 Unity 官方工程师的活儿你作为使用者碰不到。你真正可能接触 C 的场景只有两个一是写原生插件Native Plugin比如接一个第三方的图像处理库、音频解码库、或者厂商提供的 SDK需要编译成.dll、.so、.a这类动态库再通过 P/Invoke 让 C# 调用二是深度定制渲染管线用 C 写 Command Buffer 级别的底层扩展。这两个场景在中小项目里出现的概率很低独立开发者做完整整一个项目都未必碰一次。所以对绝大多数人来说C 的价值在于看懂——看懂引擎报的崩溃堆栈、看懂第三方库的头文件、看懂别人给你的插件接口。真要上手写优先级排得很靠后。1.3 工具链与内容管线层Python、Shader、SQL 各管一段再往外一层就是外围了这一层的语言选择才是真正因项目而异的。Shader 语言Unity 用的是 ShaderLab 包 HLSL 片段。做特效、做水面、做描边这部分绕不开但它不是通用编程语言是面向 GPU 的并行计算描述。Python几乎不出现在运行时但大量出现在编辑器扩展、资源批量处理、自动化构建脚本、CI 流程里。比如批量给几百个模型设置导入参数、批量检查材质球引用是否丢失用 Python 加 Unity 的批处理模式跑一遍比手点快几个数量级。SQL跟客户端没关系属于服务端。排行榜、存档、道具发放、订单系统这些数据最终落在数据库里。很多独立开发者做小游戏时不写服务端用的是平台提供的云存储那就更用不上了。Lua这是唯一一个真的侵入脚本层的语言后面单独开一节讲。把这三层摆清楚你会发现争论的各方其实都没错只是在说不同的楼层。下面这张表可以当作速查所在层语言典型用途普通开发者优先级脚本逻辑层C#所有游戏玩法、UI、系统最高必学原生插件层C第三方库封装、平台 SDK低按需原生插件层C轻量插件、嵌入式库低按需渲染层HLSL / ShaderLab材质、特效、后处理中看项目类型工具链层Python资源批处理、自动化构建中团队协作时价值高工具链层Shell / 命令行打包脚本、CI中数据层SQL服务端数据、排行榜看是否自建后端热更新层Lua逻辑热更看项目发行策略2. C# 是怎么一步步坐稳这个位置的从 Mono 到 IL2CPP2.1 Mono 时代留下的两个包袱Unity 早期的脚本运行时用的是 Mono一个开源的 .NET 实现。这个选择在当年非常聪明跨平台、开发效率高、有成熟的 GC、社区庞大。但它给 Unity 带来了两个长期包袱。第一个是语言版本落后。因为绑定了特定版本的 Mono 运行时Unity 长期停留在比较老的语言特性上。你在网上抄到一段用新语法写的代码编译报错很多时候不是你的问题是编辑器版本的问题。这件事直到 Unity 2018 之后绑定 .NET 4.x 等价 API、再到 2021 支持 .NET Standard 2.1才算基本解决。老教程里满天飞的WWW类也是这个时代的产物现在早就被UnityWebRequest取代了。第二个是GC垃圾回收带来的帧率波动。这是 Unity 开发者绕不开的必修课。Mono 用的是保守式 GC堆内存增长到某个阈值就触发一次回收回收过程会让主线程卡顿。你的游戏在编辑器里跑得好好的打包到手机上每隔几十秒就掉一次帧八成就是 GC 干的。这个问题没法根除只能通过减少分配来缓解——后面第 5 节会具体讲怎么治。2.2 IL2CPP 把 C# 提前编译成 C解决了什么iOS 平台不允许运行时动态生成代码JIT而 Mono 的默认模式恰恰依赖 JIT。这个限制逼出了 IL2CPP把 C# 先编译成中间语言 IL再把 IL 转换成 C 源码最后由平台的原生编译器编译成机器码。整个过程叫 AOT提前编译。IL2CPP 带来了三个实际变化。性能上AOT 编译后的代码比解释执行或 JIT 更稳定尤其是数值计算和循环密集的逻辑兼容性上能覆盖那些禁止 JIT 的平台安全上比起直接发布的托管程序集逆向门槛高了不少。代价是构建时间长、包体略增而且 C 侧会生成大量代码编译一次动辄十几分钟。但 IL2CPP 有个很关键的副作用你得记住反射和动态代码生成会受限。在某些 AOT 平台上System.Reflection.Emit用不了Activator.CreateInstance依赖的无参构造在某些裁剪配置下会被砍掉。这就是为什么很多依赖反射的序列化库、依赖动态代理的框架在编辑器里跑得好好的打包到真机上直接报错。我后面会讲一个真实的排错过程。2.3 UnityScript 和 Boo 的退场给后来者的提示Unity 早期为了降低门槛支持了 UnityScript语法像 JavaScript和 Boo语法像 Python。这两个语言当年确实吸引了一批人但最终还是被砍掉了时间点在 Unity 2017 系列之后。原因不复杂维护三套运行时的成本远超收益。三套语言意味着三份文档、三份示例、三套调试工具、三个生态。更麻烦的是跨语言互操作——UnityScript 想调用 C# 写的类C# 想回调 UnityScript 的方法中间需要一层桥接性能和心智负担都不划算。而社区资源、第三方插件、招聘市场上的人都压倒性地集中在 C# 上。这件事的逻辑今天依然有效选语言的时候生态规模比语言本身的美感重要得多。你在选技术栈时也该用这个标准而不是看哪个语法写着更舒服。3. 把 C、Lua、Python、JS 放到正确的格子里的对照分析3.1 C 不是 Unity 的脚本语言但它是必须能看懂的语言C 和 C# 的区别是很多人问的问题我把它压缩成一句话C 给你控制权C# 给你交付速度。C 允许你手动管理内存、直接操作指针、精确控制对象布局和虚表所以性能天花板高适合引擎、物理、图形这类对每一毫秒较真的场景。代价是你要自己负责释放内存、处理悬垂指针、应对复杂的编译链接问题。C# 则把内存管理交给 GC语法更简洁工具链更友好开发迭代速度通常是 C 的数倍——对一个功能频繁改动的游戏项目来说这个差距是决定性的。在 Unity 项目里C 的真实出场机会有三种接入平台 SDK比如某些厂商的支付、登录、语音能力只提供 C 接口、集成已有的高性能算法库图像识别、物理仿真、以及性能瓶颈处的原生加速。我做过的项目里唯一一次真正写 C 是为了接一个音频处理库因为 C# 侧的实时音频处理性能不够。除此之外全部是 C#。3.2 Lua 与热更新曾经的刚需和现在的处境Lua 在游戏行业的名声几乎全来自热更新三个字。逻辑用 Lua 写打包进资源包运行时下载新版本脚本替换就能在不重新发版的情况下修 bug、调数值、上活动。这对发行节奏紧张的手游来说价值巨大。Unity 生态里比较知名的 Lua 方案有 XLua、ToLua 这一类本质都是把 Lua 虚拟机嵌进 Unity通过绑定把 C# 的接口暴露给 Lua实现逻辑在 Lua、引擎能力在 C#的分工。但你要注意两件事。第一热更新的合规边界在变各大平台对动态下发代码的限制越来越明确很多团队现在改成了只热更配置和数据不热更逻辑代码。第二Lua 的性能和调试体验都不如 C#。Lua 是动态类型缺少编译期检查一个拼错的字段名要到运行时才报错跨语言调用的开销也不小在每帧调用上千次的逻辑里会明显拖后腿。所以现在的普遍做法是核心玩法用 C#配置和数值走数据表把热更新的需求压到数据层面而不是把整套逻辑搬到 Lua。这个转变很多人没跟上还在按几年前的经验做技术选型。3.3 Python、JS、SQL 在游戏项目里的真实用途Python 在 Unity 项目里的价值被严重低估了。举个具体场景美术给过来三百个模型需要统一设置缩放因子、关闭导入动画、指定材质球、生成对应的预制体。手动做要一整天用 C# 写个 EditorWindow 要半天用 Python 加批处理模式大概一小时能搞定而且下次还能复用。JavaScript 则主要出现在两个地方一是网页端工具比如给策划做配置表编辑器二是 Node.js 做后端服务配合小程序游戏或者轻量级联网功能。微信小程序游戏开发这个方向近年挺热如果你走的是这条路JS/TS 的优先级会大幅上升因为小游戏环境的原生语言就是 JavaScript 系。SQL 属于服务端范畴。做一个带排行榜、带好友系统的游戏后端数据基本都会落在关系型数据库里。独立开发者如果不上自建后端用云开发提供的数据存储能力那 SQL 也可以跳过。3.4 选型对照表与判断标准下面这张表是我自己整理用的判断清单把常见场景和对应语言的关系列清楚了你想做的事该用的语言别踩的坑写游戏玩法、UI、关卡逻辑C#别去折腾替代方案浪费时间接入平台 SDK 或高性能库C / C注意平台架构32/64 位、注意 ABI 兼容修 bug 不发新版本数据热更优先Lua 次之先确认发行平台的动态代码限制批量处理资源、自动化打包Python / Shell用 Unity 批处理模式别用 GUI 脚本做小程序游戏JavaScript / TypeScript注意包体上限和首屏加载时间做联网排行榜、存档SQL服务端别把服务端逻辑写到客户端里写 Shader 特效HLSL ShaderLab不同渲染管线Built-in / URP写法不通用判断的核心就一句话先确定这段东西跑在哪个进程、哪台机器上再选语言。跑在客户端脚本层就是 C#跑在服务端就是服务端那套跑在 GPU 上就是 Shader 语言。位置定死了语言基本没得选也不需要纠结。4. 落到具体项目上不同体量、不同平台的真实选择4.1 原型和小体量独立项目别做任何多余的技术决策如果你在做第一个小游戏或者一个只有几个人的独立项目我的建议非常直接全部 C#一行别的语言都不要碰。原因很简单小项目最大的敌人不是性能是做不完。任何一层额外的技术栈都会成倍增加你的学习成本和调试成本。有团队为了以后好扩展上来就搭 Lua 框架结果连核心玩法都没跑通时间全耗在绑定和调试上了。所谓以后好扩展在项目没上线之前都是幻觉。这个阶段你真正该花心思的地方是玩法循环能不能跑起来、操作手感对不对、第一分钟能不能留住人。这些和语言选择一点关系都没有。4.2 中大型商业手游团队语言分工是组织问题不是技术问题到了几十人规模的团队语言选择就变成了协作分工问题答案也会变得复杂一些。典型配置是客户端逻辑 C#大部分人、数值与配置走数据表、活动逻辑视发行需求可能走 Lua 热更、原生插件由专门的客户端底层同学用 C 写、服务端独立团队用后端语言加 SQL。这时候用哪个语言其实取决于谁来做、什么时候改、改完要不要发版这三个问题。我在这种项目里观察到的一个规律跨语言边界越多出问题的概率越高。每加一层 Lua 绑定就多一类编辑器里对、真机上错的诡异 bug。所以成熟团队的做法通常是把跨语言接口压到最小——Lua 只暴露少量稳定的高层接口内部实现全在 C#而不是把整套逻辑搬过去。4.3 小程序游戏与多端发布场景技术栈被迫改变如果你的目标是微信小程序游戏开发或者类似的轻量分发渠道技术栈会有明显偏移。小游戏环境的运行语言是 JavaScript 系Unity 虽然提供了导出小游戏的方案把 C# 编译后再跑在特定运行时上但包体和首屏启动时间是硬约束。很多团队会在这类项目上直接用 JS/TS 重写核心逻辑或者用轻量引擎做而不是把完整的 Unity 项目硬塞进去。如果你同时想覆盖 App 端和小游戏端一个比较务实的做法是核心数值和玩法规则抽出成与语言无关的配置两端各自实现表现层。这样规则只有一份表现可以有多种实现改数值不改代码。4.4 从 UE5、Godot 转过来的人最容易踩的认知差现在有不少人是先接触 UE5 或者 Godot再回头用 Unity 的。这两条路转过来有几个认知差异必须纠正。从UE5过来的最大的不适应是蓝图思维没地方落。UE5 用可视化蓝图串联逻辑很多人在那上面养成了不写代码也能做游戏的习惯。Unity 里虽然有可视化脚本插件但主流依然是写 C#你得把蓝图里那种连线的直觉翻译成代码的思维。另外 UE5 的策略游戏开发实例教程里常见的那种组件化搭建方式在 Unity 里对应的是组件加脚本的组合思路相通但 API 完全不同别硬套。从Godot过来的则相反Godot 用 GDScript语法很像 Python写起来轻快。转 Unity 之后会觉得 C# 啰嗦——类型声明、大括号、泛型视觉上确实重。但换来的是静态类型检查、更好的重构工具、更强的性能。这个交换在大项目上绝对是赚的只是需要一两周适应期。5. C# 要练到什么程度才算够用5.1 语法之外的必备三件套生命周期、协程、序列化C# 语法本身不难有 C 语言基础的人两三天就能上手。真正卡住新手的是 Unity 特有的三样东西。第一是脚本生命周期。Awake、OnEnable、Start、Update、FixedUpdate、LateUpdate、OnDisable、OnDestroy的执行顺序和调用时机这个必须刻进肌肉记忆里。我见过最常见的 bug 就是在Awake里访问还没初始化的对象或者在Update里读物理刚体位置导致画面抖动。记住一个原则初始化依赖关系用Awake需要其他对象已经就位用Start读物理相关的位置用FixedUpdate之后再处理。第二是协程。IEnumerator加yield return这套写法是 Unity 里做延时、分帧、异步流程的主力。它的本质是一个可以被引擎逐帧推进的状态机不是真正的多线程。所以协程里不适合做重计算否则一样会卡主线程。遇到需要在后台跑的计算得用 Job System 或者自己开线程加主线程回投。第三是序列化。[SerializeField]、[Serializable]、ScriptableObject这套机制决定了你的数据怎么在编辑器里配置、怎么存进场景和预制体。它的规则比较反直觉比如Dictionary默认不能被序列化、接口字段不能被序列化、泛型字段支持有限。不理解这套规则你会不停遇到我在面板上填了值运行时怎么变成空了这种问题。5.2 和性能强相关的使用习惯这部分是真正拉开水平差距的地方。Unity 的性能问题里内存分配导致的 GC 卡顿占了很大比例。下面几个习惯必须养成。避免在Update里做字符串拼接。字符串在 C# 里是不可变对象每次拼接都会产生新的对象和垃圾。Score: score这种写法写在每帧执行的代码里等于每帧制造一个垃圾对象。要么缓存起来定期更新要么用StringBuilder。慎用foreach遍历某些集合。老版本 Mono 的foreach在遍历ListT时会产生枚举器对象虽然现在多数情况已经被优化掉了但在遍历字典或者接口类型时仍可能装箱。在每帧执行的热路径上我习惯直接用for循环。警惕装箱。把值类型赋给object或者接口类型会发生装箱。Debug.Log(123)这种写法在正式版本里即使被条件编译去掉在开发版本里也会产生装箱。热路径上避免一切不必要的装箱。缓存组件引用。GetComponentT()是有成本的绝对不要在Update里调用。标准做法是在Awake里取一次存到字段里后面直接用。坏习惯后果改法Update里GetComponent每帧查找组件CPU 飙升Awake缓存到字段每帧字符串拼接持续产生垃圾GC 频繁缓存 /StringBuilder每帧new数组或列表大量小对象分配复用容器Clear而非重建Find系列 API 每帧调用全场景遍历用引用或事件传递闭包捕获外部变量隐式分配提取成方法或静态函数5.3 从能跑到能维护工程习惯与代码组织能写出跑得动的代码和能写出别人愿意接手的代码中间差了大约两三年经验。我自己的几条硬规矩一个脚本只干一件事名字能说清它干什么数据与表现分离数值配置放ScriptableObject而不是硬编码在脚本里公共接口尽量窄别把内部字段全公开出去事件优先于轮询能用Action回调解决的就别每帧检查状态。还有一条特别实用把临时调试代码标记出来。项目中总有Debug.Log或者写死的测试路径我习惯用统一的注释标记// TODO-TEMP上线前全局搜索一遍清掉。这个小习惯帮我省过好几次线上事故。6. 几个我实际踩过并记录下来的坑6.1 每帧分配导致的卡顿几个隐蔽的来源早年做过一个弹幕类的小项目在编辑器里跑得挺顺上手机之后每十几秒固定卡一下。打开 Profiler 一看GC Alloc 那一栏每帧都有几百字节的持续分配累积到阈值就触发回收。排查过程挺费劲。第一个来源是foreach遍历一个Dictionary我当时以为没问题实际上在老版本运行时里泛型字典的枚举器在特定情况下会装箱。换成KeyValuePair数组缓存后消失了。第二个来源是 LINQlist.Where(...).ToList()这种写法写在每次刷怪的路径上一次调用产生两三个临时对象。把 LINQ 换成普通循环后分配量直接归零。这段经历给我的教训是在热路径上任何看起来优雅的抽象都要先怀疑一遍。LINQ、foreach、闭包、匿名类型这些在业务代码里非常好用的东西放到每帧执行的地方就可能是性能杀手。判断标准很简单——看它在不在Update、在不在被频繁调用的回调里。不在的话随便用在的话老实写循环。6.2 编辑器里好好的打包后崩掉第二个坑更阴险。项目里用了一套基于反射的配置加载机制读取配置表根据字段名反射赋值到对应的数据类上。编辑器里完美运行打出来的包一进入配置加载就直接闪退。排查思路是这样的。先看真机日志日志里有明确异常大意是某个类型在当前平台上不可用。这里就指向了 AOT 限制——反射创建泛型类型在某些平台上需要提前生成代码。再确认构建配置发现代码裁剪Managed Stripping开了较高等级把只被反射调用、没有被静态代码引用的类给裁掉了。定位之后有三种修法我按推荐程度排首选是改掉反射用显式的工厂方法或者配置映射表来加载虽然代码量大一点但完全不依赖运行时动态特性最稳次选是保留反射但加链接保护在配置里声明不要裁掉相关程序集缺点是要人工维护清单容易漏最后是降低裁剪等级代价是包体变大。这个坑的本质是编辑器环境的能力和真机环境的能力不对等。凡是涉及反射、动态生成、序列化、程序集加载的功能都必须专门在真机上验证一次不能只在编辑器里跑通就认为没问题。6.3 外部模型资源导入后的连锁反应还有个坑跟美术资源有关。项目里导入了一批外部制作的模型其中有从机械建模软件导出的高精度网格面数相当可观。导入之后画面直接掉帧Draw Call 数量暴涨。原因有几层。第一是网格面数远超实际需要在手机上渲染高面数模型非常不划算。第二是材质球太多导入时每个部件都带一个独立材质导致合批失败。第三是导入设置全默认缩放、法线、动画、混合形状全都按最大兼容性处理内存占用高得离谱。解决过程分三步。先是统一导入配置用脚本批量设置——关掉不需要的动画、压缩纹理、按比例缩放、开启动态合批相关设置。然后是合并材质把同类的部件合成一个材质球用图集和顶点色区分细节Draw Call 从几十降到个位数。最后是减面用建模软件或者减面工具把模型压到合理范围远处物体加上 LOD。这里有个通用原则值得记下来导入设置本身就是性能优化的一部分。很多人只关注运行时优化忽略了资源导入阶段就能解决掉一大半问题。批量设置导入参数这件事用脚本来做比手点高效得多通常几十行编辑器脚本就能覆盖整个项目的资源规范。我自己后来养成了一个习惯每个项目开工第一件事就是写一份资源导入规范脚本把纹理尺寸、压缩格式、模型缩放、动画导入选项全部定下来新人拿到项目直接跑一遍能省掉后面无数次的返工。如果你现在还在纠结Unity3d 游戏开发用哪个语言更好我的建议是把这个问题拆成两半写逻辑这件事答案就是 C#不用再花时间比较直接开始写第一个能跑起来的小游戏等到你真的遇到性能瓶颈、遇到原生 SDK 对接、遇到需要热更新的发行需求那些具体的语言自然会被项目拽进来。技术选型是需要具体场景支撑的在没有项目的阶段做选型选出来的多半是想象中的最优解。
返回列表