屏幕空间反射
屏幕空间反射也是一个老生常谈的效果了,但正如本博客的宗旨,要从千篇一律中脱颖而出,这篇文章也将介绍与众不同的,至少我在网上没有见到过的计算屏幕空间反射的方法。
网上有很多很多的屏幕空间反射的教程,绝大部分的流程是这样的:计算世界空间的反射方向,使用一个大部分情况下是统一的步长在世界空间中步进,对于每一次步进,计算标准化设备空间的坐标,将当前的深度和深度图进行比较,如果在深度图之后,认为发生了交叉,采样当前点的颜色值并返回。这种方法能看到很多很多看上去非常完美的反射效果,但几乎没有人会提及所需要的步进次数,因为它往往高得惊人,关于这点我们后续还会谈到。而且对于不同远近的物体,想要达到比较好反射效果,其需要的步长往往是不同的,也很少有人去做这方面的思考。稍好一些的会考虑在交叉之后做几次二分法查找,这样能够让一段一段的反射后的颜色带上下颠倒,使画面看上去更加连贯,后面也能看到对比。还有一些会考虑在计算标准化设备空间的坐标后,根据坐标和[-1, 1]之间的大小关系,提前结束步进或是对反射的颜色和环境反射进行插值。目前看来最好的步进方法,是预先计算Hierarchical ZBuffer,通过对更高LOD步进的方法,使用更少的步进次数达到同样的步进效果,但是Hierarchical ZBuffer并不是一个所有项目都能有的特性。
网上能找到的最有用的教程,是Morgan McGuire写的Screen Space Ray Tracing。在他的这篇文章中也提到了为什么在世界空间中步进是不好的,因为世界空间步进的位置在经过透视变换后,很有可能在屏幕空间中没多大变化,也就导致了世界空间步进需要更多的次数来达到较好的反射效果。在这篇文章中展示了一个非常好的方法,计算裁剪空间和屏幕空间的起点和终点的坐标,通过对裁剪空间的z、裁剪空间的1/w、屏幕空间的xy进行线性插值,省去了每一次步进所需要的矩阵运算,十分值得使用。
本文的目标是,在一个Shader中使用尽量少的步进次数得到正确的反射颜色。随机采样、模糊、菲涅尔效应之类的不在本文的考虑范围之内。本文仅考虑Windows平台下DX11的Shader,这样能省去很多的平台适配的代码,使用的Unity版本是Unity 2022.3.21f1,在文章的最后会附上最终的Shader代码。
反射的计算
参数的选择
计算反射基本上只需要三个参数,一个是Max Distance
,只考虑距离反射点一定范围内的物体带来的反射,一个是Step Count
,更多的步进次数带来更精确的反射,同时也增加性能消耗,最后一个是Thickness Params
,对于一个物体,默认其厚度为depth * _Thickness.y + _Thickness.x
,这样当射线经过物体背面时不会被认为发生了交叉。
深度比较
步进的时候比较什么深度也是一个值得思考的问题。将步进的深度记为rayDepth
,将采样获得的深度记为sampleDepth
,一个很简单的想法在标准化设备空间进行比较,因为直接采样深度图就能获取到标准化设备空间的深度值,当rayDepth < sampleDepth
的时候,射线和场景发生了交叉。又或是对实际的深度进行比较,这样能够指定一个厚度,当深度的差大于厚度时,认为射线从场景物体的后面穿了过去并没有发生交叉,当rayDepth > sampleDepth && rayDepth < sampleDepth + thickness
的时候,射线和场景发生了交叉。此外裁剪空间的Z分量也能用来判断是否发生了交叉,这里不再赘述。深度图的采样方式则应该使用PointClamp
的方式,使用线性插值的话在一前一后的两个面的边缘很可能会被认为发生了交叉,导致画面上有不少的小点,除非另外有一张标记物体边缘的贴图可以用来排除掉这部分的交叉点。
光线步进
伪代码很简单:
- 记
k0
、k1
分别是步进起点和终点的裁剪空间坐标的w分量的倒数。- 记
q0
、q1
分别是步进起点和终点的裁剪空间坐标的xyz分量。- 记
p0
、p1
分别是步进起点和终点的标准化设备空间坐标的xy分量。- 记
w
是一个在(0, 1)之间按照1.0f/_StepCount
递增的变量。- 对每一次步进,更新
w
的值,并对上面的三组分量线性插值得到k
、q
、p
。- 使用
q.z * k
获得rayDepth
,使用p
采样深度图获得sampleDepth
。- 如果
rayDepth < sampleDepth
,射线和场景发生了交叉,跳出循环,返回p
。- 使用
p
采样颜色图,获得反射的颜色。
效果是这样的(步进次数为32次):
看上去非常糟糕,最明显的是拉扯的效果。它主要有两个产生的原因:一是我们并没有使用厚度来判断射线是否从物体的背面穿过,这导致了悬空的物体下方会有很长的拉扯;二是我们并没有对超出屏幕范围的位置进行限制,这导致了我们使用屏幕外的坐标采样深度图但返回了Clamp之后的深度值。
厚度检测
为了解决上面的厚度问题,我们新增了一个方法由于判断步进的位置是否在物体后面。我们需要使用的是距离相机的线性深度linearRayDepth
和linearSampleDepth
。上文说到我们使用linearSampleDepth * _Thickness.y + _Thickness.x
来作为一个场景中一个物体的厚度,我们只需要判断(linearRayDepth-linearSampleDepth-_Thickness.x) / linearSampleDepth
和_Thickness.y
的大小即可,如果前式大于后式,则表明射线从物体后面穿过。
float getThicknessDiff(float diff, float linearSampleDepth, float2 thicknessParams)
{
return (diff - thicknessParams.x) / linearSampleDepth;
}
伪代码变成了:
- 如果
rayZ < sampleZ
且thicknessDiff < _Thickness.y
,射线和场景发生了交叉,跳出循环,返回p
。
效果是这样的(步进次数为32次):
视锥体裁剪
对于超出屏幕空间的p1
,会带来两个坏处,一是采样了超出范围的深度图得到了错误的深度值,二是减少了有效采样的次数。因此我们可以考虑将p1
限制在屏幕空间内,这里新增了一个方法用于将步进终点限制在视锥体内部。我们记nf
为近裁剪面深度和远裁剪面深度值(正数),s
为视锥体的左右和上下的斜率(正数),s
在数值上为float2(asepect * tan(fovy * 0.5f), tan(fovy * 0.5f)
,注意为了方便计算,这里from
和to
的z分量为正数。下面的算法应该还能优化一些,不过已经够用了。
事实上我还写了一个Shadertoy用来演示,使用鼠标进行交互:
#define INFINITY 1e10
float3 frustumClip(float3 from, float3 to, float2 nf, float2 s)
{
float3 dir = to - from;
float3 signDir = sign(dir);
float nfSlab = signDir.z * (nf.y - nf.x) * 0.5f + (nf.y + nf.x) * 0.5f;
float lenZ = (nfSlab - from.z) / dir.z;
if (dir.z == 0.0f) lenZ = INFINITY;
float2 ss = sign(dir.xy - s * dir.z) * s;
float2 denom = ss * dir.z - dir.xy;
float2 lenXY = (from.xy - ss * from.z) / denom;
if (lenXY.x < 0.0f || denom.x == 0.0f) lenXY.x = INFINITY;
if (lenXY.y < 0.0f || denom.y == 0.0f) lenXY.y = INFINITY;
float len = min(min(1.0f, lenZ), min(lenXY.x, lenXY.y));
float3 clippedVS = from + dir * len;
return clippedVS;
}
伪代码变成了:
- 将步进终点进行视锥体裁剪得到
clippedPosVS
,再进一步得到终点的裁剪空间坐标endCS
。
效果是这样的(步进次数为32次):
看上去有那么点反射的意思了,视锥体剔除一定程度地减少了每次步进的像素数,因此补上了一部分的窟窿。但是反射的颜色扭扭曲曲的。
二分法查找
我们上一步获得的p
虽然确保了在物体内部,但距离实际的交点仍有一部分的距离,我们可以通过二分法查找减少两者之间的误差。为了进行二分法查找,我们需要记录最后两次步进的w
值,记为w1
和w2
(w1 > w2
)。每次二分法时,取w = 0.5f * (w1 + w2)
,如果检测到相交,则w1 = w
,否则w2 = w
,进入下一个循环。
伪代码变成了:
- 将步进终点进行视锥体裁剪得到
clippedPosVS
,再进一步得到终点的裁剪空间坐标endCS
。- 记
k0
、k1
分别是步进起点和终点的裁剪空间坐标的w分量的倒数。- 记
q0
、q1
分别是步进起点和终点的裁剪空间坐标的xyz分量。- 记
p0
、p1
分别是步进起点和终点的标准化设备空间坐标的xy分量。- 记
w1
是一个在(0, 1)之间按照1.0f/_StepCount
递增的变量,w1
和w2
初始化为0。- 对每一次步进,
w2=w1
,更新w1
的值,并对上面的三组分量线性插值得到k
、q
、p
。- 使用
q.z * k
获得rayDepth
,使用p
采样深度图获得sampleDepth
。- 如果
rayZ < sampleZ
且thicknessDiff < _Thickness.y
,射线和场景发生了交叉,跳出循环。- 记
w
为w1
和w2
的平均数,按照567判断是否发生交叉,根据是否交叉更新w1
或w2
,直到结束二分法循环。- 使用
p
采样颜色图,获得反射的颜色。
效果是这样的(步进次数为32次,二分法查找次数为5次):
可以看到反射的效果看上去不那么扭扭曲曲的了(左下角尤为明显),但是两段颜色之间仍然有着空隙,这来自于我们的厚度测试,它将潜在的交叉排除在外了。
潜在的交叉
为了计算潜在的交叉,我们需要回顾之前做厚度测试的代码。当射线穿过物体后面时,如果前一次射线还在物体前方,我们可以记录两者之间的差距thicknessDiff
,如果它的值小于最小的差距minThicknessDiff
,我们将其作为潜在的交叉,更新minThicknessDiff
,并记录当前的w1
和w2
用于后续的二分法查找。在二分法时我们需要判断发生了交叉还是发生了潜在的交叉,如果发生了交叉,我们执行原有的代码,如果发生的是潜在的交叉,在二分法时我们也需要记录thicknessDiff
,找到最小的小于_Thickness.y
的thicknessDiff
,使用当前w
插值得到的p
采样获得最终的颜色。
伪代码变成了:
- 将步进终点进行视锥体裁剪得到
clippedPosVS
,再进一步得到终点的裁剪空间坐标endCS
。- 记
k0
、k1
分别是步进起点和终点的裁剪空间坐标的w分量的倒数。- 记
q0
、q1
分别是步进起点和终点的裁剪空间坐标的xyz分量。- 记
p0
、p1
分别是步进起点和终点的标准化设备空间坐标的xy分量。- 记
w1
是一个在(0, 1)之间按照1.0f/_StepCount
递增的变量,w1
和w2
初始化为0。- 对每一次步进,
w2=w1
,更新w1
的值,并对上面的三组分量线性插值得到k
、q
、p
。- 使用
q.z * k
获得rayDepth
,使用p
采样深度图获得sampleDepth
。- 如果
rayZ < sampleZ
且thicknessDiff < _Thickness.y
,射线和场景发生了交叉,跳出循环。- 否则如果
rayZ < sampleZ
且thicknessDiff > _Thickness.y
且上一次射线在物体前方,将thicknessDiff
和最小值进行比较,如果更小则更新最小值,记录此时的w1
和w2
,记为发生了潜在的交叉,继续循环。- 如果发生了交叉,记
w
为w1
和w2
的平均数,按照567判断是否发生交叉,根据是否交叉更新w1
或w2
,直到结束二分法循环。- 否则如果发生了潜在交叉,按照567判断是否发生交叉,使用最小的
thicknessDiff
更新p
。- 使用
p
采样颜色图,获得反射的颜色。
效果是这样的(步进次数为32次,二分法查找次数为5次): 下图是步进次数为64次,二分法查找5次的效果:
最终的代码
/*
// Copyright (c) 2024 zznewclear@gmail.com
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
*/
Shader "zznewclear13/SSRShader"
{
Properties
{
[Toggle(USE_POTENTIAL_HIT)] _UsePotentialHit ("Use Potential Hit", Float) = 1.0
[Toggle(USE_FRUSTUM_CLIP)] _UseFrustumClip ("Use Frustum Clip", Float) = 1.0
[Toggle(USE_BINARY_SEARCH)] _UseBinarySearch ("Use Binary Search", Float) = 1.0
[Toggle(USE_THICKNESS)] _UseThickness ("Use Thickness", Float) = 1.0
_MaxDistance ("Max Distance", Range(0.1, 100.0)) = 15.0
[int] _StepCount ("Step Count", Float) = 32
_ThicknessParams ("Thickness Params", Vector) = (0.1, 0.02, 0.0, 0.0)
}
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#pragma shader_feature _ USE_POTENTIAL_HIT
#pragma shader_feature _ USE_FRUSTUM_CLIP
#pragma shader_feature _ USE_BINARY_SEARCH
#pragma shader_feature _ USE_THICKNESS
#define INFINITY 1e10
#define DEPTH_SAMPLER sampler_PointClamp
Texture2D _CameraOpaqueTexture;
Texture2D _CameraDepthTexture;
CBUFFER_START(UnityPerMaterial)
float _MaxDistance;
int _StepCount;
float2 _ThicknessParams;
CBUFFER_END
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 texcoord : TEXCOORD0;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float3 positionWS : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float2 uv : TEXCOORD2;
float3 viewWS : TEXCOORD3;
};
Varyings vert(Attributes input)
{
Varyings output = (Varyings)0;
VertexPositionInputs vpi = GetVertexPositionInputs(input.positionOS.xyz);
VertexNormalInputs vni = GetVertexNormalInputs(input.normalOS);
output.positionCS = vpi.positionCS;
output.positionWS = vpi.positionWS;
output.normalWS = vni.normalWS;
output.uv = input.texcoord;
output.viewWS = GetCameraPositionWS() - vpi.positionWS;
return output;
}
float3 frustumClip(float3 from, float3 to, float2 nf, float2 s)
{
float3 dir = to - from;
float3 signDir = sign(dir);
float nfSlab = signDir.z * (nf.y - nf.x) * 0.5f + (nf.y + nf.x) * 0.5f;
float lenZ = (nfSlab - from.z) / dir.z;
if (dir.z == 0.0f) lenZ = INFINITY;
float2 ss = sign(dir.xy - s * dir.z) * s;
float2 denom = ss * dir.z - dir.xy;
float2 lenXY = (from.xy - ss * from.z) / denom;
if (lenXY.x < 0.0f || denom.x == 0.0f) lenXY.x = INFINITY;
if (lenXY.y < 0.0f || denom.y == 0.0f) lenXY.y = INFINITY;
float len = min(min(1.0f, lenZ), min(lenXY.x, lenXY.y));
float3 clippedVS = from + dir * len;
return clippedVS;
}
float getThicknessDiff(float diff, float linearSampleDepth, float2 thicknessParams)
{
return (diff - thicknessParams.x) / linearSampleDepth;
}
float4 frag(Varyings input) : SV_TARGET
{
float3 positionWS = input.positionWS;
float3 normalWS = normalize(input.normalWS);
float3 viewWS = normalize(input.viewWS);
float3 reflWS = reflect(-viewWS, normalWS);
float3 env = GlossyEnvironmentReflection(reflWS, 0.0f, 1.0f);
float3 color = env;
float3 originWS = positionWS;
float3 endWS = positionWS + reflWS * _MaxDistance;
#if defined(USE_FRUSTUM_CLIP)
float3 originVS = mul(UNITY_MATRIX_V, float4(originWS, 1.0f)).xyz;
float3 endVS = mul(UNITY_MATRIX_V, float4(endWS, 1.0f)).xyz;
float3 flipZ = float3(1.0f, 1.0f, -1.0f);
float3 clippedVS = frustumClip(originVS * flipZ, endVS * flipZ, _ProjectionParams.yz, float2(1.0f, -1.0f) / UNITY_MATRIX_P._m00_m11);
clippedVS *= flipZ;
float4 originCS = mul(UNITY_MATRIX_VP, float4(originWS, 1.0f));
float4 endCS = mul(UNITY_MATRIX_P, float4(clippedVS, 1.0f));
#else
float4 originCS = mul(UNITY_MATRIX_VP, float4(originWS, 1.0f));
float4 endCS = mul(UNITY_MATRIX_VP, float4(endWS, 1.0f));
#endif
float k0 = 1.0f / originCS.w;
float k1 = 1.0f / endCS.w;
float3 q0 = originCS.xyz;
float3 q1 = endCS.xyz;
float2 p0 = originCS.xy * float2(1.0f, -1.0f) * k0 * 0.5f + 0.5f;
float2 p1 = endCS.xy * float2(1.0f, -1.0f) * k1 * 0.5f + 0.5f;
#if defined(USE_POTENTIAL_HIT)
float w1 = 0.0f;
float w2 = 0.0f;
bool hit = false;
bool lastHit = false;
bool potentialHit = false;
float2 potentialW12 = float2(0.0f, 0.0f);
float minPotentialHitPos = INFINITY;
[unroll(64)]
for (int i=0; i<_StepCount; ++i)
{
w2 = w1;
w1 += 1.0f / float(_StepCount);
float3 q = lerp(q0, q1, w1);
float2 p = lerp(p0, p1, w1);
float k = lerp(k0, k1, w1);
float sampleDepth = _CameraDepthTexture.Sample(DEPTH_SAMPLER, p).r;
float linearSampleDepth = LinearEyeDepth(sampleDepth, _ZBufferParams);
float linearRayDepth = LinearEyeDepth(q.z * k, _ZBufferParams);
float hitDiff = linearRayDepth - linearSampleDepth;
float thicknessDiff = getThicknessDiff(hitDiff, linearSampleDepth, _ThicknessParams);
if (hitDiff > 0.0f)
{
if (thicknessDiff < _ThicknessParams.y)
{
hit = true;
break;
}
else if(!lastHit)
{
potentialHit = true;
if (minPotentialHitPos > thicknessDiff)
{
minPotentialHitPos = thicknessDiff;
potentialW12 = float2(w1, w2);
}
}
}
lastHit = hitDiff > 0.0f;
}
#else
float w1 = 0.0f;
float w2 = 0.0f;
bool hit = false;
[unroll(64)]
for (int i=0; i<_StepCount; ++i)
{
w2 = w1;
w1 += 1.0f / float(_StepCount);
float3 q = lerp(q0, q1, w1);
float2 p = lerp(p0, p1, w1);
float k = lerp(k0, k1, w1);
float sampleDepth = _CameraDepthTexture.Sample(DEPTH_SAMPLER, p).r;
#if defined(USE_THICKNESS)
float linearSampleDepth = LinearEyeDepth(sampleDepth, _ZBufferParams);
float linearRayDepth = LinearEyeDepth(q.z * k, _ZBufferParams);
float hitDiff = linearRayDepth - linearSampleDepth;
float thicknessDiff = getThicknessDiff(hitDiff, linearSampleDepth, _ThicknessParams);
if (hitDiff > 0.0f && thicknessDiff < _ThicknessParams.y)
{
hit = true;
break;
}
#else
if (q.z * k < sampleDepth)
{
hit = true;
break;
}
#endif
}
#endif
#if defined(USE_POTENTIAL_HIT)
if (hit || potentialHit)
{
if (!hit)
{
w1 = potentialW12.x;
w2 = potentialW12.y;
}
bool realHit = false;
float2 hitPos;
float minThicknessDiff = _ThicknessParams.y;
[unroll(5)]
for (int i=0; i<5; ++i)
{
float w = 0.5f * (w1 + w2);
float3 q = lerp(q0, q1, w);
float2 p = lerp(p0, p1, w);
float k = lerp(k0, k1, w);
float sampleDepth = _CameraDepthTexture.Sample(DEPTH_SAMPLER, p).r;
float linearSampleDepth = LinearEyeDepth(sampleDepth, _ZBufferParams);
float linearRayDepth = LinearEyeDepth(q.z * k, _ZBufferParams);
float hitDiff = linearRayDepth - linearSampleDepth;
if (hitDiff > 0.0f)
{
w1 = w;
if (hit) hitPos = p;
}
else
{
w2 = w;
}
float thicknessDiff = getThicknessDiff(hitDiff, linearSampleDepth, _ThicknessParams);
float absThicknessDiff = abs(thicknessDiff);
if (!hit && absThicknessDiff < minThicknessDiff)
{
realHit = true;
minThicknessDiff = thicknessDiff;
hitPos = p;
}
}
if (hit || realHit) color = _CameraOpaqueTexture.Sample(sampler_LinearClamp, hitPos).rgb * 0.3f;
}
#elif defined(USE_BINARY_SEARCH)
if (hit)
{
float2 hitPos;
[unroll(5)]
for (int i=0; i<5; ++i)
{
float w = 0.5f * (w1 + w2);
float3 q = lerp(q0, q1, w);
float2 p = lerp(p0, p1, w);
float k = lerp(k0, k1, w);
float sampleDepth = _CameraDepthTexture.Sample(DEPTH_SAMPLER, p).r;
if (q.z * k < sampleDepth)
{
w1 = w;
hitPos = p;
}
else
{
w2 = w;
}
}
color = _CameraOpaqueTexture.Sample(sampler_LinearClamp, hitPos).rgb * 0.3f;
}
#else
if (hit)
{
float2 hitPos = lerp(p0, p1, w1);
color = _CameraOpaqueTexture.Sample(sampler_LinearClamp, hitPos).rgb * 0.3f;
}
#endif
return float4(color, 1.0f);
}
ENDHLSL
SubShader
{
Tags { "RenderType"="Transparent" "Queue"="Transparent" }
LOD 100
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDHLSL
}
}
}
优化的方向
目前还有一个值得优化的方向,就是根据p0
和p1
之间的像素距离控制总体的步进次数,总不至于对10个像素步进64次吧。不过这个就比较简单了,留给有空的人来做吧。至于随机采样、模糊和菲涅尔,等到真的用到的时候再去考虑吧。
后记
2024简直就是开幕雷击,各种糟糕的事情接踵而至,有时候会有深深的无力感。可能只有写博客和Shadertoy才能给我带来最强的满足感吧,希望真的有人能从我的博客和Shadertoy中有所收获。本来是打算写一篇Contact Shadow的,但是发现屏幕空间ray marching其实并不是那么一件简单的事情,因此想先写完这个再继续我的Contact Shadow。说实在的我对这篇博客的质量还是比较满意的,打算再写个英文版的去投稿Graphics Programming weekly,拭目以待.gif。