基于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_元素的_position_和_rotation_的属性值来创建动画
  2. 使用轻量级动画库_tween_'补间'控制_DOM_元素_position_和_rotation_属性值的过渡。
  3. 确定不同排版的每一个_DOM_元素的_position_和_rotation_(部分排版需要确定_rotation_)的值,并将之保存在_THREE.Object3_D的子对象的_position_属性中(也可以是一组想象数组后面我会详细讲解),然后使用‘补间’将_DOM_元素的_position_和_rotation_像其保存的对应属性值过渡。

话不多说,直接上代码。

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部分非常简单仅仅是一个包含六个控制转换的按钮的选择栏,下面看看他们的样式

 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-shadow_和_text-shadow。_下面是效果图

通过_box-shadow_和_text-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)。这样通过内置的几何体我们可以不进行数学计算,直接创建一些有意思的排版。

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

Home - Wiki
Copyright © 2011-2024 iteam. Current version is 2.139.0. UTC+08:00, 2024-12-23 10:39
浙ICP备14020137号-1 $Map of visitor$