Tìm hiểu về quá trình kết xuất trong vòng lặp trò chơi.

Một cách rất phổ biến để triển khai vòng lặp trò chơi (game loop) trông giống như sau:

while (playing) {
    advance state by one frame
    render the new frame
    sleep until it’s time to do the next frame
}

Có một vài vấn đề với điều này. Vấn đề cơ bản nhất chính là là ý tưởng mà trò chơi có thể định nghĩa một "khung hình" ("frame"). Các màn hình sẽ làm mới ở các tốc độ riêng và tỷ lệ đó có thể thay đổi theo thời gian. Nếu bạn tạo các khung hình nhanh hơn khả năng hiển thị của màn hình, thỉnh thoảng bạn sẽ phải bỏ một khung hình. Nếu bạn tạo khung hình quá chậm, SurfaceFlinger sẽ định kỳ không tìm thấy bộ đệm mới để nạp và sẽ hiển thị lại khung trước. Cả hai tình huống này đều có thể gây ra trục trặc dễ thấy.

Việc bạn cần làm là khớp tốc độ khung hình của màn hình và tiến trình chơi trò chơi theo thời lượng đã trôi qua kể từ khung hình trước đó. Có một số cách để giải quyết vấn đề này:

  • Sử dụng thư viện Android Frame Pacing (khuyến nghị)
  • Nhồi cho BufferQueue đầy và dựa vào áp suất ngược của "bộ đệm hoán đổi"
  • Sử dụng Choreographer (API 16 trở lên)

Thư viện Android Frame Pacing (Tốc độ khung hình cho Android)

Hãy xem mục Đạt đúng tốc độ khung hình để biết thông tin về cách sử dụng thư viện này.

Nhồi nhét hàng đợi

Điều này rất dễ triển khai: chỉ cần hoán đổi vùng đệm nhanh nhất có thể. Trong các phiên bản Android cũ, điều này thực sự có thể dẫn đến một "hình phạt" là SurfaceView#lockCanvas() sẽ khiến bạn "ngủ" trong 100 mili giây. Giờ đây BufferQueue đã hỗ trợ tốc độ này và BufferQueue sẽ được dọn sạch nhanh chóng nhất trong khả năng của SurfaceFlinger.

Bạn có thể xem một ví dụ về phương pháp này trong Android Breakout. Ví dụ này sử dụng GLSurfaceView, chạy trong một vòng lặp gọi lệnh gọi lại onDrawFrame() của ứng dụng và sau đó hoán đổi vùng đệm. Nếu BufferQueue đã đầy, lệnh gọi eglSwapBuffers() sẽ đợi cho đến khi có vùng đệm. Các vùng đệm trở nên sẵn sàng khi SurfaceFlinger giải phóng chúng. Việc này sẽ được thực hiện sau khi nạp dữ liệu mới để hiển thị. Vì điều này xảy ra trên VSYNC, nên thời gian của vòng lặp vẽ sẽ khớp với tốc độ làm mới. Hầu hết là như vậy.

Có một vài vấn đề với phương pháp này. Trước tiên, ứng dụng gắn với hoạt động SurfaceFlinger. Thời gian cho quá trình này còn tuỳ thuộc vào khối lượng công việc và thời gian CPU phải xử lý các quá trình khác. Vì trạng thái trò chơi tiến triển theo thời gian giữa các lần hoán đổi vùng đệm, nên ảnh động sẽ không cập nhật theo tỷ lệ nhất quán. Tuy nhiên, khi chạy ở tốc độ 60 khung hình/giây với mức không nhất quán được tính trung bình theo thời gian, thì bạn có thể sẽ không nhận thấy mức tăng giảm đột biến đó.

Thứ hai, vài lần hoán đổi vùng đệm đầu tiên sẽ xảy ra rất nhanh vì BufferQueue vẫn chưa đầy. Thời gian được tính toán giữa các khung sẽ gần bằng 0, vì vậy trò chơi sẽ tạo một số khung trong đó không có gì thay đổi. Trong một trò chơi như Breakout, màn hình cập nhật mỗi lần làm mới, hàng đợi luôn đầy ngoại trừ khi trò chơi mới bắt đầu (hoặc khi vừa hủy tạm dừng), vì vậy hiệu ứng này không đáng kể. Nếu một trò chơi hỉnh thoảng dừng các hình ảnh động rồi trở lại chế độ hoạt động nhanh nhất có thể, thì có thể trò chơi đó đang gặp trục trặc bất thường.

Choreographer

Choreographer cho phép bạn đặt một lệnh gọi lại kích hoạt trên VSYNC tiếp theo. Thời gian VSYNC thực tế được đưa vào dưới dạng đối số. Vì vậy, ngay cả khi ứng dụng của bạn không được đánh thức ngay lập tức, bạn vẫn có hình ảnh chính xác về thời điểm bắt đầu làm mới màn hình. Khi sử dụng giá trị này thay vì thời gian hiện tại, bạn sẽ nhận được nguồn thời gian nhất quán cho logic cập nhật trạng thái trò chơi của mình.

Nhưng tiếc là, việc bạn nhận được lệnh gọi lại sau mỗi VSYNC không đảm bảo rằng lệnh gọi lại sẽ được thực thi kịp thời hoặc bạn sẽ có thể hành động nhanh chóng. Ứng dụng sẽ cần phải phát hiện các tình huống mà ứng dụng bị chậm và bỏ khung hình theo cách thủ công.

Hoạt động "Record GL app" trong Grafika là ví dụ về trường hợp này. Trên một số thiết bị (ví dụ: Nexus 4 và Nexus 5), hoạt động sẽ bắt đầu làm giảm khung hình nếu bạn chỉ ngồi và xem. Việc kết xuất GL là điều bình thường, nhưng đôi khi các phần tử View được vẽ lại và việc truyền thông số đo lường/bố cục có thể mất nhiều thời gian nếu thiết bị rơi vào chế độ tiết kiệm pin. (Theo systrace, mất 28 mili giây thay vì 6 mili giây sau khi tốc độ xung nhịp bị chậm trên Android 4.4. Nếu bạn kéo ngón tay xuống trên màn hình, thiết bị sẽ cho rằng bạn đang tương tác với hoạt động. Vì vậy, tốc độ xung nhịp sẽ cao và bạn sẽ không bao giờ bỏ khung hình.)

Cách khắc phục đơn giản là bỏ một khung trong lệnh gọi lại Choreographer nếu thời gian hiện tại nhiều hơn N mili giây sau thời gian VSYNC. Lý tưởng nhất là giá trị của N được xác định dựa trên các khoảng VSYNC đã quan sát trước đó. Ví dụ: nếu thời gian làm mới là 16,7 mili giây (60 khung hình/giây), thì bạn có thể bỏ một khung hình nếu chạy trễ hơn 15 mili giây.

Nếu xem "Record GL app" chạy, bạn sẽ thấy bộ đếm khung hình bị bỏ tăng lên và thậm chí sẽ thấy chớp đỏ xuất hiện ở viền khi bỏ các khung hình. Tuy nhiên, trừ khi mắt bạn nhìn rất tốt, bạn sẽ không thấy ảnh động bị giật. Ở tốc độ 60 khung hình/giây, ứng dụng này có thể thỉnh thoảng bỏ khung hình mà không ai nhận thấy, miễn là ảnh động sẽ tiếp tục chạy ở tốc độ không đổi. Chất lượng mà bạn sẽ nhận được ở một mức độ nào đó phụ thuộc vào việc bạn đang vẽ gì, chất lượng của màn hình, và người dùng ứng dụng có phát hiện là bị giật hình hay không.

Quản lý luồng

Nói chung, nếu bạn đang kết xuất lên SurfaceView, GLSurfaceView hoặc TextureView, bạn muốn thực hiện việc kết xuất đó trong một luồng riêng. Tuyệt đối không thực hiện bất kỳ thao tác "nặng" nào hoặc bất cứ điều gì sẽ chiếm một khoảng thời gian không xác định trên luồng giao diện người dùng (UI thread). Thay vào đó, hãy tạo hai luồng cho trò chơi: luồng trò chơi và luồng kết xuất. Hãy xem bài viết Cải thiện hiệu năng của trò chơi để biết thêm thông tin.

Breakout và "Record GL app" sử dụng các luồng kết xuất đồ hoạ chuyên dụng, đồng thời cập nhật trạng thái ảnh động trên luồng đó. Đây là một phương pháp hợp lý, miễn là trạng thái trò chơi có thể được cập nhật nhanh chóng.

Các trò chơi khác tách biệt logic trò chơi và việc kết xuất đồ hoạ. Nếu bạn có một trò chơi đơn giản hầu như chẳng làm gì ngoài việc di chuyển một khối mỗi 100 mili giây, bạn sẽ có một luồng dành riêng cho việc này:

run() {
    Thread.sleep(100);
    synchronized (mLock) {
        moveBlock();
    }
}

(Bạn có thể muốn căn cứ vào thời gian ngủ của một xung nhịp cố định để ngăn tình trạng drift – sleep() không hoàn toàn nhất quán và moveBlock() chiếm thời lượng khác 0 – ý tưởng là vậy.)

Khi đánh thức mã vẽ, mã này chỉ cần lấy khoá, nhận vị trí hiện tại của khối, mở khoá và vẽ. Thay vì thực hiện chuyển động rời rạc dựa trên thời gian delta xen giữa khung, bạn chỉ có một luồng di chuyển mọi thứ dọc theo và một luồng khác sẽ vẽ mọi thứ bất kỳ lúc nào chúng bắt đầu khi việc vẽ bắt đầu.

Đối với một cảnh có bất kỳ độ phức tạp nào, bạn sẽ muốn tạo một danh sách các sự kiện sắp tới được sắp xếp theo thời gian thức và ngủ cho đến khi sự kiện tiếp theo đến hạn, nhưng đó là cùng một ý tưởng.