最近两个多月,在研究实现一个问题。如何使用 MetalKit 实现 OpenGL ES 的接口?
已有了一点点进展,现在可以跑起 RGBA 格式的 2D 纹理测试例子。接下来打算完善 Shader 转换器,跑起 shadertoy 的一些例子。
这是公司的项目,不能分享代码。此文粗略说说思路,也算阶段性总结。假如其他人打算做类似的项目,可能对其有点帮助。
缘由
为什么要做这个呢?苹果公司在 iOS 12 将 GLES 标记成废除,不清楚什么时候会真正移除。公司有些项目为了同时支持 Android 和 iOS, 是用 GLES 写的。公司项目很多只是图片处理,基本只用到 GLES 2.x。苹果有可能真正移除 GLES, 这是个隐患。假如用 MetalKit 实现 GLES,就有备无患了。这东西不是随便可以弄好的,需要时间打磨。估计以后会有类似的开源项目,只是不知道什么时候。
消除隐患,这是我的初衷,就提出要做 MetalKit 实现 GLES 这个兼容层。额外理由是,用 MetalKit 实现,性能或者会高一些。另外也可以采用 Xcode 自带的工具来调试 Shader。
实际有类似的项目了,如 MoltenGL,只是它闭源。重要的功能,如果可以选择,就不要去选择闭源的实现。真的没有选择就另说。封闭代码是一个不确定因素,不知道什么时候踩坑,并且修改不了。
MoltenGL 闭源,但提供例子工程,可以调试,也很有参考价值。最大的价值,在于它已验证 Metal 实现 GLES 是可行的。
大方向
用 MetalKit 去实现 GLES,需要做两件事。
- 实现一个 Shader 语言转换器,将 GL Shader 转换成 Metal Shader。
- 封装起 Metal 类,实现具体的 GLES 的 C API。如 glDrawElements。
第 1 点 Shader 转换器本身比较独立。但转换出来的 Shader,会影响如何向 Shader 传递数据,也就会影响第 2 点 C API 的实现。转换 Shader 代码和 Api 实现,两者需要密切配合起来。
项目初步命名为 TransGL。经过调研,决定基于 google/angle 实现。
Angle 是 Chrome 的子项目,Windows 默认没有安装 OpenGL,而浏览器需要支持 WebGL。Angle 最开始的目的是在 Windows 上,使用 DirectX 来模拟出 WebGL 的接口,这样用户不用安装 GL 也可以使用 Chrome。跟我们的目的很像。现在 angle 已有 Dx9、Dx11、Vulkan(进行中)多个后端,我只需要多加一个 Metal 后端。实际上,以后 Angle 很可能也会官方支持 Metal,只是不确认何时。
Angle 项目最起码做了两件麻烦事。
- 将 GL 的 Shader 语言转换成语法树。
- 提供了一个中间层,管理了 GL 的大量状态。(GL 的接口其实是个状态机)
于是我裁剪 angle 的代码,将其移植到 iOS 工程中编译。这样只需要将原来的 DirectX 换成 Metal。需要提供两个后端。
- 提供一个转换器,将语法树转换成 Metal Shader 语言。
- 提供一个 Metal 实现的渲染后端,将 GL 的状态同步过去,渲染出来。
难点
我没有动手之前,会觉得第 1 点,转换 Shader 是难点。而第 2 点封装 C API 本身应该不难。但真正动手,因为 angle 项目已经转换出语法树了,遍历语法树转换到 Metal Shader 实际并不难。
但需要设计好 GLSL 跟 Metal Shader 的转换规则。
我调试过 MoltenGL 转换出来的 Metal(虽然是不开源,但是可以调用其接口,也可以用 Xcode 自带的工具来看)。发觉它转换出来的 Metal Shader,函数调用是被自动 inline 了,实际已经没有函数定义和调用了。另外循环也被展开,转换后的代码是比较乱的。只能参考,不能照抄。我额外设计了一套对应规则。
现在的趋势,Shader 可以转成统一的字节码标准 SPIRV,然后再转成目标 Shader。只是我的语法树都是 angle 代码中现成的,自然就用语法树。另外我转换出来的 Metal Shader 格式,需要跟封装的 API 本身是密切配合,直接操作语法树反而方便些,也全部都可控。
转换 Shader 本身已不是难点,比较之下。GL 和 Metal 接口的对应,反而是麻烦。
- GL 是个状态机,Api 可随时修改状态,而 Metal 会用 Descriptor 创建好对象,之后对象的状态通常不可变。不能让可变状态的 GL Api 立即调用 Metal 的某个 Api,不然那 GL 状态变来变去,而 Metal 对象不可变,就需要不断销毁创建对象。因此需要缓存 GL 状态,等真正调用 draw 函数的时候,再一次同步到 Metal 中。
- 对于 Texture、VertexArray、Buffer 等涉及到大量内存的地方,需要尽可能减少内存拷贝。直接拷贝内存,实现起来最简单,但会有性能问题。假如不拷贝,就比较麻烦。绝大部分的场合,是不需要拷贝的,这样需要将不需要拷贝的情况shuai'x出来。
但幸好 Angle 项目将一些麻烦事做好了,也有完整的 Dx11 实现参考。在 Windows 上编译跑起来,就可以调试其代码,有些代码可以直接复制过来使用。主要参考 Dx11 实现。
简述 Angle 后端实现
需要实现 google/Angle 中两个后端。转换器后端,和渲染后端。
转换器后端,对应于 translator 目录的代码。编写自己的类,继承 TIntermTraverser,就可遍历语法树。这个 TIntermTraverser 其实就是设计模式中访问者(Visitor)模式。
渲染器后端,对应于 renderer 目录的代码。从 gl::Context 中可以获取 GL 的状态,gl::Context 是所有 GL 函数的入口点。gl::Context 中包含一个 rx::ContextImpl 的基类指针。我编写自己的类 rx::ContextMetal,去继承 rx::ContextImpl,可以实现平台相关的渲染接口。
同理 gl::Texture 包装着 rx::TextureImpl, gl::Buffer 包含 rx::BufferImpl 等等。编写自己的类 rx::TextureMetal、rx::BufferMetal,分别继承 rx::TextureImpl、rx::BufferImpl,去定制 Metal 相关实现。
gl::Context、gl::Texture、gl::Buffer 中编写共有代码,再转调 rx::ContextImpl、rx::TextureImpl、rx::BufferImpl 各渲染后端的独特代码。这是设计模式中的桥接(Bridge)模式。
上述类的继承关系是静态的,程序真正运行的并非是类,而是对象。除了要理清继承关系,还需要关心从类生成对象的时机。
google/Angle 的入口点是 gl::Context,我们可以直接创建出 gl::Context 的对象。而创建 gl::Context 的时候,需要传递一个 EGLImplFactory 的基类指针。于是可以定义自己的类 DisplayMetal 继承 EGLImplFactory,创建出 rx::ContextMetal 的对象。而我们又可以定制 rx::ContextMetal 对象(实际是个 GLImplFactory),于是创建出 rx::TextureMetal、rx::BufferMetal 等对象。
总结一下,就是先创建出 DisplayMetal 对象。在创建 gl::Context 时传入 DisplayMetal 对象。于是 gl::Context 会调用 DisplayMetal(继承 EGLImplFactory) 的虚函数接口,创建出 rx::ContextMetal 对象,存储下来。系统运行时,在需要的时候,会调用 rx::ContextMetal (继承 GLImplFactory) 的虚函数接口,创建 rx::TextureMetal、rx::BufferMetal 等对象。很典型地,这是设计模式中的工厂(Factory)。
理解了访问者(Visitor)、桥接(Bridge)、工厂(Factory)三个模式。就可以大体理解了 Angle 项目的后端编写方式,真正动手自然会遇到很多细节。但在心中形成一个大框架会对实现很有帮助,容易可以找到对应的地方。
比如在实现 glTexImage2D 的时候,知道这函数属于 GL 的 Texture。就应该猜到应该在 gl::Texture,而 gl::Texture 会包装着 gl::TextureImpl, 于是需要编写 rx::TextureMetal 继承 gl::TextureImpl。而在 rx::ContextMetal 的接口中,创建出 rx::TextureMetal。要去参考 Dx11 的对应代码,就应该去找 rx::TextureD3D。(Angle 的 Dx 实现有两个 Dx9 和 Dx11,经常会有 11 后缀的类,去继承 D3D 后缀的类。D3D 是 Dx9 和 Dx11 的公共实现)。要参考 Dx11 的实现,比如 Buffer, 就去搜索 Buffer11 或者 BufferD3D 之类的关键字就行。
实现过程
将 Angle 的代码裁剪掉多余的,在 iOS 上编译通过。我就可以慢慢分别实现各个 BufferMetal、TextureMetal 等类。最开始类的方法都可以是空方法,需要用到才实现。
要尽快让测试例子跑起来,尽管不能显示任何东西。让代码每时每刻都可以运行是很重要的。只要可以运行,就可以设置断点,打印运行信息,慢慢修改完善。假如大半天都不能运行,埋头写上一堆代码,错误就会积累。也就是 Make it run, make it right, make it fast。
第一步,需要先弄好测试环境。我先定义一个宏 USE_METAL_GL
#define USE_METAL_GL 1
#if USE_METAL_GL
#define glActiveTexture mglActiveTexture
#define glAttachShader mglAttachShader
#define glBindAttribLocation mglBindAttribLocation
#define glBindBuffer mglBindBuffer
xxxx
#endif如果宏 USE_METAL_GL = 1,各 gl 函数其实就是对应的 mgl 函数,也就是 MetalKit 实现的 GLES。假如 USE_METAL_GL = 0,就会调用到 iOS 自带的 GL 实现。
自带的实现是 gl 开头的,这可能会使得跟 iOS 系统库的函数符号冲突。我统一将 gl 修改成 mgl 了。其实不需要自己修改,只需要找个 gl 的标准头文件,写个脚本,将 gl 函数扫一遍,就可以批量改名了。
这时就可以编写 GL 的测试例子。先将 USE_METAL_GL = 0,编写好测试例子。之后在 USE_METAL_GL = 1,调用 MetalKit 实现。
最简单的 GL 测试例子,就是用 glClear 清除背景色。假如可以将背景设置成红色,GL 的环境就建立起来了。
iOS 的 GL 环境,并非用 egl 这些函数来创建。而是使用 CAEAGLLayer、EAGLContext、GLKView 这些类。于是也需要创建对应的类 MGLLayer、EAGLContext、MGLKView。也使用 USE_METAL_GL 宏来切换,让他们的接口一模一样。最开始都是空方法,让自己写的代码可以跑起来,逐步完善。只需要切换 USE_METAL_GL 宏,测试例子就可以在 iOS 自带的 GL 和 MetalKit GL 中切换。
测试例子,从简单到复杂。
- 用 glClear 清除背景色了。
- glVertexAttribPointer、glDrawArrays 显示红色的三角形。
- glUniformMatrix4fv 传入矩阵参数让红色三角形旋转。
- 载入 RGBA 纹理,使用 glDrawArrays 显示一张图片。
- 载入 RGBA 纹理,使用 glDrawElements 显示一张图片。
- 载入 RGBA 纹理,使用 glBuffer 和 glDrawElements 显示一张图片。
- 载入 RGBA 纹理,将图片用 glFramebufferTexture2D 离屏渲染到另一张纹理上。
- 修改 GLGL Shader,完善 Shader 转换器,让代码支持 if for 函数调用等等。
从简单到复杂,慢慢完善。会有很多细节需要处理、也需要参考 Windows 上 Dx11 的实现方式。
我现在可以显示出 RGBA 纹理了,也跑起了一段比较复杂点的 Shader 代码。接下来就去跑起更复杂些的 shadertoy 上的代码。和优化一些内存实现,现在有多余拷贝。Make it run, make it right, make it fast。现在需要 make it fast。
附上一张调试截图。

Shader 转换
文章最后会附上给出一个 GLSL 转换到 Metal Shader 的例子。
对比 GLSL 和 Metal Shader, 会发现 GLSL 的所有 varying 变量,会被收集起来放到 MtlShaderVaryings 结构中,所有 attribute 变量,会被收集起来放到 MtlShaderAttributes 接口中。uniform 变量会放到 MtlVertexUniforms MtlFragmentUniforms 中。例中原始 vertex shader 没有 uniform 变量,也就没有生成 MtlVertexUniforms 结构。
其中 GLSL 的 sampler 的 uniform 变量需要特别处理,映射到 Metal 中,就会产生对应的 texture2d 和 sampler。在 Metal 中,纹理和采样方式是分离的两个对象。而在 GL 中,采样方式只是纹理的参数。于是 GLSL 中一个 sampler 类型,会产生两个对象。为了方便跟 GLSL 对应,我定义了 Texture2D 结构,将 texture2d 和 sampler 包装起来。
另外需要注意的是,我将代码放到 MtlVertex 和 MtlFragment 两个结构中。原因是 GLSL 中可以再 main 函数外面对应一些全局是变量,其它函数都可以访问修改。但 Metal Shader 中是不能这样做的,Metal 中,main 函数外面的全局变量,需要用 constant 修饰。于是为了容易对应,我定义了 MtlVertex、MtlFragment 结构,GLSL 中的需要全局访问的数据映射成结构中的成员变量,而全局函数对应成成员函数,于是成员函数可以访问成员变量。
对 GLES 的看法
GL 其实有很多很繁琐的东西。GL 是个全局状态机(准确点说,GL 的状态机是线程独立的,每个线程的 GL 状态可以分开,但说全局也没有啥错),它的 C API 也是全局函数,随时可以修改状态。这会导致 GL 的状态比较混乱,往往不知道啥地方设置错了,就要找半天。底层的库为了不修改调用后的状态,往往会将状态保持起来之后再恢复,这很多时候是多余的。
假如不考虑什么跨平台的话,只做 iOS 的 App, 直接用 Metal 写代码反而更简单。
过现在的移动 App,基本都需要支持 iOS 和 Android,就需要一套跨平台的图形封装层。还在用 GLES, 更多是拿它作为统一的图形封装层来使用,另外 GLES 这个图形层也多人熟悉,工具也算多。但实际上 GLES 作为图形封装层,是很厚的,很多繁琐的东西其实根本没有必要。
就算不直接写 GLES, 通常也需要一个统一的图形层,不然代码就会分裂。可以像一些游戏引擎一样,自己定义一套统一的图形 API, 一套统一的 Shader。抛开 GLES 的包袱,这样图形层的实现反而会更简单些,只是这样就需要改写太多现有的代码,也需要建立一套完整的工具链。
类似的统一图形封装层,有 bkaradzic/bgfx 项目。
附录
原始 GLSL 代码
// GLSL Vertex
attribute vec4 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTexCoord;
void main() {
gl_Position = aPosition;
vTexCoord = aTextureCoord.xy;
}
// GLSL Fragment
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D u_flowmap;
uniform sampler2D u_image;
uniform float u_progress;
uniform sampler2D uMaskTexture;
uniform int uUseMaskTexture;
vec2 getEdgeUV(vec2 position, vec2 mov) {
vec2 uv = position + mov;
float mask = texture2D(uMaskTexture, uv).r;
if (mask == 0.0) {
return uv;
}
float left = 0.0;
float right = 1.0;
float mid = (left + right) / 2.0;
vec2 newMov = mov * mid;
for(int i = 0; i < 6; i++) {
uv = position + newMov;
mask = texture2D(uMaskTexture, uv).r;
if (mask <= 0.05) {
left = mid;
} else {
right = mid;
}
mid = (left + right) / 2.0;
newMov = mov * mid;
}
return uv;
}
void main() {
vec4 o1 = texture2D(u_flowmap, vTexCoord * vec2(1.0, 0.5));
vec4 o2 = texture2D(u_flowmap, vTexCoord * vec2(1.0, 0.5) + vec2(0.0, 0.5));
vec2 mov1 = (o1.ga * 255.0 + o1.rb) * (2.0 / 256.0) - 1.0;
vec2 mov2 = (o2.ga * 255.0 + o2.rb) * (2.0 / 256.0) - 1.0;
vec2 uv1 = vTexCoord + mov1;
vec2 uv2 = vTexCoord + mov2;
if (uUseMaskTexture != 0) {
float mask = texture2D(uMaskTexture, vTexCoord).r;
if (mask >= 0.95) {
uv1 = vTexCoord;
uv2 = vTexCoord;
} else {
uv1 = getEdgeUV(vTexCoord, mov1);
uv2 = getEdgeUV(vTexCoord, mov2);
}
}
gl_FragColor = mix(texture2D(u_image, uv1), texture2D(u_image, uv2), u_progress);
}转换出来的 Metal Shader
#include <metal_stdlib>
using namespace metal;
struct MtlShaderAttributes {
float4 aPosition[[attribute(0)]];
float4 aTextureCoord[[attribute(1)]];
};
struct MtlShaderVaryings {
float4 gl_Position[[position]];
float2 vTexCoord;
};
struct MtlFragmentUniforms {
float u_progress;
int uUseMaskTexture;
};
struct Texture2D {
Texture2D(texture2d<half> t_in, sampler s_in) : t(t_in), s(s_in) {
}
texture2d<half> t;
sampler s;
};
static float4 mtl_texture2D(Texture2D t, float2 v) {
half4 tmp = (t.t).sample((t.s), v);
return float4(tmp);
}
struct MtlVertex {
MtlVertex(thread MtlShaderAttributes& mtl_a_in)
: mtl_a(mtl_a_in) {
}
MtlShaderVaryings mtl_v;
thread MtlShaderAttributes& mtl_a;
void main() {
(mtl_v.gl_Position = float4(0.0, 0.0, 0.0, 0.0));
(mtl_v.gl_Position = mtl_a.aPosition);
(mtl_v.vTexCoord = mtl_a.aTextureCoord.xy);
}
};
vertex MtlShaderVaryings vertex_main(MtlShaderAttributes mtl_a[[stage_in]]) {
MtlVertex mtl(mtl_a);
mtl.main();
return mtl.mtl_v;
}
struct MtlFragment {
MtlFragment(thread MtlShaderVaryings& mtl_v_in, constant MtlFragmentUniforms& mtl_u_in, texture2d<half> mtltex_u_flowmap, sampler mtlsmp_u_flowmap, texture2d<half> mtltex_u_image, sampler mtlsmp_u_image, texture2d<half> mtltex_uMaskTexture, sampler mtlsmp_uMaskTexture)
: mtl_v(mtl_v_in), mtl_u(mtl_u_in), u_flowmap(mtltex_u_flowmap, mtlsmp_u_flowmap), u_image(mtltex_u_image, mtlsmp_u_image), uMaskTexture(mtltex_uMaskTexture, mtlsmp_uMaskTexture) {
}
float4 gl_FragColor;
thread MtlShaderVaryings& mtl_v;
constant MtlFragmentUniforms& mtl_u;
Texture2D u_flowmap;
Texture2D u_image;
Texture2D uMaskTexture;
float2 getEdgeUV(float2 position, float2 mov) {
float2 uv = (position + mov);
float mask = mtl_texture2D(uMaskTexture, uv).x;
if ((mask == 0.0)) {
return uv;
}
float left = 0.0;
float right = 1.0;
float mid = ((left + right) / 2.0);
float2 newMov = (mov * mid);
for (int i = 0; (i < 6); (i++)) {
(uv = (position + newMov));
(mask = mtl_texture2D(uMaskTexture, uv).x);
if ((mask <= 0.050000001)) {
(left = mid);
} else {
(right = mid);
}
(mid = ((left + right) / 2.0));
(newMov = (mov * mid));
}
return uv;
}
void main() {
float4 o1 = mtl_texture2D(u_flowmap, (mtl_v.vTexCoord * float2(1.0, 0.5)));
float4 o2 = mtl_texture2D(u_flowmap, ((mtl_v.vTexCoord * float2(1.0, 0.5)) + float2(0.0, 0.5)));
float2 mov1 = ((((o1.yw * 255.0) + o1.xz) * 0.0078125) - 1.0);
float2 mov2 = ((((o2.yw * 255.0) + o2.xz) * 0.0078125) - 1.0);
float2 uv1 = (mtl_v.vTexCoord + mov1);
float2 uv2 = (mtl_v.vTexCoord + mov2);
if ((mtl_u.uUseMaskTexture != 0)) {
float mask = mtl_texture2D(uMaskTexture, mtl_v.vTexCoord).x;
if ((mask >= 0.94999999)) {
(uv1 = mtl_v.vTexCoord);
(uv2 = mtl_v.vTexCoord);
} else {
(uv1 = getEdgeUV(mtl_v.vTexCoord, mov1));
(uv2 = getEdgeUV(mtl_v.vTexCoord, mov2));
}
}
(gl_FragColor = mix(mtl_texture2D(u_image, uv1), mtl_texture2D(u_image, uv2), mtl_u.u_progress));
}
};
fragment float4 fragment_main(MtlShaderVaryings mtl_v[[stage_in]], constant MtlFragmentUniforms &mtl_u[[buffer(1)]], texture2d<half> mtltex_u_flowmap[[texture(0)]], sampler mtlsmp_u_flowmap[[sampler(0)]], texture2d<half> mtltex_u_image[[texture(1)]], sampler mtlsmp_u_image[[sampler(1)]], texture2d<half> mtltex_uMaskTexture[[texture(2)]], sampler mtlsmp_uMaskTexture[[sampler(2)]]) {
MtlFragment mtl(mtl_v, mtl_u, mtltex_u_flowmap, mtlsmp_u_flowmap, mtltex_u_image, mtlsmp_u_image, mtltex_uMaskTexture, mtlsmp_uMaskTexture);
mtl.main();
return mtl.gl_FragColor;
}