前言
我们在前面,首先进行了针对 iOS中的多媒体技术相关几个框架概述:
- 进而 用 两篇文章 对 其中的
UIKit
相关要点 进行了分述:- 然后我们 针对 Core Animation框架的要点 进一步展开分述:
- 紧接着 我们快速回顾了 2D绘图引擎Core Graphics框架的要点
- 再然后 我们 围绕 滤镜框架Core Image、GPUImage框架的要点 进行了快速回顾:
- 我们 在此篇文章 ,将 针对 底层渲染框架Metal、OpenGL ES框架的要点 进一步展开分述:
一、图形API简介(OpenGL ES、Metal)
1. 走向3D图形世界
关于3D图形世界
的更详细介绍,我推荐阅读书籍: 《OpenGL超级宝典》第一章: 3D图形和OpenGL简介.
幸运的是,已经有图形开发爱好者,将一些重点整理在博文上:3D图形和OpenGL简介
三维(3D)表示一个正在描述或显示的物体具有3个维度:宽度
、高度
和深度
。例如
- 放在书桌上的一张画是一个二维物体,因为它没有可以令人感受得到的深度。
- 而旁边的药瓶却能感受到它是3D,因为它具备
高度
、深度
、宽度
。
几个世纪以来,艺术家们已经知道如何让一幅画有立体感
并且具有真实的深度。
- 通过颜料在二维画布上所创作的作品,它其实本质上画的是一个二维物体
- 类似,计算机3D图像实质上也是平面的,它只是在计算机屏幕上所显示的二维图像,但它可以提供深度的错觉。
2D + 透视 = 3D
2. 图形API简介
无论是2D还是3D图形界面,它们都有相关的编程接口,这里介绍几个图形API:
- OpenGL(Open Graphics Library)
- 是一种
跨平台
的图形API,用于开发2D和3D图形应用
程序 - 它将计算机的资源抽象称为一个个OpenGL的对象
- 对这些资源的操作抽象为一个个的OpenGL指令。
- 是一种
- OpenGL ES(OpenGL for Embedded Systems)
- 是OpenGL三维图形API的子集
- 针对手机、PDA和游戏主机等嵌入式设备而设计的,
去除了许多不必要和性能较低的API接口
- DirectX
- 是由很多API组成的,DirectX并不是一个单纯的图形API。
- 最重要的是DirectX是属于Windows上一个多媒体处理API。
- 并不支持Windows以外的平台,所以不是跨平台框架。
- 按照性质分类,可分为四大部分:
显示部分
、声音部分
、输入部分
和网络部分
- Metal
- Apple为游戏开发者推出了新的平台技术,该技术能够为3D图像提高10倍的渲染性能。
- Metal是Apple为解决3D渲染而推出的框架
3. 学习OpenGL ES和Metal的选择
我们首先 引入苹果 官方提供的OpenGL ES
编程指南:OpenGL ES Programming Guide
从指南中,苹果已经提及,在iOS12以后,对OpenGL ES弃用,转向对Metal的支持。
苹果都弃用了OpenGL/OpenGL ES,那还需要学OpenGL ES?
- 1、苹果自身系统迁移到Metal是花费了4年时间
- 2、在没有推出Metal时,苹果对于OpenGL ES是高度集成且配合相应图层和GLKit来辅助开发者能快速使用
OpenGL ES
- 3、
OpenGL ES
的弃用,只是针对苹果内部系统底层API依赖而言- 并不是想让iOS开发者从此不使用
OpenGL ES
- 只是角色变成了第三方,毕竟它的跨平台以及稳定是很难让现有的开发放弃,而这几点Metal目前很难做到
- 并不是想让iOS开发者从此不使用
- 4、目前大多数类似百度地图、高德地图和音视频处理的项目组已经很庞大了,暂时不会迁移到Metal,所以只学习Metal是不够用的
- 5、所以学习需要一步一步的走
OpenGL
->OpenGL ES
->Metal
4. 图形API用于解决什么问题
实现图形的底层渲染
比如:
- 在游戏开发中,对
游戏场景/游戏任务
的渲染 - 在音视频开发中,对于
视频解码后的数据
渲染 - 在地图引擎,对于
地图上的数据渲染
- 在动画中,
实现动画的绘制
- 在视频处理中,对于
视频加上滤镜
效果 - ...
图形API工作的本质
- 图形API工作的本质:就是利用GPU芯片来高效渲染图形图像
- 图形API是iOS开发者唯一接近GPU的方式
二、渲染工作流水线简介
以iOS平台为例。我们先回顾一下渲染工作流水线,再过渡到图形API(如OpenGL ES、Metal)的在流水线上的工作环节和工作流程。再介绍一下流程内涉及到的一些 3D图形技术 术语。如此,我们学习 3D图形技术就不那么突兀了。
1. 渲染工作流水线说明(点击查看详情)
- ①-应用交互前端UIKit/AppKit →
- ②-Core Animation →
- ③ OpenGL ES/ Metal →
- ④ GPU Driver →
- ⑤ GPU →
- ⑥ Screen Display
在屏幕成像的过程中,
CPU
和GPU
起着至关重要的作用
- CPU(Central Processing Unit,中央处理器)
CPU的主要任务是进行对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制(Core Graphics) - GPU(Graphics Processing Unit,图形处理器)
GPU的主要任务是对纹理的渲染 - 小结:
从CPU准备渲染数据(纹理),交由GPU进行渲染
2. Core Animation 渲染流水线详细说明(点击查看详情)
- ①-
CPU阶段
: Core Animation (Commit Transaction)CPU阶段
→ 数据缓存 →GPU阶段
- 注:
- CPU、GPU无法直接交换数据
- 从⼀块内存中将数据复制到另⼀块内存中, 传递速度是非常慢的,内存复制数据时, CPU和GPU 都不能 操作数据(避免引起错误)
- OpenGL API 可以 在CPU(
C端
)工作、也可以在GPU(S端
)工作,是两者的桥梁 - Buffers数据 用于
C端
与S端
交互
- ②-
GPU阶段
: → Render Server(OpenGL ES、Metal等图形API在工作)
3. GPU图形渲染流水线(点击查看详情)
1. (CPU 负责的时期)Application 应用处理阶段:得到图元
- 这个阶段具体指的就是图像在应用中被处理的阶段,此时还处于 CPU 负责的时期。
- 在这个阶段应用可能会对图像进行一系列的操作或者改变,最终将新的图像信息传给下一阶段
- 这部分信息被叫做图元(primitives)
- 图元(Primitive) 是指 OpenGL ES 中支持渲染的基本图形。
- OpenGL ES 只支持三种图元,分别是
三角形
、线段
、顶点
等。 - 复杂的图形得通过渲染
多个三角形
来实现。
2. Geometry 几何处理阶段:处理图元
- 进入这个阶段之后,以及之后的阶段,就都主要由 GPU 负责了
- 此时 GPU 可以拿到上一个阶段传递下来的图元信息,GPU 会对这部分图元进行处理,之后输出新的图元。这一系列阶段包括:
顶点着色器(Vertex Shader)
:
这个阶段中会将图元中的顶点信息进行视角转换
、添加光照信息
、增加纹理
等操作。形状装配(Shape Assembly)
:
图元中的三角形
、线段
、点
分别对应三个 Vertex、两个 Vertex、一个 Vertex。
这个阶段会将 Vertex 连接成相对应的形状。几何着色器(Geometry Shader)
:
额外添加额外的Vertex,将原始图元转换成新图元,以构建一个不一样的模型。
简单来说就是基于通过三角形、线段和点构建更复杂的几何图形。
3. Rasterization 光栅化阶段:图元转换为像素
光栅化的主要目的是将几何渲染之后的图元信息,转换为一系列的像素,以便后续显示在屏幕上
- 这个阶段中会根据图元信息,
计算出每个图元所覆盖的像素信息
等,从而将像素划分成不同的部分。
4. Pixel 像素处理阶段:处理像素,得到位图
- 经过上述光栅化阶段,我们得到了图元所对应的像素
- 此时,我们需要给这些像素
填充颜色
和效果
GPU 图形渲染流水线的主要工作可以被划分为两个部分:
- 把 3D 坐标转换为 2D 坐标
- 把 2D 坐标转变为实际的有颜色的像素
4. OpenGL ES工作过程(点击查看详情)
前面提及的 →②
几何处理阶段
→③光栅化阶段
→④像素处理阶段
都属于OpenGL ES
的工作环节。它又可以细分为,上图的6个小阶段:
- ②
几何处理阶段
顶点着色器(Vertex Shader)
形状装配(Shape Assembly)
几何着色器(Geometry Shader)
- ③
光栅化阶段
光栅化(Rasterization)
- ④
像素处理阶段
片段着色器(Fragment Shader)
测试与混合(Tests and Blending)
5. GLES 渲染三角形
简述
GLES 工作过程
:
渲染三角形的基本流程如下图所示,我们通过了解“怎么渲染三角形”,来理解一下 GLES 工作过程
:
其中,顶点着色器和片段着色器是可编程的部分:
- 着色器(Shader) 是一个小程序,它们运行在 GPU 上,在主程序运行的时候进行动态编译,而不用写死在代码里面。
- 编写着色器用的语言是 GLSL(OpenGL Shading Language) (相关的学习,附在下文)
下面介绍一下渲染流程的每一步都做了什么:
1、顶点数据
- 为了渲染一个三角形,我们需要传入一个包含 3 个三维顶点坐标的数组
- 每个顶点都有对应的顶点属性,顶点属性中可以包含任何我们想用的数据。
- 在上图的例子里,我们的每个顶点包含了一个颜色值。
- 并且,为了让 OpenGL ES 知道我们是要绘制三角形,而不是点或者线段,我们在调用绘制指令的时候,都会把图元信息传递给 OpenGL ES 。
2、顶点着色器
顶点着色器
会对每个顶点执行一次运算,它可以使用顶点数据
来计算该顶点的坐标
、颜色
、光照
、纹理坐标
等。顶点着色器
的一个重要任务是进行坐标转换,例如将模型的原始坐标系
(一般是指其 3D 建模工具中的坐标)转换到屏幕坐标系
。
3、图元装配
- 在
顶点着色器
程序输出顶点坐标之后,各个顶点按照绘制命令中的图元类型参数
,以及顶点索引数组
被组装成一个个图元。 - 通过这一步,模型中 3D 的图元已经被转化为屏幕上 2D 的图元。
4、几何着色器
- 在「OpenGL」的版本中,
顶点着色器
和片段着色器
之间有一个可选的着色器,叫做几何着色器(Geometry Shader) 。
几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的图元来生成其他形状。
OpenGL ES 目前还不支持几何着色器,这个部分我们可以先不关注。
5、光栅化
- 在光栅化阶段,
基本图元
被转换为供片段着色器
使用的片段。 - 片段表示可以被渲染到屏幕上的
像素
,它包含位置
、颜色
、纹理坐标
等信息,这些值是由图元的顶点信息
进行插值计算得到的。 - 在片段着色器运行之前会执行裁切,处于视图以外的所有像素会被裁切掉,用来提升执行效率。
6、片段着色器
片段着色器
的主要作用是计算每一个片段最终的颜色值(或者丢弃该片段)。片段着色器
决定了最终屏幕上每一个像素点的颜色值。
7、测试与混合
- 在这一步,OpenGL ES 会根据片段
是否被遮挡
、视图上是否已存
在绘制好的片段等情况,对片段进行丢弃或着混合,最终被保留下来的片段会被写入帧缓存
中,最终呈现在设备屏幕上。
6. GLES 如何渲染多变形
- 由于 OpenGL ES 只能渲染三角形,因此
多边形需要由多个三角形来组成
。
如图所示,一个五边形,我们可以把它拆分成 3 个三角形来渲染。
- 渲染一个三角形,我们需要一个保存 3 个顶点的数组。
- 这意味着我们渲染一个五边形,需要用 9 个顶点。
- 而且我们可以看到,其中 V0 、 V2 、V3 都是重复的顶点,显得有点冗余。
那么有没有更简单的方式,可以让我们复用之前的顶点呢?答案是肯定的。
3种
绘制三角形模式
在 OpenGL ES 中,对于三角形有 3 种绘制模式。在给定的顶点数组相同的情况下,可以指定我们想要的连接方式。如下图所示:
- 1、GL_TRIANGLES
GL_TRIANGLES
就是我们一开始说的方式,没有复用顶点,以每三个顶点绘制一个三角形。- 第一个三角形使用 V0 、 V1 、V2
- 第二个使用 V3 、 V4 、V5 ,以此类推。
- 如果顶点的个数不是 3 的倍数,那么最后的 1 个或者 2 个顶点会被舍弃。
- 2、GL_TRIANGLE_STRIP
GL_TRIANGLE_STRIP
在绘制三角形的时候,会复用前两个顶点
。- 第一个三角形依然使用 V0 、 V1 、V2
- 第二个则会使用 V1 、 V2 、V3,以此类推。
- 第 n 个会使用 V(n-1) 、 V(n) 、V(n+1) 。
- 3、GL_TRIANGLE_FAN
GL_TRIANGLE_FAN
在绘制三角形的时候,会复用第一个顶点和前一个顶点。- 第一个三角形依然使用 V0 、 V1 、V2
- 第二个则会使用 V0 、 V2 、V3,以此类推。
- 第 n 个会使用 V0 、 V(n) 、V(n+1) 。
- 这种方式看上去像是在绕着 V0 画扇形。
接下来,我们就由OpenGL ES
的工作环节,引入图形API相关的技术术语
三、3D图形技术概念
1. OpenGL状态机
- OpenGL上下文
Context
- 上下文是一个非常庞大的状态机,保存了OpenGL中的各种状态
- 不管在哪个语言中,都是类似C语言一样面向过程的函数
- 我们可以配置多个上下文,通过调用
[EAGLContext setCurrentContext:context]
来切换
OpenGL状态机
- 描述了一个对象的生命周期经历各种状态,发生转变时的
动因
、条件
及转变中所执行的活动
- 有记忆功能
- 能记住其当前的状态(如当前所使用的颜色、是否开启了混合功能等):
glClearColor(1,1,1,1)
设置颜色glEable(GL_DEPTH_TEST)
开启深度测试glEable(GL_BLEND)
开启混合
- 可以接收输入
根据输入
的内容和自己的原先状态
,修改自己当前
状态,并且可以有对应输出
- 当进入特殊状态(停机状态)时便不再接收输入,停止工作
- 描述了一个对象的生命周期经历各种状态,发生转变时的
2. 渲染管线
渲染
:- 将
图形/图像数据
转换成3D空间图像的操作
叫做渲染(Rendering) - 即
数据->可视化界面
的过程,也就是我们口中所说的绘制
- 将
顶点数组
(VertexArray)和顶点缓冲区
(VertexBuffer):顶点数据
是由GPU处理的顶点数组
是存在内存中,GPU通过操作内存来处理顶点数据
顶点缓冲区
存在显卡显存中,使得GPU的操作更为简单- 在调用绘制方法的时候,直接有内存传入
顶点数据
,也就是这部分数据之前是存储在内存中的,被称为顶点数组
; - 性能更高的做法是,提前分配一块显存,将
顶点数据
预先传入到显存当中,这块显存就是顶点缓冲区
管线
:- 可以理解为流水线。在OpenGL下渲染图形,就会经历一个一个节点,这样的操作可以理解为管线。
- 之所以称为管线是因为显卡在处理数据的时候是按照一个固定的顺序来的
固定管线/存储着色器
:- 在早期OpenGL版本中,开发者只需要传入相应的参数,就能快速完成图形的渲染。
- 开发者只需要调用API使用封装好的固定shader程序段,并不需要关注底层实现原理
3. 着色器程序Shader
着色器程序Shader
- 是一段小程序代码,是用来操作GPU进行计算的,主要的着色器有:
- 顶点着色器(VertexShader)
- 片元着色器(Metal叫片元函数)/片段着色器(FragmentShader)/像素着色器(PixelShader)
- 几何着色器(GeometryShader)
- 曲面细分着色器(TessellationShader)
- 在绘制的时候
- 首先由
顶点着色器
对传入的顶点数据进行运算,将顶点转换为图元; - 然后进行
光栅化
转化为栅格化数据; - 最后传入
片元着色器
进行运算
- 首先由
- 是一段小程序代码,是用来操作GPU进行计算的,主要的着色器有:
顶点着色器
(VertexShader)- 用来处理图形每个顶点变换——旋转/平移/投影
- 每一个顶点都会执行一次
片元着色器
(FragmentShader)- 用来处理图形中每个像素点的颜色计算和填充
- 每个像素都会执行一次片元着色器(并行执行)
GLSL
(OpenGL Shading Language)- 是用来在OpenGL中着色编程的语言,即开发人员写的短小的自定义程序
- 代替了固定渲染管线,使渲染管线中不同层次具有可编程性,比如:视图转换、投影转换等
- 用来操作
顶点着色器
和片元着色器
学习GLSL语言
关于GLSL语言的学习,可以参考这几篇文章:
- GLSL 详解(基础篇):详细介绍 OpenGL ES 2.0 着色器语言 GLSL 基础语法。
- GLSL 详解(高级篇):详细介绍 OpenGL ES 2.0 着色器语言高级特性。
- OpenGL ES 着色器语言
文章的内容包括:
- GLSL ES 版本介绍
- Shader 的结构
- GLSL ES 中的预处理
- GLSL ES 中的数据类型
- GLSL ES 中向量和矩阵的操作
- GLSL ES 中的限定符
- GLSL ES 中的函数
- GLSL ES 中的内置变量和内置函数
4. 纹理相关
光栅化
(Rasterization)- 是把
顶点数据
转换成片元
的过程,具有将图转化为一个个栅格组成的图像的作用(像素数据) - 其实就是将
几何图元变为二维图像
的过程。- 该过程包含了两部分工作:
- 决定窗口坐标中的那些整形栅格区域被基本图元占用;
- 分配
一个颜色值
和一个深度值
到各个区域
- 该过程包含了两部分工作:
- 把
物体的数学描述
以及与物体相关的颜色信息
转换为屏幕上用于对应位置的像素
及用于填充像素的颜色
,这个过程称为光栅化
- 是把
缓存
:- OpenGL ES 部分运行在 CPU 上,部分运行在 GPU 上,为了协调这两部分的数据交换,定义了缓存(Buffers) 的概念。
- CPU 和 GPU 都有独自控制的内存区域,缓存可以避免数据在这两块内存区域之间进行复制,提高效率。缓存实际上就是指一块连续的 RAM 。
纹理
: 可以简单理解成图片(位图)- 纹理是一个用来保存图像颜色的元素值的缓存
- 渲染是指将数据生成图像的过程。
- 纹理渲染则是将保存在内存中的颜色值等数据,生成图像(位图)的过程。
纹理相关的概念
- 纹素(Texel):
- 一个图像初始化为一个
纹理
缓存后,每个像素会变成一个纹素
。 纹理
的坐标是范围是 0 ~ 1,在这个单位长度内,可能包含任意多个纹素
。
- 一个图像初始化为一个
- 片段(Fragment):
- 视口坐标中的颜色像素。
- 没有使用
纹理
时,会使用对象顶点
来计算片段的颜色; - 使用
纹理
时,会根据纹素
来计算。
- 映射(Mapping):
- 对齐顶点和纹素的方式。
- 即将顶点坐标 (X, Y, Z) 与 纹理坐标 (U, V) 对应起来。
- 取样(Sampling):
- 在顶点固定后,每个片段根据计算出来的 (U, V) 坐标,去找相应纹素的过程。
- 帧缓存(Frame Buffer):
- 一个接收渲染结果的缓冲区,为 GPU 指定存储渲染结果的区域。
- 更通俗点,可以理解成存储屏幕上最终显示的一帧画面的区域。
注: (U, V) 可能会超出 0 ~ 1 这个范围,需要通过
glTextParameteri()
配置相应的方案,来映射到 S 轴和 T 轴。
5. 其它
混合
:两种颜色的视图叠在一起后的颜色就叫混合变换矩阵
(Transformation):图形发生平移、缩放、旋转变换投影矩阵
(Projection):将3D坐标转换为二维屏幕坐标渲染上屏/交换缓冲区
(SwapBuffer)- 常规的OpenGL程序至少都会有两个缓冲区
- 显示在屏幕上的成为
帧缓冲区 frame buffer
,没有显示的成为离屏缓冲区 offscreen buffer
。 - 在一个缓冲区渲染完成之后,通过将屏幕缓冲区和离屏缓冲区交换,实现图像在屏幕上的显示
- 显示在屏幕上的成为
- 为了防止交换缓冲区的时候屏幕上下区域的图像分属于两个不同的帧,因此交换一般会等待显示器刷新完成的信号,在显示器两次刷新的间各种进行交换,这个信号就成为
垂直同步信号
,这个技术成为垂直同步
- 常规的OpenGL程序至少都会有两个缓冲区
6. 坐标系
6.1 2D 笛卡尔坐标系
- 2D笛卡尔坐标系:
拥有x轴、y轴的平面坐标系(用来描述平面图形)
6.2 3D 笛卡尔坐标系
- 3D笛卡尔坐标系:
拥有x轴、y轴、z轴(z轴表示深度)的立体坐标系(用来描述立体图形)
6.3 投影: 从 3D 到 2D
- 视口: 显示的窗口区域,OpenGL使用
glViewPort
来设置视口 - 投影方式
- 正投影: 用来渲染平面图形(远近物体大小一样)
- 透视投影: 用来渲染立体图形(远小近大)
- 正投影: 用来渲染平面图形(远近物体大小一样)
6.4 OpenGL ES 坐标系
OpenGL ES 坐标系的范围是 -1 ~ 1,是一个三维的坐标系,通常用 X、Y、Z 来表示。Z 轴的正方向指向屏幕外。在不考虑 Z 轴的情况下,左下角为 (-1, -1, 0),右上角为 (1, 1, 0)。
6.5 纹理坐标系
纹理坐标系的范围是 0 ~ 1,是一个二维坐标系,横轴称为 S 轴,纵轴称为 T 轴。在坐标系中,点的横坐标一般用 U 表示,点的纵坐标一般用 V 表示。左下角为 (0, 0),右上角为 (1, 1)。
注: UIKit 坐标系的 (0, 0) 点在左上角,其纵轴的方向和纹理坐标系纵轴的方向刚好相反。
7. 快速了解OpenGL
我在这里推荐几篇文章,可以帮助我们快速了解 Open GL 一些比较核心的基础:
***
01-《一看就懂的 OpenGL 基础概念》介绍:- OpenGL 的角色、渲染架构、状态机、渲染管线等内容;
- 要点备注:
- OpenGL 提供了 3 个通道来让我们从 Client 向 Server 中的顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)传递参数和渲染信息
- 02-《一看就懂的 OpenGL 基础概念》:EGL,OpenGL 与设备的桥梁
- 03-《一看就懂的 OpenGL 基础概念》:各种 O 之 VBO、EBO、VAO
- 04-《一看就懂的 OpenGL 基础概念》:各种 O 之 FBO
9. GL ES 编程小Demo
- github.com/VanZhang-CN…
-
- GLES环境搭建
-
- GLES|绘制三角形
-
- GLES|纹理渲染
-
四、OpenGL ES 开发方式
在iOS中开发 OpenGL ES,有两种方式:
-
- 通过
GLES代码
+系统的GLKit框架
渲染
- 通过
-
- 通过
纯GLES代码
+GLSL着色器小程序
渲染
- 通过
不管是哪种,都需要 对 缓存进行管理(前面提及的各种O,都需要用到缓存:FBO、RBO、VBO、VAO),所以我们先简单介绍下缓存的管理
1. 怎么使用缓存
在实际应用中,我们需要使用各种各样的缓存。比如:
- 在纹理渲染之前,需要生成一块保存了图像数据的纹理缓存。
下面介绍一下缓存管理的一般步骤:
使用缓存的过程可以分为 7 步:
- 生成(Generate):
生成缓存标识符glGenBuffers()
- 绑定(Bind):
对接下来的操作,绑定一个缓存glBindBuffer()
- 缓存数据(Buffer Data):
从CPU的内存复制数据到缓存的内存glBufferData()
/glBufferSubData()
- 启用(Enable)或者禁止(Disable):
设置在接下来的渲染中是否要使用缓存的数据glEnableVertexAttribArray()
/glDisableVertexAttribArray()
- 设置指针(Set Pointers):
告知缓存的数据类型,及相应数据的偏移量glVertexAttribPointer()
- 绘图(Draw):
使用缓存的数据进行绘制glDrawArrays()
/glDrawElements()
- 删除(Delete):
删除缓存,释放资源glDeleteBuffers()
2. 通过 GLKit 渲染
在 GLKit 中,苹果对 OpenGL ES 中的一些操作进行了封装,因此我们使用 GLKit 来渲染会省去一些步骤。
那么好奇的你肯定会问,在「纹理渲染」这件事情上,GLKit 帮我们做了什么呢?
2.1 获取顶点数据
定义顶点数据,用一个三维向量来保存 (X, Y, Z) 坐标,用一个二维向量来保存 (U, V) 坐标:
typedef struct {
GLKVector3 positionCoord; // (X, Y, Z)
GLKVector2 textureCoord; // (U, V)
} SenceVertex;
初始化顶点数据:
self.vertices = malloc(sizeof(SenceVertex) * 4); // 4 个顶点
self.vertices[0] = (SenceVertex){{-1, 1, 0}, {0, 1}}; // 左上角
self.vertices[1] = (SenceVertex){{-1, -1, 0}, {0, 0}}; // 左下角
self.vertices[2] = (SenceVertex){{1, 1, 0}, {1, 1}}; // 右上角
self.vertices[3] = (SenceVertex){{1, -1, 0}, {1, 0}}; // 右下角
退出的时候,记得手动释放内存:
- (void)dealloc {
// other code ...
if (_vertices) {
free(_vertices);
_vertices = nil;
}
}
2.2 初始化 GLKView 并设置上下文
// 创建上下文,使用 2.0 版本
EAGLContext *context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
// 初始化 GLKView
CGRect frame = CGRectMake(0, 100, self.view.frame.size.width, self.view.frame.size.width);
self.glkView = [[GLKView alloc] initWithFrame:frame context:context];
self.glkView.backgroundColor = [UIColor clearColor];
self.glkView.delegate = self;
[self.view addSubview:self.glkView];
// 设置 glkView 的上下文为当前上下文
[EAGLContext setCurrentContext:self.glkView.context];
2.3 加载纹理
使用 GLKTextureLoader
来加载纹理,并用 GLKBaseEffect
保存纹理的 ID ,为后面渲染做准备。
NSString *imagePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"sample.jpg"];
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
NSDictionary *options = @{GLKTextureLoaderOriginBottomLeft : @(YES)};
GLKTextureInfo *textureInfo = [GLKTextureLoader textureWithCGImage:[image CGImage]
options:options
error:NULL];
self.baseEffect = [[GLKBaseEffect alloc] init];
self.baseEffect.texture2d0.name = textureInfo.name;
self.baseEffect.texture2d0.target = textureInfo.target;
因为纹理坐标系和 UIKit 坐标系的纵轴方向是相反的,所以将 GLKTextureLoaderOriginBottomLeft
设置为 YES
,用来消除两个坐标系之间的差异。
注: 这里如果用
imageNamed:
来读取图片,在反复加载相同纹理的时候,会出现上下颠倒的错误。
2.4 实现 GLKView 的代理方法
在 glkView:drawInRect:
代理方法中,我们要去实现顶点数据和纹理数据的绘制逻辑。这一步是重点,注意观察「缓存管理的 7 个步骤」的具体用法。
代码如下:
- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
[self.baseEffect prepareToDraw];
// 创建顶点缓存
GLuint vertexBuffer;
glGenBuffers(1, &vertexBuffer); // 步骤一:生成
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer); // 步骤二:绑定
GLsizeiptr bufferSizeBytes = sizeof(SenceVertex) * 4;
glBufferData(GL_ARRAY_BUFFER, bufferSizeBytes, self.vertices, GL_STATIC_DRAW); // 步骤三:缓存数据
// 设置顶点数据
glEnableVertexAttribArray(GLKVertexAttribPosition); // 步骤四:启用或禁用
glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, sizeof(SenceVertex), NULL + offsetof(SenceVertex, positionCoord)); // 步骤五:设置指针
// 设置纹理数据
glEnableVertexAttribArray(GLKVertexAttribTexCoord0); // 步骤四:启用或禁用
glVertexAttribPointer(GLKVertexAttribTexCoord0, 2, GL_FLOAT, GL_FALSE, sizeof(SenceVertex), NULL + offsetof(SenceVertex, textureCoord)); // 步骤五:设置指针
// 开始绘制
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); // 步骤六:绘图
// 删除顶点缓存
glDeleteBuffers(1, &vertexBuffer); // 步骤七:删除
vertexBuffer = 0;
}
2.5 开始绘制
我们调用 GLKView
的 display
方法,即可以触发 glkView:drawInRect:
回调,开始渲染的逻辑。
代码如下:
[self.glkView display];
至此,使用 GLKit 实现纹理渲染的过程就介绍完毕了。
3. 通过 GLSL 渲染
在不使用 GLKit 的情况下,怎么实现纹理渲染。我们会着重介绍与 GLKit 渲染不同的部分。
注: Demo可以在“第三节”的第9小节内部找到
3.1 着色器编写
首先,我们需要自己编写着色器,包括顶点着色器和片段着色器,使用的语言是 GLSL 。这里对于 GLSL 就不展开讲了,只解释一下我们等下会用到的部分,更详细的语法内容,可以参见 这里。
新建一个文件,一般顶点着色器用后缀 .vsh
,片段着色器用后缀 .fsh
(当然你不喜欢这么命名也可以,但是为了方便其他人阅读,最好是还是按照这个规范来),然后就可以写代码了。
顶点着色器
顶点着色器的代码如下:
attribute vec4 Position;
attribute vec2 TextureCoords;
varying vec2 TextureCoordsVarying;
void main (void) {
gl_Position = Position;
TextureCoordsVarying = TextureCoords;
}
片段着色器
片段着色器的代码如下:
precision mediump float;
uniform sampler2D Texture;
varying vec2 TextureCoordsVarying;
void main (void) {
vec4 mask = texture2D(Texture, TextureCoordsVarying);
gl_FragColor = vec4(mask.rgb, 1.0);
}
GLSL的简单解释
GLSL 是类 C 语言写成,如果学习过 C 语言,上手是很快的。下面对这两个着色器的代码做一下简单的解释。
attribute
修饰符只存在于顶点着色器中,用于储存每个顶点信息的输入,比如这里定义了Position
和TextureCoords
,用于接收顶点的位置和纹理信息。vec4
和vec2
是数据类型,分别指四维向量和二维向量。varying
修饰符指顶点着色器的输出,同时也是片段着色器的输入,要求顶点着色器和片段着色器中都同时声明,并完全一致,则在片段着色器中可以获取到顶点着色器中的数据。gl_Position
和gl_FragColor
是内置变量,对这两个变量赋值,可以理解为向屏幕输出片段的位置信息和颜色信息。precision
可以为数据类型指定默认精度,precision mediump float
这一句的意思是将float
类型的默认精度设置为mediump
。uniform
用来保存传递进来的只读值,该值在顶点着色器和片段着色器中都不会被修改。顶点着色器和片段着色器共享了uniform
变量的命名空间,uniform
变量在全局区声明,同个uniform
变量在顶点着色器和片段着色器中都能访问到。sampler2D
是纹理句柄类型,保存传递进来的纹理。texture2D()
方法可以根据纹理坐标,获取对应的颜色信息。
那么这两段代码的含义就很明确了,顶点着色器将输入的顶点坐标信息直接输出,并将纹理坐标信息传递给片段着色器;片段着色器根据纹理坐标,获取到每个片段的颜色信息,输出到屏幕。
3.2 纹理的加载
少了 GLKTextureLoader
的相助,我们就只能自己去生成纹理了。生成纹理的步骤比较固定,以下封装成一个方法:
- (GLuint)createTextureWithImage:(UIImage *)image {
// 将 UIImage 转换为 CGImageRef
CGImageRef cgImageRef = [image CGImage];
GLuint width = (GLuint)CGImageGetWidth(cgImageRef);
GLuint height = (GLuint)CGImageGetHeight(cgImageRef);
CGRect rect = CGRectMake(0, 0, width, height);
// 绘制图片
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
void *imageData = malloc(width * height * 4);
CGContextRef context = CGBitmapContextCreate(imageData, width, height, 8, width * 4, colorSpace, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
CGContextTranslateCTM(context, 0, height);
CGContextScaleCTM(context, 1.0f, -1.0f);
CGColorSpaceRelease(colorSpace);
CGContextClearRect(context, rect);
CGContextDrawImage(context, rect, cgImageRef);
// 生成纹理
GLuint textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_2D, textureID);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, imageData); // 将图片数据写入纹理缓存
// 设置如何把纹素映射成像素
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 解绑
glBindTexture(GL_TEXTURE_2D, 0);
// 释放内存
CGContextRelease(context);
free(imageData);
return textureID;
}
3.3 着色器的编译链接
对于写好的着色器,需要我们在程序运行的时候,动态地去编译链接。编译一个着色器的代码也比较固定,这里通过后缀名来区分着色器类型,直接看代码:
- (GLuint)compileShaderWithName:(NSString *)name type:(GLenum)shaderType {
// 查找 shader 文件
NSString *shaderPath = [[NSBundle mainBundle] pathForResource:name ofType:shaderType == GL_VERTEX_SHADER ? @"vsh" : @"fsh"]; // 根据不同的类型确定后缀名
NSError *error;
NSString *shaderString = [NSString stringWithContentsOfFile:shaderPath encoding:NSUTF8StringEncoding error:&error];
if (!shaderString) {
NSAssert(NO, @"读取shader失败");
exit(1);
}
// 创建一个 shader 对象
GLuint shader = glCreateShader(shaderType);
// 获取 shader 的内容
const char *shaderStringUTF8 = [shaderString UTF8String];
int shaderStringLength = (int)[shaderString length];
glShaderSource(shader, 1, &shaderStringUTF8, &shaderStringLength);
// 编译shader
glCompileShader(shader);
// 查询 shader 是否编译成功
GLint compileSuccess;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compileSuccess);
if (compileSuccess == GL_FALSE) {
GLchar messages[256];
glGetShaderInfoLog(shader, sizeof(messages), 0, &messages[0]);
NSString *messageString = [NSString stringWithUTF8String:messages];
NSAssert(NO, @"shader编译失败:%@", messageString);
exit(1);
}
return shader;
}
顶点着色器和片段着色器同样都需要经过这个编译的过程,编译完成后,还需要生成一个着色器程序,将这两个着色器链接起来,代码如下:
- (GLuint)programWithShaderName:(NSString *)shaderName {
// 编译两个着色器
GLuint vertexShader = [self compileShaderWithName:shaderName type:GL_VERTEX_SHADER];
GLuint fragmentShader = [self compileShaderWithName:shaderName type:GL_FRAGMENT_SHADER];
// 挂载 shader 到 program 上
GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
// 链接 program
glLinkProgram(program);
// 检查链接是否成功
GLint linkSuccess;
glGetProgramiv(program, GL_LINK_STATUS, &linkSuccess);
if (linkSuccess == GL_FALSE) {
GLchar messages[256];
glGetProgramInfoLog(program, sizeof(messages), 0, &messages[0]);
NSString *messageString = [NSString stringWithUTF8String:messages];
NSAssert(NO, @"program链接失败:%@", messageString);
exit(1);
}
return program;
}
这样,我们只要将两个着色器命名统一,按照规范添加后缀名。然后将着色器名称传入这个方法,就可以获得一个编译链接好的着色器程序。
有了着色器程序后,我们就需要往程序中传入数据,首先要获取着色器中定义的变量,具体操作如下:
注: 不同类型的变量获取方式不同。
GLuint positionSlot = glGetAttribLocation(program, "Position");
GLuint textureSlot = glGetUniformLocation(program, "Texture");
GLuint textureCoordsSlot = glGetAttribLocation(program, "TextureCoords");
传入生成的纹理 ID:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textureID);
glUniform1i(textureSlot, 0);
glUniform1i(textureSlot, 0)
的意思是,将 textureSlot
赋值为 0
,而 0
与 GL_TEXTURE0
对应,这里如果写 1
,glActiveTexture
也要传入 GL_TEXTURE1
才能对应起来。
设置顶点数据:
glEnableVertexAttribArray(positionSlot);
glVertexAttribPointer(positionSlot, 3, GL_FLOAT, GL_FALSE, sizeof(SenceVertex), NULL + offsetof(SenceVertex, positionCoord));
设置纹理数据:
glEnableVertexAttribArray(textureCoordsSlot);
glVertexAttribPointer(textureCoordsSlot, 2, GL_FLOAT, GL_FALSE, sizeof(SenceVertex), NULL + offsetof(SenceVertex, textureCoord));
3.4 Viewport 的设置
在渲染纹理的时候,我们需要指定 Viewport 的尺寸,可以理解为渲染的窗口大小。调用 glViewport
方法来设置:
glViewport(0, 0, self.drawableWidth, self.drawableHeight);
// 获取渲染缓存宽度
- (GLint)drawableWidth {
GLint backingWidth;
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &backingWidth);
return backingWidth;
}
// 获取渲染缓存高度
- (GLint)drawableHeight {
GLint backingHeight;
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &backingHeight);
return backingHeight;
}
3.5 渲染层的绑定
通过以上步骤,我们已经拥有了纹理,以及顶点的位置信息。现在到了最后一步,我们要怎么将缓存与视图关联起来?换句话说,假如屏幕上有两个视图,OpenGL ES 要怎么知道将图像渲染到哪个视图上?
所以我们要进行渲染层绑定。通过 renderbufferStorage:fromDrawable:
来实现:
- (void)bindRenderLayer:(CALayer <EAGLDrawable> *)layer {
GLuint renderBuffer; // 渲染缓存
GLuint frameBuffer; // 帧缓存
// 绑定渲染缓存要输出的 layer
glGenRenderbuffers(1, &renderBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, renderBuffer);
[self.context renderbufferStorage:GL_RENDERBUFFER fromDrawable:layer];
// 将渲染缓存绑定到帧缓存上
glGenFramebuffers(1, &frameBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, frameBuffer);
glFramebufferRenderbuffer(GL_FRAMEBUFFER,
GL_COLOR_ATTACHMENT0,
GL_RENDERBUFFER,
renderBuffer);
}
以上代码生成了一个帧缓存和一个渲染缓存,并将渲染缓存挂载到帧缓存上,然后设置渲染缓存的输出层为 layer
。
最后,将绑定的渲染缓存呈现到屏幕上:
[self.context presentRenderbuffer:GL_RENDERBUFFER];
至此,使用 GLSL 渲染纹理的关键步骤就结束了。
最终效果:
4.GLKit 主要帮我们做了以下几个点:
- 着色器的编写: GLKit 内置了简单的着色器,不用我们自己去编写。
- 纹理的加载:
GLKTextureLoader
封装了一个将 Image 转化为 Texture 的方法。 - 着色器的编译链接:
GLKBaseEffect
内部实现了着色器的编译链接过程,我们在使用过程中基本可以忽略「着色器」这个概念。 - Viewport 的设置: 在渲染纹理的时候,需要指定 Viewport 的大小,
GLKView
在调用display
方法的时候,会在内部去设置。 - 渲染层的绑定:
GLKView
内部会调用renderbufferStorage:fromDrawable:
将自身的layer
设置为渲染缓存的输出层。因此,在调用display
方法的时候,内部会调用presentRenderbuffer:
去将渲染缓存呈现到屏幕上。
待抽空整理
# 五、OpenGL ES
## 1. 绘制三角形
## 2. 绘制更多图形
## 3. 变换矩阵
## 4. 透视投影和正交投影
## 5. 摄像机
## 6. 绘制一个正方体
## 7. 基本光照
## 8. 基本纹理
...
# Metal
## 1. Metal 与 OpenGL ES 区别
## 2. Metal 与 Metal Kit 区别
## 3. Metal 与 OpenGL ES 对比学习
## 4. Metal Shading Language
Metal 程序中的Metal 着色语言
## 5. Metal 向量和矩阵数据类型
推荐
-
- 红宝书 OpenGL Programming Guide,出到第八版
-
- 蓝宝书 OpenGL SuperBible,出到第六版
-
- OpenGL ES 开篇 : 以 Q&A 的形式,列举出在学习 OpenGL ES 之前会存在的一些疑惑。权衡是否该继续学习 OpenGL ES。
-
- OpenGL ES 基础概念:扫盲篇,先介绍一些必须了解的知识,便于之后能直接进入实战阶段。
-
- OpenGL ES 环境搭建:详解 OpenGL ES 接入方式,以最基础效果(设置背景色)来阐述。
-
- OpenGL ES 渲染基本图元:详细介绍可编程图形渲染管线是如何工作的。
-
- GLSL 详解(基础篇):详细介绍 OpenGL ES 2.0 着色器语言 GLSL 基础语法。
-
- GLSL 详解(高级篇):详细介绍 OpenGL ES 2.0 着色器语言高级特性。