基于three.js的3D炫酷元素周期表

最近在学习 three.js 在拿example中的项目练手,用了一整天的时间模仿了一个炫酷的元素周期表,在原有的基础上进行了一些改变。下面我会逐步讲解这个项目,算是加深理解,让大家提提意见。

因为我未搭建个人服务器。截几张图给大家看看效果我做的效果(大部分是和原来的一样)。可能一部分人已经见过这个经典动画了。(这里是原项目地址: threejs.org/examples/cs…

除了优化了原来的HELIX和GRID形式的的排版之外,我用另外一种方式也创建了两种自定义的排版方式。等会分享给大家。

下面是GitHub仓库地址,文件很简单,就一个HTML文件。想自己手动实现或者拿去用的可以看一下。喜欢的给颗星星,不胜感激(请忽略代码中的注释哈哈)。

github.com/yjhtry/proj…

下面开始分析这个小项目

技术栈

  1. HTML, CSS3, Javascript
  2. three.js, tween.js
  3. 三角函数

实现原理

  1. 利用 three.js 提供的 CSS3DRenderer 渲染器,通过 CSS3 转换属性将分层3D转换应用于 DOM 元素。其实就是包装一下DOM元素,可以像操作three.js中Mesh对象一样去操作 DOM 元素。本质上还是利用 CSS3 的3D动画属性。这个项目就是操作转换后 DOM 元素的 positionrotation 的属性值来创建动画
  2. 使用轻量级动画库 tween '补间'控制 DOM 元素 positionrotation 属性值的过渡。
  3. 确定不同排版的每一个 DOM 元素的 positionrotation (部分排版需要确定 rotation )的值,并将之保存在 THREE.Object3 D的子对象的 position 属性中(也可以是一组想象数组后面我会详细讲解),然后使用‘补间’将 DOM 元素的 positionrotation 像其保存的对应属性值过渡。

话不多说,直接上代码。

HTML结构

<div id="container">
    	<!-- 选中菜单结构 start-->
    	<div id="menu">
    		<button id="table">TABLE</button>
    		<button id="sphere">SPHERE</button>
    		<button id="sphere2">SPHERE2</button>
    		<button id="plane">PLANE</button>
    		<button id="helix">HELIX</button>
    		<button id="grid">GRID</button>
    	</div>
    	<!-- end -->
    </div>复制代码

HTML部分非常简单仅仅是一个包含六个控制转换的按钮的选择栏,下面看看他们的样式

#menu {
		position: absolute; 
		z-index: 100;
		width: 100%; 
		bottom: 50px; 
		text-align: center; 
		font-size: 32px 
	}

	button {
		border: none;
		background-color: transparent; 
		color: rgba( 127, 255, 255, 0.75 ); 
		padding: 12px 24px; 
		cursor: pointer; 
		outline: 1px solid rgba( 127, 255, 255, 0.75 );
	}

	button:hover { 
		background-color: rgba( 127, 255, 255, 0.5 ) 
	}

	button:active { 
		background-color: rgba( 127, 255, 255, 0.75 ) 
	}复制代码

首先将选择栏绝对定位到窗口底部 50px 处,这里注意 z-index: 100 ,将其层级设置为最高可以防止 hover,click事件被其它元素拦截。然后清除button默认样式,并给它增加了:hover和:active伪类,使交互更生动。

效果如下:

然后是118个DOM元素的结构和样式,因为他们是在JavaScript代码中动态创建了,这里我单独写了一个元素的结构。

<div class="element">
    <div class="number">1</div>		
    <div class="symbol">H</div>
    <div class="detail">Hydrogen<br>1.00794</div>
</div>复制代码

CSS样式

.element {
		width: 120px;
		height: 160px;
		cursor: default;
		text-align: center;
		border: 1px solid rgba( 127, 255, 255, 0.25 );
		box-shadow: 0 0 12px rgba( 0, 255, 255, 0.5 );
	}

	.element:hover{ 
		border: 1px solid rgba( 127, 255, 255, 0.75 ); 
		box-shadow: 0 0 12px rgba( 0, 255, 255, 0.75 ); 
	}

	.element .number {
		position: absolute; 
		top: 20px; 
		right: 20px; 
		font-size: 12px; 
		color: rgba( 127, 255, 255, 0.75 ); 
	}

	.element .symbol {
		position: absolute; 
		top: 40px; 
		left: 0px; 
		right: 0; 
		font-size: 60px; 
		font-weight: bold; 
		color: rgba( 255, 255, 255, 0.75 ); 
		text-shadow: 0 0 10px rgba( 0, 255, 255, 0.95 );
	}

	.element .detail {
		position: absolute; 
		left: 0; 
		right: 0; 
		bottom: 15px; 
		font-size: 12px; 
		color: rgba( 127, 255, 255, 0.75 ); 
	}复制代码

注意 box-shadowtext-shadow。 下面是效果图

通过 box-shadowtext-shadow 使DOM元素产生了立体感。

JavaScript部分

首先定义了118个元素的数据储存结构,这里使用的是数组(因外数量较多,我只拿过来前二十五个,github代码中有完整数据)

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,
			"N", "Nitrogen", "14.0067", 15, 2,
			"O", "Oxygen", "15.9994", 16, 2,
			"F", "Fluorine", "18.9984032", 17, 2,
			"Ne", "Neon", "20.1797", 18, 2,
			"Na", "Sodium", "22.98976...", 1, 3,
			"Mg", "Magnesium", "24.305", 2, 3,
			"Al", "Aluminium", "26.9815386", 13, 3,
			"Si", "Silicon", "28.0855", 14, 3,
			"P", "Phosphorus", "30.973762", 15, 3,
			"S", "Sulfur", "32.065", 16, 3,
			"Cl", "Chlorine", "35.453", 17, 3,
			"Ar", "Argon", "39.948", 18, 3,
			"K", "Potassium", "39.948", 1, 4,
			"Ca", "Calcium", "40.078", 2, 4,
			"Sc", "Scandium", "44.955912", 3, 4,
			"Ti", "Titanium", "47.867", 4, 4,
			"V", "Vanadium", "50.9415", 5, 4,
			"Cr", "Chromium", "51.9961", 6, 4,
			"Mn", "Manganese", "54.938045", 7, 4
            ]复制代码

先来分析一下这个数据结构

"H", "Hydrogen", "1.00794", 1, 1,复制代码

一共118个元素,每个元素在table数组定义了五条数据分别是符号(symbol),英文全称,质量(detail),元素在表格排版中所在的列(column)和行(row)这两个数据在创建表格盘版的时我会说明使用方法。

let scene, camera, renderer, controls;
		const objects = [];
		const targets = { 
			grid: [],
			helix: [], 
			table: [], 
			sphere: [] 
		};复制代码

这里定义了一些全局变量。scene,camera,renderer是three.js的环境对象,相机及渲染器。controls是three.js提供控制库,用于与用户交互,很简单。objects用于存储118个DOM元素。targets对象包含四个数组类型的属性值,用来保存存有不同排版目标位置的Object3D子对象。

元素的创建以及动画的控制由 init函数执行,下面主要的篇幅用于将它

function init() {

    const felidView   = 40;
    const width       = window.innerWidth;
    const height      = window.innerHeight;
    const aspect      = width / height;
    const nearPlane   = 1;
    const farPlane    = 10000;
    const WebGLoutput = document.getElementById('container');

    scene    = new THREE.Scene();
    camera   = new THREE.PerspectiveCamera( felidView, aspect, nearPlane, farPlane );
    camera.position.z = 3000;
			
    renderer = new THREE.CSS3DRenderer();
    renderer.setSize( width, height );
    renderer.domElement.style.position = 'absolute';
    WebGLoutput.appendChild( renderer.domElement );

 复制代码

(可能我的代码缩进比较奇怪,我主要是为了趣味性哈哈)这段代码创建了three.js的三个基本组件,场景,相机(perspectiveCamera),渲染器。这里需要注意的是,这里的far-clipping-plane设置 的值比较大,自己做的话可以设置小一些,降低性能损耗。注意这里采用的是CSS3D渲染器。

透视相机的视锥图

平面之间的部分被称为视锥,简单点来说就是相机的拍摄区域。图上的fov(视场)是相机的第一个参数,决定了相机拍摄范围的大小,类似于人眼的横向视域(大于180deg了吧)。aspect参数控制相机投影平面的宽高比(一般是canvas的宽高比)这个主要是为了防止图片变形,因为投影平面上的图像最终会通过canvas显示。注意使用CSS3D渲染器时,显示视口是div元素。

let i   = 0;
let len = table.length;

for ( ; i < len; i += 5 ) {

    const element      		  = document.createElement('div');
    element.className 		  = 'element';
    element.style.backgroundColor = `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 detail 	= document.createElement('div');
    detail.className 	= 'detail';
    detail.innerHTML 	= `${ table[ i + 1 ] }<br/>${ table[ i + 2 ] }`;
    element.appendChild( detail );

    const object 	= new THREE.CSS3DObject( element );
    object.position.x   = Math.random() * 4000 - 2000;
    object.position.y   = Math.random() * 4000 - 2000;
    object.position.z   = Math.random() * 4000 - 2000;

    scene.add( object );
    objects.push( object );

		}复制代码

这段代码创建了显示周期表元素的HTML结构,并将每一个DOM元素使用THREE.CSS3DObject类包装成3D对象。然后随机分配对象的位置在( -2000, 2000 )这个区间内。最后把对象添加场景中,并放入objects数组中保存,为在后面的动画做准备。

上面的已经完成了118元素的创建到随机分配位置显示的部分。下面开始创建集中排版需要的数据。

table排版

function createTableVertices() {

    let i = 0;

    for ( ; i < len; i += 5 ) {

    const object      = new THREE.Object3D();

    // [ clumn 18 ]
    object.position.x = table[ i  + 3 ] * 140 - 1260;
    object.position.y = -table[ i + 4 ] * 180 + 1000;
    object.position.z = 0;

    targets.table.push( object );

	}
}复制代码

这个排版比较简单,使用table数组中每个元素的第四个数据(column)和第五个数据(row)直接就可以的到每个元素对应的table排版的位置信息,然后将它们赋值给对应的object.position属性中保存(这个不一定非要这样,只要是THREE.Vector3类型的数据就可以)。最后将对象保存到对应的数组中,以便在动画中使用。

shpere排版

const objLength = objects.length;

function createSphereVertices() {

	let i = 0;
	const vector  = new THREE.Vector3();

	for ( ; i < objLength; ++i ) {

	    let phi   = Math.acos( -1 + ( 2  * i ) / objLength );
	    let theta = Math.sqrt( objLength * Math.PI ) * phi;
	    const object      = new THREE.Object3D();

	    object.position.x =  800 * Math.cos( theta ) * Math.sin( phi );
	    object.position.y =  800 * Math.sin( theta ) * Math.sin( phi );
	    object.position.z = -800 * Math.cos( phi );

	    // rotation object 
					
	    vector.copy( object.position ).multiplyScalar( 2 );
	    object.lookAt( vector );
	    targets.sphere.push( object );
	}

}复制代码

说实话这段代码理解的不是很到位总感觉原作者的算法复杂化了,代码贴出来请大佬分析一下。后面我自己用别的方法实现了一种‘圆’不是很好看,但是很好理解。我先说一下 vector 这个变量的作用,它用来作为'目标位置',使用 object.lookAt( vector ) 这个方法让这个位置的对象看向 vector 这一点所在的方向,在three.js的内部会将 object 旋转以‘看向 vector ’。将得到旋转的值并保存在 object 对象的 rotation 属性中,在动画中将元素对象的rotation属性过渡为对应的值,使其旋转。

 helix排版

function createHelixVertices() {

        let i = 0;
	const vector = new THREE.Vector3();

	for ( ; i < objLength; ++i ) {

	    let phi = i * 0.213 + Math.PI;

	    const object = new THREE.Object3D();

	    object.position.x = 800  * Math.sin( phi );
	    object.position.y = -( i * 8 ) + 450;
	    object.position.z = 800  * Math.cos( phi + Math.PI );

	    object.scale.set( 1.1, 1.1, 1.1 );

	    vector.x = object.position.x * 2;
	    vector.y = object.position.y;
	    vector.z = object.position.z * 2;

	    object.lookAt( vector );
	    targets.helix.push( object );

	}

}复制代码

这个排版很好理解,首先看一下Y轴采取的是在Y方向上逐个下降的算法。如果X,Z轴不做处理那就是延Y轴的排成一排。然后我讲一下这个0.213是怎么取的

因为总共118个元素,如果想让这些元素排列成圆的用上图的的两种函数就可以,我使用的是正弦函数,有图可以看出使118个元素排成四个圆只需要给每一个元素一个对应的角度,再通过Math.sin( angle )或Math.cos( angle )计算后,得到四组周期性的值,元素就会呈圆形排列。通过计算公式4 * Math.PI * 2 / 118得出0.213,这样每一个元素在周期表中的位置(这里是从0开始。)乘以0.213,得到与其对应的角度。使用这个角度通过正玄余玄函数得到在圆中的位置。

grid排版

function createGridVertices() {

	let i = 0;

	for ( ; i < objLength; ++i ) {

	    const object      = new THREE.Object3D();

	    object.position.x =  360  * ( i   % 5) - 800;
	    object.position.y = -360  * ( ( i /  5 >> 0 ) % 5 ) + 700;
	    object.position.z = -700  * ( i   / 25 >> 0 );

	    targets.grid.push( object );

	}
}复制代码

网格布局使用的主要是分组的思想,这是个5 * 5的网格。在X轴上的布局采用求余可以使元素分为五列,在Y轴上先除以5然后取整(这里我喜欢使用>>位操作符,和Math.floor一个效果)。这样做是为元素分行,然后求余分列。当一个平面内5 * 5排满后,在Z轴上判断元素属于哪一面。

上面四种布局是原来的经典布局,原作者使用的是将每个元素将要过低的位置保存起来。还有两种布局是我通过这种思想延伸的,比较偷懒,也很简单。先看一下是如何使用tween动画库来完成元素位置的过渡。

const gridBtn    = document.getElementById('grid');
const tableBtn   = document.getElementById('table');
const helixBtn   = document.getElementById('helix');
const sphereBtn  = document.getElementById('sphere');

gridBtn.addEventListener(    'click', function() { transform( targets.grid,   2000 )},   false );
tableBtn.addEventListener(   'click', function() { transform( targets.table,  2000 ) },  false );
helixBtn.addEventListener(   'click', function() { transform( targets.helix,  2000 ) },  false );
sphereBtn.addEventListener(  'click', function() { transform( targets.sphere, 2000 ) },  false );复制代码
function transform( targets, duration ) {

        TWEEN.removeAll();

	for ( let i = 0; i < objLength; ++i ) {

	let object = objects[ i ];
	let target = targets[ i ];

	new TWEEN.Tween( object.position )
	    .to( { x: target.position.x, y: target.position.y, z: target.position.z },
                                                Math.random() * duration + 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() * duration + duration )
	    .easing( TWEEN.Easing.Exponential.InOut )
	    .start();

	}

	// 这个补间用来在位置与旋转补间同步执行,通过onUpdate在每次更新数据后渲染scene和camera
	new TWEEN.Tween( {} )
	    .to( {}, duration * 2 )
	    .onUpdate( render )
	    .start();

}复制代码

从事件绑定的回调可以看出,触发不同的排版时,我们传入对应的数据。然后将数据取出通过tween.js过渡这些数据产生动画。这里有tween.js使用的详细介绍 github.com/tweenjs/twe…

循环之外的的这个‘补间’是用来在动画过渡期间执行渲染页面函数的。如下

function render() {

        renderer.render( scene, camera );

}复制代码

onWindowResize 函数用于缩放页面时更新相机参数,场景大小以及重新渲染画面

animation 通过 requestAnimationFrame 这个动画神器刷新‘所有补间数据’,更新trackball控制器

function onWindowResize() {

	camera.aspect = window.innerWidth / window.innerHeight
	camera.updateProjectionMatrix();

	renderer.setSize( window.innerWidth, window.innerHeight );
	render();

}
		
function animation() {

        TWEEN.update();
        controls.update();
	requestAnimationFrame( animation );	
}复制代码

最后说一下我拓展的两种‘投机取巧的排版’

const sphere2Btn = document.getElementById('sphere2');
sphere2Btn.addEventListener( 'click', function() { transformSphere2( 2000 ) },  false );

function transformSphere2(duration) {

        TWEEN.removeAll();

	const sphereGeom = new THREE.SphereGeometry( 800, 12, 11 );
	const vertices = sphereGeom.vertices;
	const vector = new THREE.Vector3();

	for ( let i = 0; i < objLength; ++i ) {

		const target = new THREE.Object3D();

		target.position.copy(vertices[i]);
		vector.copy( target.position ).multiplyScalar( 2 );
		target.lookAt( vector );

		let object = objects[ i ];

		new TWEEN.Tween( object.position )
			.to( vertices[i],
			Math.random() * duration + 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() * duration + duration )
			.easing( TWEEN.Easing.Exponential.InOut )
			.start();

	}

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

}复制代码

整个动画的原理: 为每个元素创建一个目标位置,这些位置组合产生的排版就是元素最终的排版,通过‘补间’过渡位置的转换。所以我直接使用three.js内置的几何体,使用它的vertices属性中的位置作为目标位置(有一点限制,vertices中顶点(位置)的数目最好接近118)。这样通过内置的几何体我们可以不进行数学计算,直接创建一些有意思的排版。

写到这里讲的也差不多了,我是一个刚入门前端的菜鸟,欢迎大家的指点和批评!喜欢的同学可以给个赞哦!

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章