· 4 min read
The renderer stays yours
Spirdo stops at compiled shader bytes and reflection, leaving pipeline creation, frame scheduling, and draw calls to the host renderer.
Graphics libraries annex programs. You adopt one for a narrow job — loading a mesh, compiling a shader — and a year later your frame loop runs inside its scheduler and your types are its types. So here is what won me over in Spirdo's maintained example game — a function that does not exist: there is no Spirdo draw call anywhere in Crystal Run.
At commit 8bfd82d, the library's product boundary is compiled SPIR-V plus reflected shader metadata. The window, the meshes, the pipeline, the frame loop, and every drawModel call belong to Slop, the host renderer. You can test the scope by imagining Slop deleted: shader compilation would survive untouched, and nothing would draw.
| Work | Owner | Boundary artifact |
|---|---|---|
| Parse, type-check, and link WESL/WGSL | Spirdo | Successful shader compilation |
| Emit SPIR-V and reflect bindings | Spirdo | shaderSpirv / shaderPlan |
| Translate reflected slots to renderer descriptors | Adapter | requireShaderCounts |
| Create pipeline, meshes, and targets | Slop | graphicsPipeline |
| Schedule frames and issue draws | Slop | drawModel |
The handoff is executable#
Crystal Run's setup asks each typed shader for its SPIR-V bytes and its binding plan, a local adapter translates the plan into Slop's slot counts, and Slop builds the shader objects and the graphics pipeline from there.
vertexCounts <- requireShaderCounts "vertex shader" 1 gameVertexShadervertex <- createVertexShader (shaderSpirv gameVertexShader) vertexCountsfragmentCounts <- requireShaderCounts "fragment shader" 3 gameFragmentShaderfragment <- createFragmentShader (shaderSpirv gameFragmentShader) fragmentCountspipeline <- graphicsPipeline GraphicsDesc { gfxVertex = vertex , gfxFragment = fragment , gfxLayout = mesh3DLayout , gfxPrimitive = PrimTriangles , gfxTarget = TargetSwapchain , gfxDepth = DepthTestWrite }The adapter is deliberately strict. requireShaderCounts rejects any descriptor group or resource class the Slop integration cannot actually submit, and that refusal matters: without it, renderer independence decays into a byte array plus an undocumented promise about which slot means “texture.”
Spirdo's architecture document draws the same line the example does: parsing, imports, type checking, reflection, and SPIR-V emission end in a shader bundle, and pipeline policy begins in the host. After setup, the frame step clears the target and calls drawModel for the ground, pillars, crystals, and ship. Spirdo never reappears in the loop, because by then its outputs are ordinary renderer inputs.
Names cross with the bytes#
What does cross the boundary, besides bytes? Names and types. The reflected interface rides in the type of an opaque Shader, so host code provides values by source name and resource kind, and inputsFor checks completeness, packs the uniform layout, and normalizes everything into binding order.
frameInputs = inputsFor tintShader ( uniform @"params" (Params (V4 1.0 0.45 0.2 1.0)) <> sampledTexture @"tex" (TextureHandle 42) (SamplerHandle 7) )In this builder, uniform @"params" compiles only when the shader interface actually contains a uniform called params, and sampledTexture @"tex" carries the texture and sampler handles the renderer will recognize. Spirdo checks the shader-facing contract and never allocates a renderer object itself.
Reflection cannot pick the host's descriptor convention, and the example is honest about that. Crystal Run passes group one for vertex uniforms and group three for fragment uniforms because SDL_gpu's SPIR-V ABI assigns those locations, and the adapter owns the translation out loud. The backend's numbering rule stays out of the shader source, and Spirdo stops short of pretending every renderer shares one layout.
The narrowness is a real trade. A project with no renderer still owes itself buffers, passes, synchronization, presentation, and device policy, and Spirdo will bring none of them. Whether the boundary holds beyond the pinned examples I can't say — the adapters that would prove portability have not been written.
The boundary even shows up as an error string. Feed the SDL adapter a shader with a storage buffer and it answers renderer does not submit storage-buffer bindings — Spirdo got the name right, and the renderer has not yet learned to carry the thing.