概念引入
《Real-time Rendering》一书中介绍了多种轮廓线提取的方式,根据对比以及综合考虑,最终使用了图像空间的方法来实现实时提取轮廓。该方法需要先将场景中的物体信息先渲染到纹理中,对使用延迟渲染的框架非常友好,并且弊端也是多种轮廓线提取方法中比较少的,具体内容可以参照原书的NPR章节。
方法概述
(1) 准备2个颜色缓冲区,1个深度缓冲区。
(2) 先用一般的方法渲染一遍场景。手动将颜色,法线(世界空间)+深度分别写入两个颜色缓冲区。深度缓冲区会自动写入。
(3) 传入上一个pass得到的颜色和法线/深度纹理,对法线+深度图进行处理。对每个片段,使用sobel算子六次采样法线+深度图,横向和纵向各一次,以检测法线或深度不连续的地方。根据是否为轮廓,来决定当前像素从颜色纹理采样,还是直接绘制轮廓色。
具体实现
(一)生成纹理图
我们首先要做的一步是将颜色,法线和深度渲染到纹理中,这意味着我们至少需要在一次drawcall中,渲染2张纹理。为了达到这一目的,我们需要使用MRT(多重渲染目标)技术。
和之前一样,在初始化的时候生成一个帧缓冲区:
RenderCommon::RenderCommon()
{
// ...
// GLuint gBuffer;
glGenFramebuffers(1, &gBuffer);
// ...
}
接下来,在窗口resize的时候生成绑定到这个帧缓冲区的几个纹理:
void MainWidget::resizeGL(int w, int h)
{
float aspect = float(w) / float(h ? h : 1);
const qreal zNear = 2.0, fov = 45.0;
RenderCommon::Inst()->SetScreenXY(w, h);
RenderCommon::Inst()->UpdateTexSize(); // 更新纹理大小
RenderCommon::Inst()->GetProjectMatrix().setToIdentity();
RenderCommon::Inst()->GetProjectMatrix().perspective(fov, aspect, zNear,
RenderCommon::Inst()->GetZFarPlane());
}
void RenderCommon::UpdateTexSize()
{
// 删除旧的纹理(如果存在的话)
glDeleteTextures(2, gBufferTex);
glDeleteTextures(1, &gBufferDepthTex);
// 激活gBuffer缓冲区
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
// 分配两个颜色缓冲区的纹理
glGenTextures(2, gBufferTex);
for(int i = 0;i < 2; i++)
{
// 激活第i个颜色缓冲区
glBindTexture(GL_TEXTURE_2D, gBufferTex[i]);
// 生成纹理并初始化纹理信息
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F_ARB, screenX, screenY,
0, GL_RGBA, GL_FLOAT, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);
// 将该纹理绑定到当前激活缓冲区,其中GL_COLOR_ATTACHMENT0,GL_COLOR_ATTACHMENT1
// 代表绑定的第几个纹理
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D,
gBufferTex[i], 0);
}
// 分配深度纹理
glGenTextures(1, &gBufferDepthTex);
// 激活深度纹理
glBindTexture(GL_TEXTURE_2D,gBufferDepthTex);
// 生成深度纹理并初始化纹理信息
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, screenX, screenY, 0,
GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL);
// 将该纹理绑定到当前激活缓冲区
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D,
gBufferDepthTex, 0);
// 指定当前用了哪些颜色缓冲区
GLuint attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1};
glDrawBuffers(2, attachments);
// 清空激活状态
glBindTexture(GL_TEXTURE_2D, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
关于这一步骤,有几个要注意的关键点:
(1) 生成纹理和绑定这一步放在窗口的resize中,是为了随着屏幕大小变化,生成和屏幕大小一样大的纹理。
(2) 通过设置GL_COLOR_ATTACHMENT0,GL_COLOR_ATTACHMENT1等,可以指定绑定到第几个颜色缓冲区,这几个枚举量是连续的,所以可以直接写成循环的形式。
(3) 需要调用glDrawBuffers来指定我们使用了多少个颜色缓冲区,以及分别是什么。如果没有这一步,在多个缓冲区的情况下,默认只有第一个是生效的。
(4) 深度缓冲区我们只需要生成并绑定到帧缓冲区即可,不需要读写操作。这一步是不可少的,不然就没有系统自动为我们做的深度测试相关操作,可能出现遮挡关系不对的问题。
接下来,我们在片元着色器加几个重定向的操作,对不同的缓冲区分别写入数据:
layout(location = 0) out vec4 Color;
layout(location = 1) out vec4 Normal;
void main()
{
// Color = ...
// Normal =
}
通过上述两个重定向标记,我们就不需要写入gl_FragColor,直接填充Color和Normal即可。
(二) sobel算子检测
我们认为法线或者深度不连续的地方为轮廓,而不连续的地方一般是一阶导数发生突变的地方。所以我们使用了图像的一 个一阶导数算子——sobel算子进行计算,它包含了x和y方向的两个算子,分别可以求出x和y两个方向的图像导数。具体的运算 + 比较方法如下:
-1 -2 -1 -1 0 1
[ 0 0 0 ] [ -2 0 2 ]
1 2 1 -1 0 1
sobel_y sobel_x
#version 450 core
varying vec2 v_texcoord;
uniform float zFar;
uniform sampler2D Color;
uniform sampler2D Normal;
uniform int ScreenX;
uniform int ScreenY;
float sobel_y[] =
{
-1,-2,-1,
0 , 0, 0,
1 , 2, 1,
};
float sobel_x[] =
{
-1, 0, 1,
-2, 0, 2,
-1, 0, 1,
};
void main(void)
{
vec4 curNormalX = vec4(0, 0, 0, 0);
int k = 0;
for(int i = -1; i <=1 ;i++)
{
for(int j = -1; j <= 1;j++)
{
vec4 ret = texture2D(Normal, v_texcoord + vec2(1.0 * i / ScreenX, 1.0 * j / ScreenY));
curNormalX += sobel_x[k++] * ret;
}
}
k = 0;
vec4 curNormalY = vec4(0, 0, 0, 0);
for(int i = -1; i <=1 ;i++)
{
for(int j = -1; j <= 1;j++)
{
curNormalY += sobel_y[k++] * texture2D(Normal, v_texcoord + vec2(1.0 * i / ScreenX, 1.0 * j / ScreenY));
}
}
vec4 size = sqrt(curNormalX * curNormalX + curNormalY * curNormalY);
gl_FragColor = texture2D(Color, v_texcoord);
float threshold = 100.0/zFar;
if(size.x > threshold || size.y > threshold || size.z > threshold || size.w > 1.0/zFar)
{
gl_FragColor = vec4(0,0,0,1);
}
}
最终得到的是一个x方向的导数值gx,和y方向的导数值gy。
我们可以利用这两个值得到方向值(tan(gy/gx)),以及模长(√(gx^2 + gy^2),此处先用了模长做判断,还没有试过方向,所在这两者哪个效果更准确上还没有研究。
我们比较的阈值是一个和远裁剪面相关的值。
发一处比较有意思的,被近裁剪面剪掉的部分,也有轮廓线。