这是一个小尝试,需要展示一个动态的 3d 模型,实现一个轨道检测车模型,主要是控制四个车轮的转动动画。后来加入点击事件、鼠标移入事件、动画控制等,我就把我遇到过的问题一一说一下吧。PS:写的原生。
初始化场景
//场景
let scene = new THREE.Scene();
//相机设置为世界坐标原点
let camera = null;
let axes = null;
let contentWidth = 0;
let contentHeight = 0;
// 初始化场景
function initScene() {
contentWidth = document.getElementById( 'threeContent' ).clientWidth;
contentHeight = document.getElementById( 'threeContent' ).clientHeight;
camera = new THREE.PerspectiveCamera(60, contentWidth / contentHeight, 0.1, 1000);
camera.position.set(8, 2, -6.5);
// 看向 0,0,0
camera.lookAt(scene.position);
scene.add(camera);//添加相机
//添加坐标轴
axes = new THREE.AxesHelper(500);//500表示xyz轴的长度,红:x,绿:y,蓝:z
light = new THREE.AmbientLight(0x000000); // 打光
scene.fog = new THREE.Fog( 0x333333, 10, 18 );
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});//画布
renderer.setSize((contentWidth - 4), contentHeight);//设置渲染区域尺寸
// renderer.setClearColor(0xb9d3ff, 1); //设置背景颜色
renderer.setClearColor(0xb9d3ff, .8);
renderer.shadowMap.enabled = true;
}
async function addGlb() {
const loader = new THREE.GLTFLoader();
const dracoloader = getDracoLoader();
loader.setDRACOLoader(dracoloader);//注入loader
//加车辆
scene.name = loadModel.car_no;
scene.add(axes);
scene.add(light);
const floor = await loadGlb(loadModel.model, loader);
floor.position.y = 0;
scene.add(floor);
}
加载车模型
我使用的是 .glb 文件,因为要控制车轮(wheels),铁轨(lines),还有警灯(alarms)的变化,所以我们在拿到模型后,要先把这些对象拿出来,同时,因为窗是我自己加上的,为了能让模型看着更真实一点,我们也可以在此时再次设置模型的材料。最后再把模型加到场景中去。
// 加载glb文件,同时对模型做处理
function loadGlb(filepath, loader) {
wheels = [];
lines = [];
return new Promise((resolve, reject) => {
loader.setCrossOrigin('Anonymous');//跨域问题
loader.load(filepath, async (glb) => {
const carModel = glb.scene;
const root = carModel.getObjectByName( 'root' );
root.getObjectByName('glass').material = glassMaterial;
root.getObjectByName('bodySTL').material = bodyMaterial;
// 铁轨
lines = root.getObjectByName('h_lines').children.slice();
// 车轮
wheels = carModel.getObjectByName('wheels').children.slice();
// 警灯
carModel.getObjectByName('alarm').material = glassMaterial;
alarms = carModel.getObjectByName('alarm');
scene.add( carModel );
const canvas = getTextCanvas(loadModel.car_no);
createTextPlane(canvas);
//处理材质丢失的情况
glb.scene.traverse(child => {
if (child.isMesh) {
child.material.emissive = child.material.color;
child.material.emissiveMap = child.material.map;
}
});
resolve(glb.scene);
}, undefined, (error) => {
console.error(error);
reject(error);
});
});
}
车轮旋转 / 铁轨 动画
对于时间的计算,可以使用 performance.now 或者 Three.js 自带的 Clock API 来实现,然后执行一个动画,可以使用 requestAnimationFrame 来实现:
- Clock:对象用于跟踪时间。如果 performance.now 可用,则 Clock 对象通过该方法实现,否则回落到使用略欠精准的 Date.now 来实现。
- 官方文档: three.js docs
- window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
以下我使用两种方式举例分别实现车轮和铁轨动画:
// 用于存储动画对象,可以在暂停的时候
let animates = null;
const clock = new THREE.Clock();
// 车轮、铁轨动画效果
function animate() {
animates = requestAnimationFrame(animate);
const time = - performance.now() / 1000;
for ( let i = 0; i < wheels.length; i ++ ) {
// 根据正确的轴选择
wheels[ i ].rotation.x = time * Math.PI * 2;
}
// 铁轨动画
const delta = clock.getDelta();
const distance = - delta * 0.75;
for ( let i = 0; i < lines.length; i ++ ) {
// 根据正确的轴移动,模型的间距是相等的,保证最末位置和最初位置的变化
// 暂停又播放的时候 lines[ i ].position.z + distance <= -(lines.length)*1.8 来保证重新播放时铁轨间距不发生混乱
if (lines[ i ].position.z <= -(lines.length)*1.8 && lines[ i ].position.z + distance <= -(lines.length)*1.8) {
lines[ i ].position.z = 0;
} else {
lines[ i ].position.z += distance;
}
}
renderer.clear();
controls.update();
render();
}
// 开始渲染
function render() {
renderer.render(scene, camera);
}
// 暂停动画
function handleAnimation(action) {
if (action === 'stop') {
window.cancelAnimationFrame(animates);
animates = null;
} else {
animate();
}
}
场景鼠标控制
使用的是 OrbitControls(轨道控制器)可以使得相机围绕目标进行轨道运动。
这个地方踩了俩坑,一个是设置可控水平旋转角度(上限下限一定要同时设置),另一个是模型在静态的时候,鼠标控制失效(controls.addEventListener( 'change', render );)
官方文档:three.js docs
// 添加场景鼠标控制
let controls = null;
function createCot() {
// 鼠标控制事件 移动、缩放、旋转
controls = new THREE.OrbitControls(camera, renderer.domElement);
// 这是控制镜头移至的最远距离
controls.maxDistance = 16;
// 控制器的焦点
controls.target.set( 0, 0.5, 0 );
// 能够水平旋转的角度上限
controls.maxAzimuthAngle = 1 * Math.PI;
// 能够水平旋转的角度下限
controls.minAzimuthAngle = 0;
// 能够垂直旋转的角度的上限
controls.maxPolarAngle = 0.5 * Math.PI;
// 旋转的速度
controls.rotateSpeed = 0.4;
// 位移的速度
controls.panSpeed = 0.4;
// 模型静态时控制关键
controls.addEventListener('change', render);
//将渲染好的canvas追加到dom
let cont = document.getElementById('webgl');
cont.appendChild(renderer.domElement);
}
纹理贴图(文字、图片)
- 选择 TextureLoader 创建一个纹理贴图,将其应用到一个表面,或者作为反射/折射贴图:three.js docs
- 这里一定要注意,图片加载是异步的,如果出现图片未展示的情况,可能是图片还未加载完成
- 选择 CanvasTexture 从 Canvas 元素中创建纹理贴图。它几乎与其基类 Texture 相同,但它直接将 needsUpdate(需要更新)设置为了 true
// logo贴图
async function createLogoPlane() {
const geometry = new THREE.PlaneGeometry(0.5, 0.5, 1);
const textureLoader = new THREE.TextureLoader(); // 纹理加载器
const texture = await textureLoader.load('./threejs/res/models/gltf/railway.png'); // 加载图片,返回Texture对象
const material = new THREE.MeshBasicMaterial({
map: texture, // 设置纹理贴图
//设置深浅程度,默认值(1,1)。
side: THREE.DoubleSide, // 双面
transparent: true,
});
const mesh1 = new THREE.Mesh(geometry, material);
const mesh2 = new THREE.Mesh(geometry, material);
mesh1.rotation.y = Math.PI * 0.5;
mesh2.rotation.y = Math.PI * 0.5;
mesh1.position.set(0.643,1.189, -4.496);
mesh2.position.set(3.192,1.189, -4.496);
scene.add(mesh1, mesh2);
}
// 文字贴图(车号)
function createTextPlane(canvas) {
const geometry = new THREE.PlaneGeometry(0.6, 0.3, 1);
const texture = new THREE.CanvasTexture(canvas); // canvas做画布
const material = new THREE.MeshBasicMaterial({
map: texture, // 设置纹理贴图
side: THREE.DoubleSide, // 双面
transparent: true,
});
const mesh = new THREE.Mesh(geometry, material);
const mesh2 = new THREE.Mesh(geometry, material);
mesh.rotation.y = Math.PI * 0.5;
mesh2.rotation.y = Math.PI * 0.5;
mesh.position.set(0.643,0.969, -4.566);
mesh2.position.set(3.192,0.969, -4.566);
scene.add(mesh, mesh2);
}
// 文字纹理--创建文字canvas
function getTextCanvas(text){
let width=60, height=30;
let canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
let ctx = canvas.getContext('2d');
ctx.fillStyle = '#dbce8f00';
ctx.fillRect(0, 0, width, height);
ctx.font = 18 + 'px " bold';
ctx.textBaseline = "middle"; //文本与fillText定义的纵坐标
// ctx.textAlign = "left";
ctx.fillStyle = '#b13e21';
ctx.fillText(text,2,16);
return canvas;
}
不过最后 logo 贴图我选择了直接在 threejs 的编辑器里贴的,一定要把 transparent 勾选上
添加警灯(呼吸灯)
- 效果合成器(EffectComposer):用于在 three.js 中实现后期处理效果。该类管理了产生最终视觉效果的后期处理过程链。
- 自发光(BloomPass)
我这里当时用的 setInterval 和 requestAnimationFrame 实现的呼吸效果,后面忙别的去了暂时没有优化代码了
// 警灯
let alarms = null;
let alarmAni = null;
let alarmLight = null;
let lightMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000 });
let lightTimer = null;
let originalColor = new THREE.Color(0xbbbbbb);
// 效果渲染器
let composer = null;
// 警灯动画效果
function alarmAnimate() {
alarmAni = requestAnimationFrame(alarmAnimate);
composer.render();
render();
}
function cancelAlarmAni() {
alarmAni = null;
clearInterval(lightTimer);
lightTimer = null;
lightMaterial.emissive = new THREE.Color(0xbbbbbb);
lightMaterial.emissiveIntensity = 1;
}
//高亮显示模型(呼吸灯)
function outlineObj () {
// 创建一个Pass,用于实现车灯的自发光
let bloomPass = new THREE.BloomPass(new THREE.Vector2( contentWidth, contentHeight), 25, 4, 256);
lightMaterial.emissive = new THREE.Color(0xff0000);
alarms.children.forEach((item) => {
item.material = lightMaterial;
});
// 定义定时器,控制车灯的闪烁
lightTimer = setInterval(function() {
const time = performance.now() / 1000;
lightMaterial.emissiveIntensity = Math.sin(time * 4) * 0.5 + 0.5; // 随机修改材质的自发光强度,实现闪烁效果
}, 100);
// 创建一个EffectComposer(效果组合器)对象,然后在该对象上添加后期处理通道。
composer = new THREE.EffectComposer(renderer);
// 新建一个场景通道 为了覆盖到原理来的场景上
let renderPass = new THREE.RenderPass(scene, camera);
composer.addPass(renderPass);
// 添加一个ShaderPass,用于将车灯的自发光添加到场景中
const shaderPass = new THREE.ShaderPass(THREE.CopyShader);
shaderPass.renderToScreen = true;
composer.addPass(bloomPass);
composer.addPass(shaderPass);
}
给车轮添加点击事件和鼠标 hover 事件
- Vector2 :Three.js 中的二维向量,表示2D vector(二维向量)的类
- 官方文档:Vector2
- Raycaster : 射线,利用光线投射实现鼠标拾取
- 我们利用在标准化设备坐标中鼠标的二维坐标与射线所来源的摄像机引入射线(setFromCamera),检查与射线相交的物体(intersectObjects),检测所有在射线与物体之间,包括或不包括后代的相交部分。返回结果时,相交部分将按距离进行排序,最近的位于第一个。
- 官方文档:Raycaster
- 我们利用在标准化设备坐标中鼠标的二维坐标与射线所来源的摄像机引入射线(setFromCamera),检查与射线相交的物体(intersectObjects),检测所有在射线与物体之间,包括或不包括后代的相交部分。返回结果时,相交部分将按距离进行排序,最近的位于第一个。
// 交互 - 通过射线找到对应交互的物体
let raycaster = new THREE.Raycaster();
let mouse = new THREE.Vector2();
// hover 事件
function onHoverMesh(event) {
event.preventDefault();
mouse.x =((event.offsetX) / renderer.domElement.clientWidth) * 2 - 1;
mouse.y =-((event.offsetY) / renderer.domElement.clientHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// 获取点击到的车轮
let intersects = raycaster.intersectObjects(wheels, true);
//当intersects.length > 0说明碰撞到物体,获取最近的物体名称进行判断
console.log('hover>>>>>>>>>', intersects);
if (intersects.length > 0) {
// 碰到到的车轮
// console.log(intersects[ 0 ].object, 'intersects[ 0 ].object');
if (INTERSECTED == null || INTERSECTED.name != intersects[ 0 ].object.name ) {
if ( INTERSECTED ) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );
INTERSECTED = intersects[ 0 ].object;
INTERSECTED.currentHex = INTERSECTED.material.emissive.getHex();
INTERSECTED.material.emissive.setHex( 0xff8800 );
}
} else {
if ( INTERSECTED ) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );
INTERSECTED = null;
}
}
// click 事件
function onClickMesh(event) {
event.preventDefault();
console.log(event, 'click---------------<');
mouse.x =((event.offsetX) / renderer.domElement.clientWidth) * 2 - 1;
mouse.y =-((event.offsetY) / renderer.domElement.clientHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// 获取点击到的车轮
let intersects = raycaster.intersectObjects(wheels, true);
console.log(intersects, 'intersects>>>>>>>>>>>>>>>>>>', raycaster, mouse);
//当intersects.length > 0说明碰撞到物体,获取最近的物体名称进行判断
if (intersects.length > 0) {
// 点击到的车轮
const intersect = intersects[ 0 ];
// handleAnimation();
// console.log(intersect.material.fog);
// ...添加你需要的事件,如弹窗信息等
}
}
function addEventToWheel() {
let content = document.getElementById('webgl');
content.addEventListener("click",
onClickMesh,
false)
}
最终效果
没有安装录屏软件,浅浅的截个图吧
总结
浅浅的体验了一下 Three.js,但是很多个性化需求,还是要根据官方文档还有官方举例结合来实现。而且如果要很好看很酷炫的效果,就得结合建模了...
后面还听说了 Babylon.js ,两者都是 WebGL框架,但是差异并没有特别大,我也暂时没有去深入了解,只觉得前端技术博大精深,好头大......
稍微记录一下,欢迎各位大佬交流指导!!!
另外,我一般喜欢在语雀记录,这是刚从语雀挪过来的