支付寶 AR 空中寫福技術揭祕

語言: CN / TW / HK

編者按:本文作者為螞蟻集團前端工程師胡松,為你揭祕支付寶五福專案中,如何利用 AR 技術實現 AR 空中寫福,歡迎享用~

在支付寶五福專案中,去年我們推出了手寫福,獲得了不錯的反響,而今年在互動形式上做了一些創新,通過 AR 技術讓使用者感受到『福臨其境』。我們利用 AR 技術推出了 AR 空中寫福,讓使用者可以在實景之中創作福字,把福字放到真實空間中,也可以在空間中重現來自親友的 3D 福字。

背景

隨著網際網路產品的互動的深入發展,AR 技術逐漸火熱。目前 AR 技術在遊戲、醫療、交通、服裝和教育等領域發揮作用。但在營銷互動領域,卻沒有出現大量的應用。

許多專案躍躍欲試,希望通過技術創新突破現有營銷的互動形態。但是在技術側卻有諸多限制,目前主要有:

  1. 硬體限制嚴格:互動營銷主要基於 H5,WebXR 技術尚未成熟,只能基於(ARKit,ARCore)

  2. 開發成本過高:需要前端、客戶端、演算法聯合開發

  3. 效能容易陷入瓶頸:AR 與 AI 算力對 CPU 和 GPU 的佔用過高

我們技術基建的現狀:

  1. 支付寶小遊戲容器:支付寶小遊戲容器主要提供渲染能力,封裝了底層的 OpenGL,暴露成類 WebGL API。還有把 Native 能力通過 JSAPI 暴露給業務側。注意:小遊戲容器並沒有 DOM 的實現,所以 GUI 都主要通過 WebGL API 去繪製。

  2. ARSession:顧名思義是 AR 能力的輸出,底層是 ARCore/ARKit/陀螺儀。

  3. Oasis Engine:作為遊戲引擎提供了上層的封裝,可以輕鬆地開發 3D 應用,並且通過介面卡將部分 H5 API 橋接到小遊戲容器層。

通過上面幾項技術,我們可以通過前端(小程式)的技術棧和開發模式,提升了 AR 專案開發和迭代效率。接下來我們說下專案的開發流程。

支付寶 AR 的接入

1. 建立前端腳手架

我們採用了 Vite 作為專案工具去做 transpile 和 bundle。Vite 本身十分靈活,編譯效率非常高,lib 模式可以直接打包出一個純 js 檔案提供給小遊戲容器使用。我們提取出了一套通用的小遊戲腳手架,支援了 TypeScript、NPM Packages,更符合現代前端的開發模式,詳見開源倉庫 create-oasis-app。

2. 適配小遊戲容器

小遊戲介面卡幫助我們抹平 Web 端和小程式/小遊戲端 的API 差異,比如 Image 介面,按照 Web 端的寫法 new Image()即可,不用管小程式的 canvas.createImage()或者小遊戲的 my.createImage()。這意味著你的專案只要能夠讓 Oasis Engine 在瀏覽器端正常執行,就可以通過介面卡在小遊戲等平臺執行。詳見開源倉庫:miniprogram-adapter。

3. 繪製相機背景

雖然介面卡能夠抹平 API 差異,但是容器內 ARSession 的能力 API 是獨有的。ARSession 返回的其實是每幀相機的一些相關資料,我們看到的 AR 背景是將相機資料通過 shader 繪製到螢幕上面的,接下來介紹如何通過 Oasis 建立一個背景,並每幀更新 AR 背景:

3.1. 初始化 AR 背景

我們先建立一個全屏的平面,用來繪製返回的相機資料,因為資料顏色格式是 YUV 格式,所以我們在片元著色器裡面主要就幹一件事,就是將 YUV 轉成 RGB 顏色。

// 頂點著色器的程式碼就是繪製一個全屏的平面,用來繪製 AR 背景
const yuv_vs = `
attribute vec3 POSITION;
attribute vec2 TEXCOORD_0;

uniform mat4 u_uvMatrix;
varying vec2 v_uv;

void main() {
vec2 flipUV = TEXCOORD_0;
flipUV.y = 1.0 - flipUV.y;
v_uv = (u_uvMatrix * vec4(flipUV, 1.0, 1.0)).xy;
gl_Position = vec4( POSITION.xy, 1.0, 1.0);
}
`;

// 安卓端 YUV -> RGB 片元著色器程式碼
const yuv_fs_Android = `
uniform sampler2D u_frameY;
uniform sampler2D u_frameUV;
varying vec2 v_uv;

void main() {
float y = texture2D(u_frameY, v_uv).a;
vec4 uvColor = texture2D(u_frameUV, v_uv);
float u = uvColor.a - 0.5;
float v = uvColor.r - 0.5;

float r = y + 1.13983 * v;
float g = y - 0.39465 * u - 0.58060 * v;
float b = y + 2.03211 * u;

gl_FragColor = vec4(r, g, b, 1.0);
}
`;

// iOS 端 YUV -> RGB 片元著色器程式碼
const yuv_fs_iOS = `
uniform sampler2D u_frameY;
uniform sampler2D u_frameUV;
varying vec2 v_uv;

void main() {
float y = texture2D(u_frameY, v_uv).a;
vec4 uvColor = texture2D(u_frameUV, v_uv);
float u = uvColor.r - 0.5;
float v = uvColor.a - 0.5;

float r = y + 1.04 * v;
float g = y - 0.343 * u - 0.711 * v;
float b = y + 1.765 * u;

gl_FragColor = vec4(r, g, b, 1.0);
}
`;

// 建立一個全屏平面,用來繪製 AR 背景
const bgEntity = rootEntity.createChild("ar-bg");
const bgRenderer = bgEntity.addComponent(MeshRenderer);
bgRenderer.mesh = PrimitiveMesh.createPlane(engine, 2, 2);

// 平面的材質,即上面的 YUV 轉 RGB shader 程式碼
const bgMaterial = new Material(
  engine,
  isAndroid ? Shader.create("ar-bg-android", yuv_vs, yuv_fs_Android) : Shader.create("ar-bg-ios", yuv_vs, yuv_fs_iOS)
);
bgMaterial.renderState.depthState.compareFunction = CompareFunction.LessEqual;
// 先繪製不透明的物體再繪製 AR 背景,可以減少繪製遮擋的畫素,提高效能
bgMaterial.renderQueueType = RenderQueueType.AlphaTest + 1;
bgMaterial.shaderData.setMatrix("u_uvMatrix", new Matrix());
// 禁止視椎體裁剪,因為要保證360度都能全屏顯示 AR 背景
camera.enableFrustumCulling = false;

3.2. 每幀更新背景

初始化背景後,我們就只需要每幀更新相機資料。ARFrame 不僅包含了相機的影片流資訊,也包含了相機的空間位置,通過修改 Transform.worldMatrix 可以讓 3D 空間的相機與 AR 空間的相機座標同步。ARFrame 的資料可以從 onARFrame 的回撥函式中每幀獲取:

myARSession.onARFrame((arframe) => {
  updateBackgroundScene(arframe);
});

從上面的 shader 程式碼中可看到,一共有幾個uniform變數的值需要每幀更新 u_uvMatrixu_frameYu_frameUV。於是我們封裝一個 updateBackgroundScene 函式用來更新 ARFrame 的資料:

function updateBackgroundScene(arframe) {
  const w = arframe.width;
  const h = arframe.height;
  const len = w * h;
  if (len <= 0) {
    return;
  }

  // 更新 u_uvMatrix
  if (arframe.capturedImageMatrix) {
    const matrix = bgMaterial.shaderData.getMatrix("u_uvMatrix");
    matrix.setValueByArray(arframe.capturedImageMatrix);
  }

  if (arframe.capturedImage) {
    const cameraFrame = arframe.capturedImage;
    let textureFrameY = bgMaterial.shaderData.getTexture("u_frameY");
    let textureFrameUV = bgMaterial.shaderData.getTexture("u_frameUV");

    if (!textureFrameY) {
      textureFrameY = new Texture2D(engine, w, h, TextureFormat.Alpha8, false);
      textureFrameY.wrapModeU = textureFrameY.wrapModeV = TextureWrapMode.Clamp;
      bgMaterial.shaderData.setTexture("u_frameY", textureFrameY);
    }

    if (!textureFrameUV) {
      textureFrameUV = new Texture2D(engine, w / 2, h / 2, TextureFormat.LuminanceAlpha, false);
      textureFrameUV.wrapModeU = textureFrameUV.wrapModeV = TextureWrapMode.Clamp;
      bgMaterial.shaderData.setTexture("u_frameUV", textureFrameUV);
    }

    // 更新 u_frameY
    textureFrameY.setPixelBuffer(new Uint8Array(cameraFrame, 0, len));

    // 更新 u_frameUV
    textureFrameUV.setPixelBuffer(new Uint8Array(cameraFrame, len));
  }
}

空中寫福的玩法實現

如何實現寫福

AR 空中寫福分為兩條業務鏈路:寫福和福字回放。首先是寫福鏈路,紅色方塊為關鍵技術點,綠色部分為關鍵的客戶端 API 的依賴:

接下來我們說下紅色部分的關鍵技術實現。

筆刷轉向量

我們使用的是 2D 福字向量化後擠出幾何體的技術方案。為了看效果,我們先在 Blender 中做了初步的嘗試,覺得效果還不錯,再進一步進行技術調研。

調研過程中,我們使用了多個開源專案實現了筆刷的繪製、向量化和擠出。我們前期通過技術調研 + 遷移庫到小遊戲 + 技術 Demo 確定了技術可行性,確保了技術產品化的第一步。

首先,為了實現筆刷,我們移植開源庫 shodo 到小遊戲容器中,裁剪不必要的功能,實現不錯的書寫效果。

然後,我們使用 potrace 開源庫把點陣圖資料變成向量資料。雖然 potrace 有 js 版本,但是因為 iOS 容器中缺少 JIT,js 運算效率太低,最後我們把 potrace 的 c++ 版本植入到客戶端中,通過 JSAPI 呼叫。

最後,擠出方案我們調研了 ThreeJS 的 ExtrudeGeometry 和 geometry-extrude,由於後者和 potrace 都使用標準的 GeoJSON 的資料介面,所以我們採用了第二種方案。

貼紙

完成寫福後,使用者可以從列表中選擇貼紙,還有拖拽刪除等功能。每一個貼紙都是一個 glTF 模型,新增貼紙其實就是一個載入模型的過程。在新增 glTF 模型後,可以給 entity 新增一個自定義元件,這裡我們起名 DragComponent。在元件的 onPointerDownonPointerDrag生命週期內實現拖拽行為,具體程式碼可以參考 https://oasisengine.cn/0.6/docs/input-cn。貼紙使用了白色外描邊的高亮方式,使對比更加明顯自然,且效能最佳。

轉場效果

在 2D 寫福和貼紙完成之後,開始了 2D 到 3D 的轉場:

由上圖可看出,福字無縫切換到立體的過程,需要有一個 2D 轉 3D 的逆變換,福字本身是在一個 NDC 空間內,需要先計算出在正交相機下的 orthographicSize 和福字的縮放比例,實現福字轉到 3D 空間內的大小一致。

因為之後的效果需要整個場景放在 3D 空間並且用正交相機展示,所以還需要一個正交到透視的線性插值,使得視角的變換有一定的過度。此時渲染的 3D 福字和貼紙處於旋轉中。使用者基本察覺不出一點微小的變化。並且在 3D 轉場中,播放了一個 Lottie 效果,讓整個過程變得炫酷起來。

在後續的迴圈中,相機的變換會根據 AR 相機不斷更新位置,使得使用者可以 360 度觀察特效和 3D 福在空間中的效果,而其餘的包括特效,貼紙,3D 福在內的所有東西,都留在原地靜待觀察即可。

如何實現福字回放

回放就是把原始的筆觸資料記錄下來,再通過筆刷 + 向量化 + 擠出的過程去完成。回放包括三個部分:記錄資料,上傳資料和資料回放。

1. 記錄資料

筆跡資料的資料結構是這樣的:

{
  char: [
    [[x, y, timestamp], [x, y, timestamp], [x, y, timestamp]],
    [[x, y, timestamp], [x, y, timestamp]],
    [[x, y, timestamp], [x, y, timestamp], [x, y, timestamp], [x, y, timestamp]]
  ],
  brush: { icon, extInfo },
  canvas2D: { width, height }
}
  • char: 筆跡資料,這是一個三層陣列,最外層陣列代表整個字,其中每一項代表代表一個筆劃,比如例子中這個筆跡資料有三個筆劃;第二層陣列代表一個筆劃,其中每一項代表這個筆劃的一個點,比如例子中第一筆有三個點;第三層陣列就代表一個點資料,其中記錄了這個點的位置 xy 以及這個點的時間戳,這樣在回放資料的時候可以比較真實地還原整個寫字的過程。

  • brush: 筆刷資料,包括筆刷的圖片和引數等資訊。

  • canvas2D: 螢幕寬高,可以幫助我們在不同比例的螢幕上回放資料。

貼紙資料:

{
  markers: [
    [url,x,y],
    [url,x,y],
    [url,x,y],
  ]
}

因為使用者可以新增多個貼紙,所以貼紙資料是一個數組,其中每個貼紙資料包括貼紙模型的連結以及位置。

這裡有一個真實的資料例子:https://mdn.alipayobjects.com/afts/file/A*1WrWS6pquA8AAAAAAAAAAAAAAQAAAQ?bz=biz_file

2. 資料抽稀

福字的軌跡資料大小是沒有上限的,極端情況下可能會達到幾M,所以資料要經過抽稀後在上傳到 afts。抽稀演算法很簡單,相鄰兩個點距離小於10會被判斷為冗餘點剪裁掉:

for (let i = 0; i < charData.length; i++) {
  const originalStroke = charData[i];
  let last = null;
  charData[i] = [];
  for (let j = 0; j < originalStroke.length; j++) {
    const point = originalStroke[j];
    if (last) {
      const l = length(point[0], point[1], last[0], last[1]);
      if (l < 10 && j !== originalStroke.length - 1) {
        continue;
      }
    }
    charData[i].push(point);
    last = point;
  }
}

經過抽稀後,軌跡資料大小可以穩定在 10k 以下。

記憶體/效能優化

空中寫福對記憶體的要求相對較高,從進入小遊戲容易到專案結束,記憶體峰值理論應控制在 200M 以下較為安全。而優化手段包含了業務策略優化和容器底層優化兩種,先來看一下最初的記憶體走勢圖,記憶體超標,其中 AR 演算法側相關的記憶體,由於演算法需要優化相對困難,我們根據專案上線節奏,合理的選擇收益比更大的幾個優化點切入:

適當降低主畫布解析度

機型:小米11 pro

在移動端時代,近幾年裝置的解析度逐步從 720p 提升到 2K,但 GPU 的渲染能力的提升並沒有得到相應比例的提升。而且如果主畫布的渲染解析度採用物理解析度的話,對於視訊記憶體的消耗也難以承受。尤其是主畫布,視訊記憶體佔用極高,這是因為主畫布並非單純的 RGBA 紋理,為了得到更好的渲染效能,WebGL 內部通常會採用雙 Buffer 模式,也就是雙畫布,並且除了顏色紋理外,還需要一張深度紋理用於完成深度測試。所以我們採取三當配置,分別根據機型能力,按照物理解析度進行了不同比例的縮放,分別為(0.8,0.6,0.5)。小遊戲容器專案初期並不支援解析度修改,經過優化策略的推動,容器層最終提供了該能力。大幅度降低了畫布視訊記憶體和提升渲染效能。

降低 AR 相機解析度

同理 AR 相機的解析度可採用類似的優化策略 - 降低AR 相機解析度,並且如果主畫布渲染解析度大於 AR 相機解析度時是一種沒有任何受益的記憶體消耗,因為最終 AR 相機的內容依然要渲染到主畫布。

支付寶小遊戲 request 優化

我們在當時空中寫福的記憶體分析中找了一個記憶體異常增長:

在貼紙新增階段記憶體會異常增幅 150M 左右,而貼紙本身的記憶體和視訊記憶體佔用小於10M。經過一系列排發現,容器層只要呼叫 request 就會產生記憶體大幅增長。原 request 的請求會把二進位制資料 (ArrayBuffer) 轉化為base64 string,然後通過 JSBridge 傳遞給前端;後面優化避開了二進位制資料轉化 base64 的過程,直接在 C++ 層建立 ArrayBuffer 物件,通過JSBinding 提供給前端使用。大幅減少了轉換的效能開銷和 base64 的快取佔用的記憶體開銷。

福字畫布上傳優化

福字渲染的流程首先是將筆刷貼圖繪製到一張 canvas 上,然後再把 canvas 上傳到 GPU 紋理 Texture2D。WebGL 一個非常重要的優化原則就是減少 CPU 資料和 GPU 資料的互動。因此我們做了一個優化,當用戶手指在螢幕繪製時更新 canvas,如果使用者抬起手指時,我們可利用髒標記避免無效的 canvas 上傳到 GPU 紋理。通過該方式,使用者在寫福字時,效能得到了一定幅度提升和減少發熱。

適當降低影片錄製解析度

在對好友分享的鏈路上,我們有一個影片錄製的功能。在影片錄製期間記憶體出現約 50M 增長,這個問題和之前提到的主畫布解析度過高的問題類似。和渲染一樣,過高的錄製解析度對使用者的收益並不明顯,而且記憶體和效能佔用過高。在優化策略的推動,容器層同樣開放了錄製解析度的設定,業務根據機型能力將錄製解析度調整為720p或540p,大幅改善了影片錄製期間記憶體增長的情況。

總結

本次空中寫福有大量使用者參與寫福和分享,抖音微博搜尋 AR 空中寫福能看到不少的案例。我們也會持續優化 AR 工程鏈路,並探索新的 AR 玩法。後續請關注 oasisengine.cn 我們會推出更多的 AR 開發案例,讓人人都可以參與到 AR 業務的開發當中來。