diff --git a/src/components/hud/HUD.tsx b/src/components/hud/HUD.tsx
index d5ee15e..a0a4181 100644
--- a/src/components/hud/HUD.tsx
+++ b/src/components/hud/HUD.tsx
@@ -13,9 +13,17 @@ export default function HUD() {
const [showTimeMenu, setShowTimeMenu] = useState(false);
useEffect(() => {
- const update = () => setTimeConfig(getTimeConfig(timeMode));
- update();
- const interval = setInterval(update, 5000);
+ setTimeConfig(getTimeConfig(timeMode));
+ const interval = setInterval(() => {
+ setTimeConfig((prev) => {
+ const next = getTimeConfig(timeMode);
+ // Only trigger a React re-render if the time string or phase actually changed
+ if (prev.formattedTime !== next.formattedTime || prev.resolvedPhase !== next.resolvedPhase) {
+ return next;
+ }
+ return prev;
+ });
+ }, 5000);
return () => clearInterval(interval);
}, [timeMode]);
diff --git a/src/components/scene/GuavaTree.tsx b/src/components/scene/GuavaTree.tsx
index f7b3c4f..8b61be0 100644
--- a/src/components/scene/GuavaTree.tsx
+++ b/src/components/scene/GuavaTree.tsx
@@ -2,6 +2,7 @@
import { useFrame } from '@react-three/fiber';
import { useMemo, useRef } from 'react';
import * as THREE from 'three';
+import { Instances, Instance } from '@react-three/drei';
import {
seededRandom,
getBarkTexture,
@@ -17,54 +18,57 @@ import {
// Guava Tree — multi-stemmed, smooth bark, dense oval canopy
// ─────────────────────────────────────────────────────
-/** Leaf cluster that sits AT a branch tip */
-function GuavaLeafCluster({ position, size = 1.0 }: { position: Vec3; size?: number }) {
+/** Aggregated Leaf clusters */
+function GuavaLeafClusters({ positions }: { positions: Vec3[] }) {
const leafTex = getLeafTexture('#1a6e28', 'rgba(100, 210, 100, 0.25)', '#2d8838', 'oval');
- const leaves = useMemo(() => {
- const rng = seededRandom(
- Math.floor(position[0] * 1000 + position[1] * 100 + position[2] * 10),
- );
- const count = 6 + Math.floor(rng() * 5);
- const arr: { pos: Vec3; rot: [number, number, number]; scale: number }[] = [];
- for (let i = 0; i < count; i++) {
- const angle = rng() * Math.PI * 2;
- const tilt = rng() * 0.5 - 0.25;
- // Leaves radiate outward from this tip in a tight cluster
- const spread = 0.08 + rng() * 0.18 * size;
- arr.push({
- pos: [
- Math.cos(angle) * spread,
- (rng() - 0.4) * 0.15 * size,
- Math.sin(angle) * spread,
- ],
- rot: [tilt, angle + rng() * 0.6, rng() * 0.3],
- scale: (0.22 + rng() * 0.18) * size,
- });
- }
- return arr;
- }, [position, size]);
+
+ const allLeaves = useMemo(() => {
+ const leaves: { pos: Vec3; rot: [number, number, number]; scale: number }[] = [];
+ positions.forEach((pos, index) => {
+ const rng = seededRandom(Math.floor(pos[0] * 1000 + pos[1] * 100 + pos[2] * 10));
+ const size = 0.7 + (index % 4) * 0.12;
+ const count = 6 + Math.floor(rng() * 5);
+
+ for (let i = 0; i < count; i++) {
+ const angle = rng() * Math.PI * 2;
+ const tilt = rng() * 0.5 - 0.25;
+ const spread = 0.08 + rng() * 0.18 * size;
+ leaves.push({
+ pos: [
+ pos[0] + Math.cos(angle) * spread,
+ pos[1] + (rng() - 0.4) * 0.15 * size,
+ pos[2] + Math.sin(angle) * spread,
+ ],
+ rot: [tilt, angle + rng() * 0.6, rng() * 0.3],
+ scale: (0.22 + rng() * 0.18) * size,
+ });
+ }
+ });
+ return leaves;
+ }, [positions]);
+
+ if (allLeaves.length === 0) return null;
return (
-
- {leaves.map((leaf, i) => (
-
-
-
-
+
+
+
+ {allLeaves.map((leaf, i) => (
+
))}
-
+
);
}
-/** Renders all connected branch segments */
+/** Renders all connected branch segments using Instances */
function BranchRenderer({ branches, barkColor = '#5a5045' }: {
branches: BranchSegment[];
barkColor?: string;
@@ -72,33 +76,28 @@ function BranchRenderer({ branches, barkColor = '#5a5045' }: {
const barkTex = getBarkTexture(barkColor);
const barkNorm = getBarkNormal();
- // Pre-compute transforms for each segment
- const transforms = useMemo(() => {
- return branches.map((b) => computeCylinderTransform(b.start, b.end));
+ const instancedData = useMemo(() => {
+ return branches.map((b) => {
+ const t = computeCylinderTransform(b.start, b.end);
+ const avgRadius = (b.radiusStart + b.radiusEnd) / 2;
+ return {
+ pos: t.position,
+ rot: t.quaternion,
+ scale: [avgRadius, t.length, avgRadius] as [number, number, number],
+ };
+ });
}, [branches]);
+ if (instancedData.length === 0) return null;
+
return (
-
- {branches.map((b, i) => {
- const t = transforms[i];
- return (
-
-
-
-
- );
- })}
-
+
+
+
+ {instancedData.map((d, i) => (
+
+ ))}
+
);
}
@@ -115,45 +114,61 @@ function GuavaFruits({ fruitPositions, seed = 0 }: {
}
});
- const fruits = useMemo(() => {
+ const { fruits, calyxes, stems } = useMemo(() => {
const rng = seededRandom(seed + 400);
- return fruitPositions.map((pos) => ({
- pos: [pos[0], pos[1] - 0.1 - rng() * 0.12, pos[2]] as Vec3,
- scale: 0.07 + rng() * 0.035,
- ripeness: rng(),
- }));
+ const fArr: any[] = [];
+ const cArr: any[] = [];
+ const stArr: any[] = [];
+
+ fruitPositions.forEach((pos) => {
+ const p: Vec3 = [pos[0], pos[1] - 0.1 - rng() * 0.12, pos[2]];
+ const scale = 0.07 + rng() * 0.035;
+ const r = rng();
+
+ const color = new THREE.Color();
+ if (r < 0.5) {
+ color.lerpColors(new THREE.Color('#3a8a2a'), new THREE.Color('#8ac44a'), r * 2);
+ } else {
+ color.lerpColors(new THREE.Color('#8ac44a'), new THREE.Color('#d4c82a'), (r - 0.5) * 2);
+ }
+
+ fArr.push({ pos: p, scale, color });
+ cArr.push({ pos: [p[0], p[1] + scale * 0.85, p[2]], scale });
+ stArr.push({ pos: [p[0], p[1] + scale * 1.0, p[2]] });
+ });
+
+ return { fruits: fArr, calyxes: cArr, stems: stArr };
}, [fruitPositions, seed]);
return (
- {fruits.map((fruit, i) => {
- const r = fruit.ripeness;
- const color = new THREE.Color();
- if (r < 0.5) {
- color.lerpColors(new THREE.Color('#3a8a2a'), new THREE.Color('#8ac44a'), r * 2);
- } else {
- color.lerpColors(new THREE.Color('#8ac44a'), new THREE.Color('#d4c82a'), (r - 0.5) * 2);
- }
-
- return (
-
-
-
-
-
- {/* Calyx crown */}
-
-
-
-
- {/* Short stem connecting to branch */}
-
-
-
-
-
- );
- })}
+ {fruits.length > 0 && (
+
+
+
+ {fruits.map((f, i) => (
+
+ ))}
+
+ )}
+ {calyxes.length > 0 && (
+
+
+
+ {calyxes.map((c, i) => (
+
+ ))}
+
+ )}
+ {stems.length > 0 && (
+
+
+
+ {stems.map((st, i) => (
+
+ ))}
+
+ )}
);
}
@@ -162,45 +177,41 @@ function GuavaFruits({ fruitPositions, seed = 0 }: {
function GuavaRoots({ seed = 0 }: { seed?: number }) {
const barkTex = getBarkTexture('#5a5045');
- const roots = useMemo(() => {
+ const rootTransforms = useMemo(() => {
const rng = seededRandom(seed + 600);
- const arr: { angle: number; length: number; thickness: number }[] = [];
+ const arr = [];
const count = 3 + Math.floor(rng() * 3);
for (let i = 0; i < count; i++) {
+ const angle = (i / count) * Math.PI * 2 + rng() * 0.6;
+ const length = 0.3 + rng() * 0.25;
+ const thickness = 0.04 + rng() * 0.03;
+
+ const start: Vec3 = [0, 0.05, 0];
+ const end: Vec3 = [
+ Math.cos(angle) * length,
+ -0.05,
+ Math.sin(angle) * length,
+ ];
+ const t = computeCylinderTransform(start, end);
arr.push({
- angle: (i / count) * Math.PI * 2 + rng() * 0.6,
- length: 0.3 + rng() * 0.25,
- thickness: 0.04 + rng() * 0.03,
+ pos: t.position,
+ rot: t.quaternion,
+ scale: [thickness * 0.3, t.length, thickness] as [number, number, number]
});
}
return arr;
}, [seed]);
- const rootTransforms = useMemo(() => {
- return roots.map((root) => {
- const start: Vec3 = [0, 0.05, 0];
- const end: Vec3 = [
- Math.cos(root.angle) * root.length,
- -0.05,
- Math.sin(root.angle) * root.length,
- ];
- return { ...computeCylinderTransform(start, end), root };
- });
- }, [roots]);
+ if (rootTransforms.length === 0) return null;
return (
-
+
+
+
{rootTransforms.map((rt, i) => (
-
-
-
-
+
))}
-
+
);
}
@@ -298,12 +309,11 @@ export default function GuavaTree({ x, z, groundY = 0, isSpecial = false }: Guav
{/* ── Connected branches ── */}
+ {/* Branch Segments */}
- {/* ── Leaf clusters AT branch tips ── */}
- {treeData.leafTipPositions.map((pos, i) => (
-
- ))}
+ {/* Aggregated Leaf Clusters */}
+
{/* ── Fruits hanging from branch junctions ── */}
diff --git a/src/components/scene/JujubeTree.tsx b/src/components/scene/JujubeTree.tsx
index 140f18d..c1a17cc 100644
--- a/src/components/scene/JujubeTree.tsx
+++ b/src/components/scene/JujubeTree.tsx
@@ -2,6 +2,7 @@
import { useFrame } from '@react-three/fiber';
import { useMemo, useRef } from 'react';
import * as THREE from 'three';
+import { Instances, Instance } from '@react-three/drei';
import {
seededRandom,
getBarkTexture,
@@ -20,79 +21,83 @@ import {
const BARK_COLOR = '#4a3520';
-/** Small oval leaf cluster at a branch tip */
-function JujubeLeafCluster({ position, size = 1.0 }: { position: Vec3; size?: number }) {
- const leafTex = getLeafTexture('#1a5520', 'rgba(90, 180, 90, 0.2)', '#2a7030', 'oval');
- const leaves = useMemo(() => {
- const rng = seededRandom(
- Math.floor(position[0] * 1000 + position[1] * 100 + position[2] * 10),
- );
- const count = 7 + Math.floor(rng() * 5);
- const arr: { pos: Vec3; rot: [number, number, number]; scale: number }[] = [];
- for (let i = 0; i < count; i++) {
- const angle = rng() * Math.PI * 2;
- const tilt = rng() * 0.6 - 0.3;
- const spread = 0.06 + rng() * 0.14 * size;
- arr.push({
- pos: [
- Math.cos(angle) * spread,
- (rng() - 0.35) * 0.12 * size,
- Math.sin(angle) * spread,
- ],
- rot: [tilt, angle + rng() * 0.5, rng() * 0.4],
- scale: (0.14 + rng() * 0.1) * size,
- });
- }
- return arr;
- }, [position, size]);
+/** Aggregated Leaf clusters */
+function JujubeLeafClusters({ positions }: { positions: Vec3[] }) {
+ const leafTex = getLeafTexture('#2a5c1a', 'rgba(150, 220, 150, 0.25)', '#3a7a2a', 'oval');
+
+ const allLeaves = useMemo(() => {
+ const leaves: { pos: Vec3; rot: [number, number, number]; scale: number }[] = [];
+ positions.forEach((pos, index) => {
+ const rng = seededRandom(Math.floor(pos[0] * 1000 + pos[1] * 100 + pos[2] * 10));
+ const size = 0.7 + (index % 4) * 0.12;
+ const count = 5 + Math.floor(rng() * 4);
+
+ for (let i = 0; i < count; i++) {
+ const angle = rng() * Math.PI * 2;
+ const tilt = rng() * 0.5 - 0.1;
+ const spread = 0.05 + rng() * 0.12 * size;
+ leaves.push({
+ pos: [
+ pos[0] + Math.cos(angle) * spread,
+ pos[1] + (rng() - 0.4) * 0.12 * size,
+ pos[2] + Math.sin(angle) * spread,
+ ],
+ rot: [tilt, angle + rng() * 0.6, rng() * 0.2],
+ scale: (0.18 + rng() * 0.12) * size,
+ });
+ }
+ });
+ return leaves;
+ }, [positions]);
+
+ if (allLeaves.length === 0) return null;
return (
-
- {leaves.map((leaf, i) => (
-
-
-
-
+
+
+
+ {allLeaves.map((leaf, i) => (
+
))}
-
+
);
}
-/** Renders connected branch segments */
+/** Renders connected branch segments using Instances */
function BranchRenderer({ branches }: { branches: BranchSegment[] }) {
const barkTex = getBarkTexture(BARK_COLOR);
const barkNorm = getBarkNormal();
- const transforms = useMemo(() => {
- return branches.map((b) => computeCylinderTransform(b.start, b.end));
+ const instancedData = useMemo(() => {
+ return branches.map((b) => {
+ const t = computeCylinderTransform(b.start, b.end);
+ const avgRadius = (b.radiusStart + b.radiusEnd) / 2;
+ return {
+ pos: t.position,
+ rot: t.quaternion,
+ scale: [avgRadius, t.length, avgRadius] as [number, number, number],
+ };
+ });
}, [branches]);
+ if (instancedData.length === 0) return null;
+
return (
-
- {branches.map((b, i) => {
- const t = transforms[i];
- return (
-
-
-
-
- );
- })}
-
+
+
+
+ {instancedData.map((d, i) => (
+
+ ))}
+
);
}
@@ -101,19 +106,17 @@ function JujubeThorns({ branches, seed }: { branches: BranchSegment[]; seed: num
const thorns = useMemo(() => {
const rng = seededRandom(seed + 900);
const arr: { pos: Vec3; rot: [number, number, number] }[] = [];
- // Place thorns along branch segments
for (const b of branches) {
const thornCount = 1 + Math.floor(rng() * 2);
for (let t = 0; t < thornCount; t++) {
if (rng() > 0.55) continue;
const frac = 0.2 + rng() * 0.6;
- const pos: Vec3 = [
- b.start[0] + (b.end[0] - b.start[0]) * frac + (rng() - 0.5) * 0.03,
- b.start[1] + (b.end[1] - b.start[1]) * frac + (rng() - 0.5) * 0.03,
- b.start[2] + (b.end[2] - b.start[2]) * frac + (rng() - 0.5) * 0.03,
- ];
arr.push({
- pos,
+ pos: [
+ b.start[0] + (b.end[0] - b.start[0]) * frac + (rng() - 0.5) * 0.03,
+ b.start[1] + (b.end[1] - b.start[1]) * frac + (rng() - 0.5) * 0.03,
+ b.start[2] + (b.end[2] - b.start[2]) * frac + (rng() - 0.5) * 0.03,
+ ],
rot: [rng() * Math.PI * 0.5, rng() * Math.PI * 2, rng() * Math.PI * 0.3],
});
}
@@ -121,15 +124,16 @@ function JujubeThorns({ branches, seed }: { branches: BranchSegment[]; seed: num
return arr;
}, [branches, seed]);
+ if (thorns.length === 0) return null;
+
return (
-
+
+
+
{thorns.map((thorn, i) => (
-
-
-
-
+
))}
-
+
);
}
@@ -146,42 +150,51 @@ function JujubeFruits({ fruitPositions, seed = 0 }: {
}
});
- const fruits = useMemo(() => {
+ const { fruits, stems } = useMemo(() => {
const rng = seededRandom(seed + 700);
- return fruitPositions.map((pos) => ({
- pos: [pos[0], pos[1] - 0.06 - rng() * 0.08, pos[2]] as Vec3,
- scale: 0.035 + rng() * 0.02,
- ripeness: rng(),
- }));
+ const fArr: any[] = [];
+ const stArr: any[] = [];
+
+ fruitPositions.forEach((pos) => {
+ const scale = 0.035 + rng() * 0.02;
+ const r = rng();
+
+ const color = new THREE.Color();
+ if (r < 0.3) {
+ color.lerpColors(new THREE.Color('#4a8a2a'), new THREE.Color('#a4b044'), r * 3.3);
+ } else if (r < 0.7) {
+ color.lerpColors(new THREE.Color('#a4b044'), new THREE.Color('#c44a28'), (r - 0.3) * 2.5);
+ } else {
+ color.lerpColors(new THREE.Color('#c44a28'), new THREE.Color('#8a2020'), (r - 0.7) * 3.3);
+ }
+
+ const p: Vec3 = [pos[0], pos[1] - 0.06 - rng() * 0.08, pos[2]];
+ fArr.push({ pos: p, scale, color });
+ stArr.push({ pos: [p[0], p[1] + scale * 0.9, p[2]] });
+ });
+ return { fruits: fArr, stems: stArr };
}, [fruitPositions, seed]);
return (
- {fruits.map((fruit, i) => {
- const r = fruit.ripeness;
- const color = new THREE.Color();
- if (r < 0.3) {
- color.lerpColors(new THREE.Color('#4a8a2a'), new THREE.Color('#a4b044'), r * 3.3);
- } else if (r < 0.7) {
- color.lerpColors(new THREE.Color('#a4b044'), new THREE.Color('#c44a28'), (r - 0.3) * 2.5);
- } else {
- color.lerpColors(new THREE.Color('#c44a28'), new THREE.Color('#8a2020'), (r - 0.7) * 3.3);
- }
-
- return (
-
-
-
-
-
- {/* Tiny stem connecting to branch */}
-
-
-
-
-
- );
- })}
+ {fruits.length > 0 && (
+
+
+
+ {fruits.map((f, i) => (
+
+ ))}
+
+ )}
+ {stems.length > 0 && (
+
+
+
+ {stems.map((st, i) => (
+
+ ))}
+
+ )}
);
}
@@ -204,21 +217,26 @@ function JujubeRoots({ seed = 0 }: { seed?: number }) {
-0.04,
Math.sin(angle) * length,
];
- const transform = computeCylinderTransform(start, end);
- arr.push({ ...transform, thickness });
+ const t = computeCylinderTransform(start, end);
+ arr.push({
+ pos: t.position,
+ rot: t.quaternion,
+ scale: [thickness * 0.3, t.length, thickness] as [number, number, number]
+ });
}
return arr;
}, [seed]);
+ if (rootTransforms.length === 0) return null;
+
return (
-
+
+
+
{rootTransforms.map((rt, i) => (
-
-
-
-
+
))}
-
+
);
}
@@ -304,14 +322,13 @@ export default function JujubeTree({ x, z, groundY = 0 }: JujubeTreeProps) {
{/* ── Connected branches + thorns + leaves + fruits ── */}
+ {/* Connected branch segments */}
- {/* Leaf clusters at branch tips */}
- {treeData.leafTipPositions.map((pos, i) => (
-
- ))}
-
+ {/* Aggregated Leaf Clusters */}
+
+
{/* Fruits from branch junctions */}
diff --git a/src/components/scene/LemonTree.tsx b/src/components/scene/LemonTree.tsx
index 18a327c..3f51249 100644
--- a/src/components/scene/LemonTree.tsx
+++ b/src/components/scene/LemonTree.tsx
@@ -2,6 +2,7 @@
import { useFrame } from '@react-three/fiber';
import { useMemo, useRef } from 'react';
import * as THREE from 'three';
+import { Instances, Instance } from '@react-three/drei';
import {
seededRandom,
getBarkTexture,
@@ -20,127 +21,135 @@ import {
const BARK_COLOR = '#5a4a2a';
-/** Glossy narrow citrus leaf cluster at branch tip */
-function LemonLeafCluster({ position, size = 1.0 }: { position: Vec3; size?: number }) {
- const leafTex = getLeafTexture('#1a5a18', 'rgba(140, 220, 100, 0.3)', '#2a7a28', 'narrow');
- const leaves = useMemo(() => {
- const rng = seededRandom(
- Math.floor(position[0] * 1000 + position[1] * 100 + position[2] * 10),
- );
- const count = 7 + Math.floor(rng() * 5);
- const arr: { pos: Vec3; rot: [number, number, number]; scale: number }[] = [];
- for (let i = 0; i < count; i++) {
- const angle = rng() * Math.PI * 2;
- const tilt = rng() * 0.4 - 0.2;
- const spread = 0.06 + rng() * 0.16 * size;
- arr.push({
- pos: [
- Math.cos(angle) * spread,
- (rng() - 0.35) * 0.14 * size,
- Math.sin(angle) * spread,
- ],
- rot: [tilt, angle + rng() * 0.5, rng() * 0.25],
- scale: (0.18 + rng() * 0.14) * size,
- });
- }
- return arr;
- }, [position, size]);
+/** Aggregated Leaf clusters */
+function LemonLeafClusters({ positions }: { positions: Vec3[] }) {
+ const leafTex = getLeafTexture('#2a5c1a', 'rgba(150, 220, 150, 0.25)', '#3a7a2a', 'oval');
+
+ const allLeaves = useMemo(() => {
+ const leaves: { pos: Vec3; rot: [number, number, number]; scale: number }[] = [];
+ positions.forEach((pos, index) => {
+ const rng = seededRandom(Math.floor(pos[0] * 1000 + pos[1] * 100 + pos[2] * 10));
+ const size = 0.7 + (index % 4) * 0.12;
+ const count = 5 + Math.floor(rng() * 4);
+
+ for (let i = 0; i < count; i++) {
+ const angle = rng() * Math.PI * 2;
+ const tilt = rng() * 0.5 - 0.1;
+ const spread = 0.05 + rng() * 0.12 * size;
+ leaves.push({
+ pos: [
+ pos[0] + Math.cos(angle) * spread,
+ pos[1] + (rng() - 0.4) * 0.12 * size,
+ pos[2] + Math.sin(angle) * spread,
+ ],
+ rot: [tilt, angle + rng() * 0.6, rng() * 0.2],
+ scale: (0.18 + rng() * 0.12) * size,
+ });
+ }
+ });
+ return leaves;
+ }, [positions]);
+
+ if (allLeaves.length === 0) return null;
return (
-
- {leaves.map((leaf, i) => (
-
-
-
-
+
+
+
+ {allLeaves.map((leaf, i) => (
+
))}
-
+
);
}
-/** Renders connected branch segments */
+/** Renders connected branch segments using Instances */
function BranchRenderer({ branches }: { branches: BranchSegment[] }) {
const barkTex = getBarkTexture(BARK_COLOR);
const barkNorm = getBarkNormal();
- const transforms = useMemo(() => {
- return branches.map((b) => computeCylinderTransform(b.start, b.end));
+ const instancedData = useMemo(() => {
+ return branches.map((b) => {
+ const t = computeCylinderTransform(b.start, b.end);
+ const avgRadius = (b.radiusStart + b.radiusEnd) / 2;
+ return {
+ pos: t.position,
+ rot: t.quaternion,
+ scale: [avgRadius, t.length, avgRadius] as [number, number, number],
+ };
+ });
}, [branches]);
+ if (instancedData.length === 0) return null;
+
return (
-
- {branches.map((b, i) => {
- const t = transforms[i];
- return (
-
-
-
-
- );
- })}
-
+
+
+
+ {instancedData.map((d, i) => (
+
+ ))}
+
);
}
/** White 5-petaled citrus flowers at some branch tips */
function LemonFlowers({ positions, seed }: { positions: Vec3[]; seed: number }) {
- const flowers = useMemo(() => {
+ const { petals, centers } = useMemo(() => {
+ const p: { pos: Vec3; rot: [number, number, number]; scale: number }[] = [];
+ const c: { pos: Vec3; scale: number }[] = [];
const rng = seededRandom(seed + 250);
- // Only some positions get flowers
- return positions.filter(() => rng() > 0.55).map((pos) => ({
- pos,
- scale: 0.022 + rng() * 0.012,
- }));
+
+ positions.forEach((pos) => {
+ if (rng() > 0.55) {
+ const scale = 0.022 + rng() * 0.012;
+ c.push({ pos, scale: scale * 0.4 });
+
+ for (let pi = 0; pi < 5; pi++) {
+ const pAngle = (pi / 5) * Math.PI * 2;
+ p.push({
+ pos: [
+ pos[0] + Math.cos(pAngle) * scale * 0.7,
+ pos[1],
+ pos[2] + Math.sin(pAngle) * scale * 0.7,
+ ],
+ rot: [0.3, pAngle, 0],
+ scale,
+ });
+ }
+ }
+ });
+ return { petals: p, centers: c };
}, [positions, seed]);
return (
- {flowers.map((flower, i) => (
-
- {Array.from({ length: 5 }, (_, pi) => {
- const pAngle = (pi / 5) * Math.PI * 2;
- return (
-
-
-
-
- );
- })}
-
-
-
-
-
- ))}
+ {petals.length > 0 && (
+
+
+
+ {petals.map((p, i) => (
+
+ ))}
+
+ )}
+ {centers.length > 0 && (
+
+
+
+ {centers.map((c, i) => (
+
+ ))}
+
+ )}
);
}
@@ -158,51 +167,47 @@ function LemonFruits({ fruitPositions, seed = 0 }: {
}
});
- const fruits = useMemo(() => {
+ const { fruits } = useMemo(() => {
const rng = seededRandom(seed + 450);
- return fruitPositions.map((pos) => ({
- pos: [pos[0], pos[1] - 0.08 - rng() * 0.1, pos[2]] as Vec3,
- rot: [rng() * 0.3 - 0.15, rng() * Math.PI * 2, rng() * 0.3] as [number, number, number],
- scaleX: 0.055 + rng() * 0.02,
- scaleY: 0.075 + rng() * 0.025,
- ripeness: rng(),
- }));
+ const fArr: any[] = [];
+
+ fruitPositions.forEach((pos) => {
+ const r = rng();
+ const color = new THREE.Color();
+ if (r < 0.5) {
+ color.lerpColors(new THREE.Color('#4a8a2a'), new THREE.Color('#b4c830'), r * 2);
+ } else {
+ color.lerpColors(new THREE.Color('#b4c830'), new THREE.Color('#fde047'), (r - 0.5) * 2);
+ }
+
+ const p: Vec3 = [pos[0], pos[1] - 0.08 - rng() * 0.1, pos[2]];
+ const rot: [number, number, number] = [rng() * 0.3 - 0.15, rng() * Math.PI * 2, rng() * 0.3];
+ const scaleX = 0.055 + rng() * 0.02;
+ const scaleY = 0.075 + rng() * 0.025;
+
+ fArr.push({ pos: p, rot, scale: [scaleX, scaleY, scaleX], color });
+ });
+ return { fruits: fArr };
}, [fruitPositions, seed]);
return (
- {fruits.map((fruit, i) => {
- const r = fruit.ripeness;
- const color = new THREE.Color();
- if (r < 0.5) {
- color.lerpColors(new THREE.Color('#4a8a2a'), new THREE.Color('#b4c830'), r * 2);
- } else {
- color.lerpColors(new THREE.Color('#b4c830'), new THREE.Color('#fde047'), (r - 0.5) * 2);
- }
-
- return (
-
- {/* Lemon body */}
-
-
-
-
- {/* Nipple tip */}
-
-
-
-
- {/* Stem connecting to branch */}
-
-
-
-
-
- );
- })}
+ {fruits.map((fruit, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
);
}
@@ -221,21 +226,26 @@ function LemonRoots({ seed = 0 }: { seed?: number }) {
const thickness = 0.04 + rng() * 0.025;
const start: Vec3 = [0, 0.04, 0];
const end: Vec3 = [Math.cos(angle) * length, -0.03, Math.sin(angle) * length];
- const transform = computeCylinderTransform(start, end);
- arr.push({ ...transform, thickness });
+ const t = computeCylinderTransform(start, end);
+ arr.push({
+ pos: t.position,
+ rot: t.quaternion,
+ scale: [thickness * 0.3, t.length, thickness] as [number, number, number]
+ });
}
return arr;
}, [seed]);
+ if (rootTransforms.length === 0) return null;
+
return (
-
+
+
+
{rootTransforms.map((rt, i) => (
-
-
-
-
+
))}
-
+
);
}
@@ -321,13 +331,12 @@ export default function LemonTree({ x, z, groundY = 0 }: LemonTreeProps) {
{/* ── Connected branches + leaves + flowers + fruits ── */}
+ {/* Connected branch segments */}
- {/* Leaf clusters at branch tips */}
- {treeData.leafTipPositions.map((pos, i) => (
-
- ))}
-
+ {/* Aggregated Leaf Clusters */}
+
+
{/* Flowers at some leaf positions */}
diff --git a/src/components/scene/MangoTree.tsx b/src/components/scene/MangoTree.tsx
index e44a86f..ac413e4 100644
--- a/src/components/scene/MangoTree.tsx
+++ b/src/components/scene/MangoTree.tsx
@@ -2,6 +2,7 @@
import { useFrame } from '@react-three/fiber';
import { useMemo, useRef } from 'react';
import * as THREE from 'three';
+import { Instances, Instance } from '@react-three/drei';
import {
seededRandom,
getBarkTexture,
@@ -19,49 +20,53 @@ import {
// panicles, and hanging mango fruits at various ripeness.
// ─────────────────────────────────────────────────────
-/** Lanceolate leaf cluster at a branch tip */
-function MangoLeafCluster({ position, size = 1.0 }: { position: Vec3; size?: number }) {
+/** Aggregated Lanceolate leaf clusters for the entire tree */
+function MangoLeafClusters({ positions }: { positions: Vec3[] }) {
const leafTex = getLeafTexture('#1a5c1a', 'rgba(120, 200, 120, 0.25)', '#2a7a2a', 'lanceolate');
- const leaves = useMemo(() => {
- const rng = seededRandom(
- Math.floor(position[0] * 1000 + position[1] * 100 + position[2] * 10),
- );
- const count = 6 + Math.floor(rng() * 5);
- const arr: { pos: Vec3; rot: [number, number, number]; scale: number }[] = [];
- for (let i = 0; i < count; i++) {
- const angle = rng() * Math.PI * 2;
- const tilt = rng() * 0.6 - 0.3;
- const spread = 0.08 + rng() * 0.2 * size;
- arr.push({
- pos: [
- Math.cos(angle) * spread,
- (rng() - 0.3) * 0.18 * size,
- Math.sin(angle) * spread,
- ],
- rot: [tilt, angle + rng() * 0.5, rng() * 0.3],
- scale: (0.28 + rng() * 0.22) * size,
- });
- }
- return arr;
- }, [position, size]);
+
+ const allLeaves = useMemo(() => {
+ const leaves: { pos: Vec3; rot: [number, number, number]; scale: number }[] = [];
+ positions.forEach((pos, index) => {
+ const rng = seededRandom(Math.floor(pos[0] * 1000 + pos[1] * 100 + pos[2] * 10));
+ const size = 0.7 + (index % 4) * 0.12;
+ const count = 6 + Math.floor(rng() * 5);
+
+ for (let i = 0; i < count; i++) {
+ const angle = rng() * Math.PI * 2;
+ const tilt = rng() * 0.6 - 0.3;
+ const spread = 0.08 + rng() * 0.2 * size;
+ leaves.push({
+ pos: [
+ pos[0] + Math.cos(angle) * spread,
+ pos[1] + (rng() - 0.3) * 0.18 * size,
+ pos[2] + Math.sin(angle) * spread,
+ ],
+ rot: [tilt, angle + rng() * 0.5, rng() * 0.3],
+ scale: (0.28 + rng() * 0.22) * size,
+ });
+ }
+ });
+ return leaves;
+ }, [positions]);
+
+ if (allLeaves.length === 0) return null;
return (
-
- {leaves.map((leaf, i) => (
-
-
-
-
+
+
+
+ {allLeaves.map((leaf, i) => (
+
))}
-
+
);
}
@@ -73,32 +78,28 @@ function BranchRenderer({ branches, barkColor = '#4a3828' }: {
const barkTex = getBarkTexture(barkColor);
const barkNorm = getBarkNormal();
- const transforms = useMemo(() => {
- return branches.map((b) => computeCylinderTransform(b.start, b.end));
+ const instancedData = useMemo(() => {
+ return branches.map((b) => {
+ const t = computeCylinderTransform(b.start, b.end);
+ const avgRadius = (b.radiusStart + b.radiusEnd) / 2;
+ return {
+ pos: t.position,
+ rot: t.quaternion,
+ scale: [avgRadius, t.length, avgRadius] as [number, number, number],
+ };
+ });
}, [branches]);
+ if (instancedData.length === 0) return null;
+
return (
-
- {branches.map((b, i) => {
- const t = transforms[i];
- return (
-
-
-
-
- );
- })}
-
+
+
+
+ {instancedData.map((d, i) => (
+
+ ))}
+
);
}
@@ -107,41 +108,54 @@ function FlowerPanicles({ positions, seed = 0 }: {
positions: Vec3[];
seed?: number;
}) {
- const panicles = useMemo(() => {
+ const { spheres, stems } = useMemo(() => {
+ const sArr: any[] = [];
+ const stArr: any[] = [];
const rng = seededRandom(seed + 200);
- // Only some tips get flowers
- return positions.filter(() => rng() > 0.6).map((pos) => ({
- pos,
- count: 10 + Math.floor(rng() * 12),
- }));
+
+ positions.forEach((pos, pi) => {
+ if (rng() > 0.6) {
+ const count = 10 + Math.floor(rng() * 12);
+
+ stArr.push({ pos });
+
+ for (let fi = 0; fi < count; fi++) {
+ const rng2 = seededRandom(pi * 100 + fi);
+ sArr.push({
+ pos: [
+ pos[0] + (rng2() - 0.5) * 0.25,
+ pos[1] + rng2() * 0.3 - 0.15,
+ pos[2] + (rng2() - 0.5) * 0.25,
+ ],
+ scale: 0.018 + rng2() * 0.012,
+ color: new THREE.Color(`hsl(${65 + rng2() * 15}, ${50 + rng2() * 20}%, ${65 + rng2() * 15}%)`)
+ });
+ }
+ }
+ });
+ return { spheres: sArr, stems: stArr };
}, [positions, seed]);
return (
- {panicles.map((pan, pi) => (
-
- {Array.from({ length: pan.count }, (_, fi) => {
- const rng2 = seededRandom(pi * 100 + fi);
- const fx = (rng2() - 0.5) * 0.25;
- const fy = rng2() * 0.3 - 0.15;
- const fz = (rng2() - 0.5) * 0.25;
- return (
-
-
-
-
- );
- })}
- {/* Panicle stem */}
-
-
-
-
-
- ))}
+ {spheres.length > 0 && (
+
+
+
+ {spheres.map((s, i) => (
+
+ ))}
+
+ )}
+ {stems.length > 0 && (
+
+
+
+ {stems.map((st, i) => (
+
+ ))}
+
+ )}
);
}
@@ -155,104 +169,96 @@ function MangoFruits({ fruitPositions, seed = 0 }: {
useFrame(({ clock }) => {
if (groupRef.current) {
- groupRef.current.rotation.z = Math.sin(clock.getElapsedTime() * 0.5 + seed) * 0.02;
+ groupRef.current.rotation.z = Math.sin(clock.getElapsedTime() * 0.3 + seed) * 0.02;
}
});
- const fruits = useMemo(() => {
- const rng = seededRandom(seed + 300);
- return fruitPositions.map((pos) => ({
- pos: [pos[0], pos[1] - 0.12 - rng() * 0.15, pos[2]] as Vec3,
- rot: [rng() * 0.3, rng() * Math.PI * 2, Math.PI + rng() * 0.4] as [number, number, number],
- scale: [
- 0.1 + rng() * 0.04,
- 0.14 + rng() * 0.05,
- 0.09 + rng() * 0.03,
- ] as [number, number, number],
- ripeness: rng(),
- }));
+ const { fruits, stems } = useMemo(() => {
+ const rng = seededRandom(seed + 400);
+ const fArr: any[] = [];
+ const stArr: any[] = [];
+
+ fruitPositions.forEach((pos) => {
+ const scale = 0.08 + rng() * 0.04;
+ const r = rng();
+ const color = new THREE.Color();
+ if (r < 0.3) {
+ color.lerpColors(new THREE.Color('#2a6a2a'), new THREE.Color('#8a9a2a'), r * 3.3);
+ } else if (r < 0.7) {
+ color.lerpColors(new THREE.Color('#8a9a2a'), new THREE.Color('#d4a017'), (r - 0.3) * 2.5);
+ } else {
+ color.lerpColors(new THREE.Color('#d4a017'), new THREE.Color('#d45017'), (r - 0.7) * 3.3);
+ }
+
+ const p: Vec3 = [pos[0], pos[1] - 0.15 - rng() * 0.1, pos[2]];
+ fArr.push({ pos: p, scale, color });
+ stArr.push({ pos: [p[0], p[1] + scale * 0.9, p[2]] });
+ });
+ return { fruits: fArr, stems: stArr };
}, [fruitPositions, seed]);
return (
- {fruits.map((fruit, i) => {
- const r = fruit.ripeness;
- const color = new THREE.Color();
- if (r < 0.5) {
- color.lerpColors(new THREE.Color('#3a7d2a'), new THREE.Color('#c4b52a'), r * 2);
- } else {
- color.lerpColors(new THREE.Color('#c4b52a'), new THREE.Color('#e8872a'), (r - 0.5) * 2);
- }
-
- return (
-
- {/* Mango body */}
-
-
-
-
-
- {/* Stem */}
-
-
-
-
-
- {/* Blush spot */}
- {fruit.ripeness > 0.4 && (
-
-
-
-
- )}
-
- );
- })}
+ {fruits.length > 0 && (
+
+
+
+ {fruits.map((f, i) => (
+
+ ))}
+
+ )}
+ {stems.length > 0 && (
+
+
+
+ {stems.map((st, i) => (
+
+ ))}
+
+ )}
);
}
-/** Root buttresses at trunk base */
-function TrunkRoots({ seed = 0 }: { seed?: number }) {
- const barkTex = getBarkTexture('#3a2e20');
+/** Base root flares for mango tree */
+function MangoRoots({ seed = 0 }: { seed?: number }) {
+ const barkTex = getBarkTexture('#4a3828');
const rootTransforms = useMemo(() => {
- const rng = seededRandom(seed + 500);
- const arr = [];
+ const rng = seededRandom(seed + 600);
const count = 4 + Math.floor(rng() * 3);
+ const arr = [];
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 + rng() * 0.5;
- const length = 0.4 + rng() * 0.3;
- const thickness = 0.06 + rng() * 0.04;
- const start: Vec3 = [0, 0.06, 0];
- const end: Vec3 = [Math.cos(angle) * length, -0.04, Math.sin(angle) * length];
- const transform = computeCylinderTransform(start, end);
- arr.push({ ...transform, thickness });
+ const length = 0.5 + rng() * 0.4;
+ const thickness = 0.05 + rng() * 0.04;
+ const start: Vec3 = [0, 0.05, 0];
+ const end: Vec3 = [
+ Math.cos(angle) * length,
+ -0.05,
+ Math.sin(angle) * length,
+ ];
+ const t = computeCylinderTransform(start, end);
+ arr.push({
+ pos: t.position,
+ rot: t.quaternion,
+ scale: [thickness * 0.3, t.length, thickness] as [number, number, number]
+ });
}
return arr;
}, [seed]);
+ if (rootTransforms.length === 0) return null;
+
return (
-
+
+
+
{rootTransforms.map((rt, i) => (
-
-
-
-
+
))}
-
+
);
}
@@ -335,17 +341,15 @@ export default function MangoTree({ x, z, groundY = 0, scale: treeScale = 1 }: M
{/* ── Root buttresses ── */}
-
+
{/* ── Branches + canopy ── */}
{/* Connected branch segments */}
- {/* Leaf clusters at branch tips */}
- {treeData.leafTipPositions.map((pos, i) => (
-
- ))}
+ {/* Aggregated Leaf clusters */}
+
{/* Flower panicles at some leaf positions */}
diff --git a/src/components/scene/OrchadCanvas.tsx b/src/components/scene/OrchadCanvas.tsx
index e4af111..b4afcc6 100644
--- a/src/components/scene/OrchadCanvas.tsx
+++ b/src/components/scene/OrchadCanvas.tsx
@@ -73,10 +73,25 @@ export default function OrchadCanvas() {
const [timeConfig, setTimeConfig] = useState(() => getTimeConfig(timeMode));
useEffect(() => {
- const update = () => setTimeConfig(getTimeConfig(timeMode));
- update();
- const interval = setInterval(update, 5000);
- return () => clearInterval(interval);
+ setTimeConfig(getTimeConfig(timeMode));
+
+ // Only set an interval if we are in auto mode (to catch hour changes)
+ let interval: ReturnType | undefined;
+ if (timeMode === 'auto') {
+ // Check every minute instead of every 5 seconds, and only update if the phase actually changed
+ interval = setInterval(() => {
+ setTimeConfig((prev) => {
+ const next = getTimeConfig('auto');
+ // Only trigger a re-render if the major phase changed (e.g. day -> sunset)
+ // We don't care about formattedTime in the Canvas
+ if (prev.resolvedPhase !== next.resolvedPhase) return next;
+ return prev;
+ });
+ }, 60000);
+ }
+ return () => {
+ if (interval) clearInterval(interval);
+ };
}, [timeMode]);
return (
diff --git a/src/components/scene/PapayaTree.tsx b/src/components/scene/PapayaTree.tsx
index f1bdd2c..5018db8 100644
--- a/src/components/scene/PapayaTree.tsx
+++ b/src/components/scene/PapayaTree.tsx
@@ -2,6 +2,7 @@
import { useFrame } from '@react-three/fiber';
import { useMemo, useRef } from 'react';
import * as THREE from 'three';
+import { Instances, Instance } from '@react-three/drei';
import {
seededRandom,
getSmoothBarkTexture,
@@ -16,127 +17,98 @@ import {
// and fruits clustered beneath the crown.
// ─────────────────────────────────────────────────────
-/** Single papaya leaf on a petiole — connected to trunk top */
-function PapayaLeaf({ index, total, trunkHeight, trunkRadius, seed }: {
- index: number;
- total: number;
+/** Leaf crown with instanced lobes for performance */
+function PapayaLeafCrown({ trunkHeight, trunkRadius, seed }: {
trunkHeight: number;
trunkRadius: number;
seed: number;
}) {
const leafTex = getLeafTexture('#1a7030', 'rgba(80, 200, 80, 0.2)', '#2a8838', 'palmate');
- const rng = useMemo(() => seededRandom(seed + index * 17), [seed, index]);
-
- const angle = useMemo(() => (index / total) * Math.PI * 2 + rng() * 0.3, [index, total, rng]);
- const petioleLength = useMemo(() => 1.0 + rng() * 0.5, [rng]);
- const leafScale = useMemo(() => 0.65 + rng() * 0.3, [rng]);
- // Droop angle — lower leaves droop more, upper ones point outward/up
- const isLowerTier = useMemo(() => rng() > 0.5, [rng]);
- const droopAngle = useMemo(
- () => (isLowerTier ? 0.7 + rng() * 0.5 : 0.3 + rng() * 0.3),
- [isLowerTier, rng],
- );
-
- const leafRef = useRef(null);
- useFrame(({ clock }) => {
- if (leafRef.current) {
- const t = clock.getElapsedTime();
- leafRef.current.rotation.z = Math.sin(t * 0.4 + index * 1.3) * 0.02;
- }
- });
-
- // Compute the petiole as start→end connected cylinder
- const petioleTip: Vec3 = useMemo(() => {
- const cosD = Math.cos(droopAngle);
- const sinD = Math.sin(droopAngle);
- return [
- Math.cos(angle) * sinD * petioleLength,
- trunkHeight - 0.1 + cosD * petioleLength * 0.3,
- Math.sin(angle) * sinD * petioleLength,
- ];
- }, [angle, droopAngle, petioleLength, trunkHeight]);
+
+ const leafCount = useMemo(() => {
+ const rng = seededRandom(seed + 300);
+ return 10 + Math.floor(rng() * 5);
+ }, [seed]);
- const petioleStart: Vec3 = useMemo(() => [
- Math.cos(angle) * trunkRadius * 0.8,
- trunkHeight - 0.1,
- Math.sin(angle) * trunkRadius * 0.8,
- ], [angle, trunkRadius, trunkHeight]);
+ const { petioles, lobes } = useMemo(() => {
+ const pArr: any[] = [];
+ const lArr: any[] = [];
+
+ for (let i = 0; i < leafCount; i++) {
+ const rng = seededRandom(seed + i * 17);
+ const angle = (i / leafCount) * Math.PI * 2 + rng() * 0.3;
+ const petioleLength = 1.0 + rng() * 0.5;
+ const leafScale = 0.65 + rng() * 0.3;
+ const isLowerTier = rng() > 0.5;
+ const droopAngle = isLowerTier ? 0.7 + rng() * 0.5 : 0.3 + rng() * 0.3;
- const petioleTransform = useMemo(
- () => computeCylinderTransform(petioleStart, petioleTip),
- [petioleStart, petioleTip],
- );
+ const cosD = Math.cos(droopAngle);
+ const sinD = Math.sin(droopAngle);
+
+ const petioleTip: Vec3 = [
+ Math.cos(angle) * sinD * petioleLength,
+ trunkHeight - 0.1 + cosD * petioleLength * 0.3,
+ Math.sin(angle) * sinD * petioleLength,
+ ];
- return (
-
- {/* Petiole — properly connected cylinder from trunk to tip */}
-
-
-
-
+ const petioleStart: Vec3 = [
+ Math.cos(angle) * trunkRadius * 0.8,
+ trunkHeight - 0.1,
+ Math.sin(angle) * trunkRadius * 0.8,
+ ];
- {/* Leaf blade lobes — attached at petiole tip */}
-
- {Array.from({ length: 7 }, (_, li) => {
- const lobeRng = seededRandom(seed + index * 100 + li);
- const lobeAngle = ((li - 3) / 3) * 0.7;
- const lobeDir = angle + lobeAngle;
- const lobeTilt = droopAngle * 0.3 + lobeRng() * 0.2;
- return (
-
-
-
-
- );
- })}
-
-
- );
-}
+ const petioleTransform = computeCylinderTransform(petioleStart, petioleTip);
+ pArr.push({ ...petioleTransform, index: i });
-/** Leaf crown */
-function PapayaLeafCrown({ trunkHeight, trunkRadius, seed }: {
- trunkHeight: number;
- trunkRadius: number;
- seed: number;
-}) {
- const leafCount = useMemo(() => {
- const rng = seededRandom(seed + 300);
- return 10 + Math.floor(rng() * 5);
- }, [seed]);
+ for (let li = 0; li < 7; li++) {
+ const lobeRng = seededRandom(seed + i * 100 + li);
+ const lobeAngle = ((li - 3) / 3) * 0.7;
+ const lobeDir = angle + lobeAngle;
+ const lobeTilt = droopAngle * 0.3 + lobeRng() * 0.2;
+
+ lArr.push({
+ pos: [
+ petioleTip[0] + Math.cos(lobeDir) * 0.1,
+ petioleTip[1] - 0.02 * li * 0.3,
+ petioleTip[2] + Math.sin(lobeDir) * 0.1,
+ ],
+ rot: [lobeTilt, lobeDir + Math.PI / 2, lobeRng() * 0.15],
+ scale: leafScale * (0.75 + lobeRng() * 0.3),
+ });
+ }
+ }
+ return { petioles: pArr, lobes: lArr };
+ }, [leafCount, trunkHeight, trunkRadius, seed]);
return (
- {Array.from({ length: leafCount }, (_, i) => (
-
- ))}
+ {petioles.length > 0 && (
+
+
+
+ {petioles.map((p, i) => (
+
+ ))}
+
+ )}
+
+ {lobes.length > 0 && (
+
+
+
+ {lobes.map((lobe, i) => (
+
+ ))}
+
+ )}
);
}
@@ -155,60 +127,59 @@ function PapayaFruits({ trunkHeight, trunkRadius, seed = 0 }: {
}
});
- const fruits = useMemo(() => {
+ const { fruits, stems } = useMemo(() => {
const rng = seededRandom(seed + 500);
- const arr: {
- pos: Vec3;
- rot: [number, number, number];
- scaleX: number;
- scaleY: number;
- ripeness: number;
- }[] = [];
+ const fArr: any[] = [];
+ const stArr: any[] = [];
const numFruits = 4 + Math.floor(rng() * 4);
for (let i = 0; i < numFruits; i++) {
const angle = (i / numFruits) * Math.PI * 2 + rng() * 0.5;
const dist = trunkRadius * 1.1 + rng() * 0.05;
- arr.push({
- pos: [
- Math.cos(angle) * dist,
- trunkHeight * 0.82 - rng() * 0.35,
- Math.sin(angle) * dist,
- ],
- rot: [rng() * 0.15, rng() * Math.PI * 2, 0],
- scaleX: 0.07 + rng() * 0.03,
- scaleY: 0.11 + rng() * 0.05,
- ripeness: rng(),
- });
+ const scaleX = 0.07 + rng() * 0.03;
+ const scaleY = 0.11 + rng() * 0.05;
+ const r = rng();
+
+ const color = new THREE.Color();
+ if (r < 0.4) {
+ color.lerpColors(new THREE.Color('#3a8a2a'), new THREE.Color('#8ac44a'), r * 2.5);
+ } else {
+ color.lerpColors(new THREE.Color('#e8a020'), new THREE.Color('#f97316'), (r - 0.4) * 1.67);
+ }
+
+ const p: Vec3 = [
+ Math.cos(angle) * dist,
+ trunkHeight * 0.82 - rng() * 0.35,
+ Math.sin(angle) * dist,
+ ];
+ const rot: [number, number, number] = [rng() * 0.15, rng() * Math.PI * 2, 0];
+
+ fArr.push({ pos: p, rot, scale: [scaleX, scaleY, scaleX], color });
+ stArr.push({ pos: [p[0], p[1] + scaleY * 0.85, p[2]], rot });
}
- return arr;
+ return { fruits: fArr, stems: stArr };
}, [trunkHeight, trunkRadius, seed]);
return (
- {fruits.map((fruit, i) => {
- const r = fruit.ripeness;
- const color = new THREE.Color();
- if (r < 0.4) {
- color.lerpColors(new THREE.Color('#3a8a2a'), new THREE.Color('#8ac44a'), r * 2.5);
- } else {
- color.lerpColors(new THREE.Color('#e8a020'), new THREE.Color('#f97316'), (r - 0.4) * 1.67);
- }
-
- return (
-
-
-
-
-
- {/* Short stem connecting fruit to trunk */}
-
-
-
-
-
- );
- })}
+ {fruits.length > 0 && (
+
+
+
+ {fruits.map((f, i) => (
+
+ ))}
+
+ )}
+ {stems.length > 0 && (
+
+
+
+ {stems.map((st, i) => (
+
+ ))}
+
+ )}
);
}
@@ -221,40 +192,33 @@ function TrunkScars({ trunkHeight, trunkRadius, seed }: {
}) {
const scars = useMemo(() => {
const rng = seededRandom(seed + 800);
- const arr: { y: number; angle: number }[] = [];
+ const arr: { pos: Vec3; rot: [number, number, number] }[] = [];
const count = 12 + Math.floor(rng() * 8);
for (let i = 0; i < count; i++) {
+ const y = 0.3 + rng() * (trunkHeight * 0.85);
+ const angle = rng() * Math.PI * 2;
arr.push({
- y: 0.3 + rng() * (trunkHeight * 0.85),
- angle: rng() * Math.PI * 2,
+ pos: [
+ Math.cos(angle) * trunkRadius * 1.01,
+ y,
+ Math.sin(angle) * trunkRadius * 1.01,
+ ],
+ rot: [0, -angle + Math.PI / 2, 0],
});
}
return arr;
- }, [trunkHeight, seed]);
+ }, [trunkHeight, trunkRadius, seed]);
+
+ if (scars.length === 0) return null;
return (
-
+
+
+
{scars.map((scar, i) => (
-
-
-
-
+
))}
-
+
);
}
diff --git a/src/components/scene/Terrain.tsx b/src/components/scene/Terrain.tsx
index 5e56e84..520d3c4 100644
--- a/src/components/scene/Terrain.tsx
+++ b/src/components/scene/Terrain.tsx
@@ -7,7 +7,7 @@ export default function Terrain() {
const S = TERRAIN_SIZE;
// Generate the rolling hills geometry dynamically using our noise function
- const hillsGeometry = useMemo(() => {
+ const visualGeometry = useMemo(() => {
const geo = new THREE.PlaneGeometry(S, S, TERRAIN_SEGMENTS, TERRAIN_SEGMENTS);
geo.rotateX(-Math.PI / 2); // align plane with the ground plane (XZ)
@@ -23,18 +23,36 @@ export default function Terrain() {
return geo;
}, [S]);
+ // Generate a much lower resolution geometry for physics to prevent sluggishness
+ const physicsGeometry = useMemo(() => {
+ const geo = new THREE.PlaneGeometry(S, S, 16, 16);
+ geo.rotateX(-Math.PI / 2);
+ const posAttr = geo.attributes.position;
+ for (let i = 0; i < posAttr.count; i++) {
+ const vx = posAttr.getX(i);
+ const vz = posAttr.getZ(i);
+ const vy = getTerrainHeight(vx, vz);
+ posAttr.setY(i, vy);
+ }
+ return geo;
+ }, [S]);
+
return (
- {/* ── Main rolling hills (physics trimesh + visual mesh) ── */}
+ {/* ── Main rolling hills ── */}
+ {/* Visual mesh (high poly) */}
+
+
+
+
+ {/* Physics mesh (low poly) */}
-
-
-
+
{/* ── Boundary wall slabs (solid, invisible physics boundaries) ── */}
diff --git a/src/components/scene/Vehicle.tsx b/src/components/scene/Vehicle.tsx
index 2b2686a..861c9fc 100644
--- a/src/components/scene/Vehicle.tsx
+++ b/src/components/scene/Vehicle.tsx
@@ -9,14 +9,14 @@ import { TREE_PLACEMENTS } from '../../lib/products';
import { getTerrainHeight } from '../../lib/terrain';
import { useOrchardStore } from '../../store/useOrchardStore';
-const ACCEL = 38; // speed units per second forward
+const ACCEL = 42; // speed units per second forward
const BRAKE = 42; // braking deceleration per second
-const REVERSE_ACCEL = 14; // reverse acceleration speed per second
+const REVERSE_ACCEL = 18; // reverse acceleration speed per second
const DECEL = 15; // passive coasting decay per second
-const MAX_SPEED = 15; // max forward m/s
+const MAX_SPEED = 20 ; // max forward m/s
const MAX_REV_SPEED = 5.5; // max reverse m/s
const STEER_VEL = 2.1; // steer speed rad/s
-const HARVEST_RADIUS = 14;
+const HARVEST_RADIUS = 12;
// Pre-allocated vectors — never create new ones in useFrame
const _forward = new THREE.Vector3();
@@ -164,8 +164,8 @@ export default function Vehicle({ headlightsIntensity = 8, isNight = false }: Ve
// ── 1. ENGINE ACCELERATION AND BRAKING (WITH HILL RESISTANCE) ───────────
if (keys.forward) {
// Accelerate forward, fought slightly by gravity but with a high guaranteed minimum engine power
- const activeAccel = Math.max(14.0, ACCEL - hillGravityAccel * 0.8);
- const uphillSpeedCap = Math.max(10.5, MAX_SPEED - hillGravityAccel * 0.4);
+ const activeAccel = Math.max(16.0, ACCEL - hillGravityAccel * 0.8);
+ const uphillSpeedCap = Math.max(12.5, MAX_SPEED - hillGravityAccel * 0.4);
currentFwdSpeed = Math.min(
currentFwdSpeed + activeAccel * delta,
uphillSpeedCap
@@ -206,15 +206,18 @@ export default function Vehicle({ headlightsIntensity = 8, isNight = false }: Ve
// Apply direct linear velocity (retaining gravity Y)
body.setLinvel({ x: targetVelX, y: vel.y, z: targetVelZ }, true);
- // ── 2. STEERING — set angular velocity DIRECTLY (bypasses damping fight)
+ // ── 2. STEERING — Smooth angular velocity
{
- let yAngVel = 0;
+ let targetYAngVel = 0;
if (Math.abs(currentFwdSpeed) > 0.1) {
const steerSign = currentFwdSpeed >= 0 ? 1 : -1; // flip steer direction when reversing
- if (keys.left) yAngVel = STEER_VEL * steerSign;
- if (keys.right) yAngVel = -STEER_VEL * steerSign;
+ if (keys.left) targetYAngVel = STEER_VEL * steerSign;
+ if (keys.right) targetYAngVel = -STEER_VEL * steerSign;
}
- body.setAngvel({ x: 0, y: yAngVel, z: 0 }, true);
+ // Lerp angular velocity to remove the instant "sloppy/snappy" feel
+ const currentAngVel = body.angvel();
+ const newYAngVel = currentAngVel.y + (targetYAngVel - currentAngVel.y) * Math.min(delta * 12, 1);
+ body.setAngvel({ x: 0, y: newYAngVel, z: 0 }, true);
}
// Snap visual chassis to road/ground height under the car so it follows terrain and road surface
@@ -302,10 +305,9 @@ export default function Vehicle({ headlightsIntensity = 8, isNight = false }: Ve
ref={chassisRef}
position={[0, 3, 0]} // spawn well above ground, settles naturally
linearDamping={0.5}
- angularDamping={0}
+ angularDamping={0.5}
mass={80}
colliders={false}
- ccd // continuous collision detection — prevents tunneling
>