479 lines
22 KiB
C++
479 lines
22 KiB
C++
#include <mbgl/style/sources/raster_array_source.hpp>
|
|
|
|
#include <mbgl/particle/field/offline_fixture_particle_adapter.hpp>
|
|
#include <mbgl/storage/file_source.hpp>
|
|
#include <mbgl/storage/resource.hpp>
|
|
#include <mbgl/style/source_observer.hpp>
|
|
#include <mbgl/style/sources/raster_array_source_impl.hpp>
|
|
#include <mbgl/util/async_request.hpp>
|
|
#include <mbgl/util/logging.hpp>
|
|
|
|
#include <atomic>
|
|
#include <cmath>
|
|
|
|
namespace mbgl::style {
|
|
|
|
namespace {
|
|
|
|
// Snapshot revision is process-wide rather than source-instance local: a
|
|
// Style recreation can replace an east source with a north source under the
|
|
// same source ID, and that must always invalidate the prior GPU field.
|
|
std::atomic_uint64_t nextRasterArrayFieldGeneration{0};
|
|
|
|
} // namespace
|
|
|
|
RasterArrayTimelineRuntime::RasterArrayTimelineRuntime(std::vector<Entry> entries_)
|
|
: entries(std::move(entries_)) {
|
|
if (entries.size() < 2) {
|
|
throw std::invalid_argument("particle timeline requires at least two snapshots");
|
|
}
|
|
for (size_t index = 1; index < entries.size(); ++index) {
|
|
if (entries[index - 1].timestamp >= entries[index].timestamp) {
|
|
throw std::invalid_argument("particle timeline timestamps must be strictly increasing");
|
|
}
|
|
}
|
|
if (entries.size() > cacheCapacity_) {
|
|
throw std::invalid_argument("particle timeline exceeds bounded validation cache");
|
|
}
|
|
}
|
|
|
|
RasterArrayTimelineFrame RasterArrayTimelineRuntime::frameLocked() const {
|
|
return {entries[t0].field, entries[t0 + 1].field, mix, t0, t0 + 1, windowRevision, state};
|
|
}
|
|
|
|
RasterArrayTimelineFrame RasterArrayTimelineRuntime::frame() const {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
return frameLocked();
|
|
}
|
|
|
|
bool RasterArrayTimelineRuntime::moveToLocked(const size_t nextT0) {
|
|
if (nextT0 + 1 >= entries.size()) return false;
|
|
t0 = nextT0;
|
|
mix = 0.0f;
|
|
++windowRevision;
|
|
return true;
|
|
}
|
|
|
|
RasterArrayTimelineFrame RasterArrayTimelineRuntime::tick() {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
const auto now = std::chrono::steady_clock::now();
|
|
if (lastTick == std::chrono::steady_clock::time_point{}) {
|
|
lastTick = now;
|
|
return frameLocked();
|
|
}
|
|
const auto elapsed = std::chrono::duration<float, std::milli>(now - lastTick).count();
|
|
lastTick = now;
|
|
if (state != RasterArrayPlaybackState::Playing) return frameLocked();
|
|
const auto duration = static_cast<float>(entries[t0 + 1].timestamp - entries[t0].timestamp);
|
|
mix += elapsed * speed / duration;
|
|
while (mix >= 1.0f) {
|
|
if (gap || invalid) {
|
|
mix = 1.0f;
|
|
state = RasterArrayPlaybackState::Waiting;
|
|
Log::Warning(Event::Render,
|
|
std::string("ParticleTimeline: event=") + (invalid ? "INVALID_WAITING" : "GAP_WAITING") +
|
|
" t1=" + entries[t0 + 1].field->fixtureID);
|
|
return frameLocked();
|
|
}
|
|
if (!moveToLocked(t0 + 1)) {
|
|
mix = 1.0f;
|
|
state = RasterArrayPlaybackState::Ended;
|
|
Log::Info(Event::Render, "ParticleTimeline: event=END t1=" + entries[t0 + 1].field->fixtureID);
|
|
return frameLocked();
|
|
}
|
|
Log::Info(Event::Render,
|
|
"ParticleTimeline: event=WINDOW_BOUNDARY oldT1=" + entries[t0].field->fixtureID +
|
|
" newT1=" + entries[t0 + 1].field->fixtureID + " result=APPLIED reuse=OLD_T1_TO_NEW_T0");
|
|
}
|
|
return frameLocked();
|
|
}
|
|
|
|
bool RasterArrayTimelineRuntime::play() {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
if (state == RasterArrayPlaybackState::Waiting || state == RasterArrayPlaybackState::Ended) return false;
|
|
state = RasterArrayPlaybackState::Playing;
|
|
lastTick = std::chrono::steady_clock::now();
|
|
Log::Info(Event::Render, "ParticleTimeline: event=PLAY speed=" + std::to_string(speed));
|
|
return true;
|
|
}
|
|
bool RasterArrayTimelineRuntime::pause() {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
if (state == RasterArrayPlaybackState::Waiting || state == RasterArrayPlaybackState::Ended) return false;
|
|
state = RasterArrayPlaybackState::Paused;
|
|
Log::Info(Event::Render, "ParticleTimeline: event=PAUSE");
|
|
return true;
|
|
}
|
|
bool RasterArrayTimelineRuntime::setSpeed(const float value) {
|
|
if (value != 0.5f && value != 1.0f && value != 2.0f) return false;
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
speed = value;
|
|
Log::Info(Event::Render, "ParticleTimeline: event=SPEED value=" + std::to_string(speed));
|
|
return true;
|
|
}
|
|
bool RasterArrayTimelineRuntime::seek(const size_t index) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
if (index >= entries.size()) return false;
|
|
if (index + 1 == entries.size()) {
|
|
t0 = index - 1;
|
|
mix = 1.0f;
|
|
state = RasterArrayPlaybackState::Ended;
|
|
} else {
|
|
moveToLocked(index);
|
|
state = RasterArrayPlaybackState::Paused;
|
|
}
|
|
Log::Info(Event::Render, "ParticleTimeline: event=SEEK index=" + std::to_string(index));
|
|
return true;
|
|
}
|
|
bool RasterArrayTimelineRuntime::step(const int direction) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
const auto target = direction < 0 ? (t0 == 0 ? 0 : t0 - 1) : std::min(t0 + 1, entries.size() - 2);
|
|
moveToLocked(target);
|
|
state = RasterArrayPlaybackState::Paused;
|
|
Log::Info(Event::Render, "ParticleTimeline: event=STEP direction=" + std::to_string(direction));
|
|
return true;
|
|
}
|
|
bool RasterArrayTimelineRuntime::injectGap() {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
gap = true;
|
|
invalid = false;
|
|
Log::Info(Event::Render, "ParticleTimeline: event=GAP_INJECTED");
|
|
return true;
|
|
}
|
|
bool RasterArrayTimelineRuntime::injectInvalid() {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
invalid = true;
|
|
gap = false;
|
|
Log::Info(Event::Render, "ParticleTimeline: event=INVALID_INJECTED");
|
|
return true;
|
|
}
|
|
bool RasterArrayTimelineRuntime::recover() {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
gap = invalid = false;
|
|
if (state == RasterArrayPlaybackState::Waiting) state = RasterArrayPlaybackState::Paused;
|
|
Log::Info(Event::Render, "ParticleTimeline: event=RECOVERED");
|
|
return true;
|
|
}
|
|
|
|
RasterArraySource::RasterArraySource(std::string id,
|
|
std::string fixtureID,
|
|
std::string metadataURL,
|
|
std::string fieldURL,
|
|
std::string nextFixtureID,
|
|
std::string nextMetadataURL,
|
|
std::string nextFieldURL,
|
|
std::vector<RasterArrayRuntimeFieldDescriptor> runtimeFields)
|
|
: Source(makeMutable<Impl>(std::move(id),
|
|
std::move(fixtureID),
|
|
std::move(metadataURL),
|
|
std::move(fieldURL),
|
|
std::move(nextFixtureID),
|
|
std::move(nextMetadataURL),
|
|
std::move(nextFieldURL),
|
|
std::move(runtimeFields))) {}
|
|
|
|
RasterArraySource::~RasterArraySource() {
|
|
weakFactory.invalidateWeakPtrs();
|
|
}
|
|
|
|
const RasterArraySource::Impl& RasterArraySource::impl() const {
|
|
return static_cast<const Impl&>(*baseImpl);
|
|
}
|
|
|
|
void RasterArraySource::loadDescription(FileSource& fileSource) {
|
|
if (metadataRequest || fieldRequest) return;
|
|
metadataRequest = fileSource.request(
|
|
Resource::source(impl().metadataURL), [this, self = makeWeakPtr()](const Response& response) {
|
|
if (auto guard = self.lock(); self) {
|
|
if (response.error || response.noContent || !response.data) {
|
|
observer->onSourceError(
|
|
*this, std::make_exception_ptr(std::runtime_error("particle metadata request failed")));
|
|
return;
|
|
}
|
|
metadata = *response.data;
|
|
tryLoad();
|
|
}
|
|
});
|
|
fieldRequest = fileSource.request(
|
|
Resource::source(impl().fieldURL), [this, self = makeWeakPtr()](const Response& response) {
|
|
if (auto guard = self.lock(); self) {
|
|
if (response.error || response.noContent || !response.data) {
|
|
observer->onSourceError(
|
|
*this, std::make_exception_ptr(std::runtime_error("particle field request failed")));
|
|
return;
|
|
}
|
|
fieldBytes = std::vector<char>(response.data->begin(), response.data->end());
|
|
tryLoad();
|
|
}
|
|
});
|
|
if (!impl().nextFixtureID.empty()) {
|
|
if (impl().nextMetadataURL.empty() || impl().nextFieldURL.empty()) {
|
|
observer->onSourceError(
|
|
*this,
|
|
std::make_exception_ptr(std::runtime_error("particle next fixture requires metadata and field URLs")));
|
|
return;
|
|
}
|
|
nextMetadataRequest = fileSource.request(
|
|
Resource::source(impl().nextMetadataURL), [this, self = makeWeakPtr()](const Response& response) {
|
|
if (auto guard = self.lock(); self) {
|
|
if (response.error || response.noContent || !response.data) {
|
|
observer->onSourceError(
|
|
*this,
|
|
std::make_exception_ptr(std::runtime_error("particle next metadata request failed")));
|
|
return;
|
|
}
|
|
nextMetadata = *response.data;
|
|
tryLoad();
|
|
}
|
|
});
|
|
nextFieldRequest = fileSource.request(
|
|
Resource::source(impl().nextFieldURL), [this, self = makeWeakPtr()](const Response& response) {
|
|
if (auto guard = self.lock(); self) {
|
|
if (response.error || response.noContent || !response.data) {
|
|
observer->onSourceError(
|
|
*this, std::make_exception_ptr(std::runtime_error("particle next field request failed")));
|
|
return;
|
|
}
|
|
nextFieldBytes = std::vector<char>(response.data->begin(), response.data->end());
|
|
tryLoad();
|
|
}
|
|
});
|
|
}
|
|
runtimeFields.reserve(impl().runtimeFields.size());
|
|
for (const auto& descriptor : impl().runtimeFields) {
|
|
RuntimeFieldCandidate runtimeCandidate;
|
|
runtimeCandidate.descriptor = descriptor;
|
|
runtimeFields.push_back(std::move(runtimeCandidate));
|
|
const auto index = runtimeFields.size() - 1;
|
|
auto& candidate = runtimeFields.back();
|
|
candidate.metadataRequest = fileSource.request(
|
|
Resource::source(candidate.descriptor.metadataURL),
|
|
[this, self = makeWeakPtr(), index](const Response& response) {
|
|
if (auto guard = self.lock(); self) {
|
|
if (response.error || response.noContent || !response.data) {
|
|
observer->onSourceError(
|
|
*this,
|
|
std::make_exception_ptr(std::runtime_error("particle runtime metadata request failed")));
|
|
return;
|
|
}
|
|
runtimeFields[index].metadata = *response.data;
|
|
tryLoadRuntimeField(index);
|
|
}
|
|
});
|
|
candidate.fieldRequest = fileSource.request(
|
|
Resource::source(candidate.descriptor.fieldURL),
|
|
[this, self = makeWeakPtr(), index](const Response& response) {
|
|
if (auto guard = self.lock(); self) {
|
|
if (response.error || response.noContent || !response.data) {
|
|
observer->onSourceError(
|
|
*this,
|
|
std::make_exception_ptr(std::runtime_error("particle runtime field request failed")));
|
|
return;
|
|
}
|
|
runtimeFields[index].fieldBytes = std::vector<char>(response.data->begin(), response.data->end());
|
|
tryLoadRuntimeField(index);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
void RasterArraySource::tryLoad() {
|
|
if (!metadata || !fieldBytes || (!impl().nextFixtureID.empty() && (!nextMetadata || !nextFieldBytes))) return;
|
|
try {
|
|
const auto fieldGeneration = ++nextRasterArrayFieldGeneration;
|
|
auto field = particle::OfflineFixtureParticleAdapter::load(
|
|
impl().fixtureID, *metadata, *fieldBytes, fieldGeneration);
|
|
if (impl().nextFixtureID.empty()) {
|
|
baseImpl = makeMutable<Impl>(impl(), std::move(field));
|
|
} else {
|
|
auto nextField = particle::OfflineFixtureParticleAdapter::load(
|
|
impl().nextFixtureID, *nextMetadata, *nextFieldBytes, ++nextRasterArrayFieldGeneration);
|
|
if (!field.isTimeInterpolationCompatibleWith(nextField)) {
|
|
throw std::invalid_argument("particle time-window fixtures are incompatible");
|
|
}
|
|
baseImpl = makeMutable<Impl>(impl(), std::move(field), std::move(nextField));
|
|
}
|
|
loaded = true;
|
|
const auto& loadedField = *impl().field;
|
|
Log::Info(Event::Render,
|
|
"ParticleField: source loaded fixtureId=" + impl().fixtureID + " metadata=" + impl().metadataURL +
|
|
" field=" + impl().fieldURL + " generation=" + std::to_string(loadedField.generation) +
|
|
" dataVersion=" + std::to_string(loadedField.dataVersion));
|
|
if (impl().nextField) {
|
|
Log::Info(Event::Render,
|
|
"ParticleField: source loaded next fixtureId=" + impl().nextField->fixtureID +
|
|
" generation=" + std::to_string(impl().nextField->generation) +
|
|
" dataVersion=" + std::to_string(impl().nextField->dataVersion));
|
|
}
|
|
observer->onSourceLoaded(*this);
|
|
observer->onSourceChanged(*this);
|
|
tryInitializeTimeline();
|
|
} catch (const std::exception& error) {
|
|
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));
|
|
}
|
|
}
|
|
|
|
void RasterArraySource::tryLoadRuntimeField(const size_t index) {
|
|
auto& candidate = runtimeFields.at(index);
|
|
if (candidate.snapshot || !candidate.metadata || !candidate.fieldBytes) return;
|
|
try {
|
|
candidate.snapshot = particle::OfflineFixtureParticleAdapter::load(
|
|
candidate.descriptor.fixtureID, *candidate.metadata, *candidate.fieldBytes, 0);
|
|
Log::Info(Event::Render,
|
|
"ParticleField: runtime candidate ready fixtureId=" + candidate.snapshot->fixtureID +
|
|
" dataVersion=" + std::to_string(candidate.snapshot->dataVersion));
|
|
tryInitializeTimeline();
|
|
} catch (const std::exception& error) {
|
|
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));
|
|
}
|
|
}
|
|
|
|
void RasterArraySource::tryInitializeTimeline() {
|
|
if (timeline || !loaded || !impl().field || !impl().nextField || runtimeFields.empty()) return;
|
|
std::vector<RasterArrayTimelineRuntime::Entry> entries;
|
|
entries.reserve(runtimeFields.size() + 2);
|
|
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().field), 0});
|
|
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().nextField), 4000});
|
|
for (size_t index = 0; index < runtimeFields.size(); ++index) {
|
|
if (!runtimeFields[index].snapshot) return;
|
|
auto field = *runtimeFields[index].snapshot;
|
|
field.generation = ++nextRasterArrayFieldGeneration;
|
|
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(std::move(field)),
|
|
8000 + static_cast<uint64_t>(index) * 4000});
|
|
}
|
|
try {
|
|
timeline = std::make_shared<RasterArrayTimelineRuntime>(std::move(entries));
|
|
auto updated = makeMutable<Impl>(impl());
|
|
updated->timeline = timeline;
|
|
baseImpl = std::move(updated);
|
|
Log::Info(Event::Render,
|
|
"ParticleTimeline: event=READY snapshots=" + std::to_string(runtimeFields.size() + 2) +
|
|
" cacheCapacity=" + std::to_string(timeline->cacheCapacity()));
|
|
observer->onSourceChanged(*this);
|
|
} catch (const std::exception& error) {
|
|
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));
|
|
}
|
|
}
|
|
|
|
RasterArrayRuntimeFieldUpdateResult RasterArraySource::rejectRuntimeFieldUpdate(
|
|
const RasterArrayRuntimeFieldUpdateResult result, const std::string& reason) const {
|
|
Log::Warning(Event::Render,
|
|
"ParticleField: event=FIELD_WINDOW_UPDATE_REJECTED reason=" + reason + " sourceId=" + getID());
|
|
return result;
|
|
}
|
|
|
|
RasterArrayRuntimeFieldUpdateResult RasterArraySource::updateRuntimeFieldWindow(particle::ParticleFieldSnapshot newT1) {
|
|
if (!loaded || !impl().field || !impl().nextField) {
|
|
return rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult::RejectedNotLoaded, "SOURCE_NOT_LOADED");
|
|
}
|
|
if (!newT1.isValid() || newT1.generation == 0) {
|
|
return rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult::RejectedInvalidSnapshot,
|
|
"INVALID_SNAPSHOT");
|
|
}
|
|
if (!impl().nextField->isTimeInterpolationCompatibleWith(newT1)) {
|
|
return rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult::RejectedIncompatibleSnapshot,
|
|
"INCOMPATIBLE_SNAPSHOT");
|
|
}
|
|
if (particle::sameFieldGpuContent(*impl().nextField, newT1)) {
|
|
Log::Info(Event::Render,
|
|
"ParticleField: event=FIELD_WINDOW_UPDATE_APPLIED result=NOOP sourceId=" + getID() +
|
|
" T0=" + impl().field->fixtureID + " T1=" + impl().nextField->fixtureID);
|
|
return RasterArrayRuntimeFieldUpdateResult::Noop;
|
|
}
|
|
if (newT1.generation <= impl().nextField->generation) {
|
|
return rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult::RejectedNonMonotonicGeneration,
|
|
"NON_MONOTONIC_GENERATION");
|
|
}
|
|
|
|
const auto oldT0 = impl().field->fixtureID;
|
|
const auto oldT1 = impl().nextField->fixtureID;
|
|
Log::Info(Event::Render,
|
|
"ParticleField: event=FIELD_WINDOW_UPDATE_REQUEST sourceId=" + getID() +
|
|
" sequence=" + std::to_string(runtimeUpdateSequence + 1) + " oldT0=" + oldT0 + " oldT1=" + oldT1 +
|
|
" newT1=" + newT1.fixtureID + " generation=" + std::to_string(newT1.generation));
|
|
auto newT0 = *impl().nextField;
|
|
baseImpl = makeMutable<Impl>(impl(), std::move(newT0), std::move(newT1));
|
|
++runtimeUpdateSequence;
|
|
observer->onSourceChanged(*this);
|
|
Log::Info(Event::Render,
|
|
"ParticleField: event=FIELD_WINDOW_UPDATE_APPLIED result=APPLIED sourceId=" + getID() +
|
|
" sequence=" + std::to_string(runtimeUpdateSequence) + " newT0=" + impl().field->fixtureID +
|
|
" newT1=" + impl().nextField->fixtureID +
|
|
" generation=" + std::to_string(impl().nextField->generation));
|
|
return RasterArrayRuntimeFieldUpdateResult::Applied;
|
|
}
|
|
|
|
RasterArrayRuntimeFieldUpdateResult RasterArraySource::advanceRuntimeFieldWindow() {
|
|
if (timeline) {
|
|
return timeline->step(1) ? RasterArrayRuntimeFieldUpdateResult::Applied
|
|
: RasterArrayRuntimeFieldUpdateResult::RejectedNoCandidate;
|
|
}
|
|
if (nextRuntimeField >= runtimeFields.size()) {
|
|
return rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult::RejectedNoCandidate, "NO_PRELOADED_FIELD");
|
|
}
|
|
auto& candidate = runtimeFields[nextRuntimeField];
|
|
if (!candidate.snapshot) {
|
|
return rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult::RejectedCandidateNotReady,
|
|
"PRELOADED_FIELD_NOT_READY");
|
|
}
|
|
// A preloaded candidate is immutable content, not an active runtime
|
|
// revision. Allocate its revision when it enters the authoritative window
|
|
// so concurrent asset completion order cannot make it stale.
|
|
auto newT1 = *candidate.snapshot;
|
|
newT1.generation = ++nextRasterArrayFieldGeneration;
|
|
const auto result = updateRuntimeFieldWindow(std::move(newT1));
|
|
if (result == RasterArrayRuntimeFieldUpdateResult::Applied || result == RasterArrayRuntimeFieldUpdateResult::Noop) {
|
|
++nextRuntimeField;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
RasterArrayRuntimeFieldUpdateResult RasterArraySource::repeatRuntimeFieldWindow() {
|
|
if (timeline) {
|
|
Log::Info(Event::Render, "ParticleField: event=FIELD_WINDOW_UPDATE_APPLIED result=NOOP sourceId=" + getID());
|
|
return RasterArrayRuntimeFieldUpdateResult::Noop;
|
|
}
|
|
if (!loaded || !impl().nextField) {
|
|
return rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult::RejectedNotLoaded, "SOURCE_NOT_LOADED");
|
|
}
|
|
return updateRuntimeFieldWindow(*impl().nextField);
|
|
}
|
|
|
|
bool RasterArraySource::controlRuntimeTimeline(const int command, const float value) {
|
|
if (!timeline) {
|
|
Log::Warning(Event::Render, "ParticleTimeline: event=REJECTED reason=TIMELINE_NOT_READY sourceId=" + getID());
|
|
return false;
|
|
}
|
|
switch (command) {
|
|
case 0:
|
|
return timeline->play();
|
|
case 1:
|
|
return timeline->pause();
|
|
case 2:
|
|
return timeline->setSpeed(value);
|
|
case 3:
|
|
return timeline->step(-1);
|
|
case 4:
|
|
return timeline->step(1);
|
|
case 5:
|
|
return timeline->seek(static_cast<size_t>(value));
|
|
case 6:
|
|
return timeline->injectGap();
|
|
case 7:
|
|
return timeline->injectInvalid();
|
|
case 8:
|
|
return timeline->recover();
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool RasterArraySource::supportsLayerType(const LayerTypeInfo* info) const {
|
|
return info && std::string(info->type) == "raster-particle";
|
|
}
|
|
|
|
Mutable<Source::Impl> RasterArraySource::createMutable() const noexcept {
|
|
return staticMutableCast<Source::Impl>(makeMutable<Impl>(impl()));
|
|
}
|
|
|
|
} // namespace mbgl::style
|