动态效果如下:
之前写过一个模拟血瓶效果的文章。
Lucifer:在Unity中完善一下Unreal模拟伪液体血瓶的效果zhuanlan.zhihu.com那篇主要是为了模拟正确的液面,好进行比较正确的反射和投影等效果。这次的液体,从基本原理上来说,实现思路应该差不多。不过这次,因为瓶子已经不是规则形状了,所以,继续计算正确的液面应该就不现实了。这次还要配合瓶子的运动状态,液体的形态也要做出相应的正确改变。
先从实现思路上捋一下基本实现步骤:
1、内部液体的基本呈现:液体是要配合瓶子形状的,所以基本的实现思路就是将瓶子双Pass渲染,先将瓶子进行必要的顶点缩放,渲染内部液体,然后再渲染瓶子本身。
2、瓶内液面的位置设定:液体因为受重力影响,所以液面也总是会垂直于重力方向。所以对液体进行裁剪时,需要按照世界空间的高度进行裁剪。
所以其他先不考虑的情况下,我们可以先使用Shader,将上面两步实现出来。
一、建模,打开3dsMax软件(༼ つ ◕_◕ ༽つ程序员来学Max建模了)。
1、alt-W,将透视图(默认软件最右下角视图)最大化。
2、基本视图快捷键操作和基本建模操作。
视图:z,最大化视图。
p,f,t,l,透,前,顶,左视图。
鼠标中键,左右平移视图。
alt+鼠标中键,以选中物体为重心旋转视图。
鼠标滚轮:缩放视图。
操作:左键点选物体。
q、w、e、r,选取,移动,旋转,缩放物体。
鼠标右键:悬浮菜单。
3、创建一个简单的柱体。
依次在右侧主工具栏选择创建、模型、标准原型、柱体。
鼠标变为下图符号后:
在主视图左键按住,并拖动鼠标,创建圆片。然后松手,继续拖动鼠标拉出高度,等高度合适后,再次点击左键,创建出圆柱体。点击右键,取消创建命令。
4、调节圆柱体参数。
左键点击选中柱体,按F4,显示模型网格。移动模型到0,0,0位置。并在右侧修改菜单中,设置柱体的分段参数。如图:
调节完毕之后,右键将模型转为可编辑多边形。
5、造瓶体
建模方式有很多,我按照我习惯来说:
选择模型顶上的面,delete,删除。
选择体,并选择模型,进行一下缩放到符合一般酒瓶子的宽高比例。
然后选择上面的封闭线,按住shift,左键向上拉出并将拉出的边做一下缩放。
重复以上步骤,直到拉出以下形体。
6、优化瓶口
不讲究的话,上面的瓶子就能导出成Fbx使用了,如果想做好点,就继续优化一下瓶口,同样使用上面的方法,调整成下面的形状。
7、导出Fbx
养成良好习惯,导出前,重置 一下模型的矩阵信息。
选择菜单、导出,并选择Fbx格式,导出模型即可。
Fbx导出设置这里请参考下图:
下面提供一下测试用模型(我知道你们第一次建模,结果肯定惨不忍睹,就一个字:丑(●'◡'●)):
二、Shader
按上面的思路,就是液体,瓶子渲染两遍就好了,没啥特别的。
Shader "Unlit/BottleLiquid_Test"
{
Properties
{
_LiquidColor ("LiquidColor", Color) = (0.2,0.1,0.1,0.9)
_BottleColor("BottleColor", Color) = (0.55,0.95,0.45,0.5)
}
SubShader
{
Tags { "RenderType"="Transparent" "Queue" = "Transparent"}
LOD 100
Pass//第一个pass先渲染瓶中的液体
{
Blend SrcAlpha OneMinusSrcAlpha
//这里要双面渲染液体
Cull off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 vertex : SV_POSITION;
};
float4 _LiquidColor;
v2f vert (appdata v)
{
v2f o;
//这里就是对瓶子进行缩放,比例决定瓶子的厚度,后面加了一个向上的小偏移,因为瓶底儿应该更厚点儿。
float4 localPos = float4(0.9, 0.9, 0.98, 1) * v.vertex + float4(0, 0, 0.01, 0);
o.vertex = UnityObjectToClipPos(localPos);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
return _LiquidColor;
}
ENDCG
}
Pass
{
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 vertex : SV_POSITION;
};
float4 _BottleColor;
v2f vert(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
return _BottleColor;
}
ENDCG
}
}
}
然后我们就得到了:
嗯,然后下一步就是控制液体的液面高度了。
猛地一想,貌似只要把高度超过一个定值的液体剪裁掉就可以了。
所以我们可以猛地先这么一写:
Shader中:
//增加属性液面高度_WaterLevel
_WaterLevel ("WaterLevel", range(0.1, 1.5)) = 1.0
struct v2f
{
float4 vertex : SV_POSITION;
//顶点输出中增加世界空间坐标
float4 worldPos : TEXCOORD0;
};
float _WaterLevel;
//VS中增加关于世界坐标的计算
o.worldPos = mul(unity_ObjectToWorld, localPos);
//PS中增加液体剪裁,高于设置的高度的液体就直接裁剪掉
clip(_WaterLevel- i.worldPos.y);
嗯,调一下Level貌似有效:
但是,移动瓶子的时候,液体不跟着瓶子运动啊。。。。
呃,好吧,还需要考虑瓶子本身的世界坐标,然后根据高度差来决定液面高度。
我们先这么猛地改一下试试:
//改写VS中获取世界坐标的计算,用来获取坐标差
//o.worldPos = mul(unity_ObjectToWorld, localPos);
o.worldPos = mul(unity_ObjectToWorld, localPos) - mul(unity_ObjectToWorld, float4(0,0,0,1));
哎,貌似可以了哎:
但是,一旋转就露馅了:
瓶子里面的液体不能保持体积啊。本来半瓶的液体,瓶子横过来,液体就满了。要是现实中的快乐水能这样,那真是ƪ(˘⌣˘)ʃ。
三、体积
好吧,终于还是到了这一步了,我们怎么才能让液体保持一个相对稳定的体积呢?瓶子可是不规则形状的,明显不能根据瓶子所处的状态,来硬性计算液面的相对高度啊。
那,如果把瓶子近似成一个规则形状呢,比如box?那我们在瓶子上加一个boxcollider试试?
添加Box Collider组件,然后调整一下刚好套住瓶子。这里为什么我把collider往下面调了一下,低于瓶口了呢?原因你们自己考虑,哈哈。
然后我们就可以根据BoxCollider的8个顶点坐标,来求某个体积的液体的液面了:
可是,这么想想,好复杂啊。能不能再简化一下啊?
最终简化:
直接通过根据collider的8个顶点的最高最低点的高度插值,来确定液体的液面高度。
嗯嗯,OK,就这么搞。
创建C#脚本,用来获取box collider的顶点,并将最高最低点坐标传入shader:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(BoxCollider))]
public class BoundingBox : MonoBehaviour {
private Material bottleLiquidMat;
private BoxCollider bottleBox;
//用来存储8个顶点的Local坐标
private Vector4[] localPos = new Vector4[8];
//x_最大值,y_最小值
private Vector3 volum;
static readonly int _Volum = Shader.PropertyToID("_Volum");
//获取collider的顶点
void initializeVolum()
{
volum = Vector3.zero;
bottleBox = GetComponent<BoxCollider>();
Vector4 centerPos = bottleBox.center;
centerPos.w = 1.0f;
Vector4 boxSize = bottleBox.size;
boxSize.w = 0.0f;
localPos[0] = centerPos + 0.5f * boxSize;
localPos[1] = centerPos - 0.5f * boxSize;
localPos[2] = centerPos + 0.5f * new Vector4(-boxSize.x, boxSize.y, boxSize.z, boxSize.w);
localPos[3] = centerPos + 0.5f * new Vector4(-boxSize.x, -boxSize.y, boxSize.z, boxSize.w);
localPos[4] = centerPos + 0.5f * new Vector4(-boxSize.x, boxSize.y, -boxSize.z, boxSize.w);
localPos[5] = centerPos + 0.5f * new Vector4(boxSize.x, -boxSize.y, boxSize.z, boxSize.w);
localPos[6] = centerPos + 0.5f * new Vector4(boxSize.x, -boxSize.y, -boxSize.z, boxSize.w);
localPos[7] = centerPos + 0.5f * new Vector4(boxSize.x, boxSize.y, -boxSize.z, boxSize.w);
}
//计算collider的最低和最高的顶点世界坐标
Vector3 calculateVolum()
{
Vector3 Volum;
Matrix4x4 localToWorld = transform.localToWorldMatrix;
Volum.x = -9999999;
Volum.y = 9999999;
Vector4 worldPos;
//判断一下最高最低点
for (int i = 0; i < 8; i++)
{
worldPos = localToWorld * localPos[i];
if (worldPos.y > Volum.x)
Volum.x = worldPos.y;
if (worldPos.y < Volum.y)
Volum.y = worldPos.y;
}
Volum.z = 0;
return Volum;
}
//初始和结束都将体积计算并赋值一下。
private void OnEnable()
{
bottleLiquidMat = GetComponent<Renderer>().material;
if (bottleLiquidMat.HasProperty("_Volum"))
{
initializeVolum();
volum = calculateVolum();
bottleLiquidMat.SetVector(_Volum, volum);
}
}
private void OnDisable()
{
if (bottleLiquidMat.HasProperty("_Volum"))
{
initializeVolum();
volum = calculateVolum();
bottleLiquidMat.SetVector(_Volum, volum);
}
}
//每帧判断瓶子是否移动了,重新给shader传入最高最低点
void Update () {
if (transform.hasChanged && bottleLiquidMat.HasProperty("_Volum"))
{
volum = calculateVolum();
bottleLiquidMat.SetVector(_Volum, volum);
transform.hasChanged = false;
}
}
}
然后把脚本直接丢到瓶子身上。我们再在Shader中设置变量,接收一下:
//改一下level的数值范围。
_WaterLevel ("WaterLevel", range(0.2, 0.9)) = 0.5
//找个地方,声明体积变量
uniform float4 _Volum;
//前面都不需要修改,PS中增加一些代码
fixed4 frag(v2f i) : SV_Target
{
//取到最高,最低点
float heightMax = _Volum.x;
float heightMin = _Volum.y;
//直接根据设置的液面高度,对最低和最高顶点进行插值一下,求出液面实际高度
float waterLevel = heightMin + _WaterLevel * (heightMax - heightMin);
clip(waterLevel - i.worldPos.y);
return _LiquidColor;
}
OK,到这里,瓶中液体的液面就能根据液体体积进行自动适应了。运行看一下,是不是像下面一样了?
嗯,然后就是最后一步,加入液面的摇晃就完成了。先到这里,休息休息一下。