diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe8dc86c0..46a973fe1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,6 +61,7 @@ jobs: run: | cmake --build build-macos --target \ mkw_platform_paths_tests \ + mkw_runtime_config_tests \ mkw_nand_save_tests \ mkw_nand_settings_tests \ mkw_sc_serial_tests \ diff --git a/README.md b/README.md index 8a6403f8e..108ab34ce 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ rather it didn't. All audio that shows in your display media controls on your wi Press **F10** while the game window has focus: - Internal resolution - FPS counter +- MetalFX spatial upscaling on supported macOS GPUs - Controller assignment for all four ports - Full per-controller button mapping, including the bumpers - Dolphin-syntax input expressions and GCPadNew.ini import diff --git a/aurora-main/CMakeLists.txt b/aurora-main/CMakeLists.txt index 7035769b7..6a0ef53ed 100644 --- a/aurora-main/CMakeLists.txt +++ b/aurora-main/CMakeLists.txt @@ -2,6 +2,7 @@ cmake_minimum_required(VERSION 3.25) project(aurora LANGUAGES C CXX) if (APPLE) enable_language(OBJC) + enable_language(OBJCXX) endif() set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 20) @@ -83,3 +84,10 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR AND NOT CMAKE_CROSSCOMPIL enable_testing() add_subdirectory(tests) endif () + +option(AURORA_BUILD_METALFX_PRESENTATION_TEST "Build the macOS MetalFX presentation test" OFF) +if (AURORA_BUILD_METALFX_PRESENTATION_TEST AND APPLE AND AURORA_ENABLE_GX AND DAWN_ENABLE_METAL AND AURORA_METALFX_FRAMEWORK) + add_executable(metalfx_presentation_test tests/metalfx_interop/presentation_test.cpp) + target_include_directories(metalfx_presentation_test PRIVATE lib) + target_link_libraries(metalfx_presentation_test PRIVATE aurora::core aurora::gx aurora::vi dawn::webgpu_dawn) +endif () diff --git a/aurora-main/cmake/aurora_core.cmake b/aurora-main/cmake/aurora_core.cmake index b17cb4249..65ffaea62 100644 --- a/aurora-main/cmake/aurora_core.cmake +++ b/aurora-main/cmake/aurora_core.cmake @@ -34,6 +34,17 @@ if (AURORA_ENABLE_GX) target_compile_definitions(aurora_core PUBLIC AURORA_ENABLE_GX WEBGPU_DAWN) target_sources(aurora_core PRIVATE lib/webgpu/gpu.cpp lib/webgpu/gpu_cache.cpp lib/dawn/BackendBinding.cpp) target_link_libraries(aurora_core PRIVATE dawn::webgpu_dawn) + if (APPLE AND DAWN_ENABLE_METAL) + find_library(AURORA_METALFX_FRAMEWORK MetalFX) + endif () + if (APPLE AND DAWN_ENABLE_METAL AND AURORA_METALFX_FRAMEWORK) + target_sources(aurora_core PRIVATE lib/webgpu/metalfx.mm) + set_source_files_properties(lib/webgpu/metalfx.mm PROPERTIES COMPILE_FLAGS -fobjc-arc) + target_link_options(aurora_core PUBLIC "LINKER:-weak_framework,MetalFX") + target_link_libraries(aurora_core PRIVATE "-framework IOSurface") + else () + target_sources(aurora_core PRIVATE lib/webgpu/metalfx_stub.cpp) + endif () if (DAWN_ENABLE_VULKAN) target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_VULKAN) endif () diff --git a/aurora-main/include/aurora/aurora.h b/aurora-main/include/aurora/aurora.h index c3f70d2cf..b9e5f02e5 100644 --- a/aurora-main/include/aurora/aurora.h +++ b/aurora-main/include/aurora/aurora.h @@ -162,6 +162,21 @@ void aurora_set_background_input(bool value); void aurora_set_display_mode(AuroraDisplayMode mode); AuroraDisplayMode aurora_get_display_mode(); +typedef enum { + AURORA_METALFX_DISABLED, + AURORA_METALFX_UNSUPPORTED, + AURORA_METALFX_NOT_UPSCALING, + AURORA_METALFX_ACTIVE, + AURORA_METALFX_ERROR, +} AuroraMetalFXStatus; + +// Changes are consumed at the next sealed frame boundary. MetalFX only applies +// when both source dimensions are smaller than the aspect-fitted output. +void aurora_set_metalfx_spatial(bool enabled); +bool aurora_get_metalfx_spatial(); +bool aurora_is_metalfx_spatial_supported(); +AuroraMetalFXStatus aurora_get_metalfx_status(); + AuroraBackend aurora_get_backend(); const AuroraBackend* aurora_get_available_backends(size_t* count); diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index bf76d948f..e0343b3c0 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -7,6 +7,7 @@ #include "gx/shader_info.hpp" #include "imgui.hpp" #include "webgpu/gpu.hpp" +#include "webgpu/metalfx.hpp" #include #endif @@ -60,6 +61,9 @@ std::atomic g_frameWorkerWaitCallback{nullptr}; // deadlines derived from it, so the presenter cannot drift. Zero means present when ready. std::atomic g_presentScheduleBaseNanos{0}; std::atomic g_presentScheduleIntervalNanos{0}; +std::atomic g_metalfxRequested{false}; +std::atomic g_metalfxSupported{false}; +std::atomic g_metalfxStatus{AURORA_METALFX_DISABLED}; namespace { Module Log("aurora"); @@ -747,6 +751,9 @@ AuroraInfo initialize(int argc, char* argv[], const AuroraConfig& config) noexce #ifdef AURORA_ENABLE_GX gfx::initialize(); + g_metalfxSupported.store(webgpu::metalfx::supported(g_device, webgpu::g_backendType)); + g_metalfxStatus.store(AURORA_METALFX_DISABLED); + imgui::create_context(); #endif const auto size = window::get_window_size(); @@ -1169,12 +1176,114 @@ void stop_presenter() noexcept { g_presenterStarted.store(false, std::memory_order_release); } +struct MetalFXSlot { + webgpu::metalfx::Size size{}; + std::unique_ptr scaler; + wgpu::BindGroup bindGroup; +}; +std::array g_metalfxSlots; +size_t g_metalfxNextSlot = 0; +webgpu::metalfx::SpatialScaler* g_metalfxPendingOutput = nullptr; +bool g_metalfxFailed = false; + +void metalfx_failed(const std::string& reason) { + Log.warn("MetalFX spatial upscaling disabled: {}; using normal presentation", reason); + g_metalfxFailed = true; + g_metalfxStatus.store(AURORA_METALFX_ERROR); + g_metalfxPendingOutput = nullptr; + g_metalfxSlots = {}; +} + +wgpu::BindGroup upscale_presentation(wgpu::CommandEncoder& encoder, + const webgpu::PresentSource& source, + const webgpu::Viewport& viewport, bool enabled) { + if (!enabled) { + g_metalfxSlots = {}; + g_metalfxFailed = false; + g_metalfxStatus.store(AURORA_METALFX_DISABLED); + return {}; + } + if (!g_metalfxSupported.load()) { + g_metalfxStatus.store(AURORA_METALFX_UNSUPPORTED); + return {}; + } + if (g_metalfxFailed) return {}; + const webgpu::metalfx::Size size{ + source.size.width, source.size.height, + static_cast(viewport.width), static_cast(viewport.height), + webgpu::g_graphicsConfig.surfaceConfiguration.format, + }; + // The existing copy path samples perceptual values from unorm game images. + // Do not introduce implicit sRGB decoding or downscaling into MetalFX. + if (!size.inputWidth || !size.inputHeight || size.inputWidth >= size.outputWidth || + size.inputHeight >= size.outputHeight || + (source.format != wgpu::TextureFormat::RGBA8Unorm && source.format != wgpu::TextureFormat::BGRA8Unorm)) { + g_metalfxStatus.store(AURORA_METALFX_NOT_UPSCALING); + return {}; + } + auto& slot = g_metalfxSlots[g_metalfxNextSlot++ % g_metalfxSlots.size()]; + if (!slot.scaler || !(slot.size == size)) { + slot = {}; + std::string error; + slot.scaler = webgpu::metalfx::create(g_instance, g_device, size, error); + if (!slot.scaler) { + if (error.empty()) g_metalfxStatus.store(AURORA_METALFX_NOT_UPSCALING); + else metalfx_failed(error); + return {}; + } + slot.size = size; + wgpu::SamplerDescriptor samplerDescriptor{}; + samplerDescriptor.magFilter = wgpu::FilterMode::Linear; + samplerDescriptor.minFilter = wgpu::FilterMode::Linear; + slot.bindGroup = webgpu::create_copy_bind_group(slot.scaler->output_view(), + g_device.CreateSampler(&samplerDescriptor)); + Log.info("MetalFX spatial slot: {}x{} -> {}x{}", size.inputWidth, size.inputHeight, + size.outputWidth, size.outputHeight); + } + if (!slot.scaler->begin_input()) { + metalfx_failed(slot.scaler->error()); + return {}; + } + const wgpu::RenderPassColorAttachment attachment{ + .view = slot.scaler->input_view(), + .loadOp = wgpu::LoadOp::Clear, + .storeOp = wgpu::StoreOp::Store, + }; + const wgpu::RenderPassDescriptor descriptor{ + .label = "MetalFX input copy", + .colorAttachmentCount = 1, + .colorAttachments = &attachment, + }; + auto pass = encoder.BeginRenderPass(&descriptor); + pass.SetPipeline(webgpu::g_CopyPipeline); + pass.SetBindGroup(0, source.bindGroup); + pass.SetViewport(0, 0, static_cast(size.inputWidth), static_cast(size.inputHeight), 0, 1); + pass.Draw(3); + pass.End(); + // Submit the sealed scene and input copy before crossing to the native queue. + // The replacement encoder composites the upscaled image and ImGui normally. + auto buffer = encoder.Finish(); + { + std::lock_guard submitLock(g_queueSubmitMutex); + g_queue.Submit(1, &buffer); + } + encoder = g_device.CreateCommandEncoder(); + if (!slot.scaler->upscale()) { + metalfx_failed(slot.scaler->error()); + return {}; + } + g_metalfxPendingOutput = slot.scaler.get(); + g_metalfxStatus.store(AURORA_METALFX_ACTIVE); + return slot.bindGroup; +} + // `presentSource` is latched in the seal prologue: by the time this encodes, the producer's next // gfx::begin_frame() may already have cleared the display-copy override. -void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, - const webgpu::PresentSource& presentSource, - const PresentationImage& image, - bool includeImGui) { +wgpu::BindGroup encode_presentation_snapshot(wgpu::CommandEncoder& encoder, + const webgpu::PresentSource& presentSource, + const PresentationImage& image, + bool includeImGui, bool metalfxEnabled, + const wgpu::BindGroup* cachedMetalFXOutput = nullptr) { ZoneScoped; auto viewport = webgpu::calculate_present_viewport( image.texture.size.width, image.texture.size.height, presentSource.size.width, @@ -1185,6 +1294,13 @@ void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, image.texture.size.width, image.texture.size.height, presentAspect); } wgpu::BindGroup presentBindGroup = presentSource.bindGroup; + wgpu::BindGroup newMetalFXOutput; + if (cachedMetalFXOutput && *cachedMetalFXOutput) { + presentBindGroup = *cachedMetalFXOutput; + } else if (auto upscaled = upscale_presentation(encoder, presentSource, viewport, metalfxEnabled)) { + presentBindGroup = std::move(upscaled); + newMetalFXOutput = presentBindGroup; + } { const std::array attachments{ wgpu::RenderPassColorAttachment{ @@ -1225,6 +1341,7 @@ void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, imgui::render(pass); pass.End(); } + return newMetalFXOutput; } #endif @@ -1233,6 +1350,13 @@ void shutdown() noexcept { #ifdef AURORA_ENABLE_GX stop_presenter(); g_presentationImagePools = {}; + g_metalfxSlots = {}; + g_metalfxPendingOutput = nullptr; + g_metalfxNextSlot = 0; + g_metalfxFailed = false; + g_metalfxRequested.store(false); + g_metalfxSupported.store(false); + g_metalfxStatus.store(AURORA_METALFX_DISABLED); imgui::shutdown(); gfx::shutdown(); webgpu::shutdown(); @@ -1345,6 +1469,7 @@ struct SealedFrameContext { uint32_t logicalFrame = 0; bool interpolationActive = false; bool replayInterpolatedFrames = false; + bool metalfxEnabled = false; }; // Phase 1: everything that touches producer-shared renderer state. Needs g_rendererGpuMutex and @@ -1372,6 +1497,7 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx) { ctx.snapshotWidth = (std::max)(windowSize.native_fb_width, 1u); ctx.snapshotHeight = (std::max)(windowSize.native_fb_height, 1u); ctx.logicalFrame = gfx::current_frame(); + ctx.metalfxEnabled = g_metalfxRequested.load(); // Latched before webgpu::clear_present_source_override() in the producer's // next gfx::begin_frame(). ctx.presentSource = webgpu::current_present_source(); @@ -1419,10 +1545,14 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, const wgpu::CommandBufferDescriptor cmdBufDescriptor{ .label = "Presentation slot command buffer", }; - const auto submitEncodedSlot = [&](wgpu::CommandEncoder& target) { + const auto submitEncodedSlot = [&](wgpu::CommandEncoder& target, bool releaseMetalFXOutput = true) { const auto buffer = target.Finish(&cmdBufDescriptor); std::lock_guard submitLock(g_queueSubmitMutex); g_queue.Submit(1, &buffer); + if (releaseMetalFXOutput && g_metalfxPendingOutput) { + if (!g_metalfxPendingOutput->end_output()) metalfx_failed(g_metalfxPendingOutput->error()); + g_metalfxPendingOutput = nullptr; + } }; if (ctx.replayInterpolatedFrames) { @@ -1431,7 +1561,7 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, gfx::render(sealedFrame, encoder, static_cast(interpolatedFrame), false); auto image = acquire_presentation_image(interpolatedFrame, ctx.snapshotWidth, ctx.snapshotHeight); - encode_presentation_snapshot(encoder, ctx.presentSource, *image, true); + encode_presentation_snapshot(encoder, ctx.presentSource, *image, true, ctx.metalfxEnabled); presentationJobs.push_back({ .image = std::move(image), .logicalFrame = ctx.logicalFrame, @@ -1449,12 +1579,18 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, // The copy targets now hold this frame's resolves, so queue their readbacks on the same encoder; // completion is harvested in gfx::after_submit, never waited on here. gfx::efb_ram::encode_async_downloads(encoder); + wgpu::BindGroup duplicatedMetalFXOutput; if (!ctx.replayInterpolatedFrames) { for (uint32_t interpolatedFrame = 0; interpolatedFrame < ctx.interpolatedFrameCount; ++interpolatedFrame) { auto image = acquire_presentation_image(interpolatedFrame, ctx.snapshotWidth, ctx.snapshotHeight); - encode_presentation_snapshot(encoder, ctx.presentSource, *image, true); + const auto newMetalFXOutput = encode_presentation_snapshot( + encoder, ctx.presentSource, *image, true, ctx.metalfxEnabled, + duplicatedMetalFXOutput ? &duplicatedMetalFXOutput : nullptr); + if (!duplicatedMetalFXOutput && newMetalFXOutput) { + duplicatedMetalFXOutput = newMetalFXOutput; + } presentationJobs.push_back({ .image = std::move(image), .logicalFrame = ctx.logicalFrame, @@ -1462,13 +1598,14 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, .interpolated = true, .duplicated = true, }); - submitEncodedSlot(encoder); + submitEncodedSlot(encoder, false); encoder = g_device.CreateCommandEncoder(&encoderDescriptor); } } auto finalImage = acquire_presentation_image(ctx.interpolatedFrameCount, ctx.snapshotWidth, ctx.snapshotHeight); - encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true); + encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true, ctx.metalfxEnabled, + duplicatedMetalFXOutput ? &duplicatedMetalFXOutput : nullptr); auto pendingFrameCapture = encode_frame_capture(encoder, ctx.presentSource); presentationJobs.push_back({ .image = std::move(finalImage), @@ -1948,3 +2085,7 @@ void aurora_set_background_input(bool value) { } void aurora_set_display_mode(AuroraDisplayMode mode) { aurora::window::set_display_mode(mode); } AuroraDisplayMode aurora_get_display_mode() { return aurora::window::get_display_mode(); } +void aurora_set_metalfx_spatial(bool enabled) { aurora::g_metalfxRequested.store(enabled); } +bool aurora_get_metalfx_spatial() { return aurora::g_metalfxRequested.load(); } +bool aurora_is_metalfx_spatial_supported() { return aurora::g_metalfxSupported.load(); } +AuroraMetalFXStatus aurora_get_metalfx_status() { return aurora::g_metalfxStatus.load(); } diff --git a/aurora-main/lib/webgpu/gpu.cpp b/aurora-main/lib/webgpu/gpu.cpp index e2fe3515a..79eadcdde 100644 --- a/aurora-main/lib/webgpu/gpu.cpp +++ b/aurora-main/lib/webgpu/gpu.cpp @@ -638,6 +638,14 @@ bool initialize(AuroraBackend auroraBackend) { requiredLimits.maxDynamicStorageBuffersPerPipelineLayout, requiredLimits.maxStorageBuffersPerShaderStage, requiredLimits.minUniformBufferOffsetAlignment, requiredLimits.minStorageBufferOffsetAlignment); std::vector requiredFeatures; + // Optional native sharing for MetalFX. Devices without either feature keep + // the normal renderer; the upscaler checks the enabled pair at runtime. + if (backend == wgpu::BackendType::Metal && + g_adapter.HasFeature(wgpu::FeatureName::SharedTextureMemoryIOSurface) && + g_adapter.HasFeature(wgpu::FeatureName::SharedFenceMTLSharedEvent)) { + requiredFeatures.push_back(wgpu::FeatureName::SharedTextureMemoryIOSurface); + requiredFeatures.push_back(wgpu::FeatureName::SharedFenceMTLSharedEvent); + } bool implicitDeviceSynchronizationSupported = false; wgpu::SupportedFeatures supportedFeatures; g_adapter.GetFeatures(&supportedFeatures); diff --git a/aurora-main/lib/webgpu/metalfx.hpp b/aurora-main/lib/webgpu/metalfx.hpp new file mode 100644 index 000000000..6a9d48541 --- /dev/null +++ b/aurora-main/lib/webgpu/metalfx.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include +#include + +namespace aurora::webgpu::metalfx { +struct Size { + uint32_t inputWidth; + uint32_t inputHeight; + uint32_t outputWidth; + uint32_t outputHeight; + wgpu::TextureFormat format; + + bool operator==(const Size&) const = default; +}; + +// All methods except supported() belong to the serialized frame encoder. +// GPU ownership is explicit: begin_input -> submit input -> upscale -> +// submit output consumption -> end_output. Neither texture may be used by +// Dawn outside its access interval. Destruction retires in-flight resources. +class SpatialScaler { +public: + virtual ~SpatialScaler() = default; + virtual const wgpu::TextureView& input_view() const = 0; + virtual const wgpu::TextureView& output_view() const = 0; + virtual const wgpu::Texture& output_texture() const = 0; + virtual bool begin_input() = 0; + virtual bool upscale() = 0; + virtual bool end_output() = 0; + virtual const std::string& error() const = 0; +}; + +bool supported(const wgpu::Device& device, wgpu::BackendType backend); +// A null result with no error means the bounded retirement pool is busy; +// skip upscaling for this frame and retry at a later frame boundary. +std::unique_ptr create(const wgpu::Instance& instance, + const wgpu::Device& device, const Size& size, + std::string& error); +} // namespace aurora::webgpu::metalfx diff --git a/aurora-main/lib/webgpu/metalfx.mm b/aurora-main/lib/webgpu/metalfx.mm new file mode 100644 index 000000000..f66a5fe07 --- /dev/null +++ b/aurora-main/lib/webgpu/metalfx.mm @@ -0,0 +1,326 @@ +#include "metalfx.hpp" + +#import +#import +#import +#import + +#include + +#include +#include + +namespace aurora::webgpu::metalfx { +namespace { +constexpr uint64_t kScheduleTimeoutNs = 1'000'000'000; +// Four current slots plus at most four retiring slots during resize. A busy +// GPU must not allow resize events to allocate unbounded full-resolution images. +constexpr unsigned kMaxLiveResources = 8; +std::atomic g_liveResources{0}; + +struct SharedImage { + IOSurfaceRef surface = nullptr; + id metal; + wgpu::SharedTextureMemory memory; + wgpu::Texture texture; + wgpu::TextureView view; + + ~SharedImage() { if (surface) CFRelease(surface); } + + bool create(const wgpu::Device& device, id native, uint32_t width, + uint32_t height, wgpu::TextureFormat format, MTLTextureUsage nativeUsage, + wgpu::TextureUsage usage) { + const bool bgra = format == wgpu::TextureFormat::BGRA8Unorm; + const size_t rowBytes = IOSurfaceAlignProperty(kIOSurfaceBytesPerRow, size_t(width) * 4); + NSDictionary* properties = @{ + (id)kIOSurfaceWidth: @(width), (id)kIOSurfaceHeight: @(height), + (id)kIOSurfaceBytesPerElement: @4, (id)kIOSurfaceBytesPerRow: @(rowBytes), + (id)kIOSurfaceAllocSize: @(rowBytes * height), + (id)kIOSurfacePixelFormat: @(bgra ? 0x42475241u : 0x52474241u) + }; + surface = IOSurfaceCreate((__bridge CFDictionaryRef)properties); + if (!surface) return false; + auto descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat: + bgra ? MTLPixelFormatBGRA8Unorm : MTLPixelFormatRGBA8Unorm + width:width height:height mipmapped:NO]; + descriptor.storageMode = MTLStorageModeShared; + descriptor.usage = nativeUsage; + metal = [native newTextureWithDescriptor:descriptor iosurface:surface plane:0]; + if (!metal) return false; + + wgpu::SharedTextureMemoryIOSurfaceDescriptor io{}; + io.ioSurface = surface; + io.allowStorageBinding = false; + wgpu::SharedTextureMemoryDescriptor importDescriptor{}; + importDescriptor.nextInChain = &io; + memory = device.ImportSharedTextureMemory(&importDescriptor); + wgpu::SharedTextureMemoryProperties actual{}; + if (!memory || memory.GetProperties(&actual) != wgpu::Status::Success || + actual.format != format || actual.size.width != width || actual.size.height != height || + (actual.usage & usage) != usage) return false; + wgpu::TextureDescriptor textureDescriptor{}; + textureDescriptor.label = "MetalFX shared texture"; + textureDescriptor.size = {width, height, 1}; + textureDescriptor.format = format; + textureDescriptor.usage = usage; + texture = memory.CreateTexture(&textureDescriptor); + if (!texture) return false; + view = texture.CreateView(); + return view != nullptr; + } +}; + +struct API_AVAILABLE(macos(13.0)) Resources { + SharedImage input, output; + id scaler; + id privateOutput; + id nativeQueue; + id event; + wgpu::SharedFence fence; + std::atomic failed{false}; + + Resources() { ++g_liveResources; } + ~Resources() { --g_liveResources; } +}; + +class API_AVAILABLE(macos(13.0)) MetalSpatialScaler final : public SpatialScaler { + wgpu::Instance m_instance; + wgpu::Queue m_queue; + std::shared_ptr m_resources; + wgpu::SharedTextureMemoryEndAccessState m_outputReleased{}; + wgpu::Future m_outputScheduled{}; + uint64_t m_value = 0; + std::string m_error; + + bool fail(const char* reason) { + m_error = reason; + m_resources->failed = true; + return false; + } + + bool wait_scheduled(wgpu::Future future) { + return m_instance.WaitAny(future, kScheduleTimeoutNs) == wgpu::WaitStatus::Success || + fail("Timed out scheduling MetalFX GPU work"); + } + + bool end_access(SharedImage& image, wgpu::SharedTextureMemoryEndAccessState& state, + wgpu::Future& scheduled) { + wgpu::SharedTextureMemoryMetalEndAccessState metal{}; + state.nextInChain = &metal; + const auto status = image.memory.EndAccess(image.texture, &state); + state.nextInChain = nullptr; + scheduled = metal.commandsScheduledFuture; + return status == wgpu::Status::Success || fail("MetalFX Dawn EndAccess failed"); + } + + bool begin_access(SharedImage& image, bool initialized, uint64_t value) { + wgpu::SharedTextureMemoryBeginAccessDescriptor access{}; + access.initialized = initialized; + if (value) { + access.fenceCount = 1; + access.fences = &m_resources->fence; + access.signaledValueCount = 1; + access.signaledValues = &value; + } + return image.memory.BeginAccess(image.texture, &access) == wgpu::Status::Success || + fail("MetalFX Dawn BeginAccess failed"); + } + + bool encode_waits(id commands, + const wgpu::SharedTextureMemoryEndAccessState& state) { + for (size_t i = 0; i < state.fenceCount; ++i) { + wgpu::SharedFenceMTLSharedEventExportInfo metal{}; + wgpu::SharedFenceExportInfo info{}; + info.nextInChain = &metal; + state.fences[i].ExportInfo(&info); + if (info.type != wgpu::SharedFenceType::MTLSharedEvent || !metal.sharedEvent) + return fail("Dawn did not export a MetalFX shared-event dependency"); + [commands encodeWaitForEvent:(__bridge id)metal.sharedEvent + value:state.signaledValues[i]]; + } + return true; + } + + void retain_until_dawn_done() { + // A resize/toggle can destroy this wrapper immediately. The last submitted + // Dawn consumer keeps the IOSurfaces/scaler alive independently of the cache. + m_queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + [resources = m_resources](wgpu::QueueWorkDoneStatus status, wgpu::StringView) { + if (status != wgpu::QueueWorkDoneStatus::Success) resources->failed = true; + }); + } + +public: + MetalSpatialScaler(const wgpu::Instance& instance, const wgpu::Device& device, + std::shared_ptr resources) + : m_instance(instance), m_queue(device.GetQueue()), m_resources(std::move(resources)) {} + + const wgpu::TextureView& input_view() const override { return m_resources->input.view; } + const wgpu::TextureView& output_view() const override { return m_resources->output.view; } + const wgpu::Texture& output_texture() const override { return m_resources->output.texture; } + const std::string& error() const override { return m_error; } + + bool begin_input() override { + if (m_resources->failed) return fail("Previous MetalFX GPU work failed"); + return begin_access(m_resources->input, m_value != 0, m_value); + } + + bool upscale() override { + @autoreleasepool { + // Input has already been submitted. Retain it even if an export or native + // allocation fails and the caller immediately falls back to normal copy. + retain_until_dawn_done(); + wgpu::SharedTextureMemoryEndAccessState inputReleased{}; + wgpu::Future inputScheduled{}; + if (!end_access(m_resources->input, inputReleased, inputScheduled) || + !wait_scheduled(inputScheduled)) return false; + if (m_value && !wait_scheduled(m_outputScheduled)) return false; + id commands = [m_resources->nativeQueue commandBuffer]; + if (!commands) return fail("Could not allocate a MetalFX command buffer"); + commands.label = @"MetalFX spatial upscale and return to Dawn"; + if (!encode_waits(commands, inputReleased) || !encode_waits(commands, m_outputReleased)) + return false; + [m_resources->scaler encodeToCommandBuffer:commands]; + id blit = [commands blitCommandEncoder]; + if (!blit) return fail("Could not allocate the MetalFX output blit"); + [blit copyFromTexture:m_resources->privateOutput sourceSlice:0 sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(m_resources->privateOutput.width, m_resources->privateOutput.height, 1) + toTexture:m_resources->output.metal destinationSlice:0 destinationLevel:0 + destinationOrigin:MTLOriginMake(0, 0, 0)]; + [blit endEncoding]; + ++m_value; + [commands encodeSignalEvent:m_resources->event value:m_value]; + const auto resources = m_resources; + [commands addCompletedHandler:^(id completed) { + if (completed.status == MTLCommandBufferStatusError) resources->failed = true; + }]; + [commands commit]; + // Scheduling is required to order independent Metal queues. Completion + // remains asynchronous; resource reuse is guarded by shared GPU events. + [commands waitUntilScheduled]; + if (commands.status == MTLCommandBufferStatusError) + return fail("MetalFX command buffer failed"); + return begin_access(m_resources->output, true, m_value); + } + } + + bool end_output() override { + retain_until_dawn_done(); + m_outputReleased = {}; + return end_access(m_resources->output, m_outputReleased, m_outputScheduled); + } +}; + +// Allocation failures must be caught here rather than reaching Aurora's fatal +// uncaptured-error callback. Scope callbacks own their strings even on timeout. +bool pop_scope(const wgpu::Instance& instance, const wgpu::Device& device, std::string& error) { + auto message = std::make_shared(); + auto future = device.PopErrorScope(wgpu::CallbackMode::WaitAnyOnly, + [message](wgpu::PopErrorScopeStatus status, wgpu::ErrorType type, wgpu::StringView text) { + if (status != wgpu::PopErrorScopeStatus::Success || type != wgpu::ErrorType::NoError) { + const std::string_view detail{text}; + *message = detail.empty() ? "MetalFX texture allocation failed" : std::string(detail); + } + }); + if (instance.WaitAny(future, kScheduleTimeoutNs) != wgpu::WaitStatus::Success) { + error = "Timed out checking MetalFX texture allocation"; + return false; + } + if (!message->empty()) { error = *message; return false; } + return true; +} +} // namespace + +bool supported(const wgpu::Device& device, wgpu::BackendType backend) { + if (@available(macOS 13.0, *)) { + if (!device || backend != wgpu::BackendType::Metal || + !device.HasFeature(wgpu::FeatureName::SharedTextureMemoryIOSurface) || + !device.HasFeature(wgpu::FeatureName::SharedFenceMTLSharedEvent)) return false; + auto native = dawn::native::metal::GetMTLDevice(device.Get()); + return native && [MTLFXSpatialScalerDescriptor supportsDevice:native]; + } + return false; +} + +std::unique_ptr create(const wgpu::Instance& instance, + const wgpu::Device& device, const Size& size, + std::string& error) { + error.clear(); + if (@available(macOS 13.0, *)) { + @autoreleasepool { + if (!supported(device, wgpu::BackendType::Metal)) { + error = "MetalFX spatial scaling is unsupported"; + return {}; + } + wgpu::Limits limits{}; + device.GetLimits(&limits); + if (!size.inputWidth || !size.inputHeight || size.inputWidth >= size.outputWidth || + size.inputHeight >= size.outputHeight || size.outputWidth > limits.maxTextureDimension2D || + size.outputHeight > limits.maxTextureDimension2D || + (size.format != wgpu::TextureFormat::RGBA8Unorm && size.format != wgpu::TextureFormat::BGRA8Unorm)) { + error = "MetalFX requires smaller input dimensions and an RGBA8/BGRA8 unorm target"; + return {}; + } + if (g_liveResources.load() >= kMaxLiveResources) { + return {}; + } + auto native = dawn::native::metal::GetMTLDevice(device.Get()); + auto resources = std::make_shared(); + auto descriptor = [MTLFXSpatialScalerDescriptor new]; + descriptor.inputWidth = size.inputWidth; + descriptor.inputHeight = size.inputHeight; + descriptor.outputWidth = size.outputWidth; + descriptor.outputHeight = size.outputHeight; + descriptor.colorTextureFormat = size.format == wgpu::TextureFormat::BGRA8Unorm + ? MTLPixelFormatBGRA8Unorm : MTLPixelFormatRGBA8Unorm; + descriptor.outputTextureFormat = descriptor.colorTextureFormat; + descriptor.colorProcessingMode = MTLFXSpatialScalerColorProcessingModePerceptual; + resources->scaler = [descriptor newSpatialScalerWithDevice:native]; + resources->nativeQueue = [native newCommandQueue]; + resources->event = [native newSharedEvent]; + if (!resources->scaler || !resources->nativeQueue || !resources->event) { + error = "Could not create MetalFX spatial resources"; + return {}; + } + auto outputDescriptor = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:descriptor.outputTextureFormat + width:size.outputWidth height:size.outputHeight mipmapped:NO]; + outputDescriptor.storageMode = MTLStorageModePrivate; + outputDescriptor.usage = resources->scaler.outputTextureUsage; + resources->privateOutput = [native newTextureWithDescriptor:outputDescriptor]; + if (!resources->privateOutput) { error = "Could not allocate MetalFX private output"; return {}; } + + device.PushErrorScope(wgpu::ErrorFilter::Validation); + device.PushErrorScope(wgpu::ErrorFilter::OutOfMemory); + device.PushErrorScope(wgpu::ErrorFilter::Internal); + bool allocated = resources->input.create(device, native, size.inputWidth, size.inputHeight, + size.format, resources->scaler.colorTextureUsage, wgpu::TextureUsage::RenderAttachment); + allocated = allocated && resources->output.create(device, native, size.outputWidth, size.outputHeight, + size.format, MTLTextureUsageShaderRead, wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopySrc); + if (allocated) { + wgpu::SharedFenceMTLSharedEventDescriptor event{}; + event.sharedEvent = (__bridge void*)resources->event; + wgpu::SharedFenceDescriptor fence{}; + fence.nextInChain = &event; + resources->fence = device.ImportSharedFence(&fence); + allocated = resources->fence != nullptr; + } + for (int i = 0; i < 3; ++i) { + if (!pop_scope(instance, device, error)) allocated = false; + } + if (!allocated) { + if (error.empty()) error = "Could not import MetalFX IOSurface textures into Dawn"; + return {}; + } + resources->scaler.colorTexture = resources->input.metal; + resources->scaler.outputTexture = resources->privateOutput; + resources->scaler.inputContentWidth = size.inputWidth; + resources->scaler.inputContentHeight = size.inputHeight; + return std::make_unique(instance, device, std::move(resources)); + } + } + error = "MetalFX requires macOS 13 or newer"; + return {}; +} +} // namespace aurora::webgpu::metalfx diff --git a/aurora-main/lib/webgpu/metalfx_stub.cpp b/aurora-main/lib/webgpu/metalfx_stub.cpp new file mode 100644 index 000000000..7279443cd --- /dev/null +++ b/aurora-main/lib/webgpu/metalfx_stub.cpp @@ -0,0 +1,11 @@ +#include "metalfx.hpp" + +namespace aurora::webgpu::metalfx { +bool supported(const wgpu::Device&, wgpu::BackendType) { return false; } + +std::unique_ptr create(const wgpu::Instance&, const wgpu::Device&, + const Size&, std::string& error) { + error = "MetalFX is not available in this build"; + return {}; +} +} // namespace aurora::webgpu::metalfx diff --git a/aurora-main/tests/metalfx_interop/CMakeLists.txt b/aurora-main/tests/metalfx_interop/CMakeLists.txt new file mode 100644 index 000000000..4f5330dbc --- /dev/null +++ b/aurora-main/tests/metalfx_interop/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.25) +project(metalfx_interop LANGUAGES CXX) + +if(NOT APPLE) + message(FATAL_ERROR "The MetalFX interoperability probe requires macOS") +endif() +enable_language(OBJCXX) +set(CMAKE_OBJCXX_STANDARD 20) +set(CMAKE_OBJCXX_STANDARD_REQUIRED ON) +set(CMAKE_OSX_DEPLOYMENT_TARGET 13.0) + +# Use the same Dawn package as Aurora; no separate download or renderer build. +find_package(Threads REQUIRED) +find_package(Dawn CONFIG REQUIRED) +add_executable(metalfx_interop main.mm ../../lib/webgpu/metalfx.mm) +target_include_directories(metalfx_interop PRIVATE ../../lib) +target_compile_options(metalfx_interop PRIVATE -fobjc-arc -Wall -Wextra) +target_link_libraries(metalfx_interop PRIVATE dawn::webgpu_dawn + "-framework MetalFX" "-framework Metal" "-framework IOSurface" "-framework Foundation") +enable_testing() +add_test(NAME metalfx_interop COMMAND metalfx_interop) +set_tests_properties(metalfx_interop PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) + +add_executable(metalfx_stub_test stub_test.cpp ../../lib/webgpu/metalfx_stub.cpp) +target_include_directories(metalfx_stub_test PRIVATE ../../lib) +target_compile_features(metalfx_stub_test PRIVATE cxx_std_20) +target_link_libraries(metalfx_stub_test PRIVATE dawn::webgpu_dawn) +add_test(NAME metalfx_stub COMMAND metalfx_stub_test) diff --git a/aurora-main/tests/metalfx_interop/README.md b/aurora-main/tests/metalfx_interop/README.md new file mode 100644 index 000000000..7908a6841 --- /dev/null +++ b/aurora-main/tests/metalfx_interop/README.md @@ -0,0 +1,124 @@ +# MetalFX spatial upscaling: renderer integration and tests + +Aurora can now upscale the completed game image with MetalFX before aspect-fit +presentation and ImGui composition. It is opt-in and requires macOS 13+, a +supported Metal device, and Dawn IOSurface/shared-event support. Other backends +and builds without the MetalFX SDK use a stub and the existing presentation path. +The MetalFX framework is weak-linked; the game's deployment target is unchanged. + +## Trying the renderer integration + +Use F10 → Graphics → MetalFX spatial upscaling. Then use the existing +Resolution control to render below the output viewport's size. +Both source dimensions must be smaller than the output dimensions. Equal-size +rendering, supersampling, and unsupported source formats bypass MetalFX. In +particular, Auto (window size) generally offers no upscaling opportunity. + +The F10 toggle is saved in `Config.toml` as +`video.metalfx_spatial_upscaling`. It uses these thread-safe Aurora entry points: + +- `aurora_set_metalfx_spatial(bool)` requests a change at the next sealed frame. +- `aurora_get_metalfx_spatial()` returns the requested setting. +- `aurora_is_metalfx_spatial_supported()` reports device/build support. +- `aurora_get_metalfx_status()` distinguishes Disabled, Unsupported, + Not Upscaling, Active, and Error. A busy resize-retirement pool temporarily + bypasses upscaling and retries on a later frame. Other upscaler errors log a + reason and use normal presentation until a disabled frame resets the error. + +The game's HUD is part of the source image and is upscaled. ImGui/F10/FPS overlays +are composed afterward at output resolution. Existing source-frame captures +still capture the original source image. The interpolation snapshot call sites +all use the same upscaling hook; game-specific interpolation remains untested. + +## GPU path and ownership + +1. Request Dawn's `SharedTextureMemoryIOSurface` and `SharedFenceMTLSharedEvent` + features when the Metal adapter supports both. Use that Dawn device's native + `MTLDevice`, not a separately selected default device. +2. Cache three upscaling slots with IOSurface-backed input and output textures, + a spatial scaler, a private MetalFX output, and shared-event dependencies. + Check texture formats, dimensions, usages, and device size limits on creation. +3. Begin Dawn input access, copy the completed game image at its source size, + and submit the scene plus copy. End input access and wait for Dawn's + `commandsScheduledFuture` before submitting dependent native Metal work. +4. On the native queue, wait for input rendering and any prior Dawn consumption + of the shared output. Encode MetalFX into its required **private** output + texture, then GPU-blit the result into the output IOSurface and signal an event. +5. After native scheduling, begin Dawn output access with that event/value. + Composite the upscaled image into the existing content viewport, retaining + letterboxing, then draw ImGui. Submit and end output access. Reuse observes + both Dawn-to-Metal and Metal-to-Dawn event dependencies. +6. GPU completion callbacks retain resources after a cache entry is replaced, + disabled, or shut down. At most eight resource sets may exist (four current + plus four retiring); rapid resizing cannot allocate an unbounded queue. + +CPU scheduling waits remain, but there are no CPU image transfers or per-frame +GPU-completion waits in the upscaling path. There is one source-size GPU copy +and one full-output GPU blit. Their cost must be measured before promising a +performance gain. The input copy follows the existing perceptual/unorm sampling +path. sRGB texture formats bypass MetalFX to avoid implicit color conversion. + +## Standalone GPU regression test + +The test builds the actual `lib/webgpu/metalfx.mm` implementation. Point +`Dawn_DIR` at the package used by an existing Aurora build: + +```sh +cmake -S aurora-main/tests/metalfx_interop -B build-metalfx-interop \ + -DDawn_DIR="/absolute/path/to/dawn_prebuilt-src/lib/cmake/Dawn" \ + -DCMAKE_BUILD_TYPE=Release +cmake --build build-metalfx-interop +MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 \ + ctest --test-dir build-metalfx-interop --output-on-failure -V +``` + +The GPU test returns 77 (CTest **Skipped**) when no Metal adapter, required +sharing features, or spatial scaler is available. A skip is not evidence of +interoperability. Sandboxed processes may need GPU access. CTest imposes a +60-second timeout. The separate stub test needs no GPU. + +Tested on Apple M3, macOS 26.5.1, using Aurora's existing Dawn package +(`v20260603.191052`). Metal API and GPU validation were enabled: + +| Formats | Input | Output | Frames per format | +| --- | --- | --- | --- | +| RGBA8Unorm, BGRA8Unorm | 64 × 48 | 128 × 96 | 24 | +| RGBA8Unorm, BGRA8Unorm | 320 × 180 | 480 × 270 | 24 | +| RGBA8Unorm, BGRA8Unorm | 960 × 540 | 1920 × 1080 | 24 | + +All 144 frames and 2,304 interior pixel samples passed. Red changes per frame; +green and blue distinguish left/right and top/bottom. All channels are checked +within five 8-bit levels, catching stale images, orientation/channel mistakes, +and missing output. Tests cover 1.5× and 2× scaling, padded readback rows, slot +reuse, dropping wrappers before readback completion, invalid dimensions/sRGB +formats, the eight-set allocation bound, and the unavailable-backend stub. + +Readback is only the test oracle and is absent from the game upscaling path. +These samples do not measure reconstruction quality at edges or race performance. + +## Windowed presentation test + +This optional target exercises Aurora's actual frame submission and presentation +with a synthetic source and an ImGui overlay. It requires no Wii game data and +creates an automatically closing test window. Add the option to an existing +from-source runtime build (the normal dependency/provider options still apply): + +```sh +cmake -S runtime -B build-macos -DCMAKE_BUILD_TYPE=Release \ + -DAURORA_BUILD_METALFX_PRESENTATION_TEST=ON +cmake --build build-macos --target metalfx_presentation_test +MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 \ + ./build-macos/aurora-build/metalfx_presentation_test +``` + +On the same M3, all 84 frames passed with Metal API/GPU validation: disabled, +enabled, window resize, 4:3/16:9 aspect changes, native-size bypass, disable, and +re-enable. Assertions check renderer status and errors; this is not a pixel-level +verification of the window image. The core build and macOS 12 deployment-target +availability compilation also passed. Full Mario Kart gameplay, race performance, +visual quality, Intel Macs, other Apple GPUs, older macOS runtime versions, and +non-macOS full builds remain untested. + +References: [Apple MetalFX](https://developer.apple.com/documentation/metalfx), +[spatial scaler requirements](https://developer.apple.com/documentation/metalfx/mtlfxspatialscaler), +and the installed Dawn `MetalBackend.h` / `webgpu_cpp.h` APIs. diff --git a/aurora-main/tests/metalfx_interop/main.mm b/aurora-main/tests/metalfx_interop/main.mm new file mode 100644 index 000000000..5e62e25fa --- /dev/null +++ b/aurora-main/tests/metalfx_interop/main.mm @@ -0,0 +1,269 @@ +#import +#import +#import +#include "webgpu/metalfx.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr uint64_t kTimeoutNs = 10'000'000'000; +constexpr unsigned kFrames = 24; +std::atomic g_errors{0}; + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void wait(const wgpu::Instance& instance, wgpu::Future future) { + require(instance.WaitAny(future, kTimeoutNs) == wgpu::WaitStatus::Success, + "Dawn operation timed out or failed"); +} + +void runCase(const wgpu::Instance& instance, const wgpu::Device& device, + bool bgra, uint32_t width, uint32_t height, + uint32_t outWidth, uint32_t outHeight) { + const auto format = bgra ? wgpu::TextureFormat::BGRA8Unorm : wgpu::TextureFormat::RGBA8Unorm; + using namespace aurora::webgpu::metalfx; + std::array, 3> slots; + for (auto& slot : slots) { + std::string error; + slot = create(instance, device, {width, height, outWidth, outHeight, format}, error); + if (!slot) { + throw std::runtime_error(error.empty() + ? "MetalFX resource pool was still busy retiring earlier slots" + : error); + } + } + + // Asymmetric quadrants expose channel swaps, vertical flips, and stale frames. + wgpu::ShaderSourceWGSL source{}; + source.code = R"( + @group(0) @binding(0) var params: vec4f; + @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + let p = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3)); + return vec4f(p[i], 0, 1); + } + @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f { + return vec4f(params.x, select(0.2, 0.8, p.x >= params.y / 2), + select(0.3, 0.7, p.y >= params.z / 2), 1); + } + )"; + wgpu::ShaderModuleDescriptor shaderDescriptor{}; + shaderDescriptor.nextInChain = &source; + auto shader = device.CreateShaderModule(&shaderDescriptor); + wgpu::ColorTargetState target{}; + target.format = format; + wgpu::FragmentState fragment{}; + fragment.module = shader; + fragment.entryPoint = "fs"; + fragment.targetCount = 1; + fragment.targets = ⌖ + wgpu::RenderPipelineDescriptor pipelineDescriptor{}; + pipelineDescriptor.vertex.module = shader; + pipelineDescriptor.vertex.entryPoint = "vs"; + pipelineDescriptor.fragment = &fragment; + auto pipeline = device.CreateRenderPipeline(&pipelineDescriptor); + auto dawnQueue = device.GetQueue(); + const uint32_t bytesPerRow = (outWidth * 4 + 255) & ~255u; + const uint64_t readbackSize = uint64_t(bytesPerRow) * outHeight; + std::vector readbacks; + + for (unsigned frame = 0; frame < kFrames; ++frame) { + auto& slot = slots[frame % slots.size()]; + require(slot->begin_input(), "Production MetalFX begin_input failed"); + const std::array params{0.2f + float(frame % 5) * 0.1f, + float(width), float(height), 0}; + wgpu::BufferDescriptor uniformDescriptor{}; + uniformDescriptor.size = sizeof(params); + uniformDescriptor.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst; + auto uniform = device.CreateBuffer(&uniformDescriptor); + dawnQueue.WriteBuffer(uniform, 0, params.data(), sizeof(params)); + wgpu::BindGroupEntry entry{}; + entry.binding = 0; + entry.buffer = uniform; + entry.size = sizeof(params); + wgpu::BindGroupDescriptor bindDescriptor{}; + bindDescriptor.layout = pipeline.GetBindGroupLayout(0); + bindDescriptor.entryCount = 1; + bindDescriptor.entries = &entry; + auto bindGroup = device.CreateBindGroup(&bindDescriptor); + auto encoder = device.CreateCommandEncoder(); + wgpu::RenderPassColorAttachment attachment{}; + attachment.view = slot->input_view(); + attachment.loadOp = wgpu::LoadOp::Clear; + attachment.storeOp = wgpu::StoreOp::Store; + wgpu::RenderPassDescriptor passDescriptor{}; + passDescriptor.colorAttachmentCount = 1; + passDescriptor.colorAttachments = &attachment; + auto pass = encoder.BeginRenderPass(&passDescriptor); + pass.SetPipeline(pipeline); + pass.SetBindGroup(0, bindGroup); + pass.Draw(3); + pass.End(); + auto render = encoder.Finish(); + dawnQueue.Submit(1, &render); + if (!slot->upscale()) throw std::runtime_error(slot->error()); + + wgpu::BufferDescriptor readbackDescriptor{}; + readbackDescriptor.size = readbackSize; + readbackDescriptor.usage = wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::MapRead; + auto readback = device.CreateBuffer(&readbackDescriptor); + encoder = device.CreateCommandEncoder(); + wgpu::TexelCopyTextureInfo copySource{}; + copySource.texture = slot->output_texture(); + wgpu::TexelCopyBufferInfo destination{}; + destination.buffer = readback; + destination.layout.bytesPerRow = bytesPerRow; + destination.layout.rowsPerImage = outHeight; + const wgpu::Extent3D extent{outWidth, outHeight, 1}; + encoder.CopyTextureToBuffer(©Source, &destination, &extent); + auto copy = encoder.Finish(); + dawnQueue.Submit(1, ©); + if (!slot->end_output()) throw std::runtime_error(slot->error()); + readbacks.push_back(std::move(readback)); + } + + // Model toggle/resize immediately after submission, while either queue may + // still be consuming these textures. Production completion callbacks must + // keep the resources alive after the cache drops its wrappers. + slots = {}; + + // Readback is only the test oracle. No CPU image transfer or GPU completion + // wait occurs between Dawn rendering, MetalFX, and Dawn consumption above. + for (unsigned frame = 0; frame < kFrames; ++frame) { + bool mapped = false; + auto& readback = readbacks[frame]; + wait(instance, readback.MapAsync(wgpu::MapMode::Read, 0, readbackSize, + wgpu::CallbackMode::WaitAnyOnly, [&mapped](wgpu::MapAsyncStatus status, wgpu::StringView) { + mapped = status == wgpu::MapAsyncStatus::Success; + })); + require(mapped, "Output readback mapping failed"); + const auto* bytes = static_cast(readback.GetConstMappedRange()); + require(bytes != nullptr, "Output readback pointer is null"); + for (unsigned y = 0; y < 4; ++y) { + for (unsigned x = 0; x < 4; ++x) { + const unsigned px = (2 * x + 1) * outWidth / 8; + const unsigned py = (2 * y + 1) * outHeight / 8; + const auto* pixel = bytes + py * bytesPerRow + px * 4; + const std::array expected{ + 0.2f + float(frame % 5) * 0.1f, x >= 2 ? 0.8f : 0.2f, + y >= 2 ? 0.7f : 0.3f, 1}; + for (unsigned c = 0; c < 4; ++c) { + const unsigned channel = bgra && c != 1 && c != 3 ? 2 - c : c; + if (std::abs(int(pixel[channel]) - int(std::lround(expected[c] * 255))) > 5) { + std::cerr << "Pixel mismatch: frame=" << frame << " x=" << px << " y=" << py + << " channel=" << c << " actual=" << int(pixel[channel]) + << " expected=" << std::lround(expected[c] * 255) << '\n'; + throw std::runtime_error("MetalFX output failed image validation"); + } + } + } + } + readback.Unmap(); + } + require(g_errors.load() == 0, "Dawn reported validation errors or device loss"); + std::cout << "PASS " << (bgra ? "BGRA8" : "RGBA8") << ' ' << width << 'x' << height + << " -> " << outWidth << 'x' << outHeight << ": " << kFrames + << " frames, 3 reused slots, 16 pixel samples/frame\n"; +} + +int run() { + const wgpu::InstanceFeatureName timedWait = wgpu::InstanceFeatureName::TimedWaitAny; + wgpu::InstanceDescriptor instanceDescriptor{}; + instanceDescriptor.requiredFeatureCount = 1; + instanceDescriptor.requiredFeatures = &timedWait; + auto instance = wgpu::CreateInstance(&instanceDescriptor); + require(instance != nullptr, "Dawn instance creation failed"); + wgpu::Adapter adapter; + wgpu::RequestAdapterOptions options{}; + options.backendType = wgpu::BackendType::Metal; + wait(instance, instance.RequestAdapter(&options, wgpu::CallbackMode::WaitAnyOnly, + [&adapter](wgpu::RequestAdapterStatus status, wgpu::Adapter result, wgpu::StringView message) { + if (status == wgpu::RequestAdapterStatus::Success) adapter = std::move(result); + else std::cerr << "Adapter: " << std::string_view(message) << '\n'; + })); + if (!adapter) { std::cout << "SKIP: no Dawn Metal adapter\n"; return 77; } + const std::array features{wgpu::FeatureName::SharedTextureMemoryIOSurface, + wgpu::FeatureName::SharedFenceMTLSharedEvent}; + for (auto feature : features) { + if (!adapter.HasFeature(feature)) { + std::cout << "SKIP: Dawn adapter lacks IOSurface/shared-event interoperability\n"; + return 77; + } + } + wgpu::DeviceDescriptor descriptor{}; + descriptor.requiredFeatureCount = features.size(); + descriptor.requiredFeatures = features.data(); + descriptor.SetUncapturedErrorCallback( + [](const wgpu::Device&, wgpu::ErrorType, wgpu::StringView message) { + ++g_errors; + std::cerr << "Dawn error: " << std::string_view(message) << '\n'; + }); + descriptor.SetDeviceLostCallback(wgpu::CallbackMode::AllowSpontaneous, + [](const wgpu::Device&, wgpu::DeviceLostReason reason, wgpu::StringView message) { + if (reason != wgpu::DeviceLostReason::Destroyed) { + ++g_errors; + std::cerr << "Device lost: " << std::string_view(message) << '\n'; + } + }); + wgpu::Device device; + wait(instance, adapter.RequestDevice(&descriptor, wgpu::CallbackMode::WaitAnyOnly, + [&device](wgpu::RequestDeviceStatus status, wgpu::Device result, wgpu::StringView message) { + if (status == wgpu::RequestDeviceStatus::Success) device = std::move(result); + else std::cerr << "Device: " << std::string_view(message) << '\n'; + })); + require(device != nullptr, "Dawn device creation failed"); + id native = dawn::native::metal::GetMTLDevice(device.Get()); + require(native != nil, "Dawn native Metal device is unavailable"); + std::cout << "GPU: " << native.name.UTF8String << '\n'; + if (!aurora::webgpu::metalfx::supported(device, wgpu::BackendType::Metal)) { + std::cout << "SKIP: GPU does not support MetalFX spatial scaling\n"; + return 77; + } + require(!aurora::webgpu::metalfx::supported(device, wgpu::BackendType::Vulkan), + "MetalFX must reject non-Metal backends"); + std::string error; + require(!aurora::webgpu::metalfx::create(instance, device, + {128, 96, 128, 96, wgpu::TextureFormat::RGBA8Unorm}, error) && !error.empty(), + "MetalFX must reject equal-size input/output"); + require(!aurora::webgpu::metalfx::create(instance, device, + {128, 96, 256, 192, wgpu::TextureFormat::RGBA8UnormSrgb}, error), + "MetalFX must reject implicit sRGB conversion"); + { + using namespace aurora::webgpu::metalfx; + std::array, 8> resources; + for (auto& scaler : resources) { + scaler = create(instance, device, {64, 48, 128, 96, wgpu::TextureFormat::RGBA8Unorm}, error); + require(scaler != nullptr, "Could not fill the MetalFX resource pool"); + } + require(!create(instance, device, {64, 48, 128, 96, wgpu::TextureFormat::RGBA8Unorm}, error) + && error.empty(), "A full retirement pool must defer allocation without a fatal error"); + } + for (bool bgra : {false, true}) { + runCase(instance, device, bgra, 64, 48, 128, 96); + runCase(instance, device, bgra, 320, 180, 480, 270); + runCase(instance, device, bgra, 960, 540, 1920, 1080); + } + return 0; +} +} // namespace + +int main() { + @autoreleasepool { + try { return run(); } + catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << '\n'; + return 1; + } + } +} diff --git a/aurora-main/tests/metalfx_interop/presentation_test.cpp b/aurora-main/tests/metalfx_interop/presentation_test.cpp new file mode 100644 index 000000000..244f746c8 --- /dev/null +++ b/aurora-main/tests/metalfx_interop/presentation_test.cpp @@ -0,0 +1,124 @@ +// Explicitly opted-in windowed test of Aurora's real frame/presentation path. +// No Wii game data is needed; the source override supplies a synthetic image. +#include +#include +#include + +#include "webgpu/gpu.hpp" +#include "window.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +std::atomic g_errors{0}; + +void log_message(AuroraLogLevel level, const char* module, const char* message, unsigned len) { + if (level >= LOG_ERROR) ++g_errors; + if (level >= LOG_WARNING || std::string_view(message, len).find("MetalFX") != std::string_view::npos) + std::fprintf(stderr, "[%s] %.*s\n", module, static_cast(len), message); + if (level == LOG_FATAL) std::abort(); +} + +void require(bool value, const char* message) { + if (!value) throw std::runtime_error(message); +} + +void draw_frames(uint32_t width, uint32_t height, AuroraMetalFXStatus expected) { + using namespace aurora::webgpu; + auto source = create_render_texture(width, height, false); + auto bindGroup = create_copy_bind_group(source); + std::vector pixels(size_t(width) * height); + for (uint32_t y = 0; y < height; ++y) { + for (uint32_t x = 0; x < width; ++x) { + pixels[size_t(y) * width + x] = 0xff000000u | ((x / 16 % 2) ? 0x00bb55u : 0xbb5500u); + } + } + wgpu::TexelCopyTextureInfo target{}; + target.texture = source.texture; + wgpu::TexelCopyBufferLayout layout{}; + layout.bytesPerRow = width * 4; + layout.rowsPerImage = height; + g_queue.WriteTexture(&target, pixels.data(), pixels.size() * sizeof(uint32_t), &layout, &source.size); + + unsigned rendered = 0; + unsigned matchingStatus = 0; + for (unsigned attempt = 0; attempt < 300 && rendered < 12; ++attempt) { + aurora_update(); + if (!aurora_begin_frame()) { SDL_Delay(5); continue; } + set_present_source_override(bindGroup, source.texture, source.size, source.format); + ImGui::SetNextWindowPos(ImVec2(12, 12), ImGuiCond_Always); + ImGui::Begin("MetalFX presentation test", nullptr, ImGuiWindowFlags_AlwaysAutoResize); + ImGui::TextUnformatted("Output-resolution overlay after game upscaling"); + ImGui::Text("Source: %u x %u", width, height); + ImGui::End(); + aurora_end_frame(); + aurora_wait_for_frame_worker(); + const auto status = aurora_get_metalfx_status(); + require(status != AURORA_METALFX_ERROR, "MetalFX reported a presentation error"); + if (status == expected) ++matchingStatus; + ++rendered; + } + require(rendered == 12 && matchingStatus >= 9, "Presentation did not reach the expected MetalFX state"); + require(g_errors.load() == 0, "Aurora reported an error"); + std::printf("PASS presentation source=%ux%u status=%d frames=%u\n", width, height, expected, rendered); +} +} // namespace + +int main(int argc, char** argv) { + const auto cache = std::filesystem::temp_directory_path() / + ("aurora-metalfx-presentation-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(cache); + const auto path = cache.string(); + AuroraConfig config{}; + config.appName = "MetalFX presentation test"; + config.userPath = path.c_str(); + config.cachePath = path.c_str(); + config.resourcesPath = path.c_str(); + config.desiredBackend = BACKEND_METAL; + config.windowWidth = 640; + config.windowHeight = 480; + config.msaa = 1; + config.maxTextureAnisotropy = 1; + config.logCallback = log_message; + config.logLevel = LOG_INFO; + aurora_initialize(argc, argv, &config); + int result = 0; + try { + if (!aurora_is_metalfx_spatial_supported()) { + std::puts("SKIP: MetalFX spatial scaling is unavailable"); + result = 77; + } else { + aurora::window::lock_present_aspect_ratio(4, 3); + aurora_set_metalfx_spatial(false); + require(!aurora_get_metalfx_spatial(), "Disable request was not retained"); + draw_frames(320, 240, AURORA_METALFX_DISABLED); + aurora_set_metalfx_spatial(true); + require(aurora_get_metalfx_spatial(), "Enable request was not retained"); + draw_frames(320, 240, AURORA_METALFX_ACTIVE); + aurora::window::set_window_size(800, 500); + draw_frames(320, 240, AURORA_METALFX_ACTIVE); + aurora::window::lock_present_aspect_ratio(16, 9); + draw_frames(320, 240, AURORA_METALFX_ACTIVE); + const auto output = aurora::window::get_window_size(); + draw_frames(output.native_fb_width, output.native_fb_height, AURORA_METALFX_NOT_UPSCALING); + aurora_set_metalfx_spatial(false); + draw_frames(320, 240, AURORA_METALFX_DISABLED); + aurora_set_metalfx_spatial(true); + draw_frames(320, 240, AURORA_METALFX_ACTIVE); + } + } catch (const std::exception& error) { + std::fprintf(stderr, "FAIL: %s\n", error.what()); + result = 1; + } + aurora_shutdown(); + std::error_code cleanupError; + std::filesystem::remove_all(cache, cleanupError); + return result; +} diff --git a/aurora-main/tests/metalfx_interop/stub_test.cpp b/aurora-main/tests/metalfx_interop/stub_test.cpp new file mode 100644 index 000000000..fc4bc8dc4 --- /dev/null +++ b/aurora-main/tests/metalfx_interop/stub_test.cpp @@ -0,0 +1,9 @@ +#include "webgpu/metalfx.hpp" + +int main() { + using namespace aurora::webgpu::metalfx; + if (supported({}, wgpu::BackendType::Vulkan) || supported({}, wgpu::BackendType::Metal)) return 1; + std::string error; + if (create({}, {}, {640, 480, 1280, 960, wgpu::TextureFormat::RGBA8Unorm}, error)) return 1; + return error.empty() ? 1 : 0; +} diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 45e442cbe..062f230c4 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -302,6 +302,13 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform) target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17) add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests) +add_executable(mkw_runtime_config_tests "${CMAKE_CURRENT_LIST_DIR}/tests/runtime_config_tests.cpp") +target_include_directories(mkw_runtime_config_tests PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/include" + "${CMAKE_CURRENT_LIST_DIR}/third_party/toml11") +target_compile_features(mkw_runtime_config_tests PRIVATE cxx_std_20) +add_test(NAME mkw_runtime_config_tests COMMAND mkw_runtime_config_tests) + add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp") target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17) diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index c8cdf80f5..82eff2698 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -41,6 +41,7 @@ struct RuntimeUserConfig { std::optional graphicsApi; std::optional displayMode; std::optional frameInterpolationFps; + std::optional metalFxSpatialUpscaling; std::optional skipUnreadyPipelines; std::optional disableCopyFilter; std::optional textureReplacements; @@ -452,6 +453,8 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { config.frameInterpolationFps = migrated; } } + config.metalFxSpatialUpscaling = + FindConfigValue(document, "video", "metalfx_spatial_upscaling"); config.skipUnreadyPipelines = FindConfigValue(document, "video", "skip_unready_pipelines"); config.disableCopyFilter = FindConfigValue(document, "video", "disable_copy_filter"); config.showFps = FindConfigValue(document, "video", "show_fps"); @@ -653,6 +656,11 @@ inline bool SetFrameInterpolationFps(uint32_t value) { return WriteSetting("video", "frame_interpolation_fps", std::to_string(value)); } +inline bool SetMetalFxSpatialUpscaling(bool value) { + Mutable().metalFxSpatialUpscaling = value; + return WriteSetting("video", "metalfx_spatial_upscaling", value ? "true" : "false"); +} + inline bool SetDisplayMode(std::string value) { if (!IsSupportedDisplayMode(value)) { return false; @@ -790,6 +798,10 @@ inline float ResolutionMultiplier(float fallback = 1.0f) { return std::max(0.0f, Get().resolutionMultiplier.value_or(fallback)); } +inline bool MetalFxSpatialUpscaling(bool fallback = false) { + return Get().metalFxSpatialUpscaling.value_or(fallback); +} + inline float AudioVolume(float fallback = 1.0f) { return std::clamp(Get().audioVolume.value_or(fallback), 0.0f, 1.0f); } diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 8856ba5b0..6efe65c24 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -104,6 +104,9 @@ int g_displayMode = [] { bool g_skipUnreadyPipelines = RuntimeConfigFile::SkipUnreadyPipelines(true); bool g_disableCopyFilter = RuntimeConfigFile::DisableCopyFilter(true); bool g_showFps = RuntimeConfigFile::ShowFps(true); +#if defined(__APPLE__) +bool g_metalFxSpatialUpscaling = RuntimeConfigFile::MetalFxSpatialUpscaling(false); +#endif uint32_t g_disabledPostProcessingPaths = RuntimeConfigFile::DisabledPostProcessingPaths(0); std::array g_configuredControllerIndices = [] { std::array indices{}; @@ -823,6 +826,36 @@ void DrawGraphicsSettings() { ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); ImGui::TextDisabled("Frame interpolation is experimental, you might find visual artifacts"); ImGui::PopTextWrapPos(); +#if defined(__APPLE__) + const bool metalFxSupported = aurora_is_metalfx_spatial_supported(); + ImGui::BeginDisabled(!metalFxSupported); + if (ImGui::Checkbox("MetalFX spatial upscaling", &g_metalFxSpatialUpscaling)) { + aurora_set_metalfx_spatial(g_metalFxSpatialUpscaling); + RuntimeConfigFile::SetMetalFxSpatialUpscaling(g_metalFxSpatialUpscaling); + } + ImGui::EndDisabled(); + switch (aurora_get_metalfx_status()) { + case AURORA_METALFX_ACTIVE: + ImGui::TextDisabled("Active: upscaling the game image before the overlay."); + break; + case AURORA_METALFX_NOT_UPSCALING: + ImGui::TextDisabled("Choose a lower internal resolution to use MetalFX."); + break; + case AURORA_METALFX_UNSUPPORTED: + ImGui::TextDisabled("Requires macOS 13+, Metal, and a MetalFX-capable GPU."); + break; + case AURORA_METALFX_ERROR: + ImGui::TextDisabled("Unavailable after a renderer error; toggle off and on to retry."); + break; + case AURORA_METALFX_DISABLED: + if (metalFxSupported) { + ImGui::TextDisabled("Render below output resolution for sharper lower-cost output."); + } else { + ImGui::TextDisabled("MetalFX spatial upscaling is unavailable on this device."); + } + break; + } +#endif if (ImGui::Checkbox("Disable copy filter", &g_disableCopyFilter)) { aurora_set_disable_copy_filter(g_disableCopyFilter); RuntimeConfigFile::SetDisableCopyFilter(g_disableCopyFilter); @@ -1049,6 +1082,9 @@ void InitializeRuntimeSettings() noexcept { MusicAttenuation::SetVoicesVolume(static_cast(g_voicesVolumePercent) / 100.0f); MusicAttenuation::SetEnabled(g_attenuateMusicWhenMediaPlays); RuntimeGameGraphicsOptions::SetDisabledPostProcessingPaths(g_disabledPostProcessingPaths); +#if defined(__APPLE__) + aurora_set_metalfx_spatial(g_metalFxSpatialUpscaling); +#endif const uint32_t targetFps = kFrameInterpolationTargetFps[static_cast(g_frameInterpolationMode)]; LimitResolutionForFrameRate(); aurora_set_frame_interpolation_fps(targetFps); diff --git a/runtime/tests/runtime_config_tests.cpp b/runtime/tests/runtime_config_tests.cpp new file mode 100644 index 000000000..337d40a95 --- /dev/null +++ b/runtime/tests/runtime_config_tests.cpp @@ -0,0 +1,35 @@ +#include "runtime_config.h" + +#include +#include + +namespace { +bool Require(bool value, const char* message) { + if (!value) { + std::cerr << message << '\n'; + return false; + } + return true; +} +} // namespace + +int main() { + { + std::istringstream input("[video]\nmetalfx_spatial_upscaling = true\n"); + const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "enabled.toml"); + if (!Require(config.metalFxSpatialUpscaling == std::optional{true}, + "valid MetalFX setting was not loaded")) return 1; + } + { + std::istringstream input("[video]\nmetalfx_spatial_upscaling = false\n"); + const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "disabled.toml"); + if (!Require(config.metalFxSpatialUpscaling == std::optional{false}, + "false MetalFX setting was not loaded")) return 1; + } + { + std::istringstream input("[video]\nmetalfx_spatial_upscaling = \"yes\"\n"); + const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "invalid.toml"); + if (!Require(!config.metalFxSpatialUpscaling, "invalid MetalFX setting was accepted")) return 1; + } + return 0; +}