本文由 AI 辅助写作,作记录用
圣诞节到了,想给博客加点节日氛围。最终实现了一套可插拔的圣诞特效模块,包括:
- 3D 雪花飘落(React Three Fiber + GLSL 着色器)
- 悬挂彩灯装饰(CSS 动画)
- 头像圣诞帽(SVG)
- 圣诞配色方案(CSS 变量)
- 可拖拽圣诞球开关
先放最终效果:

如果嫌弃干扰阅读了,可以点击开关关掉~

注意看这个雪是双层的,内容容器夹在雪花中间,远景雪花在文章后面飘过,近景雪花在前面飘过,配合视差效果,感觉还挺满意的。

技术选型上,雪花飘落使用 React Three Fiber 实现 GPU 着色器驱动 的 6 层粒子系统,支持鼠标视差效果,在视觉效果和性能之间取得平衡。
整体架构
flowchart TB
subgraph Config[配置层]
SC[site-config.ts<br/>christmasConfig]
end
subgraph State[状态管理]
NS[nanostores<br/>christmasEnabled]
LS[localStorage<br/>持久化]
end
subgraph Effects[特效组件]
SF[SnowfallCanvas<br/>GLSL 雪花着色器]
CL[ChristmasLights<br/>CSS 彩灯]
CH[ChristmasHat<br/>SVG 圣诞帽]
CT[christmas-theme.css<br/>配色方案]
end
subgraph UI[用户界面]
OT[ChristmasOrnamentToggle<br/>可拖拽开关]
end
SC --> NS
NS <--> LS
NS --> SF
SC --> CL
OT --> NS
SC --> CH
SC --> CT
所有特效都通过 christmasConfig.enabled 控制,运行时状态通过 nanostores 管理,支持用户手动切换并持久化到 localStorage。
配置结构设计
在 src/constants/site-config.ts 中定义圣诞配置:
type ChristmasConfig = {
enabled: boolean;
features: {
snowfall: boolean; // 雪花飘落
christmasColorScheme: boolean; // 圣诞配色
christmasCoverDecoration: boolean; // Cover 彩灯装饰
christmasHat: boolean; // 头像圣诞帽
};
snowfall: {
speed: number; // 下落速度 (1 = 正常)
intensity: number; // 雪花密度 (0-1)
mobileIntensity: number; // 移动端密度 (0-1)
};
};
export const christmasConfig: ChristmasConfig = {
enabled: true,
features: {
snowfall: true,
christmasColorScheme: true,
christmasCoverDecoration: true,
christmasHat: true,
},
snowfall: {
speed: 1,
intensity: 0.6,
mobileIntensity: 0.4,
},
};
这种设计允许细粒度控制各个特效,方便调试和按需启用。
状态管理:跨 Astro/React 边界
Astro 的岛屿架构中,.astro 组件和 React 组件需要共享状态。使用 nanostores 作为轻量级状态管理:
// src/store/christmas.ts
import { atom } from 'nanostores';
const STORAGE_KEY = 'christmas-enabled';
// 运行时开关状态
export const christmasEnabled = atom<boolean>(true);
// 初始化:从 localStorage 恢复状态
export function initChristmasState(): void {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
christmasEnabled.set(stored === 'true');
}
syncChristmasClass(christmasEnabled.get());
}
// 同步 html.christmas class(用于 CSS 配色切换)
function syncChristmasClass(enabled: boolean): void {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('christmas', enabled);
}
// 切换开关
export function toggleChristmas(): void {
const newValue = !christmasEnabled.get();
christmasEnabled.set(newValue);
localStorage.setItem(STORAGE_KEY, String(newValue));
syncChristmasClass(newValue);
}
// 启用圣诞特效
export function enableChristmas(): void {
christmasEnabled.set(true);
localStorage.setItem(STORAGE_KEY, 'true');
syncChristmasClass(true);
}
// 禁用圣诞特效
export function disableChristmas(): void {
christmasEnabled.set(false);
localStorage.setItem(STORAGE_KEY, 'false');
syncChristmasClass(false);
}
在 React 组件中使用 @nanostores/react 的 useStore 订阅状态:
import { useStore } from '@nanostores/react';
import { christmasEnabled } from '@store/christmas';
function SnowfallCanvas() {
const isEnabled = useStore(christmasEnabled);
if (!isEnabled) return null;
// ...渲染雪花
}
在 Astro 组件的 <script> 中直接使用:
import { christmasEnabled, toggleChristmas } from '@store/christmas';
christmasEnabled.subscribe((enabled) => {
// 更新 UI 状态
});
雪花飘落:React Three Fiber 粒子系统
选择 React Three Fiber + 自定义 GLSL 着色器,参考 Shadertoy 上的 “Just snow” 效果,完全在 GPU 上计算和渲染 6 层雪花。
粒子系统实现
使用 ShaderMaterial 实现全屏着色器渲染:
// src/components/christmas/SnowParticles.tsx
import { useFrame, useThree } from '@react-three/fiber';
import { useMemo, useRef, type MutableRefObject } from 'react';
import * as THREE from 'three';
const SnowShaderMaterial = {
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec2 uResolution;
uniform float uSpeed;
uniform float uIntensity;
uniform vec2 uMouse; // 鼠标视差偏移
uniform int uLayerStart; // 渲染层范围起始
uniform int uLayerEnd; // 渲染层范围结束
varying vec2 vUv;
void main() {
vec2 fragCoord = vUv * uResolution;
float snow = 0.0;
float time = uTime * uSpeed;
// 6层雪花,每层12次迭代
for(int k = 0; k < 6; k++) {
if(k < uLayerStart || k > uLayerEnd) continue;
for(int i = 0; i < 12; i++) {
float cellSize = 2.0 + (float(i) * 3.0);
float downSpeed = 0.3 + (sin(time * 0.4 + float(k + i * 20)) + 1.0) * 0.00008;
// 视差偏移:近景层偏移更多
float parallaxFactor = 0.5 + float(k) * 0.1;
vec2 mouseOffset = uMouse * parallaxFactor;
vec2 uv = (fragCoord.xy / uResolution.x) + mouseOffset + vec2(
0.01 * sin((time + float(k * 6185)) * 0.6 + float(i)) * (5.0 / float(i)),
downSpeed * (time + float(k * 1352)) * (1.0 / float(i))
);
vec2 uvStep = (ceil((uv) * cellSize - vec2(0.5, 0.5)) / cellSize);
float x = fract(sin(dot(uvStep.xy, vec2(12.9898 + float(k) * 12.0, 78.233 + float(k) * 315.156))) * 43758.5453 + float(k) * 12.0) - 0.5;
float y = fract(sin(dot(uvStep.xy, vec2(62.2364 + float(k) * 23.0, 94.674 + float(k) * 95.0))) * 62159.8432 + float(k) * 12.0) - 0.5;
float d = 5.0 * distance((uvStep.xy + vec2(x * sin(y), y) * sin(time * 2.5) * 0.7 / cellSize), uv.xy);
float omiVal = fract(sin(dot(uvStep.xy, vec2(32.4691, 94.615))) * 31572.1684);
if(omiVal < 0.08) {
snow += (x + 1.0) * 0.4 * clamp(1.9 - d * (15.0 + (x * 6.3)) * (cellSize / 1.4), 0.0, 1.0);
}
}
}
gl_FragColor = vec4(1.0, 1.0, 1.0, snow * uIntensity);
}
`,
};
interface SnowParticlesProps {
speed?: number;
intensity?: number;
parallaxRef?: MutableRefObject<{ x: number; y: number }>;
layerRange?: [number, number];
}
export function SnowParticles({
speed = 1,
intensity = 0.6,
parallaxRef,
layerRange = [0, 5]
}: SnowParticlesProps) {
const shaderMaterial = useRef<THREE.ShaderMaterial>(null);
const { size } = useThree();
const [layerStart, layerEnd] = layerRange;
const uniforms = useMemo(() => ({
uTime: { value: 0 },
uResolution: { value: new THREE.Vector2(size.width, size.height) },
uSpeed: { value: speed },
uIntensity: { value: intensity },
uMouse: { value: new THREE.Vector2(0, 0) },
uLayerStart: { value: layerStart },
uLayerEnd: { value: layerEnd },
}), [size.width, size.height, speed, intensity, layerStart, layerEnd]);
useFrame((state) => {
if (shaderMaterial.current) {
shaderMaterial.current.uniforms.uTime.value = state.clock.getElapsedTime();
if (parallaxRef) {
shaderMaterial.current.uniforms.uMouse.value.set(parallaxRef.current.x, parallaxRef.current.y);
}
}
});
return (
<mesh>
<planeGeometry args={[2, 2]} />
<shaderMaterial
ref={shaderMaterial}
transparent
depthWrite={false}
blending={THREE.AdditiveBlending}
uniforms={uniforms}
vertexShader={SnowShaderMaterial.vertexShader}
fragmentShader={SnowShaderMaterial.fragmentShader}
/>
</mesh>
);
}
核心思路:
- 使用
planeGeometry创建全屏四边形 - Fragment Shader 中用 6 层嵌套循环生成不同大小、速度的雪花
- 每层雪花有独立的下落速度和风力相位
- 通过
uLayerStart和uLayerEnduniform 控制渲染哪些层(用于前后景分离)
Canvas 配置:不阻挡点击
一个关键问题是 R3F Canvas 默认会拦截所有鼠标事件,导致下方元素无法点击。解决方案:
<Canvas
// 全屏着色器使用正交相机
orthographic
camera={{ zoom: 1, position: [0, 0, 1] }}
// 禁用 R3F 的事件系统
eventSource={undefined}
eventPrefix={undefined}
style={{ pointerEvents: 'none' }}
gl={{
antialias: false,
alpha: true,
powerPreference: 'low-power', // 省电模式
}}
dpr={[1, 1.5]} // 限制 DPR,移动端性能优化
>
鼠标视差效果
参考 Shadertoy 上的 “Just snow” 效果,添加鼠标移动时的视差偏移,让雪花更有层次感。
核心思路:
- 使用 Motion 的
useMotionValue+useSpring追踪并平滑鼠标位置 - 将平滑后的值传递给着色器的
uMouseuniform - 着色器中不同层使用不同的视差因子,近景层偏移更多
// SnowfallCanvas.tsx - 鼠标追踪
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const smoothMouseX = useSpring(mouseX, { stiffness: 50, damping: 20 });
const smoothMouseY = useSpring(mouseY, { stiffness: 50, damping: 20 });
useEffect(() => {
if (isMobile) return; // 移动端无鼠标
const handleMouseMove = (e: MouseEvent) => {
// 标准化到 -0.5 ~ 0.5
mouseX.set(e.clientX / window.innerWidth - 0.5);
mouseY.set(e.clientY / window.innerHeight - 0.5);
};
const handleMouseLeave = () => {
// 鼠标离开窗口时回到中心
mouseX.set(0);
mouseY.set(0);
};
window.addEventListener('mousemove', handleMouseMove, { passive: true });
document.addEventListener('mouseleave', handleMouseLeave);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseleave', handleMouseLeave);
};
}, [isMobile]);
着色器中应用视差:
uniform vec2 uMouse;
// 在每层雪花的 UV 计算中
float parallaxFactor = 0.5 + float(k) * 0.1; // k=0~5 → 0.5~1.0
vec2 mouseOffset = uMouse * parallaxFactor;
vec2 uv = (fragCoord.xy / uResolution.x) + mouseOffset + ...;
由于 Motion 的 spring 值在 React 渲染循环外更新,而 R3F 的 useFrame 也在外部运行,需要用桥接组件订阅 spring 变化并存入 ref:
function SnowParticlesWithParallax({
speed,
intensity,
smoothMouseX,
smoothMouseY,
parallaxStrength,
layerRange,
}: {
speed: number;
intensity: number;
smoothMouseX: MotionValue<number>;
smoothMouseY: MotionValue<number>;
parallaxStrength: number;
layerRange: [number, number];
}) {
const parallaxRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const unsubX = smoothMouseX.on('change', (v) => {
parallaxRef.current.x = v * parallaxStrength;
});
const unsubY = smoothMouseY.on('change', (v) => {
parallaxRef.current.y = v * parallaxStrength;
});
return () => { unsubX(); unsubY(); };
}, [smoothMouseX, smoothMouseY, parallaxStrength]);
return <SnowParticles speed={speed} intensity={intensity} parallaxRef={parallaxRef} layerRange={layerRange} />;
}
双层渲染:前景与背景分离
为了让雪花更有层次感,将 6 层雪花分成两组渲染到不同的 z-index:
| 层级 | z-index | 着色器层 (k) | 效果 |
|---|---|---|---|
| 背景雪 | 1 | k=0-2 | 远景、小雪花、视差弱 |
| 内容 | 正常文档流 | - | 文章容器 |
| 前景雪 | 50 | k=3-5 | 近景、大雪花、视差强 |
实现方式是给 SnowParticles 添加 layerRange prop,着色器中根据范围跳过不需要的层:
uniform int uLayerStart;
uniform int uLayerEnd;
for(int k = 0; k < 6; k++) {
if(k < uLayerStart || k > uLayerEnd) continue;
// ... 原有雪花渲染逻辑
}
然后渲染两个 Canvas:
{/* 背景雪 */}
<SnowfallCanvas zIndex={1} layerRange={[0, 2]} />
{/* 前景雪 */}
<SnowfallCanvas zIndex={50} layerRange={[3, 5]} />
这样内容容器就”夹”在雪花中间,远景雪花在文章后面飘过,近景雪花在前面飘过,配合视差效果产生很好的深度感。
圣诞彩灯:CSS 动画
在 Cover 顶部添加悬挂式彩灯装饰,参考 Toby J 的 CodePen 实现,调整为圣诞配色(红、绿、金)。
实现原理
彩灯效果完全使用 CSS 实现:
- 灯泡使用
border-radius: 50%创建椭圆形 - 灯座使用
::before伪元素 - 电线使用
::after伪元素的border-bottom+border-radius模拟弧形 - 闪烁效果使用
@keyframes动画改变background和box-shadow
---
// src/components/christmas/ChristmasLights.astro
// 生成足够数量的灯泡覆盖最大屏幕宽度
const lightCount = 50;
---
<ul class="lightrope" aria-hidden="true">
{Array.from({ length: lightCount }).map(() => <li />)}
</ul>
<style>
.lightrope {
--globe-width: 12px;
--globe-height: 28px;
--globe-spacing: 40px;
--globe-spread: 3px;
--light-off-opacity: 0.4;
/* 圣诞配色 */
--light-red: 185, 50, 50;
--light-green: 34, 139, 34;
--light-gold: 218, 165, 32;
position: fixed;
top: 42px;
left: 0;
z-index: 100;
width: 100%;
pointer-events: none;
}
.lightrope li {
display: inline-block;
width: var(--globe-width);
height: var(--globe-height);
margin: calc(var(--globe-spacing) / 2);
border-radius: 50%;
background: rgb(var(--light-red));
box-shadow: 0px 4px 24px 3px rgba(var(--light-red), 1);
animation: flash-red 2s ease-in-out infinite;
}
/* 绿色灯泡 - 每隔一个 */
.lightrope li:nth-child(2n + 1) {
background: rgb(var(--light-green));
animation-name: flash-green;
animation-duration: 0.4s;
}
/* 金色灯泡 - 每 4 个中的第 2 个 */
.lightrope li:nth-child(4n + 2) {
background: rgb(var(--light-gold));
animation-name: flash-gold;
animation-duration: 1.1s;
}
/* 灯座 */
.lightrope li::before {
content: '';
position: absolute;
top: -5px;
width: 10px;
height: 9px;
background: hsl(var(--background) / 0.6);
border-radius: 3px;
}
/* 连接电线 */
.lightrope li::after {
content: '';
position: absolute;
top: -14px;
left: 9px;
width: 52px;
height: 18px;
border-bottom: solid hsl(var(--background) / 0.6) 2px;
border-radius: 50%;
}
/* 闪烁动画 */
@keyframes flash-red {
0%,
100% {
background: rgb(var(--light-red));
box-shadow: 0px 4px 24px 3px rgba(var(--light-red), 1);
}
50% {
background: rgba(var(--light-red), 0.4);
box-shadow: 0px 4px 24px 3px rgba(var(--light-red), 0.2);
}
}
/* 无障碍:减少动画 */
@media (prefers-reduced-motion: reduce) {
.lightrope li {
animation: none;
}
}
</style>
在 Cover 中使用
// src/components/ui/cover/Cover.astro
+ import { christmasConfig } from '@constants/site-config';
+ import ChristmasLights from '@components/christmas/ChristmasLights.astro';
<div class="relative flex h-[60dvh] max-h-200 overflow-hidden">
+ {christmasConfig.enabled && christmasConfig.features.christmasCoverDecoration && <ChristmasLights />}
<div class="absolute inset-0 h-full bg-black/40"></div>
...
</div>
彩灯使用 position: fixed 固定在视口顶部,z-index: 100 确保显示在导航栏之上。电线和灯座颜色使用 hsl(var(--background) / 0.6) 自动适配亮暗主题。
圣诞帽:SVG 绘制
头像上的圣诞帽使用纯 SVG 绘制,支持自定义尺寸、位置、旋转角度和颜色:
---
interface Props {
className?: string;
size?: number | string;
offset?: { x?: number; y?: number };
rotate?: number;
colors?: {
primary?: string;
secondary?: string;
white?: string;
};
}
const {
className,
size = '100%',
offset = { x: -28, y: -50 },
rotate = 10,
colors = {
primary: '#FF4E50',
secondary: '#C00000',
white: '#FFFFFF',
},
} = Astro.props;
// 生成唯一 ID 避免多个帽子时 gradient 冲突
const uniqueId = Math.random().toString(36).substring(2, 9);
---
<div
class:list={['christmas-hat-wrapper', className]}
style={{
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
top: `${offset.y}%`,
left: `${offset.x}%`,
transform: `rotate(${rotate}deg)`,
}}
>
<svg viewBox="0 0 200 200" fill="none">
<defs>
<linearGradient id={`hatGradient_${uniqueId}`} ...>
<stop stop-color={colors.primary}></stop>
<stop offset="1" stop-color={colors.secondary}></stop>
</linearGradient>
</defs>
<!-- 帽身 -->
<path d="M179.712 87.522 ..." fill={`url(#hatGradient_${uniqueId})`}></path>
<!-- 白边 -->
<path d="M181.512 88.5738 ..." stroke={colors.white} stroke-width="28"></path>
<!-- 毛球 -->
<circle cx="38.5" cy="144.5" r="19.5" fill={colors.white}></circle>
</svg>
</div>
<style>
.christmas-hat-wrapper {
position: absolute;
pointer-events: none;
z-index: 20;
transition: transform 0.3s ease;
filter: drop-shadow(0px 10px 15px rgba(0, 0, 0, 0.1));
}
</style>
通过 props 可以灵活调整帽子的位置和样式,适配不同尺寸的头像。使用 linearGradient 实现帽身的渐变效果,drop-shadow 添加阴影增加立体感。
圣诞配色方案
通过 CSS 自定义属性实现主题切换,当 <html> 有 .christmas 类时激活:
/* src/styles/christmas/christmas-theme.css */
.christmas {
--christmas-red: 0 70% 45%;
--christmas-green: 150 60% 35%;
--christmas-gold: 43 75% 55%;
/* 覆盖主色调 */
--primary: var(--christmas-red);
}
.christmas.dark {
--christmas-red: 0 65% 55%;
--gradient-bg-start: #1a1512;
--gradient-bg-end: #0d1a0d;
}
在 Layout 中根据配置添加类名:
<html class:list={[{ christmas: christmasConfig.enabled && christmasConfig.features.christmasColorScheme }]}></html>
开关按钮
在悬浮按钮组中添加圣诞开关:
{
christmasConfig.enabled && (
<button id="toggle-christmas" class="rounded-full p-2 shadow-lg backdrop-blur-sm" aria-label="切换圣诞特效">
<Icon name="ri:snowy-fill" class="christmas-on h-5 w-5" />
<Icon name="ri:snowy-line" class="christmas-off hidden h-5 w-5" />
</button>
)
}
<script>
import { christmasEnabled, toggleChristmas, initChristmasState } from '@store/christmas';
function init() {
initChristmasState();
const btn = document.getElementById('toggle-christmas');
btn?.addEventListener('click', () => {
toggleChristmas();
});
// 订阅状态变化,更新按钮图标
christmasEnabled.subscribe((enabled) => {
const onIcon = btn?.querySelector('.christmas-on');
const offIcon = btn?.querySelector('.christmas-off');
onIcon?.classList.toggle('hidden', !enabled);
offIcon?.classList.toggle('hidden', enabled);
});
}
document.addEventListener('astro:page-load', init);
</script>
圣诞球开关:可拖拽的装饰组件
除了简单的图标按钮,还实现了一个更有趣的交互方式:悬挂在页面右上角的圣诞球装饰,支持拖拽下拉触发开关。
实现原理
使用 Motion 的 drag API 实现拖拽交互:
useMotionValue追踪拖拽位置useTransform派生绳子高度dragSnapToOrigin自动回弹- 拖拽超过阈值时触发状态切换
// src/components/christmas/ChristmasOrnamentToggle.tsx
import { cn } from '@lib/utils';
import { useStore } from '@nanostores/react';
import { christmasEnabled, disableChristmas, toggleChristmas } from '@store/christmas';
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform } from 'motion/react';
import { useState } from 'react';
const DRAG_THRESHOLD = 80;
const ORNAMENT_SIZE = 72;
const STRING_HEIGHT = 80;
export function ChristmasOrnamentToggle() {
const isEnabled = useStore(christmasEnabled);
const shouldReduceMotion = useReducedMotion();
const [isPulling, setIsPulling] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const y = useMotionValue(0);
// 绳子高度跟随球的位置,确保始终连接
const stringHeight = useTransform(y, (v) => STRING_HEIGHT + Math.max(0, v));
const handleDragEnd = () => {
setIsPulling(false);
const currentY = y.get();
if (currentY > DRAG_THRESHOLD / 2) {
if (isEnabled) {
disableChristmas();
} else {
toggleChristmas();
}
}
};
return (
<div className="fixed top-0 right-20 z-90 flex w-[100px] justify-center">
{/* 绳子 - 高度随拖拽变化 */}
<motion.div
className="pointer-events-none absolute top-0 w-[2px] origin-top bg-linear-to-b from-yellow-700 via-yellow-500 to-yellow-400"
style={{ height: stringHeight }}
/>
{/* 球体 - 可拖拽 */}
<motion.button
className={cn(
'absolute z-101 cursor-grab touch-none select-none active:cursor-grabbing',
'rounded-full outline-none focus-visible:ring-4 focus-visible:ring-yellow-400/50',
)}
style={{
width: ORNAMENT_SIZE,
height: ORNAMENT_SIZE,
top: STRING_HEIGHT,
y, // Motion 拖拽自动更新此值
}}
drag={shouldReduceMotion ? false : 'y'}
dragConstraints={{ top: 0, bottom: DRAG_THRESHOLD + 30 }}
dragElastic={0.2}
dragSnapToOrigin // 松开后自动回弹
onDragStart={() => setIsPulling(true)}
onDragEnd={handleDragEnd}
onClick={() => toggleChristmas()}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label={isEnabled ? '关闭圣诞模式' : '开启圣诞模式'}
aria-pressed={isEnabled}
type="button"
>
<OrnamentSvg isEnabled={isEnabled} />
{/* 拖拽提示 */}
<AnimatePresence>
{isEnabled && (isPulling || isHovered) && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 30 }}
exit={{ opacity: 0 }}
className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2"
>
<div className="rounded-full bg-red-950/90 px-3 py-1 text-[10px] text-white">
下拉关闭
</div>
</motion.div>
)}
</AnimatePresence>
</motion.button>
</div>
);
}
关键技术点
- 绳子与球的同步:将
yMotionValue 同时用于球的位置和绳子高度计算,确保两者始终连接 - dragSnapToOrigin:Motion 内置的回弹动画,松开后自动回到初始位置
- useReducedMotion:尊重用户的动画偏好,禁用拖拽时改为点击切换
- dragElastic:设置 0.2 的弹性系数,超出约束时有轻微的弹性效果
性能优化
移动端适配
使用自定义 hook 检测移动端并调整雪花密度:
const isMobile = useIsMobile();
const shouldReduceMotion = useReducedMotion();
// 移动端使用更低的密度
const finalIntensity = isMobile ? mobileIntensity : intensity;
// 移动端禁用鼠标视差
const finalParallaxStrength = isMobile ? 0 : parallaxStrength;
// 尊重用户的动画偏好设置
if (shouldReduceMotion || !isChristmasEnabled) {
return null;
}
R3F Canvas 配置
<Canvas
orthographic
camera={{ zoom: 1, position: [0, 0, 1] }}
gl={{
antialias: false, // 禁用抗锯齿
alpha: true,
powerPreference: 'low-power',
}}
dpr={[1, 1.5]} // 限制像素比
/>
文件清单
| 文件 | 用途 |
|---|---|
src/constants/site-config.ts | 圣诞配置定义 |
src/store/christmas.ts | 状态管理 |
src/components/christmas/SnowfallCanvas.tsx | R3F 雪花画布包装器 |
src/components/christmas/SnowParticles.tsx | GLSL 着色器粒子系统 |
src/components/christmas/ChristmasLights.astro | CSS 彩灯装饰 |
src/components/christmas/ChristmasHat.astro | SVG 圣诞帽 |
src/components/christmas/ChristmasEffects.astro | 双层雪花包装器 |
src/components/christmas/ChristmasOrnamentToggle.tsx | 可拖拽圣诞球开关 |
src/styles/christmas/christmas-theme.css | 配色方案 |
总结
这套圣诞特效模块的设计原则如下:
- 可插拔:通过配置开关,不影响非节日期间的使用
- 性能友好:GLSL 着色器 GPU 渲染 + CSS 动画,移动端自动降级
- 用户可控:提供可拖拽圣诞球开关,尊重
prefers-reduced-motion - 跨框架:nanostores 实现 Astro/React 状态同步
- 层次感:双层渲染 + 鼠标视差,雪花有前后景深度
明年可以在此基础上扩展更多节日特效(新年烟花?万圣节南瓜?),架构已经预留了扩展空间。
Refs
- Shadertoy - Just snow - 雪花着色器参考
- Toby J 的 CodePen 彩灯参考实现
本文随时修订中,有错漏可直接评论
先使用 Remark42 作为临时评论系统,样式等有待优化