2026年9月20日 — 约 6 分钟

从 Astro 到 WebGPU:这个博客的技术架构与实现

这个博客最初只是一个很普通的需求:写文章、放简历、拥有一块真正属于自己的空间。

但我不想让它只是一个换了配色的内容模板。首页应该有风吹过旷野的感觉,文章阅读需要安静,简历又要与公开内容保持边界。最终,它变成了一个由 Astro 静态内容层、CSS 视觉系统和 GPU 实时场景共同组成的小型网站。

这篇文章不讲某个组件怎么“照着写”,而是记录整个系统如何分层、为什么这样取舍,以及哪些部分值得继续改进。

整体架构

站点可以拆成四层:

Markdown 内容
    ↓
Astro Content Collections
    ↓
页面、布局与静态路由
    ↓
CSS 视觉系统 + GPU 场景增强
    ↓
静态文件 dist/ → Nginx

核心原则是:内容与页面先成立,视觉效果再渐进增强。

即使浏览器不支持 WebGPU,文章、导航和简历入口仍然可以使用;如果连 WebGL2 也不可用,页面会退化为 CSS 渐变背景,而不是留下一块黑色画布。

为什么选择 Astro

博客没有登录、评论或复杂的服务端状态,大部分页面在发布时就能确定。Astro 的静态输出正好符合这个模型:

  • Markdown 在构建阶段转换为 HTML
  • 每篇文章生成独立路由
  • 默认不向浏览器发送前端框架运行时
  • RSS、Sitemap 和 SEO 元数据可以在构建阶段完成
  • 最终产物只是普通静态文件,部署和备份都很简单

项目没有引入 React、Vue 等客户端框架。需要交互的地方——场景启动、导航滚动状态、简历密码门禁——都使用小段原生 JavaScript 完成。

内容层:Markdown 不是散落的文件

文章位于 src/content/blog/,由 Astro Content Collections 统一加载。Schema 会在构建时检查标题、摘要、日期、标签、封面和草稿状态:

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    scene: z.enum(['grass', 'tide', 'ember']).optional(),
    draft: z.boolean().default(false),
  }),
});

这层约束很重要。它让“少写一个字段”从线上页面异常,变成构建阶段就能发现的问题。

文章详情页通过 getStaticPaths() 为所有非草稿文章生成路由,同时计算上一篇和下一篇。归档页按年份分组,首页只展示最新文章、话题和归档入口,避免首页再次承担完整列表的职责。

同一份内容集合还会生成 RSS。也就是说,文章列表、详情页和订阅源没有三套数据,它们只是同一个内容模型的不同视图。

页面层:共享骨架,场景各自表达

所有页面都经过 BaseLayout.astro,统一输出:

  • 页面标题与描述
  • Canonical URL
  • Open Graph 元数据
  • RSS 发现链接
  • Header 与 Footer
  • 全局样式

页面本身只处理自己的内容与场景:

页面场景阶段主要职责
首页暮金芒原个人表达、最新文章入口
文章归档潮汐按年份浏览全部内容
简历余烬私密信息展示与打印导出
文章详情按需静态封面专注阅读,减少实时渲染干扰

这种拆分让内容层和视觉层互不绑死。文章详情不需要为了“统一风格”强行运行一套昂贵的 3D 背景;场景页也不需要把 WebGPU 逻辑塞进页面组件。

视觉层:同一片场,三个状态

首页、归档和简历共享 SceneCanvas.astro。组件只负责挂载画布,并按能力选择渲染路径:

navigator.gpu 可用
    → Three.js WebGPURenderer + TSL
失败或不支持
    → 原生 WebGL2 场景
再次失败
    → CSS 静态渐变

WebGPU 主路径

WebGPU 场景源码位于 public/field/src/scene/,通过 esbuild 打包成浏览器直接加载的 public/js/home-scene.js。

场景不是三份独立模型,而是一套可变形的场:

  • phase = 0:暮金芒原
  • phase = 1:潮汐
  • phase = 2:余烬

天空、太阳、雾、地面、相机、Bloom、颗粒和暗角由调色状态控制;需要逐位置计算的风场、地形高度和实例形态则在 shader 中完成。

每个页面锁定自己的物理阶段,滚动只改变 readout。这意味着滚动不是把场景无止境旋转,而是在“建立镜头”和“阅读镜头”之间平滑过渡。首页滚到文章区时,相机会升高并后退,让前景草叶减少遮挡;简历页则逐渐趋于稳定,保证长文本阅读。

性能分级

主路径准备了三档实例规模:

const TIERS = [
  { count: 262144, dpr: 1.6 },
  { count: 150000, dpr: 1.25 },
  { count: 80000, dpr: 1.0 },
];

渲染器会结合帧耗时调整实例数量和像素比。高性能设备获得更密的草场,性能较弱的设备优先保持运动稳定,而不是硬撑同一份画质参数。

WebGL2 与 CSS 兜底

WebGL2 版本不依赖 Three.js,直接编译 GLSL、创建实例缓冲并驱动相机。它保留三种场景状态和指针视差,但采用更直接的渲染流程。

如果 WebGL2 初始化也失败,组件会给 body 添加 scene-fallback,隐藏画布并显示 CSS 渐变。视觉会简化,但内容结构、对比度和交互入口仍然完整。

这套降级链解决的不是“兼容所有特效”,而是更重要的事:任何时候都不让视觉增强阻塞内容。

CSS:把电影感限制在可读范围内

全局样式同时承担设计系统与场景适配:

  • CSS 变量统一颜色、字体、宽度和间距
  • 场景页使用浅色文字和独立的对比度规则
  • 首页蒙版围绕文字形成椭圆暗角,避免出现矩形遮罩边界
  • 桌面和手机分别调整标题、导航与信息分组
  • prefers-reduced-motion 降低动画强度
  • 简历拥有独立打印样式,可直接导出 PDF

GPU 负责光、风和空间,CSS 负责排版、层级与可访问性。二者的边界必须清楚,否则很容易出现“背景很好看,文字却读不清”的页面。

简历门禁:隐私边界与静态站限制

简历页默认显示密码界面。输入密码后,浏览器计算 SHA-256 并与保存的摘要比较;验证通过后在 sessionStorage 中记录当前会话状态。

同时,简历页还做了三件事:

  • 输出 noindex, nofollow, noarchive
  • 从 Sitemap 中排除 /resume
  • 在 robots.txt 中禁止抓取

但必须说明:这仍然只是静态站的轻量门禁。HTML 已经随页面下发,熟悉开发工具的人可以绕过前端限制。它适合阻止随手访问,不适合保护真正敏感的信息。

如果未来需要严格权限,正确方案应是 Nginx Basic Auth、部署平台访问控制,或把简历迁移到需要服务端鉴权的路由。

构建与部署

生产构建通过:

npm run build

Astro 会把页面生成到 dist/。开发环境中页面底部出现的 Astro Dev Toolbar 由开发服务器注入,不会进入生产构建产物。

部署脚本先构建,再使用 rsync --delete 将 dist/ 同步到服务器。Nginx 负责:

  • 静态路由回退到 index.html
  • 对带指纹的 /_astro/ 资源设置长期缓存
  • 对 HTML 禁用缓存,保证发布后立即生效
  • 开启 gzip
  • 提供自定义 404 页面

这套部署没有容器和常驻 Node.js 服务。对一个个人博客来说,简单意味着更少的维护面,也意味着出了问题时更容易定位。

这次实现中的取舍

回头看,最值得保留的不是某个 shader 或某段动画,而是几个边界:

  1. 内容必须在没有 GPU 的情况下仍然成立
  2. 场景服务于阅读,而不是要求读者欣赏技术演示
  3. 能在构建阶段完成的事,不留到浏览器运行时
  4. 静态门禁只解决轻量隐私,不假装自己是真正的安全系统
  5. 设计不是不断增加元素,而是持续删除不再必要的解释

这个博客仍然会继续变化。技术架构不会是最终答案,但它应该始终允许内容先抵达读者,再让风吹过来。