一、简介
在鸿蒙NDK开发入门中介绍了ArkTS与C/C++相互调用流程,本文承接上文,介绍使用OpenGL绘制一个三角形,通过绘制三角形来熟悉OpenGL的绘制流程。CPU和GPU都能用于图形渲染,部分场景下如果使用CPU渲染,性能就非常差。但GPU可以大大提高渲染速度,OpenGL可以操作GPU,是一个2D/3D图形库,用于视频渲染、视频编辑、视频特效、游戏引擎等。除了OpenGL外,ValKan、Metal、Direct3D、WebGL、WebGPU等都是优秀的2D/3D图形库。目前OpenGL应用最广泛,资料也最丰富,跨平台,学习起来相对容易。
二、OpenGL ES
OpenGL ES是OpenGL的子集,专门用于手机、平板等小型设备,删除了不必要的方法、减少了体积。所以准确的来说,我们学习的其实是OpenGL ES。
三、XComponent
XComponent组件作为一种渲染组件,通常用于满足较为复杂的自定义渲染需求,例如相机预览流的显示、游戏画面的渲染、视频的渲染。XComponent又拥有单独的NativeWindow,可以在native侧提供native window用来创建EGL/OpenGLES环境,进而使用标准的OpenGL ES开发。
3、1添加EGL/OpenGLES库
# the minimum version of CMake.
cmake_minimum_required(VERSION 3.5.0)
project(egl)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
if(DEFINED PACKAGE_FIND_FILE)
include(${PACKAGE_FIND_FILE})
endif()
include_directories(${NATIVERENDER_ROOT_PATH}
${NATIVERENDER_ROOT_PATH}/include)
# 查找相关库 (包括OpenGL ES相关库和XComponent提供的ndk接口)
find_library( EGL-lib
EGL )
find_library( GLES-lib
GLESv3 )
find_library( libace-lib
ace_ndk.z )
find_library(
hilog-lib
hilog_ndk.z)
add_library(egl SHARED
napi_init.cpp
manager/EglManager.cpp
render/EglCore.cpp
render/EglRender.cpp)
target_link_libraries(egl PUBLIC ${hilog-lib} ${EGL-lib} ${GLES-lib} ${libace-lib} libace_napi.z.so libc++.a)
3、2 ArkTS侧添加XComponent
XComponent有三个重要的属性:
- id : 与XComponent组件为一一对应关系,不建议重复。通常开发者可以在native侧通过OH_NativeXComponent_GetXComponentId接口来获取对应的id从而绑定对应的XComponent。如果id重复,在native侧将无法对多个XComponent进行区分。
- type:指定为surface。
- libraryname:加载模块的名称,必须与在native侧Napi模块注册时nm_modname的名字一致。
interface XComponentAttrs {
id: string;
type: number;
libraryname: string;
}
@Component
export struct GLComponent {
@State message: string = 'Hello World';
xComponentContext: object | undefined = undefined;
xComponentAttrs: XComponentAttrs = {
id: 'xcomponentId', // 与XComponent组件为一一对应关系,不建议重复。可以在native侧通过OH_NativeXComponent_GetXComponentId接口来获取对应的id从而绑定对应的XComponent。
type: XComponentType.SURFACE,
libraryname: 'egl' // 加载模块的名称,必须与在native侧Napi模块注册时模块名字一致。
}
build() {
Row() {
Column() {
XComponent(this.xComponentAttrs)
.onLoad(() => {
})
}
.width('100%')
}
.height('100%')
}
}
3、2 获取OH_NativeXComponent
在ArkTS侧添加XComponent后,就可以在C++侧获取OH_NativeXComponent。
- 调用napi_get_named_property函数解析参数。
- 调用napi_unwrap函数获取OH_NativeXComponent对象。
- 调用OH_NativeXComponent_GetXComponentId获取id,这个id就是在ArkTS侧给XComponent设置的id。
void EglManager::Export(napi_env env, napi_value exports) {
napi_value exportInstance = nullptr;
if (napi_ok != napi_get_named_property(env, exports, OH_NATIVE_XCOMPONENT_OBJ, &exportInstance)) {
LOGE("解析参数出错");
return;
}
OH_NativeXComponent *nativeXComponent = nullptr;
if (napi_ok != napi_unwrap(env, exportInstance, reinterpret_cast<void **>(&nativeXComponent))) {
LOGE("获取OH_NativeXComponent对象出错");
return;
}
// 获取id
char idStr[OH_XCOMPONENT_ID_LEN_MAX + 1] = {'\0'};
uint64_t size = OH_XCOMPONENT_ID_LEN_MAX + 1;
if (napi_ok != OH_NativeXComponent_GetXComponentId(nativeXComponent, idStr, &size)) {
LOGE("获取XComponentId出错");
return;
}
string id(idStr);
if (nativeXComponent != nullptr) {
setNativeXComponent(id, nativeXComponent);
getRender(id);
// 注册回调
OH_NativeXComponent_RegisterCallback(nativeXComponent, &EglRender::callback);
}
}
3、4注册回调
OH_NativeXComponent_Callback是个结构体,结构体里面是函数指针。
typedef struct OH_NativeXComponent_Callback {
/** 当surface创建完成后回调 */
void (*OnSurfaceCreated)(OH_NativeXComponent* component, void* window);
/** 当surface发生改变时回调 */
void (*OnSurfaceChanged)(OH_NativeXComponent* component, void* window);
/** 当surface被销毁时回调 */
void (*OnSurfaceDestroyed)(OH_NativeXComponent* component, void* window);
/** 当触发触摸事件时调用 */
void (*DispatchTouchEvent)(OH_NativeXComponent* component, void* window);
} OH_NativeXComponent_Callback;
调用OH_NativeXComponent_RegisterCallback函数注册回调,OH_NativeXComponent_RegisterCallback函数的第一个参数是OH_NativeXComponent对象,第二个参数OH_NativeXComponent_Callback结构体指针。
OH_NativeXComponent_RegisterCallback(nativeXComponent, &EglRender::callback);
当surface创建完成后回调OnSurfaceCreated函数,我们需要在OnSurfaceCreated函数里面搭建EGL环境,这样才能调用OpenGL ES的函数。
四、EGL
OpenGL是跨平台接口,面对不同平台的差异,需要有一个介于平台设备与OpenGL之间的桥梁,EGL则是其中的桥梁。EGL是OpenGL ES和系统之间的通信接口,OpenGL ES的平台无关性正是借助EGL实现的,EGL屏蔽了不同平台的差异。主要包括以下几个类:
- EGLDisplay一个抽象的系统显示类,用于操作设备窗口,加载OpenGL库。
- EGLConfig,EGL配置,如rgba位数。
- EGLSurface渲染缓存,一块内存空间,所有要渲染到屏幕上的图像数据,都要先缓存在EGLSurface上。
- EGLContext上下文,用于存储OpenGL的绘制状态信息、数据。
要想调用OpenGL ES的函数,首先就得搭建EGL环境。
4、1 搭建EGL环境
1、获取EGLDisplay对象:调用eglGetDisplay函数得到EGLDisplay,并加载OpenGL ES库。
EGLDisplay eglGetDisplay(EGLNativeDisplayType displayId);
2、初始化EGL连接:调用eglInitialize函数初始化,获取库的版本号。
GLBoolean eglInitialize(EGLDisplay display, // EGLDisplay对象
EGLint *majorVersion, // 主版本号
EGLint *minorVersion) // 次版本号
3、确定渲染表面的配置信息:调用eglChooseConfig函数得到EGLConfig。
GLBoolean eglChooseConfig (EGLDisplay dpy,
const EGLint *attrib_list,
EGLConfig *configs,
EGLint config_size,
EGLint *num_config);
4、创建渲染表面:通过EGLDisplay和EGLConfig,调用eglCreateWindowSurface函数创建渲染表面,得到EGLSurface。
EGLSurface eglCreateWindowSurface(
EGLDisplay display, //EGLDisplay对象
EGLConfig config, //EGLConfig对象
NativeWindowType native_window, //原生窗口
EGLint const * attrib_list); // attrib_list为Window Surface属性列表,可以为NULL
5、创建渲染上下文:通过EGLDisplay和EGLConfig,调用eglCreateContext函数创建渲染上下文,得到EGLContext。
EGLContext EGLAPIENTRY eglCreateContext(
EGLDisplay display,
EGLConfig config,
EGLContext share_context,
const EGLint *attribList);
6、绑定上下文:通过eglMakeCurrent函数将EGLSurface、EGLContext、EGLDisplay三者绑定,接下来就可以使用OpenGL进行绘制了。
EGLBoolean EGLAPIENTRY eglMakeCurrent(
EGLDisplay display,
EGLSurface draw,
EGLSurface read, EGLContext context);
7、交换缓冲:当用OpenGL绘制结束后,调用eglSwapBuffers函数交换前后缓冲,将绘制内容显示到屏幕上。
下面搭建EGL环境的完整代码。EglContextInit函数是在OnSurfaceCreated函数中调用的。
bool EglCore::EglContextInit(void *window, int width, int height) {
this->width = width;
this->height = height;
// 获取EGLDisplay对象:调用eglGetDisplay函数得到EGLDisplay,并加载OpenGL ES库。
eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (eglDisplay == EGL_NO_DISPLAY) {
LOGE("eGLDisplay获取失败");
return false;
}
EGLint major;
EGLint minor;
// 初始化EGL连接:调用eglInitialize函数初始化,获取库的版本号。
if (!eglInitialize(eglDisplay, &major, &minor)) {
LOGE("eGLDisplay初始化失败");
return false;
}
const EGLint maxConfigSize = 1;
EGLint numConfigs;
// 确定渲染表面的配置信息:调用eglChooseConfig函数得到EGLConfig。
if (!eglChooseConfig(eglDisplay, ATTRIB_LIST, &eglConfig, maxConfigSize, &numConfigs)) {
LOGE("eglConfig初始化失败");
return false;
}
eglWindow = reinterpret_cast<EGLNativeWindowType>(window);
// 创建渲染表面:通过EGLDisplay和EGLConfig,调用eglCreateWindowSurface函数创建渲染表面,得到EGLSurface。
eglSurface = eglCreateWindowSurface(eglDisplay, eglConfig, eglWindow, nullptr);
if (nullptr == eglSurface) {
LOGE("创建eGLSurface失败");
return false;
}
// 创建渲染上下文:通过EGLDisplay和EGLConfig,调用eglCreateContext函数创建渲染上下文,得到EGLContext。
eglContext = eglCreateContext(eglDisplay, eglConfig, EGL_NO_CONTEXT, CONTEXT_ATTRIBS);
if (nullptr == eglContext) {
LOGE("创建eglContext失败");
return false;
}
// 绑定上下文:通过eglMakeCurrent函数将EGLSurface、EGLContext、EGLDisplay三者绑定,接下来就可以使用OpenGL进行绘制了。
if (!eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
LOGE("eglMakeCurrent失败");
return false;
}
return true;
}
五、OpenGL ES坐标系
OpenGL ES两个重要坐标系分别是标准坐标系和屏幕坐标系。
5、1标准坐标系
- 屏幕中心是原点。
-
横纵坐标的范围在-1到1之间。
5、2屏幕坐标系
- 屏幕左上角是原点。
- 单位是像素。
-
OpenGL会把标准坐标系转换成屏幕坐标系。
5、3标准坐标系转换成屏幕坐标系
- 将标准坐标系的顶点加1,标准坐标系的顶点范围是-1到1之间,加1后,顶点范围就是0到2之间。
- 每个顶点乘以屏幕的宽高。
- 上面两步可以通过变换矩阵完成。
六、着色器
着色器是运行在GPU上的小程序,分为顶点着色器和片元着色器。着色器允许开发者自定义渲染过程,提高了灵活度。可以充分利用GPU的并行计算能力,提高渲染速度。可以通过变成编程,灵活的控制GPU。
6、1顶点着色器
顶点着色器用于处理几何图形的顶点,对应标准坐标系,标准坐标系的原点是屏幕中心。
6、2片元着色器
片元着色器用于处理像素颜色和纹理,为每个像素设置不同的颜色,对应屏幕坐标系,屏幕坐标系的左上角为原点。
6、3着色器语言GLSL
OpenGL 2.0加入了可编程渲染管线,可以更加灵活的控制渲染。但也因此需要学习多一门针对GPU的编程语言,语法与C语言类似,名为GLSL。
6、3、1顶点着色器语言
下面是顶点着色器的代码,我们可以看到熟悉的主函数,在主函数中,将变量vPosition赋值给gl_Position。其含义是将图形的顶点坐标vPosition赋值给OpenGL的内建变量gl_Position,这样确定了几何图形的顶点坐标。vPosition被attribute修饰,attribute用在顶点着色器中,相当于Java/C的局部变量,一般用来传递的是顶点坐标和纹理坐标。uniform修饰的是统一变量,相当于Java/C的全局变量,传的通常是矩阵。varying是由顶点着色器传递到片元着色器的。
attribute vec4 vPosition;
void main() {
// 将图形的顶点坐标vPosition赋值给OpenGL的内建变量gl_Position,确定几何图形的顶点坐标。
gl_Position = vPosition;
}
下面的顶点坐标就是下图所对应的坐标,这就是一个三角形的坐标。再次强调下,下面的顶点坐标对应的是标准坐标系,标准坐标系的原点是屏幕中心。这个顶点坐标会传给顶点着色器的vPosition变量,vPosition变量再把顶点坐标传给内建变量gl_Position,这样就确定了几何图形的顶点。
const GLfloat rectangleVertices[] = {
0.0f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f
};
6、3、2片元着色器语言
下面是片元着色器的代码,片元装饰器用于设置颜色。第一行代码指定精度,精度分别有lowp低精度,mediump中精度,highp高精度。代码第二行定义vColor变量,它是uniform类型,vec4表示有4个元素的向量,颜色是由ARGB组成,需要4个元素。gl_FragColor是内建变量,gl_FragColor用于确定每个像素的颜色,这里将vColor赋值给gl_FragColor,vColor变量将会通过程序传递过来。
precision mediump float;
uniform vec4 vColor;
void main() {
// 将vColor赋值给内建变量gl_FragColor,确定几何图形的颜色
gl_FragColor = vColor;
}
下面的代码定义了一个有4个元素的数组,这四个元素分别代表颜色RGBA,也就是红绿蓝和透明度。这个数组就会传递给片元着色器的vColor变量,vColor变量再把颜色值传给内建变量gl_FragColor,这样就确定几何图形的颜色。
// 颜色值#7E8FFB
const GLfloat DRAW_COLOR[] = { 126.0f / 255, 143.0f / 255, 251.0f / 255, 1.0f };
更多的着色器语言介绍,可以查看文章着色器语言。
七、编译和链接着色器
- 调用glCreateShader创建着色器。
- 调用glShaderSource绑定源码。
- 调用glCompileShader编译着色器。
- 调用glCreateProgram创建程序。
- 调用glAttachShader绑定着色器。
- 调用glLinkProgram链接程序。
/**
* 创建程序
*
* @param vertexShader 顶点着色器源码
* @param fragmentShader 片元着色器源码
* @return
*/
GLuint EglCore::createProgram(const char* vertexShader, const char* fragmentShader) {
GLuint vertex = loadShader(GL_VERTEX_SHADER, vertexShader);
if (vertex == PROGRAM_ERROR) {
LOGE("编译顶点着色器失败");
return PROGRAM_ERROR;
}
GLuint fragment = loadShader(GL_FRAGMENT_SHADER, fragmentShader);
if (fragment == PROGRAM_ERROR) {
LOGE("编译片元着色器失败");
return PROGRAM_ERROR;
}
GLuint program = glCreateProgram();
if (program == PROGRAM_ERROR) {
LOGE("创建程序失败");
return PROGRAM_ERROR;
}
// 绑定着色器
glAttachShader(program, vertex);
glAttachShader(program, fragment);
// 链接程序
glLinkProgram(program);
GLint link;
glGetProgramiv(program, GL_LINK_STATUS, &link);
if (PROGRAM_ERROR != link) {
glDeleteShader(vertex);
glDeleteShader(fragment);
// 链接程序成功
return program;
}
// 链接程序失败
glDeleteShader(vertex);
glDeleteShader(fragment);
glDeleteProgram(program);
return PROGRAM_ERROR;
}
GLuint EglCore::loadShader(GLenum type, const char* shaderSrc) {
// 创建着色器
GLuint shader = glCreateShader(type);
// 绑定做得起源码
glShaderSource(shader, 1, &shaderSrc, nullptr);
// 编译着色器
glCompileShader(shader);
GLint compiled;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (0 != compiled) {
return shader;
}
glDeleteShader(shader);
return PROGRAM_ERROR;
}
八、绘制三角形
8、1 清屏,设置屏幕颜色
- 调用glViewport设置窗口大小
- 调用glClearColor清屏,将屏幕颜色设置为黑色。
- 调用glClear清除颜色缓冲
void EglCore::prepareDraw() {
// 设置窗口大小
glViewport(DEFAULT_X_POSITION, DEFAULT_X_POSITION, width, height);
// 清屏,将屏幕颜色设置为黑色
glClearColor(GL_RED_DEFAULT, GL_GREEN_DEFAULT, GL_BLUE_DEFAULT, GL_ALPHA_DEFAULT);
// 清除颜色缓冲
glClear(GL_COLOR_BUFFER_BIT);
}
8、2 绘制
- 调用glUseProgram函数使用程序。
- 调用glGetAttribLocation函数获取顶点着色器中定义的属性。
- 调用glEnableVertexAttribArray函数启用顶点数组。
- 调用glVertexAttribPointer函数向顶点着色器传递顶点数组。
- 调用glGetUniformLocation函数获取片元着色器中定义的变量。
- 调用glUniform4fv函数向片元着色器传递颜色。
- 调用glDrawArrays函数绘制三角形。
- 调用glDisableVertexAttribArray函数释放属性变量。
- 调用eglSwapBuffers函数交换前后缓冲,将绘制内容显示到屏幕上。
void EglCore::draw() {
prepareDraw();
// 使用程序
glUseProgram(program);
// 获取顶点着色器中定义的属性
LOGD("绘制");
GLint positionHandler = glGetAttribLocation(program, POSITION_NAME);
// 启用顶点数组
glEnableVertexAttribArray(positionHandler);
/*
* 向顶点着色器传递顶点数组
* 第一个参数是属性变量的下标
* 第二个参数是顶点坐标的个数,我们在定义顶点坐标的时候,使用了空间坐标系,每个坐标使用x,y,z,所以第二个参数为3
* 第三个参数是数据的类型
* 第四个参数是否进行了归一化处理,这里写false
* 第五个参数是跨度,这里是0,没有跨度
* 第六个参数是要传递的顶点数据
*/
glVertexAttribPointer(positionHandler, 3, GL_FLOAT, false, 0, rectangleVertices);
// 获取片元着色器中定义的变量
GLint colorHandler = glGetUniformLocation(program, COLOR_NAME);
/*
* 向片元着色器传递颜色
* 第一个参数是变量的下标
* 第二个参数是数据的数量,由于将所有的像素都设置成一样的颜色,所以第二个参数是1
* 第三个参数是颜色
*/
glUniform4fv(colorHandler, 1, DRAW_COLOR);
// 绘制三角形
GLsizei count = sizeof(rectangleVertices) / sizeof(rectangleVertices[0]) / 3;
/*
* 绘制三角形
* 第一个参数是绘制的图形
* 第二个参数是从哪里开始读取,这里从0开始读取
* 第三个参数是顶点的数量
*/
glDrawArrays(GL_TRIANGLES, 0, count);
// 释放属性变量
glDisableVertexAttribArray(positionHandler);
finishDraw();
}
bool EglCore::finishDraw() {
glFlush();
glFinish();
// 交换前后缓冲,将绘制内容显示到屏幕上
return eglSwapBuffers(eglDisplay, eglSurface);
}
看下绘制出来的结果吧。
九、总结
真是不容易呀,如果大家能坚持看到文章末尾,希望大家能够熟悉OpenGL绘制流程。我们用大量的代码和大量的篇幅来介绍使用OpenGL绘制三角形。可能有人会问,使用Canvas可以轻轻松松的绘制出三角形,为什么要用大量的代码和大量的篇幅来介绍使用OpenGL绘制一个简简单单的三角形?
Canvas绘制其实上使用的是CPU渲染。大部分情况下,CPU渲染性能不差。但是视频渲染、视频编辑、视频特效、游戏引擎等使用CPU渲染,性能非常差,GPU渲染会大大提高渲染速度,OpenGL就是用来操作GPU的。
虽然OpenGL代码量大,但是大部分的代码是不变的,只需要写一次。自定义XComponent,注册XComponent回调,搭建EGL环境,编译和链接着色器等等,这些代码都是不变的。最重要的还是顶点着色器和片元着色器,只要OpenGL框架代码搭建起来了,大部分情况下,我们还是在写下面的代码。
attribute vec4 vPosition;
void main() {
// 将图形的顶点坐标vPosition赋值给OpenGL的内建变量gl_Position,确定几何图形的顶点坐标。
gl_Position = vPosition;
}
precision mediump float;
uniform vec4 vColor;
void main() {
// 将vColor赋值给内建变量gl_FragColor,确定几何图形的颜色
gl_FragColor = vColor;
}
后续我会持续的分享OpenGL。
源码