Files
o3de/Gems/GradientSignal/Code/Include/GradientSignal/Editor/EditorGradientPreviewRenderer.h
T
Steve Pham 38261d0800 Shorten copyright headers by splitting into 2 lines (#2213)
* Updated all copyright headers to split the longer original copyright line into 2 shorter lines

Signed-off-by: Steve Pham <spham@amazon.com>
2021-07-16 15:25:48 -07:00

459 lines
23 KiB
C++

/*
* 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 <AzCore/Math/MathUtils.h>
#include <AzCore/std/chrono/clocks.h>
#include <AzCore/Jobs/JobFunction.h>
#include <AzCore/Component/TickBus.h>
#include <AzCore/std/parallel/condition_variable.h>
// Qt
#include <QImage>
#include <QObject>
#include <QSize>
#include <QTimer>
// GradientSignal
#include <GradientSignal/Ebuses/GradientPreviewContextRequestBus.h>
#include <GradientSignal/GradientSampler.h>
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<float(float, const GradientSampleParams&)>;
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<decltype(m_previewMutex)> 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<float>(m_imageBoundsX),
texelOffset* previewBoundsExtentsY / static_cast<float>(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<float>(m_imageBoundsX),
previewBoundsExtentsY / static_cast<float>(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<decltype(m_previewMutex)> 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<AZ::u8*>(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<float>(x), static_cast<float>((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<AZ::u8>(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<decltype(m_previewMutex)> 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<float(float, const GradientSampleParams&)>;
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