- Remove TestimonialSection from homepage (no customers yet) - Footer: dark theme, NAVIGATION_V2 + MEGA_DROPDOWN_DATA links - Mobile Menu: NAVIGATION_V2 with collapsible dropdown support
32 KiB
前沿技术升级 Phase 3 实现计划:WebGPU Shader Art + Partial Prerendering + WebAssembly
面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法来跟踪进度。
目标: 引入 WebGPU Compute Shader 实现高性能水墨流体粒子系统(替代当前 Canvas 2D 粒子);评估 Partial Prerendering 从静态导出迁移到混合渲染的可行性;探索 WebAssembly (Rust → Wasm) 实现前端图像处理能力。
架构: Phase 3 是差异化创新阶段,每项技术独立评估和实现。WebGPU 粒子系统封装为独立组件,自动降级到 Canvas 2D;PPR 需要架构级变更(从 output: 'export' 到 Node.js 服务器),仅做可行性评估和原型验证;Wasm 图像处理模块为可选增强。核心原则:不破坏现有静态导出能力,所有改动在特性检测后渐进增强。
技术栈: WebGPU API + WGSL、Next.js Partial Prerendering (experimental)、Rust + wasm-pack + wasm-bindgen
文件结构
新建文件
| 文件 | 职责 |
|---|---|
src/components/effects/webgpu-particle.tsx |
WebGPU 粒子系统组件 |
src/components/effects/webgpu-particle.test.tsx |
测试 |
src/components/effects/webgpu-ink-shader.wgsl |
水墨粒子 WGSL 着色器 |
src/lib/webgpu-detect.ts |
WebGPU 能力检测工具 |
src/lib/webgpu-detect.test.ts |
测试 |
src/components/effects/particle-effect.tsx |
统一粒子效果入口(自动选择 WebGPU/Canvas2D) |
src/components/effects/particle-effect.test.tsx |
测试 |
docs/superpowers/plans/ppr-migration-assessment.md |
PPR 迁移评估文档 |
wasm/ink-filter/ |
Rust Wasm 图像处理模块目录 |
wasm/ink-filter/src/lib.rs |
Rust 水墨滤镜核心实现 |
wasm/ink-filter/Cargo.toml |
Rust 项目配置 |
src/lib/wasm-loader.ts |
Wasm 模块动态加载器 |
src/lib/wasm-loader.test.ts |
测试 |
修改文件
| 文件 | 变更内容 |
|---|---|
src/components/sections/hero-section.tsx |
替换 DataParticleFlow 为 ParticleEffect |
src/components/effects/data-particle-flow.tsx |
保留作为 Canvas 2D 降级方案 |
src/components/effects/index.ts |
导出 ParticleEffect |
next.config.ts |
添加 WebGPU 相关配置(如需要) |
任务 1:WebGPU 能力检测工具
文件:
-
创建:
src/lib/webgpu-detect.ts -
创建:
src/lib/webgpu-detect.test.ts -
步骤 1:编写失败的测试
// src/lib/webgpu-detect.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { detectWebGPU, getWebGPUCapabilities } from './webgpu-detect';
describe('detectWebGPU', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('returns false when navigator.gpu is undefined', async () => {
const originalGpu = navigator.gpu;
Object.defineProperty(navigator, 'gpu', { value: undefined, configurable: true });
const result = await detectWebGPU();
expect(result).toBe(false);
Object.defineProperty(navigator, 'gpu', { value: originalGpu, configurable: true });
});
it('returns false when requestAdapter fails', async () => {
Object.defineProperty(navigator, 'gpu', {
value: { requestAdapter: vi.fn().mockResolvedValue(null) },
configurable: true,
});
const result = await detectWebGPU();
expect(result).toBe(false);
});
it('returns true when WebGPU is available', async () => {
Object.defineProperty(navigator, 'gpu', {
value: {
requestAdapter: vi.fn().mockResolvedValue({
requestDevice: vi.fn().mockResolvedValue({}),
}),
},
configurable: true,
});
const result = await detectWebGPU();
expect(result).toBe(true);
});
});
describe('getWebGPUCapabilities', () => {
it('returns capabilities object with expected fields', async () => {
const mockDevice = {
features: new Set(['texture-compression-bc']),
limits: { maxStorageBufferBindingSize: 128 * 1024 * 1024 },
};
Object.defineProperty(navigator, 'gpu', {
value: {
requestAdapter: vi.fn().mockResolvedValue({
requestDevice: vi.fn().mockResolvedValue(mockDevice),
features: new Set(['texture-compression-bc']),
limits: { maxStorageBufferBindingSize: 128 * 1024 * 1024 },
}),
getPreferredCanvasFormat: vi.fn().mockReturnValue('bgra8unorm'),
},
configurable: true,
});
const caps = await getWebGPUCapabilities();
expect(caps.supported).toBe(true);
expect(caps.preferredFormat).toBe('bgra8unorm');
});
});
- 步骤 2:运行测试验证失败
运行:npx vitest run src/lib/webgpu-detect.test.ts
预期:FAIL
- 步骤 3:编写最少实现代码
// src/lib/webgpu-detect.ts
export interface WebGPUCapabilities {
supported: boolean;
preferredFormat?: GPUTextureFormat;
maxStorageBufferBindingSize?: number;
features?: string[];
}
export async function detectWebGPU(): Promise<boolean> {
if (!navigator.gpu) return false;
try {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) return false;
const device = await adapter.requestDevice();
return !!device;
} catch {
return false;
}
}
export async function getWebGPUCapabilities(): Promise<WebGPUCapabilities> {
if (!navigator.gpu) {
return { supported: false };
}
try {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
return { supported: false };
}
const device = await adapter.requestDevice();
const preferredFormat = navigator.gpu.getPreferredCanvasFormat();
return {
supported: true,
preferredFormat,
maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
features: [...adapter.features],
};
} catch {
return { supported: false };
}
}
- 步骤 4:运行测试验证通过
运行:npx vitest run src/lib/webgpu-detect.test.ts
预期:PASS
- 步骤 5:Commit
git add src/lib/webgpu-detect.ts src/lib/webgpu-detect.test.ts
git commit -m "feat: add WebGPU capability detection utilities"
任务 2:WGSL 水墨粒子着色器
文件:
-
创建:
src/components/effects/webgpu-ink-shader.wgsl -
步骤 1:编写水墨粒子 Compute Shader
创建 src/components/effects/webgpu-ink-shader.wgsl:
// 水墨粒子数据结构
struct Particle {
position: vec2f,
velocity: vec2f,
color: vec4f,
size: f32,
life: f32,
maxLife: f32,
opacity: f32,
};
// Uniform 参数
struct Params {
deltaTime: f32,
time: f32,
mouseX: f32,
mouseY: f32,
mouseInfluence: f32,
particleCount: u32,
canvasWidth: f32,
canvasHeight: f32,
};
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
@group(0) @binding(1) var<uniform> params: Params;
// 伪随机数生成
fn hash(value: f32) -> f32 {
return fract(sin(value) * 43758.5453123);
}
// 水墨扩散物理模拟
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= params.particleCount) { return; }
var p = particles[i];
// 鼠标吸引力
let mousePos = vec2f(params.mouseX / params.canvasWidth, 1.0 - params.mouseY / params.canvasHeight);
let toMouse = mousePos - p.position;
let mouseDist = length(toMouse);
if (mouseDist > 0.001 && mouseDist < 0.3) {
let mouseForce = normalize(toMouse) * params.mouseInfluence * (0.3 - mouseDist) / 0.3;
p.velocity += mouseForce * params.deltaTime;
}
// 水墨扩散 - 布朗运动
let noiseX = hash(p.position.x * 1000.0 + params.time) - 0.5;
let noiseY = hash(p.position.y * 1000.0 + params.time + 42.0) - 0.5;
p.velocity += vec2f(noiseX, noiseY) * 0.02;
// 阻尼 - 模拟墨水在宣纸上的阻力
p.velocity *= 0.98;
// 更新位置
p.position += p.velocity * params.deltaTime;
// 边界处理 - 柔和反弹
if (p.position.x < 0.0) { p.position.x = 0.0; p.velocity.x *= -0.5; }
if (p.position.x > 1.0) { p.position.x = 1.0; p.velocity.x *= -0.5; }
if (p.position.y < 0.0) { p.position.y = 0.0; p.velocity.y *= -0.5; }
if (p.position.y > 1.0) { p.position.y = 1.0; p.velocity.y *= -0.5; }
// 生命周期
p.life -= params.deltaTime;
if (p.life <= 0.0 {
// 重生 - 模拟新的墨滴
p.position = vec2f(hash(params.time + f32(i) * 0.1), hash(params.time + f32(i) * 0.1 + 100.0));
p.velocity = vec2f(0.0, 0.0);
p.life = p.maxLife;
p.opacity = 0.3 + hash(f32(i)) * 0.5;
}
// 透明度随生命衰减 - 模拟墨迹干涸
let lifeRatio = p.life / p.maxLife;
p.opacity = p.opacity * smoothstep(0.0, 0.2, lifeRatio);
particles[i] = p;
}
- 步骤 2:编写水墨粒子渲染 Vertex/Fragment Shader
在同一文件中追加:
// 渲染管线着色器
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) uv: vec2f,
@location(1) color: vec4f,
@location(2) opacity: f32,
};
struct RenderParams {
canvasWidth: f32,
canvasHeight: f32,
};
@group(0) @binding(0) var<storage, read> renderParticles: array<Particle>;
@group(0) @binding(1) var<uniform> renderParams: RenderParams;
@vertex
fn vertexMain(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) instanceIndex: u32) -> VertexOutput {
let p = renderParticles[instanceIndex];
// 四边形顶点偏移
let quadPos = array<vec2f, 6>(
vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0)
);
let quadUv = array<vec2f, 6>(
vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(0.0, 1.0),
vec2f(0.0, 1.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0)
);
let offset = quadPos[vertexIndex] * p.size;
let screenPos = vec2f(
p.position.x * renderParams.canvasWidth + offset.x,
(1.0 - p.position.y) * renderParams.canvasHeight + offset.y
);
var output: VertexOutput;
output.position = vec4f(
screenPos.x / renderParams.canvasWidth * 2.0 - 1.0,
screenPos.y / renderParams.canvasHeight * 2.0 - 1.0,
0.0,
1.0
);
output.uv = quadUv[vertexIndex];
output.color = p.color;
output.opacity = p.opacity;
return output;
}
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
// 圆形粒子 + 柔和边缘 - 模拟墨滴
let center = input.uv - vec2f(0.5);
let dist = length(center);
if (dist > 0.5) { discard; }
// 水墨晕染效果 - 中心浓、边缘淡
let inkDensity = smoothstep(0.5, 0.1, dist);
let alpha = inkDensity * input.opacity;
return vec4f(input.color.rgb, alpha);
}
- 步骤 3:Commit
git add src/components/effects/webgpu-ink-shader.wgsl
git commit -m "feat: add WGSL ink particle compute and render shaders"
任务 3:WebGPU 粒子系统组件
文件:
-
创建:
src/components/effects/webgpu-particle.tsx -
创建:
src/components/effects/webgpu-particle.test.tsx -
步骤 1:编写失败的测试
// src/components/effects/webgpu-particle.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { WebGPUParticle } from './webgpu-particle';
describe('WebGPUParticle', () => {
it('renders canvas element', () => {
render(<WebGPUParticle particleCount={100} />);
const canvas = screen.getByRole('img', { hidden: true });
expect(canvas).toBeInTheDocument();
});
it('renders fallback when WebGPU is not supported', async () => {
Object.defineProperty(navigator, 'gpu', { value: undefined, configurable: true });
render(<WebGPUParticle particleCount={100} fallback={<div>Fallback</div>} />);
expect(screen.getByText('Fallback')).toBeInTheDocument();
});
it('applies className to container', () => {
render(<WebGPUParticle particleCount={100} className="test-class" />);
const container = document.querySelector('.test-class');
expect(container).toBeInTheDocument();
});
it('respects reduced motion', () => {
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})
);
render(<WebGPUParticle particleCount={100} />);
matchMediaSpy.mockRestore();
});
});
- 步骤 2:运行测试验证失败
运行:npx vitest run src/components/effects/webgpu-particle.test.tsx
预期:FAIL
- 步骤 3:编写最少实现代码
// src/components/effects/webgpu-particle.tsx
'use client';
import { useRef, useEffect, useState, type ReactNode } from 'react';
import { detectWebGPU } from '@/lib/webgpu-detect';
import { cn } from '@/lib/utils';
interface WebGPUParticleProps {
particleCount?: number;
color?: string;
className?: string;
fallback?: ReactNode;
intensity?: 'subtle' | 'normal' | 'prominent';
}
interface ParticleData {
position: [number, number];
velocity: [number, number];
color: [number, number, number, number];
size: number;
life: number;
maxLife: number;
opacity: number;
}
export function WebGPUParticle({
particleCount = 5000,
className = '',
fallback,
intensity = 'normal',
}: WebGPUParticleProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [supported, setSupported] = useState<boolean | null>(null);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
useEffect(() => {
detectWebGPU().then(setSupported);
}, []);
useEffect(() => {
if (supported === false || prefersReducedMotion || !canvasRef.current) return;
const canvas = canvasRef.current;
let animationFrameId: number;
let device: GPUDevice | null = null;
async function init() {
if (!navigator.gpu) return;
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) return;
device = await adapter.requestDevice();
if (!device) return;
const context = canvas.getContext('webgpu');
if (!context) return;
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: 'premultiplied' });
const intensityConfig = {
subtle: { sizeMin: 2, sizeMax: 6, opacityMin: 0.1, opacityMax: 0.3 },
normal: { sizeMin: 4, sizeMax: 12, opacityMin: 0.2, opacityMax: 0.6 },
prominent: { sizeMin: 8, sizeMax: 20, opacityMin: 0.3, opacityMax: 0.8 },
};
const config = intensityConfig[intensity];
const particles: ParticleData[] = Array.from({ length: particleCount }, () => ({
position: [Math.random(), Math.random()],
velocity: [(Math.random() - 0.5) * 0.01, (Math.random() - 0.5) * 0.01],
color: [0.11, 0.11, 0.11, 1.0],
size: Math.random() * (config.sizeMax - config.sizeMin) + config.sizeMin,
life: Math.random() * 10 + 5,
maxLife: Math.random() * 10 + 5,
opacity: Math.random() * (config.opacityMax - config.opacityMin) + config.opacityMin,
}));
// Canvas 2D fallback rendering (WebGPU pipeline would replace this in production)
const ctx2d = canvas.getContext('2d');
if (!ctx2d) return;
function render() {
if (!ctx2d) return;
ctx2d.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
p.position[0] += p.velocity[0];
p.position[1] += p.velocity[1];
p.velocity[0] += (Math.random() - 0.5) * 0.001;
p.velocity[1] += (Math.random() - 0.5) * 0.001;
p.velocity[0] *= 0.99;
p.velocity[1] *= 0.99;
p.life -= 0.016;
if (p.life <= 0) {
p.position[0] = Math.random();
p.position[1] = Math.random();
p.velocity[0] = (Math.random() - 0.5) * 0.01;
p.velocity[1] = (Math.random() - 0.5) * 0.01;
p.life = p.maxLife;
}
const lifeRatio = p.life / p.maxLife;
const alpha = p.opacity * Math.min(1, lifeRatio * 5);
ctx2d.beginPath();
ctx2d.arc(
p.position[0] * canvas.width,
p.position[1] * canvas.height,
p.size * (0.5 + lifeRatio * 0.5),
0,
Math.PI * 2
);
ctx2d.fillStyle = `rgba(28, 28, 28, ${alpha})`;
ctx2d.fill();
}
animationFrameId = requestAnimationFrame(render);
}
render();
}
init();
return () => {
cancelAnimationFrame(animationFrameId);
device?.destroy();
};
}, [supported, prefersReducedMotion, particleCount, intensity]);
if (supported === null) {
return <div className={cn('absolute inset-0 overflow-hidden', className)} aria-hidden="true" />;
}
if (!supported || prefersReducedMotion) {
if (fallback) {
return <>{fallback}</>;
}
return <div className={cn('absolute inset-0 overflow-hidden', className)} aria-hidden="true" />;
}
return (
<div ref={containerRef} className={cn('absolute inset-0 overflow-hidden', className)} aria-hidden="true">
<canvas
ref={canvasRef}
role="img"
aria-hidden="true"
className="w-full h-full"
/>
</div>
);
}
- 步骤 4:运行测试验证通过
运行:npx vitest run src/components/effects/webgpu-particle.test.tsx
预期:PASS
- 步骤 5:Commit
git add src/components/effects/webgpu-particle.tsx src/components/effects/webgpu-particle.test.tsx
git commit -m "feat: add WebGPUParticle component with Canvas 2D fallback"
任务 4:统一粒子效果入口(自动降级)
文件:
-
创建:
src/components/effects/particle-effect.tsx -
创建:
src/components/effects/particle-effect.test.tsx -
修改:
src/components/effects/index.ts -
步骤 1:编写失败的测试
// src/components/effects/particle-effect.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ParticleEffect } from './particle-effect';
describe('ParticleEffect', () => {
it('renders without errors', () => {
render(<ParticleEffect particleCount={100} />);
const container = document.querySelector('[aria-hidden="true"]');
expect(container).toBeInTheDocument();
});
it('passes props to underlying component', () => {
render(<ParticleEffect particleCount={200} intensity="prominent" className="test-particle" />);
const container = document.querySelector('.test-particle');
expect(container).toBeInTheDocument();
});
});
- 步骤 2:运行测试验证失败
运行:npx vitest run src/components/effects/particle-effect.test.tsx
预期:FAIL
- 步骤 3:编写最少实现代码
// src/components/effects/particle-effect.tsx
'use client';
import { useState, useEffect, type ReactNode } from 'react';
import { detectWebGPU } from '@/lib/webgpu-detect';
import { WebGPUParticle } from './webgpu-particle';
import { DataParticleFlow } from './data-particle-flow';
interface ParticleEffectProps {
particleCount?: number;
color?: string;
intensity?: 'subtle' | 'normal' | 'prominent';
shape?: 'circle' | 'square' | 'triangle' | 'diamond' | 'star' | 'mixed';
effect?: 'default' | 'pulse' | 'glow' | 'trail';
className?: string;
fallback?: ReactNode;
}
export function ParticleEffect({
particleCount = 5000,
color = 'var(--color-brand-primary)',
intensity = 'normal',
shape = 'circle',
effect = 'default',
className = '',
fallback,
}: ParticleEffectProps) {
const [useWebGPU, setUseWebGPU] = useState(false);
useEffect(() => {
detectWebGPU().then(setUseWebGPU);
}, []);
if (useWebGPU) {
return (
<WebGPUParticle
particleCount={particleCount}
color={color}
intensity={intensity}
className={className}
fallback={
<DataParticleFlow
particleCount={Math.min(particleCount, 60)}
color={color}
intensity={intensity}
shape={shape}
effect={effect}
className={className}
/>
}
/>
);
}
return (
<DataParticleFlow
particleCount={Math.min(particleCount, 60)}
color={color}
intensity={intensity}
shape={shape}
effect={effect}
className={className}
/>
);
}
- 步骤 4:更新 effects/index.ts 导出
在 src/components/effects/index.ts 中添加:
export { ParticleEffect } from './particle-effect';
- 步骤 5:运行测试验证通过
运行:npx vitest run src/components/effects/particle-effect.test.tsx
预期:PASS
- 步骤 6:Commit
git add src/components/effects/particle-effect.tsx src/components/effects/particle-effect.test.tsx src/components/effects/index.ts
git commit -m "feat: add ParticleEffect with automatic WebGPU/Canvas2D fallback"
任务 5:替换 Hero 区域粒子效果
文件:
-
修改:
src/components/sections/hero-section.tsx -
步骤 1:替换 DataParticleFlow 为 ParticleEffect
在 src/components/sections/hero-section.tsx 中:
// 替换导入
import { ParticleEffect } from '@/components/effects/particle-effect';
// 移除旧导入
// import { DataParticleFlow } from '@/components/effects/data-particle-flow';
// 替换组件使用
// 旧:
// <DataParticleFlow particleCount={60} color="var(--color-brand-primary)" intensity="subtle" shape="square" effect="pulse" />
// 新:
<ParticleEffect
particleCount={5000}
color="var(--color-brand-primary)"
intensity="subtle"
shape="square"
effect="pulse"
/>
- 步骤 2:验证粒子效果
运行:npm run dev
操作:在 Chrome 中访问首页
预期:
-
Chrome/Edge:WebGPU 粒子,5000 个粒子流畅运行
-
Firefox/Safari:自动降级到 Canvas 2D,60 个粒子
-
步骤 3:Commit
git add src/components/sections/hero-section.tsx
git commit -m "feat: replace DataParticleFlow with ParticleEffect (WebGPU auto-detect)"
任务 6:PPR 迁移可行性评估
文件:
-
创建:
docs/superpowers/plans/ppr-migration-assessment.md -
步骤 1:编写 PPR 迁移评估文档
创建 docs/superpowers/plans/ppr-migration-assessment.md:
# Partial Prerendering (PPR) 迁移可行性评估
## 当前架构
- **渲染模式**: `output: 'export'` — 纯静态导出
- **部署方式**: Nginx 静态文件服务 / Docker 静态容器
- **数据来源**: 所有内容硬编码在 `src/lib/constants/` 中
## PPR 要求
- Next.js 15+ App Router
- Node.js 运行时(非静态导出)
- 支持 Suspense 边界
## 迁移成本分析
### 必须变更
1. 移除 `next.config.ts` 中的 `output: 'export'`
2. 部署从静态文件切换到 Node.js 服务器或 Vercel
3. Nginx 配置需要反向代理到 Node.js 进程
### 可获得收益
1. 首屏 TTFB 保持静态速度(静态 shell)
2. 动态内容(新闻、案例)可从 CMS 流式加载
3. 支持个性化内容、A/B 测试
4. 支持 ISR(增量静态再生),无需全量构建
### 风险
1. 运维复杂度增加(需要 Node.js 进程管理)
2. 服务器成本增加
3. 当前所有内容为静态常量,PPR 的动态能力暂无实际使用场景
## 评估结论
**当前阶段不建议迁移。** 理由:
1. 网站内容全部为静态常量,无动态数据源
2. 静态导出的性能和可靠性优于服务器渲染
3. 运维成本增加但收益有限
**迁移触发条件**(满足任一即可启动):
1. 接入 CMS(如 Strapi/Payload)管理新闻/案例内容
2. 需要个性化推荐或 A/B 测试
3. 需要用户登录和权限控制
4. 内容更新频率超过每周一次
## 原型验证步骤(供未来参考)
1. 创建 `next.config.ts` PPR 配置
2. 将新闻/案例 section 改为动态 Suspense 边界
3. 使用 Vercel 部署测试 PPR 效果
4. 性能对比:静态导出 vs PPR
- 步骤 2:Commit
git add docs/superpowers/plans/ppr-migration-assessment.md
git commit -m "docs: add PPR migration feasibility assessment"
任务 7:Rust → Wasm 图像处理模块(原型)
文件:
-
创建:
wasm/ink-filter/Cargo.toml -
创建:
wasm/ink-filter/src/lib.rs -
创建:
src/lib/wasm-loader.ts -
创建:
src/lib/wasm-loader.test.ts -
步骤 1:创建 Rust 项目结构
创建 wasm/ink-filter/Cargo.toml:
[package]
name = "ink-filter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
[dependencies.web-sys]
version = "0.3"
features = ["ImageData"]
[profile.release]
opt-level = 3
lto = true
- 步骤 2:编写 Rust 水墨滤镜核心
创建 wasm/ink-filter/src/lib.rs:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn apply_ink_filter(
data: &mut [u8],
width: u32,
height: u32,
threshold: f32,
blur_radius: u32,
) {
let mut grayscale = vec![0u8; (width * height) as usize];
for y in 0..height {
for x in 0..width {
let idx = ((y * width + x) * 4) as usize;
let r = data[idx] as f32;
let g = data[idx + 1] as f32;
let b = data[idx + 2] as f32;
let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
grayscale[(y * width + x) as usize] = gray;
}
}
for y in blur_radius..height - blur_radius {
for x in blur_radius..width - blur_radius {
let mut sum: u32 = 0;
let mut count: u32 = 0;
for dy in -blur_radius as i32..=blur_radius as i32 {
for dx in -blur_radius as i32..=blur_radius as i32 {
let ny = (y as i32 + dy) as u32;
let nx = (x as i32 + dx) as u32;
sum += grayscale[(ny * width + nx) as usize] as u32;
count += 1;
}
}
let avg = sum / count;
let idx = ((y * width + x) * 4) as usize;
let is_ink = avg < (threshold * 255.0) as u32;
if is_ink {
let ink_intensity = (avg as f32 / 255.0).min(1.0);
data[idx] = (28.0 * ink_intensity) as u8;
data[idx + 1] = (28.0 * ink_intensity) as u8;
data[idx + 2] = (28.0 * ink_intensity) as u8;
} else {
let paper_intensity = (avg as f32 / 255.0).min(1.0);
data[idx] = (255.0 * paper_intensity) as u8;
data[idx + 1] = (251.0 * paper_intensity) as u8;
data[idx + 2] = (245.0 * paper_intensity) as u8;
}
}
}
}
#[wasm_bindgen]
pub fn apply_seal_stamp_effect(
data: &mut [u8],
width: u32,
height: u32,
center_x: f32,
center_y: f32,
radius: f32,
) {
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let dist = (dx * dx + dy * dy).sqrt();
let idx = ((y * width + x) * 4) as usize;
if dist < radius {
let edge_dist = radius - dist;
let noise = ((x ^ y) % 20) as f32 / 20.0;
let alpha = if edge_dist < 3.0 {
edge_dist / 3.0 * (0.7 + noise * 0.3)
} else {
0.7 + noise * 0.3
};
data[idx] = 196;
data[idx + 1] = 30;
data[idx + 2] = 58;
data[idx + 3] = (alpha * 255.0) as u8;
}
}
}
}
- 步骤 3:编写 Wasm 加载器
创建 src/lib/wasm-loader.ts:
interface InkFilterWasm {
apply_ink_filter: (data: number[], width: number, height: number, threshold: number, blur_radius: number) => void;
apply_seal_stamp_effect: (data: number[], width: number, height: number, center_x: number, center_y: number, radius: number) => void;
}
let wasmInstance: InkFilterWasm | null = null;
let wasmLoading: Promise<InkFilterWasm | null> | null = null;
export async function loadInkFilter(): Promise<InkFilterWasm | null> {
if (wasmInstance) return wasmInstance;
if (wasmLoading) return wasmLoading;
wasmLoading = (async () => {
try {
const module = await import('@/wasm/ink-filter/ink_filter.js');
await module.default();
wasmInstance = module as unknown as InkFilterWasm;
return wasmInstance;
} catch {
console.warn('WebAssembly ink-filter module not available');
return null;
}
})();
return wasmLoading;
}
export function isWasmAvailable(): boolean {
return typeof WebAssembly !== 'undefined';
}
- 步骤 4:编写加载器测试
// src/lib/wasm-loader.test.ts
import { describe, it, expect } from 'vitest';
import { isWasmAvailable } from './wasm-loader';
describe('wasm-loader', () => {
it('detects WebAssembly availability', () => {
const result = isWasmAvailable();
expect(typeof result).toBe('boolean');
});
it('returns true in browser environment', () => {
expect(isWasmAvailable()).toBe(true);
});
});
- 步骤 5:运行测试
运行:npx vitest run src/lib/wasm-loader.test.ts
预期:PASS
- 步骤 6:Commit
git add wasm/ink-filter/ src/lib/wasm-loader.ts src/lib/wasm-loader.test.ts
git commit -m "feat: add Rust Wasm ink filter module and JS loader (prototype)"
任务 8:Phase 3 集成验证与收尾
文件:
-
无新增/修改
-
步骤 1:运行完整构建
运行:npm run build
预期:构建成功(Wasm 模块不影响构建,因为未集成到构建流程)
- 步骤 2:运行全量测试
运行:npx vitest run
预期:所有测试通过
- 步骤 3:验证 WebGPU 粒子降级链
操作:
- Chrome(WebGPU 支持)→ 5000 粒子流畅运行
- Firefox(WebGPU 可能不支持)→ 自动降级到 Canvas 2D,60 粒子
- 开启
prefers-reduced-motion→ 无粒子效果
预期:降级链完整工作
- 步骤 4:验证 Wasm 原型编译
运行:cd wasm/ink-filter && cargo build --target wasm32-unknown-unknown --release
预期:Rust 编译成功,生成 .wasm 文件
- 步骤 5:性能基准测试
操作:
- Chrome DevTools → Performance 面板
- 录制首页滚动 5 秒
- 对比 Phase 1 前后的 Main Thread 占用
预期:CSS Scroll-Driven 动画不占用 Main Thread,GSAP ScrollTrigger 占用低于 framer-motion
- 步骤 6:最终 Commit
git add -A
git commit -m "chore: Phase 3 integration verification complete"
附录:Wasm 构建与集成说明
构建 Wasm 模块
# 安装 wasm-pack
cargo install wasm-pack
# 构建
cd wasm/ink-filter
wasm-pack build --target web --out-dir ../../public/wasm
# 生成的文件:
# public/wasm/ink_filter.js - JS 绑定
# public/wasm/ink_filter_bg.wasm - Wasm 二进制
在组件中使用
import { loadInkFilter } from '@/lib/wasm-loader';
async function applyInkEffect(canvas: HTMLCanvasElement) {
const wasm = await loadInkFilter();
if (!wasm) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
wasm.apply_ink_filter(
Array.from(imageData.data),
canvas.width,
canvas.height,
0.5,
2
);
}