> 技术文档 > 【Unity技术美术】实现一个可以与玩家碰撞的(塞尔达同款)草地_unity 草

【Unity技术美术】实现一个可以与玩家碰撞的(塞尔达同款)草地_unity 草


前言

这几年,卡通风格的游戏如雨后春笋般涌出,而其中相当一部分游戏,在草地渲染中,下了不少功夫。

草地,是游戏场景的重要组成部分,是游戏营造空气感的手段之一。

 塞尔达中的草地

本文章将基于另外一篇教程,带您实现一个可以与玩家碰撞的草地

使用

如果您并不想了解原理,单纯想要运用,我这里已经准备好了成品(免费的哦)

链接: 百度网盘  提取码: 6666

实测unity2021-unity6都可以使用,支持build in pipeline和urp,选择需要的版本导入工程即可。

导入工程后,创建两个变换(缩放,坐标,旋转)完全一样的的平面,一个作为土地,另一个作为草坪

将Grass/Material/Grass.material材质应用到Grass平面上

就可以生成一片草地。(如果要更改土地的外观,更改Land平面的材质即可)

在材质面板,您可以调节关于草的参数(高度,宽度,密度,摇摆程度等)

材质面板

添加角色碰撞,只需要将包内的GetPlayerPos脚本挂入场景内任何一个物体上

在玩家上挂挂载GrassCollider脚本,即可让玩家与草地发生交互

可以调节碰撞体半径和碰撞体中心(紫色的圈代表碰撞体)

效果

动图封面

最多支持100个玩家同时对草地进行碰撞

原理和实现

本文章基于下面这篇教程,该教程,详细的讲述了在unity中如何实现一个随风飘荡的草地。我将会在下面这篇教程的基础上,为草地加入与玩家碰撞的功能,实现一个可以随风飘荡,且可以与玩家碰撞的草地。

https://roystan.net/articles/grass-shader.html

由于原教程基于build in渲染管线,使用本文章的代码也基于build in渲染管线。如果要使用基于urp的草材质,可以直接去上述链接下载(免费的哦)

纠错

个人觉得,上述教程中有个点,讲的是不正确的(个人观点哈,没别的意思,我有时候也会讲错一些东西,欢迎大家来纠正)。

在原教程中,作者为了让风的摆动看起来更自然,使用了坐标当作uv,对风的噪声图进行采样。但是作者使用的,并不是世界坐标系,而是本地坐标系。这样会导致,如果草地是好几个相同的面拼接的,其摆动仍然会不自然(看起来是一块一块的)。

这个场景由4个草平面组成,可以很明显的看到各个平面上草的摆动是完全一样的

但是如果使用世界坐标系作为uv进行采样,则不会有这个问题

float3 worldPos = mul(unity_ObjectToWorld, float4(pos, 1)).xyz;float2 uv = worldPos.xz * _WindDistortionMap_ST.xy + _WindDistortionMap_ST.zw + _WindFrequency * _Time.y;

可以看到各个平面草的摆动不一样了,而且衔接的十分自然

我们可以写个脚本将角色们的坐标传入着色器

using System.Collections.Generic;using UnityEngine;namespace Grass.Script{ [ExecuteInEditMode] public class GetPlayerPos : MonoBehaviour { //public GameObject player; private GrassCollider[] _cols; private List _poss = new List(); public Material material; // Start is called before the first frame update void Start() { _cols = GameObject.FindObjectsOfType(); } // Update is called once per frame void Update() { _poss.Clear(); foreach (var col in _cols) { _poss.Add(new Vector4(col.Position.x,col.Position.y, col.Position.z, col.radius)); } if (_poss != null) { material.SetVectorArray(\"_Players\",_poss.ToArray()); } else { print(\"no player\"); } } }}//LZX completed this script in 2024/05/06//LZX-TC-VS-2024-05-05-001

十分简单的脚本,每帧将角色的坐标传入指定的材质中。

poss数组是个四维向量的数组,其中xyz代表坐标,w代表半径

然后我们编写GrassCollider类

using UnityEngine;namespace Grass.Script{ [ExecuteInEditMode] public class GrassCollider : MonoBehaviour { public float radius = 1; public Vector3 centryOffcet = Vector3.zero; public Vector3 Position { get { return this.transform.position + centryOffcet; } } // Start is called once before the first execution of Update after the MonoBehaviour is created void Start() { } // Update is called once per frame void Update() {  } private void OnDrawGizmos() { Gizmos.color = new Color((float)0xed / 0xff, (float)0x65 / 0xff, 1,1); Gizmos.DrawWireSphere(Position, radius); } }}

作为寻找玩家的根据和记录玩家碰撞体的半径和中心

当然,我们也需要在着色器中,定义_Players数组

uniform float4 _Players[100];

我们通过uniform来修饰这个变量,代表其是所有片段公用的

这是个四维向量的数组,其中xyz代表坐标,w代表碰撞半径

我们的草,会与影响范围内的玩家作用,我们在shader中编写一个函数,让每个顶点计算与自己作用的玩家是哪个

float4 nearestPlayer(float3 vetexPos){float4 res = float4(0,0,0,0);float minDis = 100000;for(int i = 0;i < 100;i++){float3 pos = float3(_Players[i].x,_Players[i].y,_Players[i].z);float3 disDir = pos-vetexPos;if(length(disDir)<_Players[i].w){return float4(pos,_Players[i].w);}if(length(disDir)<minDis){minDis = length(disDir);res = float4(pos,_Players[i].w);}}return res;}

碰撞算法

在这个着色器中,草的顶点是在几何着色器中计算的。

所以,我们计算碰撞,也在几何着色器中计算。

我们可以通过传入的角色坐标,配合着色器中计算的worldPos,计算出玩家与该顶点的距离,和向量

float3 worldPos = mul(unity_ObjectToWorld, float4(pos, 1)).xyz;float3 playerDir = normalize(_PlayerPos - worldPos);//计算玩家与该顶点向量float playerDistance = distance(_PlayerPos, worldPos);//计算玩家与该顶点距离

知道了玩家的距离和向量,我们该怎么确定,该顶点绕哪个轴旋转呢?

其实很简单,草的旋转轴,就是与playerDir垂直的向量。

我们可以自己编写个函数,来计算与某个向量垂直的向量

float3 GetPerpendicularVector(float3 v){ float3 perp = float3(-v.y, v.x, 0); // 简单交换 x 和 y if (length(perp) < 0.001) { // 如果 perp 太小,调整 z 分量 perp = float3(0, -v.z, v.y); } return normalize(perp);}

然后在几何着色器中,调用该函数,计算与playerIDr垂直的向量

float3 playerAixs = GetPerpendicularVector(playerDir);

playerAixs就是要求的旋转轴

旋转轴确定了,那么旋转角度怎么确定呢?

我们知道,旋转的角度肯定和角色的距离成反比(是反比,不是反比例),所以我们可以使用以下算法得出旋转的角度

弧度=角色碰撞体半径-距离

假设玩家碰撞体半径为1

其中x代表该顶点与玩家的距离,y代表弧度,y*π代表旋转的角度

然后我们规定只有y大于0时,草才会旋转(在半径外的草不受影响)

写成代码

float playerSample = max(_PlayerRadius - playerDistance,0);playerSample = playerSample * UNITY_PI;

playerSample就是我们需要的旋转角度,当然了这种算法是有可能在玩家碰撞体半径过大时出现草翻转360又转回来的情况,不过也无伤大雅,因为半径如此大的角色会将重新翻转回来的草遮挡住,我们只需要让玩家周围的草有着看起来自然的扭曲即可。

通过计算出的角度计算出旋转矩阵,然后加入到顶点最终的旋转矩阵(其变换顺序在facingRotationMatrix之后,在windRotation之前),得到最终的顶点旋转矩阵

写作数学公式:

transformationMatrix = tangentToLocal * windRotation * playerRotationMatrix * facingRotationMatrix * bendRotationMatrix

注意:公式中越靠前的矩阵变换越靠后

代码实现:

float3x3 playerRotationMatrix = AngleAxis3x3(playerSample, playerAixs);。。。。。。float3x3 transformationMatrix = mul(mul(mul(mul(tangentToLocal, windRotation),playerRotationMatrix), facingRotationMatrix), bendRotationMatrix);

完整的几何着色器

[maxvertexcount(3)]void grassGeo(triangle vertexOutput IN[3], inout TriangleStream triStream){ float3 pos = IN[0].vertex;float3 vNormal = IN[0].normal;float4 vTangent = IN[0].tangent;float3 vBinormal = cross(vNormal, vTangent) * vTangent.w;float3x3 tangentToLocal = float3x3(vTangent.x, vBinormal.x, vNormal.x,vTangent.y, vBinormal.y, vNormal.y,vTangent.z, vBinormal.z, vNormal.z);float3x3 facingRotationMatrix = AngleAxis3x3(rand(pos) * UNITY_TWO_PI, float3(0, 0, 1));float3x3 bendRotationMatrix = AngleAxis3x3(rand(pos.zzx) * _BendRotationRandom * UNITY_PI * 0.5, float3(-1, 0, 0));float3 worldPos = mul(unity_ObjectToWorld, float4(pos, 1)).xyz;float2 uv = worldPos.xz * _WindDistortionMap_ST.xy + _WindDistortionMap_ST.zw + _WindFrequency * _Time.y;float4 Player = nearestPlayer(worldPos);float3 PlayerPos = Player.xyz;float PlayerRadius = Player.w;float3 playerDir = normalize(PlayerPos - worldPos);float playerDistance = distance(PlayerPos, worldPos);float3 playerAixs = GetPerpendicularVector(float3(playerDir.x,playerDir.z,playerDir.y));//float3 playerAixs = GetPerpendicularVector(playerDir);float playerSample = max(PlayerRadius - playerDistance,0);float3x3 playerRotationMatrix = AngleAxis3x3(UNITY_PI * playerSample, playerAixs);float2 windSample = (tex2Dlod(_WindDistortionMap, float4(uv, 0, 0)).xy * 2 - 1) * _WindStrength;float3 wind = normalize(float3(windSample.x, windSample.y, 0));float3x3 windRotation = AngleAxis3x3(UNITY_PI * windSample, wind); float3x3 transformationMatrix = mul(mul(mul(mul(tangentToLocal, windRotation),playerRotationMatrix), facingRotationMatrix), bendRotationMatrix);//在transformationMatrix下边构建一个用于底部顶点的矩阵float3x3 transformationMatrixFacing = mul(tangentToLocal, facingRotationMatrix);float height = (rand(pos.zyx) * 2 - 1) * _BladeHeightRandom + _BladeHeight;float width = (rand(pos.xzy) * 2 - 1) * _BladeWidthRandom + _BladeWidth;float3 tangentNormal = float3(0, -1, 0);float3 localNormal = mul(transformationMatrixFacing, tangentNormal);// 应用在底部的两个顶点triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(width, 0, 0)), float2(0, 0),localNormal));triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(-width, 0, 0)), float2(1, 0),localNormal));localNormal = mul(transformationMatrix, tangentNormal);triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0, 0, height)), float2(0.5, 1),localNormal));}

这样,我们就实现了,一个可以与玩家碰撞的草地

希望我的文章对您有帮助,也欢迎在评论区和我讨论