The Architecture Behind Trionn: Coordinating GSAP, Three.js, Lenis, and Web AudioTrionn 背后的架构:协调 GSAP、Three.js、Lenis 与 Web Audio

A behind-the-scenes look at how multiple animation, rendering, and interaction layers were unified into one performant web experience.深入幕后,了解如何将多种动画、渲染和交互层统一为一个高性能的 Web 体验。

Ready to become a GSAP expert? Access the world’s most comprehensive GSAP training with 300+ lessons. Enroll now →准备好成为 GSAP 专家了吗?获取全球最全面的 GSAP 培训,包含 300 多节课程。立即报名 →

Trionn was built as an exploration of how far a studio website could be pushed when animation, WebGL, and interaction systems are treated as one unified experience. After months of experimentation and iteration, the final result combines GSAP, Three.js, Lenis, and custom Web Audio interactions into a responsive digital experience where every section is driven by its own carefully crafted system.Trionn 的构建旨在探索当动画、WebGL 和交互系统被视为一个统一的体验时,工作室网站能达到何种高度。经过数月的实验与迭代,最终成果将 GSAP、Three.js、Lenis 和自定义 Web Audio 交互结合在一起,打造出一个响应式的数字体验,其中每个部分都由精心设计的系统驱动。

The project evolved through multiple concepts before reaching its final direction. The interactive hero, scroll-driven storytelling, procedural graphics, and real-time effects were not planned as isolated features, but gradually developed into a connected animation framework.该项目在最终确定方向前经历了多个概念阶段。交互式首屏、滚动驱动的故事叙述、程序化图形和实时特效并非作为孤立功能规划,而是逐渐发展成一个相互关联的动画框架。

Built over four months, the site received recognition from FWA, GSAP, Orpetron, CSS Design Awards, Web Design Awards, CSS Winner, and several international design galleries. More importantly, the process revealed a series of technical challenges around performance, synchronization, rendering, and interaction design.该网站历时四个月建成,获得了 FWA、GSAP、Orpetron、CSS Design Awards、Web Design Awards、CSS Winner 以及多家国际设计画廊的认可。更重要的是,这一过程揭示了在性能、同步、渲染和交互设计方面的一系列技术挑战。

In this case study, we’ll explore the architecture behind Trionn, breaking down the animation systems, WebGL techniques, optimization strategies, and code patterns used to bring the experience to life.在本案例研究中,我们将探索 Trionn 背后的架构,剖析用于实现该体验的动画系统、WebGL 技术、优化策略和代码模式。

Technical Overview技术概述

Building a site with this level of animation meant balancing creative flexibility with performance. Each part of the stack has a clear responsibility, from driving animations and scroll interactions to rendering WebGL scenes and generating audio in real time.构建这样一个高动画水准的网站,意味着要在创意灵活性与性能之间取得平衡。技术栈的每一部分都有明确的职责,从驱动动画和滚动交互,到渲染 WebGL 场景以及实时生成音频。

The core technologies used throughout the project are:整个项目使用的核心技术包括:

  • GSAP + @gsap/react for timelines, page transitions, and component-level animations.GSAP + @gsap/react:用于时间轴、页面过渡和组件级动画。
  • ScrollTrigger for scroll-driven reveals, pinned sections, and scrubbed sequences.ScrollTrigger:用于滚动驱动的元素显示、固定区域和滚动进度控制序列。
  • SplitText for reusable character-, word-, and line-based text animations.SplitText:用于可重用的字符、单词和行级文本动画。
  • Three.js for the hero symbol, the Services section, and the interactive work grid.Three.js:用于首屏符号、服务板块和交互式作品网格。
  • Lenis for smooth scrolling, synchronized with GSAP.Lenis:用于平滑滚动,并与 GSAP 同步。
  • Web Audio API for generating interactive sound effects at runtime.Web Audio API:用于在运行时生成交互式音效。
  • Next.js and React as the application framework.Next.js 和 React:作为应用框架。
  • Tailwind CSS for styling.Tailwind CSS:用于样式设计。
  • Swiper for the testimonials and awards carousels.Swiper:用于客户评价和奖项轮播。

GSAP sits at the center of the animation system. Page transitions, scroll-driven sequences, pinned sections, and component-level animations are all built around GSAP timelines and managed with <a href="https://gsap.com/resources/React/">useGSAP</a>, which simplifies setup and cleanup as components mount and unmount in Next.js.GSAP 是动画系统的核心。页面过渡、滚动驱动序列、固定区域和组件级动画均围绕 GSAP 时间轴构建,并使用 <a href="https://gsap.com/resources/React/">useGSAP</a> 进行管理,这简化了 Next.js 中组件挂载和卸载时的设置与清理工作。

ScrollTrigger handles most of the site’s scroll interactions, from pinned storytelling sections to scrubbed animations and reveal effects. We also rely heavily on gsap.matchMedia() so desktop and mobile layouts can have their own animation logic instead of sharing the same values.ScrollTrigger 处理了网站大部分的滚动交互,从固定的叙事区域到滚动控制的动画和显示效果。我们还大量依赖 gsap.matchMedia(),以便桌面端和移动端布局可以拥有各自的动画逻辑,而不是共享相同的数值。

For text animation, we built a reusable BlurTextReveal component using SplitText. It supports character-, word-, and line-based animations while centralizing reduced-motion handling, GPU layer cleanup, and ScrollTrigger refreshes instead of solving those problems for every individual heading.对于文本动画,我们使用 SplitText 构建了一个可重用的 BlurTextReveal 组件。它支持字符、单词和行级动画,同时集中处理了“减少动态效果”(reduced-motion)、GPU 层清理和 ScrollTrigger 刷新,无需为每个标题单独解决这些问题。

Three.js powers the site’s custom WebGL experiences. We chose not to use React Three Fiber because we wanted direct control over the shared render loop, resource management, and the hero symbol’s individually animated mesh panels.Three.js 为网站的自定义 WebGL 体验提供支持。我们选择不使用 React Three Fiber,因为我们需要直接控制共享渲染循环、资源管理以及首屏符号中独立动画的网格面板。

Lenis is driven directly from gsap.ticker, keeping scrolling synchronized with ScrollTrigger throughout the site.Lenis 直接由 gsap.ticker 驱动,保持滚动与整个网站的 ScrollTrigger 同步。

Interactive sound effects—including the hero hover, blast, and weld effects—are generated at runtime with the Web Audio API instead of using prerecorded audio files.交互式音效(包括首屏悬停、爆炸和焊接效果)是在运行时使用 Web Audio API 生成的,而不是使用预录制的音频文件。

The Hero Section首屏区域

The hero evolved considerably over the course of the project. It combines WebGL, GSAP, SplitText, and the Web Audio API into a single interaction system, making it one of the most technically involved parts of the site.首屏在项目过程中经历了显著演变。它将 WebGL、GSAP、SplitText 和 Web Audio API 整合进一个单一的交互系统中,使其成为网站技术含量最高的部分之一。

The hero is built from two layers that share the same state. The background is a single Three.js scene (useTrionnSymbolScene.ts) responsible for the brand symbol, including its idle motion, magnetic hover, hold-to-blast interaction, and weld spark effects. These interactions all contribute to a single explodeAmt value, which controls how far the symbol’s panels separate. Whether the user scrolls, hovers, or holds the mouse button, each interaction updates the same value, allowing transitions between states to feel smooth and continuous.首屏由两个共享相同状态的层构成。背景是一个单一的 Three.js 场景 (useTrionnSymbolScene.ts),负责品牌符号的展示,包括其待机运动、磁性悬停、按住触发爆炸的交互以及焊接火花效果。这些交互都贡献于一个单一的 explodeAmt 值,该值控制符号面板的分离程度。无论是用户滚动、悬停还是按住鼠标,每次交互都会更新该值,从而使状态之间的过渡感觉平滑且连续。

The foreground consists of standard DOM elements—the headline, rotating word, and stats hint—animated with GSAP and SplitText. Using regular HTML keeps the content accessible while mix-blend-mode: difference ensures it remains readable over the WebGL canvas.前景由标准的 DOM 元素组成——标题、旋转的单词和统计提示——使用 GSAP 和 SplitText 进行动画处理。使用常规 HTML 保持了内容的可访问性,同时 mix-blend-mode: difference 确保了其在 WebGL 画布上依然清晰可读。

Both layers are synchronized through a shared transitionReady flag. Animations don’t begin until the page transition has finished, with non-critical work deferred using requestIdleCallback to avoid competing with the initial page load.两层通过一个共享的 transitionReady 标志进行同步。动画仅在页面过渡完成后开始,非关键任务使用 requestIdleCallback 推迟,以避免与初始页面加载竞争资源。

Hero Headline Reveal首屏标题显示

On page load, the hero headline (“Designed to”) animates into view one character at a time using a staggered blur-to-sharp transition. Instead of a simple fade-in, each character gradually comes into focus, creating a more dynamic introduction to the page.页面加载时,首屏标题(“Designed to”)通过交错的“模糊转清晰”过渡,逐个字符地进入视野。不同于简单的淡入,每个字符逐渐聚焦,为页面创造了更具动态感的开场。

// components/Sections/Home/Banner.tsx — usage
<BlurTextReveal
  as="h1"
  text="Designed to"
  animationType="chars" // split per-character, not word/line
  stagger={0.08}
  delay={1.2} // waits for the page loader/transition to clear first
/>

// components/TextAnimation/BlurTextReveal.tsx — the engine behind it
const split = new SplitText(textRef.current, {
  type: "chars, words, lines",
  smartWrap: true,
});

const targets = split.chars; // animationType === "chars"

gsap.set([textRef.current, targets], {
  autoAlpha: 0,
  filter: "blur(12px)",
  willChange: "filter, opacity", // promote to its own GPU layer only while animating
});

const tl = gsap.timeline({
  paused: manual,
});

tl.to(textRef.current, {
    autoAlpha: 1,
    filter: "blur(0px)",
    duration: 0.5,
  }, delay)
  .to(targets, {
    autoAlpha: 1,
    filter: "blur(0px)",
    duration: 0.8,
    stagger: {
      each: 0.08,
      from: "random",
    }, // characters settle out of order
    ease: "power2.out",
  }, delay);

Using filter: blur() alongside opacity creates the impression that the text is coming into focus, rather than simply fading in. Once the animation completes, will-change is removed so the text no longer occupies its own GPU layer. The same BlurTextReveal component is reused throughout the site for the rotating word and the stats hint, with different animation settings.同时使用 filter: blur() 和 opacity 会产生一种文本正在聚焦的错觉,而非仅仅是淡入。动画完成后,will-change 属性会被移除,这样文本就不再占用独立的 GPU 层。相同的 BlurTextReveal 组件在网站各处被重用,用于旋转单词和统计提示,并配置了不同的动画设置。

Hero Symbol: Idle State首屏符号:待机状态

When idle, the hero symbol rotates continuously while each of its three arms follows a subtle sine-wave motion with an independent phase offset. This prevents the animation from feeling perfectly synchronized and gives the symbol a more organic sense of movement.在待机状态下,首屏符号持续旋转,同时其三个臂中的每一个都跟随具有独立相位偏移的细微正弦波运动。这防止了动画看起来过于同步,并赋予了符号一种更自然的运动感。

// hooks/useTrionnSymbolScene.ts — per-frame update loop

// Auto-rotate: a constant rotational drift, eased toward the mouse position
if (!st.dragging) {
  st.rotY += prefersReducedMotion ? 0.0015 : 0.0042; // base spin speed
  st.rotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, st.rotX));

  group.rotation.x +=
    (st.rotX + mouse.y * 0.22 - group.rotation.x) * 0.06; // eased lerp
  group.rotation.y +=
    (st.rotY + mouse.x * 0.22 - group.rotation.y) * 0.06;
}

// Per-panel ambient drift — each of the 3 arms gets its own phase offset
// so the whole symbol doesn't breathe in lockstep
particles.forEach((p) => {
  const phase = p.shapeIdx * (Math.PI * 2 / 3); // 0°, 120°, 240°

  const armDriftX =
    Math.sin(t * 0.4 + phase) * 0.012 * (1 - explodeAmt);
  const armDriftY =
    Math.cos(t * 0.35 + phase) * 0.008 * (1 - explodeAmt);
  const armDriftZ =
    Math.sin(t * 0.3 + phase * 1.5) * 0.006 * (1 - explodeAmt);

  // ...position += drift, scaled down to 0 the moment any explode/hover state kicks in
});

When prefersReducedMotion is enabled, the rotation speed is reduced instead of being disabled completely. Mouse movement is applied through linear interpolation (lerp), giving the symbol a smooth, magnetic feel rather than directly matching the cursor. The ambient drift is also scaled by (1 - explodeAmt), allowing it to fade out naturally as other interaction states take over.当启用 prefersReducedMotion 时,旋转速度会降低而不是完全禁用。鼠标移动通过线性插值 (lerp) 应用,使符号具有平滑的磁性手感,而不是直接跟随光标。环境漂移也会根据 (1 - explodeAmt) 进行缩放,使其在其他交互状态接管时自然淡出。

Hero Symbol: Magnetic Hover首屏符号:磁性悬停

As the cursor moves over the symbol, the panel beneath it briefly “charges,” becoming brighter and more reflective, while a short beep plays the first time the cursor enters that panel. Hover detection is performed with raycasting instead of CSS, allowing the interaction to follow the symbol’s actual 3D geometry as it rotates.当光标移到符号上方时,下方的面板会短暂“充电”,变得更亮且更具反射性,同时在光标首次进入该面板时播放短促的蜂鸣声。悬停检测是通过射线投射 (raycasting) 而非 CSS 执行的,这使得交互可以随着符号在旋转时的实际 3D 几何形状而变化。

// hooks/useTrionnSymbolScene.ts — hover detection via raycasting

const raycaster = new THREE.Raycaster();

// Per frame: only check for hover when the symbol is fully assembled
// (not mid-explode, not scrolled away, not in the intro animation)
if (
  st.mouseScreenX !== -9999 &&
  st.scrollProgress < 0.08 &&
  st.clickBurst < 0.05 &&
  st.introAmt < 0.08
) {
  raycaster.setFromCamera(mouse, camera);

  const hits = raycaster.intersectObjects(
    particles
      .filter((p) => !p.isEdge)
      .map((p) => p.mesh as THREE.Mesh),
    false,
  );

  const nowHit = hits.length > 0 ? hits[0].object : null;

  if (nowHit !== st.hoveredMesh) {
    if (nowHit) {
      const hm = nowHit as THREE.Mesh & {
        _flash?: number;
        _flashActive?: boolean;
      };

      hm._flash = 1.0; // triggers the charge-up below
      hm._flashActive = true;

      audio.playHoverBeep(); // only fires on a new panel, not every frame
    }

    st.hoveredMesh = nowHit;
  }
}

// Elsewhere: decay the flash and ramp the material toward its "charged" look
mesh._flash = (mesh._flash || 0) * 0.92; // exponential decay each frame

const f = mesh._flash;

mat.envMapIntensity = 3.0 + f * 1.6; // brighter reflections
mat.clearcoatRoughness = Math.max(0.01, 0.05 - f * 0.035);
mat.transmission = 0.35 + f * 0.32; // more "glassy"

Raycasting against the symbol’s geometry ensures the hover effect follows its actual shape and rotation. Each panel’s highlight decays independently using a simple exponential decay, avoiding the overhead of creating a separate GSAP tween for every mesh.针对符号几何形状进行射线投射,确保了悬停效果跟随其实际形状和旋转。每个面板的高光独立衰减,使用简单的指数衰减,避免了为每个网格创建单独 GSAP 补间动画的开销。

Hero Lines: Weld Spark Effect首屏线条:焊接火花效果

Three guide lines animate outwards from the symbol when the page loads. Once the animation is complete, hovering over any of the lines triggers a short burst of weld-like sparks that arc toward one or two of the remaining lines, reinforcing the hero’s prompt: “Dare ⚡ to touch the lines.”页面加载时,三条引导线从符号向外动画延伸。动画完成后,悬停在任何线条上都会触发一阵短促的焊接火花,电弧向剩余的一两条线延伸,强化了首屏的提示:“Dare ⚡ to touch the lines.”

// hooks/useTrionnSymbolScene.ts

// Sparks are only enabled once the guide lines have finished drawing
const baseLinesReadyForSpark =
  inS1 &&
  undrawAmt < 0.02 &&
  st.lineState.every((s) => s.prog >= 0.995);

if (baseLinesReadyForSpark) {
  // Hit-test the mouse against all 3 line paths (14px tolerance)
  const allLinePts = [ptsL, ptsR, ptsB];

  let hitResult: { x: number; y: number } | null = null;
  let hitLineIdx = -1;

  for (let li = 0; li < allLinePts.length; li++) {
    const h = mouseNearLine(allLinePts[li], 14);

    if (h) {
      hitResult = h;
      hitLineIdx = li;
      break;
    }
  }

  if (hitResult !== null) {
    // New hover onto a line (not a continuous hold) → arm a short burst
    if (!st.sparkHoverActive && st.sparkWasAway) {
      st.sparkHoverActive = true;
      st.sparkBurstLeft = 5 + Math.floor(Math.random() * 2); // 5–6 bolts per hover
      st.sparkWasAway = false;
    }

    if (st.weldCooldown <= 0 && st.sparkBurstLeft > 0) {
      const wp = unproj2(hitResult.x, hitResult.y); // screen → world space

      // Pick 1–2 other lines as targets
      const otherIdxs = [0, 1, 2].filter((i) => i !== hitLineIdx);
      const count = Math.random() > 0.5 ? 1 : 2;

      const targetIdxs = otherIdxs
        .sort(() => Math.random() - 0.5)
        .slice(0, count);

      const nearWpts = targetIdxs.map((li) => {
        // Find the closest point on the target line to the hit position
        const pts = allLinePts[li];

        let bestPt: LinePt | null = null;
        let bestD = Infinity;

        for (const pt of pts) {
          const dd =
            (pt.x - hitResult!.x) ** 2 +
            (pt.y - hitResult!.y) ** 2;

          if (dd < bestD) {
            bestD = dd;
            bestPt = pt;
          }
        }

        return unproj2(bestPt!.x, bestPt!.y);
      });

      triggerWeld(wp, nearWpts, !st.sparkSoundPlayed);

      st.sparkBurstLeft--;
      st.weldCooldown = 0.04 + Math.random() * 0.06; // throttle between bolts
    }
  }
}

A “ready” check ensures the weld effect is only enabled once all three guide lines have finished drawing. The sparkWasAway flag triggers a new burst only when the cursor enters a line, preventing a continuous stream of sparks while hovering. Each burst varies slightly, with a random number of bolts and randomly selected target lines, so the interaction never plays out exactly the same way twice.“就绪”检查确保焊接效果仅在所有三条引导线绘制完成后才启用。sparkWasAway 标志仅在光标进入线条时触发新的爆发,防止悬停时产生连续的火花流。每次爆发略有不同,螺栓数量和目标线条是随机选择的,因此交互永远不会两次完全相同。

Bolt generation is also rate-limited using weldCooldown, which spaces each bolt 0.04–0.10 seconds apart regardless of frame rate. The glow effect is built by layering THREE.Line geometries, providing the desired look without the cost of a post-processing bloom pass.螺栓生成也使用 weldCooldown 进行速率限制,无论帧率如何,每条螺栓间隔 0.04–0.10 秒。发光效果是通过叠加 THREE.Line 几何体构建的,在不产生后期处理泛光 (bloom) 开销的情况下提供了预期的外观。

The guide lines themselves are rendered to an offscreen 2D <canvas>, which is then used as a texture in the Three.js scene. This allows the weld effect to perform lightweight 2D hit testing against canvas-space coordinates instead of raycasting against 3D geometry. Because the effect behaves more like a particle system than a UI animation, its timing is driven by simple counters instead of GSAP timelines. A spark sound, synthesized with the Web Audio API, plays once per burst rather than once per bolt to prevent overlapping audio.引导线本身被渲染到一个离屏 2D <canvas> 上,然后作为纹理在 Three.js 场景中使用。这允许焊接效果针对画布空间坐标执行轻量级的 2D 碰撞检测,而不是针对 3D 几何体进行射线投射。由于该效果的行为更像粒子系统而非 UI 动画,其时序由简单的计数器而非 GSAP 时间轴驱动。使用 Web Audio API 合成的火花音效每次爆发播放一次,而非每个螺栓播放一次,以防止音频重叠。

Hero Symbol: Hold-to-Blast首屏符号:按住触发爆炸

Clicking and holding the hero symbol triggers a multi-stage interaction. Nearby interface elements, including the navigation and headings, begin to vibrate as the charge builds. After roughly half a second, the symbol breaks apart into its individual panels, each following its own trajectory and rotation while an explosion and sustained “whoosh” sound play. Releasing the mouse reverses the sequence, smoothly bringing the symbol back together.点击并按住首屏符号会触发多阶段交互。随着电荷积累,包括导航和标题在内的附近界面元素开始振动。大约 0.5 秒后,符号分解为独立的面板,每个面板沿着自己的轨迹和旋转移动,同时伴随爆炸声和持续的“呼啸”声。松开鼠标会反转该序列,平滑地将符号重新组合。

Press Down: Start the Charge-Up按下:开始充电

When the user presses the symbol, the interaction enters a charging state by resetting the timer and activating the initial vibration feedback before the blast sequence begins.当用户按下符号时,交互通过重置计时器并在爆炸序列开始前激活初始振动反馈,进入充电状态。

const onMouseDown = (e: MouseEvent) => {
  // ...hit-test guard omitted...

  st.holding = true;
  st.holdTime = 0;
  st.vibrateAmt = 1.0;
  st.vibratePhase = 0;
  st.clickBurst = 0;
  st.joinPlayed = false;
};

window.addEventListener("mousedown", onMouseDown);

Charge-Up and Blast充电与爆炸

While the mouse button is held, the interaction progresses through two phases. The first 0.5 seconds are dedicated to the charge-up animation. Once that threshold is reached, the symbol breaks apart into its individual panels, transitioning into the blast sequence.按住鼠标按钮时,交互经历两个阶段。前 0.5 秒用于充电动画。一旦达到该阈值,符号就会分解为独立的面板,进入爆炸序列。

if (st.holding) {
  st.holdTime += 1 / 60;
  st.vibrateAmt = 1.0;

  if (st.holdTime < 0.5) {
    st.clickBurst = 0; // still charging
  } else {
    if (st.clickBurst === 0) {
      // first frame past the threshold
      audio.stopVibrateSound();
      audio.playExplodeSound();
      audio.startWooshSound();
    }

    st.vibrateAmt *= 0.88;
    st.clickBurst = Math.min(1.0, st.clickBurst + 0.02); // ramps from 0 → 1 over ~50 frames
  }
} else {
  // Released — both values ease back down instead of snapping to 0
  st.vibrateAmt = Math.max(0, st.vibrateAmt - 0.08);
  st.clickBurst = Math.max(0, st.clickBurst - 0.025);
}

clickBurst Drives the ExplosionclickBurst 驱动爆炸

The clickBurst value controls how far each panel moves from its original position. As it increases from 0 to 1, every panel follows its own predefined direction and rotation, creating the effect of the symbol breaking apart while remaining fully deterministic.clickBurst 值控制每个面板从其原始位置移动的距离。随着它从 0 增加到 1,每个面板遵循其预定义的方向和旋转,产生符号分解的视觉效果,同时保持完全确定性。

const burstContrib =
  st.scrollProgress < 0.15 ? st.clickBurst : 0;

const explodeAmt = Math.max(
  st.scrollProgress,
  st.hoverAmt,
  burstContrib,
  st.introAmt,
);

particles.forEach((p) => {
  const amt = Math.max(0, explodeAmt - p.delay); // staggered by each panel's delay
  const burst = amt * 5.5;

  p.mesh.position.set(
    p.explodeDir.x * burst + /* ...idle drift, mouse offset... */ 0,
    p.explodeDir.y * burst,
    p.explodeDir.z * burst,
  );

  p.mesh.rotation.x =
    p.spinAxis.x * p.spinSpeed * amt * Math.PI;
});

Nearby UI Elements React to the Charge附近 UI 元素对电荷的反应

While the symbol charges, nearby interface elements—including the navigation and headings—use the same shared state to add a subtle vibration effect. When the interaction ends, they return smoothly to their resting position using CSS transitions.当符号充电时,附近的界面元素(包括导航和标题)使用相同的共享状态来增加细微的振动效果。交互结束时,它们使用 CSS 过渡平滑地返回到静止位置。

vibrateEls.forEach((el) => {
  el.style.transition = "none";
  el.style.transform = `translate(${sx}px, ${sy}px)`; // sx/sy from a sine wave
});

// On release:
el.style.transition =
  "transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
el.style.transform =
  "perspective(600px) translate(0px, 0px) rotateX(0deg)";

The 0.5-second charge-up introduces a deliberate delay before the explosion, making the interaction feel intentional rather than instantaneous. A single explodeAmt value combines the effects of scrolling, hovering, and the hold-to-blast interaction using Math.max(), allowing every state to share the same animation logic. Because the interaction is driven by state values instead of tweens, releasing the mouse at any point smoothly reverses the effect without requiring a separate animation path.0.5 秒的充电时间在爆炸前引入了刻意的延迟,使交互感觉是有意为之而非瞬间触发。单一的 explodeAmt 值通过 Math.max() 结合了滚动、悬停和按住爆炸交互的效果,允许所有状态共享相同的动画逻辑。由于交互由状态值而非补间驱动,在任何点松开鼠标都会平滑地反转效果,而无需单独的动画路径。

Services Scroll Sequence服务板块滚动序列

The Services section is the most complex scroll-driven sequence on the site. A single shared scrollProgressRef value (0–1) coordinates every part of the experience: scrubbing through a 371-frame WebP image sequence, breaking the “OUR SERVICES” headline into animated glyph particles, revealing the six service cards along predefined motion paths, transitioning the site’s color palette from black to white, and finishing with a stripe wipe into the Testimonials section.服务板块是网站上最复杂的滚动驱动序列。单一的共享 scrollProgressRef 值 (0–1) 协调着体验的每一部分:在 371 帧 WebP 图像序列中滚动、将“OUR SERVICES”标题分解为动画字形粒子、沿预定义运动路径显示六个服务卡片、将网站配色从黑色过渡到白色,最后以条纹擦除效果结束并进入评价板块。

A Shared Scroll Driver共享滚动驱动器

The entire Services sequence is driven by a single normalized scrollProgressRef value ranging from 0 to 1. Rather than creating separate timelines for each animation, individual progress ranges are derived from this shared value to control the image sequence, headline animation, service cards, color transitions, and section outro. This approach keeps every part of the sequence synchronized while making it easier to adjust the timing of individual effects.整个服务序列由单一的归一化 scrollProgressRef 值(0 到 1)驱动。我们没有为每个动画创建单独的时间轴,而是从这个共享值中派生出各个进度范围,以控制图像序列、标题动画、服务卡片、颜色过渡和板块结尾。这种方法使序列的每一部分保持同步,同时也更容易调整单个效果的时序。

// components/Sections/Home/TrionnServices.tsx

const TOTAL = 371;

const EXPLODE_START = 0.35;
const EXPLODE_END = 0.53;
const CARDS_START = 0.56;
const CARDS_END = 1.0;

// Inside the RAF loop:
const linear = scrollProgressRef.current; // 0 → 1, owned by the parent bridge

s.scrollT = mapServicesScrollProgress(linear, isMobile); // remapped for this section

const targetFrame = s.scrollT * (TOTAL - 1);
s.videoIdx += (targetFrame - s.videoIdx) * 0.12; // ease toward the target frame

drawFrame(s.videoIdx); // updates the <img> source

const inZone =
  s.scrollT >= EXPLODE_START &&
  s.scrollT <= EXPLODE_END;

const explodeT = inZone
  ? (s.scrollT - EXPLODE_START) / (EXPLODE_END - EXPLODE_START)
  : 0;

if (inZone && s.gsapTL) {
  s.gsapTL.progress(explodeT);
}

updateCards(s.cardsT); // cards use their own smoothed copy of scrollT

The Image Sequence图像序列

The background animation is a sequence of 371 WebP frames displayed by updating the src of a standard <img> element. Instead of rendering video or using a <canvas>, this approach keeps the implementation lightweight while still allowing the animation to be scrubbed directly by the scroll position.背景动画是一组 371 帧的 WebP 图像序列,通过更新标准 <img> 元素的 src 来显示。这种方法无需渲染视频或使用 <canvas>,保持了实现的轻量化,同时仍允许动画直接根据滚动位置进行控制。

// drawFrame — update a single <img> instead of using <canvas> or <video>
const drawFrame = useCallback((i: number) => {
  const el = imgRef.current;
  const img = stateRef.current.imgs[Math.round(i)];

  if (!img || !img.complete) return;

  // Only update the DOM when the frame actually changes
  if (el.src !== img.src) {
    el.src = img.src;
  }
}, []);

// Preload all 371 frames in idle-time chunks of 20
const loadChunk = (start: number) => {
  const end = Math.min(start + CHUNK, TOTAL);

  for (let i = start; i < end; i++) {
    const img = new Image();

    img.src = `/images/stone/frame_${String(i + 1).padStart(4, "0")}.webp`;

    // decode() avoids jank when the frame is first displayed
    img.decode().then(checkChunkDone, checkChunkDone);
  }
};

Headline Particle Explosion标题粒子爆炸

The “OUR SERVICES” headline is split into individual glyphs, each measured and animated independently. As the scroll reaches the transition point, every glyph follows its own trajectory, creating the effect of the text breaking apart before the service cards are introduced.“OUR SERVICES”标题被拆分为单个字形,每个字形都经过测量并独立动画化。当滚动达到过渡点时,每个字形沿着自己的轨迹移动,在引入服务卡片之前产生文本分解的效果。

// Measure each character's on-screen position using the Range API.
// This matches the rendered layout, including kerning and line wrapping.
const measureChars = useCallback(() => {
  overlay.querySelectorAll("[data-line]").forEach((line) => {
    // ...

    for (let i = 0; i < raw.length; i++) {
      range.setStart(textNode, i);
      range.setEnd(textNode, i + 1);

      const r = range.getBoundingClientRect();

      results.push({
        ch: display[i],
        x: r.left + r.width / 2,
        y: r.top + r.height / 2,
        /* font props */
      });
    }
  });

  return results;
}, []);

// Each measured character becomes its own <span>, preserving the original
// typography before being animated along an individual trajectory.
m.forEach((p, i) => {
  const isHero = hi.has(i);

  const angle = rand(-Math.PI, Math.PI);
  const speed = isHero
    ? rand(0.05, 0.15) * maxDim
    : rand(0.4, 0.9) * maxDim;

  s.particles.push({
    el,
    ox: p.x,
    oy: p.y,
    dirX: Math.cos(angle),
    dirY: Math.sin(angle) * rand(-1.0, 0.18),
    speed,
    /* ... */
  });
});

Service Card Animation服务卡片动画

As the headline particles disperse, the six service cards animate into view along predefined curved paths. On desktop, the cards are introduced in left and right pairs, creating a balanced composition while keeping the scroll sequence easy to follow.随着标题粒子散开,六个服务卡片沿着预定义的曲线路径进入视野。在桌面端,卡片以左右成对的方式引入,在保持滚动序列易于跟随的同时,创造了平衡的构图。

// Desktop: each pair starts 0.2 timeline units apart and follows a curved X path
const arc =
  frac <= 0.5 ? Math.sin(frac * Math.PI) : 1;

const lX = lStartX + arc * (lPeakX - lStartX);
const lY = lStartY + frac * (lEndY - lStartY); // Y moves linearly from bottom to top

frames.push({
  x: lX,
  y: lY,
  opacity: op,
});

// Once a pair reaches its center point, animate the SVG icon stroke
if (!s.svgFired.has(lk) && tlTime >= centerTime) {
  s.svgFired.add(lk);

  gsap.fromTo(
    paths,
    {
      drawSVG: "0%",
    },
    {
      drawSVG: "100%",
      duration: 1.5,
      stagger: 0.04,
    },
  );
}

Stripe Wipe Transition条纹擦除过渡

The section ends with a stripe wipe transition that is reused throughout the site, including the Vision and About sections. Using the same transition pattern across multiple sections helps maintain visual consistency while keeping the implementation centralized and reusable.该板块以条纹擦除过渡结束,该效果在网站各处(包括愿景和关于板块)被重用。在多个板块中使用相同的过渡模式有助于保持视觉一致性,同时使实现保持集中和可重用。

// `applyStripeHold` scrubs a paused stripe reveal timeline over the final
// portion of the section's scroll range, then slides the Testimonials section
// into view using a GPU-accelerated `yPercent` transform.

const holdT = Math.max(
  0,
  Math.min(1, (linear - holdStart) / (1 - holdStart)),
);

cache.tl.progress(holdT);

Unlike most of the site, this section doesn’t use ScrollTrigger. Every animation is derived from a single scroll progress value that’s recalculated each frame, keeping the entire sequence synchronized without coordinating multiple timelines.与网站大部分内容不同,此板块不使用 ScrollTrigger。每个动画都源自一个每一帧重新计算的单一滚动进度值,从而在不协调多个时间轴的情况下保持整个序列同步。

To avoid blocking the initial page load, the 371 WebP frames are preloaded in requestIdleCallback batches of 20, with img.decode() used to prepare each frame before it’s displayed. The service card motion follows a different approach: the GSAP timeline is built once whenever the layout changes and then scrubbed via .progress() during scrolling, avoiding the cost of recalculating each card’s trajectory every frame.为避免阻塞初始页面加载,371 帧 WebP 图像在 requestIdleCallback 中以 20 帧为一批进行预加载,并在显示前使用 img.decode() 准备每一帧。服务卡片的运动遵循不同的方法:GSAP 时间轴在布局更改时构建一次,然后在滚动期间通过 .progress() 进行控制,避免了每一帧重新计算每个卡片轨迹的开销。

Dribbble Helix GalleryDribbble 双螺旋画廊

The Double Helix Gallery combines DOM elements and WebGL to create a scroll-driven 3D sequence. Nine cards are arranged along a parametric helix that rotates past the camera as the user scrolls, while two animated guide lines trace the structure. Cards respond to hover through raycasting, and the sequence concludes with the helix unfolding into a flat grid, complete with rounded-corner masking and a decaying ripple effect.双螺旋画廊结合了 DOM 元素和 WebGL,创造出滚动驱动的 3D 序列。九个卡片排列在参数化螺旋线上,随着用户滚动而绕相机旋转,同时两条动画引导线勾勒出结构。卡片通过射线投射响应悬停,序列最后以螺旋展开为平面网格结束,并配有圆角遮罩和衰减的波纹效果。

Building the Helix构建螺旋

The gallery layout is generated entirely with parametric equations rather than a prebuilt 3D model. Each card’s position and orientation are calculated from its place along the helix, making the entire structure procedural and easy to adapt as the user scrolls.画廊布局完全由参数方程生成,而非预制的 3D 模型。每个卡片的位置和方向均根据其在螺旋线上的位置计算得出,使得整个结构具有程序化特性,并易于随着用户滚动进行调整。

// components/DribbleSection.tsx

const dip = (a: number) => {
  const d = (a - MID) / DIP_S;
  return DIP_A * Math.exp(-d * d); // Gaussian dip at the midpoint
};

const hPos = (a: number) =>
  new THREE.Vector3(
    R * Math.cos(a),
    Y_START + a * pitchPerRad - dip(a), // rises steadily with a subtle midpoint dip
    R * Math.sin(a),
  );

Bending Cards onto the Helix将卡片弯曲到螺旋线上

Rather than positioning individual meshes around the helix, each card’s geometry is deformed directly so it naturally follows the curve. By rewriting the vertex positions, every card bends to match the shape of the helix while remaining a single mesh, producing a much more convincing result than simply rotating flat planes.我们没有在螺旋周围放置独立的网格,而是直接对每个卡片的几何形状进行变形,使其自然跟随曲线。通过重写顶点位置,每个卡片弯曲以匹配螺旋形状,同时保持为一个单一网格,产生的结果比仅仅旋转平面要逼真得多。

// wrapCardOnHelix — runs once per visible card, per frame
for (let col = 0; col < C; col++) {
  const angle =
    (sArcStart + (col / W_SEGS) * sArcWidth) / dsPerRad;

  // Position each column along the helix using inline scalar math.
  // Avoiding Vector3 allocations keeps the render loop free of GC pressure.
  for (let row = 0; row < 2; row++) {
    pos.setXYZ(
      row * C + col,
      cpX + ux * offsetAmt,
      cpY + uy * offsetAmt,
      cpZ + uz * offsetAmt,
    );
  }
}

pos.needsUpdate = true;

Scroll-Driven Rendering滚动驱动渲染

The helix isn’t rendered in a continuous animation loop. Instead, rendering is driven directly by scroll position, with a lightweight ticker only running when needed to let interactions and transitions settle smoothly. This keeps the scene responsive while avoiding unnecessary work when the helix is at rest.螺旋不是在连续的动画循环中渲染的。相反,渲染直接由滚动位置驱动,轻量级的 ticker 仅在需要时运行,以使交互和过渡平滑地完成。这保持了场景的响应性,同时避免了螺旋静止时不必要的计算。

const st = ScrollTrigger.create({
  trigger: section,
  start: "top top",
  end: `+=${totalScroll}`,
  pin: true,

  onUpdate: () => {
    renderTick(); // render immediately on every scroll update
    syncTicker?.(); // determine whether the idle ticker should remain active
  },
});

const isActive = () => {
  const margin = window.innerHeight; // one viewport before and after the section

  const viewTop = window.scrollY - margin;
  const viewBottom = window.scrollY + window.innerHeight + margin;

  return (
    viewBottom > st.start &&
    viewTop < st.start + totalScroll
  );
};

syncTicker = () => {
  isActive() ? startTicker() : stopTicker();
};

Raycast-Based Hover Interaction基于射线投射的悬停交互

Cards respond to hover using Three.js raycasting rather than DOM events. Instead of instantly changing size, each card smoothly eases toward a target scale, making the interaction feel more natural and preserving the fluid motion of the helix.卡片通过 Three.js 射线投射而非 DOM 事件响应悬停。每个卡片不是瞬间改变大小,而是平滑地向目标缩放比例过渡,使交互感觉更自然,并保留了螺旋的流体运动。

if (pointerActive) {
  raycaster.setFromCamera(pointer, cam);

  const hits = raycaster.intersectObjects(
    cards.filter((c) => c.visible),
    false,
  );

  if (hits.length > 0) {
    hoveredCard = hits[0].object as THREE.Mesh;
  }
}

for (let k = 0; k < N; k++) {
  const cur = cards[k].userData.hoverScale ?? 1.0;
  const target = cards[k] === hoveredCard ? 1.12 : 1.0;

  // Ease toward the target scale instead of snapping instantly
  cards[k].userData.hoverScale =
    cur + (target - cur) * hoverLerpK;
}

Rounded Corners with a Fragment Shader带有片元着色器的圆角

Rather than relying on transparent PNGs or nine-slice assets, each card uses a lightweight fragment shader to generate rounded corners procedurally. A signed-distance function masks the image in the shader, producing crisp edges at any size while keeping the geometry simple and the rendering efficient.每个卡片使用轻量级片元着色器程序化生成圆角,而非依赖透明 PNG 或九宫格切片。有向距离函数 (SDF) 在着色器中对图像进行遮罩,在任何尺寸下都能产生清晰的边缘,同时保持几何形状简单且渲染高效。

// Fragment shader — rounded corners for the gallery cards

vec2 q = abs(pxPos) - halfSize + uRadius;

float dist =
  min(max(q.x, q.y), 0.0) +
  length(max(q, 0.0)) -
  uRadius;

float alpha = 1.0 - smoothstep(-0.5, 0.5, dist);

if (alpha <= 0.0) {
  discard;
}

gl_FragColor = vec4(texture2D(map, vUv).rgb, alpha);

Warming Up the Scene场景预热

To prevent a noticeable hitch when the section first comes into view, the WebGL scene is warmed up before the user reaches it. Textures, shaders, and geometry are rendered once ahead of time, ensuring the first visible frame is already prepared and the scroll experience remains smooth.为防止该板块首次进入视野时出现明显的卡顿,WebGL 场景在用户到达之前进行了预热。纹理、着色器和几何体提前渲染一次,确保第一个可见帧已经准备就绪,滚动体验保持平滑。

// Shader compilation and texture upload normally happen on the first render.
// Warming up the scene ahead of time avoids that work landing on the first
// visible frame.

const warmUp = () => {
  cards.forEach((m) => (m.visible = true));

  renderer.compile(scene, cam); // compile all shaders
  renderer.render(scene, cam);  // upload textures and initialize GPU resources

  cards.forEach((m, i) => {
    m.visible = wasVisible[i];
  });

  renderer.clear(); // discard the warm-up frame
};

// Run once on initialization, then again after all textures have loaded.
warmUp();

Rendering is driven directly by ScrollTrigger updates rather than a continuously running animation loop. A gsap.ticker only subscribes while the section is within one viewport of the screen and there are still animations settling, such as hover easing or the ripple effect, reducing unnecessary work when the scene is idle.渲染直接由 ScrollTrigger 更新驱动,而非持续运行的动画循环。gsap.ticker 仅在场景位于屏幕一个视口范围内且仍有动画(如悬停缓动或波纹效果)在完成时订阅,从而在场景空闲时减少不必要的工作。

To avoid a noticeable hitch the first time the gallery appears, the scene is also explicitly warmed up. Shaders are compiled and textures uploaded to the GPU before the section becomes visible, moving that one-time initialization cost off the user’s first scroll into the experience.为避免画廊首次出现时出现明显的卡顿,场景也进行了显式预热。着色器在板块可见前进行编译,纹理上传到 GPU,将一次性初始化成本从用户的首次滚动体验中移出。

Footer Wire Logo & Smoke页脚线框 Logo 与烟雾

The footer combines SVG, Web Audio, and WebGL into a single interactive experience. The wireframe wordmark behaves like a set of guitar strings that can be plucked with the cursor, producing synthesized notes and animated waves. At the same time, a separate WebGL smoke layer reacts to the live audio signal, responding not only to user interaction but also to the frequency content of the sound itself.页脚将 SVG、Web Audio 和 WebGL 结合为一个交互式体验。线框字标表现得像一组可以用光标拨动的吉他弦,产生合成音符和动画波浪。同时,一个独立的 WebGL 烟雾层对实时音频信号做出反应,不仅响应用户交互,还响应声音本身的频率内容。

Each SVG Stroke Becomes a String每个 SVG 笔画成为一根弦

Every stroke of the SVG wordmark is treated as an independent string with its own oscillation state. Hovering or clicking injects energy into that string, causing it to vibrate while triggering a synthesized note. Because each path maintains its own state, multiple strings can be plucked independently, allowing overlapping interactions without interfering with one another.SVG 字标的每一笔都被视为具有自身振荡状态的独立弦。悬停或点击会向该弦注入能量,使其振动并触发合成音符。由于每个路径维护自己的状态,多根弦可以独立拨动,允许重叠交互而不相互干扰。

// components/Footer/TrionnFooterLogo.tsx

for (const p of paths) {
  if (!hasStroke(p)) continue;

  const ep = getEndpoints(p); // actual start/end points of the SVG path

  const state: StringState = {
    x1: ep.x1,
    y1: ep.y1,
    x2: ep.x2,
    y2: ep.y2,
    amp: 0,
    phase: 0,
    speed: 0,
    cycles: 2.2,
    note: DEFAULT_SCALE[i % DEFAULT_SCALE.length] * (i % 2 ? 1 : 0.5),
    intensity:
      i === 0
        ? 0
        : Math.pow(i / (paths.length - 1), 1.25),
  };

  p.addEventListener("mouseenter", () => {
    state.amp = hoverAmp;
    state.speed = 18;

    gsap.killTweensOf(state);

    // Ease the string back to its resting state
    gsap.to(state, {
      amp: 0,
      duration: 0.9,
      ease: "expo.out",
    });

    gsap.to(state, {
      speed: 0,
      duration: 0.9,
      ease: "expo.out",
    });

    pluckFluteDreamy(state.note, state.intensity);
    pulseSmoke(0.4); // notify the fog layer
  });
}

The Wave Is Drawn Procedurally波浪的程序化绘制

Rather than relying on a CSS animation, the wave is recalculated and redrawn every frame. This makes it possible to control the amplitude, frequency, and damping of each string independently, so every interaction feels responsive and behaves like a plucked wire instead of a looping animation.波浪不是依赖 CSS 动画,而是每一帧重新计算和绘制的。这使得独立控制每根弦的振幅、频率和阻尼成为可能,因此每个交互感觉都非常灵敏,表现得像拨动的线,而不是循环动画。

const makeWavePath = (
  x1,
  y1,
  x2,
  y2,
  amp,
  phase,
  cycles,
) => {
  const ux = (x2 - x1) / len;
  const uy = (y2 - y1) / len; // unit vector along the string

  const px = -uy;
  const py = ux; // perpendicular ("wobble") direction

  let d = `M ${x1} ${y1}`;

  for (let i = 1; i <= 26; i++) {
    const t = i / 26;

    const env = Math.sin(Math.PI * t); // zero at the ends, strongest at the center
    const wobble = Math.sin(
      Math.PI * 2 * cycles * t + phase,
    );

    const x =
      x1 +
      (x2 - x1) * t +
      px * wobble * amp * env;

    const y =
      y1 +
      (y2 - y1) * t +
      py * wobble * amp * env;

    d += ` L ${x} ${y}`;
  }

  return d;
};

// Update only strings that are still moving.
if (st.amp > 0.02 || st.speed > 0.02) {
  st.phase += st.speed * dt;

  el.setAttribute(
    "d",
    makeWavePath(
      st.x1,
      st.y1,
      st.x2,
      st.y2,
      st.amp,
      st.phase,
      st.cycles,
    ),
  );
}

Enlarging the Hit Area扩大点击区域

Because the visible SVG strokes are only a few pixels wide, interacting with them directly would be frustrating. Instead, each string has an invisible duplicate with a much thicker stroke that’s used exclusively for pointer events. This provides a much larger hit area while keeping the visual appearance of the logo unchanged.由于可见的 SVG 笔画只有几个像素宽,直接与它们交互会令人沮丧。因此,每根弦都有一个看不见的副本,具有更粗的笔画,专门用于指针事件。这提供了更大的点击区域,同时保持 Logo 的视觉外观不变。

const clone = p.cloneNode(true) as SVGPathElement;

clone.setAttribute("stroke", "transparent");
clone.style.strokeWidth = `${Math.max(12, strokeWidth * 18)}`; // much wider than the visible stroke
clone.setAttribute("pointer-events", "stroke");

clone.addEventListener("mouseenter", () => {
  p.dispatchEvent(
    new Event("mouseenter", {
      bubbles: true,
    }),
  );
});

p.parentNode?.insertBefore(clone, p.nextSibling);

Synthesizing the Pluck Sound合成拨动音效

Rather than playing prerecorded audio, each pluck is synthesized in real time using the Web Audio API. Three slightly detuned sine-wave oscillators are layered together and fed through a feedback delay, producing a soft, flute-like sound that responds instantly to every interaction.每次拨动不是播放预录音频,而是使用 Web Audio API 实时合成。三个轻微失谐的正弦波振荡器叠加在一起,并通过反馈延迟处理,产生一种能够瞬间响应每次交互的柔和、类似长笛的声音。

const osc1 = ctx.createOscillator();
osc1.frequency.setValueAtTime(freq, now);

const osc2 = ctx.createOscillator();
osc2.frequency.setValueAtTime(freq * 2, now); // octave

const osc3 = ctx.createOscillator();
osc3.frequency.setValueAtTime(freq * 3, now); // harmonic

// Slow vibrato applied to the fundamental oscillator
const lfo = ctx.createOscillator();
lfo.frequency.setValueAtTime(4.9, now);

lfoGain.connect(osc1.frequency);

const delay = ctx.createDelay(1.0);
delay.delayTime.setValueAtTime(0.14, now);

const fb = ctx.createGain(); // feedback loop for the echo/reverb tail
fb.gain.linearRampToValueAtTime(
  0.32 + intensity * 0.12,
  now + 0.05,
);

delay.connect(echoLP);
echoLP.connect(fb);
fb.connect(delay);

Procedural Fog with a Fragment Shader带片元着色器的程序化雾气

The fog effect is rendered as a single full-screen fragment shader rather than a particle system. Layered fractal noise (FBM) creates the base pattern, while domain warping breaks up repetition and gives the smoke a more organic flow. The result is a lightweight effect that continuously drifts upward and responds smoothly to user interaction.雾气效果渲染为单一的全屏片元着色器,而非粒子系统。层叠分形噪声 (FBM) 创建了基础图案,而域扭曲 (domain warping) 打破了重复性,赋予烟雾更自然的流动感。结果是一个持续向上漂移并平滑响应用户交互的轻量级效果。

// components/Footer/FooterFog.tsx — fragment shader

float fbm(vec2 p) {
  // Fractal Brownian Motion: layered noise at progressively smaller scales
  float v = 0.0;
  float a = 0.5;

  for (int i = 0; i < 3; i++) {
    v += a * vnoise(p);
    p = p * 2.1 + vec2(3.7, 8.3);
    a *= 0.5;
  }

  return v;
}

float rise = T * 0.07; // slow upward drift over time

vec2 q = vec2(
  uv.x * aspect * 3.0,
  (1.0 - y) * 4.5 + rise
);

float f = fbm(
  q + 1.4 * fbm(q2 + ...)
  + ...
); // domain-warped noise creates organic, non-repeating smoke

// H is the hover energy injected by logo interactions.
vec3 col = mix(charcoal, ashGrey, pow(f, 1.0));
col = mix(col, lightGrey, pow(f, 2.2));
col = mix(col, hoverTint, H * 0.55 * f);

Audio-Reactive Fog音频响应式雾气

The fog doesn’t simply respond to hover events. Instead, it listens to the same audio graph used to synthesize the pluck sounds through a live AnalyserNode. This allows the shader to react to the actual frequency content of the audio, making the movement and intensity of the smoke reflect the sound being played rather than a simple on/off trigger.雾气不仅响应悬停事件。它通过实时 AnalyserNode 监听用于合成拨动音效的相同音频图。这允许着色器对音频的实际频率内容做出反应,使烟雾的运动和强度反映正在播放的声音,而非简单的开/关触发器。

// FooterFog reads from an AnalyserNode tapped off the logo's audio graph.
if (analyser && freqData) {
  analyser.getByteFrequencyData(freqData);

  let sum = 0;

  // Measure the energy in the mid-frequency range.
  for (let i = 2; i < freqData.length * 0.5; i++) {
    sum += freqData[i];
  }

  const raw =
    sum / (freqData.length * 0.5 * 255);

  // Smooth the response for a more natural attack and decay.
  freqEnergy +=
    (raw - freqEnergy) * lerpFactor;
}

// Louder notes make the fog morph more quickly.
morphOffset +=
  (4.0 + freqEnergy * 16.0 + hoverBoost * 2.8) * dt;

The pluck sound is synthesized entirely in real time using the Web Audio API, combining three oscillators, a subtle vibrato LFO, and a feedback delay instead of relying on prerecorded audio. To make the interaction feel effortless, each visible SVG stroke also has a much wider invisible duplicate that handles pointer events, allowing thin lines to remain easy to hover and click.拨动音效完全使用 Web Audio API 实时合成,结合了三个振荡器、细微的颤音 LFO 和反馈延迟,而非依赖预录音频。为使交互感觉毫不费力,每个可见的 SVG 笔画都有一个更宽的不可见副本处理指针事件,使细线依然易于悬停和点击。

The logo and fog remain loosely coupled through a small shared atmosphere context. The logo simply exposes methods such as pulseSmoke() and getSmokeAnalyser(), while the fog only reacts to the data it receives. It doesn’t know what triggered the event—only that new energy is available—keeping the effect cleanly separated and easy to reuse elsewhere.Logo 和雾气通过一个小的共享氛围上下文保持松散耦合。Logo 只暴露 pulseSmoke() 和 getSmokeAnalyser() 等方法,而雾气只对接收到的数据做出反应。它不知道是什么触发了事件,只知道有新的能量可用,从而保持了效果的清晰分离,并易于在其他地方重用。

Lion Reveal & Curtain Drag狮子显示与幕布拖拽

This section combines a depth mapped image with interactive WebGL effects to create the illusion of depth. A portrait of the lion responds to cursor movement through a fragment shader, while draggable curtain strips peel back with spring physics to reveal the image beneath. Pulling the curtain triggers a synchronized sound sequence, beginning with the fabric movement and ending with a lion’s growl.此板块结合了深度映射图像与交互式 WebGL 效果,创造出深度错觉。狮子肖像通过片元着色器响应光标移动,而可拖拽的幕布条带通过弹簧物理效果揭开,露出下方的图像。拉动幕布会触发同步的音效序列,从织物移动开始,以狮子的咆哮结束。

Sequencing the Reveal显示序列

The lion animation is intentionally delayed until the headline animation has finished. Waiting for the text to complete creates a clear visual rhythm and ensures the reveal feels like a continuation of the story, rather than competing for the user’s attention.狮子动画被刻意延迟,直到标题动画完成。等待文本完成创造了清晰的视觉节奏,并确保显示效果感觉像是故事的延续,而不是在争夺用户的注意力。

// components/Sections/About/AboutHero.tsx

useEffect(() => {
  if (!earlyStart || introStartedRef.current) return;

  introStartedRef.current = true;

  // Reveal the headline first.
  mainHeadingRef.current?.play();

  const tLion = window.setTimeout(() => {
    document.documentElement.dataset.trionnLionStart = "true";

    window.dispatchEvent(
      new CustomEvent("trionn:about-lion-start"),
    );
  }, headingAnimMs);

  // Subtitle and scroll hint follow shortly after.

  return () => clearTimeout(tLion);
}, [earlyStart]);

// components/Sections/About/AboutLion.tsx

useEffect(() => {
  const handler = () => setShouldInit(true);

  if (
    document.documentElement.dataset.trionnLionStart ===
    "true"
  ) {
    setShouldInit(true); // Event has already fired.
    return;
  }

  window.addEventListener(
    "trionn:about-lion-start",
    handler,
    { once: true },
  );

  return () => {
    window.removeEventListener(
      "trionn:about-lion-start",
      handler,
    );
  };
}, []);

Depth Mapped Portrait深度映射肖像

The lion portrait creates the illusion of depth using a single fragment shader and a precomputed depth map rather than real 3D geometry. As the cursor moves, the shader offsets the image based on the stored depth information, producing a convincing parallax effect while rendering only a single textured plane.狮子肖像使用单一片元着色器和预计算的深度图创造深度错觉,而非真实的 3D 几何体。随着光标移动,着色器根据存储的深度信息偏移图像,在仅渲染单一纹理平面的情况下产生令人信服的视差效果。

// AboutLion.tsx — fragment shader

vec2 mouse = (uMouseEase - 0.5) * vec2(2.0, -2.0); // normalize to -1...1 (Y flipped)

float depth = texture2D(uDepth, contained).r; // precomputed depth map

float breathing =
  sin(uTime * 0.0012) * 0.5 + 0.5; // subtle idle motion

float amount =
  (0.03 + 0.012 * breathing) * uHover;

vec2 disp = mouse * depth * amount; // brighter pixels shift more

vec2 uv = contained - disp;

// Small RGB offset creates a subtle chromatic fringe.
float r = texture2D(uImage, uv + disp * 0.16).r;
float g = texture2D(uImage, uv).g;
float b = texture2D(uImage, uv - disp * 0.16).b;

gl_FragColor = vec4(
  mix(bg, vec3(r, g, b), a),
  1.0
);

// JavaScript: ease the cursor toward its target position.
state.eased.x +=
  (state.mouse.x - state.eased.x) * 0.07;

state.eased.y +=
  (state.mouse.y - state.eased.y) * 0.07;

gl.uniform2f(
  uniforms.mouseEase,
  state.eased.x,
  state.eased.y,
);

Simulating the Curtain模拟幕布

Each curtain strip behaves like a simple spring that is updated every frame. When a strip is dragged, it naturally eases back to its resting position, while the surrounding strips are pulled along to preserve the shape of the curtain. This creates the impression of a continuous piece of fabric rather than a collection of independent elements.每个幕布条带表现得像一个每一帧更新的简单弹簧。当条带被拖拽时,它自然地缓动回静止位置,同时周围的条带被拉动以保持幕布的形状。这创造了连续织物的印象,而非独立元素的集合。

// physicsStep() — runs once per frame for every strip

const sp = -offY[i] * 0.12; // spring force pulling the strip back to rest
velY[i] = (velY[i] + sp) * 0.65; // apply damping
offY[i] += velY[i];

// While dragging, neighboring strips are pulled along to avoid
// visible gaps or overlaps, creating the impression of a continuous curtain.
if (dy > 0) {
  for (let ii = 1; ii < stripCount; ii++) {
    const overlap = aboveBot - thisRest;

    if (overlap <= 0) break;

    const sp = (overlap - offY[ni]) * 0.22;

    velY[ni] = (velY[ni] + sp) * 0.62;
    offY[ni] += velY[ni];
  }
}

Drawing the Curtain绘制幕布

Each strip is drawn procedurally on a 2D canvas instead of using a static image. The shape is warped by a Gaussian drag envelope centered on the user’s grab point, creating a smooth deformation that spreads naturally across the strip and closely mimics the behavior of fabric.每个条带是在 2D 画布上程序化绘制的,而非使用静态图像。形状由以用户抓取点为中心的高斯拖拽包络变形,创造出在条带上自然扩散并紧密模拟织物行为的平滑变形。

const updateDragEnvelope = () => {
    const clickX = dragSeg / SEGS; // where along the strip you grabbed it
    for (let s = 0; s <= SEGS; s++) {
        const t = s / SEGS;
        const dx = t - clickX;
        dragEnvelope[s] = Math.exp(-(dx * dx) * SIGMA_INV); // Gaussian falloff from the grab point
    }
};
// Each strip’s path bends most where you grabbed it, tapering off toward its ends:
const py = restY + displacement * dragEnvelope[s];

Movement Driven Sound运动驱动声音

The audio is tied directly to the user’s interaction with the curtain. The main sound effect loops only while a real drag is happening, using a selected section of the recording to create a continuous texture. Additional sounds are triggered based on the progress of the movement, while simple clicks or incomplete gestures remain silent.音频直接与用户对幕布的交互挂钩。主音效仅在实际拖拽发生时循环,使用录音的选定片段创造连续的纹理。额外的声音根据运动进度触发,而简单的点击或不完整的手势则保持静止。

const startCurtainSound = () => {
    // Loop just the middle section of the curtain SFX for as long as the drag continues
    dragSource.buffer = curtainBuffer;
    dragSource.loop = true;
    dragSource.loopStart = Math.min(0.1, duration * 0.1);
    dragSource.loopEnd = Math.min(1.5, duration * 0.5);
    dragSource.start(0, dragStart);
};

const maybeTriggerSounds = () => {
    if (!curtainStarted && dragSpeed > DRAG_MOVE_PX) {
        startCurtainSound();
    }

    // Growl only fires once the curtain has been audibly open for CURTAIN_LEAD_S
    // so it never plays on a quick tap, only a real pull.
    if (
        !growlStarted &&
        curtainStarted &&
        Math.abs(offY[dragStrip]) > CURTAIN_OPEN_PX &&
        audioCtx.currentTime - curtainStartedAt >= CURTAIN_LEAD_S
    ) {
        startGrowlSound();
    }
};

// On release: only play the “thud” if something actually moved.
// A plain click should be silent.
if (!playedSomething) return;

A CustomEvent (trionn:about-lion-start) connects the two components, triggering the lion sequence only after the hero headline has finished revealing. Unlike the hero symbol and footer logo, this interaction uses recorded audio assets such as the curtain movement and lion growl instead of synthesized sound. The clips are dynamically manipulated through looping sections, randomized start offsets, and drag-speed-driven volume changes, creating a more responsive audio experience that evolves with the interaction.一个 CustomEvent (trionn:about-lion-start) 连接了两个组件,仅在首屏标题显示完成后才触发狮子序列。与首屏符号和页脚 Logo 不同,此交互使用录制的音频素材(如幕布移动和狮子咆哮)而非合成音效。这些片段通过循环部分、随机起始偏移和拖拽速度驱动的音量变化进行动态处理,创造出随交互演变的更具响应性的音频体验。

Gallery Scatter Wall画廊散点墙

The “Work hard. Play loud.” section uses a scattered image layout where eleven team photos animate into place from different directions during scroll. Each image is assigned a randomized position from a predefined set of collision-safe slots, creating a layout that feels organic while remaining controlled. Clicking a photo brings it forward, adjusts surrounding images to avoid overlap, and extracts its dominant color client-side to influence the background.“Work hard. Play loud.”板块使用散点图像布局,其中 11 张团队照片在滚动过程中从不同方向进入位置。每张图像被分配一个来自预定义碰撞安全槽位的随机位置,创造出一种既有机又受控的布局。点击照片会将其带到前方,调整周围图像以避免重叠,并在客户端提取其主色调以影响背景。

Randomized positions from predefined slots来自预定义槽位的随机位置

The gallery layout uses a fixed collection of positions rather than completely random coordinates. Each image receives a shuffled slot at runtime, meaning the arrangement changes between visits while keeping every photo within a controlled, non-overlapping area. Small position offsets and rotation values are added afterward to create a more natural scattered effect.画廊布局使用固定的位置集合而非完全随机的坐标。每张图像在运行时接收一个洗牌后的槽位,这意味着安排在访问之间会发生变化,同时将每张照片保持在受控的、不重叠的区域内。之后添加小的位置偏移和旋转值,以创造更自然的散点效果。

// components/TrionnGallery/TrionnGallery.tsx

// Each image defines a desktop/mobile position fraction. The SET of
// positions is shuffled at runtime, so a given photo doesn’t always land
// in the same slot. This keeps the layout collision-free while still
// introducing variation.
const cells = IMAGES.map((img) =>
    isMobile ? img.position.mobile : img.position.desktop
);

const shuffled = cells
    .map((cell) => ({
        cell,
        sort: Math.random(),
    }))
    .sort((a, b) => a.sort - b.sort)
    .map((x) => x.cell);

return items.map((item, index) => {
    const rotation = randomBetween(-4, 4);

    // Account for the rotated bounding box when calculating limits
    const bounds = getRotatedBounds(
        rect.width,
        rect.height,
        rotation
    );

    const cell = shuffled[index];

    // Add small variations so the layout does not feel too rigid
    const jitterX = randomBetween(-36, 36);
    const jitterY = randomBetween(-36, 36);

    return {
        x: clamp(
            cell[0] * vw - vw / 2 + jitterX,
            minX,
            maxX
        ),
        y: clamp(
            cell[1] * vh - vh / 2 + jitterY,
            minY,
            maxY
        ),
        r: rotation,
        s: 1,
    };
});

Multi-directional entrance animation多方向进入动画

Instead of moving every image in from the same direction, each photo starts from one of several off-screen positions before settling into its final location. This creates a more dynamic reveal where the gallery feels like it is assembling itself around the viewer.每张照片不是从同一方向进入,而是从屏幕外的多个位置之一开始,然后沉降到最终位置。这创造了更具动态的显示效果,画廊感觉像是在观众周围组装起来。

const getStartPosition = (index: number) => {
    const gap = Math.max(vw, vh) * 0.72;

    const starts = [
        {
            x: -vw / 2 - gap,
            y: -vh / 2 - 80,
        }, // far off-screen, each corner/edge
        {
            x: vw / 2 + gap,
            y: -vh / 2 + 30,
        },
        {
            x: -80,
            y: -vh / 2 - gap,
        }, // from the top
        {
            x: 130,
            y: vh / 2 + gap,
        }, // from the bottom

        // …10 total positions, cycled by index
    ];

    return starts[index % starts.length];
};

Scroll controlled entrance sequence滚动控制的进入序列

The gallery reveal is controlled by a pinned scroll timeline that staggers each photo’s entrance before transitioning into the final stripe wipe. Each image is introduced with a slight delay, creating a sequential composition, while the second animation phase is triggered only after the gallery has fully settled.画廊显示由一个固定的滚动时间轴控制,该时间轴在过渡到最终条纹擦除之前交错显示每张照片。每张图像以轻微延迟引入,创造出序列化构图,而第二个动画阶段仅在画廊完全稳定后触发。

const animationEnd = GALLERY_VH / (GALLERY_VH + STRIPE_HOLD_VH);
// Fraction of the pinned scroll duration reserved for the photo entrance

galleryTimeline = gsap.timeline({
    scrollTrigger: {
        trigger: section,
        start: "top top",
        end: `+=${GALLERY_VH + STRIPE_HOLD_VH}%`,
        scrub: 0.6,
        pin: true,

        onUpdate: (self) => {
            const holdT = Math.max(
                0,
                Math.min(
                    1,
                    (self.progress - animationEnd) / (1 - animationEnd)
                )
            );

            // Stripes only start once the gallery entrance is complete
            if (stripesTL) stripesTL.progress(holdT);
        },
    },
});

items.forEach((item, index) => {
    galleryTimeline!.to(
        item,
        {
            x: end.x,
            y: end.y,
            rotate: end.r,
            duration: 1.2,
        },
        index * 0.34
    );
});

Dominant color extraction from images图像主色提取

The selected image is analyzed directly in the browser to determine its dominant color without requiring any external processing. The image is first reduced to a smaller canvas for faster sampling, then pixels are grouped into color buckets to identify the most prominent tones while ignoring transparent, very dark, bright, or low-saturation areas. The resulting color is used to dynamically tint the gallery background.选定的图像直接在浏览器中进行分析以确定其主色,无需任何外部处理。图像首先被缩小到较小的画布以进行快速采样,然后像素被分组到颜色桶中,以识别最突出的色调,同时忽略透明、极暗、极亮或低饱和度的区域。生成的颜色用于动态调整画廊背景色。

const getDominantImageColor = (img: HTMLImageElement): RGB => {
    const canvas = document.createElement("canvas");

    canvas.width = canvas.height = 72; // downsample for speed

    ctx.drawImage(img, 0, 0, 72, 72);

    const pixels = ctx.getImageData(0, 0, 72, 72).data;

    const buckets = new Map(); // quantize colors into coarse buckets

    for (let i = 0; i < pixels.length; i += 4) {
        const [r, g, b, a] = [
            pixels[i],
            pixels[i + 1],
            pixels[i + 2],
            pixels[i + 3],
        ];

        if (a < 220) continue;

        const brightness = (r + g + b) / 3;
        const saturation = Math.max(r, g, b) - Math.min(r, g, b);

        // Skip near-black, near-white, and gray areas
        if (brightness < 25 || brightness > 245 || saturation < 12) {
            continue;
        }

        const key = `${Math.round(r / 22) * 22},${Math.round(g / 22) * 22},${Math.round(b / 22) * 22}`;

        // Accumulate count + a "vividness" score per bucket...
    }

    // Pick the bucket with the best count × score weighting,
    // then average its raw pixels.
};

Interactive photo movement with collision response具有碰撞响应的交互式照片移动

Clicking a photo creates a pull-forward effect that briefly moves it toward the viewer before returning it to its original position. At the same time, nearby overlapping photos are detected and pushed away from the interaction point, creating a subtle physical response that makes the scattered layout feel more dynamic.点击照片会产生向前拉的效果,在返回原始位置之前将其短暂移向观众。同时,附近重叠的照片被检测并从交互点推开,创造出微妙的物理响应,使散点布局感觉更具动态。

const onClick = () => {
    if (isCardAnimating) return;

    isCardAnimating = true;

    updateBackgroundTone(item);
    moveOverlappingCards(item); // see below

    gsap.timeline({
        onComplete: () => {
            resetInnerCard(item);
            isCardAnimating = false;
        },
    })
    .to(inner, {
        x: pullX,
        y: pullY,
        rotation: direction * 4.5,
        scale: 1.035,
        opacity: 0.18,
        duration: 0.34,
    })
    .set(item, {
        zIndex: activeZIndex,
    })
    .to(inner, {
        x: 0,
        y: 0,
        rotation: 0,
        scale: 1,
        opacity: 1,
        duration: 0.56,
    });
};

const moveOverlappingCards = (clickedItem) => {
    items.forEach((otherItem) => {
        if (!isOverlapping(clickedRect, otherItem.getBoundingClientRect())) {
            return;
        }

        const dx = otherCenterX >= clickedCenterX ? 1 : -1;
        // Push away from the clicked photo

        gsap.timeline()
            .to(inner, {
                x: dx * gsap.utils.random(18, 34),
                rotation: dx * gsap.utils.random(1.4, 2.8),
                duration: 0.34,
            })
            .to(inner, {
                x: 0,
                y: 0,
                rotation: 0,
                duration: 0.52,
            }); // spring back
    });
};

Client-side image mounting and color sampling客户端图像挂载与颜色采样

The gallery images are mounted only after the component is rendered on the client, avoiding unnecessary server-side image loading for a section that depends entirely on interaction and animation. This keeps the initial render lighter while still allowing the images to be processed in the browser for color extraction.画廊图像仅在组件在客户端渲染后挂载,避免了为一个完全依赖交互和动画的板块进行不必要的服务器端图像加载。这保持了初始渲染的轻量化,同时仍允许图像在浏览器中进行颜色提取处理。

const mounted = useSyncExternalStore(
    () => () => {},
    () => true,
    () => false
);

// …

{
    mounted && (
        <img
            src={img.src}
            alt={img.alt}
            crossOrigin="anonymous"
        />
    )
}

The dominant color extraction is performed by reducing each image to a 72×72 canvas and grouping similar pixel values into color buckets. Near-black, near-white, and low-saturation pixels are removed from the calculation so images with neutral backgrounds can still produce a meaningful accent color. The pinned scroll timeline is divided into two stages, with the photo entrance completing before the stripe wipe begins, following the same progressive transition pattern used across the Services and Hero sections.主色提取通过将每张图像缩小到 72×72 画布并将相似像素值分组到颜色桶中来执行。接近黑色、白色和低饱和度的像素从计算中移除,因此具有中性背景的图像仍然可以产生有意义的强调色。固定的滚动时间轴分为两个阶段,照片进入在条纹擦除开始前完成,遵循与服务和首屏板块相同的渐进式过渡模式。

Closing Notes结语

Looking back, a shared canvasManager architecture from the beginning would have simplified the management of multiple WebGL experiences across the site. One of the main lessons was that the final level of polish came less from individual effects and more from carefully synchronizing every layer of the experience, including scrolling, animations, transitions, and audio.回首往事,如果从一开始就采用共享的 canvasManager 架构,本可以简化对网站上多个 WebGL 体验的管理。主要经验之一是,最终的润色水平与其说来自单个效果,不如说来自仔细协调体验的每一层,包括滚动、动画、过渡和音频。

Maintaining consistent patterns throughout the project, such as a shared GSAP ticker, idle-task scheduling, GPU-friendly animations, and synthesized Web Audio, helped keep the experience performant while preserving the level of detail across every section. This same attention to timing and interaction was carried through to the smaller moments as well, including the footer, where the experience continues beyond the main content.在整个项目中保持一致的模式,例如共享的 GSAP ticker、空闲任务调度、GPU 友好型动画和合成 Web Audio,有助于在保持体验高性能的同时,保留每个板块的细节水平。这种对时序和交互的关注也延续到了较小的时刻,包括页脚,体验在主要内容之外得以延续。

Trionn

TRIONN® is an independent AI-powered digital design and development studio helping ambitious brands create meaningful digital experiences through strategy, design, and technology.

Creative Spotlights

Inside the journeys and portfolios of today's most inspiring designers and developers.

Studio Stories

Discover how studios & agencies started, how they work, and what they've built.

Case Studies

Discover the ideas, design, and craft behind today’s most inspiring web experiences.

Learn with Tutorials

Level up your front-end skills with practical, step-by-step tutorials.