FSPlayer实现保持宽高比和纹理去黑边细节
复习下 Metal 的 NDC (Normalized Device Coordinates),正好介绍下 FSPlayer 实现保持宽高比和纹理去黑边的实现方式。
FSMetalRenderer 核心定义
这个方法,大概 4年前写的,有些忘了,需要回顾下了,先看逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
- (void)updateVertexIfNeed
{
if (!self.vertexChanged) {
return;
}
self.vertexChanged = NO;
float x = self.vertexRatio.width;
float y = self.vertexRatio.height;
/*
//https://stackoverflow.com/questions/58702023/what-is-the-coordinate-system-used-in-metal
triangle strip
↑y
V3|V4(1,1)
--|--→x
(-1,-1)V1|V2
📐-->V1V2V3
📐-->V2V3V4
texture
|---->x
|V3 V4
|V1 V2
↓y
*/
float max_t_y = 1.0 * (1 - self.textureCrop.height);
float max_t_x = 1.0 * (1 - self.textureCrop.width);
FSVertex quadVertices[4] =
{ //顶点坐标; 纹理坐标;
{ { -1.0 * x, -1.0 * y }, { 0.f, max_t_y } },
{ { 1.0 * x, -1.0 * y }, { max_t_x, max_t_y } },
{ { -1.0 * x, 1.0 * y }, { 0.f, 0.f } },
{ { 1.0 * x, 1.0 * y }, { max_t_x, 0.f } },
};
/// These are the view and projection transforms.
matrix_float4x4 viewMatrix;
float radian = radians_from_degrees(self.rotateDegrees);
switch (self.rotateType) {
case 1:
{
viewMatrix = matrix4x4_rotation(radian, 1.0, 0.0, 0.0);
viewMatrix = matrix_multiply(viewMatrix, matrix4x4_translation(0.0, 0.0, -0.5));
}
break;
case 2:
{
viewMatrix = matrix4x4_rotation(radian, 0.0, 1.0, 0.0);
viewMatrix = matrix_multiply(viewMatrix, matrix4x4_translation(0.0, 0.0, -0.5));
}
break;
case 3:
{
viewMatrix = matrix4x4_rotation(radian, 0.0, 0.0, 1.0);
}
break;
default:
{
viewMatrix = matrix4x4_identity();
}
break;
}
if (self.autoZRotateDegrees != 0) {
float zRadin = radians_from_degrees(self.autoZRotateDegrees);
viewMatrix = matrix_multiply(matrix4x4_rotation(zRadin, 0.0, 0.0, 1.0),viewMatrix);
}
FSVertexData data = {quadVertices[0],quadVertices[1],quadVertices[2],quadVertices[3],viewMatrix};
self.vertexBuffer = [_device newBufferWithBytes:&data
length:sizeof(data)
options:MTLResourceStorageModeShared]; // 创建顶点缓存
}
定义了顶点坐标和纹理坐标,实现了对渲染目标的比例缩放和局部裁剪。在 Metal 的归一化设备坐标(NDC)中,屏幕中心是 (0, 0),左下角是 (-1, -1),右上角是 (1, 1)。
控制显示区域
通过引入 vertexRatio(即代码中的 x 和 y),你实际上是在对原始的单位正方形进行缩放。如果 x=1.0,y=1.0,内容将铺满整个渲染目标。
上层通过计算出 ratio 可实现内容显示区域的控制,即保持宽高比的几种模式:
- (CGSize)computeNormalizedVerticesRatio:(FSOverlayAttach *)attach drawableSize:(CGSize)drawableSize
{
if (_scalingMode == FSScalingModeFill) {
return CGSizeMake(1.0, 1.0);
}
int frameWidth = attach.w;
int frameHeight = attach.h;
//keep video AVRational
if (attach.sarNum > 0 && attach.sarDen > 0) {
frameWidth = 1.0 * attach.sarNum / attach.sarDen * frameWidth;
}
int zDegrees = 0;
if (_rotatePreference.type == FSRotateZ) {
zDegrees += _rotatePreference.degrees;
}
zDegrees += attach.autoZRotate;
float darRatio = self.darPreference.ratio;
//when video's z rotate degrees is 90 odd multiple
if (abs(zDegrees) / 90 % 2 == 1) {
//need swap user's ratio
if (darRatio > 0.001) {
darRatio = 1.0 / darRatio;
}
//need swap display size
int tmp = drawableSize.width;
drawableSize.width = drawableSize.height;
drawableSize.height = tmp;
}
//apply user dar
if (darRatio > 0.001) {
if (1.0 * attach.w / attach.h > darRatio) {
frameHeight = frameWidth * 1.0 / darRatio;
} else {
frameWidth = frameHeight * darRatio;
}
}
float wRatio = drawableSize.width / frameWidth;
float hRatio = drawableSize.height / frameHeight;
float ratio = 1.0f;
if (_scalingMode == FSScalingModeAspectFit) {
ratio = FFMIN(wRatio, hRatio);
} else if (_scalingMode == FSScalingModeAspectFill) {
ratio = FFMAX(wRatio, hRatio);
}
float nW = (frameWidth * ratio / drawableSize.width);
float nH = (frameHeight * ratio / drawableSize.height);
return CGSizeMake(nW, nH);
}
计算 ratio 时,我们拿显示尺寸除以图像尺寸的目的是计算这个图像需要缩放多少倍才能使得画面尺寸正好等于屏幕尺寸,这个结果就是需要放大的倍数。当你有了两个方向的“撑满倍数”后:
- Aspect Fit (等比适应):取最小值是因为需要留黑边,需要调整的幅度小,有一个方向满足即可。
- Aspect Fill (等比填充):取最大值是因为不需要留黑边,需要调整的幅度大,两个方向都要满足。
假设,现在的缩放设置是 FSScalingModeAspectFit,将一个宽大于高的视频显示在一个横屏上时,wRatio 就会小于 hRatio,那么:
1
2
3
4
5
float wRatio = drawableSize.width / frameWidth;
float ratio = wRatio;
nW = (frameWidth * ratio / drawableSize.width);
= frameWidth * (drawableSize.width / frameWidth) / drawableSize.width;
= 1.0
这个 1.0 最终传入到顶点坐标里就是 float x = self.vertexRatio.width; 那么 X 轴的坐标范围就是 [-1,1],这在 Metal 坐标中意味着顶点横向正好填充到边界,效果就是上下留黑边。
这种做法其实是在做一种“归一化映射”:
- 算出图像为了填满宽、高分别需要放大的“目标倍数”。
- 根据你的缩放模式(Fit 或 Fill)挑选一个倍数。
- 将这个倍数应用到原图尺寸上,再除以屏幕尺寸,转化为 Metal 顶点坐标需要的百分比(即 0.0∼1.0 之间的值)。
纹理坐标 (max_t_x, max_t_y):实现裁剪
纹理坐标(UV 坐标)的取值范围通常是 [0, 1]。其中 (0, 0) 通常对应图片的左上角(取决于 Metal 配置),(1, 1) 对应右下角。 如果你设置 textureCrop.width = 0.2,那么 max_t_x 变为 0.8。
这意味着 GPU 在采样时,只会采样纹理中 0.0 到 0.8 的部分,剩下右侧的 20% 像素被“丢弃”了,从而实现了去掉右侧黑边的裁剪逻辑。
正向映射关系表
代码中的 quadVertices 数组定义了四个顶点的联动关系。我们可以通过下表看清这种“双重控制”:
| 顶点位置 (NDC) | 纹理坐标 (UV) | 视觉效果描述 |
|---|---|---|
| (-x, -y) | (0, max_t_y) | 左下角:对应纹理截取区域的左下 |
| (x, -y) | (max_t_x, max_t_y) | 右下角:对应纹理截取区域的右下 |
| (-x, y) | (0, 0) | 左上角:对应纹理截取区域的左上 |
| (x, y) | (max_t_x, 0) | 右上角:对应纹理截取区域的右上 |
插值(Interpolation)
顶点着色器接收这 4 个点,确定纹理在屏幕上的位置。光栅化阶段会根据这 4 个点围成的三角形,自动计算出中间千万个像素点的坐标。片元着色器根据插值后的纹理坐标,去原始纹理中“抠”出对应的颜色值。