diff --git a/Brillo/Internals/Data/Picture.hs b/Brillo/Internals/Data/Picture.hs
--- a/Brillo/Internals/Data/Picture.hs
+++ b/Brillo/Internals/Data/Picture.hs
@@ -9,18 +9,29 @@
   Path,
   Picture (..),
 
+  -- * Shaders
+  UniformValue (..),
+  ShaderData (..),
+  defaultShaderVertex,
+  shader,
+
   -- * Bitmaps
   Rectangle (..),
   BitmapData (..),
   PixelFormat (..),
   BitmapFormat (..),
   RowOrder (..),
+  CompressedFormat (..),
   bitmapOfForeignPtr,
   bitmapDataOfForeignPtr,
   bitmapOfByteString,
   bitmapDataOfByteString,
   bitmapOfBMP,
   bitmapDataOfBMP,
+  compressedBitmapOfForeignPtr,
+  compressedBitmapDataOfForeignPtr,
+  compressedBitmapOfByteString,
+  compressedBitmapDataOfByteString,
   loadBMP,
   rectAtOrigin,
 )
@@ -30,15 +41,17 @@
 import Brillo.Internals.Rendering.Bitmap (
   BitmapData (..),
   BitmapFormat (..),
+  CompressedFormat (..),
   PixelFormat (..),
   Rectangle (..),
   RowOrder (..),
  )
 import Codec.BMP (BMP, bmpDimensions, readBMP, unpackBMPToRGBA32)
 import Data.ByteString (ByteString)
+import Data.ByteString qualified as BS
 import Data.ByteString.Unsafe qualified as BSU
 import Data.Data (Data, Typeable)
-import Data.Text (Text)
+import Data.Text (Text, pack)
 import Data.Word (Word8)
 import Foreign.ForeignPtr (ForeignPtr, newForeignPtr)
 import Foreign.Marshal.Alloc (finalizerFree, mallocBytes)
@@ -66,6 +79,27 @@
 type Path = [Point]
 
 
+-- | A value that can be passed as a uniform to a custom shader.
+data UniformValue
+  = UniformFloat !Float
+  | UniformVec2 !Float !Float
+  | UniformVec3 !Float !Float !Float
+  | UniformVec4 !Float !Float !Float !Float
+  | UniformInt !Int
+  deriving (Show, Eq, Data, Typeable)
+
+
+-- | Data needed to render a custom shader on a quad.
+data ShaderData = ShaderData
+  { shaderVertex :: !Text
+  , shaderFragment :: !Text
+  , shaderUniforms :: ![(Text, UniformValue)]
+  , shaderWidth :: !Float
+  , shaderHeight :: !Float
+  }
+  deriving (Show, Eq, Data, Typeable)
+
+
 -- | A 2D picture
 data Picture
   = -- Primitives -------------------------------------
@@ -145,6 +179,13 @@
     Rotate Float Picture
   | -- | A picture scaled by the given x and y factors.
     Scale Float Float Picture
+  | -- Shaders ----------------------------------------
+
+    {-| A picture rendered by a custom GLSL shader on a quad.
+    The quad is centered at the origin with the dimensions from 'ShaderData'.
+    Use the 'shader' convenience function to create one with the default vertex shader.
+    -}
+    Shader ShaderData
   | -- More Pictures ----------------------------------
 
     -- | A picture consisting of several others.
@@ -209,16 +250,10 @@
 bitmapDataOfByteString ::
   Int -> Int -> BitmapFormat -> ByteString -> Bool -> BitmapData
 bitmapDataOfByteString width height fmt bs cacheMe =
-  unsafePerformIO $
-    do
-      let len = width * height * 4
-      ptr <- mallocBytes len
-      fptr <- newForeignPtr finalizerFree ptr
-
-      BSU.unsafeUseAsCString bs $
-        \cstr -> copyBytes ptr (castPtr cstr) len
-
-      return $ BitmapData len fmt (width, height) cacheMe fptr
+  unsafePerformIO $ do
+    let len = width * height * 4
+    fptr <- copyByteStringToFPtr len bs
+    return $ BitmapData len fmt (width, height) cacheMe fptr
 {-# NOINLINE bitmapDataOfByteString #-}
 
 
@@ -231,23 +266,100 @@
 -- | O(size). Copy a `BMP` file into a bitmap.
 bitmapDataOfBMP :: BMP -> BitmapData
 bitmapDataOfBMP bmp =
-  unsafePerformIO $
-    do
-      let (width, height) = bmpDimensions bmp
-      let bs = unpackBMPToRGBA32 bmp
-      let len = width * height * 4
+  unsafePerformIO $ do
+    let (width, height) = bmpDimensions bmp
+        len = width * height * 4
+    fptr <- copyByteStringToFPtr len (unpackBMPToRGBA32 bmp)
+    return $
+      BitmapData len (BitmapFormat BottomToTop PxRGBA) (width, height) True fptr
+{-# NOINLINE bitmapDataOfBMP #-}
 
-      ptr <- mallocBytes len
-      fptr <- newForeignPtr finalizerFree ptr
 
-      BSU.unsafeUseAsCString bs $
-        \cstr -> copyBytes ptr (castPtr cstr) len
+-- | Copy @len@ bytes from a `ByteString` into a freshly allocated `ForeignPtr`.
+copyByteStringToFPtr :: Int -> ByteString -> IO (ForeignPtr Word8)
+copyByteStringToFPtr len bs = do
+  ptr <- mallocBytes len
+  fptr <- newForeignPtr finalizerFree ptr
+  BSU.unsafeUseAsCString bs $ \cstr -> copyBytes ptr (castPtr cstr) len
+  pure fptr
 
-      return $
-        BitmapData len (BitmapFormat BottomToTop PxRGBA) (width, height) True fptr
-{-# NOINLINE bitmapDataOfBMP #-}
 
+{-| O(1). Use a `ForeignPtr` of GPU-compressed texel data as a bitmap.
 
+  The image is uploaded to the GPU as-is using
+  `glCompressedTexImage2D`. The byte length is the exact size of the
+  compressed payload, and @width@ / @height@ must match the dimensions
+  of the decoded image. The chosen `CompressedFormat` must be supported
+  by the running OpenGL implementation; query
+  @Graphics.Rendering.OpenGL.GL.Texturing.Specification.compressedTextureFormats@
+  at runtime if you are not sure.
+
+  The boolean flag controls whether Brillo should cache the data
+  between frames for speed. If you are programatically generating
+  the image for each frame then use `False`. If you have loaded it
+  from a file then use `True`.
+-}
+compressedBitmapOfForeignPtr ::
+  Int ->
+  Int ->
+  RowOrder ->
+  CompressedFormat ->
+  Int ->
+  ForeignPtr Word8 ->
+  Bool ->
+  Picture
+compressedBitmapOfForeignPtr w h ro cf byteLen fptr cacheMe =
+  Bitmap $
+    compressedBitmapDataOfForeignPtr w h ro cf byteLen fptr cacheMe
+
+
+compressedBitmapDataOfForeignPtr ::
+  Int ->
+  Int ->
+  RowOrder ->
+  CompressedFormat ->
+  Int ->
+  ForeignPtr Word8 ->
+  Bool ->
+  BitmapData
+compressedBitmapDataOfForeignPtr w h ro cf byteLen fptr cacheMe =
+  BitmapData byteLen (BitmapFormat ro (PxCompressed cf)) (w, h) cacheMe fptr
+
+
+{-| O(size). Copy a `ByteString` of GPU-compressed texel data into a bitmap.
+
+  See `compressedBitmapOfForeignPtr` for the constraints on the inputs.
+-}
+compressedBitmapOfByteString ::
+  Int ->
+  Int ->
+  RowOrder ->
+  CompressedFormat ->
+  ByteString ->
+  Bool ->
+  Picture
+compressedBitmapOfByteString w h ro cf bs cacheMe =
+  Bitmap $
+    compressedBitmapDataOfByteString w h ro cf bs cacheMe
+
+
+compressedBitmapDataOfByteString ::
+  Int ->
+  Int ->
+  RowOrder ->
+  CompressedFormat ->
+  ByteString ->
+  Bool ->
+  BitmapData
+compressedBitmapDataOfByteString w h ro cf bs cacheMe =
+  unsafePerformIO $ do
+    let byteLen = BS.length bs
+    fptr <- copyByteStringToFPtr byteLen bs
+    return $
+      BitmapData byteLen (BitmapFormat ro (PxCompressed cf)) (w, h) cacheMe fptr
+{-# NOINLINE compressedBitmapDataOfByteString #-}
+
+
 -- | Load an uncompressed 24 or 32bit RGBA BMP file as a bitmap.
 loadBMP :: FilePath -> IO Picture
 loadBMP filePath =
@@ -263,3 +375,46 @@
 -}
 rectAtOrigin :: Int -> Int -> Rectangle
 rectAtOrigin w h = Rectangle (0, 0) (w, h)
+
+
+-- Shaders -------------------------------------------------------------------
+
+{-| The default vertex shader used for custom shaders.
+Passes through vertex positions via the model-view-projection matrix
+and forwards texture coordinates as @vLocalCoord@.
+-}
+defaultShaderVertex :: Text
+defaultShaderVertex =
+  pack $
+    unlines
+      [ "#version 120"
+      , "varying vec2 vLocalCoord;"
+      , "void main() {"
+      , "  vLocalCoord = gl_MultiTexCoord0.xy;"
+      , "  gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;"
+      , "  gl_FrontColor = gl_Color;"
+      , "}"
+      ]
+
+
+{-| Create a 'Picture' that renders a custom GLSL fragment shader on a quad
+of the given width and height, centered at the origin.
+
+The fragment shader receives:
+
+* @vLocalCoord@ (vec2): local coordinates from @(-w\/2, -h\/2)@ to @(w\/2, h\/2)@
+* @gl_Color@: the current Brillo color (set via the 'Color' picture combinator)
+* Any custom uniforms passed in the list
+
+Uses the built-in vertex shader ('defaultShaderVertex').
+-}
+shader :: Float -> Float -> Text -> [(Text, UniformValue)] -> Picture
+shader w h frag uniforms =
+  Shader
+    ShaderData
+      { shaderVertex = defaultShaderVertex
+      , shaderFragment = frag
+      , shaderUniforms = uniforms
+      , shaderWidth = w
+      , shaderHeight = h
+      }
diff --git a/Brillo/Internals/Rendering/Bitmap.hs b/Brillo/Internals/Rendering/Bitmap.hs
--- a/Brillo/Internals/Rendering/Bitmap.hs
+++ b/Brillo/Internals/Rendering/Bitmap.hs
@@ -8,12 +8,31 @@
   BitmapFormat (..),
   PixelFormat (..),
   RowOrder (..),
+  CompressedFormat (..),
+  -- Common compressed format constants
+  compressedRGB_S3TC_DXT1,
+  compressedRGBA_S3TC_DXT1,
+  compressedRGBA_S3TC_DXT3,
+  compressedRGBA_S3TC_DXT5,
+  compressedSRGB_S3TC_DXT1,
+  compressedSRGB_ALPHA_S3TC_DXT1,
+  compressedSRGB_ALPHA_S3TC_DXT3,
+  compressedSRGB_ALPHA_S3TC_DXT5,
+  compressedRED_RGTC1,
+  compressedSIGNED_RED_RGTC1,
+  compressedRG_RGTC2,
+  compressedSIGNED_RG_RGTC2,
+  compressedRGBA_BPTC_UNORM,
+  compressedSRGB_ALPHA_BPTC_UNORM,
+  compressedRGB_BPTC_SIGNED_FLOAT,
+  compressedRGB_BPTC_UNSIGNED_FLOAT,
   bitmapPath,
   freeBitmapData,
 )
 where
 
 import Data.Data (Data, Typeable)
+import Data.Word (Word32)
 import Foreign (ForeignPtr, Ptr, Word8, free)
 
 
@@ -64,11 +83,101 @@
   deriving (Eq, Data, Typeable, Show, Ord, Enum, Bounded)
 
 
--- | Pixel formats describe the order of the color channels in memory.
+{-| Pixel formats describe how the texel data is laid out in memory.
+
+  `PxRGBA` and `PxABGR` are uncompressed 32-bit-per-pixel formats.
+
+  `PxCompressed` carries a hardware compressed format token that the GPU
+  decodes on its own. The bitmap's `bitmapDataLength` field must hold the
+  exact size in bytes of the compressed payload, since the size cannot be
+  derived from width and height alone.
+-}
 data PixelFormat
   = PxRGBA
   | PxABGR
-  deriving (Eq, Data, Typeable, Show, Ord, Enum, Bounded)
+  | PxCompressed CompressedFormat
+  deriving (Eq, Data, Typeable, Show, Ord)
+
+
+{-| Identifier of a GPU-side compressed texture format.
+
+  The wrapped value is the OpenGL @internalformat@ enum (for example
+  @0x83F1@ for @GL_COMPRESSED_RGBA_S3TC_DXT1_EXT@). Use the constants
+  exported alongside this type, or supply an enum from
+  "Graphics.Rendering.OpenGL.Raw" directly.
+-}
+newtype CompressedFormat = CompressedFormat Word32
+  deriving (Eq, Data, Typeable, Show, Ord)
+
+
+-- S3TC / DXT --------------------------------------------------------------
+
+compressedRGB_S3TC_DXT1 :: CompressedFormat
+compressedRGB_S3TC_DXT1 = CompressedFormat 0x83F0
+
+
+compressedRGBA_S3TC_DXT1 :: CompressedFormat
+compressedRGBA_S3TC_DXT1 = CompressedFormat 0x83F1
+
+
+compressedRGBA_S3TC_DXT3 :: CompressedFormat
+compressedRGBA_S3TC_DXT3 = CompressedFormat 0x83F2
+
+
+compressedRGBA_S3TC_DXT5 :: CompressedFormat
+compressedRGBA_S3TC_DXT5 = CompressedFormat 0x83F3
+
+
+compressedSRGB_S3TC_DXT1 :: CompressedFormat
+compressedSRGB_S3TC_DXT1 = CompressedFormat 0x8C4C
+
+
+compressedSRGB_ALPHA_S3TC_DXT1 :: CompressedFormat
+compressedSRGB_ALPHA_S3TC_DXT1 = CompressedFormat 0x8C4D
+
+
+compressedSRGB_ALPHA_S3TC_DXT3 :: CompressedFormat
+compressedSRGB_ALPHA_S3TC_DXT3 = CompressedFormat 0x8C4E
+
+
+compressedSRGB_ALPHA_S3TC_DXT5 :: CompressedFormat
+compressedSRGB_ALPHA_S3TC_DXT5 = CompressedFormat 0x8C4F
+
+
+-- RGTC --------------------------------------------------------------------
+
+compressedRED_RGTC1 :: CompressedFormat
+compressedRED_RGTC1 = CompressedFormat 0x8DBB
+
+
+compressedSIGNED_RED_RGTC1 :: CompressedFormat
+compressedSIGNED_RED_RGTC1 = CompressedFormat 0x8DBC
+
+
+compressedRG_RGTC2 :: CompressedFormat
+compressedRG_RGTC2 = CompressedFormat 0x8DBD
+
+
+compressedSIGNED_RG_RGTC2 :: CompressedFormat
+compressedSIGNED_RG_RGTC2 = CompressedFormat 0x8DBE
+
+
+-- BPTC --------------------------------------------------------------------
+
+compressedRGBA_BPTC_UNORM :: CompressedFormat
+compressedRGBA_BPTC_UNORM = CompressedFormat 0x8E8C
+
+
+compressedSRGB_ALPHA_BPTC_UNORM :: CompressedFormat
+compressedSRGB_ALPHA_BPTC_UNORM = CompressedFormat 0x8E8D
+
+
+compressedRGB_BPTC_SIGNED_FLOAT :: CompressedFormat
+compressedRGB_BPTC_SIGNED_FLOAT = CompressedFormat 0x8E8E
+
+
+compressedRGB_BPTC_UNSIGNED_FLOAT :: CompressedFormat
+compressedRGB_BPTC_UNSIGNED_FLOAT = CompressedFormat 0x8E8F
 
 
 instance Show BitmapData where
diff --git a/Brillo/Internals/Rendering/Picture.hs b/Brillo/Internals/Rendering/Picture.hs
--- a/Brillo/Internals/Rendering/Picture.hs
+++ b/Brillo/Internals/Rendering/Picture.hs
@@ -23,8 +23,9 @@
 import Brillo.Internals.Data.Color (Color (RGBA))
 import Brillo.Internals.Data.Picture (
   BitmapFormat (pixelFormat, rowOrder),
+  CompressedFormat (CompressedFormat),
   Picture (..),
-  PixelFormat (PxABGR, PxRGBA),
+  PixelFormat (PxABGR, PxCompressed, PxRGBA),
   Rectangle (Rectangle, rectPos, rectSize),
   RowOrder (BottomToTop, TopToBottom),
   rectAtOrigin,
@@ -42,6 +43,7 @@
 import Brillo.Internals.Rendering.Shader (
   renderArcSDF,
   renderCircleSDF,
+  renderCustomShader,
   renderThickLineSDF,
  )
 import Brillo.Internals.Rendering.State (
@@ -385,6 +387,9 @@
 
           -- Free uncachable texture objects.
           freeTexture tex
+    -- Custom shader
+    Shader shaderData ->
+      renderCustomShader state.stateShaders shaderData
     Pictures ps ->
       mapM_ (drawPicture state circScale) ps
 
@@ -398,11 +403,18 @@
       mapM_ (handleError place) errors
 
 
+{-| Report an OpenGL error.
+
+These are logged to @stderr@ rather than thrown so that a single error from a
+driver quirk (e.g. the shader-based rendering path on some Windows GL drivers)
+doesn't crash the whole program. The error flag has already been cleared by
+'checkErrors' reading it, so rendering can continue.
+-}
 handleError :: String -> GLU.Error -> IO ()
 handleError place err =
   case err of
     GLU.Error GLU.StackOverflow _ ->
-      error $
+      hPutStrLn stderr $
         unlines
           [ "Brillo / OpenGL Stack Overflow " ++ show place
           , "  This program uses the Brillo vector graphics library, which tried to"
@@ -419,7 +431,7 @@
           , "  transforms used when defining the Picture given to Brillo. Sorry."
           ]
     _ ->
-      error $
+      hPutStrLn stderr $
         unlines
           [ "Brillo / OpenGL Internal Error " ++ show place
           , "  Please report this at https://github.com/ad-si/Brillo/issues."
@@ -470,13 +482,8 @@
   returning the new texture handle.
 -}
 installTexture :: BitmapData -> IO Texture
-installTexture bitmapData@(BitmapData _ fmt (width, height) cacheMe fptr) =
+installTexture bitmapData@(BitmapData byteLen fmt (width, height) cacheMe fptr) =
   do
-    let glFormat =
-          case pixelFormat fmt of
-            PxABGR -> GL.ABGR
-            PxRGBA -> GL.RGBA
-
     -- Allocate texture handle for texture
     [tex] <- GL.genObjectNames 1
     GL.textureBinding GL.Texture2D $= Just tex
@@ -484,19 +491,26 @@
     -- Sets the texture in imgData as the current texture
     -- This copies the data from the pointer into OpenGL texture memory,
     -- so it's ok if the foreignptr gets garbage collected after this.
-    withForeignPtr fptr $
-      \ptr ->
-        GL.texImage2D
-          GL.Texture2D
-          GL.NoProxy
-          0
-          GL.RGBA8
-          ( GL.TextureSize2D
-              (gsizei width)
-              (gsizei height)
-          )
-          0
-          (GL.PixelData glFormat GL.UnsignedByte ptr)
+    let size = GL.TextureSize2D (gsizei width) (gsizei height)
+        uploadRGBA8 glFormat ptr =
+          GL.texImage2D GL.Texture2D GL.NoProxy 0 GL.RGBA8 size 0 $
+            GL.PixelData glFormat GL.UnsignedByte ptr
+    withForeignPtr fptr $ \ptr ->
+      case pixelFormat fmt of
+        PxCompressed (CompressedFormat compFmt) ->
+          GL.compressedTexImage2D
+            GL.Texture2D
+            GL.NoProxy
+            0
+            size
+            0
+            ( GL.CompressedPixelData
+                (GL.CompressedTextureFormat (fromIntegral compFmt))
+                (fromIntegral byteLen)
+                ptr
+            )
+        PxABGR -> uploadRGBA8 GL.ABGR ptr
+        PxRGBA -> uploadRGBA8 GL.RGBA ptr
 
     -- Make a stable name that we can use to identify this data again.
     -- If the user gives us the same texture data at the same size then we
diff --git a/Brillo/Internals/Rendering/Shader.hs b/Brillo/Internals/Rendering/Shader.hs
--- a/Brillo/Internals/Rendering/Shader.hs
+++ b/Brillo/Internals/Rendering/Shader.hs
@@ -7,19 +7,27 @@
   renderCircleSDF,
   renderArcSDF,
   renderThickLineSDF,
+  renderCustomShader,
 ) where
 
-import Control.Monad (unless)
-import Data.IORef (IORef, newIORef, readIORef, writeIORef)
+import Control.Monad (forM_, unless)
+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Text (Text)
+import Data.Text qualified as Text
 import Graphics.Rendering.OpenGL (($=))
 import Graphics.Rendering.OpenGL.GL qualified as GL
 
+import Brillo.Internals.Data.Picture (ShaderData (..), UniformValue (..))
 
+
 -- | State for shader-based rendering
 data ShaderState = ShaderState
   { circleProgram :: !(IORef (Maybe GL.Program))
   , arcProgram :: !(IORef (Maybe GL.Program))
   , lineProgram :: !(IORef (Maybe GL.Program))
+  , customPrograms :: !(IORef (Map (Text, Text) GL.Program))
   }
 
 
@@ -29,11 +37,13 @@
   circleRef <- newIORef Nothing
   arcRef <- newIORef Nothing
   lineRef <- newIORef Nothing
+  customRef <- newIORef Map.empty
   return
     ShaderState
       { circleProgram = circleRef
       , arcProgram = arcRef
       , lineProgram = lineRef
+      , customPrograms = customRef
       }
 
 
@@ -461,3 +471,78 @@
   where
     renderSegment ((ax, ay), (bx, by)) =
       renderLineSegmentSDF state ax ay bx by scaleFactor thickness color
+
+
+-- Custom shaders ------------------------------------------------------------
+
+-- | Get or compile a custom shader program, caching by (vertex, fragment) source pair.
+getCustomProgram :: ShaderState -> Text -> Text -> IO GL.Program
+getCustomProgram state vertSrc fragSrc = do
+  cache <- readIORef (customPrograms state)
+  let key = (vertSrc, fragSrc)
+  case Map.lookup key cache of
+    Just prog -> return prog
+    Nothing -> do
+      prog <- createProgram (Text.unpack vertSrc) (Text.unpack fragSrc)
+      modifyIORef' (customPrograms state) (Map.insert key prog)
+      return prog
+
+
+-- | Set a single uniform value on a shader program.
+setUniform :: GL.Program -> Text -> UniformValue -> IO ()
+setUniform program name val = do
+  loc <- GL.get (GL.uniformLocation program (Text.unpack name))
+  case val of
+    UniformFloat f ->
+      GL.uniform loc $= (realToFrac f :: GL.GLfloat)
+    UniformVec2 x y ->
+      GL.uniform loc $= GL.Vector2 (realToFrac x :: GL.GLfloat) (realToFrac y)
+    UniformVec3 x y z ->
+      GL.uniform loc
+        $= GL.Vector3 (realToFrac x :: GL.GLfloat) (realToFrac y) (realToFrac z)
+    UniformVec4 x y z w ->
+      GL.uniform loc
+        $= GL.Vector4
+          (realToFrac x :: GL.GLfloat)
+          (realToFrac y)
+          (realToFrac z)
+          (realToFrac w)
+    UniformInt i ->
+      GL.uniform loc $= (fromIntegral i :: GL.GLint)
+
+
+{-| Render a custom shader on a quad centered at the origin.
+The quad spans from @(-w\/2, -h\/2)@ to @(w\/2, h\/2)@ where
+@w@ and @h@ come from 'ShaderData'. The fragment shader receives
+@vLocalCoord@ with these local coordinates, and @gl_Color@ carries
+the current Brillo color.
+-}
+renderCustomShader :: ShaderState -> ShaderData -> IO ()
+renderCustomShader state sd = do
+  program <- getCustomProgram state (shaderVertex sd) (shaderFragment sd)
+
+  oldProgram <- GL.get GL.currentProgram
+  GL.currentProgram $= Just program
+
+  -- Set user uniforms
+  forM_ (shaderUniforms sd) $ \(name, val) ->
+    setUniform program name val
+
+  -- Draw quad centered at origin
+  let hw = shaderWidth sd / 2
+      hh = shaderHeight sd / 2
+
+  GL.renderPrimitive GL.Quads $ do
+    GL.texCoord $ GL.TexCoord2 (realToFrac (-hw) :: GL.GLfloat) (realToFrac (-hh))
+    GL.vertex $ GL.Vertex2 (realToFrac (-hw) :: GL.GLfloat) (realToFrac (-hh))
+
+    GL.texCoord $ GL.TexCoord2 (realToFrac hw :: GL.GLfloat) (realToFrac (-hh))
+    GL.vertex $ GL.Vertex2 (realToFrac hw :: GL.GLfloat) (realToFrac (-hh))
+
+    GL.texCoord $ GL.TexCoord2 (realToFrac hw :: GL.GLfloat) (realToFrac hh)
+    GL.vertex $ GL.Vertex2 (realToFrac hw :: GL.GLfloat) (realToFrac hh)
+
+    GL.texCoord $ GL.TexCoord2 (realToFrac (-hw) :: GL.GLfloat) (realToFrac hh)
+    GL.vertex $ GL.Vertex2 (realToFrac (-hw) :: GL.GLfloat) (realToFrac hh)
+
+  GL.currentProgram $= oldProgram
diff --git a/Brillo/Internals/Rendering/TrueTypeFont.hs b/Brillo/Internals/Rendering/TrueTypeFont.hs
--- a/Brillo/Internals/Rendering/TrueTypeFont.hs
+++ b/Brillo/Internals/Rendering/TrueTypeFont.hs
@@ -90,13 +90,38 @@
       pure $ max scaleX scaleY
 
 
+{-| The scale the current modelview matrix applies to the drawing.
+
+Text nested in a scaled `Picture` covers correspondingly more pixels,
+so the glyphs have to be rasterized at that size to stay crisp.
+-}
+getModelviewScale :: IO Float
+getModelviewScale = do
+  modelview <-
+    get (GL.matrix (Just (GL.Modelview 0))) :: IO (GL.GLmatrix GL.GLfloat)
+  components <- GL.getMatrixComponents GL.ColumnMajor modelview
+  -- Column major, so the first two columns are the images of the x and y axis
+  pure $ case components of
+    xX : xY : xZ : _xW : yX : yY : yZ : _rest ->
+      let
+        scaleX = sqrt ((xX * xX) + (xY * xY) + (xZ * xZ))
+        scaleY = sqrt ((yX * yX) + (yY * yY) + (yZ * yZ))
+      in
+        realToFrac $ max scaleX scaleY
+    _ -> 1
+
+
 renderTrueTypeText :: FilePath -> Int -> Text -> IO ()
 renderTrueTypeText fontPath pixelHeight str
   | T.null str = pure ()
   | otherwise = do
       -- Detect DPI scale factor for HiDPI/Retina displays
-      scaleFactor <- getContentScale
-      let scaledHeight = round (fromIntegral pixelHeight * scaleFactor)
+      contentScale <- getContentScale
+      modelviewScale <- getModelviewScale
+      let
+        scaleFactorRaw = contentScale * modelviewScale
+        scaleFactor = if scaleFactorRaw > 0 then scaleFactorRaw else 1
+        scaledHeight = round (fromIntegral pixelHeight * scaleFactor)
 
       glyphs <- getOrLoadFont fontPath scaledHeight
       let fallbackGlyph = Map.lookup '?' glyphs
diff --git a/Brillo/Rendering.hs b/Brillo/Rendering.hs
--- a/Brillo/Rendering.hs
+++ b/Brillo/Rendering.hs
@@ -5,6 +5,12 @@
   Vector,
   Path,
 
+  -- * Shaders
+  UniformValue (..),
+  ShaderData (..),
+  defaultShaderVertex,
+  shader,
+
   -- * Colors
   Color,
   makeColor,
@@ -20,12 +26,35 @@
   BitmapFormat (..),
   PixelFormat (..),
   RowOrder (..),
+  CompressedFormat (..),
   bitmapOfForeignPtr,
   bitmapDataOfForeignPtr,
   bitmapOfByteString,
   bitmapDataOfByteString,
   bitmapOfBMP,
   bitmapDataOfBMP,
+  compressedBitmapOfForeignPtr,
+  compressedBitmapDataOfForeignPtr,
+  compressedBitmapOfByteString,
+  compressedBitmapDataOfByteString,
+
+  -- ** Common compressed texture formats
+  Bitmap.compressedRGB_S3TC_DXT1,
+  Bitmap.compressedRGBA_S3TC_DXT1,
+  Bitmap.compressedRGBA_S3TC_DXT3,
+  Bitmap.compressedRGBA_S3TC_DXT5,
+  Bitmap.compressedSRGB_S3TC_DXT1,
+  Bitmap.compressedSRGB_ALPHA_S3TC_DXT1,
+  Bitmap.compressedSRGB_ALPHA_S3TC_DXT3,
+  Bitmap.compressedSRGB_ALPHA_S3TC_DXT5,
+  Bitmap.compressedRED_RGTC1,
+  Bitmap.compressedSIGNED_RED_RGTC1,
+  Bitmap.compressedRG_RGTC2,
+  Bitmap.compressedSIGNED_RG_RGTC2,
+  Bitmap.compressedRGBA_BPTC_UNORM,
+  Bitmap.compressedSRGB_ALPHA_BPTC_UNORM,
+  Bitmap.compressedRGB_BPTC_SIGNED_FLOAT,
+  Bitmap.compressedRGB_BPTC_UNSIGNED_FLOAT,
   loadBMP,
 
   -- * Rendering
@@ -40,6 +69,7 @@
 
 import Brillo.Internals.Data.Color
 import Brillo.Internals.Data.Picture
+import Brillo.Internals.Rendering.Bitmap qualified as Bitmap
 import Brillo.Internals.Rendering.Common
 import Brillo.Internals.Rendering.Picture
 import Brillo.Internals.Rendering.State qualified as RS
diff --git a/brillo-rendering.cabal b/brillo-rendering.cabal
--- a/brillo-rendering.cabal
+++ b/brillo-rendering.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name:          brillo-rendering
-version:       2.0.0
+version:       3.0.0
 license:       MIT
 license-file:  LICENSE
 author:        Elise Huard, Ben Lippmeier, Adrian Sieber
