手搓跨平台 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_BYTEGL_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 内部的符号)。