Skip to content

Framegraph#653

Draft
dpwiz wants to merge 58 commits into
mainfrom
framegraph
Draft

Framegraph#653
dpwiz wants to merge 58 commits into
mainfrom
framegraph

Conversation

@dpwiz

@dpwiz dpwiz commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

dpwiz and others added 30 commits July 7, 2026 16:40
Window controls: arrow keys orbit the camera and -/+ dolly, driven from a
program-lifetime Orbit state polled via GLFW each frame (updateOrbit). The
camera eye is now a parameter threaded through addScenePasses / viewProjFor /
pushCamera / pushResolve; headless keeps the fixed cameraEye. Adds the GLFW-b
dep.

Shadow fix: geomancy's Vulkan projection is Y-down, but shadowFaces are the
GL cube-face vectors (Y-up NDC), so each cube face rendered vertically flipped
versus the hardware cube sampler -- shadows landed offset from their casters.
lightShadowViewProjs now negates clip-space Y (verified (u,v)==(s,t) on the
+X/-Y/+Z faces; occluder cull is NONE so the inverted winding is harmless).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tweak barriers, give each orb its own culled set.
Port to fragr's typed-handles-flags API: the adapters set
Flags ManagedImage/ManagedBuffer = Usage and the whole Word64 codec
(usageFlags/flagsUsage, marker bits, cross-feed guards) disappears —
declaring an access with the wrong resource's flags is a type error now.
The driver decodes the schedule's existential accesses by eqTypeRep,
over-synchronizing for adapters it does not know.

Host work becomes a graph pass on a designated host QueueId:
submitGraphQueued defers those passes at record time and runs them after
the device submits — schedule waits as vkWaitSemaphoresSafe, the pass's
signal as vkSignalSemaphore — so readbacks join the dependency graph
(headless's host.readback pass). The adapters implement preRelease
(producer-side handoff with the consumer's flags, retiring Scene's two
manual pass-tail transitions), the Recorder gains a host mode that
tracks state without recording, and Buffer.Usage gains HostRead/HostWrite.

All four drivers validate clean, including under sync validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fragr keeps dropped waits' covers on the kept wait now, so the driver's
acquire-widening workaround (and its anti-edge caveat) retires; the
derived waitDstStageMask spans every consumer behind the watermark by
construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A queue's passes are cut into segments wherever a pass carries a
cross-queue wait the current segment's front does not already imply
(same producer, value not above the front's); each segment is its own
one-time buffer and submit, front-waiting its hoisted values. Front
waits therefore only reference passes registered before the segment's
first pass, which makes the segment graph acyclic by construction — the
hoistCyclic rejection is gone, and mid-stream dependencies (device
ping-pong, device→host→device) schedule instead of deadlocking.

The plan is derived from FG.snapshot before recording, so buffers are
allocated up front and passes route by passId. Headless demonstrates:
the readback copy now rides the graphics queue, sandwiching the async
compute chain (geometry / compute passes / copy / host peek — four
submits plus the host step on async hardware).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tonemap exposure moves from a push constant into a metering SSBO.
Headless adds a host.meter pass between luminance and tonemap — reading
this frame's own mean luminance and writing the exposure — so the frame
tonemaps at its own metered value and the old meter-then-re-render
double run is gone. The device→host→device sandwich rides the segmented
submits (compute waits the host timeline mid-stream).

Windowed keeps its lagged EMA (an in-graph meter would stall the loop
mid-frame): it pre-writes the metering buffer instead of pushing, and
the luminance pass keeps its manual host-visibility barrier only on
that polling path — with a meter pass the hand-off is the schedule's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dpwiz and others added 26 commits July 12, 2026 15:06
submitGraphQueued takes a SubmitConfig; its register sink receives every
queue's completion values before anything records or submits. resize
wires it straight to fGPUWork: the recycler waits the whole graph with
no hand-rolled sync, and a submit failing midway now degrades to the
recycler's wait timeout instead of reclaiming under in-flight work.

The config also carries deferHost — hand the ordered host-pass tail to
a runner (e.g. the frame's recycle thread) instead of blocking the
calling thread on the GPU. Consumers follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Frame gains fDeferredWork: host work the recycle thread runs before the
fGPUWork wait — it blocks on its own timeline values and signals values
that wait (and in-flight submits) depend on.

Windowed rides submitGraphQueued now: the orb uploads move in-graph as a
side-effecting first pass, completions register into fGPUWork through
the driver, and the exposure meter becomes a host pass deferred to the
recycle thread — same-frame metering with no readback lag and no
render-thread stall (only the tonemap's segment waits, on the GPU). The
EMA is race-free by construction: frame N+1's meter unblocks only after
its own first segment, which the queue orders behind frame N's tonemap,
which waited frame N's meter. Scene's meter parameter generalizes to a
luminance→exposure function so headless (plain target) and windowed
(adaptation state) share the pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cross-pollination while vulkan-utils is unreleased: allocateCommandPool /
allocatePrimary move into Vulkan.Utils.Frame (mkRecycledResources now
uses the former), SubmitExtras/noExtras move down with them (they are
plain Vulkan, nothing framegraph about them), frameSubmitExtras names
the canonical windowed shape queueSubmitFrame hard-codes, and waitTwice
is exported. The graph driver gains frameSubmitConfig, wiring a frame in
one call: canonical extras on the default queue, completions registered
into fGPUWork pre-submit, host tail deferred to the recycle thread.

The deferral earned a hard constraint, found the loud way (black screen,
wedged present): a deferred host pass must not gate a presented image —
at vkQueuePresentKHR time every signal the present wait depends on must
already be submitted (VUID 03268), and the deferred vkSignalSemaphore is
not. Windowed metering therefore returns to the lagged pre-write EMA;
the same-frame meter sandwich stays headless-only, and deferHost remains
for host work outside the swapchain chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RecycledResources carried one pool, for the graphics family, so anything
multi-queue had nowhere to take a recycled pool from: resize built a
fresh VkCommandPool every dirty frame and headless one per graph run.
Pools are expensive to create and cheap to reset — the inverse of
command buffers — so they belong on the frame timeline.

rrCommandPools is now a Queues of pools, one per distinct family (roles
sharing a family share the handle), reset when the frame retires;
RELEASE_RESOURCES is gone from that reset, since returning the arena to
the driver defeats the point of recycling. resize takes its compute
buffers from the frame's compute pool, and headless builds its queue
table once for every run. Only command buffers stay per-run.

resize now creates 4 pools for the whole process (2 frames in flight x 2
families) where it previously created one per dirty frame; headless 2
for its 7 graph runs. All drivers clean, 0 syncval hazards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
recordOrbUploads was a graph pass whose body still hand-rolled both its
barriers: a leading one making the previous frame's reads (WAR) and its
own uploads (WAW) retire, and a trailing one whose dst stages had to
cover every in-graph reader of the three tables — coverage maintained by
hand, in a comment.

The lights / view-projection / object tables are ManagedBuffers now,
imported per frame. orbs.upload declares three TransferDst writes and
every consumer declares its read (cull and shade at COMPUTE, geometry
and shadow.orbs at VERTEX, ssao.normals at COMPUTE), so the tracker
places the transfers — including the cross-frame write-after-read, since
the buffers' tracked state persists across graphs exactly like the image
layouts do. Both hand-written barriers are gone, and adding a reader can
no longer under-synchronize. The pass moved into addScenePasses, so
headless refreshes the tables too (identical values at t=0).

0 syncval hazards on the 25s windowed soak that originally caught the
WAW; headless metrics unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The adapters emit no ownership transfer, so an EXCLUSIVE resource read on
another queue family has undefined contents — silent garbage, no
validation message. Every wrapper now records whether its allocation is
CONCURRENT (sharedAcrossQueues, a modifier in the describedAs idiom), and
crossing queues without it is fatal, naming the resource.

Modelling the accessor properly was the substance: a resource that has
never been touched on a device queue owns nothing yet (queueRef is Maybe
now, not a QueueId 0 default), and the host is not a queue family — its
accesses order through the schedule's timeline and the producer's release
barrier. Both were false crossings before.

It immediately found a real one: headless's readback copy reads display
on the graphics queue while the gamma pass writes it on compute, and
display was allocated EXCLUSIVE — undefined by spec, working only by
RADV's grace. It is CONCURRENT now. resize's offscreen is marked exactly
when its topology is async, which is when it crosses.

Also moves the completion registration to after recording (still before
any submit), so a failure while recording cannot leave the recycler
waiting on timelines the unwinding frame scope destroys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EXCLUSIVE resources now cross queues properly: the adapters' release and
acquire hooks emit the matching barrier pair (fragr hands them the peer
queue), naming both families from the driver's QueueSlots. CONCURRENT
resources own nothing and keep riding the semaphore alone; the host is not
a family and owns nothing either.

Getting the pair to match took two things. The release performs the layout
transition, so it advances the tracked state — otherwise the next
same-queue reader re-emits a transition from a layout that already moved
(caught as VUID-VkImageMemoryBarrier-oldLayout-01197 on vis, which
ssao.normals reads between geometry and shade). The acquire therefore
remembers the state the release saw (releasedRef) and builds its half from
that, and marks the node chained so the pass's own declared access does not
stack a second barrier on top.

The guard from bc1550c narrows accordingly: a cross-family *read* the
graph never handed over is still fatal, but a *write* is fine — it acquires
by discarding (UNDEFINED old layout), which is what a queue picking up a
resource it does not need the contents of is entitled to do. That is what
lets gamma re-write display on compute each run after graphics owned it.

Demonstrated by making display EXCLUSIVE again: gamma writes it on the
compute queue, the headless readback copy reads it on graphics, and the
graph hands it over — 10 real family-1-to-family-0 barriers per run by
api-dump, everything else QUEUE_FAMILY_IGNORED. 0 validation messages, 0
syncval hazards, headless metrics unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TODO-next/TODO-misc/TODO-scene-lights/FIXME/NOTE-depth-effects were
scattered across the repo root and half of their entries had been fixed
without anyone striking them out. Consolidated into .mgmt/{TODO,FIXME,NOTE}.md
with every entry re-checked against the tree, and .mgmt/stale.md records what
was retired and why.

Notable: the orb-upload barrier and most of the auto-exposure list are done
(the readback lag went away entirely via the in-graph host meter), the
multi-queue FIXME's text now actively misdescribes the code (it predates
typed Flags), and the scene-lights bench numbers predate three rescales. The
log-average meter, the histogram fix and the depth-pyramid note are still
live and unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
.mgmt/ holds the working TODO/FIXME/NOTE ledgers, which have always been
local scratch (the root notes they came from were never tracked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Submits go through vkQueueSubmit2, so a wait carries its value and its
stage in one SemaphoreSubmitInfo -- no TimelineSemaphoreSubmitInfo chain,
and the v1 stage decoder (resourceStage/accessStage) collapses into the
sync2 one (accessScopes) it duplicated.

Drop the VkEvent split-barrier path. It only ever bought overlap between a
producer and its consumer, while each access already places a
self-sufficient barrier -- and it was the one primitive a portability-subset
implementation may not have at all (VUID-vkCreateEvent-events-04468). The
recorder's per-pass "already synchronized" set survives as the ownership
acquire's mark, renamed off the events it no longer comes from.

Fences follow: the one-shot setup submits wait a timeline instead.

Binary semaphores stay only where WSI mandates them -- vkAcquireNextImageKHR
signals one and vkQueuePresentKHR waits on one, with no timeline accepted --
so they are confined to SubmitExtras.

Requiring synchronization2 moves into frameDeviceRequirements and
HeadlessBoot, next to the helpers that now submit with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Timeline semaphores and synchronization2 are what everything here
synchronizes with, so vulkan-utils asks for them rather than each example
remembering to: syncDeviceRequirements, which frameDeviceRequirements is now
a swapchain plus. Both boots merge it in, so an example only lists what is
actually its own -- visibility-buffer drops to multiview, cube arrays and
multi-draw-indirect.

Copy-paste and tweak, or stay on older utils; we are committed to 1.3 in
this release train and these are not going away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Driver stopped building CommandBufferBeginInfo/CommandPoolCreateInfo when
pool and buffer creation moved to Vulkan.Utils.Frame, and resize's
atomicModifyIORef' went when the driver's register hook took over appending
to fGPUWork. GHC had been naming all three; the tree now builds warning-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plan-time aliasing needs to know whether two entries' lifetimes can overlap.
EntryInfo.live gives positions in execution order, but positions order the
executing passes globally while passes on different queues run concurrently
-- so disjoint positions do not imply disjoint lifetimes, and packing on
positions alone would corrupt silently.

Aliasing rides on the schedule's happens-before instead: a vector clock per
pass, folded from its queue predecessor and the passes its waits name, so
entry a may hand memory to b only when a's last pass is genuinely ordered
before b's first -- same queue, or a wait chain that reaches it.

Pure and tested first, before any VMA code exists: 13 cases, including the
one a positions-only planner gets wrong (disjoint ranges, concurrent queues,
no wait -- must not share).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hand-picked cases only test the cases I thought of, which is the wrong
property for code that corrupts silently. So enumerate the space instead:
every schedule of 5 passes over 3 queues with every combination of waits --
248832 of them -- and check happensBefore against an oracle derived the other
way round, by explicit edges and transitive closure rather than vector clocks.
An oracle sharing the implementation would prove nothing.

Also asserts the invariant the feature rests on, over the same space: whatever
planAliases puts in one block is pairwise ordered, so two tenancies can never
overlap.

0.74s for the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Macos does not retain swapchain images.
Add json viewer dump to vb.
@dpwiz
dpwiz force-pushed the framegraph branch 2 times, most recently from 794165c to 964c302 Compare July 25, 2026 11:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant