31 OrbitControls 轨道控制器与场景导航

OrbitControls 轨道控制器与场景导航

关联:索引

要解决的问题

本讲定位(与前置衔接,避免重复)

章节内容(本讲核心)

环境与先修(默认沿用 Three.js 工程)

先修要求:

如你需要补装依赖(仅在未安装时执行):

# 安装 Three.js 运行时依赖
npm i three

# 安装 TypeScript 类型(用于类型提示与构建期校验)
npm i -D @types/three

解释:


工业 3D 场景里,“好不好看”通常不是第一指标,“能不能快速定位与理解状态”才是第一指标。对导航的最低要求可以总结为:

1) OrbitControls 在“控制什么”

你可以把 OrbitControls 理解为:

关键点:

2) 三种交互的体验含义

3) 为什么工业场景需要“限制”

工业场景通常有“地面/产线平面”和“关键设备区域”。如果不加限制,常见事故是:

目标:

1) 引入 OrbitControls(关键导入路径)

在 Vite + TS 工程中,OrbitControls 通常从 examples 的 ESM 路径导入:

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

解释:

2) 最小可用组件示例:ThreeOrbitControlsScene.vue

下面给出一份可直接运行的 Vue3 + TS 示例(你也可以把它当作对上一讲 ThreeBasicScene 的“升级版”)。

<template>
  <!-- Three.js 渲染容器:renderer.domElement(canvas)会被插入到这个 div 里 -->
  <div ref="containerRef" class="three-container"></div>
</template>

<script setup lang="ts">
// Vue 生命周期:负责把 Three.js 的创建/销毁绑定到组件挂载/卸载
import { onBeforeUnmount, onMounted, ref } from 'vue';
// Three.js 核心:Scene/Camera/Renderer/Mesh/Light 等
import * as THREE from 'three';
// OrbitControls:Three.js examples 模块中的轨道控制器(需要单独从 jsm 路径导入)
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

// 组件容器 DOM 引用(用于挂载 renderer.domElement)
const containerRef = ref<HTMLDivElement | null>(null);

// Three.js 运行时对象(用 let + null,便于在卸载时统一释放)
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let controls: OrbitControls | null = null;
let demoMesh: THREE.Mesh | null = null;
// 右键菜单拦截回调:用于把“右键”稳定用于平移(Pan),避免浏览器菜单打断交互
let onContextMenu: ((e: MouseEvent) => void) | null = null;

// requestAnimationFrame id:用于卸载时停止渲染循环
let rafId: number | null = null;
// ResizeObserver:监听容器尺寸变化,避免布局变化导致画面拉伸/变形
let resizeObserver: ResizeObserver | null = null;

function resize() {
  const container = containerRef.value;
  if (!container || !renderer || !camera) return;

  // 获取容器实际像素尺寸
  const width = container.clientWidth;
  const height = container.clientHeight;
  if (width <= 0 || height <= 0) return;

  // 设备像素比:限制到 2,兼顾清晰度与性能
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  // setSize 第三个参数 false:不改 canvas 的 style,仅改绘制缓冲区尺寸(更可控)
  renderer.setSize(width, height, false);

  // 透视相机 resize:更新 aspect 并刷新投影矩阵
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
}

function animate() {
  if (!renderer || !scene || !camera) return;

  // 控制器内部状态更新:
  // - 开启 enableDamping 后必须每帧 update 才会有“惯性/阻尼”的效果
  if (controls) controls.update();
  // 将 scene 按 camera 视角渲染到 canvas
  renderer.render(scene, camera);

  // 下一帧继续渲染
  rafId = requestAnimationFrame(animate);
}

onMounted(() => {
  const container = containerRef.value;
  if (!container) throw new Error('Three container not found');

  // 1) Scene:承载所有可见物体与灯光
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0b1220);

  // 2) Renderer:创建 WebGL 渲染器,并把 canvas 插入页面
  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  container.appendChild(renderer.domElement);

  // 右键菜单禁用:右键用于平移 Pan 时,避免弹出浏览器菜单
  onContextMenu = (e: MouseEvent) => e.preventDefault();
  renderer.domElement.addEventListener('contextmenu', onContextMenu);

  // 3) Camera:透视相机(工业漫游/巡检更常用)
  const width = container.clientWidth || 1;
  const height = container.clientHeight || 1;

  camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
  // 相机初始位置:稍远、稍高、斜向看向中心,保证“开场就能看到东西”
  camera.position.set(6, 4, 8);
  camera.lookAt(0, 0, 0);

  // 首次同步尺寸
  resize();

  // 4) 辅助参照物:帮助学生建立空间坐标感(建议教学演示保留)
  scene.add(new THREE.AxesHelper(2));
  scene.add(new THREE.GridHelper(20, 20));

  // 5) Demo 物体:模拟设备/传送带等工业对象(Geometry + Material => Mesh)
  const demoGeometry = new THREE.BoxGeometry(2, 0.4, 1);
  const demoMaterial = new THREE.MeshStandardMaterial({ color: 0x22c55e });
  demoMesh = new THREE.Mesh(demoGeometry, demoMaterial);
  demoMesh.position.set(0, 0.2, 0);
  scene.add(demoMesh);

  // 6) 灯光:StandardMaterial 需要光照,否则会“变黑看不见”
  const light = new THREE.DirectionalLight(0xffffff, 1.2);
  light.position.set(5, 8, 5);
  scene.add(light);
  scene.add(new THREE.AmbientLight(0xffffff, 0.35));

  // 7) OrbitControls:把“相机”与“canvas”绑定,形成可交互导航
  controls = new OrbitControls(camera, renderer.domElement);

  // 启用三件套(按业务可裁剪)
  controls.enableRotate = true;
  controls.enableZoom = true;
  controls.enablePan = true;

  // 交互速度:数值越大越灵敏(工业场景建议偏稳)
  controls.rotateSpeed = 0.7;
  controls.zoomSpeed = 0.9;
  controls.panSpeed = 0.8;

  // 平移按屏幕坐标进行:更贴近“拖拽方向 = 画面移动方向”的直觉
  controls.screenSpacePanning = true;

  // 旋转中心:围绕设备上方一点旋转,减少“绕地面转圈”的眩晕感
  controls.target.set(0, 0.8, 0);
  // 修改 target 后需要 update 一次,让控制器内部状态同步
  controls.update();

  // 8) 自适应:容器尺寸变化时同步相机与渲染器
  resizeObserver = new ResizeObserver(() => resize());
  resizeObserver.observe(container);

  // 9) 启动渲染循环
  animate();
});

onBeforeUnmount(() => {
  // 1) 停止渲染循环与尺寸监听
  if (rafId !== null) cancelAnimationFrame(rafId);
  if (resizeObserver) resizeObserver.disconnect();

  // 2) 释放 OrbitControls(移除事件监听)
  controls?.dispose();

  // 3) 移除并释放几何体/材质(释放 GPU 资源)
  if (scene && demoMesh) scene.remove(demoMesh);
  const geometry = demoMesh?.geometry;
  if (geometry && 'dispose' in geometry) geometry.dispose();
  const material = demoMesh?.material;
  if (Array.isArray(material)) material.forEach((m) => m.dispose());
  else material?.dispose();

  // 4) 移除右键菜单监听
  if (renderer?.domElement && onContextMenu) {
    renderer.domElement.removeEventListener('contextmenu', onContextMenu);
  }

  // 5) 释放渲染器与 DOM(避免路由切换后“越用越卡”)
  renderer?.dispose();
  if (renderer?.domElement && renderer.domElement.parentNode) {
    renderer.domElement.parentNode.removeChild(renderer.domElement);
  }

  // 6) 清空引用(帮助 GC 回收)
  demoMesh = null;
  controls = null;
  camera = null;
  scene = null;
  renderer = null;
  resizeObserver = null;
  onContextMenu = null;
  rafId = null;
});
</script>

<style scoped>
.three-container {
  width: 100%;
  height: 100vh;
  overflow: hidden;
}
</style>

解释(把 OrbitControls 接入“渲染闭环”):

OrbitControls 的“限制”主要分两类:距离(或缩放)限制、角度限制。

1) 距离限制(透视相机常用)

// 透视相机:限制相机与 target 的距离,避免缩放过近穿模/过远丢失可读性
controls.minDistance = 2;
controls.maxDistance = 30;

解释:

参数建议(工业分拣产线雏形):

2) 角度限制(防止翻转/穿地)

OrbitControls 常用两组角度:

典型的“防穿地”配置:

// PolarAngle:限制“上下翻转”的范围(防止翻到地面下方)
controls.minPolarAngle = 0.15;
controls.maxPolarAngle = Math.PI / 2 - 0.05;

解释:

如果你需要限制水平绕圈(例如只允许从产线同侧观察):

// AzimuthAngle:限制“左右绕圈”的范围(适合固定站位/固定观察侧)
controls.minAzimuthAngle = -Math.PI / 3;
controls.maxAzimuthAngle = Math.PI / 3;

解释:

3) 补充:正交相机的缩放限制(了解即可)

当使用 OrthographicCamera 时,更常用:

// 正交相机:通常用 zoom 表达缩放,因此用 minZoom/maxZoom 限制
controls.minZoom = 0.6;
controls.maxZoom = 3;

解释:

1) 阻尼(damping):让拖拽/滚轮更“顺”

// 阻尼:让交互更顺滑(开启后必须在渲染循环里每帧 controls.update())
controls.enableDamping = true;
controls.dampingFactor = 0.08;

解释:

自检要点:

2) 自动旋转(autoRotate):适合“展示模式”

// 自动旋转:更适合大屏展示/无人值守演示,交互页通常默认关闭
controls.autoRotate = false;
controls.autoRotateSpeed = 1.0;

解释:

建议口径(适用于大多数“设备/产线”类场景):

OrbitControls 的鼠标按键映射可显式配置(可选):

// 显式定义鼠标按键映射:避免不同默认值导致“按键行为不一致”
controls.mouseButtons = {
  LEFT: THREE.MOUSE.ROTATE,
  MIDDLE: THREE.MOUSE.DOLLY,
  RIGHT: THREE.MOUSE.PAN,
};

解释:

把下面这组参数当作“工业默认套餐”,先跑通再微调:

// 交互能力开关:工业场景通常三件套都开;展示屏可考虑禁用平移
controls.enableRotate = true;
controls.enableZoom = true;
controls.enablePan = true;

// 速度参数:越大越灵敏(工业建议偏稳)
controls.rotateSpeed = 0.65;
controls.zoomSpeed = 0.85;
controls.panSpeed = 0.8;

// 阻尼:提升“手感”,避免突然停/突然动
controls.enableDamping = true;
controls.dampingFactor = 0.08;

// 距离限制:防穿模/防丢失
controls.minDistance = 2;
controls.maxDistance = 30;

// 角度限制:防翻转到地面下方
controls.minPolarAngle = 0.15;
controls.maxPolarAngle = Math.PI / 2 - 0.05;

// 旋转中心:围绕设备而不是围绕地面
controls.target.set(0, 0.8, 0);
controls.update();

解释(推荐理由):

  1. 缩放到最近:不会穿进箱体内部;画面不闪烁(minDistance 生效)。
  2. 缩放到最远:仍能看清产线整体;不会变成一个点(maxDistance 生效)。
  3. 俯仰到极限:不会翻到地面下方(maxPolarAngle 生效)。
  4. 左右绕圈:若设置 azimuth 限制,能感知“有边界”,且边界不会突然抽搐。
  5. 平移:不会把 target 平移到无限远;能把目标重新移回视野中心。
  6. 阻尼:松手后自然停止,不拖尾过长、不突兀急停(dampingFactor 合理)。

  1. 引入 OrbitControls 插件,完成控制器与相机、渲染器的关联(可运行且无报错)。
  2. 配置控制器的旋转角度与缩放范围(min/max 生效,能说明其意义)。
  3. 开启阻尼效果并保证渲染循环中正确 update(体验明显变顺滑)。

你可以直接把下面 3 条指令丢给 AI,让它产出可粘贴代码与调参建议(并按校验点自检):

  1. 生成完整配置代码

要求 AI 输出:

校验点:

  1. 给出“工业分拣产线”推荐参数

要求 AI 输出:

校验点:

  1. 优化交互体验技巧

要求 AI 输出:

校验点:

作业

Markdown 与代码自检清单(本讲已自检)