|
(本插件写于URP12更新前)
众所周知Unity在把重心从Build-in渲染管线移动到基于SRP的HDRP和URP渲染管线以后,许多功能被拆分/移植/强化效果/舍弃。然而从Build-in到URP12之前,原有的Lens Flare系统被Unity给扔了。虽然原来那套系统(配上Standard包的贴图)实在说不上好看,但是其实如果选用好一点的贴图,其实最后效果还是可以接受的。对于室外日光场景较多的游戏来说,Lens Flare是一个不可或缺的组成部分。所以我重写一个Lens Flare系统以支持URP和多光源,以供一些有需求的项目使用。
最终效果
先看一下最终效果,最后实现的逻辑可以在任意管线下进行,并且每个光源产生的所有flare只需要一个Batch(实际上如果所有光源使用同一个图集,经过少量修改可以让所有光源的flare在一个Batch内提交)
无flare的默认开销
添加两条flare之后的开销
Unity原有方案
我这套系统大体逻辑类似原先方案,因此先看一下原先Unity在Bulid-in管线下的Lens Flare系统的逻辑。原有的Lens Flare系统由Flare Layer组件、Lens Flare组件、Flare资产三个部分组成。其中Flare Layer需要挂在Camera上用于渲染,Lens Flare组件用于产生Flares,Flare资产用于附在光源组件或者Lens Flare组件用于设置可供显示的Flare。
我首先确定这种Lens Flare的渲染方式,这种使用图集进行显示的Lens Flare实际是将几块flare渲染在面片上,然后贴在Camera前方,根据不同遮挡情况进行显示或者隐藏来模拟镜头光晕的效果。这就是Flare Layer的意义。整个渲染流程如下:
资产构成介绍
数据
首先实现Flare资产部分,这个部分由两个脚本组成。一个是用于定义类和生成Asset的MFFlareAsset.cs,另一个是用于编辑的MFFlareAssetEditor.cs
在MFFlareAsset.cs中定义了一个继承自ScriptableObject的MFFlareAsset类,这是Flare资产的核心。继承ScriptableObject用于将资产序列化以储存在包体中。我这里定义以下五个参数:
先解释一下类上面的两行,第一行[CreateAssetMenu]是在Project视图中鼠标右键的菜单上创建一个新的选项,用以生成该类的.asset资产,第二行的[Serializable]标记该类可序列化。
依次解释五个数据:
1.flareSprite:存放该资产所使用的flareSprite图集
2.fadeWithScale:这个开关开启时,flare会调整缩放进行淡入淡出,如淡出时每一个flare的大小从标准缩放逐渐变为0
3.fadeWithAlpha:这个开关开启时,flare会调整透明度进行淡入淡出,如淡出时每一个flare的透明度从完全不透明变为半透明直至Alpha值为0
4.spriteBlocks:以队列的方式储存一条Lens flare的构成,一条Lens Flare由若干个正方形面片连成一条线,每一个面片自带旋转、缩放以及uv信息(决定该面片使用的flare图形)数据。我将这单个数据存储为一个名为SpriteData的类,它同样是可以序列化的
SpriteData类储存了以下信息:
- useLightColor:开启时这一片flare的颜色会叠加光源的颜色
2. useRotation:开启时这一片flare会随着光源在屏幕中的位置而旋转,图的“正上方”永远指向屏幕中心
3. index:该flare在图集模板中的序号,我等下解释
4. block:以矩形数据(左上角和右下角的坐标)存储flare在图集上的位置信息
5. scale:该flare的基础缩放值
6. offset:该flare相对于屏幕中心的位置偏移
7. color:该flare默认叠加的颜色
分类
基于MFFlareAsset,我目前做了两种图集切割方式以便美术使用,分别是使用N*N切割的Cell模式和使用SpriteEditor的Multiply模式进行切割的Slicer模式,美术可以根据实际需求选择使用适合的版本
两者的区别是Cell模式在Asset中多了一个Cell num参数可以自动分割行列,而Slicer模式需要把图集在Texture Importer中设置Texture Type为Sprite(2D and UI)类型并修改Sprite Mode为Multiple类型然后在编辑器下自行进行切割。
Cell模式
Slicer模式
使用流程
资产制作流程(第一部分)
下面以Cell模式为例看一下flare资产制作流程
新建资产以后可以看到以下界面
其中Fade With Scale/ Fade With Alpha指的是Flare在淡入淡出过程中是否会发生缩放/透明度变化,推荐都开启
Texture这里塞入需要使用的Flare图集,然后在下方Cell num输入图集对应的行列数量,输入完成后会自动在下方分割出对应的单个flare图
这是一个6x6的图集,但是没有用完,并且排布不准,这是准备Slicer模式的原因之一
完成这一步后我们先配置游戏内脚本(后面会讲运行原理),运行游戏后在游戏内实时调整效果。
挂载脚本
方案内有两个需要挂载的脚本,分别是用于产生flare的MFFlareLauncher和用于渲染flare的MFLensflare(MFLensFlareSimple是一个简易方案,最后讲)
产生flare的是光源,因此将MFFlareLauncher挂载在需要产生Flare的光源上如图
挂载MFFlareLauncher的物体必须包含有Light组件(可以修改)
解释一下这里的参数,Directional Light选项指的是当前灯光是否为平行光,平行光与非平行光的设置会影响最终遮挡计算的结果(平行光相当于光源在无限远处)
UseLightIntensity选项指当前Lens Flare的亮度是否与光源亮度有关,如果勾选则flare最终颜色会乘光源instensity参数
Asset Model指该光源使用哪套flare配置(就是上文创建的Asset)
之后是挂载MFLensflare到摄像机下
此处除了需要设置的只有两个参数,Material与Fadeout Time
Material是最后用于渲染flare的材质,由于要在shader内进行遮挡计算,这个材质在工程提供了默认的shader : "Moonflow/Lensflare",后文会讲需要shader满足的条件
Fadeout Time指的是光源在屏幕边缘移入移出的时候flare淡入淡出的单次时间
其他参数功能:
DebugMode:开启后,Scene视图如果开启了Gizmos,则会显示若干条线段连接相机与目标Mesh的位置,用于判断失效是由于Mesh没有被正确绘制造成还是Mesh已经绘制但shader不对的情况
LightSource显示当前有几个光源正在渲染LensFlare
FlareDatas显示当前有几个Flare面片正在进行渲染以及它们的参数信息。
接下来运行游戏,在游戏运行状态下即可调整flare最终效果
资产制作流程(第二部分)
运行游戏后确保光源在视野里,回到刚才新建并挂载到脚本的Asset,开始添加Flare信息
在Asset中自动切割好出现的各个flare其实是按钮,都是可以点击的。每点击一下flare,会在asset中生成一个对应index的asset并随带预览
单个Flare数据说明:
Index:表示当前Flare在整个图集的序号,拖动可以切换flare类型
Rotation:开启后,该flare会随相对于视野中心位置而发生旋转,上图第三排左数第三个flare就适用这种情况
LightColor:指Flare自身颜色和光源颜色的混合(一般适用于灰度Flare)。如果该值为1则该flare在渲染时会完全叠加光源颜色
Offset:指flare相对屏幕正中与光源的位置。0指在屏幕正中,-1指和光源位置完全贴合。最大值为1
Color:对当前flare渲染过程中叠加一个指定颜色
Scale:当前flare在渲染时的缩放(可为负数)
Remove:点击移除当前Flare
修改演示
调整完所有flare后点击最上方的Save可以直接保存asset的修改(直接SaveProject也是可以的),这样flare就可以生效了
当然如果flare需要计算遮挡需要开启深度图,下文会解释具体原因
计算原理
计算分为三个部分:发射、渲染、着色。中间有一些算法可以替换以满足特殊需求,项目已在github进行开源,优化魔改版本已应用于公司项目
Reguluz/Moonflow-Lensflare-System: ■■Finished■■ Full pipeline multi light source lens flare rendering scheme based on atlas (github.com)
发射部分:MFFlareLauncher
MFFlareLauncher的构成非常简单,在打开时获取光源信息,把自身注册到主相机的MFLensflare上。关闭时再从主相机的MFLensflare上移除(如果相机有特殊使用需求可以修改这里的获取方式)
渲染部分:MFLensflare
MFLensflare首先是内建了一个结构体叫FlareState,用于记录当前某个光源的Flare状态
sourceCoordinate:光源在屏幕上的坐标(像素为单位)
flareWorldPosCenter:该光源上各个Flare在3d场景的Mesh中心坐标
fadeoutScale:该光源的flare在淡入淡出时的缩放值
fadeState:该光源的flare淡入淡出状态(图里有解释)
然后MFLensflare由以下函数组成,部分核心算法部分着重讲,其他略过。其核心是通过世界空间坐标和屏幕空间坐标的转换,通过读取配置信息并写入顶点色,直接绘制永远朝向相机的面片进行渲染
Awake:初始化脚本内部数据结构
InitFlareData:初始化指定光源的FlareState数据
AddLight:将对应光源镜头光晕信息添加到MFLensflare内的数据中
RemoveLight:将对应光源镜头光晕信息从MFLensflare内的数据中移除
LastUpdate:用于每帧最后渲染光晕
1. 生成新的屏幕长宽数据,清理上一帧信息
2. 逐光源生成当前帧flare数据
(1)获取对应光源的屏幕坐标(见下方函数)
(2)判断光源是否在屏幕渲染范围内(见下方函数)
(3)根据flare当前的淡入淡出缩放值判断当前fadeState
如果不在非渲染状态就计算Mesh信息(见下方函数)
if (flareState.fadeoutScale > 0)
{
if (!isIn)
{
flareState.fadeState = 2;
}
}
if(flareState.fadeoutScale < 1)
{
if (isIn)
{
flareState.fadeState = 1;
}
}
if (!isIn && flareState.fadeoutScale <=0 )
{
if (flareState.fadeState != 3)
{
flareState.fadeState = 3;
}
}
else
{
CalculateMeshData(ref flareState, i);
}
(4)根据当前fadeState状态计算新的fadeoutScale
switch (flareState.fadeState)
{
case 1:
flareState.fadeoutScale += Time.deltaTime / fadeoutTime;
flareState.fadeoutScale = Mathf.Clamp(flareState.fadeoutScale, 0, 1);
flareDatas = flareState;
break;
case 2:
flareState.fadeoutScale -= Time.deltaTime / fadeoutTime;
flareState.fadeoutScale = Mathf.Clamp(flareState.fadeoutScale, 0, 1);
flareDatas = flareState;
break;
case 3:
// RemoveLight(lightSource);
break;
default: flareDatas = flareState;
break;
}
(5)生成Mesh(见下方代码)
(6)Debug绘制flare面片位置
for (int i = 0; i < lightSource[lightIndex].assetModel.spriteBlocks.Count; i++)
{
Debug.DrawLine(_camera.transform.position, _camera.ScreenToWorldPoint(flareDatas[lightIndex].flareWorldPosCenter));
}CheckIn:判断光源是否在屏幕渲染范围内
bool CheckIn(ref FlareState state, int lightIndex)
{
if (state.sourceCoordinate.x < _camera.pixelRect.xMin || state.sourceCoordinate.y < _camera.pixelRect.yMin
|| state.sourceCoordinate.x > _camera.pixelRect.xMax || state.sourceCoordinate.y > _camera.pixelRect.yMax
|| Vector3.Dot(lightSource[lightIndex].directionalLight ? -lightSource[lightIndex].transform.forward : lightSource[lightIndex].transform.position - _camera.transform.position, _camera.transform.forward) < 0)
{
_screenpos.Add(Vector4.zero);
return false;
}
else
{
Vector4 screenUV = state.sourceCoordinate;
screenUV.x = screenUV.x / _camera.pixelWidth;
screenUV.y = screenUV.y / _camera.pixelHeight;
screenUV.w = lightSource[lightIndex].directionalLight ? 1 : 0;
_screenpos.Add(screenUV);
return true;
}
}这里先判断sourceCoordinate是否超出屏幕范围(xMin yMin xMax yMax的计算),然后判断是否与朝向一致(平行光为平行光方向点乘相机朝向,其他光源为相机到光源的方向向量点成相机朝向)。如果在视野内则进一步计算其屏幕空间uv与平行光信息并传入shader进行计算(见着色部分)
GetSourceCoordinate:计算光源在屏幕空间的坐标,平行光坐标使用魔法数字,后退10000米进行模拟,和实际位置会有非常小的差异
void GetSourceCoordinate(ref FlareState state, int lightIndex)
{
Vector3 sourceScreenPos = _camera.WorldToScreenPoint(
lightSource[lightIndex].directionalLight
?_camera.transform.position - lightSource[lightIndex].transform.forward * 10000
:lightSource[lightIndex].transform.position
);
state.sourceCoordinate = sourceScreenPos;
}CalculateMeshData:计算Mesh信息,根据当前发射器记录的信息逐flare计算每个flare在屏幕空间的三维坐标
void CalculateMeshData(ref FlareState state, int lightIndex)
{
Vector3[] oneFlareLine = new Vector3[lightSource[lightIndex].assetModel.spriteBlocks.Count];
float[] useLightColor = new float[lightSource[lightIndex].assetModel.spriteBlocks.Count];
for (int i = 0; i < lightSource[lightIndex].assetModel.spriteBlocks.Count; i++)
{
Vector2 realSourceCoordinateOffset = new Vector2(state.sourceCoordinate.x - _halfScreen.x, state.sourceCoordinate.y - _halfScreen.y);
Vector2 realOffset = realSourceCoordinateOffset * lightSource[lightIndex].assetModel.spriteBlocks.offset;
oneFlareLine = new Vector3(_halfScreen.x + realOffset.x, _halfScreen.y + realOffset.y, DISTANCE);
useLightColor = lightSource[lightIndex].assetModel.spriteBlocks.useLightColor;
}
state.flareWorldPosCenter = oneFlareLine;
}CreateMesh: 逐光源创建对应Mesh,以下为核心渲染逻辑,算法未经优化,应该会有内存浪费
MFFlareLauncher observer = lightSource[lightIndex];
Texture2D tex = observer.assetModel.flareSprite;
//计算偏转角
float angle = (45 +Vector2.SignedAngle(Vector2.up, new Vector2(flareDatas[lightIndex].sourceCoordinate.x - _halfScreen.x, flareDatas[lightIndex].sourceCoordinate.y - _halfScreen.y))) / 180 * Mathf.PI;
//逐flare执行生成
for (int i = 0; i < lightSource[lightIndex].assetModel.spriteBlocks.Count; i++)
{
//获取当前Flare的uv信息
Rect rect = observer.assetModel.spriteBlocks.block;
//计算当前Flare最终缩放
Vector2 halfSize = new Vector2(
tex.width * rect.width / 2 * observer.assetModel.spriteBlocks.scale * (observer.assetModel.fadeWithScale ? ( flareDatas[lightIndex].fadeoutScale * 0.5f + 0.5f) : 1),
tex.height * rect.height / 2 * observer.assetModel.spriteBlocks.scale * (observer.assetModel.fadeWithScale ? ( flareDatas[lightIndex].fadeoutScale * 0.5f + 0.5f) : 1));
//计算当前Flare的Mesh世界坐标
Vector3 flarePos = flareDatas[lightIndex].flareWorldPosCenter;
//计算当前flare的旋转并生成顶点序列
if (observer.assetModel.spriteBlocks.useRotation)
{
float magnitude = Mathf.Sqrt(halfSize.x * halfSize.x + halfSize.y * halfSize.y);
float cos = magnitude * Mathf.Cos(angle);
float sin = magnitude * Mathf.Sin(angle);
vertList.Add(_camera.ScreenToWorldPoint(new Vector3(flarePos.x - sin, flarePos.y + cos, flarePos.z)) - center);
vertList.Add(_camera.ScreenToWorldPoint(new Vector3(flarePos.x - cos, flarePos.y - sin, flarePos.z)) - center);
vertList.Add(_camera.ScreenToWorldPoint(new Vector3(flarePos.x + cos, flarePos.y + sin, flarePos.z)) - center);
vertList.Add(_camera.ScreenToWorldPoint(new Vector3(flarePos.x + sin, flarePos.y - cos, flarePos.z)) - center);
}
else
{
vertList.Add(_camera.ScreenToWorldPoint(new Vector3(flarePos.x - halfSize.x, flarePos.y + halfSize.y, flarePos.z)) - center);
vertList.Add(_camera.ScreenToWorldPoint(new Vector3(flarePos.x - halfSize.x, flarePos.y - halfSize.y, flarePos.z)) - center);
vertList.Add(_camera.ScreenToWorldPoint(new Vector3(flarePos.x + halfSize.x, flarePos.y + halfSize.y, flarePos.z)) - center);
vertList.Add(_camera.ScreenToWorldPoint(new Vector3(flarePos.x + halfSize.x, flarePos.y - halfSize.y, flarePos.z)) - center);
}
//添加相关信息至uv序列
uvList.Add(rect.position);
uvList.Add(rect.position + new Vector2(rect.width, 0));
uvList.Add(rect.position + new Vector2(0, rect.height));
uvList.Add(rect.position + rect.size);
//按以上生成顺序将顶点序列添加到三角面渲染序列中
tri.Add(i * 4);
tri.Add(i * 4 + 3);
tri.Add(i * 4 + 1);
tri.Add(i * 4);
tri.Add(i * 4 + 2);
tri.Add(i * 4 + 3);
//计算当前Flare最终颜色叠加结果写入到单Flare面片的四个顶点的顶点色中
Color vertexAddColor = observer.assetModel.spriteBlocks.color;
Color lightColor = default;
Light source = observer.GetComponent<Light>();
lightColor.r = Mathf.Lerp(1, source.color.r,
observer.assetModel.spriteBlocks.useLightColor);
lightColor.g = Mathf.Lerp(1, source.color.g,
observer.assetModel.spriteBlocks.useLightColor);
lightColor.b = Mathf.Lerp(1, source.color.b,
observer.assetModel.spriteBlocks.useLightColor);
lightColor.a = 1;
lightColor *= observer.useLightIntensity ? source.intensity : 1;
vertexAddColor *= new Vector4(lightColor.r, lightColor.g, lightColor.b,
(1.5f - Mathf.Abs(observer.assetModel.spriteBlocks.offset)) / 1.5f
* (1 - Mathf.Min(1, new Vector2(flarePos.x - _halfScreen.x, flarePos.y - _halfScreen.y).magnitude / new Vector2(_halfScreen.x, _halfScreen.y).magnitude))
) * ((observer.assetModel.fadeWithAlpha ? flareDatas[lightIndex].fadeoutScale: 1));
vertexAddColor = vertexAddColor.linear;
vertColors.Add(vertexAddColor);
vertColors.Add(vertexAddColor);
vertColors.Add(vertexAddColor);
vertColors.Add(vertexAddColor);
}
//将所有光源的数据列进list中,对贴图进行赋值并进行渲染
_totalVert.AddRange(vertList);
_totalUv.AddRange(uvList);
_totalTriangle.AddRange(tri);
_totalColor.AddRange(vertColors);
_totalMesh[count].vertices = _totalVert.ToArray();
_totalMesh[count].uv = _totalUv.ToArray();
_totalMesh[count].triangles = _totalTriangle.ToArray();
_totalMesh[count].colors = _totalColor.ToArray();
_propertyBlock.SetTexture(STATIC_BaseMap, observer.assetModel.flareSprite);
_propertyBlock.SetVector(STATIC_FLARESCREENPOS, _screenpos[count]);
Graphics.DrawMesh(_totalMesh[count], center, Quaternion.identity, material, 0, _camera, 0, _propertyBlock);
count++;这样单条Lensflare就渲染完成
着色部分
为了提高整体效果,着色部分计算了光源遮挡。如果正常渲染需要shader满足以下条件
- 需要一个_MainTex贴图,对应MFLensflare内的STATIC_BaseMap,可修改,此为光源贴图
- 需要一个名为_FlareScreenPos的四位数据,对应MFLensflare内的STATIC_FLARESCREENPOS,可修改,记录了光源的屏幕坐标
- RenderType为Transparent以进行半透明渲染
- Blend One One
ZWrite Off
ZTest Off
以保证叠加结果正确且永远在最前渲染
- 需要深度图进行遮挡计算,通过传入光源的屏幕坐标,用屏幕坐标xy值读取深度图与其z值作比较判断是否被遮挡,算法如下
half depthMask = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, _FlareScreenPos.xy).r;
half depthTex = LinearEyeDepth(depthMask, _ZBufferParams);
half needRender = lerp(saturate(depthTex - _FlareScreenPos.z), 1 - ceil(depthMask), _FlareScreenPos.w);原理上就这么些,说麻烦也不是很多,而且贴出来的代码没有优化。本来这篇已经不打算写了(因为官方做了新的),后来觉得能改算法做些特殊处理,应该还是有一些作用的,熟悉整个渲染过程了解思路还有点意义。思来想去还是放这么篇上来,希望能帮到一些新手 |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|