跳转到内容

2D 光照与阴影

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

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

import { defineSystem, Commands, Transform, Light2D, Light2DType } from 'esengine';
const addSun = defineSystem([Commands()], (cmds) => {
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Light2D, {
type: Light2DType.Directional,
color: { r: 1, g: 0.95, b: 0.8, a: 1 },
intensity: 1.2,
direction: { x: -1, y: -1 },
});
});

Light2D 字段(含默认值):

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

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

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

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

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 = 0roughness = 1specular = 0 —— 所以受光精灵画出的像素一个都没变,而想要高光的表面自己开口要。

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

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

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

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

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

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

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

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

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

cmds.spawn()
.insert(Light2D, {
type: Light2DType.Directional,
direction: { x: 0.5, y: 0 }, // 光线偏向哪边
meshShadows: true,
});
  • 每帧只有一盏灯投出这张图 —— 第一盏要求它的。
  • 只有 GPU 持有的 Mesh2D 几何参与投射与接收。精灵被同一盏灯照亮,但它们的阴影走 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 让方向光不产生阴影。

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

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

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

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

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

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

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

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