Replace Three.js globe with Cesium
Migrate the globe view to a Cesium viewer with Esri satellite imagery and terrain, dropping the custom blue-marble texture and dot-bead rendering. Add vite-plugin-cesium and a container element for the viewer. Also rework the binder leather and suede materials with dedicated normal maps, and refine timeline labels and page hole sizing.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { ENTRIES, CATEGORY_INFO } from '../data/entries.js';
|
||||
|
||||
// 免费、无需 Ion 令牌的影像与地形源
|
||||
const IMAGERY_URL =
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
|
||||
const TERRAIN_URL =
|
||||
'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer';
|
||||
|
||||
export class CesiumGlobe {
|
||||
constructor({ onPick } = {}) {
|
||||
this.onPick = onPick;
|
||||
this.viewer = null;
|
||||
this.markers = [];
|
||||
this.selected = -1;
|
||||
this._created = false;
|
||||
this._visible = false;
|
||||
}
|
||||
|
||||
ensureCreated() {
|
||||
if (this._created) return;
|
||||
this._created = true;
|
||||
|
||||
const container = document.getElementById('cesium-container');
|
||||
|
||||
this.viewer = new Cesium.Viewer(container, {
|
||||
animation: false,
|
||||
timeline: false,
|
||||
geocoder: false,
|
||||
homeButton: false,
|
||||
sceneModePicker: false,
|
||||
baseLayerPicker: false,
|
||||
navigationHelpButton: false,
|
||||
fullscreenButton: false,
|
||||
infoBox: false,
|
||||
selectionIndicator: false,
|
||||
baseLayer: false,
|
||||
requestRenderMode: true,
|
||||
});
|
||||
|
||||
const viewer = this.viewer;
|
||||
const scene = viewer.scene;
|
||||
|
||||
// 贴合暗色主题
|
||||
scene.backgroundColor = Cesium.Color.fromCssColorString('#14100d');
|
||||
scene.skyAtmosphere.show = false;
|
||||
scene.skyBox.show = false;
|
||||
scene.sun.show = false;
|
||||
scene.moon.show = false;
|
||||
scene.globe.enableLighting = false;
|
||||
scene.globe.showGroundAtmosphere = false;
|
||||
scene.globe.baseColor = Cesium.Color.fromCssColorString('#0a1626');
|
||||
|
||||
// 相机交互:限制滚轮缩放范围,避免过近/过远
|
||||
const controller = scene.screenSpaceCameraController;
|
||||
controller.enableCollisionDetection = true; // 必须开启:缩放距离限制在关闭时会被忽略
|
||||
controller.minimumZoomDistance = 10000; // 最近约 10 km(区域尺度)
|
||||
controller.maximumZoomDistance = 8e6; // 最远约 8000 km
|
||||
|
||||
// 精细化渲染:质量与性能的平衡
|
||||
scene.globe.maximumScreenSpaceError = 2.0; // 默认细分,最省性能
|
||||
scene.globe.tileCacheSize = 600; // 更大的瓦片缓存,减少细节刷新
|
||||
scene.fxaa = false; // 关闭抗锯齿后处理,省一个全屏通道
|
||||
viewer.resolutionScale = 1.5; // 1.5 倍超采样,替代 FXAA 提供更锐利的抗锯齿
|
||||
scene.maximumRenderTimeChange = Infinity; // 配合 requestRenderMode:静止时停止重绘,大幅省电省帧
|
||||
|
||||
// 卫星影像
|
||||
const imagery = new Cesium.UrlTemplateImageryProvider({
|
||||
url: IMAGERY_URL,
|
||||
credit: 'Imagery © Esri, Maxar, Earthstar Geographics',
|
||||
maximumLevel: 19,
|
||||
});
|
||||
viewer.imageryLayers.addImageryProvider(imagery);
|
||||
|
||||
// 真实地形(Esri World Elevation 3D,免费无 token;失败则保持默认椭球)
|
||||
const terrain = new Cesium.Terrain(
|
||||
Cesium.ArcGISTiledElevationTerrainProvider.fromUrl(TERRAIN_URL),
|
||||
);
|
||||
terrain.errorEvent.addEventListener((err) => {
|
||||
console.warn('[CesiumGlobe] 地形加载失败,使用椭球:', err);
|
||||
});
|
||||
scene.setTerrain(terrain);
|
||||
|
||||
viewer.clock.shouldAnimate = false;
|
||||
|
||||
this._buildMarkers();
|
||||
this._setupPick();
|
||||
|
||||
viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(35, 12, 7.2e6),
|
||||
});
|
||||
}
|
||||
|
||||
_buildMarkers() {
|
||||
const viewer = this.viewer;
|
||||
const gold = Cesium.Color.fromCssColorString('#e0c46a');
|
||||
const ivory = Cesium.Color.fromCssColorString('#e8d9b4');
|
||||
const ink = Cesium.Color.fromCssColorString('#14100d');
|
||||
|
||||
ENTRIES.forEach((entry, i) => {
|
||||
const info = CATEGORY_INFO[entry.category] || CATEGORY_INFO['事'];
|
||||
const color = Cesium.Color.fromCssColorString(info.color);
|
||||
|
||||
const entity = viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat),
|
||||
point: {
|
||||
pixelSize: 8,
|
||||
color,
|
||||
outlineColor: gold,
|
||||
outlineWidth: 1.5,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
},
|
||||
label: {
|
||||
text: entry.name,
|
||||
font: '17px Georgia, "Noto Serif SC", "Songti SC", "Microsoft YaHei", serif',
|
||||
fillColor: ivory,
|
||||
outlineColor: ink,
|
||||
outlineWidth: 3,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
show: false,
|
||||
},
|
||||
});
|
||||
|
||||
entity.entryIndex = i;
|
||||
this.markers.push({ entity, index: i });
|
||||
});
|
||||
}
|
||||
|
||||
_setupPick() {
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas);
|
||||
handler.setInputAction((movement) => {
|
||||
const picked = this.viewer.scene.pick(movement.position);
|
||||
if (Cesium.defined(picked) && picked.id && picked.id.entryIndex !== undefined) {
|
||||
this.onPick?.(picked.id.entryIndex);
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
this._handler = handler;
|
||||
}
|
||||
|
||||
setSelected(index) {
|
||||
this.selected = index;
|
||||
if (!this.viewer) return;
|
||||
this.markers.forEach(({ entity, index: i }) => {
|
||||
const on = i === index;
|
||||
entity.point.pixelSize = on ? 12 : 8;
|
||||
entity.label.show = on;
|
||||
});
|
||||
this.viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
setVisible(visible) {
|
||||
this._visible = visible;
|
||||
if (visible && this.viewer) this.viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (this.viewer) this.viewer.resize();
|
||||
}
|
||||
}
|
||||
+307
-96
@@ -13,85 +13,254 @@ function makeLeatherTexture({ emblem = false } = {}) {
|
||||
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');
|
||||
g.addColorStop(0, '#6e4527');
|
||||
g.addColorStop(0.5, '#5f3a20');
|
||||
g.addColorStop(1, '#4f2f19');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
// 皮革颗粒
|
||||
for (let i = 0; i < 32000; i++) {
|
||||
|
||||
// 极细腻的天然纹理(小牛皮几乎无颗粒,只有细微毛孔)
|
||||
for (let i = 0; i < 16000; 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);
|
||||
const s = Math.random() * 1.2 + 0.3;
|
||||
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(28,14,6,0.06)' : 'rgba(205,165,125,0.05)';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, y, s, s * 0.7, Math.random() * Math.PI, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
// 大块斑驳
|
||||
for (let i = 0; i < 70; i++) {
|
||||
const x = Math.random() * w, y = Math.random() * h, r = Math.random() * 90 + 20;
|
||||
|
||||
// 极淡的天然色差
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const x = Math.random() * w, y = Math.random() * h, r = Math.random() * 120 + 40;
|
||||
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(0, 'rgba(20,8,3,0.06)');
|
||||
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) {
|
||||
// 内侧绒面(粗糙、哑光、模糊绒毛)
|
||||
function makeSuedeTexture() {
|
||||
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, '#a8825c');
|
||||
g.addColorStop(0.5, '#96724e');
|
||||
g.addColorStop(1, '#866244');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
for (let i = 0; i < 90000; i++) {
|
||||
const x = Math.random() * w, y = Math.random() * h;
|
||||
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(40,22,10,0.10)' : 'rgba(230,200,160,0.08)';
|
||||
ctx.fillRect(x, y, Math.random() * 1.6 + 0.3, Math.random() * 1.6 + 0.3);
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(30,16,8,0.10)';
|
||||
ctx.lineWidth = 0.8;
|
||||
for (let i = 0; i < 16000; i++) {
|
||||
const x = Math.random() * w, y = Math.random() * h, a = Math.random() * Math.PI, l = Math.random() * 5 + 1;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(a) * l, y + Math.sin(a) * l); ctx.stroke();
|
||||
}
|
||||
const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.4, w / 2, h / 2, Math.max(w, h) * 0.72);
|
||||
edge.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
edge.addColorStop(1, 'rgba(50,28,12,0.18)');
|
||||
ctx.fillStyle = edge;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.anisotropy = 8;
|
||||
return tex;
|
||||
}
|
||||
|
||||
// 圆角矩形轮廓(直线段 + 圆弧段都用 lineTo 细分,正反面/皮边共享同一轮廓)
|
||||
function roundedRectShape(w, h, r, edgeSeg = 40, arcSeg = 12) {
|
||||
const hw = w / 2, hh = h / 2;
|
||||
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);
|
||||
s.moveTo(-hw + r, hh);
|
||||
// 上边
|
||||
for (let i = 1; i <= edgeSeg; i++) s.lineTo(-hw + r + (w - 2 * r) * i / edgeSeg, hh);
|
||||
// 右上角:90° → 0°
|
||||
for (let i = 1; i <= arcSeg; i++) { const a = Math.PI / 2 - (Math.PI / 2) * i / arcSeg; s.lineTo(hw - r + r * Math.cos(a), hh - r + r * Math.sin(a)); }
|
||||
// 右边
|
||||
for (let i = 1; i <= edgeSeg; i++) s.lineTo(hw, hh - r - (h - 2 * r) * i / edgeSeg);
|
||||
// 右下角:0° → -90°
|
||||
for (let i = 1; i <= arcSeg; i++) { const a = 0 - (Math.PI / 2) * i / arcSeg; s.lineTo(hw - r + r * Math.cos(a), -hh + r + r * Math.sin(a)); }
|
||||
// 下边
|
||||
for (let i = 1; i <= edgeSeg; i++) s.lineTo(hw - r - (w - 2 * r) * i / edgeSeg, -hh);
|
||||
// 左下角:-90° → -180°
|
||||
for (let i = 1; i <= arcSeg; i++) { const a = -Math.PI / 2 - (Math.PI / 2) * i / arcSeg; s.lineTo(-hw + r + r * Math.cos(a), -hh + r + r * Math.sin(a)); }
|
||||
// 左边
|
||||
for (let i = 1; i <= edgeSeg; i++) s.lineTo(-hw, -hh + r + (h - 2 * r) * i / edgeSeg);
|
||||
// 左上角:180° → 90°
|
||||
for (let i = 1; i <= arcSeg; i++) { const a = Math.PI - (Math.PI / 2) * i / arcSeg; s.lineTo(-hw + r + r * Math.cos(a), hh - r + r * Math.sin(a)); }
|
||||
s.closePath();
|
||||
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);
|
||||
// 随机牛皮弯曲:低频平滑起伏(更柔和、更自然),正反面/皮边共用同一函数保证一致
|
||||
function leatherWarp(x, y) {
|
||||
const s = Math.sin(x * 1.3 + 0.7) * Math.cos(y * 1.1 + 0.9)
|
||||
+ Math.sin(x * 0.8 + y * 1.5 + 0.4) * 0.6
|
||||
+ Math.sin(x * 2.1 + 1.6) * Math.sin(y * 1.7 + 0.5) * 0.3;
|
||||
return s * 0.002;
|
||||
}
|
||||
|
||||
// 软皮曲面:ShapeGeometry 三角化(圆角正确、无缺口)+ 书脊处弧度弯曲
|
||||
function makeLeatherSurface(w, h, crXY, curve, flip) {
|
||||
const shape = roundedRectShape(w, h, crXY);
|
||||
const geo = new THREE.ShapeGeometry(shape);
|
||||
const pos = geo.attributes.position;
|
||||
// ShapeGeometry 的 UV 是原始坐标,需归一化到 [0,1]
|
||||
const uv0 = geo.attributes.uv;
|
||||
for (let i = 0; i < uv0.count; i++) {
|
||||
uv0.setXY(i, (uv0.getX(i) + w / 2) / w, (uv0.getY(i) + h / 2) / h);
|
||||
}
|
||||
uv0.needsUpdate = true;
|
||||
const hh = h / 2, sigma = 0.5;
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const x = pos.getX(i), y = pos.getY(i);
|
||||
const v = y / hh;
|
||||
const z = -curve * Math.exp(-(x * x) / (2 * sigma * sigma)) * (1 - 0.25 * v * v) + leatherWarp(x, y);
|
||||
pos.setZ(i, z);
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
if (flip) {
|
||||
// 背面:反转索引绕向(法线朝 -z),并镜像 UV
|
||||
const idx = geo.index;
|
||||
for (let i = 0; i < idx.count; i += 3) {
|
||||
const t = idx.getX(i + 1);
|
||||
idx.setX(i + 1, idx.getX(i + 2));
|
||||
idx.setX(i + 2, t);
|
||||
}
|
||||
idx.needsUpdate = true;
|
||||
const uv = geo.attributes.uv;
|
||||
for (let i = 0; i < uv.count; i++) uv.setX(i, 1 - uv.getX(i));
|
||||
uv.needsUpdate = true;
|
||||
}
|
||||
geo.computeVertexNormals();
|
||||
return geo;
|
||||
}
|
||||
|
||||
// 皮边:与曲面共享同一圆角矩形轮廓的窄侧壁(封闭厚度,纯色)
|
||||
function makeSideRim(w, h, crXY, thick, curve) {
|
||||
const shape = roundedRectShape(w, h, crXY);
|
||||
const pts = shape.getPoints();
|
||||
const hh = h / 2, sigma = 0.5;
|
||||
const pos = [], idx = [];
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const px = pts[i].x, py = pts[i].y;
|
||||
const v = py / hh;
|
||||
const z = -curve * Math.exp(-(px * px) / (2 * sigma * sigma)) * (1 - 0.25 * v * v) + leatherWarp(px, py);
|
||||
pos.push(px, py, z - thick / 2, px, py, z + thick / 2);
|
||||
if (i > 0) {
|
||||
const a = (i - 1) * 2, b = a + 1, c = i * 2, d = i * 2 + 1;
|
||||
idx.push(a, c, b, b, c, d);
|
||||
}
|
||||
}
|
||||
const n = pts.length;
|
||||
idx.push((n - 1) * 2, 0, (n - 1) * 2 + 1, (n - 1) * 2 + 1, 0, 1);
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
geo.setIndex(idx);
|
||||
geo.computeVertexNormals();
|
||||
return geo;
|
||||
}
|
||||
|
||||
// 皮革法线贴图:由卵石颗粒 + 皱纹 + 缝线凹槽的高度图生成
|
||||
function makeLeatherNormalMap() {
|
||||
const w = 512, h = 640;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const ctx = c.getContext('2d', { willReadFrequently: true });
|
||||
// 平面基准
|
||||
ctx.fillStyle = 'rgb(128,128,128)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
// 极细颗粒(光滑小牛皮:几乎无凹凸)
|
||||
for (let i = 0; i < 30000; i++) {
|
||||
const x = Math.random() * w, y = Math.random() * h, r = Math.random() * 1.5 + 0.4;
|
||||
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.2)');
|
||||
g.addColorStop(0.65, 'rgba(128,128,128,0.03)');
|
||||
g.addColorStop(1, 'rgba(0,0,0,0.16)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
// 高度图 → 法线贴图
|
||||
const img = ctx.getImageData(0, 0, w, h);
|
||||
const out = ctx.createImageData(w, h);
|
||||
const S = 1.6;
|
||||
const hAt = (yy, xx) => img.data[(Math.min(h - 1, Math.max(0, yy)) * w + Math.min(w - 1, Math.max(0, xx))) * 4];
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const l = hAt(y, x - 1), r = hAt(y, x + 1), u = hAt(y - 1, x), d = hAt(y + 1, x);
|
||||
const nx = (l - r) * S;
|
||||
const ny = (u - d) * S;
|
||||
const nz = 255;
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||||
const o = (y * w + x) * 4;
|
||||
out.data[o] = (nx / len * 0.5 + 0.5) * 255;
|
||||
out.data[o + 1] = (ny / len * 0.5 + 0.5) * 255;
|
||||
out.data[o + 2] = (nz / len * 0.5 + 0.5) * 255;
|
||||
out.data[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(out, 0, 0);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.NoColorSpace;
|
||||
tex.anisotropy = 8;
|
||||
return tex;
|
||||
}
|
||||
|
||||
// 内侧绒面法线:密集细绒毛(高频噪点)
|
||||
function makeSuedeNormalMap() {
|
||||
const w = 512, h = 640;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const ctx = c.getContext('2d', { willReadFrequently: true });
|
||||
ctx.fillStyle = 'rgb(128,128,128)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
// 密集绒毛噪点
|
||||
const img = ctx.createImageData(w, h);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const v = 128 + (Math.random() - 0.5) * 60;
|
||||
img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
|
||||
img.data[i + 3] = 255;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
// 高度 → 法线
|
||||
const src = ctx.getImageData(0, 0, w, h);
|
||||
const out = ctx.createImageData(w, h);
|
||||
const S = 4.5;
|
||||
const hAt = (yy, xx) => src.data[(Math.min(h - 1, Math.max(0, yy)) * w + Math.min(w - 1, Math.max(0, xx))) * 4];
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const l = hAt(y, x - 1), r = hAt(y, x + 1), u = hAt(y - 1, x), d = hAt(y + 1, x);
|
||||
const nx = (l - r) * S, ny = (u - d) * S, nz = 255;
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||||
const o = (y * w + x) * 4;
|
||||
out.data[o] = (nx / len * 0.5 + 0.5) * 255;
|
||||
out.data[o + 1] = (ny / len * 0.5 + 0.5) * 255;
|
||||
out.data[o + 2] = (nz / len * 0.5 + 0.5) * 255;
|
||||
out.data[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(out, 0, 0);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.NoColorSpace;
|
||||
tex.anisotropy = 8;
|
||||
return tex;
|
||||
}
|
||||
|
||||
export class Binder {
|
||||
constructor() {
|
||||
this.group = new THREE.Group();
|
||||
@@ -101,8 +270,11 @@ export class Binder {
|
||||
this.onChange = null;
|
||||
|
||||
this.coverTex = makeLeatherTexture({ emblem: true });
|
||||
this.spineTex = makeLeatherTexture({ emblem: false });
|
||||
this.leatherNormalTex = makeLeatherNormalMap();
|
||||
this.suedeTex = makeSuedeTexture();
|
||||
this.suedeNormalTex = makeSuedeNormalMap();
|
||||
this._buildCovers();
|
||||
this._buildHingePlate();
|
||||
this._buildRings();
|
||||
this._buildPages();
|
||||
|
||||
@@ -111,46 +283,75 @@ export class Binder {
|
||||
}
|
||||
|
||||
_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 coverW = PAGE_W + 0.2;
|
||||
const coverH = PAGE_H + 0.2;
|
||||
const spineW = 0.12;
|
||||
const thick = 0.017; // 牛皮厚度(减 1/3)
|
||||
|
||||
// 外侧:光滑深棕小牛皮(纯色,微光泽)
|
||||
const outerMat = new THREE.MeshStandardMaterial({
|
||||
map: this.coverTex,
|
||||
normalMap: this.leatherNormalTex,
|
||||
normalScale: new THREE.Vector2(0.3, 0.3),
|
||||
roughness: 0.25,
|
||||
metalness: 0.0,
|
||||
});
|
||||
const spineMat = new THREE.MeshStandardMaterial({
|
||||
map: this.spineTex, roughness: 0.6, metalness: 0.08,
|
||||
// 内侧:粗糙绒面(哑光)
|
||||
const innerMat = new THREE.MeshStandardMaterial({
|
||||
map: this.suedeTex,
|
||||
normalMap: this.suedeNormalTex,
|
||||
normalScale: new THREE.Vector2(1.1, 1.1),
|
||||
roughness: 0.92,
|
||||
metalness: 0.0,
|
||||
});
|
||||
|
||||
// 圆角倒角封面
|
||||
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;
|
||||
// 一张牛皮:正反两张弯曲曲面 + 封闭皮边(UV 正确无拉伸)
|
||||
const totalW = coverW * 2 + spineW;
|
||||
const frontGeo = makeLeatherSurface(totalW, coverH, 0.06, 0.07, false);
|
||||
const backGeo = makeLeatherSurface(totalW, coverH, 0.06, 0.07, true);
|
||||
const rimGeo = makeSideRim(totalW, coverH, 0.06, thick, 0.07);
|
||||
const rimMat = new THREE.MeshStandardMaterial({ color: 0x6b4524, roughness: 0.7, metalness: 0.0, side: THREE.DoubleSide });
|
||||
|
||||
// 圆角书脊
|
||||
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 front = new THREE.Mesh(frontGeo, innerMat); // +z 朝书页(绒面)
|
||||
front.position.z = thick / 2;
|
||||
const back = new THREE.Mesh(backGeo, outerMat); // -z 朝外(光滑)
|
||||
back.position.z = -thick / 2;
|
||||
const rim = new THREE.Mesh(rimGeo, rimMat);
|
||||
|
||||
// 书脊上下金属箍
|
||||
const bandMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xc9a227, metalness: 0.85, roughness: 0.32,
|
||||
this.cover = new THREE.Group();
|
||||
this.cover.add(front, back, rim);
|
||||
this.cover.position.set(0, 0, -0.095);
|
||||
front.receiveShadow = back.receiveShadow = rim.receiveShadow = true;
|
||||
front.castShadow = back.castShadow = rim.castShadow = true;
|
||||
|
||||
this.group.add(this.cover);
|
||||
}
|
||||
|
||||
_buildHingePlate() {
|
||||
// 金属连接片:薄薄地贴合在牛皮书脊内面上,用于连接铁环
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0xd8d2c6, metalness: 0.95, roughness: 0.25,
|
||||
});
|
||||
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);
|
||||
// 薄片(有可见厚度的金属条),贴在牛皮内面(书脊处曲面 z≈-0.122)上
|
||||
const plate = new THREE.Mesh(new THREE.BoxGeometry(0.09, 1.85, 0.04), mat);
|
||||
plate.position.set(0, 0.07, -0.135);
|
||||
plate.castShadow = true;
|
||||
plate.receiveShadow = true;
|
||||
|
||||
// 铆钉:把薄片钉在牛皮上
|
||||
const rivetGeo = new THREE.SphereGeometry(0.013, 12, 10);
|
||||
const rivets = [];
|
||||
for (const ry of [-0.72, -0.36, 0, 0.36, 0.72]) {
|
||||
const rivet = new THREE.Mesh(rivetGeo, mat);
|
||||
rivet.position.set(0, ry, -0.114);
|
||||
rivet.scale.z = 0.5;
|
||||
rivet.castShadow = true;
|
||||
rivets.push(rivet);
|
||||
}
|
||||
|
||||
this.group.add(this.coverL, this.coverR, this.spine);
|
||||
this.hingePlate = new THREE.Group();
|
||||
this.hingePlate.add(plate, ...rivets);
|
||||
this.group.add(this.hingePlate);
|
||||
}
|
||||
|
||||
_buildRings() {
|
||||
@@ -172,12 +373,21 @@ export class Binder {
|
||||
}
|
||||
|
||||
_buildPages() {
|
||||
const segX = 72, segY = 6;
|
||||
const segX = 96, segY = 8;
|
||||
|
||||
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 pa = geo.attributes.position;
|
||||
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);
|
||||
const { front, back } = getPageTextures(entry);
|
||||
|
||||
@@ -236,13 +446,13 @@ export class Binder {
|
||||
}
|
||||
|
||||
_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 A = 0.42 * c; // 抬升幅度(整页弧)
|
||||
const A = 0.58 * c; // 抬升幅度(整页弧)
|
||||
for (let i = 0; i < arr.length; i += 3) {
|
||||
const x = p.base[i];
|
||||
const y = p.base[i + 1];
|
||||
@@ -250,7 +460,9 @@ export class Binder {
|
||||
const u = Math.min(1, s / PAGE_W); // 0 内缘 .. 1 自由边
|
||||
const yNorm = (y + PAGE_H / 2) / PAGE_H; // 0 底边 .. 1 顶边
|
||||
const twist = 0.7 + 0.3 * yNorm; // 顶边抬升略强
|
||||
const z = A * twist * Math.sin((Math.PI * u) / 2);
|
||||
// 主抬升 + 中段“腹”的次级谐波,曲线更饱满、更柔软
|
||||
const belly = 0.85 * Math.sin((Math.PI * u) / 2) + 0.15 * Math.sin(Math.PI * u);
|
||||
const z = A * twist * belly;
|
||||
arr[i + 2] = p.base[i + 2] + z;
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
@@ -269,13 +481,13 @@ export class Binder {
|
||||
|
||||
flipForward() {
|
||||
if (!this.canFlipForward()) return;
|
||||
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 1.15 };
|
||||
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 1.3 };
|
||||
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.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 1.3 };
|
||||
this.pages[this.current - 1].group.position.z = 0.006;
|
||||
}
|
||||
|
||||
@@ -290,7 +502,6 @@ export class Binder {
|
||||
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) {
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
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) / Math.max(1, b) > 0.25);
|
||||
};
|
||||
|
||||
// 斐波那契球:单位球面上均匀分布的点
|
||||
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.0032, color: 0xe8d9b4, opacity: 0.95, emissiveIntensity: 0.6,
|
||||
});
|
||||
this._buildDotBeads(oceanPts, {
|
||||
radius: 0.0022, color: 0x53658a, opacity: 0.65, emissiveIntensity: 0.45,
|
||||
});
|
||||
}
|
||||
|
||||
_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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -72,14 +72,12 @@ export class Timeline {
|
||||
outline.visible = false;
|
||||
card.add(outline);
|
||||
|
||||
// 活页上方:主题(常显,随选中上浮)
|
||||
const name = makeTextSprite(entry.name, { fontSize: 30, color: '#e8d9b4' });
|
||||
name.scale.multiplyScalar(0.22);
|
||||
// 活页上方:主题(常显,随选中上浮,超宽居中换行)
|
||||
const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.13, maxWidth: 1.0 });
|
||||
name.position.set(x, NAME_Y, 0);
|
||||
|
||||
// 轴下方:时间(常显,固定不动)
|
||||
const time = makeTextSprite(entry.time, { fontSize: 22, color: '#c9a227' });
|
||||
time.scale.multiplyScalar(0.18);
|
||||
const time = makeTextSprite(entry.time, { fontSize: 13, color: '#c9a227', scale: 0.11 });
|
||||
time.position.set(x, -1.06, 0);
|
||||
|
||||
this.group.add(card, name, time);
|
||||
|
||||
Reference in New Issue
Block a user