Last active
February 26, 2025 20:42
-
-
Save kajott/1e2866f78e9efb3e3323a34bb04f7598 to your computer and use it in GitHub Desktop.
simple OpenGL-based KTX texture file viewer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#if 0 // self-compiling code | |
cc -std=c99 -Wall -Wextra -pedantic -Werror -g -O4 $0 -o ktxview `sdl2-config --cflags --libs` -lGL -lm || exit 1 | |
exec ./ktxview $* | |
#endif | |
// simple OpenGL-based KTX texture file viewer | |
// | |
// features: | |
// - simple, based on SDL2 and OpenGL Compatibility Profile | |
// - self-compiling on GNU/Linux and similar platforms | |
// - zooming and panning across the texture with the mouse and mouse wheel | |
// - switchable Y flip ([F] key) | |
// - initial flip setting detected from KTXorientation tag in the KTX file | |
// - switchable GL_NEAREST / GL_LINEAR texture filtering ([I] key) | |
// - switchable alpha compositing (premultiplied/non-premultiplied; [M] key) | |
// | |
// caveats: | |
// - just loads the texture with gl[Compressed]TexImage2D | |
// - only supports single 2D textures | |
// - texture arrays and 3D textures show first slice, cube map show +X face | |
// - ignores any mip-maps (only level 0 is shown) | |
// - only very basic format checking -> may crash on malformed files | |
// - no endianness swap for uncompressed texture data -> | |
// everything but GL_UNSIGNED_BYTES *may* be displayed incorrectly | |
#include <stdbool.h> | |
#include <stdint.h> | |
#include <stdio.h> | |
#include <stdlib.h> | |
#include <string.h> | |
#include <ctype.h> | |
#include <math.h> | |
#include <assert.h> | |
#define SDL_MAIN_HANDLED // don't override main() on Win32 | |
#include <SDL.h> | |
#ifdef _WIN32 | |
#define WIN32_LEAN_AND_MEAN | |
#include <windows.h> | |
#include <GL/gl.h> | |
#ifndef GL_CLAMP_TO_EDGE | |
#define GL_CLAMP_TO_EDGE 0x812F | |
#endif | |
#else | |
#define GL_GLEXT_PROTOTYPES | |
#include <GL/gl.h> | |
#include <GL/glext.h> | |
#endif | |
#define GRID_SIZE 12 | |
#define ZOOM_STEP pow(2.0, 1.0 / 4) | |
// global variables | |
bool g_flip = true; | |
bool g_premul = false; | |
bool g_filter = false; | |
bool g_snap = true; | |
bool g_autozoom = true; | |
bool g_redraw = true; | |
bool g_new_settings = true; | |
int g_screen_width, g_screen_height; // window size | |
int g_image_width, g_image_height; // image size | |
int g_image_x0, g_image_y0; // upper-left corner of image in window coordinates | |
double g_zoom = 1.0; // image scale | |
void warn(const char* msg) { | |
fprintf(stderr, "WARNING: %s\n", msg); | |
} | |
int error(const char* msg) { | |
#ifdef _WIN32 | |
MessageBox(NULL, msg, "KTXView Error", MB_ICONERROR | MB_OK); | |
#else | |
fprintf(stderr, "ERROR: %s\n", msg); | |
#endif | |
return 1; | |
} | |
int sdl_error(const char* msg) { | |
#ifdef _WIN32 | |
char* str = malloc(strlen(msg) + strlen(SDL_GetError()) + 4); | |
if (str) { | |
strcpy(str, msg); | |
strcat(str, ":\r\n"); | |
strcat(str, SDL_GetError()); | |
} | |
MessageBox(NULL, str ? str : msg, "KTXView Error", MB_ICONERROR | MB_OK); | |
#else | |
fprintf(stderr, "ERROR: %s - %s\n", msg, SDL_GetError()); | |
#endif | |
return 1; | |
} | |
static inline uint32_t swap32(uint32_t x) { | |
// endianness swap | |
x = ((x << 8) & 0xFF00FF00) | ((x >> 8) & 0x00FF00FF); | |
return (x << 16) || (x >> 16); | |
} | |
struct ktx_header { | |
uint8_t identifier[12]; | |
uint32_t endianness; | |
uint32_t glType; | |
uint32_t glTypeSize; | |
uint32_t glFormat; | |
uint32_t glInternalFormat; | |
uint32_t glBaseInternalFormat; | |
uint32_t pixelWidth; | |
uint32_t pixelHeight; | |
uint32_t pixelDepth; | |
uint32_t numberOfArrayElements; | |
uint32_t numberOfFaces; | |
uint32_t numberOfMipmapLevels; | |
uint32_t bytesOfKeyValueData; | |
uint8_t payload[]; | |
}; | |
struct ktx_image { | |
uint32_t imageSize; | |
uint8_t payload[]; | |
}; | |
struct ktx_metadata { | |
uint32_t keyAndValueByteSize; | |
char keyAndValue[]; | |
}; | |
static const uint8_t ktx_identifier[12] = | |
{ 0xAB, 'K', 'T', 'X', ' ', '1', '1', 0xBB, '\r', '\n', 0x1A, '\n' }; | |
static const union { | |
uint16_t _; | |
uint8_t is_little_endian; | |
} endianness = { 1 }; | |
// update image zoom and position around pivot | |
static void set_zoom(int x, int y, double new_zoom) { | |
// screen to image of pivot point | |
double ix = (x - g_image_x0) / g_zoom; | |
double iy = (y - g_image_y0) / g_zoom; | |
// apply zoom | |
g_new_settings = g_autozoom; | |
g_redraw = true; | |
g_autozoom = false; | |
g_zoom = new_zoom; | |
// compute new image origin | |
g_image_x0 = (int)(x - ix * g_zoom + 0.5); | |
g_image_y0 = (int)(y - iy * g_zoom + 0.5); | |
} | |
// restrict position to fit into the screen | |
int restrict_pos(int pos, int image_size, int screen_size) { | |
image_size = (int)(image_size * g_zoom + 0.5); | |
int v0 = 0, v1 = screen_size - image_size; | |
if (v1 < 0) { v0 = v1; v1 = 0; } | |
return (pos < v0) ? v0 : (pos > v1) ? v1 : pos; | |
} | |
int main(int argc, char* argv[]) { | |
// command-line help | |
if (argc != 2) { | |
fprintf(stderr, "Usage: %s <input.ktx>\n", argv[0]); | |
return 2; | |
} | |
// read the file into memory | |
FILE *f = fopen(argv[1], "rb"); | |
if (!f) { | |
return error("failed to open the input file"); | |
} | |
fseek(f, 0, SEEK_END); | |
size_t file_size = ftell(f); | |
fseek(f, 0, SEEK_SET); | |
void *file_data = malloc(file_size); | |
assert(file_data != NULL); | |
if (fread(file_data, 1, file_size, f) != file_size) { | |
return error("I/O error while reading the input file"); | |
} | |
fclose(f); | |
// check header | |
struct ktx_header *header = (struct ktx_header*) file_data; | |
if (memcmp(header->identifier, ktx_identifier, 12)) { | |
return error("input file does not have a valid KTX file signature"); | |
} | |
// endianness check | |
if (header->endianness == 0x01020304) { | |
#define swap_field(f) header->f = swap32(header->f) | |
swap_field(glType); | |
swap_field(glTypeSize); | |
swap_field(glFormat); | |
swap_field(glInternalFormat); | |
swap_field(glBaseInternalFormat); | |
swap_field(pixelWidth); | |
swap_field(pixelHeight); | |
swap_field(pixelDepth); | |
swap_field(numberOfArrayElements); | |
swap_field(numberOfFaces); | |
swap_field(numberOfMipmapLevels); | |
swap_field(bytesOfKeyValueData); | |
#undef swap_field | |
} | |
else if (header->endianness != 0x04030201) { | |
return error("invalid endianness field in KTX file"); | |
} | |
// dimension check | |
if (header->pixelWidth < 1) { | |
return error("invalid image width"); | |
} | |
if (header->pixelHeight < 1) { | |
return error("invalid image height (note that only 2D textures are supported)"); | |
} | |
if (header->pixelDepth != 0) { | |
warn("3D textures are not supported, only showing first slice"); | |
} | |
if (header->numberOfArrayElements > 1) { | |
warn("array textures are not supported, only showing first slice"); | |
} | |
if (header->numberOfFaces > 1) { | |
warn("cube map textures are not supported, only showing +X side"); | |
} | |
if ((sizeof(struct ktx_header) + header->bytesOfKeyValueData) >= file_size) { | |
return error("invalid KTX header size"); | |
} | |
const struct ktx_image* image = (const struct ktx_image*) &header->payload[header->bytesOfKeyValueData]; | |
if ((sizeof(struct ktx_header) + header->bytesOfKeyValueData + sizeof(struct ktx_image) + image->imageSize) > file_size) { | |
return error("invalid KTX image size"); | |
} | |
// search metadata to get orientation | |
uint32_t meta_offset = 0; | |
while ((meta_offset + 4) < header->bytesOfKeyValueData) { | |
const struct ktx_metadata* meta = (const struct ktx_metadata*) &header->payload[meta_offset]; | |
meta_offset += meta->keyAndValueByteSize + sizeof(struct ktx_metadata); | |
if (meta_offset > header->bytesOfKeyValueData) { | |
warn("KTX metadata is corrupt, ignoring"); | |
break; | |
} | |
if (!strncmp(meta->keyAndValue, "KTXorientation", meta->keyAndValueByteSize)) { | |
char comp = 0; | |
for (uint32_t i = (uint32_t)strlen("KTXorientation"); i < meta->keyAndValueByteSize; ++i) { | |
char c = meta->keyAndValue[i]; | |
if (isupper(c)) { comp = c; } | |
if ((comp == 'T') && (c == 'u')) { g_flip = true; } | |
if ((comp == 'T') && (c == 'd')) { g_flip = false; } | |
} | |
} | |
meta_offset = (meta_offset + 3) & (~3); | |
} | |
// dump texture format | |
printf("%s: %s endian, %dx%d pixels, %s\n", argv[1], | |
(!(header->endianness == 0x01020304) ^ endianness.is_little_endian) ? "big" : "little", | |
header->pixelWidth, header->pixelHeight, | |
g_flip ? "bottom-up" : "top-down"); | |
// create window and OpenGL context | |
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) { | |
return sdl_error("failed to initialize SDL"); | |
} | |
SDL_Window* win = SDL_CreateWindow(argv[1], | |
SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, | |
header->pixelWidth, header->pixelHeight, | |
SDL_WINDOW_OPENGL | SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_RESIZABLE); | |
if (!win) { | |
return sdl_error("failed to create window"); | |
} | |
SDL_GLContext ctx = SDL_GL_CreateContext(win); | |
if (!ctx) { | |
return sdl_error("failed to create OpenGL context"); | |
} | |
// create and upload the texture | |
GLuint tex; | |
glGenTextures(1, &tex); | |
glBindTexture(GL_TEXTURE_2D, tex); | |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); | |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); | |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); | |
while (glGetError()); // clear pre-existing errors | |
if (header->glType) { | |
glTexImage2D(GL_TEXTURE_2D, 0, header->glInternalFormat, | |
header->pixelWidth, header->pixelHeight, 0, | |
header->glFormat, header->glType, image->payload); | |
} | |
else { | |
#if _WIN32 // look up glCompressedTexImage2D (necessary on Win32...) | |
typedef void (APIENTRY *PFNGLCOMPRESSEDTEXIMAGE2DPROC) | |
(GLenum, GLint, GLenum, GLsizei, GLsizei, GLint, GLsizei, const void*); | |
PFNGLCOMPRESSEDTEXIMAGE2DPROC glCompressedTexImage2D = | |
(PFNGLCOMPRESSEDTEXIMAGE2DPROC) SDL_GL_GetProcAddress("glCompressedTexImage2D"); | |
if (!glCompressedTexImage2D) { | |
return error("glCompressedTexImage2D function not available"); | |
} | |
#endif | |
glCompressedTexImage2D(GL_TEXTURE_2D, 0, header->glInternalFormat, | |
header->pixelWidth, header->pixelHeight, 0, | |
image->imageSize, image->payload); | |
} | |
GLuint error = glGetError(); | |
if (error) { | |
fprintf(stderr, "ERROR: invalid texture - OpenGL error 0x%04X\n", error); | |
} | |
g_image_width = header->pixelWidth; | |
g_image_height = header->pixelHeight; | |
// prepare the transparency grid | |
GLuint grid; | |
glGenTextures(1, &grid); | |
glBindTexture(GL_TEXTURE_2D, grid); | |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); | |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); | |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); | |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); | |
#define grid_color_1 (0x80 * 0x01010101u) | |
#define grid_color_2 (0xC0 * 0x01010101u) | |
static const uint32_t grid_data[] = { grid_color_1, grid_color_2, grid_color_2, grid_color_1 }; | |
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, grid_data); | |
// other OpenGL preparations | |
glEnable(GL_TEXTURE_2D); | |
glClearColor(0.25f, 0.25f, 0.25f, 1.0f); | |
// event processing loop | |
bool quit = false; | |
int mouse_ref_x = 0, mouse_ref_y = 0, mouse_base_x0 = 0, mouse_base_y0 = 0; | |
bool event_handled = false; | |
while (!quit && !error) { | |
// get new event: | |
// - if we just handled an event, check if there's another one in the queue | |
// - otherwise, wait for an event | |
SDL_Event ev; | |
if (event_handled) { | |
if (!SDL_PollEvent(&ev)) { | |
ev.type = SDL_LASTEVENT; // dummy event | |
} | |
} else if (!SDL_WaitEvent(&ev)) { | |
sdl_error("failed to wait for an input event"); | |
break; | |
} | |
// handle the event | |
event_handled = true; | |
bool update_mouse_ref = false; | |
if ((ev.type == SDL_WINDOWEVENT) && (ev.window.event == SDL_WINDOWEVENT_EXPOSED)) { | |
g_redraw = true; | |
} | |
else if (ev.type == SDL_KEYDOWN) { | |
switch (ev.key.keysym.sym) { | |
case 'f': g_flip = !g_flip; g_new_settings = true; break; | |
case 'm': g_premul = !g_premul; g_new_settings = true; break; | |
case 'i': g_filter = !g_filter; g_new_settings = true; break; | |
case 'a': g_autozoom = !g_autozoom; g_new_settings = true; break; | |
case 's': | |
g_snap = !g_snap; | |
g_new_settings = true; | |
if (g_snap && !g_autozoom) { | |
set_zoom(g_screen_width / 2, g_screen_height / 2, | |
(g_zoom < 0.99) ? 0.5 : floor(g_zoom + 0.5)); | |
} | |
break; | |
case '-': case SDLK_KP_MINUS: | |
set_zoom(g_screen_width / 2, g_screen_height / 2, | |
(g_zoom <= 1.0) ? 0.5 : floor(g_zoom - 0.1)); | |
break; | |
case '+': case SDLK_KP_PLUS: | |
set_zoom(g_screen_width / 2, g_screen_height / 2, | |
(g_zoom < 1.0) ? 1.0 : ceil(g_zoom + 0.1)); | |
break; | |
case 'q': case SDLK_ESCAPE: | |
quit = true; | |
break; | |
default: | |
break; | |
} | |
} | |
else if (ev.type == SDL_MOUSEWHEEL) { | |
int x, y; | |
SDL_GetMouseState(&x, &y); | |
if (ev.wheel.y > 0) { | |
set_zoom(x, y, g_snap ? ((g_zoom < 1.0) ? 1.0 : ceil(g_zoom + 0.1)) | |
: g_zoom * ZOOM_STEP); | |
} | |
else { | |
set_zoom(x, y, g_snap ? ((g_zoom <= 1.0) ? 0.5 : floor(g_zoom - 0.1)) | |
: g_zoom / ZOOM_STEP); | |
} | |
update_mouse_ref = true; | |
} | |
else if ((ev.type == SDL_MOUSEBUTTONDOWN) && (ev.button.button == SDL_BUTTON_LEFT)) { | |
update_mouse_ref = true; | |
} | |
else if ((ev.type == SDL_MOUSEMOTION) && (ev.motion.state & SDL_BUTTON_LMASK)) { | |
g_image_x0 = mouse_base_x0 + ev.motion.x - mouse_ref_x; | |
g_image_y0 = mouse_base_y0 + ev.motion.y - mouse_ref_y; | |
g_new_settings = g_autozoom; | |
g_autozoom = false; | |
g_redraw = true; | |
} | |
else if (ev.type == SDL_QUIT) { | |
quit = true; | |
} | |
else { | |
event_handled = false; | |
} | |
if (update_mouse_ref) { | |
// latch reference mouse position | |
mouse_ref_x = ev.button.x; | |
mouse_ref_y = ev.button.y; | |
mouse_base_x0 = g_image_x0; | |
mouse_base_y0 = g_image_y0; | |
} | |
if (event_handled) { | |
continue; // handle potential other events | |
} | |
// report settings string | |
if (g_new_settings) { | |
printf("\r[F]lip:%s pre[M]ulAlpha:%s f[I]lter:%s [A]utozoom:%s [S]nap:%s ", | |
g_flip ? "yes" : "no ", | |
g_premul ? "yes" : "no ", | |
g_filter ? "yes" : "no ", | |
g_autozoom ? "yes" : "no ", | |
g_snap ? "yes" : "no "); | |
fflush(stdout); | |
g_new_settings = false; | |
g_redraw = true; | |
} | |
if (g_redraw) { | |
// query new (possibly changed) window size | |
int old_width = g_screen_width; | |
int old_height = g_screen_height; | |
SDL_GetWindowSize(win, &g_screen_width, &g_screen_height); | |
glViewport(0, 0, g_screen_width, g_screen_height); | |
glLoadIdentity(); | |
glOrtho(0,g_screen_width, g_screen_height,0, -1,1); | |
// update image position and zoom | |
if (g_autozoom) { | |
double zoomX = (double)g_screen_width / (double)g_image_width; | |
double zoomY = (double)g_screen_height / (double)g_image_height; | |
g_zoom = (zoomX < zoomY) ? zoomX : zoomY; | |
if (g_snap) { | |
g_zoom = (g_zoom > 0.999) ? floor(g_zoom) : 0.5; | |
} | |
g_image_x0 = (int)((g_screen_width - g_image_width * g_zoom) * 0.5 + 0.5); | |
g_image_y0 = (int)((g_screen_height - g_image_height * g_zoom) * 0.5 + 0.5); | |
} | |
else { | |
// adjust image position after screen resize; | |
// prone to rounding errors, but good enough | |
g_image_x0 += (g_screen_width - old_width) >> 1; | |
g_image_y0 += (g_screen_height - old_height) >> 1; | |
// don't push the image outside of the window | |
g_image_x0 = restrict_pos(g_image_x0, g_image_width, g_screen_width); | |
g_image_y0 = restrict_pos(g_image_y0, g_image_height, g_screen_height); | |
} | |
// compute final image geometry | |
double dx0 = g_image_x0; | |
double dx1 = g_image_x0 + g_image_width * g_zoom; | |
double dy0 = g_image_y0; | |
double dy1 = g_image_y0 + g_image_height * g_zoom; | |
double gridX = g_image_width * g_zoom / GRID_SIZE; | |
double gridY = g_image_height * g_zoom / GRID_SIZE; | |
// draw checkerboard background | |
glClear(GL_COLOR_BUFFER_BIT); | |
glDisable(GL_BLEND); | |
glBindTexture(GL_TEXTURE_2D, grid); | |
glBegin(GL_QUADS); | |
glTexCoord2d( 0.0, 0.0); glVertex2d(dx0, dy0); | |
glTexCoord2d(gridX, 0.0); glVertex2d(dx1, dy0); | |
glTexCoord2d(gridX, gridY); glVertex2d(dx1, dy1); | |
glTexCoord2d( 0.0, gridY); glVertex2d(dx0, dy1); | |
glEnd(); | |
// draw main image | |
glEnable(GL_BLEND); | |
glBlendFunc(g_premul ? GL_ONE : GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); | |
glBindTexture(GL_TEXTURE_2D, tex); | |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, g_filter ? GL_LINEAR : GL_NEAREST); | |
glBegin(GL_QUADS); | |
glTexCoord2f(0.0f, g_flip ? 1.0f : 0.0f); glVertex2d(dx0, dy0); | |
glTexCoord2f(1.0f, g_flip ? 1.0f : 0.0f); glVertex2d(dx1, dy0); | |
glTexCoord2f(1.0f, g_flip ? 0.0f : 1.0f); glVertex2d(dx1, dy1); | |
glTexCoord2f(0.0f, g_flip ? 0.0f : 1.0f); glVertex2d(dx0, dy1); | |
glEnd(); | |
// done | |
SDL_GL_SwapWindow(win); | |
g_redraw = false; | |
} | |
} | |
// done -- clean up | |
printf("\n"); | |
SDL_GL_MakeCurrent(NULL, NULL); | |
SDL_GL_DeleteContext(ctx); | |
SDL_DestroyWindow(win); | |
SDL_Quit(); | |
free(file_data); | |
return 0; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment