拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

uniapp小程序电子书仿真翻书:基于Three.js+Canvas的3D实现方案

uniapp小程序电子书仿真翻书:基于Three.js+Canvas的3D实现方案

做电子书阅读器项目的时候,用户对“翻书”交互的期待往往比我们想象的高。不是简单滑动一下换页,而是要像捧着一本纸质书在手里翻那样:页脚翘起来,纸面弯曲,翻动过程中能看到当前页和下一页的内容交替。这种效果在PC Web上有现成的jQuery插件turnjs可以选,但在uniapp小程序端,一切都得从头想。

我这次接到的需求就是在uniapp小程序端实现电子书仿真翻书效果,产品经理反复强调“手感要接近turnjs”。turnjs的原理是借助CSS3的3D transform,在DOM节点上做旋转变换,但小程序里既没有jQuery,也没有完整的DOM树给你操作。我比较了一圈之后,决定用threejs在WebGL渲染管线里自己做一个翻书3D场景,页面内容则通过canvas绘制成贴图贴到书页网格上。这个方案最终跑通了,并且在真机上保持了比较流畅的帧率。这篇文章就把我做这次技术选型、建模、编码、调优的整个过程拆开讲清楚,给同样要在uniapp小程序里做3D效果的朋友一个参考。

1. 先把需求聊透:从小程序里的电子书到turnjs的迁移困境

1.1 turnjs为什么不能直接搬到小程序

turnjs严格来说是jQuery时代的产物,它把每一页包装成DOM元素,利用CSS3的transform属性模拟页面在三维空间里的翻转和卷曲。它的优点是上手快、页面内容天然支持文字选中和图片混排,缺点是强依赖浏览器DOM环境。

小程序端的问题恰恰在这里。小程序里没有window和document,更不可能引进jQuery去操作一大堆DOM节点。就算强行用web-view套一个H5页面,那也失去了小程序原生交互的流畅感,还要面临web-view与小程序通信的种种限制。我在项目初期试过用web-view加载本地H5翻书页面,效果勉强能看,但页面切换有明显的白屏延迟,而且触摸反馈跟原生小程序手势完全脱节,产品看一眼就否了。

所以结论很清楚:要在小程序里做出接近turnjs的仿真翻书效果,不能指望现成插件,必须自己用渲染管线实现。

1.2 采用threejs+canvas方案的技术判断

排除掉web-view和DOM方案之后,剩下两条路:一是用小程序自带的Canvas 2D接口硬画翻页效果,二是用threejs做真正的3D渲染。

Canvas 2D硬画翻书,本质上是做图像变形。每一帧把当前页的图片用数学变换映射到一个卷曲的四边形或多边形区域里,配合切图和阴影模拟立体感。这个方案在小程序早期确实有人用过,性能尚可,但页面一多、图片一复杂,代码就会变得特别绕,而且翻页的景深感、页面厚度、光影过渡都很难做真实,顶多算“伪3D”。

threejs在小程序端的可行性,依赖的是小程序提供的WebGL渲染能力。微信小程序从基础库2.7.0开始支持WebGL,从2.9.0开始支持Canvas 2D接口。通过<canvas type="webgl">拿到WebGL上下文之后,threejs可以完整运行在自带的WebGLRenderer上。也就是说,PC网页里能做的3D效果,在小程序里理论上都能做,只是要处理很多环境差异。

我最终选择了threejs,还有一个考虑是开发效率。翻页效果的几何模型、相机控制、纹理映射、动画循环,threejs都有现成的组件。我需要做的不是从零写渲染器,而是把翻页的几何变形逻辑和业务层的书页数据结构对接起来。

1.3 最终的技术架构

整个项目最终的技术架构是三层:

  • 渲染层:threejs的Scene、Camera、WebGLRenderer负责3D场景渲染,翻页的每个页面对象是PlaneGeometry的网格模型,通过修改顶点位置实现卷曲动画。
  • 内容层:每个书页的图文内容先用Canvas 2D绘制成位图,再作为Texture贴到对应的3D网格上。这样文字、图片、排版样式都在Canvas里处理,到3D侧只需要关心“这一页长什么样”。
  • 交互层:小程序wx触摸事件负责捕获手指位置和移动轨迹,通过手势参数驱动翻页progress值,progress再映射到顶点变形和动画缓动。

这个架构的好处是职责单一:3D侧不关心页面内容是什么,Canvas侧不关心页面怎么动,交互侧只负责把手势翻译成参数。后面遇到性能问题、内容更新问题,都能各自独立排查。

2. 翻书效果背后的数学模型

2.1 一本书的3D场景应该如何搭建

翻书效果在3D空间里其实是一组页面的摆放和变形问题。最简单且观感最好的初始布局是:一本书摊开在桌上,左边一页,右边一页,中间是书脊,正在翻动的那一页从右边抬起来转到左边。

在threejs的场景里,我建了一个BookGroup来管理所有页面对象,每个页面都是一个独立的Mesh:

  • 左半页:贴左页内容,固定不动。
  • 右半页:贴右页内容,固定不动。
  • 翻页正面:正在翻起的那一页,贴当前页内容。
  • 翻页背面:翻过去之后会露出来的那一页,贴下一页内容。

书脊的位置就是翻页Mesh的旋转轴。为了方便计算,我把页面网格的坐标系原点放在书脊线上,也就是说页面的PlaneGeometry没有居中在原点,而是从原点出发向右延伸。这样后面做顶点变形时,所有坐标变换都围绕x=0这条轴线进行,直观很多。

在threejs里,PlaneGeometry默认是中心在原点、宽沿x轴、高沿y轴。要让“书脊在x=0”,可以创建Geometry之后自己平移顶点,或者把它包在一个偏移矩阵里去处理。我采用的做法是在创建PlaneGeometry后,把整个网格向右平移半个页宽,让网格局部坐标的x范围落在[0, pageWidth],0就是书脊线。这个方法代码简单,后续顶点遍历也会方便。

2.2 翻页顶点变形的核心算法

翻页效果最核心的部分,就是让一个平面网格在翻动过程中产生自然的卷曲,而不是像一块铁板硬生生转过去。我的经验是把整个翻页过程看作“刚体旋转”和“局部卷曲”的叠加。

刚体旋转好理解:整个页面绕书脊线(x=0)旋转,旋转角度从0到π。这一步让页面从“平放”转到“翻过去”。

光有旋转还不够,纸面是软的,翻的过程中会有弯曲。我采用的卷曲模型是:对网格上每个顶点,根据它离书脊线的距离t(t=顶点x坐标/pageWidth,范围是0到1),计算一个卷曲偏移量。t越接近1,也就是越靠近页边,弯曲程度越大。具体做法是在旋转之后,沿着垂直于纸面的方向再施加一个偏移,偏移量用正弦函数控制,让页面中部微微鼓起、页边略微下垂,形成纸面在空气中自然的曲线。

顶点变形的核心逻辑大致是这样:

function updateFlipPage(progress) { const position = flipGeometry.attributes.position; const vertexCount = position.count; // 翻页总角度:从0到π const angle = progress * Math.PI; // 先计算整体旋转的cos和sin const cosA = Math.cos(angle); const sinA = Math.sin(angle); for (let i = 0; i < vertexCount; i++) { // 读取原始顶点坐标(平面的原始坐标) let x0 = position.getX(i); let y0 = position.getY(i); let z0 = 0; // t表示该顶点离书脊的距离比例,0在书脊,1在页边 const t = x0 / pageWidth; // 刚性旋转:绕书脊轴线旋转angle // 这里书脊轴线是y轴方向,所以x和z参与旋转 const x1 = x0 * cosA; const z1 = x0 * sinA; // 局部卷曲:根据t施加正弦偏移,让页面产生弯曲 // curlStrength控制弯曲幅度,实际项目里可以用动态参数 const curl = Math.sin(t * Math.PI) * curlStrength; const x2 = x1; const z2 = z1 + curl; // 页面上下两侧微调:页脚和页眉边缘稍微收拢 // y0是相对页面中线的垂直坐标 const edgeTuck = Math.abs(y0) / (pageHeight / 2); const z3 = z2 - edgeTuck * edgeTuck * curl * 0.3; position.setX(i, x2); position.setZ(i, z3); } position.needsUpdate = true; flipGeometry.computeVertexNormals(); }

这段代码看起来不复杂,但里面有三个细节决定效果真假。

第一,卷曲方向必须和旋转方向一致。页面从右往左翻,旋转之后页面的法线方向已经发生变化,卷曲偏移要沿着当前法线方向加,否则会出现“页面穿模”或者“纸张翻转方向不对”的违和感。我上面的写法比较简化,实际项目里更稳妥的做法是先从原始位置的法线方向算出旋转后的法线,再沿法线施加偏移。

第二,computeVertexNormals不能省。因为顶点被手动位移了,原来的法线不再正确,光照和阴影会变得一团糟,尤其页面翻到半空时会显得很“平”。每次更新顶点后重新计算法线,是最直接的办法。

第三,t的计算要避免除零。pageWidth是固定值所以不会出问题,但如果以后页面尺寸变成动态的,记得对pageWidth做保护判断。

2.3 页面内容如何变成3D贴图

threejs负责了3D变换,页面内容本身怎么进去?这是canvas发挥作用的地方。我的做法是提前把每一页的内容绘制成一张Canvas位图,再把Canvas对象作为Texture贴到页面网格上。

推荐用离屏Canvas去做这个工作。在小程序里,离屏Canvas通过wx.createOffscreenCanvas({ type: '2d' })创建。绘制流程是:

  1. 根据页面尺寸创建对应像素比例的离屏Canvas。
  2. 用2D上下文绘制页面背景、文字、图片、页码。
  3. 调用canvas.requestAnimationFrame或者直接获取临时文件路径,把Canvas作为图像源传给threejs的Texture。

一个关键点:小程序里Canvas拿到的像素尺寸和实际展示尺寸不一定一致,要自己处理DPR。比如页面在屏幕上显示宽度是375逻辑像素,物理像素是750,如果Canvas绘制尺寸用375,贴图上文字可能会发虚。我在绘制时统一乘以设备的pixelRatio,保证纹理清晰度。

还有一点容易踩坑:纹理和网格的纵横比必须匹配。如果页面设计宽高比是3:4,PlaneGeometry的pageWidth和pageHeight也必须是3:4,否则贴图会拉伸。

3. 小程序端实操:一步一步实现仿真翻页

3.1 引入threejs并初始化WebGL画布

先解决引入问题。在uniapp项目里使用npm包,官方推荐的HBuilderX项目方式是通过npm安装,然后在代码里import。我用的命令是:

npm install three --save

然后在页面里引入:

import * as THREE from 'three';

重点来了:小程序端的threejs不能用常规的DOM初始化方式,因为小程序没有一个body节点给你挂canvas。微信小程序体系里,WebGL渲染需要依赖<canvas type="webgl">节点。页面模板里写:

<canvas type="webgl" id="bookCanvas" class="book-canvas"></canvas>

初始化渲染器的关键代码要放在onReady或者通过wx.createSelectorQuery拿到Canvas节点之后。微信小程序的Canvas节点获取方式比较特殊,我用的是:

// #ifdef MP-WEIXIN const query = wx.createSelectorQuery(); query.select('#bookCanvas') .fields({ node: true, size: true }) .exec((res) => { const canvasNode = res[0].node; const width = res[0].width; const height = res[0].height; // 这里必须传已有的canvas,不能用threejs内部创建的canvas const renderer = new THREE.WebGLRenderer({ canvas: canvasNode, context: canvasNode.getContext('webgl'), antialias: false, alpha: true }); renderer.setSize(width, height); renderer.setPixelRatio(Math.min(wx.getSystemInfoSync().pixelRatio, 2)); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); camera.position.set(0, 0, 10); camera.lookAt(0, 0, 0); startRenderLoop(); }); // #endif

这里有几个坑要单独提醒。

第一,antialias在小程序端我直接关了。抗锯齿会明显增加GPU负担,在移动端尤其明显,而且对翻书这种纯几何+贴图的效果来说,关闭后观感差异不大。

第二,setPixelRatio不要直接取系统pixelRatio,很多安卓机是3甚至更高,3倍像素会让着色器跑满帧。我用Math.min(devicePixelRatio, 2)封顶。

第三,threejs默认会创建自己的canvas和WebGL上下文,如果不传canvas字段,小程序里会直接报错。必须把<canvas type="webgl">的节点传进去。

3.2 创建书页网格与翻页组

初始化渲染器之后,就要创建翻书的3D模型。我按前面说的结构,创建了一个BookGroup,然后往里面放网格。

书页网格我直接用了PlaneGeometry,但创建方式有一些定制。为了让翻页时弯曲足够平滑,x轴方向的分段数不能太少,我用了segmentsX: 48, segmentsY: 32。这个参数在小程序里是个平衡点,再高真机容易掉帧,再低卷曲弧线会出现明显的棱角。

创建几何体的代码:

const pageWidth = 3.6; const pageHeight = 4.8; function createPageGeometry() { // 创建平面,并平移到书脊位置 const geom = new THREE.PlaneGeometry(pageWidth, pageHeight, 48, 32); // 默认PlaneGeometry在x方向是 -width/2 到 width/2 // 平移半个宽度,让x范围变为0~width geom.translate(pageWidth / 2, 0, 0); return geom; } const flipGeom = createPageGeometry(); const leftPageGeom = createPageGeometry(); const rightPageGeom = createPageGeometry();

注意我用了geom.translate(pageWidth / 2, 0, 0),这样每个顶点在局部坐标里,x从0到pageWidth,x=0就是书脊线。后面做顶点变形时,这个坐标约定非常重要。

材质方面,我用的MeshBasicMaterial加上DoubleSide:

function createPageMaterial(texture) { return new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide, transparent: true, depthWrite: false }); }

为什么要用BasicMaterial而不是StandardMaterial?在移动端,标准材质会引入大量光照计算,性能消耗明显。翻书页面本质上是贴图展示,BasicMaterial加一点后期阴影就能满足视觉效果,性能却高出一截。depthWrite: false是为了避免翻页过程中页面交叉叠放时出现奇怪的遮挡。

3.3 实现翻页动画与手势绑定

翻页动画的驱动源有两个:一个是手势驱动,用户手指按住页面边缘拖动,页面跟随手指位置变化;另一个是自动驱动,点击目录跳转、自动播放时,由动画系统驱动progress从0到1。

我把progress统一暴露成一个属性,哪里改这个属性都行。手势触摸时,progress跟着手指的水平位移走;松手后,根据当前progress所处的区间判断是归位还是翻页完成,再通过tween补间动画把progress动画到0或1。

在小程序端,触摸事件有三个:@touchstart、@touchmove、@touchend。

手势计算的思路是把手指在页面上的横向位移映射为progress。页面宽度在屏幕上是已知的(比如375px),手指从右往左滑动的距离除以页宽就是翻页progress。为了手感更好,我做了映射区间的压缩:手指滑过页面的70%宽度,progress就从0走了100%,这样翻页不需要拖满整页,符合用户预期。

touchmove里更新progress的核心逻辑:

onTouchMove(event) { const clientX = event.touches[0].clientX; // startX是touchstart时记录的位置 const deltaX = this.startX - clientX; // 页宽映射 const rawProgress = deltaX / this.pageScreenWidth; // 压缩映射,70%拖动量对应100%翻页 this.progress = THREE.MathUtils.clamp(rawProgress * 1.4, 0, 1); updateFlipPage(this.progress); }

touchend时根据progress值决定动画方向:

onTouchEnd() { const target = this.progress > 0.5 ? 1 : 0; animateProgress(this.progress, target); }

animateProgress我用了一个简单的requestAnimationFrame循环配合缓动函数,没有引额外的动画库,因为翻书只是一个数字从当前值变到目标值,用最直接的补间就够了。

3.4 处理页面切换和目录跳转

翻页动画跑通之后,还要把业务逻辑接上:翻页完成之后,当前页和目标页的内容要更新,目录跳转要让书快速翻到指定页。

我的做法是维护一个pageIndex,翻页完成后pageIndex+1,然后把下一对页面的贴图更新到左右页网格和翻页网格上。贴图更新的关键是Texture的needsUpdate属性:

function updatePageTextures(leftTex, rightTex, nextTex) { leftMesh.material.map = leftTex; leftMesh.material.map.needsUpdate = true; rightMesh.material.map = rightTex; rightMesh.material.map.needsUpdate = true; flipFrontMesh.material.map = nextTex; flipFrontMesh.material.map.needsUpdate = true; }

目录跳转我做了两种模式:直接跳转和快速翻页动画。直接跳转适合距离远的场景,比如跳到第120页,直接刷页面内容即可;快速翻页适合距离近的场景,比如就要翻3页,让用户看到连续翻动效果。实现上都是驱动progress做多次循环动画,逻辑复用同一套updateFlipPage。

4. 性能调优:让翻页在小程序上保持流畅

4.1 面数、纹理与渲染配置的三个关键约定

小程序端的GPU性能和桌面端相比差距很大,一开始我把PC端的习惯带进来,比如PlaneGeometry分段数设到64,纹理直接用2048x2048,结果一跑就卡。

后面我总结出三个必须遵守的约定。

第一个约定是面数。翻页PlaneGeometry的分段数,x方向48、y方向32是我的上限。48x32意味着网格有49x33=1617个顶点,每次更新顶点位置需要遍历1617次,加上computeVertexNormals的开销,在iOS和安卓中端机上是可以接受的。再往上到64x64,低端安卓就明显掉帧了。

第二个约定是纹理尺寸。页面的Canvas绘制尺寸我控制在1080x1440以内,对应3:4的页宽高比。2048x2048的纹理会增加纹理上传时间,而且大部分移动GPU处理大纹理时带宽消耗很大,容易触发内存警告。

第三个约定是渲染器配置。我在初始化时固定使用antialias: false,关掉stencil,手动控制setPixelRatio上限2。此外,每帧渲染不要依赖requestAnimationFrame的默认行为,应该用renderer.setAnimationLoop或者自己在回调里调度renderer.render,确保帧率和小程序页面的生命周期一致。

4.2 避免GC抖动与内存泄漏

翻书项目在页面积累较多、频繁切换时,最容易出现的就是内存抖动。我踩过的坑有两个。

第一个坑是频繁创建Texture。早期版本每次翻页都新创建一个Canvas和Texture对象,页面翻十几次之后,内存明显上涨,最终微信直接触发“webgl context lost”。后来我改成纹理池机制:提前创建好一批Texture对象,翻页时只更新Canvas的绘制内容,然后调用texture.needsUpdate = true通知threejs重新上传,不再新建Texture。

第二个坑是动画循环没有在小程序页面隐藏时暂停。小程序从后台切回来之后,3D场景可能已经丢失上下文,动画循环还在跑就会出现一连串报错。我专门监听了页面的生命周期:

onHide() { if (this.renderer) { this.renderer.setAnimationLoop(null); } } onShow() { if (this.renderer) { this.renderer.setAnimationLoop(this.renderFrame.bind(this)); } }

这个方法既避免了后台耗电,也减少了上下文丢失的概率。

4.3 真机实测帧率数据与优化记录

我用三台真机做了帧率测试:iPhone 12、小米11、一台中端安卓。测试场景是整本书翻页Demo,共20页,连续快速翻页。

  • iPhone 12:全程稳定60帧,翻页丝滑,GPU占用不到30%。
  • 小米11:快速翻页时偶尔掉到50帧,静止时60帧。
  • 中端安卓:静止时55帧左右,连续翻页会掉到40帧,能明显感觉到卡顿。

针对中端安卓的卡顿,我做了一次针对性的优化:把阴影和多余的光照全部去掉,把标题背景里的渐变阴影从shader计算改为Canvas里预渲染到贴图上。这一招非常管用,把大部分光照计算从3D渲染里移走,帧率从40帧提升到了48帧左右。

后来我又发现中端安卓调整了触摸事件频率之后有所改善。小程序touchmove事件本身频率不高,如果每次touchmove都触发一次更新和渲染,还是会消耗不少性能。我加了一个简单的时间窗口,限定每帧最多更新一次进度,重复的触摸事件直接丢弃:

let lastTouchTime = 0; onTouchMove(event) { const now = Date.now(); if (now - lastTouchTime < 16) return; // 一帧最多处理一次 lastTouchTime = now; // 更新progress }

这个细节很多人会忽略,但实测下来对安卓机的手感提升非常明显。

5. 踩坑实录:我在这类项目中遇到过的典型问题

5.1 最容易踩的6个坑

做这类项目,我把高频问题整理成了一张速查表,方便排错:

现象原因解法
白屏但控制台无报错<canvas type="webgl">没设type或节点获取失败检查模板里是否有type="webgl",检查SelectorQuery是否拿到node
WebGL context lost内存超限或小程序后台切回导致上下文失效控制纹理总量,监听onHide/onShow重建或暂停循环
翻页时页面黑色闪一下贴图异步加载未完成,纹理数据为空使用纹理池预绘制,设置texture.needsUpdate
翻页曲面不够圆滑PlaneGeometry分段数太少提高segmentsX/Y,或检查是否误用低分段的geometry
翻过页后内容方向反了旋转方向和卷曲方向不一致检查顶点变形时法线方向,必要时用DoubleSide并调整贴图flipY
左右两页重叠穿模depthWrite设置为true导致深度冲突对页面材质设置depthWrite: false

其中“翻过页后内容方向反了”这个问题最隐蔽。因为翻页过程中页面在旋转,正面贴图在某个角度看起来本来就是镜像的,容易怀疑是代码问题。实际上贴图UV方向正常时,翻到左侧展示的是背面纹理,需要在翻页完成换页时做一次左右翻转或者调整背面贴图的方向。

5.2 一次翻页卡顿的定位全过程

有一次真机测试时,翻页前几页很流畅,翻到第七八页开始明显卡顿,越翻越卡,最后直接就黑屏了。这个现象最先让我怀疑是纹理池出了问题,但检查代码之后发现纹理池没有问题。

我通过发布测试日志定位,发现每次翻页后页面的Texture尺寸都不同。原来业务方提供的页面素材有横版有竖版,Canvas绘制时虽然有固定画布,但我在绘制逻辑里用了素材原始尺寸作为绘制大小,导致部分纹理实际尺寸异常放大,上传纹理的耗时和显存占用都暴增。

修复方案是在Canvas绘制前统一做一步适配:不管素材是什么比例,都先缩放到目标画布内的最大适配区域,再居中绘制。这样所有纹理的尺寸严格保持一致,显存占用稳定,卡顿问题就消失了。

这个经历让我养成一个习惯:项目里所有页面素材进入渲染管线之前,必须先经过统一的尺寸归一化处理,否则读文档时看不出问题,真机上一定会出问题。

5.3 关于跨端适配的补充说明

我这次主要针对微信小程序做了完整实现,但uniapp的价值在于跨端,这里补充一下其它端的注意点。

在H5端,threejs初始化会简单得多,直接用常规的DOM方式创建Canvas,new THREE.WebGLRenderer()后插入页面节点即可。但在H5端要额外注意手机浏览器对WebGL的支持情况,部分老安卓浏览器不支持WebGL2。

在App端(nvue或vue页面),情况会更复杂。nvue页面走的是原生渲染,threejs的WebGL支持不如小程序端完善,我测试时出现过多端表现不一致的情况。如果项目必须在App端做同样的翻书效果,建议用renderjs或者原生子组件绕道,而不是复用微信小程序的同一套代码。

支付宝小程序、百度小程序的WebGL支持程度各不相同,我在项目里用条件编译做了接口适配层,把wx.createSelectorQuery换成uni.createSelectorQuery的同时,对WebGL上下文的获取做了平台判断。总体原则是:先保证微信端跑通,再用条件编译逐步适配其他端。

这套方案我做完之后回头再看,真正难的其实不是3D动画本身,而是内容生命周期管理和端侧性能预算控制。翻页算法的核心代码不过几十行,数据结构和纹理池的管理却花了大部分时间。如果你也要做类似的东西,建议先把“页面数据结构”和“纹理复用机制”设计好,再动手写几何变形,会比一上来就调翻页参数顺很多。

返回列表