
1. 项目概述从静态模型到动态交互的沉浸感飞跃在Unity里做海面场景很多朋友可能都经历过费劲找了一个漂亮的水体Shader调了半天参数海面波光粼粼看着挺像那么回事。然后你兴冲冲地把做好的船模拖进去结果发现船像个铁疙瘩一样杵在海面上纹丝不动或者只是僵硬地上下平移瞬间就出戏了。这种“水上漂”的割裂感是很多海洋场景项目从“好看”迈向“可信”的关键瓶颈。“随波起伏的船只”这个需求核心要解决的就是物理可信度与视觉沉浸感的问题。它不仅仅是让船体简单地跟随一个正弦波上下运动而是要模拟船体作为一个刚体与复杂、动态的海面进行实时、双向的物理交互。船头应该劈开浪花船身会根据波浪的走向产生横摇和纵摇船尾会留下扰动的水痕。这背后涉及到海浪的数学模拟、刚体物理计算、以及两者之间高效的数据耦合。我最近在为一个海洋探索类的独立游戏项目搭建这套系统从最基础的波浪公式开始到整合物理引擎再到优化性能踩了不少坑也总结出一套相对稳定、效果不错的方案。这个教程将分享我的实现路径并提供一套可复用的核心脚本与Shader资源思路。无论你是想做一个简单的航海demo还是开发复杂的海战游戏这套关于船只与海浪交互的底层逻辑都能为你提供一个坚实的起点。2. 核心思路拆解分离、采样与力的艺术实现船只随波起伏最忌讳的就是把船和海浪的代码逻辑搅在一起。一个清晰、解耦的架构是后期调试和效果迭代的基础。我的核心思路可以概括为三步海浪高度场生成 - 船体采样与姿态计算 - 物理力施加。2.1 海浪模拟不止于视觉的高度场首先我们必须建立一个海浪高度场。这个“场”是一个数学函数对于世界坐标系中的任意一个水平位置(x, z)它都能返回该点当前时刻的海平面高度y。这是所有计算的基础。常见的模拟方法有几种Gerstner波这是游戏和图形学中最经典的海浪模型。它的优势在于不仅能计算高度还能通过波函数对水平坐标进行偏移从而模拟出波峰尖锐、波谷宽阔的真实波形并且能自然地计算出每个点的法线用于光照和切线用于流动贴图。FFT快速傅里叶变换海洋通过叠加大量不同频率、方向的正弦波并进行逆FFT来生成高度场。效果非常逼真能模拟广阔的、统计学的海面但计算量较大通常用于高端PC或主机游戏。贴图位移使用一张预先计算好的、循环播放的高度图来驱动顶点位移。性能极好但动态性和变化性较弱适合移动平台或风格化水面。对于大多数需要实时交互的中小型项目叠加多个Gerstner波是一个在效果和性能上取得绝佳平衡的方案。你可以通过调整每个波的振幅、波长、速度和方向来组合出从平静湖面到惊涛骇浪的不同海况。关键点我们模拟海浪的首要目的不是为了渲染那是Shader的工作而是为了在CPU端或Compute Shader中生成一个可供查询的高度场数据。船只物理系统需要频繁地向这个“场”查询其所在位置的海面高度和法线。2.2 船体交互多采样点与浮力模拟让一个长方体船体简单地采样其中心点下方的海浪高度并移动效果会很假。真实的船只会因为形状和重量分布与海面产生多个接触点。我的做法是在船体上预设多个采样点Sample Points。这些点通常沿着船体的龙骨线从船头到船尾的中线和船舷分布。在每一帧物理更新时获取每个采样点在世界空间中的水平坐标(x, z)。向海浪高度场查询该坐标处的当前海面高度y_water和海面法线。比较采样点当前的世界高度y_point与y_water。如果y_point低于y_water说明该点浸入水中需要受到浮力反之则没有。浮力的计算遵循阿基米德原理的简化版浮力大小与浸入体积成正比方向垂直于海平面即沿着海面法线方向。我们可以用采样点浸入的深度(y_water - y_point)来近似估算该点贡献的浮力大小。最后将所有采样点产生的浮力汇总计算出一个总的作用力和扭矩旋转力施加到船体的Rigidbody上。2.3 物理集成Rigidbody与力的艺术这里是实现“真实感”的关键也是新手最容易出错的地方。绝对不能直接修改船体Transform的position和rotation必须通过Unity的物理引擎PhysX来驱动。我们需要为船只添加一个Rigidbody组件并合理设置质量Mass、阻力Drag/Angular Drag和碰撞体Collider。在FixedUpdate中这是物理更新的周期遍历所有采样点计算当前帧的总浮力矢量totalForce和总扭矩totalTorque。使用Rigidbody.AddForceAtPosition(force, position)方法。这个方法允许你在世界空间的特定位置采样点位置施加一个力物理引擎会自动计算出这个力对刚体产生的线性运动移动和角运动旋转影响。这是实现逼真横摇纵摇的核心API。适当增加Rigidbody的线性阻力和角阻力可以模拟水对船体的阻尼效应让运动不会过于“弹跳”更显沉稳。通过这种方式船体的运动完全由物理引擎驱动它会与Unity环境中的其他物理对象比如其他船只、漂浮物自然交互碰撞检测也完全正常。3. 核心模块实现详解下面我将分步骤拆解关键代码模块。我会提供核心代码片段并解释其原理你可以根据自己项目的具体需求进行调整。3.1 Gerstner波高度场的C#实现我们首先创建一个WaveManager单例类用于管理和计算全局的海浪高度场。它存储了多个Gerstner波的参数。using System.Collections.Generic; using UnityEngine; [System.Serializable] public class GerstnerWave { public float amplitude; // 振幅 (A): 波峰到静水面的高度 public float wavelength; // 波长 (L): 两个波峰之间的距离 public float speed; // 速度 (S): 波前进的快慢 public Vector2 direction; // 方向 (D): 标准化后的(x,z)方向向量 public float steepness; // 陡峭度 (Q): 控制波峰的尖锐程度通常 1/(amplitude * waveCount) private float _frequency; private float _phaseConstant; public float Frequency _frequency; public float PhaseConstant _phaseConstant; public void UpdateDerivedParams() { // 根据物理公式推导频率 w 2π / L, 相位常数 φ S * 2π / L // 深水波色散关系简化ω² gk这里我们用速度来控制相位更直观 _frequency 2 * Mathf.PI / wavelength; _phaseConstant speed * _frequency; } } public class WaveManager : MonoBehaviour { public static WaveManager Instance; public ListGerstnerWave waves new ListGerstnerWave(); public float timeScale 1.0f; // 全局时间缩放用于控制海浪动画速度 private float _currentTime 0f; void Awake() { if (Instance ! null Instance ! this) { Destroy(this.gameObject); } else { Instance this; } foreach (var wave in waves) { wave.UpdateDerivedParams(); } } void Update() { _currentTime Time.deltaTime * timeScale; } // 核心方法获取世界坐标(x,z)处海浪的高度(y)和法线(normal) public void GetWaveInfo(Vector3 worldPos, out float height, out Vector3 normal) { height 0f; Vector3 tangent new Vector3(1, 0, 0); // 初始切线 Vector3 binormal new Vector3(0, 0, 1); // 初始副切线 Vector2 posXZ new Vector2(worldPos.x, worldPos.z); foreach (var wave in waves) { float freq wave.Frequency; float phase wave.PhaseConstant; Vector2 dir wave.direction.normalized; // 计算点积和相位 float dot Vector2.Dot(dir, posXZ); float theta freq * dot phase * _currentTime; // 计算正弦余弦值避免重复计算 float cosTheta Mathf.Cos(theta); float sinTheta Mathf.Sin(theta); // Gerstner波公式核心部分 float QA wave.steepness * wave.amplitude; height wave.amplitude * sinTheta; // 水平偏移Gerstner波特性 float horizontalOffset QA * cosTheta; Vector2 offset dir * horizontalOffset; // 对切线和副切线的贡献用于计算法线 float WA wave.amplitude * freq; float factor WA * cosTheta; tangent new Vector3(-dir.x * dir.x * factor, dir.x * factor, -dir.x * dir.y * factor); binormal new Vector3(-dir.x * dir.y * factor, dir.y * factor, -dir.y * dir.y * factor); } // 通过切线和副切线的叉积计算法线 normal Vector3.Cross(binormal, tangent).normalized; // 注意这里计算出的法线是未归一化的但叉积后再归一化结果正确 } }这个类提供了GetWaveInfo方法它是船只脚本查询海面信息的唯一接口。在Shader中我们也可以用完全相同的公式来计算顶点偏移和法线确保视觉和逻辑一致。3.2 船只浮力计算器脚本接下来是附着在船体上的核心脚本BoatBuoyancy。using System.Collections.Generic; using UnityEngine; public class BoatBuoyancy : MonoBehaviour { public Rigidbody boatRigidbody; public float buoyancyStrength 2.0f; // 浮力系数用于微调 public float waterDrag 1.0f; // 水中线性阻力 public float waterAngularDrag 1.5f; // 水中旋转阻力 public ListTransform samplePoints new ListTransform(); // 采样点空物体 // 调试用在Scene视图中绘制采样点和力 public bool debugDraw true; public float debugForceScale 0.1f; private float _originalDrag; private float _originalAngularDrag; void Start() { if (boatRigidbody null) boatRigidbody GetComponentRigidbody(); if (boatRigidbody null) { Debug.LogError(BoatBuoyancy requires a Rigidbody on the same GameObject or assigned.); enabled false; return; } _originalDrag boatRigidbody.drag; _originalAngularDrag boatRigidbody.angularDrag; // 如果没有手动指定采样点尝试在子物体中寻找 if (samplePoints.Count 0) { var points GetComponentsInChildrenTransform(); // 这里可以添加一个命名规则例如所有名为“BuoyancyPoint”的子物体 // 简单起见这里只是警告 Debug.LogWarning(No sample points assigned for buoyancy. Please assign transforms in the inspector.); } } void FixedUpdate() { if (WaveManager.Instance null) return; bool isUnderwater false; float totalSubmergedVolume 0f; // 估算总浸入体积可用于高级效果 foreach (var point in samplePoints) { Vector3 worldPos point.position; WaveManager.Instance.GetWaveInfo(worldPos, out float waterHeight, out Vector3 waterNormal); float pointHeight worldPos.y; float depth waterHeight - pointHeight; // 如果采样点在水面以下 if (depth 0) { isUnderwater true; // 简化浮力计算力的大小与浸入深度成正比方向为水面法线方向 // 更精确的模型可以考虑采样点代表的体积 float buoyancyForceMagnitude buoyancyStrength * depth * Physics.gravity.magnitude; Vector3 buoyancyForce waterNormal * buoyancyForceMagnitude; // 在采样点位置施加浮力 boatRigidbody.AddForceAtPosition(buoyancyForce, worldPos); // 调试绘制 if (debugDraw) { Debug.DrawLine(worldPos, worldPos buoyancyForce * debugForceScale, Color.cyan); } totalSubmergedVolume depth; // 粗略估算 } } // 根据是否在水下调整阻力模拟水的阻尼效果 if (isUnderwater) { boatRigidbody.drag waterDrag; boatRigidbody.angularDrag waterAngularDrag; } else { // 完全离开水面时恢复原阻力如空气阻力 boatRigidbody.drag _originalDrag; boatRigidbody.angularDrag _originalAngularDrag; } } // 在编辑器Scene视图中可视化采样点 void OnDrawGizmosSelected() { if (!debugDraw) return; Gizmos.color Color.green; foreach (var point in samplePoints) { if (point ! null) Gizmos.DrawSphere(point.position, 0.1f); } } }脚本使用要点采样点布置在船体模型下创建多个空物体GameObject作为采样点。通常沿龙骨线船底中线从船头到船尾布置3-5个点在船体两侧再各布置1-2个点。点的数量和位置直接影响船的稳定性和摇晃行为需要反复调试。参数调试buoyancyStrength是关键。值太小船会沉值太大船会像软木塞一样剧烈弹跳。waterDrag和waterAngularDrag用于模拟水的粘滞阻力让船的运动有“重量感”和“阻尼感”不会无限摇晃。AddForceAtPosition这是魔法发生的地方。在浸入水中的采样点位置施加一个向上的力物理引擎会自动计算出这个力如何使船体旋转产生横摇、纵摇和移动。3.3 与渲染同步的海浪Shader概念为了让船体与海浪的视觉效果完美融合我们需要一个与WaveManager使用相同参数的海面Shader。这里给出一个Unlit Shader的核心片段展示如何在顶点着色器中应用Gerstner波。// 这是一个简化的Shader结构实际项目可能需要包含光照、镜面反射等 Shader Custom/OceanGerstner { Properties { _MainTex (Texture, 2D) white {} _Color (Color, Color) (0.1, 0.3, 0.6, 1) // 波浪参数可以通过脚本传递这里也可以定义一些覆盖参数 _WaveAmplitude (Wave Amplitude, Float) 0.5 _WaveLength (Wave Length, Float) 10 // ... 其他属性 } SubShader { Tags { RenderTypeOpaque } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc // 假设波浪参数通过全局数组从C#脚本传递进来 // 在实际项目中你可能需要使用StructuredBuffer或全局数组 uniform float4 _WaveParams[10]; // [振幅 波长 速度 陡峭度] uniform float2 _WaveDirections[10]; uniform float _GlobalTime; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; }; // Gerstner波计算函数与C#端保持一致 float3 GerstnerWave(float3 worldPos, float amplitude, float wavelength, float speed, float steepness, float2 direction) { float freq 2 * UNITY_PI / wavelength; float phase speed * freq; float dotProd dot(direction, worldPos.xz); float theta freq * dotProd phase * _GlobalTime; float cosT cos(theta); float sinT sin(theta); float QA steepness * amplitude; float height amplitude * sinT; float horizontal QA * cosT; float3 offset; offset.x direction.x * horizontal; offset.y height; offset.z direction.y * horizontal; return offset; } v2f vert (appdata v) { v2f o; // 将模型顶点转换到世界空间 float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; float3 waveOffset float3(0,0,0); float3 tangent float3(1,0,0); float3 binormal float3(0,0,1); // 叠加多个波示例叠加两个 for(int i 0; i 2; i) { float amp _WaveParams[i].x; float len _WaveParams[i].y; float spd _WaveParams[i].z; float stp _WaveParams[i].w; float2 dir _WaveDirections[i]; // 调用Gerstner函数这里简化了法线计算 waveOffset GerstnerWave(worldPos, amp, len, spd, stp, dir); } // 应用波浪偏移 worldPos waveOffset; o.vertex mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); o.uv v.uv; // 法线计算简化版实际需要更精确的切/副切计算 // 可以使用屏幕空间导数或传递计算好的法线 o.worldNormal normalize(float3(0,1,0)); // placeholder return o; } fixed4 frag (v2f i) : SV_Target { // 简单颜色输出 fixed4 col _Color; return col; } ENDCG } } }重要提示在实际项目中为了保持CPU物理和GPU渲染计算的一致性最佳实践是将波浪参数如振幅、方向定义在一个ScriptableObject或静态配置类中然后同时提供给WaveManagerC#和Shader通过MaterialPropertyBlock或全局Shader变量。这样可以确保船“感受”到的波浪和眼睛“看到”的波浪是完全同步的避免出现船在波谷而视觉在波峰的错位现象。4. 性能优化与高级技巧当你的海面上有多艘船或者波浪非常复杂时性能可能成为瓶颈。以下是一些优化思路和进阶技巧。4.1 性能优化策略采样点优化不是所有船都需要几十个采样点。对于小型、简单的船只4-6个关键点可能就足够了。可以通过脚本动态减少远处或不重要船只的采样点更新频率如每2-3帧更新一次。波浪查询批量化WaveManager.GetWaveInfo在每帧会被每个采样点调用多次。如果波的数量很多计算量不小。可以考虑使用Compute Shader将所有的采样点坐标打包成一个Buffer在Compute Shader中并行计算所有点的高度和法线结果读回CPU或直接用于GPU。这是处理大量交互对象如数百个漂浮物的最高效方法。预计算或简化对于远处或背景中的海面可以使用较低复杂度的波浪公式甚至用一张动态的高度图纹理来采样而不是实时计算Gerstner波。LOD细节层次系统为船只的浮力计算设置LOD。近距离高精度多采样点每帧更新远距离低精度少采样点降低更新频率甚至用简单的正弦波动画代替物理模拟。对象池与缓存对于频繁生成和销毁的漂浮物如炮弹溅落的水花、掉落的货物使用对象池管理其浮力脚本避免频繁的GetComponent和内存分配。4.2 增强真实感的进阶技巧船首尾浪与航迹可以通过在船头、船尾下方设置额外的粒子发射器或网格生成器来模拟。根据船体与水面的相对速度控制粒子的发射速率和大小。更高级的做法是使用RenderTexture绘制航迹贴图到水面上。动态阻力目前的waterDrag是常数。更真实的模拟是阻力应与船体浸入水中的横截面积和速度的平方成正比。你可以根据采样点估算的浸没横截面动态调整Rigidbody.drag。推进力与转向不要直接给Rigidbody施加扭矩来转向。更好的方法是在船尾两侧各设置一个“推力点”通过独立控制这两个点向后的力的大小差来实现转向类似坦克的差速转向这样更符合真实船舶的螺旋桨或舵效原理。海浪对船的阻力目前船只会被动地被海浪推着走。可以增加一个计算当船体侧面船舷采样点与波浪运动方向垂直时受到一个侧向的阻力模拟海浪拍击船身的效果。5. 常见问题与调试实录在开发过程中我遇到了不少典型问题这里记录下排查思路和解决方法。5.1 船只表现异常问题排查表问题现象可能原因排查与解决方法船体剧烈抖动、抽搐1. 物理更新帧率(FixedUpdate)与渲染帧率(Update)不匹配。2. 浮力计算过于频繁或力值过大。3. 采样点位置与碰撞体不匹配。1. 检查Time.fixedDeltaTime尝试调小如0.005s以增加物理更新频率。2. 降低buoyancyStrength或在FixedUpdate中限制最大施加力。3. 开启debugDraw观察采样点是否在船体模型内部或奇怪位置确保其与视觉模型和物理碰撞体大致对齐。船像石头一样沉底1. 浮力系数(buoyancyStrength)太小。2. 船体Rigidbody质量(Mass)太大。3. 采样点全部或大部分位于水面以上。1. 逐步增大buoyancyStrength观察效果。2. 合理设置船体质量一艘小帆船的质量可能在100-500之间货轮则需数千。3. 在Scene视图中运行游戏查看采样点Gizmo确保有足够多的点在水线以下。可能需要调整采样点的垂直位置。船体旋转缓慢或无反应1.Rigidbody的角阻力(angularDrag)过高。2. 浮力采样点分布过于集中或对称。3. 施加的扭矩太小。1. 适当降低waterAngularDrag。2. 确保采样点在船头、船尾、左右舷都有分布不对称的浸没才能产生旋转扭矩。3. 检查AddForceAtPosition的力方向和大小确保在不同倾斜状态下力的作用点能产生有效的力臂。船与海浪视觉不同步CPU端(WaveManager)和Shader端使用的波浪参数、时间或公式不一致。确保波浪的振幅、波长、速度、方向在两个地方完全一致。使用同一个Time.time乘以timeScale作为时间输入。最好将参数集中管理。多船时性能骤降每艘船每帧进行多次复杂的波浪计算CPU压力大。实施4.1节的优化策略。首先考虑减少每艘船的采样点数量其次是降低非主角船的更新频率最后考虑使用Compute Shader进行批处理。5.2 调试与可视化技巧善用Debug.Draw如代码所示在FixedUpdate中使用Debug.DrawLine绘制每个采样点上的浮力矢量用青色线表示。这能让你直观地看到哪些点在水下受力方向如何是调试浮力逻辑最强大的工具。场景视图调试在Scene视图运行时你可以选择船体查看其Rigidbody组件的速度、角速度矢量帮助你理解船体的运动状态。自定义Editor工具可以为BoatBuoyancy脚本编写一个简单的Editor脚本在Inspector中增加一个按钮一键在船体周围按规则生成采样点空物体提升工作流效率。参数动画曲线调试将关键参数如buoyancyStrength声明为public在游戏运行时直接在Inspector中调整并观察效果Unity会记录下修改过程非常方便。这套系统搭建完毕后你的船只就不再是场景中一个孤立的模型而是真正融入动态海洋环境的一部分。你可以通过调整波浪参数来创造从风平浪静到暴风骤雨的各种海况观察船只的不同反应。这种底层物理交互带来的真实感是任何简单的动画都无法比拟的。