《Cocos 游戏开发高级编程》4.2 阴影与光照贴图

光被挡住怎么办、光能不能提前算好?本节讲 2D 阴影的两条实现路线——投影剪影与阴影贴图,怎样用 RenderTexture 承载一个专用阴影相机,以及把光提前烘进贴图的预烘焙做法,并附一个可单测的烘焙器骨架、动态阴影与预烘焙的取舍表和工程决策清单。

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,tag 3.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 自定义装配与渲染纹理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画