Стандартная библиотека C++ содержит множество классов и функций, которые легко интегрируются в проект, безопасны и протестированы на множестве кейсов. Однако за удобность и всеядность приходится платить производительностью. В играх, если производительность сразу не стоит на первом месте, то к концу проекта вы получаете такой технический долг, что проще бывает всё выкинуть и начать заново. Прямолинейное использование стандартной библиотеки в большинстве случаев, когда нужен производительный и эффективный код, я сейчас не только про игры, оказывается не лучшим выбором.

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

Статья рассчитана на читателей, которые не являются гуру C++ или знатоками тонкостей языка, но в целом знакомы с языком и его идеями, хотя знание ассемблера x86 не требуется, я буду прикладывать ссылки на примеры кода quickbench, чтобы объяснить, почему даю те или иные советы.

Иногда я тут буду ужасы рассказывать, но большинство этих случаев мешало нормальной работе софта в проде, так что пришлось относиться к ним с уважением.


Спасибо, хабраюзер, что прошел со мной этот путь через серию статей про плюсатый игрострой — от лёгкой акробатики со строками и контейнерами, до мудреных аллокаторов и многопоточки. Ты не только поддержал мой интерес к собиранию разрозненных кусочков в нечто целое и самостоятельное, но и помог превратить технические заметки в живой диалог о разработке игр. Надеюсь, эти статьи стали не просто хорошим чтением на выходные, но и поводом для собственных экспериментов, рефакторинга и новых идей. Игра ещё не окончена!

Большинство тормозного кода появляется либо от лени, либо от большого ума, и обычно это действительно умный и модный код. Но вместо того, чтобы открыть профайлер, мы просто наворачиваем больше модного кода. Да и мощное железо очень сильно расслабляет, и вот ужеstd::map начинают использовать там, где должен быть простой массив. Или пихаем вездеstd::function, shared_ptr, variant, аллоцируем в рантайме, копируем в лямбде по значению, надеясь что компилятор что-то там «наоптимизирует», ну что-то он конечно оптимизирует, но думать за нас не будет. А потом QA жалуются, что игра на Xbox Series X лагает, как на калькуляторе. Кто же знал, что атомарные инкременты у shared_ptr не бесплатные? Все. Все знали. В современном софте тормозить может наверное всё, начиная от аллокаций, и заканчивая чтением с диска, ниже будет не очень большой список, но почему-то за все проекты в памяти ярко запомнились только эти случаи, но их было намного больше, всех не упомнить. Да и у тебя, уважаемый хабраюзер, наверняка есть пара историй, которые могут дополнить этот список, не стесняйся рассказать о них в комментариях. Поехали!

Выделение памяти - зло, зла на всех не хватает

Первое чему учатся при разработке игр под консоли - минимум алокаций в кадре, что в корне расходится с гайдлайнами комитета, по использованию контейнеров стандартной библиотеки (https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rsl-arrays)

SL.con.1: Prefer using STL array or vector instead of a C array
Reason C arrays are less safe, and have no advantages over array and vector. For a fixed-length array, use std::array, which does not degenerate to a pointer when passed to a function and does know its size. Also, like a built-in array, a stack-allocated std::array keeps its elements on the stack. For a variable-length array, use std::vector, which additionally can change its size and handles memory allocation.

Распространённый совет — предпочитать std::vector обычным C-массивам и статическим векторам, потому что это более безопасно и сохраняет контроль владения ресурсом. Это вполне разумно, особенно если мы предполагаем, что вектор будет расти в процессе работы. Однако если в росте нет необходимости, а производительность критична, есть способы добиться лучших результатов.

Обычно vector внутри хранит указатели, один или более, некоторые указатели (вроде конечного элемента и емкости) можно заменить на числа, но суть от этого не меняется, это все тот же последовательный кусок памяти:

  1. begin — указатель на начало данных,

  2. end — указатель на позицию сразу после последнего элемента (используется для расчета текущего размера),

  3. capacity_end — указатель на конец выделенного блока памяти (определяет максимальный размер без перералокации).

struct vector {
    T* begin;
    T* end;
    T* capacity;
};

Эта тройка указателей позволяет std::vector быстро проверить размер, есть ли место для новых элементов, проитерироваться, сдвинуть, удалить и тд. Но за это приходится платить косвенным доступом к элементам: данные хранятся не внутри объекта, а по адресу, на который указывает begin. Это нарушает локальность данных в кэше, первые несколько обращений к данным вектора (обычно 10-15) пока внутренние структуры CPU не адаптируются к новому паттерну, проходят "вхолодную", они в несколько раз медленнее чем последующие обращения, поэтому работать с небольшими векторами даже накладнее, чем с большими, там всегда присутствуют cache-miss'ы и алгоритм не успевает "разогнаться". Такой эффект известен под именем "cold start" - это немного более общее понятие, но суть та же - маленькие структуры данных всегда дороже в обработке.

Эффект "холодного старта"

Когда вы впервые обращаетесь к элементам вектора, данные еще не загружены в кэш процессора, поэтому происходит много cache miss'ов. После нескольких обращений данные загружаются в кэш (L1, L2, L3), и последующие обращения становятся значительно быстрее - "cache warm-up".

Особенно заметно при:

  • Первом проходе по большому вектору

  • Обращении к данным после вытеснения из кеша (cold start)

  • Работе с векторами, размер которых превышает размер кэша (cache poisoning)

Для минимизации этого эффекта используютcя техники вроде:

  • Prefetching (предварительная загрузка данных)

  • Cache-friendly алгоритмы с хорошей пространственной локальностью

  • "Прогрев" кэша перед основными вычислениями

Даже если вектор маленький, его данные всегда находятся в куче (heap), что значительно дороже по временным затратам. Способ инициализации вектора имеет значение. Для конкретного примера предположим, что у нас есть число N, и мы хотим вернуть вектор, содержащий квадраты первых N чисел:
[0, 1, 4, 9, 16, ...]

Современная реализация вектора обложена большим числом проверок - на текущий размер, на выход за границы массива, на необходимость перераспределения памяти. Компилятор сгенерирует кучу дополнительного кода (в частности, вызовы operator new, memmove и operator delete) и обработку исключений, так что сгенерированный код оказывается сильно раздутым безо всякой на то причины. Даже для простой функции, которая возвращает некоторый массив элементов известного размера - будут включены все эти проверки. (https://godbolt.org/z/vK5W93z8W). Вот так пишут обычные разработчики, когда хотят вернуть некий массив значений, тут еще сделано резервирование памяти в векторе, иначе будет совсем грустно.

std::vector<int> createVector(int size) {
    std::vector<int> result;
    result.reserve(size);
    for (int ii = 0; ii < size; ++ii) {
        result.push_back(ii * ii);
    }
    return result;
}
Asm (vector<int>)
makeVector(int):
        push    rbp
        push    r15
        push    r14
        push    r13
        push    r12
        push    rbx
        sub     rsp, 24
        xorps   xmm0, xmm0
        movups  xmmword ptr [rdi], xmm0
        mov     qword ptr [rdi + 16], 0
        test    esi, esi
        js      .LBB0_1
        mov     rbp, rdi
        je      .LBB0_27
        mov     r12d, esi
        movsxd  rbx, esi
        lea     rdi, [4*rbx]
        call    operator new(unsigned long)
        mov     r15, rax
        mov     qword ptr [rbp], rax
        mov     qword ptr [rbp + 8], rax
        lea     r9, [rax + 4*rbx]
        mov     qword ptr [rbp + 16], r9
        xor     r14d, r14d
        mov     r8, rax
        mov     rbx, rax
        mov     qword ptr [rsp + 16], rbp
        mov     dword ptr [rsp + 12], r12d
        jmp     .LBB0_6
.LBB0_7:
        mov     dword ptr [rbx], r13d
        add     rbx, 4
        mov     qword ptr [rbp + 8], rbx
        add     r14d, 1
        cmp     r12d, r14d
        je      .LBB0_27
.LBB0_6:
        mov     r13d, r14d
        imul    r13d, r14d
        cmp     rbx, r9
        jne     .LBB0_7
        mov     rdx, rbx
        sub     rdx, r8
        movabs  rax, 9223372036854775804
        cmp     rdx, rax
        je      .LBB0_9
        mov     rax, rdx
        sar     rax, 2
        mov     ecx, 1
        test    rdx, rdx
        je      .LBB0_13
        mov     rcx, rax
.LBB0_13:
        lea     rdx, [rcx + rax]
        movabs  rsi, 2305843009213693951
        mov     r12, rsi
        cmp     rdx, rsi
        ja      .LBB0_15
        mov     r12, rdx
.LBB0_15:
        add     rcx, rax
        movabs  rax, 2305843009213693951
        cmovb   r12, rax
        test    r12, r12
        mov     qword ptr [rsp], r8
        je      .LBB0_16
        mov     rbp, r15
        mov     r15, r9
        lea     rdi, [4*r12]
        call    operator new(unsigned long)
        mov     rbp, rax
        mov     r8, qword ptr [rsp]
        mov     r9, r15
        jmp     .LBB0_19
.LBB0_16:
        xor     ebp, ebp
.LBB0_19:
        mov     rdx, r9
        sub     rdx, r8
        mov     rax, rdx
        sar     rax, 2
        lea     r15, [4*rax]
        add     r15, rbp
        mov     dword ptr [rbp + 4*rax], r13d
        test    rdx, rdx
        jle     .LBB0_21
        mov     rdi, rbp
        mov     rsi, r8
        mov     r13, r9
        call    memmove
        mov     r9, r13
        mov     r8, qword ptr [rsp]
.LBB0_21:
        add     r15, 4
        sub     rbx, r9
        test    rbx, rbx
        jle     .LBB0_23
        mov     rdi, r15
        mov     rsi, r9
        mov     rdx, rbx
        call    memmove
        mov     r8, qword ptr [rsp]
.LBB0_23:
        test    r8, r8
        je      .LBB0_25
        mov     rdi, r8
        call    operator delete(void*)
.LBB0_25:
        add     rbx, r15
        mov     r15, rbp
        mov     rbp, qword ptr [rsp + 16]
        mov     qword ptr [rbp], r15
        mov     qword ptr [rbp + 8], rbx
        lea     r9, [r15 + 4*r12]
        mov     qword ptr [rbp + 16], r9
        mov     r8, r15
        mov     r12d, dword ptr [rsp + 12]
        add     r14d, 1
        cmp     r12d, r14d
        jne     .LBB0_6
.LBB0_27:
        mov     rax, rbp
        add     rsp, 24
        pop     rbx
        pop     r12
        pop     r13
        pop     r14
        pop     r15
        pop     rbp
        ret
.LBB0_9:
        mov     edi, offset .L.str.1
        call    std::__throw_length_error(char const*)
.LBB0_1:
        mov     edi, offset .L.str
        call    std::__throw_length_error(char const*)
        jmp     .LBB0_29
        mov     rdi, rax
        call    _Unwind_Resume
        mov     r15, rbp
.LBB0_29:
        mov     rbx, rax
        test    r15, r15
        je      .LBB0_31
        mov     rdi, r15
        call    operator delete(void*)
.LBB0_31:
        mov     rdi, rbx
        call    _Unwind_Resume

Но если вы просто вернете некоторый заполненный массив элементов, такой облегченный массив, то компилятор создаст совсем другой код, свободный от этих обвесов. Половина возвращаемых результатов в виде векторов в приложении не меняется, а если не меняется - зачем использовать для этого дорогущий вектор?

std::unique_ptr<int[]> createSharedArray(int size) {
    int *result;
    result = new int[size];
    for (int ii = 0; ii < size; ++ii) {
        result[ii] = (ii * ii);
    }
    return std::unique_ptr<int[]>{result};
}
Asm (std::unique_ptr<int[]>)
makeArray(int):
        push    rbp
        push    r14
        push    rbx
        mov     ebp, esi
        movsxd  rbx, esi
        mov     ecx, 4
        mov     rax, rbx
        mul     rcx
        mov     r14, rdi
        mov     rdi, -1
        cmovno  rdi, rax
        call    operator new[](unsigned long)
        test    ebx, ebx
        jle     .LBB1_11
        mov     ecx, ebp
        cmp     ebp, 7
        ja      .LBB1_3
        xor     edx, edx
        jmp     .LBB1_10
.LBB1_3:
        mov     edx, ecx
        and     edx, -8
        lea     rsi, [rdx - 8]
        mov     rbp, rsi
        shr     rbp, 3
        add     rbp, 1
        test    rsi, rsi
        je      .LBB1_4
        mov     rdi, rbp
        and     rdi, -2
        neg     rdi
        movdqa  xmm0, xmmword ptr [rip + .LCPI1_0]
        xor     esi, esi
        movdqa  xmm1, xmmword ptr [rip + .LCPI1_1]
        movdqa  xmm2, xmmword ptr [rip + .LCPI1_2]
        movdqa  xmm3, xmmword ptr [rip + .LCPI1_3]
        movdqa  xmm4, xmmword ptr [rip + .LCPI1_4]
.LBB1_6:
        movdqa  xmm5, xmm0
        paddd   xmm5, xmm1
        movdqa  xmm6, xmm0
        pmuludq xmm6, xmm0
        pshufd  xmm6, xmm6, 232
        pshufd  xmm7, xmm0, 245
        pmuludq xmm7, xmm7
        pshufd  xmm7, xmm7, 232
        punpckldq       xmm6, xmm7
        pshufd  xmm7, xmm5, 245
        pmuludq xmm5, xmm5
        pshufd  xmm5, xmm5, 232
        pmuludq xmm7, xmm7
        pshufd  xmm7, xmm7, 232
        punpckldq       xmm5, xmm7
        movdqu  xmmword ptr [rax + 4*rsi], xmm6
        movdqu  xmmword ptr [rax + 4*rsi + 16], xmm5
        movdqa  xmm5, xmm0
        paddd   xmm5, xmm2
        movdqa  xmm6, xmm0
        paddd   xmm6, xmm3
        pshufd  xmm7, xmm5, 245
        pmuludq xmm5, xmm5
        pshufd  xmm5, xmm5, 232
        pmuludq xmm7, xmm7
        pshufd  xmm7, xmm7, 232
        punpckldq       xmm5, xmm7
        pshufd  xmm7, xmm6, 245
        pmuludq xmm6, xmm6
        pshufd  xmm6, xmm6, 232
        pmuludq xmm7, xmm7
        pshufd  xmm7, xmm7, 232
        punpckldq       xmm6, xmm7
        movdqu  xmmword ptr [rax + 4*rsi + 32], xmm5
        movdqu  xmmword ptr [rax + 4*rsi + 48], xmm6
        add     rsi, 16
        paddd   xmm0, xmm4
        add     rdi, 2
        jne     .LBB1_6
        test    bpl, 1
        je      .LBB1_9
.LBB1_8:
        movdqa  xmm1, xmmword ptr [rip + .LCPI1_1]
        paddd   xmm1, xmm0
        pshufd  xmm2, xmm0, 245
        pmuludq xmm0, xmm0
        pshufd  xmm0, xmm0, 232
        pmuludq xmm2, xmm2
        pshufd  xmm2, xmm2, 232
        punpckldq       xmm0, xmm2
        pshufd  xmm2, xmm1, 245
        pmuludq xmm1, xmm1
        pshufd  xmm1, xmm1, 232
        pmuludq xmm2, xmm2
        pshufd  xmm2, xmm2, 232
        punpckldq       xmm1, xmm2
        movdqu  xmmword ptr [rax + 4*rsi], xmm0
        movdqu  xmmword ptr [rax + 4*rsi + 16], xmm1
.LBB1_9:
        cmp     rdx, rcx
        je      .LBB1_11
.LBB1_10:
        mov     esi, edx
        imul    esi, edx
        mov     dword ptr [rax + 4*rdx], esi
        add     rdx, 1
        cmp     rcx, rdx
        jne     .LBB1_10
.LBB1_11:
        mov     qword ptr [r14], rax
        mov     rax, r14
        pop     rbx
        pop     r14
        pop     rbp
        ret
.LBB1_4:
        movdqa  xmm0, xmmword ptr [rip + .LCPI1_0]
        xor     esi, esi
        test    bpl, 1
        jne     .LBB1_8
        jmp     .LBB1_9

std::unique_ptr

Разработчики компиляторов тоже знают об этом, и если вы используете шаблон вектора, сконструированного "под размер", то вызов будет очень похож на последний результат, который работает без большинства проверок. Этот код будет работать быстро, почти как сырая аллокация, если отработает RVO, ключевое слово тут ecли RVO в реальных программах помогает, но это хрупкий механизм, об этом в следующем абзаце.

std::vector<int> createPrealloc(int size) {
    std::vector<int> result(size);
    for (int ii = 0; ii < size; ++ii) {
        result[ii] = ii * ii;
    }
    return result;
}

https://quick-bench.com/q/ETpIgVgy1uwpYkJhn5tQRdegCm4

Жиза

В 19 году позвали меня (по знакомству) провести аудит кодовой базы движка одной питерской студии, которая уже думала уходить на Unreal, но бросать своё тоже не хотели. Название не скажу, но вы точно играли в одну из их hidden object игр. Старая команда, которая собственно движок писала, ушла в закат, а новая команда просто накидывала новый функционал под руководством ?ефективного менеджера, не особо разбираясь как движок был устроен под капотом. Не задумывались они и о производительности, от словам совсем, и по всему движку была раскидана работа с сырыми std::string, возврат мегабайтных векторов и создание std::map в циклах. Сказать что движку было плохо, это еще мягко. На среднем тогда андроиде игра выдавала 30 фпс на пустой сцене с парой десятков объектов, но т.к. сцены были в основном статичные, то фризы и просадки фпс никто особо не замечал. Посидели мы тогда со знакомым недельку над профайлером и выкатили отчет, что стюардессу дешевле будет закопать и уйти на Unreal. Вот так неправильная работа с памятью похоронила достаточно неплохой inhouse движок.

Коварный RVO

Самый простой способ начать управлять памятью — это выделять её динамически каждый раз, когда она нужна. Такой подход считается идеальным, поощряется во многих книгах по программированию и даже комитетом. Седой профессор по computer science, давно не дебаживший реальное приложение вещает студентам - как надо строить классы через строки, вектора и мапы - это не моя придумка - просто сходил на пару лекций к существующему "мэтру", когда пришлось фиксить fps drop от новых коллег.

И правда, пользоваться им очень просто. Нужен новый экземпляр анимации, когда игрок приземляется на выступ? Выдели память. Нужно воспроизвести новый звук, когда достигнута цель? Просто выдели ещё памяти.

Беспорядочное динамическое выделение памяти в теории помогает свести использование памяти к минимуму, потому что вы выделяете только то, что действительно нужно, и не больше. Но на практике всё оказывается намного хуже, поскольку у каждой аллокации вдруг оказывается неожиданно большой накладной расход, который начинает накапливаться, если программисты становятся слишком расслабленными. Это отличный способ отстрелить обе ноги компилятору, чтобы тот поменьше занимался оптимайзингом нашего кода.

struct ParticleSystem {
    std::vector<float> particles; // вектор тут специально, 
                                  // был Particle, float тут просто для теста

    ParticleSystem(size_t count) : particles(count, 1.f) {}
};

std::optional<ParticleSystem> make_editable_particle_system() {
    ParticleSystem ps(100); // тяжелая аллокация
    return ps;
}

std::optional<ParticleSystem> make_baked_particle_system() {
    const ParticleSystem ps(100); // `const` блокирует RVO
    return ps;
}

https://quick-bench.com/q/2ysRdpKJWSklB1mDA1b0EuFysuE

Жиза

Жила себе в одной игре система частиц, никого не трогала, никому не мешала. Пришло время порефакторить её и сделать более настраиваимой в редакторе эффектов — огонь, дым, искры, остаточные следы... Дали возможность дизайнерам настраивать около сотни параметров частицы, с разной динамикой и временем жизни. Всё выглядело великолепно в редакторе — эффект собирался из множества параметров, и можно было на лету менять цвета, скорость, типы частиц. Система возвращала этот эффект как объект ParticleSystem, который уже дальше передавали в игру.

Дизайнеры осмелели и начали выкатывать более сложные эффекты, да вот беда сцены начали фризить на них. Полезли в код, а там вот такая фигня с const ,который выключал RVO. То есть вместо одного создания объекта происходило создание + копирование. А так как объект был большим (один из эффектов перевалил за 10к элементов), это реально тормозило и на 60 fps был явный фриз, и ни один из тогдашних компиляторов не осилил сделать move. Почему там сделали конст и проглядели это на ревью, отдельная тема.

Тяжелый callback

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

truct GameObject {
  uint32_t id;
  float lifetime;
  int visible;

  GameObject() : id(0), lifetime(0.0f), visible(std::rand()) {}
};

bool isVisibleForEnemy(const GameObject &o) noexcept {
  return o.visible && !(o.visible & (o.visible - 1));
}

bool isVisibleForFriends(const GameObject& o) noexcept {
  constexpr std::uint64_t visibleForFriends = 0x8000;
  return o.visible > 0 && (visibleForFriends & o.visible == visibleForFriends);
}

template <typename callback_type_>
void executeRealEntityWork(const GameObject& o, callback_type_ &&callback) noexcept {
  if (o.visible) {
    callback(o);
  }

  for (std::uint64_t condition = 1; condition <= o.lifetime; condition++) {
    callback(o);
  }
}

static void FilterEntitiesLambda(benchmark::State &state) {
  float sum = 0, count = 0;
  for (auto _ : state) {
    sum = 0, count = 0;
    for (const auto &o : entities) {
      if (!isVisibleForEnemy(o) && !isVisibleForFriends(o))
        executeRealEntityWork(o, [&](const GameObject &o) {
          sum += o.lifetime;
          count++;
          benchmark::DoNotOptimize(sum);
        });
    }
  }
}

В порыве рефакторинга захотели, значит, мы это дело переписать на std::function, чтобы все было по гайдлайнам, модно и красиво. И получаем увеличение времени работы функций и времени компиляции в несколько раз при использовании std::function в качестве колбэков. Хотя это упрощает интерфейс, это также приводит к (!возможным) выделениям памяти в куче и тащит в юнит компиляции "шумный" <functional> один из самых крупных заголовков стандартной библиотеки.

Тот же код, вид сбоку, но медленнее
template<class T>
void forEachEntities(T& ent, std::function<void(const GameObject& o)> const &callback) {
  for (const auto &o : ent) callback(o);
}

void filterEntitiesStl(  //
    const GameObject &o,
    std::function<bool(const GameObject &o)> const &predicate,
    std::function<void(const GameObject &o)> const &callback) {
  if (!predicate(o)) callback(o);
}

void executeRealEntityWorkStl(
    const GameObject &o,
    std::function<void(const GameObject &o)> const &callback) {
  executeRealEntityWork(o, callback);
}

static void FilterEntitiesStdFunction(benchmark::State &state) {
  float sum = 0, count = 0;
  for (auto _ : state) {
    sum = 0, count = 0;
    forEachEntities(entities, [&](const GameObject &o) {
      filterEntitiesStl(o, isVisibleForEnemy, [&](const GameObject &o) {
        filterEntitiesStl(o, isVisibleForFriends, [&](const GameObject &o) {
          executeRealEntityWorkStl(o, [&](const GameObject &o) {
            sum += o.lifetime;
            count++;
            benchmark::DoNotOptimize(sum);
          });
        });
      });
    });
  }
}

https://quick-bench.com/q/8WeASFxS-1cNykvdtZR2Hu14mlM

Жиза

Переделывали мы как-то игровой ИИ — вроде ничего особенного: behavior tree ноды, пару десятков условий и действий, всё на месте. И тут кто-то в кавычках умный (к сожалению, я) предложил: давайте вместо самодельных callback'ов и шаблонных функций запилим через std::function. Мол, читаемость, расширяемость, "гайдлайны C++ Core" и всё такое. Запилили — красота! Код стройный, интерфейсы аккуратные, можно везде передавать анонимные лямбды, хоть из скриптов, хоть из кода, можно на лету поменять часть поведения. Всё гибко, всё по гайдлайну. Праздник для дизайнера прям.

Сильно хуже стало, когда отдали это в "прод". Сначала выросло время билда, не сильно - порядка 10%, но на новую систему перевели только 2 NPC из сотни. Стали смотреть CI, нашли сотни включений <functional> 27Kloc, который нашим неосторожным фиксом пролез очень много куда, дальше — хуже: начались периодические фризы в кадре. Причём не от рендеринга, не от загрузки, а прямо в логике ИИ. Что за х...

Начали смотреть — а std::function, оказывается, иногда аллоцирует в куче. Особенно когда туда попадает не просто лямбда, а лямбда с захваченным стейтом. А у нас эти функции вызываются по несколько сотен раз в кадр, и тенденция имела место к росту, потому что подключались новые NPC. Вот тебе и «возможные аллокации». На плойке эти аллокации стали дергать аллокатор так, что FPS просел в два раза. В итоге… всё выкинуть и вернуть обратно было уже поздно, больше года потрачено на новый ИИ, так что пришлось долго и упорно с профайлером в обнимку чинить эту мелочь и писать workaround'ы.

Иногда "модно" и "красиво" — это роскошь, которую в runtime не все могут себе позволить.

Дорогие идентификаторы

Многие библиотеки, включая STL (std::string), Folly::fbstring или boost::string, используют технику под названием «оптимизация малых строк» (SSO, Small String Optimization). Это приём, при котором короткие строки хранятся прямо внутри объекта строки, без выделения памяти в куче. Благодаря этому улучшается производительность и снижается фрагментация памяти, особенно при частых операциях со строками малой длины (например, идентификаторы, имена, ключи). Реализация SSO зависит от компилятора и стандартной библиотеки. В libstdc++ (GCC 13), std::string обычно хранит строки длиной до 15 байт прямо в объекте (sizeof(std::string) = 32 байта). В libc++ (Clang 17), лимит составляет 22 байта при sizeof(std::string) = 24 байта. Кастомные реализации строки также обычно поддерживают до 32 байт локального хранения, и обычно этого хватает для большинства идентификаторов, пока в движок не приходит компонентная система, и длинные составные строчки. И тут длина строки начинает непосредственно влиять на производительность игры, и есть смысл переходить на другие системы идентификаторов, не завязанные на строки.

https://quick-bench.com/q/J5ZTH1z_E7dE3OSU4ih3S2lB4II

Жиза

В Sims Mobile, вся внутриигровая логика — от действий персонажей до поведения объектов и триггеров в доме — была построена на колбеках и взаимодействии через строковые идентификаторы, в оригинальном Sims была такая же система но на числовых id'шках, перенести её в Unity не получилось в силу разных причин, поэтому была написана своя на raw строках.

Отчасти это было сделано временно для удобства отладки. У каждого объекта были десятки параметров: "fridge_open", "bed_sleep_short", "career_event_barista_1", "interaction_social_highfive" и так далее. Дизайнеры могли накидывать реакции просто составив разные строки и повесив на них обработчик, например fridge_open_sim_pants, если сим открыл холодильник в условных трусах.

В ранних билдах вся эта информация хранилась и передавалась через копирование, чтобы иметь возможность делать трейсы и логи работы. Это упрощало отладку, делало инструменты удобными для дизайнеров, но время шло, и временная система стала основной, и все забыли для чего её вообще делали, но в проде стало проявляться слишком много проблем.

Самая большая боль — время загрузки, на обычный уровень уходило по 3-4 минуты, понятно что игроки не будут просто тупить в экран это время, даже самые преданные симоводы. Дом симов с десятками объектов и несколькими активными симами, которые имели свои состояния и очереди действий, вызывал лавину строковых сравнений. Игрок видит как "сим встал с кровати и пошёл умываться", а в коде под капотом происходит сотни строковых сопоставлений: какая анимация? какой тип взаимодействия? какой костюм? Все это — длинные, составные имена, зачастую по 130+ символов.

С наполнением контентом пошли микрофризы даже от банальных действий: сим пытается выбрать доступный туалет — и тратит на это 2–3 мс, просто перебирая строковые ключи в логике маршрутизации. Казалось бы, фигня, но когда у тебя на экране 4 сима и 500 активных объектов, и все они активно что-то делают, игра начинала заикаться, особенно на слабых девайсах.

Пришлось переделывать, вместо строк — целочисленные ID. Каждой строке, участвующей в логике, на этапе сборки назначался стабильный uint32_t, а в рантайме происходило только сравнение чисел. Это сняло большую часть проблем: сцены стали загружаться быстрее, симы принимали решения моментально, а фоновая симуляция (даже когда пользователь в меню) перестала сжирать батарею телефона. Дизайнерам сделали все красиво и удобно и они почти не плакали, но это отдельная история.

Мораль - больше никто не предлагал "а давайте просто передадим строку в параметрах".

Бешеный синус

Функции стандартной библиотеки, такие как sin, cos, tan и другие, реализованы с акцентом на максимально возможную точность. Для этого используются сложные алгоритмы, что существенно замедляет выполнение, поэтому многие разработчики предпочитают писать свою тригонометрию, с меньшей точностью.

Абсолютная точность не всегда критична. Допустимо использовать приближённые методы, чтобы существенно ускорить вычисления — например, когда точность до четвёртого знака после запятой уже избыточна. Одним из таких приближений является разложение функции синуса в ряд Тейлора — Макларена и представить функцию в виде полинома около некоторой точки. Если ограничиться первыми несколькими членами разложения, то выражение для sin(x) будет выглядеть так:

sin(x) ≈ x - (x³)/6 + (x⁵)/120

Такие приближённые синусы применяются при симуляции дыхания, качания листьев, парящих объектов, колебаний на воде или симуляции ветра в лужах. К тому же можно делать синус еще проще на слабых устройствах.

Попытка сделать правильно и по формуле, скорее всего, провалится и будет дороже чем оригинал, потому что cpu умеет считать синус хардварно:

std::pow(phase, 3) / 6 + std::pow(phase, 5) / 120

Такой подход резко снижает число операций с плавающей точкой, особенно если избавиться от вызовов std::pow и заменить их вручную раскрытым умножением. Точность при этом теряется, особенно на больших значениях аргумента, но для небольших значений x результат получается достаточно близким к реальному и вычисляется в разы быстрее. Никто и не говорил, что листья должны описывать идеальные круги.

double x2 = phase * phase;
double x3 = x2 * phase;
double x5 = x3 * x2;
offset = phase - x3 / 6.0 + x5 / 120.0;

https://quick-bench.com/q/HTCdf5dII1Be1vvb02rv_DGrhjw

Жиза

В Sims Mobile не использовались привычные анимации для персонажей - слишком много было действий с объектами, которые надо было анимировать, но симы должны были быстро и плавно анимироваться при прогулке по дому, поднимании кружки, открывании холодильника и других действиях. Вместо этого была написана система анимации по кривым, построенным между локаторами (точками на объекте). Т.е. можно было задать точку на кружке и точку на руке, и сим плавным движением перемещал руку к кружке, а потом также плавно ко рту.

Внутри анимации каждая конечность симов — ноги, руки, голова — работала как физическая кость, управляемая по заданным кривым. Эти кривые строились из синусоидальных функций, чтобы действия выглядели реалистично. На каждый тик симуляции в коде вызывались сотни вызовов std::sin, на каждого сима, на каждую анимируемую кость. Даже на современных чипах ARM это было проблемой — что уж говорить про ведроиды среднего уровня.

Как временное решение заменили std::sin на приближение с помощью ряда Макларена. Визуально анимации остались прежними — плавными и живыми, ну как минимум продюсеры не заметили. А вот фпс вырос — на 8–10 FPS в сцене.

Через пару месяцев в проект завезли уже нормальную реализацию с табличной интерполяцией и SIMD-инструкциями, но именно этот "грязный" ряд Макларена спас не один седой волос лида прогеров.

Медленное деление

Вычисления с плавающей точкой более ресурсоёмкие, но целочисленные операции могут неожиданно оказаться значительно хуже по производительности. Особенно это касается операций деления (/) и взятия остатка (%), которые считаются одними из самых медленных арифметических операций в процессоре. Деление 32-битных целых чисел обычно занимает около 10 тактов ЦП — это примерно 2,5 наносекунды.

Если делитель известен на этапе компиляции, компилятор может выполнить оптимизацию под названием strength reduction (https://en.wikipedia.org/wiki/Strength_reduction)

В этом случае деление заменяется на более быстрые инструкции умножения и сдвига, даже для таких крупных чисел, как максимальное 32-битное. Эта замена позволяет сделать деление более дешёвым. Для того чтобы компилятор воспринял значение как известное на этапе компиляции, достаточно просто использовать const или constexpr для оптимизации. Но современные компиляторы могут делать вывод о неизменности сами при анализе времени жизни переменной (особенно это любит делать clang, из-за чего могу появляться другие баги, но это отдельная история) .

Существует и другой интересный приём: деление целых чисел через числа с плавающей запятой. Поскольку 64-битный тип double способен точно представлять любое 32-битное знаковое целое число, можно безопасно преобразовать int в double, выполнить деление с плавающей запятой и привести результат обратно к int. Это позволяет использовать блоки процессора, предназначенные для операций с double, которые выполняют деление вещественных чисел всего в пять раз быстрее, чем целочисленный ALU целых.

https://quick-bench.com/q/HhAWEpdX5L96VlVcqTb7pz18C6A

Жиза

Жизы не будет, мощности современных процов хватает, чтобы мы меньше задумывались о совсем уж таких мелочах. Но иногда все же приходится - во время портирования игры (XBlades 2) на Nintendo Switch мы столкнулись с неожиданной проблемой — у этой платформы был откровенно слабый блок ID в ALU, при большом количестве операций деления целых чисел в кадре наблюдалось падение фпс (условно падало на 1-2 кадра). Анализ снапшотов профайлера показал, что много времени уходит именно на операции целочисленного деления, которые отлично без проблем работали на пк и больших консолях, и которые активно использовались в логике расчёта всего в игре, начиная от игровых механик и заканчивая данными для шейдеров. Добавьте сюда относительно медленный проц ~1Ггц и получите неприятный бонус в виде общего замедления. Править в не стали, ибо это был миллион мест и много часов работы, изменили код только с совсем горячих функциях.

Цена промахов в кеше

Однажды мне пришлось работать вот с таким движком.

class GameObject {
    Transform* transform;       
    AIBeh* ai;             
    PhBody* physics;       
    Animation* animations;   
    // ... много еще компонентов ...
};

И такой подход был в каждом классе, вплоть до позиций объектов Point(x, y, z) и самих переменных x, y, z. Для чего так было сделано? Ну вот прихо(д/ть) архитекта. Если вы подумали про отдельные пулы памяти для компонентов, которые бы возвращали указатели на предвыделенную память - раздумайте.

Код бы просто написан так, как написан - каждый объект создавал всё нужное ему динамически, что было не нужно, не создавал, так писали сразу, видимо команда верила в обещание "zero cost everything" от комитета. Не верьте - за всё приходится платить, даже за пустые указатели.

Что творилось с кешем - отдельная песТня. Точных цифр я не помню уже, но было как-то так:

L1 cache misses: 70+%
L2 cache misses: 60+%
L3 cache misses: 40+%

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

Бенчмарк безобразия
enum class AccessPattern { Linear, Random };

struct Position {
  float x, y, z;  
};

struct Health {
  int value;  
};

struct Team {
  int id;  
};

template <AccessPattern pattern>
static void GameObjectAccessCost(benchmark::State &state) {
  const size_t objectCount = static_cast<size_t>(state.range(0));

  struct GameObject {
    Position* pos = new Position();  
    Health *health = new Health();  
    Team* team = new Team();      
  };

  std::vector<GameObject> gameObjects(objectCount);

  std::random_device rd;
  std::mt19937 gen(rd());
  std::uniform_int_distribution<int> healthDist(50, 200);
  std::uniform_real_distribution<float> posDist(-1000.0f, 1000.0f);

  for (auto &obj : gameObjects) {
    obj.pos->x = posDist(gen);
    obj.pos->y = posDist(gen);
    obj.pos->z = posDist(gen);
    obj.health->value = healthDist(gen);
    obj.team->id = healthDist(gen) % 4;
  }

  std::vector<size_t> accessOrder(objectCount);
  if constexpr (pattern == AccessPattern::Random) {
    std::iota(accessOrder.begin(), accessOrder.end(), 0);
    std::shuffle(accessOrder.begin(), accessOrder.end(), gen);
  } else {
    std::iota(accessOrder.begin(), accessOrder.end(), 0);
  }

  for (auto _ : state) {
    int totalHealth = 0;
    float totalDistance = 0.0f;

    for (size_t idx : accessOrder) {
      const auto &obj = gameObjects[idx];

      benchmark::DoNotOptimize(totalHealth += obj.health->value);
      benchmark::DoNotOptimize(
          totalDistance +=
                               std::sqrt(obj.pos->x * obj.pos->x +
                                         obj.pos->y * obj.pos->y +
                                         obj.pos->z * obj.pos->z));
    }
    benchmark::ClobberMemory();
  }
}

BENCHMARK(GameObjectAccessCost<AccessPattern::Linear>)
    ->MinTime(1)
    ->RangeMultiplier(8)
    ->Range(8 * 1024, 128 * 1024 * 1024)  
    ->Unit(benchmark::kMillisecond)
    ->Name("GameObjectAccess/Linear");

BENCHMARK(GameObjectAccessCost<AccessPattern::Random>)
    ->MinTime(1)
    ->RangeMultiplier(8)
    ->Range(8 * 1024, 128 * 1024 * 1024)
    ->Unit(benchmark::kMillisecond)
    ->Name("GameObjectAccess/Random");

Даже при последовательном доступе всё это упиралось в работу с памятью, или банально стояло в ненагруженных сценах, не в силах выжать даже 30 фпс.

Linear/8192/min_time:1.000           0.015 ms        0.015 ms        99556
Linear/32768/min_time:1.000          0.065 ms        0.065 ms        21854
Linear/262144/min_time:1.000         0.761 ms        0.762 ms         1906
Linear/2097152/min_time:1.000         9.66 ms         9.59 ms          145
Linear/16777216/min_time:1.000        77.1 ms         77.3 ms           18
Linear/134217728/min_time:1.000        608 ms          602 ms            2
Random/8192/min_time:1.000           0.018 ms        0.018 ms        74667
Random/32768/min_time:1.000          0.144 ms        0.144 ms         9956
Random/262144/min_time:1.000          2.75 ms         2.76 ms          498
Random/2097152/min_time:1.000         66.5 ms         66.2 ms           21
Random/16777216/min_time:1.000         666 ms          664 ms            2
Random/134217728/min_time:1.000       7715 ms         7688 ms            1

Если вы сделаете простую компоновку, а не через указатели, уже станет лучше. Просто из-за правильной компоновки данных, имеем лучшую локальность данных и выше скорость работы.

  struct GameObject {
    float x, y, z;  
    int health;     
    int team;       
  };
Linear/8192/min_time:1.000           0.014 ms        0.014 ms        99556
Linear/32768/min_time:1.000          0.055 ms        0.055 ms        24889
Linear/262144/min_time:1.000         0.446 ms        0.444 ms         3200
Linear/2097152/min_time:1.000         4.21 ms         4.21 ms          345
Linear/16777216/min_time:1.000        34.5 ms         34.3 ms           41
Linear/134217728/min_time:1.000        268 ms          269 ms            5
Random/8192/min_time:1.000           0.014 ms        0.014 ms        99556
Random/32768/min_time:1.000          0.055 ms        0.055 ms        25600
Random/262144/min_time:1.000         0.705 ms        0.703 ms         1867
Random/2097152/min_time:1.000         19.8 ms         19.7 ms           69
Random/16777216/min_time:1.000         221 ms          221 ms            6
Random/134217728/min_time:1.000       1910 ms         1922 ms            1
Жиза

На мои робкие, тогда еще мидловские, замечания, что не все ладно в датском королевстве, просто накидывали больше задач. Команда движка была уверена в своей непогрешимости и верности выбранного пути. Испытательный срок я почему-то не прошел и ушел работать в EA. Через год, в 2015, та студия закрылась, не факт что из-за движка, но наводит на определенные мысли.

Мораль - цени кеш, иначе кеш не оценит тебя.

Руки прочь от std::pair там, где надо выжать еще скорости

В реальности любая программа требует памяти — ваш КО. Но на скорость работы влияет также - как эта память организована, какие структуры используются, как они размещаются в памяти - особенно в системах с ограниченными ресурсами.

Произвольные типы данных (ADT, Algebraic Data Types) — одна из наиболее желанных и мощных конструкций в современных языках программирования. Они позволяют создавать сложные типы, объединяя более простые — например, через комбинации "или" (sum types, варианты) и (product types, структуры).

В plain-c единственный способ моделировать ADT — это ручное комбинирование struct и union, зачастую с явным тегом для указания активного поля. Это сложно, чревато ошибками и требует аккуратного применения. В плюсах мы можем воспользоваться std::pair, std::tuple — для составных структур, std::variant — для вариативных значений,std::optional — для представления значения, которого может не быть.

Несмотря на кажущуюся простоту и удобство, использование этих типов не "бесплатно" по производительности и времени компиляции. Особенно это касаетсяstd::function — может приводить к скрытым выделениям памяти в куче, как я показал выше,<functional>, <tuple>, <variant> — одни из самых "тяжёлых" заголовков стандартной библиотеки, содержащие десятки тысяч строк шаблонного кода, этот код не может быть закеширован и будет пересобираться в каждом юните компиляции, да - только нужные части, но файл все равно будет парситься заново.

Наивно полагать, что std::pair работает так же быстро, как и простая struct { T1 first; T2 second; }. Всё зависит от нюансов - как компилятор оптимизирует шаблоны, включены ли отладочные флаги, какая реализация стандартной библиотеки используется (libstdc++, libc++, MSVC STL), как именно создаются и копируются объекты.

https://quick-bench.com/q/46NOXqH9MFwr0FB0NcoORdClHH8

Если у вас сотни объектов, не стоит задумываться о таких вещах как std::pair, вы не увидите разницы, разве что в профайлере. Если у вас сотни тысяч объектов, которыми оперирует движок (модели, текстуры, идентификаторы) - это выливается уже в секунды, минуты и часы реального времени. И даже 10% влияют на систему сборки.

Жиза

Питерский офис EA делали также и SimCity BuildIT один из первых ситибилдеров для мобилок, сам движок и система упаковки ресурсов были написаны на C++ Marmalade (движок) + Marble(система сборки): текстуры, анимации, данные уровней — всё проходило через сложный пайплайн, в котором был шаг по объединению метаинформации по каждому ресурсу в БД.

Сборка была написано подрядчиками (не основной командой) с активным использованием современных вещейstd::pair, std::tuple и std::function для универсальности. Каждая сборка бандла ресурсов (особенно для iOS) занимала примерно 140 минут, из которых около 60+ уходило на этап "метапакеров" — простые на вид, но нагруженные шаблонными структурами процессы, вроде парсинга текстур, получения размеров, подбор параметров для атласа, добавления в систему ресурсов и т.д.

Время росло вместе с добавлением новых объектов, а когда перевалило за два часа кто-то из команды (не автор статьи, у меня тогда еще лапки не доросли) полез смотреть, почему именно метапакеры тормозят. Запустил профайлер на билдерферме и увидел очень интересную картину: море времени тратится на генерацию и копирование объектов с типами std::tuple<std::string, std::function<bool(const ResourceInfo&)>> и std::pair<std::string, std::uint32_t>. Все эти конструкции передавались по значению между функциями и копировались многократно, просто какая-то фабрика по копированию всего, что было возможно.

После избавления от всего это "шумного кода" шаг метапаковки стал работать в несколько раз быстрее, время сборки бандла вернулось к 60 минутам, а сам билд стал меньше мучать оперативную память, что особенно было тяжко для Jenkins-нод с кучей параллельных билдов. На ретро ему выдали по шапке за то, что полез не в свою систему и теперь придется её сапортить, и подарили майку с логотипом студии за сокращение времени сборки билдов. Но спокойно можно было дарить новую skoda yeti - примерно на столько влетала контора каждый месяц по аренде мощностей для дженкинса. Такие правки редко попадают в презентации или гайды — чаще в эпик фейлы и ретро разборы.

Мораль - билдферма стерпит всё, за оптимизацию времени сборки можно получить майку и по шапке.

Расплата за виртуальность

Чтобы использовать виртуальные функции эффективно, особенно в контексте производительности игр, нужно заранее понимать, где и как они будут применяться. Это может показаться сложной задачей для разработчиков, привыкших к гибкости ООП, однако при наличии чёткого дизайна игрового объекта и системного подхода (это редкость, но бывает) можно определить, где полиморфизм действительно оправдан.

Есть одна важная философия проектирования, которую необходимо принять, чтобы виртуальные функции не стали узким местом: всё поведение в игре должно быть ограниченным и предсказуемым. Это не должно восприниматься как ограничение свободы разработки — это естественные ограничения времени кадра. Это означает, что любые системы, использующие виртуальные вызовы, должны избегать бесконтрольной виртуализации функций по чиху правой пятки.

Код бенчмарка
struct NonVirtual {
    int DoWork(int x) const {
        return x * 2;
    }
};

struct VirtualBase {
    virtual int DoWork(int x) const = 0;
    virtual ~VirtualBase() = default;
};

struct VirtualDerived : VirtualBase {
    int DoWork(int x) const override {
        return x * 2;
    }
};

static void BM_NonVirtualCall(benchmark::State &state) {
    NonVirtual obj;
    int sum = 0;
    for (auto _ : state) {
        sum += obj.DoWork(42);
    }
    benchmark::DoNotOptimize(sum);
}
BENCHMARK(BM_NonVirtualCall);

static void BM_VirtualCall(benchmark::State &state) {
    std::unique_ptr<VirtualBase> obj = std::make_unique<VirtualDerived>();
    int sum = 0;
    for (auto _ : state) {
        sum += obj->DoWork(42);
    }
    benchmark::DoNotOptimize(sum);
}
BENCHMARK(BM_VirtualCall);

static void BM_FunctionCall(benchmark::State &state) {
    std::function<int(int)> func = [] (int x) { return x * 2; };
    int sum = 0;
    for (auto _ : state) {
        sum += func(42);
    }
    benchmark::DoNotOptimize(sum);
}
BENCHMARK(BM_FunctionCall);
------------------------------------------------------------
Benchmark                  Time             CPU   Iterations
------------------------------------------------------------
BM_NonVirtualCall      0.327 ns        0.297 ns   1000000000
BM_VirtualCall          1.75 ns         1.72 ns    373333333
BM_FunctionCall         1.46 ns         1.46 ns    448000000
Жиза

История не совсем про игрострой, не совсем про виртуальные вызовы, но очень рядом. В конце нулевых случилось мне участвовать в разработке системы видеонаблюдения, которая снимала точки с определенных мест фрейма и пыталась строить по ним 3д модель лица, которое проходило некоторую область. Алгоритм дернули из OpenCV и основной структурой в нем былаMatrix4x4, классическая 4x4 матрица из float. Она применялась в трансформациях, вычислениях и вообще все описание лица - это был 4к набор этих матриц. Распознавание крутилось на каких-то серверах, турникетов было достаточно, проходящих лиц тоже, так что эта структура использовалась миллионы раз в секунду, но все работало приемлемо, распознавая условно 0.5 лица в секунду с одной камеры, сжирая дофига ватт.

Время шло, придумало начальство задачку уменьшить размер хранимых точек (читай матриц), сделали ТЗ - скинули команде, но вместо того чтобы использовать инструменты профилирования или шаблонные обёртки, ООПшные на всю голову прогеры просто сделали базовый классIMatrix с виртуальной функцией GetDiff(), от которого унаследовали Matrix4x4. Т.е. сама эта функция в расчетах не участвовала.

На первый взгляд идея выглядела безобидной: виртуальный вызов который возвращал дифф изменений от базовой матрицы, что обычно составляло 1-2% и позволяло не хранить все 4к точек. Вы наверное уже представляете, что случилось когда выкатили в прод это всё? Производительность вычислительного пайплайна резко упала.

Тут уже подключили профайлер и нормальных разработчиков для фикса баги. Добавление виртуальной функции привело к появлению vtable и увеличению размера Matrix4x4 с 64 до 72 байт. Это нарушило выравнивание и разрушило кэш-локальность, поплыли ровные смещения в массивах. Матрицы больше не лежали плотно и SIMD-логика стала работать хуже. Количество кэш-промахов резко возросло, что привело к тому, что функции, которые работали с матрицами, начали тратить больше времени. Особенно сильно пострадали сервера на архитектуре IA-64, где смещения и логика специально рассчитывались по максимальную загрузку кеша и регистровую математику, и любое отклонение по выравниванию было критично.

В итоге одним днем все получили люлей, той же ночью откатили апдейт: виртуальные функции были удалены, Matrix4x4 снова стал POD-типом без vtable, и размер структуры вернулся к 64 байтам. Всю логику переписали через отдельную обёртку, которая делала это всё сама и в отдельном треде. Производительность вернулась к прежнему уровню. Почему так не сделали сразу, это уже отдельная история.

Мораль - никогда не добавляйте виртуальные функции в базовые math-структуры, особенно те, которые участвуют в tight loop'ах, SIMD-вычислениях или хранятся в рав массивах. Даже один virtual может сильно напортачить и серьёзно замедлить вычисления.

На этом, пожалуй, действительно, game over!

UPD: отдельная благодарность @Serpentine, который поредактировал и поправил ошибки в статьях.

Комментарии (43)


  1. Jijiki
    30.05.2025 00:22

    а двойной кватернион быстрее ускоренного(SIMD) перемножения матриц и ничего тут не поделать, а всё дело в том, что у мулматриц и дк количества операций, примерно по моему бенчмарку, который можно узнать через rdtsc видел даже и в 10 раз минимум, представляете как увеличивает скорость если знать как без оверкеша гонять скининг анимации через dqs, и врятли это будет Анриал мне кажется. соотв это своя физика, в паралельном процессе должна быть со всеми нужными солверами, интересно - солвер физики cli

    а еще если камера на кватернионе или дк то еще быстрее соотв.

    пользоваться виртуальными функциями с математикой почти нельзя, все виртуалки на сколько я видел по годболту у С++ превращаются в табличные штуки, когда в С будет сплошной СИМД изза математики, ну и С быстрее компилируется, придётся велосипедить, зато у С++ есть вектор и анимацию проще получается можно достигнуть, а в С пока разберешься со скелетом, пока из блендера в нужном виде отправишь скелет файликом ) пока разберешься со структурой с этими указателями ) и прочее

    поэтому квейк, дум, серии быстрее оптимальнее, может даже халфа там тоже ведь сурс. читал их подходы и в целом они годные, а если наивно делать с оверкешем, то можно захлестнуть либо памяти много, либо перенапрячь кадр, но анимация будет 100%, вот запустить сейчас Морровинд наверняка он мало кушать будет и будет работать сносно

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


  1. eao197
    30.05.2025 00:22

    Распространённый совет — предпочитать std::vector обычным C-массивам и статическим векторам

    Блин, да вы бы читать научились, раз уж приводите цитату из GSL. Там же прямое противоречие вашему утверждению про "распространенный совет -- предпочитать std::vector ... и статическим векторам".

    For a fixed-length array, use std::array, which does not degenerate to a pointer when passed to a function and does know its size. Also, like a built-in array, a stack-allocated std::array keeps its elements on the stack.

    Совет предпочитать использовать std::vector С-шным массивам, действительно, очень давний. ЕМНИП, Страуструп дает его начиная с 3-го издания своей книги "Язык программирования C++", которое вышло уже после появления и стандартизации STL. Но, во-первых, относился он в большей степени к массивам, которые меняют свой размер в динамине. И, во-вторых, с тех пор уже поколение выросло, а в STL уже почти полтора десятка лет есть std::array. Надо бы делать проправки на современные реалии.


    1. dalerank Автор
      30.05.2025 00:22

      Да, все как вы говорите, но открываешь рабочий проект, а там вектора... никто даже не смотрит в сторону std::array, pmr, boost::small_array.


      1. eao197
        30.05.2025 00:22

        Когда вектора -- это еще неплохо, бывает что и векторов нет, а люди бабахаются с голыми new, а то и malloc-ами. Только вот это же про говнокод, а не про язык. В языке-то давно средства есть, и рекомендации по их использованию.


      1. NeroMoon
        30.05.2025 00:22

        Мне кажется вы зелёное с тёплым сравниваете. В чем виноваты оптимизированные алгоритмы и контейнеры стандартной библиотеки, если их используют не по месту? Да и реализацию stl можно было бы и посмотреть, коль вставляете свои суждения о том как там всё реализовано и почему это плохо. std::variant основан на рекурсивных юнионах и аллокации памяти там нет, tuple - на рекурсивных структурах. Как следствие - динамических аллокаций в этих контейнерах не происходит. Да, есть проверки в variant при получении значения на соответствие типу, который сейчас там находится. Но чем это будет отличаться от обычного юниона с индексом?

        Наивно полагать, что std::pair работает так же быстро, как и простая struct { T1 first; T2 second; }.

        Ну почему же наивно. std::pair это и есть структура с двумя полями и набором методов. Реализация из libstdc++ ниже.

        template<typename _T1, typename _T2>
            struct pair
            : public __pair_base<_T1, _T2>
            {
              typedef _T1 first_type;    ///< The type of the `first` member
              typedef _T2 second_type;   ///< The type of the `second` member
        
              _T1 first;                 ///< The first member
              _T2 second;                ///< The second member
              ...
            };

        Мне кажется, что ошибочно делать суждения предварительно не удостоверившись в их правдивости. Большинство предположений об неоптимальности реализаций stl в статье - пустые доводы.


        1. NeroMoon
          30.05.2025 00:22

          https://quick-bench.com/q/Wcunfj0d_wvpfCxYOdC1eNAdFIQ, собственно тут это и видно. Компилятор clang, библиотека libcxx. GCC, к сожалению, провалил этот тест, возможно в новых релизах поправят


          1. dalerank Автор
            30.05.2025 00:22

            Про этот кейс я написал, что зависит от настроек и компилятора. На плоечном сдк самый свежий 16 кланг и хреновая поддержка 20 стандарта, 17 кланг завезут хорошо если через год, а то и два, судя по темпам адаптации компилятора под вендора. У меня сейчас вот как-то так, и будет так минимум еще год, потому что уже зафризили версию сдк на препроде. Что там внутри компилятора творится и как это поправить в сдк никто разбираться не будет
            https://quick-bench.com/q/M1q0ipx9v9wFlnCYMbgy0JuhP9w

            Скрытый текст


            1. Jijiki
              30.05.2025 00:22

              если она по зависимостям от фрибсд вам надо разобраться как обновиться до 14.2 версии залить туда 20 кланг и радоваться успехам ), хотя у клиента может быть другая версия, но всё равно)

              14.2 и 20 кланг это просто феерверк по производительности ) если разрабы плойки всё правильно сделали )

              хотя наверно это нереально сделать


              1. dalerank Автор
                30.05.2025 00:22

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


                1. Jijiki
                  30.05.2025 00:22

                  ну а с указателями быстрее или что тут не правильно?


                  1. dalerank Автор
                    30.05.2025 00:22

                    код не вижу, картинка очень мутная, но разница в скорости в 10х очень подозрительная, даже в 3х подозрительная, всё правильно сделали?


                    1. Jijiki
                      30.05.2025 00:22

                      не знаю правильно или нет, я просто взял и создал 2 массива по миллиону через new,

                      static void packaging_custom_game_objects(benchmark::State& state) {
                          struct GameObjectData {
                              int entity_id;
                              float velocity;
                          };
                          //GameObjectData* source, destination;
                          GameObjectData* source= new GameObjectData[1000000]; // типичный пул данных из игры
                          GameObjectData* destination= new GameObjectData[1000000];
                          for (const auto& _ : state) {
                              benchmark::DoNotOptimize(destination = source); // симулируем копирование при репликации или обновлении физики
                          }
                      }


        1. feelamee
          30.05.2025 00:22

          Мне кажется, что ошибочно делать суждения предварительно не удостоверившись в их правдивости. Большинство предположений об неоптимальности реализаций stl в статье - пустые доводы.

          Насчет производительности std::pair vs struct в рантайм сомнительно. Но вы ведь не будете спорить, что <pair>, <tuple>, <variant> могут значительно поесть время компиляции?)

          Автор неоднократно говорит что у всего есть цена - даже если в рантайме бесплатно)


  1. Tzimie
    30.05.2025 00:22

    Любопытно, а почему плавающее деление быстрее целого?


    1. Zara6502
      30.05.2025 00:22

      предположу, что на это потрачено больше транзисторов.

      Скрытый текст

      В современных процессорах вещественное деление (FP division) часто быстрее целочисленного (integer division) из-за особенностей аппаратной реализации и исторических оптимизаций. Вот основные причины:

      1. Разные алгоритмы выполнения

      Вещественное деление (FP division)

      • Использует аппаратный блок деления (FPU/ALU), оптимизированный для конвейеризации.

      • Часто реализуется через итерационные методы (например, алгоритм Ньютона-Рафсона), которые можно распараллелить.

      • Современные CPU (начиная с Intel Haswell, AMD Zen) имеют конвейеризированные FP-делители с пропускной способностью 1 операция за ~3–15 тактов (зависит от точности).

      Целочисленное деление (integer division)

      • Требует последовательной обработки (побитовые сдвиги и вычитания), которую сложнее конвейеризировать.

      • Аппаратные делители для целых чисел часто не конвейеризированы (или слабо конвейеризированы).

      • На многих CPU (например, x86) целочисленное деление выполняется за 10–40 тактов и блокирует весь конвейер.

      2. Разные стандарты точности

      • Вещественные числа (IEEE 754) имеют предсказуемую точность, что позволяет использовать приближенные вычисления с последующей коррекцией.

      • Целочисленное деление требует точного результата, что усложняет аппаратную реализацию.

      3. Исторические причины

      • FP-деление критично для научных вычислений, поэтому инженеры сильнее оптимизировали FPU (Floating-Point Unit).

      • Целочисленное деление встречается реже (чаще используют умножение и битовые операции), поэтому его ускоряли меньше.

      4. SIMD-ускорение

      • Современные CPU (AVX, SSE) поддерживают SIMD-деление для вещественных чисел (например, mm256div_ps), которое выполняется параллельно для нескольких чисел.

      • Для целочисленного деления SIMD-инструкций нет (кроме редких случаев, вроде ARM NEON).

      Примеры задержек (latency) на Intel Skylake:

      Операция Тип Задержка (тактов) DIVSD (FP64) Вещественное 13–14 DIVSS (FP32) Вещественное 11 DIV (32-bit) Целое 23–26 DIV (64-bit) Целое 32–95

      Источник: Agner Fog's Instruction Tables

      Когда целочисленное деление может быть быстрее?

      1. Если делитель — степень двойки (заменяется на сдвиг).

      2. При использовании умножения на обратное (компиляторы иногда заменяют x / 10 на x * 0xCCCD >> 19).

      3. На GPU (где целочисленные операции часто оптимизированы лучше).

      Вывод

      Вещественное деление быстрее в современных CPU из-за:

      1. Конвейеризированной аппаратной реализации,

      2. Использования приближенных методов,

      3. Большего внимания к оптимизации FPU.

      Если вам нужно быстрое целочисленное деление, лучше использовать битовые трюки или умножение на обратное.


      1. dalerank Автор
        30.05.2025 00:22

        так исторически сложилось, блоки ALU ответственные за ID/FD развивались отдельно и неравномерно, впрочем вам развернуто ответили в предыдущем комментарии


    1. Jijiki
      30.05.2025 00:22

      и не дабл а весь мир можно в флоат и вы ничего не потеряете, дабл в экзотических случаях, и на всякий случай 16 знаков после запятой в пи это очень много для точности достаточно 5-6-7


      1. lgorSL
        30.05.2025 00:22

        Нельзя, местами даже double не хватает.

        Например, радиус Земли около 6 тысяч километров = 6 * 10^6 и семь знаков точности от float значат, что координаты дадут точность плюс-минус метр.

        И это я говорю про хранение, а если делать какую-то логику вычислений, то может потребоваться точность на порядки выше.

        Допустим, автосимулятор - размер трассы 10км и делается 1000 шагов физики в секунду.
        10км = 10_000_000 мм - а вот и предел точности - миллиметр. И если за шаг физики автомобиль смещается на расстояние порядка миллиметра - это фиаско, смещаться он будет куда попало из-за погрешности округления. При 1000 шагов в секунду 1 мм за шаг это 1м/с = 3.6 км/ч - вполне наблюдаемая ненулевая скорость, на которой машину будет колбасить.

        Eщё учтём, что если используется интегрирование Верле, когда новая позиция рассчитывается на основе текущей и предыдущей, то ошибка в позиции будет отклонять заодно и скорость. Типичный порядок скорости для гонок пусть будет 30 метров в секунду и 30 мм за шаг. За каждый шаг физики из-за округления будет вылазить погрешность в 3%, и таких шагов - тысяча в секунду. И даже если взять 100 шагов в секунду вместо тысячи, что откровенно мало, погрешность в 0.3% за каждый шаг это ужасно.

        Заметьте - это будет, даже если вычисления идеально точные и округление один раз при сохранении во float.

        Только проблема в том, что если в промежуточных вычислениях тоже float - погрешность будет ощутимо расти на каждой промежуточной операции.

        Я в своём физическом движке всерьёз задумываюсь про добавление DoubleDouble - потому что иногда даже Double не хватает. Например, можно тензор инерции считать не относительно центра масс, а относительно центра координат. И это математически очень просто и красиво получается - тензоры можно перевести в глобальные координаты, там поскладывать и потом перевести обратно в локальную форму. Причём сложение инерции в глобальной СО - это просто поэлеметное сложение 16 чисел.

        Но есть нюанс - инерция это m * r^2, и вот это r^2 может заметно повлиять точность, если тело далеко от центра координат. Да, можно в локальной системе отсчёта такое делать и будет точно, я просто привожу пример когда математически красивый и простой код упирается в точность double и начинаются хаки вокруг.


        1. dalerank Автор
          30.05.2025 00:22

          А с сумматор-компенсатором точности по Kahan не сталкивались?


          1. lgorSL
            30.05.2025 00:22

            А кажется это оно и есть.
            Когда в одном double хранится какое-то число и в ещё одном - поправка к нему.

            И кроме суммирования можно при желании ещё остальных арифметических операций надобавлять. Я вот такое нашёл, но не очень понятно что там с лицензией: https://github.com/Hellblazer/Utils/blob/master/src/main/java/com/hellblazer/utils/math/DoubleDouble.java


        1. Jijiki
          30.05.2025 00:22

          ну про пи может я погарячился, но флоата хватит, в игре конкретно, можно настроить по 1 кускам в нужных масштабах вокруг нуля так? и уходить в ++(заполнять четверть с плюсами и там делать локацию, там такие размеры с таким масштабом как указан далее, что пока мы дойдём с таким масштабом до кривоты контента должно хватить), поидее я смотрел должно получиться при условии, что масштаб подобран типо 1 к 400(кусок 40 тыщ вертексов запакованых, тоесть умножаем на 3(в апи есть репитер надо смотреть его описание с ним всё проще становится), его скривляем+применяем сглаживание, делаем хейтмапу, и вот он кусочек условно острова) больше и не нужно поидее

          вытаскиваем такие 8 кусков вокруг того куска где игрок, кладём туман на границах, а система зданий и деревьев должны быть в пространстве типо ноды, чтобы можно было скрывать то что не видим, и по маске кидать в дымку, чот такое, но с кусками получается, на С++ не знаю, но на С у меня такие 9 кусков бодро грузятся и ниче не отьедает лишнего

          последний замер у меня камера на кватернионе и повороты, так вот, 1 к 400, там есть места где нету отклонений, да и зачем рисовать бескрайний мир, понятно же что это эмуляция бескрайнего мира а в загрузке просто новая сцена или участок, вспомните почему в варкрафте 2 таких континента, я выше описал почему +-, заступаем 3 кусочками на 3 четверти и пошли ++ по ширине и вниз

          редактор под такое это генератор с возможностью правки полигонов и сохранение кусочков с нужным масштабом, тоесть в блендере это болезненно

          и там моделька маленькая выходит в запаковке из-за репитера, а мы скалируем кусочки

          размер куска будь здоров, а их тут 9 тоесть слева еще куски


        1. Jijiki
          30.05.2025 00:22

          тогда получается у вас и все настройки, в шейдере по отрисовке мира тоже в даблах? я пока всё рисую флоатом, библиотеку математики тоже написал всё на флоатах, двойной кватернион тоже флоат, перспектива - флоат, орто там я чуть подправил под себя, чтобы было всё где надо, и на С у меня всё работает как часы без усердия со скруглениями, если я начну сейчас вообще все расчеты округлять и шаманить с этим то конечно не запуститься, а так в целом без скругления вся система пашед на 9 кусках хотябы, ну типо как не земля бескрайняя, а как остров или стартовая локация вроде всё работает, ну пока, а вообще согласен с вами если это ударит на солверы(скелетная анимация частный случай так как есть солверы кручений и кинематика базовая по-сути это вход в физику) если её от костей проигрывать в рантайме да, но цена такой костной физики, сейчас скажу какая. на 1 модельку в распакованном виде без скелетки та которая у синк матрикса, это 85900 вершин(в распакованом виде = 2 мегабайта, 1 действия в памяти), альтернатива физически костями их проигрывать, что тоже накладывает ограничения, например те какие вы указали

          вы как раз указали это, в блендере все матрицы - солверы поидее в локальном пространстве считаются, типо гоняем инверсией туда-сюда, да и блендер хранит матрицу-базис, матрицу-локаль, и матрицу-мировую

          Скрытый текст
          import bpy
          
          # Path to output your data
          output_file = "mesh_data.txt"
          
          # Get your object
          ob = bpy.context.object
          
          # Save the current frame to restore later
          original_frame = bpy.context.scene.frame_current
          
          # Define the frame range
          frame_start = bpy.context.scene.frame_start
          frame_end = bpy.context.scene.frame_end
          
          with open(output_file, "w") as f:
              for frame in range(frame_start, frame_end + 1):
                  depsgraph = bpy.context.evaluated_depsgraph_get()
                  bpy.context.scene.frame_set(frame)
                  bpy.context.view_layer.update()
          
                  # Get evaluated mesh
                  eval_obj = ob.evaluated_get(depsgraph)
                  mesh_eval = eval_obj.to_mesh()
          
                  # Get the world matrix
                  world_matrix = eval_obj.matrix_local
          
                  # Transform vertices into world space
                  mesh_eval.transform(world_matrix)
          
                  # Write frame header
                  f.write(f"frame {frame}\n")
                  
                  # For each polygon, write the vertices' positions in order
                  for poly in mesh_eval.polygons:
                      for v_idx in poly.vertices:
                          v = mesh_eval.vertices[v_idx]
                          f.write(f"{v.co.x} {v.co.y} {v.co.z}\n")
                  
                  # Separator for next frame
                  #f.write("\n")
                  # Clear the mesh
                  eval_obj.to_mesh_clear()
          
          # Restore original frame
          bpy.context.scene.frame_set(original_frame)
          bpy.context.view_layer.update()


          1. lgorSL
            30.05.2025 00:22

            В шейдерах можно и флоаты.
            Допустим, камера рядом с объектом и объект с камерой где-то далеко от центра координат.
            Тогда в матрицах и объекта и камеры появляюстя большие числа. Но поскольку камера и объект рядом, произведение этих матриц даст матрицу, в которой больших чисел нет (MV) и которую можно безболезненно передавать в шейдер (или вообще сразу MVP передать).

            Т.е., произведение M*V лучше с большой точностью считать, а дальше можно в шейдере.

            P.S. Я сильно подозреваю что двойной кватернион это то же самое что и мотор в геометрической алгебре. Он же описывает одновременно и позицию и поворот? Вот с ними при уделении от центра координат у меня тоже не очень хорошо получается, потому что поворот описывается косинусами-синусами в интервале [-1, +1], а линейные координаты растут.

            Вот тут в тесте можно примерные числа глянуть (причём мне тут точности double недостаточно оказывается)
            https://github.com/Kright/ScalaGameMath/blob/master/pga3dPhysics/src/test/scala/com/github/kright/pga3dphysics/Pga3dInertiaLocalTest.scala#L26

            Я даже задумываюсь над тем, чтобы мотор обратно разложить на перемещение + вращение и в таком виде везде хранить


            1. Jijiki
              30.05.2025 00:22

              я проверил у себя, у меня ни одного косинуса-синуса, на дукате, их там избежать можно вроде(сужу пока по дукату и по тесту я сравнивал мультик матрицы на СИМДЕ транслейт*поворот с дукатом они индентичны по итогу, но дукат быстрее на порядок)

              пейпер надо открывать вникать

              я видел ролики есть сравнение с ротором, но я не вникал в это потомучто не нужно покачто


              1. Jijiki
                30.05.2025 00:22

                Скрытый текст
                0.23127034 -0.022801302 -0.026058631
                 0.019543974 0.110749185 -0.016286645
                 -0.17589577 0.0032573289 0.1465798
                -----------------------------------------#то что выше другое
                 real: 0.26726124 0.5345225 0.8017837 0
                 dual: 12.026755 -8.017838 1.3363063 -45.43441
                 time equation for operation 3928
                
                -0.857143 0.28571433 0.42857152 0
                 0.28571433 -0.42857152 0.85714304 0
                 0.42857152 0.85714304 0.28571433 0
                 10.000001 30.000006 90.000015 1
                 0.26726124 0.5345225
                
                180.00006
                
                time equation for operation 14100
                
                -0.8571427 0.2857151 0.42857084 0
                 0.28571343 -0.42857128 0.8571431 0
                 0.42857197 0.8571425 0.28571433 0
                 9.999999 29.999996 89.99999 0.9999999

                снизу кватернион на нём у меня камера покачто по ней сужу что работает, а вот тут по углам приходилось по оси брать угол указывать и указывать ось, так что они равны, но видимо по ходу счета есть нюансы

                всё по факту(ничего не знаю)) равны и rdtsc зафиксировал скорость


            1. Jijiki
              30.05.2025 00:22

              если судить по дукату то можно избавиться от косинуса синуса, и вообще не использовать в углах их, тогда нету гимбол лока и прочее, не знаю у меня работает, беру ось текущую результирующую и угол и камера летит в любом месте, естессно я про повороты покачто, камера же от мышки крутится соотв есть direction куда смотрим, и оси куда крутим

              тоесть ничего не проверяем просто даём камере смотреть и крутиться по оси, я так могу описать это

              Скрытый текст
              void rotateBy(Camera1 *camera,float deg)
              {
                  Quaternion q;
                  q=QAngleAxisdV3(deg,(vec3){0,1,0});
                  mat4 rot;
                  rot=ToMatrixQQ1(q);
                  vec3 tN = getNormalView(camera);
                  vec4 rotViewVec = Mulmv4(rot, (vec4){tN.x, tN.y, tN.z, 0});
              
                  camera->vp = Addv3(camera->cameraPos, (vec3){rotViewVec.x, rotViewVec.y, rotViewVec.z});
              }
              
              void rotateUD(Camera1 *camera,float deg)
              {
                  vec3 viewVector = getNormalView(camera);
                  vec3 viewVectorNoY = Normalizev3((vec3){viewVector.x, 0.0f, viewVector.z});
              
                  float currentAngleDegrees = Anglev3(viewVectorNoY,viewVector);
                  if (viewVector.y < 0.0f) {
                      currentAngleDegrees = -currentAngleDegrees;
                  }
              
                  float newAngleDegrees = currentAngleDegrees + deg;
                  if (newAngleDegrees > -85.0f && newAngleDegrees < 85.0f)
                  {
                      vec3 rotationAxis = Crossv3(getNormalView(camera), camera->cameraUp);
                      rotationAxis = Normalizev3(rotationAxis);
              
                      Quaternion q;
                      q = QAngleAxisdV3(deg, rotationAxis);
                      mat4 rot;
                      rot = ToMatrixQQ1(q);
                      vec3 tN = getNormalView(camera);
                      vec4 rotViewVec = Mulmv4(rot, (vec4){tN.x, tN.y, tN.z, 0});
              
                      camera->vp = Addv3(camera->cameraPos, (vec3){rotViewVec.x, rotViewVec.y, rotViewVec.z});
                  }
              }
              ...
              void mouse_callback(GLFWwindow *window, double xposIn, double yposIn)
              {
                  if (mouseHandleInCenter)
                  {
                      vec2 curMPos = WindowCenterPos(&win);
                      vec2 delta = Subv2(curMPos, (vec2){xposIn, yposIn});
                      rotateBy(&camera, -delta.x);
                      rotateUD(&camera, -delta.y);
                      glfwSetCursorPos(window, win.wi / 2, win.he / 2);
                  }
              }


            1. Jijiki
              30.05.2025 00:22

              что-то типо такого у вас?

              видите не правильные деформации? и повороты

              вы написали последовательность M*V, а должно быть вроде

              p * v * m * vec4(apos,1.0);

              так же имеет значение row-major или collumn-major матрица, от этого будет перспектива и lookat соотвественно и отрисовка

              соотв солвер(кость и её вьюшка, в зависимости от выбранной конфигурации мат аппарата должны быть в пространстве мировых координат) будет из-за этого соотвествующий, потомучто блендер например хранит матрицы

              [

              a b c t
              a b c t
              a b c t
              0 0 0 1
              ]

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

              вобщем там из-за конфигурации мат аппарата еще нюансы

              и вы сказали от -1 до 1 это надо точно знать какие координаты, какое пространство, потомучто мировые координаты это ну 4 четвертные

              вот на скриншоте моделька в пространстве камеры а не в мировых координатах как я понимаю, и проблема с кручением


              1. Jijiki
                30.05.2025 00:22

                для глубины картины, напишу наблюдения, в блендере я как-то хотел отрисовать анимированный инстанс от 1 модельки в лоб, и увидел ваш еффект все синстансенные анимации были привязаны на Origin(0 0 0) тоесть там чото не хватает типо какого-то домножения, которое бы поставило бы точку относительности. Например (T*R*S) даже если вы не используете чтото есть момент что всё промножить следует, Scale(1,1,1), Rotate надо проставить 0 по 1,1,1 и типо того.

                тоесть матрицу модели всё равно можно простроить. хотя с единичной матрицей тоже рисует не знаю


  1. Solarian_Guide
    30.05.2025 00:22

    std::vector<int> createVector(int size) {
        std::vector<int> result;
        result.resize(size);
        for (int ii = 0; ii < size; ++ii) {
            result.push_back(ii * ii);
        }
        return result;
    }

    Тут явно опечатка. Вместо result.resize(size) должно быть result.reserve(size). Я не знаю проверяли ли вы ассемблер на resize или все таки reserve, хотелось бы обновленный ассемблер если все таки на resize :)


    1. dalerank Автор
      30.05.2025 00:22

      Спасибо, поправил. Опечатка там конечно reserve, выше в тексте ссылка godbolt, листинг оттуда


    1. Jijiki
      30.05.2025 00:22

      а если в этом конкретно случае оставить так, то

      //result.resize(size);//потомучто уже ресайз
      //int count=0;//откуда дописываем если ресайз не 
      //уничтожит то что было )если так то нужна 
      //проверка перед ресайзом чтобы новый ресайз был больше предыдущего
      //тогда
      int count = -1;
      bool check = false;
      if(size > result.size()){
        count = result.size();
        result.resize(size);  
        check=false;
      }
      else if (size < result.size() ) 
        check = true;
      
      for(int i=0;i<size;i++){
        if(check)result.push_back(i*i);
        else result[count++]=i*i;
      }


  1. Panzerschrek
    30.05.2025 00:22

    Хранить данные в std::array может быть иногда не так эффективно, как в std::vector. Ведь под std::array обычно выделяют памяти с запасом - под максимально-возможное количество элементов. Это может сказаться в худшую сторону на потреблении памяти. К тому же увеличивается вероятность кеш-промахов, ибо будут читаться данные из незаполненной части массива, которые по факту не нужны.


  1. Jijiki
    30.05.2025 00:22

    на крайний случай конкретно в вашем примере про GameObject

    если оставить указатели то, то что в указателях инициализируется из файла(если из файла это история про сериализацию обьектов она не связана никак с инициализацией во время отрисовки на сколько я понимаю - это вроде менеджмент ресурсов),

    второй конструктор если принципиально хотите поменять

    можно всё закинуть в массив

    struct GameObject1{ // тогда это будет DOD
      float specAttribs[6];// первое целое число ID )))
    };
    //или
    struct GameObject2{
      vec3 idHealthTeam;
      vec3 pos;
      
    };
    
    std::vector<GameObject1> objs;
    for(auto& a: objs)
      std::println("{} {} {} {} {} {}",
      a[0],a[1],a[2],
      a[3],a[4],a[5]);


  1. QtRoS
    30.05.2025 00:22

    Великолепная серия статей! Продолжайте, пожалуйста :)

    Делал игры разве что в качестве курсовых, а на C++ не пишу более 10 лет, но все равно крайне интересно читается. Ведь большинство практик и выводов неплохо переносятся на другие языки и технологии!


  1. The_Netos
    30.05.2025 00:22

    ... а std::function, оказывается, иногда аллоцирует в куче.

    Для интересующихся - как раз есть std::move_only_function (начиная с C++23), который умеет хранить объект callable в себе:

    Implementations may store a callable object of small size within the std::move_only_function object.

    Но весь вопрос как обычно сводится к "а какой у нас размер лямбды с контекстом?".

    Порой помогает простое человеческое спасибо вписывание захватываемых объектов руками:

    [&a, &b, ...](...){}

    Таким образом мы логично не засасываем в захват лямбды всё подряд из контекста над ней.

    Ещё помогает завернуть одну лямбду по ссылке в контекст другой, но там уже надо осторожно, в малых дозах.

    За статью плюс.


    1. slonopotamus
      30.05.2025 00:22

      Таким образом мы логично не засасываем в захват лямбды всё подряд из контекста над ней

      Зачем? [&]/[=] захватит только то что реально используется в теле лямбды. В явном виде имеет смысл перечислять только если у вас отличаются правила кэпчура для разных переменных.


      1. The_Netos
        30.05.2025 00:22

        Чтож, за неявный захват я и вправду запамятовал, извиняюсь.


  1. slonopotamus
    30.05.2025 00:22

    , , — одни из самых "тяжёлых" заголовков стандартной библиотеки, содержащие десятки тысяч строк шаблонного кода, этот код не может быть закеширован и будет пересобираться в каждом юните компиляции, да - только нужные части, но файл все равно будет парситься заново.

    Precompiled header?


  1. dersoverflow
    30.05.2025 00:22

    скажу сразу: статья полезная! ее лучше читать, чем не читать :) но вот в деталях...

    прежде всего, ОЧЕНЬ много опечаток!! может кто-то считает, что для программиста это нормально, но... 100% такое же будет и в коде ;((

    нарушает локальность данных в кэше, первые несколько обращений к данным вектора (обычно 10-15) пока внутренние структуры CPU не адаптируются к новому паттерну, проходят "вхолодную"

    это как ТАКОЕ возможно?!
    что, первые несколько обращений CPU не загружает данные в кэш?!?! жесть.

    Однако за убогость и всеядность приходится платить производительностью

    вот тут не поспоришь!
    на коленке сделанная int_map сразу работает в несколько раз быстрее std::unordered_map<int, int>. это конечно позор.
    https://www.linkedin.com/pulse/do-you-still-trust-stl-sergey-derevyago-bzenf

    Кто же знал, что атомарные инкременты у shared_ptr не бесплатные?

    а знали не только лишь все: https://ders.by/cpp/norefs/norefs.html#4.1


    1. dalerank Автор
      30.05.2025 00:22

      У вас хороший слог, почему не писать на хабре (только без капса плиз) ? Я бы почитал с удовольствием. З.ы. Ссылка выдаёт 403.


    1. Jijiki
      30.05.2025 00:22

      тут суть в том что либо надо в векторе хранить указатель или писать на С тогда по маллоку будет работать железобетонно, и в одном файле и в разных, а С++ оброс требованиями, чтобы вектор с переменными заработал надо постараться, фикситься раздельными файлами, тоесть увеличиваем время компиляции, писать код явно, и придётся указатели использовать, тогда всё будет на плечах разраба, а не вектора.

      тоесть борьба с таким вектором не имеет никаких побед, надо просто вызвать память и работать с ней, нет смысла увиливать вектор с переменными фигово фурычик, взял выделил память и следишь за ней вручную тогда, как быыыыыыыыы, берем кусок кода валве и там ну почти всё будет на указателях, автор этой статьи избегает упоминания работы с памятью через указатели и долго ждать вобщем

      вобщем избегать указатель это я не знаю, переменные не вариант вобщем, кеши-меши, когда нужна память, пользователь не будет 100 раз запускать игру, всё должно быть выделено, должны быть ексепшены я не знаю, но работать с переменными не варик, вот с памятью через указатель проще работать сразу всё встаёт на свои места, а так числа гонять, да я скажу вдогонку числа и я могу гонять, а дальше примера чисел с кешем и таким вектором дело не дошло покачто и что я наблюдаю у себя от такого вектора это отказ при запуске из-за как раз миссов. вобщем долго еще будем смотреть наверно на вектор с переменными


      1. Jijiki
        30.05.2025 00:22

        например однородность в менеджере

        std::vector<vec3*>* mesh;

        или однородность тривиальных обьектов

        std::vector<GameObject*> mesh;

        получили адрес вектор адресов, тогда будет явное обьявление хендлера.


    1. Serpentine
      30.05.2025 00:22

      может кто-то считает, что для программиста это нормально, но... 100% такое же будет и в коде

      Опечатки — дело житейское, тем более в русских текстах, когда люди 99,9% рабочего времени пишут и, возможно, говорят на английском. Не только лишь все могут писать чисто и публиковаться без корректора, но их статьи от этого хуже не становятся.

      И напоследок цитата одного «широко известного в узких кругах» программиста:

      «We didn’t have spell checkers in our editors back then, and I always had poor spelling. The word "collumn" appears in the source code dozens of times. After I released the source code, one of the emails that stands out in memory read: It’s "COLUMN", you dumb FUCK!».