diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,38 @@
 # Changelog for keid-render-basic
 
+## 0.1.12.0
+
+Moving from EvanwSdf atlases (thanks Evan!), to MTSDF by Chlumsky.
+Atlas generation now doesn't require using browser and copypasting.
+
+- Added `Resource.Font.Ktxf` for the texture+atlas+shaping font container.
+  * New deps: `ktx-font`, `msdf-atlas`, `kb-text-shape`.
+  * Text is shaped once with `Ktxf.shape` and placed with the pure `Ktxf.place`,
+    which wraps and aligns the lines using `kb-text-layout` via `ktx-font`.
+  * `Ktxf.Stack` pairs the shaping context with its measuring style.
+    `allocateStack1`/`allocateMonoStacks` produce those.
+- Removed `Resource.Font.EvanwSdf.*` (models and `pEvanwSdf` pipeline).
+- Added `Resource.Font.Msdf.*` models and (`pMsdf`) pipeline.
+- Changed `Engine.UI.Message.Input.inputFont` field to use textureId-augmented font stacks (`Ktxf.Stack`).
+- Added `Engine.UI.Message.Input.inputBreak` field to pick the line breaking strategy.
+  Long lines now wrap to the box width and align per line.
+- Removed `Engine.UI.Message.Input.inputFontId` field, now redundant.
+- Added Base{white} built-in texture.
+  * Breaking change as it will alter the indices that come after it.
+
+## 0.1.11.0
+
+- Added specialization constants to common Lit code (thus, all the pipelines).
+- Added Lit specialization to Basic.Settings.
+- Moved antialiasing settings (msaa, sampler anisotropy) to Basic.Settings.
+  + Use new `Basic.swapchainSettings` instead of passing swapchain as arguments.
+- Refactored external pipelines to use one loader for all the variants.
+  + External pipelines now react to change in the specialization constants.
+- Fixed AO texture application in Material.
+  + With 0 being most occluded, and 1 fully open.
+  + Values are multiplicative now, like the rest of the channels.
+- Changed Fresnel function for lights to use the roughness-aware version.
+
 ## 0.1.10.0
 
 - Update to follow the changes in keid-core-0.1.11 and geomancy-0.3.
diff --git a/embed/textures/black.ktx.zst b/embed/textures/black.ktx.zst
deleted file mode 100644
Binary files a/embed/textures/black.ktx.zst and /dev/null differ
diff --git a/embed/textures/flat.ktx.zst b/embed/textures/flat.ktx.zst
deleted file mode 100644
Binary files a/embed/textures/flat.ktx.zst and /dev/null differ
diff --git a/embed/textures/ibl_brdf_lut.ktx.zst b/embed/textures/ibl_brdf_lut.ktx.zst
deleted file mode 100644
Binary files a/embed/textures/ibl_brdf_lut.ktx.zst and /dev/null differ
diff --git a/embed/textures/white.ktx2 b/embed/textures/white.ktx2
new file mode 100644
Binary files /dev/null and b/embed/textures/white.ktx2 differ
diff --git a/keid-render-basic.cabal b/keid-render-basic.cabal
--- a/keid-render-basic.cabal
+++ b/keid-render-basic.cabal
@@ -1,11 +1,11 @@
 cabal-version: 1.12
 
--- This file has been generated from package.yaml by hpack version 0.38.1.
+-- This file has been generated from package.yaml by hpack version 0.36.1.
 --
 -- see: https://github.com/sol/hpack
 
 name:           keid-render-basic
-version:        0.1.10.0
+version:        0.1.12.0
 synopsis:       Basic rendering programs for Keid engine.
 category:       Game Engine
 homepage:       https://keid.haskell-game.dev
@@ -21,12 +21,10 @@
 data-files:
     embed/cubemaps/black.ktx.zst
     embed/cubemaps/black.ktx2
-    embed/textures/black.ktx.zst
     embed/textures/black.ktx2
-    embed/textures/flat.ktx.zst
     embed/textures/flat.ktx2
-    embed/textures/ibl_brdf_lut.ktx.zst
     embed/textures/ibl_brdf_lut.ktx2
+    embed/textures/white.ktx2
 
 source-repository head
   type: git
@@ -49,9 +47,9 @@
       Render.DescSets.Set0
       Render.DescSets.Set0.Code
       Render.DescSets.Sun
-      Render.Font.EvanwSdf.Code
-      Render.Font.EvanwSdf.Model
-      Render.Font.EvanwSdf.Pipeline
+      Render.Font.Msdf.Code
+      Render.Font.Msdf.Model
+      Render.Font.Msdf.Pipeline
       Render.ForwardMsaa
       Render.Lit.Colored.Code
       Render.Lit.Colored.Model
@@ -61,6 +59,7 @@
       Render.Lit.Material.Collect
       Render.Lit.Material.Model
       Render.Lit.Material.Pipeline
+      Render.Lit.Specialization
       Render.Lit.Textured.Code
       Render.Lit.Textured.Model
       Render.Lit.Textured.Pipeline
@@ -87,7 +86,7 @@
       Render.Unlit.TileMap.Model
       Render.Unlit.TileMap.Pipeline
       Resource.Font
-      Resource.Font.EvanW
+      Resource.Font.Ktxf
       Resource.Mesh.Lit
       Stage.Loader.Render
       Stage.Loader.Scene
@@ -135,11 +134,14 @@
     , geomancy >=0.3.0.0
     , geomancy-layout
     , gl-block
+    , kb-text-shape
     , keid-core >=0.1.11.0
     , keid-geometry
+    , ktx-font
+    , msdf-atlas
     , neat-interpolation
     , resourcet
-    , rio >=0.1.12.0
+    , rio >=0.1.24.0
     , tagged
     , text
     , unliftio
diff --git a/src/Engine/UI/Message.hs b/src/Engine/UI/Message.hs
--- a/src/Engine/UI/Message.hs
+++ b/src/Engine/UI/Message.hs
@@ -15,10 +15,8 @@
 
 import Control.Monad.Trans.Resource (ResourceT)
 import Control.Monad.Trans.Resource qualified as Resource
-import Data.Text qualified as Text
-import Data.Type.Equality (type (~))
 import Data.Vector.Storable qualified as Storable
-import Geomancy (Vec4)
+import Geomancy (Vec4, ivec2, vec2)
 import Geomancy.Layout.Alignment (Alignment)
 import Geomancy.Layout.Box (Box(..))
 import Geomancy.Vec4 qualified as Vec4
@@ -28,12 +26,12 @@
 import Engine.Types qualified as Engine
 import Engine.Vulkan.Types (MonadVulkan)
 import Engine.Worker qualified as Worker
-import Render.Font.EvanwSdf.Model qualified as EvanwSdf
+import Render.Font.Msdf.Model qualified as Msdf
 import Render.Samplers qualified as Samplers
 import Resource.Buffer qualified as Buffer
-import Resource.Font.EvanW qualified as Font
+import Resource.Font.Ktxf qualified as Ktxf
 
-type Process = Worker.Merge (Storable.Vector EvanwSdf.InstanceAttrs)
+type Process = Worker.Merge (Storable.Vector Msdf.InstanceAttrs)
 
 spawn
   :: ( MonadResource m
@@ -46,7 +44,10 @@
   => box
   -> input
   -> m Process
-spawn = Worker.spawnMerge2 mkAttrs
+spawn box input = do
+  shaped <- Worker.merge1M input \Input{inputFont, inputText} ->
+    liftIO $ Ktxf.shape inputFont inputText
+  Worker.spawnMerge3 mkAttrs box input shaped
 
 spawnFromR
   :: ( MonadResource m
@@ -65,46 +66,33 @@
 
 data Input = Input
   { inputText         :: Text
-  , inputFontId       :: Int32
-  , inputFont         :: Font.Container
+  , inputLineSize     :: Float
+  , inputBreak        :: Ktxf.Strategy
+  , inputFont         :: Ktxf.Stack
   , inputOrigin       :: Alignment
   , inputSize         :: Float
   , inputColor        :: Vec4
   , inputOutline      :: Vec4
   , inputOutlineWidth :: Float
   , inputSmoothing    :: Float
-  }
+  } deriving (Generic)
 
-mkAttrs :: Box -> Input -> Storable.Vector EvanwSdf.InstanceAttrs
-mkAttrs Box{..} Input{..} =
-  if inputSize < 1 then mempty else Storable.fromList do
-    Font.PutChar{..} <- chars
-    pure EvanwSdf.InstanceAttrs
-      { vertRect     = Vec4.fromVec22 pcPos pcSize
+mkAttrs :: Box -> Input -> Ktxf.ShapedText -> Storable.Vector Msdf.InstanceAttrs
+mkAttrs box Input{..} shaped =
+  Storable.fromList do
+    Ktxf.PutChar{..} <- Ktxf.place box inputOrigin inputLineSize inputBreak inputSize inputFont shaped
+    pure Msdf.InstanceAttrs
+      { vertRect     = Vec4.fromVec22 pcPos pcSize -- get from char?
       , fragRect     = Vec4.fromVec22 pcOffset pcScale
-      , color        = inputColor
-      , outlineColor = inputOutline -- vec4 0 0.25 0 0.25
-      , textureId    = inputFontId
-      , samplerId    = samplerId
+      , textureIds    = ivec2 Samplers.indices.linear pcTextureId
+      , sdfDist = vec2 pcDistanceLower pcDistanceRange
       , smoothing    = inputSmoothing -- 1/16
       , outlineWidth = inputOutlineWidth -- 3/16
+      , color        = inputColor
+      , outlineColor = inputOutline -- vec4 0 0.25 0 0.25
       }
-  where
-    (scale, chars) = Font.putLine
-      size
-      position
-      inputOrigin
-      inputSize
-      inputFont
-      (Text.unpack inputText)
 
-    samplerId =
-      if scale > 1 then
-        Samplers.linear Samplers.indices
-      else
-        Samplers.linear Samplers.indices
-
-type Buffer = Buffer.Allocated 'Buffer.Coherent EvanwSdf.InstanceAttrs
+type Buffer = Buffer.Allocated 'Buffer.Coherent Msdf.InstanceAttrs
 
 type Observer = Worker.ObserverIO Buffer
 
@@ -123,7 +111,7 @@
 observe
   :: ( MonadVulkan env m
      , Worker.HasOutput source
-     , Worker.GetOutput source ~ Storable.Vector EvanwSdf.InstanceAttrs
+     , Worker.GetOutput source ~ Storable.Vector Msdf.InstanceAttrs
      )
   => source
   -> Observer
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
@@ -1,17 +1,17 @@
 {-# LANGUAGE DeriveAnyClass #-}
 
--- {-# OPTIONS_GHC -fforce-recomp #-}
-
 module Global.Resource.Texture.Base
   ( Collection(..)
   , Textures
   , sources
+  , indices
   ) where
 
 import RIO
 
 import GHC.Generics (Generic1)
 import Resource.Collection (Generically1(..))
+import Resource.Collection qualified as Collection
 import Resource.Source (Source)
 import Resource.Source qualified as Source
 import Resource.Texture (Texture, Flat)
@@ -19,9 +19,10 @@
 import Global.Resource.Texture.Base.Paths qualified as Paths
 
 data Collection a = Collection
-  { black        :: a
-  , flat         :: a
-  , ibl_brdf_lut :: a
+  { black        :: a -- ^ pure black / fallback texture at index 0
+  , flat         :: a -- ^ normal fallback texture
+  , ibl_brdf_lut :: a -- ^ BRDF LUT for PBR shaders
+  , white        :: a -- ^ pure white
   }
   deriving stock (Show, Functor, Foldable, Traversable, Generic1)
   deriving Applicative via Generically1 Collection
@@ -33,4 +34,8 @@
   { black        = $(Source.embedFile Paths.BLACK_KTX2)
   , flat         = $(Source.embedFile Paths.FLAT_KTX2)
   , ibl_brdf_lut = $(Source.embedFile Paths.IBL_BRDF_LUT_KTX2)
+  , white        = $(Source.embedFile Paths.WHITE_KTX2)
   }
+
+indices :: Collection Int32
+indices = fmap fst $ Collection.enumerate sources
diff --git a/src/Render/Basic.hs b/src/Render/Basic.hs
--- a/src/Render/Basic.hs
+++ b/src/Render/Basic.hs
@@ -13,11 +13,9 @@
 import Control.Monad.Trans.Resource (ResourceT)
 import Data.Kind (Type)
 import Data.Tagged (Tagged(..))
-import Data.Type.Equality (type (~))
 import RIO.FilePath ((</>), (<.>))
 import RIO.Vector.Partial as Vector (headM)
 import Vulkan.Core10 qualified as Vk
-import Vulkan.Zero (Zero(..))
 
 -- keid-core
 
@@ -31,6 +29,7 @@
 import Engine.Vulkan.Shader qualified as Shader
 import Engine.Vulkan.Swapchain qualified as Swapchain
 import Engine.Vulkan.Types (DsLayoutBindings, HasSwapchain, HasVulkan, RenderPass(..))
+import Engine.Worker qualified as Worker
 
 -- keid-render-basic
 
@@ -40,11 +39,12 @@
 import Render.DescSets.Set0 qualified as Scene
 import Render.DescSets.Sun (Sun)
 import Render.DescSets.Sun qualified as Sun
-import Render.Font.EvanwSdf.Pipeline qualified as EvanwSdf
+import Render.Font.Msdf.Pipeline qualified as Msdf
 import Render.ForwardMsaa (ForwardMsaa)
 import Render.ForwardMsaa qualified as ForwardMsaa
 import Render.Lit.Colored.Pipeline qualified as LitColored
 import Render.Lit.Material.Pipeline qualified as LitMaterial
+import Render.Lit.Specialization qualified as Lit
 import Render.Lit.Textured.Pipeline qualified as LitTextured
 import Render.Samplers qualified as Samplers
 import Render.ShadowMap.Pipeline qualified as ShadowPipe
@@ -88,15 +88,21 @@
 data Settings = Settings
   { sShadowSize   :: Word32
   , sShadowLayers :: Word32
+  , sMSAA         :: Vk.SampleCountFlagBits
+  , sAnisotropy   :: Float
+  , sLitSpec      :: Lit.Specialization
   }
   deriving (Eq, Show)
 
-instance Zero Settings where
-  zero = Settings
-    -- XXX: 1x1 placeholder image
-    { sShadowSize   = 1
-    , sShadowLayers = 1
-    }
+swapchainSettings :: HasSwapchain swapchain => swapchain -> Settings
+swapchainSettings swapchain = Settings
+  -- XXX: 1x1 placeholder image
+  { sShadowSize   = 1
+  , sShadowLayers = 1
+  , sMSAA         = Swapchain.getMultisample swapchain
+  , sAnisotropy   = Swapchain.getAnisotropy swapchain
+  , sLitSpec      = Lit.defaultSpec
+  }
 
 allocate
   :: ( HasSwapchain swapchain
@@ -123,21 +129,23 @@
      )
   => swapchain
   -> ResourceT (RIO env) RenderPasses
-allocate_ = allocate zero
+allocate_ swapchain = allocate (swapchainSettings swapchain) swapchain
 
 type Pipelines = PipelinesF Identity
 type PipelineObservers = PipelinesF External.Observers
 type PipelineWorkers = PipelinesF External.ConfigureGraphics
 
 data PipelinesF (f :: Type -> Type) = Pipelines
-  { pMSAA       :: Vk.SampleCountFlagBits
+  { pSettings :: f ^ External.Params Settings
+
   , pSceneBinds :: Tagged Scene DsLayoutBindings
   , pSceneLayout :: Tagged '[Scene] Vk.DescriptorSetLayout
 
   , pShadowBinds :: Tagged Sun DsLayoutBindings
   , pShadowLayout :: Tagged '[Sun] Vk.DescriptorSetLayout
 
-  , pEvanwSdf :: f ^ EvanwSdf.Pipeline
+  , pMsdf :: f ^ Msdf.Pipeline
+  , pSoftmask :: f ^ Msdf.Pipeline
   , pSkybox   :: f ^ Skybox.Pipeline
 
   , pDebugUV      :: f ^ Debug.Pipeline
@@ -176,63 +184,64 @@
   => swapchain
   -> RenderPasses
   -> ResourceT (StageRIO st) Pipelines
-allocatePipelines_ =
-  allocatePipelinesFor Nothing Nothing
+allocatePipelines_ swapchain = allocatePipelinesFor settings Nothing Nothing
+  where
+    settings = swapchainSettings swapchain
 
 allocatePipelinesFor
-  :: ( HasSwapchain swapchain
-     , Foldable textures
+  :: ( Foldable textures
      , Foldable cubes
      )
-  => textures t
+  => Settings
+  -> textures t
   -> cubes c
-  -> swapchain
   -> RenderPasses
   -> ResourceT (StageRIO st) Pipelines
-allocatePipelinesFor textures cubes swapchain renderpasses = do
-  samplers <- Samplers.allocate (Swapchain.getAnisotropy swapchain)
+allocatePipelinesFor settings textures cubes renderpasses = do
+  samplers <- Samplers.allocate settings.sAnisotropy
 
   allocatePipelines
+    settings
     (Scene.mkBindings samplers textures cubes shadows)
-    (Swapchain.getMultisample swapchain)
     renderpasses
   where
     shadows = renderpasses.rpShadowPass.smLayerCount
 
 allocatePipelines
-  :: Tagged Scene DsLayoutBindings
-  -> Vk.SampleCountFlagBits
+  :: Settings
+  -> Tagged Scene DsLayoutBindings
   -> RenderPasses
   -> ResourceT (StageRIO st) Pipelines
-allocatePipelines pSceneBinds pMSAA RenderPasses{..} = do
-  pEvanwSdf <- EvanwSdf.allocate pMSAA pSceneBinds rpForwardMsaa
-  pSkybox   <- Skybox.allocate pMSAA pSceneBinds rpForwardMsaa
+allocatePipelines pSettings@Settings{..} pSceneBinds RenderPasses{..} = do
+  pMsdf <- Msdf.allocate Msdf.defaultSpecialization sMSAA pSceneBinds rpForwardMsaa
+  pSoftmask <- Msdf.allocate Msdf.defaultSpecialization{Msdf.mode=Msdf.SoftMask} sMSAA pSceneBinds rpForwardMsaa
+  pSkybox   <- Skybox.allocate sMSAA pSceneBinds rpForwardMsaa
 
-  pDebugUV      <- Debug.allocate Debug.UV         pMSAA pSceneBinds rpForwardMsaa
-  pDebugTexture <- Debug.allocate Debug.Texture    pMSAA pSceneBinds rpForwardMsaa
-  pDebugShadow  <- Debug.allocate (Debug.Shadow 1) pMSAA pSceneBinds rpForwardMsaa
+  pDebugUV      <- Debug.allocate Debug.UV         sMSAA pSceneBinds rpForwardMsaa
+  pDebugTexture <- Debug.allocate Debug.Texture    sMSAA pSceneBinds rpForwardMsaa
+  pDebugShadow  <- Debug.allocate (Debug.Shadow 1) sMSAA pSceneBinds rpForwardMsaa
 
-  pDepthOnly <- DepthOnly.allocate pMSAA pSceneBinds rpForwardMsaa
+  pDepthOnly <- DepthOnly.allocate sMSAA pSceneBinds rpForwardMsaa
 
-  pLitColored       <- LitColored.allocate pMSAA pSceneBinds rpForwardMsaa
-  pLitColoredBlend  <- LitColored.allocateBlend pMSAA pSceneBinds rpForwardMsaa
-  pLitMaterial      <- LitMaterial.allocate pMSAA pSceneBinds rpForwardMsaa
-  pLitMaterialBlend <- LitMaterial.allocateBlend pMSAA pSceneBinds rpForwardMsaa
-  pLitTextured      <- LitTextured.allocate pMSAA pSceneBinds rpForwardMsaa
-  pLitTexturedBlend <- LitTextured.allocateBlend pMSAA pSceneBinds rpForwardMsaa
+  pLitColored       <- LitColored.allocate sLitSpec sMSAA pSceneBinds rpForwardMsaa
+  pLitColoredBlend  <- LitColored.allocateBlend sLitSpec sMSAA pSceneBinds rpForwardMsaa
+  pLitMaterial      <- LitMaterial.allocate sLitSpec sMSAA pSceneBinds rpForwardMsaa
+  pLitMaterialBlend <- LitMaterial.allocateBlend sLitSpec sMSAA pSceneBinds rpForwardMsaa
+  pLitTextured      <- LitTextured.allocate sLitSpec sMSAA pSceneBinds rpForwardMsaa
+  pLitTexturedBlend <- LitTextured.allocateBlend sLitSpec sMSAA pSceneBinds rpForwardMsaa
 
-  pLine2d              <- UnlitLine2d.allocate True pMSAA pSceneBinds rpForwardMsaa
-  pLine2dNoDepth       <- UnlitLine2d.allocate False 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
-  pWireframe           <- UnlitColored.allocateWireframe True pMSAA pSceneBinds rpForwardMsaa
-  pWireframeNoDepth    <- UnlitColored.allocateWireframe False pMSAA pSceneBinds rpForwardMsaa
+  pLine2d              <- UnlitLine2d.allocate True sMSAA pSceneBinds rpForwardMsaa
+  pLine2dNoDepth       <- UnlitLine2d.allocate False sMSAA pSceneBinds rpForwardMsaa
+  pSprite              <- UnlitSprite.allocate sMSAA Nothing False pSceneBinds rpForwardMsaa
+  pSpriteOutline       <- UnlitSprite.allocate sMSAA Nothing True pSceneBinds rpForwardMsaa
+  pTileMap             <- UnlitTileMap.allocate sMSAA pSceneBinds rpForwardMsaa
+  pTileMapBlend        <- UnlitTileMap.allocateBlend sMSAA pSceneBinds rpForwardMsaa
+  pUnlitColored        <- UnlitColored.allocate True sMSAA pSceneBinds rpForwardMsaa
+  pUnlitColoredNoDepth <- UnlitColored.allocate False sMSAA pSceneBinds rpForwardMsaa
+  pUnlitTextured       <- UnlitTextured.allocate sMSAA pSceneBinds rpForwardMsaa
+  pUnlitTexturedBlend  <- UnlitTextured.allocateBlend sMSAA pSceneBinds rpForwardMsaa
+  pWireframe           <- UnlitColored.allocateWireframe True sMSAA pSceneBinds rpForwardMsaa
+  pWireframeNoDepth    <- UnlitColored.allocateWireframe False sMSAA pSceneBinds rpForwardMsaa
 
   let pShadowBinds = Sun.set0
   pShadowCast <- ShadowPipe.allocate pShadowBinds rpShadowPass ShadowPipe.defaults
@@ -255,25 +264,33 @@
   pure Pipelines{..}
 
 allocateWorkers
-  :: Tagged Scene DsLayoutBindings
-  -> Vk.SampleCountFlagBits
+  :: Settings
+  -> Tagged Scene DsLayoutBindings
   -> RenderPasses
   -> ResourceT (StageRIO st) PipelineWorkers
-allocateWorkers sceneBinds pMSAA renderPasses = do
-  builtin <- allocatePipelines sceneBinds pMSAA renderPasses
+allocateWorkers settings sceneBinds renderPasses = do
+  builtin <- allocatePipelines settings sceneBinds renderPasses
 
-  let
-    evanwSdfFiles =
-      Graphics.basicStages
-        (shaderDir </> "evanw-sdf" <.> "vert" <.> "spv")
-        (shaderDir </> "evanw-sdf" <.> "frag" <.> "spv")
+  settingsVar <- Worker.newVar settings
+  litSpecUpdates <- Worker.mergeNeq1 settingsVar (.sLitSpec)
 
-  evanwSdfWorker <-
-    External.spawnReflect "evanw-sdf" evanwSdfFiles \(stageCode, reflect) ->
-      (EvanwSdf.config sceneBinds)
+  msdfLoader <-
+    External.spawnReflect_ "msdf" $
+      Graphics.basicStages
+        (shaderDir </> "msdf" <.> "vert" <.> "spv")
+        (shaderDir </> "msdf" <.> "frag" <.> "spv")
+  msdfWorker <-
+    Worker.merge1 msdfLoader \(stageCode, reflect) ->
+      (Msdf.config Msdf.defaultSpecialization sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
+  softmaskWorker <-
+    Worker.merge1 msdfLoader \(stageCode, reflect) ->
+      (Msdf.config Msdf.defaultSpecialization sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
 
   let
     skyboxFiles =
@@ -326,185 +343,167 @@
         , Graphics.cReflect = Just reflect
         }
 
-  let
-    litColoredFiles =
-      Graphics.basicStages
-        (shaderDir </> "lit-colored" <.> "vert" <.> "spv")
-        (shaderDir </> "lit-colored" <.> "frag" <.> "spv")
-
+  litColoredLoader <- External.spawnReflect_ "lit-colored" $
+    Graphics.basicStages
+      (shaderDir </> "lit-colored" <.> "vert" <.> "spv")
+      (shaderDir </> "lit-colored" <.> "frag" <.> "spv")
   litColoredWorker <-
-    External.spawnReflect "lit-colored" litColoredFiles \(stageCode, reflect) ->
-      (LitColored.config sceneBinds)
+    Worker.merge2 litSpecUpdates litColoredLoader \litSpec (stageCode, reflect) ->
+      (LitColored.config litSpec sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
-
   litColoredBlendWorker <-
-    External.spawnReflect "lit-colored-blend" litColoredFiles \(stageCode, reflect) ->
-      (LitColored.configBlend sceneBinds)
+    Worker.merge2 litSpecUpdates litColoredLoader \litSpec (stageCode, reflect) ->
+      (LitColored.configBlend litSpec sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
 
-  let
-    litMaterialFiles =
+  litMaterialLoader <-
+    External.spawnReflect_ "lit-material" $
       Graphics.basicStages
         (shaderDir </> "lit-material" <.> "vert" <.> "spv")
         (shaderDir </> "lit-material" <.> "frag" <.> "spv")
-
   litMaterialWorker <-
-    External.spawnReflect "lit-material" litMaterialFiles \(stageCode, reflect) ->
-      (LitMaterial.config sceneBinds)
+    Worker.merge2 litSpecUpdates litMaterialLoader \litSpec (stageCode, reflect) ->
+      (LitMaterial.config litSpec sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
 
   litMaterialBlendWorker <-
-    External.spawnReflect "lit-material-blend" litMaterialFiles \(stageCode, reflect) ->
-      (LitMaterial.configBlend sceneBinds)
+    Worker.merge2 litSpecUpdates litMaterialLoader \litSpec (stageCode, reflect) ->
+      (LitMaterial.configBlend litSpec sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
 
-  let
-    litTexturedFiles =
+  litTexturedLoader <-
+    External.spawnReflect_ "lit-textured" $
       Graphics.basicStages
         (shaderDir </> "lit-textured" <.> "vert" <.> "spv")
         (shaderDir </> "lit-textured" <.> "frag" <.> "spv")
-
   litTexturedWorker <-
-    External.spawnReflect "lit-textured" litTexturedFiles \(stageCode, reflect) ->
-      (LitTextured.config sceneBinds)
+    Worker.merge2 litSpecUpdates litTexturedLoader \litSpec (stageCode, reflect) ->
+      (LitTextured.config litSpec sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
-
   litTexturedBlendWorker <-
-    External.spawnReflect "lit-textured-blend" litTexturedFiles \(stageCode, reflect) ->
-      (LitTextured.configBlend sceneBinds)
+    Worker.merge2 litSpecUpdates litTexturedLoader \litSpec (stageCode, reflect) ->
+      (LitTextured.configBlend litSpec sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
 
-  let
-    line2dFiles =
+  line2dLoader <-
+    External.spawnReflect_ "line2d" $
       Graphics.basicStages
         (shaderDir </> "line-2d" <.> "vert" <.> "spv")
         (shaderDir </> "line-2d" <.> "frag" <.> "spv")
-
   line2dWorker <-
-    External.spawnReflect "unlit-colored" line2dFiles \(stageCode, reflect) ->
+    Worker.merge1 line2dLoader \(stageCode, reflect) ->
       (UnlitLine2d.config True sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
-
   line2dNoDepthWorker <-
-    External.spawnReflect "unlit-colored-nodepth" line2dFiles \(stageCode, reflect) ->
+    Worker.merge1 line2dLoader \(stageCode, reflect) ->
       (UnlitLine2d.config False sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
 
-  let
-    unlitColoredFiles =
+  unlitColoredLoader <-
+    External.spawnReflect_ "unlit-colored" $
       Graphics.basicStages
         (shaderDir </> "unlit-colored" <.> "vert" <.> "spv")
         (shaderDir </> "unlit-colored" <.> "frag" <.> "spv")
-
   unlitColoredWorker <-
-    External.spawnReflect "unlit-colored" unlitColoredFiles \(stageCode, reflect) ->
+     Worker.merge1 unlitColoredLoader \(stageCode, reflect) ->
       (UnlitColored.config True sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
-
   unlitColoredNoDepthWorker <-
-    External.spawnReflect "unlit-colored-nodepth" unlitColoredFiles \(stageCode, reflect) ->
+    Worker.merge1 unlitColoredLoader \(stageCode, reflect) ->
       (UnlitColored.config False sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
+  wireframeWorker <-
+    Worker.merge1 unlitColoredLoader \(stageCode, reflect) ->
+      (UnlitColored.configWireframe True sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
 
-  let
-    unlitTexturedFiles =
+  wireframeNoDepthWorker <-
+    Worker.merge1 unlitColoredLoader \(stageCode, reflect) ->
+      (UnlitColored.configWireframe False sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  unlitTexturedLoader <-
+    External.spawnReflect_ "unlit-textured" $
       Graphics.basicStages
         (shaderDir </> "unlit-textured" <.> "vert" <.> "spv")
         (shaderDir </> "unlit-textured" <.> "frag" <.> "spv")
-
   unlitTexturedWorker <-
-    External.spawnReflect "unlit-textured" unlitTexturedFiles \(stageCode, reflect) ->
+    Worker.merge1 unlitTexturedLoader \(stageCode, reflect) ->
       (UnlitTextured.config sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
-
   unlitTexturedBlendWorker <-
-    External.spawnReflect "unlit-textured" unlitTexturedFiles \(stageCode, reflect) ->
+    Worker.merge1 unlitTexturedLoader \(stageCode, reflect) ->
       (UnlitTextured.configBlend sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
 
-  let
-    spriteFiles =
+  spriteLoader <-
+    External.spawnReflect_ "sprite" $
       Graphics.basicStages
         (shaderDir </> "sprite" <.> "vert" <.> "spv")
         (shaderDir </> "sprite" <.> "frag" <.> "spv")
-
   spriteWorker <-
-    External.spawnReflect "sprite" spriteFiles \(stageCode, reflect) ->
+    Worker.merge1 spriteLoader \(stageCode, reflect) ->
       (UnlitSprite.config Nothing False sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
-
   spriteOutlineWorker <-
-    External.spawnReflect "sprite" spriteFiles \(stageCode, reflect) ->
+    Worker.merge1 spriteLoader \(stageCode, reflect) ->
       (UnlitSprite.config Nothing True sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
 
-  let
-    tileMapFiles =
+  tileMapLoader <-
+    External.spawnReflect_ "tilemap" $
       Graphics.basicStages
         (shaderDir </> "tilemap" <.> "vert" <.> "spv")
         (shaderDir </> "tilemap" <.> "frag" <.> "spv")
-
   tileMapWorker <-
-    External.spawnReflect "tilemap" tileMapFiles \(stageCode, reflect) ->
+    Worker.merge1 tileMapLoader \(stageCode, reflect) ->
       (UnlitTileMap.config sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
-
   tileMapBlendWorker <-
-    External.spawnReflect "tilemap" tileMapFiles \(stageCode, reflect) ->
+    Worker.merge1 tileMapLoader \(stageCode, reflect) ->
       (UnlitTileMap.configBlend sceneBinds)
         { Graphics.cStages = stageCode
         , Graphics.cReflect = Just reflect
         }
 
-  wireframeWorker <-
-    External.spawnReflect "wireframe" unlitColoredFiles \(stageCode, reflect) ->
-      (UnlitColored.configWireframe True sceneBinds)
-        { Graphics.cStages = stageCode
-        , Graphics.cReflect = Just reflect
-        }
-
-  wireframeNoDepthWorker <-
-    External.spawnReflect "wireframe-nodepth" unlitColoredFiles \(stageCode, reflect) ->
-      (UnlitColored.configWireframe False sceneBinds)
-        { Graphics.cStages = stageCode
-        , Graphics.cReflect = Just reflect
-        }
-
   let
     shadowCastFiles =
       Graphics.vertexOnly
         (shaderDir </> "shadow-cast" <.> "vert" <.> "spv")
-
   shadowCastWorker <-
     External.spawnReflect "shadow-cast" shadowCastFiles \(stageCode, reflect) ->
       (ShadowPipe.config (pShadowBinds builtin) ShadowPipe.defaults)
@@ -513,31 +512,37 @@
         }
 
   pure builtin
-    { pEvanwSdf = evanwSdfWorker
-    , pSkybox = skyboxWorker
-    , pDebugUV = debugUVWorker
-    , pDebugTexture = debugTextureWorker
-    , pDebugShadow = debugShadowWorker
-    , pDepthOnly = depthOnlyWorker
-    , pLitColored = litColoredWorker
-    , pLitColoredBlend = litColoredBlendWorker
-    , pLitMaterial = litMaterialWorker
-    , pLitMaterialBlend = litMaterialBlendWorker
-    , pLitTextured = litTexturedWorker
-    , pLitTexturedBlend = litTexturedBlendWorker
-    , pLine2d = line2dWorker
-    , pLine2dNoDepth = line2dNoDepthWorker
-    , pUnlitColored = unlitColoredWorker
-    , pUnlitColoredNoDepth = unlitColoredNoDepthWorker
-    , pUnlitTextured = unlitTexturedWorker
-    , pUnlitTexturedBlend = unlitTexturedBlendWorker
-    , pSprite = spriteWorker
-    , pSpriteOutline = spriteOutlineWorker
-    , pTileMap = tileMapWorker
-    , pTileMapBlend = tileMapBlendWorker
-    , pWireframe = wireframeWorker
-    , pWireframeNoDepth = wireframeNoDepthWorker
-    , pShadowCast = shadowCastWorker
+    { pSettings = Worker.getOutput settingsVar
+
+    , pSoftmask = Worker.getOutput softmaskWorker
+    , pMsdf = Worker.getOutput msdfWorker
+    , pSkybox   = Worker.getOutput skyboxWorker
+
+    , pDebugUV      = Worker.getOutput debugUVWorker
+    , pDebugTexture = Worker.getOutput debugTextureWorker
+    , pDebugShadow  = Worker.getOutput debugShadowWorker
+    , pDepthOnly    = Worker.getOutput depthOnlyWorker
+
+    , pLitColored       = Worker.getOutput litColoredWorker
+    , pLitColoredBlend  = Worker.getOutput litColoredBlendWorker
+    , pLitMaterial      = Worker.getOutput litMaterialWorker
+    , pLitMaterialBlend = Worker.getOutput litMaterialBlendWorker
+    , pLitTextured      = Worker.getOutput litTexturedWorker
+    , pLitTexturedBlend = Worker.getOutput litTexturedBlendWorker
+
+    , pLine2d              = Worker.getOutput line2dWorker
+    , pLine2dNoDepth       = Worker.getOutput line2dNoDepthWorker
+    , pUnlitColored        = Worker.getOutput unlitColoredWorker
+    , pUnlitColoredNoDepth = Worker.getOutput unlitColoredNoDepthWorker
+    , pUnlitTextured       = Worker.getOutput unlitTexturedWorker
+    , pUnlitTexturedBlend  = Worker.getOutput unlitTexturedBlendWorker
+    , pSprite              = Worker.getOutput spriteWorker
+    , pSpriteOutline       = Worker.getOutput spriteOutlineWorker
+    , pTileMap             = Worker.getOutput tileMapWorker
+    , pTileMapBlend        = Worker.getOutput tileMapBlendWorker
+    , pWireframe           = Worker.getOutput wireframeWorker
+    , pWireframeNoDepth    = Worker.getOutput wireframeNoDepthWorker
+    , pShadowCast          = Worker.getOutput shadowCastWorker
     }
 
 allocateObservers
@@ -545,133 +550,144 @@
   -> PipelineWorkers
   -> ResourceT (Engine.StageRIO rs) PipelineObservers
 allocateObservers renderPasses Pipelines{..} = do
-  evanwSdfExt <- External.newObserverGraphics
+  -- let Settings{sMSAA} = pSettings
+  initialSettings@Settings{sMSAA} <- Worker.getOutputData pSettings
+  settingsExt <- Worker.newObserverIO initialSettings
+
+  msdfExt <- External.newObserverGraphics
     forward
-    pMSAA
-    pEvanwSdf
+    sMSAA
+    pMsdf
 
+  softmaskExt <- External.newObserverGraphics
+    forward
+    sMSAA
+    pSoftmask
+
   skyboxExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pSkybox
 
   debugUVExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pDebugUV
 
   debugTextureExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pDebugTexture
 
   debugShadowExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pDebugShadow
 
   depthOnlyExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pDepthOnly
 
   litColoredExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pLitColored
 
   litColoredBlendExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pLitColoredBlend
 
   litMaterialExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pLitMaterial
 
   litMaterialBlendExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pLitMaterialBlend
 
   litTexturedExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pLitTextured
 
   litTexturedBlendExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pLitTexturedBlend
 
   line2dExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pLine2d
 
   line2dNoDepthExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pLine2dNoDepth
 
   unlitColoredExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pUnlitColored
 
   unlitColoredNoDepthExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pUnlitColoredNoDepth
 
   unlitTexturedExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pUnlitTextured
 
   unlitTexturedBlendExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pUnlitTexturedBlend
 
   spriteExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pSprite
 
   spriteOutlineExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pSpriteOutline
 
   tileMapExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pTileMap
 
   tileMapBlendExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pTileMapBlend
 
   wireframeExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pWireframe
 
   wireframeNoDepthExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pWireframeNoDepth
 
   shadowCastExt <- External.newObserverGraphics
     forward
-    pMSAA
+    sMSAA
     pShadowCast
 
   pure Pipelines
-    { pEvanwSdf = evanwSdfExt
+    { pSettings = settingsExt
+    , pMsdf = msdfExt
+    , pSoftmask = softmaskExt
     , pSkybox = skyboxExt
     , pDebugUV = debugUVExt
     , pDebugTexture = debugTextureExt
@@ -707,7 +723,8 @@
   -> PipelineObservers
   -> Engine.StageFrameRIO rp p fr rs ()
 observePipelines fRenderpass workers pipelines = do
-  observe @EvanwSdf.Pipeline pEvanwSdf
+  observe @Msdf.Pipeline pMsdf
+  observe @Msdf.Pipeline pSoftmask
 
   observe @Skybox.Pipeline pSkybox
 
@@ -741,12 +758,13 @@
   observe @UnlitColored.Pipeline pWireframe
   observe @UnlitColored.Pipeline pWireframeNoDepth
 
+  settings <- Worker.readVar workers.pSettings
   External.observeGraphics
     (rpShadowPass fRenderpass) -- XXX: different RP here
-    (pMSAA workers)
+    settings.sMSAA
     (Tagged [unTagged $ pSceneBinds pipelines])
-    (pShadowCast workers)
-    (pShadowCast pipelines)
+    workers.pShadowCast
+    pipelines.pShadowCast
   where
     observe
       :: forall
@@ -758,15 +776,17 @@
          )
       => (forall a . PipelinesF a -> a ^ p)
       -> Engine.StageFrameRIO rps ps fr rs ()
-    observe =
+    observe f = do
+      settings <- Worker.readVar workers.pSettings
       External.observeField
         @PipelinesF
         @p
         (rpForwardMsaa fRenderpass)
-        (pMSAA workers)
+        settings.sMSAA
         (pSceneBinds pipelines)
         workers
         pipelines
+        f
 
 getSceneLayout :: PipelinesF f -> Tagged '[Scene] Vk.DescriptorSetLayout
 getSceneLayout = pSceneLayout
@@ -779,7 +799,7 @@
 
 stageSources :: Map Text Graphics.StageCode
 stageSources =
-  [ ("evanw-sdf", EvanwSdf.stageCode)
+  [ ("msdf", Msdf.stageCode)
   , ("skybox", Skybox.stageCode)
   , ("debug", Debug.stageCode)
   , ("depth-only", DepthOnly.stageCode)
diff --git a/src/Render/Code/Lit.hs b/src/Render/Code/Lit.hs
--- a/src/Render/Code/Lit.hs
+++ b/src/Render/Code/Lit.hs
@@ -5,11 +5,15 @@
   , structMaterial
   , shadowFuns
   , litMain
+  , specConstants
   , brdfSpecular
   ) where
 
-import Render.Code (Code(..), trimming)
+import RIO
 
+import Render.Code (Code(..), glsl, trimming)
+import Render.Lit.Specialization (Specialization(..), defaultSpec)
+
 raySphereIntersection :: Code
 raySphereIntersection = Code
   [trimming|
@@ -171,16 +175,14 @@
     if (scene.envCubeId > -1) {
       irradiance = textureLod(
         samplerCube(
-          cubes[nonuniformEXT(scene.envCubeId)],
-          samplers[0]
+          cubes[nonuniformEXT(scene.envCubeId + IRRADIANCE_OFFSET)],
+          samplers[IRRADIANCE_SAMPLER]
         ),
         normal * vec3(1, -1, 1),
         IRRADIANCE_LOD
       ).rgb;
     }
 
-    // BUG: raw NdotV is sometimes negative, like we're looking at the back side.
-    // Cubists would appreciate, but IBL term gives that edge and the dot artifacts.
     float NdotV = clamp(dot(normal, rayDir), 0.0, 1.0);
 
     // Specular reflectance
@@ -223,15 +225,25 @@
     oColor = vec4(color, baseColor.a);
   |]
 
-brdfSpecular :: Code
-brdfSpecular = Code
-  [trimming|
-    // TODO: unhardcode
-    const int BRDF_LUT = 2;
-    const int BRDF_LUT_SAMPLER = 3; // linear/mip0/no-repeat
-    const float MAX_REFLECTION_LOD = 9.0; // todo: param/const
-    const float IRRADIANCE_LOD = 10.0; // todo: param/const
+specConstants :: Code
+specConstants = fromString
+  [glsl|
+    layout(constant_id=0) const int   BRDF_LUT           = ${sBRDF_LUT};
+    layout(constant_id=1) const int   BRDF_LUT_SAMPLER   = ${sBRDF_LUT_SAMPLER};
+    layout(constant_id=2) const float MAX_REFLECTION_LOD = ${sMAX_REFLECTION_LOD};
+    layout(constant_id=3) const float IRRADIANCE_LOD     = ${sIRRADIANCE_LOD};
+    layout(constant_id=4) const int   IRRADIANCE_OFFSET  = ${sIRRADIANCE_OFFSET};
+    layout(constant_id=5) const int   IRRADIANCE_SAMPLER = ${sIRRADIANCE_SAMPLER};
+    layout(constant_id=6) const uint  MAX_LIGHTS         = ${sMAX_LIGHTS};
+    layout(constant_id=7) const float PCF_STEP           = ${sPCF_STEP};
+    layout(constant_id=8) const uint  MAX_MATERIALS      = ${sMAX_MATERIALS};
+  |]
+  where
+    Specialization{..} = defaultSpec
 
+brdfSpecular :: Code
+brdfSpecular = fromString
+  [glsl|
     // Normal Distribution function --------------------------------------
     float D_GGX(float dotNH, float roughness) {
       float alpha = roughness * roughness;
@@ -250,10 +262,6 @@
     }
 
     // Fresnel function ----------------------------------------------------
-    vec3 F_Schlick(float cosTheta, vec3 F0) {
-      return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
-    }
-
     vec3 F_SchlickR(float cosTheta, vec3 F0, float roughness) {
       return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0);
     }
@@ -317,7 +325,7 @@
         float rroughness = max(0.05, roughness);
         float G = G_SchlicksmithGGX(dotNL, dotNV, rroughness);
         // F = Fresnel factor (Reflectance depending on angle of incidence)
-        vec3 F = F_Schlick(dotVH, F0);
+        vec3 F = F_SchlickR(dotVH, F0, rroughness);
         vec3 spec = D * F * G / (4.0 * dotNL * dotNV + 0.001);
         vec3 kD = (vec3(1.0) - F) * (1.0 - metallic);
         color += (kD * ALBEDO / 3.1415926535897932384626433832795 + spec) * dotNL * lightColor;
diff --git a/src/Render/Font/EvanwSdf/Code.hs b/src/Render/Font/EvanwSdf/Code.hs
deleted file mode 100644
--- a/src/Render/Font/EvanwSdf/Code.hs
+++ /dev/null
@@ -1,114 +0,0 @@
-module Render.Font.EvanwSdf.Code
-  ( vert
-  , frag
-  ) where
-
-import RIO
-
-import Render.Code (Code, glsl)
-import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2)
-
-vert :: Code
-vert = fromString
-  [glsl|
-    #version 450
-
-    ${set0binding0}
-
-    layout(location = 0) in  vec4 iVert;
-    layout(location = 1) in  vec4 iFrag;
-    layout(location = 2) in  vec4 iColor;
-    layout(location = 3) in  vec4 iOutlineColor;
-    layout(location = 4) in ivec2 iTextureIds;
-    layout(location = 5) in  vec2 iSdf;
-
-    layout(location = 0)      out  vec2 fTexCoord;
-    layout(location = 1) flat out  vec4 fColor;
-    layout(location = 2) flat out  vec4 fOutlineColor;
-    layout(location = 3) flat out ivec2 fTextureIds;
-    layout(location = 4) flat out  vec2 fSdf;
-
-    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
-      // TODO: * iModel
-        * vec4(iVert.xy + pos * iVert.zw, 0, 1.0);
-
-      vec2 uv = texCoords[gl_VertexIndex];
-      fTexCoord = iFrag.xy + uv * iFrag.zw;
-
-      fColor = iColor;
-      fOutlineColor = iOutlineColor;
-      fTextureIds = iTextureIds;
-      fSdf = iSdf;
-    }
-  |]
-
-frag :: Code
-frag = fromString
-  [glsl|
-    #version 450
-    #extension GL_EXT_nonuniform_qualifier : enable
-
-    ${set0binding1}
-    ${set0binding2}
-
-    layout(location = 0)      in  vec2 fTexCoord;
-    layout(location = 1) flat in  vec4 fColor;
-    layout(location = 2) flat in  vec4 fOutlineColor;
-    layout(location = 3) flat in ivec2 fTextureIds;
-    layout(location = 4) flat in  vec2 fSdf;
-
-    layout(location = 0) out vec4 outColor;
-
-    void main() {
-      float sdf = texture(
-        sampler2D(
-          textures[nonuniformEXT(fTextureIds.t)],
-          samplers[nonuniformEXT(fTextureIds.s)]
-        ),
-        fTexCoord
-      ).r;
-
-      float smoothing    = fSdf[0];
-      float outlineWidth = fSdf[1];
-
-      float outerEdgeCenter = 0.5 - outlineWidth;
-
-      float alpha = smoothstep(
-        outerEdgeCenter - smoothing,
-        outerEdgeCenter + smoothing,
-        sdf
-      );
-      float border = smoothstep(
-        0.5 - smoothing,
-        0.5 + smoothing,
-        sdf
-      );
-      outColor = mix(fOutlineColor, fColor, border);
-      outColor *= alpha;
-    }
-  |]
diff --git a/src/Render/Font/EvanwSdf/Model.hs b/src/Render/Font/EvanwSdf/Model.hs
deleted file mode 100644
--- a/src/Render/Font/EvanwSdf/Model.hs
+++ /dev/null
@@ -1,47 +0,0 @@
-{-# OPTIONS_GHC -fplugin Foreign.Storable.Generic.Plugin #-}
-
-{-# LANGUAGE DeriveAnyClass #-}
-
-module Render.Font.EvanwSdf.Model
-  ( InstanceAttrs(..)
-  ) where
-
-import RIO
-
-import Foreign.Storable.Generic (GStorable)
-import Geomancy (Vec4)
-import Vulkan.Core10 qualified as Vk
-
-import Engine.Vulkan.Format (HasVkFormat(..))
-import Engine.Vulkan.Pipeline.Graphics (HasVertexInputBindings(..), instanceFormat)
-
-data InstanceAttrs = InstanceAttrs
-  { vertRect     :: Vec4
-  , fragRect     :: Vec4
-  , color        :: Vec4
-  , outlineColor :: Vec4
-
-  , samplerId    :: Int32
-  , textureId    :: Int32
-
-  , smoothing    :: Float
-  , outlineWidth :: Float
-  }
-  deriving (Eq, Show, Generic)
-
--- XXX: Fine, the layout matches
-instance GStorable InstanceAttrs
-
-instance HasVkFormat InstanceAttrs where
-  getVkFormat =
-    [ Vk.FORMAT_R32G32B32A32_SFLOAT -- Quad scale+offset
-    , Vk.FORMAT_R32G32B32A32_SFLOAT -- UV scale+offset
-    , Vk.FORMAT_R32G32B32A32_SFLOAT -- Color
-    , Vk.FORMAT_R32G32B32A32_SFLOAT -- Outline color
-
-    , Vk.FORMAT_R32G32_SINT         -- Sampler + texture IDs
-    , Vk.FORMAT_R32G32_SFLOAT       -- Smoothing + outline width
-    ]
-
-instance HasVertexInputBindings InstanceAttrs where
-  vertexInputBindings = [instanceFormat @InstanceAttrs]
diff --git a/src/Render/Font/EvanwSdf/Pipeline.hs b/src/Render/Font/EvanwSdf/Pipeline.hs
deleted file mode 100644
--- a/src/Render/Font/EvanwSdf/Pipeline.hs
+++ /dev/null
@@ -1,64 +0,0 @@
-{-# LANGUAGE OverloadedLists #-}
-
-module Render.Font.EvanwSdf.Pipeline
-  ( Config
-  , config
-  , Pipeline
-  , allocate
-  , stageCode
-  , stageSpirv
-  ) where
-
-import RIO
-
-import Control.Monad.Trans.Resource (ResourceT)
-import Data.Tagged (Tagged(..))
-import Vulkan.Core10 qualified as Vk
-
-import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
-import Engine.Vulkan.Types (DsLayoutBindings, HasVulkan, HasRenderPass(..))
-import Render.Code (compileVert, compileFrag)
-import Render.DescSets.Set0 (Scene)
-import Render.Font.EvanwSdf.Code qualified as Code
-import Render.Font.EvanwSdf.Model qualified as Model
-
-type Pipeline = Graphics.Pipeline '[Scene] () Model.InstanceAttrs
-type Config = Graphics.Configure Pipeline
-type instance Graphics.Specialization Pipeline = ()
-
-allocate
-  :: ( HasVulkan env
-     , HasRenderPass renderpass
-     )
-  => Vk.SampleCountFlagBits
-  -> Tagged Scene DsLayoutBindings
-  -> renderpass
-  -> ResourceT (RIO env) Pipeline
-allocate multisample tset0 = do
-  fmap snd . Graphics.allocate
-    Nothing
-    multisample
-    (config tset0)
-
-config :: Tagged Scene DsLayoutBindings -> Config
-config (Tagged set0) = Graphics.baseConfig
-  { Graphics.cDescLayouts = Tagged @'[Scene] [set0]
-  , Graphics.cStages      = stageSpirv
-  , Graphics.cVertexInput = Graphics.vertexInput @Pipeline
-  , Graphics.cDepthTest   = False
-  , Graphics.cDepthWrite  = False
-  , Graphics.cBlend       = True
-  , Graphics.cCull        = Vk.CULL_MODE_NONE
-  }
-
-stageCode :: Graphics.StageCode
-stageCode = Graphics.basicStages Code.vert Code.frag
-
-stageSpirv :: Graphics.StageSpirv
-stageSpirv = Graphics.basicStages vertSpirv fragSpirv
-
-vertSpirv :: ByteString
-vertSpirv = $(compileVert Code.vert)
-
-fragSpirv :: ByteString
-fragSpirv = $(compileFrag Code.frag)
diff --git a/src/Render/Font/Msdf/Code.hs b/src/Render/Font/Msdf/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Font/Msdf/Code.hs
@@ -0,0 +1,167 @@
+module Render.Font.Msdf.Code
+  ( vert
+  , frag
+  ) where
+
+import RIO
+
+import Render.Code (Code, glsl)
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2)
+
+vert :: Code
+vert = fromString
+  [glsl|
+    #version 450
+
+    ${set0binding0}
+
+    layout(location = 0) in  vec4 iVert;
+    layout(location = 1) in  vec4 iFrag;
+    layout(location = 2) in ivec2 iTextureIds;
+    layout(location = 3) in  vec2 iSdfDist;
+
+    layout(location = 4) in  vec4 iColor;
+    layout(location = 5) in  vec4 iOutlineColor;
+    layout(location = 6) in  vec2 iSdf;
+
+    layout(location = 0)      out  vec2 fTexCoord;
+    layout(location = 1) flat out  vec4 fColor;
+    layout(location = 2) flat out  vec4 fOutlineColor;
+    layout(location = 3) flat out  vec4 fSdf;
+    layout(location = 4) flat out ivec2 fTextureIds;
+
+    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, 1),
+      vec2(1.0, 1),
+      vec2(0.0, 0),
+
+      vec2(1.0, 0),
+      vec2(0.0, 0),
+      vec2(1.0, 1)
+    );
+
+    void main() {
+      vec2 pos = positions[gl_VertexIndex];
+
+      gl_Position
+        = scene.projection
+        * scene.view
+      // TODO: * iModel
+        * vec4(iVert.xy + pos * iVert.zw, 0, 1.0);
+
+      vec2 uv = texCoords[gl_VertexIndex];
+      fTexCoord = iFrag.xy + uv * iFrag.zw;
+
+      fColor = iColor;
+      fOutlineColor = iOutlineColor;
+      fTextureIds = iTextureIds;
+      fSdf = vec4(iSdfDist, iSdf);
+    }
+  |]
+
+frag :: Code
+frag = fromString
+  [glsl|
+    #version 450
+    #extension GL_EXT_nonuniform_qualifier : enable
+
+    ${set0binding1}
+    ${set0binding2}
+
+    layout(location = 0)      in  vec2 fTexCoord;
+    layout(location = 1) flat in  vec4 fColor;
+    layout(location = 2) flat in  vec4 fOutlineColor;
+    layout(location = 3) flat in  vec4 fSdf;
+    layout(location = 4) flat in ivec2 fTextureIds;
+
+    layout(location = 0) out vec4 outColor;
+
+    #define HARDMASK 0
+    #define SOFTMASK 1
+    #define SDF 2
+    #define PSDF 3
+    #define MSDF 4
+    #define MTSDF 5
+
+    layout(constant_id = 0) const uint type = MTSDF;
+    layout(constant_id = 1) const bool srgb = false;
+
+    float median3(float a, float b, float c) {
+      return max(min(a, b), min(max(a, b), c));
+    }
+
+    void main() {
+      float hard; // text
+      float soft; // effects
+
+      switch (type) {
+        // single-channel
+        case HARDMASK: // actually, bit-packed
+        case SOFTMASK: // r8, antialiased greyscale
+        case SDF: // r8, actually distance
+        case PSDF: // r8, dunno
+          hard = texture(
+            sampler2D(
+              textures[nonuniformEXT(fTextureIds[1])],
+              samplers[nonuniformEXT(fTextureIds[0])]
+            ),
+            fTexCoord
+          ).r;
+          if (srgb) hard = pow(hard, 1/2.2);
+          // No outlines for non-SDF types, just apply tint
+          outColor = fColor * vec4(hard);
+          return;
+        // 3-channel
+        case MSDF:
+          vec3 msdf = texture(
+            sampler2D(
+              textures[nonuniformEXT(fTextureIds[1])],
+              samplers[nonuniformEXT(fTextureIds[0])]
+            ),
+            fTexCoord
+          ).rgb;
+          hard = median3(msdf.r, msdf.g, msdf.b);
+          if (srgb) hard = pow(hard, 1/2.2);
+          soft = hard; // no dedicated channel
+          break;
+        // 4-channel
+        case MTSDF:
+          vec4 mtsdf = texture(
+            sampler2D(
+              textures[nonuniformEXT(fTextureIds[1])],
+              samplers[nonuniformEXT(fTextureIds[0])]
+            ),
+            fTexCoord
+          ).rgba;
+          hard = median3(mtsdf.r, mtsdf.g, mtsdf.b);
+          if (srgb) hard = pow(hard, 1/2.2);
+          soft = mtsdf.a; // dedicated channel for soft effects
+          break;
+      }
+
+      float sdfLower = fSdf[0];
+      float sdfRange = fSdf[1];
+      float sdfSmoothing = fSdf[2]; // fallback
+      float sdfOutline = fSdf[3];
+
+      float hardPx = sdfLower + (hard * sdfRange);
+      float hardWidth = max(fwidth(hard) * sdfRange, sdfSmoothing);
+      float hardCoverage = smoothstep(-0.5, 0.5, hardPx / hardWidth);
+
+     	float softPx = sdfLower + (soft * sdfRange);
+     	float softWidth = max(fwidth(soft) * sdfRange, sdfSmoothing);
+     	float softCoverage = clamp(smoothstep(-sdfOutline / softWidth, 0, softPx / softWidth), 0, 1);
+
+     	outColor = mix(fOutlineColor, fColor, hardCoverage) * softCoverage;
+    }
+  |]
diff --git a/src/Render/Font/Msdf/Model.hs b/src/Render/Font/Msdf/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Font/Msdf/Model.hs
@@ -0,0 +1,48 @@
+{-# OPTIONS_GHC -fplugin Foreign.Storable.Generic.Plugin #-}
+
+{-# LANGUAGE DeriveAnyClass #-}
+
+module Render.Font.Msdf.Model
+  ( InstanceAttrs(..)
+  ) where
+
+import RIO
+
+import Geomancy (IVec2, Vec2, Vec4)
+import Graphics.Gl.Block (Block)
+import Graphics.Gl.Block qualified as Block
+import Vulkan.Core10 qualified as Vk
+
+import Engine.Vulkan.Format (HasVkFormat(..))
+import Engine.Vulkan.Pipeline.Graphics (HasVertexInputBindings(..), instanceFormat)
+
+data InstanceAttrs = InstanceAttrs
+  { -- static, can be shoved into font buffer
+    vertRect     :: Vec4
+  , fragRect     :: Vec4
+  , textureIds    :: IVec2 -- | sampler + textureId
+  , sdfDist :: Vec2
+
+    -- dynamic
+  , color        :: Vec4
+  , outlineColor :: Vec4
+  , smoothing    :: Float -- size-related
+  , outlineWidth :: Float
+  }
+  deriving (Eq, Show, Generic, Block)
+  deriving Storable via (Block.Packed InstanceAttrs)
+
+instance HasVkFormat InstanceAttrs where
+  getVkFormat =
+    [ Vk.FORMAT_R32G32B32A32_SFLOAT -- Quad scale+offset
+    , Vk.FORMAT_R32G32B32A32_SFLOAT -- UV scale+offset
+    , Vk.FORMAT_R32G32_SINT         -- Sampler + texture IDs
+    , Vk.FORMAT_R32G32_SFLOAT -- SDF lower + range
+    -----
+    , Vk.FORMAT_R32G32B32A32_SFLOAT -- Color
+    , Vk.FORMAT_R32G32B32A32_SFLOAT -- Outline color
+    , Vk.FORMAT_R32G32_SFLOAT -- Smoothing + outline
+    ]
+
+instance HasVertexInputBindings InstanceAttrs where
+  vertexInputBindings = [instanceFormat @InstanceAttrs]
diff --git a/src/Render/Font/Msdf/Pipeline.hs b/src/Render/Font/Msdf/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Font/Msdf/Pipeline.hs
@@ -0,0 +1,96 @@
+{-# LANGUAGE OverloadedLists #-}
+
+module Render.Font.Msdf.Pipeline
+  ( Config
+  , config
+  , Pipeline
+  , allocate
+  , Specialization(..)
+  , Mode(..)
+  , defaultSpecialization
+  , stageCode
+  , stageSpirv
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import Vulkan.Core10 qualified as Vk
+
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
+import Engine.Vulkan.Shader qualified as Shader
+import Engine.Vulkan.Types (DsLayoutBindings, HasVulkan, HasRenderPass(..))
+import Render.Code (compileVert, compileFrag)
+import Render.DescSets.Set0 (Scene)
+import Render.Font.Msdf.Code qualified as Code
+import Render.Font.Msdf.Model qualified as Model
+
+type Pipeline = Graphics.Pipeline '[Scene] () Model.InstanceAttrs
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = Specialization
+
+data Specialization = Specialization
+  { mode :: Mode
+  , srgb :: Bool
+  } deriving (Eq, Ord, Show)
+
+defaultSpecialization :: Specialization
+defaultSpecialization = Specialization
+  { mode = MTSDF
+  , srgb = False
+  }
+
+data Mode
+  = HardMask
+  | SoftMask
+  | SDF
+  | PSDF
+  | MSDF
+  | MTSDF
+  deriving (Eq, Ord, Show, Enum, Bounded)
+
+instance Shader.Specialization Specialization where
+  specializationData Specialization{..} =
+    [ fromIntegral (fromEnum mode)
+    , Shader.packConstData srgb
+    ]
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Specialization
+  -> Vk.SampleCountFlagBits
+  -> Tagged Scene DsLayoutBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocate spec multisample tset0 = do
+  fmap snd . Graphics.allocate
+    Nothing
+    multisample
+    (config spec tset0)
+
+config :: Specialization -> Tagged Scene DsLayoutBindings -> Config
+config spec (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts = Tagged @'[Scene] [set0]
+  , Graphics.cStages      = stageSpirv
+  , Graphics.cVertexInput = Graphics.vertexInput @Pipeline
+  , Graphics.cDepthTest   = False
+  , Graphics.cDepthWrite  = False
+  , Graphics.cBlend       = True
+  , Graphics.cCull        = Vk.CULL_MODE_NONE
+  , Graphics.cSpecialization = spec
+  }
+
+stageCode :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
+
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
+
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
+
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
diff --git a/src/Render/Lit/Colored/Code.hs b/src/Render/Lit/Colored/Code.hs
--- a/src/Render/Lit/Colored/Code.hs
+++ b/src/Render/Lit/Colored/Code.hs
@@ -6,7 +6,7 @@
 import RIO
 
 import Render.Code (Code, glsl)
-import Render.Code.Lit (litMain, shadowFuns, structLight, brdfSpecular)
+import Render.Code.Lit (brdfSpecular, litMain, shadowFuns, specConstants, structLight)
 import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding4, set0binding5)
 
 vert :: Code
@@ -58,9 +58,7 @@
 
     layout(early_fragment_tests) in;
 
-    // TODO: move to spec constant
-    const uint MAX_LIGHTS = 255;
-    const float PCF_STEP = 1.5 / 4096;
+    ${specConstants}
 
     // TODO: move to material
     const float reflectivity = 1.0/256.0;
diff --git a/src/Render/Lit/Colored/Pipeline.hs b/src/Render/Lit/Colored/Pipeline.hs
--- a/src/Render/Lit/Colored/Pipeline.hs
+++ b/src/Render/Lit/Colored/Pipeline.hs
@@ -23,24 +23,26 @@
 import Render.DescSets.Set0 (Scene)
 import Render.Lit.Colored.Code qualified as Code
 import Render.Lit.Colored.Model qualified as Model
+import Render.Lit.Specialization (Specialization)
 
 type Pipeline = Graphics.Pipeline '[Scene] Model.Vertex Model.InstanceAttrs
 type Config = Graphics.Configure Pipeline
-type instance Graphics.Specialization Pipeline = ()
+type instance Graphics.Specialization Pipeline = Specialization
 
 allocate
   :: ( HasVulkan env
      , HasRenderPass renderpass
      )
-  => Vk.SampleCountFlagBits
+  => Specialization
+  -> Vk.SampleCountFlagBits
   -> Tagged Scene DsLayoutBindings
   -> renderpass
   -> ResourceT (RIO env) Pipeline
-allocate multisample tset0 rp = do
+allocate spec multisample tset0 rp = do
   (_, p) <- Graphics.allocate
     Nothing
     multisample
-    (config tset0)
+    (config spec tset0)
     rp
   pure p
 
@@ -48,29 +50,31 @@
   :: ( HasVulkan env
      , HasRenderPass renderpass
      )
-  => Vk.SampleCountFlagBits
+  => Specialization
+  -> Vk.SampleCountFlagBits
   -> Tagged Scene DsLayoutBindings
   -> renderpass
   -> ResourceT (RIO env) Pipeline
-allocateBlend multisample tset0 rp = do
+allocateBlend spec multisample tset0 rp = do
   (_, p) <- Graphics.allocate
     Nothing
     multisample
-    (configBlend tset0)
+    (configBlend spec tset0)
     rp
   pure p
 
-config :: Tagged Scene DsLayoutBindings -> Config
-config (Tagged set0) = Graphics.baseConfig
-  { Graphics.cDescLayouts  = Tagged @'[Scene] [set0]
-  , Graphics.cStages       = stageSpirv
-  , Graphics.cVertexInput  = Graphics.vertexInput @Pipeline
-  , Graphics.cDepthWrite   = False -- XXX: Lit pipelines require depth pre-pass
-  , Graphics.cDepthCompare = Vk.COMPARE_OP_EQUAL
+config :: Specialization -> Tagged Scene DsLayoutBindings -> Config
+config spec (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts    = Tagged @'[Scene] [set0]
+  , Graphics.cStages         = stageSpirv
+  , Graphics.cVertexInput    = Graphics.vertexInput @Pipeline
+  , Graphics.cDepthWrite     = False -- XXX: Lit pipelines require depth pre-pass
+  , Graphics.cDepthCompare   = Vk.COMPARE_OP_EQUAL
+  , Graphics.cSpecialization = spec
   }
 
-configBlend :: Tagged Scene DsLayoutBindings -> Config
-configBlend tset0 = (config tset0)
+configBlend :: Specialization -> Tagged Scene DsLayoutBindings -> Config
+configBlend spec tset0 = (config spec tset0)
   { Graphics.cBlend = True
   , Graphics.cDepthCompare =
       -- XXX: blended objects aren't a part of depth prepass
diff --git a/src/Render/Lit/Material/Code.hs b/src/Render/Lit/Material/Code.hs
--- a/src/Render/Lit/Material/Code.hs
+++ b/src/Render/Lit/Material/Code.hs
@@ -6,7 +6,7 @@
 import RIO
 
 import Render.Code (Code, glsl)
-import Render.Code.Lit (litMain, shadowFuns, structLight, structMaterial, brdfSpecular)
+import Render.Code.Lit (brdfSpecular, litMain, shadowFuns, specConstants, structLight, structMaterial)
 import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding4, set0binding5, set0binding6)
 
 vert :: Code
@@ -64,12 +64,7 @@
 
     layout(early_fragment_tests) in;
 
-    // XXX: copypasta from Lit.Colored
-    // TODO: move to spec constant
-    const uint MAX_LIGHTS = 255;
-    const float PCF_STEP = 1.5 / 4096;
-
-    const uint MAX_MATERIALS = 2048;
+    ${specConstants}
 
     ${structLight}
     ${structMaterial}
@@ -127,7 +122,7 @@
         ).rgb;
         // XXX: assuming sRGB textures, even for AMR
         packed = pow(packed, vec3(1.0/2.2));
-        nonOcclusion -= packed.r;
+        nonOcclusion *= packed.r;
         metallic *= packed.b;
         roughness *= packed.g;
       }
diff --git a/src/Render/Lit/Material/Pipeline.hs b/src/Render/Lit/Material/Pipeline.hs
--- a/src/Render/Lit/Material/Pipeline.hs
+++ b/src/Render/Lit/Material/Pipeline.hs
@@ -23,24 +23,26 @@
 import Render.DescSets.Set0 (Scene)
 import Render.Lit.Material.Code qualified as Code
 import Render.Lit.Material.Model qualified as Model
+import Render.Lit.Specialization (Specialization)
 
 type Pipeline = Graphics.Pipeline '[Scene] Model.Vertex Model.InstanceAttrs
 type Config = Graphics.Configure Pipeline
-type instance Graphics.Specialization Pipeline = ()
+type instance Graphics.Specialization Pipeline = Specialization
 
 allocate
   :: ( HasVulkan env
      , HasRenderPass renderpass
      )
-  => Vk.SampleCountFlagBits
+  => Specialization
+  -> Vk.SampleCountFlagBits
   -> Tagged Scene DsLayoutBindings
   -> renderpass
   -> ResourceT (RIO env) Pipeline
-allocate multisample tset0 rp = do
+allocate spec multisample tset0 rp = do
   (_, p) <- Graphics.allocate
     Nothing
     multisample
-    (config tset0)
+    (config spec tset0)
     rp
   pure p
 
@@ -48,29 +50,31 @@
   :: ( HasVulkan env
      , HasRenderPass renderpass
      )
-  => Vk.SampleCountFlagBits
+  => Specialization
+  -> Vk.SampleCountFlagBits
   -> Tagged Scene DsLayoutBindings
   -> renderpass
   -> ResourceT (RIO env) Pipeline
-allocateBlend multisample tset0 rp = do
+allocateBlend spec multisample tset0 rp = do
   (_, p) <- Graphics.allocate
     Nothing
     multisample
-    (configBlend tset0)
+    (configBlend spec tset0)
     rp
   pure p
 
-config :: Tagged Scene DsLayoutBindings -> Config
-config (Tagged set0) = Graphics.baseConfig
-  { Graphics.cDescLayouts  = Tagged @'[Scene] [set0]
-  , Graphics.cStages       = stageSpirv
-  , Graphics.cVertexInput  = Graphics.vertexInput @Pipeline
-  , Graphics.cDepthWrite   = False -- XXX: Lit pipelines require depth pre-pass
-  , Graphics.cDepthCompare = Vk.COMPARE_OP_EQUAL
+config :: Specialization -> Tagged Scene DsLayoutBindings -> Config
+config spec (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts    = Tagged @'[Scene] [set0]
+  , Graphics.cStages         = stageSpirv
+  , Graphics.cVertexInput    = Graphics.vertexInput @Pipeline
+  , Graphics.cDepthWrite     = False -- XXX: Lit pipelines require depth pre-pass
+  , Graphics.cDepthCompare   = Vk.COMPARE_OP_EQUAL
+  , Graphics.cSpecialization = spec
   }
 
-configBlend :: Tagged Scene DsLayoutBindings -> Config
-configBlend tset0 = (config tset0)
+configBlend :: Specialization -> Tagged Scene DsLayoutBindings -> Config
+configBlend spec tset0 = (config spec tset0)
   { Graphics.cBlend =
       True
   , Graphics.cDepthCompare =
diff --git a/src/Render/Lit/Specialization.hs b/src/Render/Lit/Specialization.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Specialization.hs
@@ -0,0 +1,51 @@
+module Render.Lit.Specialization
+  ( Specialization(..)
+  , defaultSpec
+  ) where
+
+import RIO
+
+import Engine.Vulkan.Shader qualified as Shader
+
+import Global.Resource.Texture.Base qualified as Base
+import Render.Samplers qualified as Samplers
+
+defaultSpec :: Specialization
+defaultSpec = Specialization
+  { sBRDF_LUT           = Base.indices.ibl_brdf_lut
+  , sBRDF_LUT_SAMPLER   = Samplers.indices.linear
+  , sMAX_REFLECTION_LOD = 9.0
+  , sIRRADIANCE_LOD     = 10.0
+  , sIRRADIANCE_OFFSET  = 0
+  , sIRRADIANCE_SAMPLER = Samplers.indices.linearMipRepeat
+  , sMAX_LIGHTS         = 255
+  , sPCF_STEP           = 1.5 / 4096
+  , sMAX_MATERIALS      = 2048
+  }
+
+data Specialization = Specialization
+  { sBRDF_LUT           :: Int32
+  , sBRDF_LUT_SAMPLER   :: Int32
+  , sMAX_REFLECTION_LOD :: Float
+  , sIRRADIANCE_LOD     :: Float
+  , sIRRADIANCE_OFFSET  :: Int32
+  , sIRRADIANCE_SAMPLER :: Int32
+  , sMAX_LIGHTS         :: Word32
+  , sPCF_STEP           :: Float
+  , sMAX_MATERIALS      :: Word32
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance Shader.Specialization Specialization where
+  {-# INLINE specializationData #-}
+  specializationData Specialization{..} =
+    [ Shader.packConstData sBRDF_LUT
+    , Shader.packConstData sBRDF_LUT_SAMPLER
+    , Shader.packConstData sMAX_REFLECTION_LOD
+    , Shader.packConstData sIRRADIANCE_LOD
+    , Shader.packConstData sIRRADIANCE_OFFSET
+    , Shader.packConstData sIRRADIANCE_SAMPLER
+    , Shader.packConstData sMAX_LIGHTS
+    , Shader.packConstData sPCF_STEP
+    , Shader.packConstData sMAX_MATERIALS
+    ]
diff --git a/src/Render/Lit/Textured/Code.hs b/src/Render/Lit/Textured/Code.hs
--- a/src/Render/Lit/Textured/Code.hs
+++ b/src/Render/Lit/Textured/Code.hs
@@ -6,7 +6,7 @@
 import RIO
 
 import Render.Code (Code, glsl)
-import Render.Code.Lit (litMain, shadowFuns, structLight, brdfSpecular)
+import Render.Code.Lit (brdfSpecular, litMain, shadowFuns, specConstants, structLight)
 import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding4, set0binding5)
 
 vert :: Code
@@ -61,10 +61,7 @@
 
     layout(early_fragment_tests) in;
 
-    // XXX: copypasta from Lit.Colored
-    // TODO: move to spec constant
-    const uint MAX_LIGHTS = 255;
-    const float PCF_STEP = 1.5 / 4096;
+    ${specConstants}
 
     // TODO: move to material
     const float reflectivity = 1.0/256.0;
diff --git a/src/Render/Lit/Textured/Pipeline.hs b/src/Render/Lit/Textured/Pipeline.hs
--- a/src/Render/Lit/Textured/Pipeline.hs
+++ b/src/Render/Lit/Textured/Pipeline.hs
@@ -23,24 +23,26 @@
 import Render.DescSets.Set0 (Scene)
 import Render.Lit.Textured.Code qualified as Code
 import Render.Lit.Textured.Model qualified as Model
+import Render.Lit.Specialization (Specialization)
 
 type Pipeline = Graphics.Pipeline '[Scene] Model.Vertex Model.Attrs
 type Config = Graphics.Configure Pipeline
-type instance Graphics.Specialization Pipeline = ()
+type instance Graphics.Specialization Pipeline = Specialization
 
 allocate
   :: ( HasVulkan env
      , HasRenderPass renderpass
      )
-  => Vk.SampleCountFlagBits
+  => Specialization
+  -> Vk.SampleCountFlagBits
   -> Tagged Scene DsLayoutBindings
   -> renderpass
   -> ResourceT (RIO env) Pipeline
-allocate multisample tset0 rp = do
+allocate spec multisample tset0 rp = do
   (_, p) <- Graphics.allocate
     Nothing
     multisample
-    (config tset0)
+    (config spec tset0)
     rp
   pure p
 
@@ -48,29 +50,31 @@
   :: ( HasVulkan env
      , HasRenderPass renderpass
      )
-  => Vk.SampleCountFlagBits
+  => Specialization
+  -> Vk.SampleCountFlagBits
   -> Tagged Scene DsLayoutBindings
   -> renderpass
   -> ResourceT (RIO env) Pipeline
-allocateBlend multisample tset0 rp = do
+allocateBlend spec multisample tset0 rp = do
   (_, p) <- Graphics.allocate
     Nothing
     multisample
-    (configBlend tset0)
+    (configBlend spec tset0)
     rp
   pure p
 
-config :: Tagged Scene DsLayoutBindings -> Config
-config (Tagged set0) = Graphics.baseConfig
-  { Graphics.cDescLayouts  = Tagged @'[Scene] [set0]
-  , Graphics.cStages       = stageSpirv
-  , Graphics.cVertexInput  = Graphics.vertexInput @Pipeline
-  , Graphics.cDepthWrite   = False -- XXX: Lit pipelines require depth pre-pass
-  , Graphics.cDepthCompare = Vk.COMPARE_OP_EQUAL
+config :: Specialization -> Tagged Scene DsLayoutBindings -> Config
+config spec (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts    = Tagged @'[Scene] [set0]
+  , Graphics.cStages         = stageSpirv
+  , Graphics.cVertexInput    = Graphics.vertexInput @Pipeline
+  , Graphics.cDepthWrite     = False -- XXX: Lit pipelines require depth pre-pass
+  , Graphics.cDepthCompare   = Vk.COMPARE_OP_EQUAL
+  , Graphics.cSpecialization = spec
   }
 
-configBlend :: Tagged Scene DsLayoutBindings -> Config
-configBlend tset0 = (config tset0)
+configBlend :: Specialization -> Tagged Scene DsLayoutBindings -> Config
+configBlend spec tset0 = (config spec tset0)
   { Graphics.cBlend = True
   , Graphics.cDepthCompare =
       -- XXX: blended objects aren't a part of depth prepass
diff --git a/src/Render/ShadowMap/RenderPass.hs b/src/Render/ShadowMap/RenderPass.hs
--- a/src/Render/ShadowMap/RenderPass.hs
+++ b/src/Render/ShadowMap/RenderPass.hs
@@ -27,8 +27,11 @@
 import Resource.Region qualified as Region
 import Resource.Vulkan.Named qualified as Named
 
--- * Depth-only pass for shadowmapping pipelines
+{- | Depth-only pass for multi-view shadowmapping pipelines
 
+Each light writes to its own layer of the same size.
+Instantiate multiple times for different sizes.
+-}
 data ShadowMap = ShadowMap
   { smRenderPass  :: Vk.RenderPass
   , smDepthImage  :: AllocatedImage
diff --git a/src/Render/Unlit/Line2d/Model.hs b/src/Render/Unlit/Line2d/Model.hs
--- a/src/Render/Unlit/Line2d/Model.hs
+++ b/src/Render/Unlit/Line2d/Model.hs
@@ -26,7 +26,6 @@
 import RIO
 
 import Control.Monad.Trans.Resource qualified as Resource
-import Data.Type.Equality (type (~))
 import Data.Vector.Generic qualified as Generic
 import Foreign.Storable.Generic (GStorable)
 import Geomancy (Vec2, Vec4, vec3)
diff --git a/src/Resource/Font.hs b/src/Resource/Font.hs
--- a/src/Resource/Font.hs
+++ b/src/Resource/Font.hs
@@ -1,6 +1,5 @@
 module Resource.Font
-  ( Config(..)
-  , collectionTextures
+  ( collectionTextures
 
   , Font(..)
   , allocate
@@ -14,26 +13,20 @@
 import Vulkan.Core10 qualified as Vk
 
 import Engine.Vulkan.Types (MonadVulkan, Queues)
-import Resource.Font.EvanW qualified as EvanW
+import Resource.Font.Ktxf qualified as Ktxf
 import Resource.Source (Source)
 import Resource.Texture (Texture, Flat)
 import Resource.Texture.Ktx2 qualified as Ktx2
 
 -- * General collection tools
 
-data Config = Config
-  { configContainer :: Source
-  , configTexture   :: Source
-  }
-  deriving (Show)
-
 collectionTextures :: Foldable collection => collection Font -> Vector (Texture Flat)
 collectionTextures = Vector.fromList . map texture . toList
 
 -- * Individual fonts
 
 data Font = Font
-  { container :: EvanW.Container
+  { bundle :: Ktxf.Bundle
   , texture   :: Texture Flat
   }
 
@@ -45,13 +38,9 @@
      , Resource.MonadResource m
      )
   => Queues Vk.CommandPool
-  -> Config
+  -> Source
   -> m Font
-allocate pools Config{..} = do
-  container <- withFrozenCallStack $
-    EvanW.load configContainer
-
-  texture <- withFrozenCallStack $
-    Ktx2.load pools configTexture
-
+allocate pools source = do
+  bundle <- withFrozenCallStack $ Ktxf.load source
+  texture <- withFrozenCallStack $ Ktx2.load pools source
   pure Font{..}
diff --git a/src/Resource/Font/EvanW.hs b/src/Resource/Font/EvanW.hs
deleted file mode 100644
--- a/src/Resource/Font/EvanW.hs
+++ /dev/null
@@ -1,175 +0,0 @@
-{- |
-  JSON font loader for bitmaps and SDFs
-
-  Generator: https://evanw.github.io/font-texture-generator/
-
-  Usage (WebGL): https://evanw.github.io/font-texture-generator/example-webgl/
--}
-
-module Resource.Font.EvanW
-  ( load
-  , Container(..)
-  , Character(..)
-
-  , putLine
-  , PutChar(..)
-  ) where
-
-import RIO
-
-import Data.Aeson (FromJSON, eitherDecodeStrict')
-import Foreign qualified
-import Geomancy (Vec2, vec2, pattern WithVec2)
-import Geomancy.Layout qualified as Layout
-import Geomancy.Layout.Alignment (Alignment)
-import Geomancy.Layout.Box (Box(..))
-import Geomancy.Layout.Box qualified as Box
-import GHC.Stack (withFrozenCallStack)
-import RIO.HashMap qualified as HashMap
-import RIO.Text qualified as Text
-import Vulkan.NamedType ((:::))
-
-import Resource.Source (Source)
-import Resource.Source qualified as Source
-
--- * Loading
-
-newtype FontError = FontError Text
-  deriving (Eq, Ord, Show, Generic)
-
-instance Exception FontError
-
-data Container = Container
-  { name       :: Text
-  , size       :: Float
-  , bold       :: Bool
-  , italic     :: Bool
-  , width      :: Float
-  , height     :: Float
-  , characters :: HashMap Char Character
-  }
-  deriving (Eq, Ord, Show, Generic)
-
-data Character = Character
-  { x       :: Float
-  , y       :: Float
-  , width   :: Float
-  , height  :: Float
-  , originX :: Float
-  , originY :: Float
-  , advance :: Float
-  }
-  deriving (Eq, Ord, Show, Generic)
-
-instance FromJSON Container
-instance FromJSON Character
-
-load
-  :: ( MonadIO m
-     , MonadReader env m
-     , HasLogFunc env
-     , 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
-
--- * Typesetting
-
-data PutChar = PutChar
-  { pcPos    :: Vec2
-  , pcSize   :: Vec2
-  , pcOffset :: Vec2
-  , pcScale  :: Vec2
-  } deriving (Show)
-
-instance Foreign.Storable PutChar where
-  alignment ~_ = 16
-
-  sizeOf ~_ = 32 -- 4 of pairs of floats
-
-  peek ptr = PutChar
-    <$> Foreign.peekElemOff (Foreign.castPtr ptr) 0
-    <*> Foreign.peekElemOff (Foreign.castPtr ptr) 1
-    <*> Foreign.peekElemOff (Foreign.castPtr ptr) 2
-    <*> Foreign.peekElemOff (Foreign.castPtr ptr) 3
-
-  poke ptr PutChar{..} = do
-    Foreign.pokeElemOff (Foreign.castPtr ptr) 0 pcPos
-    Foreign.pokeElemOff (Foreign.castPtr ptr) 1 pcSize
-    Foreign.pokeElemOff (Foreign.castPtr ptr) 2 pcOffset
-    Foreign.pokeElemOff (Foreign.castPtr ptr) 3 pcScale
-
-putLine
-  :: "WH"        ::: Vec2
-  -> "XY"        ::: Vec2
-  -> "Alignment" ::: Alignment
-  -> "Size"      ::: Float
-  -> "Font"      ::: Container
-  -> "Line"      ::: [Char]
-  -> ("scale" ::: Float, [PutChar])
-putLine bs bp a targetSize font =
-  (sizeScale,) . extract . foldl' step (0, [])
-  where
-    parent = Box
-      { position = bp
-      , size = bs
-      }
-
-    Container
-      { size   = fontSize
-      , width  = atlasWidth
-      , height = atlasHeight
-      , characters
-      } = font
-
-    sizeScale = targetSize / fontSize
-    baseline = 0.7125
-
-    extract (offX, bits) =
-      Box.withTRBL (Layout.placeSize a (vec2 (offX * sizeScale) targetSize) parent) \t _r _b l -> do
-        (WithVec2 w h, WithVec2 x y, (pcOffset, pcScale)) <- bits
-        let
-          pcPos =
-            vec2
-              (l + x * sizeScale)
-              (t + y * sizeScale + baseline * targetSize)
-
-          pcSize =
-            vec2
-              (w * sizeScale)
-              (h * sizeScale)
-
-        pure PutChar{..}
-
-    step (offX, acc) = \case
-      ' ' ->
-        ( offX + fontSize / 2
-        , acc
-        )
-
-      char ->
-        case HashMap.lookup char characters of
-          Nothing ->
-            ( offX
-            , acc
-            )
-          Just Character{..} ->
-            ( offX + advance
-            , ( vec2 width (-height)
-              , vec2 ox oy
-              , (uvOffset, uvScale)
-              ) : acc
-            )
-            where
-              ox = width / 2 - originX + offX
-              oy = height / 2 - originY
-
-              uvOffset = vec2 (x / atlasWidth) (y / atlasHeight)
-              uvScale  = vec2 (width / atlasWidth) (height / atlasHeight)
diff --git a/src/Resource/Font/Ktxf.hs b/src/Resource/Font/Ktxf.hs
new file mode 100644
--- /dev/null
+++ b/src/Resource/Font/Ktxf.hs
@@ -0,0 +1,141 @@
+module Resource.Font.Ktxf
+  ( Font.Bundle(..)
+  , load
+
+  , Stack(..)
+  , allocateMonoStacks
+  , allocateStack1
+
+  , shape
+  , Layout.ShapedText
+  , place
+  , Layout.Strategy(..)
+  , Layout.LineEnd(..)
+  , PutChar(..)
+  ) where
+
+import RIO
+
+import Geomancy (Vec2, vec2, withVec2)
+import Geomancy.Layout.Alignment (Alignment(..))
+import Geomancy.Layout.Box (Box(..))
+import UnliftIO.Resource (MonadResource)
+import UnliftIO.Resource qualified as Resource
+
+import Resource.Source (Source)
+import Resource.Source qualified as Source
+
+import Codec.Ktx2.Font (Bundle(..), loadBundleFile, loadBundleBytes)
+import Codec.Ktx2.Font qualified as Font
+import Codec.Ktx2.Font.Layout qualified as Layout
+import Codec.Ktx2.Font.Shaping qualified as Shaping
+import Graphics.MSDF.Atlas.Compact qualified as Atlas
+
+-- * Loading
+
+newtype FontError = FontError Text
+  deriving (Eq, Ord, Show, Generic)
+
+instance Exception FontError
+
+load
+  :: ( MonadIO m
+     , MonadReader env m
+     , HasLogFunc env
+     , HasCallStack
+     )
+  => Source -> m Bundle
+load = \case
+  Source.File _label path ->
+    liftIO $ loadBundleFile path
+  embedded ->
+    Source.load (liftIO . loadBundleBytes) embedded
+
+-- | A shaping context for one bundle, annotated with its texture id.
+data Stack = Stack
+  { context :: Font.StackContext Int32
+  , style   :: Layout.TextStyle
+  }
+
+allocateMonoStacks :: (MonadResource m, Traversable t) => t (Int32, Font.Bundle) -> m (t Stack)
+allocateMonoStacks = traverse (uncurry allocateStack1)
+
+allocateStack1 :: MonadResource m => Int32 -> Font.Bundle -> m Stack
+allocateStack1 textureId bundle = do
+  stack <- liftIO $ Font.createStackContext (Identity bundle)
+  _key <- Resource.register $ Font.destroyStackContext stack
+  let context = Font.mapWithBundle (Identity (bundle, const textureId)) stack
+  style <- liftIO $ Layout.bundleStyle context bundle
+  pure Stack{context, style}
+
+-- * Typesetting
+
+-- BUG: pipeline-specific, should be moved to pipeline models
+
+-- XXX: those can be collapsed to just an index into glyph storage (and the boxes are already storable)
+data PutChar = PutChar
+  { pcPos    :: Vec2 -- quads, static base + dynamic cursor'd positions in ems * shared dynamic scale
+  , pcSize   :: Vec2 -- static base * shared dynamic scale
+  , pcOffset :: Vec2 -- uv, static
+  , pcScale  :: Vec2
+  , pcDistanceLower :: Float
+  , pcDistanceRange :: Float
+  , pcTextureId :: Int32
+  } deriving (Show)
+
+-- | Shape and measure the text once, so it can be placed into any box.
+shape :: Stack -> Text -> IO Layout.ShapedText
+shape Stack{context, style} = Layout.shapeText context style
+
+place
+  :: Box -- ^ Parent box, screen units
+  -> Alignment -- ^ Line alignment inside the box, block alignment along it
+  -> Float -- ^ Line height, in cap-height units
+  -> Layout.Strategy
+  -> Float -- ^ Target size, screen units per cap height
+  -> Stack
+  -> Layout.ShapedText
+  -> [PutChar]
+place parent (Alignment alignment) lineHeight strategy targetSize Stack{context} shaped =
+  withVec2 alignment \alignX alignY ->
+  withVec2 parent.size \parentWidth parentHeight -> do
+    let
+      maxWidth = parentWidth / targetSize
+
+      placed = Layout.placeText
+        Layout.LayoutOptions
+          { cursor = Shaping.initialCursorDown lineHeight
+          , strategy
+          , align = Layout.AlignLeft
+          }
+        maxWidth
+        shaped
+
+      -- XXX: ignoring glyph height and using cap height (the sizes are normalized to it)
+      textHeight = 1 + (fromIntegral (length placed) - 1) * lineHeight
+
+      blockOffset =
+        parent.position
+          - parent.size * 0.5
+          + vec2 0 ((parentHeight - textHeight * targetSize) * alignY + targetSize)
+
+    line <- placed
+    let lineOffset = vec2 ((maxWidth - line.width) * alignX * targetSize) 0
+
+    ((font, atlas_), glyphs) <- line.runs
+    (pcTextureId, atlas) <- maybeToList $ (,) <$> Font.lookupBundled font context <*> atlas_
+    Shaping.PlacedGlyph{glyph = atlasBox, plane = quadBox} <- glyphs
+    guard $ quadBox.w > 0 && quadBox.h > 0
+    let
+      Atlas.Box{x=ax,y=ay,w=aw,h=ah} = atlasBox
+      pcOffset = vec2 ax ay
+      pcScale = vec2 aw ah
+    let
+      Atlas.Box{x,y,w,h} = quadBox
+      pcPos = vec2 (x * targetSize) (-y * targetSize) + blockOffset + lineOffset
+      pcSize = vec2 (w * targetSize) (h * targetSize)
+      Atlas.Compact
+        { _distanceLower = pcDistanceLower
+        , _distanceRange = pcDistanceRange
+        } = atlas
+    pure PutChar{..}
diff --git a/src/Stage/Loader/Render.hs b/src/Stage/Loader/Render.hs
--- a/src/Stage/Loader/Render.hs
+++ b/src/Stage/Loader/Render.hs
@@ -52,5 +52,5 @@
         Draw.indexed cb (UI.quadUV ui) background
         Draw.indexed cb (UI.quadUV ui) spinner
 
-      Graphics.bind cb pEvanwSdf $
+      Graphics.bind cb pMsdf $
         traverse_ (Draw.quads cb) uiMessages
diff --git a/src/Stage/Loader/Scene.hs b/src/Stage/Loader/Scene.hs
--- a/src/Stage/Loader/Scene.hs
+++ b/src/Stage/Loader/Scene.hs
@@ -5,7 +5,6 @@
 
 import RIO
 
-import Data.Type.Equality (type (~))
 import UnliftIO.Resource (MonadResource)
 
 import Engine.Camera qualified as Camera
diff --git a/src/Stage/Loader/Setup.hs b/src/Stage/Loader/Setup.hs
--- a/src/Stage/Loader/Setup.hs
+++ b/src/Stage/Loader/Setup.hs
@@ -18,6 +18,7 @@
 import Engine.Vulkan.Types (Queues)
 import Engine.Worker qualified as Worker
 import Geometry.Quad qualified as Quad
+import RIO.State (gets)
 import Render.Basic qualified as Basic
 import Render.DescSets.Set0 qualified as Set0
 import Render.Samplers qualified as Samplers
@@ -25,12 +26,11 @@
 import Resource.Combined.Textures qualified as CombinedTextures
 import Resource.CommandBuffer (withPools)
 import Resource.Font qualified as Font
+import Resource.Font.Ktxf qualified as Ktxf
 import Resource.Model qualified as Model
 import Resource.Region qualified as Region
 import Resource.Source (Source)
 import Resource.Texture.Ktx2 qualified as Ktx2
-import RIO.State (gets)
-import RIO.Vector.Partial ((!))
 import UnliftIO.Resource qualified as Resource
 import Vulkan.Core10 qualified as Vk
 
@@ -41,92 +41,88 @@
 
 bootstrap
   :: Text
-  -> (Font.Config, Font.Config)
   -> (Source, Source)
+  -> (Source, Source)
   -> ((Text -> StageSetupRIO ()) -> StageSetupRIO loaded)
   -> (loaded -> StackStage)
-  -> ( Setup Vector Vector loaded -> Engine.StackStage
-     , Engine.StageSetupRIO (Setup Vector Vector loaded)
+  -> ( Setup loaded -> Engine.StackStage
+     , Engine.StageSetupRIO (Setup loaded)
      )
-bootstrap titleMessage (smallFont, largeFont) (bgPath, spinnerPath) loadAction nextStage =
+bootstrap titleMessage (smallFontSrc, largeFontSrc) (bgPath, spinnerPath) loadAction nextStage =
   (stackStageBootstrap, action)
   where
     action = withPools \pools -> do
       logDebug "Bootstrapping loader"
 
-      fonts <- traverse (Font.allocate pools) [smallFont, largeFont]
+      smallFont <- Font.allocate pools smallFontSrc
+      largeFont <- Font.allocate pools largeFontSrc
+
       textures <- traverse (Ktx2.load pools) [bgPath, spinnerPath]
 
       let
-        fontContainers = fmap Font.container fonts
         combinedTextures = Collection.enumerate CombinedTextures.Collection
           { textures = textures
-          , fonts    = fmap Font.texture fonts
+          , fonts    = [smallFont.texture, largeFont.texture]
           }
 
       let
+        (backgroundIx, spinnerIx, smallFontId, largeFontId) =
+          case map fst $ toList combinedTextures of
+            [a, b, c, d] -> (a, b, c, d)
+            _ -> error "assert: combinedTextures has exactly 4 elements"
+
+      smallFontStack <- Ktxf.allocateStack1 smallFontId smallFont.bundle
+      largeFontStack <- Ktxf.allocateStack1 largeFontId largeFont.bundle
+
+      let
         uiSettings = UI.Settings
           { titleMessage = titleMessage
-          , backgroundIx = 0
-          , spinnerIx    = 1
-
+          , backgroundIx = backgroundIx
+          , spinnerIx    = spinnerIx
           , combined = combinedTextures
-          , fonts    = fontContainers
-
-          , smallFont = \fs -> fs ! 0
-          , largeFont = \fs -> fs ! 1
+          , smallFont = smallFontStack
+          , largeFont = largeFontStack
+          , fontBundles = [smallFont.bundle, largeFont.bundle]
           }
 
       logDebug "Finished bootstrapping loader"
       pure Setup{..}
 
-data Setup fonts textures loaded = Setup
+data Setup loaded = Setup
   { loadAction :: (Text -> StageSetupRIO ()) -> StageSetupRIO loaded
   , nextStage  :: loaded -> StackStage
-  , uiSettings :: UI.Settings textures fonts
+  , uiSettings :: UI.Settings
   }
 
-stackStageBootstrap
-  :: (Traversable fonts, Traversable textures)
-  => Setup fonts textures loaded -> StackStage
+stackStageBootstrap :: Setup loaded -> StackStage
 stackStageBootstrap Setup{..} = stackStage loadAction nextStage uiSettings
 
 stackStage
-  :: (Traversable fonts, Traversable textures)
-  => ((Text -> StageSetupRIO ()) -> StageSetupRIO loaded)
+  :: ((Text -> StageSetupRIO ()) -> StageSetupRIO loaded)
   -> (loaded -> StackStage)
-  -> UI.Settings textures fonts
+  -> UI.Settings
   -> StackStage
 stackStage loadAction nextStage uiSettings =
   StackStage $ loaderStage loadAction nextStage uiSettings
 
 loaderStage
-  :: (Traversable fonts, Traversable textures)
-  => ((Text -> StageSetupRIO ()) -> StageSetupRIO loaded)
+  :: ((Text -> StageSetupRIO ()) -> StageSetupRIO loaded)
   -> (loaded -> StackStage)
-  -> UI.Settings textures fonts
+  -> UI.Settings
   -> Basic.Stage FrameResources RunState
 loaderStage loadAction nextStage uiSettings = Stage.assemble "Loader" rendering resources (Just scene)
   where
     rendering = Stage.Rendering
-      { rAllocateRP = allocateRenderPass
+      { rAllocateRP = Basic.allocate_
       , rAllocateP = allocatePipelines
       }
 
-    allocateRenderPass swapchain = do
-      Basic.allocate
-        Basic.Settings
-          { sShadowLayers = 1
-          , sShadowSize   = 1
-          }
-        swapchain
-
     allocatePipelines swapchain rps = do
       logDebug "Allocating loader pipelines"
       samplers <- Samplers.allocate (Swapchain.getAnisotropy swapchain)
       Basic.allocatePipelines
+        (Basic.swapchainSettings swapchain)
         (sceneBinds samplers)
-        (Swapchain.getMultisample swapchain)
         rps
 
     sceneBinds samplers = Set0.mkBindings
@@ -149,7 +145,7 @@
 initialRunState
   :: ((Text -> StageSetupRIO ()) -> StageSetupRIO loaded)
   -> (loaded -> StackStage)
-  -> UI.Settings textures fonts
+  -> UI.Settings
   -> StageSetupRIO (Resource.ReleaseKey, RunState)
 initialRunState loadAction nextStage uiSettings =
   withPools \pools -> Region.run do
@@ -199,11 +195,13 @@
     -- XXX: propagate exceptions from loader threads
     link switcher
 
+    -- transfer the references
+    let rsFontAssets = uiSettings.fontBundles
+
     pure RunState{..}
 
 initialFrameResources
-  :: (Traversable fonts, Traversable textures)
-  => UI.Settings fonts textures
+  :: UI.Settings
   -> Queues Vk.CommandPool
   -> Basic.RenderPasses
   -> Basic.Pipelines
diff --git a/src/Stage/Loader/Types.hs b/src/Stage/Loader/Types.hs
--- a/src/Stage/Loader/Types.hs
+++ b/src/Stage/Loader/Types.hs
@@ -8,6 +8,7 @@
 
 import Render.Basic qualified as Basic
 import Render.DescSets.Set0 qualified as Set0
+import Resource.Font.Ktxf qualified as Ktxf
 
 import Stage.Loader.UI (UI)
 import Stage.Loader.UI qualified as UI
@@ -26,4 +27,6 @@
   { rsSceneUiP    :: Set0.Process
 
   , rsUI :: UI
+
+  , rsFontAssets :: [Ktxf.Bundle] -- have to hold the bundle reference while it is used
   }
diff --git a/src/Stage/Loader/UI.hs b/src/Stage/Loader/UI.hs
--- a/src/Stage/Loader/UI.hs
+++ b/src/Stage/Loader/UI.hs
@@ -30,22 +30,22 @@
 import Render.Unlit.Textured.Model qualified as UnlitTextured
 import Resource.Buffer qualified as Buffer
 import Resource.Combined.Textures qualified as CombinedTextures
-import Resource.Font.EvanW qualified as EvanW
+import Resource.Font.Ktxf qualified as Ktxf
 import Resource.Model qualified as Model
 import Resource.Model.Observer qualified as Observer
 import Resource.Region qualified as Region
 import Resource.Texture (Texture, Flat)
 
-data Settings fonts textures = Settings
+data Settings = Settings
   { titleMessage :: Text
   , backgroundIx :: Int32
   , spinnerIx    :: Int32
 
-  , combined     :: CombinedTextures.Collection textures fonts (Int32, Texture Flat)
-  , fonts        :: fonts EvanW.Container
+  , combined     :: CombinedTextures.Collection [] [] (Int32, Texture Flat)
 
-  , smallFont    :: forall a . fonts a -> a
-  , largeFont    :: forall a . fonts a -> a
+  , smallFont    :: Ktxf.Stack
+  , largeFont    :: Ktxf.Stack
+  , fontBundles :: [Ktxf.Bundle]
   }
 
 data UI = UI
@@ -63,7 +63,7 @@
 
 spawn
   :: Queues Vk.CommandPool
-  -> Settings fonts textures
+  -> Settings
   -> Engine.StageRIO env (Resource.ReleaseKey, UI)
 spawn pools Settings{..} = Region.run do
   screenBoxP <- Camera.trackOrthoPixelsCentered
@@ -150,11 +150,12 @@
   where
     title = Message.Input
       { inputText         = titleMessage
+      , inputLineSize     = 1.5
+      , inputBreak        = Ktxf.Greedy
       , inputOrigin       = Alignment.centerBottom
       , inputSize         = 64
       , inputColor        = vec4 1 0.5 0.25 1
-      , inputFont         = largeFont fonts
-      , inputFontId       = fst . largeFont $ CombinedTextures.fonts combined
+      , inputFont         = largeFont
       , inputOutline      = vec4 0 0 0 1
       , inputOutlineWidth = 4/16
       , inputSmoothing    = 1/16
@@ -162,11 +163,12 @@
 
     subtitle = Message.Input
       { inputText         = "Loading..."
+      , inputLineSize     = 1.5
+      , inputBreak        = Ktxf.Greedy
       , inputOrigin       = Alignment.centerTop
       , inputSize         = 32
       , inputColor        = vec4 0.5 1 0.25 1
-      , inputFont         = smallFont fonts
-      , inputFontId       = fst . smallFont $ CombinedTextures.fonts combined
+      , inputFont         = smallFont
       , inputOutline      = vec4 0 0 0 1
       , inputOutlineWidth = 4/16
       , inputSmoothing    = 1/16
@@ -174,11 +176,12 @@
 
     progress = Message.Input
       { inputText         = ""
+      , inputLineSize     = 1.5
+      , inputBreak        = Ktxf.Greedy
       , inputOrigin       = Alignment.centerBottom
       , inputSize         = 16
       , inputColor        = vec4 0.25 0.5 1 1
-      , inputFont         = smallFont fonts
-      , inputFontId       = fst . smallFont $ CombinedTextures.fonts combined
+      , inputFont         = smallFont
       , inputOutline      = 0
       , inputOutlineWidth = 0
       , inputSmoothing    = 1/16
