PyOpenGL: Put multiple textures to a single VBO?

I am making a little game in PyOpenGL. I have run into a problem. The VBO takes only one texture. I have tried using a glTexSubImage3d, but I just cannot get it to work. I need to get multiple textures on a single VBO. There are no good tutorials explaining the point! Here is my code:

main.py

# imports
import random
import glfw
from OpenGL.GL import *
from OpenGL.GLU import *

# internal imports
from renderer import *
from player import *

if not glfw.init():
    raise Exception("glfw can not be initialized!")

window = glfw.create_window(800, 500, "PyCraft", None, None)
glfw.make_context_current(window)
renderer = TerrainRenderer(window)
player = Player(window)

glEnable(GL_DEPTH_TEST)


# generate cube faces
def generate_faces(position):
    x, y, z = position
    X, Y, Z = x + 1, y + 1, z + 1

    return [
        # top
        x, y, z,
        x, y, Z,
        X, y, Z,
        X, y, z,

        # bottom
        x, Y, z,
        X, Y, z,
        X, Y, Z,
        x, Y, Z,

        # left
        x, y, z,
        x, Y, z,
        x, Y, Z,
        x, y, Z,

        # right
        X, y, z,
        X, y, Z,
        X, Y, Z,
        X, Y, z,

        # front
        x, y, z,
        x, Y, z,
        X, Y, z,
        X, y, z,

        # back
        x, y, Z,
        X, y, Z,
        X, Y, Z,
        x, Y, Z,
    ]

defaultTexCoords = [
    # top
    0, 0,
    0, 1,
    1, 1,
    1, 0,

    # bottom
    0, 0,
    1, 0,
    1, 1,
    0, 1,

    # left
    0, 0,
    0, 1,
    1, 1,
    1, 0,

    # right
    0, 0,
    1, 0,
    1, 1,
    0, 1,

    # front
    0, 0,
    0, 1,
    1, 1,
    1, 0,

    # back
    0, 0,
    1, 0,
    1, 1,
    0, 1,
]

for i in range(-10, 10):
    for j in range(-10, 10):
        x = i
        y = random.randint(-1, 1)
        z = j
        renderer.add(generate_faces([x, y, z]), defaultTexCoords)

camrot = [0, 0]
campos = [0, 0, 0]

# get window size
def get_window_size():
    width, height = glfw.get_window_size(window)
    return width, height

def _setup_3d():
    w, h = get_window_size()

    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(70, w / h, 0.1, 1000)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def update_on_resize():
    _setup_3d()
    glViewport(0, 0, *get_window_size())

# mainloop
while not glfw.window_should_close(window):
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

    update_on_resize()

    _setup_3d()
    glClearColor(0.0, 0.0, 0.0, 1.0)
    glColor3f(1.0, 1.0, 1.0)

    player.update()
    player._translate()
    renderer.render()

    glfw.poll_events()
    glfw.swap_buffers(window)

glfw.terminate()

renderer.py

# imports
import glfw
import pygame
from OpenGL.GL import *
from ctypes import *
import threading, time

event = threading.Event()

glfw.init()

def loadTexture(path):
    texSurface = pygame.image.load(path)
    texData = pygame.image.tostring(texSurface, "RGBA", 1)
    width = texSurface.get_width()
    height = texSurface.get_height()
    texid = glGenTextures(1)
    glBindTexture(GL_TEXTURE_2D, texid)
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 
            0, GL_RGBA, GL_UNSIGNED_BYTE, texData)
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
    return texid

class TerrainRenderer:
    def __init__(self, window):
        self.window = window

        self.vertices = []
        self.texCoords = []

        VAO = glGenVertexArrays(1)
        glBindVertexArray(VAO)

        self.vbo, self.vbo_1 = glGenBuffers (2)

        self.texture = loadTexture("assets/textures/block/bricks.png",)

        glEnable(GL_TEXTURE_2D)
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
        glEnableClientState(GL_TEXTURE_COORD_ARRAY)
        glEnableClientState (GL_VERTEX_ARRAY)


    def render(self):
        glClear (GL_COLOR_BUFFER_BIT)

        glEnable(GL_TEXTURE_2D)
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
        glBindBuffer (GL_ARRAY_BUFFER, self.vbo)
        glVertexPointer (3, GL_FLOAT, 0, None)
        glBindBuffer(GL_ARRAY_BUFFER, self.vbo_1)
        glTexCoordPointer(2, GL_FLOAT, 0, None)

        glDrawArrays (GL_QUADS, 0, len(self.vertices))
        glDisable(GL_TEXTURE_2D)
        glDisable(GL_BLEND)

    def add(self, posList, texCoords):
        self.vertices.extend(posList)
        self.texCoords.extend(texCoords)

        glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
        glBufferData(GL_ARRAY_BUFFER, len(self.vertices) * 4, (c_float * len(self.vertices))(*self.vertices), GL_STATIC_DRAW)
        glFlush()

        glEnableClientState(GL_VERTEX_ARRAY)
        glVertexPointer(3, GL_FLOAT, 0, None)

        glTexCoordPointer(3, GL_FLOAT, 0, None)
        glEnableClientState(GL_TEXTURE_COORD_ARRAY)
        glBindBuffer(GL_ARRAY_BUFFER, self.vbo_1)
        glBufferData(GL_ARRAY_BUFFER, len(self.texCoords) * 4, (c_float * len(self.texCoords))(*self.texCoords), GL_STATIC_DRAW)
        glFlush()

Full stop. There’s a lot of confusion in this. For starters, there are only “buffer objects”. You can put anything you want in them. And load data from them into anything you want.

So let’s back up:

  • What are you trying to do (high-level)?
  • You obviously want textures. Why do you think you need one or more buffer objects for texel data?
  • Are your textures static? Or are the contents updated periodically?
  • I am making a minecraft clone in python. So I need more than one texture on a single block.
  • I just discovered that we can make ‘texture atlases’, so I’m currently working on that.
  • Yes, the textures ARE static, but the VBO vertices will be updated constantly. Like when terrain is generated, or chunks are removed.

then bind them to different texure units using …
glActiveTexture
glBindTexture

Thanks for your help! I updated the code to:
main.py

# imports
import random
import glfw
from OpenGL.GL import *
from OpenGL.GLU import *

# internal imports
from renderer import *
from player import *

if not glfw.init():
    raise Exception("glfw can not be initialized!")

window = glfw.create_window(800, 500, "PyCraft", None, None)
glfw.make_context_current(window)
renderer = TerrainRenderer(window)
player = Player(window)

glEnable(GL_DEPTH_TEST)

# generate cube faces
def generate_faces(position):
    x, y, z = position
    X, Y, Z = x + 1, y + 1, z + 1

    return (
        # top
        x, Y, z,
        x, Y, Z,
        X, Y, Z,
        X, Y, z,

        # bottom
        x, y, z,
        X, y, z,
        X, y, Z,
        x, y, Z,

        # left
        x, y, z,
        x, Y, z,
        x, Y, Z,
        x, y, Z,

        # right
        X, y, z,
        X, y, Z,
        X, Y, Z,
        X, Y, z,

        # front
        x, y, z,
        x, Y, z,
        X, Y, z,
        X, y, z,

        # back
        x, y, Z,
        X, y, Z,
        X, Y, Z,
        x, Y, Z,
    )

renderer.texture_manager.add_from_folder("assets/textures/block/")
renderer.texture_manager.save("atlas.png")
renderer.texture_manager.bind()

for i in range(-10, 10):
    for j in range(-10, 10):
        x = i
        y = random.randint(-3, 3)
        z = j
        renderer.add(generate_faces([x, y, z]), (
            *renderer.texture_manager.texture_coords["grass.png"],
            *renderer.texture_manager.texture_coords["dirt.png"],
            *renderer.texture_manager.texture_coords["grass_side.png"],
            *renderer.texture_manager.texture_coords["grass_side.png"],
            *renderer.texture_manager.texture_coords["grass_side.png"],
            *renderer.texture_manager.texture_coords["grass_side.png"],
        ))

camrot = [0, 0]
campos = [0, 0, 0]

# get window size
def get_window_size():
    width, height = glfw.get_window_size(window)
    return width, height

def _setup_3d():
    w, h = get_window_size()

    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(70, w / h, 0.1, 1000)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def update_on_resize():
    _setup_3d()
    glViewport(0, 0, *get_window_size())

# mainloop
while not glfw.window_should_close(window):
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

    update_on_resize()

    _setup_3d()
    glClearColor(0.0, 0.0, 0.0, 1.0)
    glColor3f(1.0, 1.0, 1.0)

    player.update()
    player._translate()
    renderer.render()

    glfw.poll_events()
    glfw.swap_buffers(window)

glfw.terminate()

renderer.py

# imports
import glfw
from OpenGL.GL import *
from ctypes import *
import threading
from texture_manager import *

event = threading.Event()

glfw.init()

class TerrainRenderer:
    def __init__(self, window):
        self.window = window

        self.vertices = []
        self.texCoords = []

        VAO = glGenVertexArrays(1)
        glBindVertexArray(VAO)

        self.vbo, self.vbo_1 = glGenBuffers (2)

        self.texture_manager = TextureAtlas()

        glEnable(GL_TEXTURE_2D)
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
        glEnableClientState(GL_TEXTURE_COORD_ARRAY)
        glEnableClientState (GL_VERTEX_ARRAY)


    def render(self):
        glClear (GL_COLOR_BUFFER_BIT)

        glEnable(GL_TEXTURE_2D)
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
        glBindBuffer (GL_ARRAY_BUFFER, self.vbo)
        glVertexPointer (3, GL_FLOAT, 0, None)
        glBindBuffer(GL_ARRAY_BUFFER, self.vbo_1)
        glTexCoordPointer(2, GL_FLOAT, 0, None)

        glDrawArrays (GL_QUADS, 0, len(self.vertices))
        glDisable(GL_TEXTURE_2D)
        glDisable(GL_BLEND)

    def add(self, posList, texCoords):
        self.vertices.extend(posList)
        self.texCoords.extend(texCoords)

        glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
        glBufferData(GL_ARRAY_BUFFER, len(self.vertices) * 4, (c_float * len(self.vertices))(*self.vertices), GL_STATIC_DRAW)
        glFlush()

        glEnableClientState(GL_VERTEX_ARRAY)
        glVertexPointer(3, GL_FLOAT, 0, None)

        glTexCoordPointer(3, GL_FLOAT, 0, None)
        glEnableClientState(GL_TEXTURE_COORD_ARRAY)
        glBindBuffer(GL_ARRAY_BUFFER, self.vbo_1)
        glBufferData(GL_ARRAY_BUFFER, len(self.texCoords) * 4, (c_float * len(self.texCoords))(*self.texCoords), GL_STATIC_DRAW)
        glFlush()

texture_manager.py

from OpenGL.GL import *
from PIL import Image

class TextureAtlasGenerator:
    def __init__(self, texture_size=32, n_textures = 100):
        self.texture_size = texture_size * n_textures
        self.n_textures = n_textures
        self.texture_atlas = Image.new("RGBA", (self.texture_size, self.texture_size), (0, 0, 0, 0))
        self.texture_atlas_data = self.texture_atlas.load()
        self.current_side = 0
        self.current_x = 0
        self.current_y = 0
        self.used = []
    
    def add(self, image):
        # Add image to texture atlas.
        if image.size[0] > self.texture_size or image.size[1] > self.texture_size:
            raise Exception("Image is too large for texture atlas.")

        if self.current_x + image.size[0] > self.texture_size:
            self.current_x = 0
            self.current_y += image.size[1]
            self.current_side += 1
        
        if self.current_y + image.size[1] > self.texture_size:
            raise Exception("Texture atlas is full.")

        self.texture_atlas.paste(image, (self.current_x, self.current_y))
        self.used.append((self.current_side, self.current_x, self.current_y, image.size[0], image.size[1]))
        self.current_x += image.size[0]
        return self.current_side, self.current_x, self.current_y, image.size[0], image.size[1]

    def get_texture_atlas(self):
        return self.texture_atlas

    def save(self, path):
        self.texture_atlas.save(path)

    def get_rect(self, index):
        side, x, y, w, h = self.used[index]
        return side, x, y, w, h

class TextureAtlas:
    def __init__(self):
        self.atlas_generator = TextureAtlasGenerator()
        self.textures = []
        self.texture_coords = {}
        self.save_path = None

    def add(self, image, name):
        self.atlas_generator.add(image)
        self.textures.append(image)

        # get texture coordinates
        _ = self.atlas_generator.get_rect(len(self.textures) - 1)
        x = _[1]
        y = -_[2]
        w = _[3]
        h = _[4]
        # TexCoords for OpenGL
        self.texture_coords[name] = (
            x / self.atlas_generator.texture_size,
            y / self.atlas_generator.texture_size,

            (x + w) / self.atlas_generator.texture_size,
            y / self.atlas_generator.texture_size,

            (x + w) / self.atlas_generator.texture_size,
            (y - h) / self.atlas_generator.texture_size,

            x / self.atlas_generator.texture_size,
            (y - h) / self.atlas_generator.texture_size
        )

    def save(self, path):
        self.atlas_generator.save(path)
        self.save_path = path

    def add_from_folder(self, path):
        import os
        for filename in os.listdir(path):
            if filename.endswith(".png"):
                image = Image.open(path + filename)
                self.add(image, filename)

    def bind(self):
        import pygame
        texSurface = pygame.image.load(self.save_path)
        texData = pygame.image.tostring(texSurface, "RGBA", 1)
        width = texSurface.get_width()
        height = texSurface.get_height()
        texid = glGenTextures(1)
        glBindTexture(GL_TEXTURE_2D, texid)
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 
                0, GL_RGBA, GL_UNSIGNED_BYTE, texData)
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
        return texid

# example usage
if __name__ == "__main__":
    atlas = TextureAtlas()
    atlas.add_from_folder("assets/textures/block")
    atlas.save("atlas.png")
    print(atlas.texture_coords)

But now, the textures on the side of the blocks are all sideways or upside down!
So, the problem is not solved yet.

OK I fixed it by reordering the tex coords!