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.

Hi @Sameer_Hameed ,

Thanks for your kind words! Yes, I used a JPEG image placed inside a sphere geometry to create the background for my Three.js scene. The key idea is to wrap a large sphere around the scene with its normals inverted, so the texture appears from the inside.

Here’s a quick breakdown of how I implemented it:

  1. Geometry and Material:
  • I created a SphereGeometry with a large radius to encompass the entire scene.
  • To ensure the texture is visible from the inside of the sphere, I used THREE.BackSide for the material’s side property.
  1. Texture Mapping:
  • The background image is applied as a texture using the useTexture hook from @react-three/drei. This allows easy loading and mapping of the JPEG file.
  1. Dynamic Positioning:
  • To keep the background stationary relative to the camera, I set up a useFrame loop that continuously updates the sphere’s position to match the camera’s position.

Code Snippet:

import { useThree, useFrame } from '@react-three/fiber';
import { useTexture } from '@react-three/drei';
import * as THREE from 'three';
import React, { useRef } from 'react';

function Background() {
  const texture = useTexture('./path-to-your-image.jpeg'); // Replace with your image path
  const { camera } = useThree();
  const backgroundRef = useRef();

  useFrame(() => {
    if (backgroundRef.current) {
      // Update the position of the sphere to match the camera's position
      backgroundRef.current.position.copy(camera.position);
    }
  });

  return (
    <mesh ref={backgroundRef}>
      {/* Large sphere geometry to enclose the scene */}
      <sphereGeometry args={[500, 60, 40]} />
      <meshBasicMaterial
        map={texture}
        side={THREE.BackSide} // Invert the normals for an inside-out sphere
      />
    </mesh>
  );
}

export default Background;

Resources:

  1. GitHub - pmndrs/drei: 🥉 useful helpers for react-three-fiber for the useTexture utility
  2. Three.js documentation on materials and sphere geometry
  3. Introduction - React Three Fiber