Struggling to Keep 3D Model Centered During Camera Movements

Hi everyone,

I’m working on a 3D scene using React Three Fiber and @react-three/drei, where I have a bike model displayed. I’m facing an issue where the bike model moves out of view when I pan or move the camera. I want the bike model to stay centered and the background to remain stationary relative to the camera.

Problem:

Despite using CameraControls from @react-three/drei and ensuring the background follows the camera’s position, the bike model still shifts out of place when interacting with the scene. I need help to properly constrain the camera movements or any other solution to keep the bike model centered.

import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Canvas, useFrame, useThree , } from '@react-three/fiber';
import { Center, Environment, PerspectiveCamera, CameraControls, Html,Caustics , useTexture  } from '@react-three/drei';
import { EffectComposer, Bloom, ToneMapping } from '@react-three/postprocessing';
import * as THREE from 'three';
import { easing } from 'maath';
import { Bike5 } from './Bike5';
import { DefaultTank, Tank1, Tank2 } from './TankComponents';
import './App.css';
import Loading from './Loading';
import BikeColorSidebar from './BikeColorSidebar';
import { ToneMappingMode } from 'postprocessing';


function GlowingRing() {
  const ringRef = useRef();
  const lightRef = useRef();
  const materialRef = useRef();

  useFrame((state) => {
    const t = (1 + Math.sin(state.clock.elapsedTime * 2)) / 2;
    if (materialRef.current) {
      materialRef.current.emissiveIntensity = 4.2;
    }
    if (lightRef.current) {
      lightRef.current.intensity = 1 + t * 4;
    }
  });



  return (
    <group rotation={[-Math.PI / 2, 0, 0]} position={[0, -5, 0]}>
      <mesh ref={ringRef}>
        <ringGeometry args={[12.6, 12.8, 500]} />
        <meshStandardMaterial
          ref={materialRef}
          emissive={new THREE.Color("#0000ff")}
          emissiveIntensity={1}
          toneMapped={false}
        />
      </mesh>
      <pointLight ref={lightRef} color={[1, 0.1, 0.1]} intensity={1} distance={5} />
    </group>
  );
}




function GlowingBlub() {
  const blubRef = useRef();
  const lightRef = useRef();
  const materialRef = useRef();

  useFrame((state) => {
    const t = (1 + Math.sin(state.clock.elapsedTime * 2)) / 2;
    if (materialRef.current) {
      materialRef.current.emissiveIntensity = 4.2;
    }
    if (lightRef.current) {
      lightRef.current.intensity = 1 + t * 4;
    }
  });

  return (
    <group rotation={[-Math.PI / 2, 0, 0]} position={[0, 5, 0]}>
      <mesh ref={blubRef}>
        <ringGeometry args={[1.6, 1.8, 500]} />
        <meshStandardMaterial
          ref={materialRef}
          emissive={new THREE.Color("white")}
          emissiveIntensity={1}
          toneMapped={false}
        />
      </mesh>
    </group>
  );
}

function Background() {
  const texture = useTexture("./AdobeStock_427329591.jpeg"); // Replace with your background image path

  return (
    <mesh scale={[-1, 1, 1]}>
      <sphereGeometry args={[500, 60, 40]} />
      <meshBasicMaterial map={texture} side={THREE.BackSide} />
    </mesh>
  );
}


function CameraLogger() {
  const { camera } = useThree();

  useFrame(() => {
    console.log('Camera position:', camera.position);
    console.log('Camera rotation:', camera.rotation);
  });

  return null;
}

function App() {
  const [color, setColor] = useState(null);
  const [resetToDefault, setResetToDefault] = useState(false);
  const [selectedBikeColor, setSelectedBikeColor] = useState('default');
  const [selectedTank, setSelectedTank] = useState('default');
  const [loading, setLoading] = useState(true);
  const [totalCost, setTotalCost] = useState(5000); // Base cost of the bike
  const cameraControlsRef = useRef();

  useEffect(() => {
    const timer = setTimeout(() => setLoading(false), 1000); // Simulated loading
    return () => clearTimeout(timer);
  }, []);

  useEffect(() => {
    // Calculate total cost
    let cost = 5000; // Base cost
    if (color) cost += 400; // Add 400 for custom color
    if (selectedTank !== 'default') cost += 200; // Add 200 for custom tank
    setTotalCost(cost);
  }, [color, selectedTank]);

  const handleColorChange = useCallback((newColor) => {
    setResetToDefault(false);
    setColor(newColor);
    setSelectedBikeColor('default');
    cameraControlsRef.current?.setLookAt(8, 0, 19, 0, 0, 0, true);
  }, []);

  const handleReset = useCallback(() => {
    setResetToDefault(true);
    setSelectedBikeColor('default');
    setSelectedTank('default');
    setColor(null);
    cameraControlsRef.current?.setLookAt(8, 0, 19, 0, 0, 0, true);
  }, []);

  const handleBikeColorChange = useCallback((bikeColor) => {
    setResetToDefault(false);
    setSelectedBikeColor(bikeColor);
    setColor(null);
    cameraControlsRef.current?.setLookAt(8, 0, 19, 0, 0, 0, true);
  }, []);

  const handleTankChange = useCallback((tankOption) => {
    setSelectedTank(tankOption);
    cameraControlsRef.current?.setLookAt(6, 1, 1, 0, 0, 0, true);
  }, []);

  const getTankComponent = useCallback(() => {
    switch (selectedTank) {
      case 'tank1':
        return Tank1;
      case 'tank2':
        return Tank2;
      default:
        return DefaultTank;
    }
  }, [selectedTank]);

  return (
    <div className="app-container">
      <div className={`canvas-container ${loading ? 'hidden' : ''}`}>
        {loading && <Loading />}
        <Canvas shadows>
          <PerspectiveCamera makeDefault position={[0, 0, 0]} fov={40} />
          <CameraControls 
            ref={cameraControlsRef} 
            minPolarAngle={Math.PI / 2.7}
            maxPolarAngle={Math.PI / 2}
            minDistance={4.5}
            maxDistance={20}
          />
          
          <color attach="background" args={['#15151a']} />
          <ambientLight intensity={0.2} />
          <directionalLight
            position={[5, 5, 5]}
            intensity={0.5}
            castShadow
            shadow-mapSize-width={1024}
            shadow-mapSize-height={1024}
            shadow-camera-far={50}
            shadow-camera-left={-10}
            shadow-camera-right={10}
            shadow-camera-top={10}
            shadow-camera-bottom={-10}
          />
          <Center top position={[0, -8, 0]} scale={1.05} castShadow>
     
          <Bike5 
              color={color} 
              resetToDefault={resetToDefault} 
              selectedBikeColor={selectedBikeColor} 
              TankComponent={getTankComponent()}
            />
          </Center>
        
          <GlowingRing />
          <mesh
            rotation={[-Math.PI / 2, 0, 0]}
            position={[2, -7.8, 0]}
            receiveShadow
          >
            <planeGeometry args={[30, 30]} />
            <shadowMaterial opacity={0.8} />
          </mesh>
          <Environment preset='city'   />
          <Background/>
          <EffectComposer>
            <Bloom luminanceThreshold={1.5} mipmapBlur intensity={0.5} mode={ToneMappingMode.ACES_FILMIC}/>
            <ToneMapping />
          </EffectComposer>
          <CameraLogger />
          <Html>
           
          </Html>
        </Canvas>
        
      </div>
      <div className="cost-overlay">
              Total Cost: ${totalCost}
            </div>
      <BikeColorSidebar
        handleBikeColorChange={handleBikeColorChange}
        handleColorChange={handleColorChange}
        handleTankChange={handleTankChange}
        handleReset={handleReset}
        currentColor={color}
        currentBikeColor={selectedBikeColor}
        currentTank={selectedTank}
      />
 

    </div>
  );
}

export default App;

######
Bike.js:
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { useGLTF } from '@react-three/drei';
import * as THREE from 'three';
import { TextureLoader } from 'three';
import { useLoader } from '@react-three/fiber';

export function Bike5({ color, resetToDefault, selectedBikeColor, TankComponent }) {
  const { nodes, materials } = useGLTF('/Bike5/Bike5-transformed.glb');

  // Load all textures at the top level
  const metalPlatesMap = useLoader(TextureLoader, './Textures/Metal/MetalPlates007_4K_Color.png');
  const metalPlatesNormalMap = useLoader(TextureLoader, './Textures/Metal/MetalPlates007_4K_NormalGL.png');
  const metalPlatesDisplacementMap = useLoader(TextureLoader, './Textures/Metal/MetalPlates007_4K_Displacement.png');
  const metalPlatesMetalnessMap = useLoader(TextureLoader, './Textures/Metal/MetalPlates007_4K_Metalness.png');
  const metalPlatesRoughnessMap = useLoader(TextureLoader, './Textures/Metal/MetalPlates007_4K_Roughness.png');

  const metal007Map = useLoader(TextureLoader, './Textures/Metal2/Metal007_4K_Color.png');
  const metal007NormalMap = useLoader(TextureLoader, './Textures/Metal2/Metal007_4K_NormalGL.png');
  const metal007DisplacementMap = useLoader(TextureLoader, './Textures/Metal2/Metal007_4K_Displacement.png');
  const metal007MetalnessMap = useLoader(TextureLoader, './Textures/Metal2/Metal007_4K_Metalness.png');
  const metal007RoughnessMap = useLoader(TextureLoader, './Textures/Metal2/Metal007_4K_Roughness.png');

  const marble009Map = useLoader(TextureLoader, './Textures/Metal3/Marble009_4K_Color.png');
  const marble009NormalMap = useLoader(TextureLoader, './Textures/Metal3/Marble009_4K_NormalGL.png');
  const marble009DisplacementMap = useLoader(TextureLoader, './Textures/Metal3/Marble009_4K_Displacement.png');
  const marble009RoughnessMap = useLoader(TextureLoader, './Textures/Metal3/Marble009_4K_Roughness.png');

  const leatherSeatMap = useLoader(TextureLoader, './Textures/Seat2/Leather005_4K_Color.png');
  const leatherSeatNormalMap = useLoader(TextureLoader, './Textures/Seat2/Leather005_4K_NormalGL.png');
  const leatherSeatDisplacementMap = useLoader(TextureLoader, './Textures/Seat2/Leather005_4K_Displacement.png');
  const leatherSeatRoughnessMap = useLoader(TextureLoader, './Textures/Seat2/Leather005_4K_Roughness.png');

  const bikeTextures = useMemo(() => ({
    metalPlates: {
      map: metalPlatesMap,
      normalMap: metalPlatesNormalMap,
      displacementMap: metalPlatesDisplacementMap,
      metalnessMap: metalPlatesMetalnessMap,
      roughnessMap: metalPlatesRoughnessMap,
    },
    metal007: {
      map: metal007Map,
      normalMap: metal007NormalMap,
      displacementMap: metal007DisplacementMap,
      metalnessMap: metal007MetalnessMap,
      roughnessMap: metal007RoughnessMap,
    },
    marble009: {
      map: marble009Map,
      normalMap: marble009NormalMap,
      displacementMap: marble009DisplacementMap,
      roughnessMap: marble009RoughnessMap,
    },
    leatherSeat: {
      map: leatherSeatMap,
      normalMap: leatherSeatNormalMap,
      displacementMap: leatherSeatDisplacementMap,
      roughnessMap: leatherSeatRoughnessMap,
    },
  }), [
    metalPlatesMap, metalPlatesNormalMap, metalPlatesDisplacementMap, metalPlatesMetalnessMap, metalPlatesRoughnessMap,
    metal007Map, metal007NormalMap, metal007DisplacementMap, metal007MetalnessMap, metal007RoughnessMap,
    marble009Map, marble009NormalMap, marble009DisplacementMap, marble009RoughnessMap,
    leatherSeatMap, leatherSeatNormalMap, leatherSeatDisplacementMap, leatherSeatRoughnessMap
  ]);      

  const getRandomTexture = useCallback(() => {
    const textures = Object.values(bikeTextures);
    return textures[Math.floor(Math.random() * textures.length)];
  }, [bikeTextures]);

  const [currentTextures, setCurrentTextures] = useState({
    frame: bikeTextures.metalPlates,
    panels: bikeTextures.metal007,
    accents: bikeTextures.marble009,
    seat: bikeTextures.leatherSeat,
  });

  useEffect(() => {
    if (resetToDefault) {
      setCurrentTextures({
        frame: bikeTextures.metalPlates,
        panels: bikeTextures.metal007,
        accents: bikeTextures.marble009,
        seat: bikeTextures.leatherSeat,
      });
    } else {
      switch (selectedBikeColor) {
        case 'BikeColor1':
        case 'BikeColor2':
        case 'BikeColor3':
          setCurrentTextures({
            frame: getRandomTexture(),
            panels: getRandomTexture(),
            accents: getRandomTexture(),
            seat: bikeTextures.leatherSeat,
          });
          break;
        default:
          setCurrentTextures(prevTextures => ({ ...prevTextures }));
          break;
      }
    }
  }, [selectedBikeColor, resetToDefault, bikeTextures, getRandomTexture]);

  const createMaterial = (textures) => {
    return new THREE.MeshStandardMaterial({
      ...textures,
      displacementScale: 0.05,
      color: color || undefined
    });
  };

  return (
    <group dispose={null} castShadow>
      <mesh
        castShadow
        receiveShadow
        geometry={nodes.Chain_transmission.geometry}
        material={createMaterial(currentTextures.frame)}
        position={[11.371, 0.891, -10.938]}
        rotation={[2.896, 0.008, -Math.PI]}
        scale={0.01}
      />
      <mesh
        castShadow
        receiveShadow
        geometry={nodes.FRONT_disc_.geometry}
        material={createMaterial(currentTextures.panels)}
        position={[10.8, 0.802, 12.375]}
        rotation={[0, 1.566, 0]}
        scale={0.01}
      />
      <TankComponent
        material={createMaterial(currentTextures.accents)}
        color={color}
        textures={currentTextures}
      />
      <mesh
        castShadow
        receiveShadow
        geometry={nodes.front_headlamp_for_blender.geometry}
        material={createMaterial(currentTextures.panels)}
        position={[10.133, -1.186, -3.42]}
        rotation={[-Math.PI, 1.562, -Math.PI]}
        scale={0.1}
      />
      <mesh
        castShadow
        receiveShadow
        geometry={nodes.front_headlamp_for_blender001.geometry}
        material={materials['Cool glass']}
        position={[10.133, -1.186, -3.42]}
        rotation={[-Math.PI, 1.562, -Math.PI]}
        scale={0.1}
      />
      <mesh
        castShadow
        receiveShadow
        geometry={nodes.HEAD_LIGHT_MOUNT_75_mm.geometry}
        material={createMaterial(currentTextures.frame)}
        position={[9.347, 5.994, -0.528]}
        rotation={[1.067, 0.036, 1.497]}
        scale={0.01}
      />
      <mesh
        castShadow
        receiveShadow
        geometry={nodes.FRONT_WHEEL.geometry}
        material={materials.Tyre}
        position={[9.537, -1.555, 5.303]}
        scale={0.01}
      />

      <mesh
        castShadow
        receiveShadow
        geometry={nodes.seat3.geometry}
        material={new THREE.MeshStandardMaterial({
          ...currentTextures.seat,
          displacementScale: 0.05,
          roughness: 0.9,
          metalness: 0.1,
          color: color || undefined
        })}
        position={[9.233, 7.211, -4.904]}
        rotation={[1.59, 0.014, -1.567]}
        scale={0.01}
      />
      <mesh
        castShadow
        receiveShadow
        geometry={nodes.REAR_WHEEL001.geometry}
        material={materials.Tyre}
        position={[9.2, -1.885, -7.132]}
        rotation={[0, -0.012, 0]}
        scale={0.01}
      />
      <mesh
        castShadow
        receiveShadow
        geometry={nodes.REAR_WHEEL003.geometry}
        material={materials.Tyre}
        position={[9.2, -1.885, -7.132]}
        rotation={[0, -0.012, 0]}
        scale={0.01}
      />
    </group>
  );
}

useGLTF.preload('/Bike5/Bike5-transformed.glb');

export default Bike5;

I want something similar to this https://makeityours.royalenfield.com/configurator/shotgun-650

but this what I get



according to GitHub - yomotsu/camera-controls: A camera control for three.js, similar to THREE.OrbitControls yet supports smooth transitions and more features. it is probably setOrbitPoint that you are looking for

panning will shift the orbit point for certain, i’m not sure how you want to reconcile that. if you pan the bike to the far right for instance, how can it be centered if you’ve just uncentered it that way?

ps, loading is inbuilt in react w/ suspense. you don’t need “setLoading”

<Suspense fallback={<Loading />}>
  <Canvas>...</Canvas>
</Suspense>

and this is a major mistake as well since it will waterfall and cause performance issues in threejs

better use drei/useTexture and do this:

const [leatherSeatMap, leatherSeatNormalMap, ...] = useTexture(['./Textures/Seat2/Leather005_4K_Color.png', './Textures/Seat2/Leather005_4K_NormalGL.png', ...])

all textures will load in parallel. you can also prefetch them via useTexture.preload(url). useTexture will also pre-emptively upload textures to the gpu. three would otherwise only do it when it “sees” the model for the first time, which creates massive runtime jank.

thank you I will check and update here.

:+1:sure mate, I will try it

@POOVENDHAN_KIDDO May i know how you setup the background for your threejs scene? Its an jpeg image placed in Sphere geometry ? Any resource you can point me towards ?
It looks really nice, curious how you managed to implement it.