11.Three.js 基础之灯光
11 Three.js 基础之灯光
灯光的种类
在场景中添加灯光后,灯光照射在物体上产生明暗、光亮和阴影,从而让物体显得更加立体有光泽。
在有些文档或教程中,会把 灯光 称呼为 光源,这只是对
light 这个单词的不同翻译而已
如果我在本系列文章中,有时候使用 “灯光”,有时候使用 “光源”,请勿见怪。
向场景中添加灯光 这个行为,在传统
3D 软件制作中通常被称为 “打灯”
若场景中的物体由非反光材质构成,即使场景中没有任何光源,渲染后依然可以看见该物体。
非反光材质为
MeshBasicMaterial
若场景中的物体由反光材质构成,假设场景中没有任何光源,渲染出的结果将是一片漆黑,什么物体都看不见。
反光材质为
MeshPhongMaterial 等
灯光的种类
在
灯光类型 |
灯光名称 | 是否支持阴影 | 是否作用于全局 |
是否有照射目标 |
---|---|---|---|---|
AmbientLight | 环境光、氛围光 | 否 | 是 | 无 |
DirectionalLight | 平行光 | 是 | 否 | 有 |
HemisphereLight | 半球光源、户外光源 | 否 | 是 | 无 |
PointLight | 点光源 | 是 | 否 | 有 |
RectAreaLight | 矩形面光源 | 否 | 否 | 无 |
SpotLight | 聚光灯光源 | 是 | 否 | 有 |
补充说明
有个别文档或教程中,会把
事实上
补充说明
一共有
其他种类的光,都支持阴影:DirectionalLight、PointLight、SpotLight
关于
关于“阴影”的进一步补充:
在之前所有的示例中,当场景上有反光物体且有灯光时,物体会产生明暗,但是请注意:
-
这个“物体显示出的明暗”并不是真正的“阴影”。
在
Three.js 中 有真正的阴影对象,这个会在后面的 “Three.js 基础之阴影” 一文中有详细说明。 -
这个“物体显示出的明暗”并不是完全符合我们日常的“光影明暗”。
这是因为我们目前所有示例都使用的是“简单光照模型”,也就是说光照射在物体上后并不进行漫反射,所以渲染出的“明暗”并不完全自然合理。
-
默认渲染器并不会渲染阴影、默认支持阴影的灯光也不会投射阴影,若想产生真正的阴影,还需开启阴影渲染和投射。
具体如何开启,也会在后面的 “
Three.js 基础之阴影” 一文中有详细说明。
补充说明
只有环境光
补充说明
答:就是这个光除了光源本身之外,还包含一个
注意是 “可以添加” 灯光目标,而不是说 “必须也要添加” 灯光目标。
请注意上面表格中,关于 “是否产生阴影” 和 “是否有照射目标”,这
在
灯光类型 | 灯光名称 |
---|---|
LightProbe | 光探针 |
环境光探针 | |
半球光探针 |
关于 光探针,是另外一套比较复杂的光的算法方式。
根据我网上搜索到的一些信息,大概可以描述为:
传统的 环境光
光的辅助对象
场景中的光本身是不可见的,为了让我们方便观测光源,
所谓光的辅助对象,就是在渲染后出现的一些白色细线,这些白色细线指示出光源的位置、大小、以及光发射的方向。
光的辅助对象用法非常简单,
- 先创建 光 的实例
- 将创建好的光实例作为 辅助对象构造函数的参数
- 场景中添加 辅助对象即可
例如:
//创建并设置平行光
const directionalLight = new Three.DirectionalLight(0xFFFFFF, 1)
directionalLight.position.set(0, 10, 0);
directionalLight.target.position.set(-5, 0, 0)
//将平行光添加到场景中
scene.add(directionalLight)
scene.add(directionalLight.target)
//根据平行光实例,创建对应的辅助对象,并将辅助对象添加到场景中
const directionalLightHelper = new Three.DirectionalLightHelper(directionalLight)
scene.add(directionalLightHelper)
灯光作用与特点
通常仅作为基础光线,一般需要与其他灯光配合使用。不能产生阴影、也无需指定坐标位置,仅需设置颜色和强度。
注意:不支持阴影
最为经常使用的光源,光纤
经常使用
相对
一共接收
- 第
1 个参数:天空光线颜色 - 第
2 个参数:地面反射光颜色 - 第
3 个参数:光的反射强度
注意:不支持阴影
类似生活中的灯泡,光纤
注意:点光源对应的辅助对象
与
注意:
-
不支持阴影
-
只有
MeshStandardMaterial 和MeshPhysicalMaterial 材料才支持RectAreaLight 光源 -
按照官方文档描述,场景中必须加入
RectAreaLightUniformsLib.init() 目前我比较疑惑的是,经过试验发现,即使不添加
RectAreaLightUniformsLib.init() ,场景依然正常渲染,似乎看不出有任何差别
特别说明:
其他光辅助对象都是内置在
const directionalLightHelper = new Three.DirectionalLightHelper(directionalLight)
但是
import { RectAreaLightHelper } from 'three/examples/jsm/helpers/RectAreaLightHelper'
类似生活中的聚光灯效果。
关于每个灯光的具体参数详情、属性用法,请参考官方文档:https://threejs.org/docs/index.html#api/zh/lights/Light
接下来,我们将通过创建一个
这个示例中,会用到我们上节学习的 纹理 相关知识。
前期准备:OrbitControls 讲解、制作纹理图片
OrbitControls 的简介和用法
本示例中需要用到一个之前从未使用过的类:OrbitControls,先简单介绍一下这个类。
orbit 单词的翻译为:轨道controls 单词的翻译为:控制权
在手机端,不是鼠标,而是手指滑动
OrbitControls 的用法
const controls = new OrbitControls(camera, canvas) //创建一个实例
controls.target.set(0, 5, 0) //controls.target 为镜头的坐标系统
//controls.target.set(0, 5, 0) 的意思是:设置原点 Y 轴的坐标(以高出5米的轨道运行)
controls.update() //使控件使用新目标
请注意,在上面代码中,
说直白点,我们终于可以通过鼠标对
无论是通过 鼠标或键盘 来修改镜头轨道 都会触发
我们可以通过添加 事件监听 来捕获该事件:
const handleChange = () => { ... }
const controls = new OrbitControls(camera, canvasRef.current)
controls.addEventListener('change',handleChange)
对于目前的我们来说,是没有必要使用该事件的,在后续的
除此之外,我们还可以设置 禁止缩放、禁止旋转、禁止右键拖拽、设置可旋转角度范围等等一系列配置,具体的可查阅官方文档:https://threejs.org/docs/#examples/zh/controls/OrbitControls
特别注意:
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
提醒:在
OrbitControls.js 中,分别导出有OrbitControls 和MapControls ,所以引入OribitControls 是需要加 大括号{ } 。
补充说明:
严格意义上讲,
由于
在
Three.js 早期的版本中,代码包中并未包含OrbitControls ,若想使用还需要yarn 安装three-orbitcontrols 这个包。只不过当后来Three.js 包含了OrbitControls 之后,才再也无需额外安装了。
补充说明
原本
只不过
本质上 组件的
onKeydown 相当于React 的合成事件。
在
- 在
useEffect 中,当第一次挂载完成,添加canvasRef.current.fouce() ,让canvas 自动获得焦点 - 在
<canvas /> 中添加tabindex 属性,属性值为-1 、0、1 都无所谓,例如:<canvas tabindex={0} />
只有满足以上
还是继续使用 鼠标拖拽 来修改查看场景视角吧
补充说明
事实上如果你真的需要监听 鼠标键盘方向键 ,其实最简单的办法就是把
- const controls = new OrbitControls(camera, canvasRef.current)
+ const controls = new OrbitControls(camera, document.body)
纹理图片准备:制作纹理图片checker.png
本示例中需要用到一个类似
在
- 左上角和右下角 的那
1 像素中填充一个较黑的颜色 - 右上角和左下角 的那
1 像素中填充一个较白的颜色 - 将图片导出为
checker.png ,并保存到src/assets/imgs/ 目录中
补充说明:
虽然制作的纹理图片非常小,只有
- 设置纹理
magFilter 的属性值为Three.NearestFilter - 设置纹理
wrapS 、wrapT 属性值为Three.RepeatWrapping - 根据 黑白网格的尺寸,计算并设置纹理
repeat 重复次数
以上
灯光示例:HelloLight
先回顾一下
光的原型
示例目标:
- 使用并体验
Three.js 中除Light 和LightProbe 之外的其他8 种光类型 - 使用并体验 光的辅助对象
( XxxLightHelper)
补充说明:
- 环境光
AmbientLight 是没有 辅助对象的、其他光都有辅助对象 - 矩形光
RectAreaLight 的辅助对象RectAreaLightHelper 和其他光的辅助对象 引入方式不同
代码拆分与梳理:
1、create-scene.ts:创建基础场景
创建
具体代码梳理:
-
该场景中包含
1 个黑白网格的地面、1 个立方体、1 个球体,但是该场景不包含任何光。 -
createScene 创建基础场景时接收一个参数type ,type 只能为以下2 个值中的其中1 种:MESH_PHONE_MATERIAL 或MESH_STANDARD_MATERIAL -
type 默认值为MESH_PHONE_MATERIAL
进一步解释:
由于
- 当使用
RectAreaLight 时,我们告知createScene ,使用MeshStandardMaterial 材质创建 地面、立方体、球体 - 当使用 其他 光时,我们告知
createScene ,使用MeshPhongMaterial 材质创建 地面、立方体、球体
2、index.tsx:创建渲染器、镜头、以及不同种类的光
具体代码梳理:
- 当
canvas DOM 初始化后,创建 渲染器、镜头、镜头交互(OrbitControls) - 创建
8 个按钮,每个按钮对应一种光 - 使用
useState 创建一个变量type ,用来记录当前演示 光的类型 - 点击不同按钮后,修改 当前光的类型
type 的值,从而引发react 重新渲染 - 在新一轮的渲染中,通过判断
type 类型,使用createScene 创建一个新的场景 和 对应的 光
3、index.scss:设置相关样式
具体样式梳理:
- 设置
canvas 对应的样式 - 设置
8 个按钮对应的样式
具体的代码:
create-scene.ts:
import * as Three from 'three'
export enum MaterialType {
MESH_PHONE_MATERIAL = 'MESH_PHONE_MATERIAL',
MESH_STANDARD_MATERIAL = 'MESH_STANDARD_MATERIAL'
}
const createScene: (type?: keyof typeof MaterialType) => Three.Scene = (type = MaterialType.MESH_PHONE_MATERIAL) => {
const scene = new Three.Scene()
const planeSize = 40
const loader = new Three.TextureLoader()
const texture = loader.load(require('@/assets/imgs/checker.png').default)
texture.wrapS = Three.RepeatWrapping
texture.wrapT = Three.RepeatWrapping
texture.magFilter = Three.NearestFilter
texture.repeat.set(planeSize / 2, planeSize / 2)
let planeMat: Three.Material
let cubeMat: Three.Material
let sphereMat: Three.Material
switch (type) {
case MaterialType.MESH_STANDARD_MATERIAL:
planeMat = new Three.MeshStandardMaterial({
map: texture,
side: Three.DoubleSide
})
cubeMat = new Three.MeshStandardMaterial({ color: '#8AC' })
sphereMat = new Three.MeshStandardMaterial({ color: '#CA8' })
break
default:
planeMat = new Three.MeshPhongMaterial({
map: texture,
side: Three.DoubleSide
})
cubeMat = new Three.MeshPhongMaterial({ color: '#8AC' })
sphereMat = new Three.MeshPhongMaterial({ color: '#8AC' })
}
const planeGeo = new Three.PlaneBufferGeometry(planeSize, planeSize)
const mesh = new Three.Mesh(planeGeo, planeMat)
mesh.rotation.x = Math.PI * -0.5
scene.add(mesh)
const cubeGeo = new Three.BoxBufferGeometry(4, 4, 4)
const cubeMesh = new Three.Mesh(cubeGeo, cubeMat)
cubeMesh.position.set(5, 2.5, 0)
scene.add(cubeMesh)
const sphereGeo = new Three.SphereBufferGeometry(3, 32, 16)
const sphereMesh = new Three.Mesh(sphereGeo, sphereMat)
sphereMesh.position.set(-4, 5, 0)
scene.add(sphereMesh)
return scene
}
export default createScene
index.tsx:
import { useEffect, useRef, useState } from "react";
import * as Three from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
//import { RectAreaLightUniformsLib } from 'three/examples/jsm/lights/RectAreaLightUniformsLib'
import { RectAreaLightHelper } from "three/examples/jsm/helpers/RectAreaLightHelper";
import createScene, { MaterialType } from "./create-scene";
import "./index.scss";
enum LightType {
AmbientLight = "AmbientLight",
AmbientLightProbe = "AmbientLightProbe",
DirectionalLight = "DirectionalLight",
HemisphereLight = "HemisphereLight",
HemisphereLightProbe = "HemisphereLightProbe",
PointLight = "PointLight",
RectAreaLight = "RectAreaLight",
SpotLight = "SpotLight",
}
const buttonLables = [
LightType.AmbientLight,
LightType.AmbientLightProbe,
LightType.DirectionalLight,
LightType.HemisphereLight,
LightType.HemisphereLightProbe,
LightType.PointLight,
LightType.RectAreaLight,
LightType.SpotLight,
];
const HelloLight = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const sceneRef = useRef<Three.Scene | null>(null);
const [type, setType] = useState<LightType>(LightType.AmbientLight);
useEffect(() => {
if (canvasRef.current === null) {
return;
}
const renderer = new Three.WebGLRenderer({
canvas: canvasRef.current as HTMLCanvasElement,
});
const camera = new Three.PerspectiveCamera(45, 2, 0.1, 1000);
camera.position.set(0, 10, 20);
const controls = new OrbitControls(camera, canvasRef.current);
controls.target.set(0, 5, 0);
controls.update();
const scene = createScene();
sceneRef.current = scene;
const render = () => {
if (sceneRef.current) {
renderer.render(sceneRef.current, camera);
}
window.requestAnimationFrame(render);
};
window.requestAnimationFrame(render);
const handleResize = () => {
const canvas = canvasRef.current;
if (canvas === null) {
return;
}
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [canvasRef]);
useEffect(() => {
if (sceneRef.current === null) {
return;
}
sceneRef.current = null;
let newScene: Three.Scene;
if (type === LightType.RectAreaLight) {
newScene = createScene(MaterialType.MESH_STANDARD_MATERIAL);
} else {
newScene = createScene();
}
sceneRef.current = newScene;
switch (type) {
case LightType.AmbientLight:
const ambientLight = new Three.AmbientLight(0xffffff, 1);
newScene.add(ambientLight);
break;
case LightType.AmbientLightProbe:
const ambientLightProbe = new Three.AmbientLightProbe(0xffffff, 1);
newScene.add(ambientLightProbe);
break;
case LightType.DirectionalLight:
const directionalLight = new Three.DirectionalLight(0xffffff, 1);
directionalLight.position.set(0, 10, 0);
directionalLight.target.position.set(-5, 0, 0);
newScene.add(directionalLight);
newScene.add(directionalLight.target);
const directionalLightHelper = new Three.DirectionalLightHelper(
directionalLight
);
newScene.add(directionalLightHelper);
break;
case LightType.HemisphereLight:
const hemisphereLight = new Three.HemisphereLight(
0xb1e1ff,
0xb97a20,
1
);
newScene.add(hemisphereLight);
const hemisphereLightHelper = new Three.HemisphereLightHelper(
hemisphereLight,
5
);
newScene.add(hemisphereLightHelper);
break;
case LightType.HemisphereLightProbe:
const hemisphereLightProbe = new Three.HemisphereLightProbe(
0xb1e1ff,
0xb97a20,
1
);
newScene.add(hemisphereLightProbe);
break;
case LightType.PointLight:
const pointLight = new Three.PointLight(0xffffff, 1);
pointLight.position.set(0, 10, 0);
newScene.add(pointLight);
const pointLightHelper = new Three.PointLightHelper(pointLight);
newScene.add(pointLightHelper);
break;
case LightType.RectAreaLight:
//RectAreaLightUniformsLib.init() //实际测试时发现即使不添加这行代码,场景似乎也依然正常渲染,没有看出差异
const rectAreaLight = new Three.RectAreaLight(0xffffff, 5, 12, 4);
rectAreaLight.position.set(0, 10, 0);
rectAreaLight.rotation.x = Three.MathUtils.degToRad(-90);
newScene.add(rectAreaLight);
const rectAreaLightHelper = new RectAreaLightHelper(rectAreaLight);
newScene.add(rectAreaLightHelper);
break;
case LightType.SpotLight:
const spotLight = new Three.SpotLight(0xffffff, 1);
spotLight.position.set(0, 10, 0);
spotLight.target.position.set(-5, 0, 0);
newScene.add(spotLight);
newScene.add(spotLight.target);
const spotLightHelper = new Three.SpotLightHelper(spotLight);
newScene.add(spotLightHelper);
break;
default:
console.log("???");
break;
}
}, [type]);
return (
<div className="full-screen">
<div className="buttons">
{buttonLables.map((label, index) => {
return (
<button
className={label === type ? "button-selected" : ""}
onClick={() => {
setType(label);
}}
key={`button${index}`}
>
{label}
</button>
);
})}
</div>
<canvas ref={canvasRef} />
</div>
);
};
export default HelloLight;
create-scene.ts 和index.tsx 的代码非常多,并且我也没有写注释。
但是如果之前章节中的示例你也都跟着 敲一遍,应该很容易看懂 这两个文件里的代码。
index.scss:
.full-screen, canvas {
display: block;
height: inherit;
width: inherit;
}
.buttons {
display: flex;
justify-content: center;
width: 100%;
position: fixed;
top: 30px;
}
.buttons button {
width: 200px;
height: 30px;
margin-left: 20px;
font-size: 18px;
cursor: pointer;
}
.buttons button:first-child {
margin-left: 0;
}
.button-selected{
background-color:green;
color: white;
}
若一切正常,实际运行后:
- 点击网页中不同顶部的按钮,可以切换不同光对应的场景效果
- 点击并拖动鼠标 或 滚动鼠标滚轴,可以切换场景视角
遗留的疑惑
第1 个疑惑:RectAreaLightUniformsLib
按照官方文档的说法,在使用
为什么会这样?
第2 个疑惑:RectAreaLightHelper
实际运行发现,
矩形面灯光的代码为:
const rectAreaLight = new Three.RectAreaLight(0xFFFFFF, 5, 12, 4)
rectAreaLight.position.set(0, 10, 0)
rectAreaLight.rotation.x = Three.MathUtils.degToRad(-90)
newScene.add(rectAreaLight)
const rectAreaLightHelper = new RectAreaLightHelper(rectAreaLight)
newScene.add(rectAreaLightHelper)
我们对
额外的一些唠叨话
我是一边学习
本片文章的源头,对应的原始教程是:https://threejsfundamentals.org/threejs/lessons/threejs-lights.html
因为之前刚好学过 场景,按照当时编写的示例代码:
- 月球围绕地球
- 地球围绕太阳
- 太阳自转
我当时得出了一个结论:每一个
所以最初我想实现 光 示例时这样的:
- 一个主场景
- 主场景内,
2 行3 列 分布着6 个子场景 - 这
6 个子场景里,分别包含这6 中基础光源
按照这个目标,我编写了代码,结果渲染后的场景画面,完全不是我预期的,实际结果是:
当时我完全懵的状态。
经过
sceneB.add(lightB)
sceneA.add(sceneB)
以上代码最终真正的执行,会将
尤其假设
这也就解释了为什么 “
或许你会疑惑?你说的这些不都已经在
事实是我在进一步理解 场景、光 之后,又重新修改编辑了 之前章节的错误观点,所以你看到的时候都才是正确的。
我是一边学习,一边有新的知识领悟,然后再不断回头修改、补充之前文章中的相关知识点。
我唠叨的这些目的,其实想表达
- 学习
Three.js 的 类、函数、方法、属性时候,最好去看一下three.js 的源码,绝对会加深你的Three.js 理解和功力。 - 学习
Three.js 真的挺难,需要不断打破已有认知,若有些地方暂时无法理解也没关系,只要继续加油,终会搞明白的。
唠叨结束。
本文学习了 光
明天是
2020 年12 月19 日,阿里巴巴 前端D2 技术分享大会开幕,19 号、20 号 为期2 天的前端技术直播会议分享。
花
98 元买的直播观看门票,不能浪费了,所以,换换脑子,未来2 天暂停Three.js 的学习。