diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,16 @@
 # Changelog for keid-render-basic
 
+## 0.1.7.0
+
+- Pipelines updated to use `Engine.Vulkan.Pipeline.Graphics`.
+- Pipelines wrapped in a HKD.
+  * The `Pipelines`  is now an alias for `Identity` wrapper.
+  * The data type is now `PipelinesF f` with a hole for HKD parameter.
+  * The constructor name is still `Pipelines`, with all the fields intact.
+- Added a bunch of functions to work with runtime-reloadable pipelines.
+- Expose `stageCode` and `stageSpirv` for all pipelines.
+- Added `Render.Basic.rendering` reusable stage component.
+
 ## 0.1.6.1
 
 - `withFrozenCallStack` fixes for GHC-9.0.
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.1
+version:        0.1.7.0
 synopsis:       Basic rendering programs for Keid engine.
 category:       Game Engine
 homepage:       https://keid.haskell-game.dev
@@ -37,32 +37,44 @@
       Global.Resource.Texture.Base.Paths
       Render.Basic
       Render.Code.Lit
+      Render.Debug.Code
       Render.Debug.Model
       Render.Debug.Pipeline
+      Render.DepthOnly.Code
       Render.DepthOnly.Pipeline
       Render.DescSets.Set0
       Render.DescSets.Set0.Code
       Render.DescSets.Sun
+      Render.Font.EvanwSdf.Code
       Render.Font.EvanwSdf.Model
       Render.Font.EvanwSdf.Pipeline
       Render.ForwardMsaa
+      Render.Lit.Colored.Code
       Render.Lit.Colored.Model
       Render.Lit.Colored.Pipeline
       Render.Lit.Material
+      Render.Lit.Material.Code
       Render.Lit.Material.Collect
       Render.Lit.Material.Model
       Render.Lit.Material.Pipeline
+      Render.Lit.Textured.Code
       Render.Lit.Textured.Model
       Render.Lit.Textured.Pipeline
+      Render.ShadowMap.Code
       Render.ShadowMap.Pipeline
       Render.ShadowMap.RenderPass
+      Render.Skybox.Code
       Render.Skybox.Pipeline
+      Render.Unlit.Colored.Code
       Render.Unlit.Colored.Model
       Render.Unlit.Colored.Pipeline
+      Render.Unlit.Sprite.Code
       Render.Unlit.Sprite.Model
       Render.Unlit.Sprite.Pipeline
+      Render.Unlit.Textured.Code
       Render.Unlit.Textured.Model
       Render.Unlit.Textured.Pipeline
+      Render.Unlit.TileMap.Code
       Render.Unlit.TileMap.Model
       Render.Unlit.TileMap.Pipeline
       Resource.Font
@@ -143,7 +155,7 @@
     , derive-storable-plugin
     , file-embed >=0.0.10
     , geomancy
-    , keid-core >=0.1.6.1
+    , keid-core >=0.1.7.0
     , keid-geometry
     , neat-interpolation
     , resourcet
diff --git a/src/Render/Basic.hs b/src/Render/Basic.hs
--- a/src/Render/Basic.hs
+++ b/src/Render/Basic.hs
@@ -1,3 +1,6 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE OverloadedLists #-}
+
 {- |
   All the provided render passes and pipelines packaged and delivered.
 -}
@@ -7,18 +10,27 @@
 import RIO
 
 import Control.Monad.Trans.Resource (ResourceT)
+import Data.Kind (Type)
 import Data.Tagged (Tagged(..))
+import RIO.FilePath ((</>), (<.>))
 import RIO.Vector.Partial as Vector (headM)
 import Vulkan.Core10 qualified as Vk
 import Vulkan.Zero (Zero(..))
 
 -- keid-core
 
+import Engine.Stage.Component qualified as Stage
 import Engine.Types (StageRIO)
 import Engine.Types qualified as Engine
 import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.External (type (^))
+import Engine.Vulkan.Pipeline.External qualified as External
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
+import Engine.Vulkan.Shader qualified as Shader
 import Engine.Vulkan.Swapchain qualified as Swapchain
 import Engine.Vulkan.Types (DsBindings, HasSwapchain, HasVulkan, RenderPass(..))
+import Engine.Worker qualified as Worker
+import Resource.Region qualified as Region
 
 -- keid-render-basic
 
@@ -48,6 +60,16 @@
 type Frame = Engine.Frame RenderPasses Pipelines
 type StageFrameRIO r s a = Engine.StageFrameRIO RenderPasses Pipelines r s a
 
+-- |  Basic rendering component without any extensions.
+type Rendering = Stage.Rendering RenderPasses Pipelines
+
+-- |  Basic rendering component without any extensions and resources.
+rendering_ :: Rendering st
+rendering_ = Stage.Rendering
+  { rAllocateRP = allocate_
+  , rAllocateP = allocatePipelines_
+  }
+
 data RenderPasses = RenderPasses
   { rpForwardMsaa :: ForwardMsaa
   , rpShadowPass  :: ShadowMap
@@ -102,39 +124,47 @@
   -> ResourceT (RIO env) RenderPasses
 allocate_ = allocate zero
 
-data Pipelines = Pipelines
+type Pipelines = PipelinesF Identity
+type PipelineObservers = PipelinesF External.Observers
+type PipelineWorkers = PipelinesF External.ConfigureGraphics
+
+data PipelinesF (f :: Type -> Type) = Pipelines
   { pMSAA       :: Vk.SampleCountFlagBits
   , pSceneBinds :: Tagged Scene DsBindings
+  , pSceneLayout :: Tagged '[Scene] Vk.DescriptorSetLayout
 
-  , pEvanwSdf :: EvanwSdf.Pipeline
-  , pSkybox   :: Skybox.Pipeline
+  , pShadowBinds :: Tagged Sun DsBindings
+  , pShadowLayout :: Tagged '[Sun] Vk.DescriptorSetLayout
 
-  , pDebugUV      :: Debug.Pipeline
-  , pDebugTexture :: Debug.Pipeline
-  , pDebugShadow  :: Debug.Pipeline
+  , pEvanwSdf :: f ^ EvanwSdf.Pipeline
+  , pSkybox   :: f ^ Skybox.Pipeline
 
-  , pDepthOnly :: DepthOnly.Pipeline
+  , pDebugUV      :: f ^ Debug.Pipeline
+  , pDebugTexture :: f ^ Debug.Pipeline
+  , pDebugShadow  :: f ^ Debug.Pipeline
 
-  , pLitColored       :: LitColored.Pipeline
-  , pLitColoredBlend  :: LitColored.Pipeline
-  , pLitMaterial      :: LitMaterial.Pipeline
-  , pLitMaterialBlend :: LitMaterial.Pipeline
-  , pLitTextured      :: LitTextured.Pipeline
-  , pLitTexturedBlend :: LitTextured.Pipeline
+  , pDepthOnly :: f ^ DepthOnly.Pipeline
 
-  , pUnlitColored        :: UnlitColored.Pipeline
-  , pUnlitColoredNoDepth :: UnlitColored.Pipeline
-  , pUnlitTextured       :: UnlitTextured.Pipeline
-  , pUnlitTexturedBlend  :: UnlitTextured.Pipeline
+  , pLitColored       :: f ^ LitColored.Pipeline
+  , pLitColoredBlend  :: f ^ LitColored.Pipeline
+  , pLitMaterial      :: f ^ LitMaterial.Pipeline
+  , pLitMaterialBlend :: f ^ LitMaterial.Pipeline
+  , pLitTextured      :: f ^ LitTextured.Pipeline
+  , pLitTexturedBlend :: f ^ LitTextured.Pipeline
 
-  , pSprite              :: UnlitSprite.Pipeline
-  , pSpriteOutline       :: UnlitSprite.Pipeline
-  , pTileMap             :: UnlitTileMap.Pipeline
-  , pTileMapBlend        :: UnlitTileMap.Pipeline
-  , pWireframe           :: UnlitColored.Pipeline
-  , pWireframeNoDepth    :: UnlitColored.Pipeline
+  , pUnlitColored        :: f ^ UnlitColored.Pipeline
+  , pUnlitColoredNoDepth :: f ^ UnlitColored.Pipeline
+  , pUnlitTextured       :: f ^ UnlitTextured.Pipeline
+  , pUnlitTexturedBlend  :: f ^ UnlitTextured.Pipeline
 
-  , pShadowCast :: ShadowPipe.Pipeline
+  , pSprite              :: f ^ UnlitSprite.Pipeline
+  , pSpriteOutline       :: f ^ UnlitSprite.Pipeline
+  , pTileMap             :: f ^ UnlitTileMap.Pipeline
+  , pTileMapBlend        :: f ^ UnlitTileMap.Pipeline
+  , pWireframe           :: f ^ UnlitColored.Pipeline
+  , pWireframeNoDepth    :: f ^ UnlitColored.Pipeline
+
+  , pShadowCast :: f ^ ShadowPipe.Pipeline
   }
 
 allocatePipelines_
@@ -157,13 +187,13 @@
   -> RenderPasses
   -> ResourceT (StageRIO st) Pipelines
 allocatePipelines pSceneBinds pMSAA RenderPasses{..} = do
+  pEvanwSdf <- EvanwSdf.allocate pMSAA pSceneBinds rpForwardMsaa
+  pSkybox   <- Skybox.allocate pMSAA 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
 
-  pEvanwSdf <- EvanwSdf.allocate pMSAA pSceneBinds rpForwardMsaa
-  pSkybox   <- Skybox.allocate pMSAA pSceneBinds rpForwardMsaa
-
   pDepthOnly <- DepthOnly.allocate pMSAA pSceneBinds rpForwardMsaa
 
   pLitColored       <- LitColored.allocate pMSAA pSceneBinds rpForwardMsaa
@@ -184,23 +214,527 @@
   pWireframe           <- UnlitColored.allocateWireframe True pMSAA pSceneBinds rpForwardMsaa
   pWireframeNoDepth    <- UnlitColored.allocateWireframe False pMSAA pSceneBinds rpForwardMsaa
 
-  let sunBinds = Sun.set0
-  pShadowCast <- ShadowPipe.allocate sunBinds rpShadowPass ShadowPipe.defaults
+  let pShadowBinds = Sun.set0
+  pShadowCast <- ShadowPipe.allocate pShadowBinds rpShadowPass ShadowPipe.defaults
 
+  let
+    pSceneLayout =
+      case Vector.headM (unTagged $ Pipeline.pDescLayouts pLitColored) of
+        Nothing ->
+          error "pLitColored has at least set0 in layout"
+        Just set0layout ->
+          Tagged set0layout
+
+    pShadowLayout =
+      case Vector.headM (unTagged $ Pipeline.pDescLayouts pShadowCast) of
+        Nothing ->
+          error "pShadowCast has at least set0 in layout"
+        Just set0layout ->
+          Tagged set0layout
+
   pure Pipelines{..}
 
-getSceneLayout :: Pipelines -> Tagged '[Scene] Vk.DescriptorSetLayout
-getSceneLayout Pipelines{pLitColored} =
-  case Vector.headM (unTagged $ Pipeline.pDescLayouts pLitColored) of
-    Nothing ->
-      error "pLitColored has at least set0 in layout"
-    Just set0layout ->
-      Tagged set0layout
+allocateWorkers
+  :: Tagged Scene DsBindings
+  -> Vk.SampleCountFlagBits
+  -> RenderPasses
+  -> ResourceT (StageRIO st) PipelineWorkers
+allocateWorkers sceneBinds pMSAA renderPasses = do
+  builtin <- allocatePipelines sceneBinds pMSAA renderPasses
 
+  let
+    evanwSdfFiles =
+      Graphics.basicStages
+        (shaderDir </> "evanw-sdf" <.> "vert" <.> "spv")
+        (shaderDir </> "evanw-sdf" <.> "frag" <.> "spv")
+
+  evanwSdfWorker <- Region.local . Worker.registered $
+    External.spawnReflect "evanw-sdf" evanwSdfFiles \(stageCode, reflect) ->
+      (EvanwSdf.config sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  let
+    skyboxFiles =
+      Graphics.basicStages
+        (shaderDir </> "skybox" <.> "vert" <.> "spv")
+        (shaderDir </> "skybox" <.> "frag" <.> "spv")
+
+  skyboxWorker <- Region.local . Worker.registered $
+    External.spawnReflect "skybox" skyboxFiles \(stageCode, reflect) ->
+      (Skybox.config sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  let
+    debugFiles =
+      Graphics.basicStages
+        (shaderDir </> "debug" <.> "vert" <.> "spv")
+        (shaderDir </> "debug" <.> "frag" <.> "spv")
+
+  debugUVWorker <- Region.local . Worker.registered $
+    External.spawnReflect "debug-uv" debugFiles \(stageCode, reflect) ->
+      (Debug.config Debug.UV sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  debugTextureWorker <- Region.local . Worker.registered $
+    External.spawnReflect "debug-texture" debugFiles \(stageCode, reflect) ->
+      (Debug.config Debug.Texture sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  debugShadowWorker <- Region.local . Worker.registered $
+    External.spawnReflect "debug-shadow" debugFiles \(stageCode, reflect) ->
+      (Debug.config (Debug.Shadow 1) sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  let
+    depthOnlyFiles = Graphics.vertexOnly
+      (shaderDir </> "depth-only" <.> "vert" <.> "spv")
+
+  depthOnlyWorker <- Region.local . Worker.registered $
+    External.spawnReflect "debug" depthOnlyFiles \(stageCode, reflect) ->
+      (DepthOnly.config sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  let
+    litColoredFiles =
+      Graphics.basicStages
+        (shaderDir </> "lit-colored" <.> "vert" <.> "spv")
+        (shaderDir </> "lit-colored" <.> "frag" <.> "spv")
+
+  litColoredWorker <- Region.local . Worker.registered $
+    External.spawnReflect "lit-colored" litColoredFiles \(stageCode, reflect) ->
+      (LitColored.config sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  litColoredBlendWorker <- Region.local . Worker.registered $
+    External.spawnReflect "lit-colored-blend" litColoredFiles \(stageCode, reflect) ->
+      (LitColored.configBlend sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  let
+    litMaterialFiles =
+      Graphics.basicStages
+        (shaderDir </> "lit-material" <.> "vert" <.> "spv")
+        (shaderDir </> "lit-material" <.> "frag" <.> "spv")
+
+  litMaterialWorker <- Region.local . Worker.registered $
+    External.spawnReflect "lit-material" litMaterialFiles \(stageCode, reflect) ->
+      (LitMaterial.config sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  litMaterialBlendWorker <- Region.local . Worker.registered $
+    External.spawnReflect "lit-material-blend" litMaterialFiles \(stageCode, reflect) ->
+      (LitMaterial.configBlend sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  let
+    litTexturedFiles =
+      Graphics.basicStages
+        (shaderDir </> "lit-textured" <.> "vert" <.> "spv")
+        (shaderDir </> "lit-textured" <.> "frag" <.> "spv")
+
+  litTexturedWorker <- Region.local . Worker.registered $
+    External.spawnReflect "lit-textured" litTexturedFiles \(stageCode, reflect) ->
+      (LitTextured.config sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  litTexturedBlendWorker <- Region.local . Worker.registered $
+    External.spawnReflect "lit-textured-blend" litTexturedFiles \(stageCode, reflect) ->
+      (LitTextured.configBlend sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  let
+    unlitColoredFiles =
+      Graphics.basicStages
+        (shaderDir </> "unlit-colored" <.> "vert" <.> "spv")
+        (shaderDir </> "unlit-colored" <.> "frag" <.> "spv")
+
+  unlitColoredWorker <- Region.local . Worker.registered $
+    External.spawnReflect "unlit-colored" unlitColoredFiles \(stageCode, reflect) ->
+      (UnlitColored.config True sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  unlitColoredNoDepthWorker <- Region.local . Worker.registered $
+    External.spawnReflect "unlit-colored-nodepth" unlitColoredFiles \(stageCode, reflect) ->
+      (UnlitColored.config False sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  let
+    unlitTexturedFiles =
+      Graphics.basicStages
+        (shaderDir </> "unlit-textured" <.> "vert" <.> "spv")
+        (shaderDir </> "unlit-textured" <.> "frag" <.> "spv")
+
+  unlitTexturedWorker <- Region.local . Worker.registered $
+    External.spawnReflect "unlit-textured" unlitTexturedFiles \(stageCode, reflect) ->
+      (UnlitTextured.config sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  unlitTexturedBlendWorker <- Region.local . Worker.registered $
+    External.spawnReflect "unlit-textured" unlitTexturedFiles \(stageCode, reflect) ->
+      (UnlitTextured.configBlend sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  let
+    spriteFiles =
+      Graphics.basicStages
+        (shaderDir </> "sprite" <.> "vert" <.> "spv")
+        (shaderDir </> "sprite" <.> "frag" <.> "spv")
+
+  spriteWorker <- Region.local . Worker.registered $
+    External.spawnReflect "sprite" spriteFiles \(stageCode, reflect) ->
+      (UnlitSprite.config Nothing False sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  spriteOutlineWorker <- Region.local . Worker.registered $
+    External.spawnReflect "sprite" spriteFiles \(stageCode, reflect) ->
+      (UnlitSprite.config Nothing True sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  let
+    tileMapFiles =
+      Graphics.basicStages
+        (shaderDir </> "tilemap" <.> "vert" <.> "spv")
+        (shaderDir </> "tilemap" <.> "frag" <.> "spv")
+
+  tileMapWorker <- Region.local . Worker.registered $
+    External.spawnReflect "tilemap" tileMapFiles \(stageCode, reflect) ->
+      (UnlitTileMap.config sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  tileMapBlendWorker <- Region.local . Worker.registered $
+    External.spawnReflect "tilemap" tileMapFiles \(stageCode, reflect) ->
+      (UnlitTileMap.configBlend sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  wireframeWorker <- Region.local . Worker.registered $
+    External.spawnReflect "wireframe" unlitColoredFiles \(stageCode, reflect) ->
+      (UnlitColored.configWireframe True sceneBinds)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  wireframeNoDepthWorker <- Region.local . Worker.registered $
+    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 <- Region.local . Worker.registered $
+    External.spawnReflect "shadow-cast" shadowCastFiles \(stageCode, reflect) ->
+      (ShadowPipe.config (pShadowBinds builtin) ShadowPipe.defaults)
+        { Graphics.cStages = stageCode
+        , Graphics.cReflect = Just reflect
+        }
+
+  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
+    , pUnlitColored = unlitColoredWorker
+    , pUnlitColoredNoDepth = unlitColoredNoDepthWorker
+    , pUnlitTextured = unlitTexturedWorker
+    , pUnlitTexturedBlend = unlitTexturedBlendWorker
+    , pSprite = spriteWorker
+    , pSpriteOutline = spriteOutlineWorker
+    , pTileMap = tileMapWorker
+    , pTileMapBlend = tileMapBlendWorker
+    , pWireframe = wireframeWorker
+    , pWireframeNoDepth = wireframeNoDepthWorker
+    , pShadowCast = shadowCastWorker
+    }
+
+allocateObservers
+  :: RenderPasses
+  -> PipelineWorkers
+  -> ResourceT (Engine.StageRIO rs) PipelineObservers
+allocateObservers renderPasses Pipelines{..} = do
+  evanwSdfExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pEvanwSdf
+
+  skyboxExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pSkybox
+
+  debugUVExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pDebugUV
+
+  debugTextureExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pDebugTexture
+
+  debugShadowExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pDebugShadow
+
+  depthOnlyExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pDepthOnly
+
+  litColoredExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pLitColored
+
+  litColoredBlendExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pLitColoredBlend
+
+  litMaterialExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pLitMaterial
+
+  litMaterialBlendExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pLitMaterialBlend
+
+  litTexturedExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pLitTextured
+
+  litTexturedBlendExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pLitTexturedBlend
+
+  unlitColoredExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pUnlitColored
+
+  unlitColoredNoDepthExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pUnlitColoredNoDepth
+
+  unlitTexturedExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pUnlitTextured
+
+  unlitTexturedBlendExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pUnlitTexturedBlend
+
+  spriteExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pSprite
+
+  spriteOutlineExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pSpriteOutline
+
+  tileMapExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pTileMap
+
+  tileMapBlendExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pTileMapBlend
+
+  wireframeExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pWireframe
+
+  wireframeNoDepthExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pWireframeNoDepth
+
+  shadowCastExt <- External.newObserverGraphics
+    forward
+    pMSAA
+    pShadowCast
+
+  pure Pipelines
+    { pEvanwSdf = evanwSdfExt
+    , pSkybox = skyboxExt
+    , pDebugUV = debugUVExt
+    , pDebugTexture = debugTextureExt
+    , pDebugShadow = debugShadowExt
+    , pDepthOnly = depthOnlyExt
+    , pLitColored = litColoredExt
+    , pLitColoredBlend = litColoredBlendExt
+    , pLitMaterial = litMaterialExt
+    , pLitMaterialBlend = litMaterialBlendExt
+    , pLitTextured = litTexturedExt
+    , pLitTexturedBlend = litTexturedBlendExt
+    , pUnlitColored = unlitColoredExt
+    , pUnlitColoredNoDepth = unlitColoredNoDepthExt
+    , pUnlitTextured = unlitTexturedExt
+    , pUnlitTexturedBlend = unlitTexturedBlendExt
+    , pSprite = spriteExt
+    , pSpriteOutline = spriteOutlineExt
+    , pTileMap = tileMapExt
+    , pTileMapBlend = tileMapBlendExt
+    , pWireframe = wireframeExt
+    , pWireframeNoDepth = wireframeNoDepthExt
+    , pShadowCast = shadowCastExt
+    , ..
+    }
+  where
+    forward = rpForwardMsaa renderPasses
+
+observePipelines
+  :: RenderPasses
+  -> PipelineWorkers
+  -> PipelineObservers
+  -> Engine.StageFrameRIO rp p fr rs ()
+observePipelines fRenderpass workers pipelines = do
+  observe @EvanwSdf.Pipeline pEvanwSdf
+
+  observe @Skybox.Pipeline pSkybox
+
+  observe @Debug.Pipeline pDebugUV
+  observe @Debug.Pipeline pDebugTexture
+  observe @Debug.Pipeline pDebugShadow
+
+  observe @DepthOnly.Pipeline pDepthOnly
+
+  observe @LitColored.Pipeline pLitColored
+  observe @LitColored.Pipeline pLitColoredBlend
+
+  observe @LitTextured.Pipeline pLitTextured
+  observe @LitTextured.Pipeline pLitTexturedBlend
+
+  observe @LitMaterial.Pipeline pLitMaterial
+  observe @LitMaterial.Pipeline pLitMaterialBlend
+
+  observe @UnlitColored.Pipeline pUnlitColored
+  observe @UnlitColored.Pipeline pUnlitColoredNoDepth
+
+  observe @UnlitTextured.Pipeline pUnlitTextured
+  observe @UnlitTextured.Pipeline pUnlitTexturedBlend
+
+  observe @UnlitSprite.Pipeline pSprite
+  observe @UnlitSprite.Pipeline pSpriteOutline
+
+  observe @UnlitTileMap.Pipeline pTileMap
+  observe @UnlitTileMap.Pipeline pTileMapBlend
+
+  observe @UnlitColored.Pipeline pWireframe
+  observe @UnlitColored.Pipeline pWireframeNoDepth
+
+  External.observeGraphics
+    (rpShadowPass fRenderpass) -- XXX: different RP here
+    (pMSAA workers)
+    (Tagged [unTagged $ pSceneBinds pipelines])
+    (pShadowCast workers)
+    (pShadowCast pipelines)
+  where
+    observe
+      :: forall
+         p
+         s vs is
+         rps ps fr rs
+      .  ( p ~ Graphics.Pipeline s vs is
+         , Shader.Specialization (Graphics.Specialization p)
+         )
+      => (forall a . PipelinesF a -> a ^ p)
+      -> Engine.StageFrameRIO rps ps fr rs ()
+    observe =
+      External.observeField
+        @PipelinesF
+        @p
+        (rpForwardMsaa fRenderpass)
+        (pMSAA workers)
+        (pSceneBinds pipelines)
+        workers
+        pipelines
+
+getSceneLayout :: PipelinesF f -> Tagged '[Scene] Vk.DescriptorSetLayout
+getSceneLayout = pSceneLayout
+
 getSunLayout :: Pipelines -> Tagged '[Sun] Vk.DescriptorSetLayout
-getSunLayout Pipelines{pShadowCast} =
-  case Vector.headM (unTagged $ Pipeline.pDescLayouts pShadowCast) of
-    Nothing ->
-      error "pShadowCast has at least set0 in layout"
-    Just set0layout ->
-      Tagged set0layout
+getSunLayout = pShadowLayout
+
+shaderDir :: FilePath
+shaderDir = "data" </> "shaders" </> "basic"
+
+stageSources :: Map Text Graphics.StageCode
+stageSources =
+  [ ("evanw-sdf", EvanwSdf.stageCode)
+  , ("skybox", Skybox.stageCode)
+  , ("debug", Debug.stageCode)
+  , ("depth-only", DepthOnly.stageCode)
+  , ("lit-colored", LitColored.stageCode)
+  , ("lit-material", LitMaterial.stageCode)
+  , ("lit-textured", LitTextured.stageCode)
+  , ("unlit-colored", UnlitColored.stageCode)
+  , ("unlit-textured", UnlitTextured.stageCode)
+  , ("sprite", UnlitSprite.stageCode)
+  , ("tilemap", UnlitTileMap.stageCode)
+  , ("shadow-cast", ShadowPipe.stageCode)
+  ]
diff --git a/src/Render/Debug/Code.hs b/src/Render/Debug/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Debug/Code.hs
@@ -0,0 +1,123 @@
+module Render.Debug.Code
+  ( vert
+  , frag
+  ) where
+
+import RIO
+
+import Render.Code (Code, glsl)
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding5color)
+
+vert :: Code
+vert = fromString
+  [glsl|
+    #version 450
+
+    ${set0binding0}
+
+    // vertexPos
+    layout(location = 0) in vec3 vPosition;
+    // vertexAttrs
+    layout(location = 1) in vec2 vTexCoord;
+    // textureParams
+    layout(location = 2) in  vec4 iTextureScaleOffset;
+    layout(location = 3) in  vec4 iTextureGamma;
+    layout(location = 4) in ivec2 iTextureIds;
+
+    // transformMat
+    layout(location = 5) in mat4 iModel;
+
+    layout(location = 0)      out  vec2 fTexCoord;
+    layout(location = 1) flat out  vec4 fTextureGamma;
+    layout(location = 2) flat out ivec2 fTextureIds;
+
+    void main() {
+      vec4 fPosition = iModel * vec4(vPosition, 1.0);
+
+      gl_Position
+        = scene.projection
+        * scene.view
+        * fPosition;
+
+      fTexCoord     = vTexCoord * iTextureScaleOffset.st + iTextureScaleOffset.pq;
+      fTextureGamma = iTextureGamma;
+      fTextureIds   = iTextureIds;
+    }
+  |]
+
+frag :: Code
+frag = fromString
+  [glsl|
+    #version 450
+    #extension GL_EXT_nonuniform_qualifier : enable
+
+    ${set0binding1}
+    ${set0binding2}
+    ${set0binding3}
+    ${set0binding5color}
+
+    layout(location = 0)      in  vec2 fTexCoord;
+    layout(location = 1) flat in  vec4 fTextureGamma;
+    layout(location = 2) flat in ivec2 fTextureIds;
+
+    layout(location = 0) out vec4 oColor;
+
+    layout (constant_id=0) const uint mode = 0;
+    layout (constant_id=1) const uint shadow_mask = 0;
+
+    void main() {
+      vec4 texel = vec4(0);
+
+      switch(mode) {
+        case 0:
+          texel = vec4(fTexCoord, 0, 1);
+          break;
+
+        case 1:
+          if ((fTextureIds.t < 0) || (fTextureIds.s < 0)) break;
+
+          texel = texture(
+            sampler2D(
+              textures[nonuniformEXT(fTextureIds.t)],
+              samplers[nonuniformEXT(fTextureIds.s)]
+            ),
+            fTexCoord
+          );
+          break;
+
+        case 2:
+          float d0 = 1.0;
+          if ((shadow_mask & 1) == 1)
+            d0 = texture(
+              shadowmaps,
+              vec3(fTexCoord, 0.0)
+            ).x;
+
+          float d1 = 1.0;
+          if ((shadow_mask & 2) == 2)
+            d1 = texture(
+              shadowmaps,
+              vec3(fTexCoord, 1.0)
+            ).x;
+
+          float d2 = 1.0;
+          if ((shadow_mask & 4) == 4)
+            d2 = texture(
+              shadowmaps,
+              vec3(fTexCoord, 2.0)
+            ).x;
+
+          texel = vec4(1-d0, 1-d1, 1-d2, 1.0);
+          break;
+
+        default:
+          break;
+      }
+
+      vec3 color = pow(texel.rgb, fTextureGamma.rgb);
+      float combinedAlpha = texel.a * fTextureGamma.a;
+
+      // XXX: premultiply alpha due to additive blending
+      oColor = vec4(color * combinedAlpha, combinedAlpha);
+    }
+  |]
diff --git a/src/Render/Debug/Pipeline.hs b/src/Render/Debug/Pipeline.hs
--- a/src/Render/Debug/Pipeline.hs
+++ b/src/Render/Debug/Pipeline.hs
@@ -1,7 +1,11 @@
 module Render.Debug.Pipeline
-  ( Pipeline
+  ( Config
+  , config
+  , Pipeline
   , allocate
   , Mode(..)
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
@@ -10,15 +14,17 @@
 import Data.Tagged (Tagged(..))
 import Vulkan.Core10 qualified as Vk
 
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Shader qualified as Shader
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, compileFrag, glsl)
+import Render.Code (compileVert, compileFrag)
+import Render.Debug.Code qualified as Code
 import Render.Debug.Model qualified as Model
 import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
-import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding5color)
 
-type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Graphics.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = Mode
 
 data Mode
   = UV
@@ -44,136 +50,32 @@
   -> Tagged Scene DsBindings
   -> renderpass
   -> ResourceT (RIO env) Pipeline
-allocate mode multisample (Tagged set0) = do
-  fmap snd . Pipeline.allocate Nothing multisample config
-  where
-    config = Pipeline.baseConfig
-      { Pipeline.cDescLayouts    = Tagged @'[Scene] [set0]
-      , Pipeline.cVertexCode     = Just vertCode
-      , Pipeline.cVertexInput    = vertexInput
-      , Pipeline.cFragmentCode   = Just fragCode
-      , Pipeline.cSpecialization = mode
-      }
+allocate mode multisample tset0 = do
+  fmap snd . Graphics.allocate Nothing multisample (config mode tset0)
 
-    vertexInput = Pipeline.vertexInput
+config :: Mode -> Tagged Scene DsBindings -> Config
+config mode (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts    = Tagged @'[Scene] [set0]
+  , Graphics.cStages         = stageSpirv
+  , Graphics.cVertexInput    = vertexInput
+  , Graphics.cSpecialization = mode
+  }
+  where
+    vertexInput = Graphics.vertexInput
       [ vertexPos -- vPosition
       , (Vk.VERTEX_INPUT_RATE_VERTEX,   Model.vkVertexAttrs)
       , (Vk.VERTEX_INPUT_RATE_INSTANCE, Model.vkInstanceTexture)
       , instanceTransform
       ]
 
-vertCode :: ByteString
-vertCode =
-  $(compileVert [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-
-    ${set0binding0}
-
-    // vertexPos
-    layout(location = 0) in vec3 vPosition;
-    // vertexAttrs
-    layout(location = 1) in vec2 vTexCoord;
-    // textureParams
-    layout(location = 2) in  vec4 iTextureScaleOffset;
-    layout(location = 3) in  vec4 iTextureGamma;
-    layout(location = 4) in ivec2 iTextureIds;
-
-    // transformMat
-    layout(location = 5) in mat4 iModel;
-
-    layout(location = 0)      out  vec2 fTexCoord;
-    layout(location = 1) flat out  vec4 fTextureGamma;
-    layout(location = 2) flat out ivec2 fTextureIds;
-
-    void main() {
-      vec4 fPosition = iModel * vec4(vPosition, 1.0);
-
-      gl_Position
-        = scene.projection
-        * scene.view
-        * fPosition;
-
-      fTexCoord     = vTexCoord * iTextureScaleOffset.st + iTextureScaleOffset.pq;
-      fTextureGamma = iTextureGamma;
-      fTextureIds   = iTextureIds;
-    }
-  |])
-
-fragCode :: ByteString
-fragCode =
-  $(compileFrag [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-    #extension GL_EXT_nonuniform_qualifier : enable
-
-    ${set0binding1}
-    ${set0binding2}
-    ${set0binding3}
-    ${set0binding5color}
-
-    layout(location = 0)      in  vec2 fTexCoord;
-    layout(location = 1) flat in  vec4 fTextureGamma;
-    layout(location = 2) flat in ivec2 fTextureIds;
-
-    layout(location = 0) out vec4 oColor;
-
-    layout (constant_id=0) const uint mode = 0;
-    layout (constant_id=1) const uint shadow_mask = 0;
-
-    void main() {
-      vec4 texel = vec4(0);
-
-      switch(mode) {
-        case 0:
-          texel = vec4(fTexCoord, 0, 1);
-          break;
-
-        case 1:
-          if ((fTextureIds.t < 0) || (fTextureIds.s < 0)) break;
-
-          texel = texture(
-            sampler2D(
-              textures[nonuniformEXT(fTextureIds.t)],
-              samplers[nonuniformEXT(fTextureIds.s)]
-            ),
-            fTexCoord
-          );
-          break;
-
-        case 2:
-          float d0 = 1.0;
-          if ((shadow_mask & 1) == 1)
-            d0 = texture(
-              shadowmaps,
-              vec3(fTexCoord, 0.0)
-            ).x;
-
-          float d1 = 1.0;
-          if ((shadow_mask & 2) == 2)
-            d1 = texture(
-              shadowmaps,
-              vec3(fTexCoord, 1.0)
-            ).x;
-
-          float d2 = 1.0;
-          if ((shadow_mask & 4) == 4)
-            d2 = texture(
-              shadowmaps,
-              vec3(fTexCoord, 2.0)
-            ).x;
-
-          texel = vec4(1-d0, 1-d1, 1-d2, 1.0);
-          break;
+stageCode :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
 
-        default:
-          break;
-      }
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
 
-      vec3 color = pow(texel.rgb, fTextureGamma.rgb);
-      float combinedAlpha = texel.a * fTextureGamma.a;
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-      // XXX: premultiply alpha due to additive blending
-      oColor = vec4(color * combinedAlpha, combinedAlpha);
-    }
-  |])
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
diff --git a/src/Render/DepthOnly/Code.hs b/src/Render/DepthOnly/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/DepthOnly/Code.hs
@@ -0,0 +1,37 @@
+module Render.DepthOnly.Code
+  ( vert
+  ) where
+
+import RIO
+
+import Render.Code (Code, glsl)
+import Render.DescSets.Set0.Code (set0binding0)
+
+vert :: Code
+vert = fromString
+  [glsl|
+    #version 450
+    #extension GL_ARB_separate_shader_objects : enable
+
+    invariant gl_Position;
+
+    ${set0binding0}
+
+    layout(location = 0) in vec3 vPosition;
+    layout(location = 1) in mat4 iModel;
+
+    // layout(location = 0) out vec4 fColor;
+
+    void main() {
+      vec4 fPosition = iModel * vec4(vPosition, 1.0);
+
+      gl_Position
+        = scene.projection
+        * scene.view
+        * fPosition;
+
+      // XXX: needed for alpha cut-outs
+      // fColor = vColor;
+      // fColor.rgb * fColor.a;
+    }
+  |]
diff --git a/src/Render/DepthOnly/Pipeline.hs b/src/Render/DepthOnly/Pipeline.hs
--- a/src/Render/DepthOnly/Pipeline.hs
+++ b/src/Render/DepthOnly/Pipeline.hs
@@ -4,6 +4,9 @@
 
   , Config
   , config
+
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
@@ -13,14 +16,15 @@
 import Vulkan.Core10 qualified as Vk
 
 import Geomancy (Transform)
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, {- compileFrag, -} glsl)
+import Render.Code (compileVert)
+import Render.DepthOnly.Code qualified as Code
 import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
-import Render.DescSets.Set0.Code (set0binding0)
 
-type Config = Pipeline.Configure Pipeline ()
-type Pipeline = Pipeline.Pipeline '[Scene] () Transform
+type Pipeline = Graphics.Pipeline '[Scene] () Transform
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = ()
 
 allocate
   :: ( HasVulkan env
@@ -31,7 +35,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocate multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (config tset0)
@@ -39,43 +43,22 @@
   pure p
 
 config :: Tagged Scene DsBindings -> Config
-config (Tagged set0) = Pipeline.baseConfig
-  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
-  , Pipeline.cVertexCode   = Just vertCode
-  , Pipeline.cVertexInput  = vertexInput
-  , Pipeline.cFragmentCode = Nothing -- XXX: needed for alpha cut-outs
+config (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Graphics.cStages       = stageSpirv
+  , Graphics.cVertexInput  = vertexInput
   }
   where
-    vertexInput = Pipeline.vertexInput
+    vertexInput = Graphics.vertexInput
       [ vertexPos
       , instanceTransform
       ]
 
-vertCode :: ByteString
-vertCode =
-  $(compileVert [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-
-    invariant gl_Position;
-
-    ${set0binding0}
-
-    layout(location = 0) in vec3 vPosition;
-    layout(location = 1) in mat4 iModel;
-
-    // layout(location = 0) out vec4 fColor;
-
-    void main() {
-      vec4 fPosition = iModel * vec4(vPosition, 1.0);
+stageCode  :: Graphics.StageCode
+stageCode = Graphics.vertexOnly Code.vert
 
-      gl_Position
-        = scene.projection
-        * scene.view
-        * fPosition;
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.vertexOnly vertSpirv
 
-      // XXX: needed for alpha cut-outs
-      // fColor = vColor;
-      // fColor.rgb * fColor.a;
-    }
-  |])
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
diff --git a/src/Render/Font/EvanwSdf/Code.hs b/src/Render/Font/EvanwSdf/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Font/EvanwSdf/Code.hs
@@ -0,0 +1,114 @@
+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/Pipeline.hs b/src/Render/Font/EvanwSdf/Pipeline.hs
--- a/src/Render/Font/EvanwSdf/Pipeline.hs
+++ b/src/Render/Font/EvanwSdf/Pipeline.hs
@@ -1,8 +1,12 @@
 {-# LANGUAGE OverloadedLists #-}
 
 module Render.Font.EvanwSdf.Pipeline
-  ( Pipeline
+  ( Config
+  , config
+  , Pipeline
   , allocate
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
@@ -11,14 +15,16 @@
 import Data.Tagged (Tagged(..))
 import Vulkan.Core10 qualified as Vk
 
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, compileFrag, glsl)
+import Render.Code (compileVert, compileFrag)
 import Render.DescSets.Set0 (Scene)
-import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2)
+import Render.Font.EvanwSdf.Code qualified as Code
 import Render.Font.EvanwSdf.Model qualified as Model
 
-type Pipeline = Pipeline.Pipeline '[Scene] () Model.InstanceAttrs
+type Pipeline = Graphics.Pipeline '[Scene] () Model.InstanceAttrs
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = ()
 
 allocate
   :: ( HasVulkan env
@@ -28,128 +34,35 @@
   -> Tagged Scene DsBindings
   -> renderpass
   -> ResourceT (RIO env) Pipeline
-allocate multisample (Tagged set0) = do
-  fmap snd . Pipeline.allocate
+allocate multisample tset0 = do
+  fmap snd . Graphics.allocate
     Nothing
     multisample
-    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
-      }
+    (config tset0)
+
+config :: Tagged Scene DsBindings -> Config
+config (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts = Tagged @'[Scene] [set0]
+  , Graphics.cStages      = stageSpirv
+  , Graphics.cVertexInput = vertexInput
+  , Graphics.cDepthTest   = False
+  , Graphics.cDepthWrite  = False
+  , Graphics.cBlend       = True
+  , Graphics.cCull        = Vk.CULL_MODE_NONE
+  }
   where
-    vertexInput = Pipeline.vertexInput
+    vertexInput = Graphics.vertexInput
       [ (Vk.VERTEX_INPUT_RATE_INSTANCE, Model.vkInstanceAttrs)
       ]
 
-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 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;
-    }
-  |])
-
-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 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;
+stageCode :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
 
-      float smoothing    = fSdf[0];
-      float outlineWidth = fSdf[1];
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
 
-      float outerEdgeCenter = 0.5 - outlineWidth;
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-      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;
-    }
-  |])
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
diff --git a/src/Render/Lit/Colored/Code.hs b/src/Render/Lit/Colored/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Colored/Code.hs
@@ -0,0 +1,100 @@
+module Render.Lit.Colored.Code
+  ( vert
+  , frag
+  ) where
+
+import RIO
+
+import Render.Code (Code, glsl)
+import Render.Code.Lit (litMain, shadowFuns, structLight, brdfSpecular)
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding4, set0binding5)
+
+vert :: Code
+vert = fromString
+  [glsl|
+    #version 450
+
+    invariant gl_Position;
+
+    ${set0binding0}
+
+    layout(location = 0) in vec3 vPosition;
+    layout(location = 1) in vec4 vBaseColor;
+    layout(location = 2) in vec4 vEmissiveColor;
+    layout(location = 3) in vec2 vMetallicRoughness;
+    layout(location = 4) in vec3 vNormal;
+
+    layout(location = 5) in mat4 iModel;
+
+    layout(location = 0) out vec4 fPosition;
+    layout(location = 1) out vec4 fColor;
+    layout(location = 2) out vec4 fEmissiveColor;
+    layout(location = 3) out vec2 fMetallicRoughness;
+    layout(location = 4) out vec3 fNormal;
+
+    void main() {
+      fPosition = iModel * vec4(vPosition, 1.0);
+
+      gl_Position
+        = scene.projection
+        * scene.view
+        * fPosition;
+
+      fNormal = transpose(mat3(inverse(iModel))) * vNormal; // TODO: use modelInv
+
+      fColor = vBaseColor;
+      fColor.rgb *= vBaseColor.a;
+
+      fEmissiveColor = vEmissiveColor;
+      fMetallicRoughness = vMetallicRoughness;
+    }
+  |]
+
+frag :: Code
+frag = fromString
+  [glsl|
+    #version 450
+    #extension GL_EXT_nonuniform_qualifier : enable
+
+    layout(early_fragment_tests) in;
+
+    // TODO: move to spec constant
+    const uint MAX_LIGHTS = 255;
+    const float PCF_STEP = 1.5 / 4096;
+
+    // TODO: move to material
+    const float reflectivity = 1.0/256.0;
+
+    ${structLight}
+
+    ${set0binding0}
+    ${set0binding1}
+    ${set0binding2}
+    ${set0binding3}
+    ${set0binding4}
+    ${set0binding5}
+
+    layout(location = 0) in vec4 fPosition;
+    layout(location = 1) in vec4 fColor;
+    layout(location = 2) in vec4 fEmissiveColor;
+    layout(location = 3) in vec2 fMetallicRoughness;
+    layout(location = 4) in vec3 fNormal;
+
+    layout(location = 0) out vec4 oColor;
+
+    ${shadowFuns}
+    ${brdfSpecular}
+
+    void main() {
+      vec4 baseColor = fColor; // XXX: assuming premultiplied alpha
+      float metallic = fMetallicRoughness[0];
+      float roughness = fMetallicRoughness[1];
+      float nonOcclusion = 1.0;
+
+      vec3 normal = normalize(fNormal);
+
+      ${litMain}
+
+      oColor.rgb += pow(fEmissiveColor.rgb, vec3(2.2));
+    }
+  |]
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
@@ -6,6 +6,9 @@
   , Config
   , config
   , configBlend
+
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
@@ -14,16 +17,16 @@
 import Data.Tagged (Tagged(..))
 import Vulkan.Core10 qualified as Vk
 
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, compileFrag, glsl)
-import Render.Code.Lit (litMain, structLight, shadowFuns, brdfSpecular)
+import Render.Code (compileVert, compileFrag)
 import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
-import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding4, set0binding5)
+import Render.Lit.Colored.Code qualified as Code
 import Render.Lit.Colored.Model qualified as Model
 
-type Config = Pipeline.Configure Pipeline ()
-type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Graphics.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = ()
 
 allocate
   :: ( HasVulkan env
@@ -34,7 +37,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocate multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (config tset0)
@@ -50,7 +53,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocateBlend multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (configBlend tset0)
@@ -58,14 +61,13 @@
   pure p
 
 config :: Tagged Scene DsBindings -> Config
-config (Tagged set0) = Pipeline.baseConfig
-  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
-  , Pipeline.cVertexCode   = Just vertCode
-  , Pipeline.cVertexInput  = vertexInput
-  , Pipeline.cFragmentCode = Just fragCode
+config (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Graphics.cStages       = stageSpirv
+  , Graphics.cVertexInput  = vertexInput
   }
   where
-    vertexInput = Pipeline.vertexInput
+    vertexInput = Graphics.vertexInput
       [ vertexPos
       , (Vk.VERTEX_INPUT_RATE_VERTEX, Model.vkVertexAttrs)
       , instanceTransform
@@ -73,97 +75,17 @@
 
 configBlend :: Tagged Scene DsBindings -> Config
 configBlend tset0 = (config tset0)
-  { Pipeline.cBlend = True
+  { Graphics.cBlend = True
   }
 
-vertCode :: ByteString
-vertCode =
-  $(compileVert [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-
-    invariant gl_Position;
-
-    ${set0binding0}
-
-    layout(location = 0) in vec3 vPosition;
-    layout(location = 1) in vec4 vBaseColor;
-    layout(location = 2) in vec4 vEmissiveColor;
-    layout(location = 3) in vec2 vMetallicRoughness;
-    layout(location = 4) in vec3 vNormal;
-
-    layout(location = 5) in mat4 iModel;
-
-    layout(location = 0) out vec4 fPosition;
-    layout(location = 1) out vec4 fColor;
-    layout(location = 2) out vec4 fEmissiveColor;
-    layout(location = 3) out vec2 fMetallicRoughness;
-    layout(location = 4) out vec3 fNormal;
-
-    void main() {
-      fPosition = iModel * vec4(vPosition, 1.0);
-
-      gl_Position
-        = scene.projection
-        * scene.view
-        * fPosition;
-
-      fNormal = transpose(mat3(inverse(iModel))) * vNormal; // TODO: use modelInv
-
-      fColor = vBaseColor;
-      fColor.rgb *= vBaseColor.a;
-
-      fEmissiveColor = vEmissiveColor;
-      fMetallicRoughness = vMetallicRoughness;
-    }
-  |])
-
-fragCode :: ByteString
-fragCode =
-  $(compileFrag [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-    #extension GL_EXT_nonuniform_qualifier : enable
-
-    layout(early_fragment_tests) in;
-
-    // TODO: move to spec constant
-    const uint MAX_LIGHTS = 255;
-    const float PCF_STEP = 1.5 / 4096;
-
-    // TODO: move to material
-    const float reflectivity = 1.0/256.0;
-
-    ${structLight}
-
-    ${set0binding0}
-    ${set0binding1}
-    ${set0binding2}
-    ${set0binding3}
-    ${set0binding4}
-    ${set0binding5}
-
-    layout(location = 0) in vec4 fPosition;
-    layout(location = 1) in vec4 fColor;
-    layout(location = 2) in vec4 fEmissiveColor;
-    layout(location = 3) in vec2 fMetallicRoughness;
-    layout(location = 4) in vec3 fNormal;
-
-    layout(location = 0) out vec4 oColor;
-
-    ${shadowFuns}
-    ${brdfSpecular}
-
-    void main() {
-      vec4 baseColor = fColor; // XXX: assuming premultiplied alpha
-      float metallic = fMetallicRoughness[0];
-      float roughness = fMetallicRoughness[1];
-      float nonOcclusion = 1.0;
+stageCode :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
 
-      vec3 normal = normalize(fNormal);
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
 
-      ${litMain}
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-      oColor.rgb += pow(fEmissiveColor.rgb, vec3(2.2));
-    }
-  |])
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
diff --git a/src/Render/Lit/Material/Code.hs b/src/Render/Lit/Material/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Material/Code.hs
@@ -0,0 +1,175 @@
+module Render.Lit.Material.Code
+  ( vert
+  , frag
+  ) where
+
+import RIO
+
+import Render.Code (Code, glsl)
+import Render.Code.Lit (litMain, shadowFuns, structLight, structMaterial, brdfSpecular)
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding4, set0binding5, set0binding6)
+
+vert :: Code
+vert = fromString
+  [glsl|
+    #version 450
+
+    invariant gl_Position;
+
+    ${set0binding0}
+
+    // vertexPos
+    layout(location = 0) in vec3 vPosition;
+    // vertexAttrs
+    layout(location = 1) in vec2 vTexCoord0;
+    layout(location = 2) in vec2 vTexCoord1;
+    layout(location = 3) in vec3 vNormal;
+    layout(location = 4) in vec3 vTangent;
+    layout(location = 5) in uint vMaterial;
+
+    // transformMat
+    layout(location = 6) in mat4 iModel;
+
+    layout(location = 0)      out  vec4 fPosition;
+    layout(location = 1)      out  vec2 fTexCoord0;
+    layout(location = 2)      out  vec2 fTexCoord1;
+    layout(location = 3) flat out  uint fMaterial;
+    layout(location = 4)      out  mat3 fTBN;
+
+    void main() {
+      fPosition = iModel * vec4(vPosition, 1.0);
+
+      gl_Position
+        = scene.projection
+        * scene.view
+        * fPosition;
+
+      fTexCoord0 = vTexCoord0;
+      fTexCoord1 = vTexCoord1;
+
+      vec3 t = normalize(vec3(iModel * vec4(vTangent, 0.0)));
+      vec3 n = normalize(vec3(iModel * vec4(vNormal, 0.0)));
+      vec3 to = normalize(t - dot(t, n) * n); // re-orthogonalize T with respect to N
+      fTBN = mat3(to, cross(n, to), n);
+
+      fMaterial = vMaterial;
+    }
+  |]
+
+frag :: Code
+frag = fromString
+  [glsl|
+    #version 450
+    #extension GL_EXT_nonuniform_qualifier : enable
+
+    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;
+
+    ${structLight}
+    ${structMaterial}
+
+    ${set0binding0}
+    ${set0binding1}
+    ${set0binding2}
+    ${set0binding3}
+    ${set0binding4} // lights
+    ${set0binding5} // shadowmap
+    ${set0binding6} // materials
+
+    layout(location = 0)      in vec4 fPosition;
+    layout(location = 1)      in vec2 fTexCoord0;
+    layout(location = 2)      in vec2 fTexCoord1;
+    layout(location = 3) flat in uint fMaterial;
+    layout(location = 4)      in mat3 fTBN;
+
+    layout(location = 0) out vec4 oColor;
+
+    ${shadowFuns}
+    ${brdfSpecular}
+
+    void main() {
+      Material material = materials[fMaterial];
+      vec4 baseColor = material.baseColor;
+      float metallic = material.metallicRoughness[0];
+      float roughness = material.metallicRoughness[1];
+      float nonOcclusion = 1.0;
+      vec4 emissive = material.emissive;
+
+      if (material.baseColorTex > -1) {
+        baseColor *= texture(
+          sampler2D(
+            textures[nonuniformEXT(material.baseColorTex)],
+            samplers[0]
+          ),
+          fTexCoord0
+        );
+      }
+
+      if (baseColor.a < material.alphaCutoff) {
+        discard;
+      }
+
+      baseColor.rgb *= baseColor.a;
+
+      if (material.metallicRoughnessTex > -1) {
+        vec3 packed = texture(
+          sampler2D(
+            textures[nonuniformEXT(material.metallicRoughnessTex)],
+            samplers[0]
+          ),
+          fTexCoord0
+        ).rgb;
+        // XXX: assuming sRGB textures, even for AMR
+        packed = pow(packed, vec3(1.0/2.2));
+        nonOcclusion -= packed.r;
+        metallic *= packed.b;
+        roughness *= packed.g;
+      }
+
+      // // TODO: combine with MR as channel R.
+      // float occlusion = texture(
+      //   sampler2D(
+      //     textures[nonuniformEXT(max(0, material.ambientOcclusionTex))],
+      //     samplers[0]
+      //   ),
+      //   fTexCoord0
+      // ).r;
+      // nonOcclusion -= pow(occlusion, 1.0/2.2);
+
+      if (material.emissiveTex > -1) {
+        emissive *= texture(
+          sampler2D(
+            textures[nonuniformEXT(material.emissiveTex)],
+            samplers[0]
+          ),
+          fTexCoord0
+        );
+      }
+
+      vec3 normal = fTBN[2];
+      if (material.normalTex > -1) {
+        vec3 normalsColor = texture(
+          sampler2D(
+            textures[nonuniformEXT(material.normalTex)],
+            samplers[0]
+          ),
+          fTexCoord0
+        ).rgb;
+
+        // XXX: convert normal non-colors to linear values from sRGB texture colorspace
+        vec3 normals = pow(normalsColor, vec3(1.0/2.2)) * 2.0 - 1.0;
+
+        normal = normalize(fTBN * normals);
+      }
+
+      ${litMain}
+
+      oColor.rgb += pow(emissive.rgb, vec3(2.2));
+    }
+  |]
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
@@ -6,6 +6,9 @@
   , Config
   , config
   , configBlend
+
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
@@ -14,16 +17,16 @@
 import Data.Tagged (Tagged(..))
 import Vulkan.Core10 qualified as Vk
 
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, compileFrag, glsl)
+import Render.Code (compileVert, compileFrag)
 import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
-import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding4, set0binding5, set0binding6)
-import Render.Code.Lit (litMain, shadowFuns, structLight, structMaterial, brdfSpecular)
+import Render.Lit.Material.Code qualified as Code
 import Render.Lit.Material.Model qualified as Model
 
-type Config = Pipeline.Configure Pipeline ()
-type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Graphics.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = ()
 
 allocate
   :: ( HasVulkan env
@@ -34,7 +37,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocate multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (config tset0)
@@ -50,7 +53,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocateBlend multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (configBlend tset0)
@@ -58,16 +61,15 @@
   pure p
 
 config :: Tagged Scene DsBindings -> Config
-config (Tagged set0) = Pipeline.baseConfig
-  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
-  , Pipeline.cVertexCode   = Just vertCode
-  , Pipeline.cVertexInput  = vertexInput
-  , Pipeline.cFragmentCode = Just fragCode
-  , Pipeline.cDepthWrite   = False -- XXX: Lit pipelines require depth pre-pass
-  , Pipeline.cDepthCompare = Vk.COMPARE_OP_EQUAL
+config (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Graphics.cStages       = stageSpirv
+  , Graphics.cVertexInput  = vertexInput
+  , Graphics.cDepthWrite   = False -- XXX: Lit pipelines require depth pre-pass
+  , Graphics.cDepthCompare = Vk.COMPARE_OP_EQUAL
   }
   where
-    vertexInput = Pipeline.vertexInput
+    vertexInput = Graphics.vertexInput
       [ vertexPos -- vPosition
       , (Vk.VERTEX_INPUT_RATE_VERTEX, Model.vkVertexAttrs)
       , instanceTransform
@@ -75,176 +77,21 @@
 
 configBlend :: Tagged Scene DsBindings -> Config
 configBlend tset0 = (config tset0)
-  { Pipeline.cBlend =
+  { Graphics.cBlend =
       True
-  , Pipeline.cDepthCompare =
+  , Graphics.cDepthCompare =
       -- XXX: blended objects aren't a part of depth prepass
       Vk.COMPARE_OP_LESS_OR_EQUAL
   }
 
-vertCode :: ByteString
-vertCode =
-  $(compileVert [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-
-    invariant gl_Position;
-
-    ${set0binding0}
-
-    // vertexPos
-    layout(location = 0) in vec3 vPosition;
-    // vertexAttrs
-    layout(location = 1) in vec2 vTexCoord0;
-    layout(location = 2) in vec2 vTexCoord1;
-    layout(location = 3) in vec3 vNormal;
-    layout(location = 4) in vec3 vTangent;
-    layout(location = 5) in uint vMaterial;
-
-    // transformMat
-    layout(location = 6) in mat4 iModel;
-
-    layout(location = 0)      out  vec4 fPosition;
-    layout(location = 1)      out  vec2 fTexCoord0;
-    layout(location = 2)      out  vec2 fTexCoord1;
-    layout(location = 3) flat out  uint fMaterial;
-    layout(location = 4)      out  mat3 fTBN;
-
-    void main() {
-      fPosition = iModel * vec4(vPosition, 1.0);
-
-      gl_Position
-        = scene.projection
-        * scene.view
-        * fPosition;
-
-      fTexCoord0 = vTexCoord0;
-      fTexCoord1 = vTexCoord1;
-
-      vec3 t = normalize(vec3(iModel * vec4(vTangent, 0.0)));
-      vec3 n = normalize(vec3(iModel * vec4(vNormal, 0.0)));
-      vec3 to = normalize(t - dot(t, n) * n); // re-orthogonalize T with respect to N
-      fTBN = mat3(to, cross(n, to), n);
-
-      fMaterial = vMaterial;
-    }
-  |])
-
-fragCode :: ByteString
-fragCode =
-  $(compileFrag [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-    #extension GL_EXT_nonuniform_qualifier : enable
-
-    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;
-
-    ${structLight}
-    ${structMaterial}
-
-    ${set0binding0}
-    ${set0binding1}
-    ${set0binding2}
-    ${set0binding3}
-    ${set0binding4} // lights
-    ${set0binding5} // shadowmap
-    ${set0binding6} // materials
-
-    layout(location = 0)      in vec4 fPosition;
-    layout(location = 1)      in vec2 fTexCoord0;
-    layout(location = 2)      in vec2 fTexCoord1;
-    layout(location = 3) flat in uint fMaterial;
-    layout(location = 4)      in mat3 fTBN;
-
-    layout(location = 0) out vec4 oColor;
-
-    ${shadowFuns}
-    ${brdfSpecular}
-
-    void main() {
-      Material material = materials[fMaterial];
-      vec4 baseColor = material.baseColor;
-      float metallic = material.metallicRoughness[0];
-      float roughness = material.metallicRoughness[1];
-      float nonOcclusion = 1.0;
-      vec4 emissive = material.emissive;
-
-      if (material.baseColorTex > -1) {
-        baseColor *= texture(
-          sampler2D(
-            textures[nonuniformEXT(material.baseColorTex)],
-            samplers[0]
-          ),
-          fTexCoord0
-        );
-      }
-
-      if (baseColor.a < material.alphaCutoff) {
-        discard;
-      }
-
-      baseColor.rgb *= baseColor.a;
-
-      if (material.metallicRoughnessTex > -1) {
-        vec3 packed = texture(
-          sampler2D(
-            textures[nonuniformEXT(material.metallicRoughnessTex)],
-            samplers[0]
-          ),
-          fTexCoord0
-        ).rgb;
-        // XXX: assuming sRGB textures, even for AMR
-        packed = pow(packed, vec3(1.0/2.2));
-        nonOcclusion -= packed.r;
-        metallic *= packed.b;
-        roughness *= packed.g;
-      }
-
-      // // TODO: combine with MR as channel R.
-      // float occlusion = texture(
-      //   sampler2D(
-      //     textures[nonuniformEXT(max(0, material.ambientOcclusionTex))],
-      //     samplers[0]
-      //   ),
-      //   fTexCoord0
-      // ).r;
-      // nonOcclusion -= pow(occlusion, 1.0/2.2);
-
-      if (material.emissiveTex > -1) {
-        emissive *= texture(
-          sampler2D(
-            textures[nonuniformEXT(material.emissiveTex)],
-            samplers[0]
-          ),
-          fTexCoord0
-        );
-      }
-
-      vec3 normal = fTBN[2];
-      if (material.normalTex > -1) {
-        vec3 normalsColor = texture(
-          sampler2D(
-            textures[nonuniformEXT(material.normalTex)],
-            samplers[0]
-          ),
-          fTexCoord0
-        ).rgb;
-
-        // XXX: convert normal non-colors to linear values from sRGB texture colorspace
-        vec3 normals = pow(normalsColor, vec3(1.0/2.2)) * 2.0 - 1.0;
+stageCode :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
 
-        normal = normalize(fTBN * normals);
-      }
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
 
-      ${litMain}
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-      oColor.rgb += pow(emissive.rgb, vec3(2.2));
-    }
-  |])
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
diff --git a/src/Render/Lit/Textured/Code.hs b/src/Render/Lit/Textured/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Textured/Code.hs
@@ -0,0 +1,120 @@
+module Render.Lit.Textured.Code
+  ( vert
+  , frag
+  ) where
+
+import RIO
+
+import Render.Code (Code, glsl)
+import Render.Code.Lit (litMain, shadowFuns, structLight, brdfSpecular)
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding4, set0binding5)
+
+vert :: Code
+vert = fromString
+  [glsl|
+    #version 450
+
+    invariant gl_Position;
+
+    ${set0binding0}
+
+    // vertexPos
+    layout(location = 0) in vec3 vPosition;
+    // vertexAttrs
+    layout(location = 1) in vec2 vTexCoord;
+    layout(location = 2) in vec3 vNormal;
+    // textureParams
+    layout(location = 3) in  vec4 iTextureScaleOffset;
+    layout(location = 4) in  vec4 iTextureGamma;
+    layout(location = 5) in ivec2 iTextureIds;
+
+    // transformMat
+    layout(location = 6) in mat4 iModel;
+
+    layout(location = 0)      out  vec4 fPosition;
+    layout(location = 1)      out  vec3 fNormal;
+    layout(location = 2)      out  vec2 fTexCoord;
+    layout(location = 3) flat out  vec4 fTextureGamma;
+    layout(location = 4) flat out ivec2 fTextureIds;
+
+    void main() {
+      fPosition = iModel * vec4(vPosition, 1.0);
+
+      gl_Position
+        = scene.projection
+        * scene.view
+        * fPosition;
+
+      fNormal = transpose(mat3(inverse(iModel))) * vNormal; // TODO: use modelInv
+
+      fTexCoord     = vTexCoord * iTextureScaleOffset.st + iTextureScaleOffset.pq;
+      fTextureGamma = iTextureGamma;
+      fTextureIds   = iTextureIds;
+    }
+  |]
+
+frag :: Code
+frag = fromString
+  [glsl|
+    #version 450
+    #extension GL_EXT_nonuniform_qualifier : enable
+
+    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;
+
+    // TODO: move to material
+    const float reflectivity = 1.0/256.0;
+
+    ${structLight}
+
+    ${set0binding0}
+    ${set0binding1}
+    ${set0binding2}
+    ${set0binding3}
+    ${set0binding4}
+    ${set0binding5}
+
+    layout(location = 0)      in  vec4 fPosition;
+    layout(location = 1)      in  vec3 fNormal;
+    layout(location = 2)      in  vec2 fTexCoord;
+    layout(location = 3) flat in  vec4 fTextureGamma;
+    layout(location = 4) flat in ivec2 fTextureIds;
+
+    layout(location = 0) out vec4 oColor;
+
+    ${shadowFuns}
+    ${brdfSpecular}
+
+    void main() {
+      // XXX: fall back to solid color
+      vec4 baseColor = fTextureGamma;
+      if ((fTextureIds.t > -1) && (fTextureIds.s > -1)) {
+        vec4 texel = texture(
+          sampler2D(
+            textures[nonuniformEXT(fTextureIds.t)],
+            samplers[nonuniformEXT(fTextureIds.s)]
+          ),
+          fTexCoord
+        );
+
+        if (texel.a < 0.5) discard;
+
+        vec3 color = pow(texel.rgb, fTextureGamma.rgb);
+        float combinedAlpha = texel.a * fTextureGamma.a;
+        baseColor = vec4(color * combinedAlpha, combinedAlpha);
+      }
+
+      // TODO: get from textures
+      float metallic = 0.0;
+      float roughness = 0.6;
+      float nonOcclusion = 1.0;
+
+      vec3 normal = normalize(fNormal);
+
+      ${litMain}
+    }
+  |]
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
@@ -6,6 +6,9 @@
   , Config
   , config
   , configBlend
+
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
@@ -14,16 +17,16 @@
 import Data.Tagged (Tagged(..))
 import Vulkan.Core10 qualified as Vk
 
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, compileFrag, glsl)
+import Render.Code (compileVert, compileFrag)
 import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
-import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding4, set0binding5)
-import Render.Code.Lit (litMain, shadowFuns, structLight, brdfSpecular)
+import Render.Lit.Textured.Code qualified as Code
 import Render.Lit.Textured.Model qualified as Model
 
-type Config = Pipeline.Configure Pipeline ()
-type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Graphics.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = ()
 
 allocate
   :: ( HasVulkan env
@@ -34,7 +37,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocate multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (config tset0)
@@ -50,7 +53,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocateBlend multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (configBlend tset0)
@@ -58,16 +61,15 @@
   pure p
 
 config :: Tagged Scene DsBindings -> Config
-config (Tagged set0) = Pipeline.baseConfig
-  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
-  , Pipeline.cVertexCode   = Just vertCode
-  , Pipeline.cVertexInput  = vertexInput
-  , Pipeline.cFragmentCode = Just fragCode
-  , Pipeline.cDepthWrite   = False -- XXX: Lit pipelines require depth pre-pass
-  , Pipeline.cDepthCompare = Vk.COMPARE_OP_EQUAL
+config (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Graphics.cStages       = stageSpirv
+  , Graphics.cVertexInput  = vertexInput
+  , Graphics.cDepthWrite   = False -- XXX: Lit pipelines require depth pre-pass
+  , Graphics.cDepthCompare = Vk.COMPARE_OP_EQUAL
   }
   where
-    vertexInput = Pipeline.vertexInput
+    vertexInput = Graphics.vertexInput
       [ vertexPos -- vPosition
       , (Vk.VERTEX_INPUT_RATE_VERTEX,   Model.vkVertexAttrs)
       , (Vk.VERTEX_INPUT_RATE_INSTANCE, Model.vkInstanceTexture)
@@ -76,118 +78,18 @@
 
 configBlend :: Tagged Scene DsBindings -> Config
 configBlend tset0 = (config tset0)
-  { Pipeline.cBlend      = True
-  , Pipeline.cDepthCompare = Vk.COMPARE_OP_LESS_OR_EQUAL
+  { Graphics.cBlend      = True
+  , Graphics.cDepthCompare = Vk.COMPARE_OP_LESS_OR_EQUAL
   }
 
-vertCode :: ByteString
-vertCode =
-  $(compileVert [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-
-    invariant gl_Position;
-
-    ${set0binding0}
-
-    // vertexPos
-    layout(location = 0) in vec3 vPosition;
-    // vertexAttrs
-    layout(location = 1) in vec2 vTexCoord;
-    layout(location = 2) in vec3 vNormal;
-    // textureParams
-    layout(location = 3) in  vec4 iTextureScaleOffset;
-    layout(location = 4) in  vec4 iTextureGamma;
-    layout(location = 5) in ivec2 iTextureIds;
-
-    // transformMat
-    layout(location = 6) in mat4 iModel;
-
-    layout(location = 0)      out  vec4 fPosition;
-    layout(location = 1)      out  vec3 fNormal;
-    layout(location = 2)      out  vec2 fTexCoord;
-    layout(location = 3) flat out  vec4 fTextureGamma;
-    layout(location = 4) flat out ivec2 fTextureIds;
-
-    void main() {
-      fPosition = iModel * vec4(vPosition, 1.0);
-
-      gl_Position
-        = scene.projection
-        * scene.view
-        * fPosition;
-
-      fNormal = transpose(mat3(inverse(iModel))) * vNormal; // TODO: use modelInv
-
-      fTexCoord     = vTexCoord * iTextureScaleOffset.st + iTextureScaleOffset.pq;
-      fTextureGamma = iTextureGamma;
-      fTextureIds   = iTextureIds;
-    }
-  |])
-
-fragCode :: ByteString
-fragCode =
-  $(compileFrag [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-    #extension GL_EXT_nonuniform_qualifier : enable
-
-    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;
-
-    // TODO: move to material
-    const float reflectivity = 1.0/256.0;
-
-    ${structLight}
-
-    ${set0binding0}
-    ${set0binding1}
-    ${set0binding2}
-    ${set0binding3}
-    ${set0binding4}
-    ${set0binding5}
-
-    layout(location = 0)      in  vec4 fPosition;
-    layout(location = 1)      in  vec3 fNormal;
-    layout(location = 2)      in  vec2 fTexCoord;
-    layout(location = 3) flat in  vec4 fTextureGamma;
-    layout(location = 4) flat in ivec2 fTextureIds;
-
-    layout(location = 0) out vec4 oColor;
-
-    ${shadowFuns}
-    ${brdfSpecular}
-
-    void main() {
-      // XXX: fall back to solid color
-      vec4 baseColor = fTextureGamma;
-      if ((fTextureIds.t > -1) && (fTextureIds.s > -1)) {
-        vec4 texel = texture(
-          sampler2D(
-            textures[nonuniformEXT(fTextureIds.t)],
-            samplers[nonuniformEXT(fTextureIds.s)]
-          ),
-          fTexCoord
-        );
-
-        if (texel.a < 0.5) discard;
-
-        vec3 color = pow(texel.rgb, fTextureGamma.rgb);
-        float combinedAlpha = texel.a * fTextureGamma.a;
-        baseColor = vec4(color * combinedAlpha, combinedAlpha);
-      }
+stageCode :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
 
-      // TODO: get from textures
-      float metallic = 0.0;
-      float roughness = 0.6;
-      float nonOcclusion = 1.0;
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
 
-      vec3 normal = normalize(fNormal);
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-      ${litMain}
-    }
-  |])
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
diff --git a/src/Render/ShadowMap/Code.hs b/src/Render/ShadowMap/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/ShadowMap/Code.hs
@@ -0,0 +1,33 @@
+module Render.ShadowMap.Code
+  ( vert
+  ) where
+
+import RIO
+
+import Render.Code (Code, glsl)
+import Render.Code.Lit (structLight)
+import Render.DescSets.Sun (pattern MAX_VIEWS)
+
+vert :: Code
+vert = fromString
+  [glsl|
+    #version 450
+    #extension GL_EXT_multiview : enable
+
+    ${structLight}
+
+    layout(set=0, binding=0, std140) uniform Globals {
+      Light lights[${MAX_VIEWS}];
+    };
+
+    layout(location = 0) in vec3 vPosition;
+
+    layout(location = 1) in mat4 iModel;
+
+    void main() {
+      gl_Position
+        = lights[gl_ViewIndex].viewProjection
+        * iModel
+        * vec4(vPosition, 1.0);
+    }
+  |]
diff --git a/src/Render/ShadowMap/Pipeline.hs b/src/Render/ShadowMap/Pipeline.hs
--- a/src/Render/ShadowMap/Pipeline.hs
+++ b/src/Render/ShadowMap/Pipeline.hs
@@ -7,6 +7,9 @@
 
   , Config
   , config
+
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
@@ -16,15 +19,16 @@
 import Geomancy (Transform)
 import Vulkan.Core10 qualified as Vk
 
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, glsl)
-import Render.Code.Lit (structLight)
+import Render.Code (compileVert)
 import Render.DescSets.Set0 (vertexPos, instanceTransform)
-import Render.DescSets.Sun (Sun, pattern MAX_VIEWS)
+import Render.DescSets.Sun (Sun)
+import Render.ShadowMap.Code qualified as Code
 
-type Config = Pipeline.Configure Pipeline ()
-type Pipeline = Pipeline.Pipeline '[Sun] () Transform
+type Pipeline = Graphics.Pipeline '[Sun] () Transform
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = ()
 
 data Settings = Settings
   { cull      :: Vk.CullModeFlagBits
@@ -46,7 +50,7 @@
   -> Settings
   -> ResourceT (RIO env) Pipeline
 allocate tset0 rp settings = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     Vk.SAMPLE_COUNT_1_BIT
     (config tset0 settings)
@@ -54,40 +58,24 @@
   pure p
 
 config :: Tagged Sun DsBindings -> Settings -> Config
-config (Tagged set0) Settings{..} = Pipeline.baseConfig
-  { Pipeline.cDescLayouts  = Tagged @'[Sun] [set0]
-  , Pipeline.cVertexCode   = Just vertCode
-  , Pipeline.cVertexInput  = vertexInput
-  , Pipeline.cDepthBias    = depthBias
-  , Pipeline.cCull         = cull
+config (Tagged set0) Settings{..} = Graphics.baseConfig
+  { Graphics.cDescLayouts  = Tagged @'[Sun] [set0]
+  , Graphics.cStages       = stageSpirv
+  , Graphics.cVertexInput  = vertexInput
+  , Graphics.cDepthBias    = depthBias
+  , Graphics.cCull         = cull
   }
   where
-    vertexInput = Pipeline.vertexInput
+    vertexInput = Graphics.vertexInput
       [ vertexPos
       , instanceTransform
       ]
 
-vertCode :: ByteString
-vertCode =
-  $(compileVert [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-    #extension GL_EXT_multiview : enable
-
-    ${structLight}
-
-    layout(set=0, binding=0, std140) uniform Globals {
-      Light lights[${MAX_VIEWS}];
-    };
-
-    layout(location = 0) in vec3 vPosition;
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.vertexOnly vertSpirv
 
-    layout(location = 1) in mat4 iModel;
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-    void main() {
-      gl_Position
-        = lights[gl_ViewIndex].viewProjection
-        * iModel
-        * vec4(vPosition, 1.0);
-    }
-  |])
+stageCode :: Graphics.StageCode
+stageCode = Graphics.vertexOnly Code.vert
diff --git a/src/Render/Skybox/Code.hs b/src/Render/Skybox/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Skybox/Code.hs
@@ -0,0 +1,64 @@
+module Render.Skybox.Code
+  ( vert
+  , frag
+  ) where
+
+import RIO
+
+import Render.Code (Code, glsl)
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding3)
+
+vert :: Code
+vert = fromString
+  [glsl|
+    #version 450
+
+    ${set0binding0}
+
+    layout(location = 0) out vec3 fragUVW;
+
+    float farZ = 0.9999; // 1 - 1e-7;
+
+    void main() {
+      vec4 pos = vec4(0.0);
+      switch(gl_VertexIndex) {
+          case 0: pos = vec4(-1.0,  3.0, farZ, 1.0); break;
+          case 1: pos = vec4(-1.0, -1.0, farZ, 1.0); break; // XXX: swapped with 2. culling?
+          case 2: pos = vec4( 3.0, -1.0, farZ, 1.0); break;
+      }
+
+      vec3 unProjected = (scene.invProjection * pos).xyz;
+      unProjected *= -1;
+      fragUVW = mat3(scene.invView) * unProjected;
+
+      gl_Position = pos;
+    }
+  |]
+
+frag :: Code
+frag = fromString
+  [glsl|
+    #version 450
+    #extension GL_EXT_nonuniform_qualifier : enable
+
+    ${set0binding0}
+
+    ${set0binding1}
+    ${set0binding3}
+
+    layout(location = 0) in vec3 fragUVW;
+
+    layout(location = 0) out vec4 outColor;
+
+    void main() {
+      if (scene.envCubeId > -1) {
+        outColor = texture(
+          samplerCube(
+            cubes[nonuniformEXT(scene.envCubeId)],
+            samplers[3] // XXX: linear
+          ),
+          fragUVW
+        );
+      }
+    }
+  |]
diff --git a/src/Render/Skybox/Pipeline.hs b/src/Render/Skybox/Pipeline.hs
--- a/src/Render/Skybox/Pipeline.hs
+++ b/src/Render/Skybox/Pipeline.hs
@@ -1,8 +1,13 @@
 {-# LANGUAGE OverloadedLists #-}
 
 module Render.Skybox.Pipeline
-  ( Pipeline
+  ( Config
+  , config
+  , Pipeline
   , allocate
+
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
@@ -11,13 +16,15 @@
 import Data.Tagged (Tagged(..))
 import Vulkan.Core10 qualified as Vk
 
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, compileFrag, glsl)
+import Render.Code (compileVert, compileFrag)
 import Render.DescSets.Set0 (Scene)
-import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding3)
+import Render.Skybox.Code qualified as Code
 
-type Pipeline = Pipeline.Pipeline '[Scene] () ()
+type Pipeline = Graphics.Pipeline '[Scene] () ()
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = ()
 
 allocate
   :: ( HasVulkan env
@@ -27,70 +34,27 @@
   -> Tagged Scene DsBindings
   -> renderpass
   -> ResourceT (RIO env) Pipeline
-allocate multisample (Tagged set0) = do
-  fmap snd . Pipeline.allocate
+allocate multisample tset0 = do
+  fmap snd . Graphics.allocate
     Nothing
     multisample
-    Pipeline.baseConfig
-      { Pipeline.cVertexCode   = Just vertCode
-      , Pipeline.cFragmentCode = Just fragCode
-      , Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
-      , Pipeline.cCull         = Vk.CULL_MODE_NONE
-      }
-
-vertCode :: ByteString
-vertCode =
-  $(compileVert [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-
-    ${set0binding0}
-
-    layout(location = 0) out vec3 fragUVW;
-
-    float farZ = 0.9999; // 1 - 1e-7;
-
-    void main() {
-      vec4 pos = vec4(0.0);
-      switch(gl_VertexIndex) {
-          case 0: pos = vec4(-1.0,  3.0, farZ, 1.0); break;
-          case 1: pos = vec4(-1.0, -1.0, farZ, 1.0); break; // XXX: swapped with 2. culling?
-          case 2: pos = vec4( 3.0, -1.0, farZ, 1.0); break;
-      }
-
-      vec3 unProjected = (scene.invProjection * pos).xyz;
-      unProjected *= -1;
-      fragUVW = mat3(scene.invView) * unProjected;
-
-      gl_Position = pos;
-    }
-  |])
-
-fragCode :: ByteString
-fragCode =
-  $(compileFrag [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-    #extension GL_EXT_nonuniform_qualifier : enable
+    (config tset0)
 
-    ${set0binding0}
+config :: Tagged Scene DsBindings -> Config
+config (Tagged set0) = Graphics.baseConfig
+  { Graphics.cStages      = stageSpirv
+  , Graphics.cDescLayouts = Tagged @'[Scene] [set0]
+  , Graphics.cCull        = Vk.CULL_MODE_NONE
+  }
 
-    ${set0binding1}
-    ${set0binding3}
+stageCode :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
 
-    layout(location = 0) in vec3 fragUVW;
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
 
-    layout(location = 0) out vec4 outColor;
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-    void main() {
-      if (scene.envCubeId > -1) {
-        outColor = texture(
-          samplerCube(
-            cubes[nonuniformEXT(scene.envCubeId)],
-            samplers[3] // XXX: linear
-          ),
-          fragUVW
-        );
-      }
-    }
-  |])
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
diff --git a/src/Render/Unlit/Colored/Code.hs b/src/Render/Unlit/Colored/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Unlit/Colored/Code.hs
@@ -0,0 +1,50 @@
+module Render.Unlit.Colored.Code
+  ( vert
+  , frag
+  ) where
+
+import RIO
+
+import Render.Code (Code, glsl)
+import Render.DescSets.Set0.Code (set0binding0)
+
+vert :: Code
+vert = fromString
+  [glsl|
+    #version 450
+
+    ${set0binding0}
+
+    layout(location = 0) in vec3 vPosition;
+    layout(location = 1) in vec4 vColor;
+
+    layout(location = 2) in mat4 iModel;
+
+    layout(location = 0) out vec4 fColor;
+
+    void main() {
+      vec4 fPosition = iModel * vec4(vPosition, 1.0);
+
+      gl_Position
+        = scene.projection
+        * scene.view
+        * fPosition;
+
+      fColor = vColor;
+      fColor.rgb * fColor.a;
+    }
+  |]
+
+frag :: Code
+frag = fromString
+  [glsl|
+    #version 450
+
+    layout(location = 0) in vec4 fragColor;
+
+    layout(location = 0) out vec4 outColor;
+
+    void main() {
+      outColor = fragColor;
+    }
+  |]
diff --git a/src/Render/Unlit/Colored/Pipeline.hs b/src/Render/Unlit/Colored/Pipeline.hs
--- a/src/Render/Unlit/Colored/Pipeline.hs
+++ b/src/Render/Unlit/Colored/Pipeline.hs
@@ -6,6 +6,9 @@
   , Config
   , config
   , configWireframe
+
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
@@ -14,15 +17,16 @@
 import Data.Tagged (Tagged(..))
 import Vulkan.Core10 qualified as Vk
 
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, compileFrag, glsl)
+import Render.Code (compileVert, compileFrag)
 import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
-import Render.DescSets.Set0.Code (set0binding0)
+import Render.Unlit.Colored.Code qualified as Code
 import Render.Unlit.Colored.Model qualified as Model
 
-type Config = Pipeline.Configure Pipeline ()
-type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Graphics.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = ()
 
 allocate
   :: ( HasVulkan env
@@ -34,7 +38,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocate useDepth multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (config useDepth tset0)
@@ -51,7 +55,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocateWireframe useDepth multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (configWireframe useDepth tset0)
@@ -59,66 +63,34 @@
   pure p
 
 config :: Bool -> Tagged Scene DsBindings -> Config
-config useDepth (Tagged set0) = Pipeline.baseConfig
-  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
-  , Pipeline.cVertexCode   = Just vertCode
-  , Pipeline.cVertexInput  = vertexInput
-  , Pipeline.cFragmentCode = Just fragCode
-  , Pipeline.cBlend        = True
-  , Pipeline.cDepthTest    = useDepth
-  , Pipeline.cDepthWrite   = useDepth
+config useDepth (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Graphics.cStages       = stageSpirv
+  , Graphics.cVertexInput  = vertexInput
+  , Graphics.cBlend        = True
+  , Graphics.cDepthTest    = useDepth
+  , Graphics.cDepthWrite   = useDepth
   }
   where
-    vertexInput = Pipeline.vertexInput
+    vertexInput = Graphics.vertexInput
       [ vertexPos
       , (Vk.VERTEX_INPUT_RATE_VERTEX, Model.vkVertexAttrs)
       , instanceTransform
       ]
 
-configWireframe :: Bool -> Tagged Scene DsBindings -> Config
-configWireframe useDepth tset0 = (config useDepth tset0)
-  { Pipeline.cTopology = Vk.PRIMITIVE_TOPOLOGY_LINE_LIST
-  }
-
-vertCode :: ByteString
-vertCode =
-  $(compileVert [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-
-    ${set0binding0}
-
-    layout(location = 0) in vec3 vPosition;
-    layout(location = 1) in vec4 vColor;
-
-    layout(location = 2) in mat4 iModel;
-
-    layout(location = 0) out vec4 fColor;
-
-    void main() {
-      vec4 fPosition = iModel * vec4(vPosition, 1.0);
-
-      gl_Position
-        = scene.projection
-        * scene.view
-        * fPosition;
-
-      fColor = vColor;
-      fColor.rgb * fColor.a;
-    }
-  |])
+stageCode  :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
 
-fragCode :: ByteString
-fragCode =
-  $(compileFrag [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
 
-    layout(location = 0) in vec4 fragColor;
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-    layout(location = 0) out vec4 outColor;
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
 
-    void main() {
-      outColor = fragColor;
-    }
-  |])
+configWireframe :: Bool -> Tagged Scene DsBindings -> Config
+configWireframe useDepth tset0 = (config useDepth tset0)
+  { Graphics.cTopology = Vk.PRIMITIVE_TOPOLOGY_LINE_LIST
+  }
diff --git a/src/Render/Unlit/Sprite/Code.hs b/src/Render/Unlit/Sprite/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Unlit/Sprite/Code.hs
@@ -0,0 +1,125 @@
+module Render.Unlit.Sprite.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 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;
+    }
+  |]
+
+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 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/Render/Unlit/Sprite/Pipeline.hs b/src/Render/Unlit/Sprite/Pipeline.hs
--- a/src/Render/Unlit/Sprite/Pipeline.hs
+++ b/src/Render/Unlit/Sprite/Pipeline.hs
@@ -2,8 +2,12 @@
 
 module Render.Unlit.Sprite.Pipeline
   ( Pipeline
+  , Config
+  , config
   , allocate
   -- , allocateOutline
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
@@ -12,15 +16,16 @@
 import Data.Tagged (Tagged(..))
 import Vulkan.Core10 qualified as Vk
 
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, compileFrag, glsl)
+import Render.Code (compileVert, compileFrag)
 import Render.DescSets.Set0 (Scene)
-import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2)
+import Render.Unlit.Sprite.Code qualified as Code
 import Render.Unlit.Sprite.Model qualified as Model
 
-type Config = Pipeline.Configure Pipeline (Float, Bool)
-type Pipeline = Pipeline.Pipeline '[Scene] () Model.InstanceAttrs
+type Pipeline = Graphics.Pipeline '[Scene] () Model.InstanceAttrs
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = (Float, Bool)
 
 allocate
   :: ( HasVulkan env
@@ -33,7 +38,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocate multisample discardAlpha outline set0 = do
-  fmap snd . Pipeline.allocate
+  fmap snd . Graphics.allocate
     Nothing
     multisample
     (config discardAlpha outline set0)
@@ -44,19 +49,18 @@
   -> 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
+  Graphics.baseConfig
+    { Graphics.cStages         = stageSpirv
+    , Graphics.cDescLayouts    = Tagged @'[Scene] [set0]
+    , Graphics.cVertexInput    = vertexInput
+    , Graphics.cDepthTest      = False
+    , Graphics.cDepthWrite     = False
+    , Graphics.cBlend          = True
+    , Graphics.cCull           = Vk.CULL_MODE_NONE
+    , Graphics.cSpecialization = specs
     }
   where
-    vertexInput = Pipeline.vertexInput
+    vertexInput = Graphics.vertexInput
       [ (Vk.VERTEX_INPUT_RATE_INSTANCE, Model.vkInstanceAttrs)
       ]
 
@@ -65,120 +69,14 @@
       , 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;
+stageCode :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
 
-      if (doOutline) {
-        if (fOutline.a > 0.0 && texel.a < 1.0) {
-          float alpha = 0.0;
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
 
-          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;
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-          outColor += fOutline * alpha;
-        }
-      }
-    }
-  |])
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
diff --git a/src/Render/Unlit/Textured/Code.hs b/src/Render/Unlit/Textured/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Unlit/Textured/Code.hs
@@ -0,0 +1,83 @@
+module Render.Unlit.Textured.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}
+
+    // vertexPos
+    layout(location = 0) in vec3 vPosition;
+    // vertexAttrs
+    layout(location = 1) in vec2 vTexCoord;
+    // textureParams
+    layout(location = 2) in  vec4 iTextureScaleOffset;
+    layout(location = 3) in  vec4 iTextureGamma;
+    layout(location = 4) in ivec2 iTextureIds;
+
+    // transformMat
+    layout(location = 5) in mat4 iModel;
+
+    layout(location = 0)      out  vec2 fTexCoord;
+    layout(location = 1) flat out  vec4 fTextureGamma;
+    layout(location = 2) flat out ivec2 fTextureIds;
+
+    void main() {
+      vec4 fPosition = iModel * vec4(vPosition, 1.0);
+
+      gl_Position
+        = scene.projection
+        * scene.view
+        * fPosition;
+
+      fTexCoord     = vTexCoord * iTextureScaleOffset.st + iTextureScaleOffset.pq;
+      fTextureGamma = iTextureGamma;
+      fTextureIds   = iTextureIds;
+    }
+  |]
+
+frag :: Code
+frag = fromString
+  [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 fTextureGamma;
+    layout(location = 2) flat in ivec2 fTextureIds;
+
+    layout(location = 0) out vec4 oColor;
+
+    void main() {
+      vec3 color = vec3(0);
+      float combinedAlpha = 0;
+
+      if (fTextureIds.t > -1 && fTextureIds.s > -1) {
+        vec4 texel = texture(
+          sampler2D(
+            textures[nonuniformEXT(fTextureIds.t)],
+            samplers[nonuniformEXT(fTextureIds.s)]
+          ),
+          fTexCoord
+        );
+        color = pow(texel.rgb, fTextureGamma.rgb);
+        combinedAlpha = texel.a * fTextureGamma.a;
+      }
+
+      // XXX: premultiply alpha due to additive blending
+      oColor = vec4(color * combinedAlpha, combinedAlpha);
+    }
+  |]
diff --git a/src/Render/Unlit/Textured/Pipeline.hs b/src/Render/Unlit/Textured/Pipeline.hs
--- a/src/Render/Unlit/Textured/Pipeline.hs
+++ b/src/Render/Unlit/Textured/Pipeline.hs
@@ -6,23 +6,27 @@
   , Config
   , config
   , configBlend
+
+  , stageCode
+  , stageSpirv
   ) where
 
 import RIO
-import Render.DescSets.Set0.Code
 
 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.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, compileFrag, glsl)
+import Render.Code (compileVert, compileFrag)
 import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
+import Render.Unlit.Textured.Code qualified as Code
 import Render.Unlit.Textured.Model qualified as Model
 
-type Config = Pipeline.Configure Pipeline ()
-type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Graphics.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = ()
 
 allocate
   :: ( HasVulkan env
@@ -33,7 +37,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocate multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (config tset0)
@@ -49,7 +53,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocateBlend multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (configBlend tset0)
@@ -57,14 +61,13 @@
   pure p
 
 config :: Tagged Scene DsBindings -> Config
-config (Tagged set0) = Pipeline.baseConfig
-  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
-  , Pipeline.cVertexCode   = Just vertCode
-  , Pipeline.cVertexInput  = vertexInput
-  , Pipeline.cFragmentCode = Just fragCode
+config (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Graphics.cStages       = stageSpirv
+  , Graphics.cVertexInput  = vertexInput
   }
   where
-    vertexInput = Pipeline.vertexInput
+    vertexInput = Graphics.vertexInput
       [ vertexPos -- vPosition
       , (Vk.VERTEX_INPUT_RATE_VERTEX,   Model.vkVertexAttrs)
       , (Vk.VERTEX_INPUT_RATE_INSTANCE, Model.vkInstanceTexture)
@@ -73,81 +76,18 @@
 
 configBlend :: Tagged Scene DsBindings -> Config
 configBlend tset0 = (config tset0)
-  { Pipeline.cBlend      = True
-  , Pipeline.cDepthWrite = False
+  { Graphics.cBlend      = True
+  , Graphics.cDepthWrite = False
   }
 
-vertCode :: ByteString
-vertCode =
-  $(compileVert [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-
-    ${set0binding0}
-
-    // vertexPos
-    layout(location = 0) in vec3 vPosition;
-    // vertexAttrs
-    layout(location = 1) in vec2 vTexCoord;
-    // textureParams
-    layout(location = 2) in  vec4 iTextureScaleOffset;
-    layout(location = 3) in  vec4 iTextureGamma;
-    layout(location = 4) in ivec2 iTextureIds;
-
-    // transformMat
-    layout(location = 5) in mat4 iModel;
-
-    layout(location = 0)      out  vec2 fTexCoord;
-    layout(location = 1) flat out  vec4 fTextureGamma;
-    layout(location = 2) flat out ivec2 fTextureIds;
-
-    void main() {
-      vec4 fPosition = iModel * vec4(vPosition, 1.0);
-
-      gl_Position
-        = scene.projection
-        * scene.view
-        * fPosition;
-
-      fTexCoord     = vTexCoord * iTextureScaleOffset.st + iTextureScaleOffset.pq;
-      fTextureGamma = iTextureGamma;
-      fTextureIds   = iTextureIds;
-    }
-  |])
-
-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 fTextureGamma;
-    layout(location = 2) flat in ivec2 fTextureIds;
-
-    layout(location = 0) out vec4 oColor;
+stageCode :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
 
-    void main() {
-      vec3 color = vec3(0);
-      float combinedAlpha = 0;
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
 
-      if (fTextureIds.t > -1 && fTextureIds.s > -1) {
-        vec4 texel = texture(
-          sampler2D(
-            textures[nonuniformEXT(fTextureIds.t)],
-            samplers[nonuniformEXT(fTextureIds.s)]
-          ),
-          fTexCoord
-        );
-        color = pow(texel.rgb, fTextureGamma.rgb);
-        combinedAlpha = texel.a * fTextureGamma.a;
-      }
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-      // XXX: premultiply alpha due to additive blending
-      oColor = vec4(color * combinedAlpha, combinedAlpha);
-    }
-  |])
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
diff --git a/src/Render/Unlit/TileMap/Code.hs b/src/Render/Unlit/TileMap/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Unlit/TileMap/Code.hs
@@ -0,0 +1,111 @@
+module Render.Unlit.TileMap.Code
+  ( vert
+  , frag
+  ) where
+
+import RIO
+
+import Render.Code (Code, glsl)
+import Render.Samplers qualified as Samplers
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2)
+
+vert :: Code
+vert = fromString
+  [glsl|
+    #version 450
+
+    ${set0binding0}
+
+    // vertexPos
+    layout(location = 0) in vec3 vPosition;
+    // vertexAttrs
+    layout(location = 1) in vec2 vTexCoord;
+    // tilemapParams
+    layout(location = 2) in ivec4 iTextureIds; // combined: tileset, tileset sampler, map, repeat
+    layout(location = 3) in vec2  iViewOffset;
+    layout(location = 4) in vec2  iViewportSize;
+    layout(location = 5) in vec2  iMapTextureSize;
+    layout(location = 6) in vec2  iTilesetTextureSize;
+    layout(location = 7) in vec2  iTileSize;
+
+    // transformMat
+    layout(location = 8) in mat4 iModel;
+
+    layout(location = 0)      out  vec2 fTexCoord;
+    layout(location = 1)      out  vec2 fPixCoord;
+    layout(location = 2) flat out ivec4 fTextureIds;
+    layout(location = 3) flat out  vec2 fTilesetTextureSize;
+    layout(location = 4) flat out  vec2 fTileSize;
+
+    void main() {
+      vec4 fPosition = iModel * vec4(vPosition, 1.0);
+
+      gl_Position
+        = scene.projection
+        * scene.view
+        * fPosition;
+
+      fPixCoord = (vTexCoord * iViewportSize) + iViewOffset;
+      fTexCoord = fPixCoord / iMapTextureSize / iTileSize;
+
+      fTextureIds = iTextureIds;
+      fTilesetTextureSize = iTilesetTextureSize;
+      fTileSize = iTileSize;
+    }
+  |]
+
+frag :: Code
+frag = fromString
+  [glsl|
+    #version 450
+    #extension GL_EXT_nonuniform_qualifier : enable
+
+    ${set0binding1}
+    ${set0binding2}
+
+    layout(location = 0) in vec2 fTexCoord;
+    layout(location = 1) in vec2 fPixCoord;
+
+    // combined: tileset, tileset sampler, map, repeat
+    layout(location = 2) flat in ivec4 fTextureIds;
+    layout(location = 3) flat in vec2 fTilesetTextureSize;
+    layout(location = 4) flat in vec2 fTileSize;
+
+    layout(location = 0) out vec4 oColor;
+
+    int tilesetTextureIx = fTextureIds[0];
+    int tilesetSamplerIx = fTextureIds[1];
+    int mapTextureIx     = fTextureIds[2];
+    int repeatTiles      = fTextureIds[3];
+
+    // TODO
+    // const vec4 fTextureGamma = vec4(1.0);
+
+    void main() {
+      if (repeatTiles == 0 && (fTexCoord.x < 0.0 || fTexCoord.y < 0.0 || fTexCoord.x > 1.0 || fTexCoord.y > 1.0)) {
+        discard;
+      }
+
+      vec4 map = texture(
+        sampler2D(
+          textures[nonuniformEXT(mapTextureIx)],
+          samplers[$samplerId]
+        ),
+        fTexCoord
+      );
+
+      vec2 spriteOffset = floor(map.xy * 256.0) * fTileSize;
+      vec2 spriteCoord = mod(fPixCoord, fTileSize);
+      vec2 spriteUV = round(spriteOffset + spriteCoord) / fTilesetTextureSize;
+
+      oColor = texture(
+        sampler2D(
+          textures[nonuniformEXT(tilesetTextureIx)],
+          samplers[nonuniformEXT(tilesetSamplerIx)]
+        ),
+        spriteUV
+      );
+    }
+  |]
+  where
+    samplerId = Samplers.nearest Samplers.indices
diff --git a/src/Render/Unlit/TileMap/Pipeline.hs b/src/Render/Unlit/TileMap/Pipeline.hs
--- a/src/Render/Unlit/TileMap/Pipeline.hs
+++ b/src/Render/Unlit/TileMap/Pipeline.hs
@@ -6,23 +6,26 @@
   , Config
   , config
   , configBlend
+
+  , stageCode
   ) where
 
 import RIO
-import Render.DescSets.Set0.Code
 
 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.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
-import Render.Code (compileVert, compileFrag, glsl)
+import Render.Code (compileVert, compileFrag)
 import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
+import Render.Unlit.TileMap.Code qualified as Code
 import Render.Unlit.TileMap.Model qualified as Model
 
-type Config = Pipeline.Configure Pipeline ()
-type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Graphics.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Config = Graphics.Configure Pipeline
+type instance Graphics.Specialization Pipeline = ()
 
 allocate
   :: ( HasVulkan env
@@ -33,7 +36,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocate multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (config tset0)
@@ -49,7 +52,7 @@
   -> renderpass
   -> ResourceT (RIO env) Pipeline
 allocateBlend multisample tset0 rp = do
-  (_, p) <- Pipeline.allocate
+  (_, p) <- Graphics.allocate
     Nothing
     multisample
     (configBlend tset0)
@@ -57,14 +60,13 @@
   pure p
 
 config :: Tagged Scene DsBindings -> Config
-config (Tagged set0) = Pipeline.baseConfig
-  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
-  , Pipeline.cVertexCode   = Just vertCode
-  , Pipeline.cVertexInput  = vertexInput
-  , Pipeline.cFragmentCode = Just fragCode
+config (Tagged set0) = Graphics.baseConfig
+  { Graphics.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Graphics.cStages       = stageSpirv
+  , Graphics.cVertexInput  = vertexInput
   }
   where
-    vertexInput = Pipeline.vertexInput
+    vertexInput = Graphics.vertexInput
       [ vertexPos
       , (Vk.VERTEX_INPUT_RATE_VERTEX,   Model.vkVertexAttrs)
       , (Vk.VERTEX_INPUT_RATE_INSTANCE, Model.vkInstanceTileMap)
@@ -73,107 +75,18 @@
 
 configBlend :: Tagged Scene DsBindings -> Config
 configBlend tset0 = (config tset0)
-  { Pipeline.cBlend      = True
-  , Pipeline.cDepthWrite = False
+  { Graphics.cBlend      = True
+  , Graphics.cDepthWrite = False
   }
 
-vertCode :: ByteString
-vertCode =
-  $(compileVert [glsl|
-    #version 450
-    #extension GL_ARB_separate_shader_objects : enable
-
-    ${set0binding0}
-
-    // vertexPos
-    layout(location = 0) in vec3 vPosition;
-    // vertexAttrs
-    layout(location = 1) in vec2 vTexCoord;
-    // tilemapParams
-    layout(location = 2) in ivec4 iTextureIds; // combined: tileset, tileset sampler, map, repeat
-    layout(location = 3) in vec2  iViewOffset;
-    layout(location = 4) in vec2  iViewportSize;
-    layout(location = 5) in vec2  iMapTextureSize;
-    layout(location = 6) in vec2  iTilesetTextureSize;
-    layout(location = 7) in vec2  iTileSize;
-
-    // transformMat
-    layout(location = 8) in mat4 iModel;
-
-    layout(location = 0)      out  vec2 fTexCoord;
-    layout(location = 1)      out  vec2 fPixCoord;
-    layout(location = 2) flat out ivec4 fTextureIds;
-    layout(location = 3) flat out  vec2 fTilesetTextureSize;
-    layout(location = 4) flat out  vec2 fTileSize;
-
-    void main() {
-      vec4 fPosition = iModel * vec4(vPosition, 1.0);
-
-      gl_Position
-        = scene.projection
-        * scene.view
-        * fPosition;
-
-      fPixCoord = (vTexCoord * iViewportSize) + iViewOffset;
-      fTexCoord = fPixCoord / iMapTextureSize / iTileSize;
-
-      fTextureIds = iTextureIds;
-      fTilesetTextureSize = iTilesetTextureSize;
-      fTileSize = iTileSize;
-    }
-  |])
-
-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) in vec2 fPixCoord;
-
-    // combined: tileset, tileset sampler, map, repeat
-    layout(location = 2) flat in ivec4 fTextureIds;
-    layout(location = 3) flat in vec2 fTilesetTextureSize;
-    layout(location = 4) flat in vec2 fTileSize;
-
-    layout(location = 0) out vec4 oColor;
-
-    int tilesetTextureIx = fTextureIds[0];
-    int tilesetSamplerIx = fTextureIds[1];
-    int mapTextureIx     = fTextureIds[2];
-    int repeatTiles      = fTextureIds[3];
-
-    // TODO
-    // const vec4 fTextureGamma = vec4(1.0);
-
-    void main() {
-      if (repeatTiles == 0 && (fTexCoord.x < 0.0 || fTexCoord.y < 0.0 || fTexCoord.x > 1.0 || fTexCoord.y > 1.0)) {
-        discard;
-      }
+stageCode  :: Graphics.StageCode
+stageCode = Graphics.basicStages Code.vert Code.frag
 
-      vec4 map = texture(
-        sampler2D(
-          textures[nonuniformEXT(mapTextureIx)],
-          samplers[7] // nearest
-        ),
-        fTexCoord
-      );
+stageSpirv :: Graphics.StageSpirv
+stageSpirv = Graphics.basicStages vertSpirv fragSpirv
 
-      vec2 spriteOffset = floor(map.xy * 256.0) * fTileSize;
-      vec2 spriteCoord = mod(fPixCoord, fTileSize);
-      vec2 spriteUV = (spriteOffset + spriteCoord) / fTilesetTextureSize;
+vertSpirv :: ByteString
+vertSpirv = $(compileVert Code.vert)
 
-      oColor = texture(
-        sampler2D(
-          textures[nonuniformEXT(tilesetTextureIx)],
-          samplers[nonuniformEXT(tilesetSamplerIx)]
-        ),
-        spriteUV
-      );
-    }
-  |])
+fragSpirv :: ByteString
+fragSpirv = $(compileFrag Code.frag)
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
@@ -8,7 +8,7 @@
 import RIO
 
 import Engine.Types qualified as Engine
-import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Pipeline.Graphics qualified as Graphics
 import Engine.Vulkan.Swapchain qualified as Swapchain
 import Engine.Worker qualified as Worker
 import Render.Draw qualified as Draw
@@ -48,9 +48,9 @@
 
     Set0.withBoundSet0 frSceneUi pWireframe cb do
       -- Render UI
-      Pipeline.bind cb pUnlitTexturedBlend do
+      Graphics.bind cb pUnlitTexturedBlend do
         Draw.indexed cb (UI.quadUV ui) background
         Draw.indexed cb (UI.quadUV ui) spinner
 
-      Pipeline.bind cb pEvanwSdf $
+      Graphics.bind cb pEvanwSdf $
         traverse_ (Draw.quads cb) uiMessages
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
@@ -9,8 +9,9 @@
 
 import Control.Monad.Trans.Resource (ResourceT)
 import Engine.Camera qualified as Camera
+import Engine.Stage.Component qualified as Stage
 import Engine.StageSwitch (trySwitchStage)
-import Engine.Types (StackStage(..), Stage(..), StageSetupRIO)
+import Engine.Types (StackStage(..), StageSetupRIO)
 import Engine.Types qualified as Engine
 import Engine.UI.Layout qualified as Layout
 import Engine.UI.Message qualified as Message
@@ -120,21 +121,13 @@
   -> (loaded -> StackStage)
   -> UI.Settings textures fonts
   -> Basic.Stage FrameResources RunState
-loaderStage loadAction nextStage uiSettings = Stage
-  { sTitle = "Loader"
-
-  , sAllocateRP = allocateRenderPass
-  , sAllocateP  = allocatePipelines
-  , sInitialRS  = initialRunState loadAction nextStage uiSettings
-  , sInitialRR  = initialFrameResources uiSettings
-  , sBeforeLoop = pure ()
-
-  , sUpdateBuffers  = Render.updateBuffers
-  , sRecordCommands = Render.recordCommands
-
-  , sAfterLoop    = pure
-  }
+loaderStage loadAction nextStage uiSettings = Stage.assemble "Loader" rendering resources (Just scene)
   where
+    rendering = Stage.Rendering
+      { rAllocateRP = allocateRenderPass
+      , rAllocateP = allocatePipelines
+      }
+
     allocateRenderPass swapchain = do
       Basic.allocate
         Basic.Settings
@@ -157,6 +150,17 @@
       (UI.combined uiSettings)
       Nothing
       0
+
+    resources = Stage.Resources
+      { rInitialRS = initialRunState loadAction nextStage uiSettings
+      , rInitialRR = initialFrameResources uiSettings
+      }
+
+    scene = Stage.Scene
+      { scBeforeLoop = pure ()
+      , scUpdateBuffers = Render.updateBuffers
+      , scRecordCommands = Render.recordCommands
+      }
 
 initialRunState
   :: ((Text -> StageSetupRIO ()) -> StageSetupRIO loaded)
