ASCII terminal mandelbrot fractal renderer in Go

Categories: Blog,Experiments,Go,Programming,Terminal,Tutorials

After the previous post about the mandelbrot renderer written in Go, this article will be related to the same topic, only this time i'll talk about a terminal based Julia set generator written again in Go language. In fact i've done this experiment prior to write the mandelbrot renderer. About the implementation I've discussed in the last article.

The whole idea came from the motivation to create something which resembles the feeling which only a terminal based application can create (something which is close enough to the pixelart technique), but at the same time it has enough visually appealing characteristics and above all it's dynamic.

Into the terminal fractals

(In preview the ASCII Mandelbrot generator running in terminal)

Because in the last few months i was pretty much delved into the fractals, i've started to create some small experiments, one of them being the code snippet below:

package main
import (
"fmt"
"github.com/shiena/ansicolor"
"os"
"sync"
)
const (
WIDTH int = 70
HEIGHT int = 120
MIN_X float64 = -2.0
MAX_X float64 = 1.0
MIN_Y float64 = -1.0
MAX_Y float64 = 1.0
MAX_IT int = 1000
)
var isColor bool = true
var wg sync.WaitGroup
func main() {
w := ansicolor.NewAnsiColorWriter(os.Stdout)
if len(os.Args) > 1 {
if os.Args[1] == "--help" || os.Args[1] == "-h" {
fmt.Println(`Usage go run mandelbrot_cli.go [--]
-c --color generate ASCII mandelbrot in color
-m --mono generate ASCII mandelbrot in monochrome`)
os.Exit(1)
}
if os.Args[1] == "--color" || os.Args[1] == "-c" {
isColor = true
} else if os.Args[1] == "--mono" || os.Args[1] == "-m" {
isColor = false
}
}
charTable := map[int]string{1: "~", 2: "#", 3: "+", 4: "$", 5: "%", 6: "^", 7: "*", 8: "'", 9: "`"}
ansiColors := map[int]string{1: "\x1b[41m", 2: "\x1b[42m", 3: "\x1b[43m", 4: "\x1b[44m", 5: "\x1b[45m", 6: "\x1b[47m", 7: "\x1b[100m", 8: "\x1b[46m", 9: "\x1b[101m"}
for row := 0; row < WIDTH; row++ {
wg.Add(1)
go func(row int) {
defer wg.Done()
for col := 0; col < HEIGHT; col++ {
var x float64 = MIN_X + (MAX_X-MIN_X)*float64(row)/float64(WIDTH)
var y float64 = MIN_Y + (MAX_Y-MIN_Y)*float64(col)/float64(HEIGHT)
var i = mandelIter(x, y, MAX_IT)
if i < MAX_IT {
if i > 5 {
if isColor {
fmt.Fprintf(w, "@%s%s%s%s%s", "\x1b[37m", "\x1b[1m", "\x1b[30m", "\x1b[41;32m", "\x1b[0m")
} else {
fmt.Printf("@")
}
} else {
if _, ok := charTable[i]; ok {
if isColor {
fmt.Fprintf(w, "%s%s", charTable[i], ansiColors[i])
} else {
fmt.Printf("%s", charTable[i])
}
}
}
} else {
fmt.Print(".")
}
}
fmt.Println()
}(row)
}
wg.Wait()
}
func mandelIter(cx, cy float64, maxIter int) int {
var x, y float64 = 0.0, 0.0
var iteration int = 0
for x*x+y*y <= 4 && iteration < MAX_IT {
var xx float64 = x*x - y*y + cx
var xy float64 = 2*x*y + cy
x = xx
y = xy
iteration++
}
return iteration
}

Which produce the following terminal output:

mandelbrot

This was nice but i wanted to be dynamic, and not something simply put in the terminal window. So i've started to search for methods to refresh the terminal window periodically, ideally to refresh on each mandelbrot iteration. For this reason i've created some utility methods to get the terminal window width and height. And another method to flush the screen buffer periodically.


// Get console width
func Width() int {
	ws, err := getWinsize()

	if err != nil {
		return -1
	}

	return int(ws.Row)
}

// Get console height
func Height() int {
	ws, err := getWinsize()
	if err != nil {
		return -1
	}
	return int(ws.Col)
}

// Flush buffer and ensure that it will not overflow screen
func Flush() {
	for idx, str := range strings.Split(Screen.String(), "\n") {
		if idx > Height() {
			return
		}

		output.WriteString(str + "\n")
	}

	output.Flush()
	Screen.Reset()
}

It was missing another ingredient: in terminal based application we need somehow to specify the cursor position where to output the desired character, some kind of pointer to a position specified by width and height coordinate. For this i've created another method which move the cursor to the desired place, defined by x and y:

// Move cursor to given position
func MoveCursor(x int, y int) {
	fmt.Fprintf(Screen, "\033[%d;%dH", x, y)
}

Then we can clear the screen buffer periodically. In linux based systems to move the cursor to a specific place in terminal window we can use ANSI escape codes, like: \033[%d;%dH, where %d;%d we can replace with values obtained from the mandelbrot renderer. In the example provided in the project github repo i'm moving the terminal window cursor to the mandelbrot x and y position, after which i'm clearing the screen.

To make it more attractive i used a cheap trick to zoom in and out into the fractal and to smoothly displace the fractal position by applying sine and cosine function on x and y coordinate.

for {
	n += 0.045
	zoom += 0.04 * math.Sin(n)
	asciibrot.DrawFractal(zoom, math.Cos(n), math.Sin(n)/zoom*0.02, math.Sin(n), MAX_IT, true, isColor) //where math.Cos(n) and math.Sin(n) are x and y coordinates	
}

But there is another issue. We need to handle differently the code responsible to obtain the terminal window size in different operating systems. In linux and darwin based operating systems here is how we can get the terminal size:

func getWinsize() (*winsize, error) {
	ws := new(winsize)

	var _TIOCGWINSZ int64

	switch runtime.GOOS {
	case "linux":
		_TIOCGWINSZ = 0x5413
	case "darwin":
		_TIOCGWINSZ = 1074295912
	}

	r1, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
		uintptr(syscall.Stdin),
		uintptr(_TIOCGWINSZ),
		uintptr(unsafe.Pointer(ws)),
	)

	if int(r1) == -1 {
		fmt.Println("Error:", os.NewSyscallError("GetWinsize", errno))
		return nil, os.NewSyscallError("GetWinsize", errno)
	}
	return ws, nil
}

In Windows operating system to obtain the terminal dimension is a little bit different:


type (
	coord struct {
		x int16
		y int16
	}

	consoleScreenBufferInfo struct {
		size              coord
		cursorPosition    coord
		maximumWindowSize coord
	}
)
// ...
func getWinSize() (width, height int, err error) {
	var info consoleScreenBufferInfo
	r0, _, e1 := syscall.Syscall(procGetConsoleScreenBufferInfo.Addr(), 2, uintptr(out), uintptr(unsafe.Pointer(&info)), 0)
	if int(r0) == 0 {
		if e1 != 0 {
			err = error(e1)
		} else {
			err = syscall.EINVAL
		}
	}
	return int(info.size.x), int(info.size.y), nil
}

One last step remained: to clear the screen buffer on CTRL-C signal, in another words to clear the screen on control break. For this i've created a channel and when the CTRL-C was pressed i signaled this event and on a separate goroutine i was listening for this event, meaning i could break the operation.


// On CTRL+C restore default terminal foreground and background color
go func() {
	<-c
	fmt.Fprint(asciibrot.Screen, "%s%s", "\x1b[49m", "\x1b[39m")
	fmt.Fprint(asciibrot.Screen, "\033[2J")
	asciibrot.Flush()
	os.Exit(1)
}()

Usage

In big that's all. You can grab the code by running the following command:

go get github.com/esimov/asciibrot

To run it type:

go run julia.go --help

You can run the example in monochrome or color version. For the color version use --color or -c. For monochrome version use --mono or -m.

You can build the binary version with: go build github.com/esimov/asciibrot.

I've created a github repo for this experiment, which you can get it here: https://github.com/esimov/asciibrot

Show comments:

comments powered by Disqus