JavaScript 線性代數:使用 ThreeJS 製作線性變換動畫
本文是“JavaScript 線性代數”教程的一部分。
最近我完成了一篇關於使用 JavaScript 進行線性變換的文章,並用 SVG 網格實現了 2D 的示例。你可以在此處檢視之前的文章。但是,那篇文章沒有三維空間的示例,因此本文將補全那篇文章的缺失。你可以在此處檢視本系列文章的 GitHub 倉庫,與本文相關的 commit 可以在此處檢視。
目標
在本文中,我們將製作一個元件,用於對三維空間的物件的線性變換進行視覺化。最終效果如下面的動圖所示,或者你也可以在此網頁體驗。
元件
當我們要在瀏覽器中製作 3D 動畫時,第一個想到的當然就是 three.js 庫啦。所以讓我們來安裝它以及另一個可以讓使用者移動攝像機的庫:
npm install --save three three-orbitcontrols
複製程式碼
下面構建一個元件,它可以由父元件的屬性中接收矩陣,並且渲染一個立方體的轉換動畫。下面程式碼展示了這個元件的結構。我們用 styled-components 和 react-sizeme 庫中的函式對這個元件進行了包裝,以訪問顏色主題和檢測元件尺寸的變化。
import React from 'react'
import { withTheme } from 'styled-components'
import { withSize } from 'react-sizeme'
class ThreeScene extends React.Component {
constructor(props) {}
render() {}
componentDidMount() {}
componentWillUnmount() {}
animate = () => {}
componentWillReceiveProps({ size: { width, height } }) {}
}
const WrappedScene = withTheme(withSize({ monitorHeight: true })(ThreeScene))
複製程式碼
在建構函式中,我們對狀態進行了初始化,其中包括了檢視的大小。因此,我們當接收新的狀態值時,可以在 componentWillReceiveProps 方法中與初始狀態進行對比。由於需要訪問實際的 DOM 元素以注入 ThreeJS 的 renderer,因此需要在 render 方法中用到 ref 屬性:
const View = styled.div`
width: 100%;
height: 100%;
`
class ThreeScene extends React.Component {
// ...
constructor(props) {
super(props)
this.state = {
width: 0,
height: 0
}
}
render() {
return <View ref={el => (this.view = el)} />
}
// ...
}
複製程式碼
在 componentDidMount 方法中,我們對方塊變換動畫所需要的所有東西都進行了初始化。首先,我們建立了 ThreeJS 的場景(scene)並確定好攝像機(camera)的位置,然後我們建立了 ThreeJS 的 renderer,為它設定好了顏色及大小,最後將 renderer 加入到 View 元件中。
接下來建立需要進行渲染的物件:座標軸、方塊以及方塊的邊。由於我們需要手動改變矩陣,因此將方塊和邊的 matrixAutoUpdate 屬性設為 false。建立好這些物件後,將它們加入場景(scene)中。為了讓使用者可以通過滑鼠來移動攝像機位置,我們還用到了 OrbitControls。
最後要做的,就是將我們的庫輸出的矩陣轉換成 ThreeJS 的格式,然後獲取根據時間返回顏色和轉換矩陣的函式。在 componentWillUnmount,取消動畫(即停止 anime frame)並從 DOM 移除 renderer。
class ThreeScene extends React.Component {
// ...
componentDidMount() {
const {
size: { width, height },
matrix,
theme
} = this.props
this.setState({ width, height })
this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(100, width / height)
this.camera.position.set(1, 1, 4)
this.renderer = new THREE.WebGLRenderer({ antialias: true })
this.renderer.setClearColor(theme.color.background)
this.renderer.setSize(width, height)
this.view.appendChild(this.renderer.domElement)
const initialColor = theme.color.red
const axes = new THREE.AxesHelper(4)
const geometry = new THREE.BoxGeometry(1, 1, 1)
this.segments = new THREE.LineSegments(
new THREE.EdgesGeometry(geometry),
new THREE.LineBasicMaterial({ color: theme.color.mainText })
)
this.cube = new THREE.Mesh(
geometry,
new THREE.MeshBasicMaterial({ color: initialColor })
)
this.objects = [this.cube, this.segments]
this.objects.forEach(obj => (obj.matrixAutoUpdate = false))
this.scene.add(this.cube, axes, this.segments)
this.controls = new OrbitControls(this.camera)
this.getAnimatedColor = getGetAnimatedColor(
initialColor,
theme.color.blue,
PERIOD
)
const fromMatrix = fromMatrix4(this.cube.matrix)
const toMatrix = matrix.toDimension(4)
this.getAnimatedTransformation = getGetAnimatedTransformation(
fromMatrix,
toMatrix,
PERIOD
)
this.frameId = requestAnimationFrame(this.animate)
}
componentWillUnmount() {
cancelAnimationFrame(this.frameId)
this.view.removeChild(this.renderer.domElement)
}
// ...
}
複製程式碼
不過此時我們還沒有定義 animate 函式,因此什麼也不會渲染。首先,我們更新立方體及其邊緣的轉換矩陣,並且更新立方體的顏色,然後進行渲染並且呼叫 window.requestAnimationFrame
。
componentWillReceiveProps 方法將接收當前元件的大小,當它檢測到元件尺寸發生了變化時,會更新狀態,改變 renderer 的尺寸,並調整 camera 的方位。
class ThreeScene extends React.Component {
// ...
animate = () => {
const transformation = this.getAnimatedTransformation()
const matrix4 = toMatrix4(transformation)
this.cube.material.color.set(this.getAnimatedColor())
this.objects.forEach(obj => obj.matrix.set(...matrix4.toArray()))
this.renderer.render(this.scene, this.camera)
this.frameId = window.requestAnimationFrame(this.animate)
}
componentWillReceiveProps({ size: { width, height } }) {
if (this.state.width !== width || this.state.height !== height) {
this.setState({ width, height })
this.renderer.setSize(width, height)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
}
}
}
複製程式碼
動畫
為了將顏色變化以及矩陣變換做成動畫,需要寫個函式來返回動畫函式。在寫這塊函式前,我們先要完成以下兩種轉換器:將我們庫的矩陣轉換為 ThreeJS 格式矩陣的函式,以及參考 StackOverflow 上程式碼的將 RGB 轉換為 hex 的函式:
import * as THREE from 'three'
import { Matrix } from 'linear-algebra/matrix'
export const toMatrix4 = matrix => {
const matrix4 = new THREE.Matrix4()
matrix4.set(...matrix.components())
return matrix4
}
export const fromMatrix4 = matrix4 => {
const components = matrix4.toArray()
const rows = new Array(4)
.fill(0)
.map((_, i) => components.slice(i * 4, (i + 1) * 4))
return new Matrix(...rows)
}
複製程式碼
import * as THREE from 'three'
import { Matrix } from 'linear-algebra/matrix'
export const toMatrix4 = matrix => {
const matrix4 = new THREE.Matrix4()
matrix4.set(...matrix.components())
return matrix4
}
export const fromMatrix4 = matrix4 => {
const components = matrix4.toArray()
const rows = new Array(4)
.fill(0)
.map((_, i) => components.slice(i * 4, (i + 1) * 4))
return new Matrix(...rows)
}
複製程式碼
顏色
首先,需要計算每種原色(RGB)變化的幅度。第一次呼叫 getGetAnimatedColor 時會返回新的色彩與時間戳的集合;並在後續被呼叫時,通過顏色變化的距離以及時間的耗費,可以計算出當前時刻新的 RGB 顏色:
import { hexToRgb, rgbToHex } from './generic'
export const getGetAnimatedColor = (fromColor, toColor, period) => {
const fromRgb = hexToRgb(fromColor)
const toRgb = hexToRgb(toColor)
const distances = fromRgb.map((fromPart, index) => {
const toPart = toRgb[index]
return fromPart <= toPart ? toPart - fromPart : 255 - fromPart + toPart
})
let start
return () => {
if (!start) {
start = Date.now()
}
const now = Date.now()
const timePassed = now - start
if (timePassed > period) return toColor
const animatedDistance = timePassed / period
const rgb = fromRgb.map((fromPart, index) => {
const distance = distances[index]
const step = distance * animatedDistance
return Math.round((fromPart + step) % 255)
})
return rgbToHex(...rgb)
}
}
複製程式碼
線性變換
為了給線性變換做出動畫效果,同樣要進行上節的操作。我們首先找到矩陣變換前後的區別,然後在動畫函式中,根據第一次呼叫 getGetAnimatedTransformation 時的狀態,根據時間來更新各個元件的狀態:
export const getGetAnimatedTransformation = (fromMatrix, toMatrix, period) => {
const distances = toMatrix.subtract(fromMatrix)
let start
return () => {
if (!start) {
start = Date.now()
}
const now = Date.now()
const timePassed = now - start
if (timePassed > period) return toMatrix
const animatedDistance = timePassed / period
const newMatrix = fromMatrix.map((fromComponent, i, j) => {
const distance = distances.rows[i][j]
const step = distance * animatedDistance
return fromComponent + step
})
return newMatrix
}
}
複製程式碼
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。