mirror of
https://github.com/ARMSX2/ARMSX1.git
synced 2026-08-24 16:53:35 -07:00
1236 lines
50 KiB
C++
1236 lines
50 KiB
C++
/*
|
|
ARMSX — RetroArch (.slangp) shader chains, via librashader. See render_shaders.h for
|
|
the contract; this file is the implementation and the reasoning.
|
|
|
|
================================================================================
|
|
HOW LIBRASHADER IS LINKED: IT IS NOT. IT IS dlopen()ED.
|
|
================================================================================
|
|
third_party/librashader/prebuilt/arm64-v8a/liblibrashader_capi.so is staged into
|
|
jniLibs next to libSDL2.so, and opened by SONAME the first time anything here needs
|
|
it. `libarmsx.so` has NO DT_NEEDED entry for it. That is deliberate:
|
|
|
|
* A hard DT_NEEDED on a 13 MB optional feature means a missing or corrupt
|
|
librashader makes LIBARMSX ITSELF fail to load — every JNI method vanishes and the
|
|
app dies at NativeApp.initialize with an error that points nowhere near shaders.
|
|
* The static archive is 79 MB and this Makefile puts -flto in BASE_CFLAGS, so
|
|
linking it would pull every Rust object into the LTO link.
|
|
* dlopen costs ~15 function pointers and gives the degrade path this feature is
|
|
required to have: no librashader -> log it once -> present normally.
|
|
|
|
================================================================================
|
|
WHERE THE CHAIN SITS RELATIVE TO INTERNAL-RESOLUTION UPSCALING
|
|
================================================================================
|
|
This is the decision that most often ships this feature broken, so it is written down
|
|
rather than left implicit.
|
|
|
|
The pipeline is: psx/dev/gpu.c rasterises (optionally through an internal-resolution
|
|
backend at scale S, [video] internal_scale 1..8), frontend/main.cpp hands the present
|
|
layer a frame of nativeW*S x nativeH*S, and render_vk.cpp blits that into an
|
|
aspect-corrected letterbox rect computed by armsx_render_compute_dst().
|
|
|
|
Two facts decide where the chain goes:
|
|
|
|
1. A CRT shader's scanline count comes from SourceSize.y. Feed it a 2x frame and it
|
|
draws 480 scanlines instead of 240; feed it 4x and it draws 960. Those land on a
|
|
fractional number of screen pixels and smear into moire. This is not a matter of
|
|
taste — a CRT chain is WRONG on an already-upscaled image, which is why RetroArch's
|
|
own answer is "use 1x with CRT shaders".
|
|
|
|
2. A CRT shader's mask/scanline geometry must be generated at DISPLAY pixel density,
|
|
not at source density, or the presenter's own scale-up smears it.
|
|
|
|
So: **the chain reads the frame at NATIVE PlayStation resolution and writes at the
|
|
aspect-corrected DISPLAY size.**
|
|
|
|
frame_image (nativeW*S x nativeH*S)
|
|
--(linear downsample by S, only when S > 1)--> chain input (nativeW x nativeH)
|
|
--(librashader, viewport = the letterbox rect)--> chain output (dst_w x dst_h)
|
|
--(1:1 blit)--> swapchain at (dst_x, dst_y)
|
|
|
|
What this means for each renderer setting:
|
|
|
|
* [video] renderer = "software" (S == 1): the downsample is the identity and is
|
|
skipped entirely. The chain reads frame_image directly. Zero added cost.
|
|
* [video] renderer = "hardware" at 2x/3x: the frame is downsampled to native first.
|
|
This is NOT throwing the upscale away — a 3x render box-filtered to 1x is
|
|
SUPERSAMPLING, so the CRT shader gets a cleaner, anti-aliased native image than the
|
|
software rasteriser could ever hand it. It is, however, a poor CPU trade on the CPU
|
|
rasteriser, so the log says so explicitly and names internal_scale = 1 as the
|
|
cheaper way to the same picture.
|
|
|
|
Deliberately NOT offered: a "run the chain on the upscaled frame" mode. It is only
|
|
right for sharpen/FSR-style chains, it is the setting people mis-set and then report a
|
|
CRT preset as broken, and a `[video]` key with no Kotlin field behind it is silently
|
|
dropped by Ps1SettingsStore the first time the user saves anything (three settings have
|
|
already been lost that way in this port). If it is ever wanted it needs all four
|
|
places, not just this file.
|
|
|
|
================================================================================
|
|
WHY THE CHAIN OWNS ITS OWN OUTPUT IMAGE
|
|
================================================================================
|
|
librashader needs a SAMPLED input and a COLOR_ATTACHMENT output. frame_image gained
|
|
VK_IMAGE_USAGE_SAMPLED_BIT (universally supported for R8G8B8A8_UNORM optimal tiling)
|
|
so the common S == 1 path samples it in place with no copy. The swapchain, though, is
|
|
created TRANSFER_DST only, and whether COLOR_ATTACHMENT is even in
|
|
VkSurfaceCapabilitiesKHR::supportedUsageFlags is driver-dependent — so the chain
|
|
renders into an image it owns, sized exactly to the letterbox rect, and that gets
|
|
blitted 1:1 into the swapchain. One extra full-rect copy per frame (~1% of memory
|
|
bandwidth at 1080p on the test device), in exchange for one code path that cannot be
|
|
defeated by a surface's usage flags. This is also what ARMSX2 does.
|
|
*/
|
|
|
|
/* A shader chain needs a Vulkan present path to hook, so ARMSX_ENABLE_SHADERS without
|
|
ARMSX_ENABLE_VULKAN is a contradiction. The Makefile already refuses to set it that way;
|
|
enforcing it here too means this file still compiles if someone defines it by hand, and
|
|
keeps the guard in exactly one place for every declaration below. */
|
|
#if defined(ARMSX_ENABLE_SHADERS) && !defined(ARMSX_ENABLE_VULKAN)
|
|
#undef ARMSX_ENABLE_SHADERS
|
|
#endif
|
|
|
|
#include "render_shaders.h"
|
|
|
|
#include "render_internal.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdarg>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <chrono>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <thread>
|
|
#include <atomic>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
/* The Vulkan presenter serializes queue recording with the shader seam even when
|
|
librashader is compiled out. Keep the guard available to the always-built
|
|
armsx_shader_vk_queue_lock/unlock entry points below; defining it only inside
|
|
ARMSX_ENABLE_SHADERS leaves Vulkan/no-shader builds with an undefined symbol. */
|
|
namespace {
|
|
std::mutex g_vk_queue_lock;
|
|
}
|
|
|
|
#if defined(ARMSX_ENABLE_SHADERS)
|
|
|
|
#include <dlfcn.h>
|
|
|
|
#include "../third_party/librashader/include/librashader_min.h"
|
|
|
|
namespace {
|
|
|
|
/* ---- librashader entry points ----------------------------------------------------- */
|
|
|
|
struct LibraApi {
|
|
void* handle = nullptr;
|
|
bool usable = false;
|
|
|
|
PFN_libra_preset_create preset_create = nullptr;
|
|
PFN_libra_preset_free preset_free = nullptr;
|
|
PFN_libra_preset_get_runtime_params preset_get_runtime_params = nullptr;
|
|
PFN_libra_preset_free_runtime_params preset_free_runtime_params = nullptr;
|
|
|
|
PFN_libra_vk_filter_chain_create chain_create = nullptr;
|
|
PFN_libra_vk_filter_chain_frame chain_frame = nullptr;
|
|
PFN_libra_vk_filter_chain_set_param chain_set_param = nullptr;
|
|
PFN_libra_vk_filter_chain_free chain_free = nullptr;
|
|
|
|
PFN_libra_error_errno error_errno = nullptr;
|
|
PFN_libra_error_write error_write = nullptr;
|
|
PFN_libra_error_free_string error_free_string = nullptr;
|
|
PFN_libra_error_free error_free = nullptr;
|
|
|
|
PFN_libra_instance_abi_version abi_version = nullptr;
|
|
PFN_libra_instance_api_version api_version = nullptr;
|
|
};
|
|
|
|
LibraApi g_libra;
|
|
std::mutex g_libra_lock;
|
|
bool g_libra_tried = false;
|
|
|
|
template <typename Fn>
|
|
bool Bind(void* handle, const char* name, Fn* out) {
|
|
void* symbol = dlsym(handle, name);
|
|
*out = reinterpret_cast<Fn>(symbol);
|
|
if (!symbol) {
|
|
armsx_render_log("shaders", "librashader: entry point '%s' is missing.", name);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* Opened by SONAME. On Android the APK's jniLibs directory is on the app's linker
|
|
namespace search path, exactly as it is for libSDL2.so, so the bare name resolves; the
|
|
absolute-path candidates are for desktop/dev runs where it sits beside the binary. */
|
|
const LibraApi* EnsureLibra() {
|
|
std::lock_guard<std::mutex> lock(g_libra_lock);
|
|
if (g_libra_tried) {
|
|
return g_libra.usable ? &g_libra : nullptr;
|
|
}
|
|
g_libra_tried = true;
|
|
|
|
static const char* const kCandidates[] = {
|
|
"liblibrashader_capi.so",
|
|
"./liblibrashader_capi.so",
|
|
"liblibrashader_capi.dylib",
|
|
};
|
|
for (const char* name : kCandidates) {
|
|
g_libra.handle = dlopen(name, RTLD_NOW | RTLD_LOCAL);
|
|
if (g_libra.handle) {
|
|
break;
|
|
}
|
|
}
|
|
if (!g_libra.handle) {
|
|
/* Not an error in itself — a build without the .so staged simply has no shader
|
|
chains. It must still be visible, because "the toggle does nothing" is the
|
|
defect shape this port keeps producing. */
|
|
armsx_render_log("shaders",
|
|
"librashader is not available (dlopen failed: %s). Shader chains are "
|
|
"disabled; presentation is unaffected.",
|
|
dlerror() ? dlerror() : "unknown");
|
|
return nullptr;
|
|
}
|
|
|
|
const bool bound =
|
|
Bind(g_libra.handle, "libra_instance_abi_version", &g_libra.abi_version) &&
|
|
Bind(g_libra.handle, "libra_instance_api_version", &g_libra.api_version) &&
|
|
Bind(g_libra.handle, "libra_preset_create", &g_libra.preset_create) &&
|
|
Bind(g_libra.handle, "libra_preset_free", &g_libra.preset_free) &&
|
|
Bind(g_libra.handle, "libra_preset_get_runtime_params", &g_libra.preset_get_runtime_params) &&
|
|
Bind(g_libra.handle, "libra_preset_free_runtime_params", &g_libra.preset_free_runtime_params) &&
|
|
Bind(g_libra.handle, "libra_vk_filter_chain_create", &g_libra.chain_create) &&
|
|
Bind(g_libra.handle, "libra_vk_filter_chain_frame", &g_libra.chain_frame) &&
|
|
Bind(g_libra.handle, "libra_vk_filter_chain_set_param", &g_libra.chain_set_param) &&
|
|
Bind(g_libra.handle, "libra_vk_filter_chain_free", &g_libra.chain_free) &&
|
|
Bind(g_libra.handle, "libra_error_errno", &g_libra.error_errno) &&
|
|
Bind(g_libra.handle, "libra_error_write", &g_libra.error_write) &&
|
|
Bind(g_libra.handle, "libra_error_free_string", &g_libra.error_free_string) &&
|
|
Bind(g_libra.handle, "libra_error_free", &g_libra.error_free);
|
|
|
|
if (!bound) {
|
|
dlclose(g_libra.handle);
|
|
g_libra.handle = nullptr;
|
|
return nullptr;
|
|
}
|
|
|
|
/* ABI is the STRUCT LAYOUT version and is not backwards compatible. Reading a
|
|
differently-shaped libra_preset_param_t is silent memory corruption, not a clean
|
|
failure, so refuse rather than hope. */
|
|
const size_t abi = g_libra.abi_version();
|
|
if (abi != LIBRASHADER_CURRENT_ABI) {
|
|
armsx_render_log("shaders",
|
|
"librashader ABI %zu does not match the %zu this build was written "
|
|
"against; refusing to use it.",
|
|
abi, LIBRASHADER_CURRENT_ABI);
|
|
dlclose(g_libra.handle);
|
|
g_libra.handle = nullptr;
|
|
return nullptr;
|
|
}
|
|
|
|
g_libra.usable = true;
|
|
armsx_render_log("shaders", "librashader loaded (ABI %zu, API %zu).", abi,
|
|
g_libra.api_version());
|
|
return &g_libra;
|
|
}
|
|
|
|
/* Consumes and frees `error`, returning its message. Empty when there was no error. */
|
|
std::string TakeError(const LibraApi* api, libra_error_t error) {
|
|
if (!error) {
|
|
return std::string();
|
|
}
|
|
std::string text;
|
|
char* message = nullptr;
|
|
if (api->error_write(error, &message) == 0 && message) {
|
|
text.assign(message);
|
|
api->error_free_string(&message);
|
|
} else {
|
|
char buffer[64];
|
|
std::snprintf(buffer, sizeof(buffer), "librashader error %d", (int)api->error_errno(error));
|
|
text.assign(buffer);
|
|
}
|
|
api->error_free(&error);
|
|
return text;
|
|
}
|
|
|
|
/* ---- Vulkan entry points we need ourselves ----------------------------------------- */
|
|
|
|
/* Only what the input/output plumbing uses. Everything is resolved from the caller's
|
|
vkGetInstanceProcAddr, so libvulkan never becomes a link-time dependency and an
|
|
adrenotools custom driver stays substitutable. */
|
|
struct VkFns {
|
|
PFN_vkGetDeviceProcAddr GetDeviceProcAddr = nullptr;
|
|
PFN_vkGetPhysicalDeviceMemoryProperties GetPhysicalDeviceMemoryProperties = nullptr;
|
|
PFN_vkCreateImage CreateImage = nullptr;
|
|
PFN_vkDestroyImage DestroyImage = nullptr;
|
|
PFN_vkGetImageMemoryRequirements GetImageMemoryRequirements = nullptr;
|
|
PFN_vkBindImageMemory BindImageMemory = nullptr;
|
|
PFN_vkAllocateMemory AllocateMemory = nullptr;
|
|
PFN_vkFreeMemory FreeMemory = nullptr;
|
|
PFN_vkCmdPipelineBarrier CmdPipelineBarrier = nullptr;
|
|
PFN_vkCmdBlitImage CmdBlitImage = nullptr;
|
|
PFN_vkDeviceWaitIdle DeviceWaitIdle = nullptr;
|
|
};
|
|
|
|
/* An image this layer owns, plus its memory. */
|
|
struct OwnedImage {
|
|
VkImage image = VK_NULL_HANDLE;
|
|
VkDeviceMemory memory = VK_NULL_HANDLE;
|
|
int width = 0;
|
|
int height = 0;
|
|
bool initialized = false; /* false => its layout is still UNDEFINED */
|
|
};
|
|
|
|
/* ---- state -------------------------------------------------------------------------- */
|
|
|
|
/* Everything the UI writes. Read by the presenting thread once per frame. */
|
|
struct Request {
|
|
std::mutex lock;
|
|
bool enabled = false;
|
|
std::string preset;
|
|
|
|
/* Parameter assignments waiting for the presenting thread, and the preset they were
|
|
read off. A queue keyed by preset is what stops a stale set from a screen the user
|
|
has left landing on a chain that has moved on. */
|
|
std::string param_preset;
|
|
std::map<std::string, float> params;
|
|
bool params_dirty = false;
|
|
};
|
|
|
|
Request g_request;
|
|
|
|
int g_source_scale = 1; /* [video] internal_scale, 1 when the rasterizer is off */
|
|
int g_backend = -1; /* armsx_render_backend_t of the live present backend */
|
|
bool g_backend_warned = false;
|
|
|
|
/*
|
|
The single VkQueue guard. Held by the present path around its submits/presents and by the
|
|
builder thread across chain_create(), which uploads librashader's lookup textures through
|
|
that same queue. A VkDevice is safe to use from several threads; a VkQueue is not.
|
|
*/
|
|
/*
|
|
Off-thread chain build.
|
|
|
|
A CRT preset takes ~110 ms to compile. On the presenting thread that is a visible hitch every
|
|
time a preset is selected, and on a driver that compiles synchronously it can be far worse —
|
|
ARMSX2 has a documented Mali case where a synchronous shader compile stalls the frame outright.
|
|
So the compile happens here, and the presenting thread adopts the finished chain on whatever
|
|
frame it is ready.
|
|
|
|
Ownership is deliberately one-way: the worker writes `result` and then sets `done`; the
|
|
presenting thread reads `done` and only then touches `result`. Nothing else is shared, so
|
|
there is no lock on this struct — the release/acquire pair on `done` is the handoff.
|
|
*/
|
|
struct Builder {
|
|
std::thread thread;
|
|
std::atomic<bool> done{false};
|
|
bool busy = false; /* presenting-thread only */
|
|
std::string path; /* what the worker is building */
|
|
libra_vk_filter_chain_t result = nullptr;
|
|
bool failed = false;
|
|
double compile_ms = 0.0;
|
|
std::string error;
|
|
};
|
|
|
|
Builder g_builder;
|
|
|
|
/* Presenting-thread-owned. No lock: only armsx_shader_vk_render/attach/detach touch it. */
|
|
struct ChainState {
|
|
bool attached = false;
|
|
armsx_shader_vk_device_t dev{};
|
|
VkFns fns{};
|
|
VkPhysicalDeviceMemoryProperties memory{};
|
|
|
|
libra_vk_filter_chain_t chain = nullptr;
|
|
std::string active; /* the preset the live chain was built from */
|
|
std::string desired; /* what the UI last asked for; "" == none */
|
|
bool failed = false; /* `desired` failed to build; do not retry it */
|
|
size_t frame_count = 0;
|
|
|
|
OwnedImage input; /* only allocated when the frame has to be downsampled */
|
|
OwnedImage output; /* always: the chain's COLOR_ATTACHMENT render target */
|
|
int logged_input_w = 0;
|
|
int logged_input_h = 0;
|
|
};
|
|
|
|
ChainState g_state;
|
|
|
|
/* ---- helpers ------------------------------------------------------------------------ */
|
|
|
|
void WarnBackendOnce() {
|
|
if (g_backend_warned || g_backend < 0) {
|
|
return;
|
|
}
|
|
bool wants = false;
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_request.lock);
|
|
wants = g_request.enabled && !g_request.preset.empty();
|
|
}
|
|
if (!wants || g_backend == ARMSX_RENDER_BACKEND_VULKAN) {
|
|
return;
|
|
}
|
|
g_backend_warned = true;
|
|
armsx_render_log("shaders",
|
|
"A shader chain is enabled, but the active present backend is %s. "
|
|
"librashader has no GLES runtime, so chains need the Vulkan backend "
|
|
"([video] gpu_backend = \"vulkan\"). Presenting normally.",
|
|
armsx_render_backend_name((armsx_render_backend_t)g_backend));
|
|
}
|
|
|
|
uint32_t FindMemoryType(uint32_t type_bits, VkMemoryPropertyFlags wanted) {
|
|
for (uint32_t index = 0; index < g_state.memory.memoryTypeCount; ++index) {
|
|
if ((type_bits & (1u << index)) == 0) {
|
|
continue;
|
|
}
|
|
if ((g_state.memory.memoryTypes[index].propertyFlags & wanted) == wanted) {
|
|
return index;
|
|
}
|
|
}
|
|
return UINT32_MAX;
|
|
}
|
|
|
|
void DestroyImage(OwnedImage* img) {
|
|
if (img->image != VK_NULL_HANDLE) {
|
|
g_state.fns.DestroyImage(g_state.dev.device, img->image, nullptr);
|
|
}
|
|
if (img->memory != VK_NULL_HANDLE) {
|
|
g_state.fns.FreeMemory(g_state.dev.device, img->memory, nullptr);
|
|
}
|
|
*img = OwnedImage{};
|
|
}
|
|
|
|
/* R8G8B8A8_UNORM only. Vulkan's mandatory format table guarantees optimal-tiling
|
|
SAMPLED_IMAGE / COLOR_ATTACHMENT / BLIT_SRC / BLIT_DST for it, so no capability probe
|
|
is needed and no device can refuse this. */
|
|
bool EnsureImage(OwnedImage* img, int width, int height, VkImageUsageFlags usage) {
|
|
if (img->image != VK_NULL_HANDLE && img->width == width && img->height == height) {
|
|
return true;
|
|
}
|
|
DestroyImage(img);
|
|
if (width <= 0 || height <= 0) {
|
|
return false;
|
|
}
|
|
|
|
VkImageCreateInfo info{};
|
|
info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
|
|
info.imageType = VK_IMAGE_TYPE_2D;
|
|
info.format = VK_FORMAT_R8G8B8A8_UNORM;
|
|
info.extent = {(uint32_t)width, (uint32_t)height, 1};
|
|
info.mipLevels = 1;
|
|
info.arrayLayers = 1;
|
|
info.samples = VK_SAMPLE_COUNT_1_BIT;
|
|
info.tiling = VK_IMAGE_TILING_OPTIMAL;
|
|
info.usage = usage;
|
|
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
|
|
info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
|
|
|
|
if (g_state.fns.CreateImage(g_state.dev.device, &info, nullptr, &img->image) != VK_SUCCESS) {
|
|
armsx_render_log("shaders", "vkCreateImage failed for a %dx%d chain image.", width, height);
|
|
*img = OwnedImage{};
|
|
return false;
|
|
}
|
|
|
|
VkMemoryRequirements requirements{};
|
|
g_state.fns.GetImageMemoryRequirements(g_state.dev.device, img->image, &requirements);
|
|
const uint32_t type_index =
|
|
FindMemoryType(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
|
if (type_index == UINT32_MAX) {
|
|
armsx_render_log("shaders", "no device-local memory type for a %dx%d chain image.", width,
|
|
height);
|
|
DestroyImage(img);
|
|
return false;
|
|
}
|
|
|
|
VkMemoryAllocateInfo alloc{};
|
|
alloc.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
|
|
alloc.allocationSize = requirements.size;
|
|
alloc.memoryTypeIndex = type_index;
|
|
if (g_state.fns.AllocateMemory(g_state.dev.device, &alloc, nullptr, &img->memory) != VK_SUCCESS ||
|
|
g_state.fns.BindImageMemory(g_state.dev.device, img->image, img->memory, 0) != VK_SUCCESS) {
|
|
armsx_render_log("shaders", "could not back a %dx%d chain image with memory.", width, height);
|
|
DestroyImage(img);
|
|
return false;
|
|
}
|
|
|
|
img->width = width;
|
|
img->height = height;
|
|
img->initialized = false;
|
|
return true;
|
|
}
|
|
|
|
void Barrier(VkCommandBuffer cmd, VkImage image, VkImageLayout old_layout, VkImageLayout new_layout,
|
|
VkAccessFlags src_access, VkAccessFlags dst_access, VkPipelineStageFlags src_stage,
|
|
VkPipelineStageFlags dst_stage) {
|
|
VkImageMemoryBarrier barrier{};
|
|
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
|
|
barrier.oldLayout = old_layout;
|
|
barrier.newLayout = new_layout;
|
|
barrier.srcAccessMask = src_access;
|
|
barrier.dstAccessMask = dst_access;
|
|
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
|
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
|
barrier.image = image;
|
|
barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
|
g_state.fns.CmdPipelineBarrier(cmd, src_stage, dst_stage, 0, 0, nullptr, 0, nullptr, 1, &barrier);
|
|
}
|
|
|
|
void BlitFull(VkCommandBuffer cmd, VkImage src, int src_w, int src_h, VkImage dst, int dst_x,
|
|
int dst_y, int dst_w, int dst_h, VkFilter filter) {
|
|
VkImageBlit blit{};
|
|
blit.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
|
blit.srcOffsets[0] = {0, 0, 0};
|
|
blit.srcOffsets[1] = {src_w, src_h, 1};
|
|
blit.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
|
blit.dstOffsets[0] = {dst_x, dst_y, 0};
|
|
blit.dstOffsets[1] = {dst_x + dst_w, dst_y + dst_h, 1};
|
|
g_state.fns.CmdBlitImage(cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, dst,
|
|
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, filter);
|
|
}
|
|
|
|
void DestroyChain() {
|
|
if (!g_state.chain) {
|
|
return;
|
|
}
|
|
const LibraApi* api = EnsureLibra();
|
|
if (g_state.fns.DeviceWaitIdle && g_state.dev.device) {
|
|
g_state.fns.DeviceWaitIdle(g_state.dev.device);
|
|
}
|
|
if (api) {
|
|
api->chain_free(&g_state.chain);
|
|
}
|
|
g_state.chain = nullptr;
|
|
g_state.active.clear();
|
|
g_state.frame_count = 0;
|
|
}
|
|
|
|
/* The divisor that turns the uploaded frame back into a native-resolution one.
|
|
|
|
The hint comes from [video] internal_scale via armsx_shader_set_source_scale(), which
|
|
is the CONFIGURED scale — the rasterizer can have failed to install and left the frame
|
|
at 1x, and config.c cannot know that. So the hint is walked DOWN until it divides the
|
|
frame exactly and leaves a plausible PlayStation display size behind. Being wrong here
|
|
is cosmetic (a chunkier scanline pitch) rather than corrupting, and the chosen divisor
|
|
is logged. */
|
|
int SourceDivisor(int width, int height) {
|
|
int divisor = g_source_scale;
|
|
if (divisor < 1) {
|
|
divisor = 1;
|
|
}
|
|
while (divisor > 1) {
|
|
const bool exact = (width % divisor) == 0 && (height % divisor) == 0;
|
|
if (exact && (width / divisor) >= 128 && (height / divisor) >= 100) {
|
|
break;
|
|
}
|
|
--divisor;
|
|
}
|
|
return divisor;
|
|
}
|
|
|
|
void ApplyQueuedParams(const LibraApi* api) {
|
|
std::map<std::string, float> values;
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_request.lock);
|
|
if (!g_request.params_dirty || g_request.param_preset != g_state.active) {
|
|
return;
|
|
}
|
|
values = g_request.params;
|
|
g_request.params_dirty = false;
|
|
}
|
|
int rejected = 0;
|
|
for (const auto& entry : values) {
|
|
libra_error_t error = api->chain_set_param(&g_state.chain, entry.first.c_str(), entry.second);
|
|
if (error) {
|
|
/* A name the chain does not know is the normal case when a preset is switched
|
|
while an editor is open; count them rather than spamming a line each. */
|
|
++rejected;
|
|
TakeError(api, error);
|
|
}
|
|
}
|
|
if (rejected) {
|
|
armsx_render_log("shaders", "%d of %zu queued parameters were not known to the chain.",
|
|
rejected, values.size());
|
|
}
|
|
}
|
|
|
|
/*
|
|
Builds a chain. Runs on the BUILDER THREAD — it must not touch g_state beyond the immutable
|
|
device handles copied in at attach, and must take the queue guard around chain_create()
|
|
because librashader uploads its lookup textures through that queue.
|
|
*/
|
|
void BuildChainWorker(const LibraApi* api, std::string path) {
|
|
const auto started = std::chrono::steady_clock::now();
|
|
|
|
libra_shader_preset_t preset = nullptr;
|
|
if (libra_error_t error = api->preset_create(path.c_str(), &preset)) {
|
|
armsx_render_log("shaders", "preset '%s' could not be read: %s. Presenting normally.",
|
|
path.c_str(), TakeError(api, error).c_str());
|
|
g_builder.failed = true;
|
|
g_builder.done.store(true, std::memory_order_release);
|
|
return;
|
|
}
|
|
|
|
filter_chain_vk_opt_t options{};
|
|
options.version = LIBRASHADER_CURRENT_VERSION;
|
|
/* Our present path has exactly one command buffer and waits its fence before
|
|
recording, so one frame is ever in flight. librashader's default of 3 keeps its
|
|
per-frame residual ring comfortably ahead of us, which is the safe direction: an
|
|
undersized ring frees intermediate image views while the GPU still reads them. */
|
|
options.frames_in_flight = 3;
|
|
options.force_no_mipmaps = false;
|
|
/* MUST stay false. render_vk.cpp creates its VkDevice with VK_KHR_swapchain and
|
|
nothing else, so the dynamic-rendering path does not exist here. */
|
|
options.use_dynamic_rendering = false;
|
|
/* librashader's compile cache resolves its directory through AppDirs ($XDG_CACHE_HOME
|
|
/ $HOME/.cache), neither of which is meaningful for an Android app — it would
|
|
either fail or scribble somewhere outside the app's sandbox. Compiling is a
|
|
one-off per preset load, so take the deterministic option. */
|
|
options.disable_cache = true;
|
|
|
|
libra_device_vk_t device{};
|
|
device.physical_device = g_state.dev.physical_device;
|
|
device.instance = g_state.dev.instance;
|
|
device.device = g_state.dev.device;
|
|
device.queue = g_state.dev.queue;
|
|
device.entry = g_state.dev.get_instance_proc_addr;
|
|
|
|
/* CONSUMES `preset`, on success and on failure alike (the Rust side takes the handle
|
|
out before doing anything). Never free it after this call. */
|
|
libra_vk_filter_chain_t built = nullptr;
|
|
libra_error_t error;
|
|
{
|
|
/* chain_create() uploads LUTs through `device.queue`. Held for the whole call rather
|
|
than guessing which part submits. */
|
|
std::lock_guard<std::mutex> lock(g_vk_queue_lock);
|
|
error = api->chain_create(&preset, device, &options, &built);
|
|
}
|
|
|
|
if (error || !built) {
|
|
armsx_render_log("shaders", "preset '%s' failed to build: %s. Presenting normally.",
|
|
path.c_str(), TakeError(api, error).c_str());
|
|
g_builder.result = nullptr;
|
|
g_builder.failed = true;
|
|
g_builder.done.store(true, std::memory_order_release);
|
|
return;
|
|
}
|
|
|
|
g_builder.result = built;
|
|
g_builder.failed = false;
|
|
/* RELEASE: everything written above must be visible to the presenting thread before it can
|
|
observe done == true. */
|
|
g_builder.compile_ms = std::chrono::duration<double, std::milli>(
|
|
std::chrono::steady_clock::now() - started)
|
|
.count();
|
|
|
|
/* RELEASE, and the LAST thing the worker does. Everything the presenting thread will read
|
|
is written above it; nothing below it exists. Touching g_state here — as this code did
|
|
when it ran on the presenting thread — would be a plain data race, because the presenting
|
|
thread owns g_state and is using it every frame. */
|
|
g_builder.done.store(true, std::memory_order_release);
|
|
}
|
|
|
|
/*
|
|
Presenting thread. Takes ownership of whatever the worker produced and does all the g_state
|
|
work the old synchronous build used to do inline.
|
|
*/
|
|
void AdoptBuiltChain(const LibraApi* api) {
|
|
if (!g_builder.busy || !g_builder.done.load(std::memory_order_acquire)) {
|
|
return;
|
|
}
|
|
|
|
if (g_builder.thread.joinable()) {
|
|
g_builder.thread.join();
|
|
}
|
|
|
|
g_builder.busy = false;
|
|
g_builder.done.store(false, std::memory_order_relaxed);
|
|
|
|
const std::string path = g_builder.path;
|
|
|
|
/* The user may have changed preset while this was compiling. Throw the result away rather
|
|
than installing a chain nobody asked for any more. */
|
|
if (path != g_state.desired) {
|
|
if (g_builder.result) {
|
|
api->chain_free(&g_builder.result);
|
|
}
|
|
g_builder.result = nullptr;
|
|
armsx_render_log("shaders", "discarded '%s': the preset changed while it compiled.",
|
|
path.c_str());
|
|
return;
|
|
}
|
|
|
|
if (g_builder.failed || !g_builder.result) {
|
|
g_state.failed = true;
|
|
g_builder.result = nullptr;
|
|
return;
|
|
}
|
|
|
|
g_state.chain = g_builder.result;
|
|
g_builder.result = nullptr;
|
|
|
|
g_state.active = path;
|
|
g_state.frame_count = 0;
|
|
g_state.logged_input_w = 0;
|
|
g_state.logged_input_h = 0;
|
|
armsx_render_log("shaders", "chain loaded: %s (compiled in %.0f ms off-thread)",
|
|
path.c_str(), g_builder.compile_ms);
|
|
|
|
/* A fresh chain sits at the preset's own initial values, so whatever the UI queued
|
|
before it existed still has to land. */
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_request.lock);
|
|
if (g_request.param_preset == path && !g_request.params.empty()) {
|
|
g_request.params_dirty = true;
|
|
}
|
|
}
|
|
ApplyQueuedParams(api);
|
|
}
|
|
|
|
/* Presenting thread. Kicks a build if one is wanted and none is running. */
|
|
void StartChainBuild(const LibraApi* api) {
|
|
if (g_builder.busy || g_state.desired.empty()) {
|
|
return;
|
|
}
|
|
|
|
g_builder.busy = true;
|
|
g_builder.failed = false;
|
|
g_builder.result = nullptr;
|
|
g_builder.compile_ms = 0.0;
|
|
g_builder.path = g_state.desired;
|
|
g_builder.done.store(false, std::memory_order_relaxed);
|
|
|
|
g_builder.thread = std::thread(BuildChainWorker, api, g_builder.path);
|
|
}
|
|
|
|
/* Presenting thread. Blocks until any in-flight build finishes and frees its result. Used by
|
|
detach and by a preset change: a worker still holding the queue guard must not outlive the
|
|
device it was handed. */
|
|
void CancelChainBuild(const LibraApi* api) {
|
|
if (!g_builder.busy) {
|
|
return;
|
|
}
|
|
|
|
if (g_builder.thread.joinable()) {
|
|
g_builder.thread.join();
|
|
}
|
|
|
|
if (g_builder.result && api) {
|
|
api->chain_free(&g_builder.result);
|
|
}
|
|
|
|
g_builder.result = nullptr;
|
|
g_builder.busy = false;
|
|
g_builder.failed = false;
|
|
g_builder.done.store(false, std::memory_order_relaxed);
|
|
}
|
|
|
|
/* Reconciles the live chain with what the UI asked for. Runs BEFORE anything is recorded
|
|
into the caller's command buffer: building a chain submits LUT uploads and waits on the
|
|
queue, and tearing one down destroys images that recorded commands could still name. */
|
|
void Reconcile(const LibraApi* api) {
|
|
std::string desired;
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_request.lock);
|
|
desired = g_request.enabled ? g_request.preset : std::string();
|
|
}
|
|
|
|
if (desired != g_state.desired) {
|
|
/* Cancel BEFORE moving `desired`: AdoptBuiltChain compares against g_state.desired to
|
|
decide whether a finished build is still wanted, and a worker mid-compile against the
|
|
old preset would otherwise be adopted or leaked. */
|
|
CancelChainBuild(api);
|
|
g_state.desired = desired;
|
|
g_state.failed = false;
|
|
if (g_state.chain) {
|
|
DestroyChain();
|
|
}
|
|
}
|
|
|
|
if (g_state.desired.empty()) {
|
|
if (g_state.input.image != VK_NULL_HANDLE || g_state.output.image != VK_NULL_HANDLE) {
|
|
if (g_state.fns.DeviceWaitIdle) {
|
|
g_state.fns.DeviceWaitIdle(g_state.dev.device);
|
|
}
|
|
DestroyImage(&g_state.input);
|
|
DestroyImage(&g_state.output);
|
|
}
|
|
return;
|
|
}
|
|
|
|
/* Adopt first, then kick: a build that finished during the last frame becomes the live
|
|
chain on this one, and only if nothing is pending do we start another. */
|
|
AdoptBuiltChain(api);
|
|
|
|
if (!g_state.chain && !g_state.failed) {
|
|
StartChainBuild(api);
|
|
}
|
|
}
|
|
|
|
/* ---- JSON ---------------------------------------------------------------------------- */
|
|
|
|
void AppendJsonString(std::string* out, const char* text) {
|
|
out->push_back('"');
|
|
for (const char* p = text ? text : ""; *p; ++p) {
|
|
const unsigned char c = (unsigned char)*p;
|
|
switch (c) {
|
|
case '"': out->append("\\\""); break;
|
|
case '\\': out->append("\\\\"); break;
|
|
case '\b': out->append("\\b"); break;
|
|
case '\f': out->append("\\f"); break;
|
|
case '\n': out->append("\\n"); break;
|
|
case '\r': out->append("\\r"); break;
|
|
case '\t': out->append("\\t"); break;
|
|
default:
|
|
if (c < 0x20) {
|
|
char escape[8];
|
|
std::snprintf(escape, sizeof(escape), "\\u%04x", c);
|
|
out->append(escape);
|
|
} else {
|
|
out->push_back((char)c);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
out->push_back('"');
|
|
}
|
|
|
|
/* Shader authors write these; NaN/inf would produce JSON that JSONArray() rejects, and a
|
|
throw there is exactly the crash-shaped failure this API is required not to have. */
|
|
void AppendJsonNumber(std::string* out, float value) {
|
|
if (!std::isfinite(value)) {
|
|
out->append("0");
|
|
return;
|
|
}
|
|
char buffer[40];
|
|
/* %g in the C locale: never a comma decimal separator. The Kotlin side reads these
|
|
with JSONObject.optDouble, so plain decimal is what it wants. */
|
|
std::snprintf(buffer, sizeof(buffer), "%.6g", (double)value);
|
|
out->append(buffer);
|
|
}
|
|
|
|
char* Duplicate(const std::string& text) {
|
|
char* copy = (char*)std::malloc(text.size() + 1);
|
|
if (!copy) {
|
|
return nullptr;
|
|
}
|
|
std::memcpy(copy, text.c_str(), text.size() + 1);
|
|
return copy;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
#endif /* ARMSX_ENABLE_SHADERS */
|
|
|
|
namespace {
|
|
|
|
/* A freshly malloc'd "[]" — the ONLY thing armsx_shader_preset_params() may return on any
|
|
failure path. Deliberately outside the ARMSX_ENABLE_SHADERS guard: the shaders-off build
|
|
needs it too, and having exactly one definition is what stops the failure paths drifting
|
|
apart into a "" or a NULL, either of which throws in the Kotlin caller's JSONArray(). */
|
|
char* EmptyParamJson() {
|
|
static const char kEmpty[] = "[]";
|
|
char* copy = (char*)std::malloc(sizeof(kEmpty));
|
|
if (copy) {
|
|
std::memcpy(copy, kEmpty, sizeof(kEmpty));
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/* ======================================================================================
|
|
Public surface. Everything below is present in EVERY build — android_jni.cpp calls
|
|
these unconditionally, and a build without librashader must link and behave, not fail.
|
|
====================================================================================== */
|
|
|
|
extern "C" {
|
|
|
|
void armsx_shader_set_chain(bool enabled, const char* preset_path) {
|
|
#if defined(ARMSX_ENABLE_SHADERS)
|
|
const std::string path = preset_path ? preset_path : "";
|
|
bool changed = false;
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_request.lock);
|
|
changed = (g_request.enabled != enabled) || (g_request.preset != path);
|
|
g_request.enabled = enabled;
|
|
g_request.preset = path;
|
|
}
|
|
if (changed) {
|
|
armsx_render_log("shaders", "chain request: %s%s%s", enabled ? "on" : "off",
|
|
path.empty() ? "" : " preset=", path.c_str());
|
|
g_backend_warned = false;
|
|
WarnBackendOnce();
|
|
}
|
|
#else
|
|
(void)enabled;
|
|
(void)preset_path;
|
|
#endif
|
|
}
|
|
|
|
void armsx_shader_queue_params(const char* preset_path, const char* const* names,
|
|
const float* values, int count) {
|
|
#if defined(ARMSX_ENABLE_SHADERS)
|
|
if (!preset_path || !*preset_path) {
|
|
return;
|
|
}
|
|
std::lock_guard<std::mutex> lock(g_request.lock);
|
|
if (g_request.param_preset != preset_path) {
|
|
g_request.param_preset = preset_path;
|
|
g_request.params.clear();
|
|
}
|
|
for (int i = 0; i < count; ++i) {
|
|
if (names && names[i] && values) {
|
|
g_request.params[names[i]] = values[i];
|
|
}
|
|
}
|
|
g_request.params_dirty = true;
|
|
#else
|
|
(void)preset_path;
|
|
(void)names;
|
|
(void)values;
|
|
(void)count;
|
|
#endif
|
|
}
|
|
|
|
char* armsx_shader_preset_params(const char* preset_path) {
|
|
/* The never-return-null contract is absolute: the Kotlin caller feeds this straight to
|
|
JSONArray(), and both null and "" throw there. "[]" parses to an empty list. */
|
|
#if defined(ARMSX_ENABLE_SHADERS)
|
|
if (!preset_path || !*preset_path) {
|
|
return EmptyParamJson();
|
|
}
|
|
|
|
const LibraApi* api = EnsureLibra();
|
|
if (!api) {
|
|
return EmptyParamJson();
|
|
}
|
|
|
|
libra_shader_preset_t preset = nullptr;
|
|
if (libra_error_t error = api->preset_create(preset_path, &preset)) {
|
|
armsx_render_log("shaders", "cannot enumerate parameters of '%s': %s", preset_path,
|
|
TakeError(api, error).c_str());
|
|
return EmptyParamJson();
|
|
}
|
|
|
|
std::string json = "[";
|
|
libra_preset_param_list_t list{};
|
|
if (libra_error_t error = api->preset_get_runtime_params(&preset, &list)) {
|
|
armsx_render_log("shaders", "cannot enumerate parameters of '%s': %s", preset_path,
|
|
TakeError(api, error).c_str());
|
|
} else {
|
|
for (uint64_t i = 0; i < list.length; ++i) {
|
|
const libra_preset_param_t& param = list.parameters[i];
|
|
if (i) {
|
|
json.push_back(',');
|
|
}
|
|
json.append("{\"name\":");
|
|
AppendJsonString(&json, param.name);
|
|
json.append(",\"description\":");
|
|
AppendJsonString(&json, param.description);
|
|
json.append(",\"initial\":");
|
|
AppendJsonNumber(&json, param.initial);
|
|
json.append(",\"minimum\":");
|
|
AppendJsonNumber(&json, param.minimum);
|
|
json.append(",\"maximum\":");
|
|
AppendJsonNumber(&json, param.maximum);
|
|
json.append(",\"step\":");
|
|
AppendJsonNumber(&json, param.step);
|
|
json.push_back('}');
|
|
}
|
|
api->preset_free_runtime_params(list);
|
|
}
|
|
json.push_back(']');
|
|
|
|
/* get_runtime_params does NOT consume the preset (unlike chain_create), so this one
|
|
is ours to free. */
|
|
api->preset_free(&preset);
|
|
|
|
if (char* copy = Duplicate(json)) {
|
|
return copy;
|
|
}
|
|
#else
|
|
(void)preset_path;
|
|
#endif
|
|
return EmptyParamJson();
|
|
}
|
|
|
|
void armsx_shader_free_string(char* text) {
|
|
std::free(text);
|
|
}
|
|
|
|
void armsx_shader_set_source_scale(int scale) {
|
|
#if defined(ARMSX_ENABLE_SHADERS)
|
|
g_source_scale = (scale >= 1 && scale <= 8) ? scale : 1;
|
|
#else
|
|
(void)scale;
|
|
#endif
|
|
}
|
|
|
|
void armsx_shader_note_backend(int backend) {
|
|
#if defined(ARMSX_ENABLE_SHADERS)
|
|
g_backend = backend;
|
|
WarnBackendOnce();
|
|
#else
|
|
(void)backend;
|
|
#endif
|
|
}
|
|
|
|
/* The Vulkan seam exists only where render_vk.cpp does — see render_shaders.h. */
|
|
#if defined(ARMSX_ENABLE_VULKAN)
|
|
|
|
void armsx_shader_vk_attach(const armsx_shader_vk_device_t* device) {
|
|
#if defined(ARMSX_ENABLE_SHADERS)
|
|
armsx_shader_vk_detach();
|
|
if (!device || !device->device || !device->get_instance_proc_addr) {
|
|
return;
|
|
}
|
|
|
|
g_state = ChainState{};
|
|
g_state.dev = *device;
|
|
|
|
PFN_vkGetInstanceProcAddr gipa = device->get_instance_proc_addr;
|
|
auto instance_fn = [&](const char* name) { return gipa(device->instance, name); };
|
|
|
|
g_state.fns.GetDeviceProcAddr =
|
|
(PFN_vkGetDeviceProcAddr)instance_fn("vkGetDeviceProcAddr");
|
|
g_state.fns.GetPhysicalDeviceMemoryProperties =
|
|
(PFN_vkGetPhysicalDeviceMemoryProperties)instance_fn("vkGetPhysicalDeviceMemoryProperties");
|
|
if (!g_state.fns.GetDeviceProcAddr || !g_state.fns.GetPhysicalDeviceMemoryProperties) {
|
|
armsx_render_log("shaders", "could not resolve the Vulkan entry points the chain needs.");
|
|
return;
|
|
}
|
|
|
|
auto device_fn = [&](const char* name) {
|
|
return g_state.fns.GetDeviceProcAddr(device->device, name);
|
|
};
|
|
g_state.fns.CreateImage = (PFN_vkCreateImage)device_fn("vkCreateImage");
|
|
g_state.fns.DestroyImage = (PFN_vkDestroyImage)device_fn("vkDestroyImage");
|
|
g_state.fns.GetImageMemoryRequirements =
|
|
(PFN_vkGetImageMemoryRequirements)device_fn("vkGetImageMemoryRequirements");
|
|
g_state.fns.BindImageMemory = (PFN_vkBindImageMemory)device_fn("vkBindImageMemory");
|
|
g_state.fns.AllocateMemory = (PFN_vkAllocateMemory)device_fn("vkAllocateMemory");
|
|
g_state.fns.FreeMemory = (PFN_vkFreeMemory)device_fn("vkFreeMemory");
|
|
g_state.fns.CmdPipelineBarrier = (PFN_vkCmdPipelineBarrier)device_fn("vkCmdPipelineBarrier");
|
|
g_state.fns.CmdBlitImage = (PFN_vkCmdBlitImage)device_fn("vkCmdBlitImage");
|
|
g_state.fns.DeviceWaitIdle = (PFN_vkDeviceWaitIdle)device_fn("vkDeviceWaitIdle");
|
|
|
|
if (!g_state.fns.CreateImage || !g_state.fns.DestroyImage ||
|
|
!g_state.fns.GetImageMemoryRequirements || !g_state.fns.BindImageMemory ||
|
|
!g_state.fns.AllocateMemory || !g_state.fns.FreeMemory ||
|
|
!g_state.fns.CmdPipelineBarrier || !g_state.fns.CmdBlitImage ||
|
|
!g_state.fns.DeviceWaitIdle) {
|
|
armsx_render_log("shaders", "could not resolve the Vulkan entry points the chain needs.");
|
|
g_state = ChainState{};
|
|
return;
|
|
}
|
|
|
|
g_state.fns.GetPhysicalDeviceMemoryProperties(device->physical_device, &g_state.memory);
|
|
g_state.attached = true;
|
|
#else
|
|
(void)device;
|
|
#endif
|
|
}
|
|
|
|
void armsx_shader_vk_detach(void) {
|
|
#if defined(ARMSX_ENABLE_SHADERS)
|
|
/* FIRST, and before any wait-idle: a builder thread may be inside chain_create() holding the
|
|
queue guard and using the very device we are about to tear down. Joining it here is what
|
|
stops the worker outliving its VkDevice — a use-after-free that would land on whichever
|
|
unlucky frame the surface was destroyed. */
|
|
CancelChainBuild(g_libra.handle ? &g_libra : nullptr);
|
|
|
|
if (!g_state.attached) {
|
|
g_state = ChainState{};
|
|
return;
|
|
}
|
|
if (g_state.fns.DeviceWaitIdle && g_state.dev.device) {
|
|
g_state.fns.DeviceWaitIdle(g_state.dev.device);
|
|
}
|
|
DestroyChain();
|
|
DestroyImage(&g_state.input);
|
|
DestroyImage(&g_state.output);
|
|
g_state = ChainState{};
|
|
#endif
|
|
}
|
|
|
|
bool armsx_shader_vk_render(const armsx_shader_vk_frame_t* frame) {
|
|
#if defined(ARMSX_ENABLE_SHADERS)
|
|
if (!frame || !g_state.attached || frame->dst_w <= 0 || frame->dst_h <= 0) {
|
|
return false;
|
|
}
|
|
|
|
/* Nothing requested and nothing live: the overwhelmingly common case. Take it before
|
|
touching dlopen, so a build with the chain off never pays for librashader at all. */
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_request.lock);
|
|
if ((!g_request.enabled || g_request.preset.empty()) && g_state.desired.empty() &&
|
|
!g_state.chain) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const LibraApi* api = EnsureLibra();
|
|
if (!api) {
|
|
/* EnsureLibra has already said why, once. Make sure a chain requested against a
|
|
missing librashader does not keep any state around. */
|
|
g_state.desired.clear();
|
|
return false;
|
|
}
|
|
|
|
Reconcile(api);
|
|
if (!g_state.chain) {
|
|
return false;
|
|
}
|
|
ApplyQueuedParams(api);
|
|
|
|
/* ---- input: native-resolution view of the uploaded frame --------------------- */
|
|
const int divisor = SourceDivisor(frame->source_width, frame->source_height);
|
|
const int input_w = frame->source_width / divisor;
|
|
const int input_h = frame->source_height / divisor;
|
|
|
|
if (input_w != g_state.logged_input_w || input_h != g_state.logged_input_h) {
|
|
g_state.logged_input_w = input_w;
|
|
g_state.logged_input_h = input_h;
|
|
if (divisor > 1) {
|
|
armsx_render_log("shaders",
|
|
"chain input %dx%d -> %dx%d (internal scale %dx downsampled to "
|
|
"native, so the shader's scanline pitch is the PlayStation's; set "
|
|
"[video] internal_scale = 1 for the same picture at lower cost), "
|
|
"output %dx%d",
|
|
frame->source_width, frame->source_height, input_w, input_h, divisor,
|
|
frame->dst_w, frame->dst_h);
|
|
} else {
|
|
armsx_render_log("shaders", "chain input %dx%d (native), output %dx%d", input_w,
|
|
input_h, frame->dst_w, frame->dst_h);
|
|
}
|
|
}
|
|
|
|
VkImage input_image = frame->source;
|
|
if (divisor > 1) {
|
|
if (!EnsureImage(&g_state.input, input_w, input_h,
|
|
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT)) {
|
|
return false;
|
|
}
|
|
input_image = g_state.input.image;
|
|
}
|
|
|
|
if (!EnsureImage(&g_state.output, frame->dst_w, frame->dst_h,
|
|
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT)) {
|
|
return false;
|
|
}
|
|
|
|
VkCommandBuffer cmd = frame->cmd;
|
|
|
|
if (divisor > 1) {
|
|
Barrier(cmd, g_state.input.image,
|
|
g_state.input.initialized ? VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
|
|
: VK_IMAGE_LAYOUT_UNDEFINED,
|
|
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
|
g_state.input.initialized ? VK_ACCESS_SHADER_READ_BIT : 0,
|
|
VK_ACCESS_TRANSFER_WRITE_BIT,
|
|
g_state.input.initialized ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT
|
|
: VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
|
|
VK_PIPELINE_STAGE_TRANSFER_BIT);
|
|
/* LINEAR: at an exact 2:1 this is a 2x2 box filter, i.e. supersampling. */
|
|
BlitFull(cmd, frame->source, frame->source_width, frame->source_height,
|
|
g_state.input.image, 0, 0, input_w, input_h, VK_FILTER_LINEAR);
|
|
Barrier(cmd, g_state.input.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
|
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_ACCESS_TRANSFER_WRITE_BIT,
|
|
VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
|
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
|
|
g_state.input.initialized = true;
|
|
} else {
|
|
/* Sample frame_image in place. The caller handed it over in TRANSFER_SRC_OPTIMAL
|
|
and expects it back that way, so this is a there-and-back pair. */
|
|
Barrier(cmd, frame->source, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
|
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_ACCESS_TRANSFER_READ_BIT,
|
|
VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
|
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
|
|
}
|
|
|
|
/* ---- output: the chain's render target -------------------------------------- */
|
|
/* UNDEFINED every frame: the chain overwrites the whole viewport, which IS the whole
|
|
image here, so there is nothing to preserve and discarding is cheaper on a tiler. */
|
|
Barrier(cmd, g_state.output.image, VK_IMAGE_LAYOUT_UNDEFINED,
|
|
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, 0, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
|
|
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT);
|
|
g_state.output.initialized = true;
|
|
|
|
libra_image_vk_t source{};
|
|
source.handle = input_image;
|
|
source.format = VK_FORMAT_R8G8B8A8_UNORM;
|
|
source.width = (uint32_t)input_w;
|
|
source.height = (uint32_t)input_h;
|
|
|
|
libra_image_vk_t target{};
|
|
target.handle = g_state.output.image;
|
|
target.format = VK_FORMAT_R8G8B8A8_UNORM;
|
|
target.width = (uint32_t)frame->dst_w;
|
|
target.height = (uint32_t)frame->dst_h;
|
|
|
|
/* The whole owned target. The aspect correction is already in its SIZE — handing
|
|
librashader the full window rect instead is what stretches a 4:3 frame to 16:9. */
|
|
libra_viewport_t viewport{};
|
|
viewport.x = 0.0f;
|
|
viewport.y = 0.0f;
|
|
viewport.width = (uint32_t)frame->dst_w;
|
|
viewport.height = (uint32_t)frame->dst_h;
|
|
|
|
libra_error_t error = api->chain_frame(&g_state.chain, cmd, g_state.frame_count, source, target,
|
|
&viewport, nullptr, nullptr);
|
|
if (error) {
|
|
armsx_render_log("shaders", "chain '%s' failed mid-frame: %s. Falling back to plain "
|
|
"presentation.",
|
|
g_state.active.c_str(), TakeError(api, error).c_str());
|
|
/* Do NOT free anything here: commands naming these objects are already recorded
|
|
into a command buffer the caller is about to submit. Mark it and let the next
|
|
Reconcile(), which runs before any recording, do the teardown. */
|
|
g_state.failed = true;
|
|
g_state.desired.clear();
|
|
/* Restore the layouts the caller expects, then let it blit over the top. */
|
|
if (divisor <= 1) {
|
|
Barrier(cmd, frame->source, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
|
|
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_SHADER_READ_BIT,
|
|
VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
|
|
VK_PIPELINE_STAGE_TRANSFER_BIT);
|
|
}
|
|
return false;
|
|
}
|
|
++g_state.frame_count;
|
|
|
|
/* ---- output -> swapchain ----------------------------------------------------- */
|
|
/* librashader emits no barrier for the final pass; the output is still in
|
|
COLOR_ATTACHMENT_OPTIMAL and transitioning it is documented as ours. */
|
|
Barrier(cmd, g_state.output.image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
|
|
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
|
|
VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
|
VK_PIPELINE_STAGE_TRANSFER_BIT);
|
|
|
|
BlitFull(cmd, g_state.output.image, frame->dst_w, frame->dst_h, frame->target, frame->dst_x,
|
|
frame->dst_y, frame->dst_w, frame->dst_h, VK_FILTER_NEAREST);
|
|
|
|
if (divisor <= 1) {
|
|
Barrier(cmd, frame->source, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
|
|
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_SHADER_READ_BIT,
|
|
VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
|
|
VK_PIPELINE_STAGE_TRANSFER_BIT);
|
|
}
|
|
|
|
return true;
|
|
#else
|
|
(void)frame;
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
#endif /* ARMSX_ENABLE_VULKAN */
|
|
|
|
} // extern "C"
|
|
|
|
extern "C" void armsx_shader_vk_queue_lock(void) {
|
|
g_vk_queue_lock.lock();
|
|
}
|
|
|
|
extern "C" void armsx_shader_vk_queue_unlock(void) {
|
|
g_vk_queue_lock.unlock();
|
|
}
|