Steps3D – Уроци – разширение ARB_shader_storage_buffer_object
Традиционно големи количества данни могат да бъдат предадени на шейдъра чрез униформи или униформи на блокове (въпреки че униформите обикновено са ограничени до 64K). Но в същото време всички тези данни са само за четене за шейдъра - шейдърът може да извършва само произволни четения от тях.
В редица случаи обаче става необходимо не само да се четат, но и да се записват данни. Тази функция се предоставя от разширенията ARB_shader_image_load_store и ARB_shader_storage_buffer_object.
Тук ще разгледаме само разширението ARB_shader_storage_buffer_object, но всъщност и двете разширения имат много общи неща, по-специално по отношение на синхронизацията. Графичните процесори са масивни паралелни изчислителни устройства, така че изпълняват огромен брой нишки по едно и също време (всъщност терминът нишка - нишка - е по-типичен за API като CUDA / OpenCL, а по отношение на OpenGL обикновено се използва терминътизвикване на шейдъри).
Когато огромен брой нишки могат да записват в паметта, естествено е това да доведе до различни проблеми. За тази цел се въвеждат както специални спецификатори (coherent, volatile), така и явни функции за синхронизация. В същото време такива функции се въвеждат както в езика на шейдъра, така и в самия OpenGL API.
Точно както можете да правите унифицирани блокове, чиито данни се предоставят чрез върхови буфери (по-конкретно, UBO, Uniform Buffer Objects), можете също да комбинирате данни в буферни блокове в GLSL.
От страна на приложението се въвежда нов тип буфери - GL_SHADER_STORAGE_BUFFER, който действа като памет за съответните буферни блокове в шейдъра. Точно като UBO, тези буфери са обвързани със специфични точки на свързване.чрез командитеglBindBufferBaseиglBindBufferRange.
Конкретна реализация на OpenGL има ограничения върху използването на SSBO. Максималният поддържан размер на такъв буфер може да бъде намерен чрез извикване наglGetIntegervс параметъра GL_MAX_SHADER_STORAGE_BLOCK_SIZE.
Има и ограничения за максималния брой такива блокове, използвани в шейдър от даден тип. Тези ограничения могат да бъдат получени с помощта на командатаglgetIntegervс аргументите GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, GL_MAX_TESS_CONTROL_SHADER_STORAGE_BLOCKS, GL_MAX_TESS_EVALUATION_SHADER_SORAGE)_BLOCKS, GL_MAX_GEOMETRY_SHADER_STORAGE_BLOCKS, GL_MAX_ FR AGMENT_SHADER_STORAGE_BLOCKS и GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS.
Ако използваният брой буферни блокове в шейдъра надвишава максималния възможен брой, това води до грешка при компилиране. Това също ще доведе до грешка, ако общият брой такива блокове, използвани от всички шейдъри в рамките на програмата, надвиши максималния брой, който може да бъде намерен чрезglGetIntegervс аргумента GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS.
За да използвате SSBO от приложение, трябва да създадете върхов буфер, да го попълните с данни (или да зададете неговия размер) и да го свържете към дадената целева опорна точка GL_SHADER_STORAGE_BUFFER, като използвате командитеglBindBufferBaseglBindBufferRange.
Следващата стъпка е да свържете подходящия блок в шейдъра към буферна опорна точка от тип GL_SHADER_STORAGE_BUFFER. Това може да стане по два начина. Най-простият е точно в тялото на шейдъра под формата на конструкциятаbinding=в дескриптораlayout, както е показано по-долу:
Вторият начин е да зададете опорната точка чрез OpenGL API. Това се прави с помощта на функциятаglShaderStorageBlockBinding, въведена от това разширение.
ПараметърътstorageBlockIndexуказва съответния буферен блок в рамките на програмата, определена от параметъраprogram. ПараметърътstorageBlockBindingуказва опорната точка за този блок към буфера. За да получите индекса на блока вътре в шейдъра, можете да използвате функциятаglGetProgramResourceIndex. И така, за да свържете блокаFragInfoкъм опорна точка 2 (което в предишния пример беше направено чрез дескриптораlayoutв шейдъра), можете да използвате следния кодов фрагмент:
Някои промени са направени в GLSL от това разширение, като добавянето на новата ключова думаbuffer. Директивата#extensionсе използва за задаване на поддръжка за това разширение в текста на шейдъра.
Вместо това можете просто да зададете поддръжка за OpenGL 4.3:
Вътре в буферния блок размерът на последната променлива на този блок може да не е посочен (както вече видяхте от примерите) - той ще бъде определен по време на изпълнение на програмата въз основа на размера на съответния буфер. В този случай стойността на методаlengthвече не е константен израз по време на компилиране - тя се определя по време на изпълнение.
Обърнете внимание, че въпреки че елементите на такъв масив могат да бъдат достъпни чрез целочислен индекс, този масив не може да бъде предаден като параметър на функцията. Също така не можете да използвате отрицателни индекси или индекси извън границите - поведението на програмата в този случай е непредсказуемо до края на изпълнението на програмата.
Също така имайте предвид, че не можете да използвате инициализатори за променливи от такъв блок. Можете да използвате един и същ блок на различни етапи от тръбопровода, но описанията на блока с едно и също име в различни шейдъри, съставляващи програмата, трябва да съвпадат, в противен случай етапът на свързване щегрешка.
Що се отнася до UBO за SSBO в тялото на шейдъра в директиватаlayout, можете да зададете не само опорната точка, но и как променливите на този блок ще бъдат поставени в паметта. За това се използват вече познатите спецификаториshared,packed,std140,row_majorиcolumn_major(вижте разширението ARB_uniform_buffer_object).
Освен това е въведен още един спецификатор за поставяне на променливи в паметта -std430. Този спецификатор се прилага само за буферни блокове и е идентичен на std140, с изключение на това, че не изисква отместванията, при които се намират променливите в паметта, да са кратни наsizeof(vec4).
Въпреки това, когато шейдър пише в такъв блок, трябва да се разбере, че шейдърът се изпълнява под формата на огромен брой нишки и следователно могат да възникнат различни проблеми, свързани със синхронизацията.
Например, редът, в който фрагментите, получени в резултат на растеризиране, ще бъдат обработени, не е строго определен и може да се променя от време на време. По същия начин, ако имате два триъгълника A и B и първо отпечатате A и след това B, тогава никой не гарантира, че произволен фрагмент от триъгълник A ще бъде изпълнен преди произволен фрагмент от триъгълник B. За някои фрагменти може да се окаже обратното.
Това разширение предоставя няколко опции наведнъж, за да избегнете подобни проблеми. На първо място, в допълнение към обичайните операции с блокови променливи се поддържат и атомарни операции, които гарантират коректно изпълнение. Поддръжката на атомарни операции се осъществява чрез следните функции:
Имайте предвид, че само целочислени типове данни,intиuint, се поддържат за атомарни операции. Следното е пример за фрагментен шейдър, койтоизползва SSBO, за да поддържа списък на всички фрагменти, за които е извикан шейдърът. Имайте предвид, че тук използваме атомен брояч, за да контролираме разпределението на елементите на масива - неговата атомарност гарантира, че всеки елемент се разпределя и използва най-много веднъж.
Следва съответният C++ код.
В допълнение към атомарните операции има и друг начин за синхронизиране - можете изрично да зададете достъп както до отделни променливи в блока, така и до всички променливи в блока наведнъж.
Поддържат се следните дескриптори -coherent,volatile,restrict,readonlyиwriteonly.
Дескрипторитеreadonlyиwriteonlyзадават типа достъп до променливата и служат за оптимизиране на кода на шейдъра. Опитът за извършване на операция, която противоречи на тези дескриптори (например промяна на променливаreadonly), води до грешка при компилиране.
Дескрипторътrestrictозначава, че съответната променлива е единственият начин за достъп до тази част от паметта (т.е. съответната буферна област не е достъпна през друг блок) - това позволява на компилатора да извърши някои оптимизации. Имайте предвид, че компилаторът не може да открие нарушение на достъпа в този случай - просто може да получите грешен резултат.
Дескрипторътcoherentозначава, че достъпът до тази променлива е съгласуван с подобен достъп от други нишки. При четене от съответната променлива, резултатът от четенето ще отразява вече завършени операции за запис от други нишки. По същия начин записът в тази променлива ще бъде видим от операции за четене от други нишки. Имайте предвид, че изпълнението на операциите за четене и запис е недефинирано, по-специално стойностите обикновено сапроменливите могат да бъдат кеширани за подобряване на производителността. Този дескриптор премахва такова кеширане, което може да повлияе негативно на производителността.
В допълнение към езика на шейдърите е въведена нова функция -memoryBarrier. Тази функция изчаква завършването на всички операции с паметта и след това се връща. Това се дължи на факта, че операцията на паметта не е мигновена и отнема известно време, докато резултатите от нея се видят в други нишки.
Подобна функция -glMemoryBarrierе въведена в OpenGL API.
Тази команда осигурява изрична синхронизация, за да се гарантира, че промените в паметта, причинени от шейдъри, ще бъдат видими при последващи операции върху същите обекти. Параметърътbarriersуказва кои операции трябва да се синхронизират. Може да включва следните битове:
Един пример за използване на тази функция е използването на данни, събрани по време на изпълнение на шейдър. Нека разгледаният по-рано шейдър записва данни за всички фрагменти в буфера и сега искаме да използваме тези данни, за да ги покажем като набор от точкови спрайтове. За да направите това, можете да използвате следния кодов фрагмент (продължавайки предишния пример).