本篇文章介绍了四种环境映射技术的原理并分析了它们的优缺点,最后介绍了环境贴图在实际应用的一些技术点。除介绍技术点以外,对UE4移动端管线的环境反射技术提供了两个扩展优化点:生成八面体环境贴图进行映射和反射向量的修正拟合。文章目录:
- 技术概要
- 球面映射
- 立方体映射
- 双抛物面映射
- 八面体映射
- 扩展
- 参考
技术概要
环境映射(Environment Mapping,EM)也称为反射映射(Reflection Mapping),在指定位置设置一个虚拟眼睛,生成一张虚拟的纹理图,然后把该纹理图映射到模型上,该模型表面得到的图像就是该场景的一个影像。如图1所示,小球表面能显示出真实场景的影像。
环境映射是基于图像光照的(Image Based Lighting,IBL)技术的基础,IBL的核心是将环境贴图作为光照的来源来照亮场景,这也是它在实时渲染中经典的应用,会在后面的文章详细介绍该技术。
如图2所示,环境贴图的算法的基本步骤为:
- 创建环境贴图;
- 计算顶点的法向量
和顶点至视点的方向向量;
- 根据
和,计算反射向量;
- 根据反射向量与贴图纹理坐标的映射关系,算出纹理坐标
;
- 最后,采样环境贴图上纹理坐标
的纹素;
模型上顶点的法向量为
其中,
不同的环境映射技术的关键在于如何建立反射向量与环境贴图的映射关系,这就决定了如何生成环境贴图,有几种不同的环境映射技术:
- 经纬映射(Latitude-Longitude Mapping);
- 球面映射(Sphere Mapping);
- 立方体映射(Cube Mapping);
- 双抛物面映射(Dual Paraboloid Environment Mapping);
- 八面体映射(Octahedral Mapping)。
最早Blinn&&Newell(1976)提出的经纬映射,是环境映射的鼻祖算法,需要将反射向量转换为经纬度坐标
接下来,讨论下其它几个环境贴图映射方案和特点。
球面映射
球面映射(Sphere Mapping)是硬件层面最早支持的技术,图4所示。
最早的固定管线就支持球面映射,以OpenGL的接口为例,如下所示:
// 绑定纹理对象
....
// 设置球面贴图纹理坐标生成参数
glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_SPHERE_MAP);
glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_SPHERE_MAP);
// 启动自动纹理生成和纹理支持
glEnable(GL_TEXTURE_GEN_S);
glEnable(GL_TEXTURE_GEN_T);
glEnable(GL_TEXTURE_2D);
// 绘制模型
drawMesh()
球面映射假定认为观察者从无穷远处观察一个完美反射球体,如图5所示。
有两个坐标系统的概念,一个是视点坐标系统,一个是球体的局部坐标系统。我们在视点坐标系统下,计算得到反射向量
计算的值限定在范围[-1,1],需要将它转化为纹理坐标的范围内,即:
球面映射与经纬映射类似,需要将普通坐标与球面坐标建立一个映射关系,那这就存在分布不均匀的问题。此外,球面映射技术中边缘点的处理存在奇异值,例如当反射向量
综上所述,它并没有在实际中得到广泛应用。该技术理解起来确实有点费劲,不过反正没啥用,知道个大概就好。
立方体映射
立方体映射(Cubmap)最早由Ned[3]提出的,算法的实现简单高效,已经得到游戏业界广泛的应用,所以非常重要。
假设你位于一个立方体盒中心,你的前后左右上下都有盒子的面包围着你,这就是立方体映射需要的六张纹理,如图7所示。
立方体映射的计算方式也很简单,以OpenGL为例。首先,需要指定6张不同方向上的纹理,可以用TEXTURE_CUBE_MAP_POSITIVE_X、TEXTURE_CUBE_MAP_NEGATIVE_X、TEXTURE_CUBE_MAP_POSITIVE_Y、TEXTURE_CUBE_MAP_NEGATIVE_Y、TEXTURE_CUBE_MAP_POSITIVE_Z、TEXTURE_CUBE_MAP_NEGATIVE_Z分别指定。
接着,给定一个反射向量
最后,根据等式(4)算出纹理坐标(u, v),采样-z轴指定的环境贴图的纹素。
立方体贴图的生成方法也比较简单,如图9所示,将摄像头置于目标拍摄点,指定前后左右上下各拍摄一张环境贴图,镜头的视锥设置为90度,一圈下来正好是360度,完美衔接。
以UE4的实现为例,简单体会下立方体贴图的生成流程:
for (int32 CubeFace = 0; CubeFace < CubeFace_MAX; CubeFace++)
{
// initialize all kinds of states.
....
// update the view and projection matrix.
if ((bool)ERHIZBuffer::IsInverted)
{
ViewInitOptions.ProjectionMatrix = FReversedZPerspectiveMatrix(
90.0f * (float)PI / 360.0f,
(float)CubemapSize * SupersampleCaptureFactor,
(float)CubemapSize * SupersampleCaptureFactor,
NearPlane
);
}
else
{
ViewInitOptions.ProjectionMatrix = FPerspectiveMatrix(
90.0f * (float)PI / 360.0f,
(float)CubemapSize * SupersampleCaptureFactor,
(float)CubemapSize * SupersampleCaptureFactor,
NearPlane
);
}
ViewInitOptions.ViewOrigin = CapturePosition;
ViewInitOptions.ViewRotationMatrix = CalcCubeFaceViewRotationMatrix((ECubeFace)CubeFace);
// Then capture the scene.
....
}
立方体映射是不依赖视点的,即环境贴图生成后,镜头发生移动旋转后,环境贴图不再需要重新生成,但是需要贴图生成时的镜头和贴图映射时的镜头差值等信息,最后再进行讨论。从上述算法看出,立方体映射的计算量也是很小的,但是需要指定较多的环境贴图。
现在PC端硬件层面都广泛支持了立方体贴图,移动平台在GLES3.0之后才支持,像早期不支持cubemap的ES2.0的机型,选择6张贴图的性能开销就会比较大,那么环境映射就需要替代方案:双抛物面映射或八面体映射。
双抛物面映射
双抛物面映射(Dual Paraboloid Environment Mapping)由Heidrich&Seidel[4]提出,整个场景分为两个半球面,分别表示前面的和后面的场景,每个半球面用一个抛物面来表现环境贴图,这就是双抛物面映射的核心思想,如图10所示。
先考虑z>0的抛物面,它的数学表示为:
f(x,y)表示Z轴,那么等式(5)的几何曲面如图11所示:
该抛物面有两个重要的性质:
- 性质1. 抛物面上任意一个点的法向量是
;
- 性质2. 给定经过原点并指向抛物面上任意一个点作为视点方向,它对应的反射向量都是(0, 0, 1);
抛物面上任意一个点可以表示为:
两条不重叠的切线的叉积,就可以算出该点的法向量:
接下来是性质2的推导[7],设任意一个经过原点的视点向量是
代入等式(1)就可以算出
在世界坐标系统下,计算好模型表面的反射向量是
环境贴图上纹理的采样坐标就是:
对于-z那一半的抛物面,可以推导相应的采样坐标是:
直观上,很难通过设置合适的镜头来生成双抛物面映射的环境贴图,但是生成立方体贴图的方法是很简单的,那么,我们可以先生成立方体贴图,通过转换生成双抛物面的环境贴图。
双抛物面与立方体贴图类似,也是不依赖视点的。然而前后两张环境纹理的交界处,可能存在接缝问题。它相比于立方体贴图,需要纹理数更少,纹理数由六张变为两张,存在一定程度的变形。对于不支持Cubemap的移动端机型来说,是一种比较好的取舍。
八面体映射
八面体映射(Octhedral Mapping),它的可视化流程如图12所示,将二维平面映射至三维八面体,再将八面体上的坐标向量归一化,就能变形为一个球体。
八面体映射定义了一个二维向量与三维向量的映射关系,核心的变换代码如下所示[8],运算量非常简单:
vec2 signNotZero(vec2 v) {
return vec2((v.x >= 0.0) ? +1.0 : -1.0, (v.y >= 0.0) ? +1.0 : -1.0);
}
// Assume normalized input. Output is on [-1, 1] for each component.
vec2 float32x3_to_oct(in vec3 v) {
// Project the sphere onto the octahedron, and then onto the xy plane
vec2 p = v.xy * (1.0 / (abs(v.x) + abs(v.y) + abs(v.z)));
// Reflect the folds of the lower hemisphere over the diagonals
return (v.z <= 0.0) ? ((1.0 - abs(p.yx)) * signNotZero(p)) : p;
}
vec3 oct_to_float32x3(vec2 e) {
vec3 v = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
if (v.z < 0) v.xy = (1.0 - abs(v.yx)) * signNotZero(v.xy);
return normalize(v);
}
八面体映射建立了一个简单高效且精度损失最低的二维向量至三维向量的映射关系,它的一种应用场景是压缩法线信息。例如延迟渲染管线下,由于GBuffer的限制,需要节约通道,简单的做法是只选用法向量的两个分量信息再还原出第三个分量
前面说过经纬映射会存在分布不均的问题,体现纹理在两极聚集。八面体映射则不存在这样的问题,它最大的优点是分布均匀,如图13所示,就是这么自信。
堡垒之夜游戏中远景树的渲染,就是采样了八面体映射分布均匀的特点,采用的方案称为Octahedral Impostors,能做到由远至近,树没有明显的突变,如图14所示,这里不细说其技术原理。
八面体映射同样可以用于做环境贴图的映射,它的制作方法与双抛物面环境贴图类似,先制作生成立方体贴图,再生成八体面环境贴图,简单的算法伪码如下所示:
for(int32 i = 0; i < width; i ++){
for(int32 j = 0; j < height; j ++){
// get the (u, v)
float u = i * 2.0f / width - 1.0f, v = j * 2.0f / height - 1.0f;
// octhedral mapping
n = oct_to_float32x3(u, v);
// sample the tex from the cubemap
tex = sample_cube_map(n);
// rgbm encode it.
data[i * width + j] = encode_rgbm(tex);
}
}
在纹理采样时,同样需要进行一次转换,简单的算法伪码如下所示:
uv = float32x3_to_oct(r);
color = sample(octhedral_texture, uv);
color = decode_rgbm(color);
在UE4做了一个实验,对于六张128x128尺寸的立方体贴图,转换为512x512的八面体贴图,纹理尺寸减少了2x128x128,生成如图15所示的纹理贴图,看不清图象主要的原因是该图经过了RGBM编码(最后一小节介绍)。
最后,经过环境贴图映射,得到图16所示的结果,可以发现八面体映射在边缘处会有较为明显的锯齿。如果生成八面体环境贴图时,选择更合理的过滤方式,以及扩大贴图尺寸,得到的效果会更好。
扩展
在实际应用中,由于镜头拍摄场景生成的环境贴图与场景中实时漫游的镜头存在偏移,若在环境映射计算时,不做任何处理,则可能出现图17所示的情况。
若反射体是一个球体,如图18所示,反射体在位置C处拍摄一张环境贴图,模型顶点P接受环境纹理,反射向量是R,R为单位向量,如果不对反射向量R进行修正,直接根据反射计算的是位置Q的环境贴图,然而我们想要获取的是位置M的环境像素。那么就需要将反射向量R修正至C->M。
设球体的半径为r,很容易推导:
那么,容易得:
把等式(10)代入(11),可得:
若反射体是一个包围盒,如图19所示。反射体在位置C处拍摄一张环境贴图,模型顶点P接受环境纹理,反射向量是R,R为单位向量,如果不对反射向量R进行修正,直接根据反射计算的是位置Q的环境贴图,然而我们想要获取的是位置M的环境像素。设包围体的最大值和最小值分别是BMax和BMin,那么由点P出发的射线可以表示为P+tR,所以容易计算出射线与包围盒的交点。
算法的伪码可以表示为:
// 计算射线在各个方向上可能的交点
float3 rbmax = (BMax - P) / R;
float3 rbmin = (BMax - P) / R;
// 根据射线朝向获取合理的截取值
float3 n = (R > 0.0f) ? rbmax : rbmin;
// 获取最小的值,即交点
float t = min(n.x, n.y, n.z);
// 计算修正后的射线
new_R = P + t * R - C
这里介绍了两种反射体的向量修正算法,对于移动端来说,这两种做法消耗都挺大的,UE4移动管线甚至没有做任何的修正处理。是否有一种简单的做法来处理呢?先考虑球体反射球,设
那么等式(12)的修正向量可以表示为:
等式(13)的核心代码如下所示,对于移动端的计算量还是可以接受的:
// 反射体位于C,模型顶点是P,反射向量为R
float3 a = P - C;
float b2 = dot(a, a);
float c = dot(a, R);
float3 new_R = a + ((r * r - b2)/(r + c)) * R;
这里给一个拟合后的演示效果,与原始计算存在差异,但是效果明显强于未修正结果。
对于反射体是包围盒的情况,也可以取包围盒的对角线为球体的直径,再采用上述方法来计算。设D = BMax - BMin,球体半径为r = sqrt(dot(D, D) / 2。
其实,不管球体和包围盒的情况,都是通过建立一个模型来对反射向量往正确的方向进行修正,因为拍摄环境贴图时并不会严格按照反射球体和反射包围盒来拍摄。
对于移动平台,若不支持Cubemap,还有环境贴图的需求话,个人觉得最理想的方案是八面体映射来解决。
对于移动平台,还有一个兼容性问题是HDR纹理,拍摄的环境贴图理想的存储格式是float16,若移动平台不支持这种格式,就需要对环境贴图进行编解,存储至R8G8B8A8的数据格式中。以UE4为例,采用RGBM编码方式进行压缩,将float16存储的数据压缩至R8G8B8A8格式来存储,核心代码如下所示,其中64.0是一个系数,可以根据各自需求选择合适的方式。对于RGBM详细的介绍,可以参见Karis[11]的介绍。
MaterialFloat4 RGBMEncode( MaterialFloat3 Color )
{
Color *= 1.0 / 64.0;
float4 rgbm;
rgbm.a = saturate( max( max( Color.r, Color.g ), max( Color.b, 1e-6 ) ) );
rgbm.a = ceil( rgbm.a * 255.0 ) / 255.0;
rgbm.rgb = Color / rgbm.a;
return rgbm;
}
MaterialFloat3 RGBMDecode( MaterialFloat4 rgbm )
{
return rgbm.rgb * (rgbm.a * 64.0f);
}
以上,完!老板赏口饭!
参考
[1] Tomas Akenine-Möller, Eric Haines, and Naty Hoffman. Real-time rendering. Crc Press, 2019.
[2] James F. Blinn, and Martin E. Newell. “Texture and reflection in computer generated images.” Communications of the ACM 19.10, pp.542-547, 1976.
[3] Ned Greene. “Environment mapping and other applications of world projections.” Computer Graphics and Applications, IEEE 6.11, pp.21-29, 1986.
[4] Wolfgang Heidrich, and Hans-Peter Seidel. "View-independent environment maps." Proceedings of the ACM SIGGRAPH/EUROGRAPHICS workshop on Graphics hardware. ACM, 1998.
[5] Dual paraboloid shadow mapping
[6] Jason Zink. Dual Paraboloid Mapping In the Vertex Shader
[7] 详解双抛物面环境映射
[8] Zina H. Cigolle, et al. "A survey of efficient representations for independent unit vectors." Journal of Computer Graphics Techniques 3.2, 2014.
[9] Ryan Brucks. "Octahedral Impostors"
[10] Behc. "Box Projected Cubemap Environment Mapping".
[11] Brian Karis. "RGBM color encoding"