對OpenGL ES學習了一段時間,今天實現一個360度的全景視頻播放器。本博客的代碼可在我的github倉庫下載,但如果感覺可以就start一下你們的支持是我將博客寫下去的動力!本博客的demo是我之前在面試一家做VR視頻的公司時寫的,雖然我拿著做好的demo去面試了但還是沒讓我見技術人員人事就把我打發了。
注意:本博客已假定你對OpenGL ES已經具備基本知識,很多OpenGL ES基礎理論不再解釋,如果感覺有不理解的地方可以找別的博客進行學習。我建議對于同一個知識最好看不同的博客有比較的學習,這樣才會有不一樣的收獲。
全景視頻原理
一、拍攝設備
全景視頻在拍攝時是多個攝像機同時在一個點向四面八方拍攝。下面在網上找了一個拍攝設備的圖片。
我在面試的那家做VR視頻的公司見到了他們的拍攝設備發面只有2個攝像頭,類似下面的圖片里的設備,但通過只有2個攝像頭的設備來拼接成的全景視頻在移動視角時會有強烈的拉伸感,我在觀看一些小公司的App里的全景視頻時會有這樣的體驗,具體視頻質量的好壞這里就不深入討論了大家可以自己在各平臺對比體驗一下就知道了。
如果要拍攝的是VR視頻每個方向上會有2個攝像頭(區分左右眼),不過這種視頻很少見。個人感覺很多公司都是通過全景視頻做一下處理來生成VR視頻的,所以沒有很強的立體感,這也是個人的感覺,如果有對VR視頻了解更多的大神可以在下面評論處說明以供大家共同學習。
二、視頻拼接
后期視頻的制作會將視頻目標當作一個球來制作成視頻,也就是說最后的視頻是要渲染到一個球上面的??梢韵胂袢绻闷匠5牟シ牌鱽聿シ诺男Ч麍D像的上面和下面是被拉伸的,下面是一個在網上下載的全景視頻在平常的播放器播放的效果,可以看出下面的路面被嚴重拉伸,上面其實也被拉伸了因為是黑色的所以看著不太明顯。
最后這個視頻是要渲染到一個球上面的,而我們的視角在是在球的中心點,這樣就可以向四面八方去觀看了。下面是一個我做好的播放器里播放的效果,有對比才能看出圖像被拉伸的情況。
代碼實現
一、生成頂點數據
從上面的介紹可知我們只要生成一個球體并將視頻的每一幀渲染到球上面就可以了。怎么生成球體可以看我另一篇博客OpenGL ES學習筆記之四(創建球體),這里只簡單介紹一下。生成球的頂點信息:
/**
繪制一個球的頂點
@param num 傳入要生成的頂點的一層的個數(最后生成的頂點個數為 num * num)
@return 返回生成后的頂點
*/
- (Vertex *)getBallDevidNum:(GLint) num{
if (num % 2 == 1) {
return 0;
}
GLfloat delta = 2 * M_PI / num; // 分割的份數
GLfloat ballRaduis = 0.3; // 球的半徑
GLfloat pointZ;
GLfloat pointX;
GLfloat pointY;
GLfloat textureY;
GLfloat textureX;
GLfloat textureYdelta = 1.0 / (num / 2);
GLfloat textureXdelta = 1.0 / num;
GLint layerNum = num / 2.0 + 1; // 層數
GLint perLayerNum = num + 1; // 要讓點再加到起點所以num + 1
Vertex * cirleVertex = malloc(sizeof(Vertex) * perLayerNum * layerNum);
memset(cirleVertex, 0x00, sizeof(Vertex) * perLayerNum * layerNum);
// 層數
for (int i = 0; i < layerNum; i++) {
// 每層的高度(即pointY),為負數讓其從下向上創建
pointY = -ballRaduis * cos(delta * i);
// 每層的半徑
GLfloat layerRaduis = ballRaduis * sin(delta * i);
// 每層圓的點,
for (int j = 0; j < perLayerNum; j++) {
// 計算
pointX = layerRaduis * cos(delta * j);
pointZ = layerRaduis * sin(delta * j);
textureX = textureXdelta * j;
// 解決圖片上下顛倒的問題
textureY = 1 - textureYdelta * i;
cirleVertex[i * perLayerNum + j] = (Vertex){pointX, pointY, pointZ, textureX, textureY};
}
}
return cirleVertex;
}
/**
生成球體的頂點索引數組
@param num 每一層頂點的個數
@return 返回生成好的數組
*/
- (GLuint *)getBallVertexIndex:(GLint)num{
// 每層要多原點兩次
GLint sizeNum = sizeof(GLuint) * (num + 1) * (num + 1);
GLuint * ballVertexIndex = malloc(sizeNum);
memset(ballVertexIndex, 0x00, sizeNum);
GLint layerNum = num / 2 + 1;
GLint perLayerNum = num + 1; // 要讓點再加到起點所以num + 1
for (int i = 0; i < layerNum; i++) {
if (i + 1 < layerNum) {
for (int j = 0; j < perLayerNum; j++) {
// i * perLayerNum * 2每層的下標是原來的2倍
ballVertexIndex[(i * perLayerNum * 2) + (j * 2)] = i * perLayerNum + j;
// 后一層數據
ballVertexIndex[(i * perLayerNum * 2) + (j * 2 + 1)] = (i + 1) * perLayerNum + j;
}
} else {
for (int j = 0; j < perLayerNum; j++) {
// 后最一層數據單獨處理
ballVertexIndex[i * perLayerNum * 2 + j] = i * perLayerNum + j;
}
}
}
return ballVertexIndex;
}
/**
設置VBO
*/
- (void)setupVertexVBO {
// 生成頂點數據(包括紋理頂點)
Vertex * vertex = [self getBallDevidNum:kDivisionNum];
// 生成頂點數據對應的索引數據
GLuint * indexes = [self getBallVertexIndex:kDivisionNum];
// 設置頂點數據的VBO緩存
GLuint vertexBufferVBO;
glGenBuffers(1, &vertexBufferVBO);
glBindBuffer(GL_ARRAY_BUFFER, vertexBufferVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(Vertex) * (kDivisionNum + 1) * (kDivisionNum / 2 + 1), vertex, GL_STATIC_DRAW);
// 設置頂點索引數據的VBO緩存
GLuint indexBufferVBO;
glGenBuffers(1, &indexBufferVBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBufferVBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(GLuint) * (kDivisionNum + 1) * (kDivisionNum + 1), indexes, GL_STATIC_DRAW);
// 設置頂點數據在從VBO中讀取和傳遞的指針設置
glVertexAttribPointer(_myPositionSlot, 3, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 5, (GLvoid *)NULL);
glEnableVertexAttribArray(_myPositionSlot);
glVertexAttribPointer(_myTextureCoordsSlot, 2, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 5, (GLfloat *)NULL + 3);
glEnableVertexAttribArray(_myTextureCoordsSlot);
free(vertex);
free(indexes);
}
這里提一下,在我另一篇博客里生成的球體視角是在球體的外面,想讓視角在球體內可將projectionMatrix傳的值中讓其沿Z軸偏移的代碼刪除即可,因為我們生成的球體默認視角就在球體的中心,我將projectionMatrix沿Z軸偏移了-3才讓視角變成了外面。
二、生成紋理數據
首先我們要知道視頻其實就是一幀一幀的圖片,我們只要將每一幀圖片在相應時間點渲染到球體上就可以實現視頻播放了。首先我們獲取視頻數據:
/**
設置播放數據
*/
- (void)setupPlayerData{
NSString * path = [[NSBundle mainBundle] pathForResource:@"demo.mp4" ofType:nil];
// 獲取視頻資源信息
_myAsset = [AVAsset assetWithURL:[NSURL fileURLWithPath:path]];
_myPlayerItem = [[AVPlayerItem alloc] initWithAsset:_myAsset];
// 創建視頻播放器
_myPlyaer = [[AVPlayer alloc] initWithPlayerItem:_myPlayerItem];
// 播放視頻
[_myPlyaer play];
// 設置視頻格式信息
NSDictionary * dic = [NSDictionary dictionaryWithObjectsAndKeys:@(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange), kCVPixelBufferPixelFormatTypeKey, nil];
// 創建視頻輸出,后面會從_myPlayerOutput里讀取視頻的每一幀圖像信息
_myPlayerOutput = [[AVPlayerItemVideoOutput alloc] initWithOutputSettings:dic];
[_myPlayerItem addOutput:_myPlayerOutput];
}
通過上面的步驟我們可以獲取視頻每一幀的CVPixelBufferRef信息,原來我是用普通的圖片轉紋理的方式實現的,但運行的時候總出錯后來查了一下資料發現CoreVideo庫里有專門轉OpenGL紋理的方法(CVOpenGLESTextureCacheCreateTextureFromImage),后來就用該方法解決了問題。方法如下,由于學習時間不長下面的參數注解只是我個人見解,可能會有錯誤這里供大家做個參考吧。
CVOpenGLESTextureCacheCreateTextureFromImage(
CFAllocatorRef CV_NULLABLE allocator, // 分配紋理對象,可能為NULL,
CVOpenGLESTextureCacheRef CV_NONNULL textureCache, // 紋理緩存
CVImageBufferRef CV_NONNULL sourceImage, // 傳入的圖像數據,用于生成相應紋理
CFDictionaryRef CV_NULLABLE textureAttributes, // 創建紋理的屬字典,可傳NULL
GLenum target, // 渲染的目標,可為GL_TEXTURE_2D和GL_RENDERBUFFER
GLint internalFormat, // 圖片的色彩空間信息,如GL_RGBA、GL_LUMINANCE、GL_RGBA8_OES、 GL_RG和 GL_RED
GLsizei width, // 圖片的寬
GLsizei height, // 圖片的高
GLenum format, // 每個像素數據的色彩空間,如GL_RGBA、GL_LUMINANCE
GLenum type, // 每個像素數據的類型
size_t planeIndex, // 視頻數據buffer里哪一個平面的數據
CVOpenGLESTextureRef * CV_NONNULL textureOut // 最終輸出的紋理信息
)
我們獲取的視頻數據是YUV(其實是YCbCr的色彩空間)格式的,該種格式主要是用于圖像數據的的壓縮。該種格式將像素信息分為1個亮度通道和2個色度通道。由于人眼對色度的感知不太敏感而對亮度感知很敏感,當我們減弱色度通道的信息時人眼也很難察覺到前后圖像的變化。通過這一原理就可以通過刪除部分像素的色度通道而使用相鄰像素色度數據的方式來顯示圖像,這樣就能實現圖像的壓縮。更詳細的解釋可查看其他資料,這里只做簡單說明。YUV的buffer數據一般會有兩個平面,一個是亮度一個是色度(2個色度通道混在一個平面里),我們都要將其傳給著色器:
/**
設置視頻數據轉紋理
*/
- (void)setupVideoTexture{
CMTime time = [_myPlayerItem currentTime];
// 通過時間獲取相應幀的圖片數據
CVPixelBufferRef pixelBuffer = [_myPlayerOutput copyPixelBufferForItemTime:time itemTimeForDisplay:nil];
if (pixelBuffer == nil) {
return;
}
CVPixelBufferLockBaseAddress(pixelBuffer, 0);
CVReturn result;
GLsizei textureWidth = (GLsizei)CVPixelBufferGetWidth(pixelBuffer);
GLsizei textureHeight = (GLsizei)CVPixelBufferGetHeight(pixelBuffer);
if (_cache == nil) {
NSLog(@"no video texture cache");
}
_lumaTexture = nil;
_chromaTexture = nil;
// 刷新緩沖區保證上次數據正常提交
CVOpenGLESTextureCacheFlush(_cache, 0);
glActiveTexture(GL_TEXTURE0);
result = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
_cache,
pixelBuffer,
nil,
GL_TEXTURE_2D,
GL_RED_EXT,
textureWidth,
textureHeight,
GL_RED_EXT,
GL_UNSIGNED_BYTE,
0,
&_lumaTexture);
if (result != 0) {
NSLog(@"create CVOpenGLESTextureCacheCreateTextureFromImage failure 1 %d", result);
}
// 綁定紋理
glBindTexture(CVOpenGLESTextureGetTarget(_lumaTexture), CVOpenGLESTextureGetName(_lumaTexture));
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// UV
glActiveTexture(GL_TEXTURE1);
result = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
_cache,
pixelBuffer,
nil,
GL_TEXTURE_2D,
GL_RG_EXT,
textureWidth/2,
textureHeight/2,
GL_RG_EXT,
GL_UNSIGNED_BYTE,
1,
&_chromaTexture);
if (result != 0) {
NSLog(@"create CVOpenGLESTextureCacheCreateTextureFromImage failure 2 %d", result);
}
// 綁定紋理
glBindTexture(CVOpenGLESTextureGetTarget(_chromaTexture), CVOpenGLESTextureGetName(_chromaTexture));
CFRelease(_lumaTexture);
CFRelease(_chromaTexture);
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
CVPixelBufferRelease(pixelBuffer);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
三、YUV轉RGBA
圖像顯示的數據是RGBA格式的而我們傳的并不是RGBA格式的而是YUV,所以我們還要將YUV數據轉成RGBA數據。在網上找了一個YUV轉RGB的公式,在查找資料的時候發現公式有好幾個,其中只是系數的細微差別。公式已在下面列出,其中公式一和公式二都可正常使用,對于公式三可適用于普通的圖片資料的轉換對于視頻格式是不適用的,因為視頻類的色彩通道的取值范圍不同與圖片詳情可看音視頻開發:RGB與YUV相互轉換問題這篇博客。
// YUV轉GRB公式一、
R = 1.164 * (Y - 16) + 1.596 * (V - 128)
G = 1.164 * (Y - 16) - 0.39 * (U - 128) - 0.813 * (V - 128)
B = 1.164 * (Y - 16) + 2.018 * (U - 128)
// YUV轉GRB公式二、
R = 1.164 * (Y - 16) + 1.793 * (V - 128)
G = 1.164 * (Y - 16) - 0.213 * (U - 128) - 0.533 * (V - 128)
B = 1.164 * (Y - 16) + 2.112 * (U - 128)
// YUV轉GRB公式三、
R = Y + 1.402V
G = Y - 0.344U - 0.714V
B = Y + 1.772U
對于色彩空間的轉換要在著色器里進行,我們將片元著色器的代碼寫成如下形式:
precision mediump float;
// 亮度通道紋理
uniform sampler2D myTexture;
// 色度通道紋理
uniform sampler2D samplerUV;
varying vec2 myTextureCoordsOut;
void main()
{
mediump vec3 yuv;
lowp vec3 rgb;
yuv.x = texture2D(myTexture, myTextureCoordsOut).r;;
yuv.yz = texture2D(samplerUV, myTextureCoordsOut).rg;
rgb.r = 1.164 * (yuv.x - 16.0 / 255.0) + 1.793 * (yuv.z - 128.0 / 255.0);
rgb.g = 1.164 * (yuv.x - 16.0 / 255.0) - 0.213 * (yuv.y - 128.0 / 255.0) - 0.533 * (yuv.z - 128.0 / 255.0);
rgb.b = 1.164 * (yuv.x - 16.0 / 255.0) + 2.112 * (yuv.y - 128.0 / 255.0);
gl_FragColor = vec4(rgb, 1.0);
}
上面的代碼我們還可以簡化一下,將上面的公式轉換成矩陣乘法因為GUP更適合矩陣的計算,代碼修改如下:
precision mediump float;
// 亮度通道紋理
uniform sampler2D myTexture;
// 色度通道紋理
uniform sampler2D samplerUV;
varying vec2 myTextureCoordsOut;
void main()
{
// 用一個矩陣來簡化后面YUV轉GRB的計算公式
mat3 conversionColor = mat3(1.164, 1.164, 1.164,
0.0, -0.213, 2.112,
1.793, -0.533, 0.0);
mediump vec3 yuv;
lowp vec3 rgb;
yuv.x = texture2D(myTexture, myTextureCoordsOut).r - (16.0/255.0);
yuv.yz = texture2D(samplerUV, myTextureCoordsOut).rg - vec2(0.5, 0.5);
rgb = conversionColor * yuv;
gl_FragColor = vec4(rgb, 1.0);
}
到這里播放器的主要代碼基本已經完成其他細節可在我github代碼里查看,上面需要一個定時器來不停的調用渲染方法以實現播放不同的幀數據,播放效果的圖以在上面給出過這里不再占地方了。
小優化
這里存在一個問題,球體的外面和里面都會渲染我們從外面看一下效果。
在播放全景視頻的時候我們是不會看外面的圖像,這樣就會造成性能消耗上的浪費。所以我們要把外面剔除不讓其渲染,在原代碼里添加以下代碼:
// 在渲染方法這前寫以下代碼
// 面剔除以提高性能
glEnable(GL_CULL_FACE); // 開啟面剔除
glCullFace(GL_BACK); // 剔除背面
glFrontFace(GL_CW); // 設置順時針為前面
其他的方法都很簡單這里就不啰嗦了。這里已把主要的實現說明了其他細節可以在我的代碼里查看。寫的有點倉促如果有不妥的地方還請各位大神指正。