Using shaders for slit scan

Greetings,
I am trying to recreate @perevalovds sampled slit scan example from his book “Mastering openFrameworks” but using glsl, as my project has other heavier CPU side processing.
When I compile the code below I only get a black screen where the image is to be drawn.

I think I see what I am doing wrong, that is not accessing the correct texture coordinate in shader.frag but I dont seam to be able to figure out how to do that correctly. For reference you can see the code here

Here is my relevant code below

// shader.vert

#version 460

uniform mat4 modelViewProjectionMatrix;
in vec4 position;
in vec2 texcoord;
out vec2 texCoord;

void main()
{
    gl_Position = modelViewProjectionMatrix * position;
    texCoord = texcoord;
}

// shader.frag

#version 460

uniform sampler2D frames;
uniform vec2 mousePos;
uniform int fSize;
in vec2 texCoord;
out vec4 outputColor;

void main()
{
    float dist = distance(mousePos, texCoord.xy);
    float f = dist / 8;

    int i0 = int(f);
    int i1 = i0+1;

    float interopAmt = i1 - f;

    i0 = clamp(i0, 0, fSize);
    i1 = clamp(i1, 0, fSize);

    vec2 co1, co2;
    co1.x = i0;
    co1.y = i0;

    co2.x = i1;
    co2.y = i1;

    vec4 col0 = texture(frames,co1);
    vec4 col1 = texture(frames,co2);

    outputColor = col1 * interopAmt + (col1 * (1 - interopAmt));
}

//ofApp.h

 ofVideoGrabber video;
    deque<ofPixels> frames;
    int N;
    ofTexture imgTexture;
    ofImage image;
    ofShader shader;

//ofApp.cpp

void ofApp::setup()
{
    video.initGrabber(640, 480);
    ofSetLogLevel(OF_LOG_VERBOSE);
    N = 150;
    shader.load("shader.vert", "shader.frag");
    image.allocate(640, 480, OF_IMAGE_COLOR);
}

//--------------------------------------------------------------
void ofApp::update()
{
    video.update();
    glm::vec2 mousePos(mouseX, mouseY);
    shader.setUniform2f("mousePos", mousePos);

    if (video.isFrameNew()) {
        frames.push_front(video.getPixels());

        if (frames.size() > N) {
            frames.pop_back();
        }
    }

    shader.setUniform1i("fSize", frames.size());
    if (!frames.empty()) {
        imgTexture.loadData(frames[0]);
        shader.setUniformTexture("frames", imgTexture, 0);
    }
}

//--------------------------------------------------------------
void ofApp::draw()
{
    ofBackground(255, 255, 255);

    shader.begin();
    // image.bind();
    image.draw(0, 0);
    shader.end();
    // image.unbind();
}

Would someone be kind enought to take out the time to read through this mess and help me?

Hi @opensourceartist,

I have not run the code but I suspect one issue might came from the texture coordinates you are using. See, when you’re not indexing from a buffer or a 1d texture your coordinates shall be normalized floating point numbers (unless you’re playing with the wrapping mode).

You might want to try :

void main()
{
    float dist = distance(mousePos, texCoord.xy);
    float f = dist / 8;

    int i0 = int(f);
    int i1 = i0+1;

    float interopAmt = i1 - f;

    vec2 co1 = vec2( clamp(i0, 0, fSize) / float(fSize) );
    vec2 co2 = vec2( clamp(i1, 0, fSize) / float(fSize) );

    vec4 col0 = texture(frames, co1);
    vec4 col1 = texture(frames, co2);

    outputColor = mix( col0, col1, interopAmt);
}

You can also use a smoothstep for a more organic effect if your not looking for linearity :
vec2 co1 = vec2( smoothstep( 0, fSize, i0) );

See http://docs.gl/smoothstep

Thank you for your response. the way you advice to do interop and normalizing makes absolutely perfect sense.
The problem I am having, now I understand, is that I need to access the deque<ofPixels> frames from the fragment shader. The i0 and i1 in my fragment shaders are for the indices of the deque “frames” to pick the texCoord.xy from.

I honestly have no idea how to pass the entire deque in a way that fragment shader can calculate the index to access from within and ask for the texture coordinates from those corresponding ofPixels in the deque.

like this

//somehow->
uniform sampler2Darrays frames[fSize];

void main()
{
    float dist = distance(mousePos, texCoord.xy);
    float f = dist / 8;

    int i0 = int(f);
    int i1 = i0+1;

    float interopAmt = i1 - f;

    i0 = clamp(i0, 0, fSize);
    i1 = clamp(i1, 0, fSize);
    
    vec4 col0 = texture(frames[i0], texCoord.xy);
    vec4 col1 = texture(frames[i1],texCoord.xy);

    outputColor = col1 * interopAmt + (col1 * (1 - interopAmt));
}

So the question boils down to, can I send an array/vector/deque of ofTextures to shaders;

Any ideas?

Hey @opensourceartist , do you know how many textures you need to access in the shader, and is it a fixed number or does it vary? If you google sampler2Darray you’ll probably find some info about this glsl type, what it is, and how it works. I’ve not used it before.

An easy approach might be to capture the previous iterations of the slitscan in an ofFbo. Depending on the nature of the slitscan, this ofFbo could be modified each cycle (ie shift 20 pixels to the left) before the newest texture is incorporated. The ofFbo basically captures the “state” of the slitscan for each cycle.

If lots of new textures are involved, you may be able to do something iteratively with a couple/few/several textures at a time, and a couple of ofFbo, reading from one, writing to the other (ping-pong), swapping, and repeating as necessary until all of the textures have been processed. This could run in a loop in ofApp::update(). Something like this might work well for a slitscan application, but it might be kinda slow though.

Also regarding this line:

    outputColor = col1 * interopAmt + (col1 * (1 - interopAmt));

glsl (via glm) has a nice function mix() for this:

outputColor = mix(col0, col1, interopAmt);

Shaders are super fun, really fast, and powerful! Have fun and don’t be afraid to experiment a whole bunch!

@TimChi thank you for your response and for the fbo suggestion. Although I had seen the multiple textuer and ping pong examples long before, it was a good referesher to go through them again.
Since I dont have only a handful of textures to cross-sample between, the FBO might not be a solution for me.

I did try to implement sampler2darray but it seems like it is sampling a single co-ordinate’s texture and setting the same on ALL fragments. This is just so confusing

seeing as glsl has no cout or other convenient debug processes, no clue where I am going wrong. here is my updated example if someone knows hows to go about this and willing to help

//ofApp.h
    ofVideoGrabber video;
    deque<ofPixels> frames;
    int N;
    GLuint texture = 0;
    ofImage image;
    ofShader shader;
//ofApp.cpp
void ofApp::setup()
{
    video.initGrabber(640, 480);
    ofSetLogLevel(OF_LOG_VERBOSE);
    N = 150;
    shader.load("shader.vert", "shader.frag");
    image.allocate(640, 480, OF_IMAGE_COLOR);

    glGenTextures(1, &texture);
    glBindTexture(GL_TEXTURE_2D_ARRAY, texture);

    GLsizei width = 640;
    GLsizei height = 480;
    GLsizei layerCount = N;
    GLsizei mipLevelCount = 1;
    glTexStorage3D(GL_TEXTURE_2D_ARRAY, mipLevelCount, GL_RGB8, width, height, layerCount);

    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

    glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
}

//--------------------------------------------------------------
void ofApp::update()
{
    video.update();
    glm::vec2 mousePos(mouseX, mouseY);
    shader.setUniform2f("mousePos", mousePos);

    if (video.isFrameNew()) {
        frames.push_front(video.getPixels());
    }
    if (frames.size() > N) {
        frames.pop_back();
    }

    shader.setUniform1i("fSize", frames.size());

    if (!frames.empty()) {
        for (int i = 0; i < frames.size(); i++) {
            glTexSubImage3D(GL_TEXTURE_2D_ARRAY, i, 0, 0, 0, 640, 480, 1, GL_RGB, GL_UNSIGNED_BYTE, frames[i].getData());
        }
    }
}

//--------------------------------------------------------------
void ofApp::draw()
{
    ofBackground(255, 255, 255);

    glActiveTexture(GL_TEXTURE0 + texture);
    glClientActiveTexture(GL_TEXTURE0 + texture);
    glEnable(GL_TEXTURE_2D_ARRAY);
    glBindTexture(GL_TEXTURE_2D_ARRAY, texture);

    shader.begin();
    shader.setUniformTexture("texArray", GL_TEXTURE_2D_ARRAY, texture, 0);
    image.draw(0, 0);
    shader.end();

    glActiveTexture(GL_TEXTURE0 + texture);
    glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
    glDisable(GL_TEXTURE_2D_ARRAY);
    glActiveTexture(GL_TEXTURE0);
}
//vertex shader
#version 460

uniform mat4 modelViewProjectionMatrix;
in vec4 position;
in vec2 texcoord;
out vec2 texCoord;

void main()
{
    gl_Position = modelViewProjectionMatrix * position;
    texCoord = texcoord;
}
//Fragment shader
#version 460

uniform sampler2DArray texArray;
uniform vec2 mousePos;
uniform int fSize;
in vec2 texCoord;
out vec4 outputColor;

void main()
{
    float dist = distance(mousePos.xy, texCoord.xy);
    float f = dist / 8;

    int i0 = int(f);
    int i1 = i0+1;

    float interopAmount = i1 - f;

    i0 = clamp(i0, 0, fSize);
    i1 = clamp(i1, 0, fSize);

    vec4 col0 = texture(texArray,vec3(texCoord, i0 ) );
    vec4 col1 = texture(texArray,vec3(texCoord, i1 ) );
    outputColor = mix(col0, col1, interopAmount);
}

I think if you’re seeing colors of any kind that you’re getting close! There are a lot of gl calls involved now, which I’m not very familiar with.

Maybe try calling ofDisableArbTex() is ofApp::setup. The sampler2Darray may have normalized texcoord, like a sampler2D, and it may want textures that are square and a power of 2

Sometimes when the screen is a solid color, its because a normalized texture (0.0 - 1.0) is getting sampled “out of range” (with large values of texCoord.xy). And conversely a non-normalized texture can get sampled repetitively at the same spot because tecCoord.xy has been normalized. Also make sure the other shader variables, like mousePos, have similar coordinates (either normalized or not).

@TimChi
Pardon my ignorance, but could you please suggest how I would go about either verifying texCoord’s normalized state or normalizing it myself? would I do this in the frag shader with something like

vec2 newTexCoord = texCoord / uniformBoundImagePixelCount

or is that wrong?

P.S:
I tried

vec2 newTex = vec2(texCoord.x/640, texCoord.y/480);

but it does not change my result.

Are you following the code from this post and also this github page? Mantissa has a note at the bottom of the page: Note that this example uses non ARB texture coordinates, which range from 0.0 to 1.0 not from 0.0 to image width/height. I think the texture coordinates are normalized. So in the shader, make sure everything else, like the mousePos, is normalized. Also (probably) nevermind about calling ofDisableArbTex(). You could always ping one of these forum posters for some specific help too.

Hey @opensourceartist Lydia, the code below is a working version for 2 ofImages. Its based off of Mantissa’s code with some tweaks, and also the Array Texture wiki was helpful too. Hopefully it will provide a starting point:
ofApp.h:

#pragma once
#include "ofMain.h"

class ofApp : public ofBaseApp{
public:
    void setup();
    void update();
    void draw();

    ofShader shader;
    ofImage textures[2];
    GLuint texture;
};

ofApp.cpp:

#include "ofApp.h"
//--------------------------------------------------------------
void ofApp::setup(){
    shader.load("shader.vert", "shader.frag");

    images[0].load("image0.jpg");
    images[1].load("image1.jpg");

    // set up the array texture
    GLsizei width = 1920;
    GLsizei height = 1080;
    GLsizei layerCount = 2;
    GLsizei mipLevelCount = 1;

    // GLuint ofApp::texture holds the handle for the texture array
    glGenTextures(1, &texture);
    glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
    glTexStorage3D(GL_TEXTURE_2D_ARRAY, mipLevelCount, GL_RGB8, width, height, layerCount);
    glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, 0, width, height, 1, GL_RGB, GL_UNSIGNED_BYTE, images[0].getPixels().getData());
    glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, 1, width, height, 1, GL_RGB, GL_UNSIGNED_BYTE, images[1].getPixels().getData());

    glTexParameteri(GL_TEXTURE_2D_ARRAY,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D_ARRAY,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D_ARRAY,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D_ARRAY,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE);

    glBindTexture(GL_TEXTURE_2D_ARRAY,0);

}
//--------------------------------------------------------------
void ofApp::update(){

}
//--------------------------------------------------------------
void ofApp::draw(){

    glActiveTexture(GL_TEXTURE0 + texture);
    glClientActiveTexture(GL_TEXTURE0 + texture);

    glEnable(GL_TEXTURE_2D_ARRAY);
    glBindTexture(GL_TEXTURE_2D_ARRAY, texture);

    shader.begin();
    shader.setUniformTexture("texture", GL_TEXTURE_2D_ARRAY, texture, 0);
    shader.setUniform2f("resolution", images[0].getWidth(), images[0].getHeight());
    shader.setUniform1i("texID", ofGetMousePressed() ? 1 : 0);
    images[0].draw(0.f, 0.f); // tex0 and texcoord
    shader.end();

    glActiveTexture(GL_TEXTURE0+texture);
    glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
    glDisable(GL_TEXTURE_2D_ARRAY);
    glActiveTexture(GL_TEXTURE0);
}

shader.vert:

#version 330

uniform mat4 modelViewProjectionMatrix;
in vec4 position;
in vec2 texcoord;

out vec2 vTexCoord;

void main()
{
    gl_Position = modelViewProjectionMatrix * position;
    vTexCoord = texcoord;
}

shader.frag:

#version 330

uniform sampler2DArray textures;
uniform int texID;

uniform vec2 resolution;
in vec2 vTexCoord;

out vec4 fragColor;

void main()
{
    // ofImage::draw() provides the texcoord, which are not normalized
    vec2 tc = vTexCoord / resolution;

    // the texcoord in textures are normalized though
    vec3 color = texture(textures, vec3(tc, texID)).rgb;
    fragColor = vec4(color, 1.0);
}
2 Likes

Thank you so much.

After your previous reply I did try the example by mantissa, but because of the normalization issue I was getting garbled pixel artifacts.

Thank you so so much for taking out the time to implelent it yoruself and share it with me. The example you shared works perfectly.
And I am already getting some results (with segfaults but correct results) for my earlier example as well, after using the normalizations that you suggested. I have just moved the 2D_ARRAY binding code to update from setup so it can do it iteratively based on newer video frames.

This is such a wonderful community. Makes learning oF and opengl so much easier.
Cant thank you enough

Hey sure and glad its working better! I learned a lot too which is why I had a look at it, and also because its a really useful concept that I had no clue about before. The forum is a great way to learn new corners of oF.

Right now, I’m trying to see if I can change 1 image in the sampler2DArray, instead of recreating the whole thing. If that works, only 1 image could be updated each cycle, and some kind of indexing scheme (an offset or an array of index values) could be used to access the sampler2DArray in the shader.

@TimChi

I worked on it by debugging using colors, basically setting the variables as red or green color for the values I want to enquire (inpace of cout) and now have it working.
Normalizing the texCoords only for sampling but not for other operations like mouseDistance etc… and selectively normalizing others, passing other values as Uniforms etc…

Here is the working code if it interests you

Passing in a massive array of texutres, that are sampled based on mouse distance and other parameters, basically like scrubbing through texture (and video) history for the slitting but from shaders.
I can imagine using this technique for all varieity of history based shader manipulationg stuff.

//ofApp.h

ofVideoGrabber video;
    deque<ofPixels> frames;
    unsigned N;
    GLuint texture;
    ofImage image;
    ofShader shader;
    glm::vec2 mousePos;

    GLsizei width;
    GLsizei height;
    GLsizei layerCount;
    GLsizei mipLevelCount;

//cpp

void ofApp::setup()
{
    N = 300;
    width = 640;
    height = 480;
    layerCount = N;
    mipLevelCount = 1;

    video.initGrabber(width, height);
    ofSetLogLevel(OF_LOG_VERBOSE);
    shader.load("shader.vert", "shader.frag");

   // for some reason just allocating without loading causes a segfault. No idea why
    // image.allocate(width, height, OF_IMAGE_COLOR);
    image.load("1.jpg");
    image.resize(width, height);

    glGenTextures(1, &texture);
    glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
    glTexStorage3D(GL_TEXTURE_2D_ARRAY, mipLevelCount, GL_RGB8, width, height, layerCount);
    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
}
void ofApp::update()
{
    video.update();
    if (video.isFrameNew()) {
        frames.push_front(video.getPixels());
    }
    if (frames.size() > N) {
        frames.pop_back();
    }

    glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
    if (!frames.empty()) {
        for (unsigned i = 0; i < frames.size(); i++) {
            glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, i, width, height, 1, GL_RGB, GL_UNSIGNED_BYTE, frames[i].getData());
        }
    }
    glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
}
void ofApp::draw()
{
    ofBackground(255, 255, 255);
    video.draw(0, 0);

    glActiveTexture(GL_TEXTURE0 + texture);
    glClientActiveTexture(GL_TEXTURE0 + texture);
    glEnable(GL_TEXTURE_2D_ARRAY);
    glBindTexture(GL_TEXTURE_2D_ARRAY, texture);

    shader.begin();
    shader.setUniform2f("mousePos", abs(mouseX), abs(mouseY));
    shader.setUniform1i("fSize", frames.size());
    shader.setUniformTexture("texArray", GL_TEXTURE_2D_ARRAY, texture, 0);
    shader.setUniform2f("resolution", frames[0].getWidth(), frames[0].getHeight());
    image.draw(0, 0);
    shader.end();
    glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
}

vertex shader

#version 460

uniform mat4 modelViewProjectionMatrix;
in vec4 position;
in vec2 texcoord;
out vec2 texCoord;

void main()
{
    gl_Position = modelViewProjectionMatrix * position;
    texCoord = texcoord;
}

fragment shader

#version 460

uniform sampler2DArray texArray;
uniform vec2 resolution;
uniform vec2 mousePos;
uniform int fSize;
in vec2 texCoord;
out vec4 outputColor;

void main()
{

    vec2 tc = texCoord / resolution;
    float dist = distance(mousePos.xy, texCoord.xy);
    float f = dist / 8;

    int i0 = int(f);
    int i1 = i0+1;

    float interopAmount = i1 - f;

    i0 = clamp(i0, 0, fSize);
    i1 = clamp(i1, 0, fSize);

    vec3 col0 = texture(texArray, vec3(tc, i0)).rgb;
    vec3 col1 = texture(texArray, vec3(tc, i1)).rgb;
    outputColor = mix(vec4(col0,1.0), vec4(col1,1.0), interopAmount);
}

Thank you so much for your help. This has opened up new doors for my projects and ideas.
Something I did not understand was declaring uniforms outside shader.begin() like mouse co-ordinates just passed in the uniform as zero. but passing it within the begin end block worked as intended

1 Like

Hey I was wondering about setting variables in the shader outside of ofShader::begin() and end(), but never tested it and glad to know that you have call these to access the shader variables. I’ve only ever done it that way, but had no idea that you have to do it that way for it to work.

One thing I’ve noticed over time is that I don’t have to send the variables that don’t change to the shader each cycle. So, if I have a texture that is the same, I can set it once in ofApp::setup() and then it seems to stay there and accessible to the shader until I set it with something else.

On an older laptop with a Skylanke i7 and HD 520 graphics, calling glTexSubImage3D() lots of times per cycle is really super slow. But calling it once with a new texture to replace the oldest texture in the sampler2DArray works much better (60 fps). It requires a scheme in the fragment shader to calculate the index from the layerCount and a location of the newest texture (which I called layerOffset), which gets incremented between 0 and layerCount. So, there is a way to use the sampler2DArray as the only place where the images for the slitscan are stored. I also found I’m limited to 128 images on that laptop.

Also, I think I learned that the “slow” part of the transfer from cpu to gpu might arise from setting up the transfer, rather than from the amount of texture data in each transfer. So, 1 transfer per cycle with 128 textures (1920x1080 RGB) is much much faster than I thought it would be. And I wonder how much slower it would be to make 128 transfers with 1 texture each, so I may have to test that a bit more.

So fun to work on this! I learned a lot and had lots of fun too!

Right good to know for sure that uniforms/attribs have to be bound within begin-end calls.

As to calling the glTexSubImage3D() several times. for my specific example its important as i am passing in newer textures from the camera on each call, the ofImage just exists as the placeholder to be drawn onto. I suppose i could even draw a rectangle and get the same result.
But the frames from the videoCamera are beign stored in a deque, added from front, removed from back and they get sent as textures . The vector between mouse and current fragCoord is part of selecting the right ‘index’ for those passed in textures. So I have no choice but to call them multiple times.

I do have an optimus laptop with hybrid(nvidia) graphics that does the work, integratd intel graphics I guess just does not have hte memory to handle all the textures to be sampled. The process inside the fragment shader itself does not seem to be heavy as its just doing a couple of vector math + reading from 2 textureSamplers at max, per fragmentShader execution. Just handling the textures in memory is the demanding part

Yes, but I’d have problem doing it this way because of all those calls to glTexSubImage3d(). So I just wanted to point out that any one texture in the sampler2DArray can be updated with the newest texture, and only 1 call to glTexSubImage3D() is required per cycle to achieve the slitscan. The other textures will remain as they are until they are replaced. The group will no longer be ordered from newest to oldest, but that’s OK because an offset value (float or int) can be used to describe the position of the newest texture in the sampler2DArray. The next-newest texture is at offset-1; the oldest texture is at offset+1.

ofApp::update()

void ofApp::update(){
    videoPlayer.update();
    // updating the entire sampler2DArray each time with all the textures is very slow;
    // so use an indexing scheme in the fragment shader,
    // and just make 1 call to glTexSubImage3D() with the newest texture
    if(videoPlayer.isFrameNew())
    {
        glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
        glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, layerOffset, width, height, 1, GL_RGB, GL_UNSIGNED_BYTE, videoPlayer.getPixels().getData());
        glBindTexture(GL_TEXTURE_2D_ARRAY, 0);

        layerOffset += 1;
        if(layerOffset > layerCount) {layerOffset = 0;}
    }
}

fragment shader

#version 330

uniform sampler2DArray textures;
uniform vec2 resolution;
uniform float layerOffset; // the newest layer in the textures array
uniform float layerCount; // the max number of layers in the textures array
in vec2 vTexCoord;
out vec4 fragColor;

void main()
{
    vec2 tc = vTexCoord / resolution.xy;

    // use distance from the center, or any other point, either variable or constant
    float index = 1.0 - distance(vec2(0.5), tc);
    index *= layerCount;
    index += layerOffset;
    if(index > layerCount) {index -= layerCount;}
    vec3 color = texture(textures, vec3(tc, floor(index))).rgb;

    fragColor = vec4(color, 1.0);
}