博主是一名独立游戏开发者,上次game jam想着做水体,但奈何时间紧迫且网上缺少资源就没有实现,偶然发现油管大佬成功实现,便跟着复刻了一下,视频链接我放在下面。但是很多知识视频并未解释,所以我希望这篇博客能对你有所帮助。
制作出可交互的2D水,主要分为三个部分。第一部分是搭建水体的Mesh,第二部分是制作水的Shader,第三部分则是处理可交互部分(也就是震动和传播)。本篇博客不过深探讨shader的制作(也就是第二部分)。最后效果如下图所示:
10月4日
一、搭建水体mesh
如果你常用Unity3D,那么对mesh一定不陌生。但是2D下很少会使用mesh来渲染,大多数情况都会用Sprite Renderer。如果你对mesh不是很了解,这里我简单解释一下:所有被计算机渲染的出来的图形都是由若干个三角形组成,而mesh就是这些三角面的集合,mesh主要包含三个非常重要的变量,三角形的顶点的位置坐标,三角形顶点的标号,以及每个顶点对应在uv上的坐标。
这样说如果很抽象,那么我们直接拿这次水体的mesh举例:
显然我要搭建的水体是一个长方形,那么我们就可以把它拆分成若干个小三角形(拆分的三角形越多,后面水体的物理模拟会更逼真,当然这里为了简单,就只拆分了10个三角形),如图所示:
接着我们就对照mesh所需的三要素来写代码,除了三要素外,我显然还需要知道水体的width和height。接着就开始写代码,创建函数public void GenerateMesh()
public void GenerateMesh()
{
mesh = new Mesh();
//1.添加顶点
vertices = new Vector3[numOfXVertices * NUM_OF_Y_VERTICES];
topVerticesIndex = new int[numOfXVertices];
for(int y = 0; y < NUM_OF_Y_VERTICES; y++)
{
for(int x = 0; x < numOfXVertices; x++)
{
float xPos = (x / (float)(numOfXVertices - 1)) * width - width / 2;
float yPos = (y / (float)(NUM_OF_Y_VERTICES - 1)) * height - height / 2;
vertices[y * numOfXVertices + x] = new Vector3(xPos, yPos, 0);
if(y == NUM_OF_Y_VERTICES - 1)
{
topVerticesIndex[x] = y * numOfXVertices + x;
}
}
}
//2.构建三角形
int[] triangles = new int[(numOfXVertices - 1) * (NUM_OF_Y_VERTICES - 1) * 6];
int index = 0;
for (int y = 0; y < NUM_OF_Y_VERTICES - 1; y++)
{
for (int x = 0; x < numOfXVertices - 1; x++)
{
int bottomLeft = y * numOfXVertices + x;
int bottomRight = bottomLeft + 1;
int topLeft = bottomLeft + numOfXVertices;
int topRight = topLeft + 1;
//第一个三角形
triangles[index++] = bottomLeft;
triangles[index++] = topLeft;
triangles[index++] = bottomRight;
//第二个三角形
triangles[index++] = bottomRight;
triangles[index++] = topLeft;
triangles[index++] = topRight;
}
}
//3.UVs
Vector2[] uvs = new Vector2[vertices.Length];
for(int i = 0; i< vertices.Length; i++)
{
uvs[i] = new Vector2((vertices[i].x + width / 2) / width, (vertices[i].y + height / 2) / height);
}
if(meshRenderer == null)
{
meshRenderer = GetComponent<MeshRenderer>();
}
if(meshFilter == null)
{
meshFilter = GetComponent<MeshFilter>();
}
meshRenderer.material = waterMaterial;
mesh.vertices = vertices;
mesh.triangles = triangles;
mesh.uv = uvs;
mesh.RecalculateNormals();
mesh.RecalculateBounds();
meshFilter.mesh = mesh;
}
参数如下:
[Header("Mesh")]
[Range(2, 500)] public int numOfXVertices = 70;
public float width = 10f;
public float height = 4f;
public Material waterMaterial;
private const int NUM_OF_Y_VERTICES = 2;
[Header("Gizmo")]
public Color gizmoColor = Color.white;
Mesh mesh;
MeshRenderer meshRenderer;
MeshFilter meshFilter;
Vector3[] vertices;
int[] topVerticesIndex;
上述代码整体还是很好理解的,这里只说比较重要的地方,那就是第二步的构建三角形,
那为什么要采取这样的方法,其实,无论采取那种方法,都要注意index要是顺时针递增。这是mesh的规则,
搭建完后,勾选如图所示的地方,如果成功出现三角形,则说明搭建成功
二、Shader Graph部分
这里,我贴张图,如果大家像要做出视频中的效果,可以看原视频,如果比较懒的话,不追求水中的模糊和扰动,可以按博主的做,图我贴下面了
效果如图所示
三、处理可交互部分
实际上水之所可以上升,下降,传播,是我浆水体分成很多个弹簧,如图所示
每一次人物或物体和水有接触,我都希望每根弹簧做相应的运动(除最左右两边的边界)。先贴上代码,然后我每一行做具体解释。
private void FixedUpdate()
{
//更新所有弹簧的位置
for(int i = 1; i < waterPoints.Count - 1; i++)
{
WaterPoint point = waterPoints[i];
float x = point.pos - point.targetHeight;
float accleration = -spriteConstant * x - damping * point.velocity;
point.pos += point.velocity * speedMult * Time.fixedDeltaTime;
vertices[topVerticesIndex[i]].y = point.pos;
point.velocity += accleration * speedMult * Time.fixedDeltaTime;
}
//波的传播
for (int j = 0; j < wavePropogationIterations; j++)
{
for (int i = 1; i < waterPoints.Count - 1; i++)
{
float leftDelta = spread * (waterPoints[i].pos - waterPoints[i - 1].pos) * speedMult * Time.fixedDeltaTime;
waterPoints[i - 1].velocity += leftDelta;
float rightDelta = spread * (waterPoints[i].pos - waterPoints[i + 1].pos) * speedMult * Time.fixedDeltaTime;
waterPoints[i + 1].velocity += rightDelta;
}
}
//更新网格
mesh.vertices = vertices;
}
参数如下,比较复杂,可对照下面解释来看
[Header("Springs")]
[SerializeField] float spriteConstant = 1.4f;
[SerializeField] float damping = 1.1f;
[SerializeField] float spread = 6.5f;
[SerializeField, Range(1, 10)] int wavePropogationIterations = 8;
[SerializeField, Range(0, 20)] float speedMult = 5.5f;
[Header("Force")]
public float forceMultiplier = 0.2f;
[Range(1, 50f)] public float maxForce = 5f;
[Header("Collisions")]
[SerializeField, Range(1f, 10f)] float playerCollisionRadiusMult;
第一部分:更新所有弹簧的位置。
我们用一个for循环遍历除左右两边的所有水面上的点,循环里第一步:float x = point.pos - point.targetHeight;即是用现在弹簧拉伸的高度减去弹簧不拉伸的高度。得到的x就是胡克定律里F = kx里的x。第二部分我们要获得加速度a,由牛顿第二定律可知F = ma。这里F是弹簧的弹力。
那么a = (k/m) *x。也就是对应循环里的第二步。但是这里代码有点不一样。首先k/m是常数,代码里用spriteConstant取代。但是为什么前面加负号以及后面减的一坨是干什么用的。
先说负号,如果x为正数,说明point.pos > point.targetHeight。此时弹簧正在拉伸,那么对应水的话肯定是向上升起,我当然不希望水一直向上升,当然希望此时它的加速度为负值,让它抓紧降下来。(读者可以尝试改成正号,就会发现水会一直向上生高)。
那后面那一坨呢?首先我们要明确,人物跳到水里后,你肯定不希望水一直波动吧,后一行的作用就是让波动逐渐减小的。因为上面的公式我并没有考虑阻力,所以加上后面- damping * point.velocity,我希望水面根据点的速度,趋于平静(正常状态)。
循环中的后面三行代码就很简单了,值得注意的是,speedMult只是一个补充,是为了让水波明显,实际并没有物理意义。
第二部分:水波如何传播
上面我们处理了每根弹簧的运动,但是他们彼此间如何影响我并未处理。下面的循环就是解决这个问题的。首先我们先忽略最外层的循环,只关注里面的循环,这又是遍历所有弹簧,这里用最通俗的语言来说,我遍历到第i根弹簧,我希望拉近i - 1和i + 1根弹簧离我的距离。即改变他们的velocity。
这里千万不要理解为上坡下移,下坡上移,按拉近理解即可。
最后,实际就是处理人物的入水和一些粒子效果,代码也比较简单易懂,我就直接贴在下方了,也就不解释了。
ps:这是本人第一次写长文,难免由错误和不妥之处,如果您有任何疑问,欢迎在评论区指出。
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using Unity.VisualScripting;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer), typeof(EdgeCollider2D))]
[RequireComponent(typeof(WaterTriggerHandler))]
public class InteractableWater : MonoBehaviour
{
[Header("Springs")]
[SerializeField] float spriteConstant = 1.4f;
[SerializeField] float damping = 1.1f;
[SerializeField] float spread = 6.5f;
[SerializeField, Range(1, 10)] int wavePropogationIterations = 8;
[SerializeField, Range(0, 20)] float speedMult = 5.5f;
[Header("Force")]
public float forceMultiplier = 0.2f;
[Range(1, 50f)] public float maxForce = 5f;
[Header("Collisions")]
[SerializeField, Range(1f, 10f)] float playerCollisionRadiusMult;
[Header("Mesh")]
[Range(2, 500)] public int numOfXVertices = 70;
public float width = 10f;
public float height = 4f;
public Material waterMaterial;
private const int NUM_OF_Y_VERTICES = 2;
[Header("Gizmo")]
public Color gizmoColor = Color.white;
Mesh mesh;
MeshRenderer meshRenderer;
MeshFilter meshFilter;
Vector3[] vertices;
int[] topVerticesIndex;
EdgeCollider2D coll;
private class WaterPoint
{
public float velocity, accleration, pos, targetHeight;
}
private List<WaterPoint> waterPoints = new List<WaterPoint>();
private void Start()
{
coll = GetComponent<EdgeCollider2D>();
GenerateMesh();
CreateWaterPoints();
}
private void Reset()
{
coll = GetComponent<EdgeCollider2D>();
coll.isTrigger = true;
}
private void FixedUpdate()
{
//更新所有弹簧的位置
for(int i = 1; i < waterPoints.Count - 1; i++)
{
WaterPoint point = waterPoints[i];
float x = point.pos - point.targetHeight;
float accleration = -spriteConstant * x - damping * point.velocity;
point.pos += point.velocity * speedMult * Time.fixedDeltaTime;
vertices[topVerticesIndex[i]].y = point.pos;
point.velocity += accleration * speedMult * Time.fixedDeltaTime;
}
//波的传播
for (int j = 0; j < wavePropogationIterations; j++)
{
for (int i = 1; i < waterPoints.Count - 1; i++)
{
float leftDelta = spread * (waterPoints[i].pos - waterPoints[i - 1].pos) * speedMult * Time.fixedDeltaTime;
waterPoints[i - 1].velocity += leftDelta;
float rightDelta = spread * (waterPoints[i].pos - waterPoints[i + 1].pos) * speedMult * Time.fixedDeltaTime;
waterPoints[i + 1].velocity += rightDelta;
}
}
//更新网格
mesh.vertices = vertices;
}
public void Splash(Collider2D collision, float force)
{
float radius = collision.bounds.extents.x * playerCollisionRadiusMult; //bound.extent.x为包围盒的一半
Debug.Log(collision.bounds.extents.x);
Vector2 center = collision.transform.position;
for(int i = 0; i < waterPoints.Count; i++)
{
Vector2 vertexWorldPos = transform.TransformPoint(vertices[topVerticesIndex[i]]);
if(IsPointInsideCircle(vertexWorldPos, center, radius))
{
waterPoints[i].velocity = force;
}
}
}
private bool IsPointInsideCircle(Vector2 point, Vector2 center, float radius)
{
float distanceSquared = (point - center).sqrMagnitude;
return distanceSquared <= radius * radius;
}
public void ResetEdgeCollider()
{
coll = GetComponent<EdgeCollider2D>();
Vector2[] newPoints = new Vector2[2];
Vector2 firstPoint = new Vector2(vertices[topVerticesIndex[0]].x, vertices[topVerticesIndex[0]].y);
newPoints[0] = firstPoint;
Vector2 secondPoint = new Vector2(vertices[topVerticesIndex[topVerticesIndex.Length - 1]].x,
vertices[topVerticesIndex[topVerticesIndex.Length - 1]].y);
newPoints[1] = secondPoint;
coll.offset = Vector2.zero;
coll.points = newPoints;
}
public void GenerateMesh()
{
mesh = new Mesh();
//1.添加顶点
vertices = new Vector3[numOfXVertices * NUM_OF_Y_VERTICES];
topVerticesIndex = new int[numOfXVertices];
for(int y = 0; y < NUM_OF_Y_VERTICES; y++)
{
for(int x = 0; x < numOfXVertices; x++)
{
float xPos = (x / (float)(numOfXVertices - 1)) * width - width / 2;
float yPos = (y / (float)(NUM_OF_Y_VERTICES - 1)) * height - height / 2;
vertices[y * numOfXVertices + x] = new Vector3(xPos, yPos, 0);
if(y == NUM_OF_Y_VERTICES - 1)
{
topVerticesIndex[x] = y * numOfXVertices + x;
}
}
}
//2.构建三角形
int[] triangles = new int[(numOfXVertices - 1) * (NUM_OF_Y_VERTICES - 1) * 6];
int index = 0;
for (int y = 0; y < NUM_OF_Y_VERTICES - 1; y++)
{
for (int x = 0; x < numOfXVertices - 1; x++)
{
int bottomLeft = y * numOfXVertices + x;
int bottomRight = bottomLeft + 1;
int topLeft = bottomLeft + numOfXVertices;
int topRight = topLeft + 1;
//第一个三角形
triangles[index++] = bottomLeft;
triangles[index++] = topLeft;
triangles[index++] = bottomRight;
//第二个三角形
triangles[index++] = bottomRight;
triangles[index++] = topLeft;
triangles[index++] = topRight;
}
}
//3.UVs
Vector2[] uvs = new Vector2[vertices.Length];
for(int i = 0; i< vertices.Length; i++)
{
uvs[i] = new Vector2((vertices[i].x + width / 2) / width, (vertices[i].y + height / 2) / height);
}
if(meshRenderer == null)
{
meshRenderer = GetComponent<MeshRenderer>();
}
if(meshFilter == null)
{
meshFilter = GetComponent<MeshFilter>();
}
meshRenderer.material = waterMaterial;
mesh.vertices = vertices;
mesh.triangles = triangles;
mesh.uv = uvs;
mesh.RecalculateNormals();
mesh.RecalculateBounds();
meshFilter.mesh = mesh;
}
private void CreateWaterPoints()
{
waterPoints.Clear();
for(int i = 0; i < topVerticesIndex.Length; i++)
{
waterPoints.Add(new WaterPoint
{
pos = vertices[topVerticesIndex[i]].y,
targetHeight = vertices[topVerticesIndex[i]].y,
});
}
}
}
[CustomEditor(typeof(InteractableWater))]
public class InteractableWaterEditor : Editor
{
InteractableWater water;
private void OnEnable()
{
water = (InteractableWater)target;
}
public override VisualElement CreateInspectorGUI()
{
VisualElement root = new VisualElement();
InspectorElement.FillDefaultInspector(root, serializedObject, this);
root.Add(new VisualElement { style = { height = 10 } });
Button generateMeshButton = new Button(() => water.GenerateMesh())
{
text = "Generate Mesh"
};
root.Add(generateMeshButton);
Button placeEdgeColliderButton = new Button(() => water.ResetEdgeCollider())
{
text = "Place Edge Collider"
};
root.Add(placeEdgeColliderButton);
return root;
}
private void ChangeDimensions(ref float width, ref float height, float calculatedWidthMax, float caluatedHeightMax)
{
width = Mathf.Max(0.1f, calculatedWidthMax);
height = Mathf.Max(0.1f, caluatedHeightMax);
}
private void OnSceneGUI()
{
Handles.color = water.gizmoColor;
Vector3 center = water.transform.position;
Vector3 size = new Vector3(water.width, water.height, 0.1f);
Handles.DrawWireCube(center, size);
float handleSize = HandleUtility.GetHandleSize(center) * 0.1f;
Vector3 snap = Vector3.one * 0.1f;
//CornerHandle
Vector3[] corners = new Vector3[4];
corners[0] = center + new Vector3(-water.width / 2, -water.height / 2, 0); //左下角
corners[1] = center + new Vector3(water.width / 2, -water.height / 2, 0); //右下角
corners[2] = center + new Vector3(-water.width / 2, water.height / 2, 0); //左上角
corners[3] = center + new Vector3(water.width / 2, water.height / 2, 0); //右上角
//处理每一个Corner
EditorGUI.BeginChangeCheck();
Vector3 newBottomLeft = Handles.FreeMoveHandle(corners[0], handleSize, snap, Handles.CubeHandleCap);
if(EditorGUI.EndChangeCheck())
{
ChangeDimensions(ref water.width, ref water.height, corners[1].x - newBottomLeft.x, corners[3].y - newBottomLeft.y);
water.transform.position += new Vector3((newBottomLeft.x - corners[0].x) / 2, (newBottomLeft.y - corners[0].y) / 2, 0);
}
EditorGUI.BeginChangeCheck();
Vector3 newBottomRight = Handles.FreeMoveHandle(corners[1], handleSize, snap, Handles.CubeHandleCap);
if (EditorGUI.EndChangeCheck())
{
ChangeDimensions(ref water.width, ref water.height, newBottomRight.x - corners[0].x, corners[3].y - newBottomRight.y);
water.transform.position += new Vector3((newBottomRight.x - corners[1].x) / 2, (newBottomRight.y - corners[1].y) / 2, 0);
}
EditorGUI.BeginChangeCheck();
Vector3 newTopLeft = Handles.FreeMoveHandle(corners[2], handleSize, snap, Handles.CubeHandleCap);
if (EditorGUI.EndChangeCheck())
{
ChangeDimensions(ref water.width, ref water.height, corners[3].x - newTopLeft.x, newTopLeft.y - corners[0].y);
water.transform.position += new Vector3((newTopLeft.x - corners[2].x) / 2, (newTopLeft.y - corners[2].y) / 2, 0);
}
EditorGUI.BeginChangeCheck();
Vector3 newTopRight = Handles.FreeMoveHandle(corners[3], handleSize, snap, Handles.CubeHandleCap);
if (EditorGUI.EndChangeCheck())
{
ChangeDimensions(ref water.width, ref water.height, newTopRight.x - corners[2].x, newTopRight.y - corners[1].y);
water.transform.position += new Vector3((newTopRight.x - corners[3].x) / 2, (newTopRight.y - corners[3].y) / 2, 0);
}
if(GUI.changed)
{
water.GenerateMesh();
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class WaterTriggerHandler : MonoBehaviour
{
[SerializeField] LayerMask waterMask;
[SerializeField] GameObject splashParticles;
EdgeCollider2D edgeColl;
InteractableWater water;
private void Awake()
{
edgeColl = GetComponent<EdgeCollider2D>();
water = GetComponent<InteractableWater>();
}
private void OnTriggerEnter2D(Collider2D collision)
{
if((waterMask.value & (1 << collision.gameObject.layer)) > 0)
{
Rigidbody2D rb = collision.GetComponent<Rigidbody2D>();
if(rb != null)
{
//生成粒子
//Vector2 localPos = gameObject.transform.localPosition;
//Vector2 hitObjectPos = collision.transform.position;
//Bounds hitObjectBounds = collision.bounds;
//Vector3 spawnPos = Vector3.zero;
//if(collision.transform.position.y >= edgeColl.points[1].y + edgeColl.offset.y + localPos.y)
//{
// spawnPos = hitObjectPos - new Vector2(0f, hitObjectBounds.extents.y);
//}
//else
//{
// spawnPos = hitObjectPos + new Vector2(0f, hitObjectBounds.extents.y);
//}
//Instantiate(splashParticles, spawnPos, Quaternion.identity);
//clamp splash point to a MAX velocity-------------------------
int multiplier = 1;
if(rb.velocity.y < 0)
{
multiplier = -1;
}
else
{
multiplier = 1;
}
float vel = rb.velocity.y * water.forceMultiplier;
vel = Mathf.Clamp(Mathf.Abs(vel), 0f, water.maxForce);
vel *= multiplier;
water.Splash(collision, vel);
}
}
}
}