feat: build responsive Three.js river journey
This commit is contained in:
199
src/components/scenes/AboutRiverScene.tsx
Normal file
199
src/components/scenes/AboutRiverScene.tsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user