事情得从一次选型争论说起。作为创新研究的踩地雷专业户虾神准备探索下一代桌面的地理信息系统渲染技术技术选型的时候经历了一系列的纠结。用 Cpp/QT 吧老兵不死但日渐凋零其实还是因为我不会虽然目前主流的GIS都是例如QGIS 就是例子。用 Electron 吧内存消耗先不说JS的渲染性能能让你怀疑人生打开一个空窗口先吃你 500MB我就破笔记本跑起来CPU风扇响得跟锯木头似的。用 Flutter 吧就更不用说了性能好是好了但是桌面端还是后妈养的踩坑得自己填。这时候突然想起我这段时间写Rust直接从VSCode转移到了Zed上面它本身就是用 Rust 写的启动快得离谱它底层那套 UI 框架叫 GPUI据说渲染性能很猛。GPUI 简介GPUI全称GPU-accelerated UI frameworkGPU 加速 UI 框架,听这个名字就感觉到渲染肯定是他的基本功。 它是Zed Industries高性能代码编辑器 Zed 的开发商于 2025 年 10 月 v0.2.0 正式开源的GUI库此前为 Zed 内部框架。它的设计目标就是替代 Electron解决高内存、启动慢、渲染卡顿问题致力于使用用 Rust GPU 渲染实现60fps 流畅交互。他的渲染引擎是基于 wgpuWebGPU 的 Rust 绑定底层砍掉了Web 中间层直接进行 GPU 渲染性能接近底层原生应用。但是——这玩意儿刚刚出到0.2版本还没有进入生产可用版后面API改动的幅度可能还挺大。他本来说用来做IDE的从来没人用 GPUI 做过地图渲染一个都没有。文档里也找不到 polygon、tile、map 这些关键词。所以就有了这么个想法要不先写个小 demo看看 GPUI 的地图渲染能力到底行不行。至于数据嘛本来shapefile是最好的但是为了减少依赖不考虑其他包而GeoJSON 结构简单直接用来作为测试数据源正合适。说干就干。架构整个测试项目分两层。底层是功能性代码跟 UI 无关读文件、解析坐标、几何计算、多边形三角剖分。上层是 GPUI 渲染负责把几何数据画到屏幕上。为了分工清楚我把功能代码放到util.rs渲染逻辑留在main.rs。依赖极简就两个 crate。GPUI 负责窗口管理和 Canvas 渲染serde_json 负责解析 GeoJSON。如果一个 UI 框架连最基本的路径绘制都做不好那地图引擎也就无从谈起了。[package] name gpui-demo version 0.1.0 edition 2024 [dependencies] gpui 0.2.2 serde_json 1.0.150数据GeoJSON 的全称是 JavaScript Object Notation for Geographic Features说白了就是用 JSON 表示地理要素。结构很直观一个 FeatureCollection 里面装了一堆 Feature每个 Feature 有属性properties和几何形状geometry。几何形状我选用的是面要素这种复杂类型比较容易测出效果和性能来特别是还有麻烦的MultiPolygon。选择 GeoJSON 作为测试数据主要原因是结构简单解析成本低不依赖第三方 GIS 库。因为没有使用geo-types、geo等已经封装好的数据库结构库所以我们还得自己定义下数据结构// // 数据结构定义 // /// 一个多边形环外环或内环。 /// 每个 Ring 由一系列 (经度, 纬度) 坐标对组成首尾坐标相同自闭合。 /// - 外环outer ring定义多边形外部边界应为顺时针CW绕行。 /// - 内环inner ring定义多边形内部孔洞应为逆时针CCW绕行。 pub type Ring Vec(f64, f64); /// 一个多边形包含一个外环和零个或多个内环孔洞。 /// - rings[0]外环outer ring定义多边形的主体边界 /// - rings[1..]内环inner rings定义多边形内部的孔洞区域 #[derive(Clone)] pub struct PolygonData { pub rings: VecRing, } /// 一个地理要素对应 GeoJSON FeatureCollection 中的一个 Feature。 /// 例如一个城市或行政区包含名称和其几何形状。 /// 一个要素可以拥有多个多边形部件MultiPolygon 结构。 #[derive(Clone)] pub struct Feature { pub name: String, pub polygons: VecPolygonData, } /// 解析后的 GeoJSON 数据包含要素列表和全局包围盒。 /// 作为 MapView 的数据源与 GPUI 渲染逻辑分离。 #[derive(Clone)] pub struct GeoData { pub features: VecFeature, pub geobounds: (f64, f64, f64, f64), }GeoJSON 解析用 serde_json 的Value类型做无结构解析不需要定义反序列化结构体灵活且代码量少。核心逻辑是判断 geometry 类型后逐层剥开 coordinates 数组// ── 解析 FeatureCollection ── if let Some(features_arr) val[features].as_array() { for feature_val in features_arr { let name feature_val[properties][name] .as_str().unwrap_or(unknown).to_string(); let geom feature_val[geometry]; let geom_type geom[type].as_str().unwrap_or(); let mut polygons Vec::new(); match geom_type { MultiPolygon { if let Some(polygons_arr) geom[coordinates].as_array() { for poly_val in polygons_arr { let mut rings Vec::new(); if let Some(rings_arr) poly_val.as_array() { for ring_val in rings_arr { rings.push(parse_ring(ring_val)); } } polygons.push(PolygonData { rings }); } } } Polygon { if let Some(rings_arr) geom[coordinates].as_array() { let mut rings Vec::new(); for ring_val in rings_arr { rings.push(parse_ring(ring_val)); } polygons.push(PolygonData { rings }); } } _ {} } features.push(Feature { name, polygons }); } } let geobounds compute_geobounds(features); GeoData { features, geobounds }嵌套数组的深度取决于几何类型Polygon 是三层嵌套[[[...]]]MultiPolygon 是四层[[[[...]]]]。每一层都有对应的语义解析的时候需要搞清楚当前层代表的是要素、多边形还是环。投影地理坐标是球面上的经纬度屏幕坐标是平面上的像素。要把前者映射到后者需要一次简单的线性变换。先用全局包围盒算出经纬度范围再根据 Canvas 的宽高确定缩放比例。为了让地图周围留白缩放时只用了可用空间的 85%。纬度的 Y 轴需要取反因为屏幕的原点在左上角Y 轴向下递增而地理纬度向上递增。如果不做这个反转画出来的地图就是上下颠倒的。/// 将地理坐标WGS84 经纬度投影到屏幕像素坐标。 /// /// 投影原理 /// 1. 计算全局包围盒的跨度 (range_x, range_y) /// 2. 根据 canvas 尺寸 85% 边距计算缩放比例 /// 3. 以地理中心为原点归一化坐标乘以 scale /// 4. 加上 canvas 中心偏移得到屏幕像素位置 /// 5. 纬度 Y 轴取反屏幕 Y 轴向下地理纬度向上 pub fn geo_to_screen( lon: f64, lat: f64, geobounds: (f64, f64, f64, f64), canvas_size: SizePixels, ) - (f64, f64) { let (min_x, max_x, min_y, max_y) geobounds; let range_x (max_x - min_x).max(1.0); let range_y (max_y - min_y).max(1.0); let w canvas_size.width.to_f64(); let h canvas_size.height.to_f64(); let scale_x w / range_x * 0.85; let scale_y h / range_y * 0.85; let scale scale_x.min(scale_y); let geo_cx (min_x max_x) / 2.0; let geo_cy (min_y max_y) / 2.0; let sx w / 2.0 (lon - geo_cx) * scale; let sy h / 2.0 - (lat - geo_cy) * scale; (sx, sy) }包围盒的计算就是遍历所有坐标找到四个极值。这个计算只在启动时做一次后续的投影计算是 O(1) 的不会成为性能瓶颈。地理行政区划有大量的要素都是由巨多个点构成的例如内蒙的呼伦贝尔市的边界有超过 1500 个坐标点在做投影计算的时候要在 f64 精度下完成才能保证地理坐标到屏幕坐标的精确映射这一块计算量很大后期可以考虑改成并行计算来实现这里因为是做Demo所以暂时暴力串行。剖分GPUI本身不具备基本的渲染API所以要进行绘制依赖的是paint_path方法这是类似于web里面canvas、svg的技术路线。最直接的做法是把多边形所有的点连成一个闭合路径然后交给 paint_path 去填充。这是最简单的方法但也最容易出问题。第一个问题来了……GPUI的底层用 “三角扇形Triangle Fan” 填充因为2D UI 形状天然适配顶点复用极致省内存wgpu 渲染管线友好且速度快三角扇形TRIANGLE_FAN是 GPU 的一种基础图元第一个顶点是公共中心点后续每两个点和中心连成一个三角形n 个顶点 → n−2 个三角形全部共享中心顶点使用三角扇形除了完美适配 UI 的凸多边形以外它特别是在渲染圆形、椭圆、圆弧、圆角、渐变这种无限逼近的形状的时候能够平滑度可控且可以自动插值让它能代码极简、性能极高。在于 GPUI 底层用的是三角扇形填充——从第一个顶点出发连接所有其他顶点。对于凸多边形这么做没问题。但对于城市边界这种典型的凹多边形扇形三角形会延伸到多边形外部覆盖到相邻的区域。下面是用山西市级行政区划绘制边界就是个很好的例子用扇形填充会溢出到隔壁的地盘上。而且如果自己把多边形切成三角形每个三角形单独画结果出现了一堆白色细线。原因是相邻三角形的共享边在抗锯齿渲染时产生了半透明叠加形成了接缝。仔细看下图后来在 GPUI 的PathAPI 文档里找到了move_to方法。这个方法抬笔移动不画线。把所有三角形放在同一个 Path 里用move_to分隔一次 fill 完成全部填充// ── 外环三角剖分 move_to 合并路径 ── let tris triangulate(outer); if !tris.is_empty() { let t0 tris[0]; let mut fill_path Path::new(to_screen(outer[t0[0]])); fill_path.line_to(to_screen(outer[t0[1]])); fill_path.line_to(to_screen(outer[t0[2]])); for tri in tris[1..] { fill_path.move_to(to_screen(outer[tri[0]])); fill_path.line_to(to_screen(outer[tri[1]])); fill_path.line_to(to_screen(outer[tri[2]])); } result.push((fill_path, format!(fill:{}, city_idx))); }然后为解决凹多边形的问题改进了一下剖分算法这里在AI的帮助下搞了一个耳切法我以前写过类似的但是也是这次知道这种算法叫做耳切法耳切法核心理论是双耳定理——任何简单多边形至少有两个耳朵。算法循环找耳朵、切除、继续直到只剩三个点。判断耳朵需要两个条件顶点是凸的叉积符号与绕行方向一致三角形内不包含其他顶点。/// 耳切法三角剖分将多边形环分解为三角形列表。 /// /// 参数 /// ring: 多边形顶点列表首尾相同末位是重复点 /// 返回值 /// Vec[usize; 3] — 三角形顶点索引数组每个三角形用三个索引表示 /// /// 用途 /// 返回的三角形可用 move_to 合并为单一 Path避免接缝 /// 也可单独渲染每个三角形。 pub fn triangulate(ring: Ring) - Vec[usize; 3] { // ── 去掉末尾重复点GeoJSON 规范要求首尾相同── let end if ring.len() 1 ring[0] ring[ring.len() - 1] { ring.len() - 1 } else { ring.len() }; if end 3 { return vec![]; } let mut indices: Vecusize (0..end).collect(); let mut triangles Vec::new(); // ── 有向面积判断绕行方向 ── let mut area 0.0; for i in 0..end { let j (i 1) % end; area ring[i].0 * ring[j].1; area - ring[j].0 * ring[i].1; } let is_ccw area 0.0; // ── 循环切耳 ── while indices.len() 3 { let len indices.len(); let mut ear_found false; for i in 0..len { let prev ring[indices[(i len - 1) % len]]; let curr ring[indices[i]]; let next ring[indices[(i 1) % len]]; // 凸性检查叉积符号须与绕行方向一致 let cross cross_2d(prev, curr, next); if is_ccw cross 0.0 { continue; } if !is_ccw cross 0.0 { continue; } // 检查三角形内是否包含其他顶点 let mut is_ear true; for idx in indices { if idx indices[(i len - 1) % len] || idx indices[i] || idx indices[(i 1) % len] { continue; } if point_in_triangle(ring[idx], prev, curr, next) { is_ear false; break; } } if is_ear { triangles.push([indices[(i len - 1) % len], indices[i], indices[(i 1) % len]]); indices.remove(i); ear_found true; break; } } // ── 强制切耳容错── if !ear_found { let mut best_i 0; let mut best_cross f64::MAX; for i in 0..indices.len() { let prev ring[indices[(i indices.len() - 1) % indices.len()]]; let curr ring[indices[i]]; let next ring[indices[(i 1) % indices.len()]]; let cross cross_2d(prev, curr, next).abs(); if cross best_cross { best_cross cross; best_i i; } } triangles.push([ indices[(best_i indices.len() - 1) % indices.len()], indices[best_i], indices[(best_i 1) % indices.len()], ]); indices.remove(best_i); } } if indices.len() 3 { triangles.push([indices[0], indices[1], indices[2]]); } triangles }对于带孔洞的复杂多边形严格意义的耳朵可能不存在。算法会在这种情况下退一步选择最接近凸点的顶点叉积绝对值最小强制切除。这个容错机制确保了三角剖分不会因为几何退化而中断。辅助函数 cross_2d 计算三个点的叉积判断转向方向。point_in_triangle 用同侧法判断点是否在三角形内部即分别计算点相对于三条边的叉积如果三个符号相同则在内部。/// 计算二维叉积 (B-A) x (C-B)。 /// 正值 左转逆时针转向负值 右转顺时针转向。 fn cross_2d(a: (f64, f64), b: (f64, f64), c: (f64, f64)) - f64 { (b.0 - a.0) * (c.1 - b.1) - (b.1 - a.1) * (c.0 - b.0) } /// 判断点 P 是否在三角形 (A, B, C) 内部含边界。 /// 使用同侧法分别计算 P 相对于三条边的叉积 /// 若三个符号全正或全负则 P 在三角形内部。 fn point_in_triangle(p: (f64, f64), a: (f64, f64), b: (f64, f64), c: (f64, f64)) - bool { let d1 (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0); let d2 (c.0 - b.0) * (p.1 - b.1) - (c.1 - b.1) * (p.0 - b.0); let d3 (a.0 - c.0) * (p.1 - c.1) - (a.1 - c.1) * (p.0 - c.0); let has_neg d1 0.0 || d2 0.0 || d3 0.0; let has_pos d1 0.0 || d2 0.0 || d3 0.0; !(has_neg has_pos) }描边GPUI 没有提供专门的描边方法。好在官方示例里有个技巧创建一个细长的平行四边形路径用 fill 来模拟 stroke。对每条边判断它更接近水平还是垂直然后在垂直方向偏移 1px。// ── 外环逐边描边 ── // 为外环的每条边创建一条 1px 宽的薄条路径。 // 原理painting 示例中绘制线条的技巧—— // 沿线段创建一个细长的平行四边形用 fill 模拟 stroke。 // 对于水平边dx dy垂直偏移 1px // 对于垂直边dy dx水平偏移 1px。 let end outer.len() - 1; for i in 0..end - 1 { let a to_screen(outer[i]); let b to_screen(outer[i 1]); let diff_x (b.x - a.x).abs(); let diff_y (b.y - a.y).abs(); let (off_x, off_y) if diff_x diff_y { (px(0.0), px(1.0)) } else { (px(1.0), px(0.0)) }; let mut seg Path::new(a); seg.line_to(b); seg.line_to(point(b.x off_x, b.y off_y)); seg.line_to(point(a.x off_x, a.y off_y)); result.push((seg, String::from(stroke))); }比较大的面要素例如呼伦贝尔市的边界有超过 1500 个坐标点每帧要生成 1500 个薄条路径。更准确地说对于一条 N 个点的边界描边需要 N-1 条薄条。GPUI 是 GPU 加速的所以即使要素数量再多也能保持 60 帧。如果 GPUI 没有 GPU 加速这个方案根本跑不动。孔洞GeoJSON 规范规定多边形的外环是顺时针方向内环孔洞是逆时针方向。这是有意为之的——渲染器可以通过绕行方向来判断一个环是外边界还是内边界。内蒙古自治区的数据中就有带孔洞的要素比如呼和浩特市的主体边界内包含了其他行政飞地。处理方式是把内环也用三角剖分剖开然后用全不透明的白色填充从视觉上挖空。白色正好是背景色所以看起来就像是在城市填充色上挖了一个洞。如果三角剖分失败了一个复杂几何形状偶尔会出现这种情况后备方案是直接用单路径闭合填充。如果不处理这个问题的话就是这样的处理完之后就变成这样了// ── 处理内环孔洞── // GeoJSON 规范中内环应为逆时针CCW绕行。 // 用白色背景色填充内环区域在视觉上挖空孔洞。 for inner in polygon.rings.iter().skip(1) { if inner.len() 3 { continue; } let hole_tris triangulate(inner); let hole_path if !hole_tris.is_empty() { let h0 hole_tris[0]; let mut p Path::new(to_screen(inner[h0[0]])); p.line_to(to_screen(inner[h0[1]])); p.line_to(to_screen(inner[h0[2]])); for tri in hole_tris[1..] { p.move_to(to_screen(inner[tri[0]])); p.line_to(to_screen(inner[tri[1]])); p.line_to(to_screen(inner[tri[2]])); } p } else { // 后备单路径闭合填充 let mut p Path::new(to_screen(inner[0])); for coord in inner[1..inner.len() - 1] { p.line_to(to_screen(coord)); } p }; result.push((hole_path, String::from(hole))); }管线下面介绍GPU渲染的一个关键术语管线。GPU 渲染管线Graphics Pipeline就是指GPU 把 “3D/2D 数据” 变成 “屏幕上一张图” 的固定流水线工序。GPU 渲染管线 一串固定且可编程的标准化流程把顶点 → 三角形 → 像素一步步变成屏幕上的颜色。GPUI 的 canvas 提供了两个回调。Pre-paint 在布局完成后调用负责计算几何路径返回值会传给 paint。Paint 负责实际绘制根据路径标签设置不同颜色。这个双回调机制是 GPUI 的特色在布局阶段可以访问实际的像素尺寸从而精确控制渲染内容。最后就是标注了城市名称的标签用 GPUI 的 Div 元素做 absolute 定位而不是画在 canvas 上。这样做的好处是利用了 GPUI 的文本渲染能力不需要手动处理字体和排版。标签位置用多边形质心坐标计算质心直接简单的用是所有顶点的算术平均。注意真实是label 渲染凸多边形没啥问题但是凹多边形会导致质心跑到图形以外生产环境下需要使用基于有向面积的积分公式法也称为鞋带公式的推广或高斯公式法来计算这里为了省事就不去做了。另外文字的位置与label点的位置也要注意计算偏离不然容易和我演示的一样文字会跑出去……不过同上这里为了省事就不去做了。// 渲染部分的代码 impl Render for MapView { fn render(mut self, window: mut Window, _cx: mut ContextSelf) - impl IntoElement { let features self.data.features.clone(); let geobounds self.data.geobounds; let viewport window.viewport_size(); // ── 城市标签质心 → 屏幕坐标── let labels: Vec(f64, f64, String) features.iter().filter_map(|f| { f.polygons.first() .map(|poly| polygon_center(poly)) .map(|(cx, cy)| { let (sx, sy) geo_to_screen(cx, cy, geobounds, viewport); (sx, sy, f.name.clone()) }) }).collect(); let label_elements: VecDiv labels.into_iter().map(|(x, y, name)| { div().absolute().left(px(x as f32)).top(px(y as f32)).child(name) }).collect(); const FILL_COLORS: [u32; 11] [ 0x4A90D9, 0xE86C00, 0x8BC34A, 0xE83E3E, 0x9B59B6, 0x20B2AA, 0xF08529, 0x3C8DBC, 0x8E44AD, 0x27AE60, 0xD35400, ]; div().bg(white()).size_full().child( canvas( // ── Pre-paint 回调 ── move |bounds, _, _| - OptionVec(PathPixels, String) { if features.is_empty() { return None; } // ... 计算投影参数遍历要素构建路径列表 ... Some(result) }, // ── Paint 回调按标签类型绘制路径 ── move |_, paths, window, _| { if let Some(paths) paths { for (path, tag) in paths.iter() { match tag.as_str() { stroke window.paint_path(path.clone(), rgba(0x444444FF)), hole window.paint_path(path.clone(), rgba(0xFFFFFFFF)), _ if tag.starts_with(fill:) { let idx: usize tag[5..].parse().unwrap_or(0); let base FILL_COLORS[idx % FILL_COLORS.len()]; window.paint_path(path.clone(), rgba((base 8) | 0x88)); } _ {} } } } }, ).size_full(), ).children(label_elements) } }最后就是GPUI的入口函数了这个很简单创建 Application打开窗口创建 MapView 作为根视图。从文件加载数据如果加载失败就创建一个空的数据集。fn main() { Application::new().run(|cx: mut App| { cx.open_window(WindowOptions::default(), |_window, cx| { cx.new(|_cx| match MapView::from_geojson_file(data/中国_省.geojson) { Ok(view) { for f in view.data.features { println!( - {}, f.name); } view } Err(e) { eprintln!(读取文件失败: {}, e); MapView { data: util::parse_geojson_str({}) } } }) }).expect(failed to open window); }); }结论测试结果比较明确。GPUI 的 Canvas 渲染管线能够处理数千条路径的同时绘制保持 60 帧Path的move_to机制为复杂的矢量图形渲染提供了灵活的拼接能力。纯 Rust 的依赖栈意味着编译出来的程序启动快、内存占用低没有 JVM 或 V8 的开销。目前发现的两个限制。第一GPUI 没有内置的描边 API需要用薄条路径来模拟对于极端复杂的边界会生成大量临时路径。第二GPUI 对于 macOS 和 Linux 平台的支持比 Windows 要好明显感觉性能要高不少。总的来说对于需要高性能矢量图形渲染的桌面 GIS 工具GPUI 是一个值得关注的方向。渲染管线是 GPU 加速的Path API 足够灵活开发体验也不错——就是用 Rust 写 UI 的逻辑需要适应一下。后记文章的中的绝大部分部分代码超过90%都是AI直接写的我的作用就是告诉AI应该往那个方向走如下例如渲染呼伦贝尔的时候多部件渲染颜色出来问题我得告诉他怎么做又比如环形在GIS里面的特殊结构当然如果你不告诉他有可能多烧上几块十几块的token也有可能烧出来但是你只要给出解决方案AI解决起来那是嗷嗷的快。这篇文章是在我主动蒸馏了虾神博客写作风格的skills的帮助下完成的初稿我在初稿上做的修改完成的下面就虾神的赛博器灵我很多时候觉得我已经废了……
GPUI能不能做地图引擎?我测了一把
事情得从一次选型争论说起。作为创新研究的踩地雷专业户虾神准备探索下一代桌面的地理信息系统渲染技术技术选型的时候经历了一系列的纠结。用 Cpp/QT 吧老兵不死但日渐凋零其实还是因为我不会虽然目前主流的GIS都是例如QGIS 就是例子。用 Electron 吧内存消耗先不说JS的渲染性能能让你怀疑人生打开一个空窗口先吃你 500MB我就破笔记本跑起来CPU风扇响得跟锯木头似的。用 Flutter 吧就更不用说了性能好是好了但是桌面端还是后妈养的踩坑得自己填。这时候突然想起我这段时间写Rust直接从VSCode转移到了Zed上面它本身就是用 Rust 写的启动快得离谱它底层那套 UI 框架叫 GPUI据说渲染性能很猛。GPUI 简介GPUI全称GPU-accelerated UI frameworkGPU 加速 UI 框架,听这个名字就感觉到渲染肯定是他的基本功。 它是Zed Industries高性能代码编辑器 Zed 的开发商于 2025 年 10 月 v0.2.0 正式开源的GUI库此前为 Zed 内部框架。它的设计目标就是替代 Electron解决高内存、启动慢、渲染卡顿问题致力于使用用 Rust GPU 渲染实现60fps 流畅交互。他的渲染引擎是基于 wgpuWebGPU 的 Rust 绑定底层砍掉了Web 中间层直接进行 GPU 渲染性能接近底层原生应用。但是——这玩意儿刚刚出到0.2版本还没有进入生产可用版后面API改动的幅度可能还挺大。他本来说用来做IDE的从来没人用 GPUI 做过地图渲染一个都没有。文档里也找不到 polygon、tile、map 这些关键词。所以就有了这么个想法要不先写个小 demo看看 GPUI 的地图渲染能力到底行不行。至于数据嘛本来shapefile是最好的但是为了减少依赖不考虑其他包而GeoJSON 结构简单直接用来作为测试数据源正合适。说干就干。架构整个测试项目分两层。底层是功能性代码跟 UI 无关读文件、解析坐标、几何计算、多边形三角剖分。上层是 GPUI 渲染负责把几何数据画到屏幕上。为了分工清楚我把功能代码放到util.rs渲染逻辑留在main.rs。依赖极简就两个 crate。GPUI 负责窗口管理和 Canvas 渲染serde_json 负责解析 GeoJSON。如果一个 UI 框架连最基本的路径绘制都做不好那地图引擎也就无从谈起了。[package] name gpui-demo version 0.1.0 edition 2024 [dependencies] gpui 0.2.2 serde_json 1.0.150数据GeoJSON 的全称是 JavaScript Object Notation for Geographic Features说白了就是用 JSON 表示地理要素。结构很直观一个 FeatureCollection 里面装了一堆 Feature每个 Feature 有属性properties和几何形状geometry。几何形状我选用的是面要素这种复杂类型比较容易测出效果和性能来特别是还有麻烦的MultiPolygon。选择 GeoJSON 作为测试数据主要原因是结构简单解析成本低不依赖第三方 GIS 库。因为没有使用geo-types、geo等已经封装好的数据库结构库所以我们还得自己定义下数据结构// // 数据结构定义 // /// 一个多边形环外环或内环。 /// 每个 Ring 由一系列 (经度, 纬度) 坐标对组成首尾坐标相同自闭合。 /// - 外环outer ring定义多边形外部边界应为顺时针CW绕行。 /// - 内环inner ring定义多边形内部孔洞应为逆时针CCW绕行。 pub type Ring Vec(f64, f64); /// 一个多边形包含一个外环和零个或多个内环孔洞。 /// - rings[0]外环outer ring定义多边形的主体边界 /// - rings[1..]内环inner rings定义多边形内部的孔洞区域 #[derive(Clone)] pub struct PolygonData { pub rings: VecRing, } /// 一个地理要素对应 GeoJSON FeatureCollection 中的一个 Feature。 /// 例如一个城市或行政区包含名称和其几何形状。 /// 一个要素可以拥有多个多边形部件MultiPolygon 结构。 #[derive(Clone)] pub struct Feature { pub name: String, pub polygons: VecPolygonData, } /// 解析后的 GeoJSON 数据包含要素列表和全局包围盒。 /// 作为 MapView 的数据源与 GPUI 渲染逻辑分离。 #[derive(Clone)] pub struct GeoData { pub features: VecFeature, pub geobounds: (f64, f64, f64, f64), }GeoJSON 解析用 serde_json 的Value类型做无结构解析不需要定义反序列化结构体灵活且代码量少。核心逻辑是判断 geometry 类型后逐层剥开 coordinates 数组// ── 解析 FeatureCollection ── if let Some(features_arr) val[features].as_array() { for feature_val in features_arr { let name feature_val[properties][name] .as_str().unwrap_or(unknown).to_string(); let geom feature_val[geometry]; let geom_type geom[type].as_str().unwrap_or(); let mut polygons Vec::new(); match geom_type { MultiPolygon { if let Some(polygons_arr) geom[coordinates].as_array() { for poly_val in polygons_arr { let mut rings Vec::new(); if let Some(rings_arr) poly_val.as_array() { for ring_val in rings_arr { rings.push(parse_ring(ring_val)); } } polygons.push(PolygonData { rings }); } } } Polygon { if let Some(rings_arr) geom[coordinates].as_array() { let mut rings Vec::new(); for ring_val in rings_arr { rings.push(parse_ring(ring_val)); } polygons.push(PolygonData { rings }); } } _ {} } features.push(Feature { name, polygons }); } } let geobounds compute_geobounds(features); GeoData { features, geobounds }嵌套数组的深度取决于几何类型Polygon 是三层嵌套[[[...]]]MultiPolygon 是四层[[[[...]]]]。每一层都有对应的语义解析的时候需要搞清楚当前层代表的是要素、多边形还是环。投影地理坐标是球面上的经纬度屏幕坐标是平面上的像素。要把前者映射到后者需要一次简单的线性变换。先用全局包围盒算出经纬度范围再根据 Canvas 的宽高确定缩放比例。为了让地图周围留白缩放时只用了可用空间的 85%。纬度的 Y 轴需要取反因为屏幕的原点在左上角Y 轴向下递增而地理纬度向上递增。如果不做这个反转画出来的地图就是上下颠倒的。/// 将地理坐标WGS84 经纬度投影到屏幕像素坐标。 /// /// 投影原理 /// 1. 计算全局包围盒的跨度 (range_x, range_y) /// 2. 根据 canvas 尺寸 85% 边距计算缩放比例 /// 3. 以地理中心为原点归一化坐标乘以 scale /// 4. 加上 canvas 中心偏移得到屏幕像素位置 /// 5. 纬度 Y 轴取反屏幕 Y 轴向下地理纬度向上 pub fn geo_to_screen( lon: f64, lat: f64, geobounds: (f64, f64, f64, f64), canvas_size: SizePixels, ) - (f64, f64) { let (min_x, max_x, min_y, max_y) geobounds; let range_x (max_x - min_x).max(1.0); let range_y (max_y - min_y).max(1.0); let w canvas_size.width.to_f64(); let h canvas_size.height.to_f64(); let scale_x w / range_x * 0.85; let scale_y h / range_y * 0.85; let scale scale_x.min(scale_y); let geo_cx (min_x max_x) / 2.0; let geo_cy (min_y max_y) / 2.0; let sx w / 2.0 (lon - geo_cx) * scale; let sy h / 2.0 - (lat - geo_cy) * scale; (sx, sy) }包围盒的计算就是遍历所有坐标找到四个极值。这个计算只在启动时做一次后续的投影计算是 O(1) 的不会成为性能瓶颈。地理行政区划有大量的要素都是由巨多个点构成的例如内蒙的呼伦贝尔市的边界有超过 1500 个坐标点在做投影计算的时候要在 f64 精度下完成才能保证地理坐标到屏幕坐标的精确映射这一块计算量很大后期可以考虑改成并行计算来实现这里因为是做Demo所以暂时暴力串行。剖分GPUI本身不具备基本的渲染API所以要进行绘制依赖的是paint_path方法这是类似于web里面canvas、svg的技术路线。最直接的做法是把多边形所有的点连成一个闭合路径然后交给 paint_path 去填充。这是最简单的方法但也最容易出问题。第一个问题来了……GPUI的底层用 “三角扇形Triangle Fan” 填充因为2D UI 形状天然适配顶点复用极致省内存wgpu 渲染管线友好且速度快三角扇形TRIANGLE_FAN是 GPU 的一种基础图元第一个顶点是公共中心点后续每两个点和中心连成一个三角形n 个顶点 → n−2 个三角形全部共享中心顶点使用三角扇形除了完美适配 UI 的凸多边形以外它特别是在渲染圆形、椭圆、圆弧、圆角、渐变这种无限逼近的形状的时候能够平滑度可控且可以自动插值让它能代码极简、性能极高。在于 GPUI 底层用的是三角扇形填充——从第一个顶点出发连接所有其他顶点。对于凸多边形这么做没问题。但对于城市边界这种典型的凹多边形扇形三角形会延伸到多边形外部覆盖到相邻的区域。下面是用山西市级行政区划绘制边界就是个很好的例子用扇形填充会溢出到隔壁的地盘上。而且如果自己把多边形切成三角形每个三角形单独画结果出现了一堆白色细线。原因是相邻三角形的共享边在抗锯齿渲染时产生了半透明叠加形成了接缝。仔细看下图后来在 GPUI 的PathAPI 文档里找到了move_to方法。这个方法抬笔移动不画线。把所有三角形放在同一个 Path 里用move_to分隔一次 fill 完成全部填充// ── 外环三角剖分 move_to 合并路径 ── let tris triangulate(outer); if !tris.is_empty() { let t0 tris[0]; let mut fill_path Path::new(to_screen(outer[t0[0]])); fill_path.line_to(to_screen(outer[t0[1]])); fill_path.line_to(to_screen(outer[t0[2]])); for tri in tris[1..] { fill_path.move_to(to_screen(outer[tri[0]])); fill_path.line_to(to_screen(outer[tri[1]])); fill_path.line_to(to_screen(outer[tri[2]])); } result.push((fill_path, format!(fill:{}, city_idx))); }然后为解决凹多边形的问题改进了一下剖分算法这里在AI的帮助下搞了一个耳切法我以前写过类似的但是也是这次知道这种算法叫做耳切法耳切法核心理论是双耳定理——任何简单多边形至少有两个耳朵。算法循环找耳朵、切除、继续直到只剩三个点。判断耳朵需要两个条件顶点是凸的叉积符号与绕行方向一致三角形内不包含其他顶点。/// 耳切法三角剖分将多边形环分解为三角形列表。 /// /// 参数 /// ring: 多边形顶点列表首尾相同末位是重复点 /// 返回值 /// Vec[usize; 3] — 三角形顶点索引数组每个三角形用三个索引表示 /// /// 用途 /// 返回的三角形可用 move_to 合并为单一 Path避免接缝 /// 也可单独渲染每个三角形。 pub fn triangulate(ring: Ring) - Vec[usize; 3] { // ── 去掉末尾重复点GeoJSON 规范要求首尾相同── let end if ring.len() 1 ring[0] ring[ring.len() - 1] { ring.len() - 1 } else { ring.len() }; if end 3 { return vec![]; } let mut indices: Vecusize (0..end).collect(); let mut triangles Vec::new(); // ── 有向面积判断绕行方向 ── let mut area 0.0; for i in 0..end { let j (i 1) % end; area ring[i].0 * ring[j].1; area - ring[j].0 * ring[i].1; } let is_ccw area 0.0; // ── 循环切耳 ── while indices.len() 3 { let len indices.len(); let mut ear_found false; for i in 0..len { let prev ring[indices[(i len - 1) % len]]; let curr ring[indices[i]]; let next ring[indices[(i 1) % len]]; // 凸性检查叉积符号须与绕行方向一致 let cross cross_2d(prev, curr, next); if is_ccw cross 0.0 { continue; } if !is_ccw cross 0.0 { continue; } // 检查三角形内是否包含其他顶点 let mut is_ear true; for idx in indices { if idx indices[(i len - 1) % len] || idx indices[i] || idx indices[(i 1) % len] { continue; } if point_in_triangle(ring[idx], prev, curr, next) { is_ear false; break; } } if is_ear { triangles.push([indices[(i len - 1) % len], indices[i], indices[(i 1) % len]]); indices.remove(i); ear_found true; break; } } // ── 强制切耳容错── if !ear_found { let mut best_i 0; let mut best_cross f64::MAX; for i in 0..indices.len() { let prev ring[indices[(i indices.len() - 1) % indices.len()]]; let curr ring[indices[i]]; let next ring[indices[(i 1) % indices.len()]]; let cross cross_2d(prev, curr, next).abs(); if cross best_cross { best_cross cross; best_i i; } } triangles.push([ indices[(best_i indices.len() - 1) % indices.len()], indices[best_i], indices[(best_i 1) % indices.len()], ]); indices.remove(best_i); } } if indices.len() 3 { triangles.push([indices[0], indices[1], indices[2]]); } triangles }对于带孔洞的复杂多边形严格意义的耳朵可能不存在。算法会在这种情况下退一步选择最接近凸点的顶点叉积绝对值最小强制切除。这个容错机制确保了三角剖分不会因为几何退化而中断。辅助函数 cross_2d 计算三个点的叉积判断转向方向。point_in_triangle 用同侧法判断点是否在三角形内部即分别计算点相对于三条边的叉积如果三个符号相同则在内部。/// 计算二维叉积 (B-A) x (C-B)。 /// 正值 左转逆时针转向负值 右转顺时针转向。 fn cross_2d(a: (f64, f64), b: (f64, f64), c: (f64, f64)) - f64 { (b.0 - a.0) * (c.1 - b.1) - (b.1 - a.1) * (c.0 - b.0) } /// 判断点 P 是否在三角形 (A, B, C) 内部含边界。 /// 使用同侧法分别计算 P 相对于三条边的叉积 /// 若三个符号全正或全负则 P 在三角形内部。 fn point_in_triangle(p: (f64, f64), a: (f64, f64), b: (f64, f64), c: (f64, f64)) - bool { let d1 (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0); let d2 (c.0 - b.0) * (p.1 - b.1) - (c.1 - b.1) * (p.0 - b.0); let d3 (a.0 - c.0) * (p.1 - c.1) - (a.1 - c.1) * (p.0 - c.0); let has_neg d1 0.0 || d2 0.0 || d3 0.0; let has_pos d1 0.0 || d2 0.0 || d3 0.0; !(has_neg has_pos) }描边GPUI 没有提供专门的描边方法。好在官方示例里有个技巧创建一个细长的平行四边形路径用 fill 来模拟 stroke。对每条边判断它更接近水平还是垂直然后在垂直方向偏移 1px。// ── 外环逐边描边 ── // 为外环的每条边创建一条 1px 宽的薄条路径。 // 原理painting 示例中绘制线条的技巧—— // 沿线段创建一个细长的平行四边形用 fill 模拟 stroke。 // 对于水平边dx dy垂直偏移 1px // 对于垂直边dy dx水平偏移 1px。 let end outer.len() - 1; for i in 0..end - 1 { let a to_screen(outer[i]); let b to_screen(outer[i 1]); let diff_x (b.x - a.x).abs(); let diff_y (b.y - a.y).abs(); let (off_x, off_y) if diff_x diff_y { (px(0.0), px(1.0)) } else { (px(1.0), px(0.0)) }; let mut seg Path::new(a); seg.line_to(b); seg.line_to(point(b.x off_x, b.y off_y)); seg.line_to(point(a.x off_x, a.y off_y)); result.push((seg, String::from(stroke))); }比较大的面要素例如呼伦贝尔市的边界有超过 1500 个坐标点每帧要生成 1500 个薄条路径。更准确地说对于一条 N 个点的边界描边需要 N-1 条薄条。GPUI 是 GPU 加速的所以即使要素数量再多也能保持 60 帧。如果 GPUI 没有 GPU 加速这个方案根本跑不动。孔洞GeoJSON 规范规定多边形的外环是顺时针方向内环孔洞是逆时针方向。这是有意为之的——渲染器可以通过绕行方向来判断一个环是外边界还是内边界。内蒙古自治区的数据中就有带孔洞的要素比如呼和浩特市的主体边界内包含了其他行政飞地。处理方式是把内环也用三角剖分剖开然后用全不透明的白色填充从视觉上挖空。白色正好是背景色所以看起来就像是在城市填充色上挖了一个洞。如果三角剖分失败了一个复杂几何形状偶尔会出现这种情况后备方案是直接用单路径闭合填充。如果不处理这个问题的话就是这样的处理完之后就变成这样了// ── 处理内环孔洞── // GeoJSON 规范中内环应为逆时针CCW绕行。 // 用白色背景色填充内环区域在视觉上挖空孔洞。 for inner in polygon.rings.iter().skip(1) { if inner.len() 3 { continue; } let hole_tris triangulate(inner); let hole_path if !hole_tris.is_empty() { let h0 hole_tris[0]; let mut p Path::new(to_screen(inner[h0[0]])); p.line_to(to_screen(inner[h0[1]])); p.line_to(to_screen(inner[h0[2]])); for tri in hole_tris[1..] { p.move_to(to_screen(inner[tri[0]])); p.line_to(to_screen(inner[tri[1]])); p.line_to(to_screen(inner[tri[2]])); } p } else { // 后备单路径闭合填充 let mut p Path::new(to_screen(inner[0])); for coord in inner[1..inner.len() - 1] { p.line_to(to_screen(coord)); } p }; result.push((hole_path, String::from(hole))); }管线下面介绍GPU渲染的一个关键术语管线。GPU 渲染管线Graphics Pipeline就是指GPU 把 “3D/2D 数据” 变成 “屏幕上一张图” 的固定流水线工序。GPU 渲染管线 一串固定且可编程的标准化流程把顶点 → 三角形 → 像素一步步变成屏幕上的颜色。GPUI 的 canvas 提供了两个回调。Pre-paint 在布局完成后调用负责计算几何路径返回值会传给 paint。Paint 负责实际绘制根据路径标签设置不同颜色。这个双回调机制是 GPUI 的特色在布局阶段可以访问实际的像素尺寸从而精确控制渲染内容。最后就是标注了城市名称的标签用 GPUI 的 Div 元素做 absolute 定位而不是画在 canvas 上。这样做的好处是利用了 GPUI 的文本渲染能力不需要手动处理字体和排版。标签位置用多边形质心坐标计算质心直接简单的用是所有顶点的算术平均。注意真实是label 渲染凸多边形没啥问题但是凹多边形会导致质心跑到图形以外生产环境下需要使用基于有向面积的积分公式法也称为鞋带公式的推广或高斯公式法来计算这里为了省事就不去做了。另外文字的位置与label点的位置也要注意计算偏离不然容易和我演示的一样文字会跑出去……不过同上这里为了省事就不去做了。// 渲染部分的代码 impl Render for MapView { fn render(mut self, window: mut Window, _cx: mut ContextSelf) - impl IntoElement { let features self.data.features.clone(); let geobounds self.data.geobounds; let viewport window.viewport_size(); // ── 城市标签质心 → 屏幕坐标── let labels: Vec(f64, f64, String) features.iter().filter_map(|f| { f.polygons.first() .map(|poly| polygon_center(poly)) .map(|(cx, cy)| { let (sx, sy) geo_to_screen(cx, cy, geobounds, viewport); (sx, sy, f.name.clone()) }) }).collect(); let label_elements: VecDiv labels.into_iter().map(|(x, y, name)| { div().absolute().left(px(x as f32)).top(px(y as f32)).child(name) }).collect(); const FILL_COLORS: [u32; 11] [ 0x4A90D9, 0xE86C00, 0x8BC34A, 0xE83E3E, 0x9B59B6, 0x20B2AA, 0xF08529, 0x3C8DBC, 0x8E44AD, 0x27AE60, 0xD35400, ]; div().bg(white()).size_full().child( canvas( // ── Pre-paint 回调 ── move |bounds, _, _| - OptionVec(PathPixels, String) { if features.is_empty() { return None; } // ... 计算投影参数遍历要素构建路径列表 ... Some(result) }, // ── Paint 回调按标签类型绘制路径 ── move |_, paths, window, _| { if let Some(paths) paths { for (path, tag) in paths.iter() { match tag.as_str() { stroke window.paint_path(path.clone(), rgba(0x444444FF)), hole window.paint_path(path.clone(), rgba(0xFFFFFFFF)), _ if tag.starts_with(fill:) { let idx: usize tag[5..].parse().unwrap_or(0); let base FILL_COLORS[idx % FILL_COLORS.len()]; window.paint_path(path.clone(), rgba((base 8) | 0x88)); } _ {} } } } }, ).size_full(), ).children(label_elements) } }最后就是GPUI的入口函数了这个很简单创建 Application打开窗口创建 MapView 作为根视图。从文件加载数据如果加载失败就创建一个空的数据集。fn main() { Application::new().run(|cx: mut App| { cx.open_window(WindowOptions::default(), |_window, cx| { cx.new(|_cx| match MapView::from_geojson_file(data/中国_省.geojson) { Ok(view) { for f in view.data.features { println!( - {}, f.name); } view } Err(e) { eprintln!(读取文件失败: {}, e); MapView { data: util::parse_geojson_str({}) } } }) }).expect(failed to open window); }); }结论测试结果比较明确。GPUI 的 Canvas 渲染管线能够处理数千条路径的同时绘制保持 60 帧Path的move_to机制为复杂的矢量图形渲染提供了灵活的拼接能力。纯 Rust 的依赖栈意味着编译出来的程序启动快、内存占用低没有 JVM 或 V8 的开销。目前发现的两个限制。第一GPUI 没有内置的描边 API需要用薄条路径来模拟对于极端复杂的边界会生成大量临时路径。第二GPUI 对于 macOS 和 Linux 平台的支持比 Windows 要好明显感觉性能要高不少。总的来说对于需要高性能矢量图形渲染的桌面 GIS 工具GPUI 是一个值得关注的方向。渲染管线是 GPU 加速的Path API 足够灵活开发体验也不错——就是用 Rust 写 UI 的逻辑需要适应一下。后记文章的中的绝大部分部分代码超过90%都是AI直接写的我的作用就是告诉AI应该往那个方向走如下例如渲染呼伦贝尔的时候多部件渲染颜色出来问题我得告诉他怎么做又比如环形在GIS里面的特殊结构当然如果你不告诉他有可能多烧上几块十几块的token也有可能烧出来但是你只要给出解决方案AI解决起来那是嗷嗷的快。这篇文章是在我主动蒸馏了虾神博客写作风格的skills的帮助下完成的初稿我在初稿上做的修改完成的下面就虾神的赛博器灵我很多时候觉得我已经废了……