【Three.js】day109-shadow-outline

作者:mario 发布时间: 2026-09-07 阅读量:4 评论数:0

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 三个关键旋钮的取舍

旋钮

调大

调小

原则

mapSize

更清晰

更模糊/锯齿

按需要,别无脑 2048×4

shadow.camera 范围

覆盖更广

更糊(同分辨率摊更大)

贴住场景,别包全宇宙

bias

消除条纹

可能"漏光/断影"

微调,-0.0005 起步


四、阴影的代价与性能纪律

4.1 阴影的显性成本

每盏开阴影的光:额外一次深度渲染(一整套场景画两遍)
阴影贴图:显存占用(mapSize² × 4 字节)

4.2 本周纪律

场景

建议

主光(DirectionalLight)

开阴影(唯一一盏)

环境光/补光

不开阴影(Ambient 本来就没方向)

设备数量大

mapSize 用 512~1024,或多设备共享一盏阴影光

性能不够

优先关 PointLight 阴影(多方向,最贵)

一句话:车间只留一盏阴影光,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 的"换材质"

方案

效果

问题

换材质(Day 104)

整体变色

① 要记原色(多设备混乱)② 没有"轮廓感"

OutlinePass

沿物体轮廓发光圈

① 无需动材质 ② 高亮选中=工业风标配

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 验收的预算依据


十、总结

环节

要点

阴影意义

空间关系的视觉语言——让设备"踩实"

三端开关

光源 castShadow + 投影 castShadow + 接影 receiveShadow + 渲染器开启

阴影配置

mapSize 清晰度 / camera 范围 / bias 防抖

性能纪律

只留一盏阴影光,mapSize 按需,范围贴场景

后处理

EffectComposer:渲染 → 特效 → 合成;循环改用 composer.render()

OutlinePass

选中对象发光圈——替换换材质方案,接口不变

车间踩实了、选中设备会发光圈了。明天给设备装上"动作"——关键帧动画。


明日预告:Day 110 动画系统——AnimationMixer 与关键帧动画:模型自带动画怎么播、代码里怎么调动作、以及"设备动作"(叶片转/指示灯闪)怎么编排进渲染循环(Day 100 时间纪律的进阶应用)。

评论