# kbrush v2 · 2026-09-29 night · the same brush, rebuilt after nerolette looked at v1 # # nerolette looked at v1 and said the dry-brush strokes still lacked the feeling of # fading out as they drag, that the wet and dirty-brush transitions needed more work, # and that a brush mark should never come out as a tidy rectangle. She also said: get # the basic tools right first; how to compose with layers comes after. # # What I learned fixing it, each time by finding the wrong step instead of blurring # over it (nerolette had already named that failure that morning: "it looks blurred out, like a censor mosaic"): # - The "pipe lines" in wet strokes were not missing blur. The shallow grooves between # bristles dried first and each one grew its own tide line. Letting the water surface # level itself (live water exchanged in small clumps) made them go away. # - The dry brush came out ten times darker than a flat wash, because all the scraped # paint landed on one grain peak. A peak can only hold about one wash-thickness of # paint; the rest goes back into the brush. So the dry brush can drag all the way from # broken marks to nothing. # - Pigment that follows the back-and-forth water gets stirred smooth, like a Gaussian # blur. It should follow only the net flow, plus a very small stir. # - The happiest moment: I gave the round brush one more thing, dip_tip() (touch the tip # into a darker colour), and wrote nothing else. Centre-tip strokes came out dark in # the middle and light at the edges, side-tip strokes dark on one side, and five # presses made a flower whose petals are dark at the root and pale at the tips. # - In the dirty-brush square I pressed three ochre strokes without planning them. They # grew two pointed ears, like a cat sitting among the blue strokes. I left it. # Layers are stacked with Kubelka-Munk (K/S values after Curtis et al. 1997). # Still not right: wet blooms are more fog than feather, no cauliflower backruns yet, # and one practice sheet takes about five minutes to compute. # Run kbrush-v2-sheet.py for the ten squares (needs numpy, scipy, Pillow). # # — Claude, for nerolette # """我们自己的笔(kbrush):一支有状态的笔 + 一张会湿会干的纸 + 一只会抖的手。 一笔 = 蘸多少 × 下面湿不湿 × 压多重 × 走多快 × 怎么落怎么收。这些不是手调的遮罩, 是笔、纸、手各自的状态,效果是它们碰出来的: - 手(gesture):路径会弯、会抖;起笔有露锋/藏锋/顿,收笔有出锋/回锋/顿收/提。 - 笔(Brush):平头或圆头。鬃在纸上踩出的是一个二维的脚印——平头笔一排排, 圆头笔压下去是个泪滴,越按越大。边上的鬃压得轻,所以边是毛的。 每根鬃自己带水和颜料、多少不一样;分笔尖和笔肚子两段,放出去的是笔尖的。 湿的时候颜料靠"流"下去(跟停留时间有关),干了只能靠"刮"(跟碰到多少纸纹有关), 所以干笔越拖越少、越拖越碎,一根一根断掉。 - 纸(Paper):有凹凸(纸纹)和纤维。干笔只碰得到纸纹的顶;水顺着纤维走,所以洇开是毛的不是糊的。 - 水(Canvas.flow):水从多处往少处流,颜料跟着水走、在水里扩散、慢慢沉下去; 边上干得快,水往边上补,把颜料带过去积成水线。颗粒颜料会往纸纹的凹处沉。 - 脏笔:笔尖和纸上没干的颜色互相交换(谁浓谁给),渴的笔还会把水和颜色吸回来。 - 层:dry() 定住一层,层与层用 Kubelka-Munk 叠,罩染和擦染的差别是算出来的。 参考:Curtis et al. 1997, Computer-Generated Watercolor(K/S 值、层叠、水线的思路)。 """ import numpy as np from scipy.ndimage import gaussian_filter, gaussian_filter1d # 颜料:K 吸收、S 散射(RGB),g 颗粒(往纸纹凹处沉),m 在水里跑多远, # st 染色性(一碰到纸就钻进纤维里的那一份:鬃走过的细纹就是它留下的,以后洗也洗不掉) PIGMENTS = { 'ultramarine': dict(K=(0.86, 0.86, 0.06), S=(0.005, 0.005, 0.09), g=1.0, m=1.0, st=0.04), 'indigo': dict(K=(1.25, 0.85, 0.45), S=(0.01, 0.01, 0.03), g=0.15, m=0.9, st=0.3), 'burnt_sienna':dict(K=(0.25, 0.90, 1.60), S=(0.08, 0.05, 0.02), g=0.5, m=0.7, st=0.15), 'burnt_umber': dict(K=(0.74, 1.54, 2.10), S=(0.09, 0.09, 0.004), g=0.4, m=0.5, st=0.12), 'ochre': dict(K=(0.10, 0.36, 1.60), S=(0.60, 0.45, 0.05), g=0.3, m=0.45, st=0.08), 'rose': dict(K=(0.22, 1.47, 0.57), S=(0.05, 0.003, 0.03), g=0.1, m=0.9, st=0.35), 'white': dict(K=(0.03, 0.03, 0.035), S=(2.6, 2.6, 2.5), g=0.0, m=0.3, st=0.0), } def km_layer(K, S): """一层颜料的反射 R、透射 T(Kubelka-Munk,厚度已并进浓度里)。""" S = np.maximum(S, 1e-4) a = 1 + K / S b = np.sqrt(np.maximum(a * a - 1, 1e-12)) bs = np.minimum(b * S, 30) sh, ch = np.sinh(bs), np.cosh(bs) c = a * sh + b * ch return sh / c, b / c def smooth_noise_1d(n, knots, rng): """长度 n 的平滑随机曲线(均值 0、幅度约 1),knots 个起伏。""" k = rng.standard_normal(max(knots, 2) + 3) x = np.linspace(0, len(k) - 1, n) y = np.interp(x, np.arange(len(k)), k) y = gaussian_filter(y, n / max(knots, 2) / 3) return y / (y.std() + 1e-9) class Paper: """纸:h 是纸纹凹凸(0 凹 … 1 凸),perm 是水在纸里好不好走(纤维方向走得快)。""" def __init__(self, H, W, seed=0, color=(0.96, 0.94, 0.89), fibers=1.0): self.H, self.W = H, W rng = np.random.default_rng(seed) n1 = gaussian_filter(rng.standard_normal((H, W)), 1.0) n2 = gaussian_filter(rng.standard_normal((H, W)), 3.5) # 纤维:一根根随机方向的细丝 F = np.zeros((H, W)) nf = int(H * W / 45 * fibers) L = rng.uniform(6, 30, nf) th = rng.uniform(0, np.pi, nf) cx = rng.uniform(0, W, nf); cy = rng.uniform(0, H, nf) for k in range(0, 31, 2): t = (k / 30 - 0.5) xs = np.clip((cx + np.cos(th) * L * t).astype(int), 0, W - 1) ys = np.clip((cy + np.sin(th) * L * t).astype(int), 0, H - 1) np.add.at(F, (ys, xs), 1.0) F = gaussian_filter(F, 0.7) F = (F - F.mean()) / (F.std() + 1e-9) h = n1 / n1.std() * 0.5 + n2 / n2.std() * 0.6 + F * 0.3 self.h = (h - h.min()) / (h.max() - h.min()) # 纸纹按高低排名(0..1 均匀):depth=0.3 就是碰到最高的三成纸面 r = np.empty(H * W); r[np.argsort(h, axis=None)] = np.linspace(0, 1, H * W) self.hu = r.reshape(H, W) perm = np.exp(0.55 * F + 0.25 * gaussian_filter(rng.standard_normal((H, W)), 6) * 4) self.perm = np.clip(perm / perm.mean(), 0.15, 2.0) # 颗粒颜料沉在哪:纸面上细小的坑(比纸纹的起伏细),所以沉淀是沙粒一样的,不是皱纹 g1 = gaussian_filter(rng.standard_normal((H, W)), 0.7) gr = g1 / g1.std() + 0.5 * n1 / n1.std() self.grain = (gr - gr.min()) / (gr.max() - gr.min()) self.color = np.array(color) class Canvas: def __init__(self, paper, pigments=PIGMENTS): self.p = paper H, W = paper.H, paper.W self.names = list(pigments) self.K = np.array([pigments[n]['K'] for n in self.names]) self.S = np.array([pigments[n]['S'] for n in self.names]) self.g = np.array([pigments[n]['g'] for n in self.names]) self.m = np.array([pigments[n]['m'] for n in self.names]) self.st = np.array([pigments[n].get('st', 0.1) for n in self.names]) nP = len(self.names) self.D = np.zeros((nP, H, W)) # 当前层:已经落在纸上的 self.Sus = np.zeros((nP, H, W)) # 当前层:还浮在水里的 self.w = np.zeros((H, W)) # 纸上的水 self.layers = [] # 干透定住的层 self.rng = np.random.default_rng(7) def idx(self, name): return self.names.index(name) # ---------- 水 ---------- def flow(self, steps=150, bbox=None, kw=0.1, kd=0.08, puddle=1.1, evap=0.003, edge_evap=0.006, sink=0.6, level=0.5, level_r=3.0, bound=0.06, stir=0.1, fib=0.8, fib_r=0.7): """纸上的水在走:水从多处往少处流,颜料跟着水走、在水里扩散、慢慢沉下去。""" if bbox is None: ys, xs = np.nonzero(self.w > 1e-3) if len(ys) == 0: return pad = 16 bbox = (max(ys.min() - pad, 0), min(ys.max() + pad, self.p.H), max(xs.min() - pad, 0), min(xs.max() + pad, self.p.W)) y0, y1, x0, x1 = bbox nP = len(self.names) act = np.nonzero(self.Sus[:, y0:y1, x0:x1].reshape(nP, -1).max(1) > 0)[0] w = self.w[y0:y1, x0:x1].copy() S = self.Sus[act, y0:y1, x0:x1] D = self.D[act, y0:y1, x0:x1] h = self.p.grain[y0:y1, x0:x1] pm = self.p.perm[y0:y1, x0:x1] kx = np.sqrt(pm[:, 1:] * pm[:, :-1]); ky = np.sqrt(pm[1:, :] * pm[:-1, :]) mob = self.m[act][:, None, None] gr = self.g[act][:, None, None] # 颗粒往凹处沉:一个指向纸纹低处的小速度 hx = (h[:, :-1] - h[:, 1:]); hy = (h[:-1, :] - h[1:, :]) pms = (gaussian_filter(pm, fib_r) if fib_r else pm) ** fib for s in range(steps): wet = w > 1e-3 # --- 水:从多处往少处流;只有积水(puddle)才会往干纸上漫 --- # 湿区里夹着的一两像素干缝,水会自己连上(外沿不会因此往外长) ins = gaussian_filter(wet.astype(float), 1.5) > 0.6 cxm = (wet[:, :-1] & wet[:, 1:]) | (np.maximum(w[:, :-1], w[:, 1:]) > puddle) | (ins[:, :-1] & ins[:, 1:]) cym = (wet[:-1, :] & wet[1:, :]) | (np.maximum(w[:-1, :], w[1:, :]) > puddle) | (ins[:-1, :] & ins[1:, :]) qx = kw * kx * (w[:, :-1] - w[:, 1:]) * cxm qy = kw * ky * (w[:-1, :] - w[1:, :]) * cym qx = np.clip(qx, -0.2 * w[:, 1:], 0.2 * w[:, :-1]) qy = np.clip(qy, -0.2 * w[1:, :], 0.2 * w[:-1, :]) if len(act): c = S / (w + 1e-4)[None] # 颜料跟着水走(迎风) fx = mob * qx * np.where(qx > 0, c[:, :, :-1], c[:, :, 1:]) fy = mob * qy * np.where(qy > 0, c[:, :-1, :], c[:, 1:, :]) # 在水里扩散(顺着纤维快) bx = (wet[:, :-1] & wet[:, 1:]) * np.minimum(w[:, :-1], w[:, 1:]) * kx by = (wet[:-1, :] & wet[1:, :]) * np.minimum(w[:-1, :], w[1:, :]) * ky fx += kd * mob * bx * (c[:, :, :-1] - c[:, :, 1:]) fy += kd * mob * by * (c[:, :-1, :] - c[:, 1:, :]) # 颗粒往凹处沉(从高处往低处漂) if sink: vx = sink * gr * hx[None] * (wet[:, :-1] & wet[:, 1:])[None] vy = sink * gr * hy[None] * (wet[:-1, :] & wet[1:, :])[None] fx += 0.3 * np.where(vx > 0, vx * S[:, :, :-1], vx * S[:, :, 1:]) fy += 0.3 * np.where(vy > 0, vy * S[:, :-1, :], vy * S[:, 1:, :]) fx = np.clip(fx, -0.22 * S[:, :, 1:], 0.22 * S[:, :, :-1]) fy = np.clip(fy, -0.22 * S[:, 1:, :], 0.22 * S[:, :-1, :]) S[:, :, :-1] -= fx; S[:, :, 1:] += fx S[:, :-1, :] -= fy; S[:, 1:, :] += fy w[:, :-1] -= qx; w[:, 1:] += qx w[:-1, :] -= qy; w[1:, :] += qy # --- 水面自己找平(湿中洇开也是它):纸吸饱之后多出来的"活水"一小团一小团往四周换, # 颜料坐在水团里一起走。水多的格子送得多、每个湿格子收得一样多,所以净流是从高往低: # 一笔里鬃和鬃之间的浅沟很快被填平,不会先干、不会各自积出一道水线; # 活水多(湿亮的纸)走得远,只是潮的纸走不远,干纸一点都不进。 # 收的时候偏爱纤维好走的格子——所以洇出去的边是毛的,不是一团糊 # 颜料主要跟着"净流"走:已经平了的一汪水不会自己把颜色搅匀(鬃留下的纹、脏笔带来的 # 一丝丝别的颜色都还在);stir 是活水里一点点的搅动,水越活越搅得开。 # 边上伸出去的小尖(周围大半是干的)被表面张力收回主体里,边就不会是一排锯齿 if level and s % 2 == 0: wm = wet | ins sm = gaussian_filter(wet.astype(float), 2.0) tip = wet & (sm < 0.4) & (sm > 0.2) mk = wm & ~tip f = np.maximum(w - np.where(tip, 0, bound), 0) * wm if f.max() > 0: mw = mk * pms den = gaussian_filter(mw, level_r) + 1e-6 out = level * f dw = mw * gaussian_filter(out / den, level_r) - out frac = (np.maximum(-dw, 0) + stir * out) / (w + 1e-6) for k in range(len(act)): oS = np.minimum(frac, 1) * self.m[act[k]] * S[k] S[k] += mw * gaussian_filter(oS / den, level_r) - oS w = w + dw # --- 蒸发:边上干得快 --- edge = 1 - gaussian_filter(wet.astype(float), 2.5) w = np.clip(w - evap - edge_evap * edge * wet, 0, None) # --- 沉淀:水越浅沉得越快,干了全落下 --- if len(act): rate = 0.003 + 0.06 * (1 - np.clip(w / 0.25, 0, 1)) dep = S * np.clip(rate, 0, 1)[None] dry = ~(w > 1e-3) dep[:, dry] = S[:, dry] S -= dep D += dep self.w[y0:y1, x0:x1] = w if len(act): self.Sus[act, y0:y1, x0:x1] = np.maximum(S, 0) self.D[act, y0:y1, x0:x1] = D def dry(self): """等纸干透:浮着的全沉下去,这一层定住,后面画的是新的一层。""" self.D += self.Sus self.Sus[:] = 0 self.w[:] = 0 if self.D.max() > 0: self.layers.append(self.D.copy()) self.D[:] = 0 # ---------- 看 ---------- def render(self): g = (0.93 + 0.07 * self.p.h)[..., None] R = np.broadcast_to(self.p.color * g, (self.p.H, self.p.W, 3)).copy() for C in self.layers + [self.D + self.Sus]: C = np.maximum(C, 0) if C.max() <= 0: continue Kx = np.einsum('phw,pc->hwc', C, self.K) Sx = np.einsum('phw,pc->hwc', C, self.S) r, t = km_layer(Kx, Sx) R = r + t * t * R / (1 - r * R) return np.clip(R, 0, 1) class Brush: """一支笔。kind='flat' 平头(排刷),'round' 圆头(毛笔)。每根鬃自己带水和颜料。""" def __init__(self, kind='flat', width=70, n=None, seed=1, missing=0.03, clumps=None): rng = np.random.default_rng(seed) self.kind, self.width = kind, width n = n or (int(width * 1.6) if kind == 'flat' else int(width * 2.4)) self.n = n # 横着在笔宽里的位置:大致均匀铺开(真笔有上千根毛,不会随机空出一条缝) self.u = np.clip(np.linspace(-1, 1, n) + rng.normal(0, 1.2 / n, n), -1, 1) rng.shuffle(self.u) self.d = rng.random(n) if kind == 'flat' else rng.random(n) ** 0.8 # 纵深:笔尖 0 … 笔肚子 1 self.len = rng.uniform(0.78, 1.0, n) # 鬃长短不齐 self.len[rng.random(n) < missing] = 0.7 # 几根短毛 self.lm = np.exp(rng.normal(0, 0.45, n)) # 每根吃进去的颜料多少不一样 self.xk = np.exp(rng.normal(0, 0.6, n)) # 每根和纸交换颜色的本事不一样 self.tipfrac = rng.uniform(0.07, 0.16, n) # 笔尖占多少 self.rr = rng.uniform(0.25, 1.0, n) # 笔肚子往笔尖补得快慢 clumps = clumps or max(5, width // 8) cut = np.sort(rng.uniform(-1, 1, clumps - 1)) cid = np.searchsorted(cut, self.u) centers = np.array([self.u[cid == k].mean() if np.any(cid == k) else 0 for k in range(clumps)]) self.clump_u = centers[cid] self.cid = cid # 属于哪一撮 self.cph = rng.uniform(0, 2 * np.pi, (2, clumps)) # 每一撮整体慢慢往旁边漂、时聚时散 self.cfq = rng.uniform(0.008, 0.03, (2, clumps)) self.hn = rng.normal(0, 1, n) # 同一撮里每根鬃高低也不一样(碰纸早晚) self.ph = rng.uniform(0, 2 * np.pi, (2, n)) self.fq = np.stack([rng.uniform(0.004, 0.012, n), rng.uniform(0.02, 0.05, n)]) self.jit = rng.normal(0, 1, n) # 笔尖参差(前后) self.k = 1.6 * width / n # 鬃多的笔每根少放一点 self.order = np.argsort(self.u) self.Wcap = self.len.copy() self.Wt = np.zeros(n); self.Wb = np.zeros(n) self.Pt = None; self.Pb = None self.paste = 0.0 def dip(self, canvas, mix, paint=1.0, water=1.0): """蘸。mix={'ultramarine': .7, ...};paint 是笔里颜料的浓度,water 是笔吃了几成饱(0..1)。""" nP = len(canvas.names) frac = np.zeros(nP) tot = sum(mix.values()) for k, v in mix.items(): frac[canvas.idx(k)] = v / tot # 蘸饱了每根差不多满;在碟边刮干了,剩多剩少就很不均匀 W = np.clip(water * self.Wcap * self.lm ** (0.25 + 0.9 * (1 - min(water, 1))), 0, self.Wcap) conc = paint * self.lm ** 0.4 P = (W * conc)[:, None] * frac[None] # 蘸饱的笔:颜料多半在笔肚子里,笔尖只是表面那一层; # 刮干了的笔(干笔法):肚子里挤空了,剩下的颜料都糊在鬃的表面上 tip = np.minimum(W, np.maximum(self.tipfrac * self.Wcap, (1 - min(water, 1)) * W)) r = tip / (W + 1e-12) self.Wt, self.Wb = tip, W - tip self.paste = float(np.clip((0.5 - water) / 0.4, 0, 1)) # 刮干的笔上是膏,碰到纸纹就被刮下去;饱的笔上是水 self.Pt, self.Pb = P * r[:, None], P * (1 - r)[:, None] def dip_tip(self, canvas, mix, paint=1.5, depth=0.35, part='tip'): """蘸完一种颜色以后,笔尖(或平头笔的一个角)再点一下另一种。 一笔里就有了深浅:圆头笔中锋画出来中间深两边浅,侧锋时深的那一边贴着笔尖那一侧; 越画笔肚子里的浅色越往外补,深色就一路淡下去。part='tip' 笔尖那一截,'corner' 平头笔 u>0 那个角。""" frac = np.zeros(len(canvas.names)) tot = sum(mix.values()) for k, v in mix.items(): frac[canvas.idx(k)] = v / tot if part == 'tip': k = np.clip((depth - self.d) / 0.12 + 0.5, 0, 1) else: k = np.clip((self.u - (1 - 2 * depth)) / 0.25 + 0.5, 0, 1) conc = paint * self.lm ** 0.4 self.Pt = self.Pt * (1 - 0.8 * k)[:, None] + (self.Wt * conc * 0.8 * k)[:, None] * frac[None] self.Pb = self.Pb * (1 - 0.25 * k)[:, None] + (self.Wb * conc * 0.25 * k)[:, None] * frac[None] def sat(self): return np.clip((self.Wt + self.Wb) / self.Wcap, 0, 1.2) def footprint(self, p, u, roll=0.0, side=0.0): """这一刻每根鬃踩在哪(横 across、纵 along,像素)以及够不够得着纸(reach 0..1)。 roll:手腕往一边侧(平头笔一个角先着纸、一个角后离纸),正数是 u>0 那边压得重。 side:圆头笔的侧锋(0 中锋 … ±1 笔尖完全躺到一边),泪滴转过去,笔尖那侧光、笔肚那侧毛。""" W = self.width if self.kind == 'flat': pe = p * (1 - 0.35 * self.u ** 2) * np.clip(1 + roll * self.u, 0, 2) # 边上的鬃压得轻;侧着的那边更轻 hw = W / 2 * (0.72 + 0.28 * p) across = u * hw Lc = W * 0.1 * (0.25 + p) along = (self.d - 0.5) * Lc + self.jit * (1 - self.len) * W * 0.12 reach = pe * 1.35 * self.len - 0.12 else: pe = np.clip(p, 0, 1) ** 0.85 # 按到多深 inside = self.d < pe + 0.02 rel = np.clip(self.d / (pe + 1e-6), 0, 1) hw = W / 2 * (0.06 + 0.94 * pe) across = u * hw * rel ** 0.55 # 泪滴:笔尖窄、笔肚子宽 Lc = W * 0.85 * pe + 2 along = (rel - 0.35) * Lc + self.jit * 0.6 reach = np.where(inside, (0.35 + 0.8 * self.len) * (1.05 - 0.4 * rel * (1 - pe)), 0) - 0.05 if side: th = side * np.pi / 2 c, s_ = np.cos(th), np.sin(th) across, along = across * c + along * s_, along * c - across * s_ return across, along, np.clip(reach, 0, 1) def _splat(A, ys, xs, vals): """把 vals(n,)或(k,n)双线性地撒到 A 上。""" H, W = A.shape[-2:] x0 = np.floor(xs).astype(int); y0 = np.floor(ys).astype(int) fx = xs - x0; fy = ys - y0 for dy, dx, wt in ((0, 0, (1 - fx) * (1 - fy)), (0, 1, fx * (1 - fy)), (1, 0, (1 - fx) * fy), (1, 1, fx * fy)): yy = np.clip(y0 + dy, 0, H - 1); xx = np.clip(x0 + dx, 0, W - 1) if A.ndim == 2: np.add.at(A, (yy, xx), vals * wt) else: for k in range(A.shape[0]): if np.any(vals[k]): np.add.at(A[k], (yy, xx), vals[k] * wt) def _catmull(ctrl, n_per=40): """过这些点的一条顺滑曲线(向心 Catmull-Rom:不打结、不甩出去)。""" P = np.vstack([2 * ctrl[0] - ctrl[1], ctrl, 2 * ctrl[-1] - ctrl[-2]]) out = [] for i in range(1, len(P) - 2): p0, p1, p2, p3 = P[i - 1], P[i], P[i + 1], P[i + 2] t0 = 0.0 t1 = t0 + max(np.hypot(*(p1 - p0)), 1e-3) ** 0.5 t2 = t1 + max(np.hypot(*(p2 - p1)), 1e-3) ** 0.5 t3 = t2 + max(np.hypot(*(p3 - p2)), 1e-3) ** 0.5 t = np.linspace(t1, t2, n_per, endpoint=False)[:, None] A1 = (t1 - t) / (t1 - t0) * p0 + (t - t0) / (t1 - t0) * p1 A2 = (t2 - t) / (t2 - t1) * p1 + (t - t1) / (t2 - t1) * p2 A3 = (t3 - t) / (t3 - t2) * p2 + (t - t2) / (t3 - t2) * p3 B1 = (t2 - t) / (t2 - t0) * A1 + (t - t0) / (t2 - t0) * A2 B2 = (t3 - t) / (t3 - t1) * A2 + (t - t1) / (t3 - t1) * A3 out.append((t2 - t) / (t2 - t1) * B1 + (t - t1) / (t2 - t1) * B2) out.append(ctrl[-1][None]) return np.vstack(out) def gesture(p0, p1, width=70, bend=0.0, start='lu', end='ti', pressure=0.8, speed=1.0, wobble=1.0, seed=0, via=None): """一只手画的一笔:弯、抖、起笔、收笔。返回 stroke() 要的 pts / pressure / speed。 start: 'lu' 露锋(笔尖轻轻落下再铺开)/ 'cang' 藏锋(先逆着进去再回头)/ 'dun' 顿(按下停一下) end: 'chu' 出锋(边走边提,尾巴尖、会飞白)/ 'hui' 回锋(往回收)/ 'dun' 顿收(按住停下)/ 'ti' 提(干脆提起) via: 中间点列表(折线/曲线路径);bend: 整体弯多少(占长度的比例) """ rng = np.random.default_rng(seed) p0 = np.asarray(p0, float); p1 = np.asarray(p1, float) ctrl = [p0] + [np.asarray(v, float) for v in (via or [])] + [p1] ctrl = np.array(ctrl) if via: ctrl = _catmull(ctrl) # 过中间点的顺滑曲线,不是折线 seg = np.hypot(*np.diff(ctrl, axis=0).T) cum = np.concatenate([[0], np.cumsum(seg)]) Lt = cum[-1] N = max(int(Lt / 3), 12) s = np.linspace(0, Lt, N) base = np.stack([np.interp(s, cum, ctrl[:, 0]), np.interp(s, cum, ctrl[:, 1])], 1) d = p1 - p0 tdir = d / (np.hypot(*d) + 1e-9) ndir = np.array([-tdir[1], tdir[0]]) u = s / Lt base += np.outer(4 * u * (1 - u) * bend * Lt, ndir) # 手抖:两种频率的慢漂 base += np.outer(smooth_noise_1d(N, max(2, int(Lt / 160)), rng) * 1.6 * wobble + smooth_noise_1d(N, max(3, int(Lt / 40)), rng) * 0.45 * wobble, ndir) pts = base r = width * 0.3 if start == 'cang': # 藏锋:先往反方向探一下再折回来 pre = np.array([p0 + tdir * r * 0.9 + ndir * r * 0.25, p0 + tdir * r * 0.3 + ndir * r * 0.35, p0 - tdir * r * 0.15 + ndir * r * 0.1]) pts = np.vstack([pre, pts]) if end == 'hui': # 回锋:到头往回兜一下 e = pts[-1] post = np.array([e + tdir * r * 0.25 - ndir * r * 0.2, e - tdir * r * 0.1 - ndir * r * 0.4, e - tdir * r * 0.55 - ndir * r * 0.3]) pts = np.vstack([pts, post]) seg = np.hypot(*np.diff(pts, axis=0).T) L = seg.sum() tk = np.linspace(0, 1, 200) P = pressure * (1 + 0.07 * smooth_noise_1d(200, 4, rng)) V = speed * (1 + 0.1 * smooth_noise_1d(200, 3, rng)) a = min(0.18, 55 / L) if start == 'lu': P *= np.clip(0.06 + 0.94 * (tk / a) ** 0.7, 0, 1) elif start == 'dun': a0 = min(0.08, 22 / L) P = np.where(tk < a0, np.minimum(1.0, pressure * 1.25), P) V = np.where(tk < a0, speed * 0.3, V) elif start == 'cang': a0 = min(0.12, (r * 1.6) / L) P = np.where(tk < a0, pressure * 0.75, P) V = np.where(tk < a0, speed * 0.5, V) if end == 'chu': b = min(0.35, 220 / L) k = np.clip((1 - tk) / b, 0, 1) P *= k ** 1.2 V *= 1 + 0.9 * (1 - k) elif end == 'ti': b = min(0.05, 16 / L) P *= np.clip((1 - tk) / b, 0, 1) ** 0.6 elif end == 'dun': b = min(0.07, 20 / L) P = np.where(tk > 1 - b, np.minimum(1.0, pressure * 1.2), P) V = np.where(tk > 1 - b, speed * 0.25, V) P *= np.clip((1 - tk) / 0.01, 0, 1) elif end == 'hui': b = min(0.12, (r * 1.2) / L) k = np.clip((1 - tk) / b, 0, 1) P = np.where(tk > 1 - b, P * (0.4 + 0.6 * k), P) V = np.where(tk > 1 - b, V * 0.6, V) P = np.clip(P, 0, 1) # 手腕侧倾:平时有一点自己的习惯、慢慢晃;落笔时一个角先着纸,出锋时一个角最后离开 side = rng.choice([-1, 1]) Rl = rng.normal(0, 0.1) + 0.1 * smooth_noise_1d(200, 3, rng) if start in ('lu', 'cang'): a1 = min(0.25, 80 / L) Rl = Rl + side * 0.7 * np.clip(1 - tk / a1, 0, 1) ** 1.5 if end == 'chu': b1 = min(0.35, 220 / L) Rl = Rl - side * 0.6 * np.clip(1 - (1 - tk) / b1, 0, 1) ** 1.5 return dict(pts=pts, pressure=lambda t: np.interp(t, tk, P), speed=lambda t: np.interp(t, tk, V), roll=lambda t: np.interp(t, tk, Rl)) def stroke(canvas, brush, pts, pressure=1.0, speed=1.0, roll=0.0, side=0.0, hold=None, step=0.6, gain=560.0, wgain=1.6, a_flow=2.2e-4, a_abr=1.2e-2, xchg=0.25, thirst=0.08, bridge=0.15, hcap=0.45, seed=None): """沿 pts 拖一笔。pressure/speed 可以是常数或 t(0..1) 的函数。 hold:平头笔握的角度(度)。None = 笔宽永远横在路径上;给定角度 = 手腕不转,转弯时自己变粗细。 """ rng = np.random.default_rng(seed) pts = np.asarray(pts, float) seg = np.diff(pts, axis=0) Ls = np.hypot(seg[:, 0], seg[:, 1]) cum = np.concatenate([[0], np.cumsum(Ls)]) total = cum[-1] nS = max(int(total / step), 2) s = np.linspace(0, total, nS) px = np.interp(s, cum, pts[:, 0]); py = np.interp(s, cum, pts[:, 1]) tx = gaussian_filter(np.gradient(px), 2, mode='nearest'); ty = gaussian_filter(np.gradient(py), 2, mode='nearest') tn = np.hypot(tx, ty) + 1e-9 tx /= tn; ty /= tn if hold is None: nx, ny = -ty, tx else: a = np.deg2rad(hold) nx, ny = np.full(nS, np.cos(a)), np.full(nS, np.sin(a)) t = s / total pr = pressure(t) if callable(pressure) else np.full(nS, float(pressure)) sp = speed(t) if callable(speed) else np.full(nS, float(speed)) ro = roll(t) if callable(roll) else np.full(nS, float(roll)) sd = side(t) if callable(side) else np.full(nS, float(side)) hu = canvas.p.hu b = brush H, W = hu.shape nPall = len(canvas.names) act = np.nonzero(((b.Pt + b.Pb).sum(0) > 0) | (canvas.Sus.reshape(nPall, -1).max(1) > 0))[0] Sus_a = canvas.Sus[act]; D_a = canvas.D[act] Pt = b.Pt[:, act].copy(); Pb = b.Pb[:, act].copy() st = canvas.st[act] w_before = canvas.w.copy() # 只跟这一笔之前就在纸上的湿颜色交换,不跟自己刚放下的 Dst = np.zeros((H, W)) # 这一笔直接落在纸上的颜料(干刷挂满了就挂不上了) prev = np.zeros(b.n) for i in range(nS): p = pr[i]; v = max(sp[i], 0.15) sat = np.clip((b.Wt + b.Wb) / b.Wcap, 0, 1.2) # 鬃在走:慢慢晃、干了往各自那撮挤、越干越散 dry = np.clip(1 - sat, 0, 1) clump = 0.45 * dry ** 1.5 * (0.65 + 0.35 * np.sin(b.cph[1, b.cid] + b.cfq[1, b.cid] * s[i])) cu = (1 - clump) * b.u + clump * b.clump_u across, along, reach = b.footprint(p, cu, ro[i], sd[i]) across = across + np.sin(b.cph[0, b.cid] + b.cfq[0, b.cid] * s[i]) * (0.4 + 2.2 * dry) wan = (np.sin(b.ph[0] + b.fq[0] * s[i]) + 0.5 * np.sin(b.ph[1] + b.fq[1] * s[i])) across = across + wan * (0.5 + 1.8 * dry) * (0.4 + 0.6 * np.abs(b.u)) + rng.normal(0, 0.3, b.n) along = along + rng.normal(0, 0.3, b.n) bx = px[i] + nx[i] * across + tx[i] * along by = py[i] + ny[i] * across + ty[i] * along ix = np.clip(np.round(bx).astype(int), 0, W - 1) iy = np.clip(np.round(by).astype(int), 0, H - 1) touching = reach > 0 # 碰到多深:饱的笔连纸纹凹处都灌满;干笔、快笔只碰得到凸起的顶 # 鬃越干,只有越高的纸纹才刮得下颜料(load→0 时一点都刮不下) sat1 = np.minimum(sat, 1) # 刮干的笔:鬃表面糊着膏,碰到哪儿都刮得下,直到表面刮光 avail = np.maximum(np.minimum(1, sat1 / 0.6) ** 0.7, b.paste * np.minimum(1, b.Wt / (0.5 * b.tipfrac * b.Wcap)) ** 0.6) depth = reach * 1.1 * avail / (1 + 0.4 * max(v - 1, 0)) cw = canvas.w[iy, ix] # 鬃压弯了是贴着纸滑的,小凹坑会跨过去:已经贴上的鬃要掉进更深的坑才离开纸 #(进门的门槛高、出门的门槛低),所以干笔是一条条的,不是一粒粒的 thr = (1 - depth) - np.where(prev > 0.5, bridge * reach, 0) + 0.03 * b.hn contact = np.clip((hu[iy, ix] - thr) / 0.16 + 0.35, 0, 1) ** 1.5 # 越高的纸纹刮下的越多 prev = contact.copy() # 够湿的鬃是一滴液体压在纸上,凹处也灌满;纸本来湿也一样 film = np.clip((sat1 - 0.12) / 0.25, 0, 1) * (1 - b.paste) liquid = np.maximum(film, (cw > 0.06).astype(float)) contact = np.maximum(contact, liquid * np.minimum(1, reach * 3)) * touching # 放出去多少:鬃里有"流得动"的水时靠流(跟停留时间有关,快笔放得少), # 流不动了只能靠纸纹刮(跟碰到多少、鬃里还剩多少有关:越干攥得越紧) Wall = b.Wt + b.Wb wetf = np.clip((sat1 - 0.1) / 0.6, 0, 1); wetf = wetf * wetf * (3 - 2 * wetf) dW = contact * b.k * (a_flow * b.Wcap * wetf * (0.3 + p) / v + a_abr * b.paste * b.Wt * p * (1 - wetf) ** 2) # 干着刮的时候,一颗纸纹顶上挂得住的颜料有限(约等于同一笔颜料平涂一层那么厚), # 挂满了,后面的鬃再蹭过去也刮不下更多——所以飞白不会比同一管颜料的平涂更黑,只是更碎 dW = np.minimum(dW, 0.6 * b.Wt) dryb = film <= 0.5 if np.any(dryb): # 同一步里挤在一颗纸纹上的几根鬃一起算:想刮下的加起来,超出还挂得住的,按比例少刮 q = np.where(dryb, dW * Pt.sum(1) / (b.Wt + 1e-12) * gain, 0) _, inv = np.unique(iy * W + ix, return_inverse=True) Q = np.bincount(inv, weights=q)[inv] * 0.5 # 落点那一格大约吃到一半(其余匀到旁边) cap = Pt.sum(1) / (b.Wt * wgain + 1e-9) * hcap room = np.clip(cap - Dst[iy, ix], 0, None) dW = np.where(dryb, dW * np.minimum(1, room / (Q + 1e-12)), dW) phi = dW / (b.Wt + 1e-12) dP = Pt * phi[:, None] Pt -= dP; b.Wt = b.Wt - dW # 笔肚子往笔尖补(每根快慢不同) need = np.clip(b.tipfrac * b.Wcap - b.Wt, 0, None) mv = np.minimum(need * b.rr * (0.1 + 0.9 * wetf), b.Wb) # 肚子里的水流得动才补得上 fr = mv / (b.Wb + 1e-12) Pt += Pb * fr[:, None]; Pb -= Pb * fr[:, None] b.Wt = b.Wt + mv; b.Wb = b.Wb - mv # 湿笔的水是连通的:相邻的鬃之间水会匀开(颜料不跟着匀,所以还留着一丝丝的纹) if i % 4 == 0: o = b.order share = 0.5 * wetf[o] for arr in (b.Wt, b.Wb): a = arr[o] arr[o] = a + share * (gaussian_filter1d(a, 4, mode='nearest') - a) # 落到纸上:笔里的一点点 → 纸上一片(gain 换算) dWg = dW * gain * wgain; dPg = dP * gain # 够湿的鬃留下的是一层水(颜料浮在里面会走);半干的鬃留下的是膏,纸一吸就定住 # 水是一整片压在纸上的,不只在鬃尖上:往两边各匀一点,鬃与鬃之间的缝就被水连上了 wv = dWg * 0.9 * film _splat(canvas.w, by, bx, wv * 0.5) _splat(canvas.w, by + ny[i], bx + nx[i], wv * 0.25) _splat(canvas.w, by - ny[i], bx - nx[i], wv * 0.25) into_water = ((cw + dWg * film) > 0.08) & (film > 0.5) # 就算是湿笔,鬃压过的地方也有一点颜料当场钻进纤维(染色性越强越多)——鬃的细纹就是这么留下的 stain = np.where(into_water[:, None], 0.6 * st[None, :] * contact[:, None], 1.0) _splat(Sus_a, by, bx, (dPg * (1 - stain)).T) # 直接落下的颜料也不是一根头发丝那么细:一颗纸纹顶有好几根鬃宽,往两边各匀一点 dd = (dPg * stain).T for off, wt in ((0, 0.5), (1, 0.25), (-1, 0.25)): _splat(D_a, by + off * ny[i], bx + off * nx[i], dd * wt) _splat(Dst, by + off * ny[i], bx + off * nx[i], dd.sum(0) * wt) # 挂满了就挂不上:这一步落下去超出上限的那点,退回给刚才放它的干鬃(笔里还留着) if np.any(dryb): qd = dd[:, dryb] # (k, 干鬃) qt = qd.sum() if qt > 0: capv = np.average(Pt[dryb].sum(1) / (b.Wt[dryb] * wgain + 1e-9), weights=qd.sum(0) + 1e-12) * hcap y0 = max(iy.min() - 3, 0); y1 = min(iy.max() + 4, H) x0 = max(ix.min() - 3, 0); x1 = min(ix.max() + 4, W) loc = Dst[y0:y1, x0:x1] ex = np.maximum(loc - capv, 0) E = ex.sum() if E > 0: comp = qd.sum(1) / qt # 这一步干刮下来的颜料配比 D_a[:, y0:y1, x0:x1] -= comp[:, None, None] * ex[None] loc -= ex back = min(E / qt, 1.0) # 退回的比例 Pt[dryb] += (qd * back).T / gain b.Wt[dryb] += dW[dryb] * back # 和纸上没干的颜色互相交换:谁浓谁给(脏笔、湿中混色);渴的笔还会把水吸回来 wetpix = (w_before[iy, ix] > 0.03) & touching if np.any(wetpix) and len(act): m = np.nonzero(wetpix)[0] wp = canvas.w[iy[m], ix[m]] cp = Sus_a[:, iy[m], ix[m]] / (wp + 1e-4) # 纸上的浓度 (k, m) ct = (Pt[m] / (b.Wt[m] * wgain + 1e-9)[:, None]).T # 笔尖的浓度(换成纸上的单位) vol = np.minimum(wp, b.Wt[m] * gain * wgain) * 0.5 F = xchg * b.xk[m] * contact[m] * (cp - ct) * vol # >0:纸给笔 F = np.minimum(F, 0.3 * Sus_a[:, iy[m], ix[m]]) F = np.maximum(F, -0.3 * Pt[m].T * gain) # 渴:笔比纸干就吸水,连水里的颜色一起 dWa = thirst * contact[m] * np.clip(wp - sat1[m], 0, None) * wp dWa = np.minimum(dWa, np.clip(b.Wcap[m] - b.Wt[m] - b.Wb[m], 0, None) * gain * wgain) Fa = cp * dWa[None] Fa = np.minimum(Fa, 0.3 * Sus_a[:, iy[m], ix[m]]) tot = F + Fa for k in range(len(act)): np.add.at(Sus_a[k], (iy[m], ix[m]), -tot[k]) np.add.at(canvas.w, (iy[m], ix[m]), -dWa) Pt[m] += tot.T / gain b.Wt[m] += dWa / (gain * wgain) np.maximum(Sus_a, 0, out=Sus_a) np.clip(canvas.w, 0, 2.0, out=canvas.w) canvas.Sus[act] = Sus_a; canvas.D[act] = D_a b.Pt[:, act] = Pt; b.Pb[:, act] = Pb