How Fluid Glass Pulls Off True 3D Refraction in the Browser
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.
SVG version: https://vue-bits.dev/components/glass-surface Simple, but has fatal problems: insufficient smoothness, jagged edges, and color anomalies.
Currently the best solution: three.js + webGL
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:
Core Mechanism:
- Scene Isolation: Background text and gallery are mounted to an independent off-screen
SceneviacreatePortal, not directly appearing on the main canvas. - Off-screen Baking: In each frame's render loop, the off-screen
Sceneis first drawn to a Framebuffer Object (FBO), generating a background texture. - 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
- Camera position is at
Z = 20(camera={{ position: [0, 0, 20], fov: 15 }}). - Scene background content is distributed at
Z = 0 ~ 12. - The lens is placed at
Z = 15, between the camera and background content, so that light refracts the background after passing through the glass.
2. Role of view
Under perspective projection, the view frustum changes with depth:
viewport.getCurrentViewport(camera, [0, 0, 15])is used to calculate the world coordinate width and height (view.width,view.height) corresponding to the screen on theZ = 15clipping plane.
3. Reason for Dividing by 2 in Coordinate Calculation
The Three.js scene origin (0, 0, 0) is at the center of the viewport:
pointerNDC.xrange is[-1, 1].- The viewport center is
0, the rightmost boundary is+view.width / 2, the leftmost boundary is-view.width / 2. - Coordinate conversion formula: $\text{destX} = \text{pointerNDC.x} \times \frac{\text{view.width}}{2}$ $\text{destY} = \text{pointerNDC.y} \times \frac{\text{view.height}}{2}$
2.5 Scene Isolation and FBO Off-screen Rendering Sequence
1. const buffer = useFBO()
- On initialization, only allocates a render buffer in video memory (the texture contains no valid pixel data at this point).
- Data writing occurs when
gl.render(scene, camera)is called withinuseFrame.
2. createPortal(children, scene)
- Mounts React child nodes (text and images) to the independent
scene(new THREE.Scene()). - These elements are detached from the default scene tree, will not be rendered directly to the screen, and are specifically used for FBO off-screen drawing.
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 |
|---|---|---|
meshBasicMaterialmap={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. |
MeshTransmissionMaterialbuffer={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>
);
}
Lens: Cylindrical lens mesh, follows the pointer.Cube: Cube mesh, presents multi-face refraction, follows the pointer.Bar: Long bar mesh, fixed at the bottom of the viewport, serving as a bottom frosted glass navigation bar.
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)
4.2 Per-Frame Execution Flow (frame-pipeline)
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
- React Three Fiber (R3F) Official Documentation: React declarative Three.js renderer.
- @react-three/drei - MeshTransmissionMaterial: Volumetric transmission material component based on physical refractive index (IOR), dispersion, and thickness.
- Three.js Official Documentation - WebGLRenderTarget (FBO): Off-screen framebuffer object API specification.
- pmndrs/maath: Math library for 3D physical motion and camera smooth interpolation (
easing.damp3).
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
It feels like your images are really well done — what tool did you use to make them?