PHASE 3 · ANDROID 实战

3.2第一帧的三部曲

编译、链接与全屏 Quad

这一章把浏览器里跑的 Shader 第一次画到 Android 屏幕上。代码量是全书之最,但结构极其清晰:编译链接(一次性)→ 顶点准备(一次性)→ 每帧绘制(循环)。而且这些代码写完一次就是你的「模板库」,以后每个项目直接抄自己的。

1从字符串到画面

先建立全局视野。你的 GLSL 源码在 Android 眼里只是个 String,它要经历这条流水线才能上岗:

GLSL 字符串
compile ×2
link 成 Program
use + 传数据
drawArrays

为什么是「编译 ×2 再链接」?因为一个完整的 Program = Vertex Shader + Fragment Shader 的组合(0.1 章的两个可编程环节),各自编译成目标码,再链接检查两者接口是否咬合(VS 的 out 对得上 FS 的 in 吗?)——和 C 程序「编译多个 .o 再 ld」一模一样。编译发生在运行时、用户的手机上,因为每家 GPU 的指令集不同,只能到现场再翻译。这也意味着:GLSL 语法错误不会在 Android Studio 里报,只会在运行时的日志里报——所以错误检查代码不是可选项。

2第一部:编译与链接

Kotlin · ShaderUtil(直接收藏)
object ShaderUtil {

    /** 编译单个 Shader。type 取 GL_VERTEX_SHADER 或 GL_FRAGMENT_SHADER */
    fun compileShader(type: Int, source: String): Int {
        val shader = GLES30.glCreateShader(type)
        GLES30.glShaderSource(shader, source)     // 塞入源码
        GLES30.glCompileShader(shader)            // 现场编译

        val status = IntArray(1)
        GLES30.glGetShaderiv(shader, GLES30.GL_COMPILE_STATUS, status, 0)
        if (status[0] == 0) {
            // 编译失败:日志里有行号和原因,GLSL 的"编译器报错"全靠它
            Log.e("Shader", "编译失败:\n${GLES30.glGetShaderInfoLog(shader)}")
            GLES30.glDeleteShader(shader)
            return 0
        }
        return shader
    }

    /** 链接 VS + FS 为一个可用的 Program */
    fun createProgram(vertSrc: String, fragSrc: String): Int {
        val vs = compileShader(GLES30.GL_VERTEX_SHADER, vertSrc)
        val fs = compileShader(GLES30.GL_FRAGMENT_SHADER, fragSrc)
        if (vs == 0 || fs == 0) return 0

        val program = GLES30.glCreateProgram()
        GLES30.glAttachShader(program, vs)
        GLES30.glAttachShader(program, fs)
        GLES30.glLinkProgram(program)

        val status = IntArray(1)
        GLES30.glGetProgramiv(program, GLES30.GL_LINK_STATUS, status, 0)
        if (status[0] == 0) {
            Log.e("Shader", "链接失败:\n${GLES30.glGetProgramInfoLog(program)}")
            GLES30.glDeleteProgram(program)
            return 0
        }
        // 链接完成后,shader 对象已被吸收进 program,可以释放
        GLES30.glDeleteShader(vs)
        GLES30.glDeleteShader(fs)
        return program
    }
}
Shader 源码放哪

别硬编码在 Kotlin 字符串里(没有高亮、转义地狱)。放 res/raw/effect.frag,读取一行搞定:resources.openRawResource(R.raw.effect).bufferedReader().readText()。配合 IDE 装个 GLSL 语法高亮插件(4.2 章),体验完全不同。

3第二部:顶点与全屏 Quad

Fragment Shader 只在「有几何体覆盖的像素」上运行(0.1 章的光栅化),所以 2D 特效需要一块盖住全屏的矩形画布:两个三角形,四个顶点。这也是 Vertex Shader 全书唯一一次出场——它简单到令人感动:

GLSL · 全屏 Quad 的顶点着色器(一生只写一次)
#version 300 es
layout(location = 0) in vec2 aPos;   // 顶点坐标,由 CPU 提供

void main() {
    // NDC(标准化设备坐标):x、y 都是 -1~1,恰好铺满视口
    // 顶点本来就给的 NDC,所以——原样输出,不做任何变换
    gl_Position = vec4(aPos, 0.0, 1.0);
}

CPU 端要做的,是把四个顶点的坐标送进 GPU 显存,并说明数据怎么解读。两个新名词:VBO(Vertex Buffer Object,显存里的顶点数据块)和 VAO(Vertex Array Object,记住「数据怎么解读」的配置快照):

Kotlin · onSurfaceCreated:一次性准备
private var program = 0
private var vao = 0

// 全屏 Quad:TRIANGLE_STRIP 顺序(左下、右下、左上、右上)
private val quad = floatArrayOf(
    -1f, -1f,   1f, -1f,   -1f, 1f,   1f, 1f,
)

override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
    program = ShaderUtil.createProgram(VERT_SRC, FRAG_SRC)

    // VAO:先建档,后面的顶点配置都记在它名下
    val ids = IntArray(1)
    GLES30.glGenVertexArrays(1, ids, 0)
    vao = ids[0]
    GLES30.glBindVertexArray(vao)

    // VBO:开显存,把顶点数组搬进去
    GLES30.glGenBuffers(1, ids, 0)
    GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, ids[0])
    val buf = ByteBuffer.allocateDirect(quad.size * 4)
        .order(ByteOrder.nativeOrder()).asFloatBuffer()
        .put(quad).apply { position(0) }
    GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER, quad.size * 4, buf,
                        GLES30.GL_STATIC_DRAW)   // STATIC:传一次不再改

    // 告诉 GPU:location 0 的属性 = 每顶点 2 个 float,紧密排列
    GLES30.glEnableVertexAttribArray(0)
    GLES30.glVertexAttribPointer(0, 2, GLES30.GL_FLOAT, false, 0, 0)

    GLES30.glBindVertexArray(0)   // 收工,解绑
}

为什么四个顶点够画矩形?因为 GL_TRIANGLE_STRIP 模式下,GPU 把顶点滑窗成三角形:(v0,v1,v2) 和 (v1,v2,v3)——两个三角形正好拼满矩形,比 GL_TRIANGLES(要 6 个顶点)省一点。这四行 float 就是你 App 里唯一的几何体,以后所有效果都画在它上面。

4第三部:每帧绘制

Kotlin · onDrawFrame:循环部分
private var startNs = System.nanoTime()
private var width = 0; private var height = 0

override fun onSurfaceChanged(gl: GL10?, w: Int, h: Int) {
    width = w; height = h
    GLES30.glViewport(0, 0, w, h)
}

override fun onDrawFrame(gl: GL10?) {
    GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT)
    GLES30.glUseProgram(program)               // 用哪套 Shader

    // 传本书标准 uniform(location 查询下一章会优化成缓存)
    val time = (System.nanoTime() - startNs) / 1e9f
    GLES30.glUniform1f(GLES30.glGetUniformLocation(program, "uTime"), time)
    GLES30.glUniform2f(GLES30.glGetUniformLocation(program, "uResolution"),
                       width.toFloat(), height.toFloat())

    GLES30.glBindVertexArray(vao)
    GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4)   // 开画!
    GLES30.glBindVertexArray(0)
}

到此为止,把本书任何一个 Demo 的 Fragment Shader 源码(比如 1.4 的调色板、1.5 的 Domain Warping)放进 FRAG_SRC,你的手机上就会出现和浏览器里一模一样的画面。三部曲的代码从此不再变,变的只有那个字符串。

5黑屏排错清单

第一次跑十有八九黑屏,按顺序查,基本五分钟内解决:

检查项怎么查
① Shader 编译/链接失败Logcat 搜「编译失败/链接失败」——ShaderUtil 的日志是为此刻准备的;ERROR: 0:5 里的 5 是行号
② 忘了 setEGLContextClientVersion(3)默认是 ES 1.0,300 es 源码必然编译失败;init 里补上
③ glViewport 没设置onSurfaceChanged 里必须调,否则视口尺寸未定义
④ 顶点属性 location 不匹配VS 里 layout(location=0) 和 glVertexAttribPointer 的第一个参数要一致
⑤ 在主线程调了 GL 函数静默失败(3.1 章军规);数据交接用 @Volatile 或 queueEvent
⑥ Shader 本身输出黑色把 FS 换成输出纯橙色的最小版(0.2 章),先验证管线,再换回效果
Android 视角 复盘

回头看,这一章其实就是本书 Demo 运行时(book.js)的 Kotlin 版——浏览器里那套「compileShader → createProgram → 四顶点 → 每帧 drawArrays」和你刚写的代码逐行对应。WebGL、OpenGL ES、桌面 OpenGL 是同一套 API 的三种口音,学一次,处处能用。

本章小结

  • 流水线:字符串 → 编译 ×2 → 链接成 Program → use → drawArrays;编译在用户手机上运行时发生,错误检查必须写。
  • 全屏 Quad = 4 顶点 + TRIANGLE_STRIP;顶点着色器原样输出 NDC,一生写一次。
  • VBO 存数据,VAO 存「数据怎么解读」的配置;都是一次性准备。
  • 每帧只做四件事:清屏、useProgram、传 uniform、drawArrays。
  • 黑屏按清单查:日志 → ES 版本 → viewport → location → 线程 → 最小 Shader。

动手练习

  1. 把三部曲完整敲一遍(别复制粘贴,这章值得手敲),用 0.2 章的 UV 渐变当 FRAG_SRC,真机跑通。
  2. 故意把 FS 里一个 1.0 改成 1,看 Logcat 里的编译错误长什么样、行号准不准。
  3. 把 1.5 章 Domain Warping 的完整源码搬上手机,感受「浏览器调效果 → 手机零修改运行」的工作流——这就是选 GLSL 300 es 的回报。