快速简便的体绘制算法


我最近写了一个小的ShaderToy,可以进行简单的体积渲染,然后决定发表一篇说明其工作的文章。可以在此处查看交互式ShaderToy本身如果您正在阅读手机或笔记本电脑,建议您观看快速版本。我在帖子中包含了一些代码片段,这些片段可以帮助您全面了解ShaderToy的性能,但其中没有全部细节。如果您想更深入地研究,建议使用ShaderToy代码进行检查。

我的ShaderToy有三个主要任务:

  1. 实时执行
  2. 简单
  3. 身体上的正确性(...或类似的东西)

我将从这个空白代码场景开始。我不会详细介绍实现,因为它不是很有趣,但是我将简要地告诉您我们从哪里开始:

  1. 不透明对象的光线跟踪。所有对象都是具有与光线(1个平面和3个球体)的简单交点的图元
  2. 为了计算照明,使用了Phong阴影,在三个球形光源中,使用了自定义的光衰减系数。不需要阴影线,因为我们仅照亮平面。

看起来是这样的:

ShaderToy屏幕截图

我们将体积渲染为与不透明场景混合的单独通道。这类似于所有实时渲染引擎分别处理不透明和半透明表面的方式。

第1部分:模拟音量


但是首先,在我们开始体积渲染之前,我们需要相同的体积!为了模拟音量,我决定使用带符号的距离函数(SDF)。为什么要精确地定义距离场的功能?因为我不是艺术家,但是他们允许您用几行代码创建非常自然的形式。我不会详细讨论带有标志的距离的功能,因为Inigo Kiles已经很好地解释了它们。如果你是好奇,然后还有就是迹象距离和改性剂的不同FUNC-蒸发散的大名单。而这里是关于这些raymarching自卫队的另一篇文章。

让我们从一个简单的开始,并在此处添加一个球体:

ShaderToy屏幕截图

现在,我们将添加另一个球体,并使用平滑共轭合并这些球体的距离函数。我直接从Inigo页面获取了此代码,但为清楚起见,我将其插入此处:

// Taken from https://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float sdSmoothUnion( float d1, float d2, float k ) 
{
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) - k*h*(1.0-h); 
}

平滑配对是一种非常强大的工具,因为您只需将其与一些简单的形状组合即可获得非常有趣的东西。这是我的许多平滑共轭球的外观:

ShaderToy屏幕截图

因此,我们得到了水滴状的东西,但是我们需要的更像是云而不是水滴。 SDF的一大特色是,只需在SDF中添加一点噪声即可使表面变形。因此,让我们使用位置索引噪声函数来添加一些分形布朗运动(fBM)开销。 Inigo Kiles在一篇有关FBM噪声的出色文章中也谈到了这个话题。叠加了fBM噪声的图像如下所示:

ShaderToy屏幕截图

精细!多亏了fBM噪音,这个物体突然看起来变得更加有趣!

现在,我们需要创建一种幻想,即体积与地球平面相互作用。为此,我将有符号平面的距离增加到略低于接地平面的距离,然后重新使用平滑配对与非常积极的配对值(参数k)的组合。之后,我们得到了这张照片:

ShaderToy屏幕截图

最后一点将是fBM噪声的xz指数随时间变化,因此音量看起来像是旋转的雾。在移动中,它看起来非常好!

ShaderToy屏幕截图

太好了,我们有点像云!SDF计算代码也非常紧凑:

float QueryVolumetricDistanceField( in vec3 pos)
{    
    vec3 fbmCoord = (pos + 2.0 * vec3(iTime, 0.0, iTime)) / 1.5f;
    float sdfValue = sdSphere(pos, vec3(-8.0, 2.0 + 20.0 * sin(iTime), -1), 5.6);
    sdfValue = sdSmoothUnion(sdfValue,sdSphere(pos, vec3(8.0, 8.0 + 12.0 * cos(iTime), 3), 5.6), 3.0f);
    sdfValue = sdSmoothUnion(sdfValue, sdSphere(pos, vec3(5.0 * sin(iTime), 3.0, 0), 8.0), 3.0) + 7.0 * fbm_4(fbmCoord / 3.2);
    sdfValue = sdSmoothUnion(sdfValue, sdPlane(pos + vec3(0, 0.4, 0)), 22.0);
    return sdfValue;
}

这只是渲染一个不透明的对象。我们需要一朵美丽的壮丽的雾!

我们如何以体积而不是不透明对象的形式呈现它?让我们先谈谈我们模拟的物理。体积是空间中特定区域中的大量粒子。当我说“巨大”时,我的意思是“巨大”。如此之多,以至于当今对这些粒子中的每一个进行建模都是一项不可能的任务,即使对于离线渲染也是如此。火,雾和云就是很好的例子。严格来说,一切都是体积,但是为了计算的速度,我们更容易对此视而不见,而假装不是。我们将这些粒子的累积表示为密度值,通常存储在某种3D网格中(或更复杂的东西,例如在OpenVDB中)。

当光穿过某个体积时,当光与粒子碰撞时会发生一对现象。它可以散射并向另一个方向传播,或者部分光可以被粒子吸收并溶解。为了满足实时执行要求,我们将执行所谓的单次散射。这意味着:当光与粒子碰撞并飞向相机时,我们将假设光仅散射一次。也就是说,我们将无法模拟多重散射(例如雾)的影响,在这种情况下,远处的对象通常看起来更加模糊。但是对于我们的系统而言,这已经足够了。这是光线散射时的单次散射的样子:

ShaderToy屏幕截图

它的伪代码如下所示:

for n steps along the camera ray:
   Calculate what % of your ray hit particles (i.e. were absorbed) and needs lighting
   for m lights:
      for k steps towards the light:
         Calculate % of light that were absorbe in this step
      Calculate lighting based on how much light is visible
Blend results on top of opaque objects pass based on % of your ray that made it through the volume

也就是说,我们正在处理复杂度为O(n * m * k)的计算。因此,GPU将必须努力工作。

我们计算吸收


首先,让我们看一下沿相机光束的体积中的光吸收(即,我们现在还不执行沿光源方向的光线marching)。为此,我们需要执行两个操作:

  1. 在体积内执行光线行进
  2. 计算每一步的吸收/照明

为了计算每个点吸收多少光,我们使用了布格–兰伯特–比尔定律,该定律描述了穿过材料时光的衰减。计算非常简单:

float BeerLambert(float absorptionCoefficient, float distanceTraveled)
{
    return exp(-absorptionCoefficient * distanceTraveled);
}

吸收系数是材料参数。例如,在透明体积中(例如在水中),该值将较低;对于较稠的东西(例如,牛奶),该系数将较高。

为了进行体积射线行进,我们只需沿光束采取固定大小的台阶,并在每一步都获得吸收。您可能不明白为什么要采取固定的步骤而不是更快地执行某些步骤,例如跟踪球体,但是如果您记得体积内的密度是异质的,那么一切都会变得清晰。下面是光线行进和累积吸收代码。一些变量不在此代码段的范围内,因此请检查ShaderToy中的完整实现。

float opaqueVisiblity = 1.0f;
const float marchSize = 0.6f;
for(int i = 0; i < MAX_VOLUME_MARCH_STEPS; i++) {
	volumeDepth += marchSize;
	if(volumeDepth > opaqueDepth) break;
	
	vec3 position = rayOrigin + volumeDepth*rayDirection;
	bool isInVolume = QueryVolumetricDistanceField(position) < 0.0f;
	if(isInVolume) 	{
		float previousOpaqueVisiblity = opaqueVisiblity;
		opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
		float absorptionFromMarch = previousOpaqueVisiblity - opaqueVisiblity;
		for(int lightIndex = 0; lightIndex < NUM_LIGHTS; lightIndex++) {
			float lightDistance = length((GetLight(lightIndex).Position - position));
			vec3 lightColor = GetLight(lightIndex).LightColor * GetLightAttenuation(lightDistance);  
			volumetricColor += absorptionFromMarch * volumeAlbedo * lightColor;
		}
		volumetricColor += absorptionFromMarch * volumeAlbedo * GetAmbientLight();
	}
}

这就是我们得到的:

ShaderToy屏幕截图

看起来像棉花糖!也许对于某些效果就足够了!但是我们缺乏自我遮蔽。光均匀地到达体积的所有部分。但这在物理上是不正确的,具体取决于渲染点和光源之间的体积大小,我们将接收到不同数量的入射光。

自阴影


我们已经做了最困难的事情。我们需要做与计算沿照相机光束的吸收相同的操作,但仅沿光束进行吸收。用于计算到达每个点的光量的代码本质上将是该代码的重复,但是复制它比破解HLSL更容易获得我们所需的递归。所以这是它的样子:

float GetLightVisiblity(in vec3 rayOrigin, in vec3 rayDirection, in float maxT, in int maxSteps, in float marchSize) {
    float t = 0.0f;
    float lightVisiblity = 1.0f;
    for(int i = 0; i < maxSteps; i++) {                       
        t += marchSize;
        if(t > maxT) break;

        vec3 position = rayOrigin + t*rayDirection;
        if(QueryVolumetricDistanceField(position) < 0.0) {
            lightVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
        }
    }
    return lightVisiblity;
}

添加自阴影可以为我们提供以下功能:

ShaderToy屏幕截图

柔化边缘


此刻,我已经很喜欢我们的音量。我向他展示了联盟视觉特效部门的才华领袖詹姆斯·夏普(James Sharp)。他立即注意到该卷的边缘看起来太尖锐。这是绝对正确的-像云一样的物体不断散布在它们周围的空间中,因此它们的边缘与体积周围的空白空间混合在一起,这将导致创建非常光滑的边缘。詹姆斯给我提供了一个好主意-根据我们离边缘的距离减少密度。而且由于我们正在使用带符号的距离功能,因此非常容易实现!因此,让我们添加一个可用于请求体积中任意点密度的函数:

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
    return sdfMultiplier;
}

然后我们简单地将其折叠为吸收值:

opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT * GetFogDensity(position), marchSize);

这是它的样子:

ShaderToy屏幕截图

密度函数


现在我们有了密度功能,您可以轻松地在体积上添加一点噪音,使其具有更多细节和光彩。在这种情况下,我只是重复使用了用来调整音量形状的fBM函数。

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
   return sdfMultiplier * abs(fbm_4(position / 6.0) + 0.5);
}

因此,我们得到以下信息:

ShaderToy屏幕截图

不透明的自我遮蔽


该卷已经看起来很漂亮!但是仍然有一点光从那里泄漏出来。在这里,我们看到绿色如何渗入体积一定要吸收的位置:

ShaderToy屏幕截图

发生这种情况是因为在渲染体积之前渲染了不透明的对象,因此它们不考虑由体积引起的阴影。这很容易修复-我们有一个GetLightVisiblity函数可用于计算阴影,因此我们只需调用它即可照亮不透明的对象。我们得到以下内容:

ShaderToy屏幕截图

除了创建漂亮的多色阴影外,这还有助于改善阴影并在场景中建立体积。此外,由于体积的平滑边缘,尽管严格地说我们使用点光源进行照明,但我们仍可获得柔和的阴影。就这样!在这里可以做更多的工作,但在我看来,我已经达到了所需的视觉效果,同时又保持了示例的相对简单性。

最佳化


最后,我将简要列出一些可能的优化:

  1. 在沿光源方向进行光线行进之前,有必要通过光的衰减值来检查是否有大量的这种光确实到达了所讨论的点。在我的实现中,我查看了光的亮度乘以材质的反照率,并确保该值足够大以执行光线行进。
  2. , , raymarching
  3. raymarching . , . , raymarching , .


就这样!就个人而言,我很惊讶您可以用这么少的代码(大约500行)创建在物理上相当正确的东西。谢谢您的阅读,我希望这很有趣。

还有一点需要注意:这是一个有趣的变化-我根据SDF距离添加了光发射以创建爆炸效果。毕竟,爆炸从未发生过。

ShaderToy屏幕截图

All Articles