/* * Copyright (c) Contributors to the Open 3D Engine Project. * For complete copyright and license terms please see the LICENSE at the root of this distribution. * * SPDX-License-Identifier: Apache-2.0 OR MIT * */ #pragma once // AZ #include #include #include #include #include // Qt #include #include #include #include // GradientSignal #include #include namespace GradientSignal { //! EditorGradientPreviewUpdateJob offloads the creation of a gradient preview image to another thread. //! This is necessary for Editor responsiveness. With complex gradient hierarchies, large previews, and/or //! multiple gradient previews visible at the same time (like in Landscape Canvas), it's possible for the //! preview generation to take multiple seconds, or even minutes in degenerate data cases. //! //! In offloading the work, we also incrementally update the preview via an adaptive interlacing scheme, similar //! to GIF or PNG interlacing, so that it becomes visible and usable even before the work has completed. //! //! Implementation notes: //! - This directly modifies the m_previewImage from a Job thread in a non-threadsafe way while it is also being //! used from Qt in the main thread. This doesn't cause any issues because we synchronously cancel the job thread //! any time we delete or recreate m_previewImage (such as during resizing). //! //! - The interlacing scheme is loosely based on the "Adam7" algorithm, which is used in the PNG format. Unlike Adam7, //! which uses a 7-pass system to operate on 8x8 interlace patterns, the code below uses an N-pass system. Roughly speaking, //! each pass doubles the number of pixels drawn relative to the previous pass. For a 256x256 image, the passes will draw //! 1 pixel, 1 pixel, 2 pixels, 4 pixels, 8 pixels, 16 pixels, 32 pixels, 64 pixels, 128 pixels, ..., 32768 pixels. //! //! - We only create a single job instance per gradient preview, and the Process() function of the job runs once for //! each time we need to refresh the preview. It remains dormant the rest of the time. We can't use a fire-and-forget job //! because we need the ability to synchronously cancel it and wait for it to be cancelled. This requirement comes from the //! way we reuse data that exists in the parent Preview widget class. We need to manage the lifetime to be exactly the same //! as the widget, and we can't have multiple jobs running in parallel that modify the same widget. (If we use fire-and-forget, //! even if we cancel asynchronously, it would be easy to start a new one before the old one finishes) class EditorGradientPreviewUpdateJob : public AZ::Job { public: AZ_CLASS_ALLOCATOR(EditorGradientPreviewUpdateJob, AZ::ThreadPoolAllocator, 0); using SampleFilterFunc = AZStd::function; EditorGradientPreviewUpdateJob(AZ::JobContext* context = nullptr) : Job(false, context) { } virtual ~EditorGradientPreviewUpdateJob() { // Make sure we don't have anything running on another thread before destroying // the job instance itself. CancelAndWait(); } bool CancelAndWait() { // Return whether or not this actually cancelled a job that had started, or if this job was already idle bool jobHadStarted = m_started; // To cancel, we start by notifying the Process() loop that it should cancel itself on the next iteration if // it's currently running. (Note that this is an atomic bool) m_shouldCancel = true; // Then we synchronously block until the job has completed. Wait(); return jobHadStarted; } void Wait() { // Jobs don't inherently have a way to block on cancellation / completion, so we need to implement it // ourselves. // If we've already started the job, block on a condition variable that gets notified at // the end of the Process() function. AZStd::unique_lock lock(m_previewMutex); if (m_started) { m_refreshFinishedNotify.wait(lock, [this] { return m_started == false; }); } // Regardless of whether or not we were running, we need to reset the internal Job class status // and clear our cancel flag. Reset(true); m_shouldCancel = false; } //! This enables our widget to know whenever the preview image has changed. //! We also clear the refreshUI flag here on polling, so that we only detect changes since the last time //! we asked. We don't rely on watching for the thread to be running, since it's possible for the thread //! to run and finish before we ever poll for the first time. bool ShouldRefreshUI() { return m_refreshUI.exchange(false); } //! Perform any main-thread and one-time setup needed to refresh the preview, then kick off the job. void RefreshPreview(GradientSampler sampler, SampleFilterFunc filterFunc, QSize imageResolution, QImage* previewImage) { // Make sure any previous run is cancelled and fully stopped before we modify any parameters below. // In particular, if we allocate / reallocate m_previewImage here while a job is running, we'll access // invalid memory. CancelAndWait(); // No matter what, we'll want to at least refresh to get an all-black image. m_refreshUI = true; // Save off a copy of the parameters that we'll use to render the preview. This way we don't have to worry // about them changing while we're running. m_sampler = sampler; m_filterFunc = filterFunc; m_imageResolution = imageResolution; // This is a direct pointer to the QImage used by the Preview widget. We allocate it here, and write pixels // into it from the job. The Preview widget (and Qt) read from it on the main thread to display even while we're // running. m_previewImage = previewImage; // If our image size has changed, resize our buffers. if (m_previewImage->size() != imageResolution) { *m_previewImage = QImage(imageResolution, QImage::Format_Grayscale8); } // Initialize it with all black. m_previewImage->fill(QColor(0, 0, 0)); // No valid gradient, so all-black is all we need. Done! if (!sampler.m_gradientId.IsValid()) { return; } // Get preview image settings from the owning entity. m_constrainToShape = false; GradientPreviewContextRequestBus::EventResult(m_constrainToShape, sampler.m_ownerEntityId, &GradientPreviewContextRequestBus::Events::GetConstrainToShape); m_previewBounds = AZ::Aabb::CreateNull(); GradientPreviewContextRequestBus::EventResult(m_previewBounds, sampler.m_ownerEntityId, &GradientPreviewContextRequestBus::Events::GetPreviewBounds); GradientPreviewContextRequestBus::EventResult(m_previewEntityId, sampler.m_ownerEntityId, &GradientPreviewContextRequestBus::Events::GetPreviewEntity); m_constrainToShape = m_constrainToShape && m_previewEntityId.IsValid(); // If the preview bounds aren't valid, something went wrong (invalid IDs?), so don't draw anything more. // The preview bounds are the world-space coordinates that we'll use to sample our gradient. if (!m_previewBounds.IsValid()) { return; } const AZ::Vector3 previewBoundsCenter = m_previewBounds.GetCenter(); const AZ::Vector3 previewBoundsExtentsOld = m_previewBounds.GetExtents(); m_previewBounds = AZ::Aabb::CreateCenterRadius(previewBoundsCenter, AZ::GetMax(previewBoundsExtentsOld.GetX(), previewBoundsExtentsOld.GetY()) / 2.0f); m_previewBoundsStart = AZ::Vector3(m_previewBounds.GetMin().GetX(), m_previewBounds.GetMin().GetY(), previewBoundsCenter.GetZ()); const AZ::Vector3 previewBoundsExtents = m_previewBounds.GetExtents(); const float previewBoundsExtentsX = previewBoundsExtents.GetX(); const float previewBoundsExtentsY = previewBoundsExtents.GetY(); // Get the actual resolution of our preview image. Note that this might be non-square, depending on how the window is sized. const uint64_t imageResolutionX = imageResolution.width(); const uint64_t imageResolutionY = imageResolution.height(); // Get the largest square size that fits into our window bounds. m_imageBoundsX = AZStd::min(imageResolutionX, imageResolutionY); m_imageBoundsY = AZStd::min(imageResolutionX, imageResolutionY); // Get how many pixels we need to offset in x and y to center our square in the window. Because we've made our square // as large as possible, one of these two values should always be 0. // i.e. we'll end up with black bars on the sides or on top, but it should never be both. m_centeringOffsetX = (imageResolutionX - m_imageBoundsX) / 2; m_centeringOffsetY = (imageResolutionY - m_imageBoundsY) / 2; // When sampling the gradient, we can choose to either do it at the corners of each texel area we're sampling, or at the center. // They're both correct choices in different ways. We're currently choosing to do the corners, which makes scaledTexelOffset = 0, // but the math is here to make it easy to change later if we ever decide sampling from the center provides a more intuitive preview. constexpr float texelOffset = 0.0f; // Use 0.5f to sample from the center of the texel. m_scaledTexelOffset = AZ::Vector3(texelOffset * previewBoundsExtentsX / static_cast(m_imageBoundsX), texelOffset* previewBoundsExtentsY / static_cast(m_imageBoundsY), 0.0f); // Scale from our preview image size space (ex: 256 pixels) to our preview bounds space (ex: 16 meters) m_pixelToBoundsScale = AZ::Vector3(previewBoundsExtentsX / static_cast(m_imageBoundsX), previewBoundsExtentsY / static_cast(m_imageBoundsY), 0.0f); // Start of interlacing support: For our interlacing algorithm to work, we need to work on images of powers of two. // Rather than actually allocate an image of that size, we simply find the smallest power of two that contains the image, // and then skip any pixels that fall outside the image when running through our per-pixel loop in Process() below. // We also calculate the number of interlacing passes that we need here. For the algorithm to work, we always need to have // an odd number of interlacing passes. We're keeping track of the *last* pass number, which is even, instead of number of passes, // just for calculation convenience later. m_imageBoundsPowerOfTwo = 1; m_finalInterlacingPass = 0; while (m_imageBoundsX > m_imageBoundsPowerOfTwo) { m_imageBoundsPowerOfTwo *= 2; m_finalInterlacingPass += 2; } // If you ever want to make this use a smaller number of passes, set this value to any even number. // A value of 0 is the same as "non-interlaced", 6 would exactly use the Adam7 algorithm, etc. // It's currently clamping to 30 as a somewhat aribtrary choice. const uint64_t maxFinalInterlacingPass = 30; m_finalInterlacingPass = AZ::GetMin(m_finalInterlacingPass, maxFinalInterlacingPass); // Finally, lock our mutex, modify our status variables, and start the Job. { AZStd::lock_guard lock(m_previewMutex); m_shouldCancel = false; m_started = true; Start(); } } //! Process runs exactly once for each time Start() is called on a Job, and processes on a Job worker thread. void Process() override { // Guard against the case that we're trying to cancel even before we've started to run. if (!m_shouldCancel) { AZ_Assert(m_sampler.m_gradientId.IsValid() && m_previewBounds.IsValid(), "Invalid gradient settings."); AZ::u8* buffer = static_cast(m_previewImage->bits()); // This is the "striding value". When walking directly through our preview image bits() buffer, there might be // extra pad bytes for each line due to alignment. We use this to make sure we start writing each line at the right byte offset. const uint64_t imageBytesPerLine = m_previewImage->bytesPerLine(); // The following are all used for calculating our interlaced pixel updates. // The current interlace pass that we're on. int64_t curPass = 0; // The index of the first pixel for this pass. This is used to calculate the relative pixel index per pass. uint64_t firstPixelPerPass = 0; // Total number of pixels that we'll process per pass. After the first two passes, the amount doubles per pass till we reach 100%. uint64_t totalPixelsPerPass = (m_imageBoundsPowerOfTwo * m_imageBoundsPowerOfTwo) / (1LL << (m_finalInterlacingPass - curPass)); // The general interlace formulas need a multiplier and an offset for x and y to apply to each relative pixel index. // The pixel multipliers start high and reduce on each pass to increase the pixel density per pass. // The pixel offsets alternate between 0 and a reducing number because we start on aligned grids, then fill in the midpoints // of the grids on every other pass. uint64_t xPixelMult = 1LL << (m_finalInterlacingPass / 2); uint64_t xPixelOffset = 0; uint64_t yPixelMult = 1LL << (m_finalInterlacingPass / 2); uint64_t yPixelOffset = 0; // The heart of the processing - loop through each pixel using interlaced indexing, get the gradient value, and write it into // the pixel buffer. On each pixel, we also check to see if the main thread requested a cancel so that we can early-out. // The loop itself runs through the full square power-of-two bounds that encapsulates our image so that we can perform our // interlaced indexing easily, but we skip processing any pixel that falls outside the actual image bounds. for (uint64_t curPixel = 0; (!m_shouldCancel) && (curPixel < (m_imageBoundsPowerOfTwo * m_imageBoundsPowerOfTwo)); curPixel++) { // Check to see if we've finished the pixels for this pass and need to move on to the next pass. if (curPixel >= (firstPixelPerPass + totalPixelsPerPass)) { curPass++; // Adjust our interlacing formula adjustments on each pass. These will cause us to process an increasing // number of pixels at a higher density on each pass, interleaving in a way that ensures each pixel is only // processed once at the end. yPixelMult = xPixelMult; yPixelOffset = xPixelOffset; xPixelMult = 1LL << ((m_finalInterlacingPass - curPass + 1) / 2); xPixelOffset = (curPass % 2) * (1LL << ((m_finalInterlacingPass - curPass) / 2)); firstPixelPerPass += totalPixelsPerPass; totalPixelsPerPass = (m_imageBoundsPowerOfTwo * m_imageBoundsPowerOfTwo) / (1LL << (m_finalInterlacingPass - curPass + 1)); } // Here's where interlacing happens. If this were non-interlaced, we'd simply have the following: // x = curPixel % m_imageBoundsPowerOfTwo // y = curPixel / m_imageBoundsPowerOfTwo uint64_t adjustedPixel = curPixel - firstPixelPerPass; uint64_t x = ((adjustedPixel * xPixelMult) + xPixelOffset) % m_imageBoundsPowerOfTwo; uint64_t y = ((((adjustedPixel * xPixelMult) + xPixelOffset) / m_imageBoundsPowerOfTwo) * yPixelMult) + yPixelOffset; // Since we're using a power of two for calculating our interlacing, it's possible to get pixel offsets beyond the bounds // of our actual image. We just skip those and continue on to the next pixel. if ((x >= m_imageBoundsX) || (y >= m_imageBoundsY)) { continue; } // Now that we've calculated the pixel position, update it with the gradient value. { // Invert world y to match axis. (We use "imageBoundsY- 1" to invert because our loop doesn't go all the way to imageBoundsY) AZ::Vector3 uvw(static_cast(x), static_cast((m_imageBoundsY - 1) - y), 0.0f); GradientSampleParams sampleParams; sampleParams.m_position = m_previewBoundsStart + (uvw * m_pixelToBoundsScale) + m_scaledTexelOffset; bool inBounds = true; if (m_constrainToShape) { LmbrCentral::ShapeComponentRequestsBus::EventResult(inBounds, m_previewEntityId, &LmbrCentral::ShapeComponentRequestsBus::Events::IsPointInside, sampleParams.m_position); } float sample = inBounds ? m_sampler.GetValue(sampleParams) : 0.0f; if (m_filterFunc) { sample = m_filterFunc(sample, sampleParams); } buffer[((m_centeringOffsetY + y) * imageBytesPerLine) + (m_centeringOffsetX + x)] = static_cast(sample * 255); } // Notify the main thread via atomic bool that the image has changed by at least one pixel. m_refreshUI = true; } } // Finally, we're done updating, so notify the main thread safely that we've finished. This is how we're able to block // and verify that the job completed before changing any parameters, restarting the job, or destroying ourselves. { AZStd::lock_guard lock(m_previewMutex); m_shouldCancel = false; m_started = false; m_refreshFinishedNotify.notify_all(); } } private: // Local copies of preview image info GradientSampler m_sampler; SampleFilterFunc m_filterFunc; QSize m_imageResolution; // Pointer that points directly to the preview image owned by EditorGradientPreviewRenderer QImage* m_previewImage = nullptr; // Preview image settings bool m_constrainToShape = false; AZ::Aabb m_previewBounds = AZ::Aabb::CreateNull(); AZ::EntityId m_previewEntityId; // Values calculated during preview setup that we'll use during processing uint64_t m_imageBoundsX = 0; uint64_t m_imageBoundsY = 0; uint64_t m_centeringOffsetX = 0; uint64_t m_centeringOffsetY = 0; AZ::Vector3 m_previewBoundsStart; AZ::Vector3 m_pixelToBoundsScale; AZ::Vector3 m_scaledTexelOffset; uint64_t m_imageBoundsPowerOfTwo = 1; uint64_t m_finalInterlacingPass = 0; // Communication / synchronization mechanisms between the different threads AZStd::mutex m_previewMutex; AZStd::atomic_bool m_started = false; AZStd::atomic_bool m_shouldCancel = false; AZStd::atomic_bool m_refreshUI = false; AZStd::condition_variable m_refreshFinishedNotify; }; class EditorGradientPreviewRenderer : public QObject , private AZ::TickBus::Handler { public: using SampleFilterFunc = AZStd::function; EditorGradientPreviewRenderer() : QObject() { m_updateJob = aznew EditorGradientPreviewUpdateJob(); AZ::TickBus::Handler::BusConnect(); } virtual ~EditorGradientPreviewRenderer() { AZ::TickBus::Handler::BusDisconnect(); delete m_updateJob; } void SetGradientSampler(const GradientSampler& sampler) { m_sampler = sampler; QueueUpdate(); } void SetGradientSampleFilter(SampleFilterFunc filterFunc) { m_filterFunc = filterFunc; QueueUpdate(); } // AZ::TickBus void OnTick([[maybe_unused]] float deltaTime, [[maybe_unused]] AZ::ScriptTimePoint time) override { if (m_refreshUpdateJob) { m_updateJob->RefreshPreview(m_sampler, m_filterFunc, GetPreviewSize(), &m_previewImage); m_refreshUpdateJob = false; } if (m_updateJob->ShouldRefreshUI()) { OnUpdate(); } } void QueueUpdate() { // Queue the refresh until the next tick. Not strictly necessary, but between separate calls to // SetGradientSample, SetGradientSampleFilter, and the multiple times Qt can cause a widget size // change, we can avoid a lot of false starts/cancels with our update job by waiting till the next tick. m_refreshUpdateJob = true; } bool OnCancelRefresh() { // When cancelling a refresh, we cancel both the current job and any pending refreshes. m_refreshUpdateJob = false; return m_updateJob->CancelAndWait(); } protected: /** * Since this base class is shared between a QWidget and a QGraphicsItem, we need to abstract * the actual update() call so that they can invoke the proper one */ virtual void OnUpdate() = 0; /** * Same as above, we need an abstract way to retrieve the size of the actual preview image from the QGraphicsItem */ virtual QSize GetPreviewSize() const = 0; GradientSampler m_sampler; SampleFilterFunc m_filterFunc; QImage m_previewImage; bool m_refreshUpdateJob = false; EditorGradientPreviewUpdateJob* m_updateJob = nullptr; }; } //namespace GradientSignal