Unity如何实现2D可交互式水

博主是一名独立游戏开发者,上次game jam想着做水体,但奈何时间紧迫且网上缺少资源就没有实现,偶然发现油管大佬成功实现,便跟着复刻了一下,视频链接我放在下面。但是很多知识视频并未解释,所以我希望这篇博客能对你有所帮助。

https://youtu.be/TbGEKpdsmCI

制作出可交互的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);
            }
        }
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值