GLSL 着色器编程
从零到 Android 实战

不再盲目复制粘贴。从 GPU 渲染管线的本质出发,一步步理解每一行 Shader 代码的含义,最终在 Android 中写出属于你自己的视觉效果。

OpenGL ES 3.0 GLSL 300 es Kotlin Jetpack Compose

01渲染管线与着色器

在写任何 GLSL 代码之前,你需要先理解一件事:GPU 的工作方式和 CPU 完全不同。CPU 擅长复杂的串行逻辑,而 GPU 擅长的是:对海量数据执行完全相同的操作。屏幕上有 200 万个像素?GPU 可以几乎同时为每个像素计算颜色。

这种并行处理通过渲染管线(Rendering Pipeline)来组织。你写的 Shader 代码,就是这条管线中你可以自定义的环节:

顶点数据
Vertex Shader
光栅化
Fragment Shader
帧缓冲
📐

Vertex Shader

处理每个顶点的位置。输入 3D 坐标,输出屏幕上的 2D 位置。每个顶点执行一次。

🔲

光栅化

GPU 自动完成。把顶点围成的三角形"填充"为像素,并对顶点数据进行插值。

🎨

Fragment Shader

处理每个像素的颜色。这是你发挥创造力的主战场。每个像素独立执行一次。

最关键的思维转换

写 Fragment Shader 和写普通代码最大的不同是:你没有循环,你不知道"隔壁像素"是什么颜色,你只知道当前像素的坐标。你的代码会同时在所有像素上并行运行,每个像素独立计算自己的颜色。

理解这个类比 想象你是一个工厂里的工人。你坐在一个固定的位置上(像素坐标),有人告诉你当前时间(uniform 变量),但你不能和旁边的工人说话。你只能根据自己的位置和收到的信息,决定涂什么颜色。工厂里有几百万个这样的工人同时工作。

02Fragment Shader 入门

来看你的第一个 Fragment Shader。在 OpenGL ES 3.0 (即 GLSL 300 es) 中,一个最简单的 Fragment Shader 长这样:

GLSL
#version 300 es
precision mediump float;

// 输出变量:这个像素最终的颜色
out vec4 fragColor;

void main() {
    // vec4(R, G, B, A)  每个分量范围是 0.0 ~ 1.0
    fragColor = vec4(1.0, 0.4, 0.2, 1.0);
}

逐行拆解:

#version 300 es — 声明 GLSL 版本。Android 上用 OpenGL ES 3.0 对应的就是这个版本。必须是文件的第一行,前面不能有空行或注释。

precision mediump float — 设置浮点数精度。mediump 是中等精度(够用了),highp 是高精度(桌面 GPU 默认,移动端要显式声明)。Fragment Shader 中必须声明,否则编译报错。

out vec4 fragColor — 用 out 关键字声明一个输出变量。vec4 是 4 维向量,代表 RGBA 颜色。这个名字你可以随便取。

main() — Shader 的入口函数,和 C 语言类似。每个像素都会独立执行这个函数。

常见坑 GLSL 中浮点数必须带小数点。写 1 会被当成整数导致类型错误,要写 1.0。这是新手最常见的编译错误来源。

引入坐标:gl_FragCoord

光输出固定颜色太无聊了。Fragment Shader 有一个内置变量 gl_FragCoord,它告诉你当前像素在屏幕上的坐标(单位是像素):

GLSL
#version 300 es
precision mediump float;

uniform vec2 uResolution; // 屏幕分辨率,由 CPU 端传入
out vec4 fragColor;

void main() {
    // 归一化坐标:把像素坐标映射到 0.0 ~ 1.0
    vec2 uv = gl_FragCoord.xy / uResolution;

    // 用坐标作为颜色:左下角黑色,右上角黄色
    fragColor = vec4(uv.x, uv.y, 0.0, 1.0);
}

gl_FragCoord.xy 的原点在左下角,x 向右增长,y 向上增长。但像素坐标取决于屏幕分辨率(比如 1080×2400),直接用不方便,所以第一步几乎总是归一化:除以分辨率,让坐标范围变成 0~1。

有时你想让坐标中心在屏幕中央且保持宽高比:

GLSL
// 居中归一化:中心为 (0,0),范围 -0.5 ~ 0.5
vec2 uv = (gl_FragCoord.xy - uResolution * 0.5) / uResolution.y;
// 除以 .y 而非 uResolution 确保 x 和 y 的比例一致(圆不会被拉成椭圆)
为什么除以 .y? 如果你分别除以 x 和 y,在非正方形屏幕上坐标会被拉伸。统一除以 y 分量,横向范围会超过 ±0.5,但比例是正确的。这是 Shadertoy 社区通用的坐标归一化手法。

03GLSL 数据类型

GLSL 是一种 C 风格语言,但类型系统为图形计算做了专门设计。核心只有几种类型:

类型说明示例
float浮点数(GLSL 的基本单位)float a = 1.0;
int整数int i = 3;
bool布尔值bool b = true;
vec2/3/42/3/4 维浮点向量vec3 c = vec3(1.0, 0.0, 0.5);
mat2/3/42×2 / 3×3 / 4×4 矩阵mat4 m = mat4(1.0);
sampler2D2D 纹理采样器uniform sampler2D uTexture;

向量是 GLSL 的灵魂

理解向量的操作方式是 GLSL 入门的关键。它不只是"容器",而是支持极其灵活的 Swizzle(分量重排) 语法:

GLSL
vec4 color = vec4(1.0, 0.5, 0.2, 1.0);

color.rgb   // vec3(1.0, 0.5, 0.2)  — 取前三个分量
color.xy    // vec2(1.0, 0.5)       — 也可以用 .xy 别名
color.rrr   // vec3(1.0, 1.0, 1.0)  — 重复同一分量
color.bgr   // vec3(0.2, 0.5, 1.0)  — 重新排列

// .xyzw、.rgba、.stpq 三套别名完全等价
// 用哪套取决于语义:坐标用 .xy,颜色用 .rgb,纹理坐标用 .st

向量运算是逐分量的

GLSL
vec3 a = vec3(1.0, 2.0, 3.0);
vec3 b = vec3(4.0, 5.0, 6.0);

a + b    // vec3(5.0, 7.0, 9.0)   — 对应分量相加
a * b    // vec3(4.0, 10.0, 18.0) — 对应分量相乘(不是点积!)
a * 2.0  // vec3(2.0, 4.0, 6.0)   — 标量会广播到每个分量

dot(a, b)    // 32.0    — 点积要用内置函数
cross(a, b)  // vec3(…) — 叉积
length(a)    // 3.74…  — 向量长度
normalize(a) // 单位向量

变量修饰符

GLSL 有三个关键的修饰符,决定了数据从哪里来:

修饰符含义谁设置它
uniform所有像素共享的全局常量CPU 端(你的 Kotlin 代码)
in从上一阶段传入的插值数据Vertex Shader 的 out
out传给下一阶段的数据当前 Shader 计算
对 Android 开发者的类比 uniform 类似于 ViewModel 里的 StateFlow — 从外部传入,Shader 只读。in/out 则像管线中的数据流,从 Vertex 流向 Fragment。

04内置函数速查

GLSL 提供了大量内置函数,理解它们是写出优雅 Shader 的关键。以下是最常用的几组:

数学基础

GLSL
abs(x)          // 绝对值
sign(x)         // 返回 -1.0, 0.0, 或 1.0
floor(x)        // 向下取整
ceil(x)         // 向上取整
fract(x)        // 小数部分 = x - floor(x)  ⬅ 做重复图案的神器
mod(x, y)       // 取模 = x - y * floor(x/y)
min(a, b)       // 取最小值
max(a, b)       // 取最大值
clamp(x, lo, hi) // 限制在 [lo, hi] 范围内

三大核心:mix / step / smoothstep

这三个函数在 Shader 编程中无处不在,值得深入理解:

GLSL
// ── mix(a, b, t) ──
// 线性插值:返回 a*(1-t) + b*t
// t=0.0 → 返回 a  t=1.0 → 返回 b  t=0.5 → 返回 a 和 b 的中间值
vec3 blended = mix(colorA, colorB, 0.5); // 两种颜色等量混合

// ── step(edge, x) ──
// 阶跃函数:x < edge → 0.0,x >= edge → 1.0
// 可以理解为"硬边界判断"
float mask = step(0.5, uv.x); // 左半边=0 右半边=1

// ── smoothstep(edge0, edge1, x) ──
// 平滑阶跃:在 edge0~edge1 之间做平滑过渡(Hermite 插值)
// x < edge0 → 0.0  x > edge1 → 1.0  之间 → 平滑曲线
float soft = smoothstep(0.4, 0.6, uv.x); // 在 0.4~0.6 之间平滑渐变
为什么 smoothstep 这么重要? 因为 GPU 上没有 if-else 的概念(虽然语法上支持,但分支会严重影响并行性能)。Shader 开发者用 stepsmoothstep 来代替条件判断,同时还能获得平滑的视觉效果。

05用数学画图形

Shader 中不存在"画一个圆"这样的 API。一切图形都是通过数学公式定义的。核心技术叫做 SDF(Signed Distance Function,有符号距离函数)

SDF 的本质

SDF 函数接收一个点的坐标,返回这个点到图形边缘的距离

  • 返回值 < 0:点在图形内部
  • 返回值 = 0:点恰好在边缘
  • 返回值 > 0:点在图形外部

画一个圆

GLSL
#version 300 es
precision mediump float;
uniform vec2 uResolution;
out vec4 fragColor;

// 圆的 SDF:到圆心的距离 - 半径
float sdCircle(vec2 p, float r) {
    return length(p) - r;
}

void main() {
    // 居中归一化坐标
    vec2 uv = (gl_FragCoord.xy - uResolution * 0.5) / uResolution.y;

    // 计算距离:半径 0.3 的圆
    float d = sdCircle(uv, 0.3);

    // 方法1:硬边缘(step)
    // float circle = step(0.0, -d);  // 圆内=1 圆外=0

    // 方法2:平滑边缘(推荐)
    float circle = smoothstep(0.005, 0.0, d);

    vec3 color = mix(
        vec3(0.1),           // 背景色
        vec3(0.4, 0.3, 0.9), // 圆的颜色
        circle
    );
    fragColor = vec4(color, 1.0);
}

更多基础 SDF

GLSL
// 矩形 SDF(以原点为中心,半尺寸为 b)
float sdBox(vec2 p, vec2 b) {
    vec2 d = abs(p) - b;
    return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}

// 圆角矩形
float sdRoundBox(vec2 p, vec2 b, float r) {
    return sdBox(p, b - r) - r;
}

// 线段
float sdSegment(vec2 p, vec2 a, vec2 b) {
    vec2 pa = p - a, ba = b - a;
    float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
    return length(pa - ba * h);
}

// 组合操作
min(d1, d2)    // 并集(Union)
max(d1, d2)    // 交集(Intersection)
max(d1, -d2)   // 差集(Subtraction)

重复图案:fract 的魔力

GLSL
// 用 fract 把坐标空间重复切割成格子
vec2 grid = fract(uv * 5.0);        // 5×5 的重复网格
vec2 cell = grid - 0.5;              // 每个格子中心为 (0,0)
float d = sdCircle(cell, 0.2);      // 每个格子画一个圆
float dots = smoothstep(0.005, 0.0, d);

// 结果:5×5 的圆点阵列!

06Uniform 与动画

静态画面有了,但 Shader 的魅力在于动起来。动画的秘密就是 uniform 变量 — 从 CPU 端每帧传入的动态数据。

最常用的三个 Uniform:

GLSL
uniform float uTime;       // 累计时间(秒),每帧更新
uniform vec2  uResolution; // 画面分辨率 (width, height)
uniform vec2  uTouch;      // 触摸/鼠标位置(Android 中特别有用)

时间驱动的动画

GLSL
#version 300 es
precision mediump float;
uniform vec2 uResolution;
uniform float uTime;
out vec4 fragColor;

void main() {
    vec2 uv = (gl_FragCoord.xy - uResolution * 0.5) / uResolution.y;

    // 圆的半径随时间呼吸式脉动
    float radius = 0.25 + 0.05 * sin(uTime * 3.0);
    float d = length(uv) - radius;

    // 彩虹色随时间旋转
    vec3 col = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0, 2, 4));

    float mask = smoothstep(0.005, 0.0, d);
    fragColor = vec4(col * mask, 1.0);
}
经典配色公式 0.5 + 0.5 * cos(time + uv.xyx + vec3(0, 2, 4)) 是 Inigo Quilez 发明的调色板公式。通过改变 vec3 里的相位偏移,可以产生无穷多种渐变色方案。这比直接在 RGB 之间 mix 效果好得多。

07纹理采样

纹理(Texture)就是一张图片,Shader 通过坐标从中"采样"颜色值。

GLSL
#version 300 es
precision mediump float;
uniform sampler2D uTexture;  // 纹理采样器
uniform vec2 uResolution;
out vec4 fragColor;

void main() {
    vec2 uv = gl_FragCoord.xy / uResolution;
    // 注意:纹理坐标也叫 UV,范围 0~1
    // (0,0) 是左下角,(1,1) 是右上角

    vec4 texColor = texture(uTexture, uv);
    fragColor = texColor;
}

在 GLSL 300 es 中,采样函数是 texture()(旧版 GLSL 100 中叫 texture2D())。

用纹理做特效

纹理采样的威力在于:你可以修改 UV 坐标来扭曲图像、偏移采样位置来做模糊、采样多张纹理来做混合。

GLSL
// 简单漩涡扭曲效果
vec2 center = vec2(0.5);
vec2 delta = uv - center;
float angle = length(delta) * 5.0;  // 距离越远旋转越多
float s = sin(angle), c = cos(angle);
vec2 rotated = vec2(
    delta.x * c - delta.y * s,
    delta.x * s + delta.y * c
) + center;
vec4 texColor = texture(uTexture, rotated);

08OpenGL ES 在 Android 中的架构

你已经理解了 GLSL 本身,现在要把它跑在 Android 上。首先理解 Android 中 OpenGL ES 的架构层次:

你的 Kotlin 代码
GLSurfaceView
EGL Context
GPU 驱动
屏幕

关键组件分工如下:

🖼️

GLSurfaceView

专门用于 OpenGL 渲染的 SurfaceView 子类。它内部管理 EGL 环境和渲染线程。

🔄

Renderer

你实现这个接口。三个回调函数分别处理初始化、窗口变化和每帧绘制。

GLES30

Android 提供的 OpenGL ES 3.0 API。通过它调用 GPU 指令。

Kotlin
class ShaderSurfaceView(context: Context) : GLSurfaceView(context) {
    init {
        // 指定 OpenGL ES 3.0
        setEGLContextClientVersion(3)
        // 设置你的 Renderer
        setRenderer(ShaderRenderer())
        // 持续渲染模式(每帧都调用 onDrawFrame)
        renderMode = RENDERMODE_CONTINUOUSLY
    }
}

class ShaderRenderer : GLSurfaceView.Renderer {
    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        // 初始化:编译 Shader、创建 Program、准备顶点数据
    }
    override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        // 窗口尺寸变化:更新视口
        GLES30.glViewport(0, 0, width, height)
    }
    override fun onDrawFrame(gl: GL10?) {
        // 每帧调用:清屏 → 传 Uniform → 画全屏四边形
    }
}
渲染线程 Renderer 的三个回调运行在 GL 线程上,不是主线程。所有 OpenGL 调用必须在 GL 线程执行。如需跨线程通信,使用 GLSurfaceView.queueEvent { }

09Shader 的编译与链接

Shader 代码是以字符串的形式传给 GPU 驱动进行编译的。整个流程分三步:编译 → 链接 → 使用。

Kotlin
object ShaderUtil {

    /**
     * 编译单个 Shader(顶点或片元)
     * @param type  GLES30.GL_VERTEX_SHADER 或 GL_FRAGMENT_SHADER
     * @param code  GLSL 源码字符串
     * @return shader ID,失败返回 0
     */
    fun compileShader(type: Int, code: String): Int {
        val shader = GLES30.glCreateShader(type)
        GLES30.glShaderSource(shader, code)
        GLES30.glCompileShader(shader)

        // 检查编译是否成功
        val status = IntArray(1)
        GLES30.glGetShaderiv(shader, GLES30.GL_COMPILE_STATUS, status, 0)
        if (status[0] == 0) {
            val log = GLES30.glGetShaderInfoLog(shader)
            Log.e("Shader", "编译失败: $log")
            GLES30.glDeleteShader(shader)
            return 0
        }
        return shader
    }

    /**
     * 链接顶点和片元 Shader 为一个 Program
     */
    fun createProgram(vertCode: String, fragCode: String): Int {
        val vert = compileShader(GLES30.GL_VERTEX_SHADER, vertCode)
        val frag = compileShader(GLES30.GL_FRAGMENT_SHADER, fragCode)

        val program = GLES30.glCreateProgram()
        GLES30.glAttachShader(program, vert)
        GLES30.glAttachShader(program, frag)
        GLES30.glLinkProgram(program)

        // 检查链接状态
        val status = IntArray(1)
        GLES30.glGetProgramiv(program, GLES30.GL_LINK_STATUS, status, 0)
        if (status[0] == 0) {
            Log.e("Shader", "链接失败: ${GLES30.glGetProgramInfoLog(program)}")
            GLES30.glDeleteProgram(program)
            return 0
        }

        // 编译链接成功后,shader 对象可以释放
        GLES30.glDeleteShader(vert)
        GLES30.glDeleteShader(frag)
        return program
    }
}
Shader 源码存哪里? 建议放在 res/raw/ 目录下作为 .glsl 文件,然后用 resources.openRawResource(R.raw.shader_frag).bufferedReader().readText() 读取。比硬编码在 Kotlin 字符串里更易维护。

10顶点数据与全屏 Quad

Fragment Shader 需要"附着"在几何体上运行。对于 2D 特效,你只需要一个覆盖整个屏幕的矩形(Quad)。这是 Vertex Shader 和顶点数据的工作。

最简顶点着色器

GLSL — vertex_shader.glsl
#version 300 es
// 输入:顶点位置(由 CPU 端传入)
in vec4 aPosition;

void main() {
    // 直接输出位置,不做任何变换
    gl_Position = aPosition;
}

设置顶点数据

Kotlin
class ShaderRenderer : GLSurfaceView.Renderer {
    private var program = 0
    private var vao = 0
    private var startTime = 0L
    private var width = 0
    private var height = 0

    // 全屏四边形:两个三角形组成
    // 坐标范围 -1 ~ 1(NDC 标准化设备坐标)
    private val quadVertices = floatArrayOf(
        -1f, -1f,   // 左下
         1f, -1f,   // 右下
        -1f,  1f,   // 左上
         1f,  1f,   // 右上
    )

    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        // 1. 编译链接 Shader Program
        program = ShaderUtil.createProgram(VERT_CODE, FRAG_CODE)
        startTime = System.nanoTime()

        // 2. 创建 VAO(顶点数组对象)— 记录顶点属性配置
        val vaos = IntArray(1)
        GLES30.glGenVertexArrays(1, vaos, 0)
        vao = vaos[0]
        GLES30.glBindVertexArray(vao)

        // 3. 创建 VBO(顶点缓冲对象)— 存放顶点数据
        val vbos = IntArray(1)
        GLES30.glGenBuffers(1, vbos, 0)
        GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, vbos[0])

        // 4. 上传顶点数据到 GPU
        val buffer = ByteBuffer
            .allocateDirect(quadVertices.size * 4)
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer()
            .put(quadVertices)
            .apply { position(0) }

        GLES30.glBufferData(
            GLES30.GL_ARRAY_BUFFER,
            quadVertices.size * 4,
            buffer,
            GLES30.GL_STATIC_DRAW
        )

        // 5. 配置顶点属性
        val posLoc = GLES30.glGetAttribLocation(program, "aPosition")
        GLES30.glEnableVertexAttribArray(posLoc)
        GLES30.glVertexAttribPointer(
            posLoc,        // attribute 位置
            2,             // 每个顶点 2 个分量 (x, y)
            GLES30.GL_FLOAT,
            false,
            0,             // stride(紧密排列所以为 0)
            0              // offset
        )

        GLES30.glBindVertexArray(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?) {
        // 计算时间
        val time = (System.nanoTime() - startTime) / 1_000_000_000f

        GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT)
        GLES30.glUseProgram(program)

        // 传递 Uniform
        val resLoc = GLES30.glGetUniformLocation(program, "uResolution")
        GLES30.glUniform2f(resLoc, width.toFloat(), height.toFloat())
        val timeLoc = GLES30.glGetUniformLocation(program, "uTime")
        GLES30.glUniform1f(timeLoc, time)

        // 绘制全屏四边形
        GLES30.glBindVertexArray(vao)
        GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4)
        GLES30.glBindVertexArray(0)
    }
}
为什么是 TRIANGLE_STRIP? GL_TRIANGLE_STRIP 把 4 个顶点自动连成两个三角形(v0-v1-v2 和 v1-v2-v3),刚好覆盖整个矩形。相比 GL_TRIANGLES 需要 6 个顶点(两个三角形各3个),更高效。

11传递 Uniform 数据

这一节汇总所有向 Shader 传递数据的方式,让你清楚每个 API 的对应关系:

GLSL 声明Kotlin API典型用途
uniform floatglUniform1f(loc, v)时间、进度
uniform vec2glUniform2f(loc, x, y)分辨率、触摸位置
uniform vec3glUniform3f(loc, x, y, z)颜色
uniform vec4glUniform4f(loc, …)RGBA 颜色
uniform mat4glUniformMatrix4fv(loc, 1, false, m, 0)变换矩阵
uniform sampler2DglUniform1i(loc, texUnit)纹理单元索引

获取 location 的模式永远是同一个:

Kotlin
// 在 onSurfaceCreated 中缓存 location(避免每帧查找)
private var uTimeLoc = 0
private var uResolutionLoc = 0
private var uTouchLoc = 0

// 在 program 创建成功后:
uTimeLoc = GLES30.glGetUniformLocation(program, "uTime")
uResolutionLoc = GLES30.glGetUniformLocation(program, "uResolution")
uTouchLoc = GLES30.glGetUniformLocation(program, "uTouch")

// 然后在 onDrawFrame 中直接用:
GLES30.glUniform1f(uTimeLoc, time)
GLES30.glUniform2f(uResolutionLoc, width.toFloat(), height.toFloat())
注意 glGetUniformLocation 如果返回 -1,说明这个 uniform 在 Shader 中未被使用(编译器会优化掉未使用的变量)。这不是错误,但传值时会被忽略。

12纹理加载与使用

把 Android 的 Bitmap 传给 Shader 作为纹理,完整流程如下:

Kotlin
fun loadTexture(context: Context, resId: Int): Int {
    val texIds = IntArray(1)
    GLES30.glGenTextures(1, texIds, 0)
    val texId = texIds[0]

    GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texId)

    // 设置纹理参数
    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 并上传到 GPU
    val options = BitmapFactory.Options().apply {
        inScaled = false  // 不缩放,保持原始尺寸
    }
    val bitmap = BitmapFactory.decodeResource(
        context.resources, resId, options
    )
    GLUtils.texImage2D(GLES30.GL_TEXTURE_2D, 0, bitmap, 0)
    bitmap.recycle()  // 上传后释放 CPU 端内存

    return texId
}

// ── 在 onDrawFrame 中使用纹理 ──
// 激活纹理单元 0
GLES30.glActiveTexture(GLES30.GL_TEXTURE0)
// 绑定纹理
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureId)
// 告诉 Shader:uTexture 使用纹理单元 0
GLES30.glUniform1i(uTextureLoc, 0)
GL_LINEAR vs GL_NEAREST GL_LINEAR 做双线性插值,放大时平滑。GL_NEAREST 取最近像素,放大时有像素风格锯齿效果。像素游戏风格用后者。

13Jetpack Compose 集成方案

在 Compose 中使用 OpenGL ES 的标准做法是通过 AndroidView 嵌入 GLSurfaceView

Kotlin
@Composable
fun ShaderView(
    modifier: Modifier = Modifier
) {
    var rendererRef by remember {
        mutableStateOf<ShaderRenderer?>(null)
    }

    AndroidView(
        modifier = modifier.fillMaxSize(),
        factory = { ctx ->
            GLSurfaceView(ctx).apply {
                setEGLContextClientVersion(3)
                // 如果需要透明背景
                setEGLConfigChooser(8, 8, 8, 8, 16, 0)
                holder.setFormat(PixelFormat.TRANSLUCENT)

                val renderer = ShaderRenderer()
                rendererRef = renderer
                setRenderer(renderer)
                renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
            }
        },
        onRelease = { glView ->
            // 清理 GL 资源(可选,GLSurfaceView 会在 destroy 时处理)
        }
    )
}

// ── 在 Screen 中使用 ──
@Composable
fun ShaderScreen() {
    Box(modifier = Modifier.fillMaxSize()) {
        ShaderView()
        // Compose UI 可以叠加在 Shader 上面
        Text(
            text = "GLSL Effect",
            modifier = Modifier.align(Alignment.Center),
            color = Color.White
        )
    }
}

处理触摸交互

Kotlin
// 在 Renderer 中添加触摸位置属性
class ShaderRenderer : GLSurfaceView.Renderer {
    @Volatile var touchX = 0f
    @Volatile var touchY = 0f
    // … onDrawFrame 中传递:
    // glUniform2f(uTouchLoc, touchX, height - touchY)
    // 注意 y 翻转:Android 触摸 y 是从上到下,GL 坐标系是从下到上
}

// 在 AndroidView 的 factory 中设置触摸监听
setOnTouchListener { _, event ->
    renderer.touchX = event.x
    renderer.touchY = event.y
    true
}

14实战:页面转场动画

把所有知识串起来,实现一个真实的页面转场效果。这种效果在 GL Transitions(glsl.io)上有大量开源案例。下面展示一个"圆形揭示"转场:

Fragment Shader

GLSL — transition.glsl
#version 300 es
precision mediump float;

uniform sampler2D uTexFrom;   // 原页面截图
uniform sampler2D uTexTo;     // 目标页面截图
uniform float     uProgress;  // 动画进度 0.0 ~ 1.0
uniform vec2      uResolution;
out vec4 fragColor;

void main() {
    vec2 uv = gl_FragCoord.xy / uResolution;

    // 居中坐标,保持宽高比
    vec2 center = (gl_FragCoord.xy - uResolution * 0.5) / uResolution.y;

    // 圆的半径随进度扩大
    // sqrt(2.0) 确保圆能完全覆盖屏幕对角线
    float maxRadius = sqrt(2.0) * max(
        uResolution.x / uResolution.y, 1.0
    );
    float radius = uProgress * maxRadius;

    // 当前像素到中心的距离
    float dist = length(center);

    // 在圆边缘添加平滑过渡(宽度约 2 像素)
    float edge = 2.0 / uResolution.y;
    float mask = smoothstep(radius - edge, radius + edge, dist);

    // mask=0 → 圆内 → 新页面,mask=1 → 圆外 → 旧页面
    vec4 colFrom = texture(uTexFrom, uv);
    vec4 colTo   = texture(uTexTo, uv);

    fragColor = mix(colTo, colFrom, mask);
}

在 Kotlin 中驱动动画

Kotlin
class TransitionRenderer(
    private val context: Context
) : GLSurfaceView.Renderer {
    private var program = 0
    private var texFrom = 0
    private var texTo = 0
    @Volatile var progress = 0f  // 由 Compose 的动画驱动

    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        program = ShaderUtil.createProgram(VERT, FRAG)
        texFrom = loadTexture(context, R.drawable.page_from)
        texTo   = loadTexture(context, R.drawable.page_to)
        // … 设置 VAO/VBO(与上一章相同)
    }

    override fun onDrawFrame(gl: GL10?) {
        GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT)
        GLES30.glUseProgram(program)

        // 绑定两个纹理到不同的纹理单元
        GLES30.glActiveTexture(GLES30.GL_TEXTURE0)
        GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texFrom)
        GLES30.glUniform1i(
            GLES30.glGetUniformLocation(program, "uTexFrom"), 0
        )

        GLES30.glActiveTexture(GLES30.GL_TEXTURE1)
        GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texTo)
        GLES30.glUniform1i(
            GLES30.glGetUniformLocation(program, "uTexTo"), 1
        )

        // 传递进度和分辨率
        GLES30.glUniform1f(
            GLES30.glGetUniformLocation(program, "uProgress"),
            progress
        )
        // … resolution, 然后 glDrawArrays
    }
}

用 Compose 动画驱动 progress

Kotlin
@Composable
fun TransitionScreen() {
    var trigger by remember { mutableStateOf(false) }
    val progress by animateFloatAsState(
        targetValue = if (trigger) 1f else 0f,
        animationSpec = tween(durationMillis = 800, easing = FastOutSlowInEasing),
        label = "transition"
    )

    var rendererRef by remember {
        mutableStateOf<TransitionRenderer?>(null)
    }

    // 每帧更新 progress
    LaunchedEffect(progress) {
        rendererRef?.progress = progress
    }

    Box(modifier = Modifier.fillMaxSize()) {
        AndroidView(
            factory = { ctx ->
                GLSurfaceView(ctx).apply {
                    setEGLContextClientVersion(3)
                    val r = TransitionRenderer(ctx)
                    rendererRef = r
                    setRenderer(r)
                    renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
                }
            },
            modifier = Modifier.fillMaxSize()
        )

        Button(
            onClick = { trigger = !trigger },
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .padding(32.dp)
        ) {
            Text(if (trigger) "返回" else "切换")
        }
    }
}
进一步探索 这只是冰山一角。掌握了基础后,可以继续探索:噪声函数(Perlin/Simplex noise 用于自然纹理)、光线步进(Ray Marching 用于 3D SDF 渲染)、后处理特效(模糊、色调映射、边缘检测)。推荐资源:Shadertoy.com(海量在线 Shader 案例)、The Book of Shaders(交互式 GLSL 教程)、Inigo Quilez 的博客(SDF 和数学技巧百科全书)。