Cocos2d-x тестирование производительности

У меня есть заготовки для top-down rts. Проведем несколько тестов, чтобы узнать на что способен движок и сложно ли с ним работать.

Установка тривиальна, рассматривать ее я здесь не буду.

Бойлерплейт проект создается с помощью консольной утилиты cocos, что довольно удобно:

cocos new MyGame -p com.your_company.mygame -l cpp -d NEW_PROJECTS_DIR
cd NEW_PROJECTS_DIR/MyGame
// стандартная команда создает нерабочую 64х версию,
// следует воспользоваться cmake: 
cmake . -A win32

Далее можно открыть созданный файл MyGame.sln в VisualStudio и выполнить сборку тестового проекта, выбрав MyGame вместо ALL_BUILD.

Что внутри

До этого пробовал javascript движки phaser, pixijs. Тут все очень похоже: Сцены, спрайты. Действия спрайтов: движение, поворот выполняются через отдельный класс Actions. Есть библиотека для текстов, кнопок. Есть автоматическая чистка объектов динамической памяти, поэтому создание объектов рекомендуется делать через методы движка.

Добавим звуки, спрайты и тайловые карты

Подключим звуковые файлы.

Ассеты нужно сначала положить в папку ./Resources. Все файлы из папки ./Resources подгружаются автоматический при сборке, можно сразу проиграть файл:

AudioEngine::play2d("audio/gruntnogold1.mp3");

Теперь добавим тайлмапу:

auto map = TMXTiledMap::create("strategy_map.tmx");
addChild(map, 0, 99);

Добавляется без проблем: слои, тайлсеты и картинки цепляются автоматом.

Не поддерживается json файлы только xml. И только один тайлсет на слой.

Рендер тайлсета

Тайлсет

Тайлсет

Добавим перемещение карты с помощью клавиатуры:

void HelloWorld::onKeyPressed(EventKeyboard::KeyCode keyCode, Event* event)
{
    log("Key with keycode %d pressed", keyCode);
    bool isMoveScreenPressed = false;
    switch (keyCode)
    {
    case cocos2d::EventKeyboard::KeyCode::KEY_LEFT_ARROW:
        this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_LEFT_ARROW] = true;
        isMoveScreenPressed = true;
        break;
    case cocos2d::EventKeyboard::KeyCode::KEY_RIGHT_ARROW:
        this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_RIGHT_ARROW] = true;
        isMoveScreenPressed = true;
        break;
    case cocos2d::EventKeyboard::KeyCode::KEY_UP_ARROW:
        this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_UP_ARROW] = true;
        isMoveScreenPressed = true;
        break;
    case cocos2d::EventKeyboard::KeyCode::KEY_DOWN_ARROW:
        this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_DOWN_ARROW] = true;
        isMoveScreenPressed = true;
        break;
    }
    if (isMoveScreenPressed == true && this->isScroll == false) {
        std::thread thr(&HelloWorld::startScroll, this);
    
        thr.detach();
    }
}

void HelloWorld::onKeyReleased(EventKeyboard::KeyCode keyCode, Event* event)
{
    log("Key with keycode %d released", keyCode);
    this->keyPressed[keyCode] = false;
    if (
        this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_DOWN_ARROW] == false
        && this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_UP_ARROW] == false
        && this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_LEFT_ARROW] == false
        && this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_RIGHT_ARROW] == false) {
            this->isScroll = false;
    }
}

void HelloWorld::startScroll() {
    if (this->isScroll == false) {
        this->isScroll = true;
        while (this->isScroll == true) {
            this->moveScreen();
            std::this_thread::sleep_for(std::chrono::milliseconds(SCROLL_INTERVAL));
        }
    }
}
  
void HelloWorld::moveScreen() {
    float x, y;
    float shift = 100.00;
    if (this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_LEFT_ARROW] == true) {
        x = this->getPositionX();
        log("move map left %f", x);
        if (x < 0) {
            this->setPositionX(x + shift);
        }
    }

    if (this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_RIGHT_ARROW] == true) {
        x = this->getPositionX();
        log("move map right %f", x);
        if (x > -(this->mapWidth)) {
            log("set pos %f", x - shift);
            this->setPositionX(x - shift);
        }
    }

    if (this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_UP_ARROW] == true) {
        y = this->getPositionY();
        log("move map up %f", y);
        if (y > -(600 + this->mapHeight)) {
            this->setPositionY(y - shift);
        }
    }

    if (this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_DOWN_ARROW] == true) {
        y = this->getPositionY();
        log("move map down %f", y);
        if (y < 0) {
            this->setPositionY(y + shift);
        }
    }
}

Перемещение по карте

Перемещение по карте

Перемещение по карте

Добавим спрайт на карту. Спрайту можно присвоить имя, чтобы в последующем можно было выбрать его из общего массива:

// Создаем текстуру
auto texture = Director::getInstance()->getTextureCache()->addImage("./images/MiniWorldSprites/Characters/Monsters/Orcs/FarmerGoblin.png");
// Вырезаем фрейм
auto frame = SpriteFrame::createWithTexture(texture, Rect(0, 0, 32, 32));
// создаем спрайт
auto sprite = Sprite::createWithSpriteFrame(frame);

sprite->setPosition(Vec2(START_X, START_Y));
sprite->setName("playerUnit");
// добавляем на сцену
this->addChild(sprite, 1);

Далее нужно выбрать спрайт по щелчку и передвинуть по-следующему. Для этого есть встроенная функция moveTo (). Все объекты которые добавляются на сцену, попадают в общий массив Vector, его можно перебирать его и вытаскивать нужные. При щелчке по спрайту добавим рамку вокруг. А если спрайт уже выбран переместим его в место клика:

void HelloWorld::onMouseDown(Event* event)
{
    EventMouse* e = (EventMouse*)event;
  
    if (e->getMouseButton() == EventMouse::MouseButton::BUTTON_LEFT) {
        float x = e->getCursorX() - this->getPositionX();
        float y = e->getCursorY() - this->getPositionY();
      
        auto children = this->getChildren();
        // перебираем все объекты на сцене
        for (auto child : children) {
            if (child->getName() == "playerUnit") {
                if (child->getBoundingBox().containsPoint(Vec2(x, y))) {
                    // Если спрайт в движении, ничего не делаем
                    if (child->getActionByTag(1) != nullptr) {
                        return;
                    }
                    // удаляем рамку, если она уже есть
                    if (!this->selectedUnits.empty()) {
                        this->removeSelections();
                    }
                    AudioEngine::play2d("audio/gruntnogold1.mp3");
                    const Vec2& pos = child->getPosition();
                    const Vec2& anchor = child->getAnchorPoint();

                    // рисуем новую рамку
                    DrawNode* node = DrawNode::create();
                    // устанавливаем размер 32х32
                    node->setContentSize(cocos2d::Size(32.00, 32.00));
                    node->setAnchorPoint(anchor);
                    
                    cocos2d::Color4F white(1, 1, 1, 1);
                    node->drawRect(Vec2(0, 0), Vec2(32, 0), Vec2(32, 32), Vec2(0, 32), white);
                    node->setPosition(pos);
                    node->setName("selection");
                    
                    this->addChild(node, 2);
                    this->selectedUnits.push_back(child);
                    this->selectedUnits.push_back(node);
                    break;
                }
                else {
                    // если есть рамка - это второй сценарий, перемещения
                    if (this->getChildByName("selection") != nullptr) {
                        for (auto unit : this->selectedUnits) {
                            this->moveUnitTo(unit, x, y);

                        }
                    }
                }
            }
        }
    }
}

void HelloWorld::removeSelections() {
    this->removeChildByName("selection");
    this->selectedUnits.clear();
}

void HelloWorld::moveUnitTo(Node* node, float x, float y) {
    // если спрайт уже в движении, отменим задание
    if (node->getActionByTag(1) != nullptr) {
        node->stopActionByTag(1);
    }
    Vec2 currPos = node->getPosition();

    float distance = currPos.distance(Vec2(x, y));
    auto calcAngle = [](Vec2 pos1, Vec2 pos2) { return atan2((pos2.y - pos1.y), (pos2.x - pos1.x)); };
    float angle = calcAngle(currPos, Vec2(x, y));
    float moveTime = distance / 100.00;
  
    auto moveTo = MoveTo::create(moveTime, Vec2(x, y));
    moveTo->setTag(1);
    node->runAction(moveTo);
}

Выбранный спрайт

Выбранный спрайт с рамкой

Выбранный спрайт с рамкой

Официальные рекомендации для создания анимаций — использовать стороннюю программу для создания анимации и файлы plist.

Но если неохота заморачиваться, можно сделать проще. Загружаем картинку, затем создаем массивы Vector с помощью SpriteFrame: createWithTexture () и затем анимация с помощью Animation: createWithSpriteFrames (). Довольно громоздко, но работает.

std::string ANIMATION_MOVE_DOWN = "moveDown",
            ANIMATION_MOVE_UP = "moveUp",
            ANIMATION_MOVE_RIGHT = "moveRight",
            ANIMATION_SPEARGOBLIN_MOVE_RIGHT = "spearGoblinMoveRight";

void HelloWorld::addStartUnitsAndBuildings() {
    auto texture = Director::getInstance()->getTextureCache()->addImage("./images/MiniWorldSprites/Characters/Monsters/Orcs/FarmerGoblin.png");
    auto frame1 = SpriteFrame::createWithTexture(texture, Rect(0, 0, 32, 32));
    
    Vector framesMoveDown = {
        SpriteFrame::createWithTexture(texture, Rect(0, 0, 32, 32)),
        SpriteFrame::createWithTexture(texture, Rect(32, 0, 32, 32)),
        SpriteFrame::createWithTexture(texture, Rect(64, 0, 32, 32)),
        SpriteFrame::createWithTexture(texture, Rect(96, 0, 32, 32)),
        SpriteFrame::createWithTexture(texture, Rect(128, 0, 32, 32))
    };
    
    Vector framesMoveUp = {
        SpriteFrame::createWithTexture(texture, Rect(0, 32, 32, 32)),
        SpriteFrame::createWithTexture(texture, Rect(32, 32, 32, 32)),
        SpriteFrame::createWithTexture(texture, Rect(64, 32, 32, 32)),
        SpriteFrame::createWithTexture(texture, Rect(96, 32, 32, 32)),
        SpriteFrame::createWithTexture(texture, Rect(128, 32, 32, 32))
    };
    
    Vector framesMoveRight = {
       SpriteFrame::createWithTexture(texture, Rect(0, 64, 32, 32)),
       SpriteFrame::createWithTexture(texture, Rect(32, 64, 32, 32)),
       SpriteFrame::createWithTexture(texture, Rect(64, 64, 32, 32)),
       SpriteFrame::createWithTexture(texture, Rect(96, 64, 32, 32)),
       SpriteFrame::createWithTexture(texture, Rect(128, 64, 32, 32))
    };
    
    auto textureSpearGoblin = Director::getInstance()->getTextureCache()->addImage("./images/MiniWorldSprites/Characters/Monsters/Orcs/SpearGoblin.png");
    
    Vector framesSpearGoblinMoveRight = {
        SpriteFrame::createWithTexture(textureSpearGoblin, Rect(0, 64, 32, 32)),
        SpriteFrame::createWithTexture(textureSpearGoblin, Rect(32, 64, 32, 32)),
        SpriteFrame::createWithTexture(textureSpearGoblin, Rect(64, 64, 32, 32)),
        SpriteFrame::createWithTexture(textureSpearGoblin, Rect(96, 64, 32, 32)),
        SpriteFrame::createWithTexture(textureSpearGoblin, Rect(128, 64, 32, 32))
    };
    
    animationFrames[ANIMATION_MOVE_DOWN] = framesMoveDown;
    animationFrames[ANIMATION_MOVE_UP] = framesMoveUp;
    animationFrames[ANIMATION_MOVE_RIGHT] = framesMoveRight;
    animationFrames[ANIMATION_SPEARGOBLIN_MOVE_RIGHT] = framesSpearGoblinMoveRight;

    auto sprite = Sprite::createWithSpriteFrame(frame1);
    if (sprite == nullptr)
    {
        problemLoading("'./images/MiniWorldSprites/Characters/Monsters/Orcs/FarmerGoblin.png'");
    }
    else
    {
        sprite->setPosition(Vec2(START_X, START_Y));
        sprite->setName("playerUnit");
        
        this->addChild(sprite, 1);
    }
}

void HelloWorld::moveUnitTo(Node* node, float x, float y) {
    ...
    if (node->getName() == "playerUnit") {
        // если анимация уже проигрывается, отменим задание
        if (node->getActionByTag(2) != nullptr) {
            node->stopActionByTag(2);
        }
        std::string animationType = "none";
      
        auto sprite = dynamic_cast(node);
        if ((M_PI / 4 >= angle) && (angle >= -1 * M_PI / 4)) {
            animationType = ANIMATION_MOVE_RIGHT;
            sprite->setFlippedX(false);
        }
        else if ((3 * M_PI / 4 >= angle) && (angle >= M_PI / 4)) {
            animationType = ANIMATION_MOVE_UP;
        }
        else if ((angle <= (- 1 * M_PI / 4)) && (angle >= (-3 * M_PI / 4))) {
            animationType = ANIMATION_MOVE_DOWN;
        }
        else if ((angle >= 3 * M_PI / 4) || (angle <= -3 * M_PI / 4)) {
            animationType = ANIMATION_MOVE_RIGHT;
            sprite->setFlippedX(true);
        }
        // set animation
        float animationTickTime = 0.3;
        float animationFrameTime = animationFrames[animationType].size() * animationTickTime;
        int animationRepeats = static_cast(moveTime / animationFrameTime);
        auto animation = Animation::createWithSpriteFrames(animationFrames[animationType], animationTickTime);
        auto animate = Animate::create(animation);
        auto repeatAction = Sequence::create(animate, DelayTime::create(.0f), nullptr);
        auto repeatAnim = Repeat::create(repeatAction, animationRepeats);
        repeatAnim->setTag(2);
        sprite->runAction(repeatAnim);
    }
}

Анимация движения

Движение и анимация

Движение и анимация

Попробуем большие tilemap карты. У меня запустилась карта 100×100, карты 200×200 и больше падают с ошибкой. Как оказалось, максимальный размер карты, ограничен 128×128 ячеек, на форумах предлагают укрупнять размер ячеек если нужна большая карта. К сравнению в своем движке jsge, реализовал поддержку сколь угодно больших карт за счет обработки только видимых ячеек на экране.

Тест по количеству спрайтов. Попробовал добавлять спрайты пробелом и двигать с анимацией в другой угол карты — 300 шт. тянет без проблем, нужен тест посложнее.

Будем добавлять спрайты пока держим клавишу пробел. Если использовать нативный вызов std: thread, как делали перемещение карты и в нем добавление спрайтов, игра падает различными с ошибками.

Основная проблема в том, что cocos2d: Vector не является потокобезопасным объектом. Официальная рекомендация — выполнять все в потоке движка используя потокобезопасный встроенный объект Schedule ():

void HelloWorld::onKeyPressed(EventKeyboard::KeyCode keyCode, Event* event)
{
    ...
    case cocos2d::EventKeyboard::KeyCode::KEY_SPACE:
        this->keyPressed[cocos2d::EventKeyboard::KeyCode::KEY_SPACE] = true;
        log("Start spawn thread");
        this->schedule(CC_SCHEDULE_SELECTOR(HelloWorld::addUnitAndMove), 0.006f);
        break;
    }
    ...
}

void HelloWorld::addUnitAndMove(float dt) {
    auto sprite = Sprite::createWithSpriteFrame(*animationFrames[ANIMATION_MOVE_RIGHT].begin());
    
    sprite->setPosition(Vec2(this->addUnitPosX, this->addUnitPosY));
    this->addUnitPosY -= 16.00;
    sprite->setName("playerUnit");
  
    this->addChild(sprite, 1);
    this->moveUnitTo(sprite, 2500, this->addUnitPosY);
}

Чтобы точно знать сколько спрайтов уже добавили, выведем эту информацию на экран:

bool HelloWorld::init()
{
    ...
    std::string labelStr = std::to_string((int)this->unitsCount);
    auto label = Label::createWithTTF("Sprites #", "fonts/arial.ttf", 62);
    label->setPosition(Vec2(origin.x + visibleSize.width / 2,
        origin.y + visibleSize.height - label->getContentSize().height));
    
    this->unitsCountLabel = Label::createWithTTF(labelStr, "fonts/arial.ttf", 62);
    this->unitsCountLabel->setPosition(Vec2(origin.x + visibleSize.width / 2 + label->getContentSize().width,
        origin.y + visibleSize.height - label->getContentSize().height));
    
    this->addChild(label, 3);
    
    this->addChild(this->unitsCountLabel, 3);
}

void HelloWorld::addUnitAndMove(float dt) {
    ...
    ++this->unitsCount;
    this->unitsCountLabel->setString(std::to_string((int)this->unitsCount));  
}

Что получилось: движок легко тянет больше 15 000 спрайтов, какого-то замедления я не заметил:

15 000 спрайтов / одна текстура

15 000 спрайтов/ одна текстура

15 000 спрайтов/ одна текстура

1*Скорость gif-ки: 1 кадр/сек.

FPS: 60

GL CALLS: 8

Судя по отладочной информации у нас постоянно 8 вызовов GL CALLS — это вызовы отрисовки, шесть для слоев тайлмапы, два — для текста (количества спрайтов на экране) и один для спрайта. Получается, все добавленные спрайты рисуются в один проход и получается очень быстро, попробуем сделать еще один тест и загрузим еще один спрайт с другой текстурой.

10000 спрайтов / две текстуры

4000 спрайтов/ две текстуры

4000 спрайтов/ две текстуры

*Скорость gif-ки: 1 кадр/сек.

FPS: 50–60

GL CALLS: 6–8376

После каждого добавления спрайтов, растет и число GL CALLS после 6000 спрайтов идет небольшое уменьшение FPS. Судя по всему движок не склеивает разные текстуры, а рисует каждый раз отдельно, производительность от этого немного страдает.

Сделаем еще один тест, добавим в симуляцию детектор коллизий, в cocos2d есть встроенная физика на основе box2d. Включим ее в приложение.

bool HelloWorld::init()
{
    //////////////////////////////
    // 1. super init first
    if ( !Scene::initWithPhysics() )
    {
        return false;
    }
    ...
  }

Далее добавляем физические параметры нашим спрайтам:

auto sprite = Sprite::createWithSpriteFrame(*animationFrames[ANIMATION_MOVE_RIGHT].begin());

sprite->setPosition(Vec2(this->addUnitPosX, this->addUnitPosY));
this->addUnitPosY -= 32.00;
sprite->setName("playerUnit");
    
auto spriteBody = PhysicsBody::createBox(sprite->getContentSize(), PhysicsMaterial(0, 0, 0));
sprite->setPhysicsBody(spriteBody);

this->addChild(sprite, 1);

Что такое? Спрайты начинают падать:

Спрайты падают

a5f9e538b93245a5c3e4dedec695e39a.gif

*Скорость gif-ки: 4 кадр/сек.

Мы забыли выключить «гравитацию»:

bool HelloWorld::init()
{
    ...
    // по-умолчанию включена гравитация и все спайты полетят вниз карты
    // выключим ее
    this->_physicsWorld->setGravity(Vec2(0, 0));
}

Теперь спрайты начинают толкаться, посмотрим сколько выдержит движок.

Физика 3000 спрайтов

физика 3000 спрайтов

физика 3000 спрайтов

*Скорость gif-ки: 1 кадр/сек.

FPS: 38–60

Около 1800 тянет легко, потом немного снижается fps при добавлении новых объектов, около 3000 спрайтов FPS примерно 50–55. К сравнению, демка matter.js, тянет 700 объектов без просадки fps, 1290 — на скорости 40 fps, но это javascript в браузере.

Заключение

Очень много различных программ и их версий под одним брендом Cocos2d, очень легко запутаться и понять, что есть что. Есть Cocos Creator, его не пробовал, по способу установки и скриншотам очень напоминает Unreal Engine 5. Есть движок cocos2d-x, который мы рассматривали в статье, последняя версия v4.

По документации. Есть отдельный ресурс для Cocos Creator версий 1–3 и для cocos2d-x: https://docs.cocos2d-x.org/api-ref/

По движку cocos2d-x

  • Технический, все очень похоже на то, что есть в других движках в т.ч. и на javascript.

  • Дружит со сторонними программами и файлами. Максимальный размер тайлмапы 128×128 ячеек.

  • В отладке, помимо FPS, есть количество вызовов отрисовки GL_CALLS.

  • Легко подключается физика на основе box2d.

  • Встроенный физ. движок, тянет до 3000 спрайтов на карте.

  • Доступна только 32 битная сборка.

  • Последний релиз 2019. К сожалению проект мертв и поддерживается только Cocos Creator. Как идейного продолжателя предлагают Axmol Engine — это форк который поддерживают и стабильно релизят, есть и 64 битная сборка.

Ссылки

Исходники и демка: https://github.com/ALapinskas/cocos2d-x-performance-test

Спрайты отсюда: https://merchant-shade.itch.io/16×16-mini-world-sprites

Конфигурация оборудования на котором проводились тесты:

© Habrahabr.ru