gpu-drawing-and-overlays · git:20261012.83b09cf · 2026-10-12 · sha256 4b3cb2b2a9dde524

gpu-drawing-and-overlays git:20261012.83b09cfB

Immutable. This exact content never changes and is served at /api/v1/blob/4b3cb2b2a9dde524.

---
name: gpu-drawing-and-overlays
description: "Draw viewport overlays and offscreen images with the gpu module instead of the removed bgl: SpaceView3D.draw_handler_add/remove symmetry, gpu.shader.from_builtin names, gpu_extras.batch.batch_for_shader, gpu.state for blending, depth and line width (POLYLINE shaders), blf text, tag_redraw, and GPUOffScreen with gpu.init() for --background on 5.2. Use when the user wants a viewport overlay, measurement readout, guide lines, debug visualization or an offscreen render from Python, or hits ModuleNotFoundError: No module named 'bgl' or 'GPU functions for drawing are not available in background mode'. Targets 5.2 LTS with 4.5 LTS fallback."
standards-version: 1.10.0
---

# GPU Drawing and Overlays

## Trigger

Use this skill when the user:

- Wants to draw lines, points, shapes or text over the 3D viewport or an image editor from an add-on
- Has add-on code with `import bgl`, `bgl.glEnable`, `bgl.glLineWidth`, or gets `ModuleNotFoundError: No module named 'bgl'` on 5.x
- Wants to render something with the GPU into an image from a script, including headless
- Sees `SystemError: GPU functions for drawing are not available in background mode`

## `bgl` is gone

`bgl` wrapped raw OpenGL calls, which Blender's Metal and Vulkan backends cannot honour. It is deprecated on 4.5 LTS and removed in 5.x. Measured: `importlib.util.find_spec('bgl')` is `None` on 5.0.1, 5.1.2 and 5.2.1, and still present on 4.5.14. Code that imports it does not register on 5.x at all. Everything below uses `gpu`, `gpu_extras` and `blf`, which work on both 4.5 and 5.x, so no version branch is needed for drawing itself. The `no-bgl` rule flags leftovers.

## The overlay pattern: register, draw, unregister

```python
import bpy
import gpu
from gpu_extras.batch import batch_for_shader

_handle = None


def draw_guides():
    obj = bpy.context.active_object
    if obj is None:
        return
    shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
    origin = obj.matrix_world.translation
    tip = origin + obj.matrix_world.to_3x3().col[2].normalized()
    batch = batch_for_shader(shader, "LINES", {"pos": [origin, tip]})
    region = bpy.context.region
    shader.uniform_float("viewportSize", (region.width, region.height))
    shader.uniform_float("lineWidth", 3.0)
    shader.uniform_float("color", (1.0, 0.6, 0.1, 0.9))
    gpu.state.blend_set("ALPHA")
    gpu.state.depth_test_set("LESS_EQUAL")
    batch.draw(shader)
    gpu.state.depth_test_set("NONE")
    gpu.state.blend_set("NONE")


def register():
    global _handle
    _handle = bpy.types.SpaceView3D.draw_handler_add(draw_guides, (), "WINDOW", "POST_VIEW")


def unregister():
    global _handle
    if _handle is not None:
        bpy.types.SpaceView3D.draw_handler_remove(_handle, "WINDOW")
        _handle = None
```

- **Keep the handle.** `draw_handler_add` returns it; `draw_handler_remove(handle, region_type)` needs it. A handler added without storing the handle cannot be removed, and it keeps drawing after the add-on is disabled. On re-enable you get two.
- **Remove in `unregister()`,** symmetric with `register()` (see `addon-scaffolding`).
- **`args` is a tuple** passed to the callback: `(self, context)` from a modal operator, `()` for a module-level function.
- **Draw types.** `POST_VIEW` draws in 3D world space under the view matrices (positions are world coordinates). `POST_PIXEL` draws in region pixels with an orthographic matrix (positions are `(x, y)` in pixels, origin bottom-left), which is where text and HUD panels go. `PRE_VIEW` draws under the scene.
- **The callback runs on every redraw** of every 3D viewport. Keep it cheap: build batches from cached data, not by walking `bpy.data`. Guard `context.active_object` against `None` (see `type-annotate-props-and-defend-context`).
- **Nothing redraws by itself.** After your data changes, call `area.tag_redraw()` for the areas that show it, for example from a timer or a depsgraph handler (see `drivers-and-app-handlers`, `timers-modal-and-threading`).

## Builtin shaders

`gpu.shader.from_builtin(name)` takes one of these names on both 4.5 LTS and 5.2:

| Name | Attributes | Uniforms |
| --- | --- | --- |
| `UNIFORM_COLOR` | `pos` | `color` |
| `FLAT_COLOR`, `SMOOTH_COLOR` | `pos`, `color` | — |
| `IMAGE` | `pos`, `texCoord` | `image` |
| `IMAGE_COLOR` | `pos`, `texCoord` | `image`, `color` |
| `POLYLINE_UNIFORM_COLOR` | `pos` | `viewportSize`, `lineWidth`, `color` |
| `POLYLINE_FLAT_COLOR`, `POLYLINE_SMOOTH_COLOR` | `pos`, `color` | `viewportSize`, `lineWidth` |
| `POINT_UNIFORM_COLOR` | `pos` | `color`, `size` |
| `POINT_FLAT_COLOR` | `pos`, `color` | `size` |

5.2 adds `IMAGE_SCENE_LINEAR_TO_REC709_SRGB` and `IMAGE_COLOR_SCENE_LINEAR_TO_REC709_SRGB` for scene-linear images. The `2D_` / `3D_` prefixed names from 2.8–3.x (`2D_UNIFORM_COLOR`, `3D_SMOOTH_COLOR`) are rejected on 5.2: `ValueError: expected a string in ('FLAT_COLOR', 'IMAGE', ...)`. One shader handles 2D and 3D: pass 2-component or 3-component `pos`.

## `gpu.state` replaces the `bgl` state calls

| `bgl` | `gpu` |
| --- | --- |
| `glEnable(GL_BLEND)` / `glDisable(GL_BLEND)` | `gpu.state.blend_set('ALPHA')` / `blend_set('NONE')` |
| `glLineWidth(w)` | a `POLYLINE_*` shader's `lineWidth` and `viewportSize` uniforms |
| `glPointSize(s)` | `POINT_*` shader `size` uniform, or `gpu.state.point_size_set(s)` |
| `glEnable(GL_DEPTH_TEST)`, `glDepthFunc` | `gpu.state.depth_test_set('LESS_EQUAL')`; `'NONE'` to disable |
| `glDepthMask` | `gpu.state.depth_mask_set(True)` |
| `glReadPixels` | `gpu.state.active_framebuffer_get().read_color(x, y, w, h, 4, 0, 'FLOAT')` |
| `bgl.Buffer` | `gpu.types.Buffer` |

Two traps:

- **Blending is off by default.** Measured in [`examples/gpu-offscreen-draw`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/gpu-offscreen-draw): red at alpha 0.5 over opaque blue gives the mix (0.498, 0, 0.502) with `blend_set('ALPHA')`, and overwrites the target with (1, 0, 0, 0.498) without it. An overlay that "ignores alpha" is missing this call.
- **Wide lines need a `POLYLINE_*` shader.** `lineWidth` 6 drew a 7 px line (one antialiased edge pixel); `lineWidth` 1 drew 2 px. `gpu.state.line_width_set` with a non-polyline shader depends on the backend, because core OpenGL, Metal and Vulkan do not guarantee wide-line rasterization. `POLYLINE_*` builds the width in geometry, so it looks the same everywhere. It needs `viewportSize` in pixels: `(region.width, region.height)` in a draw callback, or the offscreen size.

Restore every state you set before returning. Draw callbacks share GPU state with the rest of the editor, so a leftover `blend_set('ALPHA')` or depth test changes how the next overlay draws.

## Text with `blf`

```python
import blf

def draw_label():          # register with draw type "POST_PIXEL"
    font_id = 0            # the default UI font
    blf.size(font_id, 16)  # 4.0+: (fontid, size); the dpi argument is gone
    blf.color(font_id, 1.0, 1.0, 1.0, 1.0)
    blf.position(font_id, 20, 30, 0)   # region pixels, origin bottom-left
    blf.draw(font_id, "Edges: 128")
```

`blf.size(font_id, size, dpi)` from 3.x raises a `TypeError` on 4.0 and later. Measure text with `blf.dimensions(font_id, text)` before laying it out.

## Offscreen: drawing into an image, also headless

```python
import gpu
from gpu_extras.batch import batch_for_shader

if bpy.app.version >= (5, 2, 0):
    gpu.init()          # 5.2+: makes drawing available under --background

off = gpu.types.GPUOffScreen(256, 256)
try:
    with off.bind():
        fb = gpu.state.active_framebuffer_get()
        fb.clear(color=(0.0, 0.0, 0.0, 0.0))
        gpu.matrix.load_identity()          # NDC: -1..1 covers the image
        shader = gpu.shader.from_builtin("UNIFORM_COLOR")
        batch = batch_for_shader(shader, "TRI_FAN",
                                 {"pos": [(-0.5, -0.5), (0.5, -0.5), (0.5, 0.5), (-0.5, 0.5)]})
        shader.uniform_float("color", (1.0, 0.0, 0.0, 1.0))
        batch.draw(shader)
        buf = fb.read_color(0, 0, 256, 256, 4, 0, "FLOAT")
finally:
    off.free()
```

- **Headless drawing is new in 5.2.** `gpu.init()` "initializes the GPU module for background use". On 5.1, 5.0 and 4.5 LTS there is no `gpu.init`, and any draw under `--background` raises `SystemError: GPU functions for drawing are not available in background mode`. On those versions, offscreen drawing works only in a session with a window.
- **Let go of GPU objects before exit.** A 5.2.1 probe that called `sys.exit` at module level while its batch, shader and framebuffer were still referenced crashed Blender on shutdown (`EXCEPTION_ACCESS_VIOLATION`, exit 5). The same drawing done inside a function, so those references were gone on return, exited 0. Free the offscreen in a `finally`, and keep the GPU objects local.
- To save the result as an image, copy `buf` into an `Image`'s `pixels` with `foreach_set` (see `use-foreach-set-for-bulk-data`).
- To draw the scene itself into an offscreen in a UI session, use `off.draw_view3d(scene, view_layer, view3d, region, view_matrix, projection_matrix)`.

## Common AI mistakes

| Mistake | Consequence | Do instead |
| --- | --- | --- |
| `import bgl` | Import fails on 5.x; the add-on never registers | `gpu.state`, `POLYLINE_*` shaders |
| `from_builtin('3D_UNIFORM_COLOR')` | `ValueError` on 5.2 | `UNIFORM_COLOR` |
| Not storing the `draw_handler_add` handle | Overlay keeps drawing after disable; doubles on re-enable | Store it, remove it in `unregister()` |
| Expecting alpha without `blend_set` | Transparent colours draw opaque-over | `gpu.state.blend_set('ALPHA')`, then `'NONE'` |
| `line_width_set` for thick lines | Thin lines on core-profile backends | `POLYLINE_*` with `lineWidth` |
| `blf.size(id, size, 72)` | `TypeError` on 4.0+ | `blf.size(id, size)` |
| Drawing under `--background` on 4.5 / 5.1 | `SystemError` | Run with a window, or require 5.2 and call `gpu.init()` |

<!-- examples:begin (generated by scripts/build_examples_index.py from examples/skills.json; do not edit) -->
## Runnable examples

Each example runs headless, asserts the contract, and exits non-zero when it breaks. Run one with `blender --background --python <script> --`; pass a falsifier flag to watch the check fail.

- [`gpu-offscreen-draw`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/gpu-offscreen-draw): Check-only witness for drawing with the `gpu` module, the replacement for the `bgl` module that 5.x removed. Blender 5.2+. Falsify: `--skip-blend` (exit 5).

<!-- examples:end -->

## References

- `gpu`: https://docs.blender.org/api/current/gpu.html ([4.5 LTS](https://docs.blender.org/api/4.5/gpu.html))
- `gpu.shader`: https://docs.blender.org/api/current/gpu.shader.html ([4.5 LTS](https://docs.blender.org/api/4.5/gpu.shader.html))
- `gpu.state`: https://docs.blender.org/api/current/gpu.state.html
- `gpu_extras.batch`: https://docs.blender.org/api/current/gpu_extras.batch.html
- `blf`: https://docs.blender.org/api/current/blf.html
- `SpaceView3D.draw_handler_add`: https://docs.blender.org/api/current/bpy.types.Space.html#bpy.types.Space.draw_handler_add