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

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

Итак, 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 лет — приходится решать до сих пор костылями. В общем надеюсь, что эта статья поможет вам повысить отзывчивость своего приложения, а так же добавит вам довольных пользователей.
Поделиться с друзьями
-->

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


  1. workless
    01.09.2016 12:56
    +10

    В ответ на коментарии «Delphi жив?» можно давать ссылку на эту статью.
    p.s. Да, дочитал что компилируется через lazarus


    1. Darthman
      01.09.2016 15:06
      +1

      С чего бы ему помирать-то? :) Мы игру тоже на делфи делаем и двиг на делфи пишем. пфф. предрассудки всё это.


      1. workless
        01.09.2016 15:20

        С вероятность 146% у новостей про Delphi будет комментарий «А ей еще пользуются?\А она жива?»

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

        А теперь C# очень распространен и на нем такого полно.
        Очередное подтверждение что не в инструменте проблемы, а в руках.


        1. LynXzp
          03.09.2016 12:32

          Есть три типа людей: зануды говорящие правду, и те кто преувеличивает. (про проценты)


  1. OlegKozlov
    01.09.2016 13:11
    +2

    Спасибо! Утащил к себе в игру самое простое решение с SetMaximumFrameLatency(1).

    А ещё обработку ввода (опрос геймпада и всё такое) утаскивают в отдельный поток. Но пока у меня тупо опрос раз в кадр.


    1. XProger
      01.09.2016 15:09

      У меня SetMaximumFrameLatency практически не давало ощутимого результата, лучше всего через query


    1. MrShoor
      01.09.2016 16:51

      А ещё обработку ввода (опрос геймпада и всё такое) утаскивают в отдельный поток. Но пока у меня тупо опрос раз в кадр.
      Да, это будет полезно. Но только в случае с решением на Event/Текстуре, потому как после того, как кадр нарисован, нам нужно как можно быстрее загрузить видеокарту снова, и это сэкономит время на обработку ввода. Ведь свежие данные уже подготовил отдельный поток. В остальных случаях это мало поможет, если ботлнек GPU.


    1. Mercury13
      01.09.2016 20:29
      +1

      Чувствую себя креветкой понять всё это, но явно нужное дело.


  1. VioletGiraffe
    01.09.2016 15:06

    Отличная статья, даже возникло на миг ощущение, что DirexctX — это не так уж сложно :)



  1. Darthman
    01.09.2016 15:17

    Проблема эта в дх11, или в предыдущих тоже есть и решается подобным же методом?


    1. MrShoor
      01.09.2016 16:36

      Это проблема во всех графических api. И да, решается подобным же методом.


      1. Darthman
        01.09.2016 16:45

        Только вот SetMaximumFrameLatency доступен только начиная с IDirect3D9Ex. Тоесть в ХР работать не будет такой подход.


        1. MrShoor
          01.09.2016 16:54

          Я об этом написал в статье. SetMaximumFrameLatency даже не на всех Win7 заведется, а только с определенными установленными апдейтами.


          1. Darthman
            01.09.2016 16:59

            Я к тому, что работать будет только на интерфейсе IDirect3D9Ex, а на обнычном IDirect3D9 уже не сработает.


            1. MrShoor
              01.09.2016 17:07
              +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.


              1. Darthman
                01.09.2016 17:14
                +2

                IDirect3D9Ex тоже только с висты.

                Спасибо огромное за исследование, кстати.


  1. Habra-Mikhail
    01.09.2016 16:22
    +1

    Опробовал. Результаты:
    No lag reducing: 223-225 fps
    SetMaximumFrameLatency: 224-226 fps
    Query event: 218-220 fps
    GenerateMips: 215-217 fps
    P.S замеры проводились в течении минуты. В таблице выше указаны Минимум и максимум fps


    1. MrShoor
      01.09.2016 16:26
      +3

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


    1. Habra-Mikhail
      01.09.2016 16:29

      Да вижу что делается. Это поможет. Кстати, если видеокарта обгоняет, то лагов не будет точно :D


  1. HOMPAIN
    01.09.2016 16:26

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


    1. MrShoor
      01.09.2016 16:30

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

      Ну например, 30 FPS — это игра настолько тормозит, что инпут лаг уже не важен? А при 60 FPS задержка на рекацию пользовательского ввода в 50 миллисекунд (вместо 17) — это не проблема?
      Также у вас тут решается не проблема инпут лага, а проблема неправильного построенного цикла отрисовки.
      Пример не покажите, как делать правильно?


      1. HOMPAIN
        01.09.2016 16:46

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

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


        1. MrShoor
          01.09.2016 17:02

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


          1. HOMPAIN
            01.09.2016 17:20

            >Во-первых, даже при 60FPS формирование кадра на GPU может занимать значительно меньше 16мс.
            Это не имеет значения, так как всё равно будет ожидание vsync, GPU оставшееся время будет просто отдыхать и вы не увидите кадра раньше чем 16мс после начала отрисовки.
            Либо в вашем случии поскольку цикл отрисовки не привязан к vsync, у вас GPU будет молотить избыточно кадры, которых вы даже не увидите и инпут лаг будет скакать в зависимости от того, куда как по времени совпали рендер и vsync.

            >Пока CPU формирует кадр — GPU может его уже рисовать.
            Обычно пока CPU формирует текущий кадр, в это время GPU рисует предыдущий кадр, поэтому время складывается. Вот как раз организация параллельного рендера текущего кадра без простоев на GPU и CPU достаточно интересная задача и про неё было бы интересно почитать.


  1. HOMPAIN
    01.09.2016 17:19

    dell. не попал веткой, сори


    1. MrShoor
      01.09.2016 17:26

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


      1. HOMPAIN
        01.09.2016 17:38

        Понимаю. На этой картинке у GPU нет простоев.

        Если вы захотите сделать что-то сложнее демки с кубиками, вам нужно будет считать куллинг, филику, логику. И со своим подходом вы полюбому упрётесь в то, что проц ещё кулинг текущей группы объектов не досчитал, а GPU уже нарисовал всё что было и ждёт. Или на оборот, GPU делает долгий пост процессинг, а вы его сидите и ждёте на CPU и не начинаете считать следующий кадр.


        1. MrShoor
          01.09.2016 18:03

          а GPU уже нарисовал всё что было и ждёт
          Тогда все замечательно. Проверка одного евента пракически никак не замедлит работу.
          GPU делает долгий пост процессинг, а вы его сидите и ждёте на CPU и не начинаете считать следующий кадр.
          Считать следующий кадр никто не мешает. Сначала считаем, потом ждем. Еще раз, статья про рендеринг, и про то, что критичные к вводу данные (типа положения камеры, возможно положения игроков) нужно засылать на рендеринг как можно позже. Нет никакого смысла накидывать 3 кадра на GPU, потому что спустя 3 кадра вы все так же будете ждать, но уже в Present, в довесок имея Input Lag размером в 4 кадра.
          Я надеюсь вы видите, где именно Input lag на картинке выше?
          На всякий случай вот скриншоты с крайзисом и uningine:
          Заголовок спойлера
          image
          image


  1. andreishe
    02.09.2016 02:44
    +1

    А еще бывают телевизоры с «улучшайзерами» картинки, которые легко добавят задержку в 2-3 кадра и вы с этим сделать не сможете ну просто ничего.


    1. qw1
      03.09.2016 18:01

      Как разработчик игры — ничего, а как владелец телевизора — выставить определённому HDMI-входу «игровой режим», или тип устройства — ПК


  1. Newbilius
    02.09.2016 10:14

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

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


  1. NElias
    06.09.2016 13:16

    Интересно, эта проблема решена в популярных движках?


    1. MrShoor
      07.09.2016 02:35
      +1

      В CryEngine есть флажок CV_r_minimizeLatency, который поидее делает фактически SetMaximumFrameLatency.
      Кроме того их техника Coverage Buffer использует буфер глубины с предыдущего кадра, что аналогично синхронизации через текстуру с генерированием мипов. Как в остальных движках — не могу сказать.