更新于 

CSS3DRenderer:元素周期表

CSS3D-PeriodicTable
  • Intro 使用CSS3DObject创建周期表卡片,组合成不同的3D形状
  • Render: CSS3DRenderer
  • Controls: TrackballControls
    • minDistance: 500
    • maxDistance: 6000
  • Camera: PerspectiveCamera
    • fov: 40
    • near: 1
    • far: 10000
    • 初始z: 10000

先不管这个demo里奇奇怪怪的好几种图形变化,
只需要关注几点:

  1. 最开始时卡片的坐标位置
  2. 每次变化结束时卡片的坐标位置
  3. 卡片中间的变换过程
TIP

这个例子很像在上体育课时,
学生们在操场上自由活动,这时的位置都是随机的,
一旦体育老师吹哨集合, 学生们归队到原先指定的位置(事先安排好的)

这样就把实现效果的步骤拆分为了以下部分:

  1. 初始化卡片坐标(随机)
  2. 计算出卡片在结束后应该的坐标
  3. 通过Tween实现卡片移动的连续效果
步骤1:准备工作

”图形学的大部分工作实际上都花在了数据分析上“ ————谁说的来着

除了scene、camera、renderer、controls这些基础场景元素外,
还有resize、animation等标配函数外,
还有一个最最关键的东西需要准备:元素表数据
官方demo使用的是一个类似index索引的包含5个属性的完全被打平的数组:

1
2
3
4
5
6
7
8
9
10
const table =  [
//简称 全称 相对原子质量 行数 列数
'H', 'Hydrogen', '1.00794', 1, 1,
'He', 'Helium', '4.002602', 18, 1,
'Li', 'Lithium', '6.941', 1, 2,
'Be', 'Beryllium', '9.012182', 2, 2,
'B', 'Boron', '10.811', 13, 2,
'C', 'Carbon', '12.0107', 14, 2,
// ....
]

初始化场景中的实体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function init(){
camera = new THREE.PerspectiveCamera(
40,
window.innerWidth/ window.innerHeight,
1,
10000
)
camera.position.z = 3000
scene = new THREE.Scene()

renderer = new CSS3DRenderer()
renderer.setSize(window.innerWidth,window.innerHeight)
document.getElementById('container').appendChild(renderer.domElement)

controls = new TrackballControls(camera, renderer.domElement)
controls.minDistance = 500 // 最小距离
controls.maxDistance = 6000 // 最大距离
controls.addEventListener('change', render)
}
function render(){
renderer.render(scene, camera)
}

这里使用的是TrackballControls,里面设置了2个参数:

  • minDistance 拉镜最小值
  • maxDistance 拉镜最大值

窗口大小resize事件:

1
2
3
4
5
6
function animation(){
window.requestAnimationFrame(animation)
controls.update()
TWEEN.update() // 更新TWEEN
}

动画帧函数:

1
2
3
4
5
function animation(){
window.requestAnimationFrame(animation)
controls.update()
TWEEN.update()
}
步骤2:初始点位和最终点位计算

在整个变化的过程中,都需要一个objects数组,
这个数组存放所有渲染出来的CSS3DObject实例,
同时还需要一个target数组,
和objects数组中的物体一一对应,
因为target中存放的实际是objects中元素对应的目标点位和旋转角度,
因此可以使用Object3D对象存储

1
2
const objects = []
const targets = []

初始化的点位采用随机的方式生成,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
  for(let i = 0; i<table.length;i+=5){

// 1、 创建dom元素
const element = document.createElement('div')
element.className = 'element'
element.style.background = `rgba(0,127,127,${Math.random()*0.5+0.25})`

const number = document.createElement('div')
number.className = 'number'
number.textContent = (i/5)+1
element.appendChild(number)

const symbol = document.createElement('div')
symbol.className = 'symbol'
symbol.textContent = table[i]
element.appendChild(symbol)

const details = document.createElement('div')
details.className = 'details'
details.innerHTML = table[i+1]+'<br>'+table[i+2]
element.appendChild(details)

const objectCSS = new CSS3DObject(element)
// 初始化坐标 在[-2000, 2000]的范围之内
objectCSS.position.x = Math.random() * 4000 - 2000
objectCSS.position.y = Math.random() * 4000 - 2000
objectCSS.position.z = Math.random() * 4000 - 2000
scene.add(objectCSS)
objects.push(objectCSS)

// 最终坐标
const object = new THREE.Object3D()
object.position.x = ( table[ i + 3 ] * 140 ) - 1330;
object.position.y = - ( table[ i + 4 ] * 180 ) + 990;
targets.table.push(object)
}

其中计算特定卡片在场景中的位置,实际上用到的是:

1
2
3
4
// col = 总列数 colIndex = 当前列数 cw = 单张卡片宽度
// row = 总行数 rowIndex = 当前行数 ch = 单张卡片高度
x = ( colIndex - (col+1)/2 ) * cw
y = ( (row+1)/2 - rowIndex ) * ch

球形、螺旋形、栅格形的变换原理也是完全一致的,
不同的是计算的方式:

球形坐标计算

球形坐标系
一个球形坐标系需要3个参数:

  • r(radius-半径)
  • θ(polar angle - 极角)
    • 范围在 0-π 之间
  • φ(azimuthal angle - 方位角)
    • 范围在 0-2π 之间

这里对球形坐标的计算只使用到每个元素的index索引值:

极角的计算首先将index映射到-1到1的范围之内,
再通过反余弦转换为极角值:

计算极角值时,需要注意,arccos函数的参数范围在-1到1之间,
因此需要现将i索引值相对l映射到-1到1之间
(超出这个范围,Math.acos返回NaN)

方向角的范围需要在0-2π之间,
因此在phi之前需要加一个乘数因子,用于扩大坐标之间的间隔。

1
2
3
// i=元素索引 l=元素总数
const phi = Math.acos(i*2/l - 0.5) // 极角值
const theta = Math.sqrt(l * Math.PI) * phi // 方向角

使用Vector3.setFromSphericalCoords获取到坐标,

1
object.position.setFromSphericalCoords(800, phi, theta)

这样得到了坐标点,接下来需要让卡片转向,
实际上就是将卡片坐标P与原点O相连的矢量OP垂直于卡片表面,
可以使用Vector3.multiplyScalar创建一个在OP延长线上的坐标点,
然后让卡片转向它(lookAt):

1
2
vector.copy(object.position).multiplyScalar(2)
object.lookAt(vector)

最终实现球形映射:

1
2
3
4
5
6
7
8
9
10
const vector = new THREE.Vector3()
for(let i = 0, l = objects.length;i<objects.length;i++){
const phi = Math.acos( -1 +(2*i)/l ) // -1 - 1
const theta = Math.sqrt(l * Math.PI) * phi
const object = new THREE.Object3D()
object.position.setFromSphericalCoords(800, phi, theta)
vector.copy(object.position).multiplyScalar(2)
object.lookAt(vector)
targets.sphere.push(object)
}
螺旋坐标的计算

圆柱坐标系
圆柱坐标系的三个参数:

  • 镜像距离
  • 方位角
  • 高度

通过索引计算方向角,

1
const theta = i * 0.175

乘以一个较小的参数用于均匀分布,

接下来计算高度:

1
const y = (-i*8)+450

通过Vector3.setFromCylindricalCoords将坐标转换到柱状坐标系上,
并以和Sphere同样的方式对物体进行旋转:

1
2
3
4
5
object.position.setFromCylindricalCoords(900,theta,y)
vector.x = object.position.x * 2;
vector.y = object.position.y;
vector.z = object.position.z * 2;
object.lookAt(vector)

最终实现螺旋分布:

1
2
3
4
5
6
7
8
9
10
11
  for(let i =0;i<objects.length;i++){
const theta = i * 0.175 + Math.PI
const y = -(i*8)+450
const object = new THREE.Object3D()
object.position.setFromCylindricalCoords(900, theta, y)
vector.x = object.position.x * 2;
vector.y = object.position.y;
vector.z = object.position.z * 2;
object.lookAt(vector)
targets.helix.push(object)
}

实际上,使用球形坐标也能实现螺旋的效果,
但是是球形螺旋体:

1
2
const phi = Math.acos( -1 +(2*i)/l ) // -1 - 1
const theta = 10 * phi
栅格坐标计算
1
2
3
4
5
6
7
  for(let i = 0;i<objects.length;i++){
const object = new THREE.Object3D()
object.position.x = ((i%5) * 400) - 800
object.position.y = (-(Math.floor(i/5)%5)*400) +800;
object.position.z = (Math.floor(i/25))* 1000 - 2000;
targets.grid.push(object)
}
步骤3:过渡函数

通过TWEEN实现坐标的转换。

每次切换图形时,都会触发过渡函数,
TWEEN的使用方法非常简单,
类似于Gsap或animate keyframe,
需要将物体的position和rotation移动到对应的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  for(let i =0;i<objects.length;i++){
const object = objects[i]
const target = targets[i]
new TWEEN.Tween(object.position)
.to({
x:target.position.x,
y:target.position.y,
z:target.position.z,
}, (Math.random()+1)*duration)
.easing(TWEEN.Easing.Exponential.InOut)
.start()

new TWEEN.Tween(object.rotation)
.to({
x:target.rotation.x,
y:target.rotation.y,
z:target.rotation.z,
},(Math.random()+1)*duration)
.easing(TWEEN.Easing.Exponential.InOut)
.start()
}

变换结束后,需要调用render函数进行重绘:

1
2
3
4
new TWEEN.Tween(this)
.to({},duration*2)
.onUpdate(render)
.start()

另外,还需要在最新发起的变换之前,
结束当前正在进行的变换:

1
TWEEN.removeAll()

否则,在频繁的变换之后,会出现变换错乱的现象:

最后,需要在帧动画中对TWEEN进行更新:

1
TWEEN.update()
其他版本