fragr-0.1.0.0: src/Fragr.hs
{-|
Frame graph (a.k.a. render graph) engine, after the GDC 2017 talk
/"FrameGraph: Extensible Rendering Architecture in Frostbite"/.
Intended for qualified import:
@
import Fragr qualified as FG
@
The frame loop, every frame:
1. 'newFrameGraph'; 'importResource' the external objects.
2. 'addPass' each pass: declare accesses in the setup block, record work
in the execution callback.
3. 'compile' — culling, lifetimes, the sync schedule.
4. 'execute' (or 'executeQueued'), then discard the graph.
The graph is a single-use, single-threaded object rebuilt every frame,
Frostbite-style. Per-frame values (camera, exposure, the swapchain image)
travel through @ctx@ or are captured when the frame's passes are
re-registered; 'compile' is cheap by design, and imports are re-registered
each frame anyway since the external object may change identity.
-}
module Fragr
( -- * Graph lifecycle
FrameGraph
, newFrameGraph
, compile
, compileWith
, execute
, validateAliasGroups
-- * Multi-queue execution
-- $multiqueue
, executeQueued
, QueueBackend (..)
, executingQueues
-- * Setup: registering passes
, addPass
, addPass_
, Build
, create
, read
, readWith
, write
, write_
, writeWith
, writeWith_
, setSideEffect
, setQueue
-- * Setup: graph-level operations
, importResource
, importScratch
, importOwned
, markShared
, finalize
, addPreExec
, addPostExec
, isValid
, getDescriptor
-- * Execution-time resource access
, Exec
, askCtx
, get
, getDesc
-- * The resource contract
, Resource (..)
-- * Handles and access flags
, Handle (..)
, handleId
, SomeHandle (..)
, someHandleId
, Access (..)
, accessId
-- * Queues, timelines and events
, QueueId (..)
, FamilyId (..)
, EventId (..)
, defaultQueue
-- * Deferred reclamation (recycle queue)
, RecycleQueue
, RetireItem
, newRecycleQueue
, mkRetireItem
, retireItem
, acquireItem
, releaseItem
, collect
-- * Errors
, FragrError (..)
-- * Introspection (for debug output, see "Fragr.Snapshot.Dot" and "Fragr.Snapshot.JSON")
, Snapshot (..)
, PassInfo (..)
, NodeInfo (..)
, EntryInfo (..)
, PassSync (..)
, Wait (..)
, SyncEvent (..)
, Transfer (..)
, transferId
, RetireInfo (..)
, snapshot
) where
import Prelude hiding (read)
import Fragr.Builder (Build, addPass, addPass_, create, finalize, importOwned, read, readWith, setQueue, setSideEffect, write, writeWith, writeWith_, write_)
import Fragr.Compile (compile, compileWith, validateAliasGroups)
import Fragr.Error (FragrError (..))
import Fragr.Exec (Exec, askCtx, get, getDesc)
import Fragr.Execute (QueueBackend (..), execute, executeQueued, executingQueues)
import Fragr.Graph (FrameGraph, addPostExec, addPreExec, getDescriptor, importResource, importScratch, isValid, markShared, newFrameGraph)
import Fragr.Recycle (RecycleQueue, RetireItem, acquireItem, collect, mkRetireItem, newRecycleQueue, releaseItem, retireItem)
import Fragr.Resource (Access (..), Resource (..), accessId)
import Fragr.Snapshot (EntryInfo (..), NodeInfo (..), PassInfo (..), RetireInfo (..), Snapshot (..), snapshot)
import Fragr.Sync (PassSync (..), SyncEvent (..), Transfer (..), Wait (..), transferId)
import Fragr.Types (EventId (..), FamilyId (..), Handle (..), QueueId (..), SomeHandle (..), defaultQueue, handleId, someHandleId)
{- $multiqueue
The core 'execute' assumes a single, in-order queue: it destroys each
transient inline, right after its last user. On top of that, 'compile' also
derives a per-pass synchronization schedule ('PassSync') for running the
surviving passes across several queues, and 'executeQueued' drives that
schedule through a 'QueueBackend' seam without the library ever naming a
semaphore, event or barrier.
Ordering:
* No reordering. Registration order stays execution order; each queue is
submitted in its own registration order. Because a resource is always
produced before it is read, and read only before it is renamed (the
rename rule), every cross-queue dependency edge — read-after-write and
write-after-read alike — points /backward/ in registration order, so
in-order per-queue submission plus timeline waits cannot deadlock.
* One timeline per queue. The @i@-th executing pass on a queue (1-based)
signals value @i@ on completion; that is its 'signal'.
* Cross-queue waits. For each consumer, every foreign-queue producer
contributes a wait for that producer's 'signal' (read-after-write), and
for each renaming writer, every foreign-queue reader of the renamed
version contributes one likewise (write-after-read); these are collapsed
to the maximum per foreign queue and then deduplicated against a
per-(consumer-queue, producer-queue) watermark, since a value already
awaited by an earlier pass on the same consumer queue is implied. Each
kept 'Wait' carries the accesses it protects, so a backend can derive
its wait scope instead of over-synchronizing.
* Same-queue dependencies. An edge whose producer and consumer are adjacent
in their queue's order needs only a plain barrier (the existing
'preRead' / 'preWrite' hook path). An edge (data or anti) with at least
one pass in between gets a split-barrier event pair ('signalEvents' /
'waitEvents'), each 'SyncEvent' carrying its own pass's accesses for the
barrier scopes.
Ownership:
* Ownership transfer. A cross-queue /data/ edge records a 'releases'
'Transfer' on the producer and an 'acquires' one on the consumer — both
carrying the consuming access's 'Flags' (for a rename, whose implicit
read declares none, the renaming write's) — so the backend can emit
release / acquire barriers; the resource contract additionally fires
'preRelease' on the producer (after its callback) and 'preAcquire' on
the consumer (before its callback) for transfers with flags. The
'addPreExec' / 'addPostExec' flush points bracket the callback, batching
what the acquire and release hooks accumulate respectively.
* Families. Ownership's real unit is the queue /family/ ('compileWith'):
under a @QueueId -> FamilyId@ partition, a fan-out to several queues of
one family carries a single transfer — the family's first-registered
consumer acquires for its siblings, who each gain a wait on the
acquiring pass (the acquire barrier lives on its queue; the producer's
signal alone would let a sibling read before it) — and consumption on
two distinct families is rejected ('ReleasedToTwoFamilies') unless the
resource has no single owner (e.g. Vulkan @CONCURRENT@ sharing):
imports read that off the object ('isShared'), created transients say
'markShared'. Queues outside the partition (e.g. the host) keep
per-queue transfers, which is also plain 'compile''s behavior for
everything.
* Frame boundaries. An import last touched by one family and first used
by another this frame has no producer edge to derive a transfer from.
'importOwned' names the owning queue and registers a synthetic pass on
it, standing in for last frame's work: the release gains a queue to
record on, the schedule a producer edge, and everything above —
families, sibling waits, single-owner validation — applies unchanged.
Deferred reclamation uses a 'RecycleQueue': instead of destroying a
transient inline, 'executeQueued' retires it with the per-queue timeline
values that must be reached first ('entryRetire') plus a destroy action,
and 'collect' reclaims everything whose timelines have passed and whose
in-use refcount is zero.
-}