有时候你只是想快速试一段着色器代码,不想开Shadertoy,不想配构建流程,什么都不想要。只要一个文本框、一块画布、实时刷新的循环。这个项目就是干这个的:约350行原生JavaScript,固定一个全屏四边形的顶点着色器,片段着色器随你现场编辑。编译错误带行号返回到界面;URL哈希能完整保存你的代码,分享到Twitter直接点开就能用。
在线演示:https://sen.ltd/portfolio/shader-playground/
GitHub仓库:https://github.com/sen-ltd/shader-playground
核心功能很直白。顶点着色器是写死的——两个三角形拼成满屏四边形。片段着色器交给用户编辑。页面在每次按键后220毫秒防抖执行gl.compileShader(...),编译成功就热切换程序。三个uniform自动绑定:u_resolution(绘制缓冲区尺寸)、u_mouse(WebGL像素坐标系下的光标位置)、u_time(页面加载后的秒数)。编译错误从驱动的info log解析出来,带上行号显示在界面上。着色器源码用base64url编码塞进location.hash来回传递,所以分享永久链接在任何浏览器都一致可用。
就这些。下面列的是实现过程中踩到的小坑。
WebGL上下文——两个值得设置的标志
const gl =els.canvas.getContext("webgl", {antialias: true,premultipliedAlpha: false,els.canvas.getContext("experimental-webgl");两个几乎总会遇到的标志:premultipliedAlpha: false决定"画布是不透明的绘制表面"还是"画布与背后的DOM混合"。如果忘了这个,即使片段着色器写gl_FragColor = vec4(rgb, 1.0),某些浏览器上还是会看到页面背景透过来——然后你会花十分钟想不明白为什么。显式设置掉这个意外。antialias: true虽然是默认值,但显式写出来让你记得它存在。交换链上的MSAA让图形边缘干净,不需要逐片段的MSAA逻辑。
experimental-webgl回退现在基本只剩历史意义(Android Chrome 2014年左右,IE11)。多写个||的成本,所以我留着。
解析编译错误日志
驱动(大部分)输出这样的行:
ERROR: 0:5: 'foo' : undeclared identifierERROR: 0:12: 'bar' : assignment to const variableWARNING: 0:3: implicit conversion
0是文件索引,单源字符串时几乎总是零。5是源码行号,后面是人类可读的消息。
const ERROR_RE =/^\s*(ERROR|WARNING)\s*:\s*(\d+)\s*:\s*(\d+)\s*:\s*(.+?)\s*$/i;export function parseShaderError(infoLog) {if (!infoLog) return [];const out = [];for (const rawLine of infoLog.split(/\r?\n/)) {const line = rawLine.trim();if (!line) continue;const m = line.match(ERROR_RE);if (m) {out.push({severity: m[1].toUpperCase() === "ERROR" ? "error" : "warning",line: Number(m[3]),message: m[4],} else {// Mali / Adreno 经常前置一段自由格式的摘要行。别丢掉。out.push({ severity: "error", line: 0, message: line })return out;}Mali和Adreno GPU的驱动喜欢在前面加一段总结性文字,正则匹配不上,但信息有用,所以兜底存为行号0的错误。
防抖与热重载的时序
220毫秒的防抖间隔是打字节奏和编译延迟的折中。太短会频繁编译失败闪烁;太长反馈迟钝。用setTimeout+clearTimeout实现,没有拉第三方库。
热切换程序时要注意:WebGL不允许直接替换着色器,必须重新链接着色器程序。旧程序要gl.deleteProgram释放,否则显存泄漏。新程序编译链接成功后,再解绑旧程序、绑定新程序、重新设置uniform位置。这个顺序错了会导致一帧黑屏或报错。
URL哈希的体积与编码
base64url相比标准base64,把+换成-、/换成_、去掉填充=,这样不需要额外URL编码就能塞进hash。一段几百行的着色器编码后通常几KB,在浏览器URL长度限制内(约2MB)。
解码时要处理两种异常情况:hash不存在(首次打开)、base64损坏(用户手动改了URL)。前者回退到默认示例着色器,后者同样回退并控制台报个错,保证页面不崩。
鼠标坐标的坐标系陷阱
u_mouse直接传event.clientX/event.clientY是错的,因为WebGL的绘制缓冲区尺寸和CSS显示尺寸可能不同(Retina屏、页面缩放)。正确做法是用gl.drawingBufferWidth/gl.drawingBufferHeight计算比例,或者直接用event.offsetX*canvas.width/canvas.clientWidth转换。
这个项目选了后者,因为offsetX/Y已经相对于canvas元素,省去再算相对位置的麻烦。
为什么不用Shadertoy
Shadertoy功能丰富:多通道、音频频谱、纹理上传、社交功能。但有时候这些全是噪音。你想快速验证一个 signed distance function 的变形,或者调一个噪声函数的参数,350行的自托管方案没有学习成本、没有账号、没有加载时间、没有"Trending"标签分散注意力。
代码结构也适合当教学材料:没有构建步骤,没有npm依赖,打开HTML文件就能读。顶点着色器固定、片段着色器动态,这个模型覆盖了90%的实时图形原型需求。
项目用原生模块(
热门跟贴