多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

vuh库实战:Android Studio 3.2.1上跑通Vulkan三角形

vuh库实战:Android Studio 3.2.1上跑通Vulkan三角形 如果你也是那种不满足于OpenGL ES、想在Android上直接与GPU驱动对话的人vuh库应该早就躺在你的搜索记录里了。Android Studio 3.2.1是我第一次把vuh真正跑通的IDE版本这个组合听起来有点旧但直到今天它依然是排查Vulkan封装代码最清晰的一套环境。当时网上能搜到的“vuh库 例子”大多只贴片段要么缺Shader编译步骤要么直接跳过SurfaceView的绑定照着抄很难跑出一个能动的三角形。这篇就把我从零搭起来、能编译能跑的例子工程完整过程写清楚也会把实际运行中遇到的崩溃和黑屏一并交代。1. 为什么在Android Studio 3.2.1里选vuh官方Demo给我的第一印象1.1 原生Vulkan API的样板代码到底多折磨人我最早尝试的是直接调Vulkan的Java绑定也就是在Android上最“原始”的方式不借助任何中间层从vkCreateInstance一路写到vkQueuePresentKHR。这一条链路放在桌面上并不算恐怖但放到Android的SurfaceView体系里工作量立刻膨胀。你要自己管理实例、调试回调、物理设备枚举、队列族索引匹配、Surface连接、交换链格式、渲染通道、帧缓冲、管线布局、顶点缓冲、命令池、命令缓冲、信号量、栅栏……每一步都要跟NDK层的Vulkan结构体对齐结构体字段错了不报Java错而是给你一段不清不楚的Validation Layer日志。生活化一点说原生Vulkan像是让你从一颗CPU、一块主板开始组装电脑而OpenGL ES像是买一台品牌整机。大多数时候我们确实不需要整机组装但当你需要处理自定义渲染管线和GPU通用计算时整机方案又会显得束手缚脚。vuh恰好站在中间它把“拆零件”的过程收拢成几个类但仍在关键位置保留Vulkan原生的语义。用一台品牌整机的外观给你看清楚了内部电源线是怎么走的。1.2 vuh到底把哪几块收编了我拉下vuh源码之后发现它做的事情可以分成三层。第一层是设备管理主要是DeviceMonitor类它负责监听系统里有没有可用Vulkan设备、具备哪些扩展能力第二层是资源封装比如Buffer、Image、RenderPass、CommandBuffer、Framebuffer这些常见渲染资源都从原生API封装成了带生命周期的方法第三层是快速渲染辅助也就是vuh里带的那几个Boost类它们把交换链、三角形渲染流程再压缩一层适合做验证Demo。所以vuh并不是一个改头换面的渲染引擎也不帮你写光照算法它更像是一个Vulkan的“更好用的钥匙串”。钥匙还是那把钥匙但你不用每次都在口袋里摸半天。对想在Android上研究Vulkan、做自制小引擎的人来说这个定位非常舒服既没有被引擎抽象吞掉太多细节又不会被原生API的冗长吓退。1.3 这篇文章要复现的Demo是什么本文的所有代码最终只做一件事在Android Studio 3.2.1工程里用vuh初始化Vulkan设备用渲染通道绘制一个纯色三角形再通过SurfaceView送显到屏幕上。三角形虽然简单但它背后涉及实例创建、设备选择、Surface格式协商、管线状态配置、命令缓冲录制提交这些正是任何更复杂Vulkan程序的地基。整个工程不是官方Demo那种大而全而是刻意保持最小可运行方便你在此基础上改动。2. 在Android Studio 3.2.1上搭建vuh工程环境2.1 版本组合与Gradle配置Android Studio 3.2.1对应的Gradle版本通常是4.6左右Android Gradle Plugin是3.2.1。这个组合看上去老但和vuh这类对Android依赖并不深的库配合得很稳。我建议在这个环境里就把minSdkVersion定在24不要更低因为Android 7.0API 24才把Vulkan作为系统级图形接口开放给应用。官方文档里写的是“系统可能支持Vulkan”这和“一定支持”有很大区别后面运行时检测还会再提。核心的build.gradle模块配置大致如下android { compileSdkVersion 28 defaultConfig { applicationId com.example.vuhsample minSdkVersion 24 targetSdkVersion 28 versionCode 1 versionName 1.0 ndk { abiFilters armeabi-v7a, arm64-v8a, x86, x86_64 } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } }abiFilters这个字段容易被忽略但Vulkan的设备覆盖和CPU架构直接相关很多老模拟器是x86镜像不把x86加进去的话库在模拟器上根本加载不了。顺带提醒工程里不需要额外引入NDK编译步骤因为Vulkan运行时库libvulkan.so属于Android系统的一部分应用只需要在运行时声明依赖即可。2.2 把vuh集成到工程里的两种方式vuh集成方式很简单无非两种一种是引用预编译包一种是直接把源码模块放进工程。我第一次图省事选了预编译包结果在Android Studio 3.2.1里经常遇到依赖同步失败的问题后来干脆把vuh源码目录作为library模块引入误打误撞发现这是最省心的路线。具体做法是在项目根目录新建一个vuh目录把仓库代码放进去然后修改settings.gradleinclude :app, :vuh然后到app模块的build.gradle里加上dependencies { implementation project(:vuh) }为什么推荐源码模块而不是预编译包第一你可以直接在Android Studio里点进vuh的源码看Vulkan调用到底是怎么封装的这对学习很有帮助。第二预编译包在AS 3.2.1上很容易因为依赖传递问题报类找不到而源码模块没有这个问题。第三你可以在vuh内部打断点观察它到底选了哪块物理设备、哪个队列这种跟踪能力在排查黑屏问题时几乎是必需品。2.3 设备支持检测不要相信所有Android 7.0以上的手机minSdk设为24只是通过了Google Play的过滤门槛不代表用户手里的每台Android 7.0设备都有可用Vulkan驱动。实际做法是在应用启动早期做一次运行时检测检查系统是否真的暴露了Vulkan实例和Android Surface扩展。vuh里提供了对应的检查入口你可以用类似下面的代码做判断if (vuh.hasVulkanInstance(this)) { Log.d(VulkanCheck, device has Vulkan instance) } else { Log.e(VulkanCheck, device does NOT support Vulkan) }这一步千万别省。我做兼容性测试时遇到过一台系统版本是Android 8.0但GPU驱动被厂商精简过的机器直接创建Instance会返回VK_ERROR_INCOMPATIBLE_DRIVER。有了这个前置判断至少可以让App不被闪退退回软件渲染或直接提示用户。3. 创建第一个vuh渲染窗口初始化链路拆解3.1 从Instance到PhysicalDevice再到DeviceVulkan初始化链路是分层的Instance代表一次应用级别的Vulkan上下文PhysicalDevice代表物理GPUDevice代表我们实际使用的逻辑设备。很多刚接触的人不理解为什么需要三层类比一下Instance是整个厨房PhysicalDevice是灶台本身Device是你当前正在用的那口锅。你可以在同一个厨房里换不同的灶台也可以在同一灶台上换锅但它们不能随便跨层匹配。vuh把这一过程压得很薄val instance vuh.Instance(this) val device instance.getDevice(0)getDevice(0)内部是遍历所有物理设备挑出一个满足图形队列和表面支持的设备。如果你想选指定的GPU也就是设备上有独立GPU和集成GPU的情况可以通过instance.getPhysicalDevices()拿到列表自己遍历手动指定某个设备索引。这里有一个非常容易踩的坑getDevice(0)只保证挑到一块Vulkan物理设备却不保证它一定支持Present队列。绝大多数手机SoC只有一块GPU所以这种问题在手机上并不常见但如果你遇到SurfaceView创建成功却一直黑屏、应用不崩溃也没有任何报错第一反应就应该是Present队列支持出了问题。3.2 SurfaceView与Vulkan窗口的连接Vulkan要在Android屏幕上画东西必须通过SurfaceView或TextureView提供的原生窗口不能直接绑定到普通View上。原因是Vulkan需要获取NativeWindow句柄让交换链和系统窗口管理器完成缓冲区送显。在Android Studio 3.2.1时代最简单可靠的做法是在布局里放一个全屏的SurfaceView然后在SurfaceHolder.Callback.surfaceCreated回调里初始化vuh的渲染器。override fun surfaceCreated(holder: SurfaceHolder) { renderer MyVuhRenderer( context this, surface holder.surface ) renderer.start() }注意holder.surface和holder本身不一致前者是android.view.Surface后者只是管理器vuh要的是Surface对象。我在早期写的例子里传错了对象编译没问题运行到创建交换链时直接返回VK_ERROR_SURFACE_LOST_KHR查了好一会儿才想起来这里掉链了。还有一个细节SurfaceFormat要在渲染器初始化时与交换链协商。vuh不会替你做“窗口系统最喜欢的格式”探测也不会猜测你要RGB还是RGBA。我一般固定用VK_FORMAT_R8G8B8A8_UNORM但更规范的做法是从Surface的兼容格式列表里挑第一个避免在个别设备上出现格式不支持导致VK_ERROR_INITIALIZATION_FAILED。3.3 队列族索引到底意味着什么Vulkan的每条命令最后都要提交到某个队列族图形队列负责执行绘制命令Present队列负责把渲染完成的缓冲送到屏幕。大多数移动GPU的图形队列和Present队列是同一个索引vuh也因此可以在这个前提下活得非常轻松但从架构角度看两者是否一致需要显式确认。我用一个简单方法打印vuh选择的队列族索引val queueFamilyIndex device.graphicsQueueFamily() Log.d(vuh, graphics queue family $queueFamilyIndex)如果旗舰设备上出现两个不同索引程序仍然能跑只是你需要自己在vuh外面额外分配一个Present队列否则长时间运行时会出现偶发掉帧。实测下来绝大多数Android手机两颗队列是重合的这个打印主要是帮你确认而不是解决问题但等真遇到不重合的设备这条日志能省下至少半天排查时间。4. 三角形渲染管线Shader、RenderPass与CommandBuffer4.1 Shader怎么编译进VulkanVulkan不认识GLSL源码它只接受SPIR-V中间字节码。这个设计是为了各驱动后端统一处理避免每家GPU厂商都写一套GLSL编译前端。Android Studio 3.2.1里也不自带GLSL编译器你需要额外装一个glslangValidator工具或者用现成的构建脚本把.vert和.frag编译成.spv文件。我用的顶点着色器长这样#version 450 layout(location 0) in vec2 inPosition; layout(location 1) in vec3 inColor; layout(location 0) out vec3 fragColor; void main() { gl_Position vec4(inPosition, 0.0, 1.0); fragColor inColor; }片元着色器#version 450 layout(location 0) in vec3 fragColor; layout(location 0) out vec4 outColor; void main() { outColor vec4(fragColor, 1.0); }编译命令就是一行glslangValidator -V triangle.vert -o triangle.vert.spv glslangValidator -V triangle.frag -o triangle.frag.spv生成出来的.spv文件放到assets目录或res/raw。我建议放assets因为你可以用AssetManager直接读字节数组不经过Android资源系统少一层类型转换。shader在Vulkan里被封装成ShaderModule编译成SPIR-V后就不可逆改了所以shader源码最好单独存一份方便后续调参。4.2 RenderPass和Pipeline的组装逻辑渲染通道描述的是“这次绘制要经历哪些步骤、每一步对颜色缓冲做什么”。最简单的场景只有一步把后台缓冲清成指定颜色然后绘制三角形。vuh提供了一个RenderPass封装你配置时重点关注两个结构体颜色附件的loadOp和storeOp。loadOp填VK_ATTACHMENT_LOAD_OP_CLEAR意思是一开始把整张画布擦干净storeOp填VK_ATTACHMENT_STORE_OP_STORE表示渲染结束后要把结果保留下来等Present送显。这两个字段填错的反差很有意思如果忘了写CLEAR画面会显示出上一帧残留的“鬼影”不是纯黑也不是纯白如果忘了STORE屏幕上什么都看不见因为GPU把绘制结果直接丢掉了。管线状态则是一堆不可变参数的组合包括顶点着色器、片元着色器、顶点输入布局、光栅化开关、混合模式、视口大小。vuh把这些状态打包在GraphicsPipeline类里你需要显式提供视口和裁剪矩形否则默认都是空值Vulkan规范里允许不开启裁剪但视口宽度为零会导致画出来的东西被全部裁剪掉。4.3 命令缓冲的录制与提交一切就绪后我们还要把渲染指令录制成命令缓冲。Vulkan的命令缓冲有点像“拍戏剧本”它不是一句句发给GPU的实时台词而是先把所有台词写清楚再整场交给导演。这样GPU可以提前做调度优化。vuh里一条最基本命令缓冲流程是这样val commandBuffer device.commandPool().commandBuffers(1)[0] commandBuffer.begin() commandBuffer.beginRenderPass(renderPass, framebuffer, clearColor) commandBuffer.bindGraphicsPipeline(pipeline) commandBuffer.setViewport(width, height) commandBuffer.bindVertexBuffer(vertexBuffer) commandBuffer.draw(3, 1, 0, 0) commandBuffer.endRenderPass() commandBuffer.end() device.graphicsQueue().submit(listOf(commandBuffer))这段代码看起来不多但每一行删掉都会出事。我有一回忘了bindVertexBufferVulkan不会崩而是画出一个三角形顶点全为默认值坐标0,0的退化三角形表现为屏幕上只有一个像素点在闪烁。另一回忘了setViewport画面会死死卡在左上角怎么改shader都没用。5. 在AS 3.2.1上实测vuh时踩过的坑与完整排查链路5.1 黑屏问题从Surface到队列再回到同步的完整排查黑屏是vuh入门阶段最普遍的现象症状是应用正常启动、不Crash、没有任何异常日志但SurfaceView区域始终全黑。我把它拆成一条逐步排查链路供你复现排查思路。第一步先确认surfaceCreated回调有没有触发。让我记忆犹新的一次经历是布局里确实放了SurfaceView但它的visibility被设为GONE回调没执行渲染线程根本没启动。检查方式是打日志确认surfaceCreated和surfaceChanged都执行了且尺寸不是0x0。第二步确认vuh挑选的物理设备支持Present队列。你可以在选择设备后打印设备属性看queueFamilyProperties里有没有VK_QUEUE_GRAPHICS_BIT再检查对应队列是否支持VK_KHR_surface扩展。很多黑屏其实是选中了一块不支持显示的计算GPU。第三步核对Surface格式和交换链格式。在部分设备上窗口系统的原生格式是RGB565而你强制要求R8G8B8A8结果交换链创建失败且API日志不会直接打印错误。vuh的做法是尽量兼容但你自己传Surface格式时最好从系统查询结果里挑。第四步才轮到底层的同步问题。没有等待上一帧就提交当前帧会在连续快速渲染时出现闪烁或短暂黑屏。Vulkan的交换链通常允许2到3个帧在飞行中如果你不靠信号量约束它们CPU推送过快GPU还没处理完上一帧队列就被新帧塞爆。5.2 低版本Android设备直接崩掉的兼容性处理不少拿着Android 6.0、7.0早期版本手机的人会把minSdkVersion调低结果程序一跑就崩在加载libvulkan.so。原因很直接Vulkan不是Android系统早期版本的内置组件系统镜像里根本没有这个动态库而不是你的库写错了。最稳妥的处理方式是保留minSdk24然后在运行入口处做一次软检查if (Build.VERSION.SDK_INT 24) { // 继续vuh初始化 } else { // 提示用户当前设备不支持 }如果你真的需要支持Android 6.0就要走Vulkan的late-loading路线用dlopen(libvulkan.so)动态探测。但说实话为了vuh做这种兼容性价比不高因为低版本设备即使装了驱动Vulkan功能集也往往残缺跑起来照样会碰到各种无意义崩溃。5.3 验证层与日志定位技巧Android Studio 3.2.1里默认不会开启Vulkan验证层意味着你写的API调用即使顺序错误也只是黑屏或者卡住。Vulkan官方提供VK_LAYER_KHRONOS_validationvuh支持在创建Instance时传入层名称。打开后Logcat里会冒出大量Vulkan校验信息过滤关键字Vulkan或者vuh就能看到是谁触发了错误。如果日志刷得太多可以在设备上关闭验证层再跑但排查阶段还是建议开着。我遇到过一件事渲染循环写好了画面也正常但Validation Layer一直报关于内存对象生命周期的问题定位到是我没有显式释放某个临时Framebuffer。验证层在这里的价值不只是挑错还能帮你确认自己的资源管理习惯是不是合格。6. 从三角形到更多玩法vuh在计算与渲染扩展上的思路6.1 用vuh跑GPU计算任务vuh不只服务于渲染还可以承担GPU通用计算。思路很简单把三角形Demo里的GraphicsPipeline换成ComputePipeline顶点缓冲换成只读输入缓冲再加一个输出缓冲最后用vkCmdDispatch代替vkCmdDraw。整个过程几乎不用改vuh的设备初始化代码只需要调整命令缓冲录制逻辑。比如我想验证一个简单的向量加法分配两个BulkBuffer分别放输入和输出然后用一条compute管线执行2048个线程。vuh对BulkBuffer的封装比较友好你可以类似这样创建val inputBuffer device.bulkBuffer(floatArrayOf(1.0f, 2.0f, 3.0f)) val outputBuffer device.bulkBuffer(3 * 4)这里的第二行创建了一个三倍点如果忘了乘以Float.SIZE_BYTESGPU读到的字节数不对结果全是0。这类接缝处的老手病就是在封装库的“半自动”模式下最容易发生的。6.2 多帧提交不卡顿的同步策略单缓冲渲染在低复杂度三角形上感觉不到问题一旦场景里增加纹理采样、半透明混合、多线程录制帧间同步就必须认真对待。vuh里最简单有效的做法是始终在提交命令缓冲前等待上一帧的栅栏信号。device.waitIdle() // 最粗暴但有效的同步waitIdle()会阻塞直到GPU完成当前队列里全部工作画面极其稳定代价是CPU利用率降低不适合复杂场景。更优雅的做法是每帧配一个Semaphore提交时挂上等待下一帧开始前等这个信号量。我建议初学阶段先用waitIdle()保稳定等到确认渲染逻辑没问题后再换信号量不要一开始就同时优化两件事。6.3 换到新版Android Studio时需要调整什么如果你不是非要用Android Studio 3.2.1而是用新版本IDE跑vuh改动点集中在三处Gradle版本、AGP版本、以及依赖仓库地址。vuh本身不挑IDE它只是纯Java/Kotlin库重点在于Android Gradle Plugin的API变化会影响Manifest合并和依赖解析。一个常见现象是新版本AS把provided改成了compileOnly一些老文档里的配置会直接同步报错。你在迁移时只要把vuh源码模块里的依赖声明改成compileOnly再用新版AGP重新同步大概率能一次通过。真到了编译通过但运行报VK_ERROR_EXTENSION_NOT_PRESENT多半是设备驱动没支持某个扩展而不是IDE层面的问题换设备验证即可。最后说点个人体会。vuh这套库真正的价值不是帮你省掉所有Vulkan细节而是让你在Android上第一次渲染三角形时不会被400行初始化代码淹没。先把三角形跑起来再一步步把vuh的封装替换成自己手写调用这是我试过最快的学习路径。另外一个小技巧在shader里故意写一个错误比如把坐标写成NaN然后观察Validation Layer的报错位置你会比看十篇教程都更快理解Vulkan的验证机制。Vulkan不是一晚上能学完的东西先让第一个三角形动起来你就已经赢了一半。
返回列表