Random and Hash Functions
Deterministic randomness in parallel code
Randomness is everywhere in nature. The texture of stone, the pattern of leaves, the scattering of stars. To recreate these organic phenomena in shaders, we need random numbers. But shaders have a fundamental constraint that makes traditional randomness impossible.
The Parallel Problem
In a typical programming language, you might call a function like random() that returns a different value each time. Behind the scenes, the random number generator maintains internal state that it updates with each call.
But remember: shader code runs on millions of pixels simultaneously. There is no shared state. Each pixel executes the same code independently, with no knowledge of what order pixels are processed or what values other pixels received.
If we used a stateful random number generator, every pixel would get the same "random" value, because they all start from the same state. Worse, the results would be unpredictable between frames as the order of pixel execution shifts.
We need randomness that is deterministic: given the same input, always produce the same output. The input is typically the pixel's position. The output should look random but be perfectly reproducible.
Hash Functions: Scrambling Inputs
A hash function takes an input and scrambles it into an output that appears unrelated. The same input always produces the same output, but small changes in input produce wildly different outputs.
The classic shader hash function looks like this:
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}Let us break down what happens here:
-
dot(p, vec2(127.1, 311.7))takes our 2D position and converts it to a single number. The choice of 127.1 and 311.7 is deliberate: these are chosen to avoid obvious patterns. -
sin(...)applies the sine function. Sine oscillates between -1 and 1, but when fed large values, the output jumps around erratically because we are sampling different parts of the wave. -
* 43758.5453multiplies by a large number, amplifying small differences in the sine value into large differences in the result. -
fract(...)takes only the fractional part, giving us a value between 0 and 1.
The result is a value that looks random but is entirely determined by the input position.
Interactive: Compare Hash Function Quality
Compare different hash functions. Notice how simple approaches create visible patterns, while the classic sine-based hash produces apparent randomness.
Why Simple Approaches Fail
Not every formula that combines position values produces good randomness. Simple operations like multiplication or addition create visible patterns.
Consider fract(p.x * p.y). For any row where p.y is constant, the output varies linearly with p.x. You will see horizontal bands. Similarly, fract(p.x + p.y) creates diagonal lines because all positions along a diagonal have the same sum.
The sine function's non-linear behavior is what breaks these patterns. Because sine oscillates rapidly at high frequencies, small position changes cause the output to jump unpredictably, destroying the spatial correlations that create visible artifacts.
Pattern Artifacts in Poor Hash Functions
A poorly designed hash reveals diagonal patterns as you pan. The good hash maintains apparent randomness regardless of position.
Random Values Per Tile
Often we want random values not per pixel, but per tile or cell. To achieve this, we quantize our continuous coordinates into discrete grid cells before hashing:
float scale = 10.0;
vec2 grid = floor(uv * scale);
float n = hash(grid);The floor function rounds down to the nearest integer. All pixels within the same cell share the same grid coordinates, so they get the same random value.
Interactive: Tiled Random Values
Each grid cell gets a unique random value based on its integer coordinates. The same cell always produces the same value.
This pattern is foundational for noise algorithms. By assigning random values to grid points and interpolating between them, we create the smooth randomness that powers procedural textures.
Generating Multiple Random Values
Sometimes you need more than one random value per position. Perhaps you want a random color, which requires three independent values for red, green, and blue.
The solution is to hash with different offsets:
vec3 hash3(vec2 p) {
return vec3(
hash(p),
hash(p + vec2(37.0, 17.0)),
hash(p + vec2(59.0, 83.0))
);
}Each offset creates a different "sequence" of random numbers. The specific offset values do not matter much, as long as they are not zero and not too similar to each other.
Random Colors Per Tile
By calling the hash function with different offsets, we generate independent random values for each color channel.
Determinism is a Feature
It might seem like a limitation that our randomness is deterministic. In fact, it is a superpower.
Because the same position always produces the same random value, your shader produces the same image every frame. Animations built on hash functions are stable and reproducible. If you save a screenshot and come back tomorrow, running the shader again produces the identical result.
Determinism in Action
Click to re-execute the shader. The pattern is identical every time because the hash function is deterministic: same input, same output.
This reproducibility matters for debugging, for artistic control, and for any effect that needs to be consistent across frames without flicker.
Quality Considerations
The classic sine-based hash is fast and produces acceptable randomness for most visual purposes. However, it has limitations:
Precision issues. On some GPUs, especially mobile devices, the sine function has limited precision at high input values. This can cause visible banding or repetition.
Statistical weakness. The distribution is not perfectly uniform. For scientific applications, this matters. For visual effects, it usually does not.
For most shader work, the classic hash is sufficient. When you need higher quality, there are more sophisticated hash functions available, but they come with a performance cost.
Looking Ahead
Hash functions give us randomness at discrete points. In the next chapter, we will learn to interpolate between these random values smoothly, creating value noise and gradient noise. These continuous random functions are the building blocks for realistic textures like clouds, terrain, and organic surfaces.
Key Takeaways
- Shaders cannot use stateful random number generators because pixels execute in parallel
- Hash functions provide deterministic randomness: same input always gives same output
- The classic
fract(sin(dot(...)) * 43758.5453)scrambles positions into pseudo-random values - Simple math operations create visible patterns; sine's non-linearity breaks them
- Use
floor()to get the same random value for all pixels in a grid cell - Determinism is a feature that enables reproducible, stable animations