1.初始化
使用OpenGL绘制图像一般分为两步:初始化阶段和渲染阶段
在初始化阶段对要用到的资源交给OpenGL,在渲染阶段告诉OpenGL如何去使用它们。
1.1 设置几何体
几何体是由顶点构成的,将顶点数据交给OpenGL的方式有很多种,本文使用一种常见的方式。及VAO(EBO, VBO)的方式。
EBO ( Element Buffer Object ) 里面存的是顶点的绘制顺序,如果想绘制三角形,就需要将构成三角形的三个顶点放在一起。(注意:逆时针存放为正面,顺时针为反面)
VBO ( Vertex Buffer Object ) 里面存的是顶点的信息,最基本的就是位置信息Position了。也可以存顶点的颜色、方向方向、纹理坐标等任意需要的信息。可以使用一个VBO存放一种顶点信息 ( Attribute )
VAO ( Vertex Array Buffer Object ) 由一个 EBO 和 数个 VBO 构成,即顶点的绘制顺序和顶点的各种属性。可以理解为一个 VAO 就代表了一个几何体
注意:VAO、VBO、EBO的搭配方式和使用方式是多种多样的,这里使用一种便于理解的方式进行说明。
这里使用OpenGL 4.5进行代码演示
//顶点数据
const float vertices[] = {
-1.0,-1.0,0.0,
1.0,-1.0,0.0,
1.0,1.0,0.0,
-1.0,1.0,0.0,
};
const UInt indices[] = {
0,1,2,
0,2,3
};
//创建 VBO EBO VAO
glCreateBuffers(1, &m_iVBO);
glNamedBufferData(m_iVBO, sizeof(vertices), vertices, GL_STATIC_DRAW);
glCreateBuffers(1, &m_iEBO);
glNamedBufferData(m_iEBO, sizeof(indices), indices, GL_STATIC_DRAW);
glCreateVertexArrays(1, &m_iVAO);
//设置VAO的EBO
glVertexArrayElementBuffer(m_iVAO, m_iEBO);
//将VBO绑定到VAO的第一个绑定点(vaoBindingIndex )上
UInt vaoBindingIndex = 0;
glVertexArrayVertexBuffer(m_iVAO, vaoBindingIndex, m_iVBO, 0, 3 * sizeof(float));
//将VAO的绑定点0上的VBO设置为VAO的第一个属性
UInt vaoAttributeSlot = 0;
glVertexArrayAttribBinding(m_iVAO, vaoAttributeSlot, vaoBindingIndex);
//设置属性的格式(每个顶点Position 为3个float变量)
glVertexArrayAttribFormat(m_iVAO, vaoAttributeSlot, 3, GL_FLOAT, GL_FALSE, 0);
//启用该顶点属性
glEnableVertexArrayAttrib(m_iVAO, vaoAttributeSlot);
1.2 设置着色器
着色器是GPU运行的小程序。顶点着色器用于处理每一个顶点,片元着色器用于处理每一个片元 ( fragment )。片元是光栅化的结果。可能会有多个片元落在同一个像素上。
着色器的设置有什么特别的,可以参考 LearnOpenGL-着色器.,重要的是如何编写体绘制的着色器,这部分在后面进行介绍。
1.3 设置纹理
体绘制处理的体数据是三维的,可以使用一张三维纹理存放它。
//创建三维纹理
glCreateTextures(GL_TEXTURE_3D, 1, &m_iVolume);
//分配三维纹理的内存空间
glTextureStorage3D(m_iVolume, 1, GL_R32F, imageSize[0], imageSize[1], imageSize[2]);
//设置三维纹理的数据
glTextureSubImage3D(m_iVolume,
0,
0, 0, 0,
imageSize[0], imageSize[1], imageSize[2],
GL_RED, GL_FLOAT,
imageData);
需要注意的是 glTextureStorage3D 的参数GL_R32F是内部格式,即要求OpenGL以怎样的方式使用这张纹理。而 glTextureSubImage3D 中的 GL_RED 和 GL_FLOAT,是外部格式,描述的是传给OpenGL的数据的格式,也可以说是让OpenGL如何解释传给它的imageData。如果内部格式和外部格式不同,OpenGL会进行转化,对于体数据来说,这可能会很费时。
1.4 设置帧缓存
如果需要将图像绘制到某个指定的内存中,而不是屏幕上,则需要创建自己的帧缓存,并在绘制之前绑定它。
FBO ( Frame Buffer Object ) 并不直接存放数据,画面中的数据是附件的形式存在的。一个帧缓存可以包含多个附件,因为一帧的内容可能不仅包括我们肉眼看到的图像,如果开启深度测试,帧缓存中还会存放片元在相机中的深度,用来进行深度比较。
因此创建帧缓存时,除了创建 FBO,还需要创建真实存放数据的对象,它可以是一个纹理,也可以是渲染缓存对象 RBO ( Render Buffer Object ) 。
RBO ( Render Buffer Object ) 可以和 FBO 的附件进行绑定
这里使用OpenGL 4.5进行代码演示
m_iTargetSize[0] = 512;
m_iTargetSize[1] = 512;
//render to texture
//glCreateTextures(GL_TEXTURE_2D, 1, &m_iRenderTarget);
//glTextureStorage2D(m_iRenderTarget, 1, GL_R32F, m_iTargetSize[0], m_iTargetSize[1]);
//render to buffer
glCreateRenderbuffers(2, m_iRBOs);
glNamedRenderbufferStorage(m_iRBOs[0], GL_R32F, m_iTargetSize[0], m_iTargetSize[1]);
glNamedRenderbufferStorage(m_iRBOs[1], GL_DEPTH_COMPONENT, m_iTargetSize[0], m_iTargetSize[1]);
glCreateFramebuffers(1, &m_iFBO);
glNamedFramebufferRenderbuffer(m_iFBO, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, m_iRBOs[0]);
glNamedFramebufferRenderbuffer(m_iFBO, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, m_iRBOs[1]);
用纹理做帧缓存附件,可以将一个Pass ( 渲染流程 )的结果传给下一个Pass使用。用RBO做附件,速度更快,一般用来读取渲染结果到内存。
2.渲染
渲染部分就是直接调用函数了。
2.1 渲染到屏幕
void VRT::Render()
{
glClearColor(0.0, 0.0, 0.0, 0.0);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(m_iShader);
glBindTextureUnit(0, m_iVolume);
glBindVertexArray(m_iVAO);
glDrawElements(GL_TRIANGLES, sizeof(indices), GL_UNSIGNED_INT, 0);
}
2.2 渲染到内存
void VRT::RenderToTarget(float* _pData, UInt _x, UInt _y)
{
if (_x > m_iTargetSize[0] && _y > m_iTargetSize[1])
{
_pData = nullptr;
return;
}
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_iFBO);
Render();
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBindFramebuffer(GL_READ_FRAMEBUFFER, m_iFBO);
glReadBuffer(GL_COLOR_ATTACHMENT0);
glReadPixels(0, 0, m_iTargetSize[0], m_iTargetSize[1], GL_RED, GL_FLOAT, _pData);
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
}
3.体绘制算法
这里使用一个极简的着色器来展示体绘制原理,这个程序只能从一个固定的方向观察体数据。
3.1着色器程序
我们需要绘制一个铺满平面的四边形,顶点着色器里只是将输入的顶点直接输出。四边形的顶点范围是-1到1,直接需要转换到0到1变成纹理坐标,就是每个点在体数据中对应的位置。
#version 460 core
layout(location = 0) in vec3 aPos;
out vec2 texCoord;
void main()
{
texCoord = vec2(aPos.x + 1.0, aPos.y + 1.0) * 0.5;
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
经过OpenGL图元装配和光栅化之后,可以得到一个铺满平面的四边形,这样屏幕的四个角刚好对应四边形的四个角,而屏幕正好对应这体数据的一个面。以这个面上的点作为起始点进行光线步进 ( Ray Marching )
#version 460 core
#define RAY_STEP 10
uniform sampler3D volume;
in vec2 texCoord;
out vec4 FragColor;
void main()
{
vec3 StartPos = vec3(texCoord.xy, 0.0);//优化点 1
vec3 Step = vec3(0.0, 0.0, 0.1);
vec3 CurrentPos = StartPos;
float res = 0.0;
for (int i = 0; i < 10; i++)
{
float value = texture(volume, CurrentPos).r;//优化点 2
res += value;//优化点 3
CurrentPos += Step;
}
FragColor = vec4(res);
}
StartPos 是光线步进的起始点,Step是光线步进的步长。
沿着z方向步进十次,每步进一次,在当前位置采样一次体数据,将这十次采样的结果叠加,显示在屏幕上,这便是一个最简单的体绘制了。
3.2 进阶
这个体绘制Demo在以下两个方面进行改进
-
光线步进的起始点以及方向
从不同的方向观察体数据 -
使用 Transfer Function 对采样得到的值进行映射
可以突出显示感兴趣区域,剔除无关数据 -
采样结果的叠加方式
直接影响体绘制效果,稍作修改直接变成Surface Shaded Display, Min/Max Intensity Projection