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 采用threejscanvas方案的技术判断排除掉web-view和DOM方案之后剩下两条路一是用小程序自带的Canvas 2D接口硬画翻页效果二是用threejs做真正的3D渲染。Canvas 2D硬画翻书本质上是做图像变形。每一帧把当前页的图片用数学变换映射到一个卷曲的四边形或多边形区域里配合切图和阴影模拟立体感。这个方案在小程序早期确实有人用过性能尚可但页面一多、图片一复杂代码就会变得特别绕而且翻页的景深感、页面厚度、光影过渡都很难做真实顶多算“伪3D”。threejs在小程序端的可行性依赖的是小程序提供的WebGL渲染能力。微信小程序从基础库2.7.0开始支持WebGL从2.9.0开始支持Canvas 2D接口。通过canvas typewebgl拿到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没有居中在原点而是从原点出发向右延伸。这样后面做顶点变形时所有坐标变换都围绕x0这条轴线进行直观很多。在threejs里PlaneGeometry默认是中心在原点、宽沿x轴、高沿y轴。要让“书脊在x0”可以创建Geometry之后自己平移顶点或者把它包在一个偏移矩阵里去处理。我采用的做法是在创建PlaneGeometry后把整个网格向右平移半个页宽让网格局部坐标的x范围落在[0, pageWidth]0就是书脊线。这个方法代码简单后续顶点遍历也会方便。2.2 翻页顶点变形的核心算法翻页效果最核心的部分就是让一个平面网格在翻动过程中产生自然的卷曲而不是像一块铁板硬生生转过去。我的经验是把整个翻页过程看作“刚体旋转”和“局部卷曲”的叠加。刚体旋转好理解整个页面绕书脊线x0旋转旋转角度从0到π。这一步让页面从“平放”转到“翻过去”。光有旋转还不够纸面是软的翻的过程中会有弯曲。我采用的卷曲模型是对网格上每个顶点根据它离书脊线的距离tt顶点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 })创建。绘制流程是根据页面尺寸创建对应像素比例的离屏Canvas。用2D上下文绘制页面背景、文字、图片、页码。调用canvas.requestAnimationFrame或者直接获取临时文件路径把Canvas作为图像源传给threejs的Texture。一个关键点小程序里Canvas拿到的像素尺寸和实际展示尺寸不一定一致要自己处理DPR。比如页面在屏幕上显示宽度是375逻辑像素物理像素是750如果Canvas绘制尺寸用375贴图上文字可能会发虚。我在绘制时统一乘以设备的pixelRatio保证纹理清晰度。还有一点容易踩坑纹理和网格的纵横比必须匹配。如果页面设计宽高比是3:4PlaneGeometry的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 typewebgl节点。页面模板里写canvas typewebgl idbookCanvas classbook-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 typewebgl的节点传进去。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到pageWidthx0就是书脊线。后面做顶点变形时这个坐标约定非常重要。材质方面我用的MeshBasicMaterial加上DoubleSidefunction 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翻页完成后pageIndex1然后把下一对页面的贴图更新到左右页网格和翻页网格上。贴图更新的关键是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意味着网格有49x331617个顶点每次更新顶点位置需要遍历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 typewebgl没设type或节点获取失败检查模板里是否有typewebgl检查SelectorQuery是否拿到nodeWebGL 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方式创建Canvasnew THREE.WebGLRenderer()后插入页面节点即可。但在H5端要额外注意手机浏览器对WebGL的支持情况部分老安卓浏览器不支持WebGL2。在App端nvue或vue页面情况会更复杂。nvue页面走的是原生渲染threejs的WebGL支持不如小程序端完善我测试时出现过多端表现不一致的情况。如果项目必须在App端做同样的翻书效果建议用renderjs或者原生子组件绕道而不是复用微信小程序的同一套代码。支付宝小程序、百度小程序的WebGL支持程度各不相同我在项目里用条件编译做了接口适配层把wx.createSelectorQuery换成uni.createSelectorQuery的同时对WebGL上下文的获取做了平台判断。总体原则是先保证微信端跑通再用条件编译逐步适配其他端。这套方案我做完之后回头再看真正难的其实不是3D动画本身而是内容生命周期管理和端侧性能预算控制。翻页算法的核心代码不过几十行数据结构和纹理池的管理却花了大部分时间。如果你也要做类似的东西建议先把“页面数据结构”和“纹理复用机制”设计好再动手写几何变形会比一上来就调翻页参数顺很多。
返回列表