27 Three.js 几何体与材质基础开发

Three.js 几何体与材质基础开发

关联:索引

要解决的问题

本讲定位(与上一讲衔接,避免重复)

章节内容(本讲核心)

环境与先修(默认沿用上一讲工程)

先修要求:

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

# 安装 Three.js 运行时依赖(包含 Scene/Camera/Renderer/Mesh 等核心类)
npm i three
npm i -D @types/three

解释:


你要把一句话刻进脑子里:

Mesh = Geometry(形状) + Material(外观),Mesh 本体负责 position/rotation/scale(变换)。

落到工程上就是:

1) BoxGeometry(设备底座 / 工作台面 / 分拣框侧壁)

import * as THREE from 'three';

// 创建一个长方体几何体(常用于台面、底座、箱体侧壁等)
// 参数含义:宽 width、高 height、深 depth(单位是“世界单位”)
const geometry = new THREE.BoxGeometry(2, 0.4, 1);

解释:

常用调参思路:

2) SphereGeometry(传感器罩 / 警示灯罩 / 球形滚轮示意)

import * as THREE from 'three';

// 创建球体几何体(常用于罩壳、球形指示物、滚轮示意等)
// 参数含义:半径 radius、水平细分 widthSegments、垂直细分 heightSegments
const geometry = new THREE.SphereGeometry(0.35, 32, 16);

解释:

3) CylinderGeometry(立柱 / 支架柱 / 滚筒输送线滚筒)

import * as THREE from 'three';

// 创建圆柱几何体(常用于立柱、支撑杆、滚筒等)
// 参数含义:上半径 radiusTop、下半径 radiusBottom、高度 height、侧面细分 radialSegments
const geometry = new THREE.CylinderGeometry(0.12, 0.12, 1.2, 24);

解释:

最小组合方式:

import * as THREE from 'three';

// 1) 几何体:决定“形状”
const geometry = new THREE.BoxGeometry(1, 1, 1);

// 2) 材质:决定“怎么看起来”(颜色、透明、是否受光照等)
const material = new THREE.MeshBasicMaterial({ color: 0x22c55e });

// 3) Mesh:把几何体 + 材质组合成可渲染对象(并拥有 position/rotation/scale)
const mesh = new THREE.Mesh(geometry, material);

解释:

1) position:摆放位置

// 把物体移动到场景中的某个位置(x, y, z)
mesh.position.set(1.2, 0.2, -0.6);

解释:

2) rotation:旋转(单位是弧度)

// 旋转单位是“弧度”,不是角度:45° = Math.PI / 4
// 这里表示绕 Y 轴旋转 45°
mesh.rotation.set(0, Math.PI / 4, 0);

解释:

3) scale:缩放(比例)

// 按比例缩放(x, y, z),例如把 y 方向拉伸为原来的 2 倍
mesh.scale.set(1, 2, 1);

解释:

一个非常重要的工程结论:

你只要用的是 Lambert/Phong,就必须加灯光,否则你很可能得到“黑的/灰的/看不清”的物体。

目标:用同一个 SphereGeometry,同时创建三颗球,分别使用 Basic/Lambert/Phong,肉眼看到差异。

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

src/components/MaterialCompareLab.vue

src/components/MaterialCompareLab.vue

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

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

// 容器 DOM 引用:用于获取尺寸、挂载 canvas
const containerRef = ref<HTMLDivElement | null>(null);

// Three.js 核心对象(用可空类型,便于卸载时释放)
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;

// requestAnimationFrame 的 id:卸载时需要 cancel
let rafId: number | null = null;

// 监听容器尺寸变化:让渲染器和相机跟着更新
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;

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

  camera.aspect = width / height;
  camera.updateProjectionMatrix();
}

function animate() {
  if (!renderer || !scene || !camera) return;
  // 每帧渲染:把 scene 在 camera 视角下画到 canvas
  renderer.render(scene, camera);
  rafId = requestAnimationFrame(animate);
}

function createLights() {
  // Lambert/Phong 这类“受光照影响”的材质,必须有灯光,否则大概率会变黑
  const ambient = new THREE.AmbientLight(0xffffff, 0.35); // 环境光:整个场景四面八方均匀照过来的光 0xffffff白色,亮度0.35。无阴影、无立体感
  const dir = new THREE.DirectionalLight(0xffffff, 1.2); // 平行光(太阳光):用来照亮场景、产生阴影、做出3D立体感
  dir.position.set(3, 5, 2);
  return [ambient, dir] as const;
}

function disposeScene(targetScene: THREE.Scene) {
  // 示例里多个 Mesh 共享同一份 geometry,所以需要去重再 dispose
  const geometries = new Set<THREE.BufferGeometry>();
  const materials = new Set<THREE.Material>();

  targetScene.traverse((obj) => {
    const mesh = obj as THREE.Mesh;
    if (!mesh.isMesh) return;

    geometries.add(mesh.geometry);
    const material = mesh.material;
    if (Array.isArray(material)) material.forEach((m) => materials.add(m));
    else materials.add(material);
  });

  geometries.forEach((g) => g.dispose());
  materials.forEach((m) => m.dispose());
}

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:渲染器,负责把画面输出到 canvas
  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  container.appendChild(renderer.domElement);

  // 3) Camera:相机(透视)
  camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
  camera.position.set(4, 2.5, 6);
  camera.lookAt(0, 0.8, 0);

  // 辅助参照物:用于快速判断坐标系方向与尺度
  scene.add(new THREE.AxesHelper(2));
  scene.add(new THREE.GridHelper(10, 10));

  // 4) 灯光:让 Lambert/Phong 的明暗与高光生效
  const [ambient, dir] = createLights();
  scene.add(ambient, dir);

  // 5) 共享几何体:三个球形状相同,便于只对比材质差异
  const geometry = new THREE.SphereGeometry(0.45, 32, 16);

  // Basic:不受光照影响(用于“先验证可见”)
  const basic = new THREE.MeshBasicMaterial({ color: 0x22c55e });

  // Lambert:受光照影响(有明暗,但没有明显高光)
  const lambert = new THREE.MeshLambertMaterial({ color: 0x3b82f6 }); // 漫反射材质,哑光、不反光、没有亮光亮点

  // Phong:受光照影响(有高光),shininess 越大高光越锐利
  const phong = new THREE.MeshPhongMaterial({ color: 0xf97316, shininess: 80, specular: 0xffffff }); // 冯氏材质、支持光照、高光和反光。

  // 三个 Mesh:形状相同,外观不同(用于对比观察)
  const m1 = new THREE.Mesh(geometry, basic);
  const m2 = new THREE.Mesh(geometry, lambert);
  const m3 = new THREE.Mesh(geometry, phong);

  // 横向摆开,避免重叠,便于肉眼对比
  m1.position.set(-1.2, 0.5, 0);
  m2.position.set(0, 0.5, 0);
  m3.position.set(1.2, 0.5, 0);

  scene.add(m1, m2, m3);

  // 监听容器尺寸变化,自动触发 resize
  resizeObserver = new ResizeObserver(() => resize());
  resizeObserver.observe(container);

  // 首次同步尺寸后再开始渲染循环
  resize();
  animate();
});

onBeforeUnmount(() => {
  // 1) 停止渲染循环和观察器
  if (rafId !== null) cancelAnimationFrame(rafId);
  if (resizeObserver) resizeObserver.disconnect();

  // 2) 释放几何体与材质(GPU 资源)
  if (scene) disposeScene(scene);

  // 3) 释放渲染器并移除 canvas 节点
  renderer?.dispose();
  if (renderer?.domElement && renderer.domElement.parentNode) {
    renderer.domElement.parentNode.removeChild(renderer.domElement);
  }

  // 4) 清空引用,帮助 GC
  rafId = null;
  resizeObserver = null;
  camera = null;
  scene = null;
  renderer = null;
});
</script>

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

解释(你应该能“看懂差异从哪里来”):

把材质当成“外观配置对象”,常见需求是:上色、半透明、线框检查结构。

示例:半透明线框(适合调试分拣框边界)

const material = new THREE.MeshBasicMaterial({
  // 颜色:十六进制 RGB
  color: 0x60a5fa,

  // 透明:需要显式开启,否则 opacity 可能不生效
  transparent: true,

  // 透明度:0~1(越小越透明)
  opacity: 0.35,

  // 线框模式:用于结构检查/边界对齐/排错
  wireframe: true,
});

解释:

  1. 页面有 Canvas(renderer.domElement 已插入容器)。
  2. 能看到 AxesHelper 或 GridHelper(说明相机/渲染链路有效)。
  3. 至少 3 个几何体同时可见(Box/Sphere/Cylinder)。
  4. position 生效:至少一个物体明显不在原点。
  5. rotation 生效:至少一个物体明显旋转(用 45°/90° 这类易观察角度)。
  6. scale 生效:至少一个物体明显拉伸或缩放。
  7. 材质对比生效:Basic 与 Lambert/Phong 在“是否受光照”上表现不同。
  8. 材质配置生效:透明或线框至少命中一项,并能说明使用场景(调试/结构检查/强调轮廓)。

2) 最小代码自检(不依赖测试框架)

目标:在运行时快速检查“关键对象是否创建成功”,减少肉眼误判。

function assert(condition: boolean, message: string) {
  // 条件不满足就抛异常,让错误“立刻可见”
  if (!condition) throw new Error(message);
}

function runSmokeChecks(options: {
  scene: THREE.Scene | null;
  camera: THREE.Camera | null;
  renderer: THREE.WebGLRenderer | null;
  meshCountMin: number;
}) {
  // 关键对象必须存在:否则后续所有渲染/调参都无意义
  assert(!!options.scene, 'scene is null');
  assert(!!options.camera, 'camera is null');
  assert(!!options.renderer, 'renderer is null');

  // 粗粒度检查:至少要有一定数量的 Mesh(防止“scene 空空如也”)
  let meshCount = 0;
  options.scene?.traverse((o) => {  // traverse遍历
    if ((o as THREE.Mesh).isMesh) meshCount += 1;
  });
  assert(meshCount >= options.meshCountMin, `mesh count < ${options.meshCountMin}`);

  if (options.renderer?.domElement) {
    const canvas = options.renderer.domElement;
    // canvas 尺寸为 0 通常意味着容器高度为 0 或 setSize 没成功
    assert(canvas.width > 0 && canvas.height > 0, 'canvas size is 0');
  }
}

解释:

把它接入你组件的方式(示例):

// 示例:期望场景里至少有 3 个 Mesh(例如 Box/Sphere/Cylinder)
runSmokeChecks({ scene, camera, renderer, meshCountMin: 3 });

解释:

作业

参考与延伸