✨ 使用Threejs编写和ThreejsEditor一样的视角指示器(Gizimo)
基于Three.js的ViewHelper进行扩展,实现支持视角同步与点击控制的自定义视角辅助器。
📋 一、功能
-
🎯 视角可视化:通过缩略图实时展示主相机的空间位置与朝向;
-
🔄 双向同步:主相机旋转时自动更新缩略图,点击缩略图可快速切换主相机视角;
-
🛡️ 资源安全:提供完善的资源释放机制,避免内存泄漏。
🔍 二、核心实现原理拆解
自定义视角辅助器的核心是基于Three.js原生ViewHelper扩展,通过「双相机联动+事件监听」实现视角同步与控制。整体架构分为三大核心模块:辅助器初始化、双向同步机制、交互事件绑定。
📌 2.1 核心成员变量
先明确类的核心成员变量及其作用,为后续逻辑理解打下基础:
-
📷 viewHelperCamera:辅助器专用的透视相机,用于渲染缩略图中的场景视角,与主相机相互独立但状态联动; -
🔧 viewHelper:Three.js原生ViewHelper实例,负责渲染坐标轴、相机图标等缩略图核心元素; -
🚦 updateStatus:更新状态标记,用于避免“主相机更新→辅助器更新→反向同步主相机”的循环问题; -
🎮 controls:主相机的OrbitControls实例,用于实现辅助器对主相机的控制; -
🔄 changeCb:主相机状态变化的回调函数,用于同步辅助器状态。
🔨 2.2 初始化:构建辅助器基础环境
构造函数是辅助器的初始化入口,核心完成相机配置、ViewHelper实例化与事件绑定,关键步骤如下:
constructor(
private readonly domElement: HTMLElement,
private readonly domControls: HTMLElement,
) {
// 1. 初始化辅助器相机:位置默认Z轴10处,看向原点
this.viewHelperCamera = new PerspectiveCamera();
this.viewHelperCamera.position.set(0, 0, 10);
this.viewHelperCamera.up.set(0, 1, 0);
this.viewHelperCamera.lookAt(0, 0, 0);
this.viewHelperCamera.far = 100;
this.viewHelperCamera.near = 0.1;
// 2. 初始化原生ViewHelper,绑定相机与渲染DOM
this.viewHelper = new ViewHelper(this.viewHelperCamera, domElement);
(this.viewHelper as any).name = 'ViewHelper';
this.viewHelper.setLabels("X", "Y", "Z"); // 自定义坐标轴标签
// 3. 绑定交互事件:点击切换视角
domControls.addEventListener("pointerup", (e) => {
e.stopPropagation();
this.viewHelper.handleClick(e); // 复用原生点击逻辑
});
domControls.addEventListener("pointerdown", (e) => {
e.stopPropagation(); // 阻止事件冒泡影响主场景
});
}
关键注意点:① 辅助器相机的近远裁剪面(near/far)需结合场景大小配置,防止坐标轴或相机图标显示不全;② 事件绑定必须阻止冒泡,避免点击辅助器时触发主场景交互逻辑。
🔗 2.3 双向同步:主相机与辅助器的联动核心
双向同步是辅助器的核心功能,分为「主相机→辅助器」和「辅助器→主相机」两个方向,分别通过syncCamera方法与update方法实现。
➡️ 2.3.1 主相机→辅助器:状态实时同步
当用户拖拽主相机的OrbitControls时,通过监听change事件同步辅助器相机的旋转状态,实现实时联动:
syncCamera(camera: PerspectiveCamera, controls: OrbitControls) {
this.controls = controls;
// 定义状态变化回调:同步辅助器相机旋转
this.changeCb = () => {
if (!this.updateStatus) { // 避免循环更新
this.updateCameraRotation(camera.rotation);
}
};
// 绑定主控制器的change事件
controls.addEventListener('change', this.changeCb);
}
🎯 核心控制点:updateStatus标记用于避免循环更新——当辅助器控制主相机(如用户点击缩略图)时,标记设为true,跳过主相机到辅助器的反向同步。
⬅️ 2.3.2 辅助器→主相机:点击快速切换视角
当用户点击缩略图时,原生ViewHelper会自动切换辅助器相机视角并进入动画状态,此时通过update方法将辅助器状态同步至主相机,实现快速视角切换:
update(detail: number, renderer: WebGLRenderer) {
this.viewHelper.render(renderer); // 渲染缩略图
// 若辅助器处于动画状态(用户点击了缩略图)
if (this.viewHelper.animating) {
this.viewHelper.update(detail); // 更新动画过渡
if (this.controls) {
const camera = this.controls.object;
// 保持主相机到目标点的距离不变
const distance = camera.position.distanceTo(this.controls.target);
// 根据辅助器相机旋转计算主相机位置
const offset = new Vector3(0, 0, 1)
.applyQuaternion(this.viewHelperCamera.quaternion)
.multiplyScalar(distance);
// 同步主相机位置与旋转
camera.position.copy(this.controls.target).add(offset);
camera.quaternion.copy(this.viewHelperCamera.quaternion);
}
}
}
🧠 核心逻辑:点击缩略图后,辅助器相机启动旋转动画,此时① 计算主相机到目标点的原始距离;② 根据辅助器相机旋转角度计算主相机位置偏移;③ 同步主相机位置与旋转,确保切换视角时与目标点距离不变,避免场景缩放。
🗑️ 2.4 资源释放:避免内存泄漏
3D场景开发中,资源释放是易忽略的关键环节。dispose方法通过清理事件监听和实例资源,杜绝内存泄漏:
dispose() {
// 释放原生ViewHelper资源
if (this.viewHelper) {
this.viewHelper.dispose();
}
// 移除主相机控制器的事件监听
if (this.controls) {
this.controls.removeEventListener('change', this.changeCb);
}
// 清除辅助器相机资源
this.viewHelperCamera.clear();
}
🛠️ 三、实战使用教程
结合核心实现逻辑,以下为CustomViewHelper的实际项目集成步骤:
📝 3.1 准备DOM结构
需准备两个核心DOM元素:① 用于渲染缩略图的canvas;② 用于接收交互事件的容器(可覆盖在canvas上方):
<!-- 主场景渲染容器 -->
<div id="main-container" style="width: 100%; height: 80vh;"></div>
<!-- 视角辅助器容器 -->
<div id="view-helper-container" style="width: 200px; height: 200px; position: fixed; bottom: 20px; right: 20px;">
<canvas id="view-helper-canvas" style="width: 100%; height: 100%;"></canvas>
</div>
🚀 3.2 初始化与集成
在Three.js主场景初始化完成后,按以下流程创建辅助器实例并同步主相机:
整体代码
import { WebGLRenderer, PerspectiveCamera, Scene } from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { CustomViewHelper } from "./CustomViewHelper";
// 1. 初始化主场景、相机、渲染器
const scene = new Scene();
const mainCamera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById('main-container')?.appendChild(renderer.domElement);
// 2. 初始化主相机控制器
const controls = new OrbitControls(mainCamera, renderer.domElement);
controls.target.set(0, 0, 0);
mainCamera.position.set(0, 0, 5);
// 3. 初始化视角辅助器
const helperCanvas = document.getElementById('view-helper-canvas') as HTMLCanvasElement;
const helperContainer = document.getElementById('view-helper-container') as HTMLDivElement;
const customViewHelper = new CustomViewHelper(helperCanvas, helperContainer);
// 4. 同步主相机与辅助器
customViewHelper.syncCamera(mainCamera, controls);
// 5. 主渲染循环中更新辅助器
function animate() {
requestAnimationFrame(animate);
// 传入时间增量(这里简化为0.01,实际可使用performance.now()计算)
customViewHelper.update(0.01, renderer);
renderer.render(scene, mainCamera);
}
animate();
// 6. 页面卸载时释放资源
window.addEventListener('beforeunload', () => {
customViewHelper.dispose();
});
✅ 3.3 效果验证
集成完成后,通过以下步骤验证功能有效性:
-
🔄 拖拽主场景的OrbitControls,观察右下角缩略图中的相机图标旋转方向是否与主相机一致;
-
🖱️ 点击缩略图中的X、Y、Z轴或相机图标,主相机会自动切换到对应视角并伴有平滑动画;
-
🧹 页面刷新或关闭时,通过浏览器开发者工具的内存面板确认无内存泄漏。
🚀 四、整体代码
CustomViewHelper 类
import { Euler, PerspectiveCamera, Vector3, WebGLRenderer } from "three";
import { ViewHelper } from "three/examples/jsm/helpers/ViewHelper";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
/**
* 自定义视角辅助器类
* 基于three.js的ViewHelper扩展,用于在界面中显示相机视角缩略图
* 支持与主相机同步旋转状态,以及通过点击缩略图控制主相机视角
*/
export class CustomViewHelper {
// 视角辅助器使用的相机,用于渲染缩略图中的场景视角
private readonly viewHelperCamera: PerspectiveCamera;
// three.js内置的视角辅助器实例,负责渲染缩略图
private readonly viewHelper: ViewHelper;
// 更新状态标记,用于避免同步循环(主相机更新触发辅助器更新时跳过反向同步)
private updateStatus = false;
/**
* 构造函数:初始化视角辅助器
* @param domElement 用于渲染视角辅助器的DOM元素(通常是canvas)
* @param domControls 用于接收交互事件的DOM元素(通常是辅助器容器)
*/
constructor(
private readonly domElement: HTMLElement,
private readonly domControls: HTMLElement,
) {
// 初始化辅助器相机
this.viewHelperCamera = new PerspectiveCamera();
// 设置辅助器相机初始位置和朝向(默认从Z轴方向观察原点)
this.viewHelperCamera.position.set(0, 0, 10);
this.viewHelperCamera.up.set(0, 1, 0); // 设置上方向为Y轴
this.viewHelperCamera.lookAt(0, 0, 0); // 看向原点
// 设置相机的近远裁剪面,控制可见范围
this.viewHelperCamera.far = 100;
this.viewHelperCamera.near = 0.1;
// 初始化three.js的ViewHelper,绑定辅助器相机和渲染DOM
this.viewHelper = new ViewHelper(this.viewHelperCamera, domElement);
(this.viewHelper as any).name = 'ViewHelper'; // 给辅助器命名,方便调试
this.viewHelper.setLabels("X", "Y", "Z"); // 设置坐标轴标签
// 绑定交互事件:点击控制元素时触发视角切换
domControls.addEventListener("pointerup", (e) => {
e.stopPropagation(); // 阻止事件冒泡,避免影响其他元素
this.viewHelper.handleClick(e); // 让辅助器处理点击事件(切换视角)
});
// 绑定鼠标按下事件:仅阻止冒泡,不做其他处理
domControls.addEventListener("pointerdown", (e) => {
e.stopPropagation();
});
}
/**
* 更新视角辅助器状态并渲染
* @param detail 时间增量,用于动画平滑过渡
* @param renderer WebGL渲染器实例,用于渲染辅助器
*/
update(detail: number, renderer: WebGLRenderer) {
// 渲染视角辅助器缩略图
this.viewHelper.render(renderer);
// 如果辅助器正在执行视角切换动画(用户点击了缩略图)
if (this.viewHelper.animating) {
// 更新动画状态(基于时间增量计算过渡)
this.viewHelper.update(detail);
// 如果已绑定主相机控制器,则同步主相机到辅助器的当前视角
if (this.controls) {
const camera = this.controls.object; // 获取主相机
// 计算主相机到目标点的距离(保持原距离不变)
const distance = camera.position.distanceTo(this.controls.target);
// 根据辅助器相机的旋转计算主相机的位置偏移
const offset = new Vector3(0, 0, 1) // 初始沿Z轴方向
.applyQuaternion(this.viewHelperCamera.quaternion) // 应用辅助器相机的旋转
.multiplyScalar(distance); // 缩放至主相机的距离
// 更新主相机位置:以目标点为中心,按计算的偏移量放置
camera.position.copy(this.controls.target).add(offset);
// 同步主相机的旋转角度与辅助器相机一致
camera.quaternion.copy(this.viewHelperCamera.quaternion);
}
}
}
/**
* 更新辅助器相机的旋转角度
* @param rotation 欧拉角,通常来自主相机的旋转状态
*/
updateCameraRotation(rotation: Euler) {
this.viewHelperCamera.rotation.copy(rotation);
}
// 主相机的轨道控制器实例,用于同步控制状态
private controls: OrbitControls | null = null;
// 主相机状态变化时的回调函数
private changeCb: () => void = () => {};
/**
* 同步主相机与视角辅助器
* 实现主相机旋转时自动更新辅助器缩略图,以及辅助器控制主相机
* @param camera 主场景的相机
* @param controls 主相机的轨道控制器
*/
syncCamera(
camera: PerspectiveCamera,
controls: OrbitControls,
) {
this.controls = controls;
// 定义主相机状态变化时的回调:同步辅助器相机旋转
this.changeCb = () => {
// 避免循环更新:当辅助器控制主相机时(updateStatus为true),不反向同步
if (!this.updateStatus) {
this.updateCameraRotation(camera.rotation);
}
};
// 绑定主相机控制器的change事件,实时同步辅助器
controls.addEventListener('change', this.changeCb);
}
/**
* 释放资源:清理事件监听和实例,避免内存泄漏
*/
dispose() {
// 释放视角辅助器资源
if (this.viewHelper) {
this.viewHelper.dispose();
}
// 移除主相机控制器的事件监听
if (this.controls) {
this.controls.removeEventListener('change', this.changeCb);
}
// 清除辅助器相机的资源
this.viewHelperCamera.clear();
}
}
调用
// 创建自定义视图辅助器实例
// 参数1:编辑器渲染器的DOM元素,用于关联渲染上下文
// 参数2:视图辅助器的容器元素,用于挂载辅助视图
const customViewHelper = new CustomViewHelper(
editor.renderer.domElement,
viewHelperContainer.value!,
);
// 同步相机和轨道控制器状态到自定义视图辅助器
// 确保辅助视图与主视图的相机状态保持一致
customViewHelper.syncCamera(editor.camera, editor.orbitControls);
// 禁用渲染器的自动清除功能
// 因为需要机手动控制渲染顺序和清除时 (非常关键)
editor.renderer.autoClear = false;
// 创建时钟实例,用于计算每帧的时间间隔
const clock = new Clock();
// 定义动画循环函数
const animate = () => {
// 获取当前帧与上一帧的时间间隔(秒)
const detail = clock.getDelta();
// 手动清除渲染器缓存
editor.renderer.clear();
// 渲染主场景 (必须先渲染主场景)
editor.renderer.render(editor.scene, editor.camera);
// 更新自定义视图辅助器
// 参数1:时间间隔,用于动画过渡
// 参数2:渲染器实例,用于绘制辅助视图
customViewHelper.update(detail, editor.renderer);
// 更新变换控制器(可能用于物体拖拽、旋转等交互)
transformControls.update(detail);
// 更新相机的投影矩阵
// 当相机参数(如视场角、近远平面)变化时需要调用
editor.camera.updateProjectionMatrix();
};
// 将动画循环函数设置为渲染器的动画循环
// 浏览器会自动调用该函数进行帧渲染
editor.renderer.setAnimationLoop(animate);