资讯动态

〖共创稿事节〗HarmonyOS 7 新特性实战(37):3D 地图的相机投影与射线拾取

发布时间:2026/10/29 11:00:39 来源:尧图企业网站定制
同一张浮雕地图手机竖屏上最近的两枚钉大荒与南山相隔 49.8vp换到平板横屏的视口这一对拉开到 65.1vp2in1 的窗口里是 81.6vp。钉位不是按屏幕百分比摆上去的。九个区域各有一个世界坐标锚点钉子的屏幕位置由相机投影算出来所以钉与钉的间距会跟着视口高度走。命中判定里的兜底半径却是一个写死的 64vp——在手机上它已经比最近那对钉的间距还大。《山海万灵》的地图页要同时应付手机、平板和 2in1 三种窗口还要应付 3D 场景没起来时的平面形态。下面记录这条链路上三处必须自己算清楚的地方投影、标定和触点换算以及两个实测出来的边界。一、三个量决定钉位落在哪ArkGraphics 3D 的场景文档说明相机决定了 3D 场景向 2D 图片的投影过程FoV、近远平面这些参数直接影响渲染结果。落到代码里钉位只受三个量支配-yfovglTF 约定下的竖直视场角模型里是 0.69 弧度 -aspect当前视口的宽高比 - 视口本身Component3D的实际宽高单位 vp。相机基向量由眼睛位置和注视点算出来之后每个世界点先做一次透视除法再乘上两个方向的焦距private analyticProjection(): MythMapProjection { const fy: number 0.5 / Math.tan(MYTH_MAP_CAMERA_YFOV / 2); const aspect: number this.viewportWidth 1 this.viewportHeight 1 ? this.viewportWidth / this.viewportHeight : 1; return new MythMapProjection(0.5, fy / aspect, 0.5, fy); } private viewRatios(world: number[]): number[] | null { const relative: number[] subtract3(world, MYTH_MAP_CAMERA_BASIS.eye); const depth: number dot3(relative, MYTH_MAP_CAMERA_BASIS.forward); if (depth 0.001) { return null; } return [dot3(relative, MYTH_MAP_CAMERA_BASIS.right) / depth, dot3(relative, MYTH_MAP_CAMERA_BASIS.up) / depth]; }fy里的 0.5 是把 NDC 的[-1, 1]折成屏幕比例[0, 1]的因子。这一项漏掉的时候所有钉位会离屏幕中心整整两倍远看上去像相机取景错了其实是换算少乘了一半。横向焦距fx fy / aspect于是宽高比只影响横向铺开程度纵向位置完全由yfov决定。这个结论在三个视口上都能复现视口宽高比九锚点横向范围九锚点纵向范围最近一对钉手机 440×609vp0.7220.231 0.7460.312 0.657大荒/南山 49.8vp平板 1331×797vp1.6700.384 0.6070.312 0.657大荒/南山 65.1vp2in1 1642×999vp1.6440.382 0.6080.312 0.657大荒/南山 81.6vp纵向范围三行一模一样横向跨度从 0.515 收到 0.223。宽屏并不会把地图拉宽它把九枚钉挤向中间——如果钉位是按百分比手摆的横屏下要么互相压住要么跑到画外。二、标定把前提变成实测解析式只依赖yfov是竖直视场角这一个假设。实际运行里还有几个说不准的地方视口高度算不算状态栏、引擎有没有在Component3D内部再加一层偏移、yfov在某些节点上是不是被当成横向。这些都不该靠猜。做法是让引擎自己的射线反解投影参数沿 NDC 横向取 8 个探针、纵向取 5 个每条射线打中地形后拿到世界坐标再和屏幕坐标凑成样本用最小二乘解出cx/fx/cy/fy。解出来的东西如果超出合理区间或者残差太大就判定标定失败继续用解析式。const probeXs: number[] [-0.9, -0.65, -0.4, -0.15, 0.1, 0.35, 0.6, 0.85]; for (let index 0; index probeXs.length; index 1) { const hit: number[] | null await this.raycastNdc(camera, probeXs[index], 0.1); if (hit null) { continue; } const ratio: number[] | null this.viewRatios(hit); if (ratio null) { continue; } ratioXs.push(ratio[0]); screenXs.push((probeXs[index] 1) / 2); } // ...纵向探针同理 const horizontal: number[] ratioXs.length 3 ? linearFit(ratioXs, screenXs) : [0, 0]; const vertical: number[] ratioYs.length 3 ? linearFit(ratioYs.map((value: number) -value), screenYs) : [0, 0]; const usable: boolean fx 0.5 fx 60 fy 0.5 fy 60 this.maxResidual(ratioXs, screenXs, horizontal) 0.02 this.maxResidual(ratioYs.map((value: number) -value), screenYs, vertical) 0.02; if (!usable) { this.calibrated false; return; }门槛是两道焦距必须落在 0.5 到 60 之间最大残差必须小于 0.02归一化屏幕单位。任何一条不满足就退回解析投影页面照常能用只是钉位不再享有实测校正。手机上跑完的结果有点出乎意料反解出来的fx是 1.9257、fy是 1.3912和解析式算出的 1.9257 / 1.3913 一致到小数点后四位横向最大残差 0.00037、纵向 0.00017折算到 1320×1827 的视口上不到 0.5px。也就是说这台设备上标定并没有“修正”任何东西。它做的事情是把三个原本靠假设成立的前提——视口边界、视场角方向、引擎是否另加偏移——变成每次启动都实测一遍。换一台设备、换一次窗口尺寸这套假设就得重新成立一次靠人工记住“上次对过”是记不住的。三、视口一变参数就作废视口尺寸参与投影所以它必须是状态而不是初始化时读一次的常量。Component3D的onAreaChange里比对宽高只有真的变了才重新标定.onAreaChange((_oldValue: Area, newValue: Area) { const width: number Number(newValue.width); const height: number Number(newValue.height); const changed: boolean Math.abs(width - this.viewportWidth) 1 || Math.abs(height - this.viewportHeight) 1; this.viewportWidth width; this.viewportHeight height; this.mapWidth width; this.mapMeasuredHeight height; if (changed) { this.calibrateProjection(); } })这里有个异步竞态标定要等 13 条射线依次返回期间窗口可能又变了一次。解决办法是一个自增的版本号回调落地时先核对版本过期结果直接丢弃const revision: number this.calibrationRevision 1; this.calibrationRevision revision; // ...等待所有射线返回 if (revision ! this.calibrationRevision) { return; }拖拽窗口、切换横竖屏、折叠屏展开都会连续触发多次onAreaChange。没有这个版本号最后落地的往往是先发出去、后返回的那一批样本钉位会稳定地错在上一档尺寸上——这种错法很难复现因为手动拖一次窗口未必能重现同样的时序。四、触点从点击坐标到地块点击事件的坐标是相对Component3D的换算成 NDC 时用的必须是同一份视口尺寸否则触点和钉位会各算各的private pickTerrain(event: ClickEvent): void { if (this.mapCamera null || this.mapWidth 0) { return; } const viewPosition: Vec2 { x: event.x / this.mapWidth * 2 - 1, y: 1 - event.y / this.mapMeasuredHeight * 2 }; const options: RaycastParameters {}; this.mapCamera.raycast(viewPosition, options).then((hits) { const regionId: string | null this.regionIdForNode(hits[0]?.node ?? null) ?? this.regionIdForMapPoint(event); if (regionId ! null) { this.selectRegion(regionId); } }).catch(() { const regionId: string | null this.regionIdForMapPoint(event); if (regionId ! null) { this.selectRegion(regionId); } }); }射线打中的可能是地块顶面也可能是选中时露出的那道侧壁所以不能拿节点名直接比对得沿父链往上找直到名字落进九区名单。射线失败或者打空时才走兜底取屏幕上最近的锚点半径 64vp。兜底半径是个固定值钉间距却随视口高度缩放这两件事在手机上直接打架视口最近一对钉间距兜底半径半径覆盖范围手机 440×609vp49.8vp64vp已盖到相邻钉平板 1331×797vp65.1vp64vp勉强不越界2in1 1642×999vp81.6vp64vp留有余量半径 64vp 比手机上的 49.8vp 还大意味着兜底一旦生效它可能选中隔壁那块地。所以这条路径只能当最后一道射线成功时以射线为准兜底只在引擎调用失败、或者射线打到云海这类不属于任何地块的地方时兜一下。把兜底当主判据、或者为了“手感灵敏”把半径调大在竖屏手机上会直接把误选放大。五、3D 起不来时的平面形态同一份代码在平板模拟器上没能把 3D 场景拉起来页面显示「立体渲染暂不可用已切换至平面地图」。这条分支要能独立完成同样的九次选区所以钉位得另算一套。平面形态的钉位不能再用相机投影它要落在原画上。原画是 941×1672浮雕顶面覆盖的是原画v ∈ [0.10, 0.93]那一条两者共用一套 UV所以世界锚点可以反推回原画坐标private flatFit(): number[] { const scale: number Math.min( this.viewportWidth / MYTH_MAP_ART_WIDTH, this.viewportHeight / MYTH_MAP_ART_HEIGHT ); const renderedWidth: number MYTH_MAP_ART_WIDTH * scale; const renderedHeight: number MYTH_MAP_ART_HEIGHT * scale; return [(this.viewportWidth - renderedWidth) / 2, (this.viewportHeight - renderedHeight) / 2, renderedWidth, renderedHeight]; } private anchorUv(region: MythMapSpatialRegion): number[] { const u: number region.anchorX / MYTH_MAP_MAP_WIDTH 0.5; const v: number MYTH_MAP_V_MIN (MYTH_MAP_V_MAX - MYTH_MAP_V_MIN) * (region.anchorZ / MYTH_MAP_MAP_DEPTH 0.5); return [u, v]; }flatFit用的是 Contain整幅原画等比装进视口、居中留边不裁切。于是左右或上下会出现一条云气带。点选时先判“有没有点在原画上”点在留边上不属于任何地块private pickFlat(event: ClickEvent): void { if (this.viewportWidth 1 || this.viewportHeight 1) { return; } const fit: number[] this.flatFit(); const inside: boolean event.x fit[0] event.x fit[0] fit[2] event.y fit[1] event.y fit[1] fit[3]; if (!inside) { return; } const regionId: string | null this.regionIdForMapPoint(event); if (regionId ! null) { this.selectRegion(regionId); } }平板横屏的视口是 1331×797vp原画按高铺满后只有 448vp 宽左右各留出约 440vp 的云气带——单侧留边和画本身差不多宽。不留这道门点云气带会选中最近的那枚钉用户看到的是「点了空白处却跳了区域」。六、抬起的地块会改变命中选中一块地它会在 220ms 内抬起 0.8 个世界单位沿自己的边界露出侧壁。这个动效会改变命中结果。拿手机上一个固定坐标818, 1129做对照这个点在青丘的格子里上一块被抬起的地块同一坐标的命中结果昆仑青丘北冥北冥两轮各测一次结果一致。坐标没变变的只有场景状态北冥抬起后沿边界长出侧壁这块侧壁在屏幕上正好盖住该点射线先碰到它父链回溯拿到的就是北冥。命中判定因此不是屏幕坐标的纯函数它还依赖场景当前状态。这件事对验收方式的影响比对代码的影响大九个区域挨个点一遍时如果每次都接着上一次的抬起状态点结果就不可比。把顺序排成“上一块地与目标不相邻”九次点击才是同一个基准下的九次测量。七、三台设备的结果设备API 26 模拟器形态命中记录备注Pura X View / phone 1320×2232立体9/9触点落在钉位之外、地块之内靠射线拾取MatePad Pro 13 / tablet 2880×1920平面9/93D 场景未就绪走 UV 线性映射留边点击不选区MateBook Pro / 2in1 3120×2080—未取得模拟器三轮引导均未进入系统本轮无运行记录手机上的九次点击分布在这张图里触点刻意避开钉位药丸钉是 ArkUI 的Button它自己的点击回调直接选区走不到地形射线。要证明拾取触点必须落在钉位之外、又在同一块地的格子内部——所以每个触点都是从地形网格顶点里挑的要求它离区域边界至少 0.045归一化 UV且离所有钉位药丸都有余量。平板这一档是平面形态3D 未就绪的提示和两侧云气带一起出现在同一屏里2in1 这一档没拿到运行证据所以上表只写规则计算值1642×999vp 的视口下九锚点横向落在 0.3820.608最近一对钉 81.6vp。这个数字来自同一份相机常量但没有对应的运行截图不能当作已验收。八、三条可复用的结论钉位间距随视口高度缩放兜底半径是常量两者的关系必须自己盯。固定半径在一种窗口上合适换一档就可能盖住相邻钉。要么按当前视口的钉间距动态收半径要么明确它只是最后一道。这台手机上标定没修正任何数值它把视口边界、视场角方向和引擎偏移逐次验了一遍。反解值和解析式差不到 0.5px看上去白做但“看上去对”和“每次启动都验过”是两件事换设备、换窗口、换引擎版本时只有后者还能站住。命中测试要在同一个基准态下做。抬起的侧壁会遮住相邻地块的触点同一个坐标在两种状态下落到不同区域。验收顺序、状态复位这些看起来是流程问题实际上决定了结论是否成立。平面形态与立体形态的钉间距还差着一倍多同一对钉手机立体形态 49.8vp平板平面形态 119.6vp。两种形态共用一套锚点数据但它们的命中语义完全不同——一个靠射线打网格一个靠屏幕距离比大小。把它们当成“同一功能的两种皮肤”误选就会在某个尺寸上冒出来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑