
前阵子接了个外包需求需求文档就一句话做一个Unity海底场景要海草、鱼、鲨鱼、石头岩石整体说要有“海底总动员”那种明快卡通感。当时我心想这不就是资源摆放吗模型一拖、材质一挂、灯光一打就完事。结果真动手才发现这个“简单”背后全是坑默认灯光下石头像塑料、海草像铁丝、鱼群像被冻住鲨鱼游起来像一个质量块在漂移。这篇就把我在这个项目里从零搭海底场景的完整过程写下来场景怎么铺、海草动画怎么做、鱼和鲨鱼怎么控制、岩石怎么摆不穿模、最后怎么在手机上保住帧率。适合正在做水下场景、或者刚开始接触Unity场景整合的朋友参考很多细节是官方文档里不会写的。1. 刚开始我犯的最大错误直接把模型拖进场景1.1 默认光照下珊瑚和海草像塑料一样第一个版本我图省事把从资源商店买的“海底总动员”风格模型包一股脑拖进场景用的还是Unity默认的Standard管线、默认Directional Light和Skybox。结果一运行画面只能用“灾难”形容石头和珊瑚高光强得像刚刷了清漆海草因为没有次表面散射显得又干又硬最要命的是整个水下空间完全没有“水深”的概念——没有颜色衰减没有悬浮粒子就是在一个普通晴天里放了几个海底模型。这个问题的根源不是模型不行而是Unity默认场景假设的是室外天空光照和“水下”这个介质完全不是一回事。水下场景第一眼要骗过人眼靠的不是模型数量而是光的颜色和衰减浅水区偏蓝绿深水区变暗变灰远处的东西要被水雾吃掉。如果连光的氛围都没定模型再精细也是白搭。1.2 先把渲染管线和颜色空间定下来我重新搭项目时第一个决定是把渲染管线换成URPUniversal Render Pipeline原因有两点一是URP的后期Volume机制做水下色调很方便Bloom、Color Adjustment、Vignette直接推滑块二是移动端性能比默认内置管线的烘焙效果更可控。颜色空间必须选Linear这点很多人会漏掉Gamma空间下暗部颜色会偏灰水下那种深邃的蓝色很难调出来。主光源我用的是一盏主Directional Light模拟太阳从水面斜射下来的方向角度大概和水平面成40度色温偏冷强度不要超过0.8。水下不能用纯黑阴影所以要给环境光一个淡青色Environment Lighting的Ambient Color设成#A8D8E8这类颜色这样背光面不会死黑。另外在URP里记得加上指数雾Fog让远处场景逐渐融进背景色——水下没有雾是绝对穿帮的。我一般把雾的颜色设成和摄像机背景一致密度调到4到6之间具体值看场景尺寸。提示很多人开了雾以后发现前景也跟着白蒙蒙这是因为Fog的Start和End范围没调好。建议从Start10开始手动拖观察模型在距摄像机3米和20米处的差异确保近景清晰、远景消失在背景色里。2. 海草三种动画实现方案我最后选了哪种2.1 顶点Shader波浪适合大面积海草和低性能开销海草是这个场景里数量最大的元素可能同时有几百棵。如果每一棵都用Animator做骨骼动画Draw Call和动画更新开销都会失控。所以我优先尝试顶点Shader方案把海草模型的顶点交给GPU在顶点着色器里根据世界位置和高度采样噪声或正弦波制造摇摆效果。这个思路在Shader Graph里很容易实现拿一个Position节点用UV或者World Position算出一个Wave偏移加到Vertex Position上。我用了类似下面的核心逻辑float waveStrength saturate(IN.positionOS.y / _PlantHeight); float wave sin(IN.uv.x * _Density _Time.y * _Speed) * _Strength; OUT.positionOS.xz wave * waveStrength;注意这个waveStrength一定是海草顶部最大、底部趋近0否则整棵草会连根平移看起来像在漂移而不是摇曳。Shader Graph里可以用“Divide”和“Saturate”节点把高度归一化再乘到偏移量上。顶点Shader方案最大的坑在阴影Unity内置的Shadow Caster Pass如果不重新计算顶点位置阴影还是原始形状海草就会出现在原地和模型对不上。URP里需要自己写一个ShadowCaster变体或者直接在Shader里加#pragma multi_compile _ _SHADOWS_ENABLED处理。如果嫌麻烦最简单的办法是让大面积海草关闭投射阴影反正水下散光强阴影本来就淡。2.2 骨骼动画与Animator主角级海草才用它如果场景里只有几棵特别显眼的大海草比如“总动员”风格里作为视觉中心的巨型海带完全可以用骨骼动画做更自然的弯曲。做法是3D软件里给海草建3到5节骨骼烘焙一段循环摇摆动画然后在Unity里用Animator控制。骨骼动画的优点是好控制、表现力强想做风浪传波效果甚至可以每节骨骼做相位延迟。但缺点同样明显几百棵海草每人一个Animator哪怕动画状态机再简单Cpu开销也扛不住。我的做法是“混合分级”靠摄像机近的、体型大的海草用骨骼动画数量控制在5棵以内剩下的大面积背景海草全部走Shader顶点动画这样近景有细节远景有数量。这里有一个特别容易踩的坑Animator默认会让SkinnedMeshRenderer实时更新包围盒Bounds如果模型自带的长尾海草动起来Bounds没有同步扩展出现“动画模型在镜头前突然被裁剪掉”的现象。解决办法是把SkinnedMeshRenderer的Bounds手动调大或者在动画根节点上加一个足够大的空物体把整个运动范围包住。2.3 代码摇摆和碰撞响应我的补充选择纯Shader方案虽然好但不能响应鱼和潜水员的碰撞。海底总动员风格里经常有鱼钻到海草里躲猫猫的镜头如果海草完全无视碰撞画面就穿帮了。我后来加了一个“位移检测”的辅助脚本每帧记录玩家或鱼群与海草根部的距离如果进入碰撞半径就给海草一个额外的弯曲偏移这个值随时间衰减形成一种被推开再弹回来的感觉。最终的效果是把受影响的海草分成两批若海草是Shader顶点动画把碰撞偏移量传入MaterialPropertyBlock的全局属性在Shader里叠加若是骨骼动画则直接调整骨骼末端Transform。代码里没有用很复杂的物理就是用简单的Vector3.Distance加Mathf.SmoothDamp做衰减float influence Mathf.Clamp01(1 - dist / _interactRadius); _pushStrength Mathf.SmoothDamp(_pushStrength, influence, ref _vel, 0.3f); Shader.SetGlobalFloat(_GlobalPushStrength, _pushStrength);这样做的好处是走的是Shader全局属性所有海草Shader只需读同一个值就能统一响应不会每棵草单独创建材质实例。3. 鱼和鲨鱼不是摆几个模型而是让它们“像在游泳”3.1 鱼群一个不依赖插件的Boids群游实现海底总动员风格的特写镜头里鱼群是最能体现“水世界感”的元素。鱼群不能用Animation让几十条鱼做同样动作那样一眼假。正确做法是给每一条鱼一个轻量级的群游控制脚本也就是常说的Boids算法每条鱼依据“分离、聚合、对齐”三个力来更新速度和方向。我实现时没有引入第三方插件核心代码行数很少。每隔几帧取出当前鱼附近的邻居计算三个方向的权重// 分离避免撞到邻居 Vector3 sep -averageNeighborPos myPos; // 聚合向邻居中心靠拢 Vector3 coh averageNeighborPos - myPos; // 对齐与邻居平均速度保持一致 Vector3 ali averageVelocity - myVelocity; Vector3 steer sep * _sepWeight coh * _cohWeight ali * _aliWeight; velocity steer * _steerSpeed * Time.deltaTime; velocity Vector3.ClampMagnitude(velocity, _maxSpeed); transform.position velocity * Time.deltaTime;但纯Boids有个问题鱼群会慢慢散成一片没有“鱼群旋涡”的层次感。所以我又加了一个围绕中心点旋转的引导力类似tangent方向让鱼在跟随邻居的同时围绕一个动态中心点做慢速盘旋。这个中心点可以用一个空物体作为“鱼群锚点”运行后在锚点半径内缓慢做圆形移动鱼群就会跟随锚点一边游一边绕圈非常接近纪录片里的鱼群效果。性能上每帧对所有鱼两两计算邻居是O(n²)鱼超过50条就会有明显消耗。我的方案是把鱼群分成几个Group每个Group不超过30条每条鱼只检测自己所在Group内的邻居。这个优化量级虽然简单但在手机上帧率从40帧提升到60帧。3.2 鲨鱼AI巡逻、追踪、转身一气呵成鲨鱼是场景里的核心角色不能让它在场景里乱跑。我的做法是给它一个基本状态机巡逻Patrol、追踪Chase、返回Return。巡逻时鲨鱼沿着一组Waypoint匀速游动一旦玩家或小鱼进入检测范围切换追踪目标丢失后返回最后一个巡逻点。鲨鱼转向是最大的问题。很多刚开始做的人直接用transform.LookAt(target)结果鲨鱼每帧瞬间转向、像被一只无形的手掰头卡通感全无。我改用Quaternion.RotateTowards控制转向速度Quaternion targetRot Quaternion.LookRotation(direction); transform.rotation Quaternion.RotateTowards(transform.rotation, targetRot, _turnSpeed * Time.deltaTime);而且鲨鱼身体是细长型转向速度还要和当前速度挂钩游得快时要转得慢一点否则会产生“漂移”。我的做法是把_turnSpeed除以当前速度归一化值速度越高转向越小效果更自然。3.3 Animator状态机和LookAt的坑鲨鱼动画我用了Animator状态机参数有Speed、Turning、IsAttacking。游泳时播放尾部摆动转向时叠加身体扭曲。这里的坑是尾巴摆动动画和代码转向会互相打架如果脚本直接旋转transform动画就会只播不转或者转了几度又被动画“拉回去”。解决方法是把代码旋转放到Animator的OnAnimatorMove回调里或者用Animator.applyRootMotion false然后把转向逻辑放在LateUpdate中避免动画控制器覆盖角色方向。另外LookAt这个函数对非人形模型特别不友好因为模型的前方轴向可能是-Z也可能是Z直接用LookAt会朝向完全相反。我在项目里先把鲨鱼模型统一成前方朝Z然后用LookRotation并做15度偏向调整才把转向调顺。4. 石头岩石程序化摆放与穿插修复4.1 用Perlin噪声决定“哪里放石头”而不是手摆场景里岩石的数量一多手摆不仅累而且很难保证布局合理。我采用的办法是写一个编辑器脚本用Mathf.PerlinNoise生成二维密度图在海底地面每个采样点判断是否高于阈值来决定摆放石头。密度高区域放岩石群低区域留空给海草和鱼群游动通道。具体操作是在地形范围内每间隔0.5米采样一次PerlinNoise(x * 0.05, z * 0.05)大于0.62时在该点放置一块随机旋转、随机缩放0.8到1.2倍的岩石。为了防止岩石浮空或陷地用Physics.Raycast从上方往下打一个射线把岩石的Y坐标贴到地面碰撞体的表面。Vector3 spawnPos hit.point; // hit来自从上方发出的Raycast Quaternion rot Quaternion.Euler(0, Random.Range(0, 360), 0); float scale Random.Range(0.8f, 1.2f);这里加一个随机种子Random.InitState(seed)这样每次调整参数后都能复现同一个布局否则每次保存场景布局都会变调试时很痛苦。4.2 岩石穿插和压海草一个SphereCast解决的脏问题程序化摆放最大的隐患是穿插几块大石头叠在一起或者石头直接压在海草生成的区域看起来像“石头长了草”。我以前会用物理碰撞体强行推开但石头一多碰撞体检测会互相推挤导致抖动。这一次改用Physics.SphereCast预检测在放置点以石头半径发射球形射线如果检测到附近已经有岩石碰撞体就丢弃这个点或者缩小该岩石的缩放值。海草和岩石的共存问题我是用“图层”解决的铺海草的编辑器脚本只在“不含有岩石的位置”采样如果采样点周围0.3米内存在岩石碰撞体就直接跳过。这样海草永远长在石头间隙里不会从石头正中间冒出来。4.3 岩石材质PBR参数和倍率细节海底岩石的材质不能只用一张漫反射贴图否则在URP下会显得很干。我的材质参数是这样的BaseMap用石材照片贴图视情况叠加Detail Albedo和Detail Normal让近距离看有纹理颗粒光滑度Smoothness调到0.05到0.15之间因为水下岩石有浮游生物附着表面不是光滑的法线贴图强度调到0.6左右不要超过1否则凹凸感会像泡沫塑料。岩石颜色还需要根据水深做渐变浅水区石头偏暖色青色和淡黄藻类深水区偏蓝灰。我在Shader里用顶点色或者世界高度差做Blend理想情况下是“高处石头颜色亮、深处石头颜色暗”。这个效果在URP里用Shader Graph的Lerp节点连两套颜色贴图采样就能实现。5. 画面“活”起来的后期与粒子细节5.1 体积光、焦散和浮游生物场景氛围到位靠的是“水里的光感”。我用两层东西做体积光第一层是URP Volume里的Bloom强度调到0.35左右让亮部有轻微泛光第二层是在太阳光线方向放了几片半透明的billboard光柱贴图形成水下的“丁达尔光柱”。这些光柱模型不需要额外Shader用URP默认的Unlit Transparent材质加一个alpha渐隐即可。焦散效果我用的投影贴图准备一个海洋焦散序列图或者普通焦散贴图把它投到海底地面和岩石上不断滚动UV形成水纹光影。实现上不用引擎自带投影器Projector在URP里兼容麻烦而是在地面上放一个一定高度的虚拟平面用Shader基于世界坐标采样焦散贴图并随时间偏移UV。浮游生物粒子是让画面不空洞的关键。我做了两个粒子系统一个负责细小悬浮颗粒数量300左右粒子大小2到8像素随机缓慢向上飘另一个负责偶尔出现的气泡在鱼群游过时从底部冒出来气泡用默认粒子材质加透明。很多人忽略粒子系统的渲染顺序结果粒子像一层纸贴在屏幕上。记得把粒子的排序Fudge设为负值让它自然融入半透明渲染队列。5.2 摄像机跟随和三处手调细节水下摄像机如果做简单跟随鲨鱼从侧面冲过来时画面会非常“卡车式”僵硬。我用Transform.SmoothDamp做位置跟随加了一个慢速旋转偏移摄像机永远看向角色前方1.5米处而非正对着角色。这样角色处在画面下三分之一处能拍到更多的环境元素。还需要注意摄像机和岩石穿模。最简单的方法是在摄像机上加一个SphereCollider用Physics.Raycast检测摄像机与目标之间是否有障碍物有就立即把摄像机往前推近目标。这种方法简单有效比用弹簧臂组件好控制得多。光柱、焦散、浮游物都必须在Camera Depth Texture开启后才能正确混合URP里记得勾选Opaque Texture否则一部分透明物体会发黑。6. 手机上跑得动吗Draw Call、包体和拉伸问题6.1 合批和LOD把Draw Call控制在100以内海底场景元素极其碎片化几百棵海草、几十块石头、几十条鱼如果每个模型单独提交Draw Call手机直接变暖手宝。我用四招解决问题静态岩石使用Unity Static Batching把所有岩石标记为Static后自动合并海草用GPU InstancingShader里开启#pragma multi_compile_instancing并把顶点动画参数放进实例属性鱼群按Group用GPU Instancing渲染每条鱼只上传Position、Scale和Color到实例Buffer鲨鱼等体型大、数量少的模型手动做LOD GroupLOD0用高模LOD1用减面模型LOD2用Billboard平面。一通优化后整个场景在手机上Draw Call稳定在90到110之间中端机60帧没有压力。如果仍偏高优先检查粒子系统粒子数量一多会每个粒子单独提交尽量把多次粒子发射合并成一个系统。6.2 打包到手机画面拉伸的问题项目快上线时测试反馈“画面在安卓平板上是压扁的”。排查后发现不是特效问题而是我设置摄像机时直接用了固定FOV没有根据屏幕宽高比调整。不同手机分辨率一变化画面就被横向拉伸。解决办法分两种如果是第一人称或第三人称3D场景用脚本根据Screen.width / Screen.height调整相机的FieldOfView让宽屏看到更宽而不是把画面压扁如果场景本身是固定比例设计用Camera.rect把视口控制在屏幕中央两侧留渐变黑边。注意UI层永远用CanvasScaler的ScaleWithScreenSize模式匹配模式设为MatchWidthOrHeight0.5否则UI和3D场景会在不同分辨率下各自为政。6.3 包体膨胀纹理压缩和Shader变体也要管海底场景贴图特别多石头每个面一张差异贴图、海草还有法线和Alpha第一版APK直接干到600MB。我花了半天时间做减法所有贴图的Max Size按使用场景调整背景海草用256或512前景石头用1024远景岩石用256所有纹理格式在Android上设为ASTCiOS用ASTC或PVRTC不要在手机上用RGBA32关闭URP里不需要的SRP Batcher之外的Shader变体比如场景里没有阴影用不着SHADOWS_SHADOWMASK等变体最后用AssetBundle分场景打包主菜单和游戏场景分开加载场景时才下载对应资源。这套流程走完之后APK从600MB降到260MB而且帧率还提升了因为显存带宽占用小了。很多新手一上来就堆4K贴图真是没有必要的。最后再提醒一点海底场景的美感往往不是靠某一个模型“惊艳”而是靠“海草在动、鱼群在绕圈、光线在晃”这种组合效果。我在这个项目里最大的体会是先搭好光照和后期再去扣模型细节效率会高很多。如果你也在做类似的水下Unity场景建议按“光照氛围 - 海草动画 - 鱼群逻辑 - 岩石摆放 - 性能优化”这个顺序推进能少走我这一路的弯路。