7、YUV数据渲染:YUV色彩空间简介、YUV420P/NV12/NV21格式、RenderScript转换、OpenGL ES渲染YUV

说到视频渲染,YUV 是个绕不开的话题。我记得刚入行那会儿,第一次拿到摄像头采集的数据,直接当 RGB 往屏幕上怼,结果颜色全乱套了。后来才明白,原来摄像头和视频编码器用的都是 YUV,而不是我们熟悉的 RGB。

这一章,我们就来彻底搞懂 YUV。从色彩空间的基本概念,到几种常见的 YUV 格式,再到如何在 Android 上把它们渲染出来。嗯,内容不少,但都很实用。

YUV 色彩空间:为什么视频不用 RGB?

YUV 和 RGB 一样,都是描述颜色的方式。但 YUV 有个核心优势:亮度和色度分离

  • Y 代表亮度(Luma),就是画面的明暗信息。
  • U 和 V 代表色度(Chroma),负责描述颜色。

人眼对亮度变化很敏感,但对颜色细节其实没那么敏感。所以视频编码时,可以保留完整的 Y 信息,而对 UV 进行压缩。这就是 YUV 采样格式的由来。

核心思想:YUV 允许我们对色度信息进行降采样,大幅减少数据量,而人眼几乎察觉不到画质损失。视频压缩能做得那么小,YUV 功不可没。

三种常见的 YUV 格式

在 Android 平台上,我们最常打交道的 YUV 格式有三种:YUV420P、NV12 和 NV21。它们都属于 YUV420 家族,意思是每 4 个 Y 像素共享一组 UV。

YUV420P(也叫 I420)

这种格式是「平面」存储的。所有 Y 数据排在一起,然后所有 U 数据,最后所有 V 数据。结构非常清晰。

// YUV420P 内存布局
[YYYYYYYYYYYYYYYY] [UUUU] [VVVV]
// 假设图像宽高为 w x h
// Y 平面大小:w * h
// U 平面大小:w/2 * h/2
// V 平面大小:w/2 * h/2

我在项目中遇到过一个问题:用 MediaCodec 解码出来的数据,默认就是 YUV420P。但有些渲染器只认 NV12,这就得做格式转换。

NV12

NV12 是「半平面」存储。Y 数据单独一个平面,UV 数据交错存放在另一个平面。顺序是 U、V、U、V……

// NV12 内存布局
[YYYYYYYYYYYYYYYY] [UVUVUVUV]
// Y 平面大小:w * h
// UV 交错平面大小:w/2 * h/2 * 2 = w * h / 2

Android 的 Camera2 API 在 YUV_420_888 格式下,默认输出就是 NV12 居多。嗯,这个要注意。

NV21

NV21 和 NV12 几乎一样,唯一的区别是 UV 交错平面的顺序:V 在前,U 在后。

// NV21 内存布局
[YYYYYYYYYYYYYYYY] [VUVUVUVU]
// 大小和 NV12 完全一致

老版本的 Camera API(Camera1)默认输出就是 NV21。如果你还在维护老项目,大概率会碰到它。

我的习惯:在代码里统一用 YUV420P 作为中间格式。不管输入是 NV12 还是 NV21,先转成 YUV420P,再交给下游处理。这样逻辑清晰,不容易出错。

YUV 格式对比一览

格式 存储方式 UV 顺序 常见场景
YUV420P 全平面 Y / U / V 独立 MediaCodec 解码输出
NV12 半平面 UV 交错,U 在前 Camera2、硬件编码器
NV21 半平面 VU 交错,V 在前 Camera1 预览回调

RenderScript 转换:CPU 不够,脚本凑

早期 Android 上做 YUV 转 RGB,很多人用 RenderScript。它能在 CPU 和 GPU 之间自动调度,写起来也简单。

我曾经在一个低端机上用 RenderScript 做 YUV 转 Bitmap,效果还行。但后来发现,RenderScript 在 Android 10 以后被标记为废弃了。嗯,新项目不建议再用。

// RenderScript 转换示例(仅供参考)
RenderScript rs = RenderScript.create(context);
ScriptIntrinsicYuvToRGB yuvToRgb = ScriptIntrinsicYuvToRGB.create(rs, Element.U8_4(rs));
Allocation input = Allocation.createSized(rs, Element.U8(rs), yuvData.length);
Allocation output = Allocation.createTyped(rs, Type.createXY(rs, Element.RGBA_8888(rs), width, height));

input.copyFrom(yuvData);
yuvToRgb.setInput(input);
yuvToRgb.forEach(output);

Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
output.copyTo(bitmap);

注意:RenderScript 的 YUV 转 RGB 只支持 NV21 格式。如果你手里是 YUV420P 或 NV12,得先转成 NV21 才能用。这其实挺坑的。

OpenGL ES 渲染 YUV:这才是正道

说实话,用 CPU 做 YUV 转 RGB 效率太低了。尤其是 1080p 甚至 4K 的视频,每一帧都要转换,CPU 根本扛不住。

正确的做法是用 OpenGL ES,把 YUV 数据上传到 GPU,在着色器里完成颜色转换。GPU 天生适合做这种并行计算。

核心思路

我们需要把 Y、U、V 分别上传到三个纹理。然后在片段着色器里,用标准的 YUV 转 RGB 矩阵做计算。

// 片段着色器:YUV420P 转 RGB
#version 300 es
precision mediump float;
uniform sampler2D yTexture;
uniform sampler2D uTexture;
uniform sampler2D vTexture;
in vec2 vTexCoord;
out vec4 fragColor;

void main() {
    float y = texture(yTexture, vTexCoord).r;
    float u = texture(uTexture, vTexCoord).r - 0.5;
    float v = texture(vTexture, vTexCoord).r - 0.5;

    vec3 rgb;
    rgb.r = y + 1.402 * v;
    rgb.g = y - 0.344 * u - 0.714 * v;
    rgb.b = y + 1.772 * u;

    fragColor = vec4(rgb, 1.0);
}

对于 NV12 和 NV21,只需要两个纹理:一个存 Y,一个存 UV 交错数据。着色器里从 UV 纹理中分别取出 U 和 V 即可。

避坑指南:我曾经在纹理上传时,忘了设置纹理的 GL_LUMINANCE 和 GL_ALPHA 格式。结果颜色一直不对。Y 纹理用 GL_LUMINANCE,UV 纹理用 GL_LUMINANCE_ALPHA,这个别搞错了。

知识体系总览

下面这张图,把 YUV 渲染的核心流程串起来了。从数据源到最终显示,每一步都离不开对 YUV 格式的理解。

YUV 渲染核心流程 数据源 Camera / MediaCodec / 文件 YUV 格式 YUV420P / NV12 / NV21 渲染方式 OpenGL ES / RenderScript 输入数据 格式识别与处理 GPU 渲染输出 格式转换细节 • YUV420P → 三个纹理(Y / U / V) • NV12 → 两个纹理(Y / UV 交错,U 在前) • NV21 → 两个纹理(Y / VU 交错,V 在前) 核心:YUV 转 RGB 矩阵运算在 GPU 片段着色器中完成

小结

YUV 渲染这件事,说难不难,说简单也不简单。关键是把格式搞清楚,把纹理上传和着色器写对。我个人建议,新项目直接上 OpenGL ES,别再用 RenderScript 了。毕竟 Google 自己都废弃了,咱也没必要抱着不放。

嗯,这一章的内容就到这里。代码示例都是可以直接用的,但具体参数(比如纹理大小、对齐方式)还得根据你的实际数据来调整。多试几次,你就能找到感觉。