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:

2. Cell UV
Next, it finds the UV coordinates within the cell:

3. Calculating a random point for each cell
It calculates a random point inside the cell using hash functions:
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:

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:
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:
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.
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:
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:
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:
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:
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:
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:
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:
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.
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:
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- andcos-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- andcos-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.


