利用 Phaser.js 着色器(Shaders)实现高级光影特效

作者:竹影

时间:2026-11-02

浏览:658

深入讲解在 Phaser.js (WebGL) 中利用自定义片段着色器与法线贴图实现实时点光源、兰伯特漫反射衰减与光影后处理管线的完整实现步骤与工程优化。

文章目录
在 Phaser 3 游戏项目中,传统的光影通常使用半透明暗色图层或圆形遮罩拼接,无法体现物体表面的凹凸材质与动态高光。借助 WebGL 的片段着色器(Fragment Shader)与自定义渲染管线(CustomPipeline),开发者能够把光源坐标、颜色与衰减半径作为 Uniform 变量输入 GPU,结合法线贴图(Normal Map)实时计算光线入射角与兰伯特漫反射(Lambertian Diffuse)。本文将详细讲解这一技术的底层运作架构、GLSL 片段着色器代码、Phaser 自定义管线注册流程以及多光源工程优化的具体实现。
利用 Phaser.js 着色器(Shaders)实现高级光影特效
图 1:Phaser.js 着色器光影特效系统概览(输入纹理、GLSL 计算与最终画面合成)

一、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)处理,统一渲染整体氛围。
利用 Phaser.js 着色器(Shaders)实现高级光影特效
图 2:Phaser 3 WebGL 着色器渲染管线与光照合成架构数据流向

在每一帧的 update 循环中,CPU 负责追踪动态光源(例如主角火把、游标指针或移动飞弹)的坐标与光照属性,并将这些数值转换为归一化的屏幕空间参数,通过 set2f、set3f 写入着色器的 Uniforms 缓冲。随后 GPU 并行对每个像素执行片段着色器,瞬间完成数百万次向量计算。

二、光影特效落地的四个核心步骤

将光影着色器集成进 Phaser.js 并不需要从零编写底层 WebGL 调用,按照以下四个标准步骤即可构建稳健的光影系统:

利用 Phaser.js 着色器(Shaders)实现高级光影特效
图 3:Phaser.js 光影着色器实现的四个核心阶段
  1. 初始化 WebGL 模式与素材准备:Phaser 游戏配置对象中必须明确指定 type: Phaser.WEBGL,Canvas 模式无法运行着色器。同时准备成对的漫反射贴图与切线空间法线贴图(Normal Map),法线贴图的 RGB 通道分别映射三维法向向量的 X、Y、Z 分量。
  2. 编写 GLSL 片段着色器:建立 .frag 文件或内联字符串,声明主采样纹理、法线采样纹理、光源坐标与颜色衰减参数,实现向量点积与距离平方衰减算法。
  3. 继承并注册 CustomPipeline:在 JavaScript 中继承 Phaser.Renderer.WebGL.Pipelines.PostFXPipeline,并在游戏启动场景的 preload 或 create 中通过 this.renderer.pipelines.addPostPipeline() 进行全局注册。
  4. 绑定游戏对象并在更新循环中传递参数:通过 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 颜色值:

利用 Phaser.js 着色器(Shaders)实现高级光影特效
图 4:2D 法线贴图兰伯特漫反射与几何向量计算剖析

法线贴图中的标准基准色为 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,表面变暗,形成了自然的明暗渐变。

坐标系方向翻转注意: 浏览器 DOM 与 Phaser.js 的屏幕坐标系以左上角为原点 (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);

六、工程落地、常见报错与性能优化

在真实上线项目中,着色器光影的算力消耗直接影响帧率。如果不对光源数量与批处理做合理规划,移动端设备很容易出现掉帧或过热现象。以下是关键的优化决策与排查方案:

利用 Phaser.js 着色器(Shaders)实现高级光影特效
图 5:Phaser.js 着色器光影特效工程落地与性能优化总结矩阵
问题现象 / 优化维度 根本原因 解决方案与代码实践
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 网页游戏带来更具沉浸感的视觉表现。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章