This second part describes how the lighting is achieved, providing that contour when the sprites get close to a light source.
Phew! It has been a while since I wrote the first entry of this topic in the blog. Maybe you want to refresh your memory in this link, where you can learn how the shadow casting is faked, and how that specific, stepped lighting model is achieved for the backgrounds and level sprites. Now let’s get into the outline that we are using to emphasize the light.
Interactivity and style
Those were the reasons to go after an “outline” that would make the borders stand out when a light is cast upon the sprite. It adds to the overall feeling of interactivity -things in the world are alive and react to lights- and adds style instead of just illuminating the whole sprite with a flat lighting model. Let’s see that in action:
In that GIF there are 2 lights: the more obvious one, yellow-ish, emitted from that particle system that resembles fire and moves across the screen, and a red light in front of the “eye” of that robot head floating behind the character -both appear in Trespassers. Both get mixed subtly, both affect elements in their range (the back of the character, and that gas cylinder close). Let’s dive deeper into the shader code.
Light pass
We use an additional ForwardAdd pass on top of a slightly modified Unity’s default Sprite shader.
1 2 3 4 5 |
Lighting Off ZWrite Off Blend One One Tags { "LightMode" = "ForwardAdd" } |
We are not using the Lighting provided by Unity, so it gets turned off in this shader. Although the code provided does not write to depth buffer, we needed to, so ZWrite is On in Trespassers. The Blending mode is somehow obvious -we want to add on top of the existing fragment-, and we need to specify the “ForwardAdd” light mode tag to get a pass for each pixel light affecting it (see ref).
1 2 3 4 5 6 7 8 |
struct v2f { float4 vertex : SV_POSITION; float2 texcoord : TEXCOORD0; half3 lightColorAttenuated : COLOR; // Rim light color attenuated with distance half3 lightDir : TEXCOORD1; // Light direction float3x3 rotation : TEXCOORD2; // Rotation matrix }; |
We will need to pass the lighting color attenuated to the fragment shader (will get interpolated from vertex shader), the light direction and the rotation matrix, so rotated sprites get the proper outline lit.
Vertex code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
v2f vert(appdata_full v) { v2f OUT; OUT.vertex = UnityObjectToClipPos(v.vertex); OUT.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex); #ifdef PIXELSNAP_ON OUT.vertex = UnityPixelSnap(OUT.vertex); #endif // Light direction: as it is for 2D, it is placed at the same z as the vertex OUT.lightDir = ObjSpaceLightDir(v.vertex); // Calculate the rotation matrix per-vertex instead of per-pixel in fragment shader later on // Should not make a difference for small/mid-sized sprites TANGENT_SPACE_ROTATION; OUT.rotation = rotation; half distance = length(OUT.lightDir); OUT.lightDir = normalize(OUT.lightDir); // Light's linear attenuation half atten = distance; OUT.lightColorAttenuated = _LightColor0.rgb / atten; return OUT; } |
In line 12 we get the lighting direction for that pass. Unity provides that functionality built-in. The light direction is normalized later on (line 20), after getting the distance to the light (line 19), which is useful to attenuate the lighting color affecting the vertex using linear attenuation (line 24). Explore different attenuations by modifying the lines 23-24 with different formulas.
In lines 16-17 we get the rotation matrix per vertex and store it in one of the fields passed to the fragment shader. Think of the tangent space as a coordinate space for the texture, or, better, check this excellent post in Gamasutra about tangent space. You can do the same process in the fragment shader but, as the comment outlines, for small sprites there should not be a remarkable difference that justifies bloating the fragment code. Anyway, we need to perform our computations in the same coordinate space, the one local to the texture.
Fragment code
1 2 3 4 5 6 7 8 9 10 11 |
fixed4 frag(v2f IN) : SV_Target { fixed4 c = tex2D(_MainTex, IN.texcoord); if (c.a == 0) { discard; } //... Rest of the fragment code } |
The final color will depend on the lights affecting the fragment. But you may wonder: why are we accessing the main texture (which contains the sprite), if, in this pass, we are adding to the existing, not overwriting. The answer is that this fragment is not applied to transparent pixels, which will be many in most of the sprites.
One tricky point here is: how to access the neighbour pixel? How much should you add/subtract from the current UV coordinates to move just one pixel? In the following code you will find “_MainTex_TexelSize“. This is structure is filled in for the texture “_MainTex” by Unity, as long as you declare the following variable before the fragment shader:
1 |
float4 _MainTex_TexelSize; |
This will give you the size of the texel as the name suggests, which is what we are looking for to move from one pixel to another in the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
fixed4 frag(v2f IN) : SV_Target { //... Previous code // We are adding in this pass, so defaults to black c = fixed4(0, 0, 0, 0); fixed2 rim = fixed2(0, 0); fixed addedAlpha = 0; float2 aux = _MainTex_TexelSize.xy; fixed value = 0; aux.y = 0; value = tex2D(_MainTex, IN.texcoord + aux).a; rim.x -= value; addedAlpha += value; value = tex2D(_MainTex, IN.texcoord - aux).a; rim.x += value; addedAlpha += value; aux = _MainTex_TexelSize.xy; aux.x = 0; value = tex2D(_MainTex, IN.texcoord + aux).a; rim.y -= value; addedAlpha += value; value = tex2D(_MainTex, IN.texcoord - aux).a; rim.y += value; addedAlpha += value; //... More code here } |
- Know if that pixel is in a border == some of its neighbours is transparent (easy in Trespassers)
- Estimate the “normal” of that border
The first question is straightforward: if all of the 4 surrounding pixels are opaque -and in Trespassers the vast majority of the sprites don’t have transparency gradients, so pixels are fully opaque or transparent-, then the sum of their alpha values will add up to 4. If lower than that, then one of the neighbours was transparent == we are in a border!
The second question is more ellaborated. Think of the current pixel as placed at a imaginary (0, 0), so the pixels surrounding are placed at left (-1, 0), up (0, 1), right (1, 0) and down (0, -1). Now, in the previous coordinates, replace “1” by “alpha value”, and add everything. That is what the code does in lines 15, 18, 24, 27, one for each coordinate and direction.
If the pixel is in a border, and now that we have the direction for our fake rim light, we can proceed to transform the light’s direction to the tangent space, so everything is in the same coordinate system we are using here (line 5), and apply the color of the light to the current pixel depending on how the light falls upon that border (line 6)
1 2 3 4 5 6 7 8 9 |
// Check if any of the neighbours is transparent if (addedAlpha < 4) { // Transform both light's direction and rim's direction to the same space (tangent space) fixed3 light = mul(IN.rotation, IN.lightDir); c.rgb = IN.lightColorAttenuated * saturate(dot(light.xy, rim.xy)); } return c; |
And that’s all. Probably the hardest part was getting the grasp of how the tangent space works -and that it was the one I needed-, and fighting with lighting modes so I could get a solution for the needs of my project. Nothing new, really.
Caveats and possible circumventions
More artistic control over the rim light. Using a “bump” texture would do. In the code, that’s what we are replicating in realtime from part of the information. But the tricky part here is fetching the sprite if you are using an atlas. I haven’t researched about this topic, but does not sound straightforward. This option could give you small highlights inside opaque areas in the sprite as well.
Use less passes. I would like too, but I don’t know how, if it is possible at all, which I doubt. You will receive a call to that pass for every light affecting the vertex, so you are potentially needing many passes. Sorry, I have no clue here 😉
Use directional lights. Well, directional lights are not used in the rim light. They would feel unnatural, as we have many inner spaces (for instance, rooms in buildings) and would be weird to get lit when no light is directed to you. Also, it may feel a bit annoying if you are always watching everything with that outline lit. but in case you wanted, maybe you can start by adding a pass with a different LightMode. Unity provides information of lights in some built-in variables. You may need an additional pass, though.
Blocking light with obstacles. I suppose you will need to use ComputeShaders or something more elaborated. With the previous code, if you place a light at some point, walls do not affect it. Sorry!
Some references
Tangent space explanation: Gamasutra article from Siddhart Hedge
Outline shader for sprites in Unity: Nielson post about that topic
First part of this post: in this same web
Where is the code?
I have updated the code in this GitHub repo. It is the same as the used for the shadow casting. Quick note about license (you will find it there anyway):I went for a GNU GPLv3, so everyone can benefit from the work of everyone, if someone else keeps updating it. No credit is needed, but it would be welcomed 😉
Feel free to leave a comment or tweet me (@crazybitstudios).
See ya!
Leave a Reply