从零开始手敲次世代游戏引擎(Android特别篇)-2

从零开始手敲次世代游戏引擎(Android特别篇)-1当中我们构建了一个基于docker的Android开发环境,并且实现了我们引擎代码的交叉编译。为了验证我们编译出的程序是否能够在Android设备上正常运行,我们需要构建Android执行环境,并打通编译环境与执行环境,使得我们可以将编译出的程序部署到执行环境当中。
构筑Android的执行环境有两种方式:
  1. 真机模式: 使用Android物理机(比如手机)运行程序。 好处:真实的性能,真实的环境 坏处:物理机有很多型号,五花八门;收集全需要花很多💰;需要连接物理机,不方便云端调试;需要root手机,不仅麻烦而且会破坏手机的保修,并且给手机带来安全风险(所以最好不要在自己日常用的手机上搞)
  2. 模拟器模式 使用软件模拟环境运行程序。 好处:方便,省钱,可以远程部署,可以模拟多种型号 坏处:性能与物理机相差甚远,有些基于设备的功能无法测试或者无法完全测试(如GPU,摄像头,附加传感器等)
两种方式各有利弊,实际工作当中的开发往往是两者结合使用。开发前期多使用模拟器模式,后期使用物理机进行最终确认和性能方面的调试优化。
Android的模拟器程序随SDK一起安装,是基于qemu的。qemu是一个开源的虚拟机。因此,在我们前面准备的Docker环境当中,其实已经有了模拟器。不过执行模拟器还需要系统固件,就是我们平常经常说的ROM程序,或者刷机的时候使用的固件包。标准的Android各个版本的固件同样是随SDK提供的,只不过考虑到安装尺寸缺省不会安装,需要通过sdkmanager进行下载安装。
在命令行安装Android系统固件的方法是在安装了Android SDK的环境(如我们的docker环境)当中执行如下命令行:
系统固件有许多版本,可以通过下面的命令行查看所有的选项:
需要注意的就是选用的API版本以及CPU的ABI需要和我们编译代码时的选择相对应。API版本需要不低于我们的选择(我们目前的选择是21,而固件是24,所以没问题),而CPU的ABI必须完全一样。
然后我们需要构建AVD,也就是Android Virtual Device(安卓虚拟设备)。这可以通过下面的命令行实现:
这个命令行的意思就是使用”system-images;android-24;generic;armeabi-v7a”这个固件构建一个名为“test”的虚拟设备。
再接下来我们就可以启动这个虚拟设备了。在Android SDK当中,对于虚拟设备的创建是通过emulator这个程序(其实是qemu的一个wrapper)实现的。通过下面的命令行,可以实现虚拟机的启动:
后面几个选项是关闭音频设备、显示设备、启动动画并开启重力加速度传感器的模拟。这是因为我们是在Docker容器当中启动的模拟器,而Docker容器目前是不支持(如果不做一些很特别的配置)声卡显卡这些设备的。如果我们是直接在电脑上安装的Android SDK,那么其实
就可以了。这种方式能够实际看到模拟器的画面。
好了。现在模拟器已经起来了。如果是本地安装的Android SDK,那么现在应该能看到Android模拟器的画面了。如果是按照本教程使用的docker容器,那么现在看上去什么都没有。为了检查模拟器是否真的启动起来了,我们可以使用adb命令。输入如下命令,就可以看到模拟器的名字:
在模拟器的情况下,名字的后半部分其实就是与模拟器通信的TCP端口号。如果我们启动了多个模拟器,那么这些端口号会按照累增的方式指定。一般第一个是5554。
相对的,如果是使用物理实机,那么首先需要打开开发者选项。在当前主流的Android版本当中,开发者选项缺省是隐藏的。要打开它,首先在物理机上打开“设置 > 关于手机”,然后猛点“版本号”那一行,大约7次之后就会弹出一个对话框,问是否要打开开发者选项,选择“是”。
之后回到设置,就会在列表下方看到多出来一个“开发者选项”。点击进去,首先将第一行“开发者选项”的滑块打开,然后将“USB调试”打开,“监控ADB安装应用”关闭,“仅充电”模式下允许ADB调试打开。设置好了之后,用USB线将设备与电脑连接。如果是Windows电脑的话还需要安装一个手机驱动,如果之前从来没安装过的话(Win10系统的话,一般连接之后Windows会自动提示。老的Windows的话可以通过安装手机自带光盘上的助手程序完成驱动的安装。但是注意助手程序往往会在手机连接的时候抢夺对手机的控制权,所以记得将助手程序关掉)Mac电脑的话,无需任何驱动的安装就可以了。
连接完成之后,执行adb devices命令,就可以看到物理机的名字:
从这里往后的操作无论上虚拟机还是物理机都是一样的。
为了在设备上执行我们编译出的程序,我们首先需要将程序复制到(虚拟/物理)设备上去。这是通过adb push命令来实现的。adb push命令的格式很好理解,基本和cp命令相同,第一个参数是从哪里复制,第二个参数是复制到哪里去。
所以,假定我们编译输出的目录是在build/Test之下,我们要将它们复制到设备的/data/local/tmp目录之下,那么就是这样的命令:
然后,我们可以通过下面的命令进入设备的shell(也就是命令行)
切换到/data/local/tmp目录之下,执行ls -la命令,可以看到程序都已经复制进来了:(截图为写此文的时候的最新版本,包括了一些本篇文章当中尚未介绍的文件,如libMyGameEngine.so。因此如果你的环境里没有这个文件请不要担心)
因为有些Test需要加载Asset资源,我们还需要将项目当中的Asset目录复制到设备当中(上图已经是复制好了)。这同样通过adb push命令就可以了。首先输入exit退出设备命令行环境,回到电脑命令行,然后输入:
另外一个需要注意的点是上图当中的程序文件都是带“x”属性的(最左边一列)。这个属性表示可执行。其实在我们运行了adb push之后,这些程序是没有这个属性的。如果我们直接尝试启动它们,系统会报错。因此我们需要执行下面的命令为其添加可执行属性:
(在PC命令行执行的话:)
(OR,通过adb shell进入设备命令行之后执行的话:)
在执行这个命令的时候,如果系统提示权限不够,那么需要先升级到root权限。方法是:
(在PC命令行执行的话:)
(OR,通过adb shell进入设备命令行之后执行的话:)
这里需要特别注意的是,如果是使用的物理机,那么根据你的手机的型号,以及手机是否被root过,上面的命令可能会失败。解决的方法是root你的手机。(如果你不知道root你的手机会带来什么安全风险,请不要进行。最好找一台已经不用的旧手机,重置系统之后进行)
好的。然后再次输入adb shell进入设备环境,切换到/data/local/tmp(因为我们的AssetLoader会在当前目录下查找Asset目录,所以执行之前必须先将当前目录设置为包含Asset目录的目录)并执行这些测试程序。大多数程序应该可以正常执行。
最后,如果想要关闭Android设备模拟器,通过在PC命令行执行下面的命令就可以了:
如果是物理设备,直接拔掉USB线,或者在开发者选项当中关闭USB调试/开发者选项就可以了。
(注意如果是使用的日常使用的手机练习本文,请一定记得在结束的时候关闭开发者选项。否则如果之后使用了社会上一些免费充电设备的话,可能面临数据被盗或者被安装垃圾程序的风险)
(在PS4/PSV的SDK当中,也有类似的命令行程序实现对于SDK相关组件的下载和安装、开发设备的控制。这个过程是非常相似的。只不过PS4/PSV并不支持虚拟机,只有物理机,而这些开发用的物理机是不太容易拿到的。我们可以通过练习Android开发来熟悉嵌入式开发的一般流程)
 
参考引用
图标

从零开始手敲次世代游戏引擎(Android特别篇)-1

本篇开始我们将我们开发的引擎移植到Android上面去。
首先我们需要构建Android的开发环境。无论是基于Windows的Visual Studio,还是基于mac OS的XCode,亦或是基于java的Eclipse,以及google的Android Studio,都能够支持Android的开发。
不过一如既往的,我讨厌在项目代码树当中混进某种IDE的杂七杂八的文件,所以我选择使用命令行环境。当然,IDE环境在调试的时候很方便,我们可以用cmake随时生成IDE的项目配置文件,在IDE当中进行编辑调试。
由于我们的代码支持多平台,我时常需要在不同的操作系统之间切换编写确认代码。在每一个系统上安装一套android SDK/NDK环境会变得费时费力,且不容易保持环境的一致性。况且我们需要使用Circle CI进行连续集成,这个环境最好是能够很容易在不同的物理机/操作系统间迁移的。docker能很好的解决这个问题,参考引用*2展示了一个基于Ubuntu的安装了android SDK/NDK的典型docker环境的构筑方法。
为了能够在Mac上面使用Docker,我们首先需要安装Docker服务。安装的方法请参考参考引用*1。如果是Windows或者Linux环境,其安装方法也可以在该页面的左侧导航栏当中找到。
安装完成之后,在命令行通过下面的命令,就可以启动并进入这个环境:
参数选项“-it”表示我们需要以交互的方式进入这个docker,“-v $(pwd):/project”表示把当前的目录映射到docker当中的/project目录,也就是将我们的代码树挂载到docker当中的/project目录。这也隐含着执行这个命令的时候需要在我们的项目代码根目录下。“tim03/android-sdk-ndk”是使用参考引用*2构建的docker容器名称,也就是包含了android SDK NDK的开发环境。“bash”是指docker启动之后进入Linux的BASH命令行。
进入之后,缺省是在根目录下,执行“cd”命令切换当前目录到项目根目录:
执行ls命令查看当前目录下是否包括了我们的源代码文件:
在之前我们虽然做了Windows / Linux / Mac OS三种平台的开发,但是这三种平台都是基于PC的平台,我们都是在其自身进行开发并编译的。(虽然其实我们在从零开始手敲次世代游戏引擎(七)当中简要介绍了一点儿在Linux平台编译测试Windows平台程序的一种方法)
而对于诸如Android平台(或者IOS平台,以及无法在这里公开介绍细节的PS4/PSV平台)的开发,是通过在PC平台编译程序,然后再推送到目标平台进行执行的。这种方式被称为交叉编译,是嵌入式开发的一种比较基本的流程。
对于用惯IDE或者商用游戏引擎的人来说,可能不太会注意到这里面的区别(因为在IDE或者商用游戏引擎当中,基本上只要在平台选项当中切换一下就好了,整个流程与PC开发基本相同)。但是如果深入到细节,那么会发现两者有着很多不同。总的来说,嵌入式开发要麻烦一些。
坚持使用命令行的一个好处就是,我们可以精确知道实际的步骤,了解当中的细节。在这个基础之上,选用IDE/商用引擎进行开发,提高工作效率;而当我们遇到一些难解的问题的时候,又有能力将IDE/商用引擎打包好的一系列自动化步骤进行手动拆解,查找问题,从而获得超越“泛泛之辈”的能力。
因为我们的项目是基于CMake的。CMake下进行交叉编译的方法是通过建立TOOLCHAIN File来实现的。
基于参考引用*3所提供的CMake文法规格,我们项目根目录下建立cmake/android.cmake文件如下:
  1. 第一行是告诉CMake我们要交叉编译的目标平台是Android。因为Android是CMake已经知道的平台,写入这一条能够让CMake找到正确的交叉编译工具;
  2. 第二行是指定我们程序对应的Android的API Level。因为我们的程序其实并没有什么很特别依赖Android功能的地方,这个版本其实不是非常的重要;
  3. 第三行是指定目标CPU的ABI(Application Binary Interface,应用二进制接口)。Android其实是支持诸如x86, x86-64, arm, arm64, mips, mips64等多个架构的。这里为了简便起见,我们只指定了armeabi-v7a接口。这个接口是32bit的接口,相对较老,但是支持浮点的硬件加速,在当今大多数基于arm的手机上都支持;
  4. 第四行是告诉CMake在编译的时候生成NEON代码。NEON是arm CPU上的SIMD代码集,相当于Intel CPU的SSE系列;
  5. 第五行其实在armeabi-v7a的时候没有啥用。如果我们在第三行指定的是armeabi,那么这行是告诉CMake生成32位代码,而不是16位代码;(嗯,在arm64满天飞的今天,16位代码的确已经很老了)
  6. 第六行是告诉CMake关于Android NDK的安装位置。这个“ANDROID_NDK_HOME”环境变量是我们在创建编译环境的时候导出的。具体可以参看参考引用*2的Dockerfile;
  7. 第七行是告诉CMake在编译的时候链接GNU c++库。Android NDK提供了好几套c++库,缺省是一套被称为“c++_static”的经过大量裁剪的c++库。因为我们的代码基于C++ 11,当中使用了一些c++_static当中不支持的功能,因此需要在这里指定gnustl_static。
好了。有了这个TOOLCHAIN文件之后,我们只需要在使用CMake进行编译的命令行当中,加入如下的参数选项,告诉CMake使用这个TOOLCHAIN文件,就可以实现PC->Android的交叉编译了:
完整的命令行可以参考本篇文章对应的代码当中的build_crossguid_android.sh
通过同样的方式修改项目根目录下的build_opengex.sh / build.sh,我们就能实现整个引擎到Android的交叉编译。当然,因为Android并不支持OpenGL(Android支持的是OpenGL ES,是OpenGL的一个子集)和DirectX,其实我们还需要修改RHI下面的CMake文件,以及Platform下面的CMake文件,去除这些库的编译。

从零开始手敲次世代游戏引擎(三十六)

从零开始手敲次世代游戏引擎(三十五),首先导入合并从零开始手敲次世代游戏引擎(PNG特别篇)当中所写的PNG文件解析器,然后手工编辑aili.ogex,向👀材质添加eye.png贴图。执行程序,可以看到👀已经有了。(不过我们可以发现眼睛其实只是浮在脸前方的一个片面。。。这个是建模的锅,不怪我。。。)

另外仔细观察可以发现右侧的头发(side hair)没有正确的着色。这是因为右边的头发是通过对左侧的头发进行镜像得到的。虽然在Blender里面看起来没有什么问题,但是这种镜像会导致两个问题:

  1. 导出的三角形顶点的旋转方向从逆时针变为顺时针,从而导致被GPU裁剪(Culling)掉;
  2. 导出的模型变换矩阵是下面这个样子:(以X轴镜像为例)

可以看到包括一个对X坐标去反的计算。而我们的shder里面有对于法线的这么一个计算:

其中的modelMatrix就是上面这个矩阵。这就导致法向量也被镜像,也就是出现反转。所以我们看到的就是上面屏幕录制里面那样黑漆漆的。

这个问题我们可以通过修改OGEX导出脚本来修正,或者在今后我们编写了我们的游戏引擎的Editor之后,在Editor里面修正。所以我们这里就暂时不作进一步的展开了。

好了,到这里我们对于图形渲染基本管线的搭建就暂时告一个段落。当然图形渲染是一个很深的坑,我们还有很多很多没有实现的东西,比如随便列举一些:

  1. 点光源之外的光源类型
  2. 多光源
  3. 阴影生成
  4. diffuse贴图之外的贴图,如法线贴图,位移贴图,等等
  5. VS和PS之外的Shader,如Geometry Shader, Compute Shader等等
  6. 图像后处理

在场景组织和管理方面,我们也有很多可以进一步完善的东西,如:

  1. 场景物体的层次结构,分组和定义在组级别的坐标变换
  2. 场景的异步加载

等等。但是在继续深入介绍这些东西之前,作为一个游戏引擎的运行时,我决定先进行其它几个重要模块的基本构建,比如输入模块,驱动模块,策略模块,调试辅助模块等等。


接下来说一下工程方面的进展。感谢CircleCI,在之前免费的Linux环境的基础上,为我免费提供了基于macOS的连续集成环境。现在每一个push到github上的commit,都会自动进行Linux和macOS两个版本的编译和测试用例的执行确认,如下图:

实现起来也很简单,如下面这样修改我们的circleCI的脚本即可:

确实非常方便,欢迎大家在自己的项目当中也多多尝试。再次感谢CircleCI对于OpenSource项目的大力支持!

(最近一直在生病😷,文章更新的比较慢了,请多多包涵)

最后顺便说一句,第一届中国大学生AR/VR竞赛昨天拉下帷幕。虽然大赛自身的组织有很多的仓促,但是同学们的作品真的都很棒。期待大家加入游戏开发的大家庭。

从零开始手敲次世代游戏引擎(PNG特别篇)

从零开始手敲次世代游戏引擎(三十五)当中我们完成了衣裙贴图的加载和绘制。但是所谓画龙点睛,我们的模型目前仍然是没有?的。之所以这样,因为眼睛的贴图是PNG格式的,而目前我们只写了BMP和JPEG格式的解析器。
那么让我们立即开始PNG格式解析器的编写。
经过BMP和JPEG格式的锤炼,我们对此应该已经是比较驾轻就熟的了。首先依然是从FrameWork/Parser当中现有的代码拷贝生成PNG.hpp,然后根据参考引用当中的PNG格式的规格说明,修改这个代码。
与JPEG格式类似,PNG格式也是将数据组织为Chunk单位进行存储的。其Chunk的头部通过4个ASCII字母(8bit)类型进行标识。比较有意思的是,在这个4字母的标识符当中,PNG格式使用了字母的大小写来表示Chunk的一些属性。比如:
  1. 第一个字母大写表示该Chunk是一个基本Chunk,也就是PNG标准规定的必须有的Chunk。相对的,如果小写则表示该Chunk是一个扩展Chunk,也就是说是可有可无的;
  2. 第二个字母大写表示该Chunk表示符是经过国际标准化组织标准化过的,而小写则表示是未经标准化由应用私自扩展的(也就是说如果小写,表示这部分是没有互换性的);
  3. 第三个字母目前必须大写。小写保留给今后,目前还没想好有什么用;
  4. 第四个字母表示这个Chunk能否原样复制到经过处理之后的图片当中,即使我们并不知道这个Chunk是用来干啥的。如果小写,那么我们可以无脑地将其复制到处理之后另存的图片当中,即使我们不知道这个Chunk的含义;如果大写,那么如果我们对第一个字母为大写的Chunk进行了任何修改,就不能原样复制第四个字母为大写的Chunk。换句话说,第四个字母表示该Chunk当中的数据是否对第一个字母为大写的Chunk有依赖性。
图片的数据是存储在IDAT这个chunk里面。将数据提取出来之后,首先要将其解压缩。与JPEG不同,PNG文件格式是无损压缩格式,它采用得数据压缩就是LZ77,也就是我们熟知的zip压缩包采用的压缩方式。(严格来说,zip当中可以有多种压缩算法,一些是被申请了专利的。而LZ77是public的)
LZ77压缩也可以理解为一种动态的霍夫曼编码,细节上与JPEG采用的霍夫曼有一点不同。我们可以基于之前在JPEG解析器当中编写的霍夫曼树的代码编写LZ77的解压缩代码。也可以导入成熟的库-zlib。我这里采用的是后者。
zlib的使用方法可以参考参考引用*5。
在完成解压之后,需要对数据进行反过滤(defiltering)。为了提高压缩率,我们需要减少数据的带宽,也就是减少数据的统计方差。更为通俗一点来讲的话,我们需要让数据尽可能地集中在0的两边,而不是分布在取值范围所容许的所有值上(0-255)。
在前面介绍的JPEG当中,我们是通过一个被称为quantization的过程实现这个目的的。而在PNG当中,使用一种称为prediction(预测)的算法实现这个目的。这个算法其实是利用图像的连续性,使用当前像素周边已知的像素值来预测当前像素的值。(在数学意义上,就是相当用偏导数替代值)。对于大多数图像来说,导数的变化幅度要远低于值的变化幅度,从而能够起到减少带宽的目的。
对于这个偏导数的选择(也就是用周边的哪个或者哪几个像素预测当前像素的值),PNG规范规定了几种方式,并且以行(Scan Line)为单位,对方式进行了选择。也就是说,在解压出来的数据当中,每行数据的第一个字节,并不是图像数据的一部分,而是对于这个预测公式的一个选择参数。
最后完成的代码在这里:
运行测试的结果如题图所示。
注意目前的代码只考虑了color type为6,也就是rgba类型的非索引png图片格式。其它的类型稍加修改就可以对应,但是目前还未编写。
参考引用

从零开始手敲次世代游戏引擎(三十五)

从零开始手敲次世代游戏引擎(三十四)。我们开始导入材质。最终效果如下:
(本文所使用3D模型及贴图来自于ermmus制作的小萝莉(AILI)。许可证类型:CC-BY。感谢无私的分享精神)
 
 
视频封面

上传视频封面

 
 
首先作为第一步,我们导入材质的基本参数。我们需要进一步完善我们在从零开始手敲次世代游戏引擎(二十九)所写的SceneNode,SceneObject的结构,导入包括材质的基本颜色(diffuseColor),高光颜色(SpecularColor)和高光系数(SpecularPower)等颜色/参数。然后我们需要修改我们的GraphicsManager(在这里我们暂时只实现了OpenGLGraphicsManager),让它能够将这些材质参数传递给我们的Fragment Shader。最后是修改我们的Shader,将我们之前写死在Shader里面的材质参数,替换成uniform类型的变量。
下面是所有这些变更的代码:
变更之后的效果如下:
 
视频封面

上传视频封面

 
 
可以看到一部分材质已经应用上去了。最为明显的就是面部、手、脚这些裸露的部分。
至于衣裙以及?,这些是使用的基于贴图的材质。主要是这两种:
AILI: Textured by ermmus Creative Commons Attribution 3.0 CC-B
AILI: Textured by ermmus Creative Commons Attribution 3.0 CC-B
可能是因为我所使用的OGEX导出脚本与Blender的版本不匹配的问题,导出的aili.ogex文件当中并没有到贴图的链接。不过OGEX文件是文本文件,很容易编辑。我们可以手动加上去:
 
 
同时检查模型的UV坐标(也就是贴图坐标)已经正确的导出了:
然后我们结合我们在从零开始手敲次世代游戏引擎(JPEG特别篇)-3开发的成果,将贴图也加载进来。相关的代码在
接下来我们需要将贴图通过图形API接口从CPU传递到GPU,并且修改我们的Shader以及初始化Shader的代码,将UV坐标正确地传递给GPU。相关的代码在:
这样我们就完成了一个基本的贴图导出和加载的过程。最后的效果在本文开篇已经展示了。

接下来讲一些工程方面的事情。
我们现在代码已经有了一定的规模,而且支持多个平台,不同的RHI(图形渲染API)。在这样的情况下,每次对代码进行了变更,是应该在各个平台进行测试的。
这个过程十分重要,但是如果手动操作会十分的耗费时间,并且单调乏味。
事实上,行业当中一般采用“连续集成”(CI = Continues Integration)来对应这个需求。大一点的软件公司都会有专门的人/部门负责这部分工作。
所谓CI,就是指使用一套自动化流水线工具,当代码发生更新的时候,自动的按照既定的步骤对代码进行编译、测试、分发的过程。
我们的代码托管在GitHub上面,GitHub提供了CircleCI的集成,并为开源项目提供了免费的配额。开通了CircleCI的集成之后,通过十分简单的配置,CircleCI就会对我们每一个提交进行自动化编译与测试,并将结果显示在GitHub当中,如下图这样:
画面当中绿色的勾就是CircleCI添加上去的测试结果。上面是按照branch进行显示的,我们还可以按照commit进行显示:
CircleCI不仅支持Linux版本的CI,也支持macOS版本的CI。不过macOS版本的服务缺省是不免费提供的。目前我正在和CircleCI协商是否可以获得相关免费资源。
设置CircleCI的方法很简单。点击下面这个链接,然后按照页面提示进行就可以了。
对于诸如Android/iPhone等标准开发流程(就是使用IDE进行开发的项目),CircleCI可以自动检测出项目编译和测试的方法。对于像我们这样的深度自定义产品,需要在项目文件夹里面新建一个目录和文件,描述我们项目的编译和测试方法:
.circleci/config.yml
 
参考引用

从零开始手敲次世代游戏引擎(JPEG特别篇)-3

从零开始手敲次世代游戏引擎(JPEG特别篇)-2我们写了一个最基本的JPEG文件解码器。当中采用的例子是一个只有白黑两种颜色的16×8像素的图片。
如果我们采用更为复杂的图片,会不会有什么问题呢?
接下来我们可以来进行这方面的确认。
首先我们使用GIMP这个开源的图像编辑软件创建一幅稍微复杂的,带有红黄蓝绿4个颜色的16×16的JPEG文件,如下:
在保存(Export)的时候,GIMP 会提示如下一些选项:(需要展开Advanced Options)
首先让我们尝试去掉所有的复选框的勾选。然后点击Export。这时候执行我们所写的解码器,最后输出的结果如下:
可以看到基本上没有什么问题,但是红色色块的部分R的值不是FF,而是FE;绿色色块的部分G的值虽然是FF但是B通道出现了个1;而蓝色色块的部分与红色色块的部分类似FF编变成了FE;至于黄色的色块,我们设定的颜色是FF E3 08但是还原出来的是FF E2 08。
由于这个误差出现的情况在蓝色和红色色块类似,而在绿色和黄色色块出现的情况不同,我们可以推测它是与颜色相关的阶段发生的误差,而不是诸如DCT等各个通道均等处理阶段发生的误差,更不应该是在Quantization阶段因为舍弃了部分绝对值较小的高频信号导致的。我们可以立即验证这一点:
我们当前的Quantization矩阵是
重新回到GIMP,将Quality从原来的90调节到100,其它保持不变:
我们会发现Quantization矩阵变成了下面这个样子:
也就是不对DCT矩阵进行任何缩放。这就会导致所有绝对值大于(0.5)的AC分量都被按原样保留,但是最终还原的RGB数据仍然是一样的。
所以,到这里我们至少有两个收获:
  1. 在导出JPEG时设置的品质参数Q,实际上是控制着Qunatization矩阵的内容。Q设置得越高,Quantization矩阵当中的值就越大,DCT矩阵按元素与Quantization矩阵相除并取整之后得到的0⃣️就越多,压缩率就越大,但是也意味的越多的高频信号被丢弃,图像的锐度(细节)丢失得越多;
  2. 在色空间变换的时候,会产生颜色的轻微改变。
这些知识对于游戏开发的时候的(特别是美术同学)的提示至少是,
  1. 首先,对于需要严格指定颜色的情况,不要使用JPEG格式导出贴图;
  2. JPEG格式更加适合实拍的贴图,而不适合手绘的贴图;
  3. 设置比较高的Q参数可以得到比较好的细节,但是这对于矫正和原图的色差没有什么帮助。
然后我们来看导出选项当中的Optimization选项。在☑️这个选项之前,我们得到的密码本是和上一篇差不多的那一长串。在☑️之后,密码本如下:
是的,你没看错,这就是全部4个密码本。是不是比上一篇单个密码本还小?
所以☑️这个选项的用途就是,缩小密码本的大小。在密码本当中只包括实际用到的值,而去掉那些没有用到的值。而且,索引也是经过优化的,变得更短了。(因为值少了,我们可以用更短的学号(索引))
所以这里学到的是:
  1. ☑️Optimization选项可以减小导出的JPEG文件的大小,并且不会对还原出来的图片质量造成任何损失。
好了。关于JPEG的导入这里就暂告一个段落了。

从零开始手敲次世代游戏引擎(JPEG特别篇)-2

(首先说一下,这篇会非常难看。但是如果动手去做了,会对各项能力有极大的提高)
我们现在有了核心的算法库,接下来就是实际读取JPEG文件并进行解码了。
首先,JPEG标准(参考引用*2)定义了JPEG交换文件的大的框架结构。
其次,JPEG文件有JFIF和Exif这两种主要的具体实现格式。我们先来看JFIF。JFIF的格式可以参考(参考引用*1)
总的来说,JPEG文件是一个二进制文件,当中包括了一系列以0x FF xx作为开始标记的数据段(Segement)
每个数据段的格式基本如下:
  1. 首先是2个字节的标识符,以FF开头。如 FF C0;
  2. 然后是2个字节的长度,表示该段的长度(有例外,比如包含实际图像数据的SOS段);
  3. (这里需要注意的是,首先,JPEG当中所有的二进制存储都是Big Endian的,或者说是按照网络字节顺序存储的。对于两个字节以上的数据类型,是按照从高位字节(MSB)到低位字节(LSB)的顺序进行存储的,这与X86架构的存储方式是反的;)
  4. (其次,这个长度不包括标识符的2个字节,但是包括其自身的2个字节;)
  5. 接下来的内容则根据段的类型决定。
所以,我们首先需要定义这些段(头)的结构。我们从Framework/Parser/BMP.hpp拷贝新建Framework/Parser/JFIF.hpp,然后根据(参考引用*1)(参考引用*2)当中的(segment header)定义,编写对应的结构体(代码有删节):
然后,我们循环检测段的开头标志,用上面定义的这些结构体将存储在段头结构当中的数据提取出来,大致是如同下面这么一个感觉(有大量删节):
其中,0x FF DA(SOS, Start Of Scan)是实际图像数据存储块开始的标志。当我们检测到这个标志的时候,我们就可以开始Dump图像的数据,直到遇到0x FF D9(EOI, End Of Image)为止。
不过这里面需要注意的问题是,实际上图像数据里面,也会出现0x FF这样的值。为了不让其打乱我们的定位标志,JPEG标准规定,对于图像数据当中出现0x FF这样的情况,在其后立即插入0x 00,也就是以0x FF 00来代表0x FF。(同时,JPEG标准规定,不存在0x FF 00这样的段标志)
上一篇文章当中我们也提到了,JPEG是将图像分割成为8×8的图像块进行编码的。(参考引用*3)当中比较详细地介绍了手工解码一个十分简单的JPEG图像文件的过程。
在我们编程的时候,测试用例的选择同样是十分重要的。一个好的测试用例,可以大大加快我们开发的速度和质量。由于JPEG解码过程涉及到非常多的运算和类型转换,使用一个简单明确的数据样本可以帮助我们快速地找到问题。这里我选用了与参考引用*3相同的图片。这样我们可以将程序解码的每一步结果与文章当中提供的结果进行对比,快速找到问题。这个图片是这样的:
图片来自参考引用*3
太小?放大的话,如下(外框和虚线表示像素边界,不是原图片的一部分):
图片来自参考引用*3
是一个16像素宽,8像素高的图片。左边8×8是全黑,右边8×8是全白。
这么一个图片,在压缩之前是 16(像素(宽)) * 8(像素(高)) * 3 (字节/像素)= 384个字节。经过JPEG压缩之后,是304个字节到653个字节之间。
什么?653个字节?那不是更大了?是的,这是因为JPEG文件的构造当中,除了图像数据之外,还有很多记录图像属性以及压缩算法参数的元数据。因为我们的图片太小了,所以这些开销反而成为了大头。事实上,如果抛去这部分开销,压缩后的图像数据只有7到9个字节。
一般来说,JPEG的压缩率大约在1/5左右。当然这和很多因素有关。
我们接下来就来看这区区9个字节是怎么一步一步展开成为384个字节的。
经过上面的程序的处理,我们可以从文件当中的SOS和EOS这两个标志之间,提取出下面这9个字节的图像数据:
首先,如上面所说的,0x FF 00实际上是代表着FF。所以其实有效的数据一共有8个:
然后,这8个数据其实是一种变种的霍夫曼(Huffman)编码(参考引用*5)。霍夫曼是上个世纪50年代MIT的高材生。这种编码的核心思想就是,用尽量短的2进制bit代表最多出现的数据。
比如说我们有100个人,每个人有一个学号。如果其中有些人经常来上课,我们就给他一个短一点儿的号码。不太来上课的,我们就给他一个长一点的学号。这样的话,每次点名的时候,我们就可以少写一点儿字。
在计算机当中,所有数都是用二进制表示的。比如十进制8,用二进制表示是1000,这个长度就是4个bit;而十进制1,用二进制表示还是1, 这个长度就只有1个bit。如果在一个序列当中,8经常出现,而1出现的比较少,那么我们通过(强行)定义二进制1代表8,而二进制1000代表1,那么就可以缩短整个编码的长度,而不会丢失任何数据。
因此很容易可以想到,如果想要对一个序列进行霍夫曼编码,首先需要统计在这个序列当中基本符号的种类和出现频率。然后将基本符号按照出现频率从高到低进行排序,对于高的给予尽可能短的编码,对于低的给予稍长一些的编码,目的是使最终的整体码长最短。
这是一个算法问题。有兴趣的可以参考(参考引用*5)。我这里就不作更多的展开了。
我们这里需要实现的是霍夫曼编码的解码。绝大部分的解码,最终都是类似一个查字典的过程。首要的是构建这个字典(如果放在安全领域就叫密码本),然后根据索引一个个查出来就好了。
JPEG文件的目的并不是加密,所以这个密码本是放在文件当中的。这也是为什么在我们这个例子当中,压缩之后的文件反而变大的原因。
JPEG的密码本的起始标志是0x FF C4。我们在文件当中定位到它,然后将其提取出来。提取出来的结果大致如下(出于篇幅考虑只列出了部分):
竖线左侧的是编码(索引,密文),右侧的是值(词条,符号,明文)
这里需要注意的有以下几点:
  1. 虽然JPEG当中保存了这个字典(密码本),但是为了缩小文件尺寸(毕竟JPEG的目的是压缩),没有直接保存编码(索引,密文),而只是保存了值(词条,符号,明文)以及其在Huffman Tree当中的层级。我们需要自己根据这些信息重建Huffman Tree来生成索引;
  2. JPEG当中这样的字典(密码本)有4个。其内容是根据被保存的图片内容以及压缩品质设置变化的。在解码的过程当中会根据当前提取的数据状态使用不同的字典(密码本)(后面详述)
好了。接下来我们需要了解JPEG文件当中图像数据的组织形式。上一篇文章当中我们所提到的8×8分块,这个其实是最底层的组织形式。事实上,彩色图像会包括R/G/B这3个通道(注意普通的JPEG不支持Alpha通道,也就是透明度)。上一篇文章当中也提到,在JPEG压缩的过程当中,首先会将像素的色彩空间从RGB转成YCbCr。这样做的原因(或者说目的)是为了进一步减少文件尺寸。
不过单纯的RGB转YCbCr,这个是一个3个分量到3个分量的坐标轴转换,并不会直接带来数据的减少。秘密在于YCbCr的DownSampling。RGB三个通道都是彩色通道,其对于人眼的重要度其实是基本均等的(如果硬要说的话,G相对来说更重要一些)。但是到了YCbCr领域,Y(辉度)是最重要的(人眼的辨识度/敏感度最高),而两个色差信号就比较不重要一些。
所以,在变换到YCbCr之后,我们就可以将CbCr这两个通道的图缩小。意思是,比如8×8的图像块,Y通道仍然维持8×8的分辨率,但是CbCr这两个通道可以只保留4×4的分辨率,甚至只保留2×2的分辨率,就可以了。事实上,大部分的彩色电视信号,都是采用了这种方式来减少信号的码率。需要进一步了解的,可以参考(参考引用*6)
图片来源:Wikipedia
说了这么多,好像和前面的霍夫曼编码没有什么关系呢?
如果是原版的霍夫曼编码,那么只需要按照字典将“密文”一一翻译成为明文就可以了。然而JPEG当中的复杂性在于,它有4个密码本。什么时候使用哪个密码本,是和上面所说的YUV格式息息相关的。这是因为各个信号的统计分布很可能是非常不一样的。结合其特点使用不一样的密码本可以进一步减少压缩后的文件大小。
常规的做法是,对于Y信号(通道)使用一套密码本,而对于CbCr信号(通道)使用另外一套密码本。注意我这里使用的是“一套”,而不是“一个”。为什么呢?
因为单单是这样解释不了JPEG使用4个密码本的原因(看起来似乎2个就够了?)。这时候请回想起(或者如果还没有看过,请看)上一篇文章当中关于DCT/IDCT的介绍。经过DCT变换之后,矩阵的左上角(0,0)位置的值绝对值一般远大于矩阵当中的其他值。这个左上角的值被称为直流分量(DC。沿用电学称呼,在这里其实与电流没有任何关系)代表的是8×8像素块的平均值,它的统计特性与其他值(交流分量,AC。)也是非常不同的。
可能聪明的你已经猜到,其实JPEG不仅为不同的信道指定了(可能)不同的密码本,也为DCT当中的直流分量和交流分量指定了不同的密码本。所以实际上是这么一个分配情况:
  1. 用于Y通道DC分量的密码本
  2. 用于Y通道AC分量的密码本
  3. 用于CbCr通道DC分量的密码本
  4. 用于CbCr通道AC分量的密码本
下面是我们这个例子当中用到的Y通道的AC分量密码本,对比上面DC分量的密码本,我们可以看到它们是非常不同的(符号的数量远多于DC分量):
好了,有了这些知识储备之后,我们就可以解码那神秘的8个字节了:
在JPEG当中,是按照下面这么一个顺序存储数据的(这里以YUV444为例):
我们根据这个顺序反复替换对应的密码本,就可以得到Y,Cb,Cr三个分量的DCT矩阵。不过这里实际上还有一个秘密,就是被霍夫曼编码的其实并不是矩阵的值,而是关于矩阵的值的一个描述。具体来说:
将FC展开成二进制:
查阅Y(DC)密码本,可以得到前7位(1111 110)= 0x 0a。(为什么我们知道是前7位?仔细看看上面霍夫曼的编码(索引),你会发现在同一个密码本里面没有任何一个短的码会成为长的码的开头一部分。所以我们只需要一位一位的去匹配,直到找到对应的值为止。这就是霍夫曼编码的NB之处)
这个0x 0a其实并不代表Y(DC)的值是10。它的意思其实是说,接下来的10个bit是DC的值。所以我们从第8位开始往后取出10个bit,这才是DC的值。
这就是JPEG当中的霍夫曼编码与常规的霍夫曼编码不同的地方,被称为huffman / entropy coding。其目的当然是:为了进一步毫无人性地减少文件的尺寸。(提供以bit为单位,而不是byte为单位的存储方式)
这就是全部的秘密了吗?
并不是。JPEG是一个相当复杂的编码体系。接下来的AC部分,规则又有些不一样了。
原因其实也并不复杂。每个8×8矩阵里面,DC只有一个,位于(0,0)位置,但是AC有63个,并且大多数为0⃣️。学过算法的应该知道,这是一种典型的稀疏矩阵。下面是我们这个例子的左边8×8的Y分量DCT矩阵:
所以,如果按照DC的存储方式,即使是0,我们也需要为每个0分配存储空间(哪怕是1bit)。这个显然还不够省。
所以AC分量采用的编码方式是,在用于说明数据格式的那个字节(也就是被霍夫曼编码的那个字节当中)除了说明后面有几个bit用来表示AC分量之外,还说明有几个连续的零。但是因为其实这个说明符只有一个字节,所以其中的高4位用于表示连续的0⃣️的个数,低4位用于表示AC分量的bit数。这样的话,最多能够表示的连续的0⃣️的个数是15个。
这对于上面这种情况,还是不够省。所以JPEG又规定,如果数据说明字节的值为0,则表示从该位置开始之后到第64个矩阵元素的值都是0。
这就是全部的秘密了吗?
很可惜,虽然已经很复杂了,还有。
因为DCT矩阵的特点就是左上角有值,右下角基本都是0⃣️,所以传统的按行列进行存储的话,会把0⃣️分散在非零的值之间,不能达到最大的压缩率。所以,JPEG当中对于矩阵的存储采用的是ZigZag方式,如下:
图片来源:ISO/IEC 10918-1 : 1993(E)(参考引用*2)
这样可以将矩阵当中的0⃣️串一长串,然后用一个0x 00早早的将这个矩阵结束掉。
也就是说:
如果矩阵全为零,那么一个0x 00就可以代表它。(压缩率1/64)
如果矩阵里面只有稀稀拉拉几个值,那么几个字节就可以代表它。

好了,经过这样眼花缭乱的技巧之后,我们上面的8个字节,就可以展开成为下面这6个矩阵:
这里最后的一个步骤:Y分量的DC部分在块间是存储的差值。也就是说,第二个8×8块的Y(DC)其实应该是第一个8×8块的Y(DC)+第二个8×8块的Y(DC)。也就是说上面第二个块的Y分量矩阵需要修正为 -512 + 1020 = 508
然后将这些8×8矩阵分别用上一篇我们所写的IDCT变换变换回来,得到:(CbCr为全零矩阵,IDCT之后仍然是全零矩阵)
然后将其上浮128(使得值域从[-128 ,127]变为[0, 255]),然后使用我们上一篇所写的YCbCr –> RGB色彩空间变换,得到最终结果如下:
每组数据分别包括R、G、B三个分量
这正是我们用于测试的图片的样子(左边8×8黑色,右边8×8白色)
最后附上完整的程序执行输出,供有兴趣的读者参考:(代码在jpeg分支当中)
*上面的说明当中还省略了被称为Quantization的过程。这个过程是将DCT矩阵的不同元素进行缩放,使得AC的部分尽可能变成绝对值小于1的小数,然后在取整的时候变成0。JPEG之所以是有损压缩,除了色空间变换之后的YUV chrome sub sampling,数据的丢失主要就是发生在这一步。而上面介绍的其他步骤,在不考虑计算误差的情况下,都是可逆的)
 
参考引用

从零开始手敲次世代游戏引擎(JPEG特别篇)-1

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

上传视频封面

 
 
 
 
 
 
 
 
(截屏对象网页:Wikipedia)
好了,让我动手来编写这个过程。既然是8×8的矩阵计算,这又是一个绝好的可以使用并行计算的机会。我们的ispc又可以粉墨登场了。JPEG当中使用的DCT是被称为II型DCT的变种,数学公式如下:
G_{u,v} = frac{1}{4}alpha(u)alpha(v)sum_{x=0}^{7}sum_{y=0}^{7}g_{x,y}cosleft[ frac{(2x+1)upi}{16} right]cosleft[frac{(2y+1)vpi}{16}right]
其中:
  • 0leq{u}<8
  • 0leq{v}<8
  • alpha(u)= begin{cases} frac{1}{sqrt{2}}, 如果 u=0 \ 1, 其它情况 end{cases}
  • g_{x,y}是指原图像的8×8图像块当中第x行第y列的像素值
  • G_{u,v}是指DCT变换后矩阵第u行第v列的值(系数)
用ispc编写这个算法如下:
上面的程序当中用到的几个小技巧:
  1. 一般来说,加法减法比乘法快,乘法比除法快。所以我们通过定义常数的方法,把除数当中的常数在编译的时候进行倒数运算,将除法变成乘法(可能在现代编译器里面这一步编译器自动也会进行。但是我们自己写就更明确一些);
  2. 三元运算符比if else语句快。当然不建议写很复杂的三元运算符,因为那个可读性太低;
  3. 目前ispc还不支持foreach嵌套,所以我们只好用一个uniform类型的变量来桥接两个foreach。
修改我们的Test/GeomMathTest.cpp,在matrix_test()最末尾添加这个新函数的测试代码:(当然还需要修改相应的CMakeLists.txt和geommath.hpp来包含新的source和header)
为了便于比较结果,我直接使用了参考引用*1当中的示例数据。编译执行之后输出如下:
对比参考引用*1当中给出的结果,可以验证我们的计算是正确的。
不过我们需要的其实并不是将一副图片编码为JPEG格式,而是将其从JPEG格式当中恢复出来。所以我们需要的其实是这个变换的逆变换,称为IDCT(inverse DCT):
g_{x,y}=frac{1}{4}sum_{u=0}^{7}sum_{v=0}^{7}alpha(u)alpha(v)G_{u,v}cosleft[frac{(2x+1)upi}{16}right]cosleft[frac{(2y+1)vpi}{16}right]
其中:
  • 0leq{x}<8
  • 0leq{y}<8
  • alpha(u)= begin{cases} frac{1}{sqrt{2}}, 如果 u=0 \ 1, 其它情况 end{cases}
  • g_{x,y}是指重建图像的8×8图像块当中第x行第y列的像素值
  • G_{u,v}是指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分支当中。
 
参考引用

从零开始手敲次世代游戏引擎(三十四)

既然我们已经可以从Blender当中导入模型、光照和摄像机信息,那么我们可以开始尝试导入更多更复杂的模型/场景来测试我们的代码,发现并解决问题。
我是一个码农,虽然学过一点点美术,但是还是没能有啥大的发展。所以我只能依靠网上的资源了。网上有很多同样使用CC协议共享的好的Asset。比如Blend Swap这个网站。很多码农都是萝莉控,所以我们尝试导入ermmus制作的小萝莉(AILI)一只。感谢🙏
首先请注意一下这个Asset的授权信息。是CC-BY,和我们这个系列文章是一样的。所以我在本文开始按照这个授权的要求,提及了制作者,并加入了到原页面的链接。任何原创性活动都是应该得到充分尊重的,因为只有这样,我们才能分享到更多的更精彩的内容。
点击Download下载下来一个ZIP包,解压之后,双击其中的aili_cycle.blend。这会自动启动Blender(前提是你已经安装了它。安装链接在上一篇文章里面有)
按下F12,就可以进行渲染出图了。但是你会很快注意到,裙子和眼睛等都是紫红色的。
紫红色在3D图形渲染领域好像是一个不成文的约定,表示shader的缺失或者不正常。具体到这个例子,如果我们查看裙子的材质,会发现贴图不见了。查看解压后的目录,可以看到虽然有一个名为tex的目录,但是里面是空的;贴图都在项目的根目录下,而不是tex里面。
尝试着将所有贴图移动到tex目录之下,然后重新打开项目,发现问题解决了。按下F12进行渲染。这个渲染是采用的路径跟踪(Path Tracing)的方式,也就是严格按照光学方程进行的离散采样(或者说蒙特卡罗数值求解)过程,要求反复迭代直到收束到一定误差范围之内,计算量是很大的(相对的,效果也是惊艳的)。在我的mac pro上面,渲染出图大约需要40分钟的时间(插上电源的情况。用电池会更长)
我也尝试了使用台式机的GTX1080显卡进行计算渲染,速度方面并没有很大的提升。这其实就是为什么目前在游戏当中还不能大范围采用路径跟踪的渲染方式的原因之一。路径跟踪算法是一个非常典型的基于CPU的算法,在GPU上执行的效率并不高。我所理解的主要原因,一个是因为其复杂的程序分支结构,另外一个是因为其需要双精度(double)的计算精度。一般来说,GPU适合的是直统统的,大量并列重复的,要求精度低的情景。因为GPU相对于CPU来说,其实是在差不多的半导体材料上面,在总晶体管数量一定的前提下,采用“多计算核心”但是“少分支预测”的策略进行设计的。而CPU正好相反。(当然,近年N卡有专门用于计算的双精度专业卡,谷歌也搞了基于FPGA的专用计算ASIC,这个是另外一个事情了)
好了,写完上面这些文字,图也渲染好了。最终效果如下:
AILI: Model by ermmus Creative Commons Attribution 3.0 CC-BY
是不是很不错呢?
不过到这里为止我们并没有做什么。接下来才是正文。
如果我们直接如同文章三十二那样将这个场景导出为OGEX,然后用我们写的引擎进行渲染的话,会发现效果是这样的:
 
 
视频封面

上传视频封面

 
 
小女孩晒黑了,胸(👕)没有了,头发没有了,裙子是透的;天空🀄️浮着不明的黑板子。
回到Blender,我们首先来看看场景的构成:
首先可以注意到场景并没有光源。但是没有光源,为什么在Blender里面可以渲染出那么漂亮的图呢?其实答案就在天空当中的那些板子上。选取Plane,查看它的属性:
看到这个白色的圆了么?这个叫材质球,是用来显示被选中的物体的材质的。注意看球下面的地板,是不是被浅蓝色照亮了呢?再往下看,可以看到Surface被设置为Emission,Color恰恰是浅蓝色。所以,对,这个板子就是光源。
查看另外两个板子,也是类似的设置。
那么为什么在我们的场景当中几乎是黑的呢?我们再来看看实际导出的OGEX文件。用vim打开我们导出的aili.ogex文件,冒头3个Node便是这三个板子。
注意它们的类型:GeometryNode。也就是说,他们是被作为几何体导出的,而不是作为光源。
在Blender的Path Tracing当中,进行的是全局光照计算。所谓全局光照计算,指不仅仅考虑了直接来自光源的光线,还考虑了来自其他非光源物体的光。这主要包括两个方面:
  1. 反射/折射光线
  2. 自发光物体
而在我们的引擎当中(更为准确地说是我们写的basic Shader当中),目前我们只考虑了“光源类型”的光源,而没有考虑来自其他非光源物体的光线,所以在这个特别的场景当中,对我们的引擎来说,就是没有光源。(但是渲染结果当中仍然能够看到有一点光,这是因为我在编写代码的时候,为了方便区分因为场景的引擎缺省补了一个点光源)
至于天空当中的板子出现在画面当中,这是因为我们旋转了画面。Blender里面是固定视角,板子都在视野之外。我们可以通过关闭板子的渲染开关,使其在导出的OGEX当中的Visibility属性为FALSE。并且修改我们的RHI/OpenGL/OpenGLGraphicsManager.cpp,让它忽略Visibility为FALSE的对象来从渲染结果当中去除掉它。(注意这也会导致Blender里面的渲染变暗)
点击Plane右侧的照相机图标使其不参与渲染
由于自发光板子属于面光源。面光源的照射计算比较复杂,我们在后面(https://zhuanlan.zhihu.com/p/38876304 )进行介绍。(但是它的确很好看,因为会产生很柔和的光场和阴影。一般照相馆或者拍电视电影的时候,都会在人物边上支很多白板子,就是这个道理)
接下来我们来看头发。我们可以注意到,在Blender当中,代表头发的节点的左侧的图标是不太一样的:
在Blender当中,三角形图标表示Mesh,而弧线表示这是一个Path。所谓Path,就是一条线段(或者曲线)。它本身是没有体积的。没有体积,那么在渲染结果当中体现不出来,是很正常的。
但是为什么在Blender里面就能看到呢?
头发在Blender当中是有体积的
这其实是因为Blender在显示/渲染的时候,对Path进行了实体化计算。但是其内部实际的数据结构,仍然只是一个Path。我们如果将当前的编辑模式从Object Mode改成Edit Mode,我们就可以看到这些头发的真实面目:
 
视频封面

上传视频封面

 
 
导出到OGEX当中的,正是这些黄绿色/橙色的折线。因此我们在渲染结果当中看不到它们。
解决的方法有两种:
  1. 在我们的引擎当中实现这些Path的实体化计算,就如同Blender那样;
  2. 让Blender将实体化计算的结果保存下来。
这两种方法都是可以实现的。但是对于游戏引擎来说,一般希望尽量轻量(因为我们是软实时系统)。所以一般能在DDC工具当中解决的工作,尽量不要放到引擎当中。除非有别的理由(比如需要做头发飘动的动画。显然移动一根线段比移动一个mesh实体要方便)
这里我们先介绍方法2。用鼠标右键选中头发,然后按下Alt+C,会出现一个弹出式菜单:
在其中选择Mesh from Curve/Meta/Surf/Text之后,就可以将对象以Mesh的形式固定下来。对所有的头发进行这个操作(注意只需要处理名称为hair_*的模型。其它的几个放在头部右侧空中的是用来生成头发的放样曲线,不需要变换)。然后重新导出OGEX。这样我们的渲染结果就变成了这个样子:
 
视频封面

上传视频封面

 
 
可以看到头发的显示已经正常了。但是裙子依然不正常。上半身的裙子完全没有,下半身的裙子是透的。
首先来看下半身裙子的问题。之前也提到过,在3D渲染当中,为了减少无谓的渲染量,会根据表面的方向进行裁剪。即:凡是背对相机的Mesh,都不会进行渲染。
判断一个表面是面对相机还是背对相机的方法是,看这个表面的法线是指向相机的还是指向相反的方向。然而,不见得所有的模型都定义了法线参数,所以实际上GPU是根据多边形(三角形)的顶点顺序来判断表面的朝向的。
当三角形的顶点坐标被变化到摄像机坐标系当中之后,如果顶点出现的顺序是按照逆时针(当摄像机坐标系为右手坐标系)/顺时针(当摄像机坐标系为左手坐标系),那么表面就是面朝摄像机的。反之,则是背离摄像机的。
仔细观察小女孩的裙摆,可以看到我们能看到的是裙子的内表面,看不到的是裙子的外表面。这就说明目前法线是反的。
但是为什么在Blender里面看起来没有问题呢?我们还是来看这条裙子的属性。右键选中裙子,然后选择属性面板当中的Data活页:
 
视频封面

上传视频封面

 
 
我们可以看到有个Double Sided的属性是处于被勾选的状态。这就是原因了。这个属性目前并没有存在于导出的OGEX当中,我们的引擎也没有对应这种双面的材质(事实上,我们的引擎目前还没有对应材质)。
这里让我们首先来修正法线。嗯,能用图说明的不废话:
 
视频封面

上传视频封面

 
 
好了,这样就修正好了。再次导出OGEX,看看效果:
 
视频封面

上传视频封面

 
 
的确修好了。但是上半身仍然是那样。
上半身是不是也是法线问题呢?答案是否定的。因为上半身完全是透的,既看不到正面,也看不到反面。
是不是上半身的数据没有被成功导出呢?我们来看看上半身节点的名字:
“skirt_b”。在我们导出的OGEX当中,通过vim的查找命令“/skirt_b”(如果你用别的图形界面编辑器,一般是Ctrl+F)查找,发现是存在的:
进一步,该节点引用的场景对象“geometry15”,也是存在的:
那么问题在哪里呢?
作为码农,解决问题不能靠猜。我们需要有洞察力。让我们盯着小姑娘看上3小时。。。
 
视频封面

上传视频封面

 
 
看哪里?看胸。。。当然不是,看衣领。嗯。衣领!?
回到Blender,我们可以确认,衣领确实是上裙的一部分:
 
视频封面

上传视频封面

 
 
再回来看我们渲染的结果。如果看得足够仔细,我们会发现其实胸前的领结,以及腰部前方的系带,也都是有的:
 
视频封面

上传视频封面

 
 
再对比我们用Blender渲染的结果,应该不难发现有的都是白色的地方,没有的都是黑色的地方。
我们可以通过将Blender的3D视图显示模式从“Solid”调整为“Material”来确认:
 
视频封面

上传视频封面

 
 
进一步确认的话,我们可以观察到上裙其实引用了两种材质:
所以呢?其实我们需要回到从零开始手敲次世代游戏引擎(二十八)
事实上,对于复杂的模型,其往往也是包含了到不同材质的引用。比如一个人物的模型,其裸露的脸部和身上着衣的部分的材质就很可能是不同的。况且考虑到动画的需要,我们需要将模型分割为可动的部分和不可动的部分。我们在用maya或者3dmax等DCC工具进行建模的时候,往往会将顶点进行分组,指定不同的材质或者子材质,这些都是很自然很好的分割依据。
我们再仔细看看导出的OGEX文件,在代表上裙的geometry15当中:
有两个IndexArray!
而我们目前的代码是(RHI/OpenGL/OpenGLGraphicsManager.cp):
注目这一行:
知道问题在哪里了吧。我们现在是写死的,每个Mesh只渲染第一个顶点数组。
我在now the aili mesh can be properly rendered · netwarm007/GameEngineFromScratch@8a1a44f 当中修正了这个问题。同时也修正了缺省的光源在模型身后的问题。最终的效果如下:
 
视频封面

上传视频封面

 
 
另外,在这个过程当中我发现Windows版本在渲染复杂模型的时候会Crash。通过两天的调试,发现这是因为我们的SceneObject当中对于顶点Buffer尺寸的计算不正确,导致将顶点数据从主内存拷贝到GPU显存的时候访问了实际上并没有初始化的内存导致的。这个问题在macOS版以及Linux版上以极低的概率出现,而Window上100%出现。进一步调查发现上因为我们现在编译的是Debug版,用Visual Studio编译的Debug版的Windows程序会对分配的内存前后未使用的空间填充cdcdcd这样的调试内容,当这个内容被解释为指针地址的时候,就会出现保护错误。而macOS和Linux不会进行这样的填充,所以,依据执行时内存上残留的数据情况,可能crash可能不crash。
(如果记得我们之前写的内存管理模块的话,在Debug模式的时候,我们也是会对未分配的空间进行这样的填充的。只不过目前我们还没有对接内存管理模块。这也从一方面证明了在跨平台开发当中写自己的内存管理模块的好处)
 
#P.S.
啊,忘记了一个比较重要的事情。我们之前场景文件的路径都是Hard Coding到main.cpp里面的。现在我们场景资源比较多了,这显然不是什么好事情,所以从这篇开始我们的代码是通过启动命令行参数加载场景文件的。比如要加载aili,需要这样运行(场景文件根目录固定在Asset目录下,所以文件路径是相对于Asset目录的相对目录):
 
参考引用:

从零开始手敲次世代游戏引擎(三十三)

本篇我们来实现场景图的计算。
首先我们需要重构一下我们的SceneNode和SceneObject,让我们的GraphicsManager能够比较方便地获取场景几何体的空间位置。
相关的调整代码在article_33分支的 commit baefd06a 当中。
执行的效果如下:
 
 
 
 
视频封面

上传视频封面

 
 
 
 
可以看到几何体不再重合在一起了。但是几何体的位置与我们在Blender里面设置的不同。这是因为我们没有采用Blender当中设置的相机进行渲染。(Blender采用的是右手坐标系且Z轴垂直向上)
于是接下来我们进行相机的导入,并使用0号相机(第一个相机)输出画面。相关的代码在article_33分支当中通过 commit c6b39c2ab提交。执行的效果如下:
 
 
视频封面

上传视频封面

 
 
 
 
在执行这一步的时候,我遇到的主要的难关是Blender所导出的相机的矩阵是相机本身的平移与旋转矩阵(位置矩阵)。这个矩阵与View Matrix(就是将场景物体从世界坐标系变换到相机坐标系的矩阵)的关系是求逆的关系。因此我需要在我们之前写的数学库(GeomMath)当中添加矩阵求逆的函数。(参考引用*1)
在使用ispc进行矩阵求逆的函数编写的时候,遇到了下面这么一个问题:
在上面这段代码当中,foreach_tiled当中计算的inv[]矩阵,在foreach_tiled循环体当中看的时候(打印输出log)是正确的,但是到了下一个foreach当中,内容就变成随机的了。调查了一段时间之后,发现是因为inv[]矩阵未被申明为uniform类型。也就是说,按照上面这个写法,其实每个并行计算的instance都会有一个inv[16],其中只有一个数组成员会被赋值,其他的都是随机内容(未初始化)。而后面的foreach因为gang(就是单次并行运算所包括的instance数量)与前面的不同,这个inv似乎会重新分配,内容就更加不可预测了。
解决的方法很简单,因为我们实际是想让各个并行计算线程共享inv[],每个线程计算填写16个值当中的一个,所以只要把inv[]申明为uniform就可以了。
当然,如果觉得并列化计算不好理解或者不想用ispc,完全可以用纯粹的c/c++编写。方法参照(参考引用*2)
在编写数学相关的函数的时候,我们往往需要验证结果是否正确。这可以通过对比外部计算工具的结果来实现。矩阵相关的常用的有matlab,但是这个是个商业软件。Wolfram Alpha(参考引用*5)是一个强大的计算网站,如果你知道该怎么用英文描述你的数学问题,那么它能够帮你做完差不多所有的高数题,包括具体的解题步骤 ( ‘ω’ )
接下来是导入光照。我们这里首先导入经典的Phong全局照明模型。(参考引用*4)
 
图片来源:Wikipedia(参考引用*4)
 
为了实现Phong光照,我们首先需要从Blender导出的OGEX当中导入光照参数,然后将这个参数传递给Shader。然后我们在Shader当中需要根据Phong模型的公式进行计算。commit f3f66a6加入了Phong的Defuse(漫反射)计算部分,而commit a3844920加入Phong的Specular(高光)计算部分。调整后的PS Shader代码如下:(注意VS Shader也有小范围修改,主要是导出未经透视变化的摄像机坐标系当中的顶点位置,用来在PS Shader当中确定入射光和反射光方向)
下图是只有漫反射的效果:
 
 
视频封面

上传视频封面

 
 
 
 
下图是加了高光的效果(高光因子0.01)
 
视频封面

上传视频封面

 
 
 
 
(高光因子0.1)
 
视频封面

上传视频封面

 
 
 
 
高光因子1.0
 
 
视频封面

上传视频封面

 
 
 
 
可以看到有比较强烈的金属质感了。
漫反射系数0.5高光0.01
 
 
视频封面

上传视频封面

 
 
 
 
比较强烈的橡胶质感。
对比题图(用Blender渲染),主要少了阴影,以及光的衰减。这是我们接下来的课题。
(注:所有截屏为macOS版,但是Windows/Linux版能够输出同样画面)
 
参考引用: