4.2 阴影与光照贴图
4.1 解决了「一个像素被照亮多少」,但真实场景里光会被挡住——柱子后面要有阴影,角色脚下要有投影。阴影是光照的第二个问题,而且它有一个绕不开的性质:遮挡关系是全局的,无法只靠单个像素的法线算出来。
同时还有另一个方向:如果场景是静态的,光其实可以提前算好,运行时只做一次查表。这就是光照贴图。
这一节把这两条路都讲清楚,重点不是「怎么实现一个」,而是什么时候该走哪条。
4.2.1 先划清两条路线
| 路线 | 光什么时候算 | 遮挡怎么处理 | 典型场景 |
|---|---|---|---|
| 动态阴影 | 每帧实时算 | 投射体几何 + 阴影贴图 | 会动的角色、会转的火把 |
| 预烘焙光照贴图 | 构建期离线算 | 离线把阴影一起烘进贴图 | 静态场景、昼夜切换靠多层贴图 |
和 4.1 一样,先做一次证伪:Cocos Creator 3.8 的引擎里同样没有 2D 阴影组件,也没有面向 2D 的公开光照烘焙 API。cc.d.ts 里和光照贴图沾边的是 LightProbes / LightProbeGroup 这一支,它们服务的是 3D 场景;2D 侧既没有 ShadowCaster2D,也没有「烘焙 2D 光照」的入口。
所以这两条路线在 Cocos 里都得自己搭。区别在于:动态阴影要自己搭一套实时管线,预烘焙则可以把成本全部挪到构建期。
4.2.2 动态阴影的几何直觉
点光源下,一个矩形投射体在地面上投出的阴影是一个四边形:由矩形「背对光源」的那两个角,沿着「远离光源」的方向拉长而成。
先把这段几何在纯 TS 里写对。它不依赖引擎,可直接单测:
// shadow.ts —— 纯 TypeScript,零 cc 依赖
export interface Vec2 { x: number; y: number; }
export interface Rect { x: number; y: number; w: number; h: number; }
/** 返回矩形在点光源下投出的阴影四边形(顺时针四个点) */
export function shadowQuad(rect: Rect, light: Vec2, length: number): Vec2[] {
const corners: Vec2[] = [
{ x: rect.x, y: rect.y },
{ x: rect.x + rect.w, y: rect.y },
{ x: rect.x + rect.w, y: rect.y + rect.h },
{ x: rect.x, y: rect.y + rect.h },
];
const dir = corners.map((p) => {
const dx = p.x - light.x;
const dy = p.y - light.y;
const len = Math.hypot(dx, dy) || 1;
return { x: dx / len, y: dy / len };
});
// 找「张角最大」的一对相邻角 —— 它们构成轮廓边
let edge = 0;
let minDot = 2;
for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4;
const dot = dir[i].x * dir[j].x + dir[i].y * dir[j].y;
if (dot < minDot) { minDot = dot; edge = i; }
}
const j = (edge + 1) % 4;
const a = corners[edge];
const b = corners[j];
return [
a,
b,
{ x: b.x + dir[j].x * length, y: b.y + dir[j].y * length },
{ x: a.x + dir[edge].x * length, y: a.y + dir[edge].y * length },
];
}
配上单测,把「阴影一定落在光源反侧」这件事钉死:
// shadow.test.ts —— vitest
import { describe, it, expect } from 'vitest';
import { shadowQuad } from './shadow';
describe('2D 阴影几何', () => {
const rect = { x: 0, y: 0, w: 32, h: 32 };
it('光源在左时,阴影向右延伸', () => {
const q = shadowQuad(rect, { x: -100, y: 16 }, 200);
const maxX = Math.max(...q.map((p) => p.x));
expect(maxX).toBeGreaterThan(rect.w);
});
it('永远返回四个点', () => {
expect(shadowQuad(rect, { x: -100, y: 16 }, 200)).toHaveLength(4);
});
it('光源在上方时,阴影向下延伸', () => {
const q = shadowQuad(rect, { x: 16, y: -100 }, 200);
const maxY = Math.max(...q.map((p) => p.y));
expect(maxY).toBeGreaterThan(rect.h);
});
});
这段几何是后面一切的基础:把 shadowQuad 的四个点喂给 Graphics 或一个自定义网格,就得到了一块可渲染的阴影面。
4.2.3 阴影贴图:用 RenderTexture 承载阴影相机
只靠上面那块四边形,能处理「矩形挡光」这种简单情形。但一旦投射体是不规则形状、或者要处理多个光源互相遮挡,就需要更通用的做法——阴影贴图:让一个专门的相机把「投射体」渲染到一张 RenderTexture 里,得到一张「遮挡图」,再用材质把它投影到受光面上。
两种做法的分工很清晰:
| 做法 | 适用 | 代价 |
|---|---|---|
剪影投影(shadowQuad) | 少量规则形状、单一光源 | 顶点数随投射体增长 |
| 阴影贴图(RenderTexture) | 任意形状、多光源、需要软边 | 每帧一次离屏渲染 |
Cocos 侧的关键 API 是 Camera.targetTexture——把一个 RenderTexture 挂上去,这个相机的输出就不再上屏,而是画进那张纹理。下面的组件片段能过 tsc --noEmit:
import { _decorator, Camera, Color, Component, Layers, RenderTexture } from 'cc';
const { ccclass, property, requireComponent } = _decorator;
@ccclass('ShadowCasterCamera')
@requireComponent(Camera)
export class ShadowCasterCamera extends Component {
@property
shadowSize = 512;
private _rt: RenderTexture | null = null;
onEnable(): void {
const rt = new RenderTexture();
rt.reset({ width: this.shadowSize, height: this.shadowSize });
this._rt = rt;
const cam = this.getComponent(Camera);
if (!cam) return;
cam.targetTexture = rt;
// 只让相机看见投射体所在的那一层,其余层被裁掉
cam.visibility = Layers.makeMaskInclude([Layers.Enum.DEFAULT]);
cam.clearFlags = Camera.ClearFlag.SOLID_COLOR;
cam.clearColor = new Color(0, 0, 0, 0);
}
onDisable(): void {
const cam = this.getComponent(Camera);
if (cam) cam.targetTexture = null;
this._rt?.destroy();
this._rt = null;
}
}
三处容易踩坑:
targetTexture必须在相机激活时设置。相机在onEnable之后才真正参与渲染,提前设置会被内部状态覆盖。visibility是位掩码,不是层号。用Layers.makeMaskInclude([...])组装,比手写1 << n稳。RenderTexture有尺寸上限。声明里写明resize的范围是 1–2048(cocos/asset/assets/render-texture.ts,tag3.8.8),超过会被夹住,不要指望开 4096。
拿到这张遮挡图后,再写一个采样它的材质,把「被挡住」的区域压暗,就得到了实时阴影。材质写法与 4.1.6 同构,只是采样的是阴影图而不是法线图。
4.2.4 光照贴图:把光算在离线
如果场景是静态的,上面那套实时管线是浪费。预烘焙的思路是:在构建期用一套离线光照求解器(可以是简单的 CPU 逐像素,也可以是更复杂的全局光照)把整张场景图的光照算成一张或多张贴图,运行时精灵只是「叠」上去。
Cocos 里做 2D 预烘焙没有内置工具,典型做法是写一个 Node 脚本,输入是场景的几何与光源,输出是一张 PNG。下面是一个烘焙输出资源的目录结构示意,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现(产物要由编辑器导入并生成 .meta):
{
"assets/textures/lightmaps/level01_lm.png": "白天光照贴图",
"assets/textures/lightmaps/level01_lm_night.png": "夜晚光照贴图",
"assets/materials/lightmap-additive.mtl": "加法混合材质",
"userData": { "type": "texture", "wrapMode": "clamp-to-edge" }
}
要点:
- 光照贴图用
texture类型导入,不用sprite-frame——它是要喂给材质采样器的,不是独立精灵。 - 边缘用
clamp-to-edge,否则贴图接缝处会出现一圈漏光。 - 昼夜切换 = 换一张贴图,而不是重算光照。这是预烘焙最大的红利:运行时切换成本几乎为零。
运行时把它当一张覆盖全屏的精灵,用加法混合叠在场景上即可。这张「光」图不参与合批的破坏——因为它通常只有一层。
4.2.5 一个最小的烘焙器骨架
「烘焙」听起来很重,但它的核心就是「对每个像素,把每个光源的贡献加起来」。这段逻辑与引擎无关,可以先在纯 TS 里跑通:
// bake.ts —— 纯 TypeScript 烘焙器骨架,零 cc 依赖
export interface Vec2 { x: number; y: number; }
export interface Light {
pos: Vec2;
radius: number;
color: [number, number, number]; // 0–1
}
export interface BakeOptions {
lights: Light[];
ambient: number; // 环境光下限,避免纯黑
}
/** 把多个点光源烘焙进一张 RGBA 光照贴图 */
export function bake(
width: number, height: number, opts: BakeOptions,
): Uint8Array {
const out = new Uint8Array(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = opts.ambient;
let g = opts.ambient;
let b = opts.ambient;
for (const l of opts.lights) {
const d = Math.hypot(x - l.pos.x, y - l.pos.y);
if (d >= l.radius) continue;
const t = 1 - d / l.radius;
const att = t * t; // 平方衰减,与 4.1 一致
r += l.color[0] * att;
g += l.color[1] * att;
b += l.color[2] * att;
}
const i = (y * width + x) * 4;
out[i] = Math.min(255, r * 255);
out[i + 1] = Math.min(255, g * 255);
out[i + 2] = Math.min(255, b * 255);
out[i + 3] = 255;
}
}
return out;
}
// bake.test.ts —— vitest
import { describe, it, expect } from 'vitest';
import { bake } from './bake';
describe('光照烘焙', () => {
it('输出尺寸正确', () => {
const px = bake(8, 8, { lights: [], ambient: 0.1 });
expect(px.length).toBe(8 * 8 * 4);
});
it('光源正下方比远处亮', () => {
const px = bake(64, 64, {
lights: [{ pos: { x: 32, y: 32 }, radius: 40, color: [1, 1, 1] }],
ambient: 0,
});
const center = px[(32 * 64 + 32) * 4];
const corner = px[0];
expect(center).toBeGreaterThan(corner);
});
it('超过半径只有环境光', () => {
const px = bake(8, 8, {
lights: [{ pos: { x: -999, y: -999 }, radius: 1, color: [1, 1, 1] }],
ambient: 0.2,
});
expect(px[0]).toBe(Math.round(0.2 * 255));
});
});
这个骨架做的是直接光。真实的烘焙器还要处理遮挡(把 shadowQuad 的遮挡判断叠进来)与多次反弹(全局光照),但接口不变:输入光源与几何,输出一张 RGBA 贴图。骨架先在 vitest 里跑绿,再把它挪进构建期脚本,就得到了一个可复现的烘焙流程。
4.2.6 两条路线的取舍
把两条路线放在同一张表里对比:
| 维度 | 动态阴影 | 预烘焙光照贴图 |
|---|---|---|
| 运行时代价 | 每帧一次阴影渲染 + 一次采样 | 一次加法混合,接近零 |
| 构建期代价 | 无 | 烘焙时间 + 额外贴图体积 |
| 支持动态光源 | 是 | 否(光变了要重烘) |
| 支持动态物体 | 是 | 否(只对静态几何有效) |
| 包体影响 | 无 | 每套光照 +1 张贴图 |
| 实现复杂度 | 高(相机、层级、材质全套) | 中(离线脚本 + 叠加材质) |
一个折中的工程做法是混合:静态背景用光照贴图烘死,动态角色用动态阴影实时算。这样绝大多数像素是零成本的,只有少数动的东西在付实时代价。
4.2.7 工程决策
落到「什么时候用哪个」,三条经验:
- 场景不动、光也不动:无脑预烘焙。这是收益最大的场景,静态背景占了画面绝大部分像素。
- 场景不动、光会动(昼夜、可开关的灯):预烘焙几套贴图来回切,别上实时。切换贴图几乎免费。
- 有动态物体:只有这些物体用动态阴影。别为了几个会动的东西让整屏都走实时管线。
还有一条反面清单:不要给整屏几百个精灵都开动态阴影。卷二 13.1 的合批预算表在这里同样成立——每个阴影投射体都可能是一次额外的 Draw Call,累积起来就是帧率崩塌。
下一节换一个角度:不讨论「光」,而是讨论把渲染结果搬到另一张纹理上——RenderTexture 更通用的用法,做小地图、镜子、离屏合成与截图。
4.2.8 本节自检清单
- 确认过引擎里没有 2D 阴影组件,方案是自己搭的
- 阴影几何先在纯 TS + vitest 里跑绿,再搬进渲染
- 简单场景优先考虑剪影投影,不规则形状才上阴影贴图
-
targetTexture在相机激活后设置,onDisable里置回null -
visibility用Layers.makeMaskInclude组装,不是手写层号 -
RenderTexture尺寸在 1–2048 内 - 光照贴图用
texture类型导入,clamp-to-edge - 昼夜切换用换贴图,不是重算光照
- 烘焙器先在 vitest 里跑绿,再挪进构建期脚本
- 动态阴影只给动态物体,静态背景走预烘焙
阅读导航:上一节:4.1 2D 光照与法线 · 下一节:4.3 自定义装配与渲染纹理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。