效果如图,同threejs的editor中右下角的视角指示器。
一、代码调用、修改
1.1 代码目录:
html
- utils
- threeworld
- viewHelper
- baseViewHelper.js
- viewHelper.ts
- threeWorld.ts
- views
- demo1.vue
1.2 引入并修改viewHelper
在上一篇文的基础上,引入【baseViewHelper.js】(three.js/examples/jsm/helpers/ViewHelper.js),同时增加文件viewHelper.ts
ts
// @ts-nocheck
import { ViewHelper as ViewHelperBase } from './baseViewHelper.js';
class ViewHelper extends ViewHelperBase {
animating: any;
update(delta: number) {
throw new Error('Method not implemented.');
}
private panel: HTMLDivElement | undefined;
constructor( editorCamera: THREE.PerspectiveCamera, container: HTMLDivElement ) {
super( editorCamera, container );
this.panel = document.createElement('div');
this.panel.style.cssText = `position: absolute; bottom: 0; right: 0; z-index: 33; width: 128px; height: 128px`;
container.style.position = 'relative';
container.appendChild(this.panel);
this.panel.addEventListener('pointerup', this.onPointerup.bind(this));
this.panel.addEventListener('pointerdown', this.onPointerdown.bind(this));
}
onPointerup(event: MouseEvent): void {
event.stopPropagation();
this.handleClick(event);
}
onPointerdown(event: MouseEvent): void {
event.stopPropagation();
}
}
export { ViewHelper };
接下来,就在上一篇文的threeWorld.ts中增加这个viewHelper实例。(下面只更新需要改变的函数)
ts
export default class ThreeWorld {
private clock: THREE.Clock = new THREE.Clock();
init(): void {
// ...
this.setRenderer();
this.addHelper();
this.addControls();
// ...
}
render(): void {
// ...
if (this.viewHelper && this.renderer) {
this.viewHelper?.render(this.renderer);
}
}
animnate(): void {
const delta = this.clock.getDelta();
this.render();
this.orbitControls?.update();
if (this.viewHelper) {
if (this.viewHelper.animating) {
this.viewHelper.update(delta)
}
}
requestAnimationFrame(this.animnate.bind(this));
}
loadModel(modelPath: string) {
return new Promise(resolve => {
this.gltfLoader?.load(modelPath, (gltf) => {
const obj = gltf.scene;
this.scene?.add(obj);
const crood = this.center(obj);
this.updateControls(crood);
resolve(obj);
}, process => {}, err => {})
})
}
destroy() {
// @ts-ignore
this.viewHelper?.dispose();
}
addControls(): void {
if (this.dom && this.camera && this.renderer) {
this.orbitControls = new OrbitControls(this.camera, this.renderer?.domElement);
this.orbitControls.update();
}
}
updateControls(crood: THREE.Vector3): void {
const { x, y, z } = crood;
this.orbitControls?.target.set(x, y, z);
this.orbitControls?.update();
}
addHelper(): void {
if (this.dom && this.camera && this.renderer) {
this.viewHelper = new ViewHelper(this.camera, this.dom);
this.renderer.autoClear = false; // mark0
}
}
}
另外,注意mark0标记处的那行代码,记得加上,
1.3 demo修改
最后就可以调用了~~~
vue
<template>
<div class="demo1">
<!-- load model by gltf loader -->
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, shallowReactive, } from 'vue';
import ThreeWorld from '@/utils/threeWorld/threeWorld';
import type { ShallowReactive } from 'vue';
let w: ShallowReactive<ThreeWorld>;
onMounted(() => {
w = shallowReactive(new ThreeWorld({
dom: document.querySelector('div.demo1'),
}))
w.loadModel(`./static/models/charger/charger003.gltf`).then(res => {
w.animnate()
});
})
onUnmounted(() => {
w.destroy();
})
</script>
<style scoped>
.demo1 {
width: 500px;
height: 600px;
}
</style>
二、baseViewHelper做了什么(源码阅读)
baseViewHelper 继承并拓展了Object3D类
2.1 绘制基本的图形
js
class ViewHelper extends Object3D {
constructor( camera, domElement ) {
// 一、绘制x、y、z 3条轴,即, xAxis、yAxis、zAxis
// 二、绘制6个圆点:posXAxisHelper为有轴的圆点,negXAxisHelper为反方向、没有轴、相对小为0.8且目前透明度为0.5的圆点,即,posXAxisHelper、posYAxisHelper、posZAxisHelper、negXAxisHelper、negYAxisHelper、negZAxisHelper
}
}
2.2 监听方位点击事件
主要是采取raycaster进行鼠标拾取,官网相关api。
下文中通过mouse记录鼠标点击的位置,接着通过raycaster.setFromCamera(mouse, orthoCamera)
增加鼠标位置至相机的光线投射,然后通过raycaster.intersectObjects(interactiveObjects)
检测和射线相交的几个方位圆点。若存在结果,则this.animating = true
,取第一个(距离最近)执行prepareAnimationData
函数。
ts
this.handleClick = function ( event ) {
if ( this.animating === true ) return false;
const rect = domElement.getBoundingClientRect();
const offsetX = rect.left + ( domElement.offsetWidth - dim );
const offsetY = rect.top + ( domElement.offsetHeight - dim );
mouse.x = ( ( event.clientX - offsetX ) / ( rect.right - offsetX ) ) * 2 - 1;
mouse.y = - ( ( event.clientY - offsetY ) / ( rect.bottom - offsetY ) ) * 2 + 1;
// 获取鼠标点击的物体:raycaster拾取相关api
raycaster.setFromCamera( mouse, orthoCamera );
const intersects = raycaster.intersectObjects( interactiveObjects );
if ( intersects.length > 0 ) {
const intersection = intersects[ 0 ]; // 检测所有在射线与物体之间,包括或不包括后代的相交部分。返回结果时,相交部分将按距离进行排序,最近的位于第一个。
const object = intersection.object;
prepareAnimationData( object, this.center ); // 判断点击视角示意图中的哪个方位点,记录当前位置的四元数q1,目标四元数q2
this.animating = true;
return true;
} else {
return false;
}
};
2.3 prepareAnimationData
这个函数顾名思义,准备animation前的相关要素。
- 通过
switch
之前存储在圆点中的type
,判断是点击视角示意图中的哪个方位; - 用q1、q2两个四元数分别存储每一帧绘制中相机
当下camera.position
、目标targetPosition
的position信息等,
ts
function prepareAnimationData( object, focusPoint ) {
switch ( object.userData.type ) {
case 'posX':
targetPosition.set( 1, 0, 0 );
targetQuaternion.setFromEuler( new Euler( 0, Math.PI * 0.5, 0 ) );
break;
case 'posY':
targetPosition.set( 0, 1, 0 );
targetQuaternion.setFromEuler( new Euler( - Math.PI * 0.5, 0, 0 ) );
break;
case 'posZ':
targetPosition.set( 0, 0, 1 );
targetQuaternion.setFromEuler( new Euler() );
break;
case 'negX':
targetPosition.set( - 1, 0, 0 );
targetQuaternion.setFromEuler( new Euler( 0, - Math.PI * 0.5, 0 ) );
break;
case 'negY':
targetPosition.set( 0, - 1, 0 );
targetQuaternion.setFromEuler( new Euler( Math.PI * 0.5, 0, 0 ) );
break;
case 'negZ':
targetPosition.set( 0, 0, - 1 );
targetQuaternion.setFromEuler( new Euler( 0, Math.PI, 0 ) );
break;
default:
console.error( 'ViewHelper: Invalid axis.' );
}
radius = camera.position.distanceTo( focusPoint );
targetPosition.multiplyScalar( radius ).add( focusPoint );
dummy.position.copy( focusPoint );
dummy.lookAt( camera.position );
q1.copy( dummy.quaternion );
dummy.lookAt( targetPosition );
q2.copy( dummy.quaternion );
}
2.4 更新每一帧的信息
- 执行update函数的时机:在demo中调用
requestAnimationFrame
时。这里的delta
传参是Clock.getDelta()
,这个函数是用于获得前后两次执行的该方法的时间间隔。 - 采用
quaternion.rotateTowards
将当下的四元数q1按照step向目标q2进行旋转,该方法确保最终的四元数不会超过q2
ts
this.update = function (delta) {
const step = delta * turnRate;
// animate position by doing a slerp and then scaling the position on the unit sphere
// 将该四元数q1按照步长 step 向目标 q2 进行旋转。该方法确保最终的四元数不会超过 q2。
q1.rotateTowards( q2, step );
// applyQuaternion( q1 ).multiplyScalar( radius ).add( this.center ) ,即 旋转 * 缩放 + 中心点
camera.position.set( 0, 0, 1 ).applyQuaternion( q1 ).multiplyScalar( radius ).add( this.center );
// animate orientation
// 相机视角旋转
camera.quaternion.rotateTowards( targetQuaternion, step );
if ( q1.angleTo( q2 ) === 0 ) {
this.animating = false;
}
};
2.5 绘制每一帧的画面
每一帧清除缓存,绘制相关画面:判断旋转角度,改变相关的方位点的透明度。
ts
this.render = function ( renderer ) {
// quaternion表示对象局部旋转的Quaternion(四元数),此处用来获取相机的旋转信息
// quaternion.invert(),翻转该四元数 ------ 计算 conjugate 。假定该四元数具有单位长度。
// quaternion.conjugate()返回该四元数的旋转共轭。 四元数的共轭表示的是,围绕旋转轴在相反方向上的相同旋转。
this.quaternion.copy( camera.quaternion ).invert();
this.updateMatrixWorld(); // 更新子对象的matrixWorld
point.set( 0, 0, 1 );
point.applyQuaternion( camera.quaternion ); // 应用四元数
// 旋转过程中,<0的透明度设为0.5
if ( point.x >= 0 ) {
posXAxisHelper.material.opacity = 1;
negXAxisHelper.material.opacity = 0.5;
} else {
posXAxisHelper.material.opacity = 0.5;
negXAxisHelper.material.opacity = 1;
}
if ( point.y >= 0 ) {
posYAxisHelper.material.opacity = 1;
negYAxisHelper.material.opacity = 0.5;
} else {
posYAxisHelper.material.opacity = 0.5;
negYAxisHelper.material.opacity = 1;
}
if ( point.z >= 0 ) {
posZAxisHelper.material.opacity = 1;
negZAxisHelper.material.opacity = 0.5;
} else {
posZAxisHelper.material.opacity = 0.5;
negZAxisHelper.material.opacity = 1;
}
// canvas绘制rect 相关api
const x = domElement.offsetWidth - dim;
renderer.clearDepth();
renderer.getViewport( viewport );
renderer.setViewport( x, 0, dim, dim );
renderer.render( this, orthoCamera );
renderer.setViewport( viewport.x, viewport.y, viewport.z, viewport.w );
};
2.6 废置对象
废置对象有提高性能,并避免程序中的内存泄露,可见官网相关api。 three.js不能够自动废置对象,需要我们手动在组件卸载时废置。
ts
this.dispose = function () {}
2.7 动画的开关
baseViewHelper通过animating
布尔值来设定是否开始动画:
- 默认
animating=false
- 用户点击画面时,若当前正在动画,不作处理
- 用户点击方位点时,开启动画
if ( intersects.length > 0 ) {this.animating = true;}
- 动画旋转到目标位置时,关闭动画
if(q1.angleTo( q2 ) === 0) { this.animating = false; }
2.8 如何控制每次旋转动画物体的大小不变
通过radius
这个变量来控制:
ts
let radius = 0;
function prepareAnimationData( object, focusPoint ) {
radius = camera.position.distanceTo( focusPoint );
targetPosition.multiplyScalar( radius ).add( focusPoint );
}
this.update = function ( delta ) {
camera.position.set( 0, 0, 1 ).applyQuaternion( q1 ).multiplyScalar( radius ).add( this.center );
}
三、问题记录
- 添加viewHelper之后 viewHelper.render()和renderer.render()发生冲突,哪个在后面就渲染谁,要么场景全黑有helper,要么没有helper? 解决方法:添加mark0标记处那句代码。 原理:执行渲染器WebGLRenderer的渲染方法.render(),本质上就相当于调用绘制函数gl.drawArrays(),每执行一次渲染方法.render()帧缓冲区都会得到新的片元(像素)数据,覆盖帧缓冲区原来的像素数据,或者说每执行一次渲染方法.render(),canvas画布更新一次,canvas画布上上次执行.render()得到像素被覆盖。