Files
ARMSX1/frontend/render_gl.cpp

1736 lines
69 KiB
C++

/*
ARMSX — GLES 3.0 / GL 3.3-core presentation backend.
WHAT THIS IS
------------
A real GPU present path for the software-rasterized PlayStation framebuffer. The PS1 GPU
(psx/dev/gpu.c) is untouched and stays authoritative; this file only uploads its output
into a texture and draws one full-screen quad.
WHY IT IS NOT JUST glTexImage2D + a passthrough shader
------------------------------------------------------
The core hands us one of two formats:
* SDL_PIXELFORMAT_BGR555 — packed 16-bit, bit layout [15]=mask [14:10]=B [9:5]=G
[4:0]=R. GLES 3.0 has no GL_UNSIGNED_SHORT_1_5_5_5_REV (that is desktop-GL only), so
there is no native upload format for it. We upload the raw 16 bits as a two-channel
GL_RG8 texture (x = low byte, y = high byte, little-endian) and unpack in the
fragment shader. Because the texels are then *bytes*, hardware bilinear filtering
would blend the packed representation rather than the colour, so the bilinear
variant does its own 4-tap texelFetch + decode + lerp. Nearest is exact.
* SDL_PIXELFORMAT_RGB24 — 3 bytes/pixel, uploaded as GL_RGB8 and sampled normally
(hardware nearest/linear).
Source rows are 2048 bytes apart (PSX_GPU_FB_STRIDE) regardless of the pixel size, which
is not expressible with GL_UNPACK_ROW_LENGTH for the 3-byte format (2048/3 is not an
integer), so the dirty row range is repacked into a contiguous staging buffer before the
single glTexSubImage2D. The caller's dirty-row optimisation is preserved end to end.
CONTEXT CREATION
----------------
Two providers behind one implementation:
* SDL — SDL_GL_CreateContext on an SDL_WINDOW_OPENGL window, SDL_GL_SwapWindow,
SDL_GL_SetSwapInterval. Entry points come from SDL_GL_GetProcAddress, so
there is no GL loader dependency (no glad/glew/epoxy).
* EGL — Android only. Binds EGL straight to the ANativeWindow the Compose host
registered via armsx_render_set_native_window(). This exists because the
in-process Android host runs SDL on its `dummy` video driver (SDL's Android
backend needs org.libsdl.app.SDLActivity, which Compose does not provide),
so SDL_GL_CreateContext is not an option there.
Every GL entry point goes through a function-pointer table populated by whichever
provider is active, and this file declares its own GL types/enums so it needs no GL
headers at all on any platform.
*/
#include "render_internal.h"
#include "gpu_profile.h"
#if defined(ARMSX_ENABLE_GL)
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#if defined(__ANDROID__)
#include <EGL/egl.h>
#include <android/native_window.h>
#include <dlfcn.h>
#include <sys/syscall.h>
#include <unistd.h>
#define ARMSX_GL_HAVE_EGL 1
#if !defined(EGL_OPENGL_ES3_BIT_KHR)
#define EGL_OPENGL_ES3_BIT_KHR 0x00000040
#endif
#endif
namespace {
/* Thread id, for the EGL-context-affinity trace. gettid() only reached bionic's headers at
API 30 and this builds against 26, so go through the syscall directly. */
long CurrentThreadId() {
#if defined(__ANDROID__)
return static_cast<long>(::syscall(__NR_gettid));
#else
return 0;
#endif
}
/* ---- minimal GL typedefs / enums (no GL headers anywhere) -------------------------- */
typedef unsigned int GLenum;
typedef unsigned char GLboolean;
typedef unsigned int GLbitfield;
typedef int GLint;
typedef unsigned int GLuint;
typedef int GLsizei;
typedef float GLfloat;
typedef char GLchar;
typedef std::ptrdiff_t GLintptr;
typedef std::ptrdiff_t GLsizeiptr;
constexpr GLenum GL_FALSE_ = 0;
constexpr GLenum GL_TRIANGLE_STRIP = 0x0005;
constexpr GLenum GL_UNSIGNED_BYTE = 0x1401;
constexpr GLenum GL_FLOAT = 0x1406;
constexpr GLenum GL_TEXTURE_2D = 0x0DE1;
constexpr GLenum GL_TEXTURE0 = 0x84C0;
constexpr GLenum GL_TEXTURE_MAG_FILTER = 0x2800;
constexpr GLenum GL_TEXTURE_MIN_FILTER = 0x2801;
constexpr GLenum GL_TEXTURE_WRAP_S = 0x2802;
constexpr GLenum GL_TEXTURE_WRAP_T = 0x2803;
constexpr GLenum GL_NEAREST = 0x2600;
constexpr GLenum GL_LINEAR = 0x2601;
constexpr GLenum GL_CLAMP_TO_EDGE = 0x812F;
constexpr GLenum GL_RGB = 0x1907;
constexpr GLenum GL_RGB8 = 0x8051;
constexpr GLenum GL_RG = 0x8227;
constexpr GLenum GL_RG8 = 0x822B;
constexpr GLenum GL_UNPACK_ALIGNMENT = 0x0CF5;
constexpr GLbitfield GL_COLOR_BUFFER_BIT = 0x00004000;
constexpr GLenum GL_ARRAY_BUFFER = 0x8892;
constexpr GLenum GL_STATIC_DRAW = 0x88E4;
constexpr GLenum GL_VERTEX_SHADER = 0x8B31;
constexpr GLenum GL_FRAGMENT_SHADER = 0x8B30;
constexpr GLenum GL_COMPILE_STATUS = 0x8B81;
constexpr GLenum GL_LINK_STATUS = 0x8B82;
constexpr GLenum GL_VENDOR = 0x1F00;
constexpr GLenum GL_RENDERER_STR = 0x1F01;
constexpr GLenum GL_VERSION_STR = 0x1F02;
constexpr GLenum GL_DEPTH_TEST = 0x0B71;
constexpr GLenum GL_CULL_FACE = 0x0B44;
constexpr GLenum GL_BLEND = 0x0BE2;
constexpr GLenum GL_SCISSOR_TEST = 0x0C11;
struct GlApi {
void (*Viewport)(GLint, GLint, GLsizei, GLsizei) = nullptr;
void (*ClearColor)(GLfloat, GLfloat, GLfloat, GLfloat) = nullptr;
void (*Clear)(GLbitfield) = nullptr;
void (*Disable)(GLenum) = nullptr;
GLenum (*GetError)() = nullptr;
const unsigned char* (*GetString)(GLenum) = nullptr;
void (*PixelStorei)(GLenum, GLint) = nullptr;
void (*GenTextures)(GLsizei, GLuint*) = nullptr;
void (*DeleteTextures)(GLsizei, const GLuint*) = nullptr;
void (*BindTexture)(GLenum, GLuint) = nullptr;
void (*ActiveTexture)(GLenum) = nullptr;
void (*TexParameteri)(GLenum, GLenum, GLint) = nullptr;
void (*TexImage2D)(GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void*) = nullptr;
void (*TexSubImage2D)(GLenum, GLint, GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, const void*) = nullptr;
GLuint (*CreateShader)(GLenum) = nullptr;
void (*ShaderSource)(GLuint, GLsizei, const GLchar* const*, const GLint*) = nullptr;
void (*CompileShader)(GLuint) = nullptr;
void (*GetShaderiv)(GLuint, GLenum, GLint*) = nullptr;
void (*GetShaderInfoLog)(GLuint, GLsizei, GLsizei*, GLchar*) = nullptr;
void (*DeleteShader)(GLuint) = nullptr;
GLuint (*CreateProgram)() = nullptr;
void (*AttachShader)(GLuint, GLuint) = nullptr;
void (*BindAttribLocation)(GLuint, GLuint, const GLchar*) = nullptr;
void (*LinkProgram)(GLuint) = nullptr;
void (*GetProgramiv)(GLuint, GLenum, GLint*) = nullptr;
void (*GetProgramInfoLog)(GLuint, GLsizei, GLsizei*, GLchar*) = nullptr;
void (*UseProgram)(GLuint) = nullptr;
void (*DeleteProgram)(GLuint) = nullptr;
GLint (*GetUniformLocation)(GLuint, const GLchar*) = nullptr;
void (*Uniform1i)(GLint, GLint) = nullptr;
void (*Uniform2f)(GLint, GLfloat, GLfloat) = nullptr;
void (*GenVertexArrays)(GLsizei, GLuint*) = nullptr;
void (*BindVertexArray)(GLuint) = nullptr;
void (*DeleteVertexArrays)(GLsizei, const GLuint*) = nullptr;
void (*GenBuffers)(GLsizei, GLuint*) = nullptr;
void (*BindBuffer)(GLenum, GLuint) = nullptr;
void (*BufferData)(GLenum, GLsizeiptr, const void*, GLenum) = nullptr;
void (*DeleteBuffers)(GLsizei, const GLuint*) = nullptr;
void (*VertexAttribPointer)(GLuint, GLint, GLenum, GLboolean, GLsizei, const void*) = nullptr;
void (*EnableVertexAttribArray)(GLuint) = nullptr;
void (*DrawArrays)(GLenum, GLint, GLsizei) = nullptr;
};
typedef void* (*GlProcLoader)(const char* name, void* user);
bool LoadGlApi(GlApi& gl, GlProcLoader loader, void* user, std::string& missing) {
struct Entry {
const char* name;
void** slot;
};
const Entry entries[] = {
{"glViewport", (void**)&gl.Viewport},
{"glClearColor", (void**)&gl.ClearColor},
{"glClear", (void**)&gl.Clear},
{"glDisable", (void**)&gl.Disable},
{"glGetError", (void**)&gl.GetError},
{"glGetString", (void**)&gl.GetString},
{"glPixelStorei", (void**)&gl.PixelStorei},
{"glGenTextures", (void**)&gl.GenTextures},
{"glDeleteTextures", (void**)&gl.DeleteTextures},
{"glBindTexture", (void**)&gl.BindTexture},
{"glActiveTexture", (void**)&gl.ActiveTexture},
{"glTexParameteri", (void**)&gl.TexParameteri},
{"glTexImage2D", (void**)&gl.TexImage2D},
{"glTexSubImage2D", (void**)&gl.TexSubImage2D},
{"glCreateShader", (void**)&gl.CreateShader},
{"glShaderSource", (void**)&gl.ShaderSource},
{"glCompileShader", (void**)&gl.CompileShader},
{"glGetShaderiv", (void**)&gl.GetShaderiv},
{"glGetShaderInfoLog", (void**)&gl.GetShaderInfoLog},
{"glDeleteShader", (void**)&gl.DeleteShader},
{"glCreateProgram", (void**)&gl.CreateProgram},
{"glAttachShader", (void**)&gl.AttachShader},
{"glBindAttribLocation", (void**)&gl.BindAttribLocation},
{"glLinkProgram", (void**)&gl.LinkProgram},
{"glGetProgramiv", (void**)&gl.GetProgramiv},
{"glGetProgramInfoLog", (void**)&gl.GetProgramInfoLog},
{"glUseProgram", (void**)&gl.UseProgram},
{"glDeleteProgram", (void**)&gl.DeleteProgram},
{"glGetUniformLocation", (void**)&gl.GetUniformLocation},
{"glUniform1i", (void**)&gl.Uniform1i},
{"glUniform2f", (void**)&gl.Uniform2f},
{"glGenVertexArrays", (void**)&gl.GenVertexArrays},
{"glBindVertexArray", (void**)&gl.BindVertexArray},
{"glDeleteVertexArrays", (void**)&gl.DeleteVertexArrays},
{"glGenBuffers", (void**)&gl.GenBuffers},
{"glBindBuffer", (void**)&gl.BindBuffer},
{"glBufferData", (void**)&gl.BufferData},
{"glDeleteBuffers", (void**)&gl.DeleteBuffers},
{"glVertexAttribPointer", (void**)&gl.VertexAttribPointer},
{"glEnableVertexAttribArray", (void**)&gl.EnableVertexAttribArray},
{"glDrawArrays", (void**)&gl.DrawArrays},
};
for (const Entry& entry : entries) {
void* address = loader(entry.name, user);
if (!address) {
missing = entry.name;
return false;
}
*entry.slot = address;
}
return true;
}
/* ---- shaders ----------------------------------------------------------------------- */
/* u_uvoff/u_uvscale carry [video] overscan_crop as a sub-rectangle in normalised texture
space; u_rot carries [video] display_rotation in quarter turns clockwise. The defaults
(0,0)/(1,1)/0 reproduce the original one-liner exactly, and the rotation is applied to the
SOURCE coordinate, so it is the inverse of the turn the picture makes: rotating the image
90 degrees clockwise means dest (x,y) reads source (y, 1-x). */
const char* kVertexBody =
"in vec2 a_pos;\n"
"uniform vec2 u_uvoff;\n"
"uniform vec2 u_uvscale;\n"
"uniform int u_rot;\n"
"out vec2 v_uv;\n"
"void main() {\n"
" vec2 b = vec2(a_pos.x * 0.5 + 0.5, 0.5 - a_pos.y * 0.5);\n"
" if (u_rot == 1) b = vec2(b.y, 1.0 - b.x);\n"
" else if (u_rot == 2) b = vec2(1.0 - b.x, 1.0 - b.y);\n"
" else if (u_rot == 3) b = vec2(1.0 - b.y, b.x);\n"
" v_uv = u_uvoff + b * u_uvscale;\n"
" gl_Position = vec4(a_pos, 0.0, 1.0);\n"
"}\n";
const char* kFragmentRgb =
"uniform sampler2D u_tex;\n"
"uniform vec2 u_texsize;\n"
"in vec2 v_uv;\n"
"out vec4 o_color;\n"
"void main() {\n"
" o_color = vec4(texture(u_tex, v_uv).rgb, 1.0);\n"
"}\n";
/* BGR555 packed into RG8: x = low byte, y = high byte (little-endian).
value = hi*256 + lo, layout [15]=mask [14:10]=B [9:5]=G [4:0]=R. */
const char* kDecode555 =
"vec3 armsx_decode(vec2 rg) {\n"
" float lo = floor(rg.x * 255.0 + 0.5);\n"
" float hi = floor(rg.y * 255.0 + 0.5);\n"
" float r = mod(lo, 32.0);\n"
" float g = floor(lo / 32.0) + mod(hi, 4.0) * 8.0;\n"
" float b = mod(floor(hi / 4.0), 32.0);\n"
" return vec3(r, g, b) / 31.0;\n"
"}\n";
const char* kFragment555Nearest =
"uniform sampler2D u_tex;\n"
"uniform vec2 u_texsize;\n"
"in vec2 v_uv;\n"
"out vec4 o_color;\n"
"void main() {\n"
" o_color = vec4(armsx_decode(texture(u_tex, v_uv).rg), 1.0);\n"
"}\n";
/* Hardware bilinear would interpolate the packed bytes, not the colour, so the four taps
are fetched unfiltered, decoded, and blended here. */
const char* kFragment555Linear =
"uniform sampler2D u_tex;\n"
"uniform vec2 u_texsize;\n"
"in vec2 v_uv;\n"
"out vec4 o_color;\n"
"void main() {\n"
" vec2 t = v_uv * u_texsize - 0.5;\n"
" vec2 f = fract(t);\n"
" ivec2 base = ivec2(floor(t));\n"
" ivec2 limit = ivec2(u_texsize) - ivec2(1);\n"
" ivec2 lo = clamp(base, ivec2(0), limit);\n"
" ivec2 hi = clamp(base + ivec2(1), ivec2(0), limit);\n"
" vec3 c00 = armsx_decode(texelFetch(u_tex, ivec2(lo.x, lo.y), 0).rg);\n"
" vec3 c10 = armsx_decode(texelFetch(u_tex, ivec2(hi.x, lo.y), 0).rg);\n"
" vec3 c01 = armsx_decode(texelFetch(u_tex, ivec2(lo.x, hi.y), 0).rg);\n"
" vec3 c11 = armsx_decode(texelFetch(u_tex, ivec2(hi.x, hi.y), 0).rg);\n"
" o_color = vec4(mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y), 1.0);\n"
"}\n";
enum ProgramKind {
kProgramRgb = 0,
kProgram555Nearest = 1,
kProgram555Linear = 2,
kProgramCount = 3
};
/* ---- backend state ------------------------------------------------------------------ */
#if defined(ARMSX_GL_HAVE_EGL)
/*
Every EGL entry point goes through this table rather than the linked libEGL, so the whole
provider can be swapped for ANGLE's (libEGL_angle.so) at runtime. There is no #ifdef and
no second code path: "system" and "ANGLE" differ only in which library the table was
resolved out of.
*/
struct EglApi {
EGLDisplay (*GetDisplay)(EGLNativeDisplayType) = nullptr;
EGLBoolean (*Initialize)(EGLDisplay, EGLint*, EGLint*) = nullptr;
EGLBoolean (*Terminate)(EGLDisplay) = nullptr;
EGLBoolean (*ChooseConfig)(EGLDisplay, const EGLint*, EGLConfig*, EGLint, EGLint*) = nullptr;
EGLBoolean (*GetConfigAttrib)(EGLDisplay, EGLConfig, EGLint, EGLint*) = nullptr;
EGLBoolean (*BindAPI)(EGLenum) = nullptr;
EGLContext (*CreateContext)(EGLDisplay, EGLConfig, EGLContext, const EGLint*) = nullptr;
EGLBoolean (*DestroyContext)(EGLDisplay, EGLContext) = nullptr;
EGLSurface (*CreateWindowSurface)(EGLDisplay, EGLConfig, EGLNativeWindowType, const EGLint*) = nullptr;
EGLBoolean (*DestroySurface)(EGLDisplay, EGLSurface) = nullptr;
EGLBoolean (*MakeCurrent)(EGLDisplay, EGLSurface, EGLSurface, EGLContext) = nullptr;
EGLBoolean (*QuerySurface)(EGLDisplay, EGLSurface, EGLint, EGLint*) = nullptr;
EGLBoolean (*SwapBuffers)(EGLDisplay, EGLSurface) = nullptr;
EGLBoolean (*SwapInterval)(EGLDisplay, EGLint) = nullptr;
EGLint (*GetError)() = nullptr;
EGLContext (*GetCurrentContext)() = nullptr;
EGLSurface (*GetCurrentSurface)(EGLint) = nullptr;
__eglMustCastToProperFunctionPointerType (*GetProcAddress)(const char*) = nullptr;
const char* (*QueryString)(EGLDisplay, EGLint) = nullptr;
};
#endif
struct GlRenderer {
armsx_renderer_t base;
GlApi gl{};
bool es_profile = true;
/* SDL provider */
SDL_Window* window = nullptr;
SDL_GLContext sdl_context = nullptr;
#if defined(ARMSX_GL_HAVE_EGL)
/* EGL provider */
bool use_egl = false;
EglApi egl{};
EGLDisplay egl_display = EGL_NO_DISPLAY;
EGLSurface egl_surface = EGL_NO_SURFACE;
EGLContext egl_context = EGL_NO_CONTEXT;
ANativeWindow* native_window = nullptr;
void* egl_library = nullptr;
void* gles_library = nullptr;
/* What we ACTUALLY bound to, which is not always what was asked for: a missing or
unloadable ANGLE falls back to the system driver, and that has to stay visible. */
armsx_render_gl_driver_t active_gl_driver = ARMSX_RENDER_GL_DRIVER_SYSTEM;
#endif
GLuint texture = 0;
int texture_width = 0;
int texture_height = 0;
Uint32 texture_format = SDL_PIXELFORMAT_UNKNOWN;
int texture_filter = -1; /* -1 unset, 0 nearest, 1 linear */
/* A GPU-resident frame adopted from the rasterizer instead of uploaded (see
armsx_renderer_adopt_gl_texture). Non-zero means present sources from this and the
CPU `texture` above is untouched; the next OpUploadFrame() clears it, so the two
sources can never both be live. */
GLuint external_texture = 0;
int external_width = 0;
int external_height = 0;
Uint32 external_format = SDL_PIXELFORMAT_UNKNOWN;
GLuint programs[kProgramCount] = {0, 0, 0};
GLint uniform_tex[kProgramCount] = {-1, -1, -1};
GLint uniform_texsize[kProgramCount] = {-1, -1, -1};
/* [video] overscan_crop + display_rotation, all on the shared vertex shader. */
GLint uniform_uvoff[kProgramCount] = {-1, -1, -1};
GLint uniform_uvscale[kProgramCount] = {-1, -1, -1};
GLint uniform_rot[kProgramCount] = {-1, -1, -1};
GLuint vao = 0;
GLuint vbo = 0;
std::vector<uint8_t> staging;
std::string driver_name;
bool has_frame = false;
int output_width = 0;
int output_height = 0;
bool vsync = false;
/* Bring-up trace. The first few frames are logged in full; after that only anomalies. */
long context_thread = 0;
int upload_traces = 0;
int upload_checks = 0;
int present_traces = 0;
long rebinds = 0;
long swap_failures = 0;
bool swapped_once = false;
/* Window-surface rebuild state (see armsx_render_native_window_generation). The config is
kept because rebuilding needs the SAME EGLConfig the context was created with — a context
and surface from different configs are not compatible. */
void* egl_config_kept = nullptr;
unsigned long window_generation = 0;
long surface_rebuilds = 0;
};
GlRenderer* Self(armsx_renderer_t* base) {
return static_cast<GlRenderer*>(base->impl);
}
/* Drains and reports the GL error queue. Every step that can fail calls this: a GLES driver
reports nothing on its own, so a present path that quietly does nothing is indistinguishable
from one that works and happens to draw a black frame. Returns true when the queue was
clean. */
bool GlCheck(GlRenderer* self, const char* step) {
if (!self->gl.GetError) {
return true;
}
bool clean = true;
for (int guard = 0; guard < 8; ++guard) {
const GLenum error = self->gl.GetError();
if (error == 0) {
break;
}
clean = false;
armsx_render_log("renderer", "GL error 0x%04x after %s", (unsigned)error, step);
}
return clean;
}
/* ---- proc loaders -------------------------------------------------------------------- */
void* SdlProcLoader(const char* name, void*) {
return SDL_GL_GetProcAddress(name);
}
#if defined(ARMSX_GL_HAVE_EGL)
void* EglProcLoader(const char* name, void* user) {
auto* self = static_cast<GlRenderer*>(user);
if (!self) {
return nullptr;
}
if (self->gles_library) {
if (void* address = dlsym(self->gles_library, name)) {
return address;
}
}
if (!self->egl.GetProcAddress) {
return nullptr;
}
return reinterpret_cast<void*>(self->egl.GetProcAddress(name));
}
#endif
/* ---- shader helpers ------------------------------------------------------------------- */
std::string ShaderHeader(bool es_profile) {
if (es_profile) {
return "#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n";
}
return "#version 330 core\n";
}
GLuint CompileShader(GlRenderer* self, GLenum type, const std::string& source) {
const char* kind = type == GL_VERTEX_SHADER ? "vertex" : "fragment";
const GLuint shader = self->gl.CreateShader(type);
if (!shader) {
/* The one failure that used to leave no trace whatsoever: a zero name here aborted
the whole present with nothing logged, which on screen is a black window. */
armsx_render_log("renderer", "GL glCreateShader(%s) returned 0", kind);
GlCheck(self, "glCreateShader");
return 0;
}
const char* text = source.c_str();
const GLint length = static_cast<GLint>(source.size());
self->gl.ShaderSource(shader, 1, &text, &length);
self->gl.CompileShader(shader);
char log[1024];
GLsizei log_length = 0;
log[0] = '\0';
self->gl.GetShaderInfoLog(shader, static_cast<GLsizei>(sizeof(log)), &log_length, log);
log[sizeof(log) - 1] = '\0';
GLint status = 0;
self->gl.GetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == static_cast<GLint>(GL_FALSE_)) {
armsx_render_log("renderer", "GL %s shader compile failed: %s", kind, log);
self->gl.DeleteShader(shader);
return 0;
}
if (log_length > 0) {
/* A shader that compiles but warns is worth seeing: it is usually the precision or
extension mismatch behind a driver-specific black frame. */
armsx_render_log("renderer", "GL %s shader compiled with diagnostics: %s", kind, log);
}
return shader;
}
bool BuildProgram(GlRenderer* self, ProgramKind kind) {
if (self->programs[kind]) {
return true;
}
const std::string header = ShaderHeader(self->es_profile);
std::string fragment = header;
switch (kind) {
case kProgramRgb:
fragment += kFragmentRgb;
break;
case kProgram555Nearest:
fragment += kDecode555;
fragment += kFragment555Nearest;
break;
case kProgram555Linear:
default:
fragment += kDecode555;
fragment += kFragment555Linear;
break;
}
const GLuint vs = CompileShader(self, GL_VERTEX_SHADER, header + kVertexBody);
if (!vs) {
return false;
}
const GLuint fs = CompileShader(self, GL_FRAGMENT_SHADER, fragment);
if (!fs) {
self->gl.DeleteShader(vs);
return false;
}
const GLuint program = self->gl.CreateProgram();
if (!program) {
armsx_render_log("renderer", "GL glCreateProgram returned 0 (program kind %d)", (int)kind);
GlCheck(self, "glCreateProgram");
self->gl.DeleteShader(vs);
self->gl.DeleteShader(fs);
return false;
}
self->gl.AttachShader(program, vs);
self->gl.AttachShader(program, fs);
self->gl.BindAttribLocation(program, 0, "a_pos");
self->gl.LinkProgram(program);
self->gl.DeleteShader(vs);
self->gl.DeleteShader(fs);
char log[1024];
GLsizei log_length = 0;
log[0] = '\0';
self->gl.GetProgramInfoLog(program, static_cast<GLsizei>(sizeof(log)), &log_length, log);
log[sizeof(log) - 1] = '\0';
GLint status = 0;
self->gl.GetProgramiv(program, GL_LINK_STATUS, &status);
if (status == static_cast<GLint>(GL_FALSE_)) {
armsx_render_log("renderer", "GL program link failed (kind %d): %s", (int)kind, log);
self->gl.DeleteProgram(program);
return false;
}
self->programs[kind] = program;
self->uniform_tex[kind] = self->gl.GetUniformLocation(program, "u_tex");
self->uniform_texsize[kind] = self->gl.GetUniformLocation(program, "u_texsize");
self->uniform_uvoff[kind] = self->gl.GetUniformLocation(program, "u_uvoff");
self->uniform_uvscale[kind] = self->gl.GetUniformLocation(program, "u_uvscale");
self->uniform_rot[kind] = self->gl.GetUniformLocation(program, "u_rot");
armsx_render_log("renderer", "GL program kind=%d linked id=%u u_tex=%d u_texsize=%d%s%s",
(int)kind, program, self->uniform_tex[kind], self->uniform_texsize[kind],
log_length > 0 ? " log=" : "", log_length > 0 ? log : "");
GlCheck(self, "program link");
return true;
}
bool CreateQuad(GlRenderer* self) {
static const GLfloat vertices[] = {
-1.0f, -1.0f,
1.0f, -1.0f,
-1.0f, 1.0f,
1.0f, 1.0f,
};
self->gl.GenVertexArrays(1, &self->vao);
self->gl.BindVertexArray(self->vao);
self->gl.GenBuffers(1, &self->vbo);
self->gl.BindBuffer(GL_ARRAY_BUFFER, self->vbo);
self->gl.BufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(sizeof(vertices)), vertices, GL_STATIC_DRAW);
self->gl.EnableVertexAttribArray(0);
self->gl.VertexAttribPointer(0, 2, GL_FLOAT, 0, static_cast<GLsizei>(2 * sizeof(GLfloat)), nullptr);
self->gl.BindVertexArray(0);
const bool clean = GlCheck(self, "full-screen quad setup");
armsx_render_log("renderer", "GL quad vao=%u vbo=%u", self->vao, self->vbo);
return clean && self->vao != 0 && self->vbo != 0;
}
/* ---- context providers ---------------------------------------------------------------- */
/*
An EGL context is current *per thread*, and a thread with no context current silently
drops every GL call: no GL error, no EGL error, no log — just a black window.
For the Android in-process host this is currently a no-op guard: NativeApp.runVMThread()
calls external_main_ex() straight through, so the renderer is created on the same
`armsx-vm` thread that later issues upload_frame()/present(), and the context is already
current there. The guard is kept because that is an accident of the call graph rather than
a contract — a renderer rebuilt from anywhere else (recreateManagedRenderer on a vsync
change, teardown from a different thread) would otherwise fail exactly this way. The
rebind counter keeps any such migration visible in the log instead of silent.
*/
bool EnsureContextCurrent(GlRenderer* self) {
#if defined(ARMSX_GL_HAVE_EGL)
if (self->use_egl) {
if (self->egl.GetCurrentContext() == self->egl_context &&
self->egl.GetCurrentSurface(EGL_DRAW) == self->egl_surface) {
return true;
}
if (self->egl.MakeCurrent(self->egl_display, self->egl_surface, self->egl_surface,
self->egl_context) != EGL_TRUE) {
armsx_render_log("renderer", "GL: eglMakeCurrent on thread %ld failed (0x%04x)",
CurrentThreadId(), self->egl.GetError());
return false;
}
self->rebinds++;
if (self->rebinds <= 4) {
armsx_render_log("renderer",
"GL: EGL context re-bound to thread %ld (created on %ld, rebind #%ld)",
CurrentThreadId(), self->context_thread, self->rebinds);
}
return true;
}
#endif
if (self->window && self->sdl_context) {
return SDL_GL_MakeCurrent(self->window, self->sdl_context) == 0;
}
return true;
}
#if defined(ARMSX_GL_HAVE_EGL)
/* Rebuild the EGL window surface when Android has handed us a new ANativeWindow.
Called once per frame from GlSwap. The context, all GL objects and the whole upscaled VRAM
target SURVIVE — only the window surface is recreated — so resuming costs nothing beyond one
eglCreateWindowSurface and the frame it happens on. Returns false if there is no window to
present to (app fully backgrounded), which tells the caller to skip the swap rather than
fail it five times and give up. */
bool GlEnsureWindowSurface(GlRenderer* self) {
const unsigned long generation = armsx_render_native_window_generation();
if (generation == self->window_generation && self->egl_surface != EGL_NO_SURFACE) {
return true;
}
void* const window = armsx_render_native_window();
/* Tear the old surface down even when there is no replacement yet: it refers to a window
that no longer exists, and holding it keeps the driver's buffers alive for a dead
compositor object. */
if (self->egl_surface != EGL_NO_SURFACE) {
self->egl.MakeCurrent(self->egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, self->egl_context);
self->egl.DestroySurface(self->egl_display, self->egl_surface);
self->egl_surface = EGL_NO_SURFACE;
}
/* The reference taken in CreateEglContext() belonged to the EGLSurface just destroyed.
Released here, and re-taken below for the replacement, so OpShutdown always releases the
window this renderer is actually bound to. */
if (self->native_window) {
ANativeWindow_release(self->native_window);
self->native_window = nullptr;
}
if (!window) {
/* Do NOT consume the generation here. The next publish bumps it again, but if this null
were recorded as "handled" and the app were killed mid-background, a resume that
reused the same counter value would skip the rebuild. Cheap to re-check. */
return false;
}
/* Re-match the window's buffer format to the config, exactly as the initial bring-up does.
A replacement window arrives with the SurfaceView's own format, and the EGL spec requires
the two to agree — without this the rebuild is an EGL_BAD_MATCH on any device whose
chosen config is not the window's default, i.e. a permanent black screen after a
task-switch rather than a recovered one. */
{
EGLint native_visual = 0;
if (self->egl.GetConfigAttrib(self->egl_display, (EGLConfig)self->egl_config_kept,
EGL_NATIVE_VISUAL_ID, &native_visual) == EGL_TRUE &&
native_visual != 0) {
ANativeWindow_setBuffersGeometry(static_cast<ANativeWindow*>(window), 0, 0,
native_visual);
}
}
self->egl_surface = self->egl.CreateWindowSurface(
self->egl_display, (EGLConfig)self->egl_config_kept, (EGLNativeWindowType)window, nullptr);
if (self->egl_surface == EGL_NO_SURFACE) {
armsx_render_log("renderer", "GL: surface rebuild failed (0x%04x); will retry next frame",
self->egl.GetError());
return false;
}
if (self->egl.MakeCurrent(self->egl_display, self->egl_surface, self->egl_surface,
self->egl_context) != EGL_TRUE) {
armsx_render_log("renderer", "GL: eglMakeCurrent after rebuild failed (0x%04x)",
self->egl.GetError());
self->egl.DestroySurface(self->egl_display, self->egl_surface);
self->egl_surface = EGL_NO_SURFACE;
return false;
}
/* Hold a reference for the lifetime of the new EGLSurface, and re-raise the claim: the
host's blit bridge must stay parked while this backend owns the window. */
ANativeWindow_acquire(static_cast<ANativeWindow*>(window));
self->native_window = static_cast<ANativeWindow*>(window);
armsx_render_set_native_window_claimed(true);
self->window_generation = generation;
self->swap_failures = 0;
armsx_render_log("renderer", "GL: window surface rebuilt after resume (#%ld) window=%p",
++self->surface_rebuilds, window);
return true;
}
#endif
void GlSwap(GlRenderer* self) {
#if defined(ARMSX_GL_HAVE_EGL)
if (self->use_egl) {
if (!GlEnsureWindowSurface(self)) {
return;
}
if (self->egl.SwapBuffers(self->egl_display, self->egl_surface) != EGL_TRUE) {
if (++self->swap_failures <= 5) {
armsx_render_log("renderer", "GL: eglSwapBuffers failed (0x%04x)", self->egl.GetError());
}
} else if (!self->swapped_once) {
/* The line that splits "we never posted a frame" from "we posted and something
downstream ate it". Worth one log entry. */
self->swapped_once = true;
armsx_render_log("renderer", "GL: first eglSwapBuffers posted on thread %ld",
CurrentThreadId());
}
return;
}
#endif
if (self->window) {
SDL_GL_SwapWindow(self->window);
}
}
void GlQueryDrawableSize(GlRenderer* self, int* width, int* height) {
int w = 0;
int h = 0;
#if defined(ARMSX_GL_HAVE_EGL)
if (self->use_egl) {
EGLint value = 0;
if (self->egl.QuerySurface(self->egl_display, self->egl_surface, EGL_WIDTH, &value) == EGL_TRUE) {
w = value;
}
value = 0;
if (self->egl.QuerySurface(self->egl_display, self->egl_surface, EGL_HEIGHT, &value) == EGL_TRUE) {
h = value;
}
} else
#endif
if (self->window) {
SDL_GL_GetDrawableSize(self->window, &w, &h);
}
if (width) {
*width = w > 0 ? w : self->output_width;
}
if (height) {
*height = h > 0 ? h : self->output_height;
}
}
void GlSetSwapInterval(GlRenderer* self, bool enabled) {
self->vsync = enabled;
#if defined(ARMSX_GL_HAVE_EGL)
if (self->use_egl) {
self->egl.SwapInterval(self->egl_display, enabled ? 1 : 0);
return;
}
#endif
SDL_GL_SetSwapInterval(enabled ? 1 : 0);
}
#if defined(ARMSX_GL_HAVE_EGL)
/*
Resolves the EGL table out of either the system driver or ANGLE.
ANGLE ships in the APK as libEGL_angle.so + libGLESv2_angle.so, so both are in the app's
native library dir and dlopen()able by soname. libEGL_angle.so pulls libGLESv2_angle.so
in itself; we also dlopen it directly so GL entry points resolve by dlsym rather than
exclusively through eglGetProcAddress.
The system driver is always the last candidate. A device where ANGLE is missing or
refuses to load must degrade to a working renderer, never to no renderer — but it must
also never do so QUIETLY, which is what active_gl_driver and the log line below are for.
*/
bool LoadEglApi(GlRenderer* self, armsx_render_gl_driver_t wanted) {
struct Provider {
armsx_render_gl_driver_t driver;
const char* label;
const char* egl_library;
const char* gles_libraries[2];
};
static const Provider kAngle = {ARMSX_RENDER_GL_DRIVER_ANGLE, "ANGLE", "libEGL_angle.so",
{"libGLESv2_angle.so", nullptr}};
static const Provider kSystem = {ARMSX_RENDER_GL_DRIVER_SYSTEM, "system", "libEGL.so",
{"libGLESv3.so", "libGLESv2.so"}};
const Provider* order[2] = {&kSystem, nullptr};
if (wanted == ARMSX_RENDER_GL_DRIVER_ANGLE) {
order[0] = &kAngle;
order[1] = &kSystem;
}
for (const Provider* provider : order) {
if (!provider) {
break;
}
void* library = dlopen(provider->egl_library, RTLD_NOW | RTLD_LOCAL);
if (!library) {
armsx_render_log("renderer", "EGL: dlopen(%s) failed: %s", provider->egl_library,
dlerror() ? dlerror() : "(no error)");
continue;
}
EglApi api{};
struct Entry {
const char* name;
void** slot;
};
const Entry entries[] = {
{"eglGetDisplay", (void**)&api.GetDisplay},
{"eglInitialize", (void**)&api.Initialize},
{"eglTerminate", (void**)&api.Terminate},
{"eglChooseConfig", (void**)&api.ChooseConfig},
{"eglGetConfigAttrib", (void**)&api.GetConfigAttrib},
{"eglBindAPI", (void**)&api.BindAPI},
{"eglCreateContext", (void**)&api.CreateContext},
{"eglDestroyContext", (void**)&api.DestroyContext},
{"eglCreateWindowSurface", (void**)&api.CreateWindowSurface},
{"eglDestroySurface", (void**)&api.DestroySurface},
{"eglMakeCurrent", (void**)&api.MakeCurrent},
{"eglQuerySurface", (void**)&api.QuerySurface},
{"eglSwapBuffers", (void**)&api.SwapBuffers},
{"eglSwapInterval", (void**)&api.SwapInterval},
{"eglGetError", (void**)&api.GetError},
{"eglGetCurrentContext", (void**)&api.GetCurrentContext},
{"eglGetCurrentSurface", (void**)&api.GetCurrentSurface},
{"eglGetProcAddress", (void**)&api.GetProcAddress},
{"eglQueryString", (void**)&api.QueryString},
};
const char* missing = nullptr;
for (const Entry& entry : entries) {
void* address = dlsym(library, entry.name);
if (!address) {
missing = entry.name;
break;
}
*entry.slot = address;
}
if (missing) {
armsx_render_log("renderer", "EGL: %s (%s) is missing %s; not usable.", provider->label,
provider->egl_library, missing);
dlclose(library);
continue;
}
self->egl = api;
self->egl_library = library;
self->active_gl_driver = provider->driver;
for (const char* name : provider->gles_libraries) {
if (!name) {
break;
}
self->gles_library = dlopen(name, RTLD_NOW | RTLD_LOCAL);
if (self->gles_library) {
break;
}
}
if (provider->driver != wanted) {
/* The loud half of "ANGLE selected can never silently mean system driver". */
armsx_render_log("renderer",
"EGL: %s was requested but is unavailable; FELL BACK to the %s "
"driver. The renderer name will say so.",
armsx_render_gl_driver_token(wanted), provider->label);
}
armsx_render_log("renderer", "EGL: provider=%s egl=%s gles=%s", provider->label,
provider->egl_library,
self->gles_library ? "dlopen ok" : "eglGetProcAddress only");
return true;
}
armsx_render_log("renderer", "EGL: no usable EGL implementation could be opened.");
return false;
}
bool CreateEglContext(GlRenderer* self, ANativeWindow* native_window) {
if (!LoadEglApi(self, armsx_render_gl_driver())) {
return false;
}
self->egl_display = self->egl.GetDisplay(EGL_DEFAULT_DISPLAY);
if (self->egl_display == EGL_NO_DISPLAY) {
armsx_render_log("renderer", "eglGetDisplay failed (0x%04x)", self->egl.GetError());
return false;
}
EGLint major = 0;
EGLint minor = 0;
if (self->egl.Initialize(self->egl_display, &major, &minor) != EGL_TRUE) {
armsx_render_log("renderer", "eglInitialize failed (0x%04x)", self->egl.GetError());
self->egl_display = EGL_NO_DISPLAY;
return false;
}
const EGLint config_attribs[] = {
EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR,
EGL_RED_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8,
EGL_ALPHA_SIZE, 0,
EGL_DEPTH_SIZE, 0,
EGL_STENCIL_SIZE, 0,
EGL_NONE
};
armsx_render_log("renderer", "EGL: initialized %d.%d on thread %ld window=%p (%dx%d)", (int)major,
(int)minor, CurrentThreadId(), static_cast<void*>(native_window),
ANativeWindow_getWidth(native_window), ANativeWindow_getHeight(native_window));
/* eglChooseConfig's sort order is "most colour bits first", so the head of the list is
not necessarily a config whose EGL_NATIVE_VISUAL_ID is a format ANativeWindow accepts.
Walk the matches and take the first usable one, logging every candidate: the config is
the first thing to check when a context comes up clean and renders nothing. */
EGLConfig configs[16] = {nullptr};
EGLint config_count = 0;
if (self->egl.ChooseConfig(self->egl_display, config_attribs, configs,
static_cast<EGLint>(sizeof(configs) / sizeof(configs[0])),
&config_count) != EGL_TRUE || config_count < 1) {
armsx_render_log("renderer", "eglChooseConfig found no ES3 window config (0x%04x)", self->egl.GetError());
return false;
}
/* android_pixel_format values ANativeWindow_setBuffersGeometry() accepts. */
static const EGLint kWindowVisuals[] = {1 /* RGBA_8888 */, 2 /* RGBX_8888 */,
3 /* RGB_888 */, 4 /* RGB_565 */,
22 /* RGBA_FP16 */, 43 /* RGBA_1010102 */};
EGLConfig config = configs[0];
EGLint native_visual = 0;
bool picked = false;
for (EGLint index = 0; index < config_count; ++index) {
EGLint red = 0, green = 0, blue = 0, alpha = 0, samples = 0, visual = 0;
self->egl.GetConfigAttrib(self->egl_display, configs[index], EGL_RED_SIZE, &red);
self->egl.GetConfigAttrib(self->egl_display, configs[index], EGL_GREEN_SIZE, &green);
self->egl.GetConfigAttrib(self->egl_display, configs[index], EGL_BLUE_SIZE, &blue);
self->egl.GetConfigAttrib(self->egl_display, configs[index], EGL_ALPHA_SIZE, &alpha);
self->egl.GetConfigAttrib(self->egl_display, configs[index], EGL_SAMPLES, &samples);
self->egl.GetConfigAttrib(self->egl_display, configs[index], EGL_NATIVE_VISUAL_ID, &visual);
bool usable = samples == 0;
if (usable) {
usable = false;
for (const EGLint candidate : kWindowVisuals) {
if (visual == candidate) {
usable = true;
break;
}
}
}
if (index < 4 || (usable && !picked)) {
armsx_render_log("renderer",
"EGL: config[%d] r%d g%d b%d a%d samples=%d visual=%d%s", (int)index,
(int)red, (int)green, (int)blue, (int)alpha, (int)samples, (int)visual,
usable && !picked ? " <- chosen" : "");
}
if (usable && !picked) {
config = configs[index];
native_visual = visual;
picked = true;
}
}
if (!picked) {
self->egl.GetConfigAttrib(self->egl_display, config, EGL_NATIVE_VISUAL_ID, &native_visual);
armsx_render_log("renderer",
"EGL: no config with a window-compatible visual among %d matches; using the "
"first (visual=%d)", (int)config_count, (int)native_visual);
}
/* Take the window now, before its geometry is touched. The JNI host re-applies the blit
bridge's framebuffer geometry from the UI thread on every SurfaceView layout pass
(android_jni.cpp ApplyBuffersGeometryLocked), and it only stands down once this flag is
set — so raising it any later leaves a window in which a resize/reformat lands on top of
a live EGLSurface. DestroyEglContext() clears it again on every failure path. */
armsx_render_set_native_window_claimed(true);
/* Match the window buffer format to the chosen config, as the EGL spec requires on
Android. Width/height stay 0 so the surface keeps its natural size. */
if (native_visual != 0) {
const int32_t geometry = ANativeWindow_setBuffersGeometry(native_window, 0, 0, native_visual);
if (geometry != 0) {
armsx_render_log("renderer",
"EGL: ANativeWindow_setBuffersGeometry(0,0,visual %d) failed (%d); the "
"window keeps the host's buffer geometry.", (int)native_visual,
(int)geometry);
}
}
/* The current rendering API is per-thread state with a process-wide default. Nothing here
should have moved it off ES, but binding it explicitly is free and the failure it
prevents — a desktop-GL context created and made current with no error anywhere — is
indistinguishable from a black window. */
if (self->egl.BindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) {
armsx_render_log("renderer", "self->egl.BindAPI(EGL_OPENGL_ES_API) failed (0x%04x)", self->egl.GetError());
}
const EGLint context_attribs[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
self->egl_context = self->egl.CreateContext(self->egl_display, config, EGL_NO_CONTEXT, context_attribs);
if (self->egl_context == EGL_NO_CONTEXT) {
armsx_render_log("renderer", "self->egl.CreateContext(ES3) failed (0x%04x)", self->egl.GetError());
return false;
}
self->egl_config_kept = (void*)config;
self->window_generation = armsx_render_native_window_generation();
self->egl_surface = self->egl.CreateWindowSurface(self->egl_display, config, native_window, nullptr);
if (self->egl_surface == EGL_NO_SURFACE) {
armsx_render_log("renderer", "eglCreateWindowSurface failed (0x%04x)", self->egl.GetError());
return false;
}
if (self->egl.MakeCurrent(self->egl_display, self->egl_surface, self->egl_surface, self->egl_context) != EGL_TRUE) {
armsx_render_log("renderer", "eglMakeCurrent failed (0x%04x)", self->egl.GetError());
return false;
}
EGLint surface_width = 0;
EGLint surface_height = 0;
self->egl.QuerySurface(self->egl_display, self->egl_surface, EGL_WIDTH, &surface_width);
self->egl.QuerySurface(self->egl_display, self->egl_surface, EGL_HEIGHT, &surface_height);
armsx_render_log("renderer",
"EGL: context=%p surface=%p (%dx%d) visual=%d current on thread %ld, gles=%s",
static_cast<void*>(self->egl_context), static_cast<void*>(self->egl_surface),
(int)surface_width, (int)surface_height, (int)native_visual,
CurrentThreadId(), self->gles_library ? "dlopen ok" : "eglGetProcAddress only");
if (surface_width <= 0 || surface_height <= 0) {
armsx_render_log("renderer", "EGL: window surface has no area; nothing could be presented.");
return false;
}
self->context_thread = CurrentThreadId();
self->use_egl = true;
self->es_profile = true;
/* Hold our own reference: the JNI host releases its ANativeWindow the moment the Compose
Surface goes away, and the EGLSurface must outlive that race. */
ANativeWindow_acquire(native_window);
self->native_window = native_window;
return true;
}
void DestroyEglContext(GlRenderer* self) {
/* Paired with the claim CreateEglContext() takes before it touches the window: without
this, a failed bring-up would leave the host's blit bridge permanently stood down and
the fallback ladder would present into nothing. */
armsx_render_set_native_window_claimed(false);
/* Reachable with an empty table: LoadEglApi() can fail before anything was created. */
if (self->egl_display != EGL_NO_DISPLAY && self->egl.MakeCurrent) {
self->egl.MakeCurrent(self->egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (self->egl_surface != EGL_NO_SURFACE) {
self->egl.DestroySurface(self->egl_display, self->egl_surface);
}
if (self->egl_context != EGL_NO_CONTEXT) {
self->egl.DestroyContext(self->egl_display, self->egl_context);
}
self->egl.Terminate(self->egl_display);
}
self->egl_display = EGL_NO_DISPLAY;
self->egl_surface = EGL_NO_SURFACE;
self->egl_context = EGL_NO_CONTEXT;
if (self->gles_library) {
dlclose(self->gles_library);
self->gles_library = nullptr;
}
if (self->egl_library) {
dlclose(self->egl_library);
self->egl_library = nullptr;
}
self->egl = EglApi{};
if (self->native_window) {
ANativeWindow_release(self->native_window);
self->native_window = nullptr;
}
}
#endif /* ARMSX_GL_HAVE_EGL */
/* ---- texture ---------------------------------------------------------------------------- */
struct GlFormat {
GLint internal_format;
GLenum format;
int source_bytes_per_pixel;
bool packed_555;
};
bool ResolveFormat(Uint32 sdl_format, GlFormat* out) {
switch (sdl_format) {
case SDL_PIXELFORMAT_BGR555:
out->internal_format = static_cast<GLint>(GL_RG8);
out->format = GL_RG;
out->source_bytes_per_pixel = 2;
out->packed_555 = true;
return true;
case SDL_PIXELFORMAT_RGB24:
out->internal_format = static_cast<GLint>(GL_RGB8);
out->format = GL_RGB;
out->source_bytes_per_pixel = 3;
out->packed_555 = false;
return true;
default:
return false;
}
}
void ApplyTextureFilter(GlRenderer* self, bool linear, bool packed_555) {
/* Packed 16-bit texels can never be hardware-filtered (see the file header); the
bilinear variant is done in the shader instead. */
const bool hardware_linear = linear && !packed_555;
const int wanted = hardware_linear ? 1 : 0;
if (self->texture_filter == wanted) {
return;
}
const GLint mode = static_cast<GLint>(hardware_linear ? GL_LINEAR : GL_NEAREST);
self->gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, mode);
self->gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, mode);
self->texture_filter = wanted;
}
/* ---- ops ----------------------------------------------------------------------------- */
const char* OpDriverName(armsx_renderer_t* base) {
return Self(base)->driver_name.c_str();
}
bool OpIsAccelerated(armsx_renderer_t*) {
return true;
}
SDL_Renderer* OpSdlHandle(armsx_renderer_t*) {
return nullptr;
}
void OpResize(armsx_renderer_t* base, int width, int height) {
GlRenderer* self = Self(base);
if (width > 0) {
self->output_width = width;
}
if (height > 0) {
self->output_height = height;
}
}
void OpOutputSize(armsx_renderer_t* base, int* width, int* height) {
GlQueryDrawableSize(Self(base), width, height);
}
void OpSetVsync(armsx_renderer_t* base, bool enabled) {
GlSetSwapInterval(Self(base), enabled);
}
bool OpUploadFrame(armsx_renderer_t* base,
const void* pixels,
int width,
int height,
int pitch,
Uint32 sdl_format,
int dirty_first_row,
int dirty_last_row) {
GlRenderer* self = Self(base);
/* A CPU upload always wins back the source, so an adopted texture can never linger and
shadow it. Cleared before the argument checks so a rejected upload still releases it. */
self->external_texture = 0;
if (!pixels || width <= 0 || height <= 0 || pitch <= 0) {
return false;
}
GlFormat format{};
if (!ResolveFormat(sdl_format, &format)) {
armsx_render_log("renderer", "GL backend cannot upload %s", SDL_GetPixelFormatName(sdl_format));
return false;
}
if (!EnsureContextCurrent(self)) {
return false;
}
if (self->upload_traces < 3) {
self->upload_traces++;
armsx_render_log("renderer",
"GL upload #%d thread=%ld %dx%d pitch=%d format=%s rows=%d..%d",
self->upload_traces, CurrentThreadId(), width, height, pitch,
SDL_GetPixelFormatName(sdl_format), dirty_first_row, dirty_last_row);
}
self->gl.ActiveTexture(GL_TEXTURE0);
if (!self->texture || width != self->texture_width || height != self->texture_height ||
sdl_format != self->texture_format) {
if (!self->texture) {
self->gl.GenTextures(1, &self->texture);
}
self->gl.BindTexture(GL_TEXTURE_2D, self->texture);
self->gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, static_cast<GLint>(GL_CLAMP_TO_EDGE));
self->gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, static_cast<GLint>(GL_CLAMP_TO_EDGE));
self->texture_filter = -1;
ApplyTextureFilter(self, false, format.packed_555);
self->gl.PixelStorei(GL_UNPACK_ALIGNMENT, 1);
self->gl.TexImage2D(GL_TEXTURE_2D, 0, format.internal_format, width, height, 0, format.format,
GL_UNSIGNED_BYTE, nullptr);
self->texture_width = width;
self->texture_height = height;
self->texture_format = sdl_format;
/* Fresh storage has undefined contents: force a full upload this frame. */
dirty_first_row = 0;
dirty_last_row = height - 1;
if (!GlCheck(self, "texture storage allocation")) {
return false;
}
} else {
self->gl.BindTexture(GL_TEXTURE_2D, self->texture);
}
/* Storage exists, so the quad has something to sample from. Marking the frame here
rather than after the copy matters: an empty dirty span returns early, and gating
present() on "a copy happened" meant a single skipped span could stop the backend
swapping at all — which on an embedded Surface is a frozen (black) window, not a
stale frame. */
self->has_frame = true;
if (dirty_last_row < dirty_first_row) {
return true;
}
if (dirty_first_row < 0) {
dirty_first_row = 0;
}
if (dirty_last_row > height - 1) {
dirty_last_row = height - 1;
}
const int rows = dirty_last_row - dirty_first_row + 1;
const size_t row_bytes = static_cast<size_t>(width) * static_cast<size_t>(format.source_bytes_per_pixel);
/* Rows are PSX_GPU_FB_STRIDE bytes apart; repack the dirty span so the whole update is
one glTexSubImage2D. GL_UNPACK_ROW_LENGTH cannot express a 2048-byte stride for the
3-byte format. */
self->staging.resize(row_bytes * static_cast<size_t>(rows));
const auto* source = static_cast<const uint8_t*>(pixels);
for (int row = 0; row < rows; ++row) {
std::memcpy(self->staging.data() + (static_cast<size_t>(row) * row_bytes),
source + (static_cast<size_t>(dirty_first_row + row) * static_cast<size_t>(pitch)),
row_bytes);
}
self->gl.PixelStorei(GL_UNPACK_ALIGNMENT, 1);
self->gl.TexSubImage2D(GL_TEXTURE_2D, 0, 0, dirty_first_row, width, rows, format.format,
GL_UNSIGNED_BYTE, self->staging.data());
/* Bounded: glGetError() is a full pipeline sync on a tiler, so it must not survive
bring-up into the steady-state frame loop. */
if (self->upload_checks < 3) {
self->upload_checks++;
GlCheck(self, "glTexSubImage2D");
}
return true;
}
/*
Presents whatever state the backend is in. The only early-outs left are the two that make
a swap impossible — no current context, and a zero-area window surface; a missing frame,
an unsupported format or a shader that would not build all fall through to clear + swap.
That is deliberate. This backend owns the host's Surface outright — the JNI blit bridge
stands down the moment it claims the window — so a present that returns without swapping
does not leave a stale frame on screen, it leaves the last thing anyone posted, forever.
Every such bail was therefore a permanent black window indistinguishable from a broken
renderer; now each one still posts a frame and says why in the log.
*/
void OpPresent(armsx_renderer_t* base, const armsx_render_frame_params_t* params) {
GlRenderer* self = Self(base);
if (!EnsureContextCurrent(self)) {
return;
}
int out_w = 0;
int out_h = 0;
GlQueryDrawableSize(self, &out_w, &out_h);
if (out_w <= 0 || out_h <= 0) {
if (self->present_traces < 3) {
self->present_traces++;
armsx_render_log("renderer", "GL present: drawable has no area (%dx%d).", out_w, out_h);
}
return;
}
self->output_width = out_w;
self->output_height = out_h;
const bool linear = params && params->linear_filter;
GlFormat format{};
ProgramKind kind = kProgramRgb;
/* Prefer a frame the rasterizer left on the GPU: no readback, no re-upload. */
const bool use_external = self->external_texture != 0;
const GLuint src_texture = use_external ? self->external_texture : self->texture;
const int src_width = use_external ? self->external_width : self->texture_width;
const int src_height = use_external ? self->external_height : self->texture_height;
const Uint32 src_format = use_external ? self->external_format : self->texture_format;
bool drawable = src_texture != 0 && self->has_frame && ResolveFormat(src_format, &format);
if (drawable) {
if (format.packed_555) {
kind = linear ? kProgram555Linear : kProgram555Nearest;
}
if (!BuildProgram(self, kind)) {
/* Already logged in detail by CompileShader/BuildProgram. Fall through so the
window keeps getting frames rather than freezing on the failure. */
drawable = false;
}
}
SDL_Rect dst{0, 0, 0, 0};
if (drawable) {
armsx_render_compute_dst(out_w, out_h, src_width, src_height, params, &dst);
}
self->gl.Disable(GL_DEPTH_TEST);
self->gl.Disable(GL_CULL_FACE);
self->gl.Disable(GL_BLEND);
self->gl.Disable(GL_SCISSOR_TEST);
self->gl.Viewport(0, 0, out_w, out_h);
self->gl.ClearColor(0.0f, 0.0f, 0.0f, 1.0f);
self->gl.Clear(GL_COLOR_BUFFER_BIT);
if (drawable && dst.w > 0 && dst.h > 0) {
/* SDL_Rect is top-left origin; GL viewports are bottom-left. */
self->gl.Viewport(dst.x, out_h - dst.y - dst.h, dst.w, dst.h);
self->gl.UseProgram(self->programs[kind]);
self->gl.ActiveTexture(GL_TEXTURE0);
self->gl.BindTexture(GL_TEXTURE_2D, src_texture);
ApplyTextureFilter(self, linear, format.packed_555);
if (self->uniform_tex[kind] >= 0) {
self->gl.Uniform1i(self->uniform_tex[kind], 0);
}
if (self->uniform_texsize[kind] >= 0) {
self->gl.Uniform2f(self->uniform_texsize[kind], static_cast<GLfloat>(src_width),
static_cast<GLfloat>(src_height));
}
/* [video] overscan_crop / display_rotation. Clamped against the real texture size so
a stale crop from a previous, larger display mode can never sample past the edge —
the display resolution changes mid-game (256/320/368/512/640) and the crop is
recomputed from it every frame, but a frame in between must still be safe. */
{
float off_x = 0.0f, off_y = 0.0f, sc_x = 1.0f, sc_y = 1.0f;
if (params && params->crop_w > 0 && params->crop_h > 0 && src_width > 0 &&
src_height > 0) {
int cx = params->crop_x < 0 ? 0 : params->crop_x;
int cy = params->crop_y < 0 ? 0 : params->crop_y;
int cw = params->crop_w;
int ch = params->crop_h;
if (cx > src_width - 1) cx = src_width - 1;
if (cy > src_height - 1) cy = src_height - 1;
if (cw > src_width - cx) cw = src_width - cx;
if (ch > src_height - cy) ch = src_height - cy;
off_x = static_cast<float>(cx) / static_cast<float>(src_width);
off_y = static_cast<float>(cy) / static_cast<float>(src_height);
sc_x = static_cast<float>(cw) / static_cast<float>(src_width);
sc_y = static_cast<float>(ch) / static_cast<float>(src_height);
}
if (self->uniform_uvoff[kind] >= 0) {
self->gl.Uniform2f(self->uniform_uvoff[kind], off_x, off_y);
}
if (self->uniform_uvscale[kind] >= 0) {
self->gl.Uniform2f(self->uniform_uvscale[kind], sc_x, sc_y);
}
if (self->uniform_rot[kind] >= 0) {
self->gl.Uniform1i(self->uniform_rot[kind],
params ? (params->rotation & 3) : 0);
}
}
self->gl.BindVertexArray(self->vao);
self->gl.DrawArrays(GL_TRIANGLE_STRIP, 0, 4);
self->gl.BindVertexArray(0);
self->gl.UseProgram(0);
}
if (self->present_traces < 3) {
self->present_traces++;
armsx_render_log("renderer",
"GL present #%d thread=%ld drawable=%s out=%dx%d dst=%d,%d %dx%d "
"tex=%dx%d(id %u) format=%s program=%u(kind %d) vao=%u",
self->present_traces, CurrentThreadId(), drawable ? "true" : "false", out_w,
out_h, dst.x, dst.y, dst.w, dst.h, self->texture_width,
self->texture_height, self->texture,
SDL_GetPixelFormatName(self->texture_format), self->programs[kind],
(int)kind, self->vao);
GlCheck(self, "present");
}
GlSwap(self);
}
void OpPresentBlank(armsx_renderer_t* base) {
GlRenderer* self = Self(base);
if (!EnsureContextCurrent(self)) {
return;
}
int out_w = 0;
int out_h = 0;
GlQueryDrawableSize(self, &out_w, &out_h);
if (out_w <= 0 || out_h <= 0) {
return;
}
self->gl.Viewport(0, 0, out_w, out_h);
self->gl.ClearColor(0.0f, 0.0f, 0.0f, 1.0f);
self->gl.Clear(GL_COLOR_BUFFER_BIT);
GlSwap(self);
}
void OpShutdown(armsx_renderer_t* base) {
GlRenderer* self = Self(base);
/* Deleting objects needs the context on THIS thread too, and teardown does not
necessarily run on the thread that presented. */
EnsureContextCurrent(self);
if (self->gl.DeleteTextures && self->texture) {
self->gl.DeleteTextures(1, &self->texture);
}
for (int index = 0; index < kProgramCount; ++index) {
if (self->gl.DeleteProgram && self->programs[index]) {
self->gl.DeleteProgram(self->programs[index]);
}
}
if (self->gl.DeleteBuffers && self->vbo) {
self->gl.DeleteBuffers(1, &self->vbo);
}
if (self->gl.DeleteVertexArrays && self->vao) {
self->gl.DeleteVertexArrays(1, &self->vao);
}
#if defined(ARMSX_GL_HAVE_EGL)
if (self->use_egl) {
DestroyEglContext(self);
armsx_render_set_native_window_claimed(false);
} else
#endif
if (self->sdl_context) {
SDL_GL_DeleteContext(self->sdl_context);
self->sdl_context = nullptr;
}
armsx_render_set_active_name("");
delete self;
}
/* Adopt a GPU-resident frame. Returns false without changing anything when the format is
not one this backend can sample, which is the caller's cue to keep using upload_frame(). */
bool OpAdoptGlTexture(armsx_renderer_t* base, unsigned int texture, int width, int height,
Uint32 sdl_format) {
GlRenderer* self = Self(base);
if (texture == 0) {
self->external_texture = 0;
return true;
}
GlFormat format{};
if (width <= 0 || height <= 0 || !ResolveFormat(sdl_format, &format)) {
return false;
}
self->external_texture = static_cast<GLuint>(texture);
self->external_width = width;
self->external_height = height;
self->external_format = sdl_format;
self->has_frame = true;
return true;
}
const armsx_render_ops_t kGlOps = {
OpDriverName,
OpIsAccelerated,
OpSdlHandle,
OpResize,
OpOutputSize,
OpSetVsync,
OpUploadFrame,
OpPresent,
OpPresentBlank,
OpShutdown,
OpAdoptGlTexture,
};
bool FinishSetup(GlRenderer* self, GlProcLoader loader, void* user, bool vsync) {
std::string missing;
if (!LoadGlApi(self->gl, loader, user, missing)) {
armsx_render_log("renderer", "GL entry point unavailable: %s", missing.c_str());
return false;
}
const unsigned char* version = self->gl.GetString(GL_VERSION_STR);
const unsigned char* renderer_string = self->gl.GetString(GL_RENDERER_STR);
const unsigned char* vendor = self->gl.GetString(GL_VENDOR);
if (!version && !renderer_string && !vendor) {
/* Every string query coming back NULL is the signature of GL calls landing on no
context at all, which otherwise produces a black window and nothing else. */
armsx_render_log("renderer", "GL: glGetString returned nothing; there is no current context.");
GlCheck(self, "glGetString");
return false;
}
self->driver_name = renderer_string ? reinterpret_cast<const char*>(renderer_string) : "OpenGL";
armsx_render_log("renderer", "GL backend ready vendor=%s renderer=%s version=%s profile=%s",
vendor ? reinterpret_cast<const char*>(vendor) : "(unknown)",
renderer_string ? reinterpret_cast<const char*>(renderer_string) : "(unknown)",
version ? reinterpret_cast<const char*>(version) : "(unknown)",
self->es_profile ? "GLES3" : "GL core");
/* Feed the same strings to the GPU profile. This matters most under ANGLE, whose renderer
string names itself with the real GPU parenthesised inside it —
"ANGLE (ARM, Vulkan 1.1.177 (Mali-G77 MC9), ...)" — so a reader that stops at the vendor
loses every Mali fact at the exact moment they are most likely to be needed, ANGLE being
what people turn on when the native driver already misbehaved. */
armsx_gpu_profile_note_gl(vendor ? reinterpret_cast<const char*>(vendor) : nullptr,
renderer_string ? reinterpret_cast<const char*>(renderer_string) : nullptr,
version ? reinterpret_cast<const char*>(version) : nullptr);
{
char profile_line[320];
armsx_gpu_profile_describe(profile_line, sizeof(profile_line));
armsx_render_log("renderer", "GPU profile: %s", profile_line);
}
/* Whatever the driver left behind while we were resolving entry points is not ours. */
GlCheck(self, "entry point resolution");
if (!CreateQuad(self)) {
armsx_render_log("renderer", "GL full-screen quad setup failed.");
return false;
}
/* Build the program the core starts on up front rather than on the first present: a
driver that refuses this GLSL should say so during bring-up, where the fallback ladder
can still act on it, not silently at frame 1. The other two variants stay on demand —
losing the bilinear 555 shader only costs filtering, not the picture. */
if (!BuildProgram(self, kProgram555Nearest)) {
armsx_render_log("renderer", "GL present shader could not be built.");
return false;
}
GlSetSwapInterval(self, vsync);
GlQueryDrawableSize(self, &self->output_width, &self->output_height);
armsx_render_log("renderer", "GL drawable %dx%d vsync=%s", self->output_width,
self->output_height, vsync ? "true" : "false");
return GlCheck(self, "GL backend setup");
}
} // namespace
extern "C" {
void armsx_render_gl_prepare_attributes(void) {
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 0);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 0);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
}
armsx_renderer_t* armsx_render_create_gl(SDL_Window* window, const armsx_render_config_t* config) {
const bool vsync = config && config->vsync;
auto* self = new GlRenderer();
self->base.ops = &kGlOps;
self->base.impl = self;
self->base.backend = ARMSX_RENDER_BACKEND_OPENGL;
self->window = window;
#if defined(ARMSX_GL_HAVE_EGL)
/* Preferred on Android: the Compose host's ANativeWindow. SDL runs on the `dummy` video
driver there, so SDL_GL_CreateContext has nothing to attach to. */
armsx_render_log("renderer", "GL backend: create on thread %ld, native window=%p, sdl window=%p",
CurrentThreadId(), armsx_render_native_window(), static_cast<void*>(window));
if (auto* native_window = static_cast<ANativeWindow*>(armsx_render_native_window())) {
if (CreateEglContext(self, native_window)) {
if (FinishSetup(self, EglProcLoader, self, vsync)) {
/* Already claimed inside CreateEglContext, before the window's geometry was
touched; this is the point at which the claim becomes permanent. */
armsx_render_set_native_window_claimed(true);
/* Names the provider we ACTUALLY bound to, not the one that was asked for. */
const std::string name =
std::string("OpenGL ES (") +
(self->active_gl_driver == ARMSX_RENDER_GL_DRIVER_ANGLE ? "ANGLE" : "system") + ")";
armsx_render_set_active_name(name.c_str());
armsx_render_log("renderer", "GL backend bound to the host ANativeWindow via EGL: %s",
name.c_str());
return &self->base;
}
}
armsx_render_log("renderer", "EGL bring-up on the host ANativeWindow failed; trying SDL GL.");
DestroyEglContext(self);
self->use_egl = false;
}
#endif
if (!window) {
armsx_render_log("renderer", "GL backend needs a window (or a registered ANativeWindow).");
delete self;
return nullptr;
}
if ((SDL_GetWindowFlags(window) & SDL_WINDOW_OPENGL) == 0) {
armsx_render_log("renderer", "GL backend: window was not created with SDL_WINDOW_OPENGL.");
delete self;
return nullptr;
}
self->sdl_context = SDL_GL_CreateContext(window);
self->es_profile = true;
if (!self->sdl_context) {
armsx_render_log("renderer", "SDL_GL_CreateContext(GLES 3.0) failed: %s; retrying GL 3.3 core.",
SDL_GetError());
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
self->sdl_context = SDL_GL_CreateContext(window);
self->es_profile = false;
}
if (!self->sdl_context) {
armsx_render_log("renderer", "SDL_GL_CreateContext failed: %s", SDL_GetError());
delete self;
return nullptr;
}
SDL_GL_MakeCurrent(window, self->sdl_context);
if (!FinishSetup(self, SdlProcLoader, nullptr, vsync)) {
SDL_GL_DeleteContext(self->sdl_context);
delete self;
return nullptr;
}
armsx_render_set_active_name(self->es_profile ? "OpenGL ES (system)" : "OpenGL (system)");
return &self->base;
}
} // extern "C"
#else /* !ARMSX_ENABLE_GL */
extern "C" {
void armsx_render_gl_prepare_attributes(void) {}
armsx_renderer_t* armsx_render_create_gl(SDL_Window*, const armsx_render_config_t*) {
return nullptr;
}
} // extern "C"
#endif