礼物特效在端上如何渲染与合成:SVGA/VAP/PAG 的解码、绘制与叠加实战

礼物特效文件到达客户端后,如何转化为流畅动画并与直播画面无缝叠加?本文拆解SVGA、VAP、PAG从解码、逐帧绘制、纹理上传到GPU合成的完整流程,帮助定位卡顿、掉帧与花屏问题。

这个系列前面几篇拆完了礼物特效的格式选型、性能优化、资源分发、队列调度,解决的是“特效资源如何到达端上、何时播放”。但还有一个核心问题没展开:一个 SVGA/VAP/PAG 文件拿到手之后,端上到底怎么把它变成屏幕上流畅的动画,又怎么和直播画面无缝叠加,还不影响直播本身的帧率?

这篇拆特效渲染与合成的底层原理——从解码、逐帧绘制、离屏渲染、纹理上传,到 GPU 合成层,以及和直播画面的合成流程。理解这条链路,才能在卡顿、掉帧、花屏这些线上问题面前,精准定位瓶颈。


一、渲染的起点:从二进制到可绘制数据

下载 (1).png

薄荷味午后:从 SVGA/VAP/PAG 二进制文件解码成可绘制数据,是渲染的起点,数据结构的丰富度决定解码复杂度(星集礼物商城)

SVGA 的解码

SVGA 本质是一个描述矢量动画的 protobuf 二进制文件,里面记录了每一帧的矢量路径、变换矩阵、图片引用。客户端拿到 .svga 文件后,第一步是反序列化成内存对象

// SVGA 官方 SDK 的典型解码流程
SVGAParser parser = new SVGAParser(context);
parser.decodeFromAssets("gift_rocket.svga", new SVGAParser.ParseCompletion() {
    @Override
    public void onComplete(SVGAVideoEntity videoEntity) {
        // videoEntity 包含:
        // - videoSize: 画布尺寸
        // - frames: 总帧数
        // - FPS: 帧率
        // - sprites: 每一层的绘制指令(路径、图片、变换)
        svgaImageView.setVideoItem(videoEntity);
        svgaImageView.startAnimation();
    }
    @Override
    public void onError() { }
});

解码后得到的 SVGAVideoEntity 是一个纯数据结构,还没有位图、纹理这些 GPU 资源。真正的渲染发生在播放阶段,逐帧生成位图或直接绘制路径

VAP 的解码

VAP(Video Animation Plugin)本质是一个 MP4 容器,视频轨存 RGB 数据,音频轨复用存 Alpha 蒙版,再加一个 JSON 配置描述动画参数。解码分两步:

  1. 视频解码:用系统 MediaCodec 或 FFmpeg 解出视频帧(YUV → RGB);

  2. Alpha 混合:从音频轨解出 Alpha 数据,和 RGB 帧混合得到 RGBA 位图。

VapView vapView = findViewById(R.id.vap_view);
vapView.setVideoSource("gift_heart.mp4");
vapView.startPlay(new IVapListener() {
    @Override
    public void onVideoStart() { }
    @Override
    public void onVideoRender(int frameIndex, Bitmap frameBitmap) {
        // frameBitmap 已经是 RGB + Alpha 混合后的 RGBA 位图
    }
    @Override
    public void onVideoComplete() { }
});

VAP 的解码成本主要在视频解码器,尤其是硬解不支持的机型要走软解,CPU 占用高。

PAG 的解码

PAG(Portable Animated Graphics)是腾讯开源的矢量动画格式,支持 AE 导出。它的解码依赖 C++ 渲染引擎,直接输出 OpenGL 纹理,不经过 Bitmap 中转。

PAGView pagView = findViewById(R.id.pag_view);
PAGFile pagFile = PAGFile.Load(assetManager, "gift_planet.pag");
pagView.setComposition(pagFile);
pagView.setProgress(0.0);  // 0.0 ~ 1.0
pagView.play();

PAG 的优势是完全走 GPU 渲染,CPU 开销小,但对 OpenGL 版本有要求(需要 ES 2.0+)。


二、逐帧绘制:CPU 绘制 vs GPU 绘制

下载.png

玫瑰之约:多层花瓣特效,逐帧绘制时每层都要单独处理变换和混合,是 CPU vs GPU 绘制路径差异最明显的场景(星集礼物商城)

特效播放时,每一帧的生成有两条路:CPU 软绘制路径,或 GPU 硬件加速路径。

CPU 绘制(Canvas 路径)

SVGA 早期版本走的是 Android Canvas 绘制矢量路径:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    SVGAVideoEntity entity = ...;
    int currentFrame = (int) (animator.getAnimatedValue());

    for (SVGAVideoSpriteEntity sprite : entity.getSprites()) {
        // 取出当前帧的路径和变换
        Path path = sprite.getFrameEntity(currentFrame).getShapePath();
        Matrix matrix = sprite.getFrameEntity(currentFrame).getTransform();

        canvas.save();
        canvas.concat(matrix);
        canvas.drawPath(path, paint);
        canvas.restore();
    }
}

优点是兼容性好,任何 Android 版本都能跑。缺点是全在主线程 CPU 上绘制,复杂动画(几十层、上百个路径)会打满 CPU,主线程卡顿掉帧。

GPU 绘制(OpenGL 纹理)

现代方案是离屏渲染到纹理,再上传 GPU 合成。以 SVGA 为例:

  1. 离屏绘制:在一个 Bitmap 上把当前帧画好(Canvas 或 Skia);

  2. 纹理上传:把 Bitmap 数据 glTexImage2D 传给 GPU,变成纹理对象;

  3. GPU 合成:直播画面是一个纹理,特效也是一个纹理,用 OpenGL 着色器混合。

// 离屏渲染当前帧
Bitmap offscreenBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas offscreenCanvas = new Canvas(offscreenBitmap);
drawSVGAFrame(offscreenCanvas, currentFrame);

// 上传到 GPU 纹理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, offscreenBitmap, 0);
offscreenBitmap.recycle();  // 立即释放 Bitmap,只留 GPU 纹理

PAG 更进一步,完全跳过 Bitmap,直接在 GPU 上生成路径、渐变、遮罩,输出就是纹理,CPU 几乎不参与。


三、与直播画面的合成:纹理混合的三种方案

下载 (2).png

微醺时分:半透明酒杯特效,与直播画面合成时要做 Alpha 混合,考验 GPU 的 fillrate 和混合策略(星集礼物商城)

直播画面本身已经是一个视频流,礼物特效要叠加上去,有三种合成路径。

方案一:View 层叠加(最简单,性能最差)

直播画面用 SurfaceView 或 TextureView 渲染,礼物特效用一个独立的 View(SVGAImageView / VapView)盖在上面。

<FrameLayout>
    <TextureView android:id="@+id/live_video" />  <!-- 直播画面 -->
    <com.opensource.svgaplayer.SVGAImageView android:id="@+id/gift_effect" />  <!-- 特效层 -->
</FrameLayout>

优点:实现简单,特效和直播完全解耦。

缺点:

  • 两次合成开销:系统先合成直播画面,再合成特效层,再合成到屏幕,GPU 重复劳动;

  • 过度绘制(Overdraw):直播画面被特效遮挡的部分仍然在绘制,浪费带宽;

  • View 层级多:Android View 树遍历、测量、布局都有开销,礼物多了主线程抖动。

这个方案只适合低频场景(偶尔送一个礼物)。高频刷礼物直播间用这个,帧率必崩。

方案二:离屏合成后统一上屏

把直播画面和礼物特效都渲染到同一个离屏 FBO(FrameBuffer Object),合成好再一次性上屏。

// 伪代码:OpenGL 渲染线程
void onDrawFrame() {
    // 1. 绑定离屏 FBO
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, offscreenFBO);
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

    // 2. 先画直播画面(SurfaceTexture 输出的纹理)
    drawTexture(liveVideoTextureId, fullScreenQuad);

    // 3. 再画礼物特效(可能多个,按 Z-order 排序)
    for (GiftTexture gift : activeGifts) {
        GLES20.glEnable(GLES20.GL_BLEND);
        GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA);
        drawTexture(gift.textureId, gift.bounds);
    }

    // 4. 解绑 FBO,画到屏幕
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
    drawTexture(offscreenFBO.colorAttachment, fullScreenQuad);
}

优点:只合成一次,过度绘制少,帧率稳定。

缺点:需要自己管 OpenGL 上下文、FBO、纹理生命周期,代码复杂度高,出 bug 调试困难(黑屏、花屏、纹理泄漏)。

方案三:直播 SDK 提供的特效回调(推荐)

主流直播 SDK(腾讯云、阿里云、声网)都提供了视频帧回调 + 自定义渲染接口,允许你在视频帧上直接叠加自定义纹理。

以腾讯云 TRTC 为例:

trtcCloud.setLocalVideoProcessListener(TRTCCloudDef.TRTC_VIDEO_PIXEL_FORMAT_Texture_2D,
    TRTCCloudDef.TRTC_VIDEO_BUFFER_TYPE_TEXTURE, new TRTCCloudListener.TRTCVideoFrameListener() {
    @Override
    public void onProcessVideoFrame(TRTCCloudDef.TRTCVideoFrame srcFrame,
                                     TRTCCloudDef.TRTCVideoFrame dstFrame) {
        // srcFrame.textureId 是当前视频帧的 OpenGL 纹理
        // 你在这里把礼物特效纹理混合上去,写到 dstFrame
        int mergedTextureId = blendGiftEffects(srcFrame.textureId, activeGiftTextures);
        dstFrame.textureId = mergedTextureId;
    }
});

优点:

  • SDK 已经帮你管好了 GL 上下文和渲染线程,你只负责混合逻辑;

  • 直播编码器直接拿合成后的帧,推流出去的画面就是带特效的,观众端不用单独渲染;

  • 性能最优,因为少了一次 CPU ↔︎ GPU 拷贝。

缺点:和直播 SDK 强绑定,换 SDK 要重写。


四、性能瓶颈与优化

瓶颈一:纹理上传带宽

每一帧特效都要从 CPU 的 Bitmap 上传到 GPU 纹理,glTexImage2D 是个重操作。一个 1080×1920 的 RGBA 位图,一帧就是 8MB 数据。60fps 下每秒要传 480MB,很容易打爆内存带宽。

优化:

  • 降分辨率:特效不需要和屏幕一样大,512×512 足够,上传数据量降到 1MB/帧;

  • 纹理复用:同一个特效连播时,纹理对象复用,只更新内容(glTexSubImage2D),不重复创建;

  • PBO(Pixel Buffer Object):异步上传,CPU 写 PBO,GPU 从 PBO 读,减少阻塞。

瓶颈二:Alpha 混合的 fillrate

礼物特效通常是半透明的,GPU 要做 Alpha 混合(glBlendFunc)。如果特效区域大、层数多,每个像素都要多次读写,fill-rate 成为瓶颈,尤其是低端机的 GPU。

优化:

  • 预乘 Alpha:特效资源导出时用 Premultiplied Alpha,GPU 混合公式简化成 glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA),减少乘法;

  • 裁剪无效区域:特效的透明边界不要参与绘制,缩小绘制区域的 bounding box;

  • 分层降级:低端机只渲染 P0 层特效,P1/P2 层直接跳过。

瓶颈三:主线程卡顿

即使 GPU 渲染,解码、纹理上传的准备工作仍在主线程,高频礼物会卡住 UI。

优化:

  • 异步解码:SVGA/VAP 的解码扔到后台线程,解码完回调主线程;

  • 纹理上传队列:主线程只负责排队,渲染线程批量上传纹理;

  • 对象池:Bitmap、Paint、Matrix 这些高频对象用对象池复用,减少 GC 压力。


五、常见渲染 Bug 与排查

黑屏/不显示

原因:

  • 纹理 ID 错误或已被释放;

  • GL 上下文不在当前线程;

  • FBO 绑定状态错乱。

排查:

int error = GLES20.glGetError();
if (error != GLES20.GL_NO_ERROR) {
    Log.e("GL", "OpenGL error: " + error);
}

花屏/撕裂

原因:

  • 纹理数据写坏了(buffer overflow);

  • 多线程同时操作同一个纹理;

  • Bitmap 提前 recycle 了,但纹理还在引用。

排查:确保 glTexImage2D 前 Bitmap 有效,上传完立即 recycle;纹理操作加锁或都在同一 GL 线程。

内存泄漏

原因:纹理对象(glGenTextures 生成的 ID)没有 glDeleteTextures 释放,GPU 显存一直涨。

排查:

class TexturePool {
    private final Set<Integer> allocatedTextures = new HashSet<>();

    int acquire() {
        int[] ids = new int[1];
        GLES20.glGenTextures(1, ids, 0);
        allocatedTextures.add(ids[0]);
        return ids[0];
    }

    void release(int textureId) {
        GLES20.glDeleteTextures(1, new int[]{textureId}, 0);
        allocatedTextures.remove(textureId);
    }

    void checkLeaks() {
        if (!allocatedTextures.isEmpty()) {
            Log.w("Leak", "未释放的纹理: " + allocatedTextures);
        }
    }
}

六、上线后要盯的渲染指标

  • 特效帧率(FPS):单独统计特效层的帧率,和直播帧率分开看,定位是特效拖累还是直播本身卡;

  • 纹理上传耗时(P50/P95):glTexImage2D 前后打点,超过 5ms 就是瓶颈;

  • GPU 占用率:Android Profiler 或 adb shell dumpsys gfxinfo 查看,特效播放时 GPU 使用率不应超 70%;

  • 主线程卡顿率:特效触发后,主线程单帧耗时 >16ms 的占比,反映解码/上传是否阻塞 UI;

  • 渲染异常率:黑屏、花屏、崩溃的占比,按机型、GPU 型号分层看(Mali/Adreno/PowerVR 表现差异大)。

long uploadStart = System.nanoTime();
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
long uploadCost = (System.nanoTime() - uploadStart) / 1_000_000;  // ms
tracker.track("gift_texture_upload", Map.of("cost_ms", uploadCost, "size", bitmap.getByteCount()));

总结

直播礼物特效的渲染与合成,核心链路:

  1. 解码:SVGA 反序列化 protobuf、VAP 视频解码 + Alpha 混合、PAG 直出纹理;

  2. 逐帧绘制:CPU Canvas 绘制(兼容性好但慢)vs GPU 离屏渲染(快但复杂);

  3. 与直播合成:View 叠加(简单但帧率差)vs 离屏 FBO 合成(最优但要自己管 GL)vs SDK 回调(推荐);

  4. 性能优化:降分辨率、纹理复用、PBO 异步上传、预乘 Alpha、裁剪无效区域、异步解码、对象池;

  5. Bug 排查:黑屏查 GL 错误码、花屏查多线程竞争、内存泄漏查纹理未释放。

一句话:特效渲染的本质,是把一个动画文件,在每一帧变成一张 GPU 纹理,再和直播画面混合,同时不拖累主线程、不爆显存、不掉帧。

值得补一句的是:这些问题里很大一部分,早在写代码之前、在设计和导出阶段就已经定了。一个用了合理分辨率、透明干净、图层数量克制的特效,渲染成本会比“看上去一模一样”的另一个低得多。这正是我们星集在做的事:把礼物特效做成能在真机上流畅渲染的样子,按你技术栈需要的格式(SVGA、VAP 或 PAG)交付。如果你正在权衡哪种格式或合成方案适合自己的 App,欢迎找我们聊聊。

最后更新:

相关素材

浪漫斋月戒指双鱼座梦绘新春