第 10 篇:灯光与阴影

第 10 篇:灯光与阴影

本文是"使用 Three.js 构建转炉数字孪生系统"系列的第 10 篇。

本篇将系统介绍环境光、半球光、方向光、点光源、聚光灯和阴影,并给出适合转炉工业场景的基础布光方案与模型发黑排查流程。

在前面的文章中,我们已经学习了:

text 复制代码
Geometry
Material
Mesh
PBR 材质
纹理
环境贴图

当模型使用下列受光材质时:

text 复制代码
MeshLambertMaterial
MeshPhongMaterial
MeshStandardMaterial
MeshPhysicalMaterial

灯光会直接决定模型的:

  • 明暗;
  • 体积感;
  • 高光;
  • 金属反射层次;
  • 空间前后关系;
  • 阴影;
  • 高温区域局部照明。

如果灯光配置错误,模型可能出现:

text 复制代码
模型全黑
模型整体过暗
模型过曝发白
金属看起来像塑料
背光面完全不可见
阴影缺失
阴影只显示一部分
阴影边缘锯齿
阴影漂浮
表面出现黑色斑点
帧率明显下降

因此,工业三维场景的灯光并不是"多加几盏灯就会更好",而是要在材质、方向、阴影范围和性能之间取得平衡。


一、本文要解决的问题

完成本文后,应当能够回答:

  • 为什么 MeshBasicMaterial 不需要灯光;
  • 环境光和半球光有什么区别;
  • 为什么方向光通常适合作为工业场景主光;
  • DirectionalLight.positiontarget 如何决定方向;
  • 点光源怎样进行距离衰减;
  • 聚光灯的 anglepenumbratarget 有什么作用;
  • 哪些灯光可以产生阴影;
  • castShadowreceiveShadow 分别设置在哪里;
  • 阴影贴图怎样工作;
  • 为什么点光源阴影成本较高;
  • shadow.mapSize 越大是否越好;
  • 方向光阴影相机为什么需要限制范围;
  • biasnormalBias 如何处理阴影黑斑和悬浮;
  • 如何给转炉车间建立基础布光;
  • 模型发黑时应当按什么顺序排查。

二、灯光和材质的关系

1. 不受灯光影响的材质

下面这些材质通常不依赖场景灯光:

text 复制代码
MeshBasicMaterial
MeshNormalMaterial
部分自定义 ShaderMaterial

例如:

javascript 复制代码
const material =
  new THREE.MeshBasicMaterial({
    color: 0xff6600
  });

即使场景中没有任何灯光,它仍然可以显示。

MeshBasicMaterial 适合:

  • 调试;
  • 状态标记;
  • 标签底板;
  • 测点;
  • 线框;
  • 不希望受环境明暗影响的提示对象。

2. 受灯光影响的材质

下列材质需要有效灯光或环境照明:

text 复制代码
MeshLambertMaterial
MeshPhongMaterial
MeshStandardMaterial
MeshPhysicalMaterial

例如:

javascript 复制代码
const material =
  new THREE.MeshStandardMaterial({
    color: 0x64748b,
    metalness: 0.72,
    roughness: 0.66
  });

如果场景中没有灯光,也没有环境贴图,高金属度材质可能非常暗。

3. 灯光不只是提高亮度

灯光还负责让用户辨认:

text 复制代码
设备正面与侧面
设备之间的前后关系
转炉炉口方向
氧枪与炉体距离
平台与地面高度
钢包与工位位置

如果场景只使用很强的环境光,虽然模型不会黑,但会失去方向性和体积感。


三、Light 基础类

Three.js 中的具体灯光都继承自:

javascript 复制代码
THREE.Light

常见公共属性:

javascript 复制代码
light.color
light.intensity
light.visible
light.position
light.parent
light.children

创建方向光:

javascript 复制代码
const light =
  new THREE.DirectionalLight(
    0xffffff,
    3
  );

light.position.set(
  8,
  15,
  10
);

scene.add(light);

不同灯光类型的强度尺度和物理含义不同,因此不能简单认为:

text 复制代码
DirectionalLight intensity = 3
和
PointLight intensity = 3

会产生相同亮度。


四、环境光 AmbientLight

1. 环境光是什么

环境光均匀影响整个场景:

javascript 复制代码
const ambientLight =
  new THREE.AmbientLight(
    0xffffff,
    0.35
  );

scene.add(ambientLight);

它没有:

  • 位置;
  • 明确方向;
  • 距离衰减;
  • 阴影。

2. 环境光不能产生阴影

环境光没有明确的入射方向,因此无法判断阴影应该投向哪里。

即使写:

javascript 复制代码
ambientLight.castShadow = true;

也不会得到正常阴影。

3. 环境光的作用

环境光适合:

  • 抬高整体亮度;
  • 避免背光面完全黑暗;
  • 调试材质;
  • 低成本模式中的基础补光。

4. 环境光过强的问题

如果环境光强度过高:

javascript 复制代码
const ambientLight =
  new THREE.AmbientLight(
    0xffffff,
    5
  );

可能导致:

  • 正面与背面亮度相近;
  • 设备失去体积感;
  • 阴影对比度降低;
  • 画面整体发灰发白;
  • PBR 材质显得平淡。

所以环境光更适合做补光,而不是唯一主光。


五、半球光 HemisphereLight

1. 半球光是什么

半球光使用天空颜色和地面颜色模拟环境照明:

javascript 复制代码
const hemisphereLight =
  new THREE.HemisphereLight(
    0xdbeafe,
    0x332c27,
    0.8
  );

scene.add(hemisphereLight);

参数含义:

text 复制代码
skyColor
→ 上方环境颜色

groundColor
→ 下方环境颜色

intensity
→ 光照强度

2. 半球光的特点

朝上的表面更多受到 skyColor 影响,朝下的表面更多受到 groundColor 影响。

因此,它比完全均匀的环境光更容易产生上下层次。

3. 半球光不能产生阴影

HemisphereLight 主要用于环境补光,不能投射阴影。

4. 为什么适合工业厂房

工业厂房通常具有:

  • 顶部天窗或顶灯;
  • 暗色地面;
  • 顶部与底部不同的环境颜色;
  • 钢结构之间复杂的间接反射。

半球光能够以较低成本近似这种上下方向差异。

5. 环境光和半球光的选择

text 复制代码
AmbientLight
→ 所有方向完全一致

HemisphereLight
→ 上下颜色不同

转炉数字孪生中,通常可以优先使用低强度 HemisphereLight,再根据需要增加很弱的 AmbientLight。


六、方向光 DirectionalLight

1. 方向光是什么

方向光发出彼此平行的光线,常用于近似:

  • 太阳光;
  • 大范围主光;
  • 厂房顶部统一照明;
  • 大型展示主光。

创建:

javascript 复制代码
const mainLight =
  new THREE.DirectionalLight(
    0xffffff,
    3
  );

mainLight.position.set(
  12,
  20,
  10
);

scene.add(mainLight);

2. position 与 target

方向光的照射方向由:

text 复制代码
light.position
light.target.position

共同决定。

javascript 复制代码
mainLight.position.set(
  12,
  20,
  10
);

mainLight.target.position.set(
  0,
  4,
  0
);

scene.add(
  mainLight,
  mainLight.target
);

可以理解为:

text 复制代码
光线从 mainLight.position
指向 mainLight.target.position

3. rotation 通常不能直接控制方向

错误思路:

javascript 复制代码
mainLight.rotation.x =
  Math.PI / 2;

方向光是 Target Direct Light,应调整:

javascript 复制代码
mainLight.position
mainLight.target.position

4. target 需要加入场景树

如果要移动 target:

javascript 复制代码
mainLight.target.position.set(
  0,
  5,
  0
);

应确保:

javascript 复制代码
scene.add(mainLight.target);

或者把 target 加入正确的父节点。

5. DirectionalLightHelper

javascript 复制代码
const helper =
  new THREE.DirectionalLightHelper(
    mainLight,
    3
  );

scene.add(helper);

灯光或目标改变后:

javascript 复制代码
mainLight.target
  .updateMatrixWorld();

helper.update();

6. 为什么适合作为工业场景主光

方向光具有:

  • 大范围覆盖;
  • 统一光照方向;
  • 阴影方向稳定;
  • 阴影相机范围可控;
  • 比大量局部阴影灯更容易优化。

推荐结构:

text 复制代码
一盏 DirectionalLight
→ 负责主要照明和阴影

HemisphereLight
→ 环境补光

少量 PointLight / SpotLight
→ 高温和局部工位照明

七、点光源 PointLight

1. 点光源是什么

点光源从一个空间点向所有方向发光,可近似:

  • 裸灯泡;
  • 炉口高温光;
  • 火焰中心;
  • 小型警示灯;
  • 局部设备灯。
javascript 复制代码
const furnaceLight =
  new THREE.PointLight(
    0xff5a1f,
    45,
    14,
    2
  );

furnaceLight.position.set(
  0,
  7,
  0
);

scene.add(furnaceLight);

构造参数:

text 复制代码
color
intensity
distance
decay

2. distance

javascript 复制代码
furnaceLight.distance = 14;

distance = 0 时,没有人为设置的最大距离。

distance > 0 时,灯光会在接近该范围时平滑衰减到无影响。

炉口局部光建议设置合理距离,避免整个厂房都被橙红色照亮。

3. decay

javascript 复制代码
furnaceLight.decay = 2;

物理上,点光源照度与距离平方大致呈反比:

E ∝ 1 r 2 E\propto\frac{1}{r^2} E∝r21

因此,通常保持:

text 复制代码
decay = 2

不要为了让灯照得更远而随意改变衰减规律,应优先检查:

  • 场景单位;
  • 灯光强度;
  • 灯光距离;
  • 曝光;
  • 光源位置。

4. PointLightHelper

javascript 复制代码
const helper =
  new THREE.PointLightHelper(
    furnaceLight,
    0.5
  );

scene.add(helper);

5. 点光源阴影成本较高

点光源向所有方向发光。为了获得完整阴影,阴影系统需要从多个方向生成阴影贴图。

常见实现相当于使用立方体贴图的六个方向,因此一个点光源阴影可能需要多次额外渲染场景。

工业场景中推荐:

text 复制代码
主要空间阴影
→ 方向光负责

大多数点光源
→ 不投射阴影

关键局部点光
→ 少量开启,并严格限制范围

八、聚光灯 SpotLight

1. 聚光灯是什么

聚光灯从一个点向一个方向发光,形成锥形区域。

适合:

  • 顶部射灯;
  • 检修照明;
  • 工位照明;
  • 摄像头补光;
  • 局部展示灯。
javascript 复制代码
const spotLight =
  new THREE.SpotLight(
    0xffffff,
    80,
    30,
    THREE.MathUtils.degToRad(30),
    0.4,
    2
  );

参数:

text 复制代码
color
intensity
distance
angle
penumbra
decay

2. angle

javascript 复制代码
spotLight.angle =
  THREE.MathUtils.degToRad(30);

angle 控制光锥角度,单位为弧度。

3. penumbra

javascript 复制代码
spotLight.penumbra = 0.4;

通常范围:

text 复制代码
0 到 1
text 复制代码
0
→ 边缘较硬

接近 1
→ 边缘更柔和

4. target

javascript 复制代码
spotLight.position.set(
  -8,
  16,
  7
);

spotLight.target.position.set(
  -2,
  3,
  0
);

scene.add(
  spotLight,
  spotLight.target
);

5. SpotLightHelper

javascript 复制代码
const helper =
  new THREE.SpotLightHelper(
    spotLight
  );

scene.add(helper);

参数改变后:

javascript 复制代码
helper.update();

6. 聚光灯阴影

SpotLight 可以投射阴影。相比点光源,它只需要覆盖一个锥形方向,阴影成本通常更容易控制。

但如果厂房中有数十盏射灯,也不应全部开启动态阴影。


九、哪些灯光可以产生阴影

常用可投射阴影的灯光:

text 复制代码
DirectionalLight
PointLight
SpotLight

不能投射传统阴影的灯光:

text 复制代码
AmbientLight
HemisphereLight

另外,RectAreaLight 可以模拟矩形面光源,但常规 Three.js 实现中不直接支持阴影,并主要用于 PBR 材质。


十、阴影贴图原理

1. Three.js 常用 Shadow Map

阴影贴图的核心流程:

text 复制代码
第一步
从光源视角渲染投射阴影的对象

第二步
保存每个位置到光源的深度

第三步
从正常相机渲染场景

第四步
比较当前表面深度与阴影贴图深度

第五步
判断当前位置是否被其他物体挡住光线

2. 阴影需要额外渲染

如果场景有一盏投射阴影的方向光,通常会多一次从光源视角的场景渲染。

如果有多盏阴影灯,额外渲染次数继续增加。

因此:

可以有多盏灯,但通常只让极少数灯产生实时阴影。


十一、启用阴影的三个层级

1. 渲染器开启阴影

javascript 复制代码
renderer.shadowMap.enabled = true;

WebGLRenderer 的阴影默认通常没有启用。

2. 光源开启投射阴影

javascript 复制代码
mainLight.castShadow = true;

灯光必须是支持阴影的类型。

3. Mesh 设置投射或接收

投射阴影:

javascript 复制代码
mesh.castShadow = true;

接收阴影:

javascript 复制代码
mesh.receiveShadow = true;

地面一般只接收:

javascript 复制代码
ground.castShadow = false;
ground.receiveShadow = true;

4. 三个条件缺一不可

javascript 复制代码
renderer.shadowMap.enabled = true;
light.castShadow = true;
caster.castShadow = true;
receiver.receiveShadow = true;

如果其中任意一项缺失,阴影可能不显示。


十二、GLTF 模型开启阴影

只设置:

javascript 复制代码
gltf.scene.castShadow = true;

通常不能替代内部 Mesh 配置。

应遍历模型:

javascript 复制代码
gltf.scene.traverse((object) => {
  if (!object.isMesh) {
    return;
  }

  object.castShadow = true;
  object.receiveShadow = true;
});

但是,大型厂房模型不应对所有 Mesh 无条件开启阴影。

建议按类别筛选:

text 复制代码
关键动态设备
→ castShadow = true

地面和大型平台
→ receiveShadow = true

远景小零件
→ 关闭阴影

透明辅助对象
→ 通常关闭阴影

十三、阴影贴图类型

javascript 复制代码
renderer.shadowMap.type =
  THREE.PCFSoftShadowMap;

常见类型:

text 复制代码
BasicShadowMap
PCFShadowMap
PCFSoftShadowMap
VSMShadowMap

1. BasicShadowMap

  • 速度较快;
  • 阴影边缘较硬;
  • 锯齿较明显。

2. PCFShadowMap

  • 对阴影进行一定过滤;
  • 质量与性能较均衡;
  • 是常见基础选择。

3. PCFSoftShadowMap

  • 阴影边缘通常更柔和;
  • 适合普通工业可视化;
  • 不等同于真实面积光源软阴影。

4. VSMShadowMap

  • 能产生较柔和结果;
  • 可能出现漏光或模糊;
  • 需要针对具体场景测试。

十四、shadow.mapSize

1. 设置分辨率

javascript 复制代码
mainLight.shadow.mapSize.set(
  2048,
  2048
);

常见尺寸:

text 复制代码
512
1024
2048
4096

通常使用 2 的幂。

2. 分辨率越高不一定越合理

在相同覆盖范围内,mapSize 越高,阴影通常越清晰。

但代价包括:

  • 更多显存;
  • 更高渲染成本;
  • 更高带宽;
  • 移动设备压力。

阴影清晰度同时取决于:

text 复制代码
阴影贴图分辨率
÷
阴影相机覆盖范围

缩小覆盖范围通常比盲目升到 4096 更有效。


十五、方向光阴影相机

方向光使用正交阴影相机:

javascript 复制代码
mainLight.shadow.camera

1. 设置阴影范围

javascript 复制代码
const shadowCamera =
  mainLight.shadow.camera;

shadowCamera.left = -15;
shadowCamera.right = 15;
shadowCamera.top = 15;
shadowCamera.bottom = -15;
shadowCamera.near = 1;
shadowCamera.far = 50;

shadowCamera.updateProjectionMatrix();

只有处于阴影相机范围中的投射对象和接收区域,才能获得正确阴影。

2. 阴影被截断

如果阴影只显示一部分,常见原因是:

text 复制代码
对象或地面超出 shadow.camera 范围

方向光可能照亮整个场景,但阴影只会在阴影相机范围中生成。

3. CameraHelper

javascript 复制代码
const shadowCameraHelper =
  new THREE.CameraHelper(
    mainLight.shadow.camera
  );

scene.add(shadowCameraHelper);

检查:

  • 转炉是否在盒内;
  • 地面接收区域是否在盒内;
  • near 和 far 是否合理;
  • 范围是否过大。

4. 不要覆盖整个厂区

如果设置:

javascript 复制代码
shadowCamera.left = -1000;
shadowCamera.right = 1000;

虽然范围大,但每个阴影像素对应的世界面积也更大,阴影会变模糊。

实时阴影应聚焦当前转炉工作区。


十六、聚光灯和点光源阴影范围

1. SpotLight

javascript 复制代码
spotLight.shadow.mapSize.set(
  1024,
  1024
);

spotLight.shadow.camera.near = 0.5;
spotLight.shadow.camera.far = 40;

聚光灯阴影相机需要覆盖光锥。

2. PointLight

javascript 复制代码
pointLight.shadow.camera.near = 0.5;
pointLight.shadow.camera.far = 14;

点光源的 far 越大,覆盖范围越广,阴影精度和性能压力也越高。

炉口点光通常建议:

javascript 复制代码
furnaceLight.castShadow = false;

让方向光负责主要阴影。


十七、bias 与 normalBias

1. 阴影痤疮

表面出现密集黑色斑点或条纹,通常称为:

text 复制代码
Shadow Acne

它来自深度精度和表面自身比较误差。

2. bias

javascript 复制代码
mainLight.shadow.bias =
  -0.0001;

合理值可以减少表面黑斑。

但绝对值过大可能让阴影离开物体,形成阴影悬浮。

3. normalBias

javascript 复制代码
mainLight.shadow.normalBias =
  0.025;

它沿表面法线方向偏移阴影查询位置。

对曲面、斜面和大尺度设备表面通常很有用。

4. 推荐调节顺序

text 复制代码
1. 确认模型比例和场景单位
2. 缩小阴影相机范围
3. 设置合理 near 和 far
4. 调节 mapSize
5. 小幅调节 normalBias
6. 最后小幅调节 bias

不要直接使用很大的偏移值。


十八、动态阴影与静态阴影

默认情况下,阴影会随场景持续更新。

转炉倾动、氧枪升降和钢包移动时,需要更新阴影。

如果灯光和所有投射物都保持静止,可以:

javascript 复制代码
renderer.shadowMap.autoUpdate = false;

需要重新更新时:

javascript 复制代码
renderer.shadowMap.needsUpdate = true;

数字孪生项目可以采用混合策略:

text 复制代码
静态厂房
→ 烘焙光照或低成本接收阴影

转炉、氧枪、钢包
→ 保留动态阴影

远景和小零件
→ 不参与实时阴影

十九、工业场景基础布光

1. 推荐基础结构

text 复制代码
HDR 或 scene.environment
        +
低强度 HemisphereLight
        +
一盏 DirectionalLight 主光
        +
少量无阴影 PointLight / SpotLight

2. 环境层

javascript 复制代码
const hemisphereLight =
  new THREE.HemisphereLight(
    0xdbeafe,
    0x332c27,
    0.8
  );

scene.add(hemisphereLight);

作用:

  • 避免背面完全黑;
  • 提供顶部与地面环境差异;
  • 补充大型厂房间接照明。

如果已使用很亮的 HDR 环境,应降低半球光强度。

3. 主方向光

javascript 复制代码
const mainLight =
  new THREE.DirectionalLight(
    0xffffff,
    3
  );

mainLight.position.set(
  12,
  20,
  10
);

mainLight.target.position.set(
  0,
  4,
  0
);

mainLight.castShadow = true;

scene.add(
  mainLight,
  mainLight.target
);

作用:

  • 塑造设备体积;
  • 提供统一主阴影方向;
  • 覆盖转炉工作区。

4. 炉口高温光

javascript 复制代码
const furnaceLight =
  new THREE.PointLight(
    0xff5a1f,
    45,
    14,
    2
  );

furnaceLight.position.set(
  0,
  7,
  0
);

furnaceLight.castShadow = false;

scene.add(furnaceLight);

它应与:

text 复制代码
emissive 材质
Bloom
火焰和粒子特效

配合使用。

5. 检修聚光灯

javascript 复制代码
const maintenanceLight =
  new THREE.SpotLight(
    0xfff4df,
    70,
    25,
    THREE.MathUtils.degToRad(35),
    0.45,
    2
  );

maintenanceLight.position.set(
  -6,
  16,
  4
);

maintenanceLight.target.position.set(
  -2,
  3,
  0
);

maintenanceLight.castShadow = false;

scene.add(
  maintenanceLight,
  maintenanceLight.target
);

二十、完整转炉灯光与阴影示例

javascript 复制代码
import * as THREE from "three";

import {
  OrbitControls
} from "three/addons/controls/OrbitControls.js";

import "./style.css";

const app =
  document.querySelector("#app");

if (!app) {
  throw new Error(
    "未找到 #app 容器"
  );
}

const scene =
  new THREE.Scene();

scene.background =
  new THREE.Color(0x111827);

const camera =
  new THREE.PerspectiveCamera(
    55,
    window.innerWidth /
      window.innerHeight,
    0.1,
    200
  );

camera.position.set(
  16,
  12,
  20
);

const renderer =
  new THREE.WebGLRenderer({
    antialias: true
  });

renderer.setPixelRatio(
  Math.min(
    window.devicePixelRatio,
    2
  )
);

renderer.setSize(
  window.innerWidth,
  window.innerHeight
);

renderer.shadowMap.enabled =
  true;

renderer.shadowMap.type =
  THREE.PCFSoftShadowMap;

app.appendChild(
  renderer.domElement
);

const controls =
  new OrbitControls(
    camera,
    renderer.domElement
  );

controls.target.set(
  0,
  4,
  0
);

controls.enableDamping = true;

const hemisphereLight =
  new THREE.HemisphereLight(
    0xdbeafe,
    0x332c27,
    0.9
  );

scene.add(hemisphereLight);

const mainLight =
  new THREE.DirectionalLight(
    0xffffff,
    3
  );

mainLight.position.set(
  12,
  20,
  10
);

mainLight.target.position.set(
  0,
  4,
  0
);

mainLight.castShadow = true;

mainLight.shadow.mapSize.set(
  2048,
  2048
);

mainLight.shadow.camera.left =
  -14;

mainLight.shadow.camera.right =
  14;

mainLight.shadow.camera.top =
  14;

mainLight.shadow.camera.bottom =
  -14;

mainLight.shadow.camera.near =
  1;

mainLight.shadow.camera.far =
  50;

mainLight.shadow.normalBias =
  0.025;

mainLight.shadow.bias =
  -0.0001;

scene.add(
  mainLight,
  mainLight.target
);

const furnaceLight =
  new THREE.PointLight(
    0xff5a1f,
    45,
    14,
    2
  );

furnaceLight.position.set(
  0,
  7,
  0
);

furnaceLight.castShadow = false;

scene.add(furnaceLight);

const spotLight =
  new THREE.SpotLight(
    0xfff2d8,
    70,
    30,
    THREE.MathUtils.degToRad(32),
    0.45,
    2
  );

spotLight.position.set(
  -8,
  16,
  7
);

spotLight.target.position.set(
  -2,
  3,
  0
);

spotLight.castShadow = false;

scene.add(
  spotLight,
  spotLight.target
);

const ground =
  new THREE.Mesh(
    new THREE.PlaneGeometry(
      40,
      40
    ),
    new THREE.MeshStandardMaterial({
      color: 0x2f3540,
      metalness: 0,
      roughness: 0.92
    })
  );

ground.rotation.x =
  -Math.PI / 2;

ground.receiveShadow = true;
ground.castShadow = false;

scene.add(ground);

const converterRoot =
  new THREE.Group();

scene.add(converterRoot);

const converterPivot =
  new THREE.Object3D();

converterPivot.position.set(
  0,
  6,
  0
);

converterRoot.add(
  converterPivot
);

const converterMaterial =
  new THREE.MeshStandardMaterial({
    color: 0x646b73,
    metalness: 0.72,
    roughness: 0.66
  });

const converterMesh =
  new THREE.Mesh(
    new THREE.CylinderGeometry(
      2.4,
      2.8,
      5,
      48
    ),
    converterMaterial
  );

converterMesh.position.y =
  -1.7;

converterMesh.castShadow = true;
converterMesh.receiveShadow = true;

converterPivot.add(
  converterMesh
);

const hotMouth =
  new THREE.Mesh(
    new THREE.TorusGeometry(
      2.2,
      0.18,
      16,
      48
    ),
    new THREE.MeshStandardMaterial({
      color: 0x7c2d12,
      metalness: 0.15,
      roughness: 0.72,
      emissive: 0xff3d00,
      emissiveIntensity: 2
    })
  );

hotMouth.rotation.x =
  Math.PI / 2;

hotMouth.position.y =
  0.82;

converterMesh.add(
  hotMouth
);

const lance =
  new THREE.Mesh(
    new THREE.CylinderGeometry(
      0.12,
      0.12,
      9,
      16
    ),
    new THREE.MeshStandardMaterial({
      color: 0x98a2ad,
      metalness: 0.85,
      roughness: 0.38
    })
  );

lance.position.set(
  0,
  12,
  0
);

lance.castShadow = true;
lance.receiveShadow = true;

scene.add(lance);

const ladle =
  new THREE.Mesh(
    new THREE.CylinderGeometry(
      1.6,
      1.2,
      2.8,
      32
    ),
    new THREE.MeshStandardMaterial({
      color: 0x74452b,
      metalness: 0.45,
      roughness: 0.82
    })
  );

ladle.position.set(
  7,
  1.4,
  3
);

ladle.castShadow = true;
ladle.receiveShadow = true;

scene.add(ladle);

const directionalHelper =
  new THREE.DirectionalLightHelper(
    mainLight,
    3
  );

const shadowCameraHelper =
  new THREE.CameraHelper(
    mainLight.shadow.camera
  );

// 调试时启用
// scene.add(directionalHelper);
// scene.add(shadowCameraHelper);

const clock =
  new THREE.Clock();

function animate() {
  const elapsed =
    clock.getElapsedTime();

  converterPivot.rotation.z =
    THREE.MathUtils.degToRad(
      -25 +
      Math.sin(
        elapsed * 0.5
      ) * 40
    );

  lance.position.y =
    11 +
    Math.sin(elapsed) * 1.2;

  directionalHelper.update();

  controls.update();

  renderer.render(
    scene,
    camera
  );
}

renderer.setAnimationLoop(
  animate
);

function handleResize() {
  const width =
    window.innerWidth;

  const height =
    window.innerHeight;

  camera.aspect =
    width / height;

  camera.updateProjectionMatrix();

  renderer.setSize(
    width,
    height
  );
}

window.addEventListener(
  "resize",
  handleResize
);

二十一、模型发黑问题排查

1. 检查材质类型

javascript 复制代码
console.log(
  mesh.material.type
);

如果是受光材质,临时替换:

javascript 复制代码
mesh.material =
  new THREE.MeshBasicMaterial({
    color: 0xff0000
  });

如果 Basic 材质能正常显示,问题更可能在灯光、环境或原材质。

2. 检查灯光是否加入场景

javascript 复制代码
console.log(
  light.parent
);

如果没有父节点,可能忘记:

javascript 复制代码
scene.add(light);

3. 检查灯光强度

javascript 复制代码
console.log(
  light.intensity
);

强度为 0 时不会产生光照。

4. 检查方向光 target

使用:

javascript 复制代码
const helper =
  new THREE.DirectionalLightHelper(
    light
  );

scene.add(helper);

确认光线是否照向模型。

5. 检查法线

临时使用:

javascript 复制代码
mesh.material =
  new THREE.MeshNormalMaterial();

自定义 Geometry 可尝试:

javascript 复制代码
geometry.computeVertexNormals();

6. 检查负缩放

javascript 复制代码
mesh.scale.set(
  -1,
  1,
  1
);

负缩放可能影响法线、面朝向和阴影。应优先在建模工具中应用并修复镜像变换。

7. 检查正反面

调试时可以临时:

javascript 复制代码
material.side =
  THREE.DoubleSide;

如果双面后可见,说明可能存在法线或顶点绕序问题。正式模型应修复,而不是长期依赖双面材质。

8. 检查颜色和纹理

颜色贴图会与:

javascript 复制代码
material.color

相乘。

颜色贴图还应设置:

javascript 复制代码
texture.colorSpace =
  THREE.SRGBColorSpace;

9. 检查金属度与环境

高金属度材质主要反射环境。

如果:

javascript 复制代码
material.metalness = 1;
scene.environment = null;

模型可能非常暗。

可以临时降低金属度判断:

javascript 复制代码
material.metalness = 0.2;

10. 检查粗糙度

极低粗糙度会形成集中反射。普通转炉炉壳可以先从:

text 复制代码
roughness = 0.5 到 0.8

附近调试。

11. 检查曝光

javascript 复制代码
renderer.toneMappingExposure =
  1;

曝光过低会让整个场景发黑,不要只通过无限增加灯光强度补偿错误曝光。

12. 检查模型位置与尺寸

添加:

javascript 复制代码
const boxHelper =
  new THREE.BoxHelper(
    model,
    0xffff00
  );

scene.add(boxHelper);

确认模型没有:

  • 远离原点;
  • 位于其他模型内部;
  • 尺寸极小;
  • 超出相机范围;
  • 被雾完全覆盖。

二十二、阴影不显示排查

按以下顺序检查:

text 复制代码
1. renderer.shadowMap.enabled 是否为 true
2. 光源是否支持阴影
3. light.castShadow 是否为 true
4. caster.castShadow 是否为 true
5. receiver.receiveShadow 是否为 true
6. 投射物是否位于阴影相机范围
7. 接收面是否位于阴影相机范围
8. near 和 far 是否合理
9. 模型世界位置是否正确
10. 透明或自定义材质是否影响阴影
11. 阴影资源是否被关闭自动更新

可以使用:

javascript 复制代码
new THREE.CameraHelper(
  light.shadow.camera
);

直接观察阴影范围。


二十三、阴影质量问题排查

1. 阴影锯齿

尝试:

  • 缩小阴影相机范围;
  • 提高 mapSize
  • 使用 PCFSoftShadowMap
  • 调整 near 和 far;
  • 不要覆盖整个厂房。

2. 阴影模糊

可能原因:

  • 阴影范围过大;
  • mapSize 太低;
  • 阴影过滤方式;
  • 阴影相机没有聚焦关键区域。

3. 表面黑斑

尝试小幅调整:

javascript 复制代码
light.shadow.normalBias =
  0.02;

light.shadow.bias =
  -0.0001;

同时检查:

  • 重叠表面;
  • 法线;
  • near 和 far;
  • 模型比例。

4. 阴影悬浮

说明 biasnormalBias 可能过大。

还应检查模型是否真的接触地面。

5. 阴影抖动

可能来自:

  • 阴影范围过大;
  • 灯光持续移动;
  • 大世界坐标;
  • 模型远离原点;
  • 低分辨率阴影贴图。

大型厂房后续可以评估:

  • CSM;
  • 分区阴影;
  • 烘焙阴影;
  • 阴影相机跟随关键区域。

二十四、性能优化建议

1. 只保留一盏主要阴影灯

text 复制代码
DirectionalLight
→ 负责主要实时阴影

PointLight / SpotLight
→ 大多数只负责局部亮度

2. 缩小阴影范围

方向光阴影相机只覆盖转炉工作区,不覆盖整个厂区。

3. 控制 castShadow

优先让以下对象投射阴影:

  • 转炉主体;
  • 氧枪;
  • 钢包;
  • 大型平台;
  • 关键动态设备。

可以关闭:

  • 小螺栓;
  • 远处管线;
  • 标签;
  • 辅助器;
  • 小型装饰;
  • 不可见内部零件。

4. 控制 receiveShadow

主要接收对象:

  • 地面;
  • 平台;
  • 墙体;
  • 大型设备表面;
  • 关键空间关系对象。

5. 合理选择 mapSize

text 复制代码
低性能模式
→ 512 / 1024

普通桌面模式
→ 1024 / 2048

高质量局部展示
→ 2048 / 4096

不要把 4096 作为无条件默认值。

6. 静态光照烘焙

厂房、墙体和固定平台长期不动时,可以在建模阶段烘焙:

  • Light Map;
  • AO;
  • 固定阴影;
  • 环境光照。

实时阴影只留给动态设备。

7. 使用假阴影

远处车辆、人员或大量重复对象可以使用:

  • 半透明圆形平面;
  • ShadowMaterial;
  • Blob Shadow;
  • 预烘焙阴影纹理。

二十五、推荐灯光模块

javascript 复制代码
import * as THREE from "three";

export function createIndustrialLighting(
  scene
) {
  const root =
    new THREE.Group();

  root.name =
    "LightingRoot";

  scene.add(root);

  const hemisphereLight =
    new THREE.HemisphereLight(
      0xdbeafe,
      0x332c27,
      0.8
    );

  root.add(hemisphereLight);

  const mainLight =
    new THREE.DirectionalLight(
      0xffffff,
      3
    );

  mainLight.position.set(
    12,
    20,
    10
  );

  mainLight.target.position.set(
    0,
    4,
    0
  );

  mainLight.castShadow = true;

  mainLight.shadow.mapSize.set(
    2048,
    2048
  );

  mainLight.shadow.camera.left =
    -14;

  mainLight.shadow.camera.right =
    14;

  mainLight.shadow.camera.top =
    14;

  mainLight.shadow.camera.bottom =
    -14;

  mainLight.shadow.camera.near =
    1;

  mainLight.shadow.camera.far =
    50;

  mainLight.shadow.normalBias =
    0.025;

  root.add(
    mainLight,
    mainLight.target
  );

  const furnaceLight =
    new THREE.PointLight(
      0xff5a1f,
      45,
      14,
      2
    );

  furnaceLight.position.set(
    0,
    7,
    0
  );

  furnaceLight.castShadow = false;

  root.add(furnaceLight);

  return {
    root,
    hemisphereLight,
    mainLight,
    furnaceLight
  };
}

独立灯光模块便于:

  • 切换质量等级;
  • 切换昼夜模式;
  • 统一阴影参数;
  • 显示调试辅助器;
  • 页面销毁时统一释放资源。

二十六、本篇必须掌握的代码

环境光

javascript 复制代码
const light =
  new THREE.AmbientLight(
    0xffffff,
    0.35
  );

半球光

javascript 复制代码
const light =
  new THREE.HemisphereLight(
    0xdbeafe,
    0x332c27,
    0.8
  );

方向光

javascript 复制代码
const light =
  new THREE.DirectionalLight(
    0xffffff,
    3
  );

light.position.set(
  12,
  20,
  10
);

light.target.position.set(
  0,
  4,
  0
);

scene.add(
  light,
  light.target
);

点光源

javascript 复制代码
const light =
  new THREE.PointLight(
    0xff5a1f,
    45,
    14,
    2
  );

聚光灯

javascript 复制代码
const light =
  new THREE.SpotLight(
    0xffffff,
    80,
    30,
    THREE.MathUtils.degToRad(30),
    0.4,
    2
  );

启用阴影

javascript 复制代码
renderer.shadowMap.enabled = true;
light.castShadow = true;
mesh.castShadow = true;
ground.receiveShadow = true;

阴影贴图大小

javascript 复制代码
light.shadow.mapSize.set(
  2048,
  2048
);

方向光阴影范围

javascript 复制代码
light.shadow.camera.left = -15;
light.shadow.camera.right = 15;
light.shadow.camera.top = 15;
light.shadow.camera.bottom = -15;
light.shadow.camera.near = 1;
light.shadow.camera.far = 50;

阴影偏移

javascript 复制代码
light.shadow.normalBias = 0.02;
light.shadow.bias = -0.0001;

二十七、本文总结

Three.js 灯光可以分成环境照明和定向照明。

AmbientLight

text 复制代码
均匀照亮场景
没有方向
不能投射阴影

适合低强度补光。

HemisphereLight

text 复制代码
天空颜色与地面颜色渐变
不能投射阴影
具有上下环境层次

适合厂房和工业场景环境补光。

DirectionalLight

text 复制代码
平行光线
由 position 和 target 决定方向
可以产生阴影

通常是转炉场景主光和主阴影灯的首选。

PointLight

text 复制代码
从一点向所有方向发光
具有距离衰减
可以产生阴影
阴影成本较高

适合炉口和局部高温区域。

SpotLight

text 复制代码
沿锥形方向发光
具有 angle、penumbra 和 target
可以产生阴影

适合检修灯和工位照明。

阴影三道开关

text 复制代码
renderer.shadowMap.enabled
light.castShadow
mesh.castShadow / mesh.receiveShadow

必须同时正确设置。

阴影性能原则

text 复制代码
保留一盏主阴影方向光
限制阴影相机范围
只让关键对象投射阴影
大多数局部灯不投射阴影
静态场景尽量烘焙

模型发黑排查顺序

text 复制代码
材质类型
灯光是否加入场景
灯光强度
方向光 target
模型法线
负缩放
面朝向
颜色和纹理
金属度与环境贴图
粗糙度
曝光
模型位置和尺寸

最终可以用一句话概括:

环境光负责避免场景完全黑暗,方向光负责塑造主要体积和阴影,点光与聚光灯负责局部重点,而阴影必须在质量、覆盖范围和性能之间取得平衡。

对于转炉数字孪生,推荐从下面的结构开始:

text 复制代码
HDR 环境
+
低强度半球光
+
一盏方向主光和主阴影
+
少量无阴影局部灯

二十八、下一篇学习内容

下一篇建议介绍:

第 11 篇:GLTF/GLB 模型加载与工业模型管理

主要内容可以包括:

  • GLTF 与 GLB;
  • GLTFLoader;
  • 异步加载;
  • LoadingManager;
  • 加载进度;
  • 模型场景树;
  • 模型尺寸和坐标修正;
  • 节点查找;
  • 材质与纹理;
  • 动画;
  • DRACO;
  • Meshopt;
  • KTX2;
  • 模型缓存;
  • 模型释放;
  • 转炉模型接入既有控制层;
  • 生产环境路径和加载失败排查。

参考资料

相关推荐
小高0073 小时前
🔥🔥🔥TypeScript 7 正式版来了:别只看 10 倍速度,这 4 个迁移坑更值得注意
前端·javascript·面试
di24k24k3 小时前
多个 el-form 共用同一 ref 导致表单校验部分失效
前端·javascript·vue.js·elementui
JKooll4 小时前
WPS 表格 DISPIMG 函数实战:用 JavaScript 将图片真正嵌入单元格
javascript·wps
晴天164 小时前
Rust 快速入门 (从 JavaScript 开发者视角)-Day09
开发语言·javascript·rust
其美杰布-富贵-李5 小时前
第 6 篇:相机与 OrbitControls
前端·javascript·three.js
Hilaku6 小时前
当 AI 一天写完一周的代码,我们还剩什么优势?
前端·javascript·程序员
无责任此方_修行中6 小时前
换个版本号就能升级?可没那简单:pnpm 11 升级踩坑记
javascript·后端·npm
尘世中一位迷途小书童6 小时前
无人机航线里的云台角度怎么调?Cesium 做了个视锥 + 鹰眼联动
前端·javascript·数据可视化
张元清6 小时前
React useMeasure Hook:用 ResizeObserver 测量 DOM 元素 (2026)
javascript·react.js