Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39d4c728b0 | ||
|
|
e10dfed93d |
@@ -495,6 +495,7 @@ export class App {
|
|||||||
// 是否有进行中的动画(决定本帧是否需要重绘)
|
// 是否有进行中的动画(决定本帧是否需要重绘)
|
||||||
const animating =
|
const animating =
|
||||||
this.binder.flip !== null ||
|
this.binder.flip !== null ||
|
||||||
|
this.binder.isSettling() ||
|
||||||
this.timeline.hasActiveFlip() ||
|
this.timeline.hasActiveFlip() ||
|
||||||
this.cameraTween !== null ||
|
this.cameraTween !== null ||
|
||||||
this.zoomTarget !== null ||
|
this.zoomTarget !== null ||
|
||||||
|
|||||||
+63
-34
@@ -5,7 +5,9 @@ import { clamp, easeOutCubic } from '../utils/easing.js';
|
|||||||
|
|
||||||
const RING_R = 0.085;
|
const RING_R = 0.085;
|
||||||
const RING_TUBE = 0.0115;
|
const RING_TUBE = 0.0115;
|
||||||
const STACK_GAP = 0.002;
|
const STACK_GAP = 0.004;
|
||||||
|
const TOP_LIFT = 0.02; // 顶层左右两页高出栈内的距离
|
||||||
|
const FLIP_LIFT = 0.028; // 翻页过程中的最大抬高(略高于顶层静止高度)
|
||||||
const PAGE_GAP = 0.02; // 页内缘离书脊的小空隙
|
const PAGE_GAP = 0.02; // 页内缘离书脊的小空隙
|
||||||
|
|
||||||
function makeLeatherTexture({ emblem = false } = {}) {
|
function makeLeatherTexture({ emblem = false } = {}) {
|
||||||
@@ -85,7 +87,7 @@ function makeSuedeTexture() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 圆角矩形轮廓(直线段 + 圆弧段都用 lineTo 细分,正反面/皮边共享同一轮廓)
|
// 圆角矩形轮廓(直线段 + 圆弧段都用 lineTo 细分,正反面/皮边共享同一轮廓)
|
||||||
function roundedRectShape(w, h, r, edgeSeg = 40, arcSeg = 12) {
|
function roundedRectShape(w, h, r, edgeSeg = 16, arcSeg = 10) {
|
||||||
const hw = w / 2, hh = h / 2;
|
const hw = w / 2, hh = h / 2;
|
||||||
const s = new THREE.Shape();
|
const s = new THREE.Shape();
|
||||||
s.moveTo(-hw + r, hh);
|
s.moveTo(-hw + r, hh);
|
||||||
@@ -112,7 +114,7 @@ function roundedRectShape(w, h, r, edgeSeg = 40, arcSeg = 12) {
|
|||||||
// 软皮曲面:ShapeGeometry 三角化(圆角正确、无缺口)+ 书脊处弧度弯曲
|
// 软皮曲面:ShapeGeometry 三角化(圆角正确、无缺口)+ 书脊处弧度弯曲
|
||||||
function makeLeatherSurface(w, h, crXY, curve, flip) {
|
function makeLeatherSurface(w, h, crXY, curve, flip) {
|
||||||
// 规则网格(含内部顶点,曲面平滑无对角褶痕)+ 角区收缩到圆弧
|
// 规则网格(含内部顶点,曲面平滑无对角褶痕)+ 角区收缩到圆弧
|
||||||
const segX = 128, segY = 96;
|
const segX = 64, segY = 48;
|
||||||
const hw = w / 2, hh = h / 2;
|
const hw = w / 2, hh = h / 2;
|
||||||
const bx = hw - crXY, by = hh - crXY;
|
const bx = hw - crXY, by = hh - crXY;
|
||||||
const sigma = 0.5;
|
const sigma = 0.5;
|
||||||
@@ -337,7 +339,7 @@ export class Binder {
|
|||||||
plate.receiveShadow = true;
|
plate.receiveShadow = true;
|
||||||
|
|
||||||
// 铆钉:把薄片钉在牛皮上(覆盖新的铁环范围 ±0.896)
|
// 铆钉:把薄片钉在牛皮上(覆盖新的铁环范围 ±0.896)
|
||||||
const rivetGeo = new THREE.SphereGeometry(0.013, 12, 10);
|
const rivetGeo = new THREE.SphereGeometry(0.013, 8, 6);
|
||||||
const rivets = [];
|
const rivets = [];
|
||||||
for (const ry of [-0.78, -0.39, 0, 0.39, 0.78]) {
|
for (const ry of [-0.78, -0.39, 0, 0.39, 0.78]) {
|
||||||
const rivet = new THREE.Mesh(rivetGeo, mat);
|
const rivet = new THREE.Mesh(rivetGeo, mat);
|
||||||
@@ -357,7 +359,7 @@ export class Binder {
|
|||||||
color: 0xd4d7dc, metalness: 0.95, roughness: 0.2,
|
color: 0xd4d7dc, metalness: 0.95, roughness: 0.2,
|
||||||
});
|
});
|
||||||
// 更平滑的活页环
|
// 更平滑的活页环
|
||||||
const geo = new THREE.TorusGeometry(RING_R, RING_TUBE, 18, 56);
|
const geo = new THREE.TorusGeometry(RING_R, RING_TUBE, 12, 24);
|
||||||
this.rings = [];
|
this.rings = [];
|
||||||
for (const u of HOLE_U) {
|
for (const u of HOLE_U) {
|
||||||
const y = (0.5 - u) * PAGE_H;
|
const y = (0.5 - u) * PAGE_H;
|
||||||
@@ -370,30 +372,29 @@ export class Binder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_makePageGeometry(segX, segY) {
|
||||||
|
// 纯平面页(无自然弧度):页内缘离书脊留出小空隙,左右页不紧贴
|
||||||
|
const geo = new THREE.PlaneGeometry(PAGE_W, PAGE_H, segX, segY);
|
||||||
|
geo.translate(PAGE_W / 2 + PAGE_GAP, 0, 0);
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
|
|
||||||
_buildPages() {
|
_buildPages() {
|
||||||
const segX = 96, segY = 8;
|
const segX = 96, segY = 8;
|
||||||
|
|
||||||
ENTRIES.forEach((entry, i) => {
|
ENTRIES.forEach((entry, i) => {
|
||||||
const geo = new THREE.PlaneGeometry(PAGE_W, PAGE_H, segX, segY);
|
// 高分段几何:翻页/顶层页(弯曲需要足够分段)
|
||||||
// 页内缘离书脊留出小空隙,左右页不紧贴
|
const hiGeo = this._makePageGeometry(segX, segY);
|
||||||
geo.translate(PAGE_W / 2 + PAGE_GAP, 0, 0);
|
const hiBase = Float32Array.from(hiGeo.attributes.position.array);
|
||||||
// 纸张自然弧度:自由边略向正面翘起
|
// 极简几何:栈内页(只露边缘,无需弯曲,2 个三角形)
|
||||||
const pa = geo.attributes.position;
|
const loGeo = this._makePageGeometry(1, 1);
|
||||||
for (let k = 0; k < pa.count; k++) {
|
|
||||||
const x = pa.getX(k), y = pa.getY(k);
|
|
||||||
const u = Math.min(1, Math.max(0, (x - PAGE_GAP) / PAGE_W));
|
|
||||||
const yNorm = (y + PAGE_H / 2) / PAGE_H;
|
|
||||||
pa.setZ(k, pa.getZ(k) + 0.05 * Math.sin((Math.PI * u) / 2) * (0.8 + 0.2 * yNorm));
|
|
||||||
}
|
|
||||||
pa.needsUpdate = true;
|
|
||||||
const base = Float32Array.from(geo.attributes.position.array);
|
|
||||||
|
|
||||||
// 单 mesh + 双面材质:正面登记卡、背面素描(gl_FrontFacing 自动选择)
|
// 单 mesh + 双面材质:正面登记卡、背面素描(gl_FrontFacing 自动选择)
|
||||||
// 活页孔用 alphaTest 直接丢弃透明像素(不启用 transparent,避免透明+双面拆两次 draw call)
|
// 活页孔用 alphaTest 直接丢弃透明像素(不启用 transparent,避免透明+双面拆两次 draw call)
|
||||||
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, {
|
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, {
|
||||||
alphaTest: 0.5,
|
alphaTest: 0.5,
|
||||||
});
|
});
|
||||||
const mesh = new THREE.Mesh(geo, mat);
|
const mesh = new THREE.Mesh(loGeo, mat); // 初始为栈内页,用极简几何
|
||||||
mesh.castShadow = false; // 由 _restack 依据是否为顶层页控制
|
mesh.castShadow = false; // 由 _restack 依据是否为顶层页控制
|
||||||
|
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
@@ -403,8 +404,9 @@ export class Binder {
|
|||||||
|
|
||||||
this.pages.push({
|
this.pages.push({
|
||||||
group: g,
|
group: g,
|
||||||
geometry: geo,
|
hiGeo,
|
||||||
base,
|
loGeo,
|
||||||
|
hiBase,
|
||||||
material: mat,
|
material: mat,
|
||||||
mesh,
|
mesh,
|
||||||
isPlaceholder: true,
|
isPlaceholder: true,
|
||||||
@@ -426,8 +428,10 @@ export class Binder {
|
|||||||
const angle = j < this.current ? -Math.PI : 0;
|
const angle = j < this.current ? -Math.PI : 0;
|
||||||
p.angle = angle;
|
p.angle = angle;
|
||||||
p.group.rotation.y = angle;
|
p.group.rotation.y = angle;
|
||||||
|
const onTop = (j === this.current || j === this.current - 1);
|
||||||
const depth = j < this.current ? (this.current - 1 - j) : (j - this.current);
|
const depth = j < this.current ? (this.current - 1 - j) : (j - this.current);
|
||||||
p.targetZ = -depth * STACK_GAP;
|
// 顶层页抬高,与栈内页拉开深度,避免低精度深度缓冲下的穿模(z-fighting)
|
||||||
|
p.targetZ = onTop ? TOP_LIFT : -depth * STACK_GAP;
|
||||||
if (instant) {
|
if (instant) {
|
||||||
p.z = p.targetZ;
|
p.z = p.targetZ;
|
||||||
p.group.position.z = p.z;
|
p.group.position.z = p.z;
|
||||||
@@ -438,17 +442,21 @@ export class Binder {
|
|||||||
const dim = 1 - Math.min(depth * 0.06, 0.22);
|
const dim = 1 - Math.min(depth * 0.06, 0.22);
|
||||||
p.material.color.setScalar(dim);
|
p.material.color.setScalar(dim);
|
||||||
// 只让顶层(当前右页 + 左侧页)投影,栈内被遮挡页不投影,省阴影 pass
|
// 只让顶层(当前右页 + 左侧页)投影,栈内被遮挡页不投影,省阴影 pass
|
||||||
p.mesh.castShadow = (j === this.current || j === this.current - 1);
|
p.mesh.castShadow = onTop;
|
||||||
|
// 顶层页用高分段几何(翻页弯曲),栈内页用极简几何(省顶点)
|
||||||
|
if ((p.mesh.geometry === p.hiGeo) !== onTop) {
|
||||||
|
p.mesh.geometry = onTop ? p.hiGeo : p.loGeo;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this._updateCurvature();
|
this._updateCurvature();
|
||||||
this._ensureTextures();
|
this._ensureTextures();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要真实纹理的窗口:当前页 ±1(左右可见页 + 翻页缓冲)
|
// 需要真实纹理的窗口:当前页 ±2(左右可见页 + 翻页时会露出的下一层页 + 缓冲)
|
||||||
_pageWindow() {
|
_pageWindow() {
|
||||||
const len = this.pages.length;
|
const len = this.pages.length;
|
||||||
const win = new Set();
|
const win = new Set();
|
||||||
for (let d = -1; d <= 1; d++) {
|
for (let d = -2; d <= 2; d++) {
|
||||||
const i = this.current + d;
|
const i = this.current + d;
|
||||||
if (i >= 0 && i < len) win.add(i);
|
if (i >= 0 && i < len) win.add(i);
|
||||||
}
|
}
|
||||||
@@ -481,14 +489,15 @@ export class Binder {
|
|||||||
// 翻页时整页柔软弧弯:自由边最高、中段有“腹”、书脊侧贴环;顶边略强于底边;静止时完全平整
|
// 翻页时整页柔软弧弯:自由边最高、中段有“腹”、书脊侧贴环;顶边略强于底边;静止时完全平整
|
||||||
const pages = index === null ? this.pages : [this.pages[index]];
|
const pages = index === null ? this.pages : [this.pages[index]];
|
||||||
pages.forEach((p) => {
|
pages.forEach((p) => {
|
||||||
const pos = p.geometry.attributes.position;
|
// 弯曲只作用于高分段几何(顶层/翻页页);栈内极简几何不弯曲
|
||||||
|
const pos = p.hiGeo.attributes.position;
|
||||||
const arr = pos.array;
|
const arr = pos.array;
|
||||||
const theta = Math.abs(p.angle); // 0..π
|
const theta = Math.abs(p.angle); // 0..π
|
||||||
const c = Math.sin(theta); // 0 静止,1 翻页中途
|
const c = Math.sin(theta); // 0 静止,1 翻页中途
|
||||||
const A = 0.58 * c; // 抬升幅度(整页弧)
|
const A = 0.58 * c; // 抬升幅度(整页弧)
|
||||||
for (let i = 0; i < arr.length; i += 3) {
|
for (let i = 0; i < arr.length; i += 3) {
|
||||||
const x = p.base[i];
|
const x = p.hiBase[i];
|
||||||
const y = p.base[i + 1];
|
const y = p.hiBase[i + 1];
|
||||||
const s = Math.max(0, x - PAGE_GAP); // 距页内缘 0..W(防浮点误差产生负值→NaN)
|
const s = Math.max(0, x - PAGE_GAP); // 距页内缘 0..W(防浮点误差产生负值→NaN)
|
||||||
const u = Math.min(1, s / PAGE_W); // 0 内缘 .. 1 自由边
|
const u = Math.min(1, s / PAGE_W); // 0 内缘 .. 1 自由边
|
||||||
const yNorm = (y + PAGE_H / 2) / PAGE_H; // 0 底边 .. 1 顶边
|
const yNorm = (y + PAGE_H / 2) / PAGE_H; // 0 底边 .. 1 顶边
|
||||||
@@ -496,7 +505,7 @@ export class Binder {
|
|||||||
// 主抬升 + 中段“腹”的次级谐波,曲线更饱满、更柔软
|
// 主抬升 + 中段“腹”的次级谐波,曲线更饱满、更柔软
|
||||||
const belly = 0.85 * Math.sin((Math.PI * u) / 2) + 0.15 * Math.sin(Math.PI * u);
|
const belly = 0.85 * Math.sin((Math.PI * u) / 2) + 0.15 * Math.sin(Math.PI * u);
|
||||||
const z = A * twist * belly;
|
const z = A * twist * belly;
|
||||||
arr[i + 2] = p.base[i + 2] + z;
|
arr[i + 2] = p.hiBase[i + 2] + z;
|
||||||
}
|
}
|
||||||
pos.needsUpdate = true;
|
pos.needsUpdate = true;
|
||||||
});
|
});
|
||||||
@@ -512,6 +521,11 @@ export class Binder {
|
|||||||
canFlipForward() { return !this.flip; }
|
canFlipForward() { return !this.flip; }
|
||||||
canFlipBackward() { return !this.flip && this.current > 0; }
|
canFlipBackward() { return !this.flip && this.current > 0; }
|
||||||
|
|
||||||
|
// 页面 z 是否仍在向目标位置过渡(用于按需渲染:过渡期间需要持续重绘)
|
||||||
|
isSettling() {
|
||||||
|
return this.pages.some((p) => Math.abs(p.z - p.targetZ) > 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
_finalizeFlip() {
|
_finalizeFlip() {
|
||||||
// 立即把正在翻的页收到目标位置,好让下一页马上开始翻
|
// 立即把正在翻的页收到目标位置,好让下一页马上开始翻
|
||||||
if (!this.flip) return;
|
if (!this.flip) return;
|
||||||
@@ -519,8 +533,8 @@ export class Binder {
|
|||||||
const p = this.pages[f.index];
|
const p = this.pages[f.index];
|
||||||
p.angle = f.to;
|
p.angle = f.to;
|
||||||
p.group.rotation.y = f.to;
|
p.group.rotation.y = f.to;
|
||||||
p.z = 0;
|
p.z = TOP_LIFT;
|
||||||
p.group.position.z = 0;
|
p.group.position.z = TOP_LIFT;
|
||||||
if (f.to === -Math.PI) {
|
if (f.to === -Math.PI) {
|
||||||
this.current = (this.current + 1) % this.pages.length;
|
this.current = (this.current + 1) % this.pages.length;
|
||||||
} else {
|
} else {
|
||||||
@@ -534,6 +548,13 @@ export class Binder {
|
|||||||
if (this.flip) this._finalizeFlip(); // 前一页没翻完也能继续翻
|
if (this.flip) this._finalizeFlip(); // 前一页没翻完也能继续翻
|
||||||
if (!this.canFlipForward()) return;
|
if (!this.canFlipForward()) return;
|
||||||
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.38 };
|
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.38 };
|
||||||
|
// 提前把即将露出的下一层右页切到顶层状态(高分段几何/亮度/投影),避免翻页完成瞬间跳变
|
||||||
|
const reveal = this.pages[(this.current + 1) % this.pages.length];
|
||||||
|
if (reveal) {
|
||||||
|
if (reveal.mesh.geometry !== reveal.hiGeo) reveal.mesh.geometry = reveal.hiGeo;
|
||||||
|
reveal.mesh.castShadow = true;
|
||||||
|
reveal.material.color.setScalar(1.0);
|
||||||
|
}
|
||||||
// 优先更新底部导航与信息卡(翻页开始即切换目标页)
|
// 优先更新底部导航与信息卡(翻页开始即切换目标页)
|
||||||
if (this.onChange) this.onChange((this.current + 1) % this.pages.length);
|
if (this.onChange) this.onChange((this.current + 1) % this.pages.length);
|
||||||
}
|
}
|
||||||
@@ -542,6 +563,13 @@ export class Binder {
|
|||||||
if (this.flip) this._finalizeFlip();
|
if (this.flip) this._finalizeFlip();
|
||||||
if (!this.canFlipBackward()) return;
|
if (!this.canFlipBackward()) return;
|
||||||
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.38 };
|
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.38 };
|
||||||
|
// 提前把即将露出的下一层左页切到顶层状态,避免翻页完成瞬间跳变
|
||||||
|
const reveal = this.pages[this.current - 2];
|
||||||
|
if (reveal) {
|
||||||
|
if (reveal.mesh.geometry !== reveal.hiGeo) reveal.mesh.geometry = reveal.hiGeo;
|
||||||
|
reveal.mesh.castShadow = true;
|
||||||
|
reveal.material.color.setScalar(1.0);
|
||||||
|
}
|
||||||
// 优先更新底部导航与信息卡
|
// 优先更新底部导航与信息卡
|
||||||
if (this.onChange) this.onChange(this.current - 1);
|
if (this.onChange) this.onChange(this.current - 1);
|
||||||
}
|
}
|
||||||
@@ -557,8 +585,8 @@ export class Binder {
|
|||||||
const p = this.pages[f.index];
|
const p = this.pages[f.index];
|
||||||
p.angle = angle;
|
p.angle = angle;
|
||||||
p.group.rotation.y = angle;
|
p.group.rotation.y = angle;
|
||||||
// 翻页时 z 随进度同步起伏(中途 0.006,起止为 0),不瞬跳
|
// 翻页时 z 随进度同步起伏:起止停在顶层高度,中途抬得更高,不归零不瞬跳
|
||||||
p.z = 0.006 * Math.sin(Math.PI * k);
|
p.z = TOP_LIFT + (FLIP_LIFT - TOP_LIFT) * Math.sin(Math.PI * k);
|
||||||
p.group.position.z = p.z;
|
p.group.position.z = p.z;
|
||||||
this._updateCurvature(f.index);
|
this._updateCurvature(f.index);
|
||||||
|
|
||||||
@@ -583,7 +611,8 @@ export class Binder {
|
|||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.pages.forEach((p) => {
|
this.pages.forEach((p) => {
|
||||||
p.geometry.dispose();
|
p.hiGeo.dispose();
|
||||||
|
p.loGeo.dispose();
|
||||||
p.material.dispose();
|
p.material.dispose();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user