Catlike Coding

Simplex Noise

Simplexes and Radial Kernels

  • Apply radially-symmetrical falloff kernels.
  • Use simplexes to partition space.
  • Transform squares into triangles and cubes into tetrahedra.
  • Introduce circle- and sphere-based gradients.

This is the seventh tutorial in a series about pseudorandom noise. It adds support for a vectorized version of the simplex noise algorithm.

This tutorial is made with Unity 2020.3.17f1.

A sphere showing 3D simplex noise.

Simplex Value Noise

After Ken Perlin created Perlin noise he later invented another noise pattern that he named simplex noise. This type of noise uses kernel summation instead of interpolation and is based on a simplex grid instead of a hypercube grid.

In this context, a kernel can be thought of as a stamp or a mask that limits the influence of a pattern. A result is generated by adding multiple kernel samples centered at different locations together.

A simplex is the simplest possible polytope—an object with flat sides—that takes up space in all available dimensions. A straight line segment is a 1D simplex. A triangle is a 2D simplex. A square is not a 2D simplex, because it has one more corner and side than a triangle and thus isn't the simplest possible shape. A straight line segment is also not a 2D simplex, because it has only a single dimension, no matter how it is oriented in 2D space. Finally, a tetrahedron is a 3D simplex.

Simplex noise is a type of gradient noise, but we can also create value noise variants of it. We start with those because they are simpler and easier to analyze than the gradient variants.

Simplex Jobs

We cannot rely on our existing lattice structs to generate simplex noise, because they use a hypercube lattice to partition space while we need simplex lattices. So we'll create a new partial Noise class asset named Noise.Simplex and declare new simplex noise types in there, for 1D, 2D, and 3D.

Although we could create tiling simplex noise variants they won't be very useful, because the tiling would be based on the simplex lattice and thus not align with a hypercube grid. Thus 2D tiling won't match a square region and 3D tiling won't match a cube. 1D tiling would fit straight line segments so is possible, but we'll be consistent and won't support tiling for all simplex noise variants. So these noise types only need an IGradient generic type parameter. We begin by only using the gradient for the evaluation after interpolation, but pass zero to it for now.

using Unity.Mathematics;

using static Unity.Mathematics.math;

public static partial class Noise {

	public struct Simplex1D<G> : INoise where G : struct, IGradient {

		public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) {
			return default(G).EvaluateAfterInterpolation(0f);
		}
	}

	public struct Simplex2D<G> : INoise where G : struct, IGradient {

		public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) {
			return default(G).EvaluateAfterInterpolation(0f);
		}
	}

	public struct Simplex3D<G> : INoise where G : struct, IGradient {

		public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) {
			return default(G).EvaluateAfterInterpolation(0f);
		}
	}
}

Add entries for simplex value noise and simplex value turbulence noise to the enum in NoiseVisualization.

	public enum NoiseType {
		Perlin€, PerlinTurbulence, Value€, ValueTurbulence,
		SimplexValue, SimplexValueTurbulence,
		VoronoiWorleyF1, VoronoiWorleyF2, VoronoiWorleyF2MinusF1,
		VoronoiChebyshevF1, VoronoiChebyshevF2, VoronoiChebyshevF2MinusF1
	}

And insert them into the job array at the appropriate location. The simplest way to not support tiling variants is to use the non-tiling variants for those elements, so each variant gets included twice.

		{
			Job<Simplex1D<Value>>.ScheduleParallel,
			Job<Simplex1D<Value>>.ScheduleParallel,
			Job<Simplex2D<Value>>.ScheduleParallel,
			Job<Simplex2D<Value>>.ScheduleParallel,
			Job<Simplex3D<Value>>.ScheduleParallel,
			Job<Simplex3D<Value>>.ScheduleParallel
		},
		{
			Job<Simplex1D<Turbulence<Value>>>.ScheduleParallel,
			Job<Simplex1D<Turbulence<Value>>>.ScheduleParallel,
			Job<Simplex2D<Turbulence<Value>>>.ScheduleParallel,
			Job<Simplex2D<Turbulence<Value>>>.ScheduleParallel,
			Job<Simplex3D<Turbulence<Value>>>.ScheduleParallel,
			Job<Simplex3D<Turbulence<Value>>>.ScheduleParallel
		},

1D Simplexes and Kernels

We begin with 1D Simplex noise. It uses the same line-segment space partition as regular value noise, but we don't have an ILattice type to generate the required data. Instead, we find the first lattice point x0 inside Simplex1D.GetNoise4 directly, first by applying the frequency to the positions and then flooring the X coordinates.

	public struct Simplex1D<G> : INoise where G : struct, IGradient {

		public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) {
			positions *= frequency;
			int4 x0 = (int4)floor(positions.c0);

			return default(G).EvaluateAfterInterpolation(0f);
		}
	}

To generate the noise value we need a kernel. For 1D noise the kernel requires evaluation of a hash and 1D gradient input. We add a static Kernel method to Simplex1D to do this, based on a hash, a lattice point, and the sample position, all vectorized. Just like for Lattice1D, the gradient input is found by subtracting the lattice points from their X coordinates.

	public struct Simplex1D<G> : INoise where G : struct, IGradient {

		public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) {
			…
		}
		
		static float4 Kernel (SmallXXHash4 hash, float4 lx, float4x3 positions) {
			float4 x = positions.c0 - lx;
			return default(G).Evaluate(hash, x);
		}
	}

To apply the kernel for the first lattice point, invoke it inside GetNoise4, passing it the hash fed with the point, the point itself, and the positions. Pass its result to the final evaluation.

		public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) {
			positions *= frequency;
			int4 x0 = (int4)floor(positions.c0);

			return default(G).EvaluateAfterInterpolation(
				Kernel(hash.Eat(x0), x0, positions)
			);
		}

To complete the 1D noise we have to include the second point x1 of the lattice span as well, one step further along the X axis. And because Simplex noise sums kernels, add its kernel to the first one, instead of interpolating them.

		public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) {
			positions *= frequency;
			int4 x0 = (int4)floor(positions.c0), x1 = x0 + 1;

			return default(G).EvaluateAfterInterpolation(
				Kernel(hash.Eat(x0), x0, positions) + Kernel(hash.Eat(x1), x1, positions)
			);
		}

Radially-symmetrical Falloff

At this point we're simply adding the uniform lattice values of value noise and using those constant values for the entire span. To turn this into a continuous pattern we have to introduce a smooth transition from one lattice point to the next. While regular lattice noise blends between lattice points via interpolation, simplex noise does this by limiting the influence of each lattice point. This is the job of the kernel. It defines a falloff function that starts at 1 at the lattice point and drops to zero when it reaches the adjacent lattice points, in both directions. Hence it is a symmetrical kernel, which in a single dimension also makes it radially-symmetrical.

The simplest falloff function `f` that works is one minus the absolute distance along the single dimension: `f(x)=1-|x|`. This function starts at 1 and drops to zero at the adjacent lattice points. Use it to scale the gradient evaluation.

		static float4 Kernel (SmallXXHash4 hash, float4 lx, float4x3 positions) {
			float4 x = positions.c0 - lx;
			float4 f = 1f - abs(x);
			return f * default(G).Evaluate(hash, x);
		}
1D Value simplex noise, linear falloff.

The result is continuous noise, but it is equivalent to simple linear interpolation and thus isn't smooth. To make regular value noise smooth we needed to use a C2-continuous interpolation. Likewise, we need to find a C2-continuous falloff function to make simplex value noise meet the same standards.

Let's start with the observation that `|x|=sqrt(x^2)` and thus that the simplest way to adjust our falloff function is to eliminate the square root. This leads to `f(x)=1-x^2`.

			float4 f = 1f - x * x;
Squared falloff.

This squared falloff introduces curvature but is obviously not C2-continuous yet. This makes sense because the first derivative is `f'(x)=-2x` and the second derivative is `f''(x)=-2`. Neither of these are zero at the end points, where `x` equals 1 or −1.

Another observation that we can make is that in the middle of a span the noise can end up with an amplitude that exceeds 1. This happens because at the halfway point `f(1/2)=1-(1/2)^2=1-1/4=3/4`. That is the maximum for a single kernel, but we add two, so the total maximum amplitude is `2f(1/2)=3/2=1.5`.

Linear and square falloff.

Let's make this more obvious by only using the falloff factor for the kernel result, thus always visualizing the maximum possible amplitude of the noise.

			return f; // * default(G).Evaluate(hash, x);
Maximum amplitude.

We can modify the falloff function that we currently have by squaring it, which leads to `f(x)=(1-x^2)^2 ` with derivatives `f'(x)=4x^3-4x` and `f''(x)=12x^2-4`. This function is C1- but not C2-continuous.

C1 falloff and its derivatives.

To reach C2-continuity we have to raise the power of the function one step higher, cubing instead of squaring it: `f(x)=(1-x^2)^3` with derivatives `f'(x)=-6x^5+12x^3-6x` and `f''(x)=-30x^4+36x^2-6`.

C2 falloff and its derivatives.

The second derivative of this function isn't zero at the lattice point itself, because that's where the kernel falloff switches direction. As the kernel influences both sides this isn't a problem, it only has to reach zero at its edges.

Apply this falloff to our kernel.

			float4 f = 1f - x * x;
			f = f * f * f;
			return f;// * default(G).Evaluate(hash, x);
Final maximum amplitude.

Note that—unlike 1D regular value noise—the maximum amplitude of 1D simplex value noise isn't constant. It wobbles a bit, reaching 1 at lattice points and dropping to its minimum of 0.84375 in the middle of each span.

Finally, complete 1D simplex value noise by reintroducing the gradient evaluation.

			return f * default(G).Evaluate(hash, x);
simplex regular
1D simplex and regular value noise.

Compared to regular value noise, the simplex variant is a bit more wobbly due to its variable maximum amplitude. Besides that both show the same pattern as they're based on the same line-segment lattice.

Renaming

At this point we're using the IGradient.EvaluateAfterInterpolation method to adjust the final combined noise value both after interpolation of lattice noise and after kernel summation of simplex noise. Its current name is thus too specific. Let's refactor rename all relevant code so it becomes IGradient.EvaluateCombined. I only show the change for the IGradient interface.

	public interface IGradient {
		…
		
		float4 EvaluateCombined (float4 value);
	}

2D Kernels

Moving on to 2D noise, the falloff function of a radially-symmetrical kernel works the same as for 1D. We again subtract the square distance from 1 and cube that. In general the falloff function is `f(d)=(1-d)^3` where `d` is the square distance. For 1D `d=x^2` and for 2D `d=x^2+z^2`, because we base our 2D noise on the XZ plane. Thus for 2D the falloff function can be defined as `f(x,z)=(1-x^2-z^2)^3` Add a Kernel method to Simplex2D with this falloff as its result.

	public struct Simplex2D<G> : INoise where G : struct, IGradient {

		…

		static float4 Kernel (
			SmallXXHash4 hash, float4 lx, float4 lz, float4x3 positions
		) {
			float4 x = positions.c0 - lx, z = positions.c2 - lz;
			float4 f = 1f - x * x - z * z;
			f = f * f * f;
			return f;
		}
	}

Follow that with an implementation of GetNoise4 with the same logic as for 1D, initially using the same square-based lattice as Lattice2D, but this time summing four kernels.

		public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) {
			positions *= frequency;
			int4
				x0 = (int4)floor(positions.c0), x1 = x0 + 1,
				z0 = (int4)floor(positions.c2), z1 = z0 + 1;

			SmallXXHash4 h0 = hash.Eat(x0), h1 = hash.Eat(x1);

			return default(G).EvaluateCombined(
				Kernel(h0.Eat(z0), x0, z0, positions) +
				Kernel(h0.Eat(z1), x0, z1, positions) +
				Kernel(h1.Eat(z0), x1, z0, positions) +
				Kernel(h1.Eat(z1), x1, z1, positions)
			);
		}
Radial falloff with square lattice; frequency 4.

The result is a square grid filled with circular gradients that indicate the maximum amplitude of the noise. Although the maximum amplitude should be 1 at the lattice points this is currently not the case, because the kernel centered on the diagonally opposite corner has become negative as its distance has exceeded 1: `f(1,1)=(1-1-1)^3=-1^3=-1`. We fix this by eliminating negative falloff results.

		static float4 Kernel (
			SmallXXHash4 hash, float4 lx, float4 lz, float4x3 positions
		) {
			…
			return max(0f, f);
		}
Clamped kernels.

With our kernel influence clamped we see the result of kernel summation while using a square lattice. The next step is to switch to a triangular lattice.

2D Simplexes

A 2D simplex is a triangle. It is possible to tile 2D space with a grid of equilateral triangles. We can start with our existing square-based approach and convert it into a triangular lattice using a two-step process. We take a square and scale it down along the XZ diagonal. This distorting operation is known as a skew. The result is a rhombus, which when split along its XZ diagonal becomes two triangles with opposite orientations. If we apply the correct skew the end result is a grid of equilateral triangles.

Skewing from square to rhombus.

The skew is performed by moving all points along the XZ diagonal line. We do this by applying the same adjustment to both coordinates of each point. To convert from square to rhombus we have to scale down, so we have to subtract some skew value `s`. So for every point we have to apply the transformation `[[x],[z]]->[[x-s],[z-s]]`.

To create a rhombus `s` cannot be the same for all points, so it depends on their coordinates, which means that it has to be a function: `s(x,z)`. We have to determine what it is.

Let's consider the case of a degenerate rhombus: we skew so all points end up on a single line. Let's also consider the square with corner points `[[0],[0]]`, `[[1],[0]]`, `[[1],[1]]`, and `[[0],[1]]`.

Skewing into a degenerate rhombus, which is a line.

From this we can see that `s(0,0)=0`, because the point at the origin doesn't move. The other point on the XZ line also ends up at the origin, so `s(1,1)=1`. And `s(0,1)=s(1,0)=1/2`. Thus in this case `s(x,z)=(x+z)/2`. In general we have `s(x,z)=v(x+z)` where the constant value `v` determines the shape of the rhombus. We have to determine which value to use for `v` so we end up with equilateral triangles.

Let's look at the bottom right triangle of the original square, with corners `a=[[0],[0]]`, `b=[[1],[0]]` and `c=[[1],[1]]`.

Right and equilateral versions of the same triangle.

After skewing `a` is still the same, but the other two corner points have changed. We have `b=[[1-v(1+0)],[-v(1+0)]]=[[1-v],[-v]]` and `c=[[1-v(1+1)],[1-v(1+1)]]=[[1-2v],[1-2v]]`.

We do not yet know what `v` is, but we do know that the three sides of the triangle have the same length, so we can equate them. As `a` sits at the origin the distance from it to `b` and to `c` is equal to the length of the vectors that they define. So the length of those two vectors are equal: `||b||=||c||`, and thus their square lengths are also equal: `||b||^2=||c||^2`.

The square length of a 2D vector is `x^2+z^2`. Thus `||b||^2=(1-v)^2+(-v)^2=2v^2-2v+1` and `||c||^2=2(1-2v)^2=8v^2-8v+2`. Now we can solve the equation and find `v=(3-sqrt(3))/6`.

At this point we know how to convert from squares to triangles, but we have already declared that we are using a triangular lattice, that is our starting point. To find the lattice points we have to convert the other way, from triangles to squares. This requires moving all points along the same XZ diagonal, but now in the opposite direction—adding instead of subtracting the skew—so we have `[[x],[z]]->[[x+v(x+z)],[z+v(x+z)]]` where `v` is a different skew value that we have to find.

Let's consider the transformation for point `c`. We know that it has to end up at `[[1],[1]]` and `x=z` thus `x+2vx=1`. We also know that `x=1-2(3-sqrt(3))/6=1-(3-sqrt(3))/3=sqrt(3)/3=1/sqrt(3)`. This leads to `1/sqrt(3)+(2v)/sqrt(3)=1` and we find `v=(sqrt(3)-1)/2`.

Now we have two skew values: `(3-sqrt(3))/6` to convert from squares to triangles and `(sqrt(3)-1)/2` to convert from triangles to squares. To find the lattice points we have to apply the latter in GetNoise4, and then use the skewed coordinates to determine the lattice points.

			positions *= frequency;
			float4 skew = (positions.c0 + positions.c2) * ((sqrt(3f) - 1f) / 2f);
			float4 sx = positions.c0 + skew, sz = positions.c2 + skew;
			int4
				x0 = (int4)floor(sx), x1 = x0 + 1,
				z0 = (int4)floor(sz), z1 = z0 + 1;
Skewed coordinates; frequency 4.

This creates the correct lattice but messes up the kernels, because they are now calculated based on the skewed coordinates. We have to un-skew the square lattice points back to the triangles in Kernel to calculate them in the original space. For this we use the other skew value and subtract the skew from the lattice coordinates before subtracting them from the original coordinates. This is equivalent to adding the skew after the existing subtraction.

			float4 unskew = (lx + lz) * ((3f - sqrt(3f)) / 6f);
			float4 x = positions.c0 - lx + unskew, z = positions.c2 - lz + unskew;
			float4 f = 1f - x * x - z * z;

This fixes the kernel shapes but the result is blown-out white. The problem is that our kernel influence extends too far, because the triangles are smaller than the squares. The falloff should reach zero at the midpoint of the edge opposite to the corner it is centered on. We can do this by reducing the starting strength of the kernel to 0.5.

			float4 f = 0.5f - x * x - z * z;
Un-skewed kernels, but weak.

The kernels now have the correct shape and size but they are very weak, because at their center `f(0,0)=(1/2)^3=1/8`. This is fixed by scaling up the falloff to compensate: `f(x,z)=8(1/2-x^2-z^2)^3`.

			f = f * f * f * 8f;
Maximum amplitude; still frequency 4

In this case we can find two different amplitude minima: those at the midpoints along the edges and the one at the center of each triangle. The edge minimum is `m_e=16/27~~0.593` and the center minimum is `m_c=1000/1944~~0.514`.

Although our lattice and kernels are now finished, due to the skewing the simplex noise variant appears to have a higher frequency compared to the square-based version. Although this isn't a problem on its own, it makes comparing the different noise variants harder. So let's scale down the frequency, dividing it by √3 at the start of GetNoise4.

			positions *= frequency * (1f / sqrt(3f);
Frequency 4, scaled.

We finish 2D simplex value noise by including the gradient evaluation.

			return max(0f, f) * default(G).Evaluate(hash, x, z);
simplex 1 octave regular 1 octave
simplex 3 octaves regular 3 octaves
2D simplex and regular value noise; frequency 8; 1 and 3 octaves.

Compared to regular value noise, the simplex variant distorts the noise pattern to fit what appears like a honeycomb lattice. It is also weaker. The pattern difference is most obvious when comparing the turbulence variants.

simplex regular
2D simplex and regular value turbulence; frequency 4 and 3 octaves.

Only Three Kernels

Although our 2D simplex value noise is visually finished, we are currently still ignoring that each triangle requires only three kernels. We can verify this by looking at the four kernels that we currently use in isolation.

kernel 00 kernel 01
kernel 10 kernel 11
Kernels 00, 01, 10, and 11 in isolation.

Kernels 00 and 11—those along the XZ diagonal—contribute to every triangle, while the other kernels only influence half of the triangles. So we can always skip either the 01 or the 10 kernel. Let's begin by removing both from GetNoise4.

			return default(G).EvaluateCombined(
				Kernel(h0.Eat(z0), x0, z0, positions) +
				//Kernel(h0.Eat(z1), x0, z1, positions) +
				//Kernel(h1.Eat(z0), x1, z0, positions) +
				Kernel(h1.Eat(z1), x1, z1, positions)
			);
Only kernels 00 and 11.

Which third kernel is needed depends on which side of the XZ diagonal the sample point lies, in the skewed square lattice space. If we're on the side where the relative X coordinate is greater than the Z coordinate then we need the 10 kernel and otherwise the 01 kernel.

Choosing either the 01 or 10 kernel.

This is a vectorized decision that applies to the selection of the appropriate X and Z lattice points. Store whether the relative skewed X exceed Z in a bool4 variable directly after finding the lattice points.

			float4 sx = positions.c0 + skew, sz = positions.c2 + skew;
			int4
				x0 = (int4)floor(sx), x1 = x0 + 1,
				z0 = (int4)floor(sz), z1 = z0 + 1;

			bool4 xGz = sx - x0 > sz - z0;

			SmallXXHash4 h0 = hash.Eat(x0), h1 = hash.Eat(x1);

If X is greater than Z then select x1 and z0, otherwise select x0 and z1. Let's keep track of our choices via xC and zC.

			bool4 xGz = sx - x0 > sz - z0;
			int4 xC = select(x0, x1, xGz), zC = select(z1, z0, xGz);

This allows us to add the variable third kernel to our sum.

			return default(G).EvaluateCombined(
				Kernel(h0.Eat(z0), x0, z0, positions) +
				Kernel(h1.Eat(z1), x1, z1, positions) +
				Kernel(hash.Eat(xC).Eat(zC), xC, zC, positions)
			);

While this works it requires us to feed an X lattice point to the hash, which we have already done for both options. Ideally we could select either h0 or h1, but we cannot use the existing select methods because that would result in a premature hash avalanche. So let's add a public static SmallXXHash4.Select method that selects the appropriate accumulator without change.

	public static SmallXXHash4 Select (SmallXXHash4 a, SmallXXHash4 b, bool4 c) =>
		math.select(a.accumulator, b.accumulator, c);

Now we can reuse a partially-fed hash for the third kernel in Simplex2D.GetNoise4.

			SmallXXHash4
				h0 = hash.Eat(x0), h1 = hash.Eat(x1),
				hC = SmallXXHash4.Select(h0, h1, xGz);

			return default(G).EvaluateCombined(
				Kernel(h0.Eat(z0), x0, z0, positions) +
				Kernel(h1.Eat(z1), x1, z1, positions) +
				Kernel(hC.Eat(zC), xC, zC, positions)
			);

3D Simplices

Generating the 3D simplex lattice works the same as for 2D, but with an extra dimension. So instead of turning a square into a rhombus we turn a cube into a rhombohedron. The transformation is `[[x],[y],[z]]->[[x-v(x+y+z)],[y-v(x+y+z)],[z-v(x+y+z)]]` with `v` yet unknown.

Skewing from cube to rhombohedron.

And instead of splitting a square into two triangles we split a cube into six tetrahedra by selecting their corners in the following way: start with the 000 corner, then pick one of the three adjacent corners with a single 1, then pick one of the two adjacent corners with two 1s, and finish with 111. This can be done in six unique ways, resulting in six tetrahedra of the same shape that fill the cube.

Six tetrahedra in a cube.

We begin by copying the Simplex2D.Kernel method to Simplex3D and adjusting it to work for three dimensions. As we don't know the un-skew factor yet we'll set it to zero and use the same falloff start and scale as for 2D. Again we initially only show the falloff function, leaving the gradient evaluation for later.

	public struct Simplex3D<G> : INoise where G : struct, IGradient {

		…

		static float4 Kernel (
			SmallXXHash4 hash, float4 lx, float4 ly, float4 lz, float4x3 positions
		) {
			float4 unskew = (lx + ly + lz) * 0f;
			float4
				x = positions.c0 - lx + unskew,
				y = positions.c1 - ly + unskew,
				z = positions.c2 - lz + unskew;
			float4 f = 0.5f - x * x - y * y - z * z;
			f = f * f * f * 8f;
			return max(0f, f);
		}
	}

The 3D version of GetNoise uses the same approach as the 2D version. This time we begin with the original frequency, leave the skew factor at zero, and only include the 000 and 111 kernels. As they lie on the XYZ diagonal they'll be part of every tetrahedron, while the other two kernels are variable.

		public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) {
			positions *= frequency;
			float4 skew = (positions.c0 + positions.c1 + positions.c2) * 0f;
			float4
				sx = positions.c0 + skew,
				sy = positions.c1 + skew,
				sz = positions.c2 + skew;
			int4
				x0 = (int4)floor(sx), x1 = x0 + 1,
				y0 = (int4)floor(sy), y1 = y0 + 1,
				z0 = (int4)floor(sz), z1 = z0 + 1;

			SmallXXHash4
				h0 = hash.Eat(x0), h1 = hash.Eat(x1);

			return default(G).EvaluateCombined(
				Kernel(h0.Eat(y0).Eat(z0), x0, y0, z0, positions) +
				Kernel(h1.Eat(y1).Eat(z1), x1, y1, z1, positions)
			);
		}

To find the skew factor `v` we'll look at the tetrahedron with corners `a=[[0],[0],[0]]`, `b=[[1],[0],[0]]`,`c=[[1],[0],[1]]`, and `d=[[1],[1],[1]]`.

Just like for the triangle we'll find `v` by equating the square lengths of of the edges of the skewed `b`, `c`, and `d` vectors.This leads to a skew factor of ⅓.

			float4 skew = (positions.c0 + positions.c1 + positions.c2) * (1f / 3f);

And an un-skew factor of ⅙.

		static float4 Kernel (
			SmallXXHash4 hash, float4 lx, float4 ly, float4 lz, float4x3 positions
		) {
			float4 unskew = (lx + ly + lz) * (1f / 6f);
			…
		}
3D kernels 000 and 111; frequency 4.

What we get is a lattice based on groups of six tetrahedra that have the same shape but different orientations. Each tetrahedron has four edges with length √¾ and two edges with length 1.

Tetrahedron both skewed and un-skewed.

We again scale the frequency to make it easier to compare noise variants. In this case the scale factor is 0.6.

		public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) {
			positions *= frequency * 0.6f;
			…
		}
Scaled frequency, still 4.

In order for the falloff function to be correct it should reach zero when it reaches the tetrahedron face opposite to the corner it is centered on. This distance is √½, so our current falloff function is already correct.

Four Kernels

A tetrahedron has four corners, so we need to select two more kernels. Which kernels are needed can be decided in a similar way that we found the variable kernel in 2D, but now in three dimensions. We do this by walking along the edges of the cubes depending on which relative coordinates are greatest. If X is greater than both Y and Z then we go from corner 000 to 100. After that we go to 110 if Y is greater than Z, otherwise to 101. Each tetrahedron has a similar pattern.

Choosing kernels in two steps; the greater-than signs match walk direction.

To do this we can suffice with checking three comparisons: whether X is greater than Y, whether X is greater than Z, and whether Y is greater than Z.

			int4
				x0 = (int4)floor(sx), x1 = x0 + 1,
				y0 = (int4)floor(sy), y1 = y0 + 1,
				z0 = (int4)floor(sz), z1 = z0 + 1;

			bool4
				xGy = sx - x0 > sy - y0,
				xGz = sx - x0 > sz - z0,
				yGz = sy - y0 > sz - z0;

With these three boolean checks we can construct a truth table that contains a row for all eight possible combinations. For each row we can also write down the first and second kernel offset per dimension, either zero or one.

x>yx>zy>z XYZ
TTT1 10 10 0
TTF1 10 00 1
TFT0 10 01 1
TFF0 10 01 1
FTT0 11 10 0
FTF0 00 11 1
FFT0 01 10 1
FFF0 00 11 1

Because we cannot branch in our vectorized code we have to convert this to independent selection criteria for the first and second offsets per dimension. We'll keep track of whether the first X offset is 1 with an xA boolean variable and use xB for the second X offset. We'll use similar variables for Y and Z. Then we convert the truth table to boolean expressions and use those to set the variables. Note that we have to use the & operator for vectorized boolean AND, rather than the usual && operator.

			bool4
				xGy = sx - x0 > sy - y0,
				xGz = sx - x0 > sz - z0,
				yGz = sy - y0 > sz - z0;

			bool4
				xA = xGy & xGz,
				xB = xGy | (xGz & yGz),
				yA = !xGy & yGz,
				yB = !xGy | (xGz & yGz),
				zA = (xGy & !xGz) | (!xGy & !yGz),
				zB = !(xGz & yGz);

Use these to find the lattice point choices for the variable kernels.

			bool4
				xA = xGy & xGz,
				xB = xGy | (xGz & yGz),
				yA = !xGy & yGz,
				yB = !xGy | (xGz & yGz),
				zA = (xGy & !xGz) | (!xGy & !yGz),
				zB = !(xGz & yGz);

			int4
				xCA = select(x0, x1, xA),
				xCB = select(x0, x1, xB),
				yCA = select(y0, y1, yA),
				yCB = select(y0, y1, yB),
				zCA = select(z0, z1, zA),
				zCB = select(z0, z1, zB);

Then add the missing kernels to the sum. In this case we can reuse the fed kernels for both choices of X.

			SmallXXHash4
				h0 = hash.Eat(x0), h1 = hash.Eat(x1),
				hA = SmallXXHash4.Select(h0, h1, xA),
				hB = SmallXXHash4.Select(h0, h1, xB);

			return default(G).EvaluateCombined(
				Kernel(h0.Eat(y0).Eat(z0), x0, y0, z0, positions) +
				Kernel(h1.Eat(y1).Eat(z1), x1, y1, z1, positions) +
				Kernel(hA.Eat(yCA).Eat(zCA), xCA, yCA, zCA, positions) +
				Kernel(hB.Eat(yCB).Eat(zCB), xCB, yCB, zCB, positions)
			);
All kernels, without displacement; 3D and 2D.

The resulting kernel pattern looks similar to the 2D simplex lattice, but it has some variability due to its 3D nature and it not being aligned with the XZ plane. In this case we can find multiple different amplitude minima: at the midpoint of the short edges `m_s=125/256~~0.488`, at the midpoint of the long edges `m_l=1/4=0.25`, at the circumcenter of triangles `m_c=1029/4026~~0.256`, and at the circumcenter of the tetrahedron `m_t=27/128~~0.211`.

The variability is easier to see at higher frequencies.

Frequency 16.

There is a noticeable diagonal pattern. This is a consequence of the 3D skew, as the distance in the Z dimension of lattice points to the sample plane varies. The pattern depends on the orientation of the 2D slice. For example, compare no rotation with a 45° domain rotation in a single dimension.

X Y Z
45° domain rotation around X, Y, and Z.

Another interesting domain rotation is 45° around both X and Y, which aligns the slice such that we look straight down the XYZ line.

45° rotation around both X and Y.

Finally, we complete 3D simplex value noise by inserting the gradient evaluation.

			return max(0f, f) * default(G).Evaluate(hash, x, y, z);
no rotation X rotation
Y rotation Z rotation
3D simplex value noise, without rotation and with 45° rotation around X, Y, and Z.
simplex interpolated
45° rotation around both X and Y, simplex and regular 3D value noise.
simplex interpolated
45° rotation around both X and Y, simplex and regular 3D value turbulence.
3D 2D
3D and 2D simplex value turbulence.
simplex interpolated
3D simplex and regular value noise on a sphere.

Simplex Gradient Noise

To generate simplex gradient noise we need appropriate gradient vectors. We already have the Perlin struct type, but we based it on square and octahedron shapes. We did this because it worked well with the square and cube lattices and avoided vector normalization. However, the simplex lattices aren't axis-aligned and thus we'll need to use circle- and sphere-based gradients for simplex noise.

Base Gradients

The only difference between the square and circle and also between octahedron and sphere is whether their vectors are normalized. So rather than duplicate the vector-generating code we'll put it in a nested static Noise.BaseGradients class. Let's also do this for the line gradient and start with that, introducing a BaseGradients.Line method and use it in Perlin.

	public struct Perlin : IGradient {

		public float4 Evaluate (SmallXXHash4 hash, float4 x) =>
			//(1f + hash.Floats01A) * select(-x, x, ((uint4)hash & 1 << 8) == 0);
			BaseGradients.Line(hash, x);

		…
	}

	public static class BaseGradients {

		public static float4 Line (SmallXXHash4 hash, float4 x) =>
			(1f + hash.Floats01A) * select(-x, x, ((uint4)hash & 1 << 8) == 0);
	}

Then give BaseGradients private methods that generate and return the square- or octahedron-based vectors, given a hash.

		static float4x2 SquareVectors (SmallXXHash4 hash) {
			float4x2 v;
			v.c0 = hash.Floats01A * 2f - 1f;
			v.c1 = 0.5f - abs(v.c0);
			v.c0 -= floor(v.c0 + 0.5f);
			return v;
		}
		
		static float4x3 OctahedronVectors (SmallXXHash4 hash) {
			float4x3 g;
			g.c0 = hash.Floats01A * 2f - 1f;
			g.c1 = hash.Floats01D * 2f - 1f;
			g.c2 = 1f - abs(g.c0) - abs(g.c1);
			float4 offset = max(-g.c2, 0f);
			g.c0 += select(-offset, offset, g.c0 < 0f);
			g.c1 += select(-offset, offset, g.c1 < 0f);
			return g;
		}

Use these methods to implement Square, Circle, Octahedron, and Sphere€ gradient methods, without any scaling.

		public static float4 Square (SmallXXHash4 hash, float4 x, float4 y) {
			float4x2 v = SquareVectors(hash);
			return v.c0 * x + v.c1 * y;
		}
	
		public static float4 Circle (SmallXXHash4 hash, float4 x, float4 y) {
			float4x2 v = SquareVectors(hash);
			return (v.c0 * x + v.c1 * y) * rsqrt(v.c0 * v.c0 + v.c1 * v.c1);
		}
	
		public static float4 Octahedron (
			SmallXXHash4 hash, float4 x, float4 y, float4 z
		) {
			float4x3 v = OctahedronVectors(hash);
			return v.c0 * x + v.c1 * y + v.c2 * z;
		}

		public static float4 Sphere€ (SmallXXHash4 hash, float4 x, float4 y, float4 z) {
			float4x3 v = OctahedronVectors(hash);
			return
				(v.c0 * x + v.c1 * y + v.c2 * z) *
				rsqrt(v.c0 * v.c0 + v.c1 * v.c1 + v.c2 * v.c2);
		}

Then use the Square and Octahedron methods in Perlin, applying the appropriate scaling there.

		public float4 Evaluate (SmallXXHash4 hash, float4 x, float4 y) =>
			BaseGradients.Square(hash, x, y) * (2f / 0.53528f);
		
		public float4 Evaluate (SmallXXHash4 hash, float4 x, float4 y, float4 z) =>
			BaseGradients.Octahedron(hash, x, y, z) * (1f / 0.56290f);

Simplex Gradients

Now we can create a Simplex gradient type that uses the line, circle, and sphere base gradients. We leave them unscaled for now.

	public struct Simplex : IGradient {

		public float4 Evaluate (SmallXXHash4 hash, float4 x) =>
			BaseGradients.Line(hash, x);

		public float4 Evaluate (SmallXXHash4 hash, float4 x, float4 y) =>
			BaseGradients.Circle(hash, x, y);

		public float4 Evaluate (SmallXXHash4 hash, float4 x, float4 y, float4 z) =>
			BaseGradients.Sphere€(hash, x, y, z);

		public float4 EvaluateCombined (float4 value) => value;
	}

To be able to visualize the simplex gradient noise variants, add entries for them to NoiseVisualization.NoiseType.

	public enum NoiseType {
		Perlin€, PerlinTurbulence, Value€, ValueTurbulence,
		Simplex€, SimplexTurbulence, SimplexValue, SimplexValueTurbulence,
		VoronoiWorleyF1, VoronoiWorleyF2, VoronoiWorleyF2MinusF1,
		VoronoiChebyshevF1, VoronoiChebyshevF2, VoronoiChebyshevF2MinusF1
	}

Also add them to the job array, at the appropriate location.

		{
			Job<Simplex1D<Simplex>>.ScheduleParallel,
			Job<Simplex1D<Simplex>>.ScheduleParallel,
			Job<Simplex2D<Simplex>>.ScheduleParallel,
			Job<Simplex2D<Simplex>>.ScheduleParallel,
			Job<Simplex3D<Simplex>>.ScheduleParallel,
			Job<Simplex3D<Simplex>>.ScheduleParallel
		},
		{
			Job<Simplex1D<Turbulence<Simplex>>>.ScheduleParallel,
			Job<Simplex1D<Turbulence<Simplex>>>.ScheduleParallel,
			Job<Simplex2D<Turbulence<Simplex>>>.ScheduleParallel,
			Job<Simplex2D<Turbulence<Simplex>>>.ScheduleParallel,
			Job<Simplex3D<Turbulence<Simplex>>>.ScheduleParallel,
			Job<Simplex3D<Turbulence<Simplex>>>.ScheduleParallel
		},

This gives us functional simplex noise, but we still have to normalize it.

1D Simplex Noise

To normalize 1D simplex noise we have to find its unscaled maximum. Just like with Perlin noise, the maximum can only be reaches when the gradients of adjacent kernels point toward each other. Thus the maximum gradient function of a kernel can be described as `m(x)=xf(x)=x(1-x^2)^3`.

Maximum amplitude of adjacent kernels and their sum.

The sum of both kernels reaches its maximum at `x=1/2`. the maximum amplitude of the unscaled noise is `2m(1/2)=(3/4)^3=27/64`. But because Line produces a variable factor with a maximum of 2 we have to double the result. The normalization factor is the inverse of that: `32/27`. Include it in the 1D version of Simplex.Evaluate.

		public float4 Evaluate (SmallXXHash4 hash, float4 x) =>
			BaseGradients.Line(hash, x) * (32f / 27f);

The final result is very similar to 1D Perlin noise.

simplex perlin
1D simplex and Perlin noise.

2D Simplex Noise

In two dimensions we have two candidates for the maximum, that coincide with the minima of the falloff sums: the midpoint of an edge and the center of the triangle.

The edge maximum is `(lm_e)/2=(sqrt(2)/(2sqrt(3)))(16/27)=(8sqrt(2))/(27sqrt(3))~~0.2419`.

The center maximum is `rm_c=(sqrt(2)/3)(1000/1944)=(1000sqrt(2))/5832~~0.2425`.

Maxima visualized with gradient; white is highest.

As the center maximum is the largest we normalize 2D simplex noise via scaling with `1/(lm_c)=5832/(1000sqrt(2))`.

		public float4 Evaluate (SmallXXHash4 hash, float4 x, float4 y) =>
			BaseGradients.Circle(hash, x, y) * (5.832f / sqrt(2f));
simplex perlin
2D simplex and Perlin noise.
simplex perlin
2D simplex and Perlin turbulence noise.

3D Simplex Noise

To normalize the 3D gradients we first calculate the short edge maximum `(l_sm_s)/2=(125sqrt(3))/1024~~0.2114`.

Second, the long edge maximum at its middle is `(l_lm_l)/2=1/8=0.125`. In fact, its kernels are so far apart that it has two maxima instead of one in the middle. But we don't need to calculate those because they're guaranteed to be smaller than the short edge maximum.

Two maxima along a long edge.

The face triangles and tetrahedron interior also don't have a single maximum, the kernels are too far apart. Nowhere is the short edge maximum exceeded.

Maxima visualized with gradient; white is highest.

Thus our normalization factor is `2/(l_sm_s)=1024/(125sqrt(3))`.

		public float4 Evaluate (SmallXXHash4 hash, float4 x, float4 y, float4 z) =>
			BaseGradients.Sphere(hash, x, y, z) * (1024f / (125f * sqrt(3f)));
simplex perlin
simplex rotated perlin rotated
3D simplex and Perlin noise; no rotation and 45° XY rotation.
simplex perlin
simplex rotated perlin rotated
3D simplex and Perlin noise; no rotation and 45° XY rotation.
simplex perlin
3D simplex and Perlin noise on a sphere.

Want to know when the next tutorial gets released? Keep tabs on my Patreon page!

license repository PDF