AkurAI Build
Menu

Public repository / f6f5dc7059b40f7a4042c85f7c49597638a67522

AkurAI-Framework / site/frontend/hero3d.js

Latest change a4a1786d2033efcf9c512fc869ab3f45d2bc2c4e - feat(hero): live Three.js 3D hero — glass planes converge into glowing binary by Ólafur Búi Ólafsson

/* hero3d.js — live Three.js recreation of /img/hero.svg.
 *
 * "Layered HTTP, database, and UI planes converge into a glowing single binary."
 * Same object as the original illustration, rendered live with modern r185 effects:
 * glass transmission + chromatic dispersion + iridescence, bloom, ACES tone mapping.
 *
 * Self-hosted Three.js (vendored under /vendor/three) — no CDN, in keeping with the
 * framework's zero-runtime-dependency promise. Decorative + non-blocking: it only
 * upgrades the static SVG when WebGL2 is present, honours prefers-reduced-motion,
 * and pauses when scrolled out of view.
 */
import * as THREE from '/vendor/three/three.module.js';
import { EffectComposer } from '/vendor/three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from '/vendor/three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from '/vendor/three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from '/vendor/three/addons/postprocessing/OutputPass.js';

const BRAND = { blue: 0x5b8cff, purple: 0x8a6bff, light: 0xe7ebf3, green: 0x3ddc84 };

const mount = document.getElementById('hero3d');
const fallback = document.querySelector('.hero-art img');
const webgl2 = (() => { try { return !!document.createElement('canvas').getContext('webgl2'); }
  catch { return false; } })();

if (mount && webgl2) boot();

function boot() {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (fallback) fallback.style.display = 'none';
  mount.hidden = false;

  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
  renderer.setClearColor(0x0a0c11, 1); // dark stage, independent of page theme
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.15;
  mount.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0a0c11); // dark stage baked in → reads on light or dark themes
  const camera = new THREE.PerspectiveCamera(33, 1, 0.1, 100);
  camera.position.set(0, 0.4, 10);
  camera.lookAt(0, 0.3, 0);

  // coloured aurora behind the object → the glass has colour to refract
  [[0x16224f, -3.2, 1.4, -7.5, 3.0], [0x241650, 3.2, -1.2, -7.5, 3.2]]
    .forEach(([c, x, y, z, r]) => {
      const m = new THREE.Mesh(new THREE.SphereGeometry(r, 24, 24),
        new THREE.MeshBasicMaterial({ color: c }));
      m.position.set(x, y, z); scene.add(m);
    });

  // studio env (procedural, core-only) so the glass has something to refract
  const envScene = new THREE.Scene();
  envScene.add(new THREE.HemisphereLight(0xbcd0ff, 0x0a0c11, 3));
  [[BRAND.blue, -5, 3, 4], [BRAND.purple, 5, -2, 3], [0xffffff, 0, 6, -4]].forEach(([c, x, y, z]) => {
    const m = new THREE.Mesh(new THREE.SphereGeometry(2.4, 16, 16),
      new THREE.MeshBasicMaterial({ color: c }));
    m.position.set(x, y, z); envScene.add(m);
  });
  const pmrem = new THREE.PMREMGenerator(renderer);
  scene.environment = pmrem.fromScene(envScene, 0.03).texture;

  const key = new THREE.DirectionalLight(0xffffff, 2.4); key.position.set(4, 7, 6); scene.add(key);
  const rim = new THREE.DirectionalLight(0x88aaff, 1.6); rim.position.set(-6, -2, 4); scene.add(rim);
  scene.add(new THREE.AmbientLight(0x2a3350, 0.7));

  /* ── the object ─────────────────────────────────────────────────────────── */
  const obj = new THREE.Group();
  obj.rotation.x = -0.32;
  scene.add(obj);

  // shared glass material — identity carried by tint only (the binary cube)
  const glass = (tint, opts = {}) => new THREE.MeshPhysicalMaterial({
    color: 0xffffff, metalness: 0, roughness: 0.08,
    transmission: 1, ior: 1.45, thickness: 1.4, dispersion: 2.2,
    iridescence: 0.5, iridescenceIOR: 1.4,
    attenuationColor: new THREE.Color(tint), attenuationDistance: 0.9,
    clearcoat: 1, clearcoatRoughness: 0.15, envMapIntensity: 1.35,
    transparent: true, ...opts,
  });

  // tinted translucent panel — the HTTP/DB/UI planes read as filled glass, not wireframe
  const panel = (tint) => new THREE.MeshPhysicalMaterial({
    color: new THREE.Color(tint).multiplyScalar(0.22),
    emissive: new THREE.Color(tint), emissiveIntensity: 0.10,
    metalness: 0, roughness: 0.16,
    transmission: 0.55, thickness: 0.6, ior: 1.4,
    attenuationColor: new THREE.Color(tint), attenuationDistance: 0.6,
    clearcoat: 1, clearcoatRoughness: 0.2, envMapIntensity: 1.15,
    transparent: true, opacity: 0.94,
  });

  // three stacked, converging planes — HTTP / DB / UI
  const PLANES = [
    { label: 'HTTP', tint: BRAND.blue,   y: 1.95 },
    { label: 'DB',   tint: BRAND.purple, y: 1.0  },
    { label: 'UI',   tint: BRAND.light,  y: 0.05 },
  ];
  const slabGeo = new THREE.BoxGeometry(4.0, 0.13, 2.7);
  slabGeo.translate(0, 0, 0);
  PLANES.forEach((p, i) => {
    const slab = new THREE.Mesh(slabGeo, panel(p.tint));
    slab.position.set(0, p.y, -0.25);
    // converge: each plane a touch smaller + pulled toward the binary below
    const s = 1 - i * 0.07; slab.scale.set(s, 1, s);
    obj.add(slab);
    // glowing edge wireframe so the planes read on any background (bloom catches it)
    const edge = new THREE.LineSegments(new THREE.EdgesGeometry(slabGeo),
      new THREE.LineBasicMaterial({ color: p.tint, transparent: true, opacity: 0.55 }));
    edge.position.copy(slab.position); edge.scale.copy(slab.scale); obj.add(edge);
    // monospace label on the near-left corner
    obj.add(makeLabel(p.label, -1.7, p.y + 0.28, 1.45, p.tint));
  });

  // central glowing binary cube
  const cubeGeo = new THREE.BoxGeometry(1.75, 1.75, 1.75);
  const cube = new THREE.Mesh(cubeGeo, glass(0x6f7cff,
    { thickness: 1.8, dispersion: 3.0, attenuationDistance: 1.2 }));
  cube.position.set(0, -1.15, 0.55);
  obj.add(cube);
  const cubeEdges = new THREE.LineSegments(new THREE.EdgesGeometry(cubeGeo),
    new THREE.LineBasicMaterial({ color: 0xeaf0ff }));
  cubeEdges.position.copy(cube.position); obj.add(cubeEdges);
  // bright core → bloom halo (the "glow")
  const core = new THREE.Mesh(new THREE.IcosahedronGeometry(0.3, 2),
    new THREE.MeshBasicMaterial({ color: 0x86a8ff }));
  core.position.copy(cube.position); obj.add(core);
  // "binary / code" lines on the front face
  const codeMat = new THREE.MeshBasicMaterial({ color: 0xc8d6ff });
  [[0.0, 0.46], [-0.14, 0.16], [0.06, -0.14]].forEach(([dx, dy], i) => {
    const w = [0.78, 0.5, 0.64][i];
    const bar = new THREE.Mesh(new THREE.PlaneGeometry(w, 0.1), codeMat);
    bar.position.set(cube.position.x + dx, cube.position.y + dy, cube.position.z + 0.9);
    obj.add(bar);
  });

  // green accent nodes + flow lines to the binary
  const nodeMat = new THREE.MeshBasicMaterial({ color: BRAND.green });
  const nodePos = [
    [-3.2, 1.7, 0.4], [3.4, 0.9, -0.2], [-3.5, -0.9, 0.6],
    [3.3, -1.4, 0.3], [0.2, 2.7, -0.6], [-0.4, -2.4, 0.5],
  ];
  const nodes = [];
  const flowMat = new THREE.LineBasicMaterial({ color: BRAND.green, transparent: true, opacity: 0.4 });
  nodePos.forEach((p) => {
    const n = new THREE.Mesh(new THREE.SphereGeometry(0.1, 16, 16), nodeMat);
    n.position.set(...p); obj.add(n); nodes.push(n);
    const mid = new THREE.Vector3(...p).lerp(cube.position, 0.5);
    mid.z += 0.8; mid.y += 0.3;
    const curve = new THREE.QuadraticBezierCurve3(new THREE.Vector3(...p), mid, cube.position.clone());
    const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(curve.getPoints(28)), flowMat.clone());
    obj.add(line);
  });

  // faint brand flow arcs in the background (echo the SVG's data-flow curves)
  const arcMat = new THREE.LineBasicMaterial({ color: BRAND.blue, transparent: true, opacity: 0.18 });
  for (let i = 0; i < 4; i++) {
    const a = (i / 4) * Math.PI * 2;
    const r = 4.4;
    const c = new THREE.QuadraticBezierCurve3(
      new THREE.Vector3(Math.cos(a) * r, Math.sin(a) * 2.6, -2.5),
      new THREE.Vector3(0, 0, -1),
      new THREE.Vector3(Math.cos(a + 2) * r, Math.sin(a + 2) * 2.6, -2.5));
    const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints(c.getPoints(40)),
      i % 2 ? arcMat : arcMat.clone());
    if (i % 2) l.material.color.set(BRAND.purple);
    obj.add(l);
  }

  /* ── post processing ──────────────────────────────────────────────────────── */
  const composer = new EffectComposer(renderer);
  composer.addPass(new RenderPass(scene, camera));
  const bloom = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.8, 0.55, 0.62);
  composer.addPass(bloom);
  composer.addPass(new OutputPass());

  /* ── size to container ──────────────────────────────────────────────────── */
  function resize() {
    const w = mount.clientWidth || 560;
    const h = Math.round(w * 0.78);
    mount.style.height = h + 'px';
    renderer.setSize(w, h); composer.setSize(w, h);
    bloom.setSize(w, h);
    camera.aspect = w / h; camera.updateProjectionMatrix();
  }
  resize();
  new ResizeObserver(resize).observe(mount);

  /* ── motion + render ────────────────────────────────────────────────────── */
  const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
  if (!reduce) addEventListener('pointermove', (e) => {
    pointer.tx = (e.clientX / innerWidth - 0.5);
    pointer.ty = (e.clientY / innerHeight - 0.5);
  }, { passive: true });

  const clock = new THREE.Timer();
  let visible = true;
  new IntersectionObserver(([e]) => { visible = e.isIntersecting; },
    { threshold: 0.01 }).observe(mount);

  function frame(ts) {
    clock.update(ts);
    const t = clock.getElapsed();
    if (visible) {
      pointer.x += (pointer.tx - pointer.x) * 0.05;
      pointer.y += (pointer.ty - pointer.y) * 0.05;
      // gentle front-facing oscillation (keeps the 3/4 view + code face toward camera)
      obj.rotation.y = -0.32 + (reduce ? 0 : Math.sin(t * 0.32) * 0.34) + pointer.x * 0.4;
      obj.rotation.x = -0.3 + pointer.y * 0.2;
      const pulse = 1 + Math.sin(t * 2) * 0.12;
      core.scale.setScalar(reduce ? 1 : pulse);
      nodes.forEach((n, i) => n.scale.setScalar(reduce ? 1 : 1 + Math.sin(t * 2.4 + i) * 0.25));
      composer.render();
    }
    if (reduce) { composer.render(); return; } // single settle frame, then stop
    requestAnimationFrame(frame);
  }
  if (reduce) { obj.rotation.y = 0.5; requestAnimationFrame(frame); }
  else renderer.setAnimationLoop(frame);
}

/* monospace label rendered to a CanvasTexture sprite-plane */
function makeLabel(text, x, y, z, color) {
  const c = document.createElement('canvas'); c.width = 256; c.height = 64;
  const g = c.getContext('2d');
  g.fillStyle = '#' + new THREE.Color(color).getHexString();
  g.font = '600 34px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
  g.textBaseline = 'middle'; g.globalAlpha = 0.9;
  g.fillText(text, 8, 34);
  const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace;
  const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false });
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.0, 0.25), mat);
  mesh.position.set(x, y, z);
  return mesh;
}