前几天有个做数字展厅的朋友拦住我,问了一句:Unity里想放一本能亲手翻页的书,有没有现成demo可以抄?我当时第一反应是搜插件,结果不是收费就是依赖太老,要么就是打开后根本没想象中轻量。后来我没再绕弯子,直接用Shader顶点偏移的方式自己写了一个——一个建立在新空项目上、可拖拽翻页、支持双面渲染和背光修正的完整demo。这篇文章我会把选型思路、几何原理、Shader代码、C#交互,以及我从踩坑中总结出来的渲染细节全部摊开讲,给那些想快速落地又不想被插件绑架的人一条能跑通的路。
1. 翻一本能翻的书,为什么大家都在选型上反复横跳
1.1 四条路线摆在一起比一比
做"翻页书"最迷惑人的地方在于,它看着是单页,实际牵涉到网格变形、渲染状态切换、交互手感三层问题。不同方案在这三层上的取舍完全不同。
| 实现路线 | 灵活度 | 资源依赖 | 性能开销 | 上手难度 | 适合的场景 |
|---|---|---|---|---|---|
| CPU网格变形 | 高 | 需要高细分网格 | 顶点回读、每帧改Mesh | 低 | 固定翻页动画、离线演示 |
| 骨骼动画 | 中 | 需要美术绑骨骼、调权重 | 低 | 高 | 角色手翻书等复杂联动 |
| Shader顶点偏移 | 高 | 几乎为零 | 低,GPU并行 | 中 | 实时交互、动态翻页、Demo快速验证 |
| 第三方插件 | 低 | 闭源黑盒 | 不可控 | 低 | 工期紧、效果不复杂 |
网格变形是我最早试的:在CPU里按曲线移动顶点,写起来直白,但问题也直白——一旦翻到一半,碰撞体、法线、阴影全部要对齐,而且每帧调用 mesh.vertices = ... 会产生不小的GC和延迟,节点一多帧率就往下掉。
骨骼动画更适合那种"角色伸手把书拿起来翻"的复杂场景,可一本纯粹展示内容、让用户自己拖拽翻页的数字书,让美术去绑骨骼完全是大材小用,调试周期也长。
第三方插件在Demo阶段确实香,但我遇到过两次升级Unity版本后Shader失效的情况,排查起来特别被动。自己写哪怕出问题,至少心里有排查路径。
1.2 我选Shader顶点偏移的真实理由
这个方案最核心的逻辑是:书页网格的顶点坐标不用在CPU侧改,而是把"翻页进度"当参数传进Shader,在顶点阶段把每个顶点的局部坐标重新算一遍。书页还是那张书页,顶点还是那些顶点,但位置被Shader重写了,GPU并行处理,速度快,参数也天然适合交互。
我选它的理由有三个,都很实际:
- 不需要额外美术资源,一个基础网格就能起稿,文字、贴图通过UV映射上去,翻动时纹理跟着表面走。
- 翻页进度
_Progress是从C#脚本实时传入的,可以和鼠标拖拽、触摸滑动、动画曲线直接关联,天然适合"用户可以亲手翻"这个核心需求。 - 后续扩展空间大:加一本多页书,不用改Shader核心,只要给每页传不同的进度值就行,甚至可以连多页书脊厚度一起模拟。
这个方案真正难的不是"让顶点动起来",而是动了之后还别露馅——法线、背面、阴影都得跟着动。后面我会专门拆这一块。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 翻页的几何内核:把一张平面卷到圆柱面上
2.1 圆柱面卷曲的公式拆解
翻页的视觉效果,抛开炫技,本质就是把一张平面“窝”在一个虚拟圆柱面上。页面上每个点离书脊的距离,决定了这个点沿圆柱走了多长的弧。
我用的网格放在XZ平面上:x 方向是页宽,x=0 是书脊位置,x=PageWidth 是页边;z 是页面高度方向;y 初始为0。翻页进度 _Progress 在0到1之间变化,已卷曲的弧长:
code复制bendLength = _Progress * _PageWidth
这张页面从书脊开始,在半径为 _BendRadius 的圆柱面上展开。对于距离书脊为 x 的点,分两种情况处理。
第一种是点已经卷到圆柱面上,即 x <= bendLength。设它在圆柱上走过的角度为 a = x / _BendRadius,则它的新位置是:
code复制newX = _BendRadius * sin(a)
newY = _BendRadius * (1 - cos(a))
newZ = z
这条公式的几何含义很直观:圆柱面的截面圆心在书脊正上方 _BendRadius 处,页面从书脊出发贴着圆柱表面向上卷,y 越来越大,x 先向右伸再往回收。
第二种是还没卷到的那部分平面,即 x > bendLength。它作为刚体,跟随已弯曲末端的切线方向旋转,旋转角度是总卷曲角度:
code复制angle = bendLength / _BendRadius
local = x - bendLength
newX = _BendRadius * sin(angle) + local * cos(angle)
newY = _BendRadius * (1 - cos(angle)) + local * sin(angle)
newZ = z
这里能看出末节平面其实就是从弯曲端点延伸出去的切线。翻页进行到一半时,平面部分会保持平整,整张页面呈现“根部卷、尖部平”的自然纸张形态,比单纯整张绕书脊硬转要真实得多。
我建议第一次跑通时不要追求圆柱模型完全精确,先把 _Progress 从0到1匀速变化,观察页尖的运动轨迹是不是一条顺滑的弧线。确认这一点,核心几何就对了百分之八十。
2.2 三个关键参数的调参指南
翻页有没有手感,很大程度靠三个参数喂出来。
| 参数 | 作用 | 值偏小的表现 | 值偏大的表现 | 推荐起点 |
|---|---|---|---|---|
_BendRadius |
卷曲半径 | 页面折叠感强、像撕纸 | 页面像平板绕轴转,失去弯曲 | 0.5~0.9 |
_PageWidth |
页面宽度 | 翻页行程短、翻得急 | 行程长、拖拽感笨重 | 按实际书宽 |
_Progress |
翻页进度 | 回弹位置提示明显 | 直接翻完 | 由交互控制 |
调 _BendRadius 时要特别留意:半径太小,靠近书脊的顶点在角度很大时会出现向内侧穿模;半径太大,整页趋近于刚体旋转,又失去了圆柱卷曲的纸张感。我一般先把半径设为书宽的0.6倍,再按实际渲染效果微调。
还有一点容易忽略:为了让翻页方向正确,要确保网格的 x 正方向指向页边,而不是指向书脊。方向反了,公式里的 sin 和 cos 会让页面往桌面底下钻。
2.3 网格细分:弯得顺不顺就看它
顶点位移方案最反直觉的坑是:如果网格精度不够,圆柱弯曲就成了折纸。默认Unity的Plane是10×10段,直接拿来当书页,翻到一半时页面边缘会出现明显的多边形折角,完全没有纸张的丝滑感。
解决方法是代码生成网格,而不是用内置Primitive。我通常准备至少 32×24 的分段。分段数越高,弯曲越平滑,但顶点数和DrawCall成本也上来了,Demo阶段不用一味追求高细分。
生成网格时可以顺便保证UV与平面尺寸一致:u 从0到1对应书脊到页边,v 从0到1对应页底到页顶。这样文字贴图才能正确贴合在页面上,翻动时文字跟着曲面走,不会出现横向拉伸。
3. 手把手落地:从空工程到一个能拖拽的单页翻书Demo
3.1 场景搭建与书页网格生成
先在场景里建一个空物体 Book,给它挂一个子物体 Page。Page 上不需要放Unity的Plane,直接用脚本生成Mesh更可控。
code复制// 挂在Page上,负责生成高细分的书页网格
using UnityEngine;
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
[RequireComponent(typeof(MeshCollider))]
public class PageMeshGenerator : MonoBehaviour
{
public float width = 2f;
public float height = 2.8f;
public int segX = 40;
public int segY = 28;
void Awake()
{
GetComponent<MeshFilter>().mesh = BuildMesh();
GetComponent<MeshCollider>().sharedMesh = GetComponent<MeshFilter>().mesh;
}
Mesh BuildMesh()
{
var verts = new Vector3[(segX + 1) * (segY + 1)];
var uvs = new Vector2[verts.Length];
var tris = new int[segX * segY * 6];
for (int x = 0; x <= segX; x++)
{
for (int y = 0; y <= segY; y++)
{
int index = x * (segY + 1) + y;
float u = (float)x / segX;
float v = (float)y / segY;
verts[index] = new Vector3(u * width, 0f, (v - 0.5f) * height);
uvs[index] = new Vector2(u, v);
}
}
int tri = 0;
for (int x = 0; x < segX; x++)
{
for (int y = 0; y < segY; y++)
{
int i0 = x * (segY + 1) + y;
int i1 = i0 + 1;
int i2 = (x + 1) * (segY + 1) + y;
int i3 = i2 + 1;
tris[tri++] = i0;
tris[tri++] = i2;
tris[tri++] = i1;
tris[tri++] = i1;
tris[tri++] = i2;
tris[tri++] = i3;
}
}
var mesh = new Mesh();
mesh.vertices = verts;
mesh.uv = uvs;
mesh.triangles = tris;
mesh.RecalculateBounds();
return mesh;
}
}
书脊方向是 x=0 那条边,Mesh碰撞体也得一起绑上,因为后面交互要用射线去点击页面。MeshCollider的网格在Shader里不会跟着弯,这一点我们留到3.4再说,现在先保证点得中。
3.2 Shader核心:顶点偏移与法线重构
这是整个Demo的心脏。我给出一份可以直接新建 UnlitShader 拿去用的完整代码,核心全部集中在 vert 阶段。
code复制Shader "Unlit/PageCurl"
{
Properties
{
_MainTex ("Page Texture", 2D) = "white" {}
_BendRadius ("Bend Radius", Range(0.1, 2)) = 0.7
_PageWidth ("Page Width", Float) = 2.0
_Progress ("Page Turn Progress", Range(0, 1)) = 0
_LightDir ("Light Dir", Vector) = (0.5, 1, 0.3, 0)
}
SubShader
{
Tags { "RenderType"="Opaque" }
Cull Off
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
float _BendRadius;
float _PageWidth;
float _Progress;
float4 _LightDir;
float3 ApplyPageTurn(float3 localPos)
{
float x = localPos.x;
float z = localPos.z;
float bendLength = _Progress * _PageWidth;
float angle = bendLength / max(_BendRadius, 0.001);
if (_Progress <= 0.001)
return localPos;
if (x <= bendLength)
{
float a = x / max(_BendRadius, 0.001);
float newX = _BendRadius * sin(a);
float newY = _BendRadius * (1.0 - cos(a));
return float3(newX, newY, z);
}
else
{
float local = x - bendLength;
float newX = _BendRadius * sin(angle) + local * cos(angle);
float newY = _BendRadius * (1.0 - cos(angle)) + local * sin(angle);
return float3(newX, newY, z);
}
}
v2f vert (appdata v)
{
v2f o;
float3 turnedPos = ApplyPageTurn(v.vertex.xyz);
o.worldPos = mul(unity_ObjectToWorld, float4(turnedPos, 1.0)).xyz;
o.vertex = UnityObjectToClipPos(float4(turnedPos, 1.0));
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i, half facing : VFACE) : SV_Target
{
// 顶点弯曲后原法线已经失效,用屏幕空间导数重构
float3 dpdx = ddx(i.worldPos);
float3 dpdy = ddy(i.worldPos);
float3 normal = normalize(cross(dpdx, dpdy));
normal *= (facing > 0) ? 1.0 : -1.0;
float3 lightDir = normalize(_LightDir.xyz);
float ndotl = saturate(dot(normal, lightDir));
fixed4 col = tex2D(_MainTex, i.uv);
col.rgb *= 0.55 + 0.45 * ndotl;
return col;
}
ENDCG
}
}
}
这段代码里我把书页的弯曲和法线重构放在一起。ApplyPageTurn 负责把局部坐标重算;frag 里用 ddx/ddy 求世界坐标的偏导数,再叉乘得到当前像素的法线方向。因为页面弯曲是连续的,屏幕空间导数估算出来的法线在大多数平台上表现都很好,比手工按公式解析计算法线要省事得多,也不容易写错符号。
VFACE 语义用来判断当前渲染的是正面还是背面,这正是双面渲染必需的:书页正面朝你时法线向上,翻过去后背面朝你时法线必须反向,否则光照会一塌糊涂。facing 在不同图形API下取值范围可能有差异,在正常Unity平台上大于0代表正面,这一句足够可靠。
3.3 C#控制:拖拽交互与状态缓动
Shader有了,接下来让书页可以被鼠标拖起来翻。我写的 PageTurnController 负责把鼠标位置换算成 _Progress,再用缓动让页面在松手后自动回弹或翻完。
code复制using UnityEngine;
[RequireComponent(typeof(MeshRenderer))]
public class PageTurnController : MonoBehaviour
{
[Range(0f, 1f)] public float progress = 0f;
public float pageWidth = 2f;
public float smoothTime = 0.15f;
private Material mat;
private Camera mainCam;
private float targetProgress = 0f;
private bool dragging = false;
void Start()
{
mat = GetComponent<Renderer>().material;
mainCam = Camera.main;
UpdateMaterial();
}
void Update()
{
if (Input.GetMouseButtonDown(0))
{
Ray ray = mainCam.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out _, 100f))
dragging = true;
}
if (Input.GetMouseButtonUp(0))
dragging = false;
if (dragging)
{
Vector3 mouseLocal = GetMouseLocalPos();
targetProgress = Mathf.Clamp01(mouseLocal.x / pageWidth);
}
else
{
targetProgress = (targetProgress > 0.5f) ? 1f : 0f;
}
progress = Mathf.Lerp(progress, targetProgress, 1f - Mathf.Exp(-smoothTime * 60f * Time.deltaTime));
UpdateMaterial();
}
Vector3 GetMouseLocalPos()
{
float distance = Vector3.Distance(transform.position, mainCam.transform.position);
Vector3 worldMouse = mainCam.ScreenToWorldPoint(
new Vector3(Input.mousePosition.x, Input.mousePosition.y, distance));
return transform.InverseTransformPoint(worldMouse);
}
void UpdateMaterial()
{
mat.SetFloat("_Progress", progress);
}
}
拖拽手感的关键在缓动公式。Mathf.Lerp 配合指数衰减,比直接赋值更能模拟纸张的惯性:拖到一半松手,如果超过50%就继续翻完,不到一半就回弹,这段逻辑让交互有了基本的“翻页判定”。
这里有个实现细节:GetMouseLocalPos 里我取 transform.position 到相机的距离来反投影屏幕坐标,得到的世界坐标在页面的近似平面上。虽然不够严格,但配合 Mathf.Clamp01 已经足够把鼠标位置映射成翻页进度了,这也是Demo阶段最务实的做法。
3.4 碰撞体不跟着弯,射线检测怎么写更自然
这条值得单独拎出来说,因为它是Shader方案里最隐蔽的交互坑。
渲染层面页面弯了,但 MeshCollider 共享的还是CPU里的原始平面网格。鼠标点击的命中区域和渲染出来的卷曲页面对不上,页边已经卷到半空中,点击却落在平面上。为了避免这种“看得见摸不着”的割裂感,我用了两个办法。
第一个办法是尽量让页面在交互时处于主要平面附近。早期Demo阶段,书页摊开时 progress 靠近0或1,弯曲范围小,射线检测的误差不明显,足够用。
第二个办法是把射线检测从 Physics.Raycast 换成平面求交。根据书页的旋转角度和当前翻页进度,在C#里同步维护一个“交互平面”的近似位置,然后用数学方式求射线与平面的交点。这样能基本消除错位感。
code复制// 用当前游戏对象的前向量和书脊位置构造一个近似交互平面
Vector3 PlaneRaycast(Ray ray)
{
var planeNormal = transform.forward;
var planeCenter = transform.position;
float t = Vector3.Dot(planeCenter - ray.origin, planeNormal) / Vector3.Dot(ray.direction, planeNormal);
return ray.GetPoint(t);
}
如果你们项目一定要在翻页途中有精准点击,建议上这第二种方案。不是不敢用物理引擎,而是MeshCollider根本不知道Shader已经把顶点搬走了,让物理引擎去猜渲染状态本来就是南辕北辙。
4. 背面、法线、阴影和多页堆叠:真正的分水岭在这里
4.1 双面渲染的背面法线坑
把 Cull Off 打开之后,最容易出现的问题就是:正面正常,翻到一半时背面像贴了一张黑色的磨砂膜,或者高光在背面乱闪。原因很简单——顶点变了,法线没跟着变,背面的法线方向还是朝原来正面方向算的。
我在Shader里用 ddx/ddy 重构法线,再用 VFACE 翻转符号,这一步把双面渲染的光照问题解决得很干净。但这里还有另一个隐蔽点:当页面处于完全摊平状态时,曲面导数可能会因为数值精度产生轻微的噪点。不必担心,翻页动画运行时页面的曲率足够抵消这部分误差。
如果你用Shader Graph而不是手写Shader,同样有对应方案:在顶点阶段接一个自定义函数做 ApplyPageTurn,在片元阶段用 Screen Position 节点配合 Normal From Height 或者直接 DDX/DDY 节点重构法线,再用 Face Sign 节点翻转。思路和手写版完全一致。
4.2 ShadowCaster Pass:让影子别再是平板
很多人写完翻页Shader,页面弯了,但地面影子还是笔直一张板,非常出戏。原因是Unity默认的阴影投射Pass走的是原顶点位置,Shader里的顶点偏移只在主Pass生效。
解决方案是给SubShader补一个 ShadowCaster 通道,内容复刻同样的顶点偏移:
code复制Pass
{
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }
Cull Off
CGPROGRAM
#pragma vertex vertShadow
#pragma fragment fragShadow
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 vertex : SV_POSITION;
};
float _BendRadius;
float _PageWidth;
float _Progress;
float3 ApplyPageTurn(float3 localPos)
{
// 与主Pass相同,建议抽到同一个cginc文件里复用
float x = localPos.x;
float z = localPos.z;
float bendLength = _Progress * _PageWidth;
float angle = bendLength / max(_BendRadius, 0.001);
if (_Progress <= 0.001)
return localPos;
if (x <= bendLength)
{
float a = x / max(_BendRadius, 0.001);
return float3(_BendRadius * sin(a), _BendRadius * (1.0 - cos(a)), z);
}
else
{
float local = x - bendLength;
return float3(
_BendRadius * sin(angle) + local * cos(angle),
_BendRadius * (1.0 - cos(angle)) + local * sin(angle),
z
);
}
}
v2f vertShadow(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(float4(ApplyPageTurn(v.vertex.xyz), 1.0));
return o;
}
fixed4 fragShadow(v2f i) : SV_Target
{
return 0;
}
ENDCG
}
处理完这一段,地面的影子才会跟着卷曲的页面走。我建议把 ApplyPageTurn 函数抽到一个公用的 .cginc 文件里,主Pass和ShadowCaster共用,避免复制两遍逻辑导致改动不一致。
4.3 从单页到一本书:多页堆叠与厚度表现
单页跑通后,要变成“一本书”还需要处理层级关系。常见做法是创建多个 Page 子物体,每一页都挂同一个Shader和交互脚本,但每页的 _Progress 独立控制。
实时翻第 n 页时,前 n-1 页应该已经摊在左侧,当前页从右向左卷,后续页保持静止。这个状态用代码维护一个“当前翻页索引”,每帧给所有页传不同的 _Progress 值就能实现。为了让书看起来有厚度,页与页之间不需要完全贴在一起,我在 z 方向上偏移了零点几的增量,形成一个明显的书口层次。
这里还有一个常见的视觉加分项:已经翻到左侧的页面,不应该和右侧静止页处于同一个平面高度,否则看起来像一张纸片平铺。我给左侧堆积页加了一个轻微的整体旋转,让左侧页叠在一起时呈现出自然的堆叠厚度。
阴影处理上也比单页复杂,因为每一页穿插时会产生自阴影的干扰线,我的办法是把书页层的 ShadowCastingMode 设为 Off,只让封面封底参与投影,免得半透明叠层带来满屏的条纹。
4.4 性能与DrawCall:Demo也要留点余量
有人觉得Demo不必在乎性能,其实不然,翻页书Shader顶点数量一旦上去,加上多页同时渲染,在移动端会非常吃力。几个细节从第一天就值得注意。
一是避免直接 GetComponent<Renderer>().material 产生材质实例。每页一个材质实例就意味着每个实例都是一次全新的Shader状态切换,DrawCall翻倍。多页场景应该用 MaterialPropertyBlock 传 _Progress:
code复制var block = new MaterialPropertyBlock();
renderer.GetPropertyBlock(block);
block.SetFloat("_Progress", progress);
renderer.SetPropertyBlock(block);
这样多个页签可以共享同一个材质,但每页的进度互不干扰。
二是网格细分不是越高越好。我在手机上测试过,48×32 分段在较老的GPU上仍能稳定跑60帧,但如果再往上到 96×64,顶点处理量和寄存器压力会明显上升。Demo追求的是可复现,不是堆资源。
三是Shader里大量使用 ddx/ddy 会增加片元压力。如果确认页面在某一阶段曲率不大,可以只对正面或者只对特定区域启用法线重构,减少指令数。不过从Demo出发,我优先保留这部分,因为它解决的视觉问题优先级更高。
最后说点实际的
从选型到跑通,我最深的体会是:翻页书这个需求,千万不要被“翻页”两个字带偏,真正花时间的地方全在渲染细节。网格生成、顶点卷曲公式、双面法线、ShadowCaster,这四个点按顺序解决,翻页demo基本就成了。我自己排查过的最久一个Bug是背面法线忘翻转,结果页面翻过去像贴了层黑膜,查了一晚上才意识到是 facing 符号问题。如果你也卡在某一步,建议先回到这四件事上逐项过一遍。后续想扩展方向也很多——加圆角书页、做远程端同步翻页进度、把贴图换成RT动态渲染书页内容,都是在这套骨架上继续长肉的事情,祝你们一版翻得过。
