OpenGL 4.3 rendering pipeline (phần 1)
Rendering pipeline là một loạt các bước cố định mà OpenGL thực hiện để “biến” dữ liệu đầu vào thành những gì mà bạn thấy trên màn hình phẳng trước mặt.
Đây là mục tiêu của chúng ta:
Thực tế là:
Và đây là những gì có trong hộp đen “rendering pipeline”, chúng ta sẽ giải thích từng bước một trong hình.
Programmable pipeline
Fixed function pipeline
Ta sẽ lần lượt nói về 2 pipelines trên, nhưng trong loạt tutorials OpenGL 4.3 này, chúng ta chỉ sử dụng programmable pipeline.
Về programmable pipeline
0. Vertex specification
Ở bước này, việc của chúng ta là cấp dữ liệu đầu vào cho OpenGL. Dữ liệu này có thể là dữ liệu liên quan tới đỉnh (vertex) của các hình cơ bản - tam giác, tứ giác, hoặc chỉ đơn giản là 2 đỉnh (vertices) của một đoạn thẳng. Chúng ta chỉ cần cung cấp tọa độ điểm, và (kèm với một số yếu tố khác), OpenGL sẽ lập ra các tam giác/tứ giác dựa trên những điểm này (việc này sẽ xảy ra ở bước Primitive assembly, xem ở dưới).
Tóm lại, ở bước này, dữ liệu sẽ được đưa vào pipeline.
Sau bước này, chúng ta sẽ thấy:
const GLfloat vertices[] = {
1.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f,
0.0f, 0.0f, 1.0f,
};
1. Vertex shader
Có thể, bạn đã nghe ở đâu đó 2 cụm từ “vertex shader” và “pixel shader”. Trong OpenGL, “pixel shader” được gọi là “fragment shader”, nó mang nghĩa tương tự. Đây là một trong 2 giai đoạn bắt buộc trong bất cứ chương trình OpenGL nào.
Trong giai đoạn (stage) này (vertex shader), dữ liệu đầu vào (dữ liệu về vị trí của các vertices), sẽ được tính toán để cho ra vị trí cuối cùng của vertices. Well, các vertices này chưa thể nằm được trên màn hình người sử dụng, ít nhất là cho đến giai đoạn “Rasterization” (sẽ nói sau).
Bạn hỏi: “Tại sao không dùng ngay vị trí của vertices ở dữ liệu đầu vào làm dữ liệu tọa độ. Tức là sao không dùng ngay vị trí ở dữ liệu đầu vào đê xác định tọa độ vertices, mà phải cho vào vertex shader để xử lý, rất mất công?”. Vì dữ liệu đầu vào chỉ là tương đối. Có thể, sau này bạn sẽ sửa lại dữ liệu đó. Có nghĩa là, bạn đưa dữ liệu đầu vào là tọa độ của 1 cái hộp, không bị biến dạng. Nhưng bạn muốn nhìn cái hộp đó từ một góc 45 độ, nghĩa là, lúc đó tọa độ các vertices của cái hộp sẽ phải thay đổi, nghĩa là cái hộp sẽ phải bị biến dạng. Như vậy, dữ liệu đầu vào cần được “biến đổi” (transformed) sao cho khi xuất hiện trên màn hình, nó sẽ được đặt ở vị trí phù hợp với góc 45 độ mà bạn muốn nhìn. Như vậy, chúng ta sẽ nhận dữ liệu đầu vào là tọa độ gốc của cái hộp, transform tọa độ đó thành tọa độ mới mà bạn muốn nhìn, và đó là công việc của vertex shader - tính toán vị trí cuối cùng của vertices. Việc transform tọa độ vertices mình sẽ nói ở một bài riêng (cụ thể, ờ phần 2).
Vì vertex shader chỉ process dữ liệu về tọa độ vertices, những dữ liệu khác như colors, normals, textures..sẽ không mất đi mà chỉ đơn giản là bị bỏ qua, nghĩa là vertex shader sẽ truyền thẳng (pass-through) các dữ liệu này cho các stages sau mà không đá động gì tới chúng. Một ví dụ điển hình là fragment shader sẽ phụ trách dữ liệu liên quan tới color và texture của một vertex.
Tóm lại, ở giai đoạn này, vertex shader sẽ biến đổi dữ liệu tọa độ đầu vào thành dữ liệu tọa độ cuối cùng, không thể thay đổi được nữa. (Well..không hẵn, hãy chuyển qua bước kế tiếp).
Sau bước này, chúng ta sẽ thấy (lưu ý, đây không phải là những gì sẽ xuất hiện trên màn hình, chỉ là tượng trưng)
2. Tessellation
Như trên mình đã nói, một chương trình OpenGL bắt buộc phải có 2 shader stages là vertex shader và fragment shader. Sau giai đoạn vertex shader, sẽ không có một giai đoạn nào nữa để transform vị trí của các vertices đầu vào. Tuy nhiên, nếu ta sử dụng thêm stage tessellation, data về vertices có thể được thay đổi một lần nữa.
Tessellation là quá trình chia nhỏ các vertices. Thay vì phải tự tay sắp đặt các vertices nhỏ và rất nhỏ để tạo được độ chi tiết (detail, realistic, v..v..) cho model, OpenGL sẽ tự động làm việc này. Tessellation tiết kiệm được rất nhiều thời gian cho artists/level designers khi phải thiết kế nhân vật/màn chơi, cũng như giúp giảm tải GPU do không phải gọi API liên tục với số lượng lớn. Đây là một hình ảnh so sánh:
Nếu có mặt tessellation shader, vertices sau khi qua xử lý từ stage trước (vertex shader) sẽ tiếp tục được xử lý bởi tessellator để cho ra hình thù cuối cùng.
- Khác với các kiểu dữ liệu cơ bản của OpenGL, vốn dùng tam giác, đoạn thẳng và tứ giác, tessellation sử dụng “patches” để mô tả hình dáng của một vật thể. Patch, nói một cách đơn giản nhất, là một danh sách các vertices có thứ tự. Patches chỉ được sử dụng trong tessellation stage, không thể sử dụng patches trong các stage như vertex và fragment (vì chúng chỉ hoạt động trên các geometric primitives cơ bản của OpenGL đó là đoạn, tam giác, tứ giác).
2.1. Tessellation control shader (TCS)
Giai đoạn này chưa thực sự “tessellate” các vertices. Có thể gọi là “tiền tessellate”. Theo wiki OpenGL, nhiệm vụ của Tessellation Control Shader là:
- Quyết định bao nhiêu geometric primitives có thể “lôi” ra được từ những patches đầu vào.
- Thực hiện biến đổi trên các patches đầu vào, vì primitive geometry của tessellation shader là patches, không phải là primitive thường của OpenGL. Chúng bao gồm thêm, bớt patches/nội dung patches (là các primitive geometry của OpenGL), và trả chúng cho stage (2.2) (bên dưới).
Bước này không bắt buộc.
2.2. Tessellate
Ở bước này, OpenGL sẽ tiến hành “tessellate” dựa trên patches đầu vào để tạo ra các primitives mới.
2.3. Tessellation evaluation shader
Bước này sẽ sử dụng kết hợp dữ liệu ra của bước (2.1) và dữ liệu từ sau giai đoạn tessellate (2.2) để tính toán vị trí cuối cùng của vertices. Bước này là bắt buộc vì tọa độ cuối cùng của các vertices sẽ được xuất ra.
Sau giai đoạn này, bạn sẽ thấy:
3. Geometry shader
Trong giai đoạn này, các primitives mới sẽ được tạo ra từ primitives đầu vào. Khác với vertex shader stage chỉ truy cập duy nhất 1 vertex (per-vertex) trong 1 lần chạy shader, geometry shader có thể truy cập một geometric primitive (per-vertex hoặc multi-vertex) và do đó, có thể thực hiện xóa bỏ, tạo, chỉnh sửa các primitives này.
Sau bước này, bạn sẽ thấy:
4. Primitive assembly
Vì dữ liệu chúng ta truyền vào chỉ là tọa độ điểm của các vertices, nên trong stage này, OpenGL sẽ tìm cách nối các điểm đó lại để tạo thành các primitives cơ bản, có thể là điểm, đoạn thẳng, tam giác, hoặc tứ giác.
Sau bước này, bạn sẽ thấy:
5. Post-vertex processing
Đây là một nhóm các bước, nhưng chúng ta chỉ nói riêng clipping.
Ở bước này, OpenGL sẽ cắt bớt những gì nằm bên ngoài khung nhìn (viewport).
Khung nhìn mặc định là hình chữ nhật áp sát màn hình của bạn, và bị giới hạn bởi cửa sổ nơi mà việc render được thực hiện.
Vị trí và kích thước khung nhìn được định nghĩa bởi 1 API call, “glViewport()”, nhưng nếu không gọi, thì viewport mặc định là toàn bộ hình chữ nhật trong phạm vi cửa sổ chương trình.
Clipping được thực hiện tự động bởi OpenGL.
Tóm lại, ở bước này, những vertices nằm ngoài viewport sẽ bị xóa bỏ và các vertices liên quan tới vertices bị xóa sẽ được điều chỉnh và tạo thành primitive mới sao cho khớp, giống như đang bị cắt thật sự. Nói một cách chính xác hơn, geometry sẽ được cắt khỏi view frustum. Mình sẽ nói rõ hơn về vấn đề này ở phần 2.
Sau bước này, bạn sẽ thấy:
6. Rasterization
Vậy là công việc với vertices đầu vào đã hoàn tất. Việc tiếp theo là biểu diễn vị trí các vertices này trên màn hình. Đó là công việc của rastersizer.
Rastersizer sẽ nhận tọa độ vertices đầu vào (đã được transformed từ các stages trước) và generates fragment(s) cho các vertices đó.
Nói thêm về fragment:
- Dữ liệu thuộc về một pixel chỉ bao gồm màu RGBA. (RGB và thêm một 8-bit alpha channel).
- Dữ liệu thuộc về một fragment bao gồm tất cả những thông tin cần thiết để sinh pixel(s).
Sau bước này, bạn sẽ thấy:
7. Fragment shader
Stage thứ hai và cũng là stage bắt buộc cuối cùng trong rendering pipeline.
Trong stage này, fragment shader sẽ lấy từng fragment từ rastersizer và tính toán các giá trị depth, stencil và màu của fragment đó. Trong stage này, màu của một fragment sẽ được quyết định, dù trong stage sau (8), màu có thể bị biến đổi một lần nữa.
Một điểm mạnh của fragment shader là chúng có thể được dùng với kĩ thuật texture mapping để áp texture lên bề mặt vật thể. Thay vì tô màu, chúng ta có thể “tô” texture.
Tóm lại, trong giai đoạn này, màu của một fragment sẽ được quyết định. Lưu ý, fragment khác với pixel (đọc lại (6)).
Sau bước này, bạn sẽ thấy:
8. Per-fragment/per-sample processing, testing, blending
Trong stage trước, như đã nói, output của fragment shader là tính toán màu, depth và stencil.
Các fragments đầu vào cho stage này sẽ phải qua một loạt tests. Nếu thành công ở tất cả tests, một fragment sẽ trở thành pixel và có một chỗ riêng trên framebuffer (màn hình), và các giá trị của nó (màu, depth) sẽ được viết vào các buffer tương ứng (color buffer và depth buffer).
Các tests mà một fragment phải trải qua bao gồm:
- Scissor test: Kiểm tra một fragment có nằm ngoài vùng render không. Nếu nằm ngoài, fragment sẽ bị loại (discarded). Nếu fragment bị loại, các tests sau cho fragment đó sẽ không được thực hiện. Vùng render được xác định bằng glScissor().
- Stencil test: Kiểm tra một fragment có nằm ngoài vùng render không. Nghe có vẻ giống với scissor test. Nhưng ta dùng stencil buffer/test, ví dụ, để render lên một cái cửa kính xe chẳng hạn, và tất cả những gì nằm ngoài cái cửa kính xe sẽ không được rendered. Ngược lại với scissor test, chỉ hoạt động với các vùng vuông, chữ nhật. Ta có thể dùng scissor để chia viewports ra làm nhiều vùng (giống split-screen co-op shooter), và render riêng trên mỗi vùng được chia ra, thì lúc này scissor test sẽ có ích.
- Depth test: Test độ sâu của một fragment. Depth của một fragment nếu bé hơn giá trị của cùng một điểm trong depth buffer sẽ bị discarded.
- <…>
Những fragment còn sống sọt sẽ là những gì sẽ được render lên màn hình.
Về fixed function pipeline
[!] Bạn nên đọc programmable pipeline trước khi đọc fixed function pipeline, vì chúng ta sẽ không dùng fixed function pipeline nên mình sẽ rút lại phần này còn rất ngắn, chỉ nói sơ qua.
Fixed function pipeline là pipeline cũ dùng từ legacy OpenGL (1.5 trở về trước), lúc mà phần cứng còn hạn chế. Ở programmable pipeline, chúng ta được tự do quyền kiểm soát các stage vertex, fragment, tesellation và geometry, nên ta có thể biến đổi vertices/fragments/.. tùy ý muốn, thông qua ngôn ngữ đổ bóng của OpenGL - GLSL (OpenGL Shading Language). Ví dụ, vì fragment shader có thể điều khiển được màu của fragment, ta có thể sử dụng nó để viết các post-process effects như HDR, Bloom, tone mapping, depth of field (chắc chắn ít nhiều bạn cũng đã từng nghe tới những khái niệm này trong games). Tương tự với vertex shader và tessellation shader, nên pipeline của modern OpenGL mới có tên gọi “programmable pipeline”.
Fixed function, một cách ngược lại, chúng ta không có quyền điều khiển các shader stages, mà chúng sẽ được điều khiển trực tiếp bới GPU. Như vậy, ta không có quyền điều khiển các stages này, đồng nghĩa với việc không có những hiệu ứng post-process (nói ở trên), normal mapping (sử dụng normals của vật thể để tạo hiệu ứng bề nổi), shadow mapping (bóng đổ), v..v….
Hầu hết những GPUs hiện nay đều có hỗ trợ programmable pipeline.