一棵樹(shù)-可視化之圖形化基礎(chǔ)之向量

作者:肖劍華

  • 可視化是前端可視化
  • 圖形是計(jì)算機(jī)圖形學(xué)
  • 向量就是那個(gè)向量,高中學(xué)過(guò)的,你懂的
  • 樹(shù)是那棵賊丑的樹(shù)

結(jié)果

首先先看看本文最終的結(jié)果。


是不是賊丑!是不是能在畫(huà)展上賣(mài)個(gè)好價(jià)格!

過(guò)程

好了,話不多說(shuō), 看看這棵賊丑的樹(shù)是怎么誕生的吧。

坐標(biāo)系

坐標(biāo)系,或者說(shuō)平面直角坐標(biāo)系,是幾何圖形學(xué)的基礎(chǔ),其次是點(diǎn)、線、面這些元素。

坐標(biāo)系大家都很熟悉, 最初接觸坐標(biāo)系應(yīng)該是初中, 那時(shí)候的坐標(biāo)系不知大家還有沒(méi)有印象。

原點(diǎn)在中間, 水平軸是 x 軸, 豎軸是 y 軸, 分為四個(gè)象限。

但是呢, html canvas 這貨, 默認(rèn)原點(diǎn)在左上角, x 軸是跟平面直角坐標(biāo)系是一致的, y 軸是向下的??!
相信這種坐標(biāo)軸在日常工作中使用 canvas 繪圖給前端人不知道造成過(guò)多少麻煩, 計(jì)算起來(lái)費(fèi)事費(fèi)力, 還容易出 bug。

那么如何把 canvas 的坐標(biāo)系變成平面直角坐標(biāo)系呢

Maaaaaaaaagic!

const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
// 我們這里把原點(diǎn)定位在canvas左下角
ctx.translate(0, canvas.height)
// 關(guān)鍵步驟: 將canvasY軸方向翻轉(zhuǎn)
ctx.scale(1, -1)

兩行代碼, 就完成了對(duì)坐標(biāo)系的翻轉(zhuǎn)。

我們用一個(gè) ?? 來(lái)驗(yàn)證一下

假設(shè),我們要在寬 512 * 高 256 的一個(gè) Canvas 畫(huà)布上實(shí)現(xiàn)如下的視覺(jué)效果。其中,山的高度是 100,底邊 200,兩座山的中心位置到中線的距離都是 80,太陽(yáng)的圓心高度是 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], // 起始點(diǎn)
  top: [176, 100], // 頂點(diǎn)
  end: [276, 0] // 終點(diǎn)
}

const hill2Points = {
  start: [236, 0], // 起始點(diǎn)
  top: [336, 100], // 頂點(diǎn)
  end: [436, 0] // 終點(diǎn)
}

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()

這里我們翻轉(zhuǎn)了坐標(biāo)系, 定義了 mountain1,mountain2,太陽(yáng) 的各個(gè)點(diǎn)的坐標(biāo), 完全是參照直角坐標(biāo)系的坐標(biāo)。

最終的實(shí)現(xiàn)效果如下

(是不是也能在畫(huà)展上賣(mài)個(gè)不錯(cuò)的價(jià)格)

向量

定義

說(shuō)完直角坐標(biāo)系的轉(zhuǎn)換, 我們來(lái)討論今天的正主, 向量(Vector)

向量的普遍定義是具有大小和方向的量, 我們這里討論的向量是 幾何向量, 是用一組平面直角坐標(biāo)系的坐標(biāo)表示的
例如 (1, 1), 意思是, 頂點(diǎn)坐標(biāo)為 x 為 1,y 為 0 的一條有向線段, 向量的方向是由 原點(diǎn)(0, 0) 指向頂點(diǎn)(1,1)的方向。

換言之, 知道了向量的頂點(diǎn), 就知道了向量的大小和方向

向量的模

向量的大小也叫向量的模,是向量坐標(biāo)的平方和的算術(shù)平方根, length = Math.pow((x2 + y2), 0.5)。

向量的方向

向量的方向一方面可以使用向量的頂點(diǎn)表示。

另外一方面使用向量和 x 軸的夾角,也能夠表示一個(gè)向量。

使用 javascript Math 的內(nèi)置方法可以得到,計(jì)算方式:

// 構(gòu)造函數(shù)在本文稍后的地方介紹
const v = new Vector2D(1, 10)
const dir = Math.atan2(v.y, v.x)

四則運(yùn)算

加減法

示意圖:


如圖所示: 向量 v1(x1, y1)和向量 v2(x2, y2)相加得到的新的向量就是兩個(gè)向量對(duì)應(yīng)坐標(biāo)之和, 用公式表達(dá)就是
v1(x1, y1) + v2(x2, y2) = v3(x1 + x2, y1 + y2)

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

乘除

向量乘法有 叉乘和點(diǎn)乘

點(diǎn)乘示意圖:

物理意義是, 方向?yàn)?va 方向,大小為 va.length 的力, 沿 vb 方向拉動(dòng) vb.length 距離所做的功

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

叉乘示意圖:

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

也可以理解為長(zhǎng)度為 va.length 的線段沿著 vb 方向移動(dòng)到 vb 頂點(diǎn)掃過(guò)的面積, 反之就是除法

乘除這里僅做概念上的介紹

單位向量

長(zhǎng)度為 1 的向量叫做單位向量, 滿(mǎn)足這個(gè)條件的向量有無(wú)數(shù)條, 一個(gè)非 0 的向量除以他的模,就是這個(gè)向量的單位向量, 我們?nèi)∨c x 軸夾角為 0 的向量:[1, 0]作為單位向量

向量的旋轉(zhuǎn)

將一個(gè)向量轉(zhuǎn)動(dòng)一定的角度 rad 之后的向量該如何計(jì)算呢。
這里有比較復(fù)雜的推導(dǎo)過(guò)程, 因此可以直接記住結(jié)論。

具體代碼在下面構(gòu)造函數(shù)里面展示

構(gòu)造器

// 用一個(gè)長(zhǎng)度為2的數(shù)組表示一個(gè)向量, 下標(biāo)為0的位置表示x 下標(biāo)為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
  }
}

至此,畫(huà)出文章開(kāi)頭的那個(gè)圖形的基本要素都已經(jīng)準(zhǔn)備好了。
下面, 讓我們來(lái)見(jiàn)證一下世界名畫(huà)的產(chǎn)生。

動(dòng)手畫(huà)圖

  1. 準(zhǔn)備一個(gè) 512 * 512 的畫(huà)布
<html>
  ...
  <canvas
    width="512"
    height="512"
    style="display:block;margin:0 auto;background-color: #ccc"
  ></canvas>
  ...
</html>
  1. 翻轉(zhuǎn) canvas 坐標(biāo)系
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
ctx.translate(0, canvas.height)
ctx.scale(1, -1)
  1. 定義繪制樹(shù)枝的方法
/**
 * 1. ctx canvas ctx 上下文對(duì)象
 * 2. 起始向量
 * 3. length 向量長(zhǎng)度(樹(shù)枝長(zhǎng)度)
 * 4. thickness 線段寬度
 * 5. 單位向量 dir 旋轉(zhuǎn)角度
 * 6. bias 隨機(jī)因子
 */
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()
}
// 定義好了之后我們先畫(huà)一個(gè)樹(shù)枝試試看
drawBranch(ctx, v0, 50, 10, Math.PI / 2, 1)
  1. 遞歸畫(huà)圖
// 先定義收縮系數(shù)
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) {
    // 畫(huà)左樹(shù)枝
    const left =
      Math.PI / 4 +
      RAD_SHRINK * (rad + 0.2) +
      drawBranch(
        ctx,
        v1,
        length * LENGTH_SHRINK,
        thickness * THICKNESS_SHRINK,
        left,
        bias
      )

    // 畫(huà)右樹(shù)枝
    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)

這一步畫(huà)出來(lái)的是一個(gè)比較規(guī)則的形狀, 代碼寫(xiě)到這一步,樹(shù)的基本形狀已經(jīng)出來(lái)了,但是 為了展示效果, 向量翻轉(zhuǎn)上加一些隨機(jī)性來(lái)畫(huà)一顆更加接近自然狀態(tài)的樹(shù)。代碼如下:

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

  if (thickness > 2) {
    // 畫(huà)左樹(shù)枝
    const left =
      Math.PI / 4 + RAD_SHRINK * (rad + 0.2) + bias * (Math.random() - 0.5) // 加些隨機(jī)數(shù)
    drawBranch(
      ctx,
      v1,
      length * LENGTH_SHRINK,
      thickness * THICKNESS_SHRINK,
      left,
      bias
    )

    // 畫(huà)右樹(shù)枝
    const right =
      Math.PI / 4 + RAD_SHRINK * (rad - 0.2) + bias * (Math.random() - 0.5) // 加些隨機(jī)數(shù)
    drawBranch(
      ctx,
      v1,
      length * LENGTH_SHRINK,
      thickness * THICKNESS_SHRINK,
      right,
      bias
    )
  }
}

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

等等等等, 效果圖:一棵光禿禿的樹(shù)

(是不是有點(diǎn)藝術(shù)內(nèi)味兒了)

剩下的就是添加一些點(diǎn)綴, 把果子掛上

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) // 這里增大了隨機(jī)因子, 讓樹(shù)枝更加分散

此時(shí)效果圖就出來(lái)了:


(我再問(wèn)一遍, 是不是很好看, 是不是很想花個(gè)幾百萬(wàn)小錢(qián)買(mǎi)下它)

對(duì)于drawBranch第一調(diào)用, 可以嘗試調(diào)一調(diào)參數(shù),看看結(jié)果如何。

完整代碼地址:github

總結(jié)

本文首先展示了如何將 canvas 的坐標(biāo)系轉(zhuǎn)化為直角坐標(biāo)系

其次用一個(gè)例子演示了,向量在圖形學(xué)內(nèi)的基本運(yùn)算。

向量運(yùn)算的意義并不僅僅只是用來(lái)算點(diǎn)的位置和構(gòu)造線段,這只是最初級(jí)的用法。

可視化呈現(xiàn)依賴(lài)于計(jì)算機(jī)圖形學(xué),而向量運(yùn)算是整個(gè)計(jì)算機(jī)圖形學(xué)的數(shù)學(xué)基礎(chǔ)。而且,在向量運(yùn)算中,除了加法表示移動(dòng)點(diǎn)和繪制線段外,向量的點(diǎn)乘、叉乘運(yùn)算也有特殊的意義。

我們是曉黑板前端,歡迎關(guān)注我們的知乎Segmentfault、CSDN、簡(jiǎn)書(shū)開(kāi)源中國(guó)賬號(hào)。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容