All files / renderer/village FishSchool.tsx

0% Statements 0/61
0% Branches 0/1
0% Functions 0/1
0% Lines 0/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97                                                                                                                                                                                                 
/* eslint-disable react/no-unknown-property -- react-three-fiber extends JSX with three.js props */
import { useMemo, useRef } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { FISH_PATHS, FISH_COLORS, fishPositionAt, type FishPath } from "./fish";
 
/**
 * Renders a school of ~22 fish swimming along slow circular paths under
 * the water surface. The whole group's `visible` property is toggled
 * each frame based on whether the camera is below the water line, so
 * draw calls are skipped when the camera is above water but we avoid
 * React mount/unmount churn at the threshold crossing.
 *
 * Implementation choice: we use `three.Object3D.visible = false` rather
 * than conditional React rendering. Toggling `visible` skips the render
 * subtree at the Three.js level (no draw calls, no frustum traversal
 * for invisible descendants) while keeping state stable - simpler and
 * cheaper than `useState` + re-render on every threshold crossing.
 */
 
/** Camera y threshold - below this, the camera is "underwater". */
const UNDERWATER_Y = -0.2;
 
export function FishSchool() {
  const rootRef = useRef<THREE.Group>(null);
  const fishGroupsRef = useRef<Array<THREE.Group | null>>([]);
  const { camera } = useThree();
  const lookTarget = useMemo(() => new THREE.Vector3(), []);
 
  useFrame((state) => {
    const root = rootRef.current;
    if (!root) return;
    // Gate rendering via visibility. Cheap: single float compare per frame.
    const under = camera.position.y < UNDERWATER_Y;
    if (root.visible !== under) root.visible = under;
    if (!under) return;
 
    const t = state.clock.elapsedTime;
    for (let i = 0; i < FISH_PATHS.length; i++) {
      const path = FISH_PATHS[i]!;
      const g = fishGroupsRef.current[i];
      if (!g) continue;
      const { position, tangent } = fishPositionAt(path, t);
      g.position.set(position[0], position[1], position[2]);
      lookTarget.set(position[0] + tangent[0], position[1], position[2] + tangent[2]);
      g.lookAt(lookTarget);
    }
  });
 
  return (
    <group ref={rootRef} visible={false}>
      {FISH_PATHS.map((path, i) => (
        <group
          key={i}
          ref={(el) => {
            fishGroupsRef.current[i] = el;
          }}
        >
          <Fish path={path} />
        </group>
      ))}
    </group>
  );
}
 
function Fish({ path }: { path: FishPath }) {
  const color = FISH_COLORS[path.colorIndex] ?? FISH_COLORS[0]!;
  const s = path.scale;
  // Body length is along +Z (so lookAt aligns the fish with travel).
  const tailGeometry = useMemo(() => {
    const geo = new THREE.BufferGeometry();
    // Triangle in the XY plane at the back of the body.
    const positions = new Float32Array([
      0, 0, 0, -0.15, 0.12, 0, -0.15, -0.12, 0,
      // backface
      0, 0, 0, -0.15, -0.12, 0, -0.15, 0.12, 0
    ]);
    geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
    geo.computeVertexNormals();
    return geo;
  }, []);
 
  return (
    <group scale={s}>
      {/* Body - a flattened box. */}
      <mesh castShadow>
        <boxGeometry args={[0.25, 0.12, 0.4]} />
        <meshStandardMaterial color={color} roughness={0.6} />
      </mesh>
      {/* Tail - triangle at the back (local -z end). */}
      <mesh geometry={tailGeometry} position={[0, 0, -0.2]} rotation={[0, -Math.PI / 2, 0]}>
        <meshStandardMaterial color={color} roughness={0.7} side={THREE.DoubleSide} />
      </mesh>
    </group>
  );
}