26 Three.js 入门与核心概念认知

Three.js 入门与核心概念认知

关联:索引

要解决的问题

章节内容(本讲核心):

与前置知识衔接(避免重复):

作业:见文末(本次布置)。


  1. 3D 相比 2D 的关键价值:

1. 你要记住的最短结论(背下来就能排错)

把它们连成一句话:

把 Mesh 放进 Scene,用 Camera 选择视角,用 Renderer 把 Scene 渲染到页面的 Canvas。

2. 组件之间的关系图(文字版)

  1. 你创建并配置 Scene。
  2. 你创建 Camera,并把 Camera 放在某个位置(Camera 本身也是一个 3D 对象)。
  3. 你创建 Renderer,并把它生成的画布(renderer.domElement)插入到 HTML 页面。
  4. 你创建 Mesh(几何体 + 材质),把它添加进 Scene。
  5. 你调用 renderer.render(scene, camera),浏览器就能看到画面。

3. Mesh 的拆分理解(避免把 Mesh 当“黑盒”)

1. Three.js 的默认坐标系与相机朝向

伸出右手:拇指、食指、中指两两垂直:拇指–>+X;食指–>+Y;中指–>+Z

建议在任何新场景里先加 2 个辅助物体来“校准空间感”:

2. 物体的三大变换(最常用)

一个工业常见经验:

本项目工坊目标:

1) 依赖安装(两种情况二选一)

情况 A:你已有 Vue3 + TS(Vite)项目(推荐)

# 安装 Three.js 核心库(运行时依赖)
npm i three

# 安装 Three.js 的 TypeScript 类型(仅开发期依赖;用于 vue-tsc/类型提示)
npm i -D @types/three

解释:

情况 B:你需要新建一个最小 Vue3 + TS 工程(可选)

# 1) 创建 Vue3 + TS 模板工程(非交互式)
npm create vite@latest threejs-vue -- --template vue-ts

# 2) 进入工程目录
cd threejs-vue

# 3) 安装模板依赖
npm i

# 4) 安装 Three.js + 类型(避免 build 阶段 TS7016)
npm i three
npm i -D @types/three

# 5) 启动开发服务器
npm run dev

解释:

2) 组件落地:把四大组件写进 ThreeBasicScene.vue

建议新增一个组件文件(示例路径):

src/
├─ App.vue
└─ components/
   └─ ThreeBasicScene.vue

说明:

src/components/ThreeBasicScene.vue

<template>
  <div ref="containerRef" class="three-container"></div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as THREE from 'three';

const containerRef = ref<HTMLDivElement | null>(null);

let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | THREE.OrthographicCamera | null = null;
let robotRoot: THREE.Group | null = null;
let robotBody: THREE.Mesh | null = null;

let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let poseTimerId: number | null = null;

type Vec3 = { x: number; y: number; z: number };
type Quat = { x: number; y: number; z: number; w: number };
type Pose = {
  position: Vec3;
  orientation: Quat;
};

function mapPoseToThree(pose: Pose): Pose {
  return pose;
}

function applyPose(object: THREE.Object3D, pose: Pose) {
  const mapped = mapPoseToThree(pose);
  object.position.set(mapped.position.x, mapped.position.y, mapped.position.z);
  object.quaternion.set(
    mapped.orientation.x,
    mapped.orientation.y,
    mapped.orientation.z,
    mapped.orientation.w,
  );
}

function createPerspectiveCamera(width: number, height: number) {
  const cam = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
  cam.position.set(3, 2, 5);
  cam.lookAt(0, 0, 0);
  return cam;
}

function createOrthographicCamera(width: number, height: number) {
  const aspect = width / height;
  const viewSize = 4;
  const cam = new THREE.OrthographicCamera(
    -viewSize * aspect,
    viewSize * aspect,
    viewSize,
    -viewSize,
    0.1,
    1000,
  );
  cam.position.set(3, 2, 5);
  cam.lookAt(0, 0, 0);
  return cam;
}

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;

  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setSize(width, height, false);

  if (camera instanceof THREE.PerspectiveCamera) {
    camera.aspect = width / height;
  } else {
    const aspect = width / height;
    const viewSize = 4;
    camera.left = -viewSize * aspect;
    camera.right = viewSize * aspect;
    camera.top = viewSize;
    camera.bottom = -viewSize;
  }
  camera.updateProjectionMatrix();
}

function animate() {
  if (!renderer || !scene || !camera) return;
  renderer.render(scene, camera);
  rafId = requestAnimationFrame(animate);
}

function startMockPoseStream(onPose: (pose: Pose) => void) {
  const pose: Pose = {
    position: { x: 0, y: 0, z: 0 },
    orientation: { x: 0, y: 0, z: 0, w: 1 },
  };

  const euler = new THREE.Euler(0, 0, 0, 'YXZ');
  const q = new THREE.Quaternion();

  let t = 0;
  poseTimerId = window.setInterval(() => {
    t += 0.03;

    pose.position.x = Math.cos(t) * 1.5;
    pose.position.y = 0;
    pose.position.z = Math.sin(t) * 1.5;

    euler.set(0, t, 0);
    q.setFromEuler(euler);
    pose.orientation.x = q.x;
    pose.orientation.y = q.y;
    pose.orientation.z = q.z;
    pose.orientation.w = q.w;

    onPose(pose);
  }, 50);
}

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

  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0b1220);

  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  container.appendChild(renderer.domElement);

  const width = container.clientWidth || 1;
  const height = container.clientHeight || 1;

  camera = createPerspectiveCamera(width, height);
  resize();

  scene.add(new THREE.AxesHelper(2));
  scene.add(new THREE.GridHelper(10, 10));

  robotRoot = new THREE.Group();
  scene.add(robotRoot);

  const bodyGeometry = new THREE.BoxGeometry(0.8, 0.2, 0.6);
  const bodyMaterial = new THREE.MeshBasicMaterial({ color: 0x22c55e });
  robotBody = new THREE.Mesh(bodyGeometry, bodyMaterial);
  robotRoot.add(robotBody);

  startMockPoseStream((pose) => {
    if (!robotRoot) return;
    applyPose(robotRoot, pose);
  });

  resizeObserver = new ResizeObserver(() => resize());
  resizeObserver.observe(container);

  animate();
});

onBeforeUnmount(() => {
  if (poseTimerId !== null) window.clearInterval(poseTimerId);
  if (rafId !== null) cancelAnimationFrame(rafId);
  if (resizeObserver) resizeObserver.disconnect();

  if (scene && robotRoot) scene.remove(robotRoot);

  const geometry = robotBody?.geometry;
  if (geometry && 'dispose' in geometry) geometry.dispose();

  const material = robotBody?.material;
  if (Array.isArray(material)) material.forEach((m) => m.dispose());
  else material?.dispose();

  renderer?.dispose();
  if (renderer?.domElement && renderer.domElement.parentNode) {
    renderer.domElement.parentNode.removeChild(renderer.domElement);
  }

  robotBody = null;
  robotRoot = null;
  camera = null;
  scene = null;
  renderer = null;
  resizeObserver = null;
  poseTimerId = null;
  rafId = null;
});
</script>

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

解释(把 Vue3 + TS 的“工程脑”迁移到 Three.js):

关键代码注释(对应上方代码,建议你逐条对照):

  1. 数据结构(与 Gazebo/ROS 对齐)
  1. 坐标系对齐入口(最重要的工程策略)
  1. 位姿写入(统一落点)
  1. 相机(Perspective vs Orthographic)
  1. resize(画面不拉伸的核心)
  1. animate(持续渲染)
  1. mock 数据源(验证“数据 → 画面”链路)
  1. 卸载释放(真实项目必须做)

3) 预留 rosbridge 数据接入接口(不要求当堂跑通)

你后续要接入 Gazebo/ROS 数据,建议先做一个“专用 pose topic”,在 ROS 侧把目标对象的位姿发布为 geometry_msgs/Pose(或 PoseStamped),再让前端订阅。

下面给出一个“纯 WebSocket + rosbridge 协议”的最小订阅骨架(仅展示接口与消息结构,后续 rosbridge 章节会完整联调):

type Pose = {
  position: { x: number; y: number; z: number };
  orientation: { x: number; y: number; z: number; w: number };
};

type RosbridgeSubscribe = {
  op: 'subscribe';
  topic: string;
  throttle_rate?: number;
};

type RosbridgePublish<TMsg> = {
  op: 'publish';
  topic: string;
  msg: TMsg;
};

type RosPoseMsg = {
  position: { x: number; y: number; z: number };
  orientation: { x: number; y: number; z: number; w: number };
};

function createRosbridgePoseStream(
  options: { url: string; topic: string },
  onPose: (pose: Pose) => void,
) {
  const ws = new WebSocket(options.url);

  ws.addEventListener('open', () => {
    const sub: RosbridgeSubscribe = { op: 'subscribe', topic: options.topic, throttle_rate: 20 };
    ws.send(JSON.stringify(sub));
  });

  ws.addEventListener('message', (ev) => {
    try {
      const data = JSON.parse(String(ev.data)) as RosbridgePublish<RosPoseMsg>;
      if (data.op !== 'publish') return;
      if (data.topic !== options.topic) return;

      const pose: Pose = { position: data.msg.position, orientation: data.msg.orientation };
      onPose(pose);
    } catch {
      return;
    }
  });

  return () => {
    ws.close();
  };
}

解释:

4) 数据源适配器(PoseSource):mock / rosbridge 一键切换(推荐升级)

如果你后续要把同一套前端界面同时用于:

建议把“位姿数据源”抽象成统一接口 PoseSource:Three.js 渲染层只关心 Pose,不关心数据来自哪里。

下面是一份可直接粘贴的最小适配器骨架(你可以把它放在 ThreeBasicScene.vue 内部,替换掉 startMockPoseStream 相关逻辑):

使用前置:

type PoseSource = {
  start: (onPose: (pose: Pose) => void) => void;
  stop: () => void;
};

function createMockPoseSource(): PoseSource {
  let timerId: number | null = null;

  return {
    start(onPose) {
      const pose: Pose = {
        position: { x: 0, y: 0, z: 0 },
        orientation: { x: 0, y: 0, z: 0, w: 1 },
      };

      const euler = new THREE.Euler(0, 0, 0, 'YXZ');
      const q = new THREE.Quaternion();

      let t = 0;
      timerId = window.setInterval(() => {
        t += 0.03;

        pose.position.x = Math.cos(t) * 1.5;
        pose.position.y = 0;
        pose.position.z = Math.sin(t) * 1.5;

        euler.set(0, t, 0);
        q.setFromEuler(euler);
        pose.orientation.x = q.x;
        pose.orientation.y = q.y;
        pose.orientation.z = q.z;
        pose.orientation.w = q.w;

        onPose(pose);
      }, 50);
    },
    stop() {
      if (timerId !== null) window.clearInterval(timerId);
      timerId = null;
    },
  };
}

type RosbridgeSubscribe = { op: 'subscribe'; topic: string; throttle_rate?: number };
type RosbridgeUnsubscribe = { op: 'unsubscribe'; topic: string };
type RosbridgePublish<TMsg> = { op: 'publish'; topic: string; msg: TMsg };
type RosPoseMsg = Pose;

function createRosbridgePoseSource(options: { url: string; topic: string }): PoseSource {
  let ws: WebSocket | null = null;

  return {
    start(onPose) {
      ws = new WebSocket(options.url);

      ws.addEventListener('open', () => {
        const sub: RosbridgeSubscribe = { op: 'subscribe', topic: options.topic, throttle_rate: 50 };
        ws?.send(JSON.stringify(sub));
      });

      ws.addEventListener('message', (ev) => {
        try {
          const data = JSON.parse(String(ev.data)) as RosbridgePublish<RosPoseMsg>;
          if (data.op !== 'publish') return;
          if (data.topic !== options.topic) return;
          onPose({ position: data.msg.position, orientation: data.msg.orientation });
        } catch {
          return;
        }
      });
    },
    stop() {
      if (!ws) return;
      if (ws.readyState === WebSocket.OPEN) {
        const unsub: RosbridgeUnsubscribe = { op: 'unsubscribe', topic: options.topic };
        ws.send(JSON.stringify(unsub));
      }
      ws.close();
      ws = null;
    },
  };
}

function selectPoseSource(): PoseSource {
  const mode = import.meta.env.VITE_POSE_SOURCE as string | undefined;
  if (mode === 'rosbridge') {
    const url = (import.meta.env.VITE_ROSBRIDGE_URL as string | undefined) ?? 'ws://localhost:9090';
    const topic = (import.meta.env.VITE_POSE_TOPIC as string | undefined) ?? '/web_pose';
    return createRosbridgePoseSource({ url, topic });
  }
  return createMockPoseSource();
}

解释:

把它接入到组件的最小方式:

// 1) 选择数据源(mock / rosbridge)
const poseSource = selectPoseSource();

// 2) 启动数据源:每次收到 pose,都统一写入 3D 对象
poseSource.start((pose) => {
  if (!robotRoot) return;
  applyPose(robotRoot, pose);
});

// 3) 组件卸载时停掉数据源(避免 WebSocket/定时器后台继续跑)
onBeforeUnmount(() => {
  poseSource.stop();
});

解释:

5) 在页面中使用组件

src/App.vue(最小接入):

<template>
  <ThreeBasicScene />
</template>

<script setup lang="ts">
import ThreeBasicScene from './components/ThreeBasicScene.vue';
</script>

解释:

你已经在 ThreeBasicScene.vue 里完成了“Gazebo 上屏骨架”。本段只做两件事:确认相机切换没问题、确认坐标系对齐验证路径清晰(为后续接真实 Gazebo 数据做准备)。

  1. 页面渲染后能看到:背景色 + 坐标轴/网格 + 机器人实体(绿色方块)。
  2. mock 位姿更新生效:机器人实体在平面上运动并旋转(说明“数据 → 画面更新”链路已跑通)。
  3. 切换透视/正交相机并截图对比:确认你能解释“为什么俯视/对齐更适合正交”。
  4. 坐标系对齐验证口径(只说步骤,不做复杂推导):
  1. 路由切换离开页面后:不继续更新、不出现控制台报错(资源已释放)。

1) 本质差异一句话

场景 更推荐相机 原因
园区/车间数字孪生漫游、设备巡检视角 透视相机 更真实、更易理解空间层次
俯视态势图、产线布局、平面规划 正交相机 不失真,便于对齐与测量
需要同时支持“宏观漫游 + 局部精准对齐” 双相机切换 工程上常见:漫游用透视,定位/对齐用正交

3) 正交相机最小替换(只改 Camera 部分)

在 Vue 组件中,你只需要把相机创建从 createPerspectiveCamera(...) 切换为 createOrthographicCamera(...)(保留同样的 resize() 逻辑即可)。

建议你做一个最小实验:

  1. 用透视相机截图一次(能明显看出近大远小)。
  2. 切换到正交相机再截图一次(物体大小不会随距离变化)。

如果你想保留“可切换”的工程结构(更贴近工业项目),建议把相机创建抽成 createPerspectiveCamera/createOrthographicCamera 两个函数(示例代码已给出),再通过一个变量或 UI 开关控制使用哪个相机。

当你遇到“黑屏/空白/看不到物体”,按下面顺序自检(强制执行,避免乱猜):

  1. Vue/DOM 层面:容器是否有尺寸(宽高是否为 0)?控制台是否有报错?组件是否重复 mount?

  2. 渲染器层面:renderer.domElement 是否插入容器?setSize 是否与容器一致?devicePixelRatio 是否过高导致性能异常?

  3. 相机层面:相机位置是否合理?是否 lookAt(0,0,0)near/far 是否覆盖目标距离?

  4. 场景层面:是否至少添加了 AxesHelper/GridHelper 作为参照物?

  5. 物体层面:物体是否加入 scene.add(mesh)?位置是否在视野内?

  6. 材质与光照:入门阶段优先用 MeshBasicMaterial 验证“能看见”,再引入受光照影响的材质与灯光。

  7. 工程化层面:离开页面后是否停止渲染循环并 dispose?否则“没有报错但越来越卡”会非常隐蔽。

  8. 写下你的“Gazebo 上屏目标一句话”(示例:在网页里显示机器人底盘模型,并实时更新它的位姿)。

  9. 在 Vue3 + TS 工程中安装 three,创建 ThreeBasicScene.vue 并渲染成功。

  10. 在组件内创建并关联四大组件:Scene、Camera、Renderer、Mesh,并添加 AxesHelper/GridHelper(用于坐标系校验)。

  11. 做一次“仿真数据驱动更新”的最小实验(不接 ROS,先用 mock):每 50ms 更新一次物体的 position/rotation,画面稳定刷新。

  12. 完成一个工程化自检点:路由切换离开该页面后不再渲染、无报错(体现 onBeforeUnmount 的资源释放)。

大模型任务(可直接复制使用)

任务 1:讲解四大组件的协作逻辑

请用“Gazebo 仿真上屏链路类比 + 分步骤”的方式讲解 Three.js 的四大核心组件 Scene、Camera、Renderer、Mesh:
1) 每个组件各自负责什么;
2) 它们之间的调用顺序与依赖关系(从创建到持续 render);
3) 当我把 Gazebo/ROS 的位姿数据接入后,应该把“数据更新”放在哪一层(更新 mesh 还是重建 scene);
4) 新手最常见的 5 个黑屏/方向不对原因,并给出排查顺序。
要求:输出结构化要点,适合入门学习者,尽量用仿真上屏的例子类比。

任务 2:生成最简 Three.js 基础场景代码(含注释)

请生成一个“Gazebo 上屏骨架(先 mock 数据)”的最简可运行代码,要求:
- 使用 Vue3 + Vite + TypeScript;
- 输出两个文件的完整代码:
  1) src/components/ThreeBasicScene.vue
  2) src/App.vue(负责引入并渲染组件)
- 场景包含:Scene、PerspectiveCamera、WebGLRenderer、一个立方体 Mesh;
- 加上 AxesHelper 或 GridHelper;
- 需要有容器 resize 自适应;
- 需要有 onBeforeUnmount 的资源释放(cancelAnimationFrame + dispose)。
- 增加一个 mock “位姿更新”定时器:周期性更新 mesh.position 与 mesh.rotation,模拟 Gazebo 推送的数据。
输出格式:先给目录结构,再分别给出两个文件的完整代码。

任务 3:对比两种相机的工业适用场景

请对比 PerspectiveCamera 与 OrthographicCamera(面向 Gazebo/工业仿真展示):
1) 本质差异(从成像与用途角度);
2) 关键参数分别是什么(fov/aspect/near/far 与 left/right/top/bottom/near/far);
3) 给出至少 4 个工业 3D 场景,并说明更适合哪种相机以及原因;
4) 推荐一个“项目中的相机选型策略”(例如双相机切换),并给出注意事项。
要求:用表格输出对比,并给出简短结论。

任务 4:坐标系对齐与验证步骤(强烈建议做)

我准备用 Three.js 在网页展示 Gazebo/ROS 的机器人位姿数据。请给出一个“坐标系对齐与验证”的最小方案:
1) Three.js 右手系、相机默认朝向的关键结论;
2) 如果我拿到的是 ROS 中的位姿(位置 + 四元数),我应该先做哪些最小假设(例如单位、轴向、旋转顺序);
3) 给出一个验证步骤清单:先只动 X,再只动 Y,再只动 Z;再只绕某一轴旋转;每步应该看到什么;
4) 列出 5 个最常见的对齐错误与表现(镜像/轴颠倒/单位不一致/角度制 vs 弧度/四元数顺序错误等)。
要求:用 checklist 输出,并能直接用于课堂验收。

作业(本次布置)

  1. 完成 Three.js 基础环境搭建(Vue3 + TS 工程),创建一个包含场景、相机、渲染器的空白 3D 场景,确保页面能正常渲染(无报错)。

提交要求:

  1. 用 mock 数据做一次“仿真上屏最小演示”:让一个立方体在画面中沿 X 轴匀速移动,并绕 Y 轴旋转(模拟底盘运动与航向变化),截图并标注你更新的字段(position/rotation)。

提交要求:

  1. 分别配置透视相机与正交相机,截图两种相机的渲染效果,标注关键参数(fov、aspect、near、far 或正交视域 viewSize)。

提交要求:

  1. 撰写 200 字左右说明:简述四大核心组件的作用,并用 3 句话说明“Gazebo 数据到 Three.js 画面更新”的链路(不要求写代码,要求边界清晰)。

评分要点(参考):