OpenGL Android課程四:介紹紋理基礎

奏響曲發表於2019-02-11

翻譯文

原文標題:Android Lesson Four: Introducing Basic Texturing 原文連結:www.learnopengles.com/android-les…


介紹紋理基礎

這是我們Android系列的第四個課程。
在本課中,我們將新增我們在第三課
中學到的內容,並學習如何新增紋理。
我們來看看如何從應用資源中獲取一張
圖片載入到OpenGLES中,並展示到
螢幕上。

跟著我一起來,你將馬上明白紋理的
基本使用方式。
screenshot

前提條件

本系列每個課程構建都是以前一個課程為基礎,這節課是第三課的擴充套件,因此請務必在繼續之前複習該課程。

已下是本系列課程的前幾課:

紋理基礎

紋理對映的藝術(以及照明)是構建逼真的3D世界最重要的部分。沒有紋理對映,一切都是平滑的陰影,看起來很人工,就像是90年代的老式控制檯遊戲。

第一個開始大量使用紋理的遊戲,如Doom和Duke Nukem 3D,通過增加視覺衝擊力,大大提升了遊戲的真實感——如果在晚上玩可能會真的嚇唬到我們。

這裡我們來看有紋理和沒有紋理的場景

pre-fragment lighting

每片段照明;
正方形四個頂點中心位置
added texture

新增了紋理;
正方形四個頂點中心位置
看左邊的圖片,這個場景通過每像
素照明和著色點亮。這個場景看起
來非常平滑,現實生活中我們走進
一個房間有充滿了光滑陰影的東西
就像是這個立方體。

在看右邊的圖片,同樣的場景現在
紋理化了。環境光也增加了,因為
紋理的使用使整個場景變暗,也可
以看到紋理對側面立方體的影響。
立方體具有和以前相同數量的多邊
形,但它們有新紋理看起來更加詳
細。

滿足於那些好奇的人,這個紋理的
資源來自於公共領域的資源

紋理座標

在OpengGL中,紋理座標時常使用座標(s,t)代替(x,y)。(s,t)表示紋理上的一個紋理元素,然後對映到多邊形。另外需要注意這些紋理座標和其他OpengGL座標相似:t(或y)軸指向上方,所以值越高您走的越遠。

大多數計算機圖形,y軸指向下方。這意味著左上角是圖片的原點(0,0),並且y值向下遞增。換句話說,OpenGL的座標系和大多數計算機圖形相反,這是您需要考慮到的。

OpenGL的紋理座標系
coordiante

紋理對映基礎

在本課中,我們將來看看常規2D紋理(GL_TEXTURE_2D)和紅,綠,藍顏色資訊(GL_RGB)。OpenGL ES 也提供其他紋理模式讓你做更多不同的特殊效果。我們將使用GL_NEAREST檢視點取樣,GL_LINEAR和MIP-對映將在後面的課程中講解。

讓我們一起來到程式碼部分,看看怎樣開始在Android中使用基本的紋理。

頂點著色器

我們將採用上節課中的每畫素照明著色器,並新增紋理支援。

這兒是新的變化:

attribute vec2 a_TexCoordinate;// 我們將要傳入的每個頂點的紋理座標資訊
...
varying vec2 v_TexCoordinate;  // 這將會傳入到片段著色器

void main()
{
   // 傳入紋理座標
   v_TexCoordinate = a_TexCoordinate;
   ...
}
複製程式碼

在頂點著色器中,我們新增一個新的屬性型別vec2(一個包含兩個元素的陣列),將用來放入紋理座標資訊。這將是每個頂點都有,同位置,顏色,法線資料一樣。我們也新增了一個新的變數,它將通過三角形表面上的線性插值將資料傳入片段著色器。

片段著色器

uniform sampler2D u_Texture;" +  // 傳入紋理
...
varying vec2 v_TexCoordinate;" + // 插入的紋理座標
void main()
{
   ...
   // 計算光線向量和頂點法線的點積,如果法線和光線向量指向相同的方向,那麼它將獲得最大的照明
   float diffuse = max(dot(v_Normal, lightVector), 0.1);" +
   // 根據距離哀減光線
   diffuse = diffuse * (1.0 / (1.0 + (0.10 * distance * distance)));" +
   // 新增環境照明
   diffuse = diffuse + 0.3;" +
   // 顏色乘以亮度哀減和紋理值得到最終的顏色
   gl_FragColor = v_Color * diffuse * texture2D(u_Texture, v_TexCoordinate);" +
}
複製程式碼

我們新增了一個新的常量型別sampler2D來表示實際紋理資料(與紋理座標對應), 由定點著色器插值傳入紋理座標,我們再呼叫texture2D(texture, textureCoordinate) 得到紋理在當前座標的值,我們得到這個值後再乘以其他項得到最終輸出的顏色。

這種方式新增紋理會使整個場景變暗,因此我們還會稍微增強環境光照並減少光照哀減。

將一個圖片載入到紋理

public static int loadTexture(final Context context, final int resourceId) {
    final int[] textureHandle = new int[1];

    GLES20.glGenTextures(1, textureHandle, 0);

    if (textureHandle[0] != 0) {
        final BitmapFactory.Options options = new BitmapFactory.Options();
        options.inScaled = false; // 沒有預先縮放

        // 得到圖片資源
        final Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), resourceId, options);

        // 在OpenGL中繫結紋理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0]);

        // 設定過濾
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);

        // 將點陣圖載入到已繫結的紋理中
        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

        // 回收點陣圖,因為它的資料已載入到OpenGL中
        bitmap.recycle();
    }

    if (textureHandle[0] == 0) {
        throw new RuntimeException("Error loading texture.");
    }
    return textureHandle[0];
}
複製程式碼

這段程式碼將Androidres資料夾中的圖形檔案讀取並載入到OpenGL中,我會解釋每一部分的作用。

我們首先需要告訴OpenGL去為我們建立一個新的handle,這個handle作為一個唯一標識,我們想在OpenGL中引用紋理時就會使用它。

final int[] textureHandle = new int[1];
GLES20.glGenTextures(1, textureHandle, 0);
複製程式碼

這個OpenGL方法可以用來同時生成多個handle,這裡我們僅生成一個。

因為我們這裡只需要一個handle去載入紋理。首先,我們需要得到OpenGL能理解的紋理格式。 我們不能只從PNG或JPG提供原始資料,因為它不會理解。我們需要做的第一步是將影象檔案解碼為Android Bitmap物件:

final BitmapFactory.Options options = new BitmapFactory.Options();
options.inScaled = false; // 沒有預先縮放
// 得到圖片資源
final Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), resourceId, options);
複製程式碼

預設情況下,Android會根據裝置的解析度和你放置圖片的資原始檔目錄而預先縮放點陣圖。我們不希望Android根據我們的情況對點陣圖進行縮放,因此我們將inScaled設定為false

// 在OpenGL中繫結紋理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0]);

// 設定過濾
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);
複製程式碼

然後我們繫結紋理,並設定幾個引數,繫結一個紋理,並告訴OpenGL後續OpenGL呼叫需要這樣過濾這個紋理。我們將預設過濾器設定為GL_NEAREST,這是最快,也是最粗糙的過濾形式。它所做的就是在螢幕的每個點選擇最近的紋素,這可能導致影象偽像和鋸齒。

  • GL_TEXTURE_MIN_FILTER 這是告訴OpenGL在繪製小於原始大小(以畫素為單位)的紋理時要應用哪種型別的過濾。
  • GL_TEXTURE_MAG_FILTER 這是告訴OpenGL在放大紋理到原始大小時要應用哪種型別的過濾。
// 將點陣圖載入到已繫結的紋理中
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

// 回收點陣圖,因為它的資料已載入到OpenGL中
bitmap.recycle();
複製程式碼

安卓有一個非常實用的功能可以直接將點陣圖載入到OpenGL中。一旦您將資源讀入Bitmap物件GLUtils.texImage2D()將負責其他事情,這個方法的簽名:

public static void texImage2D (int target, int level, Bitmap bitmap, int border)
複製程式碼

我們想要一個常規的2D點陣圖,因此我們傳入GL_TEXTURE_2D作為第一個引數。第二個引數用於MIP-對映,並允許您指定要在哪個級別使用的影象。我們這裡沒有使用MIP-對映,因此我們將傳入0設定為預設級別。我們傳入點陣圖,由於我們沒有使用邊框,所以我們傳入0。

然後原始點陣圖物件呼叫recycle(),這提醒Android可以回收這部分記憶體。由於紋理已被載入到OpenGL,我們不需要繼續保留這個副本。 是的,Android應用程式在執行垃圾收集的Dalvik VM下執行,但Bitmap物件包含駐留在native記憶體中的資料,如果你不明確的回收它們,它們需要幾個週期來進行垃圾收集。 這意味著如果您忘記執行此操作,實際上可能會因記憶體不足錯誤而崩潰,即使您不再持有對點陣圖的任何引用。

將紋理應用到我們的場景

首先,我們需要新增各種成員變數來持有我們紋理所需要的東西:

// 存放我們的模型資料在浮點緩衝區
private final FloatBuffer mCubeTextureCoordinates;

// 用來傳入紋理
private int mTextureUniformHandle;

// 用來傳入模型紋理座標
private int mTextureCoordinateHandle;

// 每個資料元素的紋理座標大小
private final int mTextureCoordinateDataSize = 2;

// 紋理資料
private int mTextureDataHandle;
複製程式碼

我們基本上是需要新增新成員變數來跟蹤我們新增到著色器的內容,以及保持對紋理的引用。

定義紋理座標

我們在構造方法中定義我們的紋理座標

// S, T (或 X, Y)
// 紋理座標資料
// 因為影象Y軸指向下方(向下移動圖片時值會增加),OpenGL的Y軸指向上方
// 我們通過翻轉Y軸來調整它
// 每個面的紋理座標都是相同的
final float[] cubeTextureCoordinateData =
        {
                // 正面
                0.0F, 0.0F,
                0.0F, 1.0F,
                1.0F, 0.0F,
                0.0F, 1.0F,
                1.0F, 1.1F,
                1.0F, 0.0F,
        };
...
複製程式碼

這座標資料看起來可能有點混亂。如果您返回去看第三課中點的位置是如何定義的,您將會發現我們為正方體每個面都定義了兩個三角形。點的定義方式像下面這樣:

(三角形1)
左上,
左下,
右上
(三角形2)
左下,
右下,
右上
複製程式碼

紋理座標和正面的位置座標對應,但是由於Y軸翻轉,Y軸指向和OpenGL的Y軸相反的方向。

看下圖,實線座標表示在OpenGL中正方體正面X,Y座標。虛線表示翻轉後的座標,可以看出和上面定義的紋理座標是一一對應的

紋理座標對應

設定紋理

我們在onSurfaceCreated()方法中載入紋理

@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
    ...
    mProgramHandle = ShaderHelper.createAndLinkProgram(vertexShaderHandle, fragmentShaderHandle, "a_Position", "a_Color", "a_Normal", "a_TexCoordinate");
    ...
    // 載入紋理
    mTextureDataHandle = TextureHelper.loadTexture(mActivityContext, R.drawable.bumpy_bricks_public_domain);
複製程式碼

我們傳入一個新的屬性a_TexCoordinate繫結到我們的著色器中,並且我們通過之前建立的loadTexture()方法載入著色器。

使用紋理

我們也需要在onDrawFrame(GL10 gl)方法中新增一些程式碼。

@Override
public void onDrawFrame(GL10 gl) {
    ...
    mTextureUniformHandle = GLES20.glGetUniformLocation(mProgramHandle, "u_Texture");
    mTextureCoordinateHandle = GLES20.glGetAttribLocation(mProgramHandle, "a_TexCoordinate");

    // 將紋理單元設定為紋理單元0
    GLES20.glActiveTexture(GLES20.GL_TEXTURE0);

    // 將紋理繫結到這個單元
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureDataHandle);

    // 通過繫結到紋理單元0,告訴紋理標準取樣器在著色器中使用此紋理
    GLES20.glUniform1i(mTextureUniformHandle, 0);
複製程式碼

我們得到著色器中的紋理資料和紋理座標控制程式碼。在OpenGL中,紋理能在著色之前,需要繫結到紋理單元。紋理單元是讀取紋理並實際將它傳入著色器的中,因此可以再螢幕上顯示。不同的圖形晶片有不同數量的紋理單元,因此在使用它們之前,您需要檢查是否存在其他紋理單元。

首先,我們告訴OpenGL我們想設定使用的紋理單元到第一個單元,紋理單元0。然後自動繫結紋理到第一個單元,通過呼叫glBindTexture()。最後,我們告訴OpenGL,我們想將mTextureUniformHandle繫結到第一個紋理單元,它引用了片段著色器中u_Texture屬性。

簡而言之:

  1. 設定紋理單元
  2. 繫結紋理到這個單元
  3. 將此單元指定給片段著色器中的紋理標準

根據需要重複多個紋理。

進一步練習

一旦您做到這兒,您就完成的差不多了!當然這這並沒有您預期的那麼糟糕...或者確實糟糕??作為下一個練習,嘗試通過載入另一個紋理,將其繫結到另一個單元,並在著色器中使用它。

回顧

現在我們回顧一下所有的著色器程式碼,以及我們新增了一個新的幫助功能用來從資源目錄讀取著色器程式碼,而不是儲存在java字串中:

頂點著色器 all

uniform mat4 u_MVPMatrix;                      // 一個表示組合model、view、projection矩陣的常量
uniform mat4 u_MVMatrix;                       // 一個表示組合model、view矩陣的常量

attribute vec4 a_Position;                     // 我們將要傳入的每個頂點的位置資訊
attribute vec4 a_Color;                        // 我們將要傳入的每個頂點的顏色資訊
attribute vec3 a_Normal;                       // 我們將要傳入的每個頂點的法線資訊
attribute vec2 a_TexCoordinate;                // 我們將要傳入的每個頂點的紋理座標資訊

varying vec3 v_Position;
varying vec4 v_Color;
varying vec3 v_Normal;
varying vec2 v_TexCoordinate;                  // 這將會傳入到片段著色器

// 頂點著色器入口點
void main()
{
   // 傳入紋理座標
   v_TexCoordinate = a_TexCoordinate;
   // 將頂點位置轉換成眼睛空間的位置
   v_Position = vec3(u_MVMatrix * a_Position);
   // 傳入顏色
   v_Color = a_Color;
   // 將法線的方向轉換在眼睛空間
   v_Normal = vec3(u_MVMatrix * vec4(a_Normal, 0.0));
   // gl_Position是一個特殊的變數用來儲存最終的位置
   // 將頂點乘以矩陣得到標準化螢幕座標的最終點
   gl_Position = u_MVPMatrix * a_Position;
}
複製程式碼

片段著色器 all

precision mediump float; //我們將預設精度設定為中等,我們不需要片段著色器中的高精度
uniform sampler2D u_Texture;  // 傳入紋理
uniform vec3 u_LightPos; // 光源在眼睛空間的位置
varying vec3 v_Position; // 插入的位置
varying vec4 v_Color; // 插入的位置顏色
varying vec3 v_Normal; // 插入的位置法線
varying vec2 v_TexCoordinate; // 插入的紋理座標
void main()  // 片段著色器入口
{
   // 將用於哀減
   float distance = length(u_LightPos - v_Position);
   // 獲取從光源到頂點方向的光線向量
   vec3 lightVector = normalize(u_LightPos - v_Position);
   // 計算光線向量和頂點法線的點積,如果法線和光線向量指向相同的方向,那麼它將獲得最大的照明
   float diffuse = max(dot(v_Normal, lightVector), 0.1);
   // 根據距離哀減光線
   diffuse = diffuse * (1.0 / (1.0 + (0.25 * distance * distance)));
   // 新增環境照明
   diffuse = diffuse + 0.3;
   // 顏色乘以亮度哀減和紋理值得到最終的顏色
   gl_FragColor = v_Color * diffuse * texture2D(u_Texture, v_TexCoordinate);
}
複製程式碼

怎樣從raw資源目錄中讀取文字?

public class RawResourceReader {
    public static String readTextFileFromRawResource(final Context context, final int resurceId) {
        final InputStream inputStream = context.getResources().openRawResource(resurceId);
        final InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
        final BufferedReader bufferedReader = new BufferedReader(inputStreamReader);

        String nextLine;

        final StringBuilder body = new StringBuilder();

        try {
            while ((nextLine = bufferedReader.readLine()) != null) {
                body.append(nextLine).append('\n');
            }
        } catch (IOException e) {
            return null;
        } finally {
            try {
                bufferedReader.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return body.toString();
    }
}
複製程式碼

教程目錄

打包教材

可以在Github下載本課程原始碼:下載專案
本課的編譯版本也可以再Android市場下:google play 下載apk
“我”也編譯了個apk,方便大家下載:github download

相關文章