《跟月影学可视化》学习笔记


前言

Web数据可视化学习笔记


一、基础

01 实现可视化的四种方式

  1. HTML+CSS,这种方式通常用来呈现普通的 Web 网页。
  2. SVG,HTML 元素在绘制矢量图形方面的能力有些不足,而 SVG 恰好弥补了这方面的缺陷。
  3. Canvas2D。这是浏览器提供的 Canvas API 中的其中一种上下文,使用它可以非常方便地绘制出基础的几何图形。在可视化中,Canvas 比较常用。
  4. WebGL,这是浏览器提供的 Canvas API 中的另一种上下文,它是 OpenGL ES 规范在 Web 端的实现。可以通过它,用 GPU 渲染各种复杂的 2D 和 3D 图形。性能大大优于前 3 种绘图方式。一些数据量大、视觉效果要求高的特殊场景,使用 WebGL 渲染是一种比较合适的选择。

实现方式对比:
渲染方式对比方案选择图:
可视化选型

指令式绘图:用Canvas绘制层次关系图

注意:Canvas 元素上的 width 和 height 属性不等同于 Canvas 元素的 CSS 样式的属性。这是因为,CSS 属性中的宽高影响 Canvas 在页面上呈现的大小,而 HTML 属性中的宽高则决定了 Canvas 的坐标系。为了区分它们,我们称 Canvas 的 HTML 属性宽高为画布宽高,CSS 样式宽高为样式宽高。如果不设置 Canvas 元素的样式,那么画布宽高就会等于它的样式宽高的像素值。
因为画布宽高决定了可视区域的坐标范围,所以 Canvas 将画布宽高和样式宽高分开的做法,能更方便地适配不同的显示设备。

  • Canvas 坐标系:
    Canvas 的坐标系和浏览器窗口的坐标系类似,它们都默认左上角为坐标原点,x 轴水平向右,y 轴垂直向下。
    旋转或者三维运动,这个坐标系就会变成“左手系”:https://zhuanlan.zhihu.com/p/64707259
  • 用 Canvas 上下文绘制图形步骤:
  1. 获取 Canvas 对象,通过 getContext(‘2d’) 得到 2D 上下文;
  2. 设置绘图状态,比如填充颜色 fillStyle,平移变换 translate 等等;
  3. 调用 beginPath 指令开始绘制图形;
  4. 调用绘图指令,比如 rect,表示绘制矩形;
  5. 调用 fill 指令,将绘制内容真正输出到画布上。

context 对象上会有许多 API,它们大体上可以分为两类:一类是设置状态的 API,可以设置或改变当前的绘图状态,比如,改变要绘制图形的颜色、线宽、坐标变换等等;另一类是绘制指令 API,用来绘制不同形状的几何图形。

指令式绘图:用 SVG 绘制层次关系图

描述 SVG 的 XML 语言本身和 HTML 非常接近,都是由标签 + 属性构成的,而且浏览器的 CSS、JavaScript 都能够正常作用于 SVG 元素。

可以通过给 svg 元素设置 viewBox 属性,来改变 SVG 的坐标系。如果设置了 viewBox 属性,那 SVG 内部的绘制就都是相对于 SVG 坐标系的了。

  • SVG 绘图过程:
    1. SVG 元素要使用 document.createElementNS 方法来创建。其中,第一个参数是名字空间,对应 SVG 名字空间 http://www.w3.org/2000/svg。第二个参数是要创建的元素标签名,因为要绘制圆型,所以我们还是创建 circle 元素。然后我们将 x、y、r 分别赋给 circle 元素的 cx、cy、r 属性,将 fillStyle 赋给 circle 元素的 fill 属性。最后,我们将 circle 元素添加到它的 parent 元素上去。
    2. 遍历下一级数据。创建一个 SVG 的 g 元素,递归地调用 draw 方法。SVG 的 g 元素表示一个分组,用它来对 SVG 元素建立起层级结构。而且,给 g 元素设置属性,那么它的子元素会继承这些属性。
    3. 如果下一级没有数据了,那我们还是需要给它添加文字。在 SVG 中添加文字,只需要创建 text 元素。
      在这里插入图片描述
  const dataSource = 'https://s5.ssl.qhres.com/static/b0695e2dd30daa64.json'
  const data = await (await fetch(dataSource)).json()
  const regions = d3
    .hierarchy(data)
    .sum((d) => 1)
    .sort((a, b) => b.value - a.value)
  const pack = d3.pack().size([1600, 1600]).padding(3)
  const root = pack(regions)
  
  function svgDraw(parent, node, { fillStyle = 'rgba(0, 0, 0, 0.2)', textColor = 'white' } = {}) {
    const children = node.children
    const { x, y, r } = node
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
    circle.setAttribute('cx', x)
    circle.setAttribute('cy', y)
    circle.setAttribute('r', r)
    circle.setAttribute('fill', fillStyle)
    circle.setAttribute('data-name', node.data.name)
    parent.appendChild(circle)
    if (children) {
      const group = document.createElementNS('http://www.w3.org/2000/svg', 'g')
      for (let i = 0; i < children.length; i++) {
        svgDraw(group, children[i], { fillStyle, textColor })
      }
      group.setAttribute('data-name', node.data.name)
      parent.appendChild(group)
    } else {
      const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
      text.setAttribute('fill', textColor)
      text.setAttribute('font-family', 'Arial')
      text.setAttribute('font-size', '1.5rem')
      text.setAttribute('text-anchor', 'middle')
      text.setAttribute('x', x)
      text.setAttribute('y', y)
      const name = node.data.name
      text.textContent = name
      parent.appendChild(text)
    }
  }

  svgDraw(svgroot, root)

SVG 与 Canvas 绘图对比

  1. 绘制方式不同
    SVG 首先通过创建标签来表示图形元素,circle 表示圆,g 表示分组,text 表示文字。接着,SVG 通过元素的 setAttribute 给图形元素赋属性值,这个和操作 HTML 元素是一样的。而 Canvas 先是通过上下文执行绘图指令来绘制图形,画圆是调用 context.arc 指令,然后再调用 context.fill 绘制,画文字是调用 context.fillText 指令。另外,Canvas 还通过上下文设置状态属性,context.fillStyle 设置填充颜色,conext.font 设置元素的字体。我们设置的这些状态,在绘图指令执行时才会生效。SVG 图形需要由浏览器负责渲染和管理,将元素节点维护在 DOM 树中。这样做的缺点是,在一些动态的场景中,也就是需要频繁地增加、删除图形元素的场景中,SVG 与一般的 HTML 元素一样会带来 DOM 操作的开销,所以 SVG 的渲染性能相对比较低。
    对于 SVG 的性能问题,也是有解决方案的。比如说,可以使用虚拟 DOM 方案来尽可能地减少重绘,这样就可以优化 SVG 的渲染。但是这些方案只能解决一部分问题,当节点数太多时,这些方案也无能为力。还是得依靠 Canvas 和 WebGL 来绘图,才能彻底解决问题。

  2. 交互方式不同
    利用 SVG 的一个图形对应一个 svg 元素的机制,我们就可以像操作普通的 HTML 元素那样,给 svg 元素添加事件实现用户交互。所以,SVG 有一个非常大的优点,那就是可以让图形的用户交互非常简单。

GPU与渲染管线:用WebGL绘制简单几何图形

  1. 通用计算机图形系统:
    在这里插入图片描述2. 概念
    光栅(Raster):几乎所有的现代图形系统都是基于光栅来绘制图形的,光栅就是指构成图像的像素阵列。
    像素(Pixel):一个像素对应图像上的一个点,它通常保存图像上的某个具体位置的颜色等信息。

数据经过 CPU 处理,成为具有特定结构的几何信息。然后,这些信息会被送到 GPU 中进行处理。在 GPU 中要经过两个步骤生成光栅信息。这些光栅信息会输出到帧缓存中,最后渲染到屏幕上。
在这里插入图片描述渲染管线(RenderPipelines)
一是对给定的数据结合绘图的场景要素(例如相机、光源、遮挡物体等等)进行计算,最终将图形变为屏幕空间的 2D 坐标。二是为屏幕空间的每个像素点进行着色,把最终完成的图形输出到显示设备上。这整个过程是一步一步进行的,前一步的输出就是后一步的输入,所以也把这个过程叫做渲染管线(RenderPipelines)。直译(pipe管子line线路),其实是指三维渲染的过程中显卡执行的、从几何体到最终渲染图像的、数据传输处理计算的过程。

光栅化:就是把顶点数据转换为片元的过程。片元中的每一个元素对应于帧缓冲区中的一个像素。
光栅化其实是一种将几何图元变为二维图像的过程。该过程包含了两部分的工作。第一部分工作:决定窗口坐标中的哪些整型栅格区域被基本图元占用;第二部分工作:分配一个颜色值和一个深度值到各个区域。光栅化过程产生的是片元。
把物体的数学描述以及与物体相关的颜色信息转换为屏幕上用于对应位置的像素及用于填充像素的颜色,这个过程称为光栅化,这是一个将模拟信号转化为离散信号的过程。
在这里插入图片描述

着色器(Shader)
通常着色器分两种:

  1. 顶点着色器(vertex shader)这个是告诉电脑如何打线稿的——如何处理顶点、法线等的数据的小程序。
  2. 片元着色器(fragment shader)这个是告诉电脑如何上色的——如何处理光、阴影、遮挡、环境等等对物体表面的影响,最终生成一副图像的小程序。采用了这两种着色器小程序 的 数据传输处理计算的渲染过程,称之为 可编程管线。

顶点着色器的作用是计算顶点的位置。根据计算出的一系列顶点位置,WebGL可以对点, 线和三角形在内的一些图元进行光栅化处理。当对这些图元进行光栅化处理时需要使用片断着色器方法。 片断着色器的作用是计算出当前绘制图元中每个像素的颜色值。

使用 WebGL 绘制图像
浏览器提供的 WebGL API 是 OpenGL ES 的 JavaScript 绑定版本,它赋予了开发者操作 GPU 的能力。WebGL只关心两件事:裁剪空间中的坐标值和颜色值。使用WebGL只需要给它提供这两个东西。 你需要提供两个着色器来做这两件事,一个顶点着色器提供裁剪空间坐标值,一个片断着色器提供颜色值。

总结为以下 5 个步骤:

  1. 创建 WebGL 上下文
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');
  1. 创建 WebGL 程序(WebGL Program)
    WebGL 是以顶点和图元来描述图形几何信息的。顶点就是几何图形的顶点,比如,三角形有三个顶点,四边形有四个顶点。图元是 WebGL 可直接处理的图形单元,由 WebGL 的绘图模式决定,有点、线、三角形等等。
    WebGL 绘制一个图形的过程,一般需要用到两段着色器,一段叫顶点着色器(Vertex Shader)负责处理图形的顶点信息,另一段叫片元着色器(Fragment Shader)负责处理图形的像素信息。
    WebGL 从顶点着色器和图元提取像素点给片元着色器执行代码的过程,就是我们前面说的生成光栅信息的过程,我们也叫它光栅化过程。所以,片元着色器的作用,就是处理光栅化后的像素信息。
    顶点着色器主要任务就是把传入的顶点转化成裁剪后的坐标值发送到GPU的光栅化模块中。有几个顶点,顶点着色器就会执行几次。
    如果我们要画一个三角形,光栅化就是把顶点着色器传进来的三个顶点组成的三角形用像素画出来,有多少个像素,片元着色器就会执行多少遍,每个像素的颜色需要片元着色器输出的gl_FragColor来决定。
  2. 将数据存入缓冲区
const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);
  1. 将缓冲区数据读取到 GPU
  2. GPU 执行 WebGL 程序,输出结果
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);

写一个WebGL程序应该也只需要三步:1、把数据放入缓冲区,2、把缓冲区的数据给着色器,3、着色器把数据给GPU。
在这里插入图片描述THREE.js和BABYLON.js等很多框架封装了WebGL,提供了各个平台之间的兼容性。使用这些框架而非原生的WebGL可以更容易地开发3D应用和游戏。

在这里插入图片描述

export default function webGLDraw() {
  const canvas = document.querySelector('.canvas_webgl')
  const gl = canvas.getContext('webgl')
  // WebGL 就会根据顶点和绘图模式指定的图元,计算出需要着色的像素点,然后对它们执行片元着色器程序。

  //顶点着色器(Vertex Shader)
  const vertex = ` 
    attribute vec2 position;
    varying vec3 color;
    void main() {
      gl_PointSize = 1.0;
      color = vec3(0.5 + position * 0.5, 0.0);
      gl_Position = vec4(position * 0.5, 1.0, 1.0);
    }
    `
  // 片元着色器(Fragment Shader)
  const fragment = `
    precision mediump float;
    varying vec3 color;
    void main() {
        gl_FragColor = vec4(color, 1.0);
    }
    `
  const vertexShader = gl.createShader(gl.VERTEX_SHADER)
  gl.shaderSource(vertexShader, vertex)
  gl.compileShader(vertexShader)

  const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
  gl.shaderSource(fragmentShader, fragment)
  gl.compileShader(fragmentShader)

  const program = gl.createProgram()
  gl.attachShader(program, vertexShader)
  gl.attachShader(program, fragmentShader)
  gl.linkProgram(program)
  gl.useProgram(program)

  // 定义这个三角形的三个顶点
  const points = new Float32Array([-1, -1, 0, 1, 1, -1])

  // 将定义好的数据写入 WebGL 的缓冲区
  const bufferId = gl.createBuffer()
  gl.bindBuffer(gl.ARRAY_BUFFER, bufferId)
  gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW)

  const vPosition = gl.getAttribLocation(program, 'position') //获取顶点着色器中的position变量的地址
  gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0) //给变量设置长度和类型
  gl.enableVertexAttribArray(vPosition) //激活这个变量

  // 先调用 gl.clear 将当前画布的内容清除,然后调用 gl.drawArrays 传入绘制模式。
  // gl.TRIANGLES 表示以三角形为图元绘制,再传入绘制的顶点偏移量和顶点数量,
  // WebGL 就会将对应的 buffer 数组传给顶点着色器,并且开始绘制
  gl.clear(gl.COLOR_BUFFER_BIT)
  gl.drawArrays(gl.TRIANGLES, 0, points.length / 2)
}

参考:

  • https://juejin.cn/post/6844904112157360136
  • MDN:https://developer.mozilla.org/zh-CN/docs/Web/API/WebGL_API/Tutorial
  • Webgl Fundamentals: https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-fundamentals.html

数学篇

用向量和坐标系描述点和线段

建立一套描述几何图形信息的数学体系,以及如何用这个体系来解决我们的可视化图形呈现的问题。

  • 坐标系与坐标映射

    1. 首先,我们来看看浏览器的四个图形系统通用的坐标系分别是什么样的。HTML 采用的是窗口坐标系,以参考对象(参考对象通常是最接近图形元素的 position 非 static 的元素)的元素盒子左上角为坐标原点,x 轴向右,y 轴向下,坐标值对应像素值。
    2. SVG 采用的是视区盒子(viewBox)坐标系。这个坐标系在默认情况下,是以 svg 根元素左上角为坐标原点,x 轴向右,y 轴向下,svg 根元素右下角坐标为它的像素宽高值。如果我们设置了 viewBox 属性,那么 svg 根元素左上角为 viewBox 的前两个值,右下角为 viewBox 的后两个值。
    3. Canvas 默认以画布左上角为坐标原点,右下角坐标值为 Canvas 的画布宽高值。
    4. WebGL 的坐标系比较特殊,是一个三维坐标系。它默认以画布正中间为坐标原点,x 轴朝右,y 轴朝上,z 轴朝外,x 轴、y 轴在画布中范围是 -1 到 1。
  • 用 Canvas 实现坐标系转换

假设,我们要在宽 512 * 高 256 的一个 Canvas 画布上实现如下的视觉效果。其中,山的高度是 100,底边 200,两座山的中心位置到中线的距离都是 80,太阳的圆心高度是 150。

  1. 在不转换坐标系的情况下把图形绘制出来
    需要计算每个点的坐标并绘制,绘制的代码如下所示:
const rc = rough.canvas(document.querySelector('canvas'));
const hillOpts = {roughness: 2.8, strokeWidth: 2, fill: 'blue'};
rc.path('M76 256L176 156L276 256', hillOpts);
rc.path('M236 256L336 156L436 256', hillOpts);
rc.circle(256, 106, 105, {  
  stroke: 'red',  
  strokeWidth: 4,  
  fill: 'rgba(255, 255, 0, 0.4)',  
  fillStyle: 'solid',
});
  1. 通过对坐标系转换绘制
    如果每次绘制都要花费时间在坐标换算上,这会非常不方便。所以,为了解决这个问题,我们可以采用坐标系变换来代替坐标换算。
  const rc = rough.canvas(document.querySelector('.canvas_t'))
  const ctx = rc.ctx
  ctx.translate(256, 256)
  ctx.scale(1, -1)

  const hillOpts = { roughness: 2.8, strokeWidth: 2, fill: 'blue' }
  rc.path('M-180 0L-80 100L20 0', hillOpts)
  rc.path('M-20 0L80 100L180 0', hillOpts)
  rc.circle(0, 150, 105, {
    stroke: 'red',
    strokeWidth: 4,
    fill: 'rgba(255,255, 0, 0.4)',
    fillStyle: 'solid'
  })

在这里插入图片描述

采用坐标变换的方式能够简化计算量,这不仅让代码更容易理解,也可以节省 CPU 运算的时间。

实战演练:用向量绘制一棵树

export class Vector2D extends Array {
  constructor(x = 1, y = 0) {
    super(x, y)
  }
  ......
}
function drawBranch(context, v0, length, thickness, dir, bias) {
  const v = new Vector2D().rotate(dir).scale(length)
  const v1 = v0.copy().add(v)

  context.lineWidth = thickness
  context.beginPath()
  context.moveTo(...v0)
  context.lineTo(...v1)
  context.stroke()

  if (thickness > 2) {
    const left = Math.PI / 4 + 0.5 * (dir + 0.2) + bias * (Math.random() - 0.5)
    drawBranch(context, v1, length * 0.9, thickness * 0.8, left, bias * 0.9)
    const right = Math.PI / 4 + 0.5 * (dir - 0.2) + bias * (Math.random() - 0.5)
    drawBranch(context, v1, length * 0.9, thickness * 0.8, right, bias * 0.9)
  }

  if (thickness < 5 && Math.random() < 0.3) {
    context.save()
    context.strokeStyle = '#c72c35'
    const th = Math.random() * 6 + 3
    context.lineWidth = th
    context.beginPath()
    context.moveTo(...v1)
    context.lineTo(v1.x, v1.y - 2)
    context.stroke()
    context.restore()
  }
}

export default function drawTree() {
  const v0 = new Vector2D(256, 0)
  drawBranch(ctx, v0, 50, 10, 1, 3)
}

在这里插入图片描述
向量的点乘
a、b 向量点积的几何含义,是 a 向量乘以 b 向量在 a 向量上的投影分量。它的物理含义相当于 a 力作用于物体,产生 b 位移所做的功。
在这里插入图片描述向量的叉乘
叉乘的几何意义是向量 a 和向量 b 构成的平行四边形的面积,物理意义是力产生的力矩。把向量归一化以后,可以通过向量的点乘与叉乘快速求出向量夹角的正弦和余弦值。
叉乘和点乘有两点不同:首先,向量叉乘运算的结果不是标量,而是一个向量;其次,两个向量的叉积与两个向量组成的坐标平面垂直。
归一化就是让向量 v0除以它的长度(或者说是模)。归一化后的向量方向不变,长度为 1。归一化是向量运算中一个非常重要的操作,用处也非常多。比如说,在向量乘法里,如果 a、b 都是长度为 1 的归一化向量,那么|a X b| 的结果就是 a、b 夹角的正弦值,而|a • b|的结果就是 a、b 夹角的余弦值。这个特性在图形学里用处非常大,一定要记住它。

判断点在不在扫描器范围内:

  1. 点在扫描范围内,如向量 a,就一定满足: |a X v| <= ||a||v|sin(30°)| = |sin(30°)| = 0.5;
  2. 点不在扫描范围内,如向量 b,就一定满足:|b X v| > ||b||v|sin(30°)| = |sin(30°)| = 0.5。
    在这里插入图片描述
const isInRange = Math.abs(new Vec2(0, 1).cross(v0.normalize())) <= 0.5; // v0.normalize()即将v0归一化

用向量和参数方程描述曲线

曲线是图形系统的基本元素之一,它可以构成几何图形的边,也可以描述点和几何体的运动轨迹,还可以控制像素属性的变化。不论我们用什么图形系统绘图,图形的呈现都离不开曲线。

  1. 用向量描述曲线
    用向量绘制折线的方法来绘制正多边形,给定边数 edges、起点 x, y、一条边的长度 step,就可以绘制一个正多边形了。我们定义一个函数 regularShape,代码如下:
function regularShape(edges = 3, x, y, step) {
  const ret = [];
  const delta = Math.PI * (1 - (edges - 2) / edges);
  let p = new Vector2D(x, y);
  const dir = new Vector2D(step, 0);
  ret.push(p);
  for(let i = 0; i < edges; i++) {
    p = p.copy().add(dir.rotate(delta));
    ret.push(p);
  }
  return ret;
}

function draw(points, strokeStyle = 'black', fillStyle = null) {
  ctx.strokeStyle = strokeStyle
  ctx.beginPath()
  ctx.moveTo(...points[0])
  for (let i = 1; i < points.length; i++) {
    ctx.lineTo(...points[i])
  }
  ctx.closePath()
  if (fillStyle) {
    ctx.fillStyle = fillStyle
    ctx.fill()
  }
  ctx.stroke()
}
draw(regularShape(3, 128, 128, 100));  // 绘制三角形 
draw(regularShape(6, -64, 128, 50));  // 绘制六边形 
draw(regularShape(11, -64, -64, 30));  // 绘制十一边形 
draw(regularShape(60, 128, -64, 6));  // 绘制六十边形 

在这里插入图片描述
2. 用参数方程描述曲线

画圆
首先,圆可以用一组参数方程来定义。如下所示的参数方程,定义了一个圆心在(x0,y0),半径为 r 的圆。
在这里插入图片描述其他常见曲线
如果为每一种曲线都分别对应实现一个函数,就会非常笨拙和繁琐。可以用函数式的编程思想,封装一个更简单的 JavaScript 参数方程绘图模块,以此来绘制出不同的曲线。

function draw(points, context, {
  strokeStyle = 'black',
  fillStyle = null,
  close = false,
} = {}) {
  context.strokeStyle = strokeStyle;
  context.beginPath();
  context.moveTo(...points[0]);
  for(let i = 1; i < points.length; i++) {
    context.lineTo(...points[i]);
  }
  if(close) context.closePath();
  if(fillStyle) {
    context.fillStyle = fillStyle;
    context.fill();
  }
  context.stroke();
}
export function parametric(sFunc, tFunc, rFunc) {
  return function (start, end, seg = 100, ...args) {
    const points = [];
    for(let i = 0; i <= seg; i++) {
      const p = i / seg;
      const t = start * (1 - p) + end * p;
      const x = sFunc(t, ...args);
      const y = tFunc(t, ...args);
      if(rFunc) {
        points.push(rFunc(x, y));
      } else {
        points.push([x, y]);
      }
    }
    return {
      draw: draw.bind(null, points),
      points,
    };
  };
}

贝塞尔曲线
前面都是符合某种固定数学规律的曲线。但生活中还有很多不规则的图形,无法用上面这些规律的曲线去描述。贝塞尔曲线(Bezier Curves)就是最常见的一种解决方式。它在可视化领域中也是一类非常常用的曲线,它通过起点、终点和少量控制点,就能定义参数方程来生成复杂的平滑曲线,所以它通常被用来构建数据信息之间连接线。

二阶贝塞尔曲线:在这里插入图片描述
三阶贝塞尔曲线:
在这里插入图片描述在这里插入图片描述三阶贝塞尔曲线控制点比二阶贝塞尔曲线多,曲线能够模拟出更多不同的形状,也能更精确地控制细节。在上面绘制了 30 个三阶贝塞尔曲线,它们的起点都为 (0,0),终点均匀分布在半径 200 的圆上,控制点 1 均匀分布在半径为 100 的圆上,控制点 2 均匀分布半径 150 的圆上。与二阶贝塞尔曲线相比,控制得更细致,形成的图案信息更丰富。

参考:http://math001.com/bezier_curve/

利用三角剖分和向量操作描述并处理多边形

在图形系统中,我们最终看到的丰富多彩的图像,都是由多边形构成的。换句话说,不论是 2D 图形还是 3D 图形,经过投影变换后,在屏幕上输出的都是多边形。因此,理解多边形的基本性质,了解用数学语言描述并且处理多边形的方法,是我们在可视化中必须要掌握的内容。

多边形又可以分为简单多边形复杂多边形。我们该怎么区分它们呢?如果一个多边形的每条边除了相邻的边以外,不和其他边相交,那它就是简单多边形,否则就是复杂多边形。
在这里插入图片描述Canvas2D 填充多边形的方法

function draw(context, points, {
  fillStyle = 'black',
  close = false,
  rule = 'nonzero',
} = {}) {
  context.beginPath();
  context.moveTo(...points[0]);
  for(let i = 1; i < points.length; i++) {
    context.lineTo(...points[i]);
  }
  if(close) context.closePath();
  context.fillStyle = fillStyle;
  context.fill(rule);
}

WebGL 如何填充多边形
在 WebGL 中没有提供自动填充多边形的方法,可以用三角形这种基本图元来快速地填充多边形。因此,在 WebGL 中填充多边形的第一步,就是将多边形分割成多个三角形。这种将多边形分割成若干个三角形的操作,在图形学中叫做三角剖分(Triangulation)。
直接利用 GitHub 上的一些成熟的库(常用的如Earcut、Tess2.js以及cdt2d),来对多边形进行三角剖分就可以了。 tess2.js 是一个比 Earcut 更强大的三角剖分库,使用 tess2.js 可以像原生的 Canvas2D 的 fill 方法那样,实现 evenodd 的填充规则。
针对 3D 模型,WebGL 在绘制的时候,也需要使用三角剖分,而 3D 的三角剖分又被称为网格化(Meshing)。不过,因为 3D 模型比 2D 模型更加复杂,顶点的数量更多,所以针对复杂的 3D 模型,我们一般不在运行的时候进行三角剖分,而是通过设计工具把图形的三角剖分结果直接导出进行使用。也就是说,在 3D 渲染的时候,一般使用的模型数据都是已经经过三角剖分以后的顶点数据

实现通用的 isPointInPath 方法
不使用 Canvas 的 isPointInPath 方法,而是直接通过点与几何图形的数学关系来判断点是否在图形内。可以把视线放在最简单的多边形,也就是三角形上。因为对于三角形来说,我们有一个非常简单的方法可以判断点是否在其中。这个方法就是,已知一个三角形的三条边分别是向量 a、b、c,平面上一点 u 连接三角形三个顶点的向量分别为 u1、u2、u3,那么 u 点在三角形内部的充分必要条件是:u1 X a、u2 X b、u3 X c 的符号相同。如果要判断一个点是否在任意多边形的内部,我们只需要在判断之前将它进行三角剖分就可以了。
在这里插入图片描述三角刨分参考:http://www.ae.metu.edu.tr/tuncer/ae546/prj/delaunay/

用仿射变换对几何图形进行坐标变换

经常需要在画布上绘制许多轮廓相同的图形,难道这也需要我们重复地去计算每个图形的顶点吗?当然不需要。我们只需要创建一个基本的几何轮廓,然后通过仿射变换来改变几何图形的位置、形状、大小和角度。

仿射变换简单来说就是“线性变换 + 平移”。实际上在平常的 Web 开发中,我们也经常会用到仿射变换,比如,对元素设置 CSS 的 transform 属性就是对元素应用仿射变换。

向量的平移、旋转与缩放

平移变换是最简单的仿射变换。如果我们想让向量 P(x0, y0) 沿着向量 Q(x1, y1) 平移,只要将 P 和 Q 相加就可以了。
在这里插入图片描述旋转变换
在这里插入图片描述矩阵表示
在这里插入图片描述缩放变换
在这里插入图片描述能写成矩阵与向量相乘形式的变换,就叫做线性变换。线性变换可以叠加,多个线性变换的叠加结果就是将线性变换的矩阵依次相乘,再与原始向量相乘。总结出一个通用的线性变换公式,即一个原始向量 P0经过 M1、M2、…Mn 次的线性变换之后得到最终的坐标 P。线性变化的叠加是一个非常重要的性质,是对图形进行变换的基础。
在这里插入图片描述向量的基本仿射变换分为平移、旋转与缩放,其中旋转与缩放属于线性变换,而平移不属于线性变换。基于此,我们可以得到仿射变换的一般表达式,如下图所示:
在这里插入图片描述仿射变换的公式优化
上面这个公式我们还可以改写成矩阵的形式,在改写的公式里,我们实际上是给线性空间增加了一个维度。换句话说,我们用高维度的线性变换表示了低维度的仿射变换!
在这里插入图片描述将原本 n 维的坐标转换为了 n+1 维的坐标。这种 n+1 维坐标被称为齐次坐标,对应的矩阵就被称为齐次矩阵。

仿射变换的应用:实现粒子动画
在粒子动画的实现过程中,需要在界面上快速改变一大批图形的大小、形状和位置,所以用图形的仿射变换来实现是一个很好的方法。
在这里插入图片描述
数学知识
在这里插入图片描述

二、视觉基础

颜色表示

四种基本的颜色表示法,分别是 RGB 和 RGBA 颜色表示法、HSL 和 HSV 颜色表示法、CIE Lab 和 CIE Lch 颜色表示法以及 Cubehelix 色盘。动态构建视觉颜色效果的时候,我们很少直接选用 RGB 色值,而是使用其他的颜色表示形式。这其中,比较常用的就是 HSL 和 HSV 颜色表示形式

RGB 颜色表示法的局限性
因为对一个 RGB 颜色来说,我们只能大致直观地判断出它偏向于红色、绿色还是蓝色,或者在颜色立方体的大致位置。所以,在对比两个 RGB 颜色的时候,我们只能通过对比它们在 RGB 立方体中的相对距离,来判断它们的颜色差异。除此之外,我们几乎就得不到其他任何有用的信息了。也就是说,当要选择一组颜色给图表使用时,我们并不知道要以什么样的规则来配置颜色,才能让不同数据对应的图形之间的对比尽可能鲜明。
在这里插入图片描述
HSL 和 HSV 颜色
与 RGB 颜色以色阶表示颜色不同,HSL 和 HSV 用色相(Hue)、饱和度(Saturation)和亮度(Lightness)或明度(Value)来表示颜色。其中,Hue 是角度,取值范围是 0 到 360 度,饱和度和亮度 / 明度的值都是从 0 到 100%。可以把 HSL 和 HSV 颜色理解为,是将 RGB 颜色的立方体从直角坐标系投影到极坐标的圆柱上,所以它的色值和 RGB 色值是一一对应的。
在这里插入图片描述HSL 和 HSV 的局限性,先看第一排圆你会发现,虽然它们的色相相差都是 15,但是相互之间颜色变化并不是均匀的,尤其是中间几个绿色圆的颜色比较接近。接着我们再看第二排圆,虽然这些圆的亮度都是 50%,但是蓝色和紫色的圆看起来就是不如偏绿偏黄的圆亮。这都是由于人眼对不同频率的光的敏感度不同造成的。
在这里插入图片描述HSL 依然不是最完美的颜色方法,我们还需要建立一套针对人类知觉的标准,这个标准在描述颜色的时候要尽可能地满足以下 2 个原则:

  1. 人眼看到的色差 = 颜色向量间的欧氏距离
  2. 相同的亮度,能让人感觉亮度相同

于是,一个针对人类感觉的颜色描述方式就产生了,它就是 CIE Lab。

CIE Lab 和 CIE Lch 颜色
CIE Lab 颜色空间简称 Lab,它其实就是一种符合人类感觉的色彩空间,它用 L 表示亮度,a 和 b 表示颜色对立度。RGB 值也可以 Lab 转换,但是转换规则比较复杂。在以 CIELab 方式呈现的色彩变化中,我们设置的数值和人眼感知的一致性比较强。
在这里插入图片描述
在可视化应用里,一般有两种使用颜色的方式:第一种,整个项目的 UI 配色全部由 UI 设计师设计好,提供给可视化工程师使用。那在这种情况下,设计师设计的颜色是多少就是多少,开发者使用任何格式的颜色都行。第二种方式就是根据数据情况由前端动态地生成颜色值。当然不会是整个项目都由开发者完全自由选择,而一般是由设计师定下视觉基调和一些主色,开发者根据主色和数据来生成对应的颜色。

RGB 用三原色的色阶来表示颜色,是最基础的颜色表示法,但是它对用户不够友好。而 HSL 和 HSV 是用色相、饱和度、亮度(明度)来表示颜色,对开发者比较友好,但是它的数值变换与人眼感知并不完全相符。CIELab 和 CIELch 与 Cubehelix 色盘,这两种颜色表示法还比较新,在实际工作中使用得不是很多。其中,CIELab 和 CIELch 是与人眼感知相符的色彩空间表示法,已经被纳入 css-color level4 规范中。虽然还没有被浏览器支持,但是一些如 d3-color 这样的 JavaScript 库可以直接处理 Lab 颜色空间。而如果我们要呈现颜色随数据动态改变的效果,那 Cubehelix 色盘就是一种非常更合适的选择了。

参考资料:
[1] CSS Color Module Level 4
[2] RGB color model
[3] HSL 和 HSV 色彩空间
[4] 色彩空间中的 HSL、HSV、HSB 的区别
[5] 用 JavaScript 实现 RGB-HSL-HSB 相互转换的方法
[6] Lab 色彩空间维基百科
[7] Cubehelix 颜色表算法
[8] Dave Green’s `cubehelix’ colour scheme
[9] d3-color 官方文档

图案生成:生成重复图案、分形图案以及随机效果

  • 第一种,批量重复图案。一般来说,在绘制批量重复图案的时候,我们可以采用 2 种方案。首先是使用 CSS 的 background-image 属性,利用 backgroud-repeat 快速重复绘制。其次,我们可以使用片元着色器,利用 GPU 的并行渲染的特点来绘制。
  • 第二种,分形图案。绘制分形图案有一个可以直接的公式,曼德勃罗特集。我们可以使用它来绘制分形图案。
  • 第三种是在重复图案上增加随机性,我们可以在片元着色器中使用伪随机函数,来给重复图案实现随机效果。

推荐阅读
[1]基于 WebGL 底层简单封装的基础库 [gl-renderer]的官方文档 (https://github.com/akira-cn/gl-renderer) ,它可以大大简化 WebGL 代码的书写难度
[2]很棒的学习片元着色器的教程 The Book of Shaders .

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AaronZZH

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值