Skip to content

Commit

Permalink
Add cool 3D art
Browse files Browse the repository at this point in the history
- introduce three.js and tween.js
- render a group of tiles that react to the mouse position
- setup components for a background canvas and a moving display target
- add a switch to hide the art/distraction
  • Loading branch information
honzaflash committed Nov 15, 2023
1 parent d13ba17 commit 29f87cf
Show file tree
Hide file tree
Showing 14 changed files with 451 additions and 40 deletions.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@tweenjs/tween.js": "^21.0.0",
"@types/jest": "^27.5.2",
"@types/lodash": "^4.14.199",
"@types/node": "^16.18.49",
Expand All @@ -20,7 +21,8 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"react-scripts": "5.0.1",
"showdown": "^2.1.0"
"showdown": "^2.1.0",
"three": "^0.157.0"
},
"scripts": {
"start": "react-scripts start",
Expand Down Expand Up @@ -55,6 +57,7 @@
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@types/showdown": "^2.0.2",
"@types/three": "^0.157.2",
"@typescript-eslint/eslint-plugin": "^5.60.1",
"@typescript-eslint/parser": "^5.60.1",
"eslint": "^8.43.0",
Expand Down
48 changes: 48 additions & 0 deletions src/components/CoolArt/CoolArt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createContext, useContext, useEffect, useRef } from 'react'
import { getRendererDomElement, updateRendererSize } from './three-main'
import { Box, BoxProps } from '@mui/material'
import { mergeSxProps } from '../../utils'


export const CoolArtContext = createContext({ hidden: false, setHidden: (val: boolean) => { val; return } })
export const useCoolArtContext = () => useContext(CoolArtContext)

export const CoolArt = ({ sx, ...rest }: BoxProps) => {
const canvasContainer = useRef<HTMLDivElement>()
const handleWindowResize = () => {
if (canvasContainer.current)
updateRendererSize(canvasContainer.current.offsetWidth, canvasContainer.current.offsetHeight)
}
// Attach the Three.js canvas to this container through a ref
useEffect(() => {
canvasContainer.current?.appendChild(getRendererDomElement())
handleWindowResize() // also set the size
}, [canvasContainer.current])

// Handle canvas resizing
useEffect(() => {
handleWindowResize()
window.addEventListener('resize', handleWindowResize)
return () => window.removeEventListener('resize', handleWindowResize)
}, [canvasContainer, canvasContainer.current])

const { hidden } = useCoolArtContext()

return (
<Box
{...rest}
ref={canvasContainer}
sx={mergeSxProps({
position: 'absolute',
zIndex: '-1',
top: 0,
left: 0,
width: '100%',
overflowX: 'hidden',
visibility: hidden ? 'hidden' : 'visible',
opacity: hidden ? 0 : 1,
transition: '400ms opacity',
}, sx)}
/>
)
}
22 changes: 22 additions & 0 deletions src/components/CoolArt/CoolArtSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Box, BoxProps, Switch, Typography } from '@mui/material'
import { mergeSxProps } from '../../utils'
import { useCoolArtContext } from './CoolArt'


export const CoolArtSwitch = ({sx, ...rest}: BoxProps) => {
const { hidden, setHidden } = useCoolArtContext()
return (
<Box
{...rest}
sx={mergeSxProps({
display: 'flex',
alignItems: 'center',
}, sx)}
>
<Typography variant="button">Distraction</Typography>
<Switch
checked={!hidden}
onChange={({target}) => { setHidden(!target.checked)}}
/>
</Box>
)}
42 changes: 42 additions & 0 deletions src/components/CoolArt/CoolArtTarget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Box, BoxProps } from '@mui/material'
import { RefObject, createRef } from 'react'
import { mergeSxProps } from '../../utils'
import _ from 'lodash'
import { CoolArtSwitch } from './CoolArtSwitch'
import { useCoolArtContext } from './CoolArt'


export const coolArtTarget: RefObject<HTMLDivElement | undefined> = createRef()

export const CoolArtTarget = ({ sx, ...rest }: BoxProps) => {
const { hidden } = useCoolArtContext()
return (
<Box
ref={coolArtTarget}
sx={mergeSxProps({
width: '100%',
height: '100%',
position: 'relative',
...(hidden ? { minHeight: { xs: '9em', md: undefined } } : undefined),
transition: '400ms min-height',
}, sx)}
{...rest}
>
<CoolArtSwitch sx={{ position: 'absolute', top: '3.5em', right: '-4.2em', transform: 'rotate(90deg)' }} />
</Box>
)}


/** Return position of the target box relative to the canvas and its dimensions */
export const getArtTargetPosition = (canvasRect: DOMRect) => {
if (!coolArtTarget.current)
return undefined
const targetRect = coolArtTarget.current.getBoundingClientRect()
return _.mapValues({
left: targetRect.left - canvasRect.left,
top: targetRect.top - canvasRect.top,
width: targetRect.width,
height: targetRect.height,
}, (x) => Math.round(x))
}

37 changes: 37 additions & 0 deletions src/components/CoolArt/animationUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Vector3 } from 'three'


export const distancePointToLine = (point: Vector3, lineOrigin: Vector3, lineDirection: Vector3) => {
const pointToLineOrigin = new Vector3().subVectors(lineOrigin, point)
const crossProduct = new Vector3().crossVectors(lineDirection, pointToLineOrigin)
return crossProduct.length() ** 2 / lineDirection.length() ** 2
}

export const perpendicularToLineFromPoint = (point: Vector3, lineOrigin: Vector3, lineDirection: Vector3) => {
const pointTolineOrigin = new Vector3().subVectors(lineOrigin, point)
const normalOfPlane = new Vector3().crossVectors(pointTolineOrigin, lineDirection)
return new Vector3().crossVectors(pointTolineOrigin, normalOfPlane).normalize()
}

export const distanceVectorPointToLine = (point: Vector3, lineOrigin: Vector3, lineDirection: Vector3) => {
const pointToLineOrigin = new Vector3().subVectors(lineOrigin, point)
// normal vector, perpendicular to the plane of the problem (plane defined by the line and the point)
const normalOfPlane = new Vector3().crossVectors(pointToLineOrigin, lineDirection)
const direction = new Vector3().crossVectors(pointToLineOrigin, normalOfPlane)

const crossProduct = new Vector3().crossVectors(lineDirection, pointToLineOrigin)
const distance = crossProduct.length() ** 2 / lineDirection.length() ** 2

return direction.setLength(distance)
}

/**
* Exponential easing function (easing in and out) generator
* @param aggressivness how quickly the value plateaus; (1 by default)
* @param range returned values will be between -`range`/2 and `range`/2; (2 by default)
*/
export const getEasingFunc = (aggressivness=1, range=2) => (x: number) =>
x < 0
? (Math.pow(2, aggressivness * x) * range/2 - range/2)
: ((2 - Math.pow(2, -aggressivness * x))* range/2 - range/2)

46 changes: 46 additions & 0 deletions src/components/CoolArt/artPositioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Tween, Easing } from '@tweenjs/tween.js'
import { Vector2 } from 'three'
import _ from 'lodash'

import { getArtTargetPosition } from './CoolArtTarget'


// the default position if off screen
const DEFAULT_TARET_BOX_POSITION = { left: 800, top: -600, width: 0, height: 0 }

// state
let prevTargetBoxPos = DEFAULT_TARET_BOX_POSITION
const prevCanvasSize = new Vector2()

/**
* Mutates object's position to be at the center of the `CoolArtTarget` container.
* It is stateful and only updates the object if the container or the canvas have changed since the last execution.
* */
export const followArtTargetPosition = (renderer: THREE.WebGLRenderer, object: THREE.Object3D) => {
// get current parameters
const newTargetBoxPos = getArtTargetPosition(renderer.domElement.getBoundingClientRect()) ?? DEFAULT_TARET_BOX_POSITION
const canvasSize = new Vector2()
renderer.getSize(canvasSize)

// check if parameters changed since last execution
if (_.isEqual(newTargetBoxPos, prevTargetBoxPos) && canvasSize.equals(prevCanvasSize)) return

// some of the parameters have changed => update the object position
prevTargetBoxPos = newTargetBoxPos
prevCanvasSize.set(canvasSize.x, canvasSize.y)

const { left, top, width, height } = newTargetBoxPos
const SCALE = 0.009 // TODO use a raycaster to actually move the object precisely to the desired position

new Tween({ x: object.position.x, y: object.position.y })
.to({
x: (left + width / 2 - canvasSize.x / 2) * SCALE,
y: -(top + height / 2 - canvasSize.y / 2) * SCALE,
}, 1000)
.easing(Easing.Cubic.Out)
.onUpdate(({ x, y}) => {
object.position.setX(x)
object.position.setY(y)
})
.start()
}
3 changes: 3 additions & 0 deletions src/components/CoolArt/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { CoolArt, CoolArtContext, useCoolArtContext } from './CoolArt'
export { CoolArtTarget } from './CoolArtTarget'
export { CoolArtSwitch } from './CoolArtSwitch'
109 changes: 109 additions & 0 deletions src/components/CoolArt/three-main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as THREE from 'three'
import * as TWEEN from '@tweenjs/tween.js'

import { getTileMatrix, updateTileMatrix } from './tileMatrix'
import { distancePointToLine } from './animationUtils'
import { followArtTargetPosition } from './artPositioning'



/** Initialization */
const scene = new THREE.Scene()

const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000)
camera.position.z = 10

const renderer = new THREE.WebGLRenderer()
renderer.setSize(800, 1000)

const clock = new THREE.Clock()


/** Exported API */
export const getRendererDomElement = () => renderer.domElement

export const updateRendererSize = (...args: Parameters<typeof renderer.setSize>) => {
renderer.setSize(...args)
camera.aspect = args[0] / args[1]
camera.updateProjectionMatrix()
}


/** Set up the scene */

// Ligths
const directionalLight = new THREE.DirectionalLight(0xffffff, 3)
directionalLight.target.position.set(0, 0, -0.5)
scene.add(directionalLight)
scene.add(directionalLight.target)

const ambientLight = new THREE.AmbientLight(0xfffff, 0.1)
scene.add(ambientLight)

// Objects
const MATRIX_ROTATION_X = -Math.PI/10
const MATRIX_ROTATION_Y = -Math.PI/10
const MATRIX_ROTATION_Z = -Math.PI/40
const tileMatrix = getTileMatrix(8, 8, 0.4, 0.2)
tileMatrix.position.set(0, 5, 0)
tileMatrix.rotation.x = MATRIX_ROTATION_X
tileMatrix.rotation.y = MATRIX_ROTATION_Y
tileMatrix.rotation.z = MATRIX_ROTATION_Z
scene.add(tileMatrix)


/** Animate */

// Animation loop state
const pointer = new THREE.Vector2() // position of the pointer in normalized coordinates
/** Calculate pointer position in normalized device coordinates ((-1 to +1) for both components) */
const onPointerMove = (event: PointerEvent) => {
pointer.x = (event.clientX / window.innerWidth) * 2 - 1
pointer.y = (-(event.clientY + window.scrollY) / window.innerHeight) * 2 + 1
}
window.addEventListener('pointermove', onPointerMove)

const pointerRaycaster = new THREE.Raycaster() // actual pointer position ray
const floatyPointer = new THREE.Vector2() // position that slowly chases the real pointer
const floatyRaycaster = new THREE.Raycaster() // ray slowly chasing the actual pointer ray
let floatyDistance = 0 // variable that slowly chases the distance of the real pointer ray from the matrix
const FLOATINESS_C = 0.24
const FLOATINESS_THRESHOLD = 10 // at this distance from the matrix start throtlling down the hover effects

/** Incrementally update the floatyPointer position.
* As the pointer moves away from the matrix (past the FLOATINESS_THRESHOLD) the update increments
* become extremely small resulting in a near-still pointer. */
const updateFloatyPointer = (timeDelta: number) => {
const distance = distancePointToLine(tileMatrix.position, camera.position, pointerRaycaster.ray.direction)
const floatiness = timeDelta / FLOATINESS_C
floatyDistance += (distance - floatyDistance) * floatiness // indenpendent of the floatyRaycaster
// using `floatyDistance` results in a smoother stopping of the floatyPointer and
// therefore the matrix movement stops smoothly as well
const incrementMultiplier = floatyDistance < FLOATINESS_THRESHOLD
? floatiness // should be framerate dependant
: floatiness / ((floatyDistance - FLOATINESS_THRESHOLD) / 8 + 1)**4

floatyPointer.x += (pointer.x - floatyPointer.x) * incrementMultiplier
floatyPointer.y += (pointer.y - floatyPointer.y) * incrementMultiplier
}

// Animation loop
const animate = (t?: number) => {
TWEEN.update(t)
requestAnimationFrame(animate)
const timeDelta = clock.getDelta()

followArtTargetPosition(renderer, tileMatrix)

updateFloatyPointer(timeDelta)

// update the raycasters with the camera and pointer positions
pointerRaycaster.setFromCamera(pointer, camera)
floatyRaycaster.setFromCamera(floatyPointer, camera)

updateTileMatrix(tileMatrix, camera.position, floatyRaycaster.ray.direction, MATRIX_ROTATION_X, MATRIX_ROTATION_Y)

renderer.render( scene, camera )
}

animate()
48 changes: 48 additions & 0 deletions src/components/CoolArt/tileMatrix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { BoxGeometry, Group, Mesh, MeshPhongMaterial, Vector3 } from 'three'
import { PRIMARY_COLOR } from '../../theme'
import { distanceVectorPointToLine, getEasingFunc } from './animationUtils'


const primary = parseInt(PRIMARY_COLOR.substring(1), 16)

/** Creates a group of tiles in a matrix (in the XY plane) centered at [0; 0; 0] */
export const getTileMatrix = (columnCount: number, rowCount: number, tileSize: number, gap: number) => {
const tiles = []
for (let col = 0; col < columnCount; col++) {
for (let row = 0; row < rowCount; row++) {
const geometry = new BoxGeometry(tileSize, tileSize, tileSize * 0.2)
const material = new MeshPhongMaterial({ color: primary })
const mesh = new Mesh(geometry, material)
mesh.position.set(
(col - (columnCount -1) / 2) * tileSize * (1 + gap),
(row - (rowCount - 1) / 2) * tileSize * (1 + gap),
0,
)
tiles.push(mesh)
}
}

const tileMatrix = new Group()
tiles.forEach((tile) => tileMatrix.add(tile))

return tileMatrix
}


const easing = getEasingFunc()
const zOffseetEasing = getEasingFunc(0.6, 0.8)

export const updateTileMatrix = (tileMatrix: Group, cameraPosition: Vector3, raycasterDirection: Vector3, groupRotationX=0, groupRotationY=0) => {
const floatyDistanceVec = distanceVectorPointToLine(tileMatrix.position, cameraPosition, raycasterDirection)
tileMatrix.rotation.x = groupRotationX + easing(floatyDistanceVec.y) * Math.PI/16
tileMatrix.rotation.y = groupRotationY - easing(floatyDistanceVec.x) * Math.PI/8

tileMatrix.children.forEach((child) => {
const childAbsolutePosition = new Vector3().addVectors(child.position, tileMatrix.position) // does not account for the rotation of the group
const childDistanceVec = distanceVectorPointToLine(childAbsolutePosition, cameraPosition, raycasterDirection)

child.rotation.x = easing(childDistanceVec.y) * Math.PI/5
child.rotation.y = -easing(childDistanceVec.x) * Math.PI/10
child.position.z = zOffseetEasing(childDistanceVec.length())
})
}
Loading

0 comments on commit 29f87cf

Please sign in to comment.