Cocos Creator Shader 入门 ⑽ —— 拖尾效果的实现

一、拖尾效果及原理

在游戏中,拖尾效果是一种常见的视觉表现手法。它表现为运动物体(如角色、子弹或特效)在移动路径上留下逐渐消散的痕迹,从而增强运动的速度感和动态视觉冲击力。

拖尾效果最简单的实现,就是保留运动物体每一帧的渲染像素,并随着时间的流逝降低每帧像素的透明度(直到完全透明):

本文会介绍如何使用着色器来实现拖尾效果。

二、尝试捕获各帧像素

我们可以创建一个摄像头(命名为 CameraRaw)并绑定 Render Texture 对象(命名为 rt-raw.rt 文件)来捕获运动物体的渲染纹理。

然而此时捕获的纹理,仅对应运动物体当前帧的像素。然而要实现拖尾效果,就必须获取到每一帧的纹理,并动态修改它们的透明度。

通过组件脚本来缓存每一帧像素,再作为参数传递给着色器去加工,是一种实现方案:

js 复制代码
import { _decorator, Component, RenderTexture,  Sprite } from 'cc';
import { Texture2D } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('EntryComp')
export class EntryComp extends Component {
    @property(RenderTexture)
    rawRT: RenderTexture = null;  // 对应新创建的 rt-raw.rt 文件

    private textureWidth = 1280;
    private textureHeight = 720;

    private curTexture: Texture2D = null;      // spine 动画当前帧的捕获纹理
    private lastTexture: Texture2D = null;     // spine 动画上一帧的捕获纹理

    nodeSprite: Sprite = null;

    start() {
        this.nodeSprite = this.node.getChildByName('NodeDemo').getComponent(Sprite);
    }

    update(_deltaTime: number) {
        this.updateTexture();  // 更新材质

        if (this.lastTexture) {
            // 把前一帧的像素纹理作为参数传入着色器中
            this.nodeSprite.material.setProperty('trailTexture', this.lastTexture);
        }
    }

    private createTexture(rt: RenderTexture) {
        const width = this.textureWidth;
        const height = this.textureHeight;
        
        // 从 RenderTexture 读取像素数据
        const texPixels = new Uint8Array(width * height * 4);  // 4个字节来分别存储 RGBA 信息
        rt.readPixels(0, 0, width, height, texPixels);

        const texture = new Texture2D();
        texture.reset({
            width: width,
            height: height,
        });

        texture.uploadData(texPixels, 0);  // 更新纹理像素,同步到 GPU
        return texture;
    }

    private updateTexture() {
        this.lastTexture = this.curTexture;      // 存储上一帧的捕获纹理
        
        // 创建 Texture2D 并设置图像数据
        this.curTexture = this.createTexture(this.rawRT);
    }
}

第 51 行的 updateTexture 方法会先缓存前一帧的像素纹理到 this.lastTexture 中,接着再更新当前帧的数据到 this.curTexture

因此可以在第 29 行把前一帧的像素纹理传递到某个 Sprite 节点使用的着色器中去进一步加工处理。

如果需要"前一帧的前一帧纹理",可以多创建一个内部属性(例如 secLastTexture)来缓存:

js 复制代码
    // 略...
    private lastTexture: Texture2D = null; 
    private secLastTexture: Texture2D = null;  // spine 动画上上一帧的捕获纹理
    // 略...
    
    private updateTexture() {
        this.secLastTexture = this.lastTexture;  // 存储上上一帧的捕获纹理
        this.lastTexture = this.curTexture;
        
        this.curTexture = this.createTexture(this.rawRT);
    }

但这种拖尾方案存在几个弊端:

  • 需要创建多个内部属性来缓存每帧的纹理;
  • 调用 readPixels 时需要 GPU 往 CPU 传输数据;调用 uploadData 时又从 CPU 往 GPU 传输数据。此类大数据的传输非常低效。
  • 频繁执行 new Uint8Array 非常占用内存,且存在内存泄漏风险。

从性能层面考量,下文将介绍完全在 GPU 中处理纹理的方案。

三、Render Texture 双缓冲互绘

如果存在两个 Render Texture 渲染纹理 A 和 B,它们按顺序依次互绘彼此的纹理,且每次绘制都会新增移动物体当前帧的纹理,就可以绕过 CPU 来达成高效的拖尾效果。

💡 绑定了 Target Texture 的摄像头会将渲染结果离屏输出到一个 GPU 内存(显存)中的纹理对象,并绑定到 Framebuffer 中,整个过程完全在 GPU 中进行,非常高效。

💡 留意虽然我们会创建 .rt 文件与摄像头绑定,但该文件仅用于存储 Render Texture 的元数据(尺寸、格式、设置等),不包含任何像素数据,因此摄像头在离屏输出纹理到 GPU 时,不涉及任何硬盘读写操作。

就着上述的思路,我们在原有 CameraRaw 摄像头及 rt-raw.rt 的基础上,额外创建两个新的摄像头 CameraACameraB,且 Target Texture 属性分别绑定 rt-a.rtrt-b.rt 两个新建的 Render Texture 文件。

接着创建 NodeANodeB 两个 Sprite 组件节点,分别绑定 material-a.mtlmaterial-b.mtl 材质文件。

留意 NodeANodeB 需要设置好独立的 Layer 属性,并同步修改对应摄像头的 Visibility

NodeALayer 属性需要被场景的主摄像头的 Visibility 涵盖(NodeB 的则不需要),它们的互绘过程才会被渲染到屏幕上。

此时只要让 NodeANodeB 依次互绘彼此(衰减了透明度后)的帧缓冲数据,且每次绘制都叠加上移动物体当前帧的纹理(rt-raw.rt)即可:

我们创建着色器文件 trail.effect,来完成 A、B 纹理互绘,以及叠加上移动物体纹理的功能:

js 复制代码
// 对应解析文章 

CCEffect %{
  techniques:
  - name: trail
    passes:
    - vert: vs:vert
      frag: fs:frag
      # 略...
      properties:
        rawRT: { value: white }
        trailTexture: { value: white }
}%

CCProgram vs %{
  #include "../../resources/chunk/render-texture-vert.chunk"
}%

CCProgram fs %{
  precision highp float;
  #include <sprite-texture>

  in vec2 uv;
 
  uniform sampler2D rawRT;         // 当前帧 spine 动画的 RenderTexture
  uniform sampler2D trailTexture;  // 上一帧合成的拖尾纹理

  vec4 frag () {
    vec4 rawRTColor = texture(rawRT, uv);
    vec4 trailColor = texture(trailTexture, uv);

    // 透明度衰减(每次衰减 2%)
    trailColor.a *= 0.98;

    vec4 color = trailColor;

    if (rawRTColor.a > 0.0) {
      // 叠加上移动物体当前帧的纹理
      color = mix(trailColor, rawRTColor, rawRTColor.a);
    }
    
    return color;
  }
}%

将着色器绑定到 material-a.mtlmaterial-b.mtl 材质文件上,并绑定对应的两个纹理参数:

留意材质 A 的 Trail Texture 需要绑定 NodeB 节点的纹理,材质 B 需要绑定 NodeA 节点的纹理,才能实现互绘纹理的功能。

此时执行效果如下:

四、清理残影

虽然上文实现了 GPU 层面的拖尾效果,但会发现拖尾像素会在降低到某个较低的透明度时,就无法再被淡化,导致残影一直遗留在画面上(等多久都不会消失):

我们先不深究原因,试着在片元着色器中剔除 Alpha 通道分量低于 0.1 的拖尾像素:

js 复制代码
  vec4 frag () {
    vec4 rawRTColor = texture(rawRT, uv);
    vec4 trailColor = texture(trailTexture, uv);

    trailColor.a *= 0.98;

    vec4 color = trailColor;

    if (rawRTColor.a > 0.0) {
      color = mix(trailColor, rawRTColor, rawRTColor.a);
    } else if (trailColor.a <= 0.1) {  // 剔除透明度低于 10% 的拖尾像素
        discard;
    }
    
    return color;
  }

此时问题解决了,但是 0.1 的 A 通道分量值还是偏高,导致拖影消失的有些突兀:

但是当我们把 0.1 修改为 0.01 来解决拖影消失突兀的新问题时,会发现之前残影不消失的旧问题又出现了。

其实上述两个问题的原因,本质都是 GPU 浮点精度限制导致低 Alpha 值计算失真 ------------ 大多数 GPU 纹理使用 8 位存储,其最小可表示的非零值约为 1/255 ≈ 0.0039。每次拖影像素的透明度的衰减处理(trailColor.a *= 0.98),都会让精度的损失被放大(最终导致理论上应该小于 0.01 的值实际上却在 [0.02, 0.1] 范围内)。

对此有如下两种可选方案。

4.1 加速透明度衰减

慢衰减会让 Alpha 值在临界区域停留更长时间,累积更多的精度误差,因此加速透明度衰减可以解决此问题:

js 复制代码
  vec4 frag () {
    vec4 rawRTColor = texture(rawRT, uv);
    vec4 trailColor = texture(trailTexture, uv);

    trailColor.a *= 0.8;  // 加大衰减值,每次大幅衰减 20%

    vec4 color = trailColor;

    if (rawRTColor.a > 0.0) {
      color = mix(trailColor, rawRTColor, rawRTColor.a);
    } else if (trailColor.a <= 0.01) {
        discard;
    }
    
    return color;
  }

或者

js 复制代码
  vec4 frag () {
    vec4 rawRTColor = texture(rawRT, uv);
    vec4 trailColor = texture(trailTexture, uv);

    trailColor.a *= 0.9;  // 加大衰减值,每次衰减 10%

    vec4 color = trailColor;

    if (rawRTColor.a > 0.0) {
      color = mix(trailColor, rawRTColor, rawRTColor.a);
    } else if (trailColor.a <= 0.02) {   // 同时加快移除残影像素的过程
        discard;
    }
    
    return color;
  }

执行效果:

4.2 分段衰减

trailColor.a(0.1, 1.0](0.02, 0.1][0.0, 0.02] 这三段区间做不同的衰减处理:

js 复制代码
  vec4 frag () {
    vec4 rawRTColor = texture(rawRT, uv);
    vec4 trailColor = texture(trailTexture, uv);

    // 分段衰减:高 alpha 时慢衰减,低 alpha 时快衰减
    if (trailColor.a > 0.1) {
      trailColor.a *= 0.98;  // 慢衰减保持拖尾效果
    } else if (trailColor.a > 0.02) {
      trailColor.a *= 0.91;  // 加速衰减
    } else {
      trailColor.a = 0.0;  // 直接清零
    }

    vec4 color = trailColor;

    if (rawRTColor.a > 0.0) {
      color = mix(trailColor, rawRTColor, rawRTColor.a);
    } else if (trailColor.a <= 0.0) {
        discard;
    }
    
    return color;
  }

这种处理可以让拖影显得更长和丝滑,执行效果:

相关推荐
LcGero12 天前
TypeScript 快速上手:泛型与工具类型
typescript·cocos creator·游戏开发
LcGero13 天前
Cocos Creator 3.x 高维护性打字机对话系统设计与实现
cocos creator·打字机
LcGero13 天前
Cocos Creator 三端接入穿山甲 SDK
sdk·cocos creator·穿山甲
LcGero14 天前
Cocos Creator平台适配层框架设计
cocos creator·平台·框架设计
LcGero15 天前
Cocos Creator 业务与原生通信详解
android·ios·cocos creator·游戏开发·jsb
LcGero16 天前
TypeScript 快速上手:前言
typescript·cocos creator·游戏开发
Setsuna_F_Seiei17 天前
CocosCreator 游戏开发 - 多维度状态机架构设计与实现
前端·cocos creator·游戏开发
CodeCaptain3 个月前
cocoscreator 2.4.x 场景运行时的JS生命周期浅析
cocos creator·开发经验
CodeCaptain3 个月前
CocosCreator 3.8.x [.gitignore]文件内容,仅供参考
经验分享·cocos creator
VaJoy5 个月前
Cocos Creator Shader 入门 (21) —— 高斯模糊的高性能实现
前端·cocos creator