跪拜 Guibai
← Back to the summary

How Fluid Glass Pulls Off True 3D Refraction in the Browser

image.png

Let's first look at existing solutions.

A master's implementation: https://github.com/iyinchao/liquid-glass-studio The effect is extremely impressive, but currently it can only render images and is powerless for HTML.

image.png

SVG version: https://vue-bits.dev/components/glass-surface Simple, but has fatal problems: insufficient smoothness, jagged edges, and color anomalies.

image.png

image.png

Currently the best solution: three.js + webGL

fluid-glass

image.png

Image_20260827192124.png

余承东表情包制作需求 (1)_cleanup.png

1. Rendering Principle Overview

The core of Fluid Glass uses FBO off-screen rendering and volumetric transmission material (MeshTransmissionMaterial) to achieve true 3D optical refraction and dispersion.

Data Flow:

image.png

Core Mechanism:

  1. Scene Isolation: Background text and gallery are mounted to an independent off-screen Scene via createPortal, not directly appearing on the main canvas.
  2. Off-screen Baking: In each frame's render loop, the off-screen Scene is first drawn to a Framebuffer Object (FBO), generating a background texture.
  3. Single Pass Refraction Sampling: The 3D glass mesh directly uses this FBO texture as the transmission sampling source, calculating refraction offset based on mesh surface normals and optical parameters (IOR, thickness, dispersion).

2. Key Implementation Analysis (FluidGlass.tsx)

2.1 Model Preloading and Parameter Configuration

import { memo, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import * as THREE from 'three';
import { Canvas, createPortal, useFrame, useThree } from '@react-three/fiber';
import {
  Image,
  MeshTransmissionMaterial,
  Preload,
  Scroll,
  ScrollControls,
  Text,
  useFBO,
  useGLTF,
  useScroll,
} from '@react-three/drei';
import { easing } from 'maath';

export type FluidMode = 'lens' | 'cube' | 'bar';

export type FluidGlassProps = {
  mode?: FluidMode;
  scale?: number;
  ior?: number;
  thickness?: number;
  chromaticAberration?: number;
  anisotropy?: number;
};

const LENS_GLB = '/assets/3d/lens.glb';
const CUBE_GLB = '/assets/3d/cube.glb';
const BAR_GLB = '/assets/3d/bar.glb';

// Preload models to avoid screen flicker caused by asynchronous loading when switching forms
useGLTF.preload(LENS_GLB);
useGLTF.preload(CUBE_GLB);
useGLTF.preload(BAR_GLB);

2.2 Core Wrapper ModeWrapper: Event and State Handling

ModeWrapper is responsible for state management, model loading, event listening, and render pipeline scheduling.

type ModeWrapperProps = {
  children?: ReactNode;
  glb: string;
  geometryKey: string;
  followPointer?: boolean;
  lockToBottom?: boolean;
  modeProps: Record<string, unknown>;
};

const ModeWrapper = memo(function ModeWrapper({
  children,
  glb,
  geometryKey,
  lockToBottom = false,
  followPointer = true,
  modeProps = {},
}: ModeWrapperProps) {
  const meshRef = useRef<THREE.Mesh>(null);
  const gltf = useGLTF(glb);
  const nodes = gltf.nodes as Record<string, THREE.Mesh>;
  const buffer = useFBO(); // Allocate off-screen FBO render target
  const { viewport, gl } = useThree();
  const scene = useMemo(() => new THREE.Scene(), []); // Create independent off-screen scene
  const geoWidthRef = useRef(1);
  const pointerNDC = useRef(new THREE.Vector2());

  // 1. Get model bounding box dimensions for adaptive calculation when scale is not specified
  useEffect(() => {
    const geo = nodes[geometryKey]?.geometry;
    if (!geo) return;
    geo.computeBoundingBox();
    const box = geo.boundingBox;
    geoWidthRef.current = box ? box.max.x - box.min.x || 1 : 1;
  }, [nodes, geometryKey]);

  // 2. Pointer event listening and NDC (Normalized Device Coordinates) conversion
  useEffect(() => {
    const canvas = gl.domElement;
    const onMove = (event: PointerEvent) => {
      const rect = canvas.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
      pointerNDC.current.set(
        ((event.clientX - rect.left) / rect.width) * 2 - 1,
        -((event.clientY - rect.top) / rect.height) * 2 + 1,
      );
    };
    window.addEventListener('pointermove', onMove);
    return () => window.removeEventListener('pointermove', onMove);
  }, [gl]);

Since the outer layer uses Drei's ScrollControls, the scroll container hijacks some default events, so normalized coordinates are calculated manually here via native pointermove and canvas.getBoundingClientRect().


2.3 Per-Frame Scheduling: useFrame Render Loop

The useFrame callback is driven by requestAnimationFrame and executes before each frame's main screen render:

  useFrame((state, delta) => {
    const mesh = meshRef.current;
    if (!mesh) return;
    const { gl, camera } = state;

    // Calculate viewport dimensions at the lens depth (Z=15)
    const view = viewport.getCurrentViewport(camera, [0, 0, 15]);
    const destX = followPointer ? (pointerNDC.current.x * view.width) / 2 : 0;
    const destY = lockToBottom
      ? -view.height / 2 + 0.2
      : followPointer
        ? (pointerNDC.current.y * view.height) / 2
        : 0;

    // Inertial smooth interpolation
    easing.damp3(mesh.position, [destX, destY, 15], 0.15, delta);

    // Adaptive scaling
    if (modeProps.scale == null) {
      const maxWorld = view.width * 0.9;
      mesh.scale.setScalar(Math.min(0.15, maxWorld / geoWidthRef.current));
    }

    // Render off-screen Scene into FBO
    gl.setRenderTarget(buffer);
    gl.render(scene, camera);
    gl.setRenderTarget(null);
    gl.setClearColor(0x5227ff, 1);
  });

2.4 Viewport and Coordinate Calculation Principle

1. Depth Fixed at Z = 15

2. Role of view

Under perspective projection, the view frustum changes with depth:

3. Reason for Dividing by 2 in Coordinate Calculation

The Three.js scene origin (0, 0, 0) is at the center of the viewport:


2.5 Scene Isolation and FBO Off-screen Rendering Sequence

1. const buffer = useFBO()

2. createPortal(children, scene)


2.6 Dual Material Design: Background Plane and Transmission Material

  return (
    <>
      {createPortal(children, scene)}

      {/* 1. Bottom layer: Full-screen background plane */}
      <mesh scale={[viewport.width, viewport.height, 1]}>
        <planeGeometry />
        <meshBasicMaterial map={buffer.texture} transparent />
      </mesh>

      {/* 2. Top layer: 3D glass mesh */}
      <mesh
        ref={meshRef}
        scale={(scale as number | undefined) ?? 0.15}
        rotation-x={Math.PI / 2}
        geometry={nodes[geometryKey]?.geometry}
      >
        <MeshTransmissionMaterial
          buffer={buffer.texture}
          ior={(ior as number | undefined) ?? 1.15}
          thickness={(thickness as number | undefined) ?? 5}
          anisotropy={(anisotropy as number | undefined) ?? 0.01}
          chromaticAberration={(chromaticAberration as number | undefined) ?? 0.1}
          {...extraMat}
        />
      </mesh>
    </>
  );
Material and Parameters Data Source Role and Mechanism
meshBasicMaterial
map={buffer.texture}
buffer.texture Unlit texture map: Pastes the FBO content 1:1 across the viewport, serving as the normal background base when not occluded by the lens.
MeshTransmissionMaterial
buffer={buffer.texture}
buffer.texture Transmission refraction sampling source: The fragment shader performs offset dynamic sampling on the texture based on mesh normals, ior (refractive index), thickness, and chromaticAberration (dispersion).

3. Scene Components and Mode Switching

3.1 Lens Form Modes (Lens / Cube / Bar)

function Lens({ children, modeProps }: { children?: ReactNode; modeProps: Record<string, unknown> }) {
  return (
    <ModeWrapper glb={LENS_GLB} geometryKey="Cylinder" followPointer modeProps={modeProps}>
      {children}
    </ModeWrapper>
  );
}

function Cube({ children, modeProps }: { children?: ReactNode; modeProps: Record<string, unknown> }) {
  return (
    <ModeWrapper glb={CUBE_GLB} geometryKey="Cube" followPointer modeProps={modeProps}>
      {children}
    </ModeWrapper>
  );
}

function Bar({ children, modeProps = {} }: { children?: ReactNode; modeProps?: Record<string, unknown> }) {
  return (
    <ModeWrapper
      glb={BAR_GLB}
      geometryKey="Cube"
      lockToBottom
      followPointer={false}
      modeProps={{
        transmission: 1,
        roughness: 0,
        thickness: 10,
        ior: 1.15,
        color: '#ffffff',
        attenuationColor: '#ffffff',
        attenuationDistance: 0.25,
        ...modeProps,
      }}
    >
      {children}
    </ModeWrapper>
  );
}

3.2 Scroll Gallery Component Images

type ZoomMaterial = THREE.MeshBasicMaterial & { zoom: number };

function Images() {
  const group = useRef<THREE.Group>(null);
  const data = useScroll();
  const { height } = useThree((state) => state.viewport);

  useFrame(() => {
    const children = group.current?.children;
    if (!children || children.length < 5) return;
    const zoom = (index: number, value: number) => {
      ((children[index] as THREE.Mesh).material as ZoomMaterial).zoom = value;
    };
    zoom(0, 1 + data.range(0, 1 / 3) / 3);
    zoom(1, 1 + data.range(0, 1 / 3) / 3);
    zoom(2, 1 + data.range(1.15 / 3, 1 / 3) / 2);
    zoom(3, 1 + data.range(1.15 / 3, 1 / 3) / 2);
    zoom(4, 1 + data.range(1.15 / 3, 1 / 3) / 2);
  });

  return (
    <group ref={group}>
      <Image position={[-2, 0, 0]} scale={[3, height / 1.1]} url="/assets/demo/cs1.webp" />
      <Image position={[2, 0, 3]} scale={3} url="/assets/demo/cs2.webp" />
      <Image position={[-2.05, -height, 6]} scale={[1, 3]} url="/assets/demo/cs3.webp" />
      <Image position={[-0.6, -height, 9]} scale={[1, 2]} url="/assets/demo/cs1.webp" />
      <Image position={[0.75, -height, 10.5]} scale={1.5} url="/assets/demo/cs2.webp" />
    </group>
  );
}

3.3 Responsive Typography Component Typography and NavItems

function Typography() {
  const DEVICE = {
    mobile: { fontSize: 0.2 },
    tablet: { fontSize: 0.4 },
    desktop: { fontSize: 0.6 },
  };
  const getDevice = (): keyof typeof DEVICE => {
    const width = window.innerWidth;
    return width <= 639 ? 'mobile' : width <= 1023 ? 'tablet' : 'desktop';
  };
  const [device, setDevice] = useState(getDevice);

  useEffect(() => {
    const onResize = () => setDevice(getDevice());
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return (
    <Text
      position={[0, 0, 12]}
      fontSize={DEVICE[device].fontSize}
      letterSpacing={-0.05}
      outlineWidth={0}
      outlineBlur="20%"
      outlineColor="#000"
      outlineOpacity={0.5}
      color="white"
      anchorX="center"
      anchorY="middle"
    >
      React Bits
    </Text>
  );
}

function NavItems({ items }: { items: { label: string; link: string }[] }) {
  const group = useRef<THREE.Group>(null);
  const { viewport, camera } = useThree();
  const DEVICE = {
    mobile: { max: 639, spacing: 0.2, fontSize: 0.035 },
    tablet: { max: 1023, spacing: 0.24, fontSize: 0.035 },
    desktop: { max: Infinity, spacing: 0.3, fontSize: 0.035 },
  };
  const getDevice = (): keyof typeof DEVICE => {
    const width = window.innerWidth;
    return width <= DEVICE.mobile.max ? 'mobile' : width <= DEVICE.tablet.max ? 'tablet' : 'desktop';
  };
  const [device, setDevice] = useState(getDevice);

  useEffect(() => {
    const onResize = () => setDevice(getDevice());
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  const { spacing, fontSize } = DEVICE[device];

  useFrame(() => {
    const nav = group.current;
    if (!nav) return;
    const view = viewport.getCurrentViewport(camera, [0, 0, 15]);
    nav.position.set(0, -view.height / 2 + 0.2, 15.1);
    nav.children.forEach((child, index) => {
      child.position.x = (index - (items.length - 1) / 2) * spacing;
    });
  });

  return (
    <group ref={group} renderOrder={10}>
      {items.map(({ label }) => (
        <Text
          key={label}
          fontSize={fontSize}
          color="white"
          anchorX="center"
          anchorY="middle"
          outlineWidth={0}
          outlineBlur="20%"
          outlineColor="#000"
          outlineOpacity={0.5}
          renderOrder={10}
        >
          {label}
        </Text>
      ))}
    </group>
  );
}

3.4 Root Component Container FluidGlass

export function FluidGlass({
  mode = 'lens',
  scale = 0.2,
  ior = 1.15,
  thickness = 2,
  chromaticAberration = 0.05,
  anisotropy = 0.01,
}: FluidGlassProps) {
  const Wrapper = mode === 'bar' ? Bar : mode === 'cube' ? Cube : Lens;
  const modeProps = {
    scale,
    ior,
    thickness,
    chromaticAberration,
    anisotropy,
    transmission: 1,
    roughness: 0,
  };

  return (
    <Canvas camera={{ position: [0, 0, 20], fov: 15 }} gl={{ alpha: true }}>
      <Suspense fallback={null}>
        <ScrollControls damping={0.2} pages={3} distance={0.4}>
          {mode === 'bar' && (
            <NavItems
              items={[
                { label: 'Home', link: '' },
                { label: 'About', link: '' },
                { label: 'Contact', link: '' },
              ]}
            />
          )}
          <Wrapper modeProps={modeProps}>
            <Scroll>
              <Typography />
              <Images />
            </Scroll>
            <Scroll html />
            <Preload />
          </Wrapper>
        </ScrollControls>
      </Suspense>
    </Canvas>
  );
}

4. Architecture and Render Flow Diagram

4.1 Core Rendering and Data Flow (core-pipeline)

image.png

4.2 Per-Frame Execution Flow (frame-pipeline)

image.png

5. Glass Rendering Solution Comparison

Dimension Fluid Glass (/fluid-glass.html) Studio Four Pass GLSL (/) SVG Filter (/glass-svg.html)
Rendering Technology Three.js + R3F + FBO Off-screen Rendering WebGL Native Four Pass (Offscreen FBO) Native DOM + SVG Filter
Shape Representation 3D Mesh Model (.glb Geometry) 2D Signed Distance Field (SDF) HTML DOM Box Model
Refraction Mechanism Physical Normal Refraction (MeshTransmissionMaterial) GLSL Fragment Multi-sampling feDisplacementMap Pixel Displacement
Dispersion Support Per-channel RGB Physical Dispersion Shader Manual Offset Sampling Dispersion Pseudo Hue Shift
Applicable Scenarios 3D Model Interaction, Physical Lens Visual Effects Parametric Glass Material Editor, Gaussian Blur Background Lightweight Pure Web HTML UI Decoration

6. Complete Source Code (FluidGlass.tsx)

/* eslint-disable react/no-unknown-property */
import { memo, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import * as THREE from 'three';
import { Canvas, createPortal, useFrame, useThree } from '@react-three/fiber';
import {
  Image,
  MeshTransmissionMaterial,
  Preload,
  Scroll,
  ScrollControls,
  Text,
  useFBO,
  useGLTF,
  useScroll,
} from '@react-three/drei';
import { easing } from 'maath';

export type FluidMode = 'lens' | 'cube' | 'bar';

export type FluidGlassProps = {
  mode?: FluidMode;
  scale?: number;
  ior?: number;
  thickness?: number;
  chromaticAberration?: number;
  anisotropy?: number;
};

type ZoomMaterial = THREE.MeshBasicMaterial & { zoom: number };

type ModeWrapperProps = {
  children?: ReactNode;
  glb: string;
  geometryKey: string;
  followPointer?: boolean;
  lockToBottom?: boolean;
  modeProps: Record<string, unknown>;
};

const LENS_GLB = '/assets/3d/lens.glb';
const CUBE_GLB = '/assets/3d/cube.glb';
const BAR_GLB = '/assets/3d/bar.glb';

useGLTF.preload(LENS_GLB);
useGLTF.preload(CUBE_GLB);
useGLTF.preload(BAR_GLB);

const ModeWrapper = memo(function ModeWrapper({
  children,
  glb,
  geometryKey,
  lockToBottom = false,
  followPointer = true,
  modeProps = {},
}: ModeWrapperProps) {
  const meshRef = useRef<THREE.Mesh>(null);
  const gltf = useGLTF(glb);
  const nodes = gltf.nodes as Record<string, THREE.Mesh>;
  const buffer = useFBO();
  const { viewport, gl } = useThree();
  const scene = useMemo(() => new THREE.Scene(), []);
  const geoWidthRef = useRef(1);
  const pointerNDC = useRef(new THREE.Vector2());

  useEffect(() => {
    const geo = nodes[geometryKey]?.geometry;
    if (!geo) return;
    geo.computeBoundingBox();
    const box = geo.boundingBox;
    geoWidthRef.current = box ? box.max.x - box.min.x || 1 : 1;
  }, [nodes, geometryKey]);

  useEffect(() => {
    const canvas = gl.domElement;
    const onMove = (event: PointerEvent) => {
      const rect = canvas.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
      pointerNDC.current.set(
        ((event.clientX - rect.left) / rect.width) * 2 - 1,
        -((event.clientY - rect.top) / rect.height) * 2 + 1,
      );
    };
    window.addEventListener('pointermove', onMove);
    return () => window.removeEventListener('pointermove', onMove);
  }, [gl]);

  useFrame((state, delta) => {
    const mesh = meshRef.current;
    if (!mesh) return;
    const { gl, camera } = state;
    const view = viewport.getCurrentViewport(camera, [0, 0, 15]);
    const destX = followPointer ? (pointerNDC.current.x * view.width) / 2 : 0;
    const destY = lockToBottom
      ? -view.height / 2 + 0.2
      : followPointer
        ? (pointerNDC.current.y * view.height) / 2
        : 0;
    easing.damp3(mesh.position, [destX, destY, 15], 0.15, delta);

    if (modeProps.scale == null) {
      const maxWorld = view.width * 0.9;
      mesh.scale.setScalar(Math.min(0.15, maxWorld / geoWidthRef.current));
    }

    gl.setRenderTarget(buffer);
    gl.render(scene, camera);
    gl.setRenderTarget(null);
    gl.setClearColor(0x5227ff, 1);
  });

  const {
    scale,
    ior,
    thickness,
    anisotropy,
    chromaticAberration,
    ...extraMat
  } = modeProps as FluidGlassProps & Record<string, unknown>;

  return (
    <>
      {createPortal(children, scene)}
      <mesh scale={[viewport.width, viewport.height, 1]}>
        <planeGeometry />
        <meshBasicMaterial map={buffer.texture} transparent />
      </mesh>
      <mesh
        ref={meshRef}
        scale={(scale as number | undefined) ?? 0.15}
        rotation-x={Math.PI / 2}
        geometry={nodes[geometryKey]?.geometry}
      >
        <MeshTransmissionMaterial
          buffer={buffer.texture}
          ior={(ior as number | undefined) ?? 1.15}
          thickness={(thickness as number | undefined) ?? 5}
          anisotropy={(anisotropy as number | undefined) ?? 0.01}
          chromaticAberration={(chromaticAberration as number | undefined) ?? 0.1}
          {...extraMat}
        />
      </mesh>
    </>
  );
});

function Lens({ children, modeProps }: { children?: ReactNode; modeProps: Record<string, unknown> }) {
  return (
    <ModeWrapper glb={LENS_GLB} geometryKey="Cylinder" followPointer modeProps={modeProps}>
      {children}
    </ModeWrapper>
  );
}

function Cube({ children, modeProps }: { children?: ReactNode; modeProps: Record<string, unknown> }) {
  return (
    <ModeWrapper glb={CUBE_GLB} geometryKey="Cube" followPointer modeProps={modeProps}>
      {children}
    </ModeWrapper>
  );
}

function Bar({ children, modeProps = {} }: { children?: ReactNode; modeProps?: Record<string, unknown> }) {
  return (
    <ModeWrapper
      glb={BAR_GLB}
      geometryKey="Cube"
      lockToBottom
      followPointer={false}
      modeProps={{
        transmission: 1,
        roughness: 0,
        thickness: 10,
        ior: 1.15,
        color: '#ffffff',
        attenuationColor: '#ffffff',
        attenuationDistance: 0.25,
        ...modeProps,
      }}
    >
      {children}
    </ModeWrapper>
  );
}

function Images() {
  const group = useRef<THREE.Group>(null);
  const data = useScroll();
  const { height } = useThree((state) => state.viewport);

  useFrame(() => {
    const children = group.current?.children;
    if (!children || children.length < 5) return;
    const zoom = (index: number, value: number) => {
      ((children[index] as THREE.Mesh).material as ZoomMaterial).zoom = value;
    };
    zoom(0, 1 + data.range(0, 1 / 3) / 3);
    zoom(1, 1 + data.range(0, 1 / 3) / 3);
    zoom(2, 1 + data.range(1.15 / 3, 1 / 3) / 2);
    zoom(3, 1 + data.range(1.15 / 3, 1 / 3) / 2);
    zoom(4, 1 + data.range(1.15 / 3, 1 / 3) / 2);
  });

  return (
    <group ref={group}>
      <Image position={[-2, 0, 0]} scale={[3, height / 1.1]} url="/assets/demo/cs1.webp" />
      <Image position={[2, 0, 3]} scale={3} url="/assets/demo/cs2.webp" />
      <Image position={[-2.05, -height, 6]} scale={[1, 3]} url="/assets/demo/cs3.webp" />
      <Image position={[-0.6, -height, 9]} scale={[1, 2]} url="/assets/demo/cs1.webp" />
      <Image position={[0.75, -height, 10.5]} scale={1.5} url="/assets/demo/cs2.webp" />
    </group>
  );
}

function Typography() {
  const DEVICE = {
    mobile: { fontSize: 0.2 },
    tablet: { fontSize: 0.4 },
    desktop: { fontSize: 0.6 },
  };
  const getDevice = (): keyof typeof DEVICE => {
    const width = window.innerWidth;
    return width <= 639 ? 'mobile' : width <= 1023 ? 'tablet' : 'desktop';
  };
  const [device, setDevice] = useState(getDevice);

  useEffect(() => {
    const onResize = () => setDevice(getDevice());
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return (
    <Text
      position={[0, 0, 12]}
      fontSize={DEVICE[device].fontSize}
      letterSpacing={-0.05}
      outlineWidth={0}
      outlineBlur="20%"
      outlineColor="#000"
      outlineOpacity={0.5}
      color="white"
      anchorX="center"
      anchorY="middle"
    >
      React Bits
    </Text>
  );
}

function NavItems({ items }: { items: { label: string; link: string }[] }) {
  const group = useRef<THREE.Group>(null);
  const { viewport, camera } = useThree();
  const DEVICE = {
    mobile: { max: 639, spacing: 0.2, fontSize: 0.035 },
    tablet: { max: 1023, spacing: 0.24, fontSize: 0.035 },
    desktop: { max: Infinity, spacing: 0.3, fontSize: 0.035 },
  };
  const getDevice = (): keyof typeof DEVICE => {
    const width = window.innerWidth;
    return width <= DEVICE.mobile.max ? 'mobile' : width <= DEVICE.tablet.max ? 'tablet' : 'desktop';
  };
  const [device, setDevice] = useState(getDevice);

  useEffect(() => {
    const onResize = () => setDevice(getDevice());
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  const { spacing, fontSize } = DEVICE[device];

  useFrame(() => {
    const nav = group.current;
    if (!nav) return;
    const view = viewport.getCurrentViewport(camera, [0, 0, 15]);
    nav.position.set(0, -view.height / 2 + 0.2, 15.1);
    nav.children.forEach((child, index) => {
      child.position.x = (index - (items.length - 1) / 2) * spacing;
    });
  });

  return (
    <group ref={group} renderOrder={10}>
      {items.map(({ label }) => (
        <Text
          key={label}
          fontSize={fontSize}
          color="white"
          anchorX="center"
          anchorY="middle"
          outlineWidth={0}
          outlineBlur="20%"
          outlineColor="#000"
          outlineOpacity={0.5}
          renderOrder={10}
        >
          {label}
        </Text>
      ))}
    </group>
  );
}

export function FluidGlass({
  mode = 'lens',
  scale = 0.2,
  ior = 1.15,
  thickness = 2,
  chromaticAberration = 0.05,
  anisotropy = 0.01,
}: FluidGlassProps) {
  const Wrapper = mode === 'bar' ? Bar : mode === 'cube' ? Cube : Lens;
  const modeProps = {
    scale,
    ior,
    thickness,
    chromaticAberration,
    anisotropy,
    transmission: 1,
    roughness: 0,
  };

  return (
    <Canvas camera={{ position: [0, 0, 20], fov: 15 }} gl={{ alpha: true }}>
      <Suspense fallback={null}>
        <ScrollControls damping={0.2} pages={3} distance={0.4}>
          {mode === 'bar' && (
            <NavItems
              items={[
                { label: 'Home', link: '' },
                { label: 'About', link: '' },
                { label: 'Contact', link: '' },
              ]}
            />
          )}
          <Wrapper modeProps={modeProps}>
            <Scroll>
              <Typography />
              <Images />
            </Scroll>
            <Scroll html />
            <Preload />
          </Wrapper>
        </ScrollControls>
      </Suspense>
    </Canvas>
  );
}

7. Related Documentation and Reference Resources

7.1 Different Glass Implementation Modules and Pages in This Project

Module Page Source Directory Technical Solution and Features
Fluid 3D Glass Lens (/fluid-glass.html) src/fluid-glass/ Three.js + R3F + FBO Off-screen Rendering + GLB 3D Model Transmission Refraction
Material Lab Main Workbench (/) src/ (App.tsx, shaders/) WebGL2/WebGPU + Four Pass Gaussian Blur and SDF Physical Optical Shading
Frosted Glass Floating Interactive Buttons (/glass-buttons.html) src/glass-buttons/ DOM Capture + Shader Overlay Glass Floating Layer
SVG Filter Lightweight Glass (/glass-svg.html) src/glass-svg/ Pure DOM + SVG feDisplacementMap Displacement Filter

7.2 Core Reference Libraries and Specifications

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

Awei相信

It feels like your images are really well done — what tool did you use to make them?