feat: add particle visual runtime controls
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-03 19:47:47 +08:00
parent eae8652486
commit e48fa087e0
17 changed files with 704 additions and 54 deletions

View File

@@ -0,0 +1,69 @@
# STEP-046 Android OpenGL 正式粒子视觉与运行时渲染控制
日期:2026-09-03
状态:IMPLEMENTED / WAITING FOR ANDROID VALIDATION
## 目标
在已关闭的 40-04 Runtime Field 链路上,将正式 `RasterParticleLayer` 的 full particle draw 从固定蓝色 point 扩展为 GPU 速度驱动的短 streak,并一次性提供运行时视觉属性和 Android 验收入口。
正式链路保持唯一:
```text
RasterArraySource T0/T1
-> GPU time interpolation
-> State A/B
-> Trail A/B
-> GPU full streak draw
-> Composite
```
## 实现
- 每个 State texel 仍代表一颗正式粒子。静态 VBO 仅保存该 texel 的两个 line endpoint 编码;不包含 live position、direction 或 color。
- vertex shader 从当前 State write texture 和 T0/T1 Field textures 获取位置与 `mix` 后的速度,按既有边界将 `V north-positive` 转换为 Mercator `dy=-v`,生成 canonical map-space 短 streak。无效/NoData/NaN/近零速度会 fail-closed,不绘制该 streak。
- fragment shader 用最终插值速度 magnitude 走稳定的蓝→绿→橙 ramp;COLOR 控制可关闭 ramp 回退为固定蓝色。相同 magnitude 的 EAST/NORTH 保持同色,vortex 可显示局部速度差异。
- 新增 Core visual paint properties:
- `raster-particle-line-width`
- `raster-particle-particle-length`
- `raster-particle-particle-opacity`
- `raster-particle-trail-opacity`
- `raster-particle-trail-fade`
- `raster-particle-speed-color-enabled`
- 所有属性是 Render/Visual property,不是 Field 或 simulation state。其运行时更新经既有 `MapLibreMap -> NativeMapView -> JNI -> RasterParticleLayer` boolean thin bridge,调用强类型 Core setter;不通过 Android Java Layer peer。
- Android Validation 页面加入 VISUAL DEFAULT、SIZE、LENGTH、OPACITY、TRAIL、FADE 与 COLOR 控制,另有直接 VORTEX fixture 与 256K capacity 档;状态栏保留当前可见参数;Activity recreation 保存这些值。
## 不变量与观测
- visual property 更新只更新 RenderLayer/UBO;不会 reload Style、reload Source、上传 Field、重建 T0/T1、reset State/Trail 或清 Trail。
- 40-04 的 A/B→B/C→C/D、`OLD_T1 -> NEW_T0` GPU reuse、Duplicate→NOOP、`NO_PRELOADED_FIELD` fail-closed 保持不变。
- `PARTICLE_DRAW_RESOURCES_*` 记录 `particleDrawCount=N` 与 `streakVertexCount=2N`,明确两个顶点仍是一颗粒子的线段,不改变 40-03 已验证容量 `>= 1,048,576` 的含义。
- `VISUAL_PROPERTY_APPLIED` 记录参数、renderLayerInstanceId、resourceGeneration、fieldUploadCount、resetCount;不按 frame 高频输出。没有可靠 GPU timer,因此不虚构 GPU ms。
## 静态验证
- `node shaders/generate_shader_code.mjs` 连续运行两次,生成物稳定。
- Linux OpenGL 精确编译:`raster_particle_layer.cpp.o`、`render_particle_validation_layer.cpp.o`、`style_layer.test.cpp.o`:PASS。
- 新增 `RasterParticleVisualProperties` C++ unit-level coverage,检查默认、运行时 setter 和 visual 参数边界。
- `clang-format` 与 `git diff --check`:PASS。
- 未运行 Android Gradle、ADB、真机或 Jest。
## Android 单轮验收
```bash
cd /mnt/sda1/MapLibreParticleLab/platform/android
./gradlew :MapLibreAndroidTestApp:assembleOpenglDebug \
-Pandroid.injected.build.abi=x86_64 \
--console=plain
```
APK:`./MapLibreAndroidTestApp/build/intermediates/apk/opengl/debug/MapLibreAndroidTestApp-opengl-debug.apk`
用户在同一 APK 中执行 T1--T8:EAST/NORTH/vortex geometry、MIX=0/50/100 连续性、VISUAL controls property-only、STATIC/Camera/Rotation、1M baseline,以及 Runtime advance/reuse/NOOP/fail-closed 回归。
当前 T1--T8 全部为 `PENDING USER ANDROID VALIDATION`;未将任何 Android 视觉、Camera、Mask、Lifecycle 或 capacity 结果写成 PASS。
下一建议阶段:用户完成这一次综合 Android 验收并提供结构化日志;若全部通过,才可进入 40-06 时间序列 Playback/preload/cache 的规划。
本阶段不包含 WeatherColorLayer、网络/GRIB/provider、React Native、Vulkan、iOS/Metal、生产 Land/Ocean mask、粒子箭头贴图或 APK packaging 技术债。

View File

@@ -0,0 +1,66 @@
# 40-05 Android OpenGL 正式粒子视觉与运行时渲染控制 — Codex 完成报告
日期:2026-09-03
状态:IMPLEMENTED / WAITING FOR ANDROID VALIDATION
## 结果
40-05 / STEP-046 已完成代码、Android 验收入口与静态验证,尚未进行 Android 构建或实体机验收,因此不能标记 PASS。
### A. 正式方向 geometry
`RenderRasterParticleLayer` 的正式 full draw 不再画固定蓝色 point。每个 State texel 创建两个静态 line vertices;vertex shader 读取当前 State write、T0/T1 Field,并以 `timeMix` 取得最终 GPU velocity。方向由 `vec2(u, -v)` 在 Mercator 边界生成,所以继续遵守 `U=east-positive`、`V=north-positive`、`dy=-v`。没有 CPU 每粒子 direction/color、GPU readback、第二 simulation 或 Overlay。
### B/C. 视觉属性与速度颜色
Core `RasterParticleLayer` 具有 line width、streak length、particle/trail opacity、trail fade、speed-color enabled 的强类型 setter 和 style property 支持。速度色使用 shader 内蓝→绿→橙最小 ramp,输入是最终插值后的 magnitude;NoData/invalid velocity 在 vertex shader 直接不绘制。Legacy `ParticleValidationLayer` 的旧验证显示没有被改为正式视觉路径。
### D. Android runtime 控制
Android Test App 通过既有 `setStyleLayerFloatProperty()` boolean JNI bridge 调用 Core 强类型 setter,不建立 Java RasterParticleLayer peer。请求失败时 Kotlin 不写入本地 visual state;成功后才更新状态栏。新增控制:VISUAL DEFAULT、SIZE ±、LENGTH ±、OPACITY ±、TRAIL ±、FADE ±、COLOR,以及直接 VORTEX 与 256K capacity 入口。
### E. 观测和回归
资源日志现在明确区分 `particleDrawCount`(粒子数)与 `streakVertexCount`(两端点数)。visual property 变化输出一次结构化摘要,含 Field upload/reset counters 与 resource identity;没有每粒子或每帧日志,也没有虚构 GPU timer。
## 保持不变
- 40-02 T0/T1 GPU interpolation 和 MIX property-only。
- 40-03 `N State texels -> N actual particle draw` 与已验证 capacity `>=1,048,576`。
- 40-04 Runtime Field A/B→B/C→C/D、OLD_T1→NEW_T0 reuse、Duplicate NOOP、候选耗尽 fail-closed。
- State/Trail ping-pong、STATIC frozen composite、mask reuse、camera/world wrap/antimeridian 和 rotation lifecycle。
视觉参数改变预期 `FIELD_UPLOAD delta=0`、`SIMULATION_RESET delta=0`、`STYLE_LOAD delta=0`;本实现没有 visual GPU resource rebuild。
## 已执行验证
- shader generator 连续两次运行:PASS,OpenGL generated header 已同步。
- Linux OpenGL 精确对象编译:
- `CMakeFiles/mbgl-core.dir/src/mbgl/style/layers/raster_particle_layer.cpp.o`
- `CMakeFiles/mbgl-core.dir/src/mbgl/renderer/layers/render_particle_validation_layer.cpp.o`
- `test/CMakeFiles/mbgl-test.dir/style/style_layer.test.cpp.o`
- 结果:PASS。
- C++ `RasterParticleVisualProperties` 最小测试已加入并成功编译。
- `clang-format`、`git diff --check`:PASS。
未执行:Android Gradle、ADB、安装、真机、Jest。
## 用户单次 Android 验收命令
```bash
cd /mnt/sda1/MapLibreParticleLab/platform/android
./gradlew :MapLibreAndroidTestApp:assembleOpenglDebug \
-Pandroid.injected.build.abi=x86_64 \
--console=plain
```
APK 路径:`./MapLibreAndroidTestApp/build/intermediates/apk/opengl/debug/MapLibreAndroidTestApp-opengl-debug.apk`
用户随后在一轮安装中完成 T1--T8,重点检查 EAST/NORTH/vortex streak direction、MIX 连续性、visual property-only counters、STATIC/camera/rotation、1M 和 Runtime Field 回归。
T1--T8 目前均为 `PENDING USER ANDROID VALIDATION`,因此 Camera/Projection、Mask、Lifecycle/Rotation 和实际 1K/16K/64K/256K/1M streak capacity 只能标记为“验收入口已完成,尚未由 Android 实机验证”。
## 范围外
未实现 WeatherColorLayer、真实天气 HTTP/GRIB/provider、React Native、Vulkan、iOS/Metal、生产 mask、粒子箭头/旋转或 APK ZIP 技术债。

View File

@@ -12,30 +12,54 @@ struct ShaderSource<BuiltIn::ParticleGpuValidationShader, gfx::Backend::Type::Op
out vec2 v_uv;
out vec2 v_world;
out vec2 v_particle_velocity;
out float v_particle_valid;
layout (std140) uniform ParticleGpuValidationUBO {
highp vec4 u_pass;
highp vec4 u_marker;
highp vec4 u_mask;
highp vec4 u_visual;
highp mat4 u_matrix;
};
uniform sampler2D u_state_texture;
uniform sampler2D u_field_texture;
uniform sampler2D u_next_field_texture;
void main() {
v_uv = a_pos;
v_world = a_pos;
v_particle_velocity = vec2(0.0);
v_particle_valid = 1.0;
if (u_pass.x < 1.5) {
gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0);
} else if (u_pass.x > 3.5) {
// 40-03: a_pos is a static State texel index generated only when the
// particle capacity changes. The position itself remains GPU-owned.
vec2 stateUV = (a_pos + vec2(0.5)) / u_mask.xy;
// 40-05: a_pos.x packs State texel X plus a static line endpoint.
// State position, interpolated Field velocity, speed color and streak
// orientation are all evaluated on GPU; the CPU never supplies live
// particle position, direction, or color data.
float endpoint = mod(a_pos.x, 2.0) * 2.0 - 1.0;
vec2 stateIndex = vec2(floor(a_pos.x * 0.5), a_pos.y);
vec2 stateUV = (stateIndex + vec2(0.5)) / u_mask.xy;
vec2 position = texture(u_state_texture, stateUV).rg;
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);
bool valid = field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity));
vec2 mercatorVelocity = vec2(velocity.x, -velocity.y);
float speed = length(mercatorVelocity);
v_uv = position;
v_world = position;
gl_PointSize = 3.0;
gl_Position = vec4(position * 2.0 - 1.0, 0.0, 1.0);
v_particle_velocity = mercatorVelocity;
v_particle_valid = valid && speed > 0.00001 ? 1.0 : 0.0;
if (v_particle_valid < 0.5) {
gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
} else {
vec2 direction = mercatorVelocity / speed;
vec2 streakPosition = position + direction * endpoint * u_visual.y * 0.5;
gl_Position = vec4(streakPosition * 2.0 - 1.0, 0.0, 1.0);
}
} else {
// VALIDATION ONLY: a_pos is canonical z0 world geometry; u_matrix is
// PaintParameters::matrixForTile() for the selected MapLibre world copy.
@@ -47,6 +71,7 @@ void main() {
highp vec4 u_pass;
highp vec4 u_marker;
highp vec4 u_mask;
highp vec4 u_visual;
highp mat4 u_matrix;
};
@@ -57,6 +82,16 @@ uniform sampler2D u_next_field_texture;
uniform sampler2D u_state_texture;
in vec2 v_uv;
in vec2 v_world;
in vec2 v_particle_velocity;
in float v_particle_valid;
vec3 particleSpeedColor(float speed) {
float t = clamp(speed * 0.75, 0.0, 1.0);
vec3 slow = vec3(0.10, 0.45, 1.00);
vec3 medium = vec3(0.10, 0.95, 0.72);
vec3 fast = vec3(1.00, 0.48, 0.12);
return mix(mix(slow, medium, smoothstep(0.0, 0.55, t)), fast, smoothstep(0.45, 1.0, t));
}
void main() {
vec4 previous = texture(u_previous, v_uv);
@@ -88,7 +123,7 @@ void main() {
// Formal Trail first fades the previous target. Its full-particle point
// draw follows in the same target and consumes every State texel.
if (u_mask.z > 0.5) {
fragColor = vec4(previous.rgb * u_marker.w, 1.0);
fragColor = vec4(previous.rgb * u_visual.w, 1.0);
return;
}
@@ -102,10 +137,12 @@ void main() {
vec3 color = previous.rgb * u_marker.w + marker * vec3(0.08, 0.55, 1.0);
fragColor = vec4(min(color, vec3(1.0)), 1.0);
} else if (mode > 3.5) {
// The vertex shader fetched this particle's State texel directly from
// the current State write texture. Every point contributes one formal
// particle draw; no CPU position data is involved per frame.
fragColor = vec4(0.08, 0.55, 1.0, 1.0);
if (v_particle_valid < 0.5) discard;
float speed = length(v_particle_velocity);
vec3 color = u_marker.y > 0.5 ? particleSpeedColor(speed) : vec3(0.08, 0.55, 1.0);
// Trail targets are RGB accumulation buffers. Opacity changes source
// energy only; it never alters State, Field textures, or time windows.
fragColor = vec4(color * u_visual.x, 1.0);
} else {
// The formal RasterParticle layer displays the Trail texture written by
// the current State -> Trail pass. Legacy ParticleValidation keeps its
@@ -114,8 +151,9 @@ void main() {
float ocean = texture(u_mask_texture, v_world).a;
if (u_pass.z > 0.5 && ocean < 0.5) discard;
if (u_mask.z > 0.5) {
float alpha = clamp(intensity * 1.25, 0.0, 0.96);
fragColor = vec4(previous.rgb * alpha, alpha);
vec3 color = previous.rgb * u_visual.z;
float alpha = clamp(max(color.r, max(color.g, color.b)), 0.0, 0.96);
fragColor = vec4(color, alpha);
return;
}

View File

@@ -10,9 +10,12 @@ struct alignas(16) ParticleGpuValidationUBO {
std::array<float, 4> pass;
std::array<float, 4> marker;
std::array<float, 4> mask;
// Formal RasterParticle visual controls: particle opacity, canonical
// streak length, trail opacity, and Trail A/B fade multiplier.
std::array<float, 4> visual;
std::array<float, 16> matrix;
};
static_assert(sizeof(ParticleGpuValidationUBO) == 7 * 16);
static_assert(sizeof(ParticleGpuValidationUBO) == 8 * 16);
} // namespace shaders
} // namespace mbgl

View File

@@ -18,6 +18,18 @@ public:
void setTimeMix(float);
uint32_t getParticleCount() const;
void setParticleCount(uint32_t);
float getLineWidth() const;
void setLineWidth(float);
float getParticleLength() const;
void setParticleLength(float);
float getParticleOpacity() const;
void setParticleOpacity(float);
float getTrailOpacity() const;
void setTrailOpacity(float);
float getTrailFade() const;
void setTrailFade(float);
bool isSpeedColorEnabled() const;
void setSpeedColorEnabled(bool);
class Impl;
const Impl& impl() const;
Mutable<Impl> mutableImpl() const;

View File

@@ -1117,7 +1117,10 @@ jni::jboolean NativeMapView::setStyleLayerFloatProperty(JNIEnv& env,
auto* layer = map->getStyle().getLayer(jni::Make<std::string>(env, layerId));
if (!layer) return false;
const auto property = jni::Make<std::string>(env, propertyName);
if (property != "raster-particle-time-mix" && property != "raster-particle-particle-count") {
if (property != "raster-particle-time-mix" && property != "raster-particle-particle-count" &&
property != "raster-particle-line-width" && property != "raster-particle-particle-length" &&
property != "raster-particle-particle-opacity" && property != "raster-particle-trail-opacity" &&
property != "raster-particle-trail-fade" && property != "raster-particle-speed-color-enabled") {
mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=UNSUPPORTED_PROPERTY");
return false;
}
@@ -1126,10 +1129,14 @@ jni::jboolean NativeMapView::setStyleLayerFloatProperty(JNIEnv& env,
mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=UNSUPPORTED_LAYER");
return false;
}
if (!std::isfinite(value)) {
mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=INVALID_FLOAT");
return false;
}
if (property == "raster-particle-time-mix") {
particleLayer->setTimeMix(value);
} else {
if (!std::isfinite(value) || value < 0.0f || std::floor(value) != value) {
} else if (property == "raster-particle-particle-count") {
if (value < 0.0f || std::floor(value) != value) {
mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=INVALID_PARTICLE_COUNT");
return false;
}
@@ -1139,6 +1146,22 @@ jni::jboolean NativeMapView::setStyleLayerFloatProperty(JNIEnv& env,
mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=INVALID_PARTICLE_COUNT");
return false;
}
} else if (property == "raster-particle-line-width") {
particleLayer->setLineWidth(value);
} else if (property == "raster-particle-particle-length") {
particleLayer->setParticleLength(value);
} else if (property == "raster-particle-particle-opacity") {
particleLayer->setParticleOpacity(value);
} else if (property == "raster-particle-trail-opacity") {
particleLayer->setTrailOpacity(value);
} else if (property == "raster-particle-trail-fade") {
particleLayer->setTrailFade(value);
} else {
if (value != 0.0f && value != 1.0f) {
mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=INVALID_COLOR_MODE");
return false;
}
particleLayer->setSpeedColorEnabled(value > 0.5f);
}
mbgl::Log::Info(mbgl::Event::JNI,
"ParticleGPU: event=STYLE_PROPERTY_SET layerId=" + jni::Make<std::string>(env, layerId) +

View File

@@ -24,6 +24,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var fixtureMode = FIXTURE_UNIFORM_EAST
private var timeMix = 0.0f
private var particleCount = PARTICLE_COUNT_256
private var lineWidth = DEFAULT_LINE_WIDTH
private var particleLength = DEFAULT_PARTICLE_LENGTH
private var particleOpacity = DEFAULT_PARTICLE_OPACITY
private var trailOpacity = DEFAULT_TRAIL_OPACITY
private var trailFade = DEFAULT_TRAIL_FADE
private var speedColorEnabled = true
private var timeWindowMode = false
private var styleLoadEpoch = 0L
private var styleReady = false
@@ -35,6 +41,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
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
lineWidth = savedInstanceState?.getFloat(STATE_LINE_WIDTH, DEFAULT_LINE_WIDTH) ?: DEFAULT_LINE_WIDTH
particleLength = savedInstanceState?.getFloat(STATE_PARTICLE_LENGTH, DEFAULT_PARTICLE_LENGTH) ?: DEFAULT_PARTICLE_LENGTH
particleOpacity = savedInstanceState?.getFloat(STATE_PARTICLE_OPACITY, DEFAULT_PARTICLE_OPACITY) ?: DEFAULT_PARTICLE_OPACITY
trailOpacity = savedInstanceState?.getFloat(STATE_TRAIL_OPACITY, DEFAULT_TRAIL_OPACITY) ?: DEFAULT_TRAIL_OPACITY
trailFade = savedInstanceState?.getFloat(STATE_TRAIL_FADE, DEFAULT_TRAIL_FADE) ?: DEFAULT_TRAIL_FADE
speedColorEnabled = savedInstanceState?.getBoolean(STATE_SPEED_COLOR, true) ?: true
timeWindowMode = savedInstanceState?.getBoolean(STATE_TIME_WINDOW, false) ?: false
super.onCreate(savedInstanceState)
Log.i(
@@ -89,6 +101,11 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
fixtureMode = FIXTURE_UNIFORM_NORTH
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_NORTH")
}
findViewById<Button>(R.id.validationVortex).setOnClickListener {
timeWindowMode = false
fixtureMode = FIXTURE_VORTEX
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "VORTEX")
}
findViewById<Button>(R.id.validationTimeWindow).setOnClickListener {
timeWindowMode = true
fixtureMode = FIXTURE_UNIFORM_EAST
@@ -103,6 +120,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
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.validationParticle262144).setOnClickListener { setParticleCount(PARTICLE_COUNT_262144) }
findViewById<Button>(R.id.validationParticle1048576).setOnClickListener { setParticleCount(PARTICLE_COUNT_1048576) }
findViewById<Button>(R.id.validationAdvanceWindow).setOnClickListener {
if (this::maplibreMap.isInitialized) advanceRuntimeFieldWindow()
@@ -110,6 +128,18 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
findViewById<Button>(R.id.validationDuplicateWindow).setOnClickListener {
if (this::maplibreMap.isInitialized) duplicateRuntimeFieldWindow()
}
findViewById<Button>(R.id.validationVisualDefault).setOnClickListener { resetVisualDefaults() }
findViewById<Button>(R.id.validationVisualSizeDown).setOnClickListener { setVisualFloat("raster-particle-line-width", lineWidth - 0.5f) { lineWidth = it.coerceIn(1.0f, 8.0f) } }
findViewById<Button>(R.id.validationVisualSizeUp).setOnClickListener { setVisualFloat("raster-particle-line-width", lineWidth + 0.5f) { lineWidth = it.coerceIn(1.0f, 8.0f) } }
findViewById<Button>(R.id.validationVisualLengthDown).setOnClickListener { setVisualFloat("raster-particle-particle-length", particleLength - 0.004f) { particleLength = it.coerceIn(0.002f, 0.080f) } }
findViewById<Button>(R.id.validationVisualLengthUp).setOnClickListener { setVisualFloat("raster-particle-particle-length", particleLength + 0.004f) { particleLength = it.coerceIn(0.002f, 0.080f) } }
findViewById<Button>(R.id.validationVisualOpacityDown).setOnClickListener { setVisualFloat("raster-particle-particle-opacity", particleOpacity - 0.1f) { particleOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualOpacityUp).setOnClickListener { setVisualFloat("raster-particle-particle-opacity", particleOpacity + 0.1f) { particleOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualTrailDown).setOnClickListener { setVisualFloat("raster-particle-trail-opacity", trailOpacity - 0.1f) { trailOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualTrailUp).setOnClickListener { setVisualFloat("raster-particle-trail-opacity", trailOpacity + 0.1f) { trailOpacity = it.coerceIn(0.0f, 1.0f) } }
findViewById<Button>(R.id.validationVisualFadeDown).setOnClickListener { setVisualFloat("raster-particle-trail-fade", trailFade - 0.05f) { trailFade = it.coerceIn(0.0f, 0.995f) } }
findViewById<Button>(R.id.validationVisualFadeUp).setOnClickListener { setVisualFloat("raster-particle-trail-fade", trailFade + 0.05f) { trailFade = it.coerceIn(0.0f, 0.995f) } }
findViewById<Button>(R.id.validationVisualColor).setOnClickListener { setSpeedColor(!speedColorEnabled) }
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")
@@ -196,6 +226,45 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
Log.i(TAG, "ParticleGPU: event=PARTICLE_COUNT_APPLIED requestedParticleCount=$requestedParticleCount effectiveParticleCount=$particleCount styleEpoch=$styleLoadEpoch")
}
private fun setVisualFloat(property: String, requested: Float, apply: (Float) -> Unit) {
if (!styleReady || !enabled) {
Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_REJECTED reason=${if (!styleReady) "STYLE_LOADING" else "DISABLED"} property=$property styleEpoch=$styleLoadEpoch")
return
}
Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_REQUEST property=$property value=$requested styleEpoch=$styleLoadEpoch")
if (!maplibreMap.setStyleLayerFloatProperty(PARTICLE_LAYER_ID, property, requested)) {
Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_REJECTED reason=CORE_PROPERTY_REJECTED property=$property styleEpoch=$styleLoadEpoch")
return
}
apply(requested)
status.text = statusText()
Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_ONLY property=$property value=$requested styleEpoch=$styleLoadEpoch")
}
private fun setSpeedColor(requested: Boolean) {
if (!styleReady || !enabled) {
Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_REJECTED reason=${if (!styleReady) "STYLE_LOADING" else "DISABLED"} property=raster-particle-speed-color-enabled styleEpoch=$styleLoadEpoch")
return
}
val value = if (requested) 1.0f else 0.0f
if (!maplibreMap.setStyleLayerFloatProperty(PARTICLE_LAYER_ID, "raster-particle-speed-color-enabled", value)) {
Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_REJECTED reason=CORE_PROPERTY_REJECTED property=raster-particle-speed-color-enabled styleEpoch=$styleLoadEpoch")
return
}
speedColorEnabled = requested
status.text = statusText()
Log.i(TAG, "ParticleGPU: event=VISUAL_PROPERTY_ONLY property=raster-particle-speed-color-enabled value=$value styleEpoch=$styleLoadEpoch")
}
private fun resetVisualDefaults() {
setVisualFloat("raster-particle-line-width", DEFAULT_LINE_WIDTH) { lineWidth = DEFAULT_LINE_WIDTH }
setVisualFloat("raster-particle-particle-length", DEFAULT_PARTICLE_LENGTH) { particleLength = DEFAULT_PARTICLE_LENGTH }
setVisualFloat("raster-particle-particle-opacity", DEFAULT_PARTICLE_OPACITY) { particleOpacity = DEFAULT_PARTICLE_OPACITY }
setVisualFloat("raster-particle-trail-opacity", DEFAULT_TRAIL_OPACITY) { trailOpacity = DEFAULT_TRAIL_OPACITY }
setVisualFloat("raster-particle-trail-fade", DEFAULT_TRAIL_FADE) { trailFade = DEFAULT_TRAIL_FADE }
setSpeedColor(true)
}
private fun advanceRuntimeFieldWindow() {
val rejectReason = when {
!styleReady -> "STYLE_LOADING"
@@ -246,10 +315,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
"RUNTIME WINDOW"
} else if (fixtureMode == FIXTURE_UNIFORM_NORTH) {
"NORTH"
} else if (fixtureMode == FIXTURE_VORTEX) {
"VORTEX"
} else {
"EAST"
}
return "$mask · $runtime · $fields · MIX ${(timeMix * 100).toInt()}% · $particleCount particles"
return "$mask · $runtime · $fields · MIX ${(timeMix * 100).toInt()}% · $particleCount · W ${"%.1f".format(lineWidth)} L ${"%.3f".format(particleLength)} O ${(particleOpacity * 100).toInt()} T ${(trailOpacity * 100).toInt()} F ${(trailFade * 100).toInt()} C ${if (speedColorEnabled) "ON" else "OFF"}"
}
private fun validationStyle(active: Boolean, enabled: Boolean): String {
@@ -262,7 +333,10 @@ 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-particle-count":$particleCount}},"""
"raster-particle-time-mix":$timeMix,"raster-particle-particle-count":$particleCount,
"raster-particle-line-width":$lineWidth,"raster-particle-particle-length":$particleLength,
"raster-particle-particle-opacity":$particleOpacity,"raster-particle-trail-opacity":$trailOpacity,
"raster-particle-trail-fade":$trailFade,"raster-particle-speed-color-enabled":$speedColorEnabled}},"""
} else {
""
}
@@ -317,6 +391,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
outState.putInt(STATE_FIXTURE_MODE, fixtureMode)
outState.putFloat(STATE_TIME_MIX, timeMix)
outState.putInt(STATE_PARTICLE_COUNT, particleCount)
outState.putFloat(STATE_LINE_WIDTH, lineWidth)
outState.putFloat(STATE_PARTICLE_LENGTH, particleLength)
outState.putFloat(STATE_PARTICLE_OPACITY, particleOpacity)
outState.putFloat(STATE_TRAIL_OPACITY, trailOpacity)
outState.putFloat(STATE_TRAIL_FADE, trailFade)
outState.putBoolean(STATE_SPEED_COLOR, speedColorEnabled)
outState.putBoolean(STATE_TIME_WINDOW, timeWindowMode)
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
@@ -324,6 +404,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private fun fixtureId(): String = when (fixtureMode) {
FIXTURE_UNIFORM_NORTH -> "uniform-north"
FIXTURE_VORTEX -> "vortex"
else -> "uniform-east"
}
@@ -335,6 +416,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
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_LINE_WIDTH = "particle_validation.line_width"
private const val STATE_PARTICLE_LENGTH = "particle_validation.particle_length"
private const val STATE_PARTICLE_OPACITY = "particle_validation.particle_opacity"
private const val STATE_TRAIL_OPACITY = "particle_validation.trail_opacity"
private const val STATE_TRAIL_FADE = "particle_validation.trail_fade"
private const val STATE_SPEED_COLOR = "particle_validation.speed_color"
private const val STATE_TIME_WINDOW = "particle_validation.time_window"
private const val PARTICLE_LAYER_ID = "particle-raster-core"
private const val PARTICLE_SOURCE_ID = "particle-fixture"
@@ -343,11 +430,18 @@ 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 FIXTURE_VORTEX = 2
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_262144 = 262144
private const val PARTICLE_COUNT_1048576 = 1048576
private const val DEFAULT_LINE_WIDTH = 2.0f
private const val DEFAULT_PARTICLE_LENGTH = 0.018f
private const val DEFAULT_PARTICLE_OPACITY = 1.0f
private const val DEFAULT_TRAIL_OPACITY = 1.0f
private const val DEFAULT_TRAIL_FADE = 0.92f
}
}

View File

@@ -47,9 +47,43 @@
<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/validationParticle262144" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_particles_262144" />
<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"
android:orientation="horizontal">
<Button android:id="@+id/validationVisualDefault" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_default" />
<Button android:id="@+id/validationVisualSizeDown" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_size_down" />
<Button android:id="@+id/validationVisualSizeUp" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_size_up" />
<Button android:id="@+id/validationVisualLengthDown" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_length_down" />
<Button android:id="@+id/validationVisualLengthUp" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_length_up" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationVisualOpacityDown" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_opacity_down" />
<Button android:id="@+id/validationVisualOpacityUp" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_opacity_up" />
<Button android:id="@+id/validationVisualTrailDown" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_trail_down" />
<Button android:id="@+id/validationVisualTrailUp" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_trail_up" />
<Button android:id="@+id/validationVisualColor" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_color" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:orientation="horizontal">
<Button android:id="@+id/validationVisualFadeDown" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_fade_down" />
<Button android:id="@+id/validationVisualFadeUp" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_visual_fade_up" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
@@ -57,6 +91,7 @@
<Button android:id="@+id/validationUniformEast" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_uniform_east" />
<Button android:id="@+id/validationUniformNorth" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_uniform_north" />
<Button android:id="@+id/validationVortex" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_vortex" />
<Button android:id="@+id/validationTimeWindow" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_time_window" />
<Button android:id="@+id/validationAdvanceWindow" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_advance_window" />
<Button android:id="@+id/validationDuplicateWindow" style="@style/ParticleValidationButton" android:layout_width="0dp" android:layout_weight="1" android:text="@string/particle_validation_duplicate_window" />

View File

@@ -24,6 +24,7 @@
<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_vortex">VORTEX</string>
<string name="particle_validation_time_window">T0 EAST / T1 NORTH</string>
<string name="particle_validation_advance_window">ADVANCE WINDOW</string>
<string name="particle_validation_duplicate_window">DUPLICATE T1</string>
@@ -35,7 +36,20 @@
<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_262144">256K</string>
<string name="particle_validation_particles_1048576">1M</string>
<string name="particle_validation_visual_default">VIS DEFAULT</string>
<string name="particle_validation_visual_size_down">SIZE −</string>
<string name="particle_validation_visual_size_up">SIZE +</string>
<string name="particle_validation_visual_length_down">LENGTH −</string>
<string name="particle_validation_visual_length_up">LENGTH +</string>
<string name="particle_validation_visual_opacity_down">OPACITY −</string>
<string name="particle_validation_visual_opacity_up">OPACITY +</string>
<string name="particle_validation_visual_trail_down">TRAIL −</string>
<string name="particle_validation_visual_trail_up">TRAIL +</string>
<string name="particle_validation_visual_fade_down">FADE −</string>
<string name="particle_validation_visual_fade_up">FADE +</string>
<string name="particle_validation_visual_color">COLOR</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

@@ -2,6 +2,7 @@ layout (std140) uniform ParticleGpuValidationUBO {
highp vec4 u_pass;
highp vec4 u_marker;
highp vec4 u_mask;
highp vec4 u_visual;
highp mat4 u_matrix;
};
@@ -12,6 +13,16 @@ uniform sampler2D u_next_field_texture;
uniform sampler2D u_state_texture;
in vec2 v_uv;
in vec2 v_world;
in vec2 v_particle_velocity;
in float v_particle_valid;
vec3 particleSpeedColor(float speed) {
float t = clamp(speed * 0.75, 0.0, 1.0);
vec3 slow = vec3(0.10, 0.45, 1.00);
vec3 medium = vec3(0.10, 0.95, 0.72);
vec3 fast = vec3(1.00, 0.48, 0.12);
return mix(mix(slow, medium, smoothstep(0.0, 0.55, t)), fast, smoothstep(0.45, 1.0, t));
}
void main() {
vec4 previous = texture(u_previous, v_uv);
@@ -43,7 +54,7 @@ void main() {
// Formal Trail first fades the previous target. Its full-particle point
// draw follows in the same target and consumes every State texel.
if (u_mask.z > 0.5) {
fragColor = vec4(previous.rgb * u_marker.w, 1.0);
fragColor = vec4(previous.rgb * u_visual.w, 1.0);
return;
}
@@ -57,10 +68,12 @@ void main() {
vec3 color = previous.rgb * u_marker.w + marker * vec3(0.08, 0.55, 1.0);
fragColor = vec4(min(color, vec3(1.0)), 1.0);
} else if (mode > 3.5) {
// The vertex shader fetched this particle's State texel directly from
// the current State write texture. Every point contributes one formal
// particle draw; no CPU position data is involved per frame.
fragColor = vec4(0.08, 0.55, 1.0, 1.0);
if (v_particle_valid < 0.5) discard;
float speed = length(v_particle_velocity);
vec3 color = u_marker.y > 0.5 ? particleSpeedColor(speed) : vec3(0.08, 0.55, 1.0);
// Trail targets are RGB accumulation buffers. Opacity changes source
// energy only; it never alters State, Field textures, or time windows.
fragColor = vec4(color * u_visual.x, 1.0);
} else {
// The formal RasterParticle layer displays the Trail texture written by
// the current State -> Trail pass. Legacy ParticleValidation keeps its
@@ -69,8 +82,9 @@ void main() {
float ocean = texture(u_mask_texture, v_world).a;
if (u_pass.z > 0.5 && ocean < 0.5) discard;
if (u_mask.z > 0.5) {
float alpha = clamp(intensity * 1.25, 0.0, 0.96);
fragColor = vec4(previous.rgb * alpha, alpha);
vec3 color = previous.rgb * u_visual.z;
float alpha = clamp(max(color.r, max(color.g, color.b)), 0.0, 0.96);
fragColor = vec4(color, alpha);
return;
}

View File

@@ -2,30 +2,54 @@ layout (location = 0) in vec2 a_pos;
out vec2 v_uv;
out vec2 v_world;
out vec2 v_particle_velocity;
out float v_particle_valid;
layout (std140) uniform ParticleGpuValidationUBO {
highp vec4 u_pass;
highp vec4 u_marker;
highp vec4 u_mask;
highp vec4 u_visual;
highp mat4 u_matrix;
};
uniform sampler2D u_state_texture;
uniform sampler2D u_field_texture;
uniform sampler2D u_next_field_texture;
void main() {
v_uv = a_pos;
v_world = a_pos;
v_particle_velocity = vec2(0.0);
v_particle_valid = 1.0;
if (u_pass.x < 1.5) {
gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0);
} else if (u_pass.x > 3.5) {
// 40-03: a_pos is a static State texel index generated only when the
// particle capacity changes. The position itself remains GPU-owned.
vec2 stateUV = (a_pos + vec2(0.5)) / u_mask.xy;
// 40-05: a_pos.x packs State texel X plus a static line endpoint.
// State position, interpolated Field velocity, speed color and streak
// orientation are all evaluated on GPU; the CPU never supplies live
// particle position, direction, or color data.
float endpoint = mod(a_pos.x, 2.0) * 2.0 - 1.0;
vec2 stateIndex = vec2(floor(a_pos.x * 0.5), a_pos.y);
vec2 stateUV = (stateIndex + vec2(0.5)) / u_mask.xy;
vec2 position = texture(u_state_texture, stateUV).rg;
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);
bool valid = field0.a >= 0.5 && field1.a >= 0.5 && !any(isnan(velocity));
vec2 mercatorVelocity = vec2(velocity.x, -velocity.y);
float speed = length(mercatorVelocity);
v_uv = position;
v_world = position;
gl_PointSize = 3.0;
gl_Position = vec4(position * 2.0 - 1.0, 0.0, 1.0);
v_particle_velocity = mercatorVelocity;
v_particle_valid = valid && speed > 0.00001 ? 1.0 : 0.0;
if (v_particle_valid < 0.5) {
gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
} else {
vec2 direction = mercatorVelocity / speed;
vec2 streakPosition = position + direction * endpoint * u_visual.y * 0.5;
gl_Position = vec4(streakPosition * 2.0 - 1.0, 0.0, 1.0);
}
} else {
// VALIDATION ONLY: a_pos is canonical z0 world geometry; u_matrix is
// PaintParameters::matrixForTile() for the selected MapLibre world copy.

View File

@@ -58,16 +58,33 @@ struct ParticleSettings {
bool usesFormalSource;
float timeMix;
uint32_t particleCount;
float lineWidth;
float particleLength;
float particleOpacity;
float trailOpacity;
float trailFade;
bool speedColorEnabled;
};
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};
return {layer.active,
layer.maskMode,
0,
true,
layer.timeMix,
layer.particleCount,
layer.lineWidth,
layer.particleLength,
layer.particleOpacity,
layer.trailOpacity,
layer.trailFade,
layer.speedColorEnabled};
}
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};
return {layer.active, layer.maskMode, layer.fixtureMode, false, 0.0f, 256, 2.0f, 0.018f, 1.0f, 1.0f, 0.965f, false};
}
enum class ValidationPass : uint8_t {
@@ -190,6 +207,12 @@ public:
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;
bool speedColorEnabled = true;
uint8_t fixtureMode = 255;
uint8_t loggedMaskMode = 255;
Size trailSize{};
@@ -265,22 +288,29 @@ constexpr uint32_t MaxParticleVerticesPerSegment = std::numeric_limits<uint16_t>
void rebuildParticleDrawResources(const std::shared_ptr<ParticleGpuResources>& resources) {
auto vertices = std::make_shared<gfx::VertexVector<HeatmapTextureLayoutVertex>>();
vertices->reserve(resources->particleCount);
// Each State texel is represented by a two-vertex line. Its endpoints are
// static texel indices; State position, interpolated Field velocity and
// orientation remain GPU-owned in the vertex shader.
vertices->reserve(resources->particleCount * 2);
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);
vertices->emplace_back(HeatmapBucket::textureVertex({x, y}));
// a_pos.x packs state X and endpoint (0=start, 1=end). State grids
// are at most 1024 wide, so the encoded value remains a signed short.
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}));
}
auto indices = std::make_shared<gfx::IndexVectorBase>();
indices->reserve(std::min(resources->particleCount, MaxParticleVerticesPerSegment));
for (uint32_t index = 0; index < std::min(resources->particleCount, MaxParticleVerticesPerSegment); ++index) {
indices->reserve(MaxParticleVerticesPerSegment);
for (uint32_t index = 0; index < MaxParticleVerticesPerSegment; ++index) {
indices->extend(1, static_cast<uint16_t>(index));
}
SegmentVector segments;
for (uint32_t offset = 0; offset < resources->particleCount; offset += MaxParticleVerticesPerSegment) {
const auto count = std::min(resources->particleCount - offset, MaxParticleVerticesPerSegment);
const auto vertexCount = resources->particleCount * 2;
for (uint32_t offset = 0; offset < vertexCount; offset += MaxParticleVerticesPerSegment) {
const auto count = std::min(vertexCount - offset, MaxParticleVerticesPerSegment);
segments.emplace_back(offset, 0, count, count);
}
resources->particleVertices = std::move(vertices);
@@ -337,17 +367,20 @@ public:
const float frame = simulationStep;
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.
const bool seedState = pass == ValidationPass::State && !resources->stateSeeded;
const shaders::ParticleGpuValidationUBO ubo = {
.pass = {static_cast<float>(pass), frame, maskMode == 0 ? 0.0f : 1.0f, static_cast<float>(maskMode)},
.marker = {markerX, markerY, 0.035f, 0.965f},
.marker = {markerX, resources->speedColorEnabled ? 1.0f : 0.0f, 0.035f, 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},
.matrix = util::cast<float>(parameters.matrixForTile({worldWrap, {0, 0, 0}})),
};
drawable.mutableUniformBuffers().createOrUpdate(shaders::idParticleGpuValidationUBO, &ubo, parameters.context);
@@ -590,7 +623,7 @@ bool buildParticlePoints(gfx::Context& context,
builder->setCullFaceMode(gfx::CullFaceMode::disabled());
builder->setVertexAttributes(std::move(attributes));
builder->setRawVertices({}, resources->particleVertices->elements(), gfx::AttributeDataType::Short2);
builder->setSegments(gfx::Points(3.0f),
builder->setSegments(gfx::Lines(resources->lineWidth),
resources->particleIndices,
resources->particleSegments.data(),
resources->particleSegments.size());
@@ -627,7 +660,13 @@ RenderRasterParticleLayer::RenderRasterParticleLayer(Immutable<style::Layer::Imp
settings.maskMode,
settings.fixtureMode,
settings.timeMix,
settings.particleCount);
settings.particleCount,
settings.lineWidth,
settings.particleLength,
settings.particleOpacity,
settings.trailOpacity,
settings.trailFade,
settings.speedColorEnabled);
}()) {
renderLayerInstanceId = ++nextRenderLayerInstanceId;
formalSource = baseImpl->getTypeInfo() == style::RasterParticleLayer::Impl::staticTypeInfo();
@@ -653,8 +692,18 @@ void RenderRasterParticleLayer::evaluate(const PropertyEvaluationParameters&) {
"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);
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.speedColorEnabled);
passes = RenderPass::Translucent;
properties->renderPasses = mbgl::underlying_type(passes);
evaluatedProperties = std::move(properties);
@@ -664,7 +713,11 @@ 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) +
" particleCount=" + std::to_string(settings.particleCount) + " stage=40-03");
" 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) +
" speedColor=" + (settings.speedColorEnabled ? "true" : "false") + " stage=40-05");
}
bool RenderRasterParticleLayer::needsRepaint() const {
@@ -687,7 +740,7 @@ void RenderRasterParticleLayer::layerRemoved(UniqueChangeRequestVec& changes) {
std::to_string(gpuResources->particleCount) +
" resourceGeneration=" + std::to_string(gpuResources->generation));
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_DRAW_RESOURCES_RELEASE reason=LAYER_REMOVED trailDrawCount=" +
"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);
@@ -704,7 +757,7 @@ void RenderRasterParticleLayer::markContextDestroyed() {
std::to_string(gpuResources->particleCount) +
" resourceGeneration=" + std::to_string(gpuResources->generation));
Log::Info(Event::Render,
"ParticleGPU: event=PARTICLE_DRAW_RESOURCES_RELEASE reason=CONTEXT_DESTROYED trailDrawCount=" +
"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(
@@ -852,8 +905,8 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
"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) +
" trailDrawCount=" + std::to_string(resources->particleVertices->elements()) +
" particleIndexCount=" + std::to_string(resources->particleVertices->elements()) +
" 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,
@@ -913,7 +966,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
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 trailDrawCount=" +
"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);
@@ -932,8 +985,8 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
"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) +
" trailDrawCount=" + std::to_string(resources->particleVertices->elements()) +
" particleIndexCount=" + std::to_string(resources->particleVertices->elements()) +
" particleDrawCount=" + std::to_string(resources->particleCount) +
" streakVertexCount=" + std::to_string(resources->particleVertices->elements()) +
" fullParticleDraw=true resourceGeneration=" + std::to_string(resources->generation));
}
@@ -1054,6 +1107,31 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
" 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;
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;
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") +

View File

@@ -13,19 +13,37 @@ public:
uint8_t maskMode_,
uint8_t fixtureMode_,
float timeMix_,
uint32_t particleCount_)
uint32_t particleCount_,
float lineWidth_,
float particleLength_,
float particleOpacity_,
float trailOpacity_,
float trailFade_,
bool speedColorEnabled_)
: LayerProperties(std::move(impl)),
active(active_),
maskMode(maskMode_),
fixtureMode(fixtureMode_),
timeMix(timeMix_),
particleCount(particleCount_) {}
particleCount(particleCount_),
lineWidth(lineWidth_),
particleLength(particleLength_),
particleOpacity(particleOpacity_),
trailOpacity(trailOpacity_),
trailFade(trailFade_),
speedColorEnabled(speedColorEnabled_) {}
expression::Dependency getDependencies() const noexcept override { return expression::Dependency::None; }
bool active;
uint8_t maskMode;
uint8_t fixtureMode;
float timeMix;
uint32_t particleCount;
float lineWidth;
float particleLength;
float particleOpacity;
float trailOpacity;
float trailFade;
bool speedColorEnabled;
};
} // namespace mbgl::style

View File

@@ -13,6 +13,12 @@ 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 const char* LineWidthProperty = "raster-particle-line-width";
constexpr const char* ParticleLengthProperty = "raster-particle-particle-length";
constexpr const char* ParticleOpacityProperty = "raster-particle-particle-opacity";
constexpr const char* TrailOpacityProperty = "raster-particle-trail-opacity";
constexpr const char* TrailFadeProperty = "raster-particle-trail-fade";
constexpr const char* SpeedColorEnabledProperty = "raster-particle-speed-color-enabled";
constexpr uint32_t MinimumParticleCount = 256;
constexpr uint32_t MaximumParticleCount = 1024 * 1024;
@@ -91,6 +97,77 @@ void RasterParticleLayer::setParticleCount(const uint32_t particleCount) {
observer->onLayerChanged(*this);
}
}
float RasterParticleLayer::getLineWidth() const {
return impl().lineWidth;
}
void RasterParticleLayer::setLineWidth(float value) {
const auto clamped = std::clamp(value, 1.0f, 8.0f);
if (clamped != getLineWidth()) {
auto next = mutableImpl();
next->lineWidth = clamped;
baseImpl = std::move(next);
observer->onLayerChanged(*this);
}
}
float RasterParticleLayer::getParticleLength() const {
return impl().particleLength;
}
void RasterParticleLayer::setParticleLength(float value) {
const auto clamped = std::clamp(value, 0.002f, 0.080f);
if (clamped != getParticleLength()) {
auto next = mutableImpl();
next->particleLength = clamped;
baseImpl = std::move(next);
observer->onLayerChanged(*this);
}
}
float RasterParticleLayer::getParticleOpacity() const {
return impl().particleOpacity;
}
void RasterParticleLayer::setParticleOpacity(float value) {
const auto clamped = std::clamp(value, 0.0f, 1.0f);
if (clamped != getParticleOpacity()) {
auto next = mutableImpl();
next->particleOpacity = clamped;
baseImpl = std::move(next);
observer->onLayerChanged(*this);
}
}
float RasterParticleLayer::getTrailOpacity() const {
return impl().trailOpacity;
}
void RasterParticleLayer::setTrailOpacity(float value) {
const auto clamped = std::clamp(value, 0.0f, 1.0f);
if (clamped != getTrailOpacity()) {
auto next = mutableImpl();
next->trailOpacity = clamped;
baseImpl = std::move(next);
observer->onLayerChanged(*this);
}
}
float RasterParticleLayer::getTrailFade() const {
return impl().trailFade;
}
void RasterParticleLayer::setTrailFade(float value) {
const auto clamped = std::clamp(value, 0.0f, 0.995f);
if (clamped != getTrailFade()) {
auto next = mutableImpl();
next->trailFade = clamped;
baseImpl = std::move(next);
observer->onLayerChanged(*this);
}
}
bool RasterParticleLayer::isSpeedColorEnabled() const {
return impl().speedColorEnabled;
}
void RasterParticleLayer::setSpeedColorEnabled(bool value) {
if (value != isSpeedColorEnabled()) {
auto next = mutableImpl();
next->speedColorEnabled = value;
baseImpl = std::move(next);
observer->onLayerChanged(*this);
}
}
std::optional<conversion::Error> RasterParticleLayer::setPropertyInternal(const std::string& name,
const conversion::Convertible& value) {
conversion::Error error;
@@ -124,6 +201,28 @@ std::optional<conversion::Error> RasterParticleLayer::setPropertyInternal(const
setParticleCount(static_cast<uint32_t>(*converted));
return std::nullopt;
}
const auto finiteFloat = [&](const char* message, auto setter) -> std::optional<conversion::Error> {
auto converted = conversion::convert<float>(value, error);
if (!converted || !std::isfinite(*converted)) return conversion::Error{message};
(this->*setter)(*converted);
return std::nullopt;
};
if (name == LineWidthProperty)
return finiteFloat("raster-particle-line-width must be finite", &RasterParticleLayer::setLineWidth);
if (name == ParticleLengthProperty)
return finiteFloat("raster-particle-particle-length must be finite", &RasterParticleLayer::setParticleLength);
if (name == ParticleOpacityProperty)
return finiteFloat("raster-particle-particle-opacity must be finite", &RasterParticleLayer::setParticleOpacity);
if (name == TrailOpacityProperty)
return finiteFloat("raster-particle-trail-opacity must be finite", &RasterParticleLayer::setTrailOpacity);
if (name == TrailFadeProperty)
return finiteFloat("raster-particle-trail-fade must be finite", &RasterParticleLayer::setTrailFade);
if (name == SpeedColorEnabledProperty) {
auto converted = conversion::convert<bool>(value, error);
if (!converted) return error;
setSpeedColorEnabled(*converted);
return std::nullopt;
}
return conversion::Error{"layer doesn't support this property"};
}
StyleProperty RasterParticleLayer::getProperty(const std::string& name) const {
@@ -133,6 +232,14 @@ StyleProperty RasterParticleLayer::getProperty(const std::string& name) const {
if (name == TimeMixProperty) return StyleProperty(Value{getTimeMix()}, StyleProperty::Kind::Constant);
if (name == ParticleCountProperty)
return StyleProperty(Value{static_cast<uint64_t>(getParticleCount())}, StyleProperty::Kind::Constant);
if (name == LineWidthProperty) return StyleProperty(Value{getLineWidth()}, StyleProperty::Kind::Constant);
if (name == ParticleLengthProperty) return StyleProperty(Value{getParticleLength()}, StyleProperty::Kind::Constant);
if (name == ParticleOpacityProperty)
return StyleProperty(Value{getParticleOpacity()}, StyleProperty::Kind::Constant);
if (name == TrailOpacityProperty) return StyleProperty(Value{getTrailOpacity()}, StyleProperty::Kind::Constant);
if (name == TrailFadeProperty) return StyleProperty(Value{getTrailFade()}, StyleProperty::Kind::Constant);
if (name == SpeedColorEnabledProperty)
return StyleProperty(Value{isSpeedColorEnabled()}, StyleProperty::Kind::Constant);
return {};
}
Value RasterParticleLayer::serialize() const {
@@ -141,6 +248,12 @@ Value RasterParticleLayer::serialize() const {
serializeProperty(result, getProperty(MaskModeProperty), MaskModeProperty, true);
serializeProperty(result, getProperty(TimeMixProperty), TimeMixProperty, true);
serializeProperty(result, getProperty(ParticleCountProperty), ParticleCountProperty, true);
serializeProperty(result, getProperty(LineWidthProperty), LineWidthProperty, true);
serializeProperty(result, getProperty(ParticleLengthProperty), ParticleLengthProperty, true);
serializeProperty(result, getProperty(ParticleOpacityProperty), ParticleOpacityProperty, true);
serializeProperty(result, getProperty(TrailOpacityProperty), TrailOpacityProperty, true);
serializeProperty(result, getProperty(TrailFadeProperty), TrailFadeProperty, true);
serializeProperty(result, getProperty(SpeedColorEnabledProperty), SpeedColorEnabledProperty, true);
return result;
}
std::unique_ptr<Layer> RasterParticleLayer::cloneRef(const std::string& id) const {

View File

@@ -18,6 +18,12 @@ public:
uint8_t maskMode = 0;
float timeMix = 0.0f;
uint32_t particleCount = 256;
float lineWidth = 2.0f;
float particleLength = 0.018f;
float particleOpacity = 1.0f;
float trailOpacity = 1.0f;
float trailFade = 0.92f;
bool speedColorEnabled = true;
DECLARE_LAYER_TYPE_INFO;
};

View File

@@ -128,6 +128,40 @@ TEST(Layer, RasterParticleCountProperties) {
EXPECT_EQ(65536u, layer.getParticleCount());
}
TEST(Layer, RasterParticleVisualProperties) {
RasterParticleLayer layer{"particle", "raster-array"};
EXPECT_FLOAT_EQ(2.0f, layer.getLineWidth());
EXPECT_FLOAT_EQ(0.018f, layer.getParticleLength());
EXPECT_FLOAT_EQ(1.0f, layer.getParticleOpacity());
EXPECT_FLOAT_EQ(1.0f, layer.getTrailOpacity());
EXPECT_FLOAT_EQ(0.92f, layer.getTrailFade());
EXPECT_TRUE(layer.isSpeedColorEnabled());
layer.setLineWidth(4.0f);
layer.setParticleLength(0.030f);
layer.setParticleOpacity(0.45f);
layer.setTrailOpacity(0.60f);
layer.setTrailFade(0.80f);
layer.setSpeedColorEnabled(false);
EXPECT_FLOAT_EQ(4.0f, layer.getLineWidth());
EXPECT_FLOAT_EQ(0.030f, layer.getParticleLength());
EXPECT_FLOAT_EQ(0.45f, layer.getParticleOpacity());
EXPECT_FLOAT_EQ(0.60f, layer.getTrailOpacity());
EXPECT_FLOAT_EQ(0.80f, layer.getTrailFade());
EXPECT_FALSE(layer.isSpeedColorEnabled());
// Visual properties are bounded, and cannot affect the existing Field or
// simulation resource contracts through invalid values.
layer.setLineWidth(100.0f);
layer.setParticleLength(-1.0f);
layer.setTrailFade(2.0f);
EXPECT_FLOAT_EQ(8.0f, layer.getLineWidth());
EXPECT_FLOAT_EQ(0.002f, layer.getParticleLength());
EXPECT_FLOAT_EQ(0.995f, layer.getTrailFade());
}
TEST(Layer, FillProperties) {
auto layer = std::make_unique<FillLayer>("fill", "source");
ASSERT_STREQ("fill", layer->getTypeInfo()->type);

View File

@@ -306,3 +306,12 @@
- MIX 保持 property-only;Duplicate 重提当前 T1 返回 NOOP,不产生 Field upload/reset/style reload;候选耗尽 fail-closed,当前 vortex/nodata 窗口继续可用。
- `STYLE_LOAD reason=TIME_WINDOW` 仅是用户建立初始窗口的允许初始化;真正 Runtime 操作期间 styleEpoch 稳定。MIX 的 `LAYER_CHANGED` 不是 Layer recreate,最终 renderLayerInstanceId 保持 1。
- 40-04 未改变 U/V、`dy=-v`、GPU interpolation、40-03 full particle draw 或已验证 capacity ≥ 1,048,576;真实网络/GRIB/provider、视觉阶段、WeatherColorLayer、Vulkan、iOS/Metal、生产 mask、APK 技术债仍属范围外,未进入下一阶段。
## 2026-09-03 — 40-05 / STEP-046 Android OpenGL 正式粒子视觉与运行时渲染控制(代码阶段)
- 正式 `RasterParticleLayer` full draw 从固定 blue point 扩展为短 streak:每个 State texel 对应一颗粒子、两个静态 line endpoint,GPU vertex shader 从 current State write 与 T0/T1 插值 Field 求位置、方向和速度;CPU 不持有 live particle position/direction/color,也不做 readback 或第二 simulation。
- 继续冻结 `U=east-positive`、`V=north-positive` 与 Mercator 边界 `dy=-v`;EAST/NORTH/vortex 的视觉方向与 MIX 后最终速度一致。NoData、NaN 和近零速 fail-closed,不画 streak。
- 新增 `raster-particle-line-width`、`particle-length`、`particle-opacity`、`trail-opacity`、`trail-fade`、`speed-color-enabled` 等 Core Render/Visual properties。它们经现有 Android→Core JNI boolean bridge 运行时更新,只改 Render/UBO,不 reload Style/Source、不上传 Field、不 reset State/Trail。
- GPU fragment shader 使用最终插值 speed magnitude 的最小蓝→绿→橙 ramp;COLOR 控制可以回退固定蓝色。该粒子着色与未实现的 WeatherColorLayer 明确分离。
- Android Validation 页增加 VISUAL DEFAULT、SIZE、LENGTH、OPACITY、TRAIL、FADE、COLOR 集中入口、直接 VORTEX fixture 与 256K 容量档,并保存 Activity 状态;资源日志补充 `particleDrawCount=N`、`streakVertexCount=2N` 和低频 visual property snapshot。
- shader generator 二次幂等、Linux OpenGL Core/style test object 精确编译、format/diff 均通过;未运行 Android Gradle、ADB、真机或 Jest。当前状态只能是 `IMPLEMENTED / WAITING FOR ANDROID VALIDATION`;用户下一轮以一次 x86_64 APK 编译完成 T1--T8 综合验收后才可关闭。