Skip to content

ColorRpn.kt

src/jvmMain/kotlin/afield/lang/rpn/ColorRpn.kt · 675 lines

@file:afield.runtime.completion.CompletionCategory("Search")

package afield.lang.rpn

import afield.media.image.util.PngEncoder
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.stream.IntStream
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.floor
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.tan

A reverse-Polish language whose programs are pictures: tokens push numbers and colours onto a stack, the program runs once per pixel of a square, and what is left on the stack is that pixel's colour.

ColorRpn is the language, and it is small enough to read in one sitting: four variables for where the pixel is, twenty-five words, and no way to fail. Compiling a program gives a ColorRpnProgram, which reports the colour at one point, renders a square at any size on all cores, or hands back PNG bytes that display shows as an image.

display("main", ColorRpn.compile("x 9 * sin 0.18 +  y 0.55 +  0.62  hsl").png(256))

The rest of the family builds a search on top: ColorRpnGenome.kt breeds one program from two, and ColorRpnEvolution.kt puts a grid of the results in a box for you to pick from. The language is described word by word on ColorRpn below.

object ColorRpn

The colour expression language of the madp GA demo (ga.html): a reverse-Polish program run once per pixel, whose result is a colour. It is the genome of interactive evolution in the manner of Karl Sims' Genetic Images (see ColorRpnGenome and afield.study.search.EyeEvolution), and this file follows the demo's interpreter exactly, so a program draws the same picture here as in the browser.

val program = ColorRpn.compile("x 9 * sin 0.18 +  y 0.55 +  0.62  hsl")
display("main", program.png(256))

Values. A stack item is a number or a colour (three channels). Variables push numbers: x and y run 0..1 across the picture (y grows downward, as on a canvas), r is the distance from the centre divided by the distance to a corner, and a is the angle about the centre as a fraction of a turn. Any other token that reads as a number pushes it.

Words.

word takes leaves
dup v v v
rgb, hsl three numbers (a colour counts as its luma) a colour; rgb wraps each into 0..1
sat, hue, lum a colour and a number, either order the colour with that HSL component set; two numbers average, two colours average
+ - * / two values numbers: plain arithmetic, / safe near zero; with a colour: per channel, wrapped into 0..1, - is the absolute difference
exp ln sin cos tan asin acos atan invert v per channel for a colour (then wrapped); invert is 1 - v; all clamp their domain instead of failing
perlin phase 1D gradient noise in 0..1
fbm phase, lacunarity four octaves of perlin
normalize v a number's sign, or a colour scaled to unit length
dot two values a number: the product, or the channel dot product
if a b c b when c > 0.5, else a (a colour condition counts as its luma)
blend a number and two colours, any order the colours mixed by the number; numbers stand in as greys

Every use of perlin or fbm in one program gets its own seed, cycling 1, 2, 3, 4.

Nothing fails. Reading an empty stack gives 0.5. Unknown tokens are skipped (and reported in ColorRpnProgram.unknown); // and # start comments. An empty program is 0.5. Non-finite results become 0.5 and numbers are held within ±1e6. Whatever is left on the stack becomes a colour: a colour on top is the answer; otherwise the two topmost colours are averaged, or the topmost colour is mixed 70/30 with the topmost number, or two numbers make a blue-to-orange colour, or one number is a grey.

object ColorRpn {

val operators: List<String>

The words of the language, lower case, in the demo's order: stack, constructors, setters, binary, unary, special.

    val operators: List<String> = listOf(
        "dup", "rgb", "hsl", "sat", "hue", "lum", "+", "-", "*", "/", "dot",
        "exp", "ln", "sin", "cos", "tan", "asin", "acos", "atan", "invert", "perlin", "normalize",
        "if", "blend", "fbm",
    )

val variables: List<String> = listOf("x", "y", "r", "a")

The four variables.

fun tokens(source: String?): List<String>

The tokens of source: whitespace separated, with // and # comments removed.

    fun tokens(source: String?): List<String> {
        if (source.isNullOrEmpty()) return emptyList()
        return source.lines().joinToString(" ") { line -> line.substringBefore("//").substringBefore("#") }
            .trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
    }

fun isWord(token: String): Boolean = WORDS.containsKey(token.lowercase())

True when token is one of operators or variables (case-insensitively).

fun number(token: String): Double?

token as a number, the way JavaScript's Number(token) reads it, or null if it is not a finite one.

    fun number(token: String): Double? {
        val t = token.trim()
        val v = when {
            DECIMAL.matches(t) -> t.toDouble()
            t.length > 2 && t[0] == '0' && t[1] in "xX" -> t.substring(2).toLongOrNull(16)?.toDouble()
            t.length > 2 && t[0] == '0' && t[1] in "bB" -> t.substring(2).toLongOrNull(2)?.toDouble()
            t.length > 2 && t[0] == '0' && t[1] in "oO" -> t.substring(2).toLongOrNull(8)?.toDouble()
            else -> null
        }
        return v?.takeIf { it.isFinite() }
    }

fun format(value: Double): String

A number as the demo writes it into a program: three decimals below 10, two below 100, one above, trailing zeros dropped.

    fun format(value: Double): String {
        var n = safe(value)
        if (abs(n) < 1e-6) n = 0.0
        val places = if (abs(n) >= 100) 1 else if (abs(n) >= 10) 2 else 3
        val fixed = BigDecimal(n).setScale(places, RoundingMode.HALF_UP).toPlainString().replace(TRAILING_ZEROS, "")
        return if (fixed == "-0") "0" else fixed
    }

fun pretty(tokens: List<String>): String

tokens laid out as the demo lays out a program: a new line after eight tokens, after 42 characters, or after rgb, hsl, blend or if once a line has five tokens.

    fun pretty(tokens: List<String>): String {
        if (tokens.isEmpty()) return "0.5"
        val lines = mutableListOf<String>()
        val line = mutableListOf<String>()
        var chars = 0
        for (token in tokens) {
            line += token
            chars += token.length + 1
            val boundary = token == "rgb" || token == "hsl" || token == "blend" || token == "if"
            if (line.size >= 8 || chars >= 42 || (boundary && line.size >= 5)) {
                lines += line.joinToString(" ")
                line.clear()
                chars = 0
            }
        }
        if (line.isNotEmpty()) lines += line.joinToString(" ")
        return lines.joinToString("\n")
    }

fun compile(source: String?): ColorRpnProgram

Compile source into a program that can be evaluated per pixel. Never fails.

    fun compile(source: String?): ColorRpnProgram {
        val ops = ArrayList<Int>()
        val data = ArrayList<Double>()
        val known = ArrayList<String>()
        val unknown = ArrayList<String>()
        var noiseUses = 0
        for (token in tokens(source)) {
            val num = number(token)
            if (num != null) {
                ops += PUSH; data += num; known += format(num)
                continue
            }
            val lowered = token.lowercase()
            val op = WORDS[lowered]
            if (op == null) {
                unknown += token
                continue
            }
            ops += op
            data += if (op == PERLIN || op == FBM) (1 + noiseUses++ % 4).toDouble() else 0.0
            known += lowered
        }
        if (ops.isEmpty()) {
            ops += PUSH; data += 0.5; known += "0.5"
        }
        return ColorRpnProgram(source ?: "", known, unknown, ops.toIntArray(), data.toDoubleArray())
    }

fun wrap01(v: Double): Double

Wrap v into 0..1 the way the demo does (v % 1, lifted when negative); non-finite is 0.5.

    fun wrap01(v: Double): Double {
        if (!v.isFinite()) return 0.5
        val w = v % 1.0
        return if (w < 0) w + 1 else w
    }

fun clamp01(v: Double): Double = if (!v.isFinite()) 0.5 else v.coerceIn(0.0, 1.0)

Clamp v into 0..1; non-finite is 0.5.

fun safe(v: Double): Double = if (!v.isFinite()) 0.5 else v.coerceIn(-1e6, 1e6)

v held within ±1e6; non-finite is 0.5.

fun safeDiv(a: Double, b: Double): Double

a / b, with b kept at least 1e-6 away from zero (keeping its sign), then safe.

    fun safeDiv(a: Double, b: Double): Double {
        val d = if (abs(b) < 1e-6) (if (b < 0) -1e-6 else 1e-6) else b
        return safe(a / d)
    }

fun perlin(phase: Double, seed: Int): Double = clamp01(0.5 + perlinSigned(phase, seed) * 0.5)

1D gradient noise in 0..1 at phase, for one of the per-use seeds.

fun fbm(phase: Double, lacunarity: Double, seed: Int): Double

Four octaves of perlin; lacunarity at or below 1 is read as 1.5..4, above it is held to 1.05..6.

    fun fbm(phase: Double, lacunarity: Double, seed: Int): Double {
        val l = abs(safe(lacunarity))
        val lac = if (l <= 1) 1.5 + l * 2.5 else l.coerceIn(1.05, 6.0)
        var freq = 1.0
        var amp = 0.5
        var sum = 0.0
        var norm = 0.0
        for (octave in 0 until 4) {
            sum += perlinSigned(phase * freq, seed + octave * 17) * amp
            norm += amp
            freq *= lac
            amp *= 0.5
        }
        return clamp01(0.5 + 0.5 * safeDiv(sum, if (norm == 0.0) 1.0 else norm))
    }

fun luma(r: Double, g: Double, b: Double): Double = 0.2126 * r + 0.7152 * g + 0.0722 * b

Rec. 709 luma: what a colour counts as where a word wants a number.

fun hslToRgb(h0: Double, s0: Double, l0: Double, out: DoubleArray): DoubleArray

HSL to RGB into out: hue wrapped, saturation and lightness clamped.

    fun hslToRgb(h0: Double, s0: Double, l0: Double, out: DoubleArray): DoubleArray {
        val h = wrap01(h0)
        val s = clamp01(s0)
        val l = clamp01(l0)
        if (s <= 0) {
            out[0] = l; out[1] = l; out[2] = l
            return out
        }
        val q = if (l < 0.5) l * (1 + s) else l + s - l * s
        val p = 2 * l - q
        out[0] = hueToRgb(p, q, h + 1.0 / 3)
        out[1] = hueToRgb(p, q, h)
        out[2] = hueToRgb(p, q, h - 1.0 / 3)
        return out
    }

fun rgbToHsl(r0: Double, g0: Double, b0: Double, out: DoubleArray): DoubleArray

RGB (each wrapped into 0..1) to HSL into out.

body — 49 lines, from line 243
    fun rgbToHsl(r0: Double, g0: Double, b0: Double, out: DoubleArray): DoubleArray {
        val r = wrap01(r0)
        val g = wrap01(g0)
        val b = wrap01(b0)
        val mx = max(r, max(g, b))
        val mn = min(r, min(g, b))
        var h = 0.0
        var s = 0.0
        val l = (mx + mn) * 0.5
        if (mx != mn) {
            val d = mx - mn
            s = if (l > 0.5) d / (2 - mx - mn) else d / (mx + mn)
            h = when (mx) {
                r -> (g - b) / d + (if (g < b) 6 else 0)
                g -> (b - r) / d + 2
                else -> (r - g) / d + 4
            }
            h /= 6
        }
        out[0] = h; out[1] = s; out[2] = l
        return out
    }

    private fun hueToRgb(p: Double, q: Double, t0: Double): Double {
        val t = wrap01(t0)
        if (t < 1.0 / 6) return p + (q - p) * 6 * t
        if (t < 1.0 / 2) return q
        if (t < 2.0 / 3) return p + (q - p) * (2.0 / 3 - t) * 6
        return p
    }

    private fun hash(x: Int, seed: Int): Int {
        var h = x xor (seed * 374761393)
        h = (h xor (h ushr 13)) * 1274126177
        return h xor (h ushr 16)
    }

    private fun perlinSigned(phase0: Double, seed: Int): Double {
        val phase = safe(phase0)
        val cell = floor(phase)
        val local = phase - cell
        val c = cell.toInt()
        val g0 = if (hash(c, seed) and 1 != 0) 1.0 else -1.0
        val g1 = if (hash(c + 1, seed) and 1 != 0) 1.0 else -1.0
        val n0 = g0 * local
        val n1 = g1 * (local - 1)
        val fade = local * local * local * (local * (local * 6 - 15) + 10)
        return ((n0 + (n1 - n0) * fade) * 2).coerceIn(-1.0, 1.0)
    }

internal const val PUSH = 0

internal const val X = 1

internal const val Y = 2

internal const val R = 3

internal const val A = 4

internal const val DUP = 5

internal const val RGB = 6

internal const val HSL = 7

internal const val SAT = 8

internal const val HUE = 9

internal const val LUM = 10

internal const val ADD = 11

internal const val SUB = 12

internal const val MUL = 13

internal const val DIV = 14

internal const val EXP = 15

internal const val LN = 16

internal const val SIN = 17

internal const val COS = 18

internal const val TAN = 19

internal const val ASIN = 20

internal const val ACOS = 21

internal const val ATAN = 22

internal const val INVERT = 23

internal const val PERLIN = 24

internal const val NORMALIZE = 25

internal const val IF = 26

internal const val BLEND = 27

internal const val DOT = 28

internal const val FBM = 29

    private val WORDS: Map<String, Int> = mapOf(
        "x" to X, "y" to Y, "r" to R, "a" to A, "dup" to DUP, "rgb" to RGB, "hsl" to HSL,
        "sat" to SAT, "hue" to HUE, "lum" to LUM, "+" to ADD, "-" to SUB, "*" to MUL, "/" to DIV,
        "exp" to EXP, "ln" to LN, "sin" to SIN, "cos" to COS, "tan" to TAN, "asin" to ASIN,
        "acos" to ACOS, "atan" to ATAN, "invert" to INVERT, "perlin" to PERLIN,
        "normalize" to NORMALIZE, "if" to IF, "blend" to BLEND, "dot" to DOT, "fbm" to FBM,
    )

    private val DECIMAL = Regex("[+-]?(\\d+\\.?\\d*|\\.\\d+)([eE][+-]?\\d+)?")
    private val TRAILING_ZEROS = Regex("\\.?0+$")

internal const val TAU = PI * 2

internal val DIAGONAL = sqrt(0.5)

class ColorRpnProgram internal constructor

A compiled ColorRpn program.

class ColorRpnProgram internal constructor(
    /** The text it was compiled from. */
    val source: String,
    /**
     * The tokens it kept, normalised: lower case, numbers as [ColorRpn.format] writes them. Two
     * programs with the same [normalized] draw the same picture.
     */
    val tokens: List<String>,
    /** The tokens it skipped, in the order they appeared. An unknown word is dropped, not an error. */
    val unknown: List<String>,
    private val ops: IntArray,
    private val data: DoubleArray,
) {

val normalized: String get() = tokens.joinToString(" ")

tokens joined by single spaces.

    private val capacity = ops.size * 2 + 16

A scratch stack for one thread; color allocates one per call, render one per row.

    private inner class Stack {
        val type = IntArray(capacity)
        val a = DoubleArray(capacity)
        val b = DoubleArray(capacity)
        val c = DoubleArray(capacity)
        val hsl = DoubleArray(3)
    }

fun color(x: Double, y: Double, r: Double = radius(x, y), a: Double = angle(x, y)): DoubleArray

The colour at one point, as [r, g, b] in 0..1; r and a as ColorRpn defines them for the point (x, y).

    fun color(x: Double, y: Double, r: Double = radius(x, y), a: Double = angle(x, y)): DoubleArray =
        evaluate(Stack(), x, y, r, a, DoubleArray(3))

fun render(size: Int): IntArray

The picture at size×size, ARGB, row 0 at the top, rendered on all cores. Pixel centres sit at (i + 0.5) / size, held to single precision as the demo's coordinate arrays hold them.

    fun render(size: Int): IntArray {
        val px = IntArray(size * size)
        IntStream.range(0, size).parallel().forEach { row ->
            val stack = Stack()
            val out = DoubleArray(3)
            val y = (row + 0.5) / size
            for (col in 0 until size) {
                val x = (col + 0.5) / size
                evaluate(stack, x.toFloat().toDouble(), y.toFloat().toDouble(),
                    radius(x, y).toFloat().toDouble(), angle(x, y).toFloat().toDouble(), out)
                val rr = (ColorRpn.clamp01(out[0]) * 255).roundToInt()
                val gg = (ColorRpn.clamp01(out[1]) * 255).roundToInt()
                val bb = (ColorRpn.clamp01(out[2]) * 255).roundToInt()
                px[row * size + col] = (0xff shl 24) or (rr shl 16) or (gg shl 8) or bb
            }
        }
        return px
    }

fun png(size: Int): ByteArray = PngEncoder.encode(render(size), size, size)

render encoded as PNG bytes, which display(...) shows as an image.

override fun toString(): String = normalized

The program as normalized, which is what the demo's captions print.

source — 218 lines, from line 401
    private fun radius(x: Double, y: Double): Double {
        val dx = x - 0.5
        val dy = y - 0.5
        return min(1.0, sqrt(dx * dx + dy * dy) / ColorRpn.DIAGONAL)
    }

    private fun angle(x: Double, y: Double): Double = ColorRpn.wrap01(atan2(y - 0.5, x - 0.5) / ColorRpn.TAU)

    private fun unary(op: Int, v0: Double): Double {
        val v = ColorRpn.safe(v0)
        return when (op) {
            ColorRpn.EXP -> exp(v.coerceIn(-8.0, 8.0))
            ColorRpn.LN -> ln(max(1e-6, abs(v)))
            ColorRpn.SIN -> sin(v)
            ColorRpn.COS -> cos(v)
            ColorRpn.TAN -> tan(v).coerceIn(-64.0, 64.0)
            ColorRpn.ASIN -> asin(signedUnit(v))
            ColorRpn.ACOS -> acos(signedUnit(v))
            ColorRpn.ATAN -> atan(v)
            ColorRpn.INVERT -> 1 - v
            else -> v
        }
    }

    private fun signedUnit(v: Double): Double = (ColorRpn.wrap01((v + 1) * 0.5) * 2 - 1).coerceIn(-1.0, 1.0)

    @Suppress("CyclomaticComplexMethod", "LongMethod")
    private fun evaluate(s: Stack, x: Double, y: Double, r: Double, a: Double, out: DoubleArray): DoubleArray {
        val type = s.type
        val sa = s.a
        val sb = s.b
        val sc = s.c
        var sp = 0

        // pop into these; an empty stack reads as the number 0.5
        var t0 = 0; var a0 = 0.0; var b0 = 0.0; var c0 = 0.0
        var t1 = 0; var a1 = 0.0; var b1 = 0.0; var c1 = 0.0
        var t2 = 0; var a2 = 0.0; var b2 = 0.0; var c2 = 0.0

        fun push(t: Int, va: Double, vb: Double = 0.0, vc: Double = 0.0) {
            type[sp] = t; sa[sp] = va; sb[sp] = vb; sc[sp] = vc; sp++
        }
        fun pop0(default: Double = 0.5) {
            if (sp > 0) { sp--; t0 = type[sp]; a0 = sa[sp]; b0 = sb[sp]; c0 = sc[sp] } else { t0 = NUM; a0 = default; b0 = 0.0; c0 = 0.0 }
        }
        fun pop1(default: Double = 0.5) {
            if (sp > 0) { sp--; t1 = type[sp]; a1 = sa[sp]; b1 = sb[sp]; c1 = sc[sp] } else { t1 = NUM; a1 = default; b1 = 0.0; c1 = 0.0 }
        }
        fun pop2() {
            if (sp > 0) { sp--; t2 = type[sp]; a2 = sa[sp]; b2 = sb[sp]; c2 = sc[sp] } else { t2 = NUM; a2 = 0.5; b2 = 0.0; c2 = 0.0 }
        }
        /** Pop one value as a number: a colour counts as its luma. */
        fun popNumber(): Double {
            if (sp <= 0) return 0.5
            sp--
            return if (type[sp] == COLOR) ColorRpn.luma(sa[sp], sb[sp], sc[sp]) else sa[sp]
        }

        for (i in ops.indices) {
            val op = ops[i]
            when (op) {
                ColorRpn.PUSH -> push(NUM, data[i])
                ColorRpn.X -> push(NUM, x)
                ColorRpn.Y -> push(NUM, y)
                ColorRpn.R -> push(NUM, r)
                ColorRpn.A -> push(NUM, a)
                ColorRpn.DUP -> {
                    if (sp <= 0) push(NUM, 0.5)
                    push(type[sp - 1], sa[sp - 1], sb[sp - 1], sc[sp - 1])
                }
                ColorRpn.RGB -> {
                    val c = popNumber(); val b = popNumber(); val aa = popNumber()
                    push(COLOR, ColorRpn.wrap01(aa), ColorRpn.wrap01(b), ColorRpn.wrap01(c))
                }
                ColorRpn.HSL -> {
                    val l = popNumber(); val sat = popNumber(); val h = popNumber()
                    ColorRpn.hslToRgb(h, sat, l, out)
                    push(COLOR, out[0], out[1], out[2])
                }
                ColorRpn.SAT, ColorRpn.HUE, ColorRpn.LUM -> {
                    pop1(); pop0()
                    if (t0 == COLOR || t1 == COLOR) {
                        val n: Double
                        if (t0 == COLOR && t1 == COLOR) {
                            n = (ColorRpn.luma(a0, b0, c0) + ColorRpn.luma(a1, b1, c1)) * 0.5
                            a0 = (a0 + a1) * 0.5; b0 = (b0 + b1) * 0.5; c0 = (c0 + c1) * 0.5
                        } else if (t0 == COLOR) {
                            n = a1
                        } else {
                            n = a0; a0 = a1; b0 = b1; c0 = c1
                        }
                        val hsl = ColorRpn.rgbToHsl(a0, b0, c0, s.hsl)
                        when (op) {
                            ColorRpn.HUE -> hsl[0] = ColorRpn.wrap01(n)
                            ColorRpn.SAT -> hsl[1] = ColorRpn.clamp01(n)
                            else -> hsl[2] = ColorRpn.clamp01(n)
                        }
                        ColorRpn.hslToRgb(hsl[0], hsl[1], hsl[2], out)
                        push(COLOR, out[0], out[1], out[2])
                    } else {
                        push(NUM, ColorRpn.safe((a0 + a1) * 0.5))
                    }
                }
                ColorRpn.ADD, ColorRpn.SUB, ColorRpn.MUL, ColorRpn.DIV -> {
                    pop1(); pop0()
                    if (t0 == NUM && t1 == NUM) {
                        val n = when (op) {
                            ColorRpn.ADD -> a0 + a1
                            ColorRpn.SUB -> a0 - a1
                            ColorRpn.MUL -> a0 * a1
                            else -> ColorRpn.safeDiv(a0, a1)
                        }
                        push(NUM, ColorRpn.safe(n))
                    } else {
                        if (t0 == NUM) { b0 = a0; c0 = a0 }
                        if (t1 == NUM) { b1 = a1; c1 = a1 }
                        when (op) {
                            ColorRpn.ADD -> push(COLOR, ColorRpn.wrap01(a0 + a1), ColorRpn.wrap01(b0 + b1), ColorRpn.wrap01(c0 + c1))
                            ColorRpn.SUB -> push(COLOR, ColorRpn.wrap01(abs(a0 - a1)), ColorRpn.wrap01(abs(b0 - b1)), ColorRpn.wrap01(abs(c0 - c1)))
                            ColorRpn.MUL -> push(COLOR, ColorRpn.wrap01(a0 * a1), ColorRpn.wrap01(b0 * b1), ColorRpn.wrap01(c0 * c1))
                            else -> push(COLOR, ColorRpn.wrap01(ColorRpn.safeDiv(a0, a1)), ColorRpn.wrap01(ColorRpn.safeDiv(b0, b1)), ColorRpn.wrap01(ColorRpn.safeDiv(c0, c1)))
                        }
                    }
                }
                ColorRpn.EXP, ColorRpn.LN, ColorRpn.SIN, ColorRpn.COS, ColorRpn.TAN,
                ColorRpn.ASIN, ColorRpn.ACOS, ColorRpn.ATAN, ColorRpn.INVERT -> {
                    pop0()
                    if (t0 == NUM) push(NUM, ColorRpn.safe(unary(op, a0)))
                    else push(COLOR, ColorRpn.wrap01(unary(op, a0)), ColorRpn.wrap01(unary(op, b0)), ColorRpn.wrap01(unary(op, c0)))
                }
                ColorRpn.PERLIN -> {
                    pop0()
                    val seed = data[i].toInt().takeIf { it != 0 } ?: 1
                    if (t0 == NUM) push(NUM, ColorRpn.perlin(a0, seed))
                    else push(COLOR, ColorRpn.perlin(a0, seed), ColorRpn.perlin(b0, seed), ColorRpn.perlin(c0, seed))
                }
                ColorRpn.NORMALIZE -> {
                    pop0()
                    if (t0 == NUM) {
                        push(NUM, if (a0 == 0.0) 0.0 else ColorRpn.safeDiv(a0, abs(a0)))
                    } else {
                        var len = sqrt(a0 * a0 + b0 * b0 + c0 * c0)
                        if (len < 1e-6) len = 1.0
                        push(COLOR, ColorRpn.clamp01(a0 / len), ColorRpn.clamp01(b0 / len), ColorRpn.clamp01(c0 / len))
                    }
                }
                ColorRpn.IF -> {
                    pop2(); pop1(); pop0()
                    val cond = if (t2 == COLOR) ColorRpn.luma(a2, b2, c2) else a2
                    if (cond > 0.5) push(t1, a1, b1, c1) else push(t0, a0, b0, c0)
                }
                ColorRpn.BLEND -> {
                    pop2(); pop1(); pop0()
                    val nums = doubleArrayOf(0.5, 0.5, 0.5)
                    var numCount = 0
                    val col0 = doubleArrayOf(0.5, 0.5, 0.5)
                    val col1 = doubleArrayOf(0.5, 0.5, 0.5)
                    var colorCount = 0
                    fun absorb(t: Int, va: Double, vb: Double, vc: Double) {
                        if (t == COLOR) {
                            val into = if (colorCount == 0) col0 else if (colorCount == 1) col1 else null
                            if (into != null) { into[0] = va; into[1] = vb; into[2] = vc }
                            colorCount++
                        } else {
                            nums[min(numCount, 2)] = va
                            numCount++
                        }
                    }
                    absorb(t0, a0, b0, c0)
                    absorb(t1, a1, b1, c1)
                    absorb(t2, a2, b2, c2)
                    val t = ColorRpn.clamp01(
                        when (numCount) {
                            0 -> 0.5
                            1 -> nums[0]
                            2 -> (nums[0] + nums[1]) * 0.5
                            else -> (nums[0] + nums[1] + nums[2]) / 3
                        }
                    )
                    if (colorCount == 0) {
                        val g0 = ColorRpn.wrap01(nums[0])
                        val g1 = ColorRpn.wrap01(if (numCount > 1) nums[1] else nums[0])
                        col0.fill(g0); col1.fill(g1)
                    } else if (colorCount == 1) {
                        col1.fill(ColorRpn.wrap01(if (numCount > 0) nums[0] else 0.5))
                    }
                    push(
                        COLOR,
                        ColorRpn.clamp01(col0[0] + (col1[0] - col0[0]) * t),
                        ColorRpn.clamp01(col0[1] + (col1[1] - col0[1]) * t),
                        ColorRpn.clamp01(col0[2] + (col1[2] - col0[2]) * t),
                    )
                }
                ColorRpn.DOT -> {
                    pop1(); pop0()
                    if (t0 == NUM && t1 == NUM) {
                        push(NUM, ColorRpn.safe(a0 * a1))
                    } else {
                        if (t0 == NUM) { b0 = a0; c0 = a0 }
                        if (t1 == NUM) { b1 = a1; c1 = a1 }
                        push(NUM, ColorRpn.safe(a0 * a1 + b0 * b1 + c0 * c1))
                    }
                }
                ColorRpn.FBM -> {
                    pop1(default = 2.0); pop0()
                    val seed = data[i].toInt().takeIf { it != 0 } ?: 1
                    if (t0 == NUM && t1 == NUM) {
                        push(NUM, ColorRpn.fbm(a0, a1, seed))
                    } else {
                        if (t0 == NUM) { b0 = a0; c0 = a0 }
                        if (t1 == NUM) { b1 = a1; c1 = a1 }
                        push(COLOR, ColorRpn.fbm(a0, a1, seed), ColorRpn.fbm(b0, b1, seed), ColorRpn.fbm(c0, c1, seed))
                    }
                }
            }
        }
        return finish(s, sp, out)
    }

What is left on the stack, as a colour: the recovery rules in ColorRpn.

    private fun finish(s: Stack, sp: Int, out: DoubleArray): DoubleArray {
        if (sp <= 0) {
            out.fill(0.5)
            return out
        }
        if (s.type[sp - 1] == COLOR) {
            out[0] = ColorRpn.clamp01(s.a[sp - 1]); out[1] = ColorRpn.clamp01(s.b[sp - 1]); out[2] = ColorRpn.clamp01(s.c[sp - 1])
            return out
        }
        var firstNum: Double? = null
        var secondNum: Double? = null
        var firstColor = -1
        var secondColor = -1
        for (i in sp - 1 downTo 0) {
            if (s.type[i] == COLOR) {
                if (firstColor < 0) firstColor = i else if (secondColor < 0) secondColor = i
            } else {
                if (firstNum == null) firstNum = s.a[i] else if (secondNum == null) secondNum = s.a[i]
            }
            if (secondColor >= 0 && secondNum != null) break
        }
        if (firstColor >= 0 && secondColor >= 0) {
            out[0] = ColorRpn.clamp01((s.a[firstColor] + s.a[secondColor]) * 0.5)
            out[1] = ColorRpn.clamp01((s.b[firstColor] + s.b[secondColor]) * 0.5)
            out[2] = ColorRpn.clamp01((s.c[firstColor] + s.c[secondColor]) * 0.5)
            return out
        }
        if (firstColor >= 0 && firstNum != null) {
            val g = ColorRpn.wrap01(firstNum)
            out[0] = ColorRpn.clamp01(s.a[firstColor] * 0.7 + g * 0.3)
            out[1] = ColorRpn.clamp01(s.b[firstColor] * 0.7 + g * 0.3)
            out[2] = ColorRpn.clamp01(s.c[firstColor] * 0.7 + g * 0.3)
            return out
        }
        if (firstNum != null && secondNum != null) return numberPair(firstNum, secondNum, out)
        out.fill(ColorRpn.wrap01(firstNum ?: 0.5))
        return out
    }

Two numbers as a colour: their mean picks between orange and blue, their difference lifts it.

    private fun numberPair(a: Double, b: Double, out: DoubleArray): DoubleArray {
        val mix = ColorRpn.wrap01((a + b) * 0.5)
        val lift = 0.32 + ColorRpn.wrap01(abs(a - b)) * 0.26
        out[0] = ColorRpn.clamp01(lift * 0.28 + (0.94 + (0.16 - 0.94) * mix) * 0.72)
        out[1] = ColorRpn.clamp01(lift * 0.28 + (0.53 + (0.42 - 0.53) * mix) * 0.72)
        out[2] = ColorRpn.clamp01(lift * 0.28 + (0.14 + (0.94 - 0.14) * mix) * 0.72)
        return out
    }

    private companion object {
        const val NUM = 0
        const val COLOR = 1
    }
}