本文着重探讨栅格化最重要的栅格化部分。这部分涉及到了栅格化的核心代码,此核心代码是比较抽象的,因此我们重点探讨其中的物理意义,理解了每个步骤的物理意义,理解起栅格化就会容易很多。
栅格化的定义:将三维场景中的物体进行着色投影到成像平面,形成二维图形的过程。其详细定义和流程可以参考其他文章,本文暂不重复描述。
基于代码选取,栅格化渲染的代码较多,例如基于openGL的渲染管线,本文选取的是neural_renderer,此代码主要的创新点是如何在渲染中回传梯度,但是其栅格化部分的代码写的具有不错的可读性。我们暂不讨论代码中关于梯度的问题,只是详细解析一下neural_renderer是如何进行栅格化渲染的流程的。
其他部分可详细参考另外两篇文章。
栅格化渲染源码解析-neural_renderer源码(一)概览
栅格化渲染源码解析-neural_renderer源码(二)光照部分
目录
一、栅格化整体流程
1.1 遍历步骤
代码的栅格化部分分了三个步骤
1.遍历所有面片for each faces,算出face_inv,face_inv此变量可以看作面片face的线性代数逆,算出此变量的目的在于快速的运算成像点到该面片三个顶点的距离
2.遍历所有for each pixel,这个pixel是栅格化成像后所有像素点的pixel,在每个pixel上遍历所有面片,如果该面片落在这个pixel上,则运算该面片上对应的点的depth,更新z-buffer,找到每个pixel对应的面片索引。
3.采样成像,针对每个像素点的面片索引,找到对应的texture上的点,然后成像,形成图像。
1.2 基于chainer.cuda的遍历方法
查阅chainer文档的话,发现此写法是cupy的复用。
chainer官方文档
Define your own function — Chainer 7.8.1 documentation
例如需要遍历不同的faces
# for each face
faces_inv = self.xp.zeros_like(self.faces)
loop = self.xp.arange(self.batch_size * self.num_faces).astype('int32')
loop即是需要遍历的数量。
chainer.cuda.elementwise(
'int32 _, raw float32 faces, raw float32 faces_inv',
'',
string.Template('''
/* face[v012][RGB] */
const int is = ${image_size};
const float* face = (float*)&faces[i * 9];
float* face_inv_g = (float*)&faces_inv[i * 9];
/* return if backside */
if ((face[7] - face[1]) * (face[3] - face[0]) < (face[4] - face[1]) * (face[6] - face[0]))
continue;
# .....
''').substitute(
image_size=self.image_size,
),
'function',
)(loop, self.faces, faces_inv)
具体语法可参阅相应的文档。
在string之中的即是cuda每个element之中的代码,这种代码可以自动实现cuda的并行,并且可读性和理解性要好很多。
二、计算面片的inv
2.1 变量及作用
计算面片inv只有两个变量,一个是面片,另一个是面片的inv。
面片inv的作用,类似于面片的逆,可以利用此矩阵快速的计算出一个点到该面片顶点的距离。
具体的计算方式如下,如果该点落在面片内,则与x,y,z与face_inv相乘可以得出该店到这个面片三个顶点的相对距离。具体计算方法如下所示,这种计算方法在下文中会出现,这里提前放出来,方便理解face_inv的作用:
w[0] = face_inv[3 * 0 + 0] * xi + face_inv[3 * 0 + 1] * yi + face_inv[3 * 0 + 2];
w[1] = face_inv[3 * 1 + 0] * xi + face_inv[3 * 1 + 1] * yi + face_inv[3 * 1 + 2];
w[2] = face_inv[3 * 2 + 0] * xi + face_inv[3 * 2 + 1] * yi + face_inv[3 * 2 + 2];
xi和yi就是该像素点的坐标,w0表示像素点到面片其中一个顶点的相对距离。
2.2 反向直接跳过
面片分为正反,如果是反向的面片,则不需要计算该面片的inv。对应贴图在对面片生成贴图的时候,反面不需要进行计算和贴图。
/ * return if backside * /
if ((face[7] - face[1]) * (face[3] - face[0]) < (face[4] - face[1]) * (face[6] - face[0]))
continue;
2.3 将面片归一化到图像尺寸上
面片需要被归一化到图像尺寸之上。
/* p[num][xy]: x, y is normalized from [-1, 1] to [0, is - 1]. */
float p[3][2];
for (int num = 0; num < 3; num++) for (int dim = 0; dim < 2; dim++)
p[num][dim] = 0.5 * (face[3 * num + dim] * is + is - 1);
之前的面片顶点,涉及到投影,是[-1, 1]的区间之上,现在我们需要将其进行归一化,归一化到图像image_size之上,相当于投影的坐标转换为相机坐标系,方便计算。代码中is表示image_size
2.4 运算与存储
然后就是运算与存储,具体的运算公式可以参考几何代数,博主前面已经把该face_inv的物理意义说明白了,就是快速运算像素点到该面片三个顶点的方法,这里就不重新把公式推一遍了,知道其物理意义即可。
/* compute face_inv */
float face_inv[9] = {
p[1][1] - p[2][1], p[2][0] - p[1][0], p[1][0] * p[2][1] - p[2][0] * p[1][1],
p[2][1] - p[0][1], p[0][0] - p[2][0], p[2][0] * p[0][1] - p[0][0] * p[2][1],
p[0][1] - p[1][1], p[1][0] - p[0][0], p[0][0] * p[1][1] - p[1][0] * p[0][1]};
float face_inv_denominator = (
p[2][0] * (p[0][1] - p[1][1]) +
p[0][0] * (p[1][1] - p[2][1]) +
p[1][0] * (p[2][1] - p[0][1]));
for (int k = 0; k < 9; k++) face_inv[k] /= face_inv_denominator;
/* set to global memory */
for (int k = 0; k < 9; k++) face_inv_g[k] = face_inv[k];
在计算了每个面片的逆之后,将该面片的逆存储全局变量之中。
三、遍历像素运算depth和到面片三个点的相对距离
3.1 遍历像素及运算
因为是基于cuda的并行运算,因此需要确定该并行单元执行的是哪个像素的计算。因此需要把当前像素点x和y坐标计算出。
const int is = ${image_size};
const int nf = ${num_faces};
const int bn = i / (is * is);
const int pn = i % (is * is);
const int yi = pn / is;
const int xi = pn % is;
i就是loop传入的变量,表示并行单元的index,每个并行单元可以根据自己得到的i从而获取自己所在的位置xi和y1
3.2 确认该像素落入面片之中
栅格化是面片向着成像平面投影,但是对应成像平面上的点来说,该点是向着物体的面片去投影,这也是我们之前计算face_inv的意义。
但是在此之前,我们需要确定,哪些面片投影之后,会与该像素点有重叠,哪些面片没有重叠。这就是下面计算的意义:
/* return if backside */
if ((face[7] - face[1]) * (face[3] - face[0]) < (face[4] - face[1]) * (face[6] - face[0]))
continue;
/* check [py, px] is inside the face */
if (((yp - face[1]) * (face[3] - face[0]) < (xp - face[0]) * (face[4] - face[1])) ||
((yp - face[4]) * (face[6] - face[3]) < (xp - face[3]) * (face[7] - face[4])) ||
((yp - face[7]) * (face[0] - face[6]) < (xp - face[6]) * (face[1] - face[7]))) continue;
反面不渲染,这个我们刚刚已经在计算face_inv的时候跳过了,这里也需要跳过。下面部分,即判断该像素点能否落入面片内的代码,如果落到面片之外,就直接跳过即可。具体公式推导,依然可以参考几何代数,这里我们也不详细推导了。物理意义就是,像素点向着面片投影,没在面片内,那该面片就跳过。
注意这里涉及到一个坐标系的转换,就是yp和xp,因为face变量是投影到变量,它的空间是[-1, 1],而不是[0, img_size-1],因此像素点需要进行一下坐标转换,转到[-1, 1]的范围之上。
- 即计算face_inv和像素投影的时候,坐标系是[0, img_size-1]
- 计算像素点落入面片内时,坐标系[-1, 1]
3.3 计算该像素点到面片的相对距离
此时就用到了我们之前说的面片的逆,即像素点直接乘以面片的逆,得到该点投影到面片时到面片三个顶点的相对距离。
/* compute w = face_inv * p */
float w[3];
for (int k = 0; k < 3; k++)
w[0] = face_inv[3 * 0 + 0] * xi + face_inv[3 * 0 + 1] * yi + face_inv[3 * 0 + 2];
w[1] = face_inv[3 * 1 + 0] * xi + face_inv[3 * 1 + 1] * yi + face_inv[3 * 1 + 2];
w[2] = face_inv[3 * 2 + 0] * xi + face_inv[3 * 2 + 1] * yi + face_inv[3 * 2 + 2];
此相对距离需要进行归一化,从而算出到三个顶点距离的权重
/* sum(w) -> 1, 0 < w < 1 */
float w_sum = 0;
for (int k = 0; k < 3; k++) {
w[k] = min(max(w[k], 0.), 1.);
w_sum += w[k];
}
for (int k = 0; k < 3; k++) w[k] /= w_sum;
通过此归一化操作,即算出了到三个面片的距离的等权重。
例如图中,三角形中间的点就是该三角形投影后,在成像平面上的像素点,w1,w2, w3是该点到三角面片的三个顶点的权重。此步骤就是为了计算出此权重。
3.4 深度计算和更新z-buffer
按照前文所述,每个面片的顶点,已经投影到相机坐标系,即z坐标即是该面片的深度。因此该像素点在面片上的投影就是到三个面片顶点的加权平均,很容易就算出其深度zp的值
/* compute 1 / zp = sum(w / z) */
const float zp = 1. / (w[0] / face[2] + w[1] / face[5] + w[2] / face[8]);
if (zp <= ${near} || ${far} <= zp) continue;
算出之后即可用zp和z-buffer进行比较,从而更新z-buffer之中的值
/* check z-buffer */
if (zp < depth_min) {
depth_min = zp;
face_index_min = fn;
for (int k = 0; k < 3; k++) weight_min[k] = w[k];
if (${return_depth}) for (int k = 0; k < 9; k++) face_inv_min[k] = face_inv[k];
}
注意z-buffer更新时候既要更新该点的像素深度,同时需要更新对应的面片索引,以便后文从面片上进行像素采样。到三个顶点的权重也需要被存储下来,方便后文进行运算。
/* set to global memory */
if (0 <= face_index_min) {
depth_map[i] = depth_min;
face_index_map[i] = face_index_min;
for (int k = 0; k < 3; k++) weight_map[3 * i + k] = weight_min[k];
if (${return_depth}) for (int k = 0; k < 9; k++) face_inv_map[9 * i + k] = face_inv_min[k];
3.5 此步骤的重要意义
此步骤的重要意义,在于运算出,
1.每个像素点上的点,对应于原图哪个三角面片
2.算出该面片上的点到三个顶点的相对权重是多少
运用以上两个信息,即可对栅格化图像进行采样
四、成像采样
4.1 成像采样
成像采样是根据前面算算出的信息,在texture上进行采样。已知了成像点到每个面片的相对位置,已知了每个面片的深度,这个步骤就简单了非常多。
texture在图像上的存储是按照 [batch_size, num_faces, texture_size, texture_size, texture_size, RGB] 这种尺寸存储的。因此对应于每个三角面片的texture是 [texture_size, texture_size, texture_size, RGB] 这种形式存在的。
成像即是在这个texture之中取样出对应的像素点。
4.2 计算该点所属的位置
之前计算的到三个点的weight被归一化到0和1之间,现在要把它转换成texture空间上的坐标,即将0-1之间的量转换成 0, texture_size -1 之间的量。
/* get texture index (float) */
float texture_index_float[3];
for (int k = 0; k < 3; k++) {
float tif = weight[k] * (ts - 1) * (depth / (face[3 * k + 2]));
tif = max(tif, 0.);
tif = min(tif, ts - 1 - ${eps});
texture_index_float[k] = tif;
}
4.3 加权采样
对于texture而言,它的值是离散的,即texture之中,只有0,1,2,... texture_size - 1个离散的点,而不是连续的值,但是我们之前算出来的值归一化之后的 texture_index_float是离散的,因此需要对其进行离散化操作。具体如何离散化?我们可以用一维来举例,对于一维而言,很容易理解。
例如该位置是11.45,那它的值就是11和12两个对应的像素值的加权平均。
将一维扩充到三维,就是对应的2^3=8个点上的加权平均。通过此加权平均进行采样。
/* blend */
float new_pixel[3] = {0, 0, 0};
for (int pn = 0; pn < 8; pn++) {
float w = 1; // weight
int texture_index_int[3]; // index in source (int)
for (int k = 0; k < 3; k++) {
if ((pn >> k) % 2 == 0) {
w *= 1 - (texture_index_float[k] - (int)texture_index_float[k]);
texture_index_int[k] = (int)texture_index_float[k];
} else {
w *= texture_index_float[k] - (int)texture_index_float[k];
texture_index_int[k] = (int)texture_index_float[k] + 1;
}
}
int isc = texture_index_int[0] * ts * ts + texture_index_int[1] * ts + texture_index_int[2];
for (int k = 0; k < 3; k++) new_pixel[k] += w * texture[isc * 3 + k];
sampling_indices[pn] = isc;
sampling_weights[pn] = w;
}
for (int k = 0; k < 3; k++) pixel[k] = new_pixel[k];
至此,我们梳理完了整个栅格化流程
五、个人点评
此栅格化是以面片为单位的栅格化,与openGL这种,以顶点为单位的栅格化还是不太一样的,但是此方法栅格化之后的效果不错,可视化可控,可以作为栅格化原理的方案之一。
并且此方法没有像openGL一样被封装到代码之中,此方法可以看到整个栅格化的源码,具有不错的研究价值。