跳转到内容

2D 光照与阴影

Estella 用 Light 光源和 ShadowCaster2D 遮挡体照亮 2D 场景。一个光源就是一个带 Light 组件的实体;而一个渲染器只有在用 Lit-2D 材质绘制时才会被照亮。这让光照是可选的、 且开销低——大多数精灵保持不受光,你想照亮的那些才配一个受光材质。

给一个实体加 Light 组件并选一个类型:

import { defineSystem, Commands, Transform, Light, LightType, lightAimRotation } from 'esengine';
const addSun = defineSystem([Commands()], (cmds) => {
cmds.spawn()
// 灯沿所在实体的朝前方向照射,所以太阳靠转动它来瞄准。
.insert(Transform, {
position: { x: 0, y: 0, z: 0 },
rotation: lightAimRotation({ x: -1, y: -1, z: -1 }),
})
.insert(Light, {
type: LightType.Directional,
color: { r: 1, g: 0.95, b: 0.8, a: 1 },
intensity: 1.2,
});
});

Light 字段(含默认值):

字段 默认 说明
type Point LightType:Point、Directional、Ambient 或 Spot。
color {1,1,1,1} 光颜色,RGBA 0..1。
intensity 1 亮度倍数。
radius 200 衰减半径,世界单位(Point / Spot)。
innerAngle 30 聚光锥内角,度(内部全亮)。
outerAngle 45 聚光锥外角,度(到这里衰减为零)。
shadowSoftness 0 阴影边缘柔和度(光源尺寸);0 = 硬边。
shadowDistance 0 方向光阴影延伸距离;0 = 无方向光阴影。
enabled true 不移除也能关掉光源。
类型 行为
Directional(方向光) 沿实体朝前方向的均匀光,像太阳——位置无关,只看旋转。
Point(点光) 从实体位置向外辐射,到 radius 处衰减为零。
Spot(聚光) 从位置沿实体朝前方向的一个锥,在 innerAngle 内全亮、到 outerAngle 衰减到边缘,直到 radius。
Ambient(环境光) 处处叠加的平坦底光,与位置无关——用较低的 intensity,让阴影区不至于纯黑。
// 一盏暖色点光
.insert(Light, { type: LightType.Point, radius: 320, intensity: 1.5,
color: { r: 1, g: 0.7, b: 0.3, a: 1 } });
// 一束手电锥,由它所在的实体朝下瞄准
.insert(Transform, { rotation: lightAimRotation({ x: 0, y: -1, z: 0 }) })
.insert(Light, { type: LightType.Spot,
innerAngle: 18, outerAngle: 34, radius: 500, intensity: 2 });
// 一点微弱环境光,让画面不至于全黑
.insert(Light, { type: LightType.Ambient, intensity: 0.25 });

默认情况下,一个 Sprite 用不受光材质绘制,会忽略每一盏灯。让它受光最简单的方式是 lit 开关——不需要材质、不需要着色器:

app.world.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Sprite, { texture: hero, lit: true });

在编辑器里就是 Sprite 组件上的 Lit 勾选框。精灵以平法线受光——每种 2D 光源、 衰减和阴影都开箱即用。

当你需要的不止这个开关(色调参数、法线贴图、自定义表面逻辑)时,给它一个着色器声明了 Lit-2D 域的材质。引擎会注入光照 uniform 和 helper,而且 2D 着色器的顶点段是可选的—— 你只写片元:

#pragma shader "MyLit"
#pragma version 300 es
#pragma domain Lit
#pragma fragment
precision mediump float;
in vec4 v_color;
in vec2 v_texCoord;
in highp vec2 v_worldPos;
uniform sampler2D u_textures[8];
out vec4 fragColor;
// #pragma domain Lit 让引擎注入 LightConstants 块和 applyLighting2D() helper——
// 你只需用你的基础色和一个法线调用它。
void main() {
vec4 base = texture(u_textures[0], v_texCoord) * v_color;
vec3 N = vec3(0.0, 0.0, 1.0); // 平法线,朝向相机
vec3 lit = applyLighting2D(base.rgb, N, v_worldPos);
fragColor = vec4(lit, base.a);
}
#pragma end

在编辑器里,内容浏览器的 New Material (Lit) 创建的正是这样一个现成的受光材质, 自带色调参数和可选法线贴图。代码侧的编译与赋材质方式与 材质与着色器里完全一致:

import { Material, defineSystem, Query, Mut, Sprite } from 'esengine';
const litMat = Material.create({ shader: Material.compileShader(myLitSource) });
const applyLit = defineSystem([Query(Mut(Sprite))], (q) => {
for (const [, sprite] of q) sprite.material = litMat;
});

你也能可视化地做一个受光材质——把 材质图 的域设为 Lit——见 材质图一节。

你传给 applyLighting2D 的 N 是表面法线。传一个常量 vec3(0, 0, 1) 得到平坦、均匀受光的 表面。要有逐像素的凹凸感,就在着色器里采样一张法线贴图并用它作 N——同一张基础色便会随每盏 灯的方向产生高光和阴影。把法线纹理作为一个 #pragma param … texture 加到你的受光材质上 (见材质与着色器)。

applyLighting2D 是“这个表面什么都不反射”的简写。它的一般形式让同样的灯走一遍微表面 BRDF,Lit 着色器可以直接调用:

highp vec3 P = vec3(v_worldPos, 0.0);
highp vec3 lit = applyLightingPBR(
base.rgb, // albedo
N, // 表面法线
P, // 世界位置
viewDirection(P), // 指向相机,来自 frame 块
u_metallic, // 0 = 电介质,1 = 金属
u_roughness, // 0 = 镜面,1 = 完全漫反射
u_specular, // glTF 的 specularFactor:0 让高光消失
1.0 // 环境光遮蔽
);

applyLighting2D(albedo, N, worldPos) 就是这个调用取 metallic = 0、roughness = 1、 specular = 0 —— 所以受光精灵画出的像素一个都没变,而想要高光的表面自己开口要。

viewDirection(worldPos) 返回指向相机的单位向量;它读引擎每帧写进 frame 块的相机位置, 所以正交相机返回一个常量,透视相机逐像素变化。

金属完全没有漫反射项 —— 它只反射。它反射的是场景的环境光(ambient),被当作从各个 方向等量到达的环境:所以一盏 Ambient 的 Light 正是让金属在没有灯照到的地方不至于 全黑的东西。把它的颜色调亮,金属才有东西可显。

平坦的 ambient 是一个没有细节的环境——每个方向都是同一个颜色。导入一张等距柱状投影的 .hdr 全景图,同一盏灯就有了细节:上方是天空,下方是地面,文件里是什么就是什么。

方式 会发生什么
把 .hdr 拖进内容浏览器 源文件被拷入并烘焙。
把它放进项目目录 编辑器会发现并烘焙它。
estella import-hdr <file> [outDir] 同一次烘焙,在命令行上。--face-size <n> 决定最清晰的那级反射。

烘焙在源文件旁写出两个产物:<name>.esenv 用九个数字装下整个漫反射一半——完全不占纹理 ——以及 <name>_env.png,一张预过滤的反射图,每级 mip 对应一个粗糙度。

让一盏 Ambient 灯指向那个 .esenv,两半就都到位了:一个表面按它朝向的方向取环境光, 而金属反射的是这张全景图,不再是一个平坦的颜色。

cmds.spawn().insert(Light, {
type: LightType.Ambient,
environment: 'assets/sky.esenv',
intensity: 1,
});

color 与 intensity 依然缩放它,所以环境同样可以被染色和调暗。没有环境的灯则原样不动: 系数为零,同一个表达式又变回那个平坦项。

ShadowCaster2D 的方块(见下)在 XY 平面上投影,那是 2D 场景所说的阴影。有高度的几何 需要另一种:给一盏 Directional 灯打开 meshShadows,引擎每帧会从那盏灯的方向把场景的 mesh 再画一遍,于是站得高的 mesh 会让下面的暗下来。

cmds.spawn()
.insert(Transform, { rotation: lightAimRotation({ x: 0.5, y: 0, z: -1 }) }) // 光线偏向哪边
.insert(Light, {
type: LightType.Directional,
meshShadows: true,
});
  • 每帧只有一盏灯投出这张图 —— 第一盏要求它的。
  • 只有 GPU 持有的 MeshRenderer 几何参与投射与接收。精灵被同一盏灯照亮,但它们的阴影走 ShadowCaster2D,那才是 2D 的答案。
  • 覆盖范围跟着相机走。shadowExtent 把它固定成一个半径,用锐度换范围。

给一个实体加 ShadowCaster2D,让它遮挡光并投出阴影:

import { ShadowCaster2D } from 'esengine';
cmds.spawn()
.insert(Transform, { position: { x: 100, y: 0, z: 0 } })
.insert(Sprite, { /* …一个受光材质… */ })
.insert(ShadowCaster2D, { size: { x: 64, y: 64 } });
ShadowCaster2D 字段 默认 说明
size {32,32} 遮挡体尺寸,世界单位。
enabled true 开关阴影投射。

阴影质量在光源上控制,而非遮挡体:

  • shadowSoftness — 光源尺寸。0 是锐利硬边;更大的值给出柔和半影(更大、更近的光投出更软的 阴影)。
  • shadowDistance — 对 Directional 光,阴影延伸多远;0 让方向光不产生阴影。

阴影是画出来的,不是逐像素解出来的:每帧引擎取出所有启用的遮挡体的边,把它们对每个投射光遮住的 区域画进一张屏幕大小的遮蔽图,受光表面只读其中一个纹素。于是遮挡体的数量是三角形的数量,而不是一个 着色器常量——一个房间需要多少面墙就能有多少面,多加一面的代价是几个三角形,而不是整屏每个像素多一次 测试。

有上限的是同时投影的光数:遮蔽图有四个通道,所以前四个投射光各占一个。超出的光仍然照亮场景, 只是不再投影——房间还是亮的,少掉的是影子而不是光。同屏超过四个投影光通常说明美术方向本身有问题; 处理办法是把没人会看那道影子的光关掉阴影(太阳的 shadowDistance 设 0,或者用更小的 radius 让某个光离开视野)。

一帧最多 16 盏灯。第 17 盏会被丢掉——按亮度取舍,并打出一条说明丢了谁的警告——这个天花板不是画质 选项:一个摆了三十盏灯的房间,在这里根本无法实时点亮。

烘焙是绕过它的办法。不会移动的光离线算一次,存成一张纹理;运行时表面读一个纹素,而不是把所有光 加起来。于是一百盏灯的代价正好等于一盏,而这个代价是一次采样。

引擎通过两样东西读一张烘焙图,两样都要你给:

  • 带第二套 UV 的网格。 第一套 UV 是美术的包裹方式,它故意重叠——两条手臂共用一条手臂的 纹素。烘焙不能这样:每个面都需要自己的一块,否则一面墙的光会落到另一面上。两个导入前端在模型带这 条通道时都会把它带过来(glTF 的 TEXCOORD_1、FBX 的第二个 UV 层),所以这是问模型作者的问题。
  • 一个 MeshLightmap 组件。 lightmap 是图集,scaleOffset 是这个对象在图集里占的矩形—— xy 缩放第二套 UV,zw 偏移它。(1, 1, 0, 0) 读整张纹理,这正是单个对象烘焙出来的样子。矩形 是逐实体而不是逐网格的,因为同一个网格摆两次,被照亮两次,而且照法不同。

烘焙图意味着什么取决于渲染器的 lit 开关,这两种情形值得分开看:

lit 有 MeshLightmap 没有
开 实时光加上烘焙图,后者承载间接光那一项 只有实时光
关 烘焙图就是这个表面光照的全部 不受光——纹理按作者所写

第二行才是烘焙存在的理由。一个把所有灯都烘进图集的场景,lit 关着画,一盏灯的钱都不用付——在手机 上,这是「一个点亮的房间」和「一张幻灯片」之间的差别。

几何带着这条通道、却没有 MeshLightmap 的对象,画出来和以前一模一样:矩形是零,着色器把它读成 「没有烘焙」,而不是读成一张全黑的图集。

编辑器会烘:Lighting → Bake Lighting 把图集写在场景旁边并填好每个 MeshLightmap,命令行 node pipeline/bin/estella.mjs bake-scene <场景> 做同一件事。别处烘出来的图(Blender 的 Cycles、Substance 的烘焙、任何写出 lightmap 与其 UV 布局的渲染器)照样读得一样好——契约是组件, 不是烘焙器。

lightmap 是漫反射那一半。另一半是镜子里看到的东西,而没有探针时那永远是天空——在室内,一颗 铬球会隔着天花板反射云。

ReflectionProbe 就是答案,形状和 LightProbeVolume 完全一样:一个盒子,由同一次烘焙填。

world.insert(room, ReflectionProbe, { halfExtents: { x: 400, y: 220, z: 400 } });

烘焙器从探针所在的位置拍下整个球面,按每一档粗糙度预过滤,并把场景里每个探针写进同一张图集 (就放在 lightmap 旁边)——reflection 指向它,slot 说这是哪一列。这两个字段都由烘焙器写; 你摆的是那个盒子。

三件值得知道的事:

  • 反射会被投影到这个盒子上,所以视角一动,墙上的反射会跟着滑,而不是像贴上去一样不动。 盒子按房间来定大小,不要按物体。
  • 两个盒子重叠时小的赢 —— 大厅里的柜子反射的是柜子那次烘焙说的东西。
  • 不要把探针放在镜面物体里面。拍摄就在那个点进行;站在球里面,它看见的只有球的内壁。

蒙皮网格不吃烘焙:骨骼在动角色,而烘焙抓不住一个动着的角色去照亮它。烘焙好的房间里的角色,由留下来 的那些实时光点亮。

引擎默认以伽马空间渲染——颜色按作者所写直接混合,这是 2D 管线的经典方式。项目可以切换到 线性光管线:纹理在采样时从 sRGB 解码到线性(硬件完成)、光照与染色在线性光下混合、最终画面在 末尾 blit 时编码回 sRGB。

物理正确的混合改变的是光的叠加方式:衰减不再压向死黑,多盏灯的重叠像光一样相加而不是像颜料。 不受光的美术资源经解码再编码是恒等回环——切换不会“重画”你的精灵;差异只出现在颜色相互混合的地方。

线性模式也是 HDR 的入口:只要浮点渲染目标可用(WebGPU 恒可用;WebGL2 有 EXT_color_buffer_float),后处理链就跑在半浮点上——intensity 大于 1 的灯把真正的超范围 能量推进 bloom 与 tonemap,亮光会发辉光。

在 项目设置 → 渲染 → 色彩空间 → 线性 启用。着色器按该模式编译,所以它在引擎启动时定死—— 编辑器会提示重新加载。设置随项目清单走:视口、运行预览和所有导出——web、桌面、微信、playable—— 启动同一条管线。纯代码项目在创建应用时传入:

const app = createWebApp(module, { colorSpace: 'linear' });

编辑器视口里的 2D 光——它的照射范围与投射的阴影

编辑器里预览的 Light——它的照射光池,以及柱子投出的阴影(开 Preview FX 可在编辑态点亮场景)。

  • 通过 创建… → 光 添加光源,或在 Details 面板里给已有实体加一个 Light 组件。
  • 光源在编辑模式下画成 gizmo——图标加上其范围圈和朝向/锥——所以你能凭眼睛瞄准和调尺寸。见 编辑器。
  • 在 Details 里实时调 color(取色器)、intensity、radius 和聚光角度;结果在视口里即时更新。
  • 一盏灯的全部视觉字段(color、intensity、radius、各角度、阴影设置)都可动画——在 Sequencer里给它们打关键帧,做闪烁的火把、昼夜循环或脉动的信标。