这一章把浏览器里跑的 Shader 第一次画到 Android 屏幕上。代码量是全书之最,但结构极其清晰:编译链接(一次性)→ 顶点准备(一次性)→ 每帧绘制(循环)。而且这些代码写完一次就是你的「模板库」,以后每个项目直接抄自己的。
1从字符串到画面
先建立全局视野。你的 GLSL 源码在 Android 眼里只是个 String,它要经历这条流水线才能上岗:
为什么是「编译 ×2 再链接」?因为一个完整的 Program = Vertex Shader + Fragment Shader 的组合(0.1 章的两个可编程环节),各自编译成目标码,再链接检查两者接口是否咬合(VS 的 out 对得上 FS 的 in 吗?)——和 C 程序「编译多个 .o 再 ld」一模一样。编译发生在运行时、用户的手机上,因为每家 GPU 的指令集不同,只能到现场再翻译。这也意味着:GLSL 语法错误不会在 Android Studio 里报,只会在运行时的日志里报——所以错误检查代码不是可选项。
2第一部:编译与链接
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
}
}
别硬编码在 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 全书唯一一次出场——它简单到令人感动:
#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,记住「数据怎么解读」的配置快照):
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第三部:每帧绘制
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 章),先验证管线,再换回效果 |
回头看,这一章其实就是本书 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。
动手练习
- 把三部曲完整敲一遍(别复制粘贴,这章值得手敲),用 0.2 章的 UV 渐变当 FRAG_SRC,真机跑通。
- 故意把 FS 里一个
1.0改成1,看 Logcat 里的编译错误长什么样、行号准不准。 - 把 1.5 章 Domain Warping 的完整源码搬上手机,感受「浏览器调效果 → 手机零修改运行」的工作流——这就是选 GLSL 300 es 的回报。