Input lag во время рендеринга и как его побеждать

    Привет всем. Многие из вас знакомы с лагом ввода. Это бывает, когда вас в очередной раз убивают в компьютерной игре, и вы кричите: «Ну я же нажал блок/атаку/уворот». Ну а затем джойстик летит в стену. Знакомо? Происходит это потому, что между нажатием клавиш и появлением результата на экране проходит значительное время. Фактически, когда вы смотрите в экран — вы видите прошлое состояние, которое может абсолютно не отражать действительность.

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

    Итак, Input lag в любой игре складывается из:

    1. Задержки на контроллере
    2. Сетевого лага (если это онлайн игра)
    3. Лага рендеринга.

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

    CPU + GPU


    Современные GPU — устройства максимально асинхронные. CPU отдает команды видеодрайверу, и идет заниматься своими делами. Драйвер накапливает команды в пачки, и пачками отправляет на видеокарту. Видеокарта рисует, а CPU в это время занимается своими делами. Максимальный FPS, который вы можете получить в этой системе ограничен одним из условий:

    1. CPU не успевает отдавать команды видеокарте, потому что видеокарта очень быстро рисует. И нафига вы покупали такую мощную видеокарту?

    2. Видеокарта не успевает рисовать то, что дает ей CPU. Теперь CPU халявит…

    Для того, чтобы посмотреть, как красиво в паре работает CPU и GPU — есть различные профайлеры. Мы воспользуемся GPUView, который идет в составе Windows Performance Toolkit.

    Лог от GPUView может выглядеть как-то так:



    Вертикальные синие линии — это VSync. Наваленные горы кубиков — это горы пакетов, которые отправятся на видеокарту, когда та освободится. Штрихованный кубик — это пакет, содержащий переключение буферов. Иными словами — конец кадра. Любой кубик можно выбрать, и видеть, как он постепенно опускается в стопке, и отправляется на видеокарту. Видите на скриншоте кубик с желтой обводкой? Он обрабатывался аж на протяжении 3-х vsync-ов. А целый кадр занимает около 4-х VSync-ов (судя по расстоянию между разными штрихованными кубиками). Между двумя горами пакетов от разных кадров есть маленький зазор. Это то время, пока GPU отдыхал. Этот зазор маленький, и оптимизация на стороне CPU не даст большого выйгрыша.

    Но бывают зазоры большие:



    Это пример рендера из World of Warcraft. Расстояния между пакетами в очереди просто огромные. Более мощная видеокарта не даст прироста ни одного FPS. Зато если оптимизировать рендер на стороне CPU, то можно получить более чем двукратный прирост FPS на данном GPU.

    Чуть более подробно можно почитать тут, а мы пойдем дальше.

    Так где же лаг?


    Так уж сложилось, что разрыв в производительности между Hi-End и Low-End видеокартами поистине огромен. Поэтому у вас обязательно будут возникать обе ситуации. Но самая грустная ситуация — это когда GPU не справляется. Выглядеть это начинает вот так:



    Обратите внимание, сколько времени заняла обработка одного пакета. Кадр занимает 4 VSync-а, а обработка пакета занимает в 4 раза дольше! DirectX (OpenGL ведет себя так же) накапливает данных аж на 3 кадра. Но ведь когда мы кладем в очередь свежий кадр — все предыдущие кадры для нас уже не актуальны, а видеокарта по прежнему будет тратить время на отрисовку. Поэтому наше действие появится на экране спустя аж 3 кадра. Давайте посмотрим, что мы можем сделать.

    1. Честное решение. IDXGIDevice1::SetMaximumFrameLatency(1)


    Я честно, не представляю зачем копить данных на 3 кадра в буфере. Но MS видимо поняла ошибку, и начиная с DX10.1 у нас появилась возможность задать это количество кадров через специальный метод IDXGIDevice1::SetMaximumFrameLatency. Давайте посмотрим, как нам это поможет:



    Ну что же. Стало значительно лучше. Но по прежнему не идеально, т.к. все равно ждем 2 кадра. Еще один недостаток решения — то что оно работает только для DirectX.

    2. Трюк с ID3D11Query


    Идея заключается в том, что в конце кадра мы устанавливаем D3D11_QUERY_EVENT. В начале следующего кадра — ждем, постоянно проверяя событие, и если оно прошло, то только тогда начинаем отдавать команды на отрисовку, и с наисвежайшими Input данными.



    Картина практически идеальная, не находите? Ожидание я реализовал вот так:

    procedure TfrmMain.SyncQueryWaitEvent;
    var qDesc: TD3D11_QueryDesc;
        hRes: HRESULT;
        qResult: BOOL;
    begin
      if FSyncQuery = nil then //когда первый раз приходим сюда - просто создаем евент, а не ждем.
      begin
        qDesc.MiscFlags := 0;
        qDesc.Query := D3D11_QUERY_EVENT;
        Check3DError(FRawDevice.CreateQuery(qDesc, FSyncQuery));
      end
      else
      begin
        repeat
          hRes := FRawDeviceContext.GetData(FSyncQuery, @qResult, SizeOf(qResult), 0);
          case hRes of
            S_OK: ;
            S_FALSE: qResult := False;
          else
            Check3DError(hRes);
          end;
        until qResult; //просто крутим цикл, пока евент не обработается
      end;
    end; 
    

    Установка эвента тривиальна:

    procedure TfrmMain.SyncQuerySetEvent;
    begin
      if Assigned(FSyncQuery) then
        FRawDeviceContext._End(FSyncQuery);
    end;
    

    Ну и в сам рендер добавляем вначале ожидание. Затем перед самой отрисовкой собираем свежие Input данные, а перед самым Present-ом устанавливаем евент:

      if FCtx.Bind then
      try
        case WaitMethod of //ждем евента
          1: SyncQueryWaitEvent;
          2: SyncTexWaitEvent;
        end;
        FCtx.States.DepthTest := True;
    
        FFrame.FrameRect := RectI(0, 0, FCtx.WindowSize.x, FCtx.WindowSize.y);
        FFrame.Select();
        FFrame.Clear(0, Vec(0.0,0.2,0.4,0));
        FFrame.ClearDS(FCtx.Projection.DepthRange.y);
    
        ProcessInputMessages; //собираем свежие Input данные
        FShader.Select;
        FShader.SetAttributes(FBuffer, nil, FInstances);
        FShader.SetUniform('CycleCount', tbCycle.Position*1.0);
        for i := 0 to FInstances.Vertices.VerticesCount - 1 do
          FShader.Draw(ptTriangles, cmBack, False, 1, 0, -1, 0, i);
    
        FFrame.BlitToWindow(0);
    
        case WaitMethod of //устанавливаем евент
          1: SyncQuerySetEvent;
          2: SyncTexSetEvent;
        end;
        FRawSwapChain.Present(0,0);
      finally
        FCtx.Unbind;
      end;
    

    Недостаток костыля метода — работает только с DirectX. Но можно дождаться синхронизации другим оригинальным способом.

    3. Воркэраунд через текстуру


    Вот что мы делаем. У нас есть механизмы прочитать данные из видеоресурсов. Если мы заставим видеокарту что-то нарисовать, а потом попытаемся забрать, то произойдет автоматическая синхронизация между GPU-CPU. Мы не сможем забрать данные раньше, чем они будут нарисованы. Поэтому вместо установки евента я предлагаю генерить мипы на видеокарте для текстуры 2*2, а вместо ожидания евента — забирать данные из этой текстуры в системную память. В результате подход выглядит так:



    Вот так мы ожидаем евент:

    procedure TfrmMain.SyncTexWaitEvent;
    var SrcSubRes, DstSubRes: LongWord;
        TexDesc: TD3D11_Texture2DDesc;
        ViewDesc: TD3D11_ShaderResourceViewDesc;
        Mapped: TD3D11_MappedSubresource;
    begin
      if FSyncTex = nil then
      begin
        TexDesc.Width  := 2;
        TexDesc.Height := 2;
        TexDesc.MipLevels := 2;
        TexDesc.ArraySize := 1;
        TexDesc.Format := TDXGI_Format.DXGI_FORMAT_R8G8B8A8_UNORM;
        TexDesc.SampleDesc.Count := 1;
        TexDesc.SampleDesc.Quality := 0;
        TexDesc.Usage := TD3D11_Usage.D3D11_USAGE_DEFAULT;
        TexDesc.BindFlags := DWord(D3D11_BIND_SHADER_RESOURCE) or DWord(D3D11_BIND_RENDER_TARGET);
        TexDesc.CPUAccessFlags := 0;
        TexDesc.MiscFlags := DWord(D3D11_RESOURCE_MISC_GENERATE_MIPS);
        Check3DError(FRawDevice.CreateTexture2D(TexDesc, nil, FSyncTex));
    
        TexDesc.Width  := 1;
        TexDesc.Height := 1;
        TexDesc.MipLevels := 1;
        TexDesc.ArraySize := 1;
        TexDesc.Format := TDXGI_Format.DXGI_FORMAT_R8G8B8A8_UNORM;
        TexDesc.SampleDesc.Count := 1;
        TexDesc.SampleDesc.Quality := 0;
        TexDesc.Usage := TD3D11_Usage.D3D11_USAGE_STAGING;
        TexDesc.BindFlags := 0;
        TexDesc.CPUAccessFlags := DWord(D3D11_CPU_ACCESS_READ);
        TexDesc.MiscFlags := 0;
        Check3DError(FRawDevice.CreateTexture2D(TexDesc, nil, FSyncStaging));
    
        ViewDesc.Format := TDXGI_Format.DXGI_FORMAT_R8G8B8A8_UNORM;
        ViewDesc.ViewDimension := TD3D11_SRVDimension.D3D10_1_SRV_DIMENSION_TEXTURE2D;
        ViewDesc.Texture2D.MipLevels := 2;
        ViewDesc.Texture2D.MostDetailedMip := 0;
        Check3DError(FRawDevice.CreateShaderResourceView(FSyncTex, @ViewDesc, FSyncView));
      end
      else
      begin
        SrcSubRes := D3D11CalcSubresource(1, 0, 1);
        DstSubRes := D3D11CalcSubresource(0, 0, 1);
        FRawDeviceContext.CopySubresourceRegion(FSyncStaging, DstSubRes, 0, 0, 0, FSyncTex, SrcSubRes, nil);
        Check3DError(FRawDeviceContext.Map(FSyncStaging, DstSubRes, TD3D11_Map.D3D11_MAP_READ, 0, Mapped));
        FRawDeviceContext.Unmap(FSyncStaging, DstSubRes);
      end;
    end;  
    

    а вот так его устанавливаем:

    procedure TfrmMain.SyncTexSetEvent;
    begin
      if Assigned(FSyncView) then
        FRawDeviceContext.GenerateMips(FSyncView);
    end;
    

    В остальном подход полностью аналогичен предыдущему. Преимущество: работает не только на DirectX но и на OpenGL. Недостаток — маааленький оверхед на генерацию текстуры и передачу данных назад + потенциально потраченное время на «пробуждение» потока шедулером операционной системы.

    Про попробовать


    Конечно я тут растекался по дереву… но насколько проблема серьезная? Как пощупать это? Я написал специальную демонстрационную программу (требует DirectX11).

    Скачать *.exe можно здесь. Для тех, кто боится качать билды неизвестного производителя — исходный код lazarus проекта здесь (также потребуется моя библиотека фреймворк AvalancheProject, которая находится вот тут)

    Программа представляет собой такое окно:



    Тут рисуется 40*40*40=64000 (кстати каждый кубик — отдельный дравколл). GPU workload трекбар дает нагрузку на GPU (с помощью бесполезного цикла в вершинном шейдере). Просто опускаете с помощью этого трекбара фпс до низкого уровня, скажем 10-20, а потом пробуете правой кнопкой мыши крутить кубики, и переключать методы уменьшения Input лага с помощью радиобаттонов.

    Вы только оцените какая огромная разница в скорости отклика. C Query Event комфортно крутить кубик даже при 20 фпс.

    В заключение


    Я честно говоря был удивлен, когда увидел, что мало кто борется с этой проблемой. Даже крупные ААА проекты допускают такие ужасные инпут лаги. Так же меня удивляет, что новые графические API выходят один за одним, а проблему, которой явно больше 10 лет — приходится решать до сих пор костылями. В общем надеюсь, что эта статья поможет вам повысить отзывчивость своего приложения, а так же добавит вам довольных пользователей.
    Поделиться публикацией
    Комментарии 37
      +10
      В ответ на коментарии «Delphi жив?» можно давать ссылку на эту статью.
      p.s. Да, дочитал что компилируется через lazarus
        +1
        С чего бы ему помирать-то? :) Мы игру тоже на делфи делаем и двиг на делфи пишем. пфф. предрассудки всё это.
          0
          С вероятность 146% у новостей про Delphi будет комментарий «А ей еще пользуются?\А она жива?»

          Сам начинал и программировал 5 лет на ней, потом на C# перешел.
          Когда D массово использовали — у людей был негатив от кода начинающих, из-за низкого порога вхождения. Например вся логика в в OnClick\GodClass-ы.

          А теперь C# очень распространен и на нем такого полно.
          Очередное подтверждение что не в инструменте проблемы, а в руках.
            0
            Есть три типа людей: зануды говорящие правду, и те кто преувеличивает. (про проценты)
        +2
        Спасибо! Утащил к себе в игру самое простое решение с SetMaximumFrameLatency(1).

        А ещё обработку ввода (опрос геймпада и всё такое) утаскивают в отдельный поток. Но пока у меня тупо опрос раз в кадр.
          0
          У меня SetMaximumFrameLatency практически не давало ощутимого результата, лучше всего через query
            0
            А ещё обработку ввода (опрос геймпада и всё такое) утаскивают в отдельный поток. Но пока у меня тупо опрос раз в кадр.
            Да, это будет полезно. Но только в случае с решением на Event/Текстуре, потому как после того, как кадр нарисован, нам нужно как можно быстрее загрузить видеокарту снова, и это сэкономит время на обработку ввода. Ведь свежие данные уже подготовил отдельный поток. В остальных случаях это мало поможет, если ботлнек GPU.
              +1
              Чувствую себя креветкой понять всё это, но явно нужное дело.
              0
              Отличная статья, даже возникло на миг ощущение, что DirexctX — это не так уж сложно :)
                0
                  0
                  Проблема эта в дх11, или в предыдущих тоже есть и решается подобным же методом?
                    0
                    Это проблема во всех графических api. И да, решается подобным же методом.
                      0
                      Только вот SetMaximumFrameLatency доступен только начиная с IDirect3D9Ex. Тоесть в ХР работать не будет такой подход.
                        0
                        Я об этом написал в статье. SetMaximumFrameLatency даже не на всех Win7 заведется, а только с определенными установленными апдейтами.
                          0
                          Я к тому, что работать будет только на интерфейсе IDirect3D9Ex, а на обнычном IDirect3D9 уже не сработает.
                            +1
                            А я к тому, что на ID3D10Device тоже может не сработать, потому как требует IDXGIDevice1 интерфейс, который как сказано тут:
                            https://msdn.microsoft.com/en-us/library/windows/desktop/ff471331(v=vs.85).aspx
                            This interface is not supported by DXGI 1.0, which shipped in Windows Vista and Windows Server 2008. DXGI 1.1 support is required, which is available on Windows 7, Windows Server 2008 R2, and as an update to Windows Vista with Service Pack 2 (SP2) (KB 971644) and Windows Server 2008 (KB 971512).

                            Ну и самой статье я писал: «и начиная с DX10.1 у нас появилась возможность задать это количество кадров через специальный метод IDXGIDevice1::SetMaximumFrameLatency.»
                            p.s. В комментарии выше про Win7 выше опечатался. Имел ввиду Win Vista.
                              +2
                              IDirect3D9Ex тоже только с висты.

                              Спасибо огромное за исследование, кстати.
                    +1
                    Опробовал. Результаты:
                    No lag reducing: 223-225 fps
                    SetMaximumFrameLatency: 224-226 fps
                    Query event: 218-220 fps
                    GenerateMips: 215-217 fps
                    P.S замеры проводились в течении минуты. В таблице выше указаны Минимум и максимум fps
                      +3
                      Вы не правильно проводили замеры. Мы меряем не FPS. Поэтому FPS у вас практически не отличается. В статье я подробно описал в чем проблема, а так же описал, что нужно делать с программой.
                        0
                        Да вижу что делается. Это поможет. Кстати, если видеокарта обгоняет, то лагов не будет точно :D
                        0
                        Вы рассмотрели самый не интересный случай. Когда игра тормозит настолько инпут лаг уже не важен. Также у вас тут решается не проблема инпут лага, а проблема неправильного построенного цикла отрисовки.
                          0
                          Вы рассмотрели самый не интересный случай. Когда игра тормозит настолько инпут лаг уже не важен

                          Ну например, 30 FPS — это игра настолько тормозит, что инпут лаг уже не важен? А при 60 FPS задержка на рекацию пользовательского ввода в 50 миллисекунд (вместо 17) — это не проблема?
                          Также у вас тут решается не проблема инпут лага, а проблема неправильного построенного цикла отрисовки.
                          Пример не покажите, как делать правильно?
                            0
                            просто при нормальных фпс 30 -60 возникают другие проблемы. Тут уже нужно стараться считывать состояние контролера и задействовать его максимально близко по времени к vsync'у текущего кадра. Поскольку даже если сам процесс рендеринга нормальный и игра выдаёт 60 фпс, то вы получите при нормальной загрузке задержку 33 мс (16=1000/60мс построение кадра на CPU + 16мс рендер на GPU).

                            >Пример не покажите, как делать правильно?
                            Не понял вопроса, что делать правильно? Ваши решения для организации цикла рендеринга вполне приемлемы, но как я уже сказал выше, тут нет борьбы с инпут лагом, а идёт решение другой проблемы.
                              0
                              Тут уже нужно стараться считывать состояние контролера и задействовать его максимально близко по времени к vsync'у текущего кадра.
                              В статье именно это и делается.
                              Поскольку даже если сам процесс рендеринга нормальный и игра выдаёт 60 фпс, то вы получите при нормальной загрузке задержку 33 мс (16=1000/60мс построение кадра на CPU + 16мс рендер на GPU).
                              С самого начала статьи я рассказываю, и подробно на графиках показываю, как именно происходит рендеринг. Вы считаете совершенно не правильно. Во-первых, даже при 60FPS формирование кадра на GPU может занимать значительно меньше 16мс. Во-вторых, CPU и GPU работают параллельно. Пока CPU формирует кадр — GPU может его уже рисовать. Они работают одновременно. И по результатам GPUView это отлично видно. Нельзя просто складывать время CPU и GPU.
                              тут нет борьбы с инпут лагом, а идёт решение другой проблемы.
                              Судя по предыдущему комментарию — я полагаю, что вы не поняли статьи.
                                0
                                >Во-первых, даже при 60FPS формирование кадра на GPU может занимать значительно меньше 16мс.
                                Это не имеет значения, так как всё равно будет ожидание vsync, GPU оставшееся время будет просто отдыхать и вы не увидите кадра раньше чем 16мс после начала отрисовки.
                                Либо в вашем случии поскольку цикл отрисовки не привязан к vsync, у вас GPU будет молотить избыточно кадры, которых вы даже не увидите и инпут лаг будет скакать в зависимости от того, куда как по времени совпали рендер и vsync.

                                >Пока CPU формирует кадр — GPU может его уже рисовать.
                                Обычно пока CPU формирует текущий кадр, в это время GPU рисует предыдущий кадр, поэтому время складывается. Вот как раз организация параллельного рендера текущего кадра без простоев на GPU и CPU достаточно интересная задача и про неё было бы интересно почитать.
                          0
                          dell. не попал веткой, сори
                            0
                            Вот как раз организация параллельного рендера текущего кадра без простоев на GPU и CPU достаточно интересная задача и про неё было бы интересно почитать.
                            В статье много интересных картинок из GPUView. Скажите честно, вы понимаете что на них вообще нарисовано? Если да, то покажите мне простои GPU на вот этом изображении:
                            Скрытый текст
                            image
                              0
                              Понимаю. На этой картинке у GPU нет простоев.

                              Если вы захотите сделать что-то сложнее демки с кубиками, вам нужно будет считать куллинг, филику, логику. И со своим подходом вы полюбому упрётесь в то, что проц ещё кулинг текущей группы объектов не досчитал, а GPU уже нарисовал всё что было и ждёт. Или на оборот, GPU делает долгий пост процессинг, а вы его сидите и ждёте на CPU и не начинаете считать следующий кадр.
                                0
                                а GPU уже нарисовал всё что было и ждёт
                                Тогда все замечательно. Проверка одного евента пракически никак не замедлит работу.
                                GPU делает долгий пост процессинг, а вы его сидите и ждёте на CPU и не начинаете считать следующий кадр.
                                Считать следующий кадр никто не мешает. Сначала считаем, потом ждем. Еще раз, статья про рендеринг, и про то, что критичные к вводу данные (типа положения камеры, возможно положения игроков) нужно засылать на рендеринг как можно позже. Нет никакого смысла накидывать 3 кадра на GPU, потому что спустя 3 кадра вы все так же будете ждать, но уже в Present, в довесок имея Input Lag размером в 4 кадра.
                                Я надеюсь вы видите, где именно Input lag на картинке выше?
                                На всякий случай вот скриншоты с крайзисом и uningine:
                                Заголовок спойлера
                                image
                                image
                                на которых видны те же проблемы.
                            +1
                            А еще бывают телевизоры с «улучшайзерами» картинки, которые легко добавят задержку в 2-3 кадра и вы с этим сделать не сможете ну просто ничего.
                              0
                              Как разработчик игры — ничего, а как владелец телевизора — выставить определённому HDMI-входу «игровой режим», или тип устройства — ПК
                              0
                              то бывает, когда вас в очередной раз убивают в компьютерной игре, и вы кричите: «Ну я же нажал блок/атаку/уворот». Ну а затем джойстик летит в стену.

                              Это уже скорее неудачный выбор автором игры слишком жестких игровых таймингов, где нажатия на плюс-минус пары кадров влияет на исход действия. Так сказать, чересчур хардкорная механика.
                                0
                                Интересно, эта проблема решена в популярных движках?
                                  +1
                                  В CryEngine есть флажок CV_r_minimizeLatency, который поидее делает фактически SetMaximumFrameLatency.
                                  Кроме того их техника Coverage Buffer использует буфер глубины с предыдущего кадра, что аналогично синхронизации через текстуру с генерированием мипов. Как в остальных движках — не могу сказать.
                                  0
                                  Я бы не советовал называть статью «Input lag», поскольку такое название вводит в заблуждение. «Input» это система ввода — джойстики, мыши, клавиатуры, и т.д. Соответственно, ожидается что «Input lag» — это проблема подсистемы ввода.
                                  Статья же о том, что существует очередь рендера кадров, и о том, что её можно уменьшить до 1 кадра вместо 3-х по умолчанию. Безусловно, когда рендер не справляется и очередь вырастает, кадры будут отображены с задержкой (в т.ч. будут задержаны кадры с реакцией пользователя). Но это Render. Это не Input.

                                  И если соблюдать точность — это не «Lag». Тут игра смысла в английском. Lag — задержка, запаздывание, отставание (между двумя событиями) происходящая незапланированно. Проблема обозначенная в статье называется Frame Latency, что отражено в соотв. названии функции. Казалось бы, пустая придирка? Но разница будет понятна когда вы попытаетесь искать…
                                    0
                                    Я бы не советовал называть статью «Input lag», поскольку такое название вводит в заблуждение. «Input» это система ввода — джойстики, мыши, клавиатуры, и т.д. Соответственно, ожидается что «Input lag» — это проблема подсистемы ввода.
                                    Я с радостью пофилософствую с вами на эту тему, но сразу после того, как вы исправите статью на вики: en.wikipedia.org/wiki/Input_lag
                                    Идет?
                                    0
                                    Еще, Frame Latency зависит таких параметров как полноэкранное приложение или нет (отключен ли DWM), включен ли в оконном режиме Aero на 7-ке, от флип-модели, VSync, и будет полезна IDXGISwapChain::GetFrameStatistics.

                                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                    Самое читаемое