通过 Autostereograms 案例学习 OpenGL 和 OpenCL 的互操作性

引言

在过去的十年里, GPU (图形处理单元)已经从特殊硬件(特供)转变成可以在数值计算领域开辟新篇章的高性能计算机设备。

许多算法可以使用拥有巨大的处理能力的GPU来高速执行和处理大数据量。即使在通常的情况下,不可能将图形硬件编程化, 图形硬件也可以加快算法与图像的处理。 举个例子:通常情况下可以用来计算图形差分,模糊图像, 合并图像,甚至是进行图像(或数组)平均值计算。

随后,可编程方式的出现给编程者带来了极大的便利。 可编程方式所提供的新的可能性,更广泛类别的算法可以移植到GPU来执行。需要转换一定的思路来适应使用屏幕渲染的形式来表达出算法。

现如今可编程GPU支持更高级别的编程范例,可以把它们称之为GPGPU (通用图形处理单元). 新模式允许执行更加通用的算法其不涉及到GPU硬件设备相关内容,可不关心图形化来编制程序了。

本文通过透视图生成算法(autostereogram)作为案例分析,探讨了使用GPGPU APIOpenCL)及可编程渲染管线(OpenGL)进行交互的可能性。

autostereogram场景渲染深度缓冲使用GPU的OpenGL和OpenCL (GPGPU) 内核,并且OpenGL GLSL (可编程渲染通道) 着色器的深度数据不必被CPU读取.

案例分析:Autostereogram

本文只提供autostereogram生成算法基础,将不涉及到更多细节,更具体的信息请参阅其他资料。

Autostereograms的普遍使用将使立体图像重新流行。Autostereograms可以使单个图片不聚焦在平面上,以3D场景的形式进行显示 ,最常用的方式是三维场景的展示。

通过 Autostereograms 案例学习 OpenGL 和 OpenCL 的互操作性_第1张图片

autostereograms编码3D场景的能力并不是最好的, 但很快, 观看隐藏在autostereograms的这些"奥秘" 场景将变的非常容易。

本文所实现的算法是最简单的autostereogram生成算法之一,该算法简单地重复某个可平铺模式(可以是一个可平铺纹理或者一个随机生成的纹理),并根据输入深度图中像素的z深度来改变其“重复长度”:

对于输出图的每一行:
    复制该重复纹理的一整行 (该瓦片)
    对输入深度图中该行的每一个像素:
        复制离左边 one-tile-width 个像素的那个像素的颜色, 然后减去偏移量 X
            对于最大深度(离眼睛最远),X为0,对于最小深度(离眼睛最近),X为最大像素偏移量(~30 像素)

因此,查看器中的像素越是接近,重复模式就越短。这就是诱导眼睛和大脑认为该图像是三维图像的基础。输出图的宽度将会是重复图与输入深度图之和,这样就可以给最初那个未经改变的重复图的拷贝留出足够的空间。

当为之后的结果和性能比较呈现一个参考实现时,一个有着确切描述的CPU实现会在稍后被测试,而不是提供一个更加正式的算法描述。

参考:

  • stereograms描述:http://en.wikipedia.org/wiki/Stereogram
  • autostereogram描述: http://en.wikipedia.org/wiki/Autostereogram
  • autostereogram查看技术:http://www.hidden-3d.com/how_to_view_stereogram.php
  • stereograms的非技术性与技术性讨论:http://www.techmind.org/stereo/stereo.html

一般实现预览:

下图给出了总体的算法流


3d场景渲染

使用opengl核心外形管线来完成3d场景的渲染,在此文中用作的示例场景,它包括一个简单反弹的开发箱壁的动画球。此动态场景的选择能提供更多来自不同实现的“实时”效果。

使用aframebufferobject渲染场景纹理是为了更容易操作计算得到的数据,使用纹理作为渲染目的,而不是依靠主要后备缓冲区拥有的某一优势。

  • 输出尺寸(宽与高)变得更容易控制
  • 能避免渲染窗口与别的窗口重叠的问题。
  • 通常来说,用这种后处理流水线能更自然地符合场景纹理的使用。

然而,它很可能使用标准后备缓冲区来渲染和简单地来回读取此缓冲区。

场景渲染通常有两个输出结果:颜色缓存和深度缓存。深度缓存是立体图产生的一部分。当渲染场景的时候,就没有必要存储颜色,只需要深度信息。所以,当创建帧缓存对象的时候,就不需要添加颜色纹理信息。下面的代码就展示了以深度纹理作为目的的,帧缓存的创建。

// Allocate a texture to which depth will be rendered.
// This texture will be used as an input for our stereogram generation algorithm.
glGenTextures( 1 , &mDepthTexture );
glBindTexture( GL_TEXTURE_2D , mDepthTexture );
glTexImage2D(
    GL_TEXTURE_2D ,
    0 ,
    GL_DEPTH_COMPONENT32 ,
    kSceneWidth ,
    kSceneHeight ,
    0 ,
    GL_DEPTH_COMPONENT ,
    GL_FLOAT ,
    0
    );

glTexParameteri( GL_TEXTURE_2D , GL_TEXTURE_MAG_FILTER , GL_LINEAR        );
glTexParameteri( GL_TEXTURE_2D , GL_TEXTURE_MIN_FILTER , GL_LINEAR        );
glTexParameteri( GL_TEXTURE_2D , GL_TEXTURE_WRAP_S     , GL_CLAMP_TO_EDGE );
glTexParameteri( GL_TEXTURE_2D , GL_TEXTURE_WRAP_T     , GL_CLAMP_TO_EDGE );

glBindTexture( GL_TEXTURE_2D , 0 );


// Create a framebuffer object to render directly to the depth texture.
glGenFramebuffers( 1 , &mDepthFramebufferObject );

// Attach only the depth texture: we don't even bother attaching a target
// for colors, because we don't care about it.
glBindFramebuffer( GL_FRAMEBUFFER , mDepthFramebufferObject );
glFramebufferTexture2D(
    GL_FRAMEBUFFER ,
    GL_DEPTH_ATTACHMENT ,
    GL_TEXTURE_2D ,
    mDepthTexture ,
    0
    );
glBindFramebuffer( GL_FRAMEBUFFER , 0 );

当场景渲染的时候,还有一些初始化的任务。尤其是:

1. 创建,加载和编译渲染着色器

2. 创建顶点缓存

在主渲染循环中,下面的任务是必须加入的:

1. 设置帧缓存对象作为渲染的目标

2. 设置着色器程序作为当前活跃程序

3. 渲染场景

为了保持文章在合理的长度内,这些内容在此都不一一解释了。这些在这个算法中都是很常用,很直接的,没有特别的地方。

下面就是用这个程序渲染的场景结果:

通过 Autostereograms 案例学习 OpenGL 和 OpenCL 的互操作性_第2张图片

水平坐标计算

这是该算法一个有趣的部分,它将由于不同CPU和GPU的实现而不同。

在此之后,将依据每个像素计算出的纹理坐标堆积出最终的图像。

计算垂直纹理坐标不是主要的挑战,因为只需要垂直重复计算即可。垂直纹理坐标甚至无需在此时计算,只需在接下来的步骤中进行简单处理。这个立体图生成算法的核心实际上就是在输出图像的每个像素中计算水平纹理坐标

这一步的操作结果将最终输出相同的小的立体图,其中每个像素保存一个单一的浮点值,它表示在水平纹理坐标的二维图像。  这些浮点值将不断地从左向右出现,其中小数部分将代表(0~1范围内)的实际坐标和整数部分将代表该模式的重复次数。 这种表示是为了避免在寻找关键值时出现的混合值. 例如,如果值0.99和0.01之间的算法,插值将产生0.5左右的样值,但是这是完全错误的。 通过使用值0.99和1.01,插值将产生约1.0样值,这才是对的。

上面的伪码稍作修改就能实现这一中间步骤。在为每一个重复瓷片坐标的整个行设置第一个像素标记之后(比如在0——1之间增加取值个数来获取整个瓷片行),查询步骤就可以开始了。查询步骤是通过查询靠左的一个瓷片宽度数减去一个关于深度的值进行的。所以伪码如下:

For 输出坐标图片的每一行
  为所有重复瓷片的第一行写入坐标
  For 输入的深度映射行中的每个像素点
       在当前写入行中,将靠左的一瓷片宽度像素减去X的偏移量,并实例化坐标系。
          where 对于最大深度(人眼识别的最大深度),X值为0 and  对于最小深度(最接近人眼的深度)X是偏移像素的最大值(~30像素)
              这个值加“1”,这样可以使得结果连续递增。
              在输出坐标图片中存入计算得到的值。

更细致的应用细节由CPU的相关性能、应用来决定,不过考虑到各种运行细节,这个方法依然是水平较高的算法。

立体渲染

这最后一步的坐标“形象”,并重复平铺图像作为输入,并简单地通过在合适的位置采样平铺图像最终渲染图像。 它会从输入的坐标“图像“得到水平纹理坐标。他将从输出的坐标"图像"中计算垂直坐标(只是简单的重复自身).

这个采样过程是在GPU上通过自定义着色器完成的。  一个屏幕对齐的四边形开始呈现,接下来的像素着色器则被用来计算最终的颜色渲染。


#version 150
smooth in vec2 vTexCoord;
out vec4       outColor;

// Sampler for the generated offset texture.
uniform sampler2D uOffsetTexture;
// Sampler for the repeating pattern texture.
uniform sampler2D uPatternTexture;
// Scaling factor (i.e. ratio of height of two previous textures).
uniform float     uScaleFactor;

void main( )
{
    // The horizontal lookup coordinate comes directly from the
    // computed offsets stored in the offset texture.
    float lOffsetX = texture( uOffsetTexture, vTexCoord ).x;
    
    // The vertical coordinate is computed using a scaling factor
    // to map between the coordinates in the input height texture
    // (i.e. vTexCoord.y) and where to look up in the repeating pattern.
    // The scaling facture is the ratio of the two textures' height.
    float lOffsetY = ( vTexCoord.y * uScaleFactor );
    
    vec2  lCoords  = vec2( lOffsetX , lOffsetY );
    outColor = texture( uPatternTexture , lCoords );
};

这样就完成了算法概述。下一节介绍了CPU执行的坐标生成阶段。


CPU实现

在CPU上实现的算法只涉及到从输入的深度值产生偏移量(即纹理坐标)。上面的给出的是一段简单的C++版本的伪代码。这个算法主要分为三个步骤:

  1. 首先,从GPU中读取深度值到CPU
  2. 然后,根据深度值产生相应的偏移量
  3. 最后,将计算产生的偏移量从CPU写回GPU

第一步 : 从GPU中读取深度值

在进行场景渲染之后,深度值会被保存在GPU的一个纹理中。为了得到CPU算法中所需要的深度值,必须首先从GPU中获取深度值并保存到CPU能够访问的内存中。在这里,一个标准的浮点向量std::vector<float>被用来保存这些用于在CPU进行计算的深度值。实现的代码如下:

// 从GPU中读取深度值.
glBindTexture( GL_TEXTURE_2D , mDepthTexture );
glGetTexImage(
    GL_TEXTURE_2D ,
    0 ,
    GL_DEPTH_COMPONENT ,
    GL_FLOAT ,
    mInputDepths.data()
    );
glBindTexture( GL_TEXTURE_2D , 0 );

深度值将被储存在 mInputDepths 这个浮点型向量中。

第二步 : 计算偏移量

计算偏移量就是简单地实现了上面的伪代码所描述的过程并将计算结果保存到内存数组中。下面的代码展示了如何将输入的深度值转换成相应的偏移量输出。

const int lPatternWidth    = pPatternRenderer.GetPatternWidth();
const int lStereogramWidth = kSceneWidth + lPatternWidth;
for ( int j = 0; j < kSceneHeight; ++j )
{
    // 首先初始化偏移量数组.
    for ( int i = 0, lCountI = lPatternWidth; i < lCountI; ++i )
    {
        float& lOutput = mOutputOffsets[ j * lStereogramWidth + i ];
        lOutput = i / static_cast< float >( lPatternWidth );
    }
    // 然后计算偏移量.
    for ( int i = lPatternWidth; i < lStereogramWidth; ++i )
    {
        float& lOutput = mOutputOffsets[ j * lStereogramWidth + i ];
        // 得到该像素所对应的深度值.
        const int   lInputI = i - lPatternWidth;
        const float lDepthValue = mInputDepths[ j * kSceneWidth + lInputI ];
        // Get where to look up for the offset value.
        const float lLookUpPos = static_cast< float >( lInputI ) + kMaxOffset * ( 1 - lDepthValue );
        // 在两个像素之间进行线性插值.
        const int lPos1 = static_cast< int >( lLookUpPos );
        const int lPos2 = lPos1 + 1;
        const float lFrac = lLookUpPos - lPos1;
        const float lValue1 = mOutputOffsets[ j * lStereogramWidth + lPos1 ];
        const float lValue2 = mOutputOffsets[ j * lStereogramWidth + lPos2 ];
        // 我们对线性插值的量加1以保证偏移量在一个给定的行中总是递增(以保证任何偏移量之间的线性插值都是有意义的)
        const float lValue = 1.0f + ( lValue1 + lFrac * ( lValue2 - lValue1 ) );
        lOutput = lValue;
    }
}

第三步 : 将偏移量从CPU写入GPU

在上一步偏移量计算结束以后,这些值必须被写回GPU中进行渲染。这个操作和上面第一步的操作正好相反,具体实现的代码如下:

glBindTexture( GL_TEXTURE_2D , mOffsetTexture );
glTexSubImage2D( 
    GL_TEXTURE_2D ,
    0 ,
    0 ,
    0 ,
    lStereogramWidth ,
    kSceneHeight ,
    GL_RED ,
    GL_FLOAT ,
    mOutputOffsets.data()
    );
glBindTexture( GL_TEXTURE_2D , mOffsetTexture );

偏移量将会被写入到GPU的存储器中。

这样就完成了算法的CPU实现。这种方法的最大缺点是每一帧都需要在CPU和GPU之间进行大量的数据交换。从GPU进行图像数据的读取然后再写回GPU,这严重影响了实时程序的性能。

为了防止这个问题,第二步的处理将直接在GPU的存储器上执行,以避免CPU和GPU之间的往返读写。这一方法将在下面的部分中进行具体描述。

GPU 实现

为了避免CPU和GPU之间不必要的往返读写深度数据应该直接在GPU上进行处理。然而,stereogram生成算法需要得到先前输出图像同一行设置的值。和使用片段着色器一样,对相同的纹理/图像缓冲区同时进行读取和写入对于传统GPU来说是非常不友好的的处理方法。

这里可以使用一个“带”为基础的方法,其中垂直条带会被 左至右 呈现出来每个频带的大小都不会超过左边的最小距离 所提供的例子的 源代码中可以看到,重复图案的宽度85个像素, 最大的偏移量30个像素(kMaxOffset的值), 所以 产生的 最大的 频带宽度为 55个像素由于不能过对于将要写入的纹理进行随机读取的操作,因此被渲染的纹理 必须同时保存 两个副本:一个用于读取,一个用于写入那么刚才所写的必须被复制到另一个的纹理中去

这种使用两个纹理的方法并不是最佳另外频带的宽度渲染的次数有直接的影响,这也将性能产生直接的影响不过,这个宽度是依赖于重复图案的,它可以根据具体的情况而改变,同时最大偏移量也是一个可以根据实时性需求改变的参数。性能会受到参数变化的影响,这并不是理想的情况

一种更加灵活的方法是使用可编程渲染管线。使用OpenCL。GPGPU的API中“通用”的部分在类似的应用程序中发挥着十分重要的作用。这将允许使用GPU进行更通用,而非面向渲染算法。这种灵活性使得我们能够有效地利用GPU进行立体图的生成。

首先,我们需要对前面CPU实现的算法做一些改变。然后对创建一个OpenCL的上下文,以及利用OpenCL对OpenGL的上下文的共享资源的使用进行说明。最后,将对使用OpenCL核函数来产生立体图的方法以及所需的要素进行展示。

渲染场景要做出的修改

CPU版本的深度贴图算法不能被用于GPU。这贴图会同时被OpenCL使用,而OpenCL能直接使用的OpenGL贴图格式有限。依据文档clCreateFromGLTexture2D其中提到的支持的图像通道格式,GL_DEPTH_COMPONENT32不是可以被OpenCL使用的图像格式,非常不幸,因为这个图像格式和我们想要使用的非常像,但是我们可以避开这个问题。

为从场景渲染步骤中获取深度纹理,第二个纹理对象将填充到帧缓冲区。切记只有单一深度纹理会附属于CPU版本。这个深度纹理仍然需要填充到深度缓冲区进行深度测试才能显示。不管怎样,另一个纹理会作为成颜色填充除非接收到相应颜色单元值,它将会接收深度值。下面的代码展示了如何创建纹理以及如何将它帧缓冲区对象。

// Skipped code to allocate depth texture...

// *** DIFFERENCE FROM CPU IMPLEMENTATION ***
// However, because OpenCL can't bind itself to depth textures, we also create
// a "normal" floating point texture that will also hold depths.
// This texture will be the input for our stereogram generation algorithm.
glGenTextures( 1 , &mColorTexture );
glBindTexture( GL_TEXTURE_2D , mColorTexture );
glTexImage2D(
    GL_TEXTURE_2D ,
    0 ,
    GL_R32F ,
    kSceneWidth ,
    kSceneHeight ,
    0 ,
    GL_RED ,
    GL_FLOAT ,
    0
    );

glTexParameteri( GL_TEXTURE_2D , GL_TEXTURE_MAG_FILTER , GL_LINEAR        );
glTexParameteri( GL_TEXTURE_2D , GL_TEXTURE_MIN_FILTER , GL_LINEAR        );
glTexParameteri( GL_TEXTURE_2D , GL_TEXTURE_WRAP_S     , GL_CLAMP_TO_EDGE );
glTexParameteri( GL_TEXTURE_2D , GL_TEXTURE_WRAP_T     , GL_CLAMP_TO_EDGE );

glBindTexture( GL_TEXTURE_2D , 0 );


// Create a framebuffer object to render directly to the depth texture.
glGenFramebuffers( 1 , &mDepthFramebufferObject );

// Attach the depth texture and the color texture (to which depths will be output)
glBindFramebuffer( GL_FRAMEBUFFER , mDepthFramebufferObject );
glFramebufferTexture2D(
    GL_FRAMEBUFFER ,
    GL_DEPTH_ATTACHMENT ,
    GL_TEXTURE_2D ,
    mDepthTexture ,
    0
    );
glFramebufferTexture2D( 
    GL_FRAMEBUFFER ,
    GL_COLOR_ATTACHMENT0 ,
    GL_TEXTURE_2D ,
    mColorTexture ,
    0
    );
glBindFramebuffer( GL_FRAMEBUFFER , 0 );

片断着色器会使用深度值对颜色填充进行渲染。正如下面代码所示的那样简洁。

#version 150
out vec4 outColor;

void main( )
{
    float lValue = gl_FragCoord.z;
    outColor = vec4( lValue , lValue , lValue , 1.0 );
}

这些修改将使纹理适用于 withclCreateFromGLTexture2D(),以便于在OpenCL的上下文共享,正如下面的部分展示的那样。

创建OpenCL context

通常执行如下步骤来创建一个OpenCL context:

List OpenCL platforms and choose one (usually the first one).
List OpenCL devices on this platform and choose one (usually the first one).
Create an OpenCL context on this device.

然而,对于多边形生成算法而言,必须注意合理分配OpenCL context,才能从现存的context中访问OpenCL资源。额外的参数将会被传递给OpenCL context创建例程来请求一个兼容的context。这意味着context创建可能会失败,例如 OpenGL context创建在一个我们试图分配一个OpenCL context的设备上。因此,创建步骤需要作适当修改以增强兼容性。

List OpenCL platforms and choose one (usually the first one).
List OpenCL devices on this platform
For each device:
    Try to allocate a context
        on this device
        compatible with current OpenGL context
    if context successfully created:
        stop

注意到所有平台都可以遍历确保正确的context 被创建。下面的代码演示了OpenCL context 的创建。

cl_int lError = CL_SUCCESS;
std::string lBuffer;

//
// Generic OpenCL creation.
//

// Get platforms.
cl_uint lNbPlatformId = 0;
clGetPlatformIDs( 0 , 0 , &lNbPlatformId );

if ( lNbPlatformId == 0 )
{
    std::cerr << "Unable to find an OpenCL platform." << std::endl;
    return false;
}


// Choose the first platform.
std::vector< cl_platform_id > lPlatformIds( lNbPlatformId );
clGetPlatformIDs( lNbPlatformId , lPlatformIds.data() , 0 );
cl_platform_id lPlatformId = lPlatformIds[ 0 ];


// Get devices.
cl_uint lNbDeviceId = 0;
clGetDeviceIDs( lPlatformId , CL_DEVICE_TYPE_GPU , 0 , 0 , &lNbDeviceId );

if ( lNbDeviceId == 0 )
{
    std::cerr << "Unable to find an OpenCL device." << std::endl;
    return false;
}

std::vector< cl_device_id > lDeviceIds( lNbDeviceId );
clGetDeviceIDs( lPlatformId , CL_DEVICE_TYPE_GPU , lNbDeviceId , lDeviceIds.data() , 0 );


// Create the properties for this context.
cl_context_properties lContextProperties[] = {
    // We need to add information about the OpenGL context with
    // which we want to exchange information with the OpenCL context.
    #if defined (WIN32)
    // We should first check for cl_khr_gl_sharing extension.
    CL_GL_CONTEXT_KHR , (cl_context_properties) wglGetCurrentContext() ,
    CL_WGL_HDC_KHR , (cl_context_properties) wglGetCurrentDC() ,
    #elif defined (__linux__)
    // We should first check for cl_khr_gl_sharing extension.
    CL_GL_CONTEXT_KHR , (cl_context_properties) glXGetCurrentContext() ,
    CL_GLX_DISPLAY_KHR , (cl_context_properties) glXGetCurrentDisplay() ,
    #elif defined (__APPLE__)
    // We should first check for cl_APPLE_gl_sharing extension.
    #if 0
    // This doesn't work.
    CL_GL_CONTEXT_KHR , (cl_context_properties) CGLGetCurrentContext() ,
    CL_CGL_SHAREGROUP_KHR , (cl_context_properties) CGLGetShareGroup( CGLGetCurrentContext() ) ,
    #else
    CL_CONTEXT_PROPERTY_USE_CGL_SHAREGROUP_APPLE , (cl_context_properties) CGLGetShareGroup( CGLGetCurrentContext() ) ,
    #endif
    #endif
    CL_CONTEXT_PLATFORM , (cl_context_properties) lPlatformId ,
    0 , 0 ,
};


// Try to find the device with the compatible context.
cl_device_id lDeviceId = 0;
cl_context lContext = 0;
for ( size_t i = 0; i < lDeviceIds.size(); ++i )
{
    cl_device_id lDeviceIdToTry = lDeviceIds[ i ];
    cl_context lContextToTry = 0;

    lContextToTry = clCreateContext(
        lContextProperties ,
        1 , &lDeviceIdToTry ,
        0 , 0 ,
        &lError
        );
    if ( lError == CL_SUCCESS )
    {
        // We found the context.
        lDeviceId = lDeviceIdToTry;
        lContext = lContextToTry;
        break;
    }
}
if ( lDeviceId == 0 )
{
    std::cerr << "Unable to find a compatible OpenCL device." << std::endl;
    return false;
}


// Create a command queue.
cl_command_queue lCommandQueue = clCreateCommandQueue( lContext , lDeviceId , 0 , &lError );
if ( !CheckForError( lError ) )
{
    std::cerr << "Unable to create an OpenCL command queue." << std::endl;
    return false;
}

OpenCL context创建之后,OpenCL缓冲对象( typecl_mem类型)才可以创建,用来表示OpenGL textures共享。这些缓冲区并不会立即被分配内存,他们仅会成为 OpenGL textures缓冲区的引用,允许OpenCL进行读写。

为了创建OpenGL textures的引用,可以如下调用clCreateFromGLTexture2D 函数。

// OpenCL 1.2 deprecates clCreateFromGLTexture2D to use
// clCreateFromGLTexture, but we keep it to work with OpenCL 1.1.
mDepthImage = clCreateFromGLTexture2D(
    mContext ,
    CL_MEM_READ_ONLY ,
    GL_TEXTURE_2D ,
    0 ,
    pInputDepthTexture ,
    &lError
    );
if ( !CheckForError( lError ) )
    return false;

mOffsetImage = clCreateFromGLTexture2D(
    mContext ,
    CL_MEM_WRITE_ONLY ,
    GL_TEXTURE_2D ,
    0 ,
    pOutputOffsetTexture ,
    &lError
    );
if ( !CheckForError( lError ) )
    return false;

注意到该函数在 OpenCL 1.2已经被clCreateFromGLTexture取代,但clCreateFromGLTexture2D 依然存在,以确保应用可以仅在OpenCL-1.1的系统中运行。

这些缓冲区可以像常规的 OpenCL缓冲区一样使用并被 OpenCL核心处理,这个核心将在下面的段落分析。

Kernel的设计、实现和执行部分(注意这个是指opencl编程里的kernel)

本节的目的不是针OPENCL的概念和语义细节,而是针对为了描述这个问题提供一些元素(工具);在生成STEREOGRAM的算法语境里,设计kernel的设计主要考虑两个因素,其一是同一行的点的数据的从属计算,其二是对kernel来说不可能在运行时同时对一块图像buffer进行读或者写

那么kernel怎么处理数据呢

把Kernel设计成只在数据的一个子集上执行,这样就可以让多核运算单元(指支持opencl的设备)并行的处理整个数据块。在OPENCL的图像处理算法里通常流行使用下面方法处理图像:一个kernel的instance只对图像的一个点处理,这样就可以并行的处理大量的数据。

但是生成stereogram的算法里同一行里每个点的从属性也需要计算,那么把kernel设计成用一行数据来代替一点数据会更合适一些,我们的设计采用把kernel一次对一行数据进行处理

怎么避免从同一个数据buffer里同时读或写

和点数据从属性相关的另一个问题是在opencl的kernel不允许从同一块图像数据同时进行读和写,比如opencl的纹理不能在同一个渲染过程时既要采样(读)又要往里面写,但是对已经处理过的点又要求和后面待处理的点进行计算,这就需要调整一下算法。

我们发现一个简单的现象:一个不断重复的图像里点值是不需要查找它的尺寸。这样可以用一个同样宽度的本地buffer(称为local buffer)保存上次计算的偏移值(offsets),然后再把这个local buffer设计成环形buffer,来避免读/写冲突。当偏移offset计算完后,kernel总是从local buffer里读出,再把计算后的结果同时写入local buffer和output的图像buffer,这样就不会对output的图像buffer读操作。


当算法使用 GPGPU API实现时,这些适配类型是可以共用的。这些API通常提供不同的兼容性以适应不同版本CPU之间的差异,特别是针对同步原语。必要时可以修改API以适应特殊的算法。不管如何,他们可以被优化来使内核变得更快,例如使用更多的内存访问模式。将算法从 CPU 到 GPGPU时需谨记:即使很简单的问题也不容易直接转换。

处理这些设计问题时,可以提出一种内核的实现。下面一些代码讨论上面讨论的关键之处。

// We will sample using normalized coordinates.
// Because we will sample exact values, we can choose nearest filtering.
const sampler_t kSampler =
    CLK_NORMALIZED_COORDS_FALSE
    | CLK_ADDRESS_CLAMP_TO_EDGE
    | CLK_FILTER_NEAREST;

// Stereogram-generating kernel.
__kernel void Stereogram(
    __write_only image2d_t pOffsetImage ,
    __read_only image2d_t pDepthImage
    )
{
    // Private buffer to hold last image offset.;
    float lBuffer[ kPatternWidth ];
    
    const int2 lOutputDim = get_image_dim( pOffsetImage );
    const int  lRowPos = get_global_id( 0 );
    // First copy direct values.
    for ( int i = 0 ; i < kPatternWidth; ++i )
    {
        const float lValue = ( i / (float) kPatternWidth );
        // We copy them in the temporary buffer from which we will fetch upcoming offsets.
        lBuffer[ i ] = lValue;
        
        // ... and we also output it in the first band of the image.
        const int2 lOutputPos = { i , lRowPos };
        write_imagef( pOffsetImage , lOutputPos , (float4) lValue );
    }
    
    // Then actually generate offsets based on depth.
    for ( int i = kPatternWidth ; i < lOutputDim.x; ++i )
    {
        const int2 lLookupPos = { i - kPatternWidth , lRowPos };
        const float4 lDepth = read_imagef( pDepthImage , kSampler , lLookupPos );
        const float  lOffset = kMaxOffset * ( 1 - lDepth.x );
        const float  lPos  = i + lOffset;
        const int    lPos1 = ( (int) ( lPos ) );
        const int    lPos2 = ( lPos1 + 1 );
        const float  lFrac = lPos - lPos1;
        const float  lValue1 = lBuffer[ lPos1 % kPatternWidth ];
        const float  lValue2 = lBuffer[ lPos2 % kPatternWidth ];
        const float  lValue = 1 + lValue1 + lFrac * ( lValue2 - lValue1 );
        
        // Update private buffer.
        lBuffer[ i % kPatternWidth ] = lValue;
        
        // Update output image.
        const int2 lOutputPos = { i , lRowPos };
        write_imagef( pOffsetImage , lOutputPos , (float4) lValue );
    }
};

内核代码在运行前必须包含OpenCL驱动一起编译。像下面这样可以在任何OpenCL内核中编译:

// Create program.
const char* lCode = kKernelCode;

// We pass compilation parameters to define values that will be constant for
// all execution of the kernel.
std::ostringstream lParam;
lParam << "-D kPatternWidth=" << pPatternWidth << " -D kMaxOffset=" << kMaxOffset;

cl_program lProgram = clCreateProgramWithSource( mContext , 1 , &lCode , 0 , &lError );
if ( !CheckForError( lError ) )
    return false;

lError = clBuildProgram( lProgram , 1 , &mDeviceId , lParam.str().c_str() , 0 , 0 );
if ( lError == CL_BUILD_PROGRAM_FAILURE )
{
    // Determine the size of the log
    size_t lLogSize;
    clGetProgramBuildInfo(
        lProgram , mDeviceId , CL_PROGRAM_BUILD_LOG , 0 , 0 , &lLogSize
        );

    // Get the log
    std::string lLog;
    lLog.resize( lLogSize );
    clGetProgramBuildInfo(
        lProgram ,
        mDeviceId ,
        CL_PROGRAM_BUILD_LOG ,
        lLogSize ,
        const_cast< char* >( lLog.data() ) ,
        0
        );

    // Print the log
    std::cerr << "Kernel failed to compile.\n"
              << lLog.c_str() << "." << std::endl;
}
if ( !CheckForError( lError ) )
    return false;

cl_kernel lKernel = clCreateKernel( lProgram , "Stereogram" , &lError );
if ( !CheckForError( lError ) )
    return false;

一些参数被定义为常量,他们在整个内核执行过程中都会被使用。例如它可以允许运行环境调整 thekMaxOffsetparameter。这个变量的值可以作为参数传递给内核函数,但是在程序中保持不变因此它应该被定义为kernel-compile-time常量。

内核运行所需要最后一项工作是绑定内核参数,例如,输入和输出图像缓冲:

// Now that we initialized the OpenCL texture buffer, we can set
// them as kernel parameters, they won't change, the kernel will
// always be executed on those buffers.
lError = clSetKernelArg( mKernel , 0 , sizeof( mOffsetImage ) , &mOffsetImage );
if ( !CheckForError( lError ) )
    return false;

lError = clSetKernelArg( mKernel , 1 , sizeof( mDepthImage ) , &mDepthImage );
if ( !CheckForError( lError ) )
    return false;

这些参数设置一次就可以让内核不断执行因为它们不会改变。内核运行在这些缓冲区之上,因此这些参数被set实例化而不是main函数的循环中。

在main循环中运行内核代码需要简单的三步:

  1. 同步OpenGL纹理,确保OpenCL使用它们时OpenGL 已经渲染完毕。

  2. 运行 OpenCL内核。

  3. 同步OpenGL纹理确保OpenCL返回它们时OpenGL 已经渲染完毕

通过 Autostereograms 案例学习 OpenGL 和 OpenCL 的互操作性_第3张图片

下面的代码展示如何执行这些任务:

cl_mem lObjects[] = { mDepthImage , mOffsetImage };
cl_int lError = 0;

// We must make sure that OpenGL is done with the textures, so
// we ask to sync.
glFinish();
const int lNbObjects = sizeof( lObjects ) / sizeof( lObjects[0] );
lError = clEnqueueAcquireGLObjects(
    mCommandQueue , lNbObjects , lObjects , 0 , NULL , NULL
    );
CheckForError( lError );

// Perform computations.
// We trigger the kernel once for each line of the image.
const size_t lSize = kSceneHeight;
// Workgroup size can't be bigger than size.
const size_t lWorkgroupSize = std::min( mWorkgroupSize , lSize );
lError = clEnqueueNDRangeKernel(
    mCommandQueue ,
    mKernel ,
    1 ,
    NULL ,
    &lSize ,
    &lWorkgroupSize ,
    0 ,
    NULL ,
    NULL
    );
CheckForError( lError );

// Before returning the objects to OpenGL, we sync to make sure OpenCL is done.
lError = clEnqueueReleaseGLObjects(
    mCommandQueue , lNbObjects , lObjects , 0 , NULL , NULL
    );
CheckForError( lError );
lError = clFinish( mCommandQueue );
CheckForError( lError );

偏移量将直接在GPU计算,不需要将数据从GPU传送到CPU再从CPU返回到GPU.

算法中将包含 GPU的实现。它展示了如何联合 OpenGL和 OpenCL,在避免内存和显存昂贵的数据往返时仍然保持了足够的灵活性去实现无往返数据的算法。

代码

本文中提供的代码实现了文中阐述的概念。这些代码在设计上不具有可重用性。它们被设计得尽量简单,尽量直接地调用OpenGL和OpenCL API,并且尽量减少依赖,来清楚地绘制文中的物体。事实上,文中的应用一开始是在一个个人框架中实现的,之后被精简来获得现在的最小化程序。

这个demo可以成功地在Intel和Nvidia硬件上运行,并没有在AMD上测试,不过应该可以同样运行,或者只需少量修改。它可以在Windows Vista和Windows 7(用Microsoft Visual Studio编译)、Ubuntu Linux(用GCC编译)和OSX Mountain Lion(用GCC编译)运行。

应用程序支持三种模式,可以通过space bar进行交替转换。第一种通过基本灯光进行场景的渲染绘制。第二是CPU实现的渲染绘制。第三种是GPU实现的渲染绘制。

在Intel HD Graphics 4000图形绘制硬件中,第一种模式(常规渲染)每秒可以绘制大约1180帧。第二种模式(CPU渲染)每秒可以绘制11帧。第三种模式(GPU渲染)可以绘制260帧。尽管通过每秒绘制的帧数不能精确的度量表现效果,他们仍提供一种结果的评价方法。很明显,通过避免从GPU到CPU的双重数据传送和使用GPU并行计算能力,可以实现更高质量的画面。


结论

这篇文章展示的立体图生成算法是一个证明使用GPGPU与渲染管道交互的能力的很好的机会。算法的一部分已经显示了要么无法仅仅使用可编程渲染管道(GLSL着色器)实现,要么是一个非常低效、但可以使用OpenCL来轻易的获取OpenGL纹理来实现,以这种方式处理它们显然不是GLSL友好的方法。

通过提供灵活的方法在GPU上直接实现更加复杂的算法,渲染管道(OpenGL)和GPGPU APIs(OpenCL)的交互展示了一个用来处理有意思的(也就是很难的)的GPU数据处理的优雅高效的解决方式。它提供给开发者工具,通过更少的编程劳动来强调这些问题,而在GPU上实现算法是需要大量的编程劳动的,而且它甚至为更多的而不是常规可编程渲染管道提供的那些可能性打开了一扇门。

话虽这么说,这个实现可能仍然需要大量的改进。OpenCL不是一个魔杖,可以“自动”达到轻便的性能。优化OpenCL的实现可能是它自身的一个怪兽。。。所以进一步开发这个demo应用,看看它简单的实现是如何提升来达到更好的性能,这是很有意思的一件事。而且,OpenGL Compute Shader也是一个值得探索的,解决相似问题的一个有意思的路。

  • 下载demo - 81 KB

  • 下载源码 - 12.9 MB


英文原文:OpenGL / OpenCL Interoperability : ACase Study Using Autostereograms

其他说明:最初的中文翻译来自开源中国(通过Autostereograms案例学习 OpenGL和 OpenCL的互操作性),我们沟通AMD技术工程师,将翻译略做改动,文章表达更精准。


你可能感兴趣的:(通过 Autostereograms 案例学习 OpenGL 和 OpenCL 的互操作性)