零. 前言
在APP開發(fā)中,禮物特效是一個比較重要的業(yè)務(wù),而禮物特效的需求中,往特效插入頭像和昵稱又是透明特效的進一步實現(xiàn),即往視頻里面插入遮罩,騰訊開源的VAP是業(yè)界比較靠譜的遮罩實現(xiàn)方案,其效果如下:

一. VAP實現(xiàn)的原理
由于目前的特效是基于MP4格式的視頻實現(xiàn)的,往視頻直接插入頭像目前是無法實現(xiàn)的,需要開發(fā)者每一幀每一幀地對遮罩區(qū)域進行識別,將頭像紋理傳入遮罩區(qū)域中,與遮罩進行融合,最終達到效果,以下是騰訊的原素材MP4:

在騰訊自研的工具中,還有每一幀的rgb、alpha、遮罩位置點信息,在渲染過程中可以采用這些信息,逐幀進行渲染,從而達到以上的效果。
二. 自實現(xiàn)的一些想法
1. 初步想法
在采用騰訊的方案實現(xiàn)之前,自己有一些自實現(xiàn)的小想法,在之前的Metal與圖形渲染三:透明通道視頻中,我們采用了左半部分的R通道代表alpha值,突然有個大膽的想法,如果我們采用G值代表遮罩位置,是不是就可以實現(xiàn)遮罩方案了呢?
說干就干,找美術(shù)導(dǎo)出了一個含有遮罩的Mp4文件,右半部分的R值代表透明度,而G值則代表遮罩位置,G值大于30代表有一個遮罩,可以看到正中間有一個等待被替換的頭像。

2. 動手后的第一個難題
識別區(qū)域很簡單,只要在片段著色器加個判斷:if (255.0 * extraData.g) > 30.0,然后把片段著色器的輸出改為gl_FragColor = vec4(0,0,0, extraData.r);就可以得到以下的效果。

然鵝,在替換紋理的過程中,遇到了我的第一個難題:怎么對需要替換的紋理進行采樣。
我們知道,片段著色器是對光柵化后的像素進行處理,該著色器只會知道當(dāng)前某個像素相對于該原圖像(黃色區(qū)域)的位置,而不知道該像素相對于將要被替換的區(qū)域(藍(lán)色區(qū)域)的相對位置,導(dǎo)致我們無法獲知,當(dāng)前像素點相對于需要替換的紋理(綠色)的位置。

也就是說,即便我們識別出來了某個像素點是遮罩點,但我們無法從另一個輸入的紋理進行采樣,因為我們并不知道當(dāng)前像素點相對于紋理的位置。
3. 解決思路
那如果我們在提取右半邊的時候,在提取渲染的過程中,順便知道了該區(qū)域的大小和具體方位的話,我們在混合渲染時候拿到這個方位,是不是就能提取到像素相對于替換紋理的位置了呢?
這個方位我們可以通過該遮罩的坐標(biāo)(minX, minY, maxX, maxY)確定出遮罩位置的矩形,在下一層中通過讀取這個點相對于矩形的位置,來獲取到需要提取的紋理坐標(biāo)。
但是問題產(chǎn)生了,在GPU渲染的過程中,每個像素的渲染是獨立的,我們并不能存儲坐標(biāo)值,而CPU也無法實時獲取到GPU渲染過程中的某一個參數(shù),只能讀取渲染前/渲染后的完整圖像的像素值,Stack Overflow問題1, Stack Overflow問題2印證了這一點。
那么只能嘗試從渲染后的圖像入手了,GPUImage庫是鏈?zhǔn)秸{(diào)用的,在GPUImageCropFilter裁剪渲染后輸出為GPUImageTwoInputFilter的輸入,我們可以獲取GPUImageCropFilter的輸出(或GPUImageTwoInputFilter的輸入),用CPU讀取每一個像素后,得到(minX, minY, maxX, maxY),再傳入到GPUImageTwoInputFilter中。
這里我們采取的方案是用GPUImageTwoInputFilter的輸入數(shù)據(jù),在渲染開始前獲取到輸入數(shù)據(jù)的值,再傳入到片段著色器中:
- (void)renderToTextureWithVertices:(const GLfloat *)vertices textureCoordinates:(const GLfloat *)textureCoordinates {
[self extractColorFrameBuffer:secondInputFramebuffer];
[super renderToTextureWithVertices:vertices textureCoordinates:textureCoordinates];
}
GPUImageFramebuffer有個byteBuffer,就是像素的BGRA數(shù)組,其排列方式為:
[B,G,R,A,B,G,R,A,....] // bytesPerRow個值
[B,G,R,A,B,G,R,A,....] // bytesPerRow個值
... // height行
[B,G,R,A,B,G,R,A,....] // bytesPerRow個值
既然我們要取G值,那么就需要遍歷每一個像素點的第二位,讀取出來,如果大于30,則判定為遮罩像素,傳遞給片段著色器
- (void)extractColorFrameBuffer:(GPUImageFramebuffer *)frameBuffer
{
maxX = maxY = 0.0;
minX = minY = 1.0;
GLubyte *rawImagePixels = frameBuffer.byteBuffer;
CVPixelBufferRef pixelBuffer = frameBuffer.pixelBuffer;
size_t bytesPerRow = CVPixelBufferGetBytesPerRow( pixelBuffer );
NSUInteger totalNumberOfPixels = round(bytesPerRow / 4 * inputTextureSize.height);
CFAbsoluteTime startTime = CFAbsoluteTimeGetCurrent();
for (NSUInteger currentPixel = 0; currentPixel < totalNumberOfPixels; currentPixel ++) {
// BGRA,取第二位
unsigned int green = rawImagePixels[currentPixel * 4 + 1];
BOOL isGreen = green > 30;
if ([self isValidGreen:isGreen]) {
NSInteger pixelPerRow = bytesPerRow / 4;
NSUInteger xCoordinate = currentPixel % pixelPerRow;
NSUInteger yCoordinate = currentPixel / pixelPerRow;
CGFloat normalizedXCoordinate = (xCoordinate / inputTextureSize.width);
CGFloat normalizedYCoordinate = (yCoordinate / inputTextureSize.height);
minY = MIN(minY, normalizedYCoordinate);
maxY = MAX(maxY, normalizedYCoordinate);
minX = MIN(minX, normalizedXCoordinate);
maxX = MAX(maxX, normalizedXCoordinate);
}
}
NSDictionary *extraDict = @{
@"minX" : @(minX),
@"maxX" : @(maxX),
@"minY" : @(minY),
@"maxY" : @(maxY),
};
__weak typeof(self) weakSelf = self;
[extraDict enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
if ([obj isKindOfClass:[NSNumber class]]) {
[weakSelf setFloat:[obj floatValue] forUniformName:key];
}
}];
//這部分為需要統(tǒng)計時間的代碼
CFAbsoluteTime endTime = (CFAbsoluteTimeGetCurrent() - startTime);
NSLog(@"方法耗時: %f ms", endTime * 1000.0);
}
為排除干擾,需要連續(xù)n個點為遮罩點,才會判斷為遮罩:
#define kGreenThreshold 5
- (BOOL)isValidGreen:(BOOL)isGreen {
if (!isGreen) {
lastGreenCount = 0;
return NO;
} else {
lastGreenCount++;
}
if (lastGreenCount <= kGreenThreshold) {
return NO;
}
return YES;
}
在片段著色器嘗試輸出遮罩區(qū)域:
void main() {
mediump vec4 origin = texture2D(inputImageTexture, textureCoordinate);
mediump vec4 extraData = texture2D(inputImageTexture2, textureCoordinate2);
float green = extraData.g;
if ((textureCoordinate2.x >= minX) && (textureCoordinate2.y >= minY) && (textureCoordinate2.x <= maxX) && (textureCoordinate2.y <= maxY)) {
mediump vec2 avatarPos = vec2(textureCoordinate2.x - minX, textureCoordinate2.y - minY);
mediump vec4 avatarFrag = texture2D(inputImageTexture3, avatarPos);
gl_FragColor = vec4(0,0,0, extraData.r);
} else {
gl_FragColor = vec4(origin.rgb, extraData.r);
}
}
最終能直接識別出遮罩區(qū)域了:

4. 性能分析
當(dāng)我為得到結(jié)果感到高興時,性能的表現(xiàn)狠狠地潑了一盆冷水過來:

這是沒有用CPU讀取像素的性能:

這是用了CPU讀取像素的性能:

可以看到,如果用CPU去將每一幀的像素遍歷讀取的話,性能會大大受到影響,這是絕對無法容忍的..
不過也是,每一幀有好幾十萬個像素點,每一幀都這樣搞,本應(yīng)放到GPU處理的內(nèi)容交由CPU處理了,CPU吃不消也是正常的..鑒于CPU獲取到渲染內(nèi)容也就像素這一種方式了,遂放棄該思路,老老實實接入VAP吧= =
三. 總結(jié)
雖然這次嘗試以放棄告終,但經(jīng)過自己好多天的查找資料、閱讀文檔,最終讀取到了遮罩的最小最大XY坐標(biāo),并渲染到屏幕上,也算是有所收獲,起碼初步了解到了GPUImage的工作原理,以及如何編寫OpenGL的著色器。
下一步我將閱讀下VAP的源碼,并嘗試接入到工程中,畢竟他們這種思路才是比較合理的,將遮罩識別的步驟放到美術(shù)的工作,而不是用戶的使用過程中才去識別,對于性能表現(xiàn)、對于可拓展性,無疑都是一個比較好的選擇。還是要努力追趕上大佬們的腳步呀!