Optimization

Case study

Rendering

Tech-Art

Intermediate

Optimizing Unity's voronoi node

10 min

Voronoi is one of the most common noise types used in shaders. In this article, I will optimize Unity's implementation and profile it on a Redmi 12 4G.

:center-50:


I prepared a fullscreen version of this shader. My goal is to run several optimization passes on mobile and see how far I can push the node's performance.


How Voronoi works

Before optimizing Voronoi noise, I will explain how it works.

The general idea is to generate points at random positions.


Then, for every pixel, calculate the distance to the closest point.


Visualizing this distance creates a cellular pattern.


However, evaluating every pixel against every generated point would be extremely inefficient.


To reduce this complexity, Voronoi implementations usually use an underlying grid.


1. Grid division

Voronoi noise divides UV space into a grid. It starts by locating the cell closest to the UV sample:

float2 g = floor(UV * CellDensity); // Root position of the cell
DebugColor.rg = g * 0.1 - 0.5; // Displaying the root position of the cell
float2 g = floor(UV * CellDensity); // Root position of the cell
DebugColor.rg = g * 0.1 - 0.5; // Displaying the root position of the cell
float2 g = floor(UV * CellDensity); // Root position of the cell
DebugColor.rg = g * 0.1 - 0.5; // Displaying the root position of the cell


2. Cell UV

Next, it finds the UV coordinates within the cell:

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell
DebugColor.rg = f

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell
DebugColor.rg = f

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell
DebugColor.rg = f


3. Calculating a random point for each cell

It calculates a random point inside the cell using hash functions:

float2 Unity_Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 + 0.5

float2 Unity_Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 + 0.5

float2 Unity_Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 + 0.5

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell

// Random position inside the cell
float2 offset = Voronoi_RandomVector_Deterministic_float(g, AngleOffset);

DebugColor.rg = offset

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell

// Random position inside the cell
float2 offset = Voronoi_RandomVector_Deterministic_float(g, AngleOffset);

DebugColor.rg = offset

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell

// Random position inside the cell
float2 offset = Voronoi_RandomVector_Deterministic_float(g, AngleOffset);

DebugColor.rg = offset

The colors now represent unique cell positions.


4. Distance from UV to the random point

Next, I calculate the distance from the UV sample to this point:

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell

// Random position inside the cell
float2 offset = Voronoi_RandomVector_Deterministic_float(g, AngleOffset);

// Distance from UV to this random position (in cell space)
float d = distance(offset, f);

DebugColor = d

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell

// Random position inside the cell
float2 offset = Voronoi_RandomVector_Deterministic_float(g, AngleOffset);

// Distance from UV to this random position (in cell space)
float d = distance(offset, f);

DebugColor = d

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell

// Random position inside the cell
float2 offset = Voronoi_RandomVector_Deterministic_float(g, AngleOffset);

// Distance from UV to this random position (in cell space)
float d = distance(offset, f);

DebugColor = d


It now looks more like Voronoi noise, but it has a tiling problem.


5. Fixing the tiling issue

To fix the tiling problem, I must consider neighboring cells, not only the cell containing the UV sample. I will iterate through a 3x3 window and find the shortest distance to any neighboring point.


This is the modified code:

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell

// Iterate over a 3x3 kernel and calculate the distance to the closest point.
float minDistance = 10.0;
for (int y = -1; y <= 1; y++)
{
	for (int x = -1; x <= 1; x++)
	{
		// Cell offset
		float2 lattice = float2(x, y);

		// Random position inside the cell
		float2 offset = Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);

		// Distance from UV to this random position (in cell space)
		float d = distance(lattice + offset, f);

		// Save the closest distance.
		if (d < minDistance)
			minDistance = d;
	}
}

// Display the closest distance.
DebugColor = minDistance

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell

// Iterate over a 3x3 kernel and calculate the distance to the closest point.
float minDistance = 10.0;
for (int y = -1; y <= 1; y++)
{
	for (int x = -1; x <= 1; x++)
	{
		// Cell offset
		float2 lattice = float2(x, y);

		// Random position inside the cell
		float2 offset = Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);

		// Distance from UV to this random position (in cell space)
		float d = distance(lattice + offset, f);

		// Save the closest distance.
		if (d < minDistance)
			minDistance = d;
	}
}

// Display the closest distance.
DebugColor = minDistance

float2 g = floor(UV * CellDensity); // Root position of the cell
float2 f = frac(UV * CellDensity); // UV of a single cell

// Iterate over a 3x3 kernel and calculate the distance to the closest point.
float minDistance = 10.0;
for (int y = -1; y <= 1; y++)
{
	for (int x = -1; x <= 1; x++)
	{
		// Cell offset
		float2 lattice = float2(x, y);

		// Random position inside the cell
		float2 offset = Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);

		// Distance from UV to this random position (in cell space)
		float d = distance(lattice + offset, f);

		// Save the closest distance.
		if (d < minDistance)
			minDistance = d;
	}
}

// Display the closest distance.
DebugColor = minDistance

The seams are now gone.


This is almost the exact implementation Unity uses internally for the Voronoi node in Shader Graph. Let's optimize it.


___

Preparing the profiling

Test setup

I used the following setup:

  • Unity 6000.5.6f1,

  • Universal Render Pipeline (URP) with Forward+,

  • a fullscreen unlit shader containing a single node backed by the HLSL functions edited throughout this article,

  • a Redmi 12 4G running at its native fullscreen resolution.

Before each profiling session, I waited a bit to cool down the phone and then profiled it for 20 seconds. I inspected individual frames rather than aggregated statistics, so these results may have a higher measurement error.

The measured time starts when the first fragment shader begins and ends with the frame. Each measurement therefore includes the swapchain copy, not only the shader rendering time, because on a tiled GPU, determining exactly when rendering finishes is difficult.

Normalized workload

I could not lock the GPU clock on this phone, so GPU clock changes between measurements, raw frame times are not directly comparable.

I use this formula to estimate the workload normalized to the maximum GPU clock:

Normalized time = measured frame time × (GPU clock / maximum GPU clock)

For example, a 10 ms frame measured at 80% of the maximum clock has a normalized time of 8 ms. Lower normalized time means less estimated GPU work. This is an approximation, not a prediction of the exact frame time at the maximum clock. It assumes that the measured work scales linearly with clock speed, and the measurement also includes the swapchain copy. Not ideal scenario but this is what we get in practice.


___

Baseline: Unity's original node

I generated the HLSL shader code from Shader Graph to extract the node's source code. Here is the result:

// Unity hash functions used to generate the position offset for each cell.
void Hash_2_2_uint(uint2 v, out uint2 o)
{
	// ~8 ALU operations (2 multiplications)
	v.y ^= 1103515245U;
	v.x += v.y;
	v.x *= v.y;
	v.x ^= v.x >> 5u;
	v.x *= 0x27d4eb2du;
	v.y ^= (v.x << 3u);
	o = v;
}

void Hash_2_2_float(float2 i, out float2 o)
{
	uint2 r;
	uint2 v = (uint2) (int2) round(i);
	Hash_2_2_uint(v, r);
	o = (r >> 8) * (1.0 / float(0x00ffffff));
}

// Randomize the point inside the cell and rotate it using "offset."
float2 Unity_Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 + 0.5;
}

// Voronoi loop implementation
// UV - UV coordinates at which to sample the noise
// AngleOffset - controls the cell animation
// CellDensity - controls the cell size
// Out - distance to the closest point
// Cells - value derived from the closest point, constant within a cell but animated by AngleOffset
void Unity_Voronoi_Deterministic_float(float2 UV, float AngleOffset, float CellDensity, out float Out, out float Cells)
{
	float2 g = floor(UV * CellDensity);
	float2 f = frac(UV * CellDensity);
	float3 res = float3(8.0, 0.0, 0.0);

	for (int y = -1; y <= 1; y++)
	{
		for (int x = -1; x <= 1; x++)
		{
			float2 lattice = float2(x, y);
			float2 offset = Unity_Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);
			float d = distance(lattice + offset, f);
			if (d < res.x)
			{
				res = float3(d, offset.x, offset.y);
				Out = res.x;
				Cells = res.y

// Unity hash functions used to generate the position offset for each cell.
void Hash_2_2_uint(uint2 v, out uint2 o)
{
	// ~8 ALU operations (2 multiplications)
	v.y ^= 1103515245U;
	v.x += v.y;
	v.x *= v.y;
	v.x ^= v.x >> 5u;
	v.x *= 0x27d4eb2du;
	v.y ^= (v.x << 3u);
	o = v;
}

void Hash_2_2_float(float2 i, out float2 o)
{
	uint2 r;
	uint2 v = (uint2) (int2) round(i);
	Hash_2_2_uint(v, r);
	o = (r >> 8) * (1.0 / float(0x00ffffff));
}

// Randomize the point inside the cell and rotate it using "offset."
float2 Unity_Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 + 0.5;
}

// Voronoi loop implementation
// UV - UV coordinates at which to sample the noise
// AngleOffset - controls the cell animation
// CellDensity - controls the cell size
// Out - distance to the closest point
// Cells - value derived from the closest point, constant within a cell but animated by AngleOffset
void Unity_Voronoi_Deterministic_float(float2 UV, float AngleOffset, float CellDensity, out float Out, out float Cells)
{
	float2 g = floor(UV * CellDensity);
	float2 f = frac(UV * CellDensity);
	float3 res = float3(8.0, 0.0, 0.0);

	for (int y = -1; y <= 1; y++)
	{
		for (int x = -1; x <= 1; x++)
		{
			float2 lattice = float2(x, y);
			float2 offset = Unity_Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);
			float d = distance(lattice + offset, f);
			if (d < res.x)
			{
				res = float3(d, offset.x, offset.y);
				Out = res.x;
				Cells = res.y

// Unity hash functions used to generate the position offset for each cell.
void Hash_2_2_uint(uint2 v, out uint2 o)
{
	// ~8 ALU operations (2 multiplications)
	v.y ^= 1103515245U;
	v.x += v.y;
	v.x *= v.y;
	v.x ^= v.x >> 5u;
	v.x *= 0x27d4eb2du;
	v.y ^= (v.x << 3u);
	o = v;
}

void Hash_2_2_float(float2 i, out float2 o)
{
	uint2 r;
	uint2 v = (uint2) (int2) round(i);
	Hash_2_2_uint(v, r);
	o = (r >> 8) * (1.0 / float(0x00ffffff));
}

// Randomize the point inside the cell and rotate it using "offset."
float2 Unity_Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 + 0.5;
}

// Voronoi loop implementation
// UV - UV coordinates at which to sample the noise
// AngleOffset - controls the cell animation
// CellDensity - controls the cell size
// Out - distance to the closest point
// Cells - value derived from the closest point, constant within a cell but animated by AngleOffset
void Unity_Voronoi_Deterministic_float(float2 UV, float AngleOffset, float CellDensity, out float Out, out float Cells)
{
	float2 g = floor(UV * CellDensity);
	float2 f = frac(UV * CellDensity);
	float3 res = float3(8.0, 0.0, 0.0);

	for (int y = -1; y <= 1; y++)
	{
		for (int x = -1; x <= 1; x++)
		{
			float2 lattice = float2(x, y);
			float2 offset = Unity_Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);
			float d = distance(lattice + offset, f);
			if (d < res.x)
			{
				res = float3(d, offset.x, offset.y);
				Out = res.x;
				Cells = res.y

Out stores the distance to the closest point. Cells stores the x component of that point's random offset. This gives each Voronoi cell a stable value that changes when AngleOffset animates the points.

I saved this code in an HLSL file and used it in a Custom Function node.


I also prepared an "empty" shader that renders a single color. This provides a baseline for the fastest possible rendering in this test:


Original Voronoi node:

  • ~19.6 ms per frame

  • 99% of the maximum GPU clock speed

  • ~19.4 ms normalized time

Unity's original node and my copy produced the same results, confirming that they use the same algorithm.


Next, I profiled the empty shader that renders a solid color. This represents the fastest this GPU can render this specific test:

Empty shader:

  • ~7.4 ms per frame

  • 29% of the maximum GPU clock speed

  • ~2.1 ms normalized time

The empty unlit shader has about nine times less normalized workload than the original Voronoi node.


This leaves plenty of room for optimization.

Many optimizations require some loss of visual quality, such as reduced randomness or less variation in animated cells. However, players rarely see raw Voronoi noise. It usually drives another effect that shades an object, so reducing the quality of this intermediate result may not affect the final image.


___

Optimization iterations

First iteration: reducing algorithm complexity

Voronoi noise divides UV space into a grid, with a random point inside each cell. It finds the cell containing the UV sample, scans a 3x3 kernel of nine cells, and keeps the shortest distance to a random point.

Reducing shader complexity

I do not need a 3x3 window to remove all the seams. By shifting the window, I can cover only a 2x2 area.
This reduces the number of cell iterations from nine to four.


To do this, I shift the window by half a cell and iterate over four neighbors.

// Shift the UV used to compute the underlying cell by 0.5.
float2 g = floor(UV * CellDensity + 0.5);
float2 f = frac(UV * CellDensity + 0.5);
float3 res = float3(8.0, 0.0, 0.0);

// Iterate from -1 to 0 to sample a 2x2 kernel instead of a 3x3 kernel.
for (int y = -1; y <= 0; y++)
{
	for (int x = -1; x <= 0; x++)
	{
		// Calculate this cell's random point and distance.

// Shift the UV used to compute the underlying cell by 0.5.
float2 g = floor(UV * CellDensity + 0.5);
float2 f = frac(UV * CellDensity + 0.5);
float3 res = float3(8.0, 0.0, 0.0);

// Iterate from -1 to 0 to sample a 2x2 kernel instead of a 3x3 kernel.
for (int y = -1; y <= 0; y++)
{
	for (int x = -1; x <= 0; x++)
	{
		// Calculate this cell's random point and distance.

// Shift the UV used to compute the underlying cell by 0.5.
float2 g = floor(UV * CellDensity + 0.5);
float2 f = frac(UV * CellDensity + 0.5);
float3 res = float3(8.0, 0.0, 0.0);

// Iterate from -1 to 0 to sample a 2x2 kernel instead of a 3x3 kernel.
for (int y = -1; y <= 0; y++)
{
	for (int x = -1; x <= 0; x++)
	{
		// Calculate this cell's random point and distance.


Because this algorithm calculates distance in cell space, I must also shift the cell points by 0.5. I modified the function that generates the random point positions:

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 + 1.0; // Changed 0.5 to 1.0.

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 + 1.0; // Changed 0.5 to 1.0.

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 + 1.0; // Changed 0.5 to 1.0.


It worked, but introduced a problem.


There are discontinuities. However, not in all cells.

The discontinuities appear because the random points can move too far from the cell center. In the example below, the current algorithm returns the distance to the yellow point. It should return the distance to the red point in a cell that the 2x2 window did not scan.


I need to reduce the randomization range to avoid this edge case.

I scale the randomization range by 0.5 in the function that calculates each cell's point position:

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 * 0.5 + 1.0; // Multiply the point offset by 0.5.

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 * 0.5 + 1.0; // Multiply the point offset by 0.5.

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	Hash_2_2_float(UV, UV);
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 * 0.5 + 1.0; // Multiply the point offset by 0.5.


The noise now has no visible discontinuities.


The drawback is a smaller randomization range. The comparison below shows Unity's original Voronoi node on the left and the optimized version on the right. In the original node, points can get very close to each other. This cannot happen in the optimized version.


Profiling

I built the application and ran it on my phone:

  • ~10.4 ms per frame, 9.2 ms lower than the original measurement

  • 95% of the maximum GPU clock speed

  • ~9.9 ms normalized time, about 49% lower than the original

The modified shader has about half the normalized workload of the original. It has less variation in the noise shape, but this is a reasonable compromise when ALU cost is a concern.


The noise quality is lower, but in practice, players rarely see raw Voronoi output. It usually drives another effect.

For effects in which the noise shape is barely visible, a 2x2 window may have little impact on the final image.
Can you tell that the image below uses a 2x2 window instead of a 3x3 window?


___

Second iteration: loop unrolling

The shader now runs faster. The next optimization is loop unrolling.

Loop unrolling and flattening

After inspecting the compiled source on my phone with RenderDoc, I found that the shader still contains the loop logic.

In SPIR-V, the nested for loops were compiled into two while (true) loops with internal break conditions:


Loop unrolling instructs the compiler to expand the loop at compile time. In HLSL, I can request this with the [unroll] attribute.

Here is the modified shader:

void Voronoi_Deterministic_float(float2 UV, float AngleOffset, float CellDensity, out float Out, out float Cells)
{
	float2 g = floor(UV * CellDensity + 0.5); // Root position of the cell
	float2 f = frac(UV * CellDensity + 0.5); // UV of a single cell
	float3 res = float3(8.0, 0.0, 0.0);

	[unroll]
	for (int y = -1; y <= 0; y++)
	{
		[unroll]
		for (int x = -1; x <= 0; x++)
		{
			float2 lattice = float2(x, y);
			float2 offset = Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);
			float d = distance(lattice + offset, f);

			[flatten]
			if (d < res.x)
			{
				res = float3(d, offset.x, offset.y);
				Out = res.x;
				Cells = res.y

void Voronoi_Deterministic_float(float2 UV, float AngleOffset, float CellDensity, out float Out, out float Cells)
{
	float2 g = floor(UV * CellDensity + 0.5); // Root position of the cell
	float2 f = frac(UV * CellDensity + 0.5); // UV of a single cell
	float3 res = float3(8.0, 0.0, 0.0);

	[unroll]
	for (int y = -1; y <= 0; y++)
	{
		[unroll]
		for (int x = -1; x <= 0; x++)
		{
			float2 lattice = float2(x, y);
			float2 offset = Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);
			float d = distance(lattice + offset, f);

			[flatten]
			if (d < res.x)
			{
				res = float3(d, offset.x, offset.y);
				Out = res.x;
				Cells = res.y

void Voronoi_Deterministic_float(float2 UV, float AngleOffset, float CellDensity, out float Out, out float Cells)
{
	float2 g = floor(UV * CellDensity + 0.5); // Root position of the cell
	float2 f = frac(UV * CellDensity + 0.5); // UV of a single cell
	float3 res = float3(8.0, 0.0, 0.0);

	[unroll]
	for (int y = -1; y <= 0; y++)
	{
		[unroll]
		for (int x = -1; x <= 0; x++)
		{
			float2 lattice = float2(x, y);
			float2 offset = Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);
			float d = distance(lattice + offset, f);

			[flatten]
			if (d < res.x)
			{
				res = float3(d, offset.x, offset.y);
				Out = res.x;
				Cells = res.y


Profiling

I compiled the app and profiled it on mobile. The results are interesting.


I measured almost the same frame time as before, but the GPU clock dropped from 95% to 86.2%. After normalization, the estimated workload is about 8% lower than in the previous iteration.
Interestingly, this phone tends to keep frames near 10 ms. When a frame becomes shorter, the phone is more likely to reduce the GPU clock.

  • ~10.5 ms per frame (0.1 ms slower than the previous measurement)

  • 86.2% of the maximum GPU clock speed

  • ~9.1 ms normalized time, about 53% lower than the original

I also captured a period when the GPU ran at a higher clock and produced a lower frame time.


___

Third iteration: square root

Next, let's optimize the square root. On a GPU, square root is a special function that usually runs on slower units. Different shader instructions run on different hardware units:

  • integer addition, multiplication, bitwise operations are handled by the integer units,

  • floating-point addition and multiplication are handled by FP32 units,

  • special functions are handled by SFU units, which are slower,

  • texture samples are handled by texture units.

These units can run in parallel, but GPUs usually have fewer special function units than FP32 units. Special functions also use a slower hardware path.

This is a high-level mental model. GPU architectures differ, and the exact execution paths depend on the hardware and compiler. Even so, this model often leads to useful optimization decisions that can then be verified through profiling.

The diagram below shows how I visualize GPU execution. Simple integer and floating-point additions and multiplications use faster paths. Operations routed through the scoreboard, such as texture samples and special functions, are slower.


Reducing the number of square root operations should speed up the shader.

The distance function calculates a square root. I will compare squared distances inside the loop and apply one square root after the loop.


This is the optimized code:

// Calculate the squared distance.
float DistanceSquared(float2 a, float2 b)
{
	return dot(a-b, a-b);
}

void Voronoi_Deterministic_float(float2 UV, float AngleOffset, float CellDensity, out float Out, out float Cells)
{
	// Existing setup code.

	[unroll]
	for (int y = -1; y <= 0; y++)
	{
		[unroll]
		for (int x = -1; x <= 0; x++)
		{
			float2 lattice = float2(x, y);
			float2 offset = Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);

			// Calculate the squared distance.
			float d = DistanceSquared(lattice + offset, f);

			[flatten]
			if (d < res.x)
			{
				res = float3(d, offset.x, offset.y);
				Out = res.x;
				Cells = res.y;
			}
		}
	}

	// Apply the square root to the output.
	Out = sqrt(Out

// Calculate the squared distance.
float DistanceSquared(float2 a, float2 b)
{
	return dot(a-b, a-b);
}

void Voronoi_Deterministic_float(float2 UV, float AngleOffset, float CellDensity, out float Out, out float Cells)
{
	// Existing setup code.

	[unroll]
	for (int y = -1; y <= 0; y++)
	{
		[unroll]
		for (int x = -1; x <= 0; x++)
		{
			float2 lattice = float2(x, y);
			float2 offset = Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);

			// Calculate the squared distance.
			float d = DistanceSquared(lattice + offset, f);

			[flatten]
			if (d < res.x)
			{
				res = float3(d, offset.x, offset.y);
				Out = res.x;
				Cells = res.y;
			}
		}
	}

	// Apply the square root to the output.
	Out = sqrt(Out

// Calculate the squared distance.
float DistanceSquared(float2 a, float2 b)
{
	return dot(a-b, a-b);
}

void Voronoi_Deterministic_float(float2 UV, float AngleOffset, float CellDensity, out float Out, out float Cells)
{
	// Existing setup code.

	[unroll]
	for (int y = -1; y <= 0; y++)
	{
		[unroll]
		for (int x = -1; x <= 0; x++)
		{
			float2 lattice = float2(x, y);
			float2 offset = Voronoi_RandomVector_Deterministic_float(lattice + g, AngleOffset);

			// Calculate the squared distance.
			float d = DistanceSquared(lattice + offset, f);

			[flatten]
			if (d < res.x)
			{
				res = float3(d, offset.x, offset.y);
				Out = res.x;
				Cells = res.y;
			}
		}
	}

	// Apply the square root to the output.
	Out = sqrt(Out

As a result, the shader calculates the square root once instead of four times.

Profiling

Here are the results:

  • ~10.2 ms frame time (about 0.3 ms lower than the previous measurement)

  • 83.3% of the maximum GPU clock

  • ~8.5 ms normalized time, about 56% lower than the original

The normalized workload is about 6% lower than in the previous iteration.


___

Fourth iteration: float-to-uint hash

At this point, further optimization requires changing the core of the effect: how it calculates the random points.

My first idea was to remove the rounding and integer conversion from the hash function. On some GPUs, rounding and conversion use special function units, which can make these operations slower.

Instead of this:

void Hash_2_2_float(float2 i, out float2 o)
{
	uint2 r;
	uint2 v = (uint2) (int2) round(i); // Cast and round the floats.
	Hash_2_2_uint(v, r);
	o = (r >> 8) * (1.0 / float(0x00ffffff

void Hash_2_2_float(float2 i, out float2 o)
{
	uint2 r;
	uint2 v = (uint2) (int2) round(i); // Cast and round the floats.
	Hash_2_2_uint(v, r);
	o = (r >> 8) * (1.0 / float(0x00ffffff

void Hash_2_2_float(float2 i, out float2 o)
{
	uint2 r;
	uint2 v = (uint2) (int2) round(i); // Cast and round the floats.
	Hash_2_2_uint(v, r);
	o = (r >> 8) * (1.0 / float(0x00ffffff

I reinterpreted i as an unsigned integer. Because the value is already rounded, it does not need to be rounded again. I multiplied the reinterpreted values to prevent visible patterns in the cell movement:

void Hash_2_2_float(float2 i, out float2 o)
{
	uint2 r;
	uint2 v = uint2(asuint(i.x) * 8982u, asuint(i.y) * 18564u); // Reinterpret the floats as uints and multiply them.
	Hash_2_2_uint(v, r);
	o = (r >> 8) * (1.0 / float(0x00ffffff

void Hash_2_2_float(float2 i, out float2 o)
{
	uint2 r;
	uint2 v = uint2(asuint(i.x) * 8982u, asuint(i.y) * 18564u); // Reinterpret the floats as uints and multiply them.
	Hash_2_2_uint(v, r);
	o = (r >> 8) * (1.0 / float(0x00ffffff

void Hash_2_2_float(float2 i, out float2 o)
{
	uint2 r;
	uint2 v = uint2(asuint(i.x) * 8982u, asuint(i.y) * 18564u); // Reinterpret the floats as uints and multiply them.
	Hash_2_2_uint(v, r);
	o = (r >> 8) * (1.0 / float(0x00ffffff


Profiling

Here are the results:


  • 10.4 ms frame time (+0.2 ms)

  • 84.7% of the maximum GPU clock speed

  • ~8.8 ms normalized time, about 55% lower than the original

The normalized workload is about 4% higher than in the square root iteration. This small difference could be measurement noise, but I could not find a frame that rendered faster than with Unity's original hash function.


___

Fifth iteration: texture-based hash

The shader is ALU-bound, so replacing the hash function with texture samples could improve performance. ALU and texture units can work in parallel, but texture sampling also adds cache, bandwidth, and latency costs. Whether this helps depends on the rest of the shader and the target GPU.

I tested this idea:

float2 Voronoi_RandomVector_Deterministic_float(Texture2D HashTexture, float2 UV, float offset)
{
	UV = HashTexture.Load(uint3((uint2)floor(UV * 15) % 32, 0)).rb; // Sample the texture instead of using a hash function.
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 * 0.666667 + 1.0

float2 Voronoi_RandomVector_Deterministic_float(Texture2D HashTexture, float2 UV, float offset)
{
	UV = HashTexture.Load(uint3((uint2)floor(UV * 15) % 32, 0)).rb; // Sample the texture instead of using a hash function.
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 * 0.666667 + 1.0

float2 Voronoi_RandomVector_Deterministic_float(Texture2D HashTexture, float2 UV, float offset)
{
	UV = HashTexture.Load(uint3((uint2)floor(UV * 15) % 32, 0)).rb; // Sample the texture instead of using a hash function.
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 * 0.666667 + 1.0

The movement patterns are clearly visible. This optimization could still work when a larger effect stack hides the animation. I will profile it anyway.



___

Profiling

It was faster.

  • Frame time remained at 10.3 ms.

  • GPU clock dropped to 70.9% of the maximum.

  • Normalized time dropped to ~7.3 ms, about 62% lower than the original and 14% lower than the square root iteration.

However, the movement patterns are obvious, and I do not like using a hidden texture in a Shader Graph node. In more complex shaders, this version may be slower because it competes for texture-unit resources, consumes bandwidth, or causes additional cache misses. Still, this technique is useful to keep in mind.

After this experiment, I reverted to the third iteration with the square root optimization.


___

Sixth iteration: array lookup hash

This experiment produced the most surprising result. I thought an array lookup might work better than a texture lookup.

// Encode random vectors in a constant array.
static const float2 kHash[64] =
{
	float2(-0.67707, 0.19223),
	float2(0.00651, -0.56298),
	float2(-0.79776, -0.02331),
	float2(0.16331, 0.09996),

	// ...60 more vectors...
};

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	uint2 id = ((uint2)floor(UV) * 31u) % 64u;
	UV = kHash[id.x ^ id.y]; // Read the vector.
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 * 0.5 + 1.0

// Encode random vectors in a constant array.
static const float2 kHash[64] =
{
	float2(-0.67707, 0.19223),
	float2(0.00651, -0.56298),
	float2(-0.79776, -0.02331),
	float2(0.16331, 0.09996),

	// ...60 more vectors...
};

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	uint2 id = ((uint2)floor(UV) * 31u) % 64u;
	UV = kHash[id.x ^ id.y]; // Read the vector.
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 * 0.5 + 1.0

// Encode random vectors in a constant array.
static const float2 kHash[64] =
{
	float2(-0.67707, 0.19223),
	float2(0.00651, -0.56298),
	float2(-0.79776, -0.02331),
	float2(0.16331, 0.09996),

	// ...60 more vectors...
};

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	uint2 id = ((uint2)floor(UV) * 31u) % 64u;
	UV = kHash[id.x ^ id.y]; // Read the vector.
	return float2(sin(UV.y * offset), cos(UV.x * offset)) * 0.5 * 0.5 + 1.0

This replaces the hash calculations with a single array lookup.


As a result, the shader took about 400 ms to render, with RAM throughput as the main bottleneck. The shader became about 40 times slower. I did not record a reliable GPU clock percentage for this run, so I cannot calculate a normalized time.

What the profiling showed:
On this Redmi 12 4G and its ARM Mali architecture, the array behaved like a unique per-thread resource. When the shader started, its values appeared to be copied from RAM to registers for each thread, and every read caused a cache miss. The profiler data confirmed the RAM-throughput bottleneck. The same issue did not occur on PC, so this is likely specific to the mobile compiler or architecture rather than a general rule for constant arrays.

This is why I always profile every change.

I reverted the change.

I also tested other lookup-based point generators, but none were faster than Unity's original hash function.


___

Seventh iteration: no animation

Some effects do not need animation. For example, when Voronoi noise procedurally shades rocks, the animation code is unnecessary.

The original shader uses sin and cos to make each cell point bounce off the edges. These special functions are slower to execute.


The noise still works without animation, so I removed this code:

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	float2 cellCenter;
	Hash_2_2_float(UV, /*out*/ cellCenter);
	cellCenter = (cellCenter - 0.5) * 0.5; // Remove the sin/cos-based animation.
	return cellCenter

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	float2 cellCenter;
	Hash_2_2_float(UV, /*out*/ cellCenter);
	cellCenter = (cellCenter - 0.5) * 0.5; // Remove the sin/cos-based animation.
	return cellCenter

float2 Voronoi_RandomVector_Deterministic_float(float2 UV, float offset)
{
	float2 cellCenter;
	Hash_2_2_float(UV, /*out*/ cellCenter);
	cellCenter = (cellCenter - 0.5) * 0.5; // Remove the sin/cos-based animation.
	return cellCenter

The node now outputs a static image:


Here are the results:


Removed animation:

  • Frame time remained at 10.4 ms.

  • GPU clock dropped to 50.8% of the maximum.

  • Normalized time dropped to ~5.3 ms, about 73% lower than the original and 38% lower than the square root iteration.


___

Eighth iteration: baked into a texture

The final optimization is to skip the procedural node and use a tiled Voronoi texture. This version samples a texture containing a tiled 8x8 Voronoi window.

This approach can use a 3x3 kernel when baking the texture, preserving higher-quality Voronoi shapes. Runtime performance now depends on the texture's mipmaps, format, sampling method, and resolution.

The pattern repeats every eight cells.


Here is the result:


  • Frame time dropped to 8 ms.

  • GPU clock dropped to 28.9% of the maximum.

  • Normalized time dropped to ~2.3 ms, about 88% lower than the original.

The bottleneck shifted from ALU to the Early-Z test, so this test is unlikely to render much faster.


___

Performance comparison

The speedup column compares each normalized time with Unity's original Voronoi node. Derived values are rounded. The array lookup result is excluded because I did not record a reliable GPU clock percentage for that run.

Version

Measured frame time

Maximum GPU clock

Normalized time

Speedup vs. original

Empty shader baseline

7.4 ms

29.0%

2.1 ms

9.04x

Unity original

19.6 ms

99.0%

19.4 ms

1.00x

2x2 window

10.4 ms

95.0%

9.9 ms

1.96x

Loop unrolling

10.5 ms

86.2%

9.1 ms

2.14x

One square root

10.2 ms

83.3%

8.5 ms

2.28x

Float-to-uint hash

10.4 ms

84.7%

8.8 ms

2.20x

Texture-based hash

10.3 ms

70.9%

7.3 ms

2.66x

Array lookup hash

~400 ms

99.8%

~400 ms

-

No animation

10.4 ms

50.8%

5.3 ms

3.67x

Baked texture

8.0 ms

28.9%

2.3 ms

8.39x


___

Look at the final image, not the quality of a single noise

When optimizing noise, always consider the broader context: the final effect.

For example, this water glare uses:

  • four layers of Voronoi noise,

  • a 3x3 window,

  • high-quality hash functions,

  • sin- and cos-based animation,

  • procedural value-noise distortion.


The optimized version runs 5 times faster and uses:

  • three layers of Voronoi noise,

  • a 2x2 window,

  • texture-based hash functions,

  • sin- and cos-based animation,

  • one texture sample instead of procedural value noise.

Do you think players will notice the difference? Is the tradeoff worth a fivefold performance improvement?
Also remember that the players can't compare the current visuals to the original version.


You can find the full case study here:
https://www.proceduralpixels.com/blog/i-made-this-shader-run-4x-faster

To learn how I profile ARM GPUs and customized the ARM profiler for my phone, read this article:
https://www.proceduralpixels.com/blog/how-to-properly-profile-mobile-gpu-using-arm-streamline


___

Summary

  • You rarely need the highest-quality Voronoi noise in production. Players usually see it as one input to a larger effect, not as a raw output. Quality tradeoffs are very practical.

  • The largest procedural improvement came from reducing the search window from 3x3 to 2x2. Combined with loop unrolling and a single square root, it reduced normalized time from 19.4 ms to 8.5 ms. Disabling animation reduced it further to 5.3 ms.

  • Replacing procedural noise with a baked texture produced the lowest normalized time at 2.3 ms, but introduced an eight-cell repetition. Texture-based hashing also improved performance, although it added visible motion patterns and used texture-unit resources.

  • The failed experiments were equally useful. The float-to-uint hash did not beat Unity's implementation, while a constant-array lookup caused a severe architecture-specific RAM bottleneck on this phone.

Understand the visual tradeoff, optimize for the final effect - not something players never see, profile every change on the target hardware.

Join the community

If you are interested in learning more,
join the community discussions on Saturday at 15:00 CET

We meet weekly, discussing the rendering and optimization problems.
Join our mastermind sessions, chill discussions and small online conference events.

Join the community

If you are interested in learning more,
join the community discussions on Saturday at 15:00 CET

We meet weekly, discussing the rendering and optimization problems.
Join our mastermind sessions, chill discussions and small online conference events.

I write expert content on optimizing Unity games, customizing rendering pipelines, and enhancing the Unity Editor.

Copyright © 2026 Jan Mróz | Procedural Pixels

I write expert content on optimizing Unity games, customizing rendering pipelines, and enhancing the Unity Editor.

Copyright © 2026 Jan Mróz | Procedural Pixels

I write expert content on optimizing Unity games, customizing rendering pipelines, and enhancing the Unity Editor.

Copyright © 2026 Jan Mróz | Procedural Pixels