Donald Knuth nói câu đó vào năm 1974, trong một bài báo về structured programming, và từ đó đến nay nó bị hiểu sai nhiều hơn hiểu đúng. “Premature optimization is the root of all evil.”, dịch ra tiếng Việt cho nghe ngầu một chút: tối ưu hóa sớm là nguồn gốc của mọi tội lỗi. Câu này hay. Câu này đúng. Và câu này cũng bị dùng như một cái lá chắn bởi những dev không chịu suy nghĩ trước khi gõ. Knuth không nói đừng nghĩ đến hiệu năng, ông nói đừng tối ưu những chỗ chưa chắc là vấn đề, chưa đo đạc gì, rồi làm code xấu đi mà chẳng được gì. Nhưng nếu anh đang xây một game engine, một pipeline render, một hệ thống animation cho vài trăm nhân vật cùng lúc trên màn hình thì cái nền tảng kiến trúc anh đặt xuống từ ngày đầu sẽ quyết định anh có thể đi bao xa. Không phải tháng sau, không phải sau khi ra bản alpha, mà ngay từ ngày anh đặt phím viết dòng code đầu tiên.
Hai chuyện đó không mâu thuẫn nhau. Đừng micro-optimize sớm, nhưng phải macro-design đúng ngay từ đầu. Sự khác biệt giữa hai chuyện đó là toàn bộ khoảng cách giữa một game chạy mượt 60fps trên máy tầm trung và một cái đống code ngốn RAM, giật khung, mà vá mãi không hết. Vấn đề là tôi thấy ngày càng nhiều dev trẻ không phân biệt được hai thứ đó. Họ nghe Knuth, họ nghĩ “cứ viết đi, sau tối ưu sau.” Rồi họ ra một kiến trúc mà mọi thứ đều coupled với nhau, luồng dữ liệu chạy loạn, object được tạo ra rồi destroy trong mỗi frame như pháo hoa, và khi game chạy chậm thì họ bắt đầu ngồi đoán. Cái này chắc tốn nhiều CPU, cái kia chắc bị memory leak, thôi thêm cache vào đây xem sao. Shader này chắc nặng quá. Đoán mò, tối ưu mò. Và code thì ngày càng thêm lớp duct tape cho đến khi không ai hiểu nó đang chạy cái gì nữa.
Cái đó không phải tối ưu. Đó là vá víu. Muốn tối ưu thật sự, bước đầu tiên không phải là ngồi viết code khác đi, bước đầu tiên là mở profiler ra và nhìn vào mặt thật của vấn đề. Profiler không phải công cụ cao siêu dành cho game studio lớn. Trong Unity có Profiler built-in, Unreal có Unreal Insights, còn nếu anh làm game engine riêng thì GPU vendor nào cũng có tool của họ: RenderDoc, PIX trên Windows, Arm Mobile Studio cho Android, Xcode Instruments cho iOS. Không có lý do gì để đoán khi có công cụ đo được. Không có lý do gì. Vậy mà tôi vẫn thấy người ta ngồi tối ưu shader vì “trông có vẻ nặng” trong khi bottleneck thật sự nằm ở CPU đang chờ GPU xong việc, hoặc ngược lại GPU đứng không trong khi CPU đang vật lộn với một đống physics calculation viết tệ.
CPU-bound và GPU-bound là hai bệnh hoàn toàn khác nhau, và thuốc của hai bệnh đó không dùng chung được. Nếu game của anh bị CPU-bound, tức là CPU không kịp đẩy đủ lệnh cho GPU, thì dù anh có viết shader đẹp cách mấy cũng không cứu được frame rate. Ngược lại, nếu bị GPU-bound, tức là GPU đang xử lý quá nhiều thứ, thì giảm tải CPU không làm được gì. Profiler sẽ cho anh thấy frame time phân bổ ở đâu, cái gì đang chờ cái gì, chỗ nào thật sự là nút cổ chai. Một trong những nút cổ chai kinh điển ở phía CPU trong render pipeline là draw call. Mỗi lần anh ra lệnh cho GPU vẽ một mesh, một sprite, một object, đó là một draw call. Mỗi draw call đi kèm với overhead: CPU phải chuẩn bị state, gửi lệnh qua API, rồi GPU mới bắt đầu làm việc. Nếu anh có một cái scene với vài nghìn object nhỏ mà mỗi cái là một draw call riêng, CPU của anh sẽ không kịp thở. Đây không phải vấn đề lý thuyết, đây là thứ giết chết hiệu năng của vô số game mobile của các project mà tôi từng thấy qua tay. Static Batching và Dynamic Batching ra đời để giải quyết đúng vấn đề này, ý tưởng cơ bản của nó rất thực tế: thay vì gửi một nghìn draw call cho một nghìn object, gộp chúng lại thành một mesh lớn hơn và vẽ một lần. Static Batching phù hợp với những object không di chuyển trong scene, nền nhà, tường, cây cối đứng im đó. Unity sẽ gộp tất cả geometry của chúng lại trong quá trình build, và khi render chỉ cần một draw call duy nhất cho cả đống. Anh không trả giá runtime vì việc gộp đã làm sẵn. Nhưng anh trả giá bộ nhớ vì toàn bộ baked mesh đó phải nằm trong RAM.
Dynamic Batching khác ở chỗ nó làm việc đó trong runtime, với những object nhỏ đang di chuyển, nhưng có điều kiện ngặt nghèo hơn. Object phải dùng cùng material. Vertex count phải nhỏ. Không được có scale khác nhau, nếu không thỏa điều kiện thì engine sẽ không batch được và anh vẫn phải trả từng draw call riêng lẻ.
Còn có GPU Instancing, một hướng khác: thay vì gộp geometry, anh vẽ cùng một mesh nhiều lần nhưng chỉ gửi một draw call với danh sách transform khác nhau. Phù hợp khi có nhiều bản copy của cùng một object, rừng cây, đám thùng phuy, bầy quái vật cùng loại. GPU nhận một lần, rồi nó tự lo vẽ đủ số lượng theo từng vị trí được truyền vào.
Nhưng tất cả những kỹ thuật này, Static Batching, Dynamic Batching, GPU Instancing, chúng chỉ phát huy tác dụng tối đa khi anh thiết kế asset và scene từ đầu có tính đến chúng. Nếu đến cuối dự án anh mới ngồi ôm đống asset lộn xộn với vài chục material khác nhau, vài trăm object có scale tùy tiện, rồi cố nhét batching vào thì đó là công việc của người đến sau dọn rác. Tốn thời gian, hiệu quả thấp, và không bao giờ sạch hoàn toàn. Đây mới là điều Knuth không nói đủ rõ, hoặc người ta cố tình không nghe: tối ưu sớm ở mức vi mô thì đúng là nên tránh, nhưng tư duy hiệu năng ở mức kiến trúc phải có ngay từ ngày đầu. Luồng dữ liệu của anh chạy thế nào qua hệ thống? Memory access pattern có cache-friendly không? Object lifecycle được quản lý ra sao? Những câu hỏi đó không phải micro-optimization. Đó là nền móng. Và nền móng thì không sửa sau khi đã xây nhà lên.
Mánh khóe quản lý tài nguyên – Virtual textures và culling
Có một sự thật mà ít ai nói thẳng: VRAM không bao giờ đủ. Không phải hồi năm 2000 với 32MB VRAM, không phải bây giờ với 8GB hay 16GB, thậm chí 24GB. Game luôn muốn nhiều hơn những gì phần cứng cho phép, và phần lớn lịch sử tối ưu đồ họa là lịch sử của việc lách qua giới hạn đó mà không để người chơi nhận ra.
Virtual Textures là một trong những mánh khóe đẹp nhất trong cái lịch sử đó. Ý tưởng nền tảng của nó không có gì mới, thật ra thì nó mượn trực tiếp từ khái niệm virtual memory của hệ điều hành, thứ mà các kỹ sư máy tính đã dùng từ thập niên 60 để giả lập một không gian địa chỉ bộ nhớ lớn hơn RAM vật lý thực sự có. Thay vì nhốt toàn bộ texture của một scene khổng lồ vào VRAM, virtual texture system chia toàn bộ khối dữ liệu đó thành các tile nhỏ, thường là 128×128 hoặc 256×256 pixel, rồi chỉ nạp vào VRAM những tile nào camera thật sự đang nhìn vào, ở mức độ detail phù hợp với khoảng cách. VRAM lúc này đóng vai trò của một cái cache. Tile nào đang cần thì nằm trong cache, tile nào không cần thì đẩy xuống RAM hệ thống, hoặc ra ổ cứng nếu cần. Khi camera di chuyển, hệ thống tính toán xem tile nào sắp cần, nạp trước chúng lên, và đẩy những tile không còn visible xuống để nhường chỗ.
id Software là studio đặt khái niệm này vào tiêu điểm chú ý của cộng đồng game, trong RAGE năm 2011 với công nghệ họ gọi là MegaTexture. Nhưng thứ John Carmack và đội của ông làm không phải là phát minh ra virtual texturing, mà là đẩy nó đến một quy mô mà trước đó chưa ai dám làm trong game thương mại: toàn bộ địa hình của một level được xử lý như một texture khổng lồ duy nhất, có thể lên tới vài gigabyte, và hệ thống streaming tile quyết định phần nào cần render ở frame nào. Kết quả là những vách đá và sa mạc không có texture tiling lặp đi lặp lại, không có seam, không có cái cảm giác “ô tôi thấy cái pattern này rồi” quen thuộc của game thời đó. Cái khó của virtual texturing không nằm ở ý tưởng. Ý tưởng đơn giản. Cái khó là phần feedback loop: GPU cần biết tile nào đang thật sự visible để CPU có thể schedule nạp đúng tile đó lên VRAM trước khi frame tiếp theo cần nó. Nếu tile cần chưa kịp nạp lên, anh sẽ thấy texture bị mờ hoặc pop-in, cái hiện tượng xấu xí mà bất kỳ người chơi nào cũng nhận ra ngay dù không biết tên kỹ thuật của nó. Triển khai đúng đòi hỏi một pipeline streaming không được trễ dù chỉ vài frame, và trên ổ cứng HDD thời cũ thì đó là một bài toán scheduling cực kỳ đau đầu. SSD làm cho chuyện này dễ thở hơn nhiều, nhưng ngày xưa dev phải tính từng mili-giây.
Điều tôi thấy buồn cười là bây giờ nhiều dev dùng Unreal hay Unity với virtual texture support built-in, bật lên như bật công tắc đèn, mà không hiểu cái gì đang chạy bên dưới. Rồi khi có vấn đề streaming, khi texture pop-in xuất hiện, họ không biết bắt đầu debug từ đâu vì chưa bao giờ hiểu cơ chế. Cái công tắc đó che giấu hai mươi năm kỹ thuật. Không phải để anh không cần biết, mà để anh xây lên trên cái nền đó nhanh hơn, với điều kiện anh biết nền đó là gì. Nhưng virtual texturing chỉ giải quyết một phần của bài toán tài nguyên. Phần khác, phần liên quan đến draw call và render pipeline, đòi hỏi một triết lý khác: đừng vẽ những gì người chơi không thấy.
Nghe thì hiển nhiên. Thực tế thì không đơn giản vậy. GPU không tự biết cái gì visible, cái gì không. Nếu không có gì can thiệp, mặc định pipeline sẽ xử lý mọi geometry anh đưa vào, kể cả những mesh đang nằm sau lưng camera, sau một bức tường dày, dưới một tầng hầm mà camera không bao giờ nhìn xuống. Frustum culling là bước lọc đầu tiên và đơn giản nhất: loại bỏ mọi object nằm ngoài hình chóp cụt của camera. Nhưng frustum culling không giúp được gì với những object nằm trong tầm nhìn camera nhưng bị che khuất hoàn toàn bởi geometry khác, một tòa nhà đứng trước che toàn bộ khu phố phía sau, chẳng hạn.
Đây là chỗ Occlusion Culling vào cuộc. Occlusion Culling hoạt động dựa trên một quan sát cơ bản: nếu object A hoàn toàn nằm sau object B so với góc nhìn của camera, và B đủ lớn để che hoàn toàn A, thì không cần vẽ A. B được gọi là occluder, A là occludee. Hệ thống occlusion culling làm một việc: trước khi đưa draw call cho GPU, tính toán xem occludee nào bị occluder nào che, rồi loại chúng ra khỏi render list.
Có nhiều cách tiếp cận. Software occlusion culling chạy hoàn toàn trên CPU, rasterize một depth buffer độ phân giải thấp từ góc nhìn camera bằng các occluder được chọn lọc, rồi test từng occludee xem nó có nằm sau depth buffer đó không. Intel có một open-source library cho việc này, Masked Software Occlusion Culling, tận dụng SIMD để làm nhiều phép test song song. Cách này tốt vì không cần roundtrip GPU-CPU, vốn rất tốn kém về latency. Hardware occlusion query thì khác, anh gửi query xuống GPU, GPU render occluder vào depth buffer rồi trả về kết quả xem có pixel nào của occludee pass depth test không. Nhưng cái roundtrip đó, chờ GPU xử lý xong rồi mới biết kết quả, thường kéo dài vài frame, nên phải dùng kết quả của frame trước để cull frame hiện tại, chấp nhận một độ trễ nhỏ. Trong môi trường game open world, occlusion culling phức tạp hơn nhiều so với trong các không gian kín như corridor shooter. Không có tường cố định để làm occluder tin cậy, terrain có thể che hoặc không tùy góc nhìn. Cây cối có geometry phức tạp không phù hợp làm occluder. Đây là lý do nhiều game open world lớn kết hợp nhiều kỹ thuật cùng lúc: precomputed visibility cho các vùng static, dynamic occlusion cho các object di chuyển, và portal system cho interior không gian.
Rồi có LOD, Level of Detail, thứ mà nếu làm đúng thì người chơi không bao giờ nhận ra, và nếu làm sai thì chắc chắc sẽ bị họ sẽ chụp screenshot đăng lên Reddit cười mệt nghỉ. Nguyên lý đơn giản đến mức tàn nhẫn: object ở xa camera không cần nhiều triangle như object ở gần. Một nhân vật ở cách camera 200 mét chiếm chưa tới 50 pixel trên màn hình, không có con mắt người nào phân biệt được anh ta có 50,000 hay 500 triangle. Vậy thì giảm xuống 500, giảm tải GPU, dành tài nguyên cho những thứ thật sự cần. LOD0 là model cao nhất khi ở gần, LOD1 thấp hơn ở tầm trung, LOD2 và LOD3 cho xa, cuối cùng là billboard, một cái flat quad chỉ có một texture, cho những thứ ở rất xa đến mức không ai thấy gì cả. Transition giữa các LOD level là chỗ khó nhất. Nếu chuyển đột ngột thì người chơi thấy mesh pop, object bỗng nhiên thay đổi hình dạng. Dithered LOD transition là một cách giải quyết, fade dần bằng noise pattern để che đi sự chuyển đổi. Cross-fade LOD render cả hai LOD cùng lúc trong một khoảng thời gian ngắn rồi blend chúng, tốn hơn nhưng mượt hơn. Cái nào dùng phụ thuộc vào budget của từng loại object và tầm quan trọng của nó với gameplay.
Khi kết hợp LOD với occlusion culling, anh có một hệ thống mà không chỉ loại bỏ object không visible mà còn hạ thấp độ phức tạp của những object visible nhưng xa. Đây là kiểu tư duy mà tôi hay gọi là “tiêu tiền đúng chỗ”: GPU budget là cố định, câu hỏi là anh phân bổ nó thế nào để người chơi không nhận ra chỗ anh tiết kiệm nhưng lại thấy rõ chỗ anh chi.
Thế hệ chúng tôi biết qua những thứ này không phải từ documentation hay tutorial, mà là từ hardware limit, học từ việc ngồi profile một game đang chạy 22fps trên phần cứng target, biết rằng không được lên đòi thêm phần cứng mạnh hơn, phải tìm cách lách qua cái giới hạn đó bằng mọi thủ thuật có thể. Có cái gì đó thỏa mãn sâu trong xương trong việc push một cái machine đến 95% capacity của nó mà vẫn giữ được 60fps ổn định. Không phải vì thích tra tấn bản thân. Mà vì khi anh hiểu đủ sâu để làm được điều đó, anh biết mình thật sự hiểu cái máy đó đang chạy cái gì. Bây giờ phần cứng mạnh hơn, deadline ngắn hơn, engine lo nhiều thứ hơn. Không phải xấu, nhưng mất đi cái kỷ luật của việc phải hiểu từng draw call đang làm gì, từng MB VRAM đang đựng cái gì. Và khi cái kỷ luật đó mất đi, anh sẽ thấy những game chạy tệ hơn trên phần cứng mạnh hơn so với game cũ chạy trên phần cứng yếu hơn nhiều. Đó không phải tai nạn, đó là hệ quả tự nhiên của việc không hiểu những gì mình đang làm.
Engine mì ăn liền và thảm họa TAA
Có một cái meme trong cộng đồng dev mà tôi thấy ngày càng đúng: game hiện đại chạy tệ hơn game cũ trên phần cứng mạnh hơn gấp nhiều lần. Không phải vì game hiện đại đẹp hơn gấp mười lần, mà vì người ta đang dùng engine như dùng lò vi ba, bấm nút, chờ kết quả, không cần biết bên trong đang xảy ra chuyện gì.
Unreal Engine 5 là một công trình kỹ thuật đáng nể. Nanite, Lumen, World Partition, những thứ đó thật sự ấn tượng khi nhìn vào paper và demo. Vấn đề không phải engine. Vấn đề là khi anh đưa một cái Ferrari cho người chưa biết lái, họ không lái nhanh hơn. Họ đâm vào tường đẹp hơn. Và TAA là cái tường đó. Nhưng được sơn màu đẹp, có biển hiệu ghi “đột phá công nghệ.” Temporal Anti-Aliasing, về mặt lý thuyết, là một ý tưởng thông minh. Thay vì render đủ sample trong một frame tốn kém, TAA tích lũy thông tin từ nhiều frame trước đó, dùng cả quá khứ để làm mượt hiện tại. Anh subsample trong mỗi frame, jitter camera nhẹ để mỗi frame capture pixel ở vị trí hơi khác nhau, rồi blend tất cả lại, về mặt lý thuyết, anh có được chất lượng ảnh cao hơn mà không phải trả giá render đắt hơn. Về mặt thực tế, anh có được một màn hình trông như có ai đó thoa vaseline lên kính. Cơ chế ghosting trong TAA không phải bug. Nó là hậu quả tất yếu của cái triết lý “dùng quá khứ để làm mượt hiện tại.” Khi có object di chuyển nhanh, khi camera xoay đột ngột, history buffer giữ lại thông tin từ vị trí pixel cũ mà giờ không còn đúng nữa. Hệ thống reprojection cố gắng tính toán xem pixel đó ở frame trước tương ứng với pixel nào ở frame này dựa trên motion vector, nhưng motion vector không hoàn hảo, đặc biệt với geometry phức tạp, với alpha transparency, với particle effect, kết quả là cái smear mờ ảo trailing theo sau mọi thứ di chuyển, đặc biệt rõ ràng ở cạnh chi tiết mảnh như tóc, lưới fence, cỏ. Rồi khi scene đứng yên, TAA blur ảnh, không phải là do reprojection sai, mà do jitter. Mỗi frame camera dịch chuyển nhẹ theo pattern, blur này là có chủ đích để accumulate sample. Nhưng kết quả nhìn vào là ảnh mềm hơn native resolution thật sự. Trên màn hình 4K nhìn đỡ, trên 1080p thì rõ ràng thấy được, và trên 1440p upscaled lên 4K thì thôi khỏi nói.
DLSS của Nvidia, FSR của AMD, XeSS của Intel, tất cả đều xây trên nền tảng TAA hoặc concept tương tự. DLSS làm tốt hơn vì dùng neural network được train trên dữ liệu thật để reconstruct detail, nên kết quả sắc nét hơn thuần TAA. Nhưng về cơ bản vẫn là temporal accumulation, vẫn có ghosting trong các edge case, vẫn có artifacts với thin geometry. Truyền thông viết bài “DLSS 3 cách mạng hóa gaming,” dev ngồi làm việc với nó biết rõ phải disable nó trong những hoàn cảnh nào để tránh artifact, phải tune rejection threshold thế nào để cân bằng giữa ghosting và flickering. Không có công nghệ nào là free lunch. Chưa bao giờ có. Cùng thế hệ UE4 trở đi, Screen Space Reflections cũng được tung hô như một bước tiến. Phản chiếu realtime, không cần precomputed cubemap, nhìn vào nước hay sàn đánh bóng thấy cả cảnh vật xung quanh. Đẹp trong screenshot, trong gameplay thì SSR có một giới hạn mà ai cũng thấy nhưng ít người biết tên: nó chỉ reflect những gì đang visible trên screen. Object nào nằm ngoài frustum, object nào bị khuất, reflection sẽ disappear hoặc fade ra fade vào một cách hết sức lịch sự nhưng rõ ràng. Đứng cạnh một vũng nước nhìn xuống thấy trời xanh phản chiếu, rồi xoay camera đi một chút để cái trời đó ra khỏi screen, reflection mất. Đây không phải bug, mà đây là limitation bản chất của screen space, và nó được che bằng fallback về cubemap hoặc fade transition mà anh có thể thấy nếu để ý.
Bloom thì khỏi nói. Bloom trong UE mặc định intensity cao đến mức nhìn như đang chơi game qua cặp mắt cận 800 độ chưa đeo kính. Đây là thứ mà anh phải ngồi tune lại thủ công trong post-process volume nếu muốn kết quả trông như thật. Mặc định thì nó đang bán cái aesthetic của một game năm 2007 nghĩ rằng bloom là thứ tương lai trông như vậy. Nhưng thứ tôi muốn nói thật sự, cái thứ mà tôi thấy dev trẻ làm sai đều đặn đến mức đau mắt, là Hardware Tessellation. Tessellation là một tính năng GPU cho phép chia nhỏ polygon thành nhiều triangle hơn trong pipeline, tạo ra surface detail mà không cần artist model từng triangle đó. Anh có một mesh mái nhà phẳng, tessellation kết hợp với displacement map sẽ đẩy các vertex lên xuống theo height data, tạo ra ngói gồ ghề trông như thật. Nghe hay, và đúng là hay, khi dùng đúng chỗ. Vấn đề là tessellation có thể đẻ ra triangle ở tỉ lệ mà nếu anh không kìm hãm nó, GPU sẽ chết ngay tại chỗ. Không phải ẩn dụ, tessellation factor là một con số anh control, và nếu để nó tự do chạy thì một mesh đơn giản có thể generate ra hàng triệu micro-triangle mà mỗi cái nhỏ hơn một pixel. Triangle nhỏ hơn pixel là thứ vô dụng hoàn toàn, GPU vẫn phải xử lý chúng qua pipeline nhưng chúng không contribute gì cho output image. Đây gọi là triangle thrashing, và nó là một trong những cách hiệu quả nhất để bóp chết GPU mà không nhận lại được gì.
Adaptive tessellation là giải pháp, tự động điều chỉnh tessellation factor dựa trên khoảng cách camera và diện tích pixel mà object chiếm. Object ở gần, tessellation cao. Object ở xa, tessellation giảm hoặc về zero. Không tessellate những gì người chơi không đủ gần để thấy detail. Đây là nguyên tắc giống LOD nhưng áp dụng cho geometry generation trong runtime. Nghe đơn giản, nhưng implementation đúng đòi hỏi anh phải hiểu cách tessellation shader chạy, cách tính toán adaptive factor trong domain shader, cách kết hợp với frustum culling để không tessellate object ngoài view. Nếu anh chỉ bật tessellation trong material rồi để đó, không configure gì thêm, anh đang đặt một quả bom hẹn giờ trong game của mình mà nó sẽ phát nổ vào cái cảnh phức tạp nhất, lúc quan trọng nhất.
Tôi đã ngồi profile một game của một studio nhỏ mà tessellation đang chiếm 40% GPU time. Không phải vì geometry đó đẹp hơn, mà vì developer bật nó lên trên gần như mọi surface trong game, kể cả những tấm ván sàn mà camera không bao giờ nhìn gần hơn 3 mét. Khi hỏi tại sao, câu trả lời là “trông detail hơn.”, tôi không biết nên cười hay nên thở dài.
Vấn đề thật ra không phải là các công nghệ này xấu. TAA khi tune đúng tốt hơn FXAA hay MSAA ở nhiều use case. SSR khi kết hợp đúng với fallback trông ổn, tessellation khi implement đúng thật sự cải thiện surface quality rõ rệt. Vấn đề là chúng được dùng như checkbox, bật lên là xong, rồi tự hỏi sao game chạy chậm và trông vẫn kỳ. Engine hiện đại mạnh không phải vì nó làm mọi thứ cho anh. Nó mạnh vì nó cung cấp infrastructure để anh làm những thứ phức tạp nhanh hơn. Nhưng anh vẫn phải biết mình đang làm gì. Abstraction layer che đi complexity, không xóa bỏ complexity đó. Khi có vấn đề, khi performance sụt, khi visual artifact xuất hiện, anh sẽ phải đào xuống dưới cái abstraction layer đó để hiểu chuyện gì đang xảy ra. Và nếu anh chưa bao giờ nghĩ đến những thứ bên dưới, cái lúc đó sẽ rất đau. Knuth nói đừng tối ưu sớm. Ông không nói đừng hiểu hệ thống mình đang làm việc. Hai chuyện đó rất khác nhau. Và cái sự nhầm lẫn giữa hai chuyện đó đang tạo ra một thế hệ game với visual marketing đẹp nhưng chạy như kéo xe bò trên đường đèo.
Nghệ thuật đánh lừa thị giác bằng Deferred rendering
Có một câu hỏi mà bất kỳ ai làm graphics nghiêm túc cũng phải đối mặt sớm hay muộn: làm sao để một cái thành phố về đêm với hàng nghìn ngọn đèn đường, đèn cửa sổ, đèn xe, neon sign, hiệu ứng nổ, mà GPU không chết ngay từ frame đầu tiên?
Forward rendering truyền thống không trả lời được câu đó một cách thỏa đáng. Trong forward pipeline, mỗi fragment được xử lý phải tính toán contribution của tất cả light source ảnh hưởng đến nó ngay tại thời điểm rasterization. Có mười nguồn sáng thì tính mười lần. Có trăm nguồn sáng thì tính trăm lần. Complexity tăng theo tích của số lượng fragment nhân số lượng light. Anh có một scene đô thị với 500 local light, anh sẽ biết ngay tại sao GPU fan bắt đầu nghe như máy bay cất cánh. Deferred Rendering giải quyết vấn đề này bằng cách tách bài toán ra làm hai giai đoạn hoàn toàn riêng biệt, và sự tách biệt đó thay đổi fundamentally cái cách anh nghĩ về lighting pipeline. Giai đoạn đầu, Geometry Pass, không tính ánh sáng gì cả. Nó chỉ làm một việc: ghi lại thông số bề mặt của từng pixel visible vào một tập hợp texture gọi là G-Buffer. Depth buffer ghi khoảng cách từ camera đến surface tại mỗi pixel. Normal buffer ghi hướng pháp tuyến của surface đó, vector vuông góc với bề mặt, thứ quyết định ánh sáng phản chiếu thế nào. Albedo buffer ghi màu sắc cơ bản của bề mặt trước khi có ánh sáng. Roughness, metallic, specular, ambient occlusion nếu có, tất cả ghi vào đây. Kết thúc Geometry Pass, anh có một bức ảnh đầy đủ thông tin về mọi điểm trên màn hình, nhưng chưa có ánh sáng nào được tính. Giai đoạn hai, Lighting Pass, là lúc phép màu xảy ra, thay vì đi qua từng object một lần nữa, lighting pass chỉ làm việc với G-Buffer đã có. Với mỗi light source, nó sample các buffer cần thiết, tính toán lighting contribution, rồi blend vào final image. Và đây là điểm mấu chốt: complexity bây giờ không còn phụ thuộc vào số lượng object trong scene nữa. Nó phụ thuộc vào diện tích screen mà mỗi light ảnh hưởng đến. Một point light nhỏ trong góc khuất chỉ affect một vùng pixel nhỏ, anh chỉ phải tính lighting cho đúng vùng đó. Một nghìn point light nhỏ rải rác trong thành phố về cơ bản chỉ tốn chi phí render của diện tích pixel tổng cộng mà chúng cover, không phải chi phí của một nghìn lần tính lighting cho toàn bộ scene.
Đây là lý do Deferred Rendering trở thành standard cho mọi game có ambition về lighting từ khoảng 2008 trở đi. Cry Engine, Unreal, Frostbite, tất cả đều đi theo hướng này. Killzone 2 năm 2009 là một trong những game console đầu tiên demo rõ ràng sức mạnh của deferred pipeline với hàng chục dynamic light trong một frame mà hardware lúc đó làm không nổi theo cách khác.
Nhưng Deferred Rendering không phải không có trade-off, và đây là chỗ nhiều dev đọc tutorial xong tưởng hiểu rồi bỏ qua. G-Buffer tốn bandwidth. Nhiều, mỗi frame anh phải write toàn bộ thông tin bề mặt của mọi pixel vào multiple render target, rồi read lại toàn bộ trong lighting pass. Trên desktop GPU với bandwidth cao và VRAM lớn thì đỡ. Trên mobile GPU với unified memory architecture và bandwidth hạn chế thì đây là vấn đề nghiêm trọng, lý do chính khiến mobile game phải dùng các biến thể tiết kiệm bandwidth hơn như Tile-Based Deferred Rendering mà Arm và Apple optimize trong silicon của họ.
Transparency là một vấn đề khác. Deferred Rendering không xử lý được transparent geometry một cách tự nhiên vì G-Buffer chỉ lưu thông tin của fragment gần camera nhất tại mỗi pixel. Particle, glass, alpha-blended surface, tất cả phải xử lý trong một forward pass riêng sau deferred pass chính, tức là anh vẫn có một hybrid pipeline dù muốn hay không. MSAA truyền thống cũng không compatible với deferred pipeline vì antialiasing cần sample nhiều điểm trong mỗi pixel nhưng G-Buffer storage sẽ phình to tương ứng. Đây là một trong những lý do TAA trở nên phổ biến trong deferred-heavy engine, không chỉ vì nó đẹp hơn mà vì nó là giải pháp antialiasing thực tế duy nhất không làm deferred pipeline vỡ. Và bởi vì G-Buffer đã có sẵn depth và normal của mọi pixel trên màn hình, người ta nhận ra đó là mảnh đất màu mỡ để làm một thứ khác mà trước đây rất đắt: SSAO. Screen-Space Ambient Occlusion ra đời từ một quan sát về cách ánh sáng thật sự hoạt động trong thực tế, góc kẹp giữa hai bề mặt, khe hở nhỏ, chỗ mà geometry che nhau, những nơi đó nhận được ít ánh sáng môi trường hơn vì ánh sáng không reach được dễ dàng. Bức tường tiếp xúc với sàn nhà tối hơn ở góc. Nếp nhăn trên bề mặt tối hơn ở đáy nếp. Vật thể đặt gần nhau tạo ra bóng mềm ở chỗ chúng gần nhau nhất. Đây gọi là ambient occlusion, và nó là một trong những yếu tố quan trọng nhất tạo ra cảm giác depth và grounded cho hình ảnh 3D.
Tính ambient occlusion chính xác đòi hỏi raytrace, sample ánh sáng từ hemisphere xung quanh mỗi điểm trên bề mặt xem bao nhiêu phần trăm bị chặn bởi geometry xung quanh. Đắt đến mức không thể làm realtime bằng rasterization. SSAO là một approximation thông minh. Thay vì raytrace thật, nó dùng depth buffer đã có để estimate occlusion. Với mỗi pixel, SSAO sample một tập hợp điểm ngẫu nhiên trong hemisphere dựa trên normal của pixel đó, project từng điểm đó vào screen space, rồi so sánh depth của chúng với depth trong buffer. Nếu sample point nằm bên trong geometry thì nó bị occlude. Càng nhiều sample bị occlude thì pixel đó càng tối. Blur kết quả lại để smooth noise, rồi multiply vào ambient lighting.
Chi phí SSAO hoàn toàn là screen-space và không phụ thuộc vào scene complexity. Một scene với triệu polygon hay nghìn polygon, SSAO tốn y chang nhau. Đây là điều đẹp của screen-space technique: anh đã trả chi phí rasterize toàn bộ scene rồi, G-Buffer là kết quả anh có. Mọi thứ xây trên G-Buffer đó đều rẻ hơn nhiều so với làm trong geometry pass.
HBAO, Horizon-Based Ambient Occlusion, là phiên bản tinh tế hơn của Nvidia. Thay vì sample random points, nó trace horizon theo nhiều hướng trong screen space và tính góc horizon để estimate occlusion chính xác hơn. Trông tốt hơn rõ ràng, nhất là ở thin geometry và surface curved. Đắt hơn SSAO nhưng vẫn trong phạm vi chấp nhận được. GTAO, Ground Truth Ambient Occlusion, đi xa hơn nữa với một formulation toán học sát với physical reality hơn, và là thứ Unreal Engine 5 dùng mặc định trong nhiều configuration. Đây là loại kỹ thuật mà tôi gọi là tối ưu mang tính nghệ thuật. Không phải vì chúng không có cost. Mà vì khi làm đúng, chúng trả lại nhiều hơn những gì chúng lấy đi. Một scene không có AO nhìn flat, plastic, như đồ vật lơ lửng trên surface thay vì đứng trên nó. Thêm SSAO vào, với cost vài millisecond GPU time, scene đột nhiên có chiều sâu, có trọng lượng, có cảm giác thật. Đó là ratio tốt nhất trong tất cả những gì anh có thể mua bằng GPU budget.
Deferred pipeline cùng SSAO, shadow mapping, local light culling, là nền tảng của hầu hết game AAA từ 2010 đến nay. Không phải vì không có gì tốt hơn. Mà vì nó là sự cân bằng trưởng thành giữa visual quality và performance controllability. Anh biết mình đang trả bao nhiêu cho từng feature. Anh có thể scale từng thứ độc lập. Anh có thể tắt SSAO trên hardware yếu, giảm shadow distance, giảm số lượng local light active cùng lúc, mà không làm vỡ toàn bộ pipeline. Đó là chỗ GPU-driven rendering hiện đại đang đi tiếp. Mesh shaders trong DirectX 12 Ultimate và Vulkan cho phép GPU tự quyết định geometry nào cần render mà không cần CPU phải schedule từng draw call. Nanite của Unreal 5 về cơ bản là một GPU-driven visibility system, nơi GPU tự chọn mức detail phù hợp cho từng cluster geometry dựa trên projected screen size, không cần CPU can thiệp vào từng object. Ranh giới giữa CPU và GPU đang mờ đi theo nghĩa CPU ngày càng trở thành người submit high-level intent, còn GPU tự lo phần còn lại với compute shader và mesh shader.
Đây không phải thay đổi nhỏ. Đây là thay đổi kiến trúc tư duy. Trong forward và deferred pipeline truyền thống, CPU là nhạc trưởng, quyết định từng draw call, từng state change, từng object visible hay không. Trong GPU-driven pipeline, CPU submit một batch command và GPU tự orchestrate. Điều đó có nghĩa là anh phải hiểu GPU computation model sâu hơn nhiều, phải hiểu indirect drawing, phải hiểu compute pipeline, phải biết cách structure data để GPU access efficient mà không bị divergence hay cache miss. Nhưng cái gốc rễ không thay đổi. Dù pipeline là deferred hay forward plus hay GPU-driven hoàn toàn, nguyên tắc căn bản vẫn là: đừng làm công việc không cần thiết, và khi phải làm, hãy làm đúng chỗ đúng lúc. Deferred rendering dạy điều đó bằng cách tách geometry pass khỏi lighting pass. GPU-driven rendering dạy điều đó bằng cách để GPU tự biết cần làm gì. SSAO dạy điều đó bằng cách tận dụng data đã có thay vì tạo ra data mới.
Người nào hiểu nguyên lý đó, không phải chỉ biết bật tắt setting trong engine, người đó mới thật sự làm chủ được cái máy đang chạy game của mình.
Công nghệ Upscaling – Phép màu AI hay giấy phép cho sự lười biếng?
Năm 2018, Nvidia giới thiệu DLSS thế hệ đầu tiên và phần lớn cộng đồng đón nhận nó với thái độ hoài nghi có cơ sở. Kết quả trông mờ, artifact rõ ràng, và cái giá phải trả là mua RTX card đắt tiền. Không ai đặc biệt hào hứng. Rồi DLSS 2.0 ra năm 2020, và câu chuyện thay đổi hoàn toàn.
Điều Nvidia làm với DLSS 2.0 về mặt kỹ thuật thật sự đáng nể. Họ train một convolutional neural network trên cặp ảnh native resolution và ảnh low resolution tương ứng, dùng supercomputer render ra hàng triệu frame ở độ phân giải siêu cao với supersampling thật sự, rồi dạy network học cách reconstruct detail từ thông tin thấp hơn. Kết quả trong runtime: GPU render ở 1080p, DLSS reconstruct lên 4K, và output nhiều trường hợp sắc nét hơn cả native 4K với TAA vì network được train để preserve edge và detail thay vì blur chúng. Đây là ứng dụng deep learning thật sự có giá trị thực tiễn, không phải marketing.Còn AMD FSR đi theo hướng khác, không dùng machine learning mà dùng spatial upscaling thuần toán học với thuật toán Lanczos-inspired. Rẻ hơn về compute, chạy được trên mọi GPU kể cả không phải của AMD, nhưng quality thấp hơn DLSS rõ ràng ở mức scale cao. FSR 2 và 3 thêm temporal component vào, gần với DLSS hơn về approach. Intel XeSS thì hybrid, dùng neural network trên hardware Intel có tensor core, fallback về spatial trên hardware khác.
Dynamic Resolution Scaling là một kỹ thuật khác, không liên quan đến AI nhưng cùng chung mục tiêu: giữ frame rate ổn định. Hệ thống DRS theo dõi frame time của mỗi frame, nếu GPU không kịp render đủ nhanh thì tự động giảm render resolution xuống cho frame tiếp theo, nếu có headroom thì tăng lại. Người chơi không thấy resolution thay đổi, họ chỉ thấy frame rate giữ được 60fps thay vì drop xuống 45. Console dùng DRS rất nhiều vì hardware cố định, không có cách nào khác để handle các scene đặc biệt nặng. PS5 và Xbox Series X chạy nhiều game với DRS range từ 1080p đến 4K tùy tải của scene.
Những thứ trên đều là công nghệ tốt. Tốt trong bối cảnh đúng của chúng. DLSS dành cho người dùng RTX 2060 muốn chơi game demanding ở resolution cao hơn khả năng native của card. DRS dành cho console đảm bảo experience mượt mà dù scene complexity biến động. Đó là use case chính đáng, và trong những bối cảnh đó, những công nghệ này là phép màu thật sự, không cường điệu. Vấn đề là bây giờ chúng đang được dùng như một giấy phép.
Khi một game ra mắt với khuyến cáo dùng DLSS Quality mode để đạt “trải nghiệm tốt nhất” trên RTX 4080, tức là card thuộc loại mắc trên thị trường consumer, thì đó không phải là optimization. Đó là failure của optimization được đặt tên lại thành feature. RTX 4080 không cần DLSS để chạy game đẹp ở 1440p. Nếu nó cần, thì có gì đó trong game đang sai rất căn bản.
Tôi nhìn vào một số release gần đây và thấy một pattern quen thuộc đến mức buồn nôn: native performance tệ đến mức không thể chấp nhận, DLSS bật vào thì mượt hơn, và studio gọi đó là “hỗ trợ DLSS” như thể đó là một achievement. Không. Đó là anh đang dùng Nvidia’s research budget để patch cái optimization debt của anh. Đó là anh đang bắt người chơi trade visual accuracy lấy performance mà lẽ ra họ không cần phải trade nếu anh làm việc của mình đàng hoàng. Điều tệ hơn là nó tạo ra một precedent nguy hiểm. Nếu studio biết DLSS sẽ gánh phần performance, tại sao phải ngồi profile và tối ưu draw call? Tại sao phải implement LOD cẩn thận? Tại sao phải care về overdraw, về shader complexity, về memory bandwidth? Cứ push geometry thoải mái, cứ bật mọi effect lên max, rồi DLSS lo. Đó là tư duy của người không hiểu rằng DLSS không sửa được bad architecture. Nó chỉ render ở resolution thấp hơn rồi upscale. Nếu game của anh chạy chậm vì CPU bottleneck, vì draw call quá nhiều, vì memory access pattern tệ, DLSS không giúp được gì cho những vấn đề đó.
Dynamic Resolution Scaling cũng bị lạm dụng theo cách tương tự. Khi DRS của một game dao động từ 720p đến 1440p trên console, và 720p xuất hiện thường xuyên trong gameplay bình thường chứ không phải chỉ trong cutscene phức tạp, thì DRS không phải safety net nữa. Nó là mode mặc định được disguise thành adaptive feature. Người chơi thật ra không ngu như anh nghĩ. Họ nhận ra khi game trông mờ bất thường, và họ nhận ra khi DLSS Performance mode không cứu được frame rate trong area phức tạp, rồi thì họ post Digital Foundry video so sánh, họ đọc frame time graph, họ biết tất cả. Nhưng cái cycle tiếp tục vì game vẫn bán được, review vẫn ra, và cái feedback loop về quality bị bóp méo bởi hype và sunk cost của người đã preorder.
Tôi không nostalgic một cách mù quáng về thời cũ. Thời cũ có những vấn đề của nó, có những thứ khó khăn không cần thiết, có những limitation mà tôi không muốn quay lại. Nhưng có một thứ thời cũ có mà tôi thấy đang mất dần: cái kỷ luật của việc biết chính xác mình có bao nhiêu budget và không được phép vượt qua. Không có DLSS để gánh. Không có DRS để che. Game chạy đúng frame rate target trên target hardware hoặc anh không ship. Đơn giản và tàn nhẫn như vậy. Cái kỷ luật đó tạo ra những người biết trade-off. Biết rằng mỗi triangle thêm vào là phải lấy đi từ chỗ khác. Biết rằng mỗi light source thêm vào phải được justify bằng visual impact tương xứng. Biết rằng beauty không phải là số lượng polygon hay số lượng effect, mà là sự phân bổ budget thông minh để người chơi thấy đẹp ở chỗ họ nhìn vào, không bị chú ý ở chỗ anh tiết kiệm.
Đó là tối ưu thật sự. Không phải thêm DLSS vào sau khi xong. Không phải bật DRS rồi gọi là optimization. Mà là ngồi với profiler từ ngày đầu, hiểu từng millisecond đang đi về đâu, và đưa ra quyết định có chủ đích về từng thứ trong pipeline. Upscaling technology sẽ tiếp tục tốt lên. DLSS 4 với frame generation bằng AI đã bắt đầu interpolate frame hoàn toàn mới giữa các rendered frame, về cơ bản là AI đang đoán cái game cần vẽ gì. Công nghệ đó khi mature sẽ thay đổi fundamentally cái quan hệ giữa rendered performance và perceived performance. Tôi không phủ nhận điều đó.
Nhưng những người hiểu tại sao Deferred Rendering ra đời, tại sao Virtual Texturing giải quyết được VRAM constraint, tại sao occlusion culling quan trọng hơn nhiều người nghĩ, những người đó sẽ biết cách dùng các công nghệ mới đó đúng chỗ. Còn những người chưa bao giờ nghĩ đến những thứ này, họ sẽ bật frame generation lên, thấy số FPS tăng gấp đôi, rồi tự hỏi tại sao game vẫn trông và cảm giác không đúng. Frame rate là số đếm. Optimization là hiểu biết, hai thứ đó không thay thế nhau được.
Và trong cái ngành mà mỗi sản phẩm có thể đến tay hàng triệu người, mỗi frame drop, mỗi stutter, mỗi artifact là một trải nghiệm tệ nhân với con số đó, cái hiểu biết đó không phải là thứ anh có thể outsource cho bất kỳ công nghệ nào, dù công nghệ đó có tên đẹp đến đâu.
Ship game tối ưu hay đừng ship. Không có option thứ ba đáng tự hào.

Để lại một bình luận