Рендеринг плитки с opengl - PullRequest
       78

Рендеринг плитки с opengl

2 голосов
/ 23 октября 2019

Давайте начнем с рассмотрения этого простого фрагмента:

import ctypes
import textwrap
import time

import glfw
import numpy as np
from OpenGL.GL import *
from OpenGL.GLU import *
from OpenGL.GLUT import *

import glm

GLSL_VERSION = "#version 440\n"
CONTEXT_VERSION = (4, 1)


def vs_shader(text):
    return GLSL_VERSION + textwrap.dedent(text)


def shader(text):
    prefix = textwrap.dedent("""\
        uniform float iTime;
        uniform int iFrame;
        uniform vec3 iResolution;
        uniform sampler2D iChannel0;
        uniform vec2 iOffset;
        out vec4 frag_color;
    """)
    suffix = textwrap.dedent("""\
        void main() {
            mainImage(frag_color, gl_FragCoord.xy + iOffset);
        }
    """)

    return GLSL_VERSION + prefix + textwrap.dedent(text) + suffix


VS = vs_shader("""\
    layout(location = 0) in vec3 in_position;

    uniform mat4 mvp;

    void main()
    {
        gl_Position = mvp * vec4(in_position, 1.0f);
    }
""")

SIMPLE = [
    shader("""
        void mainImage( out vec4 fragColor, in vec2 fragCoord )
        {
            vec2 uv = fragCoord.xy / iResolution.xy;
            float tile_size = 4;
            vec2 g = floor(vec2(tile_size, tile_size) * uv);
            float c = mod(g.x + g.y, 2.0);
            if (uv.x<0.5 && uv.y<0.5)
                fragColor = vec4(mix(vec3(c), vec3(1), vec3(1,0,1)), 1.0);
            else if (uv.x>=0.5 && uv.y<0.5)
                fragColor = vec4(mix(vec3(c), vec3(1), vec3(1,0,0)), 1.0);
            else if (uv.x<0.5 && uv.y>=0.5)
                fragColor = vec4(mix(vec3(c), vec3(1), vec3(0,1,0)), 1.0);
            else if (uv.x>=0.5 && uv.y>=0.5)
                fragColor = vec4(mix(vec3(c), vec3(1), vec3(0,0,1)), 1.0);
        }
    """),
    shader("""
        void mainImage( out vec4 fragColor, in vec2 fragCoord )
        {
            vec2 uv = fragCoord/iResolution.xy;
            fragColor = vec4(texture(iChannel0, uv).rgb,1.0);
        }
    """)
]


# -------- MINIFRAMEWORK --------
class Tiler:

    def __init__(self, scene_width, scene_height):
        self.scene_width = scene_width
        self.scene_height = scene_height

    @classmethod
    def from_num_tiles(cls, scene_width, scene_height, num_tiles_x, num_tiles_y):
        obj = cls(scene_width, scene_height)
        obj.num_tiles_x = num_tiles_x
        obj.num_tiles_y = num_tiles_y
        obj.tile_width = obj.scene_width // num_tiles_x
        obj.tile_height = obj.scene_height // num_tiles_y
        return obj

    @classmethod
    def from_size(cls, scene_width, scene_height, tile_width, tile_height):
        obj = cls(scene_width, scene_height)
        obj.num_tiles_x = obj.scene_width // tile_width
        obj.num_tiles_y = obj.scene_height // tile_height
        obj.tile_width = tile_width
        obj.tile_height = tile_height
        return obj

    @property
    def num_tiles(self):
        return self.num_tiles_y * self.num_tiles_x


class TextureF32():

    def __init__(self, width, height):
        target = GL_TEXTURE_2D
        self.target = target
        self.identifier = glGenTextures(1)

        glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
        glBindTexture(target, self.identifier)
        glTexImage2D(target, 0, GL_RGBA32F, width, height, 0, GL_RGBA, GL_FLOAT, None)
        glTexParameteri(target, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
        glTexParameteri(target, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
        self.set_filter()

        glBindTexture(target, 0)

    def set_filter(self):
        glTexParameteri(self.target, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
        glTexParameteri(self.target, GL_TEXTURE_MIN_FILTER, GL_NEAREST)

    def bind(self):
        glBindTexture(self.target, self.identifier)

    def unbind(self):
        glBindTexture(self.target, 0)


class FboF32():

    def __init__(self, width, height):
        self.target = GL_FRAMEBUFFER
        self.identifier = glGenFramebuffers(1)
        glBindFramebuffer(GL_FRAMEBUFFER, self.identifier)

        # Color attachments
        tex = TextureF32(width, height)
        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex.identifier, 0)
        glDrawBuffers(1, [GL_COLOR_ATTACHMENT0])
        self.colors = [tex]

        self.width = width
        self.height = height

        if glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE:
            raise Exception(
                f"ERROR::FRAMEBUFFER:: Framebuffer {self.identifier} is not complete!"
            )

        glBindFramebuffer(GL_FRAMEBUFFER, 0)

    def delete(self):
        self.glDeleteFramebuffers(self.identifier)

    def rect(self):
        return [0, 0, self.width, self.height]

    def bind(self):
        glBindFramebuffer(GL_FRAMEBUFFER, self.identifier)


def set_uniform1f(prog, name, v0):
    glUniform1f(glGetUniformLocation(prog, name), v0)


def set_uniform1i(prog, name, v0):
    glUniform1i(glGetUniformLocation(prog, name), v0)


def set_uniform2i(prog, name, v0, v1):
    glUniform2i(glGetUniformLocation(prog, name), v0, v1)


def set_uniform2f(prog, name, v0, v1):
    glUniform2f(glGetUniformLocation(prog, name), v0, v1)


def set_uniform3f(prog, name, v0, v1, v2):
    glUniform3f(glGetUniformLocation(prog, name), v0, v1, v2)


def set_uniform_mat4(prog, name, mat):
    glUniformMatrix4fv(glGetUniformLocation(prog, name), 1, GL_FALSE, glm.value_ptr(mat))


def set_uniform_texture(prog, name, resource, unit_texture):
    glActiveTexture(GL_TEXTURE0 + unit_texture)
    resource.bind()
    resource.set_filter()
    glUniform1i(glGetUniformLocation(prog, name), 0 + unit_texture)


def create_quad(x0, y0, x1, y1):
    data = np.array([
        x0, y0, 0,
        x1, y0, 0,
        x0, y1, 0,

        x1, y0, 0,
        x1, y1, 0,
        x0, y1, 0,
    ], dtype=np.float32)

    vbo = glGenBuffers(1)
    glBindBuffer(GL_ARRAY_BUFFER, vbo)
    glBufferData(GL_ARRAY_BUFFER, data, GL_STATIC_DRAW)

    vao = glGenVertexArrays(1)
    glBindVertexArray(vao)
    glVertexAttribPointer(0, 3, GL_FLOAT, False, 0, ctypes.c_void_p(0))
    glEnableVertexAttribArray(0)

    return vao


def compile(shader_type, source):
    identifier = glCreateShader(shader_type)
    glShaderSource(identifier, source)
    glCompileShader(identifier)

    if not glGetShaderiv(identifier, GL_COMPILE_STATUS):
        for i, l in enumerate(source.splitlines()):
            print(f"{i+1}: {l}")
        raise Exception(glGetShaderInfoLog(identifier).decode("utf-8"))

    return identifier


def create_program(vs, fs):
    vs_identifier = compile(GL_VERTEX_SHADER, vs)
    fs_identifier = compile(GL_FRAGMENT_SHADER, fs)

    program = glCreateProgram()
    glAttachShader(program, vs_identifier)
    glAttachShader(program, fs_identifier)
    glLinkProgram(program)
    if not glGetProgramiv(program, GL_LINK_STATUS):
        raise RuntimeError(glGetProgramInfoLog(program))

    return program


# -------- Glut/Glfw --------
class Effect:

    def __init__(self, w, h, num_tiles_x, num_tiles_y, passes):
        self.fbos = []
        self.needs_updating = True
        self.allocations = 0
        self.tiler = Tiler.from_num_tiles(w, h, num_tiles_x, num_tiles_y)

        self.passes = [create_program(VS, rp) for rp in passes]
        self.iframe = 0
        self.start_time = time.time()

        self.quad = create_quad(-1, -1, 1, 1)
        self.view = glm.lookAt(
            glm.vec3(0, 0, 10),
            glm.vec3(0, 0, 0),
            glm.vec3(0, 1, 0)
        )
        self.model = glm.mat4(1)
        glEnable(GL_DEPTH_TEST)

        # print("GL_MAX_VIEWPORT_DIMS:", glGetIntegerv(GL_MAX_VIEWPORT_DIMS))
        # print("GL_MAX_TEXTURE_SIZE:", glGetIntegerv(GL_MAX_TEXTURE_SIZE))
        # print("GL_MAX_RENDERBUFFER_SIZE:", glGetIntegerv(GL_MAX_RENDERBUFFER_SIZE))

    def mem_info(self):
        GL_GPU_MEM_INFO_TOTAL_AVAILABLE_MEM_NVX = 0x9048
        GL_GPU_MEM_INFO_CURRENT_AVAILABLE_MEM_NVX = 0x9049
        total_mem_kb = glGetIntegerv(GL_GPU_MEM_INFO_TOTAL_AVAILABLE_MEM_NVX)
        cur_avail_mem_kb = glGetIntegerv(GL_GPU_MEM_INFO_CURRENT_AVAILABLE_MEM_NVX)
        return f"total_mem_kb={total_mem_kb} cur_avail_mem_kb={cur_avail_mem_kb}"

    def create_fbo(self, tiler):
        return [
            FboF32(width=tiler.tile_width, height=tiler.tile_height)
            for i in range(tiler.num_tiles)
        ]

    def make_ortho(self, x, y, num_tiles_x, num_tiles_y, left, right, bottom, top, near, far):
        # References
        #
        # https://www.opengl.org/archives/resources/code/samples/advanced/advanced97/notes/node20.html
        # /8008426/sdelaite-snimok-okna-opengl-s-ochen-bolshim-razresheniem-izobrazheniya
        #
        offset_x = (right - left) / num_tiles_x
        offset_y = (top - bottom) / num_tiles_y
        l = left + offset_x * x
        r = left + offset_x * (x + 1)
        b = bottom + offset_y * y
        t = bottom + offset_y * (y + 1)
        n = near
        f = far
        print(f"x={x} y={y} left={l} right={r} bottom={b} top={t}")
        return glm.ortho(l, r, b, t, n, f)

    def render_pass(self, rp, mvp, w, h, channel0, offset_x=0, offset_y=0):
        t = time.time() - self.start_time

        glBindVertexArray(self.quad)
        glUseProgram(rp)
        set_uniform_mat4(rp, "mvp", mvp)
        set_uniform1f(rp, "iTime", t)
        set_uniform1i(rp, "iFrame", self.iframe)
        set_uniform3f(rp, "iResolution", w, h, w / h)
        set_uniform2f(rp, "iOffset", offset_x, offset_y)
        if channel0:
            set_uniform_texture(rp, "iChannel0", channel0, self.active_texture)
            self.active_texture += 1
        glDrawArrays(GL_TRIANGLES, 0, 6)

    # No tile rendering
    def render_no_tiles(self, window_width, window_height):
        self.active_texture = 0

        if self.needs_updating:
            if not self.fbos:
                print(f"Creating fbos, allocations={self.allocations} {self.mem_info()}")
                self.fbos = [
                    FboF32(width=window_width, height=window_height),
                    FboF32(width=window_width, height=window_height)
                ]

        # clear buffers
        if self.iframe == 0:
            for fbo in self.fbos:
                fbo.bind()
                glViewport(*fbo.rect())
                glClearColor(0, 0, 0, 0)
                glClear(GL_COLOR_BUFFER_BIT)

        proj = glm.ortho(-1, 1, -1, 1, -100, 100)
        mvp = proj * self.view * self.model

        # Pass0: BufferA - Channels [BufferA, None, None, None]
        fbo0 = self.fbos[0]
        fbo1 = self.fbos[1]
        w, h = fbo0.width, fbo0.height
        rp = self.passes[0]
        fbo0.bind()
        glViewport(0, 0, w, h)
        self.render_pass(rp, mvp, w, h, fbo1.colors[0])

        # Pass1: Image - Channels [BufferA, None, None, None]
        glBindFramebuffer(GL_FRAMEBUFFER, 0)
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
        fbo0 = self.fbos[0]
        w, h = window_width, window_height
        rp = self.passes[1]
        glViewport(0, 0, w, h)
        self.render_pass(rp, mvp, w, h, fbo0.colors[0])

        # ping-pong
        self.fbos.reverse()

        self.iframe += 1

    # Tile rendering
    def render_tiles(self, window_width, window_height):
        M = self.tiler.num_tiles_x
        N = self.tiler.num_tiles_y
        offset_x = window_width // M
        offset_y = window_height // N
        proj = glm.ortho(-1, 1, -1, 1, -100, 100)

        # -------- Test --------
        # glBindFramebuffer(GL_FRAMEBUFFER, 0)
        # glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
        # self.active_texture = 0

        # for y in range(N):
        #     for x in range(M):
        #         w, h = window_width, window_height
        #         mvp = proj * self.view * self.model
        #         glViewport(offset_x * x, offset_y * y, self.tiler.tile_width, self.tiler.tile_height)
        #         self.render_pass(self.passes[0], mvp, w, h, None, offset_x * x, offset_y * y)

        # return

        # -------- Test2 --------
        self.active_texture = 0

        if self.needs_updating:
            if not self.fbos:
                print(f"Creating fbos, allocations={self.allocations} {self.mem_info()}")
                self.fbos = [
                    self.create_fbo(self.tiler),
                    self.create_fbo(self.tiler),
                ]

        # clear buffers
        if self.iframe == 0:
            for fbo_tiles in self.fbos:
                for fbo in fbo_tiles:
                    fbo.bind()
                    glViewport(*fbo.rect())
                    glClearColor(0, 0, 0, 0)
                    glClear(GL_COLOR_BUFFER_BIT)

        # Pass0: BufferA - Channels [BufferA, None, None, None]
        for y in range(N):
            for x in range(M):
                fbo0 = self.fbos[0][y * M + x]
                fbo1 = self.fbos[1][y * M + x]
                w, h, aspect = fbo0.width, fbo0.height, fbo0.width / fbo0.height
                mvp = proj * self.view * self.model
                rp = self.passes[0]
                fbo0.bind()
                glViewport(0, 0, self.tiler.tile_width, self.tiler.tile_height)
                self.render_pass(rp, mvp, w, h, fbo1.colors[0], offset_x * x, offset_y * y)

        # Pass1: Image - Channels [BufferA, None, None, None]
        glBindFramebuffer(GL_FRAMEBUFFER, 0)
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

        for y in range(N):
            for x in range(M):
                fbo0 = self.fbos[0][y * M + x]
                fbo1 = self.fbos[1][y * M + x]
                w, h, aspect = window_width, window_height, window_width / window_height
                mvp = proj * self.view * self.model
                rp = self.passes[1]
                glViewport(offset_x * x, offset_y * y, self.tiler.tile_width, self.tiler.tile_height)
                self.render_pass(rp, mvp, w, h, fbo0.colors[0], 0, 0)

        # ping-pong
        self.fbos.reverse()

        self.iframe += 1


class WindowGlut:

    def __init__(self, w, h, use_tiles, num_tiles_x, num_tiles_y, passes):
        glutInit()
        glutInitContextVersion(*CONTEXT_VERSION)
        glutInitContextProfile(GLUT_CORE_PROFILE)
        glutInitContextFlags(GLUT_FORWARD_COMPATIBLE)
        glutSetOption(GLUT_MULTISAMPLE, 16)
        glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH | GLUT_MULTISAMPLE)
        glutInitWindowSize(w, h)
        glutCreateWindow('Mcve')
        glutReshapeFunc(self.reshape)
        glutKeyboardFunc(self.keyboard_func)
        glutKeyboardUpFunc(self.keyboard_up_func)
        glutDisplayFunc(self.display)
        glutIdleFunc(self.idle_func)
        self.keys = {chr(i): False for i in range(256)}
        self.effect = Effect(w, h, num_tiles_x, num_tiles_y, passes)

        self.start_time = time.time()
        self.num_frames = 0

        if use_tiles:
            print("TILE RENDERING ENABLED")
            self.render = self.effect.render_tiles
        else:
            print("TILE RENDERING DISABLED")
            self.render = self.effect.render_no_tiles

    def keyboard_func(self, *args):
        self.keys[args[0].decode("utf8")] = True

    def keyboard_up_func(self, *args):
        self.keys[args[0].decode("utf8")] = False

    def display(self):
        if self.keys['r']:
            self.effect.iframe = 0

        self.render(self.window_width, self.window_height)

        glutSwapBuffers()
        self.num_frames += 1

        t = time.time() - self.start_time
        if t >= 1:
            glutSetWindowTitle(f"Fps: {self.num_frames}")
            self.start_time = time.time()
            self.num_frames = 0

    def run(self):
        glutMainLoop()

    def idle_func(self):
        glutPostRedisplay()

    def reshape(self, w, h):
        glViewport(0, 0, w, h)
        self.window_width = w
        self.window_height = h


class WindowGlfw:

    def __init__(self, w, h, use_tiles, num_tiles_x, num_tiles_y, passes):
        # Initialize the library
        if not glfw.init():
            return

        # Create a windowed mode window and its OpenGL context
        glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, CONTEXT_VERSION[0])
        glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, CONTEXT_VERSION[1])
        glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, GL_TRUE)
        glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE)
        window = glfw.create_window(w, h, "Mcve", None, None)
        if not window:
            glfw.terminate()
            return

        glfw.set_window_size_callback(window, self.reshape)
        glfw.set_key_callback(window, self.keyboard_func)

        # Make the window's context current
        glfw.make_context_current(window)
        self.window = window

        self.keys = {chr(i): False for i in range(256)}
        self.effect = Effect(w, h, num_tiles_x, num_tiles_y, passes)
        self.window_width = w
        self.window_height = h

        if use_tiles:
            print("TILE RENDERING ENABLED")
            self.render = self.effect.render_tiles
        else:
            print("TILE RENDERING DISABLED")
            self.render = self.effect.render_no_tiles

    def keyboard_func(self, window, key, scancode, action, mods):
        self.keys[chr(key)] = action

    def display(self):
        if self.keys['R']:
            self.iframe = 0

        self.render(self.window_width, self.window_height)

    def run(self):
        window = self.window

        while not glfw.window_should_close(window):
            self.display()
            glfw.swap_buffers(window)
            glfw.poll_events()

        glfw.terminate()

    def reshape(self, window, w, h):
        glViewport(0, 0, w, h)
        self.window_width = w
        self.window_height = h


if __name__ == '__main__':
    params = {
        "w": 320,
        "h": 240,
        "use_tiles": True,
        "num_tiles_x": 2,
        "num_tiles_y": 2,
        "passes": SIMPLE
    }
    use_glut = True
    WindowGlut(**params).run() if use_glut else WindowGlfw(**params).run()

Для запуска этого кода вам необходимо установить numpy, pyopengl, glfw, PyGLM. Вы можете переключаться между glfw или glut, переключая переменную use_glut. Я добавил эту опцию, так как кажется, что в некоторых случаях запуск glut на macosx может быть сложным.

В любом случае, цель этого потока состоит в том, чтобы выяснить, как исправить ошибочный фрагмент кода для правильного рендеринга плитки, так какВы можете видеть, что сейчас реализована очень наивная попытка.

В блоке main вы можете указать, хотите ли вы использовать метод рендеринга с использованием плиток или нет (переменная use_tiles), если вы выберетеиспользуя плитки, вам нужно указать их количество (num_tiles_x, num_tiles_y).

Случаи:

  1. Если вызапустите его с "use_tiles": False, и вы увидите следующее:

    enter image description here

    , что вывод правильный

  2. Если вы запустите его с "use_tiles": True, "num_tiles_x": 2, "num_tiles_y": 2, вы должны увидеть тот же результат, что и 1). Также исправьте

  3. Но если вы запустите его с "use_tiles": True, "num_tiles_x": 4, "num_tiles_y": 4 или выше, вы увидите полностью испорченное изображение, как показано ниже:

    enter image description here

ВОПРОС: В чем ошибка моего кода рендеринга плитки, который выдает неправильный вывод? Как бы вы это исправили?

Также ... Даже если код исправлен, способ, которым я пытаюсь сделать рендеринг плиток, довольно наивен, и он не будет работать очень хорошо при работе с более сложными эффектами, гдепроходы должны считываться с соседних плиток или даже с худшими, не смежными плитками. Для случая смежных плиток мне сказали, что добавление некоторого отступа к плиткам будет работать очень хорошо, но для более общего случая я понятия не имею, как вы решили эту проблему. В любом случае, один шаг за раз, целью этой темы будет исправление глючного фрагмента

1 Ответ

2 голосов
/ 23 октября 2019

В первом проходе отдельная плитка отображается в кадровом буфере, размер которого точно соответствует размеру плитки. gl_FragCoord.xy - это (0,0) внизу слева от плитки. uv = (0,0) должно быть в левом нижнем углу окна и uv = (1, 1) в правом верхнем углу окна. Чтобы вычислить координату uv относительно окна, необходимо добавить смещение плитки к gl_FragCoord.xy и разделить на размер окна:

формула (псевдокод):

uv = (gl_FragCoord.xy + (offset_x*x, offset_y*y)) / (window_width, window_height)

    +------------------+
    |                  |
    |    +----+        |
    |    |    |        |
    |    +----+        |
    |  (0,0) tile = gl_FragCoord.xy
    |                  |
    +------------------+
 (0,0) window

В первом проходе iResolution должно быть (window_width, window_height) и iOffset должно быть (offset_x * x, offset_y * y).

# Pass0: BufferA - Channels [BufferA, None, None, None]
for y in range(N):
    for x in range(M):
        fbo0 = self.fbos[0][y * M + x]
        fbo1 = self.fbos[1][y * M + x]
        mvp = proj * self.view * self.model
        rp = self.passes[0]
        fbo0.bind()

        glViewport(0, 0, self.tiler.tile_width, self.tiler.tile_height)

        w, h   = window_width, window_height
        aspect = window_width / window_height
        self.render_pass(rp, mvp, w, h, fbo1.colors[0], offset_x * x, offset_y * y)

Во втором проходе один фрагмент читается из текстуры и выводится в окно (кадровый буфер по умолчанию 0). Исходная текстура (плитка) имеет в точности размер плитки, и координата uv должна быть рассчитана относительно текстуры плитки. gl_FragCoord.xy - это (0,0) в левом нижнем углу окна. uv = (0,0) должно быть внизу слева от плитки и uv = (1, 1) в правом верхнем углу плитки. Для вычисления координаты uv смещение плитки должно быть вычтено из gl_FragCoord.xy, а результат должен быть разделен на размер заголовка:

формула (псевдокод)

uv = (gl_FragCoord.xy - (offset_x*x, offset_y*y)) / (tile_width, tile_height)

    +------------------+
    |                  |
    |    +----+        |
    |    |    |        |
    |    +----+        |
    |  (0,0) tile      |
    |                  |
    +------------------+
  (0,0) window = gl_FragCoord.xy

На втором проходе iResolution должно быть (self.tiler.tile_width, self.tiler.tile_height) и iOffset должно быть (-offset_x * x, -offset_y * y).

# Pass1: Image - Channels [BufferA, None, None, None]
glBindFramebuffer(GL_FRAMEBUFFER, 0)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

for y in range(N):
    for x in range(M):
        fbo0 = self.fbos[0][y * M + x]
        fbo1 = self.fbos[1][y * M + x]
        mvp = proj * self.view * self.model
        rp = self.passes[1]

        glViewport(offset_x*x, offset_y*y, self.tiler.tile_width, self.tiler.tile_height)

        w, h   = self.tiler.tile_width, self.tiler.tile_height
        aspect = self.tiler.tile_width / self.tiler.tile_height
        self.render_pass(rp, mvp, w, h, fbo0.colors[0], -offset_x * x, -offset_y * y)

Правка для mcve.py

В этом случае целью рендеринга всегда является кадровый буфер с размером плитки. 2-й проход рендеринга ("Pass1") читает измозаика и сохраняется в целевую плитку, поэтому 2-й проход должен быть:

# Pass1: Image - Channels [BufferA, None, None, None]
for y in range(N):
    for x in range(M):
        fbo_dst = self.fbo_target[0][y * M + x]
        fbo_src = self.fbos[0][y * M + x]
        mvp = proj * self.view * self.model
        rp = self.passes[1]

        fbo_dst.bind()
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
        glViewport(0, 0, self.tiler.tile_width, self.tiler.tile_height)

        w, h   = self.tiler.tile_width, self.tiler.tile_height
        aspect = self.tiler.tile_width / self.tiler.tile_height
        self.render_pass(rp, mvp, w, h, fbo_src.colors[0], 0, 0)

Еще одной проблемой является чтение текстуры из предыдущего кадра в шейдере фрагментов. Размер текстуры всегда равен размеру плитки. Нижняя левая координата текстуры (0, 0), а верхняя правая координата (1, 1). Поэтому для вычисления координаты текстуры (st) необходимо пропустить смещение, а разрешение определяется размером текстуры (textureSize):

void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
    initSpheres();

    # issue is here
    // vec2 st = fragCoord.xy / iResolution.xy; <--- delete
    vec2 st = gl_FragCoord.xy / vec2(textureSize(iChannel0, 0));

    // [...]

    // Moving average (multipass code)
    vec3 color = texture(iChannel0, st).rgb * float(iFrame);
    // [...]
}

См. Результат:

Если вы не хотите изменять код шейдера в mainImage, то вам нужен другой подходсистема и делегировать текстуру для поиска другой функции, с помощью макроса. Например:

def shader(tileTextureLookup, text):

    prefix = textwrap.dedent("""\
        uniform float iTime;
        uniform int iFrame;
        uniform vec3 iResolution;
        uniform sampler2D iChannel0;
        uniform vec2 iOffset;
        out vec4 frag_color;
    """)

    textureLookup = ""
    if tileTextureLookup:
        textureLookup = textwrap.dedent("""\
            vec4 textureTile(sampler2D sampler, vec2 uv) {
                vec2 st = (uv * iResolution.xy - iOffset.xy) / vec2(textureSize(sampler, 0));
                return texture(sampler, st); 
            }

            #define texture textureTile
        """)

    suffix = textwrap.dedent("""\
        void main() {
            mainImage(frag_color, gl_FragCoord.xy + iOffset);
        }
     """)

    return GLSL_VERSION + prefix + textureLookup + textwrap.dedent(text) + suffix
SMALLPT_MULTIPASS = [
    shader(True, """\
        // All code here is by Zavie (https://www.shadertoy.com/view/4sfGDB#)

        // [...]

    """),
    shader(False, """\
        // A simple port of Zavie's GLSL smallpt that uses multipass.
        // Original source: https://www.shadertoy.com/view/4sfGDB#

        void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
            vec2 uv = fragCoord.xy / iResolution.xy;
            vec3 color = texture(iChannel0, uv).rgb;

            fragColor = vec4(pow(clamp(color, 0., 1.), vec3(1./2.2)), 1.);
        }
    """)
]

Но обратите внимание, texture - перегруженная функция, и этот подход работает только для двухмерных текстур. Кроме того, есть и другие функции поиска, такие как texelFetch.

...