《Cocos 游戏开发高级编程》4.1 2D 光照与法线

Cocos Creator 3.8 的 cc.d.ts 里其实没有 Light2D——2D 光照在引擎里不是一个组件,而是一个着色器问题。本节先用 grep 证伪,再讲清法线贴图与 Lambert 漫反射的直觉,最后给出可直接单测的纯 TS 光照算法、把光参数喂给材质的组件,以及一个自定义 .effect。

4.1 2D 光照与法线

站内 graphics/ 有 45 篇讲光照模型与法线贴图的通用理论,这一节不重复那些推导,只回答一个更实际的问题:在 Cocos Creator 3.8 里做 2D 光照,到底要从哪里下手。

先给结论:Cocos 3.8 的引擎里没有 2D 光照组件。 这不是疏漏,而是引擎的一条明确边界——3D 有一套完整的 Light 体系(DirectionalLight / SphereLight / SpotLight),2D 则把光照完全交给 Effect 层。所以 2D 光照在 Cocos 里不是一个「勾选项」,而是一个「你自己写的着色器」。

这一节按「先证伪 → 讲原理 → 准备资源 → 纯 TS 算对 → 喂给材质 → 写 Effect → 工程取舍」的顺序走一遍。

4.1.1 先证伪:cc.d.ts 里没有 Light2D

很多教程一上来就写 import { Light2D } from 'cc',这是错的。在锁定版本 @cocos/creator-types@3.8.8 的类型声明里搜一遍:

grep -c "Light2D" node_modules/@cocos/creator-types/engine/cc.d.ts   # 输出 0

结果就是 0。再搜所有以 Light 开头的导出:

grep -nE 'export (class|interface) Light[A-Za-z0-9]*\b' \
  node_modules/@cocos/creator-types/engine/cc.d.ts

命中的是 Light、LightProbeInfo、LightProbeGroup、LightProbes 这些——全部是 3D 光照或光照探针,没有一个属于 2D。同一份声明里,2D 侧的渲染入口只有 Sprite / UIRenderer 这一支。

这个「证伪」本身就是本节最有价值的一步:它告诉你不要把时间浪费在找一个不存在的组件上。2D 光照的正解是:写一个 Effect,用一张法线贴图,逐像素算光照。 接下来把这件事拆开。

4.1.2 2D 光照在算什么

2D 画面是一堆带贴图的矩形。要让它「被照亮」,得给每个像素算出三件事:

量含义从哪里来
表面法线 N这个像素「朝哪」法线贴图(普通贴图没有)
光线方向 L从像素指向光源的单位向量光源位置 − 像素位置
光照结果亮度倍率max(0, N·L) 再乘颜色

这就是 Lambert 漫反射:N 与 L 越同向(点积越大),这个像素越亮;背对光源(点积为负)就压到 0。普通精灵贴图里没有 N,所以引擎只能把它当「永远正对屏幕」处理,光照就只能是均匀的。

把 N 存进一张额外的贴图,就是法线贴图。它的 RGB 三个通道分别编码法线的 x、y、z,取值范围 0–255 映射到 -1..1。2D 场景里通常只需要 x、y 两个方向(砖缝、布料褶皱、角色浮雕),z 由 z = sqrt(1 - x² - y²) 反推,或者干脆也存进 B 通道。

直觉版:法线贴图告诉着色器「这个像素看起来是朝哪的」,光源方向告诉它「光从哪来」,两者一夹,就知道该给多亮。凹凸感不是几何,是法线在骗眼睛。

4.1.3 准备一张法线贴图

法线贴图不是手画的,是从高度图烘焙的:美术做一张灰度图(白高黑低),用 Photoshop 的 NVIDIA NormalMap 滤镜、或 GIMP 的 Normal Map 插件、或 normalmap.js 这类工具转成法线图。关键是转换时选定一个凹凸强度——太弱没效果,太强边缘会发黑。

在 Cocos Creator 里导入这张图时,有一处必须改:把图片导入类型设成 normal map(而不是默认的 sprite-frame)。下面的导入设置片段需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:

{
  "importer": "image",
  "userData": {
    "type": "normal map",
    "hasAlpha": false
  }
}

选中法线图 → Inspector 的 Type 下拉选 normal map → 点「应用」。这一步的实质是让引擎不要做 sRGB 伽马校正——法线是方向数据,不是颜色数据,一旦被当颜色处理,N·L 的结果就会整体偏亮,光照「发灰」。

约定上,普通贴图放 assets/textures/,法线图与它同名加 _n 后缀放同目录,例如 assets/textures/brick.png 配 assets/textures/brick_n.png。卷二 2.3 讲过的 .meta 纪律在这里同样适用:两张图都要提交、都不要手改。

4.1.4 先在纯 TS 里把光照算对

在写着色器之前,先在不依赖引擎的纯 TypeScript 里把公式跑通——这样公式错没错,一个单测就能证。这段代码与引擎无关,可直接在 Node / vitest 里跑:

// light2d.ts —— 纯 TypeScript,零 cc 依赖
export interface Vec2 { x: number; y: number; }

/** 采样法线贴图里的一个像素,返回归一化到 -1..1 的法线 */
export function sampleNormal(
  normalMap: Uint8Array, width: number, x: number, y: number,
): [number, number, number] {
  const i = (y * width + x) * 4;
  const nx = (normalMap[i] / 255) * 2 - 1;
  const ny = (normalMap[i + 1] / 255) * 2 - 1;
  const nz = (normalMap[i + 2] / 255) * 2 - 1;
  // 贴图是 8 位量化,重新归一化能抵消一部分误差
  const len = Math.hypot(nx, ny, nz) || 1;
  return [nx / len, ny / len, nz / len];
}

/** Lambert:n·l,背面压到 0 */
export function diffuse(
  n: [number, number, number], l: [number, number, number],
): number {
  return Math.max(0, n[0] * l[0] + n[1] * l[1] + n[2] * l[2]);
}

/** 点光源衰减:距离越远越暗,超过半径归零 */
export function attenuation(
  pixel: Vec2, light: Vec2, radius: number,
): number {
  const d = Math.hypot(pixel.x - light.x, pixel.y - light.y);
  if (d >= radius) return 0;
  const t = 1 - d / radius;
  return t * t; // 平方衰减,比线性更接近真实观感
}

/** 合成一个像素的最终亮度倍率 */
export function shadePixel(
  n: [number, number, number], pixel: Vec2, light: Vec2,
  radius: number, intensity: number,
): number {
  const dx = light.x - pixel.x;
  const dy = light.y - pixel.y;
  const len = Math.hypot(dx, dy) || 1;
  const l: [number, number, number] = [dx / len, dy / len, 0];
  return diffuse(n, l) * attenuation(pixel, light, radius) * intensity;
}

配一个单测,把「正对光源最亮、背对为 0、超出半径归零」三件事钉死:

// light2d.test.ts —— vitest
import { describe, it, expect } from 'vitest';
import { diffuse, attenuation, shadePixel } from './light2d';

describe('2D 光照公式', () => {
  it('法线与光线同向时最亮', () => {
    expect(diffuse([1, 0, 0], [1, 0, 0])).toBeCloseTo(1);
  });
  it('背对光源时为零', () => {
    expect(diffuse([1, 0, 0], [-1, 0, 0])).toBe(0);
  });
  it('超出半径完全变暗', () => {
    expect(attenuation({ x: 0, y: 0 }, { x: 999, y: 0 }, 100)).toBe(0);
  });
  it('光源正上方时结果介于 0 与 intensity 之间', () => {
    const s = shadePixel([0, 0, 1], { x: 0, y: 0 }, { x: 0, y: 50 }, 200, 1);
    expect(s).toBeGreaterThan(0);
    expect(s).toBeLessThanOrEqual(1);
  });
});

先跑绿,再往引擎里搬。在 GPU 里 debug 光照公式的代价,比在 vitest 里高一个数量级——这是第 3 章反复出现的原则:能在纯 TS 验证的逻辑,绝不留给着色器。

4.1.5 把光参数喂给材质

引擎侧要做的只有一件事:把光源的位置、半径、强度作为 uniform 塞进材质。Cocos 里通过 Sprite.customMaterial 拿到材质,再调 Material.setProperty:

import { _decorator, Component, Material, Node, Sprite, Vec4 } from 'cc';

const { ccclass, property, executeInEditMode } = _decorator;

@ccclass('Light2DFeeder')
@executeInEditMode(true)
export class Light2DFeeder extends Component {
  @property(Node)
  lightNode: Node | null = null;

  @property
  radius = 220;

  @property
  intensity = 1.2;

  private _material: Material | null = null;

  start(): void {
    const sprite = this.getComponent(Sprite);
    if (!sprite || !sprite.customMaterial) {
      console.warn('[block-dash] 请先给 Sprite 挂上带光照的自定义材质');
      return;
    }
    this._material = sprite.customMaterial;
  }

  update(): void {
    if (!this._material || !this.lightNode) return;
    const p = this.lightNode.worldPosition;
    // 一个 Vec4 打包四个标量,省一次 uniform 上传
    this._material.setProperty('lightPos', new Vec4(p.x, p.y, this.radius, this.intensity));
  }
}

几个要点:

  • setProperty 的第二个参数类型是 number | Vec2 | Vec3 | Vec4 | Color | Mat3 | Mat4 | Quat 之一,Vec4 是合法值。用它把「位置 x、位置 y、半径、强度」打进一个 uniform,比传四个 float 更省。
  • worldPosition 返回的是只读 Vec3,读到 p.x / p.y 即可;别用 node.position——那是相对父节点的局部坐标,光源一旦被挂到某个会动的容器下,局部坐标就错了。
  • executeInEditMode(true) 让编辑器里也能看到光照,调参不用反复运行预览。

4.1.6 写一个光照 Effect

材质的行为由 .effect 定义。下面这个片段是同一个 .effect 文件的完整内容,它把 4.1.4 的公式翻译成 GLSL。需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现(.effect 由引擎 Effect 编译器编译,无法脱离编辑器单独跑):

CCEffect %{
  techniques:
  - passes:
    - vert: sprite-vs:vert
      frag: sprite-fs:frag
      properties:
        lightPos: { value: [0.0, 0.0, 200.0, 1.0] }
        lightColor: { value: [1.0, 1.0, 1.0, 1.0] }
%}

CCProgram sprite-fs %{
  precision highp float;

  in vec2 v_uv;
  in vec4 v_color;

  uniform LightBlock {
    vec4 lightPos;    // xy = 位置, z = 半径, w = 强度
    vec4 lightColor;
  };

  #pragma builtin(local)
  layout(set = 2, binding = 12) uniform sampler2D cc_spriteTexture;

  vec4 frag() {
    vec4 albedo = v_color * texture(cc_spriteTexture, v_uv);
    vec4 normalTex = texture(cc_spriteTexture, v_uv); // 实际工程用第二张法线图

    // 法线贴图 0–255 → -1..1
    vec3 n = normalize(normalTex.rgb * 2.0 - 1.0);

    // 光方向:像素 → 光源。屏幕 y 向下,取反;真实工程里像素位置由顶点着色器的 varying 传入
    vec2 pixelPos = vec2(0.0); // 占位:示意用,实际来自 v_worldPos
    vec2 toLight = vec2(lightPos.x, -lightPos.y) - pixelPos;
    float dist = length(toLight);
    vec3 l = normalize(vec3(toLight, 0.0));

    float ndl = max(0.0, dot(n, l));
    float t = max(0.0, 1.0 - dist / max(lightPos.z, 1.0));
    float lit = ndl * t * t * lightPos.w;

    return vec4(albedo.rgb * lightColor.rgb * (0.2 + lit), albedo.a);
  }
}%

逐段读:

  • CCEffect 段声明 technique 与 pass,properties 里的名字必须和 setProperty 传的字符串完全一致——写错不会报错,只是值永远不生效。
  • LightBlock 是 uniform 块,lightPos 的四个分量正好对应 4.1.5 里那个 Vec4。
  • cc_spriteTexture 是精灵的贴图采样器(#pragma builtin(local) 绑定),v_color 是节点颜色。
  • 0.2 + lit 里的 0.2 是环境光下限:没有它,背光面会纯黑,看起来像贴图丢了。真实项目里环境光通常单独一个 uniform 控制。
  • 上面的例子为了短,把 albedo 和法线都从同一张图取——这只是示意。真做时法线是第二张 sampler,要通过 properties 再声明一个 normalMap 并在 setProperty 里传 Texture2D。

4.1.7 性能与工程决策

2D 光照不是免费的,代价集中在三处:

代价来源量级
每像素多几次采样与点积片元着色器变重小面积精灵可忽略
打断合批每个用不同材质的 Sprite 各占一个 Draw Call见卷二 13.1
额外一张法线图的显存与原图等尺寸与贴图同量级

第二行是最容易被低估的。卷二 13.1 那个「180 → 42 Draw Call」的场景,前提是所有精灵共用内置材质;一旦给其中一部分换上自定义光照材质,它们就从合批里掉出来。所以工程决策很直接:

  • 该用:单个或少量「主角级」物体需要浮雕感、环境氛围(火把、霓虹、昼夜)时。
  • 不该用:整屏几百个小精灵都开光照。那种场合应该用「预烘焙」——把光照算进贴图,运行时零成本(4.2 会展开讲)。
  • 折中:只给场景里 1–2 个「受光层」节点开光照,其余精灵保持内置材质。

下一节把「光照」从单点光源推到阴影与预烘焙,看这两条路各自的适用边界。

4.1.8 本节自检清单

  • 没有在任何地方 import { Light2D }(它不存在)
  • 法线贴图在编辑器里的导入类型是 normal map,不是 sprite-frame
  • 光照公式先在纯 TS + vitest 里跑绿,再搬进着色器
  • setProperty 的 uniform 名与 .effect 里 properties 的名字逐字一致
  • 光源位置用 worldPosition,不是 position
  • 环境光下限(如 0.2)存在,背光面不是纯黑
  • 清楚光照材质会让精灵掉出合批,只在受光层使用
  • 法线图与原图一起提交,命名带 _n 后缀

阅读导航:上一节:目录 · 下一节:4.2 阴影与光照贴图 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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