FPS游戏中的喷漆效果原理
最近导师给了个项目任务,要做一个类似投影仪的效果。就是用相机拍摄高铁站的一段视频,然后捕捉高铁站的点云数据,根据点云重建出粗略的高铁站模型网格,然后将拍摄的视频投影到网格上,实现较为真实的效果。在做投影效果的时候,突然想到,这和FPS游戏里喷漆的效果非常像,就是一个用图片作为贴图,一个把视频作为贴图,原理上都一样的。
在想到喷漆的时候,我脑子里第一个出来的就是深度图。然后又想到这和实现阴影映射不是一摸一样嘛。只不过在计算阴影的时候取深度大于深度图,而这里我们只要取小于等于深度图的就可以了。
阴影映射
这里我们就要提一下深度贴图实现阴影的原理,该方法称为阴影映射。阴影映射原理就是我们以光的位置为视角进行渲染,我们能看到的东西都将被点亮,看不见的就是在阴影之中了。
如图所示,蓝色的部分即为接收到光线的部分,而黑色的部分则为处于阴影的部分
算法原理也很简单如下图:
左侧的图片展示了一个定向光源(所有光线都是平行的)在立方体下的表面投射的阴影。通过储存到深度贴图中的深度值,我们就能找到最近点,用以决定片元是否在阴影中。我们使用一个来自光源的视图和投影矩阵来渲染场景就能创建一个深度贴图。这个投影和视图矩阵结合在一起成为一个T变换,它可以将任何三维位置转变到光源的可见坐标空间。
喷漆实现
我们的喷漆效果就是和上图一样的原理,将光源作为喷漆时的摄像机,将当前渲染的深度值保存在深度贴图中。然后在我们自己移动视口观察时,将观察到的信息,通过T变换转换到第一个喷漆的摄像机空间中,再对比深度值,将深度小于等于深度贴图的信息置换成喷漆的纹理。
生成深度贴图的代码如下:
GLuint genDepthMap(){
const GLuint SHADOW_WIDTH = 2*winWidth, SHADOW_HEIGHT = 2*winHeight;
glGenFramebuffers(1, &depthMapFBO);
GLuint depthMap;
glGenTextures(1, &depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT,
SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
GLfloat borderColor[] = { 0.0, 0.0, 0.0, 0.0 };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
return depthMap;
}
我们根据喷漆的大小制定纹理的长宽,并用全局变量depthMapFBO创建桢缓冲去储存深度贴图。这里注意的是,当纹理坐标越界的时候,我们使用GL_CLAMP_TO_BORDER使越过边界的纹理自动用深度0.0去填充。保证我们看到的物体在经过T变换之后,其位置相对于喷漆摄像空间越界时(即坐标在(0,1)之外),始终不显示喷漆。如果这里使用REPEAT(重复)或者其他的纹理铺盖方式则会错。这里依旧用我的bunny来展示:
其中左图(使用REPEAT)出现深度错误的现象,而右图(使用CLAMP_TO_BORDER)则能正确的给上喷漆。其中每幅图右上角的小图则是我们保存的深度贴图。拍摄方向也就是我们在喷漆时摄像机的位置和方向。
接下来我们看一下depthMapFBO桢缓冲生成深度贴图的shader。
#version 410 core
layout(location=0) in vec3 position;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
gl_Position = projection*view*model*vec4(position,1.0);
}
在顶点着色器里,我们就简单的将传入的坐标通过喷漆空间变换,输出即可。其中projection矩阵选择透视变换矩阵,view矩阵为喷漆时摄像机对应方向的矩阵。
片元着色器则更为简单:
#version 410 core
out vec4 fragColor;
void main(){
fragColor=vec4(vec3(gl_FragCoord.z),1.0);
}
其中main函数中的fragColor=vec4(vec3(gl_FragCoord.z),1.0);这句可以删掉也没有任何影响,我这里只是用于显示调试。因为我们之前在设置桢缓冲的时候设置了glDrawBuffer(GL_NONE)和glReadBuffer(GL_NONE)。这两行代码表示我们对缓冲设为不需要绘制,也不需要读取。因此我们没有输出颜色值,深度值也已经自动保存了。
到这里,如果代码正确的话,我们可以将保存的深度贴图绘制出来就是右上角的图片中的样子了:
到这里,我们就可以写在主视图里的shader了。
首先我们看顶点着色器:
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
out vec3 Normal;
out vec3 FragPos;
out vec2 TexCoords;
void main()
{
gl_Position = projection*view*model*vec4(position, 1.0f);
Normal=mat3(transpose(inverse(model)))*normal;
FragPos=vec3(model*vec4(position,1.0f));
TexCoords=texCoords;
}
这里我们传入了位置,法线和纹理坐标。以及使用uniform传入了三个空间变换矩阵。但是这里的view,projection矩阵不同于喷漆相机空间的矩阵。这里是我们自己主空间观察的相机视口矩阵和投影矩阵。然后我们把法线,片元位置以及纹理坐标作为输出给片元着色器使用。
片元着色器代码如下所示:
#version 330 core
struct Material{
vec3 ambient;
vec3 diffuse;
vec3 specular;
float shininess;//反光度
};
struct Light{
vec3 position;
float ambient;
float diffuse;
float specular;
};
in vec3 Normal;
in vec3 FragPos;
in vec2 TexCoords;
uniform vec3 LightColor;
uniform Material material;
uniform Light light;
uniform vec3 eyePos;
out vec4 FragColor;
uniform sampler2D depthMap;
uniform sampler2D tex1;
uniform mat4 LightSpaceMatrix;
const float bias=0.05;
const float near_plane=0.1;
const float far_plane=50.0;
float LinearizeDepth(float depth)
{
float z = depth * 2.0 - 1.0; // Back to NDC
return (2.0 * near_plane * far_plane) / (far_plane + near_plane - z * (far_plane - near_plane));
}
void main()
{
vec3 LightDirection=normalize(light.position-FragPos);
vec3 Norm=normalize(Normal);
vec3 ViewDirection=-normalize(FragPos-eyePos);
vec3 ambient=light.ambient*LightColor*material.ambient;
vec3 diffuse=light.diffuse*LightColor*max(dot(Norm,LightDirection),0.0f)*material.diffuse;
vec3 specular=light.specular*LightColor*pow(max(dot(normalize(LightDirection+ViewDirection),Norm),0.0f),material.shininess)*material.specular;
vec3 result=(ambient+diffuse+specular);
//深度比较
vec4 LightSpacePos=LightSpaceMatrix*vec4(FragPos,1.0);
vec3 aftPos=LightSpacePos.xyz/LightSpacePos.w;
aftPos=aftPos*0.5+0.5;
float befdepth=LinearizeDepth(texture(depthMap,aftPos.xy).r);
if (LinearizeDepth(aftPos.z<=befdepth+bias&&LinearizeDepth(aftPos.z)>0.1+bias)
{
FragColor=texture(tex1,aftPos.xy);
}else{
FragColor=vec4(result,1.0f);
}
}
其中Material和Light是场景中的灯光和材质熟悉,我们可以简单的设置其属性。如下是我上图效果的灯光和材质熟悉。
lightShader.setVec3("material.ambient", 0.19225f, 0.19225f, 0.19225f);
lightShader.setVec3("material.diffuse", 0.50754f, 0.50754f, 0.50754f);
lightShader.setVec3("material.specular", 0.508273f, 0.508273f, 0.508273f);
lightShader.setFloat("material.shininess", 128.0f*0.4f);
lightShader.setFloat("light.ambient", 1.0f);
lightShader.setFloat("light.diffuse", 1.0f);
lightShader.setFloat("light.specular", 1.0f);
lightShader.setVec3("LightColor", glm::vec3(1.0f));
然后我们还需要每一帧在uniform传入eyePos即摄像机的位置,以用来在光照模型中计算反射和高光,这里就不细讲了。然后我们还用uniform传入了刚才在桢缓冲中绘制好的深度贴图depthMap,以及网上找的一张岩石图tex1,用来表示喷漆图案。LightSpaceMatrix则为我们所说的T变换矩阵,就是喷漆相机的projection*view矩阵。
然后看我们的这几行代码
const float near_plane=0.1;
const float far_plane=50.0;
float LinearizeDepth(float depth)
{
float z = depth * 2.0 - 1.0; // Back to NDC
return (2.0 * near_plane * far_plane) / (far_plane + near_plane - z * (far_plane - near_plane));
}
这个线性化深度函数是我们将透视矩阵的深度值从标准化空间转换到摄像机近平面near_plane到远平面far_plane的线性插值。用于更好的进行深度比对。
然后在main函数中
vec3 LightDirection=normalize(light.position-FragPos);
vec3 Norm=normalize(Normal);
vec3 ViewDirection=-normalize(FragPos-eyePos);
vec3 ambient=light.ambient*LightColor*material.ambient;
vec3 diffuse=light.diffuse*LightColor*max(dot(Norm,LightDirection),0.0f)*material.diffuse;
vec3 specular=light.specular*LightColor*pow(max(dot(normalize(LightDirection+ViewDirection),Norm),0.0f),material.shininess)*material.specular;
vec3 result=(ambient+diffuse+specular);
这里的代码,计算了光线方向,法线方向以及视口方向。然后使用它们计算一个包含环境光,漫反射以及镜面反射的基础光照模型。
在之后的深度比较代码里
//深度比较
vec4 LightSpacePos=LightSpaceMatrix*vec4(FragPos,1.0);
vec3 aftPos=LightSpacePos.xyz/LightSpacePos.w;
aftPos=aftPos*0.5+0.5;
float befdepth=LinearizeDepth(texture(depthMap,aftPos.xy).r);
if (LinearizeDepth(aftPos.z<=befdepth+bias&&LinearizeDepth(aftPos.z)>0.1+bias)
{
FragColor=texture(tex1,aftPos.xy);
}else{
FragColor=vec4(result,1.0f);
}
其中我们获取当前的坐标在喷漆空间下的位置,然后对其标准化(通过除以其次项w)。然而这时的坐标都为(-1,1),由于我们需要通过纹理坐标获得深度,纹理坐标范围为(0,1),并且深度缓冲的深度也是(0,1),因此我们需要对其变换到(0,1)的范围内。然后我们将之前喷漆空间内的深度和当前变换的深度进行线形变化后再比对。然而我们这里需要在之前的喷漆空间的深度加一个小小的偏移量,否则会出现下图效果。
这种条纹状的原理是因为:
如图所示,我们在喷漆空间的深度贴图是以像素保存的,每个像素在当前空间下观察会有一半在物体下面,一半在物体上面。因此,我们只要给一个小小的偏移值,使其位于当前观察的上方。
我们在进行比对便不会出现这种效果,我这里给的偏移值是bias=0.05;
之后我们还给出了一个限制条件是LinearizeDepth(aftPos.z)>0.1+bias。即变换后的深度大于0.1加上深度值,是因为我们的摄像机近平面是从0.1开始的,如果没有加入这个限制条件的话,摄像机的背面则也会进行深度比对,生成喷漆的图,这显然不是我们要的效果。
到这里我们的喷漆效果就很好的完成了。