Graphics Portfolio 网站本体
个人技术博客 + 作品集 + 可在浏览器中实时调参的 Shader 展示平台。Astro 纯静态生成,Tailwind CSS,Markdown/MDX 内容集合,three.js + WebGL 2 实时预览,Pagefind 离线搜索。
该项目当前状态为「开发中」,页面上的描述反映的是当前进度,不代表已经完成。
- 使用技术
- Astro 7 TypeScript 5.9 Tailwind CSS 4 three.js WebGL 2 / GLSL ES 3.00 MDX Pagefind Shiki KaTeX Playwright GitHub Actions
- 链接
- 无公开源码 无在线 Demo
项目背景与目标
需要一个能同时承载三类内容的公开站点:
- 技术文章:C++ 与计算机基础、计算机图形学、OpenGL/GLSL、Unity URP/HLSL、实时渲染与 Shader 编程;
- 作品集:项目状态、技术栈、核心实现、难点与解决方案,要求状态如实标注;
- 实时 Shader 展示:访客能在浏览器里直接看到效果,并通过控件改参数,而不是只看截图。
约束条件是:尽量使用免费工具与免费托管;不引入数据库、登录系统、评论后端或自建服务器;普通文章页面不能因为 Shader 模块而加载 3D 渲染依赖。
当前状态是开发中:代码、内容、自动化测试与部署配置都已经完成并在本地验证通过,但还没有推送到远程仓库、也没有部署到 Cloudflare Pages,所以既没有公开源码链接也没有在线 Demo。
核心实现
三层结构
站点被拆成三个相对独立的部分,边界很清楚:
- 内容系统:
src/content/下的 Markdown/MDX,由src/content.config.ts里的 Zod schema 校验。元数据写错会在npm run build阶段直接失败,而不是渲染出一个空徽标。 - 网站界面:
src/pages/与src/components/,纯 Astro 组件,没有引入任何前端框架。 - 实时渲染系统:
src/lib/shader-runtime/,只在需要时被动态加载。
着色器源码的存储边界
src/shaders/glsl/ <- GLSL ES 3.00,浏览器中真正编译执行的版本
src/shaders/hlsl/ <- Unity URP 原始 HLSL,只作为文本展示
两者都在 src/ 下而不是 public/ 下,因此不会被当作静态资源直接对外提供,也不可能被误当成客户端模块执行。构建时通过 Vite 的 ?raw glob 读入,序列化成 JSON 嵌入页面,再在客户端交给 RawShaderMaterial 编译。原始 HLSL 永远不会被移植版本覆盖。
按需加载的实时预览
ShaderPreview.astro 在页面上只放一个很小的引导脚本:
const observer = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
obs.unobserve(entry.target);
void mountRoot(entry.target as HTMLElement);
}
}, { rootMargin: '400px 0px' });
只有当预览即将进入视口时才 import('@/lib/shader-runtime/mount'),three.js 因此被拆成一个独立的 chunk。构建产物可以直接验证这一点:首页与文章页的 HTML 里没有任何指向 mount.*.js 的引用。
参数模型只有一份
参数 schema 写在内容条目的 frontmatter 里,由 Zod 校验,然后同一个对象同时驱动:
- 服务端渲染出的控件(range / color / checkbox / vec3 三分量);
- 客户端的 uniform 绑定;
- 页面上显示的 uniform 名称与说明文字。
不存在「控件改了但 uniform 没变」或者「schema 与 UI 对不上」的可能,因为它们本来就是同一份数据。所有输入都会经过 coerceParamValue() 做类型与范围校验:越界数值被夹紧,类型完全不对则抛出带参数名的错误。
颜色空间的一致处理
控件给出 sRGB 十六进制,运行时用 THREE.Color.setStyle(hex, SRGBColorSpace) 转成线性再上传;着色器在线性空间计算,最后自己调用 linearToSRGB()。因为 RawShaderMaterial 拿不到 three 追加的输出转换代码,这一步必须显式写。renderer.outputColorSpace 相应地设为 LinearSRGBColorSpace,避免以后换成 ShaderMaterial 时被编码两次。
技术难点和解决方案
1. three.js 会吞掉着色器编译错误
three 遇到编译失败只调用 console.error,不抛异常,也不把日志交回调用方。对一个要向访问者解释失败原因的作品集网站来说不够用。
解决方案是在把源码交给 three 之前,用同一个 WebGL 2 上下文先编译一次临时 program:
gl.shaderSource(shader, `#version 300 es\n${source}`);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new ShaderCompileError(stage, gl.getShaderInfoLog(shader) ?? '', source);
}
手动补上的 #version 300 es 与 three 的前缀完全一致,因此驱动报出的行号能和源文件对应上;额外再用 annotateShaderError() 在 ERROR: 0:行号 后面附上对应源码行。临时 program 与 shader 在 finally 里删除。
仓库里保留了一个故意写错的着色器条目(compile-error-demo),专门用来端到端验证这条路径,Playwright 用例断言错误面板可见且日志非空。
2. 世界空间还是视图空间
three 的 normalMatrix 是 modelViewMatrix 的逆转置,给出视图空间法线;而 URP 的 TransformObjectToWorldNormal 给出世界空间法线。把它和世界空间的 cameraPosition 混用,边缘光会随相机移动而漂移。
移植版本统一在世界空间工作,法线自己算:normalize(mat3(modelMatrix) * normal),并在注释里写明这只在均匀缩放时严格等价于逆转置。
3. 法线矩阵、内建 uniform 与 #version 的重复
RawShaderMaterial + glslVersion: THREE.GLSL3 的组合下,three 只前置一行 #version 300 es。因此源文件里不能再写一遍版本指令,但 modelMatrix、modelViewMatrix、projectionMatrix、normalMatrix、cameraPosition、position、normal、uv 又都必须手动声明——three 会上传它们,只是不帮你声明。这一条在源码注释与文章里都做了说明。
4. GPU 资源释放
three.js 不会自动回收 GPU 资源。disposeRenderer() 会遍历场景释放几何体与材质、扫描材质属性释放其中的纹理、停止动画循环、调用 renderer.dispose() 与 forceContextLoss(),并返回释放前后的 geometries / textures / programs 计数。这个计数被挂到 window.__graphicsPortfolioShaders 上,自动化测试直接断言它归零,而不是靠肉眼看有没有内存泄漏。
页面隐藏(visibilitychange)、画布离开视口(IntersectionObserver)、页面卸载(pagehide)三种情况都会停止 rAF 或释放资源。
5. 低性能设备与分辨率上限
设备像素比被限制在 2,同时还有一个 2.6 Mpx 的绘制缓冲区上限:
if ((area * ratio * ratio) / 1e6 > maxMegapixels) {
ratio = Math.sqrt((maxMegapixels * 1e6) / area);
}
3x 屏的手机因此不会渲染 9 倍像素。自动化测试全部跑在无头 Chromium 的 SwiftShader 软件渲染上(没有 GPU),这本身就构成一个低性能设备的验证场景,实际帧率会记录在测试报告里。
自动化测试
npm run test 使用 Playwright,针对构建后的站点(astro preview 提供 dist/)运行,桌面与移动两个 project:
- 站点结构:关键路由可访问、导航高亮、404 页面、草稿不会生成页面;
- 内容:文章渲染、代码高亮与复制按钮、KaTeX 公式、目录锚点、上一篇/下一篇;
- 实时渲染:状态进入
running、画布出现非纯色像素、两帧之间像素变化(动画推进)、改参数改变画面、暂停后时间停止、重置恢复默认值、减少动态效果冻结动画; - 错误路径:
compile-error-demo页面显示错误面板与驱动日志,并切回静态回退; - 能力检测:伪造 WebGL 2 不可用时页面显示回退预览而不是空白;
- 资源释放:
geometries / textures / programs归零; - 搜索:Pagefind 索引可用、输入关键词能跳转到正确页面;
- 移动端:Pixel 7 视口下没有横向溢出、控件可点击。
当前状态与后续计划
已完成并在本地验证:Astro 7 工程、内容集合与 schema、博客/项目/Shader 三类页面、深色模式、Pagefind 搜索、RSS 与 sitemap、5 个 Shader 条目(3 个实时、1 个故障演练、1 个仅源码)、HLSL→GLSL 移植示例与映射表、GitHub Actions 工作流、Cloudflare Pages 构建配置、Playwright 测试套件与文档。
待作者本人完成(需要账号与授权,不会代做):
- 创建 GitHub 远程仓库并推送;
- 在 Cloudflare Pages 上连接仓库、配置构建命令并发布;
- 填写
src/lib/site.ts里的 GitHub 链接与邮箱,补写「关于」页面的真实个人经历; - 用自己的项目替换作品集条目、用自己的文章替换示例技术文章;
- 如果安装了 Unity,实际运行
RimLitUnlit.shader做并排对照,并把porting.verifiedInUnity改为true。
后续技术计划:PBR 材质示例(需要 IBL 环境贴图)、程序化法线的滚动水面(对应目前的 static-only 条目)、文章代码块的行高亮与 diff 标注、OG 图片自动生成。