Skip to content

Commit abb860e

Browse files
committed
Update README, add coloring
1 parent b889884 commit abb860e

File tree

5 files changed

+214
-23
lines changed

5 files changed

+214
-23
lines changed

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,33 @@
11
# mandelbrot
22

3-
Iteractive Mandelbrot set visualizer.
3+
Interactive Mandelbrot set visualizer. Uses the [ebiten][3] game library to run an interactive window.
4+
5+
## Usage
6+
7+
To build and run from source:
8+
```
9+
make
10+
```
11+
12+
If you are on MacOSX there is a precompiled binary on GitHub you can run:
13+
```
14+
./mandelbrot
15+
```
16+
17+
Controls are explained when the window first loads, but for completeness:
18+
```
19+
Arrow keys to move
20+
I to zoom In
21+
O to zoom Out
22+
R to reset view
23+
Escape to exit
24+
```
425

526
## Resources
627

7-
[fractalmath][1]
8-
[lodev][2]
28+
I found the youtube channel [fractalmath][1] to be helpful for better understanding complex plane dynamics. Lode Vandevenne also has a useful [tutorial][2] as well, but as with most of his articles it can be tough to follow. The ebiten [examples page][4] was invaluable in quickly using that library for the graphical/interactive portions.
929

1030
[1]: https://www.youtube.com/channel/UCJ1i1TGHljQ6ETPgptchOZg
1131
[2]: https://lodev.org/cgtutor/juliamandelbrot.html
32+
[3]: https://ebiten.org/
33+
[4]: https://ebiten.org/examples

color.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// This code is copied from https://github.com/esimov/gobrot because I really liked
2+
// his color interpolation scheme, but couldn't figure out exactly how the interpolation
3+
// calculation worked.
4+
package main
5+
6+
import (
7+
"image/color"
8+
"math"
9+
)
10+
11+
var colorPalette = []color.RGBA{
12+
{0x00, 0x04, 0x0f, 0xff},
13+
{0x03, 0x26, 0x28, 0xff},
14+
{0x07, 0x3e, 0x1e, 0xff},
15+
{0x18, 0x55, 0x08, 0xff},
16+
{0x5f, 0x6e, 0x0f, 0xff},
17+
{0x84, 0x50, 0x19, 0xff},
18+
{0x9b, 0x30, 0x22, 0xff},
19+
{0xb4, 0x92, 0x2f, 0xff},
20+
{0x94, 0xca, 0x3d, 0xff},
21+
{0x4f, 0xd5, 0x51, 0xff},
22+
{0x66, 0xff, 0xb3, 0xff},
23+
{0x82, 0xc9, 0xe5, 0xff},
24+
{0x9d, 0xa3, 0xeb, 0xff},
25+
{0xd7, 0xb5, 0xf3, 0xff},
26+
{0xfd, 0xd6, 0xf6, 0xff},
27+
{0xff, 0xf0, 0xf2, 0xff},
28+
}
29+
30+
func interpolateColors(numberOfColors float64) []color.RGBA {
31+
var factor float64
32+
steps := []float64{}
33+
cols := []uint32{}
34+
interpolated := []uint32{}
35+
interpolatedColors := []color.RGBA{}
36+
37+
factor = 1.0 / numberOfColors
38+
for index, col := range colorPalette {
39+
stepRatio := float64(index+1) / float64(len(colorPalette))
40+
step := float64(int(stepRatio*100)) / 100
41+
steps = append(steps, step)
42+
r, g, b, a := col.RGBA()
43+
r /= 0xff
44+
g /= 0xff
45+
b /= 0xff
46+
a /= 0xff
47+
uintColor := uint32(r)<<24 | uint32(g)<<16 | uint32(b)<<8 | uint32(a)
48+
cols = append(cols, uintColor)
49+
}
50+
51+
var min, max, minColor, maxColor float64
52+
if len(colorPalette) == len(steps) && len(colorPalette) == len(cols) {
53+
for i := 0.0; i <= 1; i += factor {
54+
for j := 0; j < len(colorPalette)-1; j++ {
55+
if i >= steps[j] && i < steps[j+1] {
56+
min = steps[j]
57+
max = steps[j+1]
58+
minColor = float64(cols[j])
59+
maxColor = float64(cols[j+1])
60+
uintColor := cosineInterpolation(maxColor, minColor, (i-min)/(max-min))
61+
interpolated = append(interpolated, uint32(uintColor))
62+
}
63+
}
64+
}
65+
}
66+
67+
for _, pixelValue := range interpolated {
68+
r := pixelValue >> 24 & 0xff
69+
g := pixelValue >> 16 & 0xff
70+
b := pixelValue >> 8 & 0xff
71+
a := 0xff
72+
73+
interpolatedColors = append(interpolatedColors, color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)})
74+
}
75+
76+
return interpolatedColors
77+
}
78+
79+
func cosineInterpolation(c1, c2, mu float64) float64 {
80+
mu2 := (1 - math.Cos(mu*math.Pi)) / 2.0
81+
return c1*(1-mu2) + c2*mu2
82+
}
83+
84+
func linearInterpolation(c1, c2, mu uint32) uint32 {
85+
return c1*(1-mu) + c2*mu
86+
}
87+
88+
func rgbaToUint(color color.RGBA) uint32 {
89+
r, g, b, a := color.RGBA()
90+
r /= 0xff
91+
g /= 0xff
92+
b /= 0xff
93+
a /= 0xff
94+
return uint32(r)<<24 | uint32(g)<<16 | uint32(b)<<8 | uint32(a)
95+
}
96+
97+
func uint32ToRgba(col uint32) color.RGBA {
98+
r := col >> 24 & 0xff
99+
g := col >> 16 & 0xff
100+
b := col >> 8 & 0xff
101+
a := 0xff
102+
return color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)}
103+
}

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@ module github.com/keyan/mandelbrot
22

33
go 1.13
44

5-
require github.com/hajimehoshi/ebiten/v2 v2.0.2
5+
require (
6+
github.com/hajimehoshi/ebiten/v2 v2.0.2
7+
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5
8+
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200707082815-5321531c36a2 h1:Ac1OEHHkbA
33
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200707082815-5321531c36a2/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
44
github.com/gofrs/flock v0.8.0 h1:MSdYClljsF3PbENUUEx85nkWfJSGfzYI9yEBZOJz6CY=
55
github.com/gofrs/flock v0.8.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
6+
github.com/hajimehoshi/bitmapfont/v2 v2.1.0 h1:Kit9SsNcnrU5Q/39zni2adUMTEm128/lkNnYxnP+v3A=
67
github.com/hajimehoshi/bitmapfont/v2 v2.1.0/go.mod h1:2BnYrkTQGThpr/CY6LorYtt/zEPNzvE/ND69CRTaHMs=
78
github.com/hajimehoshi/ebiten/v2 v2.0.2 h1:t8HXO9hJfKlS9tNhht8Ov6xecag0gRl7AkfKgC9hcLE=
89
github.com/hajimehoshi/ebiten/v2 v2.0.2/go.mod h1:AbHP/SS226aFTex/izULVwW0D2AuGyqC4AVwilmRjOg=
@@ -53,6 +54,7 @@ golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7w
5354
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
5455
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM=
5556
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
57+
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
5658
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
5759
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
5860
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

main.go

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ import (
44
"errors"
55
"flag"
66
"fmt"
7+
"image/color"
78
"log"
89
"math/cmplx"
910
"os"
1011
"sync"
1112
"time"
1213

14+
"golang.org/x/image/font"
15+
"golang.org/x/image/font/opentype"
16+
1317
"github.com/hajimehoshi/ebiten/v2"
1418
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
19+
"github.com/hajimehoshi/ebiten/v2/examples/resources/fonts"
20+
"github.com/hajimehoshi/ebiten/v2/text"
1521
)
1622

1723
const (
@@ -29,9 +35,13 @@ var (
2935
xpos, ypos float64
3036
zoom float64 = 1
3137
renderJulia bool
38+
fastEvalEnabled bool
39+
beginViz bool
3240
iterationBuffer []int
3341
frameBuffer []byte
42+
colors []color.RGBA
3443
lastRenderDuration time.Duration
44+
mplusNormalFont font.Face
3545
)
3646

3747
func init() {
@@ -40,6 +50,22 @@ func init() {
4050
iterationBuffer = make([]int, windowWidth*windowHeight)
4151
// Need 4 bytes (r,g,b,a) for each pixel which is colored per frame.
4252
frameBuffer = make([]byte, windowWidth*windowHeight*4)
53+
colors = interpolateColors(fMaxIterations)
54+
55+
tt, err := opentype.Parse(fonts.MPlus1pRegular_ttf)
56+
if err != nil {
57+
log.Fatal(err)
58+
}
59+
60+
const dpi = 72
61+
mplusNormalFont, err = opentype.NewFace(tt, &opentype.FaceOptions{
62+
Size: 24,
63+
DPI: dpi,
64+
Hinting: font.HintingFull,
65+
})
66+
if err != nil {
67+
log.Fatal(err)
68+
}
4369
}
4470

4571
// Game is the required type from ebiten which must implement that package's
@@ -50,9 +76,14 @@ type Game struct{}
5076
// the max allowable TPS, but due to the high cost of our rendering function
5177
// ticks per second will end up being much less than the 60/sec default.
5278
func (g *Game) Update() error {
53-
if ebiten.CurrentTPS() < 4 {
54-
maxIterations = 100
55-
fMaxIterations = float64(maxIterations)
79+
if ebiten.IsKeyPressed(ebiten.KeyEscape) {
80+
os.Exit(0)
81+
}
82+
83+
if !beginViz {
84+
if ebiten.IsKeyPressed(ebiten.KeyEnter) {
85+
beginViz = true
86+
}
5687
}
5788

5889
shiftAmt := 0.1 / zoom
@@ -73,14 +104,10 @@ func (g *Game) Update() error {
73104
xpos, ypos = 0.0, 0.0
74105
zoom = 1
75106
}
76-
if ebiten.IsKeyPressed(ebiten.KeyEscape) {
77-
os.Exit(0)
78-
}
79107

80-
_, yScrollOffset := ebiten.Wheel()
81-
if yScrollOffset < 0 {
108+
if ebiten.IsKeyPressed(ebiten.KeyO) {
82109
zoom -= zoom * 0.03
83-
} else if yScrollOffset > 0 {
110+
} else if ebiten.IsKeyPressed(ebiten.KeyI) {
84111
zoom += zoom * 0.03
85112
}
86113
if zoom == 0 {
@@ -94,6 +121,20 @@ func (g *Game) Update() error {
94121

95122
// Draw is called on every frame and updates the ebiten screen image.
96123
func (g *Game) Draw(screen *ebiten.Image) {
124+
if !beginViz {
125+
text.Draw(
126+
screen,
127+
"Move with arrow keys\n"+
128+
"Zoom in/out with 'I'/'O' keys\n"+
129+
"Reset with 'R' key\n"+
130+
"Exit with 'Escape'\n"+
131+
"Press Enter to start\n",
132+
mplusNormalFont,
133+
20, 40, color.White,
134+
)
135+
return
136+
}
137+
97138
screen.ReplacePixels(frameBuffer)
98139
ebitenutil.DebugPrint(screen,
99140
fmt.Sprintf(
@@ -147,6 +188,9 @@ func parseFlags() {
147188
flag.BoolVar(
148189
&renderJulia, "julia", false,
149190
"Visualize a Julia set, this is really slow, don't use it")
191+
flag.BoolVar(
192+
&fastEvalEnabled, "fast eval", true,
193+
"Use an evaluation estimation for a render speedup")
150194
flag.Parse()
151195
}
152196

@@ -158,6 +202,10 @@ func parseFlags() {
158202
// Use of this function allows for optimizing frame updates in exchange for lower
159203
// resolution rendering as the user moves.
160204
func useNeighborFastEval(x, y int) (int, error) {
205+
if !fastEvalEnabled {
206+
return 0, errors.New("Fast eval disabled")
207+
}
208+
161209
left := (x - 1) + (y * windowWidth)
162210
right := (x + 1) + (y * windowWidth)
163211
up := x + ((y + 1) * windowWidth)
@@ -172,7 +220,10 @@ func useNeighborFastEval(x, y int) (int, error) {
172220
return 0, errors.New("Can't use neighbors")
173221
}
174222

223+
// renderFrame draws one frame of the image to frameBuffer, checking each pixel at
224+
// the current location and zoom level to see if it is bounded or not.
175225
func renderFrame() {
226+
// Each row of the output is computed in parallel goroutines.
176227
wg := sync.WaitGroup{}
177228
start := time.Now()
178229
for y := 0; y < windowHeight; y++ {
@@ -202,18 +253,27 @@ func renderFrame() {
202253

203254
}
204255

256+
// Cache result for fastEval checks.
257+
// This is an intential datarace! Locking this slice slows
258+
// down rendering too much because multiple goroutines need
259+
// to then synchronize in order to finish this write. This
260+
// hasn't crashed yet, but it probably should.
205261
iterationBuffer[x+(y*windowWidth)] = iterCount
206-
escapeVal := (fMaxIterations - float64(iterCount)) / fMaxIterations
207-
r := uint8(escapeVal * 230)
208-
g := uint8(escapeVal * 235)
209-
b := uint8(escapeVal * 255)
210-
// Pixels must be drawn one byte at a time.
211-
// Each pixel is a 32-bit color, so 4-bytes, use this to determine
212-
// position in the frameBuffer.
262+
263+
// Use black for high iteration counts.
264+
pixelColor := color.RGBA{}
265+
if iterCount < len(colors)-1 {
266+
color1 := colors[iterCount]
267+
color2 := colors[iterCount+1]
268+
col := linearInterpolation(
269+
rgbaToUint(color1), rgbaToUint(color2), uint32(iterCount))
270+
pixelColor = uint32ToRgba(col)
271+
272+
}
213273
p := 4 * (x + (y * windowWidth))
214-
frameBuffer[p] = r
215-
frameBuffer[p+1] = g
216-
frameBuffer[p+2] = b
274+
frameBuffer[p] = pixelColor.R
275+
frameBuffer[p+1] = pixelColor.G
276+
frameBuffer[p+2] = pixelColor.B
217277
frameBuffer[p+3] = 0xff // Alpha is always 255
218278
}
219279
}(yi, y)
@@ -222,6 +282,7 @@ func renderFrame() {
222282
lastRenderDuration = time.Since(start)
223283
}
224284

285+
// main creates an ebiten game window and begins the game loop.
225286
func main() {
226287
ebiten.SetWindowSize(windowWidth, windowHeight)
227288
ebiten.SetWindowTitle(windowName)

0 commit comments

Comments
 (0)