☰
吃透Three.js相机:投影矩阵、运动控制与渲染管线
2026/10/18 19:47:42 网站建设 项目流程

搞了这么多年 Three.js 项目,我越来越有一种感受:很多人卡在 3D 渲染的瓶颈上,不是光照没搞懂,也不是材质没弄明白,而是从头到尾就没把相机这个东西吃透。相机看起来不就是摆个位置、看向目标吗?但实际上,相机是连接"场景里的三维数据"和"屏幕上的二维像素"的唯一枢纽。你看到的每一帧画面,本质上都是相机对三维世界的一次"切片"和"投影"。

这篇文章我想把 Camera 这块掰开揉碎讲清楚,从它为什么重要、投影矩阵背后发生了什么、相机参数的真实代价,到运动控制、性能优化、整条渲染管线的串联,最后给一份实战排查清单。适合刚学 Three.js 两三个月、开始被各种画面问题困扰的朋友,也适合已经写了几个项目但总感觉哪里差点意思的开发者。看完你会发现,3D 渲染很多"玄学问题"其实都和相机有关。

1. 相机不是眼镜头,它是渲染管线的枢纽站

先说一个反直觉的事实:在 Three.js 里,你最终看到什么,不取决于物体本身在世界坐标系的绝对位置,而是取决于这些物体在相机坐标系里长什么样。同一个物体,同一个场景,相机换个位置,渲染出来的画面可能完全不像同一个世界。

1.1 从顶点到屏幕:你必须先记住这条坐标变换链

一次完整的渲染,顶点数据要经过五次坐标变换才能变成屏幕上的像素。Three.js 大部分时候帮你把这些封装好了,但你越是后期做自定义 Shader、做后处理、做特殊相机效果,越需要把这些环节刻在脑子里:

  1. 模型本地坐标(Model / Local Space):物体自身的顶点坐标,建模软件里导出的就是这些数据。
  2. 世界坐标(World Space):通过 modelMatrix 把物体放到场景里。
  3. 视图坐标(View / Camera Space):通过 viewMatrix 把所有物体变换到"以相机为原点"的坐标系里。
  4. 裁剪坐标(Clip Space):通过 projectionMatrix 把视图坐标变换成一个标准化范围,GPU 会用它做裁剪。
  5. 屏幕坐标(Screen Space):裁剪坐标经过透视除法得到 NDC,再经过视口变换映射到 canvas 像素。

相机在其中扮演的角色,是每帧生成两个关键矩阵:viewMatrix(视图矩阵)和 projectionMatrix(投影矩阵)。换句话说,相机在数学上不是"一个物体",而是一个矩阵生成器。

1.2 为什么说相机定义了一个完整坐标系

视图矩阵的本质,是重新表达整个世界在相机坐标系中的位置。Three.js 里相机有三个属性决定这个坐标系:position(位置)、target 或 lookAt 方向(朝向)、up(上方向)。

  • position:相机的原点位置。
  • 相机的负 Z 轴:指向目标方向。这里注意,Three.js 使用右手坐标系,相机默认看向负 Z 方向,所以你在场景里放一个不旋转的物体,它在相机前方时的视图坐标 z 是负值。
  • up:决定画面里"向上"是哪个方向。默认是 (0, 1, 0),也就是世界 Y 轴。

这三个向量组合起来,就构成了一个以相机为中心的正交基。所有物体位置都要"重投影"到这个坐标系里,GPU 才知道物体是在相机前面还是后面、左边还是右边、上方还是下方。

很多新手第一次写自定义 Shader 时,会看到 Three.js 内置变量 modelViewMatrix,其实就是 viewMatrix * modelMatrix 的合并结果。顶点着色器里经典的写法是:

// 顶点着色器 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

这一行代码,左边先做物体到视图坐标的变换,右边做投影到裁剪坐标的变换。懂了这条链路,你就能理解为什么普通相机画面里"远处的物体变小"——因为投影矩阵里有个除以 w 的神奇操作,这个后面会展开。

1.3 相机"挂到场景里"到底意味着什么

有个初学者经常困惑的问题是:为什么代码里要scene.add(camera)?相机的世界矩阵不应该是固定的吗?

其实scene.add(camera)的意义在于:让相机也参与场景的矩阵更新。比如你把一个模型挂到相机下面,这个模型就会跟着相机一起移动——第一人称手持武器、头盔显示、跟随视角的 UI 元素都是这么做的。相机本身在场景图里的矩阵变化,会导致它所有子节点一起变,但不会反过来改变 viewMatrix 的计算方式。

真正决定视图矩阵的,是 camera.matrixWorldInverse,这个矩阵是每帧从相机世界矩阵求逆得到的。所以 camera 放在场景里,是为了让引擎能统一更新它的 world matrix,然后顺便算出 viewMatrix。

2. 透视相机与正交相机:投影矩阵里的两个世界

Three.js 里最常见的两种相机:PerspectiveCamera(透视相机)和 OrthographicCamera(正交相机)。它们的差别不是"视野效果不同"这么简单,而是背后完全不同的投影矩阵数学。

2.1 透视投影:模拟人眼的"近大远小"

透视相机的核心参数有四个:fov、aspect、near、far。

在构造时:

const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);

这里 75 是垂直方向的视野角度,单位是度;第二个参数是画布宽高比;后两个是近远裁剪面。填好这四样,Three.js 会在内部生成一个 4x4 的透视投影矩阵。

透视投影最核心的操作,是"齐次坐标除以 w"。视图空间里,距离相机越近的点看起来越大。矩阵在计算时把 x、y 都乘以一个跟深度相关的系数,然后用 z 的负值充当 w 分量。GPU 在顶点处理阶段会做透视除法:把 (x, y, z, w) 变成 (x/w, y/w, z/w, 1)。这个除法实现了"越远越小"的视觉效果。

这里有个直观的类比:你可以把透视投影想象成用手电筒打光,光束从相机出发呈现锥形,锥形底部是近裁剪面,顶部是远裁剪面,这个锥体叫"视锥体"(frustum)。所有在这个锥体内的物体才可能被渲染,锥体外的会被裁剪掉。

2.2 正交投影:工程图式的"平行投影"

正交相机的创建参数长得更直白:

const camera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 1000);

前四个参数分别是 left、right、top、bottom,也就是视锥体在近裁剪面上的边界。正交投影的 w 分量恒为 1,x、y、z 都是线性映射,不存在近大远小。不管物体离相机多远,投影到屏幕上的尺寸都一样大。

正交相机什么时候用?两个最典型场景:一是做 2D 游戏、UI 层、编辑器里的轴辅助线,因为不会变形;二是阴影贴图,定向光用正交相机拍一张"从光源角度看世界"的深度图,这样阴影边缘均匀清晰,不会因为透视的关系导致远离光源的地方阴影模糊。

2.3 两种相机的选择与切換

很多项目不会只用一种相机。做产品展示的时候,checkout 界面适合正交相机,旋转主模型时适合透视相机。切换的时候要注意,两种相机的 aspect 和投影矩阵结构完全不同,不能直接改一两个参数就切换,通常需要分别维护,然后根据场景切换 active camera。

还有一个细节:透视相机的 fov 是垂直的,水平视角由 aspect 推出来。也就是说,改变画布宽高比,水平视野会变,垂直视野不变。很多新手改完画布尺寸发现画面里的物体变宽或变窄了,就是没理解这个关系。

3. 参数不是随便填的:fov、aspect、near/far 的隐形代价

相机参数填错,常常不会直接报错,而是体现在画面"看起来不对劲"上。这一节我把自己项目里踩过的坑和总结的规律都放出来。

3.1 fov:画面的"张力感"和镜头语言

fov 不只是看多广,它还决定了画面的透视强度和畸变感。

  • fov 很小(比如 20-30):类似长焦镜头,画面有压缩感,远处和近处的物体大小差异小,适合盯住某个物体做特写。
  • fov 适中(40-60):接近人眼舒适区,透视自然,适合大部分场景。
  • fov 很大(80-120):类似广角镜头,边缘物体明显变形,透视张力极强,适合运动感和沉浸感强的场景。

我做过一个产品 rotation 展示,主相机 fov 用的 45,画面稳定舒服;但切到行走视角时 fov 调到 80,用户会明显感到"视野变宽"的同时,物体边缘发生了夸张的拉伸感。如果你想让用户情绪紧张或动作迅速,大 fov 是好工具;但如果是精细操作或者查看模型细节,fov 太大反而会让人眩晕。

补充一个冷知识点:fov 本身和焦距是等价的。对全画幅相机,fov = 2 * atan(36 / (2 * focalLength))。如果你有一张真实照片想对齐场景,可以用这个公式反向计算出 fov。

3.2 aspect:最简单的变形元凶

aspect 设置错误,画面里的圆会变成椭圆,立方体会变成扁的长方块。这个参数必须等于 canvas 的实际像素宽高比。

一个容易踩的坑是:canvas 的 CSS 宽高和实际像素宽高不一样,尤其是设了 devicePixelRatio 之后。正确的做法是读取 canvas 的 clientWidth 和 clientHeight,再乘上 window.devicePixelRatio 得到真实绘制尺寸。每次 resize 时同时更新 renderer 和相机:

function onResize() { const width = container.clientWidth; const height = container.clientHeight; renderer.setSize(width, height); camera.aspect = width / height; camera.updateProjectionMatrix(); }

注意最后一行camera.updateProjectionMatrix(),很多人在 resize 之后忘了调,导致投影矩阵还是旧 aspect,画面越拉越歪。

3.3 near/far:深度精度是省不掉的隐形账单

near 和 far 定义了可见深度范围,但这俩参数直接决定深度缓冲的精度分布。深度缓冲(z-buffer)不是线性分布的,它把大部分精度分配给了靠近近裁剪面的区域。

为了直观说明,我不写复杂公式,只给一个经验:near 越小,far 越大,深度精度越分散,近处的物体越容易发生"z-fighting"(两个面深度太接近导致闪烁)。

我遇到过最崩溃的一次:场景里有个大地面和很多细节物体,near 设了 0.01,far 设了 100000,结果距离相机 500 米左右的墙面上,贴图和地面疯狂闪烁。后来把 near 调到 0.5,far 调到 20000,闪烁立刻消失了。

经验法则:near 和 far 尽量贴近场景实际内容范围,不要为了"保险"把 far 调得过大。一般 near 与 far 的比值不要小于 1:1000,否则深度精度会出问题。如果你确实需要超大场景,可以考虑使用 logarithmic depth buffer(Three.js 有示例扩展)或者分段渲染,但那些方案都有自己的性能与兼容性代价,能不用就不用。

4. 相机运动的数学地基:position、lookAt 与四元数

做交互项目,相机肯定要动。动的方式千差万别,但底层就那么几种数学手段。

4.1 position、target 和 up:三个向量定位一个相机

最基本的方法是直接设置位置,然后调用 lookAt:

camera.position.set(10, 10, 10); camera.lookAt(0, 0, 0);

lookAt内部会做一件简单但重要的事:用 position 减 target 得到 forward 方向,用 forward 叉乘 up 得到 right 方向,再用 right 叉乘 forward 得到真正的 up 方向。这三个互相垂直的向量组成旋转矩阵,更新相机的 quaternion。

这里面有个经典陷阱:如果 forward 方向恰好和 up 向量平行(比如相机在目标正上方,up 是 Y 轴,forward 是竖直向下),叉积结果是零向量,lookAt 就不知道"右侧"该朝哪,相机会瞬间翻转或者抖动。OrbitControls 里你转到正上方时会突然打转,根本原因就在这。解决思路是动态调整 up 向量,或者在接近极点时做平滑插值。

4.2 欧拉角与四元数:旋转的底层抉择

相机旋转有两种常用表示:欧拉角(rotation 属性)和四元数(quaternion 属性)。Three.js 里两者是同步的,你设置 rotation,quaternion 会自动更新。

欧拉角直观但有个著名问题:万向锁。当旋转到某个特殊角度时,两个旋转轴会重合,导致某个自由度丢失,表现就是相机突然"卡住"或者旋转方向反了。做轨道相机、第一人称视角时,如果用欧拉角做累计旋转,很容易踩万向锁。

我的建议是:运动控制代码里统一用四元数或矩阵计算,欧拉角只用来做初始化或者读值。想实现平滑旋转动画,用 quaternion.slerp 或者 angleTo 插值,效果比欧拉角平滑得多。

4.3 手写一个简单的轨道相机:球坐标思路

OrbitControls 很常用,但有些项目需要定制手感,此时知道它怎么做就很有用。核心思路是用球坐标控制相机:

// theta:水平角,phi:俯仰角,radius:距离 camera.position.x = radius * Math.sin(phi) * Math.sin(theta); camera.position.y = radius * Math.cos(phi); camera.position.z = radius * Math.sin(phi) * Math.cos(theta); camera.lookAt(0, 0, 0);

每次鼠标移动改变 theta 和 phi,滚轮改变 radius。这里注意 phi 的边界要限制在 (0.01, Math.PI - 0.01) 之间,否则就触发上面说的极点翻转问题。

为了手感更好,要给旋转和缩放加阻尼。可以用 lerp 做一个延迟跟随:

camera.position.lerp(targetPosition, 0.1);

这样相机不会"僵硬"地跳到目标位置,而会有一种平滑的跟随感。实测中模板里这么做的人都觉得体验提升明显。

5. 被忽视的相机性能账:视锥剔除、多相机与坐标精度

相机不只是"显示画面"的工具,它在性能层面也有巨大的存在感。

5.1 视锥剔除:相机帮你省下的 draw call

Three.js 默认开启视锥剔除(Frustum Culling),它会把视锥体的六个面从投影矩阵和视图矩阵里提取出来,然后对场景里每个物体做包围盒测试。完全在视锥外的物体不提交渲染。这是引擎自动做的,但有一个重要前提:物体必须有合理的包围盒,而且场景图要拆得够细。

如果你把一个包含成千上万个小零件的模型合并成一个 Mesh,即使只露出其中一小块,整个模型的全部顶点仍然会被提交绘制,视锥剔除对这一个大对象完全失效。所以场景越复杂,越要把大模型拆成多个部分。这是很多人忽略的优化点。

5.2 同一个场景里放多台相机

一个 WebGL 场景可以同时存在多个相机,但同一帧里只有 active camera 会被默认渲染。多相机最常见的使用场景是局部小地图、分屏游戏、或者后处理里拿深度信息。

Three.js 里可以通过设置视口实现局部渲染:

renderer.setViewport(0, 0, width, height); renderer.render(scene, mainCamera); renderer.setViewport(0, height - miniMapSize, miniMapSize, miniMapSize); renderer.render(scene, miniMapCamera);

注意每次 render 之前要关掉自动清除或者手动清屏,不然上一帧的颜色会残留。实际项目里我一般设renderer.autoClear = false,然后手动调用合适时机的renderer.clear()。

5.3 坐标精度:当相机离原点太远时

这是个大场景项目里最隐蔽的坑。WebGL 的变换矩阵和顶点属性默认是 32 位浮点数,有效精度大约 7 位十进制数字。当相机位置超过 10000 左右,物体坐标的世界数量级开始超过精度余量,画面会出现明显的顶点抖动。

一个典型解决方案叫"场景回退"或者"原点重定位":相机保持在世界原点附近,整个场景的物体每帧反向移动。也就是说,移动的不是相机,而是整个世界。

实现思路是维护一个 originOffset,让所有物体的世界位置减去这个偏移量:

// 每帧更新 world.position.x = -camera.position.x; world.position.z = -camera.position.z;

这样 GPU 里处理的坐标数值始终在个位数到百位数之间,精度就稳住了。Three.js 里有一个常用技巧是给 scene 套一层 group 来承载偏移,相机则保持在世界原点附近。

6. 把相机放进整条渲染管线:从 MVP 到像素的一次走查

前面讲了这么多概念,这一节用一个完整的小例子,把相机如何参与最终像素生成串起来。

6.1 一个具体的相机和物体的数值演算

假设场景里有一个立方体,本地坐标是 (±0.5, ±0.5, ±0.5),相机在 (0, 0, 5),看向原点,fov 是 60,aspect 是 1.6,near 0.1,far 100。立方体某个顶点在本地坐标是 (0.5, 0.5, 0.5)。

  1. 模型矩阵:立方体放在原点,modelMatrix 是单位矩阵,所以世界坐标也是 (0.5, 0.5, 0.5)。
  2. 视图矩阵:相机在 (0, 0, 5) 看向原点,相机坐标系中这个点变成 (0.5, 0.5, -4.5)。注意 z 是负的,因为它在相机前方。
  3. 投影矩阵:透视投影矩阵会把 x、y 乘以一个与 fov 和 aspect 相关的系数,然后 z 变成 w。GPU 做透视除法后得到 NDC 坐标。
  4. 屏幕映射:NDC 的范围是 [-1, 1],乘以 viewport 宽高的一半并加上中心偏移,得到最终像素坐标。

这个流程说明一件事:物体在屏幕上的大小,最终由相机到物体的距离、fov 和 aspect 共同决定,而不是物体自身的世界坐标。

6.2 相机和深度缓冲的恋爱关系

渲染管线里还有一步和相机紧密相关:深度测试。每个像素除了颜色,还会写深度值,深度缓冲存储的是"这个像素离相机多近"。三个物体重叠时,GPU 根据深度值决定谁在前面。不知道你有没有遇到过:半透明物体渲染顺序乱掉,有时是因为深度写入开关配置不对,而根本上是没理解深度缓冲和相机的配合。

后处理里想拿"视角深度"做雾效或者边缘光,也需要用相机参数做逆变换。EffectComposer 里常用 unwrap 深度贴图的方式,把非线性的深度值还原成线性距离,这中间要使用 camera.near、camera.far 和 projectionMatrixInverse。我见过很多人直接拿深度贴图当颜色用,出来的画面一团黑,就是因为没做非线性到线性的还原。

6.3 一个完整的渲染器内部快照

打开渲染器源码或者 API 日志,你能看到相机相关的方法其实驱动了几乎一切,下面是典型的渲染流程:

  • 更新相机 world matrix 和 view matrix。
  • 从 viewMatrix 和 projectionMatrix 提取视锥平面。
  • 遍历场景图,做视锥剔除。
  • 对通过的每个物体,上传 modelViewMatrix、normalMatrix、projectionMatrix 等 uniform。
  • 执行顶点着色器,输出裁剪坐标。
  • GPU 做裁剪、透视除法、背面剔除、深度测试、颜色写入。

把这套流程在心里过一遍,你就明白为什么相机参数会影响阴影质量、抗锯齿效果、甚至材质的高光形状——因为所有光照计算都发生在视图空间里,相机位置决定了法线变换的方向和数值。

7. 相机调试清单:从症状直接定位到参数

项目里遇到问题,与其瞎试,不如按症状反查相机参数。这份清单是我长期整理下来的,希望对你有用。

7.1 常见病症与对应解药

症状大概率原因解决思路
画面全黑,物体消失相机位置或朝向不对,物体不在视锥内打印 position 和 target,确认物体在相机前方且距离在 near/far 之间
物体太小或太大fov 过大/过小调整 fov,或者移动相机重新构图
画面拉伸变形aspect 和画布宽高比不一致resize 时同步更新 aspect 并调用 updateProjectionMatrix
近处物体闪烁near 太小,深度精度分配太差调大 near,或缩小 far,让 near/far 比值更合理
远处物体抖动far 过大或世界坐标数值过大调小 far,或使用场景回退方案
旋转某角度突然翻转lookAt 和 up 平行,或欧拉角万向锁改用四元数,或动态调整 up
UI 元素变形正交相机参数与分辨率不匹配根据 viewport 尺寸动态设置 left/right/top/bottom

7.2 把相机"可视化"出来

调试相机最快的方式,是把它本身变成场景里的可见对象。

Three.js 自带CameraHelper,你把相机传进去,场景里就会出现一个线框组成的视锥体,物体在不在可视范围内一眼就能看出来。

const helper = new THREE.CameraHelper(camera); scene.add(helper);

还有一个我觉得特别实用的技巧:在开发环境把当前相机的位置、朝向、参数实时渲染在屏幕上,方便你调整后立刻判断。我通常用一个简单的 HTML 覆盖层:

debugInfo.innerHTML = ` pos: ${camera.position.toArray().map(n => n.toFixed(2)).join(', ')} target: ${target.toArray().map(n => n.toFixed(2)).join(', ')} fov: ${camera.fov.toFixed(1)} near/far: ${camera.near} / ${camera.far} `;

配合轨道控制器手动拖拽观察,很多时候"画面为什么不对"的谜底会瞬间揭晓。

7.3 从"会调相机"到"会用相机思考"

如果你已经把相机当作渲染管线的一部分而不仅是"取景器",那么很多高级话题——比如立体渲染、全景相机、反射相机的实现——都会变得顺理成章。

立体渲染就是左右眼各一个相机,偏移一定的 eye distance,各渲染一半画面;全景相机本质上是往六个方向各放一个透视相机,渲染到立方体贴图再展开;反射相机是在镜子位置放一个相机,看向镜像方向,渲染到一张纹理上给镜面材质采样。它们全都是"相机即矩阵生成器"这个思路的延伸。

最后再分享一个体会:做 3D 项目这么多年,我遇到的大部分疑难杂症,最后追根溯源都落在相机这个环节上。材质、灯光、模型可以无限复杂,但只要相机参数和矩阵正确,渲染管线就稳稳的;相机一旦出问题,换什么材质打什么光都救不回来。所以如果你现在正被某个"诡异"的画面问题困扰,先别急着怀疑引擎,打开相机调试清单逐项排查一遍,多半能省下大半天的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询