[译] JavaScript 线性代数:使用 ThreeJS 制作线性变换动画

Love The Way You Lie 2022-01-10 08:23 744阅读 0赞
  • 原文地址:Linear Algebra with JavaScript: Animating Linear Transformations with ThreeJS
  • 原文作者:Rodion Chachura
  • 译文出自:掘金翻译计划
  • 本文永久链接:github.com/xitu/gold-m…
  • 译者:lsvih
  • 校对者:lgh757079506, Stevens1995

JavaScript 线性代数:使用 ThreeJS 制作线性变换动画

本文是“JavaScript 线性代数”教程的一部分。

最近我完成了一篇关于使用 JavaScript 进行线性变换的文章,并用 SVG 网格实现了 2D 的示例。你可以在此处查看之前的文章。但是,那篇文章没有三维空间的示例,因此本文将补全那篇文章的缺失。你可以在此处查看本系列文章的 GitHub 仓库,与本文相关的 commit 可以在此处查看。

目标

在本文中,我们将制作一个组件,用于对三维空间的对象的线性变换进行可视化。最终效果如下面的动图所示,或者你也可以在此网页体验。

组件

当我们要在浏览器中制作 3D 动画时,第一个想到的当然就是 three.js 库啦。所以让我们来安装它以及另一个可以让用户移动摄像机的库:

  1. npm install --save three three-orbitcontrols
  2. 复制代码

下面构建一个组件,它可以由父组件的属性中接收矩阵,并且渲染一个立方体的转换动画。下面代码展示了这个组件的结构。我们用 styled-componentsreact-sizeme 库中的函数对这个组件进行了包装,以访问颜色主题和检测组件尺寸的变化。

  1. import React from 'react'
  2. import { withTheme } from 'styled-components'
  3. import { withSize } from 'react-sizeme'
  4. class ThreeScene extends React.Component {
  5. constructor(props) {}
  6. render() {}
  7. componentDidMount() {}
  8. componentWillUnmount() {}
  9. animate = () => {}
  10. componentWillReceiveProps({ size: { width, height } }) {}
  11. }
  12. const WrappedScene = withTheme(withSize({ monitorHeight: true })(ThreeScene))
  13. 复制代码

构造函数中,我们对状态进行了初始化,其中包括了视图的大小。因此,我们当接收新的状态值时,可以在 componentWillReceiveProps 方法中与初始状态进行对比。由于需要访问实际的 DOM 元素以注入 ThreeJSrenderer,因此需要在 render 方法中用到 ref 属性:

  1. const View = styled.div` width: 100%; height: 100%; `
  2. class ThreeScene extends React.Component {
  3. // ...
  4. constructor(props) {
  5. super(props)
  6. this.state = {
  7. width: 0,
  8. height: 0
  9. }
  10. }
  11. render() {
  12. 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

  1. class ThreeScene extends React.Component {
  2. // ...
  3. componentDidMount() {
  4. const {
  5. size: { width, height },
  6. matrix,
  7. theme
  8. } = this.props
  9. this.setState({ width, height })
  10. this.scene = new THREE.Scene()
  11. this.camera = new THREE.PerspectiveCamera(100, width / height)
  12. this.camera.position.set(1, 1, 4)
  13. this.renderer = new THREE.WebGLRenderer({ antialias: true })
  14. this.renderer.setClearColor(theme.color.background)
  15. this.renderer.setSize(width, height)
  16. this.view.appendChild(this.renderer.domElement)
  17. const initialColor = theme.color.red
  18. const axes = new THREE.AxesHelper(4)
  19. const geometry = new THREE.BoxGeometry(1, 1, 1)
  20. this.segments = new THREE.LineSegments(
  21. new THREE.EdgesGeometry(geometry),
  22. new THREE.LineBasicMaterial({ color: theme.color.mainText })
  23. )
  24. this.cube = new THREE.Mesh(
  25. geometry,
  26. new THREE.MeshBasicMaterial({ color: initialColor })
  27. )
  28. this.objects = [this.cube, this.segments]
  29. this.objects.forEach(obj => (obj.matrixAutoUpdate = false))
  30. this.scene.add(this.cube, axes, this.segments)
  31. this.controls = new OrbitControls(this.camera)
  32. this.getAnimatedColor = getGetAnimatedColor(
  33. initialColor,
  34. theme.color.blue,
  35. PERIOD
  36. )
  37. const fromMatrix = fromMatrix4(this.cube.matrix)
  38. const toMatrix = matrix.toDimension(4)
  39. this.getAnimatedTransformation = getGetAnimatedTransformation(
  40. fromMatrix,
  41. toMatrix,
  42. PERIOD
  43. )
  44. this.frameId = requestAnimationFrame(this.animate)
  45. }
  46. componentWillUnmount() {
  47. cancelAnimationFrame(this.frameId)
  48. this.view.removeChild(this.renderer.domElement)
  49. }
  50. // ...
  51. }
  52. 复制代码

不过此时我们还没有定义 animate 函数,因此什么也不会渲染。首先,我们更新立方体及其边缘的转换矩阵,并且更新立方体的颜色,然后进行渲染并且调用 window.requestAnimationFrame

componentWillReceiveProps 方法将接收当前组件的大小,当它检测到组件尺寸发生了变化时,会更新状态,改变 renderer 的尺寸,并调整 camera 的方位。

  1. class ThreeScene extends React.Component {
  2. // ...
  3. animate = () => {
  4. const transformation = this.getAnimatedTransformation()
  5. const matrix4 = toMatrix4(transformation)
  6. this.cube.material.color.set(this.getAnimatedColor())
  7. this.objects.forEach(obj => obj.matrix.set(...matrix4.toArray()))
  8. this.renderer.render(this.scene, this.camera)
  9. this.frameId = window.requestAnimationFrame(this.animate)
  10. }
  11. componentWillReceiveProps({ size: { width, height } }) {
  12. if (this.state.width !== width || this.state.height !== height) {
  13. this.setState({ width, height })
  14. this.renderer.setSize(width, height)
  15. this.camera.aspect = width / height
  16. this.camera.updateProjectionMatrix()
  17. }
  18. }
  19. }
  20. 复制代码

动画

为了将颜色变化以及矩阵变换做成动画,需要写个函数来返回动画函数。在写这块函数前,我们先要完成以下两种转换器:将我们库的矩阵转换为 ThreeJS 格式矩阵的函数,以及参考 StackOverflow 上代码的将 RGB 转换为 hex 的函数:

  1. import * as THREE from 'three'
  2. import { Matrix } from 'linear-algebra/matrix'
  3. export const toMatrix4 = matrix => {
  4. const matrix4 = new THREE.Matrix4()
  5. matrix4.set(...matrix.components())
  6. return matrix4
  7. }
  8. export const fromMatrix4 = matrix4 => {
  9. const components = matrix4.toArray()
  10. const rows = new Array(4)
  11. .fill(0)
  12. .map((_, i) => components.slice(i * 4, (i + 1) * 4))
  13. return new Matrix(...rows)
  14. }
  15. 复制代码
  16. import * as THREE from 'three'
  17. import { Matrix } from 'linear-algebra/matrix'
  18. export const toMatrix4 = matrix => {
  19. const matrix4 = new THREE.Matrix4()
  20. matrix4.set(...matrix.components())
  21. return matrix4
  22. }
  23. export const fromMatrix4 = matrix4 => {
  24. const components = matrix4.toArray()
  25. const rows = new Array(4)
  26. .fill(0)
  27. .map((_, i) => components.slice(i * 4, (i + 1) * 4))
  28. return new Matrix(...rows)
  29. }
  30. 复制代码

颜色

首先,需要计算每种原色(RGB)变化的幅度。第一次调用 getGetAnimatedColor 时会返回新的色彩与时间戳的集合;并在后续被调用时,通过颜色变化的距离以及时间的耗费,可以计算出当前时刻新的 RGB 颜色:

  1. import { hexToRgb, rgbToHex } from './generic'
  2. export const getGetAnimatedColor = (fromColor, toColor, period) => {
  3. const fromRgb = hexToRgb(fromColor)
  4. const toRgb = hexToRgb(toColor)
  5. const distances = fromRgb.map((fromPart, index) => {
  6. const toPart = toRgb[index]
  7. return fromPart <= toPart ? toPart - fromPart : 255 - fromPart + toPart
  8. })
  9. let start
  10. return () => {
  11. if (!start) {
  12. start = Date.now()
  13. }
  14. const now = Date.now()
  15. const timePassed = now - start
  16. if (timePassed > period) return toColor
  17. const animatedDistance = timePassed / period
  18. const rgb = fromRgb.map((fromPart, index) => {
  19. const distance = distances[index]
  20. const step = distance * animatedDistance
  21. return Math.round((fromPart + step) % 255)
  22. })
  23. return rgbToHex(...rgb)
  24. }
  25. }
  26. 复制代码

线性变换

为了给线性变换做出动画效果,同样要进行上节的操作。我们首先找到矩阵变换前后的区别,然后在动画函数中,根据第一次调用 getGetAnimatedTransformation 时的状态,根据时间来更新各个组件的状态:

  1. export const getGetAnimatedTransformation = (fromMatrix, toMatrix, period) => {
  2. const distances = toMatrix.subtract(fromMatrix)
  3. let start
  4. return () => {
  5. if (!start) {
  6. start = Date.now()
  7. }
  8. const now = Date.now()
  9. const timePassed = now - start
  10. if (timePassed > period) return toMatrix
  11. const animatedDistance = timePassed / period
  12. const newMatrix = fromMatrix.map((fromComponent, i, j) => {
  13. const distance = distances.rows[i][j]
  14. const step = distance * animatedDistance
  15. return fromComponent + step
  16. })
  17. return newMatrix
  18. }
  19. }
  20. 复制代码

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。

转载于:https://juejin.im/post/5d05dba86fb9a07ece67ce76

发表评论

表情:
评论列表 (有 0 条评论,744人围观)

还没有评论,来说两句吧...

相关阅读