ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android 13 RuntimeShader实战:AGSL与RenderEffect实现GPU着色

Android 13 RuntimeShader实战:AGSL与RenderEffect实现GPU着色 简介面向Android图形开发者的RuntimeShader应用示例包围绕运行时着色器在View二次渲染、Canvas渲染、Canvas二次渲染三种典型场景演示逐像素渲染的完整实现思路。逐像素渲染会在每个像素上执行着色操作相比逐顶点方式能更精细地呈现阴影、光照与反射效果适合具备基础图形学知识、希望掌握AGSL和RenderEffect用法的开发者。压缩包共1541个文件涵盖agsl着色器源码、Java业务类、XML布局资源、Gradle构建配置及编译产物目录结构清晰便于按模块索引可直接还原为可运行工程包体约60.85MB。已有115人学习。除三个可交互Demo外还提供关键渲染链路的注释说明可结合配套博客对照分析逐像素操作如何作用在视图与画布上便于快速迁移到自定义特效或图像处理项目中。1. RuntimeShader 是什么为什么值得现在学RuntimeShader 是 Android 13API 33开放给应用层的可编程像素着色能力。以前想在普通 View 上做流光扫过文本、胶片颗粒滚动、玻璃折射扭曲这类实时效果要么上 OpenGL 自己拼整条渲染管线要么靠 RenderScript 顶着停止维护的压力硬撑现在用一个 RuntimeShader 实例加一段 AGSL 源码就能借 RenderEffect 渲染链路把它挂到任意 Paint 上GPU 会在离屏缓冲上逐像素重算再合成回目标画面。它特别适合画面主体仍然是 View 或 Compose、只有局部效果需要定制的场景也适合做主题引擎统一给图标、背景、卡片加一层后处理。下面把原理、两套接入写法、uniform 调参和高频坑依次讲清楚内容默认基于 API 33 及以上读完可以直接落到项目里。2. RuntimeShader 的原理RenderEffect 与 AGSL 各管什么2.1 RuntimeShader 在渲染管线里的位置RuntimeShader 是Shader的子类这一点容易让人误解以为它和BitmapShader、LinearGradient一样只是画刷的一种纹理来源。实际上 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 还是 nullval 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是当前像素在缓冲区里的坐标起点在左上角横向右、纵向下的方向增大单位是像素而不是归一化的 01。这一点和很多 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里对输入做evaluniform 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 一个 RuntimeShaderGPU 端会反复重建编译资源掉帧非常明显。3.3 用输入纹理做后处理最常用的场景其实是先有内容再加效果而不是从头生成效果。比如给编辑页的图片加马赛克或故障效果输入不是纯色矩形而是 Bitmap 或当前 View 的内容。这种场景要记得给 RuntimeShader 声明输入 uniformuniform 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 的 uvuTimefloat从 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 有非零的 paddingfragCoord的起点就可能和 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 驱动上的坐标行为不完全一致网格线会第一时间暴露差异。本文还有配套的精品资源点击获取
返回列表