Pull to refresh

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

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

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

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

Articles