今天简单实现一个three.js的小Demo,加强自己对three知识的掌握与学习,只有在项目中才能灵活将所学知识运用起来,话不多说直接开始。
目录
项目搭建
本案例还是借助框架书写three项目,借用vite构建工具搭建vue项目,vite这个构建工具如果有不了解的朋友,可以参考我之前对其讲解的文章:vite脚手架的搭建与使用。搭建完成之后,用编辑器打开该项目,在终端执行 npm i 安装一下依赖,安装完成之后终端在安装 npm i three 即可。
因为我搭建的是vue3项目,为了便于代码的可读性,所以我将three.js代码单独抽离放在一个js文件当中,在views下的index.vue文件中使用该js文件,然后再将index.vue组件引入根组件。具体如下:
<template> <div ref="canvasDom" id="canvasDom"></div> </template> <script setup> import { reactive, onMounted } from 'vue' import Base from "../components/scene.js" let data = reactive({ base3d: {}, }) onMounted(() => { data.base3d = new Base("#canvasDom") }) </script> <style scoped> #canvasDom { width: 100%; height: 100%; } </style>
接下来我们重点的three代码就不像之前的项目Demo一样直接写在vue组件中,例子 。这里我们直接将其放在一个js文件中,当然这里也是需要对three代码进行初始化代码处理,如下我们先定义一个基础的class类,将要使用的场景、相机、渲染器和渲染函数先定义起来:
import * as THREE from 'three' class Base { constructor(selector) { this.container = document.querySelector(selector) this.scene this.camera this.renderer this.init() this.animate() } init() { this.initScene() // 初始化场景 this.initCamera() // 初始化相机 this.initRenderer() // 初始化渲染器 this.initControl() // 初始化控制器 this.windowSizeChange() // 初始化窗口大小 } } export default Base
初始化场景:
initScene() { // 初始化场景 this.scene = new THREE.Scene() // 创建场景 }
初始化相机:
initCamera() { // 创建透视相机 this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 10); // 设置相机位置 this.camera.position.set(0, 15, 20); // 将相机添加到场景中 if (this.scene) { this.scene.add(this.camera); } else { console.error("Scene is not initialized!"); } // 设置相机观察目标并更新相关矩阵 this.camera.lookAt(new THREE.Vector3(0, 0, 0)); this.camera.updateProjectionMatrix(); this.camera.updateMatrixWorld(); }
初始化渲染器:
initRenderer() { // 初始化渲染器 this.renderer = new THREE.WebGLRenderer({ antialias: true }); // 设置渲染器尺寸 this.renderer.setPixelRatio(window.devicePixelRatio) // 设置屏幕像素比 this.renderer.setSize(window.innerWidth, window.innerHeight) // 渲染的尺寸大小 this.renderer.toneMapping = THREE.ACESFilmicToneMapping // 色调映射 this.renderer.toneMappingExposure = 2 // 曝光程度 this.container.appendChild(this.renderer.domElement) }
初始化控制器:
initControl() { // 初始化控制器 this.controls = new OrbitControls(this.camera, this.renderer.domElement) this.controls.enableDamping = true // 启用阻尼或指数衰减的轨道控制 }
初始化窗口大小:
windowSizeChange() { // 初始化窗口大小 window.addEventListener("resize", () => { // 重置渲染器宽高比 this.renderer.setSize(window.innerWidth, window.innerHeight); // 重置相机宽高比 this.camera.aspect = window.innerWidth / window.innerHeight; // 更新相机投影矩阵 this.camera.updateProjectionMatrix(); }); }
设置渲染函数:
render() { // 渲染函数 this.renderer.render(this.scene, this.camera) } animate() { // 动画函数 this.renderer.setAnimationLoop(this.render.bind(this)) }
完整代码如下:
import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' class Base { constructor(selector) { this.container = document.querySelector(selector) this.scene this.camera this.renderer this.init() this.animate() } init() { this.initScene() // 初始化场景 this.initCamera() // 初始化相机 this.initRenderer() // 初始化渲染器 this.initControl() // 初始化控制器 this.windowSizeChange() // 初始化窗口大小 } initScene() { // 初始化场景 this.scene = new THREE.Scene() // 创建场景 } initCamera() { // 创建透视相机 this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 10); // 设置相机位置 this.camera.position.set(0, 15, 20); // 将相机添加到场景中 if (this.scene) { this.scene.add(this.camera); } else { console.error("Scene is not initialized!"); } // 设置相机观察目标并更新相关矩阵 this.camera.lookAt(new THREE.Vector3(0, 0, 0)); this.camera.updateProjectionMatrix(); this.camera.updateMatrixWorld(); } initRenderer() { // 初始化渲染器 this.renderer = new THREE.WebGLRenderer({ antialias: true }); // 设置渲染器尺寸 this.renderer.setPixelRatio(window.devicePixelRatio) // 设置屏幕像素比 this.renderer.setSize(window.innerWidth, window.innerHeight) // 渲染的尺寸大小 this.renderer.toneMapping = THREE.ACESFilmicToneMapping // 色调映射 this.renderer.toneMappingExposure = 2 // 曝光程度 this.container.appendChild(this.renderer.domElement) } initControl() { // 初始化控制器 this.controls = new OrbitControls(this.camera, this.renderer.domElement) this.controls.enableDamping = true // 启用阻尼或指数衰减的轨道控制 } windowSizeChange() { // 初始化窗口大小 window.addEventListener("resize", () => { // 重置渲染器宽高比 this.renderer.setSize(window.innerWidth, window.innerHeight); // 重置相机宽高比 this.camera.aspect = window.innerWidth / window.innerHeight; // 更新相机投影矩阵 this.camera.updateProjectionMatrix(); }); } render() { // 渲染函数 this.renderer.render(this.scene, this.camera) } animate() { // 动画函数 this.renderer.setAnimationLoop(this.render.bind(this)) } } export default Base
写完之后,最后页面呈现一个黑色的背景说明我们的场景加载成功了:
ok,写完基础代码之后,接下来开始具体的Demo实操。
平铺元素周期表
本次项目元素周期表并不是使用我们常用的WebGLRenderer渲染器,而是CSS3DRenderer渲染器,两者区别如下,代码中是可以同时存在这两个渲染器的,它们各自负责不同类型的渲染任务。
WebGLRenderer:用于渲染基于 WebGL 的 3D 场景
CSS3DRenderer:用于渲染基于 CSS 的 3D 对象。这种情况通常用于在 Web 页面中同时显示 3D 对象和其他 HTML 元素,例如在 3D 场景中嵌入文字、按钮等。
因为本次项目单纯就使用基于CSS的3D对象,所以我们要对之前的代码进行修改,切换渲染器:
createCSS3DRenderer() { // 创建CSS3D渲染器 this.renderer3D = new CSS3DRenderer(); this.renderer3D.setSize(window.innerWidth, window.innerHeight); this.renderer3D.domElement.style.backgroundColor = 'black'; this.container.appendChild(this.renderer3D.domElement); }
接下来将元素周期表的相关数据进行如下的总结,将元素周期表的数据和位置抽离成js文件:
然后接下来在scene.js文件中引入元素周期表.js获取相关数据,进行如下函数创建元素周期表:
createElement() { for (let i = 0; i < element.length; i+=5) { // 创建父容器 let parent = document.createElement('div') parent.style.backgroundColor = `rgba(0, 127, 127, ${Math.random() * 0.5 + 0.25})` parent.className = 'element-container' // 设置数字 let number = document.createElement('div') number.className = 'element-number' number.textContent = (i / 5) + 1 parent.appendChild(number) // 设置元素名称 let symbol = document.createElement('div') symbol.className = 'element-symbol' symbol.textContent = element[i] parent.appendChild(symbol) // 详细信息 let detail = document.createElement('div') detail.className = 'element-detail' detail.innerHTML = element[i + 1] + '<br>' + element[i + 2] parent.appendChild(detail) // 实例化CSS3D对象 let element3D = new CSS3DObject(parent) this.objects.push(element3D) // 加载3D场景 this.scene.add(element3D) } }
然后我们在App根组件中删除scoped设置全局css样式,给上面创建的div类名设置样式:
接下来我们开始处理元素周期表的位置样式,将element获取的位置数据进行放大,然后通过页面进行细微的调整:
// 处理元素周期表样式 handleTableStyle() { for (let i = 0; i < element.length; i+=5) { // 将第i+3个元素的值赋给objects数组中第i/5个对象的position.x属性 this.objects[i / 5].position.x = element[i + 3] * 140 - 1350 // 将第i+4个元素的值赋给objects数组中第i/5个对象的position.y属性 this.objects[i / 5].position.y = -element[i + 4] * 180 + 1000 } }
然后根据情况设置相机位置进行细微的调整,使得整个场景处于正中央即可:
// 设置相机位置 this.camera.position.set(0, 15, 2800);
最终呈现的效果如下:
螺旋元素周期表
根据上面实现的基础上,接下来我们实现将元素周期表的位置进行一个螺旋状的展示,在three中提供了一个3D的函数,这个函数通常用于设置一个三维向量的坐标,其中柱面坐标系由一个半径、一个角度和一个高度组成。这种坐标系通常用于描述圆柱体表面上的点的位置,如下:
具体来说,setFromCylindricalCoords 函数接受柱面坐标系的三个参数:
1)radius:柱面坐标系中的半径。
2)theta:柱面坐标系中的角度,以弧度表示。
3)y:柱面坐标系中的高度。
当需要根据柱面坐标系来定位或者旋转一个对象时,可以使用这个函数来方便地设置该对象的位置或者方向,接下来通过如下代码进行简单的测试一下:
// 螺旋元素周期表 spiralTable() { for (let i = 0; i < this.objects.length; i++) { let theta = i let y = i this.objects[i].position.setFromCylindricalCoords(900, theta, y) } }
呈现的效果如下所示,可见是一圈圆,但我们想实现螺旋式的效果应该这么做,这里需要调整:
接下来对上面螺旋周期表函数进行一些参数的调整,然后设置一些rotation参数:
// 螺旋元素周期表 spiralTable() { for (let i = 0; i < this.objects.length; i++) { let theta = i * 0.175 let y = -i * 8 + 450 this.objects[i].position.setFromCylindricalCoords(900, theta, y) let obj = new THREE.Object3D() obj.position.copy(this.objects[i].position) // 改变物体的旋转 obj.lookAt(0, this.objects[i].position.y, 0) this.objects[i].rotation.x = obj.rotation.x this.objects[i].rotation.y = obj.rotation.y + Math.PI this.objects[i].rotation.z = obj.rotation.z } }
最终呈现的效果如下,大体效果还是不错的:
网格元素周期表
对于网格处理的函数也很简单,如下该函数的主要逻辑是遍历 this.objects 数组,并为每个元素(即每个物体)计算其在三维空间中的新位置。每个物体在 x、y 和 z 轴上的位置都基于其索引 i 来计算,以达到这种排列效果:
// 网格元素周期表 gridTable() { for (let i = 0; i < this.objects.length; i++) { this.objects[i].position.x = (i % 5) * 400 -720 this.objects[i].position.y = Math.floor((i / 5)) % 5 * 400 - 750 this.objects[i].position.z = -Math.floor((i / 25)) * 400 } }
最终呈现的效果如下:
球状元素周期表
在写球状元素周期表之前,我们先了解一下球概念,如下:
在threejs官网上,也有关于球状相关的api方法,如下:
在一个三维场景中,根据球状元素周期表的规则来排列和旋转一系列的物体。这里根据一定的数学规则(这里使用了反余弦函数和平方根函数)来调整 this.objects 数组中每个物体的位置和旋转模拟一种特殊的排列或动画效果:
// 球状元素周期表 ballTable() { for (let i = 0; i < this.objects.length; i++) { const phi = Math.acos( -1 + (2 * i) / this.objects.length); // 方向角 const theta = Math.sqrt(this.objects.length * Math.PI) * phi; // 半径 // 球坐标 this.objects[i].position.setFromSphericalCoords(800, phi, theta) let obj = new THREE.Object3D() let obj1 = this.objects[i] obj.position.copy(obj1.position) obj.lookAt(0, 0, 0) obj1.rotation.x = obj.rotation.x obj1.rotation.y = obj.rotation.y obj1.rotation.z = obj.rotation.z obj1.rotateOnAxis(new THREE.Vector3(0, 1, 0), Math.PI) } }
最终呈现的效果如下:
加底部交互按钮
接下来我们实现点击底部的按钮进行不同的场景切换,如下:
<template> <!-- 场景 --> <div id="canvasDom"></div> <!-- 按钮 --> <div class="menu"> <button v-for="(btn, index) in buttons" :key="index" @click="handleButtonClick(btn.key)" :class="{ active: data.activeBtn === btn.key }">{{ btn.text }}</button> </div> </template> <script setup> import { reactive, onMounted } from 'vue' import Base from "../components/scene.js" let data = reactive({ base3d: {}, activeBtn: 'tile' }) const buttons = [ { key: 'tile', text: '平铺' }, { key: 'spiral', text: '螺旋' }, { key: 'grid', text: '网格' }, { key: 'ball', text: '球状' } ] const handleButtonClick = (btnKey) => { switch (btnKey) { case 'tile': data.base3d.handleTableStyle() break; case 'spiral': data.base3d.spiralTable() break; case 'grid': data.base3d.gridTable() break; case 'ball': data.base3d.ballTable() break; default: break; } data.activeBtn = btnKey } onMounted(() => { data.base3d = new Base("#canvasDom") // 默认选中第一个按钮 handleButtonClick('tile') }) </script>
效果如下所示:
接下来我们设置其点击后样式:
<style scoped lang="scss"> #canvasDom { width: 100%; height: 100%; } .menu { position: absolute; z-index: 1000; bottom: 20px; text-align: center; width: 100%; button { color: rgba(127, 255, 255, 0.75); background: transparent; outline: 1px solid rgba(127, 255, 255, 0.75); padding: 10px 30px; margin: 0 10px; cursor: pointer; &:hover { background-color: rgba(0, 255, 255, 0.5); } &.active { background-color: rgba(0, 255, 255, 0.6); } } } </style>
最终呈现的效果如下: