The last article in my shader tutorial series was on basic paper shader setup. I did that specifically so I could write this tutorial on how to burn it. Both this paper burn shader, and its predecessors are part of my shader series.

My goal today will be to make a burn effect more on the realistic side of the spectrum. That being said, it’s just a shader. No VFX yet. Without vfx this can only be so realistic, but I plan to make a shader + VFX post in the future.

If you don’t like my fire,

Then don’t come around.

`Cause I’m gonna burn one down

– Ben Harper

This builds on many of my previous tutorials. If you have any issues, ping me on Twitter, or ask on the forums. If you’d like a github project for this tutorial, please let me know in the comments or ping me on Twitter @gamedevbill. Edit: You can follow along with this github project.

Here’s a look at where we’re headed today:

This article may contain affiliate links, mistakes, or bad puns. Please read the disclaimer for more information.

To give credit where credit is due, the background assets I’m using to fill out my scene are from the free HDRP Furniture Pack and PBR Game-Ready Desks Pack.

If you’re interested in keeping up with my work, I’d love a follow and occasional retweet @gamedevbill on twitter. You can also subscribe for updates on the sidebar (at the end on mobile).

Overview

These are the high level steps we’ll go through today.

Use distance from a “burn point” and percent input to determine burn area. Add bands of color along burn edge, including transparency. Add noise to our burn area calculation Make paper curl (move vertices) in response to burn.

Steps 1-3 only affect the color output. Step 4 will affect position.

Setup

I’ll start with the “texture sample” paper shader from the previous tutorial. That one is the simplest to extend, and I think fits well with what we’re going to do today. In that tutorial, the texture based displacement was within a subgraph called AllPaperNoise . For today, I’ve pulled that out into a dedicated subgraph called TextureDisplacement . Below are screenshots of the starting point.

Top Level Shader Baseline

TextureDisplace subgraph

Step 1: Burn Area

To begin, I just want to color the entire burn area black to make sure I can identify it.

Distance

I’m going to add a Vector2 input to my shader called BurnPoint . I’m also creating a new subgraph (or shader method in code), called BurnData . The IO of the method are explained in the comments:

//coords - current UV coordinates (0 to 1 edge to edge) //burnPoint - coordinates to burn from (also in UV space) //XOverY - x scale over y scale. 9/11 in my specific case. // this causes the circle to be a circle (not oval) //radius - output value. Distance of coord from burn point. void BurnData( float2 coords, float2 burnPoint, float XOverY, out float radius) { coords -= burnPoint.xy; coords.y *= XOverY; radius = length(coords); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 //coords - current UV coordinates (0 to 1 edge to edge) //burnPoint - coordinates to burn from (also in UV space) //XOverY - x scale over y scale. 9/11 in my specific case. // this causes the circle to be a circle (not oval) //radius - output value. Distance of coord from burn point. void BurnData ( float2 coords , float2 burnPoint , float XOverY , out float radius ) { coords -= burnPoint . xy ; coords . y * = XOverY ; radius = length ( coords ) ; }

Burn Area

At the top level, I now add inputs called PercentComplete and PercentScale . It’s important to set the Reference setting in the Blackboard to something understandable for this input. By default, the Reference is something generated like Vector1_C527FF2A but this is how you access the variable from C#. I named mine _PercentComplete to match Unity’s code naming convention.

I’ll use this PercentComplete to slowly ramp up a variable called burnLevel . When percent is 0, burnLevel will be negative everywhere. As percent increases, burnLevel is positive only within a radius that is burned.

I use the PercentScale variable to adjust my definition of “100%”. I do this to account for the more or less burn needed depending on where my burn spot is (centered vs at an edge).

fixed4 col = tex2D(_MainTex, i.uv); float radius = 0; BurnData(i.uv.xy, radius); float burnLevel = _PercentComplete * _PercentScale - radius; float burnEdge = smoothstep(0, 0.001, burnLevel ); col.rgb = lerp(col.rgb, float3(0,0,0), burnEdge); 1 2 3 4 5 6 fixed4 col = tex2D ( _MainTex , i . uv ) ; float radius = 0 ; BurnData ( i . uv . xy , radius ) ; float burnLevel = _PercentComplete * _PercentScale - radius ; float burnEdge = smoothstep ( 0 , 0.001 , burnLevel ) ; col . rgb = lerp ( col . rgb , float3 ( 0 , 0 , 0 ) , burnEdge ) ;

For now, I run burnLevel into a smoothstep with tight parameters (0 to 0.001). This gives us a burnEdge that is either zero or one, so we can clearly see where the burn area is and isn’t.

An important callout in this graph is the “Burn Area” group I made. Later on, this logic will get much more complex, and become a subgraph. I just mention it now so that you’ll know which part is changing later.

Controlling C#

In previous tutorials, when I needed something to change over time, I simply used the Time variable/node inside the shader. This gets a bit complicated if you want to control when something starts, loops, or ends. Which is what we want to control for PercentComplete .

So for this case, I’ll expose it all the way up to the material, and drive the code from there. Basically, the code just ramps from 0 to 1 over some amount of time (based on a scale) after you hit the space bar. I’m not going to go into detail here as the code should be relatively self explanatory. If you are thrown by how I set the shader variable, see my post on shaders in Unity.

public class BurnController : MonoBehaviour { private MeshRenderer m_mesh; private bool m_active = false; private float m_percent = 0.0f; public float TimeScale = 1; void Start() { m_mesh = gameObject.GetComponent<MeshRenderer>(); } void Update() { if (Input.GetKeyDown("space")) { m_active = true; m_percent = 0; } if (m_active) { m_percent += Time.deltaTime * TimeScale; if (m_percent >= 1) { m_percent = 1; m_active = false; } m_mesh.material.SetFloat("_PercentComplete", m_percent); } } } 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 public class BurnController : MonoBehaviour { private MeshRenderer m_mesh ; private bool m_active = false ; private float m_percent = 0.0f ; public float TimeScale = 1 ; void Start ( ) { m_mesh = gameObject . GetComponent & lt ; MeshRenderer > ( ) ; } void Update ( ) { if ( Input . GetKeyDown ( "space" ) ) { m_active = true ; m_percent = 0 ; } if ( m_active ) { m_percent += Time . deltaTime * TimeScale ; if ( m_percent >= 1 ) { m_percent = 1 ; m_active = false ; } m_mesh . material . SetFloat ( "_PercentComplete" , m_percent ) ; } } }

Step 2: Edge Color

As I was developing this, I started with one color gradient, and worked up to several. For the sake of time, I’ll jump to the end, but if you are developing your own paper burn shader, I suggest starting piece by piece.

I want the color at BurnLevel =0 to be my paper color. Then, as I go positive, it should have a gradient from paper color to black. Then from black to grey, and lastly from grey to orange or red. Past the orange, I want transparency (no gradient).

These gradients can be best controlled with three Vector2 inputs. Each one represents a gradient size, and color size. The “size” is in reference to our radius, so a value of 1 would be about the height of my paper. The easiest way to explain how the variables work, is to show what the values are from the screenshot above, then walk through what that means.

Variable Gradient Size Total Size BlackFadeData 0.08 0.12 GrayFadeData 0.3 0.10 OrangeFadeData 0.001 0.005

Note that in the case of the black, it’ll fade to full black before finishing the area (gradient < total size), but with gray, it won’t actually finish the fade before switching colors (gradient > total size). With orange, the gradient is so small, it ends up being a fairly sharp edge.

Color Fade Logic

I’ll now convert the “Burn Area” group from earlier screenshots into a subgraph called BurnGradient . Things get a bit complicated from here, so I’ll go through it bit by bit.

Of note, to help with complexity, I’ll be using the Preview node a lot. See my UnityTip about Preview Nodes for more info on why.

To start, my method declaration. I have a total of 7 inputs: the source color, my radius, three float2’s for my color bands, two percent related inputs. On the output front, I have the modified color, as well as an output for emission and alpha.

void BurnGradient( float4 colorIn, float radius, float2 blackFadeData, float2 grayFadeData, float2 orangeFadeData, float percentComplete, float percentScale, out float4 colorOut, out float3 emission, out float alpha) { 1 2 3 4 5 6 7 void BurnGradient ( float4 colorIn , float radius , float2 blackFadeData , float2 grayFadeData , float2 orangeFadeData , float percentComplete , float percentScale , out float4 colorOut , out float3 emission , out float alpha ) {

In the logic portion, I’m going to do essentially the same thing three times now. I take the fade part, and run that through a smoothstep . That gives a nice linear ramp of color. Then feed the total size of a section into the next stage’s math.

Here’s the black fade section.

float scaledPerc = _PercentComplete * _PercentScale; float burnLevel = scaledPerc - radius; //ramp from 0 to black.x float blackRamp = smoothstep(0, blackFadeData.x, burnLevel); float4 colorWithBlack = lerp(colorIn, float4(0,0,0,0), blackRamp); float endOfBlack = blackFadeData.y; 1 2 3 4 5 6 float scaledPerc = _PercentComplete * _PercentScale ; float burnLevel = scaledPerc - radius ; //ramp from 0 to black.x float blackRamp = smoothstep ( 0 , blackFadeData . x , burnLevel ) ; float4 colorWithBlack = lerp ( colorIn , float4 ( 0 , 0 , 0 , 0 ) , blackRamp ) ; float endOfBlack = blackFadeData . y ;

The gray and black sections will have the same color logic, but I’m introducing two new bits of logic.

The first is that I’m scaling the endOfGray down based on percent. The description and picture I gave about my multi-color transition is what I want once we are well into the burn. Early on, I want the bands thinner, with some colors missing at first. I could scale all the things down, but I personally like it better with a constant sized black, but scaled down gray and orange. So below, I run the percent into a smoothstep from 0.2 to 0.5. This means before I’m 20% of the way through this, there is no gray, then the gray will grow until we reach 50%, and then it’ll be at its max.

The second change is I’m starting on my emission output logic. This will feed into the emission input on the parent graph’s master node. Emission color will show up even when the surface is in shadow, and will glow if using Bloom post processing effect.

With emission, a little light goes a long way. So for the gray, where the color was 0.4, the emission is just 0.05.

//ramp from endOfBlack to (endOfBlack + gray.x) float grayRamp = smoothstep(endOfBlack, endOfBlack + grayFadeData.x, burnLevel); float4 colorWithGray = lerp(colorWithBlack, float4(0.4, 0.4, 0.4, 0), grayRamp); float endOfGray = endOfBlack + grayFadeData.y * smoothstep(0.2, 0.5, scaledPerc); float3 emissionGray = lerp(float3(0,0,0), float3(0.05,0.05,0.05), grayRamp); 1 2 3 4 5 //ramp from endOfBlack to (endOfBlack + gray.x) float grayRamp = smoothstep ( endOfBlack , endOfBlack + grayFadeData . x , burnLevel ) ; float4 colorWithGray = lerp ( colorWithBlack , float4 ( 0.4 , 0.4 , 0.4 , 0 ) , grayRamp ) ; float endOfGray = endOfBlack + grayFadeData . y * smoothstep ( 0.2 , 0.5 , scaledPerc ) ; float3 emissionGray = lerp ( float3 ( 0 , 0 , 0 ) , float3 ( 0.05 , 0.05 , 0.05 ) , grayRamp ) ;

The last color section is our orange area. This is basically the same as the gray area, but with some numbers changed.

//ramp from endOfGray to (endOfGray + orange.x) float orangeRamp = smoothstep(endOfGray, endOfGray + orangeFadeData.x, burnLevel); float4 colorWithOrange = lerp(colorWithGray, float4(1.0, 0.1, 0.0, 0), orangeRamp); float endOfOrange = endOfGray + orangeFadeData.y * smoothstep(0.1, 0.3, scaledPerc); float3 emissionOrange = lerp(emissionGray, float3(0.8,0,0), orangeRamp); 1 2 3 4 5 //ramp from endOfGray to (endOfGray + orange.x) float orangeRamp = smoothstep ( endOfGray , endOfGray + orangeFadeData . x , burnLevel ) ; float4 colorWithOrange = lerp ( colorWithGray , float4 ( 1.0 , 0.1 , 0.0 , 0 ) , orangeRamp ) ; float endOfOrange = endOfGray + orangeFadeData . y * smoothstep ( 0.1 , 0.3 , scaledPerc ) ; float3 emissionOrange = lerp ( emissionGray , float3 ( 0.8 , 0 , 0 ) , orangeRamp ) ;

The last section of this method is what takes our end of orange and turns it into an alpha value.

alpha = 1 - smoothstep(endOfOrange, endOfOrange + 0.001, burnLevel); colorOut = colorWithOrange; emission = emissionOrange; 1 2 3 alpha = 1 - smoothstep ( endOfOrange , endOfOrange + 0.001 , burnLevel ) ; colorOut = colorWithOrange ; emission = emissionOrange ;

Just so you can see how it all ties together, here’s a full picture with all the pieces from above.

Digging a Hole

At this point, I’ve created something I call alpha, but this shader is not set to have transparency. We could turn transparency on, but that’s costly to rendering. In the code shader, I can just do this: if(alpha < someCutoff) discard;

The way to do that in shader graph, is to use “alpha clipping”. You turn this on in the options for the master node, and then in my case, I just set it to 1.

Step 3: Noise

We’ve now gotten the coloration I was after, but a burn shouldn’t be such a perfect circle. So now we add noise. We’ll do so in two ways. One with math, the other with a texture.

We need both because they serve two different purposes. The mathy noise will be used for a macro warping of the circle. It can conveniently be used in both the fragment and vertex portions of the graph. The texture noise provides smaller scale variance, just used for the fragment.

Math Noise

To add in the noise, we’ll jump back into the method/subgraph from earlier called BurnData . It was used to calculate our radius. What we want to do is shrink that radius based on some noise data.

The math I use is wrapped up in this method below. It uses the angle of our current point, and a whole bunch of magical numbers. Some of those numbers I feed in as a float2 called noiseData . The rest are just arbitrary and set inside the method. The reason those two are passed in is because we’ll drive them from code so they can slowly change over the duration of the burn.

The output is the amount to offset the radius.

float CalculateBurnNoise(float angle, float2 noiseData) { float noise = 0; noise += (sin(10*angle-noiseData.x* .5)+1) * (sin(noiseData.y*2 )+1)*.5; noise += (sin(7 *angle+noiseData.x* 2 )+1) * (cos(noiseData.y*0.4 )+1)*.6; noise += (sin(3 *angle+noiseData.x* 4 )+1) * (cos(noiseData.y*0.15)+1)*.7; return noise; } 1 2 3 4 5 6 7 8 9 float CalculateBurnNoise ( float angle , float2 noiseData ) { float noise = 0 ; noise += ( sin ( 10 * angle - noiseData . x* . 5 ) + 1 ) * ( sin ( noiseData . y* 2 ) + 1 ) * . 5 ; noise += ( sin ( 7 * angle + noiseData . x* 2 ) + 1 ) * ( cos ( noiseData . y* 0.4 ) + 1 ) * . 6 ; noise += ( sin ( 3 * angle + noiseData . x* 4 ) + 1 ) * ( cos ( noiseData . y* 0.15 ) + 1 ) * . 7 ; return noise ; }

Ooof, what a terrible looking graph!

The result of this method is subtracted from my radius. To set that up, I need to calculate the angle of rotation around my burn origin as well as feed the noiseData input up a level.

While I’m in BurnData I’ll go ahead and make two other small alterations. Since I’m calculating angle here (to feed into the above logic), I’m also going to make it an output of this method. I’m also going to create an additional input called startNoise that will just be zero for now. I don’t need either of these now, but I’ll need them in later steps, so getting things set up.

Modifying the BurnData method from earlier, we get this:

void BurnData( float2 coords, float2 burnPoint, float XOverY, float2 noiseData, //new input float startNoise, //new input out float angle, //new output out float radius) { coords -= burnPoint.xy; coords.y *= XOverY; radius = length(coords); //new logic angle = atan2(coords.y, coords.x); float noise = CalculateBurnNoise(angle, noiseData) + startNoise; radius = radius - noise * min(radius * 0.1, 0.3); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void BurnData ( float2 coords , float2 burnPoint , float XOverY , float2 noiseData , //new input float startNoise , //new input out float angle , //new output out float radius ) { coords -= burnPoint . xy ; coords . y * = XOverY ; radius = length ( coords ) ; //new logic angle = atan2 ( coords . y , coords . x ) ; float noise = CalculateBurnNoise ( angle , noiseData ) + startNoise ; radius = radius - noise * min ( radius * 0.1 , 0.3 ) ; }

Burn Data subgraph

At the top level of my graph, I also feed NoiseData up to be an input to the shader. With that at 0,0 I get a star fish sorta shape. Altering those values will extend and rotate some of the spikes.

This looks much better than the circle, but has two main problems. One is that it looks a little stiff when animating PercentComplete . The other is that it’ll be odd to see repeated burns be identical. The NoiseData input I included in the noise subgraph solves both of these. We can animate those inputs slightly during a burn to give the shape a little life. And on subsequent burns we can set the initial state to be drastically different to get a different shape.

Taking the c# snippet from earlier, I add three member variables and alter the Update method (//… is all the existing code):

public Vector2 NoiseData; public Vector2 NoiseScaling; private Vector2 m_noiseData; //... void Update() { if (Input.GetKeyDown("space")) { //... m_noiseData = NoiseData; } if (m_active) { //... m_noiseData.x += NoiseScaling.x; m_noiseData.y += NoiseScaling.y; m_mesh.material.SetVector("_NoiseData", m_noiseData); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public Vector2 NoiseData ; public Vector2 NoiseScaling ; private Vector2 m_noiseData ; //... void Update ( ) { if ( Input . GetKeyDown ( "space" ) ) { //... m_noiseData = NoiseData ; } if ( m_active ) { //... m_noiseData . x += NoiseScaling . x ; m_noiseData . y += NoiseScaling . y ; m_mesh . material . SetVector ( "_NoiseData" , m_noiseData ) ; } }

These values are also fairly arbitrary. I liked setting NoiseData to (1,2) and NoiseScaling to (0.005, 0.0005). But again, if running this burn multiple times, I’d change those initial 1,2 values to be something different.

Texture Noise

Next up we’ll add some texture based noise. This is the last bit of fragment-driving logic before we move on to the vertex part (which is shorter). All I’m doing is sampling a cloudy noise texture and multiplying it by something (3 for now, but yet another number to tweak until you are happy).

I keep this outside the CalculateBurnNoise method/subgraph because I’m going to reuse that mathy noise in the vertex shader, but can’t do (effective) texture sampling there. So at the top level I sample the noise texture, and feed it into BurnData , which I already set up earlier.

Step 4: Vertex Curl

This last step is my favorite. It’s the one I’ve not really seen in other dissolve or burn tutorials. We will curl both the innermost burn and outer non-burned areas up.

All this displacement is done inside a subgraph called BurnVertexDisplacement to match my normal calculation tutorial. As I mention at the start, you can use the github from that tutorial as a starting point. Note that the texture based vertex displacement (the crumple) is separate. That displacement comes with its own normal map, so I don’t want to recalculate those normals. I also need to do that displacement first because its logic assumes it’s deforming a flat surface.

This new subgraph does the same burn level calculations as the fragment portion of the graph, so it requires all those inputs: coords, BurnPoint, XOverY, NoiseData, PercentComplete, PercentScale .

In addition, this sugraph only needs two more inputs: the starting PositionIn , and a factor determining how much to curl the unburned area UnburnCurl .

I’ll end up executing this sugraph four times. Once for the actual position displacement, and three times for the normal calculation.

Burned Curl

In the already-burned area, all I’m going to do is raise up the paper as it burns. I’ve also played with having it curl outwards from the burn, but that math gets far more complicated with little benefit. For the sake of length, I’ll keep it simple.

//calculate how much to raise it float burnCurl = burnLevel * 5 * (0.8 - scaledPerc); //only raise it in the burned area positionOut.y += burnCurl * smoothstep(0,0.001, burnLevel); 1 2 3 4 //calculate how much to raise it float burnCurl = burnLevel * 5 * ( 0.8 - scaledPerc ) ; //only raise it in the burned area positionOut . y += burnCurl * smoothstep ( 0 , 0.001 , burnLevel ) ;

Unburned Curl

Things are a bit more complex in the unburned section. For one, it is definitely worth having the paper curl in addition to raising it up. Secondly, we need to do the curl math based on how close we to the edge of the paper.

To do the curl, we calculate how much we want to alter the paper. We add that amount to the Y, and subtract the square of that multiplied by cos/sin for the X/Z.

How we calculate that “how much to alter” I’ll leave for you to read from the code. Partly because it’s complex, and partly because it’s arbitrary. I just played with things until I got the paper to curl more as it gets near the edges in a way I liked.

//lift the not-yet-burned paper up, curling it slightly towards the burn. float nonBurnCurlStrength = 30; float baseYEdgeCorrection = saturate(burnLevel * -1 * _PercentComplete*_Size.z) * nonBurnCurlStrength; float minBurnPos = min(_BurnPoint.x, _BurnPoint.y); float maxBurnPos = max(_BurnPoint.x, _BurnPoint.y); float yEdgeCorrection = baseYEdgeCorrection * ( 0.45 + 0.55 * (saturate(minBurnPos*2)-saturate(maxBurnPos*2-1))); o.vertex.y += yEdgeCorrection; o.vertex.x -= cos(angle) * yEdgeCorrection*yEdgeCorrection; o.vertex.z -= sin(angle) * yEdgeCorrection*yEdgeCorrection; 1 2 3 4 5 6 7 8 9 //lift the not-yet-burned paper up, curling it slightly towards the burn. float nonBurnCurlStrength = 30 ; float baseYEdgeCorrection = saturate ( burnLevel * - 1 * _PercentComplete* _Size . z ) * nonBurnCurlStrength ; float minBurnPos = min ( _BurnPoint . x , _BurnPoint . y ) ; float maxBurnPos = max ( _BurnPoint . x , _BurnPoint . y ) ; float yEdgeCorrection = baseYEdgeCorrection * ( 0.45 + 0.55 * ( saturate ( minBurnPos* 2 ) - saturate ( maxBurnPos* 2 - 1 ) ) ) ; o . vertex . y += yEdgeCorrection ; o . vertex . x -= cos ( angle ) * yEdgeCorrection* yEdgeCorrection ; o . vertex . z -= sin ( angle ) * yEdgeCorrection* yEdgeCorrection ;

Wrapping Things Up

That completes the paper burn shader. I covered these steps:

Use distance from a “burn point” and percent input to determine burn area. Add bands of color along burn edge, including transparency. Add noise to our burn area calculation Make paper curl (move vertices) in response to burn.

Each step, and even the sub sections within each step, build upon each other. So depending on your situation, you may just want to work through part of the tutorial. I went with the full, longer version because I think this paper burn shader is a good culmination of the tutorials we’ve been working on so far.

Do something. Make progress. Have fun.