练习一、阴影贴图

阴影贴图实际上是基于光线是否能看到某个地方,而是否能看到可以通过深度信息的计算来实现。因此,首先我们需要配置深度缓冲区和深度纹理。代码如下

    unsigned int depthMapFBO;
    glGenFramebuffers(1, &depthMapFBO);
    // 创建深度(阴影)纹理
    unsigned int 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_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    // 将深度纹理附加到帧缓冲对象depthMapFBO
    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);

然后我们需要从光源视角去绘制物体,实现阴影的效果。由于我们只需要填充深度信息,因此这里对应的阴影片段着色器不需要执行任何操作,我们重点看下场景的搭建,也就是构建MVP矩阵,代码如下

        // 对于平行光,使用正交矩阵
        lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);
        lightView = glm::lookAt(lightPos, glm::vec3(0.0f), glm::vec3(0.0, 1.0, 0.0));
        lightSpaceMatrix = lightProjection * lightView;
		// 渲染光源视角场景
        simpleDepthShader.use();
        simpleDepthShader.setMat4("lightSpaceMatrix", lightSpaceMatrix);
        glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
        //填充深度缓冲区
        glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
            glClear(GL_DEPTH_BUFFER_BIT);
            glActiveTexture(GL_TEXTURE0);
            glBindTexture(GL_TEXTURE_2D, woodTexture);
            renderScene(simpleDepthShader);
        glBindFramebuffer(GL_FRAMEBUFFER, 0);

其中的renderScene函数这里是渲染正方体、平面等场景物体。填充好后,我们先对深度贴图进行可视化(用于调试),代码如下

        // 重置视图
        glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
        // 可视化深度贴图
		debugDepthQuad.use();
		debugDepthQuad.setFloat("near_plane", near_plane);
		debugDepthQuad.setFloat("far_plane", far_plane);
		glActiveTexture(GL_TEXTURE0);
		glBindTexture(GL_TEXTURE_2D, depthMap);
		renderQuad();

其中renderQuad函数是渲染全屏四边形(Quad),将阴影以纹理的形式,对光源视角的场景进行输出,效果如下

接下来添加普通着色器,渲染带有阴影的场景,代码如下

        // 渲染带有阴影的场景
        shader.use();
        glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
        glm::mat4 view = camera.GetViewMatrix();
        shader.setMat4("projection", projection);
        shader.setMat4("view", view);
        // 光源分量
        shader.setVec3("viewPos", camera.Position);
        shader.setVec3("lightPos", lightPos);
        shader.setMat4("lightSpaceMatrix", lightSpaceMatrix);
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, woodTexture);
        glActiveTexture(GL_TEXTURE1);
        glBindTexture(GL_TEXTURE_2D, depthMap);     //绑定深度(阴影)贴图
        renderScene(shader);

这里的lightSpaceMatrix矩阵只包含视图和投影矩阵,因此在这个shader的顶点着色器中,还要与模型矩形右乘,接下来重点看下片段着色器的代码

float ShadowCalculation(vec4 fragPosLightSpace)
{
    // 齐次坐标转标准设备坐标(NDC)
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    // 转纹理坐标
    projCoords = projCoords * 0.5 + 0.5;
    // 获取光源视角下最近表面的深度值
    float closestDepth = texture(shadowMap, projCoords.xy).r; 
    float currentDepth = projCoords.z;
    // 当前片元深度大于采样深度则判定为阴影(1.0)
    float shadow = currentDepth > closestDepth  ? 1.0 : 0.0;

    return shadow;
}
void main()
{           
    ...
    // specular
    vec3 viewDir = normalize(viewPos - fs_in.FragPos);
    //vec3 reflectDir = reflect(-lightDir, normal); Phong模型
    float spec = 0.0;
    vec3 halfwayDir = normalize(lightDir + viewDir); 
    //  Blinn-Phong模型
    spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
    vec3 specular = spec * lightColor;    
    // shadow
    float shadow = ShadowCalculation(fs_in.FragPosLightSpace);                      
    vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;     
    FragColor = vec4(lighting, 1.0);
}

这里用的Blinn-Phong模型,比之前用的Phong模型计算效率更高,并且高光效果也柔和一点。最终效果如下

虽然实现了阴影,但画面有很多的线条,称为阴影伪影(具体为波纹伪影),是由纹理贴图和深度计算之间的精度差引起的,如何解决呢,上代码!

    // 修改创建深度贴图部分
    ...
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,  /*GL_REPEAT*/GL_CLAMP_TO_BORDER);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,  /*GL_REPEAT*/GL_CLAMP_TO_BORDER);
    float borderColor[] = { 1.0, 1.0, 1.0, 1.0 };
    glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

首先将纹理换行模式换成了“夹紧到边界”,避免重复阴影,然后将边界颜色设为白色(1.0)。也就是说,当纹理坐标超出有效范围(如阴影区域外),自动返回白色的borderColor而非进行纹理插值,确保阴影边界清晰。接下来看片段着色器的修改部分

    //修改ShadowCalculation部分
    ...
    // 计算动态的阴影偏移bias
    vec3 normal = normalize(fs_in.Normal);
    vec3 lightDir = normalize(lightPos - fs_in.FragPos);
    // 设置最小偏移0.005防止过校正
    float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
    // PCF百分比渐进过滤
    float shadow = 0.0;
    // texelSize确保采样偏移与阴影贴图分辨率匹配
    vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
    // 3×3 进行多次深度采样
    for(int x = -1; x <= 1; ++x)
    {
        for(int y = -1; y <= 1; ++y)
        {   
            //projCoords.xy + vec2(x, y) * texelSize计算偏移采样坐标 
            //texture().r 读取阴影贴图中对应位置的深度值
            float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
            shadow += currentDepth - bias > pcfDepth  ? 1.0 : 0.0;        
        }    
    }
    //取平均
    shadow /= 9.0;
    
    // 处理超出光源视锥体的区域
    if(projCoords.z > 1.0)
        shadow = 0.0;

这里的PCF(Percentage Closer Filtering)百分比渐进过滤,用来减少锯齿线伪影,后续练习二还能用来生成柔和阴影,最终效果如下~

练习二、点光源阴影

练习一实际上是模拟的平行光产生的阴影,现在我们来看下点光源产生的阴影。

与平行光不同的是,由于点光源需要全向辐射,因此需要六个正交方向的深度数据去覆盖所有的投射角度!部分代码如下

    // 创建立方体贴图
    unsigned int depthCubemap;
    glGenTextures(1, &depthCubemap);
    glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
    for (unsigned int i = 0; i < 6; ++i)
        glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);

这里还设置了R坐标,也就是Z坐标的环绕。接下来我们要给出立方体贴图六个面对应的变换矩阵,方便后续几何着色器单次完成六个面的顶点变换

        float near_plane = 1.0f;
        float far_plane  = 25.0f;
        glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), (float)SHADOW_WIDTH / (float)SHADOW_HEIGHT, near_plane, far_plane);
        std::vector<glm::mat4> shadowTransforms;
        shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 1.0f,  0.0f,  0.0f), glm::vec3(0.0f, -1.0f,  0.0f)));
        shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3(-1.0f,  0.0f,  0.0f), glm::vec3(0.0f, -1.0f,  0.0f)));
        shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 0.0f,  1.0f,  0.0f), glm::vec3(0.0f,  0.0f,  1.0f)));
        shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 0.0f, -1.0f,  0.0f), glm::vec3(0.0f,  0.0f, -1.0f)));
        shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 0.0f,  0.0f,  1.0f), glm::vec3(0.0f, -1.0f,  0.0f)));
        shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 0.0f,  0.0f, -1.0f), glm::vec3(0.0f, -1.0f,  0.0f)));

然后将场景填充到立方体贴图中,同时传递上面的变换矩阵。这里的场景包含了一个大的正方体,模拟在房间内的感觉,因此需要调用 glDisable(GL_CULL_FACE),去禁用剔除功能,让正方体的内外都渲染出来(因此调用后也会产生额外计算),填充代码如下

        glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
        glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
            glClear(GL_DEPTH_BUFFER_BIT);
            simpleDepthShader.use();
            for (unsigned int i = 0; i < 6; ++i)
                simpleDepthShader.setMat4("shadowMatrices[" + std::to_string(i) + "]", shadowTransforms[i]);
            simpleDepthShader.setFloat("far_plane", far_plane);
            simpleDepthShader.setVec3("lightPos", lightPos);
            renderScene(simpleDepthShader);
        glBindFramebuffer(GL_FRAMEBUFFER, 0);

紧接着,我们看下几何着色器的代码

...
void main()
{
    for(int face = 0; face < 6; ++face)
    {
        gl_Layer = face; // 指定渲染的目标面
        for(int i = 0; i < 3; ++i) 
        {
            FragPos = gl_in[i].gl_Position;
            gl_Position = shadowMatrices[face] * FragPos;
            EmitVertex();
        }    
        EndPrimitive();
    }
} 

这里遍历六个面,以及每个面用一个三角形去表示,因此顶点缓存最大数量为18。由于这里用的透视矩阵perspective,因此在片段着色器中还需要计算下深度缓冲值gl_FragDepth。其他代码部分和平行光阴影类似,就不赘述了,最终效果如下~

但是实际生活中的话,在阴影的边缘部分,其阴影效果是会比较浅比较模糊的,因此我们还需要再优化一下,达到软阴影的效果。需要修改下片段着色器中计算阴影的部分,代码如下

    vec3 fragToLight = fragPos - lightPos;
    float currentDepth = length(fragToLight);
    float shadow = 0.0;
    // 固定偏移量
    float bias = 0.15;
    int samples = 20;
    float viewDistance = length(viewPos - fragPos);
    //根据观察距离动态调整采样范围
    float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;
    for(int i = 0; i < samples; ++i)
    {
        float closestDepth = texture(depthMap, fragToLight + gridSamplingDisk[i] * diskRadius).r;
        closestDepth *= far_plane;   // 还原为实际深度值
        if(currentDepth - bias > closestDepth)
            shadow += 1.0;
    }
    shadow /= float(samples);

不太一样的地方是,首先使用了动态采样半径,与练习一的固定一个纹素texelSize相比远景阴影更加自然。其次使用了PCF方式,并且采样次数为20次,使得阴影边缘过渡更加平滑,也就是我们实现柔和阴影的核心方式。最终效果如下

练习三、法线贴图

现在用2D纹理贴图实现了一面墙,如下所示

如果想要增强凹凸细节和光照真实感,,就需要用到我们的法线贴图~

法线贴图不需要细分曲面等复杂计算,其核心点是采用TBN矩阵,用来连接模型空间和切线空间。其中,‌T (Tangent)矩阵分量用来表示纹理坐标U轴的变化情况,B (Bitangent/副切线)用来表示纹理坐标V轴的变化情况,N (Normal)‌是模型表面的法线,并且满足B = cross(N, T)。我们重点看下着色器的代码

    // 顶点着色器
    ...
    mat3 normalMatrix = transpose(inverse(mat3(model)));    // 避免不均匀操作后,法线失效
    vec3 T = normalize(normalMatrix * aTangent);
    vec3 N = normalize(normalMatrix * aNormal);
    T = normalize(T - dot(T, N) * N);   // 施密特正交化,确保T和N正交
    vec3 B = cross(N, T);
    
    mat3 TBN = transpose(mat3(T, B, N));    // 分量从行向量转置为列向量
    vs_out.TangentLightPos = TBN * lightPos;
    vs_out.TangentViewPos  = TBN * viewPos;
    vs_out.TangentFragPos  = TBN * vs_out.FragPos;  // 转换到切线空间

顶点着色器的核心目标是获取切线空间的相应坐标,方便在片段着色器中计算出漫反射和镜面反射光。接下来看片段着色器如何计算法向量

    // 从法向贴图中获取RGB值,范围[0,1]
    vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
    // 转换为法线空间, 范围[-1,1]
    normal = normalize(normal * 2.0 - 1.0); 

最终效果如下~

最后,水平有限,欢迎指正和交流~觉得有用的话点点赞或收藏吧~

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐