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

32 KiB
Raw Permalink Blame History

前沿技术升级 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:编写失败的测试

// 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

  • 步骤 5Commit
git add src/lib/webgpu-detect.ts src/lib/webgpu-detect.test.ts
git commit -m "feat: add WebGPU capability detection utilities"

任务 2WGSL 水墨粒子着色器

文件:

  • 创建: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);
}
  • 步骤 3Commit
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:编写失败的测试

// 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

  • 步骤 5Commit
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

  • 步骤 6Commit
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/EdgeWebGPU 粒子,5000 个粒子流畅运行

  • Firefox/Safari:自动降级到 Canvas 2D60 个粒子

  • 步骤 3Commit

git add src/components/sections/hero-section.tsx
git commit -m "feat: replace DataParticleFlow with ParticleEffect (WebGPU auto-detect)"

任务 6PPR 迁移可行性评估

文件:

  • 创建: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
  • 步骤 2Commit
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

  • 步骤 6Commit
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
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
  );
}