packages feed

vulkan-utils-0.5.11.0: src/Vulkan/Utils/Frame.hs

{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}

{-| Per-frame state and the recycling-Frame loop. Each frame owns a binary
image-available semaphore and a command pool — those are 'RecycledResources'
that get handed back to a channel in 'VulkanContext' once the frame's GPU work
has completed. (The present-wait/render-finished semaphore is per swapchain
image, on the 'Vulkan.Utils.Swapchain.Swapchain', because it is only safe to
reuse once its image is re-acquired — not when the frame's render finishes.)

The host-side timeline semaphore (@fHostTimeline@) lives across frames:
each frame increments it to its own 'fIndex' on the GPU, and the host
waits on it inside the spawned wait-and-recycle thread.

This module requires Vulkan 1.2-level timeline-semaphore support. See
'frameInstanceRequirements' / 'frameDeviceRequirements' for the
extension/feature requirements to merge into your boot sequence.
-}
module Vulkan.Utils.Frame
  ( Frame (..)
  , initialFrame
  , advanceFrame
  , runFrame
  , recordCommands
  , queueSubmitFrame
  , acquireFrameImage
  , presentFrameImage
  , drainFrames
  , allocateTimelineSemaphore
  , allocateCommandPool
  , allocatePrimary
  , SubmitExtras (..)
  , noExtras
  , frameSubmitExtras
  , waitTwice
  , frameInstanceRequirements
  , frameDeviceRequirements
  , syncDeviceRequirements
  , InitRecycledResources
  ) where

import Control.Concurrent (forkIO)
import Control.Exception (finally, mask_, throwIO)
import Control.Monad
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Trans.Resource
import Data.Foldable (for_, toList)
import Data.IORef
import Data.List (nub)
import qualified Data.Map.Strict as Map
import qualified Data.Vector as V
import Data.Word
import System.IO (hPutStrLn, stderr)
import Vulkan.CStruct.Extends (SomeStruct (..), pattern (:&), pattern (::&))
import qualified Vulkan.Core10 as CommandBufferBeginInfo (CommandBufferBeginInfo (..))
import qualified Vulkan.Core10 as CommandPoolCreateInfo (CommandPoolCreateInfo (..))
import qualified Vulkan.Core10 as Vk
import Vulkan.Core12.Promoted_From_VK_KHR_timeline_semaphore as Timeline
import Vulkan.Core13 (PhysicalDeviceSynchronization2Features)
import Vulkan.Core13.Enums.PipelineStageFlags2 (PipelineStageFlagBits2 (..), PipelineStageFlags2)
import Vulkan.Core13.Promoted_From_VK_KHR_synchronization2 (SubmitInfo2 (..), queueSubmit2)
import qualified Vulkan.Core13.Promoted_From_VK_KHR_synchronization2 as CommandBufferSubmitInfo (CommandBufferSubmitInfo (..))
import qualified Vulkan.Core13.Promoted_From_VK_KHR_synchronization2 as SemaphoreSubmitInfo (SemaphoreSubmitInfo (..))
import Vulkan.Exception (VulkanException (..))
import Vulkan.Extensions.VK_KHR_get_physical_device_properties2
import qualified Vulkan.Extensions.VK_KHR_swapchain as KHR
import Vulkan.Requirement (DeviceRequirement, InstanceRequirement (..))
import Vulkan.Utils.QueueAssignment (QueueFamilyIndex (..))
import Vulkan.Utils.Queues (Queues (..))
import Vulkan.Utils.RefCounted (resourceTRefCount)
import qualified Vulkan.Utils.Requirements.TH as U
import Vulkan.Utils.Swapchain (Swapchain (..), sRelease)
import Vulkan.Utils.VulkanContext (RecycledResources (..), VulkanContext (..))
import Vulkan.Zero (zero)

data Frame rr = Frame
  { fIndex :: Word64
  -- ^ Monotonic, used as the timeline-semaphore signal value for this frame.
  , fSwapchain :: Swapchain
  {- ^ The swapchain this frame targets. Held by reference so a frame
  in flight keeps its swapchain alive across recreation.
  -}
  , fRecycled :: RecycledResources rr
  {- ^ This frame's image-available semaphore + command pool — borrowed from
  the recycle channel; returned at retire time.
  -}
  , fHostTimeline :: Vk.Semaphore
  {- ^ Long-lived timeline semaphore. Each frame increments it to 'fIndex'
  on the GPU; the host wait thread blocks on this.
  -}
  , fGPUWork :: IORef [(Vk.Semaphore, Word64)]
  {- ^ (Timeline semaphore, value) pairs the host wait thread will block on.
  Appended to by 'queueSubmitFrame'.
  -}
  , fDeferredWork :: IORef [IO ()]
  {- ^ Host work the recycle thread runs before the 'fGPUWork' wait — e.g.
  a frame graph's host-pass tail, which blocks on its own timeline values
  and signals values listed in 'fGPUWork'. Runs in registration order, off
  the render thread.
  -}
  , fResources :: (ReleaseKey, InternalState)
  {- ^ ResourceT scope for frame-local allocations; closed when the frame
  retires. The 'ReleaseKey' lives in the outer ResourceT so the
  scope is freed cleanly even on early shutdown.
  -}
  }

{- | Instance-level requirements for the recycling 'Frame' machinery. Merge
with your application's other 'InstanceRequirement's at instance creation.

Required because checking @PhysicalDeviceTimelineSemaphoreFeatures@ at
physical-device pick time goes through @VkPhysicalDeviceFeatures2@, which
needs either Vulkan 1.1+ or this extension.
-}
frameInstanceRequirements :: [InstanceRequirement]
frameInstanceRequirements =
  [ RequireInstanceExtension
      Nothing
      KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME
      minBound
  ]

{- | Timeline semaphores and synchronization2.

The two primitives everything here synchronizes with: 'queueSubmitFrame' and
'allocateTimelineSemaphore', and any submit built by hand. Both are core in
1.3 and universally available in practice, so a headless boot wants them too
— 'frameDeviceRequirements' is these plus a swapchain.
-}
syncDeviceRequirements :: [DeviceRequirement]
syncDeviceRequirements =
  [U.reqs|
    VK_KHR_timeline_semaphore
    PhysicalDeviceTimelineSemaphoreFeatures.timelineSemaphore
    VK_KHR_synchronization2
    PhysicalDeviceSynchronization2Features.synchronization2
  |]

{- | The device-level requirements needed by 'runFrame' / 'queueSubmitFrame' /
'allocateTimelineSemaphore'. Merge into your other 'DeviceRequirement's when
calling 'Vulkan.Utils.Initialization.allocateDeviceFromRequirements'.
-}
frameDeviceRequirements :: [DeviceRequirement]
frameDeviceRequirements = [U.reqs|VK_KHR_swapchain|] <> syncDeviceRequirements

----------------------------------------------------------------
-- Construction
----------------------------------------------------------------

type InitRecycledResources m rr = VulkanContext rr -> Int -> Queues Vk.CommandPool -> m rr

{- | Build the initial frame with one spare 'RecycledResources' seeded
into the recycle channel. That, plus the set attached to this frame,
caps max-in-flight at 2 (CPU recording + GPU executing the previous).
-}
initialFrame
  :: forall rr m
   . (MonadResource m)
  => VulkanContext rr
  -> Swapchain
  -> InitRecycledResources m rr
  -> m (Frame rr)
initialFrame vc fSwapchain mkRecycled = do
  fResources <- allocate createInternalState closeInternalState
  fRecycled <- mkRecycledResources vc $ mkRecycled vc 0
  spare <- mkRecycledResources vc $ mkRecycled vc 1
  liftIO (vcRecycleBin vc spare)
  (_, fHostTimeline) <- allocateTimelineSemaphore (vcDevice vc) 0
  fGPUWork <- liftIO $ newIORef mempty
  fDeferredWork <- liftIO $ newIORef mempty
  liftIO $ runInternalState (resourceTRefCount (sRelease fSwapchain)) (snd fResources)
  pure Frame{fIndex = 1, ..}

{- | Build the next frame, taking one set of recycled resources from the bin.
Caller passes the (possibly-recreated) 'Swapchain'.
-}
advanceFrame
  :: (MonadResource m)
  => VulkanContext rr
  -> Swapchain
  -- ^ Same as old, or freshly recreated
  -> Frame rr
  -- ^ The just-finished frame
  -> m (Frame rr)
advanceFrame vc sc f = do
  fResources <- allocate createInternalState closeInternalState
  fRecycled <-
    liftIO $
      vcRecycleNib vc >>= \case
        Left block -> block
        Right rr -> pure rr
  fGPUWork <- liftIO $ newIORef mempty
  fDeferredWork <- liftIO $ newIORef mempty
  liftIO $ runInternalState (resourceTRefCount (sRelease sc)) (snd fResources)
  pure
    Frame
      { fIndex = succ (fIndex f)
      , fSwapchain = sc
      , fRecycled
      , fHostTimeline = fHostTimeline f
      , fGPUWork
      , fDeferredWork
      , fResources
      }

----------------------------------------------------------------
-- Loop
----------------------------------------------------------------

{- | Run a per-frame action against this frame's per-frame ResourceT scope,
then asynchronously wait for the GPU work and recycle. The deferred host
work and the wait/recycle run in a forked thread so the next frame can
begin recording immediately.

Anything 'allocate'd inside @action@ is freed when the frame retires.
-}
runFrame :: VulkanContext rr -> Frame rr -> ResourceT IO a -> IO a
runFrame vc f action =
  runInternalState action (snd (fResources f))
    `finally` waitAndRecycle vc f

waitAndRecycle :: VulkanContext rr -> Frame rr -> IO ()
waitAndRecycle vc f = do
  waits <- readIORef (fGPUWork f)
  deferred <- readIORef (fDeferredWork f)
  void . forkIO $ do
    -- The frame's host work first: it blocks on its own timeline values and
    -- signals values the wait below (and in-flight submits) depend on.
    sequence_ (reverse deferred)
    unless (null waits) $ do
      let waitInfo =
            zero
              { semaphores = V.fromList (fst <$> waits)
              , values = V.fromList (snd <$> waits)
              }
      r <- waitTwice (vcDevice vc) waitInfo oneSecond
      case r of
        Vk.TIMEOUT -> hPutStrLn stderr "Frame wait timed out (1s) — GPU may be hung"
        _ -> pure ()
    -- Pool reuse: reset each distinct pool, dropping all recorded buffers.
    -- No RELEASE_RESOURCES: the point of recycling a pool is to keep its
    -- arena warm for the next frame's buffers.
    for_ (nub (toList (rrCommandPools (fRecycled f)))) $ \pool ->
      Vk.resetCommandPool (vcDevice vc) pool zero
    -- Free the per-frame ResourceT scope. Must precede the channel deposit so
    -- the deposit signals "all per-frame cleanup done" — otherwise the next
    -- frame could pick up the recycled pool while this frame's cleanup is
    -- still calling vkFreeCommandBuffers on it.
    release (fst (fResources f))
    -- Hand the borrowed resources back to whoever's waiting on them.
    vcRecycleBin vc (fRecycled f)
  where
    oneSecond :: Word64
    oneSecond = 1000000000

{- | Allocate a primary command buffer from this frame's recycled command pool,
begin it with 'Vk.COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT', run the caller's
recording action, end recording, and return the buffer ready to hand to
'queueSubmitFrame'.

For a non-standard begin shape (secondary level, different usage flags,
inheritance info) call 'Vk.withCommandBuffers' and 'Vk.useCommandBuffer'
directly.
-}
recordCommands
  :: (MonadResource m, MonadFail m)
  => VulkanContext rr
  -> Frame rr
  -> (Vk.CommandBuffer -> m ())
  -> m Vk.CommandBuffer
{-# INLINE recordCommands #-}
recordCommands vc Frame{fRecycled} record = do
  (_, [cb]) <-
    Vk.withCommandBuffers
      (vcDevice vc)
      zero
        { Vk.commandPool = qGraphics (rrCommandPools fRecycled)
        , Vk.level = Vk.COMMAND_BUFFER_LEVEL_PRIMARY
        , Vk.commandBufferCount = 1
        }
      allocate
  Vk.useCommandBuffer cb zero{CommandBufferBeginInfo.flags = Vk.COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT} $
    record cb
  pure cb

{- | Submit a per-frame command buffer batch and record the timeline-wait
bookkeeping the host wait thread will block on.

Builds the standard frame submit from context/frame: waits on the frame's
image-available semaphore at @COLOR_ATTACHMENT_OUTPUT@, signals the
swapchain's per-image render-finished semaphore (at @imageIndex@) plus its
timeline value, and submits on the graphics queue.

For a non-standard submit shape (multiple submit infos, different wait
stage, extra signals), call 'queueSubmit2' directly and append
@(fHostTimeline f, fIndex f)@ to @fGPUWork f@.
-}
queueSubmitFrame
  :: (MonadIO m)
  => VulkanContext rr
  -> Frame rr
  -> Word32
  {- ^ Acquired image index (from 'acquireFrameImage'); selects the per-image
  present-wait semaphore to signal.
  -}
  -> V.Vector Vk.CommandBuffer
  -> m ()
{-# INLINE queueSubmitFrame #-}
queueSubmitFrame vc Frame{..} imageIndex cbs = liftIO . mask_ $ do
  queueSubmit2 gQ [SomeStruct submitInfo] Vk.NULL_HANDLE
  atomicModifyIORef' fGPUWork $ \jobs -> ((fHostTimeline, fIndex) : jobs, ())
  where
    gQ = snd (qGraphics (vcQueues vc))
    renderFinished = sRenderFinished fSwapchain V.! fromIntegral imageIndex
    -- The two WSI semaphores are binary (mandated: acquire signals one,
    -- present waits on one) and ignore their values; the timeline is ours.
    submitInfo =
      zero
        { waitSemaphoreInfos =
            [zero{SemaphoreSubmitInfo.semaphore = rrImageAvailable, SemaphoreSubmitInfo.stageMask = PIPELINE_STAGE_2_TOP_OF_PIPE_BIT}]
        , commandBufferInfos =
            fmap (\cb -> SomeStruct zero{CommandBufferSubmitInfo.commandBuffer = Vk.commandBufferHandle cb}) cbs
        , signalSemaphoreInfos =
            [ zero{SemaphoreSubmitInfo.semaphore = renderFinished, SemaphoreSubmitInfo.stageMask = PIPELINE_STAGE_2_ALL_COMMANDS_BIT}
            , zero{SemaphoreSubmitInfo.semaphore = fHostTimeline, SemaphoreSubmitInfo.stageMask = PIPELINE_STAGE_2_ALL_COMMANDS_BIT, SemaphoreSubmitInfo.value = fIndex}
            ]
        }
        :: SubmitInfo2 '[]
    RecycledResources{rrImageAvailable} = fRecycled

{- | Acquire the next swapchain image for this frame, signalling the frame's
image-available semaphore on completion.

The acquire result is returned alongside the image index so the caller can
thread it into 'presentFrameImage', which honours 'SUBOPTIMAL_KHR' from
either side by raising 'ERROR_OUT_OF_DATE_KHR' to drive a swapchain
recreation. Timeouts and unexpected results are also translated to
'ERROR_OUT_OF_DATE_KHR' — the main loop's swapchain-recreation path is the
right place to recover.
-}
acquireFrameImage :: (MonadIO m) => VulkanContext rr -> Frame rr -> m (Vk.Result, Word32)
{-# INLINE acquireFrameImage #-}
acquireFrameImage vc Frame{..} =
  liftIO $
    acquire >>= \case
      r@(Vk.SUCCESS, _) -> pure r
      r@(Vk.SUBOPTIMAL_KHR, _) -> pure r
      _ -> throwIO (VulkanException Vk.ERROR_OUT_OF_DATE_KHR)
  where
    acquire =
      KHR.acquireNextImageKHRSafe
        (vcDevice vc)
        (sSwapchain fSwapchain)
        oneSecond
        (rrImageAvailable fRecycled)
        Vk.NULL_HANDLE

    oneSecond :: Word64
    oneSecond = 1000000000

{- | Present this frame's acquired image, waiting on the swapchain's per-image
render-finished semaphore (at @imageIndex@). Presents on the graphics queue
(@qGraphics . vcQueues@).

If either the prior acquire (passed in) or this present reports
'SUBOPTIMAL_KHR', raises 'ERROR_OUT_OF_DATE_KHR' so the main loop
recreates the swapchain.
-}
presentFrameImage :: (MonadIO m) => VulkanContext rr -> Frame rr -> Vk.Result -> Word32 -> m ()
{-# INLINE presentFrameImage #-}
presentFrameImage vc f acquireResult imageIndex = liftIO $ do
  presentResult <-
    KHR.queuePresentKHR
      gQ
      zero
        { KHR.waitSemaphores = [renderFinished]
        , KHR.swapchains = [sSwapchain (fSwapchain f)]
        , KHR.imageIndices = [imageIndex]
        }
  when (acquireResult == Vk.SUBOPTIMAL_KHR || presentResult == Vk.SUBOPTIMAL_KHR) $
    throwIO (VulkanException Vk.ERROR_OUT_OF_DATE_KHR)
  where
    renderFinished = sRenderFinished (fSwapchain f) V.! fromIntegral imageIndex
    gQ = snd (qGraphics (vcQueues vc))

{- | Shutdown drain: spawn the unrendered current frame's wait/recycle thread,
then block on the recycle channel until both this frame's and the previous
in-flight frame's deposits have arrived. After this returns, every forked
wait thread has run its per-frame cleanup, so the outer 'ResourceT' is safe
to tear down GPU resources.

Assumes max-in-flight is 2 (see 'initialFrame').
-}
drainFrames :: VulkanContext rr -> Frame rr -> IO ()
drainFrames vc f = do
  waitAndRecycle vc f
  let take1 = vcRecycleNib vc >>= either id pure
  _ <- take1
  _ <- take1
  pure ()

----------------------------------------------------------------
-- Small helpers
----------------------------------------------------------------

-- | Allocate a timeline semaphore initialised to the given value.
allocateTimelineSemaphore :: (MonadResource m) => Vk.Device -> Word64 -> m (ReleaseKey, Vk.Semaphore)
allocateTimelineSemaphore dev initial =
  Vk.withSemaphore
    dev
    (zero ::& SemaphoreTypeCreateInfo SEMAPHORE_TYPE_TIMELINE initial :& ())
    Nothing
    allocate

----------------------------------------------------------------
-- Internals
----------------------------------------------------------------

{- | Build one set of recycled resources: a binary image-available semaphore
+ a command pool keyed to the graphics queue family. (The present-wait
semaphore is per swapchain image, on the 'Swapchain', not here.)
-}
mkRecycledResources
  :: (MonadResource m)
  => VulkanContext rr
  -> (Queues Vk.CommandPool -> m rr)
  -> m (RecycledResources rr)
mkRecycledResources vc mkData = do
  (_, rrImageAvailable) <-
    Vk.withSemaphore
      dev
      (zero ::& SemaphoreTypeCreateInfo SEMAPHORE_TYPE_BINARY 0 :& ())
      Nothing
      allocate
  -- One pool per distinct family, shared by every role on it.
  byFamily <-
    fmap Map.fromList $
      traverse (\fam -> fmap (fam,) (allocateCommandPool dev fam)) (nub (toList families))
  let rrCommandPools = fmap (byFamily Map.!) families
  rrData <- mkData rrCommandPools
  pure RecycledResources{..}
  where
    dev = vcDevice vc
    families = fmap (\(QueueFamilyIndex fam, _) -> fam) (vcQueues vc)

-- | Allocate a command pool for the family, released with the scope.
allocateCommandPool :: (MonadResource m) => Vk.Device -> Word32 -> m Vk.CommandPool
allocateCommandPool dev family = do
  (_, pool) <- Vk.withCommandPool dev zero{CommandPoolCreateInfo.queueFamilyIndex = family} Nothing allocate
  pure pool

-- | Allocate a primary command buffer from the pool and begin it, one-time-submit.
allocatePrimary :: (MonadResource m) => Vk.Device -> Vk.CommandPool -> m Vk.CommandBuffer
allocatePrimary dev pool = do
  (_, cbs) <-
    Vk.withCommandBuffers
      dev
      zero{Vk.commandPool = pool, Vk.level = Vk.COMMAND_BUFFER_LEVEL_PRIMARY, Vk.commandBufferCount = 1}
      allocate
  let cb = V.head cbs
  Vk.beginCommandBuffer cb zero{CommandBufferBeginInfo.flags = Vk.COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT}
  pure cb

{- | Frame-level waits and signals spliced into one submit.

Timeline semaphores carry their value; a binary semaphore's value is
ignored (pass 0).
-}
data SubmitExtras = SubmitExtras
  { waits :: [(Vk.Semaphore, PipelineStageFlags2, Word64)]
  , signals :: [(Vk.Semaphore, Word64)]
  }

noExtras :: SubmitExtras
noExtras = SubmitExtras [] []

{- | The canonical windowed frame extras.

Wait image-available; signal this image's render-finished and the host
timeline at the frame index — the shape 'queueSubmitFrame' hard-codes,
for drivers that assemble their own submits.
-}
frameSubmitExtras :: Frame rr -> Word32 -> SubmitExtras
frameSubmitExtras f imageIndex =
  SubmitExtras
    { waits = [(rrImageAvailable (fRecycled f), PIPELINE_STAGE_2_TOP_OF_PIPE_BIT, 0)]
    , signals =
        [ (sRenderFinished (fSwapchain f) V.! fromIntegral imageIndex, 0)
        , (fHostTimeline f, fIndex f)
        ]
    }

{- | Wait for some semaphores; if the wait times out, give the device one
more chance with a zero timeout. Catches the case where the host was
suspended during the wait and the GPU has actually finished.
-}
waitTwice :: Vk.Device -> SemaphoreWaitInfo -> Word64 -> IO Vk.Result
waitTwice dev waitInfo t =
  Timeline.waitSemaphoresSafe dev waitInfo t >>= \case
    Vk.TIMEOUT -> Timeline.waitSemaphores dev waitInfo 0
    r -> pure r