深入讲解在 Phaser.js (WebGL) 中利用自定义片段着色器与法线贴图实现实时点光源、兰伯特漫反射衰减与光影后处理管线的完整实现步骤与工程优化。
利用 Phaser.js 着色器(Shaders)实现高级光影特效
时间:2026-11-02
浏览:658
文章目录
一、Phaser.js 渲染管线与光照合成架构
Phaser 3 的渲染核心由 WebGLRenderer 驱动,所有的可视对象(如 Sprite、TilemapLayer 等)在提交给 GPU 前都会经过渲染管线(Pipeline)。默认的 MultiPipeline 主要负责批量绘制普通纹理,不计算复杂的光照方程。为了实现点光源、聚光灯或环境暗角的动态物理受光,我们需要介入片段着色阶段。
在架构层面,Phaser 提供了两种主要形式的自定义管线:一是挂载在特定精灵或图层上的专用材质管线,二是挂载在摄像机或画布上的全屏后处理管线(PostFXPipeline)。光影特效通常采用这两种方式协同工作:
- 材质级光照(Sprite Pipeline):读取精灵原本的漫反射贴图(Diffuse Map)以及对应的法线贴图(Normal Map),在像素级别计算每个表面朝向与光线向量的点积,从而在平面的 2D 精灵上产生如同 3D 浮雕般的明暗凹凸。
- 全屏后处理(PostFX Pipeline):针对整个场景渲染缓冲(FrameBuffer)进行环境泛光(Bloom)、全局暗角(Vignette)或射线散射(God Rays)处理,统一渲染整体氛围。
在每一帧的 update 循环中,CPU 负责追踪动态光源(例如主角火把、游标指针或移动飞弹)的坐标与光照属性,并将这些数值转换为归一化的屏幕空间参数,通过 set2f、set3f 写入着色器的 Uniforms 缓冲。随后 GPU 并行对每个像素执行片段着色器,瞬间完成数百万次向量计算。
二、光影特效落地的四个核心步骤
将光影着色器集成进 Phaser.js 并不需要从零编写底层 WebGL 调用,按照以下四个标准步骤即可构建稳健的光影系统:
- 初始化 WebGL 模式与素材准备:Phaser 游戏配置对象中必须明确指定
type: Phaser.WEBGL,Canvas 模式无法运行着色器。同时准备成对的漫反射贴图与切线空间法线贴图(Normal Map),法线贴图的 RGB 通道分别映射三维法向向量的 X、Y、Z 分量。 - 编写 GLSL 片段着色器:建立
.frag文件或内联字符串,声明主采样纹理、法线采样纹理、光源坐标与颜色衰减参数,实现向量点积与距离平方衰减算法。 - 继承并注册 CustomPipeline:在 JavaScript 中继承
Phaser.Renderer.WebGL.Pipelines.PostFXPipeline,并在游戏启动场景的preload或create中通过this.renderer.pipelines.addPostPipeline()进行全局注册。 - 绑定游戏对象并在更新循环中传递参数:通过
sprite.setPostPipeline()将管线挂载至目标对象,在scene.update()中实时抓取光源位置并归一化更新 Uniforms。
三、GLSL 光影片段着色器完整代码与解析
着色器代码运行在 GPU 的微小计算单元上。下面是一个工业级、具备兰伯特漫反射与物理平滑衰减的 2D 点光源片段着色器完整实现:
#ifdef GL_ES
precision mediump float;
#endif
// 由 Phaser 传入的内置采样器与 UV 坐标
uniform sampler2D uMainSampler;
varying vec2 outTexCoord;
// 自定义 Uniforms: 光源参数与分辨率
uniform vec2 u_resolution; // 画布宽高 (px)
uniform vec2 u_light_pos; // 光源屏幕位置 (归一化 0.0 - 1.0)
uniform vec3 u_light_color; // 光源颜色 (RGB 0.0 - 1.0)
uniform float u_light_radius; // 光照衰减半径 (归一化范围)
uniform vec3 u_ambient_color; // 环境光基色
uniform sampler2D u_norm_map; // 法线纹理采样器
void main(void)
{
// 1. 采样漫反射基础颜色
vec4 baseColor = texture2D(uMainSampler, outTexCoord);
if (baseColor.a < 0.01) {
discard; // 透明像素直接丢弃,降低填充率开销
}
// 2. 采样并解包法线向量:将 [0, 1] 色彩空间映射至 [-1, 1] 向量空间
vec3 normalTex = texture2D(u_norm_map, outTexCoord).rgb;
vec3 N = normalize(normalTex * 2.0 - 1.0);
// 3. 计算当前片元与光源在屏幕空间的物理距离与方向向量
// 根据屏幕纵横比校正 X 轴,防止圆形光斑在非正方形屏幕被拉伸
float aspect = u_resolution.x / u_resolution.y;
vec2 fragCoord = outTexCoord;
vec2 lightCoord = u_light_pos;
vec2 delta = (lightCoord - fragCoord);
delta.x *= aspect;
float dist = length(delta);
// 4. 构建三维光线向量 (假设光源在 Z 方向高出表面一定高度)
float lightHeight = 0.08;
vec3 L = normalize(vec3(delta, lightHeight));
// 5. 兰伯特漫反射强度 (法线与光线向量点积)
float NdotL = max(dot(N, L), 0.0);
// 6. 物理距离平滑衰减 (基于 smoothstep 与平方反比)
float attenuation = clamp(1.0 - smoothstep(0.0, u_light_radius, dist), 0.0, 1.0);
attenuation = attenuation * attenuation; // 增加平滑非线性过渡
// 7. 合成漫反射与环境光
vec3 diffuseLight = u_light_color * NdotL * attenuation;
vec3 finalLight = u_ambient_color + diffuseLight;
// 输出最终合成色彩,保持原贴图透明通道
gl_FragColor = vec4(baseColor.rgb * finalLight, baseColor.a);
}
aspect 纵横比修正是 2D 光影最容易被忽略的点。当游戏视口分辨率为 1920×1080 时,UV 坐标的单位长度在物理像素上并不相等。如果不乘以 u_resolution.x / u_resolution.y,原本设定的正圆形光源会在画面上被拉伸为椭圆。
四、法线贴图与兰伯特漫反射的几何原理
为了让 2D 精灵表面呈现立体阴影,核心在于法线贴图(Normal Map)。传统的 2D 贴图只存储红绿蓝透明度(RGBA),而法线贴图将每个像素表面的三维朝向编码为 RGB 颜色值:
法线贴图中的标准基准色为 RGB(128, 128, 255)(十六进制 #8080FF)。在着色器执行 color * 2.0 - 1.0 解包后,该颜色对应的向量恰好为 (0.0, 0.0, 1.0),即垂直于屏幕完全指向玩家的法向量。当纹理表面的某侧凸起时,X 和 Y 分量随之倾斜,形成偏红(指向右侧)或偏绿(指向顶部)的法线分布。
兰伯特漫反射定理指出:粗糙表面接收到的反射光强与法线向量 $ec{N}$ 和入射光线向量 $ec{L}$ 夹角的余弦值成正比。在 GLSL 中,两个归一化向量的点积(dot(N, L))在数学上完全等价于 $\cos( heta)$。当光线正对表面时,$ heta = 0^\circ$,点积为 1.0,表面最亮;当光线斜射或背光时,点积趋向 0.0,表面变暗,形成了自然的明暗渐变。
(0,0),Y 轴向下递增;而 WebGL 的纹理与视口坐标系以左下角为原点 (0,0),Y 轴向上递增。在将鼠标或游戏对象 Y 坐标传给着色器时,必须使用 light_pos_y = 1.0 - (y / gameHeight) 进行翻转,否则光斑移动方向与鼠标垂直反向。
五、Phaser 3 渲染管线封装与游戏逻辑集成
在完成着色器编写后,需要编写 JavaScript 代码把着色器打包为 Phaser 的 PostFXPipeline,并在游戏主逻辑中挂载使用。以下为完整的 JavaScript 实现:
// 1. 继承 PostFXPipeline 封装光影管线
class DynamicLightPipeline extends Phaser.Renderer.WebGL.Pipelines.PostFXPipeline {
constructor(game) {
super({
game: game,
renderTarget: true,
fragShader: `
#ifdef GL_ES
precision mediump float;
#endif
uniform sampler2D uMainSampler;
uniform sampler2D u_norm_map;
varying vec2 outTexCoord;
uniform vec2 u_resolution;
uniform vec2 u_light_pos;
uniform vec3 u_light_color;
uniform float u_light_radius;
uniform vec3 u_ambient_color;
void main(void) {
vec4 baseColor = texture2D(uMainSampler, outTexCoord);
if (baseColor.a < 0.01) discard;
vec3 normal = normalize(texture2D(u_norm_map, outTexCoord).rgb * 2.0 - 1.0);
float aspect = u_resolution.x / u_resolution.y;
vec2 delta = u_light_pos - outTexCoord;
delta.x *= aspect;
float dist = length(delta);
vec3 lightDir = normalize(vec3(delta, 0.075));
float diff = max(dot(normal, lightDir), 0.0);
float atten = clamp(1.0 - (dist / u_light_radius), 0.0, 1.0);
atten = atten * atten;
vec3 light = u_ambient_color + (u_light_color * diff * atten);
gl_FragColor = vec4(baseColor.rgb * light, baseColor.a);
}
`
});
// 默认光照属性
this.lightX = 0.5;
this.lightY = 0.5;
this.lightRadius = 0.45;
this.lightColor = [1.0, 0.95, 0.8]; // 暖黄色
this.ambientColor = [0.15, 0.18, 0.22]; // 冷色调环境暗光
this.normalTexture = null;
}
onBoot() {
// 绑定材质单元
this.set2f('u_resolution', this.game.config.width, this.game.config.height);
this.set3f('u_ambient_color', this.ambientColor[0], this.ambientColor[1], this.ambientColor[2]);
this.set3f('u_light_color', this.lightColor[0], this.lightColor[1], this.lightColor[2]);
this.set1f('u_light_radius', this.lightRadius);
}
onPreRender() {
// 每一帧在绘制前绑定法线纹理到 TEXTURE1
if (this.normalTexture) {
this.set1i('u_norm_map', 1);
this.bindTexture(this.normalTexture, 1);
}
this.set2f('u_light_pos', this.lightX, this.lightY);
}
setLightPosition(screenX, screenY) {
// 屏幕坐标归一化并翻转 Y 轴
this.lightX = screenX / this.game.config.width;
this.lightY = 1.0 - (screenY / this.game.config.height);
}
}
// 2. 游戏场景配置与使用
class GameScene extends Phaser.Scene {
constructor() {
super('GameScene');
}
preload() {
// 加载漫反射与法线纹理
this.load.image('character_diffuse', 'assets/character.png');
this.load.image('character_normal', 'assets/character_n.png');
}
create() {
// 向管线管理器注册自定义管线
const pipelineManager = this.renderer.pipelines;
if (!pipelineManager.has('DynamicLightPipeline')) {
pipelineManager.addPostPipeline('DynamicLightPipeline', DynamicLightPipeline);
}
// 创建精灵并挂载光影管线
this.player = this.add.sprite(400, 300, 'character_diffuse');
this.player.setPostPipeline('DynamicLightPipeline');
// 获取该精灵实例绑定的管线对象,配置法线纹理
this.lightPipeline = this.player.getPostPipeline('DynamicLightPipeline');
this.lightPipeline.normalTexture = this.textures.get('character_normal').getSourceImage();
// 监听鼠标指针移动
this.input.on('pointermove', (pointer) => {
this.lightPipeline.setLightPosition(pointer.x, pointer.y);
});
}
update() {
// 可在此处加入光源呼吸脉冲或随主角移动的逻辑
}
}
// 3. 游戏全局配置
const config = {
type: Phaser.WEBGL, // 必须开启 WebGL 渲染模式
width: 800,
height: 600,
parent: 'game-container',
scene: [GameScene],
pipeline: { DynamicLightPipeline } // 预声明管线
};
const game = new Phaser.Game(config);
六、工程落地、常见报错与性能优化
在真实上线项目中,着色器光影的算力消耗直接影响帧率。如果不对光源数量与批处理做合理规划,移动端设备很容易出现掉帧或过热现象。以下是关键的优化决策与排查方案:
| 问题现象 / 优化维度 | 根本原因 | 解决方案与代码实践 |
|---|---|---|
| Draw Call 急剧增加 | 不同精灵频繁交替使用不同 Pipeline,打断了 WebGL 自动批处理(Batching)。 | 将同一类着色器材质的精灵按图层集中渲染,或将光照提升为全屏摄像机后处理管线。 |
| 光斑随窗口拉伸变形 | 着色器直接使用 UV 欧氏距离,未引入宽高比系数修正。 | 在片元代码中加入 delta.x *= (u_resolution.x / u_resolution.y) 消除形变。 |
| 移动端画面黑屏或严重掉帧 | 部分移动端 GPU 对 highp 浮点支持较弱,或着色器内循环次数过多。 |
着色器顶部强制声明 precision mediump float;,单个着色器内光源数量控制在 4 个以内。 |
| 光源垂直移动方向相反 | Phaser 屏幕坐标 Y 轴向下,而 WebGL 视口坐标 Y 轴向上。 | 向着色器传值时统一换算:gl_y = 1.0 - (screen_y / height)。 |
| 透明边缘出现黑色杂边 | Alpha 预乘(Premultiplied Alpha)与着色器混合叠加顺序冲突。 | 在计算漫反射前加入 if (baseColor.a < 0.01) discard; 快速剔除无效像素。 |
总结
利用 Phaser.js 着色器实现高级光影特效的核心在于分工与协同:CPU 端的 Phaser 场景负责维护实体位置与光源拓扑关系,通过 Uniform 变量向 GPU 定向输送参数;GPU 端的 GLSL 片段着色器则利用法线贴图的几何信息,并行计算光线方向、余弦受光率与非线性衰减。掌握这套管线模式后,还可以进一步衍生出屏幕空间泛光(Bloom)、体积雾气(Volumetric Fog)以及动态硬阴影,为 2D 网页游戏带来更具沉浸感的视觉表现。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。