304 lines
12 KiB
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>
|