feat: add Android particle capacity baseline
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-03 14:22:16 +08:00
parent b127822301
commit c0d1ad43c4
13 changed files with 333 additions and 18 deletions

View File

@@ -0,0 +1,39 @@
# STEP-044 Android OpenGL 粒子数量容量基线
- 状态:`IMPLEMENTED / WAITING FOR ANDROID VALIDATION`
- 前置:STEP-041(40-02)已 `CLOSED / PASS`。
- 目标:在唯一正式链路 `RasterArraySource -> RasterParticleLayer -> GPU State A/B -> Trail A/B -> Composite` 上,为 Android OpenGL 提供可重复的 State 粒子容量档位;本 Step 不宣称任何容量实测结果。
## 实现
- 正式 `RasterParticleLayer` 新增常量 paint property `raster-particle-particle-count`,默认 `256`。Core 只接受 `256..1048576` 的平方数,保证 Renderer 总能将其映射为合法的正方形 State texture。
- 数量的实际承载点是正式 GPU State A/B render target:默认 `16x16=256`,可切换为 `32x32=1024`、`64x64=4096`、`128x128=16384`、`256x256=65536`、`512x512=262144`(Core 支持的中间平方值)和页面最高 `1024x1024=1048576`。每个 State texel 继续运行既有 State shader,故 effective count 即该网格面积。
- Trail 仍只读取四个确定性 State texel 作为低开销视觉健康指示;这不是第二个 simulation,也不会改变 State A/B、Trail A/B、Composite 或 40-02 Field/MIX 语义。
- Android 验证页提供 `256 / 1K / 4K / 16K / 64K / 1M` 档位。请求通过既有 `MapLibreMap -> NativeMapView -> JNI -> Style::getLayer()` 薄桥,并经强类型 `RasterParticleLayer::setParticleCount(uint32_t)` 生效;没有 Java raster-particle Layer peer。
- 切换数量只替换 State A/B,并执行一次 `SIMULATION_RESET reason=PARTICLE_COUNT_CHANGE` 以 seed 新网格、清理旧 Trail。Field T0/T1、GPU content reuse、MIX、interpolation、mask、viewport 逻辑和 layer instance/resource generation 均不因该属性本身改变。
## 观测契约
- Android host:`PARTICLE_COUNT_REQUEST`、`PARTICLE_COUNT_APPLIED`、拒绝原因;状态行显示当前 count。
- Core/JNI:`STYLE_PROPERTY_SET result=SUCCESS|INVALID_PARTICLE_COUNT`。
- Renderer:`PARTICLE_RESOURCES_CREATE|REBUILD|RELEASE`,每条含 requested/effective count、State grid 和 resource generation;现有低频 schedule/pass/commit 继续证明 simulation、trail 与 composite 持续工作。
- State target 创建失败时记录 `PARTICLE_COUNT_REJECTED reason=STATE_RESOURCE_CREATE_FAILED`,旧资源/旧 effective count 保持。
## Android 待验收矩阵
| 粒子数量 | 是否持续运行 | 画面/帧表现 | 内存趋势 | GL/GPU 错误 | 生命周期异常 | 结论 |
|---:|---|---|---|---|---|---|
| 256 | 待测 | 待测 | 待测 | 待测 | 待测 | 待测 |
| 1,024 | 待测 | 待测 | 待测 | 待测 | 待测 | 待测 |
| 4,096 | 待测 | 待测 | 待测 | 待测 | 待测 | 待测 |
| 16,384 | 待测 | 待测 | 待测 | 待测 | 待测 | 待测 |
| 65,536 | 待测 | 待测 | 待测 | 待测 | 待测 | 待测 |
| 1,048,576 | 待测 | 待测 | 待测 | 待测 | 待测 | 待测 |
## 本地验证边界
- Linux/OpenGL:`raster_particle_layer.cpp.o`、`render_particle_validation_layer.cpp.o`、`style_layer.test.cpp.o` 精确编译通过;新增 Layer 测试覆盖默认、合法及非法 count 的 Core guard。
- `clang-format` 与 `git diff --check` 通过。
- 未运行 Android Gradle、ADB、APK 安装或设备测试;未加入/执行 Jest。
Android 用户完成各档长时间测试、GL/GPU 日志和矩阵后,才可判定稳定区间、性能下降区间和压力边界。

View File

@@ -0,0 +1,48 @@
# 40-03 Android OpenGL 粒子数量容量基线 Codex 完成报告
## 状态
`40-03:IMPLEMENTED / WAITING FOR ANDROID VALIDATION`
40-02 的已关闭结论未改变。本报告只交付正式 GPU 链的容量测试入口;Android x86_64 实机矩阵尚无结果,不能写 `PASS` 或声称存在稳定容量区间。
## 已完成
1. 当前正式粒子数的最终控制点是 `RenderRasterParticleLayer` 的 State A/B render target。原固定 `16x16` 即 `256` 个 State shader texel;每个 texel保存并推进一粒子的 RG position、B age、A validity。
2. 新增 `RasterParticleLayer::setParticleCount(uint32_t)` 与 `raster-particle-particle-count`。默认仍为 256,Core 接受 256 到 1,048,576 的平方数,实际生效 count 为 State grid 面积。
3. Android 验证页增加 `256 / 1K / 4K / 16K / 64K / 1M`。请求经既有 JNI float-property 薄桥进入强类型 Core setter,成功后才更新 host count;没有 CPU simulation、第二 Layer 或 Java Layer peer。
4. 切换 count 会释放并替换 State A/B,记录一次有理由的 `SIMULATION_RESET reason=PARTICLE_COUNT_CHANGE`,随后重新 seed State/Trail。它不会执行 Style reload、Field upload、MIX 更新或时间窗推进;Field T0/T1、SHA-256 reuse、interpolation、mask 和 40-02 生命周期语义保持。
5. 新增低频资源诊断:`PARTICLE_COUNT_REQUEST/APPLIED/REJECTED`、`PARTICLE_RESOURCES_CREATE/REBUILD/RELEASE`、`STYLE_PROPERTY_SET`。它们含 requested/effective count、State grid、resource generation 或失败原因;原有采样 schedule/pass/commit 日志继续用于确认 simulation、trail 与 composite。
Trail 仍将四个确定性 State texel投射为可视 marker/trail。这是正式 State simulation 的低开销可视健康指示,不改变实际 State texel 并行数,也没有复制任何 simulation。
## 本地检查
- `clang-format`:受影响 C++ 文件已格式化。
- Linux/OpenGL 精确编译:`raster_particle_layer.cpp.o`、`render_particle_validation_layer.cpp.o`、`style_layer.test.cpp.o` 均通过。
- 新增 `Layer.RasterParticleCountProperties` 测试对象已成功编译,覆盖默认 256、合法 65,536、非平方与越界拒绝;完整测试二进制在当前 build tree 不存在,因此未将其运行误报为通过。
- `git diff --check`:通过。
- Android Gradle:未执行。ADB / APK 安装 / 设备测试:未执行。Jest:未加入、未执行。
## 未完成/等待用户验证
- 六个档位的持续运行、帧表现、内存趋势、GL/GPU 错误与 crash 结果。
- 稳定运行区间、性能明显下降区间和当前压力边界。
- Android Kotlin/JNI 的最终 Android toolchain 编译和 x86_64 OpenGL 实测。
## Windows 11 手工验证
在 PowerShell 或 CMD 中依次执行:
```powershell
cd /mnt/sda1/MapLibreParticleLab/platform/android
./gradlew :MapLibreAndroidTestApp:assembleOpenglDebug -Pandroid.injected.build.abi=x86_64 --console=plain
adb -s 192.168.1.146 install -r .\MapLibreAndroidTestApp\build\intermediates\apk\opengl\debug\MapLibreAndroidTestApp-opengl-debug.apk
adb -s 192.168.1.146 logcat -c
adb -s 192.168.1.146 shell am start -n org.maplibre.android.testapp/.activity.particle.ParticleCoreLayerValidationActivity
adb -s 192.168.1.146 logcat -v time ParticleValidation:I ParticleGPU:I "*:S"
```
页面默认 256;依次点击 `1K`、`4K`、`16K`、`64K`、`1M`,每档至少观察持续推进、四个代表性 trail、交互、内存和日志。每次切换应见 `PARTICLE_COUNT_REQUEST`、JNI `STYLE_PROPERTY_SET result=SUCCESS`、`PARTICLE_RESOURCES_RELEASE`、`SIMULATION_RESET reason=PARTICLE_COUNT_CHANGE` 与 `PARTICLE_RESOURCES_REBUILD`;不应见 `FIELD_UPLOAD`、Style reload 或无理由 reset。若高档创建失败,应记录 `PARTICLE_COUNT_REJECTED` 而保持旧档稳定运行。
APK 路径:`./MapLibreAndroidTestApp/build/intermediates/apk/opengl/debug/MapLibreAndroidTestApp-opengl-debug.apk`。

View File

@@ -2,6 +2,8 @@
#include <mbgl/style/layer.hpp>
#include <cstdint>
namespace mbgl::style {
class RasterParticleLayer final : public Layer {
@@ -14,6 +16,8 @@ public:
void setMaskMode(uint8_t);
float getTimeMix() const;
void setTimeMix(float);
uint32_t getParticleCount() const;
void setParticleCount(uint32_t);
class Impl;
const Impl& impl() const;
Mutable<Impl> mutableImpl() const;

View File

@@ -3,6 +3,8 @@
#include <cstdlib>
#include <ctime>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <memory>
#include <list>
#include <tuple>
@@ -1113,7 +1115,8 @@ jni::jboolean NativeMapView::setStyleLayerFloatProperty(JNIEnv& env,
const jni::jfloat value) {
auto* layer = map->getStyle().getLayer(jni::Make<std::string>(env, layerId));
if (!layer) return false;
if (jni::Make<std::string>(env, propertyName) != "raster-particle-time-mix") {
const auto property = jni::Make<std::string>(env, propertyName);
if (property != "raster-particle-time-mix" && property != "raster-particle-particle-count") {
mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=UNSUPPORTED_PROPERTY");
return false;
}
@@ -1122,11 +1125,23 @@ jni::jboolean NativeMapView::setStyleLayerFloatProperty(JNIEnv& env,
mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=UNSUPPORTED_LAYER");
return false;
}
particleLayer->setTimeMix(value);
if (property == "raster-particle-time-mix") {
particleLayer->setTimeMix(value);
} else {
if (!std::isfinite(value) || value < 0.0f || std::floor(value) != value) {
mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=INVALID_PARTICLE_COUNT");
return false;
}
const auto requestedCount = static_cast<uint32_t>(value);
particleLayer->setParticleCount(requestedCount);
if (particleLayer->getParticleCount() != requestedCount) {
mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=INVALID_PARTICLE_COUNT");
return false;
}
}
mbgl::Log::Info(mbgl::Event::JNI,
"ParticleGPU: event=STYLE_PROPERTY_SET layerId=" + jni::Make<std::string>(env, layerId) +
" property=" + jni::Make<std::string>(env, propertyName) + " value=" + std::to_string(value) +
" result=SUCCESS");
" property=" + property + " value=" + std::to_string(value) + " result=SUCCESS");
return true;
}

View File

@@ -23,6 +23,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var maskMode = MASK_GLOBAL
private var fixtureMode = FIXTURE_UNIFORM_EAST
private var timeMix = 0.0f
private var particleCount = PARTICLE_COUNT_256
private var timeWindowMode = false
private var styleLoadEpoch = 0L
private var styleReady = false
@@ -33,6 +34,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
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
particleCount = savedInstanceState?.getInt(STATE_PARTICLE_COUNT, PARTICLE_COUNT_256) ?: PARTICLE_COUNT_256
timeWindowMode = savedInstanceState?.getBoolean(STATE_TIME_WINDOW, false) ?: false
super.onCreate(savedInstanceState)
Log.i(
@@ -96,6 +98,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
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.validationParticle256).setOnClickListener { setParticleCount(PARTICLE_COUNT_256) }
findViewById<Button>(R.id.validationParticle1024).setOnClickListener { setParticleCount(PARTICLE_COUNT_1024) }
findViewById<Button>(R.id.validationParticle4096).setOnClickListener { setParticleCount(PARTICLE_COUNT_4096) }
findViewById<Button>(R.id.validationParticle16384).setOnClickListener { setParticleCount(PARTICLE_COUNT_16384) }
findViewById<Button>(R.id.validationParticle65536).setOnClickListener { setParticleCount(PARTICLE_COUNT_65536) }
findViewById<Button>(R.id.validationParticle1048576).setOnClickListener { setParticleCount(PARTICLE_COUNT_1048576) }
findViewById<Button>(R.id.validationAdvanceWindow).setOnClickListener {
timeWindowMode = true
fixtureMode = FIXTURE_UNIFORM_NORTH
@@ -164,6 +172,30 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
Log.i(TAG, "ParticleGPU: event=MIX_PROPERTY_ONLY value=$timeMix styleEpoch=$styleLoadEpoch")
}
private fun setParticleCount(requestedParticleCount: Int) {
if (!styleReady) {
Log.i(TAG, "ParticleGPU: event=PARTICLE_COUNT_REJECTED reason=STYLE_LOADING requestedParticleCount=$requestedParticleCount styleEpoch=$styleLoadEpoch")
return
}
if (!enabled) {
Log.i(TAG, "ParticleGPU: event=PARTICLE_COUNT_REJECTED reason=DISABLED requestedParticleCount=$requestedParticleCount styleEpoch=$styleLoadEpoch")
return
}
Log.i(TAG, "ParticleGPU: event=PARTICLE_COUNT_REQUEST requestedParticleCount=$requestedParticleCount styleEpoch=$styleLoadEpoch")
if (!maplibreMap.setStyleLayerFloatProperty(
PARTICLE_LAYER_ID,
"raster-particle-particle-count",
requestedParticleCount.toFloat()
)
) {
Log.i(TAG, "ParticleGPU: event=PARTICLE_COUNT_REJECTED reason=CORE_PROPERTY_REJECTED requestedParticleCount=$requestedParticleCount styleEpoch=$styleLoadEpoch")
return
}
particleCount = requestedParticleCount
status.text = statusText()
Log.i(TAG, "ParticleGPU: event=PARTICLE_COUNT_APPLIED requestedParticleCount=$requestedParticleCount effectiveParticleCount=$particleCount styleEpoch=$styleLoadEpoch")
}
private fun statusText(): String {
val mask = when (maskMode) {
MASK_OCEAN_A -> "OCEAN A"
@@ -178,7 +210,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
} else {
"EAST"
}
return "$mask · $runtime · $fields · MIX ${(timeMix * 100).toInt()}%"
return "$mask · $runtime · $fields · MIX ${(timeMix * 100).toInt()}% · $particleCount particles"
}
private fun validationStyle(active: Boolean, enabled: Boolean): String {
@@ -191,7 +223,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
val particleLayer = if (enabled) {
"""{"id":"particle-raster-core","type":"raster-particle","source":"particle-fixture",
"paint":{"raster-particle-active":$active,"raster-particle-mask-mode":$maskMode,
"raster-particle-time-mix":$timeMix}},"""
"raster-particle-time-mix":$timeMix,"raster-particle-particle-count":$particleCount}},"""
} else {
""
}
@@ -239,6 +271,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
outState.putInt(STATE_MASK_MODE, maskMode)
outState.putInt(STATE_FIXTURE_MODE, fixtureMode)
outState.putFloat(STATE_TIME_MIX, timeMix)
outState.putInt(STATE_PARTICLE_COUNT, particleCount)
outState.putBoolean(STATE_TIME_WINDOW, timeWindowMode)
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
@@ -256,6 +289,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
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_PARTICLE_COUNT = "particle_validation.particle_count"
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
@@ -263,5 +297,11 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private const val MASK_OCEAN_B = 2
private const val FIXTURE_UNIFORM_EAST = 0
private const val FIXTURE_UNIFORM_NORTH = 1
private const val PARTICLE_COUNT_256 = 256
private const val PARTICLE_COUNT_1024 = 1024
private const val PARTICLE_COUNT_4096 = 4096
private const val PARTICLE_COUNT_16384 = 16384
private const val PARTICLE_COUNT_65536 = 65536
private const val PARTICLE_COUNT_1048576 = 1048576
}
}

View File

@@ -30,6 +30,26 @@
android:textColor="@android:color/white"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationParticle256" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_256" />
<Button android:id="@+id/validationParticle1024" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_1024" />
<Button android:id="@+id/validationParticle4096" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_4096" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationParticle16384" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_16384" />
<Button android:id="@+id/validationParticle65536" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_65536" />
<Button android:id="@+id/validationParticle1048576" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_1048576" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"

View File

@@ -29,6 +29,12 @@
<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_particles_256">256</string>
<string name="particle_validation_particles_1024">1K</string>
<string name="particle_validation_particles_4096">4K</string>
<string name="particle_validation_particles_16384">16K</string>
<string name="particle_validation_particles_65536">64K</string>
<string name="particle_validation_particles_1048576">1M</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

@@ -42,7 +42,6 @@ namespace mbgl {
namespace {
constexpr auto ShaderName = "ParticleGpuValidationShader";
constexpr Size StateSize{16, 16};
constexpr Size MaskSize{16, 16};
std::atomic_uint64_t nextGpuGeneration{0};
@@ -56,16 +55,17 @@ struct ParticleSettings {
uint8_t fixtureMode;
bool usesFormalSource;
float timeMix;
uint32_t particleCount;
};
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};
return {layer.active, layer.maskMode, 0, true, layer.timeMix, layer.particleCount};
}
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};
return {layer.active, layer.maskMode, layer.fixtureMode, false, 0.0f, 256};
}
enum class ValidationPass : uint8_t {
@@ -83,6 +83,11 @@ 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::sqrt(static_cast<float>(particleCount)));
return {side, side};
}
const char* fixtureID(const uint8_t mode) {
switch (mode) {
case 0:
@@ -141,6 +146,15 @@ void removeTargets(const std::vector<RenderTargetPtr>& targets, UniqueChangeRequ
}
}
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 {
@@ -172,9 +186,11 @@ public:
uint64_t fieldUploadCount = 0;
uint64_t resetCount = 0;
float timeMix = 0.0f;
uint32_t particleCount = 0;
uint8_t fixtureMode = 255;
uint8_t loggedMaskMode = 255;
Size trailSize{};
Size stateSize{};
std::array<RenderTargetPtr, 2> stateTargets;
std::array<RenderTargetPtr, 2> trailTargets;
@@ -516,8 +532,12 @@ bool buildQuad(gfx::Context& context,
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);
return makeMutable<style::ParticleRenderLayerProperties>(std::move(impl_),
settings.active,
settings.maskMode,
settings.fixtureMode,
settings.timeMix,
settings.particleCount);
}()) {
renderLayerInstanceId = ++nextRenderLayerInstanceId;
formalSource = baseImpl->getTypeInfo() == style::RasterParticleLayer::Impl::staticTypeInfo();
@@ -544,7 +564,7 @@ void RenderRasterParticleLayer::evaluate(const PropertyEvaluationParameters&) {
" renderLayerInstanceId=" + std::to_string(renderLayerInstanceId));
const auto settings = particleSettings(baseImpl);
auto properties = makeMutable<style::ParticleRenderLayerProperties>(
baseImpl, settings.active, settings.maskMode, settings.fixtureMode, settings.timeMix);
baseImpl, settings.active, settings.maskMode, settings.fixtureMode, settings.timeMix, settings.particleCount);
passes = RenderPass::Translucent;
properties->renderPasses = mbgl::underlying_type(passes);
evaluatedProperties = std::move(properties);
@@ -554,7 +574,7 @@ void RenderRasterParticleLayer::evaluate(const PropertyEvaluationParameters&) {
" maskMode=" + std::to_string(settings.maskMode) + " id=" + getID() + " fixture=" +
(formalSource && sourceField ? sourceField->fixtureID : fixtureID(settings.fixtureMode)) +
" layerIndex=" + std::to_string(getLayerIndex()) + " timeMix=" + std::to_string(settings.timeMix) +
" stage=40-02");
" particleCount=" + std::to_string(settings.particleCount) + " stage=40-03");
}
bool RenderRasterParticleLayer::needsRepaint() const {
@@ -572,6 +592,10 @@ void RenderRasterParticleLayer::markLayerRenderable(bool willRender, UniqueChang
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));
removeTargets(gpuResources->registeredTargets, changes);
gpuResources->registeredTargets.clear();
}
@@ -581,6 +605,10 @@ void RenderRasterParticleLayer::layerRemoved(UniqueChangeRequestVec& 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,
"ParticleValidation30_02: context lost discard generation=" + std::to_string(gpuResources->generation) +
@@ -618,6 +646,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
const bool active = properties.active;
const uint8_t maskMode = properties.maskMode;
const uint8_t fixtureMode = properties.fixtureMode;
const uint32_t particleCount = properties.particleCount;
auto resources = gpuResources;
if (resources && resources->contextDestroyed) {
// VALIDATION ONLY: follow the 30-02 resource-generation rule. No stale
@@ -634,9 +663,11 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
owned->vertices = std::make_shared<gfx::VertexVector<HeatmapTextureLayoutVertex>>(
RenderStaticData::heatmapTextureVertices());
owned->segments = RenderStaticData::heatmapTextureSegments();
owned->particleCount = particleCount;
owned->stateSize = particleStateSize(owned->particleCount);
for (uint8_t i = 0; i < 2; ++i) {
owned->stateTargets[i] = createValidationTarget(
context, StateSize, getID() + "-state-" + slotName(i), gfx::TextureFilterType::Nearest);
context, owned->stateSize, getID() + "-state-" + slotName(i), gfx::TextureFilterType::Nearest);
}
if (!owned->shader || !owned->stateTargets[0] || !owned->stateTargets[1]) {
Log::Error(Event::Render, "ParticleValidation30_02: State resource creation failed");
@@ -710,7 +741,14 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
resources = gpuResources;
Log::Info(Event::Render,
"ParticleValidation30_02: State A/B create generation=" + std::to_string(resources->generation) +
" size=16x16 independent=true");
" 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,
"ParticleField: fixture loaded id=" + resources->field.fixtureID + " type=" + resources->field.fieldType +
@@ -745,6 +783,42 @@ 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");
}
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);
}
if (!replacementTargets[0] || !replacementTargets[1]) {
Log::Error(Event::Render,
"ParticleGPU: event=PARTICLE_COUNT_REJECTED reason=STATE_RESOURCE_CREATE_FAILED "
"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));
removeTargets(resources->stateTargets, changes);
resources->stateTargets = std::move(replacementTargets);
resources->particleCount = particleCount;
resources->stateSize = requestedSize;
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));
}
const bool sourceChanged = formalSource && sourceField && sourceNextField &&
(particle::fieldIdentityChanged(resources->field, *sourceField) ||
particle::fieldIdentityChanged(resources->nextField, *sourceNextField));

View File

@@ -2,22 +2,30 @@
#include <mbgl/style/layer_properties.hpp>
#include <cstdint>
namespace mbgl::style {
class ParticleRenderLayerProperties final : public LayerProperties {
public:
ParticleRenderLayerProperties(
Immutable<Layer::Impl> impl, bool active_, uint8_t maskMode_, uint8_t fixtureMode_, float timeMix_)
ParticleRenderLayerProperties(Immutable<Layer::Impl> impl,
bool active_,
uint8_t maskMode_,
uint8_t fixtureMode_,
float timeMix_,
uint32_t particleCount_)
: LayerProperties(std::move(impl)),
active(active_),
maskMode(maskMode_),
fixtureMode(fixtureMode_),
timeMix(timeMix_) {}
timeMix(timeMix_),
particleCount(particleCount_) {}
expression::Dependency getDependencies() const noexcept override { return expression::Dependency::None; }
bool active;
uint8_t maskMode;
uint8_t fixtureMode;
float timeMix;
uint32_t particleCount;
};
} // namespace mbgl::style

View File

@@ -12,6 +12,15 @@ namespace {
constexpr const char* ActiveProperty = "raster-particle-active";
constexpr const char* MaskModeProperty = "raster-particle-mask-mode";
constexpr const char* TimeMixProperty = "raster-particle-time-mix";
constexpr const char* ParticleCountProperty = "raster-particle-particle-count";
constexpr uint32_t MinimumParticleCount = 256;
constexpr uint32_t MaximumParticleCount = 1024 * 1024;
bool isSupportedParticleCount(const uint32_t count) {
if (count < MinimumParticleCount || count > MaximumParticleCount) return false;
const auto side = static_cast<uint32_t>(std::sqrt(static_cast<float>(count)));
return side * side == count;
}
const LayerTypeInfo typeInfo{.type = "raster-particle",
.source = LayerTypeInfo::Source::Required,
.pass3d = LayerTypeInfo::Pass3D::NotRequired,
@@ -71,6 +80,17 @@ void RasterParticleLayer::setTimeMix(float timeMix) {
observer->onLayerChanged(*this);
}
}
uint32_t RasterParticleLayer::getParticleCount() const {
return impl().particleCount;
}
void RasterParticleLayer::setParticleCount(const uint32_t particleCount) {
if (isSupportedParticleCount(particleCount) && particleCount != getParticleCount()) {
auto next = mutableImpl();
next->particleCount = particleCount;
baseImpl = std::move(next);
observer->onLayerChanged(*this);
}
}
std::optional<conversion::Error> RasterParticleLayer::setPropertyInternal(const std::string& name,
const conversion::Convertible& value) {
conversion::Error error;
@@ -94,6 +114,16 @@ std::optional<conversion::Error> RasterParticleLayer::setPropertyInternal(const
setTimeMix(*converted);
return std::nullopt;
}
if (name == ParticleCountProperty) {
auto converted = conversion::convert<float>(value, error);
if (!converted || !std::isfinite(*converted) || std::floor(*converted) != *converted ||
!isSupportedParticleCount(static_cast<uint32_t>(*converted))) {
return conversion::Error{
"raster-particle-particle-count must be a supported square count from 256 to 1048576"};
}
setParticleCount(static_cast<uint32_t>(*converted));
return std::nullopt;
}
return conversion::Error{"layer doesn't support this property"};
}
StyleProperty RasterParticleLayer::getProperty(const std::string& name) const {
@@ -101,6 +131,8 @@ StyleProperty RasterParticleLayer::getProperty(const std::string& name) const {
if (name == MaskModeProperty)
return StyleProperty(Value{static_cast<uint64_t>(getMaskMode())}, StyleProperty::Kind::Constant);
if (name == TimeMixProperty) return StyleProperty(Value{getTimeMix()}, StyleProperty::Kind::Constant);
if (name == ParticleCountProperty)
return StyleProperty(Value{static_cast<uint64_t>(getParticleCount())}, StyleProperty::Kind::Constant);
return {};
}
Value RasterParticleLayer::serialize() const {
@@ -108,6 +140,7 @@ Value RasterParticleLayer::serialize() const {
serializeProperty(result, getProperty(ActiveProperty), ActiveProperty, true);
serializeProperty(result, getProperty(MaskModeProperty), MaskModeProperty, true);
serializeProperty(result, getProperty(TimeMixProperty), TimeMixProperty, true);
serializeProperty(result, getProperty(ParticleCountProperty), ParticleCountProperty, true);
return result;
}
std::unique_ptr<Layer> RasterParticleLayer::cloneRef(const std::string& id) const {

View File

@@ -3,6 +3,8 @@
#include <mbgl/style/layer_impl.hpp>
#include <mbgl/style/layers/raster_particle_layer.hpp>
#include <cstdint>
namespace mbgl::style {
class RasterParticleLayer::Impl final : public Layer::Impl {
@@ -15,6 +17,7 @@ public:
bool active = true;
uint8_t maskMode = 0;
float timeMix = 0.0f;
uint32_t particleCount = 256;
DECLARE_LAYER_TYPE_INFO;
};

View File

@@ -16,6 +16,7 @@
#include <mbgl/style/layers/line_layer_impl.hpp>
#include <mbgl/style/layers/raster_layer.hpp>
#include <mbgl/style/layers/raster_layer_impl.hpp>
#include <mbgl/style/layers/raster_particle_layer.hpp>
#include <mbgl/style/layers/symbol_layer.hpp>
#include <mbgl/style/layers/symbol_layer_impl.hpp>
#include <mbgl/style/style_impl.hpp>
@@ -111,6 +112,22 @@ TEST(Layer, CircleProperties) {
EXPECT_EQ(layer->getCircleTranslateAnchor(), translateAnchor);
}
TEST(Layer, RasterParticleCountProperties) {
RasterParticleLayer layer{"particle", "raster-array"};
EXPECT_EQ(256u, layer.getParticleCount());
layer.setParticleCount(65536);
EXPECT_EQ(65536u, layer.getParticleCount());
// Non-square and out-of-range counts are rejected by the Core Layer so
// RenderRasterParticleLayer always receives a valid State texture grid.
layer.setParticleCount(1000);
EXPECT_EQ(65536u, layer.getParticleCount());
layer.setParticleCount(1048577);
EXPECT_EQ(65536u, layer.getParticleCount());
}
TEST(Layer, FillProperties) {
auto layer = std::make_unique<FillLayer>("fill", "source");
ASSERT_STREQ("fill", layer->getTypeInfo()->type);

View File

@@ -258,3 +258,11 @@
- STEP-043B 最终使用 `RasterParticleLayer::setTimeMix(float)`;Android x86_64 OpenGL Debug 编译、安装、启动通过,T1--T10 全部 PASS。
- A/B→B/C GPU texture reuse、MIX property-only、DISABLE/ENABLE 生命周期通过;`uniform-north + vortex` 的 MIX 50% 局部低速/近零速是正常逐点插值。
- 单 ABI APK 约 696.5 MiB 是独立 packaging 技术债,不阻塞本阶段。40-02 / 40-02C / STEP-043B 正式 `CLOSED / PASS`。
## 2026-09-03 — 40-03 Android OpenGL 粒子数量容量基线(代码阶段)
- 正式 RasterParticle GPU 链的粒子数实际由 State A/B render target 网格承载:既有 `16x16` 即 256 个 State shader texel。新增 `raster-particle-particle-count` Core 属性与强类型 `RasterParticleLayer::setParticleCount(uint32_t)`,只接受 256..1,048,576 的平方数,确保实际 count 与 State grid 面积一致。
- Android Validation 页面新增 256、1K、4K、16K、64K、1M 档位;使用既有 Android→Core 薄桥,不建立 Java layer peer、CPU simulation 或第二 GPU simulation。切换只重建 State A/B、执行一次可解释的 `PARTICLE_COUNT_CHANGE` reset/reseed,Field T0/T1、MIX、interpolation、GPU content reuse、mask 与既有 lifecycle 均不改变。
- 日志包含请求/实际 count、State grid、State target create/rebuild/release 和拒绝原因;Trail 继续用四个确定性 State texel作低开销可视健康指示,实际并行 simulation 数仍是整个 State 网格。
- Linux OpenGL 受影响 Core 和新增 Layer test object 精确编译、C++ format、diff check 均通过;未运行 Android Gradle、ADB、APK 或设备,也未加入 Jest。
- 当前状态只能是 `IMPLEMENTED / WAITING FOR ANDROID VALIDATION`。用户将以 x86_64 Android OpenGL 逐档填写容量矩阵后,才能确定稳定区间、性能下降区间和压力边界。