正常渲染(On-Screen Rendering)流程:CPU 计算好显示内容(布局、文本、图片解码等),然后将计算好的数据提交给 GPU。GPU 将渲染结果直接放入当前屏幕显示的帧缓冲区(Frame Buffer) 中,这个过程非常高效。
为了更好的理解ios视图阴影为什么会触发离屏渲染,我们先讲一下3d视图-球是如何渲染的。
第一阶段:CPU应用阶段
- 定义模型:你(开发者)决定要画一个球。你调用一个数学函数(比如
createSphere),生成了一个球的网格数据。这个数据包括:
-
顶点数组:所有顶点的位置坐标(x, y, z)。
-
索引数组:一个列表,指明如何用三角形来连接这些顶点。例如
[0, 1, 2, 2, 3, 0]表示用顶点0,1,2和顶点2,3,0组成两个三角形。这可以避免重复存储顶点,节省内存。
-
设置状态:你设置渲染状态,比如用什么着色器、什么纹理(比如地球的贴图)、什么灯光等。
-
提交绘制命令:CPU通过图形API(Metal或OpenGL ES)向GPU发出一个绘制调用,说:“嘿GPU,这里有一批顶点数据,你用我设置好的状态把它们画出来吧!”
第二阶段:GPU渲染管线
这就是著名的渲染管线,你的顶点和数据会在这里经历一场奇幻漂流:
1. 顶点处理 & 几何装配
-
输入:CPU提交的不是像素,而是顶点数据。比如要画一个三角形,CPU会提交3个顶点的坐标(x, y, z)、颜色、纹理坐标等。
-
过程:GPU首先处理这些顶点,确定它们在屏幕空间中的确切位置(经过模型视图投影变换,这里会用到我们后边提到的模型矩阵)。然后,将这些顶点连接成基本的几何形状(图元),最常见的就是三角形。
-
为什么是三角形? 因为三角形是最简单的多边形,它永远是一个平面,计算起来最稳定、最高效。任何复杂图形(包括矩形、圆形)最终都会被拆分成三角形进行处理。
2. 栅格化
这是你疑问的核心,也是2D/3D渲染的魔法所在。
-
问题:我们现在有了一个三角形的数学定义(三个顶点),但屏幕是由离散的像素点组成的。我们怎么知道哪些像素属于这个三角形呢?
-
过程:栅格化就是解决这个"由连续到离散"的转换过程。它扫描整个屏幕(或区域),对于每一个像素,计算:"这个像素的中心点,是否在我这个三角形内部?"
-
输出:所有位于三角形内部的像素,都会被标记出来,并为每一个这样的像素生成一个片元。
-
关键理解:
-
片元 vs 像素:片元是候选像素。它包含了最终成为屏幕像素所需要的所有信息(如颜色、深度、纹理坐标等),但它还不一定是最终显示的那个像素(因为后面还有深度测试、混合等环节。
所以,片元是像素的前身,但并非每一个片元都会成为物理像素(可能被丢弃,也可能被混合)。
-
物理像素是屏幕上的最小显示单元。
-
片元是GPU在渲染管线中为每个像素位置(或子采样位置)生成的数据结构,它存储了颜色、深度、模板等信息,用于计算最终像素的颜色。
-
栅格化不是着色:栅格化只负责确定位置和覆盖关系,即"哪些像素需要被填充",它本身并不决定这些像素的颜色。决定颜色是下一个阶段的工作。
简单比喻:想象你要在一张格子纸上涂一个三角形。栅格化就是帮你找出所有需要被涂色的格子的过程。
3. 纹理采样与着色
现在我们知道"哪些像素要涂色"了,接下来要决定"涂什么颜色"。
-
纹理:你可以把它理解成一张贴纸或皮肤。它本质上就是一块内存中存储的图片数据(位图)。每个纹素(纹理上的像素)都有颜色信息。
-
纹理坐标:在顶点处理时,每个顶点除了位置,还会携带一个纹理坐标(U, V),这是一个0到1之间的值,用来指明"这个顶点对应纹理图片上的哪个位置"。
-
纹理采样:在栅格化阶段,三个顶点的纹理坐标会在三角形内部进行插值。于是,每个片元都会得到一个属于自己的纹理坐标。纹理采样就是根据这个坐标,去纹理图片上取出对应位置的颜色值。
-
着色:这是一个更广义的概念,指的是计算片元最终颜色的整个过程。纹理采样只是着色的一种常用手段。着色还可以包括:
-
纯色着色:直接使用一个固定的颜色。
-
光照计算:根据光源、法线等计算出颜色和明暗。
-
混合:将纹理颜色、光照颜色等进行组合。
-
片段着色器:在现代GPU中,开发者可以编写小程序(片段着色器)来完全自定义这个颜色的计算过程,实现各种复杂效果。
继续比喻:找到了所有要涂色的格子(栅格化),现在你拿出一张有图案的贴纸(纹理),根据每个格子在三角形中的位置,从贴纸上撕下一小块对应的图案(纹理采样),贴到这个格子上(着色)。
4. 测试与混合
现在每个片元都有了颜色,但它们还不是最终的像素。
-
深度测试:对于3D场景,多个三角形会重叠。GPU会检查每个片元的深度(Z值),只保留离相机最近的那个片元,其他的丢弃。这解决了"谁在前谁在后"的问题。
-
混合:对于半透明的物体(如玻璃),就不能简单地覆盖。GPU需要将当前片元的颜色和帧缓冲区中已有像素的颜色按照透明度进行混合,得到新的颜色。
最终:通过了所有测试并完成混合的片元,才会将其颜色值写入帧缓冲区中对应的像素位置。
最终,所有通过的片段颜色会被写入到帧缓存中,等待显示在屏幕上,你就看到了一个由无数三角形完美伪装成的光滑球体。
你可能还需要知道什么是所谓的模型:
在3D图形学中,“模型”通常指的是一个3D对象的数字表示。它包括:
-
网格(Mesh):由顶点(Vertex)和索引(Index)构成,定义了物体的形状。
-
材质(Material):定义了物体的表面外观,包括颜色、纹理、光泽度等。
-
可能还包括骨骼、动画数据等。
在渲染过程中,CPU负责准备模型数据,并将其传递给GPU。具体来说:
-
模型数据(网格)通常由3D建模软件(如Blender、Maya等)创建,然后导出为特定格式的文件(如.obj、.fbx等)。
-
在iOS应用程序中,我们会读取这些文件,将模型数据加载到内存中(通常是顶点数组和索引数组)。
-
然后,我们通过图形API(如Metal或OpenGL ES)将顶点数据和索引数据上传到GPU的显存中(例如,创建顶点缓冲区和索引缓冲区)。
所以,是的,模型数据(顶点、索引等)是由CPU准备好并传递给GPU的。
但是,请注意,我们并不一定需要将模型的所有顶点数据每一帧都传递给GPU。通常,我们会在初始化阶段就将模型数据上传到GPU的显存中,然后在每一帧渲染时,只需要告诉GPU“使用哪个模型数据”以及“如何渲染(使用什么着色器、纹理、变换矩阵等)”。
模型变换矩阵(也是CPU给GPU的)
这是第二个关键部分,也是你提到的"模型视图投影变换"中的"模型"。
模型矩阵是一个4x4的矩阵,它定义了:
-
位置:这个模型在世界空间中的位置(平移)
-
旋转:模型如何旋转
-
缩放:模型的大小
为什么需要这个矩阵?
-
你的顶点数据通常定义在模型空间(也叫局部空间)中,比如一个球体的顶点可能围绕原点(0,0,0)定义
-
但在场景中,你可能需要多个球体,放在不同的位置、有不同的大小
-
模型矩阵就是用来把顶点从"模型空间"变换到"世界空间"的
// 例如在Metal中,你可能会这样:
struct Uniforms {
var modelMatrix: float4x4
var viewMatrix: float4x4
var projectionMatrix: float4x4
}
// CPU端设置模型矩阵
var modelMatrix = matrix_identity_float4x4
modelMatrix.translate(1.0, 2.0, 3.0) // 把球体放在位置(1,2,3)
modelMatrix.rotate(angle: 0.5, axis: [0, 1, 0]) // 绕Y轴旋转
// 然后将这个矩阵传递给GPU的顶点着色器
-
模型数据:CPU给GPU的顶点、法线、纹理坐标等几何信息
-
模型矩阵:CPU给GPU的变换矩阵,决定模型在场景中的位置、旋转、缩放
-
两者关系:模型数据描述"这是什么物体",模型矩阵描述"这个物体在哪里、什么朝向、多大"
问题场景:在3D场景中,多个物体可能位于不同的深度(距离摄像机的远近)。比如一个立方体在球体前面。
解决方案:
-
每个片元除了颜色,还携带一个深度值(Z值),表示它距离摄像机的远近。
-
帧缓冲区中除了存储颜色,还有一个深度缓冲区,记录每个像素位置当前最浅(最近)的深度值。
-
当新的片元产生时:
-
读取该像素位置当前的深度值
-
比较新片元的深度值与当前深度值
-
如果新片元更近:更新颜色缓冲区和深度缓冲区
-
如果新片元更远:丢弃这个片元
简单比喻:就像在舞台上,演员A站在离观众3米处,演员B站在5米处。深度测试确保当A和B在视觉上重叠时,我们只看到更近的演员A。
针对2d模型的渲染,深度测试和混合会发生吗?
情况1:不透明图片 + 单独显示
如果你的图片是完全不透明的(没有Alpha通道,或者Alpha=1.0),并且单独显示在屏幕上(没有其他内容在它下面),那么:
-
深度测试:通常不会发生
-
因为所有片元的深度值相同,默认深度测试函数是"小于",所以第一个片元写入后,后续相同深度的片元会被拒绝
-
但在实际实现中,系统可能会禁用深度测试来优化性能
-
混合:不会发生
-
因为图片不透明,直接覆盖原有像素颜色
-
GPU使用简单的覆盖操作,而不是混合
流程简化为:顶点 → 图元装配 → 栅格化 → 片段着色 → 直接写入帧缓冲区
情况2:透明图片 或 有背景内容
如果图片有透明度,或者有其他内容在它下面,情况就不同了:
-
深度测试:可能发生,取决于渲染设置
-
混合:一定会发生
-
GPU需要将新片元的颜色与帧缓冲区中已有的颜色进行Alpha混合
-
使用公式:
最终颜色 = 源颜色 × 源Alpha + 目标颜色 × (1 - 源Alpha)
那么此时片元是否就和像素一一对应了呢,即片元中存储的信息不需要再次加工了?
片元仍然是候选像素,即使在简单2D情况下!
原因:
-
多重采样抗锯齿:即使简单2D渲染,系统可能启用MSAA,一个像素对应多个片元样本
-
测试阶段:片元仍然要通过各种测试才能成为真正的像素
-
概念一致性:GPU管线是统一的,不因场景简单而改变基本概念
那么什么会导致离屏渲染呢?
但凡需要从不同的视角渲染出来之后再次计算,然后才能生成最终用户看到的效果的,都是需要离屏渲染的。
正常渲染直接操作帧缓冲区
离屏渲染需要先操作离屏缓冲区,然后再根据离屏缓冲区的值计算帧缓冲区的最终值
我们来举几个这种需要“开天眼”的离屏渲染的例子
1. 阴影 - "需要预知未来"
问题:要画一个物体的阴影,画家需要知道:
-
这个物体的完整形状(从光源角度看)
-
阴影要投射到哪些表面上
-
其他物体是否挡住了阴影
直接绘画的困境:
-
画家在画物体A时,还不知道物体B、C、D会画在哪里
-
他不知道阴影应该被哪些物体挡住
-
他无法实时计算出阴影的正确形状和位置
离屏渲染解决方案:
// 第一步:从"光源视角"拍张快照
创建离屏缓冲区 → 从光源角度渲染整个场景 → 得到深度图(记录谁离光源近)
// 第二步:在正常渲染时参考这张快照
正常渲染场景 → 对于每个像素,查深度图判断是否在阴影中 → 调整颜色
要画一个人的影子,画家必须先站到光源的位置,看看这个人会挡住哪些光,记下来,然后再回到画布前根据这个信息画影子。
2. 反射 - "需要镜中世界"
问题:要画镜子中的影像,画家需要知道:
-
从镜子角度看,场景是什么样子
-
哪些东西应该出现在镜子里
-
镜子的形状和位置
直接绘画的困境:
-
画家无法同时从两个视角(观察者视角和镜子视角)作画
-
他不知道镜子应该反射哪些内容
离屏渲染解决方案:
// 第一步:从"镜子视角"渲染场景
创建离屏缓冲区 → 从镜子角度渲染场景 → 得到反射纹理
// 第二步:在镜子上贴这个纹理
正常渲染镜子 → 将反射纹理应用到镜子表面
要画一面镜子,画家必须先站到镜子的位置,看看镜子"看到"了什么,把这个景象画在另一张纸上,然后把这张纸剪成镜子的形状贴到画布上。
3. 模糊 - "需要失焦视图"
问题:要画一个模糊效果,画家需要知道:
-
模糊区域内的所有颜色信息
-
这些颜色如何混合
直接绘画的困境:
-
画家一次只能画一个像素,无法"看到"周围像素的颜色
-
他不知道如何混合多个像素的颜色来创建模糊效果
离屏渲染解决方案:
// 第一步:先正常渲染要模糊的内容
创建离屏缓冲区 → 渲染要模糊的视图 → 得到清晰版本
// 第二步:对清晰版本进行模糊处理
对离屏缓冲区内容应用模糊算法 → 得到模糊结果
// 第三步:合成到最终画面
将模糊结果绘制到帧缓冲区
比喻:要画一个失焦的背景,画家必须先清晰地画出整个场景,然后用特殊的模糊工具处理这个清晰的画面,最后把处理后的模糊版本贴到画布上。
4. 圆角 - "需要先完整后裁剪"
问题:要画一个带圆角的视图,画家需要:
-
先画出完整的矩形内容
-
然后切掉四个角
直接绘画的困境:
-
画家无法在画圆角时"预知"四个角会被切掉
-
他需要先完成整个形状,再应用裁剪
离屏渲染解决方案:
// 第一步:先渲染完整矩形
创建离屏缓冲区 → 渲染完整视图内容
// 第二步:应用圆角蒙版
将离屏缓冲区内容与圆角形状组合 → 切掉四个角
// 第三步:合成到最终画面
将圆角版本绘制到帧缓冲区
要做一个圆角照片,摄影师必须先拍出完整的矩形照片,然后用圆角裁剪器把四个角切掉。
为什么深度测试和混合不够用?
深度测试只能回答:"谁在前面?"
混合只能回答:"两个半透明颜色叠加是什么颜色?"
但它们无法回答:
-
"这个物体的影子应该是什么形状?"
-
"镜子里应该显示什么?"
-
"这堆像素模糊后是什么样子?"
-
"这个矩形的四个角切掉后里面内容怎么显示?"
所有离屏渲染都遵循这个逻辑:
// 所有复杂效果都遵循这个三步模式:
1. 创建离屏缓冲区 → "准备草稿纸"
2. 在离屏缓冲区完成预处理 → "在草稿纸上计算"
3. 将结果合成到帧缓冲区 → "把草稿贴到画布"
本文使用 文章同步助手 同步