faqs.org.ru

 Главная > Программирование > Программирование графики >

demo.design 3D programming FAQ

Секция 4 из 6 - Предыдущая - Следующая
Все секции - 1 - 2 - 3 - 4 - 5 - 6

градации освещенности. Палитру заполняем так: 32 градации первого цвета,
второго, ..., восьмого. Тогда (для этого примера)

    outputColor = (color << 5) + intensity.

5.6.2. 24/32-битные режимы
~~~~~~~~~~~~~~~~~~~~~~~~~~
Здесь все делается теми же самыми таблицами. Только таблица переводит не
цвет в цвет, а компоненту цвета в компоненту цвета. То есть, создаем
таблицы redTable[numShades], greenTable[numShades], blueTable[numShades],
а потом для каждой компоненты каждого пиксела и нужной градации освещенности
по этой таблице определяем выходное значение компоненты:

    r = redTable[intensity],
    g = greenTable[intensity],
    b = blueTable[intensity].

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

5.6.3. 15/16-битные режимы
~~~~~~~~~~~~~~~~~~~~~~~~~~
Метод 1: тупой, но действенный. Использовать большую таблицу и занести в нее
все возможные комбинации цвета и градации освещения. Таблица получится совсем
не маленькая, размером 65536*32 = 2 мегабайта. Я написал здесь 32, потому как
в этих режимах на компоненту отводится по 5 бит (за исключением 6-битной
зеленой компоненты в 16-битном режим), и делать больше градаций освещенности,
чем 32, бессмысленно.

Метод 2: делать все так же, как в 24/32-битных режимах. Проблемы возникнут
из-за того, что придется с муками выдирать нужные несколько бит компоненты
из пиксела. Таблицы для компонент лучше заранее сделать со всеми нужными
сдвигами, т.е. значения элементов таблиц должны быть такого вида:

    000bbbbb          - синий, 8 бит
    00000gggggg00000  - зеленый, 16 бит
    rrrrr000          - красный, 8 бит

Тогда конечный цвет считается примерно так:

    outputColor =
      (redTable[(color >> 10) & 0x2F] << 8) +
      greenTable[(color >> 5) & 0x1F] +
      blueTable[color & 0x1F].

На ассемблере это делается, видимо, побыстрее - и покрасивее. Примерно так:

    ; ...
    mov  bx,color
    shr  bx,10
    and  bx,02Fh
    mov  ah,redTable[bx]
    mov  bx,color
    and  bx,01Fh
    mov  al,blueTable[bx]
    mov  bx,color
    shr  bx,5      ; можно заменить на
    and  bx,01Fh   ;   shr  bx,4
    shl  bx,1      ;   and  bx,02Eh
    or   ax,greenTable[bx]
    mov  outputColor,ax
    ; ...

Метод 3: рисовать все в 24/32-бита, освещение соответсвенно с текстурой
совмещать по пункту 5.6.2, а потом непосредственно при выводе на экран делать
преобразование из 24/32-бит в 15/16. Или использовать PTC и предоставить
делать нужное преобразование именно ему. PTC - это такая графическая система
для C++, взять ее можно на http://www.gaffer.org/ptc.

6. Оптимизация
==============

6.1. Приемы оптимизации для процессоров Intel Pentium
-----------------------------------------------------

Все, что здесь написано, является выборкой наиболее важных на мой взгляд
фактов из документации от Agner Fog. Если вы серьезно интересуетесь
оптимизацией для Intel Pentium (plain, MMX, PPro, P2), найдите и прочтите
эту документацию (я нашел на http://www.agner.org/assem, относительно
старая версия есть на ftp://ftp.cdrom.com/pub/sac/text/pentopt.zip).

6.1.1. Спаривание целочисленных команд
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
По-моему, основной прием ускорения. Дело в том, что у процессоров Pentium
есть два конвейера обработки команд, U-pipe и V-pipe. В результате некоторые
пары команд могут исполняться одновременно, а это практически удваивает
скорость.

Эти команды могут быть исполнены и в U-pipe, и в V-pipe, и при этом могут
быть спарены (с какой-либо другой командой):
    mov reg/mem,reg/mem/imm
    push reg/imm
    pop reg
    lea, nop, inc, dec, add, sub, cmp, and, or, xor
    некоторые формы test

Эти команды могут быть исполнены только в U-pipe, но при этом все-таки могут
быть спарены:
    adc, sbb
    shr, sar, shl, sal на заданное число
    ror, rol, rcr, rcl на единичку

Эти команды могут быть исполнены в любом конвейере, но могут быть спарены
только в V-pipe:
    near call (близкий вызов)
    short/near jump (короткий/близкий переход)
    short/near conditional jump (короткий/близкий переход по условию)

Все остальные целочисленные команды могут быть исполнены только в U-pipe
и не могут быть спарены вообще.

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

    1. Первая команда может быть исполнена и спарена в U-pipe, вторая,
       соответственно, в V-pipe.

    2. Если первая команда записывает что-то в регистр, то вторая команда не
       может производить чтение/запись из регистра. Причем, в этом условии
       части регистров считаются за весь регистр (то есть, запись в al/ah
       расценивается как запись в eax, а запись в cf - как запись в flags).

       Пример:

         mov eax,1234h / mov ebx,eax   - НЕ будут спарены
         mov eax,1234h / mov ebx,1234h - будут спарены
         inc eax       / mov ecx,eax   - НЕ будут спарены
         mov ecx,eax   / inc ecx       - будут спарены
         mov al,bl     / mov ah,0      - НЕ будут спарены

    3. Две команды, записывающие что-то в регистр флагов, могут быть
       спарены, несмотря на условие 2:

         shr ebx,4 / inc ebx - спарится

    4. Команда, записывающая что-то в регистр флагов, может быть спарена
       с условным переходом, несмотря на условие 2:

         cmp eax,2 / ja @@label_bigger - спарится

    5. Следующие пары команд могут спариться несмотря на то, что обе команды
       изменяют esp:

         push + push, push + call, pop + pop

    6. Существуют ограничения на исполнение команд с префиксом. Префиксы
       возникают в таких случаях:

         - команда, адресующаяся не к сегменту по умолчанию, имеет префикс
           сегмента (примеры: mov eax,es:[ebx]; mov eax,ds:[ebp])
         - команда, работающая с 16-битными операндами в 32-битном режиме
           или с 32-битными операндами в 16-битном режиме, имеет префикс
           разрядности операнда (примеры: mov ax,1234 в защищенном режиме;
           mov ax,word ptr [variable] в защищенном режиме; xor eax,eax в
           реальном режиме)
         - команды, использующая 32-битную адресацию в 16-битном режиме,
           имеет префикс разрядности адреса (пример: mov ax,[ebx] в реальном
           режиме)
         - rep, lock - префиксы (пример: rep stosd)
         - многие команды, которых не было на 8086, имеют двухбайтовый код
           команды, где первый байт равен 0Fh. На процессоре Pentium без MMX
           этот байт считается префиксом. Наиболее часто встречающиеся команды
           с префиксом 0Fh: movzx, movsx, push/pop fs/gs, lfs/lgs/lss, setXX,
           bt/btc/btr/bts/bsf/bsr/shld/shrd, imul с двумя операндами и без
           операнда-числа (immediate).

       На процессоре Pentium без MMX команда с префиксом может исполняться
       только в U-pipe, исключение - близкие переходы по условию (conditional
       near jumps). На процессоре Pentium с MMX команды с префиксами 0Fh и
       размера операнда или адреса может исполняться в любом конвейере; но
       команды с префиксами сегмента, rep или lock (повторения или блокировки
       шины) могут исполняться только в U-pipe.

    7. Команда, в которой одновременно участвует смещение (displacement) и
       заданное число (immediate) не может быть спарена на процессоре Pentium
       без MMX и может быть выполнена и спарена только в U-pipe на процессоре
       Pentium с MMX. Вот примеры:

         mov  byte ptr ds:[1000],0   ; НЕ спаривается ни с чем
         mov  byte ptr [ebx+8],1     ; НЕ спаривается ни с чем
         mov  byte ptr [ebx],1       ; спаривается в U-pipe
         mov  byte ptr [ebx+8],al    ; спаривается в U-pipe

Спаривающаяся команда, которая читает из памяти, считает и записывает
результат в регистр или в регистр флагов занимает 2 такта. Спаривающаяся
команда, которая читает из памяти, считает и записывает результат обратно
в память занимает 3 такта. Примеры таких команд:

    add eax,[ebx] ; 2 такта
    add [ebx],eax ; 3 такта

Существует также так называемое неполное спаривание (imperfect pairing),
когда обе команды выполняются в разных конвейерах, но НЕ одновременно
(возможно, частично перекрываясь по времени исполнения), а следующие за
ними команды не могут начать исполнение, пока обе команды не закончатся.
Такое случается в следующих случаях:

    1. Вторая команда вызывает AGI (address generation interlock,
       блокировка генерирования адреса). Это происходит, если адрес,
       используемый во второй команде зависит от регистров, измененных
       в первой команде. Примеры:

         add ebx,4 / mov eax,[ebx]       ; AGI
         mov eax,[ebx+4] / add ebx,4     ; нормально спаривается
         add esp,4 / pop esi             ; AGI (pop использует esp)
         inc esi / lea eax,[ebx+4*esi]   ; AGI

    2. Две команды одновременно обращаются к одному и тому же двойному
       слову памяти. Примеры (подразумевается, что esi делится на 4):

         mov al,[esi] / mov bl,[esi+1]    ; неполное спаривание
         mov al,[esi+3] / mov bl,[esi+4]  ; нормальное спариваение

    3. Две команды одновременно обращаются к адресам, в которых одинковы
       биты 2-4 (это вызывает конфликт кэш-банков). Для dword-адресов это
       значит, что разница между двумя адресами делится на 32. Пример:

         mov eax,[esi] / mov ebx,[esi+32000] ; неполное спаривание
         mov eax,[esi] / mov ebx,[esi+32004] ; нормальное спаривание

    4. Первая команда производит чтение, подсчет и запись одновременно;
       вторая - чтение и изменение; в этом случае число тактов, требующееся
       для выполнения пары команд, можно рассчитать по следующей таблице:

                                            первая команда
                               +------------------------------------------+
                               | mov или     | чтение/  | чтение/подсчет/ |
         вторая команда        | регистровая | подсчет  | запись          |
       +-----------------------+-------------+----------+-----------------+
       | mov или регистровая   |      1      |     2    |        3        |
       | чтение/подсчет        |      2      |     2    |        4        |
       | чтение/подсчет/запись |      3      |     3    |        5        |
       +-----------------------+-------------+----------+-----------------+

       Примеры:

         add [mem1],eax / add ebx,[mem2]   ; 4 такта
         add ebx,[mem2] / add [mem1],eax   ; 3 такта
         add [mem1],eax / add [mem2],ebx   ; 5 тактов
         add [mem1],eax / sub ebx,ecx      ; 3 такта

6.1.2. Кэш-память
~~~~~~~~~~~~~~~~~
У процессора Pentium непосредственно на кристалле есть 8k кэш-памяти (это
т.н. кэш-память первого уровня, L1 cache) для кода и 8k - для данных. Данные
из L1 cache считываются/записываются за один такт; кэш-промах же может
стоить довольно много тактов. Таким образом, для наиболее эффективного
использования кэша необходимо знать, как он работает.

Итак, L1 cache состоит из 256 кэш-линий (cachelines), по 32 байта в каждой.
При чтении данных, которых нет в кэше, процессор считывает из памяти целую
кэш-линию. Кэш-линии всегда выравнены на физический адрес, делящийся на 32;
так что если прочитать байт по адресу, делящемуся на 32, то можно читать и
писать в следующий за ним 31 байт без всяких задержек. Свои данные имеет
смысл располагать с учетом этого факта - например, выравнивать массивы из
структур длиной 32 байта на 32; перед записью в видеопамять читать оттуда
один байт (один раз на 32 записываемых байта); используемые вместе данные
располагать вместе; и так далее.

Но кэш-линия не может быть связана с любым физическим адресом. У каждой
кэш-линии есть некое 7-битное "заданное значение" (set-value), которое
должно совпадать с битами адреса 5-11. Для каждого из 128 возможных значений
set-value есть две кэш-линии. Отсюда следует то, что в кэше не может
одновременно содержаться более двух блоков данных с одинаковыми битами
адреса 5-11. Чем это чревато, покажем на примере.

    ; пусть в esi - адрес, делящийся на 32
loop_label:
    mov eax,[esi]
    mov ebx,[esi+13*4096+4]
    mov ecx,[esi+20*4096+28]
    dec edx
    jnz loop_label

У используемых трех адресов будет одинаковое значение в битах 5-11. Поэтому
к моменту самого первого чтения в ecx в кэше точно не окажется свободной
кэш-линии, процессор выберет для нее наименее использованную (least recently
used) линию, ту самую, которая была использована при чтении eax. При
чтении ebx, соответственно, будет заново перекрыта линия, использованная
при чтении ecx... В результате цикл будет состоять из сплошных кэш-промахов
и съест порядка 60 тактов. Если же поменять 28 на 32, изменив, таким образом,
на единичку биты 5-11 для адреса [esi+20*4096+28], то для чтения в eax и ebx
будут как раз использованы две имеющихся линии, для чтения в ecx - третья,
не совпадающая ни с одной из этих двух. В результате - скорость порядка
трех тактов на один проход и ускорение примерно в 20 (!!!) раз.

Еще одна интересная вещь, которую стоит учесть - Pentium НЕ загружает
кэш-линию при промахе записи; только при промахе чтения. При промахе записи
данные пойдут в L2 cache или память (в зависимости от настроек L2 cache).
А это довольно медленно. Поэтому, если мы последовательно пишем в один и
тот же 32-байтовый блок, но не читаем оттуда, то имеет смысл сначала сделать
холостое чтение из этого блока, чтобы загрузить его в L1 cache; тогда все
последовательные операции записи будут есть только по одному такту.

6.1.3. Разные трюки
~~~~~~~~~~~~~~~~~~~
Трюков есть много, перечислим здесь только наиболее часто используемые:

    - работа с fixed point вместо floating point иногда (если код не
      слишком сильно насыщен математикой) быстрее; практически всегда
      быстрее для клонов;
    - все данные желательно выравнивать по адресам, кратным размеру данных
      (то есть, переменные-байты можно не выравнивать, слова - выравнивать
      на 2, двойные слова - на 4); обращение к невыравненной переменной
      влечет за собой задержку минимум на три такта;
    - деление на заранее известное число можно заменить умножением на
      обратное ему число;
    - деление на степень двойки для целых чисел заменяется на сдвиг влево;
    - деление чисел с плавающей точкой (fdiv) на Intel Pentium (на клонах,
      к несчастью, это не так) может исполняться параллельно с целочисленными
      командами.

6.2. Примеры внутренних циклов текстурирования
----------------------------------------------

Если немного поработать профайлером, можно выяснить следующую интересную
вещь: большая часть времени на отрисовку сцены тратится именно в процедуре
текстурирования, а в ней, в свою очередь, большая часть времени проходит во
внутреннем цикле (inner loop). Естественно, что его и надо оптимизировать
в первую очередь.

Возьмем этот самый inner loop от обычного аффинного текстурирования (такой
же, на самом деле, используется и в перспективно-корректном) и перепишем на
ассемблере (в критических участках кода на компилятор надеяться не стоит).
Будем использовать 24:8 fixedpoint для u, v, а также 8-битную текстуру
шириной 256 байт.

    mov eax,u        ; 24:8 fixedpoint
    mov ebx,v        ; 24:8 fixedpoint
    mov ecx,length
    xor edx,edx
    mov esi,texture
    mov edi,outputbuffer
inner:
    mov dl,ah        ; вытащили целую часть u
    mov dh,bh        ; вытащили целую часть v
                     ; теперь edx = dx = (100h * v + u) - как раз смещение
                     ; тексела [v][u] относительно начала текстуры
    mov dl,[esi+edx] ; dl = texture[v][u]
    mov [edi],dl     ; *outputBuffer = dl
    add eax,du       ; u += du
    add ebx,dv       ; v += dv
    inc edi          ; outputBuffer++
    loop inner
    ; ...

Красиво, аккуратно, на ассемблере. Только вот согласно правилам спаривания,
половина команд в этом цикле не спарится, и цикл займет порядка 6-7 тактов.
А на самом деле, чуточку переставив местами команды, можно его загнать
где-то в 4.5 такта:

    ; ...
inner:
    mov dl,ah
    add eax,du
    mov dh,bh
    add ebx,dv
    mov dl,[esi+edx]
    inc edi
    dec ecx
    mov [edi-1],dl
    jnz inner
    ; ...

В таком виде любая пара команд отлично спаривается, получаем те самые 4.5
такта. Здесь, правда, есть обращения к внешним переменным du и dv, что
может снизить скорость. Решение - самомодифицирующийся код:

    ; ...
    mov eax,du
    mov ebx,dv
    mov inner_du,eax
    mov inner_dv,ebx
    ; ...
inner:
    ; ...
    add eax,12345678h
    org $-4
inner_du dd ?
    add edx,12345678h
    org $-4
inner_dv dd ?
    ; ...

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

Дальше - больше. 4.5 такта на пиксел - это тоже не предел. В fatmap.txt
(ftp://ftp.hornet.org/pub/demos/code/3d/trifill/texmap/fatmap.txt)
приводится вот такой красивый inner loop на четыре такта.

    ; текстура должна быть выравнена на 64k
    ; линии рисуются справа налево
    ; верхние 16 бит ebx = сегмент текстуры
    ; bh = целая часть v
    ; dh = дробная часть v
    ; dl = дробная часть dv
    ; ah = целая часть v
    ; ecx = u
    ; ebp = du
inner:
    add ecx,ebp        ; u += du
    mov al,[ebx]       ; al = texture[v][u]
    mov bl,ch          ; bl = новая целая часть u
    add dh,dl          ; считаем новую дробную часть v
    adc bh,ah          ; считаем новую целую часть v
    mov [edi+esi],al   ; рисуем пиксел
    dec esi            ;
    jnz inner          ;

Надо, правда, отметить, что он уже требует каких-то ухищрений - а именно,
выравнивания текстуры на 64k и отрисовки строк справа налево. Кроме того,
требует более подробного рассмотрения фрагмент с add и adc, об этом более
подробно рассказано чуть ниже.

И, наконец, цитата из fatmap2.txt - 4-тактовый inner loop, использующий
16:16 fixedpoint. Недостатки - текстура должна быть выравнена на 64k;
есть две команды adc, которые могут запросто испортить спаривание. Кстати,
рекомендую скачать этот самый fatmap2.txt; например, по этому адресу:
ftp://ftp.hornet.org/pub/demos/code/3d/trifill/texmap/fatmap2.zip.


    ; текстура должна быть выравнена на 64k
    ;
    ;         верхние 16 бит | ah/bh/ch/dh    | al/bl/cl/dl
    ;       -----------------+----------------+----------------
    ; eax = дробная часть u  | -              | -
    ; ebx = сегмент текстуры | целая часть v  | целая часть u
    ; edx = дробная часть v  | целая часть dv | целая часть du
    ; esi = дробная часть du | 0              | 0
    ; ebp = дробная часть dv | 0              | 0
    ; ecx = длина линии
    ; edi = буфер

    lea edi,[edi+ecx]  ; edi += ecx
    neg ecx            ; ecx = -ecx
inner:
    mov al,[ebx]       ; al = texture[v][u]
    add edx,ebp        ; обновляем дробную часть v
    adc bh,dh          ; обновляем целую часть v (учитывая перенос от дробной)
    add eax,esi        ; обновляем дробную часть u
    adc bl,dl          ; обновляем целую часть u (учитывая перенос от дробной)
    mov [edi+ecx],al   ; outputBuffer[ecx] = al
    inc ecx
    jnz inner

Этот цикл, с виду, ничем не лучше цикла для 24:8 fixedpoint. Но на самом
деле, он может пригодиться в том случае, если циклу с 24:8 fixedpoint не
хватит точности. Упомянутая нехватка точности проявляется в эффекте "пилы"
внутри относительно больших треугольников, который вовсе не устраняется
добавлением subpixel/subtexel accuracy.

Два последних цикла используют конструкции вида add/adc. Здесь мнения
авторов этих самых циклов явно расходятся с мнениями автора pentopt.txt.
Согласно последнему (и п.6.1.1., соответственно, тоже), add и adc НЕ
спарятся (так как add изменяет регистр флагов, adc - читает из него).
Проведенный эксперимент показал, что они действительно не спариваются, но
он был поставлен на k5; так что на данный момент я достоверной информацией
по этому поводу не располагаю. Впрочем, в любом случае лучше еще чуть-чуть
попереставлять команды - для полной надежности. И для полной надежности,
самостоятельно замеряйте скорость выполнения каждой новой версии цикла и
смотрите, что получилось. Да, совет тривиальный. Но после того, как на моем
k5 цикл из четырех инструкций исполнился, согласно замерам, за такт...

6.3. Использование инструкций MMX
---------------------------------

Если вкратце (а по-другому и не выйдет) с помощью MMX можно довольно неплохо
разогнать некоторые медленные операции - например, сделать RGB-освещение. Или
текстурирование с билинейной фильтрацией. Здесь я только продемонстрирую эти
два примера; всяческие дальнейшие применения - на откуп читателю.

Пример внутреннего цикла с освещением через инструкции MMX:

    mov   eax,u             ; 24:8 fixedpoint
    mov   ebx,v             ; 24:8 fixedpoint
    mov   ecx,length
    xor   edx,edx
    mov   esi,texture
    mov   edi,outputbuffer
    movq  mm1,light         ; RGB-освещенность, qword (4 штуки 0:9 fixedpoint)
    movq  mm2,delta_light   ; изменение освещенности
inner:
    mov        dl,ah             ; dl = (u >> 8)
    add        eax,du            ; u += du
    mov        dh,bh             ; dh = (v >> 8)
    add        ebx,dv            ; v += dv
    movd       mm0,[esi+4*edx]   ; грузим пиксел
    punpcklbw  mm0,mm0           ; распаковываем пиксел
    psrlw      mm0,1             ; для того, чтобы были беззнаковые числа
    pmulhw     mm0,mm1           ; умножаем RGB на RGB-освещенность
    add        edi,4
    dec        ecx
    packuswb   mm0,mm0           ; пакуем пиксел обратно
    paddw      mm1,mm2           ; light += delta_light
    movd       [edi-4],mm0
    jnz        inner

Этот цикл дает после некоторой дальнейшей оптимизации 7 тактов на
пиксел - зато с текстурированием и полноценным RGB-освещением. Собственно
освещение занимает лишь 2 такта. Не очень плохо.

Пример внутреннего цикла с билинейной фильтрацией через инструкции MMX:

    mov   eax,u        ; 24:8 fixedpoint
    mov   ebx,v        ; 24:8 fixedpoint
    mov   ebp,length
    xor   ecx,ecx
    xor   edx,edx
    mov   esi,texture
    mov   edi,outputbuffer
inner:
    mov        dl,ah             ; dl = (u >> 8)
    add        eax,du            ; u += du
    mov        dh,bh             ; dh = (v >> 8)
    add        ebx,dv            ; v += dv
    mov        cl,al             ; ecx = (u & 0xFF) = fu - дробная часть u
    movd       mm0,[esi+4*edx]   ; грузим пикселы
    movd       mm1,[esi+4*edx+4]
    movd       mm2,[esi+4*edx+4*256]
    movd       mm3,[esi+4*edx+4*257]
    punpcklbw  mm0,mm0           ; распаковываем пикселы
    punpcklbw  mm1,mm1
    punpcklbw  mm2,mm2
    punpcklbw  mm3,mm3
    psrlw      mm0,1             ; для того, чтобы были беззнаковые числа
    psrlw      mm1,1             ; и pmulhw (знаковое умножение) работало
    psrlw      mm2,1             ; нормально
    psrlw      mm3,1
    psubw      mm1,mm0           ; mm1 = tex[v+1][u] - tex[v][u]
    psubw      mm3,mm2           ; mm3 = tex[v+1][u+1] - tex[v][u+1]
    pmulhw     mm1,tab[8*ecx]    ; mm1 *= fu
    pmulhw     mm3,tab[8*ecx]    ; mm3 *= fu
    add        esi,4
    add        edi,4
    psllw      mm1,7             ; корректируем результат умножения
    psllw      mm3,7             ;
    paddsw     mm0,mm1           ; mm0 = tex[v][u] + mm1
    paddsw     mm2,mm3           ; mm2 = tex[v][u+1] + mm3
    mov        cl,bl             ; ecx = (v & 0xFF) = fv - дробная часть v
    psubw      mm2,mm0           ; mm2 -= mm0
    pmulhw     mm2,tab[8*ecx]    ; mm2 *= fv
    psrlw      mm0,7             ; корректируем результат умножения
    paddsw     mm0,mm2           ; mm0 += mm2 - отфильтрованное значение
    packuswb   mm0,mm0           ; пакуем пиксел
    movd       [edi-4],mm0       ; записываем его
    dec        ebp
    jnz        inner

Отдельного упоминания и разъяснение требует табличка tab. Это просто табличка
дробных частей в готовом для MMX-умножения виде:

tab label      qword
    dw         0,0,0,0
    dw         1,1,1,1
    dw         2,2,2,2
    ; ...
    dw         255,255,255,255

То есть в данном примере tab[8*ecx] = [cl, cl, cl, cl] - как раз готовая для
использования в MMX-инструкциях дробная часть.

Здесь получается уже довольно приличное количество тактов на пиксел, порядка
двадцати. Но несмотря на это, вышеприведенный цикл уронил fps на моей любимой
тестовой сцене всего лишь в 1.5 раза по сравнению с обычным текстурированием.
Тоже не очень плохо. В общем, успехов в использовании. Только не забывайте
включать поддержку не-MMX режима для тех, у кого MMX нет, и, соответственно,
детектор наличия MMX-инструкций.

6.4. Тайловые текстуры
----------------------

В пункте 6.1.2. кратко описана схема работы кэш-памяти для процессоров Intel
Pentium. Из этой схемы, в частности, видно, что при непоследовательном
чтении из памяти будут периодически случаться кэш-промахи, что не очень
хорошо влияет на скорость. Хрестоматийный пример - это поворачивающаяся
картинка; при угле поворота, равном 0, чтение из памяти последовательно и
ситуация с кэшированием идеальна; если мы читаем байт и получаем кэш-промах,
то следующий за ним 31 байт будет прочитан уже из L1 cache, по полтакта на
чтение. А при достаточно больших углах поворота, например, 90 градусов,
каждый следующий байт находится на достаточном расстоянии от предыдущего,
и получаем кэш-промах практически на каждом пикселе, что *очень* медленно.
Но эта же ситуация постоянно случается и при текстурировании, грани ведь у
нас ориентированы произвольным образом, камера - тоже. Тайловые текстуры как
раз и призваны бороться с кэш-промахами.

Идея такова. Обычно текстура хранится в памяти построчно, именно из-за этого
при движении вдоль строки все нормально, а при движении поперек строк будут
постоянные кэш-промахи (кэшируется ведь небольшой горизонтальный кусочек).
Разобьем ее на маленькие кусочки - тайлы, и будем хранить такими кусочками.
Вот пример для текстуры размера 256x256 и тайла размера 8x8:

    Текстура в пикселах:
        0,   1,  2,    3, ..., 255
      256, 257, 258, 259, ..., 511
      512, 512, 513, 514, ..., 767
      ...

    Текстура в тайлах:
       0,  1,  2,  3, ..., 31 (первые восемь строк пикселов)
      32, 33, 34, 35, ..., 63 (вторые восемь строк пикселов)
      64, 65, 67, 68, ..., 95
      ...

    Тайл 0 в пикселах:          Тайл 1 в пикселах:
         0,    1, ...,    7          8,    9, ...,   15
       256,  257, ...,  263        264,  265, ...,  271
       512,  513, ...,  519        520,  521, ...,  527
       ...                         ...
      1792, 1793, ..., 1799       1800, 1801, ..., 1807

В этом случае все близкие к текущему текселы почти наверняка находятся в
текущем тайле, и количество кэш-промахов хоть как-то, да уменьшается. То
есть, тайлы как бы позволяют двигаться в текстуре и по горизонтали, и по
вертикали. Зато изменяется код расчета смещения нужного пиксела в текстуре.
Посмотрим, что получится для случая на иллюстрации. Пусть координаты в
текстуре (то есть, их целые части) равны u, v; тогда номер нужного тайла
равен (v / 8) * 32 + (u / 8), а координаты в тайле равны (u % 8), (v % 8)
соответственно. Тут помогает то, что 8 - степень двойки, получается, что
номер и координаты в тайле можно посчитать и проще, а по ним находим и
смещение в текстуре:

    tile_number = ((v >> 3) << 5) + (u >> 3);
    tile_u = u & 0x07;
    tile_v = v & 0x07;
    texture_offset = (tile_number << 6) + (tile_v << 3) + tile_u;

Напишем эти формулы и для общего случая, то есть для текстуры размером
(2^TEXBITS)x(2^TEXBITS) и тайла размером (2^TILEBITS)x(2^TILEBITS):

    TILEMASK = ((1 << TILEBITS) - 1);
    tile_number = ((v >> TILEBITS) << (TEXBITS - TILEBITS)) + (u >> TILEBITS);
    tile_u = u & TILEMASK;
    tile_v = v & TILEMASK;
    texture_offset = (tile_number << (2*TILEBITS)) + (tile_v << TILEBITS) +
      tile_u;

Делать такое преобразование для каждого пиксела текстуры - занятие довольно
небыстрое. Поэтому начинаем заниматься оптимизацией. Выделяем части смещения,
зависящие от целых частей u, v соответственно:

    tile_u_part = ((u >> TILEBITS) << (2*TILEBITS)) + (u & TILEMASK);
    tile_v_part = ((v >> TILEBITS) << (TEXBITS + TILEBITS) +
      ((v & TILEMASK) << TILEBITS);
    texture_offset = tile_u_part + tile_v_part;

Отсюда видно, что биты целой части u, v разделяются на две группы (нижние
TILEBITS и все остальные) и эти две группы как-то раскидываются, сдвигаются.
Посмотрим, как именно это происходит для конкретного случая, где u, v - 8:16
fixedpoint, TILEBITS = 3, TEXBITS = 8:

    u             00000000 UUUUUuuu ffffffff ffffffff
    v             00000000 VVVVVvvv ffffffff ffffffff
    tile_u_part   00000UUU UU000uuu ffffffff ffffffff
    tile_v_part   VVVVV000 00vvv000 ffffffff ffffffff

Идея быстрого тайлового текстурирования заключается как раз в интерполяции
непосредственно tile_u_part и tile_v_part, а не u, v; мы заранее переставляем
биты u, v, du, dv нужным образом и интерполируем уже готовые к использованию
с тайловыми текстурами величины tile_u_part, tile_v_part. Но для того, чтобы
сложение давало правильный результат, "дырки" между кусками целой части и
дробной частью u, v в tile_u_part, tile_v_part надо перед каждым сложением
заполнять единицами; иначе, скажем, целая единица, получившаяся при сложении
v и dv уйдет в нижний бит целой части tile_v_part и вместо перехода на пиксел
вниз вызовет переход на пиксел вправо. Поэтому все должно выглядеть так:

    u             00000000 UUUUUuuu ffffffff ffffffff
    v             00000000 VVVVVvvv ffffffff ffffffff
    tile_u_part   00000UUU UU111uuu ffffffff ffffffff
    tile_v_part   VVVVV111 11vvv111 ffffffff ffffffff

Теперь переносы при сложении будут обрабатываться правильно, при переносе все
эти единички обнуляются, а переносимый бит добавляется туда, куда надо. Зато
теперь не будет работать сложение, не вызывающее переноса - в этом случае
единички останутся на месте и испортят все смещение. Получается, что перед
сложением нужно выставлять нужные биты в единичку, а после сложения их же и
очищать. Соответствующий цикл будет выглядеть так:

    // ...
    u = make_tile_u(u);
    v = make_tile_v(v);
    du = make_tile_u(du);
    dv = make_tile_v(dv);
    for (current_sx = x_start; current_sx <= x_end; current_sx++) {
      putpixel(current_sx, current_sy, texture[unfix(u) + unfix(v)];
      u |= TILE_U_MASK;
      v |= TILE_V_MASK;
      u += du;
      v += dv;
      u &= (~TILE_U_MASK);
      v &= (~TILE_U_MASK);
    }
    // ...

Здесь make_tile_u(), make_tile_v() осуществляет перевод u, v в "тайловую"
форму; unfix() просто сдвигает u, v на собственную дробную часть, оставляя
лишь целую, TILE_U_MASK, TILE_V_MASK заполняют нужные биты числа единичками.
В нашем примере видно, что

    TILE_U_MASK = 0x380000; // 00000000 00111000 00000000 00000000
    TILE_V_MASK = 0x7C3000; // 00000111 11000111 00000000 00000000

По сравнению с обычным текстурированием добавилось более четырех инструкций.
Много. Смотрим дальше. С той же самой целью - заставить биты "перепрыгивать
дырки" при сложении - можно не заполнять дырки единичками в u, v для каждой
точки, а сделать это один раз для du, dv. Кроме того, unfix() можно делать
один раз, а не два, заменив (unfix(u) + unfix(v)) на unfix(u + v). Но здесь
надо проследить за тем, чтобы дробные части u, v при сложении не вызвали бы
переноса и не испортили смещение на единичку. Достигается это использованием
fixedpoint 8:15 и вставкой одного запасного бита между целой и дробной частью
u, v. Т.о., битовые раскладки для нашего примера теперь выглядят вот так:

    tile_u_part   00000UUU UU000uuu 0fffffff ffffffff
    tile_v_part   VVVVV000 00vvv000 0fffffff ffffffff
    tile_du       00000UUU UU111uuu 1fffffff ffffffff
    tile_dv       VVVVV111 11vvv111 1fffffff ffffffff
    TILE_U_MASK   00000000 00111000 10000000 00000000

Секция 4 из 6 - Предыдущая - Следующая

Вернуться в раздел "Программирование графики" - Обсудить эту статью на Форуме
Главная - Поиск по сайту - О проекте - Форум - Обратная связь

© faqs.org.ru