IMHO
Я - не волшебник, я только учусь:) В связи с этим технологии глобального стриминга (грузим копию всей оперативки и потом расставляем указатели) для меня пока недоступны и решение я ищу без их использования.
Я - "язычник". Пишу на Delphi и так как это типизированный язык, где тип должен быть определён в момент compile time, то некоторые красивые решения для нетипизированных языков для меня тоже неприемлемы.
Ничего нового ниже нет, всё это рассказывали
aruslan,
ddima,
Plakhov и
IronPeter. Ну и до кучи, мне трудно рассказывать, не сваливаясь в конкретику реализации.
В спецификации на разработку игры должно быть указано в какой минимальный объём оперативной и видео памяти она должна влазить.
Считаю, что необходимо рассматривать три случая:
1. Все игровые ресурсы влазят в объём, указанный в спецификации.
2. Игровые ресурсы одного уровня, зоны, чанка (в дальнейшем чанк) влазят в объём, указанный в спецификации. В крайнем случае побили по рукам артистов, что бы влазило.
3. Игровые ресурсы одного чанка теоретически не влазят в объём, указанный в спецификации. Например, в MMO игрокам разрешено создавать свои раскраски одежды или из их фоток делается текстура для головы игрока, либо в сингле неограниченное количество видов одежды и оружия.
Первый случай. Все ресурсы влазят. Всё отлично, всё сразу грузим. Никакой подгрузки во время игры, никаких счётчиков и т.д.
Исходные данные:
В финальном режиме игры ресурсы должны грузиться из pak-файла, возможно зашифрованном и сжатом.
В отладочном режиме - из pak-файла, из текстовых файлов (XML).
Должен быть доступ для просмотра и изменения данных объектов.
Количество различных class-ов достаточно велико и не должно жёстко фиксироваться движком.
Этап создания ресурсов и сама игра разделены во времени и пространстве.
Решение:
pak-файл содержит таблицу (ResurceID, Offset, Size).
1. На этапе создания ресурсов делается две операции:
- компилирование ресурса в формат, принимаемый игровых движком.
- создание очереди загрузки ресурсов. Порядок загрузки - вначале "лёгкие" ресурсы, затем "тяжёлые" ресурсы, ссылающиеся на ранее загруженные "лёгкие". Граф ресурсов не содержит циклов и такой список может быть создан.
2. Загрузка ресурсов в игру.
- Для разрешения ссылок необходим список пар (ObjectID, Object), где зная ObjectID я могу получить указатель на Object.
- так как количество классов не ограничено, то необходима система регистрации классов, а точнее функций загрузки (фабрик), то есть нечто (FactoryID, Factory).
- фабрики не должны ничего знать о конкретном формате чтения-записи (bin из pak-файла или XML из каталога) - это возможно заменой чтения из потока работой с абстрактным интерфейсом чтения-записи (Resurce).
- нужна ещё одна абстракция - менеджер ресурсов, который по конкретному ResurceID выдаёт для работы данный ресурс.
Так ак мне проще это сказать кодом, то (мои сожаления не язычникам, на C++ я вроде должен был бы написать абстрактный класс или указать как использовать sdl или что-то аналогичное):
- список пар (ObjectID, Object)
IdfObjectManager = interface
function GetObject(const aObjectID: TdfObjectID): IInterface;
procedure SetObject(const aObjectID: TdfObjectID; const aObject: IInterface);
end;
где TdfObjectID - тип уникального идентификатора объекта, может быть число, строка и т.д., так как это используется только для загрузки и отладочного режима, то у меня это string[50]. Внутренняя реализация - естественно хеширование.
- список пар (FactoryID, Factory)
IdfFactoryManager = interface
function GetFactory(const aFactoryID: TdfFactoryID): IdfFactory;
procedure SetFactory(const aFactoryID: TdfFactoryID; const aFactory: IdfFactory);
end;
где TdfFactoryID - тип уникального идентификатора объекта, может быть число, строка и т.д., так как это используется только для загрузки и отладочного режима, то у меня это string[50]. Внутренняя реализация - естественно хеширование.
IdfFactory = interface
function Create: IInterface;
procedure Assign(const aSource: IInterface; var aObject: IInterface);
procedure Load(const aResurce: IdfResurce; var aObject: IInterface);
procedure Save(const aObject: IInterface; var aResurce: IdfResurce);
procedure Compile(var aObject: IInterface);
end;
при чтении и записи используется номер версии, для обеспечении совместимости изменений.
Compile(var aObject: IInterface); - выполнение действий зависящих от OpenGL контекста.
IdfResurce = interface
procedure SetStream(const aStream: TMemoryStream);
function ReadBoolean(const aName: String): boolean;
function ReadInteger(const aName: String): integer;
function ReadSingle(const aName: String): single;
function ReadString50(const aName: String): TdfString50;
function ReadOpenTag(const aType: String; const aName: String): boolean;
procedure ReadCloseTag(const aType: String):
procedure WriteBoolean(const aValue: boolean; const aName: String);
procedure WriteInteger(const aValue: integer; const aName: String);
procedure WriteSingle(const aValue: single; const aName: String);
procedure WriteString50(const aValue: String; const aName: String);
procedure WriteOpenTag(const aType: String; const aName: String);
procedure WriteCloseTag(const aType: String);
end;
, где aType: String; aName: String - дополнильная информация, используемая для записи в XML-формате и никак не использующаяся при записи в bin-формате. То есть не используется парсер XML, в действительности используется формат XML для удобства с жёстко фиксированным порядком чтения.
IdfResurceSystem = interface
function Create(const aFactoryID: TdfFactoryID; const aObjectID: TdfObjectID);
procedure Assign(const aFactoryID: TdfFactoryID; const aSourceID: TdfObjectID; const aObjectID: TdfObjectID);
procedure Load(const aFactoryID: TdfFactoryID; const aResurceID: TdfResurceID; const aObjectID: TdfObjectID);
procedure Save(const aFactoryID: TdfFactoryID; const aObjectID: TdfObjectID; const aResurceID: TdfResurceID);
end;
, где aResurceID - имя ресурса, в отладочном режиме проверяется наличие XML-файла, и при его наличии идёт загрузка из него, иначе из pak-файла, в финальном варианте только из pak-файла. В том числе возможно разделение на чтение постоянных данных либо save-ов.
Загрузка выглядит примерно так:
for iObject := 1 to ObjectCount do
begin
read(fFactoryID);
read(fResurceID);
read(fObjectID);
ResurceSystem.Load(fFactoryID, fResurceID, fObjectID);
end;
Однопоточная реализация процедуры Load
procedure TdfResurceSystem.Load(const aFactoryID: TdfFactoryID; const aResurceID: TdfResurceID; const aObjectID: TdfObjectID);
var
cFactory: IdfFactory;
cResurce: IdfResurce;
cObject: IInterface;
begin
//1. чтение с диска в TMemoryStream
cResurce := GetResurce(aResurceID);
//2. создание объекта, заполнение его данными из TMemoryStream
cFactory := FactoryManager.GetFactory(aFactoryID);
cObject := cFactory.create;
cFactory.Load(cResurce, cObject);
ObjectManager.SetObject(aObjectID, cObject);
//3. выполнение действий зависящих от OpenGL контекста
cFactory.Compile(cObject);
end;
В принципе не сложно сделать загрузку в несколько потоков. Пока у себя не успел сделать.
Основной поток.
procedure TdfResurceSystem.Load(const aFactoryID: TdfFactoryID; const aResurceID: TdfResurceID; const aObjectID: TdfObjectID);
begin
Queue1.Enqueue(aFactoryID, aResurceID, aObjectID); //push
end;
1. Первый поток. Только работа с диском
if not Queue1.IsEmpty then
begin
Queue1.Dequeue(cFactoryID, cResurceID, cObjectID); //pop
Queue2.Enqueue(cFactoryID, GetResurce(cResurceID), cObjectID); //push
end;
2. Второй поток. Разкодировка TMemoryStream в Object
if not Queue2.IsEmpty then
begin
Queue2.Dequeue(cFactoryID, cResurce, cObjectID); //pop
cFactory := FactoryManager.GetFactory(cFactoryID);
cObject := cFactory.create;
cFactory.Load(cResurce, cObject);
ObjectManager.SetObject(cObjectID, cObject);
Queue3.Enqueue(cFactory, cObject); //push
end;
Основной поток. Насколько я понял из обсуждений не более одного раза в такт, что бы не было заметных тормозов.
if not Queue3.IsEmpty then
begin
Queue3.Dequeue(cFactory, cObject); //pop
cFactory.Compile(cObject);
end;
Пример создания pak-файла
BildFromJPGToTexture('FileName.jpg',....,'ResurceID');
Пример разрешения ссылки при загрузке
procedure ModelFactory.Load(const aResurce: IdfResurce; var aObject: IInterface);
begin
aObject.QueryInterface(IdfModel, cModel);
...
cTextureID := aResurce.ReadString50('Texture');
cModel.Texture := ObjectManager.GetObject(cTextureID);
...
end;
Второй случай. Игровые ресурсы для чанка влазят в объём, указанный в спецификации.
Возможно два близких друг к другу решения.
Первый вариант: весь старый чанк сносим, грузим новый. При этом есть какая-то постоянная часть, например, данные о главном герое, то тогда можно часть данных оставлять в памяти, остальные сносить.
Соответственно необходимо данные маркировать на постоянные и зависящие от чанка.
Тогда список (ObjectID, Object) можно расширить на маркер и получить (ObjectID, MapID, Object).
И соответственно, немного изменится интерфейс
IdfObjectManager = interface
function GetObject(const aObjectID: TdfObjectID): IInterface;
procedure SetObject(const aObjectID: TdfObjectID; const aMapID: TdfMapID; const aObject: IInterface);
procedure Clear(const aMapID: TdfMapID);
end;
aMapID зависит от Вашей фантазии, например: "главное меню", "актёр", "уровень".
Второй вариант похож на первый, но отличается более мелкой нарезкой MapID. Принцип рассказывал aruslan в своём журнале, я у себя дублировал, но повторюсь ещё раз.
Исходные данные:
Для каждого чанка знаем, что ему требуется (ChunkID, ObjectID) (отношение много к многим), знаем размер каждого объекта в памяти (ObjectID, ObjectSize)
Решение:
На этапе предварительной подготовки данных можно построить список (ObjectID, MapID) (много к одному) и проверочный (MapID, MapSize), такой что сумма MapSize для всех MapID меньше MapLimit и список (ChunkID, MapID), построенный по (ChunkID, ObjectID) и (ObjectID, MapID) соответствует условию много к одному, то есть для каждого объекта у одного чанка есть свой уникальный MapID.
Ну, завернул... проще у aruslan-а прочитать. Небольшое замечание: задачу можно разбить на несколько меньшей размерности для разных диапазонов ObjectSize.
Для ObjectSize==const эта задача становится классической олимпиадной:
В институте имеются аудитории для занятий (MapID).
Учебный год состоит из определённого количества часов (ChunkID).
Обучается известное количество групп (ObjectID), для каждой группы составлено расписание во времени:
(ChunkID, ObjectID) (обычно задаётся булевой таблицей).
Задача: закрепить группы за аудиториями (ObjectID, MapID), что бы в одной аудитории не походило два занятия разных групп одновременно, используя минимальное количество аудиторий.
так же знаю формулировку этой задачи для ресторанов.
Конечно, можно обсуждать сложность этой задачи: N-полная и так далее. Но для неоптимального решения это не важно. Я решал эту задачу используя методы из раздела Теория графов, раскраски, по моему опыту нахождение локально оптимального решения возможно за почти линейное от размерности время O(n).
Конечно, на практике будет немного сложнее, так как для непрерывного мира надо учитывать подгрузку соседних уровней, телепортации и т.д., но нерешимых проблем я не вижу. Плюсы вижу: не надо считать ссылки, собирать мусор и т.д.
Изменения в интерфейсах, приведённых выше для первого варианта, незначительные, а изменений данных, хранимых на диске вообще нет, только добавляется таблица (ObjectID, MapID).
//(ObjectID, MapID)
IdfObjectMapContainer = interface
function GetMapID(const aObjectID: TdfObjectID): TdfMapID;
end;
//(ObjectID, MapID, Object)
IdfObjectManager = interface
function GetObject(const aObjectID: TdfObjectID): IInterface;
function GetObjectID(const aMapID: TdfMapID): TdfObjectID;
procedure SetObject(const aObjectID: TdfObjectID; const aMapID: TdfMapID; const aObject: IInterface);
procedure Clear(const aMapID: TdfMapID);
end;
procedure TdfResurceSystem.Load(const aFactoryID: TdfFactoryID; const aResurceID: TdfResurceID; const aObjectID: TdfObjectID);
var
cFactory: IdfFactory;
cResurce: IdfResurce;
cObject: IInterface;
cMapID: TdfMapID;
begin
//
cMapID := ObjectMapContainer.GetMapID(aObjectID);
//проверяем что хранится по адресу cMapID
if aObjectID <> ObjectManager.GetObjectID(cMapID) then
//если хранится, то что надо то ничего не делаем, иначе грузим
begin
//1. чтение с диска в TMemoryStream
cResurce := GetResurce(aResurceID);
//2. создание объекта, заполнение его данными из TMemoryStream
cFactory := FactoryManager.GetFactory(aFactoryID);
ObjectManager.Clear(cMapID);
cObject := cFactory.create(cMapID);
cFactory.Load(cResurce, cObject);
ObjectManager.SetObject(aObjectID, cMapID, cObject);
//3. выполнение действий зависящих от OpenGL контекста
cFactory.Compile(cObject);
end;
end;
Третий случай. Игровые ресурсы одного чанка теоретически не влазят в объём, указанный в спецификации.
Тут необходимо использовать подход активно обсуждаемый Plaxov-ым - массивы фиксированной длины.
Да, в игре миллион различных по текстуре и мешу кольчуг, но одномоментно у игрока может быть надета одна кольчуга, в багаже 10 и на данном чанке 20 разных. Итого 31 разных по мешу и текстуре кольчуг. Соответственно, делаем массив размерностью 31 и назначаем ему один MapID. Подсчёт ссылок не нужен, так как, одна на теле, 10 в багаже и 20 на неписях, на земле и в тайниках на любом чанке.
Аналогично для MMO. Да, зарегистрирован 1 000 000 игроков с уникальными текстурами одежды и фэйсов, но в одном чанке одномоментно не может быть больше 1 000 игроков (Ограничение заданное диздоком).
Отсюда массив фиксированной размерности, вышел игрок, пометили на удаление либо удалили, пришёл в чанк - создали, больше 1000 не пускаем.
Естественно надо учитывать лоды. Нулевые - 100, следующий - 500 и так далее. Цифры условные.
Лоды, игровые объекты, подписка, игровой цикл - это другие не менее интересные темы, имеющие тоже отличные от общеизвестных на gamedev.ru решения.
Ну не надо считать ссылки в любом варианте.