1. 项目概述:为什么“水浮力”是Unity开发中的硬骨头?
在Unity里做水,很多开发者都卡在“浮力”这个环节。你可能会觉得,不就是让物体在水里漂起来吗?加个向上的力不就行了?但实际动手你会发现,事情远没这么简单。一个漂浮的箱子,它应该随着波浪起伏,在水面倾斜时自动调整姿态,甚至在水下部分受到不同的阻力。如果只是简单给个向上的力,物体会像火箭一样窜出水面,或者像石头一样僵硬地杵在那里,毫无真实感。
这就是为什么“逼真的水浮力效果”会成为很多项目,尤其是涉及航海、生存建造或物理解谜类游戏的一个技术难点。它本质上是一个物理模拟与视觉表现深度耦合的问题。你需要计算物体浸入水中的体积(这本身在实时计算中就很复杂),根据浸没程度施加准确的浮力,同时还要处理物体与水面的碰撞、旋转扭矩以及流体阻力的模拟。网络上能找到的很多方案,要么性能开销巨大,要么效果过于简陋,难以在移动端或WebGL平台(这也是为什么“unity webgl初始化很久”会成为热词,复杂的物理计算是原因之一)稳定运行。
这篇指南的目的,就是拆解这个复杂问题,提供一个从原理到实现、兼顾效果与性能的完整解决方案。无论你是想为你的开放世界游戏添加一片可交互的海洋,还是为你的解谜游戏制作一个漂浮机关,这里的内容都能让你避开我踩过的那些坑,快速实现一个既好看又“好用”的水浮力系统。
2. 核心思路:从物理公式到可运行的代码
实现逼真浮力的核心,是阿基米德原理。但在实时渲染的游戏世界里,我们无法像科学计算软件那样进行精确的流体力学模拟。因此,我们的策略是:用简化的物理模型去逼近真实效果,并通过Shader和代码的配合来“欺骗”玩家的眼睛。
2.1 浮力计算的基本模型
浮力公式很简单:F = ρ * g * V。其中,ρ是液体密度,g是重力加速度,V是物体排开液体的体积,也就是浸入水中的体积。在Unity中,重力加速度Physics.gravity是已知的(通常为(0, -9.81, 0))。液体密度我们可以自定义一个参数,比如1.0表示水的密度。
真正的难点在于V,即实时计算物体浸入水中的体积。对于一个形状复杂的网格模型,进行精确的体积计算在每帧进行是不现实的。因此,我们采用一个经典且高效的近似方案:使用多个采样点(浮力点)来估算浸没体积。
你可以把物体想象成一个由许多小点构成的系统。我们在物体上预设一系列的点(比如在物体的底部、四周),每一帧,我们检查这些点相对于水面的位置。如果这个点在水面以下,它就贡献一部分浮力;在水面以上,则不贡献。所有水下点贡献的浮力合力,作用在物体的大致中心(或一个计算出的平均作用点),就模拟出了浮力效果。同时,这些点浸没的深度不同,还会产生旋转的扭矩,让物体自然地摆正。
2.2 方案选型:为什么不用Trigger或内置物理?
很多新手会想:用个大的水体碰撞体(Trigger),进入后给一个恒力不就完了?或者用Unity内置的Buoyancy Effector组件?
- 简单的Trigger+恒力:效果非常差。物体会获得一个恒定、方向不变的力,完全无视其姿态和浸没程度,导致行为诡异,无法模拟倾斜、翻滚等真实物理反馈。
- 内置Buoyancy Effector:这是Unity标准物理组件的一部分,但它更适用于2D物理或非常简单的3D原型。它对复杂3D网格的支持有限,自定义程度低,且难以与自定义的水体着色器(Shader)进行深度交互,以达到视觉与物理的统一。
因此,自定义脚本结合Shader计算是追求效果与灵活性的必由之路。这套方案可以让你:
- 完全控制:精确控制浮力、阻力、扭矩的每一个参数。
- 高性能:通过优化采样点数量和计算频率,可以在移动端和WebGL上良好运行(这也是解决“unity性能优化”和“unity webgl初始化很久”相关问题的实践)。
- 强交互:易于扩展,比如实现不同密度液体(油、岩浆)、动态水位、物体部分破损进水等高级效果。
3. 分步实现:构建你的浮力系统
我们将系统拆解为三个部分:水体渲染、浮力计算脚本、以及两者的交互。
3.1 第一步:创建基础的水体着色器(Shader)
水体的视觉表现是浮力效果的“舞台”。我们不需要一开始就实现无比复杂的海浪,一个能提供准确世界空间高度的平静水面就是最好的起点。这里以URP(Universal Render Pipeline)为例,使用Shader Graph会更直观。
- 创建Shader Graph:在URP项目中,创建一个新的Shader Graph,命名为“Water_Basic”。
- 输出世界位置:我们需要知道水面每一个像素在世界空间中的坐标,尤其是Y轴(高度)信息。使用
Position节点,将其空间设置为World。 - 构建水面平面:一个简单的水面可以就是一个在Y轴某个高度(例如0)的无限大平面。但在Shader中,我们通过物体的世界坐标来定义。你可以创建一个
Float属性_WaterLevel,用来动态控制水位高度。 - 关键输出:将计算出的世界空间Y坐标(
Position节点的Y分量)作为一个输出项。更专业的做法是,我们输出一个“深度”信息,即物体表面点到水面的距离。但初期,我们只需让Shader能向脚本提供“此处水面高度”的信息。一个更通用的方法是,将水体的世界坐标和法线等信息写入一张渲染纹理(Render Texture)或通过自定义缓冲区传递,但这涉及更高级的渲染管线设置。为了快速实现,我们采用一个更实用的方法:让浮力脚本直接访问水体材质的属性。
实操心得:在Shader Graph中,你可以添加一个
Vector1属性_WaterHeight,然后在脚本中通过Material.SetFloat(“_WaterHeight”, level)来动态更新全局水位。对于波浪,你可以使用Simple Noise或Sine节点对世界XZ坐标进行采样,叠加到_WaterHeight上,这样就能得到随时间变化的波浪高度。记住,脚本需要获取的是顶点变换后的世界高度,而不是未经扰动的平面高度。
3.2 第二步:编写核心浮力计算脚本(C#)
这是整个系统的“大脑”。我们将创建一个名为BuoyantObject的脚本,挂载到需要浮起来的物体上。
using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class BuoyantObject : MonoBehaviour { [Header("浮力参数")] public float waterDensity = 1.0f; // 液体密度 public float waterLevel = 0.0f; // 全局静态水位(备选方案) public Transform[] buoyancyPoints; // 浮力采样点数组 public float pointsPower = 1.0f; // 每个点的力量系数 public float dragInWater = 1.0f; // 水中阻力 public float angularDragInWater = 0.5f; // 水中角阻力 [Header("高级选项")] public bool useDynamicWaterHeight = true; // 是否从水体获取动态高度 public Renderer waterRenderer; // 关联的水体渲染器(用于动态获取高度) private Rigidbody rb; private float originalDrag; private float originalAngularDrag; void Start() { rb = GetComponent<Rigidbody>(); originalDrag = rb.drag; originalAngularDrag = rb.angularDrag; // 如果没指定浮力点,就在物体底部自动生成一个 if (buoyancyPoints == null || buoyancyPoints.Length == 0) { Debug.LogWarning("未指定浮力点,将在物体中心下方创建一个默认点。", this); GameObject point = new GameObject("BuoyancyPoint"); point.transform.SetParent(transform); point.transform.localPosition = new Vector3(0, -0.5f, 0); // 假设物体中心在原点 buoyancyPoints = new Transform[] { point.transform }; } } void FixedUpdate() // 物理计算放在FixedUpdate中 { bool isUnderwater = false; float totalSubmergedVolume = 0f; Vector3 buoyancyForce = Vector3.zero; Vector3 torqueForce = Vector3.zero; foreach (Transform point in buoyancyPoints) { Vector3 worldPoint = point.position; // 获取该点处的水面高度 float pointWaterHeight = GetWaterHeightAtPosition(worldPoint); // 计算该点浸没深度(小于0表示在水下) float submersion = pointWaterHeight - worldPoint.y; if (submersion > 0) // 点在水面以上 { // 可以处理部分出水的情况,这里简化处理为完全出水则无浮力 continue; } // 点在水下 isUnderwater = true; // 浸没深度(取正值) float depth = Mathf.Abs(submersion); // 简化的体积贡献:假设每个点代表相同体积,贡献与深度成正比 float pointVolume = depth * pointsPower; totalSubmergedVolume += pointVolume; // 计算该点贡献的浮力(方向向上) Vector3 force = waterDensity * Physics.gravity.magnitude * pointVolume * Vector3.up; buoyancyForce += force; // 计算扭矩:浮力作用点与质心之间的偏移会产生旋转力矩 Vector3 r = worldPoint - rb.worldCenterOfMass; torqueForce += Vector3.Cross(r.normalized, force); } if (isUnderwater) { // 应用浮力合力 rb.AddForce(buoyancyForce, ForceMode.Force); // 应用扭矩 rb.AddTorque(torqueForce, ForceMode.Force); // 增加水中阻力 rb.drag = dragInWater; rb.angularDrag = angularDragInWater; } else { // 恢复空中阻力 rb.drag = originalDrag; rb.angularDrag = originalAngularDrag; } } float GetWaterHeightAtPosition(Vector3 worldPos) { if (useDynamicWaterHeight && waterRenderer != null) { // 方案A:通过材质属性获取(适用于平静水面或全局波浪) // 这里假设你的水体Shader有一个“_WaterLevel”或“_TimeBasedHeight”的属性 // 实际中,你可能需要根据worldPos.xz采样一张高度图,这需要更复杂的设置。 return waterRenderer.material.GetFloat("_WaterLevel"); } // 方案B:使用简单的全局水位(适用于原型或静止水体) return waterLevel; } }脚本关键点解析:
buoyancyPoints:这是效果的灵魂。你需要在物体上手动放置一些空物体作为采样点。对于一个大木箱,在四个底角和中点各放一个点,效果会很好。点的数量是性能与精度的权衡。GetWaterHeightAtPosition函数:这是连接视觉水体与物理计算的关键。简单的全局水位(waterLevel)只能做静止水面。要实现波浪互动,你需要让这个函数能返回任意worldPos位置的水面高度。这可以通过采样一个由水体Shader生成的**高度图(Height Map)**来实现,这是更高级但效果最好的方法。作为起步,你可以先使用全局水位或一个简单的正弦波函数来模拟。- 扭矩计算:
Vector3.Cross(r, force)计算了力臂r与力force的叉积,结果就是使物体旋转的力矩。这是物体能在水面摆正的关键。 - 阻力管理:物体在水中运动时,受到的阻力远大于空气中。动态调整
Rigidbody的drag和angularDrag可以非常简单地模拟这种粘滞感。
3.3 第三步:配置场景与调试
- 准备一个带刚体(Rigidbody)的物体,比如一个Cube。为其添加
BuoyantObject脚本。 - 创建浮力点:在Cube下创建几个空子物体,拖拽到脚本的
Buoyancy Points数组中。将这些点分布在物体底部。 - 创建水体:可以使用一个缩放很大的Quad或Plane,赋予你刚才创建的“Water_Basic”材质。将这个水体的
Renderer组件拖拽到脚本的Water Renderer字段(如果使用动态高度)。 - 运行测试:将物体放到水面以上,让它下落。你应该能看到它撞击水面,溅起水花(如果你有粒子系统),然后摇晃几下,最终稳定地漂浮在水面。调整
waterDensity、pointsPower和dragInWater参数来获得不同的漂浮感觉。
注意事项:浮力点的位置至关重要。如果所有点都集中在物体中心下部,物体将很难翻转。确保点在物体浸没部分的边缘也有分布,这样才能产生恢复平衡的扭矩。对于非常复杂的模型(如一艘船),你可能需要数十个点来获得平滑的效果。
4. 效果优化与高级技巧
基础系统跑通后,我们可以从性能和表现力两方面进行深化。
4.1 性能优化策略
- 采样点优化(LOD):对于远处的漂浮物,减少
buoyancyPoints的数量。可以通过脚本根据物体与摄像机的距离动态启用/禁用部分采样点。 - 计算频率优化:不是每一帧都需要进行高精度的浮力计算。对于静止或缓慢移动的物体,可以将计算放在一个更慢的协程(Coroutine)中,比如每0.2秒计算一次力并持续施加。
- 使用Jobs System与Burst:如果你有大量需要浮力计算的物体(比如上百个碎片),
buoyancyPoints的遍历计算是并行化的绝佳场景。你可以使用Unity的C# Job System和Burst Compiler(热词“unity jobs burst”所指)来将计算转移到多线程,并获得编译优化,性能提升会非常显著。这属于高级优化范畴。 - 简化高度查询:动态从Shader获取每个点的高度可能是性能瓶颈。如果水体波浪是全局的、可通过数学公式描述的(如正弦波叠加),直接在
GetWaterHeightAtPosition中用代码计算,避免访问材质属性。
4.2 表现力增强技巧
- 水面交互反馈:
- 粒子系统:当浮力点快速从水下穿出水面时(物体落水瞬间),在该点位置播放一个水花粒子效果。
- 贴花(Decal):物体与水接触的周围,可以动态生成一个湿润的贴花或泡沫贴花,增强视觉融合感。
- 音效:根据物体入水的速度和体积,触发不同的入水音效。
- 实现动态波浪交互:真正的“圣杯”是让物体能对水面产生反作用,比如船头推开波浪。这需要将浮力计算的结果(如下压力)反馈给水体的着色器,通常通过写入一张渲染纹理(Render Texture)作为波浪高度图的扰动来实现。这需要你熟悉Command Buffer或URP的Renderer Features。
- 处理部分浸没与流体阻力:目前的阻力模型比较简单。更真实的模拟是,根据物体浸没在水中的部分形状和速度,计算一个更复杂的流体阻力。这通常需要预计算物体的横截面积或使用更复杂的流体动力学库,但对于大多数游戏,调整
drag和angularDrag已经足够。
5. 常见问题与排查实录
即使按照步骤操作,你也可能会遇到一些奇怪的问题。下面是我在实际项目中遇到的一些典型情况及其解决方法。
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 物体在水面疯狂旋转或抖动 | 1. 浮力点分布过于集中或不对称。 2. 扭矩力过大。 3. 物理更新步长(Fixed Timestep)不合适。 | 1. 检查并调整浮力点位置,确保在物体底部均匀、对称分布。 2. 尝试减小 pointsPower或在水下部分增加更多点来分散力。3. 在 Project Settings -> Time中适当减小Fixed Timestep(如从0.02改为0.01),增加物理计算频率。 |
| 物体沉底或浮起过快 | 1.waterDensity参数设置不当。2. 物体质量(Rigidbody.mass)不合理。 3. 浮力点贡献的体积估算不准。 | 1. 调整waterDensity。大于1物体会更容易浮起,小于1则更容易下沉。2. 检查刚体的质量是否合乎现实比例。一个木箱的质量不应和铁箱一样。 3. 调试时,在 FixedUpdate中打印totalSubmergedVolume的值,看其变化是否平滑合理。 |
| 物体穿过水面或卡在水面 | 1. 水面碰撞体缺失或设置错误。 2. 浮力计算频率与物理引擎不同步。 3. 水面高度获取错误。 | 1. 确保水体GameObject有Mesh Collider(如果是复杂网格)或Box Collider(如果是平面)。2. 确保浮力计算在 FixedUpdate中进行,而不是Update。3. 在 GetWaterHeightAtPosition函数中添加Debug绘制,可视化每个采样点获取到的水面高度线,检查是否与视觉水面吻合。 |
| WebGL平台性能极差或初始化慢 | 1. 浮力点过多或计算过于复杂。 2. 在Awake/Start中进行了大量计算或资源加载。 | 1. 严格优化浮力点数量,对远处物体使用LOD。 2. 考虑将部分计算移至异步流程或降低频率。WebGL是单线程,任何阻塞操作都会导致卡顿。确保你的水体Shader复杂度适中,避免使用过多复杂的噪声和纹理采样。这与热词“unity webgl初始化很久”和“unity性能优化”直接相关。 |
| 多个物体漂浮时相互穿透 | 浮力系统只处理水与物体的关系,不处理物体间的碰撞。 | 确保所有漂浮物体的刚体都设置了合适的碰撞体(Collider),并且物理材质(Physic Material)的摩擦力(Friction)和弹力(Bounciness)设置合理。对于密集漂浮物,可能需要调整刚体的碰撞检测模式(Collision Detection Mode)为Continuous或Continuous Dynamic以防止高速穿透。 |
最后再分享一个小技巧:调试物理效果时,善用Unity的Physics Debug Visualization。在Game视图右上角的下拉菜单中,开启“Colliders”和“Rigidbodies”的显示,可以清晰地看到碰撞体边界和力的作用方向,对于理解物体为何旋转、为何下沉非常有帮助。实现一个逼真的系统从来不是一蹴而就的,它需要你反复调整参数、观察现象、理解背后的物理原理。从这个基础的浮力系统出发,你已经拥有了一个强大的工具,可以在此基础上创造出令人惊叹的水体交互体验。