Poglavlje 17: Shaderi -- Sta su i kako rade

Poglavlje 17: Shaderi -- Sta su i kako rade


Deo III: Materijali i Shaderi


"Shader je mozak svakog piksela na vasem ekranu. Bez njega, GPU je samo ogromna farma tranzistora koja ne zna sta da radi sa sobom."


Sadrzaj poglavlja

  1. Sta je shader
  2. Shader jezici
  3. Shader stages -- faze u pipeline-u
  4. Uniforms i Constant Buffers
  5. Varyings -- interpolatori izmedju faza
  6. Sampleri i teksture u shaderima
  7. Grananje na GPU-u -- zasto je skupo
  8. Shader permutacije -- kombinatorna eksplozija
  9. Uber-shaderi
  10. Shader kompilacija
  11. Shader debugging
  12. Rezime kljucnih pojmova
  13. Dodatna literatura i linkovi

Uvod

Dosli smo do srca renderovanja.

U poglavlju 07 prosli smo kroz render pipeline i pomenuli vertex shadere, pixel shadere, tessellation. U poglavlju 08 zaronili smo u GPU arhitekturu -- SIMD izvrsavanje, warp-ove, occupancy, memory bandwidth. Sve to je bio hardver. Sada je vreme da govorimo o softveru koji na tom hardveru zivi.

Shaderi su programi koji se izvrsavaju na GPU-u. Oni su ti koji odredjuju kako ce svaki vertex biti transformisan, kako ce svaki piksel biti obojen, kako ce svetlost interagovati sa povrsinom. Bez shadera, GPU bi bio ogromna kolekcija tranzistora bez svrhe. SA shaderima, on postaje masina koja generise milione piksela 60+ puta u sekundi.

Ovo poglavlje otvara Deo III: Materijali i Shaderi -- deo knjige u kome se spajaju hardver, softver i umetnost. Ovde cemo detaljno objasniti:

Posle ovog poglavlja, imacete solidno razumevanje shader programa kao koncepta. U poglavlju 22 cemo videti kako Unreal Engine 5 sve ovo apstrahuje kroz svoj Material Editor -- ali tek kada razumete sta se desava "ispod haube", moci cete da donosite zaista informisane odluke o performansama.

Krenimo.


17.1 Sta je shader

Definicija

Shader je program koji se izvrsava na grafickom procesoru (GPU). To je komad koda -- obicno kratak u poredjenju sa CPU programima -- koji obavlja specifican zadatak u render pipeline-u. Vertex shader transformise svaki vertex. Pixel shader izracunava boju svakog piksela. Compute shader radi opste proracune koji nemaju veze sa crtanjem trouglova.

Ime "shader" dolazi od prvobitne namene -- izracunavanje sencenja (shading) na povrsinama. Ali danas shaderi rade mnogo vise od toga: transformacije geometrije, fizicke simulacije, post-processing efekte, pa cak i vestacku inteligenciju. Ime je ostalo iz istorijskih razloga.

Kratka istorija: Od fiksne funkcije do programabilnosti

Da bi razumeli zasto shaderi postoje u danasnjoj formi, moramo se kratko vratiti u proslost.

Era fiksne funkcije (pre ~2001.)

U ranim danima 3D grafike, GPU-ovi nisu imali programabilne shadere. Umesto toga, imali su fixed-function pipeline -- fiksirani, hardwired niz koraka koji nije mogao da se menja. Vi ste GPU-u dali vertex-e, teksture, i par parametara (boja svetla, pozicija svetla, tip sencenja), i on je radio sve po unapred definisanom algoritmu.

Hteli ste Gouraud shading? Ukljucite odgovarajuci flag. Phong shading? Drugi flag. Hteli ste nesto sto nije bilo ugradjen u hardver? Nazalost -- ne moze.

Ovo je bio svet OpenGL 1.x i DirectX 7. GPU je bio kao kalkulktor sa fiksnim setom operacija: mocan za ono za sta je dizajniran, beskoristan za bilo sta drugo.

Fixed-Function Pipeline (pojednostavljen):

Vertex podaci ──> [Transformacija: fiksna] ──> [Osvetljenje: fiksna formula]
    ──> [Rasterizacija] ──> [Texturing: fiksne operacije] ──> Framebuffer

Nema prostora za kreativnost. Sve je hardcoded.

Prva generacija programabilnih shadera (~2001-2004)

Sve se promenilo sa NVIDIA GeForce 3 (2001.) i ATI Radeon 9700 (2002.). Ovi GPU-ovi su uveli programabilne vertex i pixel shadere. Umesto fiksnih algoritama, sada ste mogli da napisete svoj kod koji ce GPU izvrsavati.

Ali prva generacija je bila izuzetno ogranicena:

Ovi rani shaderi su bili poput pisanja programa u assembly-ju na Commodore 64 -- moguce, ali bolno. Ipak, cak i sa ovim ogranicenjima, grafika je preskocila generaciju unapred. Odjednom ste mogli da implementirate per-pixel osvetljenje, normal mapping, custom post-processing efekte -- stvari koje fixed-function pipeline nikada ne bi mogao da uradi.

Moderna era (2004-danas)

Sa Shader Model 3.0 (DirectX 9.0c, 2004.), a zatim Shader Model 4.0 (DirectX 10, 2006.) i 5.0 (DirectX 11, 2009.), shaderi su postali znatno mocniji:

Danas, sa Shader Model 6.x (DirectX 12) i dalje, shaderi su kompleksni programi koji mogu da rade gotovo sve sto i CPU program -- sa jednom kljucnom razlikom: oni se izvrsavaju masovno paralelno.

Shader Model 6.6 i 6.7, koje podrzava DirectX 12 Ultimate, donose mogucnosti poput ray tracing shadera (DXR), mesh shadera, work graph-ova, i naprednog wave-level programiranja. GPU shader je danas punopravni programski model, ne samo "plugin za render pipeline".

Kako se shaderi razlikuju od CPU programa

Ovo je kljucno za razumevanje. Shader jeste program, ali se fundamentalno razlikuje od programa na CPU-u:

Aspekt CPU program GPU shader
Paralelizam Mali (8-32 thread-a tipicno) Ogroman (hiljade do miliona instanci istovremeno)
Kontrola toka Kompleksna -- petlje, rekurzija, proizvoljni skokovi Ogranicena -- grananje je skupo, rekurzija obicno nije podrzana
Memorijski pristup Proizvoljan (random access) Optimizovan za koherentan pristup (svi thread-ovi pristupaju slicnim adresama)
Deljenje podataka Lako (deljeni memorijski prostor, lock-ovi) Ograniceno (thread-ovi unutar grupe mogu deliti, izmedju grupa -- tesko)
Trajanje izvrsavanja Moze da radi neograniceno Kratko -- mikrosekunde po pozivu
Input/Output Proizvoljni fajlovi, mrezni pristup, sve Strogo definisan -- vertex podaci ulaze, boja piksela izlazi
Debugging Breakpoint-i, step-through, watchevi Izuzetno ograniceno -- vise o tome na kraju poglavlja

Ova ogranicenja nisu mane -- ona su cena paralelizma. Zato sto GPU izvrsava isti shader na hiljadama podataka istovremeno, taj shader mora da bude jednostavan i predvidiv. Kompleksna kontrola toka (if/else, while petlje sa promenljivim brojem iteracija) unistava paralelizam, jer razliciti thread-ovi pocnu da rade razlicite stvari, a GPU mora da saceka najsporijeg.

Ako niste sigurni zasto je to tako, molim vas da se vratite na poglavlje 08 i procitate sekciju o SIMD/SIMT izvrsavanju i warp divergenciji. Taj koncept je apsolutno fundamentalan za sve sto sledi u ovom poglavlju.


17.2 Shader jezici

Zasto su nam potrebni specijalizovani jezici

Ne mozete da pisete shadere u C++-u ili Python-u. GPU ima potpuno drugaciju arhitekturu od CPU-a, i potreban mu je kod koji je kompajliran specifalno za njega. Zato postoje shader jezici -- programski jezici dizajnirani iskljucivo za pisanje GPU programa.

Ovi jezici izgledaju slicno C-u, ali imaju vazne razlike:

Pogledajmo glavne shader jezike.

HLSL (High Level Shading Language)

HLSL je shader jezik razvijen od strane Microsoft-a za DirectX. To je jezik koji Unreal Engine 5 koristi interno, i jezik koji cete najcesce sretati u kontekstu UE5 razvoja.

HLSL sintaksa je izuzetno slicna C-u:

// Primer jednostavnog pixel shadera u HLSL-u
// Izracunava Lambert-ovo difuzno osvetljenje

// Constant Buffer -- podaci sa CPU-a
cbuffer LightData : register(b0)
{
    float3 LightDirection;  // Pravac svetla (normalizovan)
    float3 LightColor;      // Boja svetla
    float  LightIntensity;  // Intenzitet
};

// Ulazna struktura -- podaci iz vertex shadera (interpolirani)
struct PSInput
{
    float4 Position : SV_POSITION;  // Pozicija piksela na ekranu
    float3 Normal   : NORMAL;       // Normala povrsine (interpolirana)
    float2 TexCoord : TEXCOORD0;    // UV koordinate
};

// Tekstura i sampler
Texture2D    DiffuseTexture : register(t0);
SamplerState LinearSampler  : register(s0);

// Pixel shader funkcija
float4 MainPS(PSInput input) : SV_TARGET
{
    // Sample teksture na UV koordinatama
    float4 albedo = DiffuseTexture.Sample(LinearSampler, input.TexCoord);
    
    // Lambert-ov difuzni model: dot(N, L) clamped na [0, 1]
    float NdotL = saturate(dot(normalize(input.Normal), -LightDirection));
    
    // Finalna boja = albedo * svetlo * intenzitet
    float3 finalColor = albedo.rgb * LightColor * LightIntensity * NdotL;
    
    return float4(finalColor, albedo.a);
}

Obratite paznju na nekoliko stvari:

  1. cbuffer -- constant buffer koji sadrzi podatke poslate sa CPU-a (o tome detaljno u sekciji 17.4)
  2. Semantike (SV_POSITION, NORMAL, TEXCOORD0, SV_TARGET) -- govore GPU-u sta svaki podatak predstavlja
  3. Ugradjene funkcije (saturate, dot, normalize) -- GPU ima hardversku podrsku za ove operacije
  4. Vektorski tipovi (float3, float4) -- osnovni gradjivni blok GPU racunanja

HLSL se koristi sa DirectX 11 i DirectX 12. UE5 na Windows-u koristi DirectX 12 kao primarni graficki API, sto znaci da su svi shaderi u sustini HLSL kod.

GLSL (OpenGL Shading Language)

GLSL je shader jezik za OpenGL i, u ranim danima, za Vulkan. Sintaksa je slicna HLSL-u ali ima neke razlike:

// Isti shader u GLSL-u
#version 450

// Uniform block (ekvivalent HLSL cbuffer-a)
layout(binding = 0) uniform LightData
{
    vec3  LightDirection;
    vec3  LightColor;
    float LightIntensity;
};

// Ulazi iz vertex shadera
layout(location = 0) in vec3 inNormal;
layout(location = 1) in vec2 inTexCoord;

// Tekstura i sampler
layout(binding = 0) uniform sampler2D DiffuseTexture;

// Izlaz
layout(location = 0) out vec4 outColor;

void main()
{
    vec4 albedo = texture(DiffuseTexture, inTexCoord);
    float NdotL = clamp(dot(normalize(inNormal), -LightDirection), 0.0, 1.0);
    vec3 finalColor = albedo.rgb * LightColor * LightIntensity * NdotL;
    outColor = vec4(finalColor, albedo.a);
}

Razlike su uglavnom kozmeticke: vec3 umesto float3, clamp umesto saturate, layout dekoratori umesto register. Konceptualno, sve je isto.

GLSL se koristi na Linux-u, Android-u (OpenGL ES), i delimicno na macOS-u (gde je OpenGL deprecated). UE5 koristi GLSL za Android platformu (OpenGL ES backend).

Metal Shading Language

Metal je Apple-ov graficki API, i dolazi sa sopstvenim shader jezikom koji je zasnovan na C++14:

// Isti shader u Metal-u
#include <metal_stdlib>
using namespace metal;

struct LightData
{
    float3 LightDirection;
    float3 LightColor;
    float  LightIntensity;
};

struct PSInput
{
    float4 position [[position]];
    float3 normal;
    float2 texCoord;
};

fragment float4 mainFragment(
    PSInput            input     [[stage_in]],
    constant LightData &light   [[buffer(0)]],
    texture2d<float>   diffTex  [[texture(0)]],
    sampler            linSamp  [[sampler(0)]])
{
    float4 albedo = diffTex.sample(linSamp, input.texCoord);
    float NdotL = saturate(dot(normalize(input.normal), -light.LightDirection));
    float3 finalColor = albedo.rgb * light.LightColor * light.LightIntensity * NdotL;
    return float4(finalColor, albedo.a);
}

Metal se koristi iskljucivo na Apple platformama (iOS, macOS, tvOS, visionOS). UE5 koristi Metal shader jezik za sve Apple target-e.

SPIR-V (Standard Portable Intermediate Representation)

SPIR-V nije shader jezik u klasicnom smislu -- to je intermedijarna reprezentacija (IR), nesto poput "bytecode-a" za GPU. Zamislite ga kao ekvivalent Java bytecode-a ili .NET IL-a, ali za graficke shadere.

Izvorni kod (HLSL, GLSL)
         |
         v
    [Kompajler]
         |
         v
   SPIR-V bytecode  <-- Platformski nezavisan
         |
         v
  [GPU driver kompajler]
         |
         v
   Masinski kod za   <-- Specifican za konkretnu GPU arhitekturu
   konkretni GPU          (NVIDIA, AMD, Intel, Qualcomm...)

SPIR-V je standardizovan od strane Khronos Group-e i koristi se kao primarni format za Vulkan. Njegova prednost je portabilnost -- mozete kompajlirati HLSL ili GLSL u SPIR-V jednom, a zatim taj isti SPIR-V distribuirati na bilo koji GPU koji podrzava Vulkan.

Kako UE5 sve ovo hendluje: USF fajlovi i cross-kompilacija

Ovde dolazimo do kljucnog pitanja: ako imate igru koja treba da radi na Windows-u (DirectX 12), PlayStation 5, Xbox Series X, Nintendo Switch, iOS-u, i Android-u -- kako pisete shadere za sve te platforme?

Odgovor je: ne pisete ih rucno za svaku.

Unreal Engine 5 koristi sopstveni format shadera poznat kao USF (Unreal Shader Files) i USH (Unreal Shader Headers). Ovi fajlovi su pisani u jeziku koji je veoma blizak HLSL-u, ali sa Unreal-specificnim ekstenzijama i makroima.

Proces izgleda ovako:

Material Graph (Blueprint)     ili     Rucno pisan .usf/.ush fajl
         |                                        |
         v                                        v
┌─────────────────────────────────────────────────────────┐
│          UE5 Shader Compiler Framework                  │
│                                                         │
│   1. Material Editor generise HLSL-like kod              │
│   2. Preprocessing (makroi, #ifdef za platformu)        │
│   3. Cross-kompilacija u ciljni format:                 │
│      - DXBC/DXIL za DirectX 11/12                       │
│      - SPIR-V za Vulkan                                 │
│      - Metal IR za iOS/macOS                            │
│      - PSSL za PlayStation                               │
│      - NVM za Nintendo Switch                           │
│      - GLSL ES za Android (OpenGL ES)                   │
└─────────────────────────────────────────────────────────┘
         |
         v
   Platforma-specifican shader bytecode

Ovo je jedan od najvecih inzenjerskih poduhvata unutar UE5. Kompajler mora da razume razlike izmedju platformi (razliciti limiti na broj registara, razlicite teksturne operacije, razliciti modeli memorije) i da generise optimalan kod za svaku.

Za vecinu korisnika Unreal Engine-a, ovo je potpuno transparentno -- vi radite u Material Editor-u, kreirate node-ove, i engine se brine o svemu ostalom. Ali kada naidjete na problem sa performansama, ili kada treba da napisete custom shader, razumevanje ovog procesa postaje neophodno.

Pozicija USF fajlova u UE5 projektu:

Engine/Shaders/Private/     -- Interni engine shaderi
Engine/Shaders/Public/      -- Javno dostupni shader header-i
[VasProjekat]/Shaders/      -- Vasi custom shaderi (ako ih imate)

Neki od najvaznijih internih shader fajlova:


17.3 Shader stages -- faze u pipeline-u

U poglavlju 07 smo prosli kroz render pipeline na visokom nivou. Sada cemo detaljno objasniti svaku programabilnu fazu, sa fokusom na to sta shader u svakoj fazi radi, sta prima kao ulaz, i sta proizvodi kao izlaz.

17.3.1 Vertex Shader

Vertex shader se izvrsava jednom za svaki vertex u geometriji koja se crta. To je prva programabilna faza u tradicionalnom pipeline-u.

Ulaz:

Vertex shader prima podatke koji su upakovani u vertex buffer -- strukturu podataka koju CPU priprema i salje GPU-u pre draw call-a. Tipicni podaci po vertex-u:

Podatak Tip Opis
Position float3 Pozicija vertex-a u lokalnom (object) prostoru
Normal float3 Normala povrsine u tom vertex-u
Tangent float4 Tangenta (za normal mapping, w komponenta cesto cuva handedness)
TexCoord0 float2 Primarni UV set
TexCoord1 float2 Sekundarni UV set (npr. za lightmape)
Color float4 Vertex color (RGBA)
BoneIndices uint4 Indeksi kostiju (za skeletal mesh-eve)
BoneWeights float4 Tezine kostiju (za skeletal mesh-eve)

Sta radi:

Primarni zadatak vertex shadera je transformacija pozicije iz lokalnog prostora u clip space (prostor za clipping). To je ona famozna Model-View-Projection transformacija iz poglavlja 06:

// Pojednostavljen vertex shader
cbuffer TransformData : register(b0)
{
    float4x4 WorldMatrix;          // Local -> World
    float4x4 ViewProjectionMatrix; // World -> Clip Space
};

struct VSInput
{
    float3 Position : POSITION;
    float3 Normal   : NORMAL;
    float2 TexCoord : TEXCOORD0;
};

struct VSOutput
{
    float4 ClipPosition : SV_POSITION; // Obavezan izlaz -- pozicija u clip space
    float3 WorldNormal  : NORMAL;      // Normala u world space (za osvetljenje)
    float3 WorldPos     : TEXCOORD1;   // Pozicija u world space (za osvetljenje)
    float2 TexCoord     : TEXCOORD0;   // Prosledjene UV koordinate
};

VSOutput MainVS(VSInput input)
{
    VSOutput output;
    
    // Transformacija pozicije: Local -> World -> Clip
    float4 worldPos = mul(float4(input.Position, 1.0), WorldMatrix);
    output.ClipPosition = mul(worldPos, ViewProjectionMatrix);
    
    // Transformacija normale u world space (samo rotacioni deo matrice)
    output.WorldNormal = mul(input.Normal, (float3x3)WorldMatrix);
    
    // Prosledjivanje podataka za pixel shader
    output.WorldPos = worldPos.xyz;
    output.TexCoord = input.TexCoord;
    
    return output;
}

Pored transformacije, vertex shader moze da radi i:

Izlaz:

Vertex shader mora da proizvede SV_POSITION -- poziciju vertex-a u clip space-u. Sve ostalo su opcioni izlazi koji se proslegjuju sledecoj fazi (obicno pixel shaderu), gde se interpoliraju preko trougla (o interpolatorima detaljno u sekciji 17.5).

Kljucna stvar za performanse: Vertex shader se izvrsava jednom po vertex-u. Ako imate model sa 100.000 vertex-a, shader se poziva 100.000 puta. Ovo zvuci kao mnogo, ali za moderan GPU koji ima hiljade jezgara, 100.000 vertex-a je trivijalan posao. Vertex shader retko biva bottleneck u modernim igrama -- skoro uvek je pixel shader taj koji dominira.

17.3.2 Hull Shader i Domain Shader (Tessellation)

Tessellation je proces deljenja geometrije na sitnije trouglove u realnom vremenu, na samom GPU-u. Umesto da posaljete model sa milion trouglova, mozete poslati model sa 10.000 trouglova i reci GPU-u "podeli svaki trougao na 16 manjih, i pomeri nove vertex-e prema displacement mapi".

Tessellation se odvija u tri koraka, od kojih su dva programabilna:

Vertex Shader
     |
     v
Hull Shader (programabilan) -- Kontrolise koliko ce se svaki "patch" podeliti
     |
     v
Tessellator (fiksna funkcija) -- Fizicki deli geometriju prema instrukcijama
     |
     v
Domain Shader (programabilan) -- Izracunava poziciju svakog novog vertex-a
     |
     v
(Geometry Shader, opciono)
     |
     v
Rasterizacija

Hull Shader prima takozvani "patch" -- grupu vertex-a koji definisu povrsinu (tipicno trougao ili quad). Njegova uloga je da odredi tessellation faktor -- koliko treba podeliti svaku ivicu patch-a. Ovo se obicno bazira na udaljenosti od kamere: blizi patch-evi dobijaju veci faktor (vise detalja), dalji manji (manje detalja, jer razlika ionako nije vidljiva).

// Hull Shader -- odredjuje tessellation faktor
struct HSConstOutput
{
    float EdgeFactors[3]  : SV_TessFactor;         // Koliko podeliti svaku ivicu
    float InsideFactor    : SV_InsideTessFactor;    // Koliko podeliti unutrasnjost
};

HSConstOutput PatchConstFunc(InputPatch<VSOutput, 3> patch)
{
    HSConstOutput output;
    
    // Primer: tessellation na osnovu udaljenosti od kamere
    float dist = distance(CameraPosition, 
                          (patch[0].WorldPos + patch[1].WorldPos + patch[2].WorldPos) / 3.0);
    
    // Blize kameri = veci faktor, dalje = manji
    float factor = clamp(MaxTessFactor / (dist * 0.1), 1.0, MaxTessFactor);
    
    output.EdgeFactors[0] = factor;
    output.EdgeFactors[1] = factor;
    output.EdgeFactors[2] = factor;
    output.InsideFactor   = factor;
    
    return output;
}

Domain Shader se poziva za svaki novogenerisani vertex. Dobija baricentricne koordinate (gde se novi vertex nalazi unutar originalnog patch-a) i treba da izracuna finalnu poziciju, normalu, UV koordinate, itd.

// Domain Shader -- izracunava poziciju novog vertex-a
[domain("tri")]
DSOutput MainDS(
    HSConstOutput patchConst,
    float3 baryCoords : SV_DomainLocation,
    const OutputPatch<HSOutput, 3> patch)
{
    DSOutput output;
    
    // Interpoliraj poziciju koristeci baricentricne koordinate
    float3 pos = baryCoords.x * patch[0].WorldPos
               + baryCoords.y * patch[1].WorldPos
               + baryCoords.z * patch[2].WorldPos;
    
    // Interpoliraj UV koordinate
    float2 uv = baryCoords.x * patch[0].TexCoord
              + baryCoords.y * patch[1].TexCoord
              + baryCoords.z * patch[2].TexCoord;
    
    // Displacement: pomeri vertex duz normale prema displacement mapi
    float3 normal = normalize(baryCoords.x * patch[0].WorldNormal
                            + baryCoords.y * patch[1].WorldNormal
                            + baryCoords.z * patch[2].WorldNormal);
    
    float displacement = DisplacementMap.SampleLevel(LinearSampler, uv, 0).r;
    pos += normal * displacement * DisplacementScale;
    
    // Transformisi u clip space
    output.ClipPosition = mul(float4(pos, 1.0), ViewProjectionMatrix);
    output.WorldPos = pos;
    output.WorldNormal = normal;
    output.TexCoord = uv;
    
    return output;
}

Kada koristiti tessellation:

Kada NE koristiti tessellation:

U Unreal Engine 5, Nanite u velikoj meri zamenjuje potrebu za tessellation-om, jer Nanite dinamicki kontrolise nivo detalja geometrije. Ipak, tessellation i dalje ima mesta za efekte poput displacement-a u materijalima, posebno kada Nanite nije primenjiv (npr. na skeletal mesh-evima).

17.3.3 Geometry Shader

Geometry shader se poziva jednom za svaki primitiv (trougao, liniju, tacku) nakon vertex shadera (i tessellation-a, ako je aktivan). Za razliku od vertex shadera koji radi sa jednim vertex-om, geometry shader vidi ceo primitiv -- sva tri vertex-a trougla, na primer.

Geometry shader moze da:

// Geometry Shader -- primer: wireframe renderovanje
// Za svaki trougao, emituje tri linije (ivice trougla)
[maxvertexcount(6)]
void MainGS(triangle GSInput input[3], inout LineStream<GSOutput> outputStream)
{
    // Emituj tri linije (svaka ima dva vertex-a)
    for (int i = 0; i < 3; i++)
    {
        GSOutput v0, v1;
        v0.Position = input[i].ClipPosition;
        v0.Color = float4(1, 1, 1, 1); // bela linija
        
        v1.Position = input[(i + 1) % 3].ClipPosition;
        v1.Color = float4(1, 1, 1, 1);
        
        outputStream.Append(v0);
        outputStream.Append(v1);
        outputStream.RestartStrip();
    }
}

Vazno upozorenje: Geometry shader je u praksi retko koriscen i generalno se smatra losim za performanse na modernom hardveru. Razlog:

  1. Serijalizacija: Geometry shader mora da proizvede promenljiv broj izlaza, sto otezava paralelizaciju
  2. Memory pressure: Izlaz geometry shadera mora da se zapise u memoriju pre nego sto rasterizer moze da ga koristi
  3. Nedostatak hardverske optimizacije: GPU proizvodjaci nisu ulagali u optimizaciju geometry shadera, jer je industrijsko resenje otislo u drugom smeru (mesh shaderi)

U Unreal Engine 5, geometry shader se koristi samo u veoma specificnim slucajevima (npr. neke debug vizualizacije). Za stvari poput particle-a, UE5 koristi GPU compute i indirect draw umesto geometry shadera.

17.3.4 Pixel Shader (Fragment Shader)

Evo ga -- radni konj rendering pipeline-a. Pixel shader (u OpenGL terminologiji "fragment shader") se izvrsava jednom za svaki fragment (potencijalni piksel) koji rasterizer generise.

Kada rasterizer odredi da trougao pokriva odredjeni piksel na ekranu, on poziva pixel shader za taj piksel. Pixel shader izracunava boju tog piksela.

Ulaz:

Pixel shader prima podatke koje je vertex shader (ili domain shader) proizveo kao izlaz, ali interpolirane preko povrsine trougla. Ako vertex A ima normalu (0, 1, 0) i vertex B ima normalu (1, 0, 0), piksel na pola puta izmedju njih ce dobiti normalu priblizno (0.5, 0.5, 0) (baricentricna interpolacija -- vise o tome u sekciji 17.5).

Sta radi:

U modernom PBR renderovanju (poglavlje 11), pixel shader obicno radi sledece:

  1. Sampluje teksture -- albedo, normal map, roughness, metalness, AO, emissive...
  2. Izracunava parametre osvetljenja -- normala (obicno iz normal mape), roughness, metalness
  3. Izracunava BRDF -- Cook-Torrance ili slicne jednacine
  4. Akumulira osvetljenje -- za svako svetlo u sceni, izracunava doprinos
  5. Primenjuje globalno osvetljenje -- IBL, refleksije, ambient
  6. Proizvodi finalnu boju

U deferred rendering pipeline-u (koji UE5 koristi kao primarni), pixel shader u base pass-u ne racuna osvetljenje direktno. Umesto toga, on pise G-Buffer podatke:

// UE5 Base Pass Pixel Shader (veoma pojednostavljen koncept)
struct GBufferOutput
{
    float4 GBufferA : SV_TARGET0; // World Normal (RGB) + Per-Object Data (A)
    float4 GBufferB : SV_TARGET1; // Metallic (R), Specular (G), Roughness (B), Shading Model (A)
    float4 GBufferC : SV_TARGET2; // Base Color (RGB) + AO (A)
    float4 GBufferD : SV_TARGET3; // Custom Data (zavisi od shading modela)
};

GBufferOutput BasePassPS(PSInput input)
{
    GBufferOutput output;
    
    // Sample teksture
    float3 baseColor = BaseColorTex.Sample(Sampler, input.TexCoord).rgb;
    float  metallic  = MetallicTex.Sample(Sampler, input.TexCoord).r;
    float  roughness = RoughnessTex.Sample(Sampler, input.TexCoord).r;
    float3 normal    = NormalMapTex.Sample(Sampler, input.TexCoord).rgb * 2.0 - 1.0;
    
    // Transformisi normalu iz tangent space u world space
    float3 worldNormal = TangentToWorld(normal, input.WorldTangent, 
                                         input.WorldBinormal, input.WorldNormal);
    
    // Upisi u G-Buffer
    output.GBufferA = float4(EncodeNormal(worldNormal), 0);
    output.GBufferB = float4(metallic, 0.5, roughness, EncodeShadingModel(SHADING_MODEL_DEFAULT_LIT));
    output.GBufferC = float4(baseColor, 1.0);
    output.GBufferD = float4(0, 0, 0, 0);
    
    return output;
}

Tek u drugom prolazu (lighting pass), drugi pixel shader cita G-Buffer podatke i racuna finalno osvetljenje. Ovo je sustina deferred renderinga o kome detaljno govorimo u poglavlju 22.

Zasto je pixel shader bottleneck:

Pixel shader se izvrsava za svaki vidljivi piksel na ekranu. Na 1920x1080 rezoluciji, to je preko 2 miliona piksela. Na 4K (3840x2160), to je preko 8 miliona. Pomnozite to sa 60 FPS i dobijate:

A to je bez overdraw-a (situacije kada vise trouglova pokriva isti piksel, pa se pixel shader pozove visestruko za isti piksel). Sa overdraw faktorom od 2-3x (koji je uobicajen), brojke se dupliraju ili tripliraju.

Zato je svaka instrukcija u pixel shaderu kriticna. Jedna nepotrebna teksturna operacija, jedno nepotrebno grananje, jedan nepotreban izracunaj -- pomnozeni sa stotinama miliona poziva u sekundi -- mogu da kostaju milisekunde frame time-a.

17.3.5 Compute Shader

Compute shader je fundamentalno drugaciji od svih prethodnih shadera. On ne pripada tradicionalnom grafickom pipeline-u. Nema vertex-e, nema trouglove, nema piksele, nema rasterizaciju. Compute shader je genericki GPU program koji moze da radi bilo sta.

Compute shader se izvrsava u takozvanim thread grupama (workgroups). Vi definisete koliko thread-ova imate u jednoj grupi (npr. 8x8x1 = 64 thread-a), i koliko grupa zelite da pokrenete (dispatch).

// Compute Shader -- primer: Gaussian blur na teksturi
// Svaki thread obradjuje jedan piksel

// Ulazna i izlazna tekstura
Texture2D<float4>   InputTexture  : register(t0);
RWTexture2D<float4> OutputTexture : register(u0);  // RW = Read/Write

// Parametri blura
cbuffer BlurParams : register(b0)
{
    float2 TextureSize;
    float  BlurRadius;
};

// Thread grupa: 8x8 thread-ova (64 ukupno)
[numthreads(8, 8, 1)]
void MainCS(uint3 threadID : SV_DispatchThreadID)
{
    // threadID.xy je pozicija piksela koji ovaj thread obradjuje
    if (threadID.x >= (uint)TextureSize.x || threadID.y >= (uint)TextureSize.y)
        return; // Izvan granica teksture
    
    float4 sum = float4(0, 0, 0, 0);
    float weightSum = 0.0;
    
    // Gaussian kernel
    for (int y = -(int)BlurRadius; y <= (int)BlurRadius; y++)
    {
        for (int x = -(int)BlurRadius; x <= (int)BlurRadius; x++)
        {
            int2 samplePos = (int2)threadID.xy + int2(x, y);
            samplePos = clamp(samplePos, int2(0, 0), (int2)TextureSize - 1);
            
            float weight = exp(-(x*x + y*y) / (2.0 * BlurRadius * BlurRadius));
            sum += InputTexture[samplePos] * weight;
            weightSum += weight;
        }
    }
    
    OutputTexture[threadID.xy] = sum / weightSum;
}

Gde se compute shader koristi u UE5:

Compute shaderi su izuzetno vazni u modernom renderovanju. Sve vise posla se prebacuje iz tradicionalnog rasterization pipeline-a u compute shadere, jer nude vecu fleksibilnost i cesto bolji paralelizam.

Kljucna razlika: Compute shader nema implicitan pristup rasterizaciji. Ako zelite da generisete sliku, morate rucno da izracunate koordinate piksela i napisete rezultat u RWTexture. Ali taj rucni pristup vam daje potpunu kontrolu nad rasporedom thread-ova, pristupom memoriji, i medjusobnom komunikacijom thread-ova unutar grupe (preko groupshared memorije).

17.3.6 Mesh Shader (kratko)

Mesh shader je najnovija inovacija u pipeline-u (uvedena sa NVIDIA Turing arhitekturom i podrzana u DirectX 12 Ultimate). To je moderan zamena za tradicionalan vertex/geometry pipeline.

Umesto da GPU cita vertex-e i index-e iz buffera, mesh shader sam generise trouglove. On kombinuje funkcionalnost vertex shadera, hull shadera, i geometry shadera u jednu fazu.

Pipeline sa mesh shader-om izgleda ovako:

Stari pipeline:              Novi pipeline:
                             
Vertex Buffer ──>            Task Shader (opcion.)──>
Vertex Shader ──>            Mesh Shader ──>
Hull Shader ──>              Rasterizer ──>
Tessellator ──>              Pixel Shader
Domain Shader ──>            
Geometry Shader ──>          
Rasterizer ──>               
Pixel Shader                 

Prednosti mesh shadera:

Unreal Engine 5 koristi mesh shader pipeline unutar Nanite sistema za dinamicko upravljanje geometrijom. Nanite je jednan od primera gde mesh shaderi zaista bliste -- omogucavaju crtanje scena sa milijardama trouglova jer GPU sam odlucuje koji trouglovi su potrebni.

Mesh shaderi su jos uvek relativno novi i nisu podrzani na svim platformama, ali predstavljaju buducnost grafickog pipeline-a.


17.4 Uniforms i Constant Buffers

Problem: kako shader dobija podatke sa CPU-a?

Shader se izvrsava na GPU-u, ali mnogi podaci koje koristi dolaze sa CPU-a: pozicija kamere, matrice transformacije, boja svetla, vreme, parametri materijala... Ovi podaci se menjaju od frejma do frejma (ili od draw call-a do draw call-a), ali su konstantni unutar jednog draw call-a -- svi vertex-i i pikseli jednog objekta koriste istu matricu, istu poziciju kamere.

Ovi podaci se u shader terminologiji nazivaju uniforms (OpenGL terminologija) ili constant buffers (DirectX/HLSL terminologija). Suština je ista: podaci koji su konstantni za sve thread-ove unutar jednog poziva shadera.

Constant Buffers u HLSL-u

// Definicija constant buffer-a
cbuffer PerFrameData : register(b0)   // register(b0) = binding slot 0
{
    float4x4 ViewMatrix;              // 64 bajta
    float4x4 ProjectionMatrix;        // 64 bajta
    float4x4 ViewProjectionMatrix;    // 64 bajta
    float3   CameraPosition;          // 12 bajtova + 4 padding = 16
    float    Time;                    // 4 bajta + 12 padding = 16
    float3   SunDirection;            // 12 bajtova + 4 padding = 16
    float3   SunColor;                // 12 bajtova + 4 padding = 16
};

cbuffer PerObjectData : register(b1)  // binding slot 1
{
    float4x4 WorldMatrix;            // 64 bajta
    float4x4 WorldInverseTranspose;  // 64 bajta (za transformaciju normala)
};

cbuffer MaterialData : register(b2)   // binding slot 2
{
    float4 BaseColor;                 // 16 bajtova
    float  Roughness;                 // 4 bajta
    float  Metallic;                  // 4 bajta
    float  EmissiveStrength;          // 4 bajta
    float  Padding;                   // 4 bajta (padding do 16)
};

Binding slots i organizacija

Constant buffer-i se vezuju za odredjene slot-ove (register-e). GPU ima ogranicen broj slot-ova za constant buffer-e (tipicno 14-16 za DirectX 11, prakticno neogranicen za DirectX 12 sa root signatures).

Kljucna praksa je grupisanje po frekvenciji promene:

Slot b0: PerFrameData     -- menja se jednom po frejmu
                              (kamera, vreme, globalna svetla)

Slot b1: PerViewData      -- menja se po pogledu (stereo rendering, shadow pass)
                              (view matrica, projekcija za specifican pogled)

Slot b2: PerObjectData    -- menja se po objektu (draw call-u)
                              (world matrica, object ID)

Slot b3: MaterialData     -- menja se po materijalu
                              (boja, roughness, metallic, parametri)

Zasto je ova organizacija bitna? Zato sto promena constant buffer-a ima cenu. Kada CPU salje nov constant buffer GPU-u, to je transfer podataka preko bus-a (PCIe). Ako grupisete podatke po frekvenciji promene, mozete da minimizujete broj ovih transfera:

Padding i alignment pravila

Constant buffer-i imaju stroga alignment pravila. Na DirectX platformama:

Ovo znaci da naivno pakovanje podataka moze da protraci memoriju:

// LOSE pakovanje -- trosi vise memorije nego sto je potrebno
cbuffer BadPacking : register(b0)
{
    float  A;       // offset 0,  size 4
    float3 B;       // offset 16, size 12 (mora poceti na novom 16-byte redu!)
    float  C;       // offset 28, size 4  (nastavlja se u istom redu)
    float4 D;       // offset 32, size 16
    // Ukupno: 48 bajtova, ali imamo 12 bajtova "rupa"
};

// DOBRO pakovanje -- reorganizovano da minimizuje padding
cbuffer GoodPacking : register(b0)
{
    float3 B;       // offset 0,  size 12
    float  A;       // offset 12, size 4  (staje u isti 16-byte red)
    float  C;       // offset 16, size 4
    float  pad1;    // offset 20, size 4
    float  pad2;    // offset 24, size 4
    float  pad3;    // offset 28, size 4
    float4 D;       // offset 32, size 16
    // Ukupno: 48 bajtova, ali jasnije i predvidljivije
};

U praksi, UE5 se brine o pakovanju za vas -- Material Editor automatski generise optimalno upakovane constant buffer-e. Ali ako pisete custom shadere, ovo morate razumeti.

Kako to izgleda u UE5

U Unreal Engine 5, constant buffer-i se definisu kroz C++ kod koristeci makroe:

// C++ strana -- definicija constant buffer-a
BEGIN_GLOBAL_SHADER_PARAMETER_STRUCT(FMyShaderParameters, )
    SHADER_PARAMETER(FVector4f, BaseColor)
    SHADER_PARAMETER(float, Roughness)
    SHADER_PARAMETER(float, Metallic)
    SHADER_PARAMETER(FMatrix44f, WorldMatrix)
    SHADER_PARAMETER_TEXTURE(FTexture2DRHIRef, BaseColorTexture)
    SHADER_PARAMETER_SAMPLER(FSamplerStateRHIRef, BaseColorSampler)
END_GLOBAL_SHADER_PARAMETER_STRUCT()

Engine automatski generiše odgovarajuci HLSL kod i upravlja upload-om podataka na GPU. Ovo je deo RHI (Rendering Hardware Interface) sloja koji apstrahuje razlike izmedju grafickih API-ja.


17.5 Varyings -- interpolatori izmedju faza

Sta su varyings?

Kada vertex shader proizvede izlazne podatke (normalu, UV koordinate, boju, poziciju u world space-u), ti podaci se proslegjuju pixel shaderu. Ali tu nastaje zanimljiv problem: vertex shader se izvrsava za vertex-e, a pixel shader za piksele. Piksel koji se nalazi unutar trougla nema "svoj" vertex -- nalazi se negde izmedju tri vertex-a.

Resenje je interpolacija. GPU automatski interpolira sve izlaze vertex shadera preko povrsine trougla koristeci baricentricne koordinate. Ovi interpolirani podaci se u razlicitim kontekstima nazivaju:

Kako interpolacija radi

Recimo da imate trougao sa tri vertex-a: V0, V1, V2. Svaki vertex ima boju:

V0: Crvena  (1, 0, 0)
V1: Zelena  (0, 1, 0)
V2: Plava   (0, 0, 1)

Piksel koji se nalazi tacno u centru trougla ima baricentricne koordinate (1/3, 1/3, 1/3), pa ce dobiti interpoliranu boju:

Boja piksela = (1/3) * V0.boja + (1/3) * V1.boja + (1/3) * V2.boja
             = (1/3)(1,0,0) + (1/3)(0,1,0) + (1/3)(0,0,1)
             = (0.33, 0.33, 0.33)
             = siva

Piksel blize vertex-u V0 (npr. baricentricne koordinate 0.8, 0.1, 0.1) ce dobiti boju blizu crvene:

Boja = 0.8*(1,0,0) + 0.1*(0,1,0) + 0.1*(0,0,1)
     = (0.8, 0.1, 0.1)
     = pretezno crvena

Ova interpolacija se primenjuje na SVE izlaze vertex shadera -- pozicije, normale, UV koordinate, tangente, boje, custom podatke. GPU ovo radi automatski, u hardveru, bez ikakve cene u smislu shader instrukcija.

Perspektivno-korektna interpolacija

Postoji jedan subtilan detalj. Obicna linearna interpolacija bi dala pogresne rezultate za teksture na trouglovima koji su pod uglom prema kameri (perspektiva bi bila deformisana -- setite se starih PS1 igara gde su se teksture "kretale" na poligonima).

Moderan GPU automatski koristi perspektivno-korektnu interpolaciju -- deli svaki interpolirani atribut sa dubinom (w koordinatom) pre interpolacije, a zatim ponovo mnozi sa dubinom u pixel shaderu. Ovo je besplatno na modernom hardveru i daje korektne rezultate.

Mozete eksplicitno traziti linearnu (ne perspektivno-korektnu) interpolaciju koristeci noperspective kvalifikator u HLSL-u, ali to je veoma retko potrebno.

Ogranicen broj interpolatora

Evo gde stvari postaju prakticno vazne: broj interpolatora je ogranicen.

Svaki izlaz vertex shadera koji treba da stigne do pixel shadera zauzima jedan ili vise "interpolator slot-ova". Na vecini modernog hardvera:

Platforma / Shader Model Maksimalan broj interpolatora
DirectX 11 / SM 5.0 32 float4 registra (= 128 float vrednosti)
DirectX 12 / SM 6.x 32 float4 registra
OpenGL ES 3.0 (mobilni) 16 float4 registra (= 64 float vrednosti)
Metal (iOS) 31 float4 registra

Ovo zvuci kao mnogo, ali pogledajte koliko podataka tipican UE5 materijal treba da prosledi iz vertex u pixel shader:

Pozicija u world space        : float3 (3 floata)
Normala u world space         : float3 (3)
Tangent u world space         : float4 (4, sa handedness)
UV0                           : float2 (2)
UV1 (lightmap UVs)            : float2 (2)
Vertex Color                  : float4 (4)
Fog data                      : float4 (4)
SV_POSITION (ne broji se)     : (sistemski, ne zauzima interpolator slot)
                              --------
Ukupno:                       22 float-a = 5.5 float4 registra (zauzima 6)

To je vec 6 od 32 registra za minimalan materijal. Kompleksni materijali sa vise UV setova, world position offset-om, per-vertex custom podacima, mogu lako pojesti 15-20 registara.

Strategije pakovanja interpolatora

Kada se priblizavate limitu, mozete koristiti pakovanje -- stavljanje vise podataka u jedan float4:

// Bez pakovanja -- trosi 3 interpolator slota
struct VSOutput
{
    float4 ClipPos   : SV_POSITION;
    float2 UV0       : TEXCOORD0;  // 1 slot (float2 zauzima ceo float4 slot)
    float2 UV1       : TEXCOORD1;  // 1 slot
    float2 UV2       : TEXCOORD2;  // 1 slot
};

// Sa pakovanjem -- trosi 2 slota (ili cak 1.5)
struct VSOutput_Packed
{
    float4 ClipPos    : SV_POSITION;
    float4 UV01       : TEXCOORD0;  // 1 slot: UV0.xy u .xy, UV1.xy u .zw
    float2 UV2        : TEXCOORD1;  // 0.5 slota (ali zauzima ceo slot)
};

U UE5, engine automatski pakuje interpolatore i pokusava da minimizuje njihov broj. Ali ako dodajete mnogo custom podataka kroz Custom Expressions u Material Editor-u, mozete da prekoracite limit -- i dobicete crypticnu gresku kompilacije.

Flat interpolation i centroid sampling

Pored standardne smooth (baricentricne) interpolacije, HLSL podrzava i:

struct PSInput
{
    float4 Position  : SV_POSITION;
    float2 TexCoord  : TEXCOORD0;                    // Standardna interpolacija
    nointerpolation uint MaterialID : TEXCOORD1;      // Flat -- nema interpolacije
    centroid float2 LightmapUV : TEXCOORD2;           // Centroid sampling
};

17.6 Sampleri i teksture u shaderima

Teksture kao podaci za shadere

U poglavlju 05 detaljno smo govorili o teksturama -- sta su, kako se kreiraju, koji formati postoje. Sada cemo videti kako shaderi zapravo pristupaju tim teksturama.

Sa stanovista shadera, tekstura je read-only 2D (ili 3D, ili cube) niz podataka kome se pristupa preko UV koordinata. Ali pristup teksturi nije jednostavno "procitaj piksel na poziciji (x, y)". Postoji ceo sistem koji kontrolise kako se taj pristup obavlja.

Texture Objects

U HLSL-u, teksture se deklarisu kao globalni resursi:

// Razliciti tipovi tekstura
Texture2D<float4>      DiffuseMap   : register(t0);  // 2D tekstura, 4 kanala
Texture2D<float>       HeightMap    : register(t1);   // 2D tekstura, 1 kanal
TextureCube<float4>    EnvMap       : register(t2);   // Cubemap
Texture3D<float4>      VolumeMap    : register(t3);   // 3D tekstura
Texture2DArray<float4> TexArray     : register(t4);   // Niz 2D tekstura
Texture2DMS<float4>    MSAATarget   : register(t5);   // Multisampled tekstura

Svaki od ovih tipova ima razlicite metode pristupa i razlicite slucajeve koriscenja.

Sampler States

Sampler je objekat koji definise kako se tekstura sampluje. On kontrolise tri kljucna aspekta:

1. Filtering (filtriranje)

Sta se desava kada UV koordinata "padne" izmedju piksela u teksturi?

Texel grid (pikseli u teksturi):
+---+---+---+---+
| A | B | C | D |
+---+---+---+---+
| E | F | G | H |
+---+---+---+---+

UV koordinata pada na X koji je izmedju A, B, E, F:
+---+---+---+---+
| A . B | C | D |
+  X  . +---+---+     Sta je boja na X?
| E . F | G | H |
+---+---+---+---+
// Razliciti sampleri za razlicite potrebe
SamplerState PointSampler         : register(s0);  // Point, za lookup tabele
SamplerState BilinearSampler      : register(s1);  // Bilinear, standardno
SamplerState TrilinearSampler     : register(s2);  // Trilinear + mip blending
SamplerState AnisotropicSampler   : register(s3);  // Aniso 8x, za povrsine pod uglom

2. Address Mode (nacin adresiranja)

Sta se desava kada UV koordinata izadje van opsega [0, 1]?

U = 1.3 -- sta sad?

Wrap:   U = 0.3  (tekstura se ponavlja)    -- najcesci, za tile-ovane teksture
Clamp:  U = 1.0  (ostaje na ivici)          -- za teksture koje ne treba da se ponavljaju
Mirror: U = 0.7  (ogledalo)                -- za seamless ponavljanje
Border: pixel = BorderColor                 -- definisana boja van granica
// U HLSL-u, address mode je deo sampler stanja
// Engine/API strana (C++/Blueprint) kreira sampler sa zeljenim modom
// Shader samo koristi sampler:
float4 color = MyTexture.Sample(WrapSampler, uv);   // Wrap mode
float4 color2 = MyTexture.Sample(ClampSampler, uv); // Clamp mode

3. MIP Level selekcija

Kada samplujete teksturu, GPU automatski bira odgovarajuci MIP nivo na osnovu toga koliko se brzo UV koordinate menjaju izmedju susednih piksela (screen-space derivati, ddx/ddy). Ali mozete i rucno da kontrolisete MIP nivo:

// Automatska MIP selekcija (najcesci slucaj)
float4 color = tex.Sample(sampler, uv);

// Eksplicitno specificiran MIP nivo (ne koristi derivate)
float4 color = tex.SampleLevel(sampler, uv, mipLevel);

// Bias na automatski izabrani MIP (pozitivan = blurnije, negativan = ostrije)
float4 color = tex.SampleBias(sampler, uv, bias);

// Samplovanje sa eksplicitnim derivatima (korisno u compute shaderima)
float4 color = tex.SampleGrad(sampler, uv, ddx, ddy);

SampleLevel je vazno za compute shadere, jer compute shaderi nemaju koncept "susednog piksela" -- ne postoje automatski screen-space derivati. Ako koristite Sample u compute shaderu, dobicete gresku kompilacije.

Texture binding i limiti

Slicno kao constant buffer-i, teksture se vezuju za slot-ove (register-e u HLSL-u, oznaka t). GPU ima ogranicen broj slot-ova:

Platforma Max tekstura po shader fazi
DirectX 11 128 (t0-t127)
DirectX 12 (bindless) Prakticno neogranicen (bindless model)
OpenGL ES 3.0 16
Metal 31

DirectX 12 uvodi bindless pristup -- umesto da vezujete teksture za fiksne slot-ove, koristite "descriptor heap" i pristupate teksturama preko indeksa. Ovo je daleko fleksibilnije i eliminise overhead promene binding-a. UE5 sve vise koristi bindless pristup na platformama koje to podrzavaju.

Kako UE5 upravlja teksturama u shaderima

U Material Editor-u, kada dodate Texture Sample node, UE5 automatski:

  1. Deklarise Texture2D i SamplerState u generisanom shader kodu
  2. Dodeli binding slot
  3. Postavi sampler prema vasim podesavanjima u teksturnom asset-u (filtering, address mode)
  4. Upravlja MIP streaming-om (koje MIP nivoe da ucita u memoriju)

Vi ne morate da mislite o binding slot-ovima ili sampler stanjima u Material Editor-u -- samo povezete node-ove i sve radi. Ali ako pisete Custom HLSL Expression ili custom shader, ovo morate razumeti.


17.7 Grananje na GPU-u -- zasto je skupo

Podsetnik: SIMD izvrsavanje

Pre nego sto objasnimo zasto je grananje skupo, moramo se podsetiti kako GPU izvrsava shadere. Ovo smo detaljno obradili u poglavlju 08, ali hajde da osveziomo kljucne tacke.

GPU ne izvrsava shader za jedan piksel u izolaciji. Umesto toga, grupisE piksele u grupe koje se zovu:

Svi thread-ovi u jednom warp-u izvrsavaju istu instrukciju u istom trenutku. Ovo se zove SIMT (Single Instruction, Multiple Threads) model. Zamislite vojnu marsinsku formaciju -- svi vojnici koracaju u istom ritmu, levom-desnom-levom. Niko ne moze da koraca drugacije.

Warp od 32 thread-a:

Thread 0:  MUL r0, r1, r2    |
Thread 1:  MUL r0, r1, r2    |  Svi rade ISTU instrukciju
Thread 2:  MUL r0, r1, r2    |  u istom clock ciklusu,
Thread 3:  MUL r0, r1, r2    |  ali na RAZLICITIM podacima
...                           |
Thread 31: MUL r0, r1, r2    |

Ovo je izuzetno efikasno -- jedan instruction decoder, jedan kontrolni signal, a 32 ALU-a rade korisne proracune. Ali sta se desava kada dodjemo do if naredbe?

Problem: Warp divergencija

// Ovaj naizgled nevin kod moze da ubije performanse
if (someCondition)
{
    // Grana A: 15 instrukcija
    result = ComplexCalculation(input);
}
else
{
    // Grana B: 20 instrukcija
    result = DifferentCalculation(input);
}

Sta se desava ako je someCondition tacno za neke thread-ove u warp-u, a netacno za druge?

Setite se: svi thread-ovi u warp-u MORAJU da izvrsavaju istu instrukciju. Ne postoji nacin da thread 0 izvrsava granu A dok thread 1 izvrsava granu B istovremeno. GPU nema tu sposobnost.

Umesto toga, GPU radi sledece:

Korak 1: Evaluiraj uslov za sve thread-ove
  Thread 0:  someCondition = TRUE
  Thread 1:  someCondition = FALSE
  Thread 2:  someCondition = TRUE
  Thread 3:  someCondition = FALSE
  ...

Korak 2: Izvrsi granu A sa MASKIRANIM thread-ovima
  Thread 0:  AKTIVAN  -- izvrsava ComplexCalculation    [15 instrukcija]
  Thread 1:  MASKIRAN -- ne radi nista (ali ceka!)
  Thread 2:  AKTIVAN  -- izvrsava ComplexCalculation
  Thread 3:  MASKIRAN -- ne radi nista (ali ceka!)
  ...

Korak 3: Izvrsi granu B sa OBRNUTOM maskom
  Thread 0:  MASKIRAN -- ne radi nista (ali ceka!)
  Thread 1:  AKTIVAN  -- izvrsava DifferentCalculation  [20 instrukcija]
  Thread 2:  MASKIRAN -- ne radi nista (ali ceka!)
  Thread 3:  AKTIVAN  -- izvrsava DifferentCalculation
  ...

Korak 4: Spoji rezultate -- svaki thread ima svoj "result"

Vidite problem? Umesto da izvrsimo ili 15 ili 20 instrukcija, izvrsili smo 15 + 20 = 35 instrukcija. Obe grane su izvrsene, samo su rezultati jedne odbaceni za svaki thread. A maskirani thread-ovi su bukvalno srdeli, troseci elektricnu energiju i vreme na nicega.

U najgorem slucaju, ako samo jedan thread od 32 uzme drugu granu, ceo warp mora da izvrsi obe grane. Efikasnost pada na pola (ili gore).

Vizualizacija divergencije

Warp bez divergencije (SVI thread-ovi idu istom granom):
┌──────────────────────────────────┐
│ IF grana:  ████████████████████  │  100% efikasnost
│ ELSE grana: (preskocena)         │
└──────────────────────────────────┘
Vreme: 15 instrukcija

Warp sa divergencijom (50/50 podela):
┌──────────────────────────────────┐
│ IF grana:  ████....████....████  │  50% efikasnost
│ ELSE grana: ....████....████.... │  50% efikasnost
└──────────────────────────────────┘
Vreme: 15 + 20 = 35 instrukcija

Warp sa maksimalnom divergencijom (1 thread drugaciji):
┌──────────────────────────────────┐
│ IF grana:  ████████████████████  │  31/32 thread-ova aktivno
│ ELSE grana: ████                │  1/32 thread-ova aktivno
└──────────────────────────────────┘
Vreme: 15 + 20 = 35 instrukcija (isti cost kao 50/50!)

Kljucna stvar: nije bitno koliko thread-ova divergira. Cak i jedan jedini thread koji uzme drugu granu znaci da ceo warp mora da izvrsi obe grane. Cena je binarna -- ili je divergencije ima ili je nema.

Koherentno vs. nekoherentno grananje

Sada dolazimo do kljucnog pitanja: da li je svako grananje lose? Ne -- zavisi od toga koliko je grananje koherentno.

Koherentno grananje znaci da svi (ili vecina) thread-ova u warp-u uzimaju istu granu. U tom slucaju, GPU moze da preskoci drugu granu potpuno, i nema penala.

Primeri koherentnog grananja:

// Uslov zavisi od UNIFORM podatka (konstantan za ceo draw call)
if (bUseNormalMap)  // Svi thread-ovi UVEK idu istom granom
{
    normal = SampleNormalMap(uv);
}
else
{
    normal = input.WorldNormal;
}

// Uslov zavisi od podatka koji je koherentan u prostoru
// (susedni pikseli obicno imaju slicne vrednosti)
if (depth > FogStartDistance)  // Veliki, kontinualni regioni na ekranu
{
    color = ApplyFog(color, depth);
}

Nekoherentno grananje znaci da thread-ovi unutar istog warp-a uzimaju razlicite grane. Ovo se desava kada uslov zavisi od podatka koji je razlicit za svaki piksel na nepredvidiv nacin.

Primeri nekoherentnog grananja:

// Uslov zavisi od teksture sa ostrim detaljima
float mask = MaskTexture.Sample(sampler, uv).r;
if (mask > 0.5)  // Crno-bela maska sa ostrim prelazima -- divergencija!
{
    color = ExpensiveEffectA(input);
}
else
{
    color = ExpensiveEffectB(input);
}

// Uslov zavisi od per-pixel podatka sa velikim varijacijama
if (frac(input.WorldPos.x * 100.0) > 0.5)  // Checkerboard pattern -- maximalna divergencija!
{
    color *= 2.0;
}

Kada je grananje prihvatljivo

Na osnovu svega recenog, evo pravila:

  1. Grananje po uniform uslovu -- UVEK OK. Svi thread-ovi idu istom granom, nema divergencije. Primer: if (bEnableBloom), if (QualityLevel > 2).

  2. Grananje po prostorno koherentnom uslovu -- OBICNO OK. Ako se uslov menja glatko u prostoru (npr. dubina, udaljenost od kamere), vecina warp-ova ce biti koherentna. Samo warp-ovi na granici regiona ce divergirati.

  3. Grananje koje preskace SKUP kod -- moze biti OK cak i sa divergencijom, ako je cena racunanja jedne grane toliko velika da se isplati preskociti je za thread-ove koji je ne trebaju. Ovo je tzv. dynamic branching i GPU-ovi imaju hardversku podrsku za ovo.

  4. Grananje po per-pixel uslovu sa velikim varijacijama -- IZBEGIVATI. Ovo je scenario maksimalne divergencije.

Flattening (izravnavanje) grana

Alternativa grananju je flattening -- izracunajte oba rezultata i koristite uslov za selekciju:

// SA grananjem (moze divergirati)
float result;
if (condition)
    result = ExpensiveA(input);
else
    result = ExpensiveB(input);

// BEZ grananja (flattened) -- racuna oba, bira rezultat
float resultA = ExpensiveA(input);
float resultB = ExpensiveB(input);
float result = condition ? resultA : resultB;  // Ternary se obicno kompajlira 
                                                // u conditional move (CMOV), 
                                                // ne u branch

Flattening uvek radi oba izracunaja, ali nema divergenciju. Ovo je isplativo kada su obe grane jeftine. Ako su obe grane skupe, bolje je koristiti pravo grananje (dynamic branching) i prihvatiti divergenciju na granicama, jer ce vecina warp-ova biti koherentna i preskocice drugu granu.

Pravilo palca:

Moderni kompajleri (HLSL kompajler, driver kompajler) cesto sami donose ovu odluku. Mozete koristiti [branch] i [flatten] atribute u HLSL-u da eksplicitno kontrolisete ponasanje:

[branch]   // Forsira pravo grananje (dynamic branch)
if (condition)
{
    // Skup kod koji treba preskociti ako uslov nije ispunjen
}

[flatten]  // Forsira flattening (racuna obe grane, bira rezultat)
if (condition)
{
    // Jeftin kod gde je flattening bolji
}

Ugnjezdeno grananje -- eksponencijalni problem

Situacija postaje znacajno gora sa ugnjezdenim grananjem:

// Opasno ugnjezdeno grananje
if (conditionA)        // Potencijalna divergencija
{
    if (conditionB)    // Ponovo potencijalna divergencija
    {
        if (conditionC) // I opet...
        {
            // Samo thread-ovi gde su sva tri uslova TRUE stizu ovde
            // Ali CELA hijerarhija grana se morala izvrsiti za sve
        }
    }
}

Svaki nivo grananja potencijalno duplira cenu. Tri nivoa ugnjezdenog grananja sa nezavisnim uslovima mogu, u najgorem slucaju, da rezultuju u 8x sporijim izvrsavanjem (2^3 = 8 grana). U praksi je obicno bolje nego u najgorem slucaju, ali je dovoljno lose da se ugnjezdeno grananje treba izbegavati kad god je moguce.


17.8 Shader permutacije -- kombinatorna eksplozija

Sta je shader permutacija?

Zamislite da imate materijal sa sledecim opcijama:

To je 10 boolean opcija. Svaka moze biti TRUE ili FALSE. Koliko mogucih kombinacija imamo?

2^10 = 1.024 razlicitih shader varijanti.

Svaka od ovih varijanti je zasebna verzija shadera sa razlicitim kodom -- verzija bez normal mape uopste nema instrukcije za samplovanje i primenu normal mape, dok verzija sa normal mapom ima.

Ovo je problem shader permutacija -- kombinatorni eksplozija broja razlicitih shadera koje engine mora da kompajlira, cuva u memoriji, i ucitava po potrebi.

Zasto ne koristimo jedan shader sa if/else?

Na prvi pogled, resenje izgleda ocligledno: napisite jedan shader sa if naredbama za svaku opciju:

float4 MainPS(PSInput input) : SV_TARGET
{
    float3 normal = input.WorldNormal;
    
    if (bUseNormalMap)
        normal = ApplyNormalMap(input.TexCoord, input.Tangent);
    
    float3 baseColor = BaseColorParam;
    if (bUseBaseColorTexture)
        baseColor = BaseColorTex.Sample(Sampler, input.TexCoord).rgb;
    
    float3 emissive = 0;
    if (bUseEmissive)
        emissive = EmissiveTex.Sample(Sampler, input.TexCoord).rgb * EmissiveStrength;
    
    // ... 7 jos opcija sa if/else ...
    
    return float4(finalColor + emissive, alpha);
}

Problem: ako su bUseNormalMap, bUseEmissive, itd. uniform promenljive (konstantne za ceo draw call), onda nema divergencije -- svi thread-ovi idu istom granom. To je koherentno grananje, i na prvi pogled nema problem.

Ali postoji drugi problem: cena neiskoriscenih resursa. Cak i ako se grana ne izvrsava, shader i dalje mora da ima deklarisane sve teksture, sve samplere, sve constant buffer-e za sve moguce grane. To znaci:

Zato postoji alternativni pristup: staticke permutacije.

Staticke permutacije (#ifdef)

Umesto runtime grananja, koristite kompajler-time makroe:

float4 MainPS(PSInput input) : SV_TARGET
{
    #if USE_NORMAL_MAP
        float3 normal = ApplyNormalMap(input.TexCoord, input.Tangent);
    #else
        float3 normal = input.WorldNormal;
    #endif
    
    #if USE_BASE_COLOR_TEXTURE
        float3 baseColor = BaseColorTex.Sample(Sampler, input.TexCoord).rgb;
    #else
        float3 baseColor = BaseColorParam;
    #endif
    
    #if USE_EMISSIVE
        float3 emissive = EmissiveTex.Sample(Sampler, input.TexCoord).rgb * EmissiveStrength;
    #else
        float3 emissive = float3(0, 0, 0);
    #endif
    
    // ...
    
    return float4(finalColor + emissive, alpha);
}

Sada kompajler uklanja nekoristene grane potpuno. Verzija bez normal mape doslovno nema instrukcije za normal mapping -- ne zauzima registre, ne trosi instruction cache, ne trazi binding slot za normal mapu teksturu.

Ali: svaka kombinacija #ifdef-ova proizvodi zasebnu kompajliranu verziju shadera. Sa 10 opcija, to je 1.024 varijante. Svaka mora biti kompajlirana (traje vreme), sacuvana (trosi memoriju/disk), i ucitana po potrebi (trosi vreme ucitavanja).

Realni brojevi iz UE5 projekata

Ovo nije akademski problem. U realnim Unreal Engine 5 projektima:

Tipican AAA projekat:
- Broj materijala:                    500 - 5.000
- Prosecne permutacije po materijalu: 20 - 200
- Ukupan broj shader varijanti:       50.000 - 500.000+
- Vreme kompilacije svih shadera:     30 min - 5+ sati
- Velicina kompajliranih shadera:     2 - 20+ GB

Ovo je ogroman inzenjerski izazov. Zato UE5 ima sofisticirane sisteme za upravljanje permutacijama.

Kako UE5 upravlja permutacijama

UE5 koristi kombinaciju strategija:

1. Pametno definisanje permutacija

Ne mora svaka boolean opcija da generise permutaciju. Neke se mogu hendlati kroz dynamic branching bez znacajnog gubitka performansi:

// UE5 odredjuje za svaku opciju:
// "Da li je bolje napraviti permutaciju ili koristiti dynamic branch?"

STATIC SWITCH (generise permutaciju):
- Shading model (Default Lit vs Subsurface vs Unlit) -- fundamentalno razliciti kodovi
- Blend mode (Opaque vs Translucent vs Masked) -- razliciti pipeline putevi
- Tessellation On/Off -- menja celu strukturu pipeline-a

DYNAMIC BRANCH (jedan shader, runtime uslov):
- Quality level selekcija -- uniform uslov, koherentan
- Feature toggle za sitne razlike -- nije vredno permutacije

2. Shader sharing izmedju materijala

Ako dva materijala koriste isti set opcija (isti shading model, isti blend mode, slicnu strukturu), UE5 moze da deli kompajlirani shader izmedju njih.

3. On-demand kompilacija

UE5 ne kompajlira sve moguce permutacije unapred. Kompajlira samo one koje su zaista potrebne -- one koje se pojavljuju u sceni. Ako nikada ne koristite tessellation na nekom materijalu, permutacije sa tessellation-om se nikada ne kompajliraju.

4. Shader Permutation Reduction

UE5 5.0+ ima sisteme koji aktivno smanjuju broj permutacija:


17.9 Uber-shaderi

Koncept

Uber-shader (nekada se pise i "uber shader") je pristup u kome imate jedan veliki shader koji pokriva sve moguce funkcionalnosti, umesto mnogo manjih specijalizovanih shadera. Opcije se kontrolisu ili through staticke permutacije (#ifdef) ili dynamic branching (if/else).

Ime dolazi od nemackog prefiksa "uber" (iznad, preko), jer je ovaj shader "iznad" svih specijalizovanih shadera -- sadrzi ih sve.

Uber-shader vs. specijalizovani shaderi

Aspekt Uber-shader Specijalizovani shaderi
Broj shadera Jedan (ili mali broj sa permutacijama) Mnogo zasebnih shadera
Kompleksnost koda Visoka -- ogromni fajlovi Niska -- svaki shader radi jednu stvar
Performanse jednog shadera Potencijalno nize (nekoristeni kodovi, registri) Optimalne (samo potreban kod)
Vreme kompilacije Brze (manje ukupnih shadera) Sporije (mnogo varijanti)
Memorija na disku Manje Vise
GPU memorija Vise (veci shader, vise registara) Manje po shaderu, ali vise swap-ova
Odrzavanje koda Tesko (jedan fajl od 10.000+ linija) Lakse (modularni fajlovi)
State changes Manje (isti shader za razlicite materijale) Vise (svaki materijal ima svoj shader)

Kako UE5 pristupa ovom problemu

UE5 koristi hibridni pristup koji kombinuje prednosti oba sveta:

Material Graph generise shader kod.

Kada napravite materijal u Material Editor-u, UE5 ne koristi jedan fiksni uber-shader. Umesto toga, Material Editor generise HLSL kod specifican za vas materijal. Ali taj generisani kod koristi zajednicke template-ove i biblioteke funkcija.

Material Graph (vi kreirate)
         |
         v
Code Generation (engine generise HLSL)
         |
         v
Template Shader (MaterialTemplate.ush + vas kod)
         |
         v
Permutation Generation (#ifdef varijante)
         |
         v
Kompajliranje u GPU bytecode

Dakle, svaki materijal ima svoj shader, ali ti shaderi dele ogromnu kolicinu zajednickog koda (osvetljenje, BRDF, shadow sampling, fog, itd.) koji je definisan u engine-ovim .ush fajlovima.

Rezultat:

Ovo je razlog zasto Material Editor u UE5 moze da proizvede shadere koji su veoma blizu po performansama rucno pisanim HLSL shaderima. Engine doslovno pise HLSL za vas, ali pise samo ono sto je potrebno.

Kada uber-shader pristup ima smisla

Uber-shaderi i dalje imaju smisla u odredjenim kontekstima:

U UE5, neke od internih shader fajlova (kao sto je DeferredLightingCommon.ush) su u sustini uber-shaderi za osvetljenje -- jedan veliki blok koda koji hendluje sve tipove svetala, sve shading modele, i sve posebne slucajeve. Permutacije smanjuju ovaj kod, ali bazicna struktura je uber-shader.


17.10 Shader kompilacija

Sta znaci "kompajlirati shader"?

Shader izvorni kod (HLSL, GLSL) je human-readable tekst. GPU ne moze da ga izvrsava direktno. Mora da prodje kroz proces kompilacije koji ga pretvara u GPU bytecode (ili masinski kod) koji GPU razume.

Ovaj proces je slican kompilaciji C++ koda u izvrsni fajl, ali ima neke specificnosti.

Pipeline kompilacije shadera

HLSL izvorni kod (.hlsl, .usf)
         |
         v
┌─────────────────────────┐
│  Preprocessing          │  -- #include, #define, #ifdef obrada
│  (makro ekspanzija)     │  -- Generisanje permutacija
└─────────────────────────┘
         |
         v
┌─────────────────────────┐
│  Frontend kompajler     │  -- Parsiranje, sintaksna analiza
│  (FXC ili DXC)          │  -- Semanticka analiza, tipska provera
└─────────────────────────┘
         |
         v
┌─────────────────────────┐
│  Optimizer               │  -- Dead code elimination
│                          │  -- Constant folding, loop unrolling
│                          │  -- Register allocation
│                          │  -- Instruction scheduling
└─────────────────────────┘
         |
         v
┌─────────────────────────┐
│  Backend                 │  -- DXBC (DirectX 11 bytecode)
│  (target-specific)      │  -- DXIL (DirectX 12 IL)
│                          │  -- SPIR-V (Vulkan)
│                          │  -- Metal IR
└─────────────────────────┘
         |
         v
   Kompajlirani shader bytecode
         |
         v (runtime, na korisnikovom racunaru)
┌─────────────────────────┐
│  GPU Driver Compiler    │  -- Pretvara bytecode u masinski kod
│                          │     specifican za korisnikov GPU
│  (NVIDIA, AMD, Intel    │  -- Finalna optimizacija za konkretnu
│   driver)               │     arhitekturu
└─────────────────────────┘
         |
         v
   Nativan GPU masinski kod (ISA)

Obratite paznju na dva nivoa kompilacije:

  1. Offline kompilacija (u razvojnom okruzenju ili pri cooking-u): HLSL -> bytecode/IL
  2. Runtime kompilacija (na korisnikovom racunaru, od strane GPU drivera): bytecode/IL -> nativan GPU kod

Ovo je razlog zasto GPU driveri moraju da budu azurni -- oni sadrze kompajler koji pretvara genericki bytecode u optimalan kod za konkretnu GPU arhitekturu. Novi driver moze poboljsati performanse postojecih igara jer ima bolji kompajler.

Offline kompilacija -- Cook time

Kada "cook-ujete" (pakujete) UE5 projekat za distribuciju, engine kompajlira sve potrebne shadere u GPU bytecode. Ovaj proces moze da traje satima za velike projekte.

Tipicna vremena kompilacije shadera (UE5, AAA projekat):

Prva kompilacija (svi shaderi):     2 - 8 sati
Inkrementalna (posle promene
  jednog materijala):               30 sekundi - 5 minuta
Pun recook posle engine update-a:   3 - 10 sati

UE5 paralelizuje kompilaciju shadera koristeci sve dostupne CPU jezgre. Engine takodje implementira Shader Derived Data Cache (DDC) -- kes kompajliranih shadera koji se moze deliti izmedju clanova tima preko mreze. Ako je kolega vec kompajlirao isti shader, vi ga preuzimate iz DDC-a umesto da ponovo kompajlirate.

Runtime kompilacija i "hitching"

Evo problema sa kojim se igraci srecur: shader compilation stutter (zastajkivanje usled kompilacije shadera).

Sta se desava:

  1. Igrac ulazi u novu oblast / vidi novi efekat po prvi put
  2. Engine detektuje da mu treba shader permutacija koju jos nema u memoriji
  3. GPU driver mora da kompajlira bytecode u nativan kod
  4. Ova kompilacija traje 10-500 ms
  5. Za to vreme, igra "zamrzne" -- nastaje vidljivi stutter

Ovo je posebno izrazeno pri prvom pokretanju igre, ili kada igrac vidi nove materijale/efekte.

PSO (Pipeline State Objects) i PSO Caching

Pipeline State Object (PSO) je koncept iz DirectX 12 i Vulkan-a. On obuhvata kompletno stanje grafickog pipeline-a: koji shaderi se koriste, koji blend mode, koji depth test, koji rasterizer state, format render target-a, itd. GPU driver kompajlira nativan kod za svaki specifican PSO, ne za shader u izolaciji.

PSO = Vertex Shader + Pixel Shader + Blend State + Depth State + 
      Rasterizer State + Render Target Format + Input Layout + ...

Promena BILO CEGA u ovoj kombinaciji = NOV PSO = potencijalna kompilacija

UE5 upravlja ovim kroz PSO Caching -- sistem koji:

  1. Prikuplja PSO podatke tokom development-a i QA testiranja
  2. Generise PSO cache fajl koji sadrzi sve vidjene PSO kombinacije
  3. Pri pokretanju igre, engine salje sve PSO kombinacije GPU driver-u na pre-kompilaciju, pre nego sto igra pocne
  4. Korisnik vidi loading screen umesto stuttering-a tokom igre
Bez PSO cache-a:
[Igra radi] -> [Nov efekat] -> [STUTTER 50ms] -> [Nastavak] -> [Drugi efekat] -> [STUTTER 30ms]

Sa PSO cache-om:
[Duzi loading screen -- svi PSO-ovi se pre-kompajliraju] -> [Igra radi glatko bez stutter-a]

Prakticne implikacije za UE5 developere

  1. Ocekujte duga vremena kompilacije shadera pri prvom pokretanju projekta i posle velikih promena. Koristite DDC za deljenje kesha u timu.

  2. Testirajte na ciljnim platformama -- shader koji radi na vasem development GPU-u moze imati probleme na korisnikovom (razlicit driver, razlicita kompilacija).

  3. Koristite PSO caching za shipping builds. Bez njega, igraci ce imati vidljive stuttere, posebno na pocetku igre.

  4. Smanjite broj permutacija gde god mozete. Svaka dodatna permutacija znaci vise kompilacije, vise memorije, i potencijalno vise stuttering-a.

  5. Pratite velicinu shader cache-a na disku. Moze porasti na vise gigabajta i uticati na vreme instalacije i prostora na disku korisnika.

  6. r.ShaderPipelineCache.Enabled -- UE5 konzolna komanda za kontrolu PSO cache sistema. Uvek bi trebalo da je ukljucena za shipping builds.


17.11 Shader debugging

Zasto je debugging shadera tezak

Debugging GPU shadera je fundamentalno razlicit od debugging-a CPU programa. Evo zasto:

  1. Nema breakpoint-a (u tradicionalnom smislu). Ne mozete da "zaustavite" GPU na odredjenom pikselu i pogledate vrednosti promenljivih. GPU izvrsava milione thread-ova paralelno -- zaustavljanje jednog ne bi imalo smisla u SIMD kontekstu.

  2. Nema printf-a (u tradicionalnom smislu). Ne mozete da stampalte vrednosti na konzolu iz shadera. (Neki noviji alati imaju ogranichen "shader printf", ali nije isto kao na CPU-u.)

  3. Nedeterministicno ponasanje. Floating-point preciznost se razlikuje izmedju GPU-ova i driver verzija. Shader koji radi savrseno na NVIDIA moze imati vizuelne artefakte na AMD-u.

  4. Paralelizam otezava reprodukciju. Race condition-i u compute shaderima su izuzetno teaki za pronalazenje.

Metode debugging-a

1. Vizuelni debugging -- "ispisi boju na ekran"

Najstarija i najjednostavnija metoda. Umesto finalne boje, vratite debug informaciju kao boju:

// Debug: vizualizuj normalu
return float4(normal * 0.5 + 0.5, 1.0);  // Normala [-1,1] -> boja [0,1]

// Debug: vizualizuj UV koordinate
return float4(input.TexCoord, 0, 1);  // U = crvena, V = zelena

// Debug: vizualizuj dubinu
float linearDepth = LinearizeDepth(input.ClipPos.z);
return float4(linearDepth.xxx / FarPlane, 1);  // Blize = tamnije

// Debug: vizualizuj roughness
return float4(roughness.xxx, 1);  // Crno = smooth, belo = rough

// Debug: vizualizuj gresku -- NaN detekcija
if (isnan(finalColor.r) || isnan(finalColor.g) || isnan(finalColor.b))
    return float4(1, 0, 1, 1);  // Magenta = NaN detektovan!

U UE5, postoji ugradjeni sistem za vizuelni debugging: Buffer Visualization. U editoru, idite na viewport opcije i mozete da vizualizujete:

2. GPU debugging alati

Savremeni GPU debugging alati su znacajno napredovali:

RenderDoc (besplatan, open-source)

NVIDIA Nsight Graphics

AMD Radeon GPU Profiler (RGP)

PIX (Performance Investigator for Xbox)

Xcode GPU Debugger

3. UE5 interni alati

UE5 ima nekoliko ugradjenih alata za shader debugging:

Shader Complexity View Mode

GPU Visualizer (ProfileGPU)

Stat GPU

Shader Compilation Log

4. Custom Debug Output

Za napredni debugging, mozete koristiti Structured Buffer ili RWTexture da napisete debug podatke iz shadera, a zatim ih procitate na CPU:

// U compute shaderu: zapisi debug podatke u buffer
RWStructuredBuffer<float4> DebugOutput : register(u1);

[numthreads(8, 8, 1)]
void MainCS(uint3 id : SV_DispatchThreadID)
{
    // ... vas kod ...
    
    // Zapisi debug podatak za ovaj thread
    DebugOutput[id.y * Width + id.x] = float4(debugValue, someOtherValue, 0, 0);
}

Ovo je ekvivalent printf-a za GPU -- zapisujete vrednosti u buffer, a zatim na CPU strani citate buffer i analizirate podatke. Glomazno, ali efektivno kada vizuelni debugging nije dovoljan.

Najcesci problemi u shaderima

Evo liste najcescih problema na koje cete naici pri radu sa shaderima u UE5:

Problem Simptom Uzrok Resenje
NaN propagacija Crni ili magenta pikseli, flickering Deljenje nulom, sqrt negativnog broja Dodajte saturate(), max(), abs() zastitne pozive
Precision issues Vizuelni artefakti na dalekim rastojanjima Nedovoljna floating-point preciznost Koristite relative-to-camera izracunavanja
Z-fighting Flickering izmedju dva overlapping-a Identicne ili vrlo bliske dubine Dodajte depth bias, razmaknite geometriju
Banding Vidljivi "stepenici" u gradijentima Nedovoljna preciznost boja (8-bit) Koristite dithering, veci bit-depth
Sampler artefakti Seamovi na UV granicama, blurry teksture Pogresan address mode, pogresan MIP level Proverite sampler stanja, UV layout
Overflow Preterano svetle oblasti, "eksplozija" boja Vrednosti premasuju opseg Clamp-ujte vrednosti, koristite HDR
Compile errors Shader se ne kompajlira Sintaksne greske, incompatible tipovi Citajte error log -- UE5 daje detaljne poruke

17.12 Rezime kljucnih pojmova

Termin Objasnjenje
Shader Program koji se izvrsava na GPU-u. Odredjuje kako se vertex-i transformisu, pikseli boje, ili generalni proracuni obavljaju.
HLSL High Level Shading Language -- shader jezik za DirectX. Primarni jezik u UE5.
GLSL OpenGL Shading Language -- shader jezik za OpenGL i delimicno Vulkan.
Metal Shading Language Shader jezik za Apple platforme. Baziran na C++14.
SPIR-V Intermedijarna reprezentacija za Vulkan shadere. Platformski nezavisan bytecode.
USF/USH Unreal Shader Files / Headers -- UE5-specificni shader fajlovi bazirani na HLSL-u.
Vertex Shader Shader faza koja transformise svaki vertex (poziciju, normalu, UV). Izvrsava se jednom po vertex-u.
Hull Shader Shader faza koja kontrolise tessellation faktore. Odredjuje koliko se geometrija deli.
Domain Shader Shader faza koja izracunava poziciju novih vertex-a generisanih tessellation-om.
Geometry Shader Shader faza koja radi sa celim primitivima. Retko koriscen zbog losih performansi.
Pixel Shader Shader faza koja izracunava boju svakog piksela. Najskuplja faza u vecini scenarija. Takodje se zove Fragment Shader.
Compute Shader GPU program za generalne proracune, van tradicionalnog grafickog pipeline-a.
Mesh Shader Moderna zamena za vertex/geometry pipeline. Koristi se u Nanite sistemu UE5.
Constant Buffer (cbuffer) Blok podataka poslatih sa CPU-a koji su konstantni za ceo draw call. Sadrzi matrice, parametre, vreme, itd.
Uniform OpenGL termin za constant buffer podatke. Koncept je isti.
Binding Slot (Register) Pozicija na kojoj je resurs (tekstura, buffer, sampler) vezan za shader.
Varying / Interpolator Podatak koji se prosledjuje iz vertex shadera u pixel shader, interpoliran preko trougla.
Baricentricna interpolacija Metod interpolacije koji koristi tezine tri vertex-a trougla za izracunavanje vrednosti unutar trougla.
Sampler State Objekat koji definise kako shader pristupa teksturi: filtering, address mode, aniso level.
Warp (NVIDIA) / Wavefront (AMD) Grupa thread-ova (32 ili 64) koji izvrsavaju istu instrukciju istovremeno.
Warp divergencija Situacija kada thread-ovi u istom warp-u uzimaju razlicite grane u if/else bloku, primoravajuci GPU da izvrsi obe grane.
Koherentno grananje Grananje gde svi thread-ovi u warp-u idu istom granom -- nema penala.
Flattening Tehnika zamene grananja sa izracunavanjem obe grane i selekcijom rezultata.
Shader permutacija Jedna specifična varijanta shadera generisana kombinacijom compile-time opcija (#ifdef).
Uber-shader Jedan veliki shader sa mnogo grana koji pokriva sve moguce funkcionalnosti materijala.
PSO (Pipeline State Object) Kompletno stanje grafickog pipeline-a (shaderi + blend + depth + rasterizer + format). GPU driver kompajlira nativan kod za svaki PSO.
PSO Cache Kesirani skup prethodno vidjenih PSO-ova, koriscen za pre-kompilaciju pri pokretanju igre da se izbegne stutter.
Shader DDC Derived Data Cache -- mrežni kes kompajliranih shadera, deljen izmedju clanova tima.
Shader hitching / stuttering Vidljivi zastoj u igri izazvan runtime kompilacijom shadera od strane GPU drivera.
RenderDoc Besplatan alat za GPU debugging i frame capture.
Shader Complexity UE5 view mode koji vizualizuje koliko je shader "skup" za svaki piksel na ekranu.

17.13 Dodatna literatura i linkovi

Zvanicna dokumentacija

Knjige

Alati

Kljucni blog postovi i prezentacije

Veza sa drugim poglavljima

Poglavlje Veza sa ovim poglavljem
Poglavlje 07: Render Pipeline Uvodni pregled shader faza. Poglavlje 17 daje detaljan opis svake faze.
Poglavlje 08: GPU Arhitektura SIMD izvrsavanje, warp-ovi, occupancy -- hardverska osnova za razumevanje shader ponasanja, posebno grananja.
Poglavlje 05: Teksture Kako se teksture kreiraju i skladiste. Poglavlje 17 objasnjava kako shaderi pristupaju teksturama.
Poglavlje 06: Coordinate Spaces Transformacije izmedju prostora. Vertex shader implementira te transformacije.
Poglavlje 09: Rasterizacija Kako se trouglovi pretvaraju u fragmente za pixel shader.
Poglavlje 11: PBR BRDF jednacine koje pixel shader implementira.
Poglavlje 22: UE5 Material System Kako UE5 Material Editor apstrahuje pisanje shadera. Nadovezuje se direktno na ovo poglavlje.

U sledecem poglavlju zaronicemo dublje u specifican aspekt shadera koji je izuzetno vazan za vizuelni kvalitet: normal mapping, parallax mapping, i displacement -- tehnike koje omogucavaju da ravna povrsina izgleda kao da ima kompleksnu geometriju, sve zahvaljujuci pametnoj upotrebi pixel shadera.