fix: close STEP-081 iOS Metal particle rendering
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
tei
2026-09-18 07:58:51 +09:00
parent 8fd517bac2
commit f1c0f92b2b
14 changed files with 503 additions and 44 deletions

View File

@@ -1,6 +1,6 @@
# STEP-081 iOS Metal Renderer 与真机 Smoke 入口设计 v1 # STEP-081 iOS Metal Renderer 与真机 Smoke 入口设计 v1
状态:进行中 / WAITING_DEVICE_SMOKE。RDC 已完成 iPhoneOS arm64 无签名 Test App 编译;尚未签名安装或真机运行。 状态:CLOSED / PASS。已在 Pleiades(iPad mini 5 / iPadOS 26.5.2)完成签名安装、Metal runtime、deterministic fixtures 与 QBIN V4 T0/MID/T1/Clear 真机验收。
## 目标和边界 ## 目标和边界
@@ -15,6 +15,7 @@ Metal 路径消费既有 `RasterArraySource` V4 renderer projection、`WeatherCo
| `1.5–3.5` | Composite / FrozenComposite | 同一 trail/particle 色彩、mask、speed-color 和 legacy diagnostic 分支 | | `1.5–3.5` | Composite / FrozenComposite | 同一 trail/particle 色彩、mask、speed-color 和 legacy diagnostic 分支 |
| `> 3.5` | Trail particle line draw | State read/write displacement line,respawn alpha 抑制跨屏线 | | `> 3.5` | Trail particle line draw | State read/write displacement line,respawn alpha 抑制跨屏线 |
| `> 4.5` | WeatherColor | 同一 scalar/vector magnitude、stops/ramp 和 invalid discard | | `> 4.5` | WeatherColor | 同一 scalar/vector magnitude、stops/ramp 和 invalid discard |
| `> 5.5` | 无 | Metal-only current-endpoint 1 px quad coverage;只补 raster coverage,不改变 State、速度、时间步或 previous→current 物理长度 |
MSL 使用 `ParticleGpuValidationUBO` 的同字段 vec4/float4x4 布局;声明与 C++ 的 `alignas(16)` 相同,并在 MSL 中静态断言 `31 * 16` 字节。此 Step 未改动 C++ UBO、QBIN V4 contract 或 OpenGL source。 MSL 使用 `ParticleGpuValidationUBO` 的同字段 vec4/float4x4 布局;声明与 C++ 的 `alignas(16)` 相同,并在 MSL 中静态断言 `31 * 16` 字节。此 Step 未改动 C++ UBO、QBIN V4 contract 或 OpenGL source。
@@ -36,8 +37,17 @@ MSL 使用 `ParticleGpuValidationUBO` 的同字段 vec4/float4x4 布局;声明
Test App Runtime Styling 菜单新增 `Particle / QBIN V4 Smoke (T0)`。点击后可选 T0、midpoint、T1 或 clear/release。它从 app bundle `ParticleSmokeFixtures/T0.qbin`、`T1.qbin` 读取现有 RN Wind Golden fixtures;`platform/ios/app/CMakeLists.txt` 在构建后从唯一 fixture 来源复制,不创建第二种数据格式。 Test App Runtime Styling 菜单新增 `Particle / QBIN V4 Smoke (T0)`。点击后可选 T0、midpoint、T1 或 clear/release。它从 app bundle `ParticleSmokeFixtures/T0.qbin`、`T1.qbin` 读取现有 RN Wind Golden fixtures;`platform/ios/app/CMakeLists.txt` 在构建后从唯一 fixture 来源复制,不创建第二种数据格式。
private `MLNMapView` seam 提供 `mbglMap`。Smoke 直接创建 `RasterArraySource`,提交 `fixture.wind` T0/T1、Wind profile contract、ColorBinding 与 VectorComponents ParticleBinding,再添加 `WeatherColorLayer` 与 `RasterParticleLayer`。T0=1789239600000、midpoint=1789241400000、T1=1789243200000,全部通过 source-owned requested forecast time 选择窗口/mix。clear 先移除 layers 后移除 source,允许重复创建。 private `MLNMapView` seam 提供 `mbglMap`。Smoke 直接创建 `RasterArraySource`,提交 `fixture.wind` T0/T1、Wind profile contract、ColorBinding 与 VectorComponents ParticleBinding,再添加 `WeatherColorLayer` 与 `RasterParticleLayer`。T0=1789239600000、midpoint=1789241400000、T1=1789243200000,全部通过 source-owned requested forecast time 选择窗口/mix。clear 先移除 layers 后移除 source,允许重复创建。Wind Golden fixture 实际范围为 112.5E..157.5E、0..55.776573N,Test App smoke 会把相机置于约 27.8883N / 135E、zoom 3,保证 FIELD_READY 后画面可观察;这只是 Test App 可视化入口,不改变 Core 数据范围。
## Metal 真机收口结论
真机调试确认两个 Metal 特有问题并形成正式修复:
1. `ShaderProgram::getRenderPipelineState()` 的缓存复用 key 原先只包含调用方 reuse hash,没有包含 color/depth/stencil attachment pixel format。粒子 State 使用 RGBA32Float、Trail 使用 RGBA8Unorm、主 renderable 常见 BGRA8Unorm,同一 shader 因此可能复用到不兼容的 pipeline。修复后 cache key 显式合并 color/depth/stencil format;物理 iPad 上 Trail RGBA8 写入从全零恢复为有效像素。
2. deterministic Vortex 实测真实 previous→current 线段平均约 0.065 px、最大约 0.24 px,全部小于 0.5 px。Metal `MTLPrimitiveTypeLine` 对该亚像素长度没有稳定可见覆盖。正式 previous→current line 保持不变,Metal 额外在真实 currentPosition 绘制 1 px quad coverage;不拉长轨迹、不改变 State、advection、时间步或物理速度。
同时确认 Metal vertex-stage texture/sampler binding、State A/B RGBA32Float、Trail A/B RGBA8、矩阵投影与 Composite 均可工作。
## 后续边界 ## 后续边界
下一轮如需 iOS RN,必须另行设计桥接;不得把本 App 的 private Core seam 提升为 SDK 或 NavSea API。iPhoneOS 编译、Metal runtime compilation、资源生命周期和视觉验收均由 RDC/GPT 在真实设备完成。 STEP-081 已关闭。下一轮如需 iOS RN,必须另行设计桥接;不得把本 App 的 private Core seam 提升为 SDK 或 NavSea API。当前 Metal vertex+fragment 双阶段纹理绑定可在后续独立技术债中收窄为 stage-specific binding,但不得在 STEP-081 收尾中继续重构。

View File

@@ -1,6 +1,6 @@
# STEP-081 iOS Metal Renderer 与真机 Smoke 入口 v1 # STEP-081 iOS Metal Renderer 与真机 Smoke 入口 v1
状态:进行中 / WAITING_DEVICE_SMOKE 状态:CLOSED / PASS
## 目标 ## 目标
@@ -14,19 +14,21 @@
- [x] CMake 将既有 Wind Golden T0/T1 复制到 app bundle。 - [x] CMake 将既有 Wind Golden T0/T1 复制到 app bundle。
- [x] 仅做静态检查;未执行 Jest。 - [x] 仅做静态检查;未执行 Jest。
- [x] iPhoneOS arm64 Test App 无签名编译通过,`BUILD SUCCEEDED`。 - [x] iPhoneOS arm64 Test App 无签名编译通过,`BUILD SUCCEEDED`。
- [ ] Metal runtime compilation、签名安装、真实 iPhone/iPad smoke:待真机可用后执行。 - [x] Pleiades(iPad mini 5 / iPadOS 26.5.2)完成签名安装、真实 Metal runtime 与视觉验收。
- [x] Deterministic Uniform East 向右、Uniform North 向上、Vortex 可见并具有连续轨迹。
- [x] QBIN T0 / MID / T1 / Clear / Clear 后重新 T0 均通过。
## 允许范围与限制 ## 允许范围与限制
修改仅涉及 Metal thin-hook、iOS Test App、CMake Metal/app 资源接线以及本 Step 文档。未修改 QBIN V4/Decoder/Profile/Binding、Android/RN API、版本坐标或项目总则。现有 gfx 代码使用 `gfx::RenderTarget`、`Texture2D`、`Lines` 与 shader registry;静态审查未见 layer 内的直接 OpenGL 调用。 修改仅涉及 Metal thin-hook、iOS Test App、CMake Metal/app 资源接线以及本 Step 文档。未修改 QBIN V4/Decoder/Profile/Binding、Android/RN API、版本坐标或项目总则。现有 gfx 代码使用 `gfx::RenderTarget`、`Texture2D`、`Lines` 与 shader registry;静态审查未见 layer 内的直接 OpenGL 调用。
## 后续真机操作 ## 真机验收入口
在 iOS Test App 中打开 Settings → Runtime Styling → `Particle / QBIN V4 Smoke (T0)`,选择 T0、midpoint、T1;使用 `Clear / release` 后再次选择任一时间验证重建。预期日志包含 `ParticleQbinV4Smoke: event=FIELD_READY`、`WeatherColorLayer: event=WEATHER_COLOR_FIELD_READY`、粒子 `ParticleStateTarget: event=CREATE_SUCCESS` / `WeatherRuntime: event=PARTICLE_RESOURCE_CREATE`(若 backend 进入该 pass)。 在 iOS Test App 中,`Particle Core Validation (Fixtures)` 用 Uniform East / Uniform North / Vortex 验证正式 Host ingress → RasterParticleLayer → Metal 路径;`Particle / QBIN V4 Smoke (T0)` 用 T0 / midpoint / T1 / Clear 验证真实 Wind Golden QBIN。QBIN T0/MID/T1 对应 1789239600000 / 1789241400000 / 1789243200000。
## 构建与阻塞 ## 构建与阻塞
`Codex` 未执行任何编译;GPT 审查通过后由 RDC 完成 CMake/Xcode iPhoneOS arm64 build。最终无签名 `app` target `BUILD SUCCEEDED`,产物约 61 MB,主程序为 arm64 Mach-O,Wind T0/T1 QBIN 均已进入 `ParticleSmokeFixtures/` 且 SHA-256 与源 fixture 一致。Metal runtime shader compile、签名安装与真机 PASS 尚未执行。 `Codex` 未执行任何编译;GPT 审查后由 RDC 完成 CMake/Xcode iPhoneOS arm64 build。最终签名 device build `BUILD SUCCEEDED`,Bundle ID `jp.eyaku.maplibreparticlelab.step081`,Wind T0/T1 QBIN 均进入 `ParticleSmokeFixtures/`。随后在 Pleiades 真机完成安装、启动、Metal shader runtime 与视觉 smoke。
## GPT / RDC 独立审查(2026-09-17) ## GPT / RDC 独立审查(2026-09-17)
@@ -45,3 +47,16 @@ Codex 结束后由 RDC 读取完整 diff、设计文档和完成报告,GPT 独
- 编译中只对 Test App 做小范围修正:使用 `${CMAKE_SOURCE_DIR}/src` 暴露 private smoke 所需内部头;`weakSelf` 改为显式 `MBXViewController *`;fixture path 使用 `NSString *`。 - 编译中只对 Test App 做小范围修正:使用 `${CMAKE_SOURCE_DIR}/src` 暴露 private smoke 所需内部头;`weakSelf` 改为显式 `MBXViewController *`;fixture path 使用 `NSString *`。
- 最终 `app` target `BUILD SUCCEEDED`;`particle_gpu_validation.cpp` 和 `MBXViewController.mm` 均通过 arm64 编译。 - 最终 `app` target `BUILD SUCCEEDED`;`particle_gpu_validation.cpp` 和 `MBXViewController.mm` 均通过 arm64 编译。
- 当前登记 iPhone/iPad 均为 `unavailable`,因此状态进入 `WAITING_DEVICE_SMOKE`,不能声明真机/Metal runtime PASS。 - 当前登记 iPhone/iPad 均为 `unavailable`,因此状态进入 `WAITING_DEVICE_SMOKE`,不能声明真机/Metal runtime PASS。
## RDC 真机收口(2026-09-18)
- 设备:Pleiades,iPad mini (5th generation) / iPad11,2,iPadOS 26.5.2;使用 iPhoneOS device build,未使用 Simulator。
- 确认 Metal render-pipeline cache key 缺少 attachment pixel format:State RGBA32Float / Trail RGBA8Unorm / 主 renderable BGRA8Unorm 之间可能错误复用 pipeline。`src/mbgl/shaders/mtl/shader_program.cpp` 已把 color/depth/stencil format 合入 cache key。
- Trail target 强制写测试在修复前全零、修复后 RGBA8 有效写入,证明 cache-key 根因。
- State GPU 与 CPU/视觉验证确认 canonical U/V、T0/T1、State ping-pong、vertex-stage texture sampling 和 z0 matrix 投影正确。
- 实测正式 previous→current 线段平均约 0.065 px、最大约 0.24 px,全部 `<0.5 px`;Metal line primitive 无稳定覆盖。保留真实 line,同时增加 Metal-only 1 px current-endpoint quad coverage,不改变物理位移。
- Deterministic:Uniform East 向东/右,Uniform North 向北/上,Vortex 粒子可见、移动连续、有轨迹感。
- QBIN Golden fixture:grid 513×769,bounds=112.5E..157.5E / 0..55.776573N;Smoke 相机定位约 27.8883N / 135E、zoom 3 后,T0 / MID / T1 / Clear / Clear 后重进全部人工确认正常。
- 中间 `STEP081_*` GPU readback / fixed-line / point fallback 诊断均已删除;错误的 Point coverage 曾导致整屏绿色,最终未保留。
- `git diff --check` 在最终清理后通过;无关 `MBXAppDelegate.m` 与 vendor/submodule dirty 不纳入 STEP-081 commit。

View File

@@ -0,0 +1,54 @@
# STEP-081 iOS Metal Renderer 与真机 Smoke 入口 RDC 真机验收报告 v2
日期:2026-09-18
状态:CLOSED / PASS
## 验收环境
- 仓库:`MapLibreParticleLab`,分支 `particle/main`。
- 设备:Pleiades,iPad mini (5th generation) / iPad11,2,iPadOS 26.5.2。
- Xcode 26.3,iPhoneOS SDK 26.2,arm64 device build。
- Test App Bundle ID:`jp.eyaku.maplibreparticlelab.step081`。
- 未使用 Simulator。
## 最终通过项
- 签名 device build、安装、启动通过。
- Deterministic Uniform East:粒子整体向右/东,PASS。
- Deterministic Uniform North:粒子整体向上/北,PASS。
- Deterministic Vortex:粒子可见、持续移动并形成连续轨迹,PASS。
- QBIN Wind Golden:T0、MID、T1 均正常显示,PASS。
- QBIN Clear:正常释放;Clear 后重新 T0 可恢复,PASS。
## 真机定位出的 Metal 问题与修复
### 1. Render pipeline cache attachment format 缺失
`ShaderProgram::getRenderPipelineState()` 原 cache key 未包含 color/depth/stencil attachment pixel format。粒子同一 shader 跨 State RGBA32Float、Trail RGBA8Unorm 与主 renderable 使用时,可能错误复用不兼容 Metal pipeline。修复为在 reuse hash 中合并 color/depth/stencil format。
物理 iPad 诊断中,修复前 Trail target 即使强制纯绿色 fragment 仍为全零;修复后同一路径 RGBA8 target 出现有效写入,因此该根因得到直接设备证据。
### 2. 亚像素 previous→current line 无稳定覆盖
State/Field/vertex texture sampling、矩阵与坐标范围均被分别证明正确。实际 previous→current 屏幕线段平均约 0.065 px、最大约 0.24 px,全部小于 0.5 px。Metal line primitive 对该长度没有稳定可见 coverage。
最终保留真实 previous→current line,并新增 Metal-only current-endpoint 1 px quad coverage。该 fallback 只补 raster coverage,不修改 State、U/V、advection、时间步、速度或物理线长。中间 Point fallback 会导致整屏绿色,已撤销且不进入最终代码。
### 3. QBIN smoke 相机不在 fixture 范围
Wind Golden QBIN 解码出的 grid 为 513×769,bounds 为 112.5E..157.5E、0..55.776573N。原 Smoke 入口 FIELD_READY 后不调整相机,世界中心视图看不到字段。Test App 现定位约 27.8883N / 135E、zoom 3;此修改只影响 private smoke 可观察性。
## 其他正式修复
- Darwin layer manager 注册 ParticleValidation / RasterParticle / WeatherColor Core-only factories。
- Metal OffscreenTexture 的 size 初始化与 `isRenderable()` 实现修正。
- Metal RenderPass 增加 vertex texture / sampler binding;当前 `Texture2D::bind()` 为粒子正式路径同时绑定 vertex+fragment。
- Metal MSL 恢复 raw `short2 a_pos` 语义,并修正 fullscreen State/Trail/Composite Y 方向。
- Metal render pipeline cache key 现在包含 attachment formats。
## 清理与边界
- 所有 `STEP081_*` readback、固定绿线、matrix-only、State-range 等临时诊断均从最终源码删除。
- Test App deterministic fixture 保留高对比显示参数,仅用于人工 smoke;不改变 Core 物理语义。
- 未修改 QBIN V4 decoder/profile/binding 协议、Android/RN 公共 API、NavSea 或 provider/network 责任边界。
- `MBXAppDelegate.m` 的既有日志级别 dirty 与两个 vendor/submodule dirty 不属于 STEP-081,不纳入提交。

View File

@@ -35,10 +35,16 @@ public:
/// Apply the given stencil reference value, if different from the current value /// Apply the given stencil reference value, if different from the current value
void setStencilReference(int32_t referenceValue); void setStencilReference(int32_t referenceValue);
/// Bind a texture to the fragment location /// Bind a texture to the vertex location.
void setVertexTexture(const MTLTexturePtr&, int32_t location);
/// Set the sampler for a vertex texture binding.
void setVertexSamplerState(const MTLSamplerStatePtr&, int32_t location);
/// Bind a texture to the fragment location.
void setFragmentTexture(const MTLTexturePtr&, int32_t location); void setFragmentTexture(const MTLTexturePtr&, int32_t location);
/// Set the sampler for a texture binding /// Set the sampler for a fragment texture binding.
void setFragmentSamplerState(const MTLSamplerStatePtr&, int32_t location); void setFragmentSamplerState(const MTLSamplerStatePtr&, int32_t location);
/// Set the render pipeline state /// Set the render pipeline state
@@ -88,6 +94,8 @@ private:
std::array<std::optional<BindInfo>, maxBinds> vertexBinds; std::array<std::optional<BindInfo>, maxBinds> vertexBinds;
std::array<std::optional<BindInfo>, maxBinds> fragmentBinds; std::array<std::optional<BindInfo>, maxBinds> fragmentBinds;
std::array<MTLTexturePtr, maxBinds> vertexTextureBindings;
std::array<MTLSamplerStatePtr, maxBinds> vertexSamplerStates;
std::array<MTLTexturePtr, maxBinds> fragmentTextureBindings; std::array<MTLTexturePtr, maxBinds> fragmentTextureBindings;
std::array<MTLSamplerStatePtr, maxBinds> fragmentSamplerStates; std::array<MTLSamplerStatePtr, maxBinds> fragmentSamplerStates;

View File

@@ -65,21 +65,56 @@ FragmentStage vertex vertexMain(thread const VertexStage in [[stage_in]],
texture2d<float> nextField [[texture(3)]], texture2d<float> state [[texture(4)]], texture2d<float> nextField [[texture(3)]], texture2d<float> state [[texture(4)]],
sampler previousSampler [[sampler(0)]], sampler fieldSampler [[sampler(2)]], sampler previousSampler [[sampler(0)]], sampler fieldSampler [[sampler(2)]],
sampler nextFieldSampler [[sampler(3)]], sampler stateSampler [[sampler(4)]]) { sampler nextFieldSampler [[sampler(3)]], sampler stateSampler [[sampler(4)]]) {
float2 pos=float2(in.pos)/8192.0; // a_pos is an unnormalized short2, matching the OpenGL contract.
float2 pos=float2(in.pos);
FragmentStage out={}; out.uv=pos; out.world=pos; out.valid=1.0; FragmentStage out={}; out.uv=pos; out.world=pos; out.valid=1.0;
if (u.pass.x > 4.5) out.position=u.matrix*float4(pos*8192.0,0,1); if (u.pass.x > 5.5) {
else if (u.pass.x < 1.5) out.position=float4(pos*2.0-1.0,0,1); float corner=glMod(pos.x,4.0);
else if (u.pass.x > 3.5) { float2 index=float2(floor(pos.x*.25),pos.y);
float endpoint=glMod(pos.x*8192.0,2.0)*2.0-1.0;
float2 index=float2(floor(pos.x*8192.0*.5),pos.y*8192.0);
float2 stateUv=(index+.5)/u.mask.xy; float2 stateUv=(index+.5)/u.mask.xy;
float4 old=previous.sample(previousSampler,stateUv), current=state.sample(stateSampler,stateUv); float4 old=previous.sample(previousSampler,stateUv), current=state.sample(stateSampler,stateUv);
out.uv=stateUv; out.world=current.rg;
bool stateOk=current.a>.5&&distance(old.rg,current.rg)>1e-9;
float2 uv=fieldUv(current.rg,u);
float4 a=field.sample(fieldSampler,uv), b=nextField.sample(nextFieldSampler,uv);
float2 advection;
bool ok=validFieldUv(uv)&&a.a>=.5&&b.a>=.5&&resolveAdvection(a,b,u,advection);
out.velocity=float2(advection.x,-advection.y);
out.valid=stateOk&&ok&&length(out.velocity)>0.00001?1.0:0.0;
float4 clip=u.matrix*float4(current.rg*8192.0,0,1);
float2 cornerOffset=corner<.5?float2(-1,-1):(corner<1.5?float2(1,-1):(corner<2.5?float2(-1,1):float2(1,1)));
float2 halfPixelNdc=float2(1.0/max(u.fieldScale.w,1.0),1.0/max(u.advection.w,1.0));
clip.xy+=cornerOffset*halfPixelNdc*clip.w;
out.position=out.valid<.5?float4(-2,-2,0,1):clip;
} else if (u.pass.x > 4.5) {
out.position=u.matrix*float4(pos*8192.0,0,1);
} else if (u.pass.x < 1.5) {
// Metal render targets are addressed from the top-left. Flip the
// fullscreen varying so the same logical State/Trail texel is sampled
// on the next ping-pong pass. Particle texel indices remain unflipped.
out.uv=float2(pos.x,1.0-pos.y);
out.position=float4(pos*2.0-1.0,0,1);
} else if (u.pass.x > 3.5) {
float endpoint=glMod(pos.x,2.0)*2.0-1.0;
float2 index=float2(floor(pos.x*.5),pos.y);
float2 stateUv=(index+.5)/u.mask.xy;
float4 old=previous.sample(previousSampler,stateUv), current=state.sample(stateSampler,stateUv);
out.uv=stateUv; out.world=current.rg;
bool stateOk=current.a>.5&&distance(old.rg,current.rg)>1e-9;
float2 uv=fieldUv(current.rg,u); float4 a=field.sample(fieldSampler,uv), b=nextField.sample(nextFieldSampler,uv); float2 advection; float2 uv=fieldUv(current.rg,u); float4 a=field.sample(fieldSampler,uv), b=nextField.sample(nextFieldSampler,uv); float2 advection;
bool ok=validFieldUv(uv)&&a.a>=.5&&b.a>=.5&&resolveAdvection(a,b,u,advection); bool ok=validFieldUv(uv)&&a.a>=.5&&b.a>=.5&&resolveAdvection(a,b,u,advection);
out.uv=stateUv; out.world=current.rg; out.velocity=float2(advection.x,-advection.y); out.velocity=float2(advection.x,-advection.y);
out.valid=ok&&length(out.velocity)>0.00001&&current.a>.5&&distance(old.rg,current.rg)>1e-9 ? 1.0:0.0; out.valid=stateOk&&ok&&length(out.velocity)>0.00001?1.0:0.0;
out.position=out.valid<.5?float4(-2,-2,0,1):u.matrix*float4((endpoint<0?old.rg:current.rg)*8192.0,0,1); out.position=out.valid<.5?float4(-2,-2,0,1):u.matrix*float4((endpoint<0?old.rg:current.rg)*8192.0,0,1);
} else out.position=u.mask.z>.5 ? float4(pos*2.0-1.0,0,1) : u.matrix*float4(pos*8192.0,0,1); } else {
if (u.mask.z>.5) {
// Composite samples the viewport-sized Trail render target.
out.uv=float2(pos.x,1.0-pos.y);
out.position=float4(pos*2.0-1.0,0,1);
} else {
out.position=u.matrix*float4(pos*8192.0,0,1);
}
}
return out; return out;
} }
@@ -90,6 +125,7 @@ float4 fragment fragmentMain(FragmentStage in [[stage_in]], device const Particl
texture2d<float> previous [[texture(0)]], texture2d<float> mask [[texture(1)]], texture2d<float> field [[texture(2)]], texture2d<float> nextField [[texture(3)]], texture2d<float> state [[texture(4)]], texture2d<float> previous [[texture(0)]], texture2d<float> mask [[texture(1)]], texture2d<float> field [[texture(2)]], texture2d<float> nextField [[texture(3)]], texture2d<float> state [[texture(4)]],
sampler previousSampler [[sampler(0)]], sampler maskSampler [[sampler(1)]], sampler fieldSampler [[sampler(2)]], sampler nextFieldSampler [[sampler(3)]], sampler stateSampler [[sampler(4)]]) { sampler previousSampler [[sampler(0)]], sampler maskSampler [[sampler(1)]], sampler fieldSampler [[sampler(2)]], sampler nextFieldSampler [[sampler(3)]], sampler stateSampler [[sampler(4)]]) {
float mode=u.pass.x; float mode=u.pass.x;
if(mode>5.5){if(in.valid<.5)discard_fragment();float speed=length(in.velocity);return float4(1,1,clamp(speed/30.,0.,1.),1);}
if(mode>4.5){float2 uv=fieldUv(in.world,u); if(!validFieldUv(uv)) discard_fragment(); float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);if(a.a<.5||b.a<.5)discard_fragment();float value=u.weatherRamp.y>.5?length(mix((a.rg*2.-1.)*u.fieldScale.x,(b.rg*2.-1.)*u.fieldScale.y,u.marker.x)):mix((a.r*2.-1.)*u.fieldScale.x,(b.r*2.-1.)*u.fieldScale.y,u.marker.x);if(!isfinite(value))discard_fragment();float4 c=ramp(value,u);float alpha=c.a*u.visual.x;return float4(c.rgb*alpha,alpha);} if(mode>4.5){float2 uv=fieldUv(in.world,u); if(!validFieldUv(uv)) discard_fragment(); float4 a=field.sample(fieldSampler,uv),b=nextField.sample(nextFieldSampler,uv);if(a.a<.5||b.a<.5)discard_fragment();float value=u.weatherRamp.y>.5?length(mix((a.rg*2.-1.)*u.fieldScale.x,(b.rg*2.-1.)*u.fieldScale.y,u.marker.x)):mix((a.r*2.-1.)*u.fieldScale.x,(b.r*2.-1.)*u.fieldScale.y,u.marker.x);if(!isfinite(value))discard_fragment();float4 c=ramp(value,u);float alpha=c.a*u.visual.x;return float4(c.rgb*alpha,alpha);}
float4 old=previous.sample(previousSampler,in.uv); float4 old=previous.sample(previousSampler,in.uv);
if(mode<.5){ if(mode<.5){

View File

@@ -14,11 +14,18 @@
#import "MLNCustomDrawableStyleLayer_Private.h" #import "MLNCustomDrawableStyleLayer_Private.h"
#include <mbgl/layermanager/particle_validation_layer_factory.hpp>
#include <mbgl/layermanager/raster_particle_layer_factory.hpp>
#include <mbgl/layermanager/weather_color_layer_factory.hpp>
#include <vector> #include <vector>
namespace mbgl { namespace mbgl {
LayerManagerDarwin::LayerManagerDarwin() { LayerManagerDarwin::LayerManagerDarwin() {
addLayerTypeCoreOnly(std::make_unique<ParticleValidationLayerFactory>());
addLayerTypeCoreOnly(std::make_unique<RasterParticleLayerFactory>());
addLayerTypeCoreOnly(std::make_unique<WeatherColorLayerFactory>());
#if defined(MBGL_LAYER_FILL_DISABLE_RUNTIME) #if defined(MBGL_LAYER_FILL_DISABLE_RUNTIME)
addLayerTypeCoreOnly(std::make_unique<FillLayerFactory>()); addLayerTypeCoreOnly(std::make_unique<FillLayerFactory>());
#elif !defined(MBGL_LAYER_FILL_DISABLE_ALL) #elif !defined(MBGL_LAYER_FILL_DISABLE_ALL)

View File

@@ -33,6 +33,7 @@ set_target_properties(app PROPERTIES
MACOSX_BUNDLE YES MACOSX_BUNDLE YES
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_LIST_DIR}/Info.plist" MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_LIST_DIR}/Info.plist"
XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "org.maplibre.app" XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "org.maplibre.app"
XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2"
XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES"
XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${DEVELOPMENT_TEAM_ID}" XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${DEVELOPMENT_TEAM_ID}"
XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "iPhone Developer" XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "iPhone Developer"
@@ -51,6 +52,20 @@ add_custom_command(TARGET app POST_BUILD
COMMAND ls "$<TARGET_BUNDLE_DIR:app>" COMMAND ls "$<TARGET_BUNDLE_DIR:app>"
) )
# STEP-081 deterministic formal particle fixtures for Metal bring-up.
foreach(particle_fixture uniform-east uniform-north vortex)
add_custom_command(TARGET app POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_BUNDLE_DIR:app>/ParticleCoreFixtures/${particle_fixture}"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/particle-fixtures/${particle_fixture}/metadata.json"
"$<TARGET_BUNDLE_DIR:app>/ParticleCoreFixtures/${particle_fixture}/metadata.json"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/particle-fixtures/${particle_fixture}/field.f32"
"$<TARGET_BUNDLE_DIR:app>/ParticleCoreFixtures/${particle_fixture}/field.f32"
)
endforeach()
# STEP-081 test-only QBIN V4 fixture handoff. The canonical files remain in # STEP-081 test-only QBIN V4 fixture handoff. The canonical files remain in
# the RN Test Host; this copies them into the app bundle without a new format. # the RN Test Host; this copies them into the app bundle without a new format.
add_custom_command(TARGET app POST_BUILD add_custom_command(TARGET app POST_BUILD

View File

@@ -128,6 +128,7 @@ typedef NS_ENUM(NSInteger, MBXSettingsRuntimeStylingRows) {
MBXSettingsRuntimeStylingCustomDrawableLayer, MBXSettingsRuntimeStylingCustomDrawableLayer,
MBXSettingsRuntimeStylingAddFoursquarePOIsPMTiles, MBXSettingsRuntimeStylingAddFoursquarePOIsPMTiles,
MBXSettingsRuntimeStylingHeatmapLayer, MBXSettingsRuntimeStylingHeatmapLayer,
MBXSettingsRuntimeStylingParticleCoreValidation,
MBXSettingsRuntimeStylingParticleQbinV4Smoke MBXSettingsRuntimeStylingParticleQbinV4Smoke
}; };
@@ -255,6 +256,9 @@ CLLocationCoordinate2D randomWorldCoordinate(void) {
@property (nonatomic) NSMutableArray<UIWindow *> *helperWindows; @property (nonatomic) NSMutableArray<UIWindow *> *helperWindows;
@property (nonatomic) NSMutableArray<UIView *> *contentInsetsOverlays; @property (nonatomic) NSMutableArray<UIView *> *contentInsetsOverlays;
@property (nonatomic, copy) void (^locationBlock)(void); @property (nonatomic, copy) void (^locationBlock)(void);
- (void)showParticleCoreValidationMenu;
- (void)installParticleCoreValidationFixture:(NSString *)fixtureID label:(NSString *)label;
- (void)clearParticleCoreValidation;
- (void)showParticleQbinV4SmokeMenu; - (void)showParticleQbinV4SmokeMenu;
- (void)installParticleQbinV4SmokeAtTime:(uint64_t)requestedTime label:(NSString *)label; - (void)installParticleQbinV4SmokeAtTime:(uint64_t)requestedTime label:(NSString *)label;
- (void)clearParticleQbinV4Smoke; - (void)clearParticleQbinV4Smoke;
@@ -518,6 +522,7 @@ CLLocationCoordinate2D randomWorldCoordinate(void) {
@"Add Custom Drawable Layer", @"Add Custom Drawable Layer",
@"Add FourSquare POIs PMTiles Layer", @"Add FourSquare POIs PMTiles Layer",
@"Add Earthquake Heatmap", @"Add Earthquake Heatmap",
@"Particle Core Validation (Fixtures)",
@"Particle / QBIN V4 Smoke (T0)" @"Particle / QBIN V4 Smoke (T0)"
]]; ]];
break; break;
@@ -752,6 +757,9 @@ CLLocationCoordinate2D randomWorldCoordinate(void) {
case MBXSettingsRuntimeStylingHeatmapLayer: case MBXSettingsRuntimeStylingHeatmapLayer:
[self addEarthquakeHeatmap]; [self addEarthquakeHeatmap];
break; break;
case MBXSettingsRuntimeStylingParticleCoreValidation:
[self showParticleCoreValidationMenu];
break;
case MBXSettingsRuntimeStylingParticleQbinV4Smoke: case MBXSettingsRuntimeStylingParticleQbinV4Smoke:
[self showParticleQbinV4SmokeMenu]; [self showParticleQbinV4SmokeMenu];
break; break;
@@ -1178,6 +1186,132 @@ CLLocationCoordinate2D randomWorldCoordinate(void) {
[self.mapView.style addLayer:circleLayer]; [self.mapView.style addLayer:circleLayer];
} }
// MARK: - STEP-081 deterministic formal particle fixtures (test app only)
- (void)showParticleCoreValidationMenu {
UIAlertController *menu = [UIAlertController alertControllerWithTitle:@"Particle Core Validation"
message:@"Deterministic fields through the formal Host ingress + RasterParticleLayer path."
preferredStyle:UIAlertControllerStyleAlert];
__weak MBXViewController *weakSelf = self;
[menu addAction:[UIAlertAction actionWithTitle:@"Uniform East" style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) {
[weakSelf installParticleCoreValidationFixture:@"uniform-east" label:@"UNIFORM_EAST"];
}]];
[menu addAction:[UIAlertAction actionWithTitle:@"Uniform North" style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) {
[weakSelf installParticleCoreValidationFixture:@"uniform-north" label:@"UNIFORM_NORTH"];
}]];
[menu addAction:[UIAlertAction actionWithTitle:@"Vortex" style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) {
[weakSelf installParticleCoreValidationFixture:@"vortex" label:@"VORTEX"];
}]];
[menu addAction:[UIAlertAction actionWithTitle:@"Clear" style:UIAlertActionStyleDestructive handler:^(__unused UIAlertAction *a) {
[weakSelf clearParticleCoreValidation];
}]];
[menu addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[self presentViewController:menu animated:YES completion:nil];
}
- (void)installParticleCoreValidationFixture:(NSString *)fixtureID label:(NSString *)label {
[self clearParticleQbinV4Smoke];
[self clearParticleCoreValidation];
NSString *directory = [@"ParticleCoreFixtures" stringByAppendingPathComponent:fixtureID];
NSString *metadataPath = [[NSBundle mainBundle] pathForResource:@"metadata" ofType:@"json" inDirectory:directory];
NSString *fieldPath = [[NSBundle mainBundle] pathForResource:@"field" ofType:@"f32" inDirectory:directory];
NSData *metadataData = metadataPath ? [NSData dataWithContentsOfFile:metadataPath] : nil;
NSData *fieldData = fieldPath ? [NSData dataWithContentsOfFile:fieldPath] : nil;
if (!metadataData || !fieldData) {
NSLog(@"ParticleFormalFixture: event=FIXTURE_MISSING fixture=%@", fixtureID);
return;
}
NSError *jsonError = nil;
NSDictionary *fixtureMetadata = [NSJSONSerialization JSONObjectWithData:metadataData options:0 error:&jsonError];
NSDictionary *grid = fixtureMetadata[@"grid"];
NSDictionary *bounds = fixtureMetadata[@"bounds"];
NSDictionary *vector = fixtureMetadata[@"vector"];
NSNumber *width = grid[@"width"], *height = grid[@"height"], *noData = vector[@"noData"];
if (![fixtureMetadata isKindOfClass:[NSDictionary class]] || jsonError || !width || !height || !noData || !bounds) {
NSLog(@"ParticleFormalFixture: event=METADATA_INVALID fixture=%@", fixtureID);
return;
}
NSMutableData *t0Data = [fieldData mutableCopy];
NSMutableData *t1Data = [fieldData mutableCopy];
// Keep T1 semantically distinct while preserving the deterministic vector shape.
float *t1Values = static_cast<float *>(t1Data.mutableBytes);
const size_t valueCount = t1Data.length / sizeof(float);
const float noDataValue = noData.floatValue;
for (size_t i = 0; i < valueCount; ++i) {
if (t1Values[i] != noDataValue) t1Values[i] *= 1.001f;
}
NSDictionary *(^hostMetadata)(uint64_t, uint64_t) = ^NSDictionary *(uint64_t generation, uint64_t validTime) {
return @{
@"schema": @"maplibre-particle-field/v1", @"fieldId": fixtureID,
@"fieldType": fixtureMetadata[@"fieldType"] ?: @"WIND",
@"displayDomain": fixtureMetadata[@"displayDomain"] ?: @"GLOBAL",
@"generation": @(generation), @"validTime": @(validTime),
@"dataVersion": fixtureMetadata[@"dataVersion"] ?: @1,
@"grid": @{ @"width": width, @"height": height },
@"bounds": @{ @"west": bounds[@"west"], @"south": bounds[@"south"],
@"east": bounds[@"east"], @"north": bounds[@"north"] },
@"vector": @{ @"layout": @"interleaved-uv", @"encoding": @"float32-le", @"noData": noData },
@"payload": @{ @"byteLength": @(fieldData.length) }
};
};
NSData *t0MetadataData = [NSJSONSerialization dataWithJSONObject:hostMetadata(1, 1) options:0 error:&jsonError];
NSData *t1MetadataData = [NSJSONSerialization dataWithJSONObject:hostMetadata(2, 2) options:0 error:&jsonError];
NSString *t0Metadata = [[NSString alloc] initWithData:t0MetadataData encoding:NSUTF8StringEncoding];
NSString *t1Metadata = [[NSString alloc] initWithData:t1MetadataData encoding:NSUTF8StringEncoding];
if (!t0Metadata || !t1Metadata || jsonError) {
NSLog(@"ParticleFormalFixture: event=HOST_METADATA_FAILED fixture=%@", fixtureID);
return;
}
auto& style = [self.mapView mbglMap].getStyle();
style.addSource(std::make_unique<mbgl::style::RasterArraySource>("particle-formal-fixture-source"));
auto *sourcePtr = static_cast<mbgl::style::RasterArraySource *>(style.getSource("particle-formal-fixture-source"));
const auto bytes = [](NSData *data) {
const auto *begin = static_cast<const char *>(data.bytes);
return std::vector<char>(begin, begin + data.length);
};
if (!sourcePtr) {
NSLog(@"ParticleFormalFixture: event=SOURCE_LOOKUP_FAILED fixture=%@", fixtureID);
return;
}
const auto t0Result = sourcePtr->submitHostField(t0Metadata.UTF8String, bytes(t0Data));
const auto t1Result = sourcePtr->submitHostField(t1Metadata.UTF8String, bytes(t1Data));
if (t0Result != mbgl::style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow ||
t1Result != mbgl::style::RasterArrayHostFieldSubmitResult::InitialWindowReady) {
NSLog(@"ParticleFormalFixture: event=HOST_WINDOW_REJECTED fixture=%@ t0=%d t1=%d", fixtureID, int(t0Result), int(t1Result));
style.removeSource("particle-formal-fixture-source");
return;
}
auto particles = std::make_unique<mbgl::style::RasterParticleLayer>(
"particle-formal-fixture-particles", "particle-formal-fixture-source");
particles->setParticleCount(4096);
particles->setParticleLength(.018f);
// STEP-081 physical-device fixture uses deliberately high-contrast visuals so
// subpixel Metal coverage and Vortex direction remain visible over the demo map.
particles->setParticleOpacity(1.0f);
particles->setTrailOpacity(1.0f);
particles->setTrailFade(.985f);
particles->setSpeedColorEnabled(false);
particles->setParticleColor({1.0f, 0.0f, 0.85f, 1.0f});
particles->setTrailColor({0.35f, 0.0f, 0.45f, 1.0f});
style.addLayer(std::move(particles));
[self.mapView setCenterCoordinate:CLLocationCoordinate2DMake(0.0, 0.0) zoomLevel:2.0 animated:NO];
[self.mapView setNeedsRerender];
NSLog(@"ParticleFormalFixture: event=FIXTURE_READY fixture=%@ label=%@ ingress=host-field", fixtureID, label);
}
- (void)clearParticleCoreValidation {
auto& style = [self.mapView mbglMap].getStyle();
if (style.getLayer("particle-formal-fixture-particles")) style.removeLayer("particle-formal-fixture-particles");
if (style.getSource("particle-formal-fixture-source")) style.removeSource("particle-formal-fixture-source");
[self.mapView setNeedsRerender];
NSLog(@"ParticleFormalFixture: event=CLEAR");
}
// MARK: - STEP-081 Particle / QBIN V4 smoke (test app only) // MARK: - STEP-081 Particle / QBIN V4 smoke (test app only)
- (void)showParticleQbinV4SmokeMenu { - (void)showParticleQbinV4SmokeMenu {
@@ -1198,6 +1332,7 @@ CLLocationCoordinate2D randomWorldCoordinate(void) {
- (void)installParticleQbinV4SmokeAtTime:(uint64_t)requestedTime label:(NSString *)label { - (void)installParticleQbinV4SmokeAtTime:(uint64_t)requestedTime label:(NSString *)label {
[self clearParticleQbinV4Smoke]; [self clearParticleQbinV4Smoke];
[self clearParticleCoreValidation];
NSString *(^fixturePath)(NSString *) = ^NSString *(NSString *name) { NSString *(^fixturePath)(NSString *) = ^NSString *(NSString *name) {
return [[NSBundle mainBundle] pathForResource:name ofType:@"qbin" inDirectory:@"ParticleSmokeFixtures"]; return [[NSBundle mainBundle] pathForResource:name ofType:@"qbin" inDirectory:@"ParticleSmokeFixtures"];
}; };
@@ -1248,6 +1383,9 @@ CLLocationCoordinate2D randomWorldCoordinate(void) {
style.addSource(std::move(source)); style.addSource(std::move(source));
style.addLayer(std::move(weather)); style.addLayer(std::move(weather));
style.addLayer(std::move(particles)); style.addLayer(std::move(particles));
// The bundled wind QBIN golden fixture covers 112.5E..157.5E and 0..55.7766N.
// Center the Test App camera on that field so FIELD_READY is also visually observable.
[self.mapView setCenterCoordinate:CLLocationCoordinate2DMake(27.8882865, 135.0) zoomLevel:3.0 animated:NO];
[self.mapView setNeedsRerender]; [self.mapView setNeedsRerender];
NSLog(@"ParticleQbinV4Smoke: event=FIELD_READY state=%@ requestedTime=%llu source=fixture.wind layers=weather-color,raster-particle", label, requestedTime); NSLog(@"ParticleQbinV4Smoke: event=FIELD_READY state=%@ requestedTime=%llu source=fixture.wind layers=weather-color,raster-particle", label, requestedTime);
} }

View File

@@ -134,11 +134,18 @@ private:
OffscreenTexture::OffscreenTexture( OffscreenTexture::OffscreenTexture(
Context& context, const Size size_, const gfx::TextureChannelDataType type, bool depth, bool stencil) Context& context, const Size size_, const gfx::TextureChannelDataType type, bool depth, bool stencil)
: gfx::OffscreenTexture(size, std::make_unique<OffscreenTextureResource>(context, size_, type, depth, stencil)) {} : gfx::OffscreenTexture(size_, std::make_unique<OffscreenTextureResource>(context, size_, type, depth, stencil)) {}
bool OffscreenTexture::isRenderable() { bool OffscreenTexture::isRenderable() {
assert(false); try {
return true; auto& texture = getResource<OffscreenTextureResource>().getTexture();
if (!texture) return false;
auto* metalTexture = static_cast<Texture2D*>(texture.get());
metalTexture->create();
return metalTexture->getMetalTexture() != nullptr;
} catch (...) {
return false;
}
} }
PremultipliedImage OffscreenTexture::readStillImage() { PremultipliedImage OffscreenTexture::readStillImage() {

View File

@@ -76,6 +76,8 @@ void RenderPass::resetState() {
for (int i = 0; i < maxBinds; ++i) { for (int i = 0; i < maxBinds; ++i) {
vertexBinds[i].reset(); vertexBinds[i].reset();
fragmentBinds[i].reset(); fragmentBinds[i].reset();
vertexTextureBindings[i].reset();
vertexSamplerStates[i].reset();
fragmentTextureBindings[i].reset(); fragmentTextureBindings[i].reset();
fragmentSamplerStates[i].reset(); fragmentSamplerStates[i].reset();
} }
@@ -179,6 +181,22 @@ void RenderPass::setStencilReference(int32_t referenceValue) {
} }
} }
void RenderPass::setVertexTexture(const MTLTexturePtr& texture, int32_t location) {
assert(0 <= location && location < maxBinds);
if (0 <= location && location < maxBinds && vertexTextureBindings[location] != texture) {
vertexTextureBindings[location] = texture;
encoder->setVertexTexture(texture.get(), location);
}
}
void RenderPass::setVertexSamplerState(const MTLSamplerStatePtr& state, int32_t location) {
assert(0 <= location && location < maxBinds);
if (0 <= location && location < maxBinds && vertexSamplerStates[location] != state) {
vertexSamplerStates[location] = state;
encoder->setVertexSamplerState(state.get(), location);
}
}
void RenderPass::setFragmentTexture(const MTLTexturePtr& texture, int32_t location) { void RenderPass::setFragmentTexture(const MTLTexturePtr& texture, int32_t location) {
assert(0 <= location && location < maxBinds); assert(0 <= location && location < maxBinds);
if (0 <= location && location < maxBinds) { if (0 <= location && location < maxBinds) {

View File

@@ -257,6 +257,10 @@ void Texture2D::bind(RenderPass& renderPass, int32_t location) {
updateSamplerConfiguration(); updateSamplerConfiguration();
} }
// Particle advection samples field/state textures in the vertex stage.
// Keep fragment bindings as before and mirror the same binding to vertex.
renderPass.setVertexTexture(metalTexture, location);
renderPass.setVertexSamplerState(metalSamplerState, location);
renderPass.setFragmentTexture(metalTexture, location); renderPass.setFragmentTexture(metalTexture, location);
renderPass.setFragmentSamplerState(metalSamplerState, location); renderPass.setFragmentSamplerState(metalSamplerState, location);

View File

@@ -130,6 +130,7 @@ enum class ValidationPass : uint8_t {
Composite = 2, Composite = 2,
FrozenComposite = 3, FrozenComposite = 3,
TrailParticles = 4, TrailParticles = 4,
TrailParticleCoverage = 6,
}; };
const char* slotName(const uint8_t slot) { const char* slotName(const uint8_t slot) {
@@ -390,9 +391,12 @@ public:
std::shared_ptr<PremultipliedImage> maskFixture; std::shared_ptr<PremultipliedImage> maskFixture;
std::shared_ptr<gfx::VertexVector<HeatmapTextureLayoutVertex>> vertices; std::shared_ptr<gfx::VertexVector<HeatmapTextureLayoutVertex>> vertices;
std::shared_ptr<gfx::VertexVector<HeatmapTextureLayoutVertex>> particleVertices; std::shared_ptr<gfx::VertexVector<HeatmapTextureLayoutVertex>> particleVertices;
std::shared_ptr<gfx::VertexVector<HeatmapTextureLayoutVertex>> particleCoverageVertices;
gfx::IndexVectorBasePtr particleIndices; gfx::IndexVectorBasePtr particleIndices;
gfx::IndexVectorBasePtr particleCoverageIndices;
SegmentVector segments; SegmentVector segments;
SegmentVector particleSegments; SegmentVector particleSegments;
SegmentVector particleCoverageSegments;
uint8_t stateWrite() const { return static_cast<uint8_t>(1 - stateRead); } uint8_t stateWrite() const { return static_cast<uint8_t>(1 - stateRead); }
uint8_t trailWrite() const { return static_cast<uint8_t>(1 - trailRead); } uint8_t trailWrite() const { return static_cast<uint8_t>(1 - trailRead); }
@@ -446,17 +450,23 @@ constexpr uint32_t MaxParticleVerticesPerSegment = std::numeric_limits<uint16_t>
void rebuildParticleDrawResources(const std::shared_ptr<ParticleGpuResources>& resources) { void rebuildParticleDrawResources(const std::shared_ptr<ParticleGpuResources>& resources) {
auto vertices = std::make_shared<gfx::VertexVector<HeatmapTextureLayoutVertex>>(); auto vertices = std::make_shared<gfx::VertexVector<HeatmapTextureLayoutVertex>>();
// Each State texel is represented by a two-vertex line. Its endpoints are auto coverageVertices = std::make_shared<gfx::VertexVector<HeatmapTextureLayoutVertex>>();
// static texel indices; the vertex shader reads StateRead/StateWrite and
// projects the true previous-to-current displacement in GPU space.
vertices->reserve(resources->particleCount * 2); vertices->reserve(resources->particleCount * 2);
coverageVertices->reserve(resources->particleCount * 4);
for (uint32_t particleIndex = 0; particleIndex < resources->particleCount; ++particleIndex) { for (uint32_t particleIndex = 0; particleIndex < resources->particleCount; ++particleIndex) {
const auto x = static_cast<int16_t>(particleIndex % resources->stateSize.width); const auto x = static_cast<int16_t>(particleIndex % resources->stateSize.width);
const auto y = static_cast<int16_t>(particleIndex / resources->stateSize.width); const auto y = static_cast<int16_t>(particleIndex / resources->stateSize.width);
// a_pos.x packs state X and endpoint (0=start, 1=end). State grids // Formal line path: x*2 + endpoint (0=previous, 1=current).
// 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), y}));
vertices->emplace_back(HeatmapBucket::textureVertex({static_cast<int16_t>(x * 2 + 1), y})); vertices->emplace_back(HeatmapBucket::textureVertex({static_cast<int16_t>(x * 2 + 1), y}));
// Metal coverage path: x*4 + corner (0..3). The shader projects the
// true current State position, then applies only a 0.5-pixel clip-space
// corner offset. Simulation/world coordinates are never modified.
for (int16_t corner = 0; corner < 4; ++corner) {
coverageVertices->emplace_back(
HeatmapBucket::textureVertex({static_cast<int16_t>(x * 4 + corner), y}));
}
} }
auto indices = std::make_shared<gfx::IndexVectorBase>(); auto indices = std::make_shared<gfx::IndexVectorBase>();
@@ -465,15 +475,41 @@ void rebuildParticleDrawResources(const std::shared_ptr<ParticleGpuResources>& r
indices->extend(1, static_cast<uint16_t>(index)); indices->extend(1, static_cast<uint16_t>(index));
} }
constexpr uint32_t MaxCoverageVerticesPerSegment = MaxParticleVerticesPerSegment - (MaxParticleVerticesPerSegment % 4u);
constexpr uint32_t MaxCoverageParticlesPerSegment = MaxCoverageVerticesPerSegment / 4u;
auto coverageIndices = std::make_shared<gfx::IndexVectorBase>();
coverageIndices->reserve(MaxCoverageParticlesPerSegment * 6u);
for (uint32_t particle = 0; particle < MaxCoverageParticlesPerSegment; ++particle) {
const auto base = static_cast<uint16_t>(particle * 4u);
coverageIndices->extend(1, base + 0u);
coverageIndices->extend(1, base + 1u);
coverageIndices->extend(1, base + 2u);
coverageIndices->extend(1, base + 2u);
coverageIndices->extend(1, base + 1u);
coverageIndices->extend(1, base + 3u);
}
SegmentVector segments; SegmentVector segments;
const auto vertexCount = resources->particleCount * 2; const auto lineVertexCount = resources->particleCount * 2u;
for (uint32_t offset = 0; offset < vertexCount; offset += MaxParticleVerticesPerSegment) { for (uint32_t offset = 0; offset < lineVertexCount; offset += MaxParticleVerticesPerSegment) {
const auto count = std::min(vertexCount - offset, MaxParticleVerticesPerSegment); const auto count = std::min(lineVertexCount - offset, MaxParticleVerticesPerSegment);
segments.emplace_back(offset, 0, count, count); segments.emplace_back(offset, 0, count, count);
} }
SegmentVector coverageSegments;
const auto coverageVertexCount = resources->particleCount * 4u;
for (uint32_t offset = 0; offset < coverageVertexCount; offset += MaxCoverageVerticesPerSegment) {
const auto vertexCount = std::min(coverageVertexCount - offset, MaxCoverageVerticesPerSegment);
const auto particleCount = vertexCount / 4u;
coverageSegments.emplace_back(offset, 0, vertexCount, particleCount * 6u);
}
resources->particleVertices = std::move(vertices); resources->particleVertices = std::move(vertices);
resources->particleCoverageVertices = std::move(coverageVertices);
resources->particleIndices = std::move(indices); resources->particleIndices = std::move(indices);
resources->particleCoverageIndices = std::move(coverageIndices);
resources->particleSegments = std::move(segments); resources->particleSegments = std::move(segments);
resources->particleCoverageSegments = std::move(coverageSegments);
} }
class ParticleGpuValidationTweaker final : public gfx::DrawableTweaker { class ParticleGpuValidationTweaker final : public gfx::DrawableTweaker {
@@ -598,11 +634,11 @@ public:
.fieldScale = {resources->fieldScale, .fieldScale = {resources->fieldScale,
resources->nextFieldScale, resources->nextFieldScale,
resources->viewportDensityActive ? 1.0f : 0.0f, resources->viewportDensityActive ? 1.0f : 0.0f,
0.0f}, static_cast<float>(resources->trailSize.width)},
.advection = {resources->field.vectorSemantics == particle::VectorSemantics::Direction ? 1.0f : 0.0f, .advection = {resources->field.vectorSemantics == particle::VectorSemantics::Direction ? 1.0f : 0.0f,
resources->advectionScale, resources->advectionScale,
0.00001f, 0.00001f,
0.0f}, static_cast<float>(resources->trailSize.height)},
.particleColor = resources->particleColor, .particleColor = resources->particleColor,
.trailColor = resources->trailColor, .trailColor = resources->trailColor,
.weatherStops = {}, .weatherStops = {},
@@ -920,6 +956,67 @@ bool buildParticlePoints(gfx::Context& context,
return true; return true;
} }
bool buildParticleEndpointCoverage(gfx::Context& context,
const std::shared_ptr<ParticleGpuResources>& resources,
LayerGroup& group,
const std::string& name,
const gfx::Texture2DPtr& previousStateTexture,
const gfx::Texture2DPtr& currentStateTexture,
const gfx::Texture2DPtr& target,
const uint8_t stateSlot,
const uint8_t trailSlot) {
if (!resources->particleCoverageVertices || !resources->particleCoverageIndices ||
resources->particleCoverageSegments.empty() || !previousStateTexture || !currentStateTexture || !target) {
Log::Error(Event::Render, "ParticleGPU: event=PARTICLE_COVERAGE_REJECTED reason=MISSING_DRAW_RESOURCES");
return false;
}
auto attributes = context.createVertexAttributeArray();
if (const auto& attribute = attributes->set(shaders::idParticleGpuValidationPosVertexAttribute)) {
attribute->setSharedRawData(resources->particleCoverageVertices,
offsetof(HeatmapTextureLayoutVertex, a1),
0,
sizeof(HeatmapTextureLayoutVertex),
gfx::AttributeDataType::Short2);
}
auto builder = context.createDrawableBuilder(name);
builder->setShader(resources->shader);
builder->setRenderPass(RenderPass::Translucent);
builder->setEnableDepth(false);
builder->setColorMode(gfx::ColorMode::unblended());
builder->setCullFaceMode(gfx::CullFaceMode::disabled());
builder->setVertexAttributes(std::move(attributes));
builder->setRawVertices({}, resources->particleCoverageVertices->elements(), gfx::AttributeDataType::Short2);
builder->setSegments(gfx::Triangles(),
resources->particleCoverageIndices,
resources->particleCoverageSegments.data(),
resources->particleCoverageSegments.size());
builder->setTexture(previousStateTexture, shaders::idParticleGpuValidationPreviousTexture);
builder->setTexture(currentStateTexture, shaders::idParticleGpuValidationStateTexture);
builder->addTweaker(std::make_shared<ParticleGpuValidationTweaker>(resources,
ValidationPass::TrailParticleCoverage,
stateSlot,
trailSlot,
previousStateTexture,
target,
resources->maskTexture,
currentStateTexture,
0.0f,
0.0f,
VisualSimulationSecondsPerRealSecond,
0,
0));
builder->flush(context);
auto drawables = builder->clearDrawables();
if (drawables.size() != 1 || !drawables.front()) {
Log::Error(Event::Render, "ParticleGPU: event=PARTICLE_COVERAGE_REJECTED reason=DRAWABLE_FINALIZE_FAILED");
return false;
}
group.addDrawable(std::move(drawables.front()));
return true;
}
} // namespace } // namespace
RenderRasterParticleLayer::RenderRasterParticleLayer(Immutable<style::Layer::Impl> impl_) RenderRasterParticleLayer::RenderRasterParticleLayer(Immutable<style::Layer::Impl> impl_)
@@ -1704,6 +1801,21 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
resources->trailTargets[trailWrite]->getTexture(), resources->trailTargets[trailWrite]->getTexture(),
stateWrite, stateWrite,
trailWrite); trailWrite);
#if MLN_RENDER_BACKEND_METAL
const bool endpointCoverageBuilt = !resources->formalDisplay || resources->trailCameraInvalidated ||
buildParticleEndpointCoverage(
context,
resources,
trailGroup,
getID() + "-static-bootstrap-trail-particle-endpoints",
resources->stateTargets[resources->stateRead]->getTexture(),
resources->stateTargets[stateWrite]->getTexture(),
resources->trailTargets[trailWrite]->getTexture(),
stateWrite,
trailWrite);
#else
const bool endpointCoverageBuilt = true;
#endif
compositeGroup.clearDrawables(); compositeGroup.clearDrawables();
bool compositeBuilt = true; bool compositeBuilt = true;
const auto center = transformState.getLatLng(LatLng::Unwrapped); const auto center = transformState.getLatLng(LatLng::Unwrapped);
@@ -1728,7 +1840,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
false) && false) &&
compositeBuilt; compositeBuilt;
} }
if (!stateBuilt || !trailBuilt || !particlesBuilt || !compositeBuilt) { if (!stateBuilt || !trailBuilt || !particlesBuilt || !endpointCoverageBuilt || !compositeBuilt) {
compositeGroup.clearDrawables(); compositeGroup.clearDrawables();
return; return;
} }
@@ -1871,6 +1983,20 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
resources->trailTargets[trailWrite]->getTexture(), resources->trailTargets[trailWrite]->getTexture(),
stateWrite, stateWrite,
trailWrite); trailWrite);
#if MLN_RENDER_BACKEND_METAL
const bool endpointCoverageBuilt = !resources->formalDisplay || resources->trailCameraInvalidated ||
buildParticleEndpointCoverage(context,
resources,
trailGroup,
getID() + "-trail-particle-endpoints",
resources->stateTargets[resources->stateRead]->getTexture(),
resources->stateTargets[stateWrite]->getTexture(),
resources->trailTargets[trailWrite]->getTexture(),
stateWrite,
trailWrite);
#else
const bool endpointCoverageBuilt = true;
#endif
compositeGroup.clearDrawables(); compositeGroup.clearDrawables();
bool compositeBuilt = true; bool compositeBuilt = true;
const auto center = transformState.getLatLng(LatLng::Unwrapped); const auto center = transformState.getLatLng(LatLng::Unwrapped);
@@ -1895,7 +2021,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
false) && false) &&
compositeBuilt; compositeBuilt;
} }
if (!stateBuilt || !trailBuilt || !particlesBuilt || !compositeBuilt) { if (!stateBuilt || !trailBuilt || !particlesBuilt || !endpointCoverageBuilt || !compositeBuilt) {
compositeGroup.clearDrawables(); compositeGroup.clearDrawables();
return; return;
} }

View File

@@ -9,12 +9,14 @@
#include <mbgl/shaders/program_parameters.hpp> #include <mbgl/shaders/program_parameters.hpp>
#include <mbgl/shaders/shader_manifest.hpp> #include <mbgl/shaders/shader_manifest.hpp>
#include <mbgl/util/logging.hpp> #include <mbgl/util/logging.hpp>
#include <mbgl/util/hash.hpp>
#include <Metal/MTLLibrary.hpp> #include <Metal/MTLLibrary.hpp>
#include <Metal/MTLRenderPass.hpp> #include <Metal/MTLRenderPass.hpp>
#include <Metal/MTLRenderPipeline.hpp> #include <Metal/MTLRenderPipeline.hpp>
#include <cstring> #include <cstring>
#include <type_traits>
#include <utility> #include <utility>
#include <algorithm> #include <algorithm>
@@ -84,12 +86,6 @@ MTLRenderPipelineStatePtr ShaderProgram::getRenderPipelineState(const gfx::Rende
const MTLVertexDescriptorPtr& vertexDescriptor, const MTLVertexDescriptorPtr& vertexDescriptor,
const gfx::ColorMode& colorMode, const gfx::ColorMode& colorMode,
const std::optional<std::size_t> reuseHash) const { const std::optional<std::size_t> reuseHash) const {
if (reuseHash.has_value()) {
// we'd like to reuse a previous value
if (auto it = renderPipelineStateCache.find(reuseHash.value()); it != renderPipelineStateCache.end())
return it->second;
}
auto pool = NS::TransferPtr(NS::AutoreleasePool::alloc()->init()); auto pool = NS::TransferPtr(NS::AutoreleasePool::alloc()->init());
const auto& renderableResource = renderable.getResource<RenderableResource>(); const auto& renderableResource = renderable.getResource<RenderableResource>();
@@ -115,6 +111,23 @@ MTLRenderPipelineStatePtr ShaderProgram::getRenderPipelineState(const gfx::Rende
} }
} }
std::optional<std::size_t> cacheKey;
if (reuseHash.has_value()) {
cacheKey = reuseHash.value();
mbgl::util::hash_combine(*cacheKey, static_cast<std::underlying_type_t<MTL::PixelFormat>>(colorFormat));
mbgl::util::hash_combine(
*cacheKey,
static_cast<std::underlying_type_t<MTL::PixelFormat>>(
depthFormat.value_or(MTL::PixelFormat::PixelFormatInvalid)));
mbgl::util::hash_combine(
*cacheKey,
static_cast<std::underlying_type_t<MTL::PixelFormat>>(
stencilFormat.value_or(MTL::PixelFormat::PixelFormatInvalid)));
if (auto it = renderPipelineStateCache.find(*cacheKey); it != renderPipelineStateCache.end()) {
return it->second;
}
}
auto desc = NS::TransferPtr(MTL::RenderPipelineDescriptor::alloc()->init()); auto desc = NS::TransferPtr(MTL::RenderPipelineDescriptor::alloc()->init());
desc->setLabel(NS::String::string(shaderName.data(), NS::UTF8StringEncoding)); desc->setLabel(NS::String::string(shaderName.data(), NS::UTF8StringEncoding));
desc->setVertexFunction(vertexFunction.get()); desc->setVertexFunction(vertexFunction.get());
@@ -171,9 +184,9 @@ MTLRenderPipelineStatePtr ShaderProgram::getRenderPipelineState(const gfx::Rende
assert(false); assert(false);
} }
if (reuseHash.has_value()) { if (cacheKey.has_value()) {
// store the value for future reuse // Pipeline attachment formats are part of Metal pipeline compatibility.
renderPipelineStateCache[reuseHash.value()] = rps; renderPipelineStateCache[*cacheKey] = rps;
} }
return rps; return rps;

View File

@@ -817,3 +817,11 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2`
- Test Host 的 8 个 V4 QBIN Golden Fixture(Wind/Wave/Current/Rain,各 T0/T1)已由用户人工确认功能正常;V4 调用经 `submitEncodedField` 进入 `RasterArraySource::submitEncodedField` / `QbinV2Decoder`,不是 JS 解码。 - Test Host 的 8 个 V4 QBIN Golden Fixture(Wind/Wave/Current/Rain,各 T0/T1)已由用户人工确认功能正常;V4 调用经 `submitEncodedField` 进入 `RasterArraySource::submitEncodedField` / `QbinV2Decoder`,不是 JS 解码。
- 当前入口文档已整理:`docs/00_项目总则/当前基线与开发路线_v2.md`、`docs/50_Step记录/STEP-080_Android_AAR与RNTestHost标准构建流水线_v2.md`、`docs/70_发布/Android_RN_QBINV4_13.4.1-particle.3_发布基线_v1.md`。旧 STEP-060~072 与 STEP-080 v1 保留历史事实,但不再作为当前构建命令来源。 - 当前入口文档已整理:`docs/00_项目总则/当前基线与开发路线_v2.md`、`docs/50_Step记录/STEP-080_Android_AAR与RNTestHost标准构建流水线_v2.md`、`docs/70_发布/Android_RN_QBINV4_13.4.1-particle.3_发布基线_v1.md`。旧 STEP-060~072 与 STEP-080 v1 保留历史事实,但不再作为当前构建命令来源。
# STEP-081(2026-09-17):新增 Metal `ParticleGpuValidationShader` 与 iOS Test App Wind QBIN V4 private smoke 入口。Codex 后已完成 GPT/RDC 独立静态审查;RDC 随后完成 iPhoneOS arm64 无签名 Test App 编译并取得 `BUILD SUCCEEDED`,`particle_gpu_validation.cpp` 与 `MBXViewController.mm` 均通过,61 MB app bundle 内 T0/T1 QBIN SHA-256 与源 fixture 一致。当前真机均 unavailable,签名安装、Metal runtime compile 与 T0/MID/T1/Clear 真机 Smoke 待执行,状态 WAITING_DEVICE_SMOKE。 # STEP-081(2026-09-17):新增 Metal `ParticleGpuValidationShader` 与 iOS Test App Wind QBIN V4 private smoke 入口。Codex 后已完成 GPT/RDC 独立静态审查;RDC 随后完成 iPhoneOS arm64 无签名 Test App 编译并取得 `BUILD SUCCEEDED`,`particle_gpu_validation.cpp` 与 `MBXViewController.mm` 均通过,61 MB app bundle 内 T0/T1 QBIN SHA-256 与源 fixture 一致。当前真机均 unavailable,签名安装、Metal runtime compile 与 T0/MID/T1/Clear 真机 Smoke 待执行,状态 WAITING_DEVICE_SMOKE。
### 2026-09-18 STEP-081 iOS Metal Renderer 真机最终关闭
- Pleiades(iPad mini 5 / iPadOS 26.5.2)完成签名安装与 Metal 真机验收。Deterministic Uniform East 向东、Uniform North 向北、Vortex 粒子可见且连续有轨迹;QBIN Wind Golden T0/MID/T1/Clear/重进均通过,STEP-081 CLOSED / PASS。
- 真机确认 Metal render pipeline cache key 原先遗漏 color/depth/stencil attachment format,导致 State RGBA32Float、Trail RGBA8Unorm 等同 shader pipeline 可能错误复用;已把 attachment formats 合入 cache key。
- 正式 previous→current 线平均约 0.065 px、最大约 0.24 px,Metal line primitive 对亚像素长度无稳定 coverage;保留真实 line,并用 Metal-only 1 px current-endpoint quad 补 coverage,不改变 State、U/V、时间步或物理线长。
- Wind QBIN Golden fixture 范围为 112.5E..157.5E / 0..55.776573N;iOS private smoke 相机调整到约 27.8883N / 135E、zoom 3 后可正常观察。所有 STEP081 临时 readback/固定线/Point fallback 均已清除;错误 Point fallback 的整屏绿色结果未进入正式代码。