
这期笔记有点特别没有按我原定的计划写从零搭一个UI框架或者研究一下URP的渲染管线这类相对系统和完整的内容。因为最近两周实际动手的过程完全是碎片化的今天补了技能指示器明天调战斗相机的跟随手感后天又被一个字符串滚轮的动画给卡住中间还穿插了给项目切分版本、处理图文混排的杂活。这些事情单拎出来都不大但组合到一起恰好覆盖了从能跑到好用之间的那些细节。所以这一篇unity学习笔记#2就做成一个杂录把我近期踩过、填过、想明白的八个关键点按实战顺序整理出来。既有完整可复制的代码思路也有排查问题和取舍折中的过程。如果你也在用Unity做战斗系统或者交互界面这几段内容大概率能帮你省掉几个下午的试错时间。1. 技能指示器的完整实现思路从一条线到闭合范围战斗系统里技能指示器skill attack indicators其实是个很考验细节的东西。玩家按住技能键、拖动鼠标调整方向或者范围然后松手释放这个过程里指示器要实时响应、边界要清晰、视觉上又不能遮挡战场。我这次做的版本从简单的直线弹道提示一路扩展到了圆形区域和扇形区域就把这几种类型的实现思路一起梳理了。1.1 指示器的基本类型与适用场景先明确一点市面上大多数动作类游戏里的技能指示器可以粗略分成三类直线型弹道型一条线从角色脚下出发延伸到某个方向用于冲锋、射线攻击等。实现核心就是Length和Direction的实时更新。圆形区域型以自身为圆心在角色周围画一个半径固定的圆表示范围伤害。需要实时跟随角色位置并绘制出一个闭合的圆环或者带填充的圆面。扇形/锥形型角色面朝方向的一个夹角范围常用于AOE范围伤害。这比前两种复杂在要计算方向偏移量和角度边界。从实现难度上递增而从视觉反馈的效果上也是从给个方向感到明确告诉玩家打击区域的递进。我这次先用线型做了最基础的逻辑验证之后扩展到圆形区域最后才做的扇形。下面是我在GitHub上整理的一份通用思路的关键版本注意这里的处理是基于你的角色通过鼠标位置控制转向即鼠标控制角色面朝技能指示器跟随角色面朝同时也支持直接在世界空间给一个方向。1.2 直线型指示器的完整逻辑要在场景里显示一条线最直接的方式是使用LineRenderer。相比用Quad去缩放拉伸LineRenderer的好处是天然支持多点渲染可以做射线穿透表现材质和宽度调整顺手多个线段拼接方便。核心代码结构如下代码整理自近期项目已简化部分业务逻辑public class LinearIndicator : MonoBehaviour { public GameObject originTransform; // 指示器起始点例如角色手上的一个空物体 public LineRenderer lineRenderer; public float maxRange 8f; public float width 0.1f; private Camera mainCamera; void Awake() { mainCamera Camera.main; lineRenderer.positionCount 2; lineRenderer.startWidth width; lineRenderer.endWidth width; } void LateUpdate() { if (mainCamera null) return; // 常用做法从摄像机发出射线计算鼠标在世界平面上的落点 Plane groundPlane new Plane(Vector3.up, originTransform.position); Ray ray mainCamera.ScreenPointToRay(Input.mousePosition); float distance; Vector3 targetPosition originTransform.position originTransform.forward * maxRange; if (groundPlane.Raycast(ray, out distance)) { Vector3 pointerWorldPos ray.GetPoint(distance); Vector3 direction pointerWorldPos - originTransform.position; direction.y 0f; if (direction.sqrMagnitude 0.001f) { targetPosition originTransform.position direction.normalized * maxRange; originTransform.rotation Quaternion.LookRotation(direction); } } lineRenderer.SetPosition(0, originTransform.position Vector3.up * 0.02f); lineRenderer.SetPosition(1, targetPosition Vector3.up * 0.02f); } }这里有一个关键细节为了让指示器不被地面Z-fighting深度冲突导致的闪烁我把线的高度抬升了0.02个单位。这是一个很实用的小技巧不然你会在某些地形上看到线条疯狂闪烁。还有一个问题如果你的角色不是用鼠标控制朝向而是用摇杆/键盘的当前输入方向那么LineRenderer的两个端点来源就不是鼠标投影点而是角色朝向的forward或当前输入方向。做的时候先想清楚你的战斗模式是哪种再选数据来源。1.3 圆与扇形的可视化构造圆形区域指示器我推荐用Unity的LineRenderer去画一个圆环而不是用Unity自带的Sprite圆环图片硬缩放。画圆环的好处是不依赖贴图可以运行时修改半径可以通过分段数控制视觉平滑度支持添加第二个环形成范围边界内部高亮的组合。圆环公式很简单public void DrawCircle(float radius, Vector3 center, int segments 64) { lineRenderer.positionCount segments; for (int i 0; i segments; i) { float angle (float)i / segments * Mathf.PI * 2f; float x Mathf.Cos(angle) * radius; float z Mathf.Sin(angle) * radius; lineRenderer.SetPosition(i, center new Vector3(x, 0.02f, z)); } }扇形就是在圆环的基础上加入一个偏移角度和一个展开角度。比如角色当前朝向是Y轴旋转角为yaw扇形的中心线就是yaw展开角是60度那么绘制范围就是从yaw - 30度到yaw 30度。public void DrawSector(float radius, float yaw, float arcAngle, Vector3 center, int segments 32) { // arcAngle 是扇形总角度 float startAngle yaw - arcAngle * 0.5f; float currentAngle yaw arcAngle * 0.5f; lineRenderer.positionCount segments 2; lineRenderer.SetPosition(0, center Vector3.up * 0.02f); // 圆心 for (int i 0; i segments; i) { float t (float)i / segments; float angle startAngle (currentAngle - startAngle) * t; float rad angle * Mathf.Deg2Rad; float x Mathf.Cos(rad) * radius; float z Mathf.Sin(rad) * radius; lineRenderer.SetPosition(i 1, center new Vector3(x, 0.02f, z)); } }注意我把扇形原点放在了圆心这样形成的是一个封闭的锥形区域线框视觉上很干净告诉玩家你将从原点向当前面朝方向打出锥形冲击。1.4 组合使用一种多段式指示器的方案很多时候单个指示器是不够的。例如技能是冲锋一段距离后在终点产生爆炸——前面是直线末尾是一个圆。我最开始的实现是两个独立的LineRenderer物体分别更新位置后来觉得维护麻烦改成了一条LineRenderer上做两种段前一段画线从角色到目标点目标点的位置用一个环表示。实现很简单让LineRenderer的positionCount64264个圆环点2条线段的端点一个闭合点先画完圆的部分再画直线部分。虽然逻辑上稍微复杂了一点但减少了物体的个数也减少了一次Update的消耗。经验补充LineRenderer的材质使用Sprites/Default或Particles/Standard Unlit这类Unlit材质否则场景灯光会影响线的明暗表现导致指示器在不同光照区域下清晰度不一致。这个细节不处理玩家会觉得指示器时亮时暗。2. 战斗/交互中LookAt相关的坑与正确用法在战斗系统的开发里无论你有没有做完整的移动逻辑都避不开LookAt这个最基础、也最容易用错的旋转函数。2.1 为什么对目标LookAt后模型是躺倒的这个问题在开发社区里几乎是周经问题。用NPC或者敌人举例我把一个敌人的Transform朝向用上面代码里的方式LookAt玩家结果发现该敌人的头部朝天身体像晕倒一样转了过来。这通常是由两个原因叠加造成的模型的根节点Root节点的默认朝向不是Z轴而是Y轴或者X轴比如一些从Maya导出的角色默认骨骼朝向是Y轴朝上。LookAt旋转时没有指定upVectorUnity默认使用Vector3.up0,1,0但对于本身模型坐标系已经旋转过的物体来说这个默认up会引发连带旋转。解决方案在模型导入的模型Model面板中把Bone Naming、Rotation这些调整一下是不太建议的因为你还要考虑美术同事那边的导出更通用的是在外面包一个空物体以空物体作为逻辑朝向载体自己调整角色模型对空物体的局部偏移。实操上创建一个空物体作为战斗单位的Root把模型作为Root的子物体调整模型的局部Rotation让模型的眼睛/正面看着Root的Z轴正方向脚本只用对Root做LookAt。看起来多了一层物体但换来的是美术模型任意旋转逻辑不受影响。2.2 LookAt的旋转模式到底选哪个LookAt有LookRotation与Transform.LookAt两种常见方式。它们的区别Transform.LookAt(WorldPosition)直接修改物体的Rotation使其Z轴指向目标。Quaternion.LookRotation(forwardDir, upVector)返回一个旋转值你可以平滑插值到这个旋转值也可以单独对某个轴忽略。在做技能指示器时我既需要角色转身又需要指示器跟着方向转这时候如果用Transform.LookAt会因为模型的根节点旋转坐标系不对而出现奇怪的结果改用Quaternion.LookRotation之后配合Slerp做平滑转身手感立刻正常。具体到忽略竖直方向偏差的需求比如敌人只在水平面上转身朝向玩家但是玩家如果站在高处它不需要仰头Vector3 targetDirection player.position - transform.position; targetDirection.y 0f; Quaternion targetRotation Quaternion.LookRotation(targetDirection); transform.rotation Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotateSpeed);2.3 对于2D视角或俯视角的LookAt特例如果你的项目是一个俯视角的2D游戏角色往四面八方走朝向就是移动方向LookAt并不适用应该用Mathf.Atan2算出角度或者直接用Quaternion.Euler。因为LookAt默认会尝试让物体的Z轴朝向目标在2D世界里Z轴通常垂直于屏幕。这个我犯过不止一次大家留意下。3. 摄像机跟随的两种模式与看不见的后患摄像机跟随玩家是另一个看着简单、做起来极其讲究的东西。过去的项目里我用过两种经典方案固定位置直Follow和第三人称旋转视角的SmoothFollow。这次一并梳理掉。3.1 直接Follow的致命问题视野穿墙与抖动最直接的做法就是把摄像机设置成Player的子物体俗称挂相机或者写一段LateUpdate里每一帧把camera.transform.position player.transform.position offset;。这两种方式有一个共同的隐患当角色转身或者做出旋转动作时如果摄像机是跟随僵尸视角第一人称倒还好如果是第三人称从身后斜上方看角色相机的旋转方向和角色的旋转方向耦合会导致穿帮甚至晕3D。更常见的问题是穿模。相机在墙体后面直接跟随角色的位置会导致屏幕里出现大面积的遮挡、黑屏或看到角色胸腔内部。我在这期项目里处理这个问题的办法是不把相机直接设为Player子物体在LateUpdate中先计算出期望位置再检测期望位置和玩家位置之间是否有遮挡Raycast或者SphereCast如果有遮挡就沿视线方向把相机拉近到碰撞点前方。这个方案的好处是无论场景里墙体多奇葩相机始终能保证你和玩家之间视线通畅。Vector3 desiredPosition player.position offset; Vector3 direction desiredPosition - player.position; float distance direction.magnitude; direction.Normalize(); RaycastHit hit; if (Physics.Raycast(player.position, direction, out hit, distance, obstacleMask)) { float adjustedDistance hit.distance - cameraNearClipRadius; desiredPosition player.position direction * adjustedDistance; } transform.position Vector3.Lerp(transform.position, desiredPosition, smoothFactor); transform.LookAt(player.position lookAtOffset);一定要留意cameraNearClipRadius这个值——我最初没有加这个偏移量导致相机贴近墙面时近裁剪面穿进墙里墙内表面纹理出现在屏幕边缘看起来就像相机出轨到墙后面了。3.2 我的SmoothFollow通用模板如果做的是类似越肩视角的战斗游戏可以参考下面这套。它同时处理了跟随目标旋转和平滑延迟public class ThirdPersonCamera : MonoBehaviour { public Transform target; public float distance 5f; public float height 2f; public float rotationSpeed 3f; public float smoothTime 0.15f; private Vector3 velocity Vector3.zero; void LateUpdate() { float mouseX Input.GetAxis(Mouse X) * rotationSpeed; // 以目标为原点旋转相机 Quaternion yawRotation Quaternion.AngleAxis(mouseX, Vector3.up); Vector3 desiredPosition target.position - yawRotation * Vector3.forward * distance; desiredPosition.y target.position.y height; // 平滑移动 transform.position Vector3.SmoothDamp(transform.position, desiredPosition, ref velocity, smoothTime); // 始终看向目标头部高度 transform.LookAt(target.position Vector3.up * 1.5f); } }注意这个模板里的鼠标X轴控制的是绕着目标旋转所以相机位移不是平移而是沿着以目标为圆心的圆弧移动。这个转向方式会影响之后的指示器反算把鼠标世界点映射到角色朝向如果你后面要做技能指示器旋转参照系一定要和战斗逻辑一致否则就会出现指示器指着A技能打到B这种毁灭性的体验问题。3.3 相机抖动一个长期存在的系统性问题相机抖动原因说穿了就两类数值抖动刚体高频碰撞、浮点精度更新顺序问题——如果相机跟随逻辑放在Update而角色移动放在FixedUpdate或LateUpdate里就会导致每帧的目标位置滞后一拍出现微小的颤抖。我的排查方法是将碰撞检测和物理运动的问题先固定住再把相机跟随从Update迁移到LateUpdate里观察是否变好。这之后如果还有抖动基本就是LODLevel of Detail即模型细节分级或者地形的碰撞点抖动需要从根上处理。关于Unity的SmoothDamp我也提醒一下它的smoothTime如果设置得太小比如0.01反而会出现过冲现象看起来像抖了一下一定用0.1以上的值。4. UI组件的实用工具箱图文混排、滚轮和逐渐消失我最近在打磨游戏内的HUD和战斗反馈UI其中三个需求比较有代表性图文混排、数字滚轮效果、脚本控制的逐渐消失。都是短小但坑多的东西。4.1 图文混排的三种实现路线Unity原生的Text旧版是不支持图文混排的TextMeshProTMP是通过sprite资产来实现的。文本里用形如sprite nameicon的标记插入图片配合TMP_SpriteAsset就可以。但有一个问题美术给的图标资源如果既有UI图集又有普通图片要手动去制作sprite asset比较麻烦。我的处理是如果是少量表情/图标直接用TMP的Sprite Asset生成器选择一个图集把相关Sprite导入即可。如果是战斗中飘字、伤害数字这种需要动态生成的我建议用Text图标分离的方案即把文本拆成多个Text子物体和Image子物体用HorizontalLayoutGroup去排版。这个方案的灵活性比TMP的sprite标签更高可以单独控制图标的尺寸、缩放动画和颜色。另有一种更轻量的方案用Rich Text的quad标签插入一个Quad四边形但那个需要配合Material不太直观我很少用。4.2 UI数字滚轮效果一个完整的轮盘组件UI数字滚轮常见的用途是设置面板里的数值选择器、老虎机式的随机数字反馈、关卡选择页的数字页码。它和纯数字滚动不同纯数字滚动通常用一个TMP文本配合简单动画比如数字刷新、透明度回落就能做而滚轮更像一个3D转盘或者2D位移的轮带。我的实现思路准备一个Mask遮罩组件限定可视范围比如一个高40像素的正方形在Mask下面挂一根竖直排列的Content节点Content下动态生成若干个数字Label比如0~9通过Content节点的anchoredPosition.y来控制数字上下移动每个数字项高度一致间距为itemHeight要做滚轮惯性的话就是给Content一个VerticalVelocity然后每一帧根据速度衰减来更新anchoredPosition。关键代码简化版public void UpdateWheel(float delta) { scrollVelocity * (1f - damping * Time.deltaTime); float currentPos contentRect.anchoredPosition.y; currentPos scrollVelocity * Time.deltaTime; // 模运算对齐到最近的一个item上 float normalized currentPos % itemHeight; if (normalized itemHeight * 0.5f) currentPos (itemHeight - normalized); else currentPos - normalized; contentRect.anchoredPosition new Vector2(contentRect.anchoredPosition.x, currentPos); }注意对齐逻辑里要处理好负数取模的问题。在C#里-1 % 5 -1不像数学上的余数都是非负的。我加了一个if分支或者用Mathf.Repeat去处理。这个很容易踩坑。还有一个经验不要用GameObject.SetActive去切换数字项的显示/隐藏因为频繁的SetActive会导致GC开销和ui重建。正确做法是显示时把CanvasGroup.alpha设为1、把RaycastTarget设为false隐藏时alpha设为0这样只是透明度变化不会触发UI重建的大开销。4.3 脚本控制逐渐消失FadeIn/FadeOut的正确姿势脚本控制逐渐消失这个需求听着简单但如果你用了普通的CanvasGroup.alpha配合DOTween或LeanTween去做Tween动画会发现一个坑当CanvasGroup.alpha从1降到0鼠标还是能点到底下看不见的按钮因为CanvasGroup的interactable和blocksRaycasts并没有随alpha自动变化。正确做法是在动画回调里设置canvasGroup.interactable false; canvasGroup.blocksRaycasts false;反过来淡入的时候要先把这两个改回true再播放动画。这样才不会出现界面隐身后还遮挡后面点击区域的BUG。顺带一提如果是做受到伤害的红色闪烁这类效果不需要完整的CanvasGroup直接在Image的Color上做Alpha淡出即可性能更好。只有整个面板的整体显隐才推荐CanvasGroup。5. 用宏定义和版本管理解决同一个项目多平台多版本的配置问题做Unity开发一定会碰到多版本、多平台的分支管理需求。我这次在项目中实践了宏定义配合版本控制的处理方式——这在游戏的正式开发里几乎是刚需。5.1 Unity宏定义的基本用法与坑Unity支持通过Player Settings - Scripting Define Symbols添加自定义宏在代码中用#if UNITY_EDITOR、#if UNITY_ANDROID等预处理指令来区分平台。但更实用的场景是区分版本比如#if DEBUG_VERSION Debug.Log(自定义调试版本); #endif这里有一个很常见的坑宏定义修改后Unity并不会总是立即重新编译所有脚本有时候会出现明明加了宏代码却没生效。解决的办法有修改宏之后重开Unity尤其当你只是往Scripting Define Symbols里加了字符而没有点击应用时或者执行一次菜单栏Assets - Reimport让所有脚本重新编译。另一个坑是宏定义字符串的格式多个宏之间用分号;分隔而不是逗号。在Player Settings界面里你可能下意识输入DEBUG_VERSION;BUILD_APP这是对的但如果你通过脚本修改PlayerSettings.SetScriptingDefineSymbols需要传入的是一个用分号连接的字符串。例如string defineSymbols DEBUG_VERSION;BUILD_APP; PlayerSettings.SetScriptingDefineSymbolsForGroup(BuildTargetGroup.Android, defineSymbols);5.2 版本宏配合资源加载我在项目里的具体用法是在测试版本和正式版本之间切换不同的资源加载路径或是否开启广告SDK等第三方依赖。#if RELEASE_VERSION LoadConfigFromRemote(); #else LoadConfigFromLocal(); #endif这种做法在UWA优化和插件接入时会很有用。举个例子很多第三方SDK注意仅技术讨论不涉及具体产品在测试阶段会打大量Log而正式包是不需要的。通过宏定义可以做到打包时统一屏蔽所有Debug.Log而不需要每处代码去删。关于宏定义还有一个更进阶的心得不要在代码里到处用#if尽量把宏判断收敛到一个小型的环境配置类里面对外暴露统一的静态属性例如public class BuildConfig { public static bool IsRelease { get { #if RELEASE_VERSION return true; #else return false; #endif } } }这样业务代码只需要判断BuildConfig.IsRelease维护起来轻松得多。5.3 Git中LF/CRLF告警的正确处理既然提到了版本管理就顺带把那个在Unity项目里几乎人人都见过的Git告警说掉warning: LF will be replaced by CRLF。这个告警其实跟Unity没有直接关系而是Windows系统下Git的core.autocrlf配置导致的。它本身不会让你的项目坏掉但如果你不管可能出现文件行尾符混用的问题——尤其当同事用Mac、你用Windows的时候。解决方案是在仓库根目录添加一个.gitattributes文件显式指定各类文件的换行方式* textauto *.cs text eollf *.shader text eollf *.json text eollf *.asset text eollf *.prefab text eollf *.meta text eollf推荐给文本类文件统一设置eollf可以避免各种跨平台后遗症。Unity引擎对于eollf和eolcrlf都能识别但统一用LF更符合Git的跨平台习惯尤其是在国内团队里用Windows的同时有Mac的情况下这个坑一旦爆发会非常难排查。6. 我如何把去马赛克/模型遮挡剔除这类问题拆分成正确的方案unity游戏去马赛克和unity模型遮挡剔除插件这两个热词其实指向两个不同的东西我在做笔记时查了不少资料也顺手把思路捋了一遍。6.1 去马赛克主要发生在什么场景如果你是在说游戏画面里的马赛克/像素感那大概率是贴图分辨率不够特别是在放大时出现模糊马赛克纹理后处理抗锯齿设置不当导致边缘锯齿严重压缩格式选择不当例如在Android上使用了ASTC但设置的码率太低或者UI贴图没有添加Generate Mipmaps导致远看闪烁。处理方式在Texture Import Settings里调整Max Size和Compression到合适档位例如原图是1024分辨率Max Size设为2048或1024不要设置成512。另外Filter Mode选BilinearCompression选High Quality一般能解决大部分马赛克视觉问题。但如果你说的去马赛克是指打码隐私模糊效果——那种在图片上做马赛克模糊的技术那Unity里需要用Shader实现局部模糊或者用RenderTexture配合C#的图形处理。这两者差距很大建议先明确语境。6.2 模型遮挡剔除不要只依赖Unity自带Occlusion CullingUnity自带的Occlusion Culling遮挡剔除能解决一部分问题但它需要烘焙数据且对动态物体、破碎地形的支持并不好。尤其是很多国产游戏项目场景里布满动态细节玩家的主城里有大量NPC、粒子特效、动态载具自带的烘焙遮挡剔除在动态世界里的作用很有限。更通用的方案是多级LOD为大型建筑、地表装饰生成LOD在当前摄像机距离较远时自动切换为低面数模型减少三角形数量。CullingGroupUnity提供CullingGroup API可以对大量动态物体的可见性做精细控制比自带的Occlusion Culling更可控。自制剔除器基于Grid或者八叉树Octree对场景内物体做空间分区再配合相机视锥体做剔除。优点是体积小、可控性高适合特定玩法场景如只有几百个动态物的战斗场景。我这次在项目里选择的是方案2加方案3的组合用CullingGroup管理NPC的动作开关用简单的Grid分区配合视锥体剔除做大型特效的显示控制。实测下来跑220个动态特效节点在中等配置的安卓机上能稳定50帧以上相比一开始的全部显示提升非常可观。7. 在Unity里做地图、做数字孪生和模拟类的铺垫PerlinNoise与普通地图数据数字孪生和常见的小地图/开放世界地图在Unity里的底层处理思路其实是共通的都需要地图数据的存储与渲染映射。这次我研究了一下Mathf.PerlinNoise柏林噪声在程序化地图中的用法以及为什么它和真随机不一样。7.1 PerlinNoise的核心逻辑与一个具体案例柏林噪声生成的值是平滑连续的不像Random.Range那样每个采样点之间完全无关。它的输入是二维或多维坐标输出是0到1之间的浮点数。你用它来生成地形高度、地表湿度、云层密度等连续变化的数据非常合适。public float GetHeight(float x, float z, float scale) { float noiseValue Mathf.PerlinNoise(x * scale, z * scale); return noiseValue; }这里最容易困惑的问题是scale怎么取。如果你直接传入裸的x和z坐标世界坐标PerlinNoise的输出变化会过于剧烈或者过于平缓很难控制。正确的做法是把世界坐标除以某个缩放系数float sampleX (float)x / mapWidth * scale; float sampleY (float)z / mapHeight * scale; float noise Mathf.PerlinNoise(sampleX, sampleY);scale越大生成的地形细节越丰富scale越小地形越平滑。通常取值在1到10之间。我常用的是scale5左右配合多个octave叠加多层噪声得到更自然的地形起伏。7.2 从PerlinNoise到分帧生成地图生成一张大地图最怕的是在Start或Awake里一次性把所有地形块实例化出来那会导致进度条卡死、手机直接闪退。我这次使用的是分帧生成的思想——也就是把地图生成任务切分成许多小片段在协程里逐帧执行。IEnumerator GenerateMapChunkByChunk() { for (int x 0; x mapWidth; x) { for (int z 0; z mapHeight; z) { float height GetHeight(x, z, scale); Vector3 pos new Vector3(x, height, z); // 实例化地形块或设置为顶点高度 // 每生成一定数量的块就yield return null让出主线程 if ((x * mapHeight z) % 200 0) yield return null; } } }这个思路对于数字孪生类项目同样有效当你需要从GIS数据或者CAD数据生成大量实体时绝不能一帧全部创建用分帧加载、对象池、LOD三者配合才能真正做到流畅。7.3 处理现有地图数据导入如果你的目标是把一份已有的地图/建筑数据导入Unity做数字孪生纯PerlinNoise就不适用了。你需要的是解析数据文件比如GeoJSON、CAD DXF、3D Tiles再映射到Unity坐标系。这个领域现在有比较多的商业插件但也可以自己写一个轻量解析器把每个多边形区域拆成三角形网格再用MeshCollider包裹。这个过程里最常遇到的问题是单位转换和坐标系翻转GIS数据通常是经纬度高度Unity是米制左手坐标系官方插件有时候也会在这种转换里出现偏差。如果你手头有大范围数据要导入我个人建议先在QGIS或者CAD里把数据简化抽稀一下再导入否则Unity的Mesh生成量会直接爆掉。8. 从会写代码到能交付关于打包、混淆和可维护性这篇笔记的最后一部分聊聊工程化。做游戏和学习阶段最大的区别在于学习阶段你只需要让Demo在编辑器里跑起来而交付阶段你需要能稳定打出一个包并且保证运行时高效、代码不被轻易反编译。针对热搜词里的unity微信小游戏打包unity发布aabunity混淆unity插件推荐我把经验都集中在这节了。8.1 发布AAB与微信小游戏的差异化处理发布AABAndroid App Bundle这是Google Play要求的格式。Unity在Build Settings里选择Gradle构建勾选Build App Bundle即可。一个大坑是AAB对纹理压缩格式有要求我建议开启Texture Compression的ASTC并在Build时勾选Split Application Binary。这样打出来的包体积更小且能适配不同GPU架构。微信小游戏打包常规路径是Unity - 微信小游戏插件导出WebGL再通过微信开发者工具上传。这个过程中最痛的点是首包体积。官方推荐CDN分包和服务端资源加载的方式来解决如果是我自己的项目我会优先把UI图集和音频资源做成AssetBundle放到CDN上把首包控制在2M以内否则微信审核和用户加载体验都很糟糕。8.2 代码混淆怎么做如果是追求安全性和反破解可以在Unity打包后做一个二次加固。目前市场上主流的移动端安全方案其实更多是应用加固而不是简单的代码混淆。对大多数中小项目而言建议重点做以下三步在Player Settings - Other Settings里打开Strip Engine Code在IL2CPP模式下有效这可以裁剪无用引擎代码同时提升安全性使用二进制加固服务对最终的so文件做加密保护敏感逻辑比如计费、解锁、服务端校验如果能放服务端就放服务端——纯客户端的混淆永远都只是提高门槛不是绝对安全。关于IL2CPP vs Mono我再多说一句IL2CPP虽然是Unity未来的方向而且安全性确实比Mono高不少但它也会带来包体变大、构建时间变长、部分JITJust-In-Time依赖的第三方库比如某些热更新方案不能用的问题。在决定之前请先确认你的第三方插件是否支持IL2CPP否则打包时会报一堆奇奇怪怪的错误。8.3 插件推荐的原则unity插件推荐这个热词虽然有流量但我不太建议上来就装一堆插件。工具链的选取原则应该是越少越稳定。我目前在用的插件仅限以下几个方向且都相对成熟UI/动效DOTween动画、TextMeshPro文本现在已内置数据存储Newtonsoft.Json for UnityJSON库已内置于较新版本对象池自己写或使用参考型组件建模/场景ProBuilder内置方便原型场景搭建。核心建议不要在项目早期引入大量看起来好用的插件尤其是依赖特定Unity版本的重型插件一旦Unity升级要么插件不兼容要么你需要花大量成本去适配这比省的那点开发时间贵得多。8.4 版本管理的最后一个建议如果你和我一样用Git管理Unity项目记得.gitignore里果断排除Library/、Temp/、Obj/、Build/等目录.meta文件必须提交它们是Unity资源引用的关键尽量避免多人同时修改同一个Prefab不然每次合并Prefab都是一场灾难。当你在Git里看到一个Unity场景或Prefab的冲突时常规解决思路是保留一个版本然后手动在场景编辑器里重新合并。没有捷径除非你们强制团队使用一人一个Scene的拆分方式。这个话题我在unity 2018入门与实战那段也看过一些总结确实是要靠流程去规避的。结尾我踩完这些坑之后的一些体会这一轮项目做下来我最深的感受是Unity的坑其实大多数不是引擎本身的设计缺陷而是它给你太多自由——你可以用一万种方式去实现同一个需求但只有少数几种方式能经得起后期的扩展和性能考验。技能指示器、LookAt、相机、UI、宏定义、地图生成、打包发布每一块单独看都是查一下就懂但只有真正把它们放进一个实际项目里同时跑起来才会发现它们之间的耦合关系远比文档里描述的复杂。就拿技能指示器来说你光做好一条线是不算完的它要和角色朝向、相机朝向、鼠标投影、碰撞检测坐标准则全部对齐一点偏差都会让玩家觉得这个技能手感不对。而手感这个东西恰恰是没法靠截图和文档传递的它只能在真实场景里反复试。如果你也在研究类似的问题我建议你从最小的可运行Demo开始每次只加入一个新变量比如先固定相机移动再做指示器等这一环稳定了再动下一环。别急着把网上的热门方案全部塞进一个项目你会被它们之间的冲突搞得怀疑人生。笔记就先记到这里下一篇我应该会集中研究一下URP管线和卡通渲染到时候再见。