分类
联系方式
  1. 新浪微博
  2. E-mail

Skia-python:Skia 的 Python 封装

介绍

Skia 是一个著名的 2D 高性能图形绘制库,是 Google 公司在多年前收购来的一个项目,目前被广泛用于 Google 的大型开源项目中。比如 Chrome、Android、Flutter,它们的底层都采用 Skia 进行绘制。

skia-python 是对 skia 库的一个 Python 语言封装。由于 Python 语言安装依赖和开发都比较简单,因此非常适合于学习 Skia。

skia-python 的安装方式可以参考官网,这里不再赘述。

Hello World!

HelloWorld 绘制效果

先来看第一个示例,通过 skia-python 绘制一副图像,并输出到图片文件:

import skia

surface = skia.Surface(128, 128)

with surface as canvas:
    rect = skia.Rect(32, 32, 96, 96)
    paint = skia.Paint(
        Color=skia.ColorBLUE,
        Style=skia.Paint.kFill_Style
    )
    canvas.drawRect(rect, paint)

image = surface.makeImageSnapshot()
image.save('output.png', skia.kPNG)

绘制效果参见右图。其中:

  1. 首先需要创建 Skia Surface
  2. 之后根据 Surface 创建 Canvas 画布
  3. 绘制的流程是:
    1. 创建形状(Rect)
    2. 创建画笔(Paint):控制颜色、填充效果
    3. 使用画笔,将形状绘制到画布上
  4. 通过 makeImageSnapshot 对 Surface 创建快照
  5. 最终保存为 PNG 格式的图片文件

你已经采用 Chrome、Android、Flutter 同款底层绘制库进行绘制了,是不是很神奇!

Skia Backend

保存到图片有点让人不满足,因为不论 Chrome 也好、Android 也好,都是有一个窗口,在窗口里面绘制界面才对,而不是把图像保存成图片。

为了在窗口里动态展示界面,首先需要了解 Skia Backend 的概念。

Backend 可以理解为 Skia 的渲染模式,包含以下几种:

  1. raster:CPU 渲染,在内存中绘制,Hello World 示例采用的就是 raster 渲染方式(通过 skia.Surface)
  2. GPU:使用显卡进行硬件加速渲染,高性能在这里得到体现
  3. PDF:Skia 也支持将绘制保存到 PDF 文件
  4. Picture:Skia 专门的 display list 格式
    1. 这是一种将大量绘制操作存储起来的方式
    2. Flutter 的 UI 绘制采用这一种方式
    3. 在渲染流水线现将所有绘制指令通过 display list 存储,之后一次性完成绘制
  5. SVG:绘制到 SVG,还处于实验中

OpenGL 窗口渲染

在本节中介绍如何使用 GPU Backend,并通过窗口来进行 Skia 动态绘制。

首先需要介绍几个基础概念:

  1. OpenGL:是一套 API,用于执行高性能 2D、3D 绘制。
  2. 显卡:显卡驱动就是对 OpenGL 接口的硬件实现。现在的显卡比较复杂,除了 OpenGL 之外,还支持其它接口。
  3. GLFW:OpenGL 自身只负责绘制,对于 OpenGL 桌面程序来说,还需要进行窗口管理、键盘鼠标适配,GLFW 提供这项能力,并且是跨平台的
Skia GPU Backend

有了这些储备后,来看项目代码:

import contextlib

import glfw
import skia
from OpenGL import GL

WIDTH, HEIGHT = 640, 480


@contextlib.contextmanager
def glfw_window():
    """
    创建 GLFW 窗口
    """
    # GLFW 必须首先调用 init 初始化
    if not glfw.init():
        raise RuntimeError('glfw.init() failed')
    # window_hint 用于设置 GLFW
    glfw.window_hint(glfw.STENCIL_BITS, 8)
    # 创建一个 GLFW 窗口
    window = glfw.create_window(640, 480, '', None, None)
    # 将 GLFW 窗口作为 OpenGL 的 Context
    # 后续的渲染,将都在这个窗口中进行
    glfw.make_context_current(window)
    # contextlib 返回创建好的窗口
    yield window
    # 窗口销毁操作
    glfw.terminate()

@contextlib.contextmanager
def skia_surface(window):
    # 通过 GrDirectContext 获取 GPU Backend 下的 Surafce
    context = skia.GrDirectContext.MakeGL()
    # 获取 GLFW 的尺寸
    (fb_width, fb_height) = glfw.get_framebuffer_size(window)
    # Skia GPU Backend 相关设置
    backend_render_target = skia.GrBackendRenderTarget(
        fb_width,
        fb_height,
        0,  # sampleCnt
        0,  # stencilBits
        skia.GrGLFramebufferInfo(0, GL.GL_RGBA8))
    # 传入 context、backend_render_target,得到 surface
    surface = skia.Surface.MakeFromBackendRenderTarget(
        context, 
        backend_render_target, 
        skia.kBottomLeft_GrSurfaceOrigin,
        skia.kRGBA_8888_ColorType, 
        skia.ColorSpace.MakeSRGB())
    assert surface is not None
    # 返回创建的 surface
    yield surface
    context.abandonContext()

# 创建窗口
with glfw_window() as window:
    GL.glClear(GL.GL_COLOR_BUFFER_BIT)
    # 创建 Skia GPU Backend Surafce
    with skia_surface(window) as surface:
        # 创建画布
        with surface as canvas:
            # 绘制图形
            canvas.drawCircle(100, 100, 40, skia.Paint(Color=skia.ColorGREEN))
        # 刷新
        surface.flushAndSubmit()
        # 上屏
        glfw.swap_buffers(window)

        # 窗口事件循环进入一个死循环,等待按下回车退出程序
        while (glfw.get_key(window, glfw.KEY_ESCAPE) != glfw.PRESS
            and not glfw.window_should_close(window)):
            glfw.wait_events()

程序运行效果如右图所示。

其中:

  • 通过 GLFW 创建了窗口,并将窗口作为 OpenGL 的 context
  • 随后该 Context 被传入 Skia
  • 在 Skia 初始化中,以 GPU Backend 方式创建 Surface
  • 在绘制阶段,通过画布的绘制方法在窗口中画了一个圆
  • 通过 glfw 的 swap_buffers 上屏
  • 最后是窗口的事件循环

给 Flutter 侧的启发

我同时也在研究 Flutter 框架,从以上对 Skia 的学习中,得到的一些知识能够迁移到 Flutter:

  • GrDirectContext、GrBackendRenderTarget、MakeFromBackendRenderTarget
  • 上面这几个是 Skia GPU Backend 的创建节点
  • 在 Flutter Engine 中,应当也是调用这些方法,来初始化 Skia 的 OpenGL

比如在 Flutter Engine 的 shell/gpu/gpu_surface_gl.cc 中,GPUSurfaceGL::MakeGLContext 用来绑定 OpenGL Context:

GPUSurfaceGL::MakeGLContext(\n GPUSurfaceGLDelegate* delegate) {\n auto context_switch = delegate->GLContextMakeCurrent();\n\n const auto options =\n MakeDefaultContextOptions(ContextType::kRender, GrBackendApi::kOpenGL);\n\n auto context = GrDirectContext::MakeGL(delegate->GetGLInterface(), options);\n\n context->setResourceCacheLimit(kGrCacheMaxByteSize);\n\n PersistentCache::GetCacheForProcess()->PrecompileKnownSkSLs(context.get());\n\n return context;\n}\n"}}" data-parsoid="{"dsr":[4283,4819,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
sk_sp<GrDirectContext> GPUSurfaceGL::MakeGLContext(
    GPUSurfaceGLDelegate* delegate) {
  auto context_switch = delegate->GLContextMakeCurrent();

  const auto options =
      MakeDefaultContextOptions(ContextType::kRender, GrBackendApi::kOpenGL);

  auto context = GrDirectContext::MakeGL(delegate->GetGLInterface(), options);

  context->setResourceCacheLimit(kGrCacheMaxByteSize);

  PersistentCache::GetCacheForProcess()->PrecompileKnownSkSLs(context.get());

  return context;
}

跟上面的代码是不是很类似,有了前面 Python 的基础,理解引擎的 C++ 代码也变容易了。 再比如 shell/gpu/gpu_surface_gl.cc 的 WrapOnscreenSurface:

WrapOnscreenSurface(GrDirectContext* context,\n const SkISize& size,\n intptr_t fbo) {\n GrGLenum format = kUnknown_SkColorType;\n const SkColorType color_type = FirstSupportedColorType(context, &format);\n\n GrGLFramebufferInfo framebuffer_info = {};\n framebuffer_info.fFBOID = static_cast(fbo);\n framebuffer_info.fFormat = format;\n\n GrBackendRenderTarget render_target(size.width(), // width\n size.height(), // height\n 0, // sample count\n 8, // stencil bits\n framebuffer_info // framebuffer info\n );\n\n sk_sp colorspace = SkColorSpace::MakeSRGB();\n SkSurfaceProps surface_props(0, kUnknown_SkPixelGeometry);\n\n return SkSurface::MakeFromBackendRenderTarget(\n context, // Gr context\n render_target, // render target\n GrSurfaceOrigin::kBottomLeft_GrSurfaceOrigin, // origin\n color_type, // color type\n colorspace, // colorspace\n &surface_props // surface properties\n );\n}\n"}}" data-parsoid="{"dsr":[4922,6370,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
static sk_sp<SkSurface> WrapOnscreenSurface(GrDirectContext* context,
                                            const SkISize& size,
                                            intptr_t fbo) {
  GrGLenum format = kUnknown_SkColorType;
  const SkColorType color_type = FirstSupportedColorType(context, &format);

  GrGLFramebufferInfo framebuffer_info = {};
  framebuffer_info.fFBOID = static_cast<GrGLuint>(fbo);
  framebuffer_info.fFormat = format;

  GrBackendRenderTarget render_target(size.width(),     // width
                                      size.height(),    // height
                                      0,                // sample count
                                      8,                // stencil bits
                                      framebuffer_info  // framebuffer info
  );

  sk_sp<SkColorSpace> colorspace = SkColorSpace::MakeSRGB();
  SkSurfaceProps surface_props(0, kUnknown_SkPixelGeometry);

  return SkSurface::MakeFromBackendRenderTarget(
      context,                                       // Gr context
      render_target,                                 // render target
      GrSurfaceOrigin::kBottomLeft_GrSurfaceOrigin,  // origin
      color_type,                                    // color type
      colorspace,                                    // colorspace
      &surface_props                                 // surface properties
  );
}

是不是连上面 MakeFromBackendRenderTarget 的传参都一抹一样!返回值是什么呢?是 Skia Surface!

沿着这条线,我们就能把 Flutter Engine OpenGL 架构梳理出来了。当然,处理清楚了 OpenGL 之后,Vulkan、Metal 也都能继续梳理清晰。

Skia 引擎是如何编译的

skia-python 这个项目,还有一个厉害的点:它是如何编译 Skia 源码的。

skia-python 中包含了 Skia 的构建脚本,位于 scripts 目录下,包含 Linux、Windows、macOS 三端的,这里以 Linux 为例:

#!/usr/bin/env bash

# depot_tools 是 Google 的源码管理工具
# 该工程内置了一份,导出 PATH 地址
export PATH=${PWD}/depot_tools:$PATH

EXTRA_CFLAGS=""

# uname -m 返回的是系统架构,x86 电脑返回的是 x86_64
# 安装 ninja 构建工具
if [[ $(uname -m) == "aarch64" ]]; then
    # 如果是 ARM 架构,从源中安装 ninja
    # Install ninja for aarch64
    yum -y install epel-release && \
        yum repolist && \
        yum install -y ninja-build && \
        ln -s ninja-build /usr/bin/ninja &&
        mv depot_tools/ninja depot_tools/ninja.bak
fi

# 安装构建所需的工具
# Install system dependencies
yum install -y \
    fontconfig-devel \
    mesa-libGL-devel \
    xorg-x11-server-Xvfb \
    mesa-dri-drivers && \
    yum clean all && \
    rm -rf /var/cache/yum

# Build gn
export CC=gcc
export CXX=g++
export AR=ar
export CFLAGS="-Wno-deprecated-copy"
export LDFLAGS="-lrt"

# 这一步是编译 gn 工具
# gn 用于执行代码配置,相当于 configure
git clone https://gn.googlesource.com/gn && \
    cd gn && \
    git checkout 981f46c64d1456d2083b1a2fa1367e753e0cdc1b && \
    python build/gen.py && \
    ninja -C out gn && \
    cd ..

# Build skia
# skia 代码是一个 submodule
# 需要打入几个 patch
# git-sync-deps:调整依赖源路径
# 执行 git-sync-deps:拉取依赖
# make_data_assembly:适配 Python3
# libjpeg-arm.patch:补充了一些源文件
# 最后通过 gn 生成配置
# 配置完成后通过 ninja 执行真正的构建
cd skia && \
    patch -p1 < ../patch/git-sync-deps.patch && \
    python tools/git-sync-deps && \
    patch -p1 < ../patch/make_data_assembly.patch && \
    patch -p1 < ../patch/libjpeg-arm.patch && \
    cp -f ../gn/out/gn bin/gn && \
    bin/gn gen out/Release --args="
is_official_build=true
skia_enable_tools=true
skia_use_system_libjpeg_turbo=false
skia_use_system_libwebp=false
skia_use_system_libpng=false
skia_use_system_icu=false
skia_use_system_harfbuzz=false
extra_cflags_cc=[\"-frtti\"]
extra_ldflags=[\"-lrt\"]
" && \
    ninja -C out/Release skia skia.h experimental_svg_model && \
    cd ..

skia-python 是如何集成 Skia 的

打开 setup.py,在 Linux 平台下定义了一系列常量:

    DEFINE_MACROS = [
        ('VERSION_INFO', __version__),
        ('SK_GL', ''),
    ]
    LIBRARIES = [
        'dl',
        'fontconfig',
        'freetype',
        'GL',
    ]
    EXTRA_OBJECTS = list(
        glob.glob(
            os.path.join(
                SKIA_OUT_PATH,
                'obj',
                'experimental',
                'svg',
                'model',
                '*.o',
            )
        )
    ) + [os.path.join(SKIA_OUT_PATH, 'libskia.a')]
    EXTRA_COMPILE_ARGS = [
        '-std=c++14',
        '-fvisibility=hidden',
        '-Wno-attributes',
        '-fdata-sections',
        '-ffunction-sections',
    ]
    EXTRA_LINK_ARGS = [
        '-Wl,--gc-sections',
        '-s',
        '-O3',
    ]

这些常量用于 Extension 的配置:

extension = Extension(
    'skia',
    sources=list(glob.glob(os.path.join('src', 'skia', '*.cpp'))),
    include_dirs=[
        # Path to pybind11 headers
        get_pybind_include(),
        get_pybind_include(user=True),
        SKIA_PATH,
        os.path.join(SKIA_OUT_PATH, 'gen'),
    ],
    define_macros=DEFINE_MACROS,
    libraries=LIBRARIES,
    extra_objects=EXTRA_OBJECTS,
    extra_compile_args=EXTRA_COMPILE_ARGS,
    extra_link_args=EXTRA_LINK_ARGS,
    depends=[os.path.join('src', 'skia', 'common.h')],
    language='c++',
)

其中可以看出,Skia 构建出来的产物是 libskia.a,在 Extension 中,将 Skia 产物,与 skia-python 中的 skia python binding 一起编译。

最终生成的产物,是 skia.cpython-310-x86_64-linux-gnu.so,这个是在 pip install 后安装到了 site-package 目录下。

库的作者在 pip 发布的时候走一遍编译流程,我们通过 pip 安装的时候,都是直接拉取编译好的库,走上述构建流程。

注:skia-python 是支持 ARM 架构的,对于 Android、iOS 来说,应该可以复用这里的 .so 文件,这样就不用自己单独编译了。

注2:上述编译好的 skia 多大呢?

-rwxr-xr-x  1 maxiee maxiee  24M Jun 20 22:21 skia.cpython-310-x86_64-linux-gnu.so

skia-python 是如何封装 skia 的?

使用 pybind/pybind11 实现对 C++ 的包装。对于 pybind 的使用本文不做展开,后续专门进行学习。

这里就先作代码赏析把,在不懂 pybind 前提下,看看 skia-python 是如何封装 skia 的。

GrContext.cpp:

枚举封装:

(m, \"GrGLBackendState\", R\"docstring(\n A GrContext's cache of backend context state can be partially invalidated.\n\n These enums are specific to the GL backend and we'd add a new set for an\n alternative backend.\n )docstring\")\n .value(\"kRenderTarget_GrGLBackendState\",\n GrGLBackendState::kRenderTarget_GrGLBackendState)\n .value(\"kTextureBinding_GrGLBackendState\",\n GrGLBackendState::kTextureBinding_GrGLBackendState)\n .value(\"kView_GrGLBackendState\",\n GrGLBackendState::kView_GrGLBackendState)\n ……\n GrGLBackendState::kPathRendering_GrGLBackendState)\n .value(\"kALL_GrGLBackendState\",\n GrGLBackendState::kALL_GrGLBackendState)\n .export_values();\n"}}" data-parsoid="{"dsr":[10681,11461,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
py::enum_<GrGLBackendState>(m, "GrGLBackendState", R"docstring(
    A GrContext's cache of backend context state can be partially invalidated.

    These enums are specific to the GL backend and we'd add a new set for an
    alternative backend.
    )docstring")
    .value("kRenderTarget_GrGLBackendState",
        GrGLBackendState::kRenderTarget_GrGLBackendState)
    .value("kTextureBinding_GrGLBackendState",
        GrGLBackendState::kTextureBinding_GrGLBackendState)
    .value("kView_GrGLBackendState",
        GrGLBackendState::kView_GrGLBackendState)
    ……
        GrGLBackendState::kPathRendering_GrGLBackendState)
    .value("kALL_GrGLBackendState",
        GrGLBackendState::kALL_GrGLBackendState)
    .export_values();

包装一个类:

, GrRecordingContext>(m, \"GrContext\")\n .def(\"resetContext\", &GrContext::resetContext,\n R\"docstring(\n The :py:class:`GrContext` normally assumes that no outsider is setting\n state within the underlying 3D API's context/device/whatever.\n\n This call informs the context that the state was modified and it should\n resend. Shouldn't be called frequently for good performance. The flag\n bits, state, is dependent on which backend is used by the context,\n either GL or D3D (possible in future).\n )docstring\",\n py::arg(\"state\") = kAll_GrBackendState)\n .def(\"resetGLTextureBindings\", &GrContext::resetGLTextureBindings,\n R\"docstring(\n If the backend is :py:attr:`~GrBackendApi.kOpenGL`, then all texture\n unit/target combinations for which the GrContext has modified the bound\n texture will have texture id 0 bound.\n\n This does not flush the :py:class:`GrContext`. Calling\n :py:meth:`resetContext` does not change the set that will be bound to\n texture id 0 on the next call to :py:meth:`resetGLTextureBindings`.\n After this is called all unit/target combinations are considered to have\n unmodified bindings until the :py:class:`GrContext` subsequently\n modifies them (meaning if this is called twice in a row with no\n intervening :py:class:`GrContext` usage then the second call is a\n no-op.)\n )docstring\")\n\n"}}" data-parsoid="{"dsr":[11467,13012,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
py::class_<GrContext, sk_sp<GrContext>, GrRecordingContext>(m, "GrContext")
    .def("resetContext", &GrContext::resetContext,
        R"docstring(
        The :py:class:`GrContext` normally assumes that no outsider is setting
        state within the underlying 3D API's context/device/whatever.

        This call informs the context that the state was modified and it should
        resend. Shouldn't be called frequently for good performance. The flag
        bits, state, is dependent on which backend is used by the context,
        either GL or D3D (possible in future).
        )docstring",
        py::arg("state") = kAll_GrBackendState)
    .def("resetGLTextureBindings", &GrContext::resetGLTextureBindings,
        R"docstring(
        If the backend is :py:attr:`~GrBackendApi.kOpenGL`, then all texture
        unit/target combinations for which the GrContext has modified the bound
        texture will have texture id 0 bound.

        This does not flush the :py:class:`GrContext`. Calling
        :py:meth:`resetContext` does not change the set that will be bound to
        texture id 0 on the next call to :py:meth:`resetGLTextureBindings`.
        After this is called all unit/target combinations are considered to have
        unmodified bindings until the :py:class:`GrContext` subsequently
        modifies them (meaning if this is called twice in a row with no
        intervening :py:class:`GrContext` usage then the second call is a
        no-op.)
        )docstring")

通过浏览 skia-python 封装的 API,我发现 Skia 有大量的接口我还没有用过,还有很多东西值得后续去挖掘。

maxiee/skia-python-demos

我建了一个 Github Demo 项目,后续会把我对 skia-python 更多的挖掘内容,变成 Demo 代码记录下来。

感兴趣的同学可关注项目地址 https://github.com/maxiee/skia-python-demos

如果对项目内容喜欢的话,欢迎 star 支持~

网络资源

skia-python