裸眼3D小实验


最近偶然看到篇3D显示的论文http://roxanneluo.github.io/PeppersCone.html,感觉挺有意思,决定动手实现一下。
常见的一种裸眼3D是通过全息投影来实现,如:

即通过图像在半透明薄片上的投影来产生立体感,由于投影面是标准的平面,如果又正好是45°角,投影图像不需要做任何形变,实现起来比较简单,而这篇论文作者采用的是圆锥体:

圆锥相比凌锥可以适应更大的观察角度,另外还可以放到旋转平台上,那么问题来了,对于圆锥,投影图像需要做怎样的形变呢?

如果按数学公式算会相对复杂,特别是,DIY的圆锥不能保证是标准形状,很可能会扁一点,也不能保证正好垂直摆放,因而实际上我们需要假设它就是不标准的形状、不标准的姿态,那如何得到图像在这样曲面上投影的形变关系呢?结构光是一种不错的方法。
Kinect和iPhoneX的人脸ID都用到了结构光技术,其主要用于3D重建,获取物体表面形状信息。原理不难理解,向物体表面投射已知结构的光,然后拍摄图像,根据图像上结构的形变来计算物体表面(深度)信息,在这个实验里,我用的比较简单的格雷码结构光。

结构光

基本思路:将像素点的坐标(x,y)编码进光的结构,投影后拍摄图像,解码每个像素点(X, Y)的值,从而得到(x, y) -> (X, Y)的映射关系图。格雷码结构光是时间编码方式,即投射多张图案,每张代表一个二进制bit,假如1024分辨率,就需要log2(1024)=10张图案来编码,而且对于x、y两个轴,需要在横纵方向投影两次。格雷码相比普通二进制码有一定优势,具体可参看https://en.wikipedia.org/wiki/Gray_code

比如上图,Line1的码字就是00000,Line2是01110,解码后将格雷码转为二进制码,就得到该像素点的坐标值
格雷码结构光生成的伪代码(x轴):
encodePattern(idx) {
    for (y = 0 : height - 1) {
        for (x = 0 : width -1) {
            int gray = binaryToGray(x);              // 坐标x转为格雷码
            bool one = (gray >> idx) & 1;            // 第idx位是否是1
            image[x, y].color = one ? white : black; // 像素点颜色
        }
    }
}

解码的伪代码(x轴):
decodePattern(images) {
    for (idx = 0 : images.size) {
        for (y = 0 : height - 1) {
            for (x = 0 : width -1) {
                bool one = isPixelWhite(images[idx][x, y]); // 判断该像素点编码为0还是1
                if (one) {
                    pattern[x, y] |= 1 << idx;               // 第idx位赋1
                }
            }
        }
    }

    for (y = 0 : height - 1) {
        for (x = 0 : width -1) {
            pattern[x, y] = grayToBinary(pattern);          // 格雷码转二进制
        }
    }
}
由于解码后的像素映射关系图需要用到3D渲染,我们将像素坐标值int型压缩到颜色rgba,即rg共16位表示x,ba共16位表示y,这样解码结果就变成了一张纹理贴图,从而方便后续的shader处理。
uchar *p = image[x, y];
p[0] = patternX >> 8;       // r
p[1] = patternX & 0xFF;     // g
p[2] = patternY >> 8;       // b
p[3] = patternY & 0xFF;     // a
最终得到的结果图如下,由于x、y压缩到r、g、b、a,所以图片看起来是有断纹的,实际坐标值是连续变化的。


结构光编解码实现具体见:https://github.com/keith2018/GrayCode

Post Process

经过结构光的标定过程,我们得到了圆锥的形变映射关系图,并做成纹理贴图,下一步就是应用到3D渲染。这里我们使用Post Process技术,将原3D场景渲染到FrameBuffer,然后用shader来实现形变,最终输出形变后的3D画面。
首先创建FrameBuffer
_frameBuffer = FrameBuffer::create("PostProcess", FRAMEBUFFER_WIDTH, FRAMEBUFFER_HEIGHT);
DepthStencilTarget* dst = DepthStencilTarget::create("PostProcess", DepthStencilTarget::DEPTH_STENCIL, FRAMEBUFFER_WIDTH, FRAMEBUFFER_HEIGHT);
_frameBuffer->setDepthStencilTarget(dst);
然后创建后处理纹理
Material* material = Material::create(materialPath);
Texture::Sampler* sampler = Texture::Sampler::create(srcBuffer->getRenderTarget()->getTexture());
material->getParameter("u_texture")->setValue(sampler);

Mesh* mesh = Mesh::createQuadFullscreen();
_quadModel = Model::create(mesh);
渲染过程:
Rectangle defaultViewport = getViewport();

// draw into the framebuffer
setViewport(Rectangle(FRAMEBUFFER_WIDTH, FRAMEBUFFER_HEIGHT));
FrameBuffer* previousFrameBuffer = _frameBuffer->bind();

// main scene
_scene->draw();

// post process
setViewport(defaultViewport);      
previousFrameBuffer->bind();

clear(CLEAR_COLOR, Vector4(0, 0, 0, 1), 1.0f, 0);
_quadModel->draw();
其中用于形变映射的shader:
#ifdef OPENGL_ES
precision highp float;
#endif

// Uniforms
uniform sampler2D u_texture;
uniform sampler2D u_mapTexture;

// Inputs
varying vec2 v_texCoord;

void main()
{
    vec4 map = texture2D(u_mapTexture, v_texCoord);
    vec2 mapUV = vec2((map.b * 256.0 + map.g) / 4.0, (map.r * 256.0 + map.a) / 4.0);
    gl_FragColor = texture2D(u_texture, mapUV);
}
u_texture是3D场景正常渲染的画面,u_mapTexture是结构光解码后的像素映射图,mapUV的计算则是将rgba解压为x、y坐标(u、v),如下左右分别是形变前和形变后的图像。

实验结果



从效果上看,由于使用iPad来投影,要求比较暗的环境才行,否则亮度不够。

评论