三维可动圆背精装书的实现:装订几何、纸芯防穿模与页面纹理复用
记录三维圆背精装书的紧背与空背形变建模、左右纸叠厚度不对称时的纸根细分防穿模,以及 TextPainter 动态分页与 6 槽页面纹理原地覆写。
在设计书房桌面的分类文章阅读体验时,最初试过比较简化的做法:用两块刚性矩形封板加几片平面纸张,全部绕同一根中心轴旋转来模拟翻书。这种模型远看尚可,但把镜头拉近到桌面阅读机位后问题就很明显——真实的厚精装书在合拢时书脊呈圆弧隆起、前口呈内凹弧面;向两侧摊开时,书芯内背会向上拱起,左右纸叠的厚度还会随着当前翻到的页码不断变化。只靠刚体旋转,不是纸根悬空脱开书脊,就是纸叠边缘直接穿破外层封皮。
为了把桌面上的 4 本分类书做成真正能拿起来检视、翻开阅读的精装书,后来重新对照传统西式精装装帧结构,用固定拓扑的程序化形变网格重写了整套书本几何与动态排版管线。
1. 紧背与空背的装订几何约束
参考古籍修复与精装装帧资料,常见的圆背精装书在脊部结构上主要分为紧背(Tight Back,皮革直接粘贴在书芯背部,随书芯一同弯曲)与空背(Hollow Back,外硬书脊与书芯内背之间留有腔体,摊开时外书脊留在下方,内背连同书页向上拱起形成圆拱)。桌面阅读场景采用的是展开形态更舒展的空背结构。
为了让封面、封底、外书脊、左右静态纸叠与正在翻动的单页始终严丝合缝,核心是不让各个部件各自独立计算旋转中心,而是由 BookBinding 统一维护整条内背纸根曲线(innerRootPose):
- 材料段长守恒:把从扉页到末页的纸根总厚度离散为
32段折线。当开合度open从0(闭合)过渡到1(平摊)时,每一段只改变局部切向倾角,不拉伸或压缩段长,从而保证书芯总厚度在开合过程中保持恒定。 - 整体包络平移:纸根曲线连同按共享法线向外挤出的背衬厚度(
backingOffset + backingThickness)作为一个整体平移,同时满足左右封板内侧半平面的包络约束。假设直接对每个纸根顶点单独向封板做投影截断,就会把靠近封面的几叠纸压短变形。 - 翻页与开合解耦:当开合度不变、只是翻动书页时,整条内背圆拱的形状其实不需要重算(通过
_rootCache直接命中缓存);翻页比例leftFraction只决定从0.0到1.0的纸根弧长中,前多少比例归入左纸叠、剩余比例归入右纸叠。
在网格更新方式上,前期也专门对比过形态键(Morph Targets)与骨骼蒙皮(Skinning):对于纯刚体封板与书脊外皮,双侧铰链配合少量骨骼在 65 组开合采样姿态下的偏差小于 0.00011 mm;但书芯纸叠与活动翻页同时耦合了“开合度”“当前页码占比”和“拖拽起翘弧度”三个连续维度,用固定索引拓扑的程序化网格(GeometryStorage.updatable)只创建一次 GPU 缓冲、在状态变化帧原地覆写顶点位置与法线,相比烘焙几十组高维形态键更直接,静态阅读停帧时也可以完全跳过几何计算。
2. 纸叠厚度不对称时的端面穿模修复
第一版圆背模型运行起来之后,翻到全书正中间(左右纸叠厚度接近 1:1)时显示正常;但只要刚翻开扉页,或者快翻到最后一两页(一侧极薄、另一侧极厚),书芯顶面(天头)和底面(地脚)靠近书沟的位置就会刺出尖锐的白色纸芯三角面,穿破深色外书皮。
把网格切成线框模式后找到了具体原因:初版在封闭左、右静态纸叠的顶底端面时,在靠近书脊的纸根侧只取了该纸叠的起点和终点两个顶点,直接连了一条直线弦。当某一侧纸叠占了全书 90% 以上的厚度时,这段纸根实际上沿着弯曲的内背跨过了很大的圆心角;用一条直线弦跨过弯曲的圆拱,弦的中段自然就会切到圆弧外侧,穿透背衬与皮革。
修复分了两步:
- 纸根侧端面细分:静态纸叠的天头与地脚封盖不再用单根直线连接首尾纸根,而是沿着
innerRootPose对该纸叠所占的[qStart, qEnd]区间做多段弧长采样,使端面内缘完整贴合内背圆拱曲线。 - 活动单页控制点下界夹紧:拖拽翻页或快速连翻多页时,活动纸张采用三次曲线控制弯曲轮廓。当目标侧纸叠很高而起始侧很低时,假设控制点高度仍按固定平面插值,页腹中段就会擦进高起的那一侧静态纸叠。通过把两侧静态纸叠的当前高度剖面作为下界约束夹紧(Clamping)活动页控制点,消除了半开态与非对称页码下的穿插。
3. Markdown 动态分页与 6 槽页面纹理池
书页上的文字与站点网页共用同一份由 content/posts/ 和 content/notes/ 导出的结构化内容源,不在三维代码里单独维护第二份文本。
打开某本分类书时,BookTypesetter.bind 会先用预加载的静态子集字体测量所有标题、正文段落、列表与代码块(纹理分辨率 768×1088,四周留出页边距),超出一页底部的段落自动顺延到下一页,不会截断末尾行;同时自动在扉页右侧生成带精确页码与点击热区矩形(BookTocEntry)的目录页,保证点击三维纸面上的目录条目就能直接翻到对应文章起始跨页。
在把排版好的页面上传为 GPU 纹理时,必须控制显存驻留量与生命周期。一本分类书装订后往往有两百多页,无法一次性将两百多张纹理全部驻留在显存中;而在 WebGL 端如果每次翻页都新建纹理并丢弃旧引用,又会因为底层缺少即时销毁路径而持续累积显存。
实际阅读时,同一时刻真正需要驻留的页面最多只有 6 张(当前左、右跨页 2 张 + 正在翻动的单页正反面 2 张 + 预加载的相邻跨页 2 张)。因此 BookPage 内部采用了一个跨会话共享的 6 槽静态纹理池(_sharedPageTexturePool),配合 PageLifetime 引用计数管理异步栅格化:
Future<scene.Texture2D> _texture(int index, {Set<int> keep = const {}}) async {
final cached = _textures[index];
if (cached != null) return cached;
scene.Texture2D? reuse;
if (_textures.length >= 6) {
final recycled = _textures.keys.firstWhere(
(key) => key != _spread && key != _spread + 1 && !keep.contains(key),
);
reuse = _textures.remove(recycled);
} else if (_sharedPageTexturePool.isNotEmpty) {
reuse = _sharedPageTexturePool.removeLast();
}
final texture = await _pageLifetime.use(
() => BookTypesetter.pageTexture(_pages[index], index, reuse: reuse),
);
if (!mounted) {
if (_sharedPageTexturePool.length < 6 &&
!_sharedPageTexturePool.contains(texture)) {
_sharedPageTexturePool.add(texture);
}
throw StateError('Book disposed');
}
_textures[index] = texture;
return texture;
}
当拿到可复用的 reuse 纹理槽位后,BookTypesetter.pageTexture 内部不再调用 createTexture,而是直接调用 reuse.overwrite(bytes) 原地替换像素数据;合上书本退出阅读时,当前持有的最多 6 张纹理会归还到 _sharedPageTexturePool 供下一本书直接复用。至于 PageLifetime 的作用,则是防止用户在某页还在后台异步栅格化时迅速合上书本——此时不能立刻销毁底层的 TextPainter 段落,必须等进行中的任务执行完毕并将纹理归还缓存池后,再统一调用 disposePages 释放排版对象。
4. 斜视倾角下的字形清晰度与阅读取舍
把高对比度的深色细笔画汉字画在三维曲面纹理上,还会遇到一个光栅化层面的天然矛盾:
- 如果页面纹理启用未加偏置的标准 Mipmap 链,当镜头从斜下方或翻页起翘的掠射角观察纸面时,硬件会自动切向低分辨率 Mip 层级,原本只有一两个像素宽的汉字横画很容易被平均值稀释成断续的灰点(出现笔画断裂)。
- 反过来,关闭 Mipmap 只保留第 0 级原图采样,虽然能保住正对阅读机位下的笔画锐度,但在自由旋转检视模型、把书页缩得很小或角度很斜时,密集字行又会出现轻微的采样闪烁。
权衡之后,因为桌面打开书籍时镜头会自动平滑推近到正对纸面的阅读机位,最终优先保证了阅读视角下的字形锐度,同时将渲染像素比上限封顶在 1080p 等效面积以兼顾集显帧率。另外也很清楚三维纸面排版更适合沉浸浏览与短篇翻阅,遇到长代码块复制或快速全文检索时终究不如普通网页顺手,所以在阅读器右侧浮动栏里始终保留了一键跳转到对应静态网页文章(/posts/<slug>/ 或 /notes/<slug>/)的入口,两边互补使用。