赞
踩
本文翻译自Volume Rendering for Developers: Foundations,如有错误,欢迎指正。
体绘制(从技术上讲,使用术语 参与媒介participating media 而不是术语 体积volume 会更好)是一个几乎与硬表面绘制一样大和复杂的主题。它有自己的一套方程,事实上,几乎是用来描述光如何与硬物质相互作用的方程的概括。对于那些不一定熟悉如此复杂的数学公式的读者来说,它们可能是困难的。
与我们在Scratchapixel上教东西的方式一样,我们选择了一种“自下而上”的方法来教授体渲染的挑战。或者换句话说,一种实用的方法。而不是从方程开始并深入研究它们,我们将编写代码来渲染一个简单的体积球体,并以一种直观的方式解释事情。然后,我们学到的所有内容都会在课程结束时总结并形式化。
有几节课将专门介绍体绘制(这是一个很大的主题)。在本入门课程中,我们将学习体绘制和光线推进的基础。接下来的课程将致力于渲染体积的其他可能方法,应用于参与介质的全局照明,多重散射,用于存储体积数据的格式,如OpenVDB等。
如果对体积渲染感兴趣,你可能还想查看模拟天空颜色一课。天空是一种体积。
我们在本课前两章的目的是学习如何在均匀的颜色背景上渲染一个形状像球体的体积,该体积由单个光源照亮。这将帮助我们第一次直观地了解什么是体积,并介绍我们将用于渲染该体积的光线步进算法。
在这一章中,我们只绘制一个密度均匀的平面体。我们将忽略物体从体积外部或内部投射的阴影,以及如何渲染具有不同密度的体积。这些将在下一章中研究。
与其提供大量关于体积是什么以及用于渲染它们的方程的详细背景,不如直接深入实现并从中获得对体积渲染的更正式的理解。
由于光被物体反射或被光源发射而到达我们眼睛的光在穿过充满一些粒子的空间体积时可能被吸收。体积中的粒子越多,体积越不透明。从这个简单的观察,我们可以规定一些与体绘制相关的基本概念:吸收、透射以及体积的不透明程度与其包含的颗粒密度之间的关系。现在,我们将考虑体积所包含的粒子的密度是均匀的。
当光在我们眼睛的方向上穿过体积时(这就是我们看到的物体的图像在我们眼睛中形成的方式),其中一些将在穿过体积时被体积吸收。这种现象称为吸收。我们现在感兴趣的是从背景透过体积的光量。我们说的是内部透射率(光在穿过体积时被体积吸收的量)。内部透射率可以被看作是从0(体积阻挡所有光线)到1(好吧,它是真空,所以所有光线都被透射)的值。
通过该体积传输的光量由比尔-朗伯定律(Beer-Lambert law,或简称Beer’s law)控制。在比尔-朗伯定律中,密度的概念用吸收系数(和散射系数,但我们将在本章后面介绍散射系数)来表示。你可以理解为,“体积越密,吸收系数越高”;你可以凭直觉猜到,随着吸收系数的增加,体积变得更加不透明。Beer-Lambert定律看起来像这样:
该定律指出,在光通过体积的内部透射率
T
T
T与体积的吸收系数
σ
a
σ_a
σa(希腊字母sigma)和光通过物质行进的距离(即,光通过物质的距离)的乘积之间存在指数依赖性。 (i.e. the path length)
这些系数的单位是倒数距离或倒数长度,例如
c
m
−
1
cm^{-1}
cm−1
或
m
−
1
m^{-1}
m−1(这无关紧要,这只是尺度问题)。这一点很重要,因为它有助于直观地了解这些系数所包含的信息。如果您希望在任何给定点/距离发生随机事件(例如光子被吸收或散射),您可以将吸收系数(以及我们稍后介绍的散射系数)视为概率或可能性。
吸收和散射系数表示概率密度(以防您想对该主题进行更多研究)。然而,由于它是一种概率,因此不应超过1,这取决于测量它的单位。例如,如果您使用毫米,则对于给定的介质,您可能会得到0.2。但以厘米和米表示,这将分别变为2和20。因此,在实践中,没有什么可以阻止您使用大于1的值。
系数与平均自由程(mean free path)之间的关系。
吸收系数和散射系数的单位是长度的倒数,这一事实很重要,因为如果取系数的倒数(1除以散射系数的吸收),就可以得到距离。该距离称为平均自由程,表示随机事件发生的平均距离:
此值在模拟参与介质中的多次散射时起着重要作用。查看有关次表面散射和高级体渲染的课程,了解有关这些非常酷的主题的更多信息。
吸收系数或距离越大,T越小。Beer-Lambert定律方程返回0-1范围内的数字。如果距离或吸收系数为0,则公式返回1。对于距离或密度的非常大的数字,T变得更接近于0。对于固定的距离,T随着吸收系数的增加而减小。对于固定的吸收系数,T随着距离的增加而减小。光在体积中传播得越远,它被吸收得越多。体积中的颗粒越多,吸收的光就越多。您可以在图1中看到这种效果。
Beer & gemstones. 啤酒和宝石。
仅吸收介质是透明的(不是半透明的),但通过它看到的图像变暗(例如:啤酒、葡萄酒、宝石、有色玻璃)。
从这里开始很容易。假设我们有一个厚度和密度已知的体积块。分别为10和0.1。然后,如果背景颜色(例如,我们正在查看的墙壁反射的光)是(xr,xg,xb),那么我们通过体积能看到的背景颜色的多少是:
vec3 background_color {xr, xg, xb};
float sigma_a = 0.1; // absorption coefficient
float distance = 10;
float T = exp(-distance * sigma_a);
vec3 background_color_through_volume = T * background_color;
简直不能更简单。
请注意,到目前为止,我们一直假设体积是黑色的。换句话说,我们只需要在体积块所在的地方使背景颜色变暗。但体积不必如此。像实体物体这样的体积也反射(或更精确地散射)光。这就是为什么,当你在晴天看云的时候,你可以看到云的形状几乎就像它是一个实体物体。体积也可以发光(想想蜡烛的火焰),我们只是为了完整起见才提到它,但在本章中我们将忽略发光。所以让我们假设我们的体积块有一个特定的颜色,比如(yr,yg,yb)。我们暂时不考虑颜色的来源;我们将在本章后面解释它。在此之前,我们只说体积具有一些颜色,这是体积对象“反射”光的结果(不是真的,但现在让我们使用“反射”的概念,就像实体对象一样),它像实体对象一样照亮它。然后我们的代码变成:
vec3 background_color {xr, xg, xb};
float sigma_a= 0.1;
float distance = 10;
float T = exp(-distance * sigma_a);
vec3 volume_color {yr, yg, yb};
vec3 background_color_through_volume = T * background_color + (1 - T) * volume_color;
可以将其视为在Photoshop中混合(A+B)图像的过程,例如使用Alpha混合。假设您想在A上添加图像B,其中A是背景图像(我们的蓝色墙壁),B是具有透明度通道的红色磁盘的图像。联合两个图像的公式为:
其中透明度(Transparency)是1 - Transmission(也称为不透明度),B是体积对象的颜色(光被体积“反射”并向我们的眼睛/相机行进的光)。当我们讲到光线步进算法时,我们会回到这个问题上来;现在,请记住这一点。
我们拥有渲染我们第一个3D图像所需的一切。我们将使用到目前为止学到的知识来渲染一个球体,我们假设它充满了一些粒子。我们将假设我们在一些背景上渲染我们的球体。原理很简单。我们首先检查摄像机光线和球体之间的交点。如果没有交集,那么我们简单地返回背景颜色。如果存在交点,则计算光线进入和离开球体的球体表面上的点。从那里,我们可以计算光线穿过球体的距离,并应用比尔定律来计算有多少光线穿过球体。我们现在假设被球体“反射”(散射)的光是均匀的。我们稍后再看灯光。
Implementation detail. 实施细节。
从技术上讲,我们不需要计算光线进入和离开球体的点来获得点之间的距离。我们只需要从 tmax 中减去 tmin (光线与球体相交处沿着相机光线的光线参数距离)。在下面的例子中,我们计算它们是为了强调我们在这里关心的是这两个点之间的距离。
class Sphere : public Object { public: bool intersect(vec3, vec3, float, float) const { /* compute ray-sphere intersection */ } float sigma_a{ 0.1 }; vec3 scatter{ 0.8, 0.1, 0.5 }; vec3 center{ 0, 0, -4 }; float radius{ 1 }; }; void traceScene(vec3 ray_origin, vec3d ray_direction, const Sphere *sphere) { float t0, t1; vec3 background_color { 0.572, 0.772, 0.921 }; if (sphere->intersect(rayOrigin, rayDirection, t0, t1)) { vec3 p1 = ray_origin + ray_direction * t0; vec3 p2 = ray_origin + ray_direction * t1; float distance = (p2 - p1).length(); // though you could simply do t1 - t0 float tranmission = exp(-distance * sphere->sigma_a); return background_color * transmission + sphere->scatter * (1 - transmission); } else return background_color; } void renderImage() { Sphere *sphere = new Sphere; for (each row in the image) for (each column in the image) vec3 ray_dir = computeRay(col, row); pixel_color = traceScene(ray_orig, ray_dir, sphere); image_buffer[...] = pixel_color; // store pixel color in image buffer saveImage(image_buffer); ... }
非常合乎逻辑的是,随着密度的增加,透射率变得更接近于0,这意味着体积球体的颜色在背景的颜色上占主导地位。
在上面的图像中可以看到,体积朝着球体的中心变得更加不透明(光线穿过球体的距离最大。您还可以看到,随着密度的增加(随着
σ
a
σ_a
σa的增加),球体整体上变得更加不透明。成功了!您刚刚渲染了第一个体积球体,距离成为体积渲染专家还有一半的距离。
到目前为止,我们已经有了一个很好的体积球体的图像,但是光照呢?如果我们将灯光照射到体积对象上,我们可以看到体积中更直接暴露在灯光下的部分比阴影中的部分更亮。体积也被灯光照亮。我们如何解释这一点?
原理很简单。让我们想象一下光源发出的光穿过体积的命运。当它穿过体积时,其强度由于吸收而衰减。毫不奇怪,在体积中传播了一定距离后,光能还剩多少由比尔定律决定。换句话说,如果我们知道光穿过体积的距离,那么它在该距离处的强度为:
float light_intensity = 10; // just a number, it could be anything
float T = exp(-distance_travelled_by_light * volume->absorption_coefficient);
light_intensity_attenuation = T * ligth_intensity;
首先,根据比尔定律,光能 在穿过物体时会减少。很有逻辑。但也发生了一些其他的事情:由光源发射的最初不朝向眼睛传播的光,也可以因为散射效应的原因而重新定向,朝向眼睛(如我们将看到的,至少是眼睛的一部分)。我们称这种特殊情况为内散射(in-scattering)。内散射是指通过体积的光,由于散射事件而被重定向到眼睛。图4中示出了该效果。散射事件是光子与构成介质/体积的粒子/原子之间的相互作用的结果。而不是被吸收或反射(这也可能发生),原子只是“吐出”光子的方向与它的入射方向不同。我们将在接下来的章节中了解更多关于这种现象的信息。
如果您查看图4,请注意到达眼睛的光(沿着图中以蓝色绘制的特定眼睛/相机光线)是来自背景(我们的蓝色背景)的光和来自光源的光的组合,这些光由于内散射而散射到眼睛(黄色光线)。
那么,我们如何解释光源的贡献呢?我们需要“测量”向眼睛散射的光(沿着相机光线)作为内散射的效果。问题是,我们需要考虑整个相机光线与球体相交的部分的影响(图5)。我们需要对t0-t1范围内沿着相机光线散射的光线进行“积分”。
为了解决这个问题,我们将通过体积的相机光线部分划分为一定数量的片段(如果您愿意,我们的样本),并使用以下过程计算有多少光会到达每个片段的中心(请参见图6以获得概念的视觉表示):
为了理解我们在这里解决的问题类型,我们需要查看图6和图7。图6展示了沿着相机射线到达的入射光,正如您在图的底部部分中所看到的,这是一个连续函数。让我们称这个函数为Li(x),其中x是沿着包含在范围t0-t1内的相机射线的任何点。我们需要计算的是曲线下面的“面积”。在数学中,它是一个积分,我们可以写为:
正如我们刚才所说,积分的结果(这是一个数字)被定义为曲线(函数Li(x))下的(净符号)面积,如图6所示。在我们的例子中,问题是我们不能使用解析解来计算这个面积。但是我们可以使用一个技巧来近似这个面积,将它分解成更简单的形状,我们知道矩形的面积(如图7所示)。我们沿着曲线以规则的间隔对Li(x)进行采样,我们知道(dx)的宽度,然后可以将所得矩形的面积计算为Li(x)乘以dx(x在间隔的中间)。通过对所有矩形的面积求和,我们得到曲线下面积的近似值。瞧!这种技术被称为黎曼求和(用已知的面积来近似一个我们不知道的形状的想法可以追溯到希腊人)。
Going further. 更进一步。
您可以在课程“着色数学”中找到有关积分以及如何计算积分的更多信息。
那么它是如何转换成代码的呢?
为了整合由于内散射而沿着光线的入射光,我们将光线穿过的体积分解为小体积元素,并将这些小体积元素中的每一个的贡献组合到整个体积对象中,有点像当我们在2D编辑软件(例如Photoshop)中将具有遮罩或alpha通道(通常表示对象的不透明度)的图像堆叠到彼此上时。这就是为什么我们在第一章中谈到alpha合成方法。这些小体积元中的每一个都代表第一章中提到的黎曼和中的一个样本。
该算法的工作原理如下:
// compute Li(x) for current sample x
float lgt_t0, lgt_t1; // parametric distance to the points where the light ray intersects the sphere
volumeSphere->intersect(x, lgt_dir, t0, lgt_t1); // compute the intersection of the light ray with the sphere
color Li_x = exp(-lgt_t1 * sigma_a) * light_color * step_size; // step_size is our dx
...
如图2所示,光线-球体相交测试的t0应该始终为0,因为光线从球体内部开始,t1是从我们的采样位置x到光线与球体相交的点的参数距离。因此我们可以在比尔定律方程中使用该值,以确定光被体积物体吸收的距离。
现在可以很容易地理解“光线步进”这个名字:我们沿着射线步进,采取如图1所示的小的规则步骤(向后射线行进的示例)。请注意,使用常规步长不是光线行进算法的条件。步伐也可以是不规则的,但是为了让事情变得简单,让我们使用规则的步伐或步幅(steps or strides,Ken Musgrave喜欢这样称呼它们)。当使用常规步骤时,我们称之为均匀光线步进(与自适应射线行进相反)。
我们可以结合两种采样方式:向后(backward,从t1行进到t0)或向前(forward,从t0行进到t1)。一个比另一个更好。现在我们将描述它们是如何工作的。
在反向光线步进中,我们将沿着射线从后向前行进。换句话说,从t1到t0。这改变了我们组合样本以计算最终像素不透明度和颜色值的方式。
很自然地,由于我们从体积对象(我们的球体)的背面开始,我们可以用背景颜色(我们的蓝色)初始化像素颜色(为该相机光线返回的颜色)。但在我们的实现中,我们只会在过程结束时将两者结合起来(一旦我们计算了体积对象的颜色和不透明度),有点像我们在2D编辑软件中合成两个图像时。
我们将计算第一个样本(比如X0)在体积中的贡献,从我们提到的t1开始,然后返回到t0,采取常规步骤(由步长定义)。
这个样本的贡献是什么?
...
color Li_x0 = exp(-lgt_t1 * sigma_a) * light_color * step_size; // step_size is our dx
color x0_contrib = Li_x0 * exp(-step_size * sigma_a);
...
我们刚刚计算了第一个样本X0。然后我们移动到第二个样本(X1),但现在我们需要考虑两个光源:来自第一样品的光束X0(我们先前的结果),以及由于内散射而穿过第二样品的光束X1。我们已经计算了前者(正如我们刚才所说,这是我们之前的结果),我们知道如何渲染后者。我们将它们相加,并将该和乘以第二样本透射值。这成为我们的新成果。我们不断重复这个过程,X2,X3,…直到我们最终到达t01。最终结果是体积对象对当前摄影机射线像素颜色的贡献。该过程如下所示。
从上面的图像中注意到,我们计算了两个值:体积整体颜色(存储在结果中)和整体体积透明度。我们将该值初始化为1(完全透明),然后随着我们沿着光线向上(或向下)移动(从t1到t0),使用每个样本透明度值衰减该值。然后,我们可以(最终)使用此整体透明度值将体积对象组合到背景颜色上。我们简单地做:
color final = background_color * transmission + result;
在合成术语中,我们会说“结果”项已经预先乘以体积整体透明度。但如果这让你感到困惑,我们将在下一章阐明这一点。所以现在不要太关注这个。
还要注意,在上图和下图中,样本的衰减项始终相同:exp(-step_size * sigma_a)。当然,这是没有效率的。您应该计算一次此项,将其存储在一个变量中,然后使用该变量。但是清晰是我们的目标,而不是编写高性能的代码。此外,就目前而言,当我们沿着射线行进时,这个值是恒定的,但我们将在接下来的章节中发现,它最终会因样本而异。
这是它翻译成代码的样子:
constexpr vec3 background_color{ 0.572f, 0.772f, 0.921f }; vec3 integrate(const vec3& ray_orig, const vec3& ray_dir, ...) { const Object* hit_object = nullptr; IsectData isect; for (const auto& object : objects) { IsectData isect_object; if (object->intersect(ray_orig, ray_dir, isectObject)) { hit_object = object.get(); isect = isect_object; } } if (!hit_object) return background_color; float step_size = 0.2; float sigma_a = 0.1; // absorption coefficient int ns = std::ceil((isect.t1 - isect.t0) / step_size); step_size = (isect.t1 - isect.t0) / ns; vec3 light_dir{ 0, 1, 0 }; vec3 light_color{ 1.3, 0.3, 0.9 }; float transparency = 1; // initialize transparency to 1 vec3 result{ 0 }; // initialize the volume color to 0 for (int n = 0; n < ns; ++n) { float t = isect.t1 - step_size * (n + 0.5); vec3 sample_pos= ray_orig + t * ray_dir; // sample position (middle of the step) // compute sample transparency using Beer's law float sample_transparency = exp(-step_size * sigma_a); // attenuate global transparency by sample transparency transparency *= sample_transparency; // In-scattering. Find the distance traveled by light through // the volume to our sample point. Then apply Beer's law. IsectData isect_vol; if (hitObject->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) { float light_attenuation = exp(-isect_vol.t1 * sigma_a); result += light_color * light_attenuation * step_size; } // finally attenuate the result by sample transparency result *= sample_transparency; } // combine with background color and return return background_color* transparency + result; }
但要注意这段代码。现在还不准确它缺少了一些术语,我们将在下一章中讨论。现在,我们只想让您了解射线推进的原理。然而,这个代码将产生一个令人信服的图像。
请注意,在这个例子中,我们一直在使用自上而下的远距离光(光的方向是沿着y轴向上)。球体的红色来自于浅色。你可以看到球体的上半部分比下半部分更亮。阴影效果已经可见。
让我们再次看看当我们沿着射线行进时样本会发生什么:
如果你看一下在我们通过循环时
L
i
(
X
0
)
Li(X_0)
Li(X0)发生了什么,你可以观察到它乘以了提高到某个幂的样本衰减。我们沿着射线行进得越多,指数就越高(首先是1,然后是2,然后是3,……)因此结果越小(因为衰减或样品透明度低于1)。换句话说,第一样本对由体积散射的总体所得光的贡献随着累积更多样本而减小。
当涉及到计算 Li(x) 和样本的透射值时,与反向射线行进没有区别。不同的是我们如何组合样本,因为这一次,我们将从t0行进到t1(从前到后)。在前向光线行进中,由样本散射的光的贡献必须被我们迄今为止处理的所有样本(包括当前样本)的整体透射值(透明度)进行衰减:Li(X1) 被样本X0和X1的透射值衰减,Li(X2) 被样本X0、X1和X2的透射值遮挡,等等。下面是算法的描述:
翻译成代码:
... vec3 integrate(const vec3& ray_orig, const vec3& ray_dir, ...) { ... float transparency = 1; // initialize transparency to 1 vec3 result{ 0 }; // initialize the volume color to 0 for (int n = 0; n < ns; ++n) { float t = isect.t0 + step_size * (n + 0.5); vec3 sample_pos = ray_orig + t * ray_dir; // current sample transparency float sample_attenuation = exp(-step_size * sigma_a); // attenuate volume object transparency by current sample transmission value transparency *= sample_attenuation; // In-Scattering. Find the distance traveled by light through // the volume to our sample point. Then apply Beer's law. if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) { float light_attenuation = exp(-isect_vol.t1 * sigma_a); // attenuate in-scattering contrib. by the transmission of all samples accumulated so far result += transparency * light_color * light_attenuation * step_size; } } // combine background color and volumetric object color return background_color * transparency + result; }
但请注意这段代码。目前还不准确。它缺少一些我们将在下一章中讨论的术语。现在,我们只想让您了解光线行进的原理。然而这段代码将产生一个令人信服的图像。
无需在此处显示图像。如果我们做得正确,向后和向前的光线行进应该给出相同的结果。好吧,我们知道您不会认为这是理所当然的,所以这是结果。
因为一旦体积的透明度非常接近 0,我们就可以停止光线行进(如果体积足够大和/或散射系数足够高,就会发生这种情况)。只有当你以前向的光线行进的方式前进时,这才有可能实现。
现在,渲染我们的体积球体相当快,但随着我们继续阅读章节,您会发现它最终会变慢。因此,如果我们能够避免计算对像素颜色没有贡献的样本,因为我们沿着光线行进时到达了一个点,我们知道体积是不透明的,那么这是一个很好的优化。
我们将在下一章中实现这个想法。
请记住,我们进行光线行进,从 t0 到 t1 采取小步长的原因是使用黎曼求和方法来估计积分(由于内散射而沿着相机光线向眼睛散射的光量)。正如前一章和着色数学课程中所解释的,用于估计积分的矩形越大(在我们的例子中,矩形的宽度由此处的步长大小定义),近似值就越不准确。或者反过来:矩形越小(步长越小),估计就越准确,但计算时间当然也就越长。目前,渲染体积球体的速度相当快,但随着我们学习本课程,您会发现它最终会变得慢得多。这就是为什么选择步长是速度和准确性之间的权衡。
现在,我们假设体积密度也是均匀的。在接下来的章节中,我们将看到为了渲染云或烟雾等体积密度,密度会随着空间的变化而变化。这些体积由大频率特征和较小频率特征组成。如果步长太大(我感觉作者可能把图5注释的步长打错了,原来意思可能是想说步长太大,但是他图片注释就是our step size is too small,如果读者有疑问可以自己去看看原文),最终可能无法捕获一些较小的频率特征(图 5)。这是一个过滤问题,本身就是一个重要但复杂的主题。
可能会出现另一个需要调整步长的问题:阴影。如果微小的实体对象在体积对象上投射阴影,如果步长太大,您最终将错过它们(图 6)。
所有这些并没有告诉我们如何选择一个好的步长。理论上来说,没有任何规则。您基本上应该了解体积对象的大小。例如,如果它是一个矩形,充满了某种均匀气氛的房间,您应该了解该房间的大小(以及您使用的单位类型,例如 1 单位 = 10 厘米)。因此,如果房间有 100 个单位大,则 0.1 的步长可能太小,而 1 或 2 可能是一个不错的起点。然后,您需要像我们之前提到的那样,在速度和准确性之间找到一个良好的权衡。
现在看来,这也不完全正确。在通过考虑场景中物体的大小来凭经验选择步长时,必须有一种更合理的方法来这样做。一种可能的方法是考虑进入体积对象的距离处的像素“有多大”,并将步长设置为投影像素的尺寸。事实上,作为离散对象的像素无法表示场景中小于其大小的细节。我们不会在这里讨论更多细节,因为过滤本身就值得一课。现在我们要说的是,一个好的步长接近于相机光线与体积相交点处的像素的投影大小。这可以通过以下方式进行估计:
float projPixWidth = 2 * tanf(M_PI / 180 * fov / (2 * imageWidth)) * tmin;
如果您愿意,您可以对其进行优化。其中 tmin 是相机光线与体积对象相交的距离(应该就是光线与体积对象相交的长度)。我们可以类似地计算光线离开体积的投影像素宽度,并在 tmin 和 tmax 处线性插值投影像素宽度,以设置我们沿着光线步进时的步长。
编写生产代码需要将光线不透明度和颜色与光线数据一起存储。这样我们就可以首先对实体对象进行光线追踪,然后对体积对象进行光线追踪,并结合结果(与上例中将背景颜色与体积球体对象相结合的方式类似)。
请注意,相机光线的路径上可以有多个体积对象。因此,有必要沿途存储不透明度,并在我们对连续体积对象进行光线行进时组合它们的不透明度和颜色。
体积对象可以由联合对象的集合组成,例如彼此重叠的立方体或球体。在这种情况下,我们可能希望将它们组合成某种聚合结构。对此类聚合进行光线行进需要特别小心地计算生成聚合的对象的相交边界。
在本课的第三章/下一章中,我们将添加当前实现中缺少的一些术语,以获得物理上(更)准确的结果。我们还将向您展示,配备了这些知识,您现在应该准备好阅读和理解其他人的渲染器代码。准备好了吗?
可以下载重现前两章图像的源代码,并在课程最后一章(像往常一样)中提供编译说明(嵌入在文件中)。请注意,该代码与本章中显示的代码片段略有不同。这些差异将在下一章中解释。
这里就放一下我根据github上代码加上个人理解注释的代码,这个代码包含了后线和前向两种光线步进方式,并且还包含了后面才讲到的散射项和密度项,读者需要自己辨别。同时运行这个代码只需要c++空项目,不需要cmake编译:
//[header] // Rendering volumetric object using ray-marching. A basic implementation (chapter 1 & 2) // // https://www.scratchapixel.com/lessons/advanced-rendering/volume-rendering-for-developers/ray-marching-algorithm //[/header] //[compile] // Download the raymarch-chap2.cpp file to a folder. // Open a shell/terminal, and run the following command where the file is saved: // // clang++ -O3 raymarch-chap2.cpp -o render -std=c++17 (optional: -DBACKWARD_RAYMARCHING) // // You can use c++ if you don't use clang++ // // Run with: ./render. Open the resulting image (ppm) in Photoshop or any program // reading PPM files. //[/compile] //[ignore] // Copyright (C) 2022 www.scratchapixel.com // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see <http://www.gnu.org/licenses/>. //[/ignore] //#define BACKWARD_RAYMARCHING #define _USE_MATH_DEFINES using namespace std; #include <cmath> #include <iostream> #include <fstream> #include <memory> #include <algorithm> #include <vector> #include <random> //区间限定函数,如果使用原来的std::clamp函数需要打开 /std:c++latest 开关来启用对标准的 C++17 添加。(怎么打开我也不知道),直接自己写了个 template<typename T> T clamp(T x, T min, T max) { return std::max(std::min(x, max), min); } struct vec3 { //x,y,z坐标 float x{ 0 }, y{ 0 }, z{ 0 }; //归一化函数 vec3& nor() { float len = x * x + y * y + z * z; if (len != 0) len = sqrtf(len); x /= len, y /= len, z /= len; return *this; } //算欧氏距离的函数 float length() const { return sqrtf(x * x + y * y + z * z); } //两个向量的点乘函数 float operator * (const vec3& v) const { return x * v.x + y * v.y + z * v.z; } //向量相减 vec3 operator - (const vec3& v) const { return vec3{ x - v.x, y - v.y, z - v.z }; } //向量相加 vec3 operator + (const vec3& v) const { return vec3{ x + v.x, y + v.y, z + v.z }; } //向量加等于 vec3& operator += (const vec3& v) { x += v.x, y += v.y, z += v.z; return *this; } //乘等于 vec3& operator *= (const float& r) { x *= r, y *= r, z *= r; return *this; } //向量相乘 friend vec3 operator * (const float& r, const vec3& v) { return vec3{ v.x * r, v.y * r, v.z * r }; } //向量输出函数 friend std::ostream& operator << (std::ostream& os, const vec3& v) { os << v.x << " " << v.y << " " << v.z; return os; } //向量乘以常数 vec3 operator * (const float& r) const { return vec3{ x * r, y * r, z * r }; } }; //背景颜色 constexpr vec3 background_color{ 0.572f, 0.772f, 0.921f }; //一个无限大的浮点数 constexpr float floatMax = std::numeric_limits<float>::max(); //一个数据结构,用来存储光线进入和出去体积体的信息 struct IsectData { float t0{ floatMax }, t1{ floatMax }; //t0进去,t1出去 vec3 pHit; //从光线段的某个点发出的射线与体积体表面相交的点 vec3 nHit; //光线段上的某个点 bool inside{ false }; //光线是否在体积体内 }; //体积体的数据结构 struct Object { public: vec3 color; //体积体的颜色 int type{ 0 }; virtual bool intersect(const vec3&, const vec3&, IsectData&) const = 0; //检测是否光线与体积体相交 virtual ~Object() {} Object() {} }; //求两个交点,证明光线与体积体相交 bool solveQuadratic(float a, float b, float c, float& r0, float& r1) { float d = b * b - 4 * a * c; if (d < 0) return false; else if (d == 0) r0 = r1 = -0.5f * b / a; else { float q = (b > 0) ? -0.5f * (b + sqrtf(d)) : -0.5f * (b - sqrtf(d)); r0 = q / a; r1 = c / q; } if (r0 > r1) std::swap(r0, r1); return true; } //球体体积体的数据结构 struct Sphere : Object { public: Sphere() { color = vec3{ 1, 0, 0 }; type = 1; } bool intersect(const vec3& rayOrig, const vec3& rayDir, IsectData& isect) const override { //射线起始点与球心的距离 vec3 rayOrigc = rayOrig - center; //a,b,c三个数据,为了后面证明相交做准备 float a = rayDir * rayDir; float b = 2 * (rayDir * rayOrigc); float c = rayOrigc * rayOrigc - radius * radius; //证明是否相交 if (!solveQuadratic(a, b, c, isect.t0, isect.t1)) return false; if (isect.t0 < 0) { if (isect.t1 < 0) return false; else { isect.inside = true; isect.t0 = 0; //如果相交,就将第一个进入的相交点设为0 } } return true; } //球半径 float radius{ 1 }; //圆心 vec3 center{ 0, 0, -4 }; }; std::default_random_engine generator; std::uniform_real_distribution<float> distribution(0.0, 1.0); //光线步进的光积分函数(黎曼求和) vec3 integrate(const vec3& ray_orig, const vec3& ray_dir, const std::vector<std::unique_ptr<Object>>& objects) { const Object* hit_object = nullptr; IsectData isect; for (const auto& object : objects) { //路径上可能会有很多个体积体,遍历每一个体积体 IsectData isect_object; if (object->intersect(ray_orig, ray_dir, isect_object)) { hit_object = object.get(); isect = isect_object; } } //路径上没有任何体积体,直接返回背景颜色 if (!hit_object) return background_color; //步长 float step_size = 0.2; //吸收项 float absorption = 0.1; //散射项 float scattering = 0.1; //体积体密度 float density = 1; //根据步长算出步数 int ns = std::ceil((isect.t1 - isect.t0) / step_size); step_size = (isect.t1 - isect.t0) / ns; //光方向(太阳光只有方向,没有起点和大小) vec3 light_dir{ 0, 1, 0 }; //光颜色 vec3 light_color{ 1.3, 0.3, 0.9 }; //用来存储从体积体内的光线上某一点发出光线与体积体相交数据的数据结构 IsectData isect_vol; //透明度 float transparency = 1; // initialize transmission to 1 (fully transparent) //最终光强 vec3 result{ 0 }; // initialize volumetric sphere color to 0 #ifdef BACKWARD_RAYMARCHING //后向光线步进 // [comment] // The ray-marching loop (backward, march from t1 to t0) // [/comment] for (int n = 0; n < ns; ++n) { float t = isect.t1 - step_size * (n + 0.5); vec3 sample_pos = ray_orig + t * ray_dir; float sample_transparency = exp(-step_size * (scattering + absorption)); transparency *= sample_transparency; if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) { float light_attenuation = exp(-density * isect_vol.t1 * (scattering + absorption)); result += light_color * light_attenuation * scattering * density * step_size; } else std::cerr << "oops\n"; result *= sample_transparency; } return background_color * transparency + result; #else //前向光线步进 // [comment] // The ray-marching loop (forward, march from t0 to t1) // [/comment] for (int n = 0; n < ns; ++n) { float t = isect.t0 + step_size * (n + 0.5); vec3 sample_pos = ray_orig + t * ray_dir; // compute sample transmission float sample_attenuation = exp(-step_size * (scattering + absorption)); transparency *= sample_attenuation; // In-scattering. Find distance light travels through volumetric sphere to the sample. // Then use Beer's law to attenuate the light contribution due to in-scattering. if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) { float light_attenuation = exp(-density * isect_vol.t1 * (scattering + absorption)); result += transparency * light_color * light_attenuation * scattering * density * step_size; } else std::cerr << "oops\n"; } // combine background color and volumetric sphere color return background_color * transparency + result; #endif } int main() { //渲染图的尺寸 unsigned int width = 640, height = 480; //给渲染图开辟内存空间 auto buffer = std::make_unique<unsigned char[]>(width * height * 3); //宽高比 auto frameAspectRatio = width / float(height); //摄像机的角大小 float fov = 45; float focal = tan(M_PI / 180 * fov * 0.5); //生成体积体,这里就只生成了一个球体 std::vector<std::unique_ptr<Object>> geo; std::unique_ptr<Sphere> sph = std::make_unique<Sphere>(); sph->radius = 5; sph->center.x = 0; sph->center.y = 0; sph->center.z = -20; geo.push_back(std::move(sph)); vec3 rayOrig, rayDir; // ray origin & direction //根据摄像机的观察方向生成射线方向 unsigned int offset = 0; //根据每一个像素的位置生成颜色,并存储进之前开辟好的内存中 for (unsigned int j = 0; j < height; ++j) { for (unsigned int i = 0; i < width; ++i) { rayDir.x = (2.f * (i + 0.5f) / width - 1) * focal; rayDir.y = (1 - 2.f * (j + 0.5f) / height) * focal * 1 / frameAspectRatio; // Maya style rayDir.z = -1.f; rayDir.nor(); //射线方向归一化,防止后面使用方向的时候会因为有大小而出错 vec3 c = integrate(rayOrig, rayDir, geo); //进行积分 //存进内存 buffer[offset++] = clamp(c.x, 0.f, 1.f) * 255; buffer[offset++] = clamp(c.y, 0.f, 1.f) * 255; buffer[offset++] = clamp(c.z, 0.f, 1.f) * 255; } } // writing file 写入文件中 std::ofstream ofs; ofs.open("./image_backward.ppm", std::ios::binary); ofs << "P6\n" << width << " " << height << "\n255\n"; ofs.write(reinterpret_cast<const char*>(buffer.get()), width * height * 3); ofs.close(); return 0; }
最后生成的图就是文中的样子,前向后向看着确实没有区别,啊对,需要拿专门的看图软件或者ps来打开ppm文件:
前向光线步进:
后向光线步进:
在前面的章节中,我们只考虑了光束和构成介质的粒子之间的两种相互作用:吸收和内散射。但是,为了获得准确的结果,我们应该考虑四种类型。我们可以将它们分为两类。当光束穿过介质到达眼睛时,相互作用会减弱光束的能量。以及那些有助于增加能量的部分。
这些效果如下图所示。
在计算光通过介质到达眼睛时损失的光量时,我们必须考虑吸收和外散射。外散射和内散射都是由相同类型的光-粒子相互作用引起的:在上一章中,我们用变量 σ s σ_s σs(希腊字母 sigma)定义散射。因此,由于散射 ( σ s σ_s σs) 也会影响光穿过介质到达眼睛时损失的光量,因此我们需要在比尔定律方程中将其与吸收系数 σ a σ_a σa一起解释它。请记住,此方程用于来计算 术语Li(x)和样本透射值(transmission value)。因此,我们的代码现在变为:
...
float sigma_a = 0.5; // absorption coefficient
float sigma_s = 0.5; // scattering coefficient
// compute sample transmission
float sample_attenuation = exp(-step_size * (sigma_a + sigma_s)); //**change**:sigma_a + sigma_s
transparency *= sample_attenuation;
// In-scattering. Find the distance light travels through the volumetric sphere to the sample.
// Then use Beer's law to attenuate the light contribution due to in-scattering.
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
//**change**:sigma_a + sigma_s
float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s));
result += ...;
}
...
有时您会看到
σ
a
\sigma_a
σa 和
σ
s
\sigma_s
σs 被总结为一个称为消光系数(extinction coefficient)的术语,通常表示为
σ
t
σ_t
σt (sigma t)。
我们还没有完全完成散射项…由于内散射而向眼睛散射的光量也与散射项成正比。因此,我们还需要将由于内散射而产生的光贡献乘以 σ s σ_s σs变量。我们的代码变成:
... float sigma_a = 0.5; // absorption coefficient float sigma_s = 0.5; // scattering coefficient // compute sample transmission float sample_attenuation = exp(-step_size * (sigma_a + sigma_s)); transparency *= sample_attenuation; // In-scattering. Find the distance light travels through the volumetric sphere to the sample. // Then use Beer's law to attenuate the light contribution due to in-scattering. //由于内散射而产生的光 if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) { float light_attenuation = exp(-isect_vol.t1 * (sigma_a + sigma_s)); //**change**:sigma_s result += transparency * light_color * light_attenuation * sigma_s * step_size; } ...
我们将在下一章详细讨论这个术语。
到目前为止,我们认为用于控制体积“不透明”程度的散射和吸收系数(请记住,这些系数越高,体积越不透明)在整个体积本身上是均匀的。在科学文献中,这通常被称为同质参与介质(homogenous participating medium)。现实世界中的“卷”通常不是这种情况。例如,考虑云或烟羽。它们的不透明度随空间变化。然后我们谈论异构参与媒介(heterogeneous participating medium)。
我们只会在下一章中看到如何模拟具有不同密度的体积物体,但现在,我们只需要某种变量来全局缩放我们的散射和吸收系数。我们称之为可变密度。我们将使用它来缩放 σ a \sigma_a σa 和 σ s \sigma_s σs,如下所示:
... float sigma_a = 0.5; // absorption coefficient float sigma_s = 0.5; // scattering coefficient <span style="color: red; font-weight: bold; background-color: rgba(255,0,0,0.1);">float density = 1;</span> // compute sample transmission float sample_attenuation = exp(-step_size * density * (sigma_a + sigma_s)); //**change**:density transparency *= sample_attenuation; // In-scattering. Find the distance light travels through the volumetric sphere to the sample. // Then use Beer's law to attenuate the light contribution due to in-scattering. if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) { //**change**:density float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s)); //**change**:density result += transparency * light_color * light_attenuation * sigma_s * density * step_size; } ...
请记住, σ a \sigma_a σa在代码中的两个位置使用。我们将在下一章解释如何实现空间变化密度的概念。
现在,请注意一些有趣的事情。当密度为 0 时, result 变量中不会添加任何内容。换句话说,在没有体积(空的空间,或密度 = 0)的地方,不应该有任何累积的光。当涉及到这一行时,这一点很重要:
// combine with background color and return
return background_color* transparency + result;
如果没有体积体时,result却不为 0(例如,因为我们在散射计算中省略了将散射乘以密度值),意味着在我们本来不应该看到东西时却能看到东西(在这种情况下结果应该是 0)。这就是为什么在上一章中,我们提到 result 已经“预乘”了。它已经乘以它自己的“不透明遮罩”。当 密度/不透明度 大于 0 时,它大于 0;否则为 0。
xx 此处缺少 omega omega’ xx 的图像 (xx missing an image here with omega omega’ xx,缺图片了吗?太抽象了,不理解)
内散射贡献应使用以下方程计算:
L
i
Li
Li 是内散射(辐射)贡献,
x
x
x 是样本位置,
ω
\omega
ω 是观察方向(我们的相机光线方向)。通常,
ω
\omega
ω 始终指向辐射流(radiance flow)的方向,即从物体到眼睛的方向。术语
ω
′
\omega'
ω′ 表示光线方向(
ω
′
\omega'
ω′ 应从物体指向光线)。这里的项
L
(
x
,
ω
′
)
L(x, ω')
L(x,ω′) 就是L(x)项,即到目前为止我们在代码中计算其值的光贡献或入射辐射率。就是这个:
...
// In-scattering. Find the distance light travels through the volumetric sphere to the sample.
// Then use Beer's law to attenuate the light contribution due to in-scattering.
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s));
result += transparency * light_color * light_attenuation * sigma_s * density * step_size;
...
它考虑了来自特定光方向 ω ′ \omega' ω′(代码中的变量 light_dir )的光量,在样本点 x ,即 sample_pos 传播后通过代码中的体积 isect_vol.t1 的一定距离。
但我们还没有在积分符号之后引入这个术语: p ( x , ω , ω ′ ) p(x, ω, ω') p(x,ω,ω′) 。它被称为相位函数,我们接下来将解释它是什么。但在此之前,让我们用文字表达一下这个方程的含义。带有符号 S 2 S^2 S2 的积分(在文献中您最终也会看到写为 Ω 4 π Ω_{4\pi} Ω4π)意味着可以通过考虑来自各个方向的光来计算内散射贡献。整个方向的范围 S 2 S^2 S2。
详细信息
为了计算实体对象的外观,我们使用称为 BRDF 的函数来收集方向半球上的光线。对于实体物体,我们不关心来自表面“下方”的光 - 除了半透明材料,但是,这是另一个很长的故事了。如果您对此主题感兴趣,请查看与着色相关的课程,例如全局照明和路径跟踪或着色数学。现在让我们回到相位函数。
当光子与粒子相互作用时,它可以在粒子周围可能方向的球体内的任何方向上散射出去,其中每个方向与任何其他方向相比被选择的可能性相同。在这种特殊情况下,我们谈论各向同性散射体积。但各向同性散射并不是常态。大多数体积倾向于在有限的方向范围内散射光。然后我们讨论各向异性散射介质或体积。相位函数只是一个数学方程,它告诉您特定方向组合有多少光被散射:观察方向 ω \omega ω 和入射光方向 ω ′ \omega' ω′。该函数返回 0 到 1 范围内的值。用数学术语来说,我们说相位函数对散射光(或辐射率)的角度分布进行建模。
相位函数有几个属性。首先,它必须在其域(方向为
S
2
S^2
S2 的球体)上积分为 1。事实上,构成体积的粒子可能会被来自所有方向的光束击中,并且这组可能的方向可以看作是一个以粒子为中心的球体。因此,如果我们考虑光可以从粒子周围射出的所有方向,则在同一粒子周围散射出的光量不会大于所有入射光的总和。这就是相位函数需要在方向范围内进行归一化的原因:
如果相位函数没有标准化,它将有助于“添加(add)”或“删除(remove)”光。相位函数的另一个性质是互易性(reciprocity)。如果交换方程中的
ω
\omega
ω 和
ω
′
\omega'
ω′ 项,相位函数返回的结果是相同的。
相位函数仅取决于观察方向和入射光方向之间的角度。这就是为什么它通常用角度
θ
\theta
θ (希腊字母 theta)来定义,即两个向量之间的角度(而不是
ω
\omega
ω 和
ω
′
\omega'
ω′)。如果我们取方向
ω
\omega
ω (观察方向)和
ω
′
\omega'
ω′(入射光方向)的点积,
c
o
s
(
θ
)
cos(\theta)
cos(θ) 跨越 [-1, 1] 范围,那么
θ
\theta
θ 本身跨越范围 [0,
π
\pi
π] ,如下图所示。
总之,相位函数会告诉你对于任何特定的入射光方向 ( ω ′ \omega' ω′ ),有多少光可能会向观察者散射 ( ω \omega ω )。
已经聊得足够多了。那么来聊一下这些相函数是什么样的?
最简单的一个是各向同性体积的相位函数。因为来自方向球内所有方向组的光也均匀地散射在球体上的所有方向组上,所以相位函数(请记住其在球域上的积分需要归一化为 1)简单地为:
请注意,此函数与观察方向和入射光方向无关。 θ \theta θ 角存在于函数的定义中,但未在方程本身中使用(在等号的右侧)。这是预期的,因为外散射光子的方向独立于入射光方向(两者之间没有依赖性,因此它没有理由出现在方程中)并且所有外散射方向都同样可能被选择(这就是为什么方程是常数的原因)。理解这个方程并不难。球体的面积是 4 π 4\pi 4π 球面度,所以如果你用微分立体角来考虑这个方向,那么这基本上就是我们所有传入方向覆盖的表面,因此相位函数应该是 1 除以 4 π 4\pi 4π 等于 1。这是一个很好的时机,要提到相位函数的单位是 1/sr(这里的 sr 代表球面度(Steradian))。
各向同性体积的相函数非常简单。让我们看看另一个称为 Henyey-Greenstein 相位函数的函数。它看起来像这样:
确实有点复杂。而且它还有另一个变量 g g g 称为不对称因子,其中 − 1 ≤ g ≤ 1 -1 ≤ g ≤ 1 −1≤g≤1 。此参数可让您控制光是向前还是向后散射。当 g > 0 g > 0 g>0 时,光大部分向前散射。当 g < 0 g < 0 g<0 时,它向后分散。当 g = 0 g = 0 g=0 时,该函数等于 1 / 4 π 1/4\pi 1/4π ,即各向同性体积的相位函数。图 3 显示了对于不同 g g g 值该函数的外观。
详细信息
如果您想证明该函数在方向球上被标准化(is normalized),就在这里。首先,不要忘记我们需要在方向球体上(在 4 π 4\pi 4π 球面度上)对函数进行积分,因为这里的方向 d ω d\omega dω 是根据微分立体角定义的。我们可以用 ϕ \phi ϕ (经度, longitude)和 θ \theta θ (纬度, latitude)来写出微分立体角 d ω d\omega dω ,如着色简介课程中所述。所以我们得到:
在 2 π 2\pi 2π 上积分 d ϕ d\phi dϕ 即可得到 2 π 2\pi 2π(Integrating d ϕ d\phi dϕ over 2 π 2\pi 2π simply gives 2 π 2\pi 2π.) 。所以我们剩下:
我们可以将其写为 μ = c o s ( θ ) \mu = cos(\theta) μ=cos(θ) 的函数,对 -1 和 1 进行积分:
我们可以将积分中的常数(红色)向左移动,这样我们就有:
为了进行这种积分,我们将使用微积分的第二个基本定理:
其中 F F F 是函数 f f f 的反导数。所以我们需要计算以下的反导数:
xx 完成这一步 xx (xx finish this bit xx)
还存在其他相位函数,例如 Schlick、Rayleigh 或 Lorenz-Mie 散射相位函数。它们的设计适合不同类型粒子的行为。例如,当您尝试渲染由微小颗粒(小于光波长)组成的体积时,最好使用瑞利函数(Rayleigh function),而米氏函数(Mie function)更适合较大颗粒(灰尘、水滴等)。 Henyey-Greenstein 经常用于生产渲染,即我们为电影所做的渲染,因为它计算速度快(其他渲染可能慢一些)并且采样也简单(例如,请参阅蒙特卡罗模拟课程)。
最后,这是我们将 Henyey-Greenstein 相位函数添加到代码中时的样子(随意实现其他函数):
// the Henyey-Greenstein phase function float phase(const float &g, const float &cos_theta) { float denom = 1 + g * g - 2 * g * cos_theta; return 1 / (4 * M_PI) * (1 - g * g) / (denom * sqrtf(denom)); } vec3 integrate(...) { ... float g = 0.8; // asymmetry factor of the phase function for (int n = 0; n < ns; ++n) { ... // In-scattering. Find the distance light travels through the volumetric sphere to the sample. // Then use Beer's law to attenuate the light contribution due to in-scattering. if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) { float cos_theta = ray_dir * light_dir; float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s)); //**change**:phase(g, cos_theta) result += density * sigma_s * phase(g, cos_theta) * light_attenuation * light_color * step_size; } ... } ... }
请注意,它看起来更像上面提供的内散射项的正式数学定义。
上面的图像序列显示了两种不同照明设置中的体积球体,其相位函数不对称因子 g g g 具有不同的值。在左侧,光线直射相机(背光)。在右侧,灯光和相机直接指向球体(前照明)。
Henyey-Greenstein 相位函数很简单,但可以很好地拟合现实世界的数据。例如,您可以使用双瓣相位函数(two-lobe phase function),将 g = 0.35 值的函数结果与负值或更高 g 值的结果相结合,以实现更精细的拟合。请随意尝试。对于云或雾等对象,请使用较高的值(大约 0.8)。检查课程末尾的参考部分以获取一些指导。
到目前为止,我们始终将样本定位(positioned)在片段的中间。使用规则间隔的样本就像将体积切成切片,这些切片可能会导致一些令人不快的条带伪影,如上图所示(效果被人为夸大)。为了“解决”这个问题,我们可以在每个段上选择一个随机位置。换句话说,样本可以定位在片段边界内的任何位置(当然,必须沿着相机光线)。为此,我们将替换这些行:
float t = isect.t0 + step_size * (n + 0.5);
vec3 sample_pos = ray_orig + t * ray_dir;
为:
float t = isect.t0 + step_size * (n + rand());
vec3 sample_pos = ray_orig + t * ray_dir;
其中 rand() 是一个返回[0,1]范围内均匀分布数字的函数。我们将这种方法称为随机抽样。
随机采样是一种蒙特卡罗技术(Monte Carlo technique),我们在适当的非均匀间隔位置而不是规则间隔位置对函数进行采样。
我们不能说这更好(因此引用了“解决问题”),因为我们现在用噪声代替了条带,这本身就是一个问题。尽管如此,结果在视觉上比条带更令人愉悦。您可以使用更复杂的方式生成“随机”数字序列来减少这种噪音(例如,参见准蒙特卡罗方法)。然而,在这个版本的课程中,我们将跳过这个主题;关于这一点可以写一整本书(目前,您可以在蒙特卡罗实践课程中找到有关此方法的一些信息)。
事实上,如果在说您已经行进了 t0 和 t1 之间距离的一半后,体积的透明度低于 1e-3,您可能会认为没有必要计算剩余一半的样本(如相邻图所示)。您可以通过在检测到透明度变量低于此最小阈值后立即退出光线步进循环来实现此目的(请参阅下面的伪代码)。考虑到光线步进是一种相当慢的计算方法,我们应该使用这种优化;它将节省大量时间,特别是当体积物体相当密集时(它们越密集,透明度下降得越快)。我们在上一章中提到,这是我们可能更喜欢前向积分方法而不是后向积分方法的原因之一。
...
float transparency = 1;
// marching along the ray
for (int n = 0; n < ns; ++ns) {
...
if (transparency < 1e-3)
break;
}
现在,当我们通过此透明度测试时,您可以停止光线行进并且不执行任何其他操作,但这在“统计上”是错误的。这会以某种方式在渲染图像中引入一些偏差。如果你看一下图 5,就更容易理解这一点。正如您所看到的,红线表示阈值,低于该阈值我们将停止光线行进。如果这样做,我们就可以消除曲线下方和上方(沿 x 轴)的体积的贡献。当然,这个量在某种程度上是“可以忽略不计的”,这就是为什么我们决定先采用该截断方法的原因,但是,如果您是一名热核工程师,试图模拟中子如何穿过一块板,这是不可接受的。那么我们如何才能利用这种优化,同时满足热核工程师的期望呢?
我们将使用的方法称为俄罗斯轮盘赌(Russian roulette),我们已经在专门介绍蒙特卡洛方法的课程中讨论过该方法。这个想法是当透明度值低于某个阈值(例如 1e-3)时应用俄罗斯轮盘赌技术。然后我们在 [0, 1] 范围内选择一个随机数(均匀分布)并测试这个随机数是否大于 1/d,其中 d 是大于 1 的某个正实数(是整数,但不一定得是整数) (它可以等于 1,但这样测试就没用了)。如果是这种情况,我们就退出循环,否则,我们继续,但是,我们将当前透明度值乘以 d (we multiply the current transparency value by d.)。这里的值 d 代表我们通过测试的可能性。例如,对于 d = 5,光线行进循环终止的“机会”将为 5 中的 4。
这使得这个方法是有意义的(希望有意义)。如果随机数低于 1/d,你就能抛弃光子。它消失了。你不能再用它做任何事了。但作为抛弃它的交换,我们将给予那些在测试中幸存下来的光子更多的权力(在我们的例子中增加透明度值),与光子被抛弃的可能性成反比。这是代码中的想法:
...
float transparency = 1;
// marching along the ray
int d = 2; // the greater the value the more often we will break out from the marching loop(值越大,我们就越频繁地脱离步进循环)
for (int n = 0; n < ns; ++ns) {
...
if (transparency < 1e-3) {
if (rand() > 1.f / d) // we stop here
break;
else
transparency *= d; // we continue but compensate(继续,但是要进行补偿)
}
}
这是对俄罗斯轮盘赌技术的简单解释,正如已经提到的,该技术用于蒙特卡洛的模拟和积分。如果需要,请查看这些课程以获得更详细的解释。
本课程的前三章介绍了开始渲染体积所需的内容。到了这样的程度,如果您面临阅读其他人的代码,您现在应该能够理解正在发生的事情。让我们一起做这个练习。我们将使用一个名为 PBRT 的开源项目并研究其体渲染的实现。那里应该不再有你不能理解的东西了。
详细信息
PBRT 是一个研究/教育项目,采用了与 Scratchapixel 大致相同的方法:通过示例教授渲染。然而,PBRT 作为一个完全集成的渲染器出现,而对于 Scratchapixel,每种技术都是在独立的示例程序中实现的。此外,PBRT是为硕士/博士设计的。学生们认为他们可以使用 PBRT 来实施他们的研究。 PBRT 书中给出的大部分方程没有太多解释。本书假设您具备阅读和理解它们所需的背景。而 Scratchapixel 的目标是向所有人教授计算机图形学。我们确实相信 PBRT Book 现已上线,任何人都可以免费阅读。渲染器的源代码可在 GitHub 上获取。
除了比 Scratchapixel 稍微复杂一点之外,它还可以为该领域的学生和工程师提供参考。它仍然由第一版的作者(2004 年出版,Scratchapixel 于 2007 年左右开始)、Math Pharr、Greg Humphreys 和 Pat Hanrahan(更多人为后续版本做出贡献)维护,他们不断更新本书和代码最新的技术。
如果您仍然觉得这段代码难以理解,请不要担心。我们可能花了好几年的时间才熟悉所有这些概念。但是,我们希望通过本课程中给出的解释,您将能够遵循此代码的大致结构,能够理解它的作用,并获得“现在我终于明白了”的时刻。
Spectrum SingleScatteringIntegrator::Li(const Scene *scene, const Renderer *renderer, const RayDifferential &ray, const Sample *sample, RNG &rng, Spectrum *T, MemoryArena &arena) const { // [comment] // Find the intersection boundaries (t0, t1) with the volume object. If the ray doesn't // intersect the volumetric object, then set the transmission to 1 and return 0 as a color. // [/comment] VolumeRegion *vr = scene->volumeRegion; float t0, t1; if (!vr || !vr->IntersectP(ray, &t0, &t1) || (t1-t0) == 0.f) { *T = 1.f; return 0.f; } // [comment] // If we have an intersection. Set the global transmission (transparency) to 1, and the variable // in which we will store the final color (named Lv here) to 0. Compute the number of samples // and adjust the step size accordingly. // [/comment] // Do single scattering volume integration in _vr_ Spectrum Lv(0.); // Prepare for volume integration stepping int nSamples = Ceil2Int((t1-t0) / stepSize); float step = (t1 - t0) / nSamples; Spectrum Tr(1.f); Point p = ray(t0), pPrev; Vector w = -ray.d; t0 += sample->oneD[scatterSampleOffset][0] * step; // Compute sample patterns for single scattering samples float *lightNum = arena.Alloc<float>(nSamples); LDShuffleScrambled1D(1, nSamples, lightNum, rng); float *lightComp = arena.Alloc<float>(nSamples); LDShuffleScrambled1D(1, nSamples, lightComp, rng); float *lightPos = arena.Alloc<float>(2*nSamples); LDShuffleScrambled2D(1, nSamples, lightPos, rng); uint32_t sampOffset = 0; // [comment] // Ray-march (forward). This is the main loop, where we will loop over the segments and // calculate each sample's respective opacity and in-scattering contribution to the // final volume transparency (Tr) and color (Lv). // [/comment] for (int i = 0; i < nSamples; ++i, t0 += step) { // Advance to sample at _t0_ and update _T_ // [comment] // Update the sample position. Then evaluate the density at that point in the volume. // We haven't studied this part yet. This is the topic of the next two chapters. For now, // consider that the variable stepTau is the density variable from our code. // The sample position is jittered. Then apply Beer's law to attenuate our global // transmission variable (Tr) with our current sample's opacity. // [/comment] pPrev = p; p = ray(t0); Ray tauRay(pPrev, p - pPrev, 0.f, 1.f, ray.time, ray.depth); Spectrum stepTau = vr->tau(tauRay, .5f * stepSize, rng.RandomFloat()); Tr *= Exp(-stepTau); // [comment] // Apply the russian-roulette technique. // [/comment] // Possibly terminate ray marching if transmittance is small if (Tr.y() < 1e-3) { const float continueProb = .5f; if (rng.RandomFloat() > continueProb) { Tr = 0.f; break; } Tr /= continueProb; } // [comment] // We survived. Let's compute the in-scattering contribution for that sample. Normally // one could calculate the contribution of each light in the scene. However, this code // uses a different technique. It selects one light randomly and calculates the contribution // of that one single light instead. This is another example of Monte Carlo integration. // Don't worry too much about this for now. We will study this in a future lesson. // [/comment] // Compute single-scattering source term at _p_ Lv += Tr * vr->Lve(p, w, ray.time); Spectrum ss = vr->sigma_s(p, w, ray.time); if (!ss.IsBlack() && scene->lights.size() > 0) { int nLights = scene->lights.size(); int ln = min(Floor2Int(lightNum[sampOffset] * nLights), nLights-1); Light *light = scene->lights[ln]; // Add contribution of _light_ due to scattering at _p_ float pdf; VisibilityTester vis; Vector wo; LightSample ls(lightComp[sampOffset], lightPos[2*sampOffset], lightPos[2*sampOffset+1]); // [comment] // Calculate the light color (color * intensity, etc.) // [/comment] Spectrum L = light->Sample_L(p, 0.f, ls, ray.time, &wo, &pdf, &vis); if (!L.IsBlack() && pdf > 0.f && vis.Unoccluded(scene)) { // [comment] // Multiply the light color by the light transmission value (how much light is left // after it has traveled through the volume to the sample point). Beer's law is // applied in the Transmittance function (code not shown here but you can check // PBRT source code). // [/comment] Spectrum Ld = L * vis.Transmittance(scene, renderer, NULL, rng, arena); // [comment] // Then add the in-scattering contribution to our final color. Note here that we // multiply by all the right terms: Tr (the volume current transparency value), // ss (the scattering term), vr->p (the phase function), Ld (the light contribution, // the Li(x) term). Forget about the other terms, they have to do with the Monte Carlo // integration method we talked about earlier. Note: we don't multiply by the step size // here because it's done at the very end. Outside the ray-marching loop. // [/comment] Lv += Tr * ss * vr->p(p, w, -wo, ray.time) * Ld * float(nLights) / pdf; } } ++sampOffset; } *T = Tr; // [comment] // Finally multiply the final color by the step size. In our code, we've done it in the // ray-marching loop for clarity. But for optimization, you might want to do it at // the very end which is what they decided to do here. // [/comment] return Lv * step; }
本章的源代码可在课程结束时获取。它应该产生以下图像。请注意,在此版本的代码中,浅色具有更高的值。相位函数引入了除以
4
π
4\pi
4π 的部分,这就是为什么我们现在需要大量增加光颜色的原因。
这里贴一下我加了注释的代码,总体和第一篇源码差不多,差别是加了相位函数,并且在选择样本的时候使用了之前提到的抖动样本位置方法:
//[header] // Rendering volumetric object using ray-marching. A basic implementation (chapter 1 & 2) // // https://www.scratchapixel.com/lessons/advanced-rendering/volume-rendering-for-developers/ray-marching-algorithm //[/header] //[compile] // Download the raymarch-chap3.cpp file to a folder. // Open a shell/terminal, and run the following command where the file is saved: // // clang++ -O3 raymarch-chap3.cpp -o render -std=c++17 // // You can use c++ if you don't use clang++ // // Run with: ./render. Open the resulting image (ppm) in Photoshop or any program // reading PPM files. //[/compile] //[ignore] // Copyright (C) 2022 www.scratchapixel.com // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see <http://www.gnu.org/licenses/>. //[/ignore] #define _USE_MATH_DEFINES #include <cmath> #include <iostream> #include <fstream> #include <memory> #include <algorithm> #include <vector> #include <random> template<typename T> T clamp(T x, T min, T max) { return std::max(std::min(x, max), min); } struct vec3 { float x{ 0 }, y{ 0 }, z{ 0 }; vec3& nor() { float len = x * x + y * y + z * z; if (len != 0) len = sqrtf(len); x /= len, y /= len, z /= len; return *this; } float length() const { return sqrtf(x * x + y * y + z * z); } float operator * (const vec3& v) const { return x * v.x + y * v.y + z * v.z; } vec3 operator - (const vec3& v) const { return vec3{ x - v.x, y - v.y, z - v.z }; } vec3 operator + (const vec3& v) const { return vec3{ x + v.x, y + v.y, z + v.z }; } vec3& operator += (const vec3& v) { x += v.x, y += v.y, z += v.z; return *this; } vec3& operator *= (const float& r) { x *= r, y *= r, z *= r; return *this; } friend vec3 operator * (const float& r, const vec3& v) { return vec3{ v.x * r, v.y * r, v.z * r }; } friend std::ostream& operator << (std::ostream& os, const vec3& v) { os << v.x << " " << v.y << " " << v.z; return os; } vec3 operator * (const float& r) const { return vec3{ x * r, y * r, z * r }; } }; constexpr vec3 background_color{ 0.572f, 0.772f, 0.921f }; constexpr float floatMax = std::numeric_limits<float>::max(); struct IsectData { float t0{ floatMax }, t1{ floatMax }; vec3 pHit; vec3 nHit; bool inside{ false }; }; struct Object { public: vec3 color; int type{ 0 }; virtual bool intersect(const vec3&, const vec3&, IsectData&) const = 0; virtual ~Object() {} Object() {} }; bool solveQuadratic(float a, float b, float c, float& r0, float& r1) { float d = b * b - 4 * a * c; if (d < 0) return false; else if (d == 0) r0 = r1 = -0.5f * b / a; else { float q = (b > 0) ? -0.5f * (b + sqrtf(d)) : -0.5f * (b - sqrtf(d)); r0 = q / a; r1 = c / q; } if (r0 > r1) std::swap(r0, r1); return true; } struct Sphere : Object { public: Sphere() { color = vec3{ 1, 0, 0 }; type = 1; } bool intersect(const vec3& rayOrig, const vec3& rayDir, IsectData& isect) const override { vec3 rayOrigc = rayOrig - center; float a = rayDir * rayDir; float b = 2 * (rayDir * rayOrigc); float c = rayOrigc * rayOrigc - radius * radius; if (!solveQuadratic(a, b, c, isect.t0, isect.t1)) return false; if (isect.t0 < 0) { if (isect.t1 < 0) return false; else { isect.inside = true; isect.t0 = 0; } } return true; } float radius{ 1 }; vec3 center{ 0, 0, -4 }; }; std::default_random_engine generator; //随机数生成器 std::uniform_real_distribution<float> distribution(0.0, 1.0); // [comment] // The Henyey-Greenstein phase function // [/comment] float p(const float& g, const float& cos_theta) { float denom = 1 + g * g - 2 * g * cos_theta; return 1 / (4 * M_PI) * (1 - g * g) / (denom * sqrtf(denom)); } vec3 integrate(const vec3& ray_orig, const vec3& ray_dir, const std::vector<std::unique_ptr<Object>>& objects) { const Object* hit_object = nullptr; IsectData isect; for (const auto& object : objects) { IsectData isect_object; if (object->intersect(ray_orig, ray_dir, isect_object)) { hit_object = object.get(); isect = isect_object; } } if (!hit_object) return background_color; float step_size = 0.1; float absorption = 0.5; float scattering = 0.5; float density = 0.25; float g = 0; // henyey-greenstein asymetry factor uint8_t d = 2; // russian roulette "probability" int ns = std::ceil((isect.t1 - isect.t0) / step_size); step_size = (isect.t1 - isect.t0) / ns; vec3 light_dir{ -1, 0, 0 }; vec3 light_color{ 13, 13, 13 }; IsectData isect_vol; float transparency = 1; // initialize transmission to 1 (fully transparent) vec3 result{ 0 }; // initialize volumetric sphere color to 0 // [comment] // The ray-marching loop (forward, march from t0 to t1) // [/comment] for (int n = 0; n < ns; ++n) { // [comment] // Jiterring the sample position // [/comment] float t = isect.t0 + step_size * (n + distribution(generator)); //distribution(generator)是生成一个范围在[0,1]之间的的随机数,这里就是抖动样本位置 vec3 sample_pos = ray_orig + t * ray_dir; // compute sample transmission float sample_attenuation = exp(-step_size * density * (scattering + absorption)); transparency *= sample_attenuation; // In-scattering. Find distance light travels through volumetric sphere to the sample. // Then use Beer's law to attenuate the light contribution due to in-scattering. if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) { float light_attenuation = exp(-density * isect_vol.t1 * (scattering + absorption)); float cos_theta = (ray_dir * light_dir); //这里要使用相位函数,所以提前计算好cos(θ) //然后将相位函数项加入到结果中 result += light_color * light_attenuation * density * scattering * p(g, cos_theta) * transparency * step_size; } // [comment] // Russian roulette // [/comment] if (transparency < 1e-3) { if (distribution(generator) > 1.f / d) break; else transparency *= d; } } // combine background color and volumetric sphere color return background_color * transparency + result; } int main() { unsigned int width = 640, height = 480; auto buffer = std::make_unique<unsigned char[]>(width * height * 3); auto frameAspectRatio = width / float(height); float fov = 45; float focal = tan(M_PI / 180 * fov * 0.5); std::vector<std::unique_ptr<Object>> geo; std::unique_ptr<Sphere> sph = std::make_unique<Sphere>(); sph->radius = 5; sph->center.x = 0; sph->center.y = 0; sph->center.z = -20; geo.push_back(std::move(sph)); vec3 rayOrig, rayDir; // ray origin & direction unsigned int offset = 0; for (unsigned int j = 0; j < height; ++j) { for (unsigned int i = 0; i < width; ++i) { rayDir.x = (2.f * (i + 0.5f) / width - 1) * focal; rayDir.y = (1 - 2.f * (j + 0.5f) / height) * focal * 1 / frameAspectRatio; // Maya style rayDir.z = -1.f; rayDir.nor(); vec3 c = integrate(rayOrig, rayDir, geo); buffer[offset++] = clamp(c.x, 0.f, 1.f) * 255; buffer[offset++] = clamp(c.y, 0.f, 1.f) * 255; buffer[offset++] = clamp(c.z, 0.f, 1.f) * 255; } } // writing file std::ofstream ofs; ofs.open("./image.ppm", std::ios::binary); ofs << "P6\n" << width << " " << height << "\n255\n"; ofs.write(reinterpret_cast<const char*>(buffer.get()), width * height * 3); ofs.close(); return 0; }
如果你已经走到这一步了,那么恭喜你。您毕业了,Scratchapixel 将为您颁发虚拟荣誉证书。已经涵盖了这些算法如何工作的核心。剩下的章节更多地是关于使用我们迄今为止所学到和构建的内容,最终获得一些乐趣并制作一些很酷的图像。最后,在最后一章中,我们将利用迄今为止所学到的一切,看看它如何转化为实际方程,用于描述光能穿过参与介质(空气、烟雾、云 、水等)并与之相互作用时的光能通量。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。