diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt
index 53dbfe7b0..0397ea3fc 100644
--- a/src/video_core/CMakeLists.txt
+++ b/src/video_core/CMakeLists.txt
@@ -54,6 +54,8 @@ add_library(video_core STATIC
     renderer_opengl/post_processing_opengl.h
     renderer_opengl/renderer_opengl.cpp
     renderer_opengl/renderer_opengl.h
+    renderer_opengl/texture_downloader_es.cpp
+    renderer_opengl/texture_downloader_es.h
     renderer_opengl/texture_filters/anime4k/anime4k_ultrafast.cpp
     renderer_opengl/texture_filters/anime4k/anime4k_ultrafast.h
     renderer_opengl/texture_filters/bicubic/bicubic.cpp
@@ -99,6 +101,9 @@ add_library(video_core STATIC
 )
 
 set(SHADER_FILES
+    renderer_opengl/depth_to_color.frag
+    renderer_opengl/depth_to_color.vert
+    renderer_opengl/ds_to_color.frag
     renderer_opengl/texture_filters/anime4k/refine.frag
     renderer_opengl/texture_filters/anime4k/x_gradient.frag
     renderer_opengl/texture_filters/anime4k/y_gradient.frag
diff --git a/src/video_core/renderer_opengl/depth_to_color.frag b/src/video_core/renderer_opengl/depth_to_color.frag
new file mode 100644
index 000000000..e69bed890
--- /dev/null
+++ b/src/video_core/renderer_opengl/depth_to_color.frag
@@ -0,0 +1,10 @@
+//? #version 320 es
+
+out highp uint color;
+
+uniform highp sampler2D depth;
+uniform int lod;
+
+void main() {
+    color = uint(texelFetch(depth, ivec2(gl_FragCoord.xy), lod).x * (exp2(32.0) - 1.0));
+}
diff --git a/src/video_core/renderer_opengl/depth_to_color.vert b/src/video_core/renderer_opengl/depth_to_color.vert
new file mode 100644
index 000000000..866d43b46
--- /dev/null
+++ b/src/video_core/renderer_opengl/depth_to_color.vert
@@ -0,0 +1,8 @@
+//? #version 320 es
+
+const vec2 vertices[4] =
+    vec2[4](vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(-1.0, 1.0), vec2(1.0, 1.0));
+
+void main() {
+    gl_Position = vec4(vertices[gl_VertexID], 0.0, 1.0);
+}
diff --git a/src/video_core/renderer_opengl/ds_to_color.frag b/src/video_core/renderer_opengl/ds_to_color.frag
new file mode 100644
index 000000000..954217064
--- /dev/null
+++ b/src/video_core/renderer_opengl/ds_to_color.frag
@@ -0,0 +1,9 @@
+//? #version 320 es
+#extension GL_ARM_shader_framebuffer_fetch_depth_stencil : enable
+
+out highp uint color;
+
+void main() {
+    color = uint(gl_LastFragDepthARM * (exp2(24.0) - 1.0)) << 8;
+    color |= uint(gl_LastFragStencilARM);
+}
diff --git a/src/video_core/renderer_opengl/gl_rasterizer_cache.cpp b/src/video_core/renderer_opengl/gl_rasterizer_cache.cpp
index 262a936cf..3139c1101 100644
--- a/src/video_core/renderer_opengl/gl_rasterizer_cache.cpp
+++ b/src/video_core/renderer_opengl/gl_rasterizer_cache.cpp
@@ -36,6 +36,7 @@
 #include "video_core/renderer_opengl/gl_rasterizer_cache.h"
 #include "video_core/renderer_opengl/gl_state.h"
 #include "video_core/renderer_opengl/gl_vars.h"
+#include "video_core/renderer_opengl/texture_downloader_es.h"
 #include "video_core/renderer_opengl/texture_filters/texture_filterer.h"
 #include "video_core/utils.h"
 #include "video_core/video_core.h"
@@ -64,13 +65,6 @@ static constexpr std::array<FormatTuple, 5> fb_format_tuples_oes = {{
     {GL_RGBA4, GL_RGBA, GL_UNSIGNED_SHORT_4_4_4_4},   // RGBA4
 }};
 
-static constexpr std::array<FormatTuple, 4> depth_format_tuples = {{
-    {GL_DEPTH_COMPONENT16, GL_DEPTH_COMPONENT, GL_UNSIGNED_SHORT}, // D16
-    {},
-    {GL_DEPTH_COMPONENT24, GL_DEPTH_COMPONENT, GL_UNSIGNED_INT},   // D24
-    {GL_DEPTH24_STENCIL8, GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8}, // D24S8
-}};
-
 const FormatTuple& GetFormatTuple(PixelFormat pixel_format) {
     const SurfaceType type = SurfaceParams::GetFormatType(pixel_format);
     if (type == SurfaceType::Color) {
@@ -87,79 +81,6 @@ const FormatTuple& GetFormatTuple(PixelFormat pixel_format) {
     return tex_tuple;
 }
 
-/**
- * OpenGL ES does not support glGetTexImage. Obtain the pixels by attaching the
- * texture to a framebuffer.
- * Originally from https://github.com/apitrace/apitrace/blob/master/retrace/glstate_images.cpp
- */
-static void GetTexImageOES(GLenum target, GLint level, GLenum format, GLenum type, GLint height,
-                           GLint width, GLint depth, GLubyte* pixels, std::size_t size) {
-    memset(pixels, 0x80, size);
-
-    OpenGLState cur_state = OpenGLState::GetCurState();
-    OpenGLState state;
-
-    GLenum texture_binding = GL_NONE;
-    switch (target) {
-    case GL_TEXTURE_2D:
-        texture_binding = cur_state.texture_units[0].texture_2d;
-        break;
-    case GL_TEXTURE_CUBE_MAP_POSITIVE_X:
-    case GL_TEXTURE_CUBE_MAP_NEGATIVE_X:
-    case GL_TEXTURE_CUBE_MAP_POSITIVE_Y:
-    case GL_TEXTURE_CUBE_MAP_NEGATIVE_Y:
-    case GL_TEXTURE_CUBE_MAP_POSITIVE_Z:
-    case GL_TEXTURE_CUBE_MAP_NEGATIVE_Z:
-        texture_binding = cur_state.texture_cube_unit.texture_cube;
-        break;
-    default:
-        LOG_CRITICAL(Render_OpenGL, "Unexpected target {:x}", target);
-        UNIMPLEMENTED();
-        return;
-    }
-
-    GLint texture = 0;
-    glGetIntegerv(texture_binding, &texture);
-    if (!texture) {
-        return;
-    }
-
-    OGLFramebuffer fbo;
-    fbo.Create();
-    state.draw.read_framebuffer = fbo.handle;
-    state.Apply();
-
-    switch (target) {
-    case GL_TEXTURE_2D:
-    case GL_TEXTURE_CUBE_MAP_POSITIVE_X:
-    case GL_TEXTURE_CUBE_MAP_NEGATIVE_X:
-    case GL_TEXTURE_CUBE_MAP_POSITIVE_Y:
-    case GL_TEXTURE_CUBE_MAP_NEGATIVE_Y:
-    case GL_TEXTURE_CUBE_MAP_POSITIVE_Z:
-    case GL_TEXTURE_CUBE_MAP_NEGATIVE_Z: {
-        glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture,
-                               level);
-        GLenum status = glCheckFramebufferStatus(GL_READ_FRAMEBUFFER);
-        if (status != GL_FRAMEBUFFER_COMPLETE) {
-            LOG_DEBUG(Render_OpenGL, "Framebuffer is incomplete, status: {:X}", status);
-        }
-        glReadPixels(0, 0, width, height, format, type, pixels);
-        break;
-    }
-    case GL_TEXTURE_3D_OES:
-        for (int i = 0; i < depth; i++) {
-            glFramebufferTexture3D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_3D,
-                                   texture, level, i);
-            glReadPixels(0, 0, width, height, format, type, pixels + 4 * i * width * height);
-        }
-        break;
-    }
-
-    cur_state.Apply();
-
-    fbo.Release();
-}
-
 template <typename Map, typename Interval>
 static constexpr auto RangeFromInterval(Map& map, const Interval& interval) {
     return boost::make_iterator_range(map.equal_range(interval));
@@ -775,23 +696,28 @@ void CachedSurface::DumpTexture(GLuint target_tex, u64 tex_hash) {
         LOG_INFO(Render_OpenGL, "Dumping texture to {}", dump_path);
         std::vector<u8> decoded_texture;
         decoded_texture.resize(width * height * 4);
-        glBindTexture(GL_TEXTURE_2D, target_tex);
+        OpenGLState state = OpenGLState::GetCurState();
+        GLuint old_texture = state.texture_units[0].texture_2d;
+        state.Apply();
         /*
            GetTexImageOES is used even if not using OpenGL ES to work around a small issue that
            happens if using custom textures with texture dumping at the same.
            Let's say there's 2 textures that are both 32x32 and one of them gets replaced with a
-           higher quality 256x256 texture. If the 256x256 texture is displayed first and the 32x32
-           texture gets uploaded to the same underlying OpenGL texture, the 32x32 texture will
-           appear in the corner of the 256x256 texture.
-           If texture dumping is enabled and the 32x32 is undumped, Citra will attempt to dump it.
-           Since the underlying OpenGL texture is still 256x256, Citra crashes because it thinks the
-           texture is only 32x32.
+           higher quality 256x256 texture. If the 256x256 texture is displayed first and the
+           32x32 texture gets uploaded to the same underlying OpenGL texture, the 32x32 texture
+           will appear in the corner of the 256x256 texture. If texture dumping is enabled and
+           the 32x32 is undumped, Citra will attempt to dump it. Since the underlying OpenGL
+           texture is still 256x256, Citra crashes because it thinks the texture is only 32x32.
            GetTexImageOES conveniently only dumps the specified region, and works on both
            desktop and ES.
         */
-        GetTexImageOES(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, height, width, 0,
-                       &decoded_texture[0], decoded_texture.size());
-        glBindTexture(GL_TEXTURE_2D, 0);
+        // if the backend isn't OpenGL ES, this won't be initialized yet
+        if (!owner.texture_downloader_es)
+            owner.texture_downloader_es = std::make_unique<TextureDownloaderES>(false);
+        owner.texture_downloader_es->GetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                                                 height, width, &decoded_texture[0]);
+        state.texture_units[0].texture_2d = old_texture;
+        state.Apply();
         Common::FlipRGBA8Texture(decoded_texture, width, height);
         if (!image_interface->EncodePNG(dump_path, decoded_texture, width, height))
             LOG_ERROR(Render_OpenGL, "Failed to save decoded texture");
@@ -905,14 +831,6 @@ void CachedSurface::DownloadGLTexture(const Common::Rectangle<u32>& rect, GLuint
         return;
     }
 
-    if (GLES) {
-        if (type == SurfaceType::Depth || type == SurfaceType::DepthStencil) {
-            // TODO(bunnei): This is unsupported on GLES right now, fixme
-            LOG_WARNING(Render_OpenGL, "Unsupported depth/stencil surface download");
-            return;
-        }
-    }
-
     MICROPROFILE_SCOPE(OpenGL_TextureDL);
 
     if (gl_buffer.empty()) {
@@ -950,9 +868,9 @@ void CachedSurface::DownloadGLTexture(const Common::Rectangle<u32>& rect, GLuint
 
         glActiveTexture(GL_TEXTURE0);
         if (GLES) {
-            GetTexImageOES(GL_TEXTURE_2D, 0, tuple.format, tuple.type, rect.GetHeight(),
-                           rect.GetWidth(), 0, &gl_buffer[buffer_offset],
-                           gl_buffer.size() - buffer_offset);
+            owner.texture_downloader_es->GetTexImage(GL_TEXTURE_2D, 0, tuple.format, tuple.type,
+                                                     rect.GetHeight(), rect.GetWidth(),
+                                                     &gl_buffer[buffer_offset]);
         } else {
             glGetTexImage(GL_TEXTURE_2D, 0, tuple.format, tuple.type, &gl_buffer[buffer_offset]);
         }
@@ -1106,6 +1024,8 @@ RasterizerCacheOpenGL::RasterizerCacheOpenGL() {
     texture_filterer = std::make_unique<TextureFilterer>(Settings::values.texture_filter_name,
                                                          resolution_scale_factor);
     format_reinterpreter = std::make_unique<FormatReinterpreterOpenGL>();
+    if (GLES)
+        texture_downloader_es = std::make_unique<TextureDownloaderES>(false);
 
     read_framebuffer.Create();
     draw_framebuffer.Create();
diff --git a/src/video_core/renderer_opengl/gl_rasterizer_cache.h b/src/video_core/renderer_opengl/gl_rasterizer_cache.h
index 6f7b7443a..da795a968 100644
--- a/src/video_core/renderer_opengl/gl_rasterizer_cache.h
+++ b/src/video_core/renderer_opengl/gl_rasterizer_cache.h
@@ -171,6 +171,8 @@ private:
     bool valid = false;
 };
 
+class RasterizerCacheOpenGL;
+
 struct CachedSurface : SurfaceParams, std::enable_shared_from_this<CachedSurface> {
     CachedSurface(RasterizerCacheOpenGL& owner) : owner{owner} {}
     ~CachedSurface();
@@ -267,6 +269,15 @@ struct CachedTextureCube {
     std::shared_ptr<SurfaceWatcher> nz;
 };
 
+static constexpr std::array<FormatTuple, 4> depth_format_tuples = {{
+    {GL_DEPTH_COMPONENT16, GL_DEPTH_COMPONENT, GL_UNSIGNED_SHORT}, // D16
+    {},
+    {GL_DEPTH_COMPONENT24, GL_DEPTH_COMPONENT, GL_UNSIGNED_INT},   // D24
+    {GL_DEPTH24_STENCIL8, GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8}, // D24S8
+}};
+
+class TextureDownloaderES;
+
 class RasterizerCacheOpenGL : NonCopyable {
 public:
     RasterizerCacheOpenGL();
@@ -373,6 +384,7 @@ public:
 
     std::unique_ptr<TextureFilterer> texture_filterer;
     std::unique_ptr<FormatReinterpreterOpenGL> format_reinterpreter;
+    std::unique_ptr<TextureDownloaderES> texture_downloader_es;
 };
 
 } // namespace OpenGL
diff --git a/src/video_core/renderer_opengl/gl_shader_util.h b/src/video_core/renderer_opengl/gl_shader_util.h
index 1871403f9..8b1beb5c0 100644
--- a/src/video_core/renderer_opengl/gl_shader_util.h
+++ b/src/video_core/renderer_opengl/gl_shader_util.h
@@ -12,10 +12,12 @@ namespace OpenGL {
 // High precision may or may not supported in GLES3. If it isn't, use medium precision instead.
 static constexpr char fragment_shader_precision_OES[] = R"(
 #ifdef GL_FRAGMENT_PRECISION_HIGH
-    precision highp float;
+precision highp int;
+precision highp float;
 precision highp samplerBuffer;
 #else
-    precision mediump float;
+precision mediump int;
+precision mediump float;
 precision mediump samplerBuffer;
 #endif // GL_FRAGMENT_PRECISION_HIGH
 )";
diff --git a/src/video_core/renderer_opengl/texture_downloader_es.cpp b/src/video_core/renderer_opengl/texture_downloader_es.cpp
new file mode 100644
index 000000000..11663512e
--- /dev/null
+++ b/src/video_core/renderer_opengl/texture_downloader_es.cpp
@@ -0,0 +1,254 @@
+// Copyright 2020 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include <chrono>
+#include <vector>
+
+#include <fmt/chrono.h>
+
+#include "common/logging/log.h"
+#include "video_core/renderer_opengl/gl_rasterizer_cache.h"
+#include "video_core/renderer_opengl/gl_state.h"
+#include "video_core/renderer_opengl/gl_vars.h"
+#include "video_core/renderer_opengl/texture_downloader_es.h"
+
+#include "shaders/depth_to_color.frag"
+#include "shaders/depth_to_color.vert"
+#include "shaders/ds_to_color.frag"
+
+namespace OpenGL {
+
+/**
+ * Self tests for the texture downloader
+ */
+void TextureDownloaderES::Test() {
+    auto cur_state = OpenGLState::GetCurState();
+    OpenGLState state;
+
+    {
+        GLint range[2];
+        GLint precision;
+#define PRECISION_TEST(type)                                                                       \
+    glGetShaderPrecisionFormat(GL_FRAGMENT_SHADER, type, range, &precision);                       \
+    LOG_INFO(Render_OpenGL, #type " range: [{}, {}], precision: {}", range[0], range[1], precision);
+        PRECISION_TEST(GL_LOW_INT);
+        PRECISION_TEST(GL_MEDIUM_INT);
+        PRECISION_TEST(GL_HIGH_INT);
+        PRECISION_TEST(GL_LOW_FLOAT);
+        PRECISION_TEST(GL_MEDIUM_FLOAT);
+        PRECISION_TEST(GL_HIGH_FLOAT);
+#undef PRECISION_TEST
+    }
+    glActiveTexture(GL_TEXTURE0);
+
+    const auto test = [this, &state](FormatTuple tuple, auto original_data, std::size_t tex_size,
+                                     auto data_generator) {
+        OGLTexture texture;
+        texture.Create();
+        state.texture_units[0].texture_2d = texture.handle;
+        state.Apply();
+
+        original_data.resize(tex_size * tex_size);
+        for (std::size_t idx = 0; idx < original_data.size(); ++idx)
+            original_data[idx] = data_generator(idx);
+        glTexStorage2D(GL_TEXTURE_2D, 1, tuple.internal_format, tex_size, tex_size);
+        glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, tex_size, tex_size, tuple.format, tuple.type,
+                        original_data.data());
+
+        decltype(original_data) new_data(original_data.size());
+        glFinish();
+        auto start = std::chrono::high_resolution_clock::now();
+        GetTexImage(GL_TEXTURE_2D, 0, tuple.format, tuple.type, tex_size, tex_size,
+                    new_data.data());
+        glFinish();
+        auto time = std::chrono::high_resolution_clock::now() - start;
+        LOG_INFO(Render_OpenGL, "test took {}", std::chrono::duration<double, std::milli>(time));
+
+        int diff = 0;
+        for (std::size_t idx = 0; idx < original_data.size(); ++idx)
+            if (new_data[idx] - original_data[idx] != diff) {
+                diff = new_data[idx] - original_data[idx];
+                // every time the error between the real and expected value changes, log it
+                // some error is expected in D24 due to floating point precision
+                LOG_WARNING(Render_OpenGL, "difference changed at {:#X}: {:#X} -> {:#X}", idx,
+                            original_data[idx], new_data[idx]);
+            }
+    };
+    LOG_INFO(Render_OpenGL, "GL_DEPTH24_STENCIL8 download test starting");
+    test(depth_format_tuples[3], std::vector<u32>{}, 4096,
+         [](std::size_t idx) { return static_cast<u32>((idx << 8) | (idx & 0xFF)); });
+    LOG_INFO(Render_OpenGL, "GL_DEPTH_COMPONENT24 download test starting");
+    test(depth_format_tuples[2], std::vector<u32>{}, 4096,
+         [](std::size_t idx) { return static_cast<u32>(idx << 8); });
+    LOG_INFO(Render_OpenGL, "GL_DEPTH_COMPONENT16 download test starting");
+    test(depth_format_tuples[0], std::vector<u16>{}, 256,
+         [](std::size_t idx) { return static_cast<u16>(idx); });
+
+    cur_state.Apply();
+}
+
+TextureDownloaderES::TextureDownloaderES(bool enable_depth_stencil) {
+    vao.Create();
+    read_fbo_generic.Create();
+
+    depth32_fbo.Create();
+    r32ui_renderbuffer.Create();
+    depth16_fbo.Create();
+    r16_renderbuffer.Create();
+
+    const auto init_program = [](ConversionShader& converter, std::string_view frag) {
+        converter.program.Create(depth_to_color_vert.data(), frag.data());
+        converter.lod_location = glGetUniformLocation(converter.program.handle, "lod");
+    };
+
+    // xperia64: The depth stencil shader currently uses a GLES extension that is not supported
+    // across all devices Reportedly broken on Tegra devices and the Nexus 6P, so enabling it can be
+    // toggled
+    if (enable_depth_stencil) {
+        init_program(d24s8_r32ui_conversion_shader, ds_to_color_frag);
+    }
+
+    init_program(d24_r32ui_conversion_shader, depth_to_color_frag);
+    init_program(d16_r16_conversion_shader, R"(
+out highp float color;
+
+uniform highp sampler2D depth;
+uniform int lod;
+
+void main(){
+    color = texelFetch(depth, ivec2(gl_FragCoord.xy), lod).x;
+}
+)");
+
+    sampler.Create();
+    glSamplerParameteri(sampler.handle, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glSamplerParameteri(sampler.handle, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+
+    auto cur_state = OpenGLState::GetCurState();
+    auto state = cur_state;
+
+    state.draw.shader_program = d24s8_r32ui_conversion_shader.program.handle;
+    state.draw.draw_framebuffer = depth32_fbo.handle;
+    state.renderbuffer = r32ui_renderbuffer.handle;
+    state.Apply();
+    glRenderbufferStorage(GL_RENDERBUFFER, GL_R32UI, max_size, max_size);
+    glFramebufferRenderbuffer(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER,
+                              r32ui_renderbuffer.handle);
+    glUniform1i(glGetUniformLocation(d24s8_r32ui_conversion_shader.program.handle, "depth"), 1);
+
+    state.draw.draw_framebuffer = depth16_fbo.handle;
+    state.renderbuffer = r16_renderbuffer.handle;
+    state.Apply();
+    glRenderbufferStorage(GL_RENDERBUFFER, GL_R16, max_size, max_size);
+    glFramebufferRenderbuffer(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER,
+                              r16_renderbuffer.handle);
+
+    cur_state.Apply();
+}
+
+/**
+ * OpenGL ES does not support glReadBuffer for depth/stencil formats
+ * This gets around it by converting to a Red surface before downloading
+ */
+GLuint TextureDownloaderES::ConvertDepthToColor(GLuint level, GLenum& format, GLenum& type,
+                                                GLint height, GLint width) {
+    ASSERT(width <= max_size && height <= max_size);
+    const OpenGLState cur_state = OpenGLState::GetCurState();
+    OpenGLState state;
+    state.texture_units[0] = {cur_state.texture_units[0].texture_2d, sampler.handle};
+    state.draw.vertex_array = vao.handle;
+
+    OGLTexture texture_view;
+    const ConversionShader* converter;
+    switch (type) {
+    case GL_UNSIGNED_SHORT:
+        state.draw.draw_framebuffer = depth16_fbo.handle;
+        converter = &d16_r16_conversion_shader;
+        format = GL_RED;
+        break;
+    case GL_UNSIGNED_INT:
+        state.draw.draw_framebuffer = depth32_fbo.handle;
+        converter = &d24_r32ui_conversion_shader;
+        format = GL_RED_INTEGER;
+        break;
+    case GL_UNSIGNED_INT_24_8:
+        state.draw.draw_framebuffer = depth32_fbo.handle;
+        converter = &d24s8_r32ui_conversion_shader;
+        format = GL_RED_INTEGER;
+        type = GL_UNSIGNED_INT;
+        break;
+    default:
+        UNREACHABLE_MSG("Destination type not recognized");
+    }
+    state.draw.shader_program = converter->program.handle;
+    state.viewport = {0, 0, width, height};
+    state.Apply();
+    if (converter->program.handle == d24s8_r32ui_conversion_shader.program.handle) {
+        // TODO BreadFish64: the ARM framebuffer reading extension is probably not the most optimal
+        // way to do this, search for another solution
+        glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D,
+                               state.texture_units[0].texture_2d, level);
+    }
+
+    glUniform1i(converter->lod_location, level);
+    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
+    if (texture_view.handle) {
+        glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_STENCIL_TEXTURE_MODE, GL_DEPTH_COMPONENT);
+    }
+    return state.draw.draw_framebuffer;
+}
+
+/**
+ * OpenGL ES does not support glGetTexImage. Obtain the pixels by attaching the
+ * texture to a framebuffer.
+ * Originally from https://github.com/apitrace/apitrace/blob/master/retrace/glstate_images.cpp
+ * Depth texture download assumes that the texture's format tuple matches what is found
+ * OpenGL::depth_format_tuples
+ */
+void TextureDownloaderES::GetTexImage(GLenum target, GLuint level, GLenum format, GLenum type,
+                                      GLint height, GLint width, void* pixels) {
+    OpenGLState state = OpenGLState::GetCurState();
+    GLuint texture;
+    const GLuint old_read_buffer = state.draw.read_framebuffer;
+    switch (target) {
+    case GL_TEXTURE_2D:
+        texture = state.texture_units[0].texture_2d;
+        break;
+    case GL_TEXTURE_CUBE_MAP_POSITIVE_X:
+    case GL_TEXTURE_CUBE_MAP_NEGATIVE_X:
+    case GL_TEXTURE_CUBE_MAP_POSITIVE_Y:
+    case GL_TEXTURE_CUBE_MAP_NEGATIVE_Y:
+    case GL_TEXTURE_CUBE_MAP_POSITIVE_Z:
+    case GL_TEXTURE_CUBE_MAP_NEGATIVE_Z:
+        texture = state.texture_cube_unit.texture_cube;
+        break;
+    default:
+        UNIMPLEMENTED_MSG("Unexpected target {:x}", target);
+    }
+
+    switch (format) {
+    case GL_DEPTH_COMPONENT:
+    case GL_DEPTH_STENCIL:
+        // unfortunately, the accurate way is too slow for release
+        return;
+        state.draw.read_framebuffer = ConvertDepthToColor(level, format, type, height, width);
+        state.Apply();
+        break;
+    default:
+        state.draw.read_framebuffer = read_fbo_generic.handle;
+        state.Apply();
+        glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture,
+                               level);
+    }
+    GLenum status = glCheckFramebufferStatus(GL_READ_FRAMEBUFFER);
+    if (status != GL_FRAMEBUFFER_COMPLETE) {
+        LOG_DEBUG(Render_OpenGL, "Framebuffer is incomplete, status: {:X}", status);
+    }
+    glReadPixels(0, 0, width, height, format, type, pixels);
+
+    state.draw.read_framebuffer = old_read_buffer;
+    state.Apply();
+}
+
+} // namespace OpenGL
diff --git a/src/video_core/renderer_opengl/texture_downloader_es.h b/src/video_core/renderer_opengl/texture_downloader_es.h
new file mode 100644
index 000000000..66c27dde1
--- /dev/null
+++ b/src/video_core/renderer_opengl/texture_downloader_es.h
@@ -0,0 +1,36 @@
+// Copyright 2020 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include "common/common_types.h"
+#include "video_core/renderer_opengl/gl_resource_manager.h"
+
+namespace OpenGL {
+class OpenGLState;
+
+class TextureDownloaderES {
+    static constexpr u16 max_size = 1024;
+
+    OGLVertexArray vao;
+    OGLFramebuffer read_fbo_generic;
+    OGLFramebuffer depth32_fbo, depth16_fbo;
+    OGLRenderbuffer r32ui_renderbuffer, r16_renderbuffer;
+    struct ConversionShader {
+        OGLProgram program;
+        GLint lod_location{-1};
+    } d24_r32ui_conversion_shader, d16_r16_conversion_shader, d24s8_r32ui_conversion_shader;
+    OGLSampler sampler;
+
+    void Test();
+    GLuint ConvertDepthToColor(GLuint level, GLenum& format, GLenum& type, GLint height,
+                               GLint width);
+
+public:
+    TextureDownloaderES(bool enable_depth_stencil);
+
+    void GetTexImage(GLenum target, GLuint level, GLenum format, const GLenum type, GLint height,
+                     GLint width, void* pixels);
+};
+} // namespace OpenGL