A Shaderek sokak számára tűnhetnek fekete mágiának, de ha jobban beleássuk magunkat, rájöhetünk, hogy nem is annyira félnivaló a dolog. Shadereket Unity-ben kétféleképpen készíthetünk: az egyik lehetőség, hogy írunk egyet, a másik módszer pedig valamilyen node-alapú vizuális eszköz, pl.: a Shader Graph használata. Ebben a blogposztban az előbbivel fogunk foglalkozni.
Shader assetet a Project ablaknál tudunk létrehozni: Create → Shader. Itt bármelyik lehetőséget választhatjuk, kapni fogunk egy .shader kiterjesztésű fájlt, amit bármilyen kódszerkesztővel módosíthatunk. Ahhoz, hogy a shadert működés közben lássuk, egy materialhoz hozzá kell rendelnünk.
Shader assetekből az alábbiakat tudjuk létrehozni:
- Surface Shader: Akkor választjuk, ha szükségünk van a fények használatára, tehát, ha a shaderünknek támogatnia kell a Unity fény, árnyék, light probe és lightmap rendszerét. A Surface Shaderben ezek támogatása már eleve megoldott fényezési modellek használatán keresztül.
- Unlit Shader: Ha nincs szükségünk fényekre (pl.: speciális effektek, Hologram Shader), akkor jó választás lehet. Alapesetben a Unity által nyújtott egyetlen fényforrással sem lép kapcsolatba, ezért ha ilyenre van szükségünk, magunknak kell leprogramozni, de valószínűleg nem ezért választunk Unlit Shadert.
- Image Effect Shader: Lényegében egy post-processing effektet hozunk létre (például: szépia effekt, vagy egy éjjellátó szemüveg szimulálása). Felépítésében hasonló az Unlit Shaderhez.
- Compute Shader: GPU-n számol, az eddig megszokott render pipeline-okon kívül. Nem feltétlenül szükséges, hogy rendereljen, inkább számításigényes feladatokat látnak el, amivel a CPU nem feltétlenül tud gyorsan és hatékonyan megbirkózni. A Compute Shaderek nagyon különböznek a hagyományos shaderektől, ebben a cikkben nem is térünk ki rájuk.
Nézzük meg, hogy néz ki egy shader a kódszerkesztőnkben!
A shaderek deklarálása Unity-ben az úgynevezett ShaderLab nyelv használatával történik. Ebben a shader minden paramétere és tulajdonsága leírásra kerül, valamint ez tartalmazza a tényleges, HLSL nyelven íródott shader kódblokkokat is. Egy shadert a “Shader” kulcsszó és az utána következő elérési útvonal definiál. A #CGPROGRAM és #ENDCG tag-ek pedig a kódblokkokat definiálják.
Most hasonlítsuk össze egy Lit (Surface) és egy Unlit Shader tartalmát! Látszik, hogy a két shader felépítése nagyon hasonló, zárójeles blokkokból áll mind a kettő.
Properties (Tulajdonságok)
Mindkét shader tartalmaz egy Properties részt (bár Lit-ben egy kicsit több változót használ). Ide kerülnek azok a változók, amik publikusak, tehát megjelennek az Inspectorban, és bármikor tudjuk változtatni az értéküket. Ezért is tudjuk megadni nekik aposztrófokban a GUI-ban (Graphical User Interface) megjelenő nevet. Az ‘_’ előtagot ne hagyjuk el!
Fontos megjegyezni, hogy ha csak itt deklaráljuk a változókat, az nem elég. Ahhoz, hogy ezeket használni is tudjuk, a #CGPROGRAM-on belül is meg kell tennünk a deklarálást. Erre azért van szükség, hogy összekapcsoljuk a két változót, így a shader tudja, hogy ugyanazt az adatot használja. Továbbá a “második” deklaráláskor az adattípust is megadjuk, ami később az optimalizálásban lehet még hasznos.
A leggyakrabban használt változók:
- Textúra: _Változónév ("Megjelenő szöveg", típus: 2D) = "alapértelmezett szín, ha nem adunk meg textúrát. Általában “white” szokott lenni" {}
- Szín: _Color ("Ez a név amit látni fogsz", típus: Color) = (1,1,1,1)
- Float: _Amplitude("Amplitude", Float) = 1
- Csúszka: _Transparency ("Transparency", Range(min érték: 0.0, max érték: 0.5)) = alapértelmezett érték: 0.25
SubShader
A Properties blokk után jön(nek) a SubShader(ek). Ezekből lehet több is, attól függően például, hogy esetleg mobil platformon mit szeretnénk használni. A Unity futáskor a shaderben kiválasztja és használja a sorban első olyan SubShadert, ami kompatibilis az adott platformmal. Tehát egy shaderen belül mindig csak egy SubShader tartalma fut le.
SubShader Tag-ek
Legyen egy, vagy akár több SubShaderünk, mindegyikhez adhatunk meg különböző tageket. Ezek kulcs-érték adatpárok és információval szolgálnak, hogy mi és hogyan renderelődjön. A teljesség igénye nélkül nézzünk meg néhány példát rá.
A szintaxisuk: Tags { “[name1]” = “[value1]” “[name2]” = “[value2]”}
- RenderPipeline: UniversalRenderPipeline, HighDefinitionRenderPipeline, egyéb általunk definiált pipeline
- Queue: meghatározza a renderelési sorrendet (ezt a material beállításainál felül tudjuk írni a Render Queue értékének átírásával)
- Background: ez renderelődik először (pl.: skyboxok vagy egy szín)
- Geometry: ezt használjuk a legtöbb tárgyhoz, az Opaque ezt használja (lásd lentebb)
- Alphatest
- Transparent: üveg, particle effektek
- Overlay: effektekhez használjuk, minden ami a fentieken felül renderelődik, pl.: lens flare, UI részek, HUD (head-up display)
- RenderType:
- Opaque
- Transparent
- TransparentCutout
- Background
- Overlay
Pass Tag-ek
Ezeket a tageket a Pass alá rakhatjuk be, ugyanazzal a szintaxissal. Ilyen tagek például:
- LightMode
- PassFlags (forward rendering módban lehet csak használni, és akkor, ha ForwardBase lightmode-ot használunk. Unity csak a fő directional light és ambiens fény/light probe-ok adatait adja át a shadernek).
Ha transzparens shadert szeretnénk, akkor nem elég a fentieket beállítani. Unity - Manual: ShaderLab command: Blend (unity3d.com)
Pragma
Itt adjuk meg a shadernek, hogy milyen “függvényeink” lesznek, és hogyan kezelje őket a fordító, egyfajta preprocesszálási irányelv. Surface Shadereknél mindig lesz egy #pragma surface surfaceFunction lightModel [optionalparams] (mint ahogy azt a fenti példában is megfigyelhetjük).
Ha ott átírjuk a fullforwardshadows paramétert noshadow-ra, akkor figyeljük meg, hogy a körülötte levő mesh-ekből nem fog árnyékot kapni, hiába van a Mesh Rendereren bekapcsolva a "Receive Shadows".
A “lightModel” adja meg, hogy melyik megvilágítási modellt használjuk. A már meglévő beépítettek a “Standard”, “StandardSpecular”, “Lambert”, “Blinn Phong”, vagy akár írhatunk mi is egyet, ha a meglévők nem felelnek meg az elvárt célnak.
Ezeket a függvényeket az alábbi módon tudjuk dekralálni:
- half4 LightingName (SurfaceOutput s, half3 lightDir, half atten){} - Ezt forward renderingnél használjuk, amikor a nézeti irányra nincs szükségünk.
- half4 LightingName (SurfaceOutput s, half3 lightDir, half3 viewDir, half atten){} - Ezt forward renderingnél használjuk, amikor a nézeti irányra szükségünk van.
- half4 LightingName_PrePass (SurfaceOutput s, half4 light){} - Amikor a projektnél deferred renderinget használunk.
Mivel az Unlit Shaderek alapvetően nem foglalkoznak a fényekkel, ezért ott vertex és fragment direktívák vannak. A vertex program a mesh vertex adatait dolgozza fel. A fragment pedig a pixelek végső színéért felelős. Ezeknek a függvényeknek azonban valamilyen adatokkal is dolgozniuk kell, ezért van szükség struct-okra. Először a vertex függvény fog dolgozni, ezért ő az appdata-t kapja meg bemenetként. Ezután a fragment dolgozik vele (a vertex függvénynek ezért is v2f a visszatérési értéke - vertex to fragment).
Shader asset példák
Nézzünk meg egy igazán népszerű, "toon" shadert. Az első verzió egy Unlit, utána pedig ugyanez egy Surface Shaderként megvalósítva. Az Unlit változat hossza és bonyolultsága jól szemlélteti, hogy fényt támogatni Unlit Shaderben hosszadalmas feladat, kiütközik a Surface Shader előnye.
Shader "ShaderTutorial/ToonShader"
{
// Itt dekralájuk a szükséges változókat
Properties
{
// Szín (tint)
_Color ("Color", Color) = (1,1,1,1)
// Textúra (ha van)
_MainTex("Main Texture", 2D) = "white" {}
// HDR tulajdonsággal rendelkező ambiens fény
[HDR]
_AmbientColor("Ambient Color", Color) = (0.4,0.4,0.4,1)
// Spekuláris fényvisszaverődés színe
[HDR]
_SpecularColor("Specular Color", Color) = (0.9,0.9,0.9,1)
// Fényességérték állítása
_Glossiness("Glossiness", Float) = 32
// Rim color a megvilágtott részeken - kiemeli a sziluettet ott, ahol fény éri a modellt
[HDR]
_RimColor("Rim Color", Color) = (1,1,1,1)
_RimAmount("Rim Amount", Range(0, 1)) = 0.716
_RimThreshold("Rim Threshold", Range(0, 1)) = 0.1
}
SubShader
{
// Level of detail
LOD 200
// Ez itt egy Pass. Unlit shadereknél többet is használhatunk akár
Pass{
Tags{
// Csak a directional light-ot és az ambiens/light probe-oket használja
"LightMode" = "ForwardBase"
// Megkapjuk hozzá a szükséges adatokat
"PassFlags" = "OnlyDirectional"
}
CGPROGRAM
#pragma vertex vert // Vertex fgv
#pragma fragment frag // Fragment fgv
#pragma multi_compile_fwdbase
// A ForwardBase miatt van rá szükségünk
// Előre definiált segédváltozók - és függvények halmaza
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
sampler2D _MainTex;
// Az appdataban való adatok automatikusan a rendelkezésünkre // állnak
struct appdata
{
float4 vertex : POSITION; // Local space
float4 uv : TEXCOORD0;
float3 normal : NORMAL;
};
// Azonban az itteni adatokat nekünk kell feltölteni
struct v2f
{
// Itt kapjuk meg a tárgyunk normálvektor adatait
float3 worldNormal : NORMAL;
// Viszont ez itt már screen-space pozíció
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
// Ebben tároljuk a WorldSpaceViewDir eredményét
float3 viewDir : TEXCOORD1;
// Az árnyékkal kapcsolatos adatokat egy TEXCOORD2-be pakoljuk (AutoLight.cginc)
SHADOW_COORDS(2)
};
v2f vert (appdata v)
{
// Egy új v2f structot hozunk létre
v2f o;
// Clip space-be helyezzük a vertex pozíciót
o.pos = UnityObjectToClipPos(v.vertex);
// World space-be konvertáljuk a normálvektort Object space-ből, mivel a directional lightunk is abban van
o.worldNormal = UnityObjectToWorldNormal(v.normal);
// Megkapjuk a WorldSpace irányát
o.viewDir = WorldSpaceViewDir(v.vertex);
// A deklarált SHADOW_COORDS(2)-be eltárolja a vertex terét a shadowmap terébe
TRANSFER_SHADOW(o)
// Ezt adjuk át a fragment függvénynek
return o;
}
float4 _Color;
float4 _AmbientColor;
float _Glossiness;
float4 _SpecularColor;
float4 _RimColor;
float _RimAmount;
float _RimThreshold;
// SV_Target-et a DX10+ használja, a COLOR-t pedig a DX9.
// Az SV_Target univerzálisabb
float4 frag (v2f i) : SV_Target
{
float3 normal = normalize(i.worldNormal);
// A dot függvényt használjuk arra, hogy megnézzük mikor esik fény a tárgyunkra, azzal, hogy összehasonlítjuk a directional lightot a normálvektorral. A dot egy float értéket (1 hosszúságú vektort) ad vissza -1 és 1 között. Az érték 1, ha a két vektor iránya párhuzamos egymással, -1 ha ellenekező irányba néznek, és 0 ha merőlegesek egymásra
float NdotL = dot(_WorldSpaceLightPos0, normal);
// Ezt a makrót a AutoLight.cginc-ból kapjuk. Ha az shadow 1.0 = teljesen megvilágított, ha 0.0 = teljesen árnyékban van
float shadow = SHADOW_ATTENUATION(i);
// A sötét és világos részek közötti simább "elmosódás". 0 és 1 értéket ad vissza attól függően, hogy a harmadik paraméter kisebb vagy nagyobb mint az alsó vagy felső határ
float lightIntensity = smoothstep(0, 0.01, NdotL * shadow);
// A _LightColor0 a "Lighting.cginc"-ból kapjuk, a directional light színe
float4 light = lightIntensity * _LightColor0;
// Spekuláris visszaverődés számítása Bling-Phong modell alapján: Szükségünk van hozzá a nézeti és megvilágtási irányok felezővektorára. NdotH-val a visszatükröződés erősségének számítására a dot-ot használjuk megint, azaz hogy látjuk-e a tükröződést az adott betekintési irányból
float3 viewDir = normalize(i.viewDir);
// A WorldSpaceViewDir eredménye nem normalizált
float3 halfVector = normalize(_WorldSpaceLightPos0 + viewDir);
float NdotH = dot(normal, halfVector);
// A lightIntensity-re azért van szükség, hogy csak ott legyen tükröződés, ahol éri is fény a tárgyat
// A _Glossiness * _Glossiness azért kell, hogy ne kelljen olyan nagy értéket megadnunk inspectorban
float specularIntensity = pow(NdotH * lightIntensity, _Glossiness * _Glossiness);
// Az előzőhöz hasonlóan különválasztjuk világos és sötét részre
float specularIntensitySmooth = smoothstep(0.005, 0.01, specularIntensity);
// Megadjuk a színt a visszatükröződésnek
float4 specular = specularIntensitySmooth * _SpecularColor;
// Rim light
// Azzal, hogy kivonjuk a dot-ot 1-ből, invertáljuk az értéket
// A sziluett effekt miatt azokra a részekre lesz szükségünk, amik a kamerától távolabb helyezkednek el
float4 rimDot = 1 - dot(viewDir, normal);
// A rimIntensity fog felelni azért, hogy csak a megvilágított részeken legyen rim, továbbá mekkora felületet foglaljon el ebből a világított részből
float rimIntensity = rimDot * pow(NdotL, _RimThreshold);
rimIntensity = smoothstep(_RimAmount - 0.01, _RimAmount + 0.01, rimIntensity);
// Az előzőhöz hasonlóan a szín megadása
float4 rim = rimIntensity * _RimColor;
// Az UV és a textúra adatait felhasználva megadjuk, hogy a pixelek milyen színűek lesznek
float4 sample = tex2D(_MainTex, i.uv);
// Végül összeadjuk ezeket az extra értékeket, és megszorozzuk textúrával és alap színnel
return (light + _AmbientColor + specular + rim) * _Color * sample;
} // Frag zárása
ENDCG
} // Pass zárása
// A UsePass használatával egy másik shader Passát használjuk fel a sajátunkhoz
UsePass "Legacy Shaders/VertexLit/SHADOWCASTER"
} // SubShader lezárása
FallBack "Diffuse"
}
A következő kód ugyanezt valósítja meg, azonban Surface Shaderként. Megfigyelhetjük, hogy struct-ok, metódusok helyett az összes műveletet a deklarált light modelben végezzük el. Ezeken felül nincs szükségünk konverziós műveletekre, a legtöbb adat (például lightDir, viewDir) azonnal elérhető.
Shader "ShaderTutorial/ToonShaderSurface"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
[HDR]
_SpecColor ("SpecularColor", Color) = (1,1,1,1)
[HDR]
_AmbientColor ("AmbientColor", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_Glossiness("Glossiness", Float) = 32
[HDR]
_RimColor("Rim Color", Color) = (1,1,1,1)
_RimAmount("Rim Amount", Range(0, 1)) = 0.716
_RimThreshold("Rim Threshold", Range(0, 1)) = 0.1
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf BasicDiffuse fullforwardshadows
#pragma target 3.0
sampler2D _MainTex;
struct Input
{
float2 uv_MainTex;
float3 viewDir;
fixed3 lightDir;
};
half _Glossiness;
fixed4 _Color;
fixed4 _AmbientColor;
fixed4 _RimColor;
float _RimAmount;
float _RimThreshold;
inline float4 LightingBasicDiffuse (SurfaceOutput s, fixed3 lightDir, half3 viewDir, fixed atten)
{
float difLight = dot (s.Normal, lightDir);
float normDifLight = normalize(difLight);
float NdotL = dot(lightDir, normDifLight);
float lightIntensity = smoothstep(0, 0.01, NdotL * atten);
float4 light = lightIntensity * _LightColor0;
//Specular
float3 halfVector = normalize(lightDir + viewDir);
float NdotH = dot(s.Normal, halfVector);
float spec = pow(NdotH, _Glossiness * _Glossiness);
float specSmooth = smoothstep(0.005, 0.01, spec);
// Rim
float4 rimDot = 1 - dot(viewDir, s.Normal);
float rimIntensity = rimDot * pow(NdotL, _RimThreshold);
rimIntensity = smoothstep(_RimAmount - 0.01, _RimAmount + 0.01, rimIntensity);
float4 rim = rimIntensity * _RimColor;
float4 col;
col.rgb = ((specSmooth * _SpecColor) + rim + light) * _Color ;
col.a = s.Alpha;
return col;
}
UNITY_INSTANCING_BUFFER_START(Props)
UNITY_INSTANCING_BUFFER_END(Props)
void surf (Input IN, inout SurfaceOutput o)
{
fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb * _AmbientColor;
o.Alpha = c.a;
}
ENDCG
}
Fallback "Diffuse"
}
A fenti shadereket kedvünkre lehet bővíteni, igényeknek megfelelően. Például, az Unlit Shaderünk jelenleg csak a directional light-ot veszi figyelembe. Azonban egy extra pass és a megfelelő tag hozzáadásával a pontfények is hatással lehetnek a kis autónkra.
Érdemes kísérletezni, hiszen akár véletlenül is hozhatunk létre valami egyedi stílusú shadert. ;)
A cikk a Unity 2021.3.27-es verziójával íródott.
A cikket írta: M. Tamara