fe6e4b1c54
- Remove TestimonialSection from homepage (no customers yet) - Footer: dark theme, NAVIGATION_V2 + MEGA_DROPDOWN_DATA links - Mobile Menu: NAVIGATION_V2 with collapsible dropdown support
1136 lines
32 KiB
Markdown
1136 lines
32 KiB
Markdown
# 前沿技术升级 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:编写失败的测试**
|
||
|
||
```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
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```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);
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 3:Commit**
|
||
|
||
```bash
|
||
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:编写失败的测试**
|
||
|
||
```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
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```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
|
||
|
||
- [ ] **步骤 6:Commit**
|
||
|
||
```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/Edge:WebGPU 粒子,5000 个粒子流畅运行
|
||
- Firefox/Safari:自动降级到 Canvas 2D,60 个粒子
|
||
|
||
- [ ] **步骤 3:Commit**
|
||
|
||
```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
|
||
```
|
||
|
||
- [ ] **步骤 2:Commit**
|
||
|
||
```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
|
||
|
||
- [ ] **步骤 6:Commit**
|
||
|
||
```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. Chrome(WebGPU 支持)→ 5000 粒子流畅运行
|
||
2. Firefox(WebGPU 可能不支持)→ 自动降级到 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 Thread,GSAP 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
|
||
);
|
||
}
|
||
```
|