Skip to content

Advanced shader usage

This page described the more advanced features that could be used to implement your more advanced GPU programs for live-demos.

Passes run in the order they are streamed into the slide, show << producer << consumer, and each primitive's updater runs right after that primitive has drawn.

Render target

The offscreen target every pass draws into. Independent of the size the primitive is shown at on the slide.

Signature Effect
static ShaderPtr FromFile(const path& file, int w = 0, int h = 0) w/h set the render resolution; <= 0 keeps the window's own
static ShaderPtr Add(const std::string& src, int w = 0, int h = 0) same, from a source string
void setResolution(int w, int h) render resolution in pixels, what iResolution reports
int bufferWidth() const / int bufferHeight() const that resolution
void setFloatBuffer(bool on = true) RGBA32F attachments instead of 8-bit
void setFilter(Filter f) how this shader's own target is sampled
void setWrap(Wrap w) Wrap::Clamp (default) or Wrap::Repeat
void setHidden(bool on = true) keep rendering every frame, never blit onto the slide
enum class Shader::Filter { Nearest, Linear };
enum class Shader::Wrap   { Clamp, Repeat };

A hidden pass still has to be streamed into the slide to run: setHidden suppresses the blit, not the render. State that is integrated rather than looked at wants setFloatBuffer() and Filter::Nearest.

Textures

A pass samples its inputs through named samplers. Declare one in the shader and hand it a source from C++:

uniform sampler2D noise;
uniform vec2      noise_size;   // optional, its size in pixels
fx->setTexture("noise", "noise.png");

The name is the link, exactly like any other uniform, and every texture reports its size as <name>_size if the shader bothers to declare it.

Signature Source bound to name
void setTexture(const std::string& name, const path& image_file, Filter f = Filter::Linear, Wrap w = Wrap::Clamp) an image file, loaded once
void setTexture(const std::string& name, const ShaderPtr& src, int attachment = 0) another pass's current output
void setTextureSelf(const std::string& name, int attachment = 0) this pass's previous frame (ping-pong)
void setTexture(const std::string& name, const float* data, int w, int h, int comps = 1, Filter f = Filter::Linear, Wrap wrap = Wrap::Clamp) a CPU array as a float texture
void setTexture(const std::string& name, const std::vector<float>& data, int w, int h, int comps = 1, Filter f = Filter::Linear, Wrap wrap = Wrap::Clamp) same, from a vector
void clearTexture(const std::string& name) unbind, freeing the texture if the pass owned one
void clearTextures() the same for all of them
void retainTextures(const std::vector<std::string>& names) drop every file-backed texture whose name is not listed

comps is components per texel, 1 to 4 (R, RG, RGB, RGBA), so the array overloads read w * h * comps floats in row-major order. Calling one again with the same size and component count updates the texture in place instead of recreating it, which is what makes a per-frame upload cheap.

setTextureSelf allocates a second target and alternates between the two, so the shader samples what it wrote last frame while writing the current one.

How many textures one pass can bind at once is the driver's texture unit count, at least 16 and in practice 32, less one kept aside for the scene depth buffer.

retainTextures exists for a declarative owner such as the deck, which re-declares its whole set on every hot reload: anything no longer named goes away, and the ones still declared keep their GL objects. Data textures and inter-pass ones are left alone, since they were set from code the manifest never saw.

ShaderToy channels

iChannel0..3 are four reserved texture names, declared for you by the prelude:

uniform sampler2D iChannel0;             // .. iChannel3
uniform vec3      iChannelResolution[4]; // (w, h, 1) per channel

setChannel, setChannelSelf, setData and clearChannel are the calls above under those reserved names, so a shader written for ShaderToy runs here unchanged. That is all the compatibility amounts to: a channel does nothing a named texture cannot, and a name saves you from remembering what was in slot 2.

Multiple render targets

fragColor is location 0 and is already declared. Extra outputs are declared explicitly, up to four in total, and location 0 must not be redeclared:

layout(location = 1) out vec4 oPosition;
Signature Effect
void setTargets(int n) number of colour attachments, 1 to 4
int targets() const that number

The slide always shows attachment 0. The others are reached through setTexture(name, src, attachment) downstream, or readback(out, attachment) from the CPU.

Storage buffers

Buffers the shader reads and writes, and the only way a fragment shader writes anywhere other than its own pixel:

layout(std430, binding = 0) buffer Density { uint density[]; };
Signature Effect
void setBuffer(int binding, const void* data, std::size_t bytes) upload, creating the buffer if needed
template<class T> void setBuffer(int binding, const std::vector<T>& v) same, v.size() * sizeof(T) bytes
void allocBuffer(int binding, std::size_t bytes) reserve a zeroed buffer, uploading nothing
bool readBuffer(int binding, void* dst, std::size_t bytes) const read back; false if there is no buffer at that binding
template<class T> bool readBuffer(int binding, std::vector<T>& v) const same, into a vector you have already sized
void clearBufferData(int binding, unsigned int value = 0) refill the whole buffer with value, GPU-side (GL 4.3; a no-op below that)
void shareBuffer(int binding, const ShaderPtr& src, int src_binding) bind another pass's buffer here as well
void clearBuffer(int binding) drop this binding; the allocation dies with its last holder

Re-uploading the same byte count reuses the allocation, so a setBuffer per frame is a copy and not a reallocation. A memory barrier is issued after every draw, so writes are visible to a later pass in the same frame and to readBuffer.

shareBuffer gives two passes one allocation with no copy: the producer must be streamed first, and the source must already hold a buffer at src_binding. A binding shared into a pass is bound and readable from C++ even if that pass's GLSL never declares it.

Core GLSL has atomicAdd, atomicMin, atomicMax, atomicExchange and atomicCompSwap on uint and int, but nothing on float, so accumulating a real quantity means scaling it into fixed point and dividing on the way out. Size the scale so that contributors × largest contribution × scale stays well under 2³²: an overflow is silent.

Readback

Signature Returns
bool readback(std::vector<float>& out, int attachment = 0) const the whole attachment, 4 floats per texel, row-major, bottom-up. false if nothing has been rendered yet
RGBA readbackMean(int attachment = 0) const the mean over the attachment
RGBA readbackPixel(int x, int y, int attachment = 0) const one texel

An 8-bit target comes back normalised to 0..1, a float target exact. All three are synchronous: the call returns only once the GPU has caught up, and that stall, not the byte count, is what a per-frame readback costs. Reducing on the GPU and reading a handful of bytes out of a buffer is the cheap alternative, which is what the example below does.

An updater runs after its pass has drawn, so a readback there sees the frame that was just rendered.

Full example

Everything above in one program: a 65536-particle simulation living in a float texture, scattering itself into a shared density grid with atomics, coloured by a second pass, with the peak density reduced on the GPU and read back as four bytes to normalise the display.

main.cpp
#include "slope.h"
#include <vector>
#include <cmath>
using namespace slope;

Slideshow show;

constexpr int   N    = 256;     // N*N particles, one texel each
constexpr int   GRID = 128;     // density grid, GRID*GRID cells
constexpr int   M    = 64;      // the CPU field's resolution
constexpr float FIX  = 4096.0f; // fixed-point scale, shared with the shaders

int main(int argc, char** argv) {
    show.init("shader_io", argc, argv);

    // a field computed on the CPU, uploaded once as a data texture
    std::vector<float> weight(M * M);
    for (int y = 0; y < M; ++y)
        for (int x = 0; x < M; ++x) {
            float dx = (x + 0.5f) / M * 2.f - 1.f;
            float dy = (y + 0.5f) / M * 2.f - 1.f;
            weight[y * M + x] = std::exp(-6.f * (dx * dx + dy * dy));
        }

    // pass 1 : the simulation, compute only
    auto sim = Shader::FromFile("sim.frag", N, N);
    sim->setFloatBuffer();                    // state, not colour
    sim->setFilter(Shader::Filter::Nearest);  // a texel is a particle
    sim->setTextureSelf("state");             // last frame's state
    sim->setTexture("field", weight, M, M);   // the CPU field
    sim->setHidden();
    sim->allocBuffer(0, GRID * GRID * sizeof(unsigned));   // density grid
    sim->allocBuffer(1, sizeof(unsigned));                 // peak density
    sim->set("uAttract", 0.35f);

    // pass 2 : the view, reading what pass 1 filled
    auto view = Shader::FromFile("view.frag", 900, 900);
    view->shareBuffer(0, sim, 0);   // one grid, two passes, no copy
    view->shareBuffer(1, sim, 1);   // read from C++ below, never declared in view.frag

    float peak = 1.f;
    view->bind("uPeak", [&peak] { return peak; });

    // last updater of the frame : read the reduction, then zero both
    // accumulators for the next one
    view->updater = [view, &peak](TimeObject) {
        unsigned raw = 0;
        view->readBuffer(1, &raw, sizeof(raw));            // 4 bytes across the bus
        peak = 0.95f * peak + 0.05f * std::max(1.f, float(raw) / FIX);
        view->clearBufferData(0);
        view->clearBufferData(1);
    };

    show << sim << view->at(CENTER);   // producer first
    show.run();
    return 0;
}
sim.frag
// one texel = one particle : rg = position, ba = velocity
uniform sampler2D state;    // this shader's previous frame
uniform sampler2D field;    // the CPU field

layout(std430, binding = 0) buffer Density { uint density[]; };
layout(std430, binding = 1) buffer Peak    { uint peak[]; };

uniform float uAttract;

const float DT   = 0.008;
const float FIX  = 4096.0;
const int   GRID = 128;

float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }

void main() {
    ivec2 id = ivec2(gl_FragCoord.xy);
    vec4 s = texelFetch(state, id, 0);
    vec2 p = s.xy, v = s.zw;

    if (iFrame < 1) {                     // seeded here, nothing uploaded at startup
        float a = 6.2831853 * hash(vec2(id));
        float r = 0.25 + 0.55 * hash(vec2(id) + 7.0);
        p = r * vec2(cos(a), sin(a));
        v = 0.9 * sqrt(r) * vec2(-sin(a), cos(a));
    }

    float w = texture(field, p * 0.5 + 0.5).r;
    vec2  g = -uAttract * p / pow(dot(p, p) + 0.01, 1.5);
    v += g * (1.0 + 3.0 * w) * DT;
    p += v * DT;

    ivec2 cell = ivec2((p * 0.5 + 0.5) * float(GRID));
    if (all(greaterThanEqual(cell, ivec2(0))) && all(lessThan(cell, ivec2(GRID)))) {
        uint before = atomicAdd(density[cell.y * GRID + cell.x], uint(FIX));
        atomicMax(peak[0], before + uint(FIX));       // the reduction
    }

    fragColor = vec4(p, v);               // the next frame's state
}
view.frag
#include <colormap.glsl>

layout(std430, binding = 0) buffer Density { uint density[]; };

uniform float uPeak;

const float FIX  = 4096.0;
const int   GRID = 128;

void main() {
    vec2 uv = gl_FragCoord.xy / iResolution;
    ivec2 cell = clamp(ivec2(uv * float(GRID)), ivec2(0), ivec2(GRID - 1));
    float d = float(density[cell.y * GRID + cell.x]) / FIX;
    fragColor = vec4(inferno(clamp(d / uPeak, 0.0, 1.0)), 1.0);
}

Reading the frame order once makes the rest fall out. Per frame: sim draws (scatters into the grid, writes the new state), view draws (reads the grid), view's updater runs (reads the peak, zeroes both buffers for the next frame). Zeroing from view rather than from sim is deliberate: sim's updater would run before view had drawn, and would hand it an empty grid.