Содержание

Фракталы Julia Set - Графика с библиотекой Ebiten на Go

Иногда нужно графически изобразить какаю-то симуляцию или процесс.
Основные требования - поддержка языка Go, т.к. вычисления производится на Go и простое использование.

Рассмотрим ebiten одну из самых полупярных библиотек для работы с графикой.
Основное применение ebiten - создание интерактивных 2D игр.

Воспользуемя библиотекой чтобы наривать какой-нибудь динамический процесс.

В качестве примера попробуем нарисовать множество Жюлиа.

Множество Жюлиа описывается уравнением
J(f)={zClimnfn(z)+} J(f) = \left\{ z \in \mathbb{C} \mid \lim_{n \to \infty} |f^n(z)| \neq +\infty \right\}

где $ f(x) = z^2 + c, c \in \mathbb{C} $

Для каждой точки заданной области найти найти номер итерации, на которой точка начинает “убегать” в бесконечность.

Не будем сильно уходить в теорию, т.к. основная тема - графические возможности.

Основная логика вычисления цвета точек:

func generatePoints(toCenterX, toCenterY, toScaleX, toScaleY float64, width, height int) []Point {
	points := make([]Point, 0, width*height)

	var (
		fromCenterX = float64(width) / 2
		fromCenterY = float64(height) / 2
		fromScaleX  = float64(width) / 2
		fromScaleY  = float64(height) / 2
	)

	for x := range width {
		for y := range height {
			nx := remapPoint(float64(x), fromCenterX, fromScaleX, toCenterX, toScaleX)
			ny := remapPoint(float64(y), fromCenterY, fromScaleY, toCenterY, toScaleY)
			col := computeColor(nx, ny, maxIters, cCoeff)
			points = append(points, Point{x, y, col})
		}
	}

	return points
}

func remapPoint(x, fromCenter, fromScale, toCenter, toScale float64) float64 {
	return (x-fromCenter)*(toScale/fromScale) + toCenter
}

func computeColor(x, y float64, maxIters int, c complex128) color.RGBA {
	z := complex(x, y)

	for i := range maxIters {
		if cmplx.Abs(z) > 2 {
			r := uint8((3*i + 17) % 0xFF)
			g := uint8((2*i + 01) % 0xFF)
			b := uint8((1*i + 01) % 0xFF)
			return color.RGBA{r, g, b, 0xFF}
		}

		z = z*z + c
	}

	return color.RGBA{}
}

Не знаю как по-правильному должен зависеть цвет точки от количества итераций.
Т.к. красивая визуализация приоритетнее математической корректности, то остановился на таком варинте.

Реализация интерфейса Game для использования в ebiten:

type Game struct {
	width  int
	height int

	CenterX, CenterY float64
	ScaleX, ScaleY   float64

	points      []Point
	needsUpdate bool
}

func (g *Game) Update() error {
	g.handleInput()

	if g.needsUpdate {
		g.points = generatePoints(g.CenterX, g.CenterY, g.ScaleX, g.ScaleY, g.width, g.height)
		g.needsUpdate = false
	}

	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	for _, p := range g.points {
		screen.Set(p.X, p.Y, p.Color)
	}

	msg := fmt.Sprintf("TPS: %0.2f FPS: %0.2f\n", ebiten.ActualTPS(), ebiten.ActualFPS())
	msg += fmt.Sprintf("Center: (%v,%v), Scale: (%v,%v)\n", g.CenterX, g.CenterY, g.ScaleX, g.ScaleY)
	msg += fmt.Sprintf("maxIters: %v, cCoeff: %v\n", maxIters, cCoeff)
	ebitenutil.DebugPrint(screen, msg)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return outsideWidth, outsideHeight
}

func main() {
	ebiten.SetWindowTitle("2D Fractal Viever - Demo - amyasnikov.com")
	ebiten.SetWindowSize(screenWidth, screenHeight)

	game := NewGame(screenWidth, screenHeight)
	if err := ebiten.RunGame(game); err != nil {
		log.Fatal(err)
	}
}

Управление с клавиатуры:

func (g *Game) handleInput() {
	const scaleStep = 0.3
	if inpututil.IsKeyJustPressed(ebiten.KeyA) {
		g.CenterX -= scaleStep * g.ScaleX
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyD) {
		g.CenterX += scaleStep * g.ScaleX
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyW) {
		g.CenterY -= scaleStep * g.ScaleY
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyS) {
		g.CenterY += scaleStep * g.ScaleY
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyQ) {
		g.ScaleX *= 1 + scaleStep
		g.ScaleY *= 1 + scaleStep
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyE) {
		g.ScaleX *= 1 - scaleStep
		g.ScaleY *= 1 - scaleStep
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyU) {
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyP) {
		log.Println("Saving image ...")
		go func() {
			if err := saveImage(g.CenterX, g.CenterY, g.ScaleX, g.ScaleY, g.width, g.height); err != nil {
				log.Printf("Failed to save image: %v", err)
			} else {
				log.Printf("Image saved")
			}
		}()
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyZ) {
		os.Exit(0)
	}
}

Мне показалось, что отображение точек одной прямоугольной области в другую прямоугольную область
проще всего делать зафиксировав центр этой области и расстояния до границ.
Тогда все опеции изменения размера, перемещение, отображения реализуются очень просто.

Сохранение скриншота:

func saveImage(toCenterX, toCenterY, toScaleX, toScaleY float64, width, height int) error {
	width *= scaleSaveImage
	height *= scaleSaveImage

	points := generatePoints(toCenterX, toCenterY, toScaleX, toScaleY, width, height)

	img := image.NewRGBA(image.Rect(0, 0, width, height))
	for _, p := range points {
		img.Set(p.X, p.Y, p.Color)
	}

	filename := fmt.Sprintf("/tmp/fractal_%s.png", time.Now().Format("20060102-150405"))
	file, err := os.Create(filename)
	if err != nil {
		return err
	}
	defer file.Close()

	return png.Encode(file, img)
}

Весь код:

package main

import (
	"fmt"
	"image"
	"image/color"
	"image/png"
	"log"
	"math/cmplx"
	"math/rand"
	"os"
	"time"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/ebitenutil"
	"github.com/hajimehoshi/ebiten/v2/inpututil"
)

const (
	screenWidth    = 1024
	screenHeight   = 768
	maxIters       = 10000
	scaleSaveImage = 5

	animation = true
)

var (
	// cCoeff = complex(-0.74543, 0.11301)
	// cCoeff = complex(-0.8, 0.156)
	// cCoeff = complex(0.285, 0.01)
	// cCoeff = complex(-0.008, 0.71)
	cCoeff = complex(-0.008, 0.85)
)

type Point struct {
	X, Y  int
	Color color.RGBA
}

type ImageData struct {
	Points []int
}

type Game struct {
	width  int
	height int

	CenterX, CenterY float64
	ScaleX, ScaleY   float64

	points      []Point
	needsUpdate bool
}

func NewGame(width, height int) *Game {
	g := &Game{
		width:       width,
		height:      height,
		CenterX:     0,
		CenterY:     0,
		ScaleX:      1,
		ScaleY:      1,
		points:      nil,
		needsUpdate: true,
	}

	return g
}

func (g *Game) Update() error {
	g.handleInput()

	if animation {
		r1 := 0.0003 * (2*rand.Float64() - 1)
		r2 := 0.0003*(2*rand.Float64()-1) - 0.0001
		cCoeff += complex(r1, r2)
		g.needsUpdate = true
	}

	if g.needsUpdate {
		// log.Println("Generating fractal...")
		g.points = generatePoints(g.CenterX, g.CenterY, g.ScaleX, g.ScaleY, g.width, g.height)
		g.needsUpdate = false
		// log.Println("Fractal updated.")
	}

	return nil
}

func (g *Game) handleInput() {
	const scaleStep = 0.3
	if inpututil.IsKeyJustPressed(ebiten.KeyA) {
		g.CenterX -= scaleStep * g.ScaleX
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyD) {
		g.CenterX += scaleStep * g.ScaleX
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyW) {
		g.CenterY -= scaleStep * g.ScaleY
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyS) {
		g.CenterY += scaleStep * g.ScaleY
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyQ) {
		g.ScaleX *= 1 + scaleStep
		g.ScaleY *= 1 + scaleStep
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyE) {
		g.ScaleX *= 1 - scaleStep
		g.ScaleY *= 1 - scaleStep
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyU) {
		g.needsUpdate = true
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyP) {
		log.Println("Saving image ...")
		go func() {
			if err := saveImage(g.CenterX, g.CenterY, g.ScaleX, g.ScaleY, g.width, g.height); err != nil {
				log.Printf("Failed to save image: %v", err)
			} else {
				log.Printf("Image saved")
			}
		}()
	}
	if inpututil.IsKeyJustPressed(ebiten.KeyZ) {
		os.Exit(0)
	}
}

func saveImage(toCenterX, toCenterY, toScaleX, toScaleY float64, width, height int) error {
	width *= scaleSaveImage
	height *= scaleSaveImage

	points := generatePoints(toCenterX, toCenterY, toScaleX, toScaleY, width, height)

	img := image.NewRGBA(image.Rect(0, 0, width, height))
	for _, p := range points {
		img.Set(p.X, p.Y, p.Color)
	}

	filename := fmt.Sprintf("/tmp/fractal_%s.png", time.Now().Format("20060102-150405"))
	file, err := os.Create(filename)
	if err != nil {
		return err
	}
	defer file.Close()

	return png.Encode(file, img)
}

func generatePoints(toCenterX, toCenterY, toScaleX, toScaleY float64, width, height int) []Point {
	points := make([]Point, 0, width*height)

	var (
		fromCenterX = float64(width) / 2
		fromCenterY = float64(height) / 2
		fromScaleX  = float64(width) / 2
		fromScaleY  = float64(height) / 2
	)

	for x := range width {
		for y := range height {
			nx := remapPoint(float64(x), fromCenterX, fromScaleX, toCenterX, toScaleX)
			ny := remapPoint(float64(y), fromCenterY, fromScaleY, toCenterY, toScaleY)
			col := computeColor(nx, ny, maxIters, cCoeff)
			points = append(points, Point{x, y, col})
		}
	}

	return points
}

func (g *Game) Draw(screen *ebiten.Image) {
	for _, p := range g.points {
		screen.Set(p.X, p.Y, p.Color)
	}

	msg := fmt.Sprintf("TPS: %0.2f FPS: %0.2f\n", ebiten.ActualTPS(), ebiten.ActualFPS())
	msg += fmt.Sprintf("Center: (%v,%v), Scale: (%v,%v)\n", g.CenterX, g.CenterY, g.ScaleX, g.ScaleY)
	msg += fmt.Sprintf("maxIters: %v, cCoeff: %v\n", maxIters, cCoeff)
	ebitenutil.DebugPrint(screen, msg)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return outsideWidth, outsideHeight
}

func remapPoint(x, fromCenter, fromScale, toCenter, toScale float64) float64 {
	return (x-fromCenter)*(toScale/fromScale) + toCenter
}

func computeColor(x, y float64, maxIters int, c complex128) color.RGBA {
	z := complex(x, y)

	for i := range maxIters {
		if cmplx.Abs(z) > 2 {
			r := uint8((3*i + 17) % 0xFF)
			g := uint8((2*i + 01) % 0xFF)
			b := uint8((1*i + 01) % 0xFF)
			return color.RGBA{r, g, b, 0xFF}
		}

		z = z*z + c
	}

	return color.RGBA{}
}

func main() {
	log.SetFlags(log.Lshortfile | log.Lmicroseconds)

	ebiten.SetWindowTitle("2D Fractal Viever - Demo - amyasnikov.com")
	ebiten.SetWindowSize(screenWidth, screenHeight)

	game := NewGame(screenWidth, screenHeight)
	if err := ebiten.RunGame(game); err != nil {
		log.Fatal(err)
	}
}

julia-set-1

julia-set-2

julia-set-3

Множество Жюлиа меняется при изменении параметра c из уравнения.

Julia Set - Исходный код на GitHub

Похожее