HarmonyOS 自定义绘制实战 —— 用 ArkGraphics2D 画一个会卷曲翻动的页面网格

一、前言:转场动画骗不了眼睛

先看几个真实的翻车现场。

现场一:用 transition模拟翻页。 页面整体做 rotateY + 透明度,看起来「翻了一下」,但纸是硬的、没有弯曲、没有影子,用户一眼就知道是「假翻」。

现场二:贴一张静态卷曲图。 设计师出了一套卷曲的切图,程序按进度做 crossfade。结果翻页过程中图是「跳变」的,没法跟手,进度连续变化时画面是抽帧的。

现场三:上 XComponent + OpenGL。 能做出来,但要自己起 EGL 上下文、管理渲染线程、处理和 ArkUI 的合成层级,工程量和踩坑面都指数级上升,团队维护不动。

现场四:逐面 drawPath 硬扛。 把页面切成 N 个四边形,每个用 drawPath 画一遍。网格一密,每帧几百次 draw call,中端机直接掉帧。

根因其实只有一个:

这些方案都没在「逐顶点」层面建模形变。 形变是连续的、逐点变化的,你只有把页面拆成一张网格、逐顶点算位置,才能让画面真正连续地卷起来。

本文要讲的,就是在 @kit.ArkGraphics2D 上把这套「网格 + 投影 + 光照 + 纹理」跑通,并且跑得不卡。


二、把页面拆成顶点网格

2.1 一张页面 = (cols+1) × (rows+1) 个顶点

把一张矩形页面在 UV 空间里均匀切成 cols × rows 个格子,得到一张 (cols+1) × (rows+1) 的顶点网格。每个顶点有归一化坐标 (u, v) ∈ [0,1]²

复制代码
u=0            u=1
 ┌──┬──┬──┬──┐  v=0
 │  │  │  │  │
 ├──┼──┼──┼──┤
 │  │  │  │  │
 ├──┼──┼──┼──┤  ← 每个交叉点 = 一个顶点
 │  │  │  │  │
 └──┴──┴──┴──┘  v=1

翻页卷曲时,每个顶点在三维空间里位置不同(靠近书脊的几乎不动,靠近自由边的飞起来),最后再投回 2D 画布。网格越密,卷曲越平滑;网格越稀,越省性能。 这是后面一切调参的根。

2.2 网格密度不是越密越好

一个常见的误区是「网格越密越精细」。实测下来并非如此:

网格密度 效果 代价
2×2 刚性板足够(平面旋转) 顶点极少,最快
4×4 刚性板 + 自由边圆角仍能看出圆 推荐起步
14×10 软卷曲(圆柱弯折)才需要 中端机要小心
24×18 极致柔软 调试/截图用,生产环境慎用

关键认知:

形变越「硬」(刚性板平面转动),需要的网格越稀。 不要为了「看起来精细」盲目加密------绝大多数视觉收益来自光影和纹理,不是顶点数。

如果你的翻页是「硬纸板」式的(页面整体绕书脊转动,不弯折),2×2 在数学上就够了;用 4×4 只是为了让自由边的圆角在转动过程中不退化成直角。只有你真的要做「纸张柔软卷曲」时,才需要 14×10 起步。


三、把网格顶点送进 3D 空间

3.1 刚性板:绕书脊线性转动

最简单也最稳的形变模型是把页面当成一块硬纸板,整体绕书脊(spine)转动。设翻页进度 t ∈ [0,1],转角:

复制代码
phi = π · t

t=0 时页面平贴桌面,t=1 时翻到另一侧。注意它是线性的------这会让后面的「跟手角度」换算非常干净:进度直接等于角度除以 π。

把一个 UV 顶点 (u, v) 变到世界坐标(书脊在 spineX,页顶在 pageTop,页宽 pageW、页高 pageH,方向 dir = +1 向前翻 / -1 向后翻):

复制代码
// 纯几何,零 ArkUI 依赖 ------ 可单测
export function leafWorld(
  u: number, v: number, t: number,
  dir: number, spineX: number, pageTop: number,
  pageW: number, pageH: number
): [number, number, number] {
  const tc = clamp01(t);
  const phi = Math.PI * tc;            // 线性转角
  const bx = u * pageW;                // 顶点到书脊的水平距离
  const c = Math.cos(phi);
  const s = Math.sin(phi);
  const xr = bx * c;                   // 绕 spine 旋转后的 x
  const zr = bx * s;                   // 离开纸面的 z(朝向相机为正)
  const d = dir >= 0 ? 1 : -1;
  return [spineX + d * xr,             // 世界 x
          pageTop + v * pageH,         // 世界 y(高度方向不动)
          zr];                         // 世界 z
}

世界 y(页面的竖直方向)不参与旋转------因为页面是绕一根竖直书脊转的,竖直方向上的点只是平移。这让法线计算、投影都简化很多。

3.2 软卷曲(可选):圆柱弯折

如果你要做「纸张柔软地卷起来」,就把「到书脊的距离」喂进一段圆柱面方程,让 bx / bz 走圆弧而不是直线。原理:半径 r = pageW / amax,圆柱角随 u 线性增长:

复制代码
const r = pageW / amax;
const bx = r * Math.sin(amax * u);
const bz = r * (1.0 - Math.cos(amax * u));

amax 是峰值弯折角(弧度)。软卷曲还要叠加一个按 v 线性变化的倾角 tilt,模拟手抓在不同高度时的扭动。但软卷曲是可选项------产品里大量翻页用刚性板就够,而且刚性板天然支持快速连翻。本文后续以刚性板为主,软卷曲只在需要时切换。

工程经验:先上刚性板把闭环跑通,再决定要不要软卷曲。 软卷曲要加密网格、要算更复杂的法线,收益往往不如把光影和纹理做好。


四、针孔投影:把 3D 世界落到画布像素

4.1 为什么不直接用世界坐标当屏幕坐标

顶点有了世界坐标 (wx, wy, wz),但它不能直接当画布像素用------因为页面转起来后 z > 0(飞出纸面),离相机更近,按透视应该看起来更大。如果你直接 drawVertices(wx, wy),翻转中的页面不会有任何「近大远小」,看起来像贴纸而不是立体的纸。

4.2 标定一个 z=0 时 1:1 映射的针孔相机

我们想要一个性质:页面平贴桌面(z=0)时,屏幕坐标严格等于世界坐标,像素对像素。 这样静止页面的纹理不会因为投影而错位。为此标定相机距离 camDist

复制代码
const FOV_Y = 0.62;                    // 全竖直 FOV(弧度)

export function camDist(stageH: number, fovY: number = FOV_Y): number {
  const half = Math.tan(fovY * 0.5);
  return half < 1e-6 ? stageH : stageH / (2.0 * half);
}

然后做标准针孔投影:

复制代码
export function projectPoint(
  wx: number, wy: number, wz: number,
  stageW: number, stageH: number, fovY: number = FOV_Y
): [number, number] {
  const cam = camDist(stageH, fovY);
  const depth = cam - wz;              // 相机到点的距离(z 朝相机为正)
  const safe = depth > 1e-3 ? depth : 1e-3;   // 防穿透除零
  const scale = cam / safe;            // 近大远小
  const cx = stageW * 0.5;
  const cy = stageH * 0.5;
  return [cx + (wx - cx) * scale, cy + (wy - cy) * scale];
}

验证:当 wz = 0depth = camscale = 1,于是 sx = wx, sy = wy------静止页面 1:1 映射达成

safe = depth > 1e-3 ? depth : 1e-3 这行不是多余的------快速翻页或极端转角下,某个顶点可能瞬时穿透相机平面,不兜底会得到 Infinity 把整张网格画飞。

4.3 填充交错顶点数组

把每个网格顶点的 (sx, sy) 推进一个交错的扁平数组[x0, y0, x1, y1, ...]),这是 drawVertices 要的输入格式。注意复用而非每帧 new:

复制代码
export function fillLeafMesh(
  out: Array<number>,          // 复用的输出缓冲
  cols: number, rows: number,
  layout: LeafLayout, progress: number, dir: number = 1
): Array<number> {
  const nu = cols + 1;
  const nv = rows + 1;
  const need = nu * nv * 2;
  const reuse = out.length === need;
  if (!reuse) out.length = 0;

  let off = 0;
  for (let j = 0; j < nv; j++) {
    const v = rows === 0 ? 0 : j / rows;
    for (let i = 0; i < nu; i++) {
      const u = cols === 0 ? 0 : i / cols;
      const w = leafWorld(u, v, progress, dir,
                          layout.spineX, layout.pageTop,
                          layout.pageW, layout.pageH);
      const s = projectPoint(w[0], w[1], w[2], layout.stageW, layout.stageH);
      if (reuse) { out[off] = s[0]; out[off + 1] = s[1]; }
      else { out.push(s[0]); out.push(s[1]); }
      off += 2;
    }
  }
  return out;
}

reuse 分支的意义:当缓冲长度恰好匹配,就原地覆盖 而不是 push------避免每帧产生一个新数组触发 GC。这一点在中端机上是肉眼可见的帧率差异。


五、用差分法线打出真实光影

没有光影的卷曲页面,看起来像一张灰色的塑料片。光影是「这是张立体的纸」的全部说服力。

5.1 有限差分求法线

网格每个顶点的法线,可以用它左右、上下邻居的 world 坐标做叉积得到(有限差分法线):

复制代码
// 在网格内取邻居索引(边界处退化为中心差分)
const a = j * nu + (i > 0 ? i - 1 : i);
const b = j * nu + (i < nu - 1 ? i + 1 : i);
const c = (j > 0 ? j - 1 : j) * nu + i;
const d = (j < nv - 1 ? j + 1 : j) * nu + i;

// 切向量 Tu = 邻居b - 邻居a,Tv = 邻居d - 邻居c
const tux = wx[b] - wx[a], tuy = wy[b] - wy[a], tuz = wz[b] - wz[a];
const tvx = wx[d] - wx[c], tvy = wy[d] - wy[c], tvz = wz[d] - wz[c];

// 法线 N = Tu × Tv
let nx = tuy * tvz - tuz * tvy;
let ny = tuz * tvx - tux * tvz;
let nz = tux * tvy - tuy * tvx;
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len > 1e-9) { nx /= len; ny /= len; nz /= len; }
else { nz = 1; }   // 退化情况:法线朝向相机

为什么用差分法线而不是解析法线?因为这套几何(刚性板 + 可选软卷曲 + 自由边圆角)写出解析法线太繁琐,而差分法线对任意几何都成立,还能自然地反映网格的真实形状。代价是依赖网格密度------网格太稀,法线会失真。

5.2 Lambert 形体光:平面要全亮,不能有 pop

最基础的一层是形体光(form shading),用法线朝相机程度决定明暗。但这里有个致命的坑 :如果你直接用 N·L(法线和光源的点积),那么完全平贴的页面(法线垂直纸面朝相机)会因为光源方向不同而有明暗渐变------用户会看到一张静止的纸莫名其妙一边亮一边暗,翻转的一瞬间还会「闪一下」(pop)。

正确做法是用相机空间的 facing 项

复制代码
const ambient = 0.32;                 // 环境光地板
const facing = Math.abs(nz) > 1 ? 1 : Math.abs(nz);  // 法线朝相机的程度
const lum = ambient + (1.0 - ambient) * facing;

含义:页面正对相机(nz=1)时全亮,卷起来(nz 变小)时变暗。 静止页面 facing 恒为 1,亮度恒定,不会 pop;只有卷曲的部分才暗下去,自然产生「卷起来的边在阴影里」的效果。

复制代码
const g = Math.round(clamp01(lum) * 255);
const argb = (0xFF000000 | (g << 16) | (g << 8) | g) >>> 0;   // ARGB 不透明灰

5.3 Blinn-Phong 高光 + 曲率门(sheenGate)

纸张的高光应该只在卷起来的地方出现,平的地方不能有------否则一张静止的纸会有一个亮斑,假得离谱。

用 Blinn-Phong 半角向量算高光,但再乘一个曲率门

复制代码
const SHININESS = 16;
const SHEEN = 0.28;

// 视线方向(从顶点指向相机)
let vx = stageW * 0.5 - wx, vy = stageH * 0.5 - wy, vz = camDist - wz;
const viewLen = Math.sqrt(vx*vx + vy*vy + vz*vz);
if (viewLen > 1e-9) { vx /= viewLen; vy /= viewLen; vz /= viewLen; }

// 半角 H = L + V
let hx = LIGHT_X + vx, hy = LIGHT_Y + vy, hz = LIGHT_Z + vz;
const halfLen = Math.sqrt(hx*hx + hy*hy + hz*hz);
if (halfLen > 1e-9) { hx /= halfLen; hy /= halfLen; hz /= halfLen; }

const ndh = clamp01(Math.abs(nx*hx + ny*hy + nz*hz));
const curl = 1 - facing;                       // 曲率:0=平,1=完全立起
const sheenGate = smoothstep01(curl / 0.12);   // 曲率很小时为 0
const spec = Math.pow(ndh, SHININESS) * SHEEN * sheenGate;

sheenGate是这整套光照里最关键的技巧。 它保证:

平面(curl≈0)→ sheenGate≈0 → 高光为 0。 静止页面零高光,不会闪;只有页面真正卷起来(curl 变大),高光才渐入。这就消除了「页面一转就闪一下亮斑」的 pop。

光源方向用一个固定归一化向量(这里略),让形体光和投影阴影共享同一个光源,画面才协调。

5.4 光照汇总

公式 作用 关键约束
环境光 ambient = 0.32 阴影里也能看见 给地板
形体光 ambient + (1-ambient)·facing 卷曲处变暗 facing 用 abs(nz),平面恒亮不 pop
高光 pow(N·H, s) · sheen · sheenGate 卷曲边缘的反光 sheenGate 让平面零高光

六、让卷曲的轮廓对齐平面圆角

6.1 问题:圆角在卷曲中变成直角

静态页面通常是圆角的(比如四个角 10vp 圆角)。但你的网格是矩形 UV,卷起来后自由边(远离书脊那条边)的轮廓是直角,和静态页面的圆角对不上------翻页过程中会看到角「从圆变方再变圆」,很出戏。

6.2 解法:在 UV 空间预先把角拉到四分之一椭圆上

在算世界坐标之前,对 UV 做一次「圆角化」:把自由边(u→1)的上下两个角,沿四分之一椭圆拉到圆弧上。

复制代码
export function freeEdgeRoundUV(
  u: number, v: number, ru: number, rv: number
): [number, number] {
  if (ru < 1e-4 || rv < 1e-4) return [u, v];
  let ou = u, ov = v;

  // 上自由角 (u→1, v→0)
  if (ou > 1 - ru && ov < rv) {
    const nx = (ou - (1 - ru)) / ru;
    const ny = (ov - rv) / rv;
    const d2 = nx*nx + ny*ny;
    if (d2 > 1) {
      const d = Math.sqrt(d2);
      ou = (1 - ru) + (nx / d) * ru;
      ov = rv + (ny / d) * rv;
    }
  }
  // 下自由角 (u→1, v→1) ------ 对称处理,略
  return [ou, ov];
}

原理:把角部区域归一化到一个单位圆,落在圆外的点沿径向拉回圆周。这样卷曲过程中,自由边的轮廓始终保持和静态页面一致的圆角形状。

小技巧:因为稀疏网格(4×4)的角部只有几个顶点,圆角可能看起来像多边形。可以让 ru/rv 略放大一点(比如乘 1.15)做视觉补偿,让稀疏网格下圆角仍然「读得出是圆」。


七、纹理怎么贴到变形的网格上

7.1 不要每面一张图,打包成横向 atlas

翻页时你会看到:当前右页(翻动 leaf 的正面)和它翻过去后露出的下一页(leaf 的背面)。如果正背两面各用一张 PixelMap、各建一个 shader,每帧要切换纹理,开销大且代码乱。

正确做法:把正面和背面水平拼成一张 atlas ,建一个 ImageShader,靠 UV 的横向区间([0, 0.5] 正面、[0.5, 1] 背面)来选择显示哪一面。

复制代码
┌────────────┬────────────┐
│   正面     │   背面     │
│ u∈[0,0.5]  │ u∈[0.5,1]  │
└────────────┴────────────┘
       一张 atlas,一个 ImageShader

7.2 用 ImageShader 给每个顶点配 UV

drawVertices 支持逐顶点纹理坐标。在填充顶点时,同时把每个顶点对应的 atlas UV 算好(注意背面可能需要镜像 UV,避免文字反过来):

复制代码
const tile = new common2D.Tile();
// 用 atlas 的对应矩形区域构造 tile
// 正面用左半区,背面用右半区(背面可镜像)

const shader = new drawing.ImageShader();
shader.setImageShaderOption({
  image: atlasPixelMap,
  tileX: 0 /* CLAMP */, tileY: 0 /* CLAMP */,
  // ...采样模式、过滤
});

然后给 drawVertices 传入 vertices(屏幕坐标)、uv(atlas 坐标)、colors(上一步算的光照颜色),ArkGraphics2D 会自动把纹理按 UV 贴到变形的网格上,并乘上每顶点颜色------光照和纹理一次合成

关键纪律:atlas 要缓存,不要每帧重建。 当且仅当页面内容变化(比如用户在页面上写了字,需要重新快照)才重建 atlas。怎么检测变化?用下一篇文章会讲的「epoch」机制。


八、性能纪律:一次 drawVertices,别逐面 batch

这是中端机能不能 60fps 的分水岭。

8.1 ❌ 逐面 drawPath ------ 千万别

复制代码
// ❌ 反面教材:每个四边形一次 drawPath
for (const quad of gridQuads) {
  const path = new drawing.Path();
  path.moveTo(...); path.lineTo(...); ...
  canvas.drawPath(path);   // 几十上百次 draw call
}

网格一密(14×10 = 140 个面),每帧几百次 draw call,状态切换和合成开销直接把帧率打下去。

8.2 ✅ 一次 drawVertices ------ TriangleSoup

把所有三角形(顶点 + 索引)攒成一锅「triangle soup」,一次提交:

复制代码
// 把网格拆成三角形索引:每个格子 2 个三角形
const indices: Array<number> = [];
for (let j = 0; j < rows; j++) {
  for (let i = 0; i < cols; i++) {
    const a = j * nu + i;
    const b = a + 1;
    const c = a + nu;
    const d = c + 1;
    indices.push(a, b, c,   // 上三角
                 b, d, c);  // 下三角
  }
}

canvas.drawVertices(
  vertices,   // 屏幕坐标 [x0,y0,x1,y1,...]
  indices,    // 三角形索引
  uv,         // atlas 纹理坐标
  colors,     // 逐顶点光照颜色(ARGB)
  shader      // 单一 ImageShader(atlas)
);

一次提交,一次合成。这是这套渲染能跑得动的根本原因。

8.3 复用 scratch buffer

每帧 new Array() 会制造大量短命对象,触发频繁 GC,表现为周期性卡顿。把顶点、UV、颜色、索引都做成成员级的复用缓冲 ,每帧 length = 0 后原地覆盖(见前面 fillLeafMeshreuse 分支)。

复制代码
class PaintScene {
  // 复用的 scratch buffer ------ 生命周期跟随 scene,不每帧 new
  vertices: Array<number> = [];
  uv: Array<number> = [];
  colors: Array<number> = [];
  indices: Array<number> = [];
  // ...
}

8.4 网格密度的真实调参故事

一个真实的演进过程(可复用的调参思路):

  1. 起步用 14×10(以为越密越好)→ 中端机中段翻页掉帧;

  2. 改用软卷曲的解析法线、降低网格到 8×6 → 还是卡,因为 drawVertices 顶点数仍不少;

  3. 改刚性板(页面不弯折,只绕 spine 转) ,网格直接降到 2×2 → 丝滑,但自由边圆角在转动中变直角;

  4. 折中到 4×4 + 自由边圆角 UV 补偿 → 圆角读得出是圆,性能也够。

结论:先确定形变模型(刚性 vs 软卷曲),再定网格密度。 形变模型决定密度下限,密度决定性能上限。不要一上来就堆顶点。


九、把它接到 ArkUI 上:Modifier + PixelMap 纹理

光有数学和绘制还不够,要把这套渲染挂到 ArkUI 的组件树上。

9.1 用 drawing 的 Modifier 拿到 canvas

ArkUI 提供了 drawingModifier 机制,让你拿到一帧的 Canvas,在上面做自定义绘制。通常封装成一个 @Component,内部持有绘制所需的状态(进度、方向、纹理),在 Modifier 回调里调 drawVertices

这一层是「UI 状态 → 画布」的胶水。它只负责:拿到 canvas、把当前帧的 scene 状态喂给纯绘制函数、画完。不要在 Modifier 里做几何计算------几何算在纯函数里(可单测),UI 层只搬运结果。

9.2 纹理从哪来:组件快照

翻页的纹理就是「页面内容」。在 ArkUI 里,把一个组件的内容截成 PixelMap 的标准做法是组件快照:

复制代码
// 把一个页面组件截成 PixelMap(类似 Flutter 的 RepaintBoundary.toImage)
const pixelMap = await this.getUIContext()
  .getComponentSnapshot()
  .createFromBuilder(builder, options);

把截出来的 PixelMap 作为 atlas 的来源。注意快照尺寸要够大(长边建议 ≥ 720px),否则卷曲放大时纹理会糊。快照是耗时操作,绝不能在翻页过程中现截------要在页面内容变化时提前截好、缓存住,翻页时只读取。


十、常见错误清单(对照自检)

写这套渲染前,先把踩过的坑过一遍:

  • ❌ 用 transition / rotateY 模拟翻页 ------ 硬纸板,无光影,一眼假。

  • ❌ 贴静态卷曲切图做 crossfade ------ 进度连续变化时抽帧跳变。

  • ❌ 直接用世界坐标当屏幕坐标 ------ 没有「近大远小」,翻转中页面没有立体感。

  • ❌ 投影不兜底 depth ------ 极端转角下顶点穿透相机,坐标变 Infinity 画飞。

  • ❌ 每帧 new Array() 装顶点 ------ 短命对象暴增,周期性 GC 卡顿。

  • ❌ 形体光直接用 N·L ------ 静止页面有明暗渐变,翻转瞬间 pop。

  • ❌ 高光不加曲率门 ------ 平面出现亮斑,页面一转就闪。

  • ❌ 逐四边形 drawPath ------ 网格一密就掉帧,draw call 爆炸。

  • ❌ 正背面各一张图各一个 shader ------ 每帧切纹理,开销大、代码乱。

  • ❌ atlas 每帧重建 ------ 把昂贵的快照/合成放进了热路径。

  • ❌ 一上来就用 24×18 网格 ------ 视觉收益和性能代价严重不匹配。

  • ❌ 在 Modifier 回调里算几何 ------ 把纯可测的数学埋进 UI 层,无法单测。

  • ❌ 快照尺寸太小 ------ 卷曲放大时纹理糊掉。

  • ❌ 自由边不圆角化 ------ 卷曲中角从圆变方,和静态页面对不上。

  • ❌ 法线不归一化 ------ 高光和形体光亮度失真。


十一、性能与效果自检矩阵

11.1 效果验收

  • 静止页面:无明暗渐变、无亮斑、纹理像素对齐(z=0 投影 1:1);

  • 缓慢拖动:页面连续跟手卷起,光影随曲率渐变,无 pop;

  • 快速翻页:轮廓圆角稳定,不退化成直角;

  • 边界:第一页向前 / 最后一页向后的回弹(配合下一篇的物理)。

11.2 性能验收

  • 中端机连续翻页 30 秒,帧率不掉;

  • Profiler 看 draw call:整页一次 drawVertices,不应有逐面 batch;

  • 内存:atlas 缓存稳定,不随翻页次数线性增长;

  • GC:长时间运行无周期性卡顿(说明 scratch buffer 复用到位)。

11.3 工程验收

几何和光照函数零 ArkUI 依赖 ,能用单元测试覆盖:投影的 z=0 → 1:1、法线归一化、sheenGatecurl=0 时为 0、圆角 UV 在角部归一化等。


十二、写在最后

自定义绘制在 ArkUI 里不是黑魔法,它的心智可以收成一句话:

把页面拆成顶点网格,逐顶点算出它在 3D 空间里的位置;用针孔投影落回画布像素;用法线打光(平面要恒亮、卷曲才暗、高光要被曲率门关住);最后用一次 drawVertices把顶点、UV、颜色一锅提交。

记住这套方法的最简表达:几何决定形状,投影决定透视,法线决定明暗,drawVertices 决定性能。

一旦你接受了「逐顶点建模形变」这个前提,翻页就不再是「一个转场动画」,而是「任意软形变」的一个特例------旗帜飘动、水波涟漪、镜头扭曲、纸张揉皱,都共享同一套「网格 + 投影 + 光照 + 一锅 drawVertices」的逻辑。这套逻辑写一次,未来的形变需求就自动覆盖了。

下一篇我们会接着讲:怎么把手势和 @ohos.animator接到这套渲染上,做出跟手、能 commit、能回弹的翻页物理。 因为画得动只是第一步,让用户觉得「这张纸听我的手」,才是交互的完整闭环。


本文基于 ArkGraphics2D 的 drawing/ common2D能力整理,核心方法(顶点网格 + 针孔投影 + 差分法线光照 + 单次 drawVertices)可复用于任何 HarmonyOS 上的软形变自定义绘制场景。

相关推荐
腾科IT教育4 小时前
HarmonyOS开发|ArkTS UI颜色API通用规则
ui·华为·harmonyos·harmonyos开发·鸿蒙应用开发工程师
风华圆舞11 小时前
HarmonyOS 手势与 animator 实战 —— 捏出跟手又有弹性的翻页物理
harmonyos·手势·pixelmap·pangesture·边界回弹·native 句柄
北墨NoLimit11 小时前
DevEco Code:在终端里用 AI 写鸿蒙应用
harmonyos
智塑未来12 小时前
打开快、切换顺、游戏稳:鸿蒙的日常流畅表现
游戏·华为·harmonyos
智塑未来1 天前
鸿蒙游戏体验手册:四种能力从性能到玩法逐一解锁
游戏·华为·harmonyos
math_hongfan1 天前
鸿蒙离线数据缓存高级架构:弱网预加载/离线数据优先级/同步冲突解决/上线后数据合并策略
学习·缓存·华为·架构·harmonyos·鸿蒙
math_hongfan1 天前
鸿蒙企业级数据存储高级架构:从读写分离到冷热数据分层/归档策略/数据生命周期管理最佳实践
人工智能·学习·华为·架构·harmonyos·鸿蒙
math_hongfan1 天前
鸿蒙存储异常高级排查:文件损坏检测/数据恢复/读写失败重试/磁盘空间预警系统性根治方案
学习·华为·harmonyos·鸿蒙
lilian2331 天前
Harmony os 技术实战|拼豆制图10:把取消、解析失败和保存失败写成可恢复状态机
java·javascript·华为·harmonyos