OpenGL ES教程——GLSL

1,551 阅读3分钟

image.png

OpenGL程序总是离不开glsl语言的,各种着色器都是用glsl写的,可以说,某种程度上,glsl是OpenGL的基础,内功,只有基础深厚,才可能达到至高境界。

1、GLSL简介

着色器是使用一种叫GLSL的类C语言写成的。GLSL是为图形计算量身定制的,它包含一些针对向量和矩阵操作的有用特性。

着色器的开头总是要声明版本,接着是输入和输出变量、uniform和main函数。每个着色器的入口点都是main函数,在这个函数中我们处理所有的输入变量,并将结果输出到输出变量中。

一个典型的着色器有下面的结构:

#version version_number 
in type in_variable_name; 
in type in_variable_name; 
out type out_variable_name; 
uniform type uniform_name; 
int main() { // 处理输入并进行一些图形操作 ... 
    // 输出处理过的结果到输出变量 
    out_variable_name = weird_stuff_we_processed;
}

前几篇文章中我们绘制了三角形、图片、yuv视频,也用glsl写过一些着色器代码了。目前来看,顶点着色器中一定要返回内置变量:gl_Position。而片段着色器当中,一定要返回一个颜色值:fragColor = outColor

另外,顶点着色器中,每个输入变量也叫顶点属性,我们能申明的顶点属性是有限的,一般最多16个。它可以通过如下代码获取数值:

int nrAttributes; 
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes); 
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;

2、GLSL数据类型

和其他编程语言一样,GLSL有数据类型可以来指定变量的种类。GLSL中包含C等其它语言大部分的默认基础数据类型:intfloatdoubleuintbool。GLSL也有两种容器类型,它们会在这个教程中使用很多,分别是向量(Vector)和矩阵(Matrix),其中矩阵我们会在之后的教程里再讨论。

向量

GLSL中的向量是一个可以包含有2、3或者4个分量的容器,分量的类型可以是前面默认基础类型的任意一个。它们可以是下面的形式(n代表分量的数量):

类型含义
vecn包含n个float分量的默认向量
bvecn包含n个bool分量的向量
ivecn包含n个int分量的向量
uvecn包含n个unsigned int分量的向量
dvecn包含n个double分量的向量

大多数时候我们使用vecn,因为float足够满足大多数要求了。

一个向量的分量可以通过vec.x这种方式获取,这里x是指这个向量的第一个分量。你可以分别使用.x.y.z.w来获取它们的第1、2、3、4个分量。GLSL也允许你对颜色使用rgba,或是对纹理坐标使用stpq访问相同的分量。

向量这一数据类型也允许一些有趣而灵活的分量选择方式,叫做重组(Swizzling)。重组允许这样的语法:

vec2 someVec; 
vec4 differentVec = someVec.xyxx; 
vec3 anotherVec = differentVec.zyw; 
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

vec2 vect = vec2(0.5, 0.7); 
vec4 result = vec4(vect, 0.0, 0.0); 
vec4 otherResult = vec4(result.xyz, 1.0);

3、输入与输出

GLSL定义了inout关键字来声明输入变量和输出变量。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。但在顶点和片段着色器中会有点不同。

注意,上一着色器输出变量与下一着色器输入变量匹配,数据就会传递下去,意味着顶点着色器如果有数据要输出到片段着色器中,这两个变量名必须是一样的

顶点着色器的输入较为特殊,现在一般用layout (location = 0)来标识变量,然后在传递数据时只要location和变量申明的一样,就能传递数据了,简化了代码写法。

片段着色器必须要输出一个vec4的颜色值,如果没有,系统会默认把图形绘制成黑色或白色。

4、Uniform

Uniform是一种从CPU中的应用向GPU中的着色器发送数据的方式,但uniform和顶点属性有些不同。首先,uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。第二,无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。

uniform最大的特点就是可在任意阶段访问并修改参数。

我们来一个小例子,来学习Uniform,使用它来完成一个三角形颜色渐变

首先,顶点着色器和绘制三角形时一致,不赘述了。片段着色器稍有不一样:

#version 300 es
out vec4 fragColor;
uniform vec4 outColor;
void main(){
    fragColor = outColor;
}

三角形的绘制我们也很烂熟于心了,但想要让三角形颜色变化,我们就得时时刻刻改变片段着色器的输出颜色,如上所示,我们把颜色定义为一个uniform参数,在绘制的时候指定它的颜色,然后不停地重新绘制,是不是就会实现效果呢?

glBindVertexArray(mVaoId);

//获取当前时间
time_t t = time(NULL);
//根据时间值,调用sin方法,计算得到greenValue值
float greenValue = sin(t) / 2.0f + 0.5f;
//获取outColor颜色,然后指定此颜色
int vertexColorLocation = glGetUniformLocation(m_ProgramObj, "outColor");
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

glDrawArrays(GL_TRIANGLES, 0, 3);

glBindVertexArray(GL_NONE);
glUseProgram(GL_NONE);

那如何让页面不停地绘制呢?GlSurfaceView,可以指定它的renderMode,不指定,默认就是会不停地重绘,即上述代码会不停地执行,所以效果就显示出来了。

  • GlSurfaceView.renderMode,可以让页面不停地重绘或者按需要重绘
  • glSwapBuffer,在一次绘制中,如果有多帧页面,必须调用此方法,否则第二帧出不来,因为GlSurfaceView使用双缓冲机制,准备好一帧则应该送入系统,让系统显示新帧

uniform对于设置一个在渲染迭代中会改变的属性是一个非常有用的工具,它也是一个在程序和着色器间数据交互的很好工具

最后,因为glsl是类c语言,它不支持方法重载,如果设置不同的参数,glUniform方法后面就会加不同的标志位。

后缀含义
f函数需要一个float作为它的值
i函数需要一个int作为它的值
ui函数需要一个unsigned int作为它的值
3f函数需要3个float作为它的值
fv函数需要一个float向量/数组作为它的值

每当你打算配置一个OpenGL的选项时就可以简单地根据这些规则选择适合你的数据类型的重载函数。在我们的例子里,我们希望分别设定uniform的4个float值,所以我们通过glUniform4f传递我们的数据(注意,我们也可以使用fv版本

5、两个纹理重叠

为了适应自己的代码结构,我用了一个新的方式来获取图片。 首先,我们得有自己有JNIEnv,不可能需要用它的时候必须到jni中拿,那怎么办呢?保存一个JavaVM指针,需要的时候自己取

JavaVM* s_jni_vm = nullptr;

void InitializeJniHelper(JavaVM* vm) {
    s_jni_vm = vm;
}

JNIEnv* GetJniEnv(){
    JNIEnv* env = nullptr;
    JavaVMAttachArgs args;
    args.version = JNI_VERSION_1_4;
    args.name = "pthread-test";
    args.group = NULL;
    s_jni_vm->AttachCurrentThread(&env, &args);
    s_jni_vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_4);
    return env;
}

另外,我怎么拿到图片呢,从c++端通过反射获取图片是个不错的主意:

companion object {
    val instance: NativeContext by lazy { NativeContext() }

    //必须要加这个注解,否则会crash,反射会找不到这个方法
    @JvmStatic
    fun getAssetBitmap(path: String): Bitmap {
        val ins = App.context.assets.open(path)
        val bitmap = BitmapFactory.decodeStream(ins)
        return bitmap
    }
}

那c++中如何反射调用java,拿到图片呢?首先我们要先得到jclass对象,jmethod对象,这种一般只需要获取一次就可以了,即可以在初始化的时候获取:

jmethodID g_method_get_bitmap = nullptr;
jclass g_NativeContext_clazz = nullptr;

MyGlRenderContext::MyGlRenderContext(): mSample(nullptr) {
    auto env = GetJniEnv();
    g_NativeContext_clazz = static_cast<jclass>(env->NewGlobalRef(
            env->FindClass("com/ou/demo/nativecontext/NativeContext")));
    if (g_NativeContext_clazz) {
        g_method_get_bitmap = env->GetStaticMethodID(g_NativeContext_clazz, "getAssetBitmap",
                                                     "(Ljava/lang/String;)Landroid/graphics/Bitmap;");
    }
    LOGI("g_method_get_bitmap = %d,  clazz = %d", g_method_get_bitmap, g_NativeContext_clazz);
}

注意,jclass必须使用全局引用,否则接下来使用会报错。

反射java方法获取图片并获取它的像素值:

//想要初始化指针,就要传指针的指针,否则相当于传值,得到的指针就是个空值,报SEGV_ACCERR异常
void MyGlRenderContext::getBitmap(const char* path, void** data, int &width, int &height) {
    if (g_method_get_bitmap) {
        auto env = GetJniEnv();
        auto jPath = env->NewStringUTF(path);
        auto bitmap = env->CallStaticObjectMethod(g_NativeContext_clazz, g_method_get_bitmap, jPath);
        AndroidBitmapInfo info;
        if (AndroidBitmap_getInfo(env, bitmap, &info) < 0) {
            LOGI("get bitmap info failed");
            return;
        }
        AndroidBitmap_lockPixels(env, bitmap, data);
        width = info.width;
        height = info.height;
        LOGI("bitmap width = %d, height = %d", info.width, info.height);
        AndroidBitmap_unlockPixels(env, bitmap);
    }
}

注意,这里的data参数,如果我们用一维指针,那最后只能得到一个空值,因为这里是要初始化此指针,那就肯定要用二维指针,即指针的指针。

图片像素获取完毕之后,就简单了,首先定义片段着色器,肯定要定义两个2D纹理:

#version 300 es
precision mediump float;
in vec2 v_texCoord;
out vec4 outColor;
uniform sampler2D lyfId;
uniform sampler2D nsId;

void main() {
    //如果第三个值是0.0,它会返回第一个输入;
    //如果是1.0,会返回第二个输入值。
    //0.2会返回80%的第一个输入颜色和20%的第二个输入颜色,即返回两个纹理的混合色。
    outColor = mix(texture(lyfId, v_texCoord), texture(nsId, v_texCoord), 0.2);
}

然后,初始化纹理,指定纹理的属性,给片段着色器里的纹理变量赋值。

void TwoTextureSample::prepareTexture() {
    void* lyfPixel;
    int lyfWidth, lyfHeiht;
    MyGlRenderContext::getInstance()->getBitmap("res/lyf.png", &lyfPixel, lyfWidth, lyfHeiht);
    LOGI("lyfWidth = %d, lyfHeiht = %d", lyfWidth, lyfHeiht);

    void* nsPixel;
    int nsWidth, nsHeiht;
    MyGlRenderContext::getInstance()->getBitmap("res/ns.png", &nsPixel, nsWidth, nsHeiht);
    LOGI("nsWidth = %d, nsHeiht = %d", nsWidth, nsHeiht);

    glActiveTexture(GL_TEXTURE0);
    glGenTextures(1, &mLyfTextureId);
    glBindTexture(GL_TEXTURE_2D, mLyfTextureId);
    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_NEAREST);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    LOGI("run this line lyfPixel = %p", lyfPixel);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, lyfWidth, lyfHeiht,
                 0, GL_RGBA, GL_UNSIGNED_BYTE, lyfPixel);
    glBindTexture(GL_TEXTURE_2D, GL_NONE);

    glActiveTexture(GL_TEXTURE1);
    glGenTextures(1, &mNsTextureId);
    glBindTexture(GL_TEXTURE_2D, mNsTextureId);
    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_NEAREST);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, nsWidth, nsHeiht,
                 0, GL_RGBA, GL_UNSIGNED_BYTE, nsPixel);
    glBindTexture(GL_TEXTURE_2D, GL_NONE);
}

最后绘制:

void TwoTextureSample::draw() {
    if (m_ProgramObj == GL_NONE) {
        return;
    }
    glClearColor(1.0f, 1.0f, 1.0f, 1);
    glClear(GL_COLOR_BUFFER_BIT);
    glUseProgram(m_ProgramObj);

    glBindVertexArray(mVaoId);

    //指定片段着色器中名为lyfId的变量,使用序号为0的纹理
    GLUtils::setUniformValue1i(m_ProgramObj, "lyfId", 0);
    //指定片段着色器中名为nsId的变量,使用序号为1的纹理
    GLUtils::setUniformValue1i(m_ProgramObj, "nsId", 1);

    //激活编号为0的纹理,此纹理使用mLyfTextureId的数据。
    //要把纹理编号指定使用什么数据,也要指定片段着色器中的变量是使用哪个编号的纹理,一个程序中可能有很多个纹理
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, mLyfTextureId);
    glActiveTexture(GL_TEXTURE1);
    glBindTexture(GL_TEXTURE_2D, mNsTextureId);

    glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, (const void*)0);
}

效果如下:

image.png

6、问题记录

  • 读图片数据时,要传二维指针,否则是传值
  • 读vao时,不要在vao里去绑定空值(即处理vbo结束时,绑定个空的vbo),这样会导致vao中记录空值,之前的事白干了,什么东西也绘制不出来
  • 绘制时,一定要重新激活纹理编号,设置纹理编号对应的资源,然后把片段着色器上的变量指定为某个具体纹理编号
  • 关于JniEnv相关的处理还需要考虑下,优化下,另外全局引用也要重新处理下,需要让它自动回收