diff --git a/src/spice2x/api/h264_stream.cpp b/src/spice2x/api/h264_stream.cpp index 7774220..b51c5cb 100644 --- a/src/spice2x/api/h264_stream.cpp +++ b/src/spice2x/api/h264_stream.cpp @@ -118,7 +118,13 @@ namespace api { param.i_height = height; param.i_fps_num = this->fps; param.i_fps_den = 1; - param.i_threads = 1; + + // sliced threading, which zerolatency already selected, so a frame is split + // across workers rather than held back to be reordered. deliberately not the + // automatic count: this shares a machine with the game it is capturing, and + // taking every core to encode would win back frames at the game's expense + param.i_threads = 4; + param.b_annexb = 1; // SPS/PPS ahead of every IDR, so a client can start decoding cold param.b_repeat_headers = 1; diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp index 0ff5452..966f943 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp @@ -944,9 +944,12 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3D9::CreateDevice( } else if (!D3D9_DEVICE_HOOK_DISABLE) { graphics_hook_window(hFocusWindow, pPresentationParameters); - *ppReturnedDeviceInterface = new WrappedIDirect3DDevice9( + auto *wrapped = new WrappedIDirect3DDevice9( hFocusWindow, *ppReturnedDeviceInterface); + + wrapped->device_multithreaded = (BehaviorFlags & D3DCREATE_MULTITHREADED) != 0; + *ppReturnedDeviceInterface = wrapped; } // return result @@ -1307,13 +1310,16 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3D9::CreateDeviceEx( } else if (!D3D9_DEVICE_HOOK_DISABLE) { graphics_hook_window(hFocusWindow, pPresentationParameters); - *ppReturnedDeviceInterface = new WrappedIDirect3DDevice9( + auto *wrapped = new WrappedIDirect3DDevice9( hFocusWindow, *ppReturnedDeviceInterface, gfdm_parameters.logical_small_swapchain, gfdm_two_head_exclusive() ? static_cast(this) : nullptr, gfdm_two_head_exclusive() ? pPresentationParameters : nullptr); + wrapped->device_multithreaded = (BehaviorFlags & D3DCREATE_MULTITHREADED) != 0; + *ppReturnedDeviceInterface = wrapped; + // initialize sub screen if the game requested a multi-head context if (avs::game::is_model({"LDJ", "KFC", "M39", "M32"}) && (orig_behavior_flags & D3DCREATE_ADAPTERGROUP_DEVICE)) { diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp index 26bb855..34a8863 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp @@ -634,6 +634,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::Reset( overlay::OVERLAY->reset_invalidate(); } + // Reset refuses to run while any default pool resource is outstanding + d3d9_readback::discard_snapshot_targets(pReal); + HRESULT res = pReal->Reset(pPresentationParameters); // recreate overlay @@ -2321,6 +2324,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::ResetEx( overlay::OVERLAY->reset_invalidate(); } + // ResetEx refuses to run while any default pool resource is outstanding + d3d9_readback::discard_snapshot_targets(pReal); + HRESULT res = static_cast(pReal)->ResetEx( gfdm_parameters.presentation_parameters, gfdm_parameters.fullscreen_display_modes); diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.h b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.h index f5b517c..586f3c4 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.h +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.h @@ -264,6 +264,10 @@ struct WrappedIDirect3DDevice9 : IDirect3DDevice9Ex { IDirect3DDevice9 *pReal; bool is_d3d9ex = false; + // set from the creation flags; without it the runtime does no locking of its own, so + // nothing but the present thread may touch the device + bool device_multithreaded = false; + std::atomic_ulong refs = 1; WrappedIDirect3DSwapChain9 *main_swapchain = nullptr; diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_readback.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_readback.cpp index 1f36d77..5de25af 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_readback.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_readback.cpp @@ -1,5 +1,8 @@ #include "d3d9_readback.h" +#include +#include +#include #include #include @@ -10,6 +13,13 @@ namespace d3d9_readback { namespace { +// the snapshot path stamps frames with this to recognise one left behind by a break in the +// request stream +uint64_t now_us() { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} + SurfacePtr create_readback_surface(IDirect3DDevice9 *device, const D3DSURFACE_DESC &desc) { IDirect3DSurface9 *surface = nullptr; const HRESULT hr = device->CreateOffscreenPlainSurface( @@ -160,21 +170,8 @@ ReadbackPool &pool() { return *instance; } -} // namespace - -void release_device_resources(IDirect3DDevice9 *device) { - pool().clear_device(device); -} - -BackbufferCopy::~BackbufferCopy() { - if (this->pooled && this->surface) { - pool().release(this->device, std::move(this->surface)); - } -} - -std::optional acquire_backbuffer_copy( - IDirect3DDevice9 *device, IDirect3DSwapChain9 *swap_chain, int screen, bool pooled) { - +// the back buffer plus the checks every caller has to make before copying out of it +SurfacePtr open_backbuffer(IDirect3DSwapChain9 *swap_chain, int screen, D3DSURFACE_DESC &desc) { IDirect3DSurface9 *buffer = nullptr; HRESULT hr = swap_chain->GetBackBuffer(0, D3DBACKBUFFER_TYPE_MONO, &buffer); if (FAILED(hr) || buffer == nullptr) { @@ -182,17 +179,17 @@ std::optional acquire_backbuffer_copy( "failed to get back buffer for screen {}, hr={}", screen, FMT_HRESULT(hr)); - return std::nullopt; + return nullptr; } - D3DSURFACE_DESC desc {}; - hr = buffer->GetDesc(&desc); + SurfacePtr surface(buffer); + + hr = surface->GetDesc(&desc); if (FAILED(hr)) { log_warning("graphics::d3d9", "failed to acquire back buffer descriptor, hr={}", FMT_HRESULT(hr)); - buffer->Release(); - return std::nullopt; + return nullptr; } // GetRenderTargetData rejects multisampled sources. no supported game has been @@ -204,7 +201,399 @@ std::optional acquire_backbuffer_copy( "back buffer is multisampled ({}), screenshots and capture are unsupported", static_cast(desc.MultiSampleType)); }); - buffer->Release(); + return nullptr; + } + + return surface; +} + +SurfacePtr create_snapshot_target(IDirect3DDevice9 *device, const D3DSURFACE_DESC &desc) { + IDirect3DSurface9 *surface = nullptr; + + // matching the back buffer keeps the blit a straight copy and leaves the pixels in the + // format the conversion step would have seen without the detour + const HRESULT hr = device->CreateRenderTarget( + desc.Width, desc.Height, desc.Format, + D3DMULTISAMPLE_NONE, 0, FALSE, &surface, nullptr); + + if (FAILED(hr) || surface == nullptr) { + log_warning("graphics::d3d9", + "failed to create snapshot target, hr={}", + FMT_HRESULT(hr)); + return nullptr; + } + + return SurfacePtr(surface); +} + +// two render targets per screen, reused across frames. two because one holds the frame waiting +// out its deferral while the other takes the next one; a read only holds its target until the +// pixels are in system memory, which is far shorter than the gap between requests. +// +// unlike the readback surfaces these live in the default pool, so they have to be gone before a +// Reset and not merely before the device is released +class SnapshotTargets { +public: + SurfacePtr acquire( + IDirect3DDevice9 *device, + int screen, + const D3DSURFACE_DESC &desc, + uint64_t *out_generation) { + + if (screen < 0 || screen >= static_cast(GRAPHICS_CAPTURE_SCREEN_NO)) { + return nullptr; + } + + std::lock_guard lock(this->mutex); + + if (this->device != device) { + this->drop(); + this->device = device; + this->usable = true; + } + + auto &entry = this->screens[screen]; + + Slot *free_slot = nullptr; + for (size_t i = 0; i < SLOTS_PER_SCREEN; i++) { + auto &candidate = entry.slots[(entry.next + i) % SLOTS_PER_SCREEN]; + if (!candidate.busy) { + free_slot = &candidate; + entry.next = (entry.next + i + 1) % SLOTS_PER_SCREEN; + break; + } + } + + if (free_slot == nullptr) { + return nullptr; + } + + auto &slot = *free_slot; + + if (slot.target + && (slot.width != desc.Width + || slot.height != desc.Height + || slot.format != desc.Format)) { + slot.target.reset(); + } + + if (!slot.target) { + slot.target = create_snapshot_target(device, desc); + if (!slot.target) { + // a back buffer format the device will not give us a render target for. the + // inline path can still read it, so stop trying rather than lose the stream + this->usable = false; + return nullptr; + } + + slot.width = desc.Width; + slot.height = desc.Height; + slot.format = desc.Format; + } + + slot.busy = true; + *out_generation = this->current_generation; + + slot.target->AddRef(); + return SurfacePtr(slot.target.get()); + } + + // holds the fresh snapshot back and returns the one from the previous request, which by now + // has had a full frame for its blit and transfer to land + std::optional rotate(int screen, std::optional fresh) { + if (screen < 0 || screen >= static_cast(GRAPHICS_CAPTURE_SCREEN_NO)) { + return std::nullopt; + } + + const uint64_t now = now_us(); + std::optional previous; + + { + std::lock_guard lock(this->mutex); + previous = std::move(this->pending[screen]); + this->pending[screen] = std::move(fresh); + } + + // a frame waits here for the next request rather than for a deadline, so a gap in + // requests, a client reconnecting most obviously, would otherwise hand the new session + // a frame from before the gap. showing a stale frame is worse than showing none + if (previous.has_value() && now - previous->issued_us > MAX_DEFERRAL_US) { + return std::nullopt; + } + + return previous; + } + + // a reader whose target was already thrown away must not free the slot, or it would free + // the snapshot that replaced it while that one is still being read + void finish(int screen, uint64_t generation, IDirect3DSurface9 *target) { + if (screen < 0 || screen >= static_cast(GRAPHICS_CAPTURE_SCREEN_NO)) { + return; + } + + { + std::lock_guard lock(this->mutex); + if (generation != this->current_generation) { + return; + } + + this->free_slot(screen, target); + } + + this->idle.notify_all(); + } + + bool still_current(uint64_t generation) { + std::lock_guard lock(this->mutex); + return generation == this->current_generation; + } + + bool is_usable() { + std::lock_guard lock(this->mutex); + return this->usable; + } + + // Reset fails outright while the device still owns default pool resources, so dropping our + // own references is not enough and any read in flight has to finish first. the wait is + // bounded because a failed Reset is something games retry and a stalled present thread is + // not something they survive + void discard(IDirect3DDevice9 *device) { + // destroyed after the lock is released, since dropping a snapshot calls back in here + std::array, GRAPHICS_CAPTURE_SCREEN_NO> stale; + + { + std::unique_lock lock(this->mutex); + if (this->device != nullptr && this->device != device) { + return; + } + + // deferred frames are abandoned rather than waited for; only a read that is already + // running has to be allowed to finish + for (auto &held : this->pending) { + if (held.has_value() && held->surface) { + this->free_slot(held->screen, held->surface.get()); + } + } + + stale = std::move(this->pending); + this->pending = {}; + + const bool drained = this->idle.wait_for( + lock, + std::chrono::milliseconds(100), + [this] { return !this->any_busy(); }); + + if (!drained) { + log_warning("graphics::d3d9", + "capture snapshot still in flight, discarding its target anyway"); + } + + this->drop(); + this->device = nullptr; + } + } + +private: + static constexpr size_t SLOTS_PER_SCREEN = 2; + + // generous next to the frame interval this is meant to bridge, so that ordinary jitter + // never trips it and only a real break in the request stream does + static constexpr uint64_t MAX_DEFERRAL_US = 250'000; + + struct Slot { + SurfacePtr target; + UINT width = 0; + UINT height = 0; + D3DFORMAT format = D3DFMT_UNKNOWN; + bool busy = false; + }; + + struct Screen { + std::array slots; + size_t next = 0; + }; + + void free_slot(int screen, IDirect3DSurface9 *target) { + for (auto &slot : this->screens[screen].slots) { + if (slot.target.get() == target) { + slot.busy = false; + return; + } + } + } + + bool any_busy() const { + for (const auto &entry : this->screens) { + for (const auto &slot : entry.slots) { + if (slot.busy) { + return true; + } + } + } + + return false; + } + + void drop() { + for (auto &entry : this->screens) { + for (auto &slot : entry.slots) { + slot.target.reset(); + slot.busy = false; + } + + entry.next = 0; + } + + this->current_generation++; + } + + std::mutex mutex; + std::condition_variable idle; + std::array screens; + std::array, GRAPHICS_CAPTURE_SCREEN_NO> pending; + IDirect3DDevice9 *device = nullptr; + uint64_t current_generation = 1; + bool usable = true; +}; + +// never destroyed, for the same reason the readback pool is not +SnapshotTargets &targets() { + static SnapshotTargets *instance = new SnapshotTargets(); + return *instance; +} + +} // namespace + +void release_device_resources(IDirect3DDevice9 *device) { + targets().discard(device); + pool().clear_device(device); +} + +void discard_snapshot_targets(IDirect3DDevice9 *device) { + targets().discard(device); +} + +bool snapshots_supported() { + return targets().is_usable(); +} + +BackbufferCopy::~BackbufferCopy() { + if (this->pooled && this->surface) { + pool().release(this->device, std::move(this->surface)); + } +} + +Snapshot::~Snapshot() { + // still holding the target means the read never ran, and the slot would otherwise stay + // marked busy and take the screen out of capture permanently + if (this->surface) { + targets().finish(this->screen, this->generation, this->surface.get()); + } +} + +namespace { + +std::optional take_snapshot( + IDirect3DDevice9 *device, IDirect3DSwapChain9 *swap_chain, int screen) { + + const uint64_t started_us = now_us(); + + D3DSURFACE_DESC desc {}; + auto buffer = open_backbuffer(swap_chain, screen, desc); + if (!buffer) { + return std::nullopt; + } + + uint64_t generation = 0; + auto target = targets().acquire(device, screen, desc, &generation); + if (!target) { + return std::nullopt; + } + + // built before the blit so that a failure below hands the slot back through the destructor + Snapshot snapshot; + snapshot.screen = screen; + snapshot.desc = desc; + snapshot.device = device; + snapshot.surface = std::move(target); + snapshot.generation = generation; + snapshot.issued_us = started_us; + + // the point of the whole exercise: this is queued rather than waited on, so the game pays + // for issuing the copy and not for it completing. identical size and format, so there is + // no filtering to ask for + const HRESULT hr = device->StretchRect( + buffer.get(), nullptr, snapshot.surface.get(), nullptr, D3DTEXF_NONE); + + if (FAILED(hr)) { + log_warning("graphics::d3d9", + "failed to snapshot back buffer for screen {}, hr={}", + screen, + FMT_HRESULT(hr)); + return std::nullopt; + } + + return snapshot; +} + +} // namespace + +std::optional snapshot_backbuffer( + IDirect3DDevice9 *device, IDirect3DSwapChain9 *swap_chain, int screen) { + + return targets().rotate(screen, take_snapshot(device, swap_chain, screen)); +} + +std::optional read_snapshot(Snapshot snapshot) { + if (!snapshot.surface) { + return std::nullopt; + } + + // a Reset between the blit and now means the target no longer holds the captured frame + if (!targets().still_current(snapshot.generation)) { + return std::nullopt; + } + + auto destination = pool().acquire(snapshot.device, snapshot.desc); + if (!destination) { + return std::nullopt; + } + + const HRESULT hr = snapshot.device->GetRenderTargetData( + snapshot.surface.get(), destination.get()); + + // the target is reusable as soon as the pixels are in system memory. dropping the + // reference before freeing the slot keeps the destructor from freeing it twice + const int screen = snapshot.screen; + const uint64_t generation = snapshot.generation; + IDirect3DSurface9 *target = snapshot.surface.get(); + snapshot.surface.reset(); + targets().finish(screen, generation, target); + + if (FAILED(hr)) { + log_warning("graphics::d3d9", + "failed to read snapshot contents, hr={}", + FMT_HRESULT(hr)); + pool().release(snapshot.device, std::move(destination)); + return std::nullopt; + } + + BackbufferCopy copy; + copy.screen = screen; + copy.desc = snapshot.desc; + copy.device = snapshot.device; + copy.surface = std::move(destination); + copy.pooled = true; + + return copy; +} + +std::optional acquire_backbuffer_copy( + IDirect3DDevice9 *device, IDirect3DSwapChain9 *swap_chain, int screen, bool pooled) { + + D3DSURFACE_DESC desc {}; + auto buffer = open_backbuffer(swap_chain, screen, desc); + if (!buffer) { return std::nullopt; } @@ -212,12 +601,10 @@ std::optional acquire_backbuffer_copy( ? pool().acquire(device, desc) : create_readback_surface(device, desc); if (!destination) { - buffer->Release(); return std::nullopt; } - hr = device->GetRenderTargetData(buffer, destination.get()); - buffer->Release(); + const HRESULT hr = device->GetRenderTargetData(buffer.get(), destination.get()); if (FAILED(hr)) { log_warning("graphics::d3d9", diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_readback.h b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_readback.h index d49e595..017f3e4 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_readback.h +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_readback.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -39,6 +40,51 @@ namespace d3d9_readback { int screen, bool pooled); + // GPU side copy of a back buffer, taken while the contents are still the frame that was + // presented, so that reading them into system memory no longer has to happen before it + struct Snapshot { + int screen {}; + D3DSURFACE_DESC desc {}; + IDirect3DDevice9 *device = nullptr; + SurfacePtr surface; + uint64_t generation {}; + + // when the blit was issued, so a frame left behind by a break in the request stream can + // be recognised as stale rather than handed over + uint64_t issued_us {}; + + Snapshot() = default; + Snapshot(Snapshot &&) noexcept = default; + Snapshot &operator=(Snapshot &&) noexcept = default; + Snapshot(const Snapshot &) = delete; + Snapshot &operator=(const Snapshot &) = delete; + ~Snapshot(); + }; + + // for the present thread, between the last EndScene and Present. blits the current frame, + // then returns the snapshot taken on the *previous* call: waiting a frame before reading + // means the blit and its system memory transfer have already happened, so the read does not + // stall on the GPU. costs the stream one frame of latency. + // + // returns nothing on the first call of a stream, and whenever the frame could not be taken, + // which is the caller's cue to skip rather than to wait + std::optional snapshot_backbuffer( + IDirect3DDevice9 *device, + IDirect3DSwapChain9 *swap_chain, + int screen); + + // the expensive half, for a thread that is not the present thread. only legal on a device + // created with D3DCREATE_MULTITHREADED + std::optional read_snapshot(Snapshot snapshot); + + // false once a device has refused to give up a render target matching its back buffer, + // which leaves reading the back buffer directly as the only way to capture it + bool snapshots_supported(); + + // snapshot targets live in the default pool, so unlike the readback surfaces they have to + // be gone before a Reset and not merely before the device is released + void discard_snapshot_targets(IDirect3DDevice9 *device); + // pooled surfaces hold references on the device; call this before releasing it void release_device_resources(IDirect3DDevice9 *device); } diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_screenshot.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_screenshot.cpp index ed8ddfb..0dc90e2 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_screenshot.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_screenshot.cpp @@ -488,6 +488,63 @@ ThreadPool &capture_read_pool() { return *instance; } +// Takes the frame on the present thread as a queued GPU blit and hands the readback to a pool +// thread, so the game waits for neither. Only viable where the whole read can go off thread, +// since the back buffer is overwritten right after Present and a snapshot the present thread +// then had to read itself would cost more than reading the back buffer directly. +// +// Returns false when the frame could not be taken, including the ordinary case of the previous +// snapshot of this screen still being read, which paces capture to what the reader sustains. +static bool snapshot_capture( + IDirect3DDevice9 *device, + WrappedIDirect3DDevice9 *wrapped_device, + int screen) { + + IDirect3DSwapChain9 *swap_chain = nullptr; + const HRESULT hr = wrapped_device->get_screenshot_swap_chain(screen, &swap_chain); + if (FAILED(hr) || swap_chain == nullptr) { + log_warning("graphics::d3d9", + "failed to get swap chain for screen {}, hr={}", + screen, + FMT_HRESULT(hr)); + return false; + } + + auto snapshot = d3d9_readback::snapshot_backbuffer(device, swap_chain, screen); + swap_chain->Release(); + + if (!snapshot.has_value()) { + return false; + } + + try { + capture_read_pool().add([screen, snapshot = std::move(*snapshot)]() mutable { + // an escape from here would cross a thread boundary and terminate + try { + auto copy = d3d9_readback::read_snapshot(std::move(snapshot)); + if (!copy.has_value()) { + graphics_capture_skip(screen); + return; + } + + read_and_dispatch_capture(screen, std::move(*copy)); + } catch (const std::exception &error) { + log_warning("graphics::d3d9", "capture read failed: {}", error.what()); + graphics_capture_skip(screen); + } catch (...) { + log_warning("graphics::d3d9", "capture read failed"); + graphics_capture_skip(screen); + } + }); + } catch (const std::exception &) { + // the snapshot went into the lambda before the queue could fail, so it is already + // destroyed and its target handed back; the client just misses this frame + return false; + } + + return true; +} + // by this point the pixels are plain memory, so none of this needs the device static void dispatch_screenshot_save(std::vector writes, size_t screen_count) { auto screenshot_process = [writes = std::move(writes), screen_count]() mutable { @@ -632,6 +689,18 @@ static void process_image_request( const ImageRequest &request) { const bool screenshot = request.kind == ImageRequestKind::Screenshot; + if (!screenshot + && wrapped_device->device_multithreaded + && capture_read_off_thread(request.screen) + && d3d9_readback::snapshots_supported()) { + + if (!snapshot_capture(device, wrapped_device, request.screen)) { + graphics_capture_skip(request.screen); + } + + return; + } + std::vector screens { request.screen }; if (screenshot && GRAPHICS_SCREENSHOT_SUBSCREENS) { screens.clear();