12.Three.js 基础之镜头
12 Three.js 基础之镜头
在之前所有的示例中,关于镜头,我们使用的都是 PerspectiveCamera(透视镜头)。
再次强调一下,我个人偏好是喜欢将 Camera 称为 “镜头”,但是 Three.js 官方或其他教程中称呼其为 “相机”
在 Three.js 中,一共有 5 种镜头:
镜头类型(都继承于 Three.Camera) | 镜头名称 | 解释说明 |
---|---|---|
ArrayCamera | 镜头阵列 | 一组已预定义的镜头 |
CubeCamera | 立方镜头 | 6 个面的镜头(前、后、左、右、顶、底) |
OrthographicCamera | 正交镜头 | 无论物体距离镜头远近,最终渲染出的大小不变 |
PerspectiveCamera | 透视镜头 | 像人眼睛一样的镜头,远大近小,最常用的镜头 |
StereoCamera | 立体镜头 | 双透视镜头,常用于创建 3D 立体影像或 视差屏障 |
所有镜头的辅助对象都是:Three.CameraHelper
由于 透视镜头(PerspectiveCamera) 是日常中使用最频繁的镜头类型,因此我们先从 透视镜头 开始讲解。
镜头的一些知识
我们通过透视镜头,来讲解一些镜头的知识
透视镜头(PerspectiveCamera) 所呈现出的效果,和我们用有眼睛观察世界是一模一样的。
平截面
无论所观察的物体是什么类型,例如球体、立方体、椎体等,在我们的视野中都会形成 2 个截面:
- 远截面(far):物体最远处的截面
- 近截面(near):物体最近处的截面
若以某个特定角度,当镜头(眼睛)观察物体时,物体远截面和近截面完全相同,那么此时近截面就会遮挡远截面,我们只能看到近截面。
例如我们在一个立方体的正前方,此时近截面完全遮挡住远截面,此时我们观察到的立方体更像是一个平面。
但是由于可能存在阴影,我们依然能够感知到这是一个 “3D 立体物体”。
视椎
想象一下,假设把我们的镜头(眼睛) 当做一个点,由这个点依次与物体的近截面、远截面的顶点进行连接,就会形成一个 椎体,而这个虚构出来的椎体,就是我们镜头(眼睛)与物体在空间上存在的视椎。
如果我们眼睛不是与物体,而是与 “场景(Three.Scene)的近截面、远截面” 形成的视椎,就是正常 Three.js 场景中可见空间。
请注意,上面提到的 场景的远截面和近截面 是加了引号,事实上没有办法直接设置场景的远近截面,场景的近远视椎是由镜头的以下几个参数最终计算出的:
- 镜头的观察角度(fov)
- 镜头画面的宽高比(aspect)
- 镜头的最近可见距离(far)
- 镜头的最远可见距离(near)
- 一个隐含因素:镜头本身的位置(camera.position)
近截面和远截面决定了物体是否在镜头内可见
物体与镜头的距离决定物体在视觉上的大小
当我们初始化一个透视镜头时,构造函数需要传递的 4 个参数,就是上面前 4 个元素。
透视镜头默认参数值:fov=50、aspect=1、near=0.1、far=2000
补充说明:
在 Three.js 官方文档中对以上 4 个参数的解释是:
- fov:摄像机视椎体垂直视野角度
- aspect:摄像机视椎体长宽比(宽高比)
- near:摄像机视椎体近端面
- far:摄像机视椎体远端面
虽然我的描述和官方描述文字上存在差异,但意思相同。我认为我的用词更加口语化,容易理解,所以在本系列文章中,我会继续使用我的描述语言。
因为受到自己对 Three.js 的理解程度,或许偏个人化语言描述或许是不正确的。
关于镜头近截面与远截面的补充说明:计算量与性能
我们知道透视镜头默认参数值:fov=50、aspect=1、near=0.1、far=2000,而我们之前文章中的示例,通常镜头设置参数为:new Three.PerspectiveCamera(45,2,0.1,1000)
也就是说,近截面(镜头最近可见距离)通常设置为 0.1、远截面(镜头最远可见距离)通常为 1000
若超出这个范围内的物体或物体局部则都将不可见。
思考一下
假设我们直接将 near 由 0.1 修改为 0.0001、far 由 1000 修改为 1000000,那是不是场景最近可见度更加精细、可见范围变得更大,能够承载更多的物体呢?
答案是肯定的,但场景越大,可见度越微观,渲染所需计算量也越大。
请记得:当计算量大到一定程度后,就会出现渲染异常,画面会出现一些意外的、不符合预期结果。
通常表现为物体表面像素紊乱、破碎、闪烁、像素前后失调,这是因为 GPU 没有足够的精度来确认哪些像素应该在前,哪些在后。
我十分确信此刻我是在讲解 Three.js,而不是 大姨妈。
解决办法(并不推荐):将渲染器的 logarithmicDepthBuffer 设置为 true
logarighmicDepthBuffer:对数深度缓存器
const renderer = new Three.WebGLRenderer({
canvas:xxxx,
logarithmicDepthBuffer:true
})
注意事项:
- 通常电脑浏览器都已支持 logarithmicDepthBuffer 属性,但很多手机目前还不支持。
- logarithmicDepthBuffer 为 true 只是在一定程度上能够缓解问题,但若 near 足够小、far 足够大时,依然会出现 GPU 计算精度不够,造成画面渲染紊乱。
解决办法(推荐做法):不解决
请记得:你本就不应该把 near 设置过小、far 设置过大!
near 和 far 正确的设定原则
尽可能让 near 和 far 更接近镜头不远的位置,当然前提是不让任何物体超出消失的范围内。
注意,这里的 “更接近镜头不远的位置” 是指 “合适、适当的位置”,并不是指小数点后精确多少位。
在保证精度的前提下,尽可能设置合适的 近截面和远截面,这样让 镜头与物体产生的视椎 “更小、更接近”,以节省渲染所需计算量和性能。
举一个例子:
假设你需要渲染出一个 足球 的特写,那么把足球放置在一个 比较小的平台或地面即可,而不是创建一个城市一样大小的场景,却只渲染出一个 足球的近距离特写。
但是假设你的场景确确实实需要非常大,此时就需要多参考网上其他人是如何处理类似场景的。
或许后续文章中也会有讲解,此时此刻你只需知道 near 和 far 设置合适即可,没必要过于精细或巨大。
镜头示例 1:使用 CameraHelper 来观察镜头
关于 透视镜头 PerspectiveCamera 我们之前示例中已经使用多次。
本示例主要演示 通过 镜头辅助对象(CameraHelper) 来观察镜头。
思考一下:
在正常情况下,一个镜头只能看到别的物体但无法看到自己。
就好像我们的眼睛可以看到这个世界,但是眼睛本身自己没法看到自己(眼睛)。
当然除非对着镜子,不过对着镜子的本质依然是眼睛去看别的物体。
更加直白一点:就算你眼睛长得再大,你也做不到左眼直接看到自己右眼。
我们人虽然是 2 个眼球,但是在 Three.js 的相关举例中,是将 左右两个眼睛当成 是 1 个镜头来阐述的。
HelloCamera 示例目标
- 创建一个包含物体的场景
- 创建 2 个镜头,镜头 A 和镜头 B
- 将网页画面一分为二
- 左侧显示 镜头 A 所看到的场景
- 右侧使用使用 镜头 B 来观察 镜头 A
补充说明:
- 事实上是 镜头 B 观察并显示 镜头 A 对应的辅助对象 (CameraHelper)
- 为了省事,我们直接使用前文讲解灯光时编写的 create-scene.ts 来创建场景
代码实现思路
关键点 1:同一个场景渲染出 2 个不同的画面
**实现 2 个画面:**添加左右 2 个镜头,每个镜头设置不同,最终呈现出的场景不同
const leftCamera = new Three.PerspectiveCamera(45, 2, 5, 100)
leftCamera.position.set(0, 10, 20)
const rightCamera = new Three.PerspectiveCamera(60, 2, 0.1, 200)
rightCamera.position.set(40, 10, 30)
rightCamera.lookAt(0, 5, 0)
**实现 2 个交互:**添加 左右 2 个 div、2 个 OrbitControls,覆盖于 canvas 之上
const leftControls = new OrbitControls(leftCamera, leftViewRef.current)
leftControls.target.set(0, 5, 0)
leftControls.update()
const rightControls = new OrbitControls(rightCamera, rightViewRef.current)
rightControls.target.set(0, 5, 0)
rightControls.update()
...
<div className='full-screen'>
<div className='split'>
<div ref={leftViewRef}></div>
<div ref={rightViewRef}></div>
</div>
<canvas ref={canvasRef} />
</div>
**渲染 2 个画面:**使用渲染器的 裁减 功能
渲染器裁减功能,涉及到的方法有 setScissor()、setScissorTarget()、setViewport() 。
我们之前示例中,为了适应浏览器窗口大小的改变,我们使用过 渲染器的 setSize()
setScissor ( x : Integer, y : Integer, width : Integer, height : Integer ) : null 将剪裁区域设为(x, y)到(x + width, y + height) Sets the scissor area from
setScissorTest ( boolean : Boolean ) : null 启用或禁用剪裁检测. 若启用,则只有在所定义的裁剪区域内的像素才会受之后的渲染器影响。
setViewport ( x : Integer, y : Integer, width : Integer, height : Integer ) : null 将视口大小设置为(x, y)到 (x + width, y + height)
请额外留意在后面实际代码中,我们定义的 setScissorForElement() 函数。
多敲几遍,记住 setScissorForElement() 函数中获得裁减区域的代码套路
关键点 2:镜头辅助对象
镜头辅助对象为 Three.CameraHelper,他的用法很简单:
const helper = new THREE.CameraHelper( leftCamera )
scene.add( helper )
关键点 3:如何渲染
在之前所有的示例代码中,渲染代码都为:
renderer.render(scene, camera)
但本示例中,我们是同一个 scene,但是 2 个不同的 camera,因此与之对应的渲染代码也要发生变化。
需要依次分别渲染出 左侧镜头视角 和 右侧镜头视角。
//leftCamera一些更新操作
...
renderer.render(sceneRef.current, leftCamera)
//rightCamera一些更新操作
...
renderer.render(sceneRef.current, rightCamera)
具体的代码
create-scene.ts
我们直接使用之前 “11 Three.js 基础之灯光.md” 中已写好的代码。
index.scss
.full-screen,canvas {
display: block;
height: inherit;
width: inherit;
}
.split {
position: fixed;
display: flex;
width: inherit;
height: inherit;
}
.split div {
width: inherit;
height: inherit;
}
index.tsx
import { useRef, useEffect } from 'react'
import * as Three from 'three'
import createScene from '@/components/hello-light/create-scene'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import './index.scss'
const HelloCamera = () => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const sceneRef = useRef<Three.Scene | null>(null)
const leftViewRef = useRef<HTMLDivElement>(null)
const rightViewRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (canvasRef.current === null || leftViewRef.current === null || rightViewRef.current === null) {
return
}
const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current as HTMLCanvasElement })
renderer.setScissorTest(true)
const scene = createScene()
scene.background = new Three.Color(0x000000)
sceneRef.current = scene
const light = new Three.DirectionalLight(0xFFFFFF, 1)
light.position.set(0, 10, 0)
light.target.position.set(5, 0, 0)
scene.add(light)
scene.add(light.target)
const leftCamera = new Three.PerspectiveCamera(45, 2, 5, 100)
leftCamera.position.set(0, 10, 20)
const helper = new Three.CameraHelper(leftCamera)
scene.add(helper)
const leftControls = new OrbitControls(leftCamera, leftViewRef.current)
leftControls.target.set(0, 5, 0)
leftControls.update()
const rightCamera = new Three.PerspectiveCamera(60, 2, 0.1, 200)
rightCamera.position.set(40, 10, 30) //为了能够看清、看全镜头,所以将右侧镜头的位置设置稍远一些
rightCamera.lookAt(0, 5, 0)
const rightControls = new OrbitControls(rightCamera, rightViewRef.current)
rightControls.target.set(0, 5, 0)
rightControls.update()
const setScissorForElement = (div: HTMLDivElement) => {
if (canvasRef.current === null) {
return
}
//获得 canvas 和 div 的矩形框尺寸和位置
const canvasRect = canvasRef.current.getBoundingClientRect()
const divRect = div.getBoundingClientRect()
//计算出裁切框的尺寸和位置
const right = Math.min(divRect.right, canvasRect.right) - canvasRect.left
const left = Math.max(0, divRect.left - canvasRect.left)
const bottom = Math.min(divRect.bottom, canvasRect.bottom) - canvasRect.top
const top = Math.max(0, divRect.top - canvasRect.top)
const width = Math.min(canvasRect.width, right - left)
const height = Math.min(canvasRect.height, bottom - top)
//将剪刀设置为仅渲染到画布的该部分
const positiveYUpBottom = canvasRect.height - bottom
renderer.setScissor(left, positiveYUpBottom, width, height)
renderer.setViewport(left, positiveYUpBottom, width, height)
//返回外观
return width / height
}
const render = () => {
if (leftCamera === null || rightCamera === null || sceneRef.current === null) {
return
}
const sceneBackground = sceneRef.current.background as Three.Color
//渲染 左侧 镜头
const leftAspect = setScissorForElement(leftViewRef.current as HTMLDivElement)
leftCamera.aspect = leftAspect as number
leftCamera.updateProjectionMatrix()
helper.update()
helper.visible = false
sceneBackground.set(0x000000)
renderer.render(sceneRef.current, leftCamera)
//渲染 右侧 个镜头
const rightAspect = setScissorForElement(rightViewRef.current as HTMLDivElement)
rightCamera.aspect = rightAspect as number
rightCamera.updateProjectionMatrix()
helper.visible = true
sceneBackground.set(0x000040)
renderer.render(sceneRef.current, rightCamera)
window.requestAnimationFrame(render)
}
window.requestAnimationFrame(render)
const handleResize = () => {
if (canvasRef.current === null) {
return
}
const width = canvasRef.current.clientWidth
const height = canvasRef.current.clientHeight
renderer.setSize(width, height, false)
}
handleResize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [canvasRef])
return (
<div className='full-screen'>
<div className='split'>
<div ref={leftViewRef}></div>
<div ref={rightViewRef}></div>
</div>
<canvas ref={canvasRef} />
</div>
)
}
export default HelloCamera
执行以后,就会看到浏览器中左右 2 个可交互的画面。其中右侧画面中包含左侧灯光辅助对象。
镜头示例 2:OrthographicCamera
第二个比较经常用的镜头是 OrthographicCamera(正交镜头)。
正交镜头与透视镜头最大的区别点在于:
-
正交镜头的视椎体不是 椎体,而是立方体
-
正交镜头看到的都是一个“面”
-
因此,正交镜头没有 “透视(近大远小)”这个概念
我对于以上 2 点理解并不深,先记住以后再慢慢研究
OrthographicCamera 的用途
- 用途 1:作为 2D 画布
- 作为 3D 建模程序 的 上、下、左、右、前、后 视图。
OrthographicCamera 基本用法
const camera = new Three.OrthographicCamera(-1, 1, 1, -1, 5, 50)
camera.zoom = 0.2
camera.position.set(0,10,20)
初始化时,构造函数内的参数依次是:
OrthographicCamera( left : Number, right : Number, top : Number, bottom : Number, near : Number, far : Number )
- left:视椎体左侧面
- right:视椎体右侧面
- top:视椎体顶面
- bottom:视椎体底面
- near:视椎体近端面
- far:视椎体远端面
从实际的角度来看,一定要注意以下几点:
- left 的值不能大于 right,同理 bottom 的值不能大于 top。 如果没有按照这个约定,例如 bottom 大于 top 相当于颠倒了相机。
- near 设置越小,投影的映像越大
- left 与 right 之间的距离、top 与 bottom 之间的距离的比例一定要和 canvas 比例相同,否则会导致投影的物体形状变形
正交镜头与透视镜头的几点不同地方
渲染时,对应的设置不同。
透视镜头渲染时,需要修改的是 camera.aspect = newAspect
正交镜头渲染时,需要修改的是 camera.left = - new Aspect、camera.right = new Aspect
假设我们在 HelloCamera 示例中使用 OrthographicCamera
需要修改的地方为:
- const leftCamera = new Three.PerspectiveCamera(45, 2, 5, 100)
- leftCamera.position.set(0, 10, 20)
+ const leftCamera = new Three.OrthographicCamera(-1, 1, 1, -1, 5, 50)
+ leftCamera.zoom = 0.2
leftCamera.position.set(0,10,20)
...
const leftAspect = setScissorForElement(leftViewRef.current as HTMLDivElement)
- leftCamera.aspect = leftAspect as number
+ leftCamera.left = -(leftAspect as number)
+ leftCamera.right = leftAspect as number
leftCamera.updateProjectionMatrix()
其他代码无需修改,发布调试,即可看到正交镜头辅助对象,此时的视椎不再是椎体,而是一个立方体。
关于其他镜头:ArrayCamera、CubeCamera、StereoCamera 本文不再讲解,以后用到的时候再深入研究。
具体的用法,可查阅:https://threejs.org/docs/index.html#api/zh/cameras/Camera
至此,关于镜头的基础知识讲解完毕。
虽然本文是在讲镜头,但本文的核心知识点却是在讲 渲染器的裁切 功能。
一定要多多复习,熟练掌握 渲染器的 setScissor()、setScissorTest()、setViewport() 方法。
下一节,我们将讲解 Shardown(阴影)