項(xiàng)目簡介
文章里有相當(dāng)多的用到中學(xué)數(shù)學(xué)中的知識,推導(dǎo)3d的幾何模型是如何繪制到2d平面中去的,最終利用推導(dǎo)出的結(jié)論編寫代碼,實(shí)現(xiàn)一個波紋的demo
項(xiàng)目地址:https://github.com/zz632893783/canvas-3d

安裝項(xiàng)目依賴模塊
npm install
運(yùn)行項(xiàng)目
npm run dev
從z軸觀察yz平面上的點(diǎn)

想象一下有這么一個三維空間(如圖),有一個點(diǎn)B,我們從A點(diǎn)觀察B點(diǎn)。那么B點(diǎn)在xy平面上的投影即AB的延長線與平面xy的交點(diǎn)C。而xy平面不就是可以看一個二維的canvas畫布嗎。
我們暫且將A點(diǎn)放在z軸,B點(diǎn)放在yz平面,則A點(diǎn)的三維坐標(biāo)可以表示為
A(0,0,zA),B點(diǎn)的三維坐標(biāo)可以表示為B(0,yB,zB)。從B點(diǎn)做一條垂線垂z軸于D點(diǎn)。
ADB與AOC是相似三角形,所以有

變換得

其中DB即B點(diǎn)的y坐標(biāo),AO即A點(diǎn)的z坐標(biāo),DO即B點(diǎn)的z坐標(biāo),所以

這里的OC也就是C點(diǎn)的y坐標(biāo)。
從z軸觀察xz平面上的點(diǎn)

同理我們從A點(diǎn)觀察平面xz上的某一點(diǎn)E(xE,0,zE),ADE與AOF是相似三角形

變換得

從z軸觀察空間內(nèi)任意坐標(biāo)
之前所觀測的B點(diǎn)是位于yz平面內(nèi),E點(diǎn)是位于xz平面內(nèi),但是如果是空間內(nèi)任意位置的點(diǎn)呢
其實(shí)道理都是一樣的,如下如

如果將直線BD平移到E點(diǎn),直線DE平移到B點(diǎn),那么將形成一個矩形DBGE,矩形DBGE在xy平面上的投影為矩形OCHF。
由于AGE與AHF相似,所以有

并且由于ADE與AOF也是相似三角形,所以

所以

推導(dǎo)得

其中GE也就是G點(diǎn)的y坐標(biāo),因?yàn)榫匦蜠BGE是平行于xy平面的,所以它們z坐標(biāo)相同,DO等價于G點(diǎn)的z坐標(biāo),所以對于空間內(nèi)任意位置G(xG,yG,zG)

同樣的方法我們可以推導(dǎo)出

變換得

結(jié)合上兩步,CH是H點(diǎn)的x坐標(biāo),HF是H點(diǎn)的y坐標(biāo),所以從軸上的點(diǎn)A(0,0,zA)觀察空間內(nèi)任意位置G(xG,yG,zG)在平面xy上的投影可表示為

從任意位置觀察空間內(nèi)任意坐標(biāo)
沿著x軸平移坐標(biāo)系
之前的推論到從z軸觀察空間內(nèi)任意位置的投影了,但是實(shí)際上A點(diǎn)是有特殊性的,因?yàn)樗俏挥趜軸上的某一個點(diǎn),其xy坐標(biāo)都為0,如果A是空間內(nèi)的任意一個點(diǎn),情況又如何,請看下圖

假設(shè)這個時候真正的坐標(biāo)系是xy'z',而坐標(biāo)系xyz是我們臨時所建立的一個虛擬的坐標(biāo)系,那么這個時候A點(diǎn)相對于坐標(biāo)系xy'z'來說,坐標(biāo)點(diǎn)可表示為A(xA,0,zA),G點(diǎn)依舊表示為(xG,yG,zG)
我們之前推導(dǎo)的相似三角形的關(guān)系,即使換了坐標(biāo)系,它們的關(guān)系依然成立,所以

變換得

只不過這個時候 BG=xG-xA,AO與DO與之前相同
求得

之前推導(dǎo)出的相似三角形關(guān)系依舊成立,所以

變換得

由于GE,AO,DO與之前相比都沒有變化,
所以得

與之前的推導(dǎo)一致,最后我們得出結(jié)論,我們沿著x軸方向移動坐標(biāo)系的時候(即圖中的坐標(biāo)系有xy'z'移動到了xyz位置),G點(diǎn)在平面xy的投影H點(diǎn)的y坐標(biāo)不會有變化,但是x坐標(biāo)為

沿著y軸平移坐標(biāo)系
如下圖

假設(shè)x'yz'是真正的坐標(biāo)系,沿著y軸平移得到臨時坐標(biāo)系xyz,推導(dǎo)步驟和之前的相同,這里不再贅述,直接貼結(jié)果


也就是說當(dāng)沿著y軸方向移動坐標(biāo)系的時候,投影H的x坐標(biāo)不會有變化,y坐標(biāo)變?yōu)?br>

對于空間內(nèi)任意位置
對于空間內(nèi)任意位置,我們都可以看成是在z軸上的某一點(diǎn)A(0,0,zA),先經(jīng)歷一次x軸方向的平移(此時投影H的y坐標(biāo)不變),再經(jīng)歷一次y軸方向的平移(此時投影H的x坐標(biāo)不變),平移之前點(diǎn)A觀察到點(diǎn)G的投影H坐標(biāo)可表示為

對其進(jìn)行x軸方向的平移,(此時投影H的y坐標(biāo)不變),H的坐標(biāo)可表示為

再對其進(jìn)行y軸方向的平移,(此時投影H的x坐標(biāo)不變),H的坐標(biāo)可表示為

最終結(jié)論
從空間內(nèi)的任意點(diǎn)A(xA,yA,zA)觀察空間內(nèi)的任一點(diǎn)G(xG,yG,zG),它在xy平面內(nèi)的投影H的坐標(biāo)為

首先我們嘗試寫一個簡單的幾何圖形

立方體邊長為100,則A(-50,50,50),B(-50,50,-50),C(50,50,-50),D(50,50,50),E(-50,-50,50),F(xiàn)(-50,-50,-50),G(50,-50,-50),H(50,-50,50),假定從z軸上某一點(diǎn)(0,0,300)觀察
<template>
<div class="cube">
<canvas ref="cube" v-bind:width="canvasWidth" v-bind:height="canvasHeight"></canvas>
</div>
</template>
<script>
export default {
data: function () {
return {
canvasWidth: 600,
canvasHeight: 400,
ctx: null,
visual: {
x: 0,
y: 0,
z: 300
},
pointMap: {
A: (-50, 50, 50),
B: (-50, 50, -50),
C: (50, 50, -50),
D: (50, 50, 50),
E: (-50, -50, 50),
F: (-50, -50, -50),
G: (50, -50, -50),
H: (50, -50, 50)
}
}
},
methods: {
init: function () {
this.ctx = this.$refs.cube.getContext('2d')
},
draw: function () {}
},
mounted: function () {
this.init()
this.draw()
}
}
</script>
繪制方法也很簡單,分別繪制矩形ABCD,矩形EFGH,然后再將AE,BF,CG,DH連線即可,只不過這里的ABCDEFGH點(diǎn)需要換算成投影在三維坐標(biāo)系xy平面上的點(diǎn),運(yùn)用我們之前得出的結(jié)論,我們定義一個轉(zhuǎn)換坐標(biāo)點(diǎn)的函數(shù)
transformCoordinatePoint: function (x, y, z, offsetX = this.canvasWidth / 2, offsetY = this.canvasHeight / 2) {
return {
x: (x - this.visual.x) * this.visual.z / (this.visual.z - z) + offsetX,
y: (y - this.visual.y) * this.visual.z / (this.visual.z - z) + offsetY
}
}
然后編寫draw函數(shù)
draw: function () {
let point
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 繪制矩形ABCD
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.A)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.B)
this.ctx.lineTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.C)
this.ctx.lineTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.D)
this.ctx.lineTo(point.x, point.y)
this.ctx.closePath()
this.ctx.stroke()
// 繪制矩形EFGH
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.E)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.F)
this.ctx.lineTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.G)
this.ctx.lineTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.H)
this.ctx.lineTo(point.x, point.y)
this.ctx.closePath()
this.ctx.stroke()
// 繪制直線AE
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.A)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.E)
this.ctx.lineTo(point.x, point.y)
this.ctx.stroke()
this.ctx.closePath()
// 繪制直線BF
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.B)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.F)
this.ctx.lineTo(point.x, point.y)
this.ctx.stroke()
this.ctx.closePath()
// 繪制直線CD
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.C)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.G)
this.ctx.lineTo(point.x, point.y)
this.ctx.stroke()
this.ctx.closePath()
// 繪制直線DH
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.D)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.H)
this.ctx.lineTo(point.x, point.y)
this.ctx.stroke()
this.ctx.closePath()
}
查看代碼運(yùn)行結(jié)果

似乎是對的,但是有感覺怪怪的,我們嘗試將立方體繞著y軸旋轉(zhuǎn)
這里需要另一個數(shù)學(xué)關(guān)系的推導(dǎo)

想象一下從y軸俯視yz平面,這個時候點(diǎn)D的位置關(guān)系如下圖

這個時候假定D點(diǎn)與x軸的夾角是α,圓的半徑為R,將D點(diǎn)繞著y軸旋轉(zhuǎn)β旋轉(zhuǎn)至D'點(diǎn),這個時候D'與x軸夾角為α+β,此時D'的x坐標(biāo)為cos(α+β)R,D'的z坐標(biāo)為sin(α+β)R
回一下中學(xué)時候我們學(xué)過的三角形倍角公式


D'的x坐標(biāo)cos(α+β)R=Rcosαcosβ-Rsinαsinβ
D'的z坐標(biāo)sin(α+β)R=Rsinαcosβ+Rcosαsinβ
而Rsinα就是旋轉(zhuǎn)之前D點(diǎn)的z坐標(biāo),Rcosα就是旋轉(zhuǎn)之前D點(diǎn)的x坐標(biāo),
D'的x坐標(biāo)為xcosβ-zsinβ
D'的z坐標(biāo)為zcosβ+xsinβ
將結(jié)論代入到我們的立方體的8個頂點(diǎn)ABCDEFGH中
對于任一點(diǎn)D(xD,yD,zD),其繞y軸旋轉(zhuǎn)β角的時候,它的三維坐標(biāo)變?yōu)?xDcosβ-zDsinβ,yD,zDcosβ+xDsinβ)
轉(zhuǎn)換為代碼
methods: {
init: function () {
this.ctx = this.$refs.cube.getContext('2d')
},
transformCoordinatePoint: function (x, y, z, offsetX = this.canvasWidth / 2, offsetY = this.canvasHeight / 2) {
return {
x: (x - this.visual.x) * this.visual.z / (this.visual.z - z) + offsetX,
y: (y - this.visual.y) * this.visual.z / (this.visual.z - z) + offsetY
}
},
draw: function () {
let point
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 繪制矩形ABCD
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.A)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.B)
this.ctx.lineTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.C)
this.ctx.lineTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.D)
this.ctx.lineTo(point.x, point.y)
this.ctx.closePath()
this.ctx.stroke()
// 繪制矩形EFGH
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.E)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.F)
this.ctx.lineTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.G)
this.ctx.lineTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.H)
this.ctx.lineTo(point.x, point.y)
this.ctx.closePath()
this.ctx.stroke()
// 繪制直線AE
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.A)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.E)
this.ctx.lineTo(point.x, point.y)
this.ctx.stroke()
this.ctx.closePath()
// 繪制直線BF
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.B)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.F)
this.ctx.lineTo(point.x, point.y)
this.ctx.stroke()
this.ctx.closePath()
// 繪制直線CD
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.C)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.G)
this.ctx.lineTo(point.x, point.y)
this.ctx.stroke()
this.ctx.closePath()
// 繪制直線DH
this.ctx.beginPath()
point = this.transformCoordinatePoint(...this.pointMap.D)
this.ctx.moveTo(point.x, point.y)
point = this.transformCoordinatePoint(...this.pointMap.H)
this.ctx.lineTo(point.x, point.y)
this.ctx.stroke()
this.ctx.closePath()
},
animationFrame: function () {
let rotationAngle = 1
window.requestAnimationFrame(() => {
for (let key in this.pointMap) {
let point = this.pointMap[key]
// 保存x,y,z坐標(biāo)
let x = point[0]
let y = point[1]
let z = point[2]
// 變換后的x坐標(biāo)
point[0] = x * Math.cos(rotationAngle / 180 * Math.PI) - z * Math.sin(rotationAngle / 180 * Math.PI)
// 繞y軸旋轉(zhuǎn),y左邊不會發(fā)生變化
point[1] = y
// 變換后的z坐標(biāo)
point[2] = z * Math.cos(rotationAngle / 180 * Math.PI) + x * Math.sin(rotationAngle / 180 * Math.PI)
}
this.draw()
this.animationFrame()
})
}
},
mounted: function () {
this.init()
this.animationFrame()
}
代碼運(yùn)行效果

繪制波浪
波浪是由若干條正弦函數(shù)組成的,我們先繪制一條正弦函數(shù)
中學(xué)數(shù)學(xué)中,描述一條正弦函數(shù)的方程式 y=a*sin(b * x + c) + d,所以我們構(gòu)造一個類,需要的參數(shù)也是a,b,c,d,為了確定函數(shù)的起始位置和結(jié)束位置,另外需要兩個參數(shù)start,end
class Line {
constructor (a, b, c, d, start, end) {
this.a = a
this.b = b
this.c = c
this.d = d
this.start = start
this.end = end
}
}
export default Line
實(shí)際上每條正弦函數(shù)曲線并不是真正的連線,而是由于一個個點(diǎn)組成,我們在增加一個參數(shù),確定每個點(diǎn)之間的間距,并在實(shí)例化的時候生成這些點(diǎn),我們這里保存在pointList中
class Line {
constructor (a, b, c, d, start, end, gap) {
this.a = a
this.b = b
this.c = c
this.d = d
this.start = start
this.end = end
this.gap = gap
this.pointList = []
this.computePointList()
}
computePointList () {
this.pointList = []
for (let i = this.start; i <= this.end; i = i + this.gap) {
let x = i
let y = this.a * Math.sin((this.b * x + this.c) / 180 * Math.PI) + this.d
this.pointList.push({
x,
y
})
}
}
}
export default Line
在頁面中,Line實(shí)例保存在lineList中,遍歷lineList繪制點(diǎn)
<template>
<canvas class="wave" ref="wave" v-bind:width="canvasWidth" v-bind:height="canvasHeight"></canvas>
</template>
<script>
import Line from './line'
export default {
props: {},
data: function () {
return {
canvasWidth: 600,
canvasHeight: 400,
ctx: null,
visual: {
x: 0,
y: -100,
z: 1000
},
lineList: [
new Line(20, 2, 0, 0, -200, 200, 10)
]
}
},
methods: {
init: function () {
this.ctx = this.$refs.wave.getContext('2d')
},
draw: function () {
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.lineList.forEach(line => {
line.pointList.forEach(item => {
this.ctx.beginPath()
this.ctx.arc(item.x + this.canvasWidth / 2, item.y + this.canvasHeight / 2, 2, 0, 2 * Math.PI)
this.ctx.closePath()
this.ctx.fill()
})
})
}
},
mounted: function () {
this.init()
this.draw()
}
}
</script>
<style lang="stylus" scoped>
.wave {
border: 1px solid;
}
</style>
看一下代碼效果

我們再試著讓它動起來,波浪的運(yùn)動改變的實(shí)際上是每個點(diǎn)的縱坐標(biāo),只要我們知道每個點(diǎn)距離原點(diǎn)的偏移量,我們就能計算出當(dāng)前的縱坐標(biāo),所以我們在生成點(diǎn)的時候,記錄偏移量,我們我們聲明一個updatePointList方法用以跟新點(diǎn)的位置
computePointList () {
this.pointList = []
for (let i = this.start; i <= this.end; i = i + this.gap) {
let x = i
let y = this.a * Math.sin((this.b * x + this.c) / 180 * Math.PI) + this.d
let offset = i
this.pointList.push({
x,
y,
offset
})
}
}
updatePointList () {
this.pointList.forEach(item => {
item.y = this.a * Math.sin((this.b * item.x + this.c + item.offset) / 180 * Math.PI) + this.d
})
}
在頁面中,我們定義一個變量lineOffset,通過調(diào)整它控制line實(shí)例的c值(也就是對直線進(jìn)行平移),并不斷地調(diào)用之前寫好的updatePointList方法,更新點(diǎn)的位置
animationFrame: function () {
window.requestAnimationFrame(() => {
this.lineList.forEach(line => {
line.c = this.lineOffset
line.updatePointList()
})
this.lineOffset = this.lineOffset + 1
this.draw()
this.animationFrame()
})
}
代碼運(yùn)行效果

但是這個只是二維平面的,想象一下空間中有很多條這樣的直線,然后有的直線離屏幕比較近,有的離屏幕比較遠(yuǎn),所以我們?nèi)绻谌S空間中描述直線的話,我們還需要知道三維坐標(biāo)系中的z坐標(biāo),除此之代直線的x,z與之前的相比并無變化
constructor (a, b, c, d, z, start, end, gap) {
this.a = a
this.b = b
this.c = c
this.d = d
this.z = z
this.start = start
this.end = end
this.gap = gap
this.pointList = []
this.computePointList()
}
我們之前已經(jīng)推導(dǎo)過,對于任一點(diǎn)D(xD,yD,zD),其繞y軸旋轉(zhuǎn)β角的時候,它的三維坐標(biāo)變?yōu)?xDcosβ-zDsinβ,yD,zDcosβ+xDsinβ),想象一下我們直線上的每一個點(diǎn),其實(shí)都是繞著y軸旋轉(zhuǎn)的,旋轉(zhuǎn)之后y軸的坐標(biāo)不會發(fā)生變化,然后看我們原型中聲明的updatePointList方法
updatePointList () {
this.pointList.forEach(item => {
item.y = this.a * Math.sin((this.b * item.x + this.c + item.offset) / 180 * Math.PI) + this.d
})
}
y軸的坐標(biāo)我們之前已經(jīng)寫好了,我們運(yùn)用(xDcosβ-zDsinβ,yD,zDcosβ+xDsinβ)推導(dǎo)每個點(diǎn)旋轉(zhuǎn)β角后的坐標(biāo)位置
updatePointList (rotationAngleSpeed) {
this.pointList.forEach(item => {
let x = item.x
let z = item.z
item.x = x * Math.cos(rotationAngleSpeed / 180 * Math.PI) - z * Math.sin(rotationAngleSpeed / 180 * Math.PI)
item.z = z * Math.cos(rotationAngleSpeed / 180 * Math.PI) + x * Math.sin(rotationAngleSpeed / 180 * Math.PI)
})
}
代碼運(yùn)行效果

但是此時的粒子并沒有沿著y軸方向移動,我們將兩步結(jié)合
updatePointList (rotationAngleSpeed, visual) {
this.pointList.forEach(item => {
let x = item.x
let y = item.y
let z = item.z
item.x = x * Math.cos(rotationAngleSpeed / 180 * Math.PI) - z * Math.sin(rotationAngleSpeed / 180 * Math.PI)
item.z = z * Math.cos(rotationAngleSpeed / 180 * Math.PI) + x * Math.sin(rotationAngleSpeed / 180 * Math.PI)
item.y = this.a * Math.sin((this.b * x + this.c + item.offset) / 180 * Math.PI) + this.d
})
}
然后我們看一下運(yùn)行結(jié)果

非常的怪異,我們似乎哪里寫錯了
回過頭來看我們的代碼,波紋的左右移動實(shí)際上是靠從新計算每個點(diǎn)的y坐標(biāo)實(shí)現(xiàn),而計算y坐標(biāo)我們用的函數(shù)是
item.y = this.a * Math.sin((this.b * x + this.c + item.offset) / 180 * Math.PI) + this.d
但是我們實(shí)際上每計算一次item.y的值,我們通過控制this.c來實(shí)現(xiàn)平移,所以除了this.c之外,
this.a * Math.sin((this.b * x + this.c + item.offset) / 180 * Math.PI) + this.d
中的 this.a,x(這里的x也就是item.x),this.b,item.offset,this.d都不應(yīng)該有變化,但是我們代碼中的
item.x = x * Math.cos(rotationAngleSpeed / 180 * Math.PI) - z * Math.sin(rotationAngleSpeed / 180 * Math.PI)
卻在不停地變化item.x的值,所以我們需要保存一份最開始時時候的x值
computePointList () {
this.pointList = []
for (let i = this.start; i <= this.end; i = i + this.gap) {
let x = i
let y = this.a * Math.sin((this.b * x + this.c) / 180 * Math.PI) + this.d
let offset = i
this.pointList.push({
x,
y,
z: this.z,
originX: x,
offset
})
}
}
updatePointList (rotationAngleSpeed, visual) {
this.pointList.forEach(item => {
let x = item.x
let y = item.y
let z = item.z
item.x = x * Math.cos(rotationAngleSpeed / 180 * Math.PI) - z * Math.sin(rotationAngleSpeed / 180 * Math.PI)
item.z = z * Math.cos(rotationAngleSpeed / 180 * Math.PI) + x * Math.sin(rotationAngleSpeed / 180 * Math.PI)
item.y = this.a * Math.sin((this.b * item.originX + this.c + item.offset) / 180 * Math.PI) + this.d
})
}
繼續(xù)看運(yùn)行效果

雖然代碼是對的,但是這個時候的這些點(diǎn)還只是平面上的點(diǎn),并沒有3d效果,我們回到最開始推導(dǎo)出的結(jié)論
從空間內(nèi)的任意點(diǎn)A(xA,yA,zA)觀察空間內(nèi)的任一點(diǎn)G(xG,yG,zG),它在xy平面內(nèi)的投影H的坐標(biāo)為

我們頂一個個觀察點(diǎn)
visual: {
x: 0,
y: -100,
z: 1000
}
并在每次updatePointList方法中調(diào)用它,計算這個點(diǎn)在平面xy上的投影位置
animationFrame: function () {
window.requestAnimationFrame(() => {
this.lineList.forEach(line => {
line.c = this.lineOffset
line.updatePointList(this.rotationAngleSpeed, this.visual)
})
this.lineOffset = this.lineOffset + 1
this.draw()
this.animationFrame()
})
}
在updatePointList函數(shù)中,我們拿到傳入的視角點(diǎn)visual,并根據(jù)視角點(diǎn)計算空間內(nèi)的點(diǎn)在平面xy上的投影,我們記為(canvasX,canvasY)
updatePointList (rotationAngleSpeed, visual) {
this.pointList.forEach(item => {
let x = item.x
let y = item.y
let z = item.z
item.x = x * Math.cos(rotationAngleSpeed / 180 * Math.PI) - z * Math.sin(rotationAngleSpeed / 180 * Math.PI)
item.z = z * Math.cos(rotationAngleSpeed / 180 * Math.PI) + x * Math.sin(rotationAngleSpeed / 180 * Math.PI)
item.y = this.a * Math.sin((this.b * item.originX + this.c + item.offset) / 180 * Math.PI) + this.d
item.canvasX = (item.x - visual.x) * visual.z / (visual.z - z)
item.canvasY = (item.y - visual.y) * visual.z / (visual.z - z)
})
}
由于我們現(xiàn)在是要繪制投影的坐標(biāo),所以我們的draw方法中的繪制圓點(diǎn)的方法需要換成(canvasX,canvasY)
draw: function () {
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.lineList.forEach(line => {
line.pointList.forEach(item => {
this.ctx.beginPath()
this.ctx.arc(item.canvasX + this.canvasWidth / 2, item.canvasY + this.canvasHeight / 2, 2, 0, 2 * Math.PI)
this.ctx.closePath()
this.ctx.fill()
})
})
}
運(yùn)行結(jié)果

然后我們試著加入更多的線條
lineList: [
new Line(20, 2, 0, 0, -150, -200, 200, 10),
new Line(20, 2, 0, 0, -120, -200, 200, 10),
new Line(20, 2, 0, 0, -90, -200, 200, 10),
new Line(20, 2, 0, 0, -60, -200, 200, 10),
new Line(20, 2, 0, 0, -30, -200, 200, 10),
new Line(20, 2, 0, 0, 0, -200, 200, 10),
new Line(20, 2, 0, 0, 30, -200, 200, 10),
new Line(20, 2, 0, 0, 60, -200, 200, 10),
new Line(20, 2, 0, 0, 90, -200, 200, 10),
new Line(20, 2, 0, 0, 120, -200, 200, 10),
new Line(20, 2, 0, 0, 150, -200, 200, 10)
]
運(yùn)行結(jié)果

我們試著再對每條直線作不同的平移,我們平移直線是通過line構(gòu)造函數(shù)中的參數(shù)c控制的,在animationFrame方法中
animationFrame: function () {
window.requestAnimationFrame(() => {
this.lineList.forEach((line, index) => {
line.c = this.lineOffset
line.updatePointList(this.rotationAngleSpeed, this.visual)
})
this.lineOffset = this.lineOffset + 1
this.draw()
this.animationFrame()
})
}
line.c是被賦值為this.lineOffset,所以我們看到每條直線的偏移量都是一致的,我們試著修改代碼,使每條直線的偏移量不一致
animationFrame: function () {
window.requestAnimationFrame(() => {
this.lineList.forEach((line, index) => {
line.c = this.lineOffset + index * 30
line.updatePointList(this.rotationAngleSpeed, this.visual)
})
this.lineOffset = this.lineOffset + 1
this.draw()
this.animationFrame()
})
}
代碼運(yùn)行結(jié)果

實(shí)際上我們還忽略了一個點(diǎn),那就是點(diǎn)的遠(yuǎn)近大小關(guān)系,真實(shí)情況應(yīng)該是離我們屏幕較近的點(diǎn),看起來更大,離屏更遠(yuǎn)的點(diǎn),看起來更小,而離屏幕的距離不就是z的坐標(biāo)嗎
我們回到最開始推論的那副圖

在A點(diǎn)觀察直線DB在平面xy內(nèi)的投影OC,由相似三角形可知

推導(dǎo)得

所以假定小圓點(diǎn)的半斤是R,站在A(0,0,)點(diǎn)觀測小圓點(diǎn)位于平面xy上投影的半徑為

我們將draw方法中的代碼做修改
draw: function () {
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.lineList.forEach(line => {
line.pointList.forEach(item => {
this.ctx.beginPath()
// 暫且假定小圓點(diǎn)的原始半徑是2,則投影半徑可表示為
let pointSize = 2 * this.visual.z / (this.visual.z - item.z)
this.ctx.arc(item.canvasX + this.canvasWidth / 2, item.canvasY + this.canvasHeight / 2, pointSize, 0, 2 * Math.PI)
this.ctx.closePath()
this.ctx.fill()
})
})
}
運(yùn)行效果

我們不斷調(diào)整實(shí)例化時候line的各個參數(shù),最終實(shí)現(xiàn)效果

到此,請記住這篇文章最重要的一個結(jié)論
從空間內(nèi)的任意點(diǎn)A(xA,yA,zA)觀察空間內(nèi)的任一點(diǎn)G(xG,yG,zG),它在xy平面內(nèi)的投影H的坐標(biāo)為

如果以后還有與canvas繪制3d圖形有關(guān)的文章,這個結(jié)論會一直用到