Files
MapLibreParticleLab/src/mbgl/renderer/layers/render_particle_validation_layer.cpp

2137 lines
117 KiB
C++

#include <mbgl/renderer/layers/render_raster_particle_layer.hpp>
#include <mbgl/gfx/color_mode.hpp>
#include <mbgl/gfx/context.hpp>
#include <mbgl/gfx/cull_face_mode.hpp>
#include <mbgl/gfx/drawable.hpp>
#include <mbgl/gfx/drawable_builder.hpp>
#include <mbgl/gfx/drawable_tweaker.hpp>
#include <mbgl/gfx/index_vector.hpp>
#include <mbgl/gfx/shader_registry.hpp>
#include <mbgl/gfx/texture2d.hpp>
#include <mbgl/particle/field/particle_gpu_math.hpp>
#include <mbgl/map/transform_state.hpp>
#include <mbgl/particle/field/offline_fixture_particle_adapter.hpp>
#include <mbgl/particle/renderer_debug_counters.hpp>
#include <mbgl/renderer/particle_viewport_reseed.hpp>
#include <mbgl/renderer/buckets/heatmap_bucket.hpp>
#include <mbgl/renderer/change_request.hpp>
#include <mbgl/renderer/layer_group.hpp>
#include <mbgl/renderer/paint_parameters.hpp>
#include <mbgl/renderer/render_pass.hpp>
#include <mbgl/renderer/render_static_data.hpp>
#include <mbgl/renderer/render_target.hpp>
#include <mbgl/renderer/sources/render_raster_array_source.hpp>
#include <mbgl/shaders/particle_gpu_validation_ubo.hpp>
#include <mbgl/shaders/shader_defines.hpp>
#include <mbgl/shaders/shader_program_base.hpp>
#include <mbgl/style/types.hpp>
#include <mbgl/style/layers/raster_particle_layer_impl.hpp>
#include <mbgl/style/layers/particle_render_layer_properties.hpp>
#include <mbgl/style/layers/particle_validation_layer_impl.hpp>
#include <mbgl/tile/tile_id.hpp>
#include <mbgl/util/image.hpp>
#include <mbgl/util/logging.hpp>
#include <mbgl/util/mat4.hpp>
#include <algorithm>
#include <array>
#include <atomic>
#include <cmath>
#include <chrono>
#include <exception>
#include <limits>
#include <sstream>
#include <vector>
namespace mbgl {
namespace {
constexpr auto ShaderName = "ParticleGpuValidationShader";
constexpr Size MaskSize{16, 16};
// This is a validation visual clock, not a multiplier on canonical U/V. At
// 60 Hz it advances 20 simulation seconds per frame: a typical 5 m/s wind
// moves about 100 m (roughly 0.65 px at z9), while a 30 m/s sample remains
// below a four-pixel segment at that zoom.
constexpr float VisualSimulationSecondsPerRealSecond = 1200.0f;
constexpr float VisualSimulationReferenceZoom = 9.0f;
constexpr float MinVisualSimulationSecondsPerRealSecond = 300.0f;
constexpr float MaxVisualSimulationSecondsPerRealSecond = 153600.0f;
constexpr float MaxSimulationDeltaSeconds = 2400.0f;
constexpr float ParticleLifecycleRealSeconds = 12.0f;
constexpr float TrailFadeReferenceFramesPerSecond = 60.0f;
constexpr auto TrailCameraStableDelay = std::chrono::milliseconds(150);
constexpr float ViewportRespawnMargin = 0.15f;
constexpr float MinimumRespawnBoundsExtent = 0.000001f;
std::atomic_uint64_t nextGpuGeneration{0};
std::atomic_uint64_t nextMaskGeneration{0};
std::atomic_uint64_t nextFieldGeneration{0};
std::atomic_uint64_t nextRenderLayerInstanceId{0};
struct ParticleSettings {
bool active;
uint8_t maskMode;
uint8_t fixtureMode;
bool usesFormalSource;
float timeMix;
uint32_t particleCount;
float lineWidth;
float particleLength;
float particleOpacity;
float trailOpacity;
float trailFade;
float advectionScale;
bool speedColorEnabled;
std::array<float, 4> particleColor;
std::array<float, 4> trailColor;
};
ParticleSettings particleSettings(const Immutable<style::Layer::Impl>& value) {
if (value->getTypeInfo() == style::RasterParticleLayer::Impl::staticTypeInfo()) {
const auto& layer = static_cast<const style::RasterParticleLayer::Impl&>(*value);
return {layer.active,
layer.maskMode,
0,
true,
layer.timeMix,
layer.particleCount,
layer.lineWidth,
layer.particleLength,
layer.particleOpacity,
layer.trailOpacity,
layer.trailFade,
layer.advectionScale,
layer.speedColorEnabled,
layer.particleColor,
layer.trailColor};
}
assert(value->getTypeInfo() == style::ParticleValidationLayer::Impl::staticTypeInfo());
const auto& layer = static_cast<const style::ParticleValidationLayer::Impl&>(*value);
return {layer.active,
layer.maskMode,
layer.fixtureMode,
false,
0.0f,
256,
2.0f,
0.018f,
1.0f,
1.0f,
0.965f,
1.0f,
false,
{0.08f, 0.55f, 1.0f, 1.0f},
{1.0f, 1.0f, 1.0f, 1.0f}};
}
enum class ValidationPass : uint8_t {
State = 0,
Trail = 1,
Composite = 2,
FrozenComposite = 3,
TrailParticles = 4,
TrailParticleCoverage = 6,
};
const char* slotName(const uint8_t slot) {
return slot == 0 ? "A" : "B";
}
const char* textureDataTypeName(const gfx::TextureChannelDataType type) {
switch (type) {
case gfx::TextureChannelDataType::UnsignedByte:
return "UnsignedByte";
case gfx::TextureChannelDataType::HalfFloat:
return "HalfFloat";
case gfx::TextureChannelDataType::Float:
return "Float";
}
return "Unknown";
}
Size validViewportSize(const Size size) {
return {std::max<uint32_t>(1, size.width), std::max<uint32_t>(1, size.height)};
}
Size particleStateSize(const uint32_t particleCount) {
const auto side = static_cast<uint32_t>(std::ceil(std::sqrt(static_cast<float>(particleCount))));
return {side, side};
}
const char* fixtureID(const uint8_t mode) {
switch (mode) {
case 0:
return "uniform-east";
case 1:
return "uniform-north";
default:
return "vortex";
}
}
std::shared_ptr<PremultipliedImage> makeVelocityFieldImage(const particle::ParticleFieldSnapshot& field) {
float scale = 1.0f;
for (size_t index = 0; index < field.valid.size(); ++index) {
if (!field.valid[index]) continue;
scale = std::max(scale, std::abs(field.vector[index * 2]));
scale = std::max(scale, std::abs(field.vector[index * 2 + 1]));
}
auto image = std::make_shared<PremultipliedImage>(Size{field.width, field.height});
for (uint32_t y = 0; y < field.height; ++y) {
for (uint32_t x = 0; x < field.width; ++x) {
const auto cell = (static_cast<size_t>(y) * field.width + x) * 2;
const auto pixel = (static_cast<size_t>(y) * field.width + x) * PremultipliedImage::channels;
// RGBA8 UNORM has no exact midpoint. Byte 128 is an explicit
// signed zero; shaders round the sampled byte before decoding.
const bool valid = field.valid[y * field.width + x] != 0;
image->data[pixel + 0] = particle::encodeSignedVelocity8(field.vector[cell], scale, valid);
image->data[pixel + 1] = particle::encodeSignedVelocity8(field.vector[cell + 1], scale, valid);
image->data[pixel + 2] = 0;
image->data[pixel + 3] = field.valid[y * field.width + x] ? 255 : 0;
}
}
return image;
}
float fieldVelocityScale(const particle::ParticleFieldSnapshot& field) {
float scale = 1.0f;
for (size_t index = 0; index < field.valid.size(); ++index) {
if (!field.valid[index]) continue;
scale = std::max(scale, std::abs(field.vector[index * 2]));
scale = std::max(scale, std::abs(field.vector[index * 2 + 1]));
}
return scale;
}
double mercatorY(const double latitude) {
const auto radians = latitude * M_PI / 180.0;
return (1.0 - std::log(std::tan(radians) + 1.0 / std::cos(radians)) / M_PI) * 0.5;
}
std::array<float, 4> fieldBounds(const particle::ParticleFieldSnapshot& field) {
return {static_cast<float>((field.west + 180.0) / 360.0),
static_cast<float>(mercatorY(field.north)),
static_cast<float>((field.east + 180.0) / 360.0),
static_cast<float>(mercatorY(field.south))};
}
struct RespawnDomain {
std::array<float, 4> bounds;
bool fallback = true;
};
bool validMercatorBounds(const std::array<float, 4>& bounds) {
return std::isfinite(bounds[0]) && std::isfinite(bounds[1]) && std::isfinite(bounds[2]) &&
std::isfinite(bounds[3]) && bounds[2] - bounds[0] >= MinimumRespawnBoundsExtent &&
bounds[3] - bounds[1] >= MinimumRespawnBoundsExtent;
}
RespawnDomain viewportRespawnDomain(const TransformState& transformState,
const Size viewport,
const particle::ParticleFieldSnapshot& field) {
const auto fullFieldBounds = fieldBounds(field);
if (!validMercatorBounds(fullFieldBounds)) {
return {fullFieldBounds, true};
}
const std::array<ScreenCoordinate, 4> corners = {
ScreenCoordinate{0.0, 0.0},
ScreenCoordinate{static_cast<double>(viewport.width), 0.0},
ScreenCoordinate{0.0, static_cast<double>(viewport.height)},
ScreenCoordinate{static_cast<double>(viewport.width), static_cast<double>(viewport.height)},
};
float minX = std::numeric_limits<float>::infinity();
float minY = std::numeric_limits<float>::infinity();
float maxX = -std::numeric_limits<float>::infinity();
float maxY = -std::numeric_limits<float>::infinity();
for (const auto& corner : corners) {
const auto latLng = transformState.screenCoordinateToLatLng(corner, LatLng::Unwrapped);
const auto x = static_cast<float>((latLng.longitude() + 180.0) / 360.0);
const auto y = static_cast<float>(mercatorY(latLng.latitude()));
minX = std::min(minX, x);
minY = std::min(minY, y);
maxX = std::max(maxX, x);
maxY = std::max(maxY, y);
}
const auto width = maxX - minX;
const auto height = maxY - minY;
if (!std::isfinite(width) || !std::isfinite(height) || width < MinimumRespawnBoundsExtent ||
height < MinimumRespawnBoundsExtent) {
return {fullFieldBounds, true};
}
minX -= width * ViewportRespawnMargin;
maxX += width * ViewportRespawnMargin;
minY -= height * ViewportRespawnMargin;
maxY += height * ViewportRespawnMargin;
const std::array<float, 4> intersection = {
std::max(fullFieldBounds[0], minX),
std::max(fullFieldBounds[1], minY),
std::min(fullFieldBounds[2], maxX),
std::min(fullFieldBounds[3], maxY),
};
return validMercatorBounds(intersection) ? RespawnDomain{intersection, false}
: RespawnDomain{fullFieldBounds, true};
}
// VALIDATION ONLY: deterministic global fixture with a diagonal coast. Alpha
// 255 means OCEAN, alpha 0 means LAND. It is intentionally tiny and is not a
// production coastline source.
std::shared_ptr<PremultipliedImage> makeCoastalMaskFixture() {
auto image = std::make_shared<PremultipliedImage>(MaskSize);
for (uint32_t y = 0; y < MaskSize.height; ++y) {
for (uint32_t x = 0; x < MaskSize.width; ++x) {
const bool ocean = x > (y / 2 + 3) && !(x > 11 && y < 4);
const auto offset = (y * MaskSize.width + x) * PremultipliedImage::channels;
image->data[offset + 0] = ocean ? 36 : 90;
image->data[offset + 1] = ocean ? 128 : 66;
image->data[offset + 2] = ocean ? 188 : 42;
image->data[offset + 3] = ocean ? 255 : 0;
}
}
return image;
}
int16_t wrapForLongitude(const double longitude) {
return static_cast<int16_t>(std::floor((longitude + 180.0) / 360.0));
}
void removeTargets(const std::vector<RenderTargetPtr>& targets, UniqueChangeRequestVec& changes) {
for (const auto& target : targets) {
if (target) {
changes.emplace_back(std::make_unique<RemoveRenderTargetRequest>(target));
}
}
}
template <std::size_t N>
void removeTargets(const std::array<RenderTargetPtr, N>& targets, UniqueChangeRequestVec& changes) {
for (const auto& target : targets) {
if (target) {
changes.emplace_back(std::make_unique<RemoveRenderTargetRequest>(target));
}
}
}
} // namespace
class ParticleGpuResources {
public:
uint64_t renderLayerInstanceId = 0;
uint64_t generation = 0;
uint64_t committedFrame = 0;
uint8_t stateRead = 0;
uint8_t trailRead = 0;
bool stateSeeded = false;
bool trailSeeded = false;
bool pending = false;
bool bootstrapPending = false;
bool contextDestroyed = false;
bool lifecycleModeKnown = false;
bool lifecycleActive = false;
bool formalDisplay = false;
bool displayModeLogged = false;
bool staticFreezeLogged = false;
bool cameraDrawLogged = false;
bool worldWrapLogged = false;
bool antimeridianLogged = false;
bool trailCameraKnown = false;
bool trailCameraInvalidated = false;
bool viewportDensityActive = false;
bool viewportDensityLogged = false;
ParticleViewportReseedGesture viewportReseedGesture;
uint64_t lastCameraLogFrame = 0;
std::chrono::steady_clock::time_point lastPlaybackHeartbeat{};
std::chrono::steady_clock::time_point lastSimulationTick{};
std::chrono::steady_clock::time_point lastMotionLog{};
std::chrono::steady_clock::time_point lastLifecycleLog{};
std::chrono::steady_clock::time_point lastTrailCameraChange{};
uint64_t maskGeneration = 0;
uint64_t fieldGeneration = 0;
uint64_t gpuFieldGeneration = 0;
uint64_t nextFieldGeneration = 0;
uint64_t gpuNextFieldGeneration = 0;
float fieldScale = 1.0f;
float nextFieldScale = 1.0f;
uint64_t fieldUploadCount = 0;
uint64_t resetCount = 0;
float timeMix = 0.0f;
uint32_t particleCount = 0;
float lineWidth = 2.0f;
float particleLength = 0.018f;
float particleOpacity = 1.0f;
float trailOpacity = 1.0f;
float trailFade = 0.92f;
float advectionScale = 1.0f;
// Match the formal layer default so a newly allocated resource cannot emit
// one speed-colored frame before evaluated visual properties synchronize.
bool speedColorEnabled = false;
std::array<float, 4> particleColor{0.08f, 0.55f, 1.0f, 1.0f};
std::array<float, 4> trailColor{1.0f, 1.0f, 1.0f, 1.0f};
uint8_t fixtureMode = 255;
uint8_t loggedMaskMode = 255;
Size trailSize{};
Size stateSize{};
double trailCameraLatitude = 0.0;
double trailCameraLongitude = 0.0;
double trailCameraZoom = 0.0;
double trailCameraBearing = 0.0;
double trailCameraPitch = 0.0;
std::array<float, 4> respawnBounds{};
std::array<RenderTargetPtr, 2> stateTargets;
std::array<RenderTargetPtr, 2> trailTargets;
std::vector<RenderTargetPtr> registeredTargets;
gfx::ShaderProgramBasePtr shader;
gfx::Texture2DPtr maskTexture;
gfx::Texture2DPtr fieldTexture;
gfx::Texture2DPtr nextFieldTexture;
gfx::Texture2DPtr stateTexture;
particle::ParticleFieldSnapshot field;
particle::ParticleFieldSnapshot nextField;
std::shared_ptr<style::RasterArrayTimelineRuntime> timeline;
std::shared_ptr<PremultipliedImage> fieldImage;
std::shared_ptr<PremultipliedImage> nextFieldImage;
std::shared_ptr<PremultipliedImage> maskFixture;
std::shared_ptr<gfx::VertexVector<HeatmapTextureLayoutVertex>> vertices;
std::shared_ptr<gfx::VertexVector<HeatmapTextureLayoutVertex>> particleVertices;
std::shared_ptr<gfx::VertexVector<HeatmapTextureLayoutVertex>> particleCoverageVertices;
gfx::IndexVectorBasePtr particleIndices;
gfx::IndexVectorBasePtr particleCoverageIndices;
SegmentVector segments;
SegmentVector particleSegments;
SegmentVector particleCoverageSegments;
uint8_t stateWrite() const { return static_cast<uint8_t>(1 - stateRead); }
uint8_t trailWrite() const { return static_cast<uint8_t>(1 - trailRead); }
void resetSequence(const char* reason) {
++resetCount;
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::GpuResourceRelease);
Log::Info(Event::Render,
"ParticleGPU: event=SIMULATION_RESET reason=" + std::string(reason) + " renderLayerInstanceId=" +
std::to_string(renderLayerInstanceId) + " resourceGeneration=" + std::to_string(generation) +
" resetCount=" + std::to_string(resetCount));
committedFrame = 0;
stateRead = 0;
trailRead = 0;
stateSeeded = false;
trailSeeded = false;
pending = false;
bootstrapPending = false;
staticFreezeLogged = false;
viewportReseedGesture.cancel();
lastSimulationTick = {};
for (const auto& target : stateTargets) {
if (target && target->getLayerGroup(0)) {
target->getLayerGroup(0)->clearDrawables();
}
}
for (const auto& target : trailTargets) {
if (target && target->getLayerGroup(0)) {
target->getLayerGroup(0)->clearDrawables();
}
}
}
void commit() {
if (!pending) {
return;
}
stateRead = stateWrite();
trailRead = trailWrite();
stateSeeded = true;
trailSeeded = true;
if (!bootstrapPending) {
++committedFrame;
}
bootstrapPending = false;
pending = false;
}
};
namespace {
constexpr uint32_t MaxParticleVerticesPerSegment = std::numeric_limits<uint16_t>::max() + 1u;
void rebuildParticleDrawResources(const std::shared_ptr<ParticleGpuResources>& resources) {
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::ParticleDrawableRebuild);
auto vertices = std::make_shared<gfx::VertexVector<HeatmapTextureLayoutVertex>>();
auto coverageVertices = std::make_shared<gfx::VertexVector<HeatmapTextureLayoutVertex>>();
vertices->reserve(resources->particleCount * 2);
coverageVertices->reserve(resources->particleCount * 4);
for (uint32_t particleIndex = 0; particleIndex < resources->particleCount; ++particleIndex) {
const auto x = static_cast<int16_t>(particleIndex % resources->stateSize.width);
const auto y = static_cast<int16_t>(particleIndex / resources->stateSize.width);
// Formal line path: x*2 + endpoint (0=previous, 1=current).
vertices->emplace_back(HeatmapBucket::textureVertex({static_cast<int16_t>(x * 2), y}));
vertices->emplace_back(HeatmapBucket::textureVertex({static_cast<int16_t>(x * 2 + 1), y}));
// Metal coverage path: x*4 + corner (0..3). The shader projects the
// true current State position, then applies only a 0.5-pixel clip-space
// corner offset. Simulation/world coordinates are never modified.
for (int16_t corner = 0; corner < 4; ++corner) {
coverageVertices->emplace_back(
HeatmapBucket::textureVertex({static_cast<int16_t>(x * 4 + corner), y}));
}
}
auto indices = std::make_shared<gfx::IndexVectorBase>();
indices->reserve(MaxParticleVerticesPerSegment);
for (uint32_t index = 0; index < MaxParticleVerticesPerSegment; ++index) {
indices->extend(1, static_cast<uint16_t>(index));
}
constexpr uint32_t MaxCoverageVerticesPerSegment = MaxParticleVerticesPerSegment - (MaxParticleVerticesPerSegment % 4u);
constexpr uint32_t MaxCoverageParticlesPerSegment = MaxCoverageVerticesPerSegment / 4u;
auto coverageIndices = std::make_shared<gfx::IndexVectorBase>();
coverageIndices->reserve(MaxCoverageParticlesPerSegment * 6u);
for (uint32_t particle = 0; particle < MaxCoverageParticlesPerSegment; ++particle) {
const auto base = static_cast<uint16_t>(particle * 4u);
coverageIndices->extend(1, base + 0u);
coverageIndices->extend(1, base + 1u);
coverageIndices->extend(1, base + 2u);
coverageIndices->extend(1, base + 2u);
coverageIndices->extend(1, base + 1u);
coverageIndices->extend(1, base + 3u);
}
SegmentVector segments;
const auto lineVertexCount = resources->particleCount * 2u;
for (uint32_t offset = 0; offset < lineVertexCount; offset += MaxParticleVerticesPerSegment) {
const auto count = std::min(lineVertexCount - offset, MaxParticleVerticesPerSegment);
segments.emplace_back(offset, 0, count, count);
}
SegmentVector coverageSegments;
const auto coverageVertexCount = resources->particleCount * 4u;
for (uint32_t offset = 0; offset < coverageVertexCount; offset += MaxCoverageVerticesPerSegment) {
const auto vertexCount = std::min(coverageVertexCount - offset, MaxCoverageVerticesPerSegment);
const auto particleCount = vertexCount / 4u;
coverageSegments.emplace_back(offset, 0, vertexCount, particleCount * 6u);
}
resources->particleVertices = std::move(vertices);
resources->particleCoverageVertices = std::move(coverageVertices);
resources->particleIndices = std::move(indices);
resources->particleCoverageIndices = std::move(coverageIndices);
resources->particleSegments = std::move(segments);
resources->particleCoverageSegments = std::move(coverageSegments);
}
class ParticleGpuValidationTweaker final : public gfx::DrawableTweaker {
public:
ParticleGpuValidationTweaker(std::shared_ptr<ParticleGpuResources> state_,
ValidationPass pass_,
uint8_t readSlot_,
uint8_t writeSlot_,
gfx::Texture2DPtr source_,
gfx::Texture2DPtr target_,
gfx::Texture2DPtr mask_,
gfx::Texture2DPtr stateTexture_,
float simulationStep_,
float wallDeltaSeconds_,
float effectiveVisualSpeedScale_,
int16_t worldWrap_,
uint8_t maskMode_)
: state(std::move(state_)),
pass(pass_),
readSlot(readSlot_),
writeSlot(writeSlot_),
source(std::move(source_)),
target(std::move(target_)),
mask(std::move(mask_)),
stateTexture(std::move(stateTexture_)),
simulationStep(simulationStep_),
wallDeltaSeconds(wallDeltaSeconds_),
effectiveVisualSpeedScale(effectiveVisualSpeedScale_),
worldWrap(worldWrap_),
maskMode(maskMode_) {}
void init(gfx::Drawable&) override {}
void execute(gfx::Drawable& drawable, PaintParameters& parameters) override {
auto resources = state.lock();
if (parameters.pass != RenderPass::Translucent || !resources || !source) {
return;
}
if (pass == ValidationPass::State) {
const auto now = std::chrono::steady_clock::now();
if (now - resources->lastMotionLog >= std::chrono::seconds(1)) {
resources->lastMotionLog = now;
const auto valid = std::find(resources->field.valid.begin(), resources->field.valid.end(), uint8_t{1});
if (valid != resources->field.valid.end()) {
const auto cell = static_cast<size_t>(std::distance(resources->field.valid.begin(), valid));
const auto row = cell / resources->field.width;
const auto latitude = resources->field.north +
(resources->field.south - resources->field.north) *
(static_cast<float>(row) / std::max(1u, resources->field.height - 1));
const auto u = resources->field.vector[cell * 2];
const auto v = resources->field.vector[cell * 2 + 1];
const auto delta = particle::mercatorVelocityDelta(u, v, latitude, simulationStep);
const auto dx = delta[0];
const auto dy = delta[1];
Log::Info(Event::Render,
"ParticleMotion: fieldId=" + resources->field.fieldID + " sampleU=" + std::to_string(u) +
" sampleV=" + std::to_string(v) +
" zoom=" + std::to_string(parameters.state.getZoom()) +
" referenceZoom=" + std::to_string(VisualSimulationReferenceZoom) +
" effectiveVisualSpeedScale=" + std::to_string(effectiveVisualSpeedScale) +
" wallDeltaSeconds=" + std::to_string(wallDeltaSeconds) + " simulationDeltaSeconds=" +
std::to_string(simulationStep) + " estimatedStateDeltaWorld=" + std::to_string(dx) +
"," + std::to_string(dy) + " estimatedPixelDelta=" +
std::to_string(std::sqrt(dx * dx + dy * dy) * 512.0f *
std::exp2(static_cast<float>(parameters.state.getZoom()))) +
" simulationFrame=" + std::to_string(resources->committedFrame));
}
}
if (resources->formalDisplay && now - resources->lastLifecycleLog >= std::chrono::seconds(1)) {
resources->lastLifecycleLog = now;
Log::Info(Event::Render,
"ParticleLifecycle: configuredSlots=" + std::to_string(resources->particleCount) +
" initialAge=HASH_STAGGERED maxAge=HASH_JITTERED_0.75_1.25 "
"lifecycleBaseRealSeconds=" +
std::to_string(ParticleLifecycleRealSeconds) +
" respawn=GPU_HASH_ON_INVALID_OR_AGE_EXPIRED"
" fieldId=" +
resources->field.fieldID +
" simulationFrame=" + std::to_string(resources->committedFrame) + " readback=disabled");
}
}
drawable.setTexture(source, shaders::idParticleGpuValidationPreviousTexture);
if (mask) {
drawable.setTexture(mask, shaders::idParticleGpuValidationMaskTexture);
}
if (resources->fieldTexture) {
drawable.setTexture(resources->fieldTexture, shaders::idParticleGpuValidationFieldTexture);
}
if (resources->nextFieldTexture) {
drawable.setTexture(resources->nextFieldTexture, shaders::idParticleGpuValidationNextFieldTexture);
}
if (stateTexture) {
drawable.setTexture(stateTexture, shaders::idParticleGpuValidationStateTexture);
}
const float frame = simulationStep;
const float markerX = resources->timeMix;
// RenderTarget clear is opaque black. State initialization must therefore
// be an explicit lifecycle signal, never inferred from sampled alpha.
const bool seedState = pass == ValidationPass::State && !resources->stateSeeded;
const shaders::ParticleGpuValidationUBO ubo = {
.pass = {static_cast<float>(pass),
frame,
maskMode == 0 ? 0.0f : 1.0f,
resources->formalDisplay ? wallDeltaSeconds : static_cast<float>(maskMode)},
.marker = {markerX,
resources->speedColorEnabled ? 1.0f : 0.0f,
resources->formalDisplay ? ParticleLifecycleRealSeconds : 0.035f,
resources->formalDisplay ? TrailFadeReferenceFramesPerSecond : 0.965f},
.mask = {static_cast<float>(resources->stateSize.width),
static_cast<float>(resources->stateSize.height),
resources->formalDisplay ? 1.0f : 0.0f,
seedState ? 1.0f : 0.0f},
.visual = {resources->particleOpacity,
resources->particleLength,
resources->trailOpacity,
resources->formalDisplay ? resources->trailFade : 0.965f},
.fieldBounds = fieldBounds(resources->field),
.respawnBounds = resources->respawnBounds,
.fieldScale = {resources->fieldScale,
resources->nextFieldScale,
resources->viewportDensityActive ? 1.0f : 0.0f,
static_cast<float>(resources->trailSize.width)},
.advection = {resources->field.vectorSemantics == particle::VectorSemantics::Direction ? 1.0f : 0.0f,
resources->advectionScale,
0.00001f,
static_cast<float>(resources->trailSize.height)},
.particleColor = resources->particleColor,
.trailColor = resources->trailColor,
.weatherStops = {},
.weatherColors = {},
.weatherRamp = {},
.matrix = util::cast<float>(parameters.matrixForTile({worldWrap, {0, 0, 0}})),
};
drawable.mutableUniformBuffers().createOrUpdate(shaders::idParticleGpuValidationUBO, &ubo, parameters.context);
if (pass == ValidationPass::Composite || pass == ValidationPass::FrozenComposite) {
const auto center = parameters.state.getLatLng(LatLng::Unwrapped);
const bool primaryWorldCopy = resources->formalDisplay ? worldWrap == 0
: worldWrap == wrapForLongitude(center.longitude());
if (primaryWorldCopy && !resources->displayModeLogged) {
Log::Info(Event::Render,
"ParticleRender: displayMode=" +
std::string(resources->formalDisplay ? "FORMAL_PARTICLE" : "VALIDATION") +
" generation=" + std::to_string(resources->generation) +
" compositeTrailRead=" + slotName(readSlot));
resources->displayModeLogged = true;
}
if (primaryWorldCopy && !resources->cameraDrawLogged) {
std::ostringstream message;
message << "ParticleValidation30_03: first camera-locked draw maskGeneration="
<< resources->maskGeneration << " maskMode=" << static_cast<uint32_t>(maskMode)
<< " center=" << center.latitude() << ',' << center.longitude() << " worldCopy=" << worldWrap
<< " zoom=" << parameters.state.getZoom() << " bearing=" << parameters.state.getBearing()
<< " pitch=" << parameters.state.getPitch() << " pixelRatio=" << parameters.pixelRatio;
Log::Info(Event::Render, message.str());
resources->cameraDrawLogged = true;
}
if (primaryWorldCopy && parameters.frameCount >= resources->lastCameraLogFrame + 30) {
Log::Info(Event::Render,
"ParticleValidation30_03: camera frame=" + std::to_string(parameters.frameCount) +
" center=" + std::to_string(center.latitude()) + ',' +
std::to_string(center.longitude()) +
" zoom=" + std::to_string(parameters.state.getZoom()) +
" bearing=" + std::to_string(parameters.state.getBearing()) +
" pitch=" + std::to_string(parameters.state.getPitch()) +
" viewport=" + std::to_string(parameters.state.getSize().width) + 'x' +
std::to_string(parameters.state.getSize().height));
resources->lastCameraLogFrame = parameters.frameCount;
}
if (primaryWorldCopy && worldWrap != 0 && !resources->worldWrapLogged) {
Log::Info(Event::Render,
"ParticleValidation30_03: world wrap draw canonical=0/0/0 wrap=" + std::to_string(worldWrap));
resources->worldWrapLogged = true;
}
if (primaryWorldCopy && std::abs(center.longitude()) >= 170.0 && !resources->antimeridianLogged) {
Log::Info(Event::Render,
"ParticleValidation30_03: antimeridian camera centerLon=" +
std::to_string(center.longitude()) + " canonicalMask=[-180,180]");
resources->antimeridianLogged = true;
}
if (pass == ValidationPass::FrozenComposite) {
if (!resources->staticFreezeLogged) {
const auto size = parameters.state.getSize();
std::ostringstream message;
message << "ParticleValidation30_02: STATIC frozen composite generation=" << resources->generation
<< " frame=" << resources->committedFrame << " stateRead=" << slotName(resources->stateRead)
<< " trailRead=" << slotName(resources->trailRead)
<< " offscreenPasses=0 swap=false viewport=" << size.width << 'x' << size.height
<< " zoom=" << parameters.state.getZoom() << " bearing=" << parameters.state.getBearing()
<< " pitch=" << parameters.state.getPitch();
Log::Info(Event::Render, message.str());
resources->staticFreezeLogged = true;
}
return;
}
// The same canonical quad is drawn for adjacent MapLibre world
// copies. Only the center copy owns the 30-02 slot commit; copies
// are draw-only and must never advance simulation a second time.
if (!primaryWorldCopy) {
return;
}
const auto beforeState = resources->stateRead;
const auto beforeTrail = resources->trailRead;
resources->commit();
if (resources->committedFrame <= 3 || resources->committedFrame % 60 == 0) {
const auto size = parameters.state.getSize();
std::ostringstream message;
message << "ParticleValidation30_02: commit generation=" << resources->generation
<< " frame=" << resources->committedFrame << " state=" << slotName(beforeState) << "->"
<< slotName(resources->stateRead) << " trail=" << slotName(beforeTrail) << "->"
<< slotName(resources->trailRead)
<< " order=State,Trail,Composite hazard=SAFE viewport=" << size.width << 'x' << size.height
<< " zoom=" << parameters.state.getZoom() << " bearing=" << parameters.state.getBearing()
<< " pitch=" << parameters.state.getPitch();
Log::Info(Event::Render, message.str());
}
return;
}
if (source == target) {
Log::Error(Event::Render,
"ParticleValidation30_02: HAZARD source equals target generation=" +
std::to_string(resources->generation));
return;
}
if (resources->committedFrame < 3 || (resources->committedFrame + 1) % 60 == 0) {
const auto passName = pass == ValidationPass::State ? "State" : "Trail";
Log::Info(Event::Render,
"ParticleValidation30_02: pass=" + std::string(passName) +
" generation=" + std::to_string(resources->generation) +
" frame=" + std::to_string(resources->committedFrame + 1) + " read=" + slotName(readSlot) +
" write=" + slotName(writeSlot) + " distinct=true");
}
if (pass == ValidationPass::State &&
(!resources->stateSeeded || resources->committedFrame < 3 || (resources->committedFrame + 1) % 60 == 0)) {
Log::Info(Event::Render,
"ParticleRender: stateSeed=" + std::string(seedState ? "INITIALIZE" : "CONTINUE") +
" generation=" + std::to_string(resources->generation) + " state=" + slotName(readSlot) +
"->" + slotName(writeSlot) + " field=" + resources->field.fieldID);
}
}
private:
std::weak_ptr<ParticleGpuResources> state;
ValidationPass pass;
uint8_t readSlot;
uint8_t writeSlot;
gfx::Texture2DPtr source;
gfx::Texture2DPtr target;
gfx::Texture2DPtr mask;
gfx::Texture2DPtr stateTexture;
float simulationStep;
float wallDeltaSeconds;
float effectiveVisualSpeedScale;
int16_t worldWrap;
uint8_t maskMode;
};
RenderTargetPtr createValidationTarget(gfx::Context& context,
const Size size,
const std::string& name,
const gfx::TextureFilterType filter,
const gfx::TextureChannelDataType type,
const char* targetKind) {
try {
auto target = context.createRenderTarget(size, type);
if (!target || !target->isRenderable() || !target->getTexture()) {
Log::Error(Event::Render,
"ParticleStateTarget: event=CREATE_FAILED kind=" + std::string(targetKind) +
" dataType=" + textureDataTypeName(type) + " name=" + name + " stage=validation");
return {};
}
target->getTexture()->setSamplerConfiguration(
{.filter = filter, .wrapU = gfx::TextureWrapType::Clamp, .wrapV = gfx::TextureWrapType::Clamp});
auto group = context.createLayerGroup(0, 1, name);
if (!group || !target->addLayerGroup(std::move(group), true)) {
Log::Error(Event::Render,
"ParticleStateTarget: event=CREATE_FAILED kind=" + std::string(targetKind) +
" dataType=" + textureDataTypeName(type) + " name=" + name + " reason=LAYER_GROUP");
return {};
}
Log::Info(Event::Render,
"ParticleStateTarget: event=CREATE_SUCCESS kind=" + std::string(targetKind) +
" dataType=" + textureDataTypeName(type) + " name=" + name +
" size=" + std::to_string(size.width) + "x" + std::to_string(size.height));
return target;
} catch (const std::exception& error) {
Log::Error(Event::Render,
"ParticleStateTarget: event=CREATE_FAILED kind=" + std::string(targetKind) + " dataType=" +
textureDataTypeName(type) + " name=" + name + " stage=exception error=" + error.what());
return {};
} catch (...) {
Log::Error(Event::Render,
"ParticleStateTarget: event=CREATE_FAILED kind=" + std::string(targetKind) +
" dataType=" + textureDataTypeName(type) + " name=" + name + " stage=unknown_exception");
return {};
}
}
bool buildQuad(gfx::Context& context,
const std::shared_ptr<ParticleGpuResources>& resources,
LayerGroup& group,
const std::string& name,
const gfx::Texture2DPtr& source,
const gfx::Texture2DPtr& target,
const gfx::Texture2DPtr& mask,
const gfx::Texture2DPtr& stateTexture,
const float simulationStep,
const ValidationPass pass,
const uint8_t readSlot,
const uint8_t writeSlot,
const int16_t worldWrap,
const uint8_t maskMode,
const bool clearGroup,
const float wallDeltaSeconds = 0.0f,
const float effectiveVisualSpeedScale = VisualSimulationSecondsPerRealSecond) {
if (clearGroup) {
group.clearDrawables();
}
auto attributes = context.createVertexAttributeArray();
if (const auto& attribute = attributes->set(shaders::idParticleGpuValidationPosVertexAttribute)) {
attribute->setSharedRawData(resources->vertices,
offsetof(HeatmapTextureLayoutVertex, a1),
0,
sizeof(HeatmapTextureLayoutVertex),
gfx::AttributeDataType::Short2);
}
auto builder = context.createDrawableBuilder(name);
builder->setShader(resources->shader);
builder->setRenderPass(RenderPass::Translucent);
builder->setEnableDepth(false);
builder->setColorMode(pass == ValidationPass::Composite || pass == ValidationPass::FrozenComposite
? gfx::ColorMode::alphaBlended()
: gfx::ColorMode::unblended());
builder->setCullFaceMode(gfx::CullFaceMode::disabled());
builder->setVertexAttributes(std::move(attributes));
builder->setRawVertices({}, resources->vertices->elements(), gfx::AttributeDataType::Short2);
builder->setSegments(gfx::Triangles(),
RenderStaticData::quadTriangleIndices().vector(),
resources->segments.data(),
resources->segments.size());
builder->setTexture(source, shaders::idParticleGpuValidationPreviousTexture);
if (mask) {
builder->setTexture(mask, shaders::idParticleGpuValidationMaskTexture);
}
if (resources->fieldTexture) {
builder->setTexture(resources->fieldTexture, shaders::idParticleGpuValidationFieldTexture);
}
if (resources->nextFieldTexture) {
builder->setTexture(resources->nextFieldTexture, shaders::idParticleGpuValidationNextFieldTexture);
}
if (stateTexture) {
builder->setTexture(stateTexture, shaders::idParticleGpuValidationStateTexture);
}
builder->addTweaker(std::make_shared<ParticleGpuValidationTweaker>(resources,
pass,
readSlot,
writeSlot,
source,
target,
mask,
stateTexture,
simulationStep,
wallDeltaSeconds,
effectiveVisualSpeedScale,
worldWrap,
maskMode));
// DrawableBuilder owns the current drawable until flush finalizes it.
builder->flush(context);
auto drawables = builder->clearDrawables();
if (drawables.size() != 1 || !drawables.front()) {
Log::Error(Event::Render, "ParticleValidation30_02: failed to finalize drawable name=" + name);
return false;
}
group.addDrawable(std::move(drawables.front()));
return true;
}
bool buildParticlePoints(gfx::Context& context,
const std::shared_ptr<ParticleGpuResources>& resources,
LayerGroup& group,
const std::string& name,
const gfx::Texture2DPtr& previousStateTexture,
const gfx::Texture2DPtr& currentStateTexture,
const gfx::Texture2DPtr& target,
const uint8_t stateSlot,
const uint8_t trailSlot) {
if (!resources->particleVertices || !resources->particleIndices || resources->particleSegments.empty() ||
!previousStateTexture || !currentStateTexture || !target) {
Log::Error(Event::Render, "ParticleGPU: event=PARTICLE_DRAW_REJECTED reason=MISSING_DRAW_RESOURCES");
return false;
}
auto attributes = context.createVertexAttributeArray();
if (const auto& attribute = attributes->set(shaders::idParticleGpuValidationPosVertexAttribute)) {
attribute->setSharedRawData(resources->particleVertices,
offsetof(HeatmapTextureLayoutVertex, a1),
0,
sizeof(HeatmapTextureLayoutVertex),
gfx::AttributeDataType::Short2);
}
auto builder = context.createDrawableBuilder(name);
builder->setShader(resources->shader);
builder->setRenderPass(RenderPass::Translucent);
builder->setEnableDepth(false);
builder->setColorMode(gfx::ColorMode::unblended());
builder->setCullFaceMode(gfx::CullFaceMode::disabled());
builder->setVertexAttributes(std::move(attributes));
builder->setRawVertices({}, resources->particleVertices->elements(), gfx::AttributeDataType::Short2);
builder->setSegments(gfx::Lines(resources->lineWidth),
resources->particleIndices,
resources->particleSegments.data(),
resources->particleSegments.size());
builder->setTexture(previousStateTexture, shaders::idParticleGpuValidationPreviousTexture);
builder->setTexture(currentStateTexture, shaders::idParticleGpuValidationStateTexture);
builder->addTweaker(std::make_shared<ParticleGpuValidationTweaker>(resources,
ValidationPass::TrailParticles,
stateSlot,
trailSlot,
previousStateTexture,
target,
resources->maskTexture,
currentStateTexture,
0.0f,
0.0f,
VisualSimulationSecondsPerRealSecond,
0,
0));
builder->flush(context);
auto drawables = builder->clearDrawables();
if (drawables.size() != 1 || !drawables.front()) {
Log::Error(Event::Render, "ParticleGPU: event=PARTICLE_DRAW_REJECTED reason=DRAWABLE_FINALIZE_FAILED");
return false;
}
group.addDrawable(std::move(drawables.front()));
return true;
}
#if MLN_RENDER_BACKEND_METAL
bool buildParticleEndpointCoverage(gfx::Context& context,
const std::shared_ptr<ParticleGpuResources>& resources,
LayerGroup& group,
const std::string& name,
const gfx::Texture2DPtr& previousStateTexture,
const gfx::Texture2DPtr& currentStateTexture,
const gfx::Texture2DPtr& target,
const uint8_t stateSlot,
const uint8_t trailSlot) {
if (!resources->particleCoverageVertices || !resources->particleCoverageIndices ||
resources->particleCoverageSegments.empty() || !previousStateTexture || !currentStateTexture || !target) {
Log::Error(Event::Render, "ParticleGPU: event=PARTICLE_COVERAGE_REJECTED reason=MISSING_DRAW_RESOURCES");
return false;
}
auto attributes = context.createVertexAttributeArray();
if (const auto& attribute = attributes->set(shaders::idParticleGpuValidationPosVertexAttribute)) {
attribute->setSharedRawData(resources->particleCoverageVertices,
offsetof(HeatmapTextureLayoutVertex, a1),
0,
sizeof(HeatmapTextureLayoutVertex),
gfx::AttributeDataType::Short2);
}
auto builder = context.createDrawableBuilder(name);
builder->setShader(resources->shader);
builder->setRenderPass(RenderPass::Translucent);
builder->setEnableDepth(false);
builder->setColorMode(gfx::ColorMode::unblended());
builder->setCullFaceMode(gfx::CullFaceMode::disabled());
builder->setVertexAttributes(std::move(attributes));
builder->setRawVertices({}, resources->particleCoverageVertices->elements(), gfx::AttributeDataType::Short2);
builder->setSegments(gfx::Triangles(),
resources->particleCoverageIndices,
resources->particleCoverageSegments.data(),
resources->particleCoverageSegments.size());
builder->setTexture(previousStateTexture, shaders::idParticleGpuValidationPreviousTexture);
builder->setTexture(currentStateTexture, shaders::idParticleGpuValidationStateTexture);
builder->addTweaker(std::make_shared<ParticleGpuValidationTweaker>(resources,
ValidationPass::TrailParticleCoverage,
stateSlot,
trailSlot,
previousStateTexture,
target,
resources->maskTexture,
currentStateTexture,
0.0f,
0.0f,
VisualSimulationSecondsPerRealSecond,
0,
0));
builder->flush(context);
auto drawables = builder->clearDrawables();
if (drawables.size() != 1 || !drawables.front()) {
Log::Error(Event::Render, "ParticleGPU: event=PARTICLE_COVERAGE_REJECTED reason=DRAWABLE_FINALIZE_FAILED");
return false;
}
group.addDrawable(std::move(drawables.front()));
return true;
}
#endif
} // namespace
RenderRasterParticleLayer::RenderRasterParticleLayer(Immutable<style::Layer::Impl> impl_)
: RenderLayer([&] {
const auto settings = particleSettings(impl_);
return makeMutable<style::ParticleRenderLayerProperties>(std::move(impl_),
settings.active,
settings.maskMode,
settings.fixtureMode,
settings.timeMix,
settings.particleCount,
settings.lineWidth,
settings.particleLength,
settings.particleOpacity,
settings.trailOpacity,
settings.trailFade,
settings.advectionScale,
settings.speedColorEnabled,
settings.particleColor,
settings.trailColor);
}()) {
renderLayerInstanceId = ++nextRenderLayerInstanceId;
formalSource = baseImpl->getTypeInfo() == style::RasterParticleLayer::Impl::staticTypeInfo();
Log::Info(Event::Render,
"ParticleGPU: event=LAYER_CREATE layerId=" + getID() +
" renderLayerInstanceId=" + std::to_string(renderLayerInstanceId) +
" source=" + (formalSource ? baseImpl->source : std::string("validation-only")));
}
RenderRasterParticleLayer::~RenderRasterParticleLayer() {
if (gpuResources) {
Log::Info(Event::Render,
"ParticleGPU: event=LAYER_DESTROY layerId=" + getID() +
" renderLayerInstanceId=" + std::to_string(renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(gpuResources->generation) +
" contextDestroyed=" + (gpuResources->contextDestroyed ? "true" : "false"));
}
Log::Info(Event::Render, "ParticleValidation: RenderLayer destroy id=" + getID());
}
void RenderRasterParticleLayer::evaluate(const PropertyEvaluationParameters&) {
Log::Info(Event::Render,
"ParticleGPU: event=LAYER_CHANGED layerId=" + getID() +
" renderLayerInstanceId=" + std::to_string(renderLayerInstanceId));
const auto settings = particleSettings(baseImpl);
auto properties = makeMutable<style::ParticleRenderLayerProperties>(baseImpl,
settings.active,
settings.maskMode,
settings.fixtureMode,
settings.timeMix,
settings.particleCount,
settings.lineWidth,
settings.particleLength,
settings.particleOpacity,
settings.trailOpacity,
settings.trailFade,
settings.advectionScale,
settings.speedColorEnabled,
settings.particleColor,
settings.trailColor);
passes = RenderPass::Translucent;
properties->renderPasses = mbgl::underlying_type(passes);
evaluatedProperties = std::move(properties);
Log::Info(Event::Render,
"ParticleRender: state=" + std::string(settings.active ? "ACTIVE" : "STATIC") +
" maskMode=" + std::to_string(settings.maskMode) + " id=" + getID() +
" fixture=" + (formalSource && sourceField ? sourceField->fieldID : fixtureID(settings.fixtureMode)) +
" layerIndex=" + std::to_string(getLayerIndex()) + " timeMix=" + std::to_string(settings.timeMix) +
" particleCount=" + std::to_string(settings.particleCount) + " lineWidth=" +
std::to_string(settings.lineWidth) + " streakLength=" + std::to_string(settings.particleLength) +
" particleOpacity=" + std::to_string(settings.particleOpacity) + " trailOpacity=" +
std::to_string(settings.trailOpacity) + " trailFade=" + std::to_string(settings.trailFade) +
" advectionScale=" + std::to_string(settings.advectionScale) +
" speedColor=" + (settings.speedColorEnabled ? "true" : "false") + " stage=40-05");
}
bool RenderRasterParticleLayer::needsRepaint() const {
if (!isParticleAnimationActive()) return false;
const bool repaint = !formalSource ||
(sourceUsesV4Projection
? sourceV4Cadence && sourceV4Cadence->cadenceDue(std::chrono::steady_clock::now())
: !sourceTimeline || sourceTimeline->cadenceDue(std::chrono::steady_clock::now()));
if (repaint) particle::incrementRendererDebugCounter(particle::RendererDebugCounter::ParticleNeedsRepaint);
return repaint;
}
bool RenderRasterParticleLayer::isParticleAnimationActive() const {
const auto& properties = static_cast<const style::ParticleRenderLayerProperties&>(*evaluatedProperties);
if (!properties.active || (formalSource && (!sourceField || !sourceNextField)) ||
baseImpl->visibility == style::VisibilityType::None)
return false;
return true;
}
bool RenderRasterParticleLayer::needsHiddenLayerSourceCleanup(const RenderSource& renderSource) const {
if (!formalSource || !gpuResources || renderSource.baseImpl->type != style::SourceType::RasterArray) return false;
const auto& source = static_cast<const RenderRasterArraySource&>(renderSource);
const auto& impl = source.impl();
return impl.hostFieldSubmission && !impl.field && !impl.nextField && !impl.timeline;
}
void RenderRasterParticleLayer::markLayerRenderable(bool willRender, UniqueChangeRequestVec& changes) {
// RenderOrchestrator calls this while visiting each source. In a style with more
// than one source, false can therefore be a transient per-source result, not a
// durable particle disable. Persistent State/Trail resources are invalidated
// only by their real lifecycle events (field, viewport, context, or layer removal).
RenderLayer::markLayerRenderable(willRender, changes);
}
void RenderRasterParticleLayer::layerRemoved(UniqueChangeRequestVec& changes) {
if (gpuResources) {
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_RESOURCES_RELEASE reason=LAYER_REMOVED particleCount=" +
std::to_string(gpuResources->particleCount) +
" resourceGeneration=" + std::to_string(gpuResources->generation));
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_DRAW_RESOURCES_RELEASE reason=LAYER_REMOVED streakVertexCount=" +
std::to_string(gpuResources->particleVertices ? gpuResources->particleVertices->elements() : 0) +
" fullParticleDraw=true resourceGeneration=" + std::to_string(gpuResources->generation));
removeTargets(gpuResources->registeredTargets, changes);
gpuResources->registeredTargets.clear();
}
RenderLayer::layerRemoved(changes);
}
void RenderRasterParticleLayer::markContextDestroyed() {
if (gpuResources) {
gpuResources->contextDestroyed = true;
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_RESOURCES_RELEASE reason=CONTEXT_DESTROYED particleCount=" +
std::to_string(gpuResources->particleCount) +
" resourceGeneration=" + std::to_string(gpuResources->generation));
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_DRAW_RESOURCES_RELEASE reason=CONTEXT_DESTROYED streakVertexCount=" +
std::to_string(gpuResources->particleVertices ? gpuResources->particleVertices->elements() : 0) +
" fullParticleDraw=true resourceGeneration=" + std::to_string(gpuResources->generation));
Log::Info(
Event::Render,
"ParticleValidation30_02: context lost discard generation=" + std::to_string(gpuResources->generation) +
" stateRead=" + slotName(gpuResources->stateRead) + " trailRead=" + slotName(gpuResources->trailRead));
}
}
void RenderRasterParticleLayer::prepare(const LayerPrepareParameters& parameters) {
if (!formalSource || !parameters.source || parameters.source->baseImpl->type != style::SourceType::RasterArray) {
return;
}
const auto& source = static_cast<const RenderRasterArraySource&>(*parameters.source);
sourceUsesV4Projection = false;
sourceProjectionMix = 0.0f;
if (source.impl().v4RendererState.particle) {
const auto& projection = *source.impl().v4RendererState.particle;
sourceField = projection.t0;
sourceNextField = projection.t1;
sourceProjectionMix = projection.sampleState.mix;
sourceTimeline.reset();
sourceV4Cadence = source.impl().v4Cadence;
sourceUsesV4Projection = true;
return;
}
sourceV4Cadence.reset();
sourceTimeline = source.impl().timeline;
if (sourceTimeline) {
const auto frame = sourceTimeline->frame();
sourceField = frame.t0;
sourceNextField = frame.t1;
sourceTimelineWindowRevision = frame.windowRevision;
return;
}
sourceField.reset();
sourceNextField.reset();
if (source.impl().field) {
sourceField = std::make_shared<const particle::ParticleFieldSnapshot>(*source.impl().field);
sourceNextField = source.impl().nextField
? std::make_shared<const particle::ParticleFieldSnapshot>(*source.impl().nextField)
: sourceField;
}
}
void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
gfx::Context& context,
const TransformState& transformState,
const std::shared_ptr<UpdateParameters>&,
const RenderTree&,
UniqueChangeRequestVec& changes) {
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::ParticleUpdate);
if (!layerGroup) {
if (auto group = context.createLayerGroup(layerIndex, 1, getID())) {
setLayerGroup(std::move(group), changes);
}
}
if (formalSource && (!sourceField || !sourceNextField)) {
if (layerGroup) static_cast<LayerGroup&>(*layerGroup).clearDrawables();
if (gpuResources) {
Log::Info(Event::Render,
"WeatherRuntime: event=PARTICLE_RESOURCE_RELEASE reason=FIELD_WINDOW_CLEAR layerId=" + getID());
Log::Info(Event::Render,
"WeatherRuntime: event=TRAIL_RESOURCE_RELEASE reason=FIELD_WINDOW_CLEAR layerId=" + getID());
removeTargets(gpuResources->registeredTargets, changes);
gpuResources->registeredTargets.clear();
// Targets contain tweakers retaining Resources; break that cycle.
for (const auto& target : gpuResources->stateTargets)
if (target && target->getLayerGroup(0)) target->getLayerGroup(0)->clearDrawables();
for (const auto& target : gpuResources->trailTargets)
if (target && target->getLayerGroup(0)) target->getLayerGroup(0)->clearDrawables();
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::GpuResourceRelease);
gpuResources.reset();
}
return;
}
// Particle advection requires a compatible vector pair. Scalar-only
// remains unavailable; VELOCITY and DIRECTION are resolved in the shader.
if (formalSource &&
(!sourceField->hasVector() || !sourceNextField->hasVector() || !sourceField->vectorSemantics ||
!sourceNextField->vectorSemantics || sourceField->vectorSemantics != sourceNextField->vectorSemantics)) {
if (layerGroup) static_cast<LayerGroup&>(*layerGroup).clearDrawables();
if (gpuResources) {
removeTargets(gpuResources->registeredTargets, changes);
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::GpuResourceRelease);
gpuResources.reset();
}
return;
}
if (!layerGroup || !isLayerRenderable()) return;
const auto& properties = static_cast<const style::ParticleRenderLayerProperties&>(*evaluatedProperties);
const bool active = properties.active;
const uint8_t maskMode = properties.maskMode;
const uint8_t fixtureMode = properties.fixtureMode;
const uint32_t particleCount = properties.particleCount;
const float advectionScale = properties.advectionScale;
float timelineMix = sourceUsesV4Projection ? sourceProjectionMix : properties.timeMix;
bool cadenceAdvanced = true;
uint64_t cadenceRevision = sourceTimelineCadenceRevision;
if (formalSource && sourceTimeline) {
const auto advance = sourceTimeline->advanceIfDue(std::chrono::steady_clock::now());
const auto& frame = advance.frame;
timelineMix = frame.mix;
cadenceRevision = frame.cadenceRevision;
cadenceAdvanced = frame.cadenceRevision != sourceTimelineCadenceRevision;
if (frame.windowRevision != sourceTimelineWindowRevision) {
sourceField = frame.t0;
sourceNextField = frame.t1;
sourceTimelineWindowRevision = frame.windowRevision;
}
} else if (formalSource && sourceUsesV4Projection && sourceV4Cadence) {
cadenceAdvanced = sourceV4Cadence->consumeIfDue(std::chrono::steady_clock::now());
}
auto resources = gpuResources;
if (resources && resources->contextDestroyed) {
// VALIDATION ONLY: follow the 30-02 resource-generation rule. No stale
// State, Trail, or Mask texture survives a notified context loss.
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::GpuResourceRelease);
gpuResources.reset();
resources.reset();
}
if (!resources) {
auto owned = std::make_shared<ParticleGpuResources>();
owned->renderLayerInstanceId = renderLayerInstanceId;
owned->generation = ++nextGpuGeneration;
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::GpuResourceCreate);
owned->formalDisplay = formalSource;
owned->shader = context.getGenericShader(shaders, ShaderName);
owned->vertices = std::make_shared<gfx::VertexVector<HeatmapTextureLayoutVertex>>(
RenderStaticData::heatmapTextureVertices());
owned->segments = RenderStaticData::heatmapTextureSegments();
owned->particleCount = particleCount;
owned->stateSize = particleStateSize(owned->particleCount);
rebuildParticleDrawResources(owned);
for (uint8_t i = 0; i < 2; ++i) {
owned->stateTargets[i] = createValidationTarget(context,
owned->stateSize,
getID() + "-state-" + slotName(i),
gfx::TextureFilterType::Nearest,
gfx::TextureChannelDataType::Float,
"STATE");
}
if (!owned->shader || !owned->stateTargets[0] || !owned->stateTargets[1]) {
Log::Error(
Event::Render,
"ParticleStateTarget: event=CREATE_FAILED kind=STATE dataType=Float reason=FRAMEBUFFER_UNAVAILABLE");
return;
}
owned->maskFixture = makeCoastalMaskFixture();
owned->maskTexture = context.createTexture2D();
if (!owned->maskFixture || !owned->maskTexture) {
Log::Error(Event::Render, "ParticleValidation30_03: mask fixture/resource creation failed");
return;
}
owned->maskTexture->setImage(owned->maskFixture);
owned->maskTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Clamp,
.wrapV = gfx::TextureWrapType::Clamp});
owned->maskGeneration = ++nextMaskGeneration;
try {
if (formalSource) {
if (!sourceField || !sourceNextField) {
Log::Error(Event::Render, "ParticleField: raster-array source has no loaded time window");
return;
}
owned->field = *sourceField;
owned->nextField = *sourceNextField;
owned->fieldGeneration = owned->field.generation;
owned->nextFieldGeneration = owned->nextField.generation;
} else {
owned->fieldGeneration = ++nextFieldGeneration;
owned->field = particle::OfflineFixtureParticleAdapter::load(fixtureID(fixtureMode),
owned->fieldGeneration);
owned->nextField = owned->field;
owned->nextFieldGeneration = owned->fieldGeneration;
}
owned->fieldImage = makeVelocityFieldImage(owned->field);
owned->fieldTexture = context.createTexture2D();
owned->nextFieldImage = makeVelocityFieldImage(owned->nextField);
owned->nextFieldTexture = context.createTexture2D();
if (!owned->fieldImage || !owned->fieldTexture || !owned->nextFieldImage || !owned->nextFieldTexture) {
Log::Error(Event::Render, "ParticleGPU: field resource creation failed");
return;
}
owned->fieldTexture->setImage(owned->fieldImage);
Log::Info(Event::Render,
"ParticleGPU: event=FIELD_UPLOAD slot=T0 reason=RESOURCE_CREATE fieldUploadCount=1 "
"sourceGeneration=" +
std::to_string(owned->field.generation) +
" renderLayerInstanceId=" + std::to_string(owned->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(owned->generation));
owned->fieldTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Clamp,
.wrapV = gfx::TextureWrapType::Clamp});
owned->nextFieldTexture->setImage(owned->nextFieldImage);
Log::Info(Event::Render,
"ParticleGPU: event=FIELD_UPLOAD slot=T1 reason=RESOURCE_CREATE fieldUploadCount=2 "
"sourceGeneration=" +
std::to_string(owned->nextField.generation) +
" renderLayerInstanceId=" + std::to_string(owned->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(owned->generation));
owned->nextFieldTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Clamp,
.wrapV = gfx::TextureWrapType::Clamp});
owned->gpuFieldGeneration = owned->fieldGeneration;
owned->gpuNextFieldGeneration = owned->nextFieldGeneration;
owned->fieldUploadCount = 2;
owned->fieldScale = fieldVelocityScale(owned->field);
owned->nextFieldScale = fieldVelocityScale(owned->nextField);
owned->fixtureMode = fixtureMode;
} catch (const std::exception& error) {
Log::Error(Event::Render, "ParticleField: fixture load failed: " + std::string(error.what()));
return;
}
gpuResources = std::move(owned);
resources = gpuResources;
Log::Info(Event::Render,
"ParticleValidation30_02: State A/B create generation=" + std::to_string(resources->generation) +
" size=" + std::to_string(resources->stateSize.width) + "x" +
std::to_string(resources->stateSize.height) + " independent=true");
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_RESOURCES_CREATE reason=RESOURCE_CREATE requestedParticleCount=" +
std::to_string(particleCount) +
" effectiveParticleCount=" + std::to_string(resources->particleCount) + " stateGrid=" +
std::to_string(resources->stateSize.width) + "x" + std::to_string(resources->stateSize.height) +
" resourceGeneration=" + std::to_string(resources->generation));
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_DRAW_RESOURCES_CREATE effectiveParticleCount=" +
std::to_string(resources->particleCount) + " stateGrid=" +
std::to_string(resources->stateSize.width) + "x" + std::to_string(resources->stateSize.height) +
" particleDrawCount=" + std::to_string(resources->particleCount) +
" streakVertexCount=" + std::to_string(resources->particleVertices->elements()) +
" fullParticleDraw=true resourceGeneration=" + std::to_string(resources->generation));
Log::Info(Event::Render,
"ParticleField: field loaded id=" + resources->field.fieldID + " type=" + resources->field.fieldType +
" bounds=[" + std::to_string(resources->field.west) + ',' +
std::to_string(resources->field.east) + "]x[" + std::to_string(resources->field.south) + ',' +
std::to_string(resources->field.north) + "] grid=" + std::to_string(resources->field.width) +
'x' + std::to_string(resources->field.height) +
" dataVersion=" + std::to_string(resources->field.dataVersion) +
" fieldGeneration=" + std::to_string(resources->fieldGeneration));
Log::Info(Event::Render,
"ParticleField: field validated id=" + resources->field.fieldID +
" noData=" + std::to_string(resources->field.noData) +
" vectorCount=" + std::to_string(resources->field.vector.size()));
Log::Info(Event::Render,
"ParticleField: created id=" + resources->field.fieldID +
" generation=" + std::to_string(resources->fieldGeneration));
Log::Info(Event::Render,
"ParticleGPU: field upload reason=RESOURCE_RECREATE field=" + resources->field.fieldID +
" fieldGeneration=" + std::to_string(resources->fieldGeneration) +
" gpuFieldGeneration=" + std::to_string(resources->gpuFieldGeneration) +
" resourceGeneration=" + std::to_string(resources->generation));
Log::Info(Event::Render,
"ParticleGPU: time window T0=" + resources->field.fieldID + "@" +
std::to_string(resources->field.dataVersion) + " T1=" + resources->nextField.fieldID + "@" +
std::to_string(resources->nextField.dataVersion) +
" uploads=" + std::to_string(resources->fieldUploadCount));
Log::Info(
Event::Render,
"ParticleValidation30_03: mask fixture ready id=coastal-mask-validation-v1 bounds=[-180,180]x[-85,85] "
"encoding=uint8-land-ocean land=0 ocean=255 maskGeneration=" +
std::to_string(resources->maskGeneration));
Log::Info(Event::Render,
"ParticleValidation30_03: anchors A=0,0 B=0,-90 C=0,-172.8 D=0,172.8 canonical=Mercator-z0");
}
// This is a renderer uniform only. It intentionally preserves State,
// Trail, Timeline, and uploaded field textures when changed.
resources->advectionScale = advectionScale;
if (resources->particleCount != particleCount) {
const auto previousCount = resources->particleCount;
const auto previousSize = resources->stateSize;
const auto requestedSize = particleStateSize(particleCount);
std::array<RenderTargetPtr, 2> replacementTargets;
for (uint8_t i = 0; i < 2; ++i) {
replacementTargets[i] = createValidationTarget(context,
requestedSize,
getID() + "-state-" + slotName(i),
gfx::TextureFilterType::Nearest,
gfx::TextureChannelDataType::Float,
"STATE");
}
if (!replacementTargets[0] || !replacementTargets[1]) {
Log::Error(Event::Render,
"ParticleGPU: event=PARTICLE_COUNT_REJECTED reason=STATE_RESOURCE_CREATE_FAILED dataType=Float "
"requestedParticleCount=" +
std::to_string(particleCount) +
" effectiveParticleCount=" + std::to_string(resources->particleCount) +
" resourceGeneration=" + std::to_string(resources->generation));
return;
}
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_RESOURCES_RELEASE reason=PARTICLE_COUNT_CHANGE particleCount=" +
std::to_string(previousCount) + " stateGrid=" + std::to_string(previousSize.width) + "x" +
std::to_string(previousSize.height) +
" resourceGeneration=" + std::to_string(resources->generation));
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_DRAW_RESOURCES_RELEASE reason=PARTICLE_COUNT_CHANGE streakVertexCount=" +
std::to_string(resources->particleVertices ? resources->particleVertices->elements() : 0) +
" fullParticleDraw=true resourceGeneration=" + std::to_string(resources->generation));
removeTargets(resources->stateTargets, changes);
resources->stateTargets = std::move(replacementTargets);
resources->particleCount = particleCount;
resources->stateSize = requestedSize;
rebuildParticleDrawResources(resources);
resources->resetSequence("PARTICLE_COUNT_CHANGE");
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_RESOURCES_REBUILD reason=PARTICLE_COUNT_CHANGE requestedParticleCount=" +
std::to_string(particleCount) +
" effectiveParticleCount=" + std::to_string(resources->particleCount) + " stateGrid=" +
std::to_string(resources->stateSize.width) + "x" + std::to_string(resources->stateSize.height) +
" resourceGeneration=" + std::to_string(resources->generation));
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_DRAW_RESOURCES_REBUILD effectiveParticleCount=" +
std::to_string(resources->particleCount) + " stateGrid=" +
std::to_string(resources->stateSize.width) + "x" + std::to_string(resources->stateSize.height) +
" particleDrawCount=" + std::to_string(resources->particleCount) +
" streakVertexCount=" + std::to_string(resources->particleVertices->elements()) +
" fullParticleDraw=true resourceGeneration=" + std::to_string(resources->generation));
}
const bool sourceChanged = formalSource && sourceField && sourceNextField &&
(!particle::sameFieldGpuEndpoint(resources->field, *sourceField) ||
!particle::sameFieldGpuEndpoint(resources->nextField, *sourceNextField));
if ((!formalSource && resources->fixtureMode != fixtureMode) || sourceChanged) {
try {
const auto oldT0 = resources->field;
const auto oldT1 = resources->nextField;
auto nextT0 = formalSource ? *sourceField
: particle::OfflineFixtureParticleAdapter::load(fixtureID(fixtureMode),
++nextFieldGeneration);
auto nextT1 = formalSource ? *sourceNextField : nextT0;
if (!nextT0.isTimeInterpolationCompatibleWith(nextT1)) {
Log::Error(Event::Render, "ParticleGPU: rejected incompatible time-window fields");
return;
}
const bool reuseOldT0 = particle::sameFieldGpuEndpoint(oldT0, nextT0);
const bool reuseOldT1 = particle::sameFieldGpuEndpoint(oldT1, nextT0);
const bool reuseNewT0 = particle::sameFieldGpuEndpoint(oldT0, nextT1);
const bool reuseNewT1 = particle::sameFieldGpuEndpoint(oldT1, nextT1);
const bool windowOverlap = reuseOldT0 || reuseOldT1 || reuseNewT0 || reuseNewT1;
const auto upload = [&](const particle::ParticleFieldSnapshot& field,
std::shared_ptr<PremultipliedImage>& image,
gfx::Texture2DPtr& texture,
float& scale) {
image = makeVelocityFieldImage(field);
texture = context.createTexture2D();
if (!image || !texture) return false;
texture->setImage(image);
texture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Clamp,
.wrapV = gfx::TextureWrapType::Clamp});
scale = fieldVelocityScale(field);
++resources->fieldUploadCount;
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::FieldTextureUpload);
Log::Info(Event::Render,
"ParticleGPU: event=FIELD_UPLOAD reason=NEW_CONTENT fieldUploadCount=" +
std::to_string(resources->fieldUploadCount) +
" sourceGeneration=" + std::to_string(field.generation) +
" renderLayerInstanceId=" + std::to_string(resources->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(resources->generation));
return true;
};
auto takeSlot = [&](const particle::ParticleFieldSnapshot& wanted,
const char* destination,
particle::ParticleFieldSnapshot& assigned,
std::shared_ptr<PremultipliedImage>& image,
gfx::Texture2DPtr& texture,
float& scale) {
if (particle::gpuEndpointSource(oldT0, oldT1, wanted) == particle::GpuEndpointSource::OldT0 &&
resources->fieldTexture) {
assigned = wanted;
image = resources->fieldImage;
texture = resources->fieldTexture;
scale = resources->fieldScale;
Log::Info(Event::Render,
"ParticleGPU: event=FIELD_REUSE from=OLD_T0 to=" + std::string(destination) +
" oldGeneration=" + std::to_string(oldT0.generation) +
" newGeneration=" + std::to_string(wanted.generation) +
" renderLayerInstanceId=" + std::to_string(resources->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(resources->generation));
return true;
}
if (particle::gpuEndpointSource(oldT0, oldT1, wanted) == particle::GpuEndpointSource::OldT1 &&
resources->nextFieldTexture) {
assigned = wanted;
image = resources->nextFieldImage;
texture = resources->nextFieldTexture;
scale = resources->nextFieldScale;
Log::Info(Event::Render,
"ParticleGPU: event=FIELD_REUSE from=OLD_T1 to=" + std::string(destination) +
" oldGeneration=" + std::to_string(oldT1.generation) +
" newGeneration=" + std::to_string(wanted.generation) +
" renderLayerInstanceId=" + std::to_string(resources->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(resources->generation));
return true;
}
assigned = wanted;
return upload(assigned, image, texture, scale);
};
particle::ParticleFieldSnapshot assignedT0;
particle::ParticleFieldSnapshot assignedT1;
std::shared_ptr<PremultipliedImage> imageT0;
std::shared_ptr<PremultipliedImage> imageT1;
gfx::Texture2DPtr textureT0;
gfx::Texture2DPtr textureT1;
float scaleT0 = 1.0f;
float scaleT1 = 1.0f;
if (!takeSlot(nextT0, "NEW_T0", assignedT0, imageT0, textureT0, scaleT0)) {
Log::Error(Event::Render, "ParticleGPU: field switch resource creation failed");
return;
}
if (particle::sameFieldGpuEndpoint(nextT0, nextT1)) {
assignedT1 = assignedT0;
imageT1 = imageT0;
textureT1 = textureT0;
scaleT1 = scaleT0;
} else if (!takeSlot(nextT1, "NEW_T1", assignedT1, imageT1, textureT1, scaleT1)) {
Log::Error(Event::Render, "ParticleGPU: field switch resource creation failed");
return;
}
resources->field = std::move(assignedT0);
resources->nextField = std::move(assignedT1);
resources->fieldImage = std::move(imageT0);
resources->nextFieldImage = std::move(imageT1);
resources->fieldTexture = std::move(textureT0);
resources->nextFieldTexture = std::move(textureT1);
resources->fieldScale = scaleT0;
resources->nextFieldScale = scaleT1;
resources->fieldGeneration = resources->field.generation;
resources->nextFieldGeneration = resources->nextField.generation;
resources->gpuFieldGeneration = resources->fieldGeneration;
resources->gpuNextFieldGeneration = resources->nextFieldGeneration;
resources->fixtureMode = fixtureMode;
if (!windowOverlap) resources->resetSequence("FIELD_NO_OVERLAP");
Log::Info(Event::Render,
"ParticleGPU: time window advance T0=" + oldT0.fieldID + " T1=" + oldT1.fieldID +
" -> T0=" + resources->field.fieldID + " T1=" + resources->nextField.fieldID +
" reuse=" + (windowOverlap ? "true" : "false") +
" uploads=" + std::to_string(resources->fieldUploadCount) + " simulationRebuild=" +
(windowOverlap ? "false" : "true") + " trailCleared=" + (windowOverlap ? "false" : "true"));
} catch (const std::exception& error) {
Log::Error(Event::Render, "ParticleField: fixture switch failed: " + std::string(error.what()));
return;
}
}
// Keep the shared Core runtime alive in the per-frame drawable tweaker.
// This is a pointer to Source-owned state, not a second timeline.
resources->timeline = sourceTimeline;
const auto requestedTimeMix = std::clamp(timelineMix, 0.0f, 1.0f);
if (resources->timeMix != requestedTimeMix) {
resources->timeMix = requestedTimeMix;
if (!sourceTimeline || std::abs(resources->timeMix * 4.0f - std::round(resources->timeMix * 4.0f)) < 0.01f) {
Log::Info(Event::Render,
"ParticleGPU: event=TIME_MIX_UPDATE value=" + std::to_string(resources->timeMix) +
" T0=" + resources->field.fieldID + " T1=" + resources->nextField.fieldID +
" renderLayerInstanceId=" + std::to_string(resources->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(resources->generation) +
" fieldUploadCount=" + std::to_string(resources->fieldUploadCount) +
" resetCount=" + std::to_string(resources->resetCount));
}
}
const bool visualChanged = resources->lineWidth != properties.lineWidth ||
resources->particleLength != properties.particleLength ||
resources->particleOpacity != properties.particleOpacity ||
resources->trailOpacity != properties.trailOpacity ||
resources->trailFade != properties.trailFade ||
resources->speedColorEnabled != properties.speedColorEnabled ||
resources->particleColor != properties.particleColor ||
resources->trailColor != properties.trailColor;
if (visualChanged) {
resources->lineWidth = properties.lineWidth;
resources->particleLength = properties.particleLength;
resources->particleOpacity = properties.particleOpacity;
resources->trailOpacity = properties.trailOpacity;
resources->trailFade = properties.trailFade;
resources->speedColorEnabled = properties.speedColorEnabled;
resources->particleColor = properties.particleColor;
resources->trailColor = properties.trailColor;
Log::Info(Event::Render,
"ParticleGPU: event=VISUAL_PROPERTY_APPLIED lineWidth=" + std::to_string(resources->lineWidth) +
" streakLength=" + std::to_string(resources->particleLength) +
" particleOpacity=" + std::to_string(resources->particleOpacity) + " trailOpacity=" +
std::to_string(resources->trailOpacity) + " trailFade=" + std::to_string(resources->trailFade) +
" speedColor=" + (resources->speedColorEnabled ? "true" : "false") +
" renderLayerInstanceId=" + std::to_string(resources->renderLayerInstanceId) +
" resourceGeneration=" + std::to_string(resources->generation) +
" fieldUploadCount=" + std::to_string(resources->fieldUploadCount) +
" resetCount=" + std::to_string(resources->resetCount));
}
if (!resources->lifecycleModeKnown || resources->lifecycleActive != active) {
Log::Info(Event::Render,
"ParticleRender: lifecycle transition=" + std::string(active ? "ACTIVE" : "STATIC") +
" resourceGeneration=" + std::to_string(resources->generation) + " fixture=" +
resources->field.fieldID + " simulationFrame=" + std::to_string(resources->committedFrame) +
" stateRead=" + slotName(resources->stateRead) + " trailRead=" + slotName(resources->trailRead));
resources->lifecycleModeKnown = true;
resources->lifecycleActive = active;
}
if (resources->loggedMaskMode != maskMode) {
Log::Info(Event::Render,
"ParticleValidation30_03: mask mode=" + std::to_string(maskMode) + " consumer=" +
(maskMode == 0 ? std::string("GLOBAL") : (maskMode == 1 ? "OCEAN_ONLY_A" : "OCEAN_ONLY_B")) +
" maskGeneration=" + std::to_string(resources->maskGeneration) + " reuse=true");
resources->loggedMaskMode = maskMode;
}
removeTargets(resources->registeredTargets, changes);
resources->registeredTargets.clear();
const auto viewport = validViewportSize(transformState.getSize());
const auto trailCamera = transformState.getLatLng(LatLng::Unwrapped);
const auto cameraNow = std::chrono::steady_clock::now();
const bool viewportChanged = resources->formalDisplay && resources->trailTargets[0] && resources->trailTargets[1] &&
resources->trailSize != viewport;
const bool transformChanged = resources->formalDisplay && resources->trailCameraKnown &&
(std::abs(resources->trailCameraLatitude - trailCamera.latitude()) > 0.00001 ||
std::abs(resources->trailCameraLongitude - trailCamera.longitude()) > 0.00001 ||
std::abs(resources->trailCameraZoom - transformState.getZoom()) > 0.0001 ||
std::abs(resources->trailCameraBearing - transformState.getBearing()) > 0.0001 ||
std::abs(resources->trailCameraPitch - transformState.getPitch()) > 0.0001);
bool cameraBecameStable = false;
if (viewportChanged) {
if (transformChanged) {
resources->viewportReseedGesture.begin(resources->trailCameraZoom);
}
const auto previousSize = resources->trailSize;
resources->trailTargets = {};
resources->trailSize = {};
resources->trailRead = 0;
resources->trailSeeded = false;
resources->trailCameraInvalidated = true;
resources->viewportDensityLogged = false;
resources->lastTrailCameraChange = cameraNow;
Log::Info(Event::Render,
"ParticleTrail: event=VIEWPORT_RESIZE old=" + std::to_string(previousSize.width) + "x" +
std::to_string(previousSize.height) + " new=" + std::to_string(viewport.width) + "x" +
std::to_string(viewport.height) + " statePreserved=true fieldResubmit=false timelineReset=false");
} else if (transformChanged) {
resources->viewportReseedGesture.begin(resources->trailCameraZoom);
if (!resources->trailCameraInvalidated) {
const auto reason = std::abs(resources->trailCameraZoom - transformState.getZoom()) > 0.0001 ? "zoom"
: std::abs(resources->trailCameraBearing - transformState.getBearing()) > 0.0001
? "rotate"
: std::abs(resources->trailCameraPitch - transformState.getPitch()) > 0.0001 ? "pitch"
: "pan";
resources->trailCameraInvalidated = true;
resources->trailSeeded = false;
resources->viewportDensityLogged = false;
Log::Info(Event::Render,
"ParticleTrail: event=CAMERA_INVALIDATE reason=" + std::string(reason) +
" statePreserved=true fieldResubmit=false timelineReset=false");
}
resources->lastTrailCameraChange = cameraNow;
} else if (resources->trailCameraInvalidated &&
cameraNow - resources->lastTrailCameraChange >= TrailCameraStableDelay) {
resources->trailCameraInvalidated = false;
cameraBecameStable = true;
}
resources->trailCameraKnown = true;
resources->trailCameraLatitude = trailCamera.latitude();
resources->trailCameraLongitude = trailCamera.longitude();
resources->trailCameraZoom = transformState.getZoom();
resources->trailCameraBearing = transformState.getBearing();
resources->trailCameraPitch = transformState.getPitch();
const auto respawnDomain = viewportRespawnDomain(transformState, viewport, resources->field);
resources->viewportDensityActive = resources->formalDisplay && !resources->trailCameraInvalidated &&
!respawnDomain.fallback;
// During a camera gesture, State continues normally but all ordinary
// lifecycle respawns stay in the full Field. The viewport domain is only
// enabled after the existing stable-camera debounce has completed.
resources->respawnBounds = resources->viewportDensityActive ? respawnDomain.bounds : fieldBounds(resources->field);
if (cameraBecameStable) {
const auto reseedDecision = resources->viewportReseedGesture.finish(transformState.getZoom(),
resources->viewportDensityActive);
if (reseedDecision.action == ParticleViewportReseedAction::Reseed) {
resources->stateSeeded = false;
resources->trailSeeded = false;
const auto& bounds = resources->respawnBounds;
Log::Info(Event::Render,
"ParticleDensity: event=VIEWPORT_RESEED reason=ZOOM_OUT startZoom=" +
std::to_string(reseedDecision.startZoom) + " endZoom=" +
std::to_string(reseedDecision.endZoom) + " respawnBounds=[" +
std::to_string(bounds[0]) + "," + std::to_string(bounds[1]) + "," +
std::to_string(bounds[2]) + "," + std::to_string(bounds[3]) +
"] stateReseed=true trailReseed=true fieldResubmit=false timelineReset=false "
"gpuResourceRebuild=false");
} else if (reseedDecision.action == ParticleViewportReseedAction::SkipInvalidDomain) {
Log::Info(Event::Render,
"ParticleDensity: event=VIEWPORT_RESEED reason=ZOOM_OUT action=SKIP "
"skipReason=INVALID_RESPAWN_DOMAIN startZoom=" +
std::to_string(reseedDecision.startZoom) + " endZoom=" +
std::to_string(reseedDecision.endZoom) +
" stateReseed=false trailReseed=false fieldResubmit=false timelineReset=false "
"gpuResourceRebuild=false");
}
Log::Info(Event::Render,
"ParticleTrail: event=CAMERA_STABLE_RESUME statePreserved=" +
std::string(reseedDecision.action == ParticleViewportReseedAction::Reseed ? "false" : "true") +
" fieldResubmit=false timelineReset=false");
}
if (resources->formalDisplay && !resources->trailCameraInvalidated && !resources->viewportDensityLogged) {
const auto& bounds = resources->respawnBounds;
const auto& currentFieldBounds = fieldBounds(resources->field);
Log::Info(Event::Render,
"ParticleDensity: zoom=" + std::to_string(transformState.getZoom()) +
" mode=" + std::string(respawnDomain.fallback ? "FIELD_FALLBACK" : "VIEWPORT_RESPAWN") +
" cameraStable=true margin=" + std::to_string(ViewportRespawnMargin) + " respawnBounds=[" +
std::to_string(bounds[0]) + "," + std::to_string(bounds[1]) + "," + std::to_string(bounds[2]) +
"," + std::to_string(bounds[3]) + "] fieldBounds=[" + std::to_string(currentFieldBounds[0]) +
"," + std::to_string(currentFieldBounds[1]) + "," + std::to_string(currentFieldBounds[2]) + "," +
std::to_string(currentFieldBounds[3]) +
"] fallback=" + (respawnDomain.fallback ? "true" : "false") +
" configuredSlots=" + std::to_string(resources->particleCount));
resources->viewportDensityLogged = true;
}
const auto ensureTrailTargets = [&] {
if (resources->trailTargets[0] && resources->trailTargets[1] && resources->trailSize == viewport) {
return true;
}
const auto previousSize = resources->trailSize;
resources->trailTargets = {};
for (uint8_t i = 0; i < 2; ++i) {
resources->trailTargets[i] = createValidationTarget(context,
viewport,
getID() + "-trail-" + slotName(i),
gfx::TextureFilterType::Linear,
gfx::TextureChannelDataType::UnsignedByte,
"TRAIL");
}
if (!resources->trailTargets[0] || !resources->trailTargets[1]) {
Log::Error(Event::Render, "ParticleGPU: Trail resource creation failed");
return false;
}
resources->trailSize = viewport;
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::TrailTargetCreateOrResize);
resources->trailRead = 0;
resources->trailSeeded = false;
Log::Info(
Event::Render,
"ParticleStateTarget: stateAType=Float stateBType=Float trailAType=UnsignedByte trailBType=UnsignedByte "
"stateGrid=" +
std::to_string(resources->stateSize.width) + "x" + std::to_string(resources->stateSize.height) +
" trailSize=" + std::to_string(viewport.width) + "x" + std::to_string(viewport.height) +
" resourceGeneration=" + std::to_string(resources->generation));
Log::Info(Event::Render,
"ParticleGPU: Trail A/B " + std::string(previousSize.isEmpty() ? "create" : "resize") +
" resourceGeneration=" + std::to_string(resources->generation) +
" old=" + std::to_string(previousSize.width) + "x" + std::to_string(previousSize.height) +
" new=" + std::to_string(viewport.width) + "x" + std::to_string(viewport.height) +
" StatePreserved=true");
return true;
};
if (!active) {
// STATIC freezes simulation only. A recreated activity still needs the
// resource bootstrap and first current-frame composite without a button tap.
if (!ensureTrailTargets()) {
static_cast<LayerGroup&>(*layerGroup).clearDrawables();
return;
}
resources->pending = false;
auto& compositeGroup = static_cast<LayerGroup&>(*layerGroup);
if (!resources->stateSeeded || !resources->trailSeeded) {
const auto stateWrite = resources->stateWrite();
const auto trailWrite = resources->trailWrite();
auto& stateGroup = static_cast<LayerGroup&>(*resources->stateTargets[stateWrite]->getLayerGroup(0));
auto& trailGroup = static_cast<LayerGroup&>(*resources->trailTargets[trailWrite]->getLayerGroup(0));
const bool stateBuilt = buildQuad(context,
resources,
stateGroup,
getID() + "-static-bootstrap-state-pass",
resources->stateTargets[resources->stateRead]->getTexture(),
resources->stateTargets[stateWrite]->getTexture(),
resources->maskTexture,
{},
0.0f,
ValidationPass::State,
resources->stateRead,
stateWrite,
0,
maskMode,
true);
const bool trailBuilt = buildQuad(context,
resources,
trailGroup,
getID() + "-static-bootstrap-trail-pass",
resources->trailTargets[resources->trailRead]->getTexture(),
resources->trailTargets[trailWrite]->getTexture(),
resources->maskTexture,
resources->stateTargets[stateWrite]->getTexture(),
0.0f,
ValidationPass::Trail,
resources->trailRead,
trailWrite,
0,
maskMode,
true);
const bool particlesBuilt = !resources->formalDisplay || resources->trailCameraInvalidated ||
buildParticlePoints(context,
resources,
trailGroup,
getID() + "-static-bootstrap-trail-particles",
resources->stateTargets[resources->stateRead]->getTexture(),
resources->stateTargets[stateWrite]->getTexture(),
resources->trailTargets[trailWrite]->getTexture(),
stateWrite,
trailWrite);
#if MLN_RENDER_BACKEND_METAL
const bool endpointCoverageBuilt = !resources->formalDisplay || resources->trailCameraInvalidated ||
buildParticleEndpointCoverage(
context,
resources,
trailGroup,
getID() + "-static-bootstrap-trail-particle-endpoints",
resources->stateTargets[resources->stateRead]->getTexture(),
resources->stateTargets[stateWrite]->getTexture(),
resources->trailTargets[trailWrite]->getTexture(),
stateWrite,
trailWrite);
#else
const bool endpointCoverageBuilt = true;
#endif
compositeGroup.clearDrawables();
bool compositeBuilt = true;
const auto center = transformState.getLatLng(LatLng::Unwrapped);
const auto centerWrap = wrapForLongitude(center.longitude());
const auto firstWrap = resources->formalDisplay ? int16_t{0} : static_cast<int16_t>(centerWrap - 1);
const auto lastWrap = resources->formalDisplay ? int16_t{0} : static_cast<int16_t>(centerWrap + 1);
for (int16_t wrap = firstWrap; wrap <= lastWrap; ++wrap) {
compositeBuilt = buildQuad(context,
resources,
compositeGroup,
getID() + "-static-bootstrap-composite-pass-" + std::to_string(wrap),
resources->trailTargets[trailWrite]->getTexture(),
{},
resources->maskTexture,
{},
0.0f,
ValidationPass::Composite,
resources->trailRead,
trailWrite,
wrap,
maskMode,
false) &&
compositeBuilt;
}
if (!stateBuilt || !trailBuilt || !particlesBuilt || !endpointCoverageBuilt || !compositeBuilt) {
compositeGroup.clearDrawables();
return;
}
resources->registeredTargets.emplace_back(resources->stateTargets[resources->stateRead]);
resources->registeredTargets.emplace_back(resources->stateTargets[stateWrite]);
resources->registeredTargets.emplace_back(resources->trailTargets[resources->trailRead]);
resources->registeredTargets.emplace_back(resources->trailTargets[trailWrite]);
for (const auto& target : resources->registeredTargets) {
changes.emplace_back(std::make_unique<AddRenderTargetRequest>(target));
}
resources->bootstrapPending = true;
resources->pending = true;
Log::Info(Event::Render,
"ParticleRender: STATIC bootstrap scheduled resourceGeneration=" +
std::to_string(resources->generation) + " field=" + resources->field.fieldID +
" simulationFrame=" + std::to_string(resources->committedFrame));
return;
}
if (resources->trailTargets[resources->trailRead]) {
compositeGroup.clearDrawables();
const auto center = transformState.getLatLng(LatLng::Unwrapped);
const auto centerWrap = wrapForLongitude(center.longitude());
const auto firstWrap = resources->formalDisplay ? int16_t{0} : static_cast<int16_t>(centerWrap - 1);
const auto lastWrap = resources->formalDisplay ? int16_t{0} : static_cast<int16_t>(centerWrap + 1);
for (int16_t wrap = firstWrap; wrap <= lastWrap; ++wrap) {
buildQuad(context,
resources,
compositeGroup,
getID() + "-frozen-composite-pass-" + std::to_string(wrap),
resources->trailTargets[resources->trailRead]->getTexture(),
{},
resources->maskTexture,
{},
0.0f,
ValidationPass::FrozenComposite,
resources->trailRead,
resources->trailRead,
wrap,
maskMode,
false);
}
} else {
compositeGroup.clearDrawables();
}
return;
}
resources->staticFreezeLogged = false;
if (!ensureTrailTargets()) {
return;
}
// Camera-driven frames may recompose existing weather drawables, but do
// not advance the shared timeline, State, or Trail before the deadline.
if (formalSource && (sourceTimeline || sourceUsesV4Projection) && !cadenceAdvanced) {
auto& compositeGroup = static_cast<LayerGroup&>(*layerGroup);
compositeGroup.clearDrawables();
const auto center = transformState.getLatLng(LatLng::Unwrapped);
const auto centerWrap = wrapForLongitude(center.longitude());
const auto wrap = static_cast<int16_t>(centerWrap);
buildQuad(context,
resources,
compositeGroup,
getID() + "-composite-pass-" + std::to_string(wrap),
resources->trailTargets[resources->trailRead]->getTexture(),
{},
resources->maskTexture,
{},
0.0f,
ValidationPass::Composite,
resources->trailRead,
resources->trailRead,
wrap,
maskMode,
false);
return;
}
const auto stateWrite = resources->stateWrite();
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::ParticleSimulationAdvance);
const auto trailWrite = resources->trailWrite();
auto& stateGroup = static_cast<LayerGroup&>(*resources->stateTargets[stateWrite]->getLayerGroup(0));
auto& trailGroup = static_cast<LayerGroup&>(*resources->trailTargets[trailWrite]->getLayerGroup(0));
auto& compositeGroup = static_cast<LayerGroup&>(*layerGroup);
const auto now = std::chrono::steady_clock::now();
float wallDeltaSeconds = 1.0f / 60.0f;
if (resources->lastSimulationTick.time_since_epoch().count() != 0) {
wallDeltaSeconds = std::chrono::duration<float>(now - resources->lastSimulationTick).count();
}
resources->lastSimulationTick = now;
const float trailWallDeltaSeconds = std::clamp(wallDeltaSeconds, 0.0f, 0.250f);
const float zoom = static_cast<float>(transformState.getZoom());
const float effectiveVisualSpeedScale = std::clamp(
VisualSimulationSecondsPerRealSecond * std::exp2(VisualSimulationReferenceZoom - zoom),
MinVisualSimulationSecondsPerRealSecond,
MaxVisualSimulationSecondsPerRealSecond);
const float visualSimulationDeltaSeconds = std::min(
std::clamp(wallDeltaSeconds, 1.0f / 240.0f, 0.050f) * effectiveVisualSpeedScale, MaxSimulationDeltaSeconds);
const bool stateBuilt = buildQuad(context,
resources,
stateGroup,
getID() + "-state-pass",
resources->stateTargets[resources->stateRead]->getTexture(),
resources->stateTargets[stateWrite]->getTexture(),
resources->maskTexture,
{},
visualSimulationDeltaSeconds,
ValidationPass::State,
resources->stateRead,
stateWrite,
0,
maskMode,
true,
trailWallDeltaSeconds,
effectiveVisualSpeedScale);
const bool trailBuilt = buildQuad(context,
resources,
trailGroup,
getID() + "-trail-pass",
resources->trailTargets[resources->trailRead]->getTexture(),
resources->trailTargets[trailWrite]->getTexture(),
resources->maskTexture,
resources->stateTargets[stateWrite]->getTexture(),
trailWallDeltaSeconds,
ValidationPass::Trail,
resources->trailRead,
trailWrite,
0,
maskMode,
true,
trailWallDeltaSeconds,
effectiveVisualSpeedScale);
const bool particlesBuilt = !resources->formalDisplay || resources->trailCameraInvalidated ||
buildParticlePoints(context,
resources,
trailGroup,
getID() + "-trail-particles",
resources->stateTargets[resources->stateRead]->getTexture(),
resources->stateTargets[stateWrite]->getTexture(),
resources->trailTargets[trailWrite]->getTexture(),
stateWrite,
trailWrite);
#if MLN_RENDER_BACKEND_METAL
const bool endpointCoverageBuilt = !resources->formalDisplay || resources->trailCameraInvalidated ||
buildParticleEndpointCoverage(context,
resources,
trailGroup,
getID() + "-trail-particle-endpoints",
resources->stateTargets[resources->stateRead]->getTexture(),
resources->stateTargets[stateWrite]->getTexture(),
resources->trailTargets[trailWrite]->getTexture(),
stateWrite,
trailWrite);
#else
const bool endpointCoverageBuilt = true;
#endif
compositeGroup.clearDrawables();
bool compositeBuilt = true;
const auto center = transformState.getLatLng(LatLng::Unwrapped);
const auto centerWrap = wrapForLongitude(center.longitude());
const auto firstWrap = resources->formalDisplay ? int16_t{0} : static_cast<int16_t>(centerWrap - 1);
const auto lastWrap = resources->formalDisplay ? int16_t{0} : static_cast<int16_t>(centerWrap + 1);
for (int16_t wrap = firstWrap; wrap <= lastWrap; ++wrap) {
compositeBuilt = buildQuad(context,
resources,
compositeGroup,
getID() + "-composite-pass-" + std::to_string(wrap),
resources->trailTargets[trailWrite]->getTexture(),
{},
resources->maskTexture,
{},
0.0f,
ValidationPass::Composite,
resources->trailRead,
trailWrite,
wrap,
maskMode,
false) &&
compositeBuilt;
}
if (!stateBuilt || !trailBuilt || !particlesBuilt || !endpointCoverageBuilt || !compositeBuilt) {
compositeGroup.clearDrawables();
return;
}
if (formalSource && sourceTimeline) sourceTimelineCadenceRevision = cadenceRevision;
// Read targets are registered only for their one-time clear. Normal frames register
// only the opposite write targets, in dependency order, so a sampled texture can
// never be attached to the active render pass.
if (!resources->stateSeeded) {
resources->registeredTargets.emplace_back(resources->stateTargets[resources->stateRead]);
}
resources->registeredTargets.emplace_back(resources->stateTargets[stateWrite]);
if (!resources->trailSeeded) {
resources->registeredTargets.emplace_back(resources->trailTargets[resources->trailRead]);
}
resources->registeredTargets.emplace_back(resources->trailTargets[trailWrite]);
for (const auto& target : resources->registeredTargets) {
changes.emplace_back(std::make_unique<AddRenderTargetRequest>(target));
}
resources->pending = true;
if (resources->committedFrame < 3 || (resources->committedFrame + 1) % 60 == 0) {
Log::Info(Event::Render,
"ParticleValidation30_02: schedule generation=" + std::to_string(resources->generation) +
" frame=" + std::to_string(resources->committedFrame + 1) +
" state=" + slotName(resources->stateRead) + "->" + slotName(stateWrite) +
" trail=" + slotName(resources->trailRead) + "->" + slotName(trailWrite) +
" trailStateSource=" + slotName(stateWrite) + " compositeTrailSource=" + slotName(trailWrite) +
" displayMode=" + (resources->formalDisplay ? "FORMAL_PARTICLE" : "VALIDATION") +
" targetsDistinct=true cpuStateCopy=false");
}
}
} // namespace mbgl