01渲染管线与着色器
在写任何 GLSL 代码之前,你需要先理解一件事:GPU 的工作方式和 CPU 完全不同。CPU 擅长复杂的串行逻辑,而 GPU 擅长的是:对海量数据执行完全相同的操作。屏幕上有 200 万个像素?GPU 可以几乎同时为每个像素计算颜色。
这种并行处理通过渲染管线(Rendering Pipeline)来组织。你写的 Shader 代码,就是这条管线中你可以自定义的环节:
Vertex Shader
处理每个顶点的位置。输入 3D 坐标,输出屏幕上的 2D 位置。每个顶点执行一次。
光栅化
GPU 自动完成。把顶点围成的三角形"填充"为像素,并对顶点数据进行插值。
Fragment Shader
处理每个像素的颜色。这是你发挥创造力的主战场。每个像素独立执行一次。
最关键的思维转换
写 Fragment Shader 和写普通代码最大的不同是:你没有循环,你不知道"隔壁像素"是什么颜色,你只知道当前像素的坐标。你的代码会同时在所有像素上并行运行,每个像素独立计算自己的颜色。
02Fragment Shader 入门
来看你的第一个 Fragment Shader。在 OpenGL ES 3.0 (即 GLSL 300 es) 中,一个最简单的 Fragment Shader 长这样:
#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 语言类似。每个像素都会独立执行这个函数。
1 会被当成整数导致类型错误,要写 1.0。这是新手最常见的编译错误来源。
引入坐标:gl_FragCoord
光输出固定颜色太无聊了。Fragment Shader 有一个内置变量 gl_FragCoord,它告诉你当前像素在屏幕上的坐标(单位是像素):
#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。
有时你想让坐标中心在屏幕中央且保持宽高比:
// 居中归一化:中心为 (0,0),范围 -0.5 ~ 0.5
vec2 uv = (gl_FragCoord.xy - uResolution * 0.5) / uResolution.y;
// 除以 .y 而非 uResolution 确保 x 和 y 的比例一致(圆不会被拉成椭圆)
03GLSL 数据类型
GLSL 是一种 C 风格语言,但类型系统为图形计算做了专门设计。核心只有几种类型:
| 类型 | 说明 | 示例 |
|---|---|---|
float | 浮点数(GLSL 的基本单位) | float a = 1.0; |
int | 整数 | int i = 3; |
bool | 布尔值 | bool b = true; |
vec2/3/4 | 2/3/4 维浮点向量 | vec3 c = vec3(1.0, 0.0, 0.5); |
mat2/3/4 | 2×2 / 3×3 / 4×4 矩阵 | mat4 m = mat4(1.0); |
sampler2D | 2D 纹理采样器 | uniform sampler2D uTexture; |
向量是 GLSL 的灵魂
理解向量的操作方式是 GLSL 入门的关键。它不只是"容器",而是支持极其灵活的 Swizzle(分量重排) 语法:
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
向量运算是逐分量的
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 计算 |
uniform 类似于 ViewModel 里的 StateFlow — 从外部传入,Shader 只读。in/out 则像管线中的数据流,从 Vertex 流向 Fragment。
04内置函数速查
GLSL 提供了大量内置函数,理解它们是写出优雅 Shader 的关键。以下是最常用的几组:
数学基础
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 编程中无处不在,值得深入理解:
// ── 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 之间平滑渐变
if-else 的概念(虽然语法上支持,但分支会严重影响并行性能)。Shader 开发者用 step 和 smoothstep 来代替条件判断,同时还能获得平滑的视觉效果。
05用数学画图形
Shader 中不存在"画一个圆"这样的 API。一切图形都是通过数学公式定义的。核心技术叫做 SDF(Signed Distance Function,有符号距离函数)。
SDF 的本质
SDF 函数接收一个点的坐标,返回这个点到图形边缘的距离:
- 返回值 < 0:点在图形内部
- 返回值 = 0:点恰好在边缘
- 返回值 > 0:点在图形外部
画一个圆
#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
// 矩形 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 的魔力
// 用 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:
uniform float uTime; // 累计时间(秒),每帧更新
uniform vec2 uResolution; // 画面分辨率 (width, height)
uniform vec2 uTouch; // 触摸/鼠标位置(Android 中特别有用)
时间驱动的动画
#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 通过坐标从中"采样"颜色值。
#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 坐标来扭曲图像、偏移采样位置来做模糊、采样多张纹理来做混合。
// 简单漩涡扭曲效果
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 的架构层次:
关键组件分工如下:
GLSurfaceView
专门用于 OpenGL 渲染的 SurfaceView 子类。它内部管理 EGL 环境和渲染线程。
Renderer
你实现这个接口。三个回调函数分别处理初始化、窗口变化和每帧绘制。
GLES30
Android 提供的 OpenGL ES 3.0 API。通过它调用 GPU 指令。
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 → 画全屏四边形
}
}
GLSurfaceView.queueEvent { }。
09Shader 的编译与链接
Shader 代码是以字符串的形式传给 GPU 驱动进行编译的。整个流程分三步:编译 → 链接 → 使用。
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
}
}
res/raw/ 目录下作为 .glsl 文件,然后用 resources.openRawResource(R.raw.shader_frag).bufferedReader().readText() 读取。比硬编码在 Kotlin 字符串里更易维护。
10顶点数据与全屏 Quad
Fragment Shader 需要"附着"在几何体上运行。对于 2D 特效,你只需要一个覆盖整个屏幕的矩形(Quad)。这是 Vertex Shader 和顶点数据的工作。
最简顶点着色器
#version 300 es
// 输入:顶点位置(由 CPU 端传入)
in vec4 aPosition;
void main() {
// 直接输出位置,不做任何变换
gl_Position = aPosition;
}
设置顶点数据
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)
}
}
GL_TRIANGLE_STRIP 把 4 个顶点自动连成两个三角形(v0-v1-v2 和 v1-v2-v3),刚好覆盖整个矩形。相比 GL_TRIANGLES 需要 6 个顶点(两个三角形各3个),更高效。
11传递 Uniform 数据
这一节汇总所有向 Shader 传递数据的方式,让你清楚每个 API 的对应关系:
| GLSL 声明 | Kotlin API | 典型用途 |
|---|---|---|
uniform float | glUniform1f(loc, v) | 时间、进度 |
uniform vec2 | glUniform2f(loc, x, y) | 分辨率、触摸位置 |
uniform vec3 | glUniform3f(loc, x, y, z) | 颜色 |
uniform vec4 | glUniform4f(loc, …) | RGBA 颜色 |
uniform mat4 | glUniformMatrix4fv(loc, 1, false, m, 0) | 变换矩阵 |
uniform sampler2D | glUniform1i(loc, texUnit) | 纹理单元索引 |
获取 location 的模式永远是同一个:
// 在 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 作为纹理,完整流程如下:
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 做双线性插值,放大时平滑。GL_NEAREST 取最近像素,放大时有像素风格锯齿效果。像素游戏风格用后者。
13Jetpack Compose 集成方案
在 Compose 中使用 OpenGL ES 的标准做法是通过 AndroidView 嵌入 GLSurfaceView:
@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
)
}
}
处理触摸交互
// 在 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
#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 中驱动动画
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
@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 "切换")
}
}
}