Skip to content
On this page

实战-渲染引擎 - Renderer


感谢厨神参与这篇文章:什么是渲染引擎、为什么需要渲染引擎以及 @antv/g 相关部分的写作!

之前在使用 SVG 开发一个条形图的过程中,我们发现有一些地方不方便。比如我们每次绘制一个元素,都需要三步:创建元素,设置元素属性,最后再挂载元素。

javascript
// 创建元素 
const rect = createSVGElement('rect'); 

// 设置属性 
rect.setAttribute('x', 10); 
rect.setAttribute('y', 10); 
rect.setAttribute('fill', 'red'); 
rect.setAttribute('width', 50); 
rect.setAttribute('height', 50); 

// 挂载元素 
g.appendChild('rect');

当画布中元素较少时,这种方式还可以忍受。但随着元素数量的增长,这会变得非常冗余和繁琐。所以我们需要开发一个非常简单和轻量级的渲染引擎(Renderer)  ,用它简化我们绘图的流程。

通过上图可以发现:Sparrow 将选择 SVG 而不是 Canvas2D 来作为绘图技术,这是因为 Sparrow 对性能没有要求,同时 SVG 相对于 Canvas2D 更好测试一点(SVG 有 DOM 结构,可以直接检查 DOM 来进行调试)。

接下来我们先从什么是渲染引擎讲起以及可视化需要它的原因,然后实现一个简单的渲染引擎,最后会简单拓展一下开源社区一些优秀的渲染引擎。

什么是渲染引擎

渲染引擎这一概念在不同领域有着不同的含义。对于前端开发者来数,渲染引擎是 WebKitBlink 这样的浏览器排版引擎(或者说是内核),它负责解析 HTML 和 CSS 文档,并决定了文档里的元素将以怎样的形式放置在页面中的什么位置(即排版)。

对于艺术和计算机动画工作者来说,渲染引擎是基础图形绘制库,一般具有以下特点:

  • 能够绘制基本图形,如:点、直线、多边形、曲线等
  • 支持图形内部填充、阴影效果等
  • 支持纹理与贴图
  • 抗锯齿以及亚像素优化
  • 跨平台运行

更高阶的渲染引擎甚至支持粒子系统、光线追踪等效果。

在数据可视化与可视分析领域,尤其是前端可视化方向,我们所使用的渲染引擎更偏向后者,但又有所区别。受平台及场景制约,前端可视化渲染引擎在具备上述特点的同时,还需要具备高性能、轻量化的特性,以满足在低网络传输带宽、低绘制性能等极端场景下的图形渲染需求。此外,在面向分析的可视化领域,3D 视图可能会导致意料之外的错误感知与洞察,因此 2D 渲染引擎得到了更大规模的应用。

为什么需要渲染引擎

用户大可直接在浏览器提供的 Canvas2D, SVG 和 WebGL 中使用原生语法直接绘制想要的图形,那么为什么还需要渲染引擎呢?包括上面提到的,这里给出几点原因:

  • 管理图元:使用渲染引擎能够更轻松的绘制并管理图形元素。
  • 提供完善的动画与事件机制:原声语法绘制动画相对比较麻烦。
  • 性能优化:渲染引擎基于底层渲染器的特性进行了大量优化工作,如脏矩阵渲染、分层渲染等,能够取得更好的渲染性能。使得开发者能够专注于视图的构建。
  • 多个渲染器之间任意切换:如果有同时在这两种渲染器中进行绘制的需求,需要针对不同的渲染器进行单独开发,提高工作量的同时也难以保证其一致性。使用渲染引擎绘制时只需要指定所需的渲染器即可完成切换。

现在我们从概念上简单聊了一下渲染引擎,就像我们一直强调的:具体的实战能帮助我们更好的理解概念,所以接下来我们就来开发 Sparrow 需要的渲染引擎。

功能设计

每一次开发都伴随着功能设计,它是我们接下来开发时候依据的蓝图。

因为 Sparrow 的功能相对简单,所以我们渲染器的功能用不复杂,主要侧重于更加轻松地绘制并且管理图形元素,简化我们绘制图形的流程。它主要有两个功能:

  • 绘制基本图形:支持 rectcirclelinepathtextring 这几种基本图形的绘制。
  • 进行坐标系变换:支持 translatescalerotate 这三种变换,同时可以使用类似 Canvas2D 的 save 和 restore 去管理坐标系变换的状态。

具体期望的使用方法如下:

javascript
import { createRenderer } from 'renderer'; 

// 创建渲染器
const renderer = createRenderer(600, 400);

// 绘制基本图形 
renderer.rect({
  x: 10, 
  y: 10, 
  width: 50, 
  height: 50, 
  fill: 'red', 
});

// 坐标变换 
renderer.save(); 
renderer.scale(2, 2); 
renderer.rect({ 
  x: 10, 
  y: 10, 
  width: 50, 
  height: 50 
}); 
renderer.restore();

在开始将我们的功能设计落地之前,我们再来看看额外的一个和测试相关的约定。

测试约定

本项目是测试驱动的,所以会有测试代码,但是不会在文章里面介绍它们。  这主要是因为是因为篇幅有限,同时不是小册子的核心内容。当然每当我们新增一个功能的时候,会给出测试代码的仓库地址,大家完成相应的功能可以用提供的测试代码来验证功能是否正确。

比如大家开发完成了 foo 这个函数,就可以增加以下的测试代码,然后运行:npx jest __tests__/hello.spec.js 看是否有问题。

javascript
// __tests__/hello.spec.js 

import { foo } from '../src/foo'; 

describe('test foo', () => { 
  it('should returns foo', () => { 
    expect(hello()).toBe('foo'); 
  }); 
});

同时我们在写代码的过程中不会完全遵循 airbnb-base 的规范,所以需要修改 .eslintrc.js 如下,关闭一些规则的校验。

javascript
// .eslintrc.js
module.exports = {
  env: {
    browser: true,
    es2021: true,
    node: true,
    jest: true,
  },
  extends: ['airbnb-base'],
  parserOptions: {
    ecmaVersion: 13,
    sourceType: 'module',
  },
  rules: {
    // 关闭 eslint 的如下功能
    'import/prefer-default-export': 0,
    'no-use-before-define': 0,
    'no-shadow': 0,
    'no-restricted-syntax': 0,
    'no-return-assign': 0,
    'no-param-reassign': 0,
    'no-sequences': 0,
    'no-loop-func': 0,
    'no-nested-ternary': 0,
  },
};

不小心又说了这么多废话,接下来我们直接进入开发!

创建渲染引擎(createRenderer)

我们首先来实现 createRenderer 这个函数来返回我们的渲染器对象。它的所有功能都是通过这个对象对外暴露的。根据上面的功能设计,我们不难得到以下的代码。

javascript
// src/renderer/renderer.js

import { createContext } from './context';
import {
  line, circle, text, rect, path, ring,
} from './shape';
import {
  restore, save, scale, translate, rotate,
} from './transform';

export function createRenderer(width, height) {
  const context = createContext(width, height); // 创建上下文信息
  return {
    line: (options) => line(context, options), 
    circle: (options) => circle(context, options),
    text: (options) => text(context, options),
    rect: (options) => rect(context, options),
    path: (options) => path(context, options),
    ring: (options) => ring(context, options), // 绘制圆环
    restore: () => restore(context),
    save: () => save(context),
    scale: (...args) => scale(context, ...args),
    rotate: (...args) => rotate(context, ...args),
    translate: (...args) => translate(context, ...args),
    node: () => context.node, // 下面会讲解
    group: () => context.group, // 下面会讲解
  };
}
javascript
// src/renderer/index.js

export { createRenderer } from './renderer';

通过上面的代码我们可以发现:在初始化一个渲染器的时候,首先会去创建一个上下文 ,然后再把它给其他函数使用。接下来我们先看看上下文的创建,之后再实现其他函数。

创建上下文(createContext)

对于渲染引擎来说,上下文(Context)主要用于保存一些绘制或者其他功能需要的全局的信息,比如挂载画布的容器,当前的填充颜色,边框粗细等。

对于 Sparrow 需要的渲染器来说,它需要的 Context 比较简单:

  • 画布节点:这是一个 svg 节点,方便使用者将其挂载到 DOM 需要的位置。
  • 挂载节点:这是一个 g 节点,是当前可以挂载新元素的节点。后面可以看到,我们可以通过更新它来到达管理坐标系变换的功能。

Context 的创建是由如下的 createContext(width, height) 这个函数实现的。

javascript
// src/renderer/context.js

import { createSVGElement, mount } from './utils';

export function createContext(width, height) {
  // 创建画布 svg 节点,并且设置宽高
  const svg = createSVGElement('svg');
  svg.setAttribute('width', width);
  svg.setAttribute('height', height);
  svg.setAttribute('viewBox', `0 0 ${width} ${height}`);

  // 创建挂载 g 节点,并且把该 g 节点挂载到 svg 节点上
  const g = createSVGElement('g');
  mount(svg, g);

  //返回画布节点和挂载节点
  return {
    node: svg,
    group: g,
  };
}
javascript
// src/renderer/utils.js

// 创建 SVG 元素
export function createSVGElement(type) {
  return document.createElementNS('http://www.w3.org/2000/svg', type);
}

// 将 child 节点挂载到 parent 节点上面
export function mount(parent, child) {
  if (parent) {
    parent.appendChild(child);
  }
}

这里复制测试代码到本地,通过 DEBUG_MODE=1 npx jest __tests__/renderer/renderer.spec.js 运行它们,如果能得到下面的效果,那么就没有什么问题了。

image.png

创建 Context 大概就是这样,接下来我们就看看如何基于这个 Context 去绘制基础图形。

绘制基本图形

像文章开头说的那样,在 SVG 环境下我们绘制一个基本图形需要三步:创建元素、设置属性和挂载元素。

因为绘制不同的图形只是在创建元素阶段指定不同的元素类型,所以我们把上面三步封装成一个通用的 shape 函数:

javascript
// src/renderer/shape.js

import { applyAttributes, createSVGElement, mount } from './utils';

export function shape(type, context, attributes) {
  const { group } = context; // 挂载元素
  const el = createSVGElement(type); // 创建对应的元素
  applyAttributes(el, attributes); // 设置属性

  mount(group, el); // 挂载
  return el; // 返回该元素

}
javascript
// src/renderer/utils.js

export function applyAttributes(element, attributes) {
  for (const [key, value] of Object.entries(attributes)) {
    // 这里需要把类似 strokeWidth 的属性转换成 stroke-width 的形式
    // 思路就是将大写字母替成 - + 对应的小写字母的形式
    // 下面涉及到正则匹配,不太了解的同学可以去下面的链接学习:
    // https://juejin.cn/post/6844903487155732494
    const kebabCaseKey = key.replace(/[A-Z]/g, (d) => `-${d.toLocaleLowerCase()}`);
    element.setAttribute(kebabCaseKey, value);
  }
}

当我们完成了 shape 函数之后,那么绘制 linerectcircle 等这些基本元素就非常容易了,只要给 shape 传入不同的元素的种类并且根据需要进行简单增强即可。

javascript
// src/renderer/shape.js

export function line(context, attributes) {
  return shape('line', context, attributes);
}

// rect 不支持 width 和 height 是负数,下面这种情况将绘制不出来
// <rect width="-60" height="-60" x="100" y="100" /> ❌
// 为了使其支持负数的 width 和 height,我们转换成如下的形式
// <rect width="60" height="60" x="40" y="40" /> ✅
export function rect(context, attributes) {
  const {
    width, height, x, y,
  } = attributes;

  return shape('rect', context, {
    ...attributes,
    width: Math.abs(width),
    height: Math.abs(height),
    x: width > 0 ? x : x + width,
    y: height > 0 ? y : y + height,
  });
}

export function circle(context, attributes) {
  return shape('circle', context, attributes);
}

// text 元素是将展示内容放在标签内部,而不是作为标签的属性
// <text text='content' /> ❌
// <text>content</text> ✅
export function text(context, attributes) {
  const { text, ...rest } = attributes;
  const textElement = shape('text', context, rest);
  textElement.textContent = text; // 通过 textContent 设置标签内的内容
  return textElement;
}

// 对 path 不熟悉的同学可以去这里学习
// https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Paths
// path 的属性 d (路径)是一个字符串,拼接起来比较麻烦,这里我们通过数组去生成
// [
//  ['M', 10, 10],
//  ['L', 100, 100],
//  ['L', 100, 10],
//  ['Z'],
// ];
// 上面的二维数组会被转换成如下的字符串
// 'M 10 10 L 100 100 L 100 10 Z'
export function path(context, attributes) {
  const { d } = attributes;
  return shape('path', context, { ...attributes, d: d.flat().join(' ') });
}

除了支持 SVG 本来就有的图形之外,我们还需要额外支持一个图形(后面 Dount 图表会使用):圆环(Ring)。

我们将用三个圆去模拟一个圆环,它们的填充色都是透明的,其中两个圆的边框去模拟圆环的边框(上面的红色部分),用一个圆的边框去模拟圆环本身(上面蓝色部分)。实现细节如下:

javascript
export function ring(context, attributes) {
  // r1 是内圆的半径,r2 是外圆的半径
  const {
    cx, cy, r1, r2, ...styles
  } = attributes;
  const { stroke, strokeWidth, fill } = styles;
  const defaultStrokeWidth = 1;
  const innerStroke = circle(context, {
    fill: 'transparent',
    stroke: stroke || fill,
    strokeWidth,
    cx,
    cy,
    r: r1,
  });
  const ring = circle(context, {
    ...styles,
    strokeWidth: r2 - r1 - (strokeWidth || defaultStrokeWidth),
    stroke: fill,
    fill: 'transparent',
    cx,
    cy,
    r: (r1 + r2) / 2,
  });
  const outerStroke = circle(context, {
    fill: 'transparent',
    stroke: stroke || fill,
    strokeWidth,
    cx,
    cy,
    r: r2,
  });
  return [innerStroke, ring, outerStroke];
}

同样复制这里的测试代码并且运行,如果运行测试代码能得到如下的效果,那么就也没有什么大问题了。

image.png

实现了绘制图形就这个功能,那么接下来我们就来完成坐标系变换这个功能。

坐标系变换

通过前面的学习,我们了解到:在 SVG 中使用坐标变换的能力其实就是给 g 元素添加对应的 transform 属性,然后被 g 元素包裹的所有子元素都会应用这个 transform 属性所指定的变换。

我们的目标让我们的渲染引擎支持:平移(translate)、放缩(Scale) 旋转(Rotate)这三种变换。这三种变换虽然名字和参数不同,但添加流程都是一样的,所以我们可以创建一个名叫 transform 的函数来统一这个流程。

javascript
// src/renderer/transform.js

import { applyTransform, createSVGElement, mount } from './utils';

export function transform(type, context, ...params) {
  // type 是希望的变换种类:scale,translate,rotate 等
  const { group } = context;
  applyTransform(group, `${type}(${params.join(', ')})`);
}
javascript
// src/renderer/utils.js

export function applyTransform(element, transform) {
  const oldTransform = element.getAttribute('transform') || '';
  // 将新的变换指定到后面的变换后,这里需要字符串拼接
  const prefix = oldTransform ? `${oldTransform} ` : '';
  element.setAttribute('transform', `${prefix}${transform}`);
}

这之后就不难实现如下的坐标系变换了。

javascript
// src/renderer/transform.js

export function translate(context, tx, ty) {
  transform('translate', context, tx, ty);
}

export function rotate(context, theta) {
  transform('rotate', context, theta);
}

export function scale(context, sx, sy) {
  transform('scale', context, sx, sy);
}

在使用坐标系变换的时候,除了应用对应变换之外,还应该实现对变换状态的管理。这个地方的核心就是控制当前变换影响的元素范围。基于 SVG 通过 g 元素来指定变换的特点,我们只用更新当前挂载节点,使得当前变换只会影响当前挂载节点下面的元素即可。

javascript
// src/renderer/transform.js

export function save(context) {
  const { group } = context;
  const newGroup = createSVGElement('g');
  mount(group, newGroup);
  context.group = newGroup;
}
javascript
// src/renderer/transform.js

export function restore(context) {
  const { group } = context;
  const { parentNode } = group;
  context.group = parentNode;
}

一切正常的话,运行这里的测试代码会有以下效果:

image.png

Sparrow 需要的渲染引擎我们已经完成啦,完整的代码可以在这里查看。

拓展

虽然上面渲染引擎的开发很简单,但是一个真正优秀的渲染引擎远不如此。接下来我们就来了解几个社区上优秀且强大的渲染引擎。

首先我们来看看 @antv/g ,它是 AntV 技术栈 G2G6 等成员的底层渲染引擎(最近 @antv/g 也发布了5.0,大家可以去了解一下!),它具有以下特点:

  • 强大、可扩展的渲染能力,并内置常用的基础图形。
  • 极致的渲染性能,支持大数据量的可视化场景。
  • 完整模拟浏览器 DOM 的事件,与原生事件的表现无差异。
  • 流畅的动画实现,以及丰富的配置接口。
  • 同时提供 Canvas 和 SVG 版本的实现,且两者的 API 基本保持一致。

用 @antv/g 绘制一个简单的红色的圆形的代码如下:

javascript
// 引入并选择渲染器
import { Renderer as CanvasRenderer } from '@antv/g-svg';
import { Canvas, Circle } from '@antv/g';


// 实例化渲染器
const canvasRenderer = new CanvasRenderer();

// 创建一个新的 G 画布
const canvas = new Canvas({
  container: 'container', // 画布的 DOM ID
  width: 500, // 画布宽度
  height: 500, // 画布高度
  renderer: canvasRenderer, // 渲染器实例化对象
});

// 创建一个圆形
const circle = new Circle({
  style: {
    x: 250,
    y: 250,
    r: 100,
    fill: 'red',
  },
});

// 绘制圆形
canvas.appendChild(circle);

如果说 @antv/g 更注重性能上的提升,那么p5.js 就更注重易用性。

p5.js 表面上说是一个面向艺术家、设计师、教育家、初学者以及任何其他人的创意编程库,但是它本身其实是一个渲染引擎。它不仅提供了一套完整,简洁的绘制接口,还对 HTML5 元素(如文字、输入框、视屏、摄像头及音频)的使用有支持。

下面我们同样来看看 p5.js 是如何绘制一个圆形的。

javascript
function setup() {
  createCanvas(400, 400);
}

function draw() {
  background(220);
  fill('red');
  circle(100, 100, 50, 50);
}

@antv/g 和 p5js 都是绘制常规风格元素的渲染引擎,rough.js 就不一样了:它绘制出来元素的效果都是手绘风格!

javascript
rc.circle(50, 50, 80, { fill: 'red' }); // fill with red hachure
rc.rectangle(120, 15, 80, 80, { fill: 'red' });
rc.circle(50, 150, 80, {
  fill: "rgb(10,150,10)",
  fillWeight: 3 // thicker lines for hachure
});
rc.rectangle(220, 15, 80, 80, {
  fill: 'red',
  hachureAngle: 60, // angle of hachure,
  hachureGap: 8
});
rc.rectangle(120, 105, 80, 80, {
  fill: 'rgba(255,0,200,0.2)',
  fillStyle: 'solid' // solid fill
});

上面的代码绘制出来的效果如下:

68747470733a2f2f726f7567686a732e636f6d2f696d616765732f6d332e706e67.png

作业

其实基于 roughjs 我们可以做出很多有意思的效果,比如我们可以基于 roughjs 封装和我们上面渲染器相同的 API 的手绘风格渲染器。这样当后面我们把 Sparrow 开发完成之后,我们只用修改渲染器就可以转换图表的绘制风格了。

javascript
import { plot } from "@sparrow-vis/sparrow";
import { createPlugin } from "@sparrow-vis/rough-renderer"

const data = [
  { genre: "Sports", sold: 275 },
  { genre: "Strategy", sold: 115 },
  { genre: "Action", sold: 120 },
  { genre: "Shooter", sold: 350 },
  { genre: "Other", sold: 150 },
];

const chart = plot({
  data,
  type: "interval",
  renderer: createPlugin(), // 使用自定义的渲染器
  encodings: {
    x: "genre",
    y: "sold",
    fill: "genre"
  },
});

document.getElementById("container").appendChild(chart);

这个作为作业大家可以自己尝试实现一下,也可以参考官方的实现

小结

这一章我们了解了什么是渲染引擎,并且也知道了它想要解决的问题,了解到一个比较完整的渲染引擎应该具有以下特点:

  • 轻松绘制和管理图形元素
  • 优秀的渲染性能
  • 对动画有比较好的支持
  • 兼容不同渲染技术
  • 完善的事件机制

这之后我们通过开发一个简单的渲染引擎,完成了绘制元素和坐标变换这两个功能。不仅加深了对渲染引擎的理解,也为后面 Sparrow 的更加轻松地绘制图形打下基础。

那么下一篇我们将进入第一个可视化模块的开发:比例尺(Scale),看看它是如何把数据属性映射为视觉属性的。

最后的最后,这章我们算是正式进入 Sparrow 的开发了!