一、前言:转场动画骗不了眼睛
先看几个真实的翻车现场。
现场一:用 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 = 0,depth = cam,scale = 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 后原地覆盖(见前面 fillLeafMesh 的 reuse 分支)。
class PaintScene {
// 复用的 scratch buffer ------ 生命周期跟随 scene,不每帧 new
vertices: Array<number> = [];
uv: Array<number> = [];
colors: Array<number> = [];
indices: Array<number> = [];
// ...
}
8.4 网格密度的真实调参故事
一个真实的演进过程(可复用的调参思路):
-
起步用 14×10(以为越密越好)→ 中端机中段翻页掉帧;
-
改用软卷曲的解析法线、降低网格到 8×6 → 还是卡,因为 drawVertices 顶点数仍不少;
-
改刚性板(页面不弯折,只绕 spine 转) ,网格直接降到 2×2 → 丝滑,但自由边圆角在转动中变直角;
-
折中到 4×4 + 自由边圆角 UV 补偿 → 圆角读得出是圆,性能也够。
结论:先确定形变模型(刚性 vs 软卷曲),再定网格密度。 形变模型决定密度下限,密度决定性能上限。不要一上来就堆顶点。
九、把它接到 ArkUI 上:Modifier + PixelMap 纹理
光有数学和绘制还不够,要把这套渲染挂到 ArkUI 的组件树上。
9.1 用 drawing 的 Modifier 拿到 canvas
ArkUI 提供了 drawing 的 Modifier 机制,让你拿到一帧的 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、法线归一化、sheenGate 在 curl=0 时为 0、圆角 UV 在角部归一化等。
十二、写在最后
自定义绘制在 ArkUI 里不是黑魔法,它的心智可以收成一句话:
把页面拆成顶点网格,逐顶点算出它在 3D 空间里的位置;用针孔投影落回画布像素;用法线打光(平面要恒亮、卷曲才暗、高光要被曲率门关住);最后用一次
drawVertices把顶点、UV、颜色一锅提交。
记住这套方法的最简表达:几何决定形状,投影决定透视,法线决定明暗,drawVertices 决定性能。
一旦你接受了「逐顶点建模形变」这个前提,翻页就不再是「一个转场动画」,而是「任意软形变」的一个特例------旗帜飘动、水波涟漪、镜头扭曲、纸张揉皱,都共享同一套「网格 + 投影 + 光照 + 一锅 drawVertices」的逻辑。这套逻辑写一次,未来的形变需求就自动覆盖了。
下一篇我们会接着讲:怎么把手势和 @ohos.animator接到这套渲染上,做出跟手、能 commit、能回弹的翻页物理。 因为画得动只是第一步,让用户觉得「这张纸听我的手」,才是交互的完整闭环。
本文基于 ArkGraphics2D 的 drawing/ common2D能力整理,核心方法(顶点网格 + 针孔投影 + 差分法线光照 + 单次 drawVertices)可复用于任何 HarmonyOS 上的软形变自定义绘制场景。
