Screen Space Contact Shadow Cover

使用Compute Shader计算屏幕空间接触阴影

屏幕空间接触阴影 屏幕空间接触阴影是用来解决普通的阴影贴图精度不够的问题而提出来的一种通过深度图在屏幕空间计算阴影的方法。索尼的Bend Studio的Graham Aldridge在Sigraph 2023的索尼创作者大会上,介绍了往日不再(Days Gone)中计算屏幕空间接触阴影的方式,这里可以找到演示文稿和参考代码。 本篇文章相当于是Radial Dispatch系列的第三篇文章了,与上一篇文章一样,这篇文章是基于径向分派Compute Shader中相关算法的实际应用,具体的缓存方式也可以参考上一篇文章使用Group Shared Memory加速径向模糊,这里就不再赘述了。实际上我发现了这样计算接触阴影的一个缺陷,就是不太好计算软阴影了,由于缓存的限制,随机采样只能在一个很小的范围内分布,基本上用不上了。由于使用的是屏幕空间的深度图的信息,加上厚度检测之后很容易出现漏面的问题,封面中的瑕疵也有一部分是来自于我的Relaxed Cone Step Mapping本身深度值的瑕疵,屏幕上半部分的阴影就好很多。这就当作是一个Proof of Concept吧,之后有机会的话再回来优化优化。 本文使用的是Unity 2022.3.21f1,URP版本是14.0.10。 具体的代码 ContactShadowComputeShader.compute 核心的代码来自于前一篇文章径向分派Compute Shader。前一篇文章在循环中是通过统一步长进行采样的,会采样到四个像素中间因此需要双线性插值,这次我们固定水平或者竖直方向的步长为一个像素,这样我们只需要在一个方向上进行线性插值了。由于深度和颜色信息是两种不同的信息,我们仅对距离很近的深度进行线性插值,对于距离较远的两个深度值,我们使用离采样点最近像素的深度值。至于如何判断深度远近,我使用了和屏幕空间反射中相同的_ThicknessParams,默认物体的厚度为linearSampleDepth * _Thickness.y + _Thickness.x。 #pragma kernel ContactShadowPoint #pragma kernel ClearMain // #pragma warning(disable: 3556) #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #define THREAD_COUNT 128 Texture2D<float4> _ColorTex; Texture2D<float> _CameraDepthTexture; RWTexture2D<float4> _RW_TargetTex; float3 _LightPosWS; float4 _LightPosCS; float2 _LightPosSS; float2 _ThicknessParams; float4 _TextureSize; float _LightRange; float _Debug; struct DispatchParams { int2 offset; int count; int stride; int xMajor; }; StructuredBuffer<DispatchParams> _DispatchData; int GetDispatchType(int index, out int dispatchIndex, out DispatchParams dispatchParams) { for (int i=0; i<8; ++i) { dispatchParams = _DispatchData[i]; dispatchIndex = dispatchParams....

April 20, 2024 · zznewclear13
Radial Blur Cover

使用Group Shared Memory加速径向模糊

径向模糊 在写了上一篇文章径向分派Compute Shader之后,很自然的就需要用径向模糊来检验我们的算法。 径向模糊的效果在网上可以说是一找一大堆了,百分之九十五的教程,会使用一个很大的循环重复采样纹理,百分之四点九的教程,会使用多pass的方法减少采样的次数(当然这些数字是我瞎编的,我还留了百分之零点一,以防真的有人也用Compute Shader做了径向模糊)。这篇文章就来探讨如何使用Compute Shader中的Group Shared Memory来加速径向模糊的计算。我们的目标是在一个Compute Shader中实现超大模糊距离(甚至能超过128像素)的径向模糊。 缓存颜色信息 照例我们要再看一遍索尼的Bend Studio的Graham Aldridge在Sigraph 2023的索尼创作者大会上,介绍的往日不再(Days Gone)中计算屏幕空间接触阴影的方式,这里可以找到演示文稿和参考代码。演示文稿的第24-25页介绍了如何在Group Shared Memory中缓存深度信息,当然在径向模糊中我们缓存的是颜色信息了。 下图是缓存颜色信息的示意图,绿色方块对应的像素是我们整个Thread Group的起点,红色方块是我们当前计算的像素,黑色和灰色的线代表了该像素到径向模糊中心的方向。我们的Thread Group Size是12,深蓝色(包括绿色和红色)的像素同属于一个Thread Group,我们需要计算这些像素径向模糊后的颜色。深蓝色、浅蓝色、灰色和浅灰色、镂空的灰色和浅灰色,代表了我们需要缓存的颜色,因为我们需要对缓存的颜色进行双线性插值,我们除了需要缓存射线经过的最近的像素(即蓝色区域)外,还需要缓存射线经过的第二近的像素(即灰色区域)和射线经过的第三近的像素(即镂空灰色区域)。也就是说当Thread Group Size为12时,我们需要缓存6 * 12个像素,亦即每个像素六次采样。 仅缓存第二近的像素在大部分情况下能够得到正确的双线性插值,但是注意看红色方块向右的第二次采样,仅对蓝色和实心灰色区域的颜色进行插值是不能得到正确的颜色的,因此我们需要额外缓存镂空灰色区域的颜色,亦即第三近的像素。 Group Shared Memory分配 如上所述,我们的Group Shared Memory的大小是THREAD_COUNT * 6(懒得把THREAD_COUNT统一改成THREAD_GROUP_SIZE了),其中包含了上述的深蓝色、浅蓝色、灰色、浅灰色、镂空灰色、镂空浅灰色区域对应像素的颜色信息。数组的长度是THREAD_COUNT * 6,在我们缓存时,我们会将其视为6行THREAD_COUNT列的表来储存颜色信息,而在我们读取时,我们会将其视为3行THREAD_COUNT*2列的表来读取数据。 groupshared float3 cachedColor[THREAD_COUNT * 6]; void SetCachedColor(float3 color, int2 threadPos) {cachedColor[threadPos.x+threadPos.y*THREAD_COUNT]=color;} float3 GetCachedColor(int2 threadPos) {return cachedColor[threadPos.x+threadPos.y*THREAD_COUNT*2];} 具体的代码 RadialDispatchComputeShader.compute 核心的代码来自于上一篇文章径向分派Compute Shader。循环读取颜色值的方式有很多,可以严格按照格点来读取,这样只需要对y方向做线性插值,也可以按照统一的步长来读取,这样需要对xy方向都做线性插值,我这里使用的是统一的步长。每一组缓存的起始像素是最接近于射线的像素的下面一个像素,读取缓存使用的偏移是用的当前采样点下第一个像素的坐标和当前缓存列最下面像素的坐标相减,这里面比较绕不太好描述。。。 #pragma kernel RadialBlurMain // #pragma warning(disable: 3556) #define THREAD_COUNT 128 Texture2D<float4> _ColorTex; RWTexture2D<float4> _RW_TargetTex; float2 _CenterPosSS; float4 _TextureSize; float _Intensity; struct DispatchParams { int2 offset; int count; int stride; int xMajor; }; StructuredBuffer<DispatchParams> _DispatchData; int GetDispatchType(int index, out int dispatchIndex, out DispatchParams dispatchParams) { for (int i=0; i<8; ++i) { dispatchParams = _DispatchData[i]; dispatchIndex = dispatchParams....

March 31, 2024 · zznewclear13
Radially Dispatched Cover

径向分派Compute Shader

动机 最直接的动机是我最近需要实现屏幕空间接触阴影了。索尼的Bend Studio的Graham Aldridge在Sigraph 2023的索尼创作者大会上,介绍了往日不再(Days Gone)中计算屏幕空间接触阴影的方式,这里可以找到演示文稿和参考代码。演示文稿的第24-27页,展示了一种新颖的分派Compute Shader的方法,传统的分派Compute Shader往往是将画面水平和竖直切分成像素数量为64倍数的小块,将分派的Compute Shader对应到这些小块上,而Days Gone中则是将分派的Compute Shader对应到呈放射状的像素小块上。大致的意思可以看下图,下图中相同颜色的相邻像素属于同一个thread group,左边是传统的分派方式,右边则是径向的分派方式。 当进行径向模糊或是计算接触阴影时,往往需要沿着某个方向连续采样纹理。对于多次采样,我们一般会想到使用Compute Shader中的Group Shared Memory进行缓存从而减少采样次数。但是对特定方向进行缓存的话,会要缓存O((N+C)^2)个颜色,如果分派的Thread Group Size或是步进的次数比较大,很容易就超出了Group Shared Memory的最大限制。如果我们使用径向分派的方式,将每一个Thread Group对应的像素沿着采样的方向排列,算上线性插值也只需要缓存(N+C)*2个颜色,这样就能很方便地进行较远的步进了。 相较于索尼的演示,本文解决了Thread Group对应的像素重叠的问题,也尽量地介绍了设置分派参数时的各种条件判断。本文使用的是Unity 2022.3.21f1,URP版本是14.0.10。 如何进行径向分派 分派方式和原因 首先我们注意到对于屏幕中所有指向中心的射线,可以将其分为左下、左上、右下、右上四种,这四种射线最明显的是符号相反,因此在我们分派的时候可以分成四组数据,每一组数据使用同样的方式找到对应的偏移值,再乘上符号和中心的坐标相加,就能得到对应的像素坐标。 因此我们只需要考虑一种情况,我们以右上角为例。下图是一个径向分派的示意图,绿色是我们的中心点,所有的Thread Group都会以绿点为中心放射状排布,黑框就是屏幕上中心点右上角对应的区域(为了简便这里选取了比较小的18x10像素),这里每四个相邻白色方框同属于一个Thread Group(更多的Thread Group我没有画出来),蓝色的区域是每一个Thread Group的起点,这里可以看到深蓝和浅蓝两种颜色,它们对应了两种分派的规律,一种是呈正方形的,另一种则是呈矩形的,灰色的区域是所有计算而得的每一个Thread对应的像素,为了让灰色的区域覆盖整个黑框的区域,我们需要做比当前像素更多的分派。 直接计算每一个Thread对应的像素似乎有点困难,我们可以将分派分成两个维度,用第一个维度计算Thread Group的起点,即上图的蓝色区域,用第二个维度和Thread Group的起点,计算对应的像素的位置。因此我们分派的数据也就变成了一个GroupID和GroupIndex了。注意到浅蓝色的区域的位置决定于黑框的长宽比,当黑框的高大于长时,浅蓝色的区域会在深蓝色的上方且横向排布。我们可以做一个xMajor的判断,如果不是xMajor,我们就调换xy分量,全部计算完毕之后再换回来。 根据图上的深蓝色和浅蓝色区域,我们会将两个区域分开来计算GroupID。比较简单的是浅蓝色的区域,从数学上我们需要传入每一列的列高,计算出GroupID的列序号和在一列中的序号,就能得到起点的坐标了。深蓝色的区域,如果单纯对每一圈求和的话,这是一个二次方程,虽然也能计算但效率肯定不会很高。我们可以考虑高斯求和的方法,将第一圈的竖向的像素和最后一圈的横向像素合并成一列(也就是图上深蓝色方框左上角图案相同的为同一列),这样得到的每一列的列高都是相同的,就能使用浅蓝色区域的方式计算序号了,之后我们再对比较序号的大小来决定是竖向的像素还是横向的像素。 得到了Thread Group的起点坐标之后,我们只需要使用起点坐标到中心的向量,对X方向或Y方向以1为单位步进,再对另一个方向取最近的整数,就能得到当前Thread对应的像素相对于整个Thread Group起点坐标的偏移,两者相加就能得到最终的像素坐标了。 事实上,我们的中心点有可能会在屏幕外部,这个时候上图就会变成这样,我们在计算列高的时候需要额外的考虑中心点的偏移,深蓝色的区域也不会考虑完全在屏幕外的圈。 径向分派的额外参数 为了在Compute Shader中计算每个Thread对应的像素,我们需要从CPU额外传递一些参数。在径向分派中,我们从SV_DispatchThreadID中获取到的其实是GroupID和GroupIndex两个参数。由上面的讨论,我们将所有情况分为4 * 2种,即左下、左上、右下、右上、深蓝、浅蓝的组合,对于每一种组合我们需要知道总的数量,才能计算在每一种组合中的GroupID。根据我们上述的计算方式,我们还需要知道每一种组合对应的列高和xMajor的信息。为了兼容中心点在屏幕外的情况,我们还需要知道中心点的偏移值。这样我们的参数就是8组5个int值,分别对应偏移值X,偏移值Y,当前总Thread Group数,列高和xMajor,其中xMajor其实是一个布尔值可以封装到列高的第一位,这样就刚好是四个int值了,我们这里为了方便演示就不做这样的优化了。 private struct DispatchParams { public int2 offset; public int count; public int stride; public int xMajor; public DispatchParams(int2 offset, int count, int stride, int xMajor) { this....

March 30, 2024 · zznewclear13
Hex Tiling Cover

在地形贴图混合时使用六边形平铺

动机 最近看到了三角洲行动介绍的在虚幻引擎4中实现的地形渲染方案,感觉受益匪浅。不过在Unity中要想实现一个即插即用的虚拟贴图的技术应该有些困难,于是我把目光放在了最一开始所介绍的对地形贴图做混合的方案上。 三角洲行动提出的方案是这样的,在地形计算的时候,从对材质ID图的四个像素采样退化成对三个像素采样,这样一来既能减少地形混合的时候的采样次数,二来相较于采样四个像素,三个像素多了一个斜向45度的效果,能够减轻一些地形的块面感。不过他们也有语焉不详的地方,虽然只采样三个像素能够提供斜向45度,但是对于斜向-45度,仅使用同一种方式采样三个像素是不能消除这个方向的块面感的,当然想必他们也有对应的解决方案就是了。此外他们声称材质ID图是一张R8的贴图,但这张贴图里面怎么会有5bit的下层ID,5bit的上层ID,再有3bit的权重值呢?我只能认为这张材质ID图实际上只包含了一个5bit的材质ID和3bit的权重了,这个3bit的权重值会在和另外几个像素混合时使用到。 不过三次采样倒是让我想起了Hex Tiling。在Practical Real-Time Hex-Tiling里介绍了一种通过六边形平铺来降低平铺时纹理重复感的算法,这种算法正巧需要对主贴图采样三次(不考虑随机采样的话)。Github中也能找到参考的代码。 这样一来我们就能在三角洲行动的方案上再提出一种新的地形混合的方法了。我们同样是采样三个像素,不过我们在地形中会将这三个像素用等边三角形的方式排布,而不是目前所用的直角三角形。所以我们的流程是,先将当前的世界空间或者uv做一次三角形格点的变换,使用变换后的格点采样材质ID图获得三个材质ID和权重,再使用获得的数据和本身的六边形平铺的权重进行混合,就能得到我们最终的混合后的地形材质了。如果把我们的材质ID图平铺到世界空间,看上去应该是这样的: 生成材质ID图 为了快速生成材质ID图(我可不想手画),我们考虑使用Compute Shader通过Perlin Noise生成材质ID,使用普通的hash生成权重。为了使我们的材质ID图也能正常的平铺,我们在计算Perlin Noise的时候,要注意使用取模的运算将计算的uv值限制在同一个范围内。 我们只需要一个8bit的数据,但是由于Unity保存贴图的种种限制,我们可以将R8_Uint的数据除以255转换成R8_UNorm类型的数据再储存到贴图中。 GenerateMatIDComputeShader.compute #pragma kernel MatIDGenMain RWTexture2D<float> _RW_MatIDTex; float4 _TextureSize; // https://www.shadertoy.com/view/4djSRW float hash12(float2 p) { float3 p3 = frac(float3(p.xyx) * .1031); p3 += dot(p3, p3.yzx + 33.33); return frac((p3.x + p3.y) * p3.z); } float2 repeat(float2 coord, float2 size, float2 offset) { return coord - floor(coord / size) * size + offset; } float encode(int weight, int index) { int encoded = (weight << 5) | index; return float(encoded) / 255....

March 19, 2024 · zznewclear13
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
zznewclear13 技术美术 图形学 个人博客 technical art computer graphics