diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,12 @@
 # Changelog for keid-render-basic
 
+## 0.1.6.1
+
+- `withFrozenCallStack` fixes for GHC-9.0.
+- Added color helpers to `Render.Unlit.Colored`.
+- Added `Render.Unlit.Sprite` pipeline.
+  * Available as `pSprite` and `pSpriteOutline`.
+
 ## 0.1.6.0
 
 - Updated `Loader` stage to camera changes in core.
diff --git a/keid-render-basic.cabal b/keid-render-basic.cabal
--- a/keid-render-basic.cabal
+++ b/keid-render-basic.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           keid-render-basic
-version:        0.1.6.0
+version:        0.1.6.1
 synopsis:       Basic rendering programs for Keid engine.
 category:       Game Engine
 homepage:       https://keid.haskell-game.dev
@@ -59,6 +59,8 @@
       Render.Skybox.Pipeline
       Render.Unlit.Colored.Model
       Render.Unlit.Colored.Pipeline
+      Render.Unlit.Sprite.Model
+      Render.Unlit.Sprite.Pipeline
       Render.Unlit.Textured.Model
       Render.Unlit.Textured.Pipeline
       Render.Unlit.TileMap.Model
@@ -134,16 +136,14 @@
       ViewPatterns
   ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
   build-depends:
-      adjunctions
-    , aeson
+      aeson
     , base >=4.7 && <5
     , bytestring
     , derive-storable
     , derive-storable-plugin
-    , distributive
     , file-embed >=0.0.10
     , geomancy
-    , keid-core >=0.1.6.0
+    , keid-core >=0.1.6.1
     , keid-geometry
     , neat-interpolation
     , resourcet
diff --git a/src/Global/Resource/CubeMap/Base.hs b/src/Global/Resource/CubeMap/Base.hs
--- a/src/Global/Resource/CubeMap/Base.hs
+++ b/src/Global/Resource/CubeMap/Base.hs
@@ -9,24 +9,18 @@
 
 import RIO
 
-import Data.Distributive (Distributive(..))
-import Data.Distributive.Generic (genericCollect)
-import Data.Functor.Rep (Co(..), Representable)
 import GHC.Generics (Generic1)
 import Resource.Source (Source)
 import Resource.Source qualified as Source
+import Resource.Collection (Generically1(..))
 
 import Global.Resource.CubeMap.Base.Paths qualified as Paths
 
 data Collection a = Collection
   { black        :: a
   }
-  deriving stock (Show, Functor, Foldable, Traversable, Generic, Generic1)
-  deriving Applicative via (Co Collection)
-  deriving anyclass (Representable)
-
-instance Distributive Collection where
-  collect = genericCollect
+  deriving stock (Show, Functor, Foldable, Traversable, Generic1)
+  deriving Applicative via Generically1 Collection
 
 sources :: Collection Source
 sources = Collection
diff --git a/src/Global/Resource/Texture/Base.hs b/src/Global/Resource/Texture/Base.hs
--- a/src/Global/Resource/Texture/Base.hs
+++ b/src/Global/Resource/Texture/Base.hs
@@ -9,10 +9,8 @@
 
 import RIO
 
-import Data.Distributive (Distributive(..))
-import Data.Distributive.Generic (genericCollect)
-import Data.Functor.Rep (Co(..), Representable)
 import GHC.Generics (Generic1)
+import Resource.Collection (Generically1(..))
 import Resource.Source (Source)
 import Resource.Source qualified as Source
 
@@ -23,12 +21,8 @@
   , flat         :: a
   , ibl_brdf_lut :: a
   }
-  deriving stock (Show, Functor, Foldable, Traversable, Generic, Generic1)
-  deriving Applicative via (Co Collection)
-  deriving anyclass (Representable)
-
-instance Distributive Collection where
-  collect = genericCollect
+  deriving stock (Show, Functor, Foldable, Traversable, Generic1)
+  deriving Applicative via Generically1 Collection
 
 sources :: Collection Source
 sources = Collection
diff --git a/src/Render/Basic.hs b/src/Render/Basic.hs
--- a/src/Render/Basic.hs
+++ b/src/Render/Basic.hs
@@ -40,6 +40,7 @@
 import Render.ShadowMap.RenderPass qualified as ShadowPass
 import Render.Skybox.Pipeline qualified as Skybox
 import Render.Unlit.Colored.Pipeline qualified as UnlitColored
+import Render.Unlit.Sprite.Pipeline qualified as UnlitSprite
 import Render.Unlit.Textured.Pipeline qualified as UnlitTextured
 import Render.Unlit.TileMap.Pipeline qualified as UnlitTileMap
 
@@ -125,6 +126,9 @@
   , pUnlitColoredNoDepth :: UnlitColored.Pipeline
   , pUnlitTextured       :: UnlitTextured.Pipeline
   , pUnlitTexturedBlend  :: UnlitTextured.Pipeline
+
+  , pSprite              :: UnlitSprite.Pipeline
+  , pSpriteOutline       :: UnlitSprite.Pipeline
   , pTileMap             :: UnlitTileMap.Pipeline
   , pTileMapBlend        :: UnlitTileMap.Pipeline
   , pWireframe           :: UnlitColored.Pipeline
@@ -169,12 +173,14 @@
   pLitTextured      <- LitTextured.allocate pMSAA pSceneBinds rpForwardMsaa
   pLitTexturedBlend <- LitTextured.allocateBlend pMSAA pSceneBinds rpForwardMsaa
 
+  pSprite              <- UnlitSprite.allocate pMSAA Nothing False pSceneBinds rpForwardMsaa
+  pSpriteOutline       <- UnlitSprite.allocate pMSAA Nothing True pSceneBinds rpForwardMsaa
+  pTileMap             <- UnlitTileMap.allocate pMSAA pSceneBinds rpForwardMsaa
+  pTileMapBlend        <- UnlitTileMap.allocateBlend pMSAA pSceneBinds rpForwardMsaa
   pUnlitColored        <- UnlitColored.allocate True pMSAA pSceneBinds rpForwardMsaa
   pUnlitColoredNoDepth <- UnlitColored.allocate False pMSAA pSceneBinds rpForwardMsaa
   pUnlitTextured       <- UnlitTextured.allocate pMSAA pSceneBinds rpForwardMsaa
   pUnlitTexturedBlend  <- UnlitTextured.allocateBlend pMSAA pSceneBinds rpForwardMsaa
-  pTileMap             <- UnlitTileMap.allocate pMSAA pSceneBinds rpForwardMsaa
-  pTileMapBlend        <- UnlitTileMap.allocateBlend pMSAA pSceneBinds rpForwardMsaa
   pWireframe           <- UnlitColored.allocateWireframe True pMSAA pSceneBinds rpForwardMsaa
   pWireframeNoDepth    <- UnlitColored.allocateWireframe False pMSAA pSceneBinds rpForwardMsaa
 
diff --git a/src/Render/Unlit/Colored/Model.hs b/src/Render/Unlit/Colored/Model.hs
--- a/src/Render/Unlit/Colored/Model.hs
+++ b/src/Render/Unlit/Colored/Model.hs
@@ -4,10 +4,16 @@
   , VertexAttrs
   , vkVertexAttrs
 
+  , rgbF
+  , black
+  , white
+
   , InstanceAttrs
   ) where
 
-import Geomancy (Transform, Vec4)
+import RIO
+
+import Geomancy (Transform, Vec4, vec4)
 import Geomancy.Vec3 qualified as Vec3
 import Resource.Model qualified as Model
 import Vulkan.Core10 qualified as Vk
@@ -23,3 +29,15 @@
 vkVertexAttrs =
   [ Vk.FORMAT_R32G32B32A32_SFLOAT -- vColor :: vec4
   ]
+
+{-# INLINE rgbF #-}
+rgbF :: Float -> Float -> Float -> VertexAttrs
+rgbF r g b = vec4 r g b 1
+
+{-# INLINE black #-}
+black :: VertexAttrs
+black = rgbF 0 0 0
+
+{-# INLINE white #-}
+white :: VertexAttrs
+white = rgbF 1 1 1
diff --git a/src/Render/Unlit/Sprite/Model.hs b/src/Render/Unlit/Sprite/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Unlit/Sprite/Model.hs
@@ -0,0 +1,113 @@
+{-# OPTIONS_GHC -fplugin Foreign.Storable.Generic.Plugin #-}
+
+module Render.Unlit.Sprite.Model
+  ( InstanceAttrs(..)
+  , StorableAttrs
+  , InstanceBuffer
+  , fromTexture
+  , fromAtlas
+
+  , vkInstanceAttrs
+  ) where
+
+import RIO
+
+import Foreign.Storable.Generic (GStorable)
+import Geomancy (Vec2, Vec4, UVec2, withUVec2, vec2)
+import Geomancy.Vec4 qualified as Vec4
+import Render.Samplers qualified as Samplers
+import Resource.Buffer qualified as Buffer
+import Resource.Image.Atlas (Atlas)
+import Resource.Image.Atlas qualified as Atlas
+import RIO.Vector.Storable qualified as Storable
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Zero (Zero(..))
+
+type StorableAttrs = Storable.Vector InstanceAttrs
+
+data InstanceAttrs = InstanceAttrs
+  { vertRect :: Vec4
+  , fragRect :: Vec4
+
+  , tint    :: Vec4
+  , outline :: Vec4
+
+  , samplerId :: Int32
+  , textureId :: Int32
+  , textureSize :: UVec2
+  }
+  deriving (Show, Generic)
+
+instance GStorable InstanceAttrs
+
+instance Zero InstanceAttrs where
+  zero = InstanceAttrs
+    { vertRect = 0
+    , fragRect = 0
+
+    , tint    = 0
+    , outline = 0
+
+    , samplerId = 0
+    , textureId = 0
+    , textureSize = 0
+    }
+
+vkInstanceAttrs :: [Vk.Format]
+vkInstanceAttrs =
+  [ Vk.FORMAT_R32G32B32A32_SFLOAT -- Quad scale+offset
+  , Vk.FORMAT_R32G32B32A32_SFLOAT -- UV scale+offset
+  , Vk.FORMAT_R32G32B32A32_SFLOAT -- Tint
+  , Vk.FORMAT_R32G32B32A32_SFLOAT -- Outline (when enabled)
+
+  , Vk.FORMAT_R32G32_SINT -- Sampler + texture IDs
+  , Vk.FORMAT_R32G32_UINT -- Texture size
+  ]
+
+type InstanceBuffer stage = Buffer.Allocated stage InstanceAttrs
+
+fromTexture
+  :: Int32 -- ^ Sampler index.
+  -> Int32 -- ^ Texture index.
+  -> Vec2  -- ^ Sprite size.
+  -> Vec2  -- ^ Sprite position.
+  -> InstanceAttrs
+fromTexture sampler texture wh pos =
+  InstanceAttrs
+    { vertRect = Vec4.fromVec22 pos wh
+    , fragRect = Vec4.fromVec22 0 1
+
+    , tint    = 1
+    , outline = 0
+
+    , samplerId = sampler
+    , textureId = texture
+    , textureSize = 0
+    }
+
+fromAtlas
+  :: Int32 -- ^ Texture ID.
+  -> Atlas
+  -> Vec2 -- ^ Sprite scale, wrt. to native tile size
+  -> Vec2 -- ^ Tile position in atlas tiles. Can be fractional when using subgrids.
+  -> Vec2 -- ^ Sprite position.
+  -> InstanceAttrs
+fromAtlas texture atlas scale atlasPos worldPos =
+  InstanceAttrs
+    { vertRect = Vec4.fromVec22 worldPos (tileSize * scale)
+    , fragRect = Vec4.fromVec22 (atlasPos * uvScale) uvScale
+
+    , tint    = 1
+    , outline = 0
+
+    , samplerId = Samplers.nearest Samplers.indices
+    , textureId = texture
+    , textureSize = Atlas.sizePx atlas
+    }
+
+  where
+    uvScale = Atlas.uvScale atlas
+
+    tileSize =
+      withUVec2 (Atlas.tileSizePx atlas) \tw th ->
+        vec2 (fromIntegral tw) (fromIntegral th)
diff --git a/src/Render/Unlit/Sprite/Pipeline.hs b/src/Render/Unlit/Sprite/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Unlit/Sprite/Pipeline.hs
@@ -0,0 +1,184 @@
+{-# LANGUAGE OverloadedLists #-}
+
+module Render.Unlit.Sprite.Pipeline
+  ( Pipeline
+  , allocate
+  -- , allocateOutline
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import Vulkan.Core10 qualified as Vk
+
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
+import Render.Code (compileVert, compileFrag, glsl)
+import Render.DescSets.Set0 (Scene)
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2)
+import Render.Unlit.Sprite.Model qualified as Model
+
+type Config = Pipeline.Configure Pipeline (Float, Bool)
+type Pipeline = Pipeline.Pipeline '[Scene] () Model.InstanceAttrs
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Maybe Float
+  -> Bool
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocate multisample discardAlpha outline set0 = do
+  fmap snd . Pipeline.allocate
+    Nothing
+    multisample
+    (config discardAlpha outline set0)
+
+config
+  :: Maybe Float
+  -> Bool
+  -> Tagged Scene DsBindings
+  -> Config
+config discardAlpha outline (Tagged set0) =
+  Pipeline.baseConfig
+    { Pipeline.cVertexCode     = Just vertCode
+    , Pipeline.cFragmentCode   = Just fragCode
+    , Pipeline.cDescLayouts    = Tagged @'[Scene] [set0]
+    , Pipeline.cVertexInput    = vertexInput
+    , Pipeline.cDepthTest      = False
+    , Pipeline.cDepthWrite     = False
+    , Pipeline.cBlend          = True
+    , Pipeline.cCull           = Vk.CULL_MODE_NONE
+    , Pipeline.cSpecialization = specs
+    }
+  where
+    vertexInput = Pipeline.vertexInput
+      [ (Vk.VERTEX_INPUT_RATE_INSTANCE, Model.vkInstanceAttrs)
+      ]
+
+    specs =
+      ( fromMaybe 0.0 discardAlpha
+      , outline
+      )
+
+vertCode :: ByteString
+vertCode =
+  $(compileVert [glsl|
+    #version 450
+    #extension GL_ARB_separate_shader_objects : enable
+
+    ${set0binding0}
+
+    layout(location = 0) in  vec4 iVert;
+    layout(location = 1) in  vec4 iFrag;
+    layout(location = 2) in  vec4 iTint;
+    layout(location = 3) in  vec4 iOutline;
+    layout(location = 4) in ivec2 iTextureIds;
+    layout(location = 5) in uvec2 iTextureSize;
+
+    layout(location = 0)      out  vec2 fTexCoord;
+    layout(location = 1) flat out  vec4 fTint;
+    layout(location = 2) flat out  vec4 fOutline;
+    layout(location = 3) flat out ivec2 fTextureIds;
+    layout(location = 4) flat out uvec2 fTextureSize;
+
+    vec2 positions[6] = vec2[](
+      vec2(-0.5, -0.5),
+      vec2(0.5, -0.5),
+      vec2(-0.5, 0.5),
+
+      vec2(0.5, 0.5),
+      vec2(-0.5, 0.5),
+      vec2(0.5, -0.5)
+    );
+
+    vec2 texCoords[6] = vec2[](
+      vec2(0.0, 0.0),
+      vec2(1.0, 0.0),
+      vec2(0.0, 1.0),
+
+      vec2(1.0, 1.0),
+      vec2(0.0, 1.0),
+      vec2(1.0, 0.0)
+    );
+
+    void main() {
+      vec2 pos = positions[gl_VertexIndex];
+
+      gl_Position
+        = scene.projection
+        * scene.view
+        * vec4(iVert.xy + pos * iVert.zw, 0, 1.0);
+
+      vec2 uv = texCoords[gl_VertexIndex];
+      fTexCoord = iFrag.xy + uv * iFrag.zw;
+
+      fTint = iTint;
+      fOutline = iOutline;
+      fTextureIds = iTextureIds;
+      fTextureSize = iTextureSize;
+    }
+  |])
+
+fragCode :: ByteString
+fragCode =
+  $(compileFrag [glsl|
+    #version 450
+    #extension GL_ARB_separate_shader_objects : enable
+    #extension GL_EXT_nonuniform_qualifier : enable
+
+    ${set0binding1}
+    ${set0binding2}
+
+    layout(location = 0)      in  vec2 fTexCoord;
+    layout(location = 1) flat in  vec4 fTint;
+    layout(location = 2) flat in  vec4 fOutline;
+    layout(location = 3) flat in ivec2 fTextureIds;
+    layout(location = 4) flat in uvec2 fTextureSize;
+
+    layout(location = 0) out vec4 outColor;
+
+    layout(constant_id=0) const float discardAlpha = 0.0;
+    layout(constant_id=1) const bool  doOutline = false;
+
+    vec4 tap(vec2 uv) {
+      return texture(
+        sampler2D(
+          textures[nonuniformEXT(fTextureIds.t)],
+          samplers[nonuniformEXT(fTextureIds.s)]
+        ),
+        fTexCoord + uv
+      );
+    }
+
+    vec2 step_size = 1.0 / vec2(fTextureSize);
+
+    void main() {
+      vec4 texel = tap(vec2(0));
+
+      if (discardAlpha != 0.0) {
+        if (texel.a < discardAlpha) {
+          discard;
+        }
+      }
+
+      outColor = texel * fTint * texel.a;
+
+      if (doOutline) {
+        if (fOutline.a > 0.0 && texel.a < 1.0) {
+          float alpha = 0.0;
+
+          alpha += tap(vec2(step_size.x,  0.0)         ).a / 4.0;
+          alpha += tap(vec2(-step_size.x, 0.0)         ).a / 4.0;
+          alpha += tap(vec2(0.0,          step_size.y) ).a / 4.0;
+          alpha += tap(vec2(0.0,          -step_size.y)).a / 4.0;
+
+          outColor += fOutline * alpha;
+        }
+      }
+    }
+  |])
diff --git a/src/Resource/Font.hs b/src/Resource/Font.hs
--- a/src/Resource/Font.hs
+++ b/src/Resource/Font.hs
@@ -73,7 +73,7 @@
   container <- withFrozenCallStack $
     EvanW.load configContainer
 
-  createTexture <- toIO . withFrozenCallStack $
+  createTexture <- toIO $ withFrozenCallStack $
     Ktx1.load pools configTexture
 
   (textureKey, texture) <- Resource.allocate
diff --git a/src/Resource/Font/EvanW.hs b/src/Resource/Font/EvanW.hs
--- a/src/Resource/Font/EvanW.hs
+++ b/src/Resource/Font/EvanW.hs
@@ -68,12 +68,14 @@
      , HasCallStack
      )
   => Source -> m Container
-load = withFrozenCallStack . Source.load \bytes ->
-  case eitherDecodeStrict' bytes of
-    Left err ->
-      liftIO . throwIO . FontError $ Text.pack err
-    Right res ->
-      pure res
+load =
+  withFrozenCallStack $
+    Source.load \bytes ->
+      case eitherDecodeStrict' bytes of
+        Left err ->
+          liftIO . throwIO . FontError $ Text.pack err
+        Right res ->
+          pure res
 
 -- * Typesetting
 
