From f26c478727e6e9501253890b59d8225a6ffcd6ee Mon Sep 17 00:00:00 2001 From: Henrik Hautakoski Date: Sat, 7 Jun 2025 20:26:00 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + go.mod | 5 +++ go.sum | 2 + graphics/sdl.go | 65 ++++++++++++++++++++++++++++++ main.go | 97 +++++++++++++++++++++++++++++++++++++++++++++ makefile | 6 +++ math/color.go | 23 +++++++++++ math/degree.go | 7 ++++ math/direction.go | 26 ++++++++++++ math/position.go | 11 +++++ math/transform.go | 6 +++ math/vec2.go | 82 ++++++++++++++++++++++++++++++++++++++ player.go | 38 ++++++++++++++++++ render/engine.go | 47 ++++++++++++++++++++++ render/minimap.go | 36 +++++++++++++++++ render/raycaster.go | 85 +++++++++++++++++++++++++++++++++++++++ world/level.go | 42 ++++++++++++++++++++ world/level1.go | 42 ++++++++++++++++++++ 18 files changed, 621 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 graphics/sdl.go create mode 100644 main.go create mode 100644 makefile create mode 100644 math/color.go create mode 100644 math/degree.go create mode 100644 math/direction.go create mode 100644 math/position.go create mode 100644 math/transform.go create mode 100644 math/vec2.go create mode 100644 player.go create mode 100644 render/engine.go create mode 100644 render/minimap.go create mode 100644 render/raycaster.go create mode 100644 world/level.go create mode 100644 world/level1.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fda8714 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/game diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bdd3226 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/pnx/go-raytracer + +go 1.24.3 + +require github.com/veandco/go-sdl2 v0.4.40 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bc27727 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/U= +github.com/veandco/go-sdl2 v0.4.40/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= diff --git a/graphics/sdl.go b/graphics/sdl.go new file mode 100644 index 0000000..103feb8 --- /dev/null +++ b/graphics/sdl.go @@ -0,0 +1,65 @@ +package graphics + +import ( + "github.com/pnx/go-raytracer/math" + "github.com/veandco/go-sdl2/sdl" +) + +type Context struct { + w, h int32 + window *sdl.Window + renderer *sdl.Renderer +} + +func (c Context) Width() int32 { + return c.w +} + +func (c Context) Height() int32 { + return c.h +} + +func (c Context) DrawLine(x1 int32, y1 int32, x2 int32, y2 int32, color math.Color) { + c.renderer.SetDrawColor(color.R, color.G, color.B, color.A) + c.renderer.DrawLine(x1, y1, x2, y2) +} + +func (c Context) DrawRect(x, y, w, h int32, color math.Color) { + c.renderer.SetDrawColor(color.R, color.G, color.B, color.A) + c.renderer.FillRect(&sdl.Rect{ + X: x, + Y: y, + W: w, + H: h, + }) +} + +func (c Context) Sync() { + c.renderer.Present() +} + +func (c Context) Exit() { + c.window.Destroy() + c.renderer.Destroy() + sdl.Quit() +} + +func Init(title string, w, h int32) (*Context, error) { + if err := sdl.Init(sdl.INIT_VIDEO); err != nil { + return nil, err + } + window, err := sdl.CreateWindow(title, sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, w, h, sdl.WINDOW_SHOWN) + if err != nil { + return nil, err + } + renderer, err := sdl.CreateRenderer(window, -1, sdl.RENDERER_SOFTWARE) + if err != nil { + return nil, err + } + return &Context{ + w: w, + h: h, + window: window, + renderer: renderer, + }, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..23e4c5c --- /dev/null +++ b/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "log" + stdmath "math" + "time" + + "github.com/pnx/go-raytracer/graphics" + "github.com/pnx/go-raytracer/math" + "github.com/pnx/go-raytracer/render" + "github.com/pnx/go-raytracer/world" + "github.com/veandco/go-sdl2/sdl" +) + +var ( + gfxContext *graphics.Context + player Player + level *world.Level + startOfFrame time.Time +) + +func loadLevel(newLevel *world.Level) { + level = newLevel + + // Set player start position + start := level.PlayerStart() + + player.Position = math.Position{ + X: (float64(start.X) + 0.5) * tileSize, + Y: (float64(start.Y) + 0.5) * tileSize, + } +} + +func update() bool { + for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() { + switch ev := event.(type) { + case *sdl.QuitEvent: + return true + case *sdl.KeyboardEvent: + if ev.Type == sdl.KEYDOWN { + switch ev.Keysym.Sym { + case sdl.K_ESCAPE: + return true + } + } + } + } + + keys := sdl.GetKeyboardState() + + if keys[sdl.SCANCODE_UP] != 0 { + player.MoveForward(level) + } + if keys[sdl.SCANCODE_DOWN] != 0 { + player.MoveBackward(level) + } + if keys[sdl.SCANCODE_LEFT] != 0 { + player.RotateLeft() + } + if keys[sdl.SCANCODE_RIGHT] != 0 { + player.RotateRight() + } + + return false +} + +func main() { + var err error + + gfxContext, err = graphics.Init("Go Raycaster", 1024, 768) + if err != nil { + log.Fatal(err) + } + + player = Player{ + MoveSpd: 2, + RotSpd: 0.05, + } + + loadLevel(&world.Level1) + + for { + startOfFrame := time.Now() + if update() { + break + } + + render.DrawScene(gfxContext, player.Transform, level, colorMap) + render.DrawMiniMap(gfxContext, player.Transform, level) + gfxContext.Sync() + + elapsed := time.Since(startOfFrame) + sdl.Delay(uint32(stdmath.Max(16-float64(elapsed.Milliseconds()), 1))) + } + + gfxContext.Exit() +} diff --git a/makefile b/makefile new file mode 100644 index 0000000..46dacbb --- /dev/null +++ b/makefile @@ -0,0 +1,6 @@ +GO=go + +.PHONY: game + +game: + $(GO) build -o $@ . diff --git a/math/color.go b/math/color.go new file mode 100644 index 0000000..ae17f29 --- /dev/null +++ b/math/color.go @@ -0,0 +1,23 @@ +package math + +type Color struct { + R, G, B, A uint8 +} + +func (c Color) Shade(value uint8) Color { + return Color{ + R: c.R / value, + G: c.G / value, + B: c.B / value, + A: c.A, + } +} + +func (c Color) Sub(value uint8) Color { + return Color{ + R: c.R - value, + G: c.G - value, + B: c.B - value, + A: c.A, + } +} diff --git a/math/degree.go b/math/degree.go new file mode 100644 index 0000000..df9e6d1 --- /dev/null +++ b/math/degree.go @@ -0,0 +1,7 @@ +package math + +import "math" + +func DecToRad(deg float64) float64 { + return deg * (math.Pi / 180) +} diff --git a/math/direction.go b/math/direction.go new file mode 100644 index 0000000..d3580d9 --- /dev/null +++ b/math/direction.go @@ -0,0 +1,26 @@ +package math + +import ( + stdmath "math" +) + +type Direction float64 + +func (d *Direction) Set(v float64) { + *d = Direction(v) +} + +func (d Direction) Get() float64 { + return float64(d) +} + +func (d Direction) ForwardVector() Vec2f { + return Vec2f{ + X: stdmath.Cos(float64(d)), + Y: stdmath.Sin(float64(d)), + } +} + +func (d *Direction) Rotate(delta float64) { + *d += Direction(delta) +} diff --git a/math/position.go b/math/position.go new file mode 100644 index 0000000..9058414 --- /dev/null +++ b/math/position.go @@ -0,0 +1,11 @@ +package math + +type Position Vec2f + +func (p *Position) Set(v Vec2f) { + *p = Position(v) +} + +func (p Position) Get() Vec2f { + return Vec2f(p) +} diff --git a/math/transform.go b/math/transform.go new file mode 100644 index 0000000..94e9e6e --- /dev/null +++ b/math/transform.go @@ -0,0 +1,6 @@ +package math + +type Transform struct { + Position + Direction +} diff --git a/math/vec2.go b/math/vec2.go new file mode 100644 index 0000000..1136512 --- /dev/null +++ b/math/vec2.go @@ -0,0 +1,82 @@ +package math + +import stdmath "math" + +type UnsignedInteger interface { + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr +} + +type SignedInteger interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 +} + +type Integer interface { + UnsignedInteger | SignedInteger +} + +type Float interface { + ~float32 | ~float64 +} + +type Number interface { + Integer | Float +} + +type Vec2[T Number] struct { + X, Y T +} + +type ( + Vec2i = Vec2[int] + Vec2i32 = Vec2[int32] + Vec2f = Vec2[float64] + Vec2u8 = Vec2[uint8] +) + +func (v Vec2[T]) Add(x, y T) Vec2[T] { + return Vec2[T]{ + X: v.X + x, + Y: v.Y + y, + } +} + +func (v Vec2[T]) AddS(s T) Vec2[T] { + return v.Add(s, s) +} + +func (v Vec2[T]) AddVec(other Vec2[T]) Vec2[T] { + return Vec2[T]{ + X: v.X + other.X, + Y: v.Y + other.Y, + } +} + +func (v Vec2[T]) Mul(x, y T) Vec2[T] { + return Vec2[T]{ + X: v.X * x, + Y: v.Y * y, + } +} + +func (v Vec2[T]) MulVec(other Vec2[T]) Vec2[T] { + return v.Mul(other.X, other.Y) +} + +func (v Vec2[T]) Scale(f T) Vec2[T] { + return v.Mul(f, f) +} + +func (v Vec2[T]) Length() float64 { + return stdmath.Sqrt(float64(v.X*v.X) + float64(v.Y*v.Y)) +} + +func (v Vec2[T]) Normalize() Vec2[T] { + l := v.Length() + if l <= 0.0 { + return Vec2[T]{} + } + return Vec2[T]{ + X: T(float64(v.X) / l), + Y: T(float64(v.Y) / l), + } +} diff --git a/player.go b/player.go new file mode 100644 index 0000000..75541e5 --- /dev/null +++ b/player.go @@ -0,0 +1,38 @@ +package main + +import ( + "github.com/pnx/go-raytracer/math" + "github.com/pnx/go-raytracer/world" +) + +type Player struct { + math.Transform + MoveSpd float64 + RotSpd float64 +} + +func (p *Player) Move(delta float64, level *world.Level) { + deltaVec := p.ForwardVector().Scale(delta) + newPos := p.Position.Get().AddVec(deltaVec) + + // make sure we don't move past walls. + if !level.Wall(int(newPos.X)/world.TileSize, int(newPos.Y)/world.TileSize) { + p.Position.Set(newPos) + } +} + +func (p *Player) MoveForward(level *world.Level) { + p.Move(p.MoveSpd, level) +} + +func (p *Player) MoveBackward(level *world.Level) { + p.Move(-p.MoveSpd, level) +} + +func (p *Player) RotateLeft() { + p.Rotate(-p.RotSpd) +} + +func (p *Player) RotateRight() { + p.Rotate(p.RotSpd) +} diff --git a/render/engine.go b/render/engine.go new file mode 100644 index 0000000..92076ed --- /dev/null +++ b/render/engine.go @@ -0,0 +1,47 @@ +package render + +import ( + "github.com/pnx/go-raytracer/graphics" + "github.com/pnx/go-raytracer/math" + "github.com/pnx/go-raytracer/world" +) + +func DrawColumn(ctx *graphics.Context, x int32, wall_h int32, color math.Color) { + if wall_h < ctx.Height() { + y1 := (ctx.Height() - wall_h) / 2 + y2 := (ctx.Height() + wall_h) / 2 + + // Top + // renderer.SetDrawColor(90, 90, 0, 255) + ctx.DrawLine(x, 0, int32(x), y1, math.Color{R: 90, G: 90, B: 0, A: 255}) + + // // Middle + // renderer.SetDrawColor(color.R, color.G, color.B, color.A) + ctx.DrawLine(x, y1, int32(x), y2, color) + + // Bottom + if y2 < ctx.Height() { + // renderer.SetDrawColor(50, 50, 50, 255) + ctx.DrawLine(x, y2, int32(x), ctx.Height(), math.Color{R: 50, G: 50, B: 50, A: 255}) + } + return + } + + // ctx.SetDrawColor(color.R, color.G, color.B, color.A) + ctx.DrawLine(x, 0, int32(x), ctx.Height(), color) +} + +func DrawScene(ctx *graphics.Context, camera math.Transform, level *world.Level, colorMap []math.Color) { + for x := range ctx.Width() { + result := CastRay(camera, level, int(x), int(ctx.Width())) + lineHeight := int(float64(ctx.Height()) / result.Distance) + + color := colorMap[level.Cell(int(result.Cell.X), int(result.Cell.Y))-1] + + if result.Side > 0 { + color = color.Shade(2) + } + + DrawColumn(ctx, int32(x), int32(lineHeight), color) + } +} diff --git a/render/minimap.go b/render/minimap.go new file mode 100644 index 0000000..43abfe2 --- /dev/null +++ b/render/minimap.go @@ -0,0 +1,36 @@ +package render + +import ( + "github.com/pnx/go-raytracer/graphics" + "github.com/pnx/go-raytracer/math" + "github.com/pnx/go-raytracer/world" +) + +func DrawMiniMap(ctx *graphics.Context, camera math.Transform, level *world.Level) { + const tileSize = 32 + const scale = float64(tileSize) / float64(world.TileSize) + // offset := math.Vec2i32{X: 25, Y: 25} + // size := math.Vec2i32{X: 200, Y: 100} + + ctx.DrawRect(0, 0, int32(level.W*tileSize)+3, int32(level.H*tileSize)+3, math.Color{}) + + for y := range level.H { + for x := range level.W { + if level.Wall(x, y) { + ctx.DrawRect(int32(x*tileSize), int32(y*tileSize), tileSize, tileSize, math.Color{R: 255, G: 255, B: 255, A: 255}) + } + } + } + + // Camera Position + cPos := camera.Position.Get().Scale(scale) + ctx.DrawRect(int32(cPos.X-2), int32(cPos.Y-2), 4, 4, math.Color{R: 255, G: 0, B: 255, A: 255}) + + // Rays + for x := range ctx.Width() { + hit := CastRay(camera, level, int(x), int(ctx.Width())) + end := camera.Position.Get().AddVec(hit.Pos.Scale(world.TileSize)).Scale(scale) + + ctx.DrawLine(int32(cPos.X), int32(cPos.Y), int32(end.X), int32(end.Y), math.Color{R: 150, G: 150, B: 0, A: 255}) + } +} diff --git a/render/raycaster.go b/render/raycaster.go new file mode 100644 index 0000000..4b0a170 --- /dev/null +++ b/render/raycaster.go @@ -0,0 +1,85 @@ +package render + +import ( + stdmath "math" + + "github.com/pnx/go-raytracer/math" + "github.com/pnx/go-raytracer/world" +) + +type RayHit struct { + Cell math.Vec2u8 + Side int + Distance float64 + Pos math.Vec2f +} + +func CastRay(camera math.Transform, level *world.Level, x int, max_rays int) RayHit { + angle := camera.Direction.Get() + cameraX := 2*float64(x)/float64(max_rays) - 1 + rayDirX := stdmath.Cos(angle) + cameraX*stdmath.Cos(angle+stdmath.Pi/2) + rayDirY := stdmath.Sin(angle) + cameraX*stdmath.Sin(angle+stdmath.Pi/2) + + mapX := int(camera.X) / world.TileSize + mapY := int(camera.Y) / world.TileSize + + deltaDistX := stdmath.Abs(1 / rayDirX) + deltaDistY := stdmath.Abs(1 / rayDirY) + + var stepX, stepY int + var sideDistX, sideDistY float64 + + if rayDirX < 0 { + stepX = -1 + sideDistX = (float64(camera.X)/world.TileSize - float64(mapX)) * deltaDistX + } else { + stepX = 1 + sideDistX = (float64(mapX+1) - float64(camera.X)/world.TileSize) * deltaDistX + } + if rayDirY < 0 { + stepY = -1 + sideDistY = (float64(camera.Y)/world.TileSize - float64(mapY)) * deltaDistY + } else { + stepY = 1 + sideDistY = (float64(mapY+1) - float64(camera.Y)/world.TileSize) * deltaDistY + } + + // DDA loop + var side int // 0 = x, 1 = y + for { + if sideDistX < sideDistY { + sideDistX += deltaDistX + mapX += stepX + side = 0 + } else { + sideDistY += deltaDistY + mapY += stepY + side = 1 + } + + if mapX < 0 || mapX >= level.W || mapY < 0 || mapY >= level.H { + break // out of bounds + } + if level.Wall(mapX, mapY) { + break + } + } + + // Calculate distance to wall + var perpWallDist float64 + if side == 0 { + perpWallDist = (float64(mapX) - float64(camera.X)/world.TileSize + float64(1-stepX)/2) / rayDirX + } else { + perpWallDist = (float64(mapY) - float64(camera.Y)/world.TileSize + float64(1-stepY)/2) / rayDirY + } + + return RayHit{ + Cell: math.Vec2u8{X: uint8(mapX), Y: uint8(mapY)}, + Side: side, + Distance: perpWallDist, + Pos: math.Vec2f{ + X: rayDirX * perpWallDist, + Y: rayDirY * perpWallDist, + }, + } +} diff --git a/world/level.go b/world/level.go new file mode 100644 index 0000000..1cbf1f2 --- /dev/null +++ b/world/level.go @@ -0,0 +1,42 @@ +package world + +import "github.com/pnx/go-raytracer/math" + +const TileSize = 64 + +type Special byte + +const ( + Empty Special = 0 + PlayerStart Special = 'S' +) + +type Level struct { + W, H int + Grid []byte + Specials []Special +} + +func (m Level) Wall(x, y int) bool { + return m.Grid[(y*m.W)+x] > 0 +} + +func (m Level) Cell(x, y int) byte { + return m.Grid[(y*m.W)+x] +} + +func (m Level) PosToCell(x, y float64) (int, int) { + return int(x) / TileSize, int(y) / TileSize +} + +func (m Level) PlayerStart() math.Vec2[int] { + for k, v := range m.Specials { + if v == PlayerStart { + return math.Vec2[int]{ + X: k % m.W, + Y: k / m.W, + } + } + } + return math.Vec2[int]{X: -1, Y: -1} +} diff --git a/world/level1.go b/world/level1.go new file mode 100644 index 0000000..cb44235 --- /dev/null +++ b/world/level1.go @@ -0,0 +1,42 @@ +package world + +var Level1 = Level{ + W: 16, + H: 16, + Grid: []uint8{ + 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 2, 0, 1, + 2, 2, 2, 0, 2, 2, 2, 2, 0, 4, 4, 0, 0, 0, 0, 1, + 3, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, + 3, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, + 3, 0, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 3, 0, 0, 0, 3, 0, 0, 0, 0, 0, 4, 0, 3, 0, 0, 1, + 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 1, 0, 2, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + }, + Specials: []Special{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 'S', 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + }, +}