DataFrame можуть бути недооціненою системою компонентів та сутностей для розробки ігор.

pic

Фото від JESHOOTS.COM на Unsplash

Отже, DataFrame — це фактично електронні таблиці на стероїдах, або таблиці баз даних в програмі, але його потенціал як Entity Component System для розробки ігор ще не був належним чином досліджений.

Цілком справедливо сказати, що люди, які займаються Data Science/Machine Learning, вкладають величезні ресурси в удосконалення бібліотек DataFrame, таких як Pandas, порівняно з усіма реалізаціями ECS разом узятими. І ймовірно, це так і залишиться.

(Ми будемо показувати код із використанням Polars/cuDF/Pandas, які варіюються від "легше досягти гарної продуктивності, але важче написати логіку гри" до "легше написати логіку гри, але потрібно більше зусиль для досягнення гарної продуктивності", але їх ідеї більше схожі, ніж різні.)

Використовувати DataFrame досить просто. Рядок можна вважати сутністю. Колонка представляє компонент (або частину компонента, залежно від того, як ви хочете його закодувати). Колонки (тобто компоненти) можна додавати динамічно. Кожна колонка може бути структурою, що містить кілька полів.

import pandas as pd  
import pyarrow as pa  

size = 8  
df = pd.DataFrame(  
 {  
 "id": range(0, size),  
 }  
)  

xyz_dtype = pd.ArrowDtype(  
 pa.struct([("x", pa.float32()), ("y", pa.float32()), ("z", pa.float32())])  
)  
# не найефективніший спосіб зробити це, але це лише для демонстрації  
df = df.assign(  
 position=pd.Series(  
 ({"x": id * 2.0, "y": id * 3.0, "z": id * 5.0} for id in df["id"]),  
 dtype=xyz_dtype,  
 ),  
 velocity=pd.Series(  
 ({"x": id * 7.0, "y": id * 11.0, "z": id * 13.0} for id in df["id"]),  
 dtype=xyz_dtype,  
 ),  
)  

print(df)
id position velocity  
0 0 {'x': 0.0, 'y': 0.0, 'z': 0.0} {'x': 0.0, 'y': 0.0, 'z': 0.0}  
1 1 {'x': 2.0, 'y': 3.0, 'z': 5.0} {'x': 7.0, 'y': 11.0, 'z': 13.0}  
2 2 {'x': 4.0, 'y': 6.0, 'z': 10.0} {'x': 14.0, 'y': 22.0, 'z': 26.0}  
3 3 {'x': 6.0, 'y': 9.0, 'z': 15.0} {'x': 21.0, 'y': 33.0, 'z': 39.0}  
4 4 {'x': 8.0, 'y': 12.0, 'z': 20.0} {'x': 28.0, 'y': 44.0, 'z': 52.0}  
5 5 {'x': 10.0, 'y': 15.0, 'z': 25.0} {'x': 35.0, 'y': 55.0, 'z': 65.0}  
6 6 {'x': 12.0, 'y': 18.0, 'z': 30.0} {'x': 42.0, 'y': 66.0, 'z': 78.0}  
7 7 {'x': 14.0, 'y': 21.0, 'z': 35.0} {'x': 49.0, 'y': 77.0, 'z': 91.0}

Як бачите, все дуже динамічне та гнучке. Довільні компоненти можна додавати під час виконання через додавання колонки або видаляти, встановивши клітинку на null, що, без сумніву, набагато гнучкіше, ніж більшість реалізацій ECS. Цей динамічний підхід також має полегшити маніпуляції DataFrame з мовами сценаріїв. Збереження/завантаження також може бути простим.

А як щодо продуктивності? ECS люблять хвалитися гарною продуктивністю, розташовуючи значення одного стовпця/компонента поруч, що забезпечує кращу локальність пам'яті та потенційну автозаготовку.

Сучасні бібліотеки DataFrame також мають дружнє до кешу розташування пам'яті через або NumPy або Apache Arrow. Автоматичне паралелізування, SIMD (або явне, або автозаготовка компілятором) або навіть GPU-акселерація абсолютно можливі. Моя спроба створити бенчмарк ECS через Polars показує, що він лише вдвічі повільніший за найпродуктивніший ECS, перевірений на моїй системі, і займає всього 9 нс для обробки 2 систем за сутністю з 7 компонентами.
Наївна реалізація гри "Життя" через cuDF (яка прискорена за допомогою GPU за лаштунками), що кодує кожну клітинку як u8, може бути в 4 рази швидшою (~0.8нс на клітинку) за аналогічну наївну реалізацію на Rust. Люди також розробили різні способи передавати стовпці DataFrame (які вже можуть знаходитися в пам'яті GPU!) безпосередньо в шейдери з легкістю.

Найбільша перевага DataFrame над традиційними ECS, ймовірно, полягає в ергономіці запитів. Хтось поставив мені задачу виконати наступний запит за допомогою DataFrame, який включає досить багато зв'язків між сутностями:

// знайти всі космічні кораблі  
SpaceShip($spaceship),  
// які належать до фракції  
Faction($spaceship, $spaceship_faction),  
// які причеплені до сутності  
DockedTo($spaceship, $planet),  
// що є планетою  
Planet($planet),  
// якою керує фракція  
RuledBy($planet, $planet_faction),  
// яка є союзником фракції корабля  
AlliedWith($spaceship_faction, $planet_faction)

DataFrame легко справляється з таким запитом.
Якщо ми представимо світ за допомогою DataFrame таким чином,

use polars::df;  
use polars::prelude::*;  
use std::fmt::format;  

#[derive(Debug, Clone, Copy)]  
enum EntityType {  
 Spaceship = 0,  
 Faction,  
 SpaceStation,  
 Planet,  
}  

pub(crate) fn query() {  
 // Створюємо DataFrame для сутностей  
 let entities = df![  
 "entity_id" => &[1, 2, 3, 4, 5, 6],  
 "entity_type" => &[  
 EntityType::Spaceship as i32,  
 EntityType::Faction as i32,  
 EntityType::SpaceStation as i32,  
 EntityType::Planet as i32,  
 EntityType::Spaceship as i32,  
 EntityType::Faction as i32,  
 ],  
 "owning_faction" => &[Some(2), None, Some(2), Some(2), Some(6), None]  
 ].unwrap();  

 println!("Entities DataFrame:");  
 println!("{:?}", entities);  

 // Створюємо DataFrame для доступу до докування  
 let docking_access = df![  
 "from_faction_id" => &[2, 6],  
 "to_faction_id" => &[6, 2]  
 ].unwrap();  

 println!("Docking Access DataFrame:");  
 println!("{:?}", docking_access);  

 // Створюємо DataFrame для статусу докування  
 let docking_status = df![  
 "spaceship_id" => &[1, 5],  
 "target_id" => &[3, 4]  
 ].unwrap();  

 println!("Docking Status DataFrame:");  
 println!("{:?}", docking_status);  

 // Створюємо DataFrame для дружніх ставлень  
 let friendly_stance = df![  
 "from_faction_id" => &[2, 6],  
 "to_faction_id" => &[6, 2],  
 "is_friendly" => &[true, true]  
 ].unwrap();  

 println!("Friendly Stance DataFrame:");  
 println!("{:?}", friendly_stance);

Фактичний запит складається всього з кількох рядків коду, а оптимізатор запитів під час виконання допомагає досягти належної продуктивності без зайвих турбот:

// LazyFrame запит для знаходження всіх космічних кораблів, які доковані до планети, якою керує інша дружня фракція  
 let entities_lazy = entities.lazy();  
 let docking_status_lazy = docking_status.lazy();  
 let friendly_stance_lazy = friendly_stance.lazy();  

 let result = docking_status_lazy  
 .join(entities_lazy.clone(), [col("target_id")], [col("entity_id")], JoinType::Inner.into())  
 .filter(col("entity_type").eq(lit(EntityType::Planet as i32)))  
 .join(entities_lazy.clone(), [col("spaceship_id")], [col("entity_id")], JoinType::Inner.into())  
 .join(friendly_stance_lazy, [col("owning_faction_right")], [col("from_faction_id")], JoinType::Inner.into())  
 .filter(col("owning_faction").eq(col("to_faction_id")))  
 .select([col("spaceship_id")])  
 .collect()  
 .unwrap();  

 println!("Космічні кораблі, доковані до планети, якою керує інша дружня фракція:");  
 println!("{:?}", result);  
}

Чим більше я дивлюсь на DataFrame і порівнюю їх з рішеннями ECS, тим більше мені здається, що вони мають значну схожість, і є занадто багато прогресу в бік DataFrame, якого люди, які займаються ECS, зовсім не помічають. Більш того, здається, що вони борються з битвою, яку програшають.

В наступній статті ми розглянемо, як DataFrame вибирають між записом на місці та виконанням операції копіювання при записі, а також що можна зробити, щоб покращити продуктивність при роботі з DataFrame.

Перекладено з: DataFrames might be an underrated Entity Component System for game development

Leave a Reply

Your email address will not be published. Required fields are marked *