# 图形渲染技巧 ## GPU、CPU 难道不能直接将数据从 CPU 跨到 GPU 处理?为什么需要多此一举,出现 OpenGL 这个框架? 数据饥饿:从一块内存中将数据复制到另一块内存中,传输速度是非常慢的。内存复制数据时,CPU 和 GPU 都不能操作数据,避免引起错误。 - 如果 CPU、GPU 同时操作内存,同步和处理非常麻烦,加一些锁或额外手段将造成速度损耗。 - 如果 GPU 处理完处于等待状态 所以加了 buffer 缓冲区来处理该问题。有很多缓冲区,比如颜色缓冲区、深度缓冲区... ## 着色器渲染流程 顶点着色器:有多少个顶点,就执行多少次顶点着色器。 光栅化:将顶点着色器的结果转换为像素。将输入的图元描述,转换为与屏幕对应的位置像素片元。 片元着色器:将光栅化的结果转换为颜色。(iOS 显示图片的核心原因) 顶点着色器执行次数多还是片元着色器执行次数多?一般来说片元着色器执行次数多。比如三角形,顶点着色器执行三次,有多少个像素点片元着色器就执行多少次。 ## 着色器的渲染 - 顶点着色器(必要) - 细分着色器(可选) - 几何着色器(可选) - 片元着色器(必选) QA: - 什么是管线? ### 什么是可编程管线 可编程管线(Programmable Pipeline)是一种灵活的渲染流程,它允许开发者通过编写特定的程序代码来控制图形渲染的各个阶段。与固定管线相比,可编程管线提供了更高的灵活性和控制能力,使得开发者能够实现更复杂的图形效果和优化性能。 编程通过 Shading Language 语言(基于 C++)编写,开发者可以控制图形渲染的各个阶段,包括顶点着色器、细分着色器、几何着色器和片元着色器等。这些着色器可以实现各种复杂的图形效果和优化性能。 Apple 的 Metal 中叫 Metal Shading Language(简称 MSL 或 Metal 着色语言)是苹果公司为其图形和计算API Metal 设计的着色语言。它是一种低级别的编程语言,用于编写3D图形渲染逻辑和并行计算核心逻辑。 ### 什么是固定管线 固定管线(Fixed-Function Pipeline)是指一种渲染流程,其中图形渲染的各个阶段都是预定义的,开发者不能直接控制这些阶段的内部操作 - 顶点处理(Vertex Processing):顶点坐标转换、光照处理等。 - 图元装配(Primitive Assembly):将顶点组装成图元,如三角形、线段等。 - 光栅化(Rasterization):将图元转换为像素。 - 片元处理(Fragment Processing):对每个像素进行颜色、纹理等处理。 - 输出合并(Output Merging):将处理后的像素输出到帧缓冲区。 固定管线的优点是简单易用,对于初学者来说,可以快速上手进行图形渲染。但是,它的缺点是不够灵活,不能满足高级渲染技术的需求,如自定义着色器、高级光照模型等 ### 什么是管线 在OpenGL中,管线(Pipeline)是一个处理图形数据的序列化过程,它将顶点数据转换成最终屏幕上的像素。管线分为几个阶段,每个阶段对数据进行特定的处理 ## 渲染过程中可能产生的问题 ### 隐藏面消除 在绘制 3D 的场景时候,我们需要决定哪些部分是对观察者可见的,哪些部分是对观察者不可见的。对于不可见的部分,应该尽早丢弃,例如在一个不透明的墙壁后,就不应该渲染,这个情况叫“隐藏面消除”(Hidden surface elimination) #### 解决方案 ##### 油画算法(画家算法) 先绘制场景中离观察者较远的物体,再绘制离观察者较近的物体 例如下面的场景中,先绘制红色部分,再绘制黄色部分,最后再绘制灰色部分。即可解决隐藏面消除的问题。 缺点: - 需要遮盖的部分,画了多次,造成了渲染性能问题,浪费了资源。 - 叠加的情况,油画算法无法处理。 使用油画算法,只要将场景按照物理距离观察者的距离远近排序。由远及近的绘制即可。 但某些情况下,这个距离无法排序。比如下面的场景: 如果三个三角形是叠加的情况,油画算法无法处理。 ##### 正背面剔除(Face Culling) 想象一个 3D 图形,你从任何一个方向去观察,最多可以看到几个面?最多3个,从一个立方体的任意位置和方向去看,最多可以看到3个面。 思考:为什么需要多余的去绘制根本看不到的3个面? 如果能以某种方式丢弃这部分数据,OpenGL 渲染性能可以提高超过50%。 正背面剔除方案,不仅可以解决隐藏面消除问题,还可以带来性能提升。 如何知道某个面再观察者的视野中会不会出现?任何平面都有2个面,正面、背面。意味着同一个时刻只能看到一个面。 OpenGL 可以做到检查所有正面朝向观察者的面,并渲染他们,从而丢弃背面朝向的面,这样可以节约片元着色器的开销,提高性能。 核心:OpenGL 如何知道绘制的图形中,哪个是正面,哪个是背面? 通过分析顶点数据的顺序。 - 正面:按照逆时针顶点连接顺序的三角形面 - 背面:按照顺时针顶点连接顺序的三角形面 用顺时针、逆时针判断正反面不是绝对的,还需要结合观察者的位置。 分析: - 左侧三角形的顶点顺序为:1->2->3;右侧三角形顶点顺序为:1->2->3 - 当观察者在右侧时,则右边三角形方向为逆时针方向,则为正面。左侧三角形为顺时针,则为反面 - 当观察者在左侧时,则左边三角形顶点为逆时针方向,则为正面。右侧三角形为顺时针,则为反面 总结: 正面和背面是由三角形顶点顺序和观察者方向共同决定的。随着观察者的角度方向改变,正反面也会改变。 API ``` // 开启表面剔除(默认背面剔除) void glEnable(GL_CULL_FACE); // 关闭表面剔除(默认背面剔除) void glDisable(GL_CULL_FACE); // 用户选择剔除哪个面(设置面剔除的方式) void glCullFace(GLenum mode); // mode 为:GL_FRONT,GL_BACK,GL_FRONT_AND_BACK。默认 GL_BACK // 用户指定绕序那个为正面 void glFrontFace(GLenum mode); // mode 为:GL_CCW,GL_CW。默认 GL_CCW // 剔除正面实现 glCullFace(GL_BACK); glFrontFace(GL_CW); ``` ### 深度问题 - 什么是深度?深度其实就是该像素点在 3D 世界中距离观察者的距离,z 值。 - 什么是深度缓冲区?一块内存区域,专门存储着每个像素点(绘制在屏幕上的深度值)。深度值 Z 越大,则离摄像机越远。 - 为什么需要深度缓冲区?在不使用深度测试的时候,如果先绘制了一个距离比较近的物体,再绘制距离比较远的物体,则距离远的位图因为后绘制,则会把距离近的物体覆盖掉。有了深度缓冲区后,绘制物体的顺序就不那么重要了。实际上,只要存在深度缓冲区,OpenGL 都会把像素的深度写入到缓冲区中。除非调用 glDepthMask(GL_FALSE) 来禁止写入。 #### Z-buffer 方法(深度缓冲区 Depth-buffer) 深度测试。深度缓冲区(Depth buffer)和颜色缓冲区(Color buffer)是一一对应的,颜色缓冲区存储像素的颜色信息,而深度缓冲区存储像素的深度信息。 在决定是否绘制一个物体表面时,首先要将表面对应的像素深度值与当前深度缓冲区中的值进行比较。如果大于深度缓冲区的值,则丢弃这部分。否则利用这个像素对应的深度值和颜色值,分别更新深度缓冲区和颜色缓冲区。这个过程称为“深度测试”。 #### 使用深度测试 深度缓冲区,一般由窗口管理系统 GLFW 创建,深度值一般由16位、24位、32位值表示,通常是24位,位数越高,深度精确度越高。 - 开启深度测试:`glEnable(GL_DEPTH_TEST)` - 在绘制场景前,清除颜色和深度缓冲区:`glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glClearColor(0.0f, 0.0f, 0.0f, 1.0f);` - 清除深度缓冲区默认值为1.0,表示最大的深度之,深度值范围是(0, 1),值越小表示越靠近观察者。值越大表示越远离观察者 - 指定深度测试判断模式:`glDepthFunc(GLEnum mode);` - GL_ALWAYS:总是绘制 - GL_NEVER:永远不绘制 - GL_LESS:如果当前深度值小于测试值,则绘制 - GL_LEQUAL:如果当前深度值小于等于测试值,则绘制 - GL_GREATER:如果当前深度值大于测试值,则绘制 - GL_GEQUAL:如果当前深度值大于等于测试值,则绘制 - GL_NOTEQUAL:如果当前深度值不等于测试值,则绘制 ### ZFighting 闪烁问题 为什么会出现闪烁问题? 因为开启深度测试后,OpenGL 就不会再去绘制模型被遮盖的部分,而是直接丢弃。这样的实现显示更真实,但是由于深度缓冲区精度的限制,对于深度相差非常小的情况下(例如在同一平面上进行2次绘制)OpenGL 就可能出现不能正确判断两者的深度值,会导致深度测试的结果不可预测,显示出来的现象是交错闪烁。 #### ZFighting 闪烁问题解决 第一步:启用 Polygon offset 方式解决 让深度值之间产生间隔,如果2个图形之间有间隔,是不是意味着就不会产生干涉。可以理解为在执行深度测试前将立方体的深度值做一些细微的增加,于是就能将重叠的2个图形深度值之间有所区分。 ``` // 启用 Polygon offset 方式 glEnable(GL_POLYGOn_OFFSET_FILL) ``` 参数列表: - GL_POLYGON_OFFSET_FILL:对应光栅化 GL_FILL - GL_POLYGON_OFFSET_LINE:对应光栅化 GL_LINE - GL_POLYGON_OFFSET_POINT:对应光栅化 GL_POINT 第二步:指定偏移量。 - 通过 `glPolygonOffset` 来指定 .glPolygonOffset 需要2个参数:factor 和 units。 - 每个 Fragment 的深度值都会增加如下所示的偏移量。`Offset = (m * factor) + (r * units);` - m: 多边形的深度斜率的最大值。理解一个多边形越是与近裁剪面平行,m 就越接近于0 - r:能产生于窗口坐标系的深度值中可分辨的差异最小值。r 是由具体 OpenGL 平台指定的一个敞亮 - 一个大于 0 的 Offset 会把模型推到离你(摄像机)更远的位置,相应的一个小于 0 的 Offset 会把模型拉近 - 一般而言,只需要将 -1.0 和 0 这样简单赋值给 glPolygonOffset 基本可以满足需求 ``` void glPolygonOffset(Glfloat factor, Glfloat units); 应用到片段上总偏移计算公式: Depth offset = (DZ * factor) + (r * units); DZ:深度值(Z 值) r:使深度缓冲区产生变化的最小值 ``` ### 混合 我们把 OpenGL 渲染时,会把颜色值存储在颜色缓冲区中,每个片段的深度值也存储在深度缓冲区。当深度缓冲区被关闭时,新的颜色将简单的覆盖原来的颜色缓冲区存在的颜色值,当深度缓冲区再次打开时,新的颜色片段只是当它们比原来的值更接近邻近的裁剪平面才会替换原来的颜色片段。`glEnable(Gl_BLEND)` #### 组合颜色 目标颜色:已经存储在颜色缓冲区的颜色值 源颜色:作为当前渲染命令结果进入颜色缓冲区的颜色值 当混合功能被启用时,源颜色和目标颜色的组合方式是混合方程式控制的。在默认的情况下,混合方程式如下所示: `Cf = (Cs * s) + (Cd *d)` - Cf:最终计算参数的颜色 - Cs:源颜色 - Cd:目标颜色 - s:源混合因子 - d:目标混合因子 下面通过一个常见的混合函数组合来说明问题:`glBlendFunc(Gl_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)` 如果颜色缓冲区存在一种红色(1.0f, 0.0f, 0.0f, 0.0f),目标颜色 Cd,如果在这上面用一种 alpha 为0.6的蓝色(0.0f, 0.0f, 1.0f, 0.6f) ``` Cd(目标颜色) = (1.0f, 0.0f, 0.0f, 0.0f) Cs(源颜色) = (0.0f, 0.0f, 1.0f, 0.6f) S(源 Alpha) = 0.6f D(目标 Alpha) = 1- S = 0.4f Cf = (Cs * s) + (Cd * D) ``` 总结:混合函数经常用于实现在其他一些不透明物体前面绘制一个透明物体的效果。 ## GPUImage ### 说明 开源的基于 GPU 处理图片/视频的一个框架,本身内置了几百种常见的滤镜效果,支持自定义滤镜(由开发者基于 OpenGLES GLSL 实现片元着色器) GPUImage 基于以下框架: - CoreMedia - CoreVideo - AVFoundation - QuartzCore - OpenGL ES2.0 采用 GPU 加速处理图片/视频滤镜效果。对比 GPUImage/CoreImage - GPUImage 可以自定义滤镜,缺乏人脸识别功能 - GPUImage 在 GPU 上处理速度高于 CPU,百倍。 目的:隐藏/减弱关于 OpenGL ES 的复杂性。 滤镜处理的原理:就是把静态图片/视频上每一帧图片进行图形变换(饱和度/色温...)处理之后,再现实到屏幕上,本质上(像素点、颜色的变化) ### 模块划分 - 上下文环境:包括运行 GPUImage 的上下文定义、资源定义、缓存管理相关类都包括在其中 - 输入源:即滤镜处理链路的源头,包括视频、图片在内的各种输入源都定义其中 - 输出源:即处理链路的尽头,用于将处理后的数据绘制到屏幕、或者转成二进制数据推流等等 - 滤镜:提供多达上百种的滤镜效果使用来进行图像处理 ### 核心流程 #### OpenGL ES 处理图片的流程 - 初始化 OpenGL ES 环境、编译、链接顶点着色器、片元着色器 - 缓存顶点/纹理/坐标数据,传输相关数据到 GPU - 图片绘制在帧缓存区 - 从帧缓存区绘制图像 #### GPUImage 处理图片的流程 整体环节:Source(图片/视频数据源) -> filters(一堆滤镜)-> final target(处理好的图片/视频) ##### Source(数据源) - GPUImageVideoCamera : 摄像头(用于实时拍摄视频) - GPUImageStillCamera:摄像头(用于拍照片) - GPUImagePicture:用于处理已经拍摄完成的照片 - GPUImageVideo:用于处理已经拍摄好的视频 ##### Filter(滤镜) GPUImageFilter:用来接收图形源,通过自定义顶点/片元着色器来渲染新的图像,完成滤镜处理后交给响应链的下一个对象。 GPUImage 中的滤镜均继承自 `GPUImageFilter`其定义了一个滤镜处理的基本流程。GPUImageFilter 继承自 GPUImgaeOutput,同时实现了 GPUImageInput 协议,这就使得 GPUImageFilter 即可接收 frameBuffer 输入进行图形处理。 `@interface GPUImageFilter : GPUImageOutput ` 源对象将静止图像帧上传到OpenGL ES作为纹理,然后将这些纹理交给处理链中的下一个对象。 - GPUImage中的一个非常重要的基类 `GPUImageOutput` 和一个协议 `GPUImageInput`。基本上所有重要的 `GPUImage` 处理类都是`GPUImageOutput` 的子类,它实现了一个输出的基本功能 - 所有的 `GPUImage` 处理类也都遵循 `GPUImageInput` 协议。它定义了一个能够接收 frameBuffer 的接收者所必须实现的基本功能。主要包括: - 接收上一个GPUImageOutput的相关信息 - 接收并处理上一个GPUImageOutput渲染完成的通知 ##### Final 环节 | 输出源 | 类型 | 说明 | | --------------------- | ----------------- | ----------------------------------------------------- | | GPUImageView | 继承自 UIView | 处理后的图像直接渲染到指定的原生 View 上 | | GPUImageMovieWriter | 封装AVAssetWriter | 将处理后的视频数据逐帧写入指定路径文件中 | | GPUImageRawDataOutput | 二进制数据 | 获取出来后纹理的二进制数据,可用于上行推流 | | GPUImageRawDataOutput | 纹理数据 | 每一帧渲染结束后,通过 texture 属性返回输入纹理的索引 | ### 责任链模式 对 GPUImage 源码的解读可以看到,GPUImage 采用了责任链设计模式来实现链式处理。 GPUImage 定义了一个 GPUImageOutput 类和一个 GPUImageInput 协议,实现了 GPUImageInput 协议的对象具备接收 frameBuffer 纹理输入并进行处理的能力。而继承自 GPUImageOutput 的对象,则可以将处理后的输出纹理传递到下一个 filter。 输入源 input 继承自 GPUImageOutput,可以将图片、视频等数据上传到 frameBuffer 后传递到 GPUImageFilter 中处理。最后一个 filter 处理完成后,将数据传递到了实现 GPUImageInput 协议的输出源 Output 中进行上屏绘制或者上行推流。上下游链路的打通。