'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 ; }