Day 109 · 阴影与描边 — 让车间"立得住、看得清"
Day 101 的三灯法则给了立体感,但设备脚下没有影子——浮在空中的观感。今天的两个主题:**阴影(Shadow)**让设备"踩"在地上,**描边(OutlinePass)**让选中设备"发光圈"——同时替换掉 Day 104 那个"换材质"的土办法。这是本周"氛围"的分水岭:前八天做"真实",今天做"可信"。做完,你的车间从"展示模型"变成"像真的厂房"。
目录
一、阴影的意义:从"浮着"到"踩实"
1.1 没有阴影的三大违和
① 设备像"贴图",没有重量感、踩不住地
② 设备间距无法感知(没有投影帮助判断高度)
③ 整体像"测试场景",不像"车间"
阴影是空间关系的视觉语言——它告诉人"什么东西在什么上面、离地多高、互相多近"。工业大屏里,阴影一开,场景立刻"可信"。
1.2 阴影的成本先知道
阴影 = 渲染两次:先"从光源视角"画一遍深度图,再正常渲染时对照
→ 每盏开阴影的光 = 多一次场景渲染
→ 阴影不是免费的,是本周第一个要"量力而行"的效果
二、阴影的两端开关:castShadow / receiveShadow
阴影是"两端"的契约——光源、投影物体、接影物体三处都要开,缺一不可:
// ① 光源端:光源要"会投阴影"
sun.castShadow = true;
// ② 投影端:设备要"投出阴影"
model.traverse((c) => {
if (c instanceof THREE.Mesh) c.castShadow = true;
});
// ③ 接影端:地面要"接住阴影"
floor.receiveShadow = true;
// ④ 渲染器要"开启阴影"
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // 柔和阴影(默认硬边)
记忆钩子:阴影的三端开关 = 光 + 物 + 地。哪一端漏了,影子就出不来——这是排查"没阴影"的第一顺序。
三、阴影的配置:质量、范围与抖动
3.1 方向光阴影的"相机"
方向光的阴影其实是用一个正交相机从光源角度拍的深度图——mapSize 决定这张图的分辨率,直接决定阴影清晰度:
// 方向光阴影参数(以 Day 101 的 sun 为例)
sun.shadow.mapSize.set(1024, 1024); // 阴影贴图分辨率(512 粗糙 / 2048 细腻)
sun.shadow.camera.near = 0.5; // 阴影相机近远
sun.shadow.camera.far = 50;
sun.shadow.camera.left = -15; // 阴影覆盖范围(正交相机左右上下)
sun.shadow.camera.right = 15;
sun.shadow.camera.top = 15;
sun.shadow.camera.bottom = -15;
sun.shadow.bias = -0.0005; // 阴影偏移:防"阴影条纹/抖闪"(坑 2)
3.2 三个关键旋钮的取舍
四、阴影的代价与性能纪律
4.1 阴影的显性成本
每盏开阴影的光:额外一次深度渲染(一整套场景画两遍)
阴影贴图:显存占用(mapSize² × 4 字节)
4.2 本周纪律
一句话:车间只留一盏阴影光,mapSize 按需,范围贴场景。阴影是"锦上添花",不是"多多益善"(红线第 4 条:性能纪律)。
五、后处理管线:EffectComposer
5.1 什么是后处理
正常渲染 = 画一遍直接上屏。后处理 = 画一遍 → 在"离屏画布"上做特效 → 再合成上屏。描边、辉光、模糊、颜色分级都是后处理。
5.2 EffectComposer 基本用法
# 后处理在 three/examples 里(随 three 安装)
# import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
# import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
// 1. 创建合成器(接管渲染器)
const composer = new EffectComposer(renderer);
// 2. 第一道:正常渲染(RenderPass)
composer.addPass(new RenderPass(scene, camera));
// 3. 后续特效按序叠加(描边、辉光……)
// const outlinePass = new OutlinePass(...); composer.addPass(outlinePass);
// 4. 循环里:不再 renderer.render,改 composer.render(关键!)
const tick = () => {
// ...
composer.render(); // ← 替代 renderer.render(scene, camera)
rafId = requestAnimationFrame(tick);
};
最容易踩的坑:接了 composer 还在循环里调
renderer.render——特效永远不显示(渲染被替换了)。记住:用了 composer,渲染入口就换成composer.render()。
六、OutlinePass:选中设备的"光圈"
6.1 为什么替代 Day 104 的"换材质"
6.2 接入与使用
// 初始化:OutlinePass(尺寸, scene, camera, 选中对象数组)
const outlinePass = new OutlinePass(
new THREE.Vector2(canvas.clientWidth, canvas.clientHeight),
scene,
camera
);
outlinePass.edgeStrength = 4; // 描边强度
outlinePass.edgeGlow = 0.6; // 外发光
outlinePass.edgeThickness = 1.5; // 描边粗细
outlinePass.visibleEdgeColor.setHex(0x00c6ff); // 可见描边色(主色)
outlinePass.hiddenEdgeColor.setHex(0x16233a); // 隐藏描边色
composer.addPass(outlinePass);
// 选中设备时:把它的模型加进"描边对象列表"
outlinePass.selectedObjects = [selectedModel]; // 只描这一个
// 取消选中:
// outlinePass.selectedObjects = [];
6.3 与事件桥衔接(Day 104 契约不变)
// 出向桥回调里:引擎内部把"选中的模型"喂给 outlinePass
engine.events.on((e) => {
if (e.type === "select") {
const model = pickables.find((m) => m.userData.deviceId === e.deviceId);
outlinePass.selectedObjects = model ? [model] : [];
}
});
边界依然干净:Vue 只收到 id;描边的高亮逻辑(OutlinePass)完全在引擎内部。效果升级了,接口一个字没改——Day 103 契约的可替换性红利再次兑现。
七、实战:车间 + 阴影 + 描边
整合今天全部内容进引擎(Day 112 会在此基础上组装完整车间):
// src/core/three/create-atmosphere.ts(氛围装配:阴影 + 后处理)
import * as THREE from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
import { threeTheme as T } from "./theme";
/**
* 给引擎装配"氛围":阴影 + 描边后处理
* 返回:
* composer —— 循环里用它替代 renderer.render
* setOutline —— 选中设备描边(引擎内部调用,Vue 不感知)
* dispose —— 释放后处理资源
*/
export function createAtmosphere(
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.PerspectiveCamera,
canvas: HTMLCanvasElement
) {
// —— 阴影:渲染器开启 + 柔和阴影 ——
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// —— 后处理:渲染 → 描边 ——
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const outlinePass = new OutlinePass(
new THREE.Vector2(canvas.clientWidth, canvas.clientHeight), scene, camera
);
outlinePass.edgeStrength = 4;
outlinePass.edgeGlow = 0.6;
outlinePass.visibleEdgeColor.setHex(T.primary);
composer.addPass(outlinePass);
return {
composer,
setOutline(objects: THREE.Object3D[]) {
outlinePass.selectedObjects = objects;
},
dispose() {
outlinePass.dispose();
composer.dispose();
},
};
}
// —— 循环里:composer.render() 替代 renderer.render ——
// const atmosphere = createAtmosphere(renderer, scene, camera, canvas);
// const tick = () => { controls.update(); atmosphere.composer.render(); ... };
关键:
atmosphere.setOutline(...)是引擎内部的方法,只在事件桥回调里被调用——描边是"引擎的显示细节",与 Vue 无关(Day 103 的边界精神)。
八、常见坑点
坑 1:没阴影
排查:① 渲染器 shadowMap.enabled?② 光源 castShadow?③ 投影物 castShadow?④ 接影物 receiveShadow?——三端 + 渲染器,四处都要。
坑 2:阴影抖动/条纹
原因:深度精度不足,阴影在边缘"打架"。修法:shadow.bias 微调(-0.0005 起步)。
坑 3:阴影一大片糊
原因:shadow.camera 范围远大于场景。修法:把 left/right/top/bottom 贴住场景实际范围(第三节)。
坑 4:接了 composer 特效不显示
原因:循环还在调 renderer.render。修法:换成 composer.render()(第五节)。
坑 5:全开阴影性能崩
原因:多盏光都开阴影,每盏多一次场景渲染。修法:只留一盏主光开阴影(第四节纪律)。
九、自测挑战
T1 · 阴影开起来(50 分钟)
给车间开阴影:主光 castShadow + 设备投影 + 地面接影。调 mapSize(512/1024/2048)截三张对比图——把"清晰度 vs 性能"的权衡写进笔记。
T2 · 描边高亮(50 分钟)
完成第五、六节:接入 composer + OutlinePass,点击设备出现"主色光圈"。替换 Day 104 的换材质方案——确认事件桥接口零改动。
T3 · 条纹实验(30 分钟)
故意把 bias 设成 0 观察条纹,再加 -0.0005/-0.001 观察改善。亲手制造并修好坑 2。
T4 · 性能记录(30 分钟)
开阴影 + 描边后重测 FPS/显存,与 Day 106/107 的数值对比,记录"氛围的代价"。这份数据是 Day 112 验收的预算依据。
十、总结
车间踩实了、选中设备会发光圈了。明天给设备装上"动作"——关键帧动画。
明日预告:Day 110 动画系统——AnimationMixer 与关键帧动画:模型自带动画怎么播、代码里怎么调动作、以及"设备动作"(叶片转/指示灯闪)怎么编排进渲染循环(Day 100 时间纪律的进阶应用)。