在 X11 下使用 cairo 引擎绘制图形
cairo
是一个方便和高性能的第三方C
库。它可以作为绘制引擎,帮助我们绘制各种图形,并且提供多种输出方式。本文将在Linux
下结合X11
图形显示协议绘制简单的图形。
这是效果图:
安装Cairo库
cairo
的官方网站是https://cairographics.org。上面对cairo
图形库做了一个完整的介绍,包括如何下载、API
接口、示例等等。
通过包管理器安装
通过官方文档知道,在Linux
下可以直接通过包管理器进行下载,以Ubuntu
为例。
1 | sudo apt install libcairo2-dev |
下载好以后头文件和动态库就安装好了。头文件安装在/usr/include/cairo/
中,静态库和动态库分别位于/usr/lib/x86_64-linux-gnu/libcairo.a
和/usr/lib/x86_64-linux-gnu/libcairo.so
。因此能直接被系统识别,直接引入头文件,编译的时候链接cairo
库即可。
通过Conan安装
对于个人而言,apt
安装自然是非常友好的。但是对于LarkSDK
这样一个面向用户的基础框架而言,除非最基本的系统库例如X
窗口系统libx11-dev
,Wayland
窗口系统libwayland-dev
和Wayland
键盘处理libxkbcommon-dev
等,其他的第三方库均最好不以apt
包的方式引入。鉴于C++
没有自己的包管理器,因此需要借助第三方的包管理器,例如conan
,cpm
等。本项目使用conan
管理第三方包。
conan
是一个python
语言编写的C++
的包管理器,官方网址是https://conan.io/。conan
管理了众多第三方的C++
库,通过命令行操作就能方便的在自己的项目中引入conan
包。需要通过conanfile.py
或者conanfile.txt
进行配置,当然这不是本文的重点,具体见文档https://docs.conan.io/2/。
conan
官方提供了自己的conan
仓库,https://conan.io/center。在上面能找到很多我们熟知的第三方库,例如gtest
,qt
,boost
,fmt
等。当然还有一些更基础的工具库,这里不赘述。当然cairo库也在其中。
尝试安装Cairo
现在让我们尝试安装cairo
库并尝试用CMake
将其引入。在新开的项目中创建conanfile.txt
,引入项目依赖cairo
。使用conanfile.py
主要用于生成和发布自己的conan
包,conan
提供了丰富的选项供用户操作。这里只是为了测试,因此使用最简单的conanfile.txt
即可。
1 | [requires] |
编写自己的CMakeLists.txt
,配置项目的相关信息,引入conan
的部分类似如下:
1 | ... |
之后执行一般的构建流程即可。
1 | conan install .. |
conan
官方的包很多,很全,但是conan
本身还有很多bug
,单就cairo
包的使用过程中就有很多问题。最典型的,执行conan install ..
可能会失败,并且遇到很多错误。因此我们需要了解conan
包是个什么东西,才能明确问题是如何形成的。
关于Conan包
现在对conan
包做一个简述。对于C++
库而言,为了让用户方便的使用,把.h
和.cpp
代码全部打包出去是不合适的,这些代码不应该在用户的机器上再被编译一次,而应该在需要用的时候被直接使用。因此需要通过库的方式进行发布,也就是使用静态库和动态库。在发布的包中,最重要的文件就是头文件和库文件。当然可能会携带一些其他必要的文件,例如资源文件、版本说明文件等。
我们都知道,编译C/C++
的代码需要依赖于C++
和编译器的版本。进一步的,由于C/C++
是非常接近底层的代码,虽然标准库是跨平台的,但是如果需要写平台相关的程序,还需要注意操作系统的版本。因此,对于同一个版本的conan
包,不同的系统,不同的编译环境,是静态库还是动态库,是Debug
包还是Release
包,甚至依赖包的版本,都可能对最后的编译造成一定影响。在本地的conan
配置中会保存相关的这些信息,在conan
拉包的时候会匹配本地的配置拉取合适的包。配置文件默认位于~/.conan/profile/default
中。
在Linux
下的配置类似于这样,看其参数很明显就能知道对应的含义。
1 | [settings] |
在Windows
下类似于这样:
1 | [settings] |
现在我们以cairo/1.18.0@
包为例,使用conan
命令查看其远端的包的列表。
1 | conan search cairo/1.18.0@ -r conancenter |
得到的结果大致是这样:
我们发现settings
中的内容和我们的profiles/default
中的内容对应,这就是前面提到的匹配。例如图中是一个Mac
下的apple-clang
的13.0
版本的静态库
的Debug
包。每个包的options
,settings
和requires
都会对最前面的Package_ID
产生影响,这是一个哈希计算值,具体如何影响和生成请参考https://docs.conan.io/2/reference/binary_model/package_id.html。当然这其中也有令人费解的地方,请见下文。
解决错误(并吐槽)
前面提到,conan
官方的包有问题,会导致在安装的时候出现错误。例如,我在安装的出现的错误如下:
错误信息告诉我fontconfig
的2.15.0
的版本需要conan 1.60.4
以上才能安装。由于公司使用的是conan 1.60.1
,首先我想到的是conan
版本不正确。深入研究后,我发现的问题出乎我的意料。
由于公司实际开发的conan
版本不可能从1.60.1
升到1.60.4
,要升肯定一步到位到conan 2.0
了。首先查看conancenter
中cairo/1.18.0@
远端的包,检索符合当前系统和编译环境的。我发现了三个长的几乎一样的包:
1 | ; package 1 |
这三个包的唯一区别在于requires
不同,第一个包需要expat/2.6.0
,第二个和第三个包需要expat/2.5.0
。第二个包需要freetype/2.13.0
,第三个包freetype/2.13.2
。这就是唯一区别,然后生成了三个不同的哈希值,对应不同的package
。
那么问题来了,conan install
默认会读取本地的配置,但是本地配置不可能指定requires
啊。不同的项目可能用的版本不同,这是很正常的事情,也不应该是由用户承担责任的地方。其次,conan install
不能指定package_id
下载,因此下载的是哪一个包,完全就看他怎么想了。非常不幸运的是,我需要第二个包,但是下载只能下载到第一个包。
那么如何解决这个问题呢?幸运的是,conan
提供了conan download
的方法,这个东西可以指定package_id
进行下载。那就简单了,先把cairo
本包下载到本地,然后再指定这一套流程,由于本地有缓存,会跳过去远端拉取cairo
包的这一步,这样所有本包和所有依赖不就顺利拉取下来了吗?说干就干。
1 | conan download cairo/1.18.0@:8098347825649d9fd3e21c49992446a2a2193ad4 -r conancenter |
成功下载下来以后,再次执行conan install ..
,又出问题了。
不对啊,这个包依赖的fontconfig
的版本是2.14.2
啊,为什么这里还是下载的2.15.0
啊?为了解决这个问题,我打开了对应的conanfile.py
,阅读到requirements()
函数的时候,豁然开朗。
1 | def requirements(self): |
不是哥们,expat
需要的是2.5.0
,但是你规定依赖的包是>=2.6.2 and <3
。哈?这不前后矛盾吗?对于freetype
和fontconfig
也是相同的问题。同时,我们再浏览一下cairo
包拉下来以后的整体结构。奥,原来同一个版本的所有包的conanfile.py
都是同一个文件。好,这没有任何问题,这是conan
的设计。但是你自己不更新依赖版本的限制是什么意思,如果硬要不改的话,发布不同的版本也是ok
的啊。这样一套流程下来,导致conan
拉包就出现了问题。
那么如何解决呢?公司这边的解决方式是将该版本的cairo
包上传到公司的conan
服务器上,并手动修改conanfile.py
使其版本匹配,并将所有的依赖以及穿透依赖全部拷贝到公司服务器上。至此,cairo
终于成功通过conan
安装。
Windows
那边也是同样的问题,同样操作即可。
补充
在Linux
下的Cairo
包中,有一个依赖项值得看一下,即xorg/system
。这个包的版本是system
,意思是和系统相关的包,这里是Linux
下X
图形协议相关的依赖。众所周知,系统包是通过apt
或者yum
进行安装的,意思是conan
能够调用这些包命令来帮我们自动安装对应的包吗?
答案是可以的,参见文档https://docs.conan.io/2/reference/tools/system/package_manager.html。留意这段代码:
1 | def system_requirements(self): |
对于Ubuntu
来讲,conan
能手动帮我们调用apt
安装所需要的系统依赖。需要注意一点,需要在配置文件中加上两句,来指明开启这个功能和使用sudo
。所以最终的配置文件类似如下:
1 | [settings] |
至此,我们成功通过conan
安装下来了cairo
,看到成功的结果,我的内心无比兴奋。
使用Cairo库绘制图形
安装完cairo
库,是时候使用它绘制一些简单的图形了。
Linux GUI背景简述
Linux
本身是不带有图形界面的,真正原生的Linux
系统只是一个基于命令行的操作系统。但是我们目前所使用的Linux
发行版,例如Ubuntu
、Centos
等,都是有图形界面的啊。这是因为这些图形界面是Linux
下的一个应用程序而已,是通过程序和协议模拟和实现出来的,或者说图形界面并不属于Linux
内核的一部分。这一点和Windows
系统完全不一样,Windows
的命令行在我看来完全不如Linux
这么好用,甚至有时候我会爆粗口喷他,但是Windows
的用户的依然最多。为什么呢?就是因为GUI
好看。Windows
的图形界面是操作系统的一部分,并且做的确实好看和丝滑。这样变相的降低了用户的学习和使用成本,对大多数的人而言是件好事。但是对于程序员特别是偏向底层的程序员来讲,却真不一定。
在Linux
下,需要通过应用程序实现图形界面,那就需要设计一个合适的协议。目前市面上比较流行的有两种协议,X
协议和Wayland
协议。这两种协议都是基于网络通信的思想,将图形显示分为了客户端(即你的应用程序)和服务端通信的过程。输入设备和显示设备不是同一个设备,而且他们需要相互配合,进行画面显示,所以需要一个交互协议,建立他们直接的沟通桥梁。当然X
协议和Wayland
协议的细节有所区别,粗略的讲是server
和compositor
的设计不同,具体可见https://www.secjuice.com/wayland-vs-xorg/。
本文以及后续以X
协议为例展开,并以XClient
和XServer
分别代指客户端和服务端。例如现在我需要画一个圆,XClient
需要告诉XServer
在屏幕的什么地方,使用什么颜色,画多大的一个圆。至于这个圆如何生成,如何使用硬件真正绘制图形等等这些操作,都是由XServer
完成的。当然更进一步的,XServer
还可以捕捉鼠标和键盘的动作,会触发相应的事件。XClient
可以接受相应的事件并且完成相应的逻辑。这就是整个X
协议以及绘制逻辑的简要概括。
X
协议有很多实现。目前用的最多的是XOrg
,对应的XClient
有Xlib
和XCB
的两种实现,提供了和XServer
对接的API
。(At the bottom level of the X client library stack are Xlib and XCB, two helper libraries (really sets of libraries) that provide API for talking to the X server.
)本文的背景是X11
,也是Xlib
库的一个特殊版本。
上面只是非常粗略的说明了一下基本思路,更多文档请参考:
安装X11并编写GUI程序
强烈建议在虚拟机下进行,因为Wsl
需要内核更新到2.2.4
以后才能使用最新功能的GUI
,而且体验还不是很好。
以Ubuntu
为例,系统默认是未安装X11
库的(XServer
肯定是有的,不然你怎么看得到图形界面呢?安装X11
只是提供了窗口系统的开发支持)。因此我们使用如下命令手动安装。值得一提的是,由于X11
是系统库,因此使用apt
包安装即可。同理对于Wayland
也是一样。
1 | sudo apt install libx11-dev |
安装完成以后,使用CMake
就能很方便的引入X11
支持了。
1 | add_executable (xxx |
现在我们编写一个样例程序,用于在X11
下绘制一个图形窗口。这里面涉及到很多的X11
的API
,本文只做简单介绍,具体请参考文档https://www.x.org/releases/X11R7.7/doc/libX11/libX11/libX11.html
1 |
|
接口的大致功能以在代码注释中体现。注意到整个程序最重要的架构是最后的这个事件循环,当XClient
窗口成功创建出来以后,XServer
需要不断监听XClient
发送的事件并予以处理,这样才能实现GUI
程序的功能。因此事件循环的存在就自然而然了。有点类似于IO
多路复用中epoll
技术的框架。在这里监听的是Expose
事件,不用具体关心这个语义是什么意思,在窗口创建和窗口大小发生改变的时候会触发Expose
事件,这里也是用作信息打印测试。
程序的运行结果如下,可以发现窗口创建和改变窗口大小的时候在不断打印Expose
的信息。
对于事件循环这个概念,不管是Qt
,还是LarkSDK
,还是对于一个跨平台的GUI
框架,事件循环显然是必不可少的。问题在于跨平台需要统一不同平台下的窗体系统和事件处理等逻辑,最终抽象出跨平台的接口,这就是这些框架正在做最重要的一件事情。以LarkSDK
为例,虽然最简单的跨平台的程序是四行就能搞定,但是其中涉及到的知识和背景是非常庞大的。
1 |
|
尝试引入Cairo
cairo
将输出和绘制的概念做了严格区分。cairo surface
是一个抽象出来的概念,与其对接的是多种输出方式,例如PDF
、PNG
、SVG
、Win32
、XLib
、XCB
等,如图所示。
接着我们查看cairo
官方提供的samples,可以发现,官方提供的样例好像完全和surface
没有关系,换句话说没有反映输出的方式。例如这段代码:
1 | double xc = 128.0; |
它的绘制结果是这样的:
这个图样可以被输出到前面提到的任意一种surface
中。同时这也是我想说的,cairo
将输出和绘制的概念做了完整区分,同样这也是我们容易想到和愿意看到的。所以,如果想要创建一个输出到PNG
中的实例,代码应该类似如下:
1 | // 创建 surface |
至此,我们用surface
和context
来代指输出和绘制的概念,也明白了cario
是如何区分这两个概念的了。surface
用作指明绘制的图形输出到哪里,context
则用于进行绘制。读者可以尝试编写一个完整的程序输出上面的图案到一张图片文件中。
Cairo和X11相结合
使用cairo
成功输出到图片文件中以后,试着想想如何与X11
窗口系统结合了。提前声明,用到的surface
只有XLib Surface
和Image Surface
,其他的API
请自行查询文档。
XLib Surface
首先想到的肯定是XLib Surface
。这代表cairo
帮我做好了与X11
平台的对接工作,我只需要按部就班地使用cairo
的API
即可。所有的surface
基本上只有在创建的时候会有区别,在context
那一层的绘制几乎没有区别。例如下面就是XLib Surface
的创建API
,参数具体含义请参考官方文档。
1 | cairo_surface_t * |
查询该方法需要的接口如何获取以后,编写出如下的代码:
1 |
|
这样能在Expose
事件触发的时候成功绘制出文章开始时候展示的图样。
Image Surface
XLib Surface
的代码结构看着很像自动挡的感觉,创建surface
,在事件循环中用context
进行绘制,最终得到想要的图案。
我们深入思考一下,图形是如何被绘制到屏幕上的呢?前面举了个例子,画一个圆,告诉XServer
在哪里,用什么颜色,画多大、多宽的圆。至于如何用硬件画不是XClient
关心的事情,但是如何表示这些信息呢?显然需要用合适的data
进行存储。进一步讲,context
调用各种方法的实际过程,其实就是往数据缓冲区data
中写数据的过程。当类似flush
操作的被调用以后,这些数据才会真正反映在屏幕上,形成我们观看的效果。
在这样的语义下,我们进一步思考surface
的概念,其实用可绘制表面的概念好像更加贴切(本概念借鉴于LarkSDK
的LSurface
)。绘图的数据缓冲区记录了图形的数据,类型是unsigned char *
,这些数据和不同的输出方式对接就能达到不同的输出效果。至于为什么是unsigned char *
(我猜测大概是字节流)以及如何对接,这不是本文的重点,有兴趣请自己查询资料。
知道这个过程以后,回到Image Surface
本身,为什么要使用这个东西,是因为它为我们提供了获取数据的接口。也就是当我用context
绘制以后,调用这个方法就能立刻拿到缓冲区的数据。
1 | unsigned char * |
然后让我们思考一下绘制效率。不同引擎的效率的区别根本上就是在于如何快速的把这些数据计算出来,或者换句话讲,如何快速地让缓冲区的内存填充为指定的数据。比如对于最基本的暴力软渲染和cairo
引擎,他们的效率差距显然是非常大的。这里有一个例子可以参考,是LarkSDK
原生软渲染和cairo
引擎同样绘制10000
条斜线的效率差距,以下是结果,保守估计至少差了几百到一千倍。
回到正题,再结合X11
的API
,我们可以给出使用Image Surface
的代码:
1 |
|
至此,我们完成了在X11
下使用Cairo
引擎绘制图形的全部过程。Windows
的程序架构和事件循环有所区别,但思路是相同的。当然,这仅仅是阐述了基本过程,还有更多的细节值得研究和探讨。