import React, { useMemo } from 'react'; import { useCurrentFrame, useVideoConfig, interpolate, Easing, AbsoluteFill, } from 'remotion'; import * as THREE from 'three'; // ============================================================================= // COMPOSITION CONFIG // ============================================================================= export const compositionConfig = { id: 'RollerCoasterPOV', durationInSeconds: 30, fps: 60, width: 1920, height: 1080, }; // ============================================================================= // STYLE CONSTANTS // ============================================================================= const COLORS = { sky: 0x87ceeb, skyHorizon: 0xffeedd, trackRail: 0x8b0000, trackTie: 0x4a4a4a, trackSupport: 0xf5f5f5, ground: 0x4a7c4a, groundDark: 0x3d6b3d, } as const; // ============================================================================= // SEEDED RANDOM // ============================================================================= const seededRandom = (seed: number): number => { const x = Math.sin(seed * 9999) * 10000; return x - Math.floor(x); }; // ============================================================================= // TRACK DEFINITION - Realistic Coaster Layout // ============================================================================= interface TrackControlPoint { x: number; y: number; z: number; bank: number; // Bank angle in radians } // Define the coaster path with control points const trackControlPoints: TrackControlPoint[] = [ // Station and lift hill { x: 0, y: 3, z: 0, bank: 0 }, { x: 0, y: 4, z: -15, bank: 0 }, { x: 0, y: 12, z: -35, bank: 0 }, { x: 0, y: 22, z: -55, bank: 0 }, { x: 0, y: 32, z: -75, bank: 0 }, { x: 0, y: 38, z: -90, bank: 0 }, // Crest and first drop { x: 0, y: 40, z: -100, bank: 0 }, { x: 0, y: 38, z: -110, bank: 0 }, { x: 0, y: 28, z: -125, bank: 0 }, { x: 0, y: 12, z: -145, bank: 0 }, { x: 0, y: 5, z: -165, bank: 0 }, // Pullout and right turn { x: 0, y: 4, z: -180, bank: 0 }, { x: 5, y: 5, z: -195, bank: 0.3 }, { x: 15, y: 6, z: -205, bank: 0.6 }, { x: 30, y: 7, z: -210, bank: 0.8 }, { x: 45, y: 8, z: -205, bank: 0.7 }, { x: 55, y: 10, z: -195, bank: 0.5 }, // Small hill { x: 60, y: 18, z: -180, bank: 0.2 }, { x: 62, y: 22, z: -165, bank: 0 }, { x: 60, y: 20, z: -150, bank: -0.1 }, { x: 55, y: 14, z: -135, bank: -0.3 }, // Left sweeping turn { x: 45, y: 10, z: -120, bank: -0.6 }, { x: 30, y: 8, z: -110, bank: -0.7 }, { x: 15, y: 7, z: -105, bank: -0.6 }, { x: 0, y: 6, z: -105, bank: -0.4 }, { x: -15, y: 6, z: -110, bank: -0.5 }, // Helix down { x: -25, y: 8, z: -120, bank: -0.7 }, { x: -30, y: 10, z: -135, bank: -0.8 }, { x: -28, y: 12, z: -150, bank: -0.7 }, { x: -20, y: 10, z: -160, bank: -0.5 }, { x: -10, y: 8, z: -165, bank: -0.3 }, { x: 0, y: 6, z: -160, bank: 0 }, // Return straight with small bunny hops { x: 5, y: 8, z: -145, bank: 0.1 }, { x: 8, y: 6, z: -130, bank: 0 }, { x: 10, y: 9, z: -115, bank: 0.1 }, { x: 8, y: 6, z: -100, bank: 0 }, { x: 5, y: 8, z: -85, bank: -0.1 }, { x: 0, y: 5, z: -70, bank: 0 }, // Final turn into brake run { x: -5, y: 4, z: -55, bank: -0.3 }, { x: -8, y: 4, z: -40, bank: -0.2 }, { x: -5, y: 3.5, z: -25, bank: -0.1 }, { x: 0, y: 3, z: -10, bank: 0 }, { x: 0, y: 3, z: 0, bank: 0 }, ]; // ============================================================================= // SPLINE UTILITIES // ============================================================================= const catmullRom = (t: number, p0: number, p1: number, p2: number, p3: number): number => { const t2 = t * t; const t3 = t2 * t; return 0.5 * ( (2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3 ); }; const catmullRomDerivative = (t: number, p0: number, p1: number, p2: number, p3: number): number => { const t2 = t * t; return 0.5 * ( (-p0 + p2) + (4 * p0 - 10 * p1 + 8 * p2 - 2 * p3) * t + (-3 * p0 + 9 * p1 - 9 * p2 + 3 * p3) * t2 ); }; interface TrackPoint { position: THREE.Vector3; tangent: THREE.Vector3; normal: THREE.Vector3; binormal: THREE.Vector3; bank: number; } const getPointOnTrack = (points: TrackControlPoint[], t: number): TrackPoint => { const segmentCount = points.length - 1; const scaledT = t * segmentCount; const segment = Math.min(Math.floor(scaledT), segmentCount - 1); const localT = scaledT - segment; const p0 = points[Math.max(0, segment - 1)]; const p1 = points[segment]; const p2 = points[Math.min(segmentCount, segment + 1)]; const p3 = points[Math.min(segmentCount, segment + 2)]; const position = new THREE.Vector3( catmullRom(localT, p0.x, p1.x, p2.x, p3.x), catmullRom(localT, p0.y, p1.y, p2.y, p3.y), catmullRom(localT, p0.z, p1.z, p2.z, p3.z) ); const tangent = new THREE.Vector3( catmullRomDerivative(localT, p0.x, p1.x, p2.x, p3.x), catmullRomDerivative(localT, p0.y, p1.y, p2.y, p3.y), catmullRomDerivative(localT, p0.z, p1.z, p2.z, p3.z) ).normalize(); const bank = catmullRom(localT, p0.bank, p1.bank, p2.bank, p3.bank); // Calculate initial up vector (world up) const worldUp = new THREE.Vector3(0, 1, 0); // Binormal (side vector) const binormal = new THREE.Vector3().crossVectors(tangent, worldUp).normalize(); // Normal (up vector relative to track) const normal = new THREE.Vector3().crossVectors(binormal, tangent).normalize(); // Apply banking rotation around tangent axis const bankQuat = new THREE.Quaternion().setFromAxisAngle(tangent, bank); normal.applyQuaternion(bankQuat); binormal.applyQuaternion(bankQuat); return { position, tangent, normal, binormal, bank }; }; // ============================================================================= // PHYSICS: Calculate speed based on height (energy conservation) // ============================================================================= const MAX_HEIGHT = 42; const BASE_SPEED = 0.008; // Base progress per frame at lowest point const LIFT_SPEED = 0.003; // Slow climb up lift hill const GRAVITY_FACTOR = 0.6; const calculateSpeed = (currentHeight: number, isLiftHill: boolean): number => { if (isLiftHill) { return LIFT_SPEED; } // v = sqrt(2 * g * h) - simplified energy conversion const heightDiff = MAX_HEIGHT - currentHeight; const energySpeed = Math.sqrt(Math.max(0.1, heightDiff) * GRAVITY_FACTOR) * 0.003; return Math.max(BASE_SPEED * 0.5, Math.min(BASE_SPEED * 2.5, energySpeed)); }; // ============================================================================= // PRE-CALCULATE TRACK PROGRESS BASED ON PHYSICS // ============================================================================= const preCalculateTrackTiming = (points: TrackControlPoint[], totalFrames: number) => { const samples = 2000; const progressToTime: number[] = []; let accumulatedTime = 0; // First pass: calculate time to reach each progress point for (let i = 0; i <= samples; i++) { const progress = i / samples; const trackPoint = getPointOnTrack(points, progress); const isLiftHill = progress < 0.15; // First 15% is lift hill const speed = calculateSpeed(trackPoint.position.y, isLiftHill); progressToTime.push(accumulatedTime); accumulatedTime += 1 / speed; } // Normalize to total duration const totalTime = accumulatedTime; // Create lookup: frame -> progress const frameToProgress: number[] = []; for (let frame = 0; frame <= totalFrames; frame++) { const targetTime = (frame / totalFrames) * totalTime; // Binary search for the progress that matches this time let low = 0; let high = samples; while (low < high) { const mid = Math.floor((low + high) / 2); if (progressToTime[mid] < targetTime) { low = mid + 1; } else { high = mid; } } // Interpolate for smoother result const idx = Math.min(low, samples - 1); const prevIdx = Math.max(0, idx - 1); const timeDiff = progressToTime[idx] - progressToTime[prevIdx]; const localT = timeDiff > 0 ? (targetTime - progressToTime[prevIdx]) / timeDiff : 0; const progress = (prevIdx + localT) / samples; frameToProgress.push(Math.min(0.999, Math.max(0, progress))); } return frameToProgress; }; // ============================================================================= // TYPES // ============================================================================= interface TreeData { x: number; z: number; scale: number; rotation: number; } interface SupportData { position: THREE.Vector3; height: number; } // ============================================================================= // THREE.JS SCENE COMPONENT // ============================================================================= const ThreeScene: React.FC<{ frame: number; fps: number; durationInFrames: number }> = ({ frame, fps, durationInFrames, }) => { const canvasRef = React.useRef(null); const time = frame / fps; // Pre-generate all static data const sceneData = useMemo(() => { // Pre-calculate physics-based timing const frameToProgress = preCalculateTrackTiming(trackControlPoints, durationInFrames); // Generate track geometry points const trackSegments = 800; const trackGeometryPoints: TrackPoint[] = []; for (let i = 0; i <= trackSegments; i++) { trackGeometryPoints.push(getPointOnTrack(trackControlPoints, i / trackSegments)); } // Generate support positions const supports: SupportData[] = []; for (let i = 0; i < trackSegments; i += 15) { const point = trackGeometryPoints[i]; if (point.position.y > 4) { supports.push({ position: point.position.clone(), height: point.position.y - 0.5, }); } } // Generate trees const trees: TreeData[] = []; for (let i = 0; i < 300; i++) { const angle = seededRandom(i * 1.1) * Math.PI * 2; const distance = 40 + seededRandom(i * 2.2) * 150; const x = Math.cos(angle) * distance; const z = -100 + Math.sin(angle) * distance; // Don't place trees too close to track let tooClose = false; for (const tp of trackGeometryPoints) { const dx = tp.position.x - x; const dz = tp.position.z - z; if (dx * dx + dz * dz < 400) { tooClose = true; break; } } if (!tooClose) { trees.push({ x, z, scale: 0.8 + seededRandom(i * 3.3) * 0.6, rotation: seededRandom(i * 4.4) * Math.PI * 2, }); } } // Wind particles const windParticles: { x: number; y: number; z: number; speed: number }[] = []; for (let i = 0; i < 100; i++) { windParticles.push({ x: seededRandom(i * 10) * 4 - 2, y: seededRandom(i * 11) * 3 - 1, z: seededRandom(i * 12) * 10 - 15, speed: 0.5 + seededRandom(i * 13) * 0.5, }); } return { frameToProgress, trackGeometryPoints, supports, trees, windParticles, }; }, [durationInFrames]); React.useEffect(() => { if (!canvasRef.current) return; const canvas = canvasRef.current; const width = 1920; const height = 1080; const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance', }); renderer.setSize(width, height); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.1; const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0xc9daf0, 0.004); // ========================================================================= // SKY // ========================================================================= const skyGeo = new THREE.SphereGeometry(500, 32, 32); const skyMat = new THREE.ShaderMaterial({ uniforms: { topColor: { value: new THREE.Color(COLORS.sky) }, bottomColor: { value: new THREE.Color(COLORS.skyHorizon) }, }, vertexShader: ` varying vec3 vWorldPosition; void main() { vec4 worldPosition = modelMatrix * vec4(position, 1.0); vWorldPosition = worldPosition.xyz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform vec3 topColor; uniform vec3 bottomColor; varying vec3 vWorldPosition; void main() { float h = normalize(vWorldPosition).y; float t = max(0.0, min(1.0, h * 1.5 + 0.3)); gl_FragColor = vec4(mix(bottomColor, topColor, t), 1.0); } `, side: THREE.BackSide, }); scene.add(new THREE.Mesh(skyGeo, skyMat)); // ========================================================================= // LIGHTING // ========================================================================= const ambientLight = new THREE.AmbientLight(0x9bb8d4, 0.5); scene.add(ambientLight); const sunLight = new THREE.DirectionalLight(0xfffaf0, 1.3); sunLight.position.set(50, 80, 30); sunLight.castShadow = true; sunLight.shadow.mapSize.width = 2048; sunLight.shadow.mapSize.height = 2048; sunLight.shadow.camera.near = 1; sunLight.shadow.camera.far = 300; sunLight.shadow.camera.left = -100; sunLight.shadow.camera.right = 100; sunLight.shadow.camera.top = 100; sunLight.shadow.camera.bottom = -100; sunLight.shadow.bias = -0.0002; scene.add(sunLight); const fillLight = new THREE.HemisphereLight(0x87ceeb, 0x3d6b3d, 0.4); scene.add(fillLight); // ========================================================================= // GROUND // ========================================================================= const groundGeo = new THREE.PlaneGeometry(600, 600, 64, 64); const groundPositions = groundGeo.attributes.position; for (let i = 0; i < groundPositions.count; i++) { const x = groundPositions.getX(i); const y = groundPositions.getY(i); const noise = Math.sin(x * 0.02) * Math.cos(y * 0.02) * 3; groundPositions.setZ(i, noise); } groundGeo.computeVertexNormals(); const groundMat = new THREE.MeshStandardMaterial({ color: COLORS.ground, roughness: 0.9, }); const ground = new THREE.Mesh(groundGeo, groundMat); ground.rotation.x = -Math.PI / 2; ground.position.y = 0; ground.receiveShadow = true; scene.add(ground); // ========================================================================= // TRACK RAILS // ========================================================================= const railMat = new THREE.MeshStandardMaterial({ color: COLORS.trackRail, roughness: 0.4, metalness: 0.6, }); const tieMat = new THREE.MeshStandardMaterial({ color: COLORS.trackTie, roughness: 0.7, metalness: 0.3, }); const RAIL_GAUGE = 0.8; // Distance between rails const RAIL_RADIUS = 0.05; // Create rail geometry using tubes const createRailPath = (offset: number) => { const points: THREE.Vector3[] = []; for (const tp of sceneData.trackGeometryPoints) { const offsetVec = tp.binormal.clone().multiplyScalar(offset); points.push(tp.position.clone().add(offsetVec)); } return new THREE.CatmullRomCurve3(points); }; const leftRailCurve = createRailPath(-RAIL_GAUGE / 2); const rightRailCurve = createRailPath(RAIL_GAUGE / 2); const railGeo = new THREE.TubeGeometry(leftRailCurve, 600, RAIL_RADIUS, 8, false); const leftRail = new THREE.Mesh(railGeo, railMat); leftRail.castShadow = true; scene.add(leftRail); const rightRailGeo = new THREE.TubeGeometry(rightRailCurve, 600, RAIL_RADIUS, 8, false); const rightRail = new THREE.Mesh(rightRailGeo, railMat); rightRail.castShadow = true; scene.add(rightRail); // Track ties (cross beams) const tieGeo = new THREE.BoxGeometry(RAIL_GAUGE + 0.3, 0.06, 0.12); for (let i = 0; i < sceneData.trackGeometryPoints.length; i += 4) { const tp = sceneData.trackGeometryPoints[i]; const tie = new THREE.Mesh(tieGeo, tieMat); tie.position.copy(tp.position); tie.position.y -= 0.08; // Orient tie perpendicular to track const quaternion = new THREE.Quaternion(); const matrix = new THREE.Matrix4(); matrix.lookAt( tp.position, tp.position.clone().add(tp.tangent), tp.normal ); quaternion.setFromRotationMatrix(matrix); tie.setRotationFromQuaternion(quaternion); tie.castShadow = true; scene.add(tie); } // ========================================================================= // SUPPORT STRUCTURE // ========================================================================= const supportMat = new THREE.MeshStandardMaterial({ color: COLORS.trackSupport, roughness: 0.5, metalness: 0.2, }); sceneData.supports.forEach((support) => { // Vertical column const columnGeo = new THREE.CylinderGeometry(0.15, 0.2, support.height, 8); const column = new THREE.Mesh(columnGeo, supportMat); column.position.set( support.position.x, support.height / 2, support.position.z ); column.castShadow = true; scene.add(column); // Base const baseGeo = new THREE.CylinderGeometry(0.4, 0.5, 0.3, 8); const base = new THREE.Mesh(baseGeo, supportMat); base.position.set(support.position.x, 0.15, support.position.z); scene.add(base); }); // ========================================================================= // TREES (Instanced) // ========================================================================= // Tree trunk const trunkGeo = new THREE.CylinderGeometry(0.3, 0.5, 4, 8); const trunkMat = new THREE.MeshStandardMaterial({ color: 0x4a3728, roughness: 0.9 }); const trunkMesh = new THREE.InstancedMesh(trunkGeo, trunkMat, sceneData.trees.length); trunkMesh.castShadow = true; // Tree foliage const foliageGeo = new THREE.ConeGeometry(2.5, 6, 8); const foliageMat = new THREE.MeshStandardMaterial({ color: 0x2d5a27, roughness: 0.8 }); const foliageMesh = new THREE.InstancedMesh(foliageGeo, foliageMat, sceneData.trees.length); foliageMesh.castShadow = true; const dummy = new THREE.Object3D(); sceneData.trees.forEach((tree, i) => { // Trunk dummy.position.set(tree.x, 2 * tree.scale, tree.z); dummy.scale.set(tree.scale, tree.scale, tree.scale); dummy.rotation.y = tree.rotation; dummy.updateMatrix(); trunkMesh.setMatrixAt(i, dummy.matrix); // Foliage dummy.position.set(tree.x, 5 * tree.scale, tree.z); dummy.updateMatrix(); foliageMesh.setMatrixAt(i, dummy.matrix); }); scene.add(trunkMesh); scene.add(foliageMesh); // ========================================================================= // STATION // ========================================================================= const stationGroup = new THREE.Group(); // Platform const platformGeo = new THREE.BoxGeometry(8, 0.5, 6); const platformMat = new THREE.MeshStandardMaterial({ color: 0x808080, roughness: 0.8 }); const platform = new THREE.Mesh(platformGeo, platformMat); platform.position.set(0, 2.75, 3); platform.receiveShadow = true; stationGroup.add(platform); // Roof const roofGeo = new THREE.BoxGeometry(10, 0.3, 8); const roofMat = new THREE.MeshStandardMaterial({ color: 0x8b4513, roughness: 0.7 }); const roof = new THREE.Mesh(roofGeo, roofMat); roof.position.set(0, 6, 3); roof.castShadow = true; stationGroup.add(roof); // Pillars const pillarGeo = new THREE.CylinderGeometry(0.2, 0.2, 3, 8); const pillarPositions = [[-4, 2], [4, 2], [-4, 6], [4, 6]]; pillarPositions.forEach(([x, z]) => { const pillar = new THREE.Mesh(pillarGeo, supportMat); pillar.position.set(x, 4.5, z); pillar.castShadow = true; stationGroup.add(pillar); }); scene.add(stationGroup); // ========================================================================= // COASTER CAR (for reference in POV) // ========================================================================= // We'll add subtle car frame elements at edges of view // ========================================================================= // GET CURRENT TRACK POSITION // ========================================================================= const progress = sceneData.frameToProgress[frame] || 0; const currentPoint = getPointOnTrack(trackControlPoints, progress); const lookAheadPoint = getPointOnTrack(trackControlPoints, Math.min(0.999, progress + 0.005)); // ========================================================================= // CAMERA SETUP // ========================================================================= const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000); // Position camera at track point, slightly above and forward const cameraOffset = new THREE.Vector3(0, 0.8, 0); cameraOffset.add(currentPoint.normal.clone().multiplyScalar(0.3)); camera.position.copy(currentPoint.position).add(cameraOffset); // Look ahead along track const lookTarget = lookAheadPoint.position.clone(); lookTarget.add(lookAheadPoint.normal.clone().multiplyScalar(0.5)); camera.lookAt(lookTarget); // Apply banking const bankAngle = currentPoint.bank; camera.rotateZ(bankAngle * 0.7); // Partial banking for comfort // ========================================================================= // WIND/SPEED PARTICLES // ========================================================================= const currentHeight = currentPoint.position.y; const isLiftHill = progress < 0.15; const currentSpeed = calculateSpeed(currentHeight, isLiftHill); const speedRatio = currentSpeed / (BASE_SPEED * 2); // Only show particles when moving fast if (speedRatio > 0.3) { const particleMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: Math.min(0.6, speedRatio * 0.8), }); sceneData.windParticles.forEach((p, i) => { const cycleTime = (time * p.speed * 3 + i * 0.1) % 1; const particleZ = p.z + cycleTime * 20 - 10; if (particleZ > -15 && particleZ < 2) { const particleGeo = new THREE.PlaneGeometry(0.01, 0.15 + speedRatio * 0.2); const particle = new THREE.Mesh(particleGeo, particleMat); // Position relative to camera const worldPos = new THREE.Vector3(p.x, p.y, particleZ); worldPos.applyQuaternion(camera.quaternion); worldPos.add(camera.position); particle.position.copy(worldPos); particle.lookAt(camera.position); scene.add(particle); } }); } // ========================================================================= // G-FORCE VIGNETTE (darken edges during high G) // ========================================================================= // Calculate approximate G-force from track curvature and speed const nextPoint = getPointOnTrack(trackControlPoints, Math.min(0.999, progress + 0.01)); const curvature = currentPoint.tangent.distanceTo(nextPoint.tangent); const gForce = Math.min(1, curvature * speedRatio * 15); // ========================================================================= // RENDER // ========================================================================= renderer.render(scene, camera); // Add vignette overlay for G-force effect const ctx = canvas.getContext('2d'); if (ctx && gForce > 0.1) { const gradient = ctx.createRadialGradient( width / 2, height / 2, height * 0.3, width / 2, height / 2, height * 0.8 ); gradient.addColorStop(0, 'rgba(0,0,0,0)'); gradient.addColorStop(1, `rgba(0,0,0,${gForce * 0.4})`); ctx.fillStyle = gradient; ctx.fillRect(0, 0, width, height); } // Add car frame overlay if (ctx) { ctx.strokeStyle = 'rgba(40,40,40,0.8)'; ctx.lineWidth = 8; // Bottom bar (lap restraint hint) ctx.beginPath(); ctx.moveTo(width * 0.2, height - 60); ctx.quadraticCurveTo(width * 0.5, height - 30, width * 0.8, height - 60); ctx.stroke(); // Side bars ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(30, height * 0.5); ctx.lineTo(30, height - 80); ctx.stroke(); ctx.beginPath(); ctx.moveTo(width - 30, height * 0.5); ctx.lineTo(width - 30, height - 80); ctx.stroke(); } return () => { renderer.dispose(); }; }, [frame, fps, durationInFrames, time, sceneData]); return ( ); }; // ============================================================================= // MAIN COMPONENT // ============================================================================= const RollerCoasterPOV: React.FC = () => { const frame = useCurrentFrame(); const { durationInFrames, fps } = useVideoConfig(); return ( ); }; export default RollerCoasterPOV;