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
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