转载请注明出处为KlayGE游戏引擎,本文的永久链接为http://www.klayge.org/?p=344

多年前NVIDIA就发布了3D Vision技术,能提供多种立体渲染的效果。随着2009年的电影阿凡达所带来的世界性3D狂潮,你是否也想在自己的程序中加入立体渲染呢?

3D Vision的原理

根据http://developer.nvidia.com/object/3d_stereo_dev.html,3D Vision的原理如下:

在驱动内部,所有3D场景都渲染两次——一次用左眼,一次用右眼。驱动会自动“在线”修改典型的3D游戏vertex shader,所以在执行期可以产生正确的图像。

注意加粗的几个词所透露出来的信息。首先,你的每一个Draw call都被驱动变成了两个Draw call;其次,立体化的过程是自动的,无法自由控制;第三,它只能处理典型的vertex shader,而不是任意的vertex shader,比如sky box的vertex shader,就往往是个“非典型”的。NVIDIA的思路就是,把一切都封装起来,只有个别参数可以让开发人员和用户调整。程序能做的事就只能是把一切交给驱动,祈祷最终结果正确,极其被动。

其实,立体渲染没有那么stupid。比如,图形引擎可以分别从左右眼主动生成2张图像,都是用正确的vertex shader,然后交给图形API。这么做就可以保证整条流水线都是支持立体的,包括视锥裁剪,结果就是所有物体均能100%渲染正确。而不会像3D Vision的方法,不但vertex shader必须“典型”,还得没法处理被场景管理器裁掉的物体(比如左眼能看到,右眼看不到的物体)。再比如Crytek在CryEngine3里的立体渲染方法,把生成的单一图像通过image warp的方式分别得到左右眼的结果,不必渲染2遍,就能在性能基本不降低的情况下进行立体渲染。这些方式都不能使用自动的3D Vision,必须要通过生成2张image的方式才能实现。

既然放弃了自动的3D Vision,我们就来开始探索如何手动把两张图提交给驱动,控制驱动产生立体渲染的方法。

尝试1:NVAPI

3D Vision并没有提供提交2张图像的方法,那就看看师出同门的NVAPI。NVAPI是NVIDIA提供的一个SDK,可以直接访问GPU和驱动的功能。本来我满怀欣喜地认为NVAPI一定给出了手动控制立体渲染的方法,结果发现公开版本的NVAPI最多也就提供了打开和关闭立体渲染这样的功能,并不能实现我们想要的。根据http://en.wikipedia.org/wiki/Nvidia_3D_Vision,在专有版本的NVAPI(也就是签署了NDA的版本)包含有显式控制的功能。但这个专有的版本很难申请到,对于大公司还好,对于小作坊、业余开发的爱好者之流,就根本没机会了(经常是提交申请后什么反应都没有)。对于开源开发就成了噩梦,NDA的东西无法随着开源软件一起发布,所以这条路成了死胡同。难道就没有办法了吗?

尝试2:OpenGL QuadBuffer

OpenGL本身就提供了GL_LEFT_BACK、GL_RIGHT_BACK、GL_LEFT_FRONT和GL_RIGHT_FRONT四个缓冲区,内建了立体的支持。但这种方法的缺点也是明显的:

  1. 只支持OpenGL。游戏主流的D3D均无法使用quad buffer。
  2. 在Windows上,一般的OpenGL驱动均不支持quad buffer,只有Quadro支持。

可以认为,这条路也失败了。难道,就真的没有办法了吗?

尝试3:3D Video

NVIDIA在发布3D Vision的同时,也发布了一款叫做3D Video player的软件,可以播放立体影片,并提供了一些样片下载。这些影片只是普通的视频格式,用一般播放器播放的时候是左右眼并排排列的,如下图所示:

Knights Quest Snapshot

但用3D Video player播放的时候,就能呈现出立体效果。在播放视频的情况下,驱动没有3D信息、没有vertex shader,所以肯定不是用上文所说的“自动”方式得到立体效果的。虽然3D Video player非常可能用了专有的NVAPI,我仍然希望它是用一般的方法做到的。在GDC 2009上,NV的演讲提到了3D Video的显示方法,经过测试果然成功!那是NV留的一个后门,用来显示以有的立体数据。

3D Video细节

根据前面找到的材料,3D Video的处理方法如下:

3D Video

  1. 把左右眼图像拷入一张大纹理中,大纹理的宽为w * 2,高为h + 1(w和h分别是原图像的宽和高)。左眼在左边,右眼在右边。
  2. 大纹理的最后一行加入特别的标志(此为关键所在)。
  3. 用StretchRect把大纹理拷入Back buffer。
  4. 当Back buffer显示出来的时候就是立体的了。

看来,一切玄机尽在“特别的标志”中。正是那个标志,让驱动把纹理识别为立体图像,在StretchRect和Present的时候做特殊处理。该标志的定义是这样的:

// Stereo Blit defines
#define NVSTEREO_IMAGE_SIGNATURE 0x4433564e //NV3D

typedef struct _Nv_Stereo_Image_Header
{
unsigned int dwSignature;
unsigned int dwWidth;
unsigned int dwHeight;
unsigned int dwBPP;
unsigned int dwFlags;
} NVSTEREOIMAGEHEADER, *LPNVSTEREOIMAGEHEADER;

// ORed flags in the dwFlags fiels of the _Nv_Stereo_Image_Header structure above
#define     SIH_SWAP_EYES               0x00000001
#define     SIH_SCALE_TO_FIT            0x00000002

填充的方式:

D3DLOCKED_RECT lr;
pSurf->LockRect(&lr, NULL, 0);

// 填到最后一行
LPNVSTEREOIMAGEHEADER pSIH = reinterpret_cast<LPNVSTEREOIMAGEHEADER>(static_cast<unsigned char *>(lr.pBits) + (lr.Pitch * height));

pSIH->dwSignature = NVSTEREO_IMAGE_SIGNATURE;
pSIH->dwBPP = 32;
pSIH->dwFlags = SIH_SWAP_EYES;
pSIH->dwWidth = gImageWidth*2;
pSIH->dwHeight = gImageHeight;

pSurf->UnlockRect();

这个标志可以在纹理建立的时候就填充上,以后每一帧只需要把左右眼的图像拷进去就行了。

Direct3D 10/11的实现

前面举得例子用的是D3D 9,而在D3D10/11中,情况又会如何呢?D3D10/11没有了StretchRect,取而代之的是CopyResource和CopySubresourceRegion,两者都没有缩放的能力。不管了,试试再说:

D3D11_BOX box;
box.left = 0;
box.right = w;
box.top = 0;
box.bottom = h;
box.front = 0;
box.back = 1;
d3d_imm_ctx->CopySubresourceRegion(backbuffer, 0, 0, 0, 0, surf, 0, &box);

结果成功了!NV的驱动仍然会根据那个标志来特殊化CopySubresourceRegion。

至此,D3D9/D3D10/D3D11/OpenGL下都可以通过2张图像来控制3D Vision显示出立体效果,你的程序也可以用同样的方法快步进入立体的行列。在KlayGE 3.11中,stereo模式也使用本文所述的方法。

未解决的问题

在分别获得左右眼图像的过程中,应该是要关闭3D Vision的,否则左右眼图像也都会分别变成立体的,性能大减。通过NVAPI的NvAPI_Stereo_Deactivate确实可以关闭3D Vision,但这样的话连最后需要3D Vision的StretchRect等也失效了。由于某些原因NvAPI_Stereo_Activate这个函数出现在头文件和库文件中,却没出现在文档里,对该函数的调用似乎需要等到下一帧才会启用3D Vision。结果还是不正确。暂时的解决方法是把左右眼图像略微减小,比如高度从h变成h – 2,驱动发现小于front buffer的渲染就会暂时关闭3D Vision。