做小程序端的电子书项目,翻页交互几乎是甲方必提的需求。第一反应是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。单独用type="2d"的话,虽然getContext('2d')可以取到2D画笔,但threejs需要的是WebGL上下文,两者区别很大,搞混了画面直接白屏且没报错。
<template> <view class="book-container" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd"> <canvas type="webgl" id="bookCanvas" class="book-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是因为当progress=1时theta=PI,整个页面翻到一半,弯曲部分刚好扫描过半径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 = true,threejs才会把新内容上传到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标签type="webgl",适配层把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做翻书有点小题大做,但当看到真机上弧面弯曲、明暗过渡、跟手翻页的效果出来时,确实值得。这套方案的亮点不在于用了多复杂的技术,而是把几何计算、纹理绘制和交互状态机三个层面拆得干净,每一个环扣都能独立调整、独立测试。照着这个思路做下来,翻书效果只是开始,后面还能演化出不少有意思的玩法。