摘要:/// 生成球體數據 /// @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。如圖,我們需要使用 GLKMatrix4MakePerspective(float fovyRadians, float aspect, float nearZ, float farZ) 來構造透視投影的變換矩陣。

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

首先看一下最終的效果:

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

一般全景視頻的編碼格式與普通視頻並無區別,只不過它的每一幀都記錄了 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 爲例進行講解:

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

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

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

(2)根據 頂點數索引數 申請 頂點數組索引數組 的內存空間。

(3)開始創建頂點數據。這裏遍歷每一個頂點,計算每一個頂點的頂點座標和對應的紋理座標。

爲了方便表示,將 角 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 默認使用的是 正射投影 ,正射投影的特點是遠近圖像的大小是一樣的。

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

如圖,我們需要使用 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 這個範圍內。所以通過透視投影的矩陣變換後,看到的是從球面的內部,由 平截頭體 截出來的圖像。

因爲是球面內部的圖像,所以是鏡像的(這個問題後面一起解決)。

三、視角移動

手機設備內置有陀螺儀,可以實時獲取到設備的 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];
    }
}
相關文章