在上一篇文章中,我讨论了一个解决方案,即通过使用单个缓冲带渲染减少VR中的延迟。我提到,VR对图像质量的要求相当高,因为GPU相比传统的移动应用程序需要做更多的工作。
工作负载增加的其中一个原因是对象内容需要进行两次渲染(每单位矩阵渲染一次)。另一个原因是GPU最后一次渲染使用了桶形失真过滤(每单位矩阵再渲染一次)。这使得图像处理器的工作负荷特别大,因此我们需要思考良策以降低负荷。
为何使用透镜?
您可能会有疑惑:当今的移动设备功能已十分强大,为什么我们还需要这些学校里习得的老旧的光学技巧?
VR耳机使用透镜有两大主要原因:
1. 透镜可以拓宽视野
2. 透镜可以使屏幕与你的眼睛离得更近
例如,如果您的手机屏幕是11.5cm x 6.5cm,那么每只眼睛水平位置可视的屏幕宽度为5.75cm。由于屏幕与人脸非常靠近,因此相比人眼实际可视的宽度,这个宽度还远远不够。在两个屏幕之间放置一块透镜,那么不管是水平视角还是垂直视角,可视范围均会增加。
使用透镜,我们可以使虚拟世界比实际世界看起来大很多。
其次,有了透镜,即使用户的眼睛与屏幕非常接近,但其看到的屏幕效果比实际上的却远很多,这样使得用户体验更为轻松。
显然,也存在一些缺陷。由于失真,图像的某些部分将被压缩,从而将失去一些信息;而剩下的部分则意味着要用更高的分辨率进行填充。同时,透镜导致的一些色差也需要被修正。
桶形失真
由于存在桶形失真,我们需要应用反向转换,这样屏幕发出的光线才比较适宜。而这种转换的完成主要取决于物理透镜的设计。
在VR中,透镜设计相当复杂。Oculus Rift CV1是一款做的非常好的VR案例。显然,所有这些设计的决策都会影响到修正图像所需的算法。
桶形失真最常见的方法是使用。当然,还有很多其他可用的失真模型——如使用多项式函数或曲线。一个常见的问题是创建的是非零逆函数(non-trival)。为简单起见,我们将使用以下模型用于桶形失真,其逆函数可以进行计算:
α定义了我们想要应用的失真量,而α由理论透镜设计进行界定。p中的输入值x和y在[-1, +1]之间是标准化的。
p(x, 0)的正向和反向失真函数,α=0.3
α呈增长态势的桶形失真图
基于像素的修正 VS 基于网格的修正
一个方法便是,在显示最后图像之前,在后期处理片段着色器中进行最后渲染的修正。假设将VR内容渲染至每矩阵的帧缓冲对象(FBO)中。使用OpenGL ES 3.0,顶点着色器和片段着色器便如下所示:
#version 300 es
in highp vec4 posVtx;
uniform mat4 mvpM;
void main(void)
{
gl_Position = mvpM * posVtx;
}
#version 300 es
in highp vec2 texFrg;
out highp vec4 frgCol;
uniform highp vec2 centre;
uniform highp sampler2D texSampler;
void main(void)
{
highp vec4 col = vec4(0.0, 0.0, 0.0, 1.0); /* base colour */
highp float alpha = 0.2; /* lens parameter */
/* Left/Right eye are slightly off centre */
/* Normalize to [-1, 1] and put the centre to "centre" */
highp vec2 p1 = vec2(2.0 * texFrg - 1.0) - centre;
/* Transform */
highp vec2 p2 = p1 / (1.0 - alpha * length(p1));
/* Back to [0, 1] */
p2 = (p2 + centre + 1.0) * 0.5;
if (all(greaterThanEqual(p2, vec2(0.0))) &&
all(lessThanEqual(p2, vec2(1.0))))
{
col = texture(texSampler, p2);
}
frgCol = col;
}
centre 允许将失真集中的部分稍微移出FBO的中间位置,通常透镜并不完全集中到屏幕的左边或右边。
这是我们可以使用的用来计算失真的最准确的方法。不过就GPU的使用而言,这种方法的成本也非常高。由于我们仅处理6个点(两个三角形到输出一个矩形),因此顶点着色器运行很快。另外,片段着色器需要在屏幕上为每个像素进行转换。所以对于1920 x 1080像素的显示,要进行2073600次运算及纹理查找。
当然,我们可以做得更好!每个程序员都知道,良好性能的关键在于预计算和近似值。如果观察游戏的对象内容,可以看到这些对象中有很多近似值。像级联阴影贴图那样进行阴影计算。不过在未来有了光线追踪,这将得到极大的改善。
看着以上数字,很明显我们需要在片段着色器之外进行转换。我们可以使用多个矩形连接组成网格,而不是在显示FBO时形成一个矩形。如果现在对网格进行预转换,便可以得到以下信息:
这种方法的优点是,在初始化时间以上工作只需要做一次。因为透镜参数不会随时间变化,因此我们可以反复利用每一帧上的网格。这是优化的第一部分——预计算。
第二部分是近似值:在本例中,由网格分辨率对其进行定义。网格点之间可以进行插补。网格分辨率越低产生的图像质量则越高,而网格分辨率越高则运行速度会越快。所以使用网格的优势在哪里呢?使用32 x 32像素的网格可以生成1920 x 1080像素的显示:
(1920, 1080) / 32 = (60, 33.75)
因此,有2040个矩形和12240个点组成了顶点。顶点着色器要处理的是12240个点,而不是6个。不过,上述相同的顶点着色器也可以使用。可以简化片段着色器,如下所示:
#version 300 es
in highp vec2 texFrg;
out highp vec4 frgCol;
uniform highp sampler2D texSampler;
void main(void)
{
highp vec4 col = vec4(0.0, 0.0, 0.0, 1.0); /* base colour */
if (all(greaterThanEqual(texFrg, vec2(0.0))) &&
all(lessThanEqual(texFrg, vec2(1.0))))
{
col = texture(texSampler, texFrg);
}
frgCol = col;
}
这还需要进行纹理查找,但可以避免转换工作。我们节省了大量的功耗,且分辨率更高将节省更多的功耗。不过,在 PowerVR GPU上,还有另一大优势。由于顶点着色器在TA阶段运行 (可以参考PowerVR架构一文),它可以在3D阶段独立运行,而3D阶段由于要进行光栅化,因此要执行片段着色器。如果对象填充率有限,将有更多的空间可以确保按时完成对象的渲染。这里,我再次总结了全高清显示节省功耗的示例:
接下来用一个视频来展示这两种方法:
可以找出不同之处么?我也找不出不同点。第一个渲染在片段着色器中进行了转换,而第二个使用了预计算的网格。
质量评估
看看以上视频,似乎近似法行之有效。但是千万不要被我们的双眼蒙蔽!让我们尝试进行量化。对于每一个近似,最重要(最困难)的部分是找到合适的参数。在我们的例子中,这个参数即网格的大小。我使用的网格大小是32 x 32像素,这个大小极好地权衡了输出质量和速度。以下视频展示的是使用160 x 160像素作为网格大小时的效果:
当立方体停止旋转时,看看其左边的部分,可以看到它是如何循着网格的轨迹。在矩形边缘点之间,纹理的查找只是线性插补。另一个问题是动画效果是摇摆的,因为它们不能遵循失真的曲线。所以,可以确定160 x 160像素不是最佳选择。但看看静态图像,并将之与片段着色器制中的输出转换进行比较。首先,我为每个输出图像创建了向前转换图像(在软件中完成,不借助GPU)。即,仅使用片段着色器时,输出的网格大小为32px和160px。我还针对向前转换图像和原始非转换图像输出的不同创建了差值图像,如下左所示:
图像质量对比图
可以看到,在差值图像中有更多红色。但可以用数值进行表述。在视频编解码器的图像对比中,PSNR值(峰值信噪比)是已知的标准。通过使用ImageMagick,并计算差值图像和PSNR,可以得到:
PSNR值更高意味两个图像更接近。可以清楚地看到,使用32px作为图像的网格参数与使用片段渲染生成的图像非常接近。
总结
透镜修正是VR流水线一个至关重要的部分。通过使用正确的技巧,我们可以降低这个特定部分的GPU需求,并同时保持图像的高质量。这使应用程序得以创建更丰富的对象或设备,以通过缩短唤醒GPU的时间,从而节省功耗。
评论
查看更多