LensFlare(SRP) in VR

前言

Unity的URP和HDRP针对LensFlare也就是炫光,使用的是同一套逻辑(LensFlare(SRP))。但就是不支持VR,无论是单通道还是多通道。

Unity这样做也有一定的道理,本来这个效果就是模拟镜头的一种缺陷,人眼一般不会看到这种炫光,所以VR不支持也是理所当然的。
但本着没事找事的心态,权当练习,我翻看了这部分的源码,做了一点修改,使之能兼容VR。

效果如下:

镜头炫光

这里是仓库地址
在我给官方提了这个BUG后,回复是就是不支持。
论坛上也有有说这个问题已经存在很久了。
平行光偏移点光源偏移

分析

先从看它的shader入手吧,很简单,就一个文件 LensFlareDataDriven.shader,分了4个pass,每个pass的区别仅仅在于Blend的方式,分别对应了Additive、Screen、Premultiply、Lerp四种BlendMode。
Lens Flare Data中的四种BlendMode

关键的逻辑在LensFlareCommon.hlsl中,输入有两张贴图,颜色一张,遮挡一张,然后是一个颜色和5个向量,分别定义了颜色,旋转、缩放、屏幕位置、偏移、宽高比、遮挡半径、遮挡采样次数、是否离屏渲染以及边缘偏移、羽化范围、多边形属性等等。

TEXTURE2D(_FlareTex);
SAMPLER(sampler_FlareTex);

#if defined(HDRP_FLARE) && defined(FLARE_OCCLUSION)
TEXTURE2D_X(_FlareOcclusionTex);
SAMPLER(sampler_FlareOcclusionTex);
#endif

float4 _FlareColorValue;
float4 _FlareData0; // x: localCos0, y: localSin0, zw: PositionOffsetXY
float4 _FlareData1; // x: OcclusionRadius, y: OcclusionSampleCount, z: ScreenPosZ, w: ScreenRatio
float4 _FlareData2; // xy: ScreenPos, zw: FlareSize
float4 _FlareData3; // x: Allow Offscreen, y: Edge Offset, z: Falloff, w: invSideCount
float4 _FlareData4; // x: SDF Roundness, y: Poly Radius, z: PolyParam0, w: PolyParam1

而且都通过定义宏的方式来增强了可读性,学到了。

#define _LocalCos0              _FlareData0.x
#define _LocalSin0              _FlareData0.y
#define _PositionTranslate      _FlareData0.zw

#define _OcclusionRadius        _FlareData1.x
#define _OcclusionSampleCount   _FlareData1.y
#define _ScreenPosZ             _FlareData1.z
#ifndef _FlareScreenRatio
#define _FlareScreenRatio       _FlareData1.w
#endif

再往下就发现了从shadertoy拿来的工具函数float4 ComputeCircle(float2 uv) float4 ComputePolygon(float2 uv_),用来程序化直接从UV绘制椭圆和多边形的。

float4 GetFlareShape(float2 uv)
{
#ifdef FLARE_CIRCLE
    return ComputeCircle(uv);
#elif defined(FLARE_POLYGON)
    return ComputePolygon(uv);
#else
    return SAMPLE_TEXTURE2D(_FlareTex, sampler_FlareTex, uv);
#endif
}

这里看到绘制分成三种,圆,多边形,贴图。对应了这里的Type
Lens Flare Data中的三种类型

最后来看看vert函数

float4 posPreScale = float4(2.0f, 2.0f, 1.0f, 1.0f) * GetQuadVertexPosition(input.vertexID) - float4(1.0f, 1.0f, 0.0f, 0.0);
float2 uv = GetQuadTexCoord(input.vertexID);
uv.x = 1.0f - uv.x;

output.texcoord.xy = uv;

posPreScale.xy *= _FlareSize;
float2 local = Rotate(posPreScale.xy, _LocalCos0, _LocalSin0);

local.x *= screenRatio;

output.positionCS.xy = local + _ScreenPos + _PositionTranslate;
output.positionCS.z = 1.0f;
output.positionCS.w = 1.0f;

用传入的屏幕坐标来缩放、旋转、乘上宽高比,加上UV偏移和另一个传入的偏移量直接写入positionCS。这里的positionCS是裁剪空间坐标,因为w设置为1,那xy范围就是(-1,1),(0,0)表示屏幕中心。

看到这就已经猜到它为什么不支持VR了,因为屏幕坐标是事先用C#脚本通过Camera的属性计算好的,而且估计计算时没有考虑VR下有两个摄像机,导致最终的屏幕位置是错误的。

修改

那修改的思路就确定了,增加一个向量,记录炫光分别在左右眼下的屏幕位置,依据USING_STEREO_MATRICES和unity_StereoEyeIndex来判断当前到底该使用哪一个屏幕坐标。

脚本修改

回到代码中,从LensFlareComponent一路往回推,发现主要的逻辑代码都在LensFlareCommonSRP中,用单例来管理,而调用则是在URP的UniversalRenderer中,在后处理时,由PostProcessPass调用,通过FrameDebugger也能看到。

URP的代码没法修改,只好全部另起炉灶,自定义RenderFeature、pass、Component等,把原来的逻辑先复制一遍,开始修改。
目录结构
从PostProcessPass的DoLensFlareDatadriven中可以看到

void DoLensFlareDatadriven(Camera camera, CommandBuffer cmd, RenderTargetIdentifier source, bool usePanini, float paniniDistance, float paniniCropToFit)
{
    var gpuView = camera.worldToCameraMatrix;
    var gpuNonJitteredProj = GL.GetGPUProjectionMatrix(camera.projectionMatrix, true);
    // Zero out the translation component.
    gpuView.SetColumn(3, new Vector4(0, 0, 0, 1));
    var gpuVP = gpuNonJitteredProj * camera.worldToCameraMatrix;
    
    LensFlareCommonSRP.DoLensFlareDataDrivenCommon(m_Materials.lensFlareDataDriven, LensFlareCommonSRP.Instance, camera, (float)m_Descriptor.width, (float)m_Descriptor.height,
        usePanini, paniniDistance, paniniCropToFit,
        true,
        camera.transform.position,
        gpuVP,
        cmd, source,
        (Light light, Camera cam, Vector3 wo) => { return GetLensFlareLightAttenuation(light, cam, wo); },
        ShaderConstants._FlareOcclusionTex, ShaderConstants._FlareOcclusionIndex,
        ShaderConstants._FlareTex, ShaderConstants._FlareColorValue,
        ShaderConstants._FlareData0, ShaderConstants._FlareData1, ShaderConstants._FlareData2, ShaderConstants._FlareData3, ShaderConstants._FlareData4,
        false);
}

这里是用的camera.worldToCameraMatrix来直接拿的矩阵,但在VR环境下,应该要用camera.GetStereoViewMatrix(eye);的方式分别来取左右眼的矩阵。所以这里添加上左右眼的VP矩阵,传入DoLensFlareDataDrivenCommon中。

Matrix4x4 getMatrixFromEye(Camera camera, Camera.StereoscopicEye eye)
{
    var gpuView = camera.GetStereoViewMatrix(eye);
    var gpuNonJitteredProj = GL.GetGPUProjectionMatrix(camera.GetStereoNonJitteredProjectionMatrix(eye), true);
    // Zero out the translation component.
    gpuView.SetColumn(3, new Vector4(0, 0, 0, 1));
    var gpuVP = gpuNonJitteredProj * gpuView;

    return gpuVP;
}

void DoLensFlareDatadriven(Camera camera, CommandBuffer cmd, RenderTargetIdentifier source, bool usePanini,
    float paniniDistance, float paniniCropToFit)
{
    ......
    var leftGpuVP = getMatrixFromEye(camera, Camera.StereoscopicEye.Left);
    var rightGpuVP = getMatrixFromEye(camera, Camera.StereoscopicEye.Right);
    
    LensFlareCommonMK.DoLensFlareDataDrivenCommon(m_Material, LensFlareCommonMK.Instance, camera,
    (float)m_Descriptor.width, (float)m_Descriptor.height,
    usePanini, paniniDistance, paniniCropToFit,
    true,
    camera.transform.position,
    gpuVP,
    leftGpuVP,
    rightGpuVP,
    cmd, source,
    ......
}

来到DoLensFlareDataDrivenCommon函数,这里必须吐槽下这个函数,近16个参数就不说了,函数体也有500多行。。。
在这里增加一个函数来计算炫光在左右眼中的屏幕位置

static public Vector2 GetScreenPos(Camera.MonoOrStereoscopicEye eye, LensFlareComponentMK comp, Camera cam, bool isCameraRelative, Matrix4x4 viewProjMatrix)
{
    Light light = comp.GetComponent<Light>();

    Vector3 positionWS;
    Vector3 viewportPos;

    bool isDirLight = false;
    if (light != null && light.type == LightType.Directional)
    {
        positionWS = -light.transform.forward * cam.farClipPlane;
        isDirLight = true;
    }
    else
    {
        positionWS = comp.transform.position;
    }

    viewportPos = WorldToViewport(eye, cam, !isDirLight, isCameraRelative, viewProjMatrix, positionWS);

    Vector2 screenPos = new Vector2(2.0f * viewportPos.x - 1.0f, 1.0f - 2.0f * viewportPos.y);
    return screenPos;
}

在使用相机位置的时候也分别要用左右眼球的位置

public static Vector3 GetEyePosition(Side eye)
{
    if (XRSettings.enabled)
    {
        Vector3 posLeft;
        InputDevice device =
            InputDevices.GetDeviceAtXRNode(eye == Side.Left ? XRNode.LeftEye : XRNode.RightEye);
        if (device.isValid)
        {
            if (device.TryGetFeatureValue(
                    eye == Side.Left ? CommonUsages.leftEyePosition : CommonUsages.rightEyePosition,
                    out posLeft))
                return posLeft;
        }

        Debug.LogError("can not find eyePos");
        return default(Vector3);
    }
    else
    {
        return Camera.main.transform.position;
    }
}

计算出来后,添加进去

var leftPos = GetScreenPos(Camera.MonoOrStereoscopicEye.Left,comp,cam,isCameraRelative,LeftViewProjMatrix);
var rightPos = GetScreenPos(Camera.MonoOrStereoscopicEye.Right,comp,cam,isCameraRelative,RightViewProjMatrix);
cmd.SetGlobalVector(ShaderConstants._FlareData5,new Vector4(leftPos.x,leftPos.y,rightPos.x,rightPos.y));

Shader修改

然后是shader的修改
增加一行输入,并定义好宏

float4 _FlareData5; 
  
#define _LeftScreenPos          _FlareData5.xy
#define _RightScreenPos         _FlareData5.zw

vert中根据再unity提供的宏来判断一下

#if defined(USING_STEREO_MATRICES)
float2 _ScreenPos = unity_StereoEyeIndex == 1? _RightScreenPos : _LeftScreenPos;
# else
float2 _ScreenPos = _CameraScreenPos;
# endif

就完工了。

平行光点光源

这里其实还是有个小问题,这种效果其实不符合物理。
因为如果在左右眼分别放置一个相机,拍出来的炫光的偏移位置(#define _PositionTranslate _FlareData0.zw)其实应该是各不相同的。
但这会造成体验VR的人视觉混乱,所以为了体验效果,这里使用的偏移还是之前相机(也就是两眼中间位置)的偏移,只是修复了炫光原点的屏幕坐标位置。


LensFlare(SRP) in VR
https://www.kuanmi.top/2022/06/27/LensFlare-SRP-in-VR/
作者
KuanMi
发布于
2022年6月27日
许可协议