tinyrenderer笔记(Shadow Mapping)

  • tinyrenderer
  • 个人代码仓库:tinyrenderer个人练习代码

前言

阴影是光线被阻挡的结果;当光源的光线由于其他物体的阻挡而无法到达物体表面时,该物体就会产生阴影。阴影能使场景看起来更真实,并让观察者获得物体之间的空间位置关系。场景和物体的深度感因此能得到极大提升。下图展示了有阴影和没有阴影的情况下的不同:

image.png

可以看到,有阴影时,你能更容易地区分物体之间的位置关系。例如,使用阴影时,浮在地板上的立方体的真实感更加清晰。

本节我们会手动实现一种硬阴影算法——Shadow Mapping。

Shadow Mapping

Shadow Mapping 背后的思路非常简单:我们以光源的位置为视角进行渲染,能被光源看到的东西都将被照亮,看不见的则处于阴影之中。假设有一个地板,光源和地板之间有一个大盒子。由于从光源处向光线方向看去,可以看到这个盒子,但看不到地板的一部分,这部分就应该在阴影之中。

下图中的所有蓝线代表光源可以看到的片段。黑线代表被遮挡的片段:它们应该渲染为带阴影的。如果我们绘制一条从光源出发,到达最右边盒子上的一个片段上的线段或射线,那么射线将先击中悬浮的盒子,随后才会到达最右侧的盒子。结果就是悬浮的盒子被照亮,而最右侧的盒子将处于阴影之中。

image.png

我们希望得到射线击中物体的第一个点,然后用这个最近点和射线上其他点进行对比。然后我们会做一个测试,判断测试点是否比射线上对应的最近点距离更远,如果是的话,这个点就在阴影中。

那么如何获得这个最近点呢?我们先将摄像机放在光源的位置,然后对整个场景进行渲染,进行深度测试但不着色,待渲染结束之后将此时 zbuffer 的数据写入纹理中,这个纹理就被称为 Shadow Map 或 Depth Map。

image.png

左图展示了一个定向光源(所有光线都是平行的)在立方体下方表面投射的阴影。通过存储在 Depth Map 中的深度值,我们可以找到最近点,并用它来决定片段是否在阴影中。我们可以使用来自光源的视图和投影矩阵来渲染场景,从而创建一个 Depth Map。这个投影和视图矩阵结合在一起形成一个 T 变换,它可以将任何三维位置转换到光源的可见坐标空间。

在右图中,我们展示了相同的平行光和观察者。我们渲染一个 P ‾ \overline{P} P 点处的片段,需要确定它是否在阴影中。我们首先使用 T P ‾ \overline{P} P 变换到光源的坐标空间中。由于点 P ‾ \overline{P} P 是从光的透视图中看到的,它的 z 坐标对应于它的深度,在此例中,该值为 0.9。使用点 P ‾ \overline{P} P 在光源坐标空间中的坐标,我们可以索引 Depth Map,以获得从光视角看到的最近可见深度,结果是点 C ‾ \overline{C} C,最近深度为 0.4。由于索引 Depth Map 的结果小于点 P ‾ \overline{P} P 的深度,我们可以断定 P ‾ \overline{P} P 被遮挡,它在阴影中。

总结下来,Shadow Mapping 由两个步骤组成:

  1. 首先,我们渲染 Depth Map ;
  2. 然后,我们像往常一样渲染场景,并使用生成的 Depth Map 来计算片段是否在阴影中。

准备工作

为了更好地测试阴影的效果,设定一些参数如下:

Vec3f light_dir = Vec3f(-1, 0, 0);// 光线方向
Vec3f light_color = Vec3f(1, 1, 1);// 光线的颜色
Vec3f cameraPos = Vec3f(0, 0, 2);
Vec3f targetPos = Vec3f(0, 0, 0);

head 的模型矩阵如下:

glm::mat4 modelMatrix;
modelMatrix = glm::rotate(modelMatrix, 22.5, Vec3f(0, 1, 0));

注意定向光没有位置,因为它被定义为无穷远。然而,为了实现 Depth Map,我们必须从光的透视图渲染场景,因此需要在光的方向上的某个点渲染场景。假定光源位于 -light_dir * 10 的位置并朝原点看去,LookAt 矩阵如下:

glm::mat4 lightView = glm::lookAt(-light_dir * 10, Vec3f(0, 0, 0), Vec3f(0, 1, 0));

我们使用的是定向光,所以使用正交投影来为光源创建透视图:

glm::mat4 lightProjection = glm::ortho(-5.f, 5.f, -5.f, 5.f, 1.0f, 20.f);

为了更好地表达阴影的效果,在光源与 head 模型之间渲染一个长方体来遮挡光照:

glm::mat4 bricksModelMat;
bricksModelMat = glm::translate(bricksModelMat, Vec3f(1, 0, 0));
bricksModelMat = glm::rotate(bricksModelMat, -45, Vec3f(0, 1, 0));
bricksModelMat = glm::scale(bricksModelMat, Vec3f(2, 1, 1));

现在渲染的效果如下,最后我们期望在 head 模型上看到长方体遮挡出来的阴影:

image.png

Depth Map

为了生成 depth map,我们需要定义一组新的 shader,它的顶点着色器如下:

class DepthVertexShader : public VertexShader
{
public:virtual void ConfirmVaryingVar() override {}virtual Vec4f vertex(std::shared_ptr<VertexInfoBase> vertexInfos) override{glm::mat4 modelMatrix = GetUniformVar<glm::mat4>("Model");glm::mat4 lightSpaceMatrix = GetUniformVar<glm::mat4>("lightSpaceMatrix");return lightSpaceMatrix * modelMatrix * Vec4f(vertexInfos->location, 1.f);}
};

顶点着色器只将顶点转换到光空间即可。lightSpaceMatrix 就是将顶点转换到光空间的变换矩阵:

depthProgram->SetUniformVar<glm::mat4>("lightSpaceMatrix", lightProjection * lightView);

片段着色器也很简单,因为我们只关心渲染完成之后的 zbuffer,所以不用进行任何操作:

class DepthFragmentShader : public FragmentShader
{
public:virtual bool fragment(FragmentInfo& info) override{return false;}
};

我们定一个新的 zbuffer 数组专门用来存储深度值:

float* depthMapBuffer;
depthMapBuffer = new float[width * height];
std::fill(depthMapBuffer, depthMapBuffer + width * height, std::numeric_limits<float>::max());

渲染完成之后,将 depthMapBuffer 的值输出到纹理中:

std::shared_ptr<TGAImage> depthMap = std::make_shared<TGAImage>(width, height, TGAImage::RGB);
for (int i = 0; i < width * height; i++)
{int x = i % width;int y = i / width;if (depthMapBuffer[i] >= 1){	depthMap->set(x, y, white);}else{unsigned int color = depthMapBuffer[i] * 255;depthMap->set(x, y, TGAColor(color, color, color, 255));}
}
depthMap->flip_vertically();
depthMap->write_tga_file("depthMap.tga");

image.png

可以观察到长方体的颜色更偏黑,代表深度值小,位于更前面。

正常渲染

第二步就是正常的渲染场景。在顶点着色器中,我们使用相同的 lightSpaceMatrix,将世界空间顶点位置转换为光空间。顶点着色器传递一个普通的经变换的世界空间顶点位置 worldPos 和一个光空间的 posForLightSpace 给片段着色器。

片段着色器使用 Blinn-Phong 光照模型渲染场景。我们会计算出一个 shadow 值,当片段在阴影中时是 1.0,在阴影外是 0.0。然后,diffusespecular 颜色会乘以这个阴影分量。由于散射(环境光照)的存在,模型不会完全黑暗,所以不会对 ambient 分量进行乘法。

Vec3f I = ambient + (1 - shadow) * (diffuse + specular);

剩下要做的就是计算阴影分量 shadow。要检查片段是否处于阴影中,首先要做的是将光空间片段位置转换为裁剪空间的标准化设备坐标(NDC),进行透视除法:

float shadow = 0.f;
Vec3f projCoords = posForLightSpace / posForLightSpace.w;

这会将坐标转换到 [ − 1 , 1 ] [-1,1] [1,1] 之间。当使用正交投影矩阵时,顶点 w 分量保持不变,所以这一步实际上毫无意义。可是,当使用透视投影的时候就是必须的了,所以为了保证在两种投影矩阵下都有效就得留着这行。

随后我们需要将坐标转化到 [ 0 , 1 ] [0,1] [0,1] 之间:

projCoords = projCoords * 0.5 + Vec3f(0.5, 0.5, 0.5);

然后就可以对 depth map 进行采样了,得到最近的深度值:

float closestDepth = depthMap->texture(int(projCoords.x), int(projCoords.y)).r / 255.f;

然后将最近深度值与当前深度值进行比较,判断当前片段是否位于阴影之中:

float currentDepth = projCoords.z;
shadow = (currentDepth > closestDepth) ? 1.f : 0.f;

image.png

渲染出来的效果还不错,但是仔细观察红圈地方,你会发现这里出现了阴影!为什么会这样呢?这个问题被称为:Shadow Acne。

由于 Depth Map 受到分辨率的限制,在距离光源较远的情况下,多个片段可能从 Depth Map 的同一个值中采样。图中的每个斜坡代表 Depth Map 的一个单独纹理像素。可以看到,多个片段用同一个深度值进行采样。

image.png

虽然很多时候没问题,但是当光源以一个角度朝向表面时就会出现问题,在这种情况下,Depth Map 也是从一个角度下渲染的。多个片段会从同一个斜坡的 Depth Map 像素中采样,图中黄色与黑色交界处为实际采样点,左边区域的像素与光源的距离小于采样点则是明亮的(表面上面),右边区域的像素与光源的距离大于采样点则存在阴影(表面下面);这样我们所得到的阴影就有了差异。因此,有些片段被认为是在阴影之中,有些不在,由此产生了图中的条纹样式。

我们可以用一个叫做阴影偏移(shadow bias)的技巧来解决这个问题,我们简单地对表面的深度(或 Depth Map)应用一个偏移量,这样片段就不会被错误地认为在表面之下了。

image.png

使用偏移量后,所有采样点都获得了比表面深度更小的深度值(相当于所有点都更靠近了光源一些,但是 Depth Map 中的深度值没变),这样整个表面就正确地被照亮,没有任何阴影。我们可以这样实现这个偏移:

float bias = 0.0155f;
shadow = (currentDepth > closestDepth + bias) ? 1.f : 0.f;

image.png

需要注意的是这个 bias 值的大小完全取决于你渲染表面的坡度大小,不同的模型可能需要不同的 bias,有一个更加可靠的办法是根据表面朝向光线的角度更改偏移量,使用点乘:

float bias = std::max(0.0155 * (1.0 - std::max(normal.dot(lightDir), 0.f)), 0.001);

在 tinyrenderer 原教程中,并没有对 depth map 采样来获得深度值,而是直接访问 depthMapBuffer 缓冲区内的深度值来得到最近深度值的,这样也是可行的:

// 计算阴影
float shadow = 0.f;
{Vec4f projCoords = posForLightSpace / posForLightSpace.w;//projCoords = projCoords * 0.5 + Vec3f(0.5, 0.5, 0.5);//float closestDepth = depthMap->texture(projCoords.x, projCoords.y).r / 255.f;projCoords = m_program->GetViewPort() * projCoords;float closestDepth = depthMapBuffer[int(projCoords.x) + int(projCoords.y) * width];float currentDepth = projCoords.z;float bias = std::max(0.015 * (1.0 - std::max(normal.dot(lightDir), 0.f)), 0.001);shadow = (currentDepth > closestDepth + bias) ? 1.f : 0.f;
}

请格外注意我这部分代码:int(projCoords.x) + int(projCoords.y) * width ,强制类型转换的地方,因为循环遍历像素的时候,我们相当于直接截断了小数部分。

这种实现方式也会出现相似的问题:Z-fighting,所以也需要 bias 来缓解。

image.png

归根结底,Z-fighting 与 Shadow Acne 都是精度不够而导致的深度采样竞争,bias 能缓解但不能根除,而且也会带来其它的问题。

本次代码提交记录:

image.png

参考

  • tinyrenderer
  • 阴影映射 - LearnOpenGL CN

others

tinyrenderer 的工作暂时告一段落,还剩下最后一篇环境光遮蔽(Ambient Occlusion,AO)没有实现,当然重点是 SSAO——屏幕空间环境光遮蔽。SSAO 的理论其实并不复杂,但受限于我目前实现的框架,真正做的时候束手束脚的。现在缺少哪些必备功能呢?

TGAImage 能够存储 float 类型的数据 ——> Multiple Render Target 多重渲染目标 ——> 延迟着色 ——> SSAO

上面是我的理想工作流程,但这样一看花费的时间就有点多了。现在还有很多东西没学,所以暂告一段落吧。后面在推进这个软光栅项目时,肯定会进行大范围重构的。

最后感叹一句,有些东西看起来很简单,但是有很多坑,手动实现光栅化流程确实受益匪浅!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/pingmian/79664.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

debian中笔记本的省电选择auto-cpufreq

在reddit中&#xff0c;看评论区出现这个软件&#xff0c;于是打算尝试一下&#xff0c;应该能对不使用电源时笔记本的省电起到一定的作用。 https://github.com/AdnanHodzic/auto-cpufreq?tabreadme-ov-file#why-do-i-need-auto-cpufreq 作用 One of the problems with Linux…

Windows 查看电脑是否插拔过U盘

1、按 “WinR” 组合键打开 “运行” 对话框&#xff0c;输入 “regedit” 并回车&#xff0c;打开注册表编辑器。 2、依次展开HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Enum\USBSTOR注册表项&#xff0c;这里记录了所有已连接过的 USB 设备信息&#xff0c;包括 U 盘&am…

426、N叉树的层序遍历

输入检查&#xff1a; if not root:return [] 如果根节点为空&#xff0c;直接返回空列表 初始化&#xff1a; result [] queue collections.deque([root]) result用于存储最终结果queue初始化包含根节点&#xff0c;使用双端队列实现 主循环&#xff1a; while queue:leve…

【ES】Elasticsearch字段映射冲突问题分析与解决

在使用Elasticsearch作为搜索引擎时&#xff0c;经常会遇到一些映射(Mapping)相关的问题。本文将深入分析字段映射冲突问题&#xff0c;并通过原生的Elasticsearch API请求来复现和解决这个问题。 问题描述 在实际项目中&#xff0c;我们遇到以下错误&#xff1a; Transport…

小红书怎么看自己ip地址?小红书更改ip地址教学

在社交媒体高度透明的今天&#xff0c;小红书等平台公开用户IP属地的功能引发了广泛讨论。无论是出于隐私保护的担忧&#xff0c;还是因需要切换属地&#xff0c;许多用户都迫切想知道&#xff1a;能否通过手动修改“伪装”所在地&#xff1f; 事实上&#xff0c;IP属地可能影…

深入理解 Java 观察者模式:原理、实现与应用

在软件开发领域&#xff0c;设计模式堪称开发者智慧的凝练结晶&#xff0c;它们为解决各类常见编程难题提供了行之有效的方案。观察者模式&#xff08;Observer Pattern&#xff09;作为行为型设计模式的重要一员&#xff0c;在处理对象间依赖关系与事件通知方面表现卓越。本文…

网络原理 TCP/IP

1.应用层 1.1自定义协议 客户端和服务器之间往往进行交互的是“结构化”数据&#xff0c;网络传输的数据是“字符串”“二进制bit流”&#xff0c;约定协议的过程就是把结构化”数据转成“字符串”或“二进制bit流”的过程. 序列化&#xff1a;把结构化”数据转成“字符串”…

2025年5月HCIP题库(带解析)

某个ACL规则如下:则下列哪些IP地址可以被permit规则匹配&#xff1a; rule 5 permit ip source 10.0.2.0 0.0.254.255 A、10.0.4.5 B、10.0.5.6 C、10.0.6.7 D、10.0.2.1 试题答案&#xff1a;A;C;D 试题解析&#xff1a; 10.0.2.000001010.00000000.00000010.0000000…

【Redis | 基础总结篇 】

目录 前言&#xff1a; 1.Redis的介绍&#xff1a; 2.Redis的类型与命令&#xff1a; 3.Redis的安装&#xff1a; 3.1.Windows版本 3.2.Linux版本 4.在java中使用Redis&#xff1a; 4.1.介绍 4.2.Jedis 4.3.Spring Data Redis 前言&#xff1a; 本篇主要讲述了Redis的…

38.前端代码拆分

因为前端代码之前是一体编写的&#xff0c;所以为了方便对代码进行了拆分 之前是这样的&#xff1a; 为了更加规范&#xff0c;所以拆分成vue、util、store、api等部分&#xff1a; css&#xff1a; store: 拆分后的大致界面为&#xff1a; 其实还有点别扭需要后续再调整

tinyrenderer笔记(Shader)

tinyrenderer个人代码仓库&#xff1a;tinyrenderer个人练习代码 前言 现在我们将所有的渲染代码都放在了 main.cpp 中&#xff0c;然而在 OpenGL 渲染管线中&#xff0c;渲染的核心逻辑是位于 shader 中的&#xff0c;下面是 OpenGL 的渲染管线&#xff1a; 蓝色是我们可以自…

C++高性能内存池

目录 1. 项目介绍 1. 这个项目做的是什么? 2. 该项目要求的知识储备 2. 什么是内存池 1. 池化技术 2. 内存池 3. 内存池主要解决的问题 4.malloc 3. 先设计一个定长的内存池 4.高并发内存池 -- 整体框架设计 5. 高并发内存池 -- thread cache 6. 高并发内存池 -- …

LintCode407-加一,LintCode第479题-数组第二大数

第407题: 描述 给定一个非负数&#xff0c;表示一个数字数组&#xff0c;在该数的基础上1&#xff0c;返回一个新的数组。 该数字按照数位高低进行排列&#xff0c;最高位的数在列表的最前面. 样例 1&#xff1a; 输入&#xff1a;[1,2,3] 输出&#xff1a;[1,2,4] 样例 …

SMT贴片钢网精密设计与制造要点解析

内容概要 SMT贴片钢网作为电子组装工艺的核心载体&#xff0c;其设计与制造质量直接影响焊膏印刷精度及产品良率。本文系统梳理了钢网全生命周期中的15项关键技术指标&#xff0c;从材料选择、结构设计到工艺控制构建完整技术框架。核心要点涵盖激光切割精度的微米级调控、开口…

OpenCV进阶操作:角点检测

文章目录 一、角点检测1、定义2、检测流程1&#xff09;输入图像2&#xff09;图像预处理3&#xff09;特征提取4&#xff09;角点检测5&#xff09;角点定位和标记6&#xff09;角点筛选或后处理&#xff08;可选&#xff09;7&#xff09;输出结果 二、Harris 角点检测&#…

江苏正力新能Verify认知能力测评笔试已通知 | SHL测评题库预测题 | 华东同舟求职讲求职

江苏正力新能入职笔试通知&#xff0c;Verify&#xff08;认知能力测评&#xff09;&#xff0c;用时约46分钟&#xff0c;其中正式测试部分计时36分钟&#xff1b;时间到了试卷会自动提交&#xff0c;请合理安排答题时间&#xff01;前面有10分钟练习时间&#xff0c;可以略过…

在若依里创建新菜单

首先打开左侧菜单栏的系统管理&#xff0c;然后点击菜单管理 可以点击左上角的新增&#xff0c;也可以点击右侧对应目录的新增 这里我选择了右侧的新增&#xff0c;即在系统管理目录下新增菜单 其中的组件路径就是写好的页面的路径 &#xff08;从views的下一级开始写即可&…

【AI知识库云研发部署】RAGFlow + DeepSeek

可以分成两台机器部署,一台gpu,一台cpu,cpu的机器运行ragflow的主程序,使用模型时才访问gpu。当然全部在一台机器上部署是完全ok的。全文没有复杂的环境问题 gpu 安装screen:yum install screen 配置ollama: 下载官方安装脚本并执行: curl -fsSL https://ollama.co…

Java后端开发day40--异常File

&#xff08;以下内容全部来自上述课程&#xff09; 异常 异常&#xff1a;异常就是代表程序出现的问题 1. 异常的分类 1.1 Error 代表的是系统级别的错误&#xff08;属于严重问题&#xff09; 系统一旦出现问题&#xff0c;sun公司会把这些错误封装成Error对象。 Error…

算法思想之深度优先搜索(DFS)、递归以及案例(最多能得到多少克黄金、精准核酸检测、最富裕的小家庭)

深度优先搜索&#xff08;DFS&#xff09;、递归 深度优先搜索&#xff08;Depth First Search&#xff0c;DFS&#xff09;是一种用于遍历或搜索树或图的算法。在 DFS 算法中&#xff0c;从起始节点开始&#xff0c;沿着一条路径尽可能深地访问节点&#xff0c;直到到达叶子节…