前言
本文介绍了openGl 是什么,与我们Android开发者怎么使用它,
以及 几个 在Android 通过 NDK 使用 OpenGLES的案例:
- 绘制最简单的图元(点,线,三角形)
- 绘制一个可以随手势简单旋转的6面贴有图片的立方体
- 视频转场动画
github链接: github.com/qiahasx/ope...
效果演示: v.douyin.com/iPsNSgHt/
文章面向对JNI有一定了解的读者,在下文不会特意说明相关知识
如果对入门JNI有兴趣的同学,可以去看我的在Android 做mp3编码和重采样那两篇文章,两篇文章都有举例子
文章链接:我还没有写,后续补上
OpenGl是什么?-- 一堆函数
OpenGL(Open Graphics Library)是一个跨平台、与硬件无关的API(应用程序接口),用于渲染2D和3D矢量图形。
基本上就是一堆函数允许我们实际访问我们的GPU
一开始我把OpenGl认为是一套库或者类似的东西
但其实OpenGl只是一套规范 它没有写任何的代码实现,只是列出这么一套规范
应该存在某个函数接受xxx值 干yyy事,返回zzz
实际的代码由GPU的生产商决定,如果说程序运行平台使用英伟达的显卡,那么调用的就是英伟达写的驱动程序
OpenGL是由Khronos Group维护的跨平台图形API规范,其本质是一套硬件抽象接口。它明确定义了函数原型、参数格式和预期行为,但未提供具体实现。 各GPU厂商(NVIDIA/AMD/Intel)根据规范开发驱动程序,实现API功能。当调用glDrawArrays
时:
- NVIDIA驱动:生成CUDA指令
- AMD驱动:生成ROCm指令
- Intel驱动:生成Xe指令
通过标准接口实现"一次编写,多平台运行"
因此,虽然你可以通过任何支持OpenGL的平台上的相同API来编写图形应用程序,但实际的性能和一些特定功能可能会因不同的硬件和驱动程序实现而有所差异。这也是为什么有时你会看到某些游戏或图形应用在不同品牌的显卡上有不同的表现。
现代openGl的工作流程
现代 OpenGL(可编程管线)的主要工作步骤大致是 : 准备数据→顶点处理→图元装配→光栅化→片段处理→帧缓冲操作→显示。
-
准备数据:定义模型的几何形状(如顶点坐标、颜色、纹理坐标等),我们称呼记录几何数据的最小单位叫顶点,一个模型由一定数量的顶点模型生成。 然后将数据存储到显存
-
顶点处理:处理每个顶点的属性,由顶点着色器(运行在GPU上面的程序)处理
-
图元装配:将顶点按指定图元如 :点、线、三角形, 组装成几何形状。
-
光栅化: 将图元转换为屏幕上的 片段(Fragment)(即像素)。
-
片段处理:设置每个片段的颜色,由片段着色器(运行在GPU上面的程序)处理
-
写入帧缓存:将最终像素写入到帧缓存中
-
显示到屏幕
不过这个是openGl大概的工作步骤
对我们程序员来说一般注意这三步
- 准备数据
- 图元装配
- 编写着色器
准备数据
顶点 :顶点是 几何数据的最小单元。
它既包括数学上的点,既坐标(x,y, z)
顶点还可以携带颜色,法线,纹理坐标等属性
顶点是图元的组成部分,opengl中的一切由三角形拼接成,例如: 一个简单的立方体由6个面组成,每个面由2个三角形组成,每个三角形又需要3个顶点,所以一个立方体由36个顶点组成。
VBO(Vertex Buffer Object): 顶点缓冲对象,顾名思义就是顶点的一个缓冲区,就是一块存放顶点数据的内存,与普通的缓冲区的区别是它实际上是在我们的GPU上的,在我们的显存(VRAM)中
EBO(Element Buffer Object) : 元素缓存对象,用于存储顶点索引的缓冲区,通过复用顶点数据来优化渲染效率, 通过索引指向顶点缓冲区(VBO)中的顶点,避免重复存储相同顶点数据。
按照之前分析的,一个立方体由36个顶点组成,但是实际上立方体在空间中只要确定了8个立方体角点就可以确定,那么我们是不是可以只往VBO写入8个顶点数据,然后ebo复用这写顶点数据就可以了?
并不是,顶点数据除了坐标外还包括纹理,颜色,法线的信息。 而每个立方体角点 需要为3个不同的面提供独立的顶点数据。 也就是说需要24个顶点数据,然后通过索引复用这24个顶点。
arduino
// 顶点数据结构(位置 + 颜色)
struct Vertex {
glm::vec3 position;
glm::vec4 color;
};
// 立方体顶点数据(24个顶点)
Vertex vertices[] = {
// 前表面(红色)
{ { 0.5f, 0.5f, 0.5f}, {1.0f, 0.0f, 0.0f, 1.0f} }, // 0
{ { 0.5f,-0.5f, 0.5f}, {1.0f, 0.0f, 0.0f, 1.0f} }, // 1
{ {-0.5f,-0.5f, 0.5f}, {1.0f, 0.0f, 0.0f, 1.0f} }, // 2
{ {-0.5f, 0.5f, 0.5f}, {1.0f, 0.0f, 0.0f, 1.0f} }, // 3
// 右表面(绿色)
{ { 0.5f, 0.5f,-0.5f}, {0.0f, 1.0f, 0.0f, 1.0f} }, // 4
{ { 0.5f,-0.5f,-0.5f}, {0.0f, 1.0f, 0.0f, 1.0f} }, // 5
{ { 0.5f,-0.5f, 0.5f}, {0.0f, 1.0f, 0.0f, 1.0f} }, // 6
{ { 0.5f, 0.5f, 0.5f}, {0.0f, 1.0f, 0.0f, 1.0f} }, // 7
// 上表面(蓝色)
{ {-0.5f, 0.5f, 0.5f}, {0.0f, 0.0f, 1.0f, 1.0f} }, // 8
{ { 0.5f, 0.5f, 0.5f}, {0.0f, 0.0f, 1.0f, 1.0f} }, // 9
{ { 0.5f, 0.5f,-0.5f}, {0.0f, 0.0f, 1.0f, 1.0f} }, // 10
{ {-0.5f, 0.5f,-0.5f}, {0.0f, 0.0f, 1.0f, 1.0f} }, // 11
// 后表面(黄色)
{ {-0.5f, 0.5f,-0.5f}, {1.0f, 1.0f, 0.0f, 1.0f} }, // 12
{ {-0.5f,-0.5f,-0.5f}, {1.0f, 1.0f, 0.0f, 1.0f} }, // 13
{ { 0.5f,-0.5f,-0.5f}, {1.0f, 1.0f, 0.0f, 1.0f} }, // 14
{ { 0.5f, 0.5f,-0.5f}, {1.0f, 1.0f, 0.0f, 1.0f} }, // 15
// 左表面(品红)
{ {-0.5f, 0.5f,-0.5f}, {1.0f, 0.0f, 1.0f, 1.0f} }, // 16
{ {-0.5f, 0.5f, 0.5f}, {1.0f, 0.0f, 1.0f, 1.0f} }, // 17
{ {-0.5f,-0.5f, 0.5f}, {1.0f, 0.0f, 1.0f, 1.0f} }, // 18
{ {-0.5f,-0.5f,-0.5f}, {1.0f, 0.0f, 1.0f, 1.0f} }, // 19
// 下表面(青色)
{ {-0.5f,-0.5f,-0.5f}, {0.0f, 1.0f, 1.0f, 1.0f} }, // 20
{ { 0.5f,-0.5f,-0.5f}, {0.0f, 1.0f, 1.0f, 1.0f} }, // 21
{ { 0.5f,-0.5f, 0.5f}, {0.0f, 1.0f, 1.0f, 1.0f} }, // 22
{ {-0.5f,-0.5f, 0.5f}, {0.0f, 1.0f, 1.0f, 1.0f} }, // 23
};
// 索引数据(36个索引)
unsigned int indices[] = {
// 前表面
0, 1, 2,
2, 3, 0,
// 右表面
4, 5, 6,
6, 7, 4,
// 上表面
8, 9, 10,
10, 11, 8,
// 后表面
12, 13, 14,
14, 15, 12,
// 左表面
16, 17, 18,
18, 19, 16,
// 下表面
20, 21, 22,
22, 23, 20
};
获得数据后就要把这些数据写入到缓存区
scss
// 生成 VBO EBO
GLuint vbo,ebo;
// 参数1表示生成一个缓存区,生成后吧缓冲区id写入到第二个参数中
glGenBuffers(1, &vbo);
glGenBuffers(1, &ebo);
// 将新创建的VBO绑定到GL_ARRAY_BUFFER,
// 之前glGenBuffers只是生成一个缓冲区
// vbo, ebo都是由glGenBuffers但是实际的作用却不一样就是因为这一步的区别
glBindBuffer(GL_ARRAY_BUFFER, vbo);
// 提交数据到vbo中,
// 第一个参数表示缓冲区的类型,
// 第二个参数变送缓冲区大小
// 第三个就是缓冲区起始位置
// 最后是个枚举对象,表示数据不会或很少改变
glBufferData(GL_ARRAY_BUFFER, sizeof(cubeVertices), cubeVertices, GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(cubeIndices), cubeIndices, GL_STATIC_DRAW);
glBufferData
把数据放到缓存区还没完,我们放到缓冲区的数据说到底只是说在那个位置有一个多少字节的缓冲区 但是说这些字节要怎么使用?需要调用glVertexAttribPointer.
设置坐标属性:
scss
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void *) nullptr);
glEnableVertexAttribArray(0);
glVertexAttribPointer
参数解析:
0
: 表示把属性写入 0 属性通道,需要在后面启用这个属性通道glEnableVertexAttribArray(0)
。对应着色器中layout(location=0)
的属性, 这个属性后面会提到,现在只要记得不同的属性写不一样的值就行了3
:表示每个顶点有3个分量(x,y,z)GL_FLOAT
:数据类型为浮点数GL_FALSE
:不需要归一化(因为我们本身的数据已经进行归一化了)sizeof(Vertex)
:步长(Stride)表示当读取了一个顶点后我要移动多少个字节读取另一个顶点,通常为整个顶点结构的大小(void*)nullptr
:位置数据在结构体中的偏移量为0(即从结构体开头开始)
设置颜色属性:
scss
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, sizeof(Vertex),
(void *) offsetof(Vertex, color));
glEnableVertexAttribArray(1);
glVertexAttribPointer
参数解析:
-
1
:对应着色器中layout(location=1)
的属性, -
4
:表示每个顶点有4个分量(r,g,b,a) -
GL_FLOAT
:数据类型为浮点数 -
GL_FALSE
:不需要归一化(因为我们本身的数据已经进行归一化了) -
sizeof(Vertex)
:步长(Stride)表示当读取了一个顶点后我要移动多少个字节读取另一个顶点,通常为整个顶点结构的大小 -
(void *) offsetof(Vertex, color)
:自动计算color属性在顶点结构体中的偏移量
如此这般这个提交数据的工作才算是结束了 不过为了避免污染后续操作,每次绘画完我们要解绑所有状态,在现在我们就绑定了GL_ARRAY_BUFFER
和 GL_ELEMENT_ARRAY_BUFFER
scss
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
回顾一下全流程:
openGl是一帧一帧的绘制的,所以实际上会被多次调用绘制
上图中红色的表示每次绘制必须要调用的步骤
你会发现肉眼可见的每次都得调用一大堆的函数,你会写出一大堆相同的代码
为了解决这个问题,opengl es 3.0版本后 新增vao
VAO(Vertex Array Object,顶点数组对象)是 OpenGL 中用于管理顶点属性状态的核心容器。它相当于一个"配置档案",记录了以下关键信息:
-
顶点缓冲绑定关系
- 绑定的 VBO(Vertex Buffer Object)
- 绑定的 EBO(Element Buffer Object)
-
顶点属性指针配置
-
通过
glVertexAttribPointer
设置的:- 数据偏移量
- 数据类型(如 GL_FLOAT)
- 属性步长(stride)
- 是否标准化
-
-
属性启用状态
- 哪些顶点属性位置(如 location=0)被启用
scss
// 在初始化的时候
init() {
// 生成一个vao
glGenVertexArrays(1, &vao);
// 绑定一个vao
glBindVertexArray(vao);
在这里写绑定vbo,ebo
设置vbo,ebo的数据,
设置顶点数据的代码
这些设置的状态会记录在vao这个"档案"中
// 解绑vao
glBindVertexArray(0);
}
// 在绘制的时候
draw() {
// 绑定一个vao
glBindVertexArray(vao);
调用实际的绘制函数
// 恢复状态
glBindVertexArray(0);
}
到这里准备数据中关于顶点数据的操作是结束了。回顾一下不同的缓冲对象
对象类型 | 存储内容 |
---|---|
VBO | 原始顶点数据 |
EBO | 顶点索引 |
VAO | 属性配置状态 |
但是对于开发人员外还需要注意 加载纹理,传递Uniform 变量这些操作也是在这一环节进行的,这个后面再补充
纹理与材质:加载贴图、设置材质参数(如漫反射、高光强度)。
Uniform 变量:传递全局参数(如 MVP 矩阵、光照位置、时间变量)。
图元装配
这方面的代码比较简单
通过 glDrawArrays
或 glDrawElements
函数选择图元类型(如 GL_TRIANGLES
、GL_LINES
等)。图元类型决定了顶点如何被连接成几何形状。
glDrawArrays
vs glDrawElements
glDrawArrays(mode, first, count)
- 直接按顶点缓冲区顺序绘制,无需索引。
- 适用场景:顶点数据无重复、简单图元。
glDrawElements(mode, count, type, indices)
- 通过索引缓冲区(
EBO
)间接引用顶点数据。 - 优势:复用顶点(如立方体的8个顶点被多个三角形共享),减少内存占用。
图元类型
1. GL_POINTS
(0x0000)
-
行为:每个顶点单独渲染为一个点。
-
顶点数量:任意数量(每个顶点独立)。
-
注意事项:
- 点的大小可以通过
glPointSize
设置。默认1像素 - 这个示例的顶点数据包括4个点,以后的示例也是如此
- 点的大小可以通过
2. GL_LINES
(0x0001)
-
行为 :每2个顶点组成一条独立线段。
-
顶点数量:必须是2的倍数(如2,4,6...),如果不是多余的点会被忽略了
-
示例 : 顶点序列
[v0, v1, v2, v3]
会渲染两条线段:v0→v1
和v2→v3
。 -
注意事项:
- 线宽通过glLineWidth设置
3. GL_LINE_LOOP
(0x0002)
- 行为:所有顶点依次连接成闭合折线(首尾相连)。
- 顶点数量:任意数量(至少2个)。
- 示例 : 顶点序列
[v0, v1, v2, v3]
会渲染线段:v0→v1→v2→v3→v0
(自动闭合)。
4. GL_LINE_STRIP
(0x0003)
- 行为 :顶点依次连接成连续折线 ,但不闭合。
- 顶点数量:任意数量(至少2个)。
- 示例 : 顶点序列
[v0, v1, v2, v3]
会渲染线段:v0→v1→v2→v3
。
5. GL_TRIANGLES
(0x0004)
- 行为 :每3个顶点组成一个独立三角形。
- 顶点数量:必须是3的倍数(如3,6,9...)。
- 示例 : 顶点序列
[v0, v1, v2, v3]
会渲染成一个三角形:v0→v1→v2
6. GL_TRIANGLE_STRIP
(0x0005)
- 行为 :顶点按顺序组成连续的三角形,共享相邻边。
- 顶点数量:至少3个。
- 示例 : 顶点序列
[v0, v1, v2, v3]
会渲染两个三角形:v0→v1→v2
和v1→v2→v3
。
7. GL_TRIANGLE_FAN
(0x0006)
- 行为:所有三角形共享第一个顶点,形成扇形。
- 顶点数量:至少3个。
- 示例 : 顶点序列
[v0, v1, v2, v3]
会渲染两个三角形:v0→v1→v2
和v0→v2→v3
。
示例代码见如下(ElementRender.cpp):
scss
#include <jni.h>
#include "ElementRender.h"
#include "glm/vec3.hpp"
#include "glm/vec4.hpp"
#include "shader.h"
struct Vertex {
glm::vec3 position;
glm::vec4 color;
};
const char *vsSrc = R"(#version 300 es
precision mediump float;
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec4 aColor;
out vec4 color;
void main() {
gl_PointSize = 10.0;
gl_Position = vec4(aPosition, 1.0);
color = aColor;
}
)";
const char *fsSrc = R"(#version 300 es
precision mediump float;
in vec4 color;
out vec4 fragColor;
void main() {
fragColor = color;
}
)";
Vertex vs[] = {
{{-0.3f, 0.5f, 0.5f}, {0.0f, 0.0f, 1.0f, 1.0f}},
{{0.3f, 0.5f, 0.5f}, {0.0f, 1.0f, 0.0f, 1.0f}},
{{0.3f, -0.5f, 0.5f}, {1.0f, 0.0f, 0.0f, 1.0f}},
{{-0.3f, -0.5f, 0.5f}, {0.0f, 0.0f, 0.0f, 1.0f}},
};
void ElementRender::init() {
shaderProgram = createShaderProgram(vsSrc, fsSrc);
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vs), vs, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void *) nullptr);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, sizeof(Vertex),
(void *) offsetof(Vertex, color));
glEnableVertexAttribArray(1);
glBindVertexArray(0);
glClearColor(1.0, 1.0, 1.0, 1.0);
glClearDepthf(1.0);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
glLineWidth(10);
}
void ElementRender::draw() const {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(shaderProgram);
glBindVertexArray(vao);
glDrawArrays(mode, 0, sizeof(vs) / sizeof(Vertex));
glBindVertexArray(0);
}
void ElementRender::resize(int width, int height) {
glViewport(0, 0, width, height);
}
extern "C" JNIEXPORT jlong JNICALL
Java_com_example_opengl_render_ElementRender_initOpenGL(
JNIEnv *env,
jobject thiz,
jint mode) {
auto *pRender = new ElementRender(mode);
return (jlong) pRender;
}
extern "C" JNIEXPORT void JNICALL
Java_com_example_opengl_render_ElementRender_draw(
JNIEnv *env,
jobject /* this */ ,
jlong pRender) {
reinterpret_cast<ElementRender *>(pRender)->draw();
}
extern "C" JNIEXPORT void JNICALL
Java_com_example_opengl_render_ElementRender_resize(
JNIEnv *env,
jobject /* this */ ,
jlong pRender,
jint width,
jint height) {
reinterpret_cast<ElementRender *>(pRender)->resize(width, height);
}
编写着色器
如果仔细看图元装配的示例代码就会发现,欸这是什么?
感觉是c语言的代码,但是又不像。这就是着色器源码
Shader(着色器)是运行在GPU上的小程序,用于控制图形渲染管线的不同阶段。OpenGL中最常用的两种Shader便是上文提到的顶点着色器和片段着色器
-
顶点着色器(Vertex Shader)
- 处理每个顶点的位置、颜色等属性
- 输出裁剪空间坐标(
gl_Position
)
-
片段着色器(Fragment Shader)
- 处理每个像素(片段)的颜色
- 输出最终颜色(
fragColor
)
GLSL
着色器使用的语言叫glsl,整体与c类似,但是略有区别
详细请看docs.gl/
这里简单讲一下
声明版本
#version 300 es
// GLSL必须声明版本(无分号)且必须在第一行前面不能有任何字符
#version 300 es
表示使用OpenGL ES 3.0(移动端常用)
C语言没有这种声明,而GLSL的版本号直接影响可用特性
定义浮点数精度:
precision mediump float;
highp
:高精度(可能不支持)
mediump
:中等精度(推荐默认)
lowp
:低精度(适合颜色)
向量函数类型
C语言没有内置的向量类型,需要手动定义结构体或使用库(如后面会导入的glm)。 而glsl自带
ini
vec3 pos = vec3(1.0, 0.0, 0.0); // 3D向量
mat4 mvp = mat4(1.0); // 4x4单位矩阵
限定符
C语言没有这类限定符,GLSL的限定符用于明确数据流方向:
in
:输入(顶点属性或上一阶段输出)
out
:输出到下一阶段
uniform
:全局只读变量
scss
layout(location = 0) in vec3 aPosition; // 输入属性
out vec4 color; // 输出到下一阶段
uniform mat4 uMVP; // 统一变量(CPU传入
数据的流动
我们编写的程序是运行在CPU上的,声明的属性是保存在内存中的
但是着色器是运行在GPU上的,它读取的数据需要在保存显存中。
从cpu数据到着色器访问到数据存在这么的流程
scss
CPU数据 (vertices)
→ 复制到VBO (glBufferData)
→ 配置解析规则 (glVertexAttribPointer)
→ Shader通过location读取数据
CPU数据:
ini
Vertex vs[] = {
{{-0.3f, 0.5f, 0.5f}, {0.0f, 0.0f, 1.0f, 1.0f}},
{{0.3f, 0.5f, 0.5f}, {0.0f, 1.0f, 0.0f, 1.0f}},
{{0.3f, -0.5f, 0.5f}, {1.0f, 0.0f, 0.0f, 1.0f}},
{{-0.3f, -0.5f, 0.5f}, {0.0f, 0.0f, 0.0f, 1.0f}},
};
将数据复制到VBO(位于GPU上的显存):
scss
glBufferData(GL_ARRAY_BUFFER, sizeof(vs), vs, GL_STATIC_DRAW);
配置解析规则 :
scss
// 配置position属性(location=0)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void *) nullptr);
glEnableVertexAttribArray(0);
// 配置color属性(location=1
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, sizeof(Vertex),
(void *) offsetof(Vertex, color));
glEnableVertexAttribArray(1);
Shader通过location读取数据:
scss
const char *vsSrc = R"(#version 300 es
precision mediump float;
// in 表示输入的属性
// layout(location = 0) 表示属性的数据从哪里读取
// 按照之前 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void *) nullptr);
// 声明的一般在vbo ((void *) nullptr) 多少个偏移量的地方开始读取
// 每个属性间隔sizeof(Vertex)个字节
// 每个属性有3 个 FLOAT类型的长度
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec4 aColor;
// 传递给片段着色器
out vec4 color;
void main() {
// 设置顶点的大小
gl_PointSize = 10.0;
// 设置顶点的位置
gl_Position = vec4(aPosition, 1.0);
// 将输入的颜色传递给片段着色器
color = aColor;
}
)";
const char *fsSrc = R"(#version 300 es
precision mediump float;
// 从顶点着色器传入的颜色
in vec4 color;
// 最终的颜色
out vec4 fragColor;
void main() {
fragColor = color;
}
)";
值得一提的是,顶点着色器只会对每个顶点调用,而片段着色器会对每个像素生效
假如说片段着色器中的color属性是从顶点着色器中直接传递过来的
那么一个三角形,我们只定义了3个顶点,这个三个顶点的颜色又是从哪里来的?
openGl工作的流程中
顶点处理 后的下一步 不是 片段处理 ,中间还会经过图元装配与光栅化
图元装配会将将顶点连接成几何图形(如三角形)。
光栅化会将 将三角形转换为屏幕上的像素片段(Fragment),自动生成顶点之间的所有像素位置。 同时通过插值算法为每个像素计算插值后的顶点属性值,例如颜色和纹理坐标
编译与使用着色器
编译着色器
我们先去编写的着色器代码都是一些字符串,不能直接运行,需要经过编译。
这方面的代码非常公式 :
-
创建着色器
-
设置着色器源码
-
编译着色器
scss
static GLuint compileShader(GLenum shaderType, const char *shaderSource) {
// 创建一个shader 返回着色器的id
// 枚举类型 shaderType 表示着色器的类型
// GL_VERTEX_SHADER : 顶点着色器
// GL_FRAGMENT_SHADER : 片段着色器
GLuint shader = glCreateShader(shaderType);
// 设置着色器源码
// 函数声明为: glShaderSource (GLuint shader, GLsizei count, const GLchar *const*string, const GLint *length);
// shader 表示 着色器id
// count 表示 表示传入的源代码字符串的数量 这里传递 1,表示只有一个源代码字符串
// &shaderSource 指向源代码字符串数组的指针
// length 表示字符串的长度, nullptr 表示每个字符串用\0结尾
glShaderSource(shader, 1, &shaderSource, nullptr);
// 编译着色器
glCompileShader(shader);
return shader;
}
不过这方面的代码非常容易写错,而且因为前文提到的opengl的实现根据gpu不同略有差异,有些手机可以运行的程序在另一个手机可能会编译失败。导致画面不能正常显示
所以我们最后可以检查一下有没有错误发生
scss
GLint success;
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success) {
char infoLog[512];
glGetShaderInfoLog(shader, 512, nullptr, infoLog);
debug("Shader compilation failed: %s", infoLog);
}
使用着色器
openGl没有一个诸如glUseShader()
一般的函数启用着色器,
而是提出另一个概念program
(着色器程序)
Program是一个容器,将多个Shader链接(Link) 成一个完整的GPU可执行程序,协调各个Shader阶段的数据传递。
两者关系:
Shader | Shader | |
---|---|---|
职责 | 处理管线某一阶段(如顶点/片段) | 整合多个Shader,管理管线完整流程 |
生命周期 | 独立编译,可重复使用 | 依赖Shader,需链接后才能使用 |
数据交互 | 通过输入/输出变量传递数据 | 统一管理Uniform、Attribute等全局变量 |
这方面的代码也是非常公式:
- 创建program
- 编译shader
- 将shader添加到program
- 链接
scss
static GLuint
createShaderProgram(const char *vertexShaderSource, const char *fragmentShaderSource) {
// 创建 program 放回值表示程序的id
GLuint program = glCreateProgram();
// 编译着色器
GLuint vertexShader = compileShader(GL_VERTEX_SHADER, vertexShaderSource);
GLuint fragmentShader = compileShader(GL_FRAGMENT_SHADER, fragmentShaderSource);
// 将着色器绑定到 program
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
// 链接
glLinkProgram(program);
// 检查链接错误
GLint success;
glGetProgramiv(program, GL_LINK_STATUS, &success);
if (!success) {
char infoLog[512];
glGetProgramInfoLog(program, 512, nullptr, infoLog);
debug("Program linking failed: %s", infoLog);
}
return program;
}
在Android中使用OpenGl ES
终于可以来做点东西了
在Android中使用openGl ES 可以直接使用java或者kotlin代码调用GLES30.xxx()函数
也可以 旋转编写cpp代码后 用NDK调用cpp代码。
这里旋转第二种方式
从绘制简单的图形开始
在Android中使用openGL 需要用GLSurfaceView
kotlin
class ElementActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val glsView = GLSurfaceView(this)
glsView.setEGLContextClientVersion(3)
glsView.setRenderer(render)
setContentView(glsView)
}
}
简单使用只要注意两个api
setEGLContextClientVersion
设置openGL ES 版本 默认是1,现在手机都支持3.x,
一般最少要使用 2,
1版本完全是另外的写法。
setRenderer
Render
是一个接口,定义了几个函数,这些函数会在GLSurfaceView特定时期被调用。
Render
定义了三个函数
-
onSurfaceCreated(GL10 gl, EGLConfig config)
-
触发时机:
- 首次创建GL上下文时
- Surface被销毁后重建时(如屏幕旋转)
-
典型用途
-
初始化openGL状态 :
- 创建vao,vbo,ebo
- 编译着色器
- 加载纹理等等
-
-
-
onSurfaceChanged(GL10 gl, int width, int height)
-
触发时机
- 首次创建surface的时候触发
- Surface尺寸变化的时候触发
-
典型用途
- 设置openGl 窗口,更新投影矩阵
-
-
onDrawFrame(GL10 gl)
-
触发时机:
- 画面刷新的时候调用 按
setRenderMode()
设置的频率调用,默认连续刷新(RENDERMODE_CONTINUOUSLY) - 可以通过
requestRender()
手动触发 - 类似
View
的draw
函数
- 画面刷新的时候调用 按
-
用途
- 调用OpenGl 绘制函数
-
因为我们是使用NDK开发,所以Render基本上不用进行太多的操作,只是作为桥梁作用链接Cpp代码
kotlin
class ElementRender(val mode: Int) : GLSurfaceView.Renderer {
private var pRender: Long = 0
private external fun initOpenGL(mode: Int): Long
private external fun draw(pRender: Long)
private external fun resize(pRender: Long, width: Int, height: Int)
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
pRender = initOpenGL(mode)
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
resize(pRender, width, height)
}
override fun onDrawFrame(gl: GL10?) {
draw(pRender)
}
}
jni接口的实现也是很简单
initOpenGL
会创建一个ElementRender.cpp
的对象
然后将指针返回
draw
和 resize
通过传入的ElementRender.cpp
的对象的指针访问对应的方法
arduino
extern "C" JNIEXPORT jlong JNICALL
Java_com_example_opengl_render_ElementRender_initOpenGL(
JNIEnv *env,
jobject thiz,
jint mode) {
auto *pRender = new ElementRender(mode);
return (jlong) pRender;
}
extern "C" JNIEXPORT void JNICALL
Java_com_example_opengl_render_ElementRender_draw(
JNIEnv *env,
jobject /* this */ ,
jlong pRender) {
reinterpret_cast<ElementRender *>(pRender)->draw();
}
extern "C" JNIEXPORT void JNICALL
Java_com_example_opengl_render_ElementRender_resize(
JNIEnv *env,
jobject /* this */ ,
jlong pRender,
jint width,
jint height) {
reinterpret_cast<ElementRender *>(pRender)->resize(width, height);
}
ElementRender.cpp
的代码代码实现如下: 完整的代码见github
csharp
class ElementRender {
public:
ElementRender(jint mode) : mode(mode) {
init();
}
void draw() const;
void resize(int width, int height);
private:
void init();
GLenum mode;
GLuint vao, vbo;
GLuint shaderProgram;
};
上述代码存在一些之前没有提到的函数
glClearColor(1.0, 1.0, 1.0, 1.0);
: 设置清屏的颜色为白色 可以理解成设置背景颜色
glLineWidth(10);
: 设置线的宽度,有些设备不生效,如果不是为了测试,最好不要使用
glClear(GL_COLOR_BUFFER_BIT);
: 颜色缓冲区清除, 用glClearColor(1.0, 1.0, 1.0, 1.0);
预设的白色覆盖整个渲染区域,擦除历史帧。
运行效果如下: v.douyin.com/iPsd1JxN/
总结一下要做的事情
转场动画
这一小节我们会从易到难多个在两张图片间切换的一个转场动画
这一小节会介绍的到新知识点有:
stb_image
- 渲染纹理与编写shader代码
实现效果: v.douyin.com/iPsdtyFf/
stb_image
stb_image
是一个轻量级的开源单文件图像加载库(GitHub 地址),它支持多种常见图像格式(如 PNG、JPEG、BMP 等),并将图像数据解码为 像素内存数组,便于后续处理。
关键功能:
-
加载图像文件到内存
-
自动处理格式转换(如将 RGB 转换为 RGBA)
集成到项目中:
-
下载源码 :
stb_image
仅由一个头文件构成 直接下载下来就行了 地址GitHub 地址 -
修改
CmakeList.txt
:- 将**
stb_image.h
** 放到项目路径下面,然后添加这么两行
- 将**
-
定义宏
- 在**
stb_image.h
** ****要用到一个宏定义 - 顺便找一个cpp文件,
- 写下这么两行就行了
arduino#define STB_IMAGE_IMPLEMENTATION #include "stb_image.h"
- 记得把这个cpp文件添加到
CmakeList.txt
的add_library
- 在**
-
简单封装
在Android中如果要在cpp中访问assert里面的文件真的超级麻烦,所以我会先把assert里面的文件写入到指定路径,后面访问的时候再去指定路劲访问
Image.h
:
arduino
class Image {
public:
~Image() {
stbi_image_free(data);
}
static Image *CreateFormFile(const char *fileName) {
if (fileName == nullptr) {
return nullptr;
}
int type = 0;
int width = 0;
int height = 0;
stbi_uc *imageData = stbi_load(fileName, &width, &height, &type, STBI_rgb_alpha);
auto result = new Image(type, width, height, imageData);
return result;
}
int getWidth() {
return width;
}
int getHeight() {
return height;
}
stbi_uc *getData() {
return data;
}
private:
Image(int t, int w, int h, stbi_uc *d) : type(t), width(w), height(h) {
auto size = w * h * 4;
if (size > 0 && d != nullptr) {
data = d;
}
}
int type{};
int width{};
int height{};
stbi_uc *data = nullptr;
};
渲染纹理
纹理(Texture): 纹理是一种用于存储图像数据的对象。它的核心作用是将图像数据"贴"到几何图形表面,从而实现复杂的视觉效果。
纹理坐标 (UV 坐标): 通过 (u, v)
纹理坐标(范围通常是 [0,1]
)确定如何采样纹理,(0,0)
表示图像的左上角, (1,1)
表示图像的右下角
纹理参数:
-
过滤方式(解决纹理缩放问题):
GL_NEAREST
:最近邻采样(像素化效果)。GL_LINEAR
:线性插值(平滑效果)。
-
环绕模式 (处理超出
[0,1]
的纹理坐标):GL_REPEAT
:重复纹理。GL_CLAMP_TO_EDGE
:截断到边缘。
纹理单元:纹理单元是OpenGL中管理多重纹理的核心机制,
渲染经常需要同时使用多个纹理,如果只能设置一个纹理,那么每次切换纹理都要重新上传数据,效率极低:
因此openGL 提供从GL_TEXTRE0
到GL_TEXTRE31
32个纹理单元,可以分别存储不同的纹理。
渲染纹理的过程包括:
- 读取图片信息
- 创建纹理
- 激活纹理单元
- 设置纹理参数
- 上传纹理数据
- 绑定纹理
- 着色器中采样纹理
其中1->6基本上由我习惯在初始化的时候完成
scss
inline GLuint loadTexture(const char *fileName, GLenum target) {
Image *image = Image::CreateFormFile(fileName);
if (image == nullptr) {
debug("Failed to load image from file: %s", fileName);
return 0;
}
// 保存当前激活的纹理单元
GLint originalActiveTexture;
glGetIntegerv(GL_ACTIVE_TEXTURE, &originalActiveTexture);
glActiveTexture(target);
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// 设置纹理参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 上传纹理数据
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image->getWidth(), image->getHeight(), 0, GL_RGBA,
GL_UNSIGNED_BYTE, image->getData());
glGenerateMipmap(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, texture);
glActiveTexture(originalActiveTexture);
delete image; // 释放图像数据
return texture;
}
先写一个渲染一张图片的着色器:
与简单图元对比:
新增的cpp代码主要包括:
loadShaderFromFiles("transition.vert", "transition.frag");
我把着色器的代码写到assert里面的文件,并封装一个方法去加载着色器程序
addTextureFromFile("transition_0.png",
GL_TEXTURE0
);
读取照片数据并创建纹理后绑定到指定纹理单元
glUniform1i(glGetUniformLocation(shaderProgram, "oldTexture"), 0);
指定着色器中名为oldTexture
的属性的值。
使用**uniform
** ****的好处是如果因为顶点着色器对于每一个顶点要用到的纹理可能是一样的,而顶点属性是针对每个顶点的数据,而uniform变量则是全局的,在整个绘制调用过程中保持不变,所有顶点和片段都共享同一个uniform值。
采样着色器:
csharp
#version 300 es
precision mediump float;
// 声明从那一个纹理进行采样
uniform sampler2D oldTexture;
in vec2 fragCoord;
out vec4 fragColor;
void main() {
// texture为glsl自带方法,就是到某某纹理单元,某某坐标 采样出RGBA数据
fragColor = texture(oldTexture, fragCoord);
}
顶点着色器和简单图元的类似,只是传递的数据变成纹理坐标了:
ini
#version 300 es
precision mediump float;
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec2 aTexCoord;
out vec2 fragCoord;
void main() {
gl_Position = vec4(aPosition, 1.0);
fragCoord = aTexCoord;
}
完整代码如下:
arduino
struct Vertex {
glm::vec3 position;
glm::vec2 texCoord;
};
void TransitionRender::init() {
// 加载指定文件夹下面名为"transition.vert"与"transition.frag"的着色器
loadShaderFromFiles("transition.vert", "transition.frag");
// 加载指定文件夹下面名为"transition_0.png" 并将纹理信息绑定到GL_TEXTURE0且提交数据
addTextureFromFile("transition_0.png", GL_TEXTURE0);
glUseProgram(shaderProgram);
glUniform1i(glGetUniformLocation(shaderProgram, "oldTexture"), 0);
// 提交数据 下面没有什么新的代码了
vao.bind();
std::vector<Vertex> vs = {
{{-1.0f, -1.0f, 1.0f}, {0.0f, 1.0f}},
{{1.0f, -1.0f, 1.0f}, {1.0f, 1.0f}},
{{1.0f, 1.0f, 1.0f}, {1.0f, 0.0f}},
{{-1.0f, 1.0f, 1.0f}, {0.0f, 0.0f}},
} ;
vbo.bufferData(vs);
vao.setAttribute<Vertex>(0, 3, GL_FLOAT, nullptr);
vao.setAttribute<Vertex>(1, 2, GL_FLOAT, (void *) offsetof(Vertex, texCoord));
std::vector<GLuint> indices = { 0, 1, 2, 0, 2, 3 } ;
ebo.bufferData(indices);
vao.unbind();
vbo.unbind();
ebo.unbind();
glClearColor(1.0, 1.0, 1.0, 1.0);
}
void TransitionRender::draw() {
glClear(GL_COLOR_BUFFER_BIT);
vao.bind();
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
vao.unbind();
}
void TransitionRender::resize(int width, int height) {
glViewport(0, 0, width, height);
}
extern "C" JNIEXPORT jlong JNICALL
Java_com_example_opengl_render_TransitionRender_initOpenGL(
JNIEnv *env,
jobject thiz,
jint mode) {
auto *pRender = new TransitionRender(mode);
return (jlong) pRender;
}
extern "C" JNIEXPORT void JNICALL
Java_com_example_opengl_render_TransitionRender_draw(
JNIEnv *env,
jobject /* this */ ,
jlong pRender) {
reinterpret_cast<TransitionRender *>(pRender)->draw();
}
extern "C" JNIEXPORT void JNICALL
Java_com_example_opengl_render_TransitionRender_resize(
JNIEnv *env,
jobject /* this */ ,
jlong pRender,
jint width,
jint height) {
reinterpret_cast<TransitionRender *>(pRender)->resize(width, height);
}
运行可以看到图片已经被正常渲染到屏幕上
平移动画:
在正式开始之前我们先修改一个之前的代码
因为动画是在着色器中进行,所以需要将动画的进度[0,1],与两张图片绑定到纹理单元后传递到shader
片段着色器:新增unifom newTexture
: 第二张图片的纹理单元与 progress
: 动画的进度
csharp
#version 300 es
precision mediump float;
uniform sampler2D oldTexture;
uniform sampler2D newTexture;
uniform float progress;
in vec2 fragCoord;
out vec4 fragColor;
void main() {
}
在cpp代码中
scss
init() {
loadShaderFromFiles("transition.vert", "transition.frag");
addTextureFromFile("transition_0.png", GL_TEXTURE0);
addTextureFromFile("transiiton_1.png", GL_TEXTURE1); // 新增 : 绑定纹理到GL_TEXTURE1
glUseProgram(shaderProgram);
progressLoc = glGetUniformLocation(shaderProgram, "progress"); // 新增 : 记录progress的位置
glUniform1i(glGetUniformLocation(shaderProgram, "oldTexture"), 0);
glUniform1i(glGetUniformLocation(shaderProgram, "newTexture"), 1); // 新增 :传递纹理单元到newTexture
...其他代码略
}
draw() {
// 每次绘制的时候传入不同的progress 控制动画的进度;
progress = (progress + 0.003f);
// 当进度超过1.0f后 恢复到原来的状态
if (progress >= 1.0f) {
progress = -0.2f;
// 控制动画在结束的状态等待
glUniform1f(progressLoc, 1.0f);
} else if (progress >= 0.0f) {
glUniform1f(progressLoc, progress);
}
}
接下来到 片段着色器 transition.frag
正式开始写动画
先看效果: v.douyin.com/iPsdq7Kg/
新图片从左下角开始移动,慢慢的沿着左下 -> 右上的方向平移
因为要渲染两张图片所以会有两套纹理坐标,为了方便 我称旧图片的坐标为(u, v) 新图片的纹理坐标为(x,y)
首先我们要明白,片段着色器会对每个像素都生效,但是我们动画运行的时候可能会有一大片的像素是不用改动的,只要去采样原来的图片就行了
因为我们是正正好好样子沿着左下到右上移动的
可以得到新图片的右上顶点的uv为(progress, 1 - progress);
那么对于uv.x > progress 或者 uv.y < (1 - progress) 的像素来说,只需要去采样旧的图片就行,
而对于uv.x <= progress 并且 uv.y >= (1 - progress)的像素,那么就需要去采样新的图片
scss
void slide() {
if (fragCoord.x <= progress && fragCoord.y >= (1.0f - progress)) {
fragColor = texture(newTexture, vec2(newX, newY));
} else {
fragColor = texture(oldTexture, fragCoord);
}
}
如下图易得
ini
float newX = 1.0f - progress + fragCoord.x;
float newY = progress - 1.0f + fragCoord.y;
最终代码为:
ini
void slide() {
if (fragCoord.x <= progress && fragCoord.y >= (1.0f - progress)) {
float newX = 1.0f - progress + fragCoord.x;
float newY = progress - 1.0f + fragCoord.y;
fragColor = texture(newTexture, vec2(newX, newY));
} else {
fragColor = texture(oldTexture, fragCoord);
}
}
淡入淡出
运行效果: v.douyin.com/iPsdXkAP/
代码:
同时采样两张照片后根据系数混合
ini
void fade() {
float midProgress = 2.0f * abs(progress - 0.5f);
float fadeIn = progress;
float fadeOut = (1.0f - progress);
vec4 oldColor = texture(oldTexture, fragCoord) * fadeOut;
vec4 newColor = texture(newTexture, fragCoord) * fadeIn;
fragColor = oldColor + newColor;
}
线性擦除
运行效果: v.douyin.com/iPsdH94m/
直接上代码:
scss
void linearWipe() {
float angle = 45.0f;
float smoothness = 0.02f;
vec2 dir = vec2(cos(radians(angle)), sin(radians(angle)));
float gradient = dot(fragCoord, dir) / dot(vec2(1.0f), dir);
float mask = smoothstep(progress, progress + smoothness, gradient); float mask = smoothstep(progress - smoothness, progress + smoothness, gradient);
fragColor = mix(texture(newTexture, fragCoord), texture(oldTexture, fragCoord), mask);
}
这段代码里面使用了一些GLSL内置函数
-
radians(angle)
:角度转弧度,GLSL 的三角函数(cos
,sin
)只接受弧度, 所以需要将角度(°)转换为弧度(rad)。 -
cos/sin
:三角函数 -
dot(uv, dir)
:计算向量点积,计算fragCoord
(当前像素坐标)在 指定方向单位向量上的投影长度。 -
smoothstep(edge0, edge1, x)
:生成一个在[edge0, edge1]
区间内平滑过渡的掩码值ini如果 x < edge0 → 返回 0。 如果 x > edge1 → 返回 1。 在 [edge0, edge1] 之间 → 平滑过渡 float mask = smoothstep(progress - smoothness, progress + smoothness, gradient); 假设 progress=0.5, smoothness=0.02: gradient < 0.48 → 完全新纹理(mask=0)。 gradient > 0.52 → 完全旧纹理(mask=1)。 在 0.48~0.52 之间 → 新旧渐变(羽化边缘)
-
mix(a, b, t)
:线性插值(混合新旧纹理)-
t=0
→ 完全返回a
(新纹理)。 -
t=1
→ 完全返回b
(旧纹理)。 -
t=0.5
→ 新旧各占 50%。
-
需要注意的是dot(fragCoord, dir)
的范围在[0, √2] 之间
所以需要进行归一化 除以 最大的模
scss
float gradient = dot(fragCoord, dir) / dot(vec2(1.0f), dir);
中央扩散
整体上和线性擦除类似:
scss
void radialUnfold() {
// 设定动画展开的圆心位置
vec2 center = vec2(0.5f);
float smoothness = 0.1f;
// 归一化
vec2 uv = (fragCoord - center) * (sqrt(2.0f) - smoothness);
// glsl自带函数length结算一个向量的长度
float dist = length(uv);
// dist < progress - smoothness → 返回 0(完全显示新纹理)。
// dist > progress → 返回 1(完全显示旧纹理)。
// 在 [progress-smoothness , progress] 之间 → 平滑过渡(羽化边缘)。
float mask = smoothstep(progress - smoothness, progress, dist);
// 混合纹理
fragColor = mix(texture(newTexture, fragCoord), texture(oldTexture, fragCoord), mask);
}
运行效果: v.douyin.com/iPsdaWF2/
分块旋转
运行效果: v.douyin.com/iPsRFHCJ/
ini
void rotatingTilesTransition() {
vec2 center = vec2(0.5f);
float tileSize = 0.1f;
float maxRotation = radians(360.0);
float time = 0.4f;
float maxDist = length(center - vec2(tileSize / 2.0f));
vec2 tileID = floor(fragCoord / tileSize);
// 局部坐标
vec2 localUV = (fragCoord - tileID * tileSize) / tileSize;
float distToCenter = length((tileID + 0.5f) * tileSize - center);
float trigge = distToCenter * (1.0f - time) / maxDist;
float tileProgress = smoothstep(trigge, trigge + time, progress);
float angle = maxRotation * tileProgress;
mat2 rotation = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
// 局部坐标
vec2 rotatedUV = rotation * (localUV - 0.5f) + 0.5f;
vec4 oldColor = texture(oldTexture, tileID * tileSize + rotatedUV * tileSize);
vec4 newColor = texture(newTexture, tileID * tileSize + rotatedUV * tileSize);
float mixFactor = pow(tileProgress, 4.0);
fragColor = mix(oldColor, newColor, mixFactor);
}
Shadertoy BETA
这是一个分享shader的网址
我一开始的演示视频中后面两个转场动画便是在这上面抄过来的
改一改入参就能用了。
详细的可以看看我的我的项目