Android 13 RuntimeShader实战:AGSL与RenderEffect实现GPU着色
2026/9/17 2:58:44 网站建设 项目流程

简介:面向Android图形开发者的RuntimeShader应用示例包,围绕运行时着色器在View二次渲染、Canvas渲染、Canvas二次渲染三种典型场景,演示逐像素渲染的完整实现思路。逐像素渲染会在每个像素上执行着色操作,相比逐顶点方式能更精细地呈现阴影、光照与反射效果,适合具备基础图形学知识、希望掌握AGSL和RenderEffect用法的开发者。压缩包共1541个文件,涵盖agsl着色器源码、Java业务类、XML布局资源、Gradle构建配置及编译产物,目录结构清晰,便于按模块索引,可直接还原为可运行工程,包体约60.85MB。已有115人学习。除三个可交互Demo外,还提供关键渲染链路的注释说明,可结合配套博客对照分析逐像素操作如何作用在视图与画布上,便于快速迁移到自定义特效或图像处理项目中。

1. RuntimeShader 是什么,为什么值得现在学

RuntimeShader 是 Android 13(API 33)开放给应用层的可编程像素着色能力。以前想在普通 View 上做流光扫过文本、胶片颗粒滚动、玻璃折射扭曲这类实时效果,要么上 OpenGL 自己拼整条渲染管线,要么靠 RenderScript 顶着停止维护的压力硬撑;现在用一个 RuntimeShader 实例加一段 AGSL 源码,就能借 RenderEffect 渲染链路把它挂到任意 Paint 上,GPU 会在离屏缓冲上逐像素重算再合成回目标画面。它特别适合画面主体仍然是 View 或 Compose、只有局部效果需要定制的场景,也适合做主题引擎统一给图标、背景、卡片加一层后处理。下面把原理、两套接入写法、uniform 调参和高频坑依次讲清楚,内容默认基于 API 33 及以上,读完可以直接落到项目里。

2. RuntimeShader 的原理:RenderEffect 与 AGSL 各管什么

2.1 RuntimeShader 在渲染管线里的位置

RuntimeShader 是Shader的子类,这一点容易让人误解,以为它和BitmapShaderLinearGradient一样只是画刷的一种纹理来源。实际上 RuntimeShader 没有内置的像素生成逻辑,它的唯一任务是把 AGSL 源码编译成 GPU 上的片段着色程序。真正让它生效的是RenderEffectRenderEffect.createRuntimeShaderEffect(runtimeShader, inputName)把着色器包装成一种渲染效果,再通过Paint.setRenderEffect挂到绘制链路上。系统在执行 Canvas 绘制时,先把绘制内容放进一个离屏缓冲区,然后运行 AGSL 里的main函数对缓冲区每个像素重新取值,最后把结果合成到屏幕上。

一个常见的错误认知是把 RuntimeShader 当成Paint.setShader来用,直接设置给 paint 再画矩形。Paint.setShader只决定图形内部的填充内容,不会触发逐像素的后处理;而Paint.setRenderEffect是在 GPU 绘制完之后再做一层像素级加工。把这两者分清之后,你对 RuntimeShader 的定位就准确了:画上去的东西仍然是原样的,只是渲染链路的最后一公里被替换成了着色器输出。

需要注意版本边界。RenderEffect 在 API 31 引入,RuntimeShader 在 API 33 才开放,minSdk 低于 33 时不能直接写死这个类,需要做版本判断和降级路径。常见做法是抽一个效果接口,低版本回退到 ColorFilter 或直接关闭特效,我一般会在项目里把它包在一个EffectLayer里,由调用方决定传入 RuntimeShader 还是 null:

val effect: RenderEffect? = if (Build.VERSION.SDK_INT >= 33) { RenderEffect.createRuntimeShaderEffect(runtimeShader, null) } else { null } paint.renderEffect = effect if (Build.VERSION.SDK_INT < 33) { // 低版本关闭效果,保证功能仍然可用 }

这段代码里的null是输入着色器名,表示当前效果不需要读取原画面,直接从零生成颜色。后面 2.2 会讲到输入纹理的用法。

2.2 AGSL 的入口函数、坐标约定与输入着色器

AGSL 全称 Android Graphics Shading Language,语法兼容 GLSL ES 的一个子集,但入口函数固定。每个像素都会执行一次main,参数fragCoord是当前像素在缓冲区里的坐标,起点在左上角,横向右、纵向下的方向增大,单位是像素而不是归一化的 0~1。这一点和很多 Web 端的 shader 工具完全不同,写第一段 AGSL 时最容易在这里栽跟头。

一个最基础的 RuntimeShader 源码长这样:

uniform float2 uSize; uniform float uTime; half4 main(float2 fragCoord) { float2 uv = fragCoord / uSize; float3 col = 0.5 + 0.5 * cos(uTime * 0.6 + uv.xyx + vec3(0.0, 2.0, 4.0)); return half4(col, 1.0); }

注意half4是输出类型,表示 RGBA 四通道,其中half是半精度浮点。GPU 上处理半精度比 float 快,颜色计算场景精度足够,这也是官方示例里大量使用half4而少用vec4的原因。uv.xyx是在组合向量,展开成(uv.x, uv.y, uv.x),最终得到三个浮点分量分别作为 RGB 的波动相位。uSize 和 uTime 都是外部注入的 uniform,前者告诉着色器缓冲区尺寸,后者驱动动画。

如果要做"读取原画面再处理",需要动用输入着色器。在 AGSL 里声明一个uniform shader uInput;,然后在main里对输入做eval

uniform shader uInput; half4 main(float2 fragCoord) { half4 src = uInput.eval(fragCoord); return half4(1.0 - src.rgb, src.a); }

这段代码实现了最简单的反色效果。uInput 这个名称不是随便起的,它必须和RenderEffect.createRuntimeShaderEffect(runtimeShader, "uInput")的第二个参数严格对应,系统才会把当前 Paint 输入给 Canvas 的内容绑定到这个 uniform 变量上。

2.3 uniform 类型与命名约束

AGSL 里的 uniform 变量命名有约定。我建议统一加上u前缀,这样setFloatUniform在匹配变量名时不容易和系统内部自动注入的坐标、尺寸等变量撞名。你在setFloatUniform里填的名字必须和 AGSL 源码中的变量名一字不差,否则运行时会抛出IllegalArgumentException。类型也很严格,float 用对应浮点重载,float2 要按顺序传两个数,float4 或颜色可以用专门的颜色注入方法。

常见类型映射关系:

AGSL 类型RuntimeShader 方法示例用途
floatsetFloatUniform(String, float)时间、强度、进度
float2setFloatUniform(String, float, float)尺寸、偏移量
float3/float4setColorUniform(String, Color)注入颜色值
mat3/mat4setFloatUniform(String, FloatArray)矩阵变换
shadercreateRuntimeShaderEffect(shader, "名字")输入纹理绑定

这个表基本覆盖了日常写效果会用到的输入类型。setFloatUniform也有一些接收FloatArray的重载,传数组时保证数组长度和 AGSL 声明的类型一致,顺序不能乱。第 3 章和第 4 章的代码都会围绕这些方法展开。

3. 在自定义 View 与 Compose 中接入 RuntimeShader 的落地代码

3.1 在自定义 View 里挂 RuntimeShader 的最小代码

先实现一个动态胶片颗粒效果,也就是每个像素叠加一个随时间滚动的随机值。在自定义 View 里分创建着色器、挂载效果、更新 uniform、绘制四步:

class GrainView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : View(context, attrs) { private val runtimeShader = RuntimeShader( """ uniform float2 uSize; uniform float uTime; half4 main(float2 fragCoord) { float2 uv = fragCoord / uSize; float grain = fract(sin(dot(uv + uTime, vec2(12.9898, 78.233))) * 43758.5453); return half4(vec3(grain), 1.0); } """.trimIndent() ) private val paint = Paint().apply { renderEffect = RenderEffect.createRuntimeShaderEffect(runtimeShader, null) } override fun onAttachedToWindow() { super.onAttachedToWindow() setLayerType(LAYER_TYPE_HARDWARE, null) startAnimator() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() animator?.cancel() } private var animator: ValueAnimator? = null private fun startAnimator() { animator = ValueAnimator.ofFloat(0f, 100f).apply { duration = 4000 repeatCount = ValueAnimator.INFINITE addUpdateListener { anim -> runtimeShader.setFloatUniform("uTime", anim.animatedValue as Float) invalidate() } start() } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) runtimeShader.setFloatUniform("uSize", width.toFloat(), height.toFloat()) canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) } }

这里最容易被忽略的是setLayerType(LAYER_TYPE_HARDWARE, null)。RuntimeShader 要求硬件加速环境,虽然 targetSdk 较高时 View 默认会走硬件渲染,但显式指定后可以排除掉模拟器和部分定制 ROM 的兼容性问题。如果没有硬件加速,drawRect 不会报编译错误,但屏幕上什么都看不到,这是排查时要看的第一个位置。

参数更新节奏也有讲究:uTime 在动画回调里更新,uSize 在 onDraw 里更新。为什么不在同一个地方更新?因为 onDraw 每次都会执行,转屏或尺寸变化时,uSize 必须跟着最新坐标系走;uTime 是连续增长的,放在动画回调里更新可以避免 invalidate 频率和动画频率不一致导致的跳变。

3.2 在 Compose 中不经过 AndroidView 直接绘制

Compose 的 Canvas 底层仍然走 Android 原生绘制,所以 RuntimeShader 可以绕开 Interop 直接嵌入到组合阶段。常见做法是把 RuntimeShader 和 RenderEffect 放进remember,再用drawIntoCanvas拿到 nativeCanvas 配置 Paint。不要在 Modifier.drawBehind 里直接操作 RenderEffect,那个阶段的画笔不在开发者手里,可控性差。正确的入口是drawContext.canvas的原生层:

@Composable fun RuntimeShaderLayer( modifier: Modifier = Modifier, agslSource: String, ) { val shader = remember { RuntimeShader(agslSource) } val renderEffect = remember { RenderEffect.createRuntimeShaderEffect(shader, null) } Canvas(modifier) { shader.setFloatUniform("uSize", size.width, size.height) drawIntoCanvas { canvas -> canvas.nativeCanvas.drawRect( 0f, 0f, size.width, size.height, android.graphics.Paint().apply { this.renderEffect = renderEffect } ) } } }

如果你想驱动动画,不要用remember { mutableStateOf }每秒刷新 60 次,最好接withFrameNanos,让时间参数和渲染帧同步。Shader 和 RenderEffect 都必须在 remember 里缓存,如果每次重组都 new 一个 RuntimeShader,GPU 端会反复重建编译资源,掉帧非常明显。

3.3 用输入纹理做后处理

最常用的场景其实是"先有内容再加效果",而不是从头生成效果。比如给编辑页的图片加马赛克或故障效果,输入不是纯色矩形,而是 Bitmap 或当前 View 的内容。这种场景要记得给 RuntimeShader 声明输入 uniform:

uniform shader uInput; uniform float2 uSize; half4 main(float2 fragCoord) { float2 uv = fragCoord / uSize; float2 mosaic = floor(uv * 30.0) / 30.0; return uInput.eval(mosaic * uSize); }

Kotlin 端创建 effect 时要加上输入名:

val effect = RenderEffect.createRuntimeShaderEffect(runtimeShader, "uInput")

创建后这个 effect 配合同一个 Paint 绘制到 Canvas 上,输入就是 Canvas 上画的内容。如果输入来自一张 Bitmap,可以先把 BitmapShader 包一层,再用RuntimeShader.setInputShader("uInput", bitmapShader)注入,这样就不需要先把 Bitmap 画到画布上再做第二步处理,省掉一个中间缓冲。

接入方式可以参考这个表:

场景推荐接入原因
单个 View 内容加效果onDraw + Paint.setRenderEffect最小改动,状态都在 View 内
Compose 背景或内容特效Canvas + drawIntoCanvas不引入 AndroidView,跟随重组
图片滤镜、相机帧setInputShader 绑 BitmapShader输入可控,省一次离屏拷贝

第 3.1 和 3.2 两种写法是后续调参的基础,第 4 章涉及的 uniform 更新在这两个环境下都适用。

4. RuntimeShader 调参:uniform 命名、动效与三个高频坑

4.1 推荐先调稳这四个 uniform

写 RuntimeShader 效果时,我一般不会一上来就抠颜色公式,而是先把四个最容易引起整体偏差的参数调稳,它们是 uSize、uTime、uStrength、uInput。下面这张表列出最常见的初始值范围和作用:

uniform类型推荐初始值作用
uSizefloat2View 实时宽高把像素坐标转成 0~1 的 uv
uTimefloat从 0 开始连续递增所有随时间变化的波动
uStrengthfloat0.0~1.0控制效果混合强度
uInputshader当前画面或 BitmapShader原始画面采样

uSize 必须按实际尺寸更新,否则转屏后 uv 计算会整体错位。uTime 不需要重置,连续增长最适合做周期波动。uStrength 通常配合 mix 函数使用,mix 是线性插值,源画面和效果画面按比例混合,强度为 0 时完全显示原图,为 1 时完全显示处理结果:

uniform shader uInput; uniform float uStrength; half4 main(float2 fragCoord) { half4 src = uInput.eval(fragCoord); half4 processed = half4(1.0 - src.rgb, src.a); return mix(src, processed, uStrength); }

这段 AGSL 里,uStrength 设成 0.3 就是 30% 的反色强度。设置方式用一句话就是runtimeShader.setFloatUniform("uStrength", 0.3f)

4.2 动画驱动时 uniform 更新顺序

RuntimeShader 的 uniform 不是一改就立刻生效的,它要等下一帧渲染时才会被 GPU 读取。所以动画代码里不要出现这种情况:先setFloatUniform("uTime", newTime),紧接着去读画面像素,你读到的仍然是上一帧的结果。正确顺序是先更新所有 uniform,再触发 invalidate,最后在下一帧 onDraw 里看到效果。

模拟器上做验证经常遇到另一个现象:在 API 33 以下模拟器上,RuntimeShader 类根本不存在,代码在类加载阶段就抛异常。需要在初始化处加版本拦截,让低版本设备完全避开这个分支:

val canUseRuntimeShader = Build.VERSION.SDK_INT >= 33 if (canUseRuntimeShader) { val shader = RuntimeShader(source) }

这里没有做反射或动态代理,因为官方只保证 API 33 以上稳定,与其维护兼容层不如在低版本关闭效果,节省维护成本。

4.3 三个高频踩坑点

第一个坑是软渲染静默失效。在模拟器或者关闭硬件加速的 View 上,RenderEffect 通常不报错,但效果不出现。排查顺序:先看 manifest 里 hardwareAccelerated 属性,再看 View 的 isHardwareAccelerated 返回值,最后确认 setLayerType 设的是 LAYER_TYPE_HARDWARE 而不是 LAYER_TYPE_SOFTWARE。

第二个坑是 Canvas 的裁剪和位移影响坐标。如果外层对 Canvas 做了 translate,或者 View 有非零的 padding,fragCoord的起点就可能和 uSize 对不上,效果出现偏移。处理办法是在 onDraw 里通过canvas.getClipBounds()获取真实渲染区域,再把这个区域宽高传成 uSize,而不是直接用 View 的宽高。

第三个坑是半精度精度问题。GPU 的半精度浮点数对很大的数值不敏感,uTime 跑到几千之后,sin 和 cos 的结果可能开始出现抖动。所以 uTime 每累计到一定范围后需要做取模,比如uTime % 100.0,既保留动画连续性又避免精度劣化。很多看起来莫名其妙的闪烁,追到最后都是这个原因。

注意:RuntimeShader 的编译发生在首次绘制那一帧,屏幕会多出一帧的停顿,正规做法是把必要的着色器在应用启动后台预编译,或者把当前页面提前画一帧,让编译开销不在用户交互路径上。

5. 用网格法快速验证 RuntimeShader 坐标系没有偏移

网格法最大优势是不需要断点、不需要日志,肉眼观察亮线间距就能判断坐标是否准确。把下面这段 AGSL 挂到任何 View 上,正常情况下画面中会出现 12 条等距的竖线,从左向右依次排到 11/12 的位置:

uniform float2 uSize; half4 main(float2 fragCoord) { float2 uv = fragCoord / uSize; float line = step(0.97, abs(sin(uv.x * 12.0))); return half4(vec3(line), 1.0); }

uv.x * 12.0让 sin 在水平方向完成 12 个周期,step(0.97, ...)只保留每个周期末段接近 1 的值,于是亮线落在每个周期的末尾。验证时用一只手遮住画面的某一个局部区域,另一只手看线的间距和数量有没有发生变化。

观察结果结论处理方向
12 条等距竖线坐标与缓冲尺寸匹配无需处理
竖线数量多于 12uSize 大于实际缓冲区改成画布实际宽高
竖线数量少于 12uSize 小于实际缓冲区检查窗口 insets 和旋转
竖线倾斜或截断Canvas 被 translate 或 clip去掉外层位移或改离屏画布

如果只是靠目测判断颜色对不对,很容易被屏幕亮度干扰;网格法的判断标准是几何位置,这是着色器坐标系正确性的第一道防线。要想更精确,可以在 View 的 onDraw 里配合 PixelCopy 把渲染结果回读到 Bitmap,然后检查某个像素的 RGB 是否落在预期区间,首次回读要预留一两帧等待 GPU 完成写回。

当你打算把同一个 AGSL 源码复用到多个项目时,保存一份只做网格验证的极简 shader,排错时先切换成它,就能快速区分坐标问题还是颜色计算问题。这个习惯在处理多设备适配时尤其省时间,AGSL 在部分 GPU 驱动上的坐标行为不完全一致,网格线会第一时间暴露差异。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询