loading...
Cover image for Going to the Stars With SDL2 and Go

Going to the Stars With SDL2 and Go

shindakun profile image Steve Layton ・7 min read

Attempting to Learn SDL2

Its Full of Stars

stars

It looks better in real life, honest

Welcome back to another post. I'm still taking some time on things other the normal APIs or web servers. Instead this time we're doing our own riff on an article from the latest issue of Wireframe by one Daniel Pope. The article covers a basic Python implementation of a star field warp similar to what was seen in by Konami's Gyruss.

We're not going to do a one-for-one reimagining since we're leaving out the speed changing part for now. Maybe we'll add that next week if we don't transition back to working on the roguelike.


Code Walkthrough

The first thing you'll note if you compare this to the Python code is that we've got a lot more setup to do. A large chunk of the code is actually dedicated to getting SDL2 ready to go. After that, it is just a matter of updating our star field array and writing it to a texture. Ready? Well then let's dive in.

main.go

As always we'll start off with our imports. We'll be using math and time packages from the standard library. And of course, we'll import the SDL2 bindings to actually make something appear on the screen. Using SDL2 gives us an easy path to release a cross-platform application.

package main

import (
  "math"
  "math/rand"
  "time"

  "github.com/veandco/go-sdl2/sdl"
)

We're setting up a few constants, the window size, how fast we want the warp to start at, and the center of the screen. Oh, and the number of stars we want to use in the warp display.

const (
  winWidth      = 1280
  winHeight     = 720
  minWarpFactor = 0.1
  numStars      = 300
  centerX       = winWidth / 2
  centerY       = winHeight / 2
)

The position struct is going to be used to hold a star's current coordinates. We'll also use it to hold our velocities. I suppose we didn't need to call it position since that's not quite what it is in that case, coords might have been better, or just xy, or something - you get the idea.

type position struct {
  x float64
  y float64
}

Our basic star struct is pretty simple, it holds the position, the velocity, and the brightness (current color). We hold a single byte value for the brightness and apply it to the red, green, and blue color channels to make a color from black to white. We then set up a stars struct which will hold our []star.

type star struct {
  pos        position
  vel        position
  brightness byte
}

type stars struct {
  stars []star
}

randFloat64() is a small helper function that will help us create the initial angle our new star will leave the center of the screen from.

func randFloat64(min float64, max float64) float64 {
  rand.Seed(time.Now().UTC().UnixNano())
  return min + rand.Float64()*(max-min)
}

clear() does what it says on the tin. We use this to quickly clear out of pixel "map" in between screen draws. This way we start from a blank canvas and don't have trails. It does look pretty nice when you skip clearing, creating a nice trail effect but I haven't implemented anything to clean up so eventually, the screen would fill with mostly white. setPixel() is used to write our stars in the correct location with the current color starting from black (0,0,0).

// clear a slice of pixels.
func clear(pixels []byte) {
  for i := range pixels {
    pixels[i] = 0
  }
}

func setPixel(x, y int, c byte, pixels []byte) {
  index := (y*winWidth + x) * 4

  if index < len(pixels)-4 && index >= 0 {
    pixels[index] = c
    pixels[index+1] = c
    pixels[index+2] = c
  }
}

Here we create a newStar()! The code is more or less the same as the original article. I did leave out the trailing tail though since I'm not using SDL2s line draw - instead, we draw pixels directly into a texture (you'll see that later on).

func newStar() star {

  // # Pick a direction and speed
  // angle = random.uniform(-math.pi, math.pi)
  angle := randFloat64(float64(-3.14), float64(3.14))

  // speed = 255 * random.uniform(0.3, 1.0) ** 2
  speed := 255 * math.Pow(randFloat64(float64(0.3), float64(1.0)), 2)

  // # Turn the direction into position and velocity vectors
  // dx = math.cos(angle)
  dx := math.Cos(angle)

  // dy = math.sin(angle)
  dy := math.Sin(angle)

  // d = random.uniform(25 + TRAIL_LENGTH, 100)
  d := rand.Intn(100) + 25 //+ traillength

  // pos = centerx + dx * d, centery + dy * d
  pos := position{
    x: centerX + dx*float64(d),
    y: centerY + dy*float64(d),
  }

  // vel = speed * dx, speed * dy
  vel := position{
    x: speed * dx,
    y: speed * dy,
  }

  s := star{
    pos:        pos,
    vel:        vel,
    brightness: 0,
  }

  return s
}

On each loop through our main... loop, we'll need to update the positions of the stars. We're passing in the elapsedTime but in the case, we're not actually using it in this function. We could do some updating in the future to make sure everything runs a stable frame rate and moves the proper distances within those frames. But, for this exercise, we're just going to roll with it. I also decided to just use a simple incrementing brightness. It's probably a touch to fast at getting brighter but it works.

func (s *stars) update(elapsedTime float32) {

  // calculate the stars new position
  for i := 0; i < len(s.stars); i++ {
    newPosX := s.stars[i].pos.x + (s.stars[i].vel.x * minWarpFactor) //* dt
    newPosY := s.stars[i].pos.y + (s.stars[i].vel.y * minWarpFactor) //* dt

    // if we're off the screen with the new position reset else update position
    if newPosX > winWidth || newPosY > winHeight || newPosX < 0 || newPosY < 0 {
      s.stars[i] = newStar()
    } else {
      s.stars[i].pos.x = newPosX
      s.stars[i].pos.y = newPosY

      // # Grow brighter
      // s.brightness = min(s.brightness + warp_factor * 200 * dt, s.speed)
      if s.stars[i].brightness < 255 {
        s.stars[i].brightness += 40
      }
    }
  }
}

Our draw() loops through the stars and calls setPixel() to write each star "pixel" into our slice.

func (s *stars) draw(pixels []byte) {
  for i := 0; i < len(s.stars); i++ {
    if int(s.stars[i].pos.x) >= 0 {
      setPixel(int(s.stars[i].pos.x), int(s.stars[i].pos.y), s.stars[i].brightness, pixels)
    }
  }
}

Now we have our main()! I'm going to kind of jump over the vast majority of the SDL2 boilerplate code. It's all there to get SDL all set and ready to run. The code is pretty readable but if you have any questions feel free to let me know in the comments. A look at the Go/SDL2 examples in the repo is worth it as well.

func main() {
  err := sdl.Init(sdl.INIT_EVERYTHING)
  if err != nil {
    panic(err)
  }
  defer sdl.Quit()

  sdl.SetHint(sdl.HINT_RENDER_SCALE_QUALITY, "1")

  window, err := sdl.CreateWindow("Stars", sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, int32(winWidth), int32(winHeight), sdl.WINDOW_SHOWN)
  if err != nil {
    panic(err)
  }
  defer window.Destroy()

  renderer, err := sdl.CreateRenderer(window, -1, sdl.RENDERER_ACCELERATED)
  if err != nil {
    panic(err)
  }
  defer renderer.Destroy()

  tex, err := renderer.CreateTexture(sdl.PIXELFORMAT_ABGR8888, sdl.TEXTUREACCESS_STREAMING, int32(winWidth), int32(winHeight))
  if err != nil {
    panic(err)
  }
  defer tex.Destroy()

Now that SDL is all set and ready to go we need to get our initial star set ready to go. First, we'll set up our basics, elpasedTime, our pixels map, and our starField. all is going to hold the full set of stars.

  var elapsedTime float32
  pixels := make([]byte, winWidth*winHeight*4)
  starField := make([]star, numStars)
  all := &stars{}

  for i := 0; i < len(starField); i++ {
    all.stars = append(all.stars, newStar())
  }

We're using the following for loop to run through 2000 iterations of our update() and clear() functions. This makes it so we don't pop up a black screen and then just dump our a new set of stars. That doesn't look very nice. 2000 might be a bit much but I don't think this adds any significate time to the start up so it should be OK.

  for i :=; i < 2000; i++ {
    all.update(nil)
    clear(pixels)
  }

And here we are our final for loop. This will set up our event to shut down SDL when we exit. Then update() and draw() our stars. That's a bit misleading since we're not actually drawing them to screen but instead to a texture. Using it as a texture buffer, we then draw the texture to screen and clear out our pixel set to make them ready for the next go around. Finally, we've got a little delay built in which hopefully will give us a steady frame rate. I've never timed it out so I'm not sure just how well it works in practice but, the screen seems smooth enough.

  for {
    frameStart := time.Now()
    for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
      switch event.(type) {
      case *sdl.QuitEvent:
        return
      }
    }

    all.update(elapsedTime)
    all.draw(pixels)

    tex.Update(nil, pixels, winWidth*4)
    renderer.Copy(tex, nil, nil)
    renderer.Present()
    clear(pixels)
    elapsedTime = float32(time.Since(frameStart).Seconds() * 1000)
    if elapsedTime < 7 {
      sdl.Delay(7 - uint32(elapsedTime))
      elapsedTime = float32(time.Since(frameStart).Seconds() * 1000)
    }
  }
}

Wrapping Up

And there we have it! Another use of Go that isn't focused on business logic! I've been toying with the idea of re-writing a portion of the roguelike code to use SDL2 instead of a plain console so I can incorporate effects. Though I suppose I'd have to put in some time learning how to do shaders in SDL. Who knows where we may end up next week, more graphics type stuff, or roguelikes, or back to APIs...

Now, I really need to prep an update to the ATLG repo is a few articles behind!


You can find the code for this and most of the other Attempting to Learn Go posts in the repo on GitHub.



Posted on by:

shindakun profile

Steve Layton

@shindakun

I've been known to write some code from time to time.

Discussion

markdown guide