與OpenGL ES的第一次約會


const char *shaderUTF8 = [shaderContent UTF8String];
GLint length = (GLint)[shaderContent length];
GLuint shader = glCreateShader(type);


glShaderSource(shader, 1, &shaderUTF8, &length);

glCompileShader(shader);

GLint status;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);

if (status == GL_FALSE) { glDeleteShader(shader); exit(1); }

return shader;
}

現在我們有了編譯之後的shader對象,接下來需要把它鏈接到OpenGL的glProgram上,讓它可以在GPU上run起來。代碼如下所示:

program = glCreateProgram();
glAttachShader(program, vertShader);
glAttachShader(program, fragShader);
glLinkProgram(program);

GLint status;
glGetProgramiv(program, GL_LINK_STATUS, &status);

完成上面的步驟後,我們就可以用programe來和shader交互了,比如賦值給頂點shader的position變量:

GLuint attrib_position = glGetAttribLocation(program, "position");
glEnableVertexAttribArray(attrib_position);
glVertexAttribPointer(attrib_position, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (char *)points);

幾何圖元

有了上面的介紹,我們就可以開始繪圖了。所有幾何圖元的繪製都是通過調用glDrawArrays實現的:

glDrawArrays (GLenum mode, GLint first, GLsizei count);

這裡的mode為幾何形狀類型,主要有點,線和三角形三種:

#define GL_POINTS 0x0000 // 點 -> 默認為方形
#define GL_LINES 0x0001 // 線段 -> 可不連續
#define GL_LINE_LOOP 0x0002 // 線圈 -> 首尾相連的線段
#define GL_LINE_STRIP 0x0003 // 線段帶 -> 相鄰線段共享頂點
#define GL_TRIANGLES 0x0004 // 三角形 -> 三個頂點連接
#define GL_TRIANGLE_STRIP 0x0005 // 三角帶 -> 相鄰三角共享邊
#define GL_TRIANGLE_FAN 0x0006 // 三角扇 -> 所有三角共享頂點

繪製點代碼如下所示,其中幾何類型傳入GL_POINTS

static GLfloat points[] = { // 前三位表示位置x, y, z 後三位表示顏色值r, g, b 
0.0f, 0.5f, 0, 0, 0, 0, // 位置為( 0.0, 0.5, 0.0); 顏色為(0, 0, 0)黑色
-0.5f, 0.0f, 0, 1, 0, 0, // 位置為(-0.5, 0.0, 0.0); 顏色為(1, 0, 0)紅色
0.5f, 0.0f, 0, 1, 0, 0 // 位置為( 0.5, 0.0, 0.0); 顏色為(1, 0, 0)紅色
}; // 共有三組數據,表示三個點
GLuint attrib_position = glGetAttribLocation(program, "position");
glEnableVertexAttribArray(attrib_position);
GLuint attrib_color = glGetAttribLocation(program, "color");
glEnableVertexAttribArray(attrib_color);
// 對於position每個數值包含3個分量,即3個byte,兩組數據間間隔6個GLfloat
// 同樣,對於color每個數值含3個分量,但數據開始的指針位置為跳過3個position的GLFloat大小
glVertexAttribPointer(attrib_position, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (char *)points);
glVertexAttribPointer(attrib_color, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (char *)points + 3 * sizeof(GLfloat));

glDrawArrays(GL_POINTS, 0, 3);

效果如圖所示:

與OpenGL ES的第一次約會

可以看到繪製出來的點默認為方點,那如果要繪製圓點呢?為了讓OpenGL ES 2.0把點繪製成圓形而非矩形,需要處理光柵化後的點所包含的像素數據,思路是,忽略半徑大於0.5的點,從而實現圓點繪製。在FragmentShader.glsl修改代碼如下:

// FragmentShader.glsl
varying lowp vec4 fragColor;
void main(void) {
if (length(gl_PointCoord - vec2(0.5, 0.5)) > 0.5) {
discard;
}
gl_FragColor = fragColor;
}

運行後,可以看到圓點效果如下所示:

與OpenGL ES的第一次約會

繪製直線的代碼如下所示,其中幾何類型傳入GL_LINES

static GLfloat lines[] = { 
0.0f, 0.0f, 1, 1, 1, 1,
0.5f, 0.5f, 0, 0, 0, 0,
0.0f, 0.0f, 0, 1, 0, 0,
-0.5f, 0.0f, 0, 0, 0, 1,
};
glVertexAttribPointer(attrib_position, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (char *)lines);
glVertexAttribPointer(attrib_color, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (char *)lines + 3 * sizeof(GLfloat));

glLineWidth(5); // 設置線寬為5
glDrawArrays(GL_LINES, 0, 4);

對於線段,如果兩點之間的顏色值不同,則OpenGL會默認產生漸變色效果,具體繪製結果如圖所示:

與OpenGL ES的第一次約會

由於本文最開始的效果裡面只用到了點和線的繪製,所以繪製最基本的三角形,讀者可以自行嘗試,這邊就不再贅述了。

紋理貼圖

除了圖元之外,OpenGL還有紋理的概念。簡單來說就是把圖像數據顯示到我們所繪製的圖元上,以使圖元表示的物體更真實。我們首先來看下紋理的座標系,如下圖所示:

與OpenGL ES的第一次約會

紋理座標的範圍為0到1之間。紋理座標的原點為圖片的左下角,其和OpenGL繪製座標系的對應關係如示意圖上箭頭所示,在紋理貼圖的時候我們需要確保座標點映射關係與上圖一致。

要實現紋理的繪製需要兩個信息,一個是紋理的座標,另一個則是紋理的內容。紋理的內容簡單來說,就是把iOS中的UIImage轉換為OpenGL ES中的texture數據。

- (GLuint)textureFromImage:(UIImage *)image 
{
CGImageRef imageRef = [image CGImage];
size_t w = CGImageGetWidth (imageRef);
size_t h = CGImageGetHeight(imageRef);

GLubyte *textureData = (GLubyte *)malloc(w * h * 4);
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();

NSUInteger bytesPerPixel = 4;
NSUInteger bytesPerRow = bytesPerPixel * w;
NSUInteger bitsPerComponent = 8;

CGContextRef context = CGBitmapContextCreate(textureData,
w,
h,
bitsPerComponent,
bytesPerRow,
colorSpace,
kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
CGContextTranslateCTM(context, 0, h);
CGContextScaleCTM(context, 1.0f, -1.0f);
CGContextDrawImage(context, CGRectMake(0, 0, w, h), imageRef);

glEnable(GL_TEXTURE_2D);
GLuint texName;
glGenTextures(1, &texName);
glBindTexture(GL_TEXTURE_2D, texName);

glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(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);


glTexImage2D(GL_TEXTURE_2D,
0,
GL_RGBA,
(GLsizei)w,
(GLsizei)h,
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
textureData);

CGContextRelease(context);
CGColorSpaceRelease(colorSpace);
free(textureData);

return texName;
}

有了紋理對象後,接下來我們需要在頂點著色器和片段著色器中轉化座標和紋理信息,也就是進行採樣渲染。頂點著色器如下所示:

// vertex.glsl
attribute vec4 aPosition;
attribute vec2 aTexcoord;
varying vec2 vTexcoord;
void main(void) {
gl_Position = aPosition;
vTexcoord = aTexcoord;
}

上述代碼中的aTexcoord用來接受紋理座標信息,然後傳遞給片段著色器中定義的varying變量vTexcoord。這樣就傳遞了紋理座標信息。片段著色器代碼如下所示:

// fragment.glsl
precision mediump float;
uniform sampler2D uTexture;
varying vec2 vTexcoord;
void main(void) {
gl_FragColor = texture2D(uTexture, vTexcoord);

}

這裡的uTexture就是我們的紋理,而vTexcoord則是紋理座標。有了座標和紋理信息後就可以通過texture2D函數進行採樣。簡單來說,就是取出每個座標點像素的顏色信息賦給OpenGL進行繪製,而圖片的數據就是由每個點的顏色像素值所組成的矩陣信息,因此,有了紋理和像素間的顏色映射關係後,就可以通過OpenGL顯示整張圖片了。完成了上述操作之後,最後一步就是激活紋理並渲染了,代碼如下所示:

GLuint tex_name = [self textureFromImage:[UIImage imageNamed:@"ryan.jpg"]];
glActiveTexture(GL_TEXTURE5);
glBindTexture(GL_TEXTURE_2D, tex_name);
glUniform1i(uTexture, 5);
const GLfloat vertices[] = { // OpenGL繪製座標
-0.5, -0.25, 0,
0.5, -0.25, 0,
-0.5, 0.25, 0,
0.5, 0.25, 0 };
glEnableVertexAttribArray(aPosition);
glVertexAttribPointer(aPosition, 3, GL_FLOAT, GL_FALSE, 0, vertices);
static const GLfloat coords[] = { // 紋理座標
0, 0,
1, 0,
0, 1,
1, 1
};
glEnableVertexAttribArray(aTexcoord);
glVertexAttribPointer(aTexcoord, 2, GL_FLOAT, GL_FALSE, 0, coords);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

代碼中的vertices為OpenGL的繪製座標,紋理座標為coords, 這兩個座標需要與上圖的座標對應關係相符合才能正確顯示出圖片。運行後效果如下圖所示:

與OpenGL ES的第一次約會

視頻繪製

好了,有了上面的理論基礎,我們可以來實現文章開篇所示的實時視頻繪製了。對於視頻流的獲取以及OpenGL的繪製環境我們採用GPUImage來實現,人臉識別的算法採用公司自有視覺引擎(免費開放使用,下載地址為虹軟視覺AI引擎開放平臺)當然也可以使用CoreImage框架的CIDetector人臉識別類。

@interface PVTStickerFilter : GPUImageFilter
@property (nonatomic, copy) NSArray *facePoints;
@end

首先繼承GPUImageFilter類,並定義一個人臉點位數組用來接收人臉識別引擎傳入的點位信息。需要注意的是,相機獲取的圖像默認在內存中是逆時針90度存放的,所以我們獲取的點位需要順時針旋轉90度才是我們在取景框中看到的圖像。另外,如果是前置攝像頭,默認會有鏡像效果,因此還需要將點位沿Y軸翻轉180度。

[self.facePoints enumerateObjectsUsingBlock:^(NSValue *obj, NSUInteger idx, BOOL *stop) {
CGPoint point = [obj CGPointValue];
[mPs addObject:[NSValue valueWithCGPoint:CGPointMake(point.y, point.x)]];
}];

對於某個點(x, y)順時針旋轉90度後坐標為(imageHeight - y, x), 如果是鏡像效果的點,則還需要再繞Y軸旋轉180度,最終的座標為(y, x)。

從效果圖中可以看到,我們要實現的為左右兩邊對稱線條的動畫繪製。效果圖中一共繪製了三組線條,我們就其中一組來分析下其原理。具體點位為鼻樑左下角點(x67, y67)到眉毛左內側點(x24, y24)的線段繪製,以及鼻樑右下角點(x70, y70)到眉毛右內側點(x29, y29)的線段繪製。同時(x24, y24)和(x29, y29)在動畫的最後還需要顯示圓點。

根據前文的分析,在繪製點位之前我們還需要把視頻圖像幀的座標轉換為OpenGL的座標系,也就是把上面幾個點位的座標轉換到-1到1之間。轉換公式前文已給出:

CGFloat x67 = 2 * [mPs[67] CGPointValue].x / frameWidth - 1.f;
CGFloat y67 = 1 - 2 * [mPs[67] CGPointValue].y / frameHeight ;
CGFloat x24 = 2 * [mPs[24] CGPointValue].x / frameWidth - 1.f;
CGFloat y24 = 1 - 2 * [mPs[24] CGPointValue].y / frameHeight ;
CGFloat x70 = 2 * [mPs[70] CGPointValue].x / frameWidth - 1.f;
CGFloat y70 = 1 - 2 * [mPs[70] CGPointValue].y / frameHeight ;
CGFloat x29 = 2 * [mPs[29] CGPointValue].x / frameWidth - 1.f;
CGFloat y29 = 1 - 2 * [mPs[29] CGPointValue].y / frameHeight ;

有了這些點位,我們可以很容易的使用glDrawArrays(GL_LINES, 0, 4)來繪製出線段。但是這邊有兩個問題需要解決,一是如何繪製虛線,二是如何實現繪製的動畫。

對於虛線的繪製,OpenGL ES 2.0沒有直接的API可以實現,所以我們需要換一種思路,將虛線轉換為若干直線的連續繪製。具體思路為,一個長度為10像素的虛線(x1, 0)至(x10, 0),我們將它切斷為5個長度為1像素線段繪製。即繪製(x1, 0)到(x2, 0)的線段,(x3, 0)到(x4, 0)的線段,(x5, 0)到(x6, 0)的線段,(x7, 0)到(x8, 0)的線段,(x9, 0)到(x10, 0)的線段。

所以,首先我們需要根據繪製虛線的長度來給整條線段分段,比如我們定義每段虛線的長度為0.01,那麼就可以計算出來兩個點位之間的線段需要分為多少片段線來繪製:

CGFloat w_24_67 = (x24 - x67); // 兩點之間的x軸距離
CGFloat h_24_67 = (y24 - y67); // 兩點之間的y軸距離
CGFloat w_29_70 = (x29 - x70); // 兩點之間的x軸距離
CGFloat h_29_70 = (y29 - y70); // 兩點之間的y軸距離
GLsizei s_24_67 = [self stepsOfLineWidth:w_24_67 height:h_24_67]; // 需要劃分為多少個片段線

GLsizei s_29_70 = [self stepsOfLineWidth:w_29_70 height:h_29_70]; // 需要劃分為多少個片段線

計算片段性的函數如下所示,其中PVT_DASH_LENGTH為每段虛線的長度:

- (GLsizei)stepsOfLineWidth:(CGFloat)w height:(CGFloat)h
{
CGFloat a_w = fabs(w);
CGFloat a_h = fabs(h);
GLsizei s = a_w / (PVT_DASH_LENGTH * cos(atan(a_h / a_w)));

return ((s % 2) ? s : ++s) + 1;
}

然後將所有的線段片塞到OpenGL中繪製,代碼如下:

GLsizei total_s = s_24_67 + s_29_70;
GLfloat *lines = (GLfloat *)malloc(sizeof(GLfloat) * total_s * 3);
for (int i = 0; i < s_24_67; i++) {
CGFloat xt = x67 + (CGFloat)i/(CGFloat)(s_24_67-1) * w_24_67;
CGFloat yt = y67 + (CGFloat)i/(CGFloat)(s_24_67-1) * h_24_67;
int idx = i * 3;
lines[idx] = xt; lines[idx+1] = yt; lines[idx+2] = 0;
}
for (int i = 0; i < s_29_70; i++) {
CGFloat xt = x70 + (CGFloat)i/(CGFloat)(s_29_70-1) * w_29_70;
CGFloat yt = y70 + (CGFloat)i/(CGFloat)(s_29_70-1) * h_29_70;
int idx = s_24_67 * 3 + i * 3;
lines[idx] = xt; lines[idx+1] = yt; lines[idx+2] = 0;
}
glVertexAttribPointer(_position, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (char *)lines);
glLineWidth(2.5);
glDrawArrays(GL_LINES, 0, total_s);

好了,虛線的問題我們解決了,我們再來看看如何實現繪製的動畫。其實思路很簡單,比如我們要在4秒內逐步繪製出線段(由於需要繪製虛線,我們分成了100個線段片),那麼,我們在相機每幀數據回調來的時候判斷下當前幀距離第一幀已經間隔了多次時間,假設間隔了1秒,那就是對於這一幀圖像我們需要繪製出四分之一的長度,也就是將25個線段片塞到OpenGL裡面去繪製。以此類推,如果超過了4秒,那麼再清零重頭計算。在4秒的時候應該是繪製整條線段的完整長度。

- (void)newFrameReadyAtTime:(CMTime)frameTime atIndex:(NSInteger)textureIndex
{
_currentTime = frameTime;
[super newFrameReadyAtTime:frameTime atIndex:textureIndex];
}

首先記錄下當前幀的時間,以便在後面計算當前幀距離第一幀的累積時間。

- (void)calcAccumulatorTime
{
NSTimeInterval interval = 0;

if (CMTIME_IS_VALID(_lastTime)) {
interval = CMTimeGetSeconds(CMTimeSubtract(_currentTime, _lastTime));
}
_lastTime = _currentTime;
_accumulator += interval;

_frameDuration = _stepsIdx == 3 ? PVT_FRAME_DURATION / 2.f : PVT_FRAME_DURATION;

CGFloat sumTime = _accumulator + interval;
_accumulator = MIN(sumTime, _frameDuration);
}

然後計算出當前幀根據總的動畫時間應該繪製到哪一步:

- (GLsizei)animationIdxWithStep:(GLsizei)step
{
CGFloat s_scale = _accumulator / _frameDuration;
GLsizei s_index = ceil(s_scale * step);

return (s_index % 2) ? ++s_index : s_index;
}

最後一步則是將計算好的片段數傳給OpenGL進行繪製,需要注意的時候當累積時間超過了動畫時間後需要將累積時間清零,從而實現動畫的連續展示。這裡的_frameDuration即是動畫時間。

- (void)renderToTextureWithVertices:(const GLfloat *)vertices textureCoordinates:(const GLfloat *)textureCoordinates;
{
[self calcAccumulatorTime];
GLsizei s_24_67_index = [self animationIdxWithStep:s_24_67];
GLsizei s_29_70_index = [self animationIdxWithStep:s_29_70];
GLsizei total_s = s_24_67_index + s_29_70_index;
GLfloat *lines = (GLfloat *)malloc(sizeof(GLfloat) * total_s * 3);

for (int i = 0; i < s_24_67_index; i++) {
CGFloat xt = x67 + (CGFloat)i/(CGFloat)(s_24_67_index-1) * w_24_67 * s_index_scale;
CGFloat yt = y67 + (CGFloat)i/(CGFloat)(s_24_67_index-1) * h_24_67 * s_index_scale;
int idx = i * 3;
lines[idx] = xt; lines[idx+1] = yt; lines[idx+2] = 0;
}
for (int i = 0; i < s_29_70_index; i++) {
CGFloat xt = x70 + (CGFloat)i/(CGFloat)(s_29_70_index-1) * w_29_70 * s_index_scale;
CGFloat yt = y70 + (CGFloat)i/(CGFloat)(s_29_70_index-1) * h_29_70 * s_index_scale;
int idx = s_24_67_index * 3 + i * 3;
lines[idx] = xt; lines[idx+1] = yt; lines[idx+2] = 0;
}

if (_accumulator == _frameDuration) {
_accumulator = 0.f;
}

// to do drawing work...
}

虛線和動畫的問題都解決了,現在還剩最後一個需求,在動畫結束的時候在(x24, y24)和(x29, y29)處繪製圓點。對於圓點的繪製,前文有提到可以直接繪製點,然後在FragmentShader.glsl中修改忽略半徑大於0.5的即可實現圓點繪製。但是由於我們需要同時繪製點和線,且使用同一個Fragment Shader文件,所以難以區分當前是繪製點還是線,不能直接在Shader中忽略半徑大於0.5的點,因此我們這邊對於圓點直接採用幾何方法繪製。具體的幾何原理可以參照這篇博文。

#define PVT_CIRCLE_SLICES 100 

#define PVT_CIRCLE_RADIUS 0.015
- (void)drawCircleWithPositionX:(CGFloat)x y:(CGFloat)y radio:(CGFloat)radio
{
glLineWidth(2.0);

GLfloat *vertext = (GLfloat *)malloc(sizeof(GLfloat) * PVT_CIRCLE_SLICES * 3);

memset(vertext, 0x00, sizeof(GLfloat) * PVT_CIRCLE_SLICES * 3);

float a = PVT_CIRCLE_RADIUS; // horizontal radius
float b = a * radio; // fWidth / fHeight;

float delta = 2.0 * M_PI / PVT_CIRCLE_SLICES;

for (int i = 0; i < PVT_CIRCLE_SLICES; i++) {
GLfloat cx = a * cos(delta * i) + x;
GLfloat cy = b * sin(delta * i) + y;
int idx = i * 3;
vertext[idx] = cx; vertext[idx+1] = cy; vertext[idx+2] = 0;
}

glVertexAttribPointer(_position, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (char *)vertext);
glDrawArrays(GL_TRIANGLE_FAN, 0, PVT_CIRCLE_SLICES);

free(vertext);
}

OpenGL ES的深度不亞於學習一門新語言,萬丈高樓平地起,希望本文的總結可以給想入門的同學帶來一些幫助和收穫,也歡迎大家留言討論。

參考文章

  1. OpenGL ES入門及繪製一個三角形
  2. 仿QQ視屏動畫特效-人臉識別
  3. 從0打造一個GPUImage
  4. 學習OpenGL ES之繪製更多的圖形
  5. OpenGL ES 3.0 數據可視化 1:繪製圓點
  6. OpenGL ES入門03-OpenGL ES圓形繪製
  7. OpenGL ES入門05-OpenGL ES 紋理貼圖


分享到:


相關文章: