文章目录
About URasterizer
去年学习完GAMES101之后,一直想着自己动手把GAMES101的理论知识再实际操练一遍。恰好又了解到Unreal5的Nanite使用了软光栅提高微小三角形的光栅化性能,那么学习研究光栅化这个古老的幕后技术貌似在新时代又有了新的意义。所以从今年1月份开始,利用了春节假期的大部分时间以及两个月来的夜晚和周末,在Unity上实现了一套实验性的软光栅渲染器:URasterizer。
为什么要在Unity上实现呢?有几个原因。
- 首先,Unity提供了很多基础设施,如载入模型贴图材质,相机、灯光以及Game Object的控制,甚至还有动画什么的,都可以直接利用,大大节省了自己写框架的时间。
- 第二,可以和Unity直接渲染的效果以及性能做对比。通过Unity检验光栅化的正确性。
- 第三,Unity使用Compute Shader很方便,使用Compute Shader实现软光栅也是计划之一,正好可以顺便练习一下CS的使用。
- 第四,Unity尽管使用C#代码写光栅化没有C++效率高,但是Unity的Job System和Burst Compiler很好用,正好借这个机会体验一下CPU多核计算的威力。
包含哪些内容
URasterizer有三个独立的光栅化渲染器:
- CPU Rasterizer :完全使用C#编写,在CPU上实现的单线程软件光栅化
- CPU Job Rasterizer: 使用Unity的Job System和Burst Compiler,使用多个处理器核心执行经过编译优化的光栅化代码,性能大幅提升。
- GPU Driven Rasterizer: 使用Compute shader实现整个光栅化流水线。GPU Driven模式的性能和直接使用Unity渲染相当。
具体支持的特性见项目github仓库的README
关于本系列总结文章
- 首先,我们要建立框架,让软光栅能跑起来并输出最终结果。我们需要通过相机参数和Transform构建我们自己的矩阵,并且要从Unity获取到各种数据。Unity的坐标系约定和GAMES101并不一致,因此需要转换数据。本篇中会总结这部分内容,让Unity为我们所用。
- 第二篇中,总结光栅化以及相关算法的实现,以及使用C#实现时一些需要注意的优化。
- 第三篇中,介绍使用Job System和Burst Compiler加速我们的光栅化代码。
- 第四篇中,介绍使用Compute Shader在GPU上执行整个软光栅流水线。
好了,下面开始正文。
框架搭建
关于Unity版本
我使用的是Unity 2020.3.25f1,使用了内置的流水线。实际上,并没有使用特别新的Unity功能。因此更老或更新的Unity版本都可以。
渲染目标和RawImage
为了软光栅最终的渲染结果能显示出来,首先要解决的是渲染目标是什么。对于CPURasterizer,我使用一个普通的Texture2D作为渲染目标,将整个颜色缓冲区通过SetPixels方法设置到Texture2D中。对于GPURasterizer,最终会渲染到一个RenderTexture中。为了展示这些Texture,我使用了一个RawImage,这是个UI对象,放到Canvas下面。RawImage包含了一张Texture,我只要给它设置一个Texture就可以显示Texture的内容。这就是我们的渲染目标,兼容Texture2D和RenderTexture,完美。
CameraRenderer和RenderingObject
虽然我们要实现的是光栅化渲染器,但是首先要让渲染器的代码能跑起来。CameraRenderer就是驱动渲染器的核心部分。
首先是Init方法。
void Init()
{
_camera = GetComponent<Camera>();
var rootObjs = this.gameObject.scene.GetRootGameObjects();
renderingObjects.Clear();
foreach(var o in rootObjs)
{
renderingObjects.AddRange(o.GetComponentsInChildren<RenderingObject>());
}
Debug.Log($"Find rendering objs count:{ renderingObjects.Count}");
RectTransform rect = rawImg.GetComponent<RectTransform>();
rect.sizeDelta = new Vector2(Screen.width, Screen.height);
int w = Mathf.FloorToInt(rect.rect.width);
int h = Mathf.FloorToInt(rect.rect.height);
Debug.Log($"screen size: { w}x{ h}");
_cpuRasterizer = new CPURasterizer(w, h, _config);
_jobRasterizer = new JobRasterizer(w, h, _config);
_gpuRasterizer = new GPURasterizer(w, h, _config);
_lastRasterizer = null;
_statsPanel = this.GetComponent<StatsPanel>();
if (_statsPanel != null) {
_cpuRasterizer.StatDelegate += _statsPanel.StatDelegate;
_jobRasterizer.StatDelegate += _statsPanel.StatDelegate;
_gpuRasterizer.StatDelegate += _statsPanel.StatDelegate;
}
}
- 顾名思义,CameraRenderer需要获取到一个Camera才能渲染。
- 然后会收集场景中所有的可渲染物体(RenderingObject)。是的,为了能让Unity的GameObject可以被我们渲染,使用RenderingObject脚本进行标记,并且RenderingObject还会管理渲染器所需要的数据。
- 之后我们根据屏幕的尺寸去设置RawImage的大小,这样就可以让RawImage和Unity显示窗口一致,便于我们比较渲染结果。
- 这之后初始化我们的3个渲染器:为了能在运行时动态切换,会将所有的渲染器实例化出来
然后是Render()方法,它被放置到OnPostRender系统回调中执行。这个方法就是驱动渲染器的核心了。
void Render()
{
ProfileManager.BeginSample("CameraRenderer.Render");
switch(_config.RasterizerType){
case RasterizerType.CPU:
_rasterizer = _cpuRasterizer;
break;
case RasterizerType.CPUJobs:
_rasterizer = _jobRasterizer;
break;
case RasterizerType.GPUDriven:
_rasterizer = _gpuRasterizer;
break;
}
if(_rasterizer != _lastRasterizer){
Debug.Log($"Change Rasterizer to { _rasterizer.Name}");
_lastRasterizer = _rasterizer;
rawImg.texture = _rasterizer.ColorTexture;
_statsPanel.SetRasterizerType(_rasterizer.Name);
}
var r = _rasterizer;
r.Clear(BufferMask.Color | BufferMask.Depth);
r.SetupUniforms(_camera, _mainLight);
for (int i=0; i<renderingObjects.Count; ++i)
{
if (renderingObjects[i].gameObject.activeInHierarchy)
{
r.DrawObject(renderingObjects[i]);
}
}
r.UpdateFrame();
ProfileManager.EndSample();
}
}
为了参考,同时看一下渲染器接口:
public interface IRasterizer
{
string Name { get; }
void Clear(BufferMask mask);
void SetupUniforms(Camera camera, Light mainLight);
void DrawObject(RenderingObject ro);
Texture ColorTexture { get; }
void UpdateFrame();
void Release();
}
- 首先,会在渲染器切换(或第一次设置)时,将渲染器的ColorTexture设置给RawImage.
- 然后再渲染开始,执行Clear
- 之后我们设置渲染器需要的uniform参数,主要是camera和灯光。
- 之后我们遍历所有的Rendring Object,对于所有场景中可见的物体进行绘制。
- 最后我们调用UpdateFrame,刷新我们的Texture。对于GPU Rasterizer,因为是直接绘制到RenderTexture上的,所以这个函数基本没做什么。
RenderingObject和RenderObjectData
对于不同的Rasterizer,我们需要缓存不同的数据,有的是数组的形式,有的是NativeArray,有的是ComputeBuffer。这些数据放到各自的RenderObjectData中。而为了动态切换渲染器,RenderingObject包含了所有三种RenderObjectData。此外,由于RenderingObject是挂在GameObject身上的,因此它知道GameObject的transform,模型和材质, 所以它还负责计算GameObject的模型变换矩阵以及获取模型和材质信息。目前材质部分仅仅获取了一张diffuse贴图,因为最终渲染的shader是我们自定义的,目前只有BlinnPhong材质,理论上可以实现各种材质,只要你愿意,实现PBR也没问题,只要通过RenderingObject从Unity材质中获取各种贴图和参数,然后在自己的渲染器Shader函数中使用即可。由于Shader和渲染效果不是本项目的重点,因此只实现了一个简单的BlinnPhong。
矩阵计算
URasterizer自己计算了模型,视图和投影矩阵,基于GAMES101的约定,但是从Unity获取了camera和transform参数。由于Unity的本地和世界坐标都是左手系,所以必然在获取参数时要进行一些转换。基本就是对于坐标和向量,将z坐标取反。对于旋转,也要将欧拉角的值取反。
视图和投影矩阵计算
矩阵计算相关的算法都放到TransformTool这个类中,其中计算视图和投影矩阵的函数如下:
public static void SetupViewProjectionMatrix(Camera camera, float aspect, out Matrix4x4 ViewMatrix, out Matrix4x4 ProjectionMatrix)
{
//左手坐标系转右手坐标系,以下坐标和向量z取反
var camPos = camera.transform.position;
camPos.z *= -1;
var lookAt = camera.transform.forward;
lookAt.z *= -1;
var up = camera.transform.up;
up.z *= -1;
ViewMatrix = TransformTool.GetViewMatrix(camPos, lookAt, up);
if (camera.orthographic)
{
float halfOrthHeight = camera.orthographicSize;
float halfOrthWidth = halfOrthHeight * aspect;
float f = -camera.farClipPlane;
float n = -camera.nearClipPlane;
ProjectionMatrix = GetOrthographicProjectionMatrix(-halfOrthWidth, halfOrthWidth, -halfOrthHeight, halfOrthHeight, f, n);
}
else
{
ProjectionMatrix = GetPerspectiveProjectionMatrix(camera.fieldOfView, aspect, camera.nearClipPlane, camera.farClipPlane);
}
}
视图矩阵计算
URasterizer是基于GAME101的约定的,因此各坐标空间都是右手系(包括NDC),且视图空间中camera是看向-z轴。
而Unity的本地和世界坐标都是左手系,因此我们从camera.transform获取到的坐标和方向都要进行转换。转换的方法很简单,就是将z轴取反。然后使用GetViewMatrix计算视图矩阵。代码如下:
public static Matrix4x4 GetViewMatrix(Vector3 eye_pos, Vector3 lookAtDir, Vector3 upDir)
{
//这儿lookAtDir取反是因为我们使用的view space默认camrea看向(0,0,-1),因此lookAt会被对应到(0,0,-1)
//那么-lookAt就对应到(0,0,1)
//我们构造的旋转矩阵是将(0,0,1)变换到-lookAt,其逆矩阵就是将-lookAt变换到(0,0,1)
Vector3 camZ = -lookAtDir.normalized;
Vector3 camY = upDir.normalized;
Vector3 camX = Vector3.Cross(camY, camZ);
camY = Vector3.Cross(camZ, camX);
Matrix4x4 matRot = Matrix4x4.identity;
matRot.SetColumn(0, camX);
matRot.SetColumn(1, camY);
matRot.SetColumn(2, camZ);
Matrix4x4 translate = Matrix4x4.identity;
translate.SetColumn(3, new Vector4(-eye_pos.x, -eye_pos.y, -eye_pos.z, 1f));
Matrix4x4 view = matRot.transpose * translate;
return view;
}
URasterizer中直接使用了Unity的Matrix4x4,但是并没有使用Unity的方法构造矩阵,只是将Matrix4x4作为一个容器。 根据我们使用的约定,矩阵乘以向量时,向量在乘法右边。这儿计算的matRot是将标准坐标轴变换到camera的朝向的旋转矩阵,所以只要将变换后的3个轴填入矩阵的3个列中即可。然后使用其逆矩阵连接一个平移矩阵,就得到了视图矩阵。注意这儿传入的坐标和方向,已经在上一步中转换到了右手系。
然后我们看投影矩阵的计算。
平行投影矩阵
从Unity的camera中可以很方便的获取到参数,比如 orthographicSize,意义为投影面高度的一半(对于平行投影,前后剪裁面是一样大的),根据aspect可计算出宽度的一半,然后使用GetOrthographicProjectionMatrix计算矩阵:
//根据ViewSpace下的frustum剪裁面坐标计算正交投影矩阵。
//ViewSpace使用右手坐标系,camera看向-Z轴。
//所有参数都是坐标值。因此f和n都是负数,且 f < n
public static Matrix4x4 GetOrthographicProjectionMatrix(float l, float r, float b, float t, float f, float n)
{
Matrix4x4 translate = Matrix4x4.identity;
translate.SetColumn(3, new Vector4(-(r + l) * 0.5f, -(t + b) * 0.5f, -(n + f) * 0.5f, 1f));
Matrix4x4 scale = Matrix4x4.identity;
scale.m00 = 2f / (r - l);
scale.m11 = 2f / (t - b);
scale.m22 = 2f / (n - f);
return scale * translate;
}
由于这个函数的约定中,参数都是坐标值,而camera是看向-z轴的,因此传入的f和n要将camera的farClipPlane和nearClipPlane值取负。而l,r,b,t则根据前面获取到的半宽和半高计算。
透视投影矩阵
透视投影矩阵使用fov加aspect的方式接受参数,正好可以从camera获取到这些参数:
//根据FOV等参数计算透视投影矩阵。fov为fov y, aspect_ratio为宽/高,zNear,zFar为距离值(正数)
public static Matrix4x4 GetPerspectiveProjectionMatrix(float eye_fov, float aspect_ratio, float zNear, float zFar)
{
float t = zNear * Mathf.Tan(eye_fov * D2R * 0.5f);
float b = -t;
float r = t * aspect_ratio;
float l = -r;
float n = -zNear;
float f = -zFar;
return GetPerspectiveProjectionMatrix(l, r, b, t, f, n);
}
//根据ViewSpace下的frustum剪裁面坐标计算透视投影矩阵。
//ViewSpace使用右手坐标系,camera看向-Z轴。
//所有参数都是坐标值。因此f和n都是负数,且 f < n
public static Matrix4x4 GetPerspectiveProjectionMatrix(float l, float r, float b, float t, float f, float n)
{
Matrix4x4 perspToOrtho = Matrix4x4.identity;
perspToOrtho.m00 = n;
perspToOrtho.m11 = n;
perspToOrtho.m22 = n + f;
perspToOrtho.m23 = -n * f;
perspToOrtho.m32 = 1;
perspToOrtho.m33 = 0;
var orthoProj = GetOrthographicProjectionMatrix(l, r, b, t, f, n);
return orthoProj * perspToOrtho;
}
透视投影计算使用了GAMES101介绍的挤压法。其实这里为了效率可以将矩阵计算好了直接填入,不过为了展示还是保留了过程。
模型变换矩阵的计算
如上所述,模型变换矩阵在RenderingObject类中计算,因为RenderingObject可以获取到所在GameObject的transform。
// TRS
public Matrix4x4 GetModelMatrix()
{
if(transform == null)
{
return TransformTool.GetRotZMatrix(0);
}
var matScale = TransformTool.GetScaleMatrix(transform.lossyScale);
var rotation = transform.rotation.eulerAngles;
var rotX = TransformTool.GetRotationMatrix(Vector3.right, -rotation.x);
var rotY = TransformTool.GetRotationMatrix(Vector3.up, -rotation.y);
var rotZ = TransformTool.GetRotationMatrix(Vector3.forward, rotation.z);
var matRot = rotY * rotX * rotZ; // rotation apply order: z(roll), x(pitch), y(yaw)
var matTranslation = TransformTool.GetTranslationMatrix(transform.position);
return matTranslation * matRot * matScale;
}
计算模型矩阵,我们必须遵守Unity的约定,使用TRS。并且旋转应用的顺序为 ZXY。因为只有这样才能使用Unity的动画和编辑器Gizmos来移动旋转缩放物体。构造缩放矩阵时需要使用transform.lossyScale才是全局缩放。构造旋转矩阵时要注意,由于手向性的差异,我们仍然要处理旋转的方向,因为左手系和右手系的旋转方向是相反的,另外由于z坐标本身也要取反,所以最终旋转欧拉角的x和y值取反,z不变。构造平移矩阵时,是在内部处理z的取反的。(总结的时候我感觉应该还是统一在外部处理)
其实Unity在内部是使用四元数保存旋转的,这里将四元数转换成欧拉角,然后再构造成旋转矩阵似乎有点麻烦了。应该直接从四元数构造出旋转矩阵。这个留着以后再改吧。主要GAMES101没讲四元数啊。另外旋转矩阵的实现是绕任意轴旋转矩阵,但是目前使用的情况,其实只要分别构造沿3个坐标轴旋转的矩阵即可。我在某引擎代码里看到绕任意轴矩阵的实现时会判断是否是坐标轴,如果是就直接构造,这也是一种优化。同样构造旋转矩阵的函数也是保留了过程,没有直接写结果。
模型数据的手向性转换
我们知道Unity在世界空间使用的是左手坐标系,而GAMES101在所有空间都使用右手坐标系。所以我们从Unity中获取到的Mesh的坐标法线等都要做转换才能使用,并且三角形的环绕方向也要做转换。转换的方法都一样,但转换的位置不同,为了使用数据更方便,有时转换会在Shader函数中进行。这里我仅以CPURasterizer为例。
模型坐标和法线转换
很简单,z值取反,例如CPURasterizer是在做vertex shader时进行处理:
//Unity模型本地坐标系也是左手系,需要转成我们使用的右手系
//1. z轴反转
//2. 三角形顶点环绕方向从顺时针改成逆时针
// Vertex shader相关代码(由于是CPU上的软件渲染,这儿都是C#代码)
var vert = ro.cpuData.MeshVertices[i];
var objVert = new Vector4(vert.x, vert.y, -vert.z, 1); //注意这儿反转了z坐标
vsOutput[i].clipPos = mvp * objVert;
vsOutput[i].worldPos = _matModel * objVert;
var normal = ro.cpuData.MeshNormals[i];
var objNormal = new Vector3(normal.x, normal.y, -normal.z);//同样法线也反转了z坐标
vsOutput[i].objectNormal = objNormal;
vsOutput[i].worldNormal = normalMat * objNormal;
三角形环绕方向修改
由于三角形是使用3个索引值表示的,而Unity模型本地坐标是左手系,因此正面是顺时针环绕,我们这儿要改成逆时针。
CPURasterizer是在图元集成阶段做的。对于一个三角形的3个顶点v0,v1,v2,只要对调了v0和v1的索引即可。即原来的 0,1,2是顺时针的,对调后是 1,0,2是逆时针的。
var indices = ro.cpuData.MeshTriangles;
for(int i=0; i< indices.Length; i+=3)
{
/// -------------- Primitive Assembly -----------------
//注意这儿对调了v0和v1的索引,因为原来的 0,1,2是顺时针的,对调后是 1,0,2是逆时针的
//Unity Quard模型的两个三角形索引分别是 0,3,1,3,0,2 转换后为 3,0,1,0,3,2
int idx0 = indices[i+1];
int idx1 = indices[i];
int idx2 = indices[i+2];
//使用调整后的索引获取三角形三个顶点
v[0] = vsOutput[idx0].clipPos;
v[1] = vsOutput[idx1].clipPos;
v[2] = vsOutput[idx2].clipPos;
}
GPURasterizer中是在构造RenderingObject数据时做的调整,可能更清晰些:
//初始化三角形数组,每个三角形包含3个索引值
//注意这儿对调了v0和v1的索引,因为原来的 0,1,2是顺时针的,对调后是 1,0,2是逆时针的
//Unity Quard模型的两个三角形索引分别是 0,3,1,3,0,2 转换后为 3,0,1,0,3,2
var mesh_triangles = mesh.triangles;
int triCnt = mesh_triangles.Length/3;
Vector3Int[] triangles = new Vector3Int[triCnt];
for(int i=0; i < triCnt; ++i){
int j = i * 3;
triangles[i].x = mesh_triangles[j+1];
triangles[i].y = mesh_triangles[j];
triangles[i].z = mesh_triangles[j+2];
}
小结
本篇中介绍了URasterizer的框架,他支持多个渲染器,以及渲染器的实时切换。介绍了如何使用Unity提供的数据构造矩阵,以及对Unity模型进行手向性转换。下一篇中将介绍纯C#实现的软件光栅化渲染器,基本就是对GAMES101光栅化相关理论的复现。由于是c#实现,还要注意性能的优化。