一棵树-可视化之图形化基础之向量

作者:肖剑华

  • 可视化是前端可视化
  • 图形是计算机图形学
  • 向量就是那个向量,高中学过的,你懂的
  • 树是那棵贼丑的树

结果

首先先看看本文最终的结果。


是不是贼丑!是不是能在画展上卖个好价格!

过程

好了,话不多说, 看看这棵贼丑的树是怎么诞生的吧。

坐标系

坐标系,或者说平面直角坐标系,是几何图形学的基础,其次是点、线、面这些元素。

坐标系大家都很熟悉, 最初接触坐标系应该是初中, 那时候的坐标系不知大家还有没有印象。

原点在中间, 水平轴是 x 轴, 竖轴是 y 轴, 分为四个象限。

但是呢, html canvas 这货, 默认原点在左上角, x 轴是跟平面直角坐标系是一致的, y 轴是向下的!!
相信这种坐标轴在日常工作中使用 canvas 绘图给前端人不知道造成过多少麻烦, 计算起来费事费力, 还容易出 bug。

那么如何把 canvas 的坐标系变成平面直角坐标系呢

Maaaaaaaaagic!

const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
// 我们这里把原点定位在canvas左下角
ctx.translate(0, canvas.height)
// 关键步骤: 将canvasY轴方向翻转
ctx.scale(1, -1)

两行代码, 就完成了对坐标系的翻转。

我们用一个 🌰 来验证一下

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

我们这里使用 rough.js 增加一下趣味性

<canvas
  width="512"
  height="256"
  style="display: block;margin: 0 auto;background-color: #ccc"
></canvas>
const canvas = document.querySelector('canvas')
const rc = rough.canvas(canvas)
rc.ctx.translate(0, canvas.height)
rc.ctx.scale(1, -1)

const cSun = [canvas.width / 2, 106]
const diameter = 100 // 直径

const hill1Points = {
  start: [76, 0], // 起始点
  top: [176, 100], // 顶点
  end: [276, 0] // 终点
}

const hill2Points = {
  start: [236, 0], // 起始点
  top: [336, 100], // 顶点
  end: [436, 0] // 终点
}

const hill1Options = {
  roughness: 0.8,
  stokeWidth: 2,
  fill: 'pink'
}

const hill2Options = {
  roughness: 0.8,
  stokeWidth: 2,
  fill: 'chocolate'
}

function createHillPath(point) {
  const { start, top, end } = point

  return `M${start[0]} ${start[1]}L${top[0]} ${top[1]}L${end[0]} ${end[1]}`
}

function paint() {
  rc.path(createHillPath(hill1Points), hill1Options)
  rc.path(createHillPath(hill2Points), hill2Options)

  rc.circle(cSun[0], cSun[1], diameter, {
    stroke: 'red',
    strokeWidth: 4,
    fill: 'rgba(255, 255, 0, 0.4)',
    fillStyle: 'solid'
  })
}

paint()

这里我们翻转了坐标系, 定义了 mountain1,mountain2,太阳 的各个点的坐标, 完全是参照直角坐标系的坐标。

最终的实现效果如下

(是不是也能在画展上卖个不错的价格)

向量

定义

说完直角坐标系的转换, 我们来讨论今天的正主, 向量(Vector)

向量的普遍定义是具有大小和方向的量, 我们这里讨论的向量是 几何向量, 是用一组平面直角坐标系的坐标表示的
例如 (1, 1), 意思是, 顶点坐标为 x 为 1,y 为 0 的一条有向线段, 向量的方向是由 原点(0, 0) 指向顶点(1,1)的方向。

换言之, 知道了向量的顶点, 就知道了向量的大小和方向

向量的模

向量的大小也叫向量的模,是向量坐标的平方和的算术平方根, length = Math.pow((x2 + y2), 0.5)。

向量的方向

向量的方向一方面可以使用向量的顶点表示。

另外一方面使用向量和 x 轴的夹角,也能够表示一个向量。

使用 javascript Math 的内置方法可以得到,计算方式:

// 构造函数在本文稍后的地方介绍
const v = new Vector2D(1, 10)
const dir = Math.atan2(v.y, v.x)

四则运算

加减法

示意图:


如图所示: 向量 v1(x1, y1)和向量 v2(x2, y2)相加得到的新的向量就是两个向量对应坐标之和, 用公式表达就是
v1(x1, y1) + v2(x2, y2) = v3(x1 + x2, y1 + y2)

反之就是减法 v3(x1 + x2, y1 + y2) - v2 (x2, y2)= v1(x1, y1)

乘除

向量乘法有 叉乘和点乘

点乘示意图:

物理意义是, 方向为 va 方向,大小为 va.length 的力, 沿 vb 方向拉动 vb.length 距离所做的功

va * vb = va.length * vb.length * cos(rad)

叉乘示意图:

va * vb = va.length * va.length * sin(rad)

也可以理解为长度为 va.length 的线段沿着 vb 方向移动到 vb 顶点扫过的面积, 反之就是除法

乘除这里仅做概念上的介绍

单位向量

长度为 1 的向量叫做单位向量, 满足这个条件的向量有无数条, 一个非 0 的向量除以他的模,就是这个向量的单位向量, 我们取与 x 轴夹角为 0 的向量:[1, 0]作为单位向量

向量的旋转

将一个向量转动一定的角度 rad 之后的向量该如何计算呢。
这里有比较复杂的推导过程, 因此可以直接记住结论。

具体代码在下面构造函数里面展示

构造器

// 用一个长度为2的数组表示一个向量, 下标为0的位置表示x 下标为1的位置表示 y
class Vector2D extends Array {
  constructor(x = 1, y = 0) {
    super(x, y)
  }

  get x() {
    return this[0]
  }

  get y() {
    return this[1]
  }

  set x(v) {
    this[0] = v
  }

  set y(v) {
    this[1] = v
  }

  add(v) {
    this.x = this.x + v.x
    this.y = this.y + v.y
    return this
  }

  length() {
    return Math.hypot(this.x, this.y)
  }

  rotate(rad) {
    const c = Math.cos(rad)
    const s = Math.sin(rad)
    const [x, y] = this
    this.x = x * c + y * -s
    this.y = x * s + y * c
    return this
  }
}

至此,画出文章开头的那个图形的基本要素都已经准备好了。
下面, 让我们来见证一下世界名画的产生。

动手画图

  1. 准备一个 512 * 512 的画布
<html>
  ...
  <canvas
    width="512"
    height="512"
    style="display:block;margin:0 auto;background-color: #ccc"
  ></canvas>
  ...
</html>
  1. 翻转 canvas 坐标系
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
ctx.translate(0, canvas.height)
ctx.scale(1, -1)
  1. 定义绘制树枝的方法
/**
 * 1. ctx canvas ctx 上下文对象
 * 2. 起始向量
 * 3. length 向量长度(树枝长度)
 * 4. thickness 线段宽度
 * 5. 单位向量 dir 旋转角度
 * 6. bias 随机因子
 */
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
ctx.translate(0, canvas.height)
ctx.scale(1, -1)
ctx.lineCap = 'round'
console.log(canvas.width)
const v0 = new Vector2D(canvas.width / 2, 0)

function drawBranch(ctx, v0, length, thickness, rad, bias) {
  const v = new Vector2D().rotate(rad).scale(length)
  console.log(v, rad, length)
  const v1 = v0.copy().add(v)
  ctx.beginPath()
  ctx.lineWidth = thickness
  ctx.moveTo(...v0)
  ctx.lineTo(...v1)
  ctx.stroke()
  ctx.closePath()
}
// 定义好了之后我们先画一个树枝试试看
drawBranch(ctx, v0, 50, 10, Math.PI / 2, 1)
  1. 递归画图
// 先定义收缩系数
const LENGTH_SHRINK = 0.9
const THICKNESS_SHRINK = 0.8
const RAD_SHRINK = 0.5
const BIAS_SHRINK = 1

function drawBranch(ctx, v0, length, thickness, rad, bias) {
  // ....

  if (thickness > 2) {
    // 画左树枝
    const left =
      Math.PI / 4 +
      RAD_SHRINK * (rad + 0.2) +
      drawBranch(
        ctx,
        v1,
        length * LENGTH_SHRINK,
        thickness * THICKNESS_SHRINK,
        left,
        bias
      )

    // 画右树枝
    const right = Math.PI / 4 + RAD_SHRINK * (rad - 0.2)
    drawBranch(
      ctx,
      v1,
      length * LENGTH_SHRINK,
      thickness * THICKNESS_SHRINK,
      right,
      bias
    )
  }
}

drawBranch(ctx, v0, 50, 10, Math.PI / 2, 1)

这一步画出来的是一个比较规则的形状, 代码写到这一步,树的基本形状已经出来了,但是 为了展示效果, 向量翻转上加一些随机性来画一颗更加接近自然状态的树。代码如下:

function drawBranch(ctx, v0, length, thickness, rad, bias) {
  // ....

  if (thickness > 2) {
    // 画左树枝
    const left =
      Math.PI / 4 + RAD_SHRINK * (rad + 0.2) + bias * (Math.random() - 0.5) // 加些随机数
    drawBranch(
      ctx,
      v1,
      length * LENGTH_SHRINK,
      thickness * THICKNESS_SHRINK,
      left,
      bias
    )

    // 画右树枝
    const right =
      Math.PI / 4 + RAD_SHRINK * (rad - 0.2) + bias * (Math.random() - 0.5) // 加些随机数
    drawBranch(
      ctx,
      v1,
      length * LENGTH_SHRINK,
      thickness * THICKNESS_SHRINK,
      right,
      bias
    )
  }
}

drawBranch(ctx, v0, 50, 10, Math.PI / 2, 1)

等等等等, 效果图:一棵光秃秃的树

(是不是有点艺术内味儿了)

剩下的就是添加一些点缀, 把果子挂上

function drawBranch(ctx, v0, length, thickness, rad, bias) {
  // .....

  if (thickness < 5 && Math.random() < 0.3) {
    const th = 6 + Math.random()

    ctx.save()
    ctx.strokeStyle = '#e4393c'
    ctx.lineWidth = th
    ctx.beginPath()
    ctx.moveTo(...v1)
    ctx.lineTo(v1.x, v1.y + 2)
    ctx.stroke()
    ctx.closePath()
    ctx.restore()
  }
}

drawBranch(ctx, v0, 50, 10, Math.PI / 2, 3) // 这里增大了随机因子, 让树枝更加分散

此时效果图就出来了:


(我再问一遍, 是不是很好看, 是不是很想花个几百万小钱买下它)

对于drawBranch第一调用, 可以尝试调一调参数,看看结果如何。

完整代码地址:github

总结

本文首先展示了如何将 canvas 的坐标系转化为直角坐标系

其次用一个例子演示了,向量在图形学内的基本运算。

向量运算的意义并不仅仅只是用来算点的位置和构造线段,这只是最初级的用法。

可视化呈现依赖于计算机图形学,而向量运算是整个计算机图形学的数学基础。而且,在向量运算中,除了加法表示移动点和绘制线段外,向量的点乘、叉乘运算也有特殊的意义。

我们是晓黑板前端,欢迎关注我们的知乎SegmentfaultCSDN简书开源中国账号。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容