import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import debounce from 'lodash/debounce'
import styled from '../styled-components'
import {
ACESFilmicToneMapping,
AnimationClip,
Color,
PCFSoftShadowMap,
PerspectiveCamera,
Scene,
WebGLRenderer,
} from 'three'
import { CameraData, GTLFModelObject, POI, GLTF } from '../types'
import { CameraRig, ThreeDOFControls } from 'three-story-controls'
import { DraftRenderer } from '../draft-renderer/index'
import { LoadingProgress } from './loading-progress'
import { ScrollTrigger } from 'gsap/dist/ScrollTrigger'
import { gsap } from 'gsap/dist/gsap'
import { mediaQuery } from '../utils/media-query'
import { useGSAP } from '@gsap/react'
// lodash
const _ = {
debounce,
}
gsap.registerPlugin(ScrollTrigger)
function Sections({
pois,
durationPer100vh,
windowObject,
}: {
pois: POI[]
durationPer100vh: number
windowObject: {
innerHeight: number
}
}) {
let accumlatedDuration = 0
const jsx = pois.map((poi, idx) => {
const caption = poi.caption
// remove empty blocks
const emptyBlockRemoved = caption.rawContentState.blocks.filter((b) => {
if (b.type === 'unstyled' && b.text === '') {
return false
}
return true
})
// No content to render
if (emptyBlockRemoved.length === 0) {
return null
}
const duration = idx === 0 ? 0 : poi.duration // the first poi's duration should be 0 since it should be rendered in the very beginning.
accumlatedDuration = accumlatedDuration + duration
const _top = Math.round((accumlatedDuration / durationPer100vh) * 100) / 100
const top = `${_top * windowObject.innerHeight}px`
// @TODO make `darkMode` as a param
const darkMode = true
return (
)
})
return <>{jsx}>
}
const Section = styled.div`
position: absolute;
padding: 24px 16px;
&[data-section-dark-mode='false'] {
background-color: rgba(255, 255, 255, 0.5);
}
&[data-section-dark-mode='true'] {
background-color: rgba(0, 0, 0, 0.5);
}
${mediaQuery.mobileOnly} {
// horizontally center
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
&[data-section-narrow-width='true'] {
width: 75vw;
}
&[data-section-narrow-width='false'] {
width: 100vw;
max-width: 720px;
}
}
${mediaQuery.tabletAbove} {
&[data-section-narrow-width='true'] {
width: 320px;
}
&[data-section-narrow-width='false'] {
width: 46vw;
max-width: 720px;
}
&[data-section-alignment='left'] {
left: 4vw;
}
&[data-section-alignment='right'] {
right: 4vw;
}
&[data-section-alignment='center'] {
// horizontally center
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
}
}
`
type ThreeObj = {
controls: ThreeDOFControls
cameraRig: CameraRig
renderer: WebGLRenderer
camera: PerspectiveCamera
scene: Scene
}
type WindowObject = {
innerWidth: number
innerHeight: number
}
export type ScrollableThreeModelProps = CameraData & {
debugMode?: boolean
modelObjs: GTLFModelObject[]
scrollerRef?: React.RefObject
durationPer100vh?: number
}
function ScrollableThreeModel({
pois,
animationClip: animationClipJSON,
debugMode,
modelObjs,
scrollerRef,
durationPer100vh = 1,
}: ScrollableThreeModelProps) {
const [windowObject, setWindowObject] = useState({
innerWidth: 0,
innerHeight: 0,
})
const scrollTriggerInstance = useRef(null)
const scrollTriggerRef = useRef(null)
const [gltfs, setGltfs] = useState([])
const [threeObj, setThreeObj] = useState(null)
const canvasRef = useRef(null)
const animationClip = useMemo(
() => AnimationClip.parse(animationClipJSON),
[animationClipJSON]
)
// We use duration to calculate sections' height.
// If the last poi has content, the content might overflow the sections block.
// Therefore, we extend the sections' height by a viewport height.
const duration = animationClip?.duration + durationPer100vh
const _sectionsHeight = Math.round((duration / durationPer100vh) * 100) / 100
const sectionsHeight = `${_sectionsHeight * windowObject.innerHeight}px`
const onModelsLoaded = useCallback((_modelObjs: GTLFModelObject[]) => {
const gltfs = _modelObjs
.map((obj) => {
const data = obj.data
if (data && obj.userData) {
data.scene.userData = obj.userData
}
return data
})
.filter((data) => data !== null) as GLTF[]
setGltfs(gltfs)
}, [])
const areModelsLoaded = gltfs.length !== 0
// use gsap ScrollTrigger to check if
// Sections are in the viewport or not,
// and if the Sections are in the viewport,
// use the scroll progress to manipulate the 3D model animation.
useGSAP(
() => {
if (
!scrollTriggerRef.current ||
!threeObj ||
windowObject.innerHeight === 0
) {
return
}
if (scrollTriggerInstance.current) {
// kill old instance to avoid getting stale dependencies
scrollTriggerInstance.current.kill()
}
const { cameraRig } = threeObj
scrollTriggerInstance.current = ScrollTrigger.create({
markers: debugMode,
trigger: scrollTriggerRef.current,
start: 'top 50%',
end: 'bottom 50%',
scroller: scrollerRef?.current || window,
onUpdate: ({ progress }: { progress: number }) => {
// update the animation
cameraRig.setAnimationPercentage(progress)
},
})
return () => {
if (scrollTriggerInstance.current) {
scrollTriggerInstance.current.kill()
}
}
},
{
scope: scrollTriggerRef,
dependencies: [debugMode, windowObject, scrollerRef, threeObj],
}
)
useEffect(() => {
const threeObj = createThreeObj({
gltfs,
canvas: canvasRef.current,
animationClip,
windowObject,
})
setThreeObj(threeObj)
}, [gltfs, animationClip, windowObject])
// Handle 3D model rendering
useEffect(() => {
let requestId: number
const render = (t: number) => {
if (threeObj !== null) {
const { controls, scene, camera, renderer } = threeObj
controls.update(t)
// Render
renderer.render(scene, camera)
// Call tick again on the next frame
requestId = window.requestAnimationFrame(render)
}
}
render(Date.now())
// Clean up
return () => {
cancelAnimationFrame(requestId)
}
}, [threeObj])
// Handle resize
useEffect(() => {
const handleResize = _.debounce(() => {
const innerWidth = window.innerWidth
const innerHeight = window.innerHeight
if (innerWidth !== windowObject.innerWidth) {
setWindowObject({
innerWidth,
innerHeight,
})
}
if (!threeObj) {
return
}
const { camera, renderer } = threeObj
// Update camera
camera.aspect = innerWidth / innerHeight
camera.updateProjectionMatrix()
// Update renderer
renderer.setSize(innerWidth, innerHeight)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
}, 300)
window.addEventListener('resize', handleResize)
handleResize()
return () => {
window.removeEventListener('resize', handleResize)
}
}, [windowObject, threeObj])
if (!areModelsLoaded) {
return (
)
}
return (
{windowObject.innerHeight !== 0 && Array.isArray(pois) && (
)}
)
}
const LoadingProgressContainer = styled.div`
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background-color: #f1f1f1e5;
`
const Container = styled.div`
position: relative;
width: fit-content;
height: fit-content;
`
const CanvasContainer = styled.div`
width: 100vw;
height: 100vh;
position: sticky;
top: 0;
touch-action: none;
z-index: -1;
`
const Content = styled.div`
position: relative;
`
function createThreeObj({
gltfs,
canvas,
animationClip,
windowObject,
}: {
gltfs: GLTF[]
canvas: HTMLCanvasElement | null
animationClip: AnimationClip
windowObject: WindowObject
}) {
if (!canvas || windowObject.innerHeight === 0) {
return null
}
const width = windowObject.innerWidth
const height = windowObject.innerHeight
/**
* Scene
*/
const scene = new Scene()
scene.background = new Color(0x1d1d1d)
if (Array.isArray(gltfs)) {
gltfs.forEach((gltf) => {
gltf.scene.traverse(function (object) {
object.castShadow = gltf.scene.userData.castShadow
object.receiveShadow = gltf.scene.userData.receiveShadow
})
scene.add(gltf.scene)
})
}
/**
* Camera
*/
const camera = new PerspectiveCamera(45, width / height, 0.1, 10000)
// camera.lookAt(0, 0, 0)
/**
* CameraRig
*/
const cameraRig = new CameraRig(camera, scene)
cameraRig.setAnimationClip(animationClip)
cameraRig.setAnimationTime(0)
//
/**
* Renderer
*/
const renderer = new WebGLRenderer({
canvas: canvas,
})
renderer.toneMapping = ACESFilmicToneMapping
renderer.toneMappingExposure = 1
renderer.shadowMap.enabled = true
renderer.shadowMap.type = PCFSoftShadowMap
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setClearColor(0x000000) // 第二個參數 0 表示透明度
/**
* Controls
*/
const controls = new ThreeDOFControls(cameraRig, {
panFactor: Math.PI / 10,
tiltFactor: Math.PI / 10,
truckFactor: 0,
pedestalFactor: 0,
})
controls.enable()
return {
controls,
scene,
renderer,
camera,
cameraRig,
}
}
export { ScrollableThreeModel }