news 2026/10/29 11:00:33

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

作者头像

张小明

前端开发工程师

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

同一张浮雕地图,手机竖屏上最近的两枚钉(大荒与南山)相隔 49.8vp;换到平板横屏的视口,这一对拉开到 65.1vp;2in1 的窗口里是 81.6vp。

钉位不是按屏幕百分比摆上去的。九个区域各有一个世界坐标锚点,钉子的屏幕位置由相机投影算出来,所以钉与钉的间距会跟着视口高度走。命中判定里的兜底半径却是一个写死的 64vp——在手机上,它已经比最近那对钉的间距还大。

《山海万灵》的地图页要同时应付手机、平板和 2in1 三种窗口,还要应付 3D 场景没起来时的平面形态。下面记录这条链路上三处必须自己算清楚的地方:投影、标定和触点换算,以及两个实测出来的边界。

一、三个量决定钉位落在哪

ArkGraphics 3D 的场景文档说明相机决定了 3D 场景向 2D 图片的投影过程,FoV、近远平面这些参数直接影响渲染结果。落到代码里,钉位只受三个量支配:

-yfov:glTF 约定下的竖直视场角,模型里是 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.1vp
2in1 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.382~0.608,最近一对钉 81.6vp。这个数字来自同一份相机常量,但没有对应的运行截图,不能当作已验收。

八、三条可复用的结论

钉位间距随视口高度缩放,兜底半径是常量,两者的关系必须自己盯。固定半径在一种窗口上合适,换一档就可能盖住相邻钉。要么按当前视口的钉间距动态收半径,要么明确它只是最后一道。

这台手机上标定没修正任何数值,它把视口边界、视场角方向和引擎偏移逐次验了一遍。反解值和解析式差不到 0.5px,看上去白做;但“看上去对”和“每次启动都验过”是两件事,换设备、换窗口、换引擎版本时,只有后者还能站住。

命中测试要在同一个基准态下做。抬起的侧壁会遮住相邻地块的触点,同一个坐标在两种状态下落到不同区域。验收顺序、状态复位这些看起来是流程问题,实际上决定了结论是否成立。

平面形态与立体形态的钉间距还差着一倍多:同一对钉,手机立体形态 49.8vp,平板平面形态 119.6vp。两种形态共用一套锚点数据,但它们的命中语义完全不同——一个靠射线打网格,一个靠屏幕距离比大小。把它们当成“同一功能的两种皮肤”,误选就会在某个尺寸上冒出来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 10:58:03

STM32 FreeRTOS 实战学习笔记:从任务调度到任务间通信(CMSIS-RTOS V2)

一、前言在 STM32 裸机开发中&#xff0c;程序通常由主循环和中断服务函数组成。随着项目功能增加&#xff0c;传感器采集、数据处理、OLED 显示、串口通信等功能逐渐耦合在一起&#xff0c;容易出现代码结构混乱、任务响应不及时、共享资源冲突等问题。FreeRTOS 是一种面向嵌入…

作者头像 李华
网站建设 2026/10/29 10:58:01

音频频谱怎么判断音质的好与坏?三个能判断与三个判断不了的

论坛看到有人说&#xff0c;把文件拖进频谱图&#xff0c;一眼就能看出这音频被压过、被砍过高频、甚至是假无损。你也照做了&#xff1a;图放大到像素级&#xff0c;密密麻麻的亮线&#xff0c;看不出所以然。有人说 16kHz 以上一片黑就是假无损&#xff0c;你的图上确实是黑的…

作者头像 李华
网站建设 2026/10/29 10:57:50

云端 GPU 基线如何服务边缘部署:功能、延迟与算子兼容性分层验收

模型在云端 GPU 上完成推理测试&#xff0c;输出也符合预期。接下来准备部署到边缘设备&#xff0c;是否可以直接沿用云端测试报告&#xff1f; 不能。 从工程验收的角度看&#xff0c;云端功能测试与目标设备部署测试属于不同的证据范围。 云端测试可以建立功能基线&#x…

作者头像 李华
网站建设 2026/10/29 10:55:22

AI 来了之后,我的心流状态去哪了

&#x1f4a1; 很多 Java 同学都有一个疑惑&#xff1a;背了几百道八股文&#xff0c;刷了一堆面试题&#xff0c;可面试依旧频频挂掉&#xff1f; 不是你知识点记不住&#xff0c;而是面试官早就变了考核思路&#xff01; 以前面试官可能会问&#xff1a;什么是 JVM 内存模型&…

作者头像 李华
网站建设 2026/10/29 10:54:54

找开源 OA+人力项目?这套 SpringBoot + Vue3 系统值得看看

找开源 OA&#xff0b;人力项目&#xff1f;这套 SpringBoot Vue3 系统值得看看&#x1f310; 文档地址&#xff1a;https://ruoyioffice.com &#x1f447; 文章底部获取源码和演示地址 &#x1f447; &#x1f4ac; &#xff1a;17156169080&#xff08;获取产品咨询&#x…

作者头像 李华