使用 OpenGL ES 實現全景播放器

雷曼同學發表於2020-03-22

全景視訊在播放的時候,可以自由地旋轉視角。如果結合手機的陀螺儀,全景視訊在移動端可以具備更好的瀏覽體驗。本文主要介紹如何基於 AVPlayer 實現一個全景播放器。

首先看一下最終的效果:

使用 OpenGL ES 實現全景播放器

在上一篇文章中,我們瞭解瞭如何對視訊進行圖形處理。(如果還不瞭解的話,建議先閱讀一下。傳送門

一般全景視訊的編碼格式與普通視訊並無區別,只不過它的每一幀都記錄了 360 度的影像資訊。全景播放器需要做的事情是,可以通過引數的設定,播放指定區域的影像。

所以,我們需要實現一個濾鏡,這個濾鏡可以接收一些角度相關的引數,渲染指定區域的影像。然後我們再將這個濾鏡,通過上一篇文章的方式,應用到視訊上,就可以實現全景播放器的效果。

一、構造球面

全景視訊的每一幀影像,其實是一個球面紋理。所以,我們第一步要做的是先構造球面,然後把紋理貼上去。

首先來看一段程式碼:

/// 生成球體資料
/// @param slices 分割數,越多越平滑
/// @param radius 球半徑
/// @param vertices 頂點陣列
/// @param indices 索引陣列
/// @param verticesCount 頂點陣列長度
/// @param indicesCount 索引陣列長度
- (void)genSphereWithSlices:(int)slices
                     radius:(float)radius
                   vertices:(float **)vertices
                    indices:(uint16_t **)indices
              verticesCount:(int *)verticesCount
               indicesCount:(int *)indicesCount {
    // (1)
    int numParallels = slices / 2;
    int numVertices = (numParallels + 1) * (slices + 1);
    int numIndices = numParallels * slices * 6;
    float angleStep = (2.0f * M_PI) / ((float) slices);
    
    // (2)
    if (vertices != NULL) {
        *vertices = malloc(sizeof(float) * 5 * numVertices);
    }
    
    if (indices != NULL) {
        *indices = malloc(sizeof(uint16_t) * numIndices);
    }
    
    // (3)
    for (int i = 0; i < numParallels + 1; i++) {
        for (int j = 0; j < slices + 1; j++) {
            int vertex = (i * (slices + 1) + j) * 5;
            
            if (vertices) {
                (*vertices)[vertex + 0] = radius * sinf(angleStep * (float)i) * sinf(angleStep * (float)j);
                (*vertices)[vertex + 1] = radius * cosf(angleStep * (float)i);
                (*vertices)[vertex + 2] = radius * sinf(angleStep * (float)i) * cosf(angleStep * (float)j);
                (*vertices)[vertex + 3] = (float)j / (float)slices;
                (*vertices)[vertex + 4] = 1.0f - ((float)i / (float)numParallels);
            }
        }
    }
    
    // (4)
    if (indices != NULL) {
        uint16_t *indexBuf = (*indices);
        for (int i = 0; i < numParallels ; i++) {
            for (int j = 0; j < slices; j++) {
                *indexBuf++ = i * (slices + 1) + j;
                *indexBuf++ = (i + 1) * (slices + 1) + j;
                *indexBuf++ = (i + 1) * (slices + 1) + (j + 1);
                
                *indexBuf++ = i * (slices + 1) + j;
                *indexBuf++ = (i + 1) * (slices + 1) + (j + 1);
                *indexBuf++ = i * (slices + 1) + (j + 1);
            }
        }
    }
    
    // (5)
    if (verticesCount) {
        *verticesCount = numVertices * 5;
    }
    if (indicesCount) {
        *indicesCount = numIndices;
    }
}
複製程式碼

這段程式碼參考自 bestswifter/BSPanoramaView 這個庫。它通過分割數球半徑,生成了頂點陣列索引陣列

現在來逐行解釋程式碼的含義:

(1) 這部分程式碼是對原始影像進行分割。下面以 slices = 10 為例進行講解:

使用 OpenGL ES 實現全景播放器

如圖,slices 表示分割的份數,橫向被分割成了 10 份。numParallels 表示層數,縱向分割成 5 份。因為紋理貼到球面時,橫向需要覆蓋 360 度,縱向只需要覆蓋 180 度,所以縱向分割數是橫向分割數的一半。可以把它們想象成經緯度來幫助理解。

numVertices 表示頂點數,如圖中藍色點的個數。numIndices 表示索引數,當使用 EBO 繪製矩形的時候,一個矩形需要 6 個索引值,所以這裡需要用矩形的個數乘以 6 。

angleStep 表示紋理貼到球面後,每一份分割對應的角度增量。

(2) 根據頂點數索引數申請頂點陣列索引陣列的記憶體空間。

(3) 開始建立頂點資料。這裡遍歷每一個頂點,計算每一個頂點的頂點座標和對應的紋理座標。

使用 OpenGL ES 實現全景播放器

為了方便表示,將角 AOB 記為 α ,將角 COD 記為 β ,半徑記為 r 。

ij 都為 0 的時候,表示的是圖中的 G 點。實際上,第一行的 11 個點都會和 G 點重合。

對於圖中的 A 點,它的座標為:

x = r * sin α * sin β
y = r * cos α
z = r * sin α * cos β
複製程式碼

由此易得出頂點座標的計算公式。

而紋理座標只需要根據分割數等比增長。值得注意的是,由於紋理座標的原點在左下角,所以紋理座標的 y 值要取反,即 G 點對應的紋理座標是 (0, 1)

(4) 計算每個索引的值。其實很好理解,比如第一個矩形,它需要用到第一行的前兩個頂點和第二行的前兩個頂點,然後將這四個頂點拆成兩個三角形來組合。

(5) 返回生成的頂點陣列和索引陣列的長度,在實際渲染的時候需要用到。因為每一個頂點有 5 個變數,所以需要乘上 5 。

將上面生成的資料進行繪製,可以看到球面已經生成:

使用 OpenGL ES 實現全景播放器

二、透視投影

OpenGL ES 預設使用的是正射投影,正射投影的特點是遠近影像的大小是一樣的。

在這個例子中,我們需要使用透視投影。透視投影定義了可視空間的平截頭體,處於平截頭體內的物體才會被以近大遠小的方式渲染。

使用 OpenGL ES 實現全景播放器

如圖,我們需要使用 GLKMatrix4MakePerspective(float fovyRadians, float aspect, float nearZ, float farZ) 來構造透視投影的變換矩陣。

fovyRadians 表示視野,fovyRadians 越大,視野越大。aspect 表示視窗的比例,nearZ 表示近平面,farZ 表示遠平面。

在實際使用中,nearZ 一般設定為 0.1farZ 一般設定為 100

具體程式碼如下:

GLfloat aspect = [self outputSize].width / [self outputSize].height;
CGFloat perspective = MIN(MAX(self.perspective, kMinPerspective), kMaxPerspective);
GLKMatrix4 matrix = GLKMatrix4MakePerspective(GLKMathDegreesToRadians(perspective), aspect, 0.1, 100.f);
複製程式碼

因為攝像機的預設座標是 (0, 0, 0),而球面的半徑是 1,處於 0.1 ~ 100 這個範圍內。所以通過透視投影的矩陣變換後,看到的是從球面的內部,由平截頭體截出來的影像。

使用 OpenGL ES 實現全景播放器

因為是球面內部的影像,所以是映象的(這個問題後面一起解決)。

三、視角移動

手機裝置內建有陀螺儀,可以實時獲取到裝置的 rollpitchyaw 資訊,它們被稱為尤拉角

但凡使用過尤拉角,都會遇到一個萬向節死鎖問題,它可以用四元數來解決。所以我們這裡不直接讀取裝置的尤拉角,而是使用四元數,再把四元數轉成旋轉矩陣。

幸運的是,系統也提供四元數的直接訪問介面:

CMQuaternion quaternion = self.motionManager.deviceMotion.attitude.quaternion;
複製程式碼

但是得到的四元數還不能直接使用,需要做三步變換:

第一步: Y 軸取反

matrix = GLKMatrix4Scale(matrix, 1.0f, -1.0f, 1.0f);
複製程式碼

考慮到前面 X 軸映象的問題,所以這一步實際上是:

matrix = GLKMatrix4Scale(matrix, -1.0f, -1.0f, 1.0f);
複製程式碼

第二步: 頂點著色器 y 分量取反

// Panorama.vsh
gl_Position = matrix * vec4(position.x, -position.y, position.z, 1.0);
複製程式碼

第三步: 四元數 x 分量取反

CMQuaternion quaternion = self.motionManager.deviceMotion.attitude.quaternion;
double w = quaternion.w;
double wx = quaternion.x;
double wy = quaternion.y;
double wz = quaternion.z;
self.desQuaternion = GLKQuaternionMake(-wx, wy, wz, w);
複製程式碼

然後通過 self.desQuaternion 才能計算出正確的旋轉矩陣。

GLKMatrix4 rotation = GLKMatrix4MakeWithQuaternion(self.desQuaternion);
matrix = GLKMatrix4Multiply(matrix, rotation);
複製程式碼

四、鏡頭平滑移動

我們在不斷地移動手機時,self.desQuaternion 會不斷地變化。由於移動手機的速度是變化的,所以 self.desQuaternion 的增量是不固定的。這樣導致的結果是畫面卡頓。

所以需要做平滑處理,在當前四元數目標四元數之間,根據一定的增量進行線性插值。這樣能保證鏡頭的移動不會發生突變。

float distance = 0.35;   // 數字越小越平滑,同時移動也更慢
self.srcQuaternion = GLKQuaternionNormalize(GLKQuaternionSlerp(self.srcQuaternion, self.desQuaternion, distance));
複製程式碼

五、渲染引數傳遞

在實際的渲染過程中,外部可以進行渲染引數的調整,來修改渲染的結果。

比如以 perspective 為例,看一下在修改視野大小的時候,具體的引數是怎麼傳遞的。

// MFPanoramaPlayerItem.m
- (void)setPerspective:(CGFloat)perspective {
    _perspective = perspective;
    NSArray *instructions = self.videoComposition.instructions;
    for (MFPanoramaVideoCompositionInstruction *instruction in instructions) {
        instruction.perspective = perspective;
    }
}
複製程式碼

MFPanoramaPlayerItem 中,當 perspective 修改時,會從當前的 videoComposition 中獲取到 MFPanoramaVideoCompositionInstruction 陣列,再遍歷賦值。

// MFPanoramaVideoCompositionInstruction.m
- (void)setPerspective:(CGFloat)perspective {
    _perspective = perspective;
    self.panoramaFilter.perspective = perspective;
}
複製程式碼

MFPanoramaVideoCompositionInstruction 中,修改 perspective 會給 panoramaFilter 賦值。然後 MFPanoramaFilter 開始渲染的時候,在 startRendering 方法中,會根據 perspective 屬性,生成新的變換矩陣。

六、避免後臺渲染

由於 OpenGL ES 不支援後臺渲染,所以要注意,在 APP 切換到後臺前,應該暫停播放。

NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center addObserver:self
           selector:@selector(willResignActive:)
               name:UIApplicationWillResignActiveNotification
             object:nil];
複製程式碼
- (void)willResignActive:(NSNotification *)notification {
    if (self.state == MFPanoramaPlayerStatePlaying) {
        [self pause];
    }
}
複製程式碼

原始碼

請到 GitHub 上檢視完整程式碼。

參考

獲取更佳的閱讀體驗,請訪問原文地址【Lyman's Blog】使用 OpenGL ES 實現全景播放器

相關文章