200字
使用Threejs 实现一个和Threejs Editor一样的视角指示器(Gizimo)
2025-11-30
2026-03-15

✨ 使用Threejs编写和ThreejsEditor一样的视角指示器(Gizimo)

基于Three.js的ViewHelper进行扩展,实现支持视角同步与点击控制的自定义视角辅助器。

📋 一、功能

  1. 🎯 视角可视化:通过缩略图实时展示主相机的空间位置与朝向;

  2. 🔄 双向同步:主相机旋转时自动更新缩略图,点击缩略图可快速切换主相机视角;

  3. 🛡️ 资源安全:提供完善的资源释放机制,避免内存泄漏。

🔍 二、核心实现原理拆解

自定义视角辅助器的核心是基于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 效果验证

集成完成后,通过以下步骤验证功能有效性:

  1. 🔄 拖拽主场景的OrbitControls,观察右下角缩略图中的相机图标旋转方向是否与主相机一致;

  2. 🖱️ 点击缩略图中的X、Y、Z轴或相机图标,主相机会自动切换到对应视角并伴有平滑动画;

  3. 🧹 页面刷新或关闭时,通过浏览器开发者工具的内存面板确认无内存泄漏。

🚀 四、整体代码

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);

评论