← Готовые шаблоныПосмотреть сайт ↗
Готовый шаблон
Поставщика кварцевого песка
100 ₽

Промпт автора
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VANTA — Digital Atelier</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@300;400&display=swap" rel="stylesheet">
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<style>
:root {
--c-bg: #0e0e0e;
--c-surface: #1a1a1a;
--c-text: #e8e4de;
--c-text-dim: #7a756d;
--c-accent: #c4a882;
--c-accent2: #8b7355;
--c-highlight: #f0ebe3;
--font-serif: 'Playfair Display', serif;
--font-sans: 'Space Grotesk', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; cursor: default; }
body {
background: var(--c-bg);
font-family: var(--font-sans);
height: 100vh;
width: 100vw;
overflow: hidden;
color: var(--c-text);
-webkit-font-smoothing: antialiased;
}
#three-container {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 0;
}
.layer-blur {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 2;
pointer-events: none;
mask-image: radial-gradient(circle 250px at var(--x, 50%) var(--y, 50%), transparent 0%, transparent 30%, black 100%);
-webkit-mask-image: radial-gradient(circle 250px at var(--x, 50%) var(--y, 50%), transparent 0%, transparent 30%, black 100%);
}
.noise-overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 3;
opacity: 0.02;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.grain-overlay {
position: fixed;
top: -50%; left: -50%;
width: 200%; height: 200%;
z-index: 4;
pointer-events: none;
opacity: 0.006;
background: repeating-conic-gradient(var(--c-text) 0.00015%, transparent 0.0004%, transparent 0.00075%, var(--c-text) 0.001%);
animation: grainShift 0.5s steps(1) infinite;
will-change: transform;
}
@keyframes grainShift {
0% { transform: translate3d(0, 0, 0); }
25% { transform: translate3d(-2%, 3%, 0); }
50% { transform: translate3d(1%, -2%, 0); }
75% { transform: translate3d(-3%, 1%, 0); }
100% { transform: translate3d(2%, -1%, 0); }
}
/* === LAYOUT === */
.ui-layer {
position: relative;
z-index: 10;
width: 100%; height: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr auto;
pointer-events: none;
}
/* NAV */
.nav {
grid-column: 1 / -1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 2.5rem 3.5rem;
pointer-events: auto;
}
.nav-brand {
font-family: var(--font-sans);
font-size: 1.1rem;
font-weight: 500;
color: var(--c-text);
text-decoration: none;
letter-spacing: 0.18em;
text-transform: uppercase;
transition: color 0.3s;
}
.nav-brand:hover { color: var(--c-accent); }
.nav-links {
display: flex;
gap: 2.5rem;
align-items: center;
}
.nav-link {
font-family: var(--font-sans);
font-size: 0.75rem;
letter-spacing: 0.04em;
color: var(--c-text-dim);
text-decoration: none;
transition: color 0.4s, letter-spacing 0.4s;
font-weight: 300;
position: relative;
}
.nav-link:hover { color: var(--c-highlight); letter-spacing: 0.1em; }
.nav-link::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
width: 0;
height: 1px;
background: var(--c-accent);
transition: width 0.4s var(--ease);
}
.nav-link:hover::after { width: 100%; }
.nav-idx {
font-family: var(--font-mono);
font-size: 0.5rem;
color: var(--c-accent2);
margin-right: 0.4rem;
opacity: 0.5;
}
/* HERO — left aligned */
.hero {
grid-column: 1;
grid-row: 2;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 3.5rem;
max-width: 640px;
}
.hero-tag {
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.25em;
color: var(--c-accent);
margin-bottom: 2rem;
opacity: 0;
animation: slideUp 1.2s var(--ease) forwards 0.3s;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
border: 1px solid rgba(196, 168, 130, 0.15);
border-radius: 2rem;
width: fit-content;
}
.hero-tag::before {
content: '◈';
font-size: 0.5rem;
color: var(--c-accent);
}
.hero-title {
font-family: var(--font-serif);
font-size: 4.8rem;
line-height: 1.05;
font-weight: 400;
letter-spacing: -0.02em;
color: var(--c-highlight);
margin-bottom: 2rem;
opacity: 0;
animation: slideUp 1.4s var(--ease) forwards 0.5s;
}
.hero-title em {
font-style: italic;
color: var(--c-accent);
display: inline-block;
position: relative;
}
.hero-title em::after {
content: '';
position: absolute;
bottom: 0.08em;
left: 0;
width: 100%;
height: 2px;
background: var(--c-accent);
opacity: 0.4;
}
.hero-title .line-thin {
font-family: var(--font-sans);
font-weight: 300;
font-size: 3.6rem;
color: var(--c-text-dim);
display: block;
letter-spacing: 0.02em;
}
.hero-body {
font-family: var(--font-sans);
font-size: 0.9rem;
line-height: 1.8;
color: var(--c-text-dim);
font-weight: 300;
max-width: 380px;
margin-bottom: 3rem;
opacity: 0;
animation: slideUp 1.4s var(--ease) forwards 0.8s;
border-left: 2px solid rgba(196, 168, 130, 0.2);
padding-left: 1.2rem;
}
.hero-actions {
display: flex;
gap: 1rem;
align-items: center;
pointer-events: auto;
opacity: 0;
animation: slideUp 1.2s var(--ease) forwards 1.1s;
}
.btn-main {
font-family: var(--font-sans);
font-size: 0.72rem;
letter-spacing: 0.06em;
padding: 0.9rem 2rem;
background: transparent;
color: var(--c-highlight);
border: 1.5px solid var(--c-accent);
text-decoration: none;
font-weight: 400;
transition: all 0.5s var(--ease);
border-radius: 3rem;
position: relative;
overflow: hidden;
}
.btn-main::before {
content: '';
position: absolute;
top: 0; left: -100%;
width: 100%; height: 100%;
background: var(--c-accent);
transition: left 0.5s var(--ease);
z-index: -1;
}
.btn-main:hover::before { left: 0; }
.btn-main:hover {
color: var(--c-bg);
transform: translateY(-2px);
box-shadow: 0 10px 40px rgba(196, 168, 130, 0.25);
}
.btn-ghost {
font-family: var(--font-sans);
font-size: 0.72rem;
letter-spacing: 0.06em;
padding: 0.9rem 2rem;
background: transparent;
color: var(--c-text-dim);
border: 1.5px solid rgba(122, 117, 109, 0.2);
text-decoration: none;
font-weight: 300;
transition: all 0.5s var(--ease);
border-radius: 3rem;
}
.btn-ghost:hover {
border-color: var(--c-accent);
color: var(--c-accent);
transform: translateY(-2px);
}
.btn-arrow {
display: inline-block;
margin-left: 0.5rem;
transition: transform 0.3s var(--ease);
}
.btn-main:hover .btn-arrow { transform: translateX(4px); }
/* RIGHT SIDE — morph info */
.morph-info {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
padding: 0 3.5rem 6rem 0;
}
.morph-label {
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--c-text-dim);
margin-bottom: 0.5rem;
opacity: 0;
animation: fadeIn 2s var(--ease) forwards 1.5s;
}
.morph-name {
font-family: var(--font-serif);
font-size: 2.2rem;
font-style: italic;
color: var(--c-accent);
transition: all 0.6s var(--ease);
opacity: 0;
animation: fadeIn 2s var(--ease) forwards 1.7s;
letter-spacing: -0.02em;
}
.morph-progress-bar {
width: 80px;
height: 2px;
background: rgba(196, 168, 130, 0.1);
margin-top: 1rem;
border-radius: 1px;
overflow: hidden;
opacity: 0;
animation: fadeIn 2s var(--ease) forwards 2s;
}
.morph-progress-fill {
height: 100%;
width: 0%;
background: var(--c-accent);
border-radius: 1px;
transition: width 0.1s linear;
}
.morph-counter {
font-family: var(--font-mono);
font-size: 0.55rem;
color: var(--c-accent2);
margin-top: 0.8rem;
letter-spacing: 0.1em;
opacity: 0;
animation: fadeIn 2s var(--ease) forwards 1.9s;
}
/* FOOTER */
.footer {
grid-column: 1 / -1;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0 3.5rem 2.5rem;
}
.footer-left {
font-family: var(--font-mono);
font-size: 0.55rem;
color: var(--c-text-dim);
letter-spacing: 0.08em;
line-height: 1.8;
opacity: 0;
animation: fadeIn 2s var(--ease) forwards 2s;
}
.footer-right {
display: flex;
gap: 0.5rem;
opacity: 0;
animation: fadeIn 2s var(--ease) forwards 2.2s;
}
.dot-nav {
width: 6px; height: 6px;
border-radius: 50%;
border: 1px solid var(--c-accent2);
background: transparent;
transition: all 0.3s;
pointer-events: auto;
}
.dot-nav.active {
background: var(--c-accent);
border-color: var(--c-accent);
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@media (max-width: 768px) {
.ui-layer {
grid-template-columns: 1fr;
}
.hero {
padding: 0 2rem;
grid-column: 1;
}
.hero-title { font-size: 2.6rem; }
.hero-title .line-thin { font-size: 2rem; }
.morph-info { display: none; }
.nav { padding: 1.5rem 2rem; }
.footer { padding: 0 2rem 2rem; }
}
</style>
</head>
<body>
<div id="three-container"></div>
<div class="layer-blur"></div>
<div class="noise-overlay"></div>
<div class="grain-overlay"></div>
<div class="ui-layer">
<nav class="nav">
<a href="#" class="nav-brand">Vanta</a>
<div class="nav-links">
<a href="#" class="nav-link"><span class="nav-idx">01</span>Projects</a>
<a href="#" class="nav-link"><span class="nav-idx">02</span>Method</a>
<a href="#" class="nav-link"><span class="nav-idx">03</span>About</a>
<a href="#" class="nav-link"><span class="nav-idx">04</span>Inquire</a>
</div>
</nav>
<div class="hero">
<div class="hero-tag">Generative Design Lab</div>
<h1 class="hero-title"><span class="line-thin">We craft</span>Algorithmic<br><em>Elegance</em></h1>
<p class="hero-body">Turning raw computation into sensory experiences — each particle a pixel of intention, each form a conversation between chaos and control.</p>
<div class="hero-actions">
<a href="#" class="btn-main">View Collection <span class="btn-arrow">→</span></a>
<a href="#" class="btn-ghost">How We Think</a>
</div>
</div>
<div class="morph-info">
<div class="morph-label">Active Form</div>
<div class="morph-name" id="morph-name">Dodeca</div>
<div class="morph-counter" id="morph-counter">01 / 04</div>
<div class="morph-progress-bar"><div class="morph-progress-fill" id="morph-progress"></div></div>
</div>
<div class="footer">
<div class="footer-left">
Somewhere between math & magic<br>
© 2026 VANTA LAB
</div>
<div class="footer-right">
<div class="dot-nav active" data-idx="0"></div>
<div class="dot-nav" data-idx="1"></div>
<div class="dot-nav" data-idx="2"></div>
<div class="dot-nav" data-idx="3"></div>
</div>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const container = document.getElementById('three-container');
const morphNameEl = document.getElementById('morph-name');
const morphCounterEl = document.getElementById('morph-counter');
const dots = document.querySelectorAll('.dot-nav');
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 2.5;
renderer.powerPreference = 'high-performance';
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0e0e0e);
scene.fog = new THREE.FogExp2(0x0e0e0e, 0.02);
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 0, 5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.04;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.4;
controls.enableZoom = false;
controls.enablePan = false;
// Lights
const ambient = new THREE.AmbientLight(0xffeedd, 3);
scene.add(ambient);
const keyLight = new THREE.PointLight(0xc4a882, 15, 50);
keyLight.position.set(3, 3, 4);
scene.add(keyLight);
const fillLight = new THREE.PointLight(0x4a6fa5, 8, 50);
fillLight.position.set(-4, -2, 3);
scene.add(fillLight);
const rimLight = new THREE.PointLight(0x8b7355, 10, 50);
rimLight.position.set(0, 4, -3);
scene.add(rimLight);
const frontLight = new THREE.PointLight(0xffffff, 12, 40);
frontLight.position.set(0, 0, 6);
scene.add(frontLight);
const bottomLight = new THREE.PointLight(0xc4a882, 8, 40);
bottomLight.position.set(0, -3, 3);
scene.add(bottomLight);
// === SHAPE DEFINITIONS ===
const PARTICLE_COUNT = 25000;
const shapes = [];
const shapeNames = ['Dodeca', 'Heart', 'Diamond', 'Helix'];
// Helper: sample points on geometry surface
function sampleGeometry(geometry, count) {
const pos = new Float32Array(count * 3);
const posAttr = geometry.attributes.position;
const indexAttr = geometry.index;
// Get triangle data
const triangles = [];
const triCount = indexAttr ? indexAttr.count / 3 : posAttr.count / 3;
const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3();
const areas = [];
let totalArea = 0;
for (let i = 0; i < triCount; i++) {
let a, b, c;
if (indexAttr) {
a = indexAttr.getX(i * 3);
b = indexAttr.getX(i * 3 + 1);
c = indexAttr.getX(i * 3 + 2);
} else {
a = i * 3; b = i * 3 + 1; c = i * 3 + 2;
}
vA.fromBufferAttribute(posAttr, a);
vB.fromBufferAttribute(posAttr, b);
vC.fromBufferAttribute(posAttr, c);
const area = new THREE.Triangle(vA.clone(), vB.clone(), vC.clone()).getArea();
areas.push(area);
totalArea += area;
triangles.push([vA.clone(), vB.clone(), vC.clone()]);
}
// Weighted random sampling
for (let i = 0; i < count; i++) {
let r = Math.random() * totalArea;
let triIdx = 0;
for (let j = 0; j < areas.length; j++) {
r -= areas[j];
if (r <= 0) { triIdx = j; break; }
}
const tri = triangles[triIdx];
let u = Math.random(), v = Math.random();
if (u + v > 1) { u = 1 - u; v = 1 - v; }
const w = 1 - u - v;
pos[i * 3] = tri[0].x * w + tri[1].x * u + tri[2].x * v;
pos[i * 3 + 1] = tri[0].y * w + tri[1].y * u + tri[2].y * v;
pos[i * 3 + 2] = tri[0].z * w + tri[1].z * u + tri[2].z * v;
}
return pos;
}
// Shape 0: Dodecahedron
function makeSkull() {
const geo = new THREE.DodecahedronGeometry(1.2, 1);
const nonIdx = geo.toNonIndexed();
const idxGeo = new THREE.BufferGeometry();
idxGeo.setAttribute('position', nonIdx.attributes.position);
const idxArr = [];
for (let i = 0; i < nonIdx.attributes.position.count; i++) idxArr.push(i);
idxGeo.setIndex(idxArr);
return sampleGeometry(idxGeo, PARTICLE_COUNT);
}
// Shape 1: Heart
function makeHeart() {
const pos = new Float32Array(PARTICLE_COUNT * 3);
for (let i = 0; i < PARTICLE_COUNT; i++) {
const t = Math.random() * Math.PI * 2;
const s = Math.random() * Math.PI;
const scatter = 0.03;
// Heart parametric surface
const x = 1.2 * Math.sin(t) * Math.sin(s);
let y = 0.8 * Math.cos(s) + 0.4 * Math.cos(t) * Math.sin(s);
const z = 1.0 * Math.sin(t) * Math.cos(s) * (1 + 0.3 * Math.sin(t));
// Heart shape modifier
const heartX = 16 * Math.pow(Math.sin(t), 3) / 16;
const heartY = (13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t)) / 16;
const depth = Math.sin(s) * 0.5;
pos[i * 3] = heartX + (Math.random() - 0.5) * scatter;
pos[i * 3 + 1] = heartY + (Math.random() - 0.5) * scatter;
pos[i * 3 + 2] = depth + (Math.random() - 0.5) * scatter;
}
return pos;
}
// Shape 2: Diamond / Gem (larger, centered, shifted up)
function makeDiamond() {
const yOffset = 0.35;
const topGeo = new THREE.ConeGeometry(1.4, 0.9, 8);
topGeo.translate(0, 0.45 + yOffset, 0);
const bottomGeo = new THREE.ConeGeometry(1.4, 2.0, 8);
bottomGeo.rotateX(Math.PI);
bottomGeo.translate(0, -1.0 + yOffset, 0);
const topNonIdx = topGeo.toNonIndexed();
const botNonIdx = bottomGeo.toNonIndexed();
const topIdxGeo = new THREE.BufferGeometry();
topIdxGeo.setAttribute('position', topNonIdx.attributes.position);
const topIdx = []; for (let i = 0; i < topNonIdx.attributes.position.count; i++) topIdx.push(i);
topIdxGeo.setIndex(topIdx);
const botIdxGeo = new THREE.BufferGeometry();
botIdxGeo.setAttribute('position', botNonIdx.attributes.position);
const botIdx = []; for (let i = 0; i < botNonIdx.attributes.position.count; i++) botIdx.push(i);
botIdxGeo.setIndex(botIdx);
const topCount = Math.floor(PARTICLE_COUNT * 0.4);
const botCount = PARTICLE_COUNT - topCount;
const topPts = sampleGeometry(topIdxGeo, topCount);
const botPts = sampleGeometry(botIdxGeo, botCount);
const pos = new Float32Array(PARTICLE_COUNT * 3);
pos.set(topPts);
for (let i = 0; i < botCount * 3; i++) {
pos[topCount * 3 + i] = botPts[i];
}
return pos;
}
// Shape 3: Double Helix
function makeHelix() {
const pos = new Float32Array(PARTICLE_COUNT * 3);
const helixCount = PARTICLE_COUNT / 2;
for (let h = 0; h < 2; h++) {
const offset = h * Math.PI;
for (let i = 0; i < helixCount; i++) {
const idx = (h * helixCount + i) * 3;
const t = (i / helixCount) * Math.PI * 6 - Math.PI * 3;
const r = 0.6;
pos[idx] = r * Math.cos(t + offset) + (Math.random() - 0.5) * 0.04;
pos[idx + 1] = t * 0.25 + (Math.random() - 0.5) * 0.04;
pos[idx + 2] = r * Math.sin(t + offset) + (Math.random() - 0.5) * 0.04;
// Add connecting rungs every so often
if (i % 200 < 10 && h === 0) {
const rungT = Math.floor(i / 200) * 200;
const rungAngle = (rungT / helixCount) * Math.PI * 6 - Math.PI * 3;
const frac = (i % 200) / 10;
pos[idx] = r * Math.cos(rungAngle) * (1 - frac) + r * Math.cos(rungAngle + Math.PI) * frac;
pos[idx + 2] = r * Math.sin(rungAngle) * (1 - frac) + r * Math.sin(rungAngle + Math.PI) * frac;
}
}
}
return pos;
}
shapes.push(makeSkull());
shapes.push(makeHeart());
shapes.push(makeDiamond());
shapes.push(makeHelix());
// === PARTICLE SYSTEM ===
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(PARTICLE_COUNT * 3);
const colors = new Float32Array(PARTICLE_COUNT * 3);
const sizes = new Float32Array(PARTICLE_COUNT);
const randoms = new Float32Array(PARTICLE_COUNT);
// Initialize with first shape
positions.set(shapes[0]);
const c1 = new THREE.Color(0xf0d9b5);
const c2 = new THREE.Color(0xd4a574);
const c3 = new THREE.Color(0x7eb8e0);
for (let i = 0; i < PARTICLE_COUNT; i++) {
const ratio = i / PARTICLE_COUNT;
const color = ratio < 0.5
? c1.clone().lerp(c2, ratio * 2)
: c2.clone().lerp(c3, (ratio - 0.5) * 2);
colors[i * 3] = color.r;
colors[i * 3 + 1] = color.g;
colors[i * 3 + 2] = color.b;
sizes[i] = 0.012 + Math.random() * 0.02;
randoms[i] = Math.random();
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1));
geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1));
// Custom shader for soft particles
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uPixelRatio: { value: renderer.getPixelRatio() },
uMorph: { value: 0 },
uMouse3D: { value: new THREE.Vector3(0, 0, 0) },
uMouseActive: { value: 0 },
},
vertexShader: `
attribute float aSize;
attribute float aRandom;
varying vec3 vColor;
varying float vAlpha;
uniform float uTime;
uniform float uPixelRatio;
uniform float uMorph;
uniform vec3 uMouse3D;
uniform float uMouseActive;
void main() {
vColor = color;
vec3 pos = position;
// Subtle breathing
float breath = sin(uTime * 0.5 + aRandom * 6.28) * 0.02;
pos += normalize(pos) * breath;
// During morph, particles scatter outward slightly
float scatter = sin(uMorph * 3.14159) * 0.3;
pos += normalize(pos + vec3(0.001)) * scatter * aRandom;
// Mouse influence — swirl + push with depth-agnostic distance
vec3 toParticle = pos - uMouse3D;
// Use only XY distance so depth doesn't reduce influence
float xyDist = length(toParticle.xy);
float fullDist = length(toParticle);
float mouseRadius = 1.4;
float influence = 1.0 - smoothstep(0.0, mouseRadius, xyDist);
influence = influence * influence * uMouseActive;
if (influence > 0.001) {
// Push away from mouse
vec3 pushDir = fullDist > 0.001 ? normalize(toParticle) : vec3(0.0, 1.0, 0.0);
float pushStrength = influence * 0.3;
pos += pushDir * pushStrength;
// Swirl around mouse (rotate in XY plane around mouse position)
float swirlSpeed = uTime * 2.0 + aRandom * 6.28;
float swirlStrength = influence * 0.25;
vec2 radial = pos.xy - uMouse3D.xy;
float angle = swirlStrength * (1.0 + sin(swirlSpeed) * 0.3);
float cosA = cos(angle);
float sinA = sin(angle);
vec2 rotated = vec2(
radial.x * cosA - radial.y * sinA,
radial.x * sinA + radial.y * cosA
);
pos.xy = uMouse3D.xy + rotated;
// Z-axis gentle orbit for depth feel
pos.z += sin(swirlSpeed * 0.7 + aRandom * 3.14) * influence * 0.15;
// Organic jitter
float jitter = sin(uTime * 4.0 + aRandom * 18.0) * 0.02 * influence;
pos += pushDir * jitter;
}
vec4 mvPos = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = aSize * uPixelRatio * 500.0 / -mvPos.z;
gl_PointSize = max(gl_PointSize, 1.5);
gl_Position = projectionMatrix * mvPos;
vAlpha = 0.85 + 0.15 * (1.0 - smoothstep(0.0, 10.0, -mvPos.z));
}
`,
fragmentShader: `
varying vec3 vColor;
varying float vAlpha;
void main() {
float d = length(gl_PointCoord - vec2(0.5));
if (d > 0.5) discard;
float alpha = smoothstep(0.5, 0.0, d) * vAlpha;
vec3 brightColor = vColor * 2.2 + 0.15;
gl_FragColor = vec4(brightColor, alpha);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexColors: true,
});
const particles = new THREE.Points(geometry, material);
scene.add(particles);
// === MORPHING ===
let currentShape = 0;
let targetShape = 0;
let morphProgress = 0;
let isMorphing = false;
const morphDuration = 2.5;
let morphStartTime = 0;
const clock = new THREE.Clock();
function startMorph(targetIdx) {
if (isMorphing || targetIdx === currentShape) return;
targetShape = targetIdx;
isMorphing = true;
morphStartTime = clock.getElapsedTime();
}
// Morph progress bar
const morphProgressEl = document.getElementById('morph-progress');
let morphTimer = 0;
const morphInterval = 5;
// Auto-morph every 5 seconds
let autoMorphInterval = setInterval(() => {
const next = (currentShape + 1) % shapes.length;
startMorph(next);
}, 5000);
// Click dots to morph
dots.forEach(dot => {
dot.addEventListener('click', () => {
const idx = parseInt(dot.dataset.idx);
clearInterval(autoMorphInterval);
startMorph(idx);
autoMorphInterval = setInterval(() => {
const next = (currentShape + 1) % shapes.length;
startMorph(next);
}, 5000);
});
});
function updateUI(idx) {
morphNameEl.textContent = shapeNames[idx];
morphCounterEl.textContent = `0${idx + 1} / 0${shapes.length}`;
dots.forEach((d, i) => d.classList.toggle('active', i === idx));
}
// === MOUSE RAYCASTING ===
const raycaster = new THREE.Raycaster();
const mouseNDC = new THREE.Vector2(9999, 9999);
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const mouse3D = new THREE.Vector3();
let mouseOnScreen = false;
let mouseActiveSmooth = 0;
document.addEventListener('mousemove', (e) => {
mouseNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
mouseNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
mouseOnScreen = true;
});
document.addEventListener('mouseleave', () => {
mouseNDC.set(9999, 9999);
mouseOnScreen = false;
});
// === ANIMATION ===
// Pre-allocate reusable objects to avoid GC pressure
const _invMatrix = new THREE.Matrix4();
const _localMouse = new THREE.Vector3();
const _intersectPoint = new THREE.Vector3();
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
let lastUIUpdate = -1;
function animate() {
requestAnimationFrame(animate);
controls.update();
const elapsed = clock.getElapsedTime();
material.uniforms.uTime.value = elapsed;
// Smooth mouse activation
const mouseTarget = mouseOnScreen ? 1 : 0;
mouseActiveSmooth += (mouseTarget - mouseActiveSmooth) * 0.08;
material.uniforms.uMouseActive.value = mouseActiveSmooth;
// Update mouse 3D position via raycasting onto a plane at z=0
raycaster.setFromCamera(mouseNDC, camera);
raycaster.ray.intersectPlane(mousePlane, _intersectPoint);
// Transform to particle local space (reuse matrices)
_invMatrix.copy(particles.matrixWorld).invert();
_localMouse.copy(_intersectPoint).applyMatrix4(_invMatrix);
material.uniforms.uMouse3D.value.copy(_localMouse);
if (isMorphing) {
const rawProgress = Math.min((elapsed - morphStartTime) / morphDuration, 1);
morphProgress = easeInOutCubic(rawProgress);
material.uniforms.uMorph.value = morphProgress;
const srcPositions = shapes[currentShape];
const tgtPositions = shapes[targetShape];
const posArray = geometry.attributes.position.array;
const len = PARTICLE_COUNT * 3;
// Optimized lerp loop
for (let i = 0; i < len; i++) {
posArray[i] = srcPositions[i] + (tgtPositions[i] - srcPositions[i]) * morphProgress;
}
geometry.attributes.position.needsUpdate = true;
if (rawProgress >= 1) {
isMorphing = false;
currentShape = targetShape;
material.uniforms.uMorph.value = 0;
updateUI(currentShape);
lastUIUpdate = -1;
}
// Update UI at midpoint (only once)
if (rawProgress > 0.4 && rawProgress < 0.6 && lastUIUpdate !== targetShape) {
lastUIUpdate = targetShape;
updateUI(targetShape);
}
}
// Gentle float
particles.rotation.y = elapsed * 0.05;
particles.position.y = Math.sin(elapsed * 0.3) * 0.05;
// Light animation
const sinT = Math.sin(elapsed * 0.2);
const cosT = Math.cos(elapsed * 0.2);
keyLight.position.x = sinT * 4;
keyLight.position.z = cosT * 4;
// Update morph progress bar
if (!isMorphing) {
const barProgress = ((elapsed % morphInterval) / morphInterval) * 100;
if (morphProgressEl) morphProgressEl.style.width = barProgress + '%';
} else {
if (morphProgressEl) morphProgressEl.style.width = '0%';
}
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
material.uniforms.uPixelRatio.value = renderer.getPixelRatio();
});
animate();
</script>
<script>
const blurLayer = document.querySelector('.layer-blur');
let mouseX = window.innerWidth / 2, mouseY = window.innerHeight / 2;
let smoothX = mouseX, smoothY = mouseY;
let lastBlurX = '', lastBlurY = '';
document.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
});
function animateBlur() {
smoothX += (mouseX - smoothX) * 0.1;
smoothY += (mouseY - smoothY) * 0.1;
// Only update DOM when value actually changes (rounded to 1 decimal)
const newX = (smoothX / window.innerWidth * 100).toFixed(1) + '%';
const newY = (smoothY / window.innerHeight * 100).toFixed(1) + '%';
if (newX !== lastBlurX || newY !== lastBlurY) {
lastBlurX = newX;
lastBlurY = newY;
blurLayer.style.setProperty('--x', newX);
blurLayer.style.setProperty('--y', newY);
}
requestAnimationFrame(animateBlur);
}
animateBlur();
</script>
</body>
</html>