从 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 或某段动画,而是几个边界:
- 内容必须在没有 GPU 的情况下仍然成立
- 场景服务于阅读,而不是要求读者欣赏技术演示
- 能在构建阶段完成的事,不留到浏览器运行时
- 静态门禁只解决轻量隐私,不假装自己是真正的安全系统
- 设计不是不断增加元素,而是持续删除不再必要的解释
这个博客仍然会继续变化。技术架构不会是最终答案,但它应该始终允许内容先抵达读者,再让风吹过来。