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
Project setup
Create a new folder, initialize a module, install dependencies, and run the program.
go mod init lowpoly-sphere
go mod tidy
go run .
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 = (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.
lightDir = lightPos.Sub(center).Normalize()
brightness = max(0, normal.Dot(lightDir))
Add a little ambient light so the dark side is still readable:
brightness = 0.18 + 0.82 * max(0, normal.Dot(lightDir))
Project 3D to 2D
Perspective projection is just division by depth:
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.
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.