Utforska klusterbaserad Forward Rendering i WebGL, en kraftfull teknik för att rendera hundratals dynamiska ljuskällor i realtid. Lär dig grundkoncepten och optimeringsstrategierna.
Lås upp prestanda: En djupdykning i klusterbaserad Forward Rendering i WebGL och optimering av ljusindexering
Inom realtids-3D-grafik på webben har rendering av ett stort antal dynamiska ljuskällor alltid varit en betydande prestandautmaning. Som utvecklare strävar vi efter att skapa rikare, mer uppslukande scener, men varje ytterligare ljuskälla kan exponentiellt öka beräkningskostnaden och pressa WebGL till dess gränser. Traditionella renderingstekniker tvingar oss ofta att göra ett svårt val: offra visuell kvalitet för prestanda, eller acceptera lägre bildfrekvenser. Men tänk om det fanns ett sätt att få det bästa av två världar?
Här kommer klusterbaserad Forward Rendering, även känd som Forward+. Denna kraftfulla teknik erbjuder en sofistikerad lösning som kombinerar enkelheten och materialflexibiliteten hos traditionell forward rendering med ljuseffektiviteten hos deferred shading. Den gör det möjligt för oss att rendera scener med hundratals, eller till och med tusentals, dynamiska ljuskällor samtidigt som vi bibehåller interaktiva bildfrekvenser.
Den här artikeln ger en omfattande genomgång av klusterbaserad Forward Rendering i en WebGL-kontext. Vi kommer att dissekera grundkoncepten, från att dela upp vyn (view frustum) till att sortera bort ljus (culling), och fokusera intensivt på den mest kritiska optimeringen: dataströmmen för ljusindexering. Detta är mekanismen som effektivt kommunicerar vilka ljuskällor som påverkar vilka delar av skärmen från CPU:n till GPU:ns fragment shader.
Renderingslandskapet: Forward vs. Deferred
För att förstå varför klusterbaserad rendering är så effektiv måste vi först förstå begränsningarna med de metoder som föregick den.
Traditionell Forward Rendering
Detta är den mest rättframma renderingsmetoden. För varje objekt bearbetar vertex shadern dess hörn (vertices), och fragment shadern beräknar den slutliga färgen för varje pixel. När det gäller belysning itererar fragment shadern vanligtvis genom varenda ljuskälla i scenen och ackumulerar dess bidrag. Kärnproblemet är dess dåliga skalbarhet. Beräkningskostnaden är ungefär proportionell mot (Antal fragment) x (Antal ljuskällor). Med bara några dussin ljuskällor kan prestandan sjunka drastiskt, eftersom varje pixel redundant kontrollerar varje ljuskälla, även de som är miltals bort eller bakom en vägg.
Deferred Shading
Deferred Shading utvecklades för att lösa just detta problem. Det frikopplar geometri från belysning i en tvåstegsprocess:
- Geometripass: Scenens geometri renderas till flera helskärmstexturer som tillsammans kallas G-bufferten. Dessa texturer lagrar data som position, normaler och materialegenskaper (t.ex. albedo, roughness) för varje pixel.
- Belysningspass: En helskärmsrektangel ritas upp. För varje pixel samplar fragment shadern G-bufferten för att återskapa ytegenskaperna och beräknar sedan belysningen. Den stora fördelen är att belysningen endast beräknas en gång per pixel, och det är enkelt att avgöra vilka ljuskällor som påverkar den pixeln baserat på dess världsposition.
Även om deferred shading är mycket effektivt för scener med många ljuskällor har det sina egna nackdelar, särskilt för WebGL. Det har höga krav på minnesbandbredd på grund av G-bufferten, har svårt med transparens (vilket kräver ett separat forward rendering-pass) och komplicerar användningen av kantutjämningstekniker som MSAA.
Argument för en medelväg: Forward+
Klusterbaserad Forward Rendering erbjuder en elegant kompromiss. Den behåller enstegsnaturen och materialflexibiliteten från forward rendering men införlivar ett förbehandlingssteg för att dramatiskt minska antalet ljusberäkningar per fragment. Den undviker den tunga G-bufferten, vilket gör den mer minnesvänlig och kompatibel med transparens och MSAA direkt från start.
Grundkoncepten i klusterbaserad Forward Rendering
Den centrala idén med klusterbaserad rendering är att vara smartare med vilka ljuskällor vi kontrollerar. Istället för att varje pixel kontrollerar varje ljuskälla, kan vi i förväg bestämma vilka ljuskällor som är tillräckligt nära för att eventuellt kunna påverka en region av skärmen, och låta pixlarna i den regionen endast kontrollera dessa ljuskällor.
Detta uppnås genom att dela in kamerans synfält (view frustum) i ett 3D-rutnät av mindre volymer som kallas kluster (eller "tiles").
Hela processen kan delas in i fyra huvudsteg:
- 1. Skapa klusterrutnät: Definiera och konstruera ett 3D-rutnät som partitionerar synfältet. Detta rutnät är fixt i vy-rymden (view space) och rör sig med kameran.
- 2. Ljustilldelning (Culling): För varje kluster i rutnätet, bestäm en lista över alla ljuskällor vars influensvolymer skär med det. Detta är det avgörande culling-steget.
- 3. Ljusindexering: Detta är vårt fokus. Vi paketerar resultaten från ljustilldelningen i en kompakt datastruktur som effektivt kan skickas till GPU:n och läsas av fragment shadern.
- 4. Shading: Under det huvudsakliga renderingspasset bestämmer fragment shadern först vilket kluster den tillhör. Den använder sedan ljusindexeringsdatan för att hämta listan över relevanta ljuskällor för det klustret och utför ljusberäkningar *endast* för den lilla delmängden av ljuskällor.
Djupdykning: Att bygga klusterrutnätet
Grunden för tekniken är ett välstrukturerat rutnät. Valen som görs här påverkar direkt både culling-effektiviteten och prestandan.
Definiera rutnätets dimensioner
Rutnätet definieras av dess upplösning längs X-, Y- och Z-axlarna (t.ex. 16x9x24 kluster). Valet av dimensioner är en avvägning:
- Högre upplösning (fler kluster): Leder till snävare, mer exakt ljus-culling. Färre ljuskällor kommer att tilldelas per kluster, vilket innebär mindre arbete för fragment shadern. Det ökar dock overheaden för ljustilldelningssteget på CPU:n och minnesavtrycket för klusterdatastrukturerna.
- Lägre upplösning (färre kluster): Minskar overheaden på CPU-sidan och i minnet men resulterar i en grövre culling. Varje kluster är större, så det kommer att skära med fler ljuskällor, vilket leder till mer arbete i fragment shadern.
En vanlig praxis är att knyta X- och Y-dimensionerna till skärmens bildförhållande, till exempel genom att dela upp skärmen i 16x9 "tiles". Z-dimensionen är ofta den mest kritiska att justera.
Logaritmisk Z-indelning: En kritisk optimering
Om vi delar upp synfältets djup (Z-axeln) i linjära skivor stöter vi på ett problem relaterat till perspektivprojektion. En stor mängd geometriska detaljer är koncentrerade nära kameran, medan objekt långt borta upptar väldigt få pixlar. En linjär Z-uppdelning skulle skapa stora, oprecisa kluster nära kameran (där precision behövs mest) och små, slösaktiga kluster på avstånd.
Lösningen är logaritmisk (eller exponentiell) Z-indelning. Detta skapar mindre, mer exakta kluster nära kameran och progressivt större kluster längre bort, vilket anpassar klusterfördelningen till hur perspektivprojektion fungerar. Detta säkerställer ett mer enhetligt antal fragment per kluster och leder till mycket effektivare culling.
En formel för att beräkna djupet `z` för den i:te skivan av `N` totala skivor, givet det nära planet `n` och det bortre planet `f`, kan uttryckas som:
z_i = n * (f/n)^(i/N)Denna formel säkerställer att förhållandet mellan djupet på efterföljande skivor är konstant, vilket skapar den önskade exponentiella fördelningen.
Kärnan i det hela: Ljus-culling och indexering
Det är här magin sker. När vårt rutnät är definierat måste vi ta reda på vilka ljuskällor som påverkar vilka kluster och sedan paketera denna information för GPU:n. I WebGL exekveras denna ljus-culling-logik vanligtvis på CPU:n med JavaScript för varje bildruta där ljuskällor eller kameran rör sig.
Korsningstester mellan ljus och kluster
Processen är konceptuellt enkel: iterera genom varje ljuskälla och testa den för korsning mot varje klusters avgränsningsvolym. Avgränsningsvolymen för ett kluster är i sig ett frustum. Vanliga tester inkluderar:
- Punktljus (Point Lights): Behandlas som sfärer. Testet är en korsning mellan sfär och frustum.
- Riktade ljuskällor (Spot Lights): Behandlas som koner. Testet är en korsning mellan kon och frustum, vilket är mer komplext.
- Riktningsbestämda ljus (Directional Lights): Dessa anses ofta påverka allt, så de hanteras vanligtvis separat och inkluderas inte i culling-processen.
Att utföra dessa tester effektivt är nyckeln. Efter detta steg har vi en mappning, kanske i en JavaScript-array av arrayer, som: clusterLights[clusterId] = [lightId1, lightId2, ...].
Datastrukturutmaningen: Från CPU till GPU
Hur får vi denna ljuslista per kluster till fragment shadern? Vi kan inte bara skicka en array med variabel längd. Shadern behöver ett förutsägbart sätt att slå upp denna data. Det är här metoden med en Global ljuslista och en Ljusindexlista kommer in. Det är en elegant metod för att platta ut vår komplexa datastruktur till GPU-vänliga texturer.
Vi skapar två primära datastrukturer:
- En textur för klusterinformationsrutnät: Detta är en 3D-textur (eller en 2D-textur som emulerar en 3D-textur) där varje texel motsvarar ett kluster i vårt rutnät. Varje texel lagrar två viktiga informationsdelar:
- En offset: Detta är startindexet i vår andra datastruktur (den Globala ljuslistan) där ljusen för detta kluster börjar.
- Ett antal (count): Detta är antalet ljuskällor som påverkar detta kluster.
- En Global ljuslistetextur: Detta är en enkel 1D-lista (lagrad i en 2D-textur) som innehåller en sammanlänkad sekvens av alla ljusindex för alla kluster.
Visualisering av dataflödet
Låt oss föreställa oss ett enkelt scenario:
- Kluster 0 påverkas av ljuskällor med index [5, 12].
- Kluster 1 påverkas av ljuskällor med index [8, 5, 20].
- Kluster 2 påverkas av ljuskällan med index [7].
Global ljuslista: [5, 12, 8, 5, 20, 7, ...]
Klusterinformationsrutnät:
- Texel för Kluster 0:
{ offset: 0, count: 2 } - Texel för Kluster 1:
{ offset: 2, count: 3 } - Texel för Kluster 2:
{ offset: 5, count: 1 }
Med denna uppsättning kan vilket fragment som helst bestämma sitt kluster, läsa en texel från klusterrutnätet för att få en offset och ett antal, och sedan utföra en enkel loop för att läsa sina specifika ljus från den Globala ljuslistan.
Implementering i WebGL & GLSL
Nu ska vi koppla ihop koncepten med koden. Implementeringen involverar en JavaScript-del för culling och dataförberedelse, och en GLSL-del för shading.
Dataöverföring till GPU:n (JavaScript)
Efter att ha utfört ljus-culling på CPU:n kommer du att ha din klusterrutnätsdata (offset/count-par) och din globala ljuslista. Dessa måste laddas upp till GPU:n varje bildruta.
- Paketera och ladda upp klusterdata: Skapa en `Float32Array` eller `Uint32Array` för din klusterdata. Du kan paketera offset och antal för varje kluster i RG-kanalerna i en textur. Använd `gl.texImage2D` för att skapa eller `gl.texSubImage2D` för att uppdatera en textur med denna data. Detta blir din textur för klusterinformationsrutnätet.
- Ladda upp den globala ljuslistan: På liknande sätt, platta ut dina ljusindex till en `Uint32Array` och ladda upp den till en annan textur.
- Ladda upp ljusegenskaper: All ljusdata (position, färg, intensitet, radie, etc.) bör lagras i en stor textur eller ett Uniform Buffer Object (UBO) för snabba, indexerade uppslagningar från shadern.
Fragment Shader-logiken (GLSL)
Det är i fragment shadern som prestandavinsterna realiseras. Här är logiken steg-för-steg:
Steg 1: Bestäm fragmentets klusterindex
Först måste vi veta vilket kluster det aktuella fragmentet hamnar i. Detta kräver dess position i vy-rymden (view space).
// Uniforms som tillhandahåller rutnätsinformation
uniform vec3 u_gridDimensions; // t.ex. vec3(16.0, 9.0, 24.0)
uniform vec2 u_screenDimensions;
uniform float u_nearPlane;
uniform float u_farPlane;
// Funktion för att hämta Z-skivans index från djup i vy-rymd
float getClusterZIndex(float viewZ) {
// viewZ är negativt, gör det positivt
viewZ = -viewZ;
// Inversen av den logaritmiska formeln vi använde på CPU:n
float slice = floor(log(viewZ / u_nearPlane) / log(u_farPlane / u_nearPlane) * u_gridDimensions.z);
return slice;
}
// Huvudlogik för att hämta 3D-klusterindex
vec3 getClusterIndex() {
// Hämta X- och Y-index från skärmkoordinater
float clusterX = floor(gl_FragCoord.x / u_screenDimensions.x * u_gridDimensions.x);
float clusterY = floor(gl_FragCoord.y / u_screenDimensions.y * u_gridDimensions.y);
// Hämta Z-index från fragmentets Z-position i vy-rymd (v_viewPos.z)
float clusterZ = getClusterZIndex(v_viewPos.z);
return vec3(clusterX, clusterY, clusterZ);
}
Steg 2: Hämta klusterdata
Med hjälp av klusterindexet samplar vi vår textur för klusterinformationsrutnätet för att få offset och antal för detta fragments ljuslista.
uniform sampler2D u_clusterTexture; // Textur som lagrar offset och antal
// ... i main() ...
vec3 clusterIndex = getClusterIndex();
// Platta ut 3D-index till 2D-texturkoordinat om det behövs
vec2 clusterTexCoord = ...;
vec2 lightData = texture2D(u_clusterTexture, clusterTexCoord).rg;
int offset = int(lightData.x);
int count = int(lightData.y);
Steg 3: Iterera och ackumulera belysning
Detta är det sista steget. Vi exekverar en kort, begränsad loop. För varje iteration hämtar vi ett ljusindex från den Globala ljuslistan, använder sedan det indexet för att hämta ljusets fullständiga egenskaper och beräkna dess bidrag.
uniform sampler2D u_globalLightIndexTexture;
uniform sampler2D u_lightPropertiesTexture; // UBO skulle vara bättre
vec3 finalColor = vec3(0.0);
for (int i = 0; i < count; i++) {
// 1. Hämta index för ljuskällan som ska bearbetas
int lightIndex = int(texture2D(u_globalLightIndexTexture, vec2(float(offset + i), 0.0)).r);
// 2. Hämta ljuskällans egenskaper med detta index
Light currentLight = getLightProperties(lightIndex, u_lightPropertiesTexture);
// 3. Beräkna denna ljuskällas bidrag
finalColor += calculateLight(currentLight, surfaceProperties, viewDir);
}
Och det var allt! Istället för en loop som körs hundratals gånger har vi nu en loop som kanske körs 5, 10 eller 30 gånger, beroende på ljustätheten i just den delen av scenen, vilket leder till en monumental prestandaförbättring.
Avancerade optimeringar och framtida överväganden
- CPU vs. Compute: Den primära flaskhalsen med denna teknik i WebGL är att ljus-culling sker på CPU:n i JavaScript. Detta är enkeltrådat och kräver en datasynkronisering med GPU:n varje bildruta. Ankomsten av WebGPU är en game-changer. Dess compute shaders kommer att göra det möjligt att flytta hela processen med att bygga kluster och culla ljus till GPU:n, vilket gör den parallell och flera storleksordningar snabbare.
- Minneshantering: Var medveten om minnet som används av dina datastrukturer. För ett 16x9x24-rutnät (3 456 kluster) och ett max på, säg, 64 ljuskällor per kluster, kan den globala ljuslistan potentiellt innehålla 221 184 index. Att justera ditt rutnät och sätta ett realistiskt maximum för ljus per kluster är avgörande.
- Justering av rutnätet: Det finns inget magiskt nummer för rutnätsdimensioner. Den optimala konfigurationen beror starkt på din scens innehåll, kamerabeteende och målhårdvara. Profilering och experiment med olika rutnätsstorlekar är avgörande för att uppnå topprestanda.
Slutsats
Klusterbaserad Forward Rendering är mer än bara en akademisk kuriositet; det är en praktisk och kraftfull lösning på ett betydande problem inom realtidsgrafik på webben. Genom att intelligent dela upp vy-rymden och utföra ett högt optimerat steg för ljus-culling och indexering, bryter den den direkta kopplingen mellan antalet ljuskällor och kostnaden för fragment shadern.
Även om det introducerar mer komplexitet på CPU-sidan jämfört med traditionell forward rendering, är prestandavinsten enorm, vilket möjliggör rikare, mer dynamiska och visuellt övertygande upplevelser direkt i webbläsaren. Kärnan i dess framgång ligger i den effektiva pipelinen för ljusindexering – bron som omvandlar ett komplext rumsligt problem till en enkel, begränsad loop på GPU:n.
I takt med att webbplattformen utvecklas med teknologier som WebGPU, kommer tekniker som klusterbaserad Forward Rendering bara att bli mer tillgängliga och prestandamässiga, vilket ytterligare suddar ut gränserna mellan native- och webbaserade 3D-applikationer.