GPU加速2D图形渲染原理 - 图形API、纹理与GPU

1,275 阅读34分钟

GPU加速2D图形渲染原理 - 图形API、纹理与GPU

本篇作为 Shaft 框架 博客系列的第二篇,将简单介绍 GPU 加速 2D 图形渲染的相关概念,包括图形 API、纹理和 GPU 等,以及如何在 Shaft 中使用这些底层能力来实现炫酷的图形效果,与大幅优化渲染性能。

browser_bilibili.gif

(一) Shaft: 为极致性能与开发体验而生的跨平台UI框架

(二) GPU加速2D图形渲染原理 - 图形API、纹理与GPU (本文)

显卡

早期的图形 UI 绘制是由 CPU 完成的,但是随着图形效果越来越复杂,CPU 的计算能力已经无法满足现代图形 UI 的需求。同时移动设备的普及也使得能耗成为了一个重要的考虑因素,而 GPU 作为专门用于图形计算的硬件,其并行计算能力和低功耗特性使得其成为了现代图形 UI 渲染的首选。

WWDC14-419-uiblur.png

目前所有主流的平台均使用 GPU 来加速 UI 的绘制,可以说现代的图形 UI 渲染离不开 GPU。但是说到 GPU,着色器、纹理、帧缓冲、渲染管线、VSync、DirectX、OpenGL、Vulkan、Metal 等等这些层出不穷的概念难免让人头大。

为了更好的理解这些概念的来龙去脉,这里让我们稍稍稍稍微回顾一下显卡的发展历史:

CRT显示器

CRT显示器,于1922年出现,俗称大脑袋,是最早的显示器类型。CRT显示器通过电子枪发射电子束,通过磁场控制电子束的位置,从而在荧光屏上绘制图形:

crt.gif

与现代的HDMI、DisplayPort等数字接口相比,CRT 显示器采用了更为简单的模拟信号传输方式。要在CRT显示器上显示图像,只需按照显示器的扫描时序,将 RGB 电压信号输入到对应的引脚即可。

这里以常用的 VGA 接口为例,VGA接口有15个引脚,其中最主要的是 Red、Green、Blue、HSync、VSync 这五个引脚:

VGA-Pinout-features.png

RGB(红、绿、蓝)是显示器中最基础的三原色。通过精确控制 VGA 接口上的 R、G、B 三个引脚的电压值,我们可以调节电子束的发射强度,从而在荧光屏上产生不同亮度和颜色的像素点。

以显示纯红色(#FF0000)为例:我们需要将 R 引脚的电压设为最大值 0.7V 以获得最亮的红色,同时将 G 和 B 引脚的电压设为 0V 以完全关闭绿色和蓝色通道。这样的电压组合就能在屏幕上呈现出的红色。

水平同步(HSync)垂直同步(VSync) 信号则是控制 CRT 显示器刷新的两个时序信号。

以 640x480@60Hz 分辨率为例:

  • 垂直同步(VSync):控制电子束从屏幕顶部到底部的扫描周期。每隔16.67ms(1/60秒)向垂直同步引脚发送一个脉冲信号,可以控制显示器以60Hz的频率进行垂直扫描,完成一帧画面的显示。

  • 水平同步(HSync):控制电子束从屏幕左侧到右侧的扫描周期。考虑到垂直消隐期的45行,每行扫描周期为0.03177ms (16.67ms/(480+45)),以这个周期向水平同步引脚发送脉冲信号,CRT显示器就能以我们期望的速度进行水平摆动。

这两个同步信号的精确配合,使得CRT显示器能够稳定地显示完整的画面。 现在我们知道了电子束的水平扫描周期,就可以进一步计算每个像素点的显示时间。在640x480@60Hz的分辨率下,每个像素点的实际显示时间为: 1 / 60Hz / (480 + 45行) / (640 + 160像素) = 0.00000004ms。

水平消隐(+45行)和垂直消隐(+160像素)这里篇幅限制不过多介绍,有兴趣推荐阅读这篇文章

考虑到在编程相关的文章里写电路的内容还是过于阴间了,这里我们还是来点伪代码简单描述一下向 CRT 显示器输出图形的过程:

import { pin1, pin2, pin3, pin13, pin14, sleep } from 'hardware';

// 640x480的一帧图像
var framebuffer = List.filled(640 * 480, Color(0, 0, 0));

// 在其中绘制一个100x100的矩形
for y in 0..100 {
    for x in 0..100 {
        var index = y * 640 + x
        framebuffer[index] = Color(255, 0, 0); // #FF0000
    }
}

// 输出到显示器
for y in 0..480 {
    for x in 0..640 {
        var index = (y * 640 + x)
        var color = framebuffer[index];

        pin1.value = color.r / 255; // R
        pin2.value = color.g / 255; // G
        pin3.value = color.b / 255; // B

        sleep(0.00000004ms); // 等待电子束移动到下一个像素的位置
    }

    pin13.value = 1; // HSync
    pin13.value = 0;
}

pin14.value = 1; // VSync
pin14.value = 0;

执行这种逐像素输出的逻辑对现代计算机来说轻而易举,但在20世纪80年代的计算机硬件条件下却面临挑战。当时的CPU计算能力有限,且在单任务操作系统中需要持续不断地输出显示信号,这就占用了大量的计算资源。为了解决这个问题,开发专门的硬件来处理显示器的信号输出的方案便出现了。

在这种架构下,CPU 只需将渲染好的图像数据写入显存,专用的显示硬件就会自动从显存读取数据,并转换为精确的模拟信号输出到显示器。这一改变不仅可以使 CPU 摆脱信号输出任务,显示硬件内部专用时钟电路还可以实现更稳定的显示时序控制。这种专用的的显示硬件就是最早的显卡。

8BitGraphicBoard.png

这里用伪代码描述一下这种显卡的使用方式:

import { vram } from 'hardware';

// 640x480的一帧图像
var framebuffer = List.filled(640 * 480, Color(0, 0, 0));

// 在其中绘制一个100x100的矩形
for y in 0..100 {
    for x in 0..100 {
        var index = y * 640 + x
        framebuffer[index] = Color(255, 0, 0); // #FF0000
    }
}

// 输出到显卡
memcpy(framebuffer, vram);

可以看到用这种方式,CPU 只需要将图像数据拷贝到显存中即可,而不需要再关心具体的显示器类型,也不需要全程不间断的负责信号的输出。

2D加速绘图

CPU 负责绘图,拷贝到显卡进行输出的模式解决了最初的图形显示问题,但是随着 MacOS 与 Windows 等图形操作系统的出现,GUI 的复杂度也在不断提升,CPU 需要耗费越来越多的时间来处理图形绘制。帧率和分辨率的提升导致需要拷贝的数据量也在不断增加,使得总线带宽也成为了瓶颈。

在这个背景下,支持 2D 加速绘图的显卡应运而生。在之前的基础上,这类显卡在硬件层面内置了对 2D 绘图的支持,例如绘制直线、矩形等。

Mach8isa.jpg

使用这类显卡,CPU 只需要向显卡发送绘图指令。伪代码描述如下:

import { registers, wait_for_idle } from 'hardware';

// 绘制一个100x100的矩形
registers.color = 0xFF0000
registers.x = 0
registers.y = 0
registers.width = 100
registers.height = 100
registers.command = "DRAW_RECT"
wait_for_idle()

这种硬件加速方式同时解决了上述的两个问题:首先,CPU 只需发送简单的绘图指令给显卡,由显卡的专用硬件电路完成实际的像素渲染,避免了 CPU 逐像素计算的开销。其次,显卡直接将绘制结果写入显存,消除了 CPU 和显存之间的大量数据拷贝,提升了整体的图形渲染效率。

这种架构为后来的现代 GPU 奠定了基础。

3D加速绘图

在20世纪90年代,3D 游戏开始兴起。与 2D 图像绘制面临的问题类似,3D 图形的绘制也需要大量的计算与数据拷贝,同样 3D 绘图的解决方案也与 2D 绘图类似,即引入专门的硬件来处理 3D 图形的绘制,将矩阵运算、光照计算、纹理映射等复杂的计算任务交给显卡来完成。

与 2D 图形使用的直线、多边形等元素绘制不同,3D 绘图使用一系列三维空间中的点来描述物体的形状。通过对这些点进行变换与投影,来得到最终显示在屏幕上的平面图像。

graphicspipeline.webp

这里我们同样用伪代码描述一下 3D 绘图的过程:

import { vram, registers, wait_for_idle } from 'hardware';

// 使用顶点来描述一个100x100的矩形
var vertices = [
    Point3D(0.0, 0.0, 0.0),      // 左下
    Point3D(100.0, 0.0, 0.0),    // 右下
    Point3D(100.0, 100.0, 0.0),  // 右上
    Point3D(0.0, 100.0, 0.0),    // 左上
];

// 上传顶点数据到显存
memcpy(vertices, vram[100]);

// 设置渲染参数
registers.vertex_buffer = vram[100]
registers.vertex_count = 4

// 通知 GPU 开始绘制
registers.command = "DRAW_TRIANGLE_STRIP"

// 等待GPU完成渲染
wait_for_idle()

可以看出 3D 绘图的流程与 2D 绘图类似,只需要上传少量数据到显卡,由显卡的专用硬件电路完成实际的像素渲染并写入显存。不同之处在于 2D 绘图时我们告诉显卡在某个位置绘制一个矩形,而 3D 绘图时我们用一些三维空间中的点来描述物体的形状,由显卡根据我们的配置将这些点进行映射,转换为屏幕上的像素。

这种基于顶点数据和渲染管线的架构一直延续至今,并不断发展完善。如今的显卡在此基础上增加了光追单元,通用计算单元等模块,但核心逻辑仍是显卡根据绘图指令进行图像渲染并写入显存。

图形API

在了解了现代显卡的工作流程,以及为什么这样工作之后,我们来看看如何实际使用这些硬件来绘制图形。在接下来这部分,我们将逐个介绍 OpenGLVulkanMetalDirect3D 这些主流的图形 API,以及基于这些 API 的 WebGLWebGPU

就如同 Unix 接口为操作系统提供了标准的系统调用,图形 API 为开发者提供了统一的图形绘制接口。这些 API 抽象了底层硬件细节,使得开发者可以编写跨平台、跨硬件的图形应用,而无需关心具体的显卡型号和驱动实现。通过这种抽象层,同一套代码可以在不同的硬件和操作系统上高效运行。

OpenGL

OpenGL 是最早的跨平台图形 API 之一,由 SGI 公司于1992年发布。尽管出现的时间比大多数操作系统都要早,但目前 OpenGL 仍然是最流行的图形 API 之一。所有主流桌面与移动平台都对 OpenGL 有较好的支持,包括 Windows、MacOS、Linux、iOS、Android 等。下边是一段使用 OpenGL 绘制一个红色矩形的代码:

#include <GL/gl.h>

void draw() {
    glClear(GL_COLOR_BUFFER_BIT);
    glColor3f(1.0, 0.0, 0.0); // 设置颜色为红色
    glBegin(GL_POLYGON);
    glVertex2f(-0.5, -0.5);   // 左下
    glVertex2f(0.5, -0.5);    // 右下
    glVertex2f(0.5, 0.5);     // 右上
    glVertex2f(-0.5, 0.5);    // 左上
    glEnd();
    glFlush();                // 发起绘制
}

...

可以看到,尽管函数名有很大的差异,但 OpenGL 的绘图流程与之前的伪代码非常相似。开发者通过调用 OpenGL 提供的函数来上传顶点数据、设置渲染参数、通知 GPU 开始绘制等。受限于篇幅限制,这里不再详细介绍 OpenGL 的具体使用方法,有兴趣的读者可以参考 learnopengl.com/Introductio…

尽管 OpenGL 如今仍然是一个非常流行的图形 API,但其设计已经相当古老,难以满足更高的性能需求。为了解决这个问题,Khronos Group 发布了新的图形 API: Vulkan。

Vulkan

Vulkan 是由 Khronos Group 于2016年发布的新一代图形 API,旨在取代 OpenGL。与 OpenGL 不同,Vulkan 的设计更加贴近现代显卡的硬件架构,使得开发者可以更加充分地利用显卡的算力。

同时也因为 Vulkan 这种更加贴近底层的设计思路,使得 Vulkan 的使用远复杂于 OpenGL。绘制一个红色矩形的代码将动辄二三百行,这里不再赘述。有兴趣的读者可以参考 vulkan-tutorial.com/

与几乎全平台通用的 OpenGL 不同,目前主流平台中差不多只有安卓在力推 Vulkan,其他平台的支持度相对较低。在开发安卓 App 时,使用 Vulkan 通常可以获得更好的性能表现。但是如今仍有 10% 以上的安卓设备在硬件层面上不支持 Vulkan,所以在实际开发中为了兼容性仍有大量的开发者选择使用 OpenGL ES。

android_vulkan.png

Metal 与 Direct3D

Metal 与 Direct3D 分别是苹果和微软平台的专有图形 API。与 Vulkan 相同,Metal 与新版本的 Direct3D 也是为了更好地利用现代显卡的硬件特性而设计的。如果针对这两个平台进行开发,而不考虑跨平台性,那么使用 Metal 或 Direct3D 会是更好的选择。

WebGL 与 WebGPU

简单的说,WebGL 版的 OpenGL ES;而 WebGPU 就是版的 Vulkan。在浏览器中我们可以通过 JS 调用 WebGL 或者 WebGPU 实现基于 GPU 的高性能渲染,来实现更加复杂的视觉效果。

webgpu.png

下边是使用 WebGL 绘制红色矩形的代码:

const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
const gl = canvas.getContext('webgl');
gl.clearColor(1, 0, 0, 1);  // red
gl.clear(gl.COLOR_BUFFER_BIT);

可以看到,WebGL 的使用方式与 OpenGL 非常相似,只是 API 名称略有不同。同样地 WebGPU 的使用方式与 Vulkan 类似,这里不再赘述。

渲染管线与着色器

在了解了图形 API 之后,我们来看看 GPU 是如何将 3D 模型的顶点数据转换为屏幕上的像素的。这里我们会了解渲染管线光栅化着色器的概念。

与 2D 绘图时drawLine(...)drawRect(...)这种简单的调用不同,3D 模型的顶点数据要经过多个步骤才能最终显示在屏幕上。这些步骤中每一步的结果都会作为下一步的输入,因此这些步骤合在一起就被称为渲染管线(Render Pipeline):

graphics-pipeline.png

一个最简单的渲染管线如上图:

  1. 如上文所述,首先我们会将顶点数据上传到显存中,这些顶点数据描述了物体的形状。

    var vertices = [1, 1, 1, -1, -1, -1, -1, 1, 1 ...];
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
    
  2. 随着我们观察角度的变化,3D 对象映射到屏幕上的位置也会发生变化。

    想要计算出视角变化后的顶点位置,我们需要使用矩阵运算对 3D 对象的每个点的位置都进行一次变换,得到这些点在当前视角下的绝对坐标。

    3d-rotation.gif 复杂的 3D 模型可能有数以万计的顶点,因此多数情况下这个变换也在 GPU 中完成。 现代的 GPU 都允许开发者自己编写程序来自定义顶点的处理逻辑。这个程序使用的语言称为着色器语言(Shader Language),而这个程序就是我们常说的着色器(Shader)

    var shader = ```
        attribute vec3 position;
        uniform mat4 view;
        void main() {
            gl_Position = view * vec4(position, 1.0);
        }
    ```
    
    var vertexShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vertexShader, shader);
    gl.compileShader(vertexShader);
    
    ...
    

    上方的着色器代码描述了一个简单的顶点着色器,它接受一个顶点的位置,然后将这个顶点的位置变换到屏幕空间。

  3. 当顶点的位置变换完成后,我们就得到了这些点在屏幕上的坐标,但是仅仅知道这些顶点的位置显然是不够的,我们需要知道这些顶点连接成的三角形都覆盖了屏幕上的哪些像素,之后才能够在这些像素上填充颜色。这个过程就是所谓的光栅化(Rasterization)

    rasterization.gif

  4. 现在我们知道了每个三角形覆盖了屏幕上的哪些像素,接下来就该为这些像素上色了。这个过程 GPU 也允许开发者自己编写程序来控制整个过程,这个程序就是片段着色器(Fragment Shader)。如下是一个简单的片段着色器:

    var shader = ```
        void main() {
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
        }
    ```
    
    var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fragmentShader, shader);
    gl.compileShader(fragmentShader);
    
    ...
    

    这段着色器代码的逻辑非常简单。无论这个像素的位置如何,它都会被填充为红色。

初次接触着色器的读者可能会有疑问:我明明绘制了多个三角形,为什么每个着色器程序看起来都是在对单个顶点或者像素进行操作?

这是因为与 CPU 的少量高性能核心不同,GPU内部包含成百上千个专用处理单元,每个单元负责执行相同的着色器程序,但处理不同的数据。

可以用如下的伪代码来描述 GPU 内部的工作流程:

var vertices = [1, 1, 1, -1, -1, -1, -1, 1, 1 ...];
var transformedVertex = []
for index, vertex in vertices {
    // 多个核心并行执行
    gpuCore.run(() => {
        transformedVertex[index] = vertexShader(vertex)
    })
}

var output = []
for triangle in transformedVertex {
    for pixels in rasterization(triangle) {
        for pixel in pixels {
            // 多个核心并行执行
            gpuCore.run(() => {
                output[pixel] = fragmentShader(pixel)
            })
        }
    }
}

这种编程模式也揭示了 CPU 与 GPU 在图形渲染上的根本差异:CPU 采用串行处理方式,我们在 for 循环里按顺序逐个计算每个像素的颜色;而 GPU 则利用其并行架构,同时对成百上千个像素进行计算。这种并行处理能力使得 GPU 在图形渲染任务上具有显著的性能优势。

gpu-cores.jpeg

纹理

在上一节的最后我们使用了一个简单的片段着色器,将所有的像素都填充为红色。但是在实际的图形渲染中,我们通常会使用图片作为 3D 对象的表面,这就需要用到纹理(Texture),也就是我们常说的贴图

texture-map.png

纹理的使用也并不复杂,仍然遵循着上文所述的渲染模型。首先我们需要将图片解码并作为纹理数据上传到显存中,然后在片段着色器中使用纹理数据来填充像素的颜色。

// 下载图片
var image = new Image();
image.src = 'texture.png';

// 将图片上传到显存,得到纹理对象
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);

...

// 在片段着色器中使用纹理
var shader = ```
    uniform sampler2D texture;
    void main() {
        gl_FragColor = texture2D(texture, gl_FragCoord.xy);
    }
```

...

这段代码中,我们首先下载了一张图片,然后使用 texImage2D 方法将图片上传到显存中,得到了一个可用的纹理对象。接着我们像片段着色器传入了这个纹理对象,在着色器中国通过 texture2D 函数从纹理中获取颜色作为返回值填充到像素上。

本文目前为止的内容覆盖了一些基本的 WebGL 知识,但是受篇幅限制,这里只做了一些简要的介绍。感兴趣的读者可以参考 webglfundamentals.org/ 进一步学习。

窗口管理器

使用 OpenGL 或者 Metal 等 API,我们可以调用 GPU 完成图像的绘制,但是想必好奇的读者已经开始好奇了:我们绘制的图形只是显示在了窗口里,并没有直接显示在屏幕上。那么我们绘制的图像从窗口到屏幕之间又经历了怎样的过程?

完成这最后一步组装工作的就是窗口管理器(Window Manager)。窗口管理器按照顺序将每个窗口渲染好的图像合成到屏幕上,同时还负责事件的分发与处理窗口的拖动、缩放、最小化等工作。

与 OpenGL 一样,窗口管理器的历史最早也可以追溯到1980年代。期间窗口管理器的架构也随着显卡的发展一同演进。接下来我们以相对较新且开源的 Wayland 为例,简单介绍一下现代窗口管理器的一般工作流程。

Wayland 是新一代的窗口管理器协议,旨在取代现有的 X11。目前 Ubuntu 等主流 Linux 发行版已经开始逐渐转向 Wayland。更多关于 Wayland 的信息可以参考 en.wikipedia.org/wiki/Waylan…

wayland.svg

窗口管理器这个概念听起来可能比较抽象,但是实际上做的事情非常简单:

  1. 从系统中接收鼠标、键盘等外设的输入,将这些事件分发到当前活动的窗口。
  2. 接收并记录窗口的绘制请求,将每个窗口的图像按顺序绘制在一起,最终输出到屏幕上。

接下来我们以 Wayland 为例,看看窗口管理器是如何实际工作的:

首先窗口管理器本身是系统中一个常规的进程。它通过系统调用来与系统内核进行通信,通过进程间通信(IPC)与其他 GUI 应用程序进行通信。以 Wayland 为例,GUI 应用程序启动时会通过环境变量 WAYLAND_DISPLAY 找到 Wayland 窗口管理器的 Unix socket,然后通过这个 socket 与窗口管理器进行进程间通信:

wayland-0.png

这里我们可以尝试将环境变量WAYLAND_DISPLAY清除,再尝试运行一个 GUI 程序,会发现和我们预期的一样,这个程序可以正常启动,但是无法显示窗口:

no-wayland.png

接下来我们看看窗口管理器是如何与 GUI 程序进行通信的。这里我们借助 wayland-debug 工具,查看窗口管理器 与 GUI 程序之间的通信:

gedit.gif

可以看到,在按下键盘时,窗口管理器向 GUI 程序发送了键盘事件 wl_keyboard.enter

140.9654 A: wl_keyboard@29a.leave(serial=2122, surface=wl_surface@31a) ↲
140.9654 A: wl_keyboard@29a.enter(serial=2123, surface=wl_surface@51df, keys=Unknown: 'array[0]') ↲
140.9654 A: wl_keyboard@29a.modifiers(serial=2123, mods_depressed=0, mods_latched=0, mods_locked=0, group=0) ↲

在窗口上移动鼠标,窗口管理器就会像向 GUI 程序发送鼠标事件 wl_pointer.motion

141.7816 A: wl_pointer@10b.motion(time=131196455, surface_x=115.234375, surface_y=179.7265625) ↲
141.7816 A: wl_pointer@10b.frame() ↲

而当 GUI 程序处理完这些事件,需要重绘 UI 时,就会向窗口管理器发送一系列的 wl_surface. 请求,将新的一帧传递给窗口管理器:

141.7882 A: → wl_surface@51df.attach(buffer=wl_buffer@57a, x=0, y=0)
141.7882 A: → wl_surface@51df.set_buffer_scale(scale=2)
141.7882 A: → wl_surface@51df.damage(x=10, y=148, width=644, height=45)

那么 GUI 程序向窗口管理器传递的内容具体是什么呢?如果是早期基于 X11 的窗口管理器,GUI 程序会将类似 drawRectdrawImage 的绘制指令发送给窗口管理器,由窗口管理器负责将这些指令转换为 GPU 指令,但是这种方式需要大量的数据拷贝,效率不高。

现代的窗口管理器协议如 Wayland 则允许 GUI 程序通过共享内存或者共享 OpenGL 纹理的方式,不经拷贝直接将绘制好的图像传递给窗口管理器,降低了数据传输的开销,提升了绘制效率。

使用 3D 图形 API 加速 2D 绘图

到目前为止我们已经介绍了显卡的工作方式,如何调用显卡进行 3D 绘图,以及从绘图到最终显示的的完整流程,希望大家看得还过瘾。

不过说起来标题不是 GPU 加速 2D 渲染来着?从刚才开始我们怎么一直在聊 3D 绘制相关的东西?

这是因为 2D 绘图本质上是 3D 绘图的一个特例。如同上文中的伪代码,在绘图时我们只是将 Z 轴的值固定为 0,这样就可以将 3D 绘图变为 2D 绘图。目前主流 GPU 均已不再单独支持 2D 绘图,转而使用 3D 绘图硬件来实现。同样地,目前主流的硬件加速 2D 绘图方案也均使用 3D 图形 API 来实现。

下边我们尝试用 WebGL 来画一些简单的图形:

圆形

还记得我们上文提到过,与 CPU 绘图依次绘制像素不同,在 GPU 中运行的着色器程序并行执行,每次运行只处理一个像素。在 WebGL 的片段着色器中,我们可以通过内置 gl_FragCoord 变量拿到当前像素的坐标,然后通过计算当前像素到圆心的距离来判断这个像素是否在圆内:

#version 300 es

precision highp float;

// uniform 类似于 C 语言中的全局变量,由外部传入
uniform vec2 u_resolution;

// 用于输出颜色的变量
out vec4 output_color;

float circle(in vec2 coordinate, in vec2 center, in float radius) {
    // 计算当前像素到圆心的距离矢量
    vec2 distFromCenter = coordinate - center;

    // 计算直线距离
    float distSquared = dot(distFromCenter, distFromCenter) * 4.0;

    // 判断当前像素是否在圆内,是则返回1.0,否则返回0.0
    return step(distSquared, radius * radius);
}

void main() {
    // 计算当前像素的坐标
    vec2 coordinate = gl_FragCoord.xy / u_resolution.xy;

    vec2 circleCenter = vec2(0.5, 0.5); // 圆心坐标
    float circleRadius = 0.5;           // 圆半径

    // 计算当前像素的颜色。如果当前像素在圆内,则颜色为白色(1.0, 1.0, 1.0),否则为黑色(0.0, 0.0, 0.0)
    vec3 color = vec3(circle(coordinate, circleCenter, circleRadius));

    // 将颜色输出
    output_color = vec4(color, 1.0);
}

这是一段真实可运行的 WebGL 片段着色器代码,感兴趣的读者可以尝试在浏览器中运行。而对于初次接触 WebGL 的读者,这里可以先忽略掉一些语法细节,只需要关注在 main 函数中,我们是怎样根据当前像素的坐标计算出像素的颜色。

在渲染过程中,GPU 会对每个像素调用一次片段着色器,然后将着色器的输出写入到屏幕上。在这种模式下,我们只需要编写对单个像素的处理逻辑,就可以完成整个图形的绘制:

webgl-circle.png

矩形

同样地,我们可以通过类似的方式绘制一个矩形:

#version 300 es

precision highp float;

uniform vec2 u_resolution;

out vec4 output_color;

void main() {
    vec2 coordinate = gl_FragCoord.xy / u_resolution.xy;

    vec2 rectCenter = vec2(0.5, 0.5);
    vec2 rectSize = vec2(0.3, 0.3);

    vec3 color = vec3(
        step(rectCenter.x - rectSize.x, coordinate.x) *
        step(rectCenter.y - rectSize.y, coordinate.y) *
        step(coordinate.x, rectCenter.x + rectSize.x) *
        step(coordinate.y, rectCenter.y + rectSize.y)
    );

    output_color = vec4(color, 1.0);
}

矩形的绘制逻辑与圆形类似,我们只需要判断当前像素的坐标是否在矩形的范围内即可。

webgl-rect.png

图片

最后我们来看看如何在 WebGL 中绘制一张图片。这里我们使用一个简单的图片作为例子:

#version 300 es

precision highp float;

uniform vec2 u_resolution;

uniform sampler2D u_texture;

out vec4 output_color;

void main() {
    vec2 coordinate = gl_FragCoord.xy / u_resolution.xy;
    
    vec4 texColor = texture(u_texture, coordinate);

    output_color = vec4(texColor.rgb, 1.0);
}

在这段代码中,我们使用了 texture 函数来从纹理中获取颜色值,然后将这个颜色值作为当前像素的颜色输出。这样我们就实现了用 3D 图形 API 绘制一张图片:

webgl-texture.png

当然在着色器中我们也可以对图片进行一些简单的处理,实现例如灰度化、模糊等效果。

多边形、曲线与 Tsessellation

看到上边的代码大家可能会想,简单的图形可以这样绘制,那么多边形怎么办?还有贝塞尔曲线等等,总不能为每个要绘制的图形都编写一个片段着色器吧?

解决方案当然也是有的,这里介绍一种比较流行的方案:Tessellation

Tessellation 可以翻译成细分、镶嵌。在 2D 绘图的语境中,Tessellation 指的是将一个复杂的图形细分为多个简单的图元,然后通过分别绘制这些简单的图元来完成整个图形的绘制。

对于 GPU 来说,三角形是最基本的图元。我们可以使用算法将复杂的图形细分为多个三角形,将这些三角形的顶点坐标传递给 GPU 进行绘制。这里我们用 lyon 库的官方示例来演示一下这一过程:

extern crate lyon;
use lyon::math::point;
use lyon::path::Path;
use lyon::tessellation::*;

fn main() {
    // 使用 Path::builder() 构建图形
    let mut builder = Path::builder();
    builder.begin(point(0.0, 0.0));
    builder.line_to(point(300.0, 0.0));
    builder.quadratic_bezier_to(point(600.0, 0.0), point(600.0, 300.0));
    builder.cubic_bezier_to(point(300.0, 300.0), point(0.0, 300.0), point(0.0, 0.0));
    builder.end(true);
    let path = builder.build();

    #[derive(Copy, Clone, Debug)]
    struct Vertex {
        position: [f32; 2],
    }

    // 将构建好的图形传递给 tessellator,计算出细分后的顶点数据
    let mut geometry: VertexBuffers<Vertex, u16> = VertexBuffers::new();
    let mut tessellator = FillTessellator::new();
    tessellator
        .tessellate_path(
            &path,
            &FillOptions::default(),
            &mut BuffersBuilder::new(&mut geometry, |vertex: FillVertex| Vertex {
                position: vertex.position().to_array(),
            }),
        )
        .unwrap();

    // 输出细分后的顶点数据
    println!(
        "vertices: [{}]",
        geometry.vertices.iter()
            .map(|v| format!("{:?}", v.position))
            .collect::<Vec<_>>().join(", "),
    );

    println!(
        "indices: [{}]",
        geometry.indices.iter()
            .map(|i| i.to_string())
            .collect::<Vec<_>>().join(", ")
    );
}

运行这段代码,我们可以得到细分后的三角形顶点数据和索引数据:

vertices: [[0.0, 0.0], [300.0, 0.0], [320.39755, 0.35902986], [339.7426, 1.4113736], [358.08554, 3.121967], [375.47537, 5.458588], ...]
indices: [1, 0, 2, 2, 0, 3, ...]

这里我们可以使用 html canvas 将这些顶点数据和索引数据可视化地展示出来:

var vertices = [[0.0, 0.0], [300.0, 0.0], [320.39755, 0.35902986], [339.7426, 1.4113736], [358.08554, 3.121967], [375.47537, 5.458588], ...]
var indices = [1, 0, 2, 2, 0, 3, ...]

var canvas = document.getElementById('canvas')
var ctx = canvas.getContext('2d')

for (var i = 0; i < indices.length; i += 3) {
    var i0 = indices[i]
    var i1 = indices[i + 1]
    var i2 = indices[i + 2]

    var x0 = vertices[i0][0]
    var y0 = vertices[i0][1]
    var x1 = vertices[i1][0]
    var y1 = vertices[i1][1]
    var x2 = vertices[i2][0]
    var y2 = vertices[i2][1]

    ctx.beginPath()
    ctx.moveTo(x0, y0)
    ctx.lineTo(x1, y1)
    ctx.lineTo(x2, y2)
    ctx.closePath()
    ctx.stroke()
}

就可以看到细分后的图形了:

tess.png

可以看到尽管 tessellator 输出的数据都是三角形,将这些三角形绘制在一起后,我们却可以得到平滑的贝塞尔曲线。通过这种方式,我们可以使用 3D 图形 API 来绘制各种复杂的 2D 图形。

Flutter 的新渲染引擎 Impeller 早期就使用了这种方式,通过 libtess2 库将 Path 转变为 GPU 可以绘制的三角形数据,然后通过 OpenGL ES 等图形 API 完成绘制。然而在近期的版本中,Impeller 将 Path 的渲染算法从 Tessellation 切换到了 Stencil-then-Cover,将更多的计算移到 GPU 中,来进一步提升渲染性能。

Skia

在上边一节中我们看到了如何从零开始使用 3D 图形 API 绘制各种图形,但是为了绘制一个矩形就要写几十行的着色器代码显然过于麻烦了,更好的办法是将这些底层细节封装成一个高级的绘图库,这样我们作为开发者只需要调用类似drawRectdrawCircle这样的函数就可以完成绘图,而这些繁琐的图形 API 调用与着色器编译等细节则由库的内部处理。

目前有许多图形库具有这样的功能,其中最著名的就是 Google 的 Skia

Skia 作为安卓、Chrome浏览器、Flutter 框架等 Google 产品的绘图引擎,支撑了数以亿计的设备的 GUI 渲染。Web 开发者熟悉的各种 CSS 属性、<canvas> 2D 绘图 API 等都基于 Skia 实现。

接下来我们就实际看看如何使用 Skia 来绘制图形:

// 指定 Skia 使用 OpenGL
sk_sp<const GrGLInterface> interface = GrGLMakeNativeInterface();
sk_sp<GrDirectContext> context = GrDirectContexts::MakeGL(interface);

// 指定 Skia 将图像渲染到哪个 framebuffer 中。0 代表默认 framebuffer,通常是用户可见的窗口
GrGLFramebufferInfo fboInfo;
fboInfo.fFBOID = 0;
fboInfo.fFormat = GL_RGBA8;
fboInfo.fProtected = GrProtected::kNo;

// 指定目标 framebuffer 的宽高等信息
GrBackendRenderTarget backendRT = GrBackendRenderTargets::MakeGL(
    fWidth, 
    fHeight, 
    1, 
    kStencilBits, 
    fboInfo
);

// 通过以上的配置信息创建一个 SkSurface 对象。在 Skia 中,SkSurface 代表了一个可储存绘制结果的 buffer
sk_sp<SkSurface> surface = SkSurfaces.WrapBackendRenderTarget(
    context.get(),
    backendRT,
    kBottomLeft_GrSurfaceOrigin,
    kRGBA_8888_SkColorType,
    nullptr,
    nullptr,
    nullptr,
    nullptr
);

// 从 SkSurface 中获取 SkCanvas。我们可以调用 SkCanvas 的各种方法来将图形绘制到对应的 SkSurface 上
SkCanvas* canvas = surface->getCanvas();

// 设置画笔颜色
SkPaint paint;
paint.setColor(SK_ColorRED);

// 使用 SkCanvas 绘制矩形
SkRect rect = SkRect::MakeLTRB(0, 0, 100, 100);
canvas->drawRect(rect, paint);

// 将已有的绘制指令提交到 GPU
context->flush();

这段代码使用 Skia 绘制了一个红色的矩形。可以看到,相比于直接使用 OpenGL 等 API,使用 Skia 可以大大简化绘图的过程,开发者只需要在初期配置好想要使用的图形 API,接着就可以调用 canvas.drawRectcanvas.drawCircle 等高级 API 完成绘制,而不需要关心底层图形 API 调用细节。

调试工具

在使用 C 等编程语言编写图形程序时,我们可以使用 GDB、LLDB 等调试工具来调试程序。但是着色器程序运行在 GPU 上,GPU 硬件上独立于 CPU,我们显然不能直接使用 GDB 等调试工具来调试。

好在有一些专门的工具,可以记录并回放 GPU 的绘图指令,帮助我们调试 GPU 的图像绘制:

Spector.js

Spector.js 是一个用于 WebGL 的开源调试工具,可以在 Chrome 应用商店中下载。这里我们尝试用 Spector.js 来调试 Flutter 官方的 demo 网站

spector-js.gif

可以看到,Spector.js 不仅可以记录每一次的 WebGL 函数调用,还可以查看绘制所使用的着色器代码,以及每一次绘制的结果。

使用 Spector.js 调试 WebGL 无需配置环境或者编译代码,非常方便。这里推荐各位读者尝试一下,看看这些使用了 WebGL 网站是如何一步一步完成绘制的。

Xcode

在 MacOS 平台下 Xcode 也提供了类似 Spector.js 的功能,允许我们查看 Metal 的 API 调用。这里让我们回收伏笔,看一下文章最开头的窗口是如何同时绘制多个浏览器页面的:

xcode.gif

Xcode 的 Metal 调试工具会用绿色线条高亮出每一次 Metal API 调用所绘制的区域。可以看到,尽管这个程序同时绘制了多个浏览器窗口,但是这些所有窗口都来自同一个纹理,并且在一次 API 调用中完成全部绘制工作。因此有着与直觉相反的、非常低的绘制开销。

在 Shaft 中的应用

如 Shaft 系列第一篇博客所述,Shaft 的设计初衷之一就是尽可能地简化架构,允许开发者直接调用各种底层 API,这其中就包括了 GPU 的绘图 API。

通过对 Shaft 框架的全局变量 renderer 进行类型转换,我们可以获取到各种系统的原生对象,通过这些对象我们可以直接调用系统的 GPU 绘图 API。以 MacOS 为例:


var ioSurface: IOSurfaceRef!

private func cef_on_accelerated_paint(
    _ self: UnsafeMutablePointer<cef_render_handler_t>?,
    _ browser: UnsafeMutablePointer<cef_browser_t>?,
    _ type: cef_paint_element_type_t,
    _ dirtyRectsCount: size_t,
    _ dirtyRects: UnsafePointer<cef_rect_t>?,
    _ info: UnsafePointer<cef_accelerated_paint_info_t>?
) {
    ioSurface = unsafeBitCast(info!.pointee.shared_texture_io_surface, to: IOSurfaceRef.self)
}

var image: NativeImage!

if let renderer = renderer as? MetalRenderer {
    let textureDescriptor = MTLTextureDescriptor()
    textureDescriptor.width = IOSurfaceGetWidth(surface)
    textureDescriptor.height = IOSurfaceGetHeight(surface)

    let texture = renderer.device.makeTexture(
        descriptor: textureDescriptor,
        iosurface: surface,
        plane: 0
    )

    image = renderer.createMetalImage(texture: texture)
}

runApp(
    Center {
        RawImage(
            image: image
        )
    }
)

这是从即将开源的 ShaftBrowser 项目中摘取的一段代码,这个项目使用了 CEF 绘制浏览器网页。

在 CEF 绘制完成的 cef_on_accelerated_paint 回调中,我们可以获取到包含网页最新一帧的 IOSurfaceRef 对象;通过 MTLDevice.makeTexture 我们可以将 IOSurfaceRef 转换为 Metal 的 MTLTexture 纹理对象;接下来我们再通过 MetalRenderer.createMetalImageMTLTexture 转换为 Shaft 中的 NativeImage 对象,最终将 NativeImage 传入 RawImage 进而绘制到屏幕上。

是不是还有点看得眼花缭乱?没关系,在 ShaftBrowser 项目正式发布时还会有更详细的文档和教程。

渲染性能优化

了解底层原理的意义之一就在于可以更好地理解和优化程序的性能。这里结合上文列举一些 Web、移动端以及 Flutter 等平台的通用性能优化建议:

  • 选择合理的图片尺寸:绘制图片时应用程序需要将图片数据解码并上传到 GPU。图片解码后的体积随着分辨率以平方的速度增长。例如一张 512x512 分辨率的图片解码后的体积为 512x512x4 = 1MB,而一张 4096x4096 的图片解码后的体积达到了 64MB。这种数据量对总线带宽以及显存都会产生一定的压力,降低图片分辨率可以避免不必要的开销。

  • 避免不必要的绘制:尽管 GPU 绘图速度很快,但也并非完全没有性能开销。同时 Skia 等图形库将绘图指令转换为 GPU 调用的过程也会产生一定的开销。以 svg 为例,svg 矢量图中的每个 Path 都需要使用 GPU 进行一次渲染,网页中一张复杂的 svg 图像产生的 GPU 调用数量甚至可能会高于网页中的其他元素之合。而 png 等格式的位图仅需要一次 GPU 调用就可以完成绘制。

  • 使用光栅化缓存:在使用 GPU 绘制 UI 时,页面中一小部分的改动通常也会导致整个页面的重绘,为了解决这个问题,浏览器与 Flutter 框架等都引入了 Layer 的概念,将完整的页面按照层级划分为不同 Layer,将每个 Layer 分别绘制到不同的 GPU 纹理上,这样在页面的一部分改动时只需要重绘对应的 Layer,而不需要重绘整个页面。

    Layer 的划分通常是由浏览器或者框架自动完成的,但是在特殊场景下开发者也可以手动控制,例如使用 CSS 的 will-change 属性,或者 Flutter 中的 setIsComplexHint。需要注意的是 Layer 并非越多越好,另外过多的 Layer 也会像高分辨率图片一样增加显存负担。

  • 渲染 3D 对象前进行 Mesh Simplification:在使用 WebGL 或者 Metal 等 API 绘制 3D 模型之前可以对 3D 模型进行预处理,通过算法在几乎不损失视觉效果的情况下减少 3D 模型的顶点数量,从而大幅减少着色器的计算量。

  • 避免复杂的绘制操作:半透明、高斯模糊等效果在 GPU 上的计算量会显著高于一般的绘制操作。在这种情况下可以通过上文中提到的 Specter.js、Xcode 等工具可视化地定位到这些复杂的绘制操作,并通过触发光栅化缓存等方式减少这些操作的开销。

结语

正如 The Law of Leaky Abstractions 所描述的那样,没有抽象是完美的。抽象层可以帮助我们节省工作时间,但是并不能真正使我们摆脱底层的细节。

在前端开发中,多数情况下我可以都可以使用 HTML 与 CSS 、Flutter Widget 等高级 API 来完成 UI 的构建,但是最终都难免会遇到出乎意料的渲染异常或者卡顿问题。而了解底层原理则可以让我们更加从容地作出应对。

接下来我还会更新更多与 Shaft框架 相关的文章,感兴趣的话不妨点个关注。也欢迎来关注我的 GitHub 账号。