diff --git a/docs/30_设计/STEP-081_iOS_Metal_Renderer与真机Smoke入口设计_v1.md b/docs/30_设计/STEP-081_iOS_Metal_Renderer与真机Smoke入口设计_v1.md index 8702a29d7..e0d16c2e9 100644 --- a/docs/30_设计/STEP-081_iOS_Metal_Renderer与真机Smoke入口设计_v1.md +++ b/docs/30_设计/STEP-081_iOS_Metal_Renderer与真机Smoke入口设计_v1.md @@ -1,6 +1,6 @@ # 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 分支 | | `> 3.5` | Trail particle line draw | State read/write displacement line,respawn alpha 抑制跨屏线 | | `> 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。 @@ -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 来源复制,不创建第二种数据格式。 -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 收尾中继续重构。 diff --git a/docs/50_Step记录/STEP-081_iOS_Metal_Renderer与真机Smoke入口_v1.md b/docs/50_Step记录/STEP-081_iOS_Metal_Renderer与真机Smoke入口_v1.md index 20549934c..4d26949aa 100644 --- a/docs/50_Step记录/STEP-081_iOS_Metal_Renderer与真机Smoke入口_v1.md +++ b/docs/50_Step记录/STEP-081_iOS_Metal_Renderer与真机Smoke入口_v1.md @@ -1,6 +1,6 @@ # 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] 仅做静态检查;未执行 Jest。 - [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 调用。 -## 后续真机操作 +## 真机验收入口 -在 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) @@ -45,3 +47,16 @@ Codex 结束后由 RDC 读取完整 diff、设计文档和完成报告,GPT 独 - 编译中只对 Test App 做小范围修正:使用 `${CMAKE_SOURCE_DIR}/src` 暴露 private smoke 所需内部头;`weakSelf` 改为显式 `MBXViewController *`;fixture path 使用 `NSString *`。 - 最终 `app` target `BUILD SUCCEEDED`;`particle_gpu_validation.cpp` 和 `MBXViewController.mm` 均通过 arm64 编译。 - 当前登记 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。 diff --git a/docs/报告/STEP-081_iOS_Metal_Renderer与真机Smoke入口_RDC真机验收报告_v2.md b/docs/报告/STEP-081_iOS_Metal_Renderer与真机Smoke入口_RDC真机验收报告_v2.md new file mode 100644 index 000000000..e15c65d22 --- /dev/null +++ b/docs/报告/STEP-081_iOS_Metal_Renderer与真机Smoke入口_RDC真机验收报告_v2.md @@ -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,不纳入提交。 diff --git a/include/mbgl/mtl/render_pass.hpp b/include/mbgl/mtl/render_pass.hpp index 49ffbf10b..f582b9f32 100644 --- a/include/mbgl/mtl/render_pass.hpp +++ b/include/mbgl/mtl/render_pass.hpp @@ -35,10 +35,16 @@ public: /// Apply the given stencil reference value, if different from the current value 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); - /// Set the sampler for a texture binding + /// Set the sampler for a fragment texture binding. void setFragmentSamplerState(const MTLSamplerStatePtr&, int32_t location); /// Set the render pipeline state @@ -88,6 +94,8 @@ private: std::array, maxBinds> vertexBinds; std::array, maxBinds> fragmentBinds; + std::array vertexTextureBindings; + std::array vertexSamplerStates; std::array fragmentTextureBindings; std::array fragmentSamplerStates; diff --git a/include/mbgl/shaders/mtl/particle_gpu_validation.hpp b/include/mbgl/shaders/mtl/particle_gpu_validation.hpp index d76c3f3ac..e6a5ad059 100644 --- a/include/mbgl/shaders/mtl/particle_gpu_validation.hpp +++ b/include/mbgl/shaders/mtl/particle_gpu_validation.hpp @@ -65,21 +65,56 @@ FragmentStage vertex vertexMain(thread const VertexStage in [[stage_in]], texture2d nextField [[texture(3)]], texture2d state [[texture(4)]], sampler previousSampler [[sampler(0)]], sampler fieldSampler [[sampler(2)]], 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; - if (u.pass.x > 4.5) out.position=u.matrix*float4(pos*8192.0,0,1); - else if (u.pass.x < 1.5) out.position=float4(pos*2.0-1.0,0,1); - else if (u.pass.x > 3.5) { - 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); + if (u.pass.x > 5.5) { + float corner=glMod(pos.x,4.0); + float2 index=float2(floor(pos.x*.25),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; + 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; 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.valid=ok&&length(out.velocity)>0.00001&¤t.a>.5&&distance(old.rg,current.rg)>1e-9 ? 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); - } else out.position=u.mask.z>.5 ? float4(pos*2.0-1.0,0,1) : u.matrix*float4(pos*8192.0,0,1); + out.velocity=float2(advection.x,-advection.y); + 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); + } 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; } @@ -90,6 +125,7 @@ float4 fragment fragmentMain(FragmentStage in [[stage_in]], device const Particl texture2d previous [[texture(0)]], texture2d mask [[texture(1)]], texture2d field [[texture(2)]], texture2d nextField [[texture(3)]], texture2d state [[texture(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; + 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);} float4 old=previous.sample(previousSampler,in.uv); if(mode<.5){ diff --git a/platform/darwin/src/MLNStyleLayerManager.mm b/platform/darwin/src/MLNStyleLayerManager.mm index 7005c84ad..421e74db9 100644 --- a/platform/darwin/src/MLNStyleLayerManager.mm +++ b/platform/darwin/src/MLNStyleLayerManager.mm @@ -14,11 +14,18 @@ #import "MLNCustomDrawableStyleLayer_Private.h" +#include +#include +#include + #include namespace mbgl { LayerManagerDarwin::LayerManagerDarwin() { + addLayerTypeCoreOnly(std::make_unique()); + addLayerTypeCoreOnly(std::make_unique()); + addLayerTypeCoreOnly(std::make_unique()); #if defined(MBGL_LAYER_FILL_DISABLE_RUNTIME) addLayerTypeCoreOnly(std::make_unique()); #elif !defined(MBGL_LAYER_FILL_DISABLE_ALL) diff --git a/platform/ios/app/CMakeLists.txt b/platform/ios/app/CMakeLists.txt index 6f9aee974..44885debc 100644 --- a/platform/ios/app/CMakeLists.txt +++ b/platform/ios/app/CMakeLists.txt @@ -33,6 +33,7 @@ set_target_properties(app PROPERTIES MACOSX_BUNDLE YES MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_LIST_DIR}/Info.plist" XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "org.maplibre.app" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2" XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${DEVELOPMENT_TEAM_ID}" XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "iPhone Developer" @@ -51,6 +52,20 @@ add_custom_command(TARGET app POST_BUILD COMMAND ls "$" ) +# 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 + "$/ParticleCoreFixtures/${particle_fixture}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_SOURCE_DIR}/particle-fixtures/${particle_fixture}/metadata.json" + "$/ParticleCoreFixtures/${particle_fixture}/metadata.json" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_SOURCE_DIR}/particle-fixtures/${particle_fixture}/field.f32" + "$/ParticleCoreFixtures/${particle_fixture}/field.f32" + ) +endforeach() + # 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. add_custom_command(TARGET app POST_BUILD diff --git a/platform/ios/app/MBXViewController.mm b/platform/ios/app/MBXViewController.mm index 6ea2a09d8..b198c78eb 100644 --- a/platform/ios/app/MBXViewController.mm +++ b/platform/ios/app/MBXViewController.mm @@ -128,6 +128,7 @@ typedef NS_ENUM(NSInteger, MBXSettingsRuntimeStylingRows) { MBXSettingsRuntimeStylingCustomDrawableLayer, MBXSettingsRuntimeStylingAddFoursquarePOIsPMTiles, MBXSettingsRuntimeStylingHeatmapLayer, + MBXSettingsRuntimeStylingParticleCoreValidation, MBXSettingsRuntimeStylingParticleQbinV4Smoke }; @@ -255,6 +256,9 @@ CLLocationCoordinate2D randomWorldCoordinate(void) { @property (nonatomic) NSMutableArray *helperWindows; @property (nonatomic) NSMutableArray *contentInsetsOverlays; @property (nonatomic, copy) void (^locationBlock)(void); +- (void)showParticleCoreValidationMenu; +- (void)installParticleCoreValidationFixture:(NSString *)fixtureID label:(NSString *)label; +- (void)clearParticleCoreValidation; - (void)showParticleQbinV4SmokeMenu; - (void)installParticleQbinV4SmokeAtTime:(uint64_t)requestedTime label:(NSString *)label; - (void)clearParticleQbinV4Smoke; @@ -518,6 +522,7 @@ CLLocationCoordinate2D randomWorldCoordinate(void) { @"Add Custom Drawable Layer", @"Add FourSquare POIs PMTiles Layer", @"Add Earthquake Heatmap", + @"Particle Core Validation (Fixtures)", @"Particle / QBIN V4 Smoke (T0)" ]]; break; @@ -752,6 +757,9 @@ CLLocationCoordinate2D randomWorldCoordinate(void) { case MBXSettingsRuntimeStylingHeatmapLayer: [self addEarthquakeHeatmap]; break; + case MBXSettingsRuntimeStylingParticleCoreValidation: + [self showParticleCoreValidationMenu]; + break; case MBXSettingsRuntimeStylingParticleQbinV4Smoke: [self showParticleQbinV4SmokeMenu]; break; @@ -1178,6 +1186,132 @@ CLLocationCoordinate2D randomWorldCoordinate(void) { [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(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("particle-formal-fixture-source")); + auto *sourcePtr = static_cast(style.getSource("particle-formal-fixture-source")); + const auto bytes = [](NSData *data) { + const auto *begin = static_cast(data.bytes); + return std::vector(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( + "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) - (void)showParticleQbinV4SmokeMenu { @@ -1198,6 +1332,7 @@ CLLocationCoordinate2D randomWorldCoordinate(void) { - (void)installParticleQbinV4SmokeAtTime:(uint64_t)requestedTime label:(NSString *)label { [self clearParticleQbinV4Smoke]; + [self clearParticleCoreValidation]; NSString *(^fixturePath)(NSString *) = ^NSString *(NSString *name) { return [[NSBundle mainBundle] pathForResource:name ofType:@"qbin" inDirectory:@"ParticleSmokeFixtures"]; }; @@ -1248,6 +1383,9 @@ CLLocationCoordinate2D randomWorldCoordinate(void) { style.addSource(std::move(source)); style.addLayer(std::move(weather)); 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]; NSLog(@"ParticleQbinV4Smoke: event=FIELD_READY state=%@ requestedTime=%llu source=fixture.wind layers=weather-color,raster-particle", label, requestedTime); } diff --git a/src/mbgl/mtl/offscreen_texture.cpp b/src/mbgl/mtl/offscreen_texture.cpp index 71f9fa0c9..13fb8d947 100644 --- a/src/mbgl/mtl/offscreen_texture.cpp +++ b/src/mbgl/mtl/offscreen_texture.cpp @@ -134,11 +134,18 @@ private: OffscreenTexture::OffscreenTexture( Context& context, const Size size_, const gfx::TextureChannelDataType type, bool depth, bool stencil) - : gfx::OffscreenTexture(size, std::make_unique(context, size_, type, depth, stencil)) {} + : gfx::OffscreenTexture(size_, std::make_unique(context, size_, type, depth, stencil)) {} bool OffscreenTexture::isRenderable() { - assert(false); - return true; + try { + auto& texture = getResource().getTexture(); + if (!texture) return false; + auto* metalTexture = static_cast(texture.get()); + metalTexture->create(); + return metalTexture->getMetalTexture() != nullptr; + } catch (...) { + return false; + } } PremultipliedImage OffscreenTexture::readStillImage() { diff --git a/src/mbgl/mtl/render_pass.cpp b/src/mbgl/mtl/render_pass.cpp index cd9b1e818..7af045165 100644 --- a/src/mbgl/mtl/render_pass.cpp +++ b/src/mbgl/mtl/render_pass.cpp @@ -76,6 +76,8 @@ void RenderPass::resetState() { for (int i = 0; i < maxBinds; ++i) { vertexBinds[i].reset(); fragmentBinds[i].reset(); + vertexTextureBindings[i].reset(); + vertexSamplerStates[i].reset(); fragmentTextureBindings[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) { assert(0 <= location && location < maxBinds); if (0 <= location && location < maxBinds) { diff --git a/src/mbgl/mtl/texture2d.cpp b/src/mbgl/mtl/texture2d.cpp index 90dbd5bd4..1c7604bb7 100644 --- a/src/mbgl/mtl/texture2d.cpp +++ b/src/mbgl/mtl/texture2d.cpp @@ -257,6 +257,10 @@ void Texture2D::bind(RenderPass& renderPass, int32_t location) { 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.setFragmentSamplerState(metalSamplerState, location); diff --git a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp index 1af251749..b52e83535 100644 --- a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp +++ b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp @@ -130,6 +130,7 @@ enum class ValidationPass : uint8_t { Composite = 2, FrozenComposite = 3, TrailParticles = 4, + TrailParticleCoverage = 6, }; const char* slotName(const uint8_t slot) { @@ -390,9 +391,12 @@ public: std::shared_ptr maskFixture; std::shared_ptr> vertices; std::shared_ptr> particleVertices; + std::shared_ptr> particleCoverageVertices; gfx::IndexVectorBasePtr particleIndices; + gfx::IndexVectorBasePtr particleCoverageIndices; SegmentVector segments; SegmentVector particleSegments; + SegmentVector particleCoverageSegments; uint8_t stateWrite() const { return static_cast(1 - stateRead); } uint8_t trailWrite() const { return static_cast(1 - trailRead); } @@ -446,17 +450,23 @@ constexpr uint32_t MaxParticleVerticesPerSegment = std::numeric_limits void rebuildParticleDrawResources(const std::shared_ptr& resources) { auto vertices = std::make_shared>(); - // Each State texel is represented by a two-vertex line. Its endpoints are - // static texel indices; the vertex shader reads StateRead/StateWrite and - // projects the true previous-to-current displacement in GPU space. + auto coverageVertices = std::make_shared>(); vertices->reserve(resources->particleCount * 2); + coverageVertices->reserve(resources->particleCount * 4); + for (uint32_t particleIndex = 0; particleIndex < resources->particleCount; ++particleIndex) { const auto x = static_cast(particleIndex % resources->stateSize.width); const auto y = static_cast(particleIndex / resources->stateSize.width); - // 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. + // Formal line path: x*2 + endpoint (0=previous, 1=current). vertices->emplace_back(HeatmapBucket::textureVertex({static_cast(x * 2), y})); vertices->emplace_back(HeatmapBucket::textureVertex({static_cast(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(x * 4 + corner), y})); + } } auto indices = std::make_shared(); @@ -465,15 +475,41 @@ void rebuildParticleDrawResources(const std::shared_ptr& r indices->extend(1, static_cast(index)); } + constexpr uint32_t MaxCoverageVerticesPerSegment = MaxParticleVerticesPerSegment - (MaxParticleVerticesPerSegment % 4u); + constexpr uint32_t MaxCoverageParticlesPerSegment = MaxCoverageVerticesPerSegment / 4u; + auto coverageIndices = std::make_shared(); + coverageIndices->reserve(MaxCoverageParticlesPerSegment * 6u); + for (uint32_t particle = 0; particle < MaxCoverageParticlesPerSegment; ++particle) { + const auto base = static_cast(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; - const auto vertexCount = resources->particleCount * 2; - for (uint32_t offset = 0; offset < vertexCount; offset += MaxParticleVerticesPerSegment) { - const auto count = std::min(vertexCount - offset, MaxParticleVerticesPerSegment); + const auto lineVertexCount = resources->particleCount * 2u; + for (uint32_t offset = 0; offset < lineVertexCount; offset += MaxParticleVerticesPerSegment) { + const auto count = std::min(lineVertexCount - offset, MaxParticleVerticesPerSegment); 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->particleCoverageVertices = std::move(coverageVertices); resources->particleIndices = std::move(indices); + resources->particleCoverageIndices = std::move(coverageIndices); resources->particleSegments = std::move(segments); + resources->particleCoverageSegments = std::move(coverageSegments); } class ParticleGpuValidationTweaker final : public gfx::DrawableTweaker { @@ -598,11 +634,11 @@ public: .fieldScale = {resources->fieldScale, resources->nextFieldScale, resources->viewportDensityActive ? 1.0f : 0.0f, - 0.0f}, + static_cast(resources->trailSize.width)}, .advection = {resources->field.vectorSemantics == particle::VectorSemantics::Direction ? 1.0f : 0.0f, resources->advectionScale, 0.00001f, - 0.0f}, + static_cast(resources->trailSize.height)}, .particleColor = resources->particleColor, .trailColor = resources->trailColor, .weatherStops = {}, @@ -920,6 +956,67 @@ bool buildParticlePoints(gfx::Context& context, return true; } +bool buildParticleEndpointCoverage(gfx::Context& context, + const std::shared_ptr& 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(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 RenderRasterParticleLayer::RenderRasterParticleLayer(Immutable impl_) @@ -1704,6 +1801,21 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources->trailTargets[trailWrite]->getTexture(), stateWrite, 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(); bool compositeBuilt = true; const auto center = transformState.getLatLng(LatLng::Unwrapped); @@ -1728,7 +1840,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, false) && compositeBuilt; } - if (!stateBuilt || !trailBuilt || !particlesBuilt || !compositeBuilt) { + if (!stateBuilt || !trailBuilt || !particlesBuilt || !endpointCoverageBuilt || !compositeBuilt) { compositeGroup.clearDrawables(); return; } @@ -1871,6 +1983,20 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, resources->trailTargets[trailWrite]->getTexture(), stateWrite, 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(); bool compositeBuilt = true; const auto center = transformState.getLatLng(LatLng::Unwrapped); @@ -1895,7 +2021,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, false) && compositeBuilt; } - if (!stateBuilt || !trailBuilt || !particlesBuilt || !compositeBuilt) { + if (!stateBuilt || !trailBuilt || !particlesBuilt || !endpointCoverageBuilt || !compositeBuilt) { compositeGroup.clearDrawables(); return; } diff --git a/src/mbgl/shaders/mtl/shader_program.cpp b/src/mbgl/shaders/mtl/shader_program.cpp index 778d318c4..8fef83577 100644 --- a/src/mbgl/shaders/mtl/shader_program.cpp +++ b/src/mbgl/shaders/mtl/shader_program.cpp @@ -9,12 +9,14 @@ #include #include #include +#include #include #include #include #include +#include #include #include @@ -84,12 +86,6 @@ MTLRenderPipelineStatePtr ShaderProgram::getRenderPipelineState(const gfx::Rende const MTLVertexDescriptorPtr& vertexDescriptor, const gfx::ColorMode& colorMode, const std::optional 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()); const auto& renderableResource = renderable.getResource(); @@ -115,6 +111,23 @@ MTLRenderPipelineStatePtr ShaderProgram::getRenderPipelineState(const gfx::Rende } } + std::optional cacheKey; + if (reuseHash.has_value()) { + cacheKey = reuseHash.value(); + mbgl::util::hash_combine(*cacheKey, static_cast>(colorFormat)); + mbgl::util::hash_combine( + *cacheKey, + static_cast>( + depthFormat.value_or(MTL::PixelFormat::PixelFormatInvalid))); + mbgl::util::hash_combine( + *cacheKey, + static_cast>( + 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()); desc->setLabel(NS::String::string(shaderName.data(), NS::UTF8StringEncoding)); desc->setVertexFunction(vertexFunction.get()); @@ -171,9 +184,9 @@ MTLRenderPipelineStatePtr ShaderProgram::getRenderPipelineState(const gfx::Rende assert(false); } - if (reuseHash.has_value()) { - // store the value for future reuse - renderPipelineStateCache[reuseHash.value()] = rps; + if (cacheKey.has_value()) { + // Pipeline attachment formats are part of Metal pipeline compatibility. + renderPipelineStateCache[*cacheKey] = rps; } return rps; diff --git a/粒子开发记录.md b/粒子开发记录.md index 220f83383..2b0f0b3dc 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -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 解码。 - 当前入口文档已整理:`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。 + + +### 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 的整屏绿色结果未进入正式代码。