TetrArcade/arcade/shader.py

550 lines
18 KiB
Python

"""Utilities for dealing with Shaders in OpenGL 3.3+.
"""
from ctypes import *
from collections import namedtuple
import weakref
from typing import Tuple, Iterable
from pyglet.gl import *
from pyglet import gl
import numpy as np
class ShaderException(Exception):
pass
# Thank you Benjamin Moran for writing part of this code!
# https://bitbucket.org/HigashiNoKaze/pyglet/src/shaders/pyglet/graphics/shader.py
_uniform_getters = {
GLint: glGetUniformiv,
GLfloat: glGetUniformfv,
}
_uniform_setters = {
# uniform type: (gl_type, setter, length, count)
GL_INT: (GLint, glUniform1iv, 1, 1),
GL_INT_VEC2: (GLint, glUniform2iv, 2, 1),
GL_INT_VEC3: (GLint, glUniform3iv, 3, 1),
GL_INT_VEC4: (GLint, glUniform4iv, 4, 1),
GL_FLOAT: (GLfloat, glUniform1fv, 1, 1),
GL_FLOAT_VEC2: (GLfloat, glUniform2fv, 2, 1),
GL_FLOAT_VEC3: (GLfloat, glUniform3fv, 3, 1),
GL_FLOAT_VEC4: (GLfloat, glUniform4fv, 4, 1),
GL_SAMPLER_2D: (GLint, glUniform1iv, 1, 1),
GL_FLOAT_MAT2: (GLfloat, glUniformMatrix2fv, 4, 1),
GL_FLOAT_MAT3: (GLfloat, glUniformMatrix3fv, 6, 1),
GL_FLOAT_MAT4: (GLfloat, glUniformMatrix4fv, 16, 1),
# TODO: test/implement these:
# GL_FLOAT_MAT2x3: glUniformMatrix2x3fv,
# GL_FLOAT_MAT2x4: glUniformMatrix2x4fv,
#
# GL_FLOAT_MAT3x2: glUniformMatrix3x2fv,
# GL_FLOAT_MAT3x4: glUniformMatrix3x4fv,
#
# GL_FLOAT_MAT4x2: glUniformMatrix4x2fv,
# GL_FLOAT_MAT4x3: glUniformMatrix4x3fv,
}
def _create_getter_func(program_id, location, gl_getter, c_array, length):
if length == 1:
def getter_func():
gl_getter(program_id, location, c_array)
return c_array[0]
else:
def getter_func():
gl_getter(program_id, location, c_array)
return c_array[:]
return getter_func
def _create_setter_func(location, gl_setter, c_array, length, count, ptr, is_matrix):
if is_matrix:
def setter_func(value):
c_array[:] = value
gl_setter(location, count, GL_FALSE, ptr)
elif length == 1 and count == 1:
def setter_func(value):
c_array[0] = value
gl_setter(location, count, ptr)
elif length > 1 and count == 1:
def setter_func(values):
c_array[:] = values
gl_setter(location, count, ptr)
else:
raise NotImplementedError("Uniform type not yet supported.")
return setter_func
Uniform = namedtuple('Uniform', 'getter, setter')
ShaderCode = str
ShaderType = GLuint
Shader = type(Tuple[ShaderCode, ShaderType])
class Program:
"""Compiled and linked shader program.
Access Uniforms via the [] operator.
Example:
program['MyUniform'] = value
For Matrices, pass the flatten array.
Example:
matrix = np.array([[...]])
program['MyMatrix'] = matrix.flatten()
"""
def __init__(self, *shaders: Shader):
self.prog_id = prog_id = glCreateProgram()
shaders_id = []
for shader_code, shader_type in shaders:
shader = compile_shader(shader_code, shader_type)
glAttachShader(self.prog_id, shader)
shaders_id.append(shader)
glLinkProgram(self.prog_id)
for shader in shaders_id:
# Flag shaders for deletion. Will only be deleted once detached from program.
glDeleteShader(shader)
self._uniforms = {}
self._introspect_uniforms()
weakref.finalize(self, Program._delete, shaders_id, prog_id)
@staticmethod
def _delete(shaders_id, prog_id):
# Check to see if the context was already cleaned up from program
# shut down. If so, we don't need to delete the shaders.
if gl.current_context is None:
return
for shader_id in shaders_id:
glDetachShader(prog_id, shader_id)
glDeleteProgram(prog_id)
def release(self):
if self.prog_id != 0:
glDeleteProgram(self.prog_id)
self.prog_id = 0
def __getitem__(self, item):
try:
uniform = self._uniforms[item]
except KeyError:
raise ShaderException(f"Uniform with the name `{item}` was not found.")
return uniform.getter()
def __setitem__(self, key, value):
try:
uniform = self._uniforms[key]
except KeyError:
raise ShaderException(f"Uniform with the name `{key}` was not found.")
uniform.setter(value)
def __enter__(self):
glUseProgram(self.prog_id)
def __exit__(self, exception_type, exception_value, traceback):
glUseProgram(0)
def get_num_active(self, variable_type: GLenum) -> int:
"""Get the number of active variables of the passed GL type.
variable_type can be GL_ACTIVE_ATTRIBUTES, GL_ACTIVE_UNIFORMS, etc.
"""
num_active = GLint(0)
glGetProgramiv(self.prog_id, variable_type, byref(num_active))
return num_active.value
def _introspect_uniforms(self):
for index in range(self.get_num_active(GL_ACTIVE_UNIFORMS)):
uniform_name, u_type, u_size = self.query_uniform(index)
loc = glGetUniformLocation(self.prog_id, uniform_name.encode('utf-8'))
if loc == -1: # Skip uniforms that may be in Uniform Blocks
continue
try:
gl_type, gl_setter, length, count = _uniform_setters[u_type]
except KeyError:
raise ShaderException(f"Unsupported Uniform type {u_type}")
gl_getter = _uniform_getters[gl_type]
is_matrix = u_type in (GL_FLOAT_MAT2, GL_FLOAT_MAT3, GL_FLOAT_MAT4)
# Create persistant mini c_array for getters and setters:
c_array = (gl_type * length)()
ptr = cast(c_array, POINTER(gl_type))
# Create custom dedicated getters and setters for each uniform:
getter = _create_getter_func(self.prog_id, loc, gl_getter, c_array, length)
setter = _create_setter_func(loc, gl_setter, c_array, length, count, ptr, is_matrix)
# print(f"Found uniform: {uniform_name}, type: {u_type}, size: {u_size}, "
# f"location: {loc}, length: {length}, count: {count}")
self._uniforms[uniform_name] = Uniform(getter, setter)
def query_uniform(self, index: int) -> Tuple[str, int, int]:
"""Retrieve Uniform information at given location.
Returns the name, the type as a GLenum (GL_FLOAT, ...) and the size. Size is
greater than 1 only for Uniform arrays, like an array of floats or an array
of Matrices.
"""
usize = GLint()
utype = GLenum()
buf_size = 192
uname = create_string_buffer(buf_size)
glGetActiveUniform(self.prog_id, index, buf_size, None, usize, utype, uname)
return uname.value.decode(), utype.value, usize.value
def program(vertex_shader: str, fragment_shader: str) -> Program:
"""Create a new program given the vertex_shader and fragment shader code.
"""
return Program(
(vertex_shader, GL_VERTEX_SHADER),
(fragment_shader, GL_FRAGMENT_SHADER)
)
def compile_shader(source: str, shader_type: GLenum) -> GLuint:
"""Compile the shader code of the given type.
`shader_type` could be GL_VERTEX_SHADER, GL_FRAGMENT_SHADER, ...
Returns the shader id as a GLuint
"""
shader = glCreateShader(shader_type)
source = source.encode('utf-8')
# Turn the source code string into an array of c_char_p arrays.
strings = byref(
cast(
c_char_p(source),
POINTER(c_char)
)
)
# Make an array with the strings lengths
lengths = pointer(c_int(len(source)))
glShaderSource(shader, 1, strings, lengths)
glCompileShader(shader)
result = c_int()
glGetShaderiv(shader, GL_COMPILE_STATUS, byref(result))
if result.value == GL_FALSE:
msg = create_string_buffer(512)
length = c_int()
glGetShaderInfoLog(shader, 512, byref(length), msg)
raise ShaderException(
f"Shader compile failure ({result.value}): {msg.value.decode('utf-8')}")
return shader
class Buffer:
"""OpenGL Buffer object of type GL_ARRAY_BUFFER.
Apparently it's possible to initialize a GL_ELEMENT_ARRAY_BUFFER with
GL_ARRAY_BUFFER, provided we later on bind to it with the right type.
The buffer knows its id `buffer_id` and its `size` in bytes.
"""
usages = {
'static': GL_STATIC_DRAW,
'dynamic': GL_DYNAMIC_DRAW,
'stream': GL_STREAM_DRAW
}
def __init__(self, data: bytes, usage: str = 'static'):
self.buffer_id = buffer_id = GLuint()
self.size = len(data)
glGenBuffers(1, byref(self.buffer_id))
if self.buffer_id.value == 0:
raise ShaderException("Cannot create Buffer object.")
glBindBuffer(GL_ARRAY_BUFFER, self.buffer_id)
self.usage = Buffer.usages[usage]
glBufferData(GL_ARRAY_BUFFER, self.size, data, self.usage)
weakref.finalize(self, Buffer.release, buffer_id)
@classmethod
def create_with_size(cls, size: int, usage: str = 'static'):
"""Create an empty Buffer storage of the given size."""
buffer = Buffer(b"", usage=usage)
glBindBuffer(GL_ARRAY_BUFFER, buffer.buffer_id)
glBufferData(GL_ARRAY_BUFFER, size, None, Buffer.usages[usage])
buffer.size = size
return buffer
@staticmethod
def release(buffer_id):
# If we have no context, then we are shutting down, so skip this
if gl.current_context is None:
return
if buffer_id.value != 0:
glDeleteBuffers(1, byref(buffer_id))
buffer_id.value = 0
def write(self, data: bytes, offset: int = 0):
glBindBuffer(GL_ARRAY_BUFFER, self.buffer_id)
glBufferSubData(GL_ARRAY_BUFFER, GLintptr(offset), len(data), data)
# print(f"Writing data:\n{data[:60]}")
# ptr = glMapBufferRange(GL_ARRAY_BUFFER, GLintptr(0), 20, GL_MAP_READ_BIT)
# print(f"Reading back from buffer:\n{string_at(ptr, size=60)}")
# glUnmapBuffer(GL_ARRAY_BUFFER)
def orphan(self):
glBindBuffer(GL_ARRAY_BUFFER, self.buffer_id)
glBufferData(GL_ARRAY_BUFFER, self.size, None, self.usage)
def _read(self, size):
""" Debug method to read data from the buffer. """
glBindBuffer(GL_ARRAY_BUFFER, self.buffer_id)
ptr = glMapBufferRange(GL_ARRAY_BUFFER, GLintptr(0), size, GL_MAP_READ_BIT)
print(f"Reading back from buffer:\n{string_at(ptr, size=size)}")
glUnmapBuffer(GL_ARRAY_BUFFER)
def buffer(data: bytes, usage: str = 'static') -> Buffer:
"""Create a new OpenGL Buffer object.
"""
return Buffer(data, usage)
class BufferDescription:
"""Vertex Buffer Object description, allowing easy use with VAOs.
This class provides a Buffer object with a description of its content, allowing
a VertexArray object to correctly enable its shader attributes with the
vertex Buffer object.
The formats is a string providing the number and type of each attribute. Currently
we only support f (float), i (integer) and B (unsigned byte).
`normalized` enumerates the attributes which must have their values normalized.
This is useful for instance for colors attributes given as unsigned byte and
normalized to floats with values between 0.0 and 1.0.
`instanced` allows this buffer to be used as instanced buffer. Each value will
be used once for the whole geometry. The geometry will be repeated a number of
times equal to the number of items in the Buffer.
"""
GL_TYPES_ENUM = {
'B': GL_UNSIGNED_BYTE,
'f': GL_FLOAT,
'i': GL_INT,
}
GL_TYPES = {
'B': GLubyte,
'f': GLfloat,
'i': GLint,
}
def __init__(self,
buffer: Buffer,
formats: str,
attributes: Iterable[str],
normalized: Iterable[str] = None,
instanced: bool = False):
self.buffer = buffer
self.attributes = list(attributes)
self.normalized = set() if normalized is None else set(normalized)
self.instanced = instanced
if self.normalized > set(self.attributes):
raise ShaderException("Normalized attribute not found in attributes.")
formats = formats.split(" ")
if len(formats) != len(self.attributes):
raise ShaderException(
f"Different lengths of formats ({len(formats)}) and "
f"attributes ({len(self.attributes)})"
)
self.formats = []
for i, fmt in enumerate(formats):
size, type_ = fmt
if size not in '1234' or type_ not in 'fiB':
raise ShaderException("Wrong format {fmt}.")
size = int(size)
gl_type_enum = BufferDescription.GL_TYPES_ENUM[type_]
gl_type = BufferDescription.GL_TYPES[type_]
attribsize = size * sizeof(gl_type)
self.formats.append((size, attribsize, gl_type_enum))
class VertexArray:
"""Vertex Array Object (VAO) is holding all the different OpenGL objects
together.
A VAO is the glue between a Shader program and buffers data.
Buffer information is provided through a list of tuples `content`
content = [
(buffer, 'format str', 'attrib1', 'attrib2', ...),
]
The first item is a Buffer object. Then comes a format string providing information
about the count and type of data in the buffer. Type can be `f` for floats or `i`
for integers. Count can be 1, 2, 3 or 4.
Finally comes the strings representing the attributes found in the shader.
Example:
Providing a buffer with data of interleaved positions (x, y) and colors
(r, g, b, a):
content = [(buffer, '2f 4f', 'in_pos', 'in_color')]
You can use the VAO as a context manager. This is required for setting Uniform
variables or for rendering.
vao = VertexArrax(...)
with vao:
vao['MyUniform'] = value
vao.render
"""
def __init__(self,
program: Program,
content: Iterable[BufferDescription],
index_buffer: Buffer = None):
self.program = program.prog_id
self.vao = vao = GLuint()
self.num_vertices = -1
self.ibo = index_buffer
glGenVertexArrays(1, byref(self.vao))
glBindVertexArray(self.vao)
for buffer_desc in content:
self._enable_attrib(buffer_desc)
if self.ibo is not None:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, self.ibo.buffer_id)
weakref.finalize(self, VertexArray.release, vao)
@staticmethod
def release(vao):
# If we have no context, then we are shutting down, so skip this
if gl.current_context is None:
return
if vao.value != 0:
glDeleteVertexArrays(1, byref(vao))
vao.value = 0
def __enter__(self):
glBindVertexArray(self.vao)
glUseProgram(self.program)
def __exit__(self, exception_type, exception_value, traceback):
glUseProgram(0)
def _enable_attrib(self, buf_desc: BufferDescription):
buffer = buf_desc.buffer
stride = sum(attribsize for _, attribsize, _ in buf_desc.formats)
if buf_desc.instanced:
if self.num_vertices == -1:
raise ShaderException(
"The first vertex attribute cannot be a per instance attribute."
)
else:
self.num_vertices = max(self.num_vertices, buffer.size // stride)
# print(f"Number of vertices: {self.num_vertices}")
glBindBuffer(GL_ARRAY_BUFFER, buffer.buffer_id)
offset = 0
for (size, attribsize, gl_type_enum), attrib in zip(buf_desc.formats, buf_desc.attributes):
loc = glGetAttribLocation(self.program, attrib.encode('utf-8'))
if loc == -1:
raise ShaderException(f"Attribute {attrib} not found in shader program")
normalized = GL_TRUE if attrib in buf_desc.normalized else GL_FALSE
glVertexAttribPointer(
loc, size, gl_type_enum,
normalized, stride, c_void_p(offset)
)
# print(f"{attrib} of size {size} with stride {stride} and offset {offset}")
if buf_desc.instanced:
glVertexAttribDivisor(loc, 1)
offset += attribsize
glEnableVertexAttribArray(loc)
def render(self, mode: GLuint, instances: int = 1):
if self.ibo is not None:
count = self.ibo.size // 4
glDrawElementsInstanced(mode, count, GL_UNSIGNED_INT, None, instances)
else:
glDrawArraysInstanced(mode, 0, self.num_vertices, instances)
def vertex_array(program: GLuint, content, index_buffer=None):
"""Create a new Vertex Array.
"""
return VertexArray(program, content, index_buffer)
class Texture:
def __init__(self, size: Tuple[int, int], component: int, data: np.array):
self.width, self.height = size
sized_format = (GL_R8, GL_RG8, GL_RGB8, GL_RGBA8)[component - 1]
self.format = (GL_R, GL_RG, GL_RGB, GL_RGBA)[component - 1]
glActiveTexture(GL_TEXTURE0 + 0) # If we need other texture unit...
self.texture_id = texture_id = GLuint()
glGenTextures(1, byref(self.texture_id))
if self.texture_id.value == 0:
raise ShaderException("Cannot create Texture.")
glBindTexture(GL_TEXTURE_2D, self.texture_id)
glPixelStorei(GL_PACK_ALIGNMENT, 1)
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
try:
glTexImage2D(
GL_TEXTURE_2D, 0, sized_format, self.width, self.height, 0,
self.format, GL_UNSIGNED_BYTE, data.ctypes.data_as(c_void_p)
)
except GLException:
raise GLException(f"Unable to create texture. {GL_MAX_TEXTURE_SIZE} {size}")
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
weakref.finalize(self, Texture.release, texture_id)
@staticmethod
def release(texture_id):
# If we have no context, then we are shutting down, so skip this
if gl.current_context is None:
return
if texture_id.value != 0:
glDeleteTextures(1, byref(texture_id))
def use(self, texture_unit: int = 0):
glActiveTexture(GL_TEXTURE0 + texture_unit)
glBindTexture(GL_TEXTURE_2D, self.texture_id)
def texture(size: Tuple[int, int], component: int, data: np.array) -> Texture:
return Texture(size, component, data)