屏幕空间(SreenSpace)的想象力

在写这篇文章时,我特意去Wiki上搜了一下ScreenSpace, 可能是由于太过直白的缘故,并没有找到标准的定义。

不过他的定义是显而易见的,屏幕空间的所有的信息都是与屏幕上的像素有关的,而不是和场景中的几何有关的信息都叫屏幕空间,这一点其实很像是Pixel和Fragment的区别。pixel是定义在屏幕空间上的,而Fragment是定义在三维空间上的。

举个最简单的例子,我们从相机原点射出一条射线,然后穿过两个不透明物体。这两个交点,在进行光栅化时就是2个Fragment, 但是最终渲染到屏幕上最终只会有一个Fragment被采用,而屏幕空间就是最终被采用的Fragment的集合。


显然深度纹理属于屏幕空间。

以前,我一直觉得深度缓存只能用来做ZTest。然而大神们分分钟教我做人。

在光线追踪算法下,我们可以这样生成深度图,将深度图放在相机的近平面。

然后相机原点对深度图上所有像素都发出射线和场景中物体相交,并把首次相交的物体的Fragment,在相机空间下的Z坐标写入深度图。

下面这张图片更详细的展示了光线追踪的细节。

在光栅化算法下,生成的深度图结果和光线追踪一模一样,但显然光线追踪的算法更清晰并易于理解。

从光线追踪算法来看,给我们一个指定分辨率的深度图和相机近平面距离,我们就可以完全还原出来生成深度图用到的所有射线。

我们还知道,深度图上每个像素上的深度值,都是从相机原点到这个像素发出的射线与场景物体相交的点产生的。

反过来说就是,影响像素A的深度值Z_A所在的Fragment_A(3D坐标下的点),一定在从相机原点出发到像素A的射线Ray_A上。

我们来看看,有了这些信息,我们都能求出哪些额外的信息。

RayA*Z_A = Fragment_A在相机空间下的位置ViewPosition_A

mul(inverse(V), ViewPosition_A) = Fragment_A在世界坐标下的位置WorldPosition_A

我们甚至可以通过mul(inverse(M), WorldPosition_A)将Fragment_A变换到任意模型ModelPosition_A。

来看看我们能利用这些还原的信息做什么吧。


深度贴花:

在很久以前, 我曾经执迷于贴花,最终学会了使用Mesh来贴花,但是利用深度信息,我们可以更便捷的做到。

我们假设要贴的花是一张平面贴纸,我们有这个纸片的MVP矩阵。

根据上面的推导,我们可以将深度图上的任意一个像素的Fragment转换到贴纸的模型空间中来。

然后我们根据这个Fragment在贴纸的模型空间中的坐标X,计算出需要采样的uv。

假设整个贴花的大小是(x,z) = (-0.5,-0.5)~(0.5,0.5), 那么uv=X.xy + (0.5,0.5)。当然需要clip掉uv.xy < (0,0)和uv.xy >(1,1)的坐标。

最后根据计算出的uv直接采样纹理,整个贴花就完成了。


全局雾效:

有了深度图,我们可以重新计算出每一个像素的Fragment在相机空间下的位置ViewPosition。

记受雾影响的最大和最小距离为 d_{\tiny min}d_{\tiny max}

假设雾的浓度和高度有关的,一种简单的雾效计算公式为。

f = \frac{d_{\tiny max} - ViewPosition.y}{d_{\tiny max}-d_{\tiny min}}

float3 afterFog = f fogColor + (1 – f) oriColor;


SSAO:

在SSAO的实现里, 我们甚至都不需要去重新计算坐标信息。

仅通过采样一个像素周围的平均深度,就可以来近似计算一个遮蔽关系。


虽然深度图可以计算出来很多信息,但是还是有很多信息是计算不出来,比如法线信息。

在我们写光栅化时,都会有这样一个经历,在有多光源的情况下,我们的代码和下面代码很相似。

for l in lights do
    for m in meshes do
        render(l,m)
    end
end

没错,这就是传说中的ForwardBase Lighting。在上述代码中,每多增加一个光源,我们就需要把所有Mesh重新渲染一遍,如果光源非常多的话,这种开销几乎是不可承受的。

聪明的大神们发现了一个现象,还是光线追踪的思路。当我从相机原点到成像纹理的像素发出射线时,只有第一个与射线相交的场景中的Fragment才会被采用,后面的Fragment在后来做ZTest时都会被丢弃, 即然这样,我只对屏幕空间中的Fragment计算光照就可以了。

这种思路在有复杂遮挡关系的场景中,优化效果是惊人的。那具体应该怎么做呢?

一般是通过两个Pass来做的。

在第一趟Pass中,Shader会计算所有Fragment的采样颜色和法线。并将颜色和法线分别写入两张Texture中。由于在Fragment Shader在输出结果时,会做ZTest, 因此只有离相机最近的Fragment才有资格写入纹理。这一步的目的就是为了减少计算光照的Fragment。

在第二趟Pass中,Shader会根据纹理中的每一个像素所包含颜色和法线信息来对所有光源进行计算。

整个计算流程大概如下:

--First Pass
for f in fragments do
    if depth_test(f) do
        write_diffuse(f, texture2D(f.uv, tex))
        write_normal(f, f.normal)
    end
end

--Second Pass
for f in fragments_in_screen_space do
    c = float3(0)
    for l in lights do
        c += render(l, f)
    end
    write(f, c)
end

这就是大鼎鼎的Deferred Lighting。


你以为这就是大神极限了么,大神会立马再教你做人。

即然光照可以通过两趟Pass的方式来优化,那我可不可以用来加速光线追踪呢?

答案是肯定的, 由于屏幕空间上的信息量相比整个场景来讲少之又少,在计算光线相交时,可以更快的判定。当然同样由于信息量过少, 所以会有这样或那样的瑕疵,不过相比效果来讲,瑕疵反而不是微不足道的。

没错,这就是传说中的SSR(ScreenSpaceReflection)。其实就是在屏幕空间下进行RayTracing, 是不是叫ScreenSpaceRayTracing更好一些:D。

一些对辐射度量学的理解

随着硬件性能的提升,PBS/PBR已经越来越成为一种趋势。

Unity内置的standard shader都已经默认使用PBR算法。

而学习PBR过程中,有一门必提的知识是“辐射度量学”。

本来我以为我都学会了,毕竟path tracer都写出来了,并且效果看起来很正常。

但是最近在学习全局光照时,突然对“辐射度量学”突然产生了两个疑惑。

  1. 光线在传播过程中,Radiance是如何体现出,能量随着距离增加而衰减的?

    回忆在写path tracing时,代码并没有刻意去计算距离对光线能量的衰减。

    而从数学公式上看,在计算光线弹射时,反射方程的输入和输出都是Radiance。

    某一单位表面弹射出的某一个方向上的光线A的Radiance值,会在计算被光线A击中物体表面时直接使用。

    那么衰减到底在哪里体现的呢?

  2. 为什么一个物体表面弹射出的光线的Radiance,可以直接被光线击中的物体直接拿来计算?

    先看一下定义:

    入射Radiance, 单位面积单位立体角接收到的flux(功率)。 L(p,\omega)=\frac{dE\left(p\right)}{d\omega\cos\theta}

    出射Radiance, 单位面和单位立体角发出的flux(功率)。 L(p,\omega)=\frac{dI\left(p,\omega\right)}{dA\cos\theta}

    从定义上看,入射Radiance使用的是接收面接收方向的立体角,而出射Radiance是出射面出射方向上的立体角。

    那么,凭什么一个物体表面的出射光线Radiance可以直接被照射物体用来计算,而且还可以随距离智能衰减?


是的,所有答案的问题指向立体角,是立体角将他们神奇的联系到一起的。

先复习一下,立体角的定义:d\Omega=\frac{dA}{r^2}


然后回答第一个问题(这里仅考虑 单位表面与光线垂直的情况,所以去掉了cos项):

假设在平面DE上有一个单位表面A发光表面B发出了一条光线照向A。

根据Irradiance的定义,dE(p)\;=L(p,\omega)\;\ast\;d\omega

代入立体角公式就可以得出,单位表面A接收由表面B照射过来的功率为(HG表面积)/(HG离表面A的距离) * Radiance。如果将表面B由HG移动到JI位置,由于JI离表面A更远,单位表面A接收到的单位表面B照射出的功率更小。

那么path tracer是如何利用这一性质的呢,想象一下,从单位表面A发出720根射线,离发光表面B离表面A越近,被表面A的射线击中的次数就会越多,表面A就接收到表面B的能量就越多。


在回答问题1时,假定了第2个问题是毫无疑问的。但是入射Radiance和出射Radiance为什么一定是一样的呢?

我们先来化简一下入射Radiance出射Radiance的公式。

入射Radiance, L(p_2,\omega_2)=\frac{d^2\phi(p_2,\omega_2)}{d\omega_2dA_2\cos\theta_2}

出射Radiance, L(p_1,\omega_1)=\frac{d^2\phi(p_1,\omega_1)}{d\omega_1dA_1\cos\theta_1}

可以看到入射Radiance和出射Radiance公式完全一样,只不过作为接收平面,他使用是接收平面的面积,立体角,法线与光线的夹角。而作为出射平面,使用的是另一组参数而已。

到止前为止接收Radiance出射Radiance仅仅是公式相同而已,没有任何迹象表明他们会是同一个值。

借用《Fundamentals of Optics and Radiometry for Color Reproduction》中的一张图,来解释这奇迹的变换。

假设P1点所在的微平面ds1发出一条光线\xrightarrow[{P_2P_1}]{}照向点P2所在的平面ds2。

由于ds1在立体角d\omega_1方向上发出的能量被ds2全部吸收,所以\phi(p_2,\omega_2) = \phi(p_1,\omega_1)

根据立体角的定义:

d\omega_2dA_2\cos\theta_2

= \frac{ds_1\cos\theta_1}{r^2}dA_2\cos\theta_2

= ds_1\cos\theta_1\frac{dA_2\cos\theta_2}{r^2}

= ds_1\cos\theta_1d\omega_1

可以得出接收Radiance出射Radiance是完全相同的量。

嗯,这就是大名鼎鼎的radiance invariance

ps. 在研究这两个问题过程中,我还有另外一个疑惑,不过这个疑惑随着这两个问题的解决一起解决了。这个问题就是“对于一个微小平面dA, 他在整个单球上发射出的radiance是均匀(完全相等)的么?答案显然否定的,因为dA在整个半球上发射出的I是相同的,根据radiance公式,radiance必然不是均匀的。”

pps. 但是根据Lambert’s Law的漫反射模型,I_\theta=I_n*\cos\theta,也就是说物体跟光线的夹角会影响接收到的I_\theta,代入Radiance公式后,radiance在漫反射模型下是均匀的。

深度缓冲和半透明渲染

在场景渲染方式上,一般分为“不透明渲染”和“半透明渲染”。


“不透明渲染”意思是,如果A物体被B物体挡住,那么A物体将完全不可见。

至少有两种方式达到这种效果(假设相机的朝向为Z轴正方向):

  1. 由于屏幕上每一个相素都是由场景中某一世界坐标(Fragment)投影得到,在绘制时以Fragment在相机空间内按Z轴从大到小进行绘制, 写入颜色值时直接覆盖FrameBuffer中的颜色值。

  2. 物体以任意顺序渲染,但是在渲染某一个Fragment时,会将其在相机空间的Z坐标写入一个叫做深度缓冲区的地方,在渲染Fragment之前会先使用相机空间下的Z坐标与深度缓冲区中当前Fragment所对应的深度值进行比较,如果Z更小,则替换之前Fragment所对应的相素颜色值,如果Z更大,则什么也不做。

虽然方式2的描述过长,但是方式2反而会更快。

因为方式1需要排序,那么排序之前,需要为所有物体生成Fragment数据,这会需要海量的内存。另外由于需要按顺序渲染,物体与物体之间的穿插关系可能会频繁打断DrawCall,这会显著降低我们图形程序的效率(Nvidia发出的资料表明,DrawCall过高会喂不饱GPU,导致CPU性能急剧下降)。

因此在实际项目中, 都是采用方式2来绘制“不透明物体“, 这就是大名鼎鼎的ZTest.


而”半透明渲染“, 则是如果A物体被B物体挡住,那么透过B可以看到A物体。

而要做到这一步,只能采用画家算法,绘制时以fragment在相机空间内按Z轴从大到小进行绘制, 写入颜色值时,与当前FrameBuffer中的颜色值进行alpha混合。(与不透明渲染中的方式1的惟一差别,就在于最后写入颜色值的行为)。

但是前面说过了,如果要对所有物体的所有Fragment进行排序需要海量的内存,所以一般可行的实现方式都是将物体在相机空间下的Z坐标进行排序,然后由大到小进行绘制(ps.可能是为了性能考虑,Unity的透视相机在半透明渲染中,直接采用了世界坐标系下物体离相机的距离进行排序,然后由大到小渲染。显然他与Z坐标排序并不等价,以致于我们发现在拖动相机过程中,不同sprite之间的穿插关系像‘多米诺骨牌’般进行变化)。

这种折衷会导致:如果物体没有相互交插,都可以得到正确的结果。而一旦物体有交插,就会产生错误的结果。为了解决这个问题,还需要同时配合ZTest进行使用,这时就需要非常小心,如果处理不当就会产生一些非常微妙的效果。即使ZTest通过了,由于alpha值的影响,物体的渲染顺序依然会影响最终的渲染结果。


是的,之的所以会有这篇文章,就是因为我们在最近开发2.5D地形时,遇到了一些困境。

我们在地图上刷了大量的山和树(都是面片),这些山和树会有各种复杂的穿插关系。

最开始我们将所有的山和树以半透明的方式进行渲染,然后发现DrawCall被频繁打断,最坏的情况需要增加50+DrawCall。

此时摆在我们面前的只有两条路:

  1. 动态图集法,将当前屏幕内地形所用到的资源,动态合并成几张1024*1024的图集。但是如果算法不好,动态图集反而可能会加剧DrawCall的增加。

  2. 使用ZTest, 将所有山和树改为不透明渲染,然后开启ZWrite和ZTest来解决遮挡关系。由于所有的半透明纹理的alpha的值只有两种(0和1)。只要我将alpha=0的Fragment给clip掉,整个渲染流程跟”不透明“渲染流程完全一样,这样我们有几种材质就只需要几个DrawCall。

我第一反应,就选择了方案2。因为我觉得他很简单。但是这个世界是平衡的,怎么可能有如此完美的事情。

在我改写shader之后,很快就发现了问题。

  1. 山有巨大的阴影区,这块阴影的alpha值不为0,而且阴影不应该可以直接遮挡后面的物体。

  2. 由于需要抗锯齿,所以我们使用的山和树纹理的alpha并不像我想的那样非0即1,而是会有一个极窄的alpha过度区。

问题1相对较容易解决,由于不透明物体也可以指定渲染顺序,我们将山的渲染延后。由于山的阴影alpha通道的值不为1也不为0,所以会与之前写入FrameBuffer中的颜色值进行alpha混合形成阴影效果。

问题2就颇为复杂,而且很多问题几乎无解。考虑这样一个场景(在相机空间下,由远到近):树x2,山x1,树x2。

我们相机空间下最远的树称为”树群A", 将最近的树称为“树群B"。

由于树群A和树群B之间隔了山,所以树群A和树群B之间不用考虑遮挡关系。

但是树群A/B中有可能有多种树,以至于有多个DrawCall, 树群A/B中树与树之间的会有”半透明(alpha值为(0,1)之间的值)“ 穿插问题,ZTest是解决不了的。

那么一种可能的渲染顺序是,树群A,树群B,山。

由于先渲染树群A,只要树群A的结果是正确的,那么在渲染山时,山的半透明部分与树群A在FrameBuffer中留下的颜色值进行混合的结果一定是正确的。

但是树群B先于山渲染,所以树群B的”半透明(alpha值为(0,1)之间的值)“部分是与地表颜色进行混合,而山又比树群B中的所有树都更远,所以山的ZTest会失败。这时看上去就像是树把山给透视了(直接看到地表)。

最终我们还是采用了ZTest。 为了解决山的”半透明(alpha值为(0,1)之间的值)“问题,我们在刷树时刻意避过了山的边缘部分。

而树与树的遮挡部分,我们尽量让同一种树聚簇种植,这样在同一个DC内,画家算法还是解决”半透明“混合问题。

更新一些GPU相关知识

学完并实现路径追踪之后,即使增加了多线程渲染,在SPP=1024的情况下,依然需要30+分钟才能渲染一帧。

为了更快的渲染速度 ,我试图通过使用GPU的CUDA SDK来加速渲染。然而测下来竟然还没我的CPU跑的快,一方面我没有更好的显卡,另一方面我也不太确定是不是我CUDA使用错误所致。再加上就算使用GPU也不可能达到每帧秒级渲染。于是GPU的学习就搁置了。

然而在最近研究Splat地形渲染方案时, 我无意间发现了一个现象。测试地形为4层混合,在所有Texture都不开Mipmapping的情况下, FPS只有30左右,而开了Mipmapping之后,FPS可以稳定在60. 这激起了我强烈的好奇心,终于将了解GPU的运行架构提上日程(又产生了一次PageFault, 本来我在学习《mysql是怎么运行的》这本书,都已经快把B+ Tree看完了) 。

对照《GPU 精粹1》中的【28.2节 定位瓶颈】得出一个结论,如果Texture Filtering会影响FPS, 那么就说明瓶颈在Texture Bandwidth。

这引出我的第一个问题,Texture Bandwidth到底是什么,为什么Mipmapping会影响Texture Bandwidth?

我最开始以为是从CPU到GPU之间传输图片的带宽,越查资料越确定不是这样。

在找到影响FPS的因素之前,其实我大约花了一天试验了各种设置(这就是基本功不够扎实的现象,没有头绪各种试),甚至在FPS达到60时,我都搞不清到底是改了哪个设置变好的。

当然在这期间我也查了很多资料,其中最重要的两个点是说,对于Splat地形方案,他们都会提到减少Sampler的个数,并且提到使用 TextureArray可以改善性能。使用TextureArray可以改善性能的原因是因为它减少了bindtexture的次数,而为什么要减少SamplerState我当时并没有找到依据。

这就引出了第二和第三个问题,为什么要减少SamplerState的数量,是不是性能问题?bindtexture为什么会很“贵”?

直到昨天我发现了一篇NVIDIA讲解GPU架构的文章, 这篇文章虽然不长,但是指出了各种我们在写shader时需要知道的要点。


我先简要概述这篇文章,然后试图来解释这三个问题。

在GPU中有Warp Scheduler, thread, register file, TMU, TextureCache等概念。

Warp Scheduler是最基本的调度单元,也就是说整个Warp Scheduler中的thread一直在执行“齐步走”逻辑. 如果有一个Thread需要换出(switch out)比如等待内存加载), 整个Warp Scheduler的所有Thread都会换出(switch out)。

只要有一个Thread 的if () 判断为真,那所有的Thread都需要执行if为真的逻辑,即使有的Thread的if判断为假,也需要等待if为真的Thread都执行完才执行else, 而之前那部分if为真的Thread同样需要等待else的语句执行完再继续“齐步走”。

每个Warp Scheduler会有32个Thread。这么理解下来其实每个Warp Scheduler就相当于一个具有32通道的SIMD指令(英伟达把他叫做SIMT)。

每个Thread都有自己的寄存器, 这些寄存器都从register file进行分配,如果shader使用过多的寄存器,就会导致更少的Warp Scheduler和更少的Threads, 而更少的Warp Scheduler则意味着GPU的Core可能跑不满(类比操作系统,如果所有Thread都Sleep, 那CPU就在空转是一样的), GPU的性能就得不到发挥。

根据Wiki的解释,纹理采样主要是通过TMU模块进行执行的,TMU模块是一种有限的硬件资源, 因此你采样更多(不一样的)纹理,就需要消耗更多的周期。

纹理采样除了计算之外,还需要加载纹理数据,TMU会首先向Texture Cache中去加载,如果Cache Miss就会从L2加载到Textuer Cache, 如果L2也Cache Miss,就会从DRAM(显存)中加载纹理,然后依次填充L2和Texture Cache.

根据英伟达说明的GF100内存架构从Thread读到Texture Cache只需要几十个周期,而从L2向DRAM加载则需要几百个周期。在这些周期内,需要采样纹理的Warp Scheduler都需要被换出(swap out)。

至目前为止,其实已经能解释前两个问题了:

  1. Texture Bandwidth到底是什么,为什么Mipmapping会影响Texture Bandwidth?

    Texture Bandwidth其实就是指Texture 从DRAM到L2和L2到Texture Cache的加载带宽

    没有使用Mipmapping之前,我们地形的每一层图片尺寸都是10241024的图片,并且被渲染出的像素尺寸只有256256大小, 这样在渲染相邻的pixel时被采样的texel在内存中是不连续的, 因此在纹理采样过程中会频发触发Texture Cache Miss, 每次Cache Miss都需要额外的周期从L2或DRAM中重新加载。

    使用了Mipmapping之后,GPU可以根据当前的渲染情况来判断采用哪一个Mip Level。当选择合适的Mip Level之后, 相邻的pixel对应的texel也会尽可能的相邻,可以极大的缓解Texture Cache Miss的状况。

  2. 为什么要减少SamplerState的数量,是不是性能问题?

    根据微软文档显示,Direct3D 11中SamplerState最大上限为16,但是采样纹理数最大上限为128。可能是因为别人使用Splat方案时,地形层数远超16层。当然也有可能是性能问题,但是我没查到具体依据。

    根据英伟达的说明,具体执行采样是由TMU来执行的,而根据SIMT的特性,同一时间只有一个纹理会被采样,所以理论上SamplerState的多少并不会影响TMU的执行和并发度。

    我反汇编了shader(D3D版本), 看到一个现象,每当我定义一个SamplerState, 就会有一行dcl_sampler的语句,我查了一下MSDN, 发现这个语句是用来声明sampler寄存器。所以如果非要说减少SamplerState可以提高性能,那原因应该就是,使用更多的sampler寄存器,可以获得更多的Warp来增加GPU的并行度。

  3. 为什么bindtexture开销比较大?
    暂时未找到合理的解释


在查资料过程中,有两个额外的收获:

bindtexture并不是从CPU向GPU上传图片,在opengl中上传图片是使用glTexImage2D来实现的,这时图片只在显存中。

在fragment阶段,并不是每一个像素都被任意分配到一个Thread然后并行执行的。

一个Warp Scheduler被分成8*4个线程组,每2×2的像素块,被分配给一个数量为4的Thread组, 也是就说每2×2的像素块一定被分配给在同一个Warp Scheduler中的4个Thread。具体原因英伟达的文章上并没有细说。但是大概意思是,比如在决定mip level时,除非这4个像素uv跳跃太大,不然可以只用计算一次mip level就可以了。

地形渲染之爬过的坑

目前我们采用TiledMap的菱形模式来编辑地形,然后再导入到Unity, 将TiledMap的每一个菱形以Unity中的Quad为单位来拼出来。

以目前我的知识水平来看,这么做至少有4个问题。

  1. Quad是以正方形为单位拼接的,而我们在TiledMap中每一个菱形是以Quad为单位渲染而成,客户端在使用Quad进行渲染时,为了表现的像个菱形,每两个Quad都会在顶角进行重叠,这需要我们美术出的图四个角Alpha通道都是0,这会增加无用的Overdraw。
  2. 为了降低纹理大小,整个地形都是由有限个基础Tile相互叠加来生成不同的地形。所以在TiledMap中,整个地形是由好几层组成,这就意味着每一个菱形都有可能需要几个Tile进行混合而成。这同样会增加Overdraw, 而且大概猜测一下,半透明渲染渲染由于不会写zbuffer, 所以在渲染之前可能还需要类似画家算法一样进行排序,这同样是开销。
  3. 虽然整个地形只加载9屏,但是由于每一个Quad都是一个GameObject, 这导致我们客户端在做性能测试时,刚起来就需要Instantiate数千个GameObject并常驻。
  4. 本质上每个Quad就是一块mesh,但是他有顶点UV总是从0到1,所以我们无法良好的使用法线贴图来增加地表细节(虽然我不懂渲染,但是作为一个玩家来讲,一块平板地表,我是不能接受的^_^!)。

最开始我并没有接触到,客户端采用的什么方式进行地形渲染,只是在开发中期,我们在一个叫UWA的网站上进行了一次真人真机性能分析。我们发现客户端会Instantiate 和Destroy大量的GameObject并且Overdraw居高不下。 仔细了解下来,才发现他们是使用上面所说的方式进行渲染的。

虽然后来将Quad进行池化, 但是依然会造成1000~2000的GameObject常驻内存。

此时虽然我已经写过一次光栅化程序, 大致了解了渲染流程。但是除了池化的建议外,我也想不到更好的办法了。对于居高不下的Overdraw我更是没有任何办法。

随着后来对Unity Shader的熟悉,我发现了一个可以降低Overdraw和GameObject一举两得的办法。

那就是对地图使用的这些Quad进行自定义Shader, 我们只要需要保证每一个菱形都是由一个Quad渲染而成,那么上面所说的问题2所带来的开销就不存在了。

而实现这一需求也很简单,可以让一个Shader有多个纹理输入,把每一层的纹理都输入进去,然后在shader内部去手动混合后,直接输出最终颜色值。至此我黔驴技穷,再也没有想法了。

又学了一次计算机图形学之后,基于上面的方案我又有了新的想法。

即然现在整个地形是由很多Quad组合而成,如果我们对整个抽象进行“降维打击”。从最终渲染单位来看,其实整个地形是由很多个三角形组成,那我们完全可以创建一个Mesh,这些Mesh的顶点数据和相应的Quad上的顶点数据(position,uv)完全一样。这样我们只需要一个GameObject就能渲染出一屏的地形来。

当然不仅仅是节省GameObject这么简单,有了这个Mesh我们可以做很多事。

比如我可以给每个顶点增加一组UV坐标,这个坐标用于采用整个地形的法线纹理。这样我只需要一张对应整个地形的法线纹理,就可以极大的加强地形细节效果。甚至我们还可以再增加对应整个地形的高度图来生成各种连续起伏的山脉。

同时,由于我们在一张Mesh中,不可能也不需要采用Quad相互重叠来达到菱形的效果。我们在创建Mesh时采用的顶点可以是恰好菱形的四个顶点。这样问题1,3,4都在一定程度上解决了。更棒的是我们还可以使用TiledMap, 整个工作流也没有任何变化。对于美术来讲惟一的变化是他们需要多提供一张法线纹理。


原本我以为我这个方案已算是极好。

但是最近我们在改版游戏时, 我了解到了一个地形编辑器叫WorldCreator, 一种叫做splatting的地形渲染方案,该方案在知乎上有详细的介绍及Demo.

这个方案相比上文的最终方案来讲更灵活。假如我们地形最多由四层纹理混合而成。

WorldCreator除了会使用四层纹理之后,还会额外生成三张对应整个地图的三张纹理,splatting,normalmap,heightmap。

其中splatting的四个通道会控制四层纹理在混合时的权重,在上文我的方案中,固定的四层纹理混合到一起效果是固定不变的。但是splatting渲染方案下,即使相同的四层纹理,在splatting图的控制下依然会形完全不同的效果,可以做到全地形唯一。

heightmap的作用与上文我的方案并无太大差别,这里就不做说明。

其中normalmap纹理是用作增加地形细节的,比如有一座高山,我们的Mesh三角形很大,如果仅凭zbuffer, 很多明暗细节就表现不出来,这时就需要靠这张normalmap来达到逼真的效果。

WorldCreator生成的四层纹理,每一层纹理有三张贴图组成,分别叫diffuse,disp,normal。

diffuse就是地貌纹理,normal是用于使diffuse的细节更加逼真。

最为惊艳的就是这个disp纹理,通过这个disp纹理,我们可以知道以纹素为单位的高度。在混合时,除了可以依据splatting纹理的权重外,纹理间的高度对比也是生成逼真的细节的重要部分,如沙子只出现在砖缝里,这也是我的方案难以企及的效果。

再学计算机图形学入门

在网上查资料时,无意间发现了一门课叫《现代计算机图形学入门》。于是事隔将近3年后,我再一次尝试图形学入门。这次学习从8月20号开始,一直到10月11日,约持续了一个半月。

但是这次的学习体验和上次是完全不同的,最直接的感受有3点。

  1. 《现代计算机图形学入门》要比《3D游戏编程大师技巧》中的内容现代的多,内容和知识体系都更全面。

  2. 现代计算机的性能远超《3D游戏编程大师技巧》著作当时,因此很多为了提高性能的Trick已经没有必要使用。这会使我们花费大量精力在局部细节,而不能窥其全貌。一个最简单的例子就是,一个简单的lerp就让我花费的大量时间来调试

  3. 《现代计算机图形学入门》把主要精力都放在了如何渲染上,至于一些优化手段很少提及,比如三角形裁切,剔除等。这只是一些加速的优化手段,并不影响最终的渲染效果。这些优化在入门阶段,其实并不重要。而在《3D游戏编程大师技巧》中我们会沉浸在各种优化技巧中,而失去了渲染的全貌。

下面记录一下这次学习的新收获。


  1. 透视矫正(光栅化)

在纹理采样时,所有3维坐标都已经被投影到一个2维平面,但是纹理坐标属于3维空间。因此2维坐标下的插值系数不能用在3维坐标进行插值, 下面先来看一下为什么不能插值。

我们先定义q(x,y,z,1)为3维坐标,r(x,y,z,w)为齐次坐标,s(x,y,z, 1) 为投影坐标。

Xr = aXq + b; Yr = cYr + d; Zr = e * Zq + f; Wr = g;

q’ = lerp(q) = q1 + (q2 – q1) t;
r’ = lerp(r) = r1 + (r2 – r1)
t = q’ (a,c,e) + ((b,d,f) + ((b,d,f)’ – (b,d,f)) t)

可以看到虽然q到r的变换不是线性变换,但是有一部分变量是由q*t来贡献的,因此在齐次空间下直接对3维空间坐标进行插值,并没有太大的问题。

再来看一下齐次坐标到投影坐标的变换。

Xs = Xr / Wr; Ys = Yr / Wr; Zs = Zr / Wr;

先对齐次坐标r进行lerp,得到r’ = (lerp(Xr), lerp(Yr), lerp(Zr), lerp(Wr)).

再对r’做投影变换,得到s’ = lerp(s) = (lerp(Xr)/lerp(Wr), lerp(Yr)/lerp(Wr), lerp(Zr)/lerp(Wr), 1) = lerp(r) / lerp(Wr).

这与直接对s做插值是一样的(在投影坐标系之后,不会再次执行齐次除法)。

根据q’和r’的公式推导,可以看到lerp(r)和lerp(q)是可以共用插值系数的。

再根据s’的公式推导,可以得出lerp(s) 和 lerp(q) 的插值系数多了一个lerp(Wr).

这就是为什么,在投影坐标系对3维坐标系下的坐标做插值时,需要先做一次投影除法,将其变换到投影坐标系下,做完插值后,再除以lerp(1/w)来变回3维坐标系。

有一点需要额外提的是,有些资料提到除以lerp(1/z)也可以。但这其实不正确,因为投影变换本质上压缩了z坐标的值,相比w(实际上是3维空间下的z)来讲,会有更大误差的。

  1. 光线追踪

这次学习过程中,最大的收获就要数光线追踪了。在之前的印象中,我一直以为,渲染就是模型空间->世界空间->投影空间->屏幕空间坐标系之间的转换然后再将其光栅化成像。

这是我第一次了解到,原来另一种更好的成像方式叫光线追踪,甚至还有更真实的路径追踪。

  1. 一些零碎的额外收获

正交平移矩阵用的是坐标,所以如果相机本来就在原点那么(l+r) / 2 刚好也等于0。可能这就是为什么要选用[-1,1] 而不是[0,2] 来定义NDC的原因。

右手坐标系,脸朝向的地方为z = -1。因此在计算z深度时需要对z做反转。BUT,在计算投影矩阵时,我们一般会对进屏幕和远平面取负。因此在光栅化时就不需要再次补偿了。

使用一个矩阵M对一个三角形的三个顶点做变换后,使用M来变换法线向量有可能会使法线向量变形。由于切线向量就是三角形的一个边,因此切线不会发生形变。因此,当需要变换法线时,需要额外算出一个矩阵。具体计算公式在《Mathematics for 3D Game Programming and Computer Graphics Third Edition》中第4.5节有详细推导。

球面与射线相交处的法线,并不是从从射线原点到球心的向量。因为射线可能斜着打中球面(甚至会只擦中一点)

中位线和法线的夹角并不能代表出射光线和视线的夹角,是为了更好的光照效果,blin-phong故意引入的。其中一个副作用是,运算更快了。

在微表面模型下,不同的粗糙程度会有不同的概率密度函数,这是因为光线的分布不一样。例如在镜面反射下,大部分光线能量都会沿着某一个特定的方向射出。其他方位的光线概率密度就会特别低。

通过Mesh投影来实现贴花系统

在做FPS之类的游戏中,如果枪打到了墙角,并不能简单放置一来弹孔面片了事。而是要像一张贴纸一样,完全与墙角贴合。这时就需要去实现一个贴花系统来达到这种效果。

贴花系统有几种不同的实现方式,但这里仅考虑通过Mesh投影来实现贴花系统的实现原理。

这种方式的本质是,找到视野中贴花资源会影响的Mesh, 并创建一个同样大小以贴花资源为纹理的Mesh覆盖上去,从而达到贴花的目的。主要分下面两步来实现。

1. 先找到会受影响的物体,比如将弹孔贴在两面墙的夹角,那么受影响的物体就是两面墙。

怎么找到这两面墙不同的需求可能实现方式也不一样, 在场景编辑器中通过贴花来实现静态点缀效果,可以通过创建贴花资源的AABB盒来实现。如果是运行时动态创建弹孔也可以通过四次射线检测来达到,总之方式有很多。

2. 先创建一个半径为0.5单位的裁切立方体,在裁切坐标系中,贴花资源就被放在y=0平面中,贴花资源的中心就是裁切坐标系的(0, 0, 0)点。

需要说明的时这一步实际上并没有代码操作,只是一个数学抽象。我们的目的是要将所有受影响的三角形投影到y=0平面上,以便可以正确的采样贴花纹理。

3. 将受影响物体Mesh的所有三角形均转换到裁切立方体的坐标系之下对立方体的8个平面进行裁切。

在进行裁切之前,有一种情况需要处理,因为三角形是有朝向的,这个朝向是通过面法线来确定的(Unity中三角形的法线为Cross(v2-v1, v3-v1)),在正常的渲染流程中法线不能射入眼睛时,是不会被渲染的。在Unity中视锥体坐标系中,Vector3(0, 0, -1)是前向,因此眼睛的位置在Vector3(0, 0, 1)处。

在这个裁切立方体同样如此,不可能将纹理投影到一个三角形平面的背面,所以需要先先判断三角形的法线与Vector3(0, 0, 1)的夹角是否小于90度,只有小于90度才可能会被投影,才需要被裁切。

裁切时会出现,三角形完全在立方体外, 三角形完全在立方体内,三角形一部分在立方体外一部分在立方体内。前两种情况很好处理,但是第三种情况有可能会将一个三解形切成2个,因此需要格外注意。具体的裁切算法视锥体裁切算法一致,这里就不赘述。

4. 纹理采样,在创建三角形时,我们需要为每个一顶点指定一个uv坐标。前面已经说过了,我们的实现方式是将裁切后合法的三角形投影到裁切坐标系的y=0平面上, 投影之后的坐标为(x, 0, z). 因此uv可直接执行u = Lerp(0.0f, 1.0f, x + 0.5f), v = Lerp(0.0f, 1.0f, z + 0.5f).这里之所以加0.5f修正是因为立方体中心坐标为(0, 0, 0),这也意味着x,y,z的最小值均为-0.5f.

说了这么多附上源码一篇。需要说明的是,这个源码并不是我实现的,是我从网上找来之后修改的,毕竟我对Unity3d没有那么熟悉。

ps.单位相同的裁切立方体如何适应不同尺寸的贴花资源?在Unity中可以通过设置Scale拉伸坐标轴来实现,所以说3D数学真奇妙。

pps.在实现过程中发现,新创建的Mesh不能紧贴被覆盖的Mesh, 因为在相同的深度情况下,新创建的Mesh并不能保证一定在被覆盖的Mesh之后渲染,这会概率性出现新创建的Mesh与被覆盖的Mesh相互覆盖的情况。因为在创建完Mesh之后,需要根据平面法线上浮一点,以保证Z-Buffer正常工作。

三角形光栅化时遇到的坑

前一段时间打算写一个完整的游戏, 客户采用Unity3D引擎, 服务端则采用我自己的Silly网络框架

然而,最终这个项目烂尾了。烂尾的原因有很多,比如缺少资源,在不断寻找资源过程中使自己开发的热情消失殆尽等。但更为重要的是,我发现在使用Unity3D过程中,除了拼接UI逻辑时,没有碰到太大困难外。在实现一些3D效果时竟处处掣肘,甚至连最简单的贴花系统都实现不了。

而此时我的图形学背景是,《3D数学基础:图形与游戏开发》,《DirectX9.9 3D游戏开发编程基础》,《Unity Shader入门精要》和其他一些Unity操作手册。

这使我意识到,我的图形学知识结构出现了根本性问题。之后偶然的一个机会,我在网上接触到了“光栅化软件渲染器”的概念。深挖之下,发现这正是我目前所缺少的知识。

《3D数学基础:图形与游戏开发》介绍了“物体坐标系”,“世界坐标系”,“摄相机坐标系”等各种坐标系,以及如何实现一个数学引擎来实现各坐标系之间的转换。

《DirectX9.9 3D游戏开发编程基础》则介绍的是我们如何使用DirectX提供的各种API来使用相机,光照等各种抽象好的模型。

但是,这两本书之间存在着明显的断层,很难明白在调用DirectX提供的API之后,底层渲染是如何工作。例如,一个物体到底是如何一步一步画到屏幕上的,纹理是如何映射到3D物体上的,Shader到底是如何工作等。虽然这几本书,每一本都会讲一遍渲染流水线,但是这些细节很难被提到。

从零实现一个“光栅化软件渲染器”可以完美的填充这个空白,哪是一个效率很低的”软件渲染器”,这对理解3D渲染是如何工作起着至关重要的作用。幸运的是刚好有一本《3D游戏编程大师技巧》讲的就是这些内容(虽然我不是很认可他的章节安排:D)。


按照《3D游戏编程大师技巧》实现自己的软件渲染器的过程中,数学引擎,坐标系转换,甚至连光照都没碰到什么大问题,在最后一步将屏幕坐标系下的2D三角形光栅化时,踩了一个3连坑,导致一下耽搁了半个月才终于在昨天找到问题所在。

在光栅化的过程中,为了防止重复绘制相素,一般会采用左上(top-left)填充规则。例如绘制一个对角定点为(0, 0), (6,6)的正方形时,会避免为第6行和第6列绘制相素。大概实现伪码如下:

//void draw_pixel(int x, int y, int color);
int x, y;
for (y = 0; y < 6; y++) {
        for (x = 0; x < 6; x++) {
                draw_pixel(x, y, color);
        }
        ... do something ...
}

然而,看实简单的代码背后,却隐藏着各种坑。

在经过渲染流水线计算之后的图元(一般为三角形)坐标一般为float类型,而且大部分情况下都会有小数部分,比如(94.65, 331.64)。这时就需要制定一个规则,是向上取整(即将坐标变成(95,332))或者向下取整(即将坐标变成(94,331))。

为了省事,我的第一版直接采用了相下取整进行坐标映射,渲染伪码如下(这里仅仅将x,y的类型从int变成了float,因为向draw_pixel传参时,将float转成int采用的就是去1法):

//这里仅假设光栅化一个平顶三角形【(x0,y0), (x1, y1), (x2, y2)】并且(x0 < x1 && y0 == y1 && y0 < y2)。
//void draw_pixel(int x, int y, int color);
void draw(float x0, float y0, float x1, float y1, float x2, float y2)
{
        float x, y;
        float h = y2 - y1;
        float xleft_step = (x2 - x0) / h;
        float xright_step = (x2 - x1) / h;
        xleft = x0;
        xright = x1;
        for (y = y0; y < y2; y++) {
                for (x = xleft; x < xright; x++) {
                        draw_pixel(x, y, color);
                }
                xleft += xleft_step;
                xright += xright_step;
        }
}

然而这是不对的,一个最简单的例子,当【xstart=9.1,xend = 18.5】时,由于采用去1法映射相素坐标,因此理论上x方向绘制的相素坐标最大值应该小于18。但是当x=18.1上仍然会执行draw_pixel函数,此时经过传参转换后,x的值为18,这违反了左上规则(y轴同样存在这个问题)。

改良后第二版如下:

//这里仅假设光栅化一个平顶三角形【(x0,y0), (x1, y1), (x2, y2)】并且(x0 < x1 && y0 == y1 && y0 < y2)。
//void draw_pixel(int x, int y, int color);
void draw(float x0, float y0, float x1, float y1, float x2, float y2)
{
        int x, y;
        float h = y2 - y1;
        float xleft_step = (x2 - x0) / h;
        float xright_step = (x2 - x1) / h;
        xleft = x0;
        xright = x1;
        for (y = (int)y0; y < (int)y2; y++) {
                for (x = (int)xleft; x < (int)xright; x++) {
                        draw_pixel(x, y, color);
                }
                xleft += xleft_step;
                xright += xright_step;
        }
}

但是这依然不对, 当(x0, y0) = (2.1, 0.5)并且(x2, y2) = (7.1, 5.5) 时,代码实际绘制的第一个相素坐标为(2,0)。

但是根据直线【(x0, y0),(x2,y2)】的斜率可以算出当y=0时,x的值应该为1.1,换句话说理论上应该绘制的第一个相素坐标为(1, 0)。因此上述代码完美的将相素坐标(1,0)给避过去了,大部分情况下这种现象表现为,两个相邻的图元(这里指三角形)之间会有一条空白线(即两个三角形均没有绘制这条线)。

因此,当我们将y向下取整时,需要根据舍去去的小数修正x的值(因为斜率可能会非常大,有可能y只舍去了0.1,但是x会偏差好几个像素)。

修正版的代码如下:

//这里仅假设光栅化一个平顶三角形【(x0,y0), (x1, y1), (x2, y2)】并且(x0 < x1 && y0 == y1 && y0 < y2)。
//void draw_pixel(int x, int y, int color);
void draw(float x0, float y0, float x1, float y1, float x2, float y2)
{
        int x, y;
        float h = y2 - y1;
        float xleft_step = (x2 - x0) / h;
        float xright_step = (x2 - x1) / h;
        xleft = x0 - (y0 - (int)y0) * xleft_step;
        xright = x1 - (y0 - (int)y0) * xright_step;
        for (y = (int)y0; y < (int)y2; y++) {
                for (x = (int)xleft; x < (int)xright; x++) {
                        draw_pixel(x, y, color);
                }
                xleft += xleft_step;
                xright += xright_step;
        }
}

你以为事情就结束了么= =!

当直线【(x0, y0),(x2,y2)】的斜率为20时,y坐标向上取整时舍去y坐标0.5,x坐标会向左偏移10个坐标。这样原本(x0, y0) = (100, 10.5)的坐标经过取整后就会变成(80, 10)。这种错误大部分情况下表现为,三角形的顶部或底部突然多出一条直线。将所有逻辑改为向上取整,即可解决此问题。因为向上取整会保证所有坐标点都会落在原始三角形内,而向下取整会导致某些不在三角形内部。

修正版代码如下:

//这里仅假设光栅化一个平顶三角形【(x0,y0), (x1, y1), (x2, y2)】并且(x0 < x1 && y0 == y1 && y0 < y2)。
//void draw_pixel(int x, int y, int color);
void draw(float x0, float y0, float x1, float y1, float x2, float y2)
{
        int x, y;
        float h = y2 - y1;
        float xleft_step = (x2 - x0) / h;
        float xright_step = (x2 - x1) / h;
        xleft = x0 +(ceil(y0) - y0) * xleft_step;
        xright = x1 + (ceil(y0) - y0) * xright_step;
        for (y = ceil(y0); y < ceil(y2); y++) {
                for (x = ceil(xleft); x < ceil(xright); x++) {
                        draw_pixel(x, y, color);
                }
                xleft += xleft_step;
                xright += xright_step;
        }
}

至此,三连坑才算被踩完.


3月25日补充:

在进行仿射纹理映射时, 同样踩了两个坑。

1. 当y坐标向上取整时,相应的uv坐标需要采用与xleft和lright一样的算法进行修正。
2. 所有的纹理坐标范围是(0.0~1.0),在向位图坐标转换时(假设位图宽度为64×64)需要转换为(0, 63)

完整的修复代码在这里