UV Shader Nodes
August 22, 2022
This post provides several reference plots of (mostly UV-related) shader nodes within Unity, with their implementation formulas as well as the ability to hover over the plots to see the exact values output by these nodes. Importantly, hovering allows you to inspect values outside of the 0.0 to 1.0 display range! These examples all come from Unity’s shader graph system, though other systems (e.g. Unreal’s material editor or Blender’s shading editor) have similar nodes where the same intuition would apply.
This is largely for my own reference/experimentation. While I’ve tried to make sure all the data and behaviors are as close to correct as possible, some edge-cases may not be handled correctly.
Unlike the other nodes here, the UV node is not a function or a transformation, it’s just represents raw data. Every mesh that has been set up for texturing will have a pair of numbers (called U and V) associated with every point on the surface of the model. Intuitively, these numbers are like coordinates describing where you are on the 2D surface of a 3D object. A bit like how latitude and longitude can act as a 2D representation of where you are on the surface of the Earth (ignoring altitude). It’s important to note that these coordinates are completely arbitrary: they’re decided by whoever made the mesh to begin with, and there can be more than one set of UVs for a single model.
Even though UVs are mesh-dependent, shaders are not (a single shader can be applied to many different meshes). Therefore the depiction of UV data as a shader node is generic. U values are represented with the red channel and are mapped to the x-axis, going from 0.0 (left) to 1.0 (right). Likewise, V values are represented with the green channel and are mapped to the y-axis, going from 0.0 (bottom) to 1.0 (top). Click or hover over the UV node below to see the corresponding U (red) and V (green) values associated with any given point.
(+0.00, +0.00)
All other nodes presented below take this UV data as an input and provide some sort of transformed output. Sometimes the transformed values will land outside of the original 0.0 to +1.0 range, in these cases, no color will be rendered, though you can hover to see the underlying values.
Alternatively, the Input
control on each node allows you to use the UV map to sample from an image so that the distorting effects of the nodes can be seen in a more intuitive way. For example, if a given point has a UV value of (0.25, 0.5), then that acts as an instruction to replace that point with the pixel from the selected image located 25% of the way along the x-axis and 50% of the way along the y-axis. For the base UV node above, selecting an image results in seeing the (undistorted) image itself, since U and V map directly to x and y sampling coordinates in this case.
These nodes aren’t specific to UV data, but they’re important enough to include here for reference. The Add
node provides a scrolling effect when used with image data. The Multiply
node provides scaling effects unsurprisingly, but the behavior is counter-intuitive when working with image data since decreasing the multiplication factor increases the size of the image. This makes sense if you take some time to think about it, but it’s easy to get this backwards, a bit like a cognitive reflection test.
(+0.00, +0.00)
(+0.00, +0.00)
This node provides convenient outputs for rotation-based effects. The red channel outputs the radial distance from the Center
coordinate, while the green channel represents rotation from the +y-axis. By default, this node generates negative values, which can result in a somewhat confusing looking plot. For example, with default settings, the rotation data (green channel) ranges from -0.5 to +0.5 which produces an image with very little green coloring since only the 0.0 to +0.5 values are displayed. However, if you hover over the left half of the plot, you can see how the underlying rotation (green) data is actually varying.
(+0.00, +0.00)
Shader function:
hlsl
// Get Centered UVsfloat2 delta = UV - Center;// Calculate polar output componentsfloat radius = length(delta) * RadialScale * 2.0;float angle = atan2(delta.x, delta.y) * LengthScale * (1.0 / TWO_PI);return float2(radius, angle);
This node rotates all values around a given pivot point by a given rotation angle. This one is conceptually straight forward, but if you move the center point at all, it’s very easy to end up with incomprehensible UV values due to rotating in values outside of the 0.0 to 1.0 range.
(+0.00, +0.00)
Shader function:
hlsl
// Get Centered UVsfloat2 delta = UV - Center;// Calculate rotation termsfloat rotation_radians = Rotation * PI / 180.0;float sin_term = sin(rotation_radians);float cos_term = cos(rotation_radians);// Calculate rotated coords. (2D rotation)float x_rot = delta.x * cos_term + delta.y * sin_term;float y_rot = -delta.x * sin_term + delta.y * cos_term;// Reset positioningfloat out_x = x_rot + Center.x;float out_y = y_rot + Center.y;return float2(out_x, out_y);
Fun looking! I’m sure it could be used for some interesting artistic effects. It’s similar to the rotation node, except that the rotation angle is reduced to zero as you get closer to the central pivot point.
This node (and several others) contains an Offset
control, though a more intuitive name would be Scroll
. It acts exactly like having an Add
node following the twirl effect, which may seem odd to have built-in to the node, but the result is pretty cool. Try it on image data to see the full effect.
(+0.00, +0.00)
Shader function:
hlsl
// Get Centered UVsfloat2 delta = UV - Center;// Calculate rotation termsfloat angle = Strength * length(delta);float cos_term = cos(angle);float sin_term = sin(angle);// Calculate twirled coords. (2D rotation)float x_twirl = cos_term * delta.x - sin_term * delta.y;float y_twirl = sin_term * delta.x + cos_term * delta.y;// Reset positioning and apply offsets for final outputfloat out_x = x_twirl + Center.x + Offset.x;float out_y = y_twirl + Center.y + Offset.y;return float2(out_x, out_y);
It would be fair to call this the stair step node or even a pixelate node. It’s not specific to UV data, but the effect is very easy to see with UVs. It converts a continuous range of values into a small number of equally sized regions of constant value. Intuitively, the Steps
input determines how many regions you’ll get in the 0.0 to 1.0 range. It’s worth noting that a step value of 0 results in undefined behavior (Unity renders a magenta image in these cases).
(+0.00, +0.00)
Shader function:
hlsl
return floor(Value * Steps) / Steps;
While quite useful, the tiling and offset node exhibits some unintuitive behavior at first glance. For example, the UV plot doesn’t appear to tile at all! In fact, it would be more correct to call it a scaling and offset node, or even a y = mx + b node, since that’s exactly what it does. However, it really does have a tiling effect when used to sample textures, since sampling seems to have a built-in wrap-around behavior.
(+0.00, +0.00)
Shader function:
hlsl
return (Value * Tiling) + Offset;
Combining a fraction node with the tiling and offset node mentioned above gives the behavior you might expect from the name tiling and offset. For positive numbers, the fraction node gives the fractional part of the input (i.e. given 2.73 as an input, the output from the fraction node would be 0.73). For negative numbers the behavior is similar though a bit less intuitive. Nevertheless, this is a surprisingly useful node, and definitely worth remembering.
(+0.00, +0.00)
Shader function (fraction only):
hlsl
return Value - floor(Value);
This node doesn’t make a lot of sense from the UV plot, but is very intuitive when applying it to an image. As with the twirl node, there is an Offset
control, which can create a very interesting scrolling effect. It’s most likely useful for VFX. Try it with negative strength values!
(+0.00, +0.00)
Shader function:
hlsl
// Get Centered UVsfloat2 delta = UV - Center;// Calculate spherize componentsfloat length_squared = dot(delta.xy, delta.xy);float length_to_4th_power = length_squared * length_squared;float2 sphere_offset = delta * Strength * length_to_4th_power;return UV + sphere_offset + Offset;
This is another node that only seems to make sense with image data. Varying the Offset
property gives some pretty wild looking distortion that’s probably useful for VFX. Maybe for flowing water?
(+0.00, +0.00)
Shader function:
hlsl
// Get Centered UVsfloat2 delta = UV - Center;// Calculate shear scalingfloat length_squared = dot(delta.xy, delta.xy);float2 shear_scale = Strength * length_squared;// Calculate shear componentsfloat x_shear = delta.y * shear_scale;float y_shear = -delta.x * shear_scale;float2 xy_shear = float2(x_shear, y_shear);return UV + xy_shear + Offset;
This node definitely has the most specific use-case of all other nodes presented here. It’s purpose is to subsample rectangular regions of the UV space. If an image is used, then it provides the cropping you might need for a texture altas/spritesheet/tile set.
The somewhat confusingly named Width
and Height
controls are used to specify the number of tiles in the x and y directions (respectively) across the UV space. It might be easier to think of these inputs as the number of Columns and Rows instead. The Tile
control is a 1D index into the 2D grid of tiles, in row-major order. The inverting controls determine where the tiling begins as well as the ordering sequence.
Try switching to the flipbook image input and then changing the Tile
control to see the effect. Make sure to also set Width
to 5 and Height
to 2 to account for the tiling within the image.
(+0.00, +0.00)
Shader function:
hlsl
// Calculate tile sizing in UV units// Note Width/Height controls actually represent// the number of columns and rows, respectivelyfloat tile_width = 1.0 / Width; // i.e. 1.0 / Num Columnsfloat tile_height = 1.0 / Height; // i.e. 1.0 / Num Rows// Figure out the grid indexing of the selected tilefloat col_idx = floor(Tile) % Width;float row_idx = floor(Tile * tile_width) % Height;// Find x offset needed to crop the selected tilefloat tile_offset_x = col_idx * tile_width;if (InvertX) tile_offset_x = 1.0 - tile_width * (1.0 + col_idx);// Find y offset needed to crop the selected tilefloat tile_offset_y = row_idx * tile_height;if (InvertY) tile_offset_y = 1.0 - tile_height * (1.0 + row_idx);// Apply scaling & offsets for outputfloat out_x = (u * tile_width) + tile_offset_x;float out_y = (v * tile_height) + tile_offset_y;return float2(out_x, out_y);
To help clarify the inverting behavior, the diagrams below show how the Tile
index samples from it’s grid (with Width = 3
and Height = 2
in this case) with each of the different inversion settings. The large circles indicate where the first tile would be located (i.e. Tile = 0
), with the arrows showing how the grid cells are indexed as Tile
increases. The indexing wraps back to the start if the Tile
value increases past the number of grid cells.
Warning: The implementation above deviates from Unity’s shader graph implementation when using fractional tile sizes (i.e. Width
or Height
values that are not integers). The code and implementation presented here is focused on providing an intuitive grid-based description of the behavior. While Unity’s implementation appears grid-based with integer values, it does something quite a bit more complex when switching to fractional values. I’m not sure that non-integer values are relevant in practice, so I don’t think this would be much of an issue, however, if you do use non-integer sizes just be sure to test the output directly within Unity!
Unlike the other UV-related nodes, the triplanar node seems to depend on mesh/normals data, so I haven’t included it here. I may revisit this node in the future, once I’m more familiar it’s operation and have some interesting sample data to work from!