纹理映射基础:从零到一显示一张图片
纹理映射,说白了就是把一张图片贴到你的3D模型上。没有纹理,OpenGL的世界就是一堆单调的线框和纯色块,看起来就像上世纪90年代的3D游戏。有了纹理,你的模型才有了「皮肤」,才有了真实感。
我个人习惯把纹理映射分成三步走:准备纹理数据、设置纹理坐标、采样显示。今天我们就用最简单的方式,把一张Bitmap贴到屏幕上。
纹理坐标:一张隐形的网格
先聊聊纹理坐标。你想想看,要把一张图片贴到三角形上,总得告诉OpenGL「图片的哪个部分对应三角形的哪个顶点」吧?这就是纹理坐标干的事。
纹理坐标是一个二维坐标,用 (s, t) 表示,取值范围是 0.0 到 1.0。注意,不是像素坐标,是归一化坐标。
- (0, 0) 对应图片的左下角
- (1, 0) 对应图片的右下角
- (0, 1) 对应图片的左上角
- (1, 1) 对应图片的右上角
嗯,这里要注意:OpenGL的纹理坐标系原点在左下角,而Android的Bitmap坐标系原点在左上角。我第一次做纹理映射时,图片上下颠倒了,找了半天bug才发现是坐标系搞反了。后来我习惯在加载纹理时直接做一次Y轴翻转,省得后面麻烦。
核心概念:纹理坐标与顶点坐标一一对应。你有几个顶点,就要给几个纹理坐标。每个顶点除了位置 (x, y, z),还要带上纹理 (s, t)。
加载Bitmap纹理:把图片送进GPU
在Android里,我们通常用 BitmapFactory 加载一张图片,然后通过 OpenGL 的 API 把它上传到 GPU 显存中。这个过程我称之为「纹理搬家」——从CPU内存搬到GPU显存。
直接上代码,这是我项目中一直在用的纹理加载方法:
public static int loadTexture(Context context, int resourceId) {
// 生成纹理对象
final int[] textureIds = new int[1];
glGenTextures(1, textureIds, 0);
if (textureIds[0] == 0) {
return 0;
}
// 加载Bitmap
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inScaled = false; // 不要缩放,保持原始尺寸
final Bitmap bitmap = BitmapFactory.decodeResource(
context.getResources(), resourceId, options);
// 绑定纹理
glBindTexture(GL_TEXTURE_2D, textureIds[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_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 上传纹理数据
GLUtils.texImage2D(GL_TEXTURE_2D, 0, bitmap, 0);
// 生成Mipmap
glGenerateMipmap(GL_TEXTURE_2D);
// 释放Bitmap
bitmap.recycle();
// 解绑纹理
glBindTexture(GL_TEXTURE_2D, 0);
return textureIds[0];
}
这段代码有几个关键点,我挨个说一下:
- glGenTextures:向OpenGL申请一个纹理ID,就像申请一个文件句柄。
- glBindTexture:绑定纹理,告诉OpenGL「接下来我要操作这个纹理了」。
- GLUtils.texImage2D:把Bitmap数据上传到GPU。这个方法会自动处理格式转换。
- bitmap.recycle():上传完后立即释放Bitmap。Bitmap占的是Java堆内存,不及时释放容易OOM。
注意:options.inScaled = false 这个设置很重要。如果不设置,系统会根据屏幕密度自动缩放Bitmap,导致纹理尺寸和原始图片不一致。我曾经因为这个坑,在低分辨率设备上纹理模糊得一塌糊涂。
纹理过滤:放大和缩小时怎么办?
一张图片贴到模型上,模型可能比图片大,也可能比图片小。这时候就需要纹理过滤来决定「怎么采样」。
OpenGL提供了两种基本的过滤方式:
| 过滤方式 | 常量 | 效果 | 性能 |
|---|---|---|---|
| 最近邻 | GL_NEAREST | 像素风,锯齿明显 | 快 |
| 双线性 | GL_LINEAR | 平滑模糊 | 中等 |
| 三线性Mipmap | GL_LINEAR_MIPMAP_LINEAR | 远距离也清晰 | 较慢 |
我个人建议:对于大多数2D图片显示,用 GL_LINEAR 就够了。如果你要显示的是像素风格的游戏,用 GL_NEAREST 反而更有味道。至于Mipmap,它主要用于3D场景中物体远离时避免闪烁,2D显示一般用不上。
纹理环绕方式:纹理坐标超出1.0怎么办?
纹理坐标理论上在0到1之间。但如果你的纹理坐标超出了这个范围,OpenGL需要决定「怎么处理」。这就是纹理环绕方式。
常用的环绕方式有三种:
- GL_CLAMP_TO_EDGE:超出部分取边缘像素。适合显示一张照片,边缘不会出现奇怪的颜色。
- GL_REPEAT:重复平铺。适合做地板、墙纸等重复纹理。
- GL_MIRRORED_REPEAT:镜像平铺。比REPEAT更自然,接缝处不会太明显。
我一般在显示单张图片时用 CLAMP_TO_EDGE。有一次做全景图查看器,用了 REPEAT 模式,结果图片边缘出现了接缝,折腾了半天才发现是环绕方式的问题。
完整流程:从纹理坐标到屏幕显示
好了,理论说完了,我们看看完整的渲染流程。下面这张图展示了纹理映射的核心数据流:
这个流程其实不复杂。顶点着色器把纹理坐标原样传递下去,光栅化阶段在三角形内部插值出每个片段的纹理坐标,片段着色器拿着这个坐标去纹理单元采样,拿到颜色值,最后输出到屏幕。
实战:显示一张图片
好了,我们把所有东西串起来。假设你已经有了一个渲染好的三角形,现在要给它贴上纹理。你需要做这几件事:
- 在顶点数据中增加纹理坐标
- 在顶点着色器中声明一个 varying 变量传递纹理坐标
- 在片段着色器中声明 sampler2D 并采样
- 调用上面的 loadTexture 方法加载纹理
- 在绘制前绑定纹理
顶点着色器大概长这样:
attribute vec4 aPosition;
attribute vec2 aTexCoord;
varying vec2 vTexCoord;
void main() {
gl_Position = aPosition;
vTexCoord = aTexCoord;
}
片段着色器:
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uTexture;
void main() {
gl_FragColor = texture2D(uTexture, vTexCoord);
}
绘制代码:
// 绑定纹理
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textureId);
glUniform1i(uTextureLocation, 0);
// 绘制三角形
glDrawArrays(GL_TRIANGLES, 0, 3);
小技巧:glActiveTexture 和 glUniform1i 是配套使用的。glActiveTexture 激活纹理单元0,glUniform1i 告诉着色器「采样器uTexture对应纹理单元0」。如果你只有一个纹理,用 GL_TEXTURE0 就够了。
避坑指南
最后分享几个我踩过的坑:
- 纹理坐标反了:记得检查Bitmap的坐标系和OpenGL的坐标系是否一致。不一致的话,在纹理坐标的t分量上做 1.0 - t 翻转。
- 纹理显示为白色:大概率是纹理没有正确绑定,或者着色器中的uniform变量名拼写错误。
- 图片模糊:检查 options.inScaled 是否设为 false,以及纹理尺寸是否是2的幂(虽然现代OpenGL已经放宽了这个限制,但部分旧设备仍有要求)。
- 内存泄漏:Bitmap用完一定要 recycle,纹理ID用完要 glDeleteTextures。
纹理映射是OpenGL中最基础也最重要的功能之一。掌握了它,你就能在屏幕上显示任意图片了。下一步,你可以试试把纹理贴到矩形、立方体上,甚至做图片的旋转和缩放——这些都是在今天的基础上扩展的。
公众号:蓝海资料掘金营,微信deep3321