本篇:第 1 篇 · 走进 3D | 配套示例:
examples/ch02/index.html
本章目标
- 能说出 scene、camera、renderer 各自负责什么,并解释
renderer.render(scene, camera)为什么必须同时带两个参数。 - 记住 three.js 的空间约定:右手坐标系、y 轴朝上,能用
AxesHelper读懂一个场景里"东西在哪、朝哪放"。 - 不看参考能默写出三要素样板代码(含
setSize和setPixelRatio两行容易被漏掉的设置)。
上一章你"看见"了画面但没有"看懂"代码。这一章把examples/ch01那 20 行拆成零件,同时建立 three.js 世界里最重要的一块地基——坐标系。后面第 2 篇的数学基础,全部从它出发。
2.1 一个比喻记住三要素
把渲染一帧画面想象成拍一张照片:
| 代码里的对象 | 片场里它是 | 职责边界 |
|---|---|---|
THREE.Scene | 拍摄现场(地块) | 一个容器:所有要被拍到的东西都必须摆进这个现场 |
THREE.Mesh | 现场里的道具 | 一个具体物体 = 形状(geometry)+ 外观配方(material) |
THREE.PerspectiveCamera | 摄影机 | 决定从哪看、朝哪看、看多宽,它不渲染任何东西 |
THREE.WebGLRenderer | 摄影师 + 冲洗设备 | 把"现场 + 机位"算成一张图像,写到<canvas>上 |
注意"职责边界"这栏的三个措辞,它们是后面很多 bug 的诊断入口:物体不add进 scene 就等于没摆进片场——摄影师拍不到;camera 只管取景不管合成;renderer 是唯一的"输出端",一个 Scene 可以喂给多个 renderer(比如做画中画、缩略图),一个 camera 也可以在不同时刻拍不同 scene。
2.2 坐标系:这个世界的门牌号
3D 场景里一切位置都是"门牌号",必须先约定怎么读门牌。three.js 的约定是右手坐标系,y 轴朝上,默认单位为"1 = 1 米"(单位不是强制的,是社区惯例,见 Ch1 实验 1)。
y(上) │ │ │ │________ x(右) ╱ ╱ z(朝向屏幕外的你)用右手判定:四指从 x 轴弯向 y 轴,拇指指向的就是 z 轴。three.js 里**+z 指向屏幕外、朝向观众**,摄像机默认放在 +z 一侧往原点看。这一点与许多软件不同,换工具时要心里有数:
| 软件/标准 | 上方向 | 备注 |
|---|---|---|
| three.js / OpenGL | y 朝上,-z 为相机视线方向 | 本书世界 |
| Blender / 游戏引擎常见 | z 朝上 | 导出模型进 three.js 要做轴向转换(第 19 章) |
| 网页 CSS / 图像坐标 | y 朝下 | 所以贴图 UV 的 v 轴方向经常"反"(第 8 章) |
怎么亲眼确认?用AxesHelper——在场景里放一个,画面角上会出现红绿蓝三根短线,红=x,绿=y,蓝=z(RGB 顺序对应 XYZ)。配套的还有GridHelper(地面网格)和CameraHelper(把相机视锥画出来)。它们只用于开发调试,正式发布前删掉。本章示例里这三个都有,下一章开始我们就不用了——但任何时候画面不对,先摆一个 AxesHelper 看方向,这是 3D 开发的第一诊断动作。
2.3 逐行拆解:那 20 行代码
按执行顺序,分四组。每组末尾有"现在只需/第 N 章深讲"的标注——这是本书对复杂度的显式管理。
组一:现场
constscene=newTHREE.Scene();Scene 就是一个容器对象,本身不可见、不产生任何像素。它还顺手管着两样全局设定:scene.background(背景,可赋颜色或贴图)和scene.fog(雾效,让远处物体逐渐融进背景色)。本章示例用到了这两个。——fog 与背景贴图第 15 章见。
组二:机位(一个相机需要的全部:位置 + 朝向)
constcamera=newTHREE.PerspectiveCamera(45,innerWidth/innerHeight,0.1,100);camera.position.set(3,2,4);camera.lookAt(0,0,0);PerspectiveCamera构造函数的四个参数,逐个对应"摄影机怎么架":
- fov(视场角)= 45:镜头纵向张开多少度。越小越"望远"(画面被拉近、空间感压缩),越大越"广角"(夸张的近大远小、鱼眼边缘畸变)。人眼舒适区间大约 45~75。
- aspect(宽高比)= 内宽/内高:画面宽高比必须等于 canvas 的实际宽高比,否则圆球渲染成椭圆——这是常见坑 2 的前半段,窗口变化时还要同步更新(示例里做了
resize监听)。 - near(近裁剪面)= 0.1:比这更近的物体不画。它必须大于 0,且相机离物体比 near 还近时物体会消失(常见坑 1 的另一半)。
- far(远裁剪面)= 100:比这更远的不画。near 与 far 之间围成一个梯形"取景空间",叫视锥(frustum)——流水线第 ① 站之后、第 ② 站剔除时,GPU 真的在拿视锥逐个三角形做检查。
position.set(3, 2, 4)与lookAt(0, 0, 0):架机器要拧两颗螺丝——摆在哪(位置)、镜头朝哪(朝向)。相机和所有物体一样生活在 2.2 节的坐标系里。少了lookAt,相机默认只朝着自己的 -z 方向,画面十有八九是空的。
为什么透视相机参数这么重要?因为 fov/near/far 三个数最终会被做成一个 4×4 投影矩阵,第 11 章你把它完整推导出来,现在先把参数含义用熟。
组三:道具
constcube=newTHREE.Mesh(newTHREE.BoxGeometry(1,1,1),newTHREE.MeshNormalMaterial());scene.add(cube);Mesh= 网格 = 形状 + 外观,两个零件各管一半:BoxGeometry是"一个 1×1×1 米盒子的三角形清单",MeshNormalMaterial是"用面朝向涂色的配方"。new完不scene.add等于道具造好了没搬进片场——画面空白,新手第一大坑(第 7、8 章把 geometry 和 material 拆开深讲)。
组四:摄影师
constrenderer=newTHREE.WebGLRenderer({canvas:document.getElementById('stage'),antialias:true});renderer.setSize(innerWidth,innerHeight);renderer.setPixelRatio(Math.min(devicePixelRatio,2));renderer.setClearColor('#11151c');- 构造参数里的
canvas:告诉 renderer 往哪个<canvas>元素上画。不传它会自己造一个 canvas 塞进 body,布局会乱。 setSize:设置 canvas 的绘制尺寸(回忆 Ch1 坑 3:尺寸只听它的)。setPixelRatio:高分屏(Retina、现代手机)一个 CSS 像素对应 2~3 个物理像素,不设会糊;用Math.min(devicePixelRatio, 2)封顶是行业惯例——再高的像素比带来的清晰度收益抵不上 GPU 开销(第 20 章算这笔账)。antialias: true:打开抗锯齿,三角形边缘不再呈台阶状。
快门
renderer.render(scene,camera);一条命令把流水线走一遍:从 camera 的视点,把 scene 算成 canvas 上的一张图。必须同时有两个参数——现场和机位缺一不可:同一个 scene 换机位就是另一张照片。上一章setAnimationLoop里每帧执行一次它,就是每秒按 60 次快门。
2.4 最小可运行示例:把坐标系摆进画面
examples/ch02/index.html在本章知识点上做了三件事:地面网格、坐标轴、三块颜色不同且摆在坐标正方向上的板子,每个对象一眼对应一条轴:
scene.add(newTHREE.AxesHelper(5));// 红x 绿y 蓝z,长5米constgrid=newTHREE.GridHelper(10,10);// 10米见方、10格的xOz地面网格scene.add(grid);constplane=(color)=>newTHREE.Mesh(newTHREE.PlaneGeometry(1,1),newTHREE.MeshBasicMaterial({color,side:THREE.DoubleSide}));constredOnX=plane('red');redOnX.position.set(1.5,0.5,0);constgreenOnY=plane('lime');greenOnY.position.set(0,1.5,0);greenOnY.rotation.y=Math.PI/2;constblueOnZ=plane('dodgerblue');blueOnZ.position.set(0,0.5,1.5);scene.add(redOnX,greenOnY,blueOnZ);运行它,对着画面做一件事:说出绿色板子为什么被rotation.y = π/2转了一下。答案:PlaneGeometry默认朝 +z(它的法线是 z 轴),不转的话我们从 -z 一侧的相机看它是一条边;绕 y 轴转 90° 后朝向 x 轴方向,才能被看到正面。——这就是 2.2 节坐标系 + "面有朝向"的第一次配合。DoubleSide是双保险(正面背面都画,第 8 章再谈要不要用)。
2.5 改造实验
- 删掉
scene.add(cube)(本章示例则删greenOnY):那块东西消失,其余照旧。确认"容器决定画什么"。 camera.position.set(0, 0.5, 0.05)保持lookAt(0,0,0):几乎怼在原点。结果一片空——相机进入了 near 裁剪面以内,所有物体都"太近"被裁掉了。这是坑 2 的实体化。fov改成 100:退后一点再对比,感受广角的边缘拉伸;改成 15,远处的东西"黏"到近处物体上(空间压缩)。这个手感在第 11 章推导完投影矩阵后会有理论解释。- 把浏览器窗口从宽拉成窄,观察画面:示例里
resize监听器会同步camera.aspect。把监听器删掉再试:窗口变化时圆变椭圆、板子变形——aspect 错了相机不知道,它只会用上一次构造时的比例画。
2.6 常见坑
坑 1:新new的物体没add进 scene。画面空白或"少了某样东西",第一反应永远是检查 add。
坑 2:相机太靠近物体,或 near 设得太大。现象是"看进去全是黑/背景色"。相机不是无限穿透的:它有一个贴脸的最近取景距离 near。排查思路:把 near 改到 0.01 试试,或把相机退远。
坑 3:窗口尺寸变了只改setSize、不改camera.aspect。画面拉伸、比例失真。正确样板(已在 ch02 示例内,抄走即用):
window.addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();// 必须调,参数改完不会自动生效renderer.setSize(innerWidth,innerHeight);});坑 4:误以为 camera 是"玩家的视角"。camera 只是取景框,与"你在场景里走到哪"无关——移动相机 ≠ 移动角色。第一人称、第三人称是"谁跟着谁"的关系(第 23 章游戏实战展开)。
2.7 练习
- 默写题:不看书写出三要素初始化 + 一次
render的完整代码(一个红立方体、相机在 (2,2,5) 看原点、背景深灰)。写完再对照——重点检查setSize那行和scene.add有没有漏。 - 预测题:
camera.position.set(0, 0, 10),lookAt(0, 0, 0),一个位于 (3, 2, 4) 的 1×1 立方体会出现在画面哪个方位?为什么?(提示:相机在 +z 看原点,相机自己的 x 轴、y 轴各朝哪?) - 动手题:给示例加上
scene.fog = new THREE.Fog('#11151c', 10, 50),把相机挪到(20, 10, 20),观察远处板子发生了什么;再挪回来。说出 fog 的两个数字参数分别是什么距离。
参考答案
- 略——自查点:
PerspectiveCamera四参数、Mesh两参数、scene.add(mesh)、renderer.setSize。 - 画面偏右上。相机在 (0,0,10) 看向原点时视线方向是 -z,此时它的右方恰好是世界 +x、上方是 +y(对照 ch01 示例的默认机位即可印证)。所以 x=3 出现在画面右侧,y=2 出现在上方,z=4 让它离相机更近一些、显得更大。这道题的要点:相机在 +z 一侧朝 -z 看时,"屏幕左右"和"世界 x"并不反号;只有当你把相机移到 z 轴负侧往回看时,左右才会互换——画面摆位时先想清楚相机视线方向,根源就在这一层。
- fog 两个参数是开始距离 10、完全遮蔽距离 50:距离相机 10 米内不受雾影响,50 米外融成背景色。相机拉远后板子逐渐消失,是因为它们相对相机距离进了雾区间——fog 的参照是相机距离,不是世界坐标。
本章小结
- 三要素分工:scene 是容器(不被 add 就不存在),camera 是取景框(位置+朝向两颗螺丝),renderer 是唯一输出端(尺寸只归
setSize/setPixelRatio管)。 render(scene, camera)每次调用 = 整条图形管线走一遍;动画就是反复按快门。- 世界是右手坐标系、y 朝上;
AxesHelper(红x绿y蓝z)是第一诊断工具。 PlaneGeometry默认朝 +z,物体"看不见"很多时候是朝向问题——朝向由面的法线决定,法线是向量,向量是下一章的主角。
从 Ch3 起进入第 2 篇数学基础。第一章就说过,这些数学不是选读:本章你已经两次碰到它的影子(朝向/法线、相机参数背后的投影矩阵)。下一站:向量。
本章完整源码
本章示例的全部源码都在下面,逐字复制即可重建,不需要另外下载。每个代码块都是examples/里对应文件的原样副本(由脚本生成并逐字节对账),所以你抄下来的就是作者这边跑通过的那一份。
把下面这个文件逐字存成examples/ch02/index.html,然后在examples/的上一级目录里起一个静态服务器:
python-mhttp.server8000再访问http://127.0.0.1:8000/examples/ch02/。three 由 importmap 从 unpkg 取,要联网;版本号保持 0.186.1,换版本先回看 Ch1 的版本纪律。
文件清单
ch02/index.html (76 行)ch02/index.html
<!DOCTYPEhtml><htmllang="zh-CN"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>Ch2 · 三要素与坐标系</title><style>html, body{margin:0;height:100%;overflow:hidden;background:#11151c;}#stage{display:block;}</style><scripttype="importmap">{"imports":{"three":"https://unpkg.com/three@0.186.1/build/three.module.js","three/addons/":"https://unpkg.com/three@0.186.1/examples/jsm/"}}</script></head><body><canvasid="stage"></canvas><scripttype="module">import*asTHREEfrom'three';// ── 组一:现场 ──constscene=newTHREE.Scene();scene.background=newTHREE.Color('#11151c');// ── 组二:机位 ──constcamera=newTHREE.PerspectiveCamera(45,innerWidth/innerHeight,0.1,100);camera.position.set(4,3,6);camera.lookAt(0,1,0);// ── 组三:道具 ──scene.add(newTHREE.AxesHelper(5));// 红=x 绿=y 蓝=z,长 5 米constgrid=newTHREE.GridHelper(10,10);// xOz 地面网格:10 米见方、10 格scene.add(grid);constplane=(color)=>newTHREE.Mesh(newTHREE.PlaneGeometry(1,1),newTHREE.MeshBasicMaterial({color,side:THREE.DoubleSide}));constredOnX=plane('red');redOnX.position.set(1.5,0.5,0);constgreenOnY=plane('lime');greenOnY.position.set(0,1.5,0);greenOnY.rotation.y=Math.PI/2;constblueOnZ=plane('dodgerblue');blueOnZ.position.set(0,0.5,1.5);scene.add(redOnX,greenOnY,blueOnZ);// ── 组四:摄影师 ──constrenderer=newTHREE.WebGLRenderer({canvas:document.getElementById('stage'),antialias:true,});renderer.setSize(innerWidth,innerHeight);renderer.setPixelRatio(Math.min(devicePixelRatio,2));// 坑 3 的正确样板:aspect 与尺寸同步window.addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);});renderer.setAnimationLoop(()=>{renderer.render(scene,camera);});</script></body></html>