Menu
Public repository / master
AkurAI-Framework / site/frontend/hero3d.js
Latest change edb63febb8db14f87cc49d41b9548f661921d568 - feat(hero): lightweight animated gradient hero background (Stripe-style) by Ólafur Búi Ólafsson
/* hero3d.js — lightweight animated gradient hero background.
*
* Mimics the Stripe / whatamesh "mesh gradient" technique (Ashima simplex noise
* driving colour layers blended over a base), reimplemented as a single full-screen
* fragment-shader quad — GPU-only, no geometry density, no post-processing. Deep
* brand palette so light hero text stays readable.
*
* Self-hosted Three.js (vendored under /vendor/three, no CDN). Non-blocking: skips
* silently without WebGL2 (CSS gradient remains), renders a single static frame under
* prefers-reduced-motion, and pauses when scrolled out of view.
*
* Reference: Stripe gradient (Nick Jones) / whatamesh; Ashima Arts simplex noise (MIT).
*/
import * as THREE from '/vendor/three/three.module.js';
const canvas = document.getElementById('hero-bg');
const webgl2 = (() => { try { return !!document.createElement('canvas').getContext('webgl2'); }
catch { return false; } })();
if (canvas && webgl2) boot();
function boot() {
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const host = canvas.parentElement;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const uniforms = {
u_time: { value: 0 },
u_aspect: { value: 1 },
// deep, muted brand palette — premium, not neon; keeps light text legible
c0: { value: new THREE.Color(0x070b16) }, // base — deep navy
c1: { value: new THREE.Color(0x214099) }, // blue #5b8cff, darkened
c2: { value: new THREE.Color(0x3b2a86) }, // purple #8a6bff, darkened
c3: { value: new THREE.Color(0x125446) }, // mint #7af0d0, darkened
};
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader: /* glsl */`
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
`,
fragmentShader: /* glsl */`
precision highp float;
varying vec2 vUv;
uniform float u_time, u_aspect;
uniform vec3 c0, c1, c2, c3;
// Ashima Arts simplex noise 3D (MIT)
vec4 permute(vec4 x){ return mod(((x*34.0)+1.0)*x, 289.0); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v){
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + 1.0 * C.xxx;
vec3 x2 = x0 - i2 + 2.0 * C.xxx;
vec3 x3 = x0 - 1.0 + 3.0 * C.xxx;
i = mod(i, 289.0);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 1.0/7.0;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z *ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ *ns.x + ns.yyyy;
vec4 y = y_ *ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0)*2.0 + 1.0;
vec4 s1 = floor(b1)*2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
vec3 blend(vec3 base, vec3 c, float o){ return mix(base, c, clamp(o, 0.0, 1.0)); }
// one Stripe-style colour layer: simplex → smoothstep → pow → opacity
float layer(vec2 uv, vec2 freq, float flow, float speed, float seed, float t){
float n = snoise(vec3(uv.x*freq.x + t*flow, uv.y*freq.y, t*speed + seed));
return smoothstep(0.0, 1.0, n*0.5 + 0.5);
}
void main(){
vec2 uv = vUv;
uv.x *= u_aspect; // keep the noise from stretching wide
float t = u_time * 0.05;
vec3 col = c0;
col = blend(col, c1, pow(layer(uv, vec2(1.8, 2.6), 0.22, 0.16, 0.0, t), 3.0));
col = blend(col, c2, pow(layer(uv, vec2(2.7, 1.9), -0.18, 0.20, 13.0, t), 3.0));
col = blend(col, c3, pow(layer(uv, vec2(1.4, 2.3), 0.14, 0.13, 31.0, t), 4.0));
// gentle top-darken + vignette so hero copy keeps contrast
col -= pow(vUv.y, 2.2) * 0.05;
col -= smoothstep(0.45, 1.1, distance(vUv, vec2(0.5))) * 0.06;
gl_FragColor = vec4(col, 1.0);
}
`,
});
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
function resize() {
const w = host.clientWidth || innerWidth;
const h = host.clientHeight || 520;
renderer.setSize(w, h, false);
uniforms.u_aspect.value = w / h;
}
resize();
new ResizeObserver(resize).observe(host);
const timer = new THREE.Timer();
let visible = true;
new IntersectionObserver(([e]) => { visible = e.isIntersecting; },
{ threshold: 0.01 }).observe(host);
if (reduce) { uniforms.u_time.value = 8.0; renderer.render(scene, camera); return; }
renderer.setAnimationLoop((ts) => {
if (!visible) return;
timer.update(ts);
uniforms.u_time.value = timer.getElapsed();
renderer.render(scene, camera);
});
}