Uuhm, so Grok just unleashed an anime “waifu” mascot and the internet is losing its mind. Wild, I know.
It reminded me of the mascot I built for my Golang kernel assistant, definitely not a waifu, just a friendly gopher like buddy.
The cool part? With Go + C + OpenGL it’s surprisingly painless to do the same yourself.
By the end of this walkthrough you’ll have a draggable, clickable, dancing mascot on your desktop, and a roadmap for taking it even further (because OpenGL is C).
Heads‑up: I grabbed the only decent free 2‑D dancing sprite I could find on the internet: Dancing Girl. Feel free to swap in anything you like.
Repo
git clone https://github.com/sklyt/mascot.git
1. Setting Up
Create a fresh Go module and pull in the OpenGL bindings:
go mod init github.com/mascot
go get -u github.com/go-gl/gl/v4.1-core/gl
go get -u github.com/go-gl/glfw/v3.3/glfw
Optional (legacy / GLES testing):
go get -u github.com/go-gl/gl/v4.1-compatibility/gl
go get -u github.com/go-gl/gl/v3.1/gles2
go get -u github.com/go-gl/gl/v2.1/gl
2. Create the Mascot Skeleton
main.go
package main
func main() {
// we’ll wire this up in a sec
}
Project layout:
mascot/
shaders/ <- how we tell the GPU to draw stuff
glf.go <- OpenGL window + mascot logic
3. Shaders 101 (a very quick crash course)
Think of shaders as assembly for your GPU: one tiny program decides where each vertex goes, another decides what color each pixel should be.
CPU GPU
┌────────┐ ┌──────────────┐
│ Go code│ ---> │ Vertex │
│ │ │ Shader │
└────────┘ └────┬─────────┘
│positions
▼
┌──────────────┐
│ Fragment │
│ Shader │
└────┬─────────┘
│colors
▼
Screen 🔆
If you want the deeper theory later, bookmark The Book of Shaders.
shaders/quad.frag
– how pixels get painted
#version 330 core
in vec2 vUV;
out vec4 FragColor;
uniform sampler2D uTex;
void main() {
FragColor = texture(uTex, vUV);
}
shaders/quad.vert
– where vertices live
#version 330 core
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aUV;
out vec2 vUV;
void main() {
vUV = aUV;
gl_Position = vec4(aPos, 0.0, 1.0);
}
4. Spinning up the OpenGL Window
glf.go
package mascot
import (
"embed"
"fmt"
"image"
"image/png"
"os"
"runtime"
"sync"
"time"
"github.com/go-gl/gl/v4.1-core/gl"
"github.com/go-gl/glfw/v3.3/glfw"
)
//go:embed shaders/*
var shaders embed.FS
// DesktopMascot is a transparent GLFW window with an animated sprite.
type DesktopMascot struct {
window *glfw.Window
textures []uint32
frameDur time.Duration
frames int
shaderProg uint32
vao, vbo uint32
OnClick func()
}
var (
instance *DesktopMascot
once sync.Once
)
// GetMascot returns a singleton mascot (safe to call multiple times).
func GetMascot(framePaths []string, frameRate float64) *DesktopMascot {
once.Do(func() {
m, err := newDesktopMascot(framePaths, frameRate)
if err != nil {
panic(err)
}
instance = m
})
return instance
}
Inside newDesktopMascot
- Init GLFW & OpenGL.
- Hint the window (transparent, always on top, borderless).
- Create the window; park it bottom‑left.
- Compile shaders and bind a simple quad.
- Load textures for each animation frame.
func newDesktopMascot(framePaths []string, frameRate float64) (*DesktopMascot, error) {
runtime.LockOSThread()
if err := glfw.Init(); err != nil {
return nil, fmt.Errorf("failed to init GLFW: %w", err)
}
// Window hints <- Define Window Behavior
glfw.WindowHint(glfw.ContextVersionMajor, 3)
glfw.WindowHint(glfw.ContextVersionMinor, 3)
glfw.WindowHint(glfw.OpenGLProfile, glfw.OpenGLCoreProfile)
glfw.WindowHint(glfw.TransparentFramebuffer, glfw.True)
glfw.WindowHint(glfw.Floating, glfw.True) // always on top
glfw.WindowHint(glfw.Decorated, glfw.False) // no borders
glfw.WindowHint(glfw.Resizable, glfw.False)
// Create & place window
win, err := glfw.CreateWindow(200, 250, "Mascot", nil, nil)
if err != nil {
glfw.Terminate()
return nil, fmt.Errorf("failed to create window: %w", err)
}
mon := glfw.GetPrimaryMonitor()
x, y := mon.GetPos()
mode := mon.GetVideoMode()
win.SetPos(x, y+mode.Height-300) // bottom‑left
win.MakeContextCurrent()
glfw.SwapInterval(1)
if err := gl.Init(); err != nil {
return nil, fmt.Errorf("failed to init GL: %w", err)
}
gl.ClearColor(0, 0, 0, 0)
// Compile shaders
vertSrc, _ := shaders.ReadFile("shaders/quad.vert")
fragSrc, _ := shaders.ReadFile("shaders/quad.frag")
program, err := newProgram(string(vertSrc), string(fragSrc))
if err != nil {
return nil, err
}
gl.UseProgram(program)
gl.Uniform1i(gl.GetUniformLocation(program, gl.Str("uTex\x00")), 0)
// Quad geometry
var vao, vbo uint32
setupQuad(&vao, &vbo)
// Sprite frames
textures, err := loadTextures(framePaths)
if err != nil {
return nil, err
}
return &DesktopMascot{
window: win,
textures: textures,
frameDur: time.Second / time.Duration(frameRate),
frames: len(textures),
shaderProg: program,
vao: vao,
vbo: vbo,
}, nil
}
// newProgram, setupQuad, loadTextures … (helper funcs coming next)
And that’s the backbone!
Helper Functions
Let’s wrap up the last bits, these are the building blocks that bring your mascot to life.
newProgram
: Compile the shaders
This function compiles the vertex and fragment shaders. Don’t stress about the internals if shaders are new to you, black box it for now, trust your intuition, and circle back later.
func newProgram(vertSrc, fragSrc string) (uint32, error) {
compile := func(src string, shaderType uint32) (uint32, error) {
s := gl.CreateShader(shaderType)
csources, free := gl.Strs(src + "\x00")
defer free()
gl.ShaderSource(s, 1, csources, nil)
gl.CompileShader(s)
var status int32
gl.GetShaderiv(s, gl.COMPILE_STATUS, &status)
if status == gl.FALSE {
var logLen int32
gl.GetShaderiv(s, gl.INFO_LOG_LENGTH, &logLen)
log := string(make([]byte, logLen))
gl.GetShaderInfoLog(s, logLen, nil, gl.Str(log+"\x00"))
return 0, fmt.Errorf("shader compile error: %s", log)
}
return s, nil
}
vs, err := compile(vertSrc, gl.VERTEX_SHADER)
if err != nil {
return 0, err
}
fs, err := compile(fragSrc, gl.FRAGMENT_SHADER)
if err != nil {
return 0, err
}
prog := gl.CreateProgram()
gl.AttachShader(prog, vs)
gl.AttachShader(prog, fs)
gl.LinkProgram(prog)
var stat int32
gl.GetProgramiv(prog, gl.LINK_STATUS, &stat)
if stat == gl.FALSE {
var logLen int32
gl.GetProgramiv(prog, gl.INFO_LOG_LENGTH, &logLen)
log := string(make([]byte, logLen))
gl.GetProgramInfoLog(prog, logLen, nil, gl.Str(log+"\x00"))
return 0, fmt.Errorf("program link error: %s", log)
}
gl.DeleteShader(vs)
gl.DeleteShader(fs)
return prog, nil
}
setupQuad
: Define the drawing space
This sets up the geometry, the little canvas your mascot dances on.
func setupQuad(vao, vbo *uint32) {
vertices := []float32{
-1, -1, 0, 1, // Bottom-left
1, -1, 1, 1, // Bottom-right
1, 1, 1, 0, // Top-right
-1, 1, 0, 0, // Top-left
}
gl.GenVertexArrays(1, vao)
gl.GenBuffers(1, vbo)
gl.BindVertexArray(*vao)
gl.BindBuffer(gl.ARRAY_BUFFER, *vbo)
gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW)
// position attribute
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointer(0, 2, gl.FLOAT, false, 4*4, gl.PtrOffset(0))
// uv attribute
gl.EnableVertexAttribArray(1)
gl.VertexAttribPointer(1, 2, gl.FLOAT, false, 4*4, gl.PtrOffset(2*4))
}
loadTextures
: Bring your sprite to life
Loads each sprite frame from disk and pushes it to the GPU.
func loadTextures(paths []string) ([]uint32, error) {
var ids []uint32
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return nil, err
}
img, err := png.Decode(f)
f.Close()
if err != nil {
return nil, err
}
rgba := image.NewRGBA(img.Bounds())
for y := 0; y < rgba.Rect.Dy(); y++ {
for x := 0; x < rgba.Rect.Dx(); x++ {
rgba.Set(x, y, img.At(x, y))
}
}
var tex uint32
gl.GenTextures(1, &tex)
gl.BindTexture(gl.TEXTURE_2D, tex)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA,
int32(rgba.Rect.Dx()), int32(rgba.Rect.Dy()), 0,
gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(rgba.Pix))
ids = append(ids, tex)
}
return ids, nil
}
Running the Mascot
Let’s attach the final methods to your DesktopMascot
struct.
Cleanup & Destroy
func (m *DesktopMascot) clean() {
defer gl.DeleteProgram(m.shaderProg)
defer gl.DeleteVertexArrays(1, &m.vao)
defer gl.DeleteBuffers(1, &m.vbo)
for _, tex := range m.textures {
gl.DeleteTextures(1, &tex)
}
}
func (m *DesktopMascot) Close_() {
m.clean()
m.window.Destroy()
}
The Run Loop
This is where the magic happens, render loop + click events + drag-to-move.
func (m *DesktopMascot) Run() {
defer m.clean()
if m == nil {
panic("DesktopMascot is nil; ensure NewDesktopMascot did not return an error")
}
var dragStart struct {
x, y float64
active bool
}
// Drag with right-click
m.window.SetCursorPosCallback(func(w *glfw.Window, x, y float64) {
if dragStart.active {
winX, winY := w.GetPos()
w.SetPos(winX+int(x-dragStart.x), winY+int(y-dragStart.y))
}
})
// Click and Drag Events
m.window.SetMouseButtonCallback(func(w *glfw.Window, button glfw.MouseButton, action glfw.Action, mods glfw.ModifierKey) {
if button == glfw.MouseButtonLeft && action == glfw.Press {
if m.OnClick != nil {
m.OnClick()
}
}
if button == glfw.MouseButtonRight {
if action == glfw.Press {
dragStart.active = true
dragStart.x, dragStart.y = w.GetCursorPos()
} else {
dragStart.active = false
}
}
})
start := time.Now()
m.window.Show()
for !m.window.ShouldClose() {
delta := time.Since(start)
frame := int(delta / m.frameDur % time.Duration(m.frames))
gl.Clear(gl.COLOR_BUFFER_BIT)
gl.UseProgram(m.shaderProg)
gl.BindVertexArray(m.vao)
gl.ActiveTexture(gl.TEXTURE0)
gl.BindTexture(gl.TEXTURE_2D, m.textures[frame])
gl.DrawArrays(gl.TRIANGLE_FAN, 0, 4)
m.window.SwapBuffers()
glfw.PollEvents()
}
glfw.Terminate()
}
Running the Mascot in main.go
Wire everything together:
package main
import mascot "github.com/mascot/maskot"
func main() {
win := mascot.GetMaskot([]string{
// paths to your sprite frames
// "C:/[path]/mascot1.png",
// "C:/[path]/mascot2.png",
// "C:/[path]/mascot3.png",
}, 8) // 8 FPS animation
win.OnClick = func() {
// Optional click logic here
}
win.Run()
}
Then just run it:
go run .
That’s a Wrap!
Congrats! You’ve built your own starter mascot from scratch.
Want to build a full-blown customization studio?
Throw in a drag-and-drop UI, a plugin system, maybe even a voice or LLM module, OpenGL + Go gives you all the room to play.
Sky's the limit
Let me know what you build!
Top comments (1)
Context Tweet(grok mascot):