Search code examples
reactjsthree.jsreact-three-fiber

Threejs Phone rotate on scroll with react-three/fiber


I've created this threejs model in react-three/fiber

enter image description here

enter image description here

https://codesandbox.io/p/sandbox/r3f-scroll-rig-sticky-box-forked-7nkhpn?file=%2Fsrc%2FModel.js%3A9%2C30

https://7nkhpn.csb.app/

import React, { Suspense, useRef } from "react";
import { Canvas } from "@react-three/fiber";
import { Environment } from "@react-three/drei";
import { OrbitControls, Stage } from "@react-three/drei";
import Model from "./Model";

export default function App() {
  const ref = useRef();
  const overlay = useRef();
  const caption = useRef();
  const scroll = useRef(0);
  return (
    <>
      <Canvas
        shadows
        eventSource={document.getElementById("root")}
        eventPrefix="client"
      >
        <ambientLight intensity={1} />
        <Suspense fallback={null}>
          <Model scroll={scroll} />
          <Environment preset="city" />
        </Suspense>
        <OrbitControls ref={ref} autoRotate />
      </Canvas>
    </>
  );
}

I want to get it to have it rotate on user scroll like this stickybox demo https://codesandbox.io/p/sandbox/r3f-scroll-rig-sticky-box-w5v4u7

function SpinningBox({ scale, scrollState, inViewport }) {
  const box = useRef()
  const size = scale.xy.min() * 0.5

  useFrame(() => {
    box.current.rotation.y = scrollState.progress * Math.PI * 2
  })

  const spring = useSpring({
    scale: inViewport ? size : size * 0.0,
    config: inViewport ? config.wobbly : config.stiff,
    delay: inViewport ? 100 : 0
  })

  return (
    <AnimatedRoundedBox ref={box} {...spring}>
      <meshNormalMaterial />
    </AnimatedRoundedBox>
  )
}

Would I just bind a ref = box to the props of the component?

similar to this https://codepen.io/kdbkapsere/pen/wvWJmGX


latest codesandbox https://codesandbox.io/p/sandbox/r3f-scroll-rig-sticky-box-forked-4zj88r

import React, { useRef, useEffect } from 'react'
import { Canvas, useThree } from '@react-three/fiber'
import { useGLTF, OrbitControls } from '@react-three/drei'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import * as THREE from 'three'

gsap.registerPlugin(ScrollTrigger)

const IphoneModel = () => {
  const group = useRef()
  const { nodes, materials } = useGLTF('/Iphone15.glb')

  useEffect(() => {
    const tl = gsap.timeline({
      scrollTrigger: {
        trigger: '#three-canvas-container',
        scrub: 1,
        //markers: true,
        pin: true,
        start: 'top top',
        end: 'bottom top'
      }
    })

    tl.to(group.current.rotation, { z: Math.PI / 8, duration: 2 })
  }, [])

  return (
    <group ref={group} dispose={null} scale={0.2} rotation={[Math.PI / 2, 0, -Math.PI / 8]}>
      <mesh geometry={nodes.M_Cameras.geometry} material={materials.cam} />
      <mesh geometry={nodes.M_Glass.geometry} material={materials['glass.001']} />
      <mesh geometry={nodes.M_Metal_Rough.geometry} material={materials.metal_rough} />
      <mesh geometry={nodes.M_Metal_Shiny.geometry} material={materials.metal_Shiny} />
      <mesh geometry={nodes.M_Plastic.geometry} material={materials.metal_rough} />
      <mesh geometry={nodes.M_Portal.geometry} material={materials['M_Base.001']} />
      <mesh geometry={nodes.M_Screen.geometry} material={materials.Screen} />
      <mesh geometry={nodes.M_Speakers.geometry} material={materials.metal_rough} />
      <mesh geometry={nodes.M_USB.geometry} material={materials.metal_rough} />
    </group>
  )
}

const Background = () => {
  const { scene } = useThree()
  useEffect(() => {
    scene.background = new THREE.Color('#555555')
  }, [scene])

  return null
}

const TextSection = () => {
  const textRefs = useRef([])

  useEffect(() => {
    gsap.fromTo(
      textRefs.current,
      { opacity: 0 },
      {
        opacity: 1,
        stagger: 0.1,
        scrollTrigger: {
          trigger: '#text-trigger',
          start: 'top bottom',
          end: 'center center',
          scrub: 1,
          markers: false
        }
      }
    )
  }, [])

  const texts = ['Ready 5', 'Ready 4', 'Ready 3', 'Ready 2', 'Ready 1']

  return (
    <div
      id="text-trigger"
      style={{
        height: '100vh',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        position: 'relative',
        top: '500px'
      }}>
      {texts.map((text, index) => (
        <h1 key={index} ref={(el) => (textRefs.current[index] = el)} style={{ opacity: 0 }}>
          {text}
        </h1>
      ))}
    </div>
  )
}

const ThreeScene = () => (
  <div id="three-canvas-container" style={{ width: '100vw', height: '500px' }}>
    <Canvas camera={{ position: [0, 0, 10], fov: 45 }} gl={{ antialias: true, alpha: false }}>
      <ambientLight intensity={0.4} />
      <directionalLight position={[5, 10, 7.5]} intensity={1} />
      <IphoneModel />
      <OrbitControls enableZoom={false} />
      <Background />
    </Canvas>
  </div>
)

const App = () => (
  <div style={{ display: 'flex', flexDirection: 'column', height: '400vh' }}>
    <div className="some-content" style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <h1>ACTION</h1>
    </div>
    <ThreeScene />
    <TextSection />
  </div>
)

export default App

-- using a youtube embed into the mesh

  <mesh geometry={nodes.M_Screen.geometry} material={materials.Screen}>
    <Html occlude="true" transform rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.6, 0]} scale={[1, 1, 1]}>
      <div style={{ width: '278px', height: '580px', background: 'white', borderRadius: '50px' }}>
        <iframe
          src="https://www.youtube.com/embed/oGtQKF62ZYg"
          style={{ width: '100%', height: '100%', border: 'none', borderRadius: 'inherit' }}
          title="video"
        />
      </div>
    </Html>
  </mesh>

using local video

  <mesh geometry={nodes.M_Screen.geometry} material={materials.Screen}>
    <Html occlude="true" transform rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.6, 0]} scale={[1, 1, 1]}>
      <div style={{ width: '278px', height: '580px', background: 'white', borderRadius: '50px' }}>
        <video width="278" height="580" autoplay style={{ width: '278px', height: '580px', background: 'white', borderRadius: '50px' }}>
          <source
            src="https://videos.ctfassets.net/f1onadsih6xk/5xdqjOZgZHvYos0cTRB4D6/4b5a6b47bc9e46d5ed3bfc8edd780da6/DocumentInSeconds.mp4"
            type="video/mp4"
          />
          Your browser does not support the video tag.
        </video>
      </div>
    </Html>

Better way of adding the video so the speaker part still shows is modifying the screen material.

  useEffect(() => {
    const video = document.createElement('video')
    video.src = 'https://cdn.pixabay.com/video/2024/07/14/221180_tiny.mp4'
    video.crossOrigin = 'anonymous'
    video.loop = true
    video.muted = true
    video.play()

    const videoTexture = new THREE.VideoTexture(video)
    videoTexture.minFilter = THREE.LinearFilter
    videoTexture.magFilter = THREE.LinearFilter
    videoTexture.encoding = THREE.sRGBEncoding

    materials.Screen.map = videoTexture
    materials.Screen.needsUpdate = true

    const tl = gsap.timeline({
      scrollTrigger: {
        trigger: '#three-canvas-container',
        scrub: 1,
        markers: true,
        pin: true,
        start: 'top top',
        end: 'bottom top'
      }
    })

    tl.to(group.current.rotation, { z: Math.PI * 2, duration: 2 })
  }, [materials.Screen])

Solution

  • You can do this in several ways. You can use some frame to place the canvas as part of a scrolling DOM with other elements. You can completely fill the view with the canvas and create the entire scrolling logic from scratch with each specific event calculated. And this seems to be the best solution, especially when you remove the native scrollbar. However, this will also have its limitations, such as the inability to use ScrollTrigger, you will have to use another way of listening for elements to enter the part of the viewport you are interested in, and firing specific events. This will have great benefits, especially on mobile devices, with the cumbersome and unsightly recalculation of the canvas. But with a complex project, it's time-consuming...

    import React, { useRef, useEffect } from 'react'
    import { Canvas, useThree } from '@react-three/fiber'
    import { useGLTF, OrbitControls } from '@react-three/drei'
    import gsap from 'gsap'
    import { ScrollTrigger } from 'gsap/ScrollTrigger'
    import * as THREE from 'three'
    
    gsap.registerPlugin(ScrollTrigger)
    
    const IphoneModel = () => {
      const group = useRef()
      const { nodes, materials } = useGLTF('/Iphone15.glb')
    
      useEffect(() => {
        const tl = gsap.timeline({
          scrollTrigger: {
            trigger: '#three-canvas-container',
            scrub: 1,
            markers: true,
            pin: true,
            start: 'top top',
            end: 'bottom top'
          }
        })
    
        tl.to(group.current.rotation, { z: Math.PI * 2, duration: 2 })
    
      }, [])
    
      return (
        <group ref={group} dispose={null} scale={0.2} rotation={[Math.PI / 2, 0, 0]}>
          <mesh geometry={nodes.M_Cameras.geometry} material={materials.cam} />
          <mesh geometry={nodes.M_Glass.geometry} material={materials['glass.001']} />
          <mesh geometry={nodes.M_Metal_Rough.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_Metal_Shiny.geometry} material={materials.metal_Shiny} />
          <mesh geometry={nodes.M_Plastic.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_Portal.geometry} material={materials['M_Base.001']} />
          <mesh geometry={nodes.M_Screen.geometry} material={materials.Screen} />
          <mesh geometry={nodes.M_Speakers.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_USB.geometry} material={materials.metal_rough} />
        </group>
      )
    }
    
    const Background = () => {
      const { scene } = useThree()
      useEffect(() => {
        scene.background = new THREE.Color('#555555')
      }, [scene])
    
      return null
    }
    
    const TextSection = () => {
      const textRefs = useRef([])
    
      useEffect(() => {
        gsap.fromTo(
          textRefs.current,
          { opacity: 0 },
          {
            opacity: 1,
            stagger: 0.1,
            scrollTrigger: {
              trigger: '#text-trigger',
              start: 'top bottom',
              end: 'center center',
              scrub: 1,
              markers: false
            }
          }
        )
      }, [])
    
      const texts = ['Ready 5', 'Ready 4', 'Ready 3', 'Ready 2', 'Ready 1']
    
      return (
        <div
          id="text-trigger"
          style={{
            height: '100vh',
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'center',
            position: 'relative',
            top: '500px'
          }}>
          {texts.map((text, index) => (
            <h1 key={index} ref={(el) => (textRefs.current[index] = el)} style={{ opacity: 0 }}>
              {text}
            </h1>
          ))}
        </div>
      )
    }
    
    const ThreeScene = () => (
      <div id="three-canvas-container" style={{ width: '100vw', height: '500px' }}>
        <Canvas camera={{ position: [0, 0, 10], fov: 45 }} gl={{ antialias: true, alpha: false }}>
          <ambientLight intensity={0.4} />
          <directionalLight position={[5, 10, 7.5]} intensity={1} />
          <IphoneModel />
          <OrbitControls enableZoom={false} />
          <Background />
        </Canvas>
      </div>
    )
    
    const App = () => (
      <div style={{ display: 'flex', flexDirection: 'column', height: '400vh' }}>
        <div className="some-content" style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <h1>ACTION</h1>
        </div>
        <ThreeScene />
        <TextSection />
      </div>
    )
    
    export default App
    

    SANDBOX

    EDIT

    Rotating iFrame

    <group ref={group} dispose={null} scale={0.2} rotation={[Math.PI / 2, 0, 0]}>
          <mesh geometry={nodes.M_Cameras.geometry} material={materials.cam} />
          <mesh geometry={nodes.M_Glass.geometry} material={materials['glass.001']} />
          <mesh geometry={nodes.M_Metal_Rough.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_Metal_Shiny.geometry} material={materials.metal_Shiny} />
          <mesh geometry={nodes.M_Plastic.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_Portal.geometry} material={materials['M_Base.001']} />
          <mesh geometry={nodes.M_Screen.geometry} material={materials.Screen}>
            <Html occlude="true" transform rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.6, 0]} scale={[1, 1, 1]}>
              <div style={{ width: '278px', height: '580px', background: 'white', borderRadius: '50px' }}>
                <iframe
                  src="https://www.youtube.com/embed/oGtQKF62ZYg"
                  style={{ width: '100%', height: '100%', border: 'none', borderRadius: 'inherit' }}
                  title="video"
                />
              </div>
            </Html>
          </mesh>
          <mesh geometry={nodes.M_Speakers.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_USB.geometry} material={materials.metal_rough} />
        </group>
    

    EDIT 2

    Video texture

    const IphoneModel = () => {
      const group = useRef()
      const { nodes, materials } = useGLTF('/Iphone15.glb')
    
      useEffect(() => {
        const video = document.createElement('video')
        video.src = 'https://cdn.pixabay.com/video/2024/07/14/221180_tiny.mp4'
        video.crossOrigin = 'anonymous'
        video.loop = true
        video.muted = true
        video.play()
    
        const videoTexture = new THREE.VideoTexture(video)
        videoTexture.minFilter = THREE.LinearFilter
        videoTexture.magFilter = THREE.LinearFilter
        videoTexture.encoding = THREE.sRGBEncoding
    
        materials.Screen.map = videoTexture
        materials.Screen.needsUpdate = true
    
        const tl = gsap.timeline({
          scrollTrigger: {
            trigger: '#three-canvas-container',
            scrub: 1,
            markers: true,
            pin: true,
            start: 'top top',
            end: 'bottom top'
          }
        })
    
        tl.to(group.current.rotation, { z: Math.PI * 2, duration: 2 })
      }, [materials.Screen])
    
      return (
        <group ref={group} dispose={null} scale={0.2} rotation={[Math.PI / 2, 0, 0]}>
          <mesh geometry={nodes.M_Cameras.geometry} material={materials.cam} />
          <mesh geometry={nodes.M_Glass.geometry} material={materials['glass.001']} />
          <mesh geometry={nodes.M_Metal_Rough.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_Metal_Shiny.geometry} material={materials.metal_Shiny} />
          <mesh geometry={nodes.M_Plastic.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_Portal.geometry} material={materials['M_Base.001']} />
          <mesh geometry={nodes.M_Screen.geometry} material={materials.Screen}>
            {/* Video Texture */}
          </mesh>
          <mesh geometry={nodes.M_Speakers.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_USB.geometry} material={materials.metal_rough} />
        </group>
      )
    }
    

    Texture

    const IphoneModel = () => {
      const group = useRef()
      const { nodes, materials } = useGLTF('/Iphone15.glb')
    
      useEffect(() => {
        const imageTexture = new THREE.TextureLoader().load(
          'https://pixabay.com/get/gc7b67400496e98a63a3c56eae484aa0bbb9163a92866e2588bcf817c8f164853cf8b7153fc1710f8b90ab5e27c9efd8b24dda4bd9238ef937a5474638ceba83536155b6ac9bf2b91f8eebcad2d36519c_640.jpg'
        )
        materials.Screen.map = imageTexture
        materials.Screen.needsUpdate = true
    
        const tl = gsap.timeline({
          scrollTrigger: {
            trigger: '#three-canvas-container',
            scrub: 1,
            markers: true,
            pin: true,
            start: 'top top',
            end: 'bottom top'
          }
        })
    
        tl.to(group.current.rotation, { z: Math.PI * 2, duration: 2 })
      }, [materials.Screen])
    
      return (
        <group ref={group} dispose={null} scale={0.2} rotation={[Math.PI / 2, 0, 0]}>
          <mesh geometry={nodes.M_Cameras.geometry} material={materials.cam} />
          <mesh geometry={nodes.M_Glass.geometry} material={materials['glass.001']} />
          <mesh geometry={nodes.M_Metal_Rough.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_Metal_Shiny.geometry} material={materials.metal_Shiny} />
          <mesh geometry={nodes.M_Plastic.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_Portal.geometry} material={materials['M_Base.001']} />
          <mesh geometry={nodes.M_Screen.geometry} material={materials.Screen}>
            {/* Texture */}
          </mesh>
          <mesh geometry={nodes.M_Speakers.geometry} material={materials.metal_rough} />
          <mesh geometry={nodes.M_USB.geometry} material={materials.metal_rough} />
        </group>
      )
    }