PHASE 3 · ANDROID 实战

3.3数据的桥梁

Uniform、纹理与触摸

管线已经通了,现在把三种数据源接上桥:数值(uniform)、图像(纹理)、交互(触摸)。接完这一章,本书 Phase 0~2 的每一个 Demo——包括那些带滑块和照片的——你都能在 Android 上完整复刻。

1Uniform:类型对照与缓存

GLSL 每种 uniform 类型都有一个对应的 Kotlin 传值函数,规律是 glUniform + 分量数 + 类型:

GLSL 声明Kotlin API本书里的角色
uniform floatglUniform1f(loc, v)uTime、uProgress
uniform vec2glUniform2f(loc, x, y)uResolution、uMouse
uniform vec3 / vec4glUniform3f / 4f(loc, …)颜色参数
uniform int / boolglUniform1i(loc, v)开关、模式切换
uniform mat4glUniformMatrix4fv(loc, 1, false, m, 0)3D 变换(本书没用到)
uniform sampler2DglUniform1i(loc, 纹理单元号)uTex(注意:传的是单元号!)

loc 是 uniform 在 Program 里的「门牌号」,用名字查询。查询有成本,别每帧查——初始化时查一次存成员变量:

Kotlin · location 缓存(标准写法)
private var uTimeLoc = -1
private var uResLoc = -1
private var uTexLoc = -1

override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
    program = ShaderUtil.createProgram(VERT_SRC, FRAG_SRC)
    uTimeLoc = GLES30.glGetUniformLocation(program, "uTime")
    uResLoc  = GLES30.glGetUniformLocation(program, "uResolution")
    uTexLoc  = GLES30.glGetUniformLocation(program, "uTex")
}

override fun onDrawFrame(gl: GL10?) {
    // ……glUseProgram 之后:
    GLES30.glUniform1f(uTimeLoc, time)
    GLES30.glUniform2f(uResLoc, width.toFloat(), height.toFloat())
}
location 返回 -1 ≠ 出错

0.3 章坑清单的复习:如果某个 uniform 在 Shader 里声明了但没参与最终颜色计算,编译器会把它优化掉,查询返回 -1。给 -1 传值会被静默忽略,不崩溃。所以「调了半天 uniform 没反应」时,先确认它真的被用到了。

2纹理:把 Bitmap 送进 GPU

Phase 2 里所有吃图的效果,在 Android 上的图像来源就是 Bitmap。上传流程四步:建纹理对象 → 设采样参数 → 上传像素 → 释放 Bitmap:

Kotlin · loadTexture(收藏第二件)
fun loadTexture(context: Context, resId: Int): Int {
    val ids = IntArray(1)
    GLES30.glGenTextures(1, ids, 0)
    val texId = ids[0]
    GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texId)

    // 采样参数:决定 texture() 在放大/缩小/越界时的行为
    GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D,
        GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_LINEAR)   // 缩小:线性
    GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D,
        GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_LINEAR)   // 放大:线性
    GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D,
        GLES30.GL_TEXTURE_WRAP_S, GLES30.GL_CLAMP_TO_EDGE) // 越界:延展边缘
    GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D,
        GLES30.GL_TEXTURE_WRAP_T, GLES30.GL_CLAMP_TO_EDGE)

    // 解码 Bitmap 并上传(inScaled=false 防止按密度缩放)
    val opts = BitmapFactory.Options().apply { inScaled = false }
    val bitmap = BitmapFactory.decodeResource(context.resources, resId, opts)
    GLUtils.texImage2D(GLES30.GL_TEXTURE_2D, 0, bitmap, 0)
    bitmap.recycle()   // 像素已进显存,CPU 侧的可以扔了

    return texId
}

两组参数值得懂,因为它们直接改变画面观感:

  • FILTER(过滤):GL_LINEAR 双线性插值,放大平滑;GL_NEAREST 取最近像素,放大出马赛克锯齿——像素风游戏(以及 2.2 章的像素化,如果你想在采样端偷懒)反而要选它。
  • WRAP(越界):UV 超出 0~1 时,CLAMP_TO_EDGE 重复边缘像素(特效常用,防止扭曲时采到「对面」);GL_REPEAT 平铺重复(无缝纹理背景用)。2.3 章毛玻璃的随机偏移能安全越界,靠的就是 CLAMP。
图是倒的?正常,这是那个 y 轴

0.2 章预告过:Bitmap 第一行在顶部,GL 纹理坐标原点在底部,所以直接上传的图是上下颠倒的。三种修法任选:① Shader 里 uv.y = 1.0 - uv.y(最简单);② 上传前 Matrix().apply { postScale(1f, -1f) } 翻转 Bitmap;③ 顶点数据里把纹理坐标翻过来。团队项目里选一种全项目统一,不然翻两次等于没翻,查起来怀疑人生。

3纹理单元:多图协作

Shader 里可以声明多个 sampler2D(3.5 章转场就要两张图)。GPU 通过「纹理单元」这个中转站管理:每个单元插一张纹理,uniform 里存的是单元号。绑定是三步走:

Kotlin · 每帧绑定两张纹理
// 单元 0 ← 第一张图
GLES30.glActiveTexture(GLES30.GL_TEXTURE0)          // ① 选中单元 0
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texFrom) // ② 往里插纹理
GLES30.glUniform1i(uTexFromLoc, 0)                  // ③ 告诉 sampler:去 0 号找

// 单元 1 ← 第二张图
GLES30.glActiveTexture(GLES30.GL_TEXTURE1)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texTo)
GLES30.glUniform1i(uTexToLoc, 1)

新手最容易犯的错是给 sampler2D 传纹理 ID(texId)——记住口诀:「纹理插单元,sampler 记单元号」,ID 只在 glBindTexture 里出现。

4触摸:线程与坐标翻转

最后一座桥是交互。它牵扯 3.1 章的 GL 线程军规:触摸事件在主线程到达,GL 在渲染线程消费,标准解法是 @Volatile 字段做单向交接:

Kotlin · 触摸 → uMouse
class ShaderRenderer : GLSurfaceView.Renderer {
    @Volatile var touchX = 0f     // 主线程写,GL 线程读
    @Volatile var touchY = 0f

    override fun onDrawFrame(gl: GL10?) {
        // ……
        // y 翻转:Android 触摸 y 向下,GL 坐标 y 向上(0.2 章的老朋友)
        GLES30.glUniform2f(uMouseLoc, touchX, height - touchY)
    }
}

// View 侧(主线程):
glSurfaceView.setOnTouchListener { v, event ->
    renderer.touchX = event.x
    renderer.touchY = event.y
    if (event.action == MotionEvent.ACTION_DOWN) v.performClick()
    true
}

更复杂的数据(比如一次性替换整张纹理)用 glSurfaceView.queueEvent { … } 把闭包投递到 GL 线程执行——它保证运行在正确线程、正确时机(帧间隙)。

Android 视角 对账

到这里,本书 Demo 运行时的每个能力你都有 Kotlin 版了:uTime/uResolution(3.2)、uTex(本章)、uMouse(本章)、uProgress(就是一个普通 float uniform,接 Slider/动画值)。也就是说:Phase 0~2 的四十多个 Demo,每一个你都能原样搬上手机了。

本章小结

  • Uniform 传值 API 按「分量数+类型」命名;location 初始化时查一次缓存,-1 表示被优化掉而非出错。
  • 纹理上传四步:genTextures → 参数 → texImage2D → recycle;FILTER 决定缩放观感,WRAP 决定越界行为。
  • Bitmap 与 GL 的 y 轴相反,图是倒的就翻 uv.y,全项目统一一种翻法。
  • 多纹理靠单元中转:纹理插单元,sampler 记单元号,别把 texId 传给 sampler。
  • 主线程写 @Volatile、GL 线程读;重操作用 queueEvent;触摸 y 要翻转。

动手练习

  1. 把 2.2 章乐高 Demo 搬上手机:loadTexture 加载一张你的相册照片,uProgress 接一个 Compose Slider(通过 @Volatile 交接)。
  2. 实现「触摸涟漪」:2.1 章水波扭曲的中心点改为 uMouse,手指按哪波纹从哪扩散。
  3. 故意把 sampler2D 的 uniform 传成 texId 而不是单元号,观察画面症状(通常是黑或取错图)——见过一次,终生免疫。