上一篇我们导入了Aili这个相对比较复杂的模型,并发现了一些问题,进行了修正。
但是目前我们所渲染的只是一个没有任何材质的模型(也常常被称为白模,或者灰模)。为了能够输出类似Blender渲染的那种效果,我们需要支持材质的导入和渲染。
一般来说,材质是由一组描述表面光学特性的参数及公式组成的。对于表面光学性质均一的单一材质(就是其光学特性在整个表面的任何一点是一样的),我们往往可以通过几个独立的参数来描述。但是现实世界的材质大多数都是不均一的。这样我们其实需要的是一个按表面位置(也称为uv)排列的参数表,或者说矩阵,或者说贴图。
是的。贴图最早的运用可能是真的作为一张图贴到表面。即使是这样,其实它是一张关于表面颜色的参数表。而近代的贴图则更多的代表着参数矩阵,虽然很多时候它是美术使用DDC工具“画”出来的,但是它在渲染引擎当中是作为参数表来被使用的。
也正是因为这样的工作流程,我们的游戏引擎需要能够导入以图片文件格式保存的这些参数表。我们在从零开始手敲次世代游戏引擎(二十六)当中支持了BMP格式,但是正如我们在其中所说,BMP格式在数字内容制作方面不是常用的格式,有很多其自身的缺陷。
AILI的贴图主要有两种格式:PNG和JPG。这两种格式都是被国际化组织进行标准化过的格式,有很好的平台无关性。
在大多数商用软件开发当中,我们一般是采用标准化组织推出的参考实现库来支持这些格式。但是作为学习,其实自己动手写一下这些文件的解析器,也是很有意思的。况且,因为这些格式应用范围十分广泛,文件格式当中包含了很多我们其实并不需要关心的扩展和内容。使用参考实现库对于我们这种特定的应用来说,有些时候也是有些杀鸡焉用牛刀的味道。特别是在跨平台开发的时候,如果遇到的是一个全新的平台,仅仅移植我们需要用到的部分总是比移植一个大而全的库要来得简单。
首先,关于JPEG文件格式,可以参考(参考引用*1)
通过阅读参考引用*1,我们可以了解到JPEG其实是一种图像压缩算法,而文件格式其实是叫JFIF或者Exif。事实上并没有什么JPEG文件格式(虽然大家都这么叫)。
然后我们可以了解到,JPEG对于图像的压缩,其实是将图像分割为8×8像素的小方块,然后以这些小方块为单位经过一系列数学运算来实现的。这其实也是MPEG(视频)的压缩算法基础。
DCT/IDCT变换
在这些数学运算当中,有一个称为DCT的东东。它的中文名字叫离散余弦变换,是傅立叶变换的一种离散形式的特例。(参考引用*2)而傅立叶变换是特别有用的一个数学工具,特别是在通信领域,比如有线电视可以在一根同轴电缆上传输几十个上百个频道而不互相干扰,或者是大家每天用的手机,几百台手机可能同时在和一个塔台进行无线通信但是能够相互不干扰,这里面都有它的功劳。
在计算机视觉领域,DCT也用来从图片当中提取主要信息,过滤次要信息和噪点。比如我们手机带的美颜功能,有一种算法就是用DCT来磨皮。
具体到JPEG这个压缩算法来说,DCT其实是将图片的任意一个8×8的图像块,分解为下图所示的64个8×8像素的图案模版的叠加。

也就是说,JPEG其实是在通过用不同的比重反复混合这64个图案,来模拟原始的图像的。这个过程将其图像化的话,差不多是下面这么个样子:最右边是64个图案依次被选取出来,然后我们将其乘以一个系数(这个系数就是JPEG文件当中保存的主要信息),得到中间这个图,然后将结果逐渐叠加,就可以得到原图(左图),如下面所示:

上传视频封面
(截屏对象网页:Wikipedia)
好了,让我动手来编写这个过程。既然是8×8的矩阵计算,这又是一个绝好的可以使用并行计算的机会。我们的ispc又可以粉墨登场了。JPEG当中使用的DCT是被称为II型DCT的变种,数学公式如下:
其中:
-
-
-
-
是指原图像的8×8图像块当中第x行第y列的像素值
-
是指DCT变换后矩阵第u行第v列的值(系数)
用ispc编写这个算法如下:
上面的程序当中用到的几个小技巧:
-
一般来说,加法减法比乘法快,乘法比除法快。所以我们通过定义常数的方法,把除数当中的常数在编译的时候进行倒数运算,将除法变成乘法(可能在现代编译器里面这一步编译器自动也会进行。但是我们自己写就更明确一些);
-
三元运算符比if else语句快。当然不建议写很复杂的三元运算符,因为那个可读性太低;
-
目前ispc还不支持foreach嵌套,所以我们只好用一个uniform类型的变量来桥接两个foreach。
修改我们的Test/GeomMathTest.cpp,在matrix_test()最末尾添加这个新函数的测试代码:(当然还需要修改相应的CMakeLists.txt和geommath.hpp来包含新的source和header)
为了便于比较结果,我直接使用了参考引用*1当中的示例数据。编译执行之后输出如下:

对比参考引用*1当中给出的结果,可以验证我们的计算是正确的。
不过我们需要的其实并不是将一副图片编码为JPEG格式,而是将其从JPEG格式当中恢复出来。所以我们需要的其实是这个变换的逆变换,称为IDCT(inverse DCT):
其中:
-
-
-
-
是指重建图像的8×8图像块当中第x行第y列的像素值
-
是指IDCT变换前矩阵第u行第v列的值(系数)
ispc代码的实现如下:
看着是不是和DCT的代码非常类似?我一开始也以为是一样的,但是其实还是有差别的。
好了,接下来依旧是利用参考引用*1当中的数据进行测试,在Text/GeomMathTest.cpp当中追加测试用例:
运行的结果如下:

也是与(参考引用*1)一致的。
如果将这个结果与上面DCT之前的数据进行比较,我们会发现它们是不一样的(但是趋势相同)。这并不是因为DCT和IDCT计算的问题,而是因为我们喂给IDCT的数据并不是上面DCT之后的数据。这个差,其实就是JPEG压缩的秘密所在。
如果仔细观察图像的数据经过DCT变换之后的矩阵,我们会发现数据有往矩阵左上角进行汇集的趋势。即:左上角的数据的绝对值较大,而右下角的数值绝对值小。左上角的系数对应的是基波和低周波数的谐波分量,而右下角的对应的是高周波数的谐波分量。请看上面的那个8×8的调色板(图案模式),左上角的是大色块的,而右下角的是小色块的。
对于人眼来说,大色块远远比小色块重要。这其实与当前最热的AI的卷积总是先认识总体,然后才认识细节的很类似。AI今天能有这样的发展,是因为总算找到了一种和人的?认识规律类似的算法。
所以,如果仔细阅读(参考引用*1),我们可以看到在保存JPEG的时候,在做完DCT之后,是通过算法根据一定的阈值丢弃了右下角比较小的数据了的。这个阈值放得越宽,左下角就有越多的0,那么就是保存下来的JPEG文件就会越小。
在上面这个测试当中,我们喂给IDCT的数据就是这么一个,右下角数据大多为零的数据。经过IDCT变换之后,虽然没有能够原样恢复原始数据,但是很类似。这就是JPEG充分利用人眼特性进行压缩的原理。
所以,通常意义下的JPEG压缩是属于有损压缩。主要用于展示给人眼看的图片,而不太适合其它的场合。而游戏,就是典型的展示给人眼看的应用。
色空间变换
好。除了DCT/IDCT,JPEG当中还有一个变换,就是RGB<—>YCbCr变换。这是一个色空间的变换,通过这个变换同样可以实现数据的压缩。

根据(参考引用*3),JPEG标准当中的YCbCr变换公式如下:

因为这个其实就是标准的向量变换,我们不需要写新的ispc函数。我们在Framework/Common下面新建一个C++头文件ColorSpaceConversion.hpp,在其中定义这两个变换:
然后我们在Test目录下面增加一个测试用例程,ColorSpaceConversionTest.cpp
更新CMakeLists.txt之后,编译运行,得到下面这个结果:

好了,休息一下。代码在dct分支当中。
参考引用