Show HN: Inkwash——一款水彩素描应用及技术解析

Hacker News Top 工具

摘要

Inkwash 是一款基于 WebGL2 的水彩素描应用,可模拟颜料流动与纸张交互,由 Claude Fable 5 生成。本文解释了实现逼真水彩效果的浮点纹理与着色器技术管线。

我基于自己的实体素描实践制作了一款绘图应用,使用流体模拟和一些着色器技巧来模仿水彩风格的墨迹渲染。最佳使用场景是 iPad 或绘图板。链接的文章展示了核心引擎的工作原理,并包含大量交互式小演示。制作过程很有趣,分享出来希望其他人也乐在其中 :)
查看原文
查看缓存全文

缓存时间: 2026/06/17 14:41

# inkwash · 工作原理 来源:https://johnowhitaker.github.io/inkwash/about ## 01 灵感 我热爱自然笔记(https://johnmuirlaws.com/nature-journaling-starting-growing/)。久而久之,我形成了自己偏爱的速写风格和方法:使用Pilot G2钢笔搭配水笔刷。这样我可以同时进行线稿和阴影绘制(我左右手双持,笔刷在左手),并且这迫使我不去过分追求最终效果——总会有墨渍和瑕疵,而且钢笔没有“撤销”功能! 这个项目起初是对Anthropic新模型Claude Fable 5的测试,当我看到在浏览器中重现那种体验的潜力后,它便成长起来。我超爱最终效果! 钢笔水彩速写:一个带有紫色和蓝色水洗的机器人,以及火烈鸟和鸸鹋的钢笔习作 我笔记本中的示例速写,从快速的火烈鸟动态速写到更精细的同人画乐趣。 当然,我处于一个相当滑稽的境地:我“创造”了这个应用,但我实际上并没有碰过代码!我能读懂它(它是一个相当整洁、自包含的单个HTML文件(https://github.com/johnowhitaker/inkwash/blob/main/index.html)),因为我熟悉底层技术。但我希望这个应用能吸引许多*不*是WebGL极客的人,所以为了我们共同的理解,我让Fable生成了几个交互式演示来说明这些概念。也欢迎查看我用来变出这个应用的提示词(https://github.com/johnowhitaker/inkwash/blob/main/prompts.md)。 **免责声明:虽然我稍作整理,但本文其余部分包含大量AI撰写的散文。我通常不喜欢AI写作,尤其是不加说明的!希望这次你能原谅我,因为(经过几次迭代)AI在展示关键部分方面确实做得不错。**随着时间的推移,我可能会重构并重新组织,使其更符合我个人的审美,但不敢保证 :) ## 02 三张状态层 在画布之下,绘画并非像素——而是一小堆浮点纹理,每帧通过大约十几个WebGL2片段着色器来回传递。可以将它们想象成叠在一起的透明薄片: | 字段 | 格式 | 分辨率 | 含义 | |------|------|--------|------| | ink | RGBA16F | 最高2048,匹配屏幕 | *移动*的颜料。RGB是光学密度(吸收多少各色光),而非颜色。Alpha是白色水粉。 | | fixed | RGBA16F | 同ink | 已沉淀在纸上不再移动的颜料(第07节(https://johnowhitaker.github.io/inkwash/about#fixing))。 | | wet | R16F | 同ink | 纸上每个点含有的水量。 | | velocity | RG16F | ~256个单元(短边) | 水的运动。有意粗糙——流动是平滑的,颜料不是。 | | pressure | R16F | 同velocity | 维持流动不可压缩的暂存空间。 | 每帧:描画引擎将高斯溅射点印入这些字段(第05节(https://johnowhitaker.github.io/inkwash/about#marks)),模拟推进它们(03(https://johnowhitaker.github.io/inkwash/about#water)–04(https://johnowhitaker.github.io/inkwash/about#paper)),最后由显示着色器将密度转化为纸墨颜色(08(https://johnowhitaker.github.io/inkwash/about#display))。流水线中没有任何地方存储“一种颜色”——颜色只存在于绘制屏幕的那一个着色器中。 你可以直接看到这些层。下面的演示绘制一条笔画并用水刷过;按钮切换你查看的字段。 fig 2 引擎的X光透视。*painting*是合成结果;*pigment*是原始墨水密度;*water*是湿润度场(注意它如何向笔画外扩散并缓慢蒸发);*flow*显示速度,色相代表方向。流动只存在于纸湿的地方。 两个分辨率很重要。速度存在于一个粗糙的~256单元网格上,因为流体运动本质上是平滑的,而且压力求解(最昂贵的部分)随单元数量缩放。颜料和湿润度则存在于最高2048——接近屏幕分辨率——因为那里是边缘、颗粒感和精细线条的所在。整个应用的花招在于,采样一个模糊、廉价的速度场来推动一个锐利、昂贵的墨水场。 ## 03 流动的水 流动基于Jos Stam的*Stable Fluids*(1999),这是GPU上几乎所有实时烟雾、墨水和火焰玩具背后的算法。它名称中的“稳定”来自一个想法:**不要推,要拉**。 一个朴素的模拟会将每个流体微团沿其速度向前移动——但一旦微团超出网格单元就会爆炸。Stam的*半拉格朗日平流*翻转了问题:每个网格单元询问:“如果这里的流体来自某处,那么它一个时间步之前在哪里?”它会沿速度反向追踪,在那个点采样旧场(双线性插值,在最近的四个单元之间),并采用该值。不可能有超出,因为每个单元最终得到的是已存在值的加权平均。大时间步长、低帧率都没关系——它不会爆炸。 fig 3 半拉格朗日平流。高亮单元沿局部速度(虚线)*向后*追踪,在四个最近单元之间采样场,并将该值带回。每个单元,每帧,都在一个片段着色器中完成。 在GLSL中,整个操作就两行: `` vec2 coord = vUv - uDt * texture(uVelocity, vUv).xy * uTexel; vec2 vel = texture(uVelocity, coord).xy * uDissipation; `` 仅靠平流得到的是糖浆,不是水。另外两个通道赋予其特性: **压力投影**使水不可压缩。平流之后,速度场中会有流动堆积(正散度)或撕裂(负散度)的区域。真实液体两者都不允许——推它就必须*绕开*。求解器计算散度,通过大约22次雅可比迭代松弛一个压力场,然后从速度中减去压力梯度。可见的效果是漩涡:推挤变成涡流和卷曲,而不是飞溅。 **涡量约束**对抗数值模糊。所有那些双线性采样就像一个低通滤波器——小漩涡几秒内就模糊不见了。因此求解器测量剩余的涡量,找到其脊,并施加一个微小的力将它们重新旋转起来。这是一个调节活力的旋钮:inkwash将其(连同推动强度和速度衰减速度)绑定到**flow**滑块上。 flow · 低 flow · 高 fig 4 相同的脚本化笔画——一个墨点,然后一圈水刷——在*flow*滑块的两端。低流动是潮湿、顺从的水洗;高流动具有动量、涡量和自己的主张。 ## 04 有主见的纸 单独一个流体求解器产生的是烟雾——一切都永远飘荡。让这感觉像*纸*的是,湿润度场在整个模拟中扮演着权限系统的角色。三个关卡,都读取同一张小纹理: **速度局限于湿纸。**平流之后,速度乘以`smoothstep(0.005, 0.2, wet)`——流动根本不能在干燥地面上存在。这就是为什么水洗会在自己的边界停下,而不是涂满整页。 **颜料流动性是争取来的,不是默认有的。**墨水通道计算`mob = smoothstep(0.02, 0.45, wet)`,并将其平流距离和渗色速率按此缩放。湿纸让墨水缓慢爬行;浸透的纸让它奔跑。干透的纸是博物馆——着色器原样返回旧值,该像素几乎不花成本。 **水本身移动迟缓。**湿润场仅以0.6倍流动速度进行平流,每帧向邻居模糊一点(毛细爬行——水坑边缘缓慢扩大),并且指数衰减。**dry**滑块设置该时间常数,大约从2秒到18秒。干燥是将流体模拟变成一幅画的关键:每次水洗都是一个正在关闭的窗口。 fig 5 一条钢笔线,然后用水刷过其左半部分。只有被弄湿的一半移动——并且只有直到纸干为止。滑动*dry*并让循环重放,感受工作窗口的变化。 inkwash中的干燥在另一点上也诚实:水离开时并不带走颜料。墨水无论正巧在它的水坑蒸发时处于何处,它就留在那里——绽放途中、条纹途中、漩涡途中。大多数看起来像“水彩”的纹理,只是流动场最后的言语,被冻结了。 ## 05 制作标记 你的手和那些字段之间有一个小小的笔画引擎,它用单一图元绘制一切:一个**高斯溅射点**——一个模糊的径向印章,`exp(-d2/r2)`,混合入一个字段。一条钢笔线就是一串墨水溅射点;一条笔刷线就是一串水溅射点加上指向运动方向的速度脉冲。印章的间距为半径的0.6倍,因此它们的重叠总和成一条平滑的带: fig 6 沿着笔画的高斯印章。每条曲线是一个溅射点;上面的线是它们的和,那条带是它的渲染效果。在应用的间距(0.6×r)下,接缝消失;将滑块拉开,看到笔画其实是由珠子组成的。 印章的混合方式与其形状同样重要。**墨水是加法式的**——密度相加,这(正如第08节将精确说明)正是真实釉彩加深的方式。**水使用MAX混合**:湿润度是饱和而非累积,因此在原地擦拭刷子只会让纸*湿*,而不是漫灌。一个混合方程标志,就是水彩和沼泽的区别。 那些输入给溅射点的手势数据也会被塑造: **压力和速度设定笔尖。**对于钢笔,半径和密度都随着触控笔压力增大而增大,随着速度加快而缩小——快速一划得到细而干的线条;缓慢用力拖动得到暗而粗的线条。在触控板上,Force Touch替代压力;用鼠标或手指时,引擎从速度模拟压力(缓慢≈慎重≈厚重),这在理论上错误,实践中却令人信服。 **光标是被追赶的,不是被顺从的。**笔刷位置指数级地松弛向指针(`k = 1 - exp(-14·dt)`)。那几毫秒的滞后是图形学中最廉价的线条质量技巧:抖动被吸收,角落变圆,线条带有真正笔刷的那种轻微跟随。 **静止是一个标记。**如果钢笔停留不动,墨水会持续以细流输入,斑点绽放——就像将真正的笔尖放在湿纸上一样。一只停留的刷子则会轻轻搅动其下的水。 fig 7 钢笔的词汇表:一条缓慢、压力渐变的笔画;一条快速轻的笔画;以及一个会积聚的停顿。自己试试——用触控笔你有真实压力,用其他设备则是速度模拟的压力。 ## 06 黑色的非黑 这是整个应用围绕构建的花招。将一滴水滴在一行廉价黑色墨水线上,观察边缘:黑色保持接近,但一个蓝紫色的幽灵在它前面走出来。黑色墨水是染料混合物,在湿纸上它们会进行色谱分离——每种染料以各自速度行进。 inkwash几乎未花代价就实现了这一点,因为第02节中的一个决定:颜料存储为**每通道光学密度**,而渗色步骤——每帧将墨水推向邻居平均值,其中湿——以**每通道不同的速率**运行: `` // 吸收红光的染料逃逸最快,吸收蓝光的染料拖在后面 uChroma = vec3(1.0 + 0.85*C, 1.0 + 0.15*C, max(0.25, 1.0 - 0.65*C)); vec4 bleedAmt = clamp(uBleed * (0.25 + 1.3*brush) * mob * vec4(uChroma, 1.05), 0., 0.92); vec4 mixed = mix(advected, neighborhood, bleedAmt); `` 把它读作化学:吸收红光(因此*看起来*呈青蓝色)的成分向外扩散最快;吸收蓝光的成分则留在线条中。几秒钟后,任何湿边缘都会自我分离成暗色核心和冷色光晕——没有光晕是被画出来的,它是*分离*出来的。**color**滑块是那段代码中的`C`:为0时各通道步调一致,墨水像灯黑一样;调高后,染料分道扬镳。 还有一个值得注意的术语:`brush`是以笔刷尖端为中心的高斯分布,因此渗色在刷毛正下方快约5倍。擦拭不仅弄湿墨水——它主动将其松动,这正是擦拭应做的事。 fig 8 按需的色谱分离:一个墨点,然后一圈湿刷子。*bleed*设置颜料在湿处扩散的速度;*color*设置各通道不同步的程度——这就是蓝色的来源。两者都为零时,是规矩的印度墨水;两者都高时,是你曾爱过的最便宜的钢笔水囊。 ## 07 按压固定 真实水彩能分层是因为干燥的颜料与纸张结合——你可以叠加上一层的洗刷而不复活它。一个单一的移动墨水场无法给你这个,所以inkwash保留两个颜料层:`ink`(移动)和`fixed`(沉降)。按下**fix**(或d)键会执行1.2秒的沉降过程:每帧一部分移动颜料转移到固定层,速度场被大力制动,湿润度瞬间干燥。画面在固定前后看起来一模一样——但它已改变状态,从液体变成层压物。 fig 9 分层。画一条对角线并水洗——它模糊了。*fix。*画另一条对角线并同样水洗——只有新线条移动;第一条现在成了纸的一部分,保持不动。fix按钮也作用于你自己的标记。 白色墨水是分层故事的另一半,它比看起来更狡黠。白色水粉存在于颜料纹理的Alpha通道,在屏幕上合成*覆盖*在深色墨水之上。但如果在黑色上画白色并固定它,然后在上面画深色——物理上你是在一个新鲜白色底子上作画,所以新线条必须读作深色。如果白色永远“作为一个上层”,它会漂白之后画的所有东西。 因此,烘烤白色是破坏性的,就像真实水粉的不透明性。固定时,白色覆盖*漂白其下的密度*——它隐藏的深色墨水在透射空间中被真正移除——然后白色本身溶解进纸张: `` // 白色水粉的覆盖度c擦除它掩盖的内容,然后变成纸 float c = (1.0 - exp(-2.2 * whiteAmount)) * uSettle; vec3 T = exp(-density); // 当前透射率 density = -log(clamp(T * (1.0 - c) + c, 1e-4, 1.0)); `` fig 10 水粉逻辑:固定一个暗色区域,画一个白色环并固定——漂白它所覆盖的——然后一条深色笔画穿过一切,即使在白色上也读作暗色。同一张纹理的三个状态。 ## 08 绘制纸张 到目前为止的一切都是密度领域的记账工作。显示着色器才是它变成一幅画的地方,其核心是一个物理定律。**比尔-朗伯定律**:光穿过颜料时,每通道指数衰减。 `` vec3 color = paper * exp(-density * uInkStrength); `` 这就是为什么颜料被存储为密度。重叠笔画*增加*密度,这*乘以*透射率——而通过略微着色的吸收光谱的指数行为,就像真正的颜料:第一层是明亮的灰色,第四层是深炭色但依然偏冷,永远不会截断成纯黑。天真的Alpha混合(所有绘图API的默认方式)则会收敛成泥浆: fig 11 相同的重叠笔画以两种方式合成。左侧,比尔-朗伯(乘以透射率——inkwash的做法):重叠加深并保持色彩。右侧,标准alpha叠加:重叠迅速趋向平坦的灰色天花板。用滑块添加笔画。 围绕这一个定律,显示通道叠加了使纸成为纸的东西——全部生成,没有从图像采样: **纤维和纹理。**两个倍频程相隔的值噪声(一个大尺度fbm,一个像素尺度的精细噪声)为空白纸张着色,使其不再是平坦的十六进制色码。 **颗粒化。**第三个噪声场调制墨水密度——但仅在颜料存在的地方。那是真实颜料留下的斑点在全部文本中添加了注释。

相似文章