ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity 3D跑酷游戏源码解析:从赛道生成到C#性能优化

Unity 3D跑酷游戏源码解析:从赛道生成到C#性能优化 简介一款适配Unity 2019.2.3f1及以上版本的3D跑酷游戏完整源码以海绵宝宝为题材包含数百个风格独特的关卡。资源面向有一定C#基础、希望学习完整手游项目架构的Unity开发者尤其是对移动端跑酷玩法、休闲游戏商业化模块感兴趣的人群。压缩包内共2000个文件核心包括91个prefab预制体、68个cs脚本、101个材质、15个动画、36个fbx模型等搭配大量png贴图与unity场景文件构成可直接运行的工程整体包体224.5MB。目前已吸引118人学习浏览。项目内置Unity Ads与Admob的插屏和激励视频接入、IAP去广告购买及无尽模式并针对移动设备做了优化。下载后可拆解其关卡生成逻辑、角色换肤系统、广告与内购的调用流程也可直接换皮或作为跑酷玩法框架二次开发节省从零搭建项目的时间。1. 卡通IP跑酷的 Unity 3D 游戏源码核心逻辑落在哪看到“海绵宝宝赛车”这个主题别被IP光鲜的外表骗了。跑酷游戏的技术内核比多数RPG要干净得多一条永无尽头的路、一个只管向左向右跳的角色、一套不断回收重生的障碍物。这份标题里的“Funny Spongebob Racer 3D”落点就是Unity项目里那套C#脚本怎么把这三件事串起来。三轨跑酷的玩法早在移动端就被验证过无数次而代码的组织方式决定了你是能快速迭代加新障碍物还是每加一个道具就得重写一半逻辑。这篇文章适合两类人一类是刚拿到跑酷源码面对几十个脚本不知道从哪里开刀的新手另一类是打算自己从零搭建一个3D跑酷Demo想少走几趟弯路的在职开发者。下文会按“赛道生成——角色控制——碰撞与UI——性能与打包”的顺序把一份典型Unity跑酷源码里最关键的实现和参数逐个说透。2. 3D跑酷赛道生成与C#角色控制先让角色跑起来再追求视觉跑酷项目的第一行代码往往写角色控制但更该先写的是赛道。角色控制得再好没有不断延伸的地面也无从跑起。常见的做法是分段预制体加对象池管理运行中根据玩家位置在前进方向追加新的一段同时回收落后玩家的一段。这套逻辑在2D和3D里通用区别只在分段的尺寸和生成间隔的取值。2.1 无限分段赛道预制体池与C#数据结构设计一个赛道分段通常包含地面、障碍物布置点、装饰物和金币。用代码建模时我一般会定义一个TrackSegment类把分段规格和挂点集中到同一个组件上using UnityEngine; public class TrackSegment : MonoBehaviour { [Header(赛道段配置)] public Vector3 segmentLength new Vector3(20f, 0f, 0f); // 分段在前进轴上的长度 public Transform[] obstacleSlots; // 障碍物挂点运行时按难度概率填充 public void ResetSegment() { // 对象池回收前把子物体全部禁用避免下一次复用出现残留 for (int i 0; i obstacleSlots.Length; i) { if (obstacleSlots[i].childCount 0) { obstacleSlots[i].GetChild(0).gameObject.SetActive(false); } } } }这段代码做的事情很轻但它是整个赛道生成的基础。segmentLength是赛道管理的核心参数生成下一段的锚点就是上一段的位置加上这个向量。obstacleSlots是挂点数组运行时会按难度曲线在空位上实例化障碍物。ResetSegment在对象池回收时被调用负责把激活过的障碍物禁用掉。如果你拿到的源码用的是另一种做法——把整条赛道做成一长条静态场景然后用角色的位移假装“前进”——那就绕开了生成逻辑代价是关卡容量有上限低端设备上加载会卡。分段生成配合对象池才是跑酷源码里默认的正解。对象池的关键参数是预创建数量我一般按“可视距离除以分段长度再加2”来算留两段余量给性能毛刺。2.1.1 分段生成触发条件两个距离参数生成逻辑最简单的写法是每帧检查玩家位置通过Z轴距离差决定是否继续生成public class TrackGenerator : MonoBehaviour { public GameObject[] segmentPrefabs; // 多个变体随机选取 public Transform player; public ObjectPool pool; private QueueTrackSegment activeSegments new QueueTrackSegment(); private float spawnZ 0f; void Update() { // 玩家前方60米内始终保持地面存在 while (spawnZ player.position.z 60f) { SpawnNextSegment(); } RecyclePastSegments(); } private void SpawnNextSegment() { GameObject prefab segmentPrefabs[Random.Range(0, segmentPrefabs.Length)]; TrackSegment segment pool.Get(prefab).GetComponentTrackSegment(); segment.transform.position new Vector3(0f, 0f, spawnZ); spawnZ segment.segmentLength.z; activeSegments.Enqueue(segment); } private void RecyclePastSegments() { // 玩家身后保留20米更早的段直接回收 while (activeSegments.Count 0 activeSegments.Peek().transform.position.z player.position.z - 20f) { TrackSegment tail activeSegments.Dequeue(); tail.ResetSegment(); pool.Release(tail.gameObject); } } }生成余量60f和回收距离20f是一组需要联调的参数。60f要覆盖摄像机可见的最远深度加上缓冲设得太大内存会无谓上涨太小则会出现前方地面还没生成出来的“断崖”。20f是身后保留距离小于角色与尾段间距就会在镜头拉回或角色倒退时穿帮。队列结构保证了分段顺序不会乱。2.2 CharacterController 移动三轨切换与跳跃手感三轨跑酷的“三”在代码里就是一个索引数组。角色在当前轨道上正常跑动左右滑动的输入把目标索引加减1每帧把角色横向位置尽量靠近目标坐标。2.2.1 三轨切换的Lerp陷阱与固定速度移动很多人直接写transform.position Vector3.Lerp(pos, targetPos, Time.deltaTime * speed)。问题在于Lerp的第三个参数与帧率耦合60帧和30帧下横移速度不一致且切换未完成时如果连续按两次方向键目标值被覆盖角色会半路折返。稳定做法是缓存目标轨道坐标再用固定速度移动private int targetLane 1; private float currentX; private const float LANE_WIDTH 2.5f; // 轨道间距与角色碰撞体宽度匹配 void Update() { if (!canControl) return; float targetX (targetLane - 1) * LANE_WIDTH; currentX Mathf.MoveTowards(currentX, targetX, 12f * Time.deltaTime); Vector3 pos transform.position; pos.x currentX; transform.position pos; } public void MoveLeft() targetLane Mathf.Max(0, targetLane - 1); public void MoveRight() targetLane Mathf.Min(2, targetLane 1);这里12f是横向移动速度,直接决定手感。跑酷的横移速度通常取8到15太小发“粘”太大容易冲出轨道边缘。LANE_WIDTH必须大于角色碰撞体的宽度否则相邻轨道在视觉上会重叠。2.2.2 跳跃缓冲与土狼时间的配合跳跃手感好坏一半在跳跃高度和重力的数值另一半在输入缓冲。玩家说“角色跳不起来”很多时候不是代码没执行而是按键那帧角色刚好不处于可跳状态。加一个短暂缓冲计时器能明显改善按下跳跃键先记下时间戳角色一落地就检查缓冲是否还在有效窗口内。private float jumpBufferTimer 0f; private float coyoteTimer 0f; private Rigidbody rb; public void OnJumpInput() { jumpBufferTimer 0.08f; // 输入在80毫秒内都有效 } void Update() { jumpBufferTimer - Time.deltaTime; bool grounded Physics.CheckSphere(feetPos.position, 0.2f, groundLayer); if (grounded) { coyoteTimer 0.1f; // 离开地面的短暂宽容 } else { coyoteTimer - Time.deltaTime; } if (jumpBufferTimer 0f coyoteTimer 0f) { rb.velocity new Vector3(rb.velocity.x, jumpSpeed, rb.velocity.z); jumpBufferTimer 0f; coyoteTimer 0f; } }缓冲时间0.08f和土狼时间0.1f是横版跑酷常见的取值区间。土狼时间的意思是角色刚从平台边缘走出、还没开始下坠的那一瞬间依然允许起跳。这个设定在物理上不真实但手感上几乎必需。Physics.CheckSphere在地面检测时要指定groundLayer否则踩到障碍物边缘会被误判为落地。2.3 摄像机跟随Mathf.SmoothDamp 的三个手感参数跑酷摄像机不是自由视角而是“锁在角色后上方”的追尾机位。它的实时反馈直接决定了玩家对速度的感受。public class CameraFollow : MonoBehaviour { public Transform target; public Vector3 offset new Vector3(0f, 5f, -8f); public float smoothTime 0.12f; 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.position Vector3.up * 2f); } }SmoothDamp相比Lerp的优势在于内部做了速度累积镜头起步和停止都带缓动更接近真实摄影机的运动曲线。参数手感参考下表smoothTime 值镜头响应特征适用场面0.05f~0.08f几乎贴脸晃动明显高速冲刺或第一人称演出0.12f~0.15f轻微拉拽转向呈弧线跑酷项目的默认区间0.2f以上镜头明显滞后观战视角或过场演出不适合实操LookAt里的Vector3.up * 2f是把视线焦点放在角色头部以上避免角色下蹲或跳跃时镜头频繁俯仰导致晕眩。注意摄像机必须在LateUpdate中跟随。若写在Update里可能先于角色控制执行导致镜头拍到上一帧的位置出现抖动。3. C#脚本组织碰撞判定、动画状态机与UI回调把跑酷逻辑拆干净角色能跑了下一步是处理“撞上东西之后发生什么”。障碍物碰撞、金币拾取、得分累加、UI刷新这些模块在中小型源码里经常被写在同一个类里互相引用改一处炸多处。3.1 障碍碰撞Collider触发还是Raycast判定三轨跑酷的碰撞判定的实现方式有两种流派。一种是在障碍物上挂真实Collider角色用OnTriggerEnter判定另一种是角色每帧向前发射线从射线命中结果推断最近障碍。前者物理计算量小但障碍物必须真的有碰撞体后者省掉了Collider却多了每帧的射线开销。从代码工程性角度看我倾向用Trigger加Tag区分。金币和障碍物在一根轨道上交错出现对应处理逻辑完全不同void OnTriggerEnter(Collider other) { if (other.CompareTag(Obstacle)) { PlayerHit(other.gameObject); } else if (other.CompareTag(Coin)) { GameEvents.CoinCollected(1); other.gameObject.SetActive(false); } }用Tag而不是Layer在快速迭代时更省心。Tag列表在Inspector里就能维护不需要去改Layer索引也避免因为误用同名Layer造成的隐藏Bug。3.1.1 射线检测的LayerMask过滤有些跑酷源码还会加一道射线检测用于预判“前方两米内是否有障碍”提前触发减速提示或镜头震动int obstacleMask 1 LayerMask.NameToLayer(Obstacle); RaycastHit hit; if (Physics.Raycast(transform.position Vector3.up, transform.forward, out hit, 3f, obstacleMask)) { // 命中障碍触发减速或提示动画 }这里必须用LayerMask过滤不能直接传默认mask否则地板、金币、装饰物都会触发判断。射线起点抬高Vector3.up是为了避开地面产生的干扰。3.2 Animator状态机与代码中状态变量同步跑酷角色的动画状态相对固定Run、JumpStart、JumpFall、Slide、Hit。如果在Animator里堆满触发条件和Bool变量代码执行顺序稍有变化就会出角色播完死亡动画还能跑的怪事。最稳的方案是不在Animator上挂复杂条件直接用CrossFade控制。private Animator animator; public void OnJump() { animator.CrossFade(JumpStart, 0.05f); } public void OnLand() { animator.CrossFade(Run, 0.1f); }CrossFade的好处是打断自然。过渡时间参数直接影响观感参考值如下表CrossFade 场景推荐过渡时间观感影响起跳打断0.03f~0.05f太小会像被硬切太大起跳显得迟钝落地回跑步0.1f~0.15f超过0.15f腿部会出现短暂打滑受击进入0.08f受击反馈不能太拖拉要及时动画只是表现层核心玩法变量如isAlive、canControl必须由C#脚本独占。Animator可以播死亡动画但角色位移和碰撞响应已经完全由代码接管。跑酷源码里最常见的Bug来源就是把这两层边界写模糊了。3.3 用C#事件总线解耦得分、UI、音效UI、音效、体力条在玩法上是弱关联关系。如果用UnityEvent拖引用每次改UI都要回到场景里重新连线如果直接用静态变量轮询每次得分都要刷新好几个组件。这中间插入一个简单的事件总线就能把两者都解开。public static class GameEvents { public static System.Actionint OnCoinChanged; public static System.Action OnPlayerHit; public static void CoinCollected(int total) { OnCoinChanged?.Invoke(total); } }UI层只做订阅和刷新void OnEnable() { GameEvents.OnCoinChanged UpdateCoinText; } void OnDisable() { GameEvents.OnCoinChanged - UpdateCoinText; } private void UpdateCoinText(int total) { coinText.text total.ToString(); }这里的关键约束有两处订阅必须成对出现在OnEnable和OnDisable避免物体被销毁后事件里还挂着悬空委托不要在Update里轮询分数。事件总线的粒度适合跑酷这个规模的项目如果游戏扩展到几十个互相关联的系统再考虑引入正式的消息框架。3.4 UI滑动条、点击热区与World Space UI被遮挡设置界面里最常见的控件是滑动条。Unity自带的Slider可以绑定UnityEvent直接改AudioMixer参数但有一个细节容易踩坑Slider的value是0到1的线性值AudioMixer的volume组用的是分贝。直接连的话滑块拉下一半时静音效果并不明显。public void OnMusicVolumeChanged(float sliderValue) { float db sliderValue 0.0001f ? -80f : Mathf.Log10(sliderValue) * 20f; audioMixer.SetFloat(MusicVolume, db); }用Log10做转换让滑块的线性输入映射到近似听觉线性的分贝曲线。分界值0.0001f是为了避开对数函数在零附近无定义的问题给一个极小下限。按钮点击范围也是一个常见优化点。Unity默认的Button点击区域是Image的RectTransform矩形但美术给的按钮图往往有透明边视觉上只需要中心区域响应。最简单的扩热区方式是调整RectTransform尺寸RectTransform rt button.GetComponentRectTransform(); rt.sizeDelta new Vector2(rt.sizeDelta.x 20f, rt.sizeDelta.y 20f);代价是父布局的尺寸也被撑大了。如果不想影响布局更推荐的做法保持Button和Image尺寸不变额外加一个透明子Image专门接收射线并在Raycast Target关掉视觉Image。顺带一提World Space UI无遮挡的问题。跑酷里计分板如果挂在World Space Canvas上会被障碍物模型遮挡。常见做法有两种一是把Canvas的RenderMode改为Screen Space Camera指定到主摄像机并设置合适的Plane Distance二是保留World Space但把障碍物的Layer从UICulling的剪裁遮罩中去掉。前者简单直接后者适合需要计分板“立在场景里”的演出需求。4. Unity性能优化与阴影问题移动端3D跑酷的渲染与GC治理跑酷游戏看似场景简单实际因为赛道无限生成每帧渲染的东西一直在变GPU负载比普通固定关卡更不稳定。再加上移动端的功耗限制优化优先级要排序先管阴影再管GC最后管Draw Call。4.1 阴影问题的三个排查方向影子特效是移动端第一个失控的点。跑酷场景里最常见的现象是远处障碍物阴影一帧一帧闪原因很简单平行光的阴影贴图覆盖范围远大于实际需要分辨率分摊到拉长的距离上就不够了。4.1.1 远处的阴影映射距离调校把阴影距离压到合理范围是最快的止血方式QualitySettings.shadowDistance 40f;这个值必须小于摄像机的最远可视距离。如果设成全局默认的150移动端GPU会在每一帧都计算大量无关物体的阴影掉帧几乎不可避免。跑酷场景中角色前方40米外的东西已经没多少人盯得住了。4.1.2 为何移动端跑酷不推荐烘焙阴影跑酷地面和障碍物是运行时动态生成的烘焙阴影的前提是静态场景。对动态生成的分段重新烘焙显然不现实所以只能走实时阴影方案但要压低参数QualitySettings.shadows ShadowQuality.All; QualitySettings.shadowResolution ShadowResolution.Low; QualitySettings.shadowCascades 2;级联从4降到2视觉差别在移动端小屏幕上并不大GPU压力的节省却很可观。再进一步可以把动态物体的阴影投射关掉只保留接受地面阴影MeshRenderer mr obstacle.GetComponentMeshRenderer(); mr.shadowCastingMode UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows true;这样障碍物不再互相投射阴影但地面上的阴影还在视觉上保留了立体感。移动端跑酷项目里这个方法几乎白拿性能。4.2 用Profile定位GC与物理开销跑酷的GC压力集中在无限生成和UI刷新上。每一次Instantiate、Destroy、字符串拼接都可能产生堆分配。C#字符串对int做拼接会装箱起跳一次的得分、缓存三连的重置都是GC尖峰的来源。// 坏习惯每次刷新文本都拼一个新字符串 scoreText.text 得分: coinCount; // 好一点用固定容量StringBuilder StringBuilder sb GetStringBuilder(); sb.Clear(); sb.Append(得分: ); sb.Append(coinCount); scoreText.text sb.ToString();在Profiler的CPU模块里筛选Scripting时间线凡是出现密集的红色内存分配尖峰基本都能定位到某个方法的Instantiate或字符串操作。物理方面无限生成场景如果给每个装饰物都挂Collider物理引擎每帧要做大量Broadphase检测。正确做法是分段内只给会参与碰撞的障碍物加Collider装饰物不挂地面用一整块BoxCollider覆盖。排查方向常见问题表现优先验证手段阴影远处闪烁、近处过硬降低shadowDistance看是否缓解GC真机帧率跳变Profiler看Scripting阶段曲线物理角色偶发穿透障碍检查Collider数量与网格复杂度4.3 减少材质与合并网格的路径跑酷段每生成一段就会新增一批MeshRenderer和材质。如果每段各自独立材质Draw Call会随着赛道长度线性上涨。纹理相同的地面分段在逻辑上完全可以用一个合并网格替代public static Mesh CombineMesh(MeshFilter[] meshFilters) { CombineInstance[] combine new CombineInstance[meshFilters.Length]; for (int i 0; i meshFilters.Length; i) { combine[i].mesh meshFilters[i].sharedMesh; combine[i].transform meshFilters[i].transform.localToWorldMatrix; } Mesh mesh new Mesh(); mesh.CombineMeshes(combine); return mesh; }CombineMeshes会把多个网格合成一个新网格前提是所有网格共用同一材质。如果材质不同合成后会出现材质分支产生额外State ChangeDraw Call不降反升。使用合并网格时原始MeshFilter对应的GameObject要禁用或销毁只保留新网格的显示。合并后的网格无法再做单段回收所以这个方案只适用于背景地面这类静态装饰障碍物和金币必须保持独立实例。5. 跑酷项目的收尾技巧WebGL部署、存档与真机验证一个跑酷Demo从编辑器里跑通到真正能发出去中间还隔着平台适配这道坎。这里说两个在Unity跑酷项目里经常卡住的收尾问题。5.1 WebGL部署的IDBFS写入失败Unity发布WebGL后PlayerPrefs默认写到浏览器提供的IndexedDB里。遇到浏览器隐私模式、配额清理或域名切换时很容易出现IDBFS写入失败表现为存档存了一次刷新网页后进度全丢。常见的兜底方案是把持久化接口单独封装一层浏览器端用localStorage替代public static class WebGLSave { [DllImport(__Internal)] private static extern string GetSaveData(); public static string Load() { #if UNITY_WEBGL !UNITY_EDITOR return GetSaveData(); #else return PlayerPrefs.GetString(savedata, ); #endif } }配合.jslib插件把存档写入localStorage好处是不会受IndexedDB配额和隐私策略影响。要注意PlayerPrefs在Editor里依然可以正常读写所以条件编译保证在编辑器里继续用PlayerPrefs避免开发期与发布期行为不一致。5.2 真机验收清单与参数核对收尾阶段每一次真机测试都值得按下面这张清单逐项过一遍检查项建议值或做法容易忽略的问题垂直同步vSyncCount 1关掉垂直同步后高速跑动容易出现画面撕裂目标帧率Application.targetFrameRate 60低于60要优先查阴影和后处理物理步长保持固定0.02s改小物理步长可能引发射线检测漏检阴影质量按第4章参数压到Low移动端型号差异大同一套参数要在中低端机上跑复用存档写入时机在Checkpoint和退出时各写入一次只在退出时写杀进程或闪退会导致进度丢失帧率的优化顺序是先关闭后处理体积设置再压低阴影距离最后才考虑改脚本里的循环。跑酷项目的逻辑层通常压力小于渲染层一上来就改代码里的数据结构收益往往不明显。真机调试时如果遇到“角色撞不上障碍”这类问题先检查Rigidbody的Collision Detection Mode。跑酷角色移动速度快使用离散检测在高速穿透时有可能直接越过薄障碍物。把角色Rigidbody的碰撞检测改为Continuous会让它连续扫描路径上的碰撞体虽然物理开销增加一点但能避免最尴尬的穿越。对于存档我习惯再加一层保护每次成功落笔后把数据同时缓存到内存字段游戏加载时先读内存内存没有才去读外部存储。这样即使浏览器存储系统中途失灵当前会话内玩家的进度也不会丢得干干净净。本文还有配套的精品资源点击获取
返回列表