Files
my-protfolio/src/components/scenes/AboutRiverScene.tsx
Amirhossein Khalili b3cfcfe057
Some checks failed
Test and deploy / Verify production build (push) Has been cancelled
Test and deploy / Deploy production (push) Has been cancelled
fix: stabilize portfolio scrolling and loading
Remove skeleton swaps and deferred layout estimates, restore reveal state, and use native proximity scrolling for project cards. Also make the Docker build npm registry configurable.
2026-09-09 17:53:48 +03:30

276 lines
9.3 KiB
TypeScript

'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';
// Straight isometric river running between the two platform groups.
const riverPath = [
new THREE.Vector2(0, -20),
new THREE.Vector2(0, 20)
];
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(60, 60),
new THREE.ShadowMaterial({
color: 0x000000,
opacity: theme === 'dark' ? 0.2 : 0.08,
transparent: true,
}),
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.32;
ground.receiveShadow = true;
world.add(ground);
const gridVertices: number[] = [];
for (let value = -30; value <= 30; value += 1) {
gridVertices.push(-30, -0.3, value, 30, -0.3, value);
gridVertices.push(value, -0.3, -30, value, -0.3, 30);
}
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.6, -2.7, 8.5, 4.2], [-6.8, 2.2, 8.7, 4.3],
[-7.1, 7.2, 9.2, 5.0], [7.4, -8.0, 9.4, 5.4], [6.4, -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, 4.5));
const riverEndFade = 0.16;
const fadeTextureSize = 256;
const fadeTextureData = new Uint8Array(fadeTextureSize * 4);
for (let index = 0; index < fadeTextureSize; index += 1) {
const progress = index / (fadeTextureSize - 1);
const distanceFromEnd = Math.min(progress, 1 - progress);
const opacity = Math.round(255 * Math.min(1, distanceFromEnd / riverEndFade));
const offset = index * 4;
fadeTextureData[offset] = opacity;
fadeTextureData[offset + 1] = opacity;
fadeTextureData[offset + 2] = opacity;
fadeTextureData[offset + 3] = 255;
}
const riverFadeTexture = new THREE.DataTexture(
fadeTextureData,
1,
fadeTextureSize,
THREE.RGBAFormat,
);
riverFadeTexture.minFilter = THREE.LinearFilter;
riverFadeTexture.magFilter = THREE.LinearFilter;
riverFadeTexture.needsUpdate = true;
riverGeometry.computeBoundingBox();
const riverBounds = riverGeometry.boundingBox;
const riverMinY = riverBounds?.min.y ?? -16;
const riverLength = Math.max((riverBounds?.max.y ?? 16) - riverMinY, 1);
const riverPositions = riverGeometry.getAttribute('position');
const riverUvs = riverGeometry.getAttribute('uv');
for (let index = 0; index < riverPositions.count; index += 1) {
const pathProgress = (riverPositions.getY(index) - riverMinY) / riverLength;
riverUvs.setXY(index, 0.5, pathProgress);
}
riverUvs.needsUpdate = true;
const waterMaterial = new THREE.MeshStandardMaterial({
color: palette.water,
emissive: palette.water,
emissiveIntensity: theme === 'dark' ? 0.28 : 0.1,
roughness: 0.52,
alphaMap: riverFadeTexture,
transparent: true,
});
const river = new THREE.Mesh(riverGeometry, waterMaterial);
river.rotation.x = -Math.PI / 2;
river.position.y = -0.23;
world.add(river);
const riverEdgeMaterial = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
uniforms: {
edgeColor: { value: new THREE.Color(palette.edge) },
edgeOpacity: { value: theme === 'dark' ? 0.48 : 0.56 },
fadeFraction: { value: riverEndFade },
pathMinY: { value: riverMinY },
pathLength: { value: riverLength },
},
vertexShader: `
uniform float pathMinY;
uniform float pathLength;
varying float pathProgress;
void main() {
pathProgress = clamp((position.y - pathMinY) / pathLength, 0.0, 1.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 edgeColor;
uniform float edgeOpacity;
uniform float fadeFraction;
varying float pathProgress;
void main() {
float startFade = smoothstep(0.0, fadeFraction, pathProgress);
float endFade = smoothstep(0.0, fadeFraction, 1.0 - pathProgress);
gl_FragColor = vec4(edgeColor, edgeOpacity * startFade * endFade);
}
`,
});
const riverEdge = new THREE.LineSegments(
new THREE.EdgesGeometry(riverGeometry),
riverEdgeMaterial,
);
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();
const fadeDistance = length * riverEndFade;
for (let distance = fadeDistance; distance < length - fadeDistance; distance += 1.35) {
const at = start.clone().addScaledVector(direction, distance);
const seam = new THREE.Mesh(
new THREE.PlaneGeometry(1.72, 0.016),
new THREE.MeshBasicMaterial({ color: palette.edge, transparent: true, opacity: 0.32 }),
);
seam.rotation.x = -Math.PI / 2;
seam.rotation.z = Math.atan2(direction.y, direction.x) + Math.PI / 2;
seam.position.set(at.x, -0.21, -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} />;
}