交互水

使用模拟Springs 和 Sprite Shape 的 Unity 交互式 2D 水。 本教程以类似的方式制作,您可以通过不同的示例逐步了解如何创建 2D 水。

最终效果为玩家跳入水中有水面波纹,游泳的时候水面前高后低

实现思路来源

【【Unity教程搬运】Unity中带有Sprite Shape的2D水教程】https://www.bilibili.com/video/BV1MM4y1r7JU?vd_source=0c56e77847d28781d8cdd412054492d9

这里添加在有玩家游泳的情况下的水体扰动。

2D物理组件

Buoyancy Effector 2D浮力效应

Buoyancy Effector 2D 浮力效应:在特定区域内没有力度效果。
Use Collider Mask 使用碰撞遮罩,默认勾选,碰撞遮罩可以用于忽略某些层的碰撞效果,比如一块玻璃,如果是光Layer就可以穿过去,如果是物体Layer就会反弹。
Collider Mask 碰撞遮罩,用于设置哪些层产生碰撞效果。
Density 密度,从而影响对撞机的行为:那些有更高的密度,密度较低的浮动,和那些相同的流体密度表现
Surface Level 定义了浮力液体的表面。当一个对象超过这条线,不应用力。当一个物体相交或完全低于这条线,力被重新应用。
Linear Drag 线性阻力,如果物体在水上弹来弹去的,就是阻力给小了,这时候加点阻力就好了。
Angular Drag 角度阻力
Flow Angle 流动力在世界坐标的角度的方向,这个方向就是水流的方向。
Flow Magnitude 流动力大小。结合流体角,这个指定的水平力大小,相当于水的流速。
Flow Variation 随机流动力大小

给一个区域添加水的浮力效果

Render Shape Controller

给2D图片塑性的关键性性组件

在场景管理器后者assets文件中选择新建一个SpriteShapeProfile,有两个选项框进行操作,Open和Close,二者具体的区别如下图所示

image-20241121152219691

获取点

spritshapeController是改变形状的组件,类型是SpriteShapeController,Spline类型中保存着顶点,可以使用数组形式的索引进行设置操作

1
2
Spline waterSpline = spriteShapeController.spline; //spritshapeController是改变形状的组件
int waterPointsCount = waterSpline.GetPointCount();//获取到点的数量

在close的类型下

点的获取遵循数组的常规定义,从0开始作为第一个

1
2
3
4
5
6
7
 //对于一个矩形来讲,0123,四个点分别是左下,左上,右上和右下

//一直在2为止插入顶点就是将矩形的12点的中间插入顶点

Vector3 waterTopLeftCorner = waterSpline.GetPosition(1);

Vector3 waterTopRightCorner = waterSpline.GetPosition(2);

注意:

使用spline的GetPosition()方法获取到的vector3的位置是基于SpriteShapeController所在的游戏对象的局部坐标系,也就是模型坐标系

并且在本例子将局部坐标抓换为世界坐标和在水中进行游泳的物体进行计算的时候会出现索引越界的情况,因此使用的是将世界坐标转为局部坐标的方式

世界坐标和局部坐标的相互转换

二者的参数和返回类型一样,需要注意的是需要通过相关的局部坐标系的Transform来调用TransformPoint方法

即A为世界坐标系,B为的目标或这源头局部坐标系。

世界到局部

1
Vector3 objectPos = transform.InverseTransformPoint(waterSwimInDetect.transform.position);

局部到世界

1
transform.TransformPoint(waterSwimDetect.transform.position);

波纹点生成

Unity事件

Editor运行时才会被自动调用的函数

Unity实现的事件函数

1
2
3
4
5
6
void OnValidate() {
// Clean waterpoints

StartCoroutine(CreateWaves());

}

在序列化的数值被更改后,会自动调用该函数,

为避免歧义,当脚本中以下属性值在Unity中被可视化改变后会调用以上函数

1
2
[SerializeField] private float swimWaveCreateDistance = 0.2f;
[SerializeField] private float swimWaveEndDistance = 0.1f;

删除、生成点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
 IEnumerator CreateWaves() {
foreach (Transform child in wavePoints.transform) {
StartCoroutine(Destroy(child.gameObject));
}
yield return null;
SetWaves();
yield return null;
}

//销毁原本的所有点
IEnumerator Destroy(GameObject go) {
yield return null;
DestroyImmediate(go);
}

//新生成点并配置游戏对象
private void SetWaves() {
Spline waterSpline = spriteShapeController.spline;
int waterPointsCount = waterSpline.GetPointCount();

//根据close类型中点的分布,1、2组成的线在该矩形图片的初始上方
//每次删除index为2的点,后面的会自动向前补齐
//CorsnerCount的值默认 = 2
//该循环会保证1、2线上有两个点
for (int i = CorsnersCount; i < waterPointsCount - CorsnersCount; i++) {
waterSpline.RemovePointAt(CorsnersCount);
}

//对于一个矩形来讲,0123,四个点分别是左下,左上,右上和右下
//一直在2为止插入顶点就是将矩形的12点的中间插入顶点
//GetPosition的点是基于该 SpriteShapeController(这里的实例是WaterSpline)组件所在的游戏对象的局部坐标
Vector3 waterTopLeftCorner = waterSpline.GetPosition(1);
Vector3 waterTopRightCorner = waterSpline.GetPosition(2);
float waterWidth = waterTopRightCorner.x - waterTopLeftCorner.x;

pointOriginPositionY = waterTopLeftCorner.y;

//计算每个点之间的宽度
float spacingPerWave = waterWidth / (WavesCount+1);

//在1、2线上插入新的顶点
for (int i = WavesCount; i > 0 ; i--) {

int index = CorsnersCount;
float xPosition = waterTopLeftCorner.x + (spacingPerWave*i);
Vector3 wavePoint = new Vector3(xPosition, waterTopLeftCorner.y, waterTopLeftCorner.z);
waterSpline.InsertPointAt(index, wavePoint);

waterSpline.SetHeight(index, 0.1f);//设置index位置的水波高度为0.1。这意味着在样条曲线上,这个点的y坐标会增加0.1。实际上要改变点的位置,尤其是在视觉上向上浮动,需要在上一步的InserPointAt()或者SetPosition()函数中通过索引和新的vector3进行改变,这个的SetHieight()效果不佳
waterSpline.SetCorner(index, false);//将index位置的点设置为非角点。在样条曲线中,角点会创建一个尖锐的拐角,而非角点则创建平滑的过渡。
waterSpline.SetTangentMode(index, ShapeTangentMode.Continuous);//设置index位置的切线模式为连续。ShapeTangentMode.Continuous表示样条曲线在该点处的切线是平滑连续的,没有突变,这有助于创建平滑的水波效果。

}



//实例化给定游戏对象到新加点的位置上,目的是为了对齐局部坐标,能直接使用游戏对象的局部坐标位置改变Spline中的点位置
springs = new();
for (int i = 0; i <= WavesCount+1; i++) {
int index = i + 1;

Smoothen(waterSpline, index);//平滑

GameObject wavePoint = Instantiate(wavePointPref, wavePoints.transform, false);
wavePoint.transform.localPosition = waterSpline.GetPosition(index);

WaterSpring waterSpring = wavePoint.GetComponent<WaterSpring>();
waterSpring.Init(spriteShapeController);
springs.Add(waterSpring);

}

//本地调用
spline = waterSpline;

}

平滑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void Smoothen(Spline waterSpline, int index)
{
Vector3 position = waterSpline.GetPosition(index);
Vector3 positionPrev = position;
Vector3 positionNext = position;
if (index > 1) {
positionPrev = waterSpline.GetPosition(index-1);
}
if (index - 1 <= WavesCount) {
positionNext = waterSpline.GetPosition(index+1);
}

Vector3 forward = gameObject.transform.forward;

float scale = Mathf.Min((positionNext - position).magnitude, (positionPrev - position).magnitude) * 0.33f;

Vector3 leftTangent = (positionPrev - position).normalized * scale;
Vector3 rightTangent = (positionNext - position).normalized * scale;

SplineUtility.CalculateTangents(position, positionPrev, positionNext, forward, scale, out rightTangent, out leftTangent);

waterSpline.SetLeftTangent(index, leftTangent);
waterSpline.SetRightTangent(index, rightTangent);
}

代码解释

1
Vector3 position = waterSpline.GetPosition(index);
  • 获取样条曲线上索引为index的点的位置。
1
2
3
4
5
6
if (index > 1) {
positionPrev = waterSpline.GetPosition(index-1);
}
if (index - 1 <= WavesCount) {
positionNext = waterSpline.GetPosition(index+1);
}
  • 获取当前点的前一个和后一个点的位置,如果它们存在的话。
1
float scale = Mathf.Min((positionNext - position).magnitude, (positionPrev - position).magnitude) * 0.33f;
  • 计算当前点到前后点的最小距离,并乘以0.33来得到切线长度的缩放因子。
1
2
Vector3 leftTangent = (positionPrev - position).normalized * scale;
Vector3 rightTangent = (positionNext - position).normalized * scale;
  • 计算当前点的左右切线向量,基于前后点的位置和缩放因子。
1
SplineUtility.CalculateTangents(position, positionPrev, positionNext, forward, scale, out rightTangent, out leftTangent);
  • 使用SplineUtility类的方法来优化左右切线的计算。
1
2
waterSpline.SetLeftTangent(index, leftTangent);
waterSpline.SetRightTangent(index, rightTangent);
  • 将计算出的左右切线应用到样条曲线的当前点,以平滑曲线。

波纹模拟

注意,该部分有两个不同实现点

跳水部分顶点的位置改变是通过和顶点位置同步的WaterSpring游戏对象进行控制

游泳则是直接改变Shape的顶点

总结就是前者有同步的游戏对象位置和单独的弹簧模拟,后者没有

WaterSpringController的执行代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
void FixedUpdate()
{
JumpInWatetWaveChange();
SwimWaveChange();
//波传递
UpdateSprings();
}

private void JumpInWatetWaveChange()
{
// 物体跳入水中进行的扰动
foreach(WaterSpring waterSpringComponent in springs) {
waterSpringComponent.WaveSpringUpdate(springStiffness, dampening);
waterSpringComponent.WavePointUpdate();
}

}

private void SwimWaveChange()
{
// 游泳前后扰动
for (int i = 2; i <= WavesCount + 2; ++i)
{
int index = i;
if (waterSwimInDetect.isObjectInWater)
{
if (MathF.Abs(waterSwimInDetect.rb.velocity.x) < 0.04f || MathF.Abs(waterSwimInDetect.rb.velocity.y)>0.09f) return;
// 获取物体当前位置和水面点的相对水平距离
Vector3 objectPos = transform.InverseTransformPoint(waterSwimInDetect.transform.position);
//获取shape坐标并转换到世界坐标
Vector3 pointPos = spline.GetPosition(index);
// 计算物体与水面点之间的水平距离
float distance = pointPos.x - objectPos.x;

//配合跳入水中固有的y值扰动,让在物体游泳的时候别的在波动的地方还可以正常波动
pointOriginPositionY = pointPos.y;

// 判断物体水平速度和物体与水面点的距离
bool isTooFar = Mathf.Abs(distance) > swimWaveCreateDistance;
bool isTooClose = Mathf.Abs(distance) < swimWaveEndDistance;
bool isSlow = Mathf.Abs(waterSwimInDetect.rb.velocity.x) < 0.004f;

// 如果物体离水面点太远或水平速度小于阈值,则恢复到原始高度
if (isTooFar || isSlow || isTooClose)
{
// 恢复水面点的原始高度
pointPos.y = pointOriginPositionY;
spline.SetPosition(index, pointPos);
}
else
{
// 这里使用物体和水面点之间的距离进行扰动计算
float disturbance = ((MathF.Cos(distance)+1)/2 * heightFactor * waterSwimInDetect.rb.velocity.x) * MathF.Sign(distance);
pointPos.y = pointOriginPositionY + disturbance;
spline.SetPosition(index, pointPos);
}
}
}
}

/// <summary>
/// 模拟波的传递
/// </summary>
//mao 24-11-20 09:46
private void UpdateSprings() {
int count = springs.Count;
float[] left_deltas = new float[count];
float[] right_deltas = new float[count];

for(int i = 0; i < count; i++) {
if (i > 0) {
left_deltas[i] = spread * (springs[i].height - springs[i-1].height);
springs[i-1].velocity += left_deltas[i];
}
if (i < springs.Count - 1) {
right_deltas[i] = spread * (springs[i].height - springs[i+1].height);
springs[i+1].velocity += right_deltas[i];
}
}
}

SpringWater中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.U2D;

public class WaterSpring : MonoBehaviour
{
public float velocity = 0;
public float force = 0;
// current height
public float height = 0f;
// normal height
private float target_height = 0f;
public Transform springTransform;
[SerializeField]
private SpriteShapeController spriteShapeController = null;
private int waveIndex = 0;
private List<WaterSpring> springs = new();
private float resistance = 40f;
public void Init(SpriteShapeController ssc) {

var index = transform.GetSiblingIndex();
waveIndex = index+1;

spriteShapeController = ssc;
velocity = 0;
height = transform.localPosition.y;
target_height = transform.localPosition.y;
}
// with dampening
// adding the dampening to the force
public void WaveSpringUpdate(float springStiffness, float dampening) {
height = transform.localPosition.y;
// maximum extension
var x = height - target_height;
var loss = -dampening * velocity;

force = - springStiffness * x + loss;
velocity += force;
var y = transform.localPosition.y;
transform.localPosition = new Vector3(transform.localPosition.x, y+velocity, transform.localPosition.z);

}

//使用该对象的局部坐标同步Render Shape Controller控制的图形形状
public void WavePointUpdate() {
if (spriteShapeController != null) {
Spline waterSpline = spriteShapeController.spline;
Vector3 wavePosition = waterSpline.GetPosition(waveIndex);
waterSpline.SetPosition(waveIndex, new Vector3(wavePosition.x, transform.localPosition.y, wavePosition.z));
}
}

//collier的碰撞检查
private void OnCollisionEnter2D(Collision2D other) {
if (other.gameObject.tag.Equals("FallingObject")) {
FallingObject fallingObject = other.gameObject.GetComponent<FallingObject>();
Rigidbody2D rb = fallingObject.rigidbody2D;
var speed = rb.velocity;

velocity += speed.y/resistance;
}
}
}

跳水

更新控制

1
2
3
4
5
6
7
8
9
private void JumpInWatetWaveChange()
{
// 物体跳入水中进行的扰动
foreach(WaterSpring waterSpringComponent in springs) {
waterSpringComponent.WaveSpringUpdate(springStiffness, dampening);
waterSpringComponent.WavePointUpdate();
}

}

弹簧模拟

WaveSpringUpdate

该方法模拟了一个简单的弹簧系统,用于更新某个对象(可能是水面或波浪)的高度。以下是代码的逐行解释:

1
public void WaveSpringUpdate(float springStiffness, float dampening) { 
  • 定义了一个公共方法WaveSpringUpdate,它接受两个参数:springStiffness(弹簧刚度)和dampening(阻尼)。
1
height = transform.localPosition.y;
  • 获取当前对象在Y轴上的位置,并将其存储在变量height中。
1
var x = height - target_height;
  • 计算当前高度与目标高度target_height之间的差值,并存储在变量x中。
1
var loss = -dampening * velocity;
  • 计算阻尼力,即阻尼乘以当前速度的负值,并存储在变量loss中。
1
force = - springStiffness * x + loss;
  • 计算总的力,即弹簧力(由弹簧刚度乘以高度差)加上阻尼力。
1
velocity += force;
  • 将计算出的总力添加到当前速度上,更新速度。
1
var y = transform.localPosition.y;  
  • 再次获取当前对象在Y轴上的位置,并存储在变量y中。
1
transform.localPosition = new Vector3(transform.localPosition.x, y+velocity, transform.localPosition.z);
  • 更新对象的位置,将速度加到Y轴的位置上,从而在Y轴方向上移动对象。

总的来说,这个方法模拟了一个简单的弹簧-阻尼系统,其中:

  • springStiffness决定了弹簧恢复到原始位置的力的大小。
  • dampening决定了系统减速到停止的速度。
  • height是当前对象在Y轴上的位置。
  • target_height是对象想要达到的目标高度。
  • velocity是对象在Y轴上的速度。
  • force是作用在对象上的总力。

这个方法在每一帧被调用时,都会根据弹簧力和阻尼力更新对象的位置,模拟波浪或弹簧的运动。

以上内容总结于智普清言

波动扩散

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// <summary>
/// 模拟波的传递
/// </summary>
//mao 24-11-20 09:46
private void UpdateSprings() {
int count = springs.Count;
float[] left_deltas = new float[count];
float[] right_deltas = new float[count];

for(int i = 0; i < count; i++) {
if (i > 0) {
left_deltas[i] = spread * (springs[i].height - springs[i-1].height);
springs[i-1].velocity += left_deltas[i];
}
if (i < springs.Count - 1) {
right_deltas[i] = spread * (springs[i].height - springs[i+1].height);
springs[i+1].velocity += right_deltas[i];
}
}
}

在WaterSpringController中根据传播因子spread,对没有直接被改变速度的WaterSpring取周边被直接影响速度,下一帧就会在FixedUpdate函数中自行模拟弹簧运动

游泳

具体见注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private void SwimWaveChange()
{
// 游泳前后扰动
for (int i = 2; i <= WavesCount + 2; ++i)
{
int index = i;
if (waterSwimInDetect.isObjectInWater)
{
if (MathF.Abs(waterSwimInDetect.rb.velocity.x) < 0.04f || MathF.Abs(waterSwimInDetect.rb.velocity.y)>0.09f) return;
// 获取物体当前位置和水面点的相对水平距离
Vector3 objectPos = transform.InverseTransformPoint(waterSwimInDetect.transform.position);
//获取shape坐标并转换到世界坐标
Vector3 pointPos = spline.GetPosition(index);
// 计算物体与水面点之间的水平距离
float distance = pointPos.x - objectPos.x;

//配合跳入水中固有的y值扰动,让在物体游泳的时候别的在波动的地方还可以正常波动
pointOriginPositionY = pointPos.y;

// 判断物体水平速度和物体与水面点的距离
bool isTooFar = Mathf.Abs(distance) > swimWaveCreateDistance;
bool isTooClose = Mathf.Abs(distance) < swimWaveEndDistance;
bool isSlow = Mathf.Abs(waterSwimInDetect.rb.velocity.x) < 0.004f;

// 如果物体离水面点太远或水平速度小于阈值,则恢复到原始高度
if (isTooFar || isSlow || isTooClose)
{
// 恢复水面点的原始高度
pointPos.y = pointOriginPositionY;
spline.SetPosition(index, pointPos);
}
else
{
// 这里使用物体和水面点之间的距离进行扰动计算
float disturbance = ((MathF.Cos(distance)+1)/2 * heightFactor * waterSwimInDetect.rb.velocity.x) * MathF.Sign(distance);
pointPos.y = pointOriginPositionY + disturbance;
spline.SetPosition(index, pointPos);
}
}
}
}

注意

跳水的模拟需要在游泳前面,具体原因参照在波纹模拟最开始的提示

1
2
3
4
5
6
7
void FixedUpdate()
{
JumpInWatetWaveChange();
SwimWaveChange();
//波传递
UpdateSprings();
}

跳水在后面的话,因为游泳没有改变WaterSpring游戏对象的位置,最终游泳的波动会被跳水更新中的以下代码根据游戏对象的位置强制更新

1
2
3
4
5
6
7
8
9
//使用该对象的局部坐标同步Render Shape Controller控制的图形形状
public void WavePointUpdate() {
if (spriteShapeController != null) {
Spline waterSpline = spriteShapeController.spline;
Vector3 wavePosition = waterSpline.GetPosition(waveIndex);
waterSpline.SetPosition(waveIndex, new Vector3(wavePosition.x, transform.localPosition.y, wavePosition.z));
}
}

物体游泳通过的区域需要抚平,想要在游泳的时候保留跳水波动的效果,就使用以下注释为 非常重要的部分代码,将跳水影响的高度重新赋值给指定顶点

1
2
3
4
5
6
7
8
9
10
11
12
Vector3 pointPos = spline.GetPosition(index);
// 计算物体与水面点之间的水平距离
float distance = pointPos.x - objectPos.x;

//非常重要
//配合跳入水中固有的y值扰动,让在物体游泳的时候别的在波动的地方还可以正常波动
pointOriginPositionY = pointPos.y;

// 判断物体水平速度和物体与水面点的距离
bool isTooFar = Mathf.Abs(distance) > swimWaveCreateDistance;
bool isTooClose = Mathf.Abs(distance) < swimWaveEndDistance;
bool isSlow = Mathf.Abs(waterSwimInDetect.rb.velocity.x) < 0.004f;

最终效果

1dca19e3814f38f63c31 -original-horizontal

补充

这个版本还有部分缺陷,游泳产生的扰动不和跳水的弹簧波动有交互,即,你游你的,我震我的。日后修正。

注意

在以上示例中使用到的OnValidate()Unity事件函数在打包后调用会出现错误,因此要如果要将效果加入到打包工程中,应将该函数中的对应代码加入到Awake、Start或者OEnable中去