这是一个会话,你导入在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值已包含) |
| 纹理 | 附图 |
| 场景结构 | 亲子关系。 手臂悬挂在身体上,诸如此类。 |
| 动画 | 骨骼运动、位置和旋转的变化 |
| 摄像灯 | 由制作软件设置的设置 |
换句话说,“你可以把整个视觉环境融合在一起。” 多亏了这个,加载时看起来就是。
扩展有两种类型,我就简单地整理一下。
| 形式 | 目录 | 使用点 |
|---|---|---|
.gltf | JSON 主文件 + 用于图片和二进制文件的独立文件 | 当你想看看里面是什么,或者只换掉纹理时 |
.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)
);有两个关键点。
GLTFLoader不是主要的三个,但可以参考示例/JSM...... Three.js的附加功能已包含在此。 还是同一个地方OrbitControls- 加载是异步的...... 他们会立即回来,完成后会再回电。 因此,“对加载模型做某件事”的过程总是写在第二个参数中
load()
回调中有一个对象,用于总结文件内容。gltf
| 财产 | 目录 |
|---|---|
gltf.scene | 模型车身(集团)。 我几乎只用这个 |
gltf.animations | AnimationClip 布局。 有动漫,就有内容。 |
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的情况很常见,所以在发布前玩一遍会让人安心。