20 KiB
Plan: Texture Pool + Node Evaluation
Goal
Design the texture pool and node-to-shader dispatch so the node DAG doubles as the frame graph. Each node type maps to a single Slang shader (no fusion). The texture pool enables concurrent branches by allowing multiple intermediate textures to coexist.
1. Pool Allocator
1.1 Purpose
A reusable pool allocator for fixed-size blocks. This replaces the ad-hoc
PrPool in scratchpad/dag.c and can be used for any fixed-size allocation
throughout the project: node structs, edge structs, texture slots, descriptor
sets, etc.
Lives in src/prism/allocators/, not in wapp. wapp is vendored and may be
replaced — the pool allocator must not be part of it.
The pool owns its memory. No external allocator is passed — the pool allocates blocks internally via wapp OS allocation and grows on demand when free slots run out.
1.2 Design
The pool manages fixed-size slots arranged in contiguous blocks. Free slots are
tracked via an intrusive free list (first sizeof(void*) bytes of each free
slot hold a pointer to the next free slot). When the free list is empty, the
pool allocates a new block of block_slots slots and carves them into the
free list.
typedef struct PrPool PrPool;
struct PrPool {
void **blocks; // array of allocated block pointers (for destroy)
u64 block_count; // number of allocated blocks
u64 block_cap; // capacity of blocks array
void *free_list; // intrusive free list head
u64 slot_size; // user-requested slot size
u64 alloc_size; // actual slot size used internally (>= slot_size, >= sizeof(void*))
u64 block_slots; // slots per block
u64 total; // total slots ever allocated (diagnostics)
u64 active; // currently in use (diagnostics)
};
1.3 API
// Initialise a pool.
// slot_size: fixed size of each slot
// initial_slots: starting capacity in slots (also used as block size)
void prPoolInit(PrPool *pool, u64 slot_size, u64 initial_slots);
// Allocate one slot. Grows by a new block if the free list is empty.
// Returns NULL only on allocation failure.
void *prPoolAlloc(PrPool *pool);
// Return a slot to the pool's free list. Safe no-op on NULL.
void prPoolFree(PrPool *pool, void *slot);
// Free all blocks and zero the pool.
void prPoolDestroy(PrPool *pool);
// Diagnostics
u64 prPoolTotalSlots(const PrPool *pool);
u64 prPoolActiveSlots(const PrPool *pool);
1.4 Behavior
| Operation | Implementation |
|---|---|
prPoolAlloc |
Pop from free list if non-empty, otherwise allocate a new block of block_slots slots via wapp OS allocation, link it into the blocks array, carve it into the free list, and pop. |
prPoolFree |
Push slot onto the intrusive free list. Safe no-op on NULL. |
prPoolDestroy |
Free every block in the blocks array, free the array itself, zero the struct. |
Block growth: each new block has block_slots slots (same size as the initial
block). The minimum block size is 4096 bytes — if slot_size * initial_slots
is smaller, block_slots is rounded up to the nearest multiple of slot_size
that meets the minimum. The blocks array starts at capacity 4 and doubles
when full.
1.5 Usage examples
// Edge pool (replaces PrPool in scratchpad/dag.c):
PrPool edge_pool;
prPoolInit(&edge_pool, sizeof(PrGraphEdge), 64);
PrGraphEdge *edge = prPoolAlloc(&edge_pool);
prPoolFree(&edge_pool, edge);
prPoolDestroy(&edge_pool);
// Texture slot pool:
PrPool tex_pool;
prPoolInit(&tex_pool, sizeof(PrTextureSlot), 16);
PrTextureSlot *slot = prPoolAlloc(&tex_pool);
prPoolDestroy(&tex_pool);
2. Texture Pool
2.1 Purpose
Intermediate textures (node outputs) need GPU resources. The texture pool manages a set of textures that are reused across graph evaluations. Without a pool, a linear chain of N nodes would need N textures. With refcount-based reuse, textures are returned to the pool as soon as all their consumers have executed, keeping the peak live count low.
2.2 Data structures
typedef struct PrTextureSlot {
PrRhiTexture *texture; // the GPU texture (SAMPLED | COLOR_ATTACHMENT)
u32 refcount; // how many downstream nodes still need to read this
b8 in_use; // currently assigned to a node's output
} PrTextureSlot;
typedef struct PrTexturePool {
PrPool slot_pool; // pool allocator for PrTextureSlot structs
PrTextureSlot *slots; // flat array for iteration (backed by slot_pool)
u32 count; // number of allocated slots
u32 max; // hard cap (never allocate beyond this)
u32 width; // texture width (matches window)
u32 height; // texture height (matches window)
} PrTexturePool;
All pool textures are RGBA16F, SAMPLED | COLOR_ATTACHMENT. Any free slot works for any node — no format/dimension matching needed.
The slot_pool is a PrPool allocator for PrTextureSlot structs. The slots
pointer provides flat-array access for iteration during evaluation. When the pool
grows, a new batch of slots is allocated via the pool allocator and the flat
array is extended.
2.3 Lifecycle
prTexturePoolInit(pool, device, initial_capacity, max, width, height)
→ creates pool allocator, allocates initial slot array
prTexturePoolReset(pool)
→ marks all slots as free, zeroes refcounts (called once per frame)
prTexturePoolAcquire(pool, device) -> PrTextureSlot*
→ returns a free slot (in_use = true)
→ if no free slot: allocate new slot + GPU texture, grow array
→ if max reached: abort with diagnostic message
prTexturePoolRelease(pool, slot)
→ marks slot as free (in_use = false)
→ called when refcount hits 0
prTexturePoolDestroy(pool, device)
→ destroys all GPU textures, destroys pool allocator
2.4 Allocation strategy (growth)
The pool does not pre-allocate all textures upfront. Instead:
- Start with
initial_capacitytextures (e.g., 16) - When all slots are occupied and a new one is needed, allocate a batch of
GROWTH_BATCH(e.g., 8) additional textures - Never exceed
max(e.g., 128) - If
maxis reached, abort with:"texture pool exhausted: N in use, max M"
Growth is amortized (batch allocation) and the pool never shrinks. The count
monotonically increases as textures are allocated on demand.
Why growth instead of fixed pre-allocation:
- Small graphs don't pay for 64 unused textures
- Complex graphs can grow beyond the initial allocation
- The hard cap prevents unbounded memory use
- vkCreateImage is only called when actually needed
2.5 Refcount management
Before evaluation, compute the initial refcount for each node's output:
refcount[node] = out_degree(node) // number of outgoing edges
During evaluation, when a node executes and reads an input texture:
input_slot->refcount -= 1
if (input_slot->refcount == 0):
prTexturePoolRelease(pool, input_slot)
This naturally handles:
- Linear chains: A→B→C. A's output refcount=1, freed after B executes.
- Fan-out: A→B, A→C. A's output refcount=2, freed after both B and C execute.
- Fan-in: B→D, C→D. B and C have independent refcounts, freed independently.
2.6 Texture dimensions
Pool textures are created at the window/swapchain resolution. All nodes operate at this resolution. If a node needs a different resolution (e.g., a half-resolution blur), it would need a separate mechanism — out of scope for V1.
3. Node-to-Shader Mapping
3.1 Type registry
A static table maps PrNodeType → shader modules + pipeline + resource
signatures:
typedef enum PrShaderType {
PR_SHADER_TYPE_FRAGMENT, // fullscreen triangle, per-pixel
PR_SHADER_TYPE_COMPUTE, // dispatch, shared memory
} PrShaderType;
typedef struct PrNodeTypeEntry {
PrNodeType type;
PrShaderType shader_type;
// shaders (pre-compiled SPIR-V, built from .slang via slangc)
const char *vertex_shader_path; // NULL for compute
const char *fragment_shader_path; // NULL for compute
const char *compute_shader_path; // NULL for fragment
// pipeline (created at init, cached here)
PrRhiPipeline *pipeline;
// resource signature
u32 input_count; // number of texture inputs (1 for blur, 2 for blend)
u32 output_count; // always 1 for V1
// descriptor set layout (created at init)
PrRhiDescriptorSetLayout *set_layout;
// push constant size (bytes)
u32 push_constant_size;
} PrNodeTypeEntry;
3.2 Registry instance
wp_persist PrNodeTypeEntry _node_type_table[COUNT_NODE_TYPES] = {
[PR_NODE_TYPE_READ] = {
.type = PR_NODE_TYPE_READ,
.shader_type = PR_SHADER_TYPE_FRAGMENT,
.vertex_shader_path = "assets/shaders/blit.vert.spv",
.fragment_shader_path= "assets/shaders/read.frag.spv",
.input_count = 0,
.output_count = 1,
.push_constant_size = 0,
},
[PR_NODE_TYPE_BLUR] = {
.type = PR_NODE_TYPE_BLUR,
.shader_type = PR_SHADER_TYPE_FRAGMENT,
.vertex_shader_path = "assets/shaders/blit.vert.spv",
.fragment_shader_path= "assets/shaders/blur.frag.spv",
.input_count = 1,
.output_count = 1,
.push_constant_size = sizeof(PrBlurPushConstants),
},
[PR_NODE_TYPE_GRADE] = {
.type = PR_NODE_TYPE_GRADE,
.shader_type = PR_SHADER_TYPE_FRAGMENT,
.vertex_shader_path = "assets/shaders/blit.vert.spv",
.fragment_shader_path= "assets/shaders/grade.frag.spv",
.input_count = 1,
.output_count = 1,
.push_constant_size = sizeof(PrGradePushConstants),
},
[PR_NODE_TYPE_BLEND] = {
.type = PR_NODE_TYPE_BLEND,
.shader_type = PR_SHADER_TYPE_FRAGMENT,
.vertex_shader_path = "assets/shaders/blit.vert.spv",
.fragment_shader_path= "assets/shaders/blend.frag.spv",
.input_count = 2,
.output_count = 1,
.push_constant_size = sizeof(PrBlendPushConstants),
},
};
3.3 Shader loading
Shaders are written in Slang (src/shaders/*.slang) and compiled to SPIR-V as
a build step via slangc. The .spv files are output to assets/shaders/. At
init, the application loads pre-compiled SPIR-V directly:
for each entry in _node_type_table:
load vertex shader SPIR-V from .spv file
load fragment/compute shader SPIR-V from .spv file
create PrRhiShader handles
create descriptor set layout (input_count combined image samplers)
create pipeline layout (set layout + push constant range)
create pipeline (vertex + fragment stages, dynamic rendering)
cache everything in the entry
3.4 Shaders per node type
| Node | Shader | Inputs | Push constants |
|---|---|---|---|
| READ | read.frag.spv |
0 (samples from KTX texture loaded separately) | — |
| BLUR | blur.frag.spv |
1 input texture | f32 radius |
| GRADE | grade.frag.spv |
1 input texture | f32 gain, f32 lift, f32 gamma |
| BLEND | blend.frag.spv |
2 input textures | u32 mode (over/under/add) |
All share blit.vert.spv (fullscreen triangle, no vertex buffer needed).
4. Evaluation Loop
4.1 Per-frame sequence
prGraphEvaluate(graph, device, pool, cb, swapchain_texture):
1. topo_order = prGraphTopologicalSort(graph)
2. // compute initial refcounts
for each node in graph:
node.output_refcount = out_degree(node)
3. prTexturePoolReset(pool)
4. // reset per-frame descriptor pool (allocated once at init, reset each frame)
prRhiResetDescriptorPool(device, desc_pool)
5. for each node_id in topo_order:
node = &nodes[node_id]
entry = &_node_type_table[node->type]
// acquire output texture from pool
output_slot = prTexturePoolAcquire(pool, device)
// gather input textures (from upstream nodes' output slots)
input_count = 0
input_slots[4] // max 4 inputs
for each upstream edge (upstream → node):
input_slots[input_count++] = upstream.output_slot
// allocate and update descriptor set
desc_set = prRhiAllocateDescriptorSet(device, desc_pool, entry->set_layout)
writes = stack_array(input_count)
for i in 0..input_count:
writes[i] = {
.dst_set = desc_set,
.dst_binding = i,
.dst_array_element = 0,
.type = PR_RHI_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.image_info = &(PrRhiDescriptorImageInfo){
.texture = input_slots[i]->texture,
.sampler = shared_sampler,
.layout = PR_RHI_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
},
}
prRhiUpdateDescriptorSet(device, writes)
// record commands
prRhiCmdBeginRendering(cb, output_slot->texture, ...)
prRhiCmdBindPipeline(cb, GRAPHICS, entry->pipeline)
prRhiCmdBindDescriptorSets(cb, GRAPHICS, entry->pipeline_layout, 0, 1, &desc_set, 0, NULL)
prRhiCmdPushConstants(cb, ..., node->params)
prRhiCmdDraw(cb, 3, 1, 0, 0) // fullscreen triangle
prRhiCmdEndRendering(cb)
// release input textures whose refcount hit 0
for each input_slot:
input_slot->refcount -= 1
if input_slot->refcount == 0:
prTexturePoolRelease(pool, input_slot)
// store output slot on node for downstream consumers
node->output_slot = output_slot
6. // final blit to swapchain
final_slot = last_node.output_slot
blit final_slot->texture → swapchain_texture
7. prRhiQueueSubmit(cb)
4.2 READ node special case
READ nodes load a texture from disk (KTX) via prRhiCreateTextureFromKtx.
The loaded texture is stored directly on the node (persistent, lives across
frames). Unlike other nodes, READ's input comes from this persistent texture
rather than from an upstream node's output slot.
READ nodes still render a fullscreen triangle that samples from the loaded texture and writes to the output pool texture. This allows the user to view the raw texture before any modifications, and ensures READ nodes participate uniformly in the evaluation pipeline.
READ nodes participate in refcount tracking like any other node: their output
slot's refcount is set to out_degree(READ), and downstream consumers
decrement it normally.
4.3 Barrier insertion
Between nodes that share a texture (one writes, next reads), a pipeline barrier is needed to transition the texture layout:
after node A executes (writes to texture T):
barrier: T from COLOR_ATTACHMENT → SHADER_READ_ONLY
before node B executes (reads texture T):
(barrier already inserted above)
In practice, the barrier is inserted after each node's render pass:
- Transition the output texture from
COLOR_ATTACHMENT_OPTIMALtoSHADER_READ_ONLY_OPTIMAL
The first node in a chain (READ) needs a transition from TRANSFER_DST to
SHADER_READ_ONLY after loading from disk. This is already handled by
prRhiCreateTextureFromKtx.
Layout transitions per node:
READ: UNDEFINED → TRANSFER_DST → SHADER_READ_ONLY (done by KTX loader)
BLUR: SHADER_READ_ONLY (input) → COLOR_ATTACHMENT (output, during render)
output transitions to SHADER_READ_ONLY after render pass
GRADE: same as BLUR
BLEND: same as BLUR (two inputs)
4.4 Descriptor management
Each node needs a descriptor set binding its input textures. The flow:
Init (once):
- Create a per-node-type descriptor set layout with
input_countcombined image sampler bindings. Stored inPrNodeTypeEntry.set_layout. - Create a persistent descriptor pool large enough for the worst-case node count (e.g., 128 sets). Created once, reused every frame.
Per frame:
- Reset the descriptor pool via
prRhiResetDescriptorPool. This is much cheaper than create/destroy — it reuses the pool's internal memory. - For each node during evaluation:
- Allocate a descriptor set from the pool using the node type's layout.
- Write each input texture into the set via
prRhiUpdateDescriptorSet. Each write specifies:dst_set/dst_binding— which set and binding indextype—PR_RHI_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLERimage_info— texture handle, shared sampler, layout
- Bind the set during rendering via
prRhiCmdBindDescriptorSets.
The pool lives for the lifetime of the application. Only its contents are reset each frame.
5. File layout
src/prism/allocators/
└── pr_pool_allocator.h / .c ← pool allocator (self-managing, wapp OS allocation)
src/prism/core/
├── pr_graph.h / .c ← promoted from scratchpad/dag.c
├── pr_node.h / .c ← PrNode, PrNodeType, PrNodeManager
├── pr_texture_pool.h / .c ← PrTexturePool
└── pr_node_eval.h / .c ← evaluation loop, type registry
src/shaders/ ← Slang source (compiled to assets/shaders/ via slangc)
├── blit.vert.slang ← fullscreen triangle (shared by all fragment nodes)
├── read.frag.slang ← passthrough (samples loaded texture)
├── blur.frag.slang ← gaussian blur
├── grade.frag.slang ← colour grading
└── blend.frag.slang ← alpha compositing
assets/shaders/ ← compiled SPIR-V output (loaded at runtime)
├── blit.vert.spv
├── read.frag.spv
├── blur.frag.spv
├── grade.frag.spv
└── blend.frag.spv
6. Implementation order
-
Pool allocator: Implement
PrPoolinsrc/prism/allocators/.prPoolInit,prPoolAlloc,prPoolFree,prPoolDestroy. Self-managing growth via wapp OS allocation. Replace the ad-hocPrPoolin scratchpad/dag.c. -
Promote graph to production: Move
PrGraph,PrNodeManager, topology ops fromscratchpad/dag.ctosrc/prism/core/pr_graph.h/.candpr_node.h/.c. Clean up — remove themain()test harness. -
Define node type registry: Create
PrNodeTypeEntrytable with resource signatures (input_count, output_count, push_constant_size). No shaders yet. -
Implement PrTexturePool: Growth-based pool with refcount tracking.
prTexturePoolInit,prTexturePoolReset,prTexturePoolAcquire,prTexturePoolRelease,prTexturePoolDestroy. -
Write blit.vert.slang: Fullscreen triangle, no vertex buffer. Shared by all fragment-shader nodes. Compile to SPIR-V via
slangc. -
Write initial frag shaders:
read.frag.slang,blur.frag.slang,grade.frag.slang,blend.frag.slang. Simple per-pixel operations. Compile to SPIR-V viaslangc. -
Wire up shader loading + pipeline creation: At init, load pre-compiled SPIR-V from
assets/shaders/, create descriptor set layouts, pipeline layouts, pipelines. Cache in the type registry. -
Implement evaluation loop:
prGraphEvaluate— topo sort, refcount compute, pool reset, per-node dispatch, barrier insertion, final blit to swapchain. -
Integrate with main loop: Replace the current mesh-rendering demo with a node graph evaluation. Create a test graph (Read→Blur→Blend) and render it to the swapchain.
7. Decisions
-
Pool allocator: Self-contained
PrPoolwith standalone API. No external allocator parameter — pool allocates blocks via wapp OS allocation (wpOsMemAlloc/wpOsMemFree) and grows on demand. Handles slot sizes smaller thansizeof(void*)transparently via an internalalloc_size. Lives insrc/prism/allocators/, outside vendored wapp. -
READ node texture lifetime: READ nodes hold a persistent
PrRhiTexture(loaded viaprRhiCreateTextureFromKtx) outside the pool. The pool slot'stexturepointer references this persistent texture. This means READ nodes don't consume pool slots — they just participate in refcount tracking. -
Sampler: Single shared sampler (linear filtering, clamp-to-edge) for all nodes in V1. Created once at init.
-
Push constant layout: Each node type defines its own push constant struct. The evaluation loop reads the node's params union and passes it via
prRhiCmdPushConstants. The shader declares matching layout.