refactor: Enhance docs, add tests in PerlinNoise (#6635)

* refactor: Enhance docs, add tests in `PerlinNoise`

* Fix lint

* Fix lint

---------

Co-authored-by: Deniz Altunkapan <deniz.altunkapan@outlook.com>
This commit is contained in:
Hardik Pawar
2025-10-11 18:45:18 +05:30
committed by GitHub
parent 69d8406a6b
commit f0fb971f35
2 changed files with 208 additions and 43 deletions

View File

@@ -4,99 +4,156 @@ import java.util.Random;
import java.util.Scanner;
/**
* For detailed info and implementation see: <a
* href="http://devmag.org.za/2009/04/25/perlin-noise/">Perlin-Noise</a>
* Utility for generating 2D value-noise blended across octaves (commonly known
* as Perlin-like noise).
*
* <p>
* The implementation follows the classic approach of:
* <ol>
* <li>Generate a base grid of random values in [0, 1).</li>
* <li>For each octave k, compute a layer by bilinear interpolation of the base
* grid
* at period 2^k.</li>
* <li>Blend all layers from coarse to fine using a geometric series of
* amplitudes
* controlled by {@code persistence}, then normalize to [0, 1].</li>
* </ol>
*
* <p>
* For background see:
* <a href="http://devmag.org.za/2009/04/25/perlin-noise/">Perlin Noise</a>.
*
* <p>
* Constraints and notes:
* <ul>
* <li>{@code width} and {@code height} should be positive.</li>
* <li>{@code octaveCount} must be at least 1 (0 would lead to a division by
* zero).</li>
* <li>{@code persistence} should be in (0, 1], typical values around
* 0.50.8.</li>
* <li>Given the same seed and parameters, results are deterministic.</li>
* </ul>
*/
public final class PerlinNoise {
private PerlinNoise() {
}
/**
* @param width width of noise array
* @param height height of noise array
* @param octaveCount numbers of layers used for blending noise
* @param persistence value of impact each layer get while blending
* @param seed used for randomizer
* @return float array containing calculated "Perlin-Noise" values
* Generate a 2D array of blended noise values normalized to [0, 1].
*
* @param width width of the noise array (columns)
* @param height height of the noise array (rows)
* @param octaveCount number of octaves (layers) to blend; must be >= 1
* @param persistence per-octave amplitude multiplier in (0, 1]
* @param seed seed for the random base grid
* @return a {@code width x height} array containing blended noise values in [0,
* 1]
*/
static float[][] generatePerlinNoise(int width, int height, int octaveCount, float persistence, long seed) {
final float[][] base = new float[width][height];
final float[][] perlinNoise = new float[width][height];
final float[][][] noiseLayers = new float[octaveCount][][];
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("width and height must be > 0");
}
if (octaveCount < 1) {
throw new IllegalArgumentException("octaveCount must be >= 1");
}
if (!(persistence > 0f && persistence <= 1f)) { // using > to exclude 0 and NaN
throw new IllegalArgumentException("persistence must be in (0, 1]");
}
final float[][] base = createBaseGrid(width, height, seed);
final float[][][] layers = createLayers(base, width, height, octaveCount);
return blendAndNormalize(layers, width, height, persistence);
}
/** Create the base random lattice values in [0,1). */
static float[][] createBaseGrid(int width, int height, long seed) {
final float[][] base = new float[width][height];
Random random = new Random(seed);
// fill base array with random values as base for noise
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
base[x][y] = random.nextFloat();
}
}
return base;
}
// calculate octaves with different roughness
/** Pre-compute each octave layer at increasing frequency. */
static float[][][] createLayers(float[][] base, int width, int height, int octaveCount) {
final float[][][] noiseLayers = new float[octaveCount][][];
for (int octave = 0; octave < octaveCount; octave++) {
noiseLayers[octave] = generatePerlinNoiseLayer(base, width, height, octave);
}
return noiseLayers;
}
/** Blend layers using geometric amplitudes and normalize to [0,1]. */
static float[][] blendAndNormalize(float[][][] layers, int width, int height, float persistence) {
final int octaveCount = layers.length;
final float[][] out = new float[width][height];
float amplitude = 1f;
float totalAmplitude = 0f;
// calculate perlin noise by blending each layer together with specific persistence
for (int octave = octaveCount - 1; octave >= 0; octave--) {
amplitude *= persistence;
totalAmplitude += amplitude;
final float[][] layer = layers[octave];
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
// adding each value of the noise layer to the noise
// by increasing amplitude the rougher noises will have more impact
perlinNoise[x][y] += noiseLayers[octave][x][y] * amplitude;
out[x][y] += layer[x][y] * amplitude;
}
}
}
// normalize values so that they stay between 0..1
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
perlinNoise[x][y] /= totalAmplitude;
}
if (totalAmplitude <= 0f || Float.isInfinite(totalAmplitude) || Float.isNaN(totalAmplitude)) {
throw new IllegalStateException("Invalid totalAmplitude computed during normalization");
}
return perlinNoise;
final float invTotal = 1f / totalAmplitude;
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
out[x][y] *= invTotal;
}
}
return out;
}
/**
* @param base base random float array
* @param width width of noise array
* Generate a single octave layer by bilinear interpolation of a base grid at a
* given octave (period = 2^octave).
*
* @param base base random float array of size {@code width x height}
* @param width width of noise array
* @param height height of noise array
* @param octave current layer
* @return float array containing calculated "Perlin-Noise-Layer" values
* @param octave current octave (0 for period 1, 1 for period 2, ...)
* @return float array containing the octave's interpolated values
*/
static float[][] generatePerlinNoiseLayer(float[][] base, int width, int height, int octave) {
float[][] perlinNoiseLayer = new float[width][height];
// calculate period (wavelength) for different shapes
// Calculate period (wavelength) for different shapes.
int period = 1 << octave; // 2^k
float frequency = 1f / period; // 1/2^k
for (int x = 0; x < width; x++) {
// calculates the horizontal sampling indices
// Calculate the horizontal sampling indices.
int x0 = (x / period) * period;
int x1 = (x0 + period) % width;
float horizintalBlend = (x - x0) * frequency;
float horizontalBlend = (x - x0) * frequency;
for (int y = 0; y < height; y++) {
// calculates the vertical sampling indices
// Calculate the vertical sampling indices.
int y0 = (y / period) * period;
int y1 = (y0 + period) % height;
float verticalBlend = (y - y0) * frequency;
// blend top corners
float top = interpolate(base[x0][y0], base[x1][y0], horizintalBlend);
// Blend top corners.
float top = interpolate(base[x0][y0], base[x1][y0], horizontalBlend);
// blend bottom corners
float bottom = interpolate(base[x0][y1], base[x1][y1], horizintalBlend);
// Blend bottom corners.
float bottom = interpolate(base[x0][y1], base[x1][y1], horizontalBlend);
// blend top and bottom interpolation to get the final blend value for this cell
// Blend top and bottom interpolation to get the final value for this cell.
perlinNoiseLayer[x][y] = interpolate(top, bottom, verticalBlend);
}
}
@@ -105,16 +162,21 @@ public final class PerlinNoise {
}
/**
* @param a value of point a
* @param b value of point b
* @param alpha determine which value has more impact (closer to 0 -> a,
* closer to 1 -> b)
* @return interpolated value
* Linear interpolation between two values.
*
* @param a value at alpha = 0
* @param b value at alpha = 1
* @param alpha interpolation factor in [0, 1]
* @return interpolated value {@code (1 - alpha) * a + alpha * b}
*/
static float interpolate(float a, float b, float alpha) {
return a * (1 - alpha) + alpha * b;
}
/**
* Small demo that prints a text representation of the noise using a provided
* character set.
*/
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
@@ -148,7 +210,7 @@ public final class PerlinNoise {
final char[] chars = charset.toCharArray();
final int length = chars.length;
final float step = 1f / length;
// output based on charset
// Output based on charset thresholds.
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
float value = step;

View File

@@ -0,0 +1,103 @@
package com.thealgorithms.others;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class PerlinNoiseTest {
@Test
@DisplayName("generatePerlinNoise returns array with correct dimensions")
void testDimensions() {
int w = 8;
int h = 6;
float[][] noise = PerlinNoise.generatePerlinNoise(w, h, 4, 0.6f, 123L);
assertThat(noise).hasDimensions(w, h);
}
@Test
@DisplayName("All values are within [0,1] after normalization")
void testRange() {
int w = 16;
int h = 16;
float[][] noise = PerlinNoise.generatePerlinNoise(w, h, 5, 0.7f, 42L);
for (int x = 0; x < w; x++) {
for (int y = 0; y < h; y++) {
assertThat(noise[x][y]).isBetween(0f, 1f);
}
}
}
@Test
@DisplayName("Deterministic for same parameters and seed")
void testDeterminism() {
int w = 10;
int h = 10;
long seed = 98765L;
float[][] a = PerlinNoise.generatePerlinNoise(w, h, 3, 0.5f, seed);
float[][] b = PerlinNoise.generatePerlinNoise(w, h, 3, 0.5f, seed);
for (int x = 0; x < w; x++) {
for (int y = 0; y < h; y++) {
assertThat(a[x][y]).isEqualTo(b[x][y]);
}
}
}
@Test
@DisplayName("Different seeds produce different outputs (probabilistically)")
void testDifferentSeeds() {
int w = 12;
int h = 12;
float[][] a = PerlinNoise.generatePerlinNoise(w, h, 4, 0.8f, 1L);
float[][] b = PerlinNoise.generatePerlinNoise(w, h, 4, 0.8f, 2L);
// Count exact equalities; expect very few or none.
int equalCount = 0;
for (int x = 0; x < w; x++) {
for (int y = 0; y < h; y++) {
if (Float.compare(a[x][y], b[x][y]) == 0) {
equalCount++;
}
}
}
assertThat(equalCount).isLessThan(w * h / 10); // less than 10% equal exact values
}
@Test
@DisplayName("Interpolation endpoints are respected")
void testInterpolateEndpoints() {
assertThat(PerlinNoise.interpolate(0f, 1f, 0f)).isEqualTo(0f);
assertThat(PerlinNoise.interpolate(0f, 1f, 1f)).isEqualTo(1f);
assertThat(PerlinNoise.interpolate(0.2f, 0.8f, 0.5f)).isEqualTo(0.5f);
}
@Test
@DisplayName("Single octave reduces to bilinear interpolation of base grid")
void testSingleOctaveLayer() {
int w = 8;
int h = 8;
long seed = 7L;
float[][] base = PerlinNoise.createBaseGrid(w, h, seed);
float[][] layer = PerlinNoise.generatePerlinNoiseLayer(base, w, h, 0); // period=1
// With period = 1, x0=x, x1=(x+1)%w etc. Values should be smooth and within
// [0,1]
for (int x = 0; x < w; x++) {
for (int y = 0; y < h; y++) {
assertThat(layer[x][y]).isBetween(0f, 1f);
}
}
}
@Test
@DisplayName("Invalid inputs are rejected")
void testInvalidInputs() {
assertThatThrownBy(() -> PerlinNoise.generatePerlinNoise(0, 5, 1, 0.5f, 1L)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> PerlinNoise.generatePerlinNoise(5, -1, 1, 0.5f, 1L)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> PerlinNoise.generatePerlinNoise(5, 5, 0, 0.5f, 1L)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> PerlinNoise.generatePerlinNoise(5, 5, 1, 0f, 1L)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> PerlinNoise.generatePerlinNoise(5, 5, 1, Float.NaN, 1L)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> PerlinNoise.generatePerlinNoise(5, 5, 1, 1.1f, 1L)).isInstanceOf(IllegalArgumentException.class);
}
}