Вместе с релизом в 1999 году исходного кода Quake был выпущен файл readme.txt, написанный Джоном Кармаком. Особый интерес в нём вызвало одно предложение.

Также для сборки файлов на языке ассемблера требуется Masm. Можно изменить
#define и выполнять сборку только с кодом на C, но версии с программным рендерингом
при этом потеряют почти половину скорости.

Quake был вдвое быстрее благодаря написанному вручную ассемблерному коду? Давайте разберёмся, так ли это, как это работает, и какими были самые важные оптимизации.

Определяем базовую частоту кадров на моей машине

Прежде, чем приниматься за исходники, мне нужно было определить, какой была частота кадров релизной версии winquake.exe на моём Pentium MMX 233MHz.

C:\winquake> winquake.exe -wavonly +d_subdiv16 0 +timedemo demo1

Я отключил d_subdiv16 , потому что она не имела реализации на C (из-за чего сравнение C и ASM было бы невозможным). Это заставит движок откатиться к D_DrawSpans8 вместо D_DrawSpans16 (перспективный сэмплинг каждых 8 пикселей, а не 16). -wav — это самый быстрый бэкенд аудио (также называющися опцией fastvid в wq.bat).

Базовый winquake в среднем выполнял timedemo demo1 с частотой 42,3fps.

Сборка с ASM

Повторив действия из статьи «Компилируем Quake, как будто на дворе 1997 год» [перевод на Хабре], я собрал winquake.exe в режиме релиза с оптимизациями ASM. Я очень надеялся, что компилятор VC++6 не сильно улучшился[1] по сравнению с VC++4 (версией, которую id Software использовала для выпуска winquake в 1997 году).

C:\winquake> WinQuake_ASM.exe -wavonly +d_subdiv16 0 +timedemo demo1

Я с облегчением убедился, что WinQuake_ASM.exe работает почти с такой же частотой, 42,2 fps. Я был на верном пути.

Сборка без ASM

Как написал Джон Кармак, для сборки без ASM достаточно в quakedef.h присвоить id386 значение 0.

Это поломало компоновщик, потому что в то время проект VC6 должен был запускаться на CPU Intel.

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

Quake без оптимизаций ASM

После успешной сборки релиза настало время запуска WinQuake_No_ASM.exe.

C:\winquake> WinQuake_No_ASM.exe -wavonly +d_subdiv16 0 +timedemo demo1

Вот дела! Игра действительно работала на 22,7fps вместо 42,2fps! Как и предупреждал Джон Кармак, без оптимизаций Майкла Абраша частота кадров Quake упала вдвое!

Изучаем ассемблерный код

В Quake очень много ассемблерного кода. Суммарно grep нашёл 63 функции, разбросанные по 21 файлу.

$ find . -name "*.s" | wc -l
21
$ find . -name "*.s" -exec grep -H ".globl C(" {} \;
./server/worlda.s:.globl C(SV_HullPointContents)
./server/math.s:.globl C(BoxOnPlaneSide)
./client/d_copy.s:.globl C(VGA_UpdatePlanarScreen)
./client/d_copy.s:.globl C(VGA_UpdateLinearScreen)
./client/d_draw.s:.globl C(D_DrawSpans8)
./client/d_draw.s:.globl C(D_DrawZSpans)
./client/surf16.s:.globl C(R_Surf16Start)
./client/surf16.s:.globl C(R_DrawSurfaceBlock16)
./client/surf16.s:.globl C(R_Surf16End)
./client/surf16.s:.globl C(R_Surf16Patch)
./client/d_scana.s:.globl C(D_DrawTurbulent8Span)
./client/r_drawa.s:.globl C(R_ClipEdge)
./client/d_parta.s:.globl C(D_DrawParticle)
./client/d_polysa.s:.globl C(D_PolysetCalcGradients)
./client/d_polysa.s:.globl C(D_PolysetRecursiveTriangle)
./client/d_polysa.s:.globl C(D_PolysetAff8Start)
./client/d_polysa.s:.globl C(D_PolysetDrawSpans8)
./client/d_polysa.s:.globl C(D_PolysetAff8End)
./client/d_polysa.s:.globl C(D_Aff8Patch)
./client/d_polysa.s:.globl C(D_PolysetDraw)
./client/d_polysa.s:.globl C(D_PolysetScanLeftEdge)
./client/d_polysa.s:.globl C(D_PolysetDrawFinalVerts)
./client/d_polysa.s:.globl C(D_DrawNonSubdiv)
./client/sys_wina.s:.globl C(MaskExceptions)
./client/sys_wina.s:.globl C(unmaskexceptions)
./client/sys_wina.s:.globl C(Sys_LowFPPrecision)
./client/sys_wina.s:.globl C(Sys_HighFPPrecision)
./client/sys_wina.s:.globl C(Sys_PushFPCW_SetHigh)
./client/sys_wina.s:.globl C(Sys_PopFPCW)
./client/sys_wina.s:.globl C(Sys_SetFPCW)
./client/math.s:.globl C(Invert24To16)
./client/math.s:.globl C(TransformVector)
./client/math.s:.globl C(BoxOnPlaneSide)
./client/d_draw16.s:.globl C(D_DrawSpans16)
./client/r_aclipa.s:.globl C(R_Alias_clip_bottom)
./client/r_aclipa.s:.globl C(R_Alias_clip_top)
./client/r_aclipa.s:.globl C(R_Alias_clip_right)
./client/r_aclipa.s:.globl C(R_Alias_clip_left)
./client/snd_mixa.s:.globl C(SND_PaintChannelFrom8)
./client/snd_mixa.s:.globl C(Snd_WriteLinearBlastStereo16)
./client/r_aliasa.s:.globl C(R_AliasTransformAndProjectFinalVerts)
./client/d_spr8.s:.globl C(D_SpriteDrawSpans)
./client/r_edgea.s:.globl C(R_EdgeCodeStart)
./client/r_edgea.s:.globl C(R_InsertNewEdges)
./client/r_edgea.s:.globl C(R_RemoveEdges)
./client/r_edgea.s:.globl C(R_StepActiveU)
./client/r_edgea.s:.globl C(R_GenerateSpans)
./client/r_edgea.s:.globl C(R_EdgeCodeEnd)
./client/r_edgea.s:.globl C(R_SurfacePatch)
./client/surf8.s:.globl C(R_Surf8Start)
./client/surf8.s:.globl C(R_DrawSurfaceBlock8_mip0)
./client/surf8.s:.globl C(R_DrawSurfaceBlock8_mip1)
./client/surf8.s:.globl C(R_DrawSurfaceBlock8_mip2)
./client/surf8.s:.globl C(R_DrawSurfaceBlock8_mip3)
./client/surf8.s:.globl C(R_Surf8End)
./client/surf8.s:.globl C(R_Surf8Patch)
./client/sys_dosa.s:.globl C(MaskExceptions)
./client/sys_dosa.s:.globl C(unmaskexceptions)
./client/sys_dosa.s:.globl C(Sys_LowFPPrecision)
./client/sys_dosa.s:.globl C(Sys_HighFPPrecision)
./client/sys_dosa.s:.globl C(Sys_PushFPCW_SetHigh)
./client/sys_dosa.s:.globl C(Sys_PopFPCW)
./client/sys_dosa.s:.globl C(Sys_SetFPCW)

Для сравнения: в DOOM было всего два файла .asm и три функции для ускорения движка.

Многие из этих функций можно исключить из нашего анализа. Некоторые из них выполняют действия, невозможные на C, например, задают точность математического сопроцессора (FPU) или устанавливают значение счётчика высокой точности (   ). Часть из них не используется (  ). Некоторые дублируются (одна для сервера, другая для клиента). Некоторые оптимизации используют самомодифицирующийся код, требующий маркеров, чтобы область .text могла быть обновлена с r до rw и пропатчена (  ).

КАРТИНКА

Остаётся 32 метода, связанных с математикой, звуком, рендерингом и отрисовкой. Различие между R_ и D_ становится понятным не сразу. Код с R_ отвечает за то, что отрисовывать. Код с D_ отвечает за то, как это отрисовывать.

//******* ОТРИСОВКА *******
./client/d_spr8.s:.globl C(D_SpriteDrawSpans)            // Отрисовка спрайта, смотрящего на камеру
./client/d_draw.s:.globl C(D_DrawSpans8)                 // Отрисовка мира с персп. коррекцией по 8 пикселей
./client/d_draw.s:.globl C(D_DrawZSpans)                 // Запись мира в Z-буфер
./client/d_draw16.s:.globl C(D_DrawSpans16)              // Отрисовка мира с персп. коррекцией по 16 пикселей
./client/d_scana.s:.globl C(D_DrawTurbulent8Span)
./client/d_parta.s:.globl C(D_DrawParticle)
./client/d_polysa.s:.globl C(D_PolysetCalcGradients)     // Все polysets предназначены
./client/d_polysa.s:.globl C(D_PolysetRecursiveTriangle) // для рендеринга моделей alias. 
./client/d_polysa.s:.globl C(D_PolysetDrawSpans8)        
./client/d_polysa.s:.globl C(D_PolysetDraw)
./client/d_polysa.s:.globl C(D_PolysetScanLeftEdge)
./client/d_polysa.s:.globl C(D_PolysetDrawFinalVerts)
./client/d_polysa.s:.globl C(D_DrawNonSubdiv)            // Тоже отрисовка моделей
//******* МАТЕМАТИКА *******
./client/math.s:.globl C(TransformVector)
./client/math.s:.globl C(BoxOnPlaneSide)
./server/worlda.s:.globl C(SV_HullPointContents)
//******* ЗВУК *******
./client/snd_mixa.s:.globl C(SND_PaintChannelFrom8)
./client/snd_mixa.s:.globl C(Snd_WriteLinearBlastStereo16)
//******* РЕНДЕРИНГ *******
./client/r_drawa.s:.globl C(R_ClipEdge)
./client/r_aclipa.s:.globl C(R_Alias_clip_bottom)
./client/r_aclipa.s:.globl C(R_Alias_clip_top)
./client/r_aclipa.s:.globl C(R_Alias_clip_right)
./client/r_aclipa.s:.globl C(R_Alias_clip_left)
./client/r_aliasa.s:.globl C(R_AliasTransformAndProjectFinalVerts)
./client/r_edgea.s:.globl C(R_InsertNewEdges)
./client/r_edgea.s:.globl C(R_RemoveEdges)
./client/r_edgea.s:.globl C(R_StepActiveU)
./client/r_edgea.s:.globl C(R_GenerateSpans)
./client/surf8.s:.globl C(R_DrawSurfaceBlock8_mip0)       // Генерация кэширования поверхностей
./client/surf8.s:.globl C(R_DrawSurfaceBlock8_mip1)       // Генерация кэширования поверхностей
./client/surf8.s:.globl C(R_DrawSurfaceBlock8_mip2)       // Генерация кэширования поверхностей
./client/surf8.s:.globl C(R_DrawSurfaceBlock8_mip3)       // Генерация кэширования поверхностей

Прежде, чем двигаться дальше, нужно оценить вклад каждой функции в повышение частоты кадров с 22,7fps до 42,2fps. Чтобы разобраться в этом, я модифицировал движок для включения по одной ASM-функции за раз, после чего многократно запускал одно и то же timedemo.

Имя функции

Прирост частоты кадров (fps)

D_DrawSpans8

12,6

R_DrawSurfaceBlock8_mip*

 4,2

D_Polyset*

 2,2

D_DrawZSpans

 0,2

D_DrawParticle

 0,1

Остальные

 0,6

Всего: 19,5

Неудивительно, что самые важные оптимизации находятся в низкоуровневых подпрограммах отрисовки: D_DrawSpans8 рендерит стены, R_DrawSurfaceBlock8X комбинирует текстуру и карту освещения в поверхность, а D_Polyset* отрисовывает модели. Остальные едва влияют на мой (довольно приблизительный) бенчмарк.

Функции Polyset* переплетены таким образом, что их нельзя по отдельности переключать между C/ASM. Они все одновременно должны быть или на C, или на ASM.

Обнаруженные мной ASM-оптимизации часто включали в себя развёртывание циклов, самомодифицирующийся код, избегание ошибочного предсказания, применение конвейера Pentium FPU для сокрытия задержек и создание «пересечения», при котором конвейеры Pentium U/V и конвейеры FPU выполняют команды параллельно.

Ниже более подробно описаны некоторые из функций. Если вы хотите ещё глубже залезть в эту кроличью нору, то рекомендую прочитать Optimizations for Intel's 32-Bit Processors (Feb 94)[2], где исчерпывающе описан Pentium. Учтите, что это снотворное посильнее, чем 20 г мелатонина.

TransformVector

Функция TransformVector — хороший способ знакомства с FPU процессора P5. Это простое перемножение матриц-векторов, активно используемое для проецирования в экранном пространстве полигонов мира, полигонов моделей/alias и спрайтов.

typedef float vec_t;
typedef vec_t vec3_t[3];

vec3_t  vpn, vright, vup;  

#define DotProduct(x,y) (x[0]*y[0]+x[1]*y[1]+x[2]*y[2])

void TransformVector (vec3_t in, vec3_t out) {
  out[0] = DotProduct(in,vright);
  out[1] = DotProduct(in,vup);
  out[2] = DotProduct(in,vpn);    
}

Давайте взглянем на ассемблерный код. Сначала я приведу asm Майкла Абраша в нотации AT&T[3], а ниже — код, сгенерированный VC6 в нотации Intel и декомпилированный Ninja.

// Версия Абраша

.globl C(TransformVector)

movl  in(%esp),%eax
movl  out(%esp),%edx

flds  (%eax)    
fmuls C(vright) 
flds  (%eax)    
fmuls C(vup)    
flds  (%eax)    
fmuls C(vpn)    

flds  4(%eax)   
fmuls C(vright)+4 
flds  4(%eax)   
fmuls C(vup)+4  
flds  4(%eax)   
fmuls C(vpn)+4  
fxch  %st(2)    

faddp %st(0),%st(5) 
faddp %st(0),%st(3) 
faddp %st(0),%st(1) 

flds  8(%eax)   
fmuls C(vright)+8 
flds  8(%eax)   
fmuls C(vup)+8    
flds  8(%eax)   
fmuls C(vpn)+8    
fxch  %st(2)    

faddp %st(0),%st(5) 
faddp %st(0),%st(3) 
faddp %st(0),%st(1) 

fstps 8(%edx)   
fstps 4(%edx)   
fstps (%edx)     

ret
// Вывод VC6

float* TransformVector(float* a1, float* a2)

mov     eax, dword [esp+0x4 {a1}]
mov     ecx, dword [esp+0x8 {a2}]

fld     st0, dword [0x2970]  // vright.x
fmul    st0, dword [eax]
fld     st0, dword [0x2978]  // vright.y
fmul    st0, dword [eax+0x8] 
faddp   st1, st0
fld     st0, dword [0x2974]  // vright.z
fmul    st0, dword [eax+0x4]
faddp   st1, st0
fstp    dword [ecx], st0

fld     st0, dword [0x2974]  // vup.x
fmul    st0, dword [eax]
fld     st0, dword [0x297c]  // vup.y
fmul    st0, dword [eax+0x8]
faddp   st1, st0
fld     st0, dword [0x2978]  // vup.z
fmul    st0, dword [eax+0x4]
faddp   st1, st0
fstp    dword [ecx+0x4], st0

fld     st0, dword [0x296c]  // vpn.x
fmul    st0, dword [eax]
fld     st0, dword [0x2974]  // vpn.y
fmul    st0, dword [eax+0x8]
faddp   st1, st0
fld     st0, dword [0x2970]  // vpn.z
fmul    st0, dword [eax+0x4]
faddp   st1, st0
fstp    dword [ecx+0x8], st0






retn     {__return_addr}

Вывод VC6: FPU используется, как FPU 487, а именно со стеком без конвейера, в котором операнды берутся с вершины стека, а результаты записываются тоже в вершину (если вы знаете, как работает JVM, то могу сказать, что принцип тот же). Команды находятся в том же порядке, что и в коде, одно скалярное произведение за другим. И каждое скалярное произведение — это *, *, +, *, +. Вся последовательность выглядит так:

*, *, +, *, +, store
*, *, +, *, +, store
*, *, +, *, +, store

Такое решение приводит к простоям. Чтобы вернуть результат, fmul требуются три такта[4]. Это значит, что каждая fadd простаивает два такта, ожидая, пока станет доступен результат fmul.

Версия Абраша: это радикально иное решение. Оно создаёт в конвейере очередь максимально возможного количества независимых команд (результат которых не зависит от предыдущей операции). В 487 это было бы проблемой, потому что для реорганизации операндов в стеке требовалась бы дорогостоящая команда fxch (4 такта!).

Но в Pentium команда fxch бесплатна (0 тактов). Эта команда позволяет разработчикам использовать практически все регистры (s) в стеке FPU. Благодаря этому неуклюжий легаси-стек FPU превращается в удобный массив регистров.

Это позволяет параллельно вычислять три скалярных произведения с постоянным нахождением в стеке x87 трёх частичных сумм. Вычисления выглядят так:

* * * * * *
+ + +
* * *
+ + +
store, store, store

К моменту выполнения сложений результаты умножения уже доступны. Это скрывает задержки fmul и позволяет P5 полностью избежать простоев.

Оптимизация сохранения: ещё одна оптимизация в версии Абраша заключается в том, что команды сохранения (fstp) расположены в конце, а не перемешаны с другими операциями, как в выводе VC6. Сохранение значения (fstp) сразу после вычислений приводит к простою в 1 такт, поскольку этап записи результата конвейера невозможно обойти[5]. Благодаря тому, что сохранения находятся в конце, последней faddp достаточно циклов для завершения своей работы до того, как fstp попытается переместить эти данные в память.

Invert24To16

На самом деле, эта функция не используется в Quake. Скорее всего, это одна из тех оптимизаций, написанных Майклом Абрашем, которые оказались заброшенными, потому что Джон Кармак полностью переписал движок.

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

Часть работы он выполнял на NeXT (ему удавалось мерджить код между нами), но ассемблерные тайминги ему приходилось обеспечивать в DOS.

- Из беседы с Джоном Кармаком

fixed16_t Invert24To16(fixed16_t val) {
  if (val < 256)
    return (0xFFFFFFFF);

  return (fixed16_t)
      (((double)0x10000 * (double)0x1000000 / (double)val) + 0.5);
}

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

// Версия Абраша
.globl C(Invert24To16)

  movl  val(%esp),%ecx
  movl  $0x100,%edx // делимое 0x10000000000
  cmpl  %edx,%ecx
  jle   LOutOfRange

  subl  %eax,%eax
  divl  %ecx

  ret

LOutOfRange:
  movl  $0xFFFFFFFF,%eax
  ret
int32_t _Invert24To16(int32_t arg1)

cmp     dword [esp+0x4 {arg1}], 0x100
jge     0xf04

or      eax, 0xffffffff  {0xffffffff}
retn     {__return_addr}

fild    st0, dword [esp+0x4 {arg1}]
fdivr   st0, qword [__real@4270000]
fadd    st0, qword [__real@3fe0000]
jmp     __ftol

R_DrawSurfaceBlock8_mipX

К моменту, когда движок доходит до R_DrawSurfaceBlock8, он уже определил, какая часть стены видима. Теперь R_enderer должен «запечь» карту освещения в текстуру. Результат этого называется «поверхностью» (Surface) (позже она передаётся D_rawer, который растеризирует её в буфер кадров). Эту часть Майкл Абраш подробно описывает в Главе 68: Quake’s Lighting Model, поэтому больше я не буду в неё вдаваться.

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

Версия всех четырёх функций на C находится здесь: https://github.com/id-Software/Quake/blob/bf4ac424ce754894ac8f1dae6a3981954bc9852d/WinQuake/r_surf.c#L343. ASM-версии находятся здесь: https://github.com/id-Software/Quake/blob/bf4ac424ce754894ac8f1dae6a3981954bc9852d/WinQuake/surf8.s#L47. А вывод VC6 для R_DrawSurfaceBlock8_mip0 — здесь: https://fabiensanglard.net/quake_asm_optimizations/R_DrawSurfaceBlock8_mip0.txt.

Самая очевидная оптимизация — это самомодифицирующийся код. Во множестве адресов памяти жёстко прописаны значения 0x12345678, и они патчатся в R_Surf8Patch непосредственно перед вызовом R_DrawSurfaceBlock8. Патчинг запекает основы цветовой карты в поток команд, что позволяет не использовать регистр для хранения основы. Более того, это позволяет избежать дополнительной ADD для поиска по цветовой карте.

Внутренний цикл b полностью развёрнут. Это тоже помогает экономить регистр благодаря отсутствию необходимости в счётчике цикла. А от одного ошибочного предсказания помогает избавиться последняя итерация (чтобы быстро справляться с циклами, P5 всегда выбирает обратный адрес назначения jmp).

Учитывая важность этой функции, я теперь лучше понимаю, почему Майкл Абраш упомянул её в своей книге.

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

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

На языке ассемблера нам удалось снизить время выполнения этого кода в Quake до 2,25 такта на тексел.

- Майкл Абраш, Глава 68: Quake’s Lighting Model

D_DrawSpans8

Quake использует Active Edge Table для рендеринга полигонов в виде горизонтальных интервалов (span) (если хотите посмотреть на это в действии, то прочитайте статью, которую я написал 15 лет назад). Версия на C — это довольно большая функция, состоящая из почти 220 строк кода. VC6 сгенерировал 256 строк ASM. А оптимизированная вручную версия — это монстр из 650 строк.

D_DrawSpans8 получает список интервалов (частей поверхности), которые нужно растеризировать в буфер кадров. Её цель заключается в обеспечении перспективной коррекции через каждые 8 пикселей (D_DrawSpans16 выполняет ту же задачу для каждых 16 пикселей) и в интерполяции остальных.

Самая большая сложность для этой функции — невозможность интерполяции Z в экранном пространстве. Для корректности перспективы интерполяция должна выполняться для 1/z. Деление — это наихудшая задача для FPU P5, потому что она может занимать до 39 тактов.

Основная оптимизация здесь — это огромное «пересечение»: FDIV для следующего интервала из 8 пикселей запускается в самом начале текущего интервала. Пока FPU выполняет это деление тридцать с лишним тактов, целочисленные конвейеры U и V процессора отрисовывают текущие 8 пикселей. Во многих комментариях к коду говорится, что деление происходит «без перерывов». В забавном комментарии Майкл Абраш упоминает тщательный просчёт, который потребовался ему, чтобы поместить задачи в целочисленные конвейеры, пока fdiv выполняется в конвейере вычислений с плавающей запятой.

 fdiv  %st(1),%st(0) // вот, из-за чего нам пришлось так заморочиться
                     // с пересечением

Чтобы избежать ошибочного предсказания в последней части интервала (который может состоять из менее, чем 8 пикселей) используется таблица переходов. Код вычисляет количество пикселей, отрисовываемых в интервале, ищет адрес памяти в таблице и переходит непосредственно к метке вида Entry3_8. Здесь абсолютно невозможно ошибочное предсказание.

Тут есть и другие крошечные оптимизации, но учитывая то, насколько раскалённо-горячая эта функция, важна каждая мелочь. Например, в случае clamp. В версии на C она выполняет две проверки, одна для «слишком много», вторая для «ниже нуля», то есть два ветвления, которые могут привести к ошибочным предсказаниям. Благодаря использованию ja (Jump if Above), то есть беззнакового сравнения целых чисел со знаком, условия «слишком много» и «слишком мало» проверяются одновременно (если значение отрицательное, оно превращается в очень большое число, которое выше, чем «слишком много»). Это очень круто.

В коде Quake на ASM есть множество упоминаний того, где Майкл искал «пересечения». Это демонстрирует его одержимость поиском мест, в которых FPU и целочисленный конвейер могли бы обрабатывать команды параллельно.

 
  // TODO: возникнет ли пересечение, если изменить порядок?

Как и в случае с R_DrawSurfaceBlock8_mip, Майкл Абраш рассказал о D_DrawSpans в своей Graphic Programming Black Book, которая подчёркивает, насколько первостепенной эта оптимизация была в то время.

Внутренний цикл наложения текстур, обеспечивающий пересечение FDIV перспективной коррекции с плавающей запятой и целочисленную отрисовку пикселей интервалами по 16 пикселей, был ужат на Pentium до 7,5 такта на пиксель, поэтому суммарное время внутреннего цикла создания и отрисовки поверхности примерно равно 10 тактам на пиксель; этого достаточно для обеспечения 40 кадров в секунду с разрешением 640×400 на Pentium/100.

- Майкл Абраш, Глава 68: Quake’s Lighting Model

Дальнейшие исследования

Если вы хотите углубиться в эту тему, то можете изучить obj, полученные при компиляции Quake с отключенными ассемблерными оптимизациями. Дизассемблированный код можно легко получить при помощи Binary Ninja.

Источники и примечания

[1] A visual history of Visual C++

[2] Optimizations for Intel's 32-Bit Processors

[3] Ассемблер GMU использует нотацию AT&T. Эта нотация была использована, чтобы код компилировался и в Linux.

[4] Architecture of the Pentium Microprocessor

[5] Сохранение значения с плавающей запятой должно ожидать дополнительный такт своего операнда с плавающей запятой

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


  1. BiTL
    17.02.2026 09:14

    Для сравнения: в DOOM было всего два файла .asm и три функции для ускорения движка.


    В тех сорцах, что от 97-года? Я так понимаю, что их Кармак как раз очень сильно почистил от asm-кода, который был заточен чисто под x86, DOS, аппаратные фичи VGA, таймер и т.п., и переписал на Си. А пару файлов с ассемблером приложил чисто для лулзов, как исторический артефакт.

    Или где-то есть оригинальные сорцы Doom'a, который был под DOS, использовал DOS4GW, и все такое?


    1. atomlib
      17.02.2026 09:14

      А как он должен был выпустить всё из версии для DOS в 1997 году, если там некая закопирайченная аудиобиблиотека? Буквально это и было указано в ридми:

      We couldn't release the dos code because of a copyrighted sound library we used (wow, was that a mistake -- I write my own sound code now)

      Выпустил он в 1997 году версию для Linux, там то же и указано:

      this code only compiles and runs on linux

      Насколько я понимаю, полные исходники версии DOS/Watcom + DOS4GW нигде не публиковались.

      Более того, некоторые DOS-специфичные файлы (i_ibm.c, i_ibm_a.asm, planar.asm и так далее) в релизе исходников версии для Linux выкинули.


      1. BiTL
        17.02.2026 09:14

        ну, дык, о том и речь. Что в оригинальном Doom'e скорее всего ассемблера было более чем дофига. Да и в DOS версии Quake его явно поболее,, чем в той, что описывает автор.


    1. Maksim_Kuznetsov
      17.02.2026 09:14

      1. BiTL
        17.02.2026 09:14

        на полноценные сорцы не похоже


        1. Maksim_Kuznetsov
          17.02.2026 09:14

          DMX самого нет, возможно, каких-то других библиотек...


        1. unreal_undead2
          17.02.2026 09:14

          Некий товарищ на форуме по ссылке пишет, что удалось с помощью доступных в других местах файликов (dmx и мелочёвка типа makefile) получить бинарник идентичный оригинальному.


    1. cher-nov
      17.02.2026 09:14

      Или где-то есть оригинальные сорцы Doom'a, который был под DOS, использовал DOS4GW, и все такое?

      Есть, восстановленные: https://doomwiki.org/wiki/Gamesrc-ver-recreation.
      Да и исходники DMX уже утекли: https://doomwiki.org/wiki/Talk:DMX#DMX_sources_leaked_in_2015.


  1. Kapsa-sa
    17.02.2026 09:14

    Вот это реально крутая инженерия. D_DrawSpans8 по сути и делал весь фпс. Неудивительно что там самый жирный прирост


  1. GCU
    17.02.2026 09:14

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


  1. Sergek888
    17.02.2026 09:14

    У Абраша была книга про 3D графику и оптимизацию, там как раз много разбиралось про использование ассемблера.