diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp index 7fc8d3a..651c6f7 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp @@ -11,6 +11,14 @@ * its viewport into an offscreen frame buffer and presents the finished * frame with a single blit, so every layer uses the same transformation and * the visible surface never shows a partially drawn frame. + * + * Pan caching: the last fully rendered layer frame is kept in a second + * offscreen buffer. While the user drags the map (WM_LBUTTONDOWN through + * WM_LBUTTONUP), OnDraw paints the background and blits the cached frame at + * the accumulated pixel offset instead of re-rasterizing every layer on + * each WM_MOUSEMOVE; the drag-end handler invalidates the cache so the new + * view is rasterized once. The cache is invalidated on layer add/remove/ + * clear, zoom, resize, and backend-preference changes. */ #include "MapWinGISControl.h" @@ -470,6 +478,7 @@ HRESULT CMapWinGISControl::LoadVectorDatasetImpl( { *layerId = convertedId; } + InvalidateLayerCache(); FireViewChange(); return S_OK; } @@ -492,6 +501,7 @@ STDMETHODIMP CMapWinGISControl::ClearLayers() if (_view.ClearLayers()) { _featureCount = 0; + InvalidateLayerCache(); FireViewChange(); } return S_OK; @@ -570,6 +580,7 @@ STDMETHODIMP CMapWinGISControl::put_RenderingBackendPreference(BSTR preference) return E_INVALIDARG; } + InvalidateLayerCache(); FireViewChange(); return S_OK; } @@ -582,6 +593,7 @@ STDMETHODIMP CMapWinGISControl::FitToMap() changed); if (SUCCEEDED(result) && changed) { + InvalidateLayerCache(); FireViewChange(); } return result; @@ -595,6 +607,7 @@ STDMETHODIMP CMapWinGISControl::ZoomIn(const DOUBLE factor) changed); if (SUCCEEDED(result) && changed) { + InvalidateLayerCache(); FireViewChange(); } return result; @@ -608,6 +621,7 @@ STDMETHODIMP CMapWinGISControl::ZoomOut(const DOUBLE factor) changed); if (SUCCEEDED(result) && changed) { + InvalidateLayerCache(); FireViewChange(); } return result; @@ -623,6 +637,7 @@ STDMETHODIMP CMapWinGISControl::Pan( changed); if (SUCCEEDED(result) && changed) { + InvalidateLayerCache(); FireViewChange(); } return result; @@ -642,6 +657,7 @@ STDMETHODIMP CMapWinGISControl::ZoomAt( changed); if (SUCCEEDED(result) && changed) { + InvalidateLayerCache(); FireViewChange(); } return result; @@ -665,6 +681,7 @@ STDMETHODIMP CMapWinGISControl::ZoomToLayer(const LONGLONG layerId) changed); if (SUCCEEDED(result) && changed) { + InvalidateLayerCache(); FireViewChange(); } return result; @@ -750,10 +767,32 @@ void CMapWinGISControl::CancelMousePan() noexcept if (_isPanning) { _isPanning = false; + // Drop the cache even when the control is closing or being destroyed: + // a reactivated control must never present a frame from a drag that + // ended without a final render. + InvalidateLayerCache(); ReleaseMouseCapture(); } } +void CMapWinGISControl::EndMousePan() noexcept +{ + if (!_isPanning) + { + return; + } + + CancelMousePan(); + // The drag ended: rasterize the layers once at the final view so the + // cached frame matches the new center exactly (a clamped pan can leave + // the blitted frame up to half a pixel off). Every drag termination + // path (button-up, capture loss, cancel mode, button released) goes + // through here, so a terminated drag never leaves the shifted cached + // frame on screen. + InvalidateLayerCache(); + FireViewChange(); +} + bool CMapWinGISControl::GetMousePoint( const LPARAM lParam, const bool screenCoordinates, @@ -821,7 +860,7 @@ LRESULT CMapWinGISControl::OnMouseMove( if ((wParam & MK_LBUTTON) == 0) { - CancelMousePan(); + EndMousePan(); handled = TRUE; return 0; } @@ -849,6 +888,8 @@ LRESULT CMapWinGISControl::OnMouseMove( return 0; } + // The drag pan changes only the view center, so OnDraw can serve the + // frame from the layer cache; the cache is dropped on button-up. bool changed = false; const HRESULT result = RunViewOperation( [this, pixelDeltaX, pixelDeltaY, resolution]() @@ -879,7 +920,7 @@ LRESULT CMapWinGISControl::OnLButtonUp( return 0; } - CancelMousePan(); + EndMousePan(); handled = TRUE; return 0; } @@ -917,6 +958,7 @@ LRESULT CMapWinGISControl::OnMouseWheel( changed); if (SUCCEEDED(result) && changed) { + InvalidateLayerCache(); FireViewChange(); } @@ -930,7 +972,7 @@ LRESULT CMapWinGISControl::OnCaptureChanged( LPARAM, BOOL& handled) { - _isPanning = false; + EndMousePan(); handled = TRUE; return 0; } @@ -941,7 +983,7 @@ LRESULT CMapWinGISControl::OnCancelMode( LPARAM, BOOL& handled) { - CancelMousePan(); + EndMousePan(); handled = TRUE; return 0; } @@ -955,6 +997,7 @@ LRESULT CMapWinGISControl::OnSize( _gdalEvents.OnSize(); if (ResizeView(LOWORD(lParam), HIWORD(lParam))) { + InvalidateLayerCache(); FireViewChange(); } handled = TRUE; @@ -1024,12 +1067,161 @@ STDMETHODIMP CMapWinGISControl::SetObjectRects(LPCRECT position, LPCRECT clip) static_cast(std::clamp(width, 0, mwg::Viewport::kMaxDimension)), static_cast(std::clamp(height, 0, mwg::Viewport::kMaxDimension)))) { + InvalidateLayerCache(); FireViewChange(); } } return result; } +void CMapWinGISControl::InvalidateLayerCache() noexcept +{ + _layerCacheValid = false; +} + +bool CMapWinGISControl::TryBlitCachedLayers( + HDC frameDc, + const mwg::Viewport& viewport) noexcept +{ + if (!_layerCacheValid || + !_layerCache.IsValid() || + _layerCache.Width() != viewport.Width() || + _layerCache.Height() != viewport.Height()) + { + return false; + } + + const std::optional resolution = viewport.Resolution(); + const std::optional cacheResolution = _layerCacheViewport.Resolution(); + const std::optional center = viewport.Center(); + const std::optional cacheCenter = _layerCacheViewport.Center(); + if (!resolution.has_value() || + !cacheResolution.has_value() || + !center.has_value() || + !cacheCenter.has_value() || + *resolution != *cacheResolution) + { + // A resolution change (or a missing view) needs a re-render. + return false; + } + + // The cached background must match the color the frame is cleared with; + // a system-color or high-contrast change would otherwise leave the old + // background showing through an otherwise valid cache. + if (::GetSysColor(COLOR_WINDOW) != _layerCacheBackground) + { + return false; + } + + // Screen position of a fixed map point shifts by the center difference + // divided by the resolution, with the Y axis pointing down. The center + // accumulates rounding through repeated pan operations, so the offset can + // sit a tiny amount off an integer; anything within half a pixel rounds to + // the nearest pixel, which is imperceptible during the drag and corrected + // by the final render when the drag ends. + const double offsetX = (cacheCenter->x - center->x) / *resolution; + const double offsetY = (center->y - cacheCenter->y) / *resolution; + const LONG shiftX = static_cast(std::lround(offsetX)); + const LONG shiftY = static_cast(std::lround(offsetY)); + if (std::abs(offsetX - static_cast(shiftX)) >= 0.5 || + std::abs(offsetY - static_cast(shiftY)) >= 0.5) + { + // A clamped pan (or extreme accumulated drift) leaves a sub-pixel-or- + // worse offset; re-render so the frame does not jitter between a + // shifted blit and a true render. + return false; + } + + const LONG width = static_cast(viewport.Width()); + const LONG height = static_cast(viewport.Height()); + + // Destination (x, y) shows cache pixel (x - shiftX, y - shiftY); blit the + // rectangle where that source position is inside the cached frame. The + // rest of the destination keeps the background already painted. + const LONG destLeft = std::max(0, shiftX); + const LONG destTop = std::max(0, shiftY); + const LONG destRight = std::min(width, width + shiftX); + const LONG destBottom = std::min(height, height + shiftY); + if (destRight <= destLeft || destBottom <= destTop) + { + // The cached content is entirely outside the frame. + return true; + } + + return ::BitBlt( + frameDc, + destLeft, + destTop, + destRight - destLeft, + destBottom - destTop, + _layerCache.Get(), + destLeft - shiftX, + destTop - shiftY, + SRCCOPY) != 0; +} + +HRESULT CMapWinGISControl::DrawLayersAndUpdateCache( + HDC frameDc, + const RECT& frameBounds, + const mwg::rendering::MapRenderScene& scene, + const mwg::Viewport& viewport, + const bool useCache) +{ + if (!useCache) + { + return _renderer.Draw(frameDc, frameBounds, scene, viewport); + } + + const int width = viewport.Width(); + const int height = viewport.Height(); + if (!_layerCache.Resize(width, height)) + { + // Without a cache the layers are drawn straight into the frame; the + // next draw retries the allocation. + return _renderer.Draw(frameDc, frameBounds, scene, viewport); + } + + HDC cacheDc = _layerCache.Get(); + const RECT cacheBounds{0, 0, width, height}; + if (::FillRect( + cacheDc, + &cacheBounds, + ::GetSysColorBrush(COLOR_WINDOW)) == 0) + { + return _renderer.Draw(frameDc, frameBounds, scene, viewport); + } + + const HRESULT result = _renderer.Draw(cacheDc, cacheBounds, scene, viewport); + if (FAILED(result)) + { + _layerCacheValid = false; + return result; + } + + _layerCacheViewport = viewport; + _layerCacheBackground = ::GetSysColor(COLOR_WINDOW); + _layerCacheValid = true; + + if (::BitBlt( + frameDc, + frameBounds.left, + frameBounds.top, + width, + height, + cacheDc, + 0, + 0, + SRCCOPY) != 0) + { + return result; + } + + // A blit between different devices (printer or metafile targets) can + // fail; present the layers by drawing straight to the target. + _layerCacheValid = false; + return _renderer.Draw(frameDc, frameBounds, scene, viewport); +} + HRESULT CMapWinGISControl::OnDraw(ATL_DRAWINFO& drawInfo) { if (drawInfo.hdcDraw == nullptr || drawInfo.prcBounds == nullptr) @@ -1061,6 +1253,12 @@ HRESULT CMapWinGISControl::OnDraw(ATL_DRAWINFO& drawInfo) ? RECT{0, 0, static_cast(width), static_cast(height)} : bounds; + // The cache holds a viewport-sized bitmap that is blitted at the frame + // origin; it is only valid while the frame is the offscreen buffer. A + // failed presentation blit switches to the target DC and bounds, and + // must not use the cache. + bool useLayerCache = buffered; + const auto renderFrame = [&]() -> HRESULT { if (::FillRect( @@ -1088,7 +1286,16 @@ HRESULT CMapWinGISControl::OnDraw(ATL_DRAWINFO& drawInfo) } if (!scene.layers.empty() && viewport.IsValid()) { - return _renderer.Draw(frameDc, frameBounds, scene, viewport); + if (useLayerCache && TryBlitCachedLayers(frameDc, viewport)) + { + return S_OK; + } + return DrawLayersAndUpdateCache( + frameDc, + frameBounds, + scene, + viewport, + useLayerCache); } return S_OK; } @@ -1128,6 +1335,7 @@ HRESULT CMapWinGISControl::OnDraw(ATL_DRAWINFO& drawInfo) // can fail; present the frame by drawing straight to the target. frameDc = drawInfo.hdcDraw; frameBounds = bounds; + useLayerCache = false; } return renderFrame(); diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.h b/src/MapWinGIS.ActiveX/MapWinGISControl.h index 1e02e01..3a55abe 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.h +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.h @@ -165,11 +165,48 @@ class ATL_NO_VTABLE CMapWinGISControl : bool CaptureMouse() noexcept; void ReleaseMouseCapture() noexcept; void CancelMousePan() noexcept; + + /// Ends an active drag pan through every termination path (button-up, + /// capture loss, cancel mode, button released): stops panning, + /// invalidates the layer cache so the final view is rasterized, and + /// requests a redraw. + void EndMousePan() noexcept; + bool GetMousePoint(LPARAM lParam, bool screenCoordinates, POINT& point) const noexcept; + /// Drops the cached layer frame; the next draw re-renders the layers + /// into the cache. Called when layers, the view (except a drag pan), the + /// viewport size, or the backend preference changes. + void InvalidateLayerCache() noexcept; + + /// Paints the cached layer frame over the already-cleared frame device + /// context, shifted to the viewport's current view when only the center + /// moved (a drag pan). Returns true when the cache could be used; false + /// when the layers must be re-rendered (cache miss, resize, zoom, or a + /// clamped, sub-pixel pan). + bool TryBlitCachedLayers(HDC frameDc, const mwg::Viewport& viewport) noexcept; + + /// Draws the layers for the viewport, refreshing the layer cache when + /// useCache holds. Falls back to drawing straight to frameDc when the + /// cache cannot be allocated or the cached frame cannot be blitted. + [[nodiscard]] HRESULT DrawLayersAndUpdateCache( + HDC frameDc, + const RECT& frameBounds, + const mwg::rendering::MapRenderScene& scene, + const mwg::Viewport& viewport, + bool useCache); + mwg::MapView _view; LONGLONG _featureCount = 0; mwg::activex::FrameBuffer _frameBuffer; + mwg::activex::FrameBuffer _layerCache; + mwg::Viewport _layerCacheViewport; + + /// COLOR_WINDOW the cached frame was rendered with; a system-color or + /// high-contrast change makes the cache unusable. + COLORREF _layerCacheBackground = 0; + + bool _layerCacheValid = false; mwg::activex::VectorLayerRenderer _renderer; mwg::activex::GdalEventSource _gdalEvents; POINT _lastPanPoint{};