feat: add GPU particle time interpolation

This commit is contained in:
OpenAI Codex
2026-09-02 09:45:10 +08:00
parent b0f855eb09
commit 4675a8a29d
20 changed files with 425 additions and 57 deletions

View File

@@ -42,6 +42,7 @@ void main() {
uniform sampler2D u_previous;
uniform sampler2D u_mask_texture;
uniform sampler2D u_field_texture;
uniform sampler2D u_next_field_texture;
uniform sampler2D u_state_texture;
in vec2 v_uv;
in vec2 v_world;
@@ -57,9 +58,12 @@ void main() {
// u_mask.w is the explicit State lifecycle flag: new RenderTargets are
// cleared opaque by MapLibre, so sampled alpha cannot identify a seed.
vec2 position = u_mask.w > 0.5 ? v_uv : previous.rg;
vec4 field = texture(u_field_texture, position);
vec2 velocity = field.rg * 2.0 - 1.0;
if (field.a < 0.5) {
// 40-02: decode each independent Field texture, then interpolate the
// velocity on GPU. u_marker.x is the clamped Layer timeMix property.
vec4 field0 = texture(u_field_texture, position);
vec4 field1 = texture(u_next_field_texture, position);
vec2 velocity = mix(field0.rg * 2.0 - 1.0, field1.rg * 2.0 - 1.0, u_marker.x);
if (field0.a < 0.5 || field1.a < 0.5) {
position = v_uv;
velocity = vec2(0.0);
}

View File

@@ -342,6 +342,7 @@ enum {
idParticleGpuValidationPreviousTexture,
idParticleGpuValidationMaskTexture,
idParticleGpuValidationFieldTexture,
idParticleGpuValidationNextFieldTexture,
idParticleGpuValidationStateTexture,
particleGpuValidationTextureCount
};

View File

@@ -12,6 +12,8 @@ public:
void setActive(bool);
uint8_t getMaskMode() const;
void setMaskMode(uint8_t);
float getTimeMix() const;
void setTimeMix(float);
class Impl;
const Impl& impl() const;
Mutable<Impl> mutableImpl() const;

View File

@@ -11,7 +11,13 @@ namespace style {
class RasterArraySource final : public Source {
public:
RasterArraySource(std::string id, std::string fixtureID, std::string metadataURL, std::string fieldURL);
RasterArraySource(std::string id,
std::string fixtureID,
std::string metadataURL,
std::string fieldURL,
std::string nextFixtureID = {},
std::string nextMetadataURL = {},
std::string nextFieldURL = {});
~RasterArraySource() override;
class Impl;
@@ -28,8 +34,12 @@ private:
std::unique_ptr<AsyncRequest> metadataRequest;
std::unique_ptr<AsyncRequest> fieldRequest;
std::unique_ptr<AsyncRequest> nextMetadataRequest;
std::unique_ptr<AsyncRequest> nextFieldRequest;
std::optional<std::string> metadata;
std::optional<std::vector<char>> fieldBytes;
std::optional<std::string> nextMetadata;
std::optional<std::vector<char>> nextFieldBytes;
mapbox::base::WeakPtrFactory<Source> weakFactory{this};
};

View File

@@ -22,12 +22,16 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var enabled = true
private var maskMode = MASK_GLOBAL
private var fixtureMode = FIXTURE_UNIFORM_EAST
private var timeMix = 0.0f
private var timeWindowMode = false
override fun onCreate(savedInstanceState: Bundle?) {
active = savedInstanceState?.getBoolean(STATE_ACTIVE, true) ?: true
enabled = savedInstanceState?.getBoolean(STATE_ENABLED, true) ?: true
maskMode = savedInstanceState?.getInt(STATE_MASK_MODE, MASK_GLOBAL) ?: MASK_GLOBAL
fixtureMode = savedInstanceState?.getInt(STATE_FIXTURE_MODE, FIXTURE_UNIFORM_EAST) ?: FIXTURE_UNIFORM_EAST
timeMix = savedInstanceState?.getFloat(STATE_TIME_MIX, 0.0f) ?: 0.0f
timeWindowMode = savedInstanceState?.getBoolean(STATE_TIME_WINDOW, false) ?: false
super.onCreate(savedInstanceState)
Log.i(
TAG,
@@ -72,13 +76,30 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
if (this::maplibreMap.isInitialized) setValidationProperties(active = active, maskMode = MASK_OCEAN_B)
}
findViewById<Button>(R.id.validationUniformEast).setOnClickListener {
timeWindowMode = false
fixtureMode = FIXTURE_UNIFORM_EAST
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled)
}
findViewById<Button>(R.id.validationUniformNorth).setOnClickListener {
timeWindowMode = false
fixtureMode = FIXTURE_UNIFORM_NORTH
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled)
}
findViewById<Button>(R.id.validationTimeWindow).setOnClickListener {
timeWindowMode = true
fixtureMode = FIXTURE_UNIFORM_EAST
timeMix = 0.0f
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled)
}
findViewById<Button>(R.id.validationMix0).setOnClickListener { setTimeMix(0.0f) }
findViewById<Button>(R.id.validationMix50).setOnClickListener { setTimeMix(0.5f) }
findViewById<Button>(R.id.validationMix100).setOnClickListener { setTimeMix(1.0f) }
findViewById<Button>(R.id.validationAdvanceWindow).setOnClickListener {
timeWindowMode = true
fixtureMode = FIXTURE_UNIFORM_NORTH
timeMix = 0.0f
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled)
}
findViewById<Button>(R.id.validationDateline).setOnClickListener {
maplibreMap.cameraPosition = CameraPosition.Builder().target(LatLng(0.0, 179.0)).zoom(2.0).build()
Log.i(TAG, "ParticleValidation30_03: DATELINE preset center=0,179 zoom=2")
@@ -116,6 +137,20 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
Log.i(TAG, "ParticleRender: property update active=$active maskMode=$maskMode fixtureMode=$fixtureMode")
}
private fun setTimeMix(value: Float) {
timeMix = value.coerceIn(0.0f, 1.0f)
val layer = maplibreMap.style?.getLayer(PARTICLE_LAYER_ID)
if (layer == null || !enabled || !timeWindowMode) {
timeWindowMode = true
fixtureMode = FIXTURE_UNIFORM_EAST
loadValidationStyle(active, enabled)
return
}
layer.setProperties(PaintPropertyValue("raster-particle-time-mix", timeMix))
status.text = "40-02 T0 east / T1 north timeMix=$timeMix"
Log.i(TAG, "ParticleGPU: test timeMix=$timeMix; expect no Field upload or simulation rebuild")
}
private fun statusText(): String = when {
!enabled -> getString(R.string.particle_validation_disabled_status)
maskMode == MASK_OCEAN_A -> getString(R.string.particle_validation_ocean_a_status)
@@ -126,9 +161,15 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private fun validationStyle(active: Boolean, enabled: Boolean): String {
val fixtureId = fixtureId()
val nextFixtureId = if (timeWindowMode) {
if (fixtureMode == FIXTURE_UNIFORM_NORTH) "vortex" else "uniform-north"
} else {
fixtureId
}
val particleLayer = if (enabled) {
"""{"id":"particle-raster-core","type":"raster-particle","source":"particle-fixture",
"paint":{"raster-particle-active":$active,"raster-particle-mask-mode":$maskMode}},"""
"paint":{"raster-particle-active":$active,"raster-particle-mask-mode":$maskMode,
"raster-particle-time-mix":$timeMix}},"""
} else {
""
}
@@ -141,7 +182,10 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
"type": "raster-array",
"fixture": "$fixtureId",
"metadata": "asset://integration/particle-fixtures/$fixtureId/metadata.json",
"field": "asset://integration/particle-fixtures/$fixtureId/field.f32"
"field": "asset://integration/particle-fixtures/$fixtureId/field.f32",
"next-fixture": "$nextFixtureId",
"next-metadata": "asset://integration/particle-fixtures/$nextFixtureId/metadata.json",
"next-field": "asset://integration/particle-fixtures/$nextFixtureId/field.f32"
},
"validation-point": {
"type": "geojson",
@@ -172,6 +216,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
outState.putBoolean(STATE_ENABLED, enabled)
outState.putInt(STATE_MASK_MODE, maskMode)
outState.putInt(STATE_FIXTURE_MODE, fixtureMode)
outState.putFloat(STATE_TIME_MIX, timeMix)
outState.putBoolean(STATE_TIME_WINDOW, timeWindowMode)
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
@@ -187,6 +233,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private const val STATE_ENABLED = "particle_validation.enabled"
private const val STATE_MASK_MODE = "particle_validation.mask_mode"
private const val STATE_FIXTURE_MODE = "particle_validation.fixture_mode"
private const val STATE_TIME_MIX = "particle_validation.time_mix"
private const val STATE_TIME_WINDOW = "particle_validation.time_window"
private const val PARTICLE_LAYER_ID = "particle-raster-core"
private const val MASK_GLOBAL = 0
private const val MASK_OCEAN_A = 1

View File

@@ -44,6 +44,53 @@
android:text="@string/particle_validation_uniform_north" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/validationTimeWindow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/particle_validation_time_window" />
<Button
android:id="@+id/validationAdvanceWindow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/particle_validation_advance_window" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/validationMix0"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/particle_validation_mix_0" />
<Button
android:id="@+id/validationMix50"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/particle_validation_mix_50" />
<Button
android:id="@+id/validationMix100"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/particle_validation_mix_100" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -24,6 +24,11 @@
<string name="particle_validation_world_wrap">WORLD WRAP</string>
<string name="particle_validation_uniform_east">UNIFORM EAST</string>
<string name="particle_validation_uniform_north">UNIFORM NORTH</string>
<string name="particle_validation_time_window">T0 EAST / T1 NORTH</string>
<string name="particle_validation_advance_window">ADVANCE B→C</string>
<string name="particle_validation_mix_0">MIX 0%</string>
<string name="particle_validation_mix_50">MIX 50%</string>
<string name="particle_validation_mix_100">MIX 100%</string>
<string name="particle_validation_global_active_status">40-01 GLOBAL ACTIVE: offline field simulation and current-frame composite.</string>
<string name="particle_validation_global_static_status">40-01 GLOBAL STATIC: zero-step bootstrap and frozen composite.</string>
<string name="particle_validation_ocean_a_status">40-01 OCEAN_ONLY_A: shared coastal-mask consumer.</string>

View File

@@ -8,6 +8,7 @@ layout (std140) uniform ParticleGpuValidationUBO {
uniform sampler2D u_previous;
uniform sampler2D u_mask_texture;
uniform sampler2D u_field_texture;
uniform sampler2D u_next_field_texture;
uniform sampler2D u_state_texture;
in vec2 v_uv;
in vec2 v_world;
@@ -23,9 +24,12 @@ void main() {
// u_mask.w is the explicit State lifecycle flag: new RenderTargets are
// cleared opaque by MapLibre, so sampled alpha cannot identify a seed.
vec2 position = u_mask.w > 0.5 ? v_uv : previous.rg;
vec4 field = texture(u_field_texture, position);
vec2 velocity = field.rg * 2.0 - 1.0;
if (field.a < 0.5) {
// 40-02: decode each independent Field texture, then interpolate the
// velocity on GPU. u_marker.x is the clamped Layer timeMix property.
vec4 field0 = texture(u_field_texture, position);
vec4 field1 = texture(u_next_field_texture, position);
vec2 velocity = mix(field0.rg * 2.0 - 1.0, field1.rg * 2.0 - 1.0, u_marker.x);
if (field0.a < 0.5 || field1.a < 0.5) {
position = v_uv;
velocity = vec2(0.0);
}

View File

@@ -121,6 +121,12 @@ bool ParticleFieldSnapshot::isValid() const {
return true;
}
bool ParticleFieldSnapshot::isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot& other) const {
return width == other.width && height == other.height && west == other.west && south == other.south &&
east == other.east && north == other.north && noData == other.noData && fieldType == other.fieldType &&
displayDomain == other.displayDomain;
}
ParticleFieldSnapshot OfflineFixtureParticleAdapter::load(const std::string& fixtureID,
const std::string& metadataText,
const std::vector<char>& bytes,

View File

@@ -27,6 +27,10 @@ struct ParticleFieldSnapshot {
std::vector<uint8_t> valid;
bool isValid() const;
// Two snapshots can be interpolated only when their geographic sampling
// contract is identical. Fixture identity/version intentionally do not
// participate: they identify different values in the same grid.
bool isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot&) const;
};
// A snapshot revision identifies a runtime load, while fixtureID and
@@ -37,6 +41,15 @@ inline bool fieldIdentityChanged(const ParticleFieldSnapshot& current, const Par
current.dataVersion != next.dataVersion;
}
// A formal source time window. T0/T1 remain independent immutable snapshots;
// the renderer uploads and samples them independently on the GPU.
struct ParticleFieldTimeWindow {
ParticleFieldSnapshot t0;
ParticleFieldSnapshot t1;
bool isValid() const { return t0.isValid() && t1.isValid() && t0.isTimeInterpolationCompatibleWith(t1); }
};
// Phase-one OfflineFixtureParticleAdapter. Its public contract deliberately
// contains no Android/UI/GPU objects, allowing the same decoded snapshot to be
// used by future Android Vulkan and iOS renderers.

View File

@@ -54,16 +54,17 @@ struct ParticleSettings {
uint8_t maskMode;
uint8_t fixtureMode;
bool usesFormalSource;
float timeMix;
};
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};
return {layer.active, layer.maskMode, 0, true, layer.timeMix};
}
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};
return {layer.active, layer.maskMode, layer.fixtureMode, false, 0.0f};
}
enum class ValidationPass : uint8_t {
@@ -164,6 +165,10 @@ public:
uint64_t maskGeneration = 0;
uint64_t fieldGeneration = 0;
uint64_t gpuFieldGeneration = 0;
uint64_t nextFieldGeneration = 0;
uint64_t gpuNextFieldGeneration = 0;
uint64_t fieldUploadCount = 0;
float timeMix = 0.0f;
uint8_t fixtureMode = 255;
uint8_t loggedMaskMode = 255;
Size trailSize{};
@@ -174,9 +179,12 @@ public:
gfx::ShaderProgramBasePtr shader;
gfx::Texture2DPtr maskTexture;
gfx::Texture2DPtr fieldTexture;
gfx::Texture2DPtr nextFieldTexture;
gfx::Texture2DPtr stateTexture;
particle::ParticleFieldSnapshot field;
particle::ParticleFieldSnapshot nextField;
std::shared_ptr<PremultipliedImage> fieldImage;
std::shared_ptr<PremultipliedImage> nextFieldImage;
std::shared_ptr<PremultipliedImage> maskFixture;
std::shared_ptr<gfx::VertexVector<HeatmapTextureLayoutVertex>> vertices;
SegmentVector segments;
@@ -263,12 +271,15 @@ public:
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 = 0.5f + 0.32f * std::sin(frame * 0.071f);
const float markerX = resources->timeMix;
const float markerY = 0.5f + 0.32f * std::cos(frame * 0.053f);
// RenderTarget clear is opaque black. State initialization must therefore
// be an explicit lifecycle signal, never inferred from sampled alpha.
@@ -472,6 +483,9 @@ bool buildQuad(gfx::Context& context,
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);
}
@@ -495,7 +509,7 @@ RenderRasterParticleLayer::RenderRasterParticleLayer(Immutable<style::Layer::Imp
: RenderLayer([&] {
const auto settings = particleSettings(impl_);
return makeMutable<style::ParticleRenderLayerProperties>(
std::move(impl_), settings.active, settings.maskMode, settings.fixtureMode);
std::move(impl_), settings.active, settings.maskMode, settings.fixtureMode, settings.timeMix);
}()) {
formalSource = baseImpl->getTypeInfo() == style::RasterParticleLayer::Impl::staticTypeInfo();
Log::Info(Event::Render,
@@ -515,7 +529,7 @@ RenderRasterParticleLayer::~RenderRasterParticleLayer() {
void RenderRasterParticleLayer::evaluate(const PropertyEvaluationParameters&) {
const auto settings = particleSettings(baseImpl);
auto properties = makeMutable<style::ParticleRenderLayerProperties>(
baseImpl, settings.active, settings.maskMode, settings.fixtureMode);
baseImpl, settings.active, settings.maskMode, settings.fixtureMode, settings.timeMix);
passes = RenderPass::Translucent;
properties->renderPasses = mbgl::underlying_type(passes);
evaluatedProperties = std::move(properties);
@@ -524,7 +538,8 @@ void RenderRasterParticleLayer::evaluate(const PropertyEvaluationParameters&) {
"ParticleRender: state=" + std::string(settings.active ? "ACTIVE" : "STATIC") +
" maskMode=" + std::to_string(settings.maskMode) + " id=" + getID() + " fixture=" +
(formalSource && sourceField ? sourceField->fixtureID : fixtureID(settings.fixtureMode)) +
" layerIndex=" + std::to_string(getLayerIndex()) + " stage=40-01");
" layerIndex=" + std::to_string(getLayerIndex()) + " timeMix=" + std::to_string(settings.timeMix) +
" stage=40-02");
}
bool RenderRasterParticleLayer::needsRepaint() const {
@@ -565,6 +580,7 @@ void RenderRasterParticleLayer::prepare(const LayerPrepareParameters& parameters
const auto& source = static_cast<const RenderRasterArraySource&>(*parameters.source);
if (source.impl().field) {
sourceField = *source.impl().field;
sourceNextField = source.impl().nextField ? *source.impl().nextField : *source.impl().field;
}
}
@@ -623,20 +639,26 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
owned->maskGeneration = ++nextMaskGeneration;
try {
if (formalSource) {
if (!sourceField) {
Log::Error(Event::Render, "ParticleField: raster-array source has no loaded field");
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();
if (!owned->fieldImage || !owned->fieldTexture) {
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;
}
@@ -644,7 +666,13 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
owned->fieldTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Repeat,
.wrapV = gfx::TextureWrapType::Clamp});
owned->nextFieldTexture->setImage(owned->nextFieldImage);
owned->nextFieldTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Repeat,
.wrapV = gfx::TextureWrapType::Clamp});
owned->gpuFieldGeneration = owned->fieldGeneration;
owned->gpuNextFieldGeneration = owned->nextFieldGeneration;
owned->fieldUploadCount = 2;
owned->fixtureMode = fixtureMode;
} catch (const std::exception& error) {
Log::Error(Event::Render, "ParticleField: fixture load failed: " + std::string(error.what()));
@@ -675,6 +703,11 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
" 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.fixtureID + "@" +
std::to_string(resources->field.dataVersion) + " T1=" + resources->nextField.fixtureID + "@" +
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] "
@@ -684,48 +717,100 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
"ParticleValidation30_03: anchors A=0,0 B=0,-90 C=0,-172.8 D=0,172.8 canonical=Mercator-z0");
}
const bool sourceChanged = formalSource && sourceField &&
particle::fieldIdentityChanged(resources->field, *sourceField);
const bool sourceChanged = formalSource && sourceField && sourceNextField &&
(particle::fieldIdentityChanged(resources->field, *sourceField) ||
particle::fieldIdentityChanged(resources->nextField, *sourceNextField));
if ((!formalSource && resources->fixtureMode != fixtureMode) || sourceChanged) {
try {
const auto oldFixtureID = resources->field.fixtureID;
const auto oldFieldGeneration = resources->fieldGeneration;
const auto fieldGeneration = formalSource ? sourceField->generation : ++nextFieldGeneration;
auto field = formalSource
? *sourceField
: particle::OfflineFixtureParticleAdapter::load(fixtureID(fixtureMode), fieldGeneration);
auto image = makeVelocityFieldImage(field);
auto texture = context.createTexture2D();
if (!image || !texture) {
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::fieldIdentityChanged(oldT0, nextT0);
const bool reuseOldT1 = !particle::fieldIdentityChanged(oldT1, nextT0);
const bool reuseNewT0 = !particle::fieldIdentityChanged(oldT0, nextT1);
const bool reuseNewT1 = !particle::fieldIdentityChanged(oldT1, nextT1);
const bool windowOverlap = reuseOldT0 || reuseOldT1 || reuseNewT0 || reuseNewT1;
const auto upload = [&](const particle::ParticleFieldSnapshot& field,
std::shared_ptr<PremultipliedImage>& image,
gfx::Texture2DPtr& texture) {
image = makeVelocityFieldImage(field);
texture = context.createTexture2D();
if (!image || !texture) return false;
texture->setImage(image);
texture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Repeat,
.wrapV = gfx::TextureWrapType::Clamp});
++resources->fieldUploadCount;
return true;
};
auto takeSlot = [&](const particle::ParticleFieldSnapshot& wanted,
particle::ParticleFieldSnapshot& assigned,
std::shared_ptr<PremultipliedImage>& image,
gfx::Texture2DPtr& texture) {
if (!particle::fieldIdentityChanged(oldT0, wanted)) {
assigned = oldT0;
image = resources->fieldImage;
texture = resources->fieldTexture;
return true;
}
if (!particle::fieldIdentityChanged(oldT1, wanted)) {
assigned = oldT1;
image = resources->nextFieldImage;
texture = resources->nextFieldTexture;
return true;
}
assigned = wanted;
return upload(assigned, image, texture);
};
particle::ParticleFieldSnapshot assignedT0;
particle::ParticleFieldSnapshot assignedT1;
std::shared_ptr<PremultipliedImage> imageT0;
std::shared_ptr<PremultipliedImage> imageT1;
gfx::Texture2DPtr textureT0;
gfx::Texture2DPtr textureT1;
if (!takeSlot(nextT0, assignedT0, imageT0, textureT0) ||
!takeSlot(nextT1, assignedT1, imageT1, textureT1)) {
Log::Error(Event::Render, "ParticleGPU: field switch resource creation failed");
return;
}
texture->setImage(image);
texture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Nearest,
.wrapU = gfx::TextureWrapType::Repeat,
.wrapV = gfx::TextureWrapType::Clamp});
resources->field = std::move(field);
resources->fieldImage = std::move(image);
resources->fieldTexture = std::move(texture);
resources->fieldGeneration = fieldGeneration;
resources->gpuFieldGeneration = fieldGeneration;
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->fieldGeneration = resources->field.generation;
resources->nextFieldGeneration = resources->nextField.generation;
resources->gpuFieldGeneration = resources->fieldGeneration;
resources->gpuNextFieldGeneration = resources->nextFieldGeneration;
resources->fixtureMode = fixtureMode;
resources->resetSequence();
if (!windowOverlap) resources->resetSequence();
Log::Info(Event::Render,
"ParticleRender: fixture switched from=" + oldFixtureID + " to=" + resources->field.fixtureID +
" oldGeneration=" + std::to_string(oldFieldGeneration) +
" newGeneration=" + std::to_string(resources->fieldGeneration) +
" gpuFieldGeneration=" + std::to_string(resources->gpuFieldGeneration) +
" trail=cleared stateSeed=INITIALIZE upload reason=FIELD_CHANGED");
Log::Info(Event::Render,
"ParticleField: fixture validated id=" + resources->field.fixtureID +
" noData=" + std::to_string(resources->field.noData) +
" velocityCount=" + std::to_string(resources->field.velocity.size()));
"ParticleGPU: time window advance T0=" + oldT0.fixtureID + " T1=" + oldT1.fixtureID +
" -> T0=" + resources->field.fixtureID + " T1=" + resources->nextField.fixtureID +
" 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;
}
}
const auto requestedTimeMix = std::clamp(properties.timeMix, 0.0f, 1.0f);
if (resources->timeMix != requestedTimeMix) {
resources->timeMix = requestedTimeMix;
Log::Info(Event::Render,
"ParticleGPU: timeMix=" + std::to_string(resources->timeMix) + " T0=" + resources->field.fixtureID +
" T1=" + resources->nextField.fixtureID + " uploads=" +
std::to_string(resources->fieldUploadCount) + " simulationRebuild=0 trailRebuild=0");
}
if (!resources->lifecycleModeKnown || resources->lifecycleActive != active) {
Log::Info(Event::Render,

View File

@@ -32,6 +32,7 @@ private:
std::shared_ptr<ParticleGpuResources> gpuResources;
std::optional<particle::ParticleFieldSnapshot> sourceField;
std::optional<particle::ParticleFieldSnapshot> sourceNextField;
bool formalSource = false;
};

View File

@@ -300,6 +300,7 @@ const std::vector<TextureInfo> ParticleGpuValidationShaderInfo::textures = {
TextureInfo{"u_previous", idParticleGpuValidationPreviousTexture},
TextureInfo{"u_mask_texture", idParticleGpuValidationMaskTexture},
TextureInfo{"u_field_texture", idParticleGpuValidationFieldTexture},
TextureInfo{"u_next_field_texture", idParticleGpuValidationNextFieldTexture},
TextureInfo{"u_state_texture", idParticleGpuValidationStateTexture},
};

View File

@@ -221,7 +221,24 @@ std::optional<std::unique_ptr<Source>> convertRasterArraySource(const std::strin
error.message = "raster-array fixture inputs must be strings";
return std::nullopt;
}
return {std::make_unique<RasterArraySource>(id, *fixtureID, *metadataURL, *fieldURL)};
const auto nextFixture = objectMember(value, "next-fixture");
const auto nextMetadata = objectMember(value, "next-metadata");
const auto nextField = objectMember(value, "next-field");
if (static_cast<bool>(nextFixture) != static_cast<bool>(nextMetadata) ||
static_cast<bool>(nextFixture) != static_cast<bool>(nextField)) {
error.message = "raster-array next fixture requires next-fixture, next-metadata, and next-field";
return std::nullopt;
}
if (!nextFixture) return {std::make_unique<RasterArraySource>(id, *fixtureID, *metadataURL, *fieldURL)};
const auto nextFixtureID = toString(*nextFixture);
const auto nextMetadataURL = toString(*nextMetadata);
const auto nextFieldURL = toString(*nextField);
if (!nextFixtureID || !nextMetadataURL || !nextFieldURL) {
error.message = "raster-array next fixture inputs must be strings";
return std::nullopt;
}
return {std::make_unique<RasterArraySource>(
id, *fixtureID, *metadataURL, *fieldURL, *nextFixtureID, *nextMetadataURL, *nextFieldURL)};
}
} // namespace

View File

@@ -6,15 +6,18 @@ namespace mbgl::style {
class ParticleRenderLayerProperties final : public LayerProperties {
public:
ParticleRenderLayerProperties(Immutable<Layer::Impl> impl, bool active_, uint8_t maskMode_, uint8_t fixtureMode_)
ParticleRenderLayerProperties(
Immutable<Layer::Impl> impl, bool active_, uint8_t maskMode_, uint8_t fixtureMode_, float timeMix_)
: LayerProperties(std::move(impl)),
active(active_),
maskMode(maskMode_),
fixtureMode(fixtureMode_) {}
fixtureMode(fixtureMode_),
timeMix(timeMix_) {}
expression::Dependency getDependencies() const noexcept override { return expression::Dependency::None; }
bool active;
uint8_t maskMode;
uint8_t fixtureMode;
float timeMix;
};
} // namespace mbgl::style

View File

@@ -11,6 +11,7 @@ namespace mbgl::style {
namespace {
constexpr const char* ActiveProperty = "raster-particle-active";
constexpr const char* MaskModeProperty = "raster-particle-mask-mode";
constexpr const char* TimeMixProperty = "raster-particle-time-mix";
const LayerTypeInfo typeInfo{.type = "raster-particle",
.source = LayerTypeInfo::Source::Required,
.pass3d = LayerTypeInfo::Pass3D::NotRequired,
@@ -58,6 +59,18 @@ void RasterParticleLayer::setMaskMode(uint8_t mode) {
observer->onLayerChanged(*this);
}
}
float RasterParticleLayer::getTimeMix() const {
return impl().timeMix;
}
void RasterParticleLayer::setTimeMix(float timeMix) {
const auto clamped = std::clamp(timeMix, 0.0f, 1.0f);
if (clamped != getTimeMix()) {
auto next = mutableImpl();
next->timeMix = clamped;
baseImpl = std::move(next);
observer->onLayerChanged(*this);
}
}
std::optional<conversion::Error> RasterParticleLayer::setPropertyInternal(const std::string& name,
const conversion::Convertible& value) {
conversion::Error error;
@@ -74,18 +87,27 @@ std::optional<conversion::Error> RasterParticleLayer::setPropertyInternal(const
setMaskMode(static_cast<uint8_t>(*converted));
return std::nullopt;
}
if (name == TimeMixProperty) {
auto converted = conversion::convert<float>(value, error);
if (!converted || !std::isfinite(*converted))
return conversion::Error{"raster-particle-time-mix must be finite"};
setTimeMix(*converted);
return std::nullopt;
}
return conversion::Error{"layer doesn't support this property"};
}
StyleProperty RasterParticleLayer::getProperty(const std::string& name) const {
if (name == ActiveProperty) return StyleProperty(Value{isActive()}, StyleProperty::Kind::Constant);
if (name == MaskModeProperty)
return StyleProperty(Value{static_cast<uint64_t>(getMaskMode())}, StyleProperty::Kind::Constant);
if (name == TimeMixProperty) return StyleProperty(Value{getTimeMix()}, StyleProperty::Kind::Constant);
return {};
}
Value RasterParticleLayer::serialize() const {
auto result = Layer::serialize();
serializeProperty(result, getProperty(ActiveProperty), ActiveProperty, true);
serializeProperty(result, getProperty(MaskModeProperty), MaskModeProperty, true);
serializeProperty(result, getProperty(TimeMixProperty), TimeMixProperty, true);
return result;
}
std::unique_ptr<Layer> RasterParticleLayer::cloneRef(const std::string& id) const {

View File

@@ -14,6 +14,7 @@ public:
void stringifyLayout(rapidjson::Writer<rapidjson::StringBuffer>&) const override {}
bool active = true;
uint8_t maskMode = 0;
float timeMix = 0.0f;
DECLARE_LAYER_TYPE_INFO;
};

View File

@@ -24,8 +24,17 @@ std::atomic_uint64_t nextRasterArrayFieldGeneration{0};
RasterArraySource::RasterArraySource(std::string id,
std::string fixtureID,
std::string metadataURL,
std::string fieldURL)
: Source(makeMutable<Impl>(std::move(id), std::move(fixtureID), std::move(metadataURL), std::move(fieldURL))) {}
std::string fieldURL,
std::string nextFixtureID,
std::string nextMetadataURL,
std::string nextFieldURL)
: Source(makeMutable<Impl>(std::move(id),
std::move(fixtureID),
std::move(metadataURL),
std::move(fieldURL),
std::move(nextFixtureID),
std::move(nextMetadataURL),
std::move(nextFieldURL))) {}
RasterArraySource::~RasterArraySource() {
weakFactory.invalidateWeakPtrs();
@@ -61,21 +70,69 @@ void RasterArraySource::loadDescription(FileSource& fileSource) {
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();
}
});
}
}
void RasterArraySource::tryLoad() {
if (!metadata || !fieldBytes) return;
if (!metadata || !fieldBytes || (!impl().nextFixtureID.empty() && (!nextMetadata || !nextFieldBytes))) return;
try {
const auto fieldGeneration = ++nextRasterArrayFieldGeneration;
auto field = particle::OfflineFixtureParticleAdapter::load(
impl().fixtureID, *metadata, *fieldBytes, fieldGeneration);
baseImpl = makeMutable<Impl>(impl(), std::move(field));
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);
} catch (const std::exception& error) {

View File

@@ -8,24 +8,44 @@ namespace mbgl::style {
class RasterArraySource::Impl final : public Source::Impl {
public:
Impl(std::string id, std::string fixtureID_, std::string metadataURL_, std::string fieldURL_)
Impl(std::string id,
std::string fixtureID_,
std::string metadataURL_,
std::string fieldURL_,
std::string nextFixtureID_ = {},
std::string nextMetadataURL_ = {},
std::string nextFieldURL_ = {})
: Source::Impl(SourceType::RasterArray, std::move(id)),
fixtureID(std::move(fixtureID_)),
metadataURL(std::move(metadataURL_)),
fieldURL(std::move(fieldURL_)) {}
fieldURL(std::move(fieldURL_)),
nextFixtureID(std::move(nextFixtureID_)),
nextMetadataURL(std::move(nextMetadataURL_)),
nextFieldURL(std::move(nextFieldURL_)) {}
Impl(const Impl&) = default;
Impl(const Impl& other, particle::ParticleFieldSnapshot field_)
: Source::Impl(other),
fixtureID(other.fixtureID),
metadataURL(other.metadataURL),
fieldURL(other.fieldURL),
nextFixtureID(other.nextFixtureID),
nextMetadataURL(other.nextMetadataURL),
nextFieldURL(other.nextFieldURL),
field(std::move(field_)) {}
Impl(const Impl& other, particle::ParticleFieldSnapshot field_, particle::ParticleFieldSnapshot nextField_)
: Impl(other, std::move(field_)) {
nextField = std::move(nextField_);
}
std::optional<std::string> getAttribution() const override { return {}; }
std::string fixtureID;
std::string metadataURL;
std::string fieldURL;
std::string nextFixtureID;
std::string nextMetadataURL;
std::string nextFieldURL;
std::optional<particle::ParticleFieldSnapshot> field;
std::optional<particle::ParticleFieldSnapshot> nextField;
};
} // namespace mbgl::style

View File

@@ -219,6 +219,27 @@ TEST(Source, RasterArrayFieldIdentityIncludesFixtureAndDataVersion) {
EXPECT_FALSE(particle::fieldIdentityChanged(east, east));
}
TEST(Source, RasterArrayTimeWindowKeepsIndependentCompatibleSnapshots) {
const auto east = particle::OfflineFixtureParticleAdapter::load("uniform-east", 11);
const auto north = particle::OfflineFixtureParticleAdapter::load("uniform-north", 12);
const particle::ParticleFieldTimeWindow window{east, north};
EXPECT_TRUE(window.isValid());
EXPECT_NE(window.t0.generation, window.t1.generation);
EXPECT_TRUE(window.t0.isTimeInterpolationCompatibleWith(window.t1));
EXPECT_TRUE(particle::fieldIdentityChanged(window.t0, window.t1));
}
TEST(Source, RasterArrayTimeWindowRejectsIncompatibleGrid) {
const auto east = particle::OfflineFixtureParticleAdapter::load("uniform-east", 13);
auto incompatible = east;
++incompatible.width;
const particle::ParticleFieldTimeWindow window{east, incompatible};
EXPECT_FALSE(east.isTimeInterpolationCompatibleWith(incompatible));
EXPECT_FALSE(window.isValid());
}
TEST(Source, LoadingCorrupt) {
SourceTest test;