add pixel shader deblocking sample

This commit is contained in:
Richard Geldreich
2026-02-10 12:26:35 -05:00
parent 718047f102
commit 5250032c24
15 changed files with 1007 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
Python+GLSL Shader Deblocking Sample
This sample demonstrates how to use a simpler pixel shader to greatly reduce
ASTC block artifacts, which can be quite noticeable when the block size goes
beyond roughly 6x6. The shader determines if it's going to sample near an edge,
and if so it samples a small vertical and horizontal region around the texel
and applies a small low pass filter. The example shader is compatible with
mipmapping, bilinear filtering, trilinear filtering etc.
You'll need these Python dependencies to run it:
>pip install numpy Pillow glfw PyOpenGL
See `run.bat` for the command line on how to run the sample. Or run:
>py -3.12 testbed.py shader.glsl 12 12 flower_unpacked_rgb_ASTC_LDR_12X12_RGBA_level_0_face_0_layer_0000.png flower_unpacked_rgb_ASTC_LDR_12X12_RGBA_level_1_face_0_layer_0000.png flower_unpacked_rgb_ASTC_LDR_12X12_RGBA_level_2_face_0_layer_0000.png flower_unpacked_rgb_ASTC_LDR_12X12_RGBA_level_3_face_0_layer_0000.png flower_unpacked_rgb_ASTC_LDR_12X12_RGBA_level_4_face_0_layer_0000.png
The shader can be easily simplified to sample the texture less by using less taps. The current shader uses a total of 9 taps, but 5 are possible.
Many variations and optimizations of this basic idea are possible.
Usage:
python testbed.py shader.glsl block_w block_h mip0.png mip1.png [mip2.png ...]
block_w, block_h: Block size in texels (e.g. 8 8 for 8x8 DCT blocks)
Controls:
Arrows Move quad left/right/up/down
W / S Move closer / farther
A / D Rotate yaw (cube mode)
Q / E Rotate pitch (cube mode)
C Toggle cube / quad mode
B Bilinear filtering
T Trilinear filtering
P Point filtering
R Reload shader
1 Toggle deblocking shader off/on
2 Toggle edge visualization (only when deblocking active)
3-4 Toggle shader const0.x/y/z/w (0 <-> 1)
5-8 Toggle shader const1.x/y/z/w (0 <-> 1)
Space Reset to initial state
Esc Quit
---
The included sunflower image is in the CC0/Public Domain, and was downloaded from here:
https://www.publicdomainpictures.net/en/view-image.php?image=756601&picture=large-yellow-sunflower
"License: CC0 Public Domain - Lynn Greyling has released this “Large Yellow Sunflower” image under Public Domain license."

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 B

View File

@@ -0,0 +1 @@
py -3.12 testbed.py shader.glsl 12 12 flower_unpacked_rgb_ASTC_LDR_12X12_RGBA_level_0_face_0_layer_0000.png flower_unpacked_rgb_ASTC_LDR_12X12_RGBA_level_1_face_0_layer_0000.png flower_unpacked_rgb_ASTC_LDR_12X12_RGBA_level_2_face_0_layer_0000.png flower_unpacked_rgb_ASTC_LDR_12X12_RGBA_level_3_face_0_layer_0000.png flower_unpacked_rgb_ASTC_LDR_12X12_RGBA_level_4_face_0_layer_0000.png

View File

@@ -0,0 +1,97 @@
#vertex
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec2 aUV;
uniform mat4 mvp;
out vec2 vUV;
void main() {
vUV = aUV;
gl_Position = mvp * vec4(aPos, 1.0);
}
#fragment
#version 330 core
uniform sampler2D tex;
uniform vec4 texSize; // Base mip dimensions (mip 0)
uniform vec4 const0; // User constant 0 (keys 1-4 toggle x,y,z,w)
uniform vec4 const1; // User constant 1 (keys 5-8 toggle x,y,z,w)
in vec2 vUV;
out vec4 fragColor;
void main()
{
vec2 blockSize = vec2(texSize.z, texSize.w);
vec2 du = dFdx(vUV);
vec2 dv = dFdy(vUV);
float rho = max(length(du * texSize.xy), length(dv * texSize.xy));
float lod = max(0.0, log2(max(rho, 1e-8))); // lod index
float mipScale = exp2(floor(lod + .5)); // 2^lod mipmap scale, snaps to dominant mipmap
vec2 texDim = vec2(texSize.x, texSize.y);
vec2 texelStep = 1.0 / texDim;
vec2 texelPos = (vUV * texDim) / mipScale;
vec2 blockPos = mod(texelPos, blockSize);
vec3 color;
color = texture(tex, vUV).rgb;
if (const0.x > 0.5)
{
float falloff = 2.0;
float leftProx = 1.0 - clamp(blockPos.x / falloff, 0.0, 1.0);
float rightProx = 1.0 - clamp((blockSize.x - 1.0 - blockPos.x) / falloff, 0.0, 1.0);
float topProx = 1.0 - clamp(blockPos.y / falloff, 0.0, 1.0);
float bottomProx = 1.0 - clamp((blockSize.y - 1.0 - blockPos.y) / falloff, 0.0, 1.0);
float horizWeight = max(leftProx, rightProx);
float vertWeight = max(topProx, bottomProx);
float edgeWeight = max(horizWeight, vertWeight); // overall proximity
vec3 c0 = color; //texture2D(tex, vUV).rgb;
vec3 l2 = texture2D(tex, vUV - vec2(2 * texelStep.x * mipScale, 0.0)).rgb;
vec3 l1 = texture2D(tex, vUV - vec2(texelStep.x * mipScale, 0.0)).rgb;
vec3 r1 = texture2D(tex, vUV + vec2(texelStep.x * mipScale, 0.0)).rgb;
vec3 r2 = texture2D(tex, vUV + vec2(2 * texelStep.x * mipScale, 0.0)).rgb;
vec3 u2 = texture2D(tex, vUV - vec2(0.0, 2 * texelStep.y * mipScale)).rgb;
vec3 u1 = texture2D(tex, vUV - vec2(0.0, texelStep.y * mipScale)).rgb;
vec3 d1 = texture2D(tex, vUV + vec2(0.0, texelStep.y * mipScale)).rgb;
vec3 d2 = texture2D(tex, vUV + vec2(0.0, 2 * texelStep.y * mipScale)).rgb;
//vec3 filteredH = (l2 + 2 * l1 + 3 * c0 + 2 * r1 + r2) / 9.0;
//vec3 filteredV = (u2 + 2 * u1 + 3 * c0 + 2 * d1 + d2) / 9.0;
vec3 filteredH = (l2 + 2 * l1 + 2 * c0 + 2 * r1 + r2) / 8.0;
vec3 filteredV = (u2 + 2 * u1 + 2 * c0 + 2 * d1 + d2) / 8.0;
float smoothH = 1.0;
float smoothV = 1.0;
if (edgeWeight > 0.0)
{
vec3 horizColor = mix(c0, filteredH, smoothH * horizWeight);
vec3 vertColor = mix(c0, filteredV, smoothV * vertWeight);
float totalW = horizWeight + vertWeight;
if (totalW > 0.0)
color = (horizColor * horizWeight + vertColor * vertWeight) / totalW;
}
// block edge vis
if (const0.y > 0.5)
{
color = vec3(edgeWeight, edgeWeight, edgeWeight);
}
}
fragColor = vec4(color, 1.0);
}

View File

@@ -0,0 +1,861 @@
#!/usr/bin/env python3
"""
Mipmap Deblocking Shader Testbed
Copyright (C) 2026 Binomial LLC.
LICENSE: Apache 2.0
Usage:
python testbed.py shader.glsl block_w block_h mip0.png mip1.png [mip2.png ...]
block_w, block_h: Block size in texels (e.g. 8 8 for 8x8 DCT blocks)
Controls:
Arrows Move quad left/right/up/down
W / S Move closer / farther
A / D Rotate yaw (cube mode)
Q / E Rotate pitch (cube mode)
C Toggle cube / quad mode
B Bilinear filtering
T Trilinear filtering
P Point filtering
R Reload shader
1 Toggle deblocking shader off/on
2 Toggle edge visualization (only when deblocking active)
3-4 Toggle shader const0.x/y/z/w (0 <-> 1)
5-8 Toggle shader const1.x/y/z/w (0 <-> 1)
Space Reset to initial state
Esc Quit
"""
import sys, os, importlib.util
print("=== DIAG ===")
print("exe:", sys.executable)
print("ver:", sys.version)
print("cwd:", os.getcwd())
print("glfw spec:", importlib.util.find_spec("glfw"))
print("OpenGL spec:", importlib.util.find_spec("OpenGL"))
print("============")
import sys
import ctypes
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from pathlib import Path
import glfw
from OpenGL.GL import *
# -----------------------------------------------------------------------------
# Globals
# -----------------------------------------------------------------------------
WINDOW_WIDTH = 1280
WINDOW_HEIGHT = 720
FOV_DEGREES = 90.0
Z_MIN = .40
Z_MAX = -50.0
Z_SPEED = 2.0
XY_SPEED = 1.5
ROT_SPEED = 90.0 # degrees per second
# Block size (set from command line)
BLOCK_WIDTH = 12
BLOCK_HEIGHT = 12
g_state = {
'x': 0.0,
'y': 0.0,
'z': -3.0,
'yaw': 0.0,
'pitch': 0.0,
'mode': 'QUAD', # 'QUAD' or 'CUBE'
'filter_mode': 'BILINEAR',
'shader_path': None,
'program': None,
'texture': None,
'tex_size': (0, 0),
'quad_vao': None,
'cube_vao': None,
'cube_index_count': 0,
'debug_vao': None,
'debug_texture': None,
'debug_dirty': True,
'last_time': 0.0,
'const0': [0.0, 0.0, 0.0, 0.0],
'const1': [0.0, 0.0, 0.0, 0.0],
}
INIT_X = 0.0
INIT_Y = 0.0
INIT_Z = -3.0
INIT_YAW = 0.0
INIT_PITCH = 0.0
INIT_CONST0 = [0.0, 0.0, 0.0, 0.0]
INIT_CONST1 = [0.0, 0.0, 0.0, 0.0]
# -----------------------------------------------------------------------------
# Shader Loading
# -----------------------------------------------------------------------------
def parse_shader_file(path):
"""Parse shader file with #vertex and #fragment markers."""
text = Path(path).read_text()
vertex_src = None
fragment_src = None
parts = text.split('#vertex')
if len(parts) < 2:
print(f"ERROR: No #vertex marker found in {path}")
return None, None
rest = parts[1]
frag_parts = rest.split('#fragment')
if len(frag_parts) < 2:
print(f"ERROR: No #fragment marker found in {path}")
return None, None
vertex_src = frag_parts[0].strip()
fragment_src = frag_parts[1].strip()
return vertex_src, fragment_src
def compile_shader(source, shader_type):
"""Compile a shader, return handle or None on error."""
shader = glCreateShader(shader_type)
glShaderSource(shader, source)
glCompileShader(shader)
if glGetShaderiv(shader, GL_COMPILE_STATUS) != GL_TRUE:
error = glGetShaderInfoLog(shader)
if isinstance(error, bytes):
error = error.decode('utf-8')
type_name = "VERTEX" if shader_type == GL_VERTEX_SHADER else "FRAGMENT"
print(f"{type_name} SHADER ERROR:\n{error}")
glDeleteShader(shader)
return None
return shader
def link_program(vertex_shader, fragment_shader):
"""Link shaders into program, return handle or None on error."""
program = glCreateProgram()
glAttachShader(program, vertex_shader)
glAttachShader(program, fragment_shader)
glLinkProgram(program)
if glGetProgramiv(program, GL_LINK_STATUS) != GL_TRUE:
error = glGetProgramInfoLog(program)
if isinstance(error, bytes):
error = error.decode('utf-8')
print(f"LINK ERROR:\n{error}")
glDeleteProgram(program)
return None
return program
def load_shader(path):
"""Load, compile, and link shader from file. Returns program or None."""
print(f"Loading shader: {path}")
vertex_src, fragment_src = parse_shader_file(path)
if vertex_src is None or fragment_src is None:
return None
vertex_shader = compile_shader(vertex_src, GL_VERTEX_SHADER)
if vertex_shader is None:
return None
fragment_shader = compile_shader(fragment_src, GL_FRAGMENT_SHADER)
if fragment_shader is None:
glDeleteShader(vertex_shader)
return None
program = link_program(vertex_shader, fragment_shader)
glDeleteShader(vertex_shader)
glDeleteShader(fragment_shader)
if program:
print("Shader compiled successfully.")
return program
def reload_shader():
"""Attempt to reload shader. Keep old one if failed."""
new_program = load_shader(g_state['shader_path'])
if new_program is not None:
if g_state['program'] is not None:
glDeleteProgram(g_state['program'])
g_state['program'] = new_program
else:
print("Shader reload failed, keeping previous shader.")
# -----------------------------------------------------------------------------
# Texture Loading
# -----------------------------------------------------------------------------
def load_mipmap_texture(paths):
"""Load PNG files as mipmap levels. Returns texture handle and base size."""
images = []
for i, path in enumerate(paths):
img = Image.open(path).convert('RGBA')
images.append(img)
print(f"Loaded mip {i}: {path} ({img.width}x{img.height})")
# Validate dimensions
for i in range(1, len(images)):
expected_w = images[i - 1].width // 2
expected_h = images[i - 1].height // 2
actual_w = images[i].width
actual_h = images[i].height
if actual_w != expected_w or actual_h != expected_h:
print(f"ERROR: Mip {i} should be {expected_w}x{expected_h}, got {actual_w}x{actual_h}")
sys.exit(1)
# Create texture
texture = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, texture)
# Upload each mip level
for level, img in enumerate(images):
data = np.array(img, dtype=np.uint8)
glTexImage2D(
GL_TEXTURE_2D, level, GL_RGBA8,
img.width, img.height, 0,
GL_RGBA, GL_UNSIGNED_BYTE, data
)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, len(images) - 1)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
base_size = (images[0].width, images[0].height)
return texture, base_size
def set_filter_mode(mode):
"""Set texture filtering mode."""
glBindTexture(GL_TEXTURE_2D, g_state['texture'])
if mode == 'BILINEAR':
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_NEAREST)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
elif mode == 'TRILINEAR':
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
else: # POINT
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
g_state['filter_mode'] = mode
g_state['debug_dirty'] = True
# -----------------------------------------------------------------------------
# Geometry
# -----------------------------------------------------------------------------
def create_quad(aspect_ratio):
"""Create a quad VAO centered at origin with given aspect ratio."""
# Normalize so longest dimension is 1.0
if aspect_ratio >= 1.0:
half_w = 1.0
half_h = 1.0 / aspect_ratio
else:
half_w = aspect_ratio
half_h = 1.0
# Position (x, y, z) + UV (u, v)
vertices = np.array([
-half_w, -half_h, 0.0, 0.0, 1.0,
half_w, -half_h, 0.0, 1.0, 1.0,
half_w, half_h, 0.0, 1.0, 0.0,
-half_w, half_h, 0.0, 0.0, 0.0,
], dtype=np.float32)
indices = np.array([0, 1, 2, 0, 2, 3], dtype=np.uint32)
vao = glGenVertexArrays(1)
vbo = glGenBuffers(1)
ebo = glGenBuffers(1)
glBindVertexArray(vao)
glBindBuffer(GL_ARRAY_BUFFER, vbo)
glBufferData(GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL_STATIC_DRAW)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices, GL_STATIC_DRAW)
# Position attribute (location 0)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 20, ctypes.c_void_p(0))
glEnableVertexAttribArray(0)
# UV attribute (location 1)
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 20, ctypes.c_void_p(12))
glEnableVertexAttribArray(1)
glBindVertexArray(0)
return vao
def create_cube(size=1.0):
"""Create a textured cube VAO centered at origin."""
h = size / 2.0
# Each face: 4 vertices with position (x,y,z) + UV (u,v)
# Front face (z = +h)
front = [
-h, -h, h, 0.0, 1.0,
h, -h, h, 1.0, 1.0,
h, h, h, 1.0, 0.0,
-h, h, h, 0.0, 0.0,
]
# Back face (z = -h)
back = [
h, -h, -h, 0.0, 1.0,
-h, -h, -h, 1.0, 1.0,
-h, h, -h, 1.0, 0.0,
h, h, -h, 0.0, 0.0,
]
# Right face (x = +h)
right = [
h, -h, h, 0.0, 1.0,
h, -h, -h, 1.0, 1.0,
h, h, -h, 1.0, 0.0,
h, h, h, 0.0, 0.0,
]
# Left face (x = -h)
left = [
-h, -h, -h, 0.0, 1.0,
-h, -h, h, 1.0, 1.0,
-h, h, h, 1.0, 0.0,
-h, h, -h, 0.0, 0.0,
]
# Top face (y = +h)
top = [
-h, h, h, 0.0, 1.0,
h, h, h, 1.0, 1.0,
h, h, -h, 1.0, 0.0,
-h, h, -h, 0.0, 0.0,
]
# Bottom face (y = -h)
bottom = [
-h, -h, -h, 0.0, 1.0,
h, -h, -h, 1.0, 1.0,
h, -h, h, 1.0, 0.0,
-h, -h, h, 0.0, 0.0,
]
vertices = np.array(front + back + right + left + top + bottom, dtype=np.float32)
# 6 faces, each with 2 triangles (6 indices per face)
indices = []
for i in range(6):
base = i * 4
indices.extend([base, base+1, base+2, base, base+2, base+3])
indices = np.array(indices, dtype=np.uint32)
vao = glGenVertexArrays(1)
vbo = glGenBuffers(1)
ebo = glGenBuffers(1)
glBindVertexArray(vao)
glBindBuffer(GL_ARRAY_BUFFER, vbo)
glBufferData(GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL_STATIC_DRAW)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices, GL_STATIC_DRAW)
# Position attribute (location 0)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 20, ctypes.c_void_p(0))
glEnableVertexAttribArray(0)
# UV attribute (location 1)
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 20, ctypes.c_void_p(12))
glEnableVertexAttribArray(1)
glBindVertexArray(0)
return vao, len(indices)
def create_debug_quad():
"""Create a screen-space quad for debug text overlay."""
# Screen-space quad at top-left
# NDC: x=-1 is left, y=1 is top
w = 680.0 / WINDOW_WIDTH * 2.0
h = 60.0 / WINDOW_HEIGHT * 2.0
vertices = np.array([
-1.0, 1.0, 0.0, 0.0,
-1.0 + w, 1.0, 1.0, 0.0,
-1.0 + w, 1.0 - h, 1.0, 1.0,
-1.0, 1.0 - h, 0.0, 1.0,
], dtype=np.float32)
indices = np.array([0, 1, 2, 0, 2, 3], dtype=np.uint32)
vao = glGenVertexArrays(1)
vbo = glGenBuffers(1)
ebo = glGenBuffers(1)
glBindVertexArray(vao)
glBindBuffer(GL_ARRAY_BUFFER, vbo)
glBufferData(GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL_STATIC_DRAW)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices, GL_STATIC_DRAW)
# Position attribute (location 0) - xy only
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 16, ctypes.c_void_p(0))
glEnableVertexAttribArray(0)
# UV attribute (location 1)
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 16, ctypes.c_void_p(8))
glEnableVertexAttribArray(1)
glBindVertexArray(0)
return vao
# -----------------------------------------------------------------------------
# Debug Text
# -----------------------------------------------------------------------------
DEBUG_VERTEX = """
#version 330 core
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aUV;
out vec2 vUV;
void main() {
vUV = aUV;
gl_Position = vec4(aPos, 0.0, 1.0);
}
"""
DEBUG_FRAGMENT = """
#version 330 core
uniform sampler2D tex;
in vec2 vUV;
out vec4 fragColor;
void main() {
fragColor = texture(tex, vUV);
}
"""
g_debug_program = None
def init_debug_rendering():
"""Initialize debug text rendering resources."""
global g_debug_program
vs = compile_shader(DEBUG_VERTEX, GL_VERTEX_SHADER)
fs = compile_shader(DEBUG_FRAGMENT, GL_FRAGMENT_SHADER)
if vs is None or fs is None:
print("ERROR: Failed to compile debug shaders")
if vs:
glDeleteShader(vs)
if fs:
glDeleteShader(fs)
return
g_debug_program = link_program(vs, fs)
glDeleteShader(vs)
glDeleteShader(fs)
if g_debug_program is None:
print("ERROR: Failed to link debug program")
return
g_state['debug_vao'] = create_debug_quad()
g_state['debug_texture'] = glGenTextures(1)
def update_debug_text():
"""Render debug text to texture."""
if not g_state['debug_dirty']:
return
c0 = g_state['const0']
c1 = g_state['const1']
# Build status lines
lines = [
f"Mode:{g_state['mode']:4s} Filter:{g_state['filter_mode']:9s} Block:{BLOCK_WIDTH}x{BLOCK_HEIGHT} Deblock: [{int(c0[0])}{int(c0[1])}{int(c0[2])}{int(c0[3])}][{int(c1[0])}{int(c1[1])}{int(c1[2])}{int(c1[3])}]",
f"X:{g_state['x']:+5.1f} Y:{g_state['y']:+5.1f} Z:{g_state['z']:5.1f} Yaw:{g_state['yaw']:+6.1f} Pitch:{g_state['pitch']:+6.1f}",
"Arrows:move, W/S:zoom, A/D:yaw, Q/E:pitch, C:cube, B/T/P:filter, 1=deblocking toggle, 2=edge vis, R:reload, Space:reset",
]
img = Image.new('RGBA', (680, 60), (0, 0, 0, 180))
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 14)
except:
font = ImageFont.load_default()
y = 4
for line in lines:
draw.text((6, y), line, fill=(255, 255, 255, 255), font=font)
y += 18
data = np.array(img, dtype=np.uint8)
glBindTexture(GL_TEXTURE_2D, g_state['debug_texture'])
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, img.width, img.height, 0,
GL_RGBA, GL_UNSIGNED_BYTE, data)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
g_state['debug_dirty'] = False
def draw_debug_text():
"""Draw debug text overlay."""
if g_debug_program is None:
return
update_debug_text()
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glDisable(GL_DEPTH_TEST)
glUseProgram(g_debug_program)
glBindTexture(GL_TEXTURE_2D, g_state['debug_texture'])
glBindVertexArray(g_state['debug_vao'])
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, None)
glEnable(GL_DEPTH_TEST)
glDisable(GL_BLEND)
# -----------------------------------------------------------------------------
# Math
# -----------------------------------------------------------------------------
def perspective_matrix(fov_deg, aspect, near, far):
"""Create perspective projection matrix."""
fov_rad = np.radians(fov_deg)
f = 1.0 / np.tan(fov_rad / 2.0)
m = np.zeros((4, 4), dtype=np.float32)
m[0, 0] = f / aspect
m[1, 1] = f
m[2, 2] = (far + near) / (near - far)
m[2, 3] = (2 * far * near) / (near - far)
m[3, 2] = -1.0
return m
def translation_matrix(x, y, z):
"""Create translation matrix."""
m = np.eye(4, dtype=np.float32)
m[0, 3] = x
m[1, 3] = y
m[2, 3] = z
return m
def rotation_matrix_y(deg):
"""Create rotation matrix around Y axis (yaw)."""
rad = np.radians(deg)
c, s = np.cos(rad), np.sin(rad)
m = np.eye(4, dtype=np.float32)
m[0, 0] = c
m[0, 2] = s
m[2, 0] = -s
m[2, 2] = c
return m
def rotation_matrix_x(deg):
"""Create rotation matrix around X axis (pitch)."""
rad = np.radians(deg)
c, s = np.cos(rad), np.sin(rad)
m = np.eye(4, dtype=np.float32)
m[1, 1] = c
m[1, 2] = -s
m[2, 1] = s
m[2, 2] = c
return m
# -----------------------------------------------------------------------------
# Input
# -----------------------------------------------------------------------------
def framebuffer_size_callback(window, width, height):
"""Handle window resize."""
global WINDOW_WIDTH, WINDOW_HEIGHT
WINDOW_WIDTH = width
WINDOW_HEIGHT = height
glViewport(0, 0, width, height)
g_state['debug_dirty'] = True
def key_callback(window, key, scancode, action, mods):
if action == glfw.PRESS:
if key == glfw.KEY_ESCAPE:
glfw.set_window_should_close(window, True)
elif key == glfw.KEY_R:
reload_shader()
elif key == glfw.KEY_B:
set_filter_mode('BILINEAR')
print("Filter: BILINEAR")
elif key == glfw.KEY_P:
set_filter_mode('POINT')
print("Filter: POINT")
elif key == glfw.KEY_T:
set_filter_mode('TRILINEAR')
print("Filter: TRILINEAR")
# Toggle const0 components (keys 1-4)
elif key == glfw.KEY_1:
g_state['const0'][0] = 1.0 - g_state['const0'][0]
print(f"const0: {g_state['const0']}")
g_state['debug_dirty'] = True
elif key == glfw.KEY_2:
g_state['const0'][1] = 1.0 - g_state['const0'][1]
print(f"const0: {g_state['const0']}")
g_state['debug_dirty'] = True
elif key == glfw.KEY_3:
g_state['const0'][2] = 1.0 - g_state['const0'][2]
print(f"const0: {g_state['const0']}")
g_state['debug_dirty'] = True
elif key == glfw.KEY_4:
g_state['const0'][3] = 1.0 - g_state['const0'][3]
print(f"const0: {g_state['const0']}")
g_state['debug_dirty'] = True
# Toggle const1 components (keys 5-8)
elif key == glfw.KEY_5:
g_state['const1'][0] = 1.0 - g_state['const1'][0]
print(f"const1: {g_state['const1']}")
g_state['debug_dirty'] = True
elif key == glfw.KEY_6:
g_state['const1'][1] = 1.0 - g_state['const1'][1]
print(f"const1: {g_state['const1']}")
g_state['debug_dirty'] = True
elif key == glfw.KEY_7:
g_state['const1'][2] = 1.0 - g_state['const1'][2]
print(f"const1: {g_state['const1']}")
g_state['debug_dirty'] = True
elif key == glfw.KEY_8:
g_state['const1'][3] = 1.0 - g_state['const1'][3]
print(f"const1: {g_state['const1']}")
g_state['debug_dirty'] = True
elif key == glfw.KEY_C:
g_state['mode'] = 'CUBE' if g_state['mode'] == 'QUAD' else 'QUAD'
print(f"Mode: {g_state['mode']}")
g_state['debug_dirty'] = True
elif key == glfw.KEY_SPACE:
g_state['x'] = INIT_X
g_state['y'] = INIT_Y
g_state['z'] = INIT_Z
g_state['yaw'] = INIT_YAW
g_state['pitch'] = INIT_PITCH
g_state['const0'] = INIT_CONST0.copy()
g_state['const1'] = INIT_CONST1.copy()
g_state['debug_dirty'] = True
print("Reset to initial state")
def process_held_keys(window, dt):
"""Process continuously held keys."""
moved = False
if glfw.get_key(window, glfw.KEY_W) == glfw.PRESS:
g_state['z'] += Z_SPEED * dt
moved = True
if glfw.get_key(window, glfw.KEY_S) == glfw.PRESS:
g_state['z'] -= Z_SPEED * dt
moved = True
if glfw.get_key(window, glfw.KEY_LEFT) == glfw.PRESS:
g_state['x'] += XY_SPEED * dt
moved = True
if glfw.get_key(window, glfw.KEY_RIGHT) == glfw.PRESS:
g_state['x'] -= XY_SPEED * dt
moved = True
if glfw.get_key(window, glfw.KEY_UP) == glfw.PRESS:
g_state['y'] += XY_SPEED * dt
moved = True
if glfw.get_key(window, glfw.KEY_DOWN) == glfw.PRESS:
g_state['y'] -= XY_SPEED * dt
moved = True
# Rotation (A/D for yaw, Q/E for pitch)
if glfw.get_key(window, glfw.KEY_A) == glfw.PRESS:
g_state['yaw'] += ROT_SPEED * dt
moved = True
if glfw.get_key(window, glfw.KEY_D) == glfw.PRESS:
g_state['yaw'] -= ROT_SPEED * dt
moved = True
if glfw.get_key(window, glfw.KEY_Q) == glfw.PRESS:
g_state['pitch'] += ROT_SPEED * dt
moved = True
if glfw.get_key(window, glfw.KEY_E) == glfw.PRESS:
g_state['pitch'] -= ROT_SPEED * dt
moved = True
# Clamp Z
g_state['z'] = max(Z_MAX, min(Z_MIN, g_state['z']))
if moved:
g_state['debug_dirty'] = True
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
def main():
global BLOCK_WIDTH, BLOCK_HEIGHT
if len(sys.argv) < 5:
print(__doc__)
print("ERROR: Need shader, block_w, block_h, and at least one mipmap PNG")
print("Example: python testbed.py shader.glsl 8 8 mip0.png mip1.png")
sys.exit(1)
shader_path = sys.argv[1]
try:
BLOCK_WIDTH = int(sys.argv[2])
BLOCK_HEIGHT = int(sys.argv[3])
except ValueError:
print(f"ERROR: block_w and block_h must be integers, got '{sys.argv[2]}' '{sys.argv[3]}'")
sys.exit(1)
if BLOCK_WIDTH < 1 or BLOCK_HEIGHT < 1:
print(f"ERROR: block size must be positive, got {BLOCK_WIDTH}x{BLOCK_HEIGHT}")
sys.exit(1)
mip_paths = sys.argv[4:]
print(f"Block size: {BLOCK_WIDTH}x{BLOCK_HEIGHT}")
g_state['shader_path'] = shader_path
# Init GLFW
if not glfw.init():
print("ERROR: Failed to initialize GLFW")
sys.exit(1)
glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3)
glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3)
glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE)
glfw.window_hint(glfw.RESIZABLE, glfw.TRUE)
glfw.window_hint(glfw.FOCUSED, glfw.TRUE)
glfw.window_hint(glfw.FOCUS_ON_SHOW, glfw.TRUE)
window = glfw.create_window(WINDOW_WIDTH, WINDOW_HEIGHT, "Deblock Shader Testbed", None, None)
if not window:
glfw.terminate()
print("ERROR: Failed to create window")
sys.exit(1)
glfw.make_context_current(window)
glfw.set_key_callback(window, key_callback)
glfw.set_framebuffer_size_callback(window, framebuffer_size_callback)
glfw.swap_interval(1) # VSync
glfw.focus_window(window)
print(f"OpenGL: {glGetString(GL_VERSION).decode()}")
# Load shader (exit on failure at startup)
g_state['program'] = load_shader(shader_path)
if g_state['program'] is None:
glfw.terminate()
sys.exit(1)
# Load texture
g_state['texture'], g_state['tex_size'] = load_mipmap_texture(mip_paths)
set_filter_mode('BILINEAR')
# Create quad
aspect = g_state['tex_size'][0] / g_state['tex_size'][1]
g_state['quad_vao'] = create_quad(aspect)
# Create cube
g_state['cube_vao'], g_state['cube_index_count'] = create_cube(1.0)
# Init debug rendering
init_debug_rendering()
glEnable(GL_DEPTH_TEST)
glClearColor(0.2, 0.2, 0.2, 1.0)
g_state['last_time'] = glfw.get_time()
# Main loop
while not glfw.window_should_close(window):
# Delta time
now = glfw.get_time()
dt = now - g_state['last_time']
g_state['last_time'] = now
# Input
glfw.poll_events()
process_held_keys(window, dt)
# Projection matrix (recalculate for resize)
proj = perspective_matrix(FOV_DEGREES, WINDOW_WIDTH / WINDOW_HEIGHT, 0.001, 100.0)
# Clear
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
# Draw quad or cube
glUseProgram(g_state['program'])
# MVP (include rotation for cube mode)
trans = translation_matrix(g_state['x'], g_state['y'], g_state['z'])
rot_y = rotation_matrix_y(g_state['yaw'])
rot_x = rotation_matrix_x(g_state['pitch'])
model = trans @ rot_y @ rot_x
mvp = proj @ model
loc = glGetUniformLocation(g_state['program'], "mvp")
if loc >= 0:
glUniformMatrix4fv(loc, 1, GL_TRUE, mvp)
loc = glGetUniformLocation(g_state['program'], "tex")
if loc >= 0:
glUniform1i(loc, 0)
loc = glGetUniformLocation(g_state['program'], "texSize")
if loc >= 0:
glUniform4f(loc, float(g_state['tex_size'][0]), float(g_state['tex_size'][1]), BLOCK_WIDTH, BLOCK_HEIGHT);
loc = glGetUniformLocation(g_state['program'], "const0")
if loc >= 0:
c = g_state['const0']
glUniform4f(loc, c[0], c[1], c[2], c[3])
loc = glGetUniformLocation(g_state['program'], "const1")
if loc >= 0:
c = g_state['const1']
glUniform4f(loc, c[0], c[1], c[2], c[3])
glActiveTexture(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_2D, g_state['texture'])
if g_state['mode'] == 'CUBE':
glBindVertexArray(g_state['cube_vao'])
glDrawElements(GL_TRIANGLES, g_state['cube_index_count'], GL_UNSIGNED_INT, None)
else:
glBindVertexArray(g_state['quad_vao'])
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, None)
# Draw debug overlay
draw_debug_text()
glfw.swap_buffers(window)
glfw.terminate()
print("Done.")
if __name__ == "__main__":
main()