上篇结尾我留了这么一句话:“下一篇我们接着在控件这块走——这次不是左右分屏,而是把"这一屏有多少米"标在角落里。距离比例尺控件,我们下一篇见。”#64 的加载遮罩、完全不碰引擎;#65 的卷帘、几乎没有一行代码是自己的。这两个极端之间还有一个位置——今天这个控件是"引擎给一半,自己算一半":Cesium负责把两条射线打到地上,剩下的判断、取舍、画线全是我们自己的代码。而且它有一个特别的地方:这可能是你见过的代码量最小、却最经不起推敲的一个控件——全部逻辑不到 40 行,但我把它拆开之后数出了四五个"看起来没问题、其实早晚会出事"的地方。大家好,我是 Cesium 酱(也可以叫我"本猿"),一名在 WebGIS 领域摸爬滚打多年的前端开发者。💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。一、场景与痛点:地图上那条从来没人解释的横线🎯 背景现状纸质地图的角落里,永远有一条带数字的横线,学名叫线段式比例尺。它的作用是让人用一把尺子就能量出距离,哪怕地图被缩印到 A5 也照样能量。WebGIS 把它接了过来,却遇到了一个纸质地图没有的麻烦:它的"大小"是可变的。用户滚一下滚轮,这个比例尺就得跟着换数字。于是就有了三种常见的敷衍做法:写死一个比例:默认视角下对着卷尺校准一次,之后不管谁滚哪都不变 —— 放大十倍之后它说的全是错的;拿相机高度硬换算:高度 × 常数 = 屏幕跨度,画出来勉强能看,一到高纬度(经线收拢)就系统性偏小;干脆不画:让用户自己去拉测距工具。⚠️ 难点到底在哪难就难在"一个像素是多少米"这件事同时被四样东西左右着,而且没有一个是常量:因素影响实测幅度(视口 1280×640 CSS px)相机高度成正比的主因100 m → 0.09 m/px;260 km → 234.96 m/px,跨度2600 倍俯仰角同一屏不同行,尺度不同俯角 45° 时顶行 502.64、底行 259.19 m/px,差 1.94 倍纬度经线方向的实际距离不同用EllipsoidGeodesic走测地线消掉地形射线打在地形表面而非椭球globe.pick命中的是本帧渲染过的瓦片注意最后一条:这条路还得"看得见才测得着",这一点在第五节会变成一个真问题。💡 本文目标做一个和主流地图软件观感一致的比例尺:看上去就是左下/底边中间一条横向 I 形线加一个[20 km]标签,但它会随相机实时换档,永远落在 50~99 px 的宽度里,并且知道自己什么时候不该出现。二、原理拆解:一个像素到底代表多少米2.1 一句话的原理量出"横向一个像素有多大",再找一个最接近 100 px 的整数距离去配它。整个控件只需要一个中间量:pixelDistance,单位是"米/像素"。有了它,要显示的距离 ÷ pixelDistance = 这条横线该画多少像素;反过来遍历一堆候选距离,挑一个宽度接近 100 px 的就行。2.2 两根射线:底边正中的相邻两个像素pixelDistance怎么来?代码是这样的:constwidth=scene.canvas.widthconstheight=scene.canvas.heightconstleftRay=scene.camera.getPickRay(newCartesian2((width/2)|0,height-1))constrightRay=scene.camera.getPickRay(newCartesian2((1+width/2)|0,height-1))if(!leftRay||!rightRay)returnconstleftPosition=scene.globe.pick(leftRay,scene)constrightPosition=scene.globe.pick(rightRay,scene)if(!leftPosition||!rightPosition)return三个细节值得单独拎出来:取样点选在画面底边的最中间(x = width/2,y = height - 1)。这是 Cesium 老版本DistanceLegend的遗产:底边是最"靠近相机的常驻地表区域",在绝大多数视角里都既不会跑到地平线外面、也不会落到极端压缩的边缘。两根射线只差一个像素:左边(width/2)|0,右边(1 + width/2)|0。取的是"相邻两个像素的中心",而不是同一个像素的左右边缘——量出来的刚好是一个像素的跨度,后面可以直接当"米/像素"用。| 0是取整不是四舍五入,目的只是让坐标落在整数像素上,免得 Cesium 内部 NDC 换算里出现半个像素的漂移。getPickRay之后是globe.pick。这里有个容易忽略的实现细节(Globe.jsL823):它并不是拿射线去解椭球方程,而是遍历本帧渲染过的瓦片集合_tilesRenderedThisFrame(Globe.jsL746)逐做相交。也就是说:globe.pick测的是"渲染出来的地表",不是"数学上的椭球"。命中点再转成经纬度交给EllipsoidGeodesic:geodesic.setEndPoints(scene.globe.ellipsoid.cartesianToCartographic(leftPosition),scene.globe.ellipsoid.cartesianToCartographic(rightPosition))constpixelDistance=geodesic.surfaceDistance2.3 为什么是测地线,不是直线距离Cartesian3.distance(left, right)是两点之间的直线距离(弦),而surfaceDistance是沿椭球表面的弧长。一个像素的尺度下两者数值几乎相同,但这里有个语义差别:用户需要的"图上距离"是沿地表的;而且EllipsoidGeodesic用的是椭球参数(Vincenty 反解),自动把纬度的收敛考虑进去。另外一个复用上的小动作值得学:geodesic是一个模块级的对象(let geodesic = new EllipsoidGeodesic()),每帧只调setEndPoints重填数据。在每帧触发的回调里新建对象,是这类控件最容易犯的性能错误——这里避免了。2.4 底边 ≠ 全屏:挪到顶行差了 1.94 倍这是本篇第一个反直觉的结论。我把相机放在南京附近 260 km 高、俯角 45°,逐行算"这一行一个像素代表多少米":屏幕位置米 / 像素相对底行顶行(y = 0)502.641.939×中间行(y = 320)338.741.307×底行(y = 639)259.191.000×也就是说:这条比例尺只对底边那一行负责。同一幅画面里,靠上的地方一个像素压着更长的地面。你拿这条 I 形线去量靠近屏幕顶端的两个地物,读数是 1 km,真实距离接近 1.94 km。俯角压到 30°(贴地斜看)时更夸张:顶行一个像素代表 736.74 米,底行 319.21 米,比值 2.31 倍,而且再往上一行人已经越过了地平线(globe.pick直接返回undefined)。所以"采样点放在底边"不是随手选的,它代表了这个控件一贯的口径:以最接近画面的那一行为准。所有的误差都在这个前提下成立,这也是这类控件的通病 —— 不是本猿写错了,是二维屏幕本来就没法用一个数字描述透视画面。2.5 每帧都算?不,250 ms 一次回调挂在scene.preRender上。默认useDefaultRenderLoop = true(本项目没有开requestRenderMode),所以这个回调是60 Hz 触发的。代码里加了一道闸门:constnow=performance.now()if(now<lastUpdate+250)returnlastUpdate=now于是实际更新频率被压到最多 4 次/秒。这是个很合适的数字:比它更快,人眼看不出差别还白费算力;比它更慢,拖动相机时数字会明显滞后于画面。三、代码实战:从 28 个档位里挑一个3.1 档位表:1/2/3/5 序列constBASE=[1,2,3,5]constDIS=[...BASE,...BASE.map((item)=>item*10),...BASE.map((item)=>item*100),...BASE.map((item)=>item*1000),...BASE.map((item)=>item*10000),...BASE.map((item)=>item*100000),...BASE.map((item)=>item*1000000)]7 个数量级 × 4 个尾数 = 28 个档位,最小 1 米、最大 5,000,000 米(5000 km)。相邻两档的比值在2、1.5、1.6667三个数之间循环:1 → 2 (×2) → 3 (×1.5) → 5 (×1.667) → 10 (×2) → 20 (×2) → 30 (×1.5) → …这套 1/2/3/5 序列是工程约定俗成的(电阻阻值、货币面额、相机 ISO 都用它):在"档位不能太多"和"相邻档位不能太挤"之间取了一个平衡。实测相邻倍率被钳在1.5 ~ 2之间(最大 2、最小 1.5)——这一点马上会成为整个控件的核心性质。3.2 挑档位只有一行,但它藏着一条"锯齿定理"constmaxBarWidth=100letdistance=0for(leti=DIS.length-1;i>=0;--i){if(DIS[i]/pixelDistance<maxBarWidth){distance=DIS[i]break}}从最大的档位往回扫,取第一个(也就是最大的那个)满足档位 / pixelDistance < 100的数。这段代码看上去平平无奇,但它藏着一条可以证明的性质:只要选中的不是最大档位,横线宽度一定落在 [50, 99] px 之间。证明很简单:设选中的档位是d,下一档是d'(因为从大到小扫,说明d'不满足条件,即d' / pixelDistance ≥ 100)。于是宽度 = d / pixelDistance = (d / d') × (d' / pixelDistance) ≥ (d / d') × 100而相邻倍率最大是 2,所以d / d' ≥ 1/2,宽度≥ 50 px;同时判定条件是< 100,宽度≤ 99 px(还有一次| 0取整)。我直接把pixelDistance从 10⁻³ 扫到 10⁶ 验证了一遍,最小值是 50 px、最大值是 99 px,一次都没越界。这就是"横线永远不会短于半个身位"的来历——用户看到的线永远在半宽到满宽之间来回跳,视觉上很稳,但对应的数字已经换了好几轮。3.3 两个边界:贴得太近会消失,退得太远会塌陷这行循环还漏掉了两种情况——而这两种情况才是真正决定它"什么时候不该出现"的地方。① 贴得太近(≤ 0.01 m/px)→ 整块消失最小的档位是 1 米。当pixelDistance ≤ 0.01时,连 1 米都需要 100 px 以上,28 个档位一个都不合格,distance保持 0,代码走进另一分支:if(distance){legendVisible.value=truelabel.value=distance>=1000?`${distance/1000}km`:`${distance}m`barWidth.value=(distance/pixelDistance)|0}else{legendVisible.value=false// 直接隐藏}换算到具体高度:在本猿这套 1280×640 的视口里,正视下方、相机离地11.085 米时正好卡在 0.01 m/px,再往下压,比例尺就没有了。而 Cesium 的minimumZoomDistance默认是1.0 米(ScreenSpaceCameraController.jsL132),这个边界是用户伸手就能够到的。② 退得太远(> 50 km/px)→ 顶档塌陷反方向的失效更隐蔽:一旦pixelDistance > 50 km/px,唯一的幸存者就是最大档 5000 km,而且从这一刻起,横线宽度不再有下限:每像素米数选中的档位横线宽度50 km/px3000 km60 px100 km/px5000 km50 px851 km/px5000 km5 px5000 km/px5000 km1 px10000 km/px5000 km0 px标签固执地写着 5000 km,横线却已经塌成一条看不见的线。值得注意的是:真的把视角退到这一步之前,你会先撞上另一堵墙(第五节 5.2),所以这里看到的多半是算法层面的性质,而不是你在屏幕上真正能看见的样子。3.4 画出来:一条只有三边的边框和一段内联样式<div v-show="enabled && legendVisible" class="cesium-distance-legend"> <div class="label">{{ label }}</div> <div class="scale-bar" :style="{ width: barWidth + 'px', left: (125 - barWidth) / 2 + 'px' }"></div> </div>.cesium-distance-legend{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);width:125px;height:25px;}.scale-bar{position:absolute;height:10px;top:10px;border-left:1.5px solid #fff;border-right:1.5px solid #fff;border-bottom:1.5px solid #fff;}I 形线是靠三条边框拼出来的(左、右、下),不是一根<hr>;因为宽度在变,用边框才能保证两端永远是竖直的端点。容器宽125 px,比maxBarWidth = 100多留了 25 px,所以任何档位下两侧都至少有约 12.5 px 余量,不会贴着边。left: (125 - barWidth) / 2是为了让变宽的横线始终居中。⚠️这段正确性是踩在项目全局样式上的:本仓库src/style.css第 2 行写着* { box-sizing: border-box; }。也就是说width: barWidth已经把左右各 1.5 px 的边框算进去了,居中是精确的。若把这个组件搬到一个没有这条 reset 的项目里,content-box会让实际宽度多出 3 px,居中随之偏 1.5 px。这种"依赖全局 reset 才对"的代码,是复制粘贴时最容易翻车的一类。barWidth用了(distance / pixelDistance) | 0,向下取整最多丢 1 px;在最窄的 50 px 档位上,这是2% 的误差,肉眼基本看不出来,但别指望它参与计算。四、运行效果初始视角是Cartesian3.fromDegrees(118.8, 32.05, 260000)(南京附近、抬高 260 km)。因为setView只给了 destination 没给 orientation,Cesium 的默认姿态是heading = 0, pitch = -π/2, roll = 0(Camera.jsL1528-1530),也就是镜头笔直朝下。本猿把这个视角按 1280×640 CSS px 复算了一遍,全过程是这样:步骤实测值每像素代表234.956 米选中的档位20000 米显示的标签20 km横线宽度85 px把相机高度从小往大整个推一遍,是这个样子:相机高度米 / 像素标签宽度0.1 km0.09025 m55 px1 km0.902150 m55 px5 km4.5107300 m66 px20 km18.04461 km55 px100 km90.27115 km55 px260 km(初始)234.955720 km85 px1000 km908.197050 km55 px5000 km4674.0244300 km64 px12000 km12012.31971000 km83 px16418 km——射线越过地平线这个控件的面板可能是我们写过的最简陋的一块:一个开关,加一段提示。因为它没有可调参数——所有尺度都是量出来的,唯一的人为设定是"横线最多 100 px",而这恰恰是藏得最深的一个常量。五、避坑与优化5.1canvas.width不是clientWidth:一个被默认值兜住的错误回到 2.2 那段代码,主角是两个变量:constwidth=scene.canvas.width// ← 注意constheight=scene.canvas.height而 Cesium 自己在getPickRayPerspective里(Camera.jsL3007,L3009-3010)读的是:constcanvas=camera._scene.canvas;constwidth=canvas.clientWidth;// ← 是 CSS 像素constheight=canvas.clientHeight;constx=(2.0/width)*windowPosition.x-1.0;两套单位。canvas.width是绘制缓冲的设备像素,clientWidth是 CSS 像素,两者相差一个pixelRatio:// Cesium.js(未压缩)L255472-255493functionconfigurePixelRatio(widget){letpixelRatio=widget._useBrowserRecommendedResolution?1:window.devicePixelRatio;pixelRatio*=widget._resolutionScale;...}functionconfigureCanvasSize(widget){letwidth=canvas.clientWidth;letheight=canvas.clientHeight;constpixelRatio=configurePixelRatio(widget);width*=pixelRatio;height*=pixelRatio;canvas.width=width;canvas.height=height;// = clientWidth × pixelRatio}好消息:useBrowserRecommendedResolution的默认值是true(L255546),resolutionScale默认 1,于是pixelRatio = 1,canvas.width === clientWidth,这段代码默认是对的。坏消息:这个"对"是别人给的。一旦有人为了 Retina 清晰度写下useBrowserRecommendedResolution: false,或者为了性能写下viewer.resolutionScale = 0.5,换算立刻不成立,取样点会顺着倍率飘出画面:pixelRatio取样点落到 NDC画面内?1(默认)(0.000, -0.997)✅ 底边正中1.25(0.250, -1.497)❌ 已越过下边缘1.5(0.500, -1.997)❌2(1.000, -2.997)❌ x 正好压在右边缘、y 已在下边缘之外两屏3(2.000, -4.997)❌注意那个(0.000, -0.997):y 分量之所以是 -0.997 而不是 -1,是因为(2/H)(H - (H-1)) - 1 = -1 + 2/H,它落在底边那一行像素的中心上——这也是为什么代码写的是height - 1而不是height。飘出去之后会发生什么?射线往往还能命中椭球(只是命中了画面外的某片地),于是控件不报错,只给出系统性偏小的数:视角真值×1.5×2×3俯角 45°、260 km20 km-18.4%-30.6%(读成 10 km)-46.1%(读成 10 km)俯角 30°、260 km30 km-25.6%-40.2%(读成 10 km)-56.8%(读成 10 km)正视下方、260 km20 km+1.1%+3.7%+16.1%(-x% 表示量到的"米/像素"比真值小,读数随之偏小)也就是:在正下方俯视时这个错误几乎看不出来,可是一旦变成正常的斜视角,DPR=2 的屏幕上读数只有真值的一半多一点。这才是它阴的地方——受害最深的是最日常的视角,而最容易被拿来验收的(正下方)恰好没事。改成这样就没事了,两个方案任选一个:// 方案一:一切都用 CSS 像素constwidth=scene.canvas.clientWidthconstheight=scene.canvas.clientHeight// 方案二:从 drawingBuffer 折回 CSS 像素constwidth=scene.drawingBufferWidth/scene.pixelRatioconstheight=scene.drawingBufferHeight/scene.pixelRatio5.2 射线没命中:它不是消失,是"定格"注意 2.2 那段里的两句return:if(!leftRay||!rightRay)returnif(!leftPosition||!rightPosition)return两处都是在更新legendVisible之前就返回了。也就是说,一旦射线打空,label和barWidth保持上一次的值,容器也不会隐藏。结果就是一条**“僵尸比例尺”**:画面已经拉到看不清的地方了,角落里还挂着上一次的数字,不再更新,也不肯消失。什么时候会打空?两个都很常见:越过地平线:本猿这套视口里正视下方退到 16,418 km 以上,底边射线本身就已经越过地球边缘(sin 16.1° > R / (R + h)),globe.pick直接返回undefined;本帧没有渲染过的瓦片可供相交:别忘了globe.pick遍历的是_tilesRenderedThisFrame。修法很简单,把失败当成一种状态显式处理掉:letok=truefunctionupdateContent():void{// ...if(!leftPosition||!rightPosition){if(ok){ok=false;legendVisible.value=false}// 打空一次就收起来return}ok=true// ...}顺带把legendVisible的初值考虑清楚:它一开始就是false,所以从来没成功量到过的控件不会显示占位,这点是对的——只是失败时它忘了回到这个初值。5.3preRender慢一帧_tilesRenderedThisFrame是在beginFrame里清空的(QuadtreePrimitive.jsL352),而preRender事件是在渲染动作之前抛出的(Scene.jsL4640)。拼起来看:第 N 帧:preRender(看到的是第 N-1 帧留下的瓦片集合) → beginFrame(清空) → 瓦片入集合 → …所以严格说,你在preRender里拿到的始终是上一帧的瓦片。对比例尺这种"差几毫秒无所谓"的东西完全够用;但如果你要拿globe.pick做精确的定位/量算,请把它放到postRender,或者干脆别用 pick,改用camera.pickEllipsoid这类纯解析路径。另外,一旦项目开了requestRenderMode = true,静止时不会触发任何帧,preRender也就停了——那时必须每一步操作后手动scene.requestRender()。本例的onEnableChange里已经调了viewer.scene.requestRender(),方向是对的。5.4 想改的话,可以从这几处下手参考位置:现在锁定在底边中央,于是整个屏幕只有底边那一行是准的(2.4 节的 1.94 倍)。想要"代表整屏",可以改成取画面中心附近,或者直接提示用户"以屏幕底部为准"。单位切换:当前是distance >= 1000时切成 km。中文习惯里很少说"5000 km",到那一档换成"万公里"会更顺手;做国际化时m/km之外别忘了还有ft/mi——同样一套 1/2/3/5 序列,换一组基准数就行。档位表:28 档只覆盖了 1 m ~ 5000 km。要是你的业务只看城区,不妨裁掉头尾几档,代价是两个失效边界也跟着挪了位置(见 3.3 节)。绑一个显式的高度守卫:legendVisible现在只由"有没有合适的档位"决定,于是"贴到 11 米就会消失"成了一个没人写在明面上的副产品。加一道最小/最大高度的显式判断,比指望别人读懂那句< 100好得多。5.5 上线前 checklist✅ 取样坐标统一用CSS 像素(clientWidth或drawingBufferWidth / pixelRatio),别直接拿canvas.width✅globe.pick返回空时显式处理,不要让控件留着旧值不退场✅ 需要精确值时挪到postRender,或改用解析式pickEllipsoid✅ 开了requestRenderMode就要自己唤起渲染✅ 每帧回调里加节流(本例 250 ms / 最多 4 Hz),并且复用EllipsoidGeodesic对象✅ 组件里的宽高计算要写清楚依赖的是不是全局box-sizing⚠️ 别忘了留一个说明:这条比例尺只对屏幕底边那一行负责六、写在最后Cesium 是头优雅的大象。我们曾经以为,比例尺是一个"画多大"的问题;今天才发现,它是一个"先承认自己测不准哪儿"的问题 —— 两条射线隔开一个像素,测出来的从来不是全屏,只是底边那一行。回头看这一篇,最有意思的不是"怎么画那条线",而是这一路攒下来的四个认知:第一,它承认测不准。底边 vs 顶行 1.94 倍的差距不是 bug,是透视投影的宿命。好的控件会把自己的口径说清楚,而不是假装自己是全屏通用的。第二,一句< 100里藏着一条定理。50~99 px 的锯齿不是玄学,它是"1/2/3/5 序列相邻倍率最大为 2"的直接推论。写到这一行的时候,你可能根本没意识到自己证明了一件事。第三,默认值会替你挡住错误,也会替你藏起错误。useBrowserRecommendedResolution默认true,让canvas.width这个写法侥幸正确;而一旦有人为了清晰度改动它,倾斜视角下的读数直接砍半。这种"被默认值兜着"的代码最危险——它在你的机器上永远是对的。第四,失败要是显式的。两处return让控件在射线打空时既不自愈也不退场,于是我们看到的是一条僵尸比例尺。这里只需要一句legendVisible = false。这个案例还不完美:面板上只有一个开关、好几处行为要跑起来才看得见、依赖全局box-sizing,也没做移动端适配。但它把"地图角落里那条横线是怎么算出来的、什么时候不该信它"讲清楚了,这就够了。🎉下一篇我们先搁下地图控件这口气,去算一件要真动手的事——在一片地形上,挖掉多少、填上多少。空间分析-填挖方分析,我们下一篇见。(至于还在旁边排队的罗盘控件,那是这条线往后的下一站,先按下不表。)如果你也在项目里写过比例尺,欢迎留言说说你是怎么处理"屏幕各处尺度不一致"这件事的——是干脆提示"以屏幕底部为准",还是换了另一种参考位置?🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX代码在src/cases/widget-distance-legend/目录下,核心就一个 138 行的WidgetDistanceLegendDemo.vue,把初始视角的经纬度换成你自己的城市就能跑。Cesium酱の百宝箱 · 第 66 篇两个相邻像素量尽千里,一条横线只认底边那一寸2026 年的秋夜,与你共探"看不见的尺度该怎样被说出来"
上篇结尾我留了这么一句话:“下一篇我们接着在控件这块走——这次不是左右分屏,而是把"这一屏有多少米"标在角落里。距离比例尺控件,我们下一篇见。”#64 的加载遮罩、完全不碰引擎;#65 的卷帘、几乎没有一行代码是自己的。这两个极端之间还有一个位置——今天这个控件是"引擎给一半,自己算一半":Cesium负责把两条射线打到地上,剩下的判断、取舍、画线全是我们自己的代码。而且它有一个特别的地方:这可能是你见过的代码量最小、却最经不起推敲的一个控件——全部逻辑不到 40 行,但我把它拆开之后数出了四五个"看起来没问题、其实早晚会出事"的地方。大家好,我是 Cesium 酱(也可以叫我"本猿"),一名在 WebGIS 领域摸爬滚打多年的前端开发者。💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。一、场景与痛点:地图上那条从来没人解释的横线🎯 背景现状纸质地图的角落里,永远有一条带数字的横线,学名叫线段式比例尺。它的作用是让人用一把尺子就能量出距离,哪怕地图被缩印到 A5 也照样能量。WebGIS 把它接了过来,却遇到了一个纸质地图没有的麻烦:它的"大小"是可变的。用户滚一下滚轮,这个比例尺就得跟着换数字。于是就有了三种常见的敷衍做法:写死一个比例:默认视角下对着卷尺校准一次,之后不管谁滚哪都不变 —— 放大十倍之后它说的全是错的;拿相机高度硬换算:高度 × 常数 = 屏幕跨度,画出来勉强能看,一到高纬度(经线收拢)就系统性偏小;干脆不画:让用户自己去拉测距工具。⚠️ 难点到底在哪难就难在"一个像素是多少米"这件事同时被四样东西左右着,而且没有一个是常量:因素影响实测幅度(视口 1280×640 CSS px)相机高度成正比的主因100 m → 0.09 m/px;260 km → 234.96 m/px,跨度2600 倍俯仰角同一屏不同行,尺度不同俯角 45° 时顶行 502.64、底行 259.19 m/px,差 1.94 倍纬度经线方向的实际距离不同用EllipsoidGeodesic走测地线消掉地形射线打在地形表面而非椭球globe.pick命中的是本帧渲染过的瓦片注意最后一条:这条路还得"看得见才测得着",这一点在第五节会变成一个真问题。💡 本文目标做一个和主流地图软件观感一致的比例尺:看上去就是左下/底边中间一条横向 I 形线加一个[20 km]标签,但它会随相机实时换档,永远落在 50~99 px 的宽度里,并且知道自己什么时候不该出现。二、原理拆解:一个像素到底代表多少米2.1 一句话的原理量出"横向一个像素有多大",再找一个最接近 100 px 的整数距离去配它。整个控件只需要一个中间量:pixelDistance,单位是"米/像素"。有了它,要显示的距离 ÷ pixelDistance = 这条横线该画多少像素;反过来遍历一堆候选距离,挑一个宽度接近 100 px 的就行。2.2 两根射线:底边正中的相邻两个像素pixelDistance怎么来?代码是这样的:constwidth=scene.canvas.widthconstheight=scene.canvas.heightconstleftRay=scene.camera.getPickRay(newCartesian2((width/2)|0,height-1))constrightRay=scene.camera.getPickRay(newCartesian2((1+width/2)|0,height-1))if(!leftRay||!rightRay)returnconstleftPosition=scene.globe.pick(leftRay,scene)constrightPosition=scene.globe.pick(rightRay,scene)if(!leftPosition||!rightPosition)return三个细节值得单独拎出来:取样点选在画面底边的最中间(x = width/2,y = height - 1)。这是 Cesium 老版本DistanceLegend的遗产:底边是最"靠近相机的常驻地表区域",在绝大多数视角里都既不会跑到地平线外面、也不会落到极端压缩的边缘。两根射线只差一个像素:左边(width/2)|0,右边(1 + width/2)|0。取的是"相邻两个像素的中心",而不是同一个像素的左右边缘——量出来的刚好是一个像素的跨度,后面可以直接当"米/像素"用。| 0是取整不是四舍五入,目的只是让坐标落在整数像素上,免得 Cesium 内部 NDC 换算里出现半个像素的漂移。getPickRay之后是globe.pick。这里有个容易忽略的实现细节(Globe.jsL823):它并不是拿射线去解椭球方程,而是遍历本帧渲染过的瓦片集合_tilesRenderedThisFrame(Globe.jsL746)逐做相交。也就是说:globe.pick测的是"渲染出来的地表",不是"数学上的椭球"。命中点再转成经纬度交给EllipsoidGeodesic:geodesic.setEndPoints(scene.globe.ellipsoid.cartesianToCartographic(leftPosition),scene.globe.ellipsoid.cartesianToCartographic(rightPosition))constpixelDistance=geodesic.surfaceDistance2.3 为什么是测地线,不是直线距离Cartesian3.distance(left, right)是两点之间的直线距离(弦),而surfaceDistance是沿椭球表面的弧长。一个像素的尺度下两者数值几乎相同,但这里有个语义差别:用户需要的"图上距离"是沿地表的;而且EllipsoidGeodesic用的是椭球参数(Vincenty 反解),自动把纬度的收敛考虑进去。另外一个复用上的小动作值得学:geodesic是一个模块级的对象(let geodesic = new EllipsoidGeodesic()),每帧只调setEndPoints重填数据。在每帧触发的回调里新建对象,是这类控件最容易犯的性能错误——这里避免了。2.4 底边 ≠ 全屏:挪到顶行差了 1.94 倍这是本篇第一个反直觉的结论。我把相机放在南京附近 260 km 高、俯角 45°,逐行算"这一行一个像素代表多少米":屏幕位置米 / 像素相对底行顶行(y = 0)502.641.939×中间行(y = 320)338.741.307×底行(y = 639)259.191.000×也就是说:这条比例尺只对底边那一行负责。同一幅画面里,靠上的地方一个像素压着更长的地面。你拿这条 I 形线去量靠近屏幕顶端的两个地物,读数是 1 km,真实距离接近 1.94 km。俯角压到 30°(贴地斜看)时更夸张:顶行一个像素代表 736.74 米,底行 319.21 米,比值 2.31 倍,而且再往上一行人已经越过了地平线(globe.pick直接返回undefined)。所以"采样点放在底边"不是随手选的,它代表了这个控件一贯的口径:以最接近画面的那一行为准。所有的误差都在这个前提下成立,这也是这类控件的通病 —— 不是本猿写错了,是二维屏幕本来就没法用一个数字描述透视画面。2.5 每帧都算?不,250 ms 一次回调挂在scene.preRender上。默认useDefaultRenderLoop = true(本项目没有开requestRenderMode),所以这个回调是60 Hz 触发的。代码里加了一道闸门:constnow=performance.now()if(now<lastUpdate+250)returnlastUpdate=now于是实际更新频率被压到最多 4 次/秒。这是个很合适的数字:比它更快,人眼看不出差别还白费算力;比它更慢,拖动相机时数字会明显滞后于画面。三、代码实战:从 28 个档位里挑一个3.1 档位表:1/2/3/5 序列constBASE=[1,2,3,5]constDIS=[...BASE,...BASE.map((item)=>item*10),...BASE.map((item)=>item*100),...BASE.map((item)=>item*1000),...BASE.map((item)=>item*10000),...BASE.map((item)=>item*100000),...BASE.map((item)=>item*1000000)]7 个数量级 × 4 个尾数 = 28 个档位,最小 1 米、最大 5,000,000 米(5000 km)。相邻两档的比值在2、1.5、1.6667三个数之间循环:1 → 2 (×2) → 3 (×1.5) → 5 (×1.667) → 10 (×2) → 20 (×2) → 30 (×1.5) → …这套 1/2/3/5 序列是工程约定俗成的(电阻阻值、货币面额、相机 ISO 都用它):在"档位不能太多"和"相邻档位不能太挤"之间取了一个平衡。实测相邻倍率被钳在1.5 ~ 2之间(最大 2、最小 1.5)——这一点马上会成为整个控件的核心性质。3.2 挑档位只有一行,但它藏着一条"锯齿定理"constmaxBarWidth=100letdistance=0for(leti=DIS.length-1;i>=0;--i){if(DIS[i]/pixelDistance<maxBarWidth){distance=DIS[i]break}}从最大的档位往回扫,取第一个(也就是最大的那个)满足档位 / pixelDistance < 100的数。这段代码看上去平平无奇,但它藏着一条可以证明的性质:只要选中的不是最大档位,横线宽度一定落在 [50, 99] px 之间。证明很简单:设选中的档位是d,下一档是d'(因为从大到小扫,说明d'不满足条件,即d' / pixelDistance ≥ 100)。于是宽度 = d / pixelDistance = (d / d') × (d' / pixelDistance) ≥ (d / d') × 100而相邻倍率最大是 2,所以d / d' ≥ 1/2,宽度≥ 50 px;同时判定条件是< 100,宽度≤ 99 px(还有一次| 0取整)。我直接把pixelDistance从 10⁻³ 扫到 10⁶ 验证了一遍,最小值是 50 px、最大值是 99 px,一次都没越界。这就是"横线永远不会短于半个身位"的来历——用户看到的线永远在半宽到满宽之间来回跳,视觉上很稳,但对应的数字已经换了好几轮。3.3 两个边界:贴得太近会消失,退得太远会塌陷这行循环还漏掉了两种情况——而这两种情况才是真正决定它"什么时候不该出现"的地方。① 贴得太近(≤ 0.01 m/px)→ 整块消失最小的档位是 1 米。当pixelDistance ≤ 0.01时,连 1 米都需要 100 px 以上,28 个档位一个都不合格,distance保持 0,代码走进另一分支:if(distance){legendVisible.value=truelabel.value=distance>=1000?`${distance/1000}km`:`${distance}m`barWidth.value=(distance/pixelDistance)|0}else{legendVisible.value=false// 直接隐藏}换算到具体高度:在本猿这套 1280×640 的视口里,正视下方、相机离地11.085 米时正好卡在 0.01 m/px,再往下压,比例尺就没有了。而 Cesium 的minimumZoomDistance默认是1.0 米(ScreenSpaceCameraController.jsL132),这个边界是用户伸手就能够到的。② 退得太远(> 50 km/px)→ 顶档塌陷反方向的失效更隐蔽:一旦pixelDistance > 50 km/px,唯一的幸存者就是最大档 5000 km,而且从这一刻起,横线宽度不再有下限:每像素米数选中的档位横线宽度50 km/px3000 km60 px100 km/px5000 km50 px851 km/px5000 km5 px5000 km/px5000 km1 px10000 km/px5000 km0 px标签固执地写着 5000 km,横线却已经塌成一条看不见的线。值得注意的是:真的把视角退到这一步之前,你会先撞上另一堵墙(第五节 5.2),所以这里看到的多半是算法层面的性质,而不是你在屏幕上真正能看见的样子。3.4 画出来:一条只有三边的边框和一段内联样式<div v-show="enabled && legendVisible" class="cesium-distance-legend"> <div class="label">{{ label }}</div> <div class="scale-bar" :style="{ width: barWidth + 'px', left: (125 - barWidth) / 2 + 'px' }"></div> </div>.cesium-distance-legend{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);width:125px;height:25px;}.scale-bar{position:absolute;height:10px;top:10px;border-left:1.5px solid #fff;border-right:1.5px solid #fff;border-bottom:1.5px solid #fff;}I 形线是靠三条边框拼出来的(左、右、下),不是一根<hr>;因为宽度在变,用边框才能保证两端永远是竖直的端点。容器宽125 px,比maxBarWidth = 100多留了 25 px,所以任何档位下两侧都至少有约 12.5 px 余量,不会贴着边。left: (125 - barWidth) / 2是为了让变宽的横线始终居中。⚠️这段正确性是踩在项目全局样式上的:本仓库src/style.css第 2 行写着* { box-sizing: border-box; }。也就是说width: barWidth已经把左右各 1.5 px 的边框算进去了,居中是精确的。若把这个组件搬到一个没有这条 reset 的项目里,content-box会让实际宽度多出 3 px,居中随之偏 1.5 px。这种"依赖全局 reset 才对"的代码,是复制粘贴时最容易翻车的一类。barWidth用了(distance / pixelDistance) | 0,向下取整最多丢 1 px;在最窄的 50 px 档位上,这是2% 的误差,肉眼基本看不出来,但别指望它参与计算。四、运行效果初始视角是Cartesian3.fromDegrees(118.8, 32.05, 260000)(南京附近、抬高 260 km)。因为setView只给了 destination 没给 orientation,Cesium 的默认姿态是heading = 0, pitch = -π/2, roll = 0(Camera.jsL1528-1530),也就是镜头笔直朝下。本猿把这个视角按 1280×640 CSS px 复算了一遍,全过程是这样:步骤实测值每像素代表234.956 米选中的档位20000 米显示的标签20 km横线宽度85 px把相机高度从小往大整个推一遍,是这个样子:相机高度米 / 像素标签宽度0.1 km0.09025 m55 px1 km0.902150 m55 px5 km4.5107300 m66 px20 km18.04461 km55 px100 km90.27115 km55 px260 km(初始)234.955720 km85 px1000 km908.197050 km55 px5000 km4674.0244300 km64 px12000 km12012.31971000 km83 px16418 km——射线越过地平线这个控件的面板可能是我们写过的最简陋的一块:一个开关,加一段提示。因为它没有可调参数——所有尺度都是量出来的,唯一的人为设定是"横线最多 100 px",而这恰恰是藏得最深的一个常量。五、避坑与优化5.1canvas.width不是clientWidth:一个被默认值兜住的错误回到 2.2 那段代码,主角是两个变量:constwidth=scene.canvas.width// ← 注意constheight=scene.canvas.height而 Cesium 自己在getPickRayPerspective里(Camera.jsL3007,L3009-3010)读的是:constcanvas=camera._scene.canvas;constwidth=canvas.clientWidth;// ← 是 CSS 像素constheight=canvas.clientHeight;constx=(2.0/width)*windowPosition.x-1.0;两套单位。canvas.width是绘制缓冲的设备像素,clientWidth是 CSS 像素,两者相差一个pixelRatio:// Cesium.js(未压缩)L255472-255493functionconfigurePixelRatio(widget){letpixelRatio=widget._useBrowserRecommendedResolution?1:window.devicePixelRatio;pixelRatio*=widget._resolutionScale;...}functionconfigureCanvasSize(widget){letwidth=canvas.clientWidth;letheight=canvas.clientHeight;constpixelRatio=configurePixelRatio(widget);width*=pixelRatio;height*=pixelRatio;canvas.width=width;canvas.height=height;// = clientWidth × pixelRatio}好消息:useBrowserRecommendedResolution的默认值是true(L255546),resolutionScale默认 1,于是pixelRatio = 1,canvas.width === clientWidth,这段代码默认是对的。坏消息:这个"对"是别人给的。一旦有人为了 Retina 清晰度写下useBrowserRecommendedResolution: false,或者为了性能写下viewer.resolutionScale = 0.5,换算立刻不成立,取样点会顺着倍率飘出画面:pixelRatio取样点落到 NDC画面内?1(默认)(0.000, -0.997)✅ 底边正中1.25(0.250, -1.497)❌ 已越过下边缘1.5(0.500, -1.997)❌2(1.000, -2.997)❌ x 正好压在右边缘、y 已在下边缘之外两屏3(2.000, -4.997)❌注意那个(0.000, -0.997):y 分量之所以是 -0.997 而不是 -1,是因为(2/H)(H - (H-1)) - 1 = -1 + 2/H,它落在底边那一行像素的中心上——这也是为什么代码写的是height - 1而不是height。飘出去之后会发生什么?射线往往还能命中椭球(只是命中了画面外的某片地),于是控件不报错,只给出系统性偏小的数:视角真值×1.5×2×3俯角 45°、260 km20 km-18.4%-30.6%(读成 10 km)-46.1%(读成 10 km)俯角 30°、260 km30 km-25.6%-40.2%(读成 10 km)-56.8%(读成 10 km)正视下方、260 km20 km+1.1%+3.7%+16.1%(-x% 表示量到的"米/像素"比真值小,读数随之偏小)也就是:在正下方俯视时这个错误几乎看不出来,可是一旦变成正常的斜视角,DPR=2 的屏幕上读数只有真值的一半多一点。这才是它阴的地方——受害最深的是最日常的视角,而最容易被拿来验收的(正下方)恰好没事。改成这样就没事了,两个方案任选一个:// 方案一:一切都用 CSS 像素constwidth=scene.canvas.clientWidthconstheight=scene.canvas.clientHeight// 方案二:从 drawingBuffer 折回 CSS 像素constwidth=scene.drawingBufferWidth/scene.pixelRatioconstheight=scene.drawingBufferHeight/scene.pixelRatio5.2 射线没命中:它不是消失,是"定格"注意 2.2 那段里的两句return:if(!leftRay||!rightRay)returnif(!leftPosition||!rightPosition)return两处都是在更新legendVisible之前就返回了。也就是说,一旦射线打空,label和barWidth保持上一次的值,容器也不会隐藏。结果就是一条**“僵尸比例尺”**:画面已经拉到看不清的地方了,角落里还挂着上一次的数字,不再更新,也不肯消失。什么时候会打空?两个都很常见:越过地平线:本猿这套视口里正视下方退到 16,418 km 以上,底边射线本身就已经越过地球边缘(sin 16.1° > R / (R + h)),globe.pick直接返回undefined;本帧没有渲染过的瓦片可供相交:别忘了globe.pick遍历的是_tilesRenderedThisFrame。修法很简单,把失败当成一种状态显式处理掉:letok=truefunctionupdateContent():void{// ...if(!leftPosition||!rightPosition){if(ok){ok=false;legendVisible.value=false}// 打空一次就收起来return}ok=true// ...}顺带把legendVisible的初值考虑清楚:它一开始就是false,所以从来没成功量到过的控件不会显示占位,这点是对的——只是失败时它忘了回到这个初值。5.3preRender慢一帧_tilesRenderedThisFrame是在beginFrame里清空的(QuadtreePrimitive.jsL352),而preRender事件是在渲染动作之前抛出的(Scene.jsL4640)。拼起来看:第 N 帧:preRender(看到的是第 N-1 帧留下的瓦片集合) → beginFrame(清空) → 瓦片入集合 → …所以严格说,你在preRender里拿到的始终是上一帧的瓦片。对比例尺这种"差几毫秒无所谓"的东西完全够用;但如果你要拿globe.pick做精确的定位/量算,请把它放到postRender,或者干脆别用 pick,改用camera.pickEllipsoid这类纯解析路径。另外,一旦项目开了requestRenderMode = true,静止时不会触发任何帧,preRender也就停了——那时必须每一步操作后手动scene.requestRender()。本例的onEnableChange里已经调了viewer.scene.requestRender(),方向是对的。5.4 想改的话,可以从这几处下手参考位置:现在锁定在底边中央,于是整个屏幕只有底边那一行是准的(2.4 节的 1.94 倍)。想要"代表整屏",可以改成取画面中心附近,或者直接提示用户"以屏幕底部为准"。单位切换:当前是distance >= 1000时切成 km。中文习惯里很少说"5000 km",到那一档换成"万公里"会更顺手;做国际化时m/km之外别忘了还有ft/mi——同样一套 1/2/3/5 序列,换一组基准数就行。档位表:28 档只覆盖了 1 m ~ 5000 km。要是你的业务只看城区,不妨裁掉头尾几档,代价是两个失效边界也跟着挪了位置(见 3.3 节)。绑一个显式的高度守卫:legendVisible现在只由"有没有合适的档位"决定,于是"贴到 11 米就会消失"成了一个没人写在明面上的副产品。加一道最小/最大高度的显式判断,比指望别人读懂那句< 100好得多。5.5 上线前 checklist✅ 取样坐标统一用CSS 像素(clientWidth或drawingBufferWidth / pixelRatio),别直接拿canvas.width✅globe.pick返回空时显式处理,不要让控件留着旧值不退场✅ 需要精确值时挪到postRender,或改用解析式pickEllipsoid✅ 开了requestRenderMode就要自己唤起渲染✅ 每帧回调里加节流(本例 250 ms / 最多 4 Hz),并且复用EllipsoidGeodesic对象✅ 组件里的宽高计算要写清楚依赖的是不是全局box-sizing⚠️ 别忘了留一个说明:这条比例尺只对屏幕底边那一行负责六、写在最后Cesium 是头优雅的大象。我们曾经以为,比例尺是一个"画多大"的问题;今天才发现,它是一个"先承认自己测不准哪儿"的问题 —— 两条射线隔开一个像素,测出来的从来不是全屏,只是底边那一行。回头看这一篇,最有意思的不是"怎么画那条线",而是这一路攒下来的四个认知:第一,它承认测不准。底边 vs 顶行 1.94 倍的差距不是 bug,是透视投影的宿命。好的控件会把自己的口径说清楚,而不是假装自己是全屏通用的。第二,一句< 100里藏着一条定理。50~99 px 的锯齿不是玄学,它是"1/2/3/5 序列相邻倍率最大为 2"的直接推论。写到这一行的时候,你可能根本没意识到自己证明了一件事。第三,默认值会替你挡住错误,也会替你藏起错误。useBrowserRecommendedResolution默认true,让canvas.width这个写法侥幸正确;而一旦有人为了清晰度改动它,倾斜视角下的读数直接砍半。这种"被默认值兜着"的代码最危险——它在你的机器上永远是对的。第四,失败要是显式的。两处return让控件在射线打空时既不自愈也不退场,于是我们看到的是一条僵尸比例尺。这里只需要一句legendVisible = false。这个案例还不完美:面板上只有一个开关、好几处行为要跑起来才看得见、依赖全局box-sizing,也没做移动端适配。但它把"地图角落里那条横线是怎么算出来的、什么时候不该信它"讲清楚了,这就够了。🎉下一篇我们先搁下地图控件这口气,去算一件要真动手的事——在一片地形上,挖掉多少、填上多少。空间分析-填挖方分析,我们下一篇见。(至于还在旁边排队的罗盘控件,那是这条线往后的下一站,先按下不表。)如果你也在项目里写过比例尺,欢迎留言说说你是怎么处理"屏幕各处尺度不一致"这件事的——是干脆提示"以屏幕底部为准",还是换了另一种参考位置?🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX代码在src/cases/widget-distance-legend/目录下,核心就一个 138 行的WidgetDistanceLegendDemo.vue,把初始视角的经纬度换成你自己的城市就能跑。Cesium酱の百宝箱 · 第 66 篇两个相邻像素量尽千里,一条横线只认底边那一寸2026 年的秋夜,与你共探"看不见的尺度该怎样被说出来"