Skip to content

randoms.kt

src/jvmMain/kotlin/afield/math/statistics/randoms.kt · 370 lines

package and imports — 52 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Math")

package afield.math.statistics

import afield.data.collections.invoke
import afield.data.collections.s
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.gradient
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.mean
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.channel
import afield.runtime.execution.animate
import afield.runtime.execution.cell
import afield.runtime.lifecycle.once
import afield.study.ui.numbers

import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.get
import afield.lang.stacksl.h
import afield.math.autodiff.cos
import afield.math.autodiff.floor
import afield.math.autodiff.ln
import afield.math.autodiff.sqrt
import afield.math.functions.cos
import afield.math.linalg.d
import afield.math.linalg.i
import afield.math.linalg.x
import afield.ui.output.h
import afield.document.graph.Box
import afield.math.linalg.Vector2f
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
import java.util.*
import kotlin.random.Random as KotlinRandom

Box-scoped randomness. random, randomGaussian and setRandomSeed draw from a java.util.Random held as a property on the current Box, so re-running a box replays the same sequence; with no Box in context they fall back to one process-wide generator seeded with STDLIB_RANDOM_DEFAULT_SEED.

Alongside the seed each box carries a noise phase you advance yourself (noisePhase, noiseForward, setNoisePhase). That phase and seed drive noise1D, a small Perlin-style hashed 1D noise, and the perlinNoise extensions on Vector2f/Vector3f/Vector4f, which take one independent noise channel per component.

All the vector extensions — perlinNoise and the uniform-jitter noise(d) — mutate the receiver in place and return it rather than allocating. nextGaussian is a Box-Muller extension that fills the gap left by kotlin.random.Random, which has no Gaussian of its own.

Box-scoped RNG so stdlib random/noise are repeatable per execution.

private const val STDLIB_RANDOM_PROPERTY = "__stdlib_random"
private const val STDLIB_RANDOM_SEED_PROPERTY = "__stdlib_random_seed"
private const val STDLIB_NOISE_PHASE_PROPERTY = "__stdlib_noise_phase"

const val STDLIB_RANDOM_DEFAULT_SEED: Long = 1L

The seed every box's generator starts from, and the seed setRandomSeed undoes.

It is 1L — a fixed value, so that a freshly re-run box produces the same "random" sketch as it did last time. Pass a seed of your own to setRandomSeed when you want a different draw, or null for an unpredictable one.

private val fallbackStdlibRandom = Random(STDLIB_RANDOM_DEFAULT_SEED)
private var fallbackStdlibSeed: Long = STDLIB_RANDOM_DEFAULT_SEED
private var fallbackStdlibPhase: Float = 0f

internal fun Box.resetStdlibRandom(seed: Long = STDLIB_RANDOM_DEFAULT_SEED): Random

internal fun Box.resetStdlibRandom(seed: Long = STDLIB_RANDOM_DEFAULT_SEED): Random {
    val rng = Random(seed)
    setPropertyRaw(STDLIB_RANDOM_PROPERTY, rng)
    setPropertyRaw(STDLIB_RANDOM_SEED_PROPERTY, seed)
    setPropertyRaw(STDLIB_NOISE_PHASE_PROPERTY, 0f)
    return rng
}

internal fun Box.stdlibRandom(): Random

internal fun Box.stdlibRandom(): Random {
    val existing = getPropertyRaw(STDLIB_RANDOM_PROPERTY) as? Random
    if (existing != null) return existing
    return resetStdlibRandom()
}

private fun currentStdlibSeed(): Long {
    val box = Box.context.get()
    val seeded = box?.getPropertyRaw(STDLIB_RANDOM_SEED_PROPERTY) as? Long
    return seeded ?: fallbackStdlibSeed
}

private fun currentStdlibPhase(): Float {
    val box = Box.context.get()
    val phase = box?.getPropertyRaw(STDLIB_NOISE_PHASE_PROPERTY) as? Float
    return phase ?: fallbackStdlibPhase
}

private fun setStdlibPhase(phase: Float): Float {
    val box = Box.context.get()
    if (box != null) {
        box.setPropertyRaw(STDLIB_NOISE_PHASE_PROPERTY, phase)
    } else {
        fallbackStdlibPhase = phase
    }
    return phase
}

private fun currentStdlibRandom(): Random {
    val box = Box.context.get()
    return box?.stdlibRandom() ?: fallbackStdlibRandom
}

fun random(): Double

A uniform sample from [0, 1) — zero possible, one not — drawn from the current box's generator.

Repeatable: the box's generator is reseeded to STDLIB_RANDOM_DEFAULT_SEED the first time it is touched, so the same box re-run gives the same sequence back. setRandomSeed is how you pick a different one.

fun random(): Double {
    return currentStdlibRandom().nextDouble()
}

fun randomGaussian(): Double

A standard-normal sample (mean 0, standard deviation 1) from the current box's generator.

Unbounded, though about 99.7% of draws land in -3..3; scale and offset it yourself for another mean and spread. Repeatable on the same terms as random.

fun randomGaussian(): Double {
    return currentStdlibRandom().nextGaussian()
}

fun KotlinRandom.nextGaussian(): Double

Standard-normal (Box-Muller) sample from any kotlin.random.Random. The Kotlin stdlib Random has no nextGaussian() of its own (unlike java.util.Random), so this fills the gap for code that carries its own seeded generator.

fun KotlinRandom.nextGaussian(): Double {
    var u1 = nextDouble()
    while (u1 <= Double.MIN_VALUE) u1 = nextDouble()
    val u2 = nextDouble()
    return kotlin.math.sqrt(-2.0 * kotlin.math.ln(u1)) *
        kotlin.math.cos(2.0 * kotlin.math.PI * u2)
}

fun setRandomSeed(seed: Long? = null): Long

Reseeds the current box's generator and resets its noise phase to 0, returning the seed used.

With no argument a seed is drawn unpredictably — the way to get a different sketch each run — and the return value is how you find out which one you got, so you can pass it back to reproduce the version you liked. Everything downstream restarts: random, randomGaussian, the noise jitter and, through the seed, noise1D and the perlinNoise extensions.

fun setRandomSeed(seed: Long? = null): Long {
    val chosenSeed = seed ?: KotlinRandom.Default.nextLong()
    val box = Box.context.get()
    if (box != null) {
        box.resetStdlibRandom(chosenSeed)
    } else {
        fallbackStdlibRandom.setSeed(chosenSeed)
        fallbackStdlibSeed = chosenSeed
        fallbackStdlibPhase = 0f
    }
    return chosenSeed
}

fun noisePhase(): Float = currentStdlibPhase()

Where along its noise curve this box currently is — the default phase noise1D samples at.

Starts at 0 and only moves when you move it, with noiseForward or setNoisePhase; nothing advances it for you, so a box that never advances the phase gets the same noise value every frame.

fun noiseForward(delta: Float): Float

Advances this box's noise phase by delta and returns the new phase.

The usual once-per-frame call for animated noise. delta is in phase units, and noise1D multiplies phase by its frequency, so one whole unit of delta is one lattice cell only at the default frequency = 1f — at frequency = 4f it is four. At that default, around 0.01..0.05 per frame reads as a slow drift and 1.0 as unrelated values frame to frame. Negative deltas run the noise backwards.

fun noiseForward(delta: Float): Float {
    val next = currentStdlibPhase() + delta
    return setStdlibPhase(next)
}

fun setNoisePhase(phase: Float): Float = setStdlibPhase(phase)

Jumps this box's noise phase straight to phase, returning it. Use to drive noise from a clock.

private fun fade(t: Float): Float {
    // smootherstep
    return t * t * t * (t * (t * 6 - 15) + 10)
}

private fun lerp(a: Float, b: Float, t: Float): Float = a + t * (b - a)

private fun grad1(hash: Int, x: Float): Float {
    return if ((hash and 1) == 0) x else -x
}

private fun hash1D(seed: Long, x: Int): Int {
    var h = x
    h = h xor (seed.toInt())
    h = h * 0x27d4eb2d
    h = h xor (h ushr 15)
    return h and 255
}

fun noise1D

Repeatable 1D Perlin-style noise: smooth, continuous, and bounded by ±0.5.

That half range is not a bug but it is a trap, so it is stated first: one-dimensional gradient noise with ±1 gradients peaks at 0.5 (midway between two lattice points that lean opposite ways), and nothing here re-normalises it. Double it if you want the full -1..1.

Unlike random this is a function of its phase rather than a stream — sample the same phase twice and you get the same number, and nearby phases give nearby numbers. One unit of phase is one lattice cell, frequency multiplies the phase before sampling (so frequency = 4 gives four times the wiggle per unit), and the default phase is the box's own noisePhase.

The curve is fixed by the box's random seed, so setRandomSeed gives you a different one. seedOffset picks a different curve off that seed, which is how you get one noise channel per thing you are animating without them moving in lockstep; the perlinNoise extensions use it exactly that way, one channel per vector component. extraHash is summed into the same scalar, so it is a second way to spell the same offset rather than an independent axis — seedOffset = 1 and extraHash = 1 give the identical curve, and a non-zero extraHash will collide with a neighbouring component's channel.

fun noise1D(
    phase: Float = currentStdlibPhase(),
    seedOffset: Long = 0L,
    frequency: Float = 1f,
    extraHash: Long = 0L
): Float {
    val baseSeed = currentStdlibSeed() + seedOffset + extraHash
    val phaseBias = hash1D(baseSeed, 0x12345).toFloat() / 255f // shift so integer phases aren't at lattice points

    val x = (phase + phaseBias) * frequency
    val xi = kotlin.math.floor(x).toInt()
    val xf = x - xi

    val g0 = grad1(hash1D(baseSeed, xi), xf)
    val g1 = grad1(hash1D(baseSeed, xi + 1), xf - 1f)

    val u = fade(xf)
    return lerp(g0, g1, u)
}

private fun sampleNoiseChannels(
    count: Int,
    amplitude: Float,
    phaseAdvance: Float,
    frequency: Float,
    seedOffset: Long = 0L,
    extraHash: Long = 0L
): FloatArray {
    val basePhase = noisePhase()
    val out = FloatArray(count) { i ->
        noise1D(basePhase, seedOffset + i, frequency, extraHash) * amplitude
    }
    if (phaseAdvance != 0f) noiseForward(phaseAdvance)
    return out
}

fun Vector2f.perlinNoise(amplitude: Float, phaseAdvance: Float = 0f, frequency: Float = 1f, seedOffset: Long = 0L, extraHash: Long = 0L): Vector2f

Adds smooth noise1D wander to this point in place, one independent channel per component, and returns the same instance.

Each component is offset by up to ±amplitude/2, because noise1D is bounded by ±0.5 before scaling. The receiver is mutated, so p.perlinNoise(4f) moves p — copy it first if you needed the original.

phaseAdvance is applied to the box's shared noisePhase after sampling, so a chain of calls in one frame all read the same phase; pass it on one call per frame (or use noiseForward yourself) to animate. seedOffset shifts which noise channels are used, so two points that should wander differently need different offsets — spaced by at least 2 here, since this call consumes channels seedOffset and seedOffset + 1.

fun Vector2f.perlinNoise(amplitude: Float, phaseAdvance: Float = 0f, frequency: Float = 1f, seedOffset: Long = 0L, extraHash: Long = 0L): Vector2f {
    val n = sampleNoiseChannels(2, amplitude, phaseAdvance, frequency, seedOffset, extraHash)
    this.x += n[0]
    this.y += n[1]
    return this
}

fun Vector3f.perlinNoise(amplitude: Float, phaseAdvance: Float = 0f, frequency: Float = 1f, seedOffset: Long = 0L, extraHash: Long = 0L): Vector3f

Adds smooth noise1D wander to this point in place — three independent channels, one per component — and returns the same instance. Conventions exactly as the Vector2f overload; this one consumes noise channels seedOffset through seedOffset + 2.

fun Vector3f.perlinNoise(amplitude: Float, phaseAdvance: Float = 0f, frequency: Float = 1f, seedOffset: Long = 0L, extraHash: Long = 0L): Vector3f {
    val n = sampleNoiseChannels(3, amplitude, phaseAdvance, frequency, seedOffset, extraHash)
    this.x += n[0]
    this.y += n[1]
    this.z += n[2]
    return this
}

fun Vector4f.perlinNoise(amplitude: Float, phaseAdvance: Float = 0f, frequency: Float = 1f, seedOffset: Long = 0L, extraHash: Long = 0L): Vector4f

Adds smooth noise1D wander to all four components in place, one independent channel each, and returns the same instance. Conventions as the Vector2f overload. Note that on a colour this wanders alpha too, which is rarely what you want — jitter the Vector3f and rebuild the alpha.

fun Vector4f.perlinNoise(amplitude: Float, phaseAdvance: Float = 0f, frequency: Float = 1f, seedOffset: Long = 0L, extraHash: Long = 0L): Vector4f {
    val n = sampleNoiseChannels(4, amplitude, phaseAdvance, frequency, seedOffset, extraHash)
    this.x += n[0]
    this.y += n[1]
    this.z += n[2]
    this.w += n[3]
    return this
}

fun Vector2f.noise(d: Number) : Vector2f

Jitters this point in place by an independent uniform draw per component and returns the same instance.

Each component moves by up to ±d/2 — d is the full width of the jitter box, not the radius — so p.noise(10) scatters within 10 units across. Unlike perlinNoise this is white noise from random, with no continuity between calls: a point jittered every frame flickers rather than drifts. The receiver is mutated.

fun Vector2f.noise(d: Number) : Vector2f {
    val rng = currentStdlibRandom()
    val scale = d.toFloat()
    this.x += (rng.nextFloat() - 0.5f) * scale
    this.y += (rng.nextFloat() - 0.5f) * scale
    return this
}

fun Vector3f.noise(d: Number) : Vector3f

Jitters all three components in place by up to ±d/2 each, uniformly and independently, and returns the same instance. As the Vector2f overload: white noise, not smooth wander.

fun Vector3f.noise(d: Number) : Vector3f {
    val rng = currentStdlibRandom()
    val scale = d.toFloat()
    this.x += (rng.nextFloat() - 0.5f) * scale
    this.y += (rng.nextFloat() - 0.5f) * scale
    this.z += (rng.nextFloat() - 0.5f) * scale
    return this
}

fun Vector4f.noise(d: Number) : Vector4f

Jitters all four components in place by up to ±d/2 each, uniformly and independently, and returns the same instance. As the Vector2f overload — including alpha, if this is a colour.

fun Vector4f.noise(d: Number) : Vector4f {
    val rng = currentStdlibRandom()
    val scale = d.toFloat()
    this.x += (rng.nextFloat() - 0.5f) * scale
    this.y += (rng.nextFloat() - 0.5f) * scale
    this.z += (rng.nextFloat() - 0.5f) * scale
    this.w += (rng.nextFloat() - 0.5f) * scale
    return this
}