Вместе с релизом в 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.
