稿事節(jié)〗HarmonyOS 7 新特性實戰(zhàn)(35):讓 3D 浮雕沿真實地貌邊界抬升)
浮雕地圖最容易露餡的時刻是用戶點下一塊地、它從整幅畫里抬起來的那一瞬間。靜止時連續(xù)頂面會遮住大部分接縫地塊抬起后側(cè)壁和外輪廓全部暴露。如果輪廓仍沿規(guī)則網(wǎng)格行走山脊、海岸和云氣邊緣就會變成一段段水平或豎直的樓梯。提高抗鋸齒沒有用因為鋸齒已經(jīng)寫進幾何形狀本身?!渡胶Hf靈》的第一版浮雕解決了整圖高度場、光照和區(qū)域點擊。本輪繼續(xù)解決更難的一層讓九個區(qū)域按 880 個邊界頂點真裁剪抬起的切面跟著地貌曲線走多邊形之外的紙邊獨立留在原地切面使用自己的巖層材質(zhì)點選后留出足夠時間讓用戶看清反饋。一、先確認鋸齒來自哪里地圖網(wǎng)格為 106×128共 13,568 個格子。真機上一個格子投影約 9.312.7px中心區(qū)域約 10.7px。舊切面沿格子四邊生成因此斜向邊界必然以約 10px 為一級臺階。觀察項數(shù)值判斷基礎(chǔ)網(wǎng)格106×128足以表達起伏不足以表達精細區(qū)界單格屏幕投影9.312.7px幾何樓梯肉眼明顯跨區(qū)域格子1,237 / 13,568只需處理約 9.1% 的邊界格區(qū)域邊界9 個多邊形、880 個頂點已有可復(fù)用的真實地貌分界第一輪嘗試把邊界格細分為 3×3 子格。頂面和切面都同步細化后臺階從約 10.7px 降到約 3.6px缺口也消失了。但它仍然只能沿 1/3 格的水平或豎直邊行走本質(zhì)是把一級大臺階換成三級小臺階。結(jié)論隨之明確需要改變切面的幾何邊而不是繼續(xù)增加規(guī)則網(wǎng)格密度。二、讓邊界折線真正切開網(wǎng)格每個區(qū)域已有一條連續(xù)多邊形。生成模型時先找出多邊形邊穿過的格子再用折線把格子面切成若干子面。子面內(nèi)部取一個探測點執(zhí)行點在多邊形內(nèi)判斷得到它的精確歸屬。單條線段裁進格子可使用 Liang–Barsky 參數(shù)裁剪。端點落在 0 或 1 時直接復(fù)用原始點避免相鄰格因為浮點計算生成兩個幾乎相同的頂點。const clipSegmentToRect (a, b, rect) { const dx b[0] - a[0]; const dz b[1] - a[1]; let t0 0; let t1 1; for (const [p, q] of [ [-dx, a[0] - rect.x0], [dx, rect.x1 - a[0]], [-dz, a[1] - rect.z0], [dz, rect.z1 - a[1]] ]) { if (p 0) { if (q 0) return null; continue; } const ratio q / p; if (p 0) t0 Math.max(t0, ratio); else t1 Math.min(t1, ratio); if (t0 t1) return null; } return { lo: t0 0 ? a : [a[0] dx * t0, a[1] dz * t0], hi: t1 1 ? b : [a[0] dx * t1, a[1] dz * t1] }; };多邊形在一個格子里可能拐彎因此不能拿每一小段直線分別切面折線中間點位于面內(nèi)部單獨線段的兩端并不都在面邊界上。正確做法是把完整折線一次插入沿原面邊界分出兩條路徑再分別接回折線的正序或逆序。const splitFaceByChain (face, chain) { const a chain[0]; const b chain[chain.length - 1]; const slots insertEndpointsIntoFace(face, a, b); if (slots.a 0 || slots.b 0 || slots.a slots.b) { return null; } const low Math.min(slots.a, slots.b); const high Math.max(slots.a, slots.b); const forward slots.points.slice(low, high 1); const backward [ ...slots.points.slice(high), ...slots.points.slice(0, low 1) ]; const interior chain.slice(1, -1); const forwardFromA slots.a low; const left cleanup([ ...forward, ...(forwardFromA ? interior.slice().reverse() : interior) ]); const right cleanup([ ...backward, ...(forwardFromA ? interior : interior.slice().reverse()) ]); return [normalizeWinding(left), normalizeWinding(right)]; };這里最隱蔽的錯誤是折線方向接反。兩塊子面仍能生成模型也能加載但面內(nèi)會多出一條不屬于區(qū)域多邊形的對角線抬升后表現(xiàn)為邊界外突然長出一個凸塊。修復(fù)時必須確保兩半各自連接折線的正確一端并用幾何連續(xù)性驗證結(jié)果。切開的子面不再保證是凸多邊形因此頂面三角化不能繼續(xù)使用扇形展開需要采用耳切并統(tǒng)一頂面繞向。三、短邊不能靠固定步長采樣發(fā)現(xiàn)最初按固定間隔在 880 條邊界上取樣以此收集受影響格子。大多數(shù)地方正常但一個長度只有 0.005 世界單位的角部裁剪被跨過去了整格沒有進入切割流程屏幕上留下一個約 10px 的小凸塊。最終改為 DDA 逐格步進沿邊界線每跨過一條豎向或橫向網(wǎng)格線就把進入的格子登記下來。無論線段多短只要進入某個格子就不會漏掉。const rasterizeEdgeCells (a, b) { const cells []; let column worldColumn(a[0]); let row worldRow(a[1]); const endColumn worldColumn(b[0]); const endRow worldRow(b[1]); const stepX Math.sign(b[0] - a[0]); const stepZ Math.sign(b[1] - a[1]); let nextX firstGridCrossingX(a, column, stepX); let nextZ firstGridCrossingZ(a, row, stepZ); while (true) { cells.push(row * GRID_COLUMNS column); if (column endColumn row endRow) break; if (nextX.t nextZ.t) { column stepX; nextX advanceGridCrossingX(nextX, stepX); } else { row stepZ; nextZ advanceGridCrossingZ(nextZ, stepZ); } } return cells; };多個手繪多邊形還會共享頂點或局部相交。一條折線的端點可能落進已經(jīng)切出的另一個面里此時需要按當前面集的邊界再次切段。除了常規(guī)線段交點還必須把“折線頂點恰好落在面邊或面頂點”的情況作為斷點這些交點參數(shù)正好是 0 或 1普通相交判斷通常會忽略它們。四、頂面和切面必須來自同一套邊面切開后每個子面把自己的有向邊登記進edgeUse。同一網(wǎng)格內(nèi)部的共享邊會出現(xiàn)兩次只出現(xiàn)一次的邊才是外輪廓只有這些邊需要生成切面。const edgeUse new Map(); function registerFace(face) { emitTriangulatedTop(face); for (let index 0; index face.length; index 1) { const a face[index]; const b face[(index 1) % face.length]; const key undirectedWeldKey(a, b); const current edgeUse.get(key); if (current ! undefined) { current.count 1; } else { edgeUse.set(key, { count: 1, a, b, outward: outwardNormal(a, b) }); } } } for (const edge of edgeUse.values()) { if (edge.count 1 acceptWall(edge.a, edge.b)) { emitWall(edge.a, edge.b, edge.outward); } }這個結(jié)構(gòu)保證頂面邊與切面頂邊逐點重合。高度通過所在粗格四個格點做雙線性插值沿格子邊時雙線性會退化為線性所以邊界點既能保持原高度場又不會引入 T 型接點或裂縫。五、紙邊必須成為永不抬升的獨立節(jié)點九個區(qū)域多邊形的并集覆蓋地圖帶約 83.3%剩下 16.7% 是紙邊和云邊。舊實現(xiàn)用廣度優(yōu)先搜索把無主格歸給最近區(qū)域。靜止時它補全了頂面區(qū)域抬升時卻把紙邊也帶起來形成遠離真實多邊形的規(guī)則樓梯。新模型把無主面放入獨立節(jié)點。它直接掛在場景根節(jié)點下不屬于九個區(qū)域節(jié)點因此選擇動畫永遠不會移動它。它只在整張地圖帶外緣生成切面與區(qū)域相鄰的一側(cè)由區(qū)域切面負責避免兩層墻重合閃爍。function addRegionMesh(region, regionIndex, wallMaterialIndex) { return buildReliefMesh({ name: ${region.id}_relief, wallMaterialIndex, wallRegion: regionIndex, acceptPiece: (piece) piece.owner regionIndex, acceptWall: () true }); } function addFieldMesh(wallMaterialIndex) { return buildReliefMesh({ name: myth_map_relief_field, wallMaterialIndex, wallRegion: -1, acceptPiece: (piece) piece.owner 0, acceptWall: onMapBandRim }); }這一步比繼續(xù)細化網(wǎng)格更關(guān)鍵邊界能否自然不只取決于區(qū)域多邊形本身還取決于“不屬于任何區(qū)域”的部分是否被錯誤地拖進動畫。六、切面要像巖層不能縱向拉伸頂面貼圖舊切面沿用頂面貼圖兩條豎邊的上下端使用同一個 UV。結(jié)果是一行彩繪被縱向拉成淺色帶看起來像模型破面。新切面使用獨立的 256×256 巖層貼圖。貼圖由可平鋪的噪聲和近水平層理生成UV 的橫軸取沿墻方向的絕對世界坐標縱軸取世界高度。相鄰墻段因此天然連續(xù)不會每段都從 0 開始。const WALL_TEXTURE_SPAN_U 4; const WALL_TEXTURE_SPAN_V 3; const wallUv (point, outward) [ (outward[0] ! 0 ? point[2] : point[0]) / WALL_TEXTURE_SPAN_U, point[1] / WALL_TEXTURE_SPAN_V ]; materials.push({ name: myth_map_relief_wall, pbrMetallicRoughness: { baseColorTexture: { index: 1 }, metallicFactor: 0, roughnessFactor: 0.95 }, emissiveTexture: { index: 1 }, emissiveFactor: [0.75, 0.75, 0.75] });四個朝向受光不同最亮朝向的亮度倍率為 2.19貼圖最亮通道為 0.369乘積約 0.807仍低于過曝區(qū)間。受光面呈土棕色背光面保持暗棕色層理能夠被看見又不會搶走頂面彩繪。七、讓地圖從同源云海中浮起來3D 地圖只覆蓋頁面中部透明區(qū)域原先會露出另一幅帶圓章和路線的通用背景兩種透視疊在一起邊界處形成硬縫。把云海放進 3D 引擎背景也不理想3D 視口從頁頭下方開始環(huán)境背景畫不到狀態(tài)欄和頁頭反而會在視口上沿增加一道新接縫。最終方案是把同源云海鋪成 ArkUI 頁面底圖讓頁頭、3D 透明視口和 2DContain留邊看到同一層內(nèi)容。運行時關(guān)閉引擎環(huán)境背景與間接環(huán)境光只保留 GLB 中已經(jīng)標定的貼圖、自發(fā)光和方向光const loaded: Scene await Scene.load($rawfile(myth_map_relief_v1.glb)); loaded.environment.backgroundType EnvironmentBackgroundType.BACKGROUND_NONE; loaded.environment.indirectDiffuseFactor { x: 0, y: 0, z: 0, w: 0 }; loaded.environment.indirectSpecularFactor { x: 0, y: 0, z: 0, w: 0 }; loaded.environment.environmentMapFactor { x: 0, y: 0, z: 0, w: 0 }; this.scene loaded; this.mapCamera findCamera(loaded.root); if (this.mapCamera ! null) { this.mapCamera.enabled true; this.mapCamera.clearColor { r: 0, g: 0, b: 0, a: 0 }; }HarmonyOS 的 ArkGraphics 3D 功能介紹明確支持異步加載 glTF、動態(tài)調(diào)整場景節(jié)點及自定義圖片和材質(zhì)官方圖片資源開發(fā)指導(dǎo)也給出了Scene.load()、相機與Component3D的組合方式。八、動畫需要留給用戶看見選中地塊的抬升動畫為 220ms。舊邏輯選中后立即關(guān)閉地圖云幕馬上合攏用戶看到的是“點一下云撲上來”幾乎看不到地塊浮起。現(xiàn)在先更新選區(qū)并執(zhí)行抬升停留 900ms 后再收起地圖。停留期間若再次選擇其他區(qū)域則取消舊定時器、重新計時。手動關(guān)閉、重新打開和組件銷毀也會清理定時器避免過期回調(diào)修改已離開的頁面。const MAP_REGION_DWELL_MS: number 900; private regionDwellTimer: number -1; private cancelRegionDwell(): void { if (this.regionDwellTimer ! -1) { clearTimeout(this.regionDwellTimer); this.regionDwellTimer -1; } } private pickRegionOnMap(regionId: string): void { this.cancelRegionDwell(); this.onSelectRegion(regionId); this.regionDwellTimer setTimeout(() { this.regionDwellTimer -1; this.showWorldMap false; }, MAP_REGION_DWELL_MS); }抬升高度也從 1.25 世界單位降為 0.8。按九個區(qū)域錨點和當前相機計算屏幕位移從 12.715.9vp 降到 8.110.2vp更接近淺浮雕被輕輕揭起而不是整塊厚板被頂出畫面。const RAISED_Y: number 0.8; private animateElevation(selectedRegionId: string): void { const revision this.elevationRevision; const startedAt Date.now(); const tick (): void { if (revision ! this.elevationRevision || this.scene null) return; const linear Math.min(1, (Date.now() - startedAt) / 220); const eased 1 - Math.pow(1 - linear, 3); REGION_IDS.forEach((regionId: string) { const node this.regionNode(regionId); if (node ! null) { node.position { x: 0, y: regionId selectedRegionId ? RAISED_Y * eased : 0, z: 0 }; } }); this.scene.renderFrame(); if (linear 1) setTimeout(tick, 16); }; tick(); }九、幾何正確性必須從 GLB 里讀出來視覺對比能說明效果變了卻無法證明三千多片切面都沿真實邊界。驗證腳本直接解析 GLB讀取每片切面四邊形的頂邊計算它到最近區(qū)域多邊形或地圖帶外緣的距離。指標子格細分真邊界裁剪GLB 大小1,731,140 B1,750,848 B三角面37,70438,366切面四邊形3,0033,068邊界距離中位數(shù) / p90格子量化0.0000 / 0.0000最大邊界距離格子量化0.0255 世界單位偏離達到 0.05 的切面近乎全部0恰好一整格長的切面接近 100%21.6%含地圖外緣真機同取景對比中昆侖塊內(nèi)部四個窗口測得一致的 19px 位移差與高度從 1.25 降至 0.8 的理論值 17.9px接近昆侖外靜止區(qū)域位移為 0排除了整頁或相機漂移。當前模型門禁通過3,068 片切面的最大偏差為 0.0255GLB 保持在 3MB 預(yù)算內(nèi)當前 HAP 構(gòu)建成功。官方 SceneResource API還要求場景資源在結(jié)束使用后銷毀頁面退出時相應(yīng)調(diào)用scene.destroy()并清空引用。十、從規(guī)則網(wǎng)格走到真實地貌的三個結(jié)論第一增加網(wǎng)格密度只能讓臺階變小不能讓臺階消失。輪廓質(zhì)量由邊界邊本身決定最終必須讓多邊形參與幾何裁剪。第二區(qū)域之外的無主部分也屬于模型設(shè)計。紙邊若被就近分配給區(qū)域靜止時沒有問題動畫時就會帶著錯誤輪廓一起抬起。第三3D 反饋由幾何、材質(zhì)和時間共同組成。連續(xù)邊界負責形狀巖層貼圖負責側(cè)壁質(zhì)感云海負責空間銜接900ms 停留負責讓用戶真正看見這次選擇。缺少其中任何一層地圖都容易退回“技術(shù)上是 3D觀感仍像一張被切開的平面圖”。