hotschpotsh/parallax-demo/index.html

304 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Parallax Workshop Demo</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
<style>
body { margin: 0; overflow-x: hidden; background-color: #0f0f0f; font-family: sans-serif; color: white; }
.spacer {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: center;
}
h1 { font-size: 3rem; margin-bottom: 2rem; }
p { max-width: 600px; line-height: 1.6; color: #aaa; }
.parallax-section {
position: relative;
height: 400vh; /* Scroll distance */
z-index: 10;
}
.sticky-wrapper {
position: sticky;
top: 0;
width: 100%;
height: 100vh;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
#parallax-canvas {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 1;
}
.product-reveal {
position: absolute;
z-index: 2;
opacity: 0;
transform: scale(0.9);
width: 60%;
max-width: 500px;
}
.product-reveal img {
width: 100%;
height: auto;
display: block;
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.5));
}
/* Loading Overlay */
#loader {
position: fixed; inset: 0; background: #0f0f0f; z-index: 999;
display: flex; justify-content: center; align-items: center;
}
</style>
</head>
<body>
<section class="spacer">
<h1>Scroll Down</h1>
<p>Experience the journey from the workshop to the finished form.</p>
</section>
<section class="parallax-section">
<div class="sticky-wrapper">
<canvas id="parallax-canvas"></canvas>
<div class="product-reveal">
<img src="pottery-vase.png" id="final-img">
</div>
</div>
</section>
<section class="spacer">
<h1>Collection 014</h1>
<p>Every piece tells a story.</p>
</section>
<div id="loader">Loading Assets...</div>
<script>
gsap.registerPlugin(ScrollTrigger);
async function init() {
const canvas = document.querySelector('#parallax-canvas');
// THREE SETUP
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// CHECK PROTOCOL
if (window.location.protocol === 'file:') {
const loader = document.getElementById('loader');
loader.innerHTML = `
<div style="text-align:center; padding: 2rem;">
<h2 style="color: #ff6b6b">File Protocol Error</h2>
<p>Browsers cannot load textures directly from local files due to security restrictions.</p>
<p style="margin-top: 1rem; font-weight: bold; color: white;">Please open <a href="index_embedded.html" style="color: #4cd137">index_embedded.html</a> instead.</p>
<p style="font-size: 0.8em; color: #888">Or use a local server (e.g. python -m http.server).</p>
</div>
`;
return;
}
// LOAD TEXTURES
const textureLoader = new THREE.TextureLoader();
const loadTexture = (url) => new Promise((resolve, reject) => {
textureLoader.load(url, resolve, undefined, reject);
});
try {
// Load assets for both Workshop and Vase
const [workshopTex, workshopDepth, vaseTex, vaseDepth] = await Promise.all([
loadTexture('workshop.jpg'),
loadTexture('workshop_depth.png'),
loadTexture('pottery-vase.png'),
loadTexture('pottery-vase_depth.png')
]);
document.getElementById('loader').style.display = 'none';
// --- IMAGE CONSTANTS ---
const WORKSHOP_ASPECT = workshopTex.image.width / workshopTex.image.height;
const VASE_ASPECT = vaseTex.image.width / vaseTex.image.height;
// --- SHADER SETUP ---
const vertexShader = `
varying vec2 vUv;
uniform sampler2D uDepth;
uniform float uDepthScale;
uniform vec2 uMouse;
void main() {
vUv = uv;
float depth = texture2D(uDepth, uv).r;
vec3 pos = position;
// Z Displacement
pos.z += depth * uDepthScale;
// Mouse Parallax (Low intensity)
pos.x += (uMouse.x * depth * 0.02);
pos.y += (uMouse.y * depth * 0.02);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`;
const fragmentShader = `
varying vec2 vUv;
uniform sampler2D uImage;
uniform float uOpacity;
void main() {
vec4 color = texture2D(uImage, vUv);
gl_FragColor = vec4(color.rgb, color.a * uOpacity);
}
`;
// --- WORKSHOP MESH ---
// Geometry matches image aspect ratio (Width, 1.0)
const workshopGeo = new THREE.PlaneGeometry(WORKSHOP_ASPECT, 1, 128, 128);
const workshopMat = new THREE.ShaderMaterial({
uniforms: {
uImage: { value: workshopTex },
uDepth: { value: workshopDepth },
uMouse: { value: new THREE.Vector2(0, 0) },
uDepthScale: { value: 0.15 },
uOpacity: { value: 1.0 }
},
vertexShader,
fragmentShader,
transparent: true
});
const workshopMesh = new THREE.Mesh(workshopGeo, workshopMat);
scene.add(workshopMesh);
// --- VASE MESH ---
const vaseGeo = new THREE.PlaneGeometry(VASE_ASPECT, 1, 128, 128);
const vaseMat = new THREE.ShaderMaterial({
uniforms: {
uImage: { value: vaseTex },
uDepth: { value: vaseDepth },
uMouse: { value: new THREE.Vector2(0, 0) },
uDepthScale: { value: 0.15 },
uOpacity: { value: 0.0 }
},
vertexShader,
fragmentShader,
transparent: true
});
const vaseMesh = new THREE.Mesh(vaseGeo, vaseMat);
vaseMesh.position.z = 0.1; // Just in front to avoid z-fighting
scene.add(vaseMesh);
// Camera Start
const DISTANCE = 4.0;
camera.position.z = DISTANCE;
// Handle Resize & COVER/CONTAIN Logic
const handleResize = () => {
const screenAspect = window.innerWidth / window.innerHeight;
camera.aspect = screenAspect;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
// Calculate Visible Height at the mesh distance
// fov is vertical fov in degrees
const vFOV = camera.fov * Math.PI / 180;
const visibleHeight = 2 * Math.tan(vFOV / 2) * (DISTANCE - workshopMesh.position.z);
const visibleWidth = visibleHeight * screenAspect;
// 1. WORKSHOP: COVER
// We want the mesh (which is Aspect x 1) to cover VisibleWidth x VisibleHeight
// Scale X and Y by the same factor to maintain aspect
const scaleFactorCover = Math.max(visibleWidth / WORKSHOP_ASPECT, visibleHeight / 1);
workshopMesh.scale.set(scaleFactorCover, scaleFactorCover, 1);
// 2. VASE: CONTAIN / SAFE COVER
// We want it visible. Let's make it cover 80% of min dimension, or standard scale.
// Let's just fit it to height generally, or cover if desired.
// User said "rotate... explore", let's make it fairly large but contained.
const scaleFactorContain = Math.min(visibleWidth / VASE_ASPECT, visibleHeight / 1) * 0.8;
vaseMesh.scale.set(scaleFactorContain, scaleFactorContain, 1);
};
window.addEventListener('resize', handleResize);
handleResize();
// ANIMATION LOOP
const mouse = new THREE.Vector2(0, 0);
const animate = () => {
requestAnimationFrame(animate);
workshopMat.uniforms.uMouse.value.lerp(mouse, 0.05);
vaseMat.uniforms.uMouse.value.lerp(mouse, 0.05);
renderer.render(scene, camera);
};
animate();
// INTERACTIONS
window.addEventListener('mousemove', (e) => {
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
});
// Scroll Animation
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".parallax-section",
start: "top top",
end: "bottom bottom",
scrub: true
}
});
// Transition
// Workshop Fades Out
tl.to(workshopMat.uniforms.uOpacity, { value: 0, ease: "power1.out" }, 0.2);
// Vase Fades In and Zooms slightly
tl.to(vaseMat.uniforms.uOpacity, { value: 1, ease: "power1.in" }, 0.2);
// Camera move? Maybe subtle
tl.to(camera.position, { z: 3.5, ease: "none" }, 0);
// Vase rotation/movement
tl.fromTo(vaseMesh.rotation, { z: -0.05 }, { z: 0.05, ease: "none"}, 0.2);
// Hide loader/overlay if any
tl.to(".product-reveal", { opacity: 0, duration: 0 }, 0);
tl.to(canvas, { opacity: 1 }, 0);
} catch (err) {
console.error("Error loading assets:", err);
document.getElementById('loader').innerText = "Error loading assets: " + err.message;
}
}
init();
</script>
</body>
</html>