mirror of
https://github.com/BinomialLLC/basis_universal.git
synced 2026-07-04 13:08:59 +00:00
1195 lines
41 KiB
Python
1195 lines
41 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Mipmap Compatible Texture Sampling Deblocking Shader Testbed
|
|
Copyright (C) 2026 Binomial LLC.
|
|
LICENSE: Apache 2.0
|
|
|
|
The fragment+vertex shader is always loaded from "shader.glsl" in the current dir.
|
|
|
|
Usage:
|
|
python testbed.py file.ktx2
|
|
Load a Basis Universal .ktx2: transcode to a GPU-compressed format
|
|
(ASTC -> BC7 -> ETC2, whichever the GPU supports; falls back to
|
|
uncompressed RGBA8 if none), upload all mips. Deblock filter block size
|
|
and on/off come from the file's metadata.
|
|
|
|
python testbed.py block_w block_h mip0.png [mip1.png ...]
|
|
Load PNG mip levels as uncompressed RGBA8 (development/debugging).
|
|
block_w, block_h: deblock filter block size in texels (e.g. 12 12).
|
|
|
|
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 *
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Basis Universal transcoder bindings
|
|
# -----------------------------------------------------------------------------
|
|
# Locate the basisu_py package by searching upward from this file for a directory
|
|
# that contains basisu_py/ (or python/basisu_py/). This lets the sample run from
|
|
# either python/shader_deblocking/ or another copy elsewhere in the repo.
|
|
def _add_basisu_to_path():
|
|
d = os.path.dirname(os.path.abspath(__file__))
|
|
while True:
|
|
for cand in (d, os.path.join(d, "python")):
|
|
if os.path.isdir(os.path.join(cand, "basisu_py")):
|
|
if cand not in sys.path:
|
|
sys.path.insert(0, cand)
|
|
return cand
|
|
parent = os.path.dirname(d)
|
|
if parent == d: # reached filesystem root
|
|
return None
|
|
d = parent
|
|
|
|
_basisu_dir = _add_basisu_to_path()
|
|
try:
|
|
from basisu_py import Transcoder
|
|
from basisu_py.constants import TranscoderTextureFormat as TF, TranscodeDecodeFlags
|
|
except Exception as e:
|
|
print(f"ERROR: Failed to import basisu_py transcoder bindings: {e}")
|
|
print(f" Searched upward from {os.path.dirname(os.path.abspath(__file__))}; "
|
|
f"basisu_py found in: {_basisu_dir}")
|
|
sys.exit(1)
|
|
|
|
|
|
def load_transcoder():
|
|
"""Create the Basis Universal transcoder (native .pyd preferred, WASM fallback)."""
|
|
print("Loading Basis Universal transcoder...")
|
|
try:
|
|
t = Transcoder()
|
|
except Exception as e:
|
|
print(f"ERROR: Could not initialize Basis Universal transcoder: {e}")
|
|
sys.exit(1)
|
|
print(f"Transcoder loaded: backend={t.backend_name} version={t.get_version()}")
|
|
return t
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# GPU compressed texture formats + selection ladder (ASTC -> BC7 -> ETC2)
|
|
# -----------------------------------------------------------------------------
|
|
# GL internal-format enums declared explicitly: PyOpenGL's core namespace does not
|
|
# expose the ASTC KHR enums on desktop, so we keep all three here for consistency.
|
|
GL_FMT_BC7 = 0x8E8C # GL_COMPRESSED_RGBA_BPTC_UNORM (BC7, 4x4 blocks)
|
|
GL_FMT_ETC2_RGBA = 0x9278 # GL_COMPRESSED_RGBA8_ETC2_EAC (ETC2 RGBA, 4x4 blocks)
|
|
|
|
# 14 ASTC LDR block sizes -> GL_COMPRESSED_RGBA_ASTC_<w>x<h>_KHR (0x93B0..0x93BD).
|
|
ASTC_GL_FORMATS = {
|
|
(4, 4): 0x93B0, (5, 4): 0x93B1, (5, 5): 0x93B2, (6, 5): 0x93B3,
|
|
(6, 6): 0x93B4, (8, 5): 0x93B5, (8, 6): 0x93B6, (8, 8): 0x93B7,
|
|
(10, 5): 0x93B8, (10, 6): 0x93B9, (10, 8): 0x93BA, (10, 10): 0x93BB,
|
|
(12, 10): 0x93BC, (12, 12): 0x93BD,
|
|
}
|
|
|
|
# Extension names that gate each capability, across desktop GL, native GLES, and
|
|
# WebGL, per the Khronos registries. We match on extension strings only (works for
|
|
# WebGL, where these formats are NEVER core and MUST be queried this way -- e.g.
|
|
# WebGL 2 does not guarantee ETC2 despite being GLES 3.0 based). WebGL reports
|
|
# names without the "GL_" prefix, so both forms are listed.
|
|
# ASTC LDR : desktop/GLES KHR, GLES OES, WebGL.
|
|
ASTC_EXTS = ("GL_KHR_texture_compression_astc_ldr",
|
|
"GL_OES_texture_compression_astc",
|
|
"WEBGL_compressed_texture_astc")
|
|
# BC7 (BPTC): desktop ARB (core in GL 4.2), GLES/WebGL 2 EXT.
|
|
BC7_EXTS = ("GL_ARB_texture_compression_bptc",
|
|
"GL_EXT_texture_compression_bptc",
|
|
"EXT_texture_compression_bptc")
|
|
# ETC2/EAC : desktop via ARB_ES3_compatibility (core in GL 4.3); native GLES 3.0+
|
|
# OES alias; WebGL extension (ETC2 is NOT core in WebGL 2).
|
|
ETC2_EXTS = ("GL_ARB_ES3_compatibility",
|
|
"GL_OES_compressed_ETC2_RGBA8_texture",
|
|
"WEBGL_compressed_texture_etc")
|
|
|
|
|
|
def query_gl_extensions():
|
|
"""Return the set of supported GL extension strings (core-profile safe)."""
|
|
exts = set()
|
|
n = glGetIntegerv(GL_NUM_EXTENSIONS)
|
|
for i in range(int(n)):
|
|
exts.add(glGetStringi(GL_EXTENSIONS, i).decode())
|
|
return exts
|
|
|
|
|
|
def detect_compressed_caps(exts):
|
|
"""Which transcode targets the current GL context can accept."""
|
|
return {
|
|
'ASTC': any(e in exts for e in ASTC_EXTS),
|
|
'BC7': any(e in exts for e in BC7_EXTS),
|
|
'ETC2': any(e in exts for e in ETC2_EXTS),
|
|
}
|
|
|
|
|
|
def ktx2_format_name(t, h):
|
|
"""Short human-readable name of the KTX2's source (basis) format."""
|
|
bw, bh = t.get_block_width(h), t.get_block_height(h)
|
|
if t.is_etc1s(h): return "ETC1S"
|
|
if t.is_uastc_ldr_4x4(h): return "UASTC LDR 4x4"
|
|
if t.is_xuastc_ldr(h): return f"XUASTC LDR {bw}x{bh}"
|
|
if t.is_astc_ldr(h): return f"ASTC LDR {bw}x{bh}"
|
|
if t.is_xubc7(h): return "XUBC7"
|
|
if t.is_hdr_4x4(h): return "UASTC HDR 4x4"
|
|
if t.is_hdr_6x6(h): return f"HDR 6x6 {bw}x{bh}"
|
|
if t.is_hdr(h): return "HDR"
|
|
return f"basis_fmt {t.get_basis_tex_format(h)}"
|
|
|
|
|
|
def build_load_plan(caps, t, h):
|
|
"""
|
|
Examine an open KTX2 handle and decide: (1) the GPU storage format via the
|
|
ASTC -> BC7 -> ETC2 ladder (gated by GL caps AND transcoder support for this
|
|
file), and (2) the in-shader deblocking decision.
|
|
|
|
Returns a plan dict, or None if no target format is usable.
|
|
|
|
Key rule: the deblock FILTER block size always comes from the file's native
|
|
ASTC/XUASTC block size, independent of the GPU storage format's block size
|
|
(e.g. BC7 stores 4x4 blocks but we still filter the original 12x12 lattice).
|
|
"""
|
|
basis_fmt = t.get_basis_tex_format(h)
|
|
file_bw, file_bh = t.get_block_width(h), t.get_block_height(h)
|
|
|
|
def supported(tfmt):
|
|
return bool(t.basis_is_format_supported(tfmt, basis_fmt))
|
|
|
|
chosen = None
|
|
if caps['ASTC']:
|
|
astc_tf = t.basis_get_transcoder_texture_format_from_basis_tex_format(basis_fmt)
|
|
gl_fmt = ASTC_GL_FORMATS.get((file_bw, file_bh))
|
|
if gl_fmt is not None and supported(astc_tf):
|
|
chosen = dict(family='ASTC', tfmt=astc_tf, gl_format=gl_fmt,
|
|
gpu_bw=file_bw, gpu_bh=file_bh, compressed=True)
|
|
if chosen is None and caps['BC7'] and supported(TF.TF_BC7_RGBA):
|
|
chosen = dict(family='BC7', tfmt=TF.TF_BC7_RGBA,
|
|
gl_format=GL_FMT_BC7, gpu_bw=4, gpu_bh=4, compressed=True)
|
|
if chosen is None and caps['ETC2'] and supported(TF.TF_ETC2_RGBA):
|
|
chosen = dict(family='ETC2', tfmt=TF.TF_ETC2_RGBA,
|
|
gl_format=GL_FMT_ETC2_RGBA, gpu_bw=4, gpu_bh=4, compressed=True)
|
|
if chosen is None and supported(TF.TF_RGBA32):
|
|
# No GPU-compressed format available: fall back to uncompressed RGBA8.
|
|
chosen = dict(family='RGBA32', tfmt=TF.TF_RGBA32,
|
|
gl_format=GL_RGBA8, gpu_bw=1, gpu_bh=1, compressed=False)
|
|
if chosen is None:
|
|
return None
|
|
|
|
# Deblocking decision is independent of the GPU storage format.
|
|
deblock_id = t.get_deblocking_filter_index(h)
|
|
chosen.update(
|
|
filter_bw=file_bw, filter_bh=file_bh,
|
|
deblock_id=deblock_id,
|
|
deblock_enabled=(deblock_id == 1),
|
|
levels=t.get_levels(h),
|
|
base_w=t.get_width(h), base_h=t.get_height(h),
|
|
basis_fmt=basis_fmt,
|
|
format_name=ktx2_format_name(t, h),
|
|
)
|
|
return chosen
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Globals
|
|
# -----------------------------------------------------------------------------
|
|
WINDOW_WIDTH = 1280
|
|
WINDOW_HEIGHT = 720
|
|
FOV_DEGREES = 90.0
|
|
Z_MIN = .40
|
|
Z_MAX = -50.0
|
|
Z_SPEED = 1.0
|
|
XY_SPEED = .75
|
|
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': 'TRILINEAR',
|
|
'shader_path': None,
|
|
'transcoder': None,
|
|
'gl_caps': None,
|
|
'tex_info': 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 uncompressed RGBA8 mipmap levels. Returns (texture, 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 (each level half the previous)
|
|
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)
|
|
|
|
texture = glGenTextures(1)
|
|
glBindTexture(GL_TEXTURE_2D, texture)
|
|
|
|
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 load_ktx2_texture(t, caps, path):
|
|
"""
|
|
Open a KTX2 file, decide the GPU storage format via the ASTC->BC7->ETC2 ladder
|
|
(falling back to uncompressed RGBA8 if the GPU supports none of them), transcode
|
|
every mip level, and upload them as a GL texture.
|
|
|
|
Each level is uploaded at its ORIGINAL (unpadded) width/height. For compressed
|
|
formats the transcoded block buffer (which covers the padded block grid) is the
|
|
data, and GL computes ceil(orig/blk) blocks to match len(blocks) exactly.
|
|
|
|
Returns (texture_handle, (base_w, base_h), plan_dict). Exits on error.
|
|
"""
|
|
print(f"Loading KTX2: {path}")
|
|
try:
|
|
data = open(path, "rb").read()
|
|
except OSError as e:
|
|
print(f"ERROR: Could not read KTX2 file '{path}': {e}")
|
|
sys.exit(1)
|
|
|
|
h = t.open(data)
|
|
|
|
# This sample handles LDR textures only.
|
|
if t.is_hdr(h):
|
|
t.close(h)
|
|
print(f"ERROR: '{path}' is an HDR texture; this sample supports LDR textures only.")
|
|
sys.exit(1)
|
|
|
|
plan = build_load_plan(caps, t, h)
|
|
if plan is None:
|
|
basis_fmt = t.get_basis_tex_format(h)
|
|
t.close(h)
|
|
print(f"ERROR: No usable transcode target for this file (basis_fmt={basis_fmt}). Exiting.")
|
|
sys.exit(1)
|
|
|
|
print(f" Source : basis_fmt={plan['basis_fmt']} "
|
|
f"{plan['base_w']}x{plan['base_h']} levels={plan['levels']}")
|
|
if plan['compressed']:
|
|
print(f" GPU format : {plan['family']} gl_internal=0x{plan['gl_format']:04X} "
|
|
f"block={plan['gpu_bw']}x{plan['gpu_bh']}")
|
|
else:
|
|
print(f" GPU format : {plan['family']} (uncompressed) "
|
|
f"gl_internal=0x{plan['gl_format']:04X}")
|
|
print(f" Deblock : {'ON' if plan['deblock_enabled'] else 'off'} "
|
|
f"filter block={plan['filter_bw']}x{plan['filter_bh']}")
|
|
|
|
texture = glGenTextures(1)
|
|
glBindTexture(GL_TEXTURE_2D, texture)
|
|
|
|
for lvl in range(plan['levels']):
|
|
ow = t.get_level_orig_width(h, lvl)
|
|
oh = t.get_level_orig_height(h, lvl)
|
|
# Disable the transcoder's CPU deblocking: we deblock on the GPU in the
|
|
# shader instead, so CPU deblocking here would double-filter the result.
|
|
blocks = t.transcode_tfmt_handle(
|
|
h, plan['tfmt'], level=lvl,
|
|
decode_flags=TranscodeDecodeFlags.NO_DEBLOCK_FILTERING)
|
|
data_arr = np.frombuffer(blocks, dtype=np.uint8)
|
|
if plan['compressed']:
|
|
# PyOpenGL derives imageSize from the array byte count; do NOT pass it
|
|
# explicitly (doing so raises 'NumberHandler has no attribute arrayByteCount').
|
|
glCompressedTexImage2D(GL_TEXTURE_2D, lvl, plan['gl_format'],
|
|
ow, oh, 0, data_arr)
|
|
else:
|
|
glTexImage2D(GL_TEXTURE_2D, lvl, plan['gl_format'],
|
|
ow, oh, 0, GL_RGBA, GL_UNSIGNED_BYTE, data_arr)
|
|
err = glGetError()
|
|
if err != GL_NO_ERROR:
|
|
t.close(h)
|
|
print(f"ERROR: texture upload failed at level {lvl} "
|
|
f"({ow}x{oh}, {len(blocks)} bytes, fmt=0x{plan['gl_format']:04X}): "
|
|
f"GL error 0x{err:04X}")
|
|
sys.exit(1)
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, plan['levels'] - 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)
|
|
|
|
t.close(h)
|
|
print(f" Uploaded {plan['levels']} mip level(s).")
|
|
return texture, (plan['base_w'], plan['base_h']), plan
|
|
|
|
|
|
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 = 80.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']
|
|
|
|
# First line: source texture metadata.
|
|
ti = g_state['tex_info']
|
|
if ti:
|
|
deblk = "-" if ti['deblock_id'] is None else str(ti['deblock_id'])
|
|
line_info = (f"Res:{ti['orig_w']}x{ti['orig_h']} Mips:{ti['mips']} "
|
|
f"Block:{ti['block_w']}x{ti['block_h']} DeblockID:{deblk} "
|
|
f"Fmt:{ti['format']}")
|
|
else:
|
|
line_info = "(no texture loaded)"
|
|
|
|
# Build status lines
|
|
lines = [
|
|
line_info,
|
|
f"Mode:{g_state['mode']:4s} Filter:{g_state['filter_mode']:9s} 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=deblock, 2=edge vis, R:reload, Space:reset",
|
|
]
|
|
|
|
img = Image.new('RGBA', (680, 80), (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_LEFT_SHIFT) == glfw.PRESS or \
|
|
glfw.get_key(window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS:
|
|
dt *= 1.0 / 3.0
|
|
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
|
|
|
|
# Load the Basis Universal transcoder FIRST, before any GL/window setup.
|
|
g_state['transcoder'] = load_transcoder()
|
|
|
|
if len(sys.argv) < 2:
|
|
print(__doc__)
|
|
print("ERROR: Need either a .ktx2 file or PNG mip level(s)")
|
|
print(" python testbed.py file.ktx2")
|
|
print(" python testbed.py 12 12 mip0.png [mip1.png ...]")
|
|
sys.exit(1)
|
|
|
|
# The shader is always loaded from shader.glsl in the current directory.
|
|
shader_path = "shader.glsl"
|
|
g_state['shader_path'] = shader_path
|
|
|
|
# Dispatch on the first argument: a single .ktx2, otherwise PNG mode.
|
|
is_ktx2 = sys.argv[1].lower().endswith(".ktx2")
|
|
ktx2_path = None
|
|
mip_paths = None
|
|
if is_ktx2:
|
|
if len(sys.argv) != 2:
|
|
print(__doc__)
|
|
print("ERROR: KTX2 mode takes exactly one file: testbed.py file.ktx2")
|
|
sys.exit(1)
|
|
ktx2_path = sys.argv[1]
|
|
print(f"KTX2 mode: {ktx2_path}")
|
|
else:
|
|
# PNG mode: block_w block_h mip0.png [mip1.png ...]
|
|
if len(sys.argv) < 4:
|
|
print(__doc__)
|
|
print("ERROR: PNG mode needs block_w block_h and at least one PNG")
|
|
print("Example: python testbed.py 12 12 mip0.png mip1.png")
|
|
sys.exit(1)
|
|
try:
|
|
BLOCK_WIDTH = int(sys.argv[1])
|
|
BLOCK_HEIGHT = int(sys.argv[2])
|
|
except ValueError:
|
|
print(f"ERROR: block_w and block_h must be integers, got '{sys.argv[1]}' '{sys.argv[2]}'")
|
|
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[3:]
|
|
print(f"PNG mode: block size {BLOCK_WIDTH}x{BLOCK_HEIGHT}, {len(mip_paths)} level(s)")
|
|
|
|
# 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.OPENGL_FORWARD_COMPAT, glfw.TRUE) # required for a core context on macOS
|
|
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()}")
|
|
|
|
# Detect which compressed GPU formats this context accepts (ASTC -> BC7 -> ETC2).
|
|
g_state['gl_caps'] = detect_compressed_caps(query_gl_extensions())
|
|
caps = g_state['gl_caps']
|
|
print(f"GPU compressed format support: ASTC={caps['ASTC']} BC7={caps['BC7']} ETC2={caps['ETC2']}")
|
|
if is_ktx2 and not any(caps.values()):
|
|
print("Note: no GPU-compressed format (ASTC/BC7/ETC2) available; the KTX2 "
|
|
"will be transcoded to uncompressed RGBA8.")
|
|
|
|
# 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 the texture: KTX2 (transcode -> compressed) or PNG (uncompressed RGBA8).
|
|
if is_ktx2:
|
|
g_state['texture'], g_state['tex_size'], plan = load_ktx2_texture(
|
|
g_state['transcoder'], caps, ktx2_path)
|
|
g_state['mip_count'] = plan['levels']
|
|
|
|
# Deblock filter block size feeds texSize.zw; default the shader's deblock
|
|
# toggle (const0.x) from the file's DeblockFilterID. The '1' key still toggles it.
|
|
BLOCK_WIDTH, BLOCK_HEIGHT = plan['filter_bw'], plan['filter_bh']
|
|
deblock_default = 1.0 if plan['deblock_enabled'] else 0.0
|
|
g_state['const0'][0] = deblock_default
|
|
INIT_CONST0[0] = deblock_default
|
|
g_state['tex_info'] = {
|
|
'orig_w': plan['base_w'], 'orig_h': plan['base_h'],
|
|
'mips': plan['levels'],
|
|
'block_w': plan['filter_bw'], 'block_h': plan['filter_bh'],
|
|
'deblock_id': plan['deblock_id'],
|
|
'format': plan['format_name'],
|
|
}
|
|
else:
|
|
# PNG mode: BLOCK_WIDTH/HEIGHT came from the CLI; deblock defaults off
|
|
# (press '1' to enable), matching the original sample's behavior.
|
|
g_state['texture'], g_state['tex_size'] = load_mipmap_texture(mip_paths)
|
|
g_state['mip_count'] = len(mip_paths)
|
|
g_state['tex_info'] = {
|
|
'orig_w': g_state['tex_size'][0], 'orig_h': g_state['tex_size'][1],
|
|
'mips': len(mip_paths),
|
|
'block_w': BLOCK_WIDTH, 'block_h': BLOCK_HEIGHT,
|
|
'deblock_id': None,
|
|
'format': 'PNG RGBA8',
|
|
}
|
|
|
|
set_filter_mode('TRILINEAR')
|
|
|
|
# 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'], "maxLod")
|
|
if loc >= 0:
|
|
glUniform1f(loc, float(g_state['mip_count'] - 1))
|
|
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()
|