周期空间:用周期函数重做 SDF 域重复

我最喜欢的SDF技术之一,就是“域重复”:
这是一项极佳的派对小技巧,能够让你实时渲染无限多的几何形状,并且还能呈现出柔和的阴影、 环境遮挡,以及SDF所具备的种种精美效果。 当我第一次看到这项技术时,简直觉得像魔法一样——而如今,它依然让我感到神奇——只不过是以一种截然不同的方式。
之所以能做到这一点,关键在于:你并不是在“评估无限多的几何形状”。每当一束光线穿过场景时,它只会一次只评估一个形状。
让我们先来看一下这个场景的二维切片:
说实话,这真的让我有点吓到。不过,我们不妨先看一个更简单的二维场景,相信我,这个原理同样可以推广到更复杂的几何形状上:
假设那是一个三维场景的二维切片。想象一下,我们其实正试图渲染这样一个场景:
要渲染一个三维场景,我们需要从相机出发,以不同的角度发射光线,直到光线击中某个物体。不过,在二维空间中,我们更容易直观地理解这一过程:
然而,在渲染SDF时,我们并不会像这样持续不断地发射光线——光线并不会平滑地向前推进, 直到击中某个形状。由于我们已经知道每个点处的距离场信息,因此我们可以利用这些信息来决定自己该向前推进多远。 真正的光线漫游看起来更像是这样:
……只不过,你知道的,速度更快。 那个圆的半径,正是光线行进过程中每一点处的距离场值。距离值表示的是“与最近的形状之间的距离”,而在光线漫游的过程中,你可以将它理解为:“在碰到任何方向的障碍物之前,我能移动多远?”当我们在三维空间中进行光线漫游时,它会为你提供当前光线位置周围最大空隙球的半径。
之所以能实现域重复,是因为在计算无限多形状的距离场时,我们实际上每次只关注一个形状。 这里并不是“与最近的形状之间的距离”,而是“与我所在‘单元格’中的形状之间的距离”。
也就是说,当我们重复这一操作时,实际上会将场景分割成一个个“单元格”,并只检查“当前单元格”:
而且,这种做法确实有效。对于那些相当规则、对称的几何形状来说,这种方法非常奏效。
不过,域重复的乐趣之一在于:你并不一定要使用规则的几何形状。你可以用任意不规则的形状,或者根据其所在的单元格而变化的形状来实现这一功能。
事实上,在所有这些例子中,我都一直在这么做:星星的颜色会根据它们所在的单元格坐标而有所不同。当然,我们也可以改变它们的形状:
嘿,看!我们遇到了一个问题。如果你仔细观察这条光线的漫游过程,就会发现它实际上越过了目标, 最终竟然来到了一颗恒星的中央。得益于有符号距离函数的强大能力,它能够意识到自己已经进入了某个形状内部(距离场为负), 于是光线漫游器随即向后退去,直到找到边界为止。
这一现象揭示了有符号距离函数的一个有趣特性,但真正重要的其实是:我们对无限距离的神奇近似, 其实并没有那么神奇。我们并非总能像这样顺利“退避”:如果运气稍差,糟糕的距离场甚至可能让我们彻底“越界”而无法返回。
当我们使用这一技术来渲染三维场景时,视觉上的瑕疵会变得十分明显:
因为只有部分像素会超出它们的预期目的地。
而我们可以通过不仅评估当前单元格,还要评估当前单元格及其相邻的邻近单元格来缓解这个问题。 你的距离函数的计算成本会升高——现在,你每次漫游都需要评估你的形状N次——但为了获得无限的效率, 这样的代价微不足道。
这是完全相同的场景,只不过我们不再只评估最近的单元格,而是评估了八个最近的单元格:
瑕疵消失了,但我的笔记本电脑现在已经热得不行了。
好了,这就是经典的域重复:它是一种离散的操作。我们有一个离散的“单元格”或“瓦片”的概念,其坐标为整数,而评估过程则基于这些离散单元格的多个实例。
太棒了。 背景介绍:已解释完毕。 现在,我们就可以开始撰写这篇博客文章了。 周期性空间
这里有一颗恒星:
那里只有一颗恒星。它是一颗普通的恒星。
这是我们的输入变量x坐标(横轴)与我们渲染出的x坐标(纵轴)的曲线图。
这是一个恒等函数,因为我们目前还没有做任何奇怪的事情。 接下来,我们来做一件“奇怪”的事情。 对于每一个我们要渲染的像素,我们不再使用该点恒星的颜色,而是可以使用不同点恒星的颜色。一个很简单的例子就是简单地对x坐标进行缩放: 我们看到的效果是拉伸效应:看起来就像我们对恒星进行了缩放。但实际上,我们只是对渲染出的恒星图像进行了缩放。这就好比我们把一颗恒星放在扫描仪上,然后在扫描时将其沿着玻璃滑动。我们会得到一张看起来被拉伸的图像——就像这样。 许多SDF组合器都是这样工作的。如果你想把恒星向右移动十像素,实际上你需要在十像素左侧的点上评估恒星。 不过请注意:我们可以对输入进行任何我们想要的操作。这只是一个表达式,我们可以编写任何自己想写的表达式。 我们还可以在随机的x坐标上进行评估: 我们也可以使用平滑变化、半随机偏移量来进行评估: 我们甚至可以天马行空,让x同时依赖于x和y: 或者,我们也可以回到这篇博客文章的起点,用一个周期性函数来评估x。如果我们取x % 100,会发生什么呢? 嗯,没错。这看起来不太好,因为恒星的中心位于零点。让我们试试另一种模运算方式,让输出的中心也位于零点: 啊哈!我们重新创造了在博客文章开头用来重复空间的瓦片函数。现在,我们x坐标的曲线图看起来就像锯齿波。它具有不连续性——一旦我们到达一个周期的终点,就会直接跳转到下一个周期。 这种尖锐的不连续性在二维空间中并不重要。不过,让我们来看看同一场景被挤出到三维空间后的效果: 恒星的尖端周围会出现一些视觉瑕疵:有时光线会越界,因为这种重复方式并不能生成正确的距离场。(你也可以点击图片右上角的磁铁图标,以获得另一种可视化方式,让这些瑕疵更加清晰地显现出来。) 通过观察这个场景的距离场梯度,我们可以发现距离场的效果并不理想:
注意,当波形的每个周期之间出现尖锐的线条时,等高线却无法完美对齐(如果你点击暂停旋转,就能更清楚地看到这一点)。如果我们使用Bauble内置的多采样重复运算符,就能看到距离场原本应该是什么样子: 然而,我们用锯齿波模重复的方式,看起来却并非如此。我们看不到两颗恒星之间的复杂距离场,因为我们从未对两颗恒星进行过评估。我们只是在不同的坐标上评估了唯一的一颗恒星。 所以,这种锯齿波并不是特别出色——尽管这是一种很有意思的方式来解读(天真)的瓦片运算符。 我们再试一种周期性函数。如果改用三角波呢? 对此有一个直观的解释:当你到达扫描仪的尽头时,不要重新开始,而是反向扫描,向后扫描。于是我们一边“扫描”恒星的左右两侧,另一边的效果就是让每隔一颗恒星都呈现镜像。 令我惊讶的是,这种方式居然能生成正确的距离场: 注意,每当周期发生变化时,等高线都会完美对齐。这意味着,我们真的可以在三维空间中完美地进行光线漫游,既没有视觉瑕疵,也没有额外的计算开销: 当然,这并不是我们之前渲染的场景——一半的恒星都被翻转了——但即便如此,如果你在三维空间中进行瓦片化处理,这相比多采样评估,足足快了8倍。 现在,这种效果早已广为人知,你同样可以将它应用到经典的实例化重复中。如果你想重复一个不对称的形状,只需让整个距离场对称起来——通过将每两个实例镜像对待,就能实现这一点。不过,直到我以这种空间周期性函数的形式看待它时,我才真正领悟到它的魅力。 那么,正弦波又如何呢? 这个效果就有点怪了。它并不是一个正确的距离场:等高线之间的距离并不相等,因为空间在每个瓦片的边缘附近会经历非线性的拉伸。 不过,我们仍然可以用最少的瑕疵完成光线漫游: 能够实现这样“平滑的瓦片化”效果,真是令人欣喜。随着周期的变化,各个形状会自然地融合在一起,而不是被简单地切割开来: 在全三维空间中,这种效果也非常迷人: 你也可以将这个技巧运用到径向对称的场景中。Bauble内置的径向对称运算,采用了与瓦片运算相同的一种离散方法: 在每个周期的边缘,出现了瑕疵,还有尖锐的线条。 不过,如果我们自己编写径向对称运算,就可以把锯齿波换成正弦波: 还挺有趣的。 这是一种很有用的空间扭曲效果,而且探索这样的周期性空间也十分有趣。我认为,尤其是在用廉价的方式近似实现平滑融合的重复表面时,这种效果有着独特的价值——这也是你通过多采样重复可以实现的另一种效果:你不必取所有相邻单元格的最小值,而是可以采用平滑的联合运算——但仅仅通过给它加上一个正弦波,就能带来显著的性能提升。 不过,这种技术的威力不如经典的实例化域重复。首先,我们始终在重复同一个形状。虽然我们本可以基于所处的周期,自定义“单元格坐标”的概念,并在用三角波扫描时不断变换形状,但这样一来,我们就会失去当初让三角波如此吸引人的对称性。 还有一个限制尚未在我们所有的例子中显现:使用经典的实例化重复,你可以选择不仅采样最相邻的单元格。这使得你能够重复那些并非由单一单元格界定的形状。 举个例子,这里有一颗三角形,以径向方式铺满原点周围: 由于我们只评估空间的“一片”,三角形会在跨越两个单元格边界的地方被裁剪。通过同时评估最近的相邻片,我们就能改善这一问题: 不过,它仍然会被裁剪。让我们来评估这两个相邻的片: 更好了!不过,仍然不够完美。让我们再评估这两个相邻的片,以及紧邻它们的相邻片: 这种效果可不是纯粹的空间周期性函数所能实现的:根本不可能存在一个包含五个不同三角形的片。 不过,即便如此……我们还应该尝试哪些其他的周期性函数呢?