最近在b站上面看到up主:gamemcu的3D作品,着实让人感到非常震撼,作品中的SU7模型,利用的是Blender进行建模,利用了webGL的技术进行开发。由此启发了我对3D极大的乐趣。因此,凭借一点点🤏的前端知识水平,打算从Three.js开始入门3D。学习成果如下:
初识Three
gamemcu大佬的作品
su7xiaomi su7 made by gamemcuhttps://gamemcu.com/su7/
官网学习
利用了一天的时间,到官方网站(Three.js – JavaScript 3D Library)看了documentation、examples和editor。到b站中看了视频,学习了基础的知识。首页也有推荐一些教学视频,比如resource中提到的Three.js Journery。也是up主:gamemcu推荐去看的,在b站中可以找到免费的教学视频
Three.js Journey(新版-双语字幕)_哔哩哔哩_bilibili
源码学习
Three.js是开源的,可以把源码下载来看,官网里的所有examples在源码里面都有demo。
docs:是官网的文档,网络卡的时候,可以用本地的看。
examples:放的是所有的demo,可以学习到官方的编码方式
editor:是模型编辑器,可视化编辑模型,提高在编写代码时的效率。
初识Blender
大概了解部分Three.js知识后,还需要了解一下建模的软件-Blender,这款软件功能强大,而且是开源的。如果需要给模型添加一些动画效果,那可能就要修改模型的原点和模型分组情况。就需要到Blender对模型进行建模处理了。如何更加深入的话,可以直接在Blender中,添加好动画,然后直接Three.js导入Animation。
开发作品
经过这几天的基础学习和准备,现在可以着手开发了。
搭建场景
首先是搭建基本场景
function init() {// 初始化场景const initScene = () =>{scene = new THREE.Scene();scene.background = new THREE.Color( 0xa0a0a0 );scene.fog = new THREE.Fog( 0xa0a0a0, 4, 20 );}// 初始化相机const initCamera = () =>{camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 100 );camera.position.set( 10, 20, 10 );}// 初始化半球光const hemiLight = () =>{const hemiLight = new THREE.HemisphereLight( 0xffffff, 0x444444, 3 );hemiLight.position.set( 0, 20, 0 );scene.add( hemiLight );}// 初始化平行光const initLight = () =>{dLight = new THREE.DirectionalLight( 0xffffff, 3 );dLight.position.set( 0, 20, 10 );dLight.castShadow = true;dLight.shadow.camera.top = 2;dLight.shadow.camera.bottom = - 2;dLight.shadow.camera.left = - 2;dLight.shadow.camera.right = 2;scene.add( dLight );}// 初始化地面const initGround = () =>{const ground = new THREE.Mesh( new THREE.PlaneGeometry( 40, 40 ), newTHREE.MeshPhongMaterial( { color: 0xbbbbbb, depthWrite: false } ) );ground.rotation.x = - Math.PI / 2;ground.receiveShadow = true;scene.add( ground );}// 初始化地面网格const initGrid = () =>{const grid = new THREE.GridHelper( 40, 20, 0x000000, 0x000000 );grid.material.opacity = 0.2;grid.material.transparent = true;scene.add( grid );}// 初始化场景辅助标线const initAxesHelper = () =>{let axesHelper = new THREE.AxesHelper( 3 );scene.add( axesHelper );}
}
导入模型
这里提前导入了两个模型,同时利用回调,添加了加载模型过程的progress。
// 模型1
function loadStaticScene(){return new Promise(resolve => {let loader = new GLTFLoader();const url = "../models/gltf/WM161_zhankai_1k.glb"loader.load(url, (gltf) => {gltf.scene.position.set(0,27,0)gltf.scene.scale.set(15, 15, 15);resolve(gltf);},(event) =>{const {loaded,total} = eventhandleLoadProgress({url,loaded,total})});})
}// 模型2
function loadStaticScene_v11(){return new Promise(resolve => {let loader = new GLTFLoader();const url = "../models/gltf/wm161_v11_zhedie_1k.glb"loader.load(url, (gltf) => {gltf.scene.position.set(0,-27,0)gltf.scene.scale.set(15, 15, 15);resolve(gltf);},(event) =>{const {loaded,total} = eventhandleLoadProgress({url,loaded,total})});})
}// 用于加载
function handleLoadProgress({url, loaded, total}){const percentage = ((loaded / total) * 100).toFixed(2);if (/.*\.(blob|glb)$/i.test(url)) {updateLoadingProgress(`加载场景模型:${percentage}%`);}}function updateLoadingProgress(loading_text){progress && (progress.textContent = loading_text);
}
加载模型
可用看到模型加载的时候,出现了加载进度。
loadScenes(); // 先加载模型async function loadScenes(){gltf = await loadStaticScene(); // 加载模型1gltf_v11 = await loadStaticScene_v11(); // 加载模型2showLoading(); // loading过程handleIntoScene(); // 进入场景
}
模型被加载到场景
工作台
为了更换操作模型,在页面添加一个工作台。同时加上标题,丰富画面。
这是一个普通HTML编写的工作台 ,加了mac-docker动画效果
<div class="dock_con display-none"><div class="dock"><div class="dock-item"><i class="icon icon_fly" data-name="起飞"></i></div><div class="dock-item"><i class="icon icon_color" data-name="颜色贴图"></i></div><div class="dock-item"><i class="icon icon_check" data-name="模型切换"></i></div><div class="dock-item"><i class="icon icon_setup" data-name="设置"></i></div></div></div>
切换模型
点击切换的时候,利用TWEEN进行动画式的切换效果
function handleDockClick() {// 点击切换const handleCheck = () =>{isCheck = !isCheck;const gltf_vector = isCheck ? new THREE.Vector3(0,0,0): new THREE.Vector3(0,27,0)const gltf_v11_vector = isCheck ? new THREE.Vector3(0,0,0): new THREE.Vector3(0,-27,0)new TWEEN.Tween( gltf.scene.position).to({ x: gltf_vector.x, y: gltf_vector.y, z: gltf_vector.z }, 500).easing(TWEEN.Easing.Quadratic.Out).start();new TWEEN.Tween( gltf_v11.scene.position).to({ x: gltf_v11_vector.x, y: gltf_v11_vector.y, z: gltf_v11_vector.z }, 500).easing(TWEEN.Easing.Quadratic.Out).start();}const handleColor = () =>{}const handleFly = () =>{}// 点击事件的事件委托delegate(dock, 'click', 'i', function(e){const DOCK_MAP = [{target: ()=> e.target.classList.contains('icon_check'),handler: handleCheck},{target: ()=> e.target.classList.contains('icon_color'),handler: handleColor},{target: ()=> e.target.classList.contains('icon_fly'),handler: handleFly}]const event = DOCK_MAP.find(item => item.target());if (event) {event.handler();}})}
操作模型
这里给模型添加一个起飞的动画效果。
async function loadScenes(){gltf = await loadStaticScene();gltf_v11 = await loadStaticScene_v11();setRotateAnimation(gltf) // 缓存飞起动画showLoading();handleIntoScene();}// 在加载模型的时候,先给模型加入动画进行缓存
function setRotateAnimation(gltf){gltf.scene.traverse((e)=>{const polySurface_number = e.name.replace(/polySurface/g,'')const rotatePoly = ['58','81','90','102']if (rotatePoly.includes(polySurface_number)){const rotation = e.rotationconst tween = new TWEEN.Tween( e.rotation).to({x:rotation.x,y:Math.PI * 2,z:rotation.z}, 50).easing(TWEEN.Easing.Linear.None).repeat(Infinity)flyPoly.push(tween)}})}function handleDockClick() {const handleCheck = () =>{}const handleColor = () =>{}// 起飞const handleFly = () =>{isFly = !isFlyflyPoly.forEach(item=>{if (isFly){item && item.start()}else {item && item.stop()}})}// 点击事件的事件委托delegate(dock, 'click', 'i', function(e){const DOCK_MAP = [{target: ()=> e.target.classList.contains('icon_check'),handler: handleCheck},{target: ()=> e.target.classList.contains('icon_color'),handler: handleColor},{target: ()=> e.target.classList.contains('icon_fly'),handler: handleFly}]const event = DOCK_MAP.find(item => item.target());if (event) {event.handler();}})}
颜色板块
这里给页面添加一个色板,用于改变模型的颜色,当然也可以利用该方式去对模型进行其他操作
如何知道替换颜色的模型,是哪个部位呢?可以用在上文提到过的editor编辑器,模型导入进去后,点击某个部位,就可以看到他的名称是什么了。
function handleDockClick() {const handleCheck = () =>{}// 替换颜色。const handleColor = () =>{isAppendColor = !isAppendColor;if (isAppendColor){color_map.classList.add('show-color-map');delegate(color_map, 'click', 'div', function(e){setColorMap(e.target.style.backgroundColor)})}else {color_map.classList.remove('show-color-map');}const setColorMap = (color) =>{gltf.scene.traverse((e)=>{const polySurface_number = e.name.replace(/polySurface/g,'')const windBlade = ['21','101','201'] // 给名为这几个的模型,替换颜色if (windBlade.includes(polySurface_number)){e.material.color.set(color)}})}} const handleFly = () =>{}// 点击事件的事件委托delegate(dock, 'click', 'i', function(e){const DOCK_MAP = [{target: ()=> e.target.classList.contains('icon_check'),handler: handleCheck},{target: ()=> e.target.classList.contains('icon_color'),handler: handleColor},{target: ()=> e.target.classList.contains('icon_fly'),handler: handleFly}]const event = DOCK_MAP.find(item => item.target());if (event) {event.handler();}})}
射线处理
射线处理,就是当鼠标移动到射线投射的位置上时候,可以显示出对应的模型信息和知道当前鼠标位置是否在某个模型上面,一般用于碰撞检测。我这里只用来显示一些大概的内容。
function hoverPoint(){if (mouse.x === 0 && mouse.y ===0) return;raycaster.setFromCamera(mouse, camera); // 投射到鼠标上面let intersects = raycaster.intersectObjects(scene.children); // 作用到场景if(INTERSECTED) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );if (intersects.length && intersects[0].object.name){INTERSECTED = intersects[0].object; // 用一个变量来缓存被投射到的模型信息// 这里改变的是变量中保存的模型信息,就不会改变到原本模型的信息,也有利用做其他操作INTERSECTED.currentHex = INTERSECTED.material.emissive.getHex();INTERSECTED.material.emissive.setHex( 0x8F8BFF );showTip(INTERSECTED?.material.name)}else {if (INTERSECTED) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );INTERSECTED = null;tip.style.display = 'none';}}// 监听鼠标移动事件,这里需要对鼠标做一个处理
canvas.addEventListener('mousemove', function(event) {client = {x: event.clientX,y: event.clientY}clearMouse();mouse.x = (event.clientX / window.innerWidth) * 2 - 1; // -1 ~ 1mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; // -1 ~ 1});
动画处理
剩下的就是一些常用的CSS动画、CSS过渡动画和JS动画的细节处理了。当然这里还有一个加载完毕进场的阻尼效果。
function updateCamera() {const vector = new THREE.Vector3()targetPosition.addScaledVector(vector, 0.25);const {x,y,z} = targetPositioncamera.position.x += (targetPosition.x - camera.position.x) * dampingFactor;camera.position.y += (targetPosition.y - camera.position.y) * dampingFactor;camera.position.z += (targetPosition.z - camera.position.z) * dampingFactor;const cameraX = camera.position.x.toFixed(1);const cameraY = camera.position.y.toFixed(1);const cameraZ = camera.position.z.toFixed(1);if (Number(cameraX) <= x && Number(cameraY) <= y && Number(cameraZ) <= z){isLoading = falsetitle.classList.remove("display-none");dock_con.classList.remove("display-none");}}function animate(){requestAnimationFrame( animate );updateJump();controls.update();TWEEN.update();isLoading && updateCamera();!isLoading && hoverPoint();renderer.render( scene, camera );}
操作杆处理
给摄像机添加一个前后左右的类似操作杆的效果,然后移动的时候,视口中间出现一些跳动的文字对应操作的按键。
let mouse = new THREE.Vector2();let velocity = new THREE.Vector3();let up_vector = new THREE.Vector3(0, 1, 0);let temp_vector = new THREE.Vector3();let targetPosition = new THREE.Vector3(1.8, 1.9, 3.4);// 跳动的文字
const throttledScrollHandler = throttle(function() {const id = document.querySelector('.text-up')new TextUp( {id:id,text: keyCode.toUpperCase()} ) // 在节流的控制下生成文字
}, 500);// 操作杆
document.addEventListener('keydown', function(event) {keyCode = event.key === ' '? 'space' : event.keycamera.position.addScaledVector(velocity, 0.5);const angle = controls.getAzimuthalAngle();const speed = 0.25mouse = new THREE.Vector2(0,0)switch (event.key) {case 'w':temp_vector.set(0, 0, -1).applyAxisAngle(up_vector, angle);camera.position.addScaledVector(temp_vector, speed);throttledScrollHandler()break;case 's':temp_vector.set(0, 0, 1).applyAxisAngle(up_vector, angle);camera.position.addScaledVector(temp_vector, speed);throttledScrollHandler()break;case 'a':temp_vector.set(-1, 0, 0).applyAxisAngle(up_vector, angle);camera.position.addScaledVector(temp_vector, speed);throttledScrollHandler()break;case 'd':temp_vector.set(1, 0, 0).applyAxisAngle(up_vector, angle);camera.position.addScaledVector(temp_vector, speed);throttledScrollHandler()break;case ' ':jump();throttledScrollHandler()break;}});function updateJump(){if (isJumping) {camera.position.y += jumpVelocity;jumpVelocity -= 0.05;if (camera.position.y <= 1.5) {camera.position.y = 1.5;isJumping = false;}}}function jump() {if (!isJumping) {jumpVelocity = 0.3 * maxJumpHeightconsole.log(jumpVelocity);isJumping = true;}}