纹理映射基础:从零到一显示一张图片

纹理映射,说白了就是把一张图片贴到你的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];
}

这段代码有几个关键点,我挨个说一下:

  1. glGenTextures:向OpenGL申请一个纹理ID,就像申请一个文件句柄。
  2. glBindTexture:绑定纹理,告诉OpenGL「接下来我要操作这个纹理了」。
  3. GLUtils.texImage2D:把Bitmap数据上传到GPU。这个方法会自动处理格式转换。
  4. 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 模式,结果图片边缘出现了接缝,折腾了半天才发现是环绕方式的问题。

完整流程:从纹理坐标到屏幕显示

好了,理论说完了,我们看看完整的渲染流程。下面这张图展示了纹理映射的核心数据流:

纹理映射核心流程 顶点数据 位置坐标 (x, y, z) 纹理坐标 (s, t) 顶点着色器 传递纹理坐标 变换顶点位置 光栅化 生成片段 插值纹理坐标 片段着色器 采样纹理 输出颜色 纹理单元 纹理过滤 环绕处理 最终输出:每个像素的颜色值

这个流程其实不复杂。顶点着色器把纹理坐标原样传递下去,光栅化阶段在三角形内部插值出每个片段的纹理坐标,片段着色器拿着这个坐标去纹理单元采样,拿到颜色值,最后输出到屏幕。

实战:显示一张图片

好了,我们把所有东西串起来。假设你已经有了一个渲染好的三角形,现在要给它贴上纹理。你需要做这几件事:

  1. 在顶点数据中增加纹理坐标
  2. 在顶点着色器中声明一个 varying 变量传递纹理坐标
  3. 在片段着色器中声明 sampler2D 并采样
  4. 调用上面的 loadTexture 方法加载纹理
  5. 在绘制前绑定纹理

顶点着色器大概长这样:

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