百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分析 > 正文

【源码】效果最好的网格Shader(迄今为止)

liebian365 2024-10-15 13:50 50 浏览 0 评论

我一直都在写Shader,其中有一个特定的Shader我一直想写好,但我总是因为一些我无法完全理解的原因而失败。然后过了几年,我用新学到的知识再次尝试,越来越接近,然后又失败。是什么Shader?

模拟纹理网格但看起来更好的网格Shader。

注意:我强烈建议在深色模式下阅读本文。

正文共: 14500字 57图

预计阅读时间: 37分钟

不想看技术原理的话,可以直接去文章末尾下载源码体验
不想看技术原理的话,可以直接去文章末尾下载源码体验
不想看技术原理的话,可以直接去文章末尾下载源码体验

技术路线对比

Con-text-ure(基于纹理的网格)

RGSS Texture Grid

这是使用旋转网格超采样,以及16x各向异性过滤。这基本上是简单基于纹理的网格在保持合理性能的情况下所能达到的最佳效果。

而且看起来写一个网格Shader很简单。在Shader教程中,你很早就看到了在Shader中绘制网格线。那么为什么我一直对这个Shader如此着迷呢?因为要把它做好比看起来难得多,而且我知道基于Shader的解决方案看起来会更好。真的就是这样。我只是想更好地理解问题空间。

让我们更仔细地看一下上面的基于纹理的网格,看看仍然出现的一些问题区域。

RGSS Texture Grid artifacts

正如你所看到的,一些细线仍然会出现走样,在远处中间区域有一些走样和摩尔纹,在远距离处,线条会变粗,然后在各向异性过滤失效时过早地被截断。如果纹理足够大,它们在特写时才能保持清晰,否则它们会变得有点模糊。

RGSS Texture Grid 在特写时模糊

MyFirstGrid.shader(常见的网格Shader)

那么那些教程中的网格Shader呢?从一个重复的UV开始。使用smoothstep()fwidth()绘制一些线条。我们就完成了!

对吗?(别担心,我稍后会展示代码。)

Constant Pixel Width Line Grid

但有一个问题。大多数示例网格Shader,比如这个,使用的是屏幕空间宽度线条。在很多情况下,这可能比基于纹理的网格更受欢迎,而且老实说,这可能也是大多数人想要的。但这不是我想要实现的目标。当这种网格的线条进入远处时,最终每个网格单元的宽度小于一个像素,因此线条会收敛成与线条相同颜色的纯色。

这与基于纹理的网格不同。对于基于纹理的网格,线条本身具有透视效果,并且随着距离的增加而变细。最终在小于像素宽度时消失。

RGSS Texture Grid vs Constant Pixel Width Line Grid

它们都收敛成纯色,但基于纹理的网格收敛成与网格单元区域的线条覆盖率相关的颜色。

RGSS Texture Grid vs Constant Pixel Width Line Grid

更不用说当网格小于一个像素宽时出现的明显的摩尔纹。

我过去看到的大多数尝试绘制恒定世界空间或UV空间宽度的线条的示例Shader都没有真正正确处理这个问题。它们通常使用UV空间淡化边缘或根本没有线条抗锯齿,这两种方法最终都会在远处出现严重的走样。或者它们会作弊,在某个任意距离处淡出线条以隐藏伪影。而那些没有淡出线条的Shader在远处看起来与恒定像素宽度线条网格类似。只是走样更严重,摩尔纹更明显。

Constant UV Width Line Grid

这一切都不符合基于纹理的网格的外观。虽然它至少在一定程度上与线条本身的透视效果相匹配。

RGSS Texture Grid vs Constant UV Width Line Grid

Choo Choo!(过滤后的脉冲串)

但也有一些现有的例子看起来可以正确解决这个问题。最近有人向我指出了一个例子,但它已经存在的时间比我写Shader的时间长得多。这种技术发表在Apodaca, Anthony A. 和 Larry Gritz 编著。1999. Advanced RenderMan: Creating CGI for Motion Pictures(https://books.google.com/books?id=6_4VaJiOx7EC&q=Pulsetrain#v=onepage&q&f=false)中。后来在RenderMan 的文档(https://web.archive.org/web/20220629212902/http://weber.itn.liu.se/~stegu/TNM084-2016/RenderMan_20/basicAntialiasing.html)中也有介绍。过滤后的脉冲串。

Filtered Pulsetrain Grid

这种技术旨在解决我一直试图解决的精确问题。他们分析地解决了卷积脉冲串的积分。如果你像我一样没有完成大学水平的数学课程,这意味着什么都不意味着。我从艺术学校辍学了,所以大部分内容都超出了我的理解范围。

简而言之,这个函数返回任意范围内线条与非线条的比率。而且它非常有效。与基于纹理的网格相比,它在处理淡出到远处时的效果几乎完美匹配。

RGSS Texture Grid vs Filtered Pulsetrain Grids

至少乍一看是这样。仔细观察会发现一些问题。

Filtered Pulsetrain Grid artifacts

虽然它与基于纹理的网格的感知亮度相匹配,并且在前景中没有走样,但中远距离的走样和摩尔纹明显更严重。基本上所有可见的线条抗锯齿都消失了。它比完全没有抗锯齿要好,而且摩尔纹不像像素和UV宽度线条网格那样明显。但这仍然不像我预期的那样干净。

有趣的是,书中有一段这样的说明:

… 最严重的走样消失了。

严重的,但不是所有。我不得不假设最初的作者知道它没有消除所有走样,但对结果足够满意,没有进一步研究它。而后来使用它的人也并不关心,或者只是没有仔细观察到?

Hi IQ(盒式过滤网格)

还有 Inigo Quilez 在他关于可过滤的程序(https://iquilezles.org/articles/filterableprocedurals/)的文章中提到的例子,盒式过滤网格。

Box Filtered Grid

盒式过滤网格函数确实解决了过滤后的脉冲串的一些问题,主要是它对精度高度敏感,因此在远离原点的地方就开始出现噪声伪影。但除此之外,它们的运行方式大致相同。这包括中远距离的相同走样问题。

Box Filtered Grid artifacts

尽管它们在走样和摩尔纹模式上略有不同。

Filtered Pulsetrain Grid vs Box Filtered Grid

现在,虽然我从高层次上理解了这两个Shader的工作原理,但我对数学的理解还不够,无法理解如何修改它们来获得我想要的结果。

新的方案

实际上,我想要一个网格Shader做什么?我想要:

  • 用户可配置的线条宽度。
  • 具有透视厚度的线条,而不仅仅是恒定像素宽度。
  • 任何距离或视角下都不会出现走样。
  • 线宽为 0.0 或 1.0 应该显示完全隐藏或填充。
  • 有限的摩尔纹干扰模式。
  • 与基于纹理的网格在远处混合到相同的值。
  • 可用于实时渲染以替代其他技术。

所以,我回到了我确实很熟悉的Shader,像素和UV宽度线条网格。然后决定开始研究它们,看看我能不能改变它们,让它们按照我想要的方式工作。或者更确切地说,从一条线开始,然后逐步构建。

让我们快速概述一下具有用户可配置线条宽度的基本网格Shader的构成。

首先,我们需要绘制一条线。

Line One, Begin(ner Line Shader)

我喜欢用smoothstep()函数绘制线条。

float lineUV = abs(uv.x * 2.0);
float line = smoothstep(lineWidth + lineAA, lineWidth - lineAA, lineUV);

UV 用作渐变。然后我对 UV 使用abs(),这样渐变在 0.0 两侧都是正的,因此smoothstep() 会应用到两侧,我们得到一条线而不是一条边。为什么我要将 UV 乘以 2?这样做是为了让lineWidthlineAA可以指定总宽度而不是半宽度,或者不需要将它们除以 2。

现在,让我们使用世界位置作为 UV,以及一些任意值作为 lineWidth 和 lineAA。这样我们就得到了这个:

基本线条

问题在于抗锯齿在远处失效,并在前景中变得模糊。为什么?因为边缘渐变的宽度需要根据角度和与相机的距离而改变。为了做到这一点,我们可以使用我最喜欢的工具之一,屏幕空间偏导数。简短的解释是,你可以得到一个像素与其相邻像素之间的值变化量,无论是垂直还是水平。通过获取起始 UV 的偏导数,我们可以知道smoothstep()在 UV 空间中需要多宽才能在屏幕上显示为 1 个像素宽。

float lineAA = fwidth(uv.x); //
float lineUV = abs(uv.x * 2.0);
float line = smoothstep(lineWidth + lineAA, lineWidth - lineAA, lineUV); //

抗锯齿线条

现在线条的边缘很清晰。注意,我在对 UV 进行任何修改之前获取 UV 的导数。这使它们保持在“全宽度”比例,并且避免了下一步中的一些问题。

让我们把它变成一条重复的线,而不仅仅是一条线。

float lineAA = fwidth(uv.x);
float lineUV = 1.0 - abs(frac(uv.x) * 2.0 - 1.0); //
float line = smoothstep(lineWidth + lineAA, lineWidth - lineAA, lineUV);

抗锯齿重复线条

为了解释我对 UV 做的这段奇怪的代码,它将锯齿波转换为三角波,然后确保零点与之前对齐。

我们从一个lineUV开始,它像这样:

abs(uv.x * 2.0)

使用frac(uv.x)代替会得到这个:

frac(uv.x)

然后abs(frac(uv.x) * 2.0 - 1.0)会得到这个:

abs(frac(uv.x) * 2.0–1.0)

但它的“0.0”位置从 1.0 开始而不是从 0.0 开始,所以当我们绘制线条时,它们会偏移半个周期。所以我们在开头添加了1.0 -,得到这个:

1.0-abs(frac(uv.x) * 2.0–1.0)

现在,当我们绘制线条时,“第一”条线的坐标与之前的那条单线相匹配。

现在,让我们把它变成一个完整的网格。为此,我们只需要对 UV 的两个轴都执行这些步骤,并将结果组合起来。

float2 lineAA = fwidth(uv);
float2 gridUV = 1.0 - abs(frac(uv) * 2.0 - 1.0);
float2 grid2 = smoothstep(lineWidth + lineAA, lineWidth - lineAA, gridUV);
float grid = lerp(grid2.x, 1.0, grid2.y); //

这样我们就得到了一个基本的 UV 宽度线条网格Shader!

对于lerp(grid2.x, 1.0, grid2.y),可能需要解释一下。如何将重复线条的两个轴组合成一个网格Shader,这曾经让我困惑了很久。我会使用max(x, y),或者saturate(x + y),或者其他几种方法来组合它们,但它们从来没有让我感觉很对。我花了很长时间才从“如果我要重叠两个透明的东西,我该怎么做?”的角度来思考这个问题。我会使用 alpha 混合。在本例中,lerp()等效于预乘 alpha 混合,你也可以这样写:

float grid = grid2.x * (1.0 - grid2.y) + grid2.y;

或者,如果你编写Shader,使它具有白色背景上的黑色线条,那么将两个轴相乘也产生预乘混合的等效结果。注意,在下面的示例中,与第一个示例相比,smoothstep()中的加号和减号互换了。

float2 lineAA = fwidth(uv);
float2 gridUV = 1.0 - abs(frac(uv) * 2.0 - 1.0);
float2 grid2 = smoothstep(lineWidth - lineAA, lineWidth + lineAA, gridUV); //
float grid = 1.0 - grid2.x * grid2.y; //

但是,我将继续使用原始的代码示例,因为它们最终的结果完全相同。

回想起来,使用预乘混合感觉非常明显,但不知何故,它花了十多年才让我明白。这在我写了无数其他用途的Shader之后。我甚至写了一篇整篇文章来讨论这个确切的主题。

无论如何,有了这段代码,我们就得到了这个:

抗锯齿网格

看起来还不错,除了摩尔纹。但这是我们预料到的。现在让我们将线条宽度稍微减小一些,使其更接近实际使用的宽度。

“抗锯齿”网格

糟糕。当线条靠近相机时,它看起来不错。但线条很快就开始出现走样。我们在本文前面展示了恒定 UV 宽度线条网格时已经看到了这些问题,但它看起来比最初的示例稍微暗一些,走样也更严重。为什么?

最新示例与之前示例的对比

好吧,因为两个示例之间使用的代码有一个细微的差别。我在使用smoothstep()时使用了 1.5 个像素宽的 AA。这样做的原因是,smoothstep()会锐化用于抗锯齿的边缘渐变,因此 1.5 个像素宽的 smoothstep 的斜率与 1 个像素宽的线性渐变的斜率大致相同。

线性斜率与 1.5 个单位宽的 smoothstep

1 个像素宽的 smoothstep 可能锐利了。使用 smoothstep 的原因是,当使用 1.5 个像素宽的 smoothstep 时,它会增加一点点额外的抗锯齿,而不会影响与 1 个像素宽的线性渐变相比的线条的感知锐度。

1 pixel linear vs 1.5 pixel smoothstep

公平地说,这是一个非常小的差别。但 HLSL 的smoothstep()仍然很好,因为它还充当反向 lerp(也称为重新映射)并对值进行 0.0 到 1.0 之间的钳制。因此,它有助于简化代码。它也没有完全消除感知走样,但我们稍后会回到这一点。

最终,我们为恒定 UV 宽度网格得到了这段Shader代码:

float2 uvDeriv = fwidth(uv); //
float2 lineAA = uvDeriv * 1.5; //
float2 gridUV = 1.0 - abs(frac(uv) * 2.0 - 1.0);
float2 grid2 = smoothstep(lineWidth + lineAA, lineWidth - lineAA, gridUV);
float grid = lerp(grid2.x, 1.0, grid2.y);

Constant UV Width Line Grid

那么恒定像素宽度线条网格呢?好吧,这只是一个微不足道的改变。将线条宽度乘以导数!(记住,lineWidth现在是线条的像素宽度,而不是 0.0 到 1.0 之间的值。)

float2 uvDeriv = fwidth(uv);
float2 drawWidth = uvDeriv * lineWidth; //
float2 lineAA = uvDeriv * 1.5;
float2 gridUV = 1.0 - abs(frac(uv) * 2.0 - 1.0);
float2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV); //
float grid = lerp(grid2.x, 1.0, grid2.y)

Constant Pixel Width Line Grid

现在我们回到了本文前面的地方。我们有两个Shader,它们至少满足了我的两个要求。但我们还没有解决我们之前没有解决的任何问题,其中一个只有透视线条,另一个解决了大多数走样问题。

所以,让我们暂时专注于一条线,而不是整个网格。我们如何让一条线既有透视厚度又没有走样?

Phoning It In(电话线 AA)

我最喜欢的抗锯齿线条技巧之一来自 Emil Persson。特别是他的电话线 AA 示例。

https://www.humus.name/index.php?page=3D&ID=89

这种技术的核心思想是不要让东西变细到小于一个像素。相反,将东西的大小钳制到至少一个像素宽,然后淡出。这看起来比让线条变细到小于一个像素要好得多,因为如果你这样做,它总是会出现走样。两个神奇的地方是,你如何保持东西一个像素宽,更重要的是,你淡出它们多少。

在 Emil Persson 的示例中,他使用了有关线几何体宽度、每个顶点到相机的距离以及相机投影矩阵的信息来保持线一个像素厚。但对于这个Shader,我们可以再次使用那些偏导数!我们只需要限制线条在屏幕空间中的细度。基本上,我们将我们已经拥有的两个Shader结合起来,并取 UV 线宽和 UV 导数的最大值。

float uvDeriv = fwidth(uv.x);
float drawWidth = max(lineWidth, uvDeriv); //
float lineAA = uvDeriv * 1.5;
float lineUV = abs(uv.x * 2.0);
float line = smoothstep(drawWidth + lineAA, drawWidth - lineAA, lineUV);

像素宽度限制线条

这是第一个技巧。但第二个技巧才是最重要的。我们根据我们想要的厚度除以我们绘制的厚度来淡出线条。

float uvDeriv = fwidth(uv.x);
float drawWidth = max(lineWidth, uvDeriv);
float lineAA = uvDeriv * 1.5;
float lineUV = abs(uv.x * 2.0);
float line = smoothstep(drawWidth + lineAA, drawWidth - lineAA, lineUV);
line *= saturate(lineWidth / drawWidth); //

电话线 AA 线条

看起来不错!即使线条很细,你也能看到它的透视效果。而且在远处没有走样!

电话线 AA 线条

值得注意的是,这解决了当预期线条宽度为零时线条不会完全消失的问题!它会随着线条变得越来越细而逐渐淡出线条,就像它在后退到远处时一样,最终在达到零时完全淡出。

有了这个,让我们再次回到完整的网格。

float2 uvDeriv = fwidth(uv);
float2 drawWidth = max(lineWidth, uvDeriv); //
float2 lineAA = uvDeriv * 1.5;
float2 gridUV = 1.0 - abs(frac(uv) * 2.0 - 1.0);
float2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV);
grid2 *= saturate(lineWidth / drawWidth); //
float grid = lerp(grid2.x, 1.0, grid2.y);

Phone-wire AA Line Grid

好多了!… 差不多吧。看起来不太对。它在视界处淡出到黑色!提醒一下,基于纹理的网格淡出到灰色,而不是黑色。

RGSS Texture Grid vs Phone-wire AA Line Grid

问题在于,在一个网格中,一条线只能变粗到一定程度,然后它就会比整个网格单元宽度还要宽。当它是一条单独的线时,这不是问题。但当它被绘制成一个网格时,在它变黑的地方,一个像素比多个重叠的网格宽度还要宽。但我们仍然只在每个像素中绘制一组线条,而不是多个网格单元。

我在编写这些Shader时卡住很长时间的地方是接下来该做什么。我花了很多时间试图弄清楚如何正确计算淡化线条的值,但似乎没有什么能真正正确地解决它。我相信这是可以解决的,但请记住我之前说过我是艺术学校辍学?是的,我不会是那个弄清楚的人。我走这条路是因为我对数学不擅长,无法用“正确”的方式来做。

我在这条路上走得最接近的一次是尝试将我用来除以线条宽度的值钳制到最大值 1.0。我的理论是,如果线条不能比一个像素宽,就不要除以大于 1 的值。虽然这更好,但它仍然不正确。

grid2 *= saturate(lineWidth / max(1.0, drawWidth));

非常微妙,但这会导致在单独可区分的线条与视界处的大致纯色之间出现一个暗色的“沟壑”。

RGSS Texture Grid vs failed attempt example

如前所示,过滤后的脉冲串和盒式过滤网格确实解决了这个问题。不是通过精确地淡化线条,而是通过始终计算当前像素覆盖范围内所有可能线条的总覆盖率。但正如我所展示的,它们都没有正确处理这些线条的抗锯齿!再次说明,我是艺术学校辍学。我没有足够的知识来像他们那样做。

那么现在该怎么办?

Right At The Wrong Place

在经过几年的研究后,我仍然没有取得任何进展,最近我坐下来,试着更多地思考这个问题。为什么这段代码不起作用?它感觉应该起作用,那么我错过了什么?

好吧,事实证明我做的是正确的事情。我只是在代码中的错误位置做了。如果我限制了实际drawWidth,它就能起作用!

float2 uvDeriv = fwidth(uv);
float2 drawWidth = clamp(lineWidth, uvDeriv, 0.5); //
float2 lineAA = uvDeriv * 1.5;
float2 gridUV = 1.0 - abs(frac(uv) * 2.0 - 1.0);
float2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV);
grid2 *= saturate(lineWidth / drawWidth);
float grid = lerp(grid2.x, 1.0, grid2.y);

RGSS Texture Grid vs correctly clamped draw width

是的,摩尔纹更明显,但整体亮度终于正确了!

一个奇怪的事情是,我将绘制宽度钳制到 0.5,而不是 1.0。如果我使用 1.0,它在视界处会再次太暗。

float2 drawWidth = clamp(lineWidth, uvDeriv, 1.0);

RGSS Texture Grid vs failed attempt example

如果你想的是“好吧,也许你只需要在之前的尝试中使用 0.5?”不,那样太亮了!

grid2 *= saturate(lineWidth / max(0.5, drawWidth));

RGSS Texture Grid vs failed attempt example

为什么 0.5 是钳制绘制宽度的正确值?好吧,这与线条抗锯齿的工作方式有关。

如果我们看一下一些没有淡化代码的宽度限制线条。如果我们手动覆盖使用的uvDeriv,我们可以看到线条是如何随着它们远离相机而扩展和平滑的。

当限制到 0.5 的宽度时,如上所示,这意味着在 0.5(红线)之上和之下的区域面积相等。这意味着在uvDeriv为 0.5 时,整个垂直方向上的平均值为 0.5。

这个 0.5 的平均值意味着当我们淡出线条时,以及除以 0.5 时,我们是在除以我们知道这些像素的(平均)强度。

如果限制到 1.0 的宽度,我们会得到这个。

现在,在uvDeriv为 1.0 之后,任何地方都高于 0.5 的平均值,高于多少取决于uvDeriv的大小。但它也不是 1.0 的平均值!这一点很重要,因为淡化它的数学假设它是 1.0,导致它变得太暗,这就是我们在失败示例 2 中看到的现象。

如果我们限制线条宽度,而只限制我们用来除以的值,那么“0.5”点就会完全消失,因为它被网格单元的边缘切断了,这意味着平均亮度甚至高于 0.5,但仍然不是 1.0!这意味着如果我们只将淡化计算中我们用来除以的值钳制到 0.5,它就会保持太亮,这就是我们在失败示例 3 中看到的现象。

这可能是整个过程中最难解释的部分,所以如果它仍然让你感到困惑,我表示歉意。

It’s A Moiré(抑制干扰模式)

但是我们仍然有那些更明显的摩尔纹。这是因为我们仍然没有处理网格单元小于一个像素的情况。它会正确地平均到适当的值,但这并不是唯一的问题。而这就是我决定稍微作弊的地方。请记住,我的主要目标之一是尽可能地限制摩尔纹?好吧,这是一个我想从基于纹理的网格或甚至真实情况的外观中严重偏离的地方。它们总是会有一些摩尔纹伪影,因为这确实是观看精细网格时发生的事情。

所以,与其弄清楚如何用正确的方式计算所有数学运算,为什么不淡化到纯色呢?是的,我知道我之前批评过很多其他实现的这种做法,但我不会仅仅根据某个任意距离来淡化。我将根据我知道摩尔纹出现的时间来淡化。怎么做?很简单!使用我们已经在用于抗锯齿的相同的 UV 导数!

float2 uvDeriv = fwidth(uv);
float2 drawWidth = clamp(lineWidth, uvDeriv, 0.5);
float2 lineAA = uvDeriv * 1.5;
float2 gridUV = 1.0 - abs(frac(uv) * 2.0 - 1.0);
float2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV);
grid2 *= saturate(lineWidth / drawWidth);
grid2 = lerp(grid2, lineWidth, saturate(uvDeriv * 2.0 - 1.0)); //
float grid = lerp(grid2.x, 1.0, grid2.y);

RGSS Texture Grid vs Moire suppression

这里的想法是,一旦导数大于 1.0,网格单元就小于一个像素,这时摩尔纹会变得更加明显。所以当导数为 0.5 时,它就开始淡化到纯色,这时抗锯齿线条开始合并。当导数为 1.0 时,它就完成淡化。

就是这样!我关于“完美”网格Shader的六个要求都满足了!所以我们完成了,对吧?

Flipping Out(线条反转)

好吧,差不多吧。当你试图使网格线条的宽度大于 0.5 时会发生什么?什么都不会发生,因为我们将线条宽度钳制到 0.5。这显然是一个非常利基的用例,但从技术上讲,我只成功地满足了“0.0 或 1.0 应该显示完全隐藏或填充”要求的一半。线条宽度为 0.0 将完全隐藏该轴,但 1.0 将限制在 0.5 的宽度。但如果我们让线条的宽度超过 0.5,上面的数学运算就会变得很奇怪。

最后一个技巧是,我们实际上从未绘制过宽度超过半个网格宽度的线条。相反,如果线条宽度超过 0.5,我们会翻转一些东西,并有效地绘制白色背景上的黑色线条,并偏移半个网格宽度。这意味着大多数数学运算不需要改变。

float2 uvDeriv = fwidth(uv);
bool2 invertLine = lineWidth > 0.5; //
float2 targetWidth = invertLine ? 1.0 - lineWidth : lineWidth; //
float2 drawWidth = clamp(targetWidth, uvDeriv, 0.5); //
float2 lineAA = uvDeriv * 1.5;
float2 gridUV = abs(frac(uv) * 2.0 - 1.0);
gridUV = invertLine ? gridUV : 1- gridUV; //
float2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV);
grid2 *= saturate(targetWidth / drawWidth);
grid2 = lerp(grid2, targetWidth, saturate(uvDeriv * 2.0 - 1.0));
grid2 = invertLine ? 1.0 - grid2 : grid2; //
float grid = lerp(grid2.x, 1.0, grid2.y);

还有件事(偏导数长度)

我对这个Shader做了一个最后的非常小的调整。那就是我没有使用fwidth()fwidth()函数是获取导数长度的近似值。该函数看起来像这样:

float fwidth(float a)
{
  return abs(ddx(a)) + abs(ddy(a));
}

这不是计算长度的正确方法。当东西与屏幕轴对齐时,它足够准确,但在对角线上,它们总是会太宽。计算导数长度的正确方法是这样的:

float ddLength(float a)
{
  return length(float2(ddx(a), ddy(a)));
}

还是这样?Inigo Quilez 在他关于棋盘格过滤(https://iquilezles.org/articles/checkerfiltering/)的文章中认为,正确的方法是获取导数的绝对最大值。

float ddMax(float a)
{
  return max(abs(ddx(a), abs(ddy(a)));
}

好吧,让我们比较一下,看看哪个看起来更好。这将需要非常近距离地放大,因为差异很小。

导数长度计算的比较

在这里,我认为length()选项是正确的。它在锐度和抗锯齿之间取得了平衡,与其他两个选项相比。应该注意的是,fwidth()从来就不是为了正确性而设计的,它只是一个快速的近似值。而且它确实更快,但对于现代 GPU 来说,差异可以忽略不计。max()方法也不“错误”,只是对于这个用例来说是错误的。Inigo Quilez 的可过滤程序的工作方式与这个Shader不同,因此它很可能对那个用例来说是正确的。虽然他的 Shader Toy 示例都使用了稍微不同的计算,并添加了一个任意的修正因子,所以也许它对那个用例来说也不正确?

最终,这在很大程度上取决于主观感受,哪个看起来最好,max()方法与fwidth()一样便宜,同时可能是一个稍微更好的近似值。而且,通过实际尝试并进行直接比较,始终要检查你对这类事情的假设。

但是,有了最后的调整,代码看起来像这样:

float4 uvDDXY = float4(ddx(uv), ddy(uv)); //
float2 uvDeriv = float2(length(uvDDXY.xz), length(uvDDXY.yw)); //
bool2 invertLine = lineWidth > 0.5;
float2 targetWidth = invertLine ? 1.0 - lineWidth : lineWidth;
float2 drawWidth = clamp(targetWidth, uvDeriv, 0.5);
float2 lineAA = uvDeriv * 1.5;
float2 gridUV = abs(frac(uv) * 2.0 - 1.0);
gridUV = invertLine ? gridUV : 1.0 - gridUV;
float2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV);
grid2 *= saturate(targetWidth / drawWidth);
grid2 = lerp(grid2, targetWidth, saturate(uvDeriv * 2.0 - 1.0));
grid2 = invertLine ? 1.0 - grid2 : grid2;
float grid = lerp(grid2.x, 1.0, grid2.y);

Pristine Grid

让我们将它与其他看起来最好的选项进行比较,即基于纹理的网格和盒式过滤网格。

Pristine Grid

RGSS Texture Grid artifacts

Box Filtered Grid artifacts

结论

现在,我们终于有了最平滑、最没有走样、摩尔纹最少、最纯粹的网格Shader。在我看来,它在视觉上超越了基于纹理的网格,以及之前最好的选项。

至少在 Quilez 先生写出超越它的Shader之前是这样?。

其他想法

比线条更宽的抗锯齿

是的,抗锯齿像素宽度比最小线条像素宽度更宽。这确实会稍微降低某些情况下线条的最大亮度。但不会以任何可感知的方式。更宽的抗锯齿弥补了任何感知的亮度损失。它实际上只在像素正好落在线条中心的罕见情况下才能测量出来,而这种情况并不常见。

这些smoothstep()线条看起来更暗了吗?

RGSS Texture Grid 中的走样

有些人注意到本文中基于纹理的网格示例比他们预期的走样要多一些。这在一定程度上是 RGSS Shader本身的缺陷。因为它使用旋转网格进行超采样,所以如果边缘与这些样本对齐,它就会出现一些问题。4x MSAA 也有这个问题,而 RGSS Shader使用的是相同的样本模式。像这样的透视网格是 RGSS 和 4x MSAA 最难处理的东西之一,因为一定会有几条网格线受到影响。

由于 4x MSAA 模式存在这个问题,因此在使用几何体或硬件线条网格时,抗锯齿网格也很难处理,因为 4x MSAA 是最常用的。

但是,这也是使用各向异性过滤本身的伪影,它在 GPU 上是近似的。每家供应商甚至每个世代之间的近似方法都不相同。而使用的确切方法似乎是每家公司之间严格保守的秘密。但通常,它们倾向于在少数 mip 偏差级别上尽可能少地进行额外的纹理采样。如果你有兴趣了解更多关于这方面的信息,请搜索 EWA 或 FELINE。简而言之,即使没有 RGSS,也会出现一些走样。

这两个示例之间的主要区别在于线条保持清晰的距离,以及一些垂直线条上的模糊度略低。

由于各向异性过滤的实现取决于 GPU,因此使用它时出现的走样程度也会有所不同。例如,在 Nvidia GTX 970、RTX 2080 Super 和 RTX 3080 上,它看起来略有不同。

Kaiser 滤波器

你可能想知道 Kaiser 滤波器在这个网格上的效果如何。它比默认的盒式滤波器更好吗?甚至比这个新的Shader更好吗?

不。它看起来与盒式过滤基本相同,但稍微亮一些,因为 mipmap 的平均强度不再与之前的 mipmap 相同。Kaiser 滤波器在大多数情况下正面观看时有助于缩小,但在更多斜角观看时效果不佳。细的黑白线条也是 Kaiser 滤波器的最坏情况。

而且,我不会给你一个示例来进行比较。;)

Shader源码(Unity+UE)

这是一个 ShaderToy 中的 GLSL 实现示例,特别是如果你想要一个动态的示例。
https://www.shadertoy.com/view/mdVfWw

Unity Shader示例PristineGrid.shader:
https://pan.quark.cn/s/e48012a62254

Unreal 材质函数图示例。
https://blueprintue.com/blueprint/_s_ms69e/

更复杂的表面和 UV

这个Shader实现方式的一个好处是,它不局限于平面和规则网格。它可以推广到更复杂的几何体,而无需修改。对于不同的 UV 布局,只需要进行少量的修改,主要是处理线条宽度和导数不连续性。

这是一个在凹凸不平的地形上看起来不太好的Shader。

地形上的纯粹网格

这是一个使用相同技术的修改后的PristineRadialGrid.shader:
https://pan.quark.cn/s/e48012a62254
使用的是径向 UV,以及导数不连续性修复。

还有一个另一个版本PristineTriplanarGrid.shader:
https://pan.quark.cn/s/e48012a62254
支持三平面映射。
(Unreal Engine 材质函数版本在这里
(https://blueprintue.com/blueprint/0tm1_z62/))

还有最常被问到的功能,所以这里有一个PristineMajorMinorGrid.shader
(https://pan.quark.cn/s/e48012a62254),
它具有独立的主要网格划分和轴线。


如果喜欢今天的文章,请多点点赞在看,后续就会有更多此类的文章~

相关推荐

“版本末期”了?下周平衡补丁!国服最强5套牌!上分首选

明天,酒馆战棋就将迎来大更新,也聊了很多天战棋相关的内容了,趁此机会,给兄弟们穿插一篇构筑模式的卡组推荐!老规矩,我们先来看10职业胜率。目前10职业胜率排名与一周前基本类似,没有太多的变化。平衡补丁...

VS2017 C++ 程序报错“error C2065:“M_PI”: 未声明的标识符"

首先,程序中头文件的选择,要选择头文件,在文件中是没有对M_PI的定义的。选择:项目——>”XXX属性"——>配置属性——>C/C++——>预处理器——>预处理器定义,...

东营交警实名曝光一批酒驾人员名单 88人受处罚

齐鲁网·闪电新闻5月24日讯酒后驾驶是对自己和他人生命安全极不负责的行为,为守护大家的平安出行路,东营交警一直将酒驾作为重点打击对象。5月23日,东营交警公布最新一批饮酒、醉酒名单。对以下驾驶人醉酒...

Qt界面——搭配QCustomPlot(qt platform)

这是我第一个使用QCustomPlot控件的上位机,通过串口精确的5ms发送一次数据,再将读取的数据绘制到图表中。界面方面,尝试卡片式设计,外加QSS简单的配了个色。QCustomPlot官网:Qt...

大话西游2分享赢取种族坐骑手办!PK趣闻录由你书写

老友相聚,仗剑江湖!《大话西游2》2021全民PK季4月激燃打响,各PK玩法鏖战齐开,零门槛参与热情高涨。PK季期间,不仅各种玩法奖励丰厚,参与PK趣闻录活动,投稿自己在PK季遇到的趣事,还有机会带走...

测试谷歌VS Code AI 编程插件 Gemini Code Assist

用ClaudeSonnet3.7的天气测试编码,让谷歌VSCodeAI编程插件GeminiCodeAssist自动编程。生成的文件在浏览器中的效果如下:(附源代码)VSCode...

顾爷想知道第4.5期 国服便利性到底需优化啥?

前段时间DNF国服推出了名为“阿拉德B计划”的系列改版计划,截至目前我们已经看到了两项实装。不过关于便利性上,国服似乎还有很多路要走。自从顾爷回归DNF以来,几乎每天都在跟我抱怨关于DNF里面各种各样...

掌握Visual Studio项目配置【基础篇】

1.前言VisualStudio是Windows上最常用的C++集成开发环境之一,简称VS。VS功能十分强大,对应的,其配置系统较为复杂。不管是对于初学者还是有一定开发经验的开发者来说,捋清楚VS...

还嫌LED驱动设计套路深?那就来看看这篇文章吧

随着LED在各个领域的不同应用需求,LED驱动电路也在不断进步和发展。本文从LED的特性入手,推导出适合LED的电源驱动类型,再进一步介绍各类LED驱动设计。设计必读:LED四个关键特性特性一:非线...

Visual Studio Community 2022(VS2022)安装图文方法

直接上步骤:1,首先可以下载安装一个VisualStudio安装器,叫做VisualStudioinstaller。这个安装文件很小,很快就安装完成了。2,打开VisualStudioins...

Qt添加MSVC构建套件的方法(qt添加c++11)

前言有些时候,在Windows下因为某些需求需要使用MSVC编译器对程序进行编译,假设我们安装Qt的时候又只是安装了MingW构建套件,那么此时我们该如何给现有的Qt添加一个MSVC构建套件呢?本文以...

Qt为什么站稳c++GUI的top1(qt c)

为什么现在QT越来越成为c++界面编程的第一选择,从事QT编程多年,在这之前做C++界面都是基于MFC。当时为什么会从MFC转到QT?主要原因是MFC开发界面想做得好看一些十分困难,引用第三方基于MF...

qt开发IDE应该选择VS还是qt creator

如果一个公司选择了qt来开发自己的产品,在面临IDE的选择时会出现vs或者qtcreator,选择qt的IDE需要结合产品需求、部署平台、项目定位、程序猿本身和公司战略,因为大的软件产品需要明确IDE...

Qt 5.14.2超详细安装教程,不会来打我

Qt简介Qt(官方发音[kju:t],音同cute)是一个跨平台的C++开库,主要用来开发图形用户界面(GraphicalUserInterface,GUI)程序。Qt是纯C++开...

Cygwin配置与使用(四)——VI字体和颜色的配置

简介:VI的操作模式,基本上VI可以分为三种状态,分别是命令模式(commandmode)、插入模式(Insertmode)和底行模式(lastlinemode),各模式的功能区分如下:1)...

取消回复欢迎 发表评论: