<div class="stage">
<canvas
role="button"
tabindex="0"
aria-label="Hold to form a particle globe"
></canvas>
</div>
<script type="module">
import {
animate,
frame,
motionValue,
press,
transformValue,
} from "motion"
import { threeEffect } from "motion/three"
import * as THREE from "three/webgpu"
import {
attribute,
cos,
mix,
positionLocal,
sin,
time,
uniform,
vec3,
} from "three/tsl"
/**
* One Motion value controls one TSL uniform. The uniform drives the
* position morph for every particle in the material's vertex graph.
* The same value fades the photograph in once the particles settle.
*/
const globe = uniform(1)
const globeValue = motionValue(1)
function highResolutionMix(globeAmount) {
const amount = Math.max(globeAmount, 0)
const start = 0.005
const end = 0.035
const t = Math.min(1, Math.max(0, (amount - start) / (end - start)))
return 1 - t * t * (3 - 2 * t)
}
const JAPAN_PHOTOS = Array.from(
{ length: 13 },
(_, index) => `/photos/japan/${index + 1}.jpg`
)
const previousPhoto = sessionStorage.getItem("three-tsl-photo")
const photoChoices = JAPAN_PHOTOS.filter(
(photo) => photo !== previousPhoto
)
const PHOTO_URL =
photoChoices[Math.floor(Math.random() * photoChoices.length)]
const COLUMNS = 200
const globeYaw = Math.random() * Math.PI * 2
const stage = document.querySelector(".stage")
const canvas = document.querySelector("canvas")
sessionStorage.setItem("three-tsl-photo", PHOTO_URL)
function runParticleMotion(photoMesh) {
threeEffect(globe, { value: globeValue })
threeEffect(photoMesh.material, {
opacity: transformValue(() => highResolutionMix(globeValue.get())),
})
animate(globeValue, 0, {
type: "spring",
stiffness: 55,
damping: 16,
mass: 1,
delay: 0.8,
})
press(canvas, () => {
animate(globeValue, 1, {
type: "spring",
stiffness: 120,
damping: 18,
mass: 0.9,
})
return () => {
animate(globeValue, 0, {
type: "spring",
stiffness: 70,
damping: 16,
mass: 1,
})
}
})
}
function loadPhoto(url) {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => {
const source = document.createElement("canvas")
source.width = image.naturalWidth
source.height = image.naturalHeight
const context = source.getContext("2d", {
willReadFrequently: true,
})
context.drawImage(image, 0, 0)
const map = new THREE.Texture(image)
map.colorSpace = THREE.SRGBColorSpace
map.needsUpdate = true
resolve({
width: source.width,
height: source.height,
pixels: context.getImageData(
0,
0,
source.width,
source.height
).data,
map,
})
}
image.onerror = () => reject(
new Error("Could not load the photograph")
)
image.src = url
})
}
function createParticles(photo) {
const imageAspect = photo.width / photo.height
const rows = Math.round(COLUMNS / imageAspect)
const count = COLUMNS * rows
const imagePositions = new Float32Array(count * 3)
const globePositions = new Float32Array(count * 3)
const colours = new Float32Array(count * 3)
const phases = new Float32Array(count)
const speeds = new Float32Array(count)
const colour = new THREE.Color()
for (let index = 0; index < count; index++) {
const column = index % COLUMNS
const row = Math.floor(index / COLUMNS)
const sampleX = Math.min(
photo.width - 1,
Math.floor((column + 0.5) / COLUMNS * photo.width)
)
const sampleY = Math.min(
photo.height - 1,
Math.floor((row + 0.5) / rows * photo.height)
)
const pixel = (sampleY * photo.width + sampleX) * 4
const offset = index * 3
colour.setRGB(
photo.pixels[pixel] / 255,
photo.pixels[pixel + 1] / 255,
photo.pixels[pixel + 2] / 255,
THREE.SRGBColorSpace
)
colours[offset] = colour.r
colours[offset + 1] = colour.g
colours[offset + 2] = colour.b
phases[index] = Math.random() * Math.PI * 2
speeds[index] = 0.7 + Math.random() * 0.35
}
const geometry = new THREE.InstancedBufferGeometry().copy(
new THREE.PlaneGeometry(1, 1)
)
geometry.instanceCount = count
geometry.setAttribute(
"imagePosition",
new THREE.InstancedBufferAttribute(imagePositions, 3)
)
geometry.setAttribute(
"globePosition",
new THREE.InstancedBufferAttribute(globePositions, 3)
)
geometry.setAttribute(
"colour",
new THREE.InstancedBufferAttribute(colours, 3)
)
geometry.setAttribute(
"phase",
new THREE.InstancedBufferAttribute(phases, 1)
)
geometry.setAttribute(
"speed",
new THREE.InstancedBufferAttribute(speeds, 1)
)
const imagePosition = attribute("imagePosition")
const spherePosition = attribute("globePosition")
const phase = attribute("phase")
const speed = attribute("speed")
const particleSize = uniform(new THREE.Vector2(0.01, 0.01))
const turbulence = vec3(
sin(time.mul(speed.add(0.6)).add(phase)).mul(0.045),
cos(
time.mul(speed.add(0.37)).add(phase.mul(1.71))
).mul(0.035),
sin(
time.mul(speed.add(0.22)).add(phase.mul(2.13))
).mul(0.045)
)
const material = new THREE.MeshBasicNodeMaterial({
toneMapped: false,
})
material.colorNode = attribute("colour")
const particleCenter = mix(
imagePosition,
spherePosition.add(turbulence),
globe
)
material.positionNode = particleCenter.add(
vec3(positionLocal.xy.mul(particleSize), 0)
)
return {
imageAspect,
columns: COLUMNS,
rows,
particleSize,
object: new THREE.Mesh(geometry, material),
}
}
function createPhotoOverlay(photo) {
const photoMaterial = new THREE.MeshBasicMaterial({
map: photo.map,
toneMapped: false,
transparent: true,
depthWrite: false,
depthTest: false,
opacity: 0,
})
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
photoMaterial
)
mesh.renderOrder = 1
return mesh
}
function layoutParticles(particles, photoMesh, aspect) {
const {
columns,
imageAspect,
object,
particleSize,
rows,
} = particles
const imageHalfHeight = Math.min(
0.72,
0.86 * aspect / imageAspect
)
const imageHalfWidth = imageHalfHeight * imageAspect
const globeRadius = Math.min(0.58, aspect * 0.82)
const position = object.geometry.getAttribute("imagePosition")
const sphere = object.geometry.getAttribute("globePosition")
const yawCos = Math.cos(globeYaw)
const yawSin = Math.sin(globeYaw)
const tilt = -0.28
for (let index = 0; index < position.count; index++) {
const column = index % columns
const row = Math.floor(index / columns)
const x = (column + 0.5) / columns
const y = (row + 0.5) / rows
const sphereY = 1 - index / Math.max(position.count - 1, 1) * 2
const ringRadius = Math.sqrt(
Math.max(0, 1 - sphereY * sphereY)
)
const angle = index * 2.39996323
const sphereX = Math.cos(angle) * ringRadius
const sphereZ = Math.sin(angle) * ringRadius
const spunX = sphereX * yawCos + sphereZ * yawSin
const spunZ = -sphereX * yawSin + sphereZ * yawCos
position.setXYZ(
index,
(x - 0.5) * imageHalfWidth * 2,
(0.5 - y) * imageHalfHeight * 2,
0
)
sphere.setXYZ(
index,
spunX * globeRadius,
(
sphereY * Math.cos(tilt) -
spunZ * Math.sin(tilt)
) * globeRadius,
(
sphereY * Math.sin(tilt) +
spunZ * Math.cos(tilt)
) * globeRadius
)
}
position.needsUpdate = true
sphere.needsUpdate = true
particleSize.value.set(
imageHalfWidth * 2 / columns * 1.01,
imageHalfHeight * 2 / rows * 1.01
)
photoMesh.scale.set(
imageHalfWidth * 2,
imageHalfHeight * 2,
1
)
}
async function start() {
const renderer = new THREE.WebGPURenderer({
canvas,
antialias: true,
})
await renderer.init()
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
renderer.outputColorSpace = THREE.SRGBColorSpace
const photo = await loadPhoto(PHOTO_URL)
const particles = createParticles(photo)
const photoMesh = createPhotoOverlay(photo)
const scene = new THREE.Scene()
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
scene.background = new THREE.Color(0xffffff)
camera.position.z = 4
scene.add(particles.object)
scene.add(photoMesh)
const resize = () => {
const width = Math.max(stage.clientWidth, 1)
const height = Math.max(stage.clientHeight, 1)
const aspect = width / height
renderer.setSize(width, height, false)
camera.left = -aspect
camera.right = aspect
camera.top = 1
camera.bottom = -1
camera.updateProjectionMatrix()
layoutParticles(particles, photoMesh, aspect)
}
new ResizeObserver(resize).observe(stage)
resize()
runParticleMotion(photoMesh)
canvas.dataset.ready = "true"
frame.render(() => renderer.render(scene, camera), true)
}
start().catch((error) => {
console.error(error)
})
</script>
<style>
.stage {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: #fff;
}
canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none;
}
</style>