# 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.

```c
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

```c
// 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

```c
// 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

```c
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:

1. Start with `initial_capacity` textures (e.g., 16)
2. When all slots are occupied and a new one is needed, allocate a batch of
   `GROWTH_BATCH` (e.g., 8) additional textures
3. Never exceed `max` (e.g., 128)
4. If `max` is 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:

```c
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

```c
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_OPTIMAL` to
  `SHADER_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_count` combined
  image sampler bindings. Stored in `PrNodeTypeEntry.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:**
1. Reset the descriptor pool via `prRhiResetDescriptorPool`. This is much
   cheaper than create/destroy — it reuses the pool's internal memory.
2. 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 index
     - `type` — `PR_RHI_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER`
     - `image_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

1. **Pool allocator**: Implement `PrPool` in `src/prism/allocators/`.
   `prPoolInit`, `prPoolAlloc`, `prPoolFree`, `prPoolDestroy`. Self-managing
   growth via wapp OS allocation. Replace the ad-hoc `PrPool` in scratchpad/dag.c.

2. **Promote graph to production**: Move `PrGraph`, `PrNodeManager`, topology
   ops from `scratchpad/dag.c` to `src/prism/core/pr_graph.h/.c` and
   `pr_node.h/.c`. Clean up — remove the `main()` test harness.

3. **Define node type registry**: Create `PrNodeTypeEntry` table with resource
   signatures (input_count, output_count, push_constant_size). No shaders yet.

4. **Implement PrTexturePool**: Growth-based pool with refcount tracking.
   `prTexturePoolInit`, `prTexturePoolReset`, `prTexturePoolAcquire`,
   `prTexturePoolRelease`, `prTexturePoolDestroy`.

5. **Write blit.vert.slang**: Fullscreen triangle, no vertex buffer. Shared by
   all fragment-shader nodes. Compile to SPIR-V via `slangc`.

6. **Write initial frag shaders**: `read.frag.slang`, `blur.frag.slang`,
   `grade.frag.slang`, `blend.frag.slang`. Simple per-pixel operations.
   Compile to SPIR-V via `slangc`.

7. **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.

8. **Implement evaluation loop**: `prGraphEvaluate` — topo sort, refcount
   compute, pool reset, per-node dispatch, barrier insertion, final blit to
   swapchain.

9. **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 `PrPool` with standalone API. No external
  allocator parameter — pool allocates blocks via wapp OS allocation (`wpOsMemAlloc`
  / `wpOsMemFree`) and grows on demand. Handles slot sizes smaller than
  `sizeof(void*)` transparently via an internal `alloc_size`. Lives in
  `src/prism/allocators/`, outside vendored wapp.

- **READ node texture lifetime**: READ nodes hold a persistent `PrRhiTexture`
  (loaded via `prRhiCreateTextureFromKtx`) outside the pool. The pool slot's
  `texture` pointer 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.
