炫酷的汽车幻化为粒子效果分享

先上效果链接:
https://www.bilibili.com/video/av75962636/
在这里插入图片描述
之前在做动画的时候见到有人用Krakatoa粒子做过类似的效果,其中有些方法和技巧很值得借鉴,所以我就在Unity里尝试着制作了。

制作这个效果主要用到了ComputeShader、ComputeBuffer、GeometryShader,在试验的过程中也用了Unity的JobSystem来提高效率,但还是比ComputeShader效率差了不止一个数量级。

这个效果有个最大的难点是粒子需要构成汽车,还要还原汽车的形状、光照、反射甚至玻璃的透明,更要在相机移动的时候需要粒子的光照和反射和透明也一起变,最后还要在保证高帧率的情况下显示和模拟千万级数量的粒子。看起来无从下手,我们一步步来。

先是准备工作,祭出我的御用玛莎拉蒂:
在这里插入图片描述
可以简单调一下材质效果。这里给大家分享个简单出效果的制作车漆材质的办法:车漆重要的是虫漆效果,也就是有个底层材质外加个光滑表面材质,所以可以在Standard Surface Shader上新建个透明Pass专门负责光滑表面反射。由于车漆不是本文重点就不赘述了。下面进入干货区。

我用的硬件是I7 8750H + gtx1070的笔记本电脑。

一、实时渲染千万级的粒子

如何能够实时渲染千万级数量的粒子?这是首先要解决的问题和其他效果的前提。我通常的做法是用Graphics.DrawProcedural先画点,然后在GeometryShader中给每个点周围画个四边形(可以在屏幕空间构建,这样可以保证四边形符合实际像素的尺寸)。
GeometryShader如下,_Width是四边形的宽度:

half _Width;
[maxvertexcount(4)]
void geom(point v2g input[1], inout TriangleStream<g2f> triStream)
{
	v2g p = input[0];

	float offsetX = _Width / _ScreenParams.x * p.position.w * 0.5f;
	float offsetY = offsetX;
	float aspectReciprocal = _ScreenParams.y / _ScreenParams.x;
	offsetX *= aspectReciprocal;

	g2f pIn = (g2f)0;
	pIn.vertex = p.position + float4(-offsetX, offsetY, 0, 0);
	pIn.uv = half2(0, 1);
	triStream.Append(pIn);
	pIn.vertex = p.position + float4(offsetX, offsetY, 0, 0);
	pIn.uv = half2(1, 1);
	triStream.Append(pIn);
	pIn.vertex = p.position + float4(-offsetX, -offsetY, 0, 0);
	pIn.uv = half2(0, 0);
	triStream.Append(pIn);
	pIn.vertex = p.position + float4(offsetX, -offsetY, 0, 0);
	pIn.uv = half2(1, 0);
	triStream.Append(pIn);
}

c#代码如下,其中_TotalPointCount就是点的个数:

private void OnRenderObject()
{
    if (Camera.current == Camera.main)
    {
        _Material.SetPass(0);
        Graphics.DrawProcedural(MeshTopology.Points, _TotalPointCount);
    }
}

二、用粒子填充汽车的形状

其实思路很简单,就是随机找个网格的三角形,在这个三角形上随机找个位置点然后放个粒子。
那么问题来了:每个三角形大小不一样,需要保证面积大的三角形能覆盖的粒子得多一些这样才能均匀的构建粒子汽车?就算找到三角形之后,如何在三角形上随机找个点?就算这两点都解决了,效率怎么保证?千万数量级的粒子不是说它出来就能出来的,虽然不用实时计算(第一次算出来后面就直接用),然是也不能让人等到花也谢了吧。

第一个问题,既然跟面积有关那么我们完全可以用面积作为随机计算的一个因素。首先算出每个三角形的面积,然后把面积按照一维数组排成一排。再算出所有三角形面积总和,每次随机取样的时候取 0——三角形总面积,然后根据取到的面积的随机值回到面积数组里找序号,再用序号找到三角形。
首先缓存三角形为方便后续计算,定义个结构体记录需要的数据:

public struct MeshCache
{
    public Vector3[] positions;//所有顶点位置
    public int[] triangles;//所有三角形序号
    public float[] triangleAreas;//所有三角形面积数组。这里为了方便通过面积找到序号,数组里面每个值都是前面所有三角形面积的和
    public int triangleAreaCount;//三角形面积数组的长度
    public float totalArea;//总三角形面积
    /// <summary>
    /// 根据面积找到序号
    /// </summary>
    /// <param name="area">随机生成的面积值</param>
    /// <returns></returns>
    public int GetTriangleAreaIndex(float area)
    {
        for (int i = 0; i < triangleAreaCount; i++)
        {
            if (triangleAreas[i] >= area)
            {
                return i;
            }
        }
        return -1;
    }
}

再计算面积生成缓存数据:

/// <summary>
/// 生成网格缓存
/// </summary>
private void GenerateMeshCache()
{
    var meshFilters = _TargetMeshGo.GetComponentsInChildren<MeshFilter>();
    int prevVertCount = 0;
    var totalPositions = new List<Vector3>();
    var totalTriangles = new List<int>();
    var totalTriangleAreas = new List<float>();
    foreach (var mfItem in meshFilters)
    {
        var itemVertices = mfItem.sharedMesh.vertices;
        var localToWorld = mfItem.transform.localToWorldMatrix;
        for (int i = 0; i < itemVertices.Length; i++)
        {
            itemVertices[i] = localToWorld.MultiplyPoint(itemVertices[i]);
        }
        totalPositions.AddRange(itemVertices);
        var itemTriangles = mfItem.sharedMesh.triangles;
        for (int i = 0; i < itemTriangles.Length; i++)
        {
            itemTriangles[i] += prevVertCount;
        }
        totalTriangles.AddRange(itemTriangles);
        prevVertCount += itemVertices.Length;
    }

    float totalArea = 0;
    for (int i = 0, count = totalTriangles.Count; i < count; i += 3)
    {
        var area = GetTriangleArea(totalPositions[totalTriangles[i]], totalPositions[totalTriangles[i + 1]], totalPositions[totalTriangles[i + 2]]);
        totalArea += area;
        totalTriangleAreas.Add(totalArea);
    }
    _MeshCache.positions = totalPositions.ToArray();
    _MeshCache.triangles = totalTriangles.ToArray();
    _MeshCache.triangleAreas = totalTriangleAreas.ToArray();
    _MeshCache.totalArea = totalArea;
    _MeshCache.triangleAreaCount = _MeshCache.triangleAreas.Length;
}

可能有人会问,三角形面积怎么计算?根据三角形的面积计算公式底x高/2,确定一条边作为底,需要算出高。
这里给出两种算出三角形的高的方法,一是根据向量余弦,二是根据向量投影(测试下来第一种方法快)。代码如下:

private float GetTriangleArea(Vector3 A, Vector3 B, Vector3 C)
{
    if (A == B || B == C || A == C) return 0;
    var vectorAB = B - A;
    var vectorAC = C - A;
    var dot = Vector3.Dot(vectorAB.normalized, vectorAC.normalized);
    var angle = Mathf.Acos(dot);
    var height = vectorAB.magnitude * Mathf.Sin(angle);
    var width = vectorAC.magnitude;
    return width * height * 0.5f;
}
private float GetTriangleArea2(Vector3 A, Vector3 B, Vector3 C)
{
    if (A == B || B == C || A == C) return 0;
    var vectorAB = B - A;
    var vectorAC = C - A;
    var vectorACNormalized = vectorAC.normalized;
    var vectorZ = Vector3.Cross(vectorAB.normalized, vectorACNormalized).normalized;
    var vectorHeight = Vector3.Cross(vectorACNormalized, vectorZ).normalized;
    var height = Vector3.Dot(vectorAB, vectorHeight);
    var width = vectorAC.magnitude;
    return width * height * 0.5f;
}

第二个问题,三角形上随机找点,这里我偷了个懒,感觉这种计算肯定有人做过所以我就去搜索了一下,果然找到了(原地址在注释里有写):

/// <summary>
/// reference from https://stackoverflow.com/questions/4778147/sample-random-point-in-triangle
/// </summary>
private Vector3 GetRandomPointOnTriangle(Vector3 A, Vector3 B, Vector3 C)
{
    var r1 = Random.Range(0.0f, 1.0f);
    var r2 = Random.Range(0.0f, 1.0f);
    var result = (1.0f - Mathf.Sqrt(r1)) * A + (Mathf.Sqrt(r1) * (1.0f - r2)) * B + (Mathf.Sqrt(r1) * r2) * C;
    return result;
}

然后收集生成的随机点,_PointsOnMesh是存储随机点的数组:

for (int i = 0; i < _TotalPointCount; i++)
{
    var random = Random.Range(0.0f, _MeshCache.totalArea);
    var triangleAreaIndex = _MeshCache.GetTriangleAreaIndex(random);
    if (triangleAreaIndex != -1)
    {
        var triangleStartIndex = triangleAreaIndex * 3;
        var point = GetRandomPointOnTriangle(_MeshCache.positions[_MeshCache.triangles[triangleStartIndex]], _MeshCache.positions[_MeshCache.triangles[triangleStartIndex + 1]], _MeshCache.positions[_MeshCache.triangles[triangleStartIndex + 2]]);
        _PointsOnMesh[i] = point;
    }
    else
    {
        Debug.LogError("triangleAreaIndex not found! area:" + random);
    }
}

第三个问题,计算优化。这辆车模型大概30多万个三角形,计算面积消耗了0.23秒,因为只需要计算一次所以还能接受。但是计算分布随机点的时候用了30秒(50000个粒子),这就让人不能接受了,因为这点粒子根本不能满足要求,根本不能填出个车的形状:
你能看出这是车吗?
你能看出这是车吗?好吧有点像,但是根本不符合我的使用要求。我需要的是至少10000000个粒子,按照这个计算量需要将近2小时的计算时间,那我还怎么测试啊,每次点击Play需要等2小时???所以必须优化这个计算过程。

我首先想到的就是Unity的JobSystem,可以用多核心同时计算,所以用IJobParallelFor来试了一下,但是测试结果让我大跌眼镜——同样是随机分布50000个粒子居然用了28秒,只快了2秒?可能是内存分配消耗了太多时间了?一时半会找不到原因,我不得不尝试其他方法,ComputeShader是个可以尝试的方向。

还好这个计算过程比较简单,很快就把代码写成了ComputeShader的版本,如下:

#pragma kernel Distribution

StructuredBuffer<float3> positions;
StructuredBuffer<int> triangles;
StructuredBuffer<float> triangleAreas;
int triangleAreasCount;
float totalArea;
int totalPointCount;

StructuredBuffer<float> areaRandoms;
StructuredBuffer<float> r1Randoms;
StructuredBuffer<float> r2Randoms;

RWStructuredBuffer<float3> resultPoints;

int GetTriangleAreaIndex(float area)
{
	for (int i = 0; i < triangleAreasCount; i++)
	{
		if (triangleAreas[i] >= area)
		{
			return i;
		}
	}
	return -1;
}
float3 GetRandomPointOnTriangle(float3 A, float3 B, float3 C, int i)
{
	float r1 = r1Randoms[i];
	float r2 = r2Randoms[i];
	float3 result = (1.0f - sqrt(r1)) * A + (sqrt(r1) * (1.0f - r2)) * B + (sqrt(r1) * r2) * C;
	return result;
}
[numthreads(1024, 1, 1)]
void Distribution(uint3 id : SV_DispatchThreadID)
{
	int index = (int)id.x;
	if (index < totalPointCount)
	{
		float random = areaRandoms[index];
		int triangleAreaIndex = GetTriangleAreaIndex(random);
		if (triangleAreaIndex != -1)
		{
			int triangleStartIndex = triangleAreaIndex * 3;
			float3 resultPoint = GetRandomPointOnTriangle(positions[triangles[triangleStartIndex]], positions[triangles[triangleStartIndex + 1]], positions[triangles[triangleStartIndex + 2]], index);
			resultPoints[index] = resultPoint;
		}
	}
}

其中需要注意的是Random,由于在GPU中并没有现成的Random函数,所以我是在CPU中用Random生成了数组给GPU查表来用。C#代码如下:

const int interval = 100000;
int loopCount = (_TotalPointCount / interval) + ((_TotalPointCount % interval) > 0 ? 1 : 0);
int calculatedPointCount = 0;
for (int loopIndex = 0; loopIndex < loopCount; loopIndex++)
{
    int pointCount = (_TotalPointCount - calculatedPointCount > interval) ? interval : _TotalPointCount - calculatedPointCount;
    
    _PositionsCB = new ComputeBuffer(_MeshCache.positions.Length, 12);
    _PositionsCB.SetData(_MeshCache.positions);
    _TrianglesCB = new ComputeBuffer(_MeshCache.triangles.Length, 4);
    _TrianglesCB.SetData(_MeshCache.triangles);
    _TriangleAreasCB = new ComputeBuffer(_MeshCache.triangleAreas.Length, 4);
    _TriangleAreasCB.SetData(_MeshCache.triangleAreas);
    var areaRandoms = new float[pointCount];
    var r1Randoms = new float[pointCount];
    var r2Randoms = new float[pointCount];
    for (int i = 0; i < pointCount; i++)
    {
        areaRandoms[i] = Random.Range(0.0f, _MeshCache.totalArea);
        r1Randoms[i] = Random.Range(0.0f, 1.0f);
        r2Randoms[i] = Random.Range(0.0f, 1.0f);
    }
    _AreaRandomsCB = new ComputeBuffer(areaRandoms.Length, 4);
    _AreaRandomsCB.SetData(areaRandoms);
    _R1RandomsCB = new ComputeBuffer(r1Randoms.Length, 4);
    _R1RandomsCB.SetData(r1Randoms);
    _R2RandomsCB = new ComputeBuffer(r2Randoms.Length, 4);
    _R2RandomsCB.SetData(r2Randoms);

    _KernelID_Distribution = _DistributionComputeShader.FindKernel("Distribution");
    _DistributionComputeShader.SetBuffer(_KernelID_Distribution, "positions", _PositionsCB);
    _DistributionComputeShader.SetBuffer(_KernelID_Distribution, "triangles", _TrianglesCB);
    _DistributionComputeShader.SetBuffer(_KernelID_Distribution, "triangleAreas", _TriangleAreasCB);
    _DistributionComputeShader.SetBuffer(_KernelID_Distribution, "areaRandoms", _AreaRandomsCB);
    _DistributionComputeShader.SetBuffer(_KernelID_Distribution, "r1Randoms", _R1RandomsCB);
    _DistributionComputeShader.SetBuffer(_KernelID_Distribution, "r2Randoms", _R2RandomsCB);

    var intervalResultCB = new ComputeBuffer(pointCount, 12);
    var intervalResult = new Vector3[pointCount];
    intervalResultCB.SetData(intervalResult);
    _DistributionComputeShader.SetBuffer(_KernelID_Distribution, "resultPoints", intervalResultCB);

    _DistributionComputeShader.SetInt("triangleAreasCount", _MeshCache.triangleAreaCount);
    _DistributionComputeShader.SetFloat("totalArea", _MeshCache.totalArea);
    _DistributionComputeShader.SetInt("totalPointCount", pointCount);

    _DistributionComputeShader.Dispatch(_KernelID_Distribution, pointCount / 1024 + 1, 1, 1);

    intervalResultCB.GetData(_PointsOnMesh, calculatedPointCount, 0, pointCount);
    intervalResultCB.Release();
    _PositionsCB.Release();
    _TrianglesCB.Release();
    _TriangleAreasCB.Release();
    _AreaRandomsCB.Release();
    _R1RandomsCB.Release();
    _R2RandomsCB.Release();

    calculatedPointCount += interval;
}

由于需要传递很多的数据所以用了好几个ComputeBuffer。可以看到我对每次计算做了100000个粒子的数量限制,循环多次计算把数据再拷贝回结果数组中,这样做是因为粒子到一定的数量之后GPU计算貌似就假死了,也不清楚什么原因,就这样绕过去了。测试一下,果然快很多,50000个粒子只需要0.15秒!!!直接上10000000个粒子,25秒计算完成!!!不错了,可以用了,看下面截图,已经填得满满的了:
在这里插入图片描述
为了下一步的测试方便,我把粒子保存为文件,每次就可以在1秒之内从磁盘读取(一千万个粒子保存为117mb的文件)。

三、还原汽车的光照和反射

这里要用到一个技巧。光照和反射还有透明这些效果并不是在粒子的材质上计算的,如果是相机是静态的只计算一次也还行,但是相机是可以自由移动的,这个时候还需要有光照和反射和透明,重点来了,可以用相机渲染一张RenderTexture,然后构成汽车的粒子按照屏幕空间的uv坐标来采样RenderTexture显示颜色。 这种方法不允许相机离得太近,但是完全符合我的需求了。下图就是相机离得太近之后:
在这里插入图片描述

这里没什么技术含量,在VertexShader或GeometryShader里可以计算屏幕uv,我在GeometryShader里加了一行:
在这里插入图片描述

四、扩散粒子

这里对于熟悉ComputeShader的同学就没啥技术可讲了,其实就是用个Noise的函数(Google一下能找到cg的版本),根据粒子的空间位置生成个向量值作为力,调整为合适的大小和缩放,再用力影响粒子的加速度和速度和位置,最终效果就出来了:
在这里插入图片描述
我的示例里用个平面拖动来触发扩散效果,其实只是判断了一下世界坐标的z轴位置。

当然这个效果还有很多不足的地方,比如粒子散开之后再旋转相机会让散开的粒子的颜色变化太快出现闪的的情况,但是也是有办法优化,今天就不多写了。

下次在UE4里试试看能不能做出这个效果。

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值