手搓跨平台 UI 引擎
市面上带 UI 的跨平台框架层出不穷,如 QT、Flutter、RN、Skia 等。它们很强大,但可能并不满足某些场景的需求:
例如,同时满足 轻量 + 性能 + 可定制。
那如果要自己实现跨平台 UI 引擎,该如何入手?稍微熟悉各端原生 UI 框架底层细节的人,可能很多会望而却步,因为里面涉及太多东西:
- 跟原生渲染引擎(OpenGL/Metal/Vulkan)的交互;
- 处理系统事件(如鼠标、键盘、触摸),事件分发;
- 图片的加载、渲染、缓存,文本的渲染、排版;
- 各种组件的封装,各种布局的实现,动画系统等;
- 高效的 UI 刷新机制,支持响应式和局部更新;
- UI 描述语言,支持动态/静态方式创建;
- 各种内存和性能优化机制…
看起来比较唬人,但当你真正一砖一瓦地逐步搭建起来,可能最后会发现其实也没那么复杂。
当然,这里这里讨论的仅仅是满足最基础 UI 渲染场景(图文、布局、事件、动画等),真要做得通用且完善还得花不少精力(起码的敬畏还是要有)。
但至少能从中加深对 UI 框架底层渲染原理的理解,而不满足于死记硬背那些别人嚼剩下的八股文。
图形后端选择
首先说结论,目前真正能全平台提供完善支持的图形后端,有且仅有 OpenGL。
Metal/DirectX 仅支持自家平台,直接 pass;
Vulkan 虽然目前在 Apple 以外的平台支持越来越广泛,在 Apple 上也可以通过 MoltenVK 提供支持,但也存在不少问题:
杀鸡焉用牛刀?
对于 UI 这种轻量级渲染场景(相对游戏等复杂 3D 场景而言),Vulkan 对硬件的掌控力所带来的优势并不明显。
打铁还需自身硬!
Android 10 才支持 Vulkan 1.1 ,某些硬件虽声称支持,但存在驱动 bug 或性能问题。
费力不讨好!
Vulkan 学习门槛高,而 OpenGL 跨平台支持完善,学习资料及第三方库丰富(AI 的支持自然也更完善)。
更重要的是,OpenGL 应用同样能享用 Metal/Vulkan 等现代图形后端:
- Apple 虽将其标记为 deprecated,但仍支持自动转译;
- Android 15 也通过引入 ANGLE 提供了转译支持。
所以综合来看,短期内 OpenGL 仍难以替代,并且仍是轻量级渲染场景的不二选择。
OpenGL 渲染环境
移动端
对于 Android/iOS 的渲染环境,前文有详细介绍,这里不再赘述。
而鸿蒙端,笔者暂未实践过,后续再补充这部分。
PC 端
PC 端,可以借助开源的 glfw 来管理 OpenGL 渲染窗口。
加载器
首先介绍下 glad,它是一个 OpenGL 加载器,用于在运行时加载 OpenGL 环境,甚至还支持 Vulkan。
它其实主要是抹平了不同平台之间 OpenGL 函数的差异(做了映射),使得在不同平台上使用 OpenGL 时,代码可以保持一致。
另外还推荐一个 glad loader generator:gen.glad.sh,可以根据当前平台的 OpenGL 版本,自动生成对应的加载器代码。
窗口创建
glfwInit();
if (!glfwInit()) {
std::cerr << "Failed to initialize GLFW" << std::endl;
return;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
const auto window = glfwCreateWindow(800, 600, "UI Engine", nullptr, nullptr);
if (!window) {
std::cerr << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return;
}
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
std::cerr << "Failed to init GLAD" << std::endl;
}
glfwSetWindowUserPointer(window, this);//传入用户指针,方便后续各种回调
窗口可见性控制
glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);//初始化时隐藏
glfwIconifyWindow(window);//最小化
glfwRestoreWindow(window);//最大化
glfwHideWindow(window);//完全隐藏(无法通过状态栏恢复)
glfwShowWindow(hiddenWin);//显示
glfwFocusWindow(hiddenWin);//聚焦
int visible = glfwGetWindowAttrib(window, GLFW_VISIBLE);//查询窗口是否可见
int iconified = glfwGetWindowAttrib(window, GLFW_ICONIFIED);//查询窗口是否最小化
int isFocused = glfwGetWindowAttrib(window, GLFW_FOCUSED);//查询窗口是否聚焦
glfwSetWindowVisibilityCallback(window, onVisibilityChanged);//监听窗口可见性变化
glfwSetWindowIconifyCallback(window, onIconifyChanged);//监听窗口最小化变化
glfwSetWindowFocusCallback(window, onFocusChanged);//监听窗口聚焦变化
窗口渲染
//监听窗口大小变更
void onFrameBufferSizeChanged(GLFWwindow* window, int w, int h) {
width = w;
height = h;
glViewport(0, 0, w, h);
}
glfwSetFramebufferSizeCallback(window, [](GLFWwindow* w, int width, int height) {
const auto obj = static_cast<GLWin*>(glfwGetWindowUserPointer(w));
obj->onFrameBufferSizeChanged(w, width, height);
});
glViewport(0, 0, width, height);//视口变换
while (!glfwWindowShouldClose(window)) {
if (glfwGetWindowAttrib(window, GLFW_VISIBLE) == GLFW_FALSE) {
continue;//窗口不可见不用渲染
}
glfwMakeContextCurrent(window);//切换渲染上下文
//真正的 OpenGL 渲染逻辑
glfwSwapBuffers(window);//交换缓冲区,将渲染结果显示到窗口
}
窗口销毁
glfwSetWindowCloseCallback(window, [](GLFWwindow* w) {
const auto obj = static_cast<GLWin*>(glfwGetWindowUserPointer(w));
obj->onWindowClose(w);
});
glfwSetWindowShouldClose(window, GLFW_TRUE);//设置窗口关闭标志,触发窗口关闭回调
glfwDestroyWindow(window);
glfwTerminate();
原生 UI 事件拦截
移动端
对于触摸事件,移动端需通过原生组件拦截 Touch 事件坐标及类型(跨平台场景不需要像原生一样区分那么细,只需要区分是否是 Down);
对于键盘输入事件,同样是需要原生输入框控件去负责监听输入内容、触发键盘显示与隐藏。
具体的这里就不展开了,也没啥高深的内容。
PC 端
PC 端仍然是通过 glfw 来监听鼠标和键盘事件:
auto status = glfwGetMouseButton(window, GLFW_MOUSE_BUTTON_LEFT) == GLFW_PRESS;//判断左键是否按下
double x, y;
glfwGetCursorPos(window, &x, &y);//获取鼠标当前坐标
glfwSetCharCallback(window, onCharChanged);//监听字符输入事件
glfwSetKeyCallback(window, onKeyChanged);//监听键盘输入事件
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);//禁用系统鼠标
glfwSetInputMode(window, GLFW_STICKY_KEYS, GLFW_TRUE);//启用键盘重复事件
对于 Windows 还有些特殊处理:
#if defined(_WIN32)
#ifndef GLFW_EXPOSE_NATIVE_WIN32
#define GLFW_EXPOSE_NATIVE_WIN32
#endif
static WNDPROC originalWndProc;
LRESULT CALLBACK handleWindowsImeWindow(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
switch (uMsg) {
case WM_IME_STARTCOMPOSITION:
usingIMM = true;
break;
case WM_IME_ENDCOMPOSITION:
usingIMM = false;
break;
case WM_IME_COMPOSITION: {
HIMC hIMC = ImmGetContext(hwnd);
if (lParam & GCS_RESULTSTR) {
std::array<wchar_t, 256> buffer;
if (const auto size = ImmGetCompositionString(hIMC, GCS_RESULTSTR, buffer.data(), buffer.size()); size > 0) {
onImeInput(buffer.data());
}
}
ImmReleaseContext(hwnd, hIMC);
break;
}
default:
return CallWindowProc(originalWndProc, hwnd, uMsg, wParam, lParam);
}
return 0;
}
void windowsSetIMEPosition(int x, int y) {
auto hwnd = glfwGetWin32Window(window);
if (const auto hIMC = ImmGetContext(hwnd); hIMC) {
COMPOSITIONFORM cf;
cf.dwStyle = CFS_POINT;
cf.ptCurrentPos.x = x;
cf.ptCurrentPos.y = y;
ImmSetCompositionWindow(hIMC, &cf);
ImmReleaseContext(hwnd, hIMC);
}
}
void windowsSetCustomWndProc(GLFWwindow* window) {
HWND hwnd = glfwGetWin32Window(window);
originalWndProc = (WNDPROC)GetWindowLongPtr(hwnd, GWLP_WNDPROC);
SetWindowLongPtr(hwnd, GWLP_WNDPROC, (LONG_PTR)handleWindowsImeWindow);
}
#endif
Shader
顶点着色器
直接上代码:
#version 300 es
layout(location = 0) in vec3 apos;
layout(location = 1) in vec2 uv0;
layout(location = 2) in vec4 vertColor;
out vec2 uv;
out vec4 outColor;
uniform vec2 _sreenSize;
void main() {
uv = uv0;
outColor = vertColor;
vec2 ndcPos = (apos.xy / _sreenSize - vec2(0.5, 0.5)) * 2.0;
gl_Position = vec4(ndcPos.x, ndcPos.y, 0.0, 1.0);
}
- 第一行声明 GLSL 版本,这是移动端的,如果是 PC 端,则为
#version 330 core; - 由于使用 OpenGL 3.2+,新增了
in/out/layout等关键字,不用在代码里频繁调用glBindAttribLocation(); uv0是 UV 坐标,vertColor是顶点颜色,这些都是常规的输入;apos是顶点位置,用于控制组件位置;_sreenSize是屏幕尺寸,用于将 NDC(Normalized Device Coordinates) 坐标转换为屏幕坐标;
片段着色器
还是先上代码:
#version 300 es
precision mediump float;
in vec2 uv;
in vec4 outColor;
out vec4 FragColor;
uniform sampler2D _mainTex;
uniform vec4 _tint;
uniform float _isText;
uniform float _isGray;
vec3 rgbToGray(vec3 color) {
float gray = dot(color, vec3(0.299, 0.587, 0.114));
return vec3(gray);
}
void main() {
vec4 samp = texture(_mainTex, uv);
if (_isText > 0.1) {
FragColor = vec4(_tint.rgb, samp.x * _tint.a);
FragColor.rgb *= outColor.rgb;
} else {
FragColor = samp * _tint;
}
if(_isGray > 0.1) {
FragColor.rgb = rgbToGray(FragColor.rgb);
}
}
- 第一行同样是声明 GLSL 版本;
precision mediump声明浮点数精度为中等精度,这是移动端的默认精度,PC 端则为highp(一般无需指定);sampler2D是 2D 纹理,用于渲染图片和文字;_tint是颜色,用于控制纹理颜色;_isText是否为文本,文本和图片的颜色混合逻辑不同(文本的_tint表示前景色,图片表示背景色);_isGray是否为灰度图,用于控制是否渲染为灰度图,rgbToGray()函数负责转换;
编译链接与加载
这部分就很通用了,不再展开,可参考我之前的项目 Graphics-Snippets。
图片加载及渲染
要将图片转换为 2D 纹理,需要先解码,之前的项目 Graphics-Snippets 主要使用平台层的原生 API(Android 的 GLUtils 及 iOS 的 GLKTextureLoader)。
对于跨平台场景,常用第三方库 stb_image:
int width, height, channels;
auto data = stbi_load(path.c_str(), &width, &height, &channels, 0);
if (!data) {
std::cerr << "Failed to load image: " << path << std::endl;
return;
}
GLuint glTex{0};
glGenTextures(1, &glTex);
glBindTexture(GL_TEXTURE_2D, glTex);
//处理多通道及纹理格式
GLint internalFormat;
GLenum format;
GLint dataType = GL_UNSIGNED_BYTE;
if (channels == 1) {//字体纹理
internalFormat = GL_R8;
format = GL_RED;
} else if (channels == 3) {
internalFormat = GL_RGB8;
format = GL_RGB;
} else if (channels == 4) {
internalFormat = GL_RGBA8;
format = GL_RGBA;
} else {
std::cerr << "Unsupported image format with " << channels << " channels." << std::endl;
glDeleteTextures(1, &glTex);
stbi_image_free(data);
return false;
}
//Use GL_CLAMP_TO_EDGE for better compatibility
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);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, internalFormat, width, height, 0, format, dataType, data);
stbi_image_free(data);
注意:
- 纹理格式为了兼容 iOS,统一采用
GL_UNSIGNED_BYTE,GL_FLOAT不支持; - 对于字体纹理,
internalFormat要用GL_R8,而不是GL_RED; - iOS 某些情况 R/B 通道可能是反的(例如直接从文件加载,而不是 bundle),需要交换。
文本渲染
跨平台字体文本渲染,一般用 FreeType(很多第三方框架如 Unity 等都有封装),如果要支持复杂排版(例如阿拉伯语的从右到左),还需要用到 HarfBuzz,这里主要介绍前者。
字体加载
直接上代码:
//初始化字体库
FT_Library ftLib;
if (FT_Init_FreeType(&ftLib) != FT_Err_Ok) {
std::cerr << "Failed to initialize FreeType library." << std::endl;
return;
}
//加载字体
FT_Face face;
if (FT_New_Face(ftLib, fontPath.c_str(), 0, &face) != FT_Err_Ok) {
std::cerr << "Failed to load font: " << fontPath << std::endl;
return;
}
//设置字体大小
FT_Set_Pixel_Sizes(face, 0, fontSize);
//释放字体和字体库
FT_Done_Face(face);
FT_Done_FreeType(ftLib);
自定义内存管理模块
虽然 FreeType 提供了默认的内存分配器,但为了避免内存碎片,我们可以自定义内存分配器:
//自定义内存分配逻辑
static void* my_alloc(FT_Memory memory, long size) {
return std::malloc((size_t)size);
}
//自定义内存释放逻辑
static void my_free(FT_Memory memory, void* block) {
std::free(block);
}
//自定义内存重新分配逻辑
static void* my_realloc(FT_Memory memory, long cur_size, long new_size, void* block) {
return std::realloc(block, (size_t)new_size);
}
//自定义内存管理器
FT_MemoryRec my_memory = {
.user = NULL,
.alloc = my_alloc,
.free = my_free,
.realloc = my_realloc
};
//创建字体库
FT_Library library;
FT_Error error = FT_New_Library(&my_memory, &library);
if (error) {
std::cerr << "Failed to create FreeType library with custom memory manager." << std::endl;
return;
}
//必须手动加载默认模块(如 TrueType、CFF 等)
FT_Add_Default_Modules(library);
//... 加载 Face ...
//释放字体库
FT_Done_Library(library);
数据结构
constexpr auto FONT_TEX_SIZE = 2048.0f;//字体纹理大小
constexpr auto FONT_HEIGHT = 38.0f;//字体高度
//字体字符数据结构
struct Character {
GLuint TextureID{0};//字体纹理 ID
ivec2 Size{0, 0};//字符大小
ivec2 Bearing{0, 0};//字符基线到字符顶部的距离
GLuint Advance{0};//字符水平 Advance
vec2 minUV{0, 0};//字符在字体纹理中的最小 UV 坐标
vec2 uvSize{0, 0};//字符在字体纹理中的 UV 大小
};
//字体纹理数据结构
struct FontTex {
GLuint tex{0};//字体纹理ID
ivec2 nextFontPos{0, 0};//下一个字符的绘制位置
bool full() {//判断字体纹理是否已满
return nextFontPos.y >= FONT_TEX_SIZE;
}
};
//缓存
std::vector<FontTex> cachedFontTex;
static std::unordered_map<wchar_t, Character> cachedChars;
初始化字体纹理
auto ftex = FontTex();
ftex.nextFontPos = ivec2(0,0);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glGenTextures(1, &ftex.tex);
if (ftex.tex == 0) {
std::cerr << "Text glGenTextures error: " << glGetError() << std::endl;
return nullptr;
}
glBindTexture(GL_TEXTURE_2D, ftex.tex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, FONT_TEX_SIZE, FONT_TEX_SIZE, 0, GL_RED, GL_UNSIGNED_BYTE, NULL);
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);
GLboolean isTexture = glIsTexture(ftex.tex);
if (!isTexture) {
std::cerr << "Text glIsTexture error: " << glGetError() << std::endl;
return nullptr;
}
cachedFontTex.push_back(ftex);
渲染单个字符
首先我们得判断换行符,因为换行符不用渲染(事实上在字体文件也找不到):
bool isLineChar(wchar_t c) {
return c == L'\n' || c == L'\r';
}
加载普通字符:
//检查字符支持
if (FT_Get_Char_Index(face, c) == 0) {
if (isLineChar(c)) {//换行符直接跳过
return true;
}
std::cerr << "character not found: " << c << std::endl;
return false;
}
//加载字符
if (FT_Load_Char(face, c, FT_LOAD_DEFAULT) != FT_Err_Ok) {
std::cerr << "Failed to load character: " << c << std::endl;
return false;
}
//渲染字符
const auto glyph = face->glyph;
if (FT_Render_Glyph(glyph, FT_RENDER_MODE_NORMAL) != FT_Err_Ok) {
std::cerr << "Failed to render glyph: " << c << std::endl;
return false;
}
if (!glyph->bitmap.buffer) {
std::cerr << "glyph.bitmap is null: " << c << std::endl;
return false;
}
//检查是否超出字体纹理宽度
if (pos.x + glyph->bitmap.width > FONT_TEX_SIZE) {
pos.x = 0;
pos.y += FONT_HEIGHT * 1.25;
}
//上传字符到字体纹理
ivec2 pos = ftex->nextFontPos;
glBindTexture(GL_TEXTURE_2D, ftex->tex);
//由于是多个字符共用一个字体纹理,所以用 `glTexSubImage2D`,并且要更新绘制位置
glTexSubImage2D(GL_TEXTURE_2D, 0, pos.x, pos.y, glyph->bitmap.width, glyph->bitmap.rows, GL_RED, GL_UNSIGNED_BYTE, glyph->bitmap.buffer);
//记录字符信息
Character character = {
ftex->tex,
glm::ivec2(glyph->bitmap.width, glyph->bitmap.rows),
glm::ivec2(glyph->bitmap_left, glyph->bitmap_top),
static_cast<GLuint>(glyph->advance.x),
vec2(pos) / FONT_TEX_SIZE,
vec2(glyph->bitmap.width, glyph->bitmap.rows) / FONT_TEX_SIZE
};
//更新下一个字符的绘制位置
pos.x += glyph->bitmap.width + 1;
if (pos.x > FONT_TEX_SIZE) {
pos.x = 0;
pos.y += FONT_HEIGHT * 1.25;
}
ftex->nextFontPos = pos;
//缓存字符信息
cachedChars[c] = character;
return true;
多字符排版
对于多个字符,就是逐个通过上面的逻辑渲染字符,然后以下因素来确定下一个字符的绘制位置:
- 通过是否换行符、文本组件宽度等确定是否换行;
- 通过文本对齐方式、字符间距、字体大小、行高等,更新下一个字符的绘制位置;
这块细节代码不展开。
容器组件实现
原理
我们日常使用场景,除了图文组件,很大一类就是容器组件:ScrollView、ListView、GridView 等。
而其中最核心的就是 ScrollView,后两个其实本质都是在前者基础上加上了 Cell 复用机制。
而 ScrollView 最核心的点又在于如何实现滑出父容器自动裁剪的效果,滑动本身可以借助监听触摸事件更新滚动位置实现。
而这个裁剪效果,就要用到模板(Stencil)机制:
- Stencil Buffer:每个像素附带一个整数(0~255),用于存储“标记”;
- Stencil Test:对比标记和参考值,检查是否满足条件:满足则写入,否则丢弃;
- Stencil Op:在测试通过/失败时,修改模板值(如递增、清零等)。
相对于深度测试严格根据几何遮挡关系(z-order)决定像素是否丢弃,模板测试提供了一种更灵活和精确的控制机制。
它不仅能自定义判断逻辑决定是否丢弃像素,还能修改 buffer 值,进而实现边界增强等效果。
实现
上面对模板测试机制有了介绍,那具体如何用于实现 ScrollView 呢?
其实说到底就一句话:根据逻辑上的组件嵌套层级关系,调整模板测试参数,动态决定是否丢弃像素。
首先需要开启模板测试:
glEnable(GL_STENCIL_TEST);//开启模板测试
glClearStencil(0);//清除模板缓冲区,将所有模板值设为 0
glStencilMask(0xFF);//开启模板测试,同时设置模板掩码为 0xFF,即全 1,允许所有模板值写入
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);//清除模板缓冲区
处理不同组件层级的模板测试:
constexpr GLuint Mask = 0xFF;//Mask 值,用于模板测试,全 1 允许所有模板值写入
static GLuint nextValidStencil = 1;//下一个可用的 stencil 值,初始为 1,因为 0 表示不启用模板测试
GLenum passFunc = GL_KEEP;//默认 passFun:如果模板测试通过,保持当前模板值不变
GLenum compFunc = GL_ALWAYS;//默认 compareFunc:总是通过模板测试
GLuint ref = 0;//ref 值,用于后续模板测试,0 表示不参考模板值
//自己本身是容器,并且父组件也是容器,即多级嵌套裁剪场景:
if (useAsMaskParent && maskedByParent) {
passFunc = GL_REPLACE;//如果模板测试通过,将当前模板值替换为 ref 值
compFunc = GL_EQUAL;//只有当模板值等于 ref 值时,才通过模板测试
ref = nextValidStencil;
nextValidStencil++;//递增 stencil 值,用于后续子组件的模板测试
} else
//只有自己本身是容器:
if (useAsMaskParent) {
passFunc = GL_REPLACE;//如果模板测试通过,将当前模板值替换为 ref 值
compFunc = GL_ALWAYS;//总是通过模板测试,不参考模板值
ref = nextValidStencil;
nextValidStencil++;//递增 stencil 值,用于后续子组件的模板测试
} else
//只有父组件是容器:
if (maskedByParent) {
passFunc = GL_KEEP;//如果模板测试通过,保持当前模板值不变
compFunc = GL_EQUAL;//只有当模板值等于 ref 值时,才通过模板测试
ref = nextValidStencil - 1;//参考父组件的 stencil 值
}
//应用模板测试和操作
glStencilFunc(compFunc, ref, Mask);
glStencilOp(GL_KEEP, GL_KEEP, passFunc);
总结下就是:
- 只要自己是容器,
passFunc就是GL_REPLACE,并递增stencil值,用于后续子组件的模板测试; - 只要父组件是容器,
compFunc就是GL_EQUAL,就要参考父组件的stencil值;
注意,每轮渲染循环之前,
nextValidStencil都要重置为1。
布局与动画
矩阵变换
布局本质就是根据各种规则确定组件的位置和大小,而动画则是在此基础上增加了旋转变换,各种变换本质就是矩阵乘法运算得到 Model 矩阵,最终渲染时乘以 Viewport 矩阵和 Projection 矩阵,得到最终的屏幕坐标。
这部分其实之前的 Graphics-Snippets 项目已经涉及过,不过之前是采用系统原生 API 实现的矩阵运算,对于跨平台,我们采用了 glm 库。
它不仅提供了各种矩阵数据结构封装(重载了运算符),还提供了各种矩阵运算函数(如平移、旋转、缩放等),非常方便:
localMat = glm::translate(localMat, vec3(rectTransform.localPosition, 0.0f));
localMat = glm::rotate(localMat, radians(rectTransform.localRotation), vec3(0, 0, 1));
localMat = glm::scale(localMat, vec3(rectTransform.localScale, 1.0f));
动画插值器
这里主要是基于各种坐标变换的位移/旋转/缩放的动画及透明度动画。
说到动画,其实最核心的就是插值器:根据当前时间和动画时长,计算出当前动画值。
首先定义一个通用的通过时间计算动画值的函数:
float interpolate(float start, float end, float t) {
return start + (end - start) * t;
}
定义插值器类型枚举:
enum class InterpolationType {
Linear,
EaseIn,
EaseOut,
EaseInOut,
SineIn,
SineOut,
SineInOut
};
根据不同插值器类型,对时间做变换:
float applyInterpolationType(float t) {
static const auto PI = glm::pi<float>();
switch (interpolationType) {
case InterpolationType::Linear:
return t;
case InterpolationType::EaseIn:
return t * t;
case InterpolationType::EaseOut:
return 1 - (1 - t) * (1 - t);
case InterpolationType::EaseInOut:
return t < 0.5f ? 2 * t * t : 1 - std::pow(-2 * t + 2, 2) / 2;
case InterpolationType::SineIn:
return 1 - std::cos(t * PI / 2);
case InterpolationType::SineOut:
return std::sin(t * PI / 2);
case InterpolationType::SineInOut:
return -0.5f * (std::cos(PI * t) - 1);
default:
return t;
}
}
这里仅实现了几种常见的动画插值方式,其他的这里就不再展开。
UI 事件处理
分发
这块其实可以参考 Android/iOS 原生的 UI 事件分发机制:
- 首先判断组件是否可见、是否可点击;
- 判断点击事件是否在组件的区域内;
- 如果在,倒序遍历子组件,递归执行上述逻辑;
- 如果子组件处理了事件,就直接返回;
- 如果没有子组件处理事件,就自己处理事件;
类型细分
- 原生层会透传
isTouchDown,如果是从 Down 变为 Up,二者间隔时间短为点击,否则为长按; - 一直连续是 Down 事件,并且只有某一个单一方向的坐标变化较大,即认为是拖拽事件。
UI 描述及编辑器
UI 描述语言,其实目前主流的除了 XML 就是 JSON,而 JSON 相对 XML 来说,更轻量、更易解析,也更符合 UI 组件的树形结构。
而 UI 编辑器,由于只需要考虑 PC 端,我们直接采用了 imgui 框架,这里不再展开。
性能优化
已运用的
- Shader、纹理、字体字符信息的缓存;
- 确保只有一个线程渲染,外部调用 UI 更新接口时,采用异步通知的机制,不阻塞当前渲染循环;
- 将能在 CPU 端完成的工作,放到异步线程池(图片解码、字体加载等);
- 纹理采用 PBO 异步上传 GPU,具体详见我之前的文章。
待跟进的
使用图集
合并所有材质到一张纹理,减少纹理切换次数。
首先可以使用 TexturePacker 等工具,将所有材质合并到一张大材质,并生成包含坐标信息的 JSON 或二进制配置。
后面绘制逻辑就没什么高深的:复用同一纹理,动态调整纹理坐标数组(九宫格图也是通过调整纹理坐标偏移实现的)。
批量绘制
例如相同材质的按钮,先多次调用 glBufferSubData 上传到同一 VBO,然后一次性调用 glDrawElements 绘制。
关于这两点,后续专门写文章介绍。
其他坑
除开性能问题,遇到最大的坑就是跟 Unity 的兼容问题:iOS 版本的字体渲染逻辑会 crash,但 Android 正常。
为何会这样呢?
- FreeType 内部各个组件是动态注册的函数指针(符号被干掉并不影响编译,但运行会崩);
- 安卓是动态库,每个 so 有自己独立的符号表,不受其他模块影响;而 iOS 使用静态库,模块间会合并符号产生冲突;
- Unity 内部也集成了 FreeType,IL2CPP 处理后的 UnityFramework 只会保留自己
UNITY_开头的 FreeType 相关符号。
最终的解决方案是:重命名所有 FreeType 符号,添加 UNITY_ 前缀(共用 Unity 内部的符号)。