Fixes and research

This commit is contained in:
2026-08-08 20:22:37 +01:00
parent 30408ff244
commit f5ff6c70ea
8 changed files with 592 additions and 17 deletions
+554
View File
@@ -0,0 +1,554 @@
# 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.