TGFX - 腾讯开源的轻量级 2D 渲染引擎

TGFX - 腾讯开源的轻量级 2D 渲染引擎

  • 首页
  • 下载
  • 文档
  • 案例
  • CN
  • GitHub
  • 论坛交流
  • Languages iconEN
    • CN

›架构设计

快速开始

  • TGFX 简介
  • 平台与后端支持
  • 环境准备与编译
  • Hello2D 示例

API 参考与概述

    绘图基础

    • Canvas Overview
    • Paint Overview
    • Path Overview
    • BlendMode Overview
    • Picture 录制与回放

    几何与变换

    • 几何与变换

    图像与像素

    • Image
    • Bitmap 与像素操作
    • 图像编解码
    • 视频与外部纹理

    文本渲染

    • 文本与字体

    着色与效果

    • 着色与效果

    图层系统

    • 图层系统

    进阶主题

    • 自定义 Shader
    • 色彩管理

架构设计

  • 渲染管线
  • GPU 硬件抽象层
  • 图层渲染系统
  • 缓存系统
  • 文字图集渲染
  • GPU Hairline 极细描边
  • 广色域渲染
  • SIMD 加速

API 文档

  • API 文档

渲染管线

一、从一次 draw 调用说起

你调用 canvas->drawRect(...),这行代码执行完,GPU 上发生了什么?

答案是:什么都没有发生。这行代码只是把一条绘制意图记录进了队列——真正的 GPU 工作要等到 context->flush() 才开始。

这不是偷懒,而是 TGFX 渲染管线的核心设计原则:延迟执行、批量提交。理解了这一点,整条管线的每个环节就都有了合理的解释。


二、整体架构

TGFX 的代码分四层,职责清晰,层与层之间边界明确:

四层架构图

include/ 目录是对外的全部 API,其下的 src/ 是实现细节,外部代码不应直接依赖 src/ 里的任何东西。

各模块职责:

模块职责
core/几何、图像、字体、编解码的平台无关逻辑
gpu/GPU 资源管理、Op 调度、Shader Program、Backend 适配
layers/图层树、DisplayList、脏区、Tile 渲染
svg/SVG 解析与渲染
pdf/PDF 导出

三、延迟渲染:为什么不立即执行

每次 draw*() 立刻提交 GPU,会有什么问题?

立即执行意味着每次调用产生一次 Draw Call。Draw Call 是 CPU 与 GPU 之间的通信——发出命令、等待 GPU 接收,每次通信都有固定开销,跟画的内容大小无关。如果每个 drawRect 都产生一次 Draw Call,画 200 个矩形就是 200 次通信,而实际上这 200 个矩形可以合并成一次提交。

延迟执行的价值就在这里:攒够了再打,一次打完。

canvas->draw*() 的每次调用会生成一个 DrawOp,积累在 OpsCompositor 里。flush() 时统一处理:

canvas->drawRect(...)      →  生成 RectDrawOp,入队
canvas->drawPath(...)      →  生成 ShapeDrawOp,入队
canvas->drawTextBlob(...)  →  生成 AtlasTextOp,入队

context->flush()
  └→ DrawingManager::flush()
       ├─ OpsCompositor::makeClosed()
       │    ├─ 分析队列,合并相邻同类 Op
       │    └─ submitDrawOps() → 生成 RenderTask
       └─ 返回 Recording(轻量快照,不执行 GPU 工作)

context->submit(recording)
  └→ DrawingBuffer::encode()
       ├─ Proxy 实例化(延迟到此刻)
       ├─ 生成顶点缓冲
       ├─ 查找 / 编译 GPU Program
       └─ 提交 Draw Call → GPU

延迟渲染流程


四、Op 合并:减少 Draw Call 的核心

Draw Call 多了 GPU 吞吐跟不上,CPU 侧的提交开销也不可忽视。相邻的同类型 Op,如果 Paint 参数相同,就可以合并为一次 Draw Call——把多个矩形的顶点数据打包进同一个缓冲区,一次提交全部画完。

OpsCompositor 分析 Op 队列,把满足条件的相邻 Op 合并:

Op 类型合并条件
RectDrawOp相同 Paint(Shader / BlendMode / ColorFilter)
RRectDrawOp相同圆角参数和 Paint
AtlasTextOp来自同一纹理图集,相同 BlendMode
ShapeDrawOp相同填充规则和 Paint
MeshDrawOp相同纹理和 BlendMode

有一点需要注意:合并只能在相邻的同类 Op 之间发生。中间插了一个不同类型的 Op,就会打断合并:

[红色矩形A]  [红色矩形B]  [蓝色圆形C]  [红色矩形D]

合并结果:
  批次1: A+B(合并)  →  1 次 Draw Call
  批次2: C           →  1 次 Draw Call
  批次3: D           →  1 次 Draw Call

D 哪怕跟 A 参数一样,也无法跨越 C 合并。打断合并的常见原因:不同类型的 Op 穿插、BlendMode 切换、触发 saveLayer 的 ImageFilter。

Op 合并过程


五、Proxy:资源按需分配

什么时候把图像上传到 GPU 最合理?调用 Image::MakeFromFile 时立刻上传?

不合理。应用层可能创建大量 Image 对象,但大部分在当前帧根本不会被渲染。提前上传浪费显存,上传本身也有 I/O 和编解码开销。

GPU 资源的分配也是延迟的。Image::MakeFromFile 只创建一个 TextureProxy——一个占位符,记录「需要一张来自这个文件的纹理」的意图,不做任何实际工作。等到 OpsCompositor 真正需要这张纹理时,ProxyProvider 才触发解码和上传:

Image::MakeFromFile("photo.png")
  ↓
TextureProxy 创建(仅意图,无 GPU 内存)

...可能多帧不渲染这张图片...

canvas->drawImage(image)
  ↓
OpsCompositor 需要这张纹理
  ↓
ProxyProvider 实例化:解码 → 上传 GPU
  ↓
Texture(实际 GPU 内存分配)

Proxy 延迟实例化

三种 Proxy:

Proxy对应 GPU 资源
TextureProxyGPU 2D 纹理
RenderTargetProxy可写入的渲染目标纹理
GPUBufferProxy顶点 / 索引缓冲区

六、Recording:指令录制与异步提交

如果 flush() 同步等 GPU 执行完,主线程在这段时间什么都干不了。

解决方法是把"生成指令"和"执行指令"分开:flush() 生成一个 Recording 快照——将当前帧的所有渲染任务打包为一个轻量级的 ID 快照(关联到 DrawingBuffer),但不执行任何 GPU 工作。submit() 才真正执行 GPU 指令——在此阶段完成 Proxy 实例化、顶点上传、Program 编译和 Draw Call 提交。

主线程完成 flush 后立刻开始录制下一帧,不等 GPU 执行完。双缓冲或三缓冲模式下,CPU 和 GPU 可以完全流水线并行。

异步执行时序


七、路径渲染与三角化

GPU 不能直接理解矢量路径(贝塞尔曲线、任意多边形),只能处理三角形。面对这个约束,TGFX 根据路径类型和场景选择不同策略,而不是一刀切:

策略适用场景核心方式
直接三角扇矩形、椭圆等凸多边形专用顶点生成,绕过通用三角化
Coverage AA复杂曲线路径GPU 计算亚像素覆盖率,抗锯齿
MSAA简单多边形硬件多重采样
Hairline≤1px 极细线GPU Shader 直接计算每像素覆盖率

四种路径渲染策略

矩形和圆角矩形是最高频的绘制对象,专用的顶点生成器完全绕过路径三角化,渲染开销接近于直接画一个四边形。


八、两种思路:Skia 通用化 vs TGFX 专注 GPU

TGFX 的目标很清晰:只跑在有 GPU 的场合。移动端和桌面端用真实的 GPU,服务端就用 SwiftShader 软件模拟。Skia 不一样,它要应付各种环境——没 GPU 的服务端、嵌入式设备,甚至纯 CPU 渲染也支持(SkBitmapDevice)。

因为定位不同,两个库在五个关键维度上走了完全不同的路。

渲染路径:双路径 vs 单路径

Skia 保留了完整的 CPU 软件渲染路径(raster backend),每个功能都要准备两套实现、两套测试。GPU 路径中还有 SoftwarePathRenderer 在 GPU 不支持时降级到 CPU。缓存层因此偏保守——数据可能要从 GPU 回到 CPU,不敢长期驻留。

TGFX 没这个包袱,所有中间结果直接留在 GPU 端,缓存策略更激进。资源默认常驻 GPU 直到超过过期帧数(默认 120 帧),不像 Skia 那样用 budgeted/non-budgeted 分层精细管理。

渲染线程:同步 vs 异步流水线

Skia 的 GrDirectContext::flush() 在调用时就完成指令编码和提交——flush 和 submit 绑定在同一次调用中。更关键的是,Skia 在 Debug 模式下用 ASSERT_SINGLE_OWNER 宏强制检查 flush/submit 必须在创建 Context 的线程上调用,不支持跨线程的录制-提交分离。

TGFX 把这两步拆开了:

  • flush() 只生成一个轻量级的 Recording 快照(16 字节,仅包含 contextID、drawingBufferID、generation),立即返回
  • submit(recording) 在任意线程执行真正的编码——Proxy 实例化、顶点上传、Program 编译、Draw Call 提交

这使得 CPU 和 GPU 可以在不同线程上全流水线并行工作:主线程 flush 完立刻开始下一帧的录制,不需要等 GPU 执行完。

Uniform 数据:逐个传 vs UBO 缓冲

在 OpenGL 里传 Uniform 数据给 Shader 有两种方式:glUniform* 逐个传,或者用 UBO(Uniform Buffer Object)批量写入。

Skia GL 后端只使用 glUniform* 系列函数(glUniform1f、glUniformMatrix4fv 等),逐个传递。这规避了 UBO 的 CPU-GPU 同步风险,但每次调用都有开销。

TGFX 使用 UBO + 多重缓冲:多个 slot 轮流使用,CPU 写一个 slot 时 GPU 读另一个 slot,互不干扰。SlidingWindowTracker 追踪最近帧的 Buffer 用量,自动收缩过大的 Buffer,避免显存浪费。

跨线程资源释放:互斥锁 vs 无锁队列

非渲染线程(图片解码、动画回调等)释放 GPU 资源时,需要线程安全的回收机制。

Skia 通过 SkMessageBus 传递资源释放消息,内部用 SkMutex 互斥锁保护消息队列。高频场景下,锁竞争会阻塞渲染线程。

TGFX 用 ReturnQueue 封装了 moodycamel::ConcurrentQueue(业界公认的高性能无锁并发队列)。资源引用计数归零时,自定义删除器自动将节点入队,全程无锁。渲染线程在 flush 开头批量出队回收。

超期资源:直接删除 vs 降级保留

Skia 对超预算的资源采取直接删除策略——从 purgeable 队列中取出并释放,不做降级。

TGFX 更温和:当超期资源仍有外部引用且自身携带 ScratchKey 时,只移除 UniqueKey 绑定(降级为 scratch 资源),但保留在缓存中等待复用。只有完全无引用无键的资源才被真正删除。这避免了”刚释放完下一秒又要重建”的循环,在频繁创建销毁中等大小纹理的场景(动画、列表滚动)下显存利用率更高。

一张表总结差异

对比点SkiaTGFX效果
渲染路径CPU + GPU 双路径GPU‑only 单路径缓存更激进,CPU↔GPU 传输减少
渲染线程flush 编码+提交一体化,强制单线程flush → Recording → submit,可跨线程CPU/GPU 全流水线并行
UniformglUniform* 逐个传UBO 多重缓冲每帧省去 Buffer 分配开销
资源回收SkMutex 互斥锁无锁队列 ReturnQueue高频渲染不阻塞
超期资源直接删除降级为 ScratchKey 保留复用减少重复分配

一句话总结:Skia 因为要支持没 GPU 的环境,设计上偏保守;TGFX 只跑在 GPU 上,可以一路激进。


完整流程时序图

应用层                 Core 层                GPU 层              硬件
  │                      │                      │                   │
  │ canvas->draw*(...)   │                      │                   │
  │─────────────────────>│                      │                   │
  │                      │ 生成 DrawOp           │                   │
  │                      │─────────────────────>│                   │
  │                      │                      │ Op 入队 OpsCompositor
  │ context->flush()     │                      │                   │
  │─────────────────────>│                      │                   │
  │                      │  DrawingManager::     │                   │
  │                      │  flush()             │                   │
  │                      │─────────────────────>│                   │
  │                      │                      │ Op 合并/批处理     │
  │                      │                      │ 生成 RenderTask    │
  │  Recording 快照       │                      │                   │
  │<─────────────────────│                      │                   │
  │                      │                      │                   │
  │ context->submit(rec) │                      │                   │
  │─────────────────────────────────────────────>│                  │
  │  (异步返回)           │                      │ Proxy 实例化      │
  │                      │                      │ VtxBuffer 上传────>│
  │                      │                      │ Program 编译/复用─>│
  │                      │                      │ Submit DrawCall──>│ GPU执行
← 色彩管理GPU 硬件抽象层 →
  • 一、从一次 draw 调用说起
  • 二、整体架构
  • 三、延迟渲染:为什么不立即执行
  • 四、Op 合并:减少 Draw Call 的核心
  • 五、Proxy:资源按需分配
  • 六、Recording:指令录制与异步提交
  • 七、路径渲染与三角化
  • 八、两种思路:Skia 通用化 vs TGFX 专注 GPU
    • 渲染路径:双路径 vs 单路径
    • 渲染线程:同步 vs 异步流水线
    • Uniform 数据:逐个传 vs UBO 缓冲
    • 跨线程资源释放:互斥锁 vs 无锁队列
    • 超期资源:直接删除 vs 降级保留
    • 一张表总结差异
  • 完整流程时序图
公司地址:广东省深圳市南山区海天二路33号腾讯滨海大厦Copyright © 2018 - 2026 Tencent. All Rights Reserved.联系电话:0755-86013388隐私政策