Featured image of post Tại sao lại là ECS

Tại sao lại là ECS

Cùng tìm hiểu lý do mà ECS được sinh ra

Giới thiệu

Chúng ta sẽ đi luôn vào vấn đề. Vòng lặp game kiểu cũ trông như này

Kiến trúc Enitity System cố gắng giải quyết các vấn đề của gameloop. nó làm cho gameloop trở thành cốt lõi của trò chơi. Và việc đơn giản hoá gameloop quan trọng hơn bất kỳ điều gì khác trong kiến trúc của một trò chơi hiện đại. Điều này quan trọng hơn việc tách view khỏi controller

Process

Bước đầu tiên trong quá trình thay đổi này là các processes (quy trình). Đây là những đối tượng có thể được init, update và destroy.

Interface Process trông như sau

Chúng ta có thể đơn giản hoá gameloop bằng cách chia nhỏ nó thành một số quy trình chịu trách nhiệm riêng.

Ví dụ :

  • Render
  • Moving
  • Physic

Để quản lý các quy trình này chúng ta tạo ra một ProcessManager

Dưới đây là một phiên bản đơn giản hoá của ProcessManager.

Đặc biệt chúng ta cần đảm bảo rằng chúng ta cập nhật các process theo đúng thứ tự được xác định bởi độ ưu tiên priority. Và chúng ta cần xử lý tính huống process bị xóa trong gameeloop.

ProcessManager truyền đạt ý tưởng rằng nếu gameloop của trò chơi được chia thành nhiều process thì phương pháp update của ProcessMangergameloop mới và các process trở thành cốt lõi của trò chơi

Render Process

Hãy xem xét quá trình render. Chúng ta chỉ đơn giản là đưa phần render code ra khỏi gameloop và đặt nó vào trong process.

Nhưng cách này không hiệu quả lắm, chúng ta vẫn cần render thủ công mọi loại đối tượng có thể trong game, nếu chúng ta có một interface chung cho tất cả các object render thì đơn giản hơn nhiều.

RenderProcess sẽ được viết lại như sau

Moving Process

Chúng ta thử xem xét quá trình di chuyển cập nhật vị trí của các đối tượng

Đa kế thừa (Multiple Inheritance)

Tất cả điều này trông có vẻ ổn nhưng thật không may chúng ta muốn spaceship có thể vừa move vừa render, nhưng nhiều ngôn ngữ không hỗ trợ đa kế thừa, và ngay cả những ngôn ngữ hỗ trợ nó chúng ta sẽ phải đối mặt với một vấn đề là position và rotate trong render phải đồng bộ với trong move

Giải pháp có thể là tạo chuỗi kế thừa khi move và render được implement

Chúng ta sẽ thiết kế lại như sau :

Các đối tượng không di chuyển thì chỉ cần kế thừa trực tiếp từ renderable

Nhưng điều gì sẽ xảy ra nếu chúng ta muốn tạo ra các đối tượng có thể di chuyển được mà không muốn hiển thị. Như các đối tượng tàng hình, chúng ta cần các implement thay thế của interface moveable và interface này không kế thừa từ renderable

Trong một dự án đơn giản điều này tuy lộn xộn nhưng có thể quản lý được nhưng trong các dự án lớn, phức tạp việc sử dụng kế thừa để liên kết các process với các đối tượng một cách nhanh chóng khiến code không thể quản lý được và bạn sẽ sớm tìm thấy những thứ trong trò chơi không phù hợp với một cây kế thừa đơn giản như đã trình bày ở trên.

Prefer composition over inheritance.

Có một nguyên tắc OOP cũ là Composition over inheritance, áp dụng nguyên tắc này có thể giúp bạn tránh được sự nhầm lẫn về inheritance tiềm ẩn.

Chúng ta vẫn cần có class Moveable và Renderable nhưng thay vì kế thừa chúng để tạo ra các object mới thì ta chứa các instance của mỗi class này.

Bằng cách này chúng ta có thể kết hợp các hành vi khác nhau theo bất kỳ cách nào mà không sợ các vấn đề đến từ kế thừa

Những vật thể được tạo ra bằng cách này gọi chung là entity

Cách tiếp cận này trông có vẻ tốt, nó cho phép bạn mix and match, hỗ trợ cho process trong các object khác nhau mà không có sự lộn xộn của chuỗi kế thừa (inheritance chain) hoặc tự lặp lại (self-repetition).

Nhưng nó có một vấn đề

Làm gì với những thông tin chung?

Position và rotation trong object renderable phải có cùng giá trị với position và rotation trong object moveable, vì quá trình move thay đổi giá trị của chúng và quá trình render cần chúng để hiển thị

Để giải quyết vấn đề này chúng ta cần đảm bảo cả moveable và renderable đều tham chiếu đến instance giống nhau của các thuộc tính này (các thuộc tính này cần là class / struct) vì vậy chúng ta có một tập hợp các class khác mà chúng ta gọi là component

Khi chúng ta tạo SpaceShip chúng ta cần đảm bảo rằng cả render và move tham chiếu đến cùng một instance của PositionComponent và RotationComponent

=> Mọi thay hay đổi không làm ảnh hướng đến process

Tóm lại

Lúc này ta có sự phân chia nhiệm vụ rõ ràng, gameloop chạy các process, gọi phương thức update cho từng process. mỗi process bao gồm một tập hợp các object implement một interface mà qua đó bạn có thể tương tác với chúng và (process) gọi các phương thức cần thiết cho các đối tượng này. Các đối tượng như vậy thực hiện một nhiệm vụ duy nhất cho các đối tượng này. Thông qua việc sử dụng các component, các đối tượng này chia sẻ thông tin và sự kết hợp của các quy trình khác nhau có thể tạo ra các tương tác phức tạp giữa các đối tượng trò chơi nhưng đồng thời giữ cho mỗi quy trình tương đối đơn giản. Kiến trúc này tương tự nhiều hệ thống thực thể trong phát triển trò chơi. Nó thực hiện tốt các nguyên tắc OOP và nó hoạt động nhưng nó có thể khiến bạn phát điên.

Tránh OO

Kiến trúc hiện tại sử dụng các nguyên tắc lập trình OOP như đóng gói và phần tách các mối quan tâm, các giá trị và logic trong IRenderable và IMoveable, logic by responsibility, update các object mọi khung hình.

Bước tiếp theo trong sự phát triển của các hệ thống đối tượng có vẻ như không thể hiểu được bằng trực giác và phá hoại bản chất của OOP, chúng sẽ phá vỡ tính đóng gói của thông tin và logic trong các triển khai của Renderable và Moveable, đặc biệt chúng ta sẽ chuyển logic ra khỏi các class này thành các process

Lúc trước Renderable trông như thế này

Sẽ trở thành :

Lúc trước Moveable trông như này

Sẽ trở thành :

Có thể điều này khiến bạn không lập tức hiểu rõ ràng tại sao lại làm điều này, nhưng hãy tin, ở đây ta đã loại bỏ nhu cầu về interface và công việc của các processs hiện đang quan trọng hơn, thay vì chỉ uỷ thác công việc của họ để implement IRenderable hoặc IMoveable, nó tự thực hiện công việc

Đầu tiên rõ ràng là tất cả các entity phải có cùng một phương thức render() vì việc kết xuất hiện nằm trong RenderProcess, nhưng đây không phải là điểm duy nhất ví dụ chúng ta có thể tạo hai quy trình RenderMovieClip và RenderBitmap, và chúng có thể hoạt động trên các tập thực thể khác nhau. Do đó chúng ta sẽ không bị mất tính linh hoạt của code

Những gì mà chúng ta nhận được là khả năng cấu trúc lại đáng kể của các entity để có được một architecture với sự phân tách rõ ràng hơn và cấu hình đơn giản hơn. Việc tái cấu trúc Sẽ bắt đầu với câu hỏi rằng :

Chúng ta có cần các class value hay không?

Hãy xem xét class SpaceShip

Nó chứa hai class MoveDataRenderData

2 class này lại chứa các data class sau

Các component data này được sử dụng trong các process sau

entity không nên quan tâm đến các data class. Các component đều chứa trạng thái của chính entity, các data class tồn tại để thuận tiện cho các process, ta sẽ refactor lại code của class spaceShip để chứa chính các component thay vì các data class

Bằng cách loại bỏ các class data thay vào đó sử dụng các composite component để xác định SpaceShip, chúng ta loại bỏ mọi nhu cầu đối với entity để biết những quy trình nào có thể ảnh hưởng đến nó. SpaceShip hiện chứa các component xác định tình trạng của nó. Mọi nhu cầu kết hợp các component này vào các class data khác bây giờ thuộc trách nhiệm của các class khác.

Bản thân các quy trình (process) sẽ không thay đổi, nhưng ta sẽ gọi nó với cái tên chung hơn đó là System

Entity

Không có gì đặc biệt về class SpaceShip nó chỉ là container chứa các component, vì vậy chúng ta sẽ gọi nó là thực thể (entity) và cung cấp cho nó một mảng các component chúng ta có thể truy cập các component này thông qua type class của chúng.

Và đây là cách tạo ra entity

Engine

Trước đây chúng ta quản lý các process hay bây giờ là System bằng SystemManager lúc trước nó trông như thế này

Sau khi sửa đổi nó sẽ trông như sau:

Lượt nghé thăm