Author Archives: cormullion

Automata

By: cormullion

Re-posted from: https://cormullion.github.io/blog/2018/11/29/automata.html

To play along with this post, you’ll be able to find the source on github, and you’ll need Julia (version 1), and the packages Luxor, Colors, and ColorSchemes. This post was written using Fredrik Ekre’s Literate.jl.

I don’t need to give you any references—you’ll be spoilt for choice if you start googling.

Here in the UK we’re usually better at closing railway stations than opening new ones. Dr Beeching famously closed thousands of them (more than 50% of the total) in the 1960s. Occasionally, though, new stations are built, and one recent addition to the rail network is the station at Cambridge North, built mainly to serve the Cambridge Science Park. (I used to walk along the railway tracks there at weekends before the old disused line was re-opened, but that’s another story…)

When the station was opened, in 2017, there was a bit of chatter about the decorative panels used for the building.

image label

(Image by cmglee at wikipedia, licensed CC-SA)

Thsee patterns were described by the designers at Atkins (the contractors) like this:

The station is wrapped in three equal bands of aluminium panels which have been perforated with a design derived from John Horton Conway’s “Game of Life” theories which he established while at Gonville and Caius College, Cambridge in 1970. These beautiful, delicate panels ensure passive security to ground floor glazed areas, assist with wayfinding while crossing the footbridge and allow the building to transform its appearance between day and night through sensitive backlighting.

This quote is from the original article (archived here), but it’s since been corrected, most probably in response to the anguished cries of thousands of Cambridge nerds who swiftly pointed out that, in fact, these patterns weren’t Life as we know it, but were actually one-dimensional cellular automata, and so linked, not so much with Cambridge’s John Horton Conway, as with noted Oxford/CalTech alumnus Stephen “Mr Mathematica” Wolfram.

I think the designers did a great job using these simple graphics to “create a harmonic relationship with the scientific research and industry of the Cambridge Colleges and nearby Science Park”.

So what is a one-dimensional cellular automaton?

A cellular automaton is a mathematical model that creates patterns automatically according to simple rules. In its simplest, one-dimensional form, it’s a row of empty squares, each of which can be occupied or empty. The rules for the model determine how sensitive the occupants of each square are to their immediate left and right neighbours, and whether, after some unspecified time, they survive or die. Sometimes empty squares can miraculously become occupied. Because a square has just two immediate neighbours, there are 8 different cases to consider, ranging from all empty (“□□□” or 000 in binary) to all full (“■■■” or 111).

The rule determines how one generation changes to the next by specifying the outcome for each of the 8 cases. So, for example, an empty square surrounded by empty neighbours can continue to be empty (false, or 0), or it can produce a new occupant in the next generation, (true, or 1). There are 256 different combinations, since each of the 8 cases can be either 0 or 1.

The wikipedia has this nice animation showing how the rule produces the next generation of squares.

image label

(Well, it has this nice animation now…)

First steps

To explore these simple automata, I started (1) by making a Julia structure:

mutable struct CA
    rule::Int64
    cells::BitArray{1}
    colorstops::Array{Float64, 1}
    ruleset::BitArray{1}
    generation::Int64
    function CA(rule, ncells = 100)
        cells                    = falses(ncells)
        colorstops               = zeros(Float64, ncells)
        ruleset                  = binary_to_array(rule)
        cells[length(cells) ÷ 2] = true
        generation               = 1
        new(rule, cells, colorstops, ruleset, generation)
    end
end

The cells array can hold trues or falses. The colorstops array is eventually going to hold some color information. The middle cell is seeded with a single starter value.

The binary_to_array() function just converts a binary number to a bit array (I suspect there’s a quicker way).

function binary_to_array(n)
    a = BitArray{1}()
    for c in 7:-1:0
        k = n >> c
        push!(a, (k & 1 == 1 ? true : false))
    end
    return a
end

The rules() function takes the values of an individual and its neighbours and applies the rule that determines its state for the next generation:

function rules(ca::CA, a, b, c)
    lng = length(ca.ruleset)
    return ca.ruleset[mod1(lng - (4a + 2b + c), lng)]
end

And a nextgeneration() function applies the rule to all the cells. I decided to make it wrap around, so that the final cell considers the first cell as one of its neighbours.

function nextgeneration(ca::CA)
    l = length(ca.cells)
    nextgen = falses(l)
    for i in 1:l
        left   = ca.cells[mod1(i - 1, l)]
        me     = ca.cells[mod1(i, l)]
        right  = ca.cells[mod1(i + 1, l)]
        nextgen[i] = rules(ca, left, me, right)
    end
    ca.cells = nextgen
    ca.generation += 1
    return ca
end

We’ll also teach Julia how to show an automaton in the terminal:

Base.show(io::IO, ::MIME"text/plain", ca::CA) =
    print(io, join([c ? "■" : " " for c in ca.cells]))

So now we can create a cellular automaton by providing a rule number (using the default of 100 cells):

ca = CA(30)

update the automaton like this:

nextgeneration(ca)

and show a historical diagram of its evolution:

for i in 1:30
    display(nextgeneration(ca))
end

image label

Some graphics

The REPL display is more or less functional, but I want to play with the graphic output, so (you guessed):

using Luxor, Colors

function draw(ca::CA, cellwidth=10)
    lng = length(ca.cells)
    for i in 1:lng
        if ca.cells[i] == true
            pt = Point(-(lng ÷ 2) * cellwidth + i * cellwidth, 0)
            box(pt, cellwidth, cellwidth, :fill)
        end
    end
end

@png begin
    ca = CA(30, 200)
    sidelength = 4
    # start at the top
    translate(boxtopcenter(BoundingBox()) + sidelength)
    for i in 1:200
        draw(ca, sidelength)
        nextgeneration(ca)
        translate(Point(0, sidelength))
    end
end 800 850 "images/automata/simple-ca.png"

image label

You can call nextgeneration() without displaying the results, of course. This lets you jump into the future history of an automaton at warp speed.

@png begin
    ca = CA(110, 200)
    translate(boxtopcenter(BoundingBox()) + sidelength)
    sidelength = 4
    # into the future
    for _ in 1:200_000
        nextgeneration(ca)
    end
    for _ in 1:195
        draw(ca, sidelength)
        nextgeneration(ca)
        translate(Point(0, sidelength))
    end
end 800 800 "images/automata/simple-ca-future.png"

image label

I found that sometimes drawing them from left to right looked better (like videos recorded on phones…?):

@png begin
    ca = CA(30)
    translate(boxmiddleleft(BoundingBox()) + sidelength)
    rotate(-π/2)
    sidelength = 3.5
    for i in 1:320
        draw(ca, sidelength)
        nextgeneration(ca)
        translate(Point(0, sidelength))
    end
end 1000 400 "images/automata/simple-landscape-ca.png"

image label

And now in color

So far I haven’t used the color information that’s stored.

The nextgeneration() function can be updated with instructions about how to modify the color of the next generation, based on the current set of three cells.

function nextgeneration(ca::CA)
    l = length(ca.cells)
    nextgen = falses(l)
    for i in 1:l
        left   = ca.cells[mod1(i - 1, l)]
        me     = ca.cells[mod1(i, l)]
        right  = ca.cells[mod1(i + 1, l)]
        nextgen[i] = rules(ca, left, me, right)
        if me == 1
            ca.colorstops[i] = mod(ca.colorstops[i] + 0.1, 1)
        elseif left == 1 && right == 1
            ca.colorstops[i] = mod(ca.colorstops[i] - 0.1, 1)
        else
            ca.colorstops[i] = 0
        end
    end
    ca.cells = nextgen
    ca.generation += 1
    return ca
end

and the draw() function can be adapted to make use of the color information. I decided to avoid tackling RGB color value transformations for a first pass, so the single value between 0 and 1 is used to select a color from a color map.

using ColorSchemes

function drawcolor(ca::CA, cellwidth=10;
        scheme=ColorSchemes.leonardo)
    lng = length(ca.cells)
    for i in 1:lng
        if ca.cells[i] == true
            sethue(get(scheme, ca.colorstops[i]))
            pt = Point(-(lng ÷ 2) * cellwidth + (i * cellwidth), 0)
            box(pt, cellwidth, cellwidth, :fill)
        end
    end
end

@svg begin
    background("darkorchid4")
    ca = CA(135, 65)
    # randomize start state
    ca.cells = rand(Bool, length(ca.cells))
    sidelength = 5
    translate(boxmiddleleft(BoundingBox()) + sidelength)
    rotate(-π/2)
    for i in 1:195
        drawcolor(ca, sidelength, scheme=ColorSchemes.cubehelix)
        nextgeneration(ca)
        translate(Point(0, sidelength))
    end
end 1000 400 "images/automata/simple-color-ca.svg"

image label

This could lead to hours of entertainment (depending on your definition of fun, of course). I uploaded a few experiments that didn’t turn out too badly on Flickr. The current rules show a kind of literal winning streak, as a cell that remains occupied for many generations ends up being brightly illuminated.

I think these images look quite good when scaled up. It only takes about a second to draw these, but would take much longer to stick them on the wall:

image label

image label

The rules for specifying a change in color could do with some kind of systematic definition, perhaps, such that, say, “rule C81” means “increase colorstop by amount if previous parent is 1, decrease it if previous uncle-aunt is 1”, and so on. Then you could pass a set of color rules to the drawing function. (uncle-aunt? I couldn’t find a word for something that is either an uncle or an aunt, but not a parent…)

Instead of drawing simple squares, it’s possible to draw other shapes. I’m quite fond of the squircle — you can change the rt parameter to get different shapes:

function drawcolorsquircle(ca::CA, cellwidth=10;
        scheme=ColorSchemes.leonardo)
    lng = length(ca.cells)
    for i in 1:lng
        if ca.cells[i] == true
            sethue(get(scheme, ca.colorstops[i]))
            pt = Point(-(lng ÷ 2) * cellwidth + (i * cellwidth), 0)
            squircle(pt, cellwidth, cellwidth, rt=6.0, :fill)
        end
    end
end

@svg begin
    background("navyblue")
    ca = CA(110, 30)
    # randomize start state
    ca.cells = rand(Bool, length(ca.cells))
    translate(boxmiddleleft(BoundingBox()) + sidelength)
    rotate(-π/2)
    sidelength = 10
    for i in 1:100
        drawcolorsquircle(ca, sidelength, scheme=ColorSchemes.Dark2_8)
        nextgeneration(ca)
        translate(Point(0, sidelength))
    end
end 1000 400 "images/automata/simple-color-ca-squircle.svg"

image label

(This SVG is quite big, and won’t display in Juno. But it should load in a browser.)

Getting around to it

It occurred to me that you could take a rectangular array and wrap it into a circle.

function drawsector(ca::CA, cellwidth=10;
        scheme=ColorSchemes.leonardo,
        centralradius = 10)
    lng = length(ca.cells)
    width = lng * cellwidth
    angulargap = 2π/lng
    for i in 1:lng
        sethue(get(scheme, ca.colorstops[i]))
        innerradius = centralradius
        outerradius = centralradius + cellwidth
        startang    = rescale(i, 1, lng, 0, 2π)
        endang      = startang + angulargap
        if ca.cells[i] == true
            sector(O, innerradius, outerradius, startang, endang, :fillstroke)
        end
    end
end

function drawrule(rulenumber, pos)
    @layer begin
        translate(pos)
        sethue("black")
        text(string(rulenumber), halign=:center, valign=:middle)
        rotate(-π/2)
        ca = CA(rulenumber, 50)
        # randomize start state
        # ca.cells = rand(Bool, length(ca.cells))
        sidelength = 2
        setline(0.0)
        for n in 5:sidelength:30
            drawsector(ca, sidelength,
            scheme=ColorSchemes.klimt,
            centralradius = n)
            nextgeneration(ca)
        end
    end
end

This shows all the rules in this circular form.

let
    # best in SVG or PDF, but PNG is faster
    Drawing(1200, 1200, "images/automata/color-sector-assembly.svg")
    origin()
    background("azure")
    fontsize(8)
    for (pos, n) in Tiler(1200, 1200, 16, 16)
        drawrule(n-1, pos)
    end
    finish()
    preview()
end

image label

(Again, this image is quite demanding for SVG and can take a while to load, even though it doesn’t take long to generate.)

After playing with this idea, I thought it would make some nice jewellery:

image label

This is rule 150. I mapped the array to a semicircle and drew it twice.

And it moves

Many of the rules have limited career paths. Some fizzle out very quickly, others settle down into a stable if repetitive life style. There are a few that continue to make patterns as the number of generations heads off into the thousands.

The unpredictable high achiever of the 1D cellular automata world is Rule 110. The rule itself is so simple, it could have been described in a Shakespeare play:

[Enter HAMLET, stage left.]

For Zeros become Ones at all positions,

Where the value to the right is One.

Yet Ones are changed to Zeros where’er

The values to left and right are One.

And yet this simple rule has been the subject of an astonishing amount of analysis, such as this in-depth paper by Martinez et al published in the wonderfully-named International Journal of Unconventional Computing. Matthew Cook’s famous paper proved that Rule 110 is capable of emulating the activity of a Turing machine.

I understood very little of those wonderful papers, but they did make me want to at least see Rule 110 in action, just in case I could spot all those cyclic tags, meta-gliders, and pseudo-solitons.

function frame(scene, framenumber, ca, cahistory, sidelength;
            smoothscrolling=10)
    background(colorant"navy")
    fontsize(12)
    sethue(colorant"azure")
    text(string(ca.generation), boxtopright(BoundingBox()) + (-30, 20),
        halign=:right)
    setline(0.1)

    # get in position for the first row
    translate(boxmiddleleft(BoundingBox()))
    rotate(-π/2)

    # for smooth scrolling
    translate(0, -(mod1(framenumber, smoothscrolling))
        * sidelength/smoothscrolling)

    lng = length(ca.cells)
    for gen in cahistory
        for (n, cell) in enumerate(gen)
            if cell == true
                pt = Point(-(lng / 2) * sidelength + (n * sidelength), 0)
                box(pt, sidelength - 2, sidelength - 2, :fill)
            end
        end
        translate(Point(0, sidelength))
    end
    # "beautiful buttery scrolling"
    if framenumber % smoothscrolling == 0
        # drop oldest, add a new generation
        popfirst!(cahistory)
        nextgeneration(ca)
        push!(cahistory, ca.cells)
    end
end

function makeanimation(rule, filename)
    width, height = (1920, 1080)
    sidelength = 6
    cellularmovie = Movie(width, height, "cellularmovie")
    ca = CA(rule, convert(Int, height÷sidelength))
    cahistory = []
    # initial
    for _ in 1:width÷sidelength
        push!(cahistory, ca.cells)
        nextgeneration(ca)
    end
    animate(cellularmovie,
        [Scene(cellularmovie, (s, f) ->
            frame(s, f, ca, cahistory, sidelength, smoothscrolling=4),
                1:500)],
    pathname="$(filename)")
end

makeanimation(110, "images/automata/animated-cellular-automaton.gif")

image label

If each frame moved the history ‘window’ by one generation, you’d get a jerky animation. The smooth-scrolling used here shifts the contents by a few pixels in each frame but changes the contents less often. This requires more frames than generations, so 300 frames with a smoothscrolling value of 10 shows only 30 new generations after the initial bunch.

This GIF does have a few problems; the conflicting demands of file size, image size, image quality, and scroll speed work against each other. There’s a slight flickering, due I think to the rounding or aliasing in the GIF. You could make the GIF as long as you want, subject of course to the maximum size of animated GIF that you’re prepared to handle. (And yes, this page does take a long time to load. Sorry.)

Videos also have their problems with this kind of content, because the downsampling commonly applied can affect the detail. To see what I mean, try watching Rule 110: The Movie (on YouTube). (This is my submission for YouTube’s Most Boring Video 2018 competition.)

Solid work

If you want to do more than just look at pictures on the screen—perhaps you’re building your own railway station?—here are some tips for exporting the graphics to your favourite 3D modeller.

First, place a box or shape around the outside of the design, and use the :path action rather than :fill or :stroke. Then, to draw what are now the ‘holes’, use newsubpath() before drawing each hole, and make sure to draw the holes with reversed paths (using the reversepath keyword, for example).

function drawaspath(ca::CA;
        cellwidth=10)
    lng = length(ca.cells)
    for i in 1:lng
        if ca.cells[i] == true
            newsubpath()
            pt = Point(-(lng ÷ 2) * cellwidth + (i * cellwidth), 0)
            poly(box(pt, cellwidth - 2, cellwidth - 2, vertices=true),
                :path, reversepath=true, close=true)
        end
    end
end

@svg begin
    background("black")
    ca = CA(110, 40)
    ca.cells = rand(Bool, length(ca.cells))
    cellwidth = 7
    bxw = boxwidth(BoundingBox() * 0.98)/2
    squircle(O, bxw, bxw, rt=0.1, :path)
    translate(boxtopcenter(BoundingBox()) + (-5, 7))
    for i in 1:40
        translate(0, cellwidth)
        drawaspath(ca, cellwidth=cellwidth)
        nextgeneration(ca)
    end
    sethue("grey80")
    fillpath()
end 300 300 "images/automata/ca-as-path.svg"

image label

So this is a single path, and the holes are reversed subpaths. When you import this single path into a 3D modelling program, you should be able to extrude it into a solid without problems. Here’s one modelled as a thin gold reflective sheet.

image label

It’s hard to resist playing with 3D modellers… I added some fog and a rusty metal effect:

image label

If you omit the surrounding path, it might still work visually. It would be a challenge to hang all these pieces on a gallery wall:

image label

These little critters are quite interesting, and there are a fair number of scientific papers with “cellular automata” in the title . I think this must be because, although things don’t get much simpler than a cellular automaton, perhaps they might—in some strange way—be similar to how the universe itself works when you zoom in close enough.

[2018-11-29]

cormullion signing off

This page was generated using Literate.jl.

  1. At the time of writing this I couldn’t find any working Julia packages that did one-dimensional cellular automata. But if I had found one, I probably wouldn’t have written any of the above anyway. 

Random noise

By: cormullion

Re-posted from: https://cormullion.github.io/blog/2018/10/16/noise.html

This is another post in the ongoing series in which I try to learn 2D vector graphics using Julia. It doesn’t contain any revelations or new material, and you should visit the following sites if you’re looking for a good introduction to the subject of noise in graphics:

I’m using Julia version 1.0 if you want to play along; you can find the source files and notebooks on the github. If you do, you’ll need the packages Luxor, Colors, and ColorSchemes. I used Literate.jl to produce the Markdown and Jupyter notebook versions.

Random versus Noise

Luxor provides a function called noise(). This can accept a single floating-point number as input, and it returns a value between 0.0 and 1.0.

using Luxor

noise(0.0)
0.8261106995884773

noise(1.0)
0.5

noise(2.0)
0.4460609053497945

It will be easier to draw some graphs. Here’s a quick throwaway function to draw a simple graph.

function graph(a, width = 800;
        startnumber     = 0,
        endnumber       = 1,
        style           = :line,
        margin          = 30)
    setline(1)
    bars(a, labels      =false,
            xwidth      = (width - 2margin)/length(a),
            yheight     =40,
            barfunction = (bottom::Point, top::Point, value;
        extremes=extrema(a), barnumber=0, bartotal=0) ->
            begin
                if style == :line
                    line(bottom, top, :stroke)
                else
                    circle(top, 1, :fill)
                end
            end)
    sethue("black")
    label(string(startnumber), :S,
        Point(0, 0), offset=10)
    label(string(endnumber), :S,
        Point(width - 2margin, 0), offset=10)
end

function drawgraph(startvalue, endvalue, filename)
    Drawing(800, 150, filename)
    background("white")
    origin()
    # move to top left corner
    margin=30
    translate(BoundingBox()[1] + (margin, boxheight(BoundingBox()/2)))
    sethue("black")
    graph(noise.(range(startvalue, endvalue, length=200)),
        startnumber=startvalue,
        endnumber=endvalue)
    finish(); preview()
end

To test this out, graph 200 random integers:

Drawing(800, 150, "images/noise/graph-random.png")
background("white")
origin() ## move to top left corner
margin=30
translate(BoundingBox()[1] + (margin, boxheight(BoundingBox()/2)))
sethue("black")
graph(rand(200))
finish(); preview()

image label

To start with, let’s graph the output of the noise() function for the first 200 integers:

drawgraph(0, 200, "images/noise/graph-0-200.png")

image label

It looks very random. But let’s look at 200 values between 0 and 10:

drawgraph(0, 10, "images/noise/graph-0-10.png")

image label

There’s some randomness, but it’s smoother, and looks more natural.

Zoom and enhance, between 0 and 5:

drawgraph(0, 5, "images/noise/graph-5.png")

image label

You can see that the left half of the 0 to 10 graph has been stretched.

Between 0 and 1:

drawgraph(0, 1, "images/noise/graph-0-1.png")

image label

One more for luck:

drawgraph(0, 0.5, "images/noise/graph-0-05.png")

image label

The more often you sample the noise space (ie the shorter the gaps between the set of values passed to noise(), the closer together the output values will be.

So the noise() function provides gently changing undulations rather than the unpredictable jumps of randomness. Here’s a slowly changing color pattern in the LCHab color space, using a set of noisy values to choose the hue. We’ll change the length of each line as well just for fun:

using Colors
@svg begin  ## switch to SVG for better graphic quality
    rate = .009
    setline(2)
    for x in -250:2:250
        yn = noise(x * rate)
        hue = rescale(yn, 0, 1, 0, 359)
        sethue(LCHab(50, 100, hue))
        ht = rescale(yn, 0, 1, 10, 100)
        line(Point(x, -ht/2), Point(x, ht/2), :stroke)
    end
end 600 200 "images/noise/colorbars.svg"

image label

You can use noisy values to specify other changing parameters. For example, let’s place some pebbles at random, and control their size using a noisy distribution, to give the illusion of a naturally changing distribution.

function drawpebble(pt, radius)
    sethue("grey60")
    @layer begin
        transform([rand(0.5:0.1:1) 0 0 rand(0.5:0.1:1) 0 0])
        circle(pt, radius, :fill)
        for i in 1:-0.02:0.2
            sethue(rescale(i, 1, 0,   0.5 + rand(0:0.1:0.3), 1.0),
                   rescale(i, 1, 0.1, 0.4 + rand(0:0.1:0.5), 1.0),
                   rescale(i, 1, 0.3, 0.5, 1.0))
            setopacity(1 - i)
            circle(pt + (-2i, -2i), i * radius, :fill)
        end
    end
end


@png begin
    # switch to PNG, SVG can't handle this
    background("palegoldenrod")
    pebblesize = 12
    for i in 1:6000
        pt = Point(rand(-400:400), rand(-200:200))
        n = noise(pt.x * 0.002)
        drawpebble(pt, pebblesize * n)
    end
end 800 400 "images/noise/pebbles.png"

image label

Detail and persistence

The noise() function has two optional keyword arguments that let you tweak the knobs of the noise generator.

The first is detail, an integer. Increasing it from the default value of 1 upwards will add finer detail to the basic noise. The second is persistence, a floating-point value between 0 and 1 (or more).

‘detail` is graphed here with values from 1 to 12. As the level increases, you can see that the same overall noise contours are gradually modulated with finer variations.

function detailgraph()
    @svg begin
        margin=30
        translate(BoundingBox()[1] + (margin, 0))
        setline(.5)
        sethue("black")
        stopat = 2
        r = range(0, length=400, stop=stopat)
        for detail in 1:2:12
            translate(0, 100)
            sethue("red")
            graph(noise.(r), style=:circle, endnumber=stopat)
            sethue("black")
            text("detail = $detail, persistence = 0.9", Point(200, 15))
            graph(noise.(r, detail=detail, persistence=0.9), endnumber=stopat)
        end
    end 800 650 "images/noise/detail-graph.svg"
 end
detailgraph()

image label

You can see the original noisy curve (in red) behind each more detailed graph. The noise generator is doubling the frequency but halving the amplitude every time you go one level higher. Noise, like music, can have octaves of higher frequencies mixed with lower fundamental frequency. The detail keyword is adding one or more octaves of noise.

The persistence argument defaults to zero. The value controls the amplitude of each successive octave of noise, with higher values of persistence producing higher levels of finer detail, as the values persist for longer.

function persistencegraph()
    @svg begin
        setline(.5)
        sethue("black")
        margin=30
        translate(BoundingBox()[1] + (margin, 0))
        stopat = 10
        r = range(0, length=400, stop=stopat)
        for p in 0:0.25:2
            translate(0, 70)
            sethue("red")
            graph(noise.(r, detail=4, persistence=0),
                endnumber=stopat, style=:circle)
            sethue("black")
            text("detail = 4, persistence = $p", Point(200, 15))
            graph(noise.(r, detail=4, persistence=p),
                endnumber=stopat)
        end
    end 800 675 "images/noise/persistence-graph.svg"
end

persistencegraph()

image label

Here, the detail is kept at 4, and the persistence varies from 0 upwards. As the persistence increases, the effects accumulate, until the original curve is barely visible.

There are many uses for noisy input, such as generating varying shapes that don’t have that undesirable ‘too random’ quality.

using ColorSchemes

function treerings()
    @svg begin
        Luxor.initnoise()

        nrate = 0.01
        npoints= 500
        nrings = 400
        rad = 20
        setline(0.5)
        for ring in nrings:-5:1
            pts = Point[]
            for i in 1:npoints
                push!(pts, polar(rad + (ring * noise(i * nrate)),
                    rescale(i, 1, npoints, 0, 2pi)))
            end
            sethue(get(ColorSchemes.sienna, noise(ring * rate)))
            poly(pts, :fill, close=false)
            sethue("black")
            poly(pts, :stroke, close=false)
        end
    end 800 800 "images/noise/treerings.svg"
end

treerings()

image label

Here’s a more questionable idea, using noise to control the setting of a line of text.

function drawtextline(t, point, fsize; rate=0.1)
    for (n, c) in enumerate(split(t, ""))
        f = fsize * noise(n * rate, persistence=0, detail=4)
        fontsize(f)
        te = textextents(c)
        text(c, point)
        point = Point(point.x + te[5] * 0.98, 0) ## tightness is tight
        move(point)
    end
end

@png begin
    fontface("Bodoni")
    drawtextline("variablefontsizetextsettingiscool,orisit?",
        O - (380, 0), 50, rate=.11)
end 800 120 "images/noise/text-setting.png"

image label

I then used the readpng() and placeimage() functions to add a background image (the original Tenniel illustration), with the following result:

image label

Why noise?

The first use of computer graphics in movies is generally considered to be Tron (1981).

Tron lies at the very beginning of the history of CGI in the movies, and the technology available to the artists, mathematicians, and programmers making Tron was amazingly underpowered compared with the computing power that we have today on our wrists, let alone on our phones.

Ken Perlin was a mathematician and programmer who worked on Tron, and (I think after the film was released) he realised that there was room for using mathematical techniques for realistic-looking surfaces and textures, such as terrain.

image label

Ken’s noise, or Perlin Noise as it became known, was quickly adodpted as the best way to generate naturalistic surfaces.

I think the reason why natural scenes appear to us as variable but not completely random is due to the (possibly hidden) larger scale processes that make smaller and more visible details clump together, and appear to work together and change gradually. For example, clouds, mountains, and pebble beaches have large scale structure controlled by unseen forces like heat, pressure, and gravity. We mostly see the objects that are subject to these forces, rather than the forces themselves.

Moving into 2D

So far the noise we’ve been producing has been one-dimensional, although we’ve been using 2D graphics to draw it.

The noise() function can accept two floating-point numbers as input. These effectively define a rectangular grid of varying noise values: the x and y inputs produce a third value which requires representation.

A simple way of doing this is to draw a table and vary the color of each square, giving a type of heat map.

using ColorSchemes

@svg begin
    nrows = 40
    ncols = 40
    cellwidth = 15
    cellheight = 15
    table = Table(nrows, ncols, cellwidth, cellheight)
    rate = 0.1
    fontsize(5)
    for row in 1:nrows
        for col in 1:ncols
            zvalue = noise(row * rate, col * rate)
            sethue(get(ColorSchemes.temperaturemap, zvalue))
            box(table[row, col], table.colwidths[1], table.rowheights[1], :fill)
            sethue("black")
            text(string(round(zvalue, digits=1)), table[row, col], halign=:center, valign=:middle)
        end
    end
end 800 700 "images/noise/table.svg"

image label

Alternatively, we can create a 3D surface and use the noise values for the height at each point. Normally this would require a visit to Julia’s colorful and generally awesome Swedish-nightclub-themed Package manager, Pkg:

image label

to download some of the cool plotting packages available, not least Simon Danisch’s impressive Makie.jl.

But, just to be contrary, I decided to whip up a simple isometric projection:

function project(x, y, z;
        scalingfactor = 3, heightmultiplier = -1)
    # negative because y is positive downwards!
    u = (x - y)/sqrt(2)
    v = (x + 2(heightmultiplier * z) + y)/sqrt(6)
    return Point(scalingfactor * u, scalingfactor * v)
end

project(t; kwargs...) = project(t[1], t[2], t[3]; kwargs...)

function generatenoisearray(sx=100, sy=100;
    rate=0.5,
    detail=1,
    persistence=0)
    a = Array{Float64}(undef, sx, sy)
    for x in 1:sx
        for y in 1:sy
            a[x, y] = noise(x * rate, y * rate,
                detail=detail, persistence=persistence)
        end
    end
    return a
end

function isograph(a)
    @svg begin
        background("grey30")
        translate(0, -300)
        setline(0.5)
        sx, sy = size(a)
        scalingfactor = 5
        heightmultiplier = -6
        for x in 1:sx-1
            newpath()
            move(project(x, sy, -10,
                    scalingfactor = scalingfactor,
                    heightmultiplier = heightmultiplier))
            for y in sy-1:-1:1
                toppolygon = project.([
                    (x,     y,     a[x, y]),
                    (x + 1, y,     a[x + 1, y]),
                    (x + 1, y + 1, a[x + 1, y + 1]),
                    (x,     y + 1, a[x, y + 1])],
                        scalingfactor = scalingfactor,
                        heightmultiplier = heightmultiplier)
                centroid = polycentroid(toppolygon)
                line(centroid)
            end
            line(project(x, 1, -10,
                scalingfactor = scalingfactor,
                heightmultiplier = heightmultiplier))
            sethue("grey20")
            fillpreserve()
            sethue("grey85")
            strokepath()
        end
    end 800 700 "images/noise/isograph.svg"
end

isograph(generatenoisearray(80, 80, rate=0.08))

image label

(This image reminds me of the famous Joy Division LP cover and T-shirt image, which features plots of the first ever pulsar discovered by Jocelyn Bell Burnell and Antony Hewish in 1967. This then became the basis of many entertaining blog posts, such as this one.)

image label

A more conventional surface rendering is also possible:

function isograph(a)
    @png begin
        background("grey20")
        translate(0, -200)
        setline(0.5)
        sx, sy = size(a)
        for x in 1:sx-1
            for y in sy-1:-1:1
                toppolygon = project.([
                    (x,     y,     a[x, y]),
                    (x + 1, y,     a[x + 1, y]),
                    (x + 1, y + 1, a[x + 1, y + 1]),
                    (x,     y + 1, a[x, y + 1])],
                        heightmultiplier=-10,
                        scalingfactor=5)
                sethue("black")
                poly(toppolygon, close=true, :stroke)
                sethue(get(ColorSchemes.inferno, a[x, y]))
                poly(toppolygon, close=true, :fill)
            end
        end
    end 800 500 "images/noise/isosurface-2.png"
end

isograph(generatenoisearray(100, 100, rate=0.08))

image label

What, more dimensions?

So far we’ve been generating 2D noise. The noise() function can also accept three floating-point numbers as input. This produces noise values in 3D space, where each 3D point can have a noise value between 0 and 1. Rendering these point clouds is definitely a job for something other than a simple 2D graphics system. But, while we’re here, let’s have a go:

function buildarray(a::AbstractArray; rate=20)
    sx, sy, sz = size(a)
    for x in 1:sx
        for y in 1:sy
            for z in 1:sz
                a[x, y, z] = noise(x * rate, y * rate, z * rate)
            end
        end
    end
    return a
end

function iso3d(a)
    background("grey20")
    sethue("gray80")
    setline(0.15)
    rule.([Point(0, y) for y in -400:10:400])
    sx, sy, sz = size(a)
    for x in 1:sx
        for y in 1:sy
            for z in 1:sz
                noisevalue = a[x, y, z]
                sethue(get(ColorSchemes.plasma, noisevalue))
                pt = project(x, y, z, scalingfactor=8)
                setopacity(noisevalue)
                circle(pt, rescale(noisevalue, 0, 1, 0.05, 6), :fill)
            end
        end
    end
end

const A = Array{Float64, 3}(undef, 50, 50, 50)

@png begin
    iso3d(buildarray(A, rate=0.05))
end 800 800 "images/noise/isosolid.png"

image label

The noise values nearer 1 look like hot plasma, whereas values nearer 0 are almost translucent. It suggests what you might expect to see from a real volume visualization tool.

Journey to Algorithmia

The final images in this post combine 2D noise and 1D noise; 2D noise for the sky, and 1D noise to create the contours.

There’s a seednoise() function. This takes an array of 512 integers between 1 and 12, and is broadly the equivalent of the Random.seed!() function in Julia. This is useful when you want the noise to vary from image to image.

function layer(leftminheight, rightminheight, noiserate;
        detail=1, persistence=0)
    c1, c2, c3, c4 = box(BoundingBox(), vertices=true)
    ip1 = between(c4, c1, leftminheight)
    ip2 = between(c3, c2, rightminheight)
    topedge = Point[]
    seednoise(rand(1:12, 512))
    for x in ip1.x:2:ip2.x
        ypos = between(ip1, ip2, rescale(x, ip1.x, ip2.x, 0, 1)).y
        ypos *= noise(x/noiserate,
            detail=detail, persistence=persistence)
        push!(topedge, Point(x, ypos))
    end
    p = [c4, topedge..., c3]
    poly(p, :fill, close=true)
end

function clouds()
    tiles = Tiler(boxwidth(BoundingBox()),
                  boxheight(BoundingBox()),
                  800, 800, margin=0)
    @layer begin
        transform([3 0 0 1 0 0])
        setopacity(0.3)
        noiserate = 0.01
        for (pos, n) in tiles
            nv = noise(pos.x * noiserate,
                       pos.y * noiserate,
                    detail=4, persistence=.4)
            setgray(nv)
            box(pos, tiles.tilewidth, tiles.tileheight, :fill)
        end
    end
end

function colorblend(fromcolor, tocolor, n=0.5)
    f = clamp(n, 0, 1)
    nc1 = convert(RGBA, fromcolor)
    nc2 = convert(RGBA, tocolor)
    from📕, from📗, from📘, from💡 =
        convert.(Float64, (nc1.r, nc1.g, nc1.b, nc1.alpha))
    to📕, to📗, to📘, to💡 =
        convert.(Float64, (nc2.r, nc2.g, nc2.b, nc1.alpha))
    new📕 = (f * (to📕 - from📕)) + from📕
    new📗 = (f * (to📗 - from📗)) + from📗
    new📘 = (f * (to📘 - from📘)) + from📘
    new💡 = (f * (to💡 - from💡)) + from💡
    return RGBA(new📕, new📗, new📘, new💡)
end

function landscape(scheme, filename)
    Drawing(800, 300, "$(filename).png")
    origin()
    # sky is gradient mesh
    bb = BoundingBox()
    mesh1 = mesh(box(bb, vertices=true), [
      get(scheme, rand()),
      get(scheme, rand()),
      get(scheme, rand()),
      get(scheme, rand())
      ])
    setmesh(mesh1)
    box(bb, :fill)
    # clouds are 2D noise
    clouds()
    # the sun is a disk placed at random
    @layer begin
        setopacity(0.25)
        sethue(get(scheme, .95))
        sunposition = boxtop(bb) +
            (rand(-boxwidth(bb)/3:boxwidth(bb)/3), boxheight(bb)/10)
        circle(sunposition, boxdiagonal(bb)/30, :fill)
    end
    setopacity(0.8)
    # how many layers
    len = 6
    noiselevels =  range(1000, length=len, stop=200)
    detaillevels = 1:len
    persistencelevels = range(0.5, length=len, stop=0.85 )
    for (n, i) in enumerate(range(1, length=len, stop=0))
        # avoid extremes of range
        sethue(colorblend(get(scheme, .05), get(scheme, .95), i))
        layer(i - rand()/2, i - rand()/2,
            noiselevels[n], detail=detaillevels[n],
            persistence=persistencelevels[n])
    end
    finish()
    preview()
end

landscape(ColorSchemes.leonardo, "images/noise/landscape-leonardo")

image label

landscape(ColorSchemes.starrynight, "images/noise/landscapes-starrynight")

image label

I generated a few hundred of these (there are over 300 colorschemes that can be selected at random) and, scrolling through them quickly, I found that sometimes the results were good, sometimes they weren’t. Randomness—and noise—can be hard to predict.

[2018-10-16]

cormullion signing off

This page was generated using Literate.jl.