Files
novalon-website/docs/superpowers/plans/2026-04-28-phase3-webgpu-ppr-wasm.md
张翔 fe6e4b1c54 refactor: P0 - remove testimonial, migrate footer & mobile menu to NAVIGATION_V2
- Remove TestimonialSection from homepage (no customers yet)
- Footer: dark theme, NAVIGATION_V2 + MEGA_DROPDOWN_DATA links
- Mobile Menu: NAVIGATION_V2 with collapsible dropdown support
2026-04-30 22:00:00 +08:00

1136 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前沿技术升级 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 相关配置(如需要) |
---
## 任务 1WebGPU 能力检测工具
**文件:**
- 创建:`src/lib/webgpu-detect.ts`
- 创建:`src/lib/webgpu-detect.test.ts`
- [ ] **步骤 1:编写失败的测试**
```ts
// 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:编写最少实现代码**
```ts
// 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
- [ ] **步骤 5Commit**
```bash
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`
```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**
在同一文件中追加:
```wgsl
// 渲染管线着色器
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);
}
```
- [ ] **步骤 3Commit**
```bash
git add src/components/effects/webgpu-ink-shader.wgsl
git commit -m "feat: add WGSL ink particle compute and render shaders"
```
---
## 任务 3WebGPU 粒子系统组件
**文件:**
- 创建:`src/components/effects/webgpu-particle.tsx`
- 创建:`src/components/effects/webgpu-particle.test.tsx`
- [ ] **步骤 1:编写失败的测试**
```tsx
// 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:编写最少实现代码**
```tsx
// 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
- [ ] **步骤 5Commit**
```bash
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:编写失败的测试**
```tsx
// 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:编写最少实现代码**
```tsx
// 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` 中添加:
```ts
export { ParticleEffect } from './particle-effect';
```
- [ ] **步骤 5:运行测试验证通过**
运行:`npx vitest run src/components/effects/particle-effect.test.tsx`
预期:PASS
- [ ] **步骤 6Commit**
```bash
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` 中:
```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/EdgeWebGPU 粒子,5000 个粒子流畅运行
- Firefox/Safari:自动降级到 Canvas 2D60 个粒子
- [ ] **步骤 3Commit**
```bash
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`
```markdown
# 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
```
- [ ] **步骤 2Commit**
```bash
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`
```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`
```rust
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`
```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:编写加载器测试**
```ts
// 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
- [ ] **步骤 6Commit**
```bash
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 粒子降级链**
操作:
1. ChromeWebGPU 支持)→ 5000 粒子流畅运行
2. FirefoxWebGPU 可能不支持)→ 自动降级到 Canvas 2D,60 粒子
3. 开启 `prefers-reduced-motion` → 无粒子效果
预期:降级链完整工作
- [ ] **步骤 4:验证 Wasm 原型编译**
运行:`cd wasm/ink-filter && cargo build --target wasm32-unknown-unknown --release`
预期:Rust 编译成功,生成 .wasm 文件
- [ ] **步骤 5:性能基准测试**
操作:
1. Chrome DevTools → Performance 面板
2. 录制首页滚动 5 秒
3. 对比 Phase 1 前后的 Main Thread 占用
预期:CSS Scroll-Driven 动画不占用 Main ThreadGSAP ScrollTrigger 占用低于 framer-motion
- [ ] **步骤 6:最终 Commit**
```bash
git add -A
git commit -m "chore: Phase 3 integration verification complete"
```
---
## 附录:Wasm 构建与集成说明
### 构建 Wasm 模块
```bash
# 安装 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 二进制
```
### 在组件中使用
```tsx
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
);
}
```