Lưu ý: Bài viết này dựa vào sách OpenGL ES 2.0 programming guide kết hợp với các ví dụ mới được code lại, các thuật ngữ cũng được lược giản lại để dễ đọc và hiểu hơn.

Xin chào tam giác

Chúng ta sẽ bắt đầu với chương trình mẫu sau: HelloTriangle.cpp

Sử dụng các hàm được định nghĩa sẵn dành cho framework ES

Trong chương trình mẫu ta nhận thấy các hàm được khai báo trong hàm WinMain như sau:

    ESContext esContext;
    UserData  userData;

    esInitContext(&esContext);
    esContext.userData = &userData;

Biến giá trị ESContext có biến thành phần là userData là một con trỏ void*. Chương trình mẫu này sẽ dùng thông tin được lưu trữ trong userData. Hàm esInitialize được gọi để khởi tạo ngữ cảnh và framework (khung chương trình) ES. Các dữ liệu thành phần khác trong ESContext bao gồm độ rộng và chiều cao cửa sổ, ngữ cảnh EGL, và con trỏ hàm gọi ngược (callback function pointer). Việc ESContext nằm trong file header khiến các chương trình mà dựa nó vào chỉ có thể xem. 

Các hàm còn lại trong chương trình WinMain dùng để tạo cửa sổ, khởi tạo gọi ngược hàm vẽ, vào trong vòng lặp chính.

    esCreateWindow(&esContext, "Hello Triangle", 320, 240, ES_WINDOW_RGB);

    if (!Init(&esContext))
        return 0;

    esRegisterDrawFunc(&esContext, Draw);

    esMainLoop(&esContext);

Hàm esCreateWindow tạo cửa sổ có chiều dài và chiều rộng là 320*240. Chúng ta sẽ có phần nói chuyên sâu hơn về EGL, ở đây chúng ta chỉ cần biết là hàm này tạo mặt phẳng render. Ở đây chúng ta gọi ES_WINDOW_RGB cho bit field (các bit nằm liền kề).

Sau khi gọi esCreateWindow, hàm WinMain sẽ gọi hàm Init chạy các thứ thực sự của chương trình này. Cuối cùng nó ghi nhận hàm callback là Draw để render lên trên. Kết thúc nó sẽ tiếp tục gọi hàm esMainLoop báo hiệu cho thấy sẽ tiếp lặp lại cho đến khi cửa sổ được đóng lại.

Vertex và Fragment shader đơn giản

Phân tích đoạn code vertex shader sau:

    GLbyte vShaderStr[] =
        "attribute vec4 vPosition;    \n"
        "void main() {                \n"
        "   gl_Position = vPosition;  \n"
        "}                            \n";

Giải thích:

Đầu vào attribute là một vector có 4 thành phần tên vPosition. Sau đó hàm Draw sẽ gửi các vị trí của đỉnh, mà những vị trí đó sẽ bị thay thế trong biến này. Nội dung shader rất đơn giản, nó copy đầu vào vPosition vào trong biến gl_Position. Mỗi đỉnh - vertex shader phải xuất ra kết quả cho gl_Position.

Trong phần khác của bài biết mình sẽ nói rõ hơn về Vertex shader.

Phân tích đoạn code fragment shader sau:

    GLbyte fShaderStr[] =
        "precision mediump float;\n"\
        "void main() {                                \n"
        "  gl_FragColor = vec4 ( 1.0, 1.0, 0.0, 1.0 );\n"
        "}                                            \n";

Giải thích:

Lệnh đầu tiên của fragment shader chỉ độ chính xác của biến float trong shader. Giá trị của đầu ra của gl_FragColor là một bộ chỉ số có giá trị (1.0, 1.0, 0.0, 1.0). gl_FragColor là định dạng dữ liệu được xây dựng sẵn nằm mục đích lưu trữ màu sắc của fragment shader.

Trong phần khác của bài biết mình sẽ nói rõ hơn về fragment shader.

Lưu ý rằng đây chỉ là chương trình mẫu. Trên thực tế, các ứng dụng đời thực thì shader sẽ nằm trên file riêng lẻ và được load bằng các API, chứ không như chương trình mẫu là shader được nhúng hoàn toàn trong chương trình.

Biên dịch và loading shader

Bây giờ chúng ta tiếp tục phân tích đoạn code load (tải vào chương trình) shader. Hàm LoadShader trong chương trình sẽ load shader rồi biên dịch nó, sau đó kiểm tra xem nó có lỗi phát sinh hay không. Tiếp theo nó trả về là một shader object (đối tượng), cái mà sau đó GLES sẽ đính nó vào trong program object. Phần này mình sẽ nói rõ hơn trong những phần sau. Hàm glCreateShader sẽ tạo shader object với kiểu dữ liệu specified (đặc trưng).

GLuint LoadShader(GLenum type, const char* shaderSrc)
{
    GLuint shader;
    GLint compiled;

    shader = glCreateShader(type);

    if (shader == 0)
        return 0;

Source code shader bản thân nó đã được load tới shader object bằng hàm glShaderSource. Shader được biên dịch bằng hàm glCompileShader.

    glShaderSource(shader, 1, &shaderSrc, NULL);

    glCompileShader(shader);

Sau khi biên dịch, tình trạng sau khi biên dịch nếu có lỗi sẽ được in ra.

    glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);

    if (!compiled) {
        GLint infoLen = 0;

        glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &infoLen);

        if (infoLen > 1) {
            char* infoLog = (char*)malloc(sizeof(char) * infoLen);

            glGetShaderInfoLog(shader, infoLen, NULL, infoLog);
            esLogMessage("Error compiling shader:\n%s\n", infoLog);

            free(infoLog);
        }

        glDeleteShader(shader);
        return 0;
    }

    return shader;
}

Nếu shader được biên dịch thành công, shader sẽ được trả về sau đó. Trong phần khác của bài biết mình sẽ nói rõ hơn về hàm shader object.

Tạo Program Object và link các shader

Sau khi đã tạo shader object cho vertexfragment shader, nó cần phải tạo ra program object. Về mặt ý tưởng, chương trình - program object là là chương trình được link cuối cùng. Với mỗi shader được biên dịch thành shader object, chúng phải được đính vào trong program object và được link cùng nhau trước khi được vẽ.

Bây giờ chúng ta chỉ quan tâm đến việc tìm hiểu tổng quan. Chúng ta sẽ tìm hiểu sâu hơn ở các phần khác. Bước đầu tiên là tạo program object và đính vertexfragment shader với nó.

    programObject = glCreateProgram();

    if (programObject == 0)
        return 0;

    glAttachShader(programObject, vertexShader);
    glAttachShader(programObject, fragmentShader);

Một khi hai shader được đính vào, bước tiếp theo là ứng dụng được set vị trí của vertex shader thuộc tính vPosition.

glBindAttribLocation(programObject, 0, "vPosition");

Bây giờ chúng ta chỉ cần biết rằng hàm glBindAttribLocation bind (gắn vào) thuộc tính vPosition được khai báo trong vertex ở vị trí là 0. Sau đó chúng ta mô tả dữ liệu của vertex, location này được dùng để mô tả position.

Cuối cùng thì chúng ta link chương trình và kiểm tra lỗi

Sau hết các bước trên chúng ta đã biên dịch được shader, kiểm tra lỗi biên dịch, tạo program object, và kiểm tra lỗi link. Sau khi biên dịch thành công, chúng ta có thể dùng chương trình này để render. Để dùng program object cho render, chúng ta bind nó vào glUseProgram.

glUseProgram(userData->programObject);

Thiết lập viewport và dọn sạch buffer màu sắc

Bây giờ chúng ta tạo mặt phẳng render với EGL, khởi tạo nó và load shader, bây giờ chúng ta sẽ thực sự vẽ thứ gì đó lên. Hàm đầu tiên được gọi là hàm glViewPort, nó sẽ khai báo GLES gốc tọa độ, chiều dài và chiều rộng của mặt phẳng hai chiều 2D dùng để render. Trong GLES, viewport là sẽ định nghĩa một khung hình chữ nhật dùng để thể hiện tất cả các đối tượng đồ họa lên màn hình.

glViewport(0, 0, esContext->width, esContext->height);

Viewport sẽ được định nghĩa bởi gốc tọa độ (x, y) và chiều rộng, chiều dài. Trong các phần sau chúng nói rõ hơn về hệ tọa độ và clipping (cắt xén khung hình).

Sau khi thiết lập viewport, bước tiếp theo là xóa màn hình hiển thị. Trong GLES, có nhiều loại buffer dùng để vẽ gồm màu sắc, độ sâu và khuôn tô. Chúng ta sẽ có phần nói rõ hơn về nó. Còn ở ví dụ này, chỉ có buffer màu sắc được vẽ. Với mỗi frame (khung hình) mới bắt đầu, chúng ta clear (dọn sạch) buffer màu sắc bằng hàm glClear.

glClear(GL_COLOR_BUFFER_BIT);

Buffer sẽ được clear với màu sắc được xác định bằng màu với hàm xác định là glClearColor. Trong chương trình mẫu này, vào cuối hàm Init, clear màu được thiết lập với giá trị RGB và alpha là (0.0, 0.0, 0.0, 0.0) nên hình ảnh sẽ là màu trắng. Màu sắc này sẽ được thiết lập trước khi hàm glClear được gọi cho buffer màu sắc.

Load hình học và vẽ hình nguyên thủy

Bây giờ sau khi dọn sạch buffer màu sắc, thiết lập viewport, load program object. Cái chúng ta cần là xác định giá trị hình học của tam giác. Các giá trị của đỉnh tam giác được định nghĩa với ba giá trị là (x, y, z) trong mảng vVertices.

    GLfloat vVertices[] = {
        0.0f,  0.5f, 0.0f,
       -0.5f, -0.5f, 0.0f,
        0.5f, -0.5f, 0.0f
    };
...
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, vVertices);
    glEnableVertexAttribArray(0);

    glDrawArrays(GL_TRIANGLES, 0, 3);

Vị trí đỉnh cần để load đến GLES và được kết nối với thuộc tính vPosition được định nghĩa trong vertex shader. Như các bạn đã biết, khi chúng ta set giá trị vPosition có thuộc tính giá trị bằng 0. Với mỗi thuộc tính trong vertex shader có vị trí được xác định là độc nhất là một giá trị unsigned integer. Để load dữ liệu đến thuộc tính vertex 0, chúng ta gọi hàm glVertexAttribPointer. Chúng ta sẽ nói rõ hơn trong những phần sau.

Bước cuối là chúng ta sẽ gọi GLES để vẽ hình nguyên thủy (trong trường hợp này là một hình tam giác). Hình sẽ được vẽ bằng hàm glDrawArrays. Hàm này có thể vẽ các hình nguyên thủy chẳng hạn như tam giác, đường thẳng hay hình theo dải (strip). Các phần sau chúng ta sẽ nói rõ hơn.

Hiển thị back buffer

Chúng ta cuối cùng cũng có được những điểm của tam giác được vẽ thành các framebuffer (khung hình tạm). Một chi tiết nữa chúng ta cần quan tâm là: làm thế nào để hiển thị framebuffer. Trước khi nói về nó, ta cùng tìm hiểu khái niệm double buffering (bộ tạm đôi).

Framebuffer được hiển thị trên màn hình bởi mảng hai chiều 2D của dữ liệu điểm ảnh. Một cách mà chúng ta có thể hình dung về hình ảnh trên màn hình một cách đơn giản là chúng ta cập nhật các điểm ảnh trong framebuffer có thể nhìn thấy được. Tuy nhiên một vấn đề xảy ra là các điểm ảnh trên buffer hiển thị. Nó là một hệ thống hiển thị đặc trưng, một màn hình hiển thị vật lý mà được cập nhật từ bộ nhớ framebuffer bị cố định (về lưu lượng lưu trữ). Nếu như vẽ trực tiếp vào framebuffer, người dùng sẽ nhìn thấy từng mảng của hình ảnh framebuffer của cập nhật một cách rời rạc khi đang hiển thị.

Để giải quyết vấn đề, một cách làm được nhiều người biết đến double buffering. Trong trường hợp này là front buffer (buffer trước) và back buffer (buffer sau). Tất cả render đều diễn ra ở back buffer, đó là nơi mà nó nằm ở vùng nhớ sẽ không được hiển thị trên màn hình. Khi render xong, buffer này sẽ được hoán đổi với front buffer. Front buffer lúc này sẽ thành back buffer chuẩn bị cho khung hình kế tiếp.

Để sử dụng kỹ thuật này chúng ta sẽ không hiển thị mặt phẳng hiển thị cho đến khi mọi render được hoàn tất cho một khung hình. Nó sẽ được điều khiển thông qua EGL. Hàm EGL được gọi trong trường hợp này là eglSwapBuffers:

eglSwapBuffers(esContext->eglDisplay, esContext->eglSurface);

Hàm này sẽ gọi EGL hoán đổi front bufferback buffer. Tham số được gửi đến là EGL display (màn hình hiển thị) và surface (mặt phẳng hiển thị). Hai tham số này thể hiện màn hình vật lý và mặt phẳng render. Trong phần tiếp theo chúng ta sẽ tìm hiểu rõ hơn về nó.

Còn bây giờ chúng ta cuối cùng cũng có một tam giác trên màn hình rồi!

Comments


Comments are closed