In depth: Procedural water paint stains

Above is a screenshot from a piece of tech we’ve made for our latest game: Doctor Flow. The screenshot demonstrates the procedural water paint stains we use to give feedback about the player’s performance. As you make more mistakes, these stains grow bigger and denser. In this article I will be explaining how we did this harnessing the power of signed distance fields.

Figure 1: A solution to one of the scenarios in the game

Introducing: Doctor Flow

Let’s start with a little bit of background. Doctor Flow is a puzzle game in which you must diagnose patients as efficiently as possible. You do this by building a flow chart that guides each patient to the correct diagnosis based on the symptoms he or she is exhibiting.

Besides being a fun puzzle game, this game actually helps medical doctors get insight in how their decisions affect the hospital bill and satisfaction of their patients! This is known as Value Based Healthcare. If you want to try the game yourself, it will be free to download on Android and iOS soon!

Figure 2: Initial concept art showing the water paint stains

Making a mess

When you make a mistake in configuring your flow chart, patients get assigned the wrong diagnosis. To signal his mistake to the player, we wanted a visual indicator that could increase in intensity the more severe your mistake becomes.

In an early concept art mockup, our artist added water paint stains to fulfill this function. The whole development team really liked this idea, so we had to find a way to implement it properly.

The challenges

When thinking about a good solution for displaying the paint stains in-game, we ran into two major challenges:

  • Repetition: In the worst case, when the player has misdiagnosed all patients, paint stains would be visible at all diagnosis nodes. Later levels contain up to 5 different diagnosis nodes, we didn’t want the player to be able to see repeating obvious patterns in the paint stains
  • Texture size: There are almost no textures in the rest of the game. All the visuals are vector based and are drawn with flat colors or simple gradients. This keeps both our build size and load times extremely low. We weren’t willing to sacrifice that by adding loads of variations of a paint stain texture.

Every game programmer’s mind probably instantly jumps to the same solution when thinking about these challenges: Procedural Generation! The programmer tool for creating pretty visuals, just with code (well, mostly with code, we also need some textures).

Choosing a weapon

All right, we’ve decided procedural generation is our way to attack the problem, but what weapon will we use? Well… let’s look at another water paint stain.

Figure 3: Another water paint stain, courtesy of

There are a couple of important aspects of this image we can notice:

  • There is not much detail inside the individual drops, it mainly consists of low frequency color transitions. The silhouette is more important than the pattern.
  • Individual drops contain a darker border, like with a coffee ring (If you are interested in knowing why this occurs, read this article)
  • Individual drops are roughly the same shape with small variations or stretched in a single axis.

Luckily, there is a technique that allows us to render smooth shapes using low resolution textures AND allows us to easily add dynamic borders.

Signed Distance Fields to the rescue!

Normally, you can instantly notice when a low-resolution texture is upscaled by the ugly smoothed edge where pixel values are interpolated between the filled-in part of the texture and the transparent part. However, there is a very neat trick where you do not treat the pixel values of a texture as color values, but as a distance to the shape you want to render. This is known as a distance field. You can see an example texture below.

If you then upscale the texture, it doesn’t matter that values are interpolated. A distance field texture consists of a smooth, linear gradient. Interpolating between pixels in this gradient always gives mathematically correct values!

By using a special pixel shader the distance values are converted to color values by comparing the distance value to a predefined threshold. Every pixel with a distance within that threshold is drawn, the rest are discarded. This pixel shader looks something like this:

// Value between 0.0 and 0.5. This softens the edge of the shape
float _Smoothing;

// The SDF texture, only the alpha channel is used.
sampler2D _MainTex;

fixed4 frag(v2f IN) : SV_Target
	// Sample the distance value from the texture
	// A value of 0.5 means that the pixel is exactly on the shape edge
	fixed4 sdf = tex2D(_MainTex, IN.texcoord);

	// Use our smoothing value to determine an alpha value for this pixel 
	fixed alpha = smoothstep(0.5 - _Smoothing, 0.5 + _Smoothing, sdf.a);

	// Combine the alpha with the vertex color to calculate the pixel color
	fixed4 c;
	c.rgb = IN.color.rgb;
	c.a = IN.color.a * alpha;

	return c;


This is a basic SDF shader with support for smoothing. With this, we can achieve the following result:

Figure 4: Comparison between upscaling a texture using filtering and by using the texture as a signed distance field and our specialized shader.

From drops to dried paint

So, we have a method of creating shapes with crisp silhouettes based on very small textures, how does this help us? If you think about it, paint stains are formed by lots of different blobs and drops of paint merged together. We could just simulate this effect in our stain generator.

To do this we needed some shapes to serve as our paint drops. Our artist drew some paint drops with varied shapes and amounts of detail. We then used this Node.js based tool to convert them to a signed distance field texture.

Figure 5: All the paint drops used in our generator, in only a 128×128 texture!

By using the Unity GetPixel and SetPixel methods we procedurally generate a 128×128 stain SDF texture by blitting multiple randomly selected drops on the stain texture and varying them in position and scale. Another neat thing about SDF textures is that you can easily combine them by taking the maximum of two pixel values. This results in a smoothly combined shape. In code, it looks like this:

private void BlitDrop(Sprite src, Texture dst)
	// Select a random scale for this drop
	float scaleX = minScale + 
		(float) (random.NextDouble() * (maxScale - minScale));

	float aspectRatio = minAspectRatio + 
		(float) (random.NextDouble() * (maxAspectRatio - minAspectRatio));

	float scaleY = scaleX * aspectRatio;

	// Calculate the destination rect for this drop
	Rect dstRect = new Rect()
		width = src.textureRect.width * scaleX,
		height = src.textureRect.height * scaleY

	dstRect.x = random.Next(0, dst.width - (int) dstRect.width);
	dstRect.y = random.Next(0, dst.width - (int) dstRect.height);

	// Copy pixels from the drop (src) to the stain (dst)
	for (int y = 0; y < dstRect.height; ++y)
		for (int x = 0; x < dstRect.width; ++x)
			// Since the source drop is scaled, we bilinearly sample the 4 pixels 
			// around the exact location we want to sample
			Color srcColor = SampleBilinear(src.texture, 
				srcRect.x + x / scaleX, srcRect.y + y / scaleY);

			Color dstColor = dst.GetPixel((int)dstRect.x + x, (int) dstRect.y + y);
			// Write the new pixel value to the stain texture by taking
			// the maximum of the previous value and the new value
			dstColor = new Color(0, 0, 0, Math.Max(srcColor.a, dstColor.a));

			dst.SetPixel((int)dstRect.x + x, (int)dstRect.y + y, dstColor);


This function is called a couple of times with a random drop sprite. Using this method and our SDF shader, we can generate the following, already somewhat convincing, result:

Figure 6: A paint stain generated by combining 20 random drops.

Making it look like water paint

Nice! We can generate a basic stain shape. But it doesn’t really look like water paint yet, how can we fix that?

Well, in our observations we noted water paint stains (and stains in general) appear to have a darker border. Luckily, dynamically rendering borders is very easy when using SDF’s. Since you know the distance to the sprite edge, you can use this to determine at pixel level whether to render a border or not.

We add the following shader code to create a border to the shape

// Determine if this pixel is within the border
fixed borderCenter = 0.5 + _BorderSize;
fixed border = 1 – smoothstep(borderCenter - _BorderSmoothing,
                              borderCenter + _BorderSmoothing,
c.rgb = lerp(c.rgb, _BorderColor, border);
Figure 7: Darker borders give our stain a less flat look

This is better, but it still does not look like water paint. There is no color difference inside the stain.  We need something to add a little bit of detail.

This is where we dropped our no-textures rule as we decided that the simplest solution would just be to take one high resolution, tiling, paint texture and use that as an overlay for the stain. Since stains never moved after they are placed, we used world space coordinates as UV’s for this detail texture. The result was as follows:

Figure 8: The detail texture we use to make the stain look like water paint Figure 9: The stain with the detail texture applied

This is starting to look pretty good. The last thing we did was add an alpha mask over the whole stain to make it fade out in one direction. This part could use some improvements, but at this point reality caught up and we had to focus on other parts of the game to get it released in time.

Figure 10: The mask texture we use to fade out the stain. The in-game asset is scaled down to 32×32 Figure 11: The stain with the mask texture applied

Making it faster

The generating of the stain initially already was quite fast, since it only has to blit the very small SDF images. Unfortunately. on slower Android devices it still caused a bit of a frame drop. So, we decided to do the blitting on a background thread.

Since the Unity Texture API only works from the main thread we had to keep a copy of the pixel arrays for both the drops and the stain texture. After the blitting was finished, we still have to call Texture.SetPixels and Texture.Apply on the main thread. Luckily these functions are fast enough to not cause any problems when only called once in a frame.

In conclusion and future work

Pfew, that was quite a write-up. We are happy with the results but there are a lot of improvements we would like to implement in the near future:

  • Our example water paint stain has many small drops surrounding only one big drop. Our generator tends to generate multiple big drops and only a few smaller drops. This should be easily adjustable with a couple of extra parameters.
  • The alpha mask fade-out is not very elegant and looks very uniform. Perhaps we could use another texture channel to indicate paint thickness and base the fade out and border intensity on that information.
  • Our detail texture is also a bit uniform, which is necessary since it is tiled in world space. Ideally, we would want to be able to add some hue and saturation variation to it. We could easily generate this based on Perlin noise.

As we are planning a PC release for Doctor Flow as well, we might pick this up and improve our stain generator for that release. In the mean time I hope you’ve learned something about the awesome versatility of signed distance fields!

Share this post on: