+6

UniCost: Lời tuyên chiến với Update() và Nghệ thuật "Buôn lậu Thời gian" của một kẻ "Điên"

1, Lời hứa về sự Đánh đổi (The Promise of Trade-off)

Trong bài viết trước, chúng ta đã cùng nhau mổ xẻ "cái chết êm ái" của các mô hình đa luồng truyền thống. Chúng ta nhận ra một sự thật nghiệt ngã: Dù UniTask hay Job System có giúp tính toán logic nhanh đến đâu ở hậu trường, thì tại khoảnh khắc kết quả được mang trở lại Main Thread để hiển thị, chúng ta lại vô tình tạo ra một nút thắt cổ chai mới.

Tại điểm bế tắc đó, chúng ta đã đề xuất BBDS (Budget-Based Dynamic Scheduling). Mệnh đề cốt lõi của nó đi ngược lại bản năng của mọi lập trình viên:

"Sự mượt mà của dòng chảy thời gian (Frame Rate) quan trọng hơn sự tức thời của phản hồi thị giác (Latency)."

Hãy làm một phép toán để thấy sự đánh đổi này giá trị như thế nào. Giả sử chúng ta có một tác vụ cập nhật UI gây tốn 30ms (do phải cập nhật layout, vẽ lại text, ...).

  • Với chuẩn 60 FPS (16.6ms/frame), 30ms tương đương với việc đánh mất 2 khung hình. Game sẽ khựng nhẹ.
  • Nhưng với tiêu chuẩn thực tế ảo (VR) hiện đại chạy ở 240Hz (4.1ms/frame), con số 30ms đó tương đương với 7 khung hình bị mất.

Trong môi trường VR, việc mất 7 khung hình liên tiếp không chỉ là "giật lag", nó là thảm họa sinh học. Nó phá vỡ ảo giác về quán tính (Inertia), làm lệch pha giữa chuyển động đầu của người chơi và hình ảnh hiển thị, gây ra chóng mặt và buồn nôn ngay lập tức.

Ở đây, chúng ta cần phân biệt rõ hai loại trải nghiệm:

  1. Trải nghiệm Quán tính (Inertial Experience): Camera xoay, nhân vật di chuyển. Đây là thứ sống còn, bắt buộc phải mượt mà tuyệt đối (Real-time).
  2. Trải nghiệm Phụ trợ (Auxiliary Experience): Thanh máu trừ đi, số vàng nhảy lên, hay một icon bật sáng.

Người chơi có thể chấp nhận việc thanh máu cập nhật trễ, thậm chí trễ tới 200ms - 300ms (gần 1/3 giây). Sự trễ nải này có thể tạo ra cảm giác UI hơi "lì" (sluggish) một chút, gây khó chịu nhẹ, nhưng game vẫn chơi được và vẫn mượt mà. Ngược lại, nếu vì muốn cập nhật thanh máu đó "ngay lập tức" mà khiến camera bị khựng lại dù chỉ 30ms, trải nghiệm game coi như vứt đi.

Từ nhận định đó, chúng ta đi đến tuyên ngôn định hình cho toàn bộ kiến trúc này:

"Nếu một Hành động Nền đã hoàn thành xong phần Tính toán Cốt lõi, việc trì hoãn phần Post-process thêm vài frame, thậm chí vài chục frame để giữ cho trải nghiệm quán tính ổn định là một cái giá quá rẻ mạt."

Tuy nhiên, lý thuyết thì dễ nói. Chúng ta không thể yêu cầu từng lập trình viên trong team tự ngồi đếm ms và quyết định khi nào nên dừng. Chúng ta cần một hệ thống tự động, một "Cảnh sát giao thông" nghiêm khắc.

Chào mừng bạn đến với UniCost.

Đây không phải là lý thuyết suông. UniCost là một thư viện thực tế được thiết kế để thực thi kỷ luật sắt đá của BBDS lên sự hỗn loạn của Unity Main Thread.

Trong kỷ nguyên của màn hình tần số quét cao (High Refresh Rate) và thực tế ảo, chúng ta không còn xa xỉ phẩm mang tên 'thời gian dư thừa'. UniCost ra đời vì trong cái ngân sách 4ms nghèo nàn đó, không có chỗ cho bất kỳ sự hoang phí nào của Main Thread.

Nhưng UniCost không chỉ đóng vai trò là kẻ "hà tiện" giữ của. Khi chấp nhận buông bỏ sự ám ảnh về việc cập nhật tức thời, chúng ta vô tình nhận được một tấm vé thông hành vào thế giới của Sub-thread với một cái giá cực kỳ phải chăng.

Mục đích tối thượng của UniCost sinh ra là để bình ổn thời gian khung hình (Normalize Frame Time), triệt tiêu những gai nhọn (spikes) gây giật lag. Và để làm được điều đó, nó buộc phải mở khóa toàn bộ tiềm năng của kiến trúc đa luồng (Multi-threading). Đây vừa là cơ chế vận hành cốt lõi, nhưng đồng thời cũng là một "món quà tặng kèm" vô giá: Một kiến trúc nơi Main Thread được giải phóng hoàn toàn để làm điều nó giỏi nhất – vẽ ra những khung hình mượt mà đến nghẹt thở.

2, Tổng quan Kiến trúc UniCost

Trước khi đi sâu vào các sơ đồ luồng dữ liệu hay các dòng code mẫu, tôi muốn chúng ta thống nhất với nhau về vị thế của UniCost trong bức tranh công nghệ. Đây không phải là một "viên thuốc thần" (magic pill) bạn nuốt vào là game tự nhiên mượt. UniCost là một cam kết về kiến trúc.

A, Định vị sản phẩm: Từ "Thủ công Tinh xảo" đến "Chuẩn mực Ngành"

Lộ trình phát triển của UniCost được chia làm hai giai đoạn rõ rệt, phản ánh chính sự trưởng thành trong tư duy tối ưu hóa của chúng ta:

Giai đoạn 1 (Hiện tại): Chiếc xe đua F1 dành cho những "Tay lái lụa"

Ở phiên bản hiện tại (v1.0), UniCost được định hình là một vũ khí hạng nặng (Heavy Weapon) dành cho Senior Developers và Tech Leads – những người đang trực tiếp đối mặt với các bài toán hiệu năng hóc búa nhất.

Hãy tưởng tượng UniCost lúc này giống như một chiếc xe đua F1. Nó không có máy lạnh, không có ghế đệm êm ái, và quan trọng nhất: nó là số sàn (manual transmission) tuyệt đối.

  • Nó đòi hỏi người lái (Developer) phải hiểu rõ từng nhịp tua máy của động cơ (App Lifecycle).
  • Bạn phải tự tay sang số, tự tay ước lượng "Cost" (chi phí) cho từng khúc cua.
  • Nó không tha thứ cho những sai lầm ngớ ngẩn.

Nhưng đổi lại, khi bạn đã làm chủ được nó, chiếc xe này cho phép bạn đạt được tốc độ và hiệu suất mà không một chiếc xe tự động (Automation Tools) nào có thể chạm tới. Nó trao cho bạn quyền kiểm soát tối thượng: quyền quyết định chính xác khi nàobao nhiêu tài nguyên được sử dụng. Đây là công cụ để tối ưu hóa game tới tận cùng xương tủy.

Giai đoạn 2 (Tương lai): Sự Đại chúng hóa và Cú chuyển dịch Hệ tư tưởng (Paradigm Shift)

Tầm nhìn của UniCost v2.0 là hướng tới sự Đại chúng hóa (Mass Adoption). Tôi sẽ trang bị cho "chiếc xe F1" này những hệ thống hỗ trợ lái tiên tiến như PRP (Pre-Runtime Profiler) – thứ sẽ tự động đo đạc và gợi ý các thông số Cost dựa trên dữ liệu thực tế thay vì cảm tính của Dev.

Tuy nhiên, tôi cần phải làm rõ một điều tối quan trọng để tránh mọi hiểu lầm về sau: Sự tiện lợi của v2.0 không đồng nghĩa với sự lười biếng trong tư duy.

Dù có PRP, dù công cụ có thông minh đến đâu, UniCost vẫn mãi mãi là một Paradigm Shift (Sự chuyển dịch mô hình).

  • Bạn không thể viết code theo kiểu cũ (Spaghetti code, Logic lẫn lộn với View) rồi ném vào UniCost và mong nó chạy nhanh.
  • Để sử dụng UniCost, bạn bắt buộc phải tuân thủ kỷ luật kiến trúc: Mô hình 3 Pha (3-Phase Model). Bạn phải học cách tách bạch dữ liệu (Data), tính toán (Calculation) và hiển thị (View) một cách triệt để.
  • Bạn vẫn phải gọi các API của UniCost, vẫn phải cấu trúc code theo đúng quy chuẩn.

UniCost không sinh ra để "chạy nhanh những đoạn code tồi". Nó sinh ra để ép buộc bạn viết những đoạn code tốt, và từ đó, hiệu năng sẽ đến như một hệ quả tất yếu. Giai đoạn 2 chỉ giúp quá trình "ép buộc" đó trở nên dễ chịu hơn, có số liệu chứng minh hơn, chứ không thay thế vai trò kiến trúc sư của bạn.

B, Mô hình hoạt động: Kiến trúc Producer-Consumer dị biệt

Cốt lõi của UniCost không phải là phép thuật, nó là một biến thể chuyên biệt của mô hình Producer-Consumer, được tối ưu hóa cực đoan để giảm thiểu Overhead (chi phí quản lý) trên Main Thread. Hệ thống được cấu thành từ 3 thành phần chính:

1, Main Thread Syncer (Bộ thực thi)

Đây là thành phần duy nhất của UniCost chạy trên Main Thread. Trái ngược với suy nghĩ phổ biến về một "Smart Agent" liên tục kiểm tra thời gian, Syncer được thiết kế để "ngu" nhất có thể để đạt tốc độ thực thi tối đa.

  • Vị trí trong Loop: Syncer được Hook (gắn) trực tiếp vào giai đoạn PreUpdate của vòng đời Unity. Đây là thời điểm vàng để cập nhật dữ liệu trước khi các hệ thống vật lý và render bắt đầu tính toán.
  • Cấu trúc dữ liệu: Nó sở hữu một ConcurrentQueue chứa các SyncBatch (Lô lệnh). Mỗi SyncBatch thực chất là một mảng các Delegate đã được Scheduler sắp xếp sẵn.
  • Logic thực thi: Tại mỗi frame, Syncer sẽ Dequeue một lô duy nhất được chỉ định cho frame đó và chạy vòng lặp duyệt qua mảng delegate. Logic bên trong cực kỳ tối giản:
    1. Kiểm tra điều kiện Condition() (Ví dụ: Target Object còn sống không?).
    2. Nếu True: Thực thi ngay lập tức Action().
    3. Nếu False: Bỏ qua.
  • Rủi ro và Trách nhiệm: Cần lưu ý rằng Syncer không kiểm tra thời gian (Stopwatch) sau mỗi lần chạy delegate. Việc kiểm tra thời gian liên tục sẽ tạo ra overhead không cần thiết (syscall). Syncer tin tưởng tuyệt đối vào kế hoạch mà Scheduler đã vạch ra.
    • Nếu Scheduler ước lượng sai (do Dev cung cấp Cost sai), Syncer sẽ chạy lố thời gian (Overshoot Frame Time).
    • Rủi ro này được giảm thiểu bằng cơ chế tính toán trung bình của Scheduler, nhưng về bản chất, UniCost trao quyền (và rủi ro) cho Dev để đổi lấy hiệu suất "bare-metal".

2, The Queue (Hub tiếp nhận)

Đây không phải là hàng đợi xử lý, mà là cổng tiếp nhận thông tin (Ingress Port).

  • Vai trò: Đóng vai trò là một Event Hub. Nó cho phép các luồng phụ (Sub-threads) đẩy các yêu cầu cập nhật (Post-process Request) vào hệ thống một cách an toàn (Thread-safe) bất cứ lúc nào.
  • Dòng chảy dữ liệu: Queue này không trực tiếp đẩy lệnh sang Syncer. Nó chỉ ghi nhận: "Có một yêu cầu cập nhật UI tại địa chỉ X với dữ liệu Y".
  • Đặc điểm: Nó đóng vai trò là Input (Đầu vào) cho bộ não Scheduler. Tại đây, các request chưa được sắp xếp, chưa được lọc, và nằm ở dạng thô (Raw Requests). Đây cũng là nơi chứa sẵn các logic cho cơ chế Aggregated Request (Gộp yêu cầu) để nén dữ liệu đầu vào, giảm tải cho bước lập lịch tiếp theo (sẽ được bàn chi tiết ở phần sau).

3, Sub-thread Scheduler (Bộ não lập lịch)

Đây là nơi UniCost thực sự "sống". Toàn bộ logic phức tạp, nặng nề nhất đều được đẩy xuống chạy ngầm ở luồng phụ (Worker Thread) để không làm phiền Main Thread.

  • Nhiệm vụ:
    1. Phân tích Input: Lấy dữ liệu từ The Queue.
    2. Tính toán Ngân sách (Budgeting): Dựa trên cấu hình Frame Time (ví dụ 16.6ms) và lịch sử chạy của các frame trước, nó tính toán xem frame tiếp theo có bao nhiêu "vốn" khả dụng.
    3. Đóng gói (Packaging): Nó nhặt các request từ Queue, cộng dồn Cost dự tính của chúng. Khi tổng Cost chạm ngưỡng ngân sách cho phép, nó đóng gói toàn bộ các request đó thành một SyncBatch.
    4. Phân phối (Dispatch): Đẩy SyncBatch đã hoàn thiện sang ConcurrentQueue của Main Thread Syncer.

Tóm lại: Scheduler là người đầu bếp chuẩn bị sẵn thực đơn (Batch) ở trong bếp (Sub-thread). Syncer là người phục vụ chỉ việc mang món ăn ra bàn (Main Thread) mà không cần hỏi lại cách nấu. Sự tách biệt này đảm bảo Main Thread chỉ tốn sức cho việc "ăn" (thực thi), không tốn sức cho việc "nghĩ" (lập lịch).

Nghệ thuật của "Sự Lãng phí có Chủ đích" (Intentional Waste)

Có thể bạn sẽ thắc mắc: "Việc tính toán, sắp xếp, gộp batch ở Sub-thread chẳng phải tạo ra rất nhiều overhead sao? Và việc Syncer chạy mù quáng không kiểm tra thời gian chẳng phải là một lỗ hổng lớn?"

Chính xác. Nhưng đó là tính năng, không phải lỗi.

  1. Kinh doanh chênh lệch giá (Arbitrage): Thời gian của Main Thread là Vàng. Thời gian của Sub-thread là Rác (với các thiết bị đa nhân hiện nay, các luồng phụ thường xuyên ở trạng thái Idle). UniCost chấp nhận đốt 5ms tiền "Rác" để tiết kiệm 0.1ms tiền "Vàng". Mọi sự phức tạp, kiểm tra, lock, queue đều bị đẩy ra sân sau.
  2. Lỗ hổng Niềm tin (The Trust Gap): Để Syncer chạy nhanh nhất (Zero-overhead), nó buộc phải "tin tưởng tuyệt đối" vào Batch được giao. Nó không được phép hoài nghi hay kiểm tra lại đồng hồ (vì việc kiểm tra Stopwatch liên tục cũng tốn CPU).
    • Nếu Dev ước lượng sai -> Batch quá lớn -> Lố Frame Time.
    • Đây là rủi ro nằm trong tính toán. Chúng ta chấp nhận rủi ro này để đổi lấy tốc độ thực thi "sát phần cứng" (Bare-metal performance).

Và để lấp đầy "Lỗ hổng niềm tin" đó, chúng ta cần một đơn vị tiền tệ để Dev và Scheduler giao tiếp với nhau. Đó là Cost.

3. "Cost": Đơn vị tiền tệ của Hệ thống

Để "Lỗ hổng niềm tin" giữa Scheduler (Người lên kế hoạch) và Syncer (Người thực thi) không trở thành thảm họa, chúng ta cần một ngôn ngữ chung. Ngôn ngữ đó là Cost.

Định nghĩa: Cost là gì?

Trong tư duy thông thường, chúng ta hay đo hiệu năng bằng thời gian thực (real-time milliseconds). Nhưng với UniCost, định nghĩa này cần được thu hẹp lại:

Cost là chi phí ước lượng để thực hiện chỉ riêng phần Post-process (cập nhật UI, thay đổi Transform, Instantiate...) trên Main Thread.

Hãy nhớ kỹ: Thời gian bạn tính toán Logic, Pathfinder, AI... trên Sub-thread là Miễn phí (Cost = 0) đối với UniCost. Chúng ta chỉ tính tiền cho khoảnh khắc bạn chạm vào "lãnh địa" của Unity API.

Các mô hình Định giá (Pricing Models)

UniCost cung cấp hai phương thức để lập trình viên "báo giá" cho hành động của mình:

1. Static Cost (Định giá Tĩnh - Attribute/Register)

Dành cho các tác vụ lặp đi lặp lại, có độ phức tạp hằng số.

  • Ví dụ: Việc gán text.text = "100" luôn tốn một lượng tài nguyên tương đương nhau dù chạy ở frame nào.
  • Cách dùng: Bạn có thể đánh dấu bằng Attribute [UniCost(0.5f)] hoặc đăng ký một lần lúc khởi tạo. Đây là cách "cứng" để thiết lập mức sàn cho các tác vụ cơ bản.

2. Dynamic Cost (Định giá Động - Runtime API)

Dành cho các tác vụ biến thiên theo ngữ cảnh.

  • Ví dụ: Việc Instantiate 1 con quái vật khác hoàn toàn với Instantiate 50 con quái vật cùng lúc. Hoặc việc cập nhật UI của một danh sách dài (Scroll View) sẽ nặng hơn danh sách ngắn.
  • Cách dùng: Dev truyền trực tiếp giá trị cost ước tính vào API đăng ký request: UniCost.Request(..., cost: count * 1.5f).
  • Thách thức: Đây chính là "Skill Check" (bài kiểm tra trình độ) của Developer. Khả năng cảm nhận độ nặng nhẹ của dòng code tại thời điểm runtime sẽ quyết định độ chính xác của bộ lập lịch.

Cơ chế Tính toán: Ảo giác của Miliseconds và Sự thật về "Cảm giác"

Đây là phần quan trọng nhất, nơi thay đổi hoàn toàn cách bạn nhìn nhận về việc tối ưu hóa.

Bạn có thể tự hỏi: "Làm sao tôi biết máy của người chơi mạnh hay yếu mà đặt Cost là bao nhiêu ms? 1ms trên PC là muỗi, nhưng trên Mobile ghẻ là con voi."

Đừng nghĩ Cost là mili-giây (ms).

UniCost hoạt động dựa trên cơ chế Bình thường hóa (Normalization)Học máy thống kê (Statistical Heuristics) đơn giản.

  • Scheduler không quan tâm Cost = 1 nghĩa là bao nhiêu giây.
  • Nó quan tâm đến Tỷ lệ chuyển đổi (Exchange Rate) tại thời điểm hiện tại.
  • Nó liên tục đo đạc: "À, trong 60 frame vừa qua, trung bình mỗi khi Dev báo Cost là 10 đơn vị, thì Main Thread thực tế tốn mất 0.5ms để xử lý."

Từ đó, Scheduler sẽ tự động quy đổi ngân sách còn lại của frame ra đơn vị Cost của bạn.

  • Trên máy mạnh: 16ms ngân sách có thể đổi được 1000 Cost.
  • Trên máy yếu: 16ms ngân sách chỉ đổi được 200 Cost.

Mệnh đề cốt lõi:

Cost không phải là con số tuyệt đối, Cost là Cảm giác (Relative Weight).

Thứ duy nhất hệ thống yêu cầu ở bạn là Sự nhất quán chủ quan (Subjective Consistency). Nếu bạn quy ước việc "Gán Text" có Cost là 1, thì việc "Instantiate Prefab" (vốn nặng hơn gấp 100 lần) phải có Cost là 100.

Đừng bận tâm 1 đó là 0.01ms hay 1ms. Miễn là tỷ lệ 1:100 đó được giữ nguyên xuyên suốt dự án, thuật toán của UniCost sẽ tự động điều chỉnh tỷ giá hối đoái để phù hợp với từng thiết bị phần cứng cụ thể. Sự sai lệch do phần cứng đã bị triệt tiêu, chỉ còn lại sự chính xác trong tư duy so sánh độ phức tạp của lập trình viên.

4, Các cơ chế Điều phối Nâng cao (The Dispatcher Logic)

Nếu Scheduler là bộ não, Syncer là tay chân, thì Dispatcher Logic chính là hệ thần kinh phản xạ siêu tốc. Đây là nơi UniCost chứng minh sự vượt trội so với vòng lặp Update() truyền thống của Unity.

Chúng ta thường nghĩ tối ưu hóa là làm cho một hàm chạy nhanh hơn. Nhưng đỉnh cao của tối ưu hóa là không chạy hàm đó luôn. Chào mừng bạn đến với "Sức mạnh của sự Lười biếng".

A, Aggregated Request (Gộp yêu cầu) - Nghệ thuật nén dữ liệu thời gian thực

Trong một frame (16.6ms), có thể có hàng trăm sự kiện logic xảy ra, nhưng Main Thread không cần thiết phải phản ánh tất cả chúng. UniCost biến hàng đợi (Queue) từ một ống dẫn thụ động (FIFO) thành một bộ lọc thông minh (Smart Filter). Trước khi một request kịp chạm tới Main Thread, nó phải đi qua lớp màng lọc "Aggregator" này.

Hiện tại, UniCost hỗ trợ 2 Design Pattern tối ưu hóa luồng dữ liệu cực kỳ mạnh mẽ (và nếu bạn nghĩ ra mô hình nào hay ho hơn, hãy để lại comment, chúng ta sẽ cùng hiện thực hóa nó):

1, Last-Write Wins (Người đến sau cùng thắng)

Tối ưu hóa triệt để cho các trạng thái không tích lũy (Stateless Snapshot).

  • Bối cảnh: Hãy tưởng tượng logic nhặt vàng (uiCoin.text). Trong một frame, nhân vật hút vào 100 đồng xu.
    • Cách Unity truyền thống: Bạn gọi text.text = score.ToString() 100 lần.
    • Cái giá phải trả: 100 lần cấp phát chuỗi (String Allocation) tạo rác bộ nhớ. 100 lần kích hoạt Canvas.SendWillRenderCanvases – một trong những hàm tốn kém nhất của UI system vì nó phải tính toán lại hình học (geometry) của text.
  • Cơ chế UniCost:
    • Khi request cập nhật UI đầu tiên (Coins = 1) được đẩy vào queue, UniCost đánh dấu nó bằng một Key (ví dụ: InstanceID của Text).
    • Khi request thứ 2 (Coins = 2) ập tới với cùng Key, UniCost nhận ra ngay: "Thằng cũ chưa kịp chạy đã lỗi thời rồi".
    • Hành động: Nó ghi đè (Override) dữ liệu của request cũ bằng request mới ngay trong hàng đợi.
    • Đến cuối frame, dù có 100 hay 1000 sự kiện nhặt vàng, trong hàng đợi chỉ còn lại duy nhất request cuối cùng (Coins = 100).
  • Kết quả: Main Thread chỉ tốn 1 lần cập nhật UI. Hiệu suất tăng gấp 100 lần. Một cú "lừa" ngoạn mục với hệ thống Render.

2, Accumulator (Người tích lũy)

Tối ưu hóa cho dữ liệu cộng dồn (Incremental Data).

  • Bối cảnh: Game nhập vai với tốc độ đánh cao (High Attack Speed). Trong 1 frame, quái vật nhận 20 phát đạn, mỗi phát 5 damage.
    • Cách truyền thống: Instantiate 20 cái Floating Text bay lên. Màn hình chi chít số "5", "5", "5"... che khuất tầm nhìn, FPS tụt vì draw calls và particle system quá tải.
  • Cơ chế UniCost:
    • Thay vì ghi đè, UniCost cho phép Dev cung cấp một hàm MergeDelegate:

      Request Merge(Request oldReq, Request newReq) {
          return new Request(oldReq.Damage + newReq.Damage);
      }
      
    • Khi request mới bay vào và gặp request cũ đang nằm chờ trong queue, thay vì xếp hàng phía sau, nó chui vào và "hợp thể" với request cũ.

    • 100 request * 5 damage sẽ được nén lại thành 1 request duy nhất * 500 damage trước khi Main Thread kịp nhìn thấy chúng.

  • Kết quả:
    • Về kỹ thuật: Giảm 99% số lượng Object cần Instantiate.
    • Về trải nghiệm (UX): Người chơi thay vì thấy một bãi rác số liệu, họ thấy một con số tổng lực "500!" cực kỳ thỏa mãn (Satisfying).
    • Đây là ví dụ điển hình của việc: Tối ưu hóa hiệu năng dẫn đến Tối ưu hóa trải nghiệm.

Khi Kiến trúc mở đường cho Tối ưu (The Architectural Advantage)

Cần phải thành thật với nhau: Last-Write Wins (Debounce/Throttle) hay Accumulator không phải là những phát minh mới mẻ của UniCost. Chúng là những kỹ thuật đã tồn tại hàng thập kỷ, xuất hiện rải rác trong Rx.NET, trong các hệ thống Event Bus tùy biến, hay trong những đoạn code "thủ công" mà các Senior Dev viết riêng cho những tính năng quá nặng.

Tuy nhiên, chúng chưa bao giờ trở thành tiêu chuẩn (Standard). Tại sao? Vì chi phí triển khai (Implementation Cost) cho từng trường hợp riêng lẻ quá cao. Bạn sẽ lười viết một bộ xử lý gộp cho một dòng text đơn giản. Bạn chấp nhận "thôi kệ" cho xong.

UniCost làm duy nhất một việc: Chuẩn hóa và Tầm thường hóa (Trivializing) việc triển khai các kỹ thuật cao cấp này.

Đây chính là Món quà tặng kèm (Bonus) của kiến trúc tập trung. Nếu bạn để logic chạy rải rác trong hàng nghìn hàm Update() của từng GameObject, bạn không có cách nào kiểm soát hay tối ưu chúng. Nhưng khi bạn buộc mọi dòng chảy dữ liệu phải đi qua một Cổng kiểm soát trung tâm (Request Center) – chính là The Queue của UniCost – bạn bỗng nhiên có quyền năng tối thượng để thao túng thời gian và dữ liệu.

Chỉ khi quy tụ về một mối, chúng ta mới có thể thực hiện những pha "nén dữ liệu" cực hạn như trên một cách tự động và trong suốt. UniCost biến những tối ưu xa xỉ trở thành mặc định.

B, Priority Escalation (Leo thang Ưu tiên) - Cơ chế "Chống Bỏ đói"

Khi chúng ta áp dụng ngân sách (Budget) chặt chẽ, một rủi ro hiển hiện sẽ nảy sinh: Starvation (Sự bỏ đói tác vụ).

Trong một tình huống giao tranh hỗn loạn (Combat Heavy), hàng nghìn request hiệu ứng cháy nổ (High Priority) có thể liên tục lấp đầy ngân sách của 10-20 frame liên tiếp. Điều này dẫn đến việc một request cập nhật UI ít quan trọng (Low Priority) – ví dụ như thanh máu của một con Creep ở xa – bị đẩy lùi mãi mãi. Nó nằm dưới đáy hàng đợi và không bao giờ được thực thi. Kết quả là con quái đã chết 5 giây nhưng thanh máu vẫn đầy nguyên. Đây là lỗi logic không thể chấp nhận.

UniCost giải quyết vấn đề này bằng cơ chế Priority Escalation.

  • Bộ đếm thời gian chờ (Aging Mechanism): Mỗi request khi được sinh ra sẽ được gắn một "Timestamp". Scheduler không chỉ nhìn vào Priority gốc, mà còn nhìn vào "Tuổi thọ" của request trong hàng đợi.
  • Vượt rào (The Breakout): Khi một request chờ quá ngưỡng quy định (ví dụ: > 30 frame hoặc > 500ms), nó sẽ tự động được thăng cấp thành VIP (Critical Priority).
  • Chấp nhận hy sinh: Khi đã thành VIP, Scheduler buộc phải thực thi nó ngay trong frame tiếp theo, bất chấp việc frame đó có bị vỡ ngân sách (Over-budget) hay không.

Tại đây, chúng ta chấp nhận một cú Jitter (Giật nhẹ) để cứu vãn tính đúng đắn của trạng thái game (Game State Consistency). Đây là "cửa thoát hiểm" cuối cùng để đảm bảo hệ thống không bao giờ bị tắc nghẽn vĩnh viễn.

Tuy nhiên, cần phải nhấn mạnh rằng kịch bản "vỡ trận" này thực tế rất hiếm khi xảy ra. Nhờ sự hỗ trợ đắc lực của cơ chế Aggregated Request (nén hàng trăm lệnh thành một) và LOF - Level of Fidelity (cắt giảm tải cho các đối tượng ở xa - sẽ bàn ở phần sau), áp lực lên hàng đợi thường xuyên được giữ ở mức an toàn.

Chỉ trong trường hợp thiết bị người dùng quá yếu, vốn dĩ đã không đủ năng lực phần cứng để vận hành game, cơ chế này mới thực sự lộ diện. Khi đó, UniCost chuyển mình sang một vai trò khác: Không còn là kẻ tối ưu sự mượt mà tuyệt đối, nó trở thành Bộ ổn áp (Stabilizer).

Thay vì để game bị giật cục (Stutter/Spikes) với những frame time nhảy múa điên loạn (đang 60fps tụt xuống 5fps rồi vọt lên lại), UniCost sẽ phân phối sự quá tải đó rải đều ra theo thời gian. Nó biến những cú "sốc" hiệu năng thành sự suy giảm đồng đều. Kết quả là chúng ta có một trải nghiệm "Lag đều" (Consistent Performance Degradation). Nghe có vẻ tiêu cực, nhưng về mặt tâm lý học trải nghiệm (UX Psychology), một tốc độ chậm nhưng ổn định luôn dễ chịu và ít gây ức chế hơn nhiều so với những cú giật bất ngờ không báo trước.

Câu hỏi mở về kiến trúc: Liệu chúng ta nên dùng một ngưỡng thời gian cứng (Hard Time Limit) hay một hệ số nhân ưu tiên (Priority Multiplier) tăng dần theo thời gian? Đây là bài toán cân bằng thú vị mà UniCost vẫn đang thử nghiệm.

C, Subject Validity Check (Kiểm tra sự tồn tại) - Tấm vé vào cửa an toàn

Đây là thách thức lớn nhất khi chúng ta tách rời Logic và View bằng thời gian.

Trong lập trình đồng bộ (Synchronous), khi bạn gọi monster.TakeDamage(), bạn biết chắc chắn monster đang tồn tại. Nhưng trong UniCost, khi bạn gửi request UpdateHPMonster, request đó có thể nằm trong hàng đợi 100ms. Trong 100ms đó, người chơi có thể đã tung chiêu cuối và con quái vật đã bị Destroy().

Khi Scheduler đến lượt xử lý request UpdateHPMonster, nếu nó cứ thế chạy lệnh truy cập vào Transform hay MeshRenderer của con quái đã chết, Unity sẽ ném ra lỗi kinh điển MissingReferenceException hoặc tệ hơn là Crash ở tầng Native.

UniCost coi đây là trách nhiệm bắt buộc của hệ thống, không phải của Dev. Trước khi bất kỳ Post-process nào được thực thi, nó phải trình được "Tấm vé hợp lệ". Hiện tại, có 3 hướng tiếp cận mà kiến trúc này đang cân nhắc hỗ trợ:

  1. CancellationToken (Chuẩn .NET): Mỗi chủ thể (Owner) giữ một Token Source. Khi chủ thể chết (OnDestroy), nó hủy Token. Request trong queue sẽ check Token này trước khi chạy. An toàn, chuẩn mực, nhưng có thể hơi nặng nề (allocation).
  2. Weak Reference / Unity Null Check: UniCost giữ tham chiếu yếu tới đối tượng. Trước khi chạy, nó kiểm tra object != null. Đơn giản, dễ dùng, nhưng chi phí kiểm tra sự tồn tại của Unity Object (Fake Null) ở tầng C++ không hề rẻ nếu làm hàng nghìn lần mỗi frame.
  3. Func<bool> (Delegate kiểm tra): Dev tự truyền vào một hàm điều kiện () => monster.IsAlive. Linh hoạt nhất, nhưng lại đẩy trách nhiệm và rủi ro quay về phía Dev.

Dù chọn phương án nào, nguyên tắc cốt lõi vẫn không đổi: "Fail Silently" (Chết trong im lặng). Nếu chủ thể không còn tồn tại, request đó sẽ bị hủy bỏ ngay lập tức mà không gây ra log lỗi hay crash. Trong mô hình bất đồng bộ, việc gửi tin nhắn cho một người đã khuất là chuyện bình thường, và hệ thống cần xử lý nó một cách duyên dáng thay vì hoảng loạn.

Thực tế, bài toán này mang bóng dáng rất lớn của mô hình Event Dispatcher kinh điển. Ở đó, việc Unsubscribe khi một đối tượng bị hủy là điều kiện bắt buộc nếu bạn không muốn nhận về memory leak hay các lỗi truy cập vùng nhớ null. Nhưng với UniCost, vấn đề phức tạp hơn nhiều vì hàng đợi nằm ở một luồng khác.

Tôi cũng đã từng cân nhắc đến những cơ chế quản lý vòng đời chặt chẽ hơn. Ví dụ, khi đăng ký một Request, ta có thể buộc phải truyền kèm một danh sách các "Đối tượng phụ thuộc" (Dependency Objects). Cơ chế này hoạt động như một sợi dây ràng buộc: khi hàm OnDestroy của đối tượng được kích hoạt, nó sẽ bắn tín hiệu vào hệ thống để tự động Unsubscribe hoặc thanh lọc (Purge) toàn bộ các request đang nằm chờ có liên quan đến ID của nó.

Nghe rất lý tưởng, đúng không? Nhưng đời không như mơ. Việc quét qua một ConcurrentQueue đang chứa hàng nghìn tác vụ để tìm và gỡ bỏ một vài item cụ thể là một thao tác cực kỳ tốn kém (O(n)). Nếu tôi làm vậy, tôi lại vô tình biến việc hủy đối tượng (OnDestroy) – vốn dĩ đã nặng nề trong Unity – trở thành một nguyên nhân gây giật lag mới. Một vòng luẩn quẩn.

Thú thật, cho đến tận thời điểm này, khi đang viết những dòng này cho dự án UniCost, tôi vẫn đang đứng giữa ngã ba đường. Lựa chọn giữa sự an toàn của CancellationToken, sự tiện lợi của WeakReference hay sự tối ưu của cơ chế Manual Check vẫn là một bài toán đánh đổi cân não mà tôi phải thử nghiệm và đo đạc (Benchmark) hàng ngày. Có lẽ, không có "viên đạn bạc" (Silver Bullet) nào ở đây cả, chỉ có giải pháp phù hợp nhất với "độ lười" và kỷ luật của team dev trong từng giai đoạn mà thôi.

D, Những cơ chế còn bỏ ngỏ (Open Questions)

UniCost không phải là phép màu, và tôi sẽ không ngồi đây để vẽ ra một bức tranh màu hồng hoàn hảo. Có những bài toán vật lý mà kiến trúc này vẫn đang phải chật vật tìm lời giải, và "Cơ chế Stall có kiểm soát" là một trong số đó.

Vấn đề: Cục xương quá khổ (The Oversized Task)

Điều gì xảy ra khi một request đơn lẻ (Atomic) có Cost dự tính là 30ms, trong khi ngân sách tối đa của một frame chỉ là 16ms?

Scheduler, với logic hiện tại, sẽ nhìn thấy request này và thốt lên: "Quá đắt! Không đủ tiền!" và trì hoãn nó. Frame sau, nó lại kiểm tra và lại từ chối. Request này sẽ bị đẩy lùi vô tận cho đến khi cơ chế Priority Escalation (đã nói ở trên) kích hoạt, ép nó phải chạy. Và khi nó chạy, BÙM! Game khựng lại 30ms (tương đương 2 frame).

Đây là vật lý. Chúng ta không thể nhét con voi vào tủ lạnh mà không mở cửa tủ. Câu hỏi không phải là "Làm sao để không lag?" (vì chắc chắn sẽ lag), mà là "Làm sao để lag một cách có văn hóa?".

Tôi đang cân nhắc hai hướng tiếp cận cho vấn đề này, và có lẽ nó sẽ phụ thuộc vào cách chúng ta định nghĩa về trải nghiệm game:

1. Contextual Budgeting (Ngân sách theo Ngữ cảnh):

Liệu có nên cho phép Dev báo hiệu cho Scheduler biết trạng thái hiện tại của game?

  • Combat Mode: Ngân sách thắt chặt tối đa. Bất cứ tác vụ quá khổ nào cũng bị trì hoãn đến mức tối đa có thể.
  • UI/Shop Mode: Khi người chơi mở một Popup Shop hay Inventory, tâm lý của họ đang ở trạng thái "tĩnh". Họ chấp nhận (và thậm chí không nhận ra) việc game khựng lại 50-100ms để load danh sách vật phẩm. Lúc này, Scheduler có thể được "nới lỏng" quy chế, cho phép xả các task nặng ngay lập tức.
  • Ý tưởng: Một API UniCost.SetContext(Context.Relaxed) có thể là lời giải?

2. Exclusive Execution (Thực thi Độc quyền):

Nếu bắt buộc phải chạy một task nặng 30ms, Scheduler nên xử lý thế nào để giảm thiểu thiệt hại? Thay vì cố nhét nó vào chung với các task nhỏ khác (khiến tổng thời gian thành 30ms + 5ms = 35ms), Scheduler nên nhận diện đây là "Hàng Siêu Trọng". Nó sẽ dọn sạch đường băng: "Trong frame này, chỉ duy nhất gã khổng lồ này được chạy. Tất cả các task nhỏ khác, kể cả Priority cao, đều phải nhường đường." Điều này đảm bảo cú spike frame time chỉ dừng lại ở mức tối thiểu mà tác vụ đó yêu cầu (30ms), không bị cộng hưởng thêm bởi các yếu tố khác.

Dù chọn cách nào, đây vẫn là một sự thỏa hiệp. UniCost có thể giúp bạn quản lý dòng chảy, nhưng nó không thể giúp bạn tối ưu một đoạn code tồi. Nếu bạn viết một hàm ngốn 30ms trên Main Thread, thì dù tôi có lập lịch giỏi đến đâu, cú giật đó vẫn sẽ đến. Vấn đề chỉ là nó đến vào lúc người chơi đang ngắm bắn (Tệ) hay lúc họ đang mở túi đồ (Chấp nhận được).

5, "Bản Hợp đồng" của UniCost: Cái giá phải trả

Hãy thẳng thắn với nhau: Không có bữa trưa nào là miễn phí. Để đổi lấy một hiệu suất "sát phần cứng" và khả năng điều phối hàng nghìn tác vụ mà không làm nghẽn Main Thread, tôi buộc bạn phải ký vào một bản hợp đồng kiến trúc nghiêm ngặt.

UniCost không phải là một thư viện bạn chỉ cần Import vào là xong. Nó là một lối sống. Nếu bạn vẫn giữ tư duy viết code kiểu cũ của Unity ("vừa lấy dữ liệu, vừa tính toán, vừa gán UI" trong cùng một dòng), UniCost sẽ trở thành cơn ác mộng của bạn. Nhưng nếu bạn chấp nhận thay đổi, nó sẽ trao cho bạn quyền năng kiểm soát thời gian.

A, Quy tắc Code (The Rules)

Dưới đây là 3 điều khoản cốt tử mà bạn phải tuân thủ:

1, Tái định nghĩa Update(): Sự thanh lọc cần thiết

Trong thế giới của UniCost, chúng ta không giết chết hàm Update(). Chúng ta chỉ tước bỏ những quyền lực mà nó đang bị lạm dụng.

Suốt nhiều năm, chúng ta đã biến Update() thành một cái thùng rác khổng lồ. Chúng ta ném vào đó hàng tá câu lệnh điều kiện vô nghĩa: if (!isAlive) return;, if (timer < 0) return;. Unity vẫn phải trả chi phí để gọi hàm đó 60 hoặc 120 lần mỗi giây chỉ để nhận về một câu trả lời là "Không làm gì cả".

Kỷ luật của UniCost yêu cầu bạn trả Update() về đúng mục đích tồn tại duy nhất của nó: Nơi xử lý các tác vụ yêu cầu sự đồng bộ 1-1 (Synchronous Coupling) với Engine.

Bạn cần một bộ lọc tư duy để quyết định cái gì Được phép ở lại và cái gì Phải rời đi:

  • Nhóm Trải nghiệm Quán tính (Inertial Experience - Keep in Update): Đây là những thứ gắn liền với phản xạ thần kinh của người chơi.
    • Ví dụ: Camera xoay theo chuột, nhân vật di chuyển theo phím bấm, các phản hồi vật lý tức thời.
    • Quy tắc: Những thứ này bắt buộc phải ở lại Update(). Nếu đưa chúng vào UniCost và bị delay dù chỉ 1 frame, người chơi sẽ cảm thấy "lag" ngay lập tức vì độ trễ đầu vào (Input Lag).
  • Nhóm Trải nghiệm Mở rộng (Extended Experience - Move to UniCost): Đây là phần lớn logic còn lại của game, nơi mà sự chính xác về thời gian không quan trọng bằng sự chính xác về dữ liệu.
    • AI: Một con quái vật có cần tính toán đường đi mỗi frame (16ms) không? Không, nó có thể suy nghĩ mỗi 100ms mà người chơi không hề hay biết.
    • UI: Thanh máu, bộ đếm đạn, thông báo...
    • Logic nền: Pre-loading asset, kiểm tra trạng thái game.
    • Logic hình ảnh phụ: Thậm chí việc quyết định có nên spawn một particle nổ hay không cũng nên nằm ở đây. Nếu frame đó đang quá nặng (hết Budget), UniCost có thể quyết định bỏ qua hiệu ứng đó. Người chơi thà không thấy khói bụi còn hơn là thấy game bị khựng lại.

Sự chuyển dịch tư duy: Hãy ngừng viết code theo kiểu Polling (Hỏi liên tục: "Đến giờ chưa?"). Hãy chuyển sang tư duy Dispatching (Gửi lệnh: "Khi nào rảnh thì làm việc này nhé").

Câu chuyện ở đây là sự cân bằng tinh tế: Chúng ta giữ lại sự tốn kém của Update() cho những trải nghiệm cốt lõi nhất (Core Gameplay), và đẩy tất cả những thứ rườm rà (Meta Gameplay) sang cơ chế chi phí thấp của UniCost.

Nhưng để hiện thực hóa tư duy Dispatching này, chúng ta vấp phải một rào cản kỹ thuật lớn: Tính toàn vẹn dữ liệu theo thời gian.

Khi bạn nói "Làm việc này sau nhé", từ "sau" đó có thể là 10ms, 100ms hoặc 5 frame sau. Nếu logic của bạn vẫn tham chiếu trực tiếp vào transform.position hay playerHealth.currentValue tại thời điểm thực thi, thì rất có thể giá trị đó đã thay đổi hoặc object đó đã bị hủy. Để gửi một công việc đi xa (về mặt thời gian và luồng), bạn không thể gửi một "hành vi" chung chung, bạn phải đóng gói nó thành một "bưu kiện" hoàn chỉnh.

2, Giải phẫu học 3 Pha (The 3-Phase Anatomy): Từ Hành vi đến Dữ liệu

Để hiện thực hóa tư duy Dispatching, chúng ta cần một quy trình chuẩn. Nếu bạn đã từng nghe đến Unity C# Job System, thì tin vui là: UniCost chính là Job System, nhưng "dễ thở" hơn gấp 10 lần.

Job System cực kỳ mạnh mẽ, nhưng nó bắt bạn phải tư duy lại toàn bộ cấu trúc dữ liệu: Phải dùng struct, phải dùng NativeArray, không được dùng string, không được dùng class. Đó là một cái giá quá đắt cho tốc độ phát triển (Development Speed).

UniCost chọn con đường trung đạo. Chúng tôi áp dụng mô hình 3 pha của Job System nhưng không cực đoan về kiểu dữ liệu. Bạn vẫn có thể dùng List, string, class... chấp nhận một chút overhead của Garbage Collection (GC) để đổi lấy sự tiện lợi.

Tuy nhiên, sự tự do này đi kèm với một lời cảnh báo đanh thép. Vì chúng ta cho phép sử dụng Reference Type (biến tham chiếu), nên những rào chắn an toàn mặc định của Job System không còn tồn tại. Rủi ro về Race Condition (Tranh chấp dữ liệu) là hoàn toàn hiện hữu nếu bạn bất cẩn. Việc cô lập dữ liệu (Data Isolation) để đảm bảo an toàn luồng là trách nhiệm tuyệt đối của Developer, không phải của thư viện. UniCost được xây dựng trên một giả định tiên quyết: Bạn hiểu rõ ranh giới của dữ liệu và bạn biết chính xác mình đang làm gì.

Nghe thì có vẻ "đao to búa lớn", nhưng thực chất việc chuyển đổi code sang UniCost chỉ đơn giản là việc bạn:

  1. Nhấc các biến Unity (Time.deltaTime, transform.position) lên đầu hàm.
  2. Gói logic tính toán vào một cái hộp (Lambda/Action).
  3. Gói logic gán kết quả vào một cái hộp khác.

Hãy xem xét một ví dụ thực tế: AI Targeting. Thay vì mỗi frame con quái vật đều phải quay mặt về phía người chơi (việc này tốn kém và không cần thiết), chúng ta hãy biến nó thành một quy trình bất đồng bộ.

Ví dụ: AI Targeting (Định hướng mục tiêu)

Thay vì viết trong Update, chúng ta viết một hàm khởi tạo chuỗi logic (Chain of Logic):

// Hàm này được gọi từ bất cứ đâu
void RequestTargeting() {
    // === PHA 1: PRE-PROCESS (Chụp ảnh dữ liệu) ===
    // Tại đây, ta lấy toàn bộ dữ liệu cần thiết từ Unity API.
    // Việc gán vào biến cục bộ (local variable) chính là tạo ra một bản sao (Snapshot).
    // Khi Lambda Capture diễn ra, nó sẽ copy giá trị này sang luồng khác an toàn.
    var myPos = transform.position;
    var targetPos = playerTransform.position;

    // Lưu ý: Đừng bao giờ gọi Time.deltaTime bên trong logic tính toán.
    // Hãy lấy nó ngay tại đây nếu bạn cần dùng.
    var dt = Time.deltaTime;

    // Gửi yêu cầu sang UniCost (Sub-thread)
    UniCost.Schedule(() => CalculateDirection(myPos, targetPos));
}

// Hàm này chạy hoàn toàn ở SUB-THREAD
// Không được phép truy cập 'transform' hay bất kỳ Unity API nào ở đây
void CalculateDirection(Vector3 start, Vector3 end) {
    // === PHA 2: CALCULATION (Tính toán thuần túy) ===
    // Tính toán nặng thoải mái: Căn bậc hai, lượng giác, check map...
    var vectorToTarget = end - start;
    var distance = vectorToTarget.magnitude;
    var direction = vectorToTarget.normalized;

    // Logic phức tạp hơn có thể nằm ở đây:
    // - Dự đoán hướng di chuyển của player (Predictive Aiming)
    // - Kiểm tra góc bù trừ...

    // === DEMO LEVEL OF FIDELITY (LOF) ===
    // Đây là sức mạnh của UniCost: Logic tự quyết định độ ưu tiên của mình.
    // Nếu mục tiêu ở gần (< 100m): Cần update gấp -> High Priority
    // Nếu mục tiêu ở xa (> 100m): Từ từ update cũng được -> Low Priority
    if (distance < 100) {
        UniCost.Sync(() => OnSync(direction), cost: 10, priority: SyncPriority.High);
    } else {
        UniCost.Sync(() => OnSync(direction), cost: 10, priority: SyncPriority.Low);
    }
}

// Hàm này quay về MAIN THREAD
void OnSync(Vector3 resultDirection) {
    // === PHA 3: POST-PROCESS (Áp dụng kết quả) ===
    // Chỉ tốn đúng 1 dòng lệnh để gán giá trị.
    ApplyRotation(resultDirection);

    // Đệ quy: Đăng ký lần chạy tiếp theo.
    // Thay vì chạy mỗi frame, ta delay 1 frame (hoặc n frame tùy setting)
    UniCost.DelayOneFrame(RequestTargeting);
}

Phân tích Giải phẫu:

  1. Pha 1 (Pre-process): Bạn thấy đấy, nó chỉ đơn giản là var myPos = transform.position. Không cần NativeArray, không cần Allocater.TempJob. Trình biên dịch C# sẽ tự động tạo ra một Closure Class để chứa các biến này và gửi sang luồng khác. Đơn giản, dễ hiểu, dù có tốn một chút heap allocation nhưng hoàn toàn kiểm soát được.
  2. Pha 2 (Calculation): Đây không chỉ là nơi tính toán, nó là Trung tâm Chỉ huy (Command Center) của logic.
    • Tính linh hoạt tối thượng: Ví dụ về LOF (Level of Fidelity) ở trên chỉ là bề nổi của tảng băng chìm. Tại đây, bạn nắm quyền điều khiển luồng (Control Flow) tuyệt đối. Từ một kết quả tính toán duy nhất, bạn có thể bắn ra 10 request Sync khác nhau tùy vào ngữ cảnh, hoặc không bắn ra cái nào cả.
    • Ví dụ: Khi quái vật trúng đạn. Nếu máu còn > 0, bạn bắn 1 request update thanh máu (Low Cost). Nhưng nếu máu <= 0, bạn bắn 1 request play animation chết, 1 request spawn effect nổ, 1 request cộng điểm cho player, và 1 request play âm thanh.
    • Tất cả sự rẽ nhánh phức tạp này (Branching) diễn ra âm thầm ở Sub-thread. Main Thread hoàn toàn không biết gì về những logic điều kiện đó, nó chỉ nhận lệnh cuối cùng: "Vẽ cái này đi". Đây chính là vũ khí tối thượng để nhúng những logic game phức tạp nhất vào dự án mà không bao giờ lo sợ làm nghẽn luồng chính.
  3. Pha 3 (Post-process): Hàm OnSync cực kỳ ngắn gọn. Nó chỉ làm đúng nhiệm vụ cập nhật View. Và quan trọng nhất, dòng UniCost.DelayOneFrame(RequestTargeting) chính là thứ thay thế cho vòng lặp Update(). Nó tạo ra một vòng lặp đệ quy bất đồng bộ, tự duy trì sự sống cho logic AI này.

3, Một sự thật cần làm rõ: UniCost vs. ECS/DOTS

Khi nhìn vào mô hình này, các kỹ sư sừng sỏ sẽ đặt câu hỏi: "Tại sao tôi phải dùng cái này thay vì ECS (Entity Component System)? ECS nhanh hơn nhiều vì nó tối ưu Memory Layout và Cache Miss."

Câu trả lời là: Bạn đúng. UniCost chắc chắn chậm hơn ECS về mặt tính toán thô (Raw Performance).

  • Về mặt kỹ thuật: Vì UniCost cho phép dùng Reference Type (class, list, string), nó chịu chi phí của GC và phân mảnh bộ nhớ (Memory Fragmentation), thứ mà ECS triệt tiêu hoàn toàn nhờ structNativeArray.
  • Về mặt con người (DX): Đây là nơi UniCost tỏa sáng. Viết ECS rất khó, debug ECS là địa ngục, và bảo trì logic game phức tạp trên ECS đòi hỏi một tư duy trừu tượng cực cao.

UniCost không sinh ra để đua tốc độ xử lý hàng triệu entity như ECS. Nó sinh ra để mở khóa cơ hội viết những hàm Async Update linh hoạt (Elastic).

  • Tính đàn hồi (Elasticity): Logic của bạn tự động giãn ra khi máy yếu (nhờ cơ chế delay/cost) và co lại khi máy mạnh, điều mà Update() cố định của Unity không làm được.
  • Tính dễ đọc (Readability): Bạn vẫn viết C# hướng đối tượng (OOP) bình thường, logic vẫn liền mạch như một câu chuyện, dễ đọc, dễ sửa, dễ debug.

Bạn không cần phải là một kỹ sư ECS đại tài để dùng UniCost. Bạn chỉ cần là một lập trình viên biết cách gom nhóm các biến (Scope variable) lên đầu hàm và biết dùng Lambda Expression. Chỉ một thay đổi nhỏ trong cách viết code, bạn đã mở khóa cánh cửa bước vào thế giới đa luồng một cách an toàn và thực dụng.

4, Kỷ luật GC: Tự do đi kèm Trách nhiệm

Một trong những lợi thế lớn nhất của UniCost so với Job System là sự tự do về kiểu dữ liệu. Bạn không bị trói buộc vào struct hay NativeArray. Bạn có thể thoải mái dùng class để đóng gói dữ liệu Pre-process, hay List<T> để chứa kết quả tính toán.

Nhưng sự tự do này không phải là một tấm vé cho phép bạn xả rác bừa bãi.

Mỗi lần bạn new List<T>() hay new MyDataClass(), bạn đang tạo ra một món nợ nhỏ với Garbage Collector (GC). Nếu bạn làm điều này hàng nghìn lần mỗi frame cho các request, món nợ đó sẽ nhanh chóng biến thành một quả bom hẹn giờ. Khi nó phát nổ, GC sẽ tạm dừng toàn bộ ứng dụng của bạn – bao gồm cả Main Thread thiêng liêng mà chúng ta đang cố gắng bảo vệ – để đi dọn dẹp. Một cú GC Spike có thể dễ dàng xóa sạch mọi nỗ lực tối ưu hóa của bạn.

Kỷ luật Pooling là bắt buộc

Dù UniCost cho phép overhead, bạn vẫn phải học cách quản lý nó. Kỹ năng Pooling (Sử dụng lại đối tượng) không còn là "nice-to-have", nó là tiêu chuẩn.

Bạn không cần phải viết một Object Pool phức tạp từ đầu. Hệ sinh thái .NET đã cung cấp sẵn những công cụ cực kỳ mạnh mẽ và dễ dùng. Ví dụ điển hình nhất là ArrayPool<T>.Shared.

  • Thay vì new MyData[100], hãy dùng ArrayPool<T>.Shared.Rent(100).
  • Dùng xong, hãy gọi ArrayPool<T>.Shared.Return(array).

Nó cho phép bạn "vay mượn" một mảng đã được cấp phát sẵn và "trả lại" khi dùng xong. Không có new, không có rác. Tương tự, bạn nên áp dụng nguyên tắc này cho mọi Reference Type khác (List, class...) bằng các thư viện Pooling có sẵn hoặc tự viết một Pool đơn giản.

Hãy nhớ lại bản hợp đồng của chúng ta: UniCost giúp bạn tiết kiệm CPU của Main Thread bằng cách đẩy gánh nặng sang các luồng phụ. Kỷ luật GC giúp bạn đảm bảo không có kẻ thứ ba nào (Garbage Collector) nhảy vào cướp đi những mili-giây quý giá đó.

Trong thế giới hiệu năng, bộ nhớ là thứ tài nguyên dồi dào và thường bị lãng phí, còn thời gian của Main Thread mới là vàng ròng. Hãy sử dụng sự dư dả của RAM để bảo vệ sự khan hiếm của CPU.

5, Kỷ luật Lambda: Đừng biến Code thành "Hố đen"

Trong các ví dụ ở trên, tôi sử dụng Lambda Expression () => ... (hàm vô danh) vì sự ngắn gọn và dễ hiểu trong ngữ cảnh bài viết. Nhưng nếu bạn mang thói quen "spam" lambda này vào dự án thực tế với UniCost, bạn đang tự đào hố chôn mình.

Có hai lý do khiến Lambda là "kẻ thù giấu mặt":

  1. Địa ngục Debug (Debug Hell): Khi có lỗi xảy ra trong một Task bất đồng bộ, Stack Trace là sợi dây cứu sinh duy nhất. Nếu bạn dùng Lambda, Stack Trace sẽ hiện lên những cái tên vô nghĩa như <RequestTargeting>b__0 hay b__1. Bạn sẽ không biết đoạn code đó nằm ở đâu, thuộc về ai.
  2. Rác bộ nhớ ngầm (Hidden Allocations): Mỗi khi bạn viết một lambda có sử dụng biến bên ngoài (Closure capture), trình biên dịch C# sẽ âm thầm new ra một class tạm để chứa các biến đó. Với tần suất gọi hàng nghìn lần mỗi frame của UniCost, bạn đang xả rác ra bộ nhớ (GC Alloc) một cách vô tội vạ mà không hề hay biết.

Tiêu chuẩn Vàng của UniCost: Static Named Methods

Thay vì dùng Lambda, hãy tập thói quen tách logic ra thành các hàm Static có tên rõ ràng.

  • Tại sao là Named Method? Để khi crash, log hiện ra EnemyAI.CalculateDirection. Bạn biết ngay lỗi ở đâu.
  • Tại sao là Static? Từ khóa static là một cái lồng bảo vệ tuyệt vời. Nó ngăn bạn vô tình truy cập vào this (instance state) hay các biến global không an toàn. Nó ép bạn phải truyền mọi dữ liệu cần thiết qua tham số (Arguments), đảm bảo tính thuần khiết (Purity) của hàm tính toán.

Lời hứa về tương lai (PRP Support): Đây không chỉ là vấn đề sạch đẹp code. Việc sử dụng Static Named Methods là điều kiện tiên quyết để bạn nhận được "món quà" ở UniCost v2.0.

Hệ thống PRP (Pre-Runtime Profiler) mà tôi đang phát triển sẽ dựa vào việc phân tích code tĩnh (Static Analysis/Reflection) để tự động đo đạc Cost cho bạn. Máy móc rất giỏi trong việc tìm và phân tích các hàm có tên, nhưng nó rất dở trong việc đoán mò ý đồ của các Lambda vô danh.

Thú thật, về mặt kỹ thuật, tôi hoàn toàn có thể viết code để hỗ trợ Profiling cho Lambda. Nhưng tôi sẽ không làm. Tôi không muốn tốn công sức để hỗ trợ một thói quen xấu (Bad Practice) làm ảnh hưởng đến khả năng bảo trì của dự án. Nếu bạn muốn được tự động hóa, hãy viết code chuẩn chỉ ngay từ hôm nay.

6, Thực chất, đây là Event-Driven Programming "trá hình"

Nếu lùi lại một bước và nhìn sâu vào cấu trúc 3 pha mà chúng ta vừa phân tích ở ví dụ AI Targeting, bạn sẽ nhận ra một sự thật thú vị: UniCost đang ép bạn viết code theo mô hình Hướng sự kiện (Event-Driven), dù bạn có ý thức được điều đó hay không.

Khi bạn bị tước bỏ quyền truy cập trực tiếp vào Unity API trong logic chính, bạn buộc phải tư duy theo hướng "Dữ liệu vào -> Xử lý -> Dữ liệu ra".

  • Pha 1 (Pre-process/Snapshot): Chính là việc bạn đóng gói một Event Payload (Dữ liệu sự kiện). Bạn đang tạo ra một bản tin bất biến (Immutable Message) chứa trạng thái của thế giới tại frame đó.
  • Pha 2 (Calculation): Chính là một Pure Function (Hàm thuần túy) hoặc một Event Handler. Nó nhận dữ liệu, xử lý và trả về kết quả mà không gây ra tác dụng phụ (Side-effect) lên Main Thread.
  • Pha 3 (Post-process): Chính là Callback/Reaction. Nơi kết quả được áp dụng trở lại thực tại.

Hệ quả tuyệt vời của sự ép buộc này là tính Testability (Khả năng kiểm thử). Trước đây, bạn không thể viết Unit Test cho hàm Update() vì nó dính chặt lấy transformTime. Nhưng với UniCost, hàm CalculateDirection ở Pha 2 là một hàm C# thuần khiết. Bạn có thể lôi nó ra, viết test case, chạy giả lập hàng nghìn kịch bản mà không cần mở Unity Editor lên. Bạn vô tình đạt được chuẩn mực Clean CodeDecoupling (Giảm sự phụ thuộc) mà không cần cố gắng.

Tuy nhiên, hãy khắc cốt ghi tâm điều này:

Chúng ta đã thỏa thuận ở phần trên rằng UniCost cho phép sử dụng Reference Type (class, List) để đổi lấy sự tiện lợi. Điều này đồng nghĩa với việc tấm khiên bảo vệ của Job System đã bị gỡ bỏ.

Thread Safety là trách nhiệm Sinh tử của Bạn.

UniCost đóng vai trò là Cảnh sát Giao thông: Nó đảm bảo xe (Task) chạy đúng làn, đúng giờ, không vượt đèn đỏ (Budget). Nhưng UniCost không phải là Xe bọc thép: Nó không bảo vệ hàng hóa (Dữ liệu) trên xe khỏi bị tấn công.

  • Nếu ở Pha 1, thay vì copy dữ liệu (struct copy hoặc To_Array), bạn lại truyền tham chiếu của một static List<Enemy> vào Pha 2.
  • Và trong khi Pha 2 đang chạy ở luồng phụ, một luồng khác (hoặc chính Main Thread) thò tay vào sửa cái List đó (Add/Remove).

Thì Race Condition sẽ xảy ra. Game sẽ crash, hoặc tệ hơn là dữ liệu bị sai lệch ngẫu nhiên mà không có log lỗi. UniCost đảm bảo thời điểm chạy (Scheduling), nhưng bạn phải đảm bảo sự toàn vẹn của dữ liệu chạy (Data Integrity). Hãy chắc chắn rằng gói "Snapshot" ở Pha 1 là tài sản riêng của luồng phụ, hoặc là dữ liệu bất biến (Immutable), đừng bao giờ chia sẻ một biến sống (Shared State) mà không có cơ chế bảo vệ.

B, Chế độ Debug vs. Runtime: Khiên và Gươm

Trong lập trình bất đồng bộ, một trong những nỗi đau lớn nhất là gỡ lỗi (Debugging). Một ngoại lệ (Exception) không được xử lý đúng cách trong một Task có thể khiến toàn bộ ứng dụng bị crash mà không để lại bất kỳ dấu vết nào. UniCost hiểu rõ nỗi đau này, nhưng cũng hiểu rằng sự an toàn tuyệt đối luôn phải trả giá bằng hiệu năng.

Vì vậy, UniCost cung cấp hai chế độ hoạt động riêng biệt, giống như việc bạn trang bị "Khiên" khi tập luyện và cầm "Gươm" khi ra trận thực sự.

1, Debug Mode: Người bảo mẫu tận tụy

Khi bạn làm việc trong Unity Editor (#if UNITY_EDITOR), UniCost sẽ tự động chuyển sang chế độ an toàn tối đa.

  • Try-Catch toàn diện: Mỗi một Delegate, mỗi một Callback mà bạn truyền vào UniCost sẽ được bọc trong một khối try-catch riêng biệt.
    • Hệ quả: Nếu code của bạn ném ra một Exception, UniCost sẽ bắt được nó, ghi log chi tiết (bao gồm cả Stack Trace đã được capture từ lúc bạn đăng ký request), và ngăn không cho Exception đó làm sập cả hệ thống. Game vẫn chạy, và bạn có đầy đủ thông tin để tìm ra lỗi.
  • Logging chi tiết: Mọi hành động của Scheduler và Syncer đều được ghi lại.
    • Bạn sẽ biết chính xác request nào được gộp, request nào bị trì hoãn, ngân sách mỗi frame là bao nhiêu, và tại sao một task được ưu tiên. Nó giống như việc bạn có một hộp đen ghi lại toàn bộ lịch trình bay.
  • Chi phí: Sự an toàn này không miễn phí. Chi phí của một khối try-catch, dù không có lỗi xảy ra, vẫn tồn tại. Việc ghi log liên tục cũng tiêu tốn tài nguyên I/O. Ở chế độ này, UniCost sẽ chậm hơn đáng kể, nhưng nó cho phép bạn tập trung hoàn toàn vào việc sửa lỗi logic mà không phải lo lắng về các vấn đề của hệ thống bất đồng bộ.

2, Runtime Mode: Đấu trường sinh tử ("No Mercy")

Khi bạn build game ra bản Release (#else), UniCost sẽ lột bỏ toàn bộ lớp áo bảo vệ.

  • Tối giản hóa try-catch: Hầu hết các khối try-catch bao bọc từng callback sẽ bị loại bỏ thông qua biên dịch có điều kiện (Conditional Compilation). Chỉ còn lại một khối try-catch duy nhất nằm ở lớp vỏ ngoài cùng của Syncer.
    • Mục đích: Khối try-catch cuối cùng này không phải để "cứu" bạn. Nó chỉ có một nhiệm vụ duy nhất: Ngăn không cho một Exception đơn lẻ làm sập toàn bộ luồng Main Thread. Nó sẽ bắt lỗi, có thể ghi một log tối thiểu, nhưng sẽ không cố gắng phục hồi.
  • Tắt toàn bộ Logging: Mọi lời gọi ghi log chi tiết đều bị loại bỏ.
  • Triết lý "No Mercy": Ở chế độ này, UniCost giả định rằng bạn đã test kỹ code của mình.
    • Nếu một callback của bạn ném ra Exception, đó là lỗi của bạn.
    • Hệ thống có thể sẽ bỏ qua task đó, hoặc tệ hơn là rơi vào một trạng thái không xác định.
    • UniCost ưu tiên tốc độ tuyệt đối. Nó sẽ không lãng phí dù chỉ một nanosecond cho những việc không phải là thực thi logic.

Tại sao lại cần sự cực đoan này? Bởi vì trong một môi trường hiệu năng cao, sự khác biệt giữa một hàm có và không có try-catch là có thật. Khi bạn nhân sự khác biệt đó lên hàng nghìn, thậm chí hàng chục nghìn lần mỗi frame, nó sẽ trở thành một gánh nặng đáng kể.

Chế độ Runtime của UniCost là lời cam kết về hiệu suất. Nó tin tưởng vào kỷ luật và sự chuyên nghiệp của bạn, và đổi lại, nó trao cho bạn tốc độ thực thi gần nhất với code C# thuần túy nhất có thể.

6, Tác động Vĩ mô: Định nghĩa lại Hiệu năng (Game Changer)

Nãy giờ chúng ta đã đi sâu vào "cơ khí": try-catch, lambda, queue... Nhưng mục đích cuối cùng của việc chế tạo ra một cỗ máy phức tạp như UniCost không phải để ngắm nhìn sự phức tạp đó. Mục đích là để thay đổi cuộc chơi.

Trong hàng thập kỷ qua, hiệu năng trong game là một phương trình nhị phân tàn khốc: Mượt hoặc Lag. Người chơi có phần cứng xịn thì được hưởng sự mượt mà. Người chơi có máy yếu thì phải chấp nhận những cú giật, những pha tụt FPS thảm họa phá hỏng hoàn toàn trải nghiệm. Các tùy chọn đồ họa (Graphic Settings) chỉ giúp giảm tải một phần, nhưng không giải quyết được gốc rễ vấn đề khi logic game trở nên quá phức tạp.

UniCost giới thiệu một chiều không gian thứ ba vào phương trình này.

A, Sync Speed - Một khái niệm mới trong Menu Settings

Hãy tưởng tượng, trong menu Settings của game bạn, bên cạnh "Graphic Quality", giờ đây có thêm một thanh trượt mang tên "Sync Speed" (Tốc độ Đồng bộ), với các mức từ Low đến Ultra.

Đây không phải là một mánh khóe marketing. Nó là sự kiểm soát trực tiếp đối với "độ hung hăng" (Aggressiveness) của Scheduler trong UniCost.

  • Sync Speed: Ultra -> Scheduler được cấp một ngân sách cực lớn, nó sẽ cố gắng thực thi mọi request ngay lập tức.
  • Sync Speed: Low -> Scheduler bị thắt lưng buộc bụng, nó sẽ ưu tiên trì hoãn tất cả các task không khẩn cấp để bảo vệ Frame Rate bằng mọi giá.

Điều này tạo ra một phổ trải nghiệm (Spectrum of Experience) hoàn toàn mới:

Kịch bản 1: Trên một PC Gaming cấu hình khủng (Sync Speed: Ultra)

  • Scheduler có ngân sách dồi dào. Mọi request từ AI, UI, Particle... đều được duyệt ngay lập tức.
  • Người chơi bắn trúng quái vật -> Thanh máu cập nhật trong cùng 1 frame, hiệu ứng tóe máu bay ra, âm thanh nổ vang lên.
  • Trải nghiệm: Phản hồi tức thì, mượt mà tuyệt đối. Thế giới game sống động và chân thực.

Kịch bản 2: Trên một chiếc điện thoại Android đời cũ (Sync Speed: Low)

  • Scheduler bị siết chặt ngân sách. Nó nhận ra Main Thread đang "thoi thóp" chỉ để kịp vẽ khung hình.
  • Người chơi bắn trúng quái vật. Logic tính toán sát thương đã chạy xong ở luồng phụ từ lâu.
    • Frame 1-5: Logic game vẫn chạy ở 60fps. Camera vẫn xoay mượt. Nhân vật vẫn di chuyển trơn tru. Người chơi không cảm thấy lag.
    • Frame 6: Scheduler thấy có một khoảng trống nhỏ trong ngân sách. Nó quyết định: "Ok, đủ tiền để cập nhật thanh máu rồi." -> Thanh máu của quái vật tụt xuống.
    • Frame 10: Scheduler lại có thêm chút tiền. "Đủ để spawn một hiệu ứng tóe máu nhỏ." -> Particle bay ra.
  • Trải nghiệm: Logic game không bao giờ lag. Chỉ có độ trễ thông tin (Informational Latency). Thế giới game vẫn phản hồi với người chơi một cách hoàn hảo, chỉ là các hệ thống phụ trợ (UI, VFX) cập nhật chậm hơn, giống như xem một bộ phim stop-motion tinh vi chỉ dành cho các chi tiết không quan trọng.

Thánh đường mới: Tách biệt Tỷ lệ Mô phỏng và Tỷ lệ Khung hình

Đây chính là sự thay đổi cuộc chơi: UniCost cho phép Tỷ lệ Mô phỏng (Simulation Rate) chạy độc lập với Tỷ lệ Khung hình (Frame Rate).

Trên máy yếu, mô phỏng của bạn vẫn chạy ở 60 "tick" logic mỗi giây, đảm bảo gameplay không bị sai lệch. Chỉ có "tốc độ hiển thị" kết quả của mô phỏng đó là được co giãn một cách linh hoạt.

UniCost không loại bỏ "lag". Nó định nghĩa lại "lag".

"Lag" không còn là những cú giật cục (Stutter) làm game của bạn bị đóng băng. "Lag" giờ đây được chuyển hóa thành Độ trễ Thông tin (Informational Latency), và mức độ trễ này co giãn một cách thông minh:

  • Trên máy tầm trung: Sự chậm trễ này có thể chỉ là vài khung hình. Con số sát thương hiện lên trễ 30-50ms. Người chơi thậm chí không hề nhận ra sự khác biệt. Thứ duy nhất họ cảm nhận được là game bỗng nhiên mượt mà một cách kỳ lạ, các cú sụt giảm 1% FPS (1% Low FPS) gần như biến mất vì các đỉnh nhọn hiệu năng (Spikes) đã được UniCost san phẳng và rải đều ra nhiều frame.
  • Trên máy cực yếu: Sự chậm trễ có thể lên tới nửa giây hoặc hơn. Các hiệu ứng phụ (VFX) ít quan trọng có thể bị bỏ qua hoàn toàn. UI cập nhật một cách ì ạch. Nhưng điều kỳ diệu là: Nhân vật vẫn di chuyển, camera vẫn xoay, và game vẫn chơi được. UniCost đã hy sinh mọi thứ không cốt lõi để bảo vệ trải nghiệm tương tác tối thiểu.

Và ngay cả trên những cỗ máy mạnh nhất, UniCost vẫn không phải là thừa thãi.

Lúc này, nó không còn đóng vai trò "cứu tinh" mà chuyển thành:

  1. Bộ ổn áp (Stabilizer): Ngay cả PC mạnh nhất cũng có những lúc bị "hắt hơi sổ mũi" do một tiến trình nền của OS hay một đoạn code chưa tối ưu. UniCost sẽ là tấm đệm hấp thụ những cú sốc nhỏ này, đảm bảo một Frame Time phẳng lì như mặt hồ.
  2. Cầu nối Vàng (The Golden Bridge): Nó cung cấp một con đường an toàn và dễ dàng nhất để bước vào thế giới đa luồng. Bạn có thể tự tin viết những logic game cực kỳ phức tạp (AI bầy đàn, mô phỏng kinh tế...) mà không cần phải là chuyên gia về ECS hay Job System.
  3. Công cụ Tối ưu hóa "Lười biếng": Với cơ chế Aggregated Request, lập trình viên vừa viết code nhàn hơn (không cần tự quản lý debounce/throttle), lại vừa đạt được hiệu năng đỉnh cao.

Kết quả cuối cùng: Toàn bộ sức mạnh này được mở khóa chỉ nhờ việc thay đổi một thói quen tưởng chừng như đơn giản – tách rời Dữ liệu và Hiển thị. Có thể chính bạn, ngay lúc này, đã và đang vô thức làm điều đó trong những đoạn code tâm huyết nhất của mình. UniCost chỉ đơn giản là hệ thống hóa và biến nó thành một kỷ luật.

Chỉ với một thay đổi đó, chúng ta mở ra tiềm năng cho cả một thế giới mới. Một thế giới nơi Trải nghiệm Lập trình (DX) được coi trọng, nhưng không đồng nghĩa với việc sản phẩm cuối cùng có chất lượng thấp.

Nó là lời khẳng định cho một triết lý: "Code smart, not hard... and in this case, code strict!"

B, LOF (Level of Fidelity) - Tương lai của LOD

Nếu "Sync Speed" là cú đấm trực diện vào bài toán hiệu năng, thì LOF (Level of Fidelity) chính là cú "uppercut" tinh vi, thứ vũ khí sẽ định hình nên những thế giới game quy mô lớn trong tương lai.

Chúng ta đều quen thuộc với LOD (Level of Detail). Một khái niệm đơn giản: Vật thể ở xa thì dùng model ít đa giác hơn, vật thể ở gần thì dùng model chi tiết. LOD là tối ưu hóa cho mắt nhìn.

LOF áp dụng triết lý tương tự, nhưng là cho não suy nghĩ.

Tại sao LOF lại khả thi? Nguyên lý "Độ lệch Logic" (Logical Delta)

Hãy tưởng tượng một con quái vật đang lao về phía bạn.

  • Khi nó cách bạn 10m: Mỗi bước chân của bạn đều làm thay đổi đáng kể góc tấn công của nó. Nó phải liên tục điều chỉnh hướng (update logic) mỗi frame để không bị hụt. Độ lệch Logic là rất lớn.
  • Khi nó cách bạn 1000m: Bạn di chuyển 1 mét sang trái. Hướng vector từ nó đến bạn gần như không thay đổi. Độ lệch Logic gần bằng 0.

Vậy tại sao chúng ta phải tốn tài nguyên CPU quý giá để ra lệnh cho nó "tính toán lại hướng" mỗi 16ms, trong khi kết quả gần như y hệt lần tính toán trước? Đây chính là sự lãng phí mà LOF được sinh ra để triệt tiêu.

LOF khác LOD: Co giãn chứ không phải "Ngủ" (Elasticity vs. Sleep)

LOD thường hoạt động theo cơ chế nhị phân: Bật/Tắt (Animate/Sleep) hoặc Thay thế (Swap Mesh). LOF thì không. Con quái vật ở xa 1000m vẫn đang sống, vẫn đang di chuyển, logic của nó vẫn tồn tại trong thế giới game. Nó phải được cập nhật, nếu không nó sẽ bị đứng im mãi mãi.

LOF hoạt động dựa trên sự co giãn thời gian (Time Elasticity).

  • Enemy ở gần 1m: Priority = High. UniCost sẽ cố gắng chạy logic update của nó mỗi frame.
  • Enemy ở xa 1000m: Priority = Low. UniCost sẽ trì hoãn logic update của nó vài chục, thậm chí vài trăm frame. Con quái vật vẫn tiếp tục di chuyển theo hướng cũ đã được tính toán, và chỉ khi đến lượt, nó mới "ngẩng đầu" lên nhìn xem mục tiêu đã đi đâu và tính toán lại.

Bạn có thể tự làm một phiên bản LOF ngay bây giờ

Thực chất, trong ví dụ AI Targeting ở phần trước, chúng ta đã vô tình viết ra một hệ thống LOF siêu đơn giản:

if (distance < 100)
    UniCost.Sync(..., priority: SyncPriority.High);
else
    UniCost.Sync(..., priority: SyncPriority.Low);

Chỉ với một câu lệnh if, bạn đã dạy cho con AI biết tự lượng sức mình. UniCost chính là bộ khung cung cấp các công cụ (Priority, Cost, Delay) để bạn hiện thực hóa những logic như thế này một cách dễ dàng.

Sự phức tạp và tiềm năng của một hệ thống LOF thực thụ

Một hệ thống LOF hoàn chỉnh phức tạp hơn LOD rất nhiều, vì "tâm" (Pivot) quyết định độ ưu tiên không phải lúc nào cũng là Camera hay người chơi.

  • Ví dụ 1 (Đơn giản - The Charger): Con quái chỉ biết lao thẳng vào người chơi. Tâm LOF chính là người chơi. Khi ở cực xa, logic MoveForward của nó vẫn có thể chạy mỗi frame (vì việc này rẻ), nhưng logic UpdateTargetDirection đắt đỏ thì chỉ cần chạy mỗi 2-3 giây. Thậm chí, bạn có thể ra lệnh cho nó teleport một đoạn ngắn thay vì di chuyển liên tục, tiết kiệm tuyệt đối chi phí tính toán vật lý mà người chơi không bao giờ nhận ra.
  • Ví dụ 2 (Phức tạp - The Healer): Con quái này không quan tâm người chơi. Nhiệm vụ của nó là buff máu cho đồng đội. Lúc này, tâm LOF của nó không phải là bạn, mà là con quái đồng minh gần nhất đang bị thương. Logic của nó phải tự quyết định độ ưu tiên dựa trên trạng thái của thế giới xung quanh nó, một bài toán mà LOD không bao giờ chạm tới.

UniCost, với khả năng cho phép logic ở Pha 2 tự quyết định Priority và Cost của chính mình ở Pha 3, đã trở thành nền nền tảng hoàn hảo để xây dựng những hệ thống AI tự nhận thức và tự tối ưu hóa như vậy.

Sự đánh đổi của LOF: Xấp xỉ vs. Hoàn hảo

Tại đây, tôi cần phải thẳng thắn một lần nữa: Trên quy mô cực lớn (hàng chục nghìn đơn vị), một hệ thống LOF xây dựng trên UniCost chắc chắn sẽ không nhanh bằng một hệ thống được viết bằng ECS/DOTS thuần túy.

Vậy tại sao chúng ta lại cần LOF?

Vì mục tiêu của LOF và ECS hoàn toàn khác nhau.

  • ECS theo đuổi sự hoàn hảo mô phỏng (Simulation Perfection). Nó cố gắng mô phỏng TẤT CẢ các đơn vị với độ chính xác tuyệt đối ở MỌI thời điểm, và nó đạt được điều đó bằng cách hy sinh Trải nghiệm Lập trình (DX).
  • LOF theo đuổi sự xấp xỉ hiệu quả (Efficient Approximation). Nó đặt câu hỏi: "Làm thế nào để tạo ra một ảo giác về một thế giới sống động khổng lồ với chi phí thấp nhất, cả về DX lẫn runtime?"

Nếu bạn nhìn qua lăng kính này, bạn sẽ nhận ra sức mạnh thực sự của LOF. Nó không cố gắng tạo ra một thế giới hoàn hảo. Nó cố gắng tạo ra một Lightweight Simulator (Bộ mô phỏng hạng nhẹ):

  • Độ chính xác cực cao khi người chơi đang quan sát (ở gần).
  • Quy mô khổng lồ ở cấp độ tổng thể.
  • Và cái giá phải trả là: Một chút sự không hoàn hảo ở những nơi không ai nhìn thấy. Con quái ở xa có thể đi lệch vài pixel, một vụ nổ ở chân trời có thể diễn ra trễ nửa giây.

UniCost và LOF trao cho bạn quyền năng để viết những logic phức tạp đó một cách dễ dàng, điều mà ECS không thể. Nó cho phép bạn xây dựng những thế giới rộng lớn không phải bằng sức mạnh tính toán thô, mà bằng sự khôn ngoan trong việc phân bổ tài nguyên.

7, Tương lai của UniCost: Giấc mơ về PRP (Pre-Runtime Profiler)

Chúng ta đã đi qua một hành trình dài để xây dựng nên một cỗ máy điều phối hiệu năng mạnh mẽ. Nhưng có một sự thật vẫn luôn lấn cấn trong tâm trí tôi, một "lỗ hổng niềm tin" mà chúng ta vẫn phải chấp nhận ở phiên bản hiện tại.

Vấn đề cốt lõi của UniCost v1.0: Nghệ thuật của sự "Đoán mò"

Dù chúng ta đã nói về việc "Cost là Cảm giác", thì sự thật là "cảm giác" đó phụ thuộc quá nhiều vào kinh nghiệm của Senior Developer. Một Junior Dev, dù code rất logic, sẽ không thể nào có đủ trực giác để ước lượng một hàm Instantiate nặng gấp bao nhiêu lần một hàm SetText. Điều này tạo ra một rào cản kỹ năng khổng lồ, khiến UniCost trở thành một công cụ kén người dùng.

Tầm nhìn của UniCost v2.0: PRP - Khi máy móc tự định giá

Để phá vỡ rào cản đó, tôi có một giấc mơ, một "wet dream" về mặt kỹ thuật mang tên PRP (Pre-Runtime Profiler).

Ý tưởng rất đơn giản: Tại sao chúng ta phải đoán, trong khi chúng ta có thể đo?

Thay vì bắt Dev phải tự điền Cost, PRP sẽ là một công cụ chạy ngầm, tự động làm việc đó cho chúng ta. Nó sẽ hoạt động dựa trên những kỹ thuật cực kỳ sâu ở tầng biên dịch và phân tích code:

  • Static Analysis (Phân tích tĩnh): PRP sẽ quét toàn bộ codebase của bạn, tìm ra tất cả các hàm Post-process được đăng ký với UniCost.
  • IL Weaving / Code Instrumentation: Đây là phần "phép thuật". Trong quá trình Build game hoặc chạy một phiên Test tự động, PRP sẽ tiêm (Inject) các đoạn mã đo thời gian (Profiling code) vào đầu và cuối các hàm Post-process đó.
  • Data Collection & Aggregation: Nó sẽ chạy game của bạn (hoặc một bộ test case) một vài lần, thu thập hàng triệu điểm dữ liệu về thời gian thực thi của từng hàm trong các điều kiện khác nhau, sau đó tính toán ra một con số Cost trung bình và lưu nó lại.

Kết quả là gì?

  1. Cost trở thành Data-driven và Đa chiều (Multi-dimensional): "Cost" không còn là một con số cảm tính duy nhất. Nó sẽ được phân rã thành một vector chi phí, được đo đạc bằng chứng thực nghiệm từ chính phần cứng của bạn.

    • CPU Cost: Thời gian thực thi thuần túy của các lệnh trên CPU.
    • GC Cost (Allocation Cost): Lượng bộ nhớ mà hàm này cấp phát, một chỉ số dự báo về gánh nặng tương lai lên Garbage Collector.
    • IO Cost: (Dành cho các tác vụ đặc biệt) Độ trễ khi phải chờ đợi các hoạt động I/O như đọc file hay truy vấn mạng.
    • ... và nhiều tiềm năng khác.

    Scheduler tương lai sẽ không chỉ nhìn vào một con số. Nó sẽ nhìn vào một "hồ sơ sức khỏe" đa chiều của từng tác vụ để ra quyết định thông minh hơn. Ví dụ: "Ngân sách CPU còn nhiều, nhưng bộ nhớ đang căng, ưu tiên chạy các task ít allocation trước."

  2. PGO lên tầng Application - Xây dựng "Bộ nhớ" cho Scheduler: Chúng ta đang mang kỹ thuật PGO (Profile-guided optimization) – thứ vốn chỉ tồn tại ở tầng Compiler để tối ưu mã máy – lên tầng Application để tối ưu lịch trình logic game.

    • Cơ sở dữ liệu Pre-runtime: Kết quả đo đạc từ PRP sẽ không bị vứt đi. Nó sẽ được lưu vào một Cơ sở dữ liệu Cost (Pre-runtime Cost DB) nhẹ, đi kèm với bản build của game.
    • Bộ nhớ Lịch sử: Khi game khởi động trên máy người dùng, Scheduler sẽ nạp bộ dữ liệu này. Nó không còn là một kẻ "mất trí nhớ" đoán mò ở mỗi phiên chạy. Nó bắt đầu với một bộ dữ liệu lịch sử đáng tin cậy về "giá cả" của từng hành động.
    • Tự tinh chỉnh (Fine-tuning): Hơn thế nữa, Scheduler có thể tiếp tục thu thập dữ liệu hiệu năng ngay trên máy người dùng (một cách ẩn danh và cực nhẹ) để tự tinh chỉnh (fine-tune) lại bảng giá cho phù hợp với phần cứng cụ thể đó. Một game "tiến hóa" để chạy nhanh hơn theo thời gian.
  3. Hạ thấp rào cản - Tách biệt Logic và Tối ưu hóa: Đây là mục tiêu cuối cùng: Giải phóng developer khỏi gánh nặng phải "cảm nhận" hiệu năng.

    • Quy trình làm việc trong mơ: Một Junior Dev chỉ cần tập trung vào việc viết logic game đúng đắn, tuân thủ kỷ luật 3 pha và Static Named Methods. Trước khi build bản final, họ chỉ cần chạy một lệnh: Run PRP. Xong. Toàn bộ Cost sẽ được tự động điền.
    • Giải quyết Dynamic Cost: Ngay cả với các tác vụ có Cost động (ví dụ: Instantiate N đối tượng), PRP vẫn có thể giúp. Nó có thể đo và cung cấp cho bạn cost của việc Instantiate 1 đối tượng. Khi đó, ở runtime, bạn chỉ cần lấy N * baseCost. Con số của bạn vẫn sẽ chính xác hơn rất nhiều so với việc đoán mò hoàn toàn.
    • Tiềm năng vô hạn: Trong tương lai xa hơn, các kỹ thuật phân tích tĩnh (Static Analyzing) thậm chí có thể thông minh đến mức tự nhận diện các vòng lặp, tự phân tích độ phức tạp thuật toán và tự động sinh ra các công thức tính Dynamic Cost cho bạn.

Hệ quả cuối cùng là một sự phân chia lao động hoàn hảo: Developer viết Logic, Máy móc lo Tối ưu hóa. Và đó là một tương lai đáng để chúng ta nỗ lực hướng tới.

Một lời thú nhận từ trái tim

Nãy giờ, tôi đã vẽ ra một viễn cảnh có phần quá lãng mạn về PRP. Nhưng tôi phải thành thật với các bạn, và với chính bản thân mình: tại thời điểm hiện tại, phần lớn những gì tôi vừa mô tả vẫn còn nằm trên giấy. Nó là giấc mơ lớn nhất của tôi cho dự án này.

Sự thật là, năng lực của cá nhân tôi có hạn. Để có thể làm chủ những kỹ thuật sâu và phức tạp như IL Weaving hay xây dựng một hệ thống phân tích code đủ thông minh, đòi hỏi một nguồn lực và kiến thức mà một mình tôi chưa thể gánh vác.

Việc xây dựng một hệ thống PRP ổn định, đáng tin cậy và dễ dùng là một con đường rất dài. Có thể nó sẽ ngốn của tôi vài năm thanh xuân, thậm chí cả chục năm nữa mới có thể thành hình. Tôi không ảo tưởng về điều đó.

Sự tồn tại của nó trong tương lai không còn nằm ở tôi nữa. Nó phụ thuộc rất nhiều vào các bạn - những người đang đọc bài viết này. Phụ thuộc vào sự đón nhận, sự góp ý, và có thể một ngày nào đó là sự chung tay đóng góp của cộng đồng.

Nhưng dù cho con đường có gian nan đến đâu, tôi vẫn tin rằng đây là bước tiến hóa tất yếu mà chúng ta phải đi. Tự động hóa việc đo lường hiệu năng chính là mảnh ghép cuối cùng, là cây cầu nối để biến UniCost từ một công cụ sắc bén nhưng kén chọn của chuyên gia, trở thành một tiêu chuẩn mà bất kỳ ai cũng có thể tiếp cận.

8, Kết luận

Chúng ta đã đi cùng nhau một hành trình dài. Từ những nguyên tử phần mềm khô khan của kiến trúc Von Neumann, đến nền tảng lý thuyết của BBDS, và hôm nay, chúng ta đã cùng nhau mổ xẻ "nội tạng" của UniCost. Chúng ta đã thấy từng bánh răng, từng piston của cỗ máy được thiết kế để thuần hóa thời gian.

Nếu có một điều duy nhất tôi muốn bạn mang theo sau bài viết này, đó chính là:

UniCost không chỉ là một thư viện, nó là một cuộc cách mạng trong tư duy.

Chúng ta đã dành quá nhiều năm bị ám ảnh bởi việc "Tối ưu Code" – làm sao để một hàm chạy nhanh hơn từ 1ms xuống 0.8ms. UniCost buộc chúng ta phải đặt một câu hỏi lớn hơn, quan trọng hơn: "Tối ưu Lịch trình". Chúng ta đã tự tạo ra một con hào kỹ thuật quá lớn quanh việc đa luồng, lớn đến nỗi phần lớn lập trình viên còn không dám chạm vào. Đã đến lúc san phẳng con hào đó.

Câu hỏi không còn là "Làm sao cho nhanh?", mà phải là "Có thực sự cần làm việc này ngay bây giờ không?"

Nhưng lý thuyết thì luôn màu xám, chỉ có cây đời mãi xanh tươi. Một bản thiết kế dù đẹp đến đâu cũng chỉ là giấy, cho đến khi nó được đổ xăng, nổ máy và lao đi trên đường đua.

Vì vậy, tôi xin thông báo: Trong Quý 1 năm 2026, tôi sẽ Open Source dự án GPlay.UniCost cho cộng đồng.

Đây là một dự án tâm huyết của Global Play Studio và do chính bản thân tôi chỉ đạo nhằm hiện thực hóa ý tưởng này. Đây là một món nợ đã đeo bám tôi suốt 4 năm lập trình Unity, từ những game hyper-casual nhỏ nhất tới những sản phẩm phức tạp nhất, hay những library SDK tùy biến rắc rối nhất. Tất cả luôn bị kìm hãm bởi chính những giới hạn cố hữu của Engine, và tôi không chấp nhận thực tế đó nữa.

Đây là một con đường dài và đầy chông gai, nhưng tôi tin vào nó.

Và tôi sẵn sàng dành phần đời còn lại của mình để đi trên con đường đó.


Trước khi UniCost chính thức được Open Source, sẽ có thêm những bài viết phân tích Case Study thực tế, những công cụ hỗ trợ (thậm chí không chỉ dành cho Unity Dev) được công bố. Mong các bạn tiếp tục đón xem.

Thanks for reading.

Axolotl (contact@axx83.com)

R&D Manager - Global Play Studio

CTO - Global Cognify


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí