跳至主要内容
SpatialLabFollow the Journey

SPATIALLAB / NOTEBOOK

返回全部文章
SPATIAL INTELLIGENCE
文章目录

0. Introduction

notion image
想象这样一个场景。
戴上 Apple Vision Pro,眼前展开的是一个可以自由探索的三维世界。
这个世界可能来自一段真实的室内视频,也可能由一张照片生成。可以在其中自由移动视角,从不同角度观察物体的形状与空间结构。场景中还存在一个能够被动作驱动的数字人,它可以在环境中移动、避开障碍,并与周围的物体发生交互。
更进一步,只需通过自然语言发出指令,数字人便能够理解目标物体的位置,执行相应的动作,让整个三维世界的状态随之改变。
此时,这个世界已经不再只是一个用于展示三维画面的虚拟场景,而是一个能够被重建、生成、驱动和交互,并逐步具备空间理解与推理能力的智能系统。
如何从最基础的数学、空间表示与可微渲染出发,一步步构建这样一个最终可以亲身进入、与之交互的三维世界?
这正是 Spatial Intelligence From Scratch 系列博客试图回答的核心问题。
整个构建过程不会从一个现成的神经渲染框架或复杂的世界模型开始,也不是对一系列彼此独立的研究论文进行复现,而是围绕同一个持续演进的三维世界,逐步建立它所需要的能力。
从相机几何与像素射线出发,理解三维空间如何形成二维图像;通过 Volume Rendering、NeRF 与 3D Gaussian Splatting,让场景能够从图像中被学习、重建与渲染;进一步引入几何先验,使系统从多视角重建走向少视角重建与单图三维生成;随后将可驱动的数字人引入场景,建立运动、碰撞与交互机制,让世界能够随动作改变;最后加入语义理解、空间推理与语言接口,使整个系统能够理解指令、执行动作并验证结果。
每一个阶段都不是为了引入一项孤立的新技术,而是为同一个三维世界补上当前缺失的能力。
这些能力将通过尽可能精简的 PyTorch 实现、可复现的实验和逐步扩展的项目建立起来。从一个最基本的渲染器,到可以重建真实环境的三维场景,再到能够生成内容、容纳数字人并响应语言和动作的交互世界,最终形成一个可以通过 Apple Vision Pro 亲身探索的空间智能系统。

在这一切发生之前,还有一个更加基础的问题需要解决。
一个由数学和数据描述的三维世界,究竟怎样才能被看见?
假设三维空间已经由一个连续函数表示。给定空间中的任意位置,这个函数就能返回对应的密度与颜色。
从数学上看,空间中的信息已经存在。但这些数值并不会自动变成一张可以观察的图像。
如果在这个空间中放置一台虚拟相机,每个像素应该显示什么颜色?
图 1|可微体渲染将三维 Density/Color Field 沿相机射线转换为 RGB 图像,并将图像重建损失的梯度传回场参数,形成从渲染到优化的闭环。
图 1|可微体渲染将三维 Density/Color Field 沿相机射线转换为 RGB 图像,并将图像重建损失的梯度传回场参数,形成从渲染到优化的闭环。
 
一条射线可能穿过多个具有不同密度和颜色的区域。靠近相机的介质会改变后方内容的可见性,而沿途不同位置又可能同时对最终像素产生贡献。因此,读取单个空间点的颜色,或者直接平均整条射线上的颜色,都无法正确描述这个成像过程。
要让这样的三维表示变得可见,首先需要建立一套从空间属性到像素颜色的数学映射。
这就是 Volume Rendering(体渲染)在整个构建过程中的位置:它让一个由连续密度场描述的三维世界,第一次拥有了从相机视角生成图像的能力。
更进一步,如果这个过程是可微的,图像就不只是三维世界的最终展示结果,还可以成为反向优化三维表示的监督信号。
这为下一阶段从图像中学习和重建三维世界提供了基础。
本篇将从最简单的一条射线开始,逐步推导 Absorption、Emission、Transmittance 和 Volume Rendering Equation,再将连续积分转化为 Ray Marching 与 Alpha Compositing,并使用 PyTorch 从零实现一个最小可微体渲染器。
notion image
随后,借助必要的针孔相机模型,将单条射线的计算扩展为完整图像,并通过一个简单的梯度优化实验,验证图像误差如何反向调整三维场参数。

1. 一个像素对应什么?

图 2|相机像素对应一条参数化观察射线 ;沿射线采样得到的空间位置用于查询局部 Density 与 Color,为像素合成提供输入。
图 2|相机像素对应一条参数化观察射线 ;沿射线采样得到的空间位置用于查询局部 Density 与 Color,为像素合成提供输入。
在开始推导 Volume Rendering Equation 之前,我们先尝试解决一个更小的问题。 假设现在有一个三维世界,以及一台放在这个世界中的虚拟相机。我们的目标很简单:计算相机看到的图像(计算一个像素的颜色)。 但一张图像可能包含成千上万个像素。如果从一开始就考虑整张图像、复杂的相机参数,以及三维空间中的所有光学相互作用,我们会很难分辨每一个计算步骤究竟在解决什么问题。 因此,我们先把问题缩小到极致:假设相机只有一个像素。 对于理想针孔相机,我们可以将这个像素的观测与一条穿过三维世界的射线联系起来。于是,原本复杂的成像问题便有了一个明确的起点:沿这条射线,我们需要知道什么? 我们将使用一种非常简单的三维表示。给定任意空间坐标,这个表示都能够返回一个 Density 和一个 Color。接下来,我们先把一条射线和这个三维场连接起来,观察沿途的 Density 与 Color 如何变化。 这还不是一个真正的 Volume Renderer。但它将为后面所有的光传输计算提供输入。

1.1 一个像素如何对应三维空间中的一条射线?

要从一个三维世界生成图像,首先需要建立图像与三维空间之间的联系。
一张图像通常包含成千上万个像素。如果一开始就考虑整幅图像的计算,射线生成、场景查询与颜色合成会同时出现,很难分辨各个步骤究竟在解决什么问题。
因此,先从最简单的情况开始:假设虚拟相机只有一个像素。
本节的目标是为这个像素建立一条指向三维场景的射线,并通过 PyTorch 获取射线上的一系列三维位置。暂时不需要计算像素颜色,也不需要引入复杂的相机参数。

1.1.1 从一个像素到一条观察路径

一个像素如何观察三维世界?
在理想针孔相机(Pinhole Camera)中,场景中的光通过一个无限小的针孔到达成像平面。对于成像平面上的一个确定位置,可以找到一条经过针孔并延伸进入三维场景的直线。
这意味着,在理想化模型下,一个像素可以与三维空间中的一条观察射线(Camera Ray)建立对应关系
这种简化也与像素所观测的物理量有关。
在图形学中,辐射亮度(Radiance)通常记为:
其中, 表示空间位置, 表示方向。Radiance 描述指定位置、指定方向上的辐射功率密度。
这里最重要的并不是它的完整物理单位,而是一个事实:光的描述不仅与位置有关,也与方向有关。
在理想针孔相机和线性成像近似下,一个像素的观测可以与对应观察方向上到达相机的 Radiance 联系起来。真实相机还会受到光圈、像素面积、曝光时间和传感器响应的影响,但这些因素暂时不属于当前最小模型。
现在,问题可以进一步缩小:
如果已经确定相机的观察位置和方向,如何描述这条射线经过的三维空间?

1.1.2 将观察路径写成数学表达

一条射线只需要两个基本信息:起点与方向。
假设射线从三维空间中的位置 出发,沿方向 前进,那么射线上的任意位置都可以写成:
其中:
  • 是射线起点(Origin)。
  • 是射线方向(Direction)。
  • 是沿射线前进的参数。
  • 是参数 对应的空间位置。
为了让参数 直接表示沿射线的距离,还需要一个约定 ,即方向向量的长度为 1。
有了这个条件,沿射线前进 个距离单位,就相当于从起点移动 。
例如,假设观察位置是坐标原点 ,射线沿 轴正方向前进 ,代入可得 。
因此:
射线并不对应一个固定的三维点,而是定义了一整条观察路径。不同的 代表这条路径上不同距离处的位置。
这里还有一个方向约定需要明确: 指的是从相机指向场景的查询方向,并不是光实际进入相机时的传播方向。后续计算体渲染时,将统一按照从相机到场景、由近到远的顺序组织查询位置。
至于图像中不同像素如何生成不同的 ,将在需要渲染完整图像时再引入相机内参与坐标变换。当前只需要一条固定射线,就足以开始构建渲染器。

1.1.3 使用 PyTorch 构建第一条射线

接下来,将上述数学表达转换成代码。
先定义射线起点与方向,再从 到 选择 9 个等间隔位置。
运行后得到:
这段代码只做了一件事:根据一条射线和一组距离参数,计算对应的三维坐标。
但它已经构成后续 Volume Rendering 的第一个计算组件。

1.1.4 我们知道沿射线走到了哪里,但还不知道那里有什么

到这里,第一个组件已经完成。
给定射线起点、方向以及一组距离参数,程序能够返回对应的三维坐标:
但这些坐标还不足以形成图像。
例如, 和 只是空间中的两个位置。仅凭坐标,无法判断那里是空空间、浓密的介质,还是一个具有特定颜色的区域。
换句话说,射线回答了相机正在看向哪里,却没有回答沿途存在什么。
要继续构建渲染器,就需要一种能够在任意三维位置提供信息的场景表示。
下一步,空间中的每个位置将不再只是一个坐标。给定 ,一个三维场需要返回对应的 Density 和 Color:
这就是接下来要构建的 Density and Color Field。

1.2 如何表示一个三维世界?

上一节已经将一条观察射线表示为:
通过改变参数 ,这条射线可以定位三维空间中的任意一系列位置。
但仅仅拥有这些坐标,还不足以生成一个像素。例如,位置 和 只是两个三维坐标。当前系统并不知道这些位置是空的,还是存在某种具有颜色的介质。
要让这些位置参与后续的成像计算,三维世界必须先具备一种基本能力:给定任意空间位置,能够返回该位置的局部属性。
本节将构建这样一个三维场(3D Field)。它接收一个三维坐标 ,返回两个结果:
  • Density(密度):描述该位置的局部光学介质强度。
  • Color(颜色):描述该位置的有效 RGB 颜色。
最终得到的查询接口非常简单:
它还不是一个 Volume Renderer,但会成为后续体渲染计算的输入。

1.2.1 从表面到体积:三维空间应该如何表示?

图 3|Surface Representation 描述物体边界,Volume Representation 描述空间内部属性;Voxel Grid 以离散单元存储体数据,连续 Field 则按空间坐标查询 Density 与 Color。
图 3|Surface Representation 描述物体边界,Volume Representation 描述空间内部属性;Voxel Grid 以离散单元存储体数据,连续 Field 则按空间坐标查询 Density 与 Color。
一种常见的三维表示方法,是使用几何表面描述物体。
例如,三角网格(Triangle Mesh)通过顶点和三角形定义物体边界。对于一个由不透明表面组成的场景,渲染器可以寻找观察射线与物体表面的交点,再根据交点处的材质和光照计算最终颜色。
这种表示方式适合具有明确几何边界的对象。
但考虑另一种场景:一团空间分布不均匀的烟雾。
烟雾没有一个简单、明确的表面。不同位置的浓度可能不同,而且这种变化发生在整个三维空间内部,而不只是某个外部边界上。
如果希望描述这样的场景,更自然的方式是直接为空间中的位置定义属性。
这就引出了 Volume Representation(体表示)。
与主要记录物体边界的 Surface Representation 不同,Volume Representation 允许属性分布在三维空间内部。给定任意位置,都可以查询该位置对应的数据。
需要注意,这两种表示并不互相排斥。Surface Rendering 也可以结合参与介质等方法处理复杂场景。这里选择 Volume Representation,是因为接下来要构建的体渲染器需要沿整条观察射线读取空间属性,而不仅仅寻找一个表面交点。
那么,这些属性应该如何存储?
一种直接的方案是使用 Voxel Grid(体素网格)。
类似于二维图像由 Pixel 组成,三维空间可以被划分为许多规则排列的 Voxel。假设每个 Voxel 存储一个非负标量,就得到:
其中, 表示三维网格的索引, 表示对应网格单元的属性值。
例如,这个数值可以表示局部介质的光学密度。医学 CT 数据和科学可视化中也经常使用类似的体数据表示,不过具体数值的物理含义取决于成像模态和数据处理方式。
Voxel Grid 提供了一种明确的存储方式,但它也带来了一个问题:上一节的射线可以经过任意连续的三维位置,而这些位置不一定恰好落在规则网格上。
当然,可以通过坐标映射与插值查询 Voxel Grid。但对于当前的最小实现,还有一种更直接的选择:不显式存储每个网格单元,而是用一个函数计算任意位置的属性。
于是,三维空间可以表示为一个连续的 Density Field(密度场):
这里, 表示三维空间中的任意位置, 则返回该位置的 Density。
这个表达式建立了一种重要的抽象:三维表示不一定是一组预先存储的几何元素,也可以是一个能够接受空间坐标并返回属性的函数。
无论之后使用解析函数、插值体素网格,还是神经网络实现这个查询过程,外部系统只需要知道如何输入坐标并读取结果。
不过,Density Field 目前只能返回一个标量。对于后续的彩色图像渲染,这还不够。
假设三维空间中存在两团介质,一团呈红色,另一团呈蓝色。仅凭 Density,系统无法区分它们的颜色。
因此,还需要定义一个 Color Field(颜色场):
它将三维位置映射为一个 RGB 向量:
这里暂时使用与观察方向无关的颜色函数,以保持第一个实现足够简单。后续 NeRF 中的颜色还可以依赖观察方向,但那不是当前阶段需要解决的问题。
将 Density 和 Color 合在一起,就得到本节真正需要的三维场:
也就是说,给定任意三维位置 ,这个函数同时返回局部密度和颜色。
还需要澄清 Density 的含义。
本文使用的 并不是普通的质量密度,也不是一个直接位于 之间的不透明度概率。在后续采用的 Emission–Absorption 模型中,它描述单位传播距离上的光学消光或终止强度。如果空间距离以米为单位, 的单位就是 。
因此, 越大,可以理解为单位距离上的光学相互作用越强。但一段介质究竟有多不透明,还取决于光在其中传播了多远。这个关系将在后面建立光传输模型时推导。
同样,Color Field 中的 是当前模型采用的有效颜色属性,并不意味着真实世界中的所有物体都会自发光。
到这里,三维场的输入输出已经明确。剩下的问题是:怎样构造一个能够实际计算这些值的函数?

1.2.2 使用解析函数构建第一个三维场

为了把注意力集中在 Volume Rendering 的核心机制上,当前场景不需要使用复杂的神经网络,也不需要加载真实的三维数据。
这里选择一个可以直接计算的解析场:使用两个位于不同三维位置的高斯密度团,分别赋予红色和蓝色。
选择高斯函数有几个原因。首先,它具有简单的数学表达式;其次,它在空间中连续变化,便于观察 Density 的分布;最后,只需要调整中心位置、幅度和尺度,就能够构造不同的实验场景。
需要说明的是,这里使用高斯函数只是为了定义一个可控的 Density Field,并不是在实现 3D Gaussian Splatting。
先考虑单个三维高斯密度团:
其中:
  • 是第 个密度团的中心位置;
  • 控制密度团在空间中的分布尺度;
  • 控制中心位置的 Density 峰值。
这个公式的空间含义非常直观。
当查询位置恰好位于中心 时,平方距离为零,因此:
随着查询位置远离中心,指数项逐渐减小,Density 也随之下降。
这样就获得了一个连续变化、处处非负的三维 Density 分布。
为了构建包含两个局部区域的场景,分别定义红色和蓝色密度团:
整个场景的 Density 定义为二者之和:
接下来,为两个密度分量分别指定固定颜色:
如果一个位置同时受到两个密度分量影响,可以使用局部密度作为混合权重,定义该位置的颜色:
其中 是一个很小的正数,用于避免数值上的除零问题。
这是一种为实验场景人为选择的局部颜色定义,而不是体渲染的颜色合成公式。它只决定某个位置的 Color Field 取值,并没有计算相机最终观察到的像素。
现在,将这两个函数转换为 PyTorch 代码。
整个函数只包含距离计算、指数函数、求和以及局部颜色混合,没有涉及任何渲染算法。
但这段代码已经完成了一项重要工作:它将一个抽象的三维场,变成了可以通过 PyTorch 直接查询的函数。
下面把其中几个关键操作与数学定义对应起来。
首先:
计算的是查询位置与两个高斯中心之间的相对位移:
如果输入包含 个三维位置,那么 diff 的形状就是 [N, 2, 3]。
其中第二个维度对应两个高斯分量,最后一个维度对应三维坐标。
随后:
计算每个查询位置到各个中心的平方距离:
因此,squared_distance 的形状为 [N, 2]。
接下来:
正对应之前定义的三维高斯函数:
这里设置 、,并使用两个不同的中心位置。
如果坐标单位为米,那么 的单位为米,Density 幅度 的单位则为逆米。代码中的浮点数省略了单位,但数学模型仍需保持这个约定。
然后:
把两个局部高斯分量合成为整个场的 Density:
最后:
实现按局部密度进行颜色混合。
靠近红色高斯中心时,红色分量占主导,返回的颜色就接近 ;靠近蓝色中心时,返回的颜色接近 。在两个密度团重叠的位置,颜色会发生连续过渡。
至此,三维世界第一次具有了可查询的空间属性。
更重要的是,query_field() 完全不知道相机的位置,也不知道某条射线的方向。
它只回答一个问题:给定这个三维坐标,这里的 Density 和 Color 是多少?
这意味着三维场与渲染过程可以作为两个独立组件构建。未来即使将解析函数替换成神经网络,只要保持同样的查询接口,后续渲染器就不需要改变它对场的基本调用方式。

到这里,一个最小的 Density/Color Field 已经构建完成。
它还不能生成图像,但已经能够对任意三维位置返回局部属性。
现在,整个系统拥有了两个彼此独立的组件。
上一节的射线描述观察路径:
本节的三维场描述空间属性:
将两者组合,就能得到一个自然的查询关系:
这意味着,沿着一条观察射线前进时,三维世界的 Density 与 Color 都可以被读取出来。
下一节将把这两个组件真正连接起来:沿一条 Ray 选择多个位置,查询对应的 Density 和 Color,并观察它们如何随着射线距离变化。
这将为计算第一个像素提供全部局部数据,同时也会引出一个新的问题:即使已经知道沿途每个位置的颜色,究竟应该怎样把它们合成为相机最终看到的一个 RGB?

1.3 一次朴素的渲染尝试:为什么直接累加颜色行不通?

上一节已经将观察射线与三维场连接起来。沿着一条固定的 Ray,可以查询到不同位置的 Density 和 Color:
在 PyTorch 中,整个过程只需要两行代码:
现在,sigma 保存了沿射线采样得到的局部密度,rgb 则保存了对应位置的颜色。
但这还不是渲染。
对于当前的理想针孔相机模型,一条观察射线最终应该对应一个像素颜色:
而目前的查询函数返回的是 个局部 RGB:
如何将沿射线分布的多个局部颜色,合成为一个像素的 RGB?
不妨先尝试一个最简单的方案,看看它究竟能够做到什么。

1.3.1 第一次尝试:按照 Density 对颜色加权平均

最直接的想法,是将所有采样位置的颜色求平均:
它确实能够将 个 RGB 向量转换成一个 RGB 向量。
但这种方法完全忽略了之前费力构建的 Density Field。
例如,一条射线可能经过一段密度很低的空间,随后穿过一团浓密的红色介质。如果所有采样位置都具有相同权重,低密度区域的颜色就可能与高密度区域一样重要。
这显然没有充分利用三维场提供的信息。
一个看起来更合理的方案,是按照局部 Density 为颜色分配权重:
其中, 表示第 个位置的 Density, 是对应的 RGB。
这样,Density 较大的位置就能在最终颜色中占据更大的比例。
需要注意,这里只是人为设计的加权平均规则。Density 本身并不是一个可以直接作为最终像素权重的概率。这个方法也没有考虑每个采样位置代表的空间长度,更没有引入光传输模型。
不过,作为第一次渲染尝试,它足够简单,而且可以立即实现。
代码直接对应刚刚定义的数学表达式。
首先,sigma[:, None] * rgb 将每个位置的 RGB 乘上局部 Density:
然后沿采样维度求和:
最后除以 Density 总和,使结果成为一个归一化的加权平均:
到这里,这条射线终于能够输出一个 RGB 了。
但代码能够返回颜色,并不意味着它已经正确模拟了成像过程。
为了检查这个结果是否合理,可以设计一个非常简单的对照实验。

1.3.2 一个反例:交换红色与蓝色介质的前后位置

回到上一节构建的双高斯 Density Field。
场景中有两个密度团:一个位于靠近相机的位置,另一个位于更远处。它们具有相同的 Density Amplitude 和空间尺度,但拥有不同的中心位置。
当前的观察射线为:
两个密度团的中心分别为:
因此,沿这条射线观察时,第一个密度团更靠近相机,第二个密度团更远。
现在构造两个场景。
Scene A:红色在前,蓝色在后。
靠近相机的密度团使用红色:
远处的密度团使用蓝色:
Scene B:蓝色在前,红色在后。
保持两个高斯密度团的位置、形状和 Density 完全不变,只交换它们的颜色。
这样,两个场景拥有完全相同的 Density 分布,唯一的区别是红色和蓝色在观察路径上的前后顺序。
为了进行这个实验,只需对上一节的 query_field() 做一个很小的修改:给函数增加 colors=palette 参数,并在计算 rgb 时使用 colors 代替原先固定的 palette。其余 Density 计算不需要改变。
随后运行:
对于上一节定义的两个对称高斯密度团,运行结果为:
两个场景的颜色顺序已经交换,但最终像素却几乎完全相同。
图 4|在对称 Density 分布下,交换近处与远处红、蓝介质的顺序仍得到相同的朴素加权平均结果,说明该方法无法表达沿射线的前后遮挡关系。
图 4|在对称 Density 分布下,交换近处与远处红、蓝介质的顺序仍得到相同的朴素加权平均结果,说明该方法无法表达沿射线的前后遮挡关系。
为什么会这样?
回到朴素合成公式:
它只考虑各个采样位置的 Density 和 Color,然后将它们累加。
对于当前的双高斯场,设第一个高斯分量在全部采样位置上的密度之和为 ,第二个分量为 。
由于 Color Field 采用两个高斯分量的局部密度进行颜色混合,Scene A 的朴素合成结果可以写成:
交换颜色之后:
当前两个高斯具有相同的幅度、宽度,并且沿观察射线关于采样区间中点对称。因此,在这个对称构造中:
于是:
这就是实验得到相同颜色的原因。
这个结果并不是任意两团介质交换颜色后都会出现的现象。它依赖当前特意构造的对称 Density 分布。
但它暴露了朴素加权平均的一个根本缺陷:
当前的合成规则只关心沿途出现了多少 Density 和 Color,却没有描述近处的介质如何改变远处颜色的可见性。

1.3.3 缺少的究竟是什么?

考虑 Scene A。
如果前方的红色介质足够浓密,那么来自后方蓝色介质的光在抵达相机之前,就必须先穿过这团红色介质。
在传播过程中,一部分光可能被介质吸收,或者因其他光学相互作用离开原本的传播方向。
因此,后方蓝色区域能对最终像素产生多大影响,不应该只取决于它自身的 Density。
它还取决于相机与蓝色区域之间,已经经过了多少介质。
Scene B 则正好相反。
这次蓝色介质位于前方,红色介质处于更远的位置。只要前方介质对光的传播具有非零影响,两种顺序就可能产生不同的最终观测。
但在我们的实现中:
每个采样位置都被当作一个可以独立贡献颜色的项。
代码没有记录前面的介质已经对光产生了多少影响,也没有描述后面的颜色需要怎样穿过前方介质才能到达相机。
因此,即使对称场景中交换红色与蓝色的前后关系,普通加权平均也无法反映这种差别。
这里还存在第二个问题:Density 是单位距离上的局部强度,而不是一个可以脱离路径长度直接使用的颜色权重。
沿着一段很短的介质传播,与穿过一段很长但 Density 相同的介质,理应产生不同的累计效果。当前公式却没有明确描述路径长度在其中起到的作用。
所以,问题不在于某个颜色的权重设置得不够合理,也不是简单地换一种加权函数就一定能够解决。
真正缺失的是一套描述光在介质中沿路径传播时,如何受到局部介质影响的规律。

从颜色累加转向光传输

到目前为止,三维场已经能够提供每个位置的 Density 和 Color,观察射线也能够读取沿途的局部属性。
但刚刚的实验说明,这些属性不能被当作互不相关的颜色直接合并。
一个位置对最终像素的贡献,不仅与其自身属性有关,还可能受到它与相机之间其他介质的影响。
因此,在继续改进颜色合成算法之前,必须先回答一个更基本的问题:
当光穿过一小段介质时,究竟会发生什么?
例如,一束已经存在的光经过介质后会损失多少?介质本身又能否向当前方向增加新的光?
如果能够建立这种局部变化规律,就有可能进一步计算一整段传播路径的效果,并最终推导出正确的像素合成方式。
下一章将从最基本的 Absorption(吸收) 与 Emission(发射) 开始,建立描述局部光传输的数学模型。
这也是从一个能够查询三维场的程序,迈向真正 Volume Renderer 的关键一步。


2. 局部光传输模型:一小段介质如何改变光?

图 5|光穿过厚度为 ds 的介质段时,Absorption 与 Out-scattering 减少当前方向的 Radiance,Emission 与 In-scattering 则增加 Radiance。
图 5|光穿过厚度为 ds 的介质段时,Absorption 与 Out-scattering 减少当前方向的 Radiance,Emission 与 In-scattering 则增加 Radiance。
上一章已经构建了一个能够沿观察射线查询的三维场。对于任意位置 ,Density/Color Field都可以返回相应的局部密度与颜色:
但上一章最后的实验暴露了一个问题:即使知道射线上所有采样位置的 Density 和 Color,也不能直接通过简单的加权平均得到正确的像素颜色。
原因在于,沿射线分布的颜色并不是彼此独立的。来自远处的光在到达相机之前,可能已经穿过了其他介质。前方介质对光的影响,会进一步改变后方区域最终能够贡献多少颜色。
因此,当前渲染器真正缺少的不是另一种加权平均函数,而是一套描述光如何沿路径传播的数学规律。
直接处理整条射线上的光传输并不容易。因为 Radiance 在传播过程中会持续变化,后面一段介质接收到的光,已经受到前面介质的影响。
这里先把问题缩小到一段极短的介质:
一束光进入这段介质后,原有的 Radiance 会损失多少?介质又会增加多少新的 Radiance?
本章将从这个局部问题出发,建立最小的 Emission–Absorption(EA)模型,并实现一个可以计算局部光传输变化的 PyTorch 函数。
最终需要得到的是:
其中, 是进入介质的 Radiance, 是离开介质的 Radiance,而 则由介质的局部属性决定。
这会让之前只能查询三维空间属性的程序,第一次具备计算光学相互作用的能力。

2.1 光穿过介质时,究竟会发生什么?

先考虑一束光在三维空间中传播的过程。
如果传播路径中没有任何会与光发生相互作用的介质,Radiance 在自由空间中沿直线传播时保持不变。这使得常见的表面渲染可以沿观察射线找到一个表面交点,再计算该点朝相机方向的出射 Radiance。
但当射线穿过烟、雾或其他参与介质(Participating Media)时,这种简单关系就不再成立。
介质可能不断改变沿当前方向传播的光,使相机最终接收到的 Radiance 不再等于某一个位置处的值。
这些变化主要来自三种机制。
Absorption(吸收) 会减少已经存在的 Radiance。
例如,光穿过具有吸收能力的有色液体时,部分波长的光能会被介质吸收。随着传播路径增长,原有光的某些成分逐渐减弱。
Emission(发射) 则会向当前方向增加新的 Radiance。
一团火焰即使没有外部光源照射,仍然可以向外发光。这意味着局部介质不仅能够减少已有光,也可以独立地提供新的光学贡献。
Scattering(散射) 会改变光的传播方向。
例如,太阳光进入大气后,可能被空气分子或悬浮颗粒散射到其他方向。对于某条特定的观察射线,这既可能使原本沿该方向传播的光离开,也可能使其他方向的光进入。
需要注意,现实中的雾景并不是纯 Absorption 的例子。雾中远处物体的对比度降低、背景泛白等现象,往往同时包含明显的 Scattering 和 In-scattering。这里使用这些场景,是为了建立光沿路径发生变化的直觉,而不是将其全部归因于吸收。
从当前传播方向观察,三种机制可以归纳为两类变化:
其中,Absorption 与 Out-scattering 会减少当前方向上的 Radiance;Emission 与 In-scattering 则会增加当前方向上的 Radiance。
因此,要建立一个能够计算的光传输模型,首先需要回答:这些增加和减少,分别与什么因素有关?
一个重要的观察是,光的变化不只取决于当前位置的介质属性,还取决于进入这一位置时已经存在多少 Radiance。
例如,如果某段介质会吸收当前光的一部分,那么进入它的光越强,被吸收的总量通常也越大。
而如果介质本身能够发光,那么即使没有 Incoming Radiance,也可能产生新的贡献。
这说明 Absorption 和 Emission 虽然都影响最终 Radiance,却需要不同的数学表达式。
接下来,先不考虑整条传播路径,而是把其中极短的一段单独取出来,研究光进入和离开时的变化。

2.2 怎样计算一小段介质对光的影响?

2.2.1 怎样描述一小段介质中的光变化?

考虑一束沿固定方向 传播的光。
在它的路径上取出一段非常薄的介质。假设这段介质的横截面积为 ,长度为 ,那么体积为:
它可以被理解成一个极短的圆柱。
为了描述 Radiance 穿过这段介质时发生的变化,定义:
为进入这段介质的 Radiance,以及:
为离开这段介质的 Radiance。
两者之差,就是当前微小区间内的 Radiance 变化:
如果 ,说明沿当前方向传播的 Radiance 减少;如果 ,说明这一方向获得了新的 Radiance。
这里需要明确方向约定。
前面用于查询三维场的 Camera Ray 是从相机指向场景:
但当前分析的是光从场景传播到相机的过程。因此,下文将 定义为沿实际光传播方向增加的距离参数,。
在后续计算相机像素时,两种参数方向需要统一处理,但局部光传输规律本身不会因此改变。
现在,局部计算问题已经非常明确:
接下来,只需要分别描述 Absorption、Emission 和 Scattering 对 的影响。

2.2.2 Absorption:为什么介质会让原有的光变弱?

先只考虑 Absorption。
光穿过一段吸收介质时,一部分已有的 Radiance 会被介质移除。因此,Absorption 的局部贡献必然是负的:
但它究竟与什么因素有关?
为了得到一个直观的数学模型,先假设介质内部均匀分布着大量不透明的小球。
设单位体积内的平均粒子数量为 ,每个小球的半径为 。
这里, 是粒子数密度,单位为 ,它不同于前面 Density Field 使用的光学密度 。
对于刚才定义的微小圆柱,其体积为:
因此,内部的平均粒子数量为:
从光传播方向观察,每个不透明小球的正投影面积为:
假设介质足够薄,粒子投影之间的重叠可以忽略,那么这些粒子遮挡整个圆柱截面的比例约为:
代入粒子数量:
整理得到:
这个结果说明,极短距离内被吸收的光所占比例,与粒子数密度、单个粒子的遮挡面积以及传播距离成正比。
现在考虑进入介质的 Radiance 。
既然 描述的是损失比例,那么被吸收的 Radiance 就是:
将刚刚得到的比例代入:
为了避免在后续公式中反复出现粒子数密度与投影截面积,可以将二者合并成一个系数:
这个系数称为 Absorption Coefficient(吸收系数)。
因此:
这就是局部 Absorption Model。
它的三个因子分别具有明确含义。
首先,损失与当前 Radiance 成正比。吸收作用于已经存在的光,因此在介质性质不变时,进入的光越强,被吸收的总量也越大。
其次,损失与路径长度 成正比。光经过的介质越长,与介质发生相互作用的机会就越多。
最后,负号表示这一项会减少当前方向上的 Radiance。
还可以通过单位检查进一步理解 。
因为:
而:
所以:
因此:
是一个无量纲的量,可以在足够短的距离内作为吸收比例的一阶近似。
这也解释了为什么 Volume Rendering 中的 Density 不是通常意义上的质量密度,更不是一个直接位于 区间中的概率。
这里还需要注意, 只是在不透明、稀疏球形粒子假设下得到的关系。对于其他类型的介质,吸收系数与微观粒子性质之间的具体关系可能不同。
但在当前的线性、局部吸收模型中,Radiance 的变化仍然可以通过一个宏观系数描述:
至此,已有光怎样减少的问题已经获得了数学表达。
不过,介质并不一定只会削弱已有光。有些介质还可以向当前方向增加新的 Radiance。

2.2.3 Emission:介质怎样向光线增加新的颜色?

与 Absorption 不同,Emission 描述的是介质自身向某个方向产生 Radiance 的过程。
例如,一团火焰即使处于完全黑暗的环境中,也可以持续向外发光。
这意味着 Emission 不需要依赖已经存在的 Incoming Radiance。
因此,它不能简单地写成:
否则当 时,介质就永远无法产生新的光。
要描述这种过程,需要引入一个独立的局部源项。
在一般的光传输模型中,可以使用发射系数 ,将极短距离内的新增 Radiance 写成:
这里, 表示单位传播距离上的局部发射强度。
为了与后续的体渲染形式保持一致,当前模型采用一种特定的源项参数化:
其中, 表示局部 Emitted Radiance,或者更一般地说,是当前模型使用的有效源颜色。
于是:
将它与 Absorption 的表达式放在一起:
两者最重要的区别,在于它们依赖的 Radiance 不同。
Absorption 作用于已经存在的光 ,因此其损失与 Incoming Radiance 成正比。
Emission 则由局部源 决定,即使 Incoming Radiance 为零,也可能产生非零的贡献。
另外,Emission 乘上 并不是所有物理发射过程都必须满足的规律,而是当前模型采用的参数化选择。
这种写法的好处是,可以将局部源贡献写成:
这与后续 Volume Rendering 和 NeRF 使用的数学形式能够直接对应。
不过,这里的有效颜色不一定代表真实物体的自发光。未来由神经网络学习的颜色场,还可能将反射、固定照明和其他复杂外观因素包含在其中。
现在,将 Absorption 和 Emission 放在一起:
得到:
这是一个已经能够描述局部损失与增益的模型。
但真实光传输还存在另一个重要机制:Scattering。
它带来的困难不只是增加或减少 Radiance,而是会让不同传播方向之间产生联系。

2.2.4 Scattering:为什么其他方向的光也会影响这条射线?

Scattering(散射)指的是光与介质相互作用后改变传播方向的过程。
从当前方向 观察,散射可以分成两种情况。
第一种是 Out-scattering(外散射)。
原本沿当前方向传播的部分光被散射到其他方向,因此当前方向的 Radiance 减少。
对于线性散射模型,这种损失可以写成:
其中, 是 Scattering Coefficient(散射系数)。
这个表达式与 Absorption 非常相似。
对于当前这条传播路径来说,不论光是被吸收,还是被散射到其他方向,它都不再沿原方向继续传播,因此都表现为负贡献。
不过,两者的物理意义不同:Absorption 将光能转化为其他形式,而 Out-scattering 主要改变光的方向。
第二种情况是 In-scattering(内散射)。
其他方向上的光可能被介质散射进当前方向,使当前方向上的 Radiance 增加:
其中, 表示散射进入当前方向的有效 Radiance。
但与此前的局部源不同, 通常不能只通过当前方向上的信息独立计算。
因为某个位置能够接收到多少 In-scattered Radiance,取决于其他方向上有多少光,以及介质如何改变这些光的方向。
如果用 表示 Phase Function(相位函数),描述从入射方向 散射到当前方向 的分布,那么可以写成:
其中, 表示所有单位方向构成的球面, 表示其他方向入射的 Radiance。
这意味着,为了计算当前方向上的散射贡献,需要知道其他方向的光场。
而其他方向上的 Radiance 也可能受到更早发生的散射影响。
因此,显式 Scattering 会把原本的单条射线问题,变成多个方向之间相互耦合的光传输问题。
现在,三种局部机制已经齐全。
Absorption 和 Out-scattering 对当前方向造成损失:
Emission 和 In-scattering 则提供增益:
全部合并,得到:
两边除以 :
这就是当前模型下的 Radiative Transfer Equation(RTE,辐射传输方程)。
它本质上是在记录当前传播方向上的 Radiance 收支:
这条方程给出了比简单颜色加权更完整的光传输描述。
但对于当前正在构建的最小渲染器,完整 RTE 仍然过于复杂。
问题在于,Scattering 的 In-scattering 项需要其他方向上的光场信息,而这些信息目前并不包含在我们的单射线查询流程中。
因此,需要在物理完整性与当前项目目标之间做出一次模型选择。

2.2.5 为什么渲染器只保留 Absorption 和 Emission?

回到当前项目的目标:构建一个能够从三维 Density/Color Field 计算图像,并支持后续梯度优化的最小渲染器。
为了完成这项任务,最核心的需求是描述两件事情:
第一,已有 Radiance 在传播过程中如何被介质削弱。
第二,介质内部的有效颜色如何参与最终观测。
完整的 Scattering 模型能够提供更丰富的物理描述,但也会引入不同方向之间的耦合,使计算过程显著复杂化。
因此,当前渲染器选择暂时忽略显式 Scattering,只保留 Absorption 和 Emission。
完整 RTE:
由此简化为:
由于此时只保留一个局部密度系数,可以将 简写为 :
进一步整理:
这就是 Emission–Absorption Model(EA Model)。
它的结构非常简洁:
在这个模型中,只需要知道当前的 Radiance、局部 Density 和有效源颜色,就可以计算沿当前传播方向的局部变化率。
它不需要显式计算来自其他方向的光,因此适合建立最小的单射线传播模型。
这个形式还有一个值得注意的性质。
假设介质在局部是均匀的, 和 不随位置变化。
那么:
如果当前 Radiance 高于局部有效源,即 ,那么变化率为负,Radiance 会减小。
如果 ,变化率则为正,Radiance 会增加。
当:
时:
Radiance 不再发生变化。
因此,对于均匀 EA 介质, 可以被理解为局部 Radiance 趋向的平衡值。
对于 RGB 向量,这个关系可以分别作用于三个颜色通道。
这说明 EA Model 并不只是把一种颜色简单叠加到另一种颜色上。它同时减少已有 Radiance,并由局部有效源补充新的贡献。
后续可以看到,这种局部规律在有限距离上的解析结果,会自然产生类似 Alpha Blending 的结构。
EA Model 的表达边界
省略显式 Scattering 并不意味着散射不存在,也不意味着 EA Model 可以恢复完全正确的物理光传输。
不过,如果只要求拟合固定照明条件下观察到的图像,一个足够灵活的有效颜色场可以包含许多复杂的外观效果。
例如,在某些条件下,完整 RTE 可以形式上改写为:
其中,当 时:
这说明,部分散射带来的外观影响可以被折叠进一个更复杂的有效源中。
但这种改写并没有解决完整 Scattering 的计算问题。因为 仍然依赖未知的 Radiance 与其他方向的散射源 ,它也不再保证具有真实发射的物理意义。
而且,如果 ,上述除法表达式也不再适用。
因此,EA Model 的优势在于提供了一个简单、适合神经场表达与优化的外观模型,而不是完整的物理材质与光照分解。
对于当前的最小 Volume Renderer,这个模型已经足够作为第一步。
接下来,把它实现成可以运行的 PyTorch 代码。

2.3 用 PyTorch 实现一次局部光传输

当前已经得到局部 EA 方程:
它描述的是 Radiance 关于传播距离的瞬时变化率。
但实际代码需要计算一段具体距离之后的数值结果。
因此,先考虑一个足够短的传播距离 。
对于一个微小区间,可以使用当前的变化率近似整个区间内的变化:
将 EA Model 代入:
于是:
展开后:
这对应一种最简单的数值方法:Forward Euler。
需要注意,它使用当前区间起点的变化率近似整个区间的变化,因此只是一阶局部近似,不是有限厚度介质的精确解析解。
现在实现这个局部计算。
这里用线性 RGB 向量近似表示 Radiance 与有效源颜色,以便在最小代码中直接观察三个颜色通道的变化。它不是对真实相机光谱响应的完整模拟。
这段函数只有三行核心计算,但每一行都对应前面的数学关系。
loss 是 Absorption 对已有 Radiance 造成的负贡献:
gain 是局部有效源提供的新增贡献:
最后将两部分加入 Incoming Radiance:
这意味着,当前函数计算的不再是沿 Ray 的颜色平均值,而是一段介质对已经存在的 Radiance 造成的局部变化。
下面用一个可以手动计算的例子验证它。
假设进入介质的线性 RGB 为:
局部 Density 为:
有效源颜色为:
传播距离为:
代码如下:
运行得到:
可以直接观察到不同颜色通道的变化。
红色通道从 增加到 ,因为局部有效源的红色分量高于原始 Radiance,新增贡献超过了吸收损失。
蓝色通道则从 减少到 ,因为局部源的蓝色分量较低,吸收损失超过了新增贡献。
绿色通道也略微下降。
这些结果与:
给出的变化方向一致。
验证局部模型的基本性质
首先,如果 Density 为零:
那么:
Radiance 不应发生变化。
接下来,如果有效源为零:
局部变化只剩下 Absorption:
当前步长足够小,因此三个正的颜色通道都发生了衰减。
如果 Incoming Radiance 为零,而局部有效源非零:
则仍然存在 Emission:
最后,当 Incoming Radiance 等于局部有效源时:
局部变化率为零:
这些测试分别验证了空介质、纯吸收、有效发射以及平衡状态下的局部行为。
现在,local_transport() 已经可以计算一个极短介质段的光学影响。
而且,它可以与此前构建的三维场直接连接。
例如,在双高斯场中查询某个三维位置:
这里,query_field() 负责回答当前位置有什么介质属性,而 local_transport() 负责根据这些属性计算局部 Radiance 如何变化。
整个计算过程可以写成:
此前的三维世界只能够返回 Density 和 Color。现在,这些局部属性第一次被用于实际的光传输计算。
不过,这个实现还有一个重要限制。

2.4 从一小段到整段介质,还缺少什么?

目前已经得到一个可运行的局部光传输函数:
它基于:
并使用 Forward Euler 近似:
对于足够小的步长,这种近似能够反映正确的局部变化趋势。
但一条实际的观察射线可能穿过很长一段介质。
如果直接将很大的传播距离代入局部公式,结果还会合理吗?
仍然使用上一节的参数,只把传播距离改成:
结果为:
输出中的绿色和蓝色通道变成了负数,红色甚至超过了当前有效源的红色分量。
对于这里使用的非负、有限 RGB 输入,这显然不是一个合理的有限距离光传输结果。
问题并不在 EA Model 本身,而在于当前的数值近似。
Forward Euler 假设整个区间内的变化率,都可以使用区间起点的变化率近似。
但实际上,Radiance 在传播过程中不断变化,因此:
中的 也会持续变化。
一段介质前半部分改变了 Radiance,后半部分接收到的就不再是最初的 。
当步长过大时,使用同一个起点变化率近似整段传播,自然可能产生很大的误差。
一个直接的改进,是把总传播距离切成多段,每次只前进一个较短的距离,并将上一段的输出作为下一段的输入。
这段代码没有改变局部 EA Model,只是反复应用刚才建立的局部更新规则。
现在保持总传播距离为 1,分别使用不同的步数:
得到近似结果:
随着步长减小,结果逐渐趋于稳定。
这说明,局部变化规律确实能够通过连续的小步更新,近似描述有限距离的传播过程。
但这也暴露了新的问题。
第一,计算结果仍然依赖于所选择的步长。如果希望获得更高精度,就可能需要更多次局部更新。
第二,当前实验假设 和 在整段介质中保持不变。对于空间变化的 Density Field,局部参数还会随着位置改变。
第三,虽然减小步长能够改善当前的数值近似,但它还没有解释有限距离传播的精确数学规律究竟是什么。
既然局部 EA Model 已经是一条微分方程,是否可以直接求解它,而不是通过不断缩小步长来猜测结果?
为了回答这个问题,下一章先暂时去掉 Emission,只研究已有 Radiance 如何受到 Absorption 的影响:
这个方程比完整的 EA Model 简单,却包含了有限距离光传输中最重要的结构。
通过求解它,可以得到光穿过一段介质之后的保留比例,也就是 Transmittance(透射率)。
Transmittance 将回答当前渲染器尚未解决的核心问题:
一束已经存在的光,穿过整段介质之后,究竟还能保留多少?
一旦建立了有限距离上的传播规律,就可以进一步计算介质内部不同位置产生的颜色如何抵达相机,并最终将整条射线上的贡献组合为一个像素。
这将是从局部 EA Model 走向完整 Volume Rendering Equation 的下一步。

3. 从局部衰减到有限距离:推导 Transmittance

图 6|局部 Density σ(t) 沿射线累积为光学厚度,并决定透射率 T(t);当 σ(t) 非负时,T(t) 随传播距离单调不增。
图 6|局部 Density σ(t) 沿射线累积为光学厚度,并决定透射率 T(t);当 σ(t) 非负时,T(t) 随传播距离单调不增。
 
上一章已经建立了最小的 Emission–Absorption(EA)模型:
其中, 描述已有 Radiance 因 Absorption 产生的损失, 则描述介质向当前方向增加的有效源贡献。
通过 Forward Euler,我们还实现了一段极短介质中的光传输近似:
但上一章最后的实验也暴露了它的局限:当传播距离较大时,直接使用局部变化率近似整段过程,可能产生严重的数值误差,甚至得到负的 RGB。
这并不是 EA Model 本身的问题,而是因为 Radiance 在传播过程中不断变化,局部变化率不能被简单地当作整段距离上的常数。
一种办法是不断缩小步长,让每次更新只发生在很短的距离内。但这样会增加计算次数,而且仍然没有解释有限距离传播的精确规律。
既然局部光传输已经被表示为微分方程,是否可以直接求解它?
为了让问题尽可能简单,本章先暂时忽略 Emission,只考虑 Absorption:
它描述的是:一束已经存在的光,在穿过介质时如何逐渐减弱。
本章要完成的目标,是从这条局部微分规律出发,推导有限距离上的光传输关系,并将其实现为一个可复用的 PyTorch 函数。
最终得到的将是:
其中, 称为 Transmittance(透射率),表示光从位置 传播到位置 后,原有 Radiance 保留下来的比例。

3.1 为什么局部光传输还不能直接计算整段介质?

先考虑上一章已经建立的 Absorption Model:
它表示在当前位置,Radiance 的变化率与当前剩余的 Radiance 成正比。
这里的“当前”非常重要。
假设一束光最初具有 Radiance 。穿过第一小段介质后,一部分光被吸收,因此进入第二小段的 Radiance 已经小于 。
第二小段的吸收作用并不是继续从最初的 中扣除相同的绝对量,而是作用于经过第一段后剩余的 Radiance。
这意味着,吸收过程具有一种连续的依赖关系:
如果直接使用:
来计算很长一段介质,就相当于假设整段距离内的损失率始终由最初的 决定。
当 较大时,这种近似甚至可能得到:
但对于只有 Absorption、没有新增光源的模型,非负的 Incoming Radiance 不应该因为传播而变成负数。
因此,真正需要计算的不是“用初始 Radiance 估计整段会损失多少”,而是:
当每一小段都按照当前剩余 Radiance 的比例继续吸收时,整段传播最终会留下多少?
这正是微分方程要回答的问题。
为了首先得到一个能够精确求解的结果,先考虑最简单的情况:介质内部的 Density 处处相同。

3.2 均匀介质:为什么 Radiance 按指数衰减?

假设光从 进入一段长度为 的均匀介质。
在这段介质中,Density 保持为常数:
局部吸收规律为:
这里, 表示光传播到距离 时剩余的 Radiance, 为进入介质时的初始值。
要计算 ,需要将局部变化规律从 积累到 。

3.2.1 从局部变化率到有限距离

首先,将方程两边的变量分开:
暂时考虑 的情况。若初始 Radiance 为零,纯吸收模型的解始终为零。
现在,对整个传播过程积分:
左边累计的是 Radiance 从 变化到 的相对变化,右边累计的是传播路径上的吸收强度。
由于当前介质均匀, 是常数,因此右侧可以直接积分:
左侧的积分为:
代入上下限:
整理得到:
两边取指数:
最终:
这就是均匀介质中的 Beer–Lambert Law(比尔–朗伯定律)。
它揭示了一个重要结果:在局部比例吸收模型下,有限距离上的衰减必然具有指数形式。
这不是人为选择的曲线,也不是为了让计算结果落在合理范围内而添加的修正项,而是从局部吸收微分方程直接推导出来的结果。

3.2.2 为什么指数形式是合理的?

可以从一个简单的离散过程理解这个结果。
假设每经过一小段距离 ,当前剩余的 Radiance 都保留约:
那么,经过第一段后:
经过第二段后:
代入 :
如果总距离为 ,并将它划分为 段:
那么:
当段数不断增加、每段长度趋近零时:
因此:
指数形式正是“每一小段都按当前剩余量继续衰减”的连续极限。
这也解释了为什么简单的线性近似:
只在 足够小时才合理。
当距离或 Density 较大时,线性近似可能变成负数;指数解则始终非负,并随着累计吸收增强逐渐趋近于零。

3.2.3 将保留下来的比例定义为 Transmittance

观察刚刚推导出的结果:
右边的指数项只与介质 Density 和传播距离有关,而不依赖初始 Radiance 的具体数值。
因此,可以将这部分单独定义为:
于是:
这里的 就是 Transmittance。
它表示:已有的 Radiance 穿过长度为 的介质后,能够保留多少比例。
对于非负 Density:
当 或 时:
说明没有发生吸收损失。
当 不断增大时:
说明原有 Radiance 几乎全部被削弱。
注意,Transmittance 是一个无量纲的比例,并不是新的 Density,也不是某个位置单独具有的属性。

3.2.4 用 PyTorch 验证解析解

现在,这个有限距离的结果已经不需要通过反复 Euler 更新来获得。
只需要实现:
例如,考虑 Density 为 、传播距离为 的均匀介质:
结果为:
这意味着穿过这段介质之后,原有 Radiance 只保留约 。
与上一章的 Forward Euler 不同,这里直接使用了均匀介质的解析解。
如果对同一个纯吸收场景只进行一次长度为 1 的 Euler 更新,就会得到:
显然不合理。
但解析解始终满足非负性,并且不需要通过人为增加 Euler 步数来维持这一性质。
这并不意味着所有介质的传播过程都能直接写成 。
上面的推导依赖一个重要假设:
而我们在第一章构建的三维场,恰恰具有随空间位置变化的 Density。
当一条 Ray 穿过两个高斯密度团时,局部 Density 会先升高、下降,再次升高。
因此,下一个问题是:
如果 Density 沿传播路径不断变化,Transmittance 应该怎样计算?

3.3 非均匀介质:真正决定衰减的是什么?

现在去掉均匀介质的假设,让 Density 随位置变化:
局部吸收方程变成:
它与上一节的方程只有一个区别: 不再是常数。
因此,仍然可以使用相同的分离变量方法:
假设光从路径位置 传播到位置 ,对两边积分:
左边得到:
于是:
两边取指数:
这就是非均匀介质中的有限距离衰减关系。
与均匀介质相比,指数形式没有改变。变化的是指数中的内容:
当 Density 不再恒定时,不能只用某一个位置的 乘以总长度,而必须累计整个传播路径上的 Density。

3.3.1 Optical Depth:整条路径累计了多少消光强度?

为了简化表达,将积分部分定义为:
其中, 称为 Optical Depth(光学厚度)。
它描述的是一段路径上累计的光学消光强度。
由于:
而:
因此 Optical Depth 是无量纲的。
有了这个定义,Transmittance 可以写成:
也就是:
此时,有限距离传播可以简化为:
这条公式的意义是:对于纯吸收模型,已有 Radiance 从一个位置传播到另一个位置后,会乘上整段路径的 Transmittance。
真正决定这份衰减的,不是两个位置之间的距离本身,也不是某一个采样点的 Density,而是累计的 Optical Depth。

3.3.2 相同长度的介质,为什么可能具有不同的透射率?

考虑两段长度都为 的介质。
介质 A 的 Density 均匀分布:
因此:
对应:
介质 B 则是前半段比较浓密,后半段没有吸收介质:
它的 Optical Depth 为:
因此:
虽然两段介质内部的 Density 分布完全不同,但它们具有相同的 Optical Depth,所以对已有 Radiance 产生相同的总透射率。
这个例子揭示了一个重要性质:
在当前纯吸收模型中,最终的 Transmittance 只取决于路径上累计的 Optical Depth。
不过,这个结论仅针对已有 Radiance 的纯衰减。等到下一章重新引入介质内部的 Emission 时,即使两段介质的总 Optical Depth 相同,最终渲染颜色也不一定相同,因为内部不同位置产生的光需要经过不同长度的前方介质。

3.3.3 计算机怎样处理非均匀 Density?

对于任意连续函数:
通常无法直接得到一个简单的解析表达式。
尤其是当 Density 来自可查询的三维场时,计算机往往只能在有限个位置上获得 的数值。
因此,需要将路径划分为有限个区间,用每个区间的查询结果近似累计 Optical Depth。
假设沿路径设置 个区间边界:
第 段长度为:
在每个区间内,选择一个代表位置查询 Density:
这里采用区间中点:
于是:
对应:
这里需要明确近似发生的位置。
原本连续变化的 被每段的单个查询值 代表。对于这个分段常数近似场,累计 Optical Depth 与对应的指数衰减可以准确计算;但它与原始连续场的积分之间仍存在采样误差。
因此,下面的实现不是声称可以精确积分任意连续函数,而是在有限采样条件下计算 Transmittance。
这段代码的数学关系非常直接。
首先:
计算每一段的 Optical Depth 近似:
随后:
将这些局部值沿路径逐段累积:
最后:
将累计 Optical Depth 转换成 Transmittance:
代码中在 cumsum 前添加了一个零,因为在路径起点还没有经过任何介质:
所以:
因此, 个介质区间对应 个边界 Transmittance 值。
这里暂时没有计算 Alpha,也没有进行颜色合成。当前函数只完成一项任务:根据沿途的 Density 和段长,计算已有 Radiance 的累计保留比例。

3.4 Transmittance 有哪些重要性质?

Transmittance 已经将有限距离传播表示为:
这个定义还带来了几个直接影响后续实现的性质。

3.4.1 Density 可以变化,Transmittance 为什么不会回升?

沿路径从起点开始定义:
对 求导。
根据链式法则:
因此:
由于:
且:
所以:
这意味着,在当前纯吸收模型中,Transmittance 沿传播路径只能保持不变或下降,不会自行回升。
这个性质与 Density 的变化方式不同。
局部 Density 可以突然升高,也可以下降,甚至重新接近零。
但 Density 下降并不代表已经被吸收的 Radiance 会重新出现。
例如,一束光穿过一团浓密介质后,Transmittance 已经从 1 下降到 0.2。之后即使进入没有吸收的空空间,也只是保持当前的 0.2,而不会自动恢复到 1。
因此:
Density 描述当前位置的局部介质强度,而 Transmittance 记录从路径起点到当前位置的累计衰减历史。
后面将两者绘制在同一条 Ray 上时,这个区别会变得更加直观。

3.4.2 为什么 Transmittance 可以分段相乘?

还有一个更重要的性质。
假设路径上依次存在三个位置:
从 到 的 Optical Depth 为:
利用定积分的可加性:
也就是:
代入 Transmittance:
利用指数关系:
得到:
这就是 Transmittance 的 Multiplicativity(乘法性)。
它意味着:光从 到 的总透射率,可以分解成前半段和后半段各自透射率的乘积。
这一性质对后续计算非常重要。
如果路径被划分成许多小段,就不需要为每一个位置重新计算从起点开始的完整积分,而可以利用上一段已经累计的 Transmittance 继续更新:
其中, 表示进入第 段之前已经累计的 Transmittance。
在这个递推中,第 段的局部 Density 与长度只负责计算该段自身的衰减;此前所有介质造成的影响,已经包含在 中。
这正是后续 Ray Marching 能够沿 Ray 逐段计算可见性的数学基础。
现在用代码验证分段乘法性。
输出近似为:
第一段长度为 0.4,其透射率为:
第二段长度为 0.6,总透射率变成:
而如果直接将整段长度 1.0 代入均匀介质解析式,也会得到:
因此,无论将这段均匀介质作为一个整体计算,还是划分成多个区间,最终总透射率都一致。
这里的分段一致性不是数值巧合,而是由 Optical Depth 的可加性决定的。

3.4.3 Transmittance 与观察方向有什么关系?

还有一个需要明确的方向问题。
第一章定义的 Camera Ray 从相机指向场景:
而第二章建立局部光传输方程时,使用的是从场景向相机传播的实际光方向。
在当前模型中, 是一个只依赖空间位置的标量场,并不依赖光的传播方向。
因此,对于同一条直线路径,正向与反向经过的 Density 分布相同,累计 Optical Depth 也相同。
于是:
在当前假设下成立。
这使得后续实现可以继续沿用从相机出发、指向场景的 Ray,并按照深度从近到远计算累计 Transmittance。
但这种对称性依赖介质参数的方向无关性,并不是所有复杂方向相关介质都满足的普遍结论。
在本文的最小 Volume Renderer 中,继续采用这一标量 Density 假设即可。

3.4.4 另一个视角:Transmittance 也可以解释为 Survival Probability

到目前为止,Transmittance 一直被理解为已有 Radiance 保留下来的比例。
在相应的随机终止模型中,它还可以具有一个概率解释。
将 看作单位传播距离内发生终止事件的局部强度,那么:
也就是说,Transmittance 可以被理解为 Survival Probability(生存概率)。
如果:
可以解释为,在这一随机模型中,射线传播到距离 仍然没有发生终止事件的概率为 0.8。
这与前面的光学解释具有相同的数学形式。
因此:
可以表示在距离 之前已经发生终止事件的累计概率。
不过,这里暂时不需要展开终止位置的 Probability Density Function。下一章计算沿 Ray 的颜色贡献时,再讨论:
为什么会自然出现。
需要强调,这种概率解释是一种用于理解可见性与体渲染权重的模型,并不意味着之后的 Neural Field 一定对应真实世界中独立发光的物理粒子。
当前最重要的仍然是:Transmittance 描述了一段路径已经累计的光学衰减,而不只是某一个位置的 Density。

3.5 用 PyTorch 计算整条 Ray 的 Transmittance

现在,均匀与非均匀介质的数学关系已经建立,也得到了分段累计的计算方式。
接下来,将这些结果放回第一章构建的双高斯 Density Field。
这次不再只观察沿途的 Density,而是计算整条 Ray 上的累计 Transmittance。
本节继续复用已有的 sample_ray() 和 query_field(),不重新实现三维场与相机。

3.5.1 从三维场获得每段 Density

首先,定义从 到 的观察区间。
将其划分为 128 段,因此需要 129 个区间边界。
这里需要区分两个数组。
t_edges 保存的是区间边界,共有 129 个数值;t_mid 则保存各区间的中点,共有 128 个数值。
每个中点用于查询其所在区间的代表 Density:
此时:
虽然 query_field() 同时返回了 RGB,但当前的 Absorption-only 模型只需要 Density。
因此,本章暂时不使用 rgb。
现在调用刚刚实现的分段透射率函数:
得到:
对于当前双高斯场景,最终 Transmittance 约为 。
这表示一束来自采样区间远端的已有 Radiance,穿过两个密度团所在的介质路径之后,只保留了大约 。
这是当前手工设置的 Density Amplitude 与路径长度共同决定的结果,并不是所有双高斯场景都必然具有如此低的透射率。
接下来,需要观察这个结果是怎样沿距离逐渐形成的。

3.5.2 可视化 Density 与 Transmittance

前面已经知道,沿射线的 Density 会在接近两个高斯中心的位置出现峰值。
但 Transmittance 是一个累计量,因此它的变化趋势应该不同。
绘制两条曲线:
Figure 3-1:同一条观察射线上的 Density 与 Transmittance。上图显示两个局部密度峰;下图显示原有 Radiance 的累计保留比例。
观察结果可以发现,两条曲线具有明显不同的结构。
Density 在接近第一个高斯中心时升高,随后下降;接近第二个高斯中心时再次升高。
但 Transmittance 从 1 开始,只会随着累计 Optical Depth 的增加而下降。
当射线进入第一团介质时,Transmittance 开始明显降低。离开这团介质之后,Density 虽然下降,Transmittance 却不会恢复到之前的值。
进入第二团介质时,新的局部 Density 又会进一步降低剩余的 Transmittance。
这正是此前推导的:
在当前三维场中的具体表现。
也就是说,Density Profile 描述“当前位置的介质有多强”,而 Transmittance Profile 描述“从相机到当前位置的路径已经累计削弱了多少已有 Radiance”。
局部密度可以反复升高和下降,但纯吸收模型的累计透射率不会自行回升。

3.5.3 用数学不变量验证实现

一条看起来合理的曲线并不足以证明代码正确。
还需要将数学推导转换成可以自动检查的测试。
首先,检查 Transmittance 的起始值。
在路径起点,还没有穿过任何介质:
因此:
其次,非负 Density 下,Transmittance 应位于 :
同时,它应该沿路径单调不增:
这些验证直接对应上一节推导出的数学性质。
另外,可以使用均匀介质的解析解进行对照。
假设长度为 3 的路径被划分为三个区间,Density 分别为:
各段长度均为 1。
则累计 Optical Depth 为:
因此,Transmittance 应该为:
运行:
结果为:
这与解析计算一致。
最后验证分段乘法性。
一段长度为 1、Density 为 2 的均匀介质:
把它拆成长度分别为 0.4 和 0.6 的两段后,最终结果不应改变。
这个测试验证了当前实现不会因为将同一段均匀介质拆成更多区间,就改变它的总体光学厚度。
需要注意,对任意空间变化的连续 Density Field,有限采样仍然会产生积分近似误差。增加采样数量可能改善结果,但分段一致性的严格等式只应在这里测试的均匀介质条件下使用。
到这里,渲染器已经获得了一项新的能力:
它不仅能够查询三维空间的局部属性,还能够计算这些属性沿观察路径造成的累计衰减。
但 Transmittance 仍然不是最终的像素颜色。

3.6 只有 Transmittance,为什么还不能得到最终像素?

现在,纯吸收介质中的有限距离传播已经可以计算:
这条公式能够回答:一束已经存在的光,从一个位置传播到另一个位置后,还能保留多少。
例如,一条观察射线的远端存在某个背景表面,其朝向相机的有效颜色为 。
如果它与相机之间存在具有非零 Density 的介质,那么到达相机的这部分背景贡献为:
其中 是从相机到远端背景的距离。
这里仍然采用从相机指向场景的深度参数;由于当前标量 Density 模型的 Transmittance 对同一路径正反方向相同,因此可以直接使用从相机到背景的累计透射率。
这已经是一种最简单的成像计算:远端背景的 Radiance 被中间介质削弱,然后进入相机。
但它仍然缺少第二章 EA Model 中的另一个重要机制:
到目前为止,本章只解决了:
也就是已有 Radiance 的衰减。
介质自身提供的有效源:
还没有被重新加入。
考虑第一章构建的两个彩色密度团。即使它们后方的背景完全是黑色,当前 Color Field 仍然为两个密度团定义了红色和蓝色的有效源属性。
如果渲染器只计算 Transmittance,就只能得到背景光被削弱之后的结果,而不能得到介质内部这些颜色源对相机的贡献。
因此,一个新的问题出现:
假设介质内部某个位置产生了一份新的 Radiance,它需要经过多少衰减,才能最终抵达相机?
不同位置产生的光,距离相机的远近不同,因此需要穿过的前方介质也不同。
靠近相机的位置产生的光,可能只需要经过较短的一段介质;远处产生的光,则需要穿过更长的路径。
但 Transmittance 已经能够回答任意一段路径上的衰减比例:
因此,接下来的任务不再是重新发明光学衰减规则,而是将这条已经推导出的规律应用于介质内部的每一个有效源。
这些局部源分别产生颜色,再按照各自到相机的 Transmittance 进行衰减,最终沿整条 Ray 累积。
下一章将把 Absorption 和 Emission 重新结合起来,从 Transmittance 推导连续的 Volume Rendering Equation。
到那时,渲染器才真正具备将整个 Density/Color Field 转换为单个像素颜色的数学基础。


4. 从光的衰减到像素颜色:推导连续体渲染方程

图 7|连续体渲染将沿射线各位置的源贡献 σ(t)c(t) 按到达相机前的透射率 T(t) 加权积分,并将远端背景按剩余透射率 T(D) 衰减后加入像素颜色。
图 7|连续体渲染将沿射线各位置的源贡献 σ(t)c(t) 按到达相机前的透射率 T(t) 加权积分,并将远端背景按剩余透射率 T(D) 衰减后加入像素颜色。
 
上一章已经从局部 Absorption Model 出发,推导了有限距离上的 Transmittance:
它描述一束已经存在的光,从位置 穿过介质传播到位置 后,还能保留多少 Radiance。
这解决了光传输中的一部分问题。
例如,假设相机正在观察远处的一个背景表面,而相机与表面之间存在一团介质。只要知道介质沿观察路径的 Density,就可以利用 Transmittance 计算背景 Radiance 在到达相机之前受到的衰减。
但目前的模型还缺少一项能力:介质本身也可能向相机方向贡献颜色。
第一章构建的双高斯场就是这样的例子。两个密度团分别具有红色和蓝色的有效颜色属性。如果渲染器只计算背景的衰减,即使三维场中存在这些颜色,也无法将它们正确地转换成像素。
因此,完整的像素颜色应该包含两部分:
  1. 介质外部原本存在的 Radiance,在穿过介质后剩余的部分。
  1. 介质内部各个位置提供的有效 Radiance,在传播到相机后留下的部分。
本章将分别计算这两部分,再将它们组合成一个完整的连续 Volume Rendering Equation。
最终得到的数学模型,不再只描述光如何变弱,而是能够回答:
给定一条 Ray,以及沿途的 Density 和 Color,理想相机最终应该接收到什么颜色?

4.1 已有的 Transmittance 能计算什么?

先回到上一章已经实现的纯吸收模型:
这个表达式的含义很明确。
如果一束 Radiance 从位置 出发,经过一段具有非零 Density 的介质抵达位置 ,那么最终剩余的 Radiance,就是初始值乘上这段路径的 Transmittance。
不过,这个模型隐含了一个条件:传播途中没有新增的有效源。
如果介质不仅吸收光,还能向观察方向贡献 Radiance,那么最终结果显然不能只由初始 Radiance 和 Transmittance 决定。
考虑一个简单的思想实验。
假设相机前方存在一团红色的有效发光介质,背景为黑色:
如果渲染器只使用纯吸收模型,那么相机观察到的背景贡献是:
无论 Density Field 如何变化,只要背景是黑色,结果始终为:
但在 Emission–Absorption 模型中,这团介质自身还可以提供有效颜色贡献,因此最终像素并不一定是黑色。
问题不在 Transmittance 的计算有误,而在于 Transmittance 本身只回答了一个问题:
已有的 Radiance 沿一段路径传播后,还能保留多少?
它并没有计算介质内部新产生的 Radiance。
因此,完整体渲染需要在已有的衰减规律之上,重新加入第二章建立的 Emission 项:
这里的 是当前 EA 模型采用的有效源,不必被理解为真实物体的物理自发光。
接下来,先把最容易计算的外部背景项确定下来,再考虑介质内部的贡献。

4.2 介质外部的光如何到达相机?

考虑一条从相机出发、指向三维场景的观察射线:
其中, 是相机位置, 是归一化后的观察方向, 是从相机向场景增加的距离。
为了简化推导,假设需要考虑的介质分布在:
其中 是当前观察区间的远端边界。
远端存在一个背景,其朝向相机的有效颜色记为:
背景 Radiance 要到达相机,需要穿过整个 区间。
因此,根据上一章的 Transmittance 定义:
背景能够贡献给最终像素的部分就是:
这就是完整体渲染方程中的第一部分。
虽然观察射线从相机指向场景,实际到达相机的 Radiance 沿相反方向传播,但当前使用的位置相关、方向无关的标量 Density Field,使同一段路径上的 Transmittance 正反方向相同。
因此,可以使用从相机到背景的累计 来表示背景到达相机的保留比例。
这个公式具有两个直观的极限情况。
如果观察路径上没有介质:
那么:
所以:
相机可以完整接收到背景 Radiance。
相反,如果沿路径累计的 Optical Depth 很大:
那么:
背景 Radiance 在穿过介质后几乎全部被削弱。
这一部分不需要新的数学,也不需要重新实现 Transmittance。上一章已经提供了对应的 PyTorch 函数:
这里,T[-1] 对应穿过整个采样区间后的累计 Transmittance,也就是数学表达式中的 。
不过,这段代码仅计算了外部背景的贡献。
介质内部的 Color Field 还没有进入计算。
因此,接下来的问题是:如果某个位置本身能够向相机方向提供新的 Radiance,这份贡献应该怎样计算?

4.3 介质内部的颜色如何到达相机?

先不要考虑整个三维场。
在观察射线上选择一个固定位置 ,并取出它附近一段极短的介质,长度为 。
这段介质具有局部 Density:
以及局部有效源颜色:
现在需要回答两个问题:
  • 这段介质本身提供多少有效 Radiance?
  • 这些新产生的 Radiance 有多少能够到达相机?

4.3.1 一小段介质能够贡献多少颜色?

第二章已经建立了 EA Model 的局部 Emission 项:
这里使用相同的源项参数化,并将沿观察射线的有效颜色记为 。
因此,位置 附近一段极短介质提供的局部源贡献为:
这个表达式包含两个部分。
描述当前无穷小介质段的局部源强度系数; 描述这段介质向观察方向提供的有效颜色。
这里的 是单位距离上的强度,只有与路径长度 相乘后,才能形成一段介质对应的无量纲系数。
还需要注意,这个表达式是无穷小区间内的源项。对于有限厚度的介质段,不能直接将它当作精确的不透明度或最终颜色权重。
到目前为止,它仍然只是局部产生的贡献。
假设位于 的蓝色区域产生了一份 Radiance,但相机与它之间还存在一团红色介质。
如果直接把这份蓝色完整地加入像素,就又回到了第一章朴素颜色累加的问题:后方颜色完全忽略了前方介质的影响。
因此,还需要计算第二件事。

4.3.2 局部产生的 Radiance 如何到达相机?

位置 的有效源产生之后,需要穿过它与相机之间的介质才能被观察到。
这段路径对应的区间是:
根据上一章的结果,它的 Transmittance 为:
因此,局部产生的 Radiance 经过前方介质之后,能够到达相机的贡献为:
代入刚才的局部源:
这就是连续体渲染方程中最重要的一个局部贡献表达式。
它包含三个相互独立的因素:
每个因子都回答一个不同的问题。
  • 回答“这个位置提供什么颜色”。
  • 回答“当前极短介质段能够提供多强的局部源贡献”。
  • 回答“这些贡献经过前方介质后,有多少能够抵达相机”。
三个因子缺少任何一个,都会改变模型的含义。
  • 如果去掉 ,后方介质产生的颜色就不再受到前方介质遮挡。
  • 如果去掉 ,空空间中的颜色属性也可能无条件地加入最终像素。
  • 如果去掉 ,渲染器只知道局部介质强度,却不知道应当产生什么颜色。
其中,最容易混淆的是 的路径范围。
为什么这里使用:
而不是:
或者统一使用:
原因在于,这份 Radiance 是在位置 新产生的。
它并不需要穿过自己产生之前、位于更远处的介质;真正影响它抵达相机的,只是从位置 到相机之间的路径。
由于当前 Density Field 的 Transmittance 对同一路径正反方向相同,这份可见性正好可以由从相机到 的累计 表示。
例如,位于较近位置的红色有效源,只需要穿过较短的前方路径;位于远处的蓝色有效源,则需要穿过更长的路径,可能受到前方红色介质的明显衰减。
这样,原先无法由简单颜色平均表达的前后关系,就被自然地包含在 中。
不过,到目前为止,只计算了位置 附近一个极短区间的贡献。
整条 Ray 上的其他位置同样可能提供颜色。
接下来,需要将这些局部贡献全部合并起来。

4.4 怎样将整条 Ray 的贡献合成为一个像素?

现在,位置 附近的有效贡献已经确定:
如果观察区间为:
那么,整个区间中每个位置都可能提供局部颜色。
这些贡献需要沿射线累计。
在连续模型中,将无穷多个局部贡献累加,正对应一个积分:
这里的 表示介质内部所有有效源最终到达相机的总贡献。
注意,这不是对局部颜色 的普通平均。
每个位置的颜色都要先乘上自身的源强度系数,再乘上从该位置到相机的 Transmittance。
因此,贡献大小不只取决于局部 Color 和 Density,还取决于这个位置之前的整段介质。
现在,回到 4.2 中已经确定的背景项:
最终像素就是介质内部的贡献与远端背景贡献之和:
代入二者:
这就是当前 Emission–Absorption 模型下的 Continuous Volume Rendering Equation(连续体渲染方程)。
其中:
至此,之前建立的所有组件第一次完整连接起来:
  • Camera Ray 确定观察路径;
  • Density Field 提供局部光学强度;
  • Color Field 提供局部有效颜色;
  • Transmittance 描述局部源到相机的累计可见性;
  • Volume Rendering Integral 将所有贡献合成为最终像素。
整个成像过程终于拥有了完整的连续数学表达。

4.4.1 为什么积分里会出现 ?

这个组合还可以从另一种角度理解。
上一章已经得到:
因此:
在与 EA 模型对应的随机终止解释中, 可以看成射线传播到位置 之前尚未发生终止事件的概率。
那么:
就是在 之前已经发生终止的累计概率。
对它求导:
得到:
因此, 可以解释为沿 Ray 的首次终止位置的概率密度。
在这种解释下,连续体渲染方程可以写为:
它描述的是:按照首次终止位置的概率分布对局部颜色取期望;如果射线在整个观察区间内都没有终止,则采用背景颜色。
这里有一个重要细节。
对于有限区间 :
因此,内部终止位置的概率质量通常小于 1。
剩余的:
正好对应背景。
这也解释了为什么背景项不能随意省略。
完整的像素颜色可以理解为两部分概率质量对应的颜色贡献:
不过,这种概率解释主要用于理解可见性和合成权重的结构,并不意味着真实场景一定由独立发光的粒子组成。
在当前模型中, 仍然是一个有效颜色源。数学上的颜色合成与真实物理材质、光照分解不是同一回事。

4.4.2 一个有用的数学检查

从:
可以直接推导:
因此:
这条恒等式将成为后续代码的重要验证条件。
例如,如果整个介质的有效颜色和背景颜色恰好相同:
那么连续体渲染方程变为:
代入刚才的恒等式:
最终:
也就是说,当介质内部颜色与背景颜色完全一致时,改变介质的 Density 不应该改变最终的理想 RGB。
这不是一个需要额外设置的规则,而是连续体渲染方程本身具有的数学性质。
接下来,就用 PyTorch 验证这个结果,并让第一章的双高斯场第一次产生真正的像素颜色。

4.5 用 PyTorch 验证连续体渲染方程

连续体渲染方程已经得到:
但计算机无法直接执行无限次函数查询,因此需要使用有限采样近似其中的积分。
这里先使用一种通用的数值积分方法——梯形求积(Trapezoidal Rule),来验证连续方程的行为。
需要明确,这只是本章的数值参考实现。后续正式构建 Ray Marching 时,将使用分段常数模型和段内解析积分,而不是直接把下面的梯形求积当作最终体渲染算法。

4.5.1 先实现连续公式的数值参考版本

对于一条固定射线,假设已经在有序的距离位置:
查询了:
以及:
为了计算:
先使用数值积分得到累计 Optical Depth,再通过指数得到每个查询位置的 Transmittance。
随后,对:
再次进行数值积分,并加上远端背景项。
这段代码直接对应连续体渲染方程的两个组成部分。
torch.cumulative_trapezoid() 计算沿路径累计的 Optical Depth:
torch.exp(-accumulated_tau) 将 Optical Depth 转换为 Transmittance:
接下来:
正对应:
最后,通过 torch.trapezoid() 对这些局部贡献进行数值积分,再加入背景项:
这里的代码与前文数学公式几乎具有相同的结构,因此可以直接观察每个数学量是怎样进入计算图的。
它与上一章的 transmittance_from_segments() 并不冲突。上一章的函数使用分段常数形式累计 Optical Depth,这里使用梯形求积直接近似连续积分,主要目的是提供一个便于验证的参考结果。

4.5.2 让双高斯场第一次生成一个像素

现在复用第一章的双高斯场。
观察射线仍然从原点出发,沿 方向:
观察区间:
为了让数值积分具有较高的参考精度,这里使用 2049 个查询位置,并采用 float64 进行计算。
在前面定义的双高斯场景中,运行结果近似为:
这条射线最终得到一个以红色为主的像素。
原因可以从此前的场景几何关系中找到。
靠近相机的是红色高斯密度团,蓝色密度团位于更远处。
红色区域产生的有效颜色只需要穿过相对较短的前方路径;蓝色区域产生的颜色则要穿过更长的介质路径,其中还包括靠近相机的红色高密度区域。
因此,蓝色贡献受到明显衰减,最终像素以红色为主。
同时,当前场景在整个采样区间内累计了较大的 Optical Depth,所以最终 Transmittance 很小,白色背景只贡献了约 的每通道数值。
需要注意,输出数值依赖当前使用的高斯位置、宽度、Density Amplitude 和背景。它们是可控实验参数,而不是体渲染模型的通用输出。

4.5.3 再次交换红蓝位置,检查前后遮挡

第一章曾经构造过一个对照实验:
红色在前、蓝色在后,以及蓝色在前、红色在后。
朴素 Density-Weighted Average 对这两个对称场景给出了相同的颜色:
当时的问题是,简单的加权平均并没有描述前方介质对后方颜色的累计影响。
现在,正确的连续体渲染方程已经包含:
因此可以重新运行这个实验。
运行得到近似结果:
这次,交换红色和蓝色的前后顺序,最终像素明显改变。
Scene A 主要呈现红色,而 Scene B 主要呈现蓝色。
这不是因为渲染器专门编写了“近处颜色优先”的判断逻辑。
真正起作用的是:
其中的 自动记录了当前源位置与相机之间已经累计的介质衰减。
越远处的颜色,可能需要经过越多前方介质,因此它对最终像素的贡献会受到相应影响。
这就从数学和实验上回答了第一章留下的问题:
为什么沿 Ray 的颜色不能只根据局部 Density 做普通加权平均?
因为真正决定像素贡献的,还包括从局部源到相机的累计可见性。

4.5.4 用三个边界条件验证公式

除了红蓝交换实验,还需要检查连续体渲染方程的基本性质。
首先是空空间。
当:
时:
同时介质内部源项为零:
因此:
使用代码验证:
第二个测试是关闭内部有效源。
如果:
那么内部积分项消失,结果只剩下经过衰减的背景:
这验证了当前实现能够退化回上一章的 Absorption-only Model。
第三个测试,是让内部有效颜色与背景颜色保持一致。
设:
根据前面的推导,理论上应有:
由于这里使用有限精度的梯形求积,结果会存在很小的数值误差,因此使用适当的容差进行验证。
这个测试有很明确的数学来源:
它说明在当前模型下,介质内部与背景的颜色贡献具有一致的权重关系。
到这里,连续体渲染方程已经通过了几个重要的检查:空空间退化、纯吸收退化、常颜色一致性,以及颜色前后顺序实验。
渲染器第一次能够根据一条 Ray 上的 Density 和 Color,计算出具有正确累计遮挡行为的像素。
但当前实现还不能直接作为最终的高效 Volume Renderer。

4.6 连续积分已经建立,为什么还需要离散算法?

现在,完整的连续 Volume Rendering Equation 已经得到:
其中:
这条公式在数学上描述了 EA 模型下的像素形成过程。
但仔细观察它的结构,就会发现一个计算上的困难。
为了计算位置 的颜色贡献:
首先需要知道 。
而计算 ,又需要先累计从相机到位置 的全部 Density:
也就是说,最终的颜色积分中,包含着一个上限随 变化的 Optical Depth 积分:
对于任意空间变化的 和 ,这样的积分通常没有简单、直接可用的解析表达式。
上一节使用高密度采样和梯形求积,确实能够获得一个数值结果。但它依赖有限采样精度,需要计算并存储沿途的中间值,而且并没有利用局部 EA 方程在特殊条件下可以被精确求解这一性质。
未来当 Density 和 Color 来自神经网络时,渲染器更无法假设这些函数拥有方便的符号积分表达式。
因此,接下来的目标不再是重新推导连续方程,而是寻找一种更适合计算机执行的近似方式。
一个自然的思路是:将连续射线划分为许多较短的区间,并在每段内部采用简单的介质模型。
例如,如果能够近似认为某个短区间内的 Density 和 Color 保持不变,那么这段介质就变成了一个 Homogeneous Medium(均匀介质)。
而均匀介质恰好是此前微分方程中可以解析求解的特殊情况。
这样,整个问题就可以分成两个部分:
首先,精确计算一段均匀介质对 Radiance 的影响。
然后,将多段介质沿观察射线组合起来,得到整条路径的颜色。
下一章将从这一思路出发,推导有限介质段的解析解,并由此得到:
这里的 将成为每段介质的 Opacity(不透明度)。
再结合此前得到的 Transmittance 乘法性,就可以将连续积分转化为一套实际可执行的 Alpha Compositing 算法。
这将是从连续 Volume Rendering Equation 迈向最终 PyTorch Renderer 的下一步。


5. 从公式到代码:用 PyTorch 实现单射线体渲染

图 8|将连续射线划分为有限介质段后,各段颜色贡献由前方累计透射率与当前段 Opacity 共同加权,未被前方介质消耗的透射率则用于合成远端背景。
图 8|将连续射线划分为有限介质段后,各段颜色贡献由前方累计透射率与当前段 Opacity 共同加权,未被前方介质消耗的透射率则用于合成远端背景。
 
上一章已经从 Transmittance 和局部 Emission 出发,得到了连续体渲染方程:
其中:
这个方程描述了一个像素的两种颜色来源:介质内部沿射线分布的有效颜色贡献,以及穿过整段介质后剩余的背景贡献。
为了验证它,上一章还利用 torch.trapezoid() 实现了一个数值参考版本,让第一章构建的双高斯 Density/Color Field 第一次产生了具有正确前后遮挡关系的 RGB。
但到目前为止,渲染器仍然主要依赖对连续积分的直接数值近似。
这里还有一个值得解决的问题。
计算机无法查询连续射线上的无穷多个位置,只能获得有限个 Density 和 Color。那么,每个查询值究竟应该怎样参与最终像素的计算,才能与之前推导的光传输规律保持一致?
尤其是,Density 描述的是单位距离上的局部光学强度。如果将一段介质切成更多的小段,同一个三维场不应该仅仅因为采样数量增加,就凭空变得更加不透明。
因此,接下来不再重新推导完整的 Volume Rendering Equation,而是尝试将它转换成一套明确的离散计算规则。
本章最终要构建一个能够独立计算单条 Ray 的 PyTorch 函数:
其中,sigma 和 rgb 来自三维场查询,delta 描述各段介质的实际长度,background 则提供远端背景颜色。
完成之后,这个函数不仅能够返回一个像素,还会保留每段的 Opacity、Transmittance 和 Compositing Weight,用于检查渲染过程中究竟发生了什么。

5.1 连续积分已经能够计算,为什么还需要新的实现?

先观察上一章的内部颜色积分:
它的数学意义已经明确。
位置 的局部颜色首先由 确定源强度,再乘上从该位置到相机的可见性 ,最后沿整条射线积分。
但是,直接计算连续积分时,需要在有限采样位置上近似两个量:
以及:
上一章使用梯形求积完成了这个任务,并通过高密度采样获得数值参考结果。
这种方法适合验证数学方程,但它还没有将“每个采样位置应该代表多长的一段介质”明确组织成一个独立的计算单元。
对于后续的 Volume Renderer,更理想的做法是让每个采样值对应一段实际长度的介质,先计算这一段对光的影响,再将所有介质段组合起来。
这样,不同采样数量、不同区间长度与累计遮挡之间,就能够建立明确的关系。
因此,新的计算问题变成:
能否把一条连续 Ray 分成有限个介质段,并且为每一段推导一个可以直接计算的颜色贡献?
如果每段都能够独立计算,那么整条射线就可以由这些计算单元逐段组合而成。

5.2 将一条 Ray 划分为可计算的介质段

考虑一条从相机指向场景的观察射线:
其中,,因此 可以直接表示沿射线的距离。
假设需要渲染的范围为:
将这段路径划分为 个区间。
使用 个有序边界:
第 个区间为 ,其长度为:
这里使用从零开始的索引 。
因此, 个边界对应 个实际介质段。
这个区别非常重要。后续每个区间都必须拥有自己的长度 ,否则改变采样数量时,就可能错误地改变介质的累计光学厚度。

让每段内部的属性保持常数

原始 Density Field 是连续变化的:。
Color Field 同样可能随位置变化:。
为了让每个短区间都能够直接计算,这里引入一个近似:在同一个区间内,使用一个固定的 Density 和 Color 代表这段介质。
即:
其中 。
这称为 Piecewise-constant Approximation(分段常数近似)。
它并不要求整个三维场均匀,只要求每个足够短的区间内,用一个常数代表局部变化。
例如,可以选择区间中点作为查询位置:
对应三维坐标 ,然后查询:
这样,连续场就被转换为有限个计算单元:
这里还有一个需要明确的区别。
Sample Point 是查询位置,Segment 才具有实际长度。
一个采样点本身没有厚度。它只是用于获得某个短区间的代表属性,后续真正参与光传输计算的是整段介质。
现在,连续射线已经被划分成有限个具有 Density、Color 和 Length 的介质段。
但新的问题也随之出现:
给定其中一段的 ,它究竟会对最终像素贡献多少颜色?

5.3 一小段均匀介质的颜色能否精确计算?

先只考虑一个介质段。
假设这段介质具有固定的 Density 、有效颜色 与长度 。
由于段内 Density 和 Color 都被视为常数,这个区间就成为一个 Homogeneous Medium(均匀介质)。
这正是此前局部 EA 方程可以解析求解的特殊情况。

5.3.1 从连续积分求出一段介质的贡献

先考虑这一段自身提供的有效颜色,不考虑它前面是否还有其他介质。
从靠近相机的段边界开始,使用局部距离 表示段内位置 。
由于 Density 是常数,从局部位置 到该段靠近相机的一端,其 Transmittance 为:
根据上一章的连续 Volume Rendering Equation,当前段内部的有效颜色贡献为:
其中, 描述该位置产生的颜色在通过当前段靠近相机的部分后,还能够保留多少。
因为 与 都是常数,可以将 提出积分:
接下来计算这个积分。
注意:
因此:
代入上下限:
得到:
所以:
这就是均匀介质段的解析结果。
值得注意的是,虽然当前模型使用了分段常数近似,但对于这个近似后的均匀介质段,积分结果是精确的。
也就是说,近似发生在:
而不是把段内的指数衰减随意改成线性关系。

5.3.2 为什么这里自然出现 Alpha?

观察刚刚得到的结果 ,其中 只与介质的 Density 和传播长度有关。
因此,将它定义为这一段介质的 Opacity(不透明度):
于是,一段均匀介质的自身源贡献就可以写为:
这里的 Alpha 并不是人为给每个采样点指定的一个额外参数,而是由 Density 和 Segment Length 共同决定。
如果 、,那么 。
这意味着在当前模型中,Alpha 始终是一个合理的不透明度比例。
例如,当 时,,说明空介质段不会提供内部源贡献,也不会削弱后面的 Radiance。
当 时,,说明介质段趋于完全不透明。

为什么不能直接使用 作为 Alpha?

对于足够小的 ,指数函数满足 。
因此,当 时:
这解释了为什么极短距离内的局部 EA Model 可以使用 作为一阶近似。
但是,当 Optical Depth 增大时,这种近似就不再可靠。
例如 ,如果直接使用 ,就会得到 ,相当于“不透明度 200%”,已经失去合理的比例解释。
而正确的指数关系给出 ,不透明度虽然很高,但仍然位于 区间内。
因此,正式实现必须使用:
而不是直接用 代替有限段的不透明度。

立即实现 Density 到 Alpha 的转换

现在,Alpha 的数学来源已经明确,可以将它转换为 PyTorch 代码:
这里:
对应当前段的 Optical Depth 。
而:
对应 。
PyTorch 的 expm1(x) 用于计算 。因此:
这种写法与数学公式完全等价,但在 很小时,通常能够比直接计算 1 - torch.exp(-tau) 减少浮点数相消误差。
这是一个数值稳定性方面的工程处理,而不是改变了光传输模型。
例如:
得到:
由于 ,因此 ,结果与解析公式一致。
现在,一段介质已经拥有明确的 Alpha,也能够计算自己的局部颜色贡献 。
但它还不是最终像素中的实际贡献。
原因在于:当前介质段前面可能还有其他介质。
如果一段蓝色介质位于浓密的红色介质后方,那么它自身产生的蓝色 Radiance 还需要穿过前面的红色介质,才能最终抵达相机。
因此,还需要把之前推导的 Transmittance 重新加入计算。

5.4 多个介质段如何正确合成一个像素?

一条 Ray 通常不会只穿过一段介质,而是被划分为 个连续 Segment。
每个 Segment 都有自己的 、、,以及刚刚推导出的 。
但如果直接把所有段的 相加:
仍然会出现与第一章类似的问题:后方介质提供的颜色没有经过前方介质的衰减。
因此,需要计算第 段之前的累计 Transmittance。

5.4.1 第 段之前已经发生了什么?

将介质段按照从相机到场景的顺序排列。
定义 为进入第 段之前的累计 Transmittance。
对于第一个介质段,还没有经过任何前方介质,因此:
第一个 Segment 的局部透射率为 ,所以进入第二个 Segment 之前 ;同理 。
不断递推:
展开后:
这正是上一章 Transmittance Multiplicativity(乘法性)在离散介质段中的具体形式。
这里最容易出现的错误,是把当前段自己的 Alpha 也包含进 。
但根据定义, 表示的是进入当前段之前的累计可见性,因此只能包含索引小于 的介质段。
当前段自身的作用,仍然由 描述。

5.4.2 为什么实际贡献是 ?

现在,前方累计 Transmittance 和当前段自身的 Alpha 都已经确定。
第 段在忽略其前方其他介质时,内部源贡献为 。
但这份贡献还需要穿过当前段与相机之间的所有前方介质。
因此,实际能够抵达相机的颜色贡献为:
这三个因子分别承担不同的职责:
可以将前两个因子的乘积定义为当前段的 Compositing Weight(合成权重):
于是:
这就是后续 PyTorch 实现中最重要的颜色计算单元。
对于靠近相机的介质段,前方通常还没有累计太多衰减,因此可能具有较大的 。
对于位于远处的介质段,即使它自身具有很高的 Alpha,如果前方介质已经让 降得很低,它对最终像素的贡献仍然可能很小。
这就自然形成了正确的前后遮挡关系,而不需要人为判断“红色在前就优先显示红色”。

5.4.3 为什么最后还必须加上背景?

到目前为止,所有介质段的内部贡献可以合成为:
但观察射线可能穿过所有介质段之后,仍然保留一部分 Transmittance。
这个剩余部分对应远端背景。
穿过全部 段后的 Transmittance 为:
因此,背景贡献为:
最终像素颜色为:
或者使用 Weight:
至此,连续 Volume Rendering Equation 已经转换成一套离散计算规则。
它只需要每段的 Density、Color、Length,以及背景颜色,就可以完成一个像素的颜色合成。
这就是 Front-to-back Alpha Compositing 的数学基础。

5.4.4 一个可以直接验证的权重恒等式

刚刚定义 ,并且 。
将后一个公式展开得到 ,因此:
现在把所有段的 Weight 相加:
中间项会逐项抵消:
由于 ,所以:
加上背景权重:
这条恒等式非常重要。
它意味着所有前景段的 Weight 与最终剩余的背景 Weight,恰好共同构成完整的权重质量。
它不仅是数学性质,还能成为下一节实现时的测试条件。
如果代码得到的 明显偏离 1,就说明 Alpha、Transmittance 或 Weight 的计算中可能出现错误。
现在,最终公式中的所有计算项都已经具有明确来源。
接下来,将它们组装成真正的 PyTorch Volume Renderer。

5.5 用 PyTorch 实现并验证单射线 Volume Renderer

当前离散体渲染公式为:
其中:
这些计算都只涉及 PyTorch 已经支持的基础张量运算。
因此,并不需要额外引入复杂的渲染框架。

5.5.1 实现 render_ray()

上一章已经实现:
它接收每段的 Density 和 Length,返回全部段边界的累计 Transmittance。
因此,这里直接复用它。
核心计算只有五个步骤。
与上一章使用梯形求积的参考实现相比,这里不再对完整的颜色被积函数直接求积,而是先将每段介质近似为均匀段,对段内光传输进行解析计算,再组合所有段。
下面只需要把几个关键索引与数学定义对应起来。
首先:
对应 。
其中 optical_depth 的形状为 [N],因此每个 Segment 都得到自己的 Alpha。
接下来:
返回 个边界 Transmittance ,其中 , 对应穿过全部介质段之后的背景可见性。
然后:
这里为什么使用 T[:-1]?
因为 alpha 具有 个值,分别表示每个 Segment 自身的不透明度。
而当前段的 Weight 必须使用进入该段之前的 Transmittance ,因此要从 中取出 ,也就是:
如果错误地使用 T[1:],就等于把当前段自己的衰减提前计入前缀可见性,从而错误地降低它自身的贡献。
最后:
对应 。
这里,weights[:, None] * rgb 计算每个 Segment 的实际 RGB 贡献,sum(dim=0) 沿深度方向累加全部前景贡献。
而:
则保留了观察射线穿过全部介质之后仍然可见的背景。
至此,render_ray() 已经完整实现了离散 EA Volume Rendering Equation。

5.5.2 使用三个介质段进行手算验证

直接将整个双高斯场输入渲染器,虽然可以生成颜色,但不容易判断每个中间数值是否正确。
因此,先使用只有三个介质段的测试场景。
设 ,三段长度均为 ,从近到远对应蓝色、绿色和红色:
背景为白色 。
现在运行:
运行结果近似为:
这些值可以逐项核对。
三段的 Alpha 分别为:
但它们并不是最终的颜色权重。
第一段之前没有遮挡,,所以 。
第二段之前已经经过第一段,,因此 。
第三段之前已经经过前两段,,所以 。
最终背景透射率为 。
这意味着:
因此得到:
这个实验揭示了一个很重要的现象。
虽然第三段拥有最高的 Density 和 Alpha,但它距离相机最远,进入该段之前的 Transmittance 已经下降到约 0.2231。
因此,第三段的实际 Weight 反而低于前两段。
决定最终颜色贡献的不是局部 Alpha 本身,而是前方 Transmittance 与当前 Alpha 的乘积。
这就是 Front-to-back Compositing 的核心。

5.5.3 用数学不变量测试渲染器

手算例子能够检查单组数值,但还需要测试一些不依赖具体场景的数学性质。
首先检查权重守恒。
根据前面的推导 ,所以:
如果这个测试失败,通常需要检查 Transmittance 的前缀索引、Alpha 与 Weight 的对应关系,以及背景项是否使用了最终的 。
接下来检查空介质。
当所有 Density 为零 时,就有 ,全部前景 Weight 也为零,剩余背景透射率为 1。
因此最终像素必须等于背景 :
这个测试直接检查了背景项是否被正确加入。

5.5.4 将一个均匀介质段拆成两段,结果会改变吗?

还有一个非常重要的验证:同一段均匀介质不应该因为分段方式改变,就产生不同的理论渲染结果。
考虑一段长度为 1 的均匀介质,Density ,颜色 ,背景为白色。
先将它作为一个完整 Segment:
再将同一段介质拆成长度分别为 0.4 和 0.6 的两个 Segment。
由于它仍然是同一段均匀介质,所以两个区间使用相同的 Density 和 Color:
两种分段方式得到相同的结果,约为 。
原因来自:
以及均匀介质段的精确 Alpha 关系。
这个测试的意义比单纯比较两个 RGB 更重要。
它证明在当前 Piecewise-constant 模型下,均匀介质的渲染结果具有正确的段长一致性。
如果实现中把每个采样点都赋予一个固定的不透明度,而不考虑 ,那么增加采样数量就可能凭空增加介质的总不透明度。
需要说明,这个分段不变性对均匀介质可以严格成立。对于真正空间变化的连续 Density Field,有限采样仍然会引入近似误差,因此不能要求任意分段方式都得到完全相同的结果。

5.5.5 回到双高斯场:与连续积分参考实现对照

现在,单射线渲染器已经通过了几个基本数学测试。
接下来将它重新应用于第一章构建的双高斯 Density/Color Field,并与 Chapter 4 的连续积分数值参考版本进行比较。
沿用同一条观察射线 、,渲染区间 。
先将整条 Ray 划分为 128 个 Segment,并使用每段中点查询场:
在此前定义的双高斯场景参数下,得到:
这与 Chapter 4 使用高密度梯形求积得到的参考结果非常接近。
不过,这次计算过程已经不同。
Chapter 4 对连续的颜色被积函数进行数值求积。
而当前实现先使用有限样本构造分段常数场,再对每个均匀 Segment 的贡献进行解析计算,最后通过 Front-to-back Compositing 聚合颜色。
为了观察有限采样的影响,可以分别使用不同的 Segment 数量。
在当前平滑双高斯场景中,与 Chapter 4 使用 2049 个查询点得到的梯形求积参考结果比较,可以得到如下近似数值:
Segments
Red
Green
Blue
最大通道误差
16
0.986019
0.151727
0.114165
0.000228
32
0.986190
0.151708
0.113994
0.000057
64
0.986233
0.151704
0.113951
0.000014
128
0.986244
0.151702
0.113941
0.000004
对于这个平滑场景,随着 Segment 数量增加,渲染结果逐渐接近高采样率的参考结果。
但这里要区分两种误差。
首先,Piecewise-constant Approximation 使用每段的一个局部查询值代表整个区间,因此它与原始连续场之间存在近似误差。
其次,Chapter 4 的高密度梯形求积结果本身也是数值近似,并不是真正无误差的解析解。
因此,这个实验验证的是两种数值方法在当前场景下的一致性趋势,而不是证明所有场景的误差都会随 Segment 数量单调下降。
如果场中存在非常薄的高密度结构,有限采样还可能完全错过它。这属于后续 Sampling Strategy 需要解决的问题。

5.5.6 让中间权重成为可视化的调试工具

目前,render_ray() 不仅返回最终 RGB,还返回:
这些中间结果值得保留,而不应该在得到像素后立即丢弃。
对于同一条 Ray,可以将它们与 Density 一起可视化:
  • :当前位置的局部 Density;
  • :当前 Segment 自身的 Opacity;
  • :进入当前 Segment 之前的累计可见性;
  • :当前 Segment 对最终像素的实际颜色权重。
其中,Density 和 Alpha 描述的是局部介质的性质,而 Transmittance 和 Weight 已经包含了沿路径的传播影响。
尤其需要注意,Weight 不一定在 Density 最大的位置达到最大值。
因为 Weight 同时取决于当前段的 Alpha 和前方累计 Transmittance。
在双高斯场景中,靠近相机的红色区域可以获得较大的 Weight,而后方蓝色区域即使具有相似的局部 Density,也可能因为前方遮挡而获得较小的 Weight。
这正是本章最值得绘制的一张诊断图:
图 9|同一条 Ray 上的 Density 、Segment Opacity 、累计 Transmittance  与 Compositing Weight  随距离变化,展示局部介质属性如何转化为像素颜色贡献。
图 9|同一条 Ray 上的 Density 、Segment Opacity 、累计 Transmittance 与 Compositing Weight 随距离变化,展示局部介质属性如何转化为像素颜色贡献。
建议四条曲线共享距离轴 ,从而能够直接观察局部密度如何变成有限段 Opacity,以及前方介质如何进一步影响后方 Segment 的最终 Weight。
至此,一个最小的单射线 Volume Renderer 已经构建完成。
它具有明确的输入输出接口,能够正确处理前后遮挡和背景,并且通过了单段解析解、权重守恒、空空间以及均匀介质分段一致性等测试。
此外,当前核心计算主要由指数、乘法和加法组成,保留了 PyTorch 的计算图。因此,只要输入参数允许求导,最终像素也可以参与后续的 Autograd 计算。
不过,本章暂时只验证前向渲染。如何通过图像误差真正优化三维场参数,可以在完整图像渲染流水线建立之后再展开。

5.6 从一个像素到一张图像,还缺少什么?

现在,render_ray() 已经能够根据一条观察射线上的 Density、Color 和 Segment Length,计算出最终 RGB:
这意味着,只要给出一条 Ray 对应的场查询结果,渲染器就能够计算这个像素。
但真实图像并不只有一个像素。
假设最终需要生成一张 的 RGB 图像。
那么,图像中的每个像素都需要对应一条观察射线,而每条射线都需要独立查询三维场,并通过刚刚构建的 Volume Renderer 计算颜色。
因此,完整的图像渲染过程还缺少一个输入组件:
怎样根据相机参数,为图像中的每个像素生成对应的三维 Ray?
此前为了集中研究体渲染的数学机制,观察射线始终是手工指定的 、。
但对于完整图像,不同像素对应的观察方向通常不同。
要生成这些射线,就需要一个最小相机模型:将二维像素坐标转换成相机空间中的方向,再根据相机位姿将射线放入世界坐标系。
之后,刚刚实现的 render_ray() 就可以被复用到所有像素上。
整个流程将扩展为:
这一步不需要改变本章已经推导的 Alpha、Transmittance 或 Compositing Weight。
单射线渲染算子已经完成;接下来需要解决的,是如何为整张图像生成并组织它的输入。
下一章将从这个问题出发,构建最小的 Pinhole Camera,并把当前单射线渲染器扩展为完整的图像渲染流水线。

6. 从公式到代码:用 PyTorch 实现单射线体渲染

图 10|PyTorch 单射线渲染器根据 Density 与 Segment Length 计算 ,再由前缀乘积得到  和 ,最终累积各段颜色并合成背景 RGB。
图 10|PyTorch 单射线渲染器根据 Density 与 Segment Length 计算 ,再由前缀乘积得到 和 ,最终累积各段颜色并合成背景 RGB。
 
上一章已经将连续 Volume Rendering Equation 转换成了离散形式:
其中:
这意味着,一条连续的观察射线已经可以被近似为有限个介质段。每一段根据自己的 Density 和 Length 计算 Opacity,再结合前方累计的 Transmittance,得到最终的颜色贡献。
从数学上看,计算过程已经完整。
但对于真正的渲染器,仅仅推导出公式还不够。代码还必须正确处理段长、采样顺序、张量维度以及背景贡献。
例如, 只包含当前 Segment 之前的介质,而 对应当前段本身。如果实现时把这两个量的索引错开一位,即使程序能够正常运行,最终颜色也可能出现错误。
因此,本章不再引入新的光传输模型,而是将已经得到的离散公式组装成一个最小、可运行、可验证的 PyTorch Rendering Operator。
最终交付一个函数:
它接收一条 Ray 上的 Density、Color 和 Segment Length,返回一个 RGB 像素,以及用于分析渲染过程的中间结果。

6.1 一个正确的 Volume Renderer 应该接收什么?

先确定渲染器的输入。
在之前的离散化过程中,一条观察射线被划分为 个介质段 ,。
第 段具有三个局部属性: 是 Density, 是有效 RGB, 是当前介质段的长度。
为了让每一段都能正确参与计算,渲染器至少需要以下四个输入:
参数
Tensor Shape
含义
sigma
[N]
每段的 Density
rgb
[N, 3]
每段的有效颜色
delta
[N]
每段的真实长度
background
[3]
远端背景颜色
这里有一个重要的设计选择:render_ray() 不直接接收三维坐标,也不负责生成 Ray。
因为这些工作已经由此前构建的 sample_ray() 与 query_field() 完成。
当前函数只关心每个介质段的属性,以及如何根据离散体渲染公式计算最终像素。
这样,场景表示、采样策略和颜色合成能够保持相对独立。
无论 Density 来自解析高斯函数、Voxel Grid,还是之后的 Neural Field,只要能够提供符合接口的 sigma、rgb 和 delta,同一个渲染器就可以继续使用。

为什么 sigma 有 N 个值,而 Transmittance 有 N+1 个值?

这是实现前必须明确的索引约定。
一条 Ray 被划分为 个 Segment,需要 个边界:
每个 Segment 拥有一个 Density、一个 RGB 和一个 Length,所以对应 个值。
但 Transmittance 描述的是从相机到某个边界位置已经累计的衰减,因此需要覆盖 共 个值。
其中, 表示尚未经过任何介质; 表示已经经过全部 个 Segment,最终保留下来的 Transmittance。
这个最后的数值将用于计算背景贡献:
因此,渲染器的输出不仅包括最终 RGB,也应保留整个 Transmittance 序列,方便检查每一个 Segment 之前的累计可见性。
确定这些约定后,核心计算就可以直接从公式转换成代码。

6.2 从零实现单射线 Volume Rendering Operator

先回到离散合成公式:
要计算它,可以将过程拆成四个步骤。
第一步,将每个 Segment 的 Density 和 Length 转换成 Alpha:。
第二步,计算进入每个 Segment 之前的累计 Transmittance:。
第三步,将当前段的 Alpha 与前方 Transmittance 相乘,得到实际颜色权重:。
第四步,累加前景贡献,并加入剩余背景:。
这些步骤都可以使用基本的 PyTorch 张量运算实现。
其中,累计 Transmittance 已经在 Chapter 3 中实现为:
它接收 [N] 形状的 Density 和 Length,并返回 [N+1] 形状的累计 Transmittance。
因此,当前渲染器直接复用这一组件。
整个渲染器只有几段核心计算。
但它已经覆盖了此前推导的全部离散体渲染机制。
这里使用 -torch.expm1(-optical_depth),而不是直接写 1 - torch.exp(-optical_depth),是为了在 Optical Depth 非常小时更稳定地计算 Alpha。两者在数学上完全等价。
为了保证结果具有预期的物理含义,当前实现假设 Density 非负、Segment Length 非负,且 与 使用一致的长度单位。RGB 和背景则采用线性颜色表示。
接下来,需要确认这段代码的每一个关键操作是否真正对应此前推导的公式。

6.3 代码为什么对应离散体渲染方程?

对于一个较短的 PyTorch 函数,逐行阅读并不困难。
真正容易出错的地方,是把代码中的局部计算与累计计算混为一谈。
因此,这里重点检查三个关系:Alpha、Prefix Transmittance,以及最终的 Compositing Weight。

6.3.1 Density 和 Length 如何变成 Alpha?

首先:
对应 与 。
其中,sigma 和 delta 都具有 [N] 的形状,因此每个 Segment 都得到自己对应的 Alpha。
这一计算没有使用相机与 Segment 之间的距离,也没有考虑前方其他介质的影响。
Alpha 只描述当前 Segment 自身的不透明度。
如果把一个长度为 1 的均匀介质段拆成两个长度为 0.5 的 Segment,它们各自的 Alpha 会发生变化,但组合之后的整体光学厚度不应该改变。
这也是为什么 Alpha 必须由 Density 和真实 Segment Length 共同计算,而不能简单地为每个采样点分配一个固定值。

6.3.2 为什么 Weight 要使用 T[:-1]?

接下来:
这里是整个实现最重要的索引关系。
前面已经定义 ,它表示到达第 个 Segment 的起点之前已经累计的 Transmittance。
因此,第 段的实际 Weight 为 。
注意, 不包含当前第 段的衰减。
假设只有三个 Segment,那么 ,而 。
计算 Weight 时,需要的是 ,所以代码使用:
最后一个 不能参与第三段自身的 Weight,因为它已经包含了第三段的衰减。
如果错误地使用:
就相当于把当前 Segment 的衰减提前包含进了自己的可见性。
这样会错误地减少各段的前景贡献,使颜色合成不再符合此前推导的定义。
因此:
而:
这是 Front-to-back Compositing 最重要的索引约定。

6.3.3 为什么最后还要加入 T[-1] * background?

最后一段代码:
可以直接拆成两个部分。
第一部分:
对应:
其中,weights[:, None] 将 [N] 形状的 Weight 扩展为 [N,1],使它能够与 [N,3] 的 RGB 按通道相乘。
最终沿 Segment 维度求和,得到一个 [3] 的 RGB。
第二部分:
对应:
这里的 T[-1] 表示穿过全部介质段之后剩余的 Transmittance。
如果射线经过的区域完全为空(),那么每段 Alpha 都为零(),所有前景 Weight 也为零,但背景 Transmittance 保持为 1。
因此最终像素应该等于 Background,而不是自动变成黑色。
背景不是额外添加的装饰颜色,而是完整体渲染方程本身的一部分。
到这里,公式与代码已经完全对应。
但一个函数能够运行,还不意味着每个索引和计算都正确。
接下来,先用一个只有三个 Segment 的例子检查全部中间值。

6.4 怎样证明渲染器计算正确?

6.4.1 三个 Segment 的手算实验

复杂三维场景虽然更接近实际应用,但很难直接判断一个 RGB 是否正确。
因此,先构建一个只有三个介质段的测试。
设三个 Segment 的 Density 分别为 ,每段长度均为 。
颜色按照从近到远的顺序,依次为蓝色、绿色和红色:
背景设为白色:
这个例子包含了不同 Density、不同颜色和非零背景,因此能够同时检查多个合成步骤。
运行后得到近似结果:
这些结果可以直接通过数学公式核对。
三段的 Alpha 分别为 、、,但它们并不是最终的颜色权重。
第一段之前没有遮挡,,所以 。
第二段之前已经经过第一段,,因此 。
第三段之前已经经过前两段,,所以 。
最后的背景权重为 。
于是最终像素:
得到:
这里可以观察到一个重要现象。
虽然第三段具有最高的 Alpha,但它位于最远处,因此实际 Weight 低于前面的两个 Segment。
这不是人为设置的“近处优先”规则,而是前方累计 Transmittance 自然产生的遮挡效果。

6.4.2 测试前景与背景权重是否守恒

上一章已经证明:
因此可以直接将这条数学恒等式变成单元测试。
在当前三段实验中,,测试通过。
这一检查不仅确认了最终结果,还同时约束 Alpha、前缀 Transmittance 和背景权重之间的关系。
如果错误地把当前 Segment 的衰减重复计算进 Weight,这个恒等式通常就会被破坏。
因此,它是最小体渲染器中非常有价值的数学测试。

6.4.3 测试空空间是否返回背景

接下来,将所有 Density 设为零(),此时 ,所有前景 Weight 都应为零,而最终背景透射率为 1。
所以最终像素必须等于背景 :
代码验证:
这个测试检查了两个条件:空介质不应该凭空产生前景颜色;未受衰减的背景必须完整保留。

6.4.4 测试改变分段方式是否影响结果

再考虑一段长度为 的均匀介质,Density ,颜色 。
首先,将它作为一个完整的 Segment:
再将同一段均匀介质拆成两个区间,长度分别为 0.4 和 0.6。
由于它们描述的是同一段均匀介质,因此两个区间使用相同的 Density 和 Color。
两个结果一致,约为:
这是因为,均匀介质的总 Optical Depth 不因分段方式改变:
在段内 Density 和 Color 保持常数的前提下,逐段解析合成与一次性计算整段均匀介质具有一致的结果。
这个测试说明:改变均匀介质的采样分段方式,不应该凭空改变它的颜色。
它也证明 delta 不是一个可以省略的辅助参数,而是保持体渲染计算与传播距离一致的必要部分。
以上三个测试分别检查了权重关系、空空间行为和分段一致性。
接下来,渲染器可以重新接入之前构建的三维场,检查整个项目是否能够正确运行。

6.5 将渲染器接入我们的三维场

到目前为止,render_ray() 的输入都是人为指定的 Density、Color 和 Segment Length。
但第一章已经构建了一个可以查询任意三维位置的双高斯 Density/Color Field。
现在,这两个组件可以真正组合起来:

6.5.1 将固定 Ray 划分为有限个 Segment

继续使用此前的观察射线 、,渲染区间为 。
这里将整个区间划分成 128 个 Segment。
为了确保每个区间都具有明确的长度,先构造 129 个边界:
这里:
每一个 对应一个实际的 Segment Length。
接下来,在每段中点查询三维场:
中点只是当前采用的代表查询位置。
Piecewise-constant Approximation 将每个中点处的 Density 和 Color 视为对应区间的固定属性,再用该区间的实际长度进行合成。
这意味着当前采用的是:
而不是把 129 个边界全部当作具有长度的介质段。

6.5.2 渲染第一条 Ray

现在,全部输入已经准备好。
设置白色背景:
然后直接调用:
在前面定义的双高斯场景中,运行结果约为:
这条射线最终得到一个以红色为主的 RGB。
原因仍然来自当前场景的空间结构:红色密度团靠近相机,蓝色密度团位于更远处。
在 Front-to-back Compositing 中,后方蓝色区域的实际 Weight 会受到前方累计 Transmittance 的影响,因此最终像素主要保留靠近相机的红色贡献。
这里没有重新设计新的颜色合成规则。
渲染器只是按照此前推导的 Alpha、Transmittance 和 Weight,对三维场中的局部属性进行计算。

6.5.3 与连续积分参考实现进行对照

Chapter 4 已经使用梯形求积实现了一个连续体渲染的数值参考版本:
因此,还可以使用同一个场景和 Ray,对比两种数值计算方法。
第一种方法直接近似连续积分;第二种方法使用分段常数场,并对每个均匀段进行解析合成。
这两种方法的中间计算不同,但都以相同的连续 EA Volume Rendering Equation 为目标。
下面使用更加密集的查询位置生成参考结果。
对于当前平滑的双高斯场,128 段的分段常数渲染结果与高密度梯形求积参考值非常接近,最大 RGB 通道差异约为 。
这个数值说明,两种数值方法在当前场景中获得了一致的近似结果。
但需要注意,它并不代表任意场景都能用 128 个 Segment 达到同样精度。
例如,如果三维场中存在非常窄的 Density 峰,而采样位置没有覆盖到它,就可能遗漏实际的介质结构。
另外,Chapter 4 的梯形求积本身也是数值近似,因此这里的对照属于数值一致性验证,并不等于与无误差的真实解析解比较。
对于当前平滑场景,这项测试足以说明新实现正确地延续了此前建立的连续数学模型。

6.5.4 为什么还要观察 Alpha、Transmittance 和 Weight?

目前,渲染器已经能够返回最终 RGB。
但一个像素颜色并不能完整反映计算过程是否合理。
例如,渲染结果虽然可能接近预期,但中间的 Weight 仍然可能分布在错误的深度位置。
因此,render_ray() 保留了:
用于分析每一段介质的作用。
这里最值得观察的是三个不同层次。
第一,Alpha 描述每个 Segment 自身的不透明度,。
第二,Transmittance 描述到达每个 Segment 之前的累计可见性,。
第三,Weight 描述每个 Segment 对最终像素的实际贡献,。
这三个量虽然相互关联,但意义不同。
在当前双高斯场中,Alpha 会随着局部 Density 在两个高密度区域附近增大。
Transmittance 则随着路径累计消光不断下降。
而 Weight 同时依赖局部 Alpha 和前方累计可见性,因此它的峰值未必与 Density 的最大值完全一致。
这正是需要保留这些中间结果的原因。
建议使用同一条距离轴,将 Density、Alpha、Transmittance 和 Weight 分别绘制出来。
例如:
图 9|同一条 Ray 上的 Density 、Segment Opacity 、累计 Transmittance  与 Compositing Weight  随距离变化,展示局部介质属性如何转化为像素颜色贡献。
图 9|同一条 Ray 上的 Density 、Segment Opacity 、累计 Transmittance 与 Compositing Weight 随距离变化,展示局部介质属性如何转化为像素颜色贡献。
这张图应当是本章最重要的可视化结果。
它展示了第一章定义的三维场,怎样经过前面几章逐步建立的光传输机制,最终形成离散像素权重。
至此,一个真正可用的单射线 Volume Renderer 已经构建完成。
它能够正确计算每段介质的不透明度、前方累计透射率和最终 RGB,并且通过了多个来自数学推导的测试条件。
更重要的是,当前核心计算由指数、乘法、求和以及前缀累计组成。这些操作能够保留 PyTorch 的自动求导计算图,因此后续可以通过图像误差继续对 Density 和 Color 的参数求导。
但目前的测试仍然集中在前向渲染的正确性上。真正的图像监督与参数优化,可以在完整相机渲染流水线建立之后进行。

6.6 从一条 Ray 到一张图像,还缺少什么?

现在,单射线渲染器已经具有完整的输入输出关系:
这意味着,只要能够提供一条观察路径上的 Density、Color 和 Segment Length,渲染器就可以计算对应的 RGB。
但到目前为止,所有实验都使用了一条手工指定的射线 ,其中 、。
它只对应理想相机中的一个观察方向。
真正的图像包含大量像素。例如,一张 的图像共有 16384 个像素,每个像素都需要根据自身的位置确定对应的三维观察路径。
因此,下一步缺少的已经不是新的体渲染公式。
Alpha、Transmittance 和 Compositing Weight 都已经在当前渲染器中实现,并且不需要因为像素数量增加而重新推导。
真正需要解决的是:
怎样为图像中的每个像素生成一条对应的三维 Ray,并将这些 Ray 交给同一个 Volume Renderer?
这就需要一个最小的 Camera Model。
它将根据相机的焦距、主点与位姿,将二维 Pixel Coordinates 转换为三维 Ray Origins 和 Ray Directions。
随后,同一个 render_ray() 就可以应用于图像中的所有像素。
完整数据流将变成:
下一章将从这个问题出发,构建最小 Pinhole Camera,并将当前单射线渲染器扩展为一套完整的图像渲染流水线。


7. 从一条射线到一张图像:构建最小相机与图像渲染器

 
上一章已经完成了一个最小的单射线 Volume Renderer:
给定一条 Ray 上的 Density、Color 和 Segment Length,它能够通过 Alpha Compositing 计算最终像素颜色:
其中,每个 Segment 的 Alpha 来自均匀介质的解析解,Transmittance 则描述前方介质已经造成的累计衰减。
这意味着,沿一条已知观察路径从三维场计算 RGB 的问题已经解决。
但此前所有实验都使用一条手工指定的 Ray:
其中 ,。它只能提供一个观察方向对应的颜色。
如果希望生成一张完整图像,就必须知道图像中每个像素对应哪一条三维射线。
因此,本章不再修改光传输方程,而是为已有渲染器增加一个最小的 Camera Model,再将单射线计算扩展为批量计算。
最终,一个三维 Density/Color Field 将第一次能够生成完整的 RGB Image:
本章的核心组件是 get_pixels_from_image()、get_rays_from_pixels() 和 render_image():前两者借助已标定的 PyTorch3D Camera 生成世界空间 Rays,后者组织批量场查询与颜色合成。
第一章的 query_field()、第三章的 transmittance_from_segments() 和第六章的 render_ray() 都会继续复用;相机的坐标转换交给 PyTorch3D,张量广播与图像显示交给 PyTorch 和 Matplotlib。

7.1 一个像素已经可以渲染,为什么还不能得到图像?

先考虑一张大小为 的 RGB 图像,它包含 个像素。
在当前理想针孔相机模型中,每个像素都对应一条从相机光心出发的观察射线。
因此,第 个像素的颜色可以写成:
其中, 是图像在第 行、第 列的 RGB,而 是该像素对应的三维 Ray。
这里暂时采用最简单的成像近似:一个像素只使用穿过其中心的一条观察射线。真实相机还涉及有限像素面积、光圈、曝光时间和光谱响应,但这些因素不是当前最小项目必须解决的问题。
在这个假设下,整张图像的生成过程可以理解为:
其中每个 都由上一章的 Volume Renderer 计算。
但问题在于,目前的程序只知道如何根据一个给定的 Ray 计算颜色,并不知道不同像素应该使用什么 Ray Origin 和 Ray Direction。
所以,完整图像渲染缺少的第一个组件是:
一个能够将二维像素坐标映射为三维观察射线的相机模型。

7.2 从已知相机参数生成世界空间 Rays

上一节已经明确,一张 图像对应 条观察射线,而 Chapter 6 的渲染器已经能够计算其中任意一条 Ray 的 RGB:
现在缺少的不是新的光传输公式,而是一个将二维像素坐标转换成世界空间 Ray 的相机组件。
对于具有相机标定信息的数据,可以直接使用已知的 Intrinsic Matrix(内参)和 Extrinsic Parameters(外参),不需要再从 Field of View 估计焦距。本节使用 PyTorch3D 处理相机投影与坐标约定,只手写像素查询位置的组织和 Ray Direction 的计算。
最终的交付成果是两组与图像像素一一对应的 Tensor:

7.2.1 相机已经提供 K、R、t,现在需要 Pixel-to-Ray

在忽略 Skew、且图像已完成必要去畸变处理的针孔相机模型中,内参矩阵为:
其中, 是以像素为单位的焦距, 是主点位置。内参决定一个像素对应的相机空间观察方向;要获得世界空间中的 Ray,还需要相机位姿。
这里采用 OpenCV 常见的 World-to-Camera 外参约定:
OpenCV 与 PyTorch3D 的相机坐标轴约定不同:OpenCV 通常以 向右、 向下、 向前;PyTorch3D 的 View Space 则以 向左、 向上、 向前。为避免手工混用两套约定,使用 PyTorch3D 提供的转换接口构造 Camera 对象。
先使用一组明确给定的合成标定参数验证实现。图像取非正方形的 ,并使 、光心不在世界原点;真实重建时再替换为数据集提供的 。
该示例中的 、 对应世界坐标系下的相机光心位置坐标在:
注意,这里输入的是 World-to-Camera 外参。如果数据提供 Camera-to-World Pose,需要先转换约定,不能直接将旋转矩阵和平移向量传入。

7.2.2 NDC 是什么?为什么 PyTorch3D 使用统一的投影坐标?

相机对象已经包含内参和外参,接下来需要确定图像中哪些位置应当生成观察射线。最直接的办法是使用 Pixel Coordinates:一张 的图像以左上角为原点, 向右、 向下,第 个整数索引像素的中心位于 。
但这里出现了一个接口问题:像素坐标依赖图像分辨率,而投影和渲染系统希望用一套统一的规则描述图像上的位置。
例如,假设相机在图像尺寸从 等比例变为 时,也同步按比例更新了内参。两张图像中具有相同相对位置的点,其 Pixel Coordinates 会跟着分辨率改变;但它们相对于整幅图像的位置并没有改变。若每个后续模块都直接处理像素单位,就需要始终明确当前坐标属于哪种图像尺寸与轴方向。
**Normalized Device Coordinates(NDC,归一化设备坐标)**正是图形学中用于统一投影空间的一种表示。它不是另一个三维世界坐标系,而是把相机投影后的可见区域映射到规范化空间。在典型的投影流水线中,坐标依次经历:
World Space 描述场景中的实际三维位置;Camera Space 描述三维点相对于当前相机的位置;经过相机投影后,NDC 用统一的坐标尺度表示可见区域;Screen Space 则将投影结果对应到具体分辨率下的像素位置。传统光栅化流水线还包含齐次 Clip Space 与透视除法,但当前任务不需要展开这些内部步骤。
这里还需要避免一个名称相似的混淆。相机几何中的 Normalized Camera Coordinates 通常指 ,它描述的是透视除法后的观察方向;NDC 则进一步依赖投影尺度和图形系统的坐标约定,两者不是同一个坐标系。
为什么 PyTorch3D 使用 NDC? 因为它的 Mesh 和 Point Cloud Rasterizer 约定接收 NDC 空间中的投影几何。无论输入相机如何标定、图像有多大,光栅化器都可以按同一套规范空间解释二维位置与可见范围,而不必针对每台相机的像素尺寸重新定义几何处理规则。
但这不意味着所有 PyTorch3D 相机必须由用户以 NDC 参数创建。PerspectiveCameras 同时支持以像素为单位的 Screen Space 相机参数;当需要与 NDC 接口对接时,PyTorch3D 可以通过 Screen-to-NDC 变换处理尺度和方向差异。7.2.1 使用的 cameras_from_opencv_projection() 也会处理 OpenCV 标定参数与 PyTorch3D 投影约定之间的转换。原始焦距和主点信息不会因为归一化而丢失,也不需要再次手工修改传入的 。
这正是当前实现选择 NDC 的理由:让像素采样位置与 PyTorch3D 的相机反投影接口保持一致,而不是让后续 Volume Renderer 改在 NDC 空间运行。 如果直接使用 Screen Space 反投影,也能够生成相同的 World-space Rays;NDC 在这里是一项接口选择,不是体渲染方程的必要条件。
按照 PyTorch3D Cameras 官方文档,其 NDC 的图像平面采用如下约定:
属性
Screen / Pixel Coordinates
PyTorch3D NDC
原点
图像左上角
图像中心
水平方向
向右增大
向左
竖直方向
向下增大
向上
坐标尺度
Pixel
无量纲的规范化尺度
非正方形图像
范围随 改变
短边映射到 ,长边按宽高比扩展
严格来说,NDC 是包含深度的三维投影空间,第三个坐标的具体映射取决于相机模型。本节的 Pixel Grid 只需要二维 NDC 的 ;下一小节在反投影时再为这些位置指定一个有效的相机深度。
对于一张 的图像,令 Screen 坐标 表示像素中心,定义:
那么 PyTorch3D 的 Screen-to-NDC 图像平面转换为:
分子把原点移动到图像中心,并按 PyTorch3D 约定翻转水平和竖直轴;分母则让两轴采用相同的缩放尺度。
为什么要使用 ,而不是分别用 和 归一化?因为后一种写法会让非正方形图像的横纵方向采用不同的尺度。PyTorch3D 采用短边规范到 、长边保留宽高比的规则,使规范化图像空间仍具有一致的横纵尺度。
例如,本节的图像尺寸为 ,所以 。图像边界对应的 NDC 横向范围为 ,纵向范围为 ;左上角像素中心 对应 ,而中心像素 对应 。
现在将这条转换关系写成 PyTorch 代码:
预期得到:
meshgrid(indexing="ij") 按图像的行、列生成全部像素中心,最后 reshape(-1, 2) 将其按行优先顺序展平。代码输出的 35 个 NDC 位置与 35 个像素一一对应;它只是改变坐标表示,并没有凭空增加或修改相机的投影信息。
这里完成的转换是:
它与第一章中的 Ray Marching Sampling 仍是两件不同的事:当前只是确定每个像素从哪个方向观察,尚未沿任何三维 Ray 取样。
下一步将把这些 NDC 图像位置交给已标定的 Camera 进行 Unprojection,返回真正用于体渲染的 World-space Ray Origins 和 Directions。

7.2.3 使用 PyTorch3D 将像素坐标转换为 World-space Rays

同一个像素可能对应一条射线上多个不同深度的三维点。因此,从二维位置反投影时,需要额外指定一个有效深度。
当前目标只是计算 Ray Direction,而不是恢复实际场景深度。对于针孔透视相机,在相机前方选取一个有效的参考深度,就能够获得该像素对应的一条观察射线。
这里使用正的相机深度 1 作为参考。它不等于后续 Ray Marching 的 Near/Far,也不表示沿 Ray 的欧氏距离必然为 1。对于 PyTorch3D PerspectiveCameras 的反投影接口,应明确使用 from_ndc=True 指定输入的图像平面坐标采用 NDC 约定。
其中,unproject_points() 根据相机投影模型返回世界坐标中的一个参考点;get_camera_center() 返回相机光心。二者之差即为对应的观察方向,归一化之后:
这里必须归一化,因为前面几章采用:
使 和 Segment Length 能够直接解释为沿射线的实际距离,从而正确计算 。
预期得到:
至此,35 个像素已经分别对应一条世界空间射线,后续 Density/Color Field 可以直接接收这些射线生成的采样位置。

7.2.4 使用投影往返验证 Camera Rays

输出 Shape 正确并不等于相机几何一定正确。坐标轴翻转、外参方向混用或忘记归一化,都可能让程序正常运行,却得到错误的观察方向。
因此,先检查具有已知几何结果的测试相机,再执行一次投影往返。
首先,全部 Ray Origin 应等于世界空间光心 :
其次,当前 图像中心像素的连续坐标恰好与主点 重合;测试相机又没有旋转,因此中心 Ray 应当沿世界空间 方向:
最后,任意 Ray 上的正距离参考点,应该重新投影到原来的图像位置。选取:
再使用同一个相机投影回 NDC:
这一测试检查的是:
如果实现正确,前后两组 NDC 位置应当在浮点误差范围内一致。不过,投影往返使用了同一个 Camera 对象,不能单独排除成对抵消的约定错误,因此仍保留已知光心和中心方向的独立检查。
本节的可交付成果已经明确:给定已知 与图像尺寸,可以生成与像素一一对应、方向归一化的世界空间 Rays,并通过几何不变量验证。
下一节只需沿这些 Rays 进行 Sampling、Field Query 和 Chapter 6 已完成的 Alpha Compositing。NDC 在生成世界空间射线后便完成了任务;Volume Rendering 仍然在世界空间中计算。

7.3 怎样一次渲染整张图像?

现在,相机已经可以输出 ,其中 ,每一行代表一个像素对应的三维观察射线。
接下来,可以按照前面建立的单射线渲染流程,依次完成 Ray Sampling、Field Query 和 Alpha Compositing。
最直观的实现,是使用一个 Python 循环:
这种方式在逻辑上没有问题,但每一条 Ray 都需要单独进行场查询与张量操作。
对于一张 的图像,就需要处理 条射线。
如果每条射线使用 128 个 Segment,那么整个图像需要查询 个空间位置。
逐条执行大量小型 PyTorch 运算会带来不必要的 Python 调度开销。
因此,更合理的做法不是重新实现光传输公式,而是在张量中增加一个 Ray 维度,让所有射线共享同一套计算规则。

7.3.1 将单射线采样扩展为批量采样

单条 Ray 的采样位置为:
现在将 Ray 索引记为 ,对应:
这里, 决定正在处理哪个像素的 Ray, 决定当前 Ray 上的采样位置。
因此,采样位置的 Tensor Shape 从 扩展为 。
由于第一章的 query_field() 已经支持任意前置批量维度,这个张量可以直接送入原有的 Density/Color Field,而不必逐条调用。
对应代码只有一行:
它直接实现 。
其中:
  • origins[:, None, :] 的 Shape 为 [R, 1, 3]。
  • directions[:, None, :] 的 Shape 为 [R, 1, 3]。
  • t_mid[None, :, None] 的 Shape 为 [1, N, 1]。
通过 Broadcasting,它们共同生成 。
现在直接查询:
得到 以及 ,这意味着所有像素的 Ray 已经从同一个三维场中获得了各自的局部属性。

7.3.2 让单射线渲染器支持批量输入

接下来需要解决一个很小的接口问题。
Chapter 6 的 render_ray() 主要处理 、;现在要处理的是 、。
但 Alpha、Transmittance 和 Weight 的数学公式没有改变:
因此,只需让求和与索引始终沿每条 Ray 的 Segment 维度执行。
Chapter 3 的 transmittance_from_segments() 已经使用最后一维进行 cumsum,可以直接支持批量输入。
Chapter 6 的 render_ray() 则只需要把关键的索引与求和写成:
其他计算保持不变。
其中,... 表示保留前面的批量维度。
对于单条 Ray,这个函数仍然返回 ;对于 条 Ray,它就返回 。
最重要的是,这不是另一种渲染算法,而是同一套公式在不同 Tensor Shape 下的应用。

7.3.3 组装完整的图像渲染器

现在,Camera Ray Generation、Ray Sampling、Field Query 和批量 Alpha Compositing 都已经准备好。
render_image() 不再接收 FOV、手写的 Camera-to-World Rotation 或相机位置,而是直接接收 7.2 中构建的 已标定 PyTorch3D Camera 对象。因此,生成 Rays 的几何过程与渲染器的光传输过程保持分离。
这段代码并未引入新的光传输公式。get_pixels_from_image() 生成 NDC 图像查询位置,get_rays_from_pixels() 通过已知相机反投影返回世界空间的光心和单位观察方向。
随后所有采样点仍然使用:
在世界坐标系中构造,delta 也始终表示沿单位射线的真实段长。因此,第六章的:
完全不需要因为加入 PyTorch3D 或 NDC 而修改。
批量合成得到 后,再按像素网格的行优先顺序恢复:
额外返回的 Opacity 为 ,用于检查哪些像素的观察路径穿过了明显的 Density。接下来用已知标定参数与单射线结果验证这套流水线。

7.4 怎样验证相机与图像渲染器正确?

7.2 已经验证了 Pixel-to-Ray 的几何一致性:已知世界空间光心、中心方向与投影往返能够相互对应。本节进一步检查两个工程问题:批量渲染是否与单射线计算一致,以及改变已标定相机的位姿后,同一个三维场是否能够生成不同视角的图像。

7.4.1 准备具有已知内参的合成测试相机

为了继续复用 Chapter 7 的相机接口,先定义一个很短的测试工具。它只负责将一组明确给定的合成相机内参和外参交给 PyTorch3D,不负责从 FOV 估计焦距。
这里的 是预先指定的合成标定参数;真实相机则应直接使用真实的 。对于当前只在世界 方向移动、且不旋转的测试相机,,所以世界空间光心位于 。
后续修改相机位置时,只改变 Extrinsics;焦距、主点和三维场本身保持不变。

7.4.2 使用已标定相机渲染第一张图像

将图像大小设置为 ,已知相机焦距取 Pixel,主点位于 ,相机位于世界空间原点、朝 方向观察。每条 Ray 使用 128 个 Segment。
预期 Tensor Shape 为:
这意味着 条世界空间 Rays 已分别查询同一个三维场,并由 Chapter 6 的 Volume Renderer 合成为 RGB。显示结果:
Figure 7-1:使用已知内参和位姿的 PyTorch3D Camera,从同一个双高斯 Density/Color Field 渲染第一张 RGB 图像。
当前场景中,靠近相机的红色高斯大体遮挡了远处的蓝色高斯。图像不是直接在二维画布上绘制的圆形,而是每个像素对应一条世界空间 Ray,再通过采样与 Alpha Compositing 得到的结果。
首先检查图像和 Opacity 的基本性质:
这些条件证明输出形状和数值范围符合预期,但不能单独保证批量化过程与单射线实现一致。

7.4.3 验证批量像素与单射线结果一致

选择第 48 行、第 48 列的像素。根据前面使用的 meshgrid(indexing="ij") 和行优先展平顺序,它对应:
使用同一个 Camera 对象重新生成该像素的 World-space Ray,再单独调用 Volume Renderer:
这项检查直接约束了从二维 Pixel、PyTorch3D 反投影、World-space Sampling 到最终 RGB 的完整路径。
如果批量计算不小心错置了 Ray 维度或 Segment 维度,即使输出具有正确的 Shape,单射线一致性仍可能失败。

7.4.4 改变相机位姿:同一个三维场能否生成不同视角?

最后保持 、图像分辨率、Density/Color Field 和 Volume Renderer 不变,只将相机光心沿世界 方向从 0 平移到 0.55。
因为两台相机拥有相同的内参和旋转,这次图像变化不是来自焦距或颜色场修改,而是来自世界空间 Ray Origins 的变化。
Figure 7-2:同一双高斯场的两个已标定观察位姿与平移后 Opacity。仅修改 Extrinsics,保持 Camera Intrinsics 和体渲染算法不变。
靠近相机的红色密度团与更远处的蓝色密度团,在相机平移时会产生不同的像素位移;这来自 Perspective Projection 的深度关系。每条新 Ray 上的颜色则仍由 Transmittance 和 Alpha Compositing 决定。
这说明 Perspective Camera 负责确定“每个像素观察世界的哪里”,而 Volume Renderer 负责计算“沿这条路径能够接收到多少有效颜色”。
当前每个像素仍只使用一条中心 Ray,未处理像素面积积分和抗锯齿;批量计算也会保留 中间张量。提高分辨率时,可以引入 Ray Chunking,而不需要修改已建立的成像公式。

7.5 一张图像已经能够生成,下一步还缺少什么?

至此,完整的前向渲染流水线已经建立:
这个系统已经能够根据相机参数,为所有像素生成观察射线,再沿每条 Ray 查询共享的三维场,最终通过 Alpha Compositing 生成图像。
但目前的 Density 和 Color 仍然由第一章手工指定的高斯函数决定。
例如,两个高斯密度团的中心、空间尺度、Density Amplitude 和 RGB 都是事先设定的参数。
因此,当前系统能够回答:
给定一个已知的三维场和相机,应该渲染出什么图像?
但它还不能回答另一个更重要的问题:
如果给定一张目标图像,能否反过来调整三维场的参数,让渲染结果逐渐接近目标?
这正是三维重建的基本方向。
当前程序已经具有解决这个问题所需要的关键基础。
首先,三维场由连续函数表示,其参数可以控制 Density 和 Color 的空间分布。
其次,Camera Model 能够建立三维空间与二维像素之间的对应关系。
最后,从 Field Query 到 Alpha Compositing 的核心运算都由 PyTorch 实现,其中的指数、乘法和求和可以参与自动求导。
因此,只要定义一个比较渲染图像与目标图像的 Loss Function,就可以尝试通过 Autograd 计算损失对三维场参数的梯度。
例如,给定目标图像 以及当前场景参数 对应的渲染图像 ,可以定义一个最简单的像素均方误差:
如果能够计算 ,就可以利用梯度优化调整场景参数,使其产生的图像逐渐接近目标。
但当前的 query_field() 仍然使用固定的解析高斯参数,还没有将它们组织成真正可训练的参数集合。
因此,下一步不需要重新设计体渲染方程,而是让已经构建的三维世界从“可以被渲染”进一步发展为“可以根据图像被优化”。
下一章将从一个最小的 Inverse Rendering(逆向渲染)实验开始:保持相机和 Volume Renderer 不变,只将少量高斯场景参数变为可训练变量,并使用 PyTorch Autograd 从目标图像中恢复它们。
这样,一个能够生成图像的三维世界,就开始具备从图像中学习自身表示的能力。
 


8. 从像素误差到场参数更新:验证可微体渲染

图 11|目标图像与当前渲染结果之间的 RGB Reconstruction Loss 沿可微体渲染计算图反向传播,为双高斯 Density/Color Field 的可训练参数提供梯度。
图 11|目标图像与当前渲染结果之间的 RGB Reconstruction Loss 沿可微体渲染计算图反向传播,为双高斯 Density/Color Field 的可训练参数提供梯度。
上一章已经将 Camera Ray Generation、Density/Color Field 和 Volume Rendering 连接成一套完整的图像渲染流水线:
给定一个三维场及其参数,渲染器已经能够生成对应的图像。改变相机位置,还能从不同视角观察同一个三维场景。
但目前的三维场仍然由手工指定的参数构成。
例如,第一章的双高斯场中,每个密度团的中心、空间尺度、密度幅度和颜色都在构建场景时直接给定。
这意味着,当前系统只能解决一个方向的问题:
如果反过来,只给定希望得到的图像,三维场能否自动调整自己的参数,使渲染结果逐渐接近目标?
这个问题不需要重新设计 Volume Rendering Equation。
此前的渲染过程已经由 PyTorch 中的指数、乘法、求和与张量运算组成。只要场参数能够进入这些计算,并且像素误差能够沿计算图反向传播,就有可能利用梯度更新场参数。
因此,本章将保持相机与 Volume Renderer 不变,只把双高斯场中少量已知参数变为可训练变量。
最终构建一个最小的逆向优化闭环:
本章的目标不是恢复任意复杂场景,而是验证一个更基础的机制:由像素误差产生的梯度,是否真的能够穿过体渲染过程,并更新三维场中的空间参数?

8.1 已经能够生成图像,怎样反过来恢复场参数?

要检查一套逆向优化流程,首先需要一个能够明确判断结果是否正确的实验。
如果直接使用真实照片,就必须同时考虑相机参数误差、真实物体材质、光照条件,以及当前 Density/Color Field 是否具有足够的表达能力。
当优化效果不好时,很难判断问题究竟来自哪里。
因此,这里暂时不引入新的数据集,而是继续使用此前构建的双高斯场。
不同的是,这次将它分成两个角色。
第一个角色是 Target Field:使用一组已知参数生成目标图像,随后保持目标不变。
第二个角色是 Trainable Field:使用相同的表示方式,但故意设置错误的初始参数,通过优化让它的渲染结果接近目标图像。
这种实验能够将问题限制在已经建立的渲染系统内部,避免引入额外的未知因素。

固定场景中已知的部分

沿用第一章的两个高斯密度团,其真实中心分别为:
两个高斯的 Density Amplitude 均为 6,空间尺度参数为 0.32。
对应的有效颜色保持不变:
这里的星号表示用于生成目标图像的真实参数。
为了让优化任务足够简单,当前只允许前方红色高斯的中心坐标发生变化。
其深度、两个高斯的 Density Amplitude、Width、Color,以及后方蓝色高斯的全部参数都保持固定。
因此,实际需要优化的参数只有:
真实值为:
这并不是完整的三维重建问题,而是一个在已知场景结构下恢复少量空间参数的受约束逆问题。
但正因为它足够简单,才能清楚检查梯度究竟能否从二维图像传回三维场。

让已有的场函数接收参数

第一章的 query_field() 直接使用固定的 centers 和 palette。
现在只做一个很小的接口调整:把它们改为函数参数,让场查询可以接收不同的中心位置。
高斯密度与局部颜色混合的数学定义保持不变:
对应的实现为:
这里并没有引入新的三维表示。
它仍然是第一章构建的解析 Density/Color Field,只是原本固定在函数外部的中心参数,现在能够作为输入参与计算。
这项改动非常重要。
因为当 centers 由可训练的 PyTorch Tensor 构成时,场查询产生的 Density 和 Color 就能够保留与这些参数之间的计算关系。

生成固定的目标图像

接下来复用 Chapter 7 的已标定 Camera 和 Chapter 6 的批量 Volume Renderer。
本实验使用 ,每条 Ray 划分为 个 Segment,渲染区间仍为 。为保持合成实验可复现,使用明确给定的内参 Pixel、主点 ;相机的世界空间光心位于原点,旋转为单位矩阵。
这里直接复用 Chapter 7 定义的 make_test_camera() 和 Pixel-to-Ray 接口。真实多视角数据则应使用各张图像自身的标定参数,而不是假设所有图像共享某个 FOV。
图像尺寸刻意保持较小,因为当前实验要验证的是可微渲染与参数更新机制,而不是输出分辨率。
这一计算继续沿世界空间单位 Rays 采样,所用的 t_edges 和 delta 不受图像 NDC 的定义影响。由于相机内参与位姿在本实验的优化循环中保持固定,points 与 delta 可以提前计算并复用;更新高斯中心时无需重新生成 Rays。
接下来定义场景参数:
再使用一个很短的函数组合已有组件:
render_scene() 没有重新实现任何体渲染公式。
它只是将场参数传入 query_field(),再把查询结果交给此前已经验证的 render_ray()。
因此,最终图像可以表示为:
这里使用表示完整的前向渲染过程。
现在生成目标图像:
这张图像由真实参数生成,后续不会再发生变化。
torch.no_grad() 表示生成目标图像时不需要保存用于反向传播的计算图;.detach() 则进一步明确它是固定的监督目标。
到这里,逆向优化实验已经具备两个关键条件:
一个能够接收场参数并生成 RGB Image 的渲染函数,以及一张固定的目标图像。
下一步,需要确认图像误差究竟能否沿这个计算过程反向传播。

8.2 为什么像素误差能够沿渲染过程反向传播?

先回到离散 Volume Rendering Equation:
其中:
这些公式此前一直用于计算像素颜色。
但它们还有另一个重要性质:在当前有限、非负的场参数范围内,指数、乘法和加法都能够参与自动求导。
因此,最终像素不仅是 Density 和 Color 的函数,还是所有生成这些局部属性的场参数的函数。

先观察 Color 的梯度

为了看清梯度怎样出现,先暂时固定每段的 Density 和 Segment Length,只把局部 RGB 当作变量。
此时,所有 Weight 都是常数:
因此,对于第段的颜色:
这里的是三维单位矩阵,表示相同颜色通道之间的对应关系。
如果只考虑某一个颜色通道,例如红色,则可以写得更简单:
这个结果具有明确含义。
某个 Segment 的 Weight 越大,改变它的局部颜色,就越容易改变最终像素。
而位于强遮挡之后的 Segment,通常具有较小的 Weight,因此其颜色对像素的影响也较弱。
这与此前观察到的可见性关系完全一致。
现在,可以用 PyTorch 直接验证这条导数关系。
得到:
自动求导结果与此前推导出的 Compositing Weight 完全一致。
这项测试说明:当 Density 固定时,最终像素关于每段局部颜色的梯度,确实由该段的 Weight 决定。
不过,当前实验需要优化的是高斯的位置,而不是直接修改每个采样点的 RGB。
因此,还需要理解 Density 如何参与梯度传播。

Density 不只影响自己的 Alpha

每段 Alpha 为:
对求导:
这说明,在固定 Segment Length 的情况下,Density 的变化能够连续影响当前段的不透明度。
但 Density 的作用并不止于当前 Segment。
因为:
所以,前方第段的 Density 还会影响所有位于它后面的。
当某个前方 Segment 的 Density 增大时,它自身的 Alpha 通常增大,同时后方 Segment 的可见性下降。
因此,Density 的梯度同时包含局部贡献变化和前后遮挡关系的变化。
这正是体渲染能够通过图像误差优化空间密度分布的重要原因。
但是,可微不等于所有参数始终都有足够强的梯度。
当某个前方区域几乎完全不透明时,后方的 Transmittance 可能已经非常接近零。此时,后方 Color 对像素的梯度也会很弱。
同样,当高斯中心远离所有采样 Ray,场查询结果接近零时,对中心位置的梯度也可能很小。
所以,正确的说法并不是“体渲染一定能够通过梯度恢复任意三维场景”,而是:
体渲染建立了一条可求导的图像生成路径,使图像误差能够对影响渲染结果的场参数提供优化信号。
接下来,将这条梯度路径连接到真正的三维空间参数。

8.3 怎样让双高斯场的参数变得可训练?

当前的 centers_gt 是普通 PyTorch Tensor,用于生成固定目标图像。
要执行优化,需要另外定义一组可以被更新的参数。
此前已经确定,只优化前方红色高斯的中心坐标,真实值为。
为了观察优化能否真正发挥作用,不能直接把可训练参数初始化为真实值。
这里故意使用一个错误的初始位置:
这意味着红色高斯会出现在错误的三维位置,导致它在图像中的投影与目标不一致。

使用 nn.Parameter 声明可训练参数

PyTorch 已经提供了管理可训练张量的机制,因此不需要自己实现一套梯度存储结构。
nn.Parameter 使这个二维向量成为优化器能够更新的变量。
不过,query_field() 接收的是完整的三维高斯中心,因此还需要将这两个坐标与固定的深度组合起来。
先保留不需要更新的参数:
然后实现:
这样,assemble_centers() 返回两个三维高斯中心:
其中,只有第一行的前两个坐标来自可训练参数。
这里有一个很容易忽略的 PyTorch 细节。
在构造新的 Tensor 时,不应该使用 torch.tensor([...]) 重新包装已有的可训练张量值,否则可能切断原来的 Autograd 计算关系。
这里采用 torch.cat(),是为了让新构建的中心坐标继续保留与 front_xy 之间的梯度连接。
现在,可训练参数已经能够进入 query_field(),并沿着之前的渲染流水线影响最终图像。

定义 Pixel Reconstruction Loss

接下来需要一个衡量当前渲染结果与目标图像差异的函数。
这里采用最简单的 Mean Squared Error(MSE):
它衡量每个像素、每个颜色通道上的平方误差,并对整张图像求平均。
对应代码只有两行:
这里,predicted_image 会随着 front_xy 的变化而变化,而 target_image 始终保持固定。
因此,Loss 可以被看成场参数的函数:
现在,尝试对它执行反向传播:
当前实验得到:
这个结果已经证明了一件重要的事情:
像素误差确实产生了关于三维高斯中心坐标的非零梯度。
观察初始参数,以及。
如果沿负梯度方向更新参数,两个坐标都会减小。
而目标位置为,因此,这个初始梯度方向与当前参数需要修正的方向一致。
不过,一个非零梯度还不足以证明整个优化过程有效。
下一步,需要反复使用这些梯度更新参数,并观察图像误差是否真正下降。

8.4 用 PyTorch 让渲染结果逐渐接近目标

目前,整个计算过程已经可以表示为:
Autograd 能够计算,因此,可以使用梯度下降类方法更新场参数。
最基本的更新规则是:
其中,是 Learning Rate。
这条规则的含义是:根据当前图像误差对参数的变化率,沿着局部下降方向调整场参数。
当前实验使用 PyTorch 已经提供的 Adam Optimizer,而不手写优化器内部的动量与自适应学习率机制。
因为本章需要验证的是渲染器的梯度链路,优化器本身并不是当前项目的新技术模块。

实现最小优化循环

这是本章真正新增的核心计算闭环。
它没有修改 render_ray(),也没有更换原来的 Density/Color Field 表达式。
变化的是场参数的使用方式:此前高斯中心始终是固定值,现在它们成为能够根据图像误差更新的变量。
观察循环中的每一步。
首先:
清除上一轮留在参数上的梯度,避免默认的梯度累积影响当前更新。
接下来:
使用当前场参数重新渲染整张图像。
这个操作会让当前 front_xy 经过高斯密度查询、Alpha、Transmittance 和颜色合成,最终影响每个像素。
然后:
衡量当前图像与目标图像的差异。
执行:
之后,Autograd 根据计算图中的链式关系,将图像误差一直传回 front_xy。
这个过程可以理解为:
这里使用链式关系的简化记法,实际计算中的和都包含大量像素或查询位置,对应的导数是 Jacobian 或 Vector-Jacobian Product。
最后:
使用这些梯度更新高斯中心。
这意味着,在没有显式告诉程序高斯应该向左还是向右移动的情况下,图像误差已经可以通过渲染器,为场参数提供更新方向。

观察训练结果

运行 120 次参数更新后,当前实验的 Loss 变化如下:
参数更新次数
Image MSE
0
0.111007
40
0.000639
80
0.0000229
120
0.00000013
可以观察到,图像误差整体明显下降。
训练中并不要求每一次更新都严格降低 Loss。Adam 的自适应步长可能使参数在最优位置附近出现短暂的反复移动。
因此,比逐步单调下降更重要的是整体收敛趋势,以及最终参数是否接近目标。
检查更新后的高斯中心:
得到近似结果:
前方高斯的中心从逐渐移动到,非常接近用于生成目标图像的真实值。
这说明,在当前受控实验中,图像误差不仅能够产生梯度,还能够通过这些梯度修正三维场中的空间参数。
但训练后的图像看起来相似,并不是唯一需要检查的结果。
还需要确认梯度计算本身是否正确,以及参数恢复究竟验证了什么。

8.5 怎样验证梯度与参数更新确实正确?

到目前为止,训练已经产生两个明显结果:
渲染图像越来越接近目标,前方高斯中心也逐渐接近真实值。
不过,这仍然不足以完全排除实现错误。
例如,某段代码可能意外地切断了部分梯度,或者损失函数与目标图像之间存在错误的像素对应关系。
因此,本节从两个角度检查当前结果。
首先,验证 Autograd 计算出的参数梯度是否与独立的数值近似一致。
其次,检查恢复出的参数及其对应图像,而不是只观察 Loss。

8.5.1 用有限差分检查参数梯度

目前,Autograd 已经能够计算:
但这个结果能否通过一种独立的方法得到?
考虑有限差分(Finite Difference)。
当足够小时,可以使用:
这个公式不依赖 PyTorch 的自动求导。
它只是分别把高斯中心向和方向移动一个很小的距离,计算两次 Loss,再根据差值估计局部变化率。
因此,可以将它作为 Autograd 的独立数值检查。
为了避免在最终已经非常接近最优的位置比较极小梯度,这里重新使用初始参数。
先计算 Autograd 梯度:
这里使用 torch.autograd.grad(),直接获取 Loss 关于第一个中心坐标的梯度。
再用有限差分计算同一个量:
实际运行得到:
两种方法得到的梯度非常接近,相对差异约为。
这是一项比“Loss 能够下降”更直接的梯度验证。
因为有限差分在计算梯度时,并没有使用渲染器内部的 Autograd 反向计算,而是独立地观察参数微小变化造成的 Loss 差异。
结果一致,说明当前渲染流水线对高斯中心位置的导数,在这个测试位置上具有正确的数值表现。
当然,单个参数、单个位置的有限差分测试,不能证明所有参数在所有情况下都正确。它是当前最小实验中的一个局部梯度检查。
但结合此前对单射线 Weight 的解析梯度验证,已经能够从不同层次检查可微渲染的关键机制。

8.5.2 不能只看 Loss,还要看场参数是否恢复

现在对比优化前后的高斯中心:
状态
真实值
-0.1600
0.0000
初始化
0.3500
0.2000
120 次更新后
-0.1605
0.0002
可以看到,优化得到的两个中心坐标都非常接近真值。
在这个实验中,这是比最终 MSE 更有意义的验证。
因为目标图像本来就是由已知双高斯参数生成的,而实验只允许两个中心坐标变化,因此能够直接检查模型是否恢复了预先设定的参数。
不过,需要谨慎理解这个结果。
当前优化任务已经固定了两个高斯的数量、形状、颜色、密度幅度和深度,也已知准确的相机模型。
因此,算法只需要在二维参数空间中寻找合适的中心位置。
这与从真实图片中同时估计未知几何、材质、相机和光照,是复杂度完全不同的问题。
本实验能够支持的结论是:
在已知场景表示和固定渲染条件下,像素监督能够经由可微体渲染,恢复少量可辨识的空间参数。
它还不能证明任意三维场景都能从单张图像中唯一恢复。

8.5.3 将优化前后的图像放在一起

数值指标已经说明 Loss 明显下降,但图像对比还能帮助检查错误究竟来自哪里。
建议将结果组织为四幅图:目标图像、优化前的渲染图像、优化后的渲染图像,以及训练 Loss 曲线。
Figure 8-1:双高斯场的位置参数优化。左侧为由真实参数生成的目标图像;中间两幅分别为优化前后渲染结果;右侧是图像 MSE 随优化过程的变化。
在优化前,红色密度团的位置明显偏离目标,因此图像中的红色区域与蓝色区域的相对分布不正确。
随着高斯中心被更新,红色区域逐渐移动到目标位置,最终图像与目标几乎重合。
需要注意,这里没有直接对二维像素进行平移或复制,也没有修改目标图像。
发生改变的是三维场中的高斯中心。
中心位置改变后,query_field() 在不同空间位置返回的 Density 和 Color 随之变化;Camera Rays 查询到的局部属性发生变化,最终体渲染产生的像素也随之更新。
这说明优化作用于三维表示本身,而不仅是修改最终图像数组。
还可以采用一个更严格的后续测试:使用优化后的同一组场参数,从一个未参与训练的新相机位置重新渲染,并与真实场参数在该视角下生成的图像比较。
对于当前固定深度、只有两个可训练坐标的实验,恢复中心位置后,新视角通常也能得到接近目标的结果。
但这仍然依赖当前强约束的场景模型,不应将它直接等同于一般场景的新视角泛化能力。

8.5.4 为什么有时 Autograd 能求导,参数却仍然学不动?

最后需要区分两个容易被混为一谈的概念:
计算图可微,并不等于优化一定容易。
例如,假设在一条 Ray 上,前方某个 Segment 的 Alpha 已经非常接近 1:,那么它后方的累计 Transmittance 会非常小:,因此,后方 Segment 的颜色权重也可能接近零。
此前已经证明:
所以,后方 Color 对当前像素的梯度会随可见性下降而减弱。
这意味着,虽然公式能够求导,但当前图像可能没有为被遮挡区域提供足够强的优化信号。
同样,如果某个高斯密度团距离所有采样 Ray 都非常远,那么。
改变它的中心位置,可能几乎不会改变任何像素。
这时,即使使用正确的 Autograd,Loss 对该中心参数的梯度也可能极小。
此外,训练代码本身也可能意外切断计算图。例如,在 Forward Rendering 中对可训练参数调用 .detach()、使用 torch.no_grad() 包裹预测图像,或者把包含梯度关系的 Tensor 重新构造成一个普通 torch.tensor(),都可能导致场参数无法收到预期梯度。
因此,本章的验证不能只依赖最终图像。
同时检查参数的 grad、有限差分、Loss 曲线和场参数的实际变化,才能对可微体渲染的正确性形成更可靠的判断。

8.6 能够优化参数,是否就意味着能够重建任意三维场景?

到这里,渲染系统已经完成了从前向成像到反向优化的闭环:
Chapter 6 的单射线渲染器和 Chapter 7 的相机与批量图像渲染,将解析 Density/Color Field 转换为可观察的图像。Chapter 8 进一步通过 Autograd、有限差分与高斯中心恢复实验,验证像素误差确实能够沿 Transmittance、Alpha 和 Compositing Weights 回到三维场参数。
但这一结果有一个明确的适用边界:场景表示的函数形式已经由人工给定。
当前实验预先知道场景包含两个高斯密度团,知道它们的宽度、颜色、密度幅度与深度,只让前方高斯的两个中心坐标参与优化。因此,恢复这些参数说明已有渲染器支持梯度优化,并不能证明任意未知的三维几何都能够由单张图像唯一恢复。
当某个区域被前方介质遮挡、或某个参数对当前图像几乎没有影响时,像素监督可能提供很弱的梯度。即使优化后的 RGB 与目标接近,也不能据此推断所有未观察区域都具有正确的三维结构。
不过,本篇的核心任务已经完成。给定一个可查询的三维场及相机,系统已经能够从零计算可微 RGB Image,并利用图像损失更新已知场表示中的参数。
接下来的能力缺口来自场景表示,而不是 Volume Rendering Equation:真实场景不会提前提供高斯的数量、中心与解析形式。那么,能否不再手工写出 Density/Color Field,而是直接从多视图图像中学习一个共享的连续空间函数?
下一篇 将从这里继续。已经验证的 Camera、Ray Sampling 与 Differentiable Volume Renderer 会被直接复用;新的任务是设计并训练场景表示,再检验它是否能够生成未见视角的图像。
全部文章搜索Powered by NotionNext 4.11.0阅读配色:深色