Simple image gallery with 3D transition using InstancedMesh and shaders.
Use mouse wheel or arrow keys :)
A Pen by Kevin Levron on CodePen.
Simple image gallery with 3D transition using InstancedMesh and shaders.
Use mouse wheel or arrow keys :)
A Pen by Kevin Levron on CodePen.
<canvas id="canvas"></canvas> | |
<p class="collection"> | |
<a href="https://codepen.io/collection/AGZywR" target="_blank">WebGL Collection</a> | |
</p> |
import { | |
BufferGeometry, | |
Color, | |
DoubleSide, | |
Face3, | |
Geometry, | |
InstancedBufferAttribute, | |
InstancedMesh, | |
MathUtils, | |
MeshBasicMaterial, | |
Object3D, | |
PerspectiveCamera, | |
Scene, | |
TextureLoader, | |
Vector2, | |
Vector3, | |
WebGLRenderer | |
} from 'https://unpkg.com/three@0.119.0/build/three.module.js'; | |
function App() { | |
const conf = { | |
size: 10, | |
images: [ | |
{ src: 'https://assets.codepen.io/33787/img1.jpg' }, | |
{ src: 'https://assets.codepen.io/33787/img2.jpg' }, | |
{ src: 'https://assets.codepen.io/33787/img3.jpg' }, | |
{ src: 'https://assets.codepen.io/33787/img4.jpg' } | |
] | |
}; | |
let renderer, scene, camera, cameraCtrl; | |
const screen = { | |
width: 0, height: 0, | |
wWidth: 0, wHeight: 0, | |
ratio: 0 | |
}; | |
const loader = new TextureLoader(); | |
const textures = []; | |
let planes, plane1, plane2; | |
let progress = 0, targetProgress = 0; | |
const mouse = new Vector2(); | |
init(); | |
function init() { | |
renderer = new WebGLRenderer({ canvas: document.getElementById('canvas'), antialias: true }); | |
camera = new PerspectiveCamera(50); | |
camera.position.z = 150; | |
updateSize(); | |
window.addEventListener('resize', onResize); | |
Promise.all(conf.images.map(loadTexture)).then(responses => { | |
initScene(); | |
initListeners(); | |
gsap.fromTo(plane1.uProgress, | |
{ | |
value: -2 | |
}, | |
{ | |
value: 0, | |
duration: 2.5, | |
ease: Power4.easeOut | |
} | |
); | |
requestAnimationFrame(animate); | |
}); | |
} | |
function initScene() { | |
scene = new Scene(); | |
scene.background = new Color(0); | |
plane1 = new AnimatedPlane({ | |
renderer, screen, | |
size: conf.size, | |
anim: 1, | |
texture: textures[0] | |
}); | |
plane2 = new AnimatedPlane({ | |
renderer, screen, | |
size: conf.size, | |
anim: 2, | |
texture: textures[1] | |
}); | |
setPlanesProgress(0); | |
planes = new Object3D(); | |
planes.add(plane1.o3d); | |
planes.add(plane2.o3d); | |
scene.add(planes); | |
} | |
function initListeners() { | |
document.addEventListener('mousemove', e => { | |
mouse.x = (e.clientX / screen.width) * 2 - 1; | |
mouse.y = -(e.clientY / screen.height) * 2 + 1; | |
}); | |
window.addEventListener('wheel', e => { | |
e.preventDefault(); | |
if (e.deltaY > 0) { | |
targetProgress = limit(targetProgress + 1 / 20, 0, conf.images.length - 1); | |
} else { | |
targetProgress = limit(targetProgress - 1 / 20, 0, conf.images.length - 1); | |
} | |
}); | |
document.addEventListener('click', e => { | |
if (e.clientY < screen.height / 2) { | |
navPrevious(); | |
} else { | |
navNext(); | |
} | |
}); | |
document.addEventListener('keyup', e => { | |
if (e.keyCode === 37 || e.keyCode === 38) { | |
navPrevious(); | |
} else if (e.keyCode === 39 || e.keyCode === 40) { | |
navNext(); | |
} | |
}); | |
} | |
function navNext() { | |
if (Number.isInteger(targetProgress)) targetProgress += 1; | |
else targetProgress = Math.ceil(targetProgress); | |
targetProgress = limit(targetProgress, 0, conf.images.length - 1); | |
} | |
function navPrevious() { | |
if (Number.isInteger(targetProgress)) targetProgress -= 1; | |
else targetProgress = Math.floor(targetProgress); | |
targetProgress = limit(targetProgress, 0, conf.images.length - 1); | |
} | |
function updateProgress() { | |
const progress1 = lerp(progress, targetProgress, 0.1); | |
const pdiff = progress1 - progress; | |
if (pdiff === 0) return; | |
const p0 = progress % 1; | |
const p1 = progress1 % 1; | |
if ((pdiff > 0 && p1 < p0) || (pdiff < 0 && p0 < p1)) { | |
const i = Math.floor(progress1); | |
plane1.setTexture(textures[i]); | |
plane2.setTexture(textures[i + 1]); | |
} | |
progress = progress1; | |
setPlanesProgress(progress % 1); | |
} | |
function setPlanesProgress(progress) { | |
plane1.uProgress.value = progress; | |
plane2.uProgress.value = -1 + progress; | |
plane1.material.opacity = 1 - progress; | |
plane2.material.opacity = progress; | |
plane1.o3d.position.z = progress; | |
plane2.o3d.position.z = progress - 1; | |
} | |
function animate() { | |
requestAnimationFrame(animate); | |
updateProgress(); | |
const tiltX = lerp(planes.rotation.x, -mouse.y * 0.2, 0.1); | |
const tiltY = lerp(planes.rotation.y, mouse.x * 0.2, 0.1); | |
planes.rotation.set(tiltX, tiltY, 0); | |
renderer.render(scene, camera); | |
} | |
let resizeTimeout; | |
function onResize() { | |
clearTimeout(resizeTimeout); | |
resizeTimeout = setTimeout(updateSize, 200); | |
} | |
function updateSize() { | |
screen.width = window.innerWidth; | |
screen.height = window.innerHeight; | |
screen.ratio = screen.width / screen.height; | |
if (renderer && camera) { | |
renderer.setSize(screen.width, screen.height); | |
camera.aspect = screen.ratio; | |
camera.updateProjectionMatrix(); | |
const wsize = getRendererSize(); | |
screen.wWidth = wsize[0]; screen.wHeight = wsize[1]; | |
} | |
if (plane1) plane1.resize(); | |
if (plane2) plane2.resize(); | |
} | |
function getRendererSize() { | |
const vFOV = (camera.fov * Math.PI) / 180; | |
const h = 2 * Math.tan(vFOV / 2) * Math.abs(camera.position.z); | |
const w = h * camera.aspect; | |
return [w, h]; | |
} | |
function loadTexture(img, index) { | |
return new Promise(resolve => { | |
loader.load( | |
img.src, | |
texture => { | |
textures[index] = texture; | |
resolve(texture); | |
} | |
); | |
}); | |
} | |
} | |
class AnimatedPlane { | |
constructor(params) { | |
for (const [key, value] of Object.entries(params)) { | |
this[key] = value; | |
} | |
this.o3d = new Object3D(); | |
this.uProgress = { value: 0 }; | |
this.uvScale = new Vector2(); | |
this.initMaterial(); | |
this.initPlane(); | |
} | |
initMaterial() { | |
this.material = new MeshBasicMaterial({ | |
side: DoubleSide, | |
transparent: true, | |
map: this.texture, | |
onBeforeCompile: shader => { | |
shader.uniforms.progress = this.uProgress; | |
shader.uniforms.uvScale = { value: this.uvScale }; | |
shader.vertexShader = ` | |
uniform float progress; | |
uniform vec2 uvScale; | |
attribute vec3 offset; | |
attribute vec3 rotation; | |
attribute vec2 uvOffset; | |
mat3 rotationMatrixXYZ(vec3 r) | |
{ | |
float cx = cos(r.x); | |
float sx = sin(r.x); | |
float cy = cos(r.y); | |
float sy = sin(r.y); | |
float cz = cos(r.z); | |
float sz = sin(r.z); | |
return mat3( | |
cy * cz, cx * sz + sx * sy * cz, sx * sz - cx * sy * cz, | |
-cy * sz, cx * cz - sx * sy * sz, sx * cz + cx * sy * sz, | |
sy, -sx * cy, cx * cy | |
); | |
} | |
` + shader.vertexShader; | |
shader.vertexShader = shader.vertexShader.replace('#include <uv_vertex>', ` | |
#include <uv_vertex> | |
vUv = vUv * uvScale + uvOffset; | |
`); | |
shader.vertexShader = shader.vertexShader.replace('#include <project_vertex>', ` | |
mat3 rotMat = rotationMatrixXYZ(progress * rotation); | |
transformed = rotMat * transformed; | |
vec4 mvPosition = vec4(transformed, 1.0); | |
#ifdef USE_INSTANCING | |
mvPosition = instanceMatrix * mvPosition; | |
#endif | |
mvPosition.xyz += progress * offset; | |
mvPosition = modelViewMatrix * mvPosition; | |
gl_Position = projectionMatrix * mvPosition; | |
`); | |
} | |
}); | |
} | |
initPlane() { | |
const { width, wWidth, wHeight } = this.screen; | |
this.wSize = this.size * wWidth / width; | |
this.nx = Math.ceil(wWidth / this.wSize) + 1; | |
this.ny = Math.ceil(wHeight / this.wSize) + 1; | |
this.icount = this.nx * this.ny; | |
this.initGeometry(); | |
this.initUV(); | |
this.initAnimAttributes(); | |
if (this.imesh) { | |
this.o3d.remove(this.imesh); | |
} | |
this.imesh = new InstancedMesh(this.bGeometry, this.material, this.icount); | |
this.o3d.add(this.imesh); | |
const dummy = new Object3D(); | |
let index = 0; | |
let x = -(wWidth - (wWidth - this.nx * this.wSize)) / 2 + this.dx; | |
for (let i = 0; i < this.nx; i++) { | |
let y = -(wHeight - (wHeight - this.ny * this.wSize)) / 2 + this.dy; | |
for (let j = 0; j < this.ny; j++) { | |
dummy.position.set(x, y, 0); | |
dummy.updateMatrix(); | |
this.imesh.setMatrixAt(index++, dummy.matrix); | |
y += this.wSize; | |
} | |
x += this.wSize; | |
} | |
} | |
initGeometry() { | |
// square | |
const geometry = new Geometry(); | |
geometry.vertices.push(new Vector3(0, 0, 0)); | |
geometry.vertices.push(new Vector3(this.wSize, 0, 0)); | |
geometry.vertices.push(new Vector3(0, this.wSize, 0)); | |
geometry.vertices.push(new Vector3(this.wSize, this.wSize, 0)); | |
geometry.faces.push(new Face3(0, 2, 1)); | |
geometry.faces.push(new Face3(2, 3, 1)); | |
geometry.faceVertexUvs[0].push([ | |
new Vector2(0, 0), | |
new Vector2(0, 1), | |
new Vector2(1, 0) | |
]); | |
geometry.faceVertexUvs[0].push([ | |
new Vector2(0, 1), | |
new Vector2(1, 1), | |
new Vector2(1, 0) | |
]); | |
// geometry.computeFaceNormals(); | |
// geometry.computeVertexNormals(); | |
// center | |
this.dx = this.wSize / 2; | |
this.dy = this.wSize / 2; | |
geometry.translate(-this.dx, -this.dy, 0); | |
this.bGeometry = new BufferGeometry().fromGeometry(geometry); | |
} | |
initAnimAttributes() { | |
const { randFloat: rnd, randFloatSpread: rndFS } = MathUtils; | |
const v3 = new Vector3(); | |
const offsets = new Float32Array(this.icount * 3); | |
for (let i = 0; i < offsets.length; i += 3) { | |
if (this.anim === 1) v3.set(rndFS(10), rnd(50, 100), rnd(20, 50)).toArray(offsets, i); | |
else v3.set(rndFS(20), rndFS(20), rnd(20, 200)).toArray(offsets, i); | |
} | |
this.bGeometry.setAttribute('offset', new InstancedBufferAttribute(offsets, 3)); | |
const rotations = new Float32Array(this.icount * 3); | |
const angle = Math.PI * 4; | |
for (let i = 0; i < rotations.length; i += 3) { | |
rotations[i] = rndFS(angle); | |
rotations[i + 1] = rndFS(angle); | |
rotations[i + 2] = rndFS(angle); | |
} | |
this.bGeometry.setAttribute('rotation', new InstancedBufferAttribute(rotations, 3)); | |
} | |
initUV() { | |
const ratio = this.nx / this.ny; | |
const tRatio = this.texture.image.width / this.texture.image.height; | |
if (ratio > tRatio) this.uvScale.set(1 / this.nx, (tRatio / ratio) / this.ny); | |
else this.uvScale.set((ratio / tRatio) / this.nx, 1 / this.ny); | |
const nW = this.uvScale.x * this.nx; | |
const nH = this.uvScale.y * this.ny; | |
const v2 = new Vector2(); | |
const uvOffsets = new Float32Array(this.icount * 2); | |
for (let i = 0; i < this.nx; i++) { | |
for (let j = 0; j < this.ny; j++) { | |
v2.set( | |
this.uvScale.x * i + (1 - nW) / 2, | |
this.uvScale.y * j + (1 - nH) / 2 | |
).toArray(uvOffsets, (i * this.ny + j) * 2); | |
} | |
} | |
this.bGeometry.setAttribute('uvOffset', new InstancedBufferAttribute(uvOffsets, 2)); | |
} | |
setTexture(texture) { | |
this.texture = texture; | |
this.material.map = texture; | |
this.initUV(); | |
} | |
resize() { | |
this.initPlane(); | |
} | |
} | |
function limit(val, min, max) { | |
return val < min ? min : (val > max ? max : val); | |
} | |
function lerp(a, b, x) { | |
return a + x * (b - a); | |
} | |
App(); |
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.4.2/gsap.min.js"></script> |
body { | |
margin: 0; | |
width: 100%; | |
height: 100%; | |
} | |
canvas { | |
display: block; | |
cursor: pointer; | |
} |
<link href="https://codepen.io/soju22/pen/4aeaa568d7a4f3459bcde0e70b10f35a" rel="stylesheet" /> | |
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@900&display=swap" rel="stylesheet" /> |