在2D中行进光线柔和阴影

2020-09-24 02:48:54

免责声明:此页面上的演示使用的WebGL功能在某些移动设备上不可用。

几周前,我在推特上发布了一个玩具图形项目的视频(见下图)。虽然还没有做完,但是很多人都很喜欢,很惊喜也很有趣!一些人问它是如何工作的,所以这就是这篇帖子的主题。

在引擎盖下面,它使用一种叫做距离场的东西。距离场是一个图像,如下图所示,它告诉您每个像素离您的形状有多远。浅灰色像素接近形状,深灰色像素远离形状。

当演示启动时,它在2D画布上绘制一些文本并生成其距离场。它使用了我编写的一个库,可以非常快速地生成距离场。如果你好奇图书馆是怎么运作的,我在这里写过。

我们的照明方案是这样工作的:在处理特定像素时,我们考虑从它到光线的光线,例如…。

如果光线与字形相交,我们着色的像素一定在阴影中,因为在它和光线之间有一些东西。

要检查这一点,最简单的方法是以1px为增量沿光线移动,从我们着色的像素开始,在灯光处结束,反复询问距离场我们与形状的距离是否为0。这会奏效的,但会很慢。

我们可以选择一些特定的长度,如30px,然后以该大小为增量移动,但是我们可能会跳过小于30px的字形。我们可能会认为我们本应处于阴影中,但我们并没有处于阴影中。

Ray Marching的核心思想是:距离字段告诉您距离最近的字形有多远。您可以安全地沿着光线前进该距离,而无需跳过任何字形。

让我们来演示一个例子。我们按照上图开始,询问距离场我们离任何字形有多远。在这种情况下,结果是95px(如左图所示)。这意味着我们可以沿着光线移动95px,而不会跳过任何东西!

现在我们离光更近了一点。我们重复这个过程,直到我们到达b的上升点!如果没有b字形,我们就会一直往前走,直到撞上灯。

下面的演示展示了给定像素的光线行进步骤。红色方框是我们要着色的像素,沿光线的每个圆表示光线行进步长以及该步长与场景的距离。

下面是实现此技术的GLSL。它假定您已经定义了对距离场进行采样的函数getDistance。

Ve2 rayOrigin=...;ve2 rayDirection=...;浮动rayProgress=0;而(True){if(rayProgress>;Distance(rayOrigin,lightPosition)){//我们撞到灯了!此像素不在阴影中。Return 1.;}Float sceneDist=getDistance(rayOrigin+rayProgress*rayDirection);if(sceneDist<;=0.){//我们遇到了形状!此像素处于阴影中。返回0;}rayProgress+=sceneDist;}。

事实证明,有些像素的处理成本确实很高。因此,在实践中,我们使用for-循环而不是while循环-这样,如果我们做了太多的步骤,我们就会跳出困境。光线行进中的一种常见“缓慢情况”是光线平行于场景…中的形状的边缘。

到目前为止,我描述的方法将为您提供如下所示的场景。

很酷,但是阴影很锐利,看起来不太好。演示中的阴影看起来更像这个…。

一个很大的免责声明是,它们在物理上是不现实的!真实阴影看起来像边缘已模糊化的硬阴影。这种方法所做的事情略有不同:以前处于阴影中的所有像素仍然完全处于阴影中。我们刚刚在它们周围添加了部分阴影像素的半影。

优点是它们计算起来又漂亮又快,这就是我所关心的!在计算它们时涉及到三个“规则”。

规则1:光线与形状相交的距离越近,其像素的阴影就越大。在下图中,有两条相似的射线(它们与用黄色和绿色绘制的形状的距离)。我们希望离拐角更近的那个更有阴影。

计算成本很低,因为变量scenedist告诉我们,在每个光线行进步骤中,我们距离最接近的形状有多远。因此,对于上图中的黄色和绿色线条,所有步骤中的sceneDist的最小值是一个很好的近似值。

规则2:如果我们着色的像素距离它几乎与形状相交的点很远,我们希望阴影扩散得更远。

考虑沿着上面的射线的两个像素。一个更靠近几乎是十字路口,也更轻(它的距离是绿线)。另一个更远、更暗(它的距离是黄线)。一般而言:像素离其几乎相交的距离越远,我们应该使其越“处于阴影中”。

计算成本很低,因为变量rayProgress是上图中绿色和黄色线条的长度。

所以:我们之前为不在阴影中的像素返回了1.0。为了实现规则1和2,我们计算每个光线行进步骤上的scenedist/rayProgress,跟踪其最小值,然后返回该值。

Vector 2 rayOrigin=...;ve2 rayDirection=...;浮动rayProgress=0。;浮动stopAt=Distance(samplePt,lightPosition);Float lightContribution=1。;for(int i=0;i<;64;i++){if(rayProgress>;stopAt){return lightContribution;}//`getDistance`对距离场纹理进行采样。Float sceneDist=getDistance(rayOrigin+rayProgress*rayDirection);if(sceneDist<;=0){//我们撞到形状了!此像素处于阴影中。Return 0.;}lightContribution=min(lightContribution,scenedist/rayProgress);rayProgress+=scenedist;}//光线行进超过64步!返回0。;

这个比率对我来说有点神奇,因为它不符合任何物理价值。因此,让我们通过思考为什么它可能具有特殊的价值,来为它建立一些直觉,…。

如果sceneDist/rayProgress>;=1,则scenedist较大或rayProgress较小(相对于彼此)。在前一种情况下,我们远离任何形状,也不应该处于阴影中,因此灯光值为1是有意义的。在后一种情况下,我们正在阴影的像素非常接近投射阴影的对象,并且阴影还不是模糊的,因此光度值为1是有意义的。

仅当sceneDist为0时,该比率才为0。这对应于与对象相交且其像素在阴影中的光线。

规则3是最直截了当的:你离它越远,光线就越弱。

我们不是逐字返回scenedist/rayProgress的最小值,而是将其与距离因子相乘,距离因子紧挨着灯光为1,远离灯光为0,并且随着您离开灯光而平方变小。

Vec2 rayOrigin=...;Vec2 rayDirection=...;Float rayProgress=0。;Float stopAt=Distance(samplePt,lightPosition);Float lightContribution=1。;for(int i=0;i<;64;i++){if(rayProgress>;stopAt){//我们撞上了灯!FLOAT LIGHT_RADIUS_PX=800.;//灯光旁边的fadeRatio为1.0,为0。在//LIGHT_RADIUS_PX之外。浮动fadeRatio=1。0-CLAMP(stopAt/light_Radius_px,0.,1.);//我们希望灯光以平方方式衰减,而不是//以//线性方式衰减。Float Distancefactor=power(fadeRatio,2.);return lightContribution*DistanceFactor;}//`getDistance`对距离场纹理进行采样。Float sceneDist=getDistance(rayOrigin+rayProgress*rayDirection);if(sceneDist<;=0){//我们撞到形状了!此像素处于阴影中。Return 0.;}lightContribution=min(lightContribution,scenedist/rayProgress);rayProgress+=scenedist;}//光线行进超过64步!返回0。;

我忘了我是在哪里找到这种柔和阴影技术的,但我绝对没有发明它。Inigo Quilez在它上面有一个很棒的帖子,在那里他谈到了在3D中使用它。

Inigo的帖子还谈到了这种方法的一个问题,您可能已经在上面的演示中注意到了:它会导致条带状的工件。这是因为规则1假设所有步骤上的sceneDist的最小值是光线到场景距离的最佳近似值。这并不总是正确的,因为我们有时走的射线步数很少。

因此,在我的演示中,我使用了Inigo在他的帖子中写到的改进的近似值。我还使用了另一个更有效但性能较差的技巧:不是在每个光线行进步骤中按sceneDist前进,而是前进类似于sceneDist*随机抖动,其中随机抖动介于0和1之间。

这改进了近似值,因为我们在光线行进中添加了更多的步数。但是我们可以通过向场景方向前进来做到这一点。随机抖动确保彼此相邻的像素不会出现在同一波段。这使得结果有点模糊,这不是很好。但我觉得看起来比捆绑…要好。这是演示中我仍然不满意的一个方面,所以如果您对如何改进有什么想法,请告诉我!

总体而言,我的演示有一些额外的调整,我将来可能会写到,但这是它的核心。谢谢你的阅读!如果你有问题或意见,请在推特上告诉我。

感谢Jessica Liu,Susan Wang,Matt Nichols和Kenrick Rilee对本帖子早期草稿的反馈!另外,如果你喜欢这个帖子,你可能会喜欢和我一起在Figma工作!