一个从零写的 WebGL2 油画渲染引擎。颜料场、笔毛沉积、Kubelka–Munk 减色混色、Impasto 浮雕光照,全部为油画写实而专门构建,而不是把一个通用渲染器拿来当画板。
一次落笔会产生 上百个 细粒度的"子笔毛"沉积核。它们一起决定笔痕的厚度、覆盖、混色、流平与飞白。 打开页面时,引擎会用不同笔刷和浓稠度画一整套"红黄蓝交叉 + 调色刀刮除"的演示。
- 本项目是一个 vibecoding 产物:由 AI 模型 HY4 在 CodeBuddy 平台上,通过与人类的一段连续对话" vibe "出来的 —— 从空目录到可运行的引擎,全部代码由模型编写。
- 作者:HY4 模型 · 平台:CodeBuddy
- 项目采用 MIT License 开源,欢迎任意使用、修改与再分发。
npm install
npm run dev # http://localhost:7235
# 或
npm run build && npm run preview要求:浏览器必须支持 WebGL2 + EXT_color_buffer_float(Chrome / Edge / Firefox / Safari 近 4 年版本全部支持)。
打开后页面会自动跑一段演示:先薄铺底色,再用平头 / 榛形 / 圆头 / 扇形 / 调色刀画完整个画面。
src/
core/ 渲染核心 ───────────────────────────────────
EngineCore WebGL2 上下文、浮点扩展、统计
Program 着色器编译/链接/Uniform 缓存
RenderTarget 多附件浮点 FBO(MRT)
Quad 全屏三角形带(所有 pass 的载体)
RenderGraph 帧内 pass 调度 + 资源注册表
Types 公共类型与数值工具
field/ 颜料场 ───────────────────────────────────
PaintField ping-pong 双 FBO(ALBEDO + SURF)
FieldPasses 清空 / 拷贝 / 干燥 pass
brush/ 笔刷 ───────────────────────────────────
BrushTypes 5 种笔刷的笔刃几何与笔毛密度
BrushEngine 路径采样 + 多笔毛胶囊沉积核
pigment/ 颜料与混色 ───────────────────────────
palette 23 种基础油画颜料(钛白 / 镉黄 / 群青 …)
PigmentMixer KM 减色 + RYB + 着色力加权(JS 端)
shaders/ GLSL ───────────────────────────────────
common 哈希 / 噪声 / Kubelka–Munk / sRGB
field 拷贝 / 清空 / 干燥
deposit 笔毛沉积(混色 / 流动 / 飞白 / 刮除)
composite 浮雕法线 / 软自阴影 / AO / 布纹 / 油彩高光
render/ 上屏 ───────────────────────────────────
CompositePass 把颜料场解算为最终画面,导出 RGBA8 PNG
history/ 撤销 / 重做 ──────────────────────────
HistoryManager 命令分组 + GPU 检查点 + 快速重放
ai/ AI 桥接 ──────────────────────────────
AIBridge 暴露 window.Painter,参数容错
Composer 文字描述 → 笔触序列(本地确定性)
ui/ DOM 控件 ────────────────────────────
app/ 装配
demo/ 内置演示
两张 RGBA16F 浮点纹理,A/B 交替(ping-pong):
| 纹理 | 通道 | 含义 |
|---|---|---|
ALBEDO |
rgb |
颜料反射率(线性光) |
a |
覆盖度(0~1) | |
SURF |
r |
厚度 height(impasto) |
g |
湿度 wetness(决定能否湿画湿混合) | |
b |
粗糙度 roughness(湿=镜面,干=哑光) | |
a |
固化度 cure(干后变暗失光) |
每次落笔有上百个 stamp,每个 stamp 需要"读旧状态、写新状态"。若每 stamp 都 blit 全屏,1 笔就要数 GB 带宽。
不变式:第 k 个 pass 实际修改的矩形 R_k,只要画在 R_k ∪ R_{k-1} 上(区域外原样透传 src),就能保证交换后 dst 等于执行完 k 之后的完整场地状态。
推论:每个 stamp 只画自己那一小块,零拷贝、零 blit(详见 src/field/PaintField.ts 注释)。全屏 pass(干燥 / 清空 / 复制检查点)后 lastRect 置空,下一个局部 pass 不再并历史区域。
每道 stroke = 沿路径按弧长等距采样出 N 个 stamp。 每个 stamp = N 根"平行子笔毛"组成的胶囊并集:
- 平头 / 榛形 / 圆头 / 扇形:笔毛位置、长度、强度由 hash(
seed) 程序化生成 → 笔痕天然带分叉与拖痕 - 调色刀:单片刀刃(不做笔毛细分),支持 刮除 模式(负沉积,刀口堆出脊)
viscosity 高 → 边缘锐利、颜料立得住、不晕开
viscosity 低 → 边缘晕开、厚度自流平、与下层湿颜料拖拽互溶
pressure → 飞白程度(不够时部分笔毛"挂不上")
speed → 影响枯笔 / 厚度(快速拖动更稀薄)
thickness → 颜料厚度(dh ≈ thickness · bumpScale · 采样密度归一)
load → 笔上含色量(0~1):0 = 干净的笔,只把湿颜料揉合、不添色
drag → 拖拽力:笔把湿颜料推走的能力
uAmountScale = step / 笔刃长度 让"单位面积沉积量"与采样密度无关。
真实油画里,两种颜色相遇产生第三种颜色,不是预先在调色盘里算的, 而是笔毛把画布上已有的湿颜料推挤、拖拽、搅动,两种颜料颗粒在笔底下 被物理地揉在一起。
一次 stamp 同时发生三件事(按物理顺序):
- 涂抹 Smear —— 沿
-uMotion方向逆流采样 8 个上游点,按颜料体积加权混合 (K/S 空间)。这一步等价于"笔毛把沿途一整段湿颜料颗粒揉成一团"。 - 沉积 Deposit —— 笔上携带的颜料按体积
aDep = dh × uLoad落下。 - 混色 Mix —— 旧颜料(体积
aOld)与新颜料(体积aDep)在 K/S 空间 按体积加权平均。两种颜料相遇自动生成第三种颜色,而不是简单的"谁盖住谁"。
关键参数:
load = 0→ 一支干净的笔:只把已有的湿颜料揉合、不添新色。 这就是画家用来把两块颜色接柔的"扫笔"。drag↑ → 笔把湿颜料推走的能力越强,过渡区越宽、混色越充分。
调色台与画布使用同一套公式(mixVolume(colors, amounts)):
在 K/S 空间按份量做体积加权平均 → 与画布上"被笔揉出来"的结果完全一致。
JS: GLSL (画布上):
K/S = (1-R)²/(2R) pigmentKS(r) = (1-r)²/(2r) r ∈ [0.004, 0.996]
R = 1 + K/S - √(K/S² + 2K/S)
经验证:铺黄带 + 蓝带 → 干净的笔纵向 scrub → 交界处出现 G 最高的绿带
(K/S 公式:调色台 #2F3718;画布上实测 #3D433E,被底料和光照提亮后近似)。
干画湿:mix(old, new, c) → 普通覆盖。
湿画湿:(1-R)²/(2R) → 线性 → 1+ks - √(ks²+2ks) → R → 黄+蓝=绿而不是 RGB 平均得到的灰。
着色力加权:tint strength 让酞菁蓝一丁点就压住整盘。GLSL 与 JS 双实现,保证两端的色彩一致。
上屏 pass 全部从颜料场实时推导,没有预烘焙的法线贴图:
- 法线:从 HEIGHT 通道用 Sobel 求梯度,浮雕强度与画布分辨率无关(
relief × (1/fieldW) / 厚度→uv) - 自阴影:沿光方向在高度场里步进 20 段,厚颜料投出的软影(光照强度按距离衰减)
- AO:环形采样高度差,笔痕沟壑变暗
- 画布布纹:在薄颜料处透出经纬交替的平纹图案(程序化生成)
- 油彩高光:Blinn-Phong + Fresnel,湿度/固化度决定高光的锐利与强度
- 吸油变暗:cure 上升时颜料颜色略深、光泽降低(油画干透后的真实现象)
不做像素快照——把笔画命令存起来,撤销时用 GPU 端纹理(检查点)+ 命令回放恢复。检查点是浮点 FBO 拷贝,零 readPixels,不阻塞管线。 手绘时用"分组"把分段绘制打包成一个撤销步骤。
// 在控制台直接执行
Painter.help();
Painter.clear();
Painter.stroke({
from: [0.15, 0.3], to: [0.85, 0.6],
color: 'cadmiumRed', // 也支持 '#c8201a' / [200,32,26] / '群青'
brush: 'flat', // flat | round | filbert | fan | knife
width: 110, thickness: 0.92, viscosity: 0.85, pressure: 0.95
});
Painter.stroke({
from: [0.2, 0.7], to: [0.8, 0.3],
color: Painter.mix('cadmiumYellow', 'ultramarine', 0.5), // 真实减色混色
brush: 'filbert', width: 90
});
Painter.compose('波光粼粼的水面'); // 文字 → 自动构图
Painter.exportPNG({ scale: 1, download: true });参数容错:
- 坐标
[x,y]/{x,y},0~1 视为比例(原点左上),>1 视为画布像素 - 颜色 hex /
[r,g,b](0255 或 01) /'rgb(r,g,b)'/ 颜料 id(ultramarine)/ 中文名(群青) - 缺省参数取当前 UI 滑块值
Painter.compose(desc):本地确定性构图(不联网)。按关键词匹配题材
(波光粼粼 / 日落 / 海景 / 森林 / 花卉 / 夜色 / 抽象),每道 stroke 都是真实
"由薄到厚"的油画流程(铺底色 → 形体 → 干细节 → 调色刀厚涂)。
内置的《波光粼粼的水面》(ComposeWater.ts)是一幅单独精调的完整构图;
其余题材的 Composer.ts 规划器以构图参考为主 —— 其笔宽参数是"相对比例"语义,
实际落笔宽度由引擎换算。如果你接了 LLM,只要让它输出同样的 StrokeSpec[] 即可,无需改动引擎。
最核心的混色接口(用于在画布上自己揉色,不是调色盘):
// 铺两条湿带
Painter.stroke({from:[0.05,0.30],to:[0.95,0.30],color:'cadmiumYellow',brush:'flat',width:200,thickness:.6,viscosity:.35});
Painter.stroke({from:[0.05,0.55],to:[0.95,0.55],color:'ultramarine', brush:'flat',width:200,thickness:.6,viscosity:.35});
// 用干净的笔纵向来回扫 → 交界处被笔物理地揉出绿色
for (let y=0.32; y<0.55; y+=0.025)
Painter.stroke({from:[0.05,y],to:[0.95,y+0.01],color:'#fff',brush:'filbert',width:120,
thickness:.4, viscosity:.22, load:0.04, drag:2.0, wetness:1.0});
// 调色台用同一套公式算:Painter.mixVolume(['cadmiumYellow','ultramarine'],[1,1])tools/smoke.mjs 用本机 Chrome 无头模式验证:
- WebGL2 +
EXT_color_buffer_float可用 - 全部 GLSL 着色器能编译/链接
Painter.*接口(stroke / mix / undo / redo / exportPNG)工作
npm run dev # 先起 dev server
npm i -D puppeteer-core # 冒烟测试依赖(按需安装)
CHROME_PATH="C:\path\to\chrome.exe" node tools/smoke.mjs
# 截图写入 tools/shots/impasto.png 与 api.pngChrome 路径默认取 CHROME_PATH 环境变量(Windows 下缺省会指向系统安装位置)。
- 单画布尺寸:长边最大受
gl.MAX_TEXTURE_SIZE限制(典型 ≥ 4096) - 一次导出的最大缓冲受 VRAM 限制(2048×2048 RGBA8 = 8MB,问题不大)
- 干燥 / 撤销重放在非常长的笔画史(> 500 笔)下会重放一两秒
- WebGL2 不支持几何着色器 / 计算着色器,所以"fluid simulation / 真实溶剂扩散"等高级效果没有实现
Composer的非水景题材以构图参考为主,细节笔触宽度为相对比例语义
MIT © HY4 (AI model) · 生成于 CodeBuddy 平台
0 comments
log in to comment.