ARTICLE DETAIL

资讯详情

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

基于Three.js的uniapp小程序仿真翻书效果实现与优化

基于Three.js的uniapp小程序仿真翻书效果实现与优化 做小程序端的电子书项目翻页交互几乎是甲方必提的需求。第一反应是turnjs这个基于jQuery的经典翻书插件在Web端确实好用但它依赖完整的DOM和CSS变换能力而uniapp小程序端根本没有那套浏览器环境。换CSS3的transform做伪3D翻页页面弯曲的弧面效果做不出来生硬得像换幻灯片。最终我选了三方组合threejs负责3D渲染Canvas负责绘制页面内容再参考turnjs的翻页交互逻辑在小程序里实现了带弧面弯曲、光影变化的仿真翻书效果。这篇文章把整个从零实现过程完整拆开。从几何原理到代码实现从环境搭建到真机调试我会把踩过的坑和排查思路都写清楚。如果你正在纠结小程序端怎么做仿真翻书或者已经试过但被WebGL适配问题卡住这篇文章应该能给你省下不少时间。1. 需求分析与技术选型1.1 为什么turnjs在小程序端跑不起来先说说为什么不能直接套turnjs。turnjs的原理其实不复杂它把每页书页当成一个DOM元素用jQuery监听鼠标和触摸事件然后通过CSS的transform属性让书页绕书脊旋转。翻动过程中book宽度和翻页角度动态变化浏览器用GPU加速CSS变换看起来就有了翻书效果。但小程序端有两个硬伤。第一小程序没有传统意义的DOM和BOM视图层虽然也叫DOM树但开发者无法像浏览器里那样直接操作任意节点的style和transform属性jQuery那一套选择器、事件绑定、属性修改全都用不了。第二CSS3的变形虽然支持rotateY、skew这些但要实现书页弯曲成弧面的效果光靠transform远远不够——它只能做平面变换做不出顶点的空间扭曲。所以在uniapp小程序端想要仿真翻书效果落地方式其实就剩下一条自己用Canvas渲染。1.2 三个备选方案摸底与我的选择我当时在三个方案里犹豫过。方案一是CSS3伪3D翻页。把书页拆成左右两半翻页时右半页做Y轴旋转再叠加一点skew模拟透视。这个方案的优点是实现快、性能好但效果非常勉强没有弧面弯曲没有光影变化看起来就是一张硬纸板在转甲方肯定不满意。方案二是预渲染帧动画。用photoshop或者代码生成几十帧翻页图片用户拖拽时按进度循环播放。虽然看起来有弧面但一来文件体积很大二来交互是假的没法跟手指拖拽的位置对应翻到一半松手回弹这种操作完全做不了。方案三是threejs加Canvas。threejs负责渲染3D场景书页做成可弯曲的网格内容用Canvas绘制成纹理贴上去触摸事件驱动顶点位置变化。这个方案能做出真正的弧面弯曲和光影效果交互也完全自由缺点是开发量最大小程序端的WebGL适配还要额外处理。最终我选了方案三。项目周期允许我打磨而且threejs的3D能力带来的效果提升是前两个方案没法比的包括手指拖拽的跟手程度、翻页回弹的惯性、灯光扫过弧面产生的明暗渐变。1.3 性能与包体约束选择threejs之前必须面对两个约束包体体积和渲染性能。完整版threejs的minified体积接近600KB在小程序里肯定不能整个打包进去。好在threejs是按模块组织的我用到的核心模块只有Scene、PerspectiveCamera、WebGLRenderer、PlaneGeometry、MeshLambertMaterial、CanvasTexture这几个配合tree-shaking和压缩实际增量可以控制在150KB以内走分包加载完全可行。渲染性能方面小程序的Canvas是原生组件WebGL渲染走GPU理论上比DOM方案更快。但低端安卓机的GPU性能差异很大顶点数量和纹理尺寸必须控制住。这个我在后面的实现部分会详细说。2. 翻书效果的几何与渲染原理2.1 页面弯曲的圆柱模型要做仿真翻书先要理解书页翻动时的几何变化。真实的书页从右往左翻时页面并不是硬邦邦地绕书脊旋转而是从书脊到折痕处形成一个弯曲的弧面弯过去之后的区域再展平成平面。这个弧面可以用圆柱面来近似。假设页面宽度为w翻页角度为θ把整个页面抽象成半径为R w / θ的圆柱面的一部分。页面上任意一点如果它到书脊的距离为x那么这张纸上所有点都会沿着这个圆柱面做等距映射。点(x, y)翻动后在三维空间里的坐标是(R·sin(α), y, R·(1 - cos(α)))其中α (x / w) · θ。这个公式是整个翻页效果的基础。α就是该点扫过的弧角从书脊到自由边线性递增所以当自由边翻到θ时书脊处α0、位置不变自由边αθ、已经完全离开桌面。把所有顶点都按这个规则变换原本平面的纸张就变成了弯曲的3D曲面。3D坐标系里我约定书脊平行于Y轴页面初始状态在XZ平面上Z轴正方向朝向读者。翻页时自由边向左翻Z坐标逐渐变成负值X坐标也因为弧面弯曲而收缩。2.2 折痕与平直区间的分段拟合上面的圆柱公式描述的是整页均匀弯曲。真实翻书时已经翻过去的区域应该是平的只有中间的一段是弯曲的。如果整页都均匀弯曲看起来像纸片被卷成了筒不像翻书。所以实现时要把页面分成两个区间靠近书脊的区间还在弯曲已经翻过去的区间保持平直。设折痕在页面宽度方向的比例为bendRatio比如0.5表示折痕在页面中央。对于宽度方向上x/W bendRatio的部分走圆柱弯曲公式对于x/W bendRatio的部分先按弯曲公式计算出边界点的坐标再让剩余部分沿着已经偏转的平面方向平直延伸。这里有一个必须注意的连续性问题。bendRatio分界处弯曲部分末尾点的法线方向和平面部分起点的法线方向必须一致否则页面会出现折痕裂缝。做法是让平面部分的延伸方向取弯曲部分末端点的切线方向也就是角度θ所在的方向角。我当时就是没注意这个翻页时页面中央总有一道明显的裂痕后来在分界点处做了方向对齐才解决。2.3 纹理、法线与光影的关系页面弯曲后能不能有真实的纸感很大程度上取决于法线和光照。理论上弯曲表面的法线方向每个顶点都不同页面凹面和凸面受光效果也不一样。threejs里修改顶点位置后调用geometry.computeVertexNormals()它会根据相邻三角形的叉积重新计算每个顶点的法线。这一步不能省否则所有顶点法线都朝同一个方向弯曲部分没有明暗过渡整个页面看起来是平的。纹理的UV坐标也需要理解。PlaneGeometry默认的UV是(0,0)在左下角(1,1)在右上角纹理贴上去之后默认不做Y轴翻转。但小程序Canvas绘制出来的像素坐标是Y轴朝下直接贴到threejs网格上页面内容会上下颠倒。解决方式是在CanvasTexture创建后设置texture.flipY false或者提前在Canvas绘制时把坐标系反过来。我实测下来flipY false更省心。3. 小程序端threejs环境搭建3.1 适配方案原生threejs跑在浏览器里依赖window、document、HTMLCanvasElement这些API。小程序里没有window也没有document连canvas的获取方式都不一样。在uniapp里主要有两条路。一条是使用社区适配好的小程序版threejs。这类版本会在内部把浏览器API替换成小程序的Canvas实现使用方式跟标准threejs几乎一致。优点是省心缺点是升级慢如果项目需要比较新的threejs特性就要自己动手改。另一条是自己做适配层。核心工作是把浏览器环境依赖的API逐一桩掉然后在创建WebGLRenderer时把通过wx.createSelectorQuery拿到的canvas节点和WebGL上下文传给renderer。我中和了一下开发时用H5端标准threejs调试效果编译到小程序时通过条件编译切换到适配分支。这样既能享受标准threejs的开发效率又能在小程序真机上正常运行。3.2 页面结构与Canvas初始化小程序里要让threejs有WebGL上下文页面里的canvas标签必须指定type为webgl。单独用type2d的话虽然getContext(2d)可以取到2D画笔但threejs需要的是WebGL上下文两者区别很大搞混了画面直接白屏且没报错。template view classbook-container touchstartonTouchStart touchmoveonTouchMove touchendonTouchEnd canvas typewebgl idbookCanvas classbook-canvas/canvas /view /template初始化代码注意条件编译分支// #ifdef MP-WEIXIN const query wx.createSelectorQuery().in(this) query.select(#bookCanvas).fields({ node: true, size: true }).exec((res) { if (!res || !res[0]) return const canvas res[0].node const gl canvas.getContext(webgl) if (!gl) { console.error(WebGL上下文获取失败) return } // 用适配后的threejs创建renderer renderer new THREE.WebGLRenderer({ canvas: canvas, context: gl, antialias: true, alpha: true }) renderer.setPixelRatio(Math.min(canvas.width / res[0].width, 2)) }) // #endif // #ifdef H5 const canvas document.getElementById(bookCanvas) const renderer new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true }) // #endif这里有个小细节小程序canvas节点的宽高需要用fields里的size信息去换算不能直接拿CSS像素。我一开始用canvas.width直接设置renderer尺寸真机上画面会被拉伸因为小程序的canvas.width默认是canvas节点逻辑尺寸跟物理像素点不一样。setPixelRatio里用实际物理宽度除以逻辑宽度就是当前设备的像素比。3.3 包体裁剪与启动优化threejs的ES Module结构天然支持tree-shaking。在uniapp的vue3项目里直接从three模块按需导入构建时没有用到的部分会被摇掉。import { Scene, PerspectiveCamera, WebGLRenderer, PlaneGeometry, MeshLambertMaterial, CanvasTexture, DoubleSide, DirectionalLight, AmbientLight } from three如果项目跑的是HBuilderX的uni_modules方式也可以考虑用专门裁剪过的精简版threejs包。启动优化方面页面onLoad时先渲染封面同时异步加载其他页面的纹理第一帧不等全部资源就绪封面纹理绘制完成后立刻render避免白屏时间过长。4. 核心实现步骤4.1 书本结构建模一本书不只是孤零零一张纸。我的做法是用一个BookGroup承载整个书封面、封底、固定左半页、可动右半页、书脊和厚度都用独立的Mesh构成。const bookGroup new THREE.Group() // 书页尺寸单位是实际的逻辑像素 const pageW 180 const pageH 240 // 固定左半页 const leftGeom new THREE.PlaneGeometry(pageW, pageH, 1, 1) const leftMat new THREE.MeshLambertMaterial({ color: 0xffffff, side: THREE.DoubleSide }) const leftPage new THREE.Mesh(leftGeom, leftMat) leftPage.position.x -pageW / 2 bookGroup.add(leftPage) // 可动右半页开启分段方便弯曲 const rightGeom new THREE.PlaneGeometry(pageW, pageH, 32, 16) const rightMat new THREE.MeshLambertMaterial({ color: 0xffffff, side: THREE.DoubleSide }) const rightPage new THREE.Mesh(rightGeom, rightMat) rightPage.position.x pageW / 2 bookGroup.add(rightPage)右半页的初始position.x在pageW/2这样左边缘正好压在书脊位置。顶点弯曲计算时所有坐标都基于网格自身的局部坐标也就是以平面中心为原点所以弯曲变换时需要先减掉宽度一半做偏移算完再加回来。书本厚度我用一个薄薄的BoxGeometry垫在页面下面高度设成1.2颜色比页面稍暗一点做出纸张堆叠的侧面感。4.2 顶点弯曲算法实现下面是核心函数。注意我把连续性问题处理掉了。function updatePageVertices(mesh, progress) { const geometry mesh.geometry const positionAttr geometry.attributes.position const width geometry.parameters.width const height geometry.parameters.height // progress 取值范围 [0, 1]0为平躺1为完全翻到左边 const theta Math.PI * progress const radius width / Math.PI // 折痕位置比例弯曲区间和平直区间的分界 const bendRatio 0.5 // 弯曲区间扫过的弧度 const bendAngle bendRatio * theta for (let i 0; i positionAttr.count; i) { const localX positionAttr.getX(i) const localY positionAttr.getY(i) // 平移到以书脊为原点计算 const x localX width / 2 const u x / width // 0 ~ 1 let newX, newZ if (u bendRatio) { // 弯曲区间圆柱面映射 const alpha (u / bendRatio) * bendAngle newX radius * Math.sin(alpha) newZ radius * (1 - Math.cos(alpha)) } else { // 平直区间沿着已偏转方向延伸 const flatRatio 1 - bendRatio const t (u - bendRatio) / flatRatio const baseX radius * Math.sin(bendAngle) const baseZ radius * (1 - Math.cos(bendAngle)) newX baseX t * (width - bendRatio * width) * Math.cos(theta) newZ baseZ t * (width - bendRatio * width) * Math.sin(theta) } // 转回以网格中心为原点等宽新坐标需要在世界空间里对齐到书脊位置 positionAttr.setX(i, newX - width / 2) positionAttr.setZ(i, newZ) positionAttr.setY(i, localY) } positionAttr.needsUpdate true geometry.computeVertexNormals() }原理补充radius取width/PI是因为当progress1时thetaPI整个页面翻到一半弯曲部分刚好扫描过半径width/PI、角度PI的圆柱面。这样从完全平放到完全翻过来的过程中自由边扫过的轨迹最接近真实纸面运动。实际调试中bendRatio建议不要超过0.5。我试过设成0.6翻页时页面会显得僵硬因为弯曲区域太长了而且松手回弹的时候弧度变化不自然。0.5左右最接近真实的折痕位置。4.3 页面内容绘制与纹理绑定文字和图片内容我在离屏Canvas上绘制改页面内容只需要改这一段逻辑跟3D渲染层完全解耦。function drawPageContent(canvas, text, imagePath) { const ctx canvas.getContext(2d) const W canvas.width const H canvas.height // 背景 ctx.fillStyle #f9f4e8 ctx.fillRect(0, 0, W, H) // 边框 ctx.strokeStyle #d5c5a0 ctx.lineWidth 2 ctx.strokeRect(10, 10, W - 20, H - 20) // 正文 ctx.fillStyle #333333 ctx.font 16px sans-serif ctx.fillText(text, 24, 40) // 插图也可以用canvas绘制一个小人形象提升书的趣味性 if (imagePath) { const img canvas.createImage() img.src imagePath img.onload () { ctx.drawImage(img, 30, 120, 120, 100) refreshTexture() } } }纹理绑定要注意更新时机。Canvas绘制完成后必须设置texture.needsUpdate truethreejs才会把新内容上传到GPU。如果图片是异步加载的onload回调里再设置needsUpdate不能在外面提前设置。function createPageTexture(canvas) { const texture new THREE.CanvasTexture(canvas) texture.flipY false texture.minFilter THREE.LinearFilter texture.magFilter THREE.LinearFilter return texture }4.4 交互状态机与动画节奏翻书不能只靠一次性的顶点计算交互状态需要仔细设计。我把翻页状态拆成四个用枚举维护。const PageState { IDLE: IDLE, // 静止可触碰 DRAGGING: DRAGGING, // 拖拽中跟随手指 RELEASING: RELEASING, // 松手后惯性翻页 TURNING_BACK: TURNING_BACK // 松手回弹 }拖拽事件里根据手指的X坐标计算翻页进度。问题是翻页的初始位置在屏幕右侧手指拖拽距离跟页面宽度并不相等直接映射会让翻页速度异常。我用的做法是把触摸的startX记录为初始参考点touchmove时取当前X和startX的差值再除以屏幕宽度的一半作为进度增量最后做0到1的clamp。松手后如果当前进度大于0.15播放RELEASING完成整页翻动否则播放TURNING_BACK回弹。动画用requestAnimationFrame驱动每帧把progress按固定步长step推进。function onTouchMove(e) { const touch e.touches[0] const dx startX - touch.x const maxDelta screenWidth * 0.5 state PageState.DRAGGING progress Math.min(Math.max(dx / maxDelta, 0), 1) updatePageVertices(rightPage, progress) } function onTouchEnd() { if (progress 0.15) { state PageState.RELEASING } else { state PageState.TURNING_BACK } }动画循环的节奏也值得控制。小程序WebGL渲染本身会刷新但如果在idle状态仍然每帧执行render那就是浪费性能。我用一个isAnimating标记只有非IDLE状态下才启动主动渲染循环状态切回IDLE就立即停掉。4.5 双面渲染与正反页面切换页面翻过去之后应该显示下一页的内容。如果用同一个Mesh同一个纹理背面看到的是镜像的当前页这不是翻书。我的办法是给右半页准备两个Mesh正面页Mesh和背面页Mesh。背面页使用的纹理是当前页面的镜像Canvas里绘制时做scaleX(-1)几何体与正面页共享但material独立。// 翻页完成时交换页面数据 function turnPage(nextPageIndex) { currentPageIndex nextPageIndex const frontTexture createPageTexture(renderFrontCanvas(currentPageIndex)) const backTexture createPageTexture(renderBackCanvas(currentPageIndex)) frontPageMesh.material.map frontTexture backPageMesh.material.map backTexture frontPageMesh.material.map.needsUpdate true backPageMesh.material.map.needsUpdate true }两个Mesh的做法比只用DoubleSide多一次draw call但换来了背面内容可自定义。对于电子书来说背面显示什么内容必须可控这点开销值得。4.6 光影与仿真的最后一道工序光照是让弧面弯曲显露出质感的决定性因素。我用了两盏灯一盏方向光从左上角照亮页面凸面一盏环境光保证页面黑暗面不全黑。const dirLight new THREE.DirectionalLight(0xffffff, 0.9) dirLight.position.set(300, 500, 400) scene.add(dirLight) const ambientLight new THREE.AmbientLight(0xffffff, 0.4) scene.add(ambientLight)灯光角度不同弯曲纸面的明暗过渡完全不同。建议在H5端先调好灯光位置和强度再搬到小程序真机验证因为真机的屏幕色域和H5有差异亮部容易过曝。另外我在书脊处加了一个半圆柱体模型比单纯平面更能营造书本的真实体积感。阴影我没有用真阴影而是在页面下方放了一张半透明的黑色渐变CanvasTexture作为假阴影翻页时阴影跟随页面轻微移动性能和效果平衡很好。5. 真机调试与性能优化实录5.1 四个典型问题与排查过程真机调试阶段我遇到的最典型的四个问题列成一张速查表后面逐个说。现象可能原因解决方案页面白屏无报错canvas.getContext(webgl)返回null或renderer创建时canvas传入了错误对象确认canvas标签typewebgl适配层把node传给renderer页面内容上下颠倒Canvas纹理Y轴方向问题纹理设置flipY false贴图黑色Canvas绘制未完成就创建纹理或者needsUpdate未设绘制完成回调里设置needsUpdate true拖拽卡顿掉帧顶点数过多、每帧computeVertexNormals开销大降低分段数拖拽期间不重算法线用简单材质第一个问题最隐蔽。小程序里获取canvas节点的方式跟H5完全不同很多人拿document.getElementById去拿拿不到自然白屏。必须用wx.createSelectorQuery或者uni.createSelectorQuery且指定fields:{node: true}。第二个问题其实是我在2.3节里说的flipY。小程序Canvas默认像素坐标跟WebGL的UV坐标相反如果不处理文字全部倒过来。第三个问题的排查思路值得一提。我用一个单独的调试按钮触发翻页发现第一次翻页正常第二次再翻就全黑。检查纹理状态发现是创建纹理的时候canvas还没有内容而needsUpdate只在按钮回调里设了一次。修复方法是在Canvas内容更新完成后主动重新设置needsUpdate而不是依赖创建时的状态。第四个问题要单独开一节讲因为性能优化是翻书效果能落地的关键。5.2 性能优化清单性能优化的核心原则只有一个减少每帧GPU和CPU的工作量。具体来说有三板斧。第一板斧是控制顶点数。右半页PlaneGeometry的分段宽度方向32段、高度方向16段在H5端很流畅但真机低端机上拖拽时明显掉帧。我最终把宽度方向降到20、高度方向降到8。分段数降低后弧面平滑度肉眼可接受但每帧顶点计算量减少了接近60%。// H5端测试用32 x 16 // 真机正式版20 x 8 const rightGeom new THREE.PlaneGeometry(pageW, pageH, 20, 8)第二板斧是避开computeVertexNormals。拖拽过程中手指每移动一像素都可能触发顶点更新而computeVertexNormals要遍历所有三角形做叉积开销不小。我在拖拽过程中只更新position不重算法线用材质的光照表现稍微牺牲一点只有松手动画时才调用一次computeVertexNormals。实测掉帧明显改善。如果连这个开销都受不了还有一个更激进的做法把页面材质换成MeshBasicMaterial完全不要光照弧面弯曲只靠页面边缘的轮廓体现。这个适合超低端机降级。第三板斧是渲染循环的开关管理。IDLE状态下不启动requestAnimationFrame只有拖拽或动画中才跑循环。静态页面占用的GPU资源几乎为零后台运行更省电。5.3 资源管理与多页书的内存优化电子书通常有几十页甚至上百页。如果一次性把所有页面纹理都创建内存会迅速涨到几百MB低端机直接闪退。我的做法是只保留当前页和下一页的纹理翻页完成后再释放上一个页面的纹理。用Map管理页面索引和纹理的映射超出缓存范围的纹理调用dispose()释放。const textureCache new Map() const MAX_CACHE 3 function getPageTexture(index) { if (textureCache.has(index)) return textureCache.get(index) const canvas renderPageOffscreen(index) const tex new THREE.CanvasTexture(canvas) tex.flipY false textureCache.set(index, tex) // 超出缓存限制释放最旧的 if (textureCache.size MAX_CACHE) { const oldestKey textureCache.keys().next().value const oldestTex textureCache.get(oldestKey) oldestTex.dispose() textureCache.delete(oldestKey) } return tex }这个缓存策略配合预加载机制用户在翻第1页时后台提前创建第2页的离屏Canvas翻页动画开始后直接取纹理减少等待闪白。小程序的离屏Canvas功能在基础库2.7.0以上支持uniapp里通过uni.createOffscreenCanvas调用注意H5端和小程序端的兼容写法不同建议封装一个工厂方法。6. 这个方案的扩展空间翻书效果做出来之后我发现这套顶点弯曲算法完全可以泛化不只是翻书。卡片切换可以做把圆柱弯曲换成圆球弯曲卡片从中间向两侧扩散做成3D展开效果。相册翻页可以做只需要把离屏Canvas绘制的内容从文字换成图片纹理绘制那一层完全不用动。商品目录可以做把翻页换成横向滑动页面内容换成图片加价格标签配上3D厚度感更上档次。具体到tips我在实测中发现一个屡试不爽的小技巧顶点弯曲计算时把进度值做缓动处理也就是把输入progress先经过一个easeOutCubic再传给updatePageVertices翻页的手感会变得非常细腻尤其松手回弹时明显比线性动画自然。代价是动画逻辑稍微复杂一点但效果提升很值得。7. 写在最后的几点建议做完整个项目我最想分享的其实是三个实践层面的事。第一H5端一定是第一调试阵地。小程序端的WebGL适配层和真机调试环境限制太多先把threejs逻辑和翻页几何在H5端完全跑顺再编译到小程序真机验证适配。不要直接从真机开始调那样效率太低。第二性能指标必须量化。我给自己定的标准是真机拖拽帧率不低于50帧松手动画不低于40帧内存占用不超过300MB。超标的特征通常很明显但不量化就没有优化方向。第三Canvas绘制层和threejs渲染层的边界一定要清楚。页面内容怎么排版、文字图片怎么布局、页码怎么更新这些都在Canvas绘制函数里完成3D层只负责弯曲和渲染。这样即使以后换渲染框架页面内容的逻辑还能完整复用。说实话一开始我也觉得在小程序端用threejs做翻书有点小题大做但当看到真机上弧面弯曲、明暗过渡、跟手翻页的效果出来时确实值得。这套方案的亮点不在于用了多复杂的技术而是把几何计算、纹理绘制和交互状态机三个层面拆得干净每一个环扣都能独立调整、独立测试。照着这个思路做下来翻书效果只是开始后面还能演化出不少有意思的玩法。
返回列表