【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!
这是侑虎科技第2042篇文章,感谢作者毅毅供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:
https://www.zhihu.com/people/20-85-47-35
前言
湿润的地面,倒映着远方青山,什么样的技术才能造就如此绚丽的效果呢?
本文实现的效果:
使用法线贴图的效果
平滑地面
代码仓库:
GitHub: Screen Space Reflection in URP
https://github.com/MrZhou-art/ScreenSpaceFeflection
Unity版本:2022.3.60f1c1
URP版本:14.0.12
使用延迟渲染
反射一直是实时渲染中十分重要的部分,通常来说,我们觉得画面质感单调,缺少反光,多半是因为没有反射效果。可以看下图对比:
古墓丽影,来自SSR基础介绍[1]
作为实时渲染的一种廉价的反射效果,经常被用在游戏中:
看门狗
看门狗2
巫师3
鸣潮
前置理论基础
1. DDA
DDA算法全称为数字微分分析器算法(Digital Differential Analyzer),是计算机图形学中最基础、最直观的直线光栅化算法之一。
首先要明白在显示器上画线本质是画一系列的点,即每一个像素:
如果要光栅化一个3D线段, 应该考虑的就是如何选择XY步进方向, 即确定步进规则:
这段代码翻译过来就是:
先根据两个点的XY分量变化,确定使用一个主导方向。
步进时,在主导方向上进行加一,在另一个方向上进行光栅化判断。
下图展示了当3D线在光栅化后,横向长度大于竖直长度时(即 X1 - X0 > Y1 - Y0),Draw的规则:
同理 X1 - X0 < Y1 - Y0。
2. Ray Tracing概述
光线追踪(Ray Tracing)是计算机图形学中基于物理光学原理的渲染技术,核心思想是模拟光线在三维场景中的传播、反射、折射与吸收过程,计算光线与物体表面的交互,最终生成具有高度真实感的图像。
光线追踪的本质是逆向模拟光的传播路径 —— 由于直接模拟从光源出发的海量光线(多数不会进入相机)效率极低,实际采用的是“从相机/眼睛向场景发射光线去找光源”的逆向思路。
一个基础的光线追踪的核心代码如下:
// 输入:光线、场景、递归深度;输出:光线最终颜色
Vector3 whitted_ray_trace(Ray ray, Scene scene, int depth) {
// 1. 终止条件:递归深度达到上限(避免无限递归)
if (depth > MAX_RECURSION_DEPTH) {
return Vector3(0, 0, 0); // 超出深度,无颜色贡献
}
// 2. 光线与场景求交(用BVH加速,替代遍历所有物体)
HitInfo hit = scene.bvh_root.intersect(ray);
if (!hit.is_hit) {
return scene.background; // 未命中物体,返回背景色
}
// 3. 计算直接光照(光源对交点的直接贡献)
Vector3 direct_light = calculate_direct_light(hit, scene);
// 4. 计算间接光照(反射+折射光线的递归贡献)
Vector3 indirect_light = Vector3(0, 0, 0);
Material mat = hit.material;
// 4.1 生成反射光线(金属/漫反射物体均有反射)
Vector3 reflect_dir = reflect(ray.direction, hit.normal);
Ray reflect_ray = Ray(
hit.point, // 反射光线起点=交点(偏移微小距离避免自相交)
reflect_dir, // 反射方向(单位向量)
1e-4, // t_min=1e-4(避免与自身交点)
INF // t_max=无穷大
);
// 反射颜色贡献=材质反射系数*反射光线递归结果
Vector3 reflect_color = whitted_ray_trace(reflect_ray, scene, depth+1);
indirect_light = indirect_light.add(reflect_color.multiply(mat.albedo));
// 4.2 生成折射光线(仅透明物体如玻璃、水)
if (mat.is_transparent) {
Vector3 refract_dir;
// 判断光线是从外部进入物体(法向量反向)还是内部射出
bool is_inside = ray.direction.dot(hit.normal) > 0;
Vector3 normal = hit.normal;
float eta_i = 1.0; // 入射介质折射率(空气=1.0)
float eta_t = mat.refractive_idx; // 透射介质折射率
if (is_inside) {
normal = normal.multiply(-1); // 内部射出时,法向量反向
swap(eta_i, eta_t); // 交换折射率
}
// 斯涅尔定律计算折射方向(判断是否全反射)
if (refract(ray.direction, normal, eta_i/eta_t, refract_dir)) {
Ray refract_ray = Ray(
hit.point,
refract_dir,
1e-4,
INF
);
// 折射颜色贡献=材质透射系数*折射光线递归结果
Vector3 refract_color = whitted_ray_trace(refract_ray, scene, depth+1);
indirect_light = indirect_light.add(refract_color.multiply(mat.albedo));
} else {
// 全反射:无折射,仅反射(如水中看空气的全反射)
indirect_light = indirect_light.add(reflect_color.multiply(mat.albedo));
}
}// 5. 合并直接光照与间接光照(返回最终颜色)
return direct_light.add(indirect_light);
}
从代码中,我们可以知道,RayTracing做的事情是针对于全局的,依赖于整个场景信息,并且,求交的算法是针对于每个物体的每个表面(三角形)而言的,如果我们要在屏幕空间进行反射处理的话,首先遇到的难点就是采样什么,怎么求交。
我们怎么在X点拿到P点的信息
针对以上两个问题,我们的SSR给出了十分棒的解决方案。首先,既然是屏幕空间,那么能采样到的信息也必然是屏幕空间的信息,这就不难想到,在Fragment Shader中,针对每一个像素,我们可以在屏幕空间利用深度信息(_CameraDepthRenderTexture)计算出其对应的Shading Point的世界位置,提取法线信息(_CameraNormalRenderTexture),并计算反射后的向量,在反射向量的方向上,我们可以采用步进式采样(Ray Marching), 通过比较深度缓冲区中的深度图(_CameraDepthRenderTexture)与采样点的深度大小来判断光线是否命中场景表面,命中场景表面的颜色就从我们渲染好的场景图片(_CameraColorRenderTexture)中采样,最后叠加到原图像上。
来自Games 202
总的来说,SSR流程为:
反射光线生成:从当前像素对应的3D点出发,根据表面法向量和视角方向,计算反射光线的方向(遵循反射定律)。
屏幕空间投影:将反射光线的3D路径投影到2D屏幕空间,转化为屏幕像素坐标系下的线段。
步进采样与命中检测:沿屏幕空间线段步进采样,对比“反射光线在该像素处的3D深度”与“深度缓冲区存储的场景深度”,判断是否命中。
反射颜色提取与融合:若命中,从颜色缓冲区提取命中像素的颜色作为反射色;若未命中(如反射光线指向视口外),则使用环境贴图(Environment Map)等fallback方案,最终将反射色与原像素色按材质属性融合。
3. Ray Marching
既然我们在屏幕空间进行反射处理,那么必然就拿不到所有场景的几何信息,也就无法实现真正的Ray Tracing,好在Ray Marching给了我们不错的采样思路。
Ray Marching(光线步进)是计算机图形学中一种基于迭代采样的渲染技术,核心思想是沿光线传播方向“逐步推进”并采样场景,通过判断“当前采样点是否满足终止条件”(如命中物体、超出最大距离)来计算光线与场景的交互。
顾名思义,Ray Marching就是射线一步一步走的意思。从Shading Point开始,向我们反向向量一步一步进行采样计算,如图所示:
Games202 ScreenSpaceReflection 39分44秒[2]。
单从Ray Marching角度来说,我们需要考虑的就是步长和步进次数,如果步长越大,采样也就越不精准(容易越过交点的表面), 如果步长过小,那么就会导致过度采样(Over Sampling), 白白浪费性能,步进次数同理。
4. Efficient GPU DDA Solution
高效GPU DDA解决方案的论文中,详细介绍了具体怎么做Ray Marching,这里我们简练地说一下。
如果我们的步进是在世界空间进行的(即在世界空间中是均匀步进采样,如下图),那么会出现的两个严重问题:首先,如果我们的Shading Point距离相机远,则我们每次步进对应的其实就是一两个像素,可我们还得进行100来次的采样,这会导致严重的Over Sampling,如下图:
https://zhuanlan.zhihu.com/p/386510829
如果我们的Shading Point距离我们的相机过近,那么我们一个步长又会导致在屏幕空间中跨度太大,从而使精度大大降低,如下图:
https://zhuanlan.zhihu.com/p/386510829
那么我们如何采样才能最大程度利用起来屏幕信息呢?答案就是把我们的步进过程放到屏幕空间!
https://zhuanlan.zhihu.com/p/386510829
注意:屏幕空间进行Ray Marching丢了深度信息(我们步进到一个像素,我们不知道其步进采样点的深度),因此还需要单独保存在屏幕空间从H0变化到H1的z值(学过透视投影就知道不能用Q0到Q1进行线性插值,透视投影矩阵推导:Games101 MVP变换[3])。
为了找到步进采样点的深度,把视线空间的Q变换到裁剪空间,保留其w分量,通过透视除法转换到NDC空间中的V,这样我们就得到了与屏幕空间的成线性关系的坐标了:
之后,把步进采样屏幕空间的坐标转化为NDC空间的V,再反向投影到视线空间Q,此时就可以读取步进出来的深度了:
https://jcgt.org/published/0003/04/04/paper.pdf
知乎猫猫居士介绍此论文:
【论文复现】Efficient GPU Screen-Space Ray Tracing
https://zhuanlan.zhihu.com/p/386510829
SSR算法分析与实战
先来一个基本的SSR实现,我们可以用URP管线下的RenderFeature + Blit Shader,也可以使用MonoBehaviour(C#) + ComputeShader实现,本质都是利用GPU进行运算,本文使用URP管线的RenderFeature来实现SSR效果,对Computer Shader感兴趣的,可以简单看一下【Unity教程】小祥带你实现屏幕空间反射SSR_哔哩哔哩_bilibili[4]。
这里我使用延迟渲染管线进行渲染,我认为这种基于屏幕空间处理的,和延迟渲染十分搭配,所需要的数据只需要跑一遍pass就全都拿到了,成功把计算和数据读取解耦合,如果要新添加功能也十分容易。值得注意的是,GBuffer2的xyz储存的是世界空间法线,a通道储存的是光滑度信息。
1. 基本配置
创建Render Pipeline,创建Render Feature:
using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.Serialization;
namespaceScreenSpaceReflection.Render
{
publicclassSsrRenderFeature : ScriptableRendererFeature
{
[Serializable]
publicclassSsrSettings
{
[SerializeField] internalint StepCount = 200;
[SerializeField] internalfloat Thickness = 0.3f;
[SerializeField] internalfloat Stride = 0.1f;
[SerializeField] internalfloat RayOffset = 0.1f;
}
[SerializeField] private Shader m_SsrShader;
[SerializeField] private RenderPassEvent m_RenderPassEvent = RenderPassEvent.AfterRenderingOpaques;
[SerializeField] private SsrSettings m_Settings = new();
private Material m_SsrMaterial;
private SsrRenderPass m_SsrRenderPass;
public override void Create()
{
m_SsrRenderPass ??= new SsrRenderPass();
m_SsrRenderPass.renderPassEvent = m_RenderPassEvent;
if(!m_SsrShader)
m_SsrShader = Shader.Find("Hidden/Universal Render Pipeline/Blit");
m_SsrMaterial = CoreUtils.CreateEngineMaterial(m_SsrShader);
m_SsrRenderPass.Create(m_SsrMaterial);
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.EnqueuePass(m_SsrRenderPass);
}
public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
{
m_SsrRenderPass.Setup(m_Settings);
}// 一定要做好内存管理!!!!!
protected override void Dispose(bool disposing)
{
m_SsrRenderPass?.Dispose();
m_SsrRenderPass?.ClearUp();
m_SsrRenderPass = null;
}
}
}
创建C#,来个RenderPass:
using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
namespaceScreenSpaceReflection.Render
{
staticclassSsrShaderConstants
{
publicstaticreadonlystring SsrTexName = "_SsrTex";
publicstaticreadonlyint SsrTexID = Shader.PropertyToID(SsrTexName);
// x: step count, y: thickness, z: step size(Stride)
publicstaticreadonlyint SsrParametersID = Shader.PropertyToID("_SsrParameters");
}
publicclassSsrRenderPass : ScriptableRenderPass
{
privateconststring m_SsrProfilingTag = "SSRRenderPass";
private ProfilingSampler m_ProfilingSampler = new(m_SsrProfilingTag + "_Sampler");
private SsrRenderFeature.SsrSettings m_Settings;
private Material m_SsrMaterial;
private RTHandle m_SourceRT;
private RTHandle m_DestinationRT;
private RTHandle m_SsrRT;
private RenderTextureDescriptor m_Descriptor;
public void Create(Material ssrMaterial)
{
m_SsrMaterial = ssrMaterial;
m_SsrRT = RTHandles.Alloc(SsrShaderConstants.SsrTexID, name: SsrShaderConstants.SsrTexName);
}
public void Setup(SsrRenderFeature.SsrSettings settings)
{
m_Settings = settings;
}
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
m_SourceRT = renderingData.cameraData.renderer.cameraColorTargetHandle;
m_DestinationRT = renderingData.cameraData.renderer.cameraColorTargetHandle;
m_Descriptor = renderingData.cameraData.cameraTargetDescriptor;
m_Descriptor.useMipMap = false;
m_Descriptor.autoGenerateMips = false;
m_SsrMaterial.SetVector(SsrShaderConstants.SsrParametersID,
new Vector4(m_Settings.StepCount, m_Settings.Thickness / 100,
m_Settings.Stride / 100000, m_Settings.RayOffset));
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
CommandBuffer cmd = CommandBufferPool.Get(m_SsrProfilingTag + "_CommandBuffer");
using (new ProfilingScope(cmd, m_ProfilingSampler))
{
var tempDesc = GetCompatibleDescriptor(m_Descriptor.width, m_Descriptor.height,
GraphicsFormat.B10G11R11_UFloatPack32);
RenderingUtils.ReAllocateIfNeeded(ref m_SsrRT, tempDesc, FilterMode.Bilinear,
TextureWrapMode.Clamp,
name: m_SsrRT.name);
Blitter.BlitCameraTexture(cmd, m_SourceRT, m_SsrRT, RenderBufferLoadAction.DontCare,
RenderBufferStoreAction.Store, m_SsrMaterial, 0);
Blitter.BlitCameraTexture(cmd, m_SsrRT, m_DestinationRT);
}
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
CommandBufferPool.Release(cmd);
}
public override void OnCameraCleanup(CommandBuffer cmd)
{
m_SourceRT = null;
m_DestinationRT = null;
}
// 一定要做好内存管理!!!!!
public void Dispose()
{
m_SsrRT?.Release();
}
public void ClearUp()
{
CoreUtils.Destroy(m_SsrMaterial);
}
RenderTextureDescriptor GetCompatibleDescriptor(int width, int height, GraphicsFormat format,
DepthBits depthBufferBits = DepthBits.None)
=> GetCompatibleDescriptor(m_Descriptor, width, height, format, depthBufferBits);private static RenderTextureDescriptor GetCompatibleDescriptor(RenderTextureDescriptor desc, int width,
int height, GraphicsFormat format, DepthBits depthBufferBits = DepthBits.None)
{
desc.depthBufferBits = (int)depthBufferBits;
desc.msaaSamples = 1;
desc.width = width;
desc.height = height;
desc.graphicsFormat = format;
return desc;
}
}
}
基本的Blit:
Shader "Hidden/SsrShader"
{
SubShader
{
Tags { "RenderType" = "Opaque""RenderPipeline" = "UniversalPipeline"}
LOD 100
Pass
{
Name "SsrPass"
ZTest Always
ZWrite Off
Cull Off
HLSLPROGRAM
#pragma vertex Vert
#pragma fragment Fragment
#pragma multi_compile_fragment _ _LINEAR_TO_SRGB_CONVERSION
#pragma multi_compile_fragment _ DEBUG_DISPLAY
// Core.hlsl for XR dependencies
"Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
"Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
"Packages/com.unity.render-pipelines.universal/ShaderLibrary/Debug/DebuggingFullscreen.hlsl"
"Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
SAMPLER(sampler_BlitTexture);
half4 Fragment(Varyings input) : SV_Target
{
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
float2 uv = input.texcoord;
half4 col = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_BlitTexture, uv);
_LINEAR_TO_SRGB_CONVERSION
col = LinearToSRGB(col);
#endif
#if defined(DEBUG_DISPLAY)
half4 debugColor = 0;
if(CanDebugOverrideOutputColor(col, uv, debugColor))
{
return debugColor;
}
#endifreturn col;
}
ENDHLSL
}
}
}
运行之后看到这两个Draw Procedural,就是成功了!
视图空间Ray Marching
Shader:
// Core.hlsl for XR dependencies
"Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
........
// -- -- -- -- -- -- Functions -- -- -- -- -- -
// 函数都放在这里(看下文)
"Assets/ScreenSpaceReflection/Shaders/ShaderLib/SsrFunctions.hlsl"
// -- -- -- -- -- -- Uniform Variable -- -- -- -- -- -
SAMPLER(sampler_BlitTexture);
// x : step count, y : thickness, z : step size w : ray offset
float4 _SsrParameters;
#define RAY_OFFSET _SsrParameters.w
#define STEP_COUNT _SsrParameters.x
#define THICKNESS _SsrParameters.y
#define STEP_SIZE _SsrParameters.z
// -- -- -- -- -- -- Shader Stage -- -- -- -- --
half4 SsrFrag(Varyings input) : SV_Target
{
// -- -- -- Base Data -- -- -- --
float2 uv = input.texcoord;
// rawDepth 记录NDC空间的Z信息 [0, 1]
float rawDepth = SampleSceneDepth(uv);
// linearDepth 为视线空间的Z坐标绝对值
// float linear01Depth = Linear01Depth(rawDepth, _ZBufferParams);
float3 normalWS = 0;
float smoothness = 0;
GetNormalAndSmoothness(uv, normalWS, smoothness);
float3 normalVS = TransformWorldToViewDir(normalWS);
float3 posWS = GetWorldSpacePosition(uv, rawDepth);
float3 viewDirWS = normalize(_WorldSpaceCameraPos - posWS);
float3 viewDirVS = TransformWorldToViewDir(viewDirWS);
float3 reflDirWS = normalize(- reflect(viewDirWS, normalWS));
float3 posVS = TransformWorldToView(posWS);
// 存在自反射,需要加一定的偏移
float3 rayVS = posVS + normalVS * RAY_OFFSET;
// -- -- -- Shading -- -- -- -- --
// 视图空间RayMarching
UNITY_LOOP
for (int i = 0; i < STEP_COUNT; i ++)
{
rayVS += reflDirVS * STEP_SIZE * i;
float2 currRayUV;
ReconstructUV(rayVS, currRayUV);
float currSampleDepth = SampleSceneDepth(currRayUV).x;
// 重构出相机空间的Z坐标(绝对值)进行比较,方便调参
float currSampleZ = LinearEyeDepth(currSampleDepth, _ZBufferParams);
float currRayZ = - rayVS.z;
float deltaZ = currRayZ - currSampleZ;
if (deltaZ >= 0 && deltaZ < THICKNESS)
{
// for debug:直接获取到反射的信息
// return GetSource(currRayUV);
return GetSource(currRayUV) * smoothness + GetSource(uv);
}
}// for debug:未击中的区域为黑色
// return half4(0.0, 0.0, 0.0, 1.0);
return GetSource(uv);
}
Shader Functions:
// 获取Blit Texture
half4 GetSource(float2 uv)
{
return SAMPLE_TEXTURE2D_X_LOD(_BlitTexture, sampler_LinearRepeat, uv, _BlitMipLevel);
}
// 获取normal
float3 GetNormal(float2 uv)
{
return SAMPLE_TEXTURE2D_X(_CameraNormalsTexture, sampler_CameraNormalsTexture, uv).xyz;
}
// 在延迟渲染管线的Normal Buffer中,a通道输出的是平滑度
// 获取世界空间的法线和平滑度
void GetNormalAndSmoothness(float2 uv, out float3 normal, out float smoothness)
{
float4 normalAndSmoothness = SAMPLE_TEXTURE2D_X(_CameraNormalsTexture, sampler_CameraNormalsTexture, uv);
normal = normalize(normalAndSmoothness.xyz);// 在normalize时, 注意不要有w分量
smoothness = normalAndSmoothness.w;
}
// 反投影重建世界坐标
float3 GetWorldSpacePosition(float2 uv, half depth)
{
return ComputeWorldSpacePosition(uv.xy, depth, UNITY_MATRIX_I_VP);
}
// ---------- temp ---------------
// 从视图空间坐标重构屏幕空间的片元uv和深度
void ReconstructUVAndDepth(float3 posWS, out float2 uv, out float depth)
{
half4 posCS = TransformWViewToHClip(posWS);
// posCS.w = -posVS.z
half4 posNDC = posCS / posCS.w;
depth = posNDC.z;
uv = float2(posCS.x, posCS.y * _ProjectionParams.x) / posCS.w * 0.5 + 0.5;
}// 从视图空间坐标重构屏幕空间的片元uv和深度
void ReconstructUV(float3 posWS, out float2 uv)
{
half4 posCS = TransformWViewToHClip(posWS);
uv = float2(posCS.x, posCS.y * _ProjectionParams.x) / posCS.w * 0.5 + 0.5;
}
参数:
为了调的时候方便,我将参数进行了缩放:
m_SsrMaterial.SetVector(SsrShaderConstants.SsrParametersID,
new Vector4(m_Settings.StepCount, m_Settings.Thickness / 100,
m_Settings.Stride / 100000, m_Settings.RayOffset));
参数需要谨慎调试!
效果:
Debug
还算不错
1. 问题解析
问题1:相机向下看时,出现相同颜色的拉丝现象:
原因其实是我们这一部分已经步进到屏幕外了,采样的点自然也是屏幕外的CameraColor,又由于我们的Warp模式为Clamp,所以采样出来的东西会显示一大长条相同颜色的拉丝,这只需要在我们步进时,检查uv是否到屏幕外就能解决这个问题:
UNITY_LOOP
for (int i = 0; i < STEP_COUNT; i++)
{
.........
ReconstructUV(rayVS, currRayUV);
if (any(currRayUV < 0.0) || any(currRayUV > 1.0))
// for debug:超出屏幕外的返回黑色
return half4(0.0, 0.0, 0.0, 1.0);
// return GetSource(uv);float currSampleDepth = SampleSceneDepth(currRayUV).x;
.........
}
问题2:分级拉丝
这个问题还是比较难以理解的,简单来说就是Stride(步长)太大,高频的细节对不齐,无法完全精确捕捉到像素级别的信息,而我的解决办法就是直接拉小步长,同时提高Step Count:
效果:
高频细节出来了
性能?
2. 性能分析
参数:
这是我后期改的参数,往下看文章就明白了
我用Profiler大体测了一下执行时间,基本上都是0.58ms~0.61ms。
屏幕空间Ray Marching
视图空间步进还是太浪费了,想要高频细节必须要把控很小的Stride,导致很多步骤都是没用的,也就是Over Sample,溯其本源,我们是在屏幕空间做Ray Marching,那么就最大程度地利用好屏幕信息。
先上一段核心代码(不必细抠细节,先看个大概过程),我想重点讲解几个细节:
// 视图空间 ---> 屏幕空间 (默认是点)
float2 TransformViewToScreen(float3 posVS, float2 screenSize,out float InvW)
{
float4 posCS = TransformWViewToHClip(posVS);
InvW = 1 / posCS.w;
float2 uv = float2(posCS.x, posCS.y * _ProjectionParams.x) / posCS.w * 0.5 + 0.5;
return uv * screenSize;
}
// 屏幕空间RayMarching
float3 ScreenSpaceRayMarching(float2 uv, float3 posVS, float3 reflDirVS, float3 normalVS, float smoothness,
float stepCount, float thickness, float stride, float rayOffset, float maxDistance)
{
// 视图空间中射线起始位置和结束位置
float3 startVS = posVS;
float3 endVS = posVS + reflDirVS * maxDistance;
// inverse w
// w = -Vz,为了后面方便在屏幕空间插值深度
float startInvW = 0;
float endInvW = 0;
// 屏幕空间中射线起始位置和结束位置
// 其实 startSS = uv * _ScreenSize.xy ([0, 1]^2 -> [0, w-1],[0, h-1])
// 但是为了拿到裁剪空间的w分量, 我们必须使用自定义的TransformViewToScreen函数
float2 startSS = TransformViewToScreen(startVS, _ScreenSize.xy, startInvW);
float2 endSS = TransformViewToScreen(endVS, _ScreenSize.xy, endInvW);
// DDA算法
float2 deltaSS = endSS - startSS; // 注意方向性!
bool isVertical = false;
// 我们默认的是以横轴为主导向,如果纵轴为主导向,则进行反转,并在之后进行复原
if (abs(deltaSS.x) < abs(deltaSS.y))
{
isVertical = true;
deltaSS = deltaSS.yx;
startSS = startSS.yx;
endSS = endSS.yx;
}
// 计算屏幕坐标、inverse-w的线性增量
float dir = sign(deltaSS.x); // 屏幕空间增量方向
float invdx = dir / deltaSS.x; // 1 / 屏幕空间增量 (绝对值)
float2 dPosSS = float2(dir, invdx * deltaSS.y);
float dInvW = (endInvW - startInvW) * invdx;
// 步长的调整
dPosSS *= stride;
dInvW *= stride;
// 存在自反射,需要加一定的偏移
float3 rayVS = posVS + normalVS * rayOffset;
float preZ = rayVS.z;// 作为初始 Z 分量和缓存的作用
// 起始数据
float2 currPosSS = startSS;
float currInvW = startInvW;
// RayMarching
UNITY_LOOP
for (int i = 0; i < stepCount && currPosSS.x * dir <= endSS.x * dir; i++)
{
// 缓存前后两步视图空间的Z分量(防止跨像素太大,判断不出相交)
float rayNearZ = preZ;
float rayFarZ = -1 / (dInvW * 0.5 + currInvW);// 初始做一次半步长迈进
preZ = rayFarZ;
// 视图空间中相机朝向-Z方向
if (rayNearZ < rayFarZ)
Swap(rayNearZ, rayFarZ);
// 步近
currPosSS += dPosSS;
currInvW += dInvW;
// 得到交点uv
float2 hitUV = isVertical ? currPosSS.yx : currPosSS; // 复原
hitUV *= _ScreenSize.zw;
if (any(hitUV < 0.0) || any(hitUV > 1.0))
break;
// 相交判断
float surfaceZ = -LinearEyeDepth(SampleSceneDepth(hitUV), _ZBufferParams);
bool intersecting = (rayFarZ <= surfaceZ) && (rayNearZ >= surfaceZ - thickness);
if (intersecting)
// return GetSource(hitUV);
return GetSource(hitUV) * smoothness + GetSource(uv);
}
// return half4(0, 0, 0, 1);
return GetSource(uv);
}
// -- -- -- -- -- -- Shader Stage -- -- -- -- --
half4 SsrFrag(Varyings input) : SV_Target
{
.......
float3 posVS = TransformWorldToView(posWS);
float3 finalColor = ScreenSpaceRayMarching(uv, posVS, reflDirVS, normalVS, smoothness,
STEP_COUNT, THICKNESS, STRIDE, RAY_OFFSET, MAX_DISTANCE);return half4(finalColor, 1);
}
参数:
效果:
Debug
效果达标!
1. 坐标系转换
首先,防止你忘记不同空间的坐标变换,我让豆老师跑出一份Unity中的坐标转换关系:
视图空间与裁剪空间的转换
裁剪空间中的w分量实质上就是视线空间的z分量的相反数(小知识:如果我们把视线空间的xy分量除以裁剪空间中的w分量,得到的坐标中xy分量一定与NDC坐标成线性关系!)。
NDC空间与视图空间的转换
视线空间的z分量与NDC空间中的z分量不成线性关系,但是视线空间中的1/z是与NDC空间中的z分量成线性关系的!
屏幕空间与NDC空间的转换
从上面几个式子中可以看出来,屏幕空间z分量和视图空间1/z分量成线性关系(Unity中,经过变换之后的屏幕空间坐标的原点是在左上角,故我们应该对其进行反转),这也就是为什么我在屏幕空间插值的是1/z,而不是z。我们使用1/z可以轻松复原视图空间的z分量。
2. 重点细节讲解
1)为什么屏幕空间要用[0, w-1],[0, h-1]?
其实我开始看别人代码的时候想的是为什么不用[0, 1]范围的uv坐标,如果这样也方便采样,不用重新反向映射一次,直到我看到DDA算法要算1/屏幕空间增量(float invdx = dir / deltaSS.x;),我才反应过来。实际上我们横纵范围不能看作一样的,我们屏幕分辨率是不同的(全高清1920×1080,四倍高清2560×1440 ...),想要步进屏幕空间,必须精确到像素级别!
2)为什么循环中做一次初始半步长迈进?
从我们的相交判断条件来看:
判断rayFarZ这个较远点是否被表面遮挡;
判断rayNearZ是否在一定阈值(Thickness)内。
只有两个都满足时,才能说明相交成功,进行采样。那么我们先迈进半步长,之后的判断都在Reflection Dir方向的半步长处进行步进,如下图:
这样是十分舒服的判断,既可以防止首次的判断失误,又确保后面的判断正确!
3)为什么要这个Thickness这个阈值?
其实就是增大容错率,如下图:
3. 问题解析
1)相机向下看时,会有一个自反射圆圈,超出MaxDistance之后会消失。
这个问题我仔细分析了一下,原因是在视图空间中,我们反射出来的最终位置,可能超出近裁面了,我们如果把这个点映射到屏幕空间就会出错(仔细思考MVP变换,如果有个点在你相机后面(不在视锥体内),变换之后的位置必然不在投影盒中,那么映射到屏幕空间必然会出错),所以我们必须要对这个最终反射点进行一次近裁剪面的判断,从而调整反射点的最终位置,如下:
// 视图空间中射线起始位置和结束位置
float3 startVS = posVS;
float3 endVS = posVS + reflDirVS * maxDistance;
if (endVS.z > -_ProjectionParams.y)
maxDistance = (-_ProjectionParams.y - startVS.z) / reflDirVS.z;
endVS = startVS + reflDirVS * maxDistance;// inverse w
......
2)屏幕信息缺失
很好理解,毕竟我们在屏幕空间进行反射,那么必定会缺失信息,如下图:
根据Games 202,闫老师给的思路,在屏幕空间进行一次衰减,不同的是,闫老师的思路是利用反射距离进行衰减,我的思路是利用反射点的uv进行衰减。
那么实现步骤也是十分简单的,先在Render Pass传入一个控制衰减的参数attenuation,在Shader中,我使用暗角后处理的控制方法,如下:
if (intersecting)
{
float2 attUV = abs(hitUV - 0.5) * 1.8;
attUV = pow(saturate(attUV), attenuation);
float att = length(attUV);
float edgeFading= pow(saturate(1 - att * att), 5);return GetSource(hitUV) * smoothness * edgeFading + GetSource(uv);
}
这个效果简单来说就是做一次屏幕边缘的衰减,我的想法就是,如果我们hitUV打到了屏幕边缘,那么就肯定给一个小的权重,若打到屏幕中心,那么肯定要给一个大点的权重,同时需要考虑到屏幕长宽的影响,综合考虑之后,我决定使用暗角作为权重。
效果:
效果理想!
在龙爪处我们还是可以看到屏幕空间缺失的问题,这个缺失就是难以Trick的了,如下图:
这一部分可以结合光线追踪,补全我们的信息。
很多材质并非完全镜面反射,我们就可以使用多根光线,结合BRDF和重要性采样实现Glossy的效果。
https://www.bilibili.com/video/BV1YK4y1T7yY?spm_id_from=333.788.videopod.episodes&vd_source=cc304228c1c7e07b176b9fbcfa3ee569&p=9
关于Glossy效果,我觉得可以使用IBL的思路,将CameraColorRenderTexture进行MipMap技术分级,将射线距离(或者屏幕空间距离)作为Level , 使用三线性插值进行采样试试。
4. 性能分析
参数:
静态基本上是0.56ms~0.60ms(这一部分我可能做的不是很好,优化不明显)。
文章有点长,还有一部分(二分搜索,Hierarchical Z Buffer)放到下一期。
参考:
[1] 古墓丽影,来自SSR基础介绍
https://www.bilibili.com/video/BV1JW411i7Cx/?spm_id_from=333.337.search-card.all.click&vd_source=cc304228c1c7e07b176b9fbcfa3ee569
[2] Games202 ScreenSpaceReflection 39分44秒
https://www.bilibili.com/video/BV1YK4y1T7yY/?spm_id_from=333.788.videopod.episodes&vd_source=cc304228c1c7e07b176b9fbcfa3ee569&p=9
[3] Games101 MVP变换
https://www.bilibili.com/video/BV1X7411F744/?spm_id_from=333.788.videopod.episodes&vd_source=cc304228c1c7e07b176b9fbcfa3ee569&p=4
[4] 【Unity教程】小祥带你实现屏幕空间反射SSR_哔哩哔哩_bilibili
https://www.bilibili.com/video/BV161evzMEe1/?spm_id_from=333.880.my_history.page.click&vd_source=cc304228c1c7e07b176b9fbcfa3ee569
文末,再次感谢毅毅的分享, 作者主页:https://www.zhihu.com/people/20-85-47-35, 如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群: 793972859 )。
近期精彩回顾
热门跟贴