-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- 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
1 parent
d13ba17
commit 29f87cf
Showing
14 changed files
with
451 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)} | ||
/> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
)} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
}) | ||
} |
Oops, something went wrong.