diff --git a/python/shader_deblocking/README.md b/python/shader_deblocking/README.md new file mode 100644 index 0000000..e0dc90e --- /dev/null +++ b/python/shader_deblocking/README.md @@ -0,0 +1,170 @@ +# Python+GLSL Shader Deblocking Sample + +*Block boundaries are predictable.* + +This sample demonstrates how to use a simple pixel shader to greatly reduce +ASTC texture block artifacts, which can be quite noticeable when the block size goes +beyond roughly 6x6. The basic idea: instead of always sampling the texture using +a single tap, you instead sample the texture either one time or X times with a simple low pass filter, +depending on whether or not the sample location is near a block edge. The multiple filter taps around +the center sample blur across block boundaries of ASTC compressed textures. There are two independent filters, for horizontal and vertical block boundaries. + +The example shader is compatible with mipmapping, bilinear filtering, trilinear filtering etc. and is temporally stable. The +shader smoothly lerps between no filtering and edge filtering, and is mipmap-aware by using the pixel shader derivative instructions. Crucially, the block lattice is evaluated in the *effective mip space*, not in base texture space, which is why it's mipmap-aware. The Python sample shows either a textured quad or a cube, with various controls to move the object, rotate the cube, toggle the deblocking shader on/off, change the texture filtering, etc. + +The sample loads textures either directly from a Basis Universal `.KTX2` file — transcoding it to a GPU-compressed format (ASTC, BC7, or ETC2) and uploading the compressed mip levels — or from a set of `.PNG` mip levels (uncompressed RGBA8, for development/debugging). When loading a `.KTX2`, the deblock filter block size and whether to deblock are taken from the file's metadata. + +It was written to be as simple as possible. The shader's filter coefficients were picked for more blurring vs. our CPU deblocker to demonstrate the effect, but they are easily tuned. The shader could easily be more optimized (most inner block texels don't need any filtering, but we don't exploit this in the shader with a conditional yet). The idea is compatible with other texture formats with noticeable block artifacts, such as BC1 or ETC1, but 4x4 blocks are so tiny it may be a wash. + +It's also possible to add adaptivity to this shader, so it doesn't blindly blur across sharp edges - like we do while +CPU deblocking before transcoding to BC7 or other LDR texture formats. It's also possible +to add deblocking filter awareness to our ASTC/XUASTC/etc. encoders. + +The shader computes the mipmap LOD index assuming a full mip chain, then clamps it to `maxLod` (the number of loaded mip levels minus one), which the host passes in from the loaded `.KTX2` or `.PNG` set. This means partial chains (e.g. a single-level `.KTX2`) are sampled without reaching absent mip levels. + +Note: We're amazed the GPU hardware vendors haven't implemented this feature directly in silicon yet. It's extremely useful, even necessary at the largest ASTC block sizes. +This is a form of "GPU texture compression-aware shading" or "GPU format-informed reconstruction". + +## Running the Sample + +You'll need these Python dependencies: +``` +pip install numpy Pillow glfw PyOpenGL +``` + +You also need the **Basis Universal Python bindings** (`basisu_py`), which provide the transcoder used to load `.KTX2` files. This sample lives in `python/shader_deblocking/` inside the Basis Universal repo so that `basisu_py` (in `python/`) can be found; `testbed.py` adds the package to `sys.path` automatically by searching upward from its own location, so you can run it from its directory. + +On **macOS** (and any platform without a prebuilt native transcoder binary), `basisu_py` uses its WebAssembly backend, which requires the `wasmtime` Python package: +``` +pip install wasmtime +``` + +You may also want "PyOpenGL_accelerate", and under Linux you may need the system package "libglfw3". We developed this sample under Windows 11. + +The sample loads textures two ways: + +The shader is always loaded from `shader.glsl` in the current directory, so it is not passed on the command line. + +**1. Directly from a `.KTX2` file (recommended):** +``` +python testbed.py kodim26_12x12.ktx2 +``` +The transcoder opens the `.KTX2`, the sample picks a GPU-compressed format and uploads all mip levels (see "KTX2 Loading" below), and the deblock filter block size and on/off state come from the file's metadata. + +**2. From one or more `.PNG` mip levels (development/debugging):** +``` +python testbed.py 12 12 mip0.png mip1.png [mip2.png ...] +``` +PNGs are uploaded as uncompressed 32bpp RGBA8. The two integers are the deblock filter block size in texels (e.g. `12 12`); deblocking defaults to off (press `1`). + +Depending on your setup you may need to use `python3`, or `py -3.12`, etc. + +Keys: `1` toggles the deblocking shader, `2` toggles the edge visualization (only shows when deblocking is enabled), arrow keys move the object, `W`/`S`: forward/backward, `A`/`D`/`Q`/`E`: yaw/pitch, `C`: toggle cube vs. quad. (See the source code remarks and the Controls section below.) + +The shader can be easily simplified to sample the texture less by using fewer taps. The current shader uses 5 taps (the center sample plus its 4 axis neighbors). + +Many variations and optimizations of this basic idea are possible. *Now shader engineers can directly impact memory consumption.* The better your deblocking shaders are tuned or your specific content, the bigger the ASTC block size you can ship. ASTC texture deblocking pixel shader engineering is now a memory optimization skill. + +--- + +## KTX2 Loading + +When given a `.KTX2`, the sample inspects the file and selects a GPU-compressed texture format, preferring **ASTC**, then **BC7**, then **ETC2 RGBA**, based on which compressed-texture extensions the OpenGL/OpenGL ES/WebGL context advertises (e.g. `GL_KHR_texture_compression_astc_ldr`, `GL_ARB_texture_compression_bptc`, `GL_ARB_ES3_compatibility`, plus the GLES `OES`/WebGL `WEBGL_`/`EXT_` variants). If the GPU supports none of the three, the sample **falls back to uncompressed 32bpp RGBA8** — the transcoder transcodes each level to RGBA and it is uploaded with `glTexImage2D`, so a `.KTX2` always loads on any GL context. Compressed levels are uploaded via `glCompressedTexImage2D`, one call per mip level (each level at its original/unpadded width and height). + +Note that ASTC is rarely exposed by *desktop* OpenGL drivers — it's mostly a mobile/GLES feature — so on a typical desktop GPU the sample selects BC7. + +**Block size decoupling:** the deblocking filter always uses the *original* ASTC/XUASTC block size stored in the `.KTX2` (e.g. 12x12), independent of the GPU storage format's block size. The block artifacts are baked into the content by the original encode and survive a re-transcode to, say, BC7's 4x4 blocks — so the shader filters the original 12x12 lattice even when the texture is stored on the GPU as BC7. + +**Deblocking metadata:** Basis Universal writes a `DeblockFilterID` key into the `.KTX2`, queried via `get_deblocking_filter_index()`. When it is 1, the sample enables shader deblocking by default (the `1` key still toggles it manually). Because the *GPU shader* performs the deblocking, the transcoder's own CPU deblocking is disabled during transcode (the `NO_DEBLOCK_FILTERING` decode flag) to avoid double-filtering. + +The on-screen overlay's first line reports the source resolution, mip count, block size, `DeblockID`, and the `.KTX2` format (e.g. `XUASTC LDR 12x12`). + +--- + +## Performance + +The deblocking shader itself can be put inside a dynamic `if` conditional, so the extra ALU/texture ops only kick in near block edges (which are the minority of samples at ASTC 12x12). The extra sample taps are spatially always near the center sample, so they'll hit the texture cache most of the time. In texture bandwidth bound rendering scenarios (quite common on mobile platforms), the extra ALU ops for deblocking likely come for "free". + +*Another perspective: The alternative to not deblocking is ~2x-8x more GPU memory bandwidth (and increased download size) to use smaller ASTC block sizes which have less noticeable block artifacts.* + +--- + +## Screenshots - ASTC 12x12 Block Size + +*Note: These screenshots are from our original deblocking filter experiments, before our encoder was modified to optimize globally for deblocking.* + +**Disabled:** +![Screenshot 1: Off](screenshots/1_off.png) + +**Enabled:** +![Screenshot 1: On](screenshots/1_on.png) + +--- + +**Disabled:** +![Screenshot 2: Off](screenshots/2_off.png) + +**Enabled:** +![Screenshot 2: On](screenshots/2_on.png) + +--- + +**Disabled:** +![Screenshot 3: Off](screenshots/3_off.png) + +**Enabled:** +![Screenshot 3: On](screenshots/3_on.png) + +--- + +**Block Edge Computation Visualization:** +![Edge Vis](screenshots/edge_vis.png) +![Edge Vis 2](screenshots/edge_vis2.png) + +These screenshots show how the pixel shader computes texture block boundaries in effective mipmap space. To see this visualization, press '1' to enable deblocking, then '2' to enable block edge visualization. Only white areas in this visualization are modified by this shader, leaving the inner block texels unmodified. + +**Obviously, it's crucial that the deblock filter block size exactly matches the source ASTC/XUASTC block size, or the filtering won't align with the actual block artifacts. For `.KTX2` files this is read from the file automatically; for the `.PNG` path you pass it on the command line (e.g. `12 12`).** + +--- + +## Usage and Controls + +The sample either renders a single textured quad or a cube. Press 'C' to toggle between them. The '1' key toggles shader deblocking — it defaults to **on** for `.KTX2` files whose `DeblockFilterID` is 1, and **off** otherwise (including the `.PNG` path). The '2' key enables edge visualization, which only works when deblocking is enabled. Texture filtering defaults to **trilinear**; use `B`/`T`/`P` to switch to bilinear/trilinear/point. + +Other keys can be used to move around the quad, rotate the cube etc.: + +``` +Usage: + python testbed.py file.ktx2 + python testbed.py block_w block_h mip0.png [mip1.png ...] + block_w, block_h: deblock filter block size in texels (PNG mode only, e.g. 12 12) + The shader is always loaded from shader.glsl in the current directory. + +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 (const0.x) + 2 Toggle edge visualization (const0.y; only when deblocking active) + 3 / 4 Toggle spare shader const0.z / const0.w (0 <-> 1) + 5-8 Toggle spare shader const1.x / y / z / w (0 <-> 1) + Space Reset to initial state + Esc Quit +``` + +--- + +## Credits + +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." + diff --git a/python/shader_deblocking/kodim14_12x12.ktx2 b/python/shader_deblocking/kodim14_12x12.ktx2 new file mode 100644 index 0000000..639f77a Binary files /dev/null and b/python/shader_deblocking/kodim14_12x12.ktx2 differ diff --git a/python/shader_deblocking/kodim26_12x12.ktx2 b/python/shader_deblocking/kodim26_12x12.ktx2 new file mode 100644 index 0000000..b69d697 Binary files /dev/null and b/python/shader_deblocking/kodim26_12x12.ktx2 differ diff --git a/python/shader_deblocking/kodim26_xubc7.ktx2 b/python/shader_deblocking/kodim26_xubc7.ktx2 new file mode 100644 index 0000000..a8de4ed Binary files /dev/null and b/python/shader_deblocking/kodim26_xubc7.ktx2 differ diff --git a/python/shader_deblocking/run.bat b/python/shader_deblocking/run.bat new file mode 100644 index 0000000..b23e630 --- /dev/null +++ b/python/shader_deblocking/run.bat @@ -0,0 +1,2 @@ +@REM This assumes py and python 3.12 is installed +py -3.12 testbed.py kodim26_12x12.ktx2 diff --git a/python/shader_deblocking/screenshots/1_off.png b/python/shader_deblocking/screenshots/1_off.png new file mode 100644 index 0000000..1f04f2c Binary files /dev/null and b/python/shader_deblocking/screenshots/1_off.png differ diff --git a/python/shader_deblocking/screenshots/1_on.png b/python/shader_deblocking/screenshots/1_on.png new file mode 100644 index 0000000..e2d8e24 Binary files /dev/null and b/python/shader_deblocking/screenshots/1_on.png differ diff --git a/python/shader_deblocking/screenshots/2_off.png b/python/shader_deblocking/screenshots/2_off.png new file mode 100644 index 0000000..bc88fbd Binary files /dev/null and b/python/shader_deblocking/screenshots/2_off.png differ diff --git a/python/shader_deblocking/screenshots/2_on.png b/python/shader_deblocking/screenshots/2_on.png new file mode 100644 index 0000000..61315f7 Binary files /dev/null and b/python/shader_deblocking/screenshots/2_on.png differ diff --git a/python/shader_deblocking/screenshots/3_off.png b/python/shader_deblocking/screenshots/3_off.png new file mode 100644 index 0000000..2d5a4f3 Binary files /dev/null and b/python/shader_deblocking/screenshots/3_off.png differ diff --git a/python/shader_deblocking/screenshots/3_on.png b/python/shader_deblocking/screenshots/3_on.png new file mode 100644 index 0000000..5ba5559 Binary files /dev/null and b/python/shader_deblocking/screenshots/3_on.png differ diff --git a/python/shader_deblocking/screenshots/edge_vis.png b/python/shader_deblocking/screenshots/edge_vis.png new file mode 100644 index 0000000..7553330 Binary files /dev/null and b/python/shader_deblocking/screenshots/edge_vis.png differ diff --git a/python/shader_deblocking/screenshots/edge_vis2.png b/python/shader_deblocking/screenshots/edge_vis2.png new file mode 100644 index 0000000..0e18c83 Binary files /dev/null and b/python/shader_deblocking/screenshots/edge_vis2.png differ diff --git a/python/shader_deblocking/shader.glsl b/python/shader_deblocking/shader.glsl new file mode 100644 index 0000000..9787630 --- /dev/null +++ b/python/shader_deblocking/shader.glsl @@ -0,0 +1,98 @@ +#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) in xy, zw=ASTC block size of texture +uniform float maxLod; // = number_of_mip_levels - 1 + +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() +{ + // Note: Could use textureQueryLod() but it's not supported in WebGL. textureSize() is in WebGL 2.0 however. + const vec3 LUMA = vec3(0.299, 0.587, 0.114); + + vec2 texDim = vec2(texSize.x, texSize.y); + 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 = clamp(log2(max(rho, 1e-8)), 0.0, maxLod); // lod index + float mipScale = exp2(floor(lod + .5)); // 2^lod mipmap scale, snaps to dominant mipmap, 1=mip0, 2=mip1, 4=mip2, etc. + + vec2 texelStep = mipScale / texDim; // how to step one texel in effective mip space + vec2 texelPos = (vUV * texDim) / mipScale; // the physical texel coord in effective mip space + vec2 blockPos = mod(texelPos, blockSize); // the block offset in texels + + vec3 color; + color = texture(tex, vUV).rgb; + + // Keep these fetches outside non-uniform control flow: texture() uses implicit + // derivatives for LOD, which are undefined when neighboring fragments take + // different branches. Use explicit LOD so edgeWeight can safely go to zero. + vec3 l1 = texture(tex, vUV - vec2(texelStep.x, 0.0)).rgb; + vec3 r1 = texture(tex, vUV + vec2(texelStep.x, 0.0)).rgb; + + vec3 u1 = texture(tex, vUV - vec2(0.0, texelStep.y)).rgb; + vec3 d1 = texture(tex, vUV + vec2(0.0, texelStep.y)).rgb; + + if (const0.x > 0.5) + { + const float falloff = 1.5; + + float leftProx = 1.0 - clamp(blockPos.x / falloff, 0.0, 1.0); + float rightProx = 1.0 - clamp((blockSize.x - 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 - blockPos.y) / falloff, 0.0, 1.0); + + float horizWeight = max(leftProx, rightProx); + float vertWeight = max(topProx, bottomProx); + float edgeWeight = max(horizWeight, vertWeight); // overall proximity + + if (edgeWeight > 0.0) + { + vec3 c0 = color; + + vec3 filteredH = (l1 + c0 + r1) * (1.0/3.0); + vec3 filteredV = (u1 + c0 + d1) * (1.0/3.0); + + float strengthH = horizWeight; + float strengthV = vertWeight; + + vec3 horizColor = mix(c0, filteredH, strengthH); + vec3 vertColor = mix(c0, filteredV, strengthV); + + float totalW = strengthH + strengthV; + if (totalW > 0.0) + color = (horizColor * strengthH + vertColor * strengthV) / totalW; + } + + // block edge vis + if (const0.y > 0.5) + { + color = vec3(edgeWeight, edgeWeight, edgeWeight); + } + } + + fragColor = vec4(color, 1.0); +} diff --git a/python/shader_deblocking/testbed.py b/python/shader_deblocking/testbed.py new file mode 100644 index 0000000..2d1f5dd --- /dev/null +++ b/python/shader_deblocking/testbed.py @@ -0,0 +1,1194 @@ +#!/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_x_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()