Rotating Low-Poly Sphere in Go with Mouse-Driven Lighting

A glowing low-poly sphere is a perfect mini graphics project: small enough to finish in an afternoon, but rich enough to teach mesh generation, 3D math, projection, shading, and interactive input. In this tutorial, you’ll build one in Go and make the light source follow your mouse when you drag.

Screen recording:

Why this effect is satisfying to build

This project looks 3D, but the renderer is intentionally lightweight. You are not building a full 3D engine. Instead, you rotate triangle vertices in code, project them into 2D, compute one brightness value per face, and draw the result as flat-shaded triangles.

The mental model

1. Start with a coarse sphere mesh Build an icosphere: begin with an icosahedron, then subdivide its faces once and normalize the new points back onto a sphere.
2. Rotate every vertex each frame Apply a Y rotation and a smaller X rotation so the shape feels alive and readable.
3. Shade each triangle as one solid color Compute the face normal, compare it to the light direction, and turn that into brightness.
4. Project 3D points into 2D screen space Divide X and Y by Z, multiply by a focal length, and offset to the center of the screen.
5. Drag to move the light Convert the mouse cursor from screen coordinates into a view-space light position.

Project setup

Create a new folder, initialize a module, install dependencies, and run the program.

Terminal setup commands
go mod init lowpoly-sphere
go mod tidy
go run .
Tip:

Keep this project in one file at first. It makes the relationship between math, mesh, update loop, and renderer much easier to follow.

Build the scene in layers

Define a tiny 3D vector type

Before you render anything, give yourself a clean vector type with addition, subtraction, scaling, dot product, cross product, length, and normalization.

These seven or eight operations do nearly all the heavy lifting in small graphics experiments.

Create an icosphere

A UV sphere is common, but an icosphere produces triangles that are more evenly distributed. That means the sphere looks cleaner under flat shading and doesn’t pinch badly near the poles.

Start from the 12 vertices of an icosahedron. Then define its 20 triangular faces. For each subdivision step, split every triangle into four by inserting midpoints on each edge, then normalize those new points back to the desired radius.

Why normalize?

A midpoint between two sphere vertices lies inside the sphere, not on it. Normalizing pushes it back onto the surface.

Why only one subdivision?

One subdivision keeps the model recognizably low-poly while still feeling round enough to spin nicely.

Rotate vertices every frame

Rotation is the illusion engine here. Each frame, rotate every vertex around the Y axis, then around the X axis with a smaller angle. That slight mismatch keeps the motion from feeling robotic.

Move the sphere away from the camera

Your “camera” sits near the origin and looks down positive Z. After rotation, shift every vertex forward by adding a positive Z offset such as 4.2. Without that, projection will explode or invert.

Compute a face normal

For each triangle, calculate:

Normal formula per face
normal = (b - a).Cross(c - a).Normalize()

That gives a vector pointing away from the triangle’s surface. Because you use one normal per face, the shading stays flat instead of smooth.

Shade with a dot product

Compute the center of the triangle, subtract it from the light position, normalize that vector, then take the dot product with the face normal.

Brightness Lambert-style shading
lightDir   = lightPos.Sub(center).Normalize()
brightness = max(0, normal.Dot(lightDir))

Add a little ambient light so the dark side is still readable:

Ambient + direct light simple and effective
brightness = 0.18 + 0.82 * max(0, normal.Dot(lightDir))

Project 3D to 2D

Perspective projection is just division by depth:

Projection camera cheat
screenX = centerX + (x / z) * focal
screenY = centerY - (y / z) * focal

A larger focal length zooms in. A smaller one zooms out.

Sort triangles before drawing

Since this example skips a depth buffer, you sort triangles by average Z and draw the farthest ones first. That painter’s algorithm works well for a convex object like a sphere.

Use mouse drag to move the light

Each frame, check whether the left mouse button is pressed. If it is, read the cursor position and map it into a smaller 3D light range. The X and Y move with the mouse; Z can stay fixed.

This keeps the interaction intuitive: drag left-right to sweep the highlight around the sphere, drag up-down to lift or lower the light.

Full Go source

Here is the complete single-file program. Save it as main.go.

main.go complete project
package main

import (
	"fmt"
	"image/color"
	"log"
	"math"
	"sort"

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

const (
	screenW   = 960
	screenH   = 720
	sphereRad = 1.25
)

type Vec3 struct {
	X, Y, Z float64
}

func (a Vec3) Add(b Vec3) Vec3    { return Vec3{a.X + b.X, a.Y + b.Y, a.Z + b.Z} }
func (a Vec3) Sub(b Vec3) Vec3    { return Vec3{a.X - b.X, a.Y - b.Y, a.Z - b.Z} }
func (a Vec3) Mul(s float64) Vec3 { return Vec3{a.X * s, a.Y * s, a.Z * s} }
func (a Vec3) Dot(b Vec3) float64 { return a.X*b.X + a.Y*b.Y + a.Z*b.Z }
func (a Vec3) Cross(b Vec3) Vec3 {
	return Vec3{
		a.Y*b.Z - a.Z*b.Y,
		a.Z*b.X - a.X*b.Z,
		a.X*b.Y - a.Y*b.X,
	}
}
func (a Vec3) Len() float64 { return math.Sqrt(a.Dot(a)) }
func (a Vec3) Normalize() Vec3 {
	l := a.Len()
	if l == 0 {
		return a
	}
	return a.Mul(1 / l)
}

func rotateY(v Vec3, a float64) Vec3 {
	s, c := math.Sin(a), math.Cos(a)
	return Vec3{
		X: c*v.X + s*v.Z,
		Y: v.Y,
		Z: -s*v.X + c*v.Z,
	}
}

func rotateX(v Vec3, a float64) Vec3 {
	s, c := math.Sin(a), math.Cos(a)
	return Vec3{
		X: v.X,
		Y: c*v.Y - s*v.Z,
		Z: s*v.Y + c*v.Z,
	}
}

type Face struct{ A, B, C int }

type Mesh struct {
	Vertices []Vec3
	Faces    []Face
}

func newIcosphere(subdiv int, radius float64) Mesh {
	phi := (1.0 + math.Sqrt(5.0)) / 2.0
	verts := []Vec3{
		{-1, phi, 0}, {1, phi, 0}, {-1, -phi, 0}, {1, -phi, 0},
		{0, -1, phi}, {0, 1, phi}, {0, -1, -phi}, {0, 1, -phi},
		{phi, 0, -1}, {phi, 0, 1}, {-phi, 0, -1}, {-phi, 0, 1},
	}
	for i := range verts {
		verts[i] = verts[i].Normalize().Mul(radius)
	}

	faces := []Face{
		{0, 11, 5}, {0, 5, 1}, {0, 1, 7}, {0, 7, 10}, {0, 10, 11},
		{1, 5, 9}, {5, 11, 4}, {11, 10, 2}, {10, 7, 6}, {7, 1, 8},
		{3, 9, 4}, {3, 4, 2}, {3, 2, 6}, {3, 6, 8}, {3, 8, 9},
		{4, 9, 5}, {2, 4, 11}, {6, 2, 10}, {8, 6, 7}, {9, 8, 1},
	}

	midpointCache := map[[2]int]int{}
	midpoint := func(i, j int) int {
		key := [2]int{i, j}
		if i > j {
			key = [2]int{j, i}
		}
		if idx, ok := midpointCache[key]; ok {
			return idx
		}
		m := verts[i].Add(verts[j]).Mul(0.5).Normalize().Mul(radius)
		verts = append(verts, m)
		idx := len(verts) - 1
		midpointCache[key] = idx
		return idx
	}

	for s := 0; s < subdiv; s++ {
		midpointCache = map[[2]int]int{}
		next := make([]Face, 0, len(faces)*4)
		for _, f := range faces {
			ab := midpoint(f.A, f.B)
			bc := midpoint(f.B, f.C)
			ca := midpoint(f.C, f.A)
			next = append(next,
				Face{f.A, ab, ca},
				Face{f.B, bc, ab},
				Face{f.C, ca, bc},
				Face{ab, bc, ca},
			)
		}
		faces = next
	}

	return Mesh{Vertices: verts, Faces: faces}
}

type triToDraw struct {
	z        float64
	vertices [3]ebiten.Vertex
}

type Game struct {
	mesh     Mesh
	white    *ebiten.Image
	angle    float64
	lightPos Vec3
}

func NewGame() *Game {
	white := ebiten.NewImage(3, 3)
	white.Fill(color.White)

	return &Game{
		mesh:     newIcosphere(1, sphereRad),
		white:    white,
		lightPos: Vec3{1.8, 1.2, 1.5},
	}
}

func (g *Game) Update() error {
	g.angle += 0.015

	if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
		mx, my := ebiten.CursorPosition()

		nx := (float64(mx)/screenW)*2 - 1
		ny := 1 - (float64(my)/screenH)*2

		g.lightPos.X = nx * 3.2
		g.lightPos.Y = ny * 2.4
	}

	return nil
}

func project(v Vec3) (float32, float32) {
	focal := 420.0
	x := float32(float64(screenW)/2 + (v.X/v.Z)*focal)
	y := float32(float64(screenH)/2 - (v.Y/v.Z)*focal)
	return x, y
}

func clamp(x, lo, hi float64) float64 {
	if x < lo {
		return lo
	}
	if x > hi {
		return hi
	}
	return x
}

func (g *Game) Draw(screen *ebiten.Image) {
	screen.Fill(color.RGBA{11, 14, 20, 255})

	transformed := make([]Vec3, len(g.mesh.Vertices))
	for i, v := range g.mesh.Vertices {
		p := rotateY(v, g.angle)
		p = rotateX(p, g.angle*0.55)
		p.Z += 4.2
		transformed[i] = p
	}

	tris := make([]triToDraw, 0, len(g.mesh.Faces))
	for _, f := range g.mesh.Faces {
		a := transformed[f.A]
		b := transformed[f.B]
		c := transformed[f.C]

		normal := b.Sub(a).Cross(c.Sub(a)).Normalize()
		center := a.Add(b).Add(c).Mul(1.0 / 3.0)

		if normal.Dot(center) >= 0 {
			continue
		}

		lightDir := g.lightPos.Sub(center).Normalize()
		brightness := 0.18 + 0.82*math.Max(0, normal.Dot(lightDir))
		brightness *= 1.0 - clamp((center.Z-3.0)/4.0, 0, 0.35)
		brightness = clamp(brightness, 0.08, 1.0)

		sx0, sy0 := project(a)
		sx1, sy1 := project(b)
		sx2, sy2 := project(c)

		shadeR := float32(0.45 * brightness)
		shadeG := float32(0.85 * brightness)
		shadeB := float32(1.10 * brightness)
		if shadeB > 1 {
			shadeB = 1
		}

		tris = append(tris, triToDraw{
			z: (a.Z + b.Z + c.Z) / 3,
			vertices: [3]ebiten.Vertex{
				{DstX: sx0, DstY: sy0, SrcX: 1, SrcY: 1, ColorR: shadeR, ColorG: shadeG, ColorB: shadeB, ColorA: 1},
				{DstX: sx1, DstY: sy1, SrcX: 1, SrcY: 1, ColorR: shadeR, ColorG: shadeG, ColorB: shadeB, ColorA: 1},
				{DstX: sx2, DstY: sy2, SrcX: 1, SrcY: 1, ColorR: shadeR, ColorG: shadeG, ColorB: shadeB, ColorA: 1},
			},
		})
	}

	sort.Slice(tris, func(i, j int) bool { return tris[i].z > tris[j].z })

	indices := []uint16{0, 1, 2}
	for _, t := range tris {
		screen.DrawTriangles(t.vertices[:], indices, g.white, nil)
	}

	lx := float64(screenW)/2 + (g.lightPos.X/4.0)*260
	ly := float64(screenH)/2 - (g.lightPos.Y/3.0)*220
	ebitenutil.DrawCircle(screen, lx, ly, 6, color.RGBA{255, 240, 120, 255})

	ebitenutil.DebugPrint(screen,
		fmt.Sprintf(
			"Low-poly sphere  |  drag LMB to move light\nlight: (%.2f, %.2f, %.2f)\nFPS: %.0f",
			g.lightPos.X, g.lightPos.Y, g.lightPos.Z, ebiten.ActualFPS(),
		),
	)
}

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

func main() {
	ebiten.SetWindowSize(screenW, screenH)
	ebiten.SetWindowTitle("Go low-poly sphere with draggable light")

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

Why the code works

The sphere looks low-poly because the color is per face

If you computed one normal per vertex and blended brightness across the triangle, the sphere would look smooth. But here each triangle gets a single brightness value, so every face reads as a visible plane.

The painter’s algorithm is enough here

Sorting by average Z is not a universal 3D solution, but it’s great for convex shapes and tutorials. Since a sphere doesn’t have interlocking overhangs or self-intersections, triangle ordering is stable enough to look good.

The light feels physical because it’s in the same space as the mesh

The light is not just a 2D trick. You convert the mouse into a view-space position, then compare it against each triangle center in that same space. That keeps the highlight movement believable as the sphere rotates.

Easy upgrades

Add triangle outlines

Draw wireframe edges after the filled triangles for a more obviously faceted look.

Increase subdivisions

Change newIcosphere(1, sphereRad) to newIcosphere(2, sphereRad) for a denser mesh.

Add specular highlights

Compute a reflection term or half-vector and add a bright hotspot on top of the diffuse shading.

Orbit the camera

Instead of rotating the sphere itself, keep the mesh still and rotate the camera position around it.