2026, Jan 06 07:00

Fixing pyqtgraph GLTextItem 3D text orientation: why it faces the camera and a PIL + GLImageItem workaround

Learn why pyqtgraph GLTextItem 3D text faces the camera and how to lock orientation by rasterizing to an image and using GLImageItem. Includes code examples.

Pinning 3D text to a fixed orientation in pyqtgraph can be unexpectedly tricky. If you place a GLTextItem in a GLViewWidget, it behaves like a billboard: the glyphs always face the camera. You can lock the apparent size by reacting to pixel size changes, but stopping that face-the-camera effect is not supported out of the box.

Reproducing the issue

The example below fixes text size relative to the viewport and attempts to keep a constant rotation. Despite forcing a transform every frame, the text keeps rotating toward the camera.

import numpy as np
from PySide6 import QtWidgets, QtGui
import pyqtgraph as pg
from pyqtgraph.opengl import GLViewWidget, GLTextItem


def rot_euler(yaw_deg, pitch_deg):
    ang_a, ang_e = np.radians([yaw_deg, pitch_deg])
    ca, sa = np.cos(ang_a), np.sin(ang_a)
    ce, se = np.cos(ang_e), np.sin(ang_e)
    Rz_m = np.array([[ ca, -sa, 0],
                     [ sa,  ca, 0],
                     [  0,   0, 1]])
    Rx_m = np.array([[1,  0,   0],
                     [0, ce, -se],
                     [0, se,  ce]])
    return Rx_m @ Rz_m


class LockedText3D(GLTextItem):
    def __init__(self, *args, worldScaleHeight=0.1, **kwargs):
        super().__init__(*args, **kwargs)
        self.worldScaleHeight = worldScaleHeight
        mat4 = np.eye(4)
        mat4[:3, :3] = rot_euler(0, 0)
        self._lockedTf = mat4
        self.setTransform(mat4)

    def _apply_locked_rotation(self):
        self.setTransform(self._lockedTf)

    def _sync_text_pixels(self):
        units_per_px = self.view().pixelSize(np.array(self.pos))
        px_h = max(1, int(self.worldScaleHeight / units_per_px))
        qf = QtGui.QFont(self.font)
        qf.setPixelSize(px_h)
        self.font = qf

    def paint(self):
        self._apply_locked_rotation()
        self._sync_text_pixels()
        super().paint()


class DemoViewport3D(GLViewWidget):
    def __init__(self):
        super().__init__()
        self.lbl = LockedText3D(pos=(0, 0, 0), text="text item", worldScaleHeight=1)
        self.addItem(self.lbl)
        self.addItem(pg.opengl.GLAxisItem())
        self.setCameraPosition(distance=5)


if __name__ == "__main__":
    app = QtWidgets.QApplication([])
    win = DemoViewport3D()
    win.show()
    app.exec()

What’s actually going on

GLTextItem is rendered with a built-in billboard behavior, so it keeps facing the camera as the view changes. While the size can be corrected using pixelSize and a per-frame font update, there is no built-in way to disable that face-the-camera effect for GLTextItem in pyqtgraph.opengl. This is also a consequence of how pyqtgraph implements and wraps Qt-side pieces: some expectations don’t translate one-to-one. In addition, pyqtgraph.opengl is independent of Qt3D, so relying on Qt3D to rotate 3D text won’t help when you stay in pyqtgraph’s OpenGL stack.

Practical workaround

A reliable workaround is to rasterize the text into an image first, then draw that image as a GLImageItem. Because you control the image as a regular 3D object, you can orient it freely and prevent it from facing the camera.

import numpy as np
import pyqtgraph.opengl as gl
from PySide6.QtWidgets import QApplication
from PIL import Image, ImageDraw, ImageFont
import sys


def text_to_rgba_ndarray(s, ttf_path="arial.ttf", pt=70, img_wh=(400, 100), bg_rgba=(0, 0, 0, 0), fg_rgba=(255, 255, 255, 255)):
    """
    Create an RGBA image buffer for the given string.

    s (str): text to render
    ttf_path (str): path to a .ttf file
    pt (int): font size
    img_wh (tuple): (width, height) of the output image
    bg_rgba (tuple): background color (R, G, B, A)
    fg_rgba (tuple): text color (R, G, B, A)

    Returns:
        numpy.ndarray: H x W x 4 RGBA array
    """
    canvas = Image.new("RGBA", img_wh, bg_rgba)
    painter = ImageDraw.Draw(canvas)
    face = ImageFont.truetype(ttf_path, pt)
    tw, th = painter.textbbox((0, 0), s, font=face)[2:]
    x0 = (img_wh[0] - tw) // 2
    y0 = (img_wh[1] - th) // 2
    painter.text((x0, y0), s, font=face, fill=fg_rgba)
    arr = np.array(canvas)
    return arr


app = QApplication(sys.argv)

view3d = gl.GLViewWidget()
view3d.setGeometry(0, 0, 800, 600)
view3d.show()

mesh_grid = gl.GLGridItem()
mesh_grid.setSize(10, 10)
mesh_grid.setSpacing(1, 1)
view3d.addItem(mesh_grid)

caption = "Hello, World!"
rgba_buf = text_to_rgba_ndarray(caption)

sprite = gl.GLImageItem(rgba_buf)
scale_k = 0.01
sprite.scale(scale_k, scale_k, scale_k)
sprite.rotate(90, 0, 1, 0)
sprite.translate(0, 0, 2)
view3d.addItem(sprite)

if __name__ == "__main__":
    sys.exit(app.exec())

How this works

The code creates a GLViewWidget, adds a grid for orientation and converts the text into an RGBA image using PIL. The resulting numpy array is displayed via GLImageItem. Because it’s a regular drawable in the scene, it can be rotated and translated like any other object, so it no longer follows the camera. The small uniform scale prevents the image from dominating the scene.

Tuning and trade-offs

To improve sharpness, increase the font size and width of the generated image so the entire text fits; if the image becomes larger, you can compensate by adjusting the scale factor. Position and orientation are controlled by rotate and translate. You can pass custom colors and a path to a .ttf font file if you need different styling. Note that if your use case requires very frequent text updates, drawing a new image each time might be heavy; plan accordingly.

Why this matters

Labels that don’t auto-face the camera are useful whenever you want text to behave like a true 3D asset. Understanding that GLTextItem is billboarded by design avoids chasing a setting that doesn’t exist in pyqtgraph.opengl and helps you choose a viable path early, whether that’s sticking with pyqtgraph and rasterized text or exploring different stacks where text rendering is modeled differently.

Takeaways

GLTextItem always faces the camera in pyqtgraph.opengl, and there is no built-in toggle to change that. If you need fixed-orientation text within pyqtgraph’s 3D view, render your text to an image and display it as a GLImageItem to gain full control over rotation and placement. When fidelity is a priority, increase the rendered font size and image width, then scale the image down in the scene. If updates are frequent, consider the cost of regenerating the image and optimize around that constraint.