Correct Depth POM & RCSM Cover

从视差映射、浮雕映射中获取正确的深度值

POM和RCSM 在我之前的文章在Unity里实现松散圆锥步进Relaxed Cone Step Mapping就已经介绍过了视差映射和松散圆锥步进浮雕映射的计算方法了,但是之前并没有对计算深度值做相应的研究,同时也限制于篇幅的原因就没有再展开了,这篇文章相当于是之前文章的后续。为了简便,后续将这两种计算方法统称为视差映射。 在视差映射中计算深度值是一个很直接的想法,因为很有可能会有其他物体被放置在视差映射的表面,与之发生穿插,如果不做特殊处理,就会使用模型本身的深度值进行深度比较,导致别的物体不能有正确的被遮挡的效果,削弱了视差映射带来的真实感。网上我找了一圈,并没有找到和计算视差映射的深度值相关的文章,因此我想用这篇文章进行相关的介绍。 Unity的高清管线(HDRP)的Lit Shader支持计算像素深度偏移,提供了Primitive Length,Primitive Width,和Amplitude三个参数。Amplitude可以用来控制视差映射的强度值,虽然其一个单位和世界空间的一米完全不能直接等同起来,但是值越大视差看上去就越深,可以根据视觉实时调整这个参数。另外两个参数就很奇怪了,居然和模型的大小有关,同一个材质球,用在Quad上这里就要填1,用在Plane上就要填10,哪有这种道理?虚幻引擎则是提供了POM的接口,至于输入和输出完全都由用户控制,这里就不太好直接比较了。 回顾POM的计算过程 视差映射一般不会直接在世界空间步进,而是会先将世界空间的视线viewWS转换到切线空间viewTS,在切线空间步进。照常理_ParallaxIntensity是用来控制视差映射的深度的,因此会使用这个参数控制z方向步进的距离,但为了方便和高度图中记载的高度进行对比,会先对viewTS的z分量进行归一化,将_ParallaxIntensity在步进时乘到viewTS的xy分量上,之后就是循环比较深度进入下一个循环了。 但是为什么是切线空间呢?这是因为切线tangent和副切线bitangent代表了贴图UV的xy的正方向,将视线转换到切线空间,其实目的是将视线转到UV空间,或者说是贴图空间(Texture Space,因为其与切线空间的相似性,我们还是用TS来做简写)。这里就出现了最重要的一个问题,Unity中通过GetVertexNormalInputs获得到的世界空间的切线是经过归一化的,丢失了物体自身的缩放,所以我们其实应该先将世界坐标的视线viewWS转换到物体空间viewOS,然后再使用物体空间的tbn矩阵,将viewOS转换到切线空间viewTS。但又如我上面说到的,我们真实的目的是贴图空间,切线空间和贴图空间是存在差异性的。这也就是为什么Unity的HDRP要使用额外的参数Primitive Length和Primitive Width了,这两个参数的目的是通过额外的缩放,将切线空间和贴图空间对应起来。 这两个参数的意义应当是,贴图空间的xy分量每一个单位在物体空间的长度,这里我们记为uvScale。同时我们可以顺理成章地正式引入_ParallaxIntensity这个参数,它的含义应当是,贴图中颜色为0的点对应的物体空间的深度值。贴图空间转换到物体空间,只需要对xyz三个分量分别乘上uvScale.x,uvScale.y,和_ParallaxIntensity即可。_ParallaxIntensity这个参数我们可以作为材质球的一个输入进行控制,uvScale是一个跟模型相关的参数,我们可以在Geometry Shader中计算而得。 uvScale的计算 如上面所属,uvScale指代的是贴图空间的xy分量每一个单位在物体空间的长度。对于两个顶点v0和v1,贴图空间的xy分量其实就是这两个顶点uv值的差,物体空间的长度其实就是两个顶点之间的距离,为了对应到贴图空间上,我们需要计算这段距离在切线和副切线上的投影长度,后者除以前者就是我们需要的uvScale了。由于构成三角形的三个顶点可能会存在某两个顶点之间uv的某个分量的变化率为0,导致我们计算uvScale的时候除以零,我们在检测到这个情况的时候使用第三个顶点即可。 贴图空间变换 在获得了物体空间的切线、副切线和法线之后,为了构成贴图空间的三个基向量,我们需要对这个向量使用uvScale和_ParallaxIntensity进行缩放。这个缩放导致了我们按照以往的float3x3(tangentOS * uvScale.x, bitangentOS * uvScale.y, normalOS * _ParallaxIntensity)构成的矩阵不再是一个正交矩阵,它实际上是贴图空间到物体空间的变换矩阵的转置。因此将物体空间的视线viewOS转换到贴图空间viewTS时,我们要用这个矩阵的转置的逆左乘viewOS,将贴图空间的视线viewTS转换到物体空间viewOS时,我们要用这个矩阵的转置左乘viewTS。 深度的获取 这个就相对来说比较简单了,我们在贴图空间步进的时候,可以知道我们在贴图空间步进的z方向的深度值len。而由于我们的viewTS会做除以z分量的归一化,我们只需要用归一化前的-viewTS乘上len再除以z分量,就能知道我们在贴图空间中总的步进的向量,将其转换到物体空间再转换到世界空间,和当前点的世界空间的坐标相加后再转换到裁剪空间,其z分量除以w分量就是我们需要的深度值了。 具体的代码 这里只做了可行性的研究,应该有个方法能够简化计算矩阵的逆这一步操作。在计算世界空间的切线、副切线和法线的时候,可以不进行归一化,这样我们也就不需要先转换到物体空间再转换到贴图空间了。 POMShader.shader Shader "zznewclear13/POMShader" { Properties { [Toggle(OUTPUT_DEPTH)] _OutputDepth ("Output Depth", Float) = 1 _BaseColor("Base Color", Color) = (1, 1, 1, 1) _MainTex ("Texture", 2D) = "white" {} _HeightMap("Height Map", 2D) = "white" {} _NormalMap("Normal Map", 2D) = "bump" {} _NormalIntensity("Normal Intensity", Range(0, 2)) = 1 _ParallaxIntensity ("Parallax Intensity", Float) = 1 _ParallaxIteration ("Parallax Iteration", Float) = 15 } HLSLINCLUDE #include "Packages/com....

March 16, 2024 · zznewclear13
Screen Space Reflection Cover

Screen Space Reflection

Screen Space Reflection While screen space reflection (SSR) is a well-known effect, this article aims to introduce a unique method for calculating screen space reflections – one that I haven’t encountered online before. Many online tutorials cover screen space reflection already, and most of them follow a similar process: calculate the reflection direction in world space, use a mostly uniform step size to traverse the world space, and for each step, compute the normalized device coordinates (NDC)....

March 9, 2024 · zznewclear13
Screen Space Reflection Cover

屏幕空间反射

屏幕空间反射 屏幕空间反射也是一个老生常谈的效果了,但正如本博客的宗旨,要从千篇一律中脱颖而出,这篇文章也将介绍与众不同的,至少我在网上没有见到过的计算屏幕空间反射的方法。 网上有很多很多的屏幕空间反射的教程,绝大部分的流程是这样的:计算世界空间的反射方向,使用一个大部分情况下是统一的步长在世界空间中步进,对于每一次步进,计算标准化设备空间的坐标,将当前的深度和深度图进行比较,如果在深度图之后,认为发生了交叉,采样当前点的颜色值并返回。这种方法能看到很多很多看上去非常完美的反射效果,但几乎没有人会提及所需要的步进次数,因为它往往高得惊人,关于这点我们后续还会谈到。而且对于不同远近的物体,想要达到比较好反射效果,其需要的步长往往是不同的,也很少有人去做这方面的思考。稍好一些的会考虑在交叉之后做几次二分法查找,这样能够让一段一段的反射后的颜色带上下颠倒,使画面看上去更加连贯,后面也能看到对比。还有一些会考虑在计算标准化设备空间的坐标后,根据坐标和[-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....

March 9, 2024 · zznewclear13
High Quality Bloom Cover

Unity的高质量的Bloom效果

Bloom辉光效果 一直都想做一个Bloom效果,Bloom是一个很简单的效果,几乎所有介绍后处理的教程里都会提到Bloom效果的制作,但是Bloom又是一个不那么简单的效果,大部分教程制作出来的Bloom看上去都不太好看。 想要做好Bloom,首先得认识到什么是Bloom效果。Bloom是由于透镜不能完美地让光线聚焦与同一点而导致图像上的高亮区域的颜色向周围溢出的效果,和体积雾这样的由于多次散射和折射形成的溢出效果在原理上就不相同。在计算机图形学里往往使用多次模糊的方式来表现这种效果。 而在讨论什么是好的Bloom之前,我们先来看看差的Bloom的效果。Matthew Gallant在他的文章Bloom Disasters中就给出了很多当时的极糟糕的Bloom效果的例子。可以看到Bloom很重要的一点是,Bloom之前的画面必须要是HDR的画面,如果整个画面被限制在01之间,那么白色的T恤和特别亮以至于看上去是白色的太阳带来的Bloom效果就会相同。在LearnOpenGL上有那么一篇文章,其中说到,为了模拟我们眼睛的工作原理,我们不对颜色进行阈值限制,而是直接对HDR画面进行模糊再和原HDR画面进行插值。我认为这是一种十分错误的方式。最合理的方式应当是,画面上的每个颜色确实会向周围溢出自己的颜色,但是更亮的颜色的溢出半径会更大,对于较暗的颜色,由于溢出半径小于半个像素宽,在最后的画面中就看不到颜色的溢出了。但是根据明度来控制溢出的半径是一件很复杂的事情(这和景深的原理是一样的,所以我到现在都没有掌握一个很好的景深的算法),因此我们在计算的时候通过仅模糊超出阈值的颜色来模拟这种效果。模糊半径也是一个决定Bloom质量的关键要素,如果模糊的半径比较小,看上去就像高光套了一个稍弱的圈一样,不够美观。 Jorge Jimenez在2014年Siggraph多的Advances in Real-Time Rendering课程上介绍了他为使命召唤现代战争所做的次世代后处理效果。他的PPT里介绍了使命召唤现代战争中运动模糊、散景、次表面散射、Bloom和阴影采样的做法,十分值得一看。本文在整体的算法上就使用了他介绍的方法,而采样则使用了Dual Kawase Blur的算法,可以看我之前的文章。 Bloom的算法 主流的Bloom算法都会使用一个阈值,第一个Pass提取出大于这个阈值的颜色(使用减法,这样能够和小于阈值的颜色形成自然的过渡),然后进行一系列的降采样升采样以减少采样的次数,最后将之前所有的升采样的模糊结果(就相当于是Mip的每一级)叠加到一开始的颜色上。为了减少最后一步采样所有的Mip等级带来的消耗,根据Jorge Jimenez的做法,我们会在每一步升采样时叠加当前Mip的颜色。但是最后叠加不是一个很好的处理方法,由于叠加了各个Mip的颜色,会导致原来高光的区域的亮度会被提高到原来的两倍甚至更多,不过我们之后的Tone Mapping能够一定程度上缓解这个问题。然后是对微小的高亮物体的处理,Jorge Jimenez使用了1/(1 + Luma)的方式进行加权处理,不过如果我们将Bloom移动到TAA之后,这个问题能够很好的解决掉。至于模糊,在我之前那么多文章的铺垫下,也就不是什么难点了。 我自己在实现的时候,会在最后一步叠加Mip到最一开始的图像时,将模糊后的颜色除以所有的降采样次数,这样能够稍微弥补一下多个Mip带来的亮度剧烈增加的问题。事实上我也想过将因为阈值而丢失的亮度储存在透明通道里,和颜色一起参与模糊,在最后的时候加回之前丢失的亮度,最后和原始颜色线性插值,不过似乎不那么好做。 这里可以对比一下Unity自带的Bloom和我的Bloom之间的效果差异。Unity第一个Pass预过滤会进行13次采样,之后每一次降采样分成横竖两个方向,横向9次采样,竖向5次采样,升采样则是在2次采样中线性插值。我的则是每次降采样进行5次采样,每次升采样进行8+1次采样,不需要分横竖采样。下图上边是Unity自带的Bloom,下边是我的Bloom,最后均使用Aces Tonemapping,场景里的大立方体的大小是相邻小立方体的1.5倍,而小立方体的亮度是相邻大立方体的1.5倍。我尽量地将参数调的差不多,Unity一共22个Draw最高Mip为7,我的一共17个Draw最高Mip为8,可以观察到Unity的会有稍微明显一点的Banding,中间亮度的中间大小的物体带来的Bloom比我的稍微大一些。 Unity Default Bloom My High Quality Bloom Bloom的具体实现 HQBloomComputeShader.compute 这里有四个Kernel,HQBloomWeightedDownsample用于第一次降采样时减去阈值并加权进行模糊,HQBloomDownsample是和Dual Kawase Blur一样的降采样的模糊,HQBloomAdditiveUpsample是在Dual Kawase Blur的升采样的基础上和低一级的Mip叠加,HQBloomComposite则是将最低一级Mip和原始颜色进行混合。 #pragma kernel HQBloomDownsample #pragma kernel HQBloomWeightedDownsample #pragma kernel HQBloomAdditiveUpsample #pragma kernel HQBloomComposite #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl" #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" Texture2D<float4> _SourceTexture; Texture2D<float4> _ColorTexture; RWTexture2D<float4> _RW_TargetTexture; SamplerState sampler_LinearClamp; float4 _SourceSize; float4 _TargetSize; float _Threshold; float _InvDownsampleCount; float _BloomIntensity; float3 applyThreshold(float3 color, out float luma) { luma = Luminance(color); return color * max(0....

July 22, 2023 · zznewclear13
Relaxed Cone Step Mapping Cover

在Unity里实现松散圆锥步进Relaxed Cone Step Mapping

阅读前提示 由于本文使用的贴图均为LearnOpenGL网站上的贴图,其法线贴图和一般Unity或Unreal引擎中的法线贴图的Y分量是相反的,因此在计算世界坐标的bitangent的时候会额外再乘上一个sign,在正常情况下是不需要的。 视差效果 在三角形面数比较受限的情况下,往往会考虑使用一张高度图,通过视差的计算去渲染出一种3D的效果(虽然现在直接用曲面细分Tessellation似乎是一种更普遍的且更有效的方法)。有两种计算视差的方法,一种叫做Parallax Occlusion Mapping,先假定高度的层数,然后对每一层计算出合适的位置和颜色,从而达到3D效果;另一种叫做Cone Step Mapping,是根据高度图预先计算出每个点对于其他所有像素的最大的圆锥张角(有点像AO),根据圆锥张角快速步进,最后使用二分法计算出最终的交点的颜色。第一种方法有一个比较大的缺点,就是在视角比较接近平面的时候,如果采样次数不是很高,就会看到一层一层的效果,可以通过对最后一次计算深度进行线性插值在一定程度上减轻一层一层的问题;第二种方法的缺点是,当采样次数较小时,产生的图像会有一定程度的扭曲,但不会有一层一层的感觉,此外相较于第一种会有一个优点,较细物体不会被跳过。在GPU Gems 3中提到了一种Cone Step Mapping的优化,叫做Relaxed Cone Step Mapping,相较于之前计算最大张角的方式,这种优化通过确保通过圆锥的射线与圆锥内部的高度图至多只有一个交点,减少了一开始圆锥步进的次数。本文就主要使用这种方法进行计算,也许将圆锥的顶部放在比当前高度图更深的位置能够更加减少步进的次数,不过我稍微尝试了一下好像效果并不是特别理想。 Parallax Occlusion Mapping可以在Learn OpenGL里找到介绍和优化方案,Shadertoy上也有开源的代码可以参考。UE5中有一个叫Get Relief!的插件,可以用来快速生成Relaxed Cone Step Mapping的预计算的贴图,也提供了渲染的Shader。这个插件的作者Daniel Elliott也在GDC2023上分享了制作的思路,如果链接打不开的话这里还有一个GDC Vault的链接。 本文使用的贴图可以在Learn OpenGL中给出的下载链接中找到。为了看上去舒服一些,这里对displacement贴图的颜色进行了反向。 下图是两种视差做法的比较,左边是Parallax Occlusion Mapping,右边是Relaxed Cone Step Mapping,两者的采样次数是相同的,可以看到POM在较极限的情况下会有分层感而RCSM会有扭曲。RCSM使用的贴图也放在下面了,R通道是高度图,G通道是圆锥的张角。本文使用的是Unity 2021.3.19f1c1。 生成预计算的贴图 和Parallax Occlusion Mapping直接使用深度图不同的是,Cone Step Mapping需要预先计算出一张圆锥张角的图,圆锥的张角可以使用圆锥底的半径除以圆锥的高来表示,记为coneRatio。本文中使用的是高度图,但实际计算中会使用1减去高度值,对应的是从模型表面到实际高度的深度值。由于深度值只会在01之间,uv也只会在01之间,因此对于最深的点,其最大的圆锥张角不会大于1。 “确保通过圆锥的射线与圆锥内部的高度图至多只有一个交点”,对于圆锥顶部的currentPos和圆锥底部的rayStartPos(这个圆锥是一个倒立的圆锥,其底部和模型表面相平),可以采样一个目标点cachedPos,当cachedPos的深度小于currentPos的深度时,沿着cachedPos - rayStartPos的方向移动cachedPos的位置并一直采样所有像素samplePos,直到samplePos的深度值小于cachedPos(即射线穿过高度图并穿出),根据samplePos和currentPos就能计算出一个圆锥的张角coneRatio。循环所有的像素就能得到最小的圆锥张角了。 为了减少单次计算的消耗,本文会先将整张图片分成NxN大小的区域,在一次循环中会计算所有像素对于这NxN大小的区域的圆锥张角,循环所有的区域就能得到最后的圆锥张角了。同时只需要让N等于THREAD_GROUP_SIZE,就能使用group shared memory仅通过一次采样缓存这些区域的深度值。再有就是Early Exit的优化,当cachedPos在贴图外部,当cachedPos的深度大于currentPos的深度,当cachedPos的圆锥张角大于当前最小的圆锥张角,在这些情况下可以直接结束向外步进的循环。更多的优化方法也都能在Get Relief!的分享中找到。 具体的代码 RCSMComputeShader.compute 用于生成Relaxed Cone Step Mapping的贴图。PreProcessMain用于处理最一开始的深度图,预先设置最大的coneRatio为1。Early Exit是减少运算时间的关键。 #pragma kernel PreProcessMain #pragma kernel RCSMMain #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" Texture2D<float4> _SourceTex; RWTexture2D<float4> _RW_TargetTex; SamplerState sampler_LinearClamp; float4 _TextureSize; float2 _CacheOffset; #define THREAD_GROUP_SIZE 16u [numthreads(8, 8, 1)] void PreProcessMain(uint3 id : SV_DispatchThreadID) { uint2 tempID = uint2(id....

July 1, 2023 · zznewclear13
zznewclear13 技术美术 图形学 个人博客 technical art computer graphics