年轻人的第一篇OpenGL ES 2.0教程

Before we go

在高性能graphics领域,特别是3D graphics领域,OpenGL无疑是目前的最佳选择,虽然,现在有很多集成度高的三方的库或者SDK,但是学习一下OpenGL仍然是非常有好处的,你可以了解基本的computer graphics的概念,这会让你在使用它们的时候更加的从容。

OpenGL是一个跨平台的高性能3D渲染API,OpenGL ES是它的嵌入式平台版本。

OpenGL ES

我们即将踏上学习OpenGL ES 2.0之旅,主要针对于Android平台,会有一系列文章来分享学习OpenGL ES的总结。

主要编程语言将使用Kotlin,对于Kotlin还不熟悉的同学可以先看前面的介绍实例来快速的熟悉一下。

Android上面的OpenGL ES一共有三个版本,1.0,2.0以及现在的3.x(3.1, 3.2),其中1.0是旧式的API,与桌面版本的OpenGL非常接近,但是却不太好用。从2.0开始,API有较大变化,具体的渲染相关使用专门的着色语言来表达
矩阵的处理放到一个单独的类Matrix中,这样解耦后,学习起来和理解起来相对容易,API也不会依赖于具体的对象,直接使用static式的GLES20或者GLES30就好了。3.0是向后兼容的,它完全兼容2.0。所以,从2.0开始学习,是一个
比较好的选择,而且2.0被Android 2.3以后的SDK支持,应该说目前所有的设备API上面都是支持OpenGL ES 2.0的(当然,具体的支持情况还看硬件GPU)。

为了方便,在此系列文章中,OpenGL,或者OpenGL ES或者GL,都是指OpenGL ES 2.0。

关于平台,虽然我们是基于Android平台来学习,但是OpenGL是跨平台的,所有平台的GL的API(OpenGL, ES,或者WebGL,或者水果平台)长的都类似,方法名字,以及参数都差不多。虽然不可以直接使用,但是当作参考都没有问题。

开发环境搭建

首先是Android app的开发环境搭建,这个不多说了,大家自行Google。SDK版本最好高一点,至少要是5.0 (API 20)以上吧。

其次是Kotlin语言的支持,如是是Android Studio 3.0以上的版本,自带支持,不用折腾。否则可以参考官方网站的指导

涉及到SDK相关的东西就是Activity,我们是有页面显示的,所以必须要有一个Activity,这个都懂得。主要是widget就是android.oepngl.GLSurfaceView
以及android.opengl.GLSurfaceView.Renderer。GLSurfaceView是Android平台专门用于OpenGL绘制的组件,我们只需要创建一个
实例,然后做一些基本的配置就好了,每个例子的配置都是很类似。重点就是要实现一个GLSurfaceView.Renderer,这个是OpenGL开发的重点。

Step by step guide

首先,新建一个Android app项目,注意带上Kotlin支持,默认是钩上的。名字随意,比如叫EffectiveGL。

然后,在项目新建一个空白Activity,不用钩选backward compat和创建layout,因为我们只用一个GLSurfaceView,用不着layout文件,另外,我们是用Kotlin,Kotlin是用Anko来用代码写布局。

再有,在Activity里面,创建一个GLSurfaceView对象,然后当作Activity的布局。

最后,实现一个Renderer接口,塞给GLSurfaceView,并对其做简单的配置。

最终,一个准备好开发OpenGL的基本代码是这样子的,这些基础的准备工作,后面的示例中会略掉。

class HelloPoints : Activity() {
    private lateinit var glSurfaceView: GLSurfaceView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        title = "Play with Points"

        glSurfaceView = GLSurfaceView(this)
        setContentView(glSurfaceView)

        glSurfaceView.setEGLContextClientVersion(2)
        glSurfaceView.setRenderer(PointsRender)
        glSurfaceView.renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
    }

    override fun onResume() {
        super.onResume()
        glSurfaceView.onResume()
    }

    override fun onPause() {
        super.onPause()
        glSurfaceView.onPause()
    }

    companion object PointsRender : GLSurfaceView.Renderer {
        override fun onDrawFrame(p0: GL10?) {
        }

        override fun onSurfaceChanged(p0: GL10?, p1: Int, p2: Int) {
        }

        override fun onSurfaceCreated(p0: GL10?, p1: EGLConfig?) {
        }
    }
}

基础概念理解

有一些基础中的基础的概念需要理解一下,才能开始码代码。刚接触这么多概念,可能还没有理解它们,没有关系,先建立一个大概印象,随着学习的深入,就慢慢理解它们了。

GL context

GL API的调用,虽然都是static形式的,没有限制,在哪里都能直接call,但是实际上它是有一个上下文环境的,叫GL context(目前阶段先这么叫着吧,不是太严谨哈)。这有点听不懂,用人话说,
就是所有的GL API的调用都要在GLSurfaceView.Renderer的三个方法里面来call,就是方法的调用栈必须从这几个方法开始。在其他地方call是没有效果的:

onSurfaceCreated

onSurfaceChanged

onDrawFrame

GL的坐标系

OpenGL的坐标系是所谓的右手坐标系。

右手坐标系


首先它是三维的笛卡尔坐标系:原点在屏幕正中,x轴从屏幕左向右,最左是-1,最右是1;y轴从屏幕下向上,最下是-1,最上是1;z轴从屏幕里面向外,最里面是-1,最外面是1。


右手坐标系示意

shader

GL ES 2.0与1.0版本最大的区别在于,把渲染相关的操作用一个专门的叫作着色语言的程序来表达,全名叫作OpenGL ES Shading language,它是一个编程语言,与C语言非常类似,能够直接操作矩阵和向量,运行在GPU之上
专门用于图形渲染。它又分为两种,一个叫做顶点着色器(vertex shader),另一个叫做片元着色器(fragment shader)。前者用来指定几何形状的顶点;后者用于指定每个顶点的着色。
每个GL程序必须要有一个vertex shader和一个fragment shader,且它们是相互对应的。(相互对应,意思是vertex shader必须要有一个fragment shader,反之亦然,但并不一定是一一对应)。当然,也是可以复用的,
比如同一个vertex shader,可能会多个fragment shader来表达不同的着色方案。

坐标值和颜色值

坐标正常的取值范围都是-1到1,且是float类型。
颜色值是0到1,也是float类型,0是空(无的意思,比如黑色,或者全透明),1是有(全的意思,比如白色,或者不透明),有些API是使用0~255,这时就需要转换一下。
其实呢,写成超过此范围的值也是可以的,比如坐标传2,或者颜色写成5,OpenGL会处理成为它的合理的取值之内,用clamp的方式,超过的会被砍掉,如传5,相当于传1。

好了,准备工作差不多了,我们来撸代码吧。

年轻人的第一个OpenGL程序

我们的目标是画一个红色的点,就是这个样子的:

画一红色的点


注意: 鉴于方便理解,我们暂时只做一些2D的渲染,也不调整view port,因为这会涉及比较复杂的Model View Projection矩阵的设置。

最终的代码就是这个样子的,重点看一下Renderer的实现,后面详细讲解:

const val TAG = "HelloPoints"

class HelloPoints : Activity() {
    private lateinit var glSurfaceView: GLSurfaceView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        title = "Play with Points"

        glSurfaceView = GLSurfaceView(this)
        setContentView(glSurfaceView)

        glSurfaceView.setEGLContextClientVersion(2)
        glSurfaceView.setRenderer(PointsRender)
        glSurfaceView.renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
    }

    override fun onResume() {
        super.onResume()
        glSurfaceView.onResume()
    }

    override fun onPause() {
        super.onPause()
        glSurfaceView.onPause()
    }

    companion object PointsRender : GLSurfaceView.Renderer {
        private const val VERTEX_SHADER =
                "void main() {\n" +
                        "gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n" +
                        "gl_PointSize = 20.0;\n" +
                        "}\n"
        private const val FRAGMENT_SHADER =
                "void main() {\n" +
                        "gl_FragColor = vec4(1., 0., 0.0, 1.0);\n" +
                        "}\n"
        private var mGLProgram: Int = -1

        override fun onDrawFrame(p0: GL10?) {
            GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
            GLES20.glUseProgram(mGLProgram)

            GLES20.glDrawArrays(GLES20.GL_POINTS, 0, 1)
        }

        override fun onSurfaceChanged(p0: GL10?, p1: Int, p2: Int) {
            GLES20.glViewport(0, 0, p1, p2)
        }

        override fun onSurfaceCreated(p0: GL10?, p1: EGLConfig?) {
            GLES20.glClearColor(0f, 0f, 0f, 1f)

            val vsh = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER)
            GLES20.glShaderSource(vsh, VERTEX_SHADER)
            GLES20.glCompileShader(vsh)

            val fsh = GLES20.glCreateShader(GLES20.GL_FRAGMENT_SHADER)
            GLES20.glShaderSource(fsh, FRAGMENT_SHADER)
            GLES20.glCompileShader(fsh)

            mGLProgram = GLES20.glCreateProgram()
            GLES20.glAttachShader(mGLProgram, vsh)
            GLES20.glAttachShader(mGLProgram, fsh)
            GLES20.glLinkProgram(mGLProgram)

            GLES20.glValidateProgram(mGLProgram)

            val status = IntArray(1)
            GLES20.glGetProgramiv(mGLProgram, GLES20.GL_VALIDATE_STATUS, status, 0)
            Log.d(TAG, "validate shader program: " + GLES20.glGetProgramInfoLog(mGLProgram))
        }
    }
}

示例代码讲解

基础设施

先来看一下Activity的onCreate/onResume和onPause这三个方法。先是在onCreate里面创建一个GLSurfaceView实例,设置为content view,因为我们要使用OpenGL ES 2.0,所以要setEGLContextClientVersion(2)。然后,再
设置一个Renderer实例,渲染模式(render mode)分为两种,一个是GLSurfaceView主动刷新(continuously),不停的回调Renderer的onDrawFrame,另外一种叫做被动刷新(when dirty),就是当请求刷新时才调一次onDrawFrame。

这里我们用continuously的方式。


至于onResume/onPause,API要求是要调用一下GLSurfaceView的onResume和onPause,照做就好,对于我们的示例来说,其实调与不调看不出区别。这只是影响离开Activity页面时的性能,我们学习初期,可以不予关注。

Renderer之onSurfaceCreated

这个是最先被回调到的方法,告诉你系统层面,已经ready了,你可以开始做你的事情了。一般我们会在此方法里面做一些初始化工作,比如编译链接shader程序,初始化buffer等。我们一行一行的来分析:

GLES20.glClearColor(0f, 0f, 0f, 1f) // 参数顺序 r, g, b, a

这句是告诉OpenGL,给我把背景,或者叫作画布,画成黑色,不透明。比较绕人的说法是用参数指定的(r, g, b, a)这个颜色来初始化颜色缓冲区(color buffer)。目前就理解成为画面背景色就可以了。

接下来的这一坨是编译和链接shader程序:

val vsh = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER)

创建一个vertex shader程序,返回的是它的句柄,此返回值会用在后续操作的参数,所以,要用变量记录下来。

GLES20.glShaderSource(vsh, VERTEX_SHADER) // 告诉OpenGL,这一坨字串里面是vertex shader的源码。
GLES20.glCompileShader(vsh) // 编译vertex shader

接下来的三行,是编译fragment shader,跟vertex shader是一样的。
然后是创建shader program并把shader链到上头去。同样的,先创建一个shader program句柄,后面要用,所以要记录一下,因为要在此方法外使用program句柄,所以要用全局变量来记录。

mGLProgram = GLES20.glCreateProgram() // 创建shader program句柄
GLES20.glAttachShader(mGLProgram, vsh) // 把vertex shader添加到program
GLES20.glAttachShader(mGLProgram, fsh) // 把fragment shader添加到program
GLES20.glLinkProgram(mGLProgram) // 做链接,可以理解为把两种shader进行融合,做好投入使用的最后准备工作

到此,其实shader program的准备工作已经做完了,但是如果shader编译或者链接过程出错了怎么办呢?能不能提早发现呢?当然,有办法检查一下,就是用接下来的这几句:

GLES20.glValidateProgram(mGLProgram) // 让OpenGL来验证一下我们的shader program,并获取验证的状态
val status = IntArray(1)
GLES20.glGetProgramiv(mGLProgram, GLES20.GL_VALIDATE_STATUS, status, 0) // 获取验证的状态
Log.d(TAG, "validate shader program: " + GLES20.glGetProgramInfoLog(mGLProgram))

如果有语法错误,编译错误,或者状态出错,这一步是能够检查出来的。如果一切正常,则取出来的status[0]为0。

Renderer之onSurfaceChanged

此回调,会在surface发生改变时,通常是size发生变化。这里我们改变一下视角。

GLES20.glViewport(0, 0, p1, p2) // 参数是left, top, width, height

就是要指定OpenGL的可视区域(view port),(0, 0)是左上角,然后是width和height。
我们目前只学习2D绘制,所以,先不管三维视角的处理。

Renderer之onDrawFrame

这个是最重要的方法,没有之一。前面两个,只会在surface created时调一次。而此方法是用来绘制每帧的,所以每次刷新都会被调一次,所有的绘制都发生在这里。

GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) // 清除颜色缓冲区,因为我们要开始新一帧的绘制了,所以先清理,以免有脏数据。
GLES20.glUseProgram(mGLProgram) // 告诉OpenGL,使用我们在onSurfaceCreated里面准备好了的shader program来渲染
GLES20.glDrawArrays(GLES20.GL_POINTS, 0, 1) // 开始渲染,发送渲染点的指令, 第二个参数是offset,第三个参数是点的个数。目前只有一个点,所以是1。

vertex shader

private const val VERTEX_SHADER =
                "void main() {\n" +
                        "gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n" +
                        "gl_PointSize = 20.0;\n" +
                        "}\n"

shader语言跟C语言很像,它有一个主函数,也叫void main(){}。

gl_Position是一个内置变量,用于指定顶点,它是一个点,三维空间的点,所以用一个四维向量来赋值。vec4是四维向量的类型,vec4()是它的构造方法。等等,三维空间,不是(x, y, z)三个吗?咋用vec4呢?
四维是叫做齐次坐标,它的几何意义仍是三维,先了解这么多,记得对于2D的话,第四位永远传1.0就可以了。这里,是指定原点(0, 0, 0)作为顶点,就是说想在原点位置画一个点。gl_PointSize是另外一个内置变量,用于指定点的大小。

这个shader就是想在原点画一个尺寸为20的点。

fragment shader

private const val FRAGMENT_SHADER =
                "void main() {\n" +
                        "gl_FragColor = vec4(1., 0., 0.0, 1.0);\n" +
                        "}\n"

gl_FragColor是fragment shader的内置变量,用于指定当前顶点的颜色,四个分量(r, g, b, a)。这里是想指定为红色,不透明。

Fun time

更改一些参数,看看会发生什么:

  1. 改变onSurfaceCreated中的glClearColor的颜色值
  2. 改变gl_Position
  3. 改变gl_PointSize
  4. 改变gl_FragColor

One more thing

此系列教程会共存在同一个Android app项目里面,所以我们会随着代码的增加而进行一系列的重构,但是这与我们的主题OpenGL无关,如果是单纯学习OpenGL,可以略过此节。

因为,每个教程会讲解不同的点,对Activity可能有不同的需求,所以,一个教程对应着一个Activity,这样就需要一个列表来作为路由目录页面:

class HomeActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        title = "Learn OpenGL ES Effectively"

        verticalLayout {
            textView("Welcome to the world of OpenGL ES") {
                gravity = Gravity.CENTER
            }.onClick { startActivity<HelloPoints>() }
        }
    }
}

参考资料

  • 《WebGL Programming Guide》

    WebGL跟OpenGL ES 2.0相差无几,可以直接参考。这本书最大好处是讲解比较清晰,层次递进,代码完整,非常适合初学者上手。
  • 《OpenGL® ES 2.0 Programming Guide》

    这本书比较啰嗦和枯燥,它更接近于规范,非常详尽严谨的讲述,但是讲解过少,示例也少。所以,它更适合于有一定基础,想要更深入的全面的理解某一概念时看,不适合入门。

    所以,这两本书加起来看效果最佳,先入门,理解基本概念,然后再通过后者全面理解,巩固加强。

原文链接:http://toughcoder.net/blog/2018/07/31/introduction-to-opengl-es-2-dot-0/

微信公众号:稀有猿诉

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容