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 阴影与光照贴图 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。