diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,3 @@
+# Changelog for keid-render-basic
+
+## Unreleased changes
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright IC Rainbow (c) 2021
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Author name here nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,15 @@
+# Keid Engine - Basic forward rendering
+
+- Forward renderpass with MSAA
+  * Common descriptor set
+  * Unlit pipelines
+    * Colored
+    * Textured
+  * Lit pipelines
+    * Colored
+    * Textured
+    * Material
+  * Skybox pipeline
+  * Debug pipeline
+- Shadowmap pass
+  * Shadow-casting pipeline
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/keid-render-basic.cabal b/keid-render-basic.cabal
new file mode 100644
--- /dev/null
+++ b/keid-render-basic.cabal
@@ -0,0 +1,142 @@
+cabal-version: 1.12
+
+-- This file has been generated from package.yaml by hpack version 0.34.4.
+--
+-- see: https://github.com/sol/hpack
+
+name:           keid-render-basic
+version:        0.1.0.0
+synopsis:       Basic rendering programs for Keid engine.
+category:       Game Engine
+author:         IC Rainbow
+maintainer:     keid@aenor.ru
+copyright:      2021 IC Rainbow
+license:        BSD3
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+    ChangeLog.md
+
+source-repository head
+  type: git
+  location: https://gitlab.com/keid/engine
+
+library
+  exposed-modules:
+      Engine.UI.Message
+      Render.Basic
+      Render.Code.Lit
+      Render.Debug.Model
+      Render.Debug.Pipeline
+      Render.DescSets.Set0
+      Render.DescSets.Set0.Code
+      Render.DescSets.Sun
+      Render.Font.EvanwSdf.Model
+      Render.Font.EvanwSdf.Pipeline
+      Render.ForwardMsaa
+      Render.Lit.Colored.Model
+      Render.Lit.Colored.Pipeline
+      Render.Lit.Material
+      Render.Lit.Material.Collect
+      Render.Lit.Material.Model
+      Render.Lit.Material.Pipeline
+      Render.Lit.Textured.Model
+      Render.Lit.Textured.Pipeline
+      Render.ShadowMap.Pipeline
+      Render.ShadowMap.RenderPass
+      Render.Skybox.Pipeline
+      Render.Unlit.Colored.Model
+      Render.Unlit.Colored.Pipeline
+      Render.Unlit.Textured.Model
+      Render.Unlit.Textured.Pipeline
+      Resource.Font
+      Resource.Font.EvanW
+      Resource.Mesh.Lit
+  other-modules:
+      Paths_keid_render_basic
+  hs-source-dirs:
+      src
+  default-extensions:
+      NoImplicitPrelude
+      ApplicativeDo
+      BangPatterns
+      BinaryLiterals
+      BlockArguments
+      ConstrainedClassMethods
+      ConstraintKinds
+      DataKinds
+      DefaultSignatures
+      DeriveDataTypeable
+      DeriveFunctor
+      DeriveGeneric
+      DeriveLift
+      DeriveTraversable
+      DerivingStrategies
+      DerivingVia
+      DuplicateRecordFields
+      EmptyCase
+      EmptyDataDeriving
+      ExistentialQuantification
+      ExplicitForAll
+      FlexibleContexts
+      FlexibleInstances
+      FunctionalDependencies
+      GADTs
+      GeneralizedNewtypeDeriving
+      HexFloatLiterals
+      ImportQualifiedPost
+      InstanceSigs
+      KindSignatures
+      LambdaCase
+      LiberalTypeSynonyms
+      MultiParamTypeClasses
+      NamedFieldPuns
+      NamedWildCards
+      NumDecimals
+      NumericUnderscores
+      OverloadedStrings
+      PatternSynonyms
+      PostfixOperators
+      QuantifiedConstraints
+      QuasiQuotes
+      RankNTypes
+      RecordWildCards
+      ScopedTypeVariables
+      StandaloneDeriving
+      StandaloneKindSignatures
+      StrictData
+      TemplateHaskell
+      TupleSections
+      TypeApplications
+      TypeFamilies
+      TypeOperators
+      TypeSynonymInstances
+      UnicodeSyntax
+      ViewPatterns
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
+  build-depends:
+      GLFW-b
+    , VulkanMemoryAllocator
+    , aeson
+    , base >=4.7 && <5
+    , binary
+    , bytestring
+    , cryptohash-md5
+    , derive-storable
+    , derive-storable-plugin
+    , foldl
+    , geomancy
+    , keid-core
+    , neat-interpolation
+    , resourcet
+    , rio >=0.1.12.0
+    , rio-app
+    , tagged
+    , text
+    , unliftio
+    , vector
+    , vulkan
+    , vulkan-utils
+    , zstd
+  default-language: Haskell2010
diff --git a/src/Engine/UI/Message.hs b/src/Engine/UI/Message.hs
new file mode 100644
--- /dev/null
+++ b/src/Engine/UI/Message.hs
@@ -0,0 +1,129 @@
+module Engine.UI.Message
+  ( Process
+  , Input(..)
+  , spawn
+  , spawnFromR
+  , mkAttrs
+
+  , Observer
+  , Buffer
+  , newObserver
+  , observe
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Control.Monad.Trans.Resource qualified as Resource
+import Data.Text qualified as Text
+import Data.Vector.Storable qualified as Storable
+import Geomancy (Vec4)
+import Geomancy.Vec4 qualified as Vec4
+import Vulkan.Core10 qualified as Vk
+
+import Engine.Types qualified as Engine
+import Engine.UI.Layout qualified as Layout
+import Engine.Vulkan.Types (HasVulkan)
+import Engine.Worker qualified as Worker
+import Render.Font.EvanwSdf.Model qualified as EvanwSdf
+import Render.Samplers qualified as Samplers
+import Resource.Buffer qualified as Buffer
+import Resource.Font.EvanW qualified as Font
+
+type Process = Worker.Merge (Storable.Vector EvanwSdf.InstanceAttrs)
+
+spawn
+  :: ( Worker.HasOutput box
+     , Worker.GetOutput box ~ Layout.Box
+     , Worker.HasOutput input
+     , Worker.GetOutput input ~ Input
+     ) => box -> input -> RIO env Process
+spawn = Worker.spawnMerge2 mkAttrs
+
+spawnFromR
+  :: ( Resource.MonadResource (RIO env)
+     , Worker.HasOutput box
+     , Worker.GetOutput box ~ Layout.Box
+     , Worker.HasOutput source
+     )
+  => box
+  -> source
+  -> (Worker.GetOutput source -> Input)
+  -> RIO env (Resource.ReleaseKey, Resource.ReleaseKey, Process)
+spawnFromR parent inputProc mkMessage = do
+  inputP <- Worker.spawnMerge1 mkMessage inputProc
+  messageP <- spawn parent inputP
+
+  (,,messageP)
+    <$> Worker.register inputP
+    <*> Worker.register messageP
+
+data Input = Input
+  { inputText         :: Text
+  , inputFontId       :: Int32
+  , inputFont         :: Font.Container
+  , inputOrigin       :: Layout.Alignment
+  , inputSize         :: Float
+  , inputColor        :: Vec4
+  , inputOutline      :: Vec4
+  , inputOutlineWidth :: Float
+  , inputSmoothing    :: Float
+  }
+
+mkAttrs :: Layout.Box -> Input -> Storable.Vector EvanwSdf.InstanceAttrs
+mkAttrs Layout.Box{..} Input{..} =
+  if inputSize < 1 then mempty else Storable.fromList do
+    Font.PutChar{..} <- chars
+    pure EvanwSdf.InstanceAttrs
+      { vertRect     = Vec4.fromVec22 pcPos pcSize
+      , fragRect     = Vec4.fromVec22 pcOffset pcScale
+      , color        = inputColor
+      , outlineColor = inputOutline -- vec4 0 0.25 0 0.25
+      , textureId    = inputFontId
+      , samplerId    = samplerId
+      , smoothing    = inputSmoothing -- 1/16
+      , outlineWidth = inputOutlineWidth -- 3/16
+      }
+  where
+    (scale, chars) = Font.putLine
+      boxSize
+      boxPosition
+      inputOrigin
+      inputSize
+      inputFont
+      (Text.unpack inputText)
+
+    samplerId =
+      if scale > 1 then
+        Samplers.linear Samplers.indices
+      else
+        Samplers.linear Samplers.indices
+
+type Buffer = Buffer.Allocated 'Buffer.Coherent EvanwSdf.InstanceAttrs
+
+type Observer = Worker.ObserverIO Buffer
+
+newObserver :: Int -> ResourceT (Engine.StageRIO st) Observer
+newObserver initialSize = do
+  context <- ask
+
+  messageData <- Buffer.createCoherent context Vk.BUFFER_USAGE_VERTEX_BUFFER_BIT initialSize mempty
+  observer <- Worker.newObserverIO messageData
+
+  void $! Resource.register do
+    vData <- readIORef observer
+    Buffer.destroyAll context vData
+
+  pure observer
+
+observe
+  :: ( HasVulkan env
+     , Worker.HasOutput source
+     , Worker.GetOutput source ~ Storable.Vector EvanwSdf.InstanceAttrs
+     )
+  => source
+  -> Observer -> RIO env ()
+observe messageP observer = do
+  context <- ask
+  Worker.observeIO_ messageP observer $
+    Buffer.updateCoherentResize_ context
diff --git a/src/Render/Basic.hs b/src/Render/Basic.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Basic.hs
@@ -0,0 +1,151 @@
+{- |
+  All the provided render passes and pipelines packaged and delivered.
+-}
+
+module Render.Basic where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import RIO.Vector.Partial as Vector (headM)
+import Vulkan.Core10 qualified as Vk
+
+-- keid-core
+
+import Engine.Types qualified as Engine
+import Engine.Types (StageRIO)
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (DsBindings, HasSwapchain(getMultisample), RenderPass(..))
+
+-- keid-render-basic
+
+import Render.Debug.Pipeline qualified as Debug
+import Render.DescSets.Set0 (Scene)
+import Render.DescSets.Set0 qualified as Scene
+import Render.DescSets.Sun (Sun)
+import Render.DescSets.Sun qualified as Sun
+import Render.Font.EvanwSdf.Pipeline qualified as EvanwSdf
+import Render.ForwardMsaa (ForwardMsaa)
+import Render.ForwardMsaa qualified as ForwardMsaa
+import Render.Lit.Colored.Pipeline qualified as LitColored
+import Render.Lit.Material.Pipeline qualified as LitMaterial
+import Render.Lit.Textured.Pipeline qualified as LitTextured
+import Render.Samplers qualified as Samplers
+import Render.ShadowMap.Pipeline qualified as ShadowPipe
+import Render.ShadowMap.RenderPass (ShadowMap)
+import Render.ShadowMap.RenderPass qualified as ShadowPass
+import Render.Skybox.Pipeline qualified as Skybox
+import Render.Unlit.Colored.Pipeline qualified as UnlitColored
+import Render.Unlit.Textured.Pipeline qualified as UnlitTextured
+
+type Stage = Engine.Stage RenderPasses Pipelines
+type Frame = Engine.Frame RenderPasses Pipelines
+type StageFrameRIO r s a = Engine.StageFrameRIO RenderPasses Pipelines r s a
+
+data RenderPasses = RenderPasses
+  { rpForwardMsaa :: ForwardMsaa
+  , rpShadowPass  :: ShadowMap
+  }
+
+instance RenderPass RenderPasses where
+  allocateRenderpass_ context = do
+    rpForwardMsaa <- ForwardMsaa.allocateMsaa context
+
+    -- TODO: get from config
+    let
+      size      = 4096 -- XXX: 2048 is kinda minimum, even with PCF enabled
+      numLayers = 2    -- XXX: no-cascade sun + moon
+    rpShadowPass <- ShadowPass.allocate context size numLayers
+
+    pure RenderPasses{..}
+
+  updateRenderpass context RenderPasses{..} = RenderPasses
+    <$> ForwardMsaa.updateMsaa context rpForwardMsaa
+    <*> pure rpShadowPass -- XXX: not a screen pass
+
+  refcountRenderpass RenderPasses{..} = do
+    refcountRenderpass rpForwardMsaa
+    refcountRenderpass rpShadowPass
+
+data Pipelines = Pipelines
+  { pEvanwSdf :: EvanwSdf.Pipeline
+  , pSkybox   :: Skybox.Pipeline
+  , pDebug    :: Debug.Pipeline
+
+  , pLitColored       :: LitColored.Pipeline
+  , pLitColoredBlend  :: LitColored.Pipeline
+  , pLitMaterial      :: LitMaterial.Pipeline
+  , pLitMaterialBlend :: LitMaterial.Pipeline
+  , pLitTextured      :: LitTextured.Pipeline
+  , pLitTexturedBlend :: LitTextured.Pipeline
+
+  , pUnlitColored        :: UnlitColored.Pipeline
+  , pUnlitColoredNoDepth :: UnlitColored.Pipeline
+  , pUnlitTextured       :: UnlitTextured.Pipeline
+  , pUnlitTexturedBlend  :: UnlitTextured.Pipeline
+  , pWireframe           :: UnlitColored.Pipeline
+  , pWireframeNoDepth    :: UnlitColored.Pipeline
+
+  , pShadowCast :: ShadowPipe.Pipeline
+  }
+
+allocatePipelines_
+  :: HasSwapchain swapchain
+  => swapchain
+  -> RenderPasses
+  -> ResourceT (StageRIO st) Pipelines
+allocatePipelines_ swapchain renderpasses = do
+  (_, samplers) <- Samplers.allocate swapchain
+  allocatePipelines
+    (Scene.set0 samplers (Left 3) (Left 0) 2)
+    swapchain
+    renderpasses
+
+allocatePipelines
+  :: HasSwapchain swapchain
+  => Tagged Scene DsBindings
+  -> swapchain
+  -> RenderPasses
+  -> ResourceT (StageRIO st) Pipelines
+allocatePipelines sceneBinds swapchain RenderPasses{..} = do
+  let msaa = getMultisample swapchain
+
+  pDebug    <- Debug.allocate msaa sceneBinds rpForwardMsaa
+  pEvanwSdf <- EvanwSdf.allocate msaa sceneBinds rpForwardMsaa
+  pSkybox   <- Skybox.allocate msaa sceneBinds rpForwardMsaa
+
+  pLitColored       <- LitColored.allocate msaa sceneBinds rpForwardMsaa
+  pLitColoredBlend  <- LitColored.allocateBlend msaa sceneBinds rpForwardMsaa
+  pLitMaterial      <- LitMaterial.allocate msaa sceneBinds rpForwardMsaa
+  pLitMaterialBlend <- LitMaterial.allocateBlend msaa sceneBinds rpForwardMsaa
+  pLitTextured      <- LitTextured.allocate msaa sceneBinds rpForwardMsaa
+  pLitTexturedBlend <- LitTextured.allocateBlend msaa sceneBinds rpForwardMsaa
+
+  pUnlitColored        <- UnlitColored.allocate True msaa sceneBinds rpForwardMsaa
+  pUnlitColoredNoDepth <- UnlitColored.allocate False msaa sceneBinds rpForwardMsaa
+  pUnlitTextured       <- UnlitTextured.allocate msaa sceneBinds rpForwardMsaa
+  pUnlitTexturedBlend  <- UnlitTextured.allocateBlend msaa sceneBinds rpForwardMsaa
+  pWireframe           <- UnlitColored.allocateWireframe True msaa sceneBinds rpForwardMsaa
+  pWireframeNoDepth    <- UnlitColored.allocateWireframe False msaa sceneBinds rpForwardMsaa
+
+  let sunBinds = Sun.set0
+  pShadowCast <- ShadowPipe.allocate sunBinds rpShadowPass ShadowPipe.defaults
+
+  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
+
+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
diff --git a/src/Render/Code/Lit.hs b/src/Render/Code/Lit.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Code/Lit.hs
@@ -0,0 +1,322 @@
+module Render.Code.Lit
+  ( raySphereIntersection
+  , hgPhase
+  , structLight
+  , structMaterial
+  , shadowFuns
+  , litMain
+  , brdfSpecular
+  ) where
+
+import Render.Code (Code(..), trimming)
+
+raySphereIntersection :: Code
+raySphereIntersection = Code
+  [trimming|
+    vec2 raySphereIntersection(vec3 rayOrigin, vec3 rayDir, vec3 sphereCenter, float sphereRadius) {
+      vec3 tmp = rayOrigin - sphereCenter;
+
+      float b = dot(rayDir, tmp);
+      float c = dot(tmp, tmp) - sphereRadius * sphereRadius;
+
+      float disc = b * b - c;
+
+      if(disc < 0.0) return vec2(-M_MAX, -M_MAX);
+
+      float disc_sqrt = sqrt(disc);
+
+      float t0 = -b - disc_sqrt;
+      float t1 = -b + disc_sqrt;
+
+      return vec2(t0, t1);
+    }
+  |]
+
+-- | The Henyey-Greenstein Phase Function
+hgPhase :: Code
+hgPhase = Code
+  [trimming|
+    float hgPhase(float nu, float g) {
+      float g2 = g * g;
+      return
+        (
+          3.0 *
+          (1.0 - g2) *
+          (1.0 + nu * nu)
+        ) /
+        (
+          2.0 *
+          (2.0 + g2) *
+          pow(
+            1.0 + g2 - 2.0 * g * nu,
+            1.5
+          )
+        );
+    }
+  |]
+
+structLight :: Code
+structLight = Code
+  [trimming|
+    struct Light {
+      mat4 viewProjection; // bring model positions into light-space
+      vec4 shadow;         // offset-x, offset-y, shadowmap index, size
+      vec4 position;       // alpha: unused
+      vec4 direction;      // alpha: unused
+      vec4 color;          // alpha: energy
+      // vec2 cutoff;      // inner / outer
+    };
+  |]
+
+structMaterial :: Code
+structMaterial = Code
+  [trimming|
+    struct Material {
+      vec4 baseColor;
+      vec2 metallicRoughness;
+      vec4 emissive;
+      float normalScale;
+      float alphaCutoff;
+
+      int baseColorTex;
+      int metallicRoughnessTex;
+      int emissiveTex;
+      int normalTex;
+      int ambientOcclusionTex;
+    };
+  |]
+
+shadowFuns :: Code
+shadowFuns = Code
+  [trimming|
+    float shadow_factor(vec3 shadowCoord, float mapIx, vec2 offset) {
+      if (abs(shadowCoord.x) > 1.0 ||
+          abs(shadowCoord.y) > 1.0 ||
+          abs(shadowCoord.z) > 1.0)
+            return 0.0; // XXX: 1.0 would be better for directional
+
+      vec4 uvwi = vec4(shadowCoord.xy * 0.5 + 0.5 + offset, mapIx, shadowCoord.z);
+      return texture(shadowmaps, uvwi);
+    }
+
+    float filterPCF(vec3 shadowCoord, float mapIx) {
+      float shadowFactor = 0.0;
+      int count = 0;
+      int range = 1;
+
+      for (int x = -range; x <= range; x++) {
+        for (int y = -range; y <= range; y++) {
+          shadowFactor += shadow_factor(
+            shadowCoord,
+            mapIx,
+            vec2(x, y) * PCF_STEP
+          );
+          count++;
+        }
+      }
+      return shadowFactor / count;
+    }
+  |]
+
+litMain :: Code
+litMain = Code
+  [trimming|
+    vec3 albedo =
+      // XXX: not needed, we're in linear already
+      // pow(baseColor.rgb, vec3(2.2));
+      baseColor.rgb;
+    vec3 F0 = mix(vec3(0.04), albedo, metallic);
+
+    vec3 ray = scene.viewPosition.xyz - fPosition.xyz;
+    vec3 rayDir = normalize(ray); // V
+    float quadrance = dot(ray, ray);
+    float distance = sqrt(quadrance);
+
+    // XXX: provided by caller
+    // vec3 normal = normalize(fNormal); // N
+
+    vec3 Lo = vec3(0.0);
+    for (int l = 0; l < scene.numLights; l++) {
+      // XXX: directional lights' hit angle doesn't depend on fragment position
+      vec3 lightDir = normalize(lights[l].direction.xyz); // L
+
+      float shade = 1.0; // XXX: 0 - occluded, 1 - lit
+      if (lights[l].shadow.w > 0) {
+        vec4 light_space_pos = lights[l].viewProjection * fPosition;
+        vec4 shadowCoord = light_space_pos /= light_space_pos.w;
+
+        // TODO: pick on specialization constant
+        shade = filterPCF(shadowCoord.xyz, lights[l].shadow.z);
+        // shade = shadow_factor(shadowCoord.xyz, lights[l].shadow.z, vec2(0));
+      }
+
+      Lo += brdfSpecular(
+        lightDir,
+        ray,
+        normal,
+        F0,
+        metallic,
+        roughness,
+        albedo,
+        lights[l].color.rgb * lights[l].color.a
+      ) * shade;
+    }
+
+    vec3 reflection = prefilteredReflection(reflect(rayDir, normal), roughness).rgb;
+
+    // IBL
+    vec3 irradiance = vec3(0);
+    if (scene.envCubeId > -1) {
+      irradiance = textureLod(
+        samplerCube(
+          cubes[nonuniformEXT(scene.envCubeId)],
+          samplers[0]
+        ),
+        -normal,
+        IRRADIANCE_LOD
+      ).rgb;
+    }
+
+    // Specular reflectance
+    vec2 ibl = texture(
+      sampler2D(
+        textures[BRDF_LUT],
+        samplers[BRDF_LUT_SAMPLER]
+      ),
+      vec2(roughness, max(dot(normal, rayDir), 0.0))
+    ).rg;
+    vec3 F = F_SchlickR(max(dot(normal, rayDir), 0.0), F0, roughness);
+    vec3 specular = occlusion * reflection * (F * ibl.x + ibl.y);
+
+    vec3 kD = (1.0 - F) * (1.0 - metallic);
+
+    // Diffuse based on irradiance
+    vec3 diffuseI = occlusion * irradiance * albedo;
+    vec3 ambient = kD * diffuseI + specular;
+
+    // Combine with ambient
+    vec3 color = Lo + ambient;
+
+    // Tone mapping
+    color =
+      Uncharted2Tonemap(color * 4.5) /
+      Uncharted2Tonemap(vec3(11.2)); // White point
+
+    // Gamma correction
+    // XXX: not needed, we're in linear already
+    // color = pow(color, vec3(1.0/2.2));
+
+    // Happily ever after
+    oColor = vec4(color, baseColor.a);
+  |]
+
+brdfSpecular :: Code
+brdfSpecular = Code
+  [trimming|
+    // TODO: unhardcode
+    const int BRDF_LUT = 2;
+    const int BRDF_LUT_SAMPLER = 3; // linear/mip0/no-repeat
+    const float MAX_REFLECTION_LOD = 9.0; // todo: param/const
+    const float IRRADIANCE_LOD = 10.0; // todo: param/const
+
+    // Normal Distribution function --------------------------------------
+    float D_GGX(float dotNH, float roughness) {
+      float alpha = roughness * roughness;
+      float alpha2 = alpha * alpha;
+      float denom = dotNH * dotNH * (alpha2 - 1.0) + 1.0;
+      return (alpha2) / (3.14159265359 * denom*denom);
+    }
+
+    // Geometric Shadowing function --------------------------------------
+    float G_SchlicksmithGGX(float dotNL, float dotNV, float roughness) {
+      float r = (roughness + 1.0);
+      float k = (r*r) / 8.0;
+      float GL = dotNL / (dotNL * (1.0 - k) + k);
+      float GV = dotNV / (dotNV * (1.0 - k) + k);
+      return GL * GV;
+    }
+
+    // Fresnel function ----------------------------------------------------
+    vec3 F_Schlick(float cosTheta, vec3 F0) {
+      return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+    }
+
+    vec3 F_SchlickR(float cosTheta, vec3 F0, float roughness) {
+      return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0);
+    }
+
+    vec3 prefilteredReflection(vec3 R, float roughness) {
+      vec3 color = vec3(0);
+
+      if (scene.envCubeId > -1) {
+        float lod = roughness * MAX_REFLECTION_LOD;
+        float lodf = floor(lod);
+        float lodc = ceil(lod);
+
+        vec3 a = textureLod(
+          samplerCube(
+            cubes[nonuniformEXT(scene.envCubeId)],
+            samplers[0]
+          ),
+          R,
+          lodf
+        ).rgb;
+
+        vec3 b = textureLod(
+          samplerCube(
+            cubes[nonuniformEXT(scene.envCubeId)],
+            samplers[0]
+          ),
+          R,
+          lodc
+        ).rgb;
+
+        return mix(a, b, lod - lodf);
+
+
+//        color = texture(
+//          samplerCube(
+//            cubes[nonuniformEXT(scene.envCubeId)],
+//            samplers[2] // XXX: linear/mip0/repeat
+//          ),
+//          fragUVW,
+//          10
+//        );
+      }
+      return color;
+    }
+
+    vec3 brdfSpecular(vec3 L, vec3 V, vec3 N, vec3 F0, float metallic, float roughness, vec3 ALBEDO, vec3 lightColor) {
+      // Precalculate vectors and dot products
+      vec3 H = normalize (V + L);
+      float dotNH = clamp(dot(N, H), 0.0, 1.0);
+      float dotNV = clamp(dot(N, V), 0.0, 1.0);
+      float dotNL = clamp(dot(N, L), 0.0, 1.0);
+
+      vec3 color = vec3(0.0);
+
+      if (dotNL > 0.0) {
+        // D = Normal distribution (Distribution of the microfacets)
+        float D = D_GGX(dotNH, roughness);
+        // G = Geometric shadowing term (Microfacets shadowing)
+        float G = G_SchlicksmithGGX(dotNL, dotNV, roughness);
+        // F = Fresnel factor (Reflectance depending on angle of incidence)
+        vec3 F = F_Schlick(dotNV, F0);
+        vec3 spec = D * F * G / (4.0 * dotNL * dotNV + 0.001);
+        vec3 kD = (vec3(1.0) - F) * (1.0 - metallic);
+        color += (kD * ALBEDO / 3.1415926535897932384626433832795 + spec) * dotNL * lightColor;
+      }
+
+      return color;
+    }
+
+    vec3 Uncharted2Tonemap(vec3 color) {
+      float A = 0.15;
+      float B = 0.50;
+      float C = 0.10;
+      float D = 0.20;
+      float E = 0.02;
+      float F = 0.30;
+      return ((color * (A * color + C * B) + D * E) / (color * (A * color + B) + D * F)) - E / F;
+    }
+  |]
diff --git a/src/Render/Debug/Model.hs b/src/Render/Debug/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Debug/Model.hs
@@ -0,0 +1,218 @@
+module Render.Debug.Model
+  ( Model
+  , VertexAttrs
+  , vkVertexAttrs
+
+  , InstanceAttrs(..)
+  , instanceAttrs
+
+  , StorableAttrs
+  , storableAttrs1
+
+  , InstanceBuffers(..)
+
+  , TextureParams(..)
+  , vkInstanceTexture
+
+    -- TODO: extract and merge with UnlitTextured
+  , allocateInstancesWith
+  , allocateInstancesCoherent
+  , allocateInstancesCoherent_
+  , updateCoherentResize_
+  , Transform
+  ) where
+
+import RIO
+
+
+import Foreign (Storable(..))
+import Geomancy (Transform, Vec2, Vec4)
+import Geomancy.Vec3 qualified as Vec3
+import RIO.Vector.Storable qualified as Storable
+import UnliftIO.Resource (MonadResource, ReleaseKey, ResourceT, allocate)
+import Vulkan.Core10 qualified as Vk
+import Vulkan.NamedType ((:::))
+import Vulkan.Zero (Zero(..))
+
+import Engine.Vulkan.Types (HasVulkan)
+import Resource.Buffer qualified as Buffer
+import Resource.Model qualified as Model
+
+type Model buf = Model.Indexed buf Vec3.Packed VertexAttrs
+
+type VertexAttrs = "uv" ::: Vec2
+
+vkVertexAttrs :: [Vk.Format]
+vkVertexAttrs =
+  [ Vk.FORMAT_R32G32_SFLOAT -- vTexCoord :: vec2
+  ]
+
+-- | Data for a single element.
+data InstanceAttrs = InstanceAttrs
+  { textureParams :: TextureParams
+  , transformMat4 :: Transform
+  }
+
+instance Zero InstanceAttrs where
+  zero = InstanceAttrs
+    { textureParams = zero
+    , transformMat4 = mempty
+    }
+
+instanceAttrs :: Int32 -> Int32 -> [Transform] -> InstanceAttrs
+instanceAttrs samplerId textureId transforms = InstanceAttrs
+  { textureParams = zero
+      { tpSamplerId = samplerId
+      , tpTextureId = textureId
+      }
+  , transformMat4 = mconcat transforms
+  }
+
+-- | Intermediate data to be shipped.
+type StorableAttrs =
+  ( Storable.Vector TextureParams
+  , Storable.Vector Transform
+  )
+
+storableAttrs1 :: Int32 -> Int32 -> [Transform] -> StorableAttrs
+storableAttrs1 samplerId textureId transforms =
+  ( Storable.singleton textureParams
+  , Storable.singleton transformMat4
+  )
+  where
+    InstanceAttrs{..} = instanceAttrs
+      samplerId
+      textureId
+      transforms
+
+-- | GPU-bound data.
+data InstanceBuffers textureStage transformStage = InstanceBuffers
+  { ibTexture   :: InstanceTexture textureStage
+  , ibTransform :: InstanceTransform transformStage
+  }
+
+type InstanceTexture stage = Buffer.Allocated stage TextureParams
+
+type InstanceTransform stage = Buffer.Allocated stage Transform
+
+instance Model.HasVertexBuffers (InstanceBuffers textureStage transformStage) where
+  type VertexBuffersOf (InstanceBuffers textureStage transformStage) = InstanceAttrs
+
+  {-# INLINE getVertexBuffers #-}
+  getVertexBuffers InstanceBuffers{..} =
+    [ Buffer.aBuffer ibTexture
+    , Buffer.aBuffer ibTransform
+    ]
+
+  {-# INLINE getInstanceCount #-}
+  getInstanceCount InstanceBuffers{..} =
+    min
+      (Buffer.aUsed ibTexture)
+      (Buffer.aUsed ibTransform)
+
+data TextureParams = TextureParams
+  { tpScale     :: Vec2
+  , tpOffset    :: Vec2
+  , tpGamma     :: Vec4
+  , tpSamplerId :: Int32
+  , tpTextureId :: Int32
+  }
+  deriving (Show)
+
+instance Zero TextureParams where
+  zero = TextureParams
+    { tpScale     = 1
+    , tpOffset    = 0
+    , tpGamma     = 1.0
+    , tpSamplerId = minBound
+    , tpTextureId = minBound
+    }
+
+instance Storable TextureParams where
+  alignment ~_ = 4
+
+  sizeOf ~_ = 8 + 8 + 16 + 4 + 4
+
+  poke ptr TextureParams{..} = do
+    pokeByteOff ptr  0 tpScale
+    pokeByteOff ptr  8 tpOffset
+    pokeByteOff ptr 16 tpGamma
+    pokeByteOff ptr 32 tpSamplerId
+    pokeByteOff ptr 36 tpTextureId
+
+  peek ptr = do
+    tpScale     <- peekByteOff ptr  0
+    tpOffset    <- peekByteOff ptr  8
+    tpGamma     <- peekByteOff ptr 16
+    tpSamplerId <- peekByteOff ptr 32
+    tpTextureId <- peekByteOff ptr 36
+    pure TextureParams{..}
+
+vkInstanceTexture :: [Vk.Format]
+vkInstanceTexture =
+  [ Vk.FORMAT_R32G32B32A32_SFLOAT -- iTextureScaleOffset :: vec4
+  , Vk.FORMAT_R32G32B32A32_SFLOAT -- iTextureGamma       :: vec4
+  , Vk.FORMAT_R32G32_SINT         -- iTextureIds         :: ivec2
+  ]
+
+allocateInstancesWith
+  :: ( MonadResource m
+     , MonadUnliftIO m
+     )
+  => (Vk.BufferUsageFlagBits -> Int -> Storable.Vector TextureParams -> m (InstanceTexture texture))
+  -> (Vk.BufferUsageFlagBits -> Int -> Storable.Vector Transform -> m (InstanceTransform transform))
+  -> (forall stage a . Buffer.Allocated stage a -> m ())
+  -> [InstanceAttrs]
+  -> m (ReleaseKey, InstanceBuffers texture transform)
+allocateInstancesWith createTextures createTransforms bufferDestroy instances = do
+  ul <- askUnliftIO
+  allocate (create ul) (destroy ul)
+  where
+    textures   = Storable.fromList $ map textureParams instances
+    transforms = Storable.fromList $ map transformMat4 instances
+    numInstances = Storable.length textures
+
+    create (UnliftIO ul) = ul do
+      ibTexture   <- createTextures   Vk.BUFFER_USAGE_VERTEX_BUFFER_BIT numInstances textures
+      ibTransform <- createTransforms Vk.BUFFER_USAGE_VERTEX_BUFFER_BIT numInstances transforms
+      pure InstanceBuffers{..}
+
+    destroy (UnliftIO ul) InstanceBuffers{..} = ul do
+      bufferDestroy ibTexture
+      bufferDestroy ibTransform
+
+allocateInstancesCoherent
+  :: ( MonadReader env m
+     , HasVulkan env
+     , MonadResource m
+     , MonadUnliftIO m
+     )
+  => [InstanceAttrs]
+  -> m (ReleaseKey, InstanceBuffers 'Buffer.Coherent 'Buffer.Coherent)
+allocateInstancesCoherent instances = do
+  context <- ask
+  allocateInstancesWith
+    (Buffer.createCoherent context)
+    (Buffer.createCoherent context)
+    (Buffer.destroy context)
+    instances
+
+allocateInstancesCoherent_
+  :: (HasVulkan env)
+  => Int
+  -> ResourceT (RIO env) (InstanceBuffers 'Buffer.Coherent 'Buffer.Coherent)
+allocateInstancesCoherent_ n =
+  fmap snd $ allocateInstancesCoherent (replicate n zero)
+
+updateCoherentResize_
+  :: ( HasVulkan context
+     , MonadUnliftIO m
+     )
+  => context
+  -> InstanceBuffers 'Buffer.Coherent 'Buffer.Coherent
+  -> (Storable.Vector TextureParams, Storable.Vector Transform)
+  -> m (InstanceBuffers 'Buffer.Coherent 'Buffer.Coherent)
+updateCoherentResize_ context InstanceBuffers{..} (textures, transforms) =
+  InstanceBuffers
+    <$> Buffer.updateCoherentResize_ context ibTexture textures
+    <*> Buffer.updateCoherentResize_ context ibTransform transforms
diff --git a/src/Render/Debug/Pipeline.hs b/src/Render/Debug/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Debug/Pipeline.hs
@@ -0,0 +1,151 @@
+module Render.Debug.Pipeline
+  ( Pipeline
+  , allocate
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Zero (zero)
+
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
+import Render.Code (compileVert, compileFrag, glsl)
+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
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocate multisample (Tagged set0) rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (zero
+      { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
+      , Pipeline.cVertexCode   = Just vertCode
+      , Pipeline.cVertexInput  = vertexInput
+      , Pipeline.cFragmentCode = Just fragCode
+      })
+    rp
+  pure p
+  where
+    vertexInput = Pipeline.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() {
+      gl_Position
+        = scene.projection
+        * scene.view
+        * iModel
+        * vec4(vPosition, 1.0);
+
+      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;
+
+    const int mode = 1;
+
+    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 = texture(
+            shadowmaps,
+            vec3(fTexCoord, 0.0)
+          ).x;
+
+          float d1 = texture(
+            shadowmaps,
+            vec3(fTexCoord, 1.0)
+          ).x;
+
+          texel = vec4(1-d0, 0, 1-d1, 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/DescSets/Set0.hs b/src/Render/DescSets/Set0.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/DescSets/Set0.hs
@@ -0,0 +1,511 @@
+{-# OPTIONS_GHC -fplugin Foreign.Storable.Generic.Plugin #-}
+
+module Render.DescSets.Set0
+  ( Scene(..)
+  , emptyScene
+
+  , allocate
+  , allocateEmpty
+
+  , updateSet0Ds
+
+  , set0
+  , set0_
+
+  -- TODO: extract to typeclass magic
+  , vertexPos
+  , instanceTransform
+
+  , FrameResource(..)
+  , extendResourceDS
+
+  , Buffer
+  , Process
+  , observe
+
+  , withBoundSet0
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Control.Monad.Trans.Resource qualified as ResourceT
+import Data.Bits ((.|.))
+import Data.ByteString.Char8 qualified as BS8
+import Data.Kind (Type)
+import Data.Tagged (Tagged(..))
+import Data.Vector qualified as Vector
+import Data.Vector.Storable qualified as VectorS
+import Foreign.Storable.Generic (GStorable)
+import Geomancy (Vec3, Vec4, vec3)
+import Geomancy.Transform (Transform)
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Core12.Promoted_From_VK_EXT_descriptor_indexing qualified as Vk12
+import Vulkan.CStruct.Extends (SomeStruct(..))
+import Vulkan.NamedType ((:::))
+import Vulkan.Utils.Debug qualified as Debug
+import Vulkan.Zero (Zero(..))
+
+import Engine.Types (StageRIO)
+import Engine.Vulkan.DescSets (Bound, Extend, extendDS, withBoundDescriptorSets0)
+import Engine.Vulkan.Pipeline (Pipeline)
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (DsBindings, HasVulkan(..))
+import Engine.Worker qualified as Worker
+import Render.DescSets.Sun (Sun)
+import Render.Lit.Material (Material)
+import Resource.Buffer qualified as Buffer
+import Resource.Collection qualified as Collection
+import Resource.DescriptorSet qualified as DescriptorSet
+import Resource.Image qualified as Image
+import Resource.Texture (Flat, CubeMap, Texture)
+import Resource.Texture qualified as Texture
+
+-- * Set0 data
+
+data Scene = Scene
+  { sceneProjection    :: Transform
+  , sceneInvProjection :: Transform
+
+  , sceneView          :: Transform
+  , sceneInvView       :: Transform
+  , sceneViewPos       :: Vec3 -- XXX: gets extra padding
+  , sceneViewDir       :: Vec3 -- XXX: gets extra padding
+
+  , sceneTweaks        :: Vec4 -- ^ 4 debug tweaks bound to Kontrol
+
+  , sceneFog           :: Vec4 -- XXX: RGB color + scatter factor β
+  , sceneEnvCube       :: Int32
+  , sceneNumLights     :: Word32
+  }
+  deriving (Show, Generic)
+
+instance GStorable Scene
+
+emptyScene :: Scene
+emptyScene = Scene
+  { sceneProjection    = mempty
+  , sceneInvProjection = mempty
+
+  , sceneView          = mempty
+  , sceneInvView       = mempty
+  , sceneViewPos       = vec3 0 0 0
+  , sceneViewDir       = vec3 0 0 1
+
+  , sceneFog           = 0
+  , sceneEnvCube       = minBound
+  , sceneNumLights     = 0
+
+  , sceneTweaks        = 0
+  }
+
+-- * Common descriptor set
+
+set0
+  :: Traversable samplers
+  => samplers Vk.Sampler
+  -> Either Word32 (Vector (Texture Flat))
+  -> Either Word32 (Vector (Texture CubeMap))
+  -> Word32
+  -> Tagged Scene DsBindings
+set0 samplers textures cubes shadows = Tagged
+  [ (set0bind0,          zero)
+  , (set0bind1 samplers, zero)
+  , (set0bind2 textures, partialBinding)
+  , (set0bind3 cubes,    partialBinding)
+  , (set0bind4,          zero)
+  , (set0bind5 shadows,  partialBinding)
+  , (set0bind6,          zero)
+  ]
+  where
+    partialBinding =
+      Vk12.DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT
+
+    _partialVariable =
+      partialBinding .|.
+      Vk12.DESCRIPTOR_BINDING_VARIABLE_DESCRIPTOR_COUNT_BIT
+      {-
+        VUID-VkDescriptorSetLayoutBindingFlagsCreateInfo-pBindingFlags-03004(ERROR / SPEC):
+          msgNum: 222246202 - Validation Error:
+          [ VUID-VkDescriptorSetLayoutBindingFlagsCreateInfo-pBindingFlags-03004 ]
+          Object 0: handle = 0x157ea80, type = VK_OBJECT_TYPE_DEVICE;
+          | MessageID = 0xd3f353a
+          | Invalid flags for VkDescriptorSetLayoutBinding entry 2 The Vulkan spec states:
+              If an element of pBindingFlags includes VK_DESCRIPTOR_BINDING_VARIABLE_DESCRIPTOR_COUNT_BIT,
+              then all other elements of VkDescriptorSetLayoutCreateInfo::pBindings must have
+              a smaller value of binding
+              (https://vulkan.lunarg.com/doc/view/1.2.162.1~rc2/linux/1.2-extensions/vkspec.html#VUID-VkDescriptorSetLayoutBindingFlagsCreateInfo-pBindingFlags-03004)
+      -}
+
+-- | Nothing texture-related is provided.
+set0_ :: Tagged Scene DsBindings
+set0_ = set0 [] (Left 0) (Left 0) 0
+
+set0bind0 :: Vk.DescriptorSetLayoutBinding
+set0bind0 = Vk.DescriptorSetLayoutBinding
+  { binding           = 0
+  , descriptorType    = Vk.DESCRIPTOR_TYPE_UNIFORM_BUFFER
+  , descriptorCount   = 1
+  , stageFlags        = Vk.SHADER_STAGE_ALL
+  , immutableSamplers = mempty
+  }
+
+set0bind1 :: Traversable t => t Vk.Sampler -> Vk.DescriptorSetLayoutBinding
+set0bind1 samplers = Vk.DescriptorSetLayoutBinding
+  { Vk.binding           = 1
+  , Vk.stageFlags        = Vk.SHADER_STAGE_FRAGMENT_BIT
+  , Vk.descriptorType    = Vk.DESCRIPTOR_TYPE_SAMPLER
+  , Vk.descriptorCount   = fromIntegral $ Vector.length linearSamplers
+  , Vk.immutableSamplers = linearSamplers
+  }
+  where
+    linearSamplers = Collection.toVector samplers
+
+set0bind2 :: Either Word32 (Vector (Texture Flat)) -> Vk.DescriptorSetLayoutBinding
+set0bind2 textures = Vk.DescriptorSetLayoutBinding
+  { binding           = 2
+  , descriptorType    = Vk.DESCRIPTOR_TYPE_SAMPLED_IMAGE
+  , descriptorCount   = textureCount
+  , stageFlags        = Vk.SHADER_STAGE_FRAGMENT_BIT
+  , immutableSamplers = mempty
+  }
+  where
+    textureCount = case textures of
+      Left n ->
+        n
+      Right v ->
+        fromIntegral $ Vector.length v
+
+set0bind3 :: Either Word32 (Vector (Texture CubeMap)) -> Vk.DescriptorSetLayoutBinding
+set0bind3 cubes = Vk.DescriptorSetLayoutBinding
+  { binding           = 3
+  , descriptorType    = Vk.DESCRIPTOR_TYPE_SAMPLED_IMAGE
+  , descriptorCount   = cubeCount
+  , stageFlags        = Vk.SHADER_STAGE_FRAGMENT_BIT
+  , immutableSamplers = mempty
+  }
+  where
+    cubeCount = case cubes of
+      Left n ->
+        n
+      Right v ->
+        fromIntegral $ Vector.length v
+
+set0bind4 :: Vk.DescriptorSetLayoutBinding
+set0bind4 = Vk.DescriptorSetLayoutBinding
+  { binding           = 4
+  , descriptorType    = Vk.DESCRIPTOR_TYPE_UNIFORM_BUFFER
+  , stageFlags        = Vk.SHADER_STAGE_FRAGMENT_BIT
+  , descriptorCount   = 1
+  , immutableSamplers = mempty
+  }
+
+set0bind5 :: Word32 -> Vk.DescriptorSetLayoutBinding
+set0bind5 shadows = Vk.DescriptorSetLayoutBinding
+  { binding           = 5
+  , descriptorType    = Vk.DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER
+  , stageFlags        = Vk.SHADER_STAGE_FRAGMENT_BIT
+  , descriptorCount   = shadows
+  , immutableSamplers = mempty
+  }
+
+set0bind6 :: Vk.DescriptorSetLayoutBinding
+set0bind6 = Vk.DescriptorSetLayoutBinding
+  { binding           = 6
+  , descriptorType    = Vk.DESCRIPTOR_TYPE_UNIFORM_BUFFER
+  , stageFlags        = Vk.SHADER_STAGE_FRAGMENT_BIT
+  , descriptorCount   = 1
+  , immutableSamplers = mempty
+  }
+
+vertexPos :: (Vk.VertexInputRate, [Vk.Format])
+vertexPos =
+  ( Vk.VERTEX_INPUT_RATE_VERTEX
+  , [ Vk.FORMAT_R32G32B32_SFLOAT -- vPosition :: vec3
+    ]
+  )
+
+instanceTransform :: (Vk.VertexInputRate, [Vk.Format])
+instanceTransform =
+  ( Vk.VERTEX_INPUT_RATE_INSTANCE
+  , [ Vk.FORMAT_R32G32B32A32_SFLOAT -- iModel :: mat4
+    , Vk.FORMAT_R32G32B32A32_SFLOAT
+    , Vk.FORMAT_R32G32B32A32_SFLOAT
+    , Vk.FORMAT_R32G32B32A32_SFLOAT
+    ]
+  )
+
+-- * Setup
+
+allocate
+  :: (Traversable textures, Traversable cubes)
+  => Tagged '[Scene] Vk.DescriptorSetLayout
+  -> textures (Texture.Texture Texture.Flat)
+  -> cubes (Texture.Texture Texture.CubeMap)
+  -> Maybe (Buffer.Allocated 'Buffer.Coherent Sun)
+  -> "shadow maps" ::: Vector Vk.ImageView
+  -> Maybe (Buffer.Allocated 'Buffer.Coherent Material)
+  -> ResourceT (StageRIO st) (FrameResource '[Scene])
+allocate (Tagged set0layout) textures cubes lightsData shadowViews materialsData = do
+  context <- asks id
+
+  (_dpKey, descPool) <- DescriptorSet.allocatePool 1 dpSizes
+
+  let
+    set0dsCI = zero
+      { Vk.descriptorPool = descPool
+      , Vk.setLayouts     = Vector.singleton set0layout
+      }
+  descSets <- fmap (Tagged @'[Scene]) $
+    Vk.allocateDescriptorSets (getDevice context) set0dsCI
+
+  (_, sceneData) <- ResourceT.allocate
+    (Buffer.createCoherent context Vk.BUFFER_USAGE_UNIFORM_BUFFER_BIT 1 $ VectorS.singleton emptyScene)
+    (Buffer.destroy context)
+
+  let
+    -- TODO: must be checked against depth format and TILING_OPTIMAL
+    -- shadowFilter = Vk.FILTER_NEAREST
+    shadowFilter = Vk.FILTER_LINEAR
+
+    shadowCI = zero
+      { Vk.addressModeU  = Vk.SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE
+      , Vk.addressModeV  = Vk.SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE
+      , Vk.addressModeW  = Vk.SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE
+      , Vk.borderColor   = Vk.BORDER_COLOR_FLOAT_OPAQUE_WHITE
+      , Vk.magFilter     = shadowFilter
+      , Vk.minFilter     = shadowFilter
+      , Vk.compareEnable = True
+      , Vk.compareOp     = Vk.COMPARE_OP_LESS
+      }
+
+  let ifor = flip Vector.imapM
+  shadowMaps <- ifor shadowViews \ix depthView -> do
+    (_, shadowSampler) <- Vk.withSampler (getDevice context) shadowCI Nothing ResourceT.allocate
+    Debug.nameObject (getDevice context) shadowSampler $
+      "ShadowSampler." <> BS8.pack (show ix)
+    pure (shadowSampler, depthView)
+
+  updateSet0Ds context descSets sceneData textures cubes lightsData shadowMaps materialsData
+
+  scene <- Worker.newObserverIO emptyScene
+
+  pure $ FrameResource descSets sceneData scene
+
+dpSizes :: DescriptorSet.TypeMap Word32
+dpSizes =
+  [ ( Vk.DESCRIPTOR_TYPE_UNIFORM_BUFFER
+    , uniformBuffers
+    )
+  , ( Vk.DESCRIPTOR_TYPE_SAMPLED_IMAGE
+    , sampledImages
+    )
+  , ( Vk.DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER
+    , sampledImages + shadowMaps
+    )
+  , ( Vk.DESCRIPTOR_TYPE_SAMPLER
+    , staticSamplers
+    )
+  ]
+  where
+    uniformBuffers = 3    -- 1 scene + 1 light array + 1 material array
+    sampledImages  = 128  -- max dynamic textures and cubemaps
+    staticSamplers = 8    -- immutable samplers
+    shadowMaps     = 2    -- max shadowmaps
+
+-- | Minimal viable 'Scene' without textures and lighting.
+allocateEmpty
+  :: Tagged '[Scene] Vk.DescriptorSetLayout
+  -> ResourceT (StageRIO st) (FrameResource '[Scene])
+allocateEmpty taggedLayout = allocate taggedLayout [] [] Nothing mempty Nothing
+
+updateSet0Ds
+  :: (HasVulkan context, Traversable textures, Traversable cubes)
+  => context
+  -> Tagged '[Scene] (Vector Vk.DescriptorSet)
+  -> Buffer.Allocated 'Buffer.Coherent Scene
+  -> textures (Texture.Texture Texture.Flat)
+  -> cubes (Texture.Texture Texture.CubeMap)
+  -> Maybe (Buffer.Allocated 'Buffer.Coherent Sun)
+  -> Vector (Vk.Sampler, Vk.ImageView)
+  -> Maybe (Buffer.Allocated 'Buffer.Coherent Material)
+  -> ResourceT (StageRIO st) ()
+updateSet0Ds context (Tagged ds) sceneData textures cubes lightsData shadowMaps materialsData =
+  Vk.updateDescriptorSets (getDevice context) writeSets mempty
+
+  where
+    linearTextures = Collection.toVector textures
+    linearCubes = Collection.toVector cubes
+
+    destSet0 = case Vector.headM ds of
+      Nothing ->
+        error "assert: descriptor sets promised to contain [Scene]"
+      Just one ->
+        one
+
+    writeSet0b0 = SomeStruct zero
+      { Vk.dstSet          = destSet0
+      , Vk.dstBinding      = 0
+      , Vk.dstArrayElement = 0
+      , Vk.descriptorCount = 1
+      , Vk.descriptorType  = Vk.DESCRIPTOR_TYPE_UNIFORM_BUFFER
+      , Vk.bufferInfo      = Vector.singleton set0bind0I
+      }
+      where
+        set0bind0I = Vk.DescriptorBufferInfo
+          { Vk.buffer = Buffer.aBuffer sceneData
+          , Vk.offset = 0
+          , Vk.range  = Vk.WHOLE_SIZE
+          }
+
+    writeSet0b2 = SomeStruct zero
+      { Vk.dstSet          = destSet0
+      , Vk.dstBinding      = 2
+      , Vk.descriptorType  = Vk.DESCRIPTOR_TYPE_SAMPLED_IMAGE
+      , Vk.dstArrayElement = 0
+      , Vk.descriptorCount = fromIntegral $ Vector.length linearTextures
+      , Vk.imageInfo       = textureInfos
+      }
+      where
+        textureInfos = do
+          texture <- linearTextures
+          pure Vk.DescriptorImageInfo
+            { sampler     = zero
+            , imageView   = Image.aiImageView $ Texture.tAllocatedImage texture
+            , imageLayout = Vk.IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
+            }
+
+    writeSet0b3 = SomeStruct zero
+      { Vk.dstSet          = destSet0
+      , Vk.dstBinding      = 3
+      , Vk.descriptorType  = Vk.DESCRIPTOR_TYPE_SAMPLED_IMAGE
+      , Vk.dstArrayElement = 0
+      , Vk.descriptorCount = fromIntegral $ Vector.length linearCubes
+      , Vk.imageInfo       = cubeInfos
+      }
+      where
+        cubeInfos = do
+          cube <- linearCubes
+          pure Vk.DescriptorImageInfo
+            { sampler     = zero
+            , imageView   = Image.aiImageView $ Texture.tAllocatedImage cube
+            , imageLayout = Vk.IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
+            }
+
+    writeSet0b4M =
+      case lightsData of
+        Nothing ->
+          mzero
+        Just someLights ->
+          pure $ SomeStruct zero
+            { Vk.dstSet          = destSet0
+            , Vk.dstBinding      = 4
+            , Vk.dstArrayElement = 0
+            , Vk.descriptorCount = 1
+            , Vk.descriptorType  = Vk.DESCRIPTOR_TYPE_UNIFORM_BUFFER
+            , Vk.bufferInfo      = Vector.singleton set0bind4I
+            }
+          where
+            set0bind4I = Vk.DescriptorBufferInfo
+              { Vk.buffer = Buffer.aBuffer someLights
+              , Vk.offset = 0
+              , Vk.range  = Vk.WHOLE_SIZE
+              }
+
+    writeSet0b5 = SomeStruct zero
+      { Vk.dstSet          = destSet0
+      , Vk.dstBinding      = 5
+      , Vk.descriptorType  = Vk.DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER
+      , Vk.dstArrayElement = 0
+      , Vk.descriptorCount = fromIntegral $ Vector.length shadowMaps
+      , Vk.imageInfo       = shadowInfos
+      }
+      where
+        shadowInfos = do
+          (shadowSampler, shadowImageView) <- shadowMaps
+          pure Vk.DescriptorImageInfo
+            { sampler     = shadowSampler
+            , imageView   = shadowImageView
+            , imageLayout = Vk.IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL
+            }
+
+    writeSet0b6M =
+      case materialsData of
+        Nothing ->
+          mzero
+        Just someMaterials ->
+          pure $ SomeStruct zero
+            { Vk.dstSet          = destSet0
+            , Vk.dstBinding      = 6
+            , Vk.dstArrayElement = 0
+            , Vk.descriptorCount = 1
+            , Vk.descriptorType  = Vk.DESCRIPTOR_TYPE_UNIFORM_BUFFER
+            , Vk.bufferInfo      = Vector.singleton set0bind6I
+            }
+          where
+            set0bind6I = Vk.DescriptorBufferInfo
+              { Vk.buffer = Buffer.aBuffer someMaterials
+              , Vk.offset = 0
+              , Vk.range  = Vk.WHOLE_SIZE
+              }
+
+    writeSets = Vector.fromList $ concat
+      [ pure writeSet0b0
+      -- XXX: binding 1 is immutable samplers, baked into layout.
+      , skipEmpty linearTextures writeSet0b2
+      , skipEmpty linearCubes writeSet0b3
+      , writeSet0b4M
+      , skipEmpty shadowMaps writeSet0b5
+      , writeSet0b6M
+      ]
+      where
+        skipEmpty items writer
+          | Vector.null items = mempty
+          | otherwise = pure writer
+
+extendResourceDS
+  :: FrameResource ds
+  -> Tagged ext Vk.DescriptorSet
+  -> FrameResource (Extend ds ext)
+extendResourceDS FrameResource{..} ext = FrameResource
+  { frDescSets = extendDS frDescSets ext
+  , ..
+  }
+
+-- * Frame data
+
+data FrameResource (ds :: [Type]) = FrameResource
+  { frDescSets :: Tagged ds (Vector Vk.DescriptorSet)
+  , frBuffer   :: Buffer
+  , frObserver :: Worker.ObserverIO Scene
+  }
+
+type Buffer = Buffer.Allocated 'Buffer.Coherent Scene
+
+-- | A process that will assemble 'Scene' values.
+type Process = Worker.Merge Scene
+
+observe
+  :: (MonadUnliftIO m) -- TODO: compatible '[Scene]
+  => Process -> FrameResource ds -> m ()
+observe process FrameResource{frBuffer, frObserver}=
+  Worker.observeIO_ process frObserver \_old new -> do
+    _same <- Buffer.updateCoherent (VectorS.singleton new) frBuffer
+    pure new
+
+-- * Rendering
+
+withBoundSet0
+  :: MonadIO m
+  => FrameResource ds
+  -> Pipeline ds vertices instances
+  -> Vk.CommandBuffer
+  -> Bound ds Void Void m b
+  -> m b
+withBoundSet0 FrameResource{frDescSets} refPipeline cb action =
+  withBoundDescriptorSets0
+    cb
+    Vk.PIPELINE_BIND_POINT_GRAPHICS
+    (Pipeline.pLayout refPipeline)
+    frDescSets
+    action
diff --git a/src/Render/DescSets/Set0/Code.hs b/src/Render/DescSets/Set0/Code.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/DescSets/Set0/Code.hs
@@ -0,0 +1,76 @@
+module Render.DescSets.Set0.Code
+  ( set0binding0
+  , set0binding1
+  , set0binding2
+  , set0binding3
+  , set0binding4
+  , set0binding5
+  , set0binding5color
+  , set0binding6
+  ) where
+
+import Render.Code (Code(..), trimming)
+
+set0binding0 :: Code
+set0binding0 = Code
+  [trimming|
+    layout(set=0, binding=0, std140) uniform Globals {
+      mat4 projection;
+      mat4 invProjection;
+      mat4 view;
+      mat4 invView;
+      vec4 viewPosition;
+      vec4 viewDirection;
+      vec4 tweaks;
+      vec4 fog;
+       int envCubeId;
+      uint numLights;
+    } scene;
+  |]
+
+set0binding1 :: Code
+set0binding1 = Code
+  [trimming|
+    layout(set=0, binding=1) uniform sampler samplers[];
+  |]
+
+set0binding2 :: Code
+set0binding2 = Code
+  [trimming|
+    layout(set=0, binding=2) uniform texture2D textures[];
+  |]
+
+set0binding3 :: Code
+set0binding3 = Code
+  [trimming|
+    layout(set=0, binding=3) uniform textureCube cubes[];
+  |]
+
+set0binding4 :: Code
+set0binding4 = Code
+  [trimming|
+    layout(set=0, binding=4, std140) uniform Lights {
+      Light lights[MAX_LIGHTS];
+    };
+  |]
+
+set0binding5 :: Code
+set0binding5 = Code
+  [trimming|
+    layout(set=0, binding=5) uniform sampler2DArrayShadow shadowmaps;
+  |]
+
+-- XXX: for shadowmap mode in Debug pipeline
+set0binding5color :: Code
+set0binding5color = Code
+  [trimming|
+    layout(set=0, binding=5) uniform sampler2DArray shadowmaps;
+  |]
+
+set0binding6 :: Code
+set0binding6 = Code
+  [trimming|
+    layout(set=0, binding=6, std140) uniform Materials {
+      Material materials[MAX_MATERIALS];
+    };
+  |]
diff --git a/src/Render/DescSets/Sun.hs b/src/Render/DescSets/Sun.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/DescSets/Sun.hs
@@ -0,0 +1,165 @@
+{-# OPTIONS_GHC -fplugin Foreign.Storable.Generic.Plugin #-}
+
+module Render.DescSets.Sun
+  ( Sun(..)
+  , createSet0Ds
+  , set0
+
+  , pattern MAX_VIEWS
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Control.Monad.Trans.Resource qualified as ResourceT
+import Data.Tagged (Tagged(..))
+import Data.Vector qualified as Vector
+import Data.Vector.Storable qualified as VectorS
+import Foreign.Storable.Generic (GStorable)
+import Geomancy (Vec4, vec4)
+import Geomancy.Transform (Transform)
+import Vulkan.Core10 qualified as Vk
+import Vulkan.CStruct.Extends (SomeStruct(..))
+import Vulkan.Utils.Debug qualified as Debug
+import Vulkan.Zero (Zero(..))
+
+import Engine.Types (StageRIO)
+import Engine.Vulkan.DescSets ()
+import Engine.Vulkan.Types (DsBindings, HasVulkan(..))
+import Resource.Buffer qualified as Buffer
+import Resource.DescriptorSet qualified as DescriptorSet
+
+-- * Set0 data
+
+-- | Maximum "guaranteed" amount for multiview passes
+pattern MAX_VIEWS :: Int
+pattern MAX_VIEWS = 6
+
+data Sun = Sun
+  { sunViewProjection :: Transform
+  , sunShadow         :: Vec4 -- offsetx, offsety, index, size -- XXX: only index is used
+  , sunPosition       :: Vec4 -- XXX: alpha available for stuff
+  , sunDirection      :: Vec4 -- XXX: alpha available for stuff
+  , sunColor          :: Vec4 -- XXX: RGB premultiplied, alpha is available for stuff
+  }
+  deriving (Show, Generic)
+
+instance GStorable Sun
+
+instance Zero Sun where
+  zero = Sun
+    { sunViewProjection = mempty
+    , sunShadow         = 0
+    , sunPosition       = 0
+    , sunDirection      = vec4 0 1 0 0
+    , sunColor          = 0
+    }
+
+-- * Shadow casting descriptor set
+
+set0
+  :: Tagged Sun DsBindings
+set0 = Tagged
+  [ (set0bind0, zero)
+  ]
+
+set0bind0 :: Vk.DescriptorSetLayoutBinding
+set0bind0 = Vk.DescriptorSetLayoutBinding
+  { binding           = 0
+  , descriptorType    = Vk.DESCRIPTOR_TYPE_UNIFORM_BUFFER
+  , descriptorCount   = 1
+  , stageFlags        = Vk.SHADER_STAGE_VERTEX_BIT
+  , immutableSamplers = mempty
+  }
+
+-- * Setup
+
+createSet0Ds
+  :: Tagged '[Sun] Vk.DescriptorSetLayout
+  -> ResourceT (StageRIO st)
+      ( Tagged '[Sun] (Vector Vk.DescriptorSet)
+      , Buffer.Allocated 'Buffer.Coherent Sun
+      )
+createSet0Ds (Tagged set0layout) = do
+  context <- asks id
+
+  (_dpKey, descPool) <- DescriptorSet.allocatePool 1 dpSizes
+
+  let
+    set0dsCI = zero
+      { Vk.descriptorPool = descPool
+      , Vk.setLayouts     = Vector.singleton set0layout
+      }
+  descSets <- fmap (Tagged @'[Sun]) $
+    Vk.allocateDescriptorSets (getDevice context) set0dsCI
+
+  let
+    initialSuns = VectorS.replicate MAX_VIEWS zero
+  (_, sunData) <- ResourceT.allocate
+    (Buffer.createCoherent context Vk.BUFFER_USAGE_UNIFORM_BUFFER_BIT MAX_VIEWS initialSuns)
+    (Buffer.destroyAll context . Just)
+
+  updateSet0Ds descSets sunData
+
+  let device = getDevice context
+  Debug.nameObject device descPool "Sun.Pool"
+  for_ (unTagged descSets) \ds ->
+    Debug.nameObject device ds "Sun.DS"
+  Debug.nameObject device (Buffer.aBuffer sunData) "Sun.Data"
+
+  pure (descSets, sunData)
+
+dpSizes :: DescriptorSet.TypeMap Word32
+dpSizes =
+  [ ( Vk.DESCRIPTOR_TYPE_UNIFORM_BUFFER
+    , uniformBuffers
+    )
+  -- XXX: may be required to fetch textures for shadows from texture-masked models
+  -- , ( Vk.DESCRIPTOR_TYPE_SAMPLED_IMAGE
+  --   , sampledImages
+  --   )
+  -- , ( Vk.DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER
+  --   , sampledImages + shadowMaps
+  --   )
+  -- , ( Vk.DESCRIPTOR_TYPE_SAMPLER
+  --   , staticSamplers
+  --   )
+  ]
+  where
+    uniformBuffers = 2    -- 1 scene + 1 light array
+    -- sampledImages  = 128  -- max dynamic textures and cubemaps
+    -- staticSamplers = 8    -- immutable samplers
+    -- shadowMaps     = 2    -- max shadowmaps
+
+updateSet0Ds
+  :: Tagged '[Sun] (Vector Vk.DescriptorSet)
+  -> Buffer.Allocated 'Buffer.Coherent Sun
+  -> ResourceT (StageRIO st) ()
+updateSet0Ds (Tagged ds) Buffer.Allocated{aBuffer} = do
+  context <- asks id
+  Vk.updateDescriptorSets (getDevice context) writeSets mempty
+
+  where
+    destSet0 = case Vector.headM ds of
+      Nothing ->
+        error "assert: descriptor sets promised to contain [Sun]"
+      Just one ->
+        one
+
+    writeSet0b0 = SomeStruct zero
+      { Vk.dstSet          = destSet0
+      , Vk.dstBinding      = 0
+      , Vk.dstArrayElement = 0
+      , Vk.descriptorCount = 1
+      , Vk.descriptorType  = Vk.DESCRIPTOR_TYPE_UNIFORM_BUFFER
+      , Vk.bufferInfo      = Vector.singleton set0bind0I
+      }
+      where
+        set0bind0I = Vk.DescriptorBufferInfo
+          { Vk.buffer = aBuffer
+          , Vk.offset = 0
+          , Vk.range  = Vk.WHOLE_SIZE
+          }
+
+    writeSets =
+      Vector.singleton writeSet0b0
diff --git a/src/Render/Font/EvanwSdf/Model.hs b/src/Render/Font/EvanwSdf/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Font/EvanwSdf/Model.hs
@@ -0,0 +1,75 @@
+module Render.Font.EvanwSdf.Model
+  ( Model
+  , VertexAttrs
+  , InstanceAttrs(..)
+  , vkInstanceAttrs
+  , InstanceBuffer
+  ) where
+
+import RIO
+
+import Foreign (Storable(..))
+import Geomancy (Vec2, Vec4)
+import Geomancy.Vec3 qualified as Vec3
+import Vulkan.Core10 qualified as Vk
+import Vulkan.NamedType ((:::))
+
+import Resource.Buffer qualified as Buffer
+import Resource.Model qualified as Model
+
+type Model buf = Model.Indexed buf Vec3.Packed VertexAttrs
+
+type VertexAttrs = "uv" ::: Vec2
+
+data InstanceAttrs = InstanceAttrs
+  { vertRect     :: Vec4
+  , fragRect     :: Vec4
+  , color        :: Vec4
+  , outlineColor :: Vec4
+
+  , samplerId    :: Int32
+  , textureId    :: Int32
+
+  , smoothing    :: Float
+  , outlineWidth :: Float
+  }
+  deriving (Eq, Show)
+
+vkInstanceAttrs :: [Vk.Format]
+vkInstanceAttrs =
+  [ Vk.FORMAT_R32G32B32A32_SFLOAT -- Quad scale+offset
+  , Vk.FORMAT_R32G32B32A32_SFLOAT -- UV scale+offset
+  , Vk.FORMAT_R32G32B32A32_SFLOAT -- Color
+  , Vk.FORMAT_R32G32B32A32_SFLOAT -- Outline color
+
+  , Vk.FORMAT_R32G32_SINT         -- Sampler + texture IDs
+
+  , Vk.FORMAT_R32G32_SFLOAT       -- Smoothing + outline width
+  ]
+
+instance Storable InstanceAttrs where
+  alignment ~_ = 4
+  sizeOf ~_ = 16 + 16 + 16 + 16 + 4 + 4 + 4 + 4
+
+  peek ptr = do
+    vertRect     <- peekByteOff ptr  0 -- +16
+    fragRect     <- peekByteOff ptr 16 -- +16
+    color        <- peekByteOff ptr 32 -- +16
+    outlineColor <- peekByteOff ptr 48 -- +16
+    samplerId    <- peekByteOff ptr 64 -- +4
+    textureId    <- peekByteOff ptr 68 -- +4
+    smoothing    <- peekByteOff ptr 72 -- +4
+    outlineWidth <- peekByteOff ptr 76 -- +4
+    pure InstanceAttrs{..}
+
+  poke ptr InstanceAttrs{..} = do
+    pokeByteOff ptr  0 vertRect
+    pokeByteOff ptr 16 fragRect
+    pokeByteOff ptr 32 color
+    pokeByteOff ptr 48 outlineColor
+    pokeByteOff ptr 64 samplerId
+    pokeByteOff ptr 68 textureId
+    pokeByteOff ptr 72 smoothing
+    pokeByteOff ptr 76 outlineWidth
+
+type InstanceBuffer stage = Buffer.Allocated stage InstanceAttrs
diff --git a/src/Render/Font/EvanwSdf/Pipeline.hs b/src/Render/Font/EvanwSdf/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Font/EvanwSdf/Pipeline.hs
@@ -0,0 +1,157 @@
+{-# LANGUAGE OverloadedLists #-}
+
+module Render.Font.EvanwSdf.Pipeline
+  ( Pipeline
+  , allocate
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Zero (Zero(..))
+
+import Engine.Vulkan.Pipeline (Config(..))
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
+import Render.Code (compileVert, compileFrag, glsl)
+import Render.DescSets.Set0 (Scene)
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2)
+import Render.Font.EvanwSdf.Model qualified as Model
+
+type Pipeline = Pipeline.Pipeline '[Scene] () Model.InstanceAttrs
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocate multisample (Tagged set0) = do
+  fmap snd . Pipeline.allocate
+    Nothing
+    multisample
+    zero
+      { cVertexCode         = Just vertCode
+      , cFragmentCode       = Just fragCode
+      , cDescLayouts        = Tagged @'[Scene] [set0]
+      , cVertexInput        = vertexInput
+      , cDepthTest          = False
+      , cDepthWrite         = False
+      , cBlend              = True
+      , cCull               = Vk.CULL_MODE_NONE
+      }
+  where
+    vertexInput = Pipeline.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;
+
+      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/ForwardMsaa.hs b/src/Render/ForwardMsaa.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/ForwardMsaa.hs
@@ -0,0 +1,287 @@
+module Render.ForwardMsaa where
+
+import RIO
+
+import Control.Monad.Trans.Resource qualified as Resource
+import Data.Bits ((.|.))
+import Data.Vector qualified as Vector
+import RIO.Vector.Partial ((!))
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Utils.Debug qualified as Debug
+import Vulkan.Zero (zero)
+
+import Engine.Types.RefCounted (RefCounted, newRefCounted, releaseRefCounted, resourceTRefCount)
+import Engine.Vulkan.Types (HasVulkan(..), HasSwapchain(..), HasRenderPass(..), RenderPass(..), MonadVulkan)
+import Resource.Image (AllocatedImage)
+import Resource.Image qualified as Image
+
+-- * Simple MSAA-enabled pass
+
+data ForwardMsaa = ForwardMsaa
+  { fmRenderPass   :: Vk.RenderPass
+  , fmColor        :: AllocatedImage
+  , fmDepth        :: AllocatedImage
+  , fmFrameBuffers :: Vector Vk.Framebuffer
+  , fmRenderArea   :: Vk.Rect2D
+  , fmClear        :: Vector Vk.ClearValue
+  , fmRelease      :: RefCounted
+  }
+
+instance HasRenderPass ForwardMsaa where
+  getRenderPass          = fmRenderPass
+  getFramebuffers        = fmFrameBuffers
+  getClearValues         = fmClear
+  getRenderArea          = fmRenderArea
+
+instance RenderPass ForwardMsaa where
+  allocateRenderpass_ = allocateMsaa -- error "Thou shalt allocateMsaa instead"
+
+  updateRenderpass = updateMsaa
+
+  refcountRenderpass = resourceTRefCount . fmRelease
+
+allocateMsaa
+  :: ( Resource.MonadResource m
+     , MonadVulkan env m
+     , HasLogFunc env
+     , HasSwapchain context
+     )
+  => context
+  -> m ForwardMsaa
+allocateMsaa context = do
+  logDebug "Allocating ForwardMsaa resources"
+  (_rpKey, renderPass) <- allocateRenderPassMsaa context
+  (refcounted, color, depth, framebuffers) <- allocateFramebufferMsaa context renderPass
+
+  pure ForwardMsaa
+    { fmRenderPass   = renderPass
+    , fmRenderArea   = fullSurface
+    , fmClear        = clear
+    , fmColor        = color
+    , fmDepth        = depth
+    , fmFrameBuffers = framebuffers
+    , fmRelease      = refcounted
+    }
+  where
+    fullSurface = Vk.Rect2D
+      { Vk.offset = zero
+      , Vk.extent = getSurfaceExtent context
+      }
+    clear = Vector.fromList
+      [ clearColor
+      , Vk.DepthStencil (Vk.ClearDepthStencilValue 1.0 0)
+      , clearColor
+      ]
+    clearColor = Vk.Color zero
+
+updateMsaa
+  :: ( Resource.MonadResource m
+     , MonadVulkan env m
+     , HasLogFunc env
+     , HasSwapchain context
+     )
+  => context
+  -> ForwardMsaa
+  -> m ForwardMsaa
+updateMsaa context old@ForwardMsaa{fmRelease, fmRenderPass} = do
+  releaseRefCounted fmRelease
+  (refcounted, color, depth, framebuffers) <- allocateFramebufferMsaa context fmRenderPass
+  pure old
+    { fmColor        = color
+    , fmDepth        = depth
+    , fmFrameBuffers = framebuffers
+    , fmRelease      = refcounted
+    , fmRenderArea   = fullSurface
+    }
+  where
+    fullSurface = Vk.Rect2D
+      { Vk.offset = zero
+      , Vk.extent = getSurfaceExtent context
+      }
+
+-- ** Render pass
+
+allocateRenderPassMsaa
+  :: ( MonadVulkan env m
+     , Resource.MonadResource m
+     , HasSwapchain context
+     )
+  => context
+  -> m (Resource.ReleaseKey, Vk.RenderPass)
+allocateRenderPassMsaa context = do
+  device <- asks getDevice
+  let
+    format = getSurfaceFormat context
+    depthFormat = getDepthFormat context
+    msaa = getMultisample context
+
+    attachments =
+      [ color format msaa
+      , depth depthFormat msaa
+      , colorResolve format
+      ]
+
+  res@(_key, object) <- Vk.withRenderPass device (createInfo attachments) Nothing Resource.allocate
+  Debug.nameObject device object "ForwardMSAA"
+  pure res
+  where
+    createInfo attachments = zero
+      { Vk.attachments  = Vector.fromList attachments
+      , Vk.subpasses    = Vector.fromList [subpass]
+      , Vk.dependencies = Vector.fromList [colorDeps, depthDeps]
+      }
+
+    color format msaa = zero
+      { Vk.format         = format
+      , Vk.samples        = msaa
+      , Vk.finalLayout    = Vk.IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
+      , Vk.loadOp         = Vk.ATTACHMENT_LOAD_OP_CLEAR
+      , Vk.storeOp        = Vk.ATTACHMENT_STORE_OP_DONT_CARE
+      , Vk.stencilLoadOp  = Vk.ATTACHMENT_LOAD_OP_DONT_CARE
+      , Vk.stencilStoreOp = Vk.ATTACHMENT_STORE_OP_DONT_CARE
+      , Vk.initialLayout  = Vk.IMAGE_LAYOUT_UNDEFINED
+      }
+
+    depth format msaa = zero
+      { Vk.format         = format
+      , Vk.samples        = msaa
+      , Vk.loadOp         = Vk.ATTACHMENT_LOAD_OP_CLEAR
+      , Vk.storeOp        = Vk.ATTACHMENT_STORE_OP_DONT_CARE
+      , Vk.stencilLoadOp  = Vk.ATTACHMENT_LOAD_OP_DONT_CARE
+      , Vk.stencilStoreOp = Vk.ATTACHMENT_STORE_OP_DONT_CARE
+      , Vk.initialLayout  = Vk.IMAGE_LAYOUT_UNDEFINED
+      , Vk.finalLayout    = Vk.IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL
+      }
+
+    colorResolve format = zero
+      { Vk.format      = format
+      , Vk.samples     = Vk.SAMPLE_COUNT_1_BIT
+      , Vk.finalLayout = Vk.IMAGE_LAYOUT_PRESENT_SRC_KHR
+      , Vk.loadOp      = Vk.ATTACHMENT_LOAD_OP_DONT_CARE
+      }
+
+    subpass = zero
+      { Vk.pipelineBindPoint = Vk.PIPELINE_BIND_POINT_GRAPHICS
+      , Vk.colorAttachments = Vector.singleton zero
+          { Vk.attachment = 0
+          , Vk.layout     = Vk.IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
+          }
+      , Vk.depthStencilAttachment = Just zero
+          { Vk.attachment = 1
+          , Vk.layout     = Vk.IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL
+          }
+      , Vk.resolveAttachments = Vector.singleton zero
+          { Vk.attachment = 2
+          , Vk.layout     = Vk.IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
+          }
+      }
+
+    colorDeps = zero
+      { Vk.srcSubpass    = Vk.SUBPASS_EXTERNAL
+      , Vk.dstSubpass    = 0
+      , Vk.srcStageMask  = colorOut
+      , Vk.srcAccessMask = zero
+      , Vk.dstStageMask  = colorOut
+      , Vk.dstAccessMask = colorRW
+      }
+      where
+        colorOut =
+          Vk.PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT
+
+        colorRW =
+          Vk.ACCESS_COLOR_ATTACHMENT_READ_BIT .|.
+          Vk.ACCESS_COLOR_ATTACHMENT_WRITE_BIT
+
+    depthDeps = zero
+      { Vk.srcSubpass    = Vk.SUBPASS_EXTERNAL
+      , Vk.dstSubpass    = 0
+      , Vk.srcStageMask  = fragTests
+      , Vk.srcAccessMask = depthW
+      , Vk.dstStageMask  = fragTests
+      , Vk.dstAccessMask = depthRW
+      }
+      where
+        fragTests =
+          Vk.PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT .|.
+          Vk.PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT
+
+        depthW =
+          Vk.ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT
+
+        depthRW =
+          Vk.ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT .|.
+          depthW
+
+-- ** Framebuffer
+
+type FramebuffersMsaa =
+  ( RefCounted
+  , Image.AllocatedImage
+  , Image.AllocatedImage
+  , Vector Vk.Framebuffer
+  )
+
+allocateFramebufferMsaa
+  :: ( Resource.MonadResource m
+     , MonadVulkan env m
+     , HasLogFunc env
+     , HasSwapchain context
+     )
+  => context
+  -> Vk.RenderPass
+  -> m FramebuffersMsaa
+allocateFramebufferMsaa context renderPass = do
+  device <- asks getDevice
+  context' <- asks (, context)
+  let extent@Vk.Extent2D{width, height} = getSurfaceExtent context
+
+  (colorKey, color) <- Resource.allocate
+    (Image.createColorResource context' extent)
+    (Image.destroyAllocatedImage context')
+
+  (depthKey, depth) <- Resource.allocate
+    (Image.createDepthResource context' extent Nothing)
+    (Image.destroyAllocatedImage context')
+
+  perView <- for (getSwapchainViews context) \colorResolve -> do
+    let
+      attachments = Vector.fromList
+        [ Image.aiImageView color
+        , Image.aiImageView depth
+        , colorResolve
+        ]
+
+      fbCI = zero
+        { Vk.renderPass  = renderPass
+        , Vk.width       = width
+        , Vk.height      = height
+        , Vk.attachments = attachments
+        , Vk.layers      = 1
+        }
+
+    Vk.withFramebuffer device fbCI Nothing Resource.allocate
+
+  let (framebufferKeys, framebuffers) = Vector.unzip perView
+  releaseDebug <- toIO $ logDebug "Releasing ForwardMsaa resources"
+  release <- newRefCounted do
+    releaseDebug
+    Resource.release colorKey
+    Resource.release depthKey
+    traverse_ Resource.release framebufferKeys
+
+  pure (release, color, depth, framebuffers)
+
+usePass :: (MonadIO io, HasRenderPass a) => a -> Word32 -> Vk.CommandBuffer -> io r -> io r
+usePass render imageIndex cb =
+  Vk.cmdUseRenderPass
+    cb
+    (beginInfo render imageIndex)
+    Vk.SUBPASS_CONTENTS_INLINE
+
+beginInfo :: HasRenderPass a => a -> Word32 -> Vk.RenderPassBeginInfo '[]
+beginInfo rp imageIndex = zero
+  { Vk.renderPass  = getRenderPass rp
+  , Vk.framebuffer = getFramebuffers rp ! fromIntegral imageIndex
+  , Vk.renderArea  = getRenderArea rp
+  , Vk.clearValues = getClearValues rp
+  }
diff --git a/src/Render/Lit/Colored/Model.hs b/src/Render/Lit/Colored/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Colored/Model.hs
@@ -0,0 +1,54 @@
+module Render.Lit.Colored.Model
+  ( Model
+
+  , VertexAttrs(..)
+  , vkVertexAttrs
+
+  , InstanceAttrs
+  ) where
+
+import RIO
+
+import Foreign (Storable(..))
+import Geomancy (Transform, Vec2, Vec4)
+import Geomancy.Vec3 qualified as Vec3
+import Resource.Model qualified as Model
+import Vulkan.Core10 qualified as Vk
+
+type Model buf = Model.Indexed buf Vec3.Packed VertexAttrs
+
+data VertexAttrs = VertexAttrs
+  { vaBaseColor         :: Vec4
+  , vaEmissiveColor     :: Vec4 -- XXX: a: alpha cutoff
+  , vaMetallicRoughness :: Vec2
+  , vaNormal            :: Vec3.Packed
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance Storable VertexAttrs where
+  alignment ~_ = 4
+
+  sizeOf ~_ = 16 + 16 + 8 + 12
+
+  peek ptr = do
+    vaBaseColor         <- peekByteOff ptr  0
+    vaEmissiveColor     <- peekByteOff ptr 16
+    vaMetallicRoughness <- peekByteOff ptr 32
+    vaNormal            <- peekByteOff ptr 40
+    pure VertexAttrs{..}
+
+  poke ptr VertexAttrs{..} = do
+    pokeByteOff ptr  0 vaBaseColor
+    pokeByteOff ptr 16 vaEmissiveColor
+    pokeByteOff ptr 32 vaMetallicRoughness
+    pokeByteOff ptr 40 vaNormal
+
+type InstanceAttrs = Transform
+
+vkVertexAttrs :: [Vk.Format]
+vkVertexAttrs =
+  [ Vk.FORMAT_R32G32B32A32_SFLOAT -- vBaseColor         :: vec4
+  , Vk.FORMAT_R32G32B32A32_SFLOAT -- vEmissiveColor     :: vec4
+  , Vk.FORMAT_R32G32_SFLOAT       -- vMetallicRoughness :: vec2
+  , Vk.FORMAT_R32G32B32_SFLOAT    -- vNormal            :: vec3
+  ]
diff --git a/src/Render/Lit/Colored/Pipeline.hs b/src/Render/Lit/Colored/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Colored/Pipeline.hs
@@ -0,0 +1,162 @@
+module Render.Lit.Colored.Pipeline
+  ( Pipeline
+  , allocate
+  , allocateBlend
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Zero (zero)
+
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
+import Render.Code (compileVert, compileFrag, glsl)
+import Render.Code.Lit (litMain, structLight, shadowFuns, brdfSpecular)
+import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding2, set0binding3, set0binding4, set0binding5)
+import Render.Lit.Colored.Model qualified as Model
+
+type Config = Pipeline.Config '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocate multisample tset0 rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (config tset0)
+    rp
+  pure p
+
+allocateBlend
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocateBlend multisample tset0 rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (configBlend tset0)
+    rp
+  pure p
+
+config :: Tagged Scene DsBindings -> Config
+config (Tagged set0) = zero
+  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Pipeline.cVertexCode   = Just vertCode
+  , Pipeline.cVertexInput  = vertexInput
+  , Pipeline.cFragmentCode = Just fragCode
+  }
+  where
+    vertexInput = Pipeline.vertexInput
+      [ vertexPos
+      , (Vk.VERTEX_INPUT_RATE_VERTEX, Model.vkVertexAttrs)
+      , instanceTransform
+      ]
+
+configBlend :: Tagged Scene DsBindings -> Config
+configBlend tset0 = (config tset0)
+  { Pipeline.cBlend = True
+  }
+
+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 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
+
+    // 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 occlusion = 1.0 - 0.0;
+
+      vec3 normal = normalize(fNormal);
+
+      ${litMain}
+
+      oColor.rgb += pow(fEmissiveColor.rgb, vec3(2.2));
+    }
+  |])
diff --git a/src/Render/Lit/Material.hs b/src/Render/Lit/Material.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Material.hs
@@ -0,0 +1,74 @@
+{-# OPTIONS_GHC -fplugin Foreign.Storable.Generic.Plugin #-}
+
+module Render.Lit.Material
+  ( Material(..)
+  , shiftTextures
+  ) where
+
+import RIO
+
+import Foreign.Storable.Generic (GStorable)
+import Geomancy (Vec2, Vec4, vec2, vec4)
+import Vulkan.Zero (Zero(..))
+
+data Material = Material
+  { mBaseColor         :: Vec4
+  , mMetallicRoughness :: Vec2
+  , mEmissive          :: Vec4
+  , mNormalScale       :: Float
+  , mAlphaCutoff       :: Float
+
+  , mBaseColorTex         :: Int32
+  , mMetallicRoughnessTex :: Int32
+  , mEmissiveTex          :: Int32
+  , mNormalTex            :: Int32
+  , mAmbientOcclusionTex  :: Int32
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance GStorable Material
+
+instance Zero Material where
+  zero = Material
+    { mBaseColor         = vec4 1.0 1.0 1.0 1.0
+    , mMetallicRoughness = vec2 0 0.6
+    , mEmissive          = vec4 0 0 0 0
+    , mNormalScale       = 1.0
+    , mAlphaCutoff       = 0.5
+
+    , mBaseColorTex         = -1
+    , mMetallicRoughnessTex = -1
+    , mNormalTex            = -1
+    , mEmissiveTex          = -1
+    , mAmbientOcclusionTex  = -1
+    }
+
+shiftTextures :: Int32 -> Material -> Material
+shiftTextures offset Material{..} = Material
+  { mBaseColorTex =
+      if mBaseColorTex > -1 then
+        mBaseColorTex + offset
+      else
+        mBaseColorTex
+  , mMetallicRoughnessTex =
+      if mMetallicRoughnessTex > -1 then
+        mMetallicRoughnessTex + offset
+      else
+        mMetallicRoughnessTex
+  , mEmissiveTex =
+      if mEmissiveTex > -1 then
+        mEmissiveTex + offset
+      else
+        mEmissiveTex
+  , mNormalTex =
+      if mNormalTex > -1 then
+        mNormalTex + offset
+      else
+        mNormalTex
+  , mAmbientOcclusionTex =
+      if mAmbientOcclusionTex > -1 then
+        mAmbientOcclusionTex + offset
+      else
+        mAmbientOcclusionTex
+  , ..
+  }
diff --git a/src/Render/Lit/Material/Collect.hs b/src/Render/Lit/Material/Collect.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Material/Collect.hs
@@ -0,0 +1,81 @@
+module Render.Lit.Material.Collect
+  ( LoadedModel
+  , SceneModel(..)
+  , sceneMaterials
+  , modelMaterials
+  , nodeMaterials
+  ) where
+
+import RIO
+
+import RIO.Map qualified as Map
+import RIO.Vector.Storable qualified as Storable
+
+import Render.Lit.Material (shiftTextures)
+import Render.Lit.Material.Model qualified as LitMaterial
+import Resource.Buffer qualified as Buffer
+import Resource.Mesh.Types qualified as Mesh
+import Resource.Mesh.Lit qualified as Lit
+import Render.Lit.Material (Material)
+
+type LoadedModel =
+  ( Mesh.Meta
+  , Storable.Vector Lit.MaterialNode
+  , LitMaterial.Model 'Buffer.Staged
+  )
+
+data SceneModel models textures = SceneModel
+  { smLabel            :: Text
+  , smGetModel         :: models -> LoadedModel
+  , smGetTextureOffset :: textures -> Int32
+  }
+
+sceneMaterials
+  :: Foldable t
+  => models
+  -> textures
+  -> t (SceneModel models textures)
+  -> Storable.Vector Material
+sceneMaterials loadedModels combinedTextures =
+  Storable.fromList . Map.elems . foldl' beep mempty
+  where
+    beep acc SceneModel{..} =
+      let
+        (_meta, materialNodes, _model) = smGetModel loadedModels
+        textureOffset = smGetTextureOffset combinedTextures
+      in
+        modelMaterials smLabel textureOffset acc (Storable.toList materialNodes)
+
+modelMaterials
+  :: Foldable t
+  => Text
+  -> Int32
+  -> Map Int Material
+  -> t Lit.MaterialNode
+  -> Map Int Material
+modelMaterials label textureOffset =
+  foldr (nodeMaterials label textureOffset)
+
+nodeMaterials
+  :: Text
+  -> Int32
+  -> Lit.MaterialNode
+  -> Map Int Material
+  -> Map Int Material
+nodeMaterials label textureStart Lit.MaterialNode{mnMaterialIx, mnMaterial} collection =
+  case Map.lookup mnMaterialIx collection of
+    Nothing ->
+      Map.insert mnMaterialIx newMaterial collection
+    Just oldMaterial ->
+      if newMaterial /= oldMaterial then
+        error $ unlines
+          [ "Ouf... The material indices are clashing for " <> show label
+          , "At: " <> show mnMaterialIx
+          , "Old: " <> show oldMaterial
+          , "Now: " <> show mnMaterial
+          ]
+      else
+        collection
+  where
+    newMaterial =
+      shiftTextures textureStart mnMaterial
diff --git a/src/Render/Lit/Material/Model.hs b/src/Render/Lit/Material/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Material/Model.hs
@@ -0,0 +1,69 @@
+{-# OPTIONS_GHC -fplugin Foreign.Storable.Generic.Plugin #-}
+
+module Render.Lit.Material.Model
+  ( Model
+  , VertexAttrs(..)
+  , vkVertexAttrs
+
+  , InstanceAttrs
+  -- , InstanceBuffers(..)
+
+  -- , TextureParams(..)
+  -- , vkInstanceTexture
+
+  -- , allocateInstancesWith
+  , Transform
+  , Material
+  ) where
+
+import RIO
+
+import Foreign (Storable(..))
+import Geomancy (Transform, Vec2)
+import Geomancy.Vec3 qualified as Vec3
+import Vulkan.Core10 qualified as Vk
+
+import Resource.Model qualified as Model
+import Render.Lit.Material (Material)
+
+type Model buf = Model.Indexed buf Vec3.Packed VertexAttrs
+
+data VertexAttrs = VertexAttrs
+  { vaTexCoord0 :: Vec2
+  , vaTexCoord1 :: Vec2
+  , vaNormal    :: Vec3.Packed
+  , vaTangent   :: Vec3.Packed
+  , vaMaterial  :: Word32
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance Storable VertexAttrs where
+  alignment ~_ = 4
+
+  sizeOf ~_ = 8 + 8 + 12 + 12 + 4
+
+  peek ptr = do
+    vaTexCoord0 <- peekByteOff ptr 0
+    vaTexCoord1 <- peekByteOff ptr 8
+    vaNormal    <- peekByteOff ptr 16
+    vaTangent   <- peekByteOff ptr 28
+    vaMaterial  <- peekByteOff ptr 40
+    pure VertexAttrs{..}
+
+  poke ptr VertexAttrs{..} = do
+    pokeByteOff ptr  0 vaTexCoord0
+    pokeByteOff ptr  8 vaTexCoord1
+    pokeByteOff ptr 16 vaNormal
+    pokeByteOff ptr 28 vaTangent
+    pokeByteOff ptr 40 vaMaterial
+
+vkVertexAttrs :: [Vk.Format]
+vkVertexAttrs =
+  [ Vk.FORMAT_R32G32_SFLOAT    -- vTexCoord0 :: vec2
+  , Vk.FORMAT_R32G32_SFLOAT    -- vTexCoord1 :: vec2
+  , Vk.FORMAT_R32G32B32_SFLOAT -- vNormal    :: vec3
+  , Vk.FORMAT_R32G32B32_SFLOAT -- vTangent   :: vec3
+  , Vk.FORMAT_R32_UINT         -- vMaterial  :: uint
+  ]
+
+type InstanceAttrs = Transform
diff --git a/src/Render/Lit/Material/Pipeline.hs b/src/Render/Lit/Material/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Material/Pipeline.hs
@@ -0,0 +1,237 @@
+module Render.Lit.Material.Pipeline
+  ( Pipeline
+  , allocate
+  , allocateBlend
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Zero (zero)
+
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
+import Render.Code (compileVert, compileFrag, glsl)
+import Render.DescSets.Set0 (Scene, 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.Model qualified as Model
+
+type Config = Pipeline.Config '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocate multisample tset0 rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (config tset0)
+    rp
+  pure p
+
+allocateBlend
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocateBlend multisample tset0 rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (configBlend tset0)
+    rp
+  pure p
+
+config :: Tagged Scene DsBindings -> Config
+config (Tagged set0) = zero
+  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Pipeline.cVertexCode   = Just vertCode
+  , Pipeline.cVertexInput  = vertexInput
+  , Pipeline.cFragmentCode = Just fragCode
+  }
+  where
+    vertexInput = Pipeline.vertexInput
+      [ vertexPos -- vPosition
+      , (Vk.VERTEX_INPUT_RATE_VERTEX, Model.vkVertexAttrs)
+      , instanceTransform
+      ]
+
+configBlend :: Tagged Scene DsBindings -> Config
+configBlend tset0 = (config tset0)
+  { Pipeline.cBlend      = True
+  , Pipeline.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 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
+
+    // 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 occlusion = 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;
+        packed = pow(packed, vec3(1.0/2.2));
+        occlusion = packed.r;
+        metallic *= packed.b;
+        roughness *= packed.g;
+      }
+
+      // // TODO: combine with MR as channel R.
+      // occlusion = texture(
+      //   sampler2D(
+      //     textures[nonuniformEXT(max(0, material.ambientOcclusionTex))],
+      //     samplers[0]
+      //   ),
+      //   fTexCoord0
+      // ).r;
+      // // occlusion = 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/Textured/Model.hs b/src/Render/Lit/Textured/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Textured/Model.hs
@@ -0,0 +1,162 @@
+module Render.Lit.Textured.Model
+  ( Model
+  , VertexAttrs(..)
+  , vkVertexAttrs
+
+  , InstanceAttrs(..)
+  , InstanceBuffers(..)
+
+  , TextureParams(..)
+  , vkInstanceTexture
+
+  , allocateInstancesWith
+  , Transform
+  ) where
+
+import RIO
+
+
+import Foreign (Storable(..))
+import Geomancy (Transform, Vec2, Vec4)
+import Geomancy.Vec3 qualified as Vec3
+import RIO.Vector.Storable qualified as VectorS
+import UnliftIO.Resource (MonadResource, ReleaseKey, allocate)
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Zero (Zero(..))
+
+import Resource.Buffer qualified as Buffer
+import Resource.Model qualified as Model
+
+type Model buf = Model.Indexed buf Vec3.Packed VertexAttrs
+
+data VertexAttrs = VertexAttrs
+  { vaTexCoord :: Vec2
+  , vaNormal   :: Vec3.Packed
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance Storable VertexAttrs where
+  alignment ~_ = 4
+
+  sizeOf ~_ = 8 + 12
+
+  peek ptr = do
+    vaTexCoord <- peekByteOff ptr 0
+    vaNormal   <- peekByteOff ptr 8
+    pure VertexAttrs{..}
+
+  poke ptr VertexAttrs{..} = do
+    pokeByteOff ptr 0 vaTexCoord
+    pokeByteOff ptr 8 vaNormal
+
+vkVertexAttrs :: [Vk.Format]
+vkVertexAttrs =
+  [ Vk.FORMAT_R32G32_SFLOAT    -- vTexCoord :: vec2
+  , Vk.FORMAT_R32G32B32_SFLOAT -- vNormal   :: vec3
+  ]
+
+data InstanceAttrs = InstanceAttrs
+  { textureParams :: TextureParams
+  , transformMat4 :: Transform
+  }
+
+instance Zero InstanceAttrs where
+  zero = InstanceAttrs
+    { textureParams = zero
+    , transformMat4 = mempty
+    }
+
+data InstanceBuffers textureStage transformStage = InstanceBuffers
+  { ibTexture   :: InstanceTexture textureStage
+  , ibTransform :: InstanceTransform transformStage
+  }
+
+type InstanceTexture stage = Buffer.Allocated stage TextureParams
+
+type InstanceTransform stage = Buffer.Allocated stage Transform
+
+instance Model.HasVertexBuffers (InstanceBuffers textureStage transformStage) where
+  type VertexBuffersOf (InstanceBuffers textureStage transformStage) = InstanceAttrs
+
+  {-# INLINE getVertexBuffers #-}
+  getVertexBuffers InstanceBuffers{..} =
+    [ Buffer.aBuffer ibTexture
+    , Buffer.aBuffer ibTransform
+    ]
+
+  {-# INLINE getInstanceCount #-}
+  getInstanceCount InstanceBuffers{..} =
+    min
+      (Buffer.aUsed ibTexture)
+      (Buffer.aUsed ibTransform)
+
+data TextureParams = TextureParams
+  { tpScale     :: Vec2
+  , tpOffset    :: Vec2
+  , tpGamma     :: Vec4
+  , tpSamplerId :: Int32
+  , tpTextureId :: Int32
+  }
+  deriving (Show)
+
+instance Zero TextureParams where
+  zero = TextureParams
+    { tpScale     = 1
+    , tpOffset    = 0
+    , tpGamma     = 1.0
+    , tpSamplerId = minBound
+    , tpTextureId = minBound
+    }
+
+instance Storable TextureParams where
+  alignment ~_ = 8
+
+  sizeOf ~_ = 8 + 8 + 16 + 4 + 4
+
+  poke ptr TextureParams{..} = do
+    pokeByteOff ptr  0 tpScale
+    pokeByteOff ptr  8 tpOffset
+    pokeByteOff ptr 16 tpGamma
+    pokeByteOff ptr 32 tpSamplerId
+    pokeByteOff ptr 36 tpTextureId
+
+  peek ptr = do
+    tpScale     <- peekByteOff ptr  0
+    tpOffset    <- peekByteOff ptr  8
+    tpGamma     <- peekByteOff ptr 16
+    tpSamplerId <- peekByteOff ptr 32
+    tpTextureId <- peekByteOff ptr 36
+    pure TextureParams{..}
+
+vkInstanceTexture :: [Vk.Format]
+vkInstanceTexture =
+  [ Vk.FORMAT_R32G32B32A32_SFLOAT -- iTextureScaleOffset :: vec4
+  , Vk.FORMAT_R32G32B32A32_SFLOAT -- iTextureGamma       :: vec4
+  , Vk.FORMAT_R32G32_SINT         -- iTextureIds         :: ivec2
+  ]
+
+allocateInstancesWith
+  :: ( MonadResource m
+     , MonadUnliftIO m
+     )
+  => (Vk.BufferUsageFlagBits -> Int -> VectorS.Vector TextureParams -> m (InstanceTexture texture))
+  -> (Vk.BufferUsageFlagBits -> Int -> VectorS.Vector Transform -> m (InstanceTransform transform))
+  -> (forall stage a . Buffer.Allocated stage a -> m ())
+  -> [InstanceAttrs]
+  -> m (ReleaseKey, InstanceBuffers texture transform)
+allocateInstancesWith createTextures createTransforms bufferDestroy instances = do
+  ul <- askUnliftIO
+  allocate (create ul) (destroy ul)
+  where
+    textures   = VectorS.fromList $ map textureParams instances
+    transforms = VectorS.fromList $ map transformMat4 instances
+    numInstances = VectorS.length textures
+
+    create (UnliftIO ul) = ul do
+      ibTexture   <- createTextures   Vk.BUFFER_USAGE_VERTEX_BUFFER_BIT numInstances textures
+      ibTransform <- createTransforms Vk.BUFFER_USAGE_VERTEX_BUFFER_BIT numInstances transforms
+      pure InstanceBuffers{..}
+
+    destroy (UnliftIO ul) InstanceBuffers{..} = ul do
+      bufferDestroy ibTexture
+      bufferDestroy ibTransform
diff --git a/src/Render/Lit/Textured/Pipeline.hs b/src/Render/Lit/Textured/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Lit/Textured/Pipeline.hs
@@ -0,0 +1,184 @@
+module Render.Lit.Textured.Pipeline
+  ( Pipeline
+  , allocate
+  , allocateBlend
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Zero (zero)
+
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
+import Render.Code (compileVert, compileFrag, glsl)
+import Render.DescSets.Set0 (Scene, 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.Model qualified as Model
+
+type Config = Pipeline.Config '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocate multisample tset0 rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (config tset0)
+    rp
+  pure p
+
+allocateBlend
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocateBlend multisample tset0 rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (configBlend tset0)
+    rp
+  pure p
+
+config :: Tagged Scene DsBindings -> Config
+config (Tagged set0) = zero
+  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Pipeline.cVertexCode   = Just vertCode
+  , Pipeline.cVertexInput  = vertexInput
+  , Pipeline.cFragmentCode = Just fragCode
+  }
+  where
+    vertexInput = Pipeline.vertexInput
+      [ vertexPos -- vPosition
+      , (Vk.VERTEX_INPUT_RATE_VERTEX,   Model.vkVertexAttrs)
+      , (Vk.VERTEX_INPUT_RATE_INSTANCE, Model.vkInstanceTexture)
+      , instanceTransform
+      ]
+
+configBlend :: Tagged Scene DsBindings -> Config
+configBlend tset0 = (config tset0)
+  { Pipeline.cBlend      = True
+  , Pipeline.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;
+    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
+
+    // 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 occlusion = 1.0 - 0.0;
+
+      vec3 normal = normalize(fNormal);
+
+      ${litMain}
+    }
+  |])
diff --git a/src/Render/ShadowMap/Pipeline.hs b/src/Render/ShadowMap/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/ShadowMap/Pipeline.hs
@@ -0,0 +1,90 @@
+module Render.ShadowMap.Pipeline
+  ( Pipeline
+  , allocate
+  , Settings(..)
+  , defaults
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import Geomancy (Transform)
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Zero (zero)
+
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
+import Render.Code (compileVert, glsl)
+import Render.Code.Lit (structLight)
+import Render.DescSets.Set0 (vertexPos, instanceTransform)
+import Render.DescSets.Sun (Sun, pattern MAX_VIEWS)
+
+type Config = Pipeline.Config '[Sun] () Transform
+type Pipeline = Pipeline.Pipeline '[Sun] () Transform
+
+data Settings = Settings
+  { cull      :: Vk.CullModeFlagBits
+  , depthBias :: Maybe (Float, Float)
+  }
+
+defaults :: Settings
+defaults = Settings
+  { cull      = Vk.CULL_MODE_BACK_BIT
+  , depthBias = Just (2.0, 2.5)
+  }
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Tagged Sun DsBindings
+  -> renderpass
+  -> Settings
+  -> ResourceT (RIO env) Pipeline
+allocate tset0 rp settings = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    Vk.SAMPLE_COUNT_1_BIT
+    (config tset0 settings)
+    rp
+  pure p
+
+config :: Tagged Sun DsBindings -> Settings -> Config
+config (Tagged set0) Settings{..} = zero
+  { Pipeline.cDescLayouts  = Tagged @'[Sun] [set0]
+  , Pipeline.cVertexCode   = Just vertCode
+  , Pipeline.cVertexInput  = vertexInput
+  , Pipeline.cDepthBias    = depthBias
+  , Pipeline.cCull         = cull
+  }
+  where
+    vertexInput = Pipeline.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;
+
+    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/RenderPass.hs b/src/Render/ShadowMap/RenderPass.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/ShadowMap/RenderPass.hs
@@ -0,0 +1,229 @@
+{-# LANGUAGE OverloadedLists #-}
+
+module Render.ShadowMap.RenderPass where
+
+import RIO
+
+import Control.Monad.Trans.Resource qualified as Resource
+import Data.Vector qualified as Vector
+import RIO.Vector.Partial ((!))
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Core11.Promoted_From_VK_KHR_multiview qualified as Khr
+import Vulkan.NamedType ((:::))
+import Vulkan.Utils.Debug qualified as Debug
+import Vulkan.Zero (zero)
+import Vulkan.CStruct.Extends (pattern (:&), pattern (::&))
+
+import Engine.Types.RefCounted (RefCounted, newRefCounted, resourceTRefCount)
+import Engine.Vulkan.Types (HasVulkan(..), HasSwapchain(..), HasRenderPass(..), RenderPass(..), MonadVulkan)
+import Resource.Image (AllocatedImage)
+import Resource.Image qualified as Image
+
+-- * Depth-only pass for shadowmapping pipelines
+
+data ShadowMap = ShadowMap
+  { smRenderPass  :: Vk.RenderPass
+  , smDepthImage  :: AllocatedImage
+  , smFrameBuffer :: Vk.Framebuffer
+  , smRenderArea  :: Vk.Rect2D
+  , smExtent      :: Vk.Extent2D
+  , smLayerCount  :: Word32
+  , smClear       :: Vector Vk.ClearValue
+  , smRelease     :: RefCounted
+  }
+
+instance HasRenderPass ShadowMap where
+  getRenderPass   = smRenderPass
+  getFramebuffers = Vector.replicate 10 . smFrameBuffer
+  getClearValues  = smClear
+  getRenderArea   = smRenderArea
+
+instance RenderPass ShadowMap where
+  allocateRenderpass_ = error "Don't allocate ShadowMap via class"
+  updateRenderpass    = error "Don't recreate ShadowMap on resize"
+  refcountRenderpass  = resourceTRefCount . smRelease
+
+allocate
+  :: ( Resource.MonadResource m
+     , MonadVulkan env m
+     , HasLogFunc env
+     , HasSwapchain context
+     )
+  => context
+  -> Word32
+  -> "light count" ::: Word32
+  -> m ShadowMap
+allocate context mapSize layerCount = do
+  logDebug "Allocating ShadowMap resources"
+  (_rpKey, renderPass) <- allocateRenderPass context (2 ^ layerCount - 1) 0
+  (refcounted, depthImage, framebuffer) <- allocateFramebuffer context extent layerCount renderPass
+
+  pure ShadowMap
+    { smRenderPass  = renderPass
+    , smExtent      = extent
+    , smLayerCount  = layerCount
+    , smRenderArea  = fullSurface
+    , smClear       = clear
+    , smDepthImage  = depthImage
+    , smFrameBuffer = framebuffer
+    , smRelease     = refcounted
+    }
+  where
+    extent = Vk.Extent2D{width=mapSize, height=mapSize}
+
+    fullSurface = Vk.Rect2D
+      { Vk.offset = zero
+      , Vk.extent = extent
+      }
+
+    clear = Vector.fromList
+      [ Vk.DepthStencil (Vk.ClearDepthStencilValue 1.0 0)
+      ]
+
+-- ** Render pass
+
+allocateRenderPass
+  :: ( MonadVulkan env m
+     , Resource.MonadResource m
+     , HasSwapchain context
+     )
+  => context
+  -> Word32
+  -> Word32
+  -> m (Resource.ReleaseKey, Vk.RenderPass)
+allocateRenderPass context viewMask correlationMask = do
+  device <- asks getDevice
+  let depthFormat = getDepthFormat context
+
+  res@(_key, object) <- Vk.withRenderPass device (createInfo depthFormat) Nothing Resource.allocate
+  Debug.nameObject device object "ShadowMap.RP"
+  pure res
+  where
+    createInfo depthFormat =
+      zero
+        { Vk.attachments  = Vector.fromList [depth depthFormat]
+        , Vk.subpasses    = Vector.fromList [subpass]
+        , Vk.dependencies = Vector.fromList [pre, post]
+        }
+      ::& Khr.RenderPassMultiviewCreateInfo
+        { Khr.viewMasks        = [viewMask]
+        , Khr.viewOffsets      = []
+        , Khr.correlationMasks = [correlationMask]
+        }
+      :& ()
+
+    depth depthFormat = zero
+      { Vk.format         = depthFormat
+      , Vk.samples        = Vk.SAMPLE_COUNT_1_BIT
+      , Vk.loadOp         = Vk.ATTACHMENT_LOAD_OP_CLEAR
+      , Vk.storeOp        = Vk.ATTACHMENT_STORE_OP_STORE
+      , Vk.stencilLoadOp  = Vk.ATTACHMENT_LOAD_OP_DONT_CARE
+      , Vk.stencilStoreOp = Vk.ATTACHMENT_STORE_OP_DONT_CARE
+      , Vk.initialLayout  = Vk.IMAGE_LAYOUT_UNDEFINED
+      , Vk.finalLayout    = Vk.IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL
+      }
+
+    subpass = zero
+      { Vk.pipelineBindPoint = Vk.PIPELINE_BIND_POINT_GRAPHICS
+      , Vk.depthStencilAttachment = Just zero
+          { Vk.attachment = 0
+          , Vk.layout     = Vk.IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL
+          }
+      }
+
+    pre = zero
+      { Vk.dependencyFlags = Vk.DEPENDENCY_BY_REGION_BIT
+
+      , Vk.srcSubpass      = Vk.SUBPASS_EXTERNAL
+      , Vk.srcStageMask    = Vk.PIPELINE_STAGE_FRAGMENT_SHADER_BIT
+      , Vk.srcAccessMask   = Vk.ACCESS_SHADER_READ_BIT
+
+      , Vk.dstSubpass      = 0
+      , Vk.dstStageMask    = Vk.PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT
+      , Vk.dstAccessMask   = Vk.ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT
+      }
+
+    post = zero
+      { Vk.dependencyFlags = Vk.DEPENDENCY_BY_REGION_BIT
+
+      , Vk.srcSubpass      = 0
+      , Vk.srcStageMask    = Vk.PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT
+      , Vk.srcAccessMask   = Vk.ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT
+
+      , Vk.dstSubpass      = Vk.SUBPASS_EXTERNAL
+      , Vk.dstStageMask    = Vk.PIPELINE_STAGE_FRAGMENT_SHADER_BIT
+      , Vk.dstAccessMask   = Vk.ACCESS_SHADER_READ_BIT
+      }
+
+-- ** Framebuffer
+
+type Framebuffers =
+  ( RefCounted
+  , Image.AllocatedImage
+  , Vk.Framebuffer
+  )
+
+allocateFramebuffer
+  :: ( Resource.MonadResource m
+     , MonadVulkan env m
+     , HasLogFunc env
+     , HasSwapchain context
+     )
+  => context
+  -> Vk.Extent2D
+  -> Word32
+  -> Vk.RenderPass
+  -> m Framebuffers
+allocateFramebuffer context extent layerCount renderPass = do
+  device <- asks getDevice
+  context' <- asks (, context)
+  let Vk.Extent2D{width, height} = extent
+
+  (depthKey, depth) <- Resource.allocate
+    (Image.createDepthResource context' extent $ Just layerCount)
+    (Image.destroyAllocatedImage context')
+
+  let
+    attachments = Vector.fromList
+      [ Image.aiImageView depth
+      ]
+
+    {- XXX:
+      If the render pass uses multiview, then layers must be one and each attachment
+      requires a number of layers that is greater than the maximum bit index set in
+      the view mask in the subpasses in which it is used.
+    -}
+    fbNumLayers = 1
+
+    fbCI = zero
+      { Vk.renderPass  = renderPass
+      , Vk.width       = width
+      , Vk.height      = height
+      , Vk.attachments = attachments
+      , Vk.layers      = fbNumLayers
+      }
+  (framebufferKey, framebuffer) <- Vk.withFramebuffer device fbCI Nothing Resource.allocate
+  Debug.nameObject device framebuffer "ShadowMap.FB"
+
+  releaseDebug <- toIO $ logDebug "Releasing ShadowMap resources"
+  release <- newRefCounted do
+    releaseDebug
+    Resource.release depthKey
+    Resource.release framebufferKey
+
+  pure (release, depth, framebuffer)
+
+usePass :: (MonadIO io, HasRenderPass a) => a -> Word32 -> Vk.CommandBuffer -> io r -> io r
+usePass render imageIndex cb =
+  Vk.cmdUseRenderPass
+    cb
+    (beginInfo render imageIndex)
+    Vk.SUBPASS_CONTENTS_INLINE
+
+beginInfo :: HasRenderPass a => a -> Word32 -> Vk.RenderPassBeginInfo '[]
+beginInfo rp imageIndex = zero
+  { Vk.renderPass  = getRenderPass rp
+  , Vk.framebuffer = getFramebuffers rp ! fromIntegral imageIndex -- XXX: we only have one
+  , Vk.renderArea  = getRenderArea rp
+  , Vk.clearValues = getClearValues rp
+  }
diff --git a/src/Render/Skybox/Pipeline.hs b/src/Render/Skybox/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Skybox/Pipeline.hs
@@ -0,0 +1,98 @@
+{-# LANGUAGE OverloadedLists #-}
+
+module Render.Skybox.Pipeline
+  ( Pipeline
+  , allocate
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Zero (Zero(..))
+
+import Engine.Vulkan.Pipeline (Config(..))
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
+import Render.Code (compileVert, compileFrag, glsl)
+import Render.DescSets.Set0 (Scene)
+import Render.DescSets.Set0.Code (set0binding0, set0binding1, set0binding3)
+
+type Pipeline = Pipeline.Pipeline '[Scene] () ()
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocate multisample (Tagged set0) = do
+  fmap snd . Pipeline.allocate
+    Nothing
+    multisample
+    zero
+      { cVertexCode         = Just vertCode
+      , cFragmentCode       = Just fragCode
+      , cDescLayouts        = Tagged @'[Scene] [set0]
+      , 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
+
+    ${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[2] // XXX: linear/mip0/repeat
+          ),
+          fragUVW
+        );
+      }
+    }
+  |])
diff --git a/src/Render/Unlit/Colored/Model.hs b/src/Render/Unlit/Colored/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Unlit/Colored/Model.hs
@@ -0,0 +1,25 @@
+module Render.Unlit.Colored.Model
+  ( Model
+
+  , VertexAttrs
+  , vkVertexAttrs
+
+  , InstanceAttrs
+  ) where
+
+import Geomancy (Transform, Vec4)
+import Geomancy.Vec3 qualified as Vec3
+import Resource.Model qualified as Model
+import Vulkan.Core10 qualified as Vk
+import Vulkan.NamedType ((:::))
+
+type Model buf = Model.Indexed buf Vec3.Packed VertexAttrs
+
+type VertexAttrs = "RGBA" ::: Vec4
+
+type InstanceAttrs = Transform
+
+vkVertexAttrs :: [Vk.Format]
+vkVertexAttrs =
+  [ Vk.FORMAT_R32G32B32A32_SFLOAT -- vColor :: vec4
+  ]
diff --git a/src/Render/Unlit/Colored/Pipeline.hs b/src/Render/Unlit/Colored/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Unlit/Colored/Pipeline.hs
@@ -0,0 +1,120 @@
+module Render.Unlit.Colored.Pipeline
+  ( Pipeline
+  , allocate
+  , allocateWireframe
+  ) where
+
+import RIO
+
+import Control.Monad.Trans.Resource (ResourceT)
+import Data.Tagged (Tagged(..))
+import Vulkan.Core10 qualified as Vk
+import Vulkan.Zero (zero)
+
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
+import Render.Code (compileVert, compileFrag, glsl)
+import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
+import Render.DescSets.Set0.Code (set0binding0)
+import Render.Unlit.Colored.Model qualified as Model
+
+type Config = Pipeline.Config '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Bool
+  -> Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocate useDepth multisample tset0 rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (config useDepth tset0)
+    rp
+  pure p
+
+allocateWireframe
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Bool
+  -> Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocateWireframe useDepth multisample tset0 rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (configWireframe useDepth tset0)
+    rp
+  pure p
+
+config :: Bool -> Tagged Scene DsBindings -> Config
+config useDepth (Tagged set0) = zero
+  { 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
+  }
+  where
+    vertexInput = Pipeline.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() {
+      gl_Position
+        = scene.projection
+        * scene.view
+        * iModel
+        * vec4(vPosition, 1.0);
+
+      fColor = vColor;
+      fColor.rgb * fColor.a;
+    }
+  |])
+
+fragCode :: ByteString
+fragCode =
+  $(compileFrag [glsl|
+    #version 450
+    #extension GL_ARB_separate_shader_objects : enable
+
+    layout(location = 0) in vec4 fragColor;
+
+    layout(location = 0) out vec4 outColor;
+
+    void main() {
+      outColor = fragColor;
+    }
+  |])
diff --git a/src/Render/Unlit/Textured/Model.hs b/src/Render/Unlit/Textured/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Unlit/Textured/Model.hs
@@ -0,0 +1,216 @@
+module Render.Unlit.Textured.Model
+  ( Model
+  , VertexAttrs
+  , vkVertexAttrs
+
+  , InstanceAttrs(..)
+  , instanceAttrs
+
+  , StorableAttrs
+  , storableAttrs1
+
+  , InstanceBuffers(..)
+
+  , TextureParams(..)
+  , vkInstanceTexture
+
+  , allocateInstancesWith
+  , allocateInstancesCoherent
+  , allocateInstancesCoherent_
+  , updateCoherentResize_
+  , Transform
+  ) where
+
+import RIO
+
+import Foreign (Storable(..))
+import Geomancy (Transform, Vec2, Vec4)
+import Geomancy.Vec3 qualified as Vec3
+import RIO.Vector.Storable qualified as Storable
+import UnliftIO.Resource (MonadResource, ReleaseKey, ResourceT, allocate)
+import Vulkan.Core10 qualified as Vk
+import Vulkan.NamedType ((:::))
+import Vulkan.Zero (Zero(..))
+
+import Engine.Vulkan.Types (HasVulkan)
+import Resource.Buffer qualified as Buffer
+import Resource.Model qualified as Model
+
+type Model buf = Model.Indexed buf Vec3.Packed VertexAttrs
+
+type VertexAttrs = "uv" ::: Vec2
+
+vkVertexAttrs :: [Vk.Format]
+vkVertexAttrs =
+  [ Vk.FORMAT_R32G32_SFLOAT -- vTexCoord :: vec2
+  ]
+
+-- | Data for a single element.
+data InstanceAttrs = InstanceAttrs
+  { textureParams :: TextureParams
+  , transformMat4 :: Transform
+  }
+
+instance Zero InstanceAttrs where
+  zero = InstanceAttrs
+    { textureParams = zero
+    , transformMat4 = mempty
+    }
+
+instanceAttrs :: Int32 -> Int32 -> [Transform] -> InstanceAttrs
+instanceAttrs samplerId textureId transforms = InstanceAttrs
+  { textureParams = zero
+      { tpSamplerId = samplerId
+      , tpTextureId = textureId
+      }
+  , transformMat4 = mconcat transforms
+  }
+
+-- | Intermediate data to be shipped.
+type StorableAttrs =
+  ( Storable.Vector TextureParams
+  , Storable.Vector Transform
+  )
+
+storableAttrs1 :: Int32 -> Int32 -> [Transform] -> StorableAttrs
+storableAttrs1 samplerId textureId transforms =
+  ( Storable.singleton textureParams
+  , Storable.singleton transformMat4
+  )
+  where
+    InstanceAttrs{..} = instanceAttrs
+      samplerId
+      textureId
+      transforms
+
+-- | GPU-bound data.
+data InstanceBuffers textureStage transformStage = InstanceBuffers
+  { ibTexture   :: InstanceTexture textureStage
+  , ibTransform :: InstanceTransform transformStage
+  }
+
+type InstanceTexture stage = Buffer.Allocated stage TextureParams
+
+type InstanceTransform stage = Buffer.Allocated stage Transform
+
+instance Model.HasVertexBuffers (InstanceBuffers textureStage transformStage) where
+  type VertexBuffersOf (InstanceBuffers textureStage transformStage) = InstanceAttrs
+
+  {-# INLINE getVertexBuffers #-}
+  getVertexBuffers InstanceBuffers{..} =
+    [ Buffer.aBuffer ibTexture
+    , Buffer.aBuffer ibTransform
+    ]
+
+  {-# INLINE getInstanceCount #-}
+  getInstanceCount InstanceBuffers{..} =
+    min
+      (Buffer.aUsed ibTexture)
+      (Buffer.aUsed ibTransform)
+
+data TextureParams = TextureParams
+  { tpScale     :: Vec2
+  , tpOffset    :: Vec2
+  , tpGamma     :: Vec4
+  , tpSamplerId :: Int32
+  , tpTextureId :: Int32
+  }
+  deriving (Show)
+
+instance Zero TextureParams where
+  zero = TextureParams
+    { tpScale     = 1.0
+    , tpOffset    = 0.0
+    , tpGamma     = 1.0
+    , tpSamplerId = minBound
+    , tpTextureId = minBound
+    }
+
+instance Storable TextureParams where
+  alignment ~_ = 4
+
+  sizeOf ~_ = 8 + 8 + 16 + 4 + 4
+
+  poke ptr TextureParams{..} = do
+    pokeByteOff ptr  0 tpScale
+    pokeByteOff ptr  8 tpOffset
+    pokeByteOff ptr 16 tpGamma
+    pokeByteOff ptr 32 tpSamplerId
+    pokeByteOff ptr 36 tpTextureId
+
+  peek ptr = do
+    tpScale     <- peekByteOff ptr  0
+    tpOffset    <- peekByteOff ptr  8
+    tpGamma     <- peekByteOff ptr 16
+    tpSamplerId <- peekByteOff ptr 32
+    tpTextureId <- peekByteOff ptr 36
+    pure TextureParams{..}
+
+vkInstanceTexture :: [Vk.Format]
+vkInstanceTexture =
+  [ Vk.FORMAT_R32G32B32A32_SFLOAT -- iTextureScaleOffset :: vec4
+  , Vk.FORMAT_R32G32B32A32_SFLOAT -- iTextureGamma       :: vec4
+  , Vk.FORMAT_R32G32_SINT         -- iTextureIds         :: ivec2
+  ]
+
+allocateInstancesWith
+  :: ( MonadResource m
+     , MonadUnliftIO m
+     )
+  => (Vk.BufferUsageFlagBits -> Int -> Storable.Vector TextureParams -> m (InstanceTexture texture))
+  -> (Vk.BufferUsageFlagBits -> Int -> Storable.Vector Transform -> m (InstanceTransform transform))
+  -> (forall stage a . Buffer.Allocated stage a -> m ())
+  -> [InstanceAttrs]
+  -> m (ReleaseKey, InstanceBuffers texture transform)
+allocateInstancesWith createTextures createTransforms bufferDestroy instances = do
+  ul <- askUnliftIO
+  allocate (create ul) (destroy ul)
+  where
+    textures   = Storable.fromList $ map textureParams instances
+    transforms = Storable.fromList $ map transformMat4 instances
+    numInstances = Storable.length textures
+
+    create (UnliftIO ul) = ul do
+      ibTexture   <- createTextures   Vk.BUFFER_USAGE_VERTEX_BUFFER_BIT numInstances textures
+      ibTransform <- createTransforms Vk.BUFFER_USAGE_VERTEX_BUFFER_BIT numInstances transforms
+      pure InstanceBuffers{..}
+
+    destroy (UnliftIO ul) InstanceBuffers{..} = ul do
+      bufferDestroy ibTexture
+      bufferDestroy ibTransform
+
+allocateInstancesCoherent
+  :: ( MonadReader env m
+     , HasVulkan env
+     , MonadResource m
+     , MonadUnliftIO m
+     )
+  => [InstanceAttrs]
+  -> m (ReleaseKey, InstanceBuffers 'Buffer.Coherent 'Buffer.Coherent)
+allocateInstancesCoherent instances = do
+  context <- ask
+  allocateInstancesWith
+    (Buffer.createCoherent context)
+    (Buffer.createCoherent context)
+    (Buffer.destroy context)
+    instances
+
+allocateInstancesCoherent_
+  :: (HasVulkan env)
+  => Int
+  -> ResourceT (RIO env) (InstanceBuffers 'Buffer.Coherent 'Buffer.Coherent)
+allocateInstancesCoherent_ n =
+  fmap snd $ allocateInstancesCoherent (replicate n zero)
+
+updateCoherentResize_
+  :: ( HasVulkan context
+     , MonadUnliftIO m
+     )
+  => context
+  -> InstanceBuffers 'Buffer.Coherent 'Buffer.Coherent
+  -> (Storable.Vector TextureParams, Storable.Vector Transform)
+  -> m (InstanceBuffers 'Buffer.Coherent 'Buffer.Coherent)
+updateCoherentResize_ context InstanceBuffers{..} (textures, transforms) =
+  InstanceBuffers
+    <$> Buffer.updateCoherentResize_ context ibTexture textures
+    <*> Buffer.updateCoherentResize_ context ibTransform transforms
diff --git a/src/Render/Unlit/Textured/Pipeline.hs b/src/Render/Unlit/Textured/Pipeline.hs
new file mode 100644
--- /dev/null
+++ b/src/Render/Unlit/Textured/Pipeline.hs
@@ -0,0 +1,149 @@
+module Render.Unlit.Textured.Pipeline
+  ( Pipeline
+  , allocate
+  , allocateBlend
+  ) 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 Vulkan.Zero (zero)
+
+import Engine.Vulkan.Pipeline qualified as Pipeline
+import Engine.Vulkan.Types (HasVulkan, HasRenderPass(..), DsBindings)
+import Render.Code (compileVert, compileFrag, glsl)
+import Render.DescSets.Set0 (Scene, vertexPos, instanceTransform)
+import Render.Unlit.Textured.Model qualified as Model
+
+type Config = Pipeline.Config '[Scene] Model.VertexAttrs Model.InstanceAttrs
+type Pipeline = Pipeline.Pipeline '[Scene] Model.VertexAttrs Model.InstanceAttrs
+
+allocate
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocate multisample tset0 rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (config tset0)
+    rp
+  pure p
+
+allocateBlend
+  :: ( HasVulkan env
+     , HasRenderPass renderpass
+     )
+  => Vk.SampleCountFlagBits
+  -> Tagged Scene DsBindings
+  -> renderpass
+  -> ResourceT (RIO env) Pipeline
+allocateBlend multisample tset0 rp = do
+  (_, p) <- Pipeline.allocate
+    Nothing
+    multisample
+    (configBlend tset0)
+    rp
+  pure p
+
+config :: Tagged Scene DsBindings -> Config
+config (Tagged set0) = zero
+  { Pipeline.cDescLayouts  = Tagged @'[Scene] [set0]
+  , Pipeline.cVertexCode   = Just vertCode
+  , Pipeline.cVertexInput  = vertexInput
+  , Pipeline.cFragmentCode = Just fragCode
+  }
+  where
+    vertexInput = Pipeline.vertexInput
+      [ vertexPos -- vPosition
+      , (Vk.VERTEX_INPUT_RATE_VERTEX,   Model.vkVertexAttrs)
+      , (Vk.VERTEX_INPUT_RATE_INSTANCE, Model.vkInstanceTexture)
+      , instanceTransform
+      ]
+
+configBlend :: Tagged Scene DsBindings -> Config
+configBlend tset0 = (config tset0)
+  { Pipeline.cBlend      = True
+  , Pipeline.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() {
+      gl_Position
+        = scene.projection
+        * scene.view
+        * iModel
+        * vec4(vPosition, 1.0);
+
+      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;
+
+    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/Resource/Font.hs b/src/Resource/Font.hs
new file mode 100644
--- /dev/null
+++ b/src/Resource/Font.hs
@@ -0,0 +1,67 @@
+module Resource.Font
+  ( Config(..)
+  , allocateCollection
+  , collectionTextures
+
+  , Font(..)
+  , allocateFont
+
+  ) where
+
+import RIO
+
+import RIO.Vector qualified as Vector
+import UnliftIO.Resource qualified as Resource
+import Vulkan.Core10 qualified as Vk
+
+import Engine.Types (StageRIO)
+import Engine.Vulkan.Types (Queues)
+import Resource.Font.EvanW qualified as EvanW
+import Resource.Texture (Texture, Flat)
+import Resource.Texture qualified as Texture
+import Resource.Texture.Ktx1 qualified as Ktx1
+
+-- * General collection tools
+
+data Config = Config
+  { configContainer :: FilePath
+  , configTexture   :: FilePath
+  }
+  deriving (Eq, Ord, Show)
+
+allocateCollection
+  :: Traversable collection
+  => Queues Vk.CommandPool
+  -> collection Config
+  -> StageRIO st (Resource.ReleaseKey, collection Font)
+allocateCollection pools collection = do
+  collected <- for collection $ allocateFont pools
+  key <- Resource.register $ traverse_ Resource.release $ fmap fst collected
+  pure (key, fmap snd collected)
+
+collectionTextures :: Foldable collection => collection Font -> Vector (Texture Flat)
+collectionTextures = Vector.fromList . map texture . toList
+
+-- * Individual fonts
+
+data Font = Font
+  { container :: EvanW.Container
+  , texture   :: Texture Flat
+  }
+
+allocateFont
+  :: Queues Vk.CommandPool
+  -> Config
+  -> StageRIO st (Resource.ReleaseKey, Font)
+allocateFont pools Config{..} = do
+  context <- ask
+
+  container <- EvanW.load configContainer
+
+  createTexture <- toIO $ Ktx1.createTexture pools configTexture
+
+  (textureKey, texture) <- Resource.allocate
+    createTexture
+    (Texture.destroy context)
+
+  pure (textureKey, Font{..})
diff --git a/src/Resource/Font/EvanW.hs b/src/Resource/Font/EvanW.hs
new file mode 100644
--- /dev/null
+++ b/src/Resource/Font/EvanW.hs
@@ -0,0 +1,159 @@
+{- |
+  JSON font loader for bitmaps and SDFs
+
+  Generator: https://evanw.github.io/font-texture-generator/
+
+  Usage (WebGL): https://evanw.github.io/font-texture-generator/example-webgl/
+-}
+
+module Resource.Font.EvanW
+  ( load
+  , Container(..)
+  , Character(..)
+
+  , putLine
+  , PutChar(..)
+  ) where
+
+import RIO
+
+import Data.Aeson (FromJSON, eitherDecodeFileStrict')
+import Foreign qualified
+import Geomancy (Vec2, vec2, pattern WithVec2)
+import RIO.HashMap qualified as HashMap
+import RIO.Text qualified as Text
+import Vulkan.NamedType ((:::))
+
+import Engine.UI.Layout qualified as Layout
+
+-- * Loading
+
+newtype FontError = FontError Text
+  deriving (Eq, Ord, Show, Generic)
+
+instance Exception FontError
+
+data Container = Container
+  { name       :: Text
+  , size       :: Float
+  , bold       :: Bool
+  , italic     :: Bool
+  , width      :: Float
+  , height     :: Float
+  , characters :: HashMap Char Character
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+data Character = Character
+  { x       :: Float
+  , y       :: Float
+  , width   :: Float
+  , height  :: Float
+  , originX :: Float
+  , originY :: Float
+  , advance :: Float
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance FromJSON Container
+instance FromJSON Character
+
+load :: HasLogFunc env => FilePath -> RIO env Container
+load fp = do
+  logInfo $ "Loading font " <> fromString fp
+  liftIO (eitherDecodeFileStrict' fp) >>= \case
+    Left err ->
+      throwM . FontError $ Text.pack err
+    Right res ->
+      pure res
+
+-- * Typesetting
+
+data PutChar = PutChar
+  { pcPos    :: Vec2
+  , pcSize   :: Vec2
+  , pcOffset :: Vec2
+  , pcScale  :: Vec2
+  } deriving (Show)
+
+instance Foreign.Storable PutChar where
+  alignment ~_ = 16
+
+  sizeOf ~_ = 32 -- 4 of pairs of floats
+
+  peek ptr = PutChar
+    <$> Foreign.peekElemOff (Foreign.castPtr ptr) 0
+    <*> Foreign.peekElemOff (Foreign.castPtr ptr) 1
+    <*> Foreign.peekElemOff (Foreign.castPtr ptr) 2
+    <*> Foreign.peekElemOff (Foreign.castPtr ptr) 3
+
+  poke ptr PutChar{..} = do
+    Foreign.pokeElemOff (Foreign.castPtr ptr) 0 pcPos
+    Foreign.pokeElemOff (Foreign.castPtr ptr) 1 pcSize
+    Foreign.pokeElemOff (Foreign.castPtr ptr) 2 pcOffset
+    Foreign.pokeElemOff (Foreign.castPtr ptr) 3 pcScale
+
+putLine
+  :: "WH"        ::: Vec2
+  -> "XY"        ::: Vec2
+  -> "Alignment" ::: Layout.Alignment
+  -> "Size"      ::: Float
+  -> "Font"      ::: Container
+  -> "Line"      ::: [Char]
+  -> ("scale" ::: Float, [PutChar])
+putLine (WithVec2 cw ch) (WithVec2 cx cy) Layout.Alignment{..} targetSize font =
+  (sizeScale,) . extract . foldl' step (0, 0, [])
+  where
+    Container
+      { size   = fontSize
+      , width  = atlasWidth
+      , height = atlasHeight
+      , characters
+      } = font
+
+    sizeScale = targetSize / fontSize
+
+    extract (offX, _offY, bits) = do
+      (WithVec2 w h, WithVec2 x y, (offset, scale)) <- bits
+      let
+        ax = case alignX of
+          Layout.Begin  -> -cw / 2
+          Layout.Middle -> -offX * sizeScale / 2
+          Layout.End    -> cw / 2 - offX * sizeScale
+
+        ay = case alignY of
+          Layout.Begin  -> -ch / 2 + targetSize * 1.3
+          Layout.Middle -> targetSize * 0.5
+          Layout.End    -> ch / 2 - targetSize * 0.5
+
+      pure PutChar
+        { pcPos    = vec2 (cx + ax + x * sizeScale) (cy + ay + y * sizeScale)
+        , pcSize   = vec2 (w * sizeScale) (h * sizeScale)
+        , pcOffset = offset
+        , pcScale  = scale
+        }
+
+    step (offX, offY, acc) ' ' =
+      ( offX + fontSize / 2
+      , offY
+      , acc
+      )
+
+    step (offX, offY, acc) char =
+      case HashMap.lookup char characters <|> HashMap.lookup '?' characters of
+        Nothing ->
+          (offX, offY, acc)
+        Just Character{..} ->
+          ( offX + advance
+          , offY
+          , ( vec2 width (-height)
+            , vec2 ox oy
+            , (uvOffset, uvScale)
+            ) : acc
+          )
+          where
+            ox = offX + width / 2 - originX
+            oy = offY + height / 2 - originY
+
+            uvOffset = vec2 (x / atlasWidth) (y / atlasHeight)
+            uvScale  = vec2 (width / atlasWidth) (height / atlasHeight)
diff --git a/src/Resource/Mesh/Lit.hs b/src/Resource/Mesh/Lit.hs
new file mode 100644
--- /dev/null
+++ b/src/Resource/Mesh/Lit.hs
@@ -0,0 +1,34 @@
+{-# OPTIONS_GHC -fplugin Foreign.Storable.Generic.Plugin #-}
+
+module Resource.Mesh.Lit
+  ( MaterialNodes
+  , MaterialNode(..)
+  ) where
+
+import RIO
+
+import Foreign.Storable.Generic (GStorable)
+import RIO.Vector.Storable qualified as Storable
+
+import Render.Lit.Material (Material)
+import Resource.Mesh.Types (HasRange(..), Node)
+
+type MaterialNodes = Storable.Vector MaterialNode
+
+data MaterialNode = MaterialNode
+  { mnNode       :: Node
+  , mnMaterialIx :: Int
+  , mnMaterial   :: Material
+  }
+  deriving (Eq, Show, Generic)
+
+instance GStorable MaterialNode
+
+instance HasRange MaterialNode where
+  {-# INLINE getRange #-}
+  getRange = getRange . mnNode
+
+  {-# INLINE adjustRange #-}
+  adjustRange mn@MaterialNode{mnNode} newFirstIndex = mn
+    { mnNode = adjustRange mnNode newFirstIndex
+    }
