做Android 3D渲染的开发者,对OpenGL ES肯定不陌生。如果只是画一个纯色三角形,那只能算是入门;一旦想让虚拟物体拥有真实的光影效果,光照贴图(Light Mapping)就是绕不开的话题。我最近在一个模拟项目里实现了光照贴图渲染,从UV展开、纹理采样到Shader编写,踩了不少坑,今天把完整思路和关键代码整理出来,希望能帮到正在折腾这个的同学。
光照贴图这个概念听起来很高端,实际拆开看就是两件事:一是“光照”,二是“贴图”。它解决的痛点是让移动GPU在有限的性能预算内,也能渲染出接近真实的光影效果。这篇文章会从原理讲起,然后用一个可复现的Android工程带你走完整个实现流程,最后附上我实际调试中遇到的典型问题和排查方法。
1. 光照贴图是什么,为什么在Android上要这么写
1.1 光照贴图的概念与分类
光照贴图,英文叫Light Mapping,在我理解就是“把光的信息提前存到纹理里”。传统实时光照是在Shader里根据光源方向、法线方向实时计算每个像素的颜色,计算量随光源数量和物体复杂度直线上升。移动端GPU的并行能力有限,如果场景里放三四个动态光源,帧率立马会掉下来。
光照贴图的思路是“以空间换时间”。在离线阶段(或者加载阶段)把光照计算结果烘焙成一张二维纹理,渲染时只需要在片段着色器里做一次纹理采样,就能得到光照颜色。这样运行时省去了大量光照计算,静态场景尤其划算。
但在实际开发中,光照贴图还有另一层含义:把漫反射贴图(Diffuse Map)、镜面反射贴图(Specular Map)等材质贴图统称为光照贴图,因为它们都参与了光照计算。很多Android开发的教程里,所谓“加载光照贴图”其实就是加载了漫反射纹理和镜面纹理,然后配合光照模型实现效果。严格来说,这种应该叫“纹理光照”,但行业里也经常混叫。
两种方式的差别在于:一种是预计算好的最终光照结果,一种是光源计算时需要的材质属性。这篇文章主要讲第二种,因为它在OpenGL ES里更容易实现,也更适合动态光源场景。同时我会介绍如何把预计算好的光照纹理叠加到场景中,实现真正的“光照贴图”效果。
1.2 在OpenGL ES中的适用场景
在Android上使用OpenGL ES 2.0/3.0,光照贴图有两个典型应用场景:
第一个是静态场景烘焙。比如一个室内场景,天花板、墙壁、地板都是静态的,我们可以预先用CPU或离线工具算好每块表面的受光情况,存成光照贴图。运行时只做纹理查询,不需要任何光源计算,整体性能非常高。缺点是光照贴图需要占用额外的纹理内存,而且烘焙过程比较麻烦。
第二个是动态物体的材质模拟。比如一个角色,我们需要它既有基础颜色,又有高光反射效果。这时就用漫反射贴图控制基色,用镜面反射贴图控制高光的强度分布。在Shader里这两张贴图都会被采样,然后代入Blinn-Phong模型计算。这种方式对动态光源友好,物体可以移动,光源可以旋转,效果很直观。
我在那个模拟项目里选的是第二种方式,同时额外加入了一张预先生成的光照纹理,用于模拟场景中的明暗过渡。这样既涵盖了实时光照计算,也演示了真正的光照贴图采样,算是一次比较完整的实践。
2. 核心原理与相关技术选型
2.1 光照模型基础:环境光、漫反射、镜面反射
在写Shader之前,必须把光照模型搞清楚。OpenGL ES里最常用的是Phong模型和Blinn-Phong模型,它们都是把最终颜色拆分成三个分量:
环境光(Ambient):模拟场景中的全局间接光照,通常是一个很小的常量,防止物体完全变黑。在光照贴图应用中,环境光可以直接写死在着色器里,也可以从光照贴图的亮度值中采样。
漫反射(Diffuse):基于兰伯特余弦定律,光的强度与法线和光源方向的夹角余弦成正比。公式是diffuse = max(dot(normal, lightDir), 0.0) * lightColor * diffuseMapColor。漫反射部分决定了物体表面的基础明暗。
镜面反射(Specular):模拟高光,取决于视线方向、法线方向和光源方向的反射关系。Blinn-Phong使用半角向量来简化计算,公式是specular = pow(max(dot(normal, halfDir), 0.0), shininess) * specularMapColor。镜面贴图控制高光的强度分布,比如金属表面高光强,布料表面高光弱。
把这三个分量加起来,就得到了最终光照颜色。光照贴图在这个模型里扮演的角色是:把环境光、漫反射、镜面反射对应的颜色分别存储到纹理中,或者用纹理替换其中某一个分量。例如,漫反射贴图提供了物体每个像素的基础颜色,而光照贴图则额外记录了该像素受到的光照强度,两者相乘就是最终漫反射结果。
2.2 纹理采样与坐标映射
要把光照贴图正确贴到物体表面,纹理坐标(UV)是关键。每个顶点除了位置和法线,还必须包含一对UV坐标,范围通常是[0, 1]。在片段着色器中,纹理会根据插值后的UV坐标进行采样。
这里有一个容易踩坑的点:纹理坐标的坐标系。OpenGL ES默认左下角为原点(0,0),而Android的Bitmap默认左上角为原点,如果不做翻转,贴图会上下颠倒。最简单的解决办法是在加载纹理时调用texImage2D前,通过Matrix翻转Bitmap,或者直接在顶点数据里把V坐标反向。我之前习惯直接翻转UV,也就是v = 1.0 - v,这样就不用额外处理Bitmap。
对于光照贴图,UV映射的精度直接影响效果。如果UV展开有拉伸,采样出来的光照信息会发生扭曲。建议在建模阶段就做好UV展开,尽量不要让UV面片重叠,否则光照贴图会发生串色。
2.3 为什么选择OpenGL ES 2.0/3.0实现
Android目前虽然支持Vulkan,但OpenGL ES仍然是兼容性最好、资料最全的图形API。我选择OpenGL ES 2.0的主体写法,因为它的着色器语法(GLSL ES 1.00)非常基础,任何Android设备都支持。如果你用3.0,可以多一个纹理数组和更好的精度控制,但核心逻辑不变。
具体工程上,我会用Java/Kotlin配合NDK可选的方式。为了演示方便,纯Java调用OpenGL ES API就够了,不需要C++。代码量不大,重点在Shader和纹理数据上。渲染器使用GLSurfaceView,渲染线程自动管理OpenGL上下文,算是Android上的标准姿势。
3. 实操:构建一个带光照贴图的Android渲染项目
3.1 环境准备与工程结构
新建一个Android项目,最低API版本建议21以上,这样能保证OpenGL ES 2.0/3.0的兼容性。依赖不需要额外库,直接用android.opengl包和原生GLSurfaceView。
工程结构大致这样:
app/src/main/java/com/example/lightmap/ MainActivity.java MyGLSurfaceView.java MyGLRenderer.java LightMapShader.java TextureHelper.java app/src/main/res/drawable/ diffuse.png // 漫反射贴图 specular.png // 镜面反射贴图 lightmap.png // 光照贴图(预计算的明暗信息)其中MyGLRenderer负责创建GL环境、编译Shader、绑定数据并绘制;TextureHelper负责把Bitmap加载成OpenGL纹理;LightMapShader封装着色器程序。我这里用的是Java,用Kotlin写也是一样的逻辑。
注意:不要把资源文件放在drawable里如果后面用bitmapFactory.decodeResource没问题,但assets目录更适合放纹理文件。我习惯把贴图放在assets目录,然后通过AssetManager打开。这样可以避免Drawable的缩放干扰,确保纹理尺寸是原始像素。
3.2 顶点数据、UV坐标与法线准备
我画一个立方体,因为它能直观展示光照在不同面上的效果。每个面需要4个顶点,一个立方体共24个顶点(因为每个面的法线方向不同,顶点不能共享)。
定义顶点数组,包含位置坐标(xyz)、法线(xyz)和纹理坐标(uv)。为了方便阅读,我把数据分开写:
// 位置 float[] positions = { // Front face -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 1.0f, // Back face ... }; // 法线 float[] normals = { // Front face: +Z 0.0f, 0.0f, 1.0f, ... }; // UV float[] uvs = { // Front face 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, ... };注意每个面的UV可以相同,但法线不同。我把这三种数据分别上传至三个VBO,然后通过glVertexAttribPointer绑定到着色器中的属性变量。这样代码清晰一点。
一个关键点:法线需要归一化。如果你的顶点数据不是单位长度,一定要在代码中归一化,或者确保建模时已经归一化。我一开始偷懒没正规化,导致光照漫反射部分的颜色在斜面上出现奇怪的亮点。
3.3 着色器编写:顶点着色器与片段着色器
顶点着色器做的事情很简单:把顶点位置变换到裁剪空间,把法线和UV传递给片段着色器。
// vertex_shader.glsl attribute vec4 aPosition; attribute vec3 aNormal; attribute vec2 aTexCoord; uniform mat4 uMVPMatrix; uniform mat4 uModelMatrix; varying vec3 vNormal; varying vec2 vTexCoord; void main() { gl_Position = uMVPMatrix * aPosition; vNormal = mat3(uModelMatrix) * aNormal; vTexCoord = aTexCoord; }注意:法线变换不能直接用uModelMatrix,因为非均匀缩放下法线会被压扁。我用mat3(uModelMatrix)在无缩放或均匀缩放时是安全的,如果模型有非均匀缩放,需要用逆转置矩阵。我的立方体没有缩放,所以简单处理。
片段着色器是光照贴图的核心。我用Blinn-Phong模型,采样三张纹理:
// fragment_shader.glsl precision mediump float; varying vec3 vNormal; varying vec2 vTexCoord; uniform sampler2D uDiffuseMap; uniform sampler2D uSpecularMap; uniform sampler2D uLightMap; uniform vec3 uLightPos; uniform vec3 uCameraPos; void main() { vec3 normal = normalize(vNormal); vec3 lightDir = normalize(uLightPos - vWorldPos); // 如果光线是点光源 vec3 viewDir = normalize(uCameraPos - vWorldPos); vec3 halfDir = normalize(lightDir + viewDir); // 漫反射贴图 vec3 diffuseColor = texture2D(uDiffuseMap, vTexCoord).rgb; // 镜面反射贴图 float specularMask = texture2D(uSpecularMap, vTexCoord).r; // 光照贴图强度 float lightMapValue = texture2D(uLightMap, vTexCoord).r; // 漫反射光照 float diff = max(dot(normal, lightDir), 0.0) * lightMapValue; vec3 diffuse = diff * diffuseColor; // 环境光 vec3 ambient = 0.1 * diffuseColor; // 镜面反射 float shininess = 32.0; float spec = pow(max(dot(normal, halfDir), 0.0), shininess) * specularMask; vec3 specular = spec * vec3(1.0); gl_FragColor = vec4(ambient + diffuse + specular, 1.0); }这里我把光照贴图的值和漫反射计算相乘,模拟了“预计算的明暗遮罩”。如果你的光照贴图是彩色的,也可以直接在漫反射贴图基础上乘以光照颜色,公式变为diffuse = diff * diffuseColor * lightMapColor.rgb。
3.4 加载纹理与创建光照贴图
加载纹理的代码,网上到处都有,我贴上通用函数:
public static int loadTexture(Context context, String fileName) { Bitmap bitmap = null; try { bitmap = BitmapFactory.decodeStream(context.getAssets().open(fileName)); } catch (IOException e) { e.printStackTrace(); return 0; } int[] textureId = new int[1]; glGenTextures(1, textureId, 0); glBindTexture(GL_TEXTURE_2D, textureId[0]); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bitmap.getWidth(), bitmap.getHeight(), 0, GL_RGBA, GL_UNSIGNED_BYTE, bitmap.getPixelData()); generateMipmap(GL_TEXTURE_2D); bitmap.recycle(); return textureId[0]; }上面getPixelData是示意,实际要用ByteBuffer把Bitmap像素复制进去。如果不做翻转,这里会出现上下颠倒的问题。我的方案是在上传纹理前,先将Bitmap做一次矩阵翻转:
Matrix matrix = new Matrix(); matrix.preScale(1, -1); // 垂直翻转 bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);翻转后再上传,这样后面不需要再改UV。
关于光照贴图纹理的生成,我没有用离线软件,而是在代码里动态生成一张灰度渐变图,模拟从亮到暗的过渡。这样可以省去模型准备的麻烦,也方便调试。生成方式可以是给Bitmap的每个像素写值:
int w = 256, h = 256; Bitmap lightMap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { float brightness = (float)(x) / w; // 从左到右渐变 int gray = (int)(brightness * 255); lightMap.setPixel(x, y, Color.rgb(gray, gray, gray)); } }这张渐变光照贴图可以直观判断采样是否正常。当然,真正的游戏项目中,光照贴图应该是美术在DCC软件里烘焙出来的,这里只是演示。
3.5 渲染循环与相机控制
在MyGLRenderer的onSurfaceCreated中初始化Shader和纹理,onSurfaceChanged中设置视口和投影矩阵,onDrawFrame中更新模型矩阵,绘制立方体。
相机用简单的lookAt:
Matrix.setLookAtM(viewMatrix, 0, cameraX, cameraY, cameraZ, 0f, 0f, 0f, 0f, 1f, 0f);投影用透视投影:
Matrix.perspectiveM(projectMatrix, 0, 45f, width / height, 1f, 100f);绘制时计算MVPMatrix = projection * view * model,并传入uniform。同时传入光源位置(比如vec3(2, 2, 2)),以及相机位置。
为了让光照效果更明显,我在onDrawFrame里让立方体缓慢旋转:
Matrix.setRotateM(modelMatrix, 0, angle, 0.5f, 1f, 0f); angle += 0.5f;这样可以看到不同面的高光和明暗变化,验证光照贴图是否正常工作。
4. 常见问题与调试经验
4.1 光照贴图不显示或者全黑
这个问题几乎每个人都遇到过。我踩的坑主要是两种:
一是纹理没有正确绑定。在绘制前,必须glActiveTexture(GL_TEXTURE0)然后glBindTexture,再glUniform1i(glGetUniformLocation(program, "uDiffuseMap"), 0)。如果只写了glBindTexture却忘了设置uniform,采样器默认指向0号纹理单元,其实也正确,但如果你在绑定前没有激活合适的纹理单元,就会得到黑纹理。
二是纹理数据全为0。光照贴图里如果是纯黑,采样出来乘以漫反射后全黑是正常的。我的那张渐变图如果Bitmap格式不对,比如使用RGB_565,读取灰度时可能只有高低位,但一般不会全黑。更常见的问题是glTexImage2D的type参数写错,比如GL_UNSIGNED_BYTE对RGB_565的Bitmap就不对应,需要转成ARGB_8888。
我建议在Shader中加一个调试模式:把某个采样器直接作为输出颜色,比如gl_FragColor = vec4(texture2D(uLightMap, vTexCoord).rrr, 1.0);,这样可以快速判断光照贴图本身是否正常,排除光照计算的影响。
4.2 UV坐标错乱
UV错乱的现象是纹理贴图显示出来是断裂的、重复的、或者错位的。常见原因是顶点数组里UV顺序和位置顺序不匹配。比如一个三角形三个顶点,位置顺序是A、B、C,那么UV数组也必须按A、B、C的顺序写。如果你把UV放在另一个数组里,但两个数组顺序不一致,就是灾难。
还有一个容易忽略的点:glVertexAttribPointer的步长和偏移。我习惯用单独VBO,每个属性一个数组,这样步长是0(表示自动按紧凑排列),偏移是0。如果你用交错布局(比如一个数组里放着 xyz, rgb, uv),那步长和偏移要仔细算。我一开始把步长写错,导致UV采到的值完全不对。
经验:在调UV问题的时候,先在片段着色器里输出UV坐标本身,比如gl_FragColor = vec4(vTexCoord, 0.0, 1.0);。这样你会看到一张彩色的渐变图,红绿通道分别对应U和V。如果UV是连续的且没有跳变,说明数据没问题。否则就一段一段检查顶点数据。
4.3 纹理翻转问题
Android的Bitmap坐标和OpenGL纹理坐标确实存在差异。如果你发现贴图上下颠倒,不要急着改Shader,先试试翻转Bitmap上传。我习惯在上传前用Matrix翻转,这样UV数据始终和建模一致。
但注意:光照贴图如果是程序生成的,生成时是按Bitmap坐标写的,上传前同样翻转。否则光照贴图在物体上的方向会和漫反射贴图相反,看起来像光影错位。
另一个细节:如果使用GL_REPEAT环绕模式,UV超出 [0,1] 会重复贴图,这在某些场景是想要的。但如果光照贴图的UV使用GL_CLAMP_TO_EDGE,边缘会拉长,避免接缝。我建议光照贴图用GL_CLAMP_TO_EDGE,漫反射贴图用GL_REPEAT,有不同的需求可以分别设置。
4.4 性能优化建议
光照贴图本身是为了性能,但如果实现不当,反而会成为性能瓶颈。
第一个问题是纹理过度采样。每个碎片着色器采样三张纹理,这是在移动GPU上的一个不小开销。如果你的片段着色器复杂度太高,GPU的填充率会跟不上,导致发热和掉帧。针对纯静态场景,我建议把漫反射贴图和光照贴图预合成为一张纹理,减少采样次数。如果必须保留多张纹理,可以考虑用纹理阵列(如果设备支持OpenGL ES 3.0)减少状态切换的开销。
第二个问题是纹理尺寸。移动端纹理内存很宝贵,光照贴图尽量用低分辨率(256x256或512x512),因为光照变化通常是低频的,太高的分辨率意义不大。我在那个模拟项目里用了256x256,效果完全够。
第三个问题是精度。GLSL中的mediump对大部分纹理计算足够,但遇到暗部时可能会出现色带。如果发现光照过渡有斑马纹,尝试把某些变量改成highp,但代价是性能下降。合理的方式是平时用mediump,只在必要的高精度计算中使用highp。
4.5 着色器编译Log与调试技巧
如果Shader写错了,OpenGL不会报错,但屏幕上什么都没有。这时候需要从编译状态获取日志:
int status = glGetShaderiv(shader, GL_COMPILE_STATUS); String log = glGetShaderInfoLog(shader);如果编译失败,log里通常有行号和错误描述。我建议在开发阶段封装一个方法,每次编译后自动输出log,并且加上断言。绝大多数问题是语法错误,比如忘记写分号,或者变量名拼错。
链接Program后也要检查链接状态。GLSL的变量匹配很严格,vertex里的varying和 fragment里的varying名字必须一致,否则链接失败。我遇到过几次把vNormal敲成vNorm,导致链接报错。
调试光照效果时,不要一上来就调一堆参数。我习惯先把所有常数设置成极端值:比如把光源放在正上方,环境光设为0,漫反射系数设为1,镜面关掉,确认漫反射方向正确;再打开镜面反射,慢慢调高光参数。这样每一步都是可控的。
5. 进阶扩展与个人体会
5.1 使用多张贴图模拟复杂光照
光照贴图不仅可以存灰度明暗,还可以存储方向信息,比如预计算的环境光遮蔽(AO)效果。在材质层面,除了漫反射贴图和镜面反射贴图,还可以加入法线贴图(Normal Map)来增加表面细节。法线贴图会修改逐像素法线方向,让光照计算更丰富,但需要引入切线空间变换。
我的实践是:在顶点着色器中计算TBN矩阵,将法线贴图的采样值从切线空间变换到世界空间。这个会稍微复杂一点,但效果提升非常明显。你可以在光照贴图的基础上叠加法线贴图,模拟砖墙、凹凸路面等细节。
5.2 Gamma校正与色调映射
移动设备的屏幕一般默认sRGB输出,但OpenGL ES的计算在线性空间中进行。如果你直接输出计算出的光照颜色,会发现暗部偏暗,高光过曝。这就是Gamma问题。
一个简单的做法是:在片段着色器末尾做一次pow操作:
gl_FragColor = vec4(pow(color.rgb, vec3(1.0 / 2.2)), 1.0);当然,更规范的做法是使用sRGB纹理格式(GL_SRGB8_ALPHA8),或者在上传纹理时进行解码。光照贴图同样需要处理Gamma。我的建议是在开发初期就加入Gamma校正,否则你调节光强参数时会非常痛苦,因为你看到的结果不是实际计算结果。
5.3 静态光照贴图的完整流程简述
除了实时材质贴图,我还简单尝试过真正的预计算光照贴图。流程大概是:把场景中每个三角形按UV展开,记录每个纹素对应的世界空间位置和法线;然后遍历光源,对每个纹素计算光照;最后把结果写入纹理。这个过程相当于一个软件渲染器,非常耗时,但适合离线烘焙。
在Android上,如果你需要动态加载场景,可以在进入场景前用一段加载界面来做这件事。但移动端的CPU算力去做全场景烘焙,对复杂场景是不现实的。所以实际项目中,静态光照贴图的烘焙通常在PC端完成,把生成的光照贴图作为资源随APK打包。运行时直接加载,效率极高。
5.4 我的一些心得体会
最后,说点我个人的经验。光照贴图这个课题,刚接触时觉得很玄乎,其实就是把光照计算的一部分搬到纹理采样上。一开始我以为必须用复杂的数学,后来发现核心逻辑就是几个向量点乘和纹理坐标的对应。
在调光照贴图的过程中,我最重要的心得是:先把一个简单的立方体调通,再扩展到复杂模型。因为立方体UV简单,位置和法线都很清晰,出问题容易定位。如果一开始就直接加载一个复杂模型,UV展开乱七八糟,你根本分不清是Shader问题还是模型问题。
另外,用程序生成的渐变图作为光照贴图来验证渲染,非常高效。你不需要费心找美术资源,只要生成一张从黑到白的渐变图,就能立刻看出纹理坐标是否正确。等这一整套流程跑通了,再换上真正的美术贴图,整个过程会平滑很多。
如果你也在做类似的东西,希望这篇文章能帮你少走弯路。光照贴图不是终点,后面还有阴影贴图、环境映射、全局光照等一堆好玩的东西等着去折腾。但地基打牢了,往上走就顺了。