写下前面的总结
本文主要记录pbrt第七章的采样器Sampler的基本接口,以及分层采样器(StratifiedSampler),书中还介绍了一个重要的采样器Halton Sampler,因时间和篇幅关系暂时没有做记录(主要是我也没有完全明白)。对Halton Sampler感兴趣可以在知乎上看文刀秋二大神的关于Halton Sampler的文章。还有后面7.5节Sequence Sampler,7.6节Maximized Minimal Distance Sampler,7.7节Sobol Sampler等,因时间关系还没仔细研究,等后面有需要再看吧....
当然首先要弄明白采样器是干啥的,用我自己的话说,采样器的任务就是在film平面上按照一定的策略生成采样点的。看下图(图片来自《Real-time Rendering Third Edit》)
如上图,正方形表示一个像素,红点表示采样点,右边的两个三角形表示对应生成的image效果。最简单的是第一行,每个像素只用一个采样点,但可以看出生成的image效果并不好,锯齿很明显。由上往下,可以看到采样点数不断增加后,对应的三角形边缘锯齿逐渐减弱。另外,不止采样点的数量可以变化,采样点的位置也可以根据一定的策略放置。而我们要介绍的采样器,它们的功能就是决定采样点如何在像素中的分布!
7.2 Sampling Interface(采样接口)
所有采样器的实现都继承自一个虚基类Sampler Class。
采样向量
Sampler的任务是生成一系列的采样点,应该注意的是,一个采样点,除了包含代表该采样点在image平面上的位置x和y之外,还包含其他信息,所以采样点也称为n维采样(n-dimensional sample),或采样向量(sample vector)。如下图
图中,对像素(3,8)进行采样,该像素有两个采样点。该采样向量的前面2维表示采样点相对于像素的位置的偏移量(x,y),接下来的3维,代表该采样点对应的Camera ray的time和在lens上的位置。后续的维度用于蒙特卡洛光线传输算法,在14,15,16章介绍。可以看到采样向量的维度n可能会改变,取决于光线传输算法。
因为采样值必须严格小于1,所以pbrt代码中定义了一个小于1的最大的浮点数:
#ifdef PBRT_FLOAT_IS_DOUBLE
static const Float OneMinusEpsilon = 0x1.fffffffffffffp-1; #else
static const Float OneMinusEpsilon = 0x1.fffffep-1;
#endif
最简单的采样器
很显然,最简单的Sampler是,当需要某个采样向量的维度值,就新生成一个范围[0, 1)的随机数。但是,这种随机Sampler相对于其他复杂的Samplers,往往需要更多的采样点(意味着更多的 rays traced和计算耗时)才能达到一样的效果。因为相对于为每个采样点计算radiance这种开销巨大的操作,复杂的Sampler的开销只能算小儿科,因此选择复杂的Sampler是值得的。
采样向量的特点
1.Samplers生成的前5维的采样数据是用于Camera的,也就是x,y,t,u,v(是啥前面已经说过了)。
2.有些采样算法在某些维度会生成更好的采样点(比如说分布更均匀,更合理),我们假设越前面的维度的采样点越好。
7.2.1
为选读内容,先跳过。
7.2.2 Basic Sampler Interface (基本采样器接口)
7.2.3 Sampler Implementation (采样器接口实现)
接口与实现这两节一起记录。
Sampler构造函数
//Constructer:
Sampler::Sampler(int64_t samplesPerPixel) : samplesPerPixel(samplesPerPixel) {}
//public Data:
const int64_t samplesPerPixel;
构造函数需要一个参数,代表每个像素需要生成的采样点的数目。
StartPixel()
在开始为某个像素开始生成采样点前,需要调用StartPixel(),并指定pixel的坐标。某些采样器会根据pixel的坐标信息来优化生成采样点的分布情况,有些采样器则直接忽略这个信息。
void Sampler::StartPixel(const Point2i &p) {
currentPixel = p;
currentPixelSampleIndex = 0;
}
//Protected Data:
Point2i currentPixel;
int64_t currentPixelSampleIndex;
currentPixel成员变量记录当前像素的坐标,currentPixelSampleIndex记录当前是该像素的第几个采样点。
Get1D()和Get2D()
Get1D()返回当前采样向量的下一维的值,Get2D()返回返回当前采样向量的下2维的值.
virtual Float Get1D() = 0;
virtual Point2f Get2D() = 0;
注意书中还举了一个例子来说明调用Get1D()和Get2D() 的注意事项,在此不记录。
Get1DArray()和Get2DArray()
有时候,我们可以向采样器申请一定数量的数组来存储采样值,对应的方法与成员变量如下:
void Sampler::Request1DArray(int n) {
samples1DArraySizes.push_back(n);
sampleArray1D.push_back(std::vector(n * samplesPerPixel));
}
void Sampler::Request2DArray(int n) {
samples2DArraySizes.push_back(n);
sampleArray2D.push_back(std::vector(n * samplesPerPixel));
}
const Float *Sampler::Get1DArray(int n) {
if (array1DOffset == sampleArray1D.size())
return nullptr;
return &sampleArray1D[array1DOffset++][currentPixelSampleIndex * n];
}
const Point2f *Sampler::Get2DArray(int n) {
if (array2DOffset == sampleArray2D.size())
return nullptr;
return &sampleArray2D[array2DOffset++][currentPixelSampleIndex * n];
}
std::vector samples1DArraySizes, samples2DArraySizes;
std::vector> sampleArray1D;
std::vector> sampleArray2D;
size_t array1DOffset, array2DOffset;
画了一个图来表示向量的内存关系:
需要注意的是并不一定会用到这两个数组,取决于你是否调用了Request1DArray()和Request2DArray()函数。
StartNextSample()
当你需要下一个采样点时,需要先调用StartNextSample()方法,告诉采样器准备下一个采样点的生成。(不然的话,你一直调Get1D和Get2D都是返回同一个采样点的不断递增的维数的值)。StartNextSample()代码如下:
bool Sampler::StartNextSample() {
// Reset array offsets for next pixel sample
array1DOffset = array2DOffset = 0;
return ++currentPixelSampleIndex < samplesPerPixel;
}
可见StartNextSample()只是复位了数组偏移值,以及递增currentPixelSampleIndex。
SetSampleNumber()
如果你想要指定生成某个采样点,可以调用SetSampleNumber()来指定采样点索引。
bool Sampler::SetSampleNumber(int64_t sampleNum) {
// Reset array offsets for next pixel sample
array1DOffset = array2DOffset = 0;
currentPixelSampleIndex = sampleNum;
return currentPixelSampleIndex < samplesPerPixel;
}
需要注意的是StartPixel(),StartNextSample()和SetSampleNumber()都是虚函数,可能会被子类覆盖。
7.2.4 Pixel Sampler
某些采样算法是一个一个地生成采样向量的,但更一般的算法是,对一个像素一次性生成所有采样向量的所有维度。PixelSampler就是这样子滴。
PixelSampler构造函数
PixelSampler::PixelSampler(int64_t samplesPerPixel, int nSampledDimensions)
: Sampler(samplesPerPixel) {
for (int i = 0; i < nSampledDimensions; ++i) {
samples1D.push_back(std::vector(samplesPerPixel));
samples2D.push_back(std::vector(samplesPerPixel));
}
}
//Protected Data
std::vector> samples1D;
std::vector> samples2D;
int current1DDimension = 0, current2DDimension = 0;
PixelSampler构造函数,除了需要知道每个像素多少个采样点(samplesPerPixel)外,还需要提供采样向量的最大维数nSampledDimensions。因为要一次性生成所有该像素的所有采样向量的所有维数,当然要有个地方存放它们啦。那就是samples1D和samples2D,其实它们和上面说的sampleArray1D和sampleArray2D的内存结构一模一样。如果要访问第pixelSample个采样点的第dim维,就调用sample1D[dim][pixelSample]。
PixelSampler的其他接口
其他接口的实现会覆盖Sampler接口,代码如下,不再详述:
bool PixelSampler::StartNextSample() {
current1DDimension = current2DDimension = 0;
return Sampler::StartNextSample();
}
bool PixelSampler::SetSampleNumber(int64_t sampleNum) {
current1DDimension = current2DDimension = 0;
return Sampler::SetSampleNumber(sampleNum);
}
Float PixelSampler::Get1D() {
if (current1DDimension < samples1D.size())
return samples1D[current1DDimension++][currentPixelSampleIndex];
else
return rng.UniformFloat();
}
还有一个随机数生成器:
//protected Data
RNG rng;
7.2.5 Global Sampler(全局采样器)
另外一些采样算法不是基于像素的,而是在整个image生成连续的且位于完全不同像素的采样点。这种采样算法和我们之前的Sampler的接口有点冲突,如下面的7.2表是一个HaltonSampler为一个2x3 image生成的采样向量的前2维(x,y)。
我们可以看到,生成的采样值的x,y坐标,是基于整个image的(0~1表示整个image的宽高),而我们之前的PixelSampler生成的采样值x,y坐标则是表示它相对于当前pixel的偏移值。如下图:
从表中还可以看出,每隔6个samples的sample属于同一个像素,比如像素(0,0)的sample的索引值为0,6,12。
像上面这种生成样本的算法,用之前的Sampler接口貌似不太合适,但是Sampler这个接口还是有很多好处滴(书中巴拉巴拉说了一堆这里不提了)。于是就有了GlobalSampler。
GlobalSampler用来桥接Sampler接口和上面全局采样算法(说白了就是一个适配层吧)。它也继承自Sampler。构造函数如下:
GlobalSampler(int64_t samplesPerPixel) : Sampler(samplesPerPixel) {}
有两个方法必须实现:
GetIndexForSample()
virtual int64_t GetIndexForSample(int64_t sampleNum) const = 0;
GetIndexForSample()根据当前所处的pixel(这是已知的,currentPixel变量记录),以及输入参数sampleNum(表示当前像素的第几个采样点),返回全局index(也就是表7.2中的第一列)。例如,在表7.2中,如果currentPixel是(0, 2),调用GetIndexForSample(0)将返回2。因为,我们可以看第三列(像素坐标),第一个在像素(0, 2)区域内的采样点的像素坐标是(0.500000, 2.000000),对应的第一列的2。
SampleDimension()
virtual Float SampleDimension(int64_t index, int dimension) const = 0;
SampleDimension()根据输入参数index(全局索引,表7.2的第一列)以及采样向量的维数dimension,返回对应的采样值。要注意的是,因为前2维的采样值表示的是相对于当前pixel的偏移值(正如前面说的PixelSampler),因此要对表7.2第3列的返回值做调整。比如,SampleDimension(4,1)返回的是0.333333,因为index 4对应的第3列的像素坐标值是(0.250000, 1.333333),它处在pixel(0,1)内,因此第2维的1.33333要减去1。
GlobalSampler的其他接口
void GlobalSampler::StartPixel(const Point2i &p) {
ProfilePhase _(Prof::StartPixel);
Sampler::StartPixel(p);
dimension = 0;
intervalSampleIndex = GetIndexForSample(0);
//Compute arrayEndDim for dimensions used for array samples
//Compute 1D array samples for GlobalSampler
//Compute 2D array samples for GlobalSampler
}
//private Data
int dimension;
int64_t intervalSampleIndex;
dimension记录下一个要采样的维数,intervalSampleIndex记录的是当前像素的当前sample对应的全局index(也就是表7.2的第一列)。StartPixel()方法会对这些变量进行复位。
如果采样器中有array samples(前面提到的sampleArray1D和sampleArray2D),我们还要决定把哪些采样值放在这些array中。我们有一个假设,就是维数越小的信息越重要,对最终的image质量影响越大。因此采样器里用了arrayStartDim和arrayEndDim,把arrayStartDim维数后的信息放到数组中(其实我不明白为啥重要的就不能放到数组中吗)。arrayStartDim初始化为5,也就是前面5维(x,y,t,u,v)都不放到array中。相关代码如下,不再详述:
//private Data
static const int arrayStartDim = 5;
int arrayEndDim;
void GlobalSampler::StartPixel(const Point2i &p) {
ProfilePhase _(Prof::StartPixel);
Sampler::StartPixel(p);
dimension = 0;
intervalSampleIndex = GetIndexForSample(0);
// Compute _arrayEndDim_ for dimensions used for array samples
arrayEndDim =
arrayStartDim + sampleArray1D.size() + 2 * sampleArray2D.size();
// Compute 1D array samples for _GlobalSampler_
for (size_t i = 0; i < samples1DArraySizes.size(); ++i) {
int nSamples = samples1DArraySizes[i] * samplesPerPixel;
for (int j = 0; j < nSamples; ++j) {
int64_t index = GetIndexForSample(j);
sampleArray1D[i][j] = SampleDimension(index, arrayStartDim + i);
}
}
// Compute 2D array samples for _GlobalSampler_
int dim = arrayStartDim + samples1DArraySizes.size();
for (size_t i = 0; i < samples2DArraySizes.size(); ++i) {
int nSamples = samples2DArraySizes[i] * samplesPerPixel;
for (int j = 0; j < nSamples; ++j) {
int64_t idx = GetIndexForSample(j);
sampleArray2D[i][j].x = SampleDimension(idx, dim);
sampleArray2D[i][j].y = SampleDimension(idx, dim + 1);
}
dim += 2;
}
CHECK_EQ(arrayEndDim, dim);
}
bool GlobalSampler::StartNextSample() {
dimension = 0;
intervalSampleIndex = GetIndexForSample(currentPixelSampleIndex + 1);
return Sampler::StartNextSample();
}
bool GlobalSampler::SetSampleNumber(int64_t sampleNum) {
dimension = 0;
intervalSampleIndex = GetIndexForSample(sampleNum);
return Sampler::SetSampleNumber(sampleNum);
}
Float GlobalSampler::Get1D() {
if (dimension >= arrayStartDim && dimension < arrayEndDim)
dimension = arrayEndDim;
return SampleDimension(intervalSampleIndex, dimension++);
}
Point2f GlobalSampler::Get2D() {
if (dimension + 1 >= arrayStartDim && dimension < arrayEndDim)
dimension = arrayEndDim;
Point2f p(SampleDimension(intervalSampleIndex, dimension),
SampleDimension(intervalSampleIndex, dimension + 1));
dimension += 2;
return p; }
7.3 Stratified Sampling(分层采样)
首先要注意的是,StratifiedSampler它是一个PixelSampler。
分层采样的策略是,把pixel分割成互不重叠的矩形区域(称为strata,层),然后在每个区域中生成一个随机sample。这样的好处是,可以保证多个采样点不会挤在image某些区域,而某些区域的采样点却过少。因为如果在某点附近有多个采样点,这些采样点并不会提供更多有用的信息。可以提前看一下书中的图7.18做对比,图a是完全随机采样,图c是采用了分层采样。
分层采样器会对在每个区域(stratum)中的采样点的位置进行jittering(扰动),也就是对中心点上加一个范围为stratum的宽高的一半的随机偏移量。这种非均匀性相当于把aliasing转换为noise(7.1节说过)。
还有一个问题是随着维数增高,采样点会急剧增加。比如对一个5D image进行分层,每一个维都分4层,那么总的采样数量是45=1024个。于是我们调整一下策略,如图7.16,
我们单独产生4个2D分层的image samples,4个1D分层的time samples,和4个2D分层的lens samples。然后将它们随机的把每个image samples和一个time sample和一个lens sample关联。图7.17展示了使用分层采样的效果提升。
图7.18对比了这些采样模式。第一个是完全随机模式:完全不用分层。结果很糟糕,一些区域的采样点很少,而一些区域的许多采样点挤在一起。第二个是使用一个完全均匀的分层模式。第三个是对均匀模式加上一个扰动,对每个sample的位置加了一个随机偏移,但保持在它的单元内。图7.19展示了使用StratifiedSampler渲染的图片,以及它如何把aliasing artifacts转变为没那么讨厌的nosie。
图a是一个参考image,每个pixel用了256个samples,用于表示接近一个理想image。图b是每个pixel一个sample。图c也是每个pixel一个sample,只不过加上了扰动。图d是每个pixel 4个sample,并加上扰动。
StratifiedSampler构造函数
StratifiedSampler(int xPixelSamples, int yPixelSamples,
bool jitterSamples, int nSampledDimensions)
: PixelSampler(xPixelSamples * yPixelSamples, nSampledDimensions),
xPixelSamples(xPixelSamples), yPixelSamples(yPixelSamples),
jitterSamples(jitterSamples) { }
//private Data
const int xPixelSamples, yPixelSamples;
const bool jitterSamples;
StratifiedSampler构造函数的参数,xPixelSamples表示每个像素在x方向上的采样点个数,yPixelSamples表示每个像素在y方向上的采样点个数,jitterSamples表示是否要扰动(当然要啊,设这个主要用于测试对比),nSampledDimensions表示维数。
StartPixel()
void StratifiedSampler::StartPixel(const Point2i &p) {
ProfilePhase _(Prof::StartPixel);
// Generate single stratified samples for the pixel
for (size_t i = 0; i < samples1D.size(); ++i) {
StratifiedSample1D(&samples1D[i][0], xPixelSamples * yPixelSamples, rng,
jitterSamples);
Shuffle(&samples1D[i][0], xPixelSamples * yPixelSamples, 1, rng);
}
for (size_t i = 0; i < samples2D.size(); ++i) {
StratifiedSample2D(&samples2D[i][0], xPixelSamples, yPixelSamples, rng,
jitterSamples);
Shuffle(&samples2D[i][0], xPixelSamples * yPixelSamples, 1, rng);
}
// Generate arrays of stratified samples for the pixel
//省略...
}
StratifiedSampler的StartPixel()中会生成采样值并放在samples1D和samples2D中。以生成1D sample为例。for循环的每一次循环,StratifiedSample1D()产生该pixel的同一个维度的所有采样值,然后用Shuffle()按图7.16中方式打乱它们的关联。
StratifiedSample1D()为一个pixel的每一维产生xPixelSamples * yPixelSamples个扰动过的样本。如下:
void StratifiedSample1D(Float *samp, int nSamples, RNG &rng,
bool jitter) {
Float invNSamples = (Float)1 / nSamples;
for (int i = 0; i < nSamples; ++i) {
Float delta = jitter ? rng.UniformFloat() : 0.5f;
samp[i] = std::min((i + delta) * invNSamples, OneMinusEpsilon);
}
}
Shuffle函数如下:
void Shuffle(T *samp, int count, int nDimensions, RNG &rng) {
for (int i = 0; i < count; ++i) {
int other = i + rng.UniformUInt32(count - i);
for (int j = 0; j < nDimensions; ++j)
std::swap(samp[nDimensions * i + j],
samp[nDimensions * other + j]);
} }
当然,分层采样还有一些问题,比如,它无法满足两个像素之间采样点的分布尽量不相似的需求,以及不能很好的处理当采样个数为奇数的情况。书中后面还介绍了Latin hypercube sampling(LHS拉丁超立方抽样),可以解决这个问题,在此就不再记录。
后面的7.4节Halton Sampler,7.5节Sequence Sampler,7.6节Maximized Minimal Distance Sampler,7.7节Sobol Sampler等,因时间关系还没仔细研究,等后面有需要再看吧....
7.8 Image Reconstruction (图像重建)
有了采样点后,我们需要把这些采样点和对应的辐射率值转换为用于显示或存储的像素值。根据信号处理理论,我们需要做三件事,来得到最终的输出image的每个像素:
- 从这些image samples中重建一个连续的image函数~L.
- 对~L进行滤波,以除去高频分量,(我个人的理解,因为从~L到最终的image的像素值,相当于一次采样过程,而采样的频率可以看做是image中像素的数量,因此需要过滤掉高于奈比斯特频率的分量)。
- 对~L在每个pixel位置进行采样,得到最终的像素值。
因为我们会对~L在pixel的位置进行重采样,因此我们不需要显式的构建~L了,而是用一个滤波函数来代替前面两步。
前面说过,如果用一个高于Nyquist频率的信号进行均匀采样,且用sinc filter进行重建的话,那么第一步的重建信号和原始信号完全一样。但因为image function几乎总是有高于采样频率的成分(因为image中有边缘等),因此我们选择非均匀采样,在取舍中选择了noise而不是aliasing。
书中这里貌似写了关于重建理论的一些最新方向,没啥用略过...
重建公式
计算最终的pixel值I(x, y),公式如下:
L(xi, yi)是位置为(xi, yi)的第i个采样的radiance值;
w(xi , yi )是Camera返回的该sample贡献的权重值。
f是滤波函数。
如图7.38展示了在位置(x, y)的一个pixel,它的pixel filter的区域在x方向扩展至radius.x,在y方向扩展至radius.y。所有在该filter的box区域的samples对pixel的值都有贡献,贡献的多少取决于filter函数的值f (x − xi , y − yi )。
sinc filter不是一个合适的选择
因为除了Gibbs phenomenon,更重要的是 sinc filter在空间域中是无限延伸的
,如果用它做滤波器,那么每一个输出的pixel都需要用所有的samples计算。
7.8.1 Filter Functions (滤波函数)
Filter接口
所有的滤波器都继承自Filter接口,如下
class Filter {
public:
// Filter Interface
virtual ~Filter();
Filter(const Vector2f &radius)
: radius(radius), invRadius(Vector2f(1 / radius.x, 1 / radius.y)) {}
virtual Float Evaluate(const Point2f &p) const = 0;
// Filter Public Data
const Vector2f radius, invRadius;
};
从代码可以看出Filter接口还是很简单的,构造函数只需要滤波半径radius,子类需要实现Evaluate()方法来计算滤波后的值。
后面书中会逐一介绍Box Filter,Triangle Filter,Gaussian Filter,Mitchell Filter,Windowed Sinc Filter,因书中写的很清楚,在此就不记录了。
7.9 Film and the Imaging Pipeline
有关film的一些概述
一个相机的film(胶片)或sensor(传感器)的作用是把入射光转换为一个图片的颜色值。在pbrt中,Film class就相当于虚拟相机的传感设备。在得到所有camera ray的radiance后,Film就决定每个sample对像素的贡献。当渲染循环结束后,Film负责把最终的image写入file中。
对于真实的camera模型,6.4.7节引入了一个公式,用来描述一个传感器如何衡量在一段时间内到达某个传感器区域的能量(很复杂还没看)。对于简单一点的模型,我们可以认为传感器接收的是一段时间在某个区域的radiance的平均值。这个贡献值其实就是由Camera::GenerateRayDifferential()返回的值(第六章的笔记中可以看到,在投影相机模型中,它只是简单的返回1)。因此Film的实现可以不需要考虑这些因素。
这里书中还提到,film中主要用float型来存储image的值,以及最终要转换为RGB格式来显示。在此不详细记录。
7.9.1 The Film Class
Film构造函数
Film::Film(const Point2i &resolution, const Bounds2f &cropWindow,
std::unique_ptr filt, Float diagonal,
const std::string &filename, Float scale, Float maxSampleLuminance)
: fullResolution(resolution),
diagonal(diagonal * .001),
filter(std::move(filt)),
filename(filename),
scale(scale),
maxSampleLuminance(maxSampleLuminance) {
// Compute film image bounds
croppedPixelBounds =
Bounds2i(Point2i(std::ceil(fullResolution.x * cropWindow.pMin.x),
std::ceil(fullResolution.y * cropWindow.pMin.y)),
Point2i(std::ceil(fullResolution.x * cropWindow.pMax.x),
std::ceil(fullResolution.y * cropWindow.pMax.y)));
// Allocate film image storage
pixels = std::unique_ptr(new Pixel[croppedPixelBounds.Area()]);
filmPixelMemory += croppedPixelBounds.Area() * sizeof(Pixel);
// Precompute filter weight table
int offset = 0;
for (int y = 0; y < filterTableWidth; ++y) {
for (int x = 0; x < filterTableWidth; ++x, ++offset) {
Point2f p;
p.x = (x + 0.5f) * filter->radius.x / filterTableWidth;
p.y = (y + 0.5f) * filter->radius.y / filterTableWidth;
filterTable[offset] = filter->Evaluate(p);
}
}
}
//public Data:
Bounds2i croppedPixelBounds;
参数有resolution:image的分辨率;
cropWindow:裁剪窗口大小,注意它的范围是0到1;
filt:滤波器指针;
diagonal:film的物理对角线尺寸,只在真实相机模型中才用到;
filename:输出image的名字;
scale:缩放参数。
构造函数总的来说比较简单,不再多记录。
Pixel结构体
Film中存储的像素用Pixel结构体表示,如下:
//Film Private Data
struct Pixel {
Float xyz[3] = { 0, 0, 0 };
Float filterWeightSum = 0;
AtomicFloat splatXYZ[3];
Float pad;
};
std::unique_ptr pixels;
//Allocate film image storage
pixels = std::unique_ptr(new Pixel[croppedPixelBounds.Area()]);
选择用XYZ格式而不用Spectrum或RGB的原因是:Spectrum占用内存太多;而RGB的值会随着显示设备的不同而不同,而XYZ是独立于设备的。
filterTable
Film会提前计算滤波器的权重值,并把它放在filterTable[]数组中。因为Filter::Evaluate()方法还是有些耗时的,提前计算并重复利用可以优化性能。
大部分的filter都有f (x, y) = f (|x|, |y|)的特性,因此table只需要存储filter中正的偏移值即可。代码如下:
//Precompute filter weight table
int offset = 0;
for (int y = 0; y < filterTableWidth; ++y) {
for (int x = 0; x < filterTableWidth; ++x, ++offset) {
Point2f p;
p.x = (x + 0.5f) * filter->radius.x / filterTableWidth;
p.y = (y + 0.5f) * filter->radius.y / filterTableWidth;
filterTable[offset] = filter->Evaluate(p);
} }
//Film Private Data
static constexpr int filterTableWidth = 16;
Float filterTable[filterTableWidth * filterTableWidth];
因为一般的滤波半径都是大于或等于16,因此filterTable选择xy方向都是16的大小。
GetSampleBounds()接口
Film还有责任告诉Sampler需要产生samples的区域范围。这个采样区域由GetSampleBounds()方法返回。因为重建滤波器的会覆盖一定的区域,因此Sampler产生samples的区域必须大于实际输出image的区域。除此之外还要考虑从离散到连续pixel坐标的半个像素的偏移值。
Bounds2i Film::GetSampleBounds() const {
Bounds2f floatBounds(
Floor(Point2f(croppedPixelBounds.pMin) + Vector2f(0.5f, 0.5f) -
filter->radius),
Ceil( Point2f(croppedPixelBounds.pMax) - Vector2f(0.5f, 0.5f) +
filter->radius));
return (Bounds2i)floatBounds;
}
后面的GetPhysicalExtent()接口因为和RealisticCamera相关,还没研究,先略过...
7.9.2 Supplying Pixel values to the Film
通常为了用多线程并行执行渲染任务以提高速度,可以把Film平面分成多个tiles(我就翻译成拼图吧),这样就可以同时对多个tiles并行执行。
GetFilmTile()接口
给定采样的区域范围,GetFilmTile()将返回一个FilmTile对象,它内部存有对应区域的pixels的贡献值。每个FilmTile对象及它内部的数据对于用户来说都是独一无二的,因此不需要担心它会被其它线程访问。当结束对tile的工作时,工作线程会把它放回到Film中,可以安全的合并到最终的image。GetFilmTile()代码如下:
std::unique_ptr Film::GetFilmTile(const Bounds2i &sampleBounds) {
// Bound image pixels that samples in _sampleBounds_ contribute to
Vector2f halfPixel = Vector2f(0.5f, 0.5f);
Bounds2f floatBounds = (Bounds2f)sampleBounds;
Point2i p0 = (Point2i)Ceil(floatBounds.pMin - halfPixel - filter->radius);
Point2i p1 = (Point2i)Floor(floatBounds.pMax - halfPixel + filter->radius) +
Point2i(1, 1);
Bounds2i tilePixelBounds = Intersect(Bounds2i(p0, p1), croppedPixelBounds);
return std::unique_ptr(new FilmTile(
tilePixelBounds, filter->radius, filterTable, filterTableWidth,
maxSampleLuminance));
}
给定了pixel区域的包围盒sampleBounds,主要还要考虑从离散到连续像素的坐标转换(半个像素)以及filter的半径,还有就是croppedPixelBounds的限制。代码中比较清楚,不再赘述。
FilmTile构造函数
代码如下:
FilmTile(const Bounds2i &pixelBounds, const Vector2f &filterRadius,
const Float *filterTable, int filterTableSize,
Float maxSampleLuminance)
: pixelBounds(pixelBounds),
filterRadius(filterRadius),
invFilterRadius(1 / filterRadius.x, 1 / filterRadius.y),
filterTable(filterTable),
filterTableSize(filterTableSize),
maxSampleLuminance(maxSampleLuminance) {
pixels = std::vector(std::max(0, pixelBounds.Area()));
}
//FilmTile Private Data
const Bounds2i pixelBounds;
const Vector2f filterRadius, invFilterRadius;
const Float *filterTable;
const int filterTableSize;
std::vector pixels;
可以看到,FilmTile构造函数需要一个2D包围盒pixelBounds,滤波器半径filterRadius,滤波器预计算的权重值filterTable,以及filterTableSize。
对每个pixel,需要存储采样值对这个pixel的贡献权重的总和,以及滤波权重的总和。如下:
struct FilmTilePixel {
Spectrum contribSum = 0.f;
Float filterWeightSum = 0.f;
Bounds2i tilePixelBounds =
};
AddSample()接口
一旦一个采样点对应的radiance被计算出来,Integrator就会调用FilmTile::AddSample()。(AddSample是干嘛的呢,我感觉无法“简而言之”,但一步一步看代码分析就明白了)。
AddSample()的参数为:采样点的位置pFilm,采样点对应的radiance值,以及由Camera::GenerateRay Differential()返回的权重。接口如下:
void AddSample(const Point2f &pFilm, const Spectrum &L,
Float sampleWeight = 1.) {
//Compute sample’s raster bounds
//Loop over filter support and add sample to pixel arrays
}
为了理解FilmTile::AddSample(),首先回忆像素滤波公式:
从公式可以看出,为了计算每个pixel的值I(x, y),需要使用滤波函数f和Camera返回的w。因为pbrt中所有的Filters的半径都是有限大小,AddSample()先计算哪些像素会被这个sample影响。然后,根据这个像素滤波公式,为每个受影响的pixel(x, y)计算两个sum。一个sum是pixel filtering公式的分子的累加,另一个是分母的累加。在渲染最后,最终的pixel值由他们的除法得到。
好了,现在开始,首先计算一个sample会影响到哪些pixel,代码如下:
//Compute sample’s raster bounds
Point2f pFilmDiscrete = pFilm - Vector2f(0.5f, 0.5f);
Point2i p0 = (Point2i)Ceil(pFilmDiscrete - filterRadius);
Point2i p1 = (Point2i)Floor(pFilmDiscrete + filterRadius) + Point2i(1, 1); p0 = Max(p0, pixelBounds.pMin);
p1 = Min(p1, pixelBounds.pMax);
代码先把连续坐标转换为离散坐标(通过减0.5),然后根据滤波半径,以及pixelBounds范围,最终得到表示范围的两个点p0和p1。如图7.48
后面的计算,长话短说吧,最终的目的就是求出pixel filtering公式中分母的累加以及分子的累加,计算过程有点意思,是先计算了被影响到的每个像素在滤波表的对应的偏移值,然后再利用偏移值查表得到权重,再根据滤波公式得到pixel.contribSum和pixel.filterWeightSum。完整代码如下:
void AddSample(const Point2f &pFilm, Spectrum L,
Float sampleWeight = 1.) {
if (L.y() > maxSampleLuminance)
L *= maxSampleLuminance / L.y();
// Compute sample's raster bounds
//省略...
// Loop over filter support and add sample to pixel arrays
// Precompute $x$ and $y$ filter table offsets
int *ifx = ALLOCA(int, p1.x - p0.x);
for (int x = p0.x; x < p1.x; ++x) {
Float fx = std::abs((x - pFilmDiscrete.x) * invFilterRadius.x *
filterTableSize);
ifx[x - p0.x] = std::min((int)std::floor(fx), filterTableSize - 1);
}
int *ify = ALLOCA(int, p1.y - p0.y);
for (int y = p0.y; y < p1.y; ++y) {
Float fy = std::abs((y - pFilmDiscrete.y) * invFilterRadius.y *
filterTableSize);
ify[y - p0.y] = std::min((int)std::floor(fy), filterTableSize - 1);
}
for (int y = p0.y; y < p1.y; ++y) {
for (int x = p0.x; x < p1.x; ++x) {
// Evaluate filter value at $(x,y)$ pixel
int offset = ify[y - p0.y] * filterTableSize + ifx[x - p0.x];
Float filterWeight = filterTable[offset];
// Update pixel values with filtered sample contribution
FilmTilePixel &pixel = GetPixel(Point2i(x, y));
pixel.contribSum += L * sampleWeight * filterWeight;
pixel.filterWeightSum += filterWeight;
}
}
}
MergeFilmTile()
Film的接口MergeFilmTile(),主要是用来将FilmTiles合并到最终的Film上,因篇幅关系就不再分析了,代码也不难。
7.9.3 Image Output
也不难,略过...