☰
PBRT圆盘均匀采样:极坐标映射与同心映射原理及渲染应用
2026/10/17 1:20:37 网站建设 项目流程

如果你调过景深散景光斑,大概率见过那种“中心亮成一团、外圈稀稀拉拉”的奇怪画面。一开始我总怀疑是对焦逻辑算错了,直到某次把采样点直接画出来,才意识到问题根本不在光照,而在一个看起来毫不起眼的环节——圆盘均匀采样。这也是我从PBRT(Physical Based Rendering Techniques)里翻到对应章节时最有感触的地方:渲染器里大量“看起来高级”的效果,最后都依赖“在一个圆盘上均匀取点”这个基本功。

这篇文章就围绕PBRT里的圆盘均匀采样展开,把极坐标映射和同心映射两种方式的数学原理讲透,给出单点和numpy批量版本的Python实现,再用散点图、环带统计、网格变形实验验证它们的差异,最后扩展到半球采样和景深应用。适合正在学蒙特卡洛渲染、想实现软阴影或景深、或者只是想把采样算法搞明白的人。

1. 圆盘均匀采样到底在渲染里做了什么:从散景到软阴影都在靠它

1.1 三个最典型的应用场景

圆盘采样不是一个孤立的数学玩具。蒙地卡洛渲染器对“随机”这件事有极强的依赖,而圆盘往往是各种积分域里最基础的一个。

第一个场景是景深。真实相机镜头是有限大小的圆,光线从镜头光圈上不同的点进入。渲染器模拟薄透镜模型时,会在光圈圆盘上采样一个点,再从这点出发连接焦平面上的焦点。采样点分布直接决定散景的形状和亮度分布:如果样本往圆心堆积,散景光斑中心会比边缘亮;如果样本全是外圈点,散景会变成空心环。PBRT在处理相机采样时,用的正是圆盘采样。

第二个场景是软阴影。面光源要模拟出柔和过渡的阴影,需要在光源表面采样多个点,逐个去做可见性判断。很多实现会把面光源近似成圆盘或需要从圆盘映射过去的形状。光源采样点分布不均匀,阴影区域就会出现条带和明显噪点。

第三个场景是模糊后处理和环境光遮蔽。屏幕空间的景深模糊、泛光效果常常需要二维模糊核;AO采样虽然通常在半球上做,但半球余弦采样的基础也是圆盘均匀采样。正因为到处都要用,PBRT才专门花篇幅推导圆盘采样这个“小函数”。

1.2 “均匀”的真正含义:以面积为尺度的概率密度

很多人以为均匀采样就是“在圆盘上随机撒点,看起来哪里都有”。但“均匀”在蒙地卡洛里的定义要严格得多:圆盘上每个单位面积被采到的概率必须相同。换句话说,概率密度函数 (p(x, y)) 在圆盘内是常数 (1 / \pi),因为圆盘面积为 (\pi)。

这里有个关键逻辑:我们手里的随机数发生器只能产生 ([0, 1)) 均匀分布的数值,要把这些数值映射到圆盘上,映射后的概率密度必须保持平坦。任何“看起来均匀但实际密度不平坦”的采样,都会让积分估计产生偏差或额外方差。后面要说的极坐标映射和同心映射,本质都是在解决“怎么让密度平坦”这件事。

2. 从极坐标映射说起:公式看着没毛病,但缺陷同样明显

2.1 为什么要用sqrt:雅可比行列式的一次完整推导

最直觉的做法是采样半径 (r) 和角度 (\theta)。令 (r = u),(\theta = 2\pi v),其中 (u, v) 是 ([0,1)) 的均匀随机数。这个做法对吗?不对。

问题出在面积度量上。圆盘上的面积元素是:

[ dA = r \cdot dr \cdot d\theta ]

半径越小,同样一段 (dr) 对应的实际面积就越小。如果让 (r) 在数值上均匀分布,那么半径0附近的一小段距离只对应很小的圆环面积,但样本落在这一段的概率却不低,结果就是圆心附近样本密度很高。

正确的做法是令 (r = \sqrt{u})。我们来验证一下。设:

[ x = \sqrt{u} \cos(2\pi v), \quad y = \sqrt{u} \sin(2\pi v) ]

从 ((u, v)) 到 ((x, y)) 的雅可比行列式为:

[ J = \begin{vmatrix} \frac{\partial x}{\partial u} & \frac{\partial x}{\partial v} \ \frac{\partial y}{\partial u} & \frac{\partial y}{\partial v} \end{vmatrix} ]

代入后计算:

[ \frac{\partial x}{\partial u} = \frac{\cos(2\pi v)}{2\sqrt{u}},\quad \frac{\partial y}{\partial u} = \frac{\sin(2\pi v)}{2\sqrt{u}} ]

[ \frac{\partial x}{\partial v} = -2\pi\sqrt{u}\sin(2\pi v),\quad \frac{\partial y}{\partial v} = 2\pi\sqrt{u}\cos(2\pi v) ]

行列式展开:

[ |J| = \frac{\cos(2\pi v)}{2\sqrt{u}} \cdot 2\pi\sqrt{u}\cos(2\pi v)

  • \left(-2\pi\sqrt{u}\sin(2\pi v)\right) \cdot \frac{\sin(2\pi v)}{2\sqrt{u}} ]

化简后:

[ |J| = \pi\cos^2(2\pi v) + \pi\sin^2(2\pi v) = \pi ]

雅可比是常数 (\pi)。这意味着从 ((u,v)) 空间到圆盘,概率密度函数保持不变:((u,v)) 是均匀的,映射后 ((x,y)) 在面积上也均匀。很多人直接把这段推导跳过,然后就会对“为什么半径要用开方”百思不解。

2.2 极坐标映射的两个软肋:中心聚集感与网格扭曲

既然雅可比是常数,为什么实际效果还是有问题?

第一,正方形左边 (u=0) 这一整条边,在极坐标映射里全部收缩到圆心一点 ((0,0))。从连续概率角度看这条边面积为零,不影响均匀性;但离散采样时,如果样本在 (v) 方向上分布得比较密,就会有很多点“挤”在圆心附近,视觉上产生中心聚集感。

第二,局部形变特别大。(u) 方向上很小的变化,在圆心附近对应 (dr = du / (2\sqrt{u})),当 (u) 接近0时这个增量会非常大;而 (v) 方向始终对应完整的 (2\pi)。正方形里一个个规整的小格子,映射到圆盘上变成圆心附近细长的楔形、外圈又被压扁的条状。这种“各向异性扭曲”在纯随机采样时不那么显眼,一旦换成低差异序列或分层采样,样本之间的相邻关系就会被严重破坏,低差异序列该有的均匀性也打了折扣。

2.3 PBRT为什么偏要另起炉灶

PBRT在处理这类问题时,往往不只是问“概率密度是否均匀”,还会问“映射是否保留了几何结构”。

极坐标映射在概率密度上是达标的,但它在把正方形变成圆盘的过程中,将正方形内部大量的网格线压扁、拉长、甚至把一整条边收成一个点。PBRT认为这种映射的“扭曲”太大,因此推荐同心映射(concentric mapping):同样是把正方形映射到圆盘,但让正方形里的“同心正方形”轮廓,一一对应到圆盘上的“同心圆”。这样既没有“一条边收缩到圆心”的奇怪行为,网格线的变形也小得多,和分层采样、低差异序列配合时表现更好。

3. 同心映射原理拆解:把正方形“卷”成圆盘

3.1 从切比雪夫半径到极角:几何直觉

同心映射的思路是:先看正方形里的点离中心有多“远”。这里的“远”指的是切比雪夫距离:

[ r = \max(|u|, |v|) ]

注意单位正方形先被映射到 ([-1, 1] \times [-1, 1])。切比雪夫距离相等的点组成一个个正方形轮廓,比如 (|u| = 0.5, |v| \le 0.5) 就组成一个正方形边框。同心映射把这些正方形轮廓按比例放到半径为 (r) 的圆上,正方形轮廓上的点则按它们所在的“边”线性映射到对应的圆弧。

正方形有四条边,正好对应圆盘的四段圆弧。于是整个映射被自然地分成四个三角区域:左三角、右三角、上三角、下三角。每个三角区域里的点,都映射到圆盘对应的90度扇区内。

3.2 分区域映射的雅可比验证:每一片都是等面积

同心映射的代码看起来像是“如果 (|u| > |v|) 就怎么怎么样”,很多人以为这只是个分支技巧。实际上这个分支背后是严格的面积保持。

以右三角区域为例:此时 (u > |v|),映射写为:

[ r = u, \quad \theta = \frac{\pi}{4} \cdot \frac{v}{u} ]

于是:

[ x = u \cos\left(\frac{\pi v}{4u}\right),\quad y = u \sin\left(\frac{\pi v}{4u}\right) ]

对这个变换求雅可比行列式,结果正好是常数 (\pi / 4)。也就是说,右三角区域到右扇区的映射是等面积的。其他三个三角区域完全对称,也都是常数雅可比。四个区域合起来覆盖整个圆盘,边界之间严丝合缝,因此整个圆盘上的概率密度处处平坦。

这就是同心映射比极坐标映射更强的地方:它不仅在概率密度上均匀,还在局部面积比例上均匀,网格结构的变形也小得多。PBRT选它作为默认方案是有充分理由的。

3.3 代码行为细节:带符号半径与局部角偏移

同心映射在代码实现里比较绕:半径 (r) 是带符号的,角度 (\theta) 也不直接是最终极角。

以 (|u| > |v|) 为例,代码令 (r = u)。如果 (u) 为负,半径就是负的;(\theta) 只在 (-\pi/4) 到 (\pi/4) 之间变化。最终笛卡尔坐标 (x = r\cos(\theta), y = r\sin(\theta))。当 (r < 0) 时,颜色坐标其实落在左半圆,实际极角是 (\theta + \pi)。这是PBRT源码里的精简写法,避免了显式判断左右半圆,但理解时最好还是回到“四个三角区域分别映射到四个扇区”的几何直觉上。

import math import random def concentric_sample_disk(rng=random): # 将 [0,1)^2 映射到 [-1,1)^2 u = rng.random() * 2.0 - 1.0 v = rng.random() * 2.0 - 1.0 # 原点是一个特殊点,直接返回 if u == 0.0 and v == 0.0: return [0.0, 0.0] if abs(u) > abs(v): r = u theta = (math.pi / 4.0) * (v / u) else: r = v theta = (math.pi / 2.0) - (math.pi / 4.0) * (u / v) return [r * math.cos(theta), r * math.sin(theta)]

注意这里u == 0.0 and v == 0.0的判断。如果不处理,(|u| > |v|) 为假,会走 else 分支,(r = v = 0),(\theta = \pi/2),最终结果也是原点,看起来没问题。但浮点数除法里出现 (0/0) 这种不确定情况时,不同平台可能有不同表现,所以PBRT显式把原点单独挑出来。

4. Python实现:单点版、numpy批量版与随机源注入

4.1 单点采样函数:逐行解释

上面的代码就是单点版。它可以接受任意提供random()方法的对象,默认用 Python 的random模块,方便在测试时固定种子。

import math import random def concentric_sample_disk(rng=random): u = rng.random() * 2.0 - 1.0 v = rng.random() * 2.0 - 1.0 if u == 0.0 and v == 0.0: return [0.0, 0.0] if abs(u) > abs(v): r = u theta = (math.pi / 4.0) * (v / u) else: r = v theta = (math.pi / 2.0) - (math.pi / 4.0) * (u / v) return [r * math.cos(theta), r * math.sin(theta)]

这个版本的优点是简单、直观,适合学习和小规模测试。缺点也很明显:每次调用都要执行 Python 函数调用、多个数学函数,生成百万级样本时速度不够快。

4.2 numpy批量版:向量化与除零的工程技巧

实际用 Python 做渲染实验或数值验证时,建议直接写向量化版本。核心思路是把u和v变成数组,用np.where做条件分支。

import numpy as np def concentric_sample_disk_np(u, v): # u, v 为 [0,1) 的数组 uu = 2.0 * u - 1.0 vv = 2.0 * v - 1.0 mask = np.abs(uu) > np.abs(vv) # 半径:带符号 r = np.where(mask, uu, vv) # 防止除零:mask 为 True 时 uu 一定非零,mask 为 False 时 vv 一定非零 # 用 1 当占位符不会影响被选中分支的计算 theta = np.where( mask, np.pi / 4.0 * (vv / np.where(mask, uu, 1.0)), np.pi / 2.0 - np.pi / 4.0 * (uu / np.where(mask, 1.0, vv)) ) x = r * np.cos(theta) y = r * np.sin(theta) return x, y

这里的核心技巧在分母处理:np.where(mask, uu, 1.0)表示当 mask 为真时取uu做分母,否则用 1 占位。因为 mask 为真时必然有 (|uu| > |vv|),所以uu不可能为零;同理 mask 为假时vv不可能为零。这样既不产生除零警告,也不影响结果。

用 numpy 生成一百万个样本,几毫秒就能完成,比纯 Python 单点版快两个数量级以上。做环带统计、散点图验证时,这个版本是主力。

4.3 随机源设计:可复现与线程安全

函数签名里的rng=random不是可有可无的。渲染器里随机数来源通常不是 Python 的random,而是自己的生成器:固定种子的伪随机数、Halton/Sobol 低差异序列、蓝噪声序列等。把随机源作为参数传入,测试时方便复现,接入其他序列时也不用改函数内部逻辑。

import numpy as np from scipy.stats import qmc # 生成低差异序列,代替 np.random.rand sampler = qmc.Sobol(d=2, scramble=True) samples = sampler.random(10000) u_arr = samples[:, 0] v_arr = samples[:, 1] x_arr, y_arr = concentric_sample_disk_np(u_arr, v_arr)

这种设计也符合 PBRT 里“采样器(sampler)与映射函数分离”的思路:采样器负责产出不同分布的原始二维坐标,映射函数负责把坐标变换到目标域。

5. 用可视化与统计检验说话:均匀性不是“看起来均匀”

5.1 散点图与环带计数:先直观,再定量

光看散点图容易误判。比如极坐标映射在样本数不够多时,视觉上也会有“貌似均匀”的效果。我的经验是:先做散点图,再做环带计数,用数据说话。

环带计数的办法很简单:把圆盘按面积均分成 (n) 个环带,第 (k) 个环带的外半径是:

[ r_k = \sqrt{\frac{k}{n}} ]

因为圆盘面积占比与半径平方成正比。统计每个环带里的样本数,理论上应该接近 (N / n)。

def ring_counts(x_arr, y_arr, n_rings=10): r2 = x_arr**2 + y_arr**2 # 半径平方 ring_idx = np.clip((r2 * n_rings).astype(int), 0, n_rings - 1) counts = np.bincount(ring_idx, minlength=n_rings) return counts # 生成 200000 个样本做测试 rng = np.random.default_rng(42) u = rng.random(200000) v = rng.random(200000) xc, yc = concentric_sample_disk_np(u, v) # 极坐标映射做同样样本数对比 xr = np.sqrt(u) * np.cos(2 * np.pi * v) yr = np.sqrt(u) * np.sin(2 * np.pi * v) print("同心映射环带计数:", ring_counts(xc, yc)) print("极坐标映射环带计数:", ring_counts(xr, yr))

实测中两种映射的环带计数都接近均匀,差距在统计噪声范围内。这说明极坐标映射从概率密度角度看也是达标的,环带计数不足以区分它们,必须看下一节的网格变形实验。

5.2 网格变形实验:分层采样的底层差异

要区分极坐标映射和同心映射,最直观的方法是取一个规整网格,把网格点当作二维样本送进映射,画出映射后的连线图。

极坐标映射下,正方形左侧边缘整条线被吸到圆心;网格单元在圆心附近被拉成极长的楔形,越靠近边缘又被横向压缩。同心映射则不同,网格轮廓保持成一个个近似圆的正方形同心轮廓,每个局部网格单元虽然也有拉伸,但远没有极坐标映射那么剧烈。

因此,当样本来自分层采样或低差异序列时,同心映射能更好地保留样本之间的“间隔感”,不会出现“同一行样本在圆心附近堆成一团”的问题。这也是 PBRT 优先选择同心映射最实在的理由。

5.3 性能与代码可读性的取舍

纯 Python 单点版适合理解原理,numpy 批量版适合做实验。如果你在写一个真正的渲染器,性能关键路径上其实很少用 Python 按点调用,而是直接写 C++/Rust 和 SIMD 版本,或者把 numpy 批量生成的样本一次性传给光线追踪内核。

我做这套验证时的体感是:算法逻辑本身不到 20 行,但在 Python 里做批量采样时,分支处理才是最容易出 bug 的地方。尤其是np.where里分子分母同时为零的场景,建议写一个小型断言,提前把原点坐标过滤掉或单独处理,避免出现 NaN 悄悄混进后续流程。

6. 从二维圆盘到三维半球:Malley方法与向下扩展

6.1 Malley方法:均匀圆盘天然生成余弦密度半球采样

圆盘采样最有价值的扩展,是把它变成半球上的余弦采样。这在漫反射表面的环境光照积分、AO 采样里非常常见。

思路来自一个投影关系:把圆盘上的均匀采样点 ((x, y))“垂直抬升”到半球面上,取:

[ z = \sqrt{1 - x^2 - y^2} ]

这样就得到一个半球上的点 ((x, y, z))。这个看似简单的提升,得到的样本方向分布恰好是余弦密度:

[ p(\omega) = \frac{\cos\theta}{\pi} ]

也就是越靠近法线方向((z) 轴)的样本越多,符合朗伯散射的特性。PBRT 里的CosineSampleHemisphere就是基于圆盘均匀采样实现的,方法名叫 Malley's method。

def cosine_sample_hemisphere(u, v): x, y = concentric_sample_disk_np(u, v) z = np.sqrt(np.maximum(0.0, 1.0 - x**2 - y**2)) return x, y, z

注意这里对1 - x**2 - y**2做了maximum(0, ...)保护。浮点误差可能让圆盘边缘的点算出一个微小的负数,开根号后变成 NaN。

6.2 均匀半球采样与余弦采样的差异

均匀半球采样是另外一条路:直接令 (z = u),角度 (\phi = 2\pi v),再算 (r = \sqrt{1 - z^2})。这样得到的半球表面单位面积概率相等。但渲染漫反射时一般用余弦采样,因为朗伯模型的反射亮度正比于 (\cos\theta),余弦采样能有效降低方差。

对比代码的话,均匀半球和余弦半球差的只是“先在平面圆盘上取样,再提升”这一步。从圆盘采样出发的 Malley 方法,在工程上还有一个好处:它可以复用圆盘采样的所有改进,包括分层、低差异序列和同心映射。

6.3 景深应用的简化代码示例

把圆盘采样接进景深逻辑也很直接。薄透镜模型里,从胶片点 (p) 出发的射线,先指向焦平面上的点,再在镜头光圈上采样一个点,以这两个点为端点确定光线方向。

def sample_ray_with_dof(camera, u_disk, v_disk): px, py = concentric_sample_disk_np(u_disk, v_disk) lens_point = ( camera.lens_center + px * camera.lens_u + py * camera.lens_v ) # 焦平面焦点:镜头中心沿方向向量走焦距 focus_point = camera.lens_center + camera.focal_distance * camera.forward ray_dir = focus_point - lens_point ray_dir = ray_dir / np.linalg.norm(ray_dir) return lens_point, ray_dir

这里光圈采样点分布越均匀,散景光斑的亮度过渡就越自然。如果你只在圆心附近采样,光斑中心会明显过曝。

7. 落地时的坑与心得:边界、低差异序列和扩展方向

7.1 浮点边界与零坐标处理

圆盘采样代码里最容易翻车的是边界条件。u和v从随机数发生器出来,理论上不会等于 0,但低差异序列(比如 Sobol 序列)的第一个样本经常就是 ([0, 0])。送到映射函数里,半径计算和角度计算的零除问题就会暴露。

处理方式有两种:一种是在映射函数里像 PBRT 一样显式判断原点并返回 ([0, 0]);另一种是在生成样本时把 0 替换成极小正值,比如:

u = np.clip(u, 1e-10, 1.0 - 1e-10) v = np.clip(v, 1e-10, 1.0 - 1e-10)

用裁剪而不是直接过滤,能保持样本数量不变,适合批量生成场景。

7.2 低差异序列替换随机数:采样点会“均匀得更有纪律”

如果只是做后处理模糊,用np.random.rand生成圆盘样本也够用。但在渲染器里,特别是需要低方差积分的时候,低差异序列几乎是必需品。Sobol 序列生成的二维点在单位正方形里分布均匀,经过同心映射到圆盘后,分布依然均匀,且不会出现普通随机数那种“这里一团、那里一片空地”的聚簇现象。

用scipy.stats.qmc.Sobol时要注意:序列第一个点通常是全 0,直接送去计算可能出现上面说的原点问题;另外低差异序列的样本数一般取 2 的幂(如 1024、4096、65536),才能发挥最佳排列效果。

7.3 实际渲染器里的使用方式与进一步扩展

PBRT 里的圆盘采样不是单独存在的,它和整个采样基础设施连在一起:像素网格分层采样、低差异序列、自适应采样计数器,最终才把二维样本送到ConcentricSampleDisk。

如果你后面要做球体采样、圆环采样、圆柱采样,思路也是一样的:先写出“单位正方形到目标域”的映射,算清楚雅可比是否为常数,再写代码。圆盘均匀采样是这套方法论里最简单也最经典的一个例子,吃透它,再看别的采样域会轻松很多。


这段时间在极坐标映射和同心映射之间来回切换,最大的教训是:看分布图不能只看散点密度,还要看样本序列的内部结构。圆盘均匀采样看似只是几十行代码的小函数,但在景深、软阴影、半球余弦采样这些地方,它决定了光斑亮度是否自然、阴影过渡是否干净。如果你只是临时生成一个模糊核,极坐标映射凑合能用;但要做低差异采样、分层采样或者完整的渲染器采样管线,还是直接上同心映射更稳。代码也就多几行,收益却相当实在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询