· 4 min read

Typed shaders, visible Vulkan

vpipe’s typed triangle carries shader intent down to SPIR-V, and its particle example shows exactly where Vulkan still pokes through.

Henry Lerolle's painting of a singer and seated musicians gathered beside an organ in a bright church loft.
Henry Lerolle, Public domain

Try to find the shader in this triangle. There is no GLSL file anywhere in the example — the vertex transform is the ordinary Haskell function at the bottom of the block, and the things Vulkan will eventually demand (topology, vertex shape, interpolation, target format) live in the types wrapped around it.

examples/app/triangle/Main.hs (abridged)haskell
pipeline :: PipelineM Environment ()pipeline = do  input <- vertexInput    (vertexSource "positions" positions      :: VertexSource Environment 'Triangles (V3 Float))  fragments <- rasterize defaultRaster (fmap vertex input)  drawColor defaultBlend    (colorTarget "color" target      :: ColorTarget Environment 'B8G8R8A8Srgb)    (fmap unSmooth fragments) where  vertex position =    (vec4 (x position) (y position) (z position) (constant 1),     Smooth (constant (V4 1 0 0 1) :: V (V4 Float)))

At commit 22abaa7, compilePipeline lowers this description straight to SPIR-V. The pretty syntax works only because the public types hold on to enough Vulkan intent for pipeline creation and validation to happen later, and it is worth spelling out what they carry.

Typed declarationInformation preserved
VertexSource Environment 'Triangles (V3 Float)Triangle topology and three-float vertex input
ColorTarget Environment 'B8G8R8A8SrgbRender-target format fixed in the environment
Smooth (V (V4 Float))Perspective-correct interpolation for the color varying
PipelineM Environment ()A reusable pipeline waiting for concrete resources
The small triangle pipeline exposes the facts needed below the Haskell surface.

Each parameter closes off a familiar mismatch before any Vulkan object exists. Topology travels with the vertex source, the varying records its own interpolation mode, and the color expression has to fit the target's format. Resources are fields of the environment — no string lookups at frame-record time, which is exactly when you least want a typo discovered.

Compilation still produces an artifact for a lower layer: SPIR-V plus the resource and stage information the runtime uses to build Vulkan objects. The types preserve intent through that handoff without creating a device or picking its queues.

One allocation crosses two passes#

The particle example is where the resource story gets good. One buffer declares both Storage and Vertex roles, the environment takes two typed views of it, and an ordered frame writes through one view before rendering through the other.

examples/src/Vpipe/Examples/Particles.hs (abridged)haskell
positions <- newBuffer context particleCount  :: IO (Buffer '[Storage, Vertex] (V4 Float))let environment = ParticleEnvironment      { particleStorage = storageBufferBinding positions      , particleVertices = vertexBufferBinding positions      , particleTarget = target      }computePassFor preparedCompute environment  (toInteger particleCount, 1, 1)renderTo target (render preparedGraphics environment)

The frame tracker sees a compute write followed by a graphics vertex read and emits the required transition itself. Anyone who has hand-written buffer barriers knows how many ways there are to get that wrong; here the barrier is a consequence, derived from program order and the buffer's role list. No second position buffer, no host-side copy between the passes.

The role list earns its keep elsewhere too. Buffer '[Storage, Vertex] (V4 Float) rejects an accidental use as an index buffer, and the element type keeps the compute writer and the vertex reader on one representation, so the tracker reasons about a single allocation changing use rather than two handles that happen to alias.

One caveat before the enthusiasm runs away: everything above comes from reading the source. No benchmark or physical-GPU run backs this article, and a heavier workload may want resource scheduling that the ordered-frame model does not offer.

Device policy still reaches the surface#

Typed streams cannot conjure a capability the shader language does not express. The particle pipeline renders points, PointSize is missing from the current EDSL, and so the example requests VK_KHR_maintenance5 — an entire device extension enlisted so that a point may legally be one pixel wide without a shader output saying so.

examples/src/Vpipe/Examples/Particles.hs (abridged)haskell
enableMaintenance5 config =  config    { extraDeviceExtensions =        KHR_MAINTENANCE_5_EXTENSION_NAME : extraDeviceExtensions config    , vpipeLogicalDeviceBuilder = maintenance5DeviceBuilder    }enabledMaintenance5 =  (zero :: PhysicalDeviceMaintenance5FeaturesKHR)    { maintenance5 = True }

I like that the library is honest here. vpipe names buffer roles, formats, shader stages, pass order, and most synchronization consequences, and when an application needs a feature outside that surface, it hands over a logical-device builder instead of pretending the need away.

The builder is plain Vulkan policy written in Haskell: it adds the extension name, enables the feature structure, carries the 1.2 timeline-semaphore and 1.3 synchronization feature chains, and creates queue records from the selected device's family union — below the typed shader description, still inside your application.

The pretty frame ends after renderTo. Scroll further down the same source file and the feature chains and queue records are all still there, under a function frankly named maintenance5DeviceBuilder — a better boundary marker than one more claim that Vulkan has been abstracted away.