Day 99 · 场景基础 — Scene / Camera / Renderer:第一个 3D 物体
Three.js 的第一课,也是 3D 世界里最朴素的真理:没有相机,世界存在但你看不见;没有光照,世界存在但你看不清。今天搭起最小组件——场景(容器)、相机(眼睛)、渲染器(画布)、一个立方体(物体)——然后彻底理解三件事:坐标系约定、为什么"物体不见了"是最常见的坑、以及你与 2D 世界的第一个对照(canvas 上下文 vs 渲染器)。
目录
- 一、工程准备与最小场景
- 二、三件套职责:世界、眼睛、画布
- 三、坐标系:3D 的第一门语言
- 四、为什么"物体不见了"(三步排查法)
- 五、放进 Vue:第一个 3D 组件
- 六、常见坑点
- 七、自测挑战
- 八、总结
一、工程准备与最小场景
# 在大屏工程里加 3D 能力(或建孪生子工程,见 Week 16 决策)
npm install three
npm install -D @types/three # TS 类型
最小场景(不依赖 Vue,先看纯 Three.js 逻辑):
// src/core/three/mini-scene.ts(纯逻辑:不 import vue——Day 85 的 core/ 纪律)
import * as THREE from "three";
/**
* 搭建最小 3D 场景:世界(Scene)+ 眼睛(PerspectiveCamera)+ 画布(WebGLRenderer)
* 三件套缺一不可,职责:
* Scene —— 容器:装物体、灯光、相机
* Camera —— 观察者:决定"从哪看、看多广"
* Renderer —— 绘制者:把场景按相机视角画到 canvas
*/
export function createMiniScene(canvas: HTMLCanvasElement): {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
/** 一次性静态渲染(Day 100 换成持续渲染循环) */
renderOnce(): void;
} {
// 1. 世界
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d1421); // 大屏底色(Day 66 token 的 3D 版)
// 2. 眼睛:透视相机
// 参数:视野 75° / 宽高比(自适应) / 近裁面 0.1 / 远裁面 1000
const camera = new THREE.PerspectiveCamera(
75,
canvas.clientWidth / canvas.clientHeight,
0.1,
1000
);
camera.position.set(3, 2, 5); // 站在 (3,2,5),看向原点(默认 -Z 方向)
camera.lookAt(0, 0, 0);
// 3. 画布:WebGL 渲染器
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
// 4. 物体:立方体(几何 + 材质 = 网格 Mesh)
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00c6ff }); // 主色 token
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// 5. 光照:没有灯,Standard 材质是黑的(第三节/第四节)
// ——先加一盏环境光,让物体"能看见":
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
return {
scene,
camera,
renderer,
renderOnce() {
renderer.render(scene, camera); // 核心一行:把世界画出来
},
};
}
🎯 核心一行:
renderer.render(scene, camera)。3D 的一切最终都收敛到这一行——场景里有什么、相机怎么看、就被画成什么。今天它只调一次(静态图),Day 100 把它放进 rAF 循环。
二、三件套职责:世界、眼睛、画布
| 组件 | 2D 对应物 | 职责 | 关键属性 |
|---|---|---|---|
Scene |
一个"世界容器" | 装所有物体/灯光/相机 | background、add() |
PerspectiveCamera |
你的眼睛 | 决定视角:视野、远近裁剪、位置朝向 | fov/aspect/near/far、position、lookAt |
WebGLRenderer |
canvas 2D 上下文 | 把世界投影成画面 | setSize、render(scene, camera) |
相机的三个经典问题(本周必答)
Q1 为什么有"近裁面/远裁面"?
GPU 只绘制 near~far 之间的物体——省算力,也是"物体突然消失"的坑源之一
Q2 lookAt 是干嘛的?
相机默认朝 -Z 看;lookAt 让它对准某个点——没调用时可能"看向虚空"
Q3 相机在物体内部?
物体会被裁掉一部分(近裁面原理)——"只看得到一片黑/一片面"的坑源
三、坐标系:3D 的第一门语言
Three.js 用右手坐标系:
+Y(上)
|
|___ +X(右)
/
+Z(朝你/屏幕外)
约定:
- 默认相机朝 -Z 方向看(看向屏幕里)
- 地平面通常放 y=0,物体往上长
- 旋转:正方向 = 从轴正端看向原点的逆时针
与 2D 的对照:Canvas 2D 的 y 轴向下,Three.js 的 y 轴向上——第一天就会踩的"上下颠倒"坑。记忆钩子:3D 世界"站起来"了,y 从"往下"变成"往上"。
四、为什么"物体不见了"(三步排查法)
3D 新手 90% 的问题都是"黑屏/白屏/物体消失"。固定排查顺序(今天建立,未来全靠它):
第 1 步:相机看得见吗?
- position 是否在物体附近?(别在物体内部)
- lookAt 是否对准了物体?
- near/far 是否包含物体距离?(物体离相机 > far 或 < near 都会被裁)
第 2 步:光照与材质匹配吗?
- MeshBasicMaterial 不需要光(自发光感)
- MeshStandardMaterial/Phong 需要光——没灯 = 全黑
- 检查是否有 AmbientLight 兜底
第 3 步:渲染器画了吗?
- render(scene, camera) 被调了吗?(Day 100 的循环问题)
- canvas 尺寸是否为 0?(容器未布局/display:none——Day 76 同款坑)
把这三步背下来——它是你未来 1000 次的排错起点,也是面试"3D 黑屏怎么排查"的标准答案骨架。
五、放进 Vue:第一个 3D 组件
<!-- src/components/ThreeViewport.vue(最小 3D 容器,Day 103 全面边界化) -->
<script setup lang="ts">
/**
* 3D 视口组件:只负责"把 Three.js 场景挂进 Vue 世界"
* 今天的简化版——renderer/scene 实例普通变量持有(Day 87 markRaw 纪律的预演)
*/
import { onMounted, onUnmounted, ref } from "vue";
import { createMiniScene } from "../core/three/mini-scene";
const canvas = ref<HTMLCanvasElement>(); // 模板引用:canvas 挂载点
let dispose: (() => void) | null = null; // 清理函数(成对纪律)
onMounted(() => {
const mini = createMiniScene(canvas.value!);
mini.renderOnce(); // 静态渲染(Day 100 升级为循环)
// 清理协议:成对纪律(Day 69/82 的 3D 版)
// ——渲染器 dispose 释放 GPU 上下文,否则内存/显存泄漏
dispose = () => {
mini.renderer.dispose();
};
});
onUnmounted(() => dispose?.());
</script>
<template>
<canvas ref="canvas" class="three-viewport" />
</template>
<style scoped>
.three-viewport { width: 100%; height: 100%; display: block; }
</style>
为什么要手动 dispose:WebGLRenderer 占用 GPU 资源,组件卸载不释放会泄漏显存——这是 3D 世界的"Day 69 泄漏排查"提前预习(Day 105 会系统测)。
六、常见坑点
坑 1:canvas 尺寸为 0(渲染器白屏)
容器在 display:none 或未布局时创建渲染器 → setSize(0,0)。修法:setSize 前确认 clientWidth > 0,或用 ResizeObserver(Day 76/86 的同款协议,Day 105 统一)。
坑 2:材质用 MeshStandardMaterial 但没加光
全黑是"没灯"最典型的症状(不是 bug)。新手最容易漏 AmbientLight。口诀:Standard 材质 = 需要光。
坑 3:相机在物体内部
camera.position.set(0,0,0) + 立方体在原点 → 相机在立方体内部 → 只看到内表面/一片面。排查第 1 步的第一问。
坑 4:忘调 render()
最基础的"白屏":场景建好了、相机摆好了,就是没画。今天只调一次看不出循环问题——Day 100 会放大它。
坑 5:忘清理(提前踩)
组件卸载不 dispose → 多次进出页面显存涨。今天的 dispose 回调用上,Day 105 用 Performance 验证。
七、自测挑战
T1 · 最小场景(60 分钟)
完成第一至五节:一个青色立方体出现在深色背景中央,颜色与 Day 66 token 一致。调整相机位置(左右/远近)观察视角变化。
T2 · 三步排查演练(30 分钟)
故意制造三个故障并记录现象:① 删掉 AmbientLight(全黑)② 相机移到 (0,0,0)(看内面)③ 注释 renderOnce()(白屏)。亲手错三次,排查法才长在身上。
T3 · 坐标系实验(20 分钟)
把立方体放到 (2,0,0)(右)、(0,2,0)(上)、(0,0,2)(朝屏幕外)三个位置,截图记录每个位置在画面中的去向——建立"代码坐标 → 画面位置"的直觉。
八、总结
| 环节 | 要点 |
|---|---|
| 三件套 | Scene 世界 / Camera 眼睛 / Renderer 画布——缺一不可 |
| 核心一行 | renderer.render(scene, camera) |
| 坐标系 | 右手系,y 向上——与 2D 的 y 向下对照 |
| 三步排查 | 相机看得见吗 → 光照材质匹配吗 → 渲染器画了吗 |
| Vue 挂载 | 普通变量持有 + dispose 成对——Day 87 纪律预演 |
世界搭好了,立方体站在那里。明天让它动起来:渲染循环与动画纪律。