Add 3D loose-leaf chronicle of human civilisation

A Three.js site presenting 20 world history milestones as
double-sided loose-leaf pages, with three views: a leather
binder with page-flip animation, a golden timeline, and a
dotted globe with great-circle arcs.

All textures (paper, leather, registration cards, and 18
hand-drawn sketches) are generated procedurally via Canvas
2D, so no external image assets are needed beyond the Earth
map. Includes dev helper scripts for screenshots, ASCII
previews, and headless verification.
This commit is contained in:
2026-09-18 19:19:53 +08:00
commit 4e818d53e3
24 changed files with 4374 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
import * as THREE from 'three';
import { ENTRIES } from '../data/entries.js';
import { PAGE_W, PAGE_H, HOLE_U, getPageTextures } from '../textures/pages.js';
import { clamp, easeInOutQuint } from '../utils/easing.js';
const RING_R = 0.085;
const RING_TUBE = 0.0115;
const STACK_GAP = 0.004;
const PAGE_GAP = 0.02; // 页内缘离书脊的小空隙
function makeLeatherTexture({ emblem = false } = {}) {
const w = 512, h = 640;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
// 皮革底色(斜向渐变)
const g = ctx.createLinearGradient(0, 0, w, h);
g.addColorStop(0, '#5c3f2c');
g.addColorStop(0.5, '#4a3223');
g.addColorStop(1, '#392516');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
// 皮革颗粒
for (let i = 0; i < 32000; i++) {
const x = Math.random() * w, y = Math.random() * h;
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(28,16,8,0.16)' : 'rgba(178,128,74,0.10)';
ctx.fillRect(x, y, Math.random() * 3 + 0.5, Math.random() * 3 + 0.5);
}
// 大块斑驳
for (let i = 0; i < 70; i++) {
const x = Math.random() * w, y = Math.random() * h, r = Math.random() * 90 + 20;
const rg = ctx.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, Math.random() > 0.5 ? 'rgba(0,0,0,0.09)' : 'rgba(205,145,85,0.07)');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(0, 0, w, h);
}
// 金线压花边框 + 角饰
ctx.strokeStyle = 'rgba(201,162,39,0.5)';
ctx.lineWidth = 3;
ctx.strokeRect(20, 20, w - 40, h - 40);
ctx.strokeStyle = 'rgba(201,162,39,0.26)';
ctx.lineWidth = 1.2;
ctx.strokeRect(28, 28, w - 56, h - 56);
ctx.fillStyle = 'rgba(201,162,39,0.72)';
for (const [cx, cy] of [[20, 20], [w - 20, 20], [20, h - 20], [w - 20, h - 20]]) {
ctx.save(); ctx.translate(cx, cy); ctx.rotate(Math.PI / 4); ctx.fillRect(-4, -4, 8, 8); ctx.restore();
}
if (emblem) {
// 中央烫金徽记
ctx.strokeStyle = 'rgba(201,162,39,0.55)';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(w / 2, h / 2, 84, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(w / 2, h / 2, 70, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = 'rgba(224,196,106,0.92)';
ctx.font = '700 72px "Kaiti SC","STKaiti","KaiTi",serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('史', w / 2, h / 2 + 6);
}
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8;
return tex;
}
// 圆角盒体(ExtrudeGeometry 拉伸 + 倒角)
function roundedRectShape(w, h, r) {
const s = new THREE.Shape();
const x = -w / 2, y = -h / 2;
s.moveTo(x + r, y);
s.lineTo(x + w - r, y);
s.quadraticCurveTo(x + w, y, x + w, y + r);
s.lineTo(x + w, y + h - r);
s.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
s.lineTo(x + r, y + h);
s.quadraticCurveTo(x, y + h, x, y + h - r);
s.lineTo(x, y + r);
s.quadraticCurveTo(x, y, x + r, y);
return s;
}
function makeRoundedBox(w, h, d, r, bevel = 0.012) {
const geo = new THREE.ExtrudeGeometry(roundedRectShape(w, h, r), {
depth: d,
bevelEnabled: true,
bevelThickness: bevel,
bevelSize: bevel * 0.85,
bevelSegments: 3,
curveSegments: 10,
});
geo.translate(0, 0, -d / 2);
return geo;
}
export class Binder {
constructor() {
this.group = new THREE.Group();
this.pages = [];
this.current = 0;
this.flip = null; // { index, from, to, t, duration }
this.onChange = null;
this.coverTex = makeLeatherTexture({ emblem: true });
this.spineTex = makeLeatherTexture({ emblem: false });
this._buildCovers();
this._buildRings();
this._buildPages();
// 微微后仰,像阅读架
this.group.rotation.x = -0.09;
}
_buildCovers() {
const coverW = PAGE_W + 0.12;
const coverH = PAGE_H + 0.18;
const thick = 0.05;
const coverMat = new THREE.MeshStandardMaterial({
map: this.coverTex, roughness: 0.58, metalness: 0.1,
});
const spineMat = new THREE.MeshStandardMaterial({
map: this.spineTex, roughness: 0.6, metalness: 0.08,
});
// 圆角倒角封面
const coverGeo = makeRoundedBox(coverW, coverH, thick, 0.07);
this.coverL = new THREE.Mesh(coverGeo, coverMat);
this.coverL.position.set(-(0.05 + coverW / 2), 0, -0.115);
this.coverL.receiveShadow = true;
this.coverL.castShadow = true;
this.coverR = new THREE.Mesh(coverGeo, coverMat);
this.coverR.position.set(0.05 + coverW / 2, 0, -0.115);
this.coverR.receiveShadow = true;
this.coverR.castShadow = true;
// 圆角书脊
const spineGeo = makeRoundedBox(0.1, coverH, 0.16, 0.03, 0.008);
this.spine = new THREE.Mesh(spineGeo, spineMat);
this.spine.position.set(0, 0, -0.11);
this.spine.receiveShadow = true;
// 书脊上下金属箍
const bandMat = new THREE.MeshStandardMaterial({
color: 0xc9a227, metalness: 0.85, roughness: 0.32,
});
const bandGeo = makeRoundedBox(0.115, 0.07, 0.18, 0.02, 0.004);
for (const by of [-0.72, 0.72]) {
const band = new THREE.Mesh(bandGeo, bandMat);
band.position.set(0, by, -0.11);
band.castShadow = true;
this.group.add(band);
}
this.group.add(this.coverL, this.coverR, this.spine);
}
_buildRings() {
const mat = new THREE.MeshStandardMaterial({
color: 0xd8d2c6, metalness: 0.95, roughness: 0.22,
});
// 更平滑的活页环
const geo = new THREE.TorusGeometry(RING_R, RING_TUBE, 18, 56);
this.rings = [];
for (const u of HOLE_U) {
const y = (0.5 - u) * PAGE_H;
const ring = new THREE.Mesh(geo, mat);
ring.rotation.x = Math.PI / 2; // 环面落在 XZ 平面(绕书脊)
ring.position.set(0, y, 0);
ring.castShadow = true;
this.rings.push(ring);
this.group.add(ring);
}
}
_buildPages() {
const segX = 72, segY = 6;
ENTRIES.forEach((entry, i) => {
const geo = new THREE.PlaneGeometry(PAGE_W, PAGE_H, segX, segY);
// 页内缘离书脊留出小空隙,左右页不紧贴
geo.translate(PAGE_W / 2 + PAGE_GAP, 0, 0);
const base = Float32Array.from(geo.attributes.position.array);
const { front, back } = getPageTextures(entry);
const frontMat = new THREE.MeshStandardMaterial({
map: front, roughness: 0.88, metalness: 0.0, side: THREE.FrontSide,
});
const backMat = new THREE.MeshStandardMaterial({
map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide,
});
const frontMesh = new THREE.Mesh(geo, frontMat);
frontMesh.position.z = 0.0008;
frontMesh.castShadow = true;
const backMesh = new THREE.Mesh(geo, backMat);
backMesh.position.z = -0.0008;
backMesh.castShadow = false;
const g = new THREE.Group();
g.add(frontMesh, backMesh);
g.userData.index = i;
g.userData.entry = entry;
this.pages.push({
group: g,
geometry: geo,
base,
frontMat,
backMat,
angle: 0,
y: 0,
});
this.group.add(g);
});
this._restack();
}
// 各页休息位置:角度 + 世界 z(堆叠深度)
_restack() {
this.pages.forEach((p, j) => {
const angle = j < this.current ? -Math.PI : 0;
p.angle = angle;
p.group.rotation.y = angle;
const depth = j < this.current ? (this.current - 1 - j) : (j - this.current);
const z = -depth * STACK_GAP;
p.group.position.z = z;
p.group.position.y = 0;
p.group.position.x = 0;
// 下层页略暗
const dim = 1 - Math.min(depth * 0.06, 0.22);
p.frontMat.color.setScalar(dim);
p.backMat.color.setScalar(dim);
});
this._updateCurvature();
}
_updateCurvature() {
// 翻页时:靠近书脊的一段平滑卷曲,顶边卷曲略强于底边(斜向扭转),静止时完全平整
this.pages.forEach((p) => {
const pos = p.geometry.attributes.position;
const arr = pos.array;
const theta = Math.abs(p.angle); // 0..π
const c = Math.sin(theta); // 0 静止,1 翻页中途
const L = 0.16 * c; // 卷曲延伸长度
const h = 0.15 * c; // 卷曲抬升高度
for (let i = 0; i < arr.length; i += 3) {
const x = p.base[i];
const y = p.base[i + 1];
const s = Math.max(0, x - PAGE_GAP); // 距页内缘 0..W(防浮点误差产生负值→NaN)
let z = 0;
if (s < L) {
const t = s / L;
const yNorm = (y + PAGE_H / 2) / PAGE_H; // 0 底边 .. 1 顶边
const twist = 0.55 + 0.45 * yNorm;
z = h * twist * Math.sin((Math.PI * t) / 2);
}
arr[i + 2] = p.base[i + 2] + z;
}
pos.needsUpdate = true;
});
}
syncTo(index) {
this.current = clamp(index, 0, this.pages.length - 1);
this.flip = null;
this._restack();
if (this.onChange) this.onChange(this.current);
}
canFlipForward() { return !this.flip && this.current < this.pages.length - 1; }
canFlipBackward() { return !this.flip && this.current > 0; }
flipForward() {
if (!this.canFlipForward()) return;
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 1.15 };
this.pages[this.current].group.position.z = 0.006;
}
flipBackward() {
if (!this.canFlipBackward()) return;
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 1.15 };
this.pages[this.current - 1].group.position.z = 0.006;
}
update(dt) {
if (this.flip) {
const f = this.flip;
f.t += dt / f.duration;
const k = clamp(f.t, 0, 1);
const e = easeInOutQuint(k);
const angle = f.from + (f.to - f.from) * e;
const p = this.pages[f.index];
p.angle = angle;
p.group.rotation.y = angle;
p.group.position.y = 0.05 * Math.sin(Math.PI * k);
this._updateCurvature();
if (k >= 1) {
if (f.to === -Math.PI) this.current += 1; else this.current -= 1;
this.flip = null;
this._restack();
if (this.onChange) this.onChange(this.current);
}
}
}
dispose() {
this.pages.forEach((p) => {
p.geometry.dispose();
p.frontMat.map.dispose();
p.frontMat.dispose();
p.backMat.map.dispose();
p.backMat.dispose();
});
}
}
+202
View File
@@ -0,0 +1,202 @@
import * as THREE from 'three';
import { ENTRIES, CATEGORY_INFO } from '../data/entries.js';
import { makeTextSprite } from '../utils/labels.js';
import earthUrl from '../assets/earth-blue-marble.jpg';
const R = 1.6;
export function latLngToVec3(lat, lng, r = R) {
const phi = ((90 - lat) * Math.PI) / 180;
const theta = ((lng + 180) * Math.PI) / 180;
const x = -r * Math.sin(phi) * Math.cos(theta);
const z = r * Math.sin(phi) * Math.sin(theta);
const y = r * Math.cos(phi);
return new THREE.Vector3(x, y, z);
}
export class Globe {
constructor({ renderer } = {}) {
this.group = new THREE.Group();
this.markers = [];
this.selected = -1;
this.renderer = renderer;
this._buildSphere();
this._buildGraticule();
this._buildMarkers();
}
_buildSphere() {
const geo = new THREE.SphereGeometry(R, 64, 48);
const mat = new THREE.MeshStandardMaterial({
color: 0x0b111d, roughness: 0.82, metalness: 0.0,
});
const sphere = new THREE.Mesh(geo, mat);
sphere.receiveShadow = true;
this.group.add(sphere);
// 球体表面:真实地球地形(NASA 蓝弹珠卫星影像,等距圆柱投影)
const loader = new THREE.TextureLoader();
loader.load(earthUrl, (tex) => {
tex.colorSpace = THREE.SRGBColorSpace;
const maxAniso = this.renderer ? this.renderer.capabilities.getMaxAnisotropy() : 8;
tex.anisotropy = Math.min(8, maxAniso);
mat.map = tex;
mat.color.set(0xffffff);
mat.needsUpdate = true;
// 贴图就绪后,按真实地形采样生成点阵,保证珠子与地图精准对齐
this._buildDots(tex.image);
});
// 大气微光(背面发光)
const glowGeo = new THREE.SphereGeometry(R * 1.015, 64, 48);
const glowMat = new THREE.MeshBasicMaterial({
color: 0x2a3b5c, transparent: true, opacity: 0.16, side: THREE.BackSide,
});
this.group.add(new THREE.Mesh(glowGeo, glowMat));
}
_buildGraticule() {
const mat = new THREE.LineBasicMaterial({ color: 0x3d4f6a, transparent: true, opacity: 0.45 });
// 纬线
for (let lat = -60; lat <= 60; lat += 30) {
const pts = [];
for (let lng = 0; lng <= 360; lng += 4) {
pts.push(latLngToVec3(lat, lng - 180, R * 1.002));
}
const g = new THREE.BufferGeometry().setFromPoints(pts);
this.group.add(new THREE.Line(g, mat));
}
// 经线
for (let lng = 0; lng < 180; lng += 30) {
const pts = [];
for (let lat = -90; lat <= 90; lat += 4) {
pts.push(latLngToVec3(lat, lng, R * 1.002));
}
const g = new THREE.BufferGeometry().setFromPoints(pts);
this.group.add(new THREE.Line(g, mat));
}
}
_buildDots(image) {
// 用真实地形贴图采样:珠子经纬度直接从贴图像素反推,与地图精准对齐
const W = 2048, H = 1024;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(image, 0, 0, W, H);
const px = ctx.getImageData(0, 0, W, H).data;
// 蓝弹珠中海洋以蓝色为主导通道;非蓝色主导即为陆地/冰盖。
// 采样坐标系与 SphereGeometry + flipY 的 UV 完全一致,保证珠子与贴图精准对齐。
const isLand = (vx, vy, vz) => {
let phi = Math.atan2(vz, -vx); // 方位角
if (phi < 0) phi += Math.PI * 2; // 归一化到 [0, 2π)
const theta = Math.acos(Math.max(-1, Math.min(1, vy))); // 极角,0=北极
const x = Math.min(W - 1, Math.max(0, Math.round((phi / (Math.PI * 2)) * (W - 1))));
const y = Math.min(H - 1, Math.max(0, Math.round((theta / Math.PI) * (H - 1))));
const i = (y * W + x) * 4;
const r = px[i], g = px[i + 1], b = px[i + 2];
// 海洋:蓝色明显占优(蓝>红且蓝>绿,且差距足够大以排除冰雪的微蓝白色)
return !(b > r && b > g && (b - r) > 6 && (b - g) > 4);
};
// 斐波那契球:单位球面上均匀分布的点
const fib = (N) => {
const pts = [];
const phiG = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < N; i++) {
const y = 1 - (i / (N - 1)) * 2;
const rad = Math.sqrt(Math.max(0, 1 - y * y));
const th = phiG * i;
pts.push([Math.cos(th) * rad, y, Math.sin(th) * rad]);
}
return pts;
};
// 陆地珠子:只保留落在真实陆地上的点
const landPts = [];
for (const [vx, vy, vz] of fib(40000)) {
if (isLand(vx, vy, vz)) landPts.push(new THREE.Vector3(vx, vy, vz).multiplyScalar(R * 1.004));
}
// 海洋珠子:只保留落在真实海洋上的点(密度略低,作背景微光)
const oceanPts = [];
for (const [vx, vy, vz] of fib(9000)) {
if (!isLand(vx, vy, vz)) oceanPts.push(new THREE.Vector3(vx, vy, vz).multiplyScalar(R * 1.002));
}
this._buildDotBeads(landPts, {
radius: 0.0045, color: 0xe8d9b4, opacity: 0.95, emissiveIntensity: 0.5,
});
this._buildDotBeads(oceanPts, {
radius: 0.003, color: 0x33415f, opacity: 0.5, emissiveIntensity: 0.15,
});
}
_buildDotBeads(positions, { radius, color, opacity, emissiveIntensity }) {
const geo = new THREE.SphereGeometry(radius, 8, 6);
const mat = new THREE.MeshStandardMaterial({
color,
emissive: new THREE.Color(color),
emissiveIntensity,
roughness: 0.55,
metalness: 0.0,
transparent: true,
opacity,
});
const mesh = new THREE.InstancedMesh(geo, mat, positions.length);
const dummy = new THREE.Object3D();
positions.forEach((p, i) => {
dummy.position.copy(p);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
});
mesh.instanceMatrix.needsUpdate = true;
this.group.add(mesh);
}
_buildMarkers() {
const sphereGeo = new THREE.SphereGeometry(0.024, 20, 16);
const ringGeo = new THREE.TorusGeometry(0.04, 0.006, 10, 28);
ENTRIES.forEach((entry, i) => {
const info = CATEGORY_INFO[entry.category] || CATEGORY_INFO['事'];
const pos = latLngToVec3(entry.lat, entry.lng, R * 1.012);
const mat = new THREE.MeshStandardMaterial({
color: 0x111111, emissive: new THREE.Color(info.color),
emissiveIntensity: 0.9, roughness: 0.4, metalness: 0.3,
});
const marker = new THREE.Mesh(sphereGeo, mat);
marker.position.copy(pos);
marker.userData = { view: 'globe', index: i };
// 选中环(切于球面)
const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ color: 0xe0c46a, transparent: true, opacity: 0.9 }));
ring.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), pos.clone().normalize());
ring.position.copy(pos);
ring.visible = false;
// 名称标签
const label = makeTextSprite(entry.name, { fontSize: 40, color: '#e8d9b4' });
label.scale.multiplyScalar(0.42);
label.position.copy(pos.clone().multiplyScalar(1.22));
label.visible = false;
this.group.add(marker, ring, label);
this.markers.push({ marker, ring, label, index: i });
});
}
setSelected(index) {
this.selected = index;
this.markers.forEach((m) => {
const on = m.index === index;
m.ring.visible = on;
m.label.visible = on;
});
}
}
+95
View File
@@ -0,0 +1,95 @@
import * as THREE from 'three';
import { ENTRIES } from '../data/entries.js';
import { getPageTextures } from '../textures/pages.js';
import { makeTextSprite } from '../utils/labels.js';
export const TIMELINE_SPREAD = 6.2; // 横轴范围 ±6.2(超出视口,可横向滚动)
function cardX(i, n) {
return (i / (n - 1)) * 2 * TIMELINE_SPREAD - TIMELINE_SPREAD;
}
export class Timeline {
constructor() {
this.group = new THREE.Group();
this.cards = [];
this.selected = -1;
this._buildLine();
this._buildCards();
}
_buildLine() {
const n = ENTRIES.length;
const min = cardX(0, n) - 0.4;
const max = cardX(n - 1, n) + 0.4;
const lineMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.7 });
const lineGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(min, -0.85, 0), new THREE.Vector3(max, -0.85, 0),
]);
this.group.add(new THREE.Line(lineGeo, lineMat));
const glowMat = new THREE.LineBasicMaterial({ color: 0xe0c46a, transparent: true, opacity: 0.22 });
const glowGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(min, -0.855, -0.03), new THREE.Vector3(max, -0.855, -0.03),
]);
this.group.add(new THREE.Line(glowGeo, glowMat));
// 刻度:向上指向卡片、向下指向时间标签
const tickMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.5 });
ENTRIES.forEach((e, i) => {
const x = cardX(i, n);
const g = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(x, -0.98, 0), new THREE.Vector3(x, -0.7, 0),
]);
this.group.add(new THREE.Line(g, tickMat));
});
}
_buildCards() {
const n = ENTRIES.length;
const cardGeo = new THREE.PlaneGeometry(0.4, 0.565);
ENTRIES.forEach((entry, i) => {
const x = cardX(i, n);
const { front } = getPageTextures(entry);
const mat = new THREE.MeshStandardMaterial({ map: front, roughness: 0.85, metalness: 0.0 });
const card = new THREE.Mesh(cardGeo, mat);
card.position.set(x, 0, 0);
card.userData = { view: 'timeline', index: i };
card.castShadow = true;
const outline = new THREE.Mesh(
new THREE.PlaneGeometry(0.4 * 1.1, 0.565 * 1.1),
new THREE.MeshBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.9, side: THREE.BackSide })
);
outline.position.z = -0.01;
outline.visible = false;
card.add(outline);
// 活页上方:主题(常显)
const name = makeTextSprite(entry.name, { fontSize: 30, color: '#e8d9b4' });
name.scale.multiplyScalar(0.22);
name.position.set(x, 0.42, 0);
// 轴下方:时间(常显)
const time = makeTextSprite(entry.time, { fontSize: 22, color: '#c9a227' });
time.scale.multiplyScalar(0.18);
time.position.set(x, -1.06, 0);
this.group.add(card, name, time);
this.cards.push({ card, outline, index: i });
});
}
setSelected(index) {
this.selected = index;
this.cards.forEach((c) => {
const on = c.index === index;
c.outline.visible = on;
c.card.position.y = on ? 0.14 : 0;
});
}
}