
本篇第 2 篇 · 3D 数学基础 配套示例examples/ch04/index.html本章目标能画出并讲清变换链局部 → 世界 → 观察 → 裁剪 → NDC → 屏幕六段缺一不算走通并说出每一段由谁决定、何时发生。会手算前三段世界 父级变换复合、观察 相机逆变换会读会写localToWorld / worldToLocal / project三个关键 API。建立全书最重要的心智模型渲染 一串矩阵乘法。下一章我们把这些矩阵本身拆开看。Ch1 的流水线里第①站顶点着色器把 3D 坐标变成屏幕 2D 坐标被一笔带过。这一章把那一步展开——它是 three.js 里最有条理的一件事一个顶点从模型自己的坐标走到屏幕像素一路上要领六张身份证局部、世界、观察、裁剪、NDC、屏幕。4.1 为什么不能只用一个坐标系设想你要摆一个机器人场景手臂上有螺丝、螺丝上有刻字。如果螺丝的位置用世界坐标记录机器人抬一下手臂你就得重算螺丝和每个字的坐标——荒谬。正确做法每个零件只在自己的局部坐标系里定义一次装配关系用父到子的变换记录。动一个零件只有它和它的子孙需要重算。这就是 3D 引擎的通用架构局部坐标local space也叫物体空间/model space是出厂图纸上的尺寸世界坐标world space是装配完在厂房里的实际位置。three.js 用**场景图scene graph**管理这套父子关系Ch10 专章细讲本章先把空间与空间怎么换算这条主线拿到手。而相机又引入第三个视角取景时 GPU 不关心世界怎样只关心从相机看你在哪。于是又有了观察空间view space。最后还要投影进裁剪空间、除 w 得 NDC、再映射到像素——六段旅程由此凑齐。4.2 变换链总览局部坐标 p 世界坐标 观察坐标 裁剪坐标 NDC 屏幕像素 ┌───────────┐ M_model ┌───────────┐ M_view ┌───────────┐ M_proj ┌──────────┐ ÷w ┌────────┐ viewport ┌────────┐ │ geometry │ ────────▶ │ 场景里真实 │ ───────▶ │ 相机为原点 │ ───────▶ │ 挤进立方体 │──▶ │ [-1,1]³ │ ───────▶ │ (px,py) │ │ 顶点属性 │ (场景图链) │ 位置 │ (相机逆) │ 朝-z看 │ (视锥体) │ 带深度 │ │ 立方体 │ (画布换算)│ │ └───────────┘ └───────────┘ └───────────┘ └──────────┘ └────────┘ └────────┘ Ch7 geometry Ch4/5 世界段 Ch4 观察段 Ch11 推导投影 Ch4 视口段对应回 Ch1 流水线M_model 与 M_view 连乘发生在顶点着色器内部three.js 把modelViewMatrix作为 uniform 传进去M_proj 是顶点着色器输出的那一乘“÷w”透视除法和视口映射由 GPU 固定功能完成。你写的代码只负责摆物体和架相机这六段全由 three.js 组装——但调试时你必须知道每一段在干什么本章就是给这件事建立的。本章示例让你亲眼见证整条链屏幕上的十字准星实时钉住一个 3D 小球——准星的像素位置就是用下面六段链手工算出来的与 three.js 渲染结果分毫不差。4.3 第一段局部 → 世界场景图链一个物体的matrixWorld等于从根到它一路的局部变换矩阵连乘M_world M_parent × M_parent.parent × … × M_self注意顺序从右往左读先作用M_self矩阵乘法不可交换顺序问题 Ch5 正面处理手算例题本章示例的实际参数父级 group 在(1, 0, -1)、绕 y 轴转了 90°子物体在 group 内局部位置(1.2, 0.8, 0)。绕 y 轴转 90° 的规则(x, y, z) → (z, y, -x)Ch5 会给通式。于是局部 (1.2, 0.8, 0) ──旋转──▶ (0, 0.8, -1.2) ──平移──▶ (1, 0.8, -2.2) ← 世界坐标记住这个两步结构先旋转后平移局部旋转是自身图纸的转法平移是搬到厂房位置。three.js 的position/rotation/scale三元组在内部合成的就是这个 4×4。API 一侧读世界坐标的正确姿势scene.updateMatrixWorld();// 手动把整棵树的 matrixWorld 算新见坑 1constworldmarker.getWorldPosition(newTHREE.Vector3());constlocalAgainmarker.worldToLocal(world.clone());// 世界→局部用 matrixWorld 的逆localToWorld(v)要求传入局部坐标、输出世界坐标。注意这些方法都是原地修改带clone()的纪律保持住Ch3 坑 3。4.4 第二段世界 → 观察把世界搬到相机脚下观察空间的定义粗暴但好用让相机回到原点、朝自己的 -z 看、y 轴朝上整个场景跟着刚性移动。数学上就一个矩阵相机世界矩阵的逆——three.js 里叫camera.matrixWorldInverse渲染前自动算好。手算例题最简单的纯平移情形相机在(0, 0, 10)且朝向默认-z。世界点(3, 2, 4)的观察坐标 世界坐标 − 相机位置 (3, 2, -6)。z 为负在相机前方6 米相机视线方向是 -z前方的点观察 z 都是负的——Ch2 说过这个约定。若相机还转了角度就要用完整的matrixWorldInverse思路不变相机怎么来的世界就怎么倒回去。直观理解观察坐标回答的不是你在世界哪儿而是你相对我的眼睛在哪。所有与视点有关的计算投影、雾、剔除都在这个空间里进行。4.5 第三段观察 → 裁剪 → NDC透视除法这一段是把视锥塞进立方体。概念三步投影矩阵把相机视锥那个梯形取景空间里的所有点映射进一个立方体规范观察体积。透视相机的映射带着近大远小M_proj会把点按深度推远——离相机越远的 x/y 分量被压得越靠近中心。映射结果不是直接立方体坐标而是四维齐次坐标 (x, y, z, w)——这就是裁剪空间clip space名字来源GPU 在这一层做裁剪出了立方体的三角形在这里被切或丢。最后做透视除法(x, y, z, w) → (x/w, y/w, z/w)得到 NDC归一化设备坐标——范围[-1, 1]³的标准立方体深度也归一化进去three.js/WebGL 里 z 在近处映射 -1、远处 1。近大远小的全部魔法就藏在那次除以 w 里Ch11 逐字母推导本章先接受结论。three.js 给你一个一步到位的 APIvector.project(camera)——世界→观察→裁剪→÷w→NDC 全包。示例用它做反向验证见 4.7。4.6 第四段NDC → 屏幕像素视口映射NDC 立方体的 x-1 对应画布左边缘、1 对应右边缘注意 y 反号约定NDC 的 1 是画布上边缘y 轴朝上Ch2 的老朋友而 DOM/Canvas 的像素坐标 y 朝下。换算公式宽 W、高 HCSS 像素px (ndc.x * 0.5 0.5) * W py (1 - (ndc.y * 0.5 0.5)) * H // 那个 1 − 就是翻 y这就是 Ch13 射线拾取做鼠标坐标 → NDC反方向换算的全部依据。4.7 最小可运行示例把整条链跑起来——屏幕准星实验台examples/ch04/index.html场景一个绕 y 轴匀速旋转的 group里面挂着一颗小球局部位置(1.2, 0.8, 0)正是 4.3 例题。每帧做两次独立计算手工链本章学的六段逐段用applyMatrix4走注意 ④⑤ 必须用 Vector4 才看得见 wVector3 会自动除法、藏掉这一步constpmarker.position.clone();// ① 局部marker.localToWorld(p);// ② 世界先 updateMatrixWorld见坑 1constviewp.clone().applyMatrix4(camera.matrixWorldInverse);// ③ 观察w 仍为 1constclipnewTHREE.Vector4(view.x,view.y,view.z,1).applyMatrix4(camera.projectionMatrix);// ④ 裁剪四维w ≠ 1constndcnewTHREE.Vector3(clip.x,clip.y,clip.z).divideScalar(clip.w);// ⑤ 透视除法constpx(ndc.x*0.50.5)*innerWidth;// ⑥ 屏幕constpy(1-(ndc.y*0.50.5))*innerHeight;官方捷径marker.getWorldPosition(tmp).project(camera)——应得到同样的 NDC。示例把两套结果并排打在上角面板并把十字准星的 DOM 位置按手工链设置。你看到的效果准星死死钉住小球面板里两套数字每一帧都相等。哪个环节想不通就改代码里任意一个参数转速、局部位置、相机 fov盯着链路数字跟着变——这就是本章的活地图。为什么 w 不总是 1观察→裁剪那一乘透视矩阵把深度写进 w近似w -z_view。除法前裁剪坐标 x/y 都是未除的所以 GPU 在裁剪空间做剔除和除法顺序不能省。4.8 改造实验示例里把 group 转速改成 0世界坐标停更但小球仍在屏幕上观察/裁剪链照跑。体会渲染链每帧全走一遍与动画无关。把小球局部位置 z 改成 5跑出父级半径看面板里世界坐标变化量比 group 旋转半径大——子物体越远父级同样转速下线速度越大旋转半径放大Ch6 讨论角速度时回收。把camera.fov从 45 改到 90NDC 的 x/y 整体变小同样世界点离中心更近——fov 变大 视锥张角变大 同样物体占的归一化比例变小。这正是 Ch2 改造实验 3 的数学解释。故意制造坑 1删掉scene.updateMatrixWorld()并把链路计算挪到render之前跑——准星滞后小球半圈。恢复要么显式 update要么把计算挪到 render 之后。4.9 常见坑坑 1渲染前读matrixWorld是脏的。three.js 只在render()开头或有依赖时统一更新整棵树的matrixWorld。你在动画逻辑里先算链路、后渲染读到的是上一帧的矩阵——表现就是准星慢半拍“碰撞检测用的是旧位置”。解法二选一scene.updateMatrixWorld()显式刷一遍或把读矩阵的代码放到 render 之后。坑 2matrixAutoUpdate false之后忘了手动updateMatrix()。性能篇Ch20为了省计算会关掉自动更新代价是你改完position必须自己调marker.updateMatrix()否则matrix局部矩阵永远不动。新手期不要碰这个开关。坑 3把project()的 NDC 直接当像素。忘记*0.50.5、忘记翻 y、忘记除以 w——三处任意一处都让准星偏到离谱但不完全离谱。另外点跑到相机背后时 w 变号NDC 会假性落在合法范围——判可见性要看裁剪空间 w0Ch13 做拾取时处理。坑 4用世界坐标思维传局部 API。mesh.position.set(...)里填的是相对父级的坐标group.add(mesh)之后 group 一动 mesh 就跟着走。想加进去但保持原地要用worldToLocal先换算示例里给了两行演示代码。Ch10 专治这个错觉。4.10 练习手算沿用 4.3 例题但父级旋转改为绕 y 轴 180°规则(x,y,z)→(-x,y,-z)。求小球世界坐标再口算 4.4 情形下相机在 (0,0,10) 时它的观察坐标。手算小球 NDC 为(0, 0, -1)恰好贴近裁剪面画布 800×600。求屏幕像素。写出用到的两条公式。读码4.7 手工链里 ③④ 两步的顺序若互换先乘投影再乘观察逆结果会怎样为什么动手把示例的相机改成OrthographicCamera(-10, 10, 7.5, 7.5, 0.1, 100)并摆到 (0,10,0) 俯视camera.up设成(0,0,-1)。观察准星仍钉住小球但旋转时小球屏幕大小不再近大远小——解释投影链里哪一段变了。参考答案旋转(1.2,0.8,0)→(-1.2,0.8,0)平移 (1,0,-1) 后世界 (-0.2, 0.8, -1)。观察坐标 世界 − 相机位置 (-0.2-0, 0.8-0, -1-10) (-0.2, 0.8, -11)仍在前方更远。px (0*0.50.5)*800 400py (1-(0*0.50.5))*600 300。恰好在画布正中央——NDC z-1 只说明深度贴近裁剪面x,y0 决定它在屏幕中心。注意贴脸不等于看不见在内才裁太近z-1即比 near 还近才被裁。结果完全不同甚至非法。矩阵乘法不可交换链是作用顺序的函数③④ 的正确顺序是先观察后投影先换到相机视角、再压立方体applyMatrix4(A).applyMatrix4(B)等价于(B·A)右乘在先。Ch5 把这条纪律讲透并给出从右往左读的通式。变的只有第四段投影矩阵从透视换成正交——正交矩阵里 w 恒为 1没有深度除法NDC 的 x/y 与观察坐标成固定比例所以同样大小不随距离缩。链路 API 无需改projectionMatrix、matrixWorldInverse都存在示例手工链一字不动继续工作——这正说明这条链是接口而非某个相机专属。本章小结六段身份证局部图纸→ 世界装配父链连乘→ 观察相机之逆→ 裁剪投影矩阵Ch11 推→ NDC÷w→ 屏幕视口记得翻 y。每段一个可手算的核心动作世界段旋转完再平移观察段世界减相机位纯平移情形屏幕段(0.5x0.5)·W与翻 y。localToWorld / worldToLocal / project / matrixWorldInverse / projectionMatrix五个 API 对应链路五个接口点渲染前读矩阵先updateMatrixWorld()。本章把渲染 矩阵乘法钉进了管线第①站。但这些 4×4 矩阵里面到底填了什么数——平移和旋转怎么做成矩阵、为什么是 4×4 不是 3×3、法线为什么要用逆转置——下一章全部用数字拆开。本章完整源码本章示例的全部源码都在下面逐字复制即可重建不需要另外下载。每个代码块都是examples/里对应文件的原样副本由脚本生成并逐字节对账所以你抄下来的就是作者这边跑通过的那一份。把下面这个文件逐字存成examples/ch04/index.html然后在examples/的上一级目录里起一个静态服务器python-mhttp.server8000再访问http://127.0.0.1:8000/examples/ch04/。three 由 importmap 从 unpkg 取要联网版本号保持 0.186.1换版本先回看 Ch1 的版本纪律。文件清单ch04/index.html (133 行)ch04/index.html!DOCTYPEhtmlhtmllangzh-CNheadmetacharsetUTF-8metanameviewportcontentwidthdevice-width, initial-scale1.0titleCh4 · 变换链实验台/titlestylehtml, body{margin:0;height:100%;overflow:hidden;background:#0d1117;}#stage{display:block;}#crosshair{position:fixed;left:0;top:0;color:#7CFC00;font:700 30px/1 monospace;transform:translate(-50%,-50%);pointer-events:none;user-select:none;text-shadow:0 0 6pxrgba(124,252,0,.6);}#panel{position:fixed;top:12px;right:12px;width:380px;padding:10px 12px;background:rgba(13,17,23,.85);border:1px solid #2d3646;border-radius:8px;color:#b8cfe8;font:12px/1.55 Consolas,monospace;white-space:pre-wrap;}#panel .ok{color:#9fd59f;}#panel .seg{color:#e8b34b;}/stylescripttypeimportmap{imports:{three:https://unpkg.com/three0.186.1/build/three.module.js,three/addons/:https://unpkg.com/three0.186.1/examples/jsm/}}/script/headbodycanvasidstage/canvasdividcrosshair/divdividpanel链路计算中…/divscripttypemoduleimport*asTHREEfromthree;// ── 三要素Ch2──constscenenewTHREE.Scene();constcameranewTHREE.PerspectiveCamera(45,innerWidth/innerHeight,0.1,100);camera.position.set(4,3,6);camera.lookAt(0,0.8,0);constrenderernewTHREE.WebGLRenderer({canvas:document.getElementById(stage),antialias:true});renderer.setSize(innerWidth,innerHeight);renderer.setPixelRatio(Math.min(devicePixelRatio,2));renderer.setClearColor(#0d1117);window.addEventListener(resize,(){camera.aspectinnerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);});// ── 场景内容旋转的父 group 挂着的小球4.3 例题参数──scene.add(newTHREE.AxesHelper(3),newTHREE.GridHelper(10,10));constgroupnewTHREE.Group();group.position.set(1,0,-1);// 例题父级平移scene.add(group);constmarkernewTHREE.Mesh(newTHREE.SphereGeometry(0.18,24,16),newTHREE.MeshBasicMaterial({color:gold}));marker.position.set(1.2,0.8,0);// 例题子物体局部位置group.add(marker);// 坑 4 演示想试就取消注释并先给 group.add(child) 一个 child// const want new THREE.Vector3(3, 0.8, 2); // 希望 child 加进来时待在这个“世界”位置// const local group.worldToLocal(want.clone()); // 世界 → 局部// child.position.copy(local); // 这样 group.add(child) 后它不会“跳”constcrosshairdocument.getElementById(crosshair);constpaneldocument.getElementById(panel);constfmt(v)(${v.x.toFixed(3)},${v.y.toFixed(3)},${v.z.toFixed(3)});letframe0;renderer.setAnimationLoop((t){group.rotation.yt/1500;// 父级绕 y 轴匀速旋转// 改造实验 4坑 1删掉下面两行、并把本段链路计算挪到 render 之后观察准星仍正确// 若只删这两行且链路留在 render 之前准星会滞后小球。scene.updateMatrixWorld(true);// 显式刷新整棵树的世界矩阵camera.updateMatrixWorld();// 同时刷新 matrixWorldInverse// ① 局部 → ② 世界constworldmarker.position.clone();marker.localToWorld(world);// ③ 世界 → 观察constviewworld.clone().applyMatrix4(camera.matrixWorldInverse);// ④ 观察 → 裁剪Vector4 才能看见 wVector3.applyMatrix4 会自动除 w把关键一步藏掉constclipnewTHREE.Vector4(view.x,view.y,view.z,1).applyMatrix4(camera.projectionMatrix);// ⑤ 透视除法 → NDCconstndcnewTHREE.Vector3(clip.x,clip.y,clip.z).divideScalar(clip.w);// ⑥ NDC → 屏幕像素翻 yconstpx(ndc.x*0.50.5)*innerWidth;constpy(1-(ndc.y*0.50.5))*innerHeight;crosshair.style.leftpxpx;crosshair.style.toppypx;// 官方捷径对照project() 应给出同一 NDCconstofficialmarker.getWorldPosition(newTHREE.Vector3()).project(camera);constdiffMath.max(Math.abs(official.x-ndc.x),Math.abs(official.y-ndc.y));constvisibleclip.w0;// 可见性要看裁剪空间 w 的符号坑 3if(frame%60){panel.innerHTML[span classseg① 局部${fmt(marker.position)}/span ← 出厂图纸,② 世界${fmt(world)}← 装配后位置,③ 观察${fmt(view)}← 相对相机z0前方,④ 裁剪 (${clip.x.toFixed(2)},${clip.y.toFixed(2)},${clip.z.toFixed(2)}, w${clip.w.toFixed(2)}),⑤ NDC${fmt(ndc)},⑥ 屏幕 (${px.toFixed(1)},${py.toFixed(1)}) px,─────────────────────,project() 捷径 NDC${fmt(official)},span class${diff1e-4?ok:}${diff1e-4?✓ 手工链 官方链最大偏差 diff.toExponential(1):✗ 两条链不一致diff}/span,裁剪 w0在相机前方可见${visible?✓:✗},].join(\n);}renderer.render(scene,camera);});/script/body/html