稿事節(jié)〗HarmonyOS 7 新特性實(shí)戰(zhàn)(35):讓 3D 浮雕沿真實(shí)地貌邊界抬升)
浮雕地圖最容易露餡的時(shí)刻是用戶點(diǎn)下一塊地、它從整幅畫里抬起來的那一瞬間。靜止時(shí)連續(xù)頂面會(huì)遮住大部分接縫地塊抬起后側(cè)壁和外輪廓全部暴露。如果輪廓仍沿規(guī)則網(wǎng)格行走山脊、海岸和云氣邊緣就會(huì)變成一段段水平或豎直的樓梯。提高抗鋸齒沒有用因?yàn)殇忼X已經(jīng)寫進(jìn)幾何形狀本身?!渡胶Hf靈》的第一版浮雕解決了整圖高度場(chǎng)、光照和區(qū)域點(diǎn)擊。本輪繼續(xù)解決更難的一層讓九個(gè)區(qū)域按 880 個(gè)邊界頂點(diǎn)真裁剪抬起的切面跟著地貌曲線走多邊形之外的紙邊獨(dú)立留在原地切面使用自己的巖層材質(zhì)點(diǎn)選后留出足夠時(shí)間讓用戶看清反饋。一、先確認(rèn)鋸齒來自哪里地圖網(wǎng)格為 106×128共 13,568 個(gè)格子。真機(jī)上一個(gè)格子投影約 9.312.7px中心區(qū)域約 10.7px。舊切面沿格子四邊生成因此斜向邊界必然以約 10px 為一級(jí)臺(tái)階。觀察項(xiàng)數(shù)值判斷基礎(chǔ)網(wǎng)格106×128足以表達(dá)起伏不足以表達(dá)精細(xì)區(qū)界單格屏幕投影9.312.7px幾何樓梯肉眼明顯跨區(qū)域格子1,237 / 13,568只需處理約 9.1% 的邊界格區(qū)域邊界9 個(gè)多邊形、880 個(gè)頂點(diǎn)已有可復(fù)用的真實(shí)地貌分界第一輪嘗試把邊界格細(xì)分為 3×3 子格。頂面和切面都同步細(xì)化后臺(tái)階從約 10.7px 降到約 3.6px缺口也消失了。但它仍然只能沿 1/3 格的水平或豎直邊行走本質(zhì)是把一級(jí)大臺(tái)階換成三級(jí)小臺(tái)階。結(jié)論隨之明確需要改變切面的幾何邊而不是繼續(xù)增加規(guī)則網(wǎng)格密度。二、讓邊界折線真正切開網(wǎng)格每個(gè)區(qū)域已有一條連續(xù)多邊形。生成模型時(shí)先找出多邊形邊穿過的格子再用折線把格子面切成若干子面。子面內(nèi)部取一個(gè)探測(cè)點(diǎn)執(zhí)行點(diǎn)在多邊形內(nèi)判斷得到它的精確歸屬。單條線段裁進(jìn)格子可使用 Liang–Barsky 參數(shù)裁剪。端點(diǎn)落在 0 或 1 時(shí)直接復(fù)用原始點(diǎn)避免相鄰格因?yàn)楦↑c(diǎn)計(jì)算生成兩個(gè)幾乎相同的頂點(diǎn)。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] }; };多邊形在一個(gè)格子里可能拐彎因此不能拿每一小段直線分別切面折線中間點(diǎn)位于面內(nèi)部單獨(dú)線段的兩端并不都在面邊界上。正確做法是把完整折線一次插入沿原面邊界分出兩條路徑再分別接回折線的正序或逆序。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)]; };這里最隱蔽的錯(cuò)誤是折線方向接反。兩塊子面仍能生成模型也能加載但面內(nèi)會(huì)多出一條不屬于區(qū)域多邊形的對(duì)角線抬升后表現(xiàn)為邊界外突然長(zhǎng)出一個(gè)凸塊。修復(fù)時(shí)必須確保兩半各自連接折線的正確一端并用幾何連續(xù)性驗(yàn)證結(jié)果。切開的子面不再保證是凸多邊形因此頂面三角化不能繼續(xù)使用扇形展開需要采用耳切并統(tǒng)一頂面繞向。三、短邊不能靠固定步長(zhǎng)采樣發(fā)現(xiàn)最初按固定間隔在 880 條邊界上取樣以此收集受影響格子。大多數(shù)地方正常但一個(gè)長(zhǎng)度只有 0.005 世界單位的角部裁剪被跨過去了整格沒有進(jìn)入切割流程屏幕上留下一個(gè)約 10px 的小凸塊。最終改為 DDA 逐格步進(jìn)沿邊界線每跨過一條豎向或橫向網(wǎng)格線就把進(jìn)入的格子登記下來。無論線段多短只要進(jìn)入某個(gè)格子就不會(huì)漏掉。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; };多個(gè)手繪多邊形還會(huì)共享頂點(diǎn)或局部相交。一條折線的端點(diǎn)可能落進(jìn)已經(jīng)切出的另一個(gè)面里此時(shí)需要按當(dāng)前面集的邊界再次切段。除了常規(guī)線段交點(diǎn)還必須把“折線頂點(diǎn)恰好落在面邊或面頂點(diǎn)”的情況作為斷點(diǎn)這些交點(diǎn)參數(shù)正好是 0 或 1普通相交判斷通常會(huì)忽略它們。四、頂面和切面必須來自同一套邊面切開后每個(gè)子面把自己的有向邊登記進(jìn)edgeUse。同一網(wǎng)格內(nèi)部的共享邊會(huì)出現(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); } }這個(gè)結(jié)構(gòu)保證頂面邊與切面頂邊逐點(diǎn)重合。高度通過所在粗格四個(gè)格點(diǎn)做雙線性插值沿格子邊時(shí)雙線性會(huì)退化為線性所以邊界點(diǎn)既能保持原高度場(chǎng)又不會(huì)引入 T 型接點(diǎn)或裂縫。五、紙邊必須成為永不抬升的獨(dú)立節(jié)點(diǎn)九個(gè)區(qū)域多邊形的并集覆蓋地圖帶約 83.3%剩下 16.7% 是紙邊和云邊。舊實(shí)現(xiàn)用廣度優(yōu)先搜索把無主格歸給最近區(qū)域。靜止時(shí)它補(bǔ)全了頂面區(qū)域抬升時(shí)卻把紙邊也帶起來形成遠(yuǎn)離真實(shí)多邊形的規(guī)則樓梯。新模型把無主面放入獨(dú)立節(jié)點(diǎn)。它直接掛在場(chǎng)景根節(jié)點(diǎn)下不屬于九個(gè)區(qū)域節(jié)點(diǎn)因此選擇動(dòng)畫永遠(yuǎn)不會(huì)移動(dòng)它。它只在整張地圖帶外緣生成切面與區(qū)域相鄰的一側(cè)由區(qū)域切面負(fù)責(zé)避免兩層墻重合閃爍。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ù)細(xì)化網(wǎng)格更關(guān)鍵邊界能否自然不只取決于區(qū)域多邊形本身還取決于“不屬于任何區(qū)域”的部分是否被錯(cuò)誤地拖進(jìn)動(dòng)畫。六、切面要像巖層不能縱向拉伸頂面貼圖舊切面沿用頂面貼圖兩條豎邊的上下端使用同一個(gè) UV。結(jié)果是一行彩繪被縱向拉成淺色帶看起來像模型破面。新切面使用獨(dú)立的 256×256 巖層貼圖。貼圖由可平鋪的噪聲和近水平層理生成UV 的橫軸取沿墻方向的絕對(duì)世界坐標(biāo)縱軸取世界高度。相鄰墻段因此天然連續(xù)不會(huì)每段都從 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] });四個(gè)朝向受光不同最亮朝向的亮度倍率為 2.19貼圖最亮通道為 0.369乘積約 0.807仍低于過曝區(qū)間。受光面呈土棕色背光面保持暗棕色層理能夠被看見又不會(huì)搶走頂面彩繪。七、讓地圖從同源云海中浮起來3D 地圖只覆蓋頁面中部透明區(qū)域原先會(huì)露出另一幅帶圓章和路線的通用背景兩種透視疊在一起邊界處形成硬縫。把云海放進(jìn) 3D 引擎背景也不理想3D 視口從頁頭下方開始環(huán)境背景畫不到狀態(tài)欄和頁頭反而會(huì)在視口上沿增加一道新接縫。最終方案是把同源云海鋪成 ArkUI 頁面底圖讓頁頭、3D 透明視口和 2DContain留邊看到同一層內(nèi)容。運(yùn)行時(shí)關(guān)閉引擎環(huán)境背景與間接環(huán)境光只保留 GLB 中已經(jīng)標(biāo)定的貼圖、自發(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、動(dòng)態(tài)調(diào)整場(chǎng)景節(jié)點(diǎn)及自定義圖片和材質(zhì)官方圖片資源開發(fā)指導(dǎo)也給出了Scene.load()、相機(jī)與Component3D的組合方式。八、動(dòng)畫需要留給用戶看見選中地塊的抬升動(dòng)畫為 220ms。舊邏輯選中后立即關(guān)閉地圖云幕馬上合攏用戶看到的是“點(diǎn)一下云撲上來”幾乎看不到地塊浮起?,F(xiàn)在先更新選區(qū)并執(zhí)行抬升停留 900ms 后再收起地圖。停留期間若再次選擇其他區(qū)域則取消舊定時(shí)器、重新計(jì)時(shí)。手動(dòng)關(guān)閉、重新打開和組件銷毀也會(huì)清理定時(shí)器避免過期回調(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。按九個(gè)區(qū)域錨點(diǎn)和當(dāng)前相機(jī)計(jì)算屏幕位移從 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 里讀出來視覺對(duì)比能說明效果變了卻無法證明三千多片切面都沿真實(shí)邊界。驗(yàn)證腳本直接解析 GLB讀取每片切面四邊形的頂邊計(jì)算它到最近區(qū)域多邊形或地圖帶外緣的距離。指標(biāo)子格細(xì)分真邊界裁剪GLB 大小1,731,140 B1,750,848 B三角面37,70438,366切面四邊形3,0033,068邊界距離中位數(shù) / p90格子量化0.0000 / 0.0000最大邊界距離格子量化0.0255 世界單位偏離達(dá)到 0.05 的切面近乎全部0恰好一整格長(zhǎng)的切面接近 100%21.6%含地圖外緣真機(jī)同取景對(duì)比中昆侖塊內(nèi)部四個(gè)窗口測(cè)得一致的 19px 位移差與高度從 1.25 降至 0.8 的理論值 17.9px接近昆侖外靜止區(qū)域位移為 0排除了整頁或相機(jī)漂移。當(dāng)前模型門禁通過3,068 片切面的最大偏差為 0.0255GLB 保持在 3MB 預(yù)算內(nèi)當(dāng)前 HAP 構(gòu)建成功。官方 SceneResource API還要求場(chǎng)景資源在結(jié)束使用后銷毀頁面退出時(shí)相應(yīng)調(diào)用scene.destroy()并清空引用。十、從規(guī)則網(wǎng)格走到真實(shí)地貌的三個(gè)結(jié)論第一增加網(wǎng)格密度只能讓臺(tái)階變小不能讓臺(tái)階消失。輪廓質(zhì)量由邊界邊本身決定最終必須讓多邊形參與幾何裁剪。第二區(qū)域之外的無主部分也屬于模型設(shè)計(jì)。紙邊若被就近分配給區(qū)域靜止時(shí)沒有問題動(dòng)畫時(shí)就會(huì)帶著錯(cuò)誤輪廓一起抬起。第三3D 反饋由幾何、材質(zhì)和時(shí)間共同組成。連續(xù)邊界負(fù)責(zé)形狀巖層貼圖負(fù)責(zé)側(cè)壁質(zhì)感云海負(fù)責(zé)空間銜接900ms 停留負(fù)責(zé)讓用戶真正看見這次選擇。缺少其中任何一層地圖都容易退回“技術(shù)上是 3D觀感仍像一張被切開的平面圖”。