找回密码
 立即注册
查看: 400|回复: 0

【Unity】可以用于所有管线的基于屏幕Mesh绘制的多光源 ...

[复制链接]
发表于 2022-1-10 20:47 | 显示全部楼层 |阅读模式
(本插件写于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);原理上就这么些,说麻烦也不是很多,而且贴出来的代码没有优化。本来这篇已经不打算写了(因为官方做了新的),后来觉得能改算法做些特殊处理,应该还是有一些作用的,熟悉整个渲染过程了解思路还有点意义。思来想去还是放这么篇上来,希望能帮到一些新手

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-9-22 22:34 , Processed in 0.143619 second(s), 23 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表