Website powered by

Tints and why you shouldn’t use them

General / 07 April 2023

Anyone who works with game art is probably familiar with tints.
They are one of the most commonly used techniques among game artists and level designers to quickly adjust the visuals of their assets.
To use a tint, you just multiply a color with your existing color information (often coming from a texture):Because it's is a simple math concept, it's easy to add to any material and is often used routinely.
Tints can be used to match colors between multiple assets, create variations to hide the repeated use of the same asset or to fine-tune visuals without the need to adjust the original artwork.
However, as it is often the case with widely used tools, they are often used without making sure that they are the best solution for the given situation. When used incorrectly, they can compromise both the quality of your artwork and the efficiency of your workflows.

The purpose of this article is to point out some of the pitfalls of tints and how to avoid them. And while the hyperbolic title might suggest otherwise, the goal of this article is not to condemn tints in general. Rather, the goal is to raise awareness of when and how to use them properly, rather than routinely applying them to all materials. They are still a valuable tool. They are just used far more often than they should be.
Disclaimer: While the topic of this article is engine-agnostic, I'm an avid Unreal user, so sometimes I'll slip into Unreal jargon or mention Unreal-specific features. But there is usually something equivalent in your game engine of your choice.

Single Source of Truth

Level designers and environment artists love tints to quickly adjust the colors across their levels and adjust the artwork for that final layer of polish that turns individual assets into a cohesive whole. And yes, it's fun to adjust the colors for individual assets throughout your level and tweak the color of each individual instance. The problem with this workflow is that the tint is not the only place in the pipeline where adjustments are made. There are usually a few more:

  1. The texture artist defines the original color of the asset, usually following an art guide with a defined color palette.
  2. Engines like Unreal allow you to adjust the colors on textures during the import process. This can be very useful to ensure that a set of textures will work together without having to touch the original artwork.
  3. The texture can be modified in the material in many ways, but for the sake of simplicity, let's say that the material only applies a tint and returns the result as the base color.
  4. Once a material is applied to a mesh in a level, the final color of the material is not just determined solely by the material's base color output, but also by the lighting in the scene.
  5. In most cases, the final image is color-graded in multiple ways. Depending on what's in your post-processing stack, these adjustments can range from simple tonemapping, color grading to the use of LUTs and other shader effects.

Often, artists are unaware of how exactly the final color is achieved and thus adjust the visuals in the wrong place.

Let's say the lighting of a level is overly warm and the lighting artist wants the lighting to be cooler. But instead of reworking the lighting, they adjust the color grading. This can work fine and even look good at first glance, but can cause ramifications down the line: Now the next time the character artist has a look at the characters in the level, the skin got an unhealthy color, so they decide they have to adjust their textures to make sure the characters don't look that pale anymore. Now the lighting, the post processing and the textures are essentially fighting each other, becoming more and more extreme as a result, making every future adjustment a gamble since it's impossible to know how the next adjustment will affect this balance of power.

So when you see the final result in the viewport of your game engine and you don't like what you see, before you make any adjustments, ask yourself the question: Where in the pipeline is the error introduced and where do I want to fix it?

  • If the colors of the original texture are just plain wrong, adjusting the original source file or at least the import settings will yield the best results. Otherwise, it's easy to get stuck in a workflow where an identical tint is used on a variety of materials, all doing the same thing, just to fix a texture that doesn't fit the project's color palette. Eventually, another artist is going to use the same texture on another asset, without knowing about the tint they are supposed to use, resulting in inconsistent visuals. Or, even worse: You manage to keep your tints consistent, but then, months or years into the project, it's decided to adjust the game's color palette. Now, instead of just adjusting a single source texture, you find yourself tracking down dozens of materials to adjust all the tints that do the same thing.
  • If you suspect the lighting to be the culprit, make sure you have a neutral test scene in which to evaluate your assets. If the asset looks as intended in the test scene, you know that you need to change your lighting. And while PBR has been the standard for game rendering for years now, there are still people who need to hear it: Do not adjust your textures to fix your lighting!
  • Be careful with tinting when it comes to lighting. Unreal for example allows you to tint your skylight. While this can be helpful, be aware of the fact that there is usually a reason for the skylight to be the same color as the sky.
  • Finally, always make sure that your colors look at least OK without any color grading and post processing. Post processing should only be used for creative choices after the basic visuals are already working, not to fix problems with any of the previous steps.

While this has become a very long paragraph, it boils down to this: Make sure that no matter what you want to change, there should ideally be only one place to change it. Once you start to make adjustments to the same property in multiple places, the potential for unwanted behavior and inconsistencies increases exponentially. So if you don't need a tint, adding a tint option to your materials will only increase this.

In programming, this practice is called the Single Source of Truth. The idea is to always store and edit data in a single place, so that if the data is different than expected, you only have to look for the problem in that one place.

You could argue that this is not strictly a tint-related issue and you would be right. Still, extensively used tints are often one of the reasons why the visuals of a scene become unpredictable.

The Psychology of Tints

An interesting fact about tints is that many people who have them in their toolbox just want to use them. Maybe it's just a sense of fun, maybe it's because level designers are often given finished assets by artists and rarely have the opportunity to live out their artistic ambitions, but if you give people a tint, they'll use it. Even if it's not needed. And that's why you'll often see level designers tinting the grass green, the sky blue and the sand yellow. It's like a natural instinct that's hard to get rid of.

You'd be surprised how many real-life examples of this kind of tint exist!
The thing about these tints is that they actually hurt the visuals. They may look inconsequential at first sight, but most materials in the real world are not just a plain color. Grass isn't just green, it can also have yellowish tones, brown patches where it grows less densely or even contain completely different colors in the shape of small flowers, pebbles, and so on. Tinting a texture with its predominant color removes all of these subtleties while raising the saturation to levels that are usually unwanted.
It can also hurt image quality, which brings me to my next topic:

Tints don't do what you think they do - Luminance

Tints are often used to adjust the hue of texture, for example, to make a green grass texture appear yellowish. They also tend to increase saturation, as long as the tint color isn't a grayscale value or has a hue on the opposite side of the color wheel.
But since tints are a multiplication, and the used RGB values are usually between 0.0 - 1.0, they also affect the luminance, and the result will always be darker than before.

Why is this bad?

Especially since the advent of PBR rendering, the luminance of a texture isn't something you should change on a whim. With PBR, there are actually 'correct' values for base colors that can be measured in reality. Even if the texture is not based on that, the luminance values of textures within a project should maintain a certain consistency, and if they don't, that's not something to be fixed in a material.

Ok, but what if I still want to adjust luminance?

When adjusting the luminance of a texture, multiplying it by a constant factor isn't the way to go, because it removes all contrast from the texture. Using a tint to halve the luminance of a texture reduces the luminance range of the texture, resulting in a flat, unappealing texture that contains less detail:

It's even worse when using values higher than 1.0, since now we introduce color clipping:

As you can see in the image above, now a whole bunch of bright pixels that previously had different color values in all three channels now reach the limit of the color space and are clamped, resulting in areas that look like a uniform color.
The solution in both of these cases is to use exponentiation. By raising the color values to a power higher than 1.0 (to darken the image) and lower than 1.0 (to brighten the image), we ensure that the value range stays mostly intact and we primarily change the distribution of values in that range.

I really want to use tints, but without affecting the luminance, is there a way to do this?

While it adds some instructions, if you want to be on the safe side, you can normalize your tint before multiplying it with your texture:

This ensures that the luminosity stays roughly the same:

This works fine for rather neutral textures or subtle tints, but there are cases where it won't help:

Tints don't do what you think they do - Channels

Now that we've established that tints can inadvertently break the luminance of your textures, let's take a look at the next level of this problem. In the previous section, I considered luminance to be basically a single value, but as we all know, luminance exists for each channel separately, and it's easy for each individual channel to cause problems. The worst thing you can do with a tint is to try to change a texture to its complementary color.

Above, you can see how a predominantly green grass texture is tinted with a pure red. As you can see especially well in the desaturated version, the texture is noticeably darker as a result. This is because the green and blue channels of the red color are, not surprisingly, both 0.0.
So these channels of the source color are completely erased.
If a texture is to be used only in combination with tints, this problem can be solved by desaturating it:

Still, the subtle color shifts of the original texture are lost. The original texture had yellow flecks and different shades of green, but all those subtleties are gone. There are ways to preserve these details, described below.

Alternatives to Tints

So far, this article has mostly been a rant about why tints can cause problems. Let's now take a look at better ways to handle color adjustments at runtime:

Hue Shift

An easy to implement and effective way to add some color variation is a hue shift. Since it only changes the hue, both contrast and chromatic range are safe from unwanted changes. In the example above you can see how the hue-shifted texture still has the characteristic color changes of the source texture, while the red tint just completely erases these details.
The math behind the hue shift is a bit more complex, but still easy to understand and use. Unreal already provides a material function for this, but if you are using another development environment for your shaders, just think of your color as a vector and the hue shift as a rotation.

One problem with the hue shift is that the final result changes drastically depending on the hue of the source texture. So the same hue shift value will produce very different results for different textures.

Use HSV Color Representation

Since using RGB values for color adjustments is not very intuitive and can often cause unwanted side effects, consider converting your color information to the HSV format. It is often easier for artists to adjust these values:

Be careful though, the conversion back and forth between the color representation models does add a considerable number of instructions, so you may not want to routinely add this to all of your materials.

The Agents of Mayhem Way

This technique is more complex than the usual tint, because it requires you to create textures specifically for use with the tint. However, when used correctly, it can produce remarkable results. It was used in Volition's Agents of Mayhem, and was presented and explained in detail by James Taylor at the GDC 2017: https://www.gdcvault.com/play/1024690/-Agents-of-Mayhem-Physically
The technique is based on the insight that one of the biggest issues with tints, as described above, is that tints often clash with the original color of the texture. So what happens if you remove that color from the source texture, only keep the deviations from that color, and then add the color back in the shader? That's exactly what this technique does:

For this to work, you need to have a version of your texture that is basically a grayscale texture, where the only color present is the color difference from the tint color. This color is then desaturated and the difference is used as a mask between the original texture and the tinted version. For all the additional details I highly recommend going through the linked presentation slides; there are some really interesting insights into about how tints work and how they can be used effectively. And while Photoshop is the tool of choice in this case (as the presentation has aged a bit), it can be effectively recreated in Substance Designer and used as a filter in Substance Painter.

Update from 05.05.2025  

I was very intrigued by Agents of Mayhem's approach to customizable colors, so I took a lot of inspiration from it when designing the color customization system for Park Beyond. In Park Beyond, the base color texture is authored using a placeholder color and the texture is then processed to only contain the difference to that placeholder color. The custom color is then added in the shader:

As explained in the dev blog, the upcoming game Croakwood uses pretty much the same approach, but uses the OkLab color space, which aims to maintain a consistent perceived luminosity as the hue changes, to ensure that the displayed color better matches the color set by the user.

If you enjoyed this article or found it useful, please share it with others! If you have any comments, questions, or feedback, please post them below. To get informed about new articles, follow me on Artstation, on Mastodon or Bluesky.

You can also find this article on my WordPress blog.

Laplacian Texture Blending in Substance Designer

General / 09 March 2025

One of the most common operations when authoring materials is blending together different textures using a mask. It's a great way to create new textures by combining existing ones, or to have more options for adding variation during rendering.

But very often when you use a soft mask, this is what you get:

Both textures, the forest ground texture and the pebbles texture, are simply layered on top of each other, and details such as sticks and pebbles are noticeably transparent. Also, in the areas where the textures are displayed with reduced opacity, the effective contrast of the resulting texture is noticeably lower than in the areas where only one texture is displayed.

There are several ways to fix/improve this situation:

  • Using height-blending, you can create a mask that is affected by the heights of the details. This allows you to use a higher mask contrast without creating a noticeable edge.
  • You can increase the contrast of the result in the blended regions based on the opacity of the source textures to maintain an even contrast throughout the texture.

Neither solution is perfect. Increasing the contrast in the blended areas doesn't fix ghosting. And height blending is only an option when height maps are available, which isn't always the case. And even then, its usefulness depends on fine-grained, noisy height maps, and on all contrasty details actually being present in the height map. So if a material has a lot of details in the base color that aren't present in the height map, height blending won't help preserve them during blending.

Another approach to this problem was recently proposed by Bart Wronski, research scientist at Nvidia. It got published here in the Journal of Computer Graphics Techniques.
I highly recommend reading it because it has two very strong qualities: It's a) very clever and b) relatively simple once you understand how it works.
The technique is called Laplacian texture blending, because it uses a Laplacian pyramid.
I won't describe the technique in detail (that's what the paper is for), but the basic idea is that instead of blending the two textures with one mask, the textures are split into several frequency bands. These frequency bands are then blended together using separate masks. The high frequency bands are blended with a high frequency mask, while the low frequency bands are blended with low frequency (read: blurred) versions of the mask. This way, the contrast of the small details in the textures is preserved, while the low-frequency elements, such as the dominant colors, are blended very softly.

Let's see what the result looks like:

Compared to the classic blend, ghosting is dramatically reduced. Details remain intact and distinct, and local contrast remains consistent throughout the texture.
When I read the paper, I immediately wanted to try and implement the technique myself, and that's how this blog post came about.
It's basically a documentation of my implementation of this technique in Substance Designer, but written as a tutorial so that you can easily implement it yourself and hopefully avoid some of the problems I encountered.

A few notes first:

  • I'm using Substance Designer 5 for this tutorial. That's because I'm old and grumpy and still remember the days when Allegorithmic was not owned by Adobe and offered perpetual licenses for their products instead of forcing you to subscribe to them. The tutorial is still valid for newer versions, but in one or two places, newer versions may offer more elegant solutions to some problems.
  • This tutorial explains how to implement the technique with a fixed number of mip levels. If you're actually going to use this in production, I would recommend letting the user control the number of levels so they can control how soft they want the blend to be.
  • Since the tutorial only covers how to implement the technique in Substance Designer, I highly recommend reading Wronski's paper to understand how the technique works before starting the tutorial, as I won't go into the details.

Step 1 - Separating the Frequency Bands

The first step is to separate the frequency bands present in the mip maps of the input textures. Due to the smaller resolution, each mip map in the chain loses the highest frequencies, as there are not enough pixels anymore to display them. Therefore, you can isolate these frequencies by subtracting the smaller mip map from the bigger one:

There's a problem with this, though: Since the differences between the mip maps can be both positive and negative, the Subtract node removes about half of them because the output is clamped to the 0-1 range.

Note: Newer versions of Substance Designer apparently offer the option to have nodes that work in an HDR range outside of 0-1. I haven't been able to verify this, but it's something to consider, as it will make your life easier.

To avoid going outside the 0-1 range, you need to offset the values in the two inputs so that the output isn't centered around 0.0, but around 0.5, similar to how normals are stored in normal maps. To avoid losing any information due to the offset, the range of the input texture has to be reduced as well. This is what the resulting graph looks like:

The OffsetColor Node

The OffsetColor node in the screenshot above is a custom node because Substance Designer doesn't provide one by default. It simply combines three Histogram Shift nodes:

Their Position parameters are all mapped to the same shared Input Parameter.
Once this setup works, it's time for some copy and paste:

In this graph, the previously used Bitmap node is now replaced by an Input Color node. Each frequency band is connected to a separate Output node. Note that I’m referring to them as Levels in the comments, because that’s the name used in the paper, but in the context of the tutorial, I prefer the more descriptive term frequency band.
Finally, the smallest mip map, called the Gaussian Level here, is output without any modifications. It contains all the lower frequencies not present in the diffs above.
It's up to you how many levels you want to use, depending on how smooth you want the transition to be. Each step makes the blend softer. The paper uses 4 levels as an example, but for my test scenario, 4 was not soft enough. As the number of needed levels depends heavily on the dominant frequencies of both the textures and the mask, I recommend to expose the number of levels as a parameter. Ideally, it could even be a float parameter that mixes the results of the versions with the number of levels rounded up and down.

Step 2 - Blending the Frequencies separately

Now that you have separated the frequency bands, blend them separately. Start with the Gaussian Levels and blend them using the appropriately mip-mapped mask. The frequency bands are blended in the same way. For each blend, the appropriate mask mip map has to be used. The one with the highest frequencies uses the full resolution mask, the next uses the half resolution mask, and so on.
Then all the blended frequency bands are added to the blended Gaussian Levels. Luckily, Substance Designer offers an AddSub blend mode that subtracts (0.5 - foreground value) for foreground values below 0.5 and adds (foreground value - 0.5) for foreground values above 0.5, so there's no need to do any additional math to get rid of the offset added earlier. 
Notice in the screenshot how each AddSub blend node increases the resolution. Since the background input is the one used as the reference when the node's size setting is set to Relative to Input, each blend node needs a +1 offset to the input resolution, otherwise the size of the Gauss levels would be propagated to the top.

And that's it, you're done. The finished graph can now be used just like a classic blend mode:

A word of advice before testing: Since the mask is only blurred, not sharpened, the contrast of the original mask will dictate the highest possible contrast in the blended result. I'd recommend using masks that are either completely binary or close to it.

Performance

Since creating textures in Substance Designer doesn't need to be done in real time, performance wasn't a high priority for this implementation. I didn't look into performance improvements like skipping layers, and 7 layers would probably be a bit heavy if used in a real-time context. If you're using this implementation multiple times in an already complex material, you might want to check the computation time anyway, and I'm sure I've left some optimization potential on the table.


If you enjoyed this article or found it useful, please share it with others! If you have any comments, questions, or feedback, please post them below. To get informed about new articles, follow me on Artstation, on Mastodon or Bluesky.

You can also find this article on my WordPress blog.

Fixing normals on stretched meshes in Unreal

General / 25 November 2023

The Problem

While I usually avoid scaling meshes non-uniformly, there are situation when you're kitbashing where it can be really tempting. At one point I was stretching a rock quite a bit to create a rock wall, and then I stumbled upon this phenomenon:

Even though the stretched rock clearly had the shape of a wall, the way the surface was shaded implied that it still had a cylindrical shape, to the point where it almost looked like I had stretched the rendered image instead of the mesh.

The Cause

After playing around with other meshes, I could confirm that Unreal does not adjust vertex normals when a mesh is scaled non-uniformly. So while the surface angles change due to the distortion of the mesh, the normals just stay the same.
Searching for advice on this topic, I quickly found this thread:
Non-uniform scaling produces incorrect vertex normals
To summarize: Epic is aware of the limitation and has no intention of fixing it. And I really understand this attitude: Non-uniformly scaling meshes isn't really something you should be doing anyway. Stretched meshes quickly become obvious, so if you really need a mesh with different proportions, it's usually a better choice to just start your 3D software and adjust your mesh in there properly.
If meshes are scaled non-uniformly, it's usually because you're prototyping something or participating in a game jam. So, in most cases, adjusting the vertex normals to compensate for stretching issues would just add extra work for the GPU. At the same time, while the normals in the image above are incorrect, it's not something that many people will notice right away, so if it happens here and there, it's not the end of the world.

Still, it's a compromise, and so it's likely that at some point you'll run into this limitation and want to fix it. So let's do that.

The Fix

The thread linked above already tells us what we need to do: Invert and transpose the object's transformation matrix and multiply it by the vertex normals to get the correct normals.

If you're familiar with Unreal's material editor, you probably already know that it doesn't work with matrices. To use matrices, you basically have to use multiple vectors that make up the matrix. It works, but it feels clunky, and even simple calculations can require a lot of wiring. So I knew from the start that I wanted to use a custom node and work directly with HLSL code.

I also knew I needed the transformation matrix of the object. Finding it was easy enough. You can get the HLSL code for any material from the material editor:

The generated HLSL code contains not only the code equivalents of the nodes placed in the editor, but also a lot of boilerplate.
I quickly found a function called GetLocalToWorld3x3() that returns the transformation matrix of the object. And even better, there's a function called GetWorldToInstance(), which, as you may have guessed, returns the inverse rotation matrix.
One detail that was new to me was the return type: As a long-time Unreal Engine 4 user, I didn't know what a FLWCInverseMatrix was. After looking it up, it turned out to be a matrix for Large World Coordinates, a new feature introduced in UE5 to solve precision problems in very large levels. Since the position of the object is not relevant for this purpose, I knew I didn't really need the precision and could convert to a matrix with regular floats.
The release notes of UE 5.1 already mention a function called LWCToFloat().
So I looked for that function in Unreal's shaders folder and found LargeWorldCoordinates.ush, which contains a lot of helper functions, including
LWCToFloat3x3(FLWCInverseMatrix Value).
So at this point, I had everything I needed. The resulting custom node contains only 2 lines:

float3x3 invMat =LWCToFloat3x3(GetWorldToInstance(Parameters));
return mul(Normal, transpose(invMat)).xyz;

The node has an input for the normal, multiplies it with the inverted and transposed transform matrix and returns the result:

Results

Let's take a look at the stretched rock mesh from the beginning:

At the bottom, you can see the the same stretched rock again. In the middle, you can see the rock with the adjusted material. The difference is immediately obvious. The lighting is now much more even and really matches the shape of the mesh, which is now essentially a wall.
At the top, you can see another version of the mesh. This mesh has been stretched in Blender, and the normals were recalculated. You'd probably expect this version of the rock to look identical to the one in the middle with the normals adjusted, but it looks more like a mixture of the other two versions. That's because this rock is still using the same normal map. If stretched the high poly and low poly versions of the rock and baked a new normal map, the result would be much closer to the rock in the middle.

To have a less specific example, here is a comparison of a stretched mirror spheres with and without the fix:

Both the reflections and the positions of the specular highlights illustrate how the normals change in the fixed version.

Performance

The fix described above added 19 instructions to the pixel shader. While this will not cause performance issues in most cases, it is a cost to be aware of and should not be routinely added to materials if your project doesn't need it.

Update

Shortly after I posted this article, Brian Karis, Senior Graphics Programmer at Epic Games, commented on it, explaining that he had toyed with the idea of fixing this problem when Nanite was introduced, but decided against it because a proper fix would require several more changes in other parts of the engine. He also suggested fixing it on the CPU when creating the transformation matrix for the primitive.
https://mastodon.gamedev.place/@BrianKaris/111472135168861147
I'll update this article once I've looked into his suggestions. For now, though, I think the fix described above is a reasonable workaround for people who don't want to delve into engine modifications, but be aware that the process isn't 100% correct, so your mileage may vary.


If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, post them below. To get new posts, follow me on Artstation.