这是一个会话,你导入在Blender或 glbxz.com 官网中准备的下载3D模型,并用盒子替换它们。 世界(基础)保持不变,但只有放置的部分会改变。



在此之前:别每次都写同样的准备

在进入正题之前,让我先绕个弯。

到目前为止,我一直把场景、摄像机、渲染器、光线和地板放在main.js顶部。 因为内容短暂,所以还算可以接受,但随着我有更多东西要做,每次都写同样的20行准备工作感觉很浪费。

此外,之前的最小代码有个弱点,就是调整窗口大小会导致失真(你试试就能看到)。 我一直保持沉默,因为这会妨碍我开始,但我希望能尽快解决。

所以,让基础base.js

// src/base.js ── シリーズ共通の土台
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";

export function createWorld({ environment = true, floor = true } = {}) {
  // --- 3要素(A2でやったところ) ---
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x15171c);

  const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
  camera.position.set(0, 1, 3); // 少し上から見下ろすと、床の影が見える

  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(innerWidth, innerHeight);
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // 高解像度でも重くしすぎない
  renderer.shadowMap.enabled = true;
  document.body.appendChild(renderer.domElement);

  // --- マウスで視点を回せるように ---
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;

  // --- ライト2灯(A3でやったところ) ---
  const ambient = new THREE.AmbientLight(0xffffff, 1.5);
  const directional = new THREE.DirectionalLight(0xffffff, 2);
  directional.position.set(2, 3, 4);
  directional.castShadow = true;
  directional.shadow.mapSize.set(1024, 1024);
  scene.add(ambient, directional);

  // --- 環境マップ(映り込み) ---
  if (environment) {
    const pmrem = new THREE.PMREMGenerator(renderer);
    scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;
  }

  // --- 影を受ける床 ---
  if (floor) {
    const ground = new THREE.Mesh(
      new THREE.PlaneGeometry(20, 20),
      new THREE.MeshStandardMaterial({ color: 0x2a2d34, roughness: 1 })
    );
    ground.rotation.x = -Math.PI / 2;
    ground.position.y = -1;
    ground.receiveShadow = true;
    scene.add(ground);
  }

  // --- リサイズへの追従(毎回書くのが面倒だったやつ) ---
  addEventListener("resize", () => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
  });

  // --- アニメーションループ ---
  const clock = new THREE.Clock();
  const run = (update) => {
    renderer.setAnimationLoop(() => {
      controls.update();
      update?.(clock.getElapsedTime()); // 経過秒を渡す
      renderer.render(scene, camera);
    });
  };

  return { scene, camera, renderer, controls, run };
}

没有一个新故事。 我只是把我在 A1 到 A3 里做的事情总结成函数,添加了调整大小支持和 OrbitControls

这意味着只有在该会话中放置的物品。main.js

import * as THREE from "three";
import { createWorld } from "./base.js";

const { scene, run } = createWorld();

// ここから下が、その回のテーマ
const cube = new THREE.Mesh(
  new THREE.BoxGeometry(),
  new THREE.MeshStandardMaterial({ color: 0x4a90d9, roughness: 0.3, metalness: 0.6 })
);
cube.castShadow = true;
scene.add(cube);

run((time) => {
  cube.rotation.y = time * 0.5;
});

这次,我们唯一要做的就是用装载过的模型替换这个零件cube

你可以把GLTF看作是“3D世界的jpg”。

3D模型有无数种文件格式,但如果你在网上使用,GLTF是唯一的选择.obj.fbx.stl

该格式由Khronos Group(与WebGL同属一个组织)开发,专为网页分发设计,也是阅读最直接Three.js的方式。 而且需要携带的信息量非常大。

GLTF能带来的信息目录
网格形状(几何)
材料颜色、粗糙度、金属度(A3的PBR值已包含
纹理附图
场景结构亲子关系。 手臂悬挂在身体上,诸如此类。
动画骨骼运动、位置和旋转的变化
摄像灯由制作软件设置的设置

换句话说,“你可以把整个视觉环境融合在一起。” 多亏了这个,加载时看起来就是。

扩展有两种类型,我就简单地整理一下。

形式目录使用点
.gltfJSON 主文件 + 用于图片和二进制文件的独立文件当你想看看里面是什么,或者只换掉纹理时
.glb一个将所有内容合并到单一文件的二进制文件基本上,就是这个。 加载请求只需一次

如果你不确定。 由于文件分布较少,路径错误也更少。.glb

装载GLTFLoader

模型会被放置到(在Vite中,内容直接送到路线,你可以在那里查阅)。public/public//robot.glb

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

const loader = new GLTFLoader();

loader.load(
  "/robot.glb",
  (gltf) => {
    scene.add(gltf.scene); // 読み込み完了
  },
  (e) => console.log(`${(e.loaded / e.total * 100).toFixed(0)}% 読み込み中`),
  (err) => console.error("読み込み失敗", err)
);

有两个关键点。

  1. GLTFLoader 不是主要的三个,但可以参考示例/JSM...... Three.js的附加功能已包含在此。 还是同一个地方OrbitControls
  2. 加载是异步的...... 他们会立即回来,完成后会再回电。 因此,“对加载模型做某件事”的过程总是写在第二个参数中load()

回调中有一个对象,用于总结文件内容。gltf

财产目录
gltf.scene模型车身(集团)。 我几乎只用这个
gltf.animationsAnimationClip 布局。 有动漫,就有内容。
gltf.cameras制作软件设置的摄像机
gltf.asset如导出的软件名称和版本等信息

顺便说一句,这不是Mesh,而是Group。 可以把它想象成一个“容器”,里面悬挂着多个网格。 这一事实后来会在暗处发挥作用。gltf.scene

“加载但什么也看不到”的三个主要原因

也许一开始你会被困在这里。 我也被困住了。 就是没有错误提示,但屏幕完全是黑色的。

主要有三个主要原因。

(1)完全不同尺度的尺寸

3D模型的单元因软件和创作者而异,有的像巨大的墙壁一样矗立在摄像机前,有的则太小看不见

治疗时建议不要去动固定镜头,而是先测量后再调整scale

const box = new THREE.Box3().setFromObject(model); // モデルを囲む箱を測る
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());

const scale = 1.6 / size.y;        // 高さを1.6くらいに揃える
model.scale.setScalar(scale);

Box3是一种计算“包围物体最小方格”的工具。 只需除以高度(),任何模型的尺寸都会匹配。 每次更换型号时,你都不用再去找号码,所以值得记住这一点。size.y

(2)起源并非你想象的那样

模型的起点(0,0,0)是在身体的脚部还是中心,取决于创作者。 如果你把一个模型以中心作为原点放置,它会沉入地板。y = 0

这也可以通过我之前测量的那个来解决。box

model.position.x = -center.x * scale;
model.position.z = -center.z * scale;
model.position.y = -1 - box.min.y * scale; // 床(y=-1)に足を着ける

box.min.y它位于模型的最底部,所以你应该与地板高度匹配。

(3)纯粹黑暗

这是上次的最后一次召回。 GLTF材料基本上相当厚重,没有光线时会变成漆黑一片。MeshStandardMaterial

base.js如果你用它,灯光和环境贴图都包含在内,所以没问题,但如果你创建了一个场景,模型最终呈现出黑色剪影,首先要怀疑光照。

阴影用于覆盖所有网格。traverse

前面提到的“哈集团”在这里发挥了作用。gltf.scene

model.castShadow = true; // ❌ これでは影は出ない

即使你设置了一个组,实际绘制还是由内层网格完成的,所以什么都不会发生。 你需要描摹所有内容,一个一个地竖起来。castShadow

model.traverse((obj) => {
  if (obj.isMesh) {
    obj.castShadow = true;
    obj.receiveShadow = true;
  }
});

traverse()是一种让你和你的后代能够将它传递给所有人的方法。 由于3D模型是由躯干、头部、手臂等网格组成的集合,这就是建模时的基本形式。

顺便说一句,你也可以用它来批量更换材料。 你可以通过把所有导入的模型转换成线框图来调试结构,这很方便记住。traverse

播放动画

如果模型有动画,单纯加载动画是无法实现的。 你需要把它连接到播放设备()。AnimationMixer

let mixer = null;

loader.load("/robot.glb", (gltf) => {
  const model = gltf.scene;
  scene.add(model);

  if (gltf.animations.length > 0) {
    mixer = new THREE.AnimationMixer(model);
    mixer.clipAction(gltf.animations[0]).play(); // 最初のクリップを再生
  }
});

事情就是这样运作的。

  • 动画剪辑 ... 动作单位如“走路”或“挥手”。 排列成数组。gltf.animations
  • 动画混合器 ... 专门用于该型号的播放设备。 每个模型做一个
  • 动画动作...... 用它制作的播放控制器。 ......还有其他办法。clipAction()play()stop()fadeIn()

除非你对每一帧调用 mixer.update(),否则时间不会进展。 这里有一个重要的点需要注意。

let prev = 0;

run((time) => {
  const delta = time - prev; // 前フレームからの差を出す
  prev = time;

  mixer?.update(delta);
});

mixer.update()我们想要的是差额(delta),而不是总经过的时间。 设计目的是通过“从起点开始计时秒”,所以你可以像上面所示自己做出决定。base.jsrun()

如果你不小心把总值传过了,动画会瞬间快进结束(我一开始就是这么做的)。

压缩较重模型

3D模型的大小可以轻易达到几十MB不等。 即使是本地的瞬间,在移动网络上也可能致命。

减轻重量有两种方法,这两种方式都是GLTF的标准扩展。

压缩压缩什么添加装载侧
德拉科形状(网格)DRACOLoader
KTX2 / Basis纹理图像KTX2Loader

除非解码器连接,否则无法读取Draco压缩模型。 话虽如此,新增内容几乎只有两行。

import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";

const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("https://www.gstatic.com/draco/versioned/decoders/1.5.7/");

const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);

压缩本身是出口端的工作。 在Blender的导出设置里检查Draco,或者通过像gltf-transform这样的CLI操作很容易。 几MB长到几百KB的情况很常见,所以在发布前玩一遍会让人安心。