2026-05-16
1. Using NanoVG in Odin
Firstly, NanoVG is a small graphical UI library built with OpenGL. It has a similar API to the HTML5 Canvas (i.e.: lineTo() and the like). It is also one of the libraries that come vendored with the Odin programming language.
I sometimes use NanoVG as an alternative to Raylib and SDL2, since:- Raylib does not have fallback font support and nanovg seems to be able to load fonts and draw text a lot faster than either Raylib or SDL2.
- Unlike Raylib, it has support for multiple windows!
- Unlike SDL, NanoVG is statically linked, so you won't have to deal with DLLs in order for Windows users to start your program.
2. Creating a Window (With GLFW)
GLFW is a library that adds window handling support to OpenGL, Vulkan and more.
This can be done with many other libraries.
For example, Microslop Windows provides wglCreateContextAttribsARB.
By the way, GLFW + Wayland kind of sucks.
First off, you will, likely, need these imports:
import gl "vendor:OpenGL" // the GPU API that is used to render stuff import glfw "vendor:glfw" // window handling library import nvg "vendor:nanovg" // nanovg's main interface (shapes, text, and so on...) import ngl "vendor:nanovg/gl" // used to CreateContext and CreateFramebuffer
import gl "vendor:OpenGL" import glfw "vendor:glfw" import nvg "vendor:nanovg" import ngl "vendor:nanovg/gl"
Then creating the actual window is pretty simple: init GLFW, make the window and load part of OpenGL.
Just be careful, since every function here is necessary!
1. segfault ⇒ CreateWindow, MakeContextCurrent or load_up_to were not called;
2. invalid instruction ⇒ glfw.Init() was not called;
3. version error ⇒ you need to hint at a version.
init_glfw_window :: proc() {
assert( bool(glfw.Init()) )
glfw.WindowHint(glfw.CONTEXT_VERSION_MAJOR, 4)
window.handle = glfw.CreateWindow(600, 400, "Example window", nil, nil)
fmt.assertf(window.handle != nil, "Failed to create window with error: \n%v %v\n", glfw.GetError())
glfw.MakeContextCurrent(window.handle)
gl.load_up_to(4, 5, glfw.gl_set_proc_address)
}
init_glfw_window :: proc() {
assert( bool(glfw.Init()) )
glfw.WindowHint(glfw.CONTEXT_VERSION_MAJOR, 4)
window.handle = glfw.CreateWindow(600, 400,
"Example window", nil, nil)
fmt.assertf(window.handle != nil,
"Window creation error: \n%v %v\n",
glfw.GetError())
glfw.MakeContextCurrent(window.handle)
gl.load_up_to(4, 5, glfw.gl_set_proc_address)
}
Then, to initialize NanoVG, you can use the below code block:
init_nanovg_context :: proc() {
window.ctx = ngl.Create({ .ANTI_ALIAS, .STENCIL_STROKES })
ngl.CreateFramebuffer(window.ctx, 123, 456, { .REPEAT_X, .REPEAT_Y })
}
init_nanovg_context :: proc() {
window.ctx = ngl.Create({ .ANTI_ALIAS, .STENCIL_STROKES })
ngl.CreateFramebuffer(window.ctx,
123, 456, { .REPEAT_X, .REPEAT_Y })
}
And finally, to begin a frame, while allowing the user to resize the window:
begin_frame :: proc() {
fw, fh := glfw.GetFramebufferSize(window.handle)
w, h := glfw.GetWindowSize(window.handle)
window.size = { f32(fw), f32(fh) }
gl.Viewport(0, 0, fw, fh)
nvg.BeginFrame(window.ctx, f32(w), f32(h), f32(fw) / f32(w))
// if your window is blinking, this may be missing:
BACKGROUND := [4] f32 { 20, 23, 31, 255 } / 255
gl.ClearColor(BACKGROUND.r, BACKGROUND.g, BACKGROUND.b, BACKGROUND.a)
gl.Clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT)
}
begin_frame :: proc() {
fw, fh := glfw.GetFramebufferSize(window.handle)
w, h := glfw.GetWindowSize(window.handle)
window.size = { f32(fw), f32(fh) }
gl.Viewport(0, 0, fw, fh)
nvg.BeginFrame(window.ctx, f32(w), f32(h), f32(fw) / f32(w))
// if your window is blinking, this may be missing:
BG := [4] f32 { 20, 23, 31, 255 } / 255
gl.ClearColor(BG.r, BG.g, BG.b, BG.a)
gl.Clear(gl.COLOR_BUFFER_BIT |
gl.STENCIL_BUFFER_BIT)
}
To run the actual program, you can combine all previous parts. Draw to a buffer. Actually show the graphical buffer you have built up and receive messages from the user.
main :: proc() {
init_glfw_window()
init_nanovg_context()
for !glfw.WindowShouldClose(window.handle) {
begin_frame()
// ...
nvg.EndFrame(window.ctx)
glfw.SwapBuffers(window.handle)
glfw.WaitEventsTimeout(1) // <-- don't forget this!
}
}
main :: proc() {
init_glfw_window()
init_nanovg_context()
for !glfw.WindowShouldClose(window.handle) {
begin_frame()
// ...
nvg.EndFrame(window.ctx)
glfw.SwapBuffers(window.handle)
glfw.WaitEventsTimeout(1) // <-- don't forget this!
}
}
3. Rendering Stuff
First of all, NanoVG uses 4 floats from 0.0 to 1.0 for colors, sooo... I like making:
nvg_color :: proc(color: [4] u8) -> nvg.Color {
return nvg.RGBA(color.r, color.g, color.b, color.a)
}
nvg_color :: proc(color: [4] u8) -> nvg.Color {
return nvg.RGBA(color.r, color.g,
color.b, color.a)
}
Secondly, every shape should start with nvg.BeginPath.
And end with nvg.Stroke to draw the path's outline and/or nvg.Fill to fill the outline.
nvg.ClosePath may also be used, but it is there to connect first and last points.
Moreover, you should just wrap every color, size and alignment change in:
nvg.Save and nvg.Restore.
NanoVG, like OpenGL, is a massive state machine. State machines are hard. You can mitigate some problems by
Restoring the previous state as soon as possible.
3.1. Drawing Text
NanoVG does not include a default font, but loading a custom font is quite easy.
With #load(filepath) → [] byte you can embed files inside the executable and load fonts from memory.
Most software with user input should also have a couple of fallback fonts, like: CJK, math, emoji, etc.
Although, color emoji is not supported by anything. Color emoji is ass... 🥀 💀 -🫃.
init_fonts :: proc() {
nvg.CreateFontMem(window.ctx, "main", #load("libertinus.ttf"), false)
nvg.CreateFontMem(window.ctx, "emoji", #load("noto-emoji.ttf"), false)
// I haven't yet found a single .ttf font with every unicode character
// So, if a font doesn't have an emoji, NanoVG will fall back to Noto Emoji
nvg.AddFallbackFont(window.ctx, "main", "emoji")
// NanoVG uses first loaded font
// or you can set it at the start of every frame:
// NOT HERE: nvg.FontFace(window.ctx, "main")
}
init_fonts :: proc() {
nvg.CreateFontMem(window.ctx, "main", #load("libertinus.ttf"), false)
nvg.CreateFontMem(window.ctx, "emoji", #load("noto-emoji.ttf"), false)
// I haven't yet found a single .ttf font with every unicode character
// So, if the main font does not have an emoji, NanoVG will fall back to Noto Emoji
nvg.AddFallbackFont(window.ctx, "main", "emoji")
// NanoVG uses first loaded font
// or you can set it at the start of every frame:
// NOT HERE: nvg.FontFace(window.ctx, "main")
}
Then, drawing text is as easy as:
text :: proc(text: string, pos: [2] f32, size: f32 = 0, color := [4] u8 {}) -> [2] f32 {
size := size if size != {} else 18
color := color if color != {} else { 211, 224, 221, 255 }
nvg.FillColor(window.ctx, nvg_color(color))
nvg.FontSize(window.ctx, size) // needs to be set every frame
nvg.TextAlignVertical(window.ctx, .TOP) // needs to be set every frame
end := nvg.Text(window.ctx, pos.x, pos.y, text)
return { end - pos.x, size } // <-- approximate
}
text :: proc(text : string,
pos : [2] f32,
size : f32 = 0,
color : [4] u8 = {})
-> [2] f32 {
size := size if size != {} else 18
color := color if color != {} else { 211, 224, 221, 255 }
nvg.FillColor(window.ctx, nvg_color(color))
nvg.FontSize(window.ctx, size) // set every frame
nvg.TextAlignVertical(window.ctx, .TOP) // set every frame
end := nvg.Text(window.ctx, pos.x, pos.y, text)
return { end - pos.x, size } // <-- approx
}
3.2. Lines
MoveTo specifies the starting point, although it is not the same as nvg.Translate, which also sets the matrix rotation center. And all other parts are obvious.
line :: proc(start, end: [2] f32, thickness: f32, color: [4] u8) {
nvg.BeginPath(window.ctx)
nvg.MoveTo(window.ctx, start.x, start.y)
nvg.LineTo(window.ctx, end.x, end.y)
nvg.StrokeWidth(window.ctx, thickness)
nvg.StrokeColor(window.ctx, nvg_color(color))
nvg.Stroke(window.ctx)
}
line :: proc(start, end: [2] f32, thickness: f32, color: [4] u8) {
nvg.BeginPath(window.ctx)
nvg.MoveTo(window.ctx, start.x, start.y)
nvg.LineTo(window.ctx, end.x, end.y)
nvg.StrokeWidth(window.ctx, thickness)
nvg.StrokeColor(window.ctx, nvg_color(color))
nvg.Stroke(window.ctx)
}
3.3. Filled Shapes
These work, basically, the same way as lines, except FillColor & Fill may be used...
rect :: proc(pos, size: [2] f32, color: [4] u8) {
nvg.BeginPath(window.ctx)
nvg.Rect(window.ctx, pos.x, pos.y, size.x, size.y)
nvg.FillColor(window.ctx, nvg_color(color))
nvg.Fill(window.ctx)
}
rect :: proc(pos, size: [2] f32, color: [4] u8) {
nvg.BeginPath(window.ctx)
nvg.Rect(window.ctx, pos.x, pos.y, size.x, size.y)
nvg.FillColor(window.ctx, nvg_color(color))
nvg.Fill(window.ctx)
}
Rect, RoundedRect, Ellipse, Circle, ClosePath, Arc and, probably, more...
3.4. Miscellaneous Concepts
GUIs that have scrollable content or text boxes will almost certainly have to clip
the content that goes outside of the box. This can be done with nvg.Scissor.
text_input :: proc(state: TextInput, pos, size: [2] f32) {
// draw background rectangle
// draw border
nvg.Scissor(window.ctx, pos.x - 1, pos.y, size.x + 2, size.y)
// draw text
nvg.ResetScissor(window.ctx)
// draw cursor if text input has focus
}
text_input :: proc(state: TextInput, pos, size: [2] f32) {
// draw background rectangle
// draw border
nvg.Scissor(window.ctx, pos.x - 1, pos.y, size.x + 2, size.y)
// draw text
nvg.ResetScissor(window.ctx)
// draw cursor if text input has focus
}
Another concept you might occasionally need is transform matrices.
NanoVG has: Rotate, Translate, Scale, SkewX and SkewY to set up a matrix
and ResetTransform to clean up then and there (instead of at the end of the frame).
Just be aware that nvg.Rotate rotates neither around the shape's center, nor it's top-left corner. Instead, it rotates around
the top-left corner of the window (the coordinate {0, 0}). To rotate the shape around another origin, use nvg.Translate.
PI :: 3.14159 // better version in core:math
size := [2] f32 { 32, 32 }
pos := window.size / 4
// rotate rectangle around top-left corner
nvg.Translate(window.ctx, pos.x, pos.y)
nvg.Rotate(window.ctx, PI/4)
rect({ 0, 0 }, size, { 192, 192, 63, 255 })
nvg.ResetTransform(window.ctx)
pos.x += window.size.x / 4
// rotate rectangle around its center
nvg.Translate(window.ctx, pos.x, pos.y)
nvg.Rotate(window.ctx, PI/8)
rect(-size/2, size, { 63, 192, 63, 255 })
nvg.ResetTransform(window.ctx)
PI :: 3.14159 // better version in core:math
size := [2] f32 { 32, 32 }
pos := window.size / 4
// rotate rectangle around top-left corner
nvg.Translate(window.ctx, pos.x, pos.y)
nvg.Rotate(window.ctx, PI/4)
rect({ 0, 0 }, size, { 192, 192, 63, 255 })
nvg.ResetTransform(window.ctx)
pos.x += window.size.x / 4
// rotate rectangle around its center
nvg.Translate(window.ctx, pos.x, pos.y)
nvg.Rotate(window.ctx, PI/8)
rect(-size/2, size, { 63, 192, 63, 255 })
nvg.ResetTransform(window.ctx)
4. Extras
- The full source code can be found at codeberg.org/ulti/nanovg-odin
- Project that uses NanoVG with Raylib and implements a text input, scrollbar and more (but has messy code) can be found at codeberg.org/ulti/utxt
- Older project that supports multiple windows can be found at codeberg.org/ulti/frigg
- Obviously, NanoVG source code can be found at github.com/memononen/nanovg