對OpenGL ES學(xué)習(xí)了一段時間,今天實現(xiàn)一個360度的全景視頻播放器娩井。本博客的代碼可在我的github倉庫下載暇屋,但如果感覺可以就start一下你們的支持是我將博客寫下去的動力!本博客的demo是我之前在面試一家做VR視頻的公司時寫的洞辣,雖然我拿著做好的demo去面試了但還是沒讓我見技術(shù)人員人事就把我打發(fā)了率碾。
注意:本博客已假定你對OpenGL ES已經(jīng)具備基本知識,很多OpenGL ES基礎(chǔ)理論不再解釋屋彪,如果感覺有不理解的地方可以找別的博客進行學(xué)習(xí)所宰。我建議對于同一個知識最好看不同的博客有比較的學(xué)習(xí),這樣才會有不一樣的收獲畜挥。
全景視頻原理
一仔粥、拍攝設(shè)備
全景視頻在拍攝時是多個攝像機同時在一個點向四面八方拍攝。下面在網(wǎng)上找了一個拍攝設(shè)備的圖片蟹但。
我在面試的那家做VR視頻的公司見到了他們的拍攝設(shè)備發(fā)面只有2個攝像頭躯泰,類似下面的圖片里的設(shè)備,但通過只有2個攝像頭的設(shè)備來拼接成的全景視頻在移動視角時會有強烈的拉伸感华糖,我在觀看一些小公司的App里的全景視頻時會有這樣的體驗麦向,具體視頻質(zhì)量的好壞這里就不深入討論了大家可以自己在各平臺對比體驗一下就知道了。
如果要拍攝的是VR視頻每個方向上會有2個攝像頭(區(qū)分左右眼)客叉,不過這種視頻很少見诵竭。個人感覺很多公司都是通過全景視頻做一下處理來生成VR視頻的,所以沒有很強的立體感兼搏,這也是個人的感覺卵慰,如果有對VR視頻了解更多的大神可以在下面評論處說明以供大家共同學(xué)習(xí)。
二佛呻、視頻拼接
后期視頻的制作會將視頻目標當作一個球來制作成視頻裳朋,也就是說最后的視頻是要渲染到一個球上面的∠胖可以想像如果用平常的播放器來播放的效果圖像的上面和下面是被拉伸的鲤嫡,下面是一個在網(wǎng)上下載的全景視頻在平常的播放器播放的效果,可以看出下面的路面被嚴重拉伸绑莺,上面其實也被拉伸了因為是黑色的所以看著不太明顯暖眼。
最后這個視頻是要渲染到一個球上面的,而我們的視角在是在球的中心點紊撕,這樣就可以向四面八方去觀看了玫镐。下面是一個我做好的播放器里播放的效果捍岳,有對比才能看出圖像被拉伸的情況咖气。
代碼實現(xiàn)
一、生成頂點數(shù)據(jù)
從上面的介紹可知我們只要生成一個球體并將視頻的每一幀渲染到球上面就可以了惭缰。怎么生成球體可以看我另一篇博客OpenGL ES學(xué)習(xí)筆記之四(創(chuàng)建球體),這里只簡單介紹一下。生成球的頂點信息:
/**
繪制一個球的頂點
@param num 傳入要生成的頂點的一層的個數(shù)(最后生成的頂點個數(shù)為 num * num)
@return 返回生成后的頂點
*/
- (Vertex *)getBallDevidNum:(GLint) num{
if (num % 2 == 1) {
return 0;
}
GLfloat delta = 2 * M_PI / num; // 分割的份數(shù)
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; // 層數(shù)
GLint perLayerNum = num + 1; // 要讓點再加到起點所以num + 1
Vertex * cirleVertex = malloc(sizeof(Vertex) * perLayerNum * layerNum);
memset(cirleVertex, 0x00, sizeof(Vertex) * perLayerNum * layerNum);
// 層數(shù)
for (int i = 0; i < layerNum; i++) {
// 每層的高度(即pointY)笼才,為負數(shù)讓其從下向上創(chuàng)建
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;
}
/**
生成球體的頂點索引數(shù)組
@param num 每一層頂點的個數(shù)
@return 返回生成好的數(shù)組
*/
- (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;
// 后一層數(shù)據(jù)
ballVertexIndex[(i * perLayerNum * 2) + (j * 2 + 1)] = (i + 1) * perLayerNum + j;
}
} else {
for (int j = 0; j < perLayerNum; j++) {
// 后最一層數(shù)據(jù)單獨處理
ballVertexIndex[i * perLayerNum * 2 + j] = i * perLayerNum + j;
}
}
}
return ballVertexIndex;
}
/**
設(shè)置VBO
*/
- (void)setupVertexVBO {
// 生成頂點數(shù)據(jù)(包括紋理頂點)
Vertex * vertex = [self getBallDevidNum:kDivisionNum];
// 生成頂點數(shù)據(jù)對應(yīng)的索引數(shù)據(jù)
GLuint * indexes = [self getBallVertexIndex:kDivisionNum];
// 設(shè)置頂點數(shù)據(jù)的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);
// 設(shè)置頂點索引數(shù)據(jù)的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);
// 設(shè)置頂點數(shù)據(jù)在從VBO中讀取和傳遞的指針設(shè)置
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);
}
這里提一下漱受,在我另一篇博客里生成的球體視角是在球體的外面,想讓視角在球體內(nèi)可將projectionMatrix傳的值中讓其沿Z軸偏移的代碼刪除即可骡送,因為我們生成的球體默認視角就在球體的中心昂羡,我將projectionMatrix沿Z軸偏移了-3才讓視角變成了外面。
二摔踱、生成紋理數(shù)據(jù)
首先我們要知道視頻其實就是一幀一幀的圖片虐先,我們只要將每一幀圖片在相應(yīng)時間點渲染到球體上就可以實現(xiàn)視頻播放了。首先我們獲取視頻數(shù)據(jù):
/**
設(shè)置播放數(shù)據(jù)
*/
- (void)setupPlayerData{
NSString * path = [[NSBundle mainBundle] pathForResource:@"demo.mp4" ofType:nil];
// 獲取視頻資源信息
_myAsset = [AVAsset assetWithURL:[NSURL fileURLWithPath:path]];
_myPlayerItem = [[AVPlayerItem alloc] initWithAsset:_myAsset];
// 創(chuàng)建視頻播放器
_myPlyaer = [[AVPlayer alloc] initWithPlayerItem:_myPlayerItem];
// 播放視頻
[_myPlyaer play];
// 設(shè)置視頻格式信息
NSDictionary * dic = [NSDictionary dictionaryWithObjectsAndKeys:@(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange), kCVPixelBufferPixelFormatTypeKey, nil];
// 創(chuàng)建視頻輸出派敷,后面會從_myPlayerOutput里讀取視頻的每一幀圖像信息
_myPlayerOutput = [[AVPlayerItemVideoOutput alloc] initWithOutputSettings:dic];
[_myPlayerItem addOutput:_myPlayerOutput];
}
通過上面的步驟我們可以獲取視頻每一幀的CVPixelBufferRef信息蛹批,原來我是用普通的圖片轉(zhuǎn)紋理的方式實現(xiàn)的,但運行的時候總出錯后來查了一下資料發(fā)現(xiàn)CoreVideo庫里有專門轉(zhuǎn)OpenGL紋理的方法(CVOpenGLESTextureCacheCreateTextureFromImage)篮愉,后來就用該方法解決了問題腐芍。方法如下,由于學(xué)習(xí)時間不長下面的參數(shù)注解只是我個人見解试躏,可能會有錯誤這里供大家做個參考吧猪勇。
CVOpenGLESTextureCacheCreateTextureFromImage(
CFAllocatorRef CV_NULLABLE allocator, // 分配紋理對象,可能為NULL颠蕴,
CVOpenGLESTextureCacheRef CV_NONNULL textureCache, // 紋理緩存
CVImageBufferRef CV_NONNULL sourceImage, // 傳入的圖像數(shù)據(jù)泣刹,用于生成相應(yīng)紋理
CFDictionaryRef CV_NULLABLE textureAttributes, // 創(chuàng)建紋理的屬字典,可傳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, // 每個像素數(shù)據(jù)的色彩空間锥惋,如GL_RGBA昌腰、GL_LUMINANCE
GLenum type, // 每個像素數(shù)據(jù)的類型
size_t planeIndex, // 視頻數(shù)據(jù)buffer里哪一個平面的數(shù)據(jù)
CVOpenGLESTextureRef * CV_NONNULL textureOut // 最終輸出的紋理信息
)
我們獲取的視頻數(shù)據(jù)是YUV(其實是YCbCr的色彩空間)格式的,該種格式主要是用于圖像數(shù)據(jù)的的壓縮膀跌。該種格式將像素信息分為1個亮度通道和2個色度通道遭商。由于人眼對色度的感知不太敏感而對亮度感知很敏感,當我們減弱色度通道的信息時人眼也很難察覺到前后圖像的變化捅伤。通過這一原理就可以通過刪除部分像素的色度通道而使用相鄰像素色度數(shù)據(jù)的方式來顯示圖像劫流,這樣就能實現(xiàn)圖像的壓縮。更詳細的解釋可查看其他資料,這里只做簡單說明祠汇。YUV的buffer數(shù)據(jù)一般會有兩個平面仍秤,一個是亮度一個是色度(2個色度通道混在一個平面里),我們都要將其傳給著色器:
/**
設(shè)置視頻數(shù)據(jù)轉(zhuǎn)紋理
*/
- (void)setupVideoTexture{
CMTime time = [_myPlayerItem currentTime];
// 通過時間獲取相應(yīng)幀的圖片數(shù)據(jù)
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;
// 刷新緩沖區(qū)保證上次數(shù)據(jù)正常提交
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轉(zhuǎn)RGBA
圖像顯示的數(shù)據(jù)是RGBA格式的而我們傳的并不是RGBA格式的而是YUV诗力,所以我們還要將YUV數(shù)據(jù)轉(zhuǎn)成RGBA數(shù)據(jù)。在網(wǎng)上找了一個YUV轉(zhuǎn)RGB的公式我抠,在查找資料的時候發(fā)現(xiàn)公式有好幾個苇本,其中只是系數(shù)的細微差別。公式已在下面列出菜拓,其中公式一和公式二都可正常使用瓣窄,對于公式三可適用于普通的圖片資料的轉(zhuǎn)換對于視頻格式是不適用的,因為視頻類的色彩通道的取值范圍不同與圖片詳情可看音視頻開發(fā):RGB與YUV相互轉(zhuǎn)換問題這篇博客纳鼎。
// YUV轉(zhuǎn)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轉(zhuǎn)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轉(zhuǎn)GRB公式三喷橙、
R = Y + 1.402V
G = Y - 0.344U - 0.714V
B = Y + 1.772U
對于色彩空間的轉(zhuǎn)換要在著色器里進行啥么,我們將片元著色器的代碼寫成如下形式:
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);
}
上面的代碼我們還可以簡化一下,將上面的公式轉(zhuǎn)換成矩陣乘法因為GUP更適合矩陣的計算贰逾,代碼修改如下:
precision mediump float;
// 亮度通道紋理
uniform sampler2D myTexture;
// 色度通道紋理
uniform sampler2D samplerUV;
varying vec2 myTextureCoordsOut;
void main()
{
// 用一個矩陣來簡化后面YUV轉(zhuǎn)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);
}
到這里播放器的主要代碼基本已經(jīng)完成其他細節(jié)可在我github代碼里查看悬荣,上面需要一個定時器來不停的調(diào)用渲染方法以實現(xiàn)播放不同的幀數(shù)據(jù),播放效果的圖以在上面給出過這里不再占地方了疙剑。
小優(yōu)化
這里存在一個問題,球體的外面和里面都會渲染我們從外面看一下效果言缤。
在播放全景視頻的時候我們是不會看外面的圖像管挟,這樣就會造成性能消耗上的浪費轿曙。所以我們要把外面剔除不讓其渲染,在原代碼里添加以下代碼:
// 在渲染方法這前寫以下代碼
// 面剔除以提高性能
glEnable(GL_CULL_FACE); // 開啟面剔除
glCullFace(GL_BACK); // 剔除背面
glFrontFace(GL_CW); // 設(shè)置順時針為前面
其他的方法都很簡單這里就不啰嗦了僻孝。這里已把主要的實現(xiàn)說明了其他細節(jié)可以在我的代碼里查看。寫的有點倉促如果有不妥的地方還請各位大神指正您单。