feat: build responsive Three.js river journey

This commit is contained in:
2026-09-05 17:22:12 +08:00
parent 637da07c9e
commit f6228f8507
5 changed files with 420 additions and 215 deletions

View File

@@ -0,0 +1,199 @@
'use client';
import * as THREE from 'three';
import { useTheme } from '@/context/ThemeContext';
import { ThreeCanvas, type SceneFactory } from './ThreeCanvas';
import {
addSoftLights,
createRibbonShape,
createTree,
fitOrthographicCamera,
palettes,
} from './scenePrimitives';
import styles from './Scenes.module.css';
const riverPath = [
new THREE.Vector2(4.5, -13),
new THREE.Vector2(2.1, -7.4),
new THREE.Vector2(0.2, -3.8),
new THREE.Vector2(0.2, -1.2),
new THREE.Vector2(1.5, 0.2),
new THREE.Vector2(-0.8, 2.4),
new THREE.Vector2(-0.1, 4.1),
new THREE.Vector2(-2.5, 8.1),
new THREE.Vector2(-3.3, 13),
];
const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => {
const palette = palettes[theme];
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-14, 14, 11, -11, 0.1, 60);
const cameraBase = new THREE.Vector3(15, 17, 18);
camera.position.copy(cameraBase);
camera.lookAt(0, 0, 0);
addSoftLights(scene, theme, 19);
const world = new THREE.Group();
scene.add(world);
const edgeMaterial = new THREE.LineBasicMaterial({
color: palette.edge,
transparent: true,
opacity: theme === 'dark' ? 0.48 : 0.56,
});
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(34, 34),
new THREE.MeshStandardMaterial({ color: palette.ground, roughness: 1 }),
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.32;
ground.receiveShadow = true;
world.add(ground);
const gridVertices: number[] = [];
for (let value = -17; value <= 17; value += 1) {
gridVertices.push(-17, -0.3, value, 17, -0.3, value);
gridVertices.push(value, -0.3, -17, value, -0.3, 17);
}
const gridGeometry = new THREE.BufferGeometry();
gridGeometry.setAttribute('position', new THREE.Float32BufferAttribute(gridVertices, 3));
world.add(
new THREE.LineSegments(
gridGeometry,
new THREE.LineBasicMaterial({
color: palette.edge,
transparent: true,
opacity: theme === 'dark' ? 0.18 : 0.2,
}),
),
);
const addPlate = (
group: THREE.Group,
x: number,
z: number,
width: number,
depth: number,
height = 0.42,
color = palette.ground,
) => {
const geometry = new THREE.BoxGeometry(width, height, depth);
const plate = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({ color, roughness: 0.94 }),
);
plate.position.set(x, height / 2 - 0.25, z);
plate.castShadow = true;
plate.receiveShadow = true;
plate.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 25), edgeMaterial));
group.add(plate);
return plate;
};
const primaryPlates = new THREE.Group();
const secondaryPlates = new THREE.Group();
const tertiaryPlates = new THREE.Group();
world.add(primaryPlates, secondaryPlates, tertiaryPlates);
[
[-7.5, -7.7, 9.5, 5.1], [-6.2, -2.7, 8.5, 4.2], [-6.5, 2.2, 8.7, 4.3],
[-7.1, 7.2, 9.2, 5.0], [7.4, -8.0, 9.4, 5.4], [6.2, -2.9, 8.0, 4.1],
[6.6, 2.0, 8.4, 4.2], [6.3, 7.0, 9.1, 5.0],
].forEach(([x, z, width, depth]) => addPlate(primaryPlates, x, z, width, depth));
[
[-11.7, -2.1, 2.5, 2.3], [-10.6, 4.0, 3.0, 2.5], [-5.0, -5.4, 2.5, 2.0],
[-5.3, 5.0, 2.7, 2.1], [10.5, -3.4, 3.0, 2.6], [9.2, 4.0, 2.7, 2.1],
].forEach(([x, z, width, depth]) =>
addPlate(secondaryPlates, x, z, width, depth, 0.5, theme === 'dark' ? 0x0b2f5f : 0xe6f0fe),
);
const riverGeometry = new THREE.ShapeGeometry(createRibbonShape(riverPath, 2.2));
const waterMaterial = new THREE.MeshStandardMaterial({
color: palette.water,
emissive: palette.water,
emissiveIntensity: theme === 'dark' ? 0.28 : 0.1,
roughness: 0.52,
});
const river = new THREE.Mesh(riverGeometry, waterMaterial);
river.rotation.x = -Math.PI / 2;
river.position.y = -0.23;
world.add(river);
const riverEdge = new THREE.LineSegments(new THREE.EdgesGeometry(riverGeometry), edgeMaterial);
riverEdge.rotation.copy(river.rotation);
riverEdge.position.y = -0.21;
world.add(riverEdge);
riverPath.slice(0, -1).forEach((start, index) => {
const end = riverPath[index + 1];
const direction = end.clone().sub(start);
const length = direction.length();
direction.normalize();
for (let distance = 1; distance < length; distance += 1.15) {
const at = start.clone().addScaledVector(direction, distance);
const seam = new THREE.Mesh(
new THREE.PlaneGeometry(2.08, 0.014),
new THREE.MeshBasicMaterial({ color: palette.edge, transparent: true, opacity: 0.35 }),
);
seam.rotation.x = -Math.PI / 2;
seam.rotation.z = Math.atan2(direction.y, direction.x) + Math.PI / 2;
seam.position.set(at.x, -0.2, at.y);
tertiaryPlates.add(seam);
}
});
const addPad = (group: THREE.Group, x: number, z: number, scale = 1) => {
addPlate(group, x, z, 3.4 * scale, 2.7 * scale, 0.45, theme === 'dark' ? 0x103f7f : 0xe6f0fe);
addPlate(group, x, z, 2.75 * scale, 2.05 * scale, 0.7, palette.accentSoft);
};
addPad(primaryPlates, -6.2, -6.4, 0.95);
addPad(primaryPlates, 5.7, -5.8, 1.08);
addPad(primaryPlates, -5.6, 1.6, 1.04);
addPad(primaryPlates, 5.9, 4.8, 0.84);
addPad(secondaryPlates, 10.7, -6.3, 0.82);
const treeData = [
[-8.5, -9.5, 0.8, 1, 0], [-7.2, -9.0, 1, 0, 0], [-7.8, -0.5, 0.85, 1, 0],
[-6.8, 0.0, 1.1, 0, 0], [-8.4, 1.2, 0.8, 0, 1], [-7.0, 8.7, 0.95, 0, 1],
[7.0, -9.2, 1, 0, 0], [8.2, -8.6, 0.8, 0, 0], [9.2, -8.0, 0.85, 1, 1],
[8.3, 4.0, 1.05, 0, 0], [9.4, 4.8, 0.82, 0, 1], [3.5, 8.7, 0.9, 0, 0],
[4.8, 9.4, 0.82, 1, 1], [-4.8, 9.2, 0.92, 0, 1],
] as const;
const animatedTrees: THREE.Group[] = [];
treeData.forEach(([x, z, scale, pill, secondary]) => {
const tree = createTree(palette, scale, pill === 1);
tree.position.set(x, 0.05, z);
(secondary ? secondaryPlates : primaryPlates).add(tree);
animatedTrees.push(tree);
});
return {
scene,
camera,
resize: (width, height) => {
const mobile = width < 640;
const tablet = width < 1024;
secondaryPlates.visible = !mobile;
tertiaryPlates.visible = !tablet;
world.scale.setScalar(mobile ? 0.82 : tablet ? 0.92 : 1);
fitOrthographicCamera(camera, width, height, mobile ? 23 : tablet ? 22 : 21.5);
},
update: (elapsed) => {
waterMaterial.emissiveIntensity = (theme === 'dark' ? 0.28 : 0.1) + Math.sin(elapsed * 0.8) * 0.025;
animatedTrees.forEach((tree, index) => {
tree.rotation.z = Math.sin(elapsed * 0.6 + index) * 0.005;
});
if (!reduceMotion) {
camera.position.x += (cameraBase.x + pointer.x * 0.22 - camera.position.x) * 0.028;
camera.position.z += (cameraBase.z - pointer.x * 0.18 - camera.position.z) * 0.028;
camera.lookAt(0, 0, 0);
}
},
};
};
export function AboutRiverScene() {
const { theme } = useTheme();
return <ThreeCanvas className={styles.aboutScene} createScene={createAboutScene} theme={theme} />;
}

View File

@@ -32,8 +32,20 @@
z-index: 0;
}
.aboutScene::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, var(--bg-page), transparent 12%, transparent 88%, var(--bg-page));
}
@media (max-width: 640px) {
.heroScene::after {
opacity: 0.62;
}
.aboutScene {
bottom: auto;
height: 640px;
}
}

View File

@@ -58,6 +58,25 @@ export function fitOrthographicCamera(
camera.updateProjectionMatrix();
}
export function createRibbonShape(points: THREE.Vector2[], width: number) {
const halfWidth = width / 2;
const offsetPoint = (index: number, direction: 1 | -1) => {
const current = points[index].clone();
const previous = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
const tangent = next.clone().sub(previous).normalize();
return current.add(new THREE.Vector2(-tangent.y, tangent.x).multiplyScalar(halfWidth * direction));
};
const left = points.map((_, index) => offsetPoint(index, 1));
const right = points.map((_, index) => offsetPoint(index, -1)).reverse();
const shape = new THREE.Shape();
shape.moveTo(left[0].x, left[0].y);
[...left.slice(1), ...right].forEach((point) => shape.lineTo(point.x, point.y));
shape.closePath();
return shape;
}
export function createMesa(
width: number,
height: number,