1. 项目缘起与整体设计思路AR这两个字母在移动端开发圈子里被聊了太多年多数时候大家默认的方案是ARKit、ARCore或者某个封装好的引擎真正从零用OpenGL把一套AR渲染管线搭出来的人反而不多。我这次做的项目标题叫「AR纯OpenGL实现」核心目标很明确不依赖任何AR框架只靠OpenGL ES加摄像头纹理把虚拟物体稳定地叠加到真实画面里。听起来像是重复造轮子但做完之后我对整个渲染管线的理解上了一个台阶尤其是坐标系变换和纹理流这块踩过的坑比看十篇教程都值。这个项目能做什么简单说它能把摄像头采集到的画面作为背景纹理渲染到一个全屏四边形上然后在这个背景之上用OpenGL绘制一个三维模型并且让这个模型随着设备姿态变化产生正确的透视关系。解决了什么问题解决了“想理解AR底层原理但被高层API挡住视线”的问题。适合谁参考适合已经会写基本OpenGL三角形、想进一步搞懂AR渲染本质的开发者也适合那些用惯了引擎但想回头补底层知识的人。如果你连顶点着色器和片段着色器都没写过建议先补一下基础再来看这篇不然会有点吃力。整体设计上我选择了一条最朴素的路线摄像头数据用SurfaceTexture转成GL纹理作为背景层虚拟物体用普通的MVP矩阵变换绘制作为前景层两层在同一个GL上下文中按顺序渲染背景不写深度前景写深度。姿态数据这块项目里我用的是设备自身的旋转矢量传感器来模拟一个简单的头部追踪没有做SLAM也没有做平面检测。为什么这么选因为纯OpenGL实现AR的重点在于渲染管线的搭建而不是追踪算法本身。追踪算法可以后续替换但渲染管线一旦搭错后面怎么调都是歪的。注意这个项目里的“AR”严格来说是“基于设备姿态的虚拟叠加”不是完整意义上的环境理解型AR。如果你需要平面检测、光照估计、遮挡处理那得另外加模块本文不展开。方案选型上我对比过三种做法。第一种是用GLSurfaceView加Renderer这是最传统的Android OpenGL写法优点是结构清晰、生命周期好管理缺点是摄像头纹理和GL线程的同步需要自己处理。第二种是用TextureView加自定义GL环境灵活但代码量大。第三种是用NativeActivity加EGL性能最好但调试成本高。我最终选了第一种因为GLSurfaceView的onDrawFrame回调天然适合做每帧渲染而且SurfaceTexture的updateTexImage可以放在GL线程里执行避免了跨线程纹理更新的问题。这里有个关键决策点背景纹理和前景物体要不要分开两个GL程序我试过合并成一个用分支判断来切换渲染逻辑结果发现uniform变量管理很混乱而且背景不需要深度测试、前景需要状态切换频繁反而容易出错。后来拆成两个program背景一个、前景一个各自管理自己的attribute和uniform代码清晰很多。性能上多一次useProgram的开销可以忽略不计但可维护性提升明显。坐标系这块是重中之重。摄像头的画面是二维纹理虚拟物体是三维坐标两者要对齐必须把设备的姿态旋转矩阵正确地作用到虚拟物体的MVP矩阵上。我用的旋转矢量传感器返回的是一个四元数需要先转成旋转矩阵再和视图矩阵、投影矩阵相乘。投影矩阵我用的是透视投影根据屏幕宽高比计算视场角设成45度左右这个值接近多数手机摄像头的水平视场角叠加起来透视感比较自然。视图矩阵则是把相机放在原点朝向负Z方向然后应用设备旋转的逆矩阵。为什么是逆矩阵因为设备旋转代表的是设备自身的姿态变化而视图矩阵描述的是相机在世界中的位置和朝向两者是互逆的关系。2. 核心细节解析与实操要点2.1 摄像头纹理的创建与更新摄像头纹理这块核心是SurfaceTexture。它的作用是把摄像头输出的图像流转换成一个GL纹理每帧调用updateTexImage就能拿到最新的一帧。创建流程是这样的先创建一个OES纹理注意是GL_TEXTURE_EXTERNAL_OES而不是普通的GL_TEXTURE_2D因为摄像头输出的是外部纹理格式。然后new一个SurfaceTexture把OES纹理的ID传进去。接着用这个SurfaceTexture创建一个Surface把这个Surface设置给Camera对象作为预览输出。int[] textures new int[1]; GLES20.glGenTextures(1, textures, 0); GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0]); GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); SurfaceTexture surfaceTexture new SurfaceTexture(textures[0]); Surface surface new Surface(surfaceTexture); camera.setPreviewTexture(surface); camera.startPreview();这段代码看起来简单但有几个坑。第一个坑是纹理参数必须设置CLAMP_TO_EDGE不能是REPEAT否则边缘会出现奇怪的拉伸。第二个坑是updateTexImage必须在GL线程调用而且调用之后要调用getTransformMatrix拿到纹理变换矩阵。为什么需要这个矩阵因为摄像头传感器的方向和屏幕方向往往不一致SurfaceTexture内部会做一个旋转和裁剪这个变换矩阵就是用来在顶点着色器里修正纹理坐标的。float[] mtx new float[16]; surfaceTexture.getTransformMatrix(mtx); GLES20.glUniformMatrix4fv(uTextureMatrixLoc, 1, false, mtx, 0);顶点着色器里这样用attribute vec4 aPosition; attribute vec4 aTexCoord; uniform mat4 uTextureMatrix; varying vec2 vTexCoord; void main() { gl_Position aPosition; vTexCoord (uTextureMatrix * aTexCoord).xy; }实操心得如果你发现画面上下颠倒或者左右镜像先检查getTransformMatrix有没有正确传入再检查摄像头预览方向设置。很多教程让你手动翻转纹理坐标其实用变换矩阵更靠谱因为不同设备的传感器方向不一样硬编码翻转迟早出问题。2.2 背景全屏四边形的绘制背景层就是一个覆盖整个屏幕的四边形两个三角形组成顶点坐标直接用NDC空间也就是-1到1的范围。这样不需要任何矩阵变换顶点着色器直接输出位置就行。纹理坐标是0到1但要注意OpenGL的纹理坐标原点在左下角而摄像头图像的原点在左上角所以T轴需要翻转。不过因为我们用了getTransformMatrix这个翻转其实已经包含在矩阵里了所以纹理坐标可以按常规写。private static final float[] QUAD_VERTICES { -1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f };绘制背景的时候要关闭深度测试因为背景永远在最底层不需要和任何东西比较深度。同时要关闭混合因为背景是不透明的。片段着色器里直接采样OES纹理#extension GL_OES_EGL_image_external : require precision mediump float; varying vec2 vTexCoord; uniform samplerExternalOES uTexture; void main() { gl_FragColor texture2D(uTexture, vTexCoord); }注意第一行的扩展声明没有它编译不过。另外precision用mediump就够了摄像头画面不需要highp还能省点带宽。2.3 虚拟物体的MVP矩阵计算虚拟物体的渲染是重头戏。MVP矩阵由三部分组成模型矩阵、视图矩阵、投影矩阵。模型矩阵我暂时设为单位矩阵因为物体就放在原点附近。投影矩阵用透视投影根据屏幕宽高比和视场角计算float aspect (float) width / height; float fov 45.0f; float near 0.1f; float far 100.0f; float top near * (float) Math.tan(fov * Math.PI / 360.0); float bottom -top; float left bottom * aspect; float right top * aspect; Matrix.frustumM(projectionMatrix, 0, left, right, bottom, top, near, far);视图矩阵这块我先把相机放在原点朝向负Z方向然后应用设备旋转的逆矩阵。设备旋转从旋转矢量传感器拿到是一个四元数用SensorManager.getRotationMatrixFromVector转成旋转矩阵再转成视图矩阵SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values); Matrix.invertM(viewMatrix, 0, rotationMatrix, 0);这里有个细节旋转矩阵描述的是设备坐标系到世界坐标系的变换而视图矩阵需要的是世界坐标系到相机坐标系的变换所以取逆。但实际测试下来直接取逆有时候方向会反需要根据设备方向做一次坐标轴重映射。我试过用remapCoordinateSystem把设备坐标系转到屏幕坐标系效果更稳定。注意不同Android版本的传感器坐标系定义可能有细微差别建议在onResume里注册传感器时用SENSOR_DELAY_GAME并且在onSensorChanged里做一次低通滤波否则画面会抖得厉害。2.4 深度测试与渲染顺序渲染顺序必须是先背景后前景而且背景不写深度前景写深度。具体操作是绘制背景前glDisable(GL_DEPTH_TEST)绘制完背景后glEnable(GL_DEPTH_TEST)并且glClear(GL_DEPTH_BUFFER_BIT)。为什么背景不写深度因为背景是一个全屏四边形如果写深度它的深度值会覆盖整个深度缓冲区导致前景物体无论怎么画都被挡住。所以背景只写颜色不写深度。前景物体绘制前要清深度缓冲区确保深度测试从干净的状态开始。然后正常启用深度测试glDepthFunc(GL_LESS)这样近处的像素会覆盖远处的像素产生正确的遮挡关系。// 绘制背景 GLES20.glDisable(GLES20.GL_DEPTH_TEST); GLES20.glUseProgram(backgroundProgram); // ... 绑定纹理、设置顶点 ... GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); // 绘制前景 GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT); GLES20.glEnable(GLES20.GL_DEPTH_TEST); GLES20.glUseProgram(foregroundProgram); // ... 设置MVP矩阵、绑定顶点 ... GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, vertexCount);这个顺序不能乱乱了就会出现背景盖住前景或者前景穿透背景的问题。我一开始把清深度放在背景绘制之前结果背景把深度缓冲区写满了前景完全看不见排查了半天才发现是顺序问题。3. 实操过程与核心环节实现3.1 项目结构搭建与GLSurfaceView配置项目结构我分成几个模块CameraController负责摄像头打开、预览、关闭Renderer负责OpenGL渲染逻辑SensorController负责姿态传感器数据采集ShaderUtil负责着色器编译和程序链接。GLSurfaceView的配置要注意设置EGL上下文版本为2.0并且设置渲染模式为连续渲染glSurfaceView.setEGLContextClientVersion(2); glSurfaceView.setRenderer(renderer); glSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);为什么用连续渲染而不是按需渲染因为AR场景需要每帧都更新摄像头纹理和姿态矩阵按需渲染会导致画面卡顿。连续渲染虽然耗电但流畅度有保障。摄像头打开这块我用的是Camera1 API虽然老但兼容性好。打开摄像头后设置预览格式为NV21预览尺寸选1280x720这个分辨率在多数设备上都能跑到30帧。设置预览回调但回调里不做任何处理只是为了让摄像头持续输出。真正的图像数据是通过SurfaceTexture拿到的不需要在回调里处理字节数组。Camera.Parameters params camera.getParameters(); params.setPreviewSize(1280, 720); params.setPreviewFormat(ImageFormat.NV21); camera.setParameters(params); camera.setPreviewTexture(surfaceTexture); camera.startPreview();实操心得有些设备在设置预览尺寸时会抛异常建议先getSupportedPreviewSizes然后选一个最接近1280x720的。另外摄像头打开是耗时操作建议放在子线程打开完成后再通知GL线程开始渲染。3.2 着色器编写与调试着色器我写了四份背景顶点、背景片段、前景顶点、前景片段。背景顶点着色器前面已经给过了前景顶点着色器需要处理MVP矩阵attribute vec4 aPosition; attribute vec3 aNormal; uniform mat4 uMVPMatrix; uniform mat4 uModelMatrix; varying vec3 vNormal; void main() { gl_Position uMVPMatrix * aPosition; vNormal mat3(uModelMatrix) * aNormal; }前景片段着色器做一个简单的漫反射光照让物体看起来有立体感precision mediump float; varying vec3 vNormal; uniform vec3 uLightDir; uniform vec3 uColor; void main() { vec3 normal normalize(vNormal); float diff max(dot(normal, uLightDir), 0.0); gl_FragColor vec4(uColor * (0.3 0.7 * diff), 1.0); }着色器编译这块一定要检查编译状态和链接状态并且打印日志。我见过太多人因为着色器编译失败但没检查日志结果画面全黑找不到原因。int[] compiled new int[1]; GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0); if (compiled[0] 0) { String log GLES20.glGetShaderInfoLog(shader); Log.e(TAG, Shader compile error: log); GLES20.glDeleteShader(shader); return 0; }3.3 姿态传感器数据融合与平滑姿态数据我用的是旋转矢量传感器它融合了加速度计、陀螺仪和磁力计输出一个四元数。注册传感器时用SENSOR_DELAY_GAME采样率大概50Hz足够AR使用。拿到数据后先做低通滤波private float[] filteredRotation new float[9]; private static final float ALPHA 0.15f; Override public void onSensorChanged(SensorEvent event) { float[] rotationMatrix new float[9]; SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values); for (int i 0; i 9; i) { filteredRotation[i] filteredRotation[i] * (1 - ALPHA) rotationMatrix[i] * ALPHA; } }ALPHA取0.15是试出来的太小了延迟明显太大了抖动明显。这个值可以根据设备调整但0.1到0.2之间一般都能接受。然后要把设备坐标系重映射到屏幕坐标系。因为设备旋转矢量是基于设备自然方向的而屏幕方向可能是横屏或竖屏不重映射的话虚拟物体会跟着屏幕转看起来很奇怪。float[] remapped new float[9]; SensorManager.remapCoordinateSystem(filteredRotation, SensorManager.AXIS_X, SensorManager.AXIS_Z, remapped);重映射之后取逆矩阵作为视图矩阵再和投影矩阵相乘得到MVP矩阵。3.4 虚拟物体加载与绘制虚拟物体我选了一个简单的立方体用顶点数组和索引数组定义。立方体有8个顶点、12个三角形、36个索引。顶点数据包含位置和法线交错存储private static final float[] CUBE_VERTICES { // 前面 -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, -0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, // 后面 // ... 省略其他面 };绘制时用glDrawElements索引缓冲区用GL_STATIC_DRAW。立方体放在原点前方2个单位的位置这样旋转设备时能看到它从不同角度呈现。Matrix.setIdentityM(modelMatrix, 0); Matrix.translateM(modelMatrix, 0, 0.0f, 0.0f, -2.0f); Matrix.multiplyMM(mvpMatrix, 0, viewMatrix, 0, modelMatrix, 0); Matrix.multiplyMM(mvpMatrix, 0, projectionMatrix, 0, mvpMatrix, 0);注意translateM的Z轴负方向是相机前方因为OpenGL相机默认朝向负Z。如果你把物体放在正Z它会在相机后面看不见。4. 常见问题与排查技巧实录4.1 画面全黑或全白这是最常见的问题原因可能有以下几种。第一着色器编译失败但没检查日志程序链接失败后glUseProgram传了0导致渲染无效果。排查方法是检查glGetProgramiv的GL_LINK_STATUS并且打印glGetProgramInfoLog。第二纹理没有正确绑定或者updateTexImage没有调用。排查方法是确认SurfaceTexture的onFrameAvailable回调有没有触发以及updateTexImage有没有在GL线程执行。第三顶点数据没有正确传入attribute位置不对。排查方法是检查glGetAttribLocation的返回值如果是-1说明着色器里没有这个attribute或者被优化掉了。4.2 画面拉伸或变形这通常是投影矩阵的宽高比不对。投影矩阵的aspect必须和GLSurfaceView的实际宽高比一致不能直接用屏幕的物理分辨率因为GLSurfaceView可能被其他View挤压。正确做法是在onSurfaceChanged里拿到width和height用这两个值计算aspect。另外如果摄像头预览尺寸的宽高比和GLSurfaceView的宽高比不一致背景画面也会拉伸。解决办法是在背景顶点着色器里做一次缩放或者调整摄像头预览尺寸使其匹配。4.3 虚拟物体抖动严重抖动来自传感器噪声。除了低通滤波还可以用卡尔曼滤波或者互补滤波但实现复杂度高。我试过最简单的办法是增加滤波系数但会引入延迟。另一个办法是限制传感器的更新频率比如每两帧才更新一次姿态矩阵中间帧用上一帧的矩阵这样能减少抖动但会稍微降低响应速度。实测下来ALPHA取0.15配合SENSOR_DELAY_GAME在多数设备上抖动可以接受。4.4 摄像头画面方向不对这个问题和getTransformMatrix有关。不同设备的摄像头传感器方向不同有的需要旋转90度有的需要旋转270度。SurfaceTexture的getTransformMatrix会自动处理这个旋转但前提是你正确设置了摄像头预览方向。在Camera1 API里需要调用setDisplayOrientation这个值要根据设备旋转和摄像头方向计算Camera.CameraInfo info new Camera.CameraInfo(); Camera.getCameraInfo(cameraId, info); int rotation activity.getWindowManager().getDefaultDisplay().getRotation(); int degrees 0; switch (rotation) { case Surface.ROTATION_0: degrees 0; break; case Surface.ROTATION_90: degrees 90; break; case Surface.ROTATION_180: degrees 180; break; case Surface.ROTATION_270: degrees 270; break; } int result; if (info.facing Camera.CameraInfo.CAMERA_FACING_FRONT) { result (info.orientation degrees) % 360; result (360 - result) % 360; } else { result (info.orientation - degrees 360) % 360; } camera.setDisplayOrientation(result);这段代码是标准写法但很多人会忘记调用导致画面方向错乱。4.5 性能问题与帧率优化如果帧率低于30先检查是不是在GL线程里做了耗时操作。比如在onDrawFrame里分配内存、做复杂计算、或者调用updateTexImage之后又做了同步等待。优化手段包括把姿态矩阵计算放在传感器回调里而不是渲染循环里用VBO代替每次传顶点数据减少着色器里的分支和复杂运算降低摄像头预览分辨率。我实测下来1280x720的摄像头纹理加一个立方体在中端设备上能稳定跑30帧以上。问题现象可能原因排查方法解决手段画面全黑着色器编译失败检查编译日志修复着色器语法画面全黑纹理未更新检查onFrameAvailable确保GL线程调用updateTexImage画面拉伸投影矩阵宽高比错误检查onSurfaceChanged的宽高用实际View宽高计算aspect物体抖动传感器噪声观察原始数据增加低通滤波方向错乱预览方向未设置检查setDisplayOrientation按标准公式计算帧率低GL线程耗时操作用Systrace分析移出耗时逻辑避坑技巧在开发阶段建议在onDrawFrame里加一个帧率计数器每60帧打印一次平均帧率。这样能快速发现性能退化。另外GLSurfaceView的onPause和onResume必须调用否则切后台再回来GL上下文会丢失所有纹理和程序都要重建。5. 后续可扩展的方向与个人体会这个项目做完之后我最大的体会是AR的渲染管线本身并不复杂复杂的是坐标系变换和状态管理。很多人觉得AR难其实是被各种框架的抽象层挡住了一旦自己动手把纹理流、MVP矩阵、深度测试这几块串起来后面加什么功能都是在这个骨架上挂东西。后续可以扩展的方向有几个。第一加一个简单的平面检测用加速度计判断设备是否水平然后在水平面上放一个网格这样虚拟物体看起来像是放在桌子上。第二加光照估计从摄像头画面里采样环境光颜色用来调整虚拟物体的光照参数让叠加更自然。第三加遮挡处理用深度传感器或者双目视差估计真实场景的深度让虚拟物体被真实物体挡住。第四把立方体换成OBJ模型加纹理贴图看起来更真实。我个人在实际操作中的体会是纯OpenGL做AR最大的价值不是替代现有框架而是让你真正理解每一帧画面是怎么来的。当你看到虚拟立方体稳稳地贴在摄像头画面里随着设备转动而转动那种掌控感是调用高层API给不了的。踩过的坑包括忘记清深度缓冲区导致前景被背景盖住、传感器坐标系没重映射导致物体跟着屏幕转、着色器里用了highp导致低端设备编译失败。这些坑在文档里不会写只有自己动手才会遇到。最后再分享一个小技巧如果你在调试姿态矩阵可以先把虚拟物体换成一个坐标轴红绿蓝三根线分别代表XYZ轴这样能直观地看到坐标系的方向对不对。等坐标轴对了再换成真正的模型。这个办法帮我省了很多排查时间。