米塔3D字体网页端实现
如何在 React Three Fiber场景中实现仿米塔(Miside)3D 字体物理效果。涵盖分割、SDF 字体渲染、相机朝向视差、移动端适配及性能优化等关键技术点。
在 React Three Fiber 场景中,使文本以”miside”物理效果出现:逐字打字显现 → 保持 → 逐个释放 → 物理坠落 → 消失。 核心思路是将每个字形视为独立的物理刚体,由 Rapier 引擎驱动碰撞与运动。


具体可以点击上方 个人主站 尝试,文章种的gif显示掉落可能有点慢
还有部分米塔字体是有些先往上抬动再掉落的,我是没做这部分,如果需要也可以按本文思路自行调整
技术栈
| 类别 | 技术 | 用途 |
|---|---|---|
| 框架 | React 19 + TypeScript | 组件化 UI 与类型安全 |
| 构建 | Vite | 开发服务器与打包 |
| 3D 渲染 | Three.js + @react-three/fiber | 场景、相机、字形渲染 |
| 物理引擎 | @react-three/rapier (Rapier) | 刚体、碰撞检测、重力模拟 |
| SDF 字体 | @react-three/drei Text | 高质量 3D 文本渲染 |
| 状态管理 | Zustand room-store | 短语触发、清除、计数 |
| 测试 | Vitest | 纯函数层单元测试 |
架构总览
┌─────────────────────────────────────────────────────┐│ layer.tsx ││ 懒加载门面 · 动态导入 physics · ErrorBoundary │└──────────────────────┬──────────────────────────────┘ │ 导入┌──────────────────────▼──────────────────────────────┐│ physics.tsx ││ Three.js 渲染 · Rapier 物理 · 时序驱动 · 碰撞体 │└──────────────────────┬──────────────────────────────┘ │ 调用┌──────────────────────▼──────────────────────────────┐│ core.ts ││ 字形分割 · 排版 · 确定性随机 · 时序状态机 · 纯函数 │└─────────────────────────────────────────────────────┘核心实现原理
1. 字形分割 — Intl.Segmenter
使用浏览器原生 Intl.Segmenter 按书写单位分割文本,确保中文单字、拉丁字母、emoji 序列(如 👨👩👧👦)不被拆散。
const segmenter = new Intl.Segmenter('zh-CN', { granularity: 'grapheme' });function segmentGraphemes(value: string) { return Array.from(segmenter.segment(value), (part) => part.segment);}2. 确定性随机 — 种子哈希 + Fisher-Yates
同一短语每次生成完全一致的动画序列,保证可复现。使用 FNV-1a 哈希将短语转为种子,再用线性同余生成器产生伪随机数。
3. 排版 — 锚点居中 + 自动换行
每个短语以一个 3D 空间锚点为中心,字形按行向左右均匀展开,上下居中。支持宽窄字符(如 i 与 W 宽度不同)和自动换行。
// 核心:每行从 -width/2 开始排列,确保整行居中const width = line.reduce((sum, glyph) => sum + glyph.width, 0);let cursor = -width / 2;return line.map((glyph) => { const x = cursor + glyph.width / 2; cursor += glyph.width; return { ...glyph, x, y: totalHeight / 2 - lineIndex * lineHeight };});4. 时序状态机 — 四阶段生命周期
每个字形经历四个阶段,由 advanceTimelineGlyph 纯函数驱动:
hidden ──(showAt)──→ held ──(releaseAt)──→ dynamic ──(clear)──→ clearing ──(320ms)──→ 移除 │ │ 打字机逐字出现 Rapier 物理接管 保持位置不变 impulse + 角速度 无物理碰撞 碰撞 + 坠落- hidden → held:
showAfterMs递增实现打字机效果(每字 65-96ms) - held → dynamic:Fisher-Yates 打乱释放顺序,实现凌乱飘散效果
- dynamic → clearing:触发条件:超出边界(y < -2.2)、物理休眠(settle)、短语数量超限、手动 clear
5. 物理模拟 — Rapier 集成
使用 @react-three/rapier 将每个字形作为独立刚体,释放时施加冲量和角速度。
// 物理参数gravity: [0, -200, 0] // 重力加速度angularDamping: 2.8 // 角阻尼linearDamping: 0.05 // 线阻尼restitution: 0.16 // 弹性friction: 0.72 // 摩擦力
// 房间碰撞体(6 面墙壁 + 3 件家具顶面 + 书架层板)<CuboidCollider args={[10, 0.06, 8.5]} position={[0, -0.91, 0]} /> // 地板6. 相机朝向
字形始终面向相机(通过 Quaternion 继承相机旋转),但保持自身在锚点周围的局部偏移量,随相机旋转产生视差效果。
const cameraQuaternion = camera.quaternion.clone();const phraseQuaternion = cameraQuaternion.clone() .multiply(new Quaternion().setFromEuler(new Euler(...plan.tilt)));
// 字形位置 = 锚点 + 右向量×x + 上向量×y + 前向量×zconst position = new Vector3(...anchor) .addScaledVector(right, plan.x) .addScaledVector(up, plan.y);7. 响应式与可访问性
| 场景 | 限制 | 行为 |
|---|---|---|
| 桌面端 | 3 条短语 / 72 字形 | 完整物理动画 |
| 移动端 (<720px) | 2 条短语 / 42 字形 | 缩小字号范围 |
| prefers-reduced-motion | 无限制 | 跳过物理,直接 held 后 clearing |
8. 懒加载与错误边界
layer.tsx 使用动态 import() 延迟加载 physics 模块,ErrorBoundary 捕获字体/WebGL 加载失败,优雅降级。
// 动态导入let physicsModule: Promise<{ default: ComponentType<PhysicsLayerProps> }> | null = null;function loadPhysicsModule() { physicsModule ??= import('./physics'); return physicsModule;}完整代码
core.ts — 纯函数层
// 移动端断点:小于此宽度启用移动端限制export const DOLL_WORD_MOBILE_BREAKPOINT = 720;
// 并发限制:最多同时活跃的短语数和字形数export interface DollWordLimits { activePhrases: number; glyphs: number;}layer.tsx — 懒加载门面
import { Component, Suspense, useCallback, useEffect, useRef, useState, type ComponentType, type ReactNode,} from 'react';
import { useRoomStore } from '@/stores/room-store';
// 物理渲染层 Props:onReady 回调在字体预热完成后触发interface PhysicsLayerProps { onReady: () => void;}
// 模块级缓存:避免重复动态导入let physicsModule: Promise<{ default: ComponentType<PhysicsLayerProps> }> | null = null;
/** 懒加载 physics 模块,仅在首次调用时执行实际 import */function loadPhysicsModule() { physicsModule ??= import('./physics'); return physicsModule;}
/** 错误边界:捕获字体 / WebGL 加载失败,优雅降级,不阻塞页面 */class DollWordErrorBoundary extends Component< { children: ReactNode; onFailure: () => void }, { failed: boolean }> { state = { failed: false };
static getDerivedStateFromError() { return { failed: true }; }
componentDidCatch(error: unknown) { console.warn('3D doll words were disabled because their assets failed to load.', error); // 通知 store 字形计数归零,清除所有引用 useRoomStore.getState().setDollWordCount(0); this.props.onFailure(); }
render() { return this.state.failed ? null : this.props.children; }}
/** 懒加载门面组件:动态导入 PhysicsLayer,加载失败时静默降级 */export function DollWordLayer({ onReady }: { onReady: () => void }) { const [PhysicsLayer, setPhysicsLayer] = useState<ComponentType<PhysicsLayerProps> | null>(null); const readyReported = useRef(false); const reportReady = useCallback(() => { if (readyReported.current) return; readyReported.current = true; onReady(); }, [onReady]);
useEffect(() => { if (PhysicsLayer !== null) return; let cancelled = false; void loadPhysicsModule() .then((module) => { if (!cancelled) setPhysicsLayer(() => module.default); }) .catch((error: unknown) => { console.warn('3D doll words could not be preloaded.', error); reportReady(); }); return () => { cancelled = true; // 组件卸载时取消未完成的加载 }; }, [PhysicsLayer, reportReady]);
if (PhysicsLayer === null) return null; return ( <DollWordErrorBoundary onFailure={reportReady}> <Suspense fallback={null}> <PhysicsLayer onReady={reportReady} /> </Suspense> </DollWordErrorBoundary> );}physics.tsx — 物理渲染层
import { Text } from '@react-three/drei';import { useFrame, useThree } from '@react-three/fiber';import { CuboidCollider, Physics, RigidBody, type RapierRigidBody } from '@react-three/rapier';import { useCallback, useEffect, useMemo, useRef, useState } from 'react';import { Euler, MathUtils, Quaternion, Vector3, type Group } from 'three';
import { profileConfig } from '@/config';import { useReducedMotion } from '@/hooks/use-reduced-motion';集成方式
在对应的 3D 房间场景中引入 Layer 组件,并传入 onReady 回调:
import { DollWordLayer } from '@/scene/doll-words/layer';
export function RoomScene({ onDollWordsReady }: { onDollWordsReady: () => void }) { return ( <> {/* 房间物体 */} <DollWordLayer onReady={onDollWordsReady} /> </> );}同时需要在状态管理中定义房间 store 的相关状态:
interface RoomState { dollWordBurst: { id: number; phrase: string } | null; dollWordClearRevision: number; dollWordCount: number; setDollWordCount: (count: number) => void; clearDollWords: () => void; spawnDollWords: (phrase: string) => void;}踩坑点 & 注意事项
1. Intl.Segmenter 兼容性
Firefox 和 Safari 较旧版本不支持 Intl.Segmenter。代码中做了 fallback,回退到 Array.from(value),但 emoji 序列(如 👨👩👧👦)在回退模式下会被拆散。
2. Rapier 物理性能
- 每个字形是一个独立
RigidBody,同时存在过多时(>72)可能影响性能 - 使用
canSleep让静止的刚体自动休眠 - 超出边界的字形直接移除,不等待清除动画
- 使用
softCcdPrediction避免高速穿透
3. 字体预热
<Text> 组件首次渲染时会加载字体并生成 SDF 纹理,这会导致卡顿。使用不可见 <group visible={false}> 在加载阶段即预热所有用到的字形。
4. Camera 与布局
字形位置是相对于锚点的局部偏移,但朝向跟随相机。这导致旋转相机时字形产生视差,需要确保 camera.updateMatrixWorld() 在计算前被调用。
5. 状态更新的竞态
setGlyphs 在多个 useEffect 中同时触发,使用 queueMicrotask 延迟到微任务队列执行,避免 React 的批量更新问题。同时用 cancelled flag 防止组件卸载后更新。
性能对比
| 指标 | 旧版 CSS 实现 | 新版 3D 物理实现 |
|---|---|---|
| 渲染方式 | CSS 2D transform | Three.js SDF Text |
| 动画驱动 | CSS animation | requestAnimationFrame + Rapier |
| 碰撞检测 | 无 | 房间墙壁 + 家具碰撞体 |
| 字体支持 | 系统字体 | 4 种自定义 woff/ttf 字体 |
| 单次性能 | 轻量 | 约 0.3-0.8ms 每帧(72 字形) |
| 最大并发 | 无限(CSS) | 72 字形(硬限制) |
总结
- 核心在于将排版布局、时序控制、物理模拟三层解耦,纯函数层(core.ts)不含任何 Three.js 或 React 依赖,可独立测试
- 确定性随机保证同一短语每次播放效果一致,Seed 基于短语内容哈希,适合需要回放或录制的场景
- 四阶段状态机(hidden → held → dynamic → clearing)配合 requestAnimationFrame 驱动,避免使用 setInterval 的不精确性
- 相机朝向 + 锚点偏移的方案兼顾了”面向用户”和”空间位置感”两个需求


