open-watercolor-sim 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- open_watercolor_sim/__init__.py +14 -0
- open_watercolor_sim/brush/__init__.py +4 -0
- open_watercolor_sim/brush/configs.py +57 -0
- open_watercolor_sim/brush/watercolor_engine.py +954 -0
- open_watercolor_sim/viewer.py +305 -0
- open_watercolor_sim-0.1.1.dist-info/METADATA +86 -0
- open_watercolor_sim-0.1.1.dist-info/RECORD +11 -0
- open_watercolor_sim-0.1.1.dist-info/WHEEL +5 -0
- open_watercolor_sim-0.1.1.dist-info/entry_points.txt +2 -0
- open_watercolor_sim-0.1.1.dist-info/licenses/LICENSE +171 -0
- open_watercolor_sim-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Open Watercolor Sim.
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Shuoqi Chen
|
|
5
|
+
SPDX-License-Identifier: Apache-2.0
|
|
6
|
+
|
|
7
|
+
This engine is a stylized, realtime watercolor-like simulator.
|
|
8
|
+
|
|
9
|
+
High-level approach:
|
|
10
|
+
- Eulerian grid fields for wetness, pigment, and velocity
|
|
11
|
+
- Semi-Lagrangian advection and simple diffusion for transport
|
|
12
|
+
- Separate wet pigment vs stained pigment layers with drying/absorption
|
|
13
|
+
- Edge-biased pigment deposition to create rim darkening
|
|
14
|
+
- Procedural noise fields for turbulence and paper texture
|
|
15
|
+
- Exponential attenuation style rendering to map pigment mass to color
|
|
16
|
+
|
|
17
|
+
Inspired by classic building blocks from:
|
|
18
|
+
- Stam 1999 (stable fluids, semi-Lagrangian advection)
|
|
19
|
+
- Curtis et al. 1997 (computer-generated watercolor)
|
|
20
|
+
- Deegan et al. 1997 (coffee-ring motivation for edge deposition)
|
|
21
|
+
- Bridson et al. 2007 (noise-driven flow stylization)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
import random
|
|
27
|
+
from typing import Optional, Tuple, List
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
import taichi as ti
|
|
31
|
+
|
|
32
|
+
from .configs import SimParams
|
|
33
|
+
|
|
34
|
+
# =============================================================================
|
|
35
|
+
# GLOBAL CONSTANTS & SIMULATION LIMITS
|
|
36
|
+
# =============================================================================
|
|
37
|
+
REFERENCE_RESOLUTION = 512.0 # Baseline resolution for parameter scaling
|
|
38
|
+
NOISE_TEXTURE_RES = 512 # Resolution of the pre-computed noise buffer
|
|
39
|
+
|
|
40
|
+
_GLOBAL_TAICHI_INITIALIZED = False
|
|
41
|
+
|
|
42
|
+
def _initialize_taichi_backend(arch: str, use_profiler: bool = False):
|
|
43
|
+
"""Initializes the Taichi runtime with the best available backend."""
|
|
44
|
+
global _GLOBAL_TAICHI_INITIALIZED
|
|
45
|
+
if _GLOBAL_TAICHI_INITIALIZED:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
# Initialization settings
|
|
49
|
+
init_kwargs = {
|
|
50
|
+
"offline_cache": True,
|
|
51
|
+
"kernel_profiler": use_profiler,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Strategy for selecting backend
|
|
55
|
+
ti_arch = ti.cpu
|
|
56
|
+
if arch == "gpu":
|
|
57
|
+
if ti.core.with_cuda():
|
|
58
|
+
ti_arch = ti.cuda
|
|
59
|
+
elif ti.core.with_metal():
|
|
60
|
+
ti_arch = ti.metal
|
|
61
|
+
elif ti.core.with_vulkan():
|
|
62
|
+
ti_arch = ti.vulkan
|
|
63
|
+
else:
|
|
64
|
+
ti_arch = ti.cpu
|
|
65
|
+
elif arch == "vulkan":
|
|
66
|
+
ti_arch = ti.vulkan
|
|
67
|
+
elif arch == "metal":
|
|
68
|
+
ti_arch = ti.metal
|
|
69
|
+
elif arch == "cuda":
|
|
70
|
+
ti_arch = ti.cuda
|
|
71
|
+
|
|
72
|
+
print(f"[WatercolorEngine] Initializing Taichi with backend: {ti_arch}")
|
|
73
|
+
ti.init(arch=ti_arch, **init_kwargs)
|
|
74
|
+
|
|
75
|
+
print(f"[WatercolorEngine] Taichi initialized. Backend: {ti.cfg.arch} | Profiler: {use_profiler}")
|
|
76
|
+
_GLOBAL_TAICHI_INITIALIZED = True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@ti.func
|
|
80
|
+
def _clamp_01(x: ti.f32) -> ti.f32:
|
|
81
|
+
"""Clamps a scalar value to the range [0.0, 1.0]."""
|
|
82
|
+
return ti.min(1.0, ti.max(0.0, x))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@ti.func
|
|
86
|
+
def _clamp_vec3_01(v: ti.template()) -> ti.template():
|
|
87
|
+
"""Clamps a 3-component vector to the range [0.0, 1.0]."""
|
|
88
|
+
return ti.Vector([_clamp_01(v.x), _clamp_01(v.y), _clamp_01(v.z)])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@ti.func
|
|
92
|
+
def _hash21(p: ti.math.vec2) -> ti.f32:
|
|
93
|
+
q = ti.math.fract(p * 0.1031)
|
|
94
|
+
q += ti.math.dot(q, q.yx + 33.33)
|
|
95
|
+
return ti.math.fract((q.x + q.y) * q.x)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@ti.data_oriented
|
|
99
|
+
class WatercolorEngine:
|
|
100
|
+
"""Taichi watercolor-ish wet media simulation.
|
|
101
|
+
|
|
102
|
+
Fields on grid:
|
|
103
|
+
- W: wetness [0,1]
|
|
104
|
+
- P: wet pigment RGB [0,1]
|
|
105
|
+
- A: stained pigment (paper-bound) RGB [0,1]
|
|
106
|
+
- V: velocity field (grid-aligned)
|
|
107
|
+
- T: paper texture noise [0,1]
|
|
108
|
+
|
|
109
|
+
Uses ping-pong buffers for W/P/V.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, res: int = 384, dt: float = 1.0 / 60.0, seed: int = 0, arch: str = "cpu", preview_res: int = 256, use_profiler: bool = False):
|
|
113
|
+
try:
|
|
114
|
+
_initialize_taichi_backend(arch, use_profiler=use_profiler)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
print(f"[WatercolorEngine] GPU Init failed: {e}. Falling back to CPU.")
|
|
117
|
+
_initialize_taichi_backend("cpu", use_profiler=use_profiler)
|
|
118
|
+
|
|
119
|
+
self.res = int(res)
|
|
120
|
+
self.dt = float(dt)
|
|
121
|
+
self.seed = int(seed)
|
|
122
|
+
self.preview_res = int(preview_res)
|
|
123
|
+
|
|
124
|
+
self.render_every = 2
|
|
125
|
+
self.timing_mode = False
|
|
126
|
+
self._frame_count = 0
|
|
127
|
+
self._last_img = None
|
|
128
|
+
|
|
129
|
+
self.activity_threshold_w = 1e-4
|
|
130
|
+
self.activity_threshold_p = 1e-4
|
|
131
|
+
|
|
132
|
+
self.res_scale = self.res / REFERENCE_RESOLUTION
|
|
133
|
+
|
|
134
|
+
# Ping-pong index: 0/1
|
|
135
|
+
self._ping = ti.field(dtype=ti.i32, shape=())
|
|
136
|
+
|
|
137
|
+
# User properties
|
|
138
|
+
self.p = SimParams()
|
|
139
|
+
self._set_params_fields()
|
|
140
|
+
|
|
141
|
+
# Simulation fields (ping-pong in leading dimension)
|
|
142
|
+
self.W = ti.field(dtype=ti.f32, shape=(2, self.res, self.res))
|
|
143
|
+
self.P = ti.Vector.field(4, dtype=ti.f32, shape=(2, self.res, self.res)) # RGBA (A is mass)
|
|
144
|
+
self.V = ti.Vector.field(2, dtype=ti.f32, shape=(2, self.res, self.res))
|
|
145
|
+
self.A = ti.Vector.field(4, dtype=ti.f32, shape=(self.res, self.res)) # RGBA (A is mass)
|
|
146
|
+
self.T = ti.field(dtype=ti.f32, shape=(self.res, self.res))
|
|
147
|
+
self.Age = ti.field(dtype=ti.f32, shape=(self.res, self.res)) # Per-pixel stroke timer
|
|
148
|
+
|
|
149
|
+
self._mask = ti.field(dtype=ti.f32, shape=(self.res, self.res))
|
|
150
|
+
self._mask_w = ti.field(dtype=ti.f32, shape=(self.res, self.res))
|
|
151
|
+
self._mask2 = ti.field(dtype=ti.f32, shape=(self.res, self.res))
|
|
152
|
+
self._img = ti.Vector.field(3, dtype=ti.f32, shape=(self.res, self.res))
|
|
153
|
+
self._img_u8 = ti.field(dtype=ti.u8, shape=(self.res, self.res, 3))
|
|
154
|
+
|
|
155
|
+
self._img_preview_u8 = ti.field(dtype=ti.u8, shape=(self.preview_res, self.preview_res, 3))
|
|
156
|
+
|
|
157
|
+
self._time = ti.field(dtype=ti.f32, shape=())
|
|
158
|
+
self._stamp_debug = ti.field(dtype=ti.i32, shape=())
|
|
159
|
+
|
|
160
|
+
# Pre-calculated Noise Buffer for optimizations
|
|
161
|
+
self._noise_res = NOISE_TEXTURE_RES
|
|
162
|
+
self._noise_tex = ti.field(dtype=ti.f32, shape=(self._noise_res, self._noise_res))
|
|
163
|
+
self.VNoise = ti.Vector.field(2, dtype=ti.f32, shape=(self._noise_res, self._noise_res))
|
|
164
|
+
|
|
165
|
+
self._ping[None] = 0
|
|
166
|
+
self._time[None] = 0.0
|
|
167
|
+
|
|
168
|
+
self._build_paper_texture(2.0)
|
|
169
|
+
self._init_noise_tex()
|
|
170
|
+
self.reset()
|
|
171
|
+
self.warmup()
|
|
172
|
+
|
|
173
|
+
@ti.kernel
|
|
174
|
+
def _init_noise_tex(self):
|
|
175
|
+
for i, j in self._noise_tex:
|
|
176
|
+
n = _hash21(ti.Vector([ti.cast(i, ti.f32), ti.cast(j, ti.f32)]))
|
|
177
|
+
self._noise_tex[i, j] = n
|
|
178
|
+
# Precompute unit vectors for turbulence to avoid sin/cos in sim loop
|
|
179
|
+
ang = 6.2831853 * n
|
|
180
|
+
self.VNoise[i, j] = ti.Vector([ti.cos(ang), ti.sin(ang)])
|
|
181
|
+
|
|
182
|
+
@ti.func
|
|
183
|
+
def _sample_noise(self, i: ti.i32, j: ti.i32, offset: ti.f32) -> ti.f32:
|
|
184
|
+
# Fast sampling from pre-computed noise with tiling
|
|
185
|
+
ii = (i + ti.cast(offset * 123.4, ti.i32)) % self._noise_res
|
|
186
|
+
jj = (j + ti.cast(offset * 567.8, ti.i32)) % self._noise_res
|
|
187
|
+
return self._noise_tex[ii, jj]
|
|
188
|
+
|
|
189
|
+
def _set_params_fields(self):
|
|
190
|
+
self._brush_radius = ti.field(dtype=ti.f32, shape=())
|
|
191
|
+
self._pigment_load = ti.field(dtype=ti.f32, shape=())
|
|
192
|
+
self._water_load = ti.field(dtype=ti.f32, shape=())
|
|
193
|
+
self._color = ti.Vector.field(3, dtype=ti.f32, shape=())
|
|
194
|
+
|
|
195
|
+
self._water_diffusion = ti.field(dtype=ti.f32, shape=())
|
|
196
|
+
self._pigment_diffusion = ti.field(dtype=ti.f32, shape=())
|
|
197
|
+
self._gravity_strength = ti.field(dtype=ti.f32, shape=())
|
|
198
|
+
self._drip_threshold = ti.field(dtype=ti.f32, shape=())
|
|
199
|
+
self._drip_rate = ti.field(dtype=ti.f32, shape=())
|
|
200
|
+
self._lateral_turbulence = ti.field(dtype=ti.f32, shape=())
|
|
201
|
+
self._flow_advection = ti.field(dtype=ti.f32, shape=())
|
|
202
|
+
self._velocity_damping = ti.field(dtype=ti.f32, shape=())
|
|
203
|
+
self._k_pressure = ti.field(dtype=ti.f32, shape=())
|
|
204
|
+
self._v_max = ti.field(dtype=ti.f32, shape=())
|
|
205
|
+
|
|
206
|
+
self._drying_rate = ti.field(dtype=ti.f32, shape=())
|
|
207
|
+
self._absorption_rate = ti.field(dtype=ti.f32, shape=())
|
|
208
|
+
self._pigment_settle = ti.field(dtype=ti.f32, shape=())
|
|
209
|
+
self._granulation_strength = ti.field(dtype=ti.f32, shape=())
|
|
210
|
+
self._edge_darkening = ti.field(dtype=ti.f32, shape=())
|
|
211
|
+
self._wet_darken = ti.field(dtype=ti.f32, shape=())
|
|
212
|
+
self._max_wet_pigment = ti.field(dtype=ti.f32, shape=())
|
|
213
|
+
self._max_stain_pigment = ti.field(dtype=ti.f32, shape=())
|
|
214
|
+
self._pigment_absorb_floor = ti.field(dtype=ti.f32, shape=())
|
|
215
|
+
self._pigment_neutral_density = ti.field(dtype=ti.f32, shape=())
|
|
216
|
+
self._stroke_life = ti.field(dtype=ti.f32, shape=())
|
|
217
|
+
|
|
218
|
+
def set_params(self, params: SimParams):
|
|
219
|
+
self.p = params
|
|
220
|
+
self._upload_params()
|
|
221
|
+
|
|
222
|
+
def update_params(self, **kwargs):
|
|
223
|
+
prev_scale = float(self.p.paper_texture_scale)
|
|
224
|
+
for k, v in kwargs.items():
|
|
225
|
+
if hasattr(self.p, k):
|
|
226
|
+
setattr(self.p, k, v)
|
|
227
|
+
self._upload_params()
|
|
228
|
+
if abs(float(self.p.paper_texture_scale) - prev_scale) > 1e-6:
|
|
229
|
+
self._build_paper_texture(self.p.paper_texture_scale)
|
|
230
|
+
|
|
231
|
+
def _upload_params(self):
|
|
232
|
+
"""Synchronizes Python-side parameters to Taichi-side fields with resolution scaling."""
|
|
233
|
+
self._brush_radius[None] = float(self.p.brush_radius)
|
|
234
|
+
self._pigment_load[None] = float(self.p.pigment_load)
|
|
235
|
+
self._water_load[None] = float(self.p.water_release)
|
|
236
|
+
self._color[None] = ti.Vector([float(self.p.color_rgb[0]), float(self.p.color_rgb[1]), float(self.p.color_rgb[2])])
|
|
237
|
+
|
|
238
|
+
# Map simplified params to internal engine physics with resolution scaling
|
|
239
|
+
# Velocity and Gravity scale linearly with res_scale.
|
|
240
|
+
# Diffusion scales quadratically (res_scale^2) to maintain consistent look across resolutions.
|
|
241
|
+
s = self.res_scale
|
|
242
|
+
s2 = s * s
|
|
243
|
+
|
|
244
|
+
self._water_diffusion[None] = float(self.p.diffusion) * float(self.p.water_diffusion_coeff) * s2
|
|
245
|
+
self._pigment_diffusion[None] = float(self.p.diffusion) * float(self.p.pigment_diffusion_coeff) * s2
|
|
246
|
+
self._gravity_strength[None] = float(self.p.gravity) * float(self.p.gravity_coeff) * s
|
|
247
|
+
self._drying_rate[None] = float(self.p.canvas_evaporation) * float(self.p.drying_coeff)
|
|
248
|
+
self._granulation_strength[None] = float(self.p.granulation) * 1.5
|
|
249
|
+
|
|
250
|
+
self._drip_threshold[None] = float(self.p.drip_threshold)
|
|
251
|
+
self._drip_rate[None] = float(self.p.drip_rate_coeff) * s
|
|
252
|
+
self._lateral_turbulence[None] = float(self.p.turbulence_coeff) * s
|
|
253
|
+
self._flow_advection[None] = float(self.p.advection_coeff) * s
|
|
254
|
+
self._velocity_damping[None] = float(self.p.velocity_damping)
|
|
255
|
+
self._k_pressure[None] = float(self.p.pressure_coeff) * s
|
|
256
|
+
self._v_max[None] = float(self.p.max_velocity_coeff) * s
|
|
257
|
+
self._stroke_life[None] = float(self.p.stroke_life)
|
|
258
|
+
self._absorption_rate[None] = float(self.p.absorption_coeff)
|
|
259
|
+
self._pigment_settle[None] = float(self.p.settle_coeff)
|
|
260
|
+
self._edge_darkening[None] = float(self.p.edge_darkening) * 0.5
|
|
261
|
+
self._wet_darken[None] = float(self.p.wet_darken_coeff)
|
|
262
|
+
self._max_wet_pigment[None] = float(self.p.max_wet_pigment)
|
|
263
|
+
self._max_stain_pigment[None] = float(self.p.max_stain_pigment)
|
|
264
|
+
self._pigment_absorb_floor[None] = float(self.p.pigment_absorb_floor)
|
|
265
|
+
self._pigment_neutral_density[None] = float(self.p.pigment_neutral_density)
|
|
266
|
+
|
|
267
|
+
def reset(self):
|
|
268
|
+
self._upload_params()
|
|
269
|
+
self._clear()
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _subdivide_polygon_edges(p1, p2, depth, variance, vdiv=2.0):
|
|
273
|
+
"""Recursively subdivides a line segment with random jitter for organic shapes."""
|
|
274
|
+
if depth < 0:
|
|
275
|
+
return []
|
|
276
|
+
mid = (p1 + p2) / 2.0
|
|
277
|
+
nx = mid[0] + random.gauss(0, variance)
|
|
278
|
+
ny = mid[1] + random.gauss(0, variance)
|
|
279
|
+
new_pt = np.array([nx, ny])
|
|
280
|
+
|
|
281
|
+
res = []
|
|
282
|
+
res.extend(WatercolorEngine._subdivide_polygon_edges(p1, new_pt, depth - 1, random.uniform(0, variance / vdiv), vdiv))
|
|
283
|
+
res.append(new_pt)
|
|
284
|
+
res.extend(WatercolorEngine._subdivide_polygon_edges(new_pt, p2, depth - 1, random.uniform(0, variance / vdiv), vdiv))
|
|
285
|
+
return res
|
|
286
|
+
|
|
287
|
+
def _create_deformed_polygon(self, x, y, r, nsides=10, depth=5, variance=15.0):
|
|
288
|
+
"""Creates a circular polygon and deforms its edges for a natural look."""
|
|
289
|
+
angles = np.linspace(0, 2*np.pi, nsides, endpoint=False)
|
|
290
|
+
return [(x + np.cos(a)*r, y + np.sin(a)*r) for a in angles]
|
|
291
|
+
|
|
292
|
+
def apply_mask(self, mask01: np.ndarray):
|
|
293
|
+
"""Applies a normalized numpy mask to the canvas."""
|
|
294
|
+
if mask01 is None: return
|
|
295
|
+
self._mask.from_numpy(mask01.astype(np.float32))
|
|
296
|
+
self._mask_w.from_numpy(mask01.astype(np.float32))
|
|
297
|
+
self._apply_pigment_and_water_mask(self.dt)
|
|
298
|
+
|
|
299
|
+
@ti.kernel
|
|
300
|
+
def _mask_diffuse_step(self, k: ti.f32):
|
|
301
|
+
"""Internal helper to diffuse a mask stencil."""
|
|
302
|
+
for i, j in self._mask:
|
|
303
|
+
m = self._mask[i, j]
|
|
304
|
+
ml = self._mask[ti.max(0, i - 1), j]
|
|
305
|
+
mr = self._mask[ti.min(self.res - 1, i + 1), j]
|
|
306
|
+
mu = self._mask[i, ti.max(0, j - 1)]
|
|
307
|
+
md = self._mask[i, ti.min(self.res - 1, j + 1)]
|
|
308
|
+
m_diag = (
|
|
309
|
+
self._mask[ti.max(0, i-1), ti.max(0, j-1)] +
|
|
310
|
+
self._mask[ti.min(self.res-1, i+1), ti.max(0, j-1)] +
|
|
311
|
+
self._mask[ti.max(0, i-1), ti.min(self.res-1, j+1)] +
|
|
312
|
+
self._mask[ti.min(self.res-1, i+1), ti.min(self.res-1, j+1)]
|
|
313
|
+
)
|
|
314
|
+
lap = (ml + mr + mu + md) * 0.2 + m_diag * 0.05 - m * 1.0
|
|
315
|
+
self._mask2[i, j] = _clamp_01(m + k * lap)
|
|
316
|
+
for i, j in self._mask:
|
|
317
|
+
self._mask[i, j] = self._mask2[i, j]
|
|
318
|
+
|
|
319
|
+
def debug_stamp_center(self, radius: int = 18):
|
|
320
|
+
"""Utility to place a single round stamp in the center of the canvas."""
|
|
321
|
+
r = int(max(1, radius))
|
|
322
|
+
cx = int(self.res // 2)
|
|
323
|
+
cy = int(self.res // 2)
|
|
324
|
+
self._render_circular_brush_stamp(cx, cy, r, 0.5, 0)
|
|
325
|
+
|
|
326
|
+
@ti.kernel
|
|
327
|
+
def _render_circular_brush_stamp(self, cx: ti.i32, cy: ti.i32, r: ti.i32, dryness: ti.f32, brush_id: ti.i32):
|
|
328
|
+
"""Renders/stamps a circular brush shape onto the simulation fields."""
|
|
329
|
+
ping = self._ping[None]
|
|
330
|
+
col = self._color[None]
|
|
331
|
+
life = self._stroke_life[None]
|
|
332
|
+
t = self._time[None]
|
|
333
|
+
|
|
334
|
+
# EXTREME MAPPING - Improved to avoid hollow centers
|
|
335
|
+
# We increase base pigment so wet brushes aren't too faint
|
|
336
|
+
water_to_add = (1.0 - dryness) * 4.0 * self._water_load[None]
|
|
337
|
+
pigment_to_add = (0.4 + dryness * 0.8) * self._pigment_load[None]
|
|
338
|
+
|
|
339
|
+
# Bounded iteration
|
|
340
|
+
x_start, x_end = ti.max(0, cx - r), ti.min(self.res, cx + r + 1)
|
|
341
|
+
y_start, y_end = ti.max(0, cy - r), ti.min(self.res, cy + r + 1)
|
|
342
|
+
|
|
343
|
+
for i, j in ti.ndrange((x_start, x_end), (y_start, y_end)):
|
|
344
|
+
dx = ti.cast(i - cx, ti.f32)
|
|
345
|
+
dy = ti.cast(j - cy, ti.f32)
|
|
346
|
+
d = ti.sqrt(dx * dx + dy * dy)
|
|
347
|
+
f_r = ti.cast(r, ti.f32)
|
|
348
|
+
|
|
349
|
+
mask = 0.0
|
|
350
|
+
if brush_id == 0: # SOFT ROUND
|
|
351
|
+
if d <= f_r:
|
|
352
|
+
# Use a softer falloff (linear instead of squared) for fuller centers
|
|
353
|
+
mask = ti.max(0.0, 1.0 - d / (f_r + 1e-6))
|
|
354
|
+
elif brush_id == 1: # SPONGE
|
|
355
|
+
if d <= f_r:
|
|
356
|
+
# Multi-scale noise for sponge texture
|
|
357
|
+
uv = ti.Vector([ti.cast(i, ti.f32)*0.3, ti.cast(j, ti.f32)*0.3])
|
|
358
|
+
n1 = _hash21(uv + t)
|
|
359
|
+
n2 = _hash21(uv * 0.5 + t * 0.7)
|
|
360
|
+
n3 = _hash21(uv * 2.0 + t * 1.3)
|
|
361
|
+
sponge_mask = (n1 * 0.5 + n2 * 0.3 + n3 * 0.2)
|
|
362
|
+
if sponge_mask > 0.5:
|
|
363
|
+
mask = (1.0 - d/f_r) * sponge_mask * 1.5
|
|
364
|
+
|
|
365
|
+
if mask > 0.0:
|
|
366
|
+
self.Age[i, j] = dryness * (life + 1.0)
|
|
367
|
+
a_mask = _clamp_01(mask)
|
|
368
|
+
|
|
369
|
+
old_w = self.W[ping, i, j]
|
|
370
|
+
self.W[ping, i, j] = _clamp_01(old_w + water_to_add * a_mask)
|
|
371
|
+
|
|
372
|
+
old_p = self.P[ping, i, j]
|
|
373
|
+
new_m = pigment_to_add * a_mask
|
|
374
|
+
|
|
375
|
+
total_m_raw = old_p.w + new_m
|
|
376
|
+
total_m = ti.min(total_m_raw, self._max_wet_pigment[None])
|
|
377
|
+
|
|
378
|
+
kept = total_m / (total_m_raw + 1e-6)
|
|
379
|
+
new_m = new_m * kept
|
|
380
|
+
|
|
381
|
+
new_amt = old_p.xyz + col * new_m
|
|
382
|
+
self.P[ping, i, j] = ti.Vector([new_amt.x, new_amt.y, new_amt.z, total_m])
|
|
383
|
+
|
|
384
|
+
def step(self, steps: int = 1):
|
|
385
|
+
"""Advances the simulation by the specified number of time steps."""
|
|
386
|
+
for _ in range(int(steps)):
|
|
387
|
+
t0 = time.perf_counter() if self.timing_mode else 0
|
|
388
|
+
|
|
389
|
+
self._time[None] += self.dt
|
|
390
|
+
|
|
391
|
+
# Phase 1: Physical simulation (drying, settling, gravity, velocity)
|
|
392
|
+
self._apply_fluid_physics_step(self.dt)
|
|
393
|
+
|
|
394
|
+
t1 = time.perf_counter() if self.timing_mode else 0
|
|
395
|
+
|
|
396
|
+
# Phase 2: Flow simulation (advection and diffusion)
|
|
397
|
+
self._apply_advection_diffusion_step(self.dt)
|
|
398
|
+
|
|
399
|
+
self._frame_count += 1
|
|
400
|
+
|
|
401
|
+
if self.timing_mode and self._frame_count % 30 == 0:
|
|
402
|
+
ti.sync()
|
|
403
|
+
t2 = time.perf_counter()
|
|
404
|
+
print(f"[Watercolor] Sim Step: {(t1-t0)*1000:4.1f}ms (phys) + {(t2-t1)*1000:4.1f}ms (advect)")
|
|
405
|
+
|
|
406
|
+
def clear(self):
|
|
407
|
+
"""Clears all simulation fields."""
|
|
408
|
+
self._clear()
|
|
409
|
+
|
|
410
|
+
def set_color(self, r, g, b):
|
|
411
|
+
"""Sets the current brush color."""
|
|
412
|
+
self._color[None] = ti.Vector([r, g, b])
|
|
413
|
+
|
|
414
|
+
def paint_brush(self, x, y, r, brush_id=0, dryness=0.5, **kwargs):
|
|
415
|
+
"""Applies a circular brush stroke at (x, y)."""
|
|
416
|
+
self._render_circular_brush_stamp(int(x), int(y), int(r), float(dryness), int(brush_id))
|
|
417
|
+
|
|
418
|
+
def paint(self, x, y, r, dryness=0.5):
|
|
419
|
+
"""Applies a default round brush stroke at (x, y)."""
|
|
420
|
+
self._render_circular_brush_stamp(int(x), int(y), int(r), float(dryness), 0)
|
|
421
|
+
|
|
422
|
+
def paint_mask(self, pigment_mask, water_mask=None):
|
|
423
|
+
"""Applies an arbitrary mask to provide irregular pigment/water deposits."""
|
|
424
|
+
self._mask.from_numpy(pigment_mask)
|
|
425
|
+
if water_mask is not None:
|
|
426
|
+
self._mask_w.from_numpy(water_mask)
|
|
427
|
+
else:
|
|
428
|
+
self._mask_w.from_numpy(pigment_mask)
|
|
429
|
+
self._apply_pigment_and_water_mask(self.dt)
|
|
430
|
+
|
|
431
|
+
def render(self, debug_wet: bool = False, full_res: bool = False) -> np.ndarray:
|
|
432
|
+
"""Composites and returns the current canvas as a numpy array."""
|
|
433
|
+
if self._frame_count % self.render_every == 0 or self._last_img is None or full_res:
|
|
434
|
+
t0 = time.perf_counter() if self.timing_mode else 0
|
|
435
|
+
|
|
436
|
+
# Always update the full-resolution image field for the Taichi GUI (canvas.set_image)
|
|
437
|
+
self._draw_full_canvas(int(debug_wet))
|
|
438
|
+
|
|
439
|
+
if not full_res:
|
|
440
|
+
# Update low-res preview if full resolution isn't requested for the CPU return
|
|
441
|
+
self._draw_preview_canvas(int(debug_wet))
|
|
442
|
+
|
|
443
|
+
if self.timing_mode: ti.sync()
|
|
444
|
+
t1 = time.perf_counter() if self.timing_mode else 0
|
|
445
|
+
|
|
446
|
+
# Return the appropriate resolution to the caller
|
|
447
|
+
if full_res:
|
|
448
|
+
self._last_img = self._img_u8.to_numpy()
|
|
449
|
+
else:
|
|
450
|
+
self._last_img = self._img_preview_u8.to_numpy()
|
|
451
|
+
|
|
452
|
+
if self.timing_mode and self._frame_count % 30 == 0:
|
|
453
|
+
t2 = time.perf_counter()
|
|
454
|
+
print(f"[Watercolor] Render: {(t1-t0)*1000:4.1f}ms (kernel) + {(t2-t1)*1000:4.1f}ms (transfer)")
|
|
455
|
+
|
|
456
|
+
return self._last_img
|
|
457
|
+
|
|
458
|
+
def warmup(self):
|
|
459
|
+
"""Trigger JIT compilation of all kernels by running a small dummy simulation."""
|
|
460
|
+
self.clear()
|
|
461
|
+
self._build_paper_texture(2.0)
|
|
462
|
+
self._init_noise_tex()
|
|
463
|
+
self._render_circular_brush_stamp(self.res//2, self.res//2, 10, 0.5, 0)
|
|
464
|
+
self._apply_fluid_physics_step(self.dt)
|
|
465
|
+
self._ping[None] = 1 - self._ping[None]
|
|
466
|
+
self._apply_advection_diffusion_step(self.dt)
|
|
467
|
+
self._ping[None] = 1 - self._ping[None]
|
|
468
|
+
self._draw_preview_canvas(0)
|
|
469
|
+
self._draw_full_canvas(0)
|
|
470
|
+
self.clear()
|
|
471
|
+
ti.sync()
|
|
472
|
+
print(f"[WatercolorEngine] Warmup complete.")
|
|
473
|
+
|
|
474
|
+
def test_integrity(self):
|
|
475
|
+
"""Verifies simulation state for stability (NaN checks)."""
|
|
476
|
+
self.clear()
|
|
477
|
+
self._render_circular_brush_stamp(self.res//2, self.res//2, 10, 0.5, 0)
|
|
478
|
+
for i in range(10):
|
|
479
|
+
self.step(1)
|
|
480
|
+
|
|
481
|
+
w = self.W.to_numpy()
|
|
482
|
+
if np.any(np.isnan(w)):
|
|
483
|
+
print("[WatercolorEngine] INTEGRITY ERROR: NaN detected in wetness field!")
|
|
484
|
+
if np.any((w < -1e-5) | (w > 1.0 + 1e-5)):
|
|
485
|
+
max_w = w.max()
|
|
486
|
+
min_w = w.min()
|
|
487
|
+
print(f"[WatercolorEngine] INTEGRITY ERROR: Wetness out of range: [{min_w}, {max_w}]")
|
|
488
|
+
else:
|
|
489
|
+
print("[WatercolorEngine] Integrity test passed.")
|
|
490
|
+
|
|
491
|
+
# ===============================
|
|
492
|
+
# Taichi kernels
|
|
493
|
+
# ===============================
|
|
494
|
+
|
|
495
|
+
@ti.kernel
|
|
496
|
+
def _clear(self):
|
|
497
|
+
self._ping[None] = 0
|
|
498
|
+
self._time[None] = 0.0
|
|
499
|
+
for i, j in self.A:
|
|
500
|
+
self.A[i, j] = ti.Vector([0.0, 0.0, 0.0, 0.0])
|
|
501
|
+
self.Age[i, j] = 0.0
|
|
502
|
+
for b, i, j in self.W:
|
|
503
|
+
self.W[b, i, j] = 0.0
|
|
504
|
+
self.P[b, i, j] = ti.Vector([0.0, 0.0, 0.0, 0.0])
|
|
505
|
+
self.V[b, i, j] = ti.Vector([0.0, 0.0])
|
|
506
|
+
for i, j in self._mask:
|
|
507
|
+
self._mask[i, j] = 0.0
|
|
508
|
+
self._mask_w[i, j] = 0.0
|
|
509
|
+
self._mask2[i, j] = 0.0
|
|
510
|
+
for i, j in self._img:
|
|
511
|
+
self._img[i, j] = ti.Vector([1.0, 1.0, 1.0])
|
|
512
|
+
for i, j, k in self._img_u8:
|
|
513
|
+
self._img_u8[i, j, k] = ti.cast(255, ti.u8)
|
|
514
|
+
|
|
515
|
+
@ti.kernel
|
|
516
|
+
def _mask_diffuse(self, dt: ti.f32, k: ti.f32):
|
|
517
|
+
# A cheap diffusion step to widen the sketch mask into a soft brush stamp.
|
|
518
|
+
# Writes into _mask2 then swaps back into _mask.
|
|
519
|
+
for i, j in self._mask:
|
|
520
|
+
m = self._mask[i, j]
|
|
521
|
+
ml = self._mask[ti.max(0, i - 1), j]
|
|
522
|
+
mr = self._mask[ti.min(self.res - 1, i + 1), j]
|
|
523
|
+
mu = self._mask[i, ti.max(0, j - 1)]
|
|
524
|
+
md = self._mask[i, ti.min(self.res - 1, j + 1)]
|
|
525
|
+
lap = (ml + mr + mu + md - 4.0 * m)
|
|
526
|
+
self._mask2[i, j] = _clamp_01(m + k * lap)
|
|
527
|
+
|
|
528
|
+
for i, j in self._mask:
|
|
529
|
+
self._mask[i, j] = self._mask2[i, j]
|
|
530
|
+
|
|
531
|
+
@ti.kernel
|
|
532
|
+
def _build_paper_texture(self, scale: ti.f32):
|
|
533
|
+
"""Generates a procedural multi-octave noise texture to simulate paper roughness."""
|
|
534
|
+
for i, j in self.T:
|
|
535
|
+
p = ti.Vector([ti.cast(i, ti.f32), ti.cast(j, ti.f32)])
|
|
536
|
+
|
|
537
|
+
# Multi-octave noise for organic paper feel
|
|
538
|
+
n0 = _hash21(p * (0.015 * scale) + 7.1)
|
|
539
|
+
n1 = _hash21(p * (0.050 * scale) + 19.7)
|
|
540
|
+
n2 = _hash21(p * (0.120 * scale) + 41.3)
|
|
541
|
+
|
|
542
|
+
n = 0.50 * n0 + 0.35 * n1 + 0.15 * n2
|
|
543
|
+
|
|
544
|
+
# Contrast mapping based on scale to maintain texture visibility
|
|
545
|
+
roughness_contrast = 1.0 + 0.1 * scale + (1.0 / (scale + 0.1))
|
|
546
|
+
|
|
547
|
+
n = (n - 0.5) * roughness_contrast + 0.5
|
|
548
|
+
self.T[i, j] = _clamp_01(n)
|
|
549
|
+
|
|
550
|
+
@ti.func
|
|
551
|
+
def _sample_scalar(self, f: ti.template(), x: ti.f32, y: ti.f32, b: ti.i32) -> ti.f32:
|
|
552
|
+
# Bilinear sampling in grid coords
|
|
553
|
+
x = ti.max(0.0, ti.min(ti.cast(self.res - 1, ti.f32), x))
|
|
554
|
+
y = ti.max(0.0, ti.min(ti.cast(self.res - 1, ti.f32), y))
|
|
555
|
+
x0 = ti.cast(ti.floor(x), ti.i32)
|
|
556
|
+
y0 = ti.cast(ti.floor(y), ti.i32)
|
|
557
|
+
x1 = ti.min(self.res - 1, x0 + 1)
|
|
558
|
+
y1 = ti.min(self.res - 1, y0 + 1)
|
|
559
|
+
tx = x - ti.cast(x0, ti.f32)
|
|
560
|
+
ty = y - ti.cast(y0, ti.f32)
|
|
561
|
+
|
|
562
|
+
v00 = f[b, x0, y0]
|
|
563
|
+
v10 = f[b, x1, y0]
|
|
564
|
+
v01 = f[b, x0, y1]
|
|
565
|
+
v11 = f[b, x1, y1]
|
|
566
|
+
|
|
567
|
+
v0 = v00 * (1.0 - tx) + v10 * tx
|
|
568
|
+
v1 = v01 * (1.0 - tx) + v11 * tx
|
|
569
|
+
return v0 * (1.0 - ty) + v1 * ty
|
|
570
|
+
|
|
571
|
+
@ti.func
|
|
572
|
+
def _sample_vec3(self, f: ti.template(), x: ti.f32, y: ti.f32, b: ti.i32) -> ti.math.vec3:
|
|
573
|
+
x = ti.max(0.0, ti.min(ti.cast(self.res - 1, ti.f32), x))
|
|
574
|
+
y = ti.max(0.0, ti.min(ti.cast(self.res - 1, ti.f32), y))
|
|
575
|
+
x0 = ti.cast(ti.floor(x), ti.i32)
|
|
576
|
+
y0 = ti.cast(ti.floor(y), ti.i32)
|
|
577
|
+
x1 = ti.min(self.res - 1, x0 + 1)
|
|
578
|
+
y1 = ti.min(self.res - 1, y0 + 1)
|
|
579
|
+
tx = x - ti.cast(x0, ti.f32)
|
|
580
|
+
ty = y - ti.cast(y0, ti.f32)
|
|
581
|
+
|
|
582
|
+
v00 = f[b, x0, y0]
|
|
583
|
+
v10 = f[b, x1, y0]
|
|
584
|
+
v01 = f[b, x0, y1]
|
|
585
|
+
v11 = f[b, x1, y1]
|
|
586
|
+
|
|
587
|
+
v0 = v00 * (1.0 - tx) + v10 * tx
|
|
588
|
+
v1 = v01 * (1.0 - tx) + v11 * tx
|
|
589
|
+
return v0 * (1.0 - ty) + v1 * ty
|
|
590
|
+
|
|
591
|
+
@ti.func
|
|
592
|
+
def _sample_vec4(self, f: ti.template(), x: ti.f32, y: ti.f32, b: ti.i32) -> ti.math.vec4:
|
|
593
|
+
x = ti.max(0.0, ti.min(ti.cast(self.res - 1, ti.f32), x))
|
|
594
|
+
y = ti.max(0.0, ti.min(ti.cast(self.res - 1, ti.f32), y))
|
|
595
|
+
x0 = ti.cast(ti.floor(x), ti.i32)
|
|
596
|
+
y0 = ti.cast(ti.floor(y), ti.i32)
|
|
597
|
+
x1 = ti.min(self.res - 1, x0 + 1)
|
|
598
|
+
y1 = ti.min(self.res - 1, y0 + 1)
|
|
599
|
+
tx = x - ti.cast(x0, ti.f32)
|
|
600
|
+
ty = y - ti.cast(y0, ti.f32)
|
|
601
|
+
|
|
602
|
+
v00 = f[b, x0, y0]
|
|
603
|
+
v10 = f[b, x1, y0]
|
|
604
|
+
v01 = f[b, x0, y1]
|
|
605
|
+
v11 = f[b, x1, y1]
|
|
606
|
+
|
|
607
|
+
v0 = v00 * (1.0 - tx) + v10 * tx
|
|
608
|
+
v1 = v01 * (1.0 - tx) + v11 * tx
|
|
609
|
+
return v0 * (1.0 - ty) + v1 * ty
|
|
610
|
+
|
|
611
|
+
@ti.func
|
|
612
|
+
def _sample_vec2(self, f: ti.template(), x: ti.f32, y: ti.f32, b: ti.i32) -> ti.math.vec2:
|
|
613
|
+
x = ti.max(0.0, ti.min(ti.cast(self.res - 1, ti.f32), x))
|
|
614
|
+
y = ti.max(0.0, ti.min(ti.cast(self.res - 1, ti.f32), y))
|
|
615
|
+
x0 = ti.cast(ti.floor(x), ti.i32)
|
|
616
|
+
y0 = ti.cast(ti.floor(y), ti.i32)
|
|
617
|
+
x1 = ti.min(self.res - 1, x0 + 1)
|
|
618
|
+
y1 = ti.min(self.res - 1, y0 + 1)
|
|
619
|
+
tx = x - ti.cast(x0, ti.f32)
|
|
620
|
+
ty = y - ti.cast(y0, ti.f32)
|
|
621
|
+
|
|
622
|
+
v00 = f[b, x0, y0]
|
|
623
|
+
v10 = f[b, x1, y0]
|
|
624
|
+
v01 = f[b, x0, y1]
|
|
625
|
+
v11 = f[b, x1, y1]
|
|
626
|
+
|
|
627
|
+
v0 = v00 * (1.0 - tx) + v10 * tx
|
|
628
|
+
v1 = v01 * (1.0 - tx) + v11 * tx
|
|
629
|
+
return v0 * (1.0 - ty) + v1 * ty
|
|
630
|
+
|
|
631
|
+
@ti.kernel
|
|
632
|
+
def _apply_pigment_and_water_mask(self, dt: ti.f32):
|
|
633
|
+
"""Applies a custom mask (usually fractal/organic) to the pigment and water fields."""
|
|
634
|
+
ping = self._ping[None]
|
|
635
|
+
water_add = self._water_load[None] * 0.8
|
|
636
|
+
pig_add = self._pigment_load[None] * 0.08
|
|
637
|
+
col = self._color[None]
|
|
638
|
+
|
|
639
|
+
for i, j in self._mask:
|
|
640
|
+
m_p = _clamp_01(self._mask[i, j])
|
|
641
|
+
m_w = _clamp_01(self._mask_w[i, j])
|
|
642
|
+
|
|
643
|
+
if m_p > 0.0 or m_w > 0.0:
|
|
644
|
+
self.Age[i, j] = 0.0
|
|
645
|
+
|
|
646
|
+
old_p = self.P[ping, i, j]
|
|
647
|
+
|
|
648
|
+
bonus_w = 0.35 * _clamp_01(self.W[ping, i, j] + old_p.w * 2.5)
|
|
649
|
+
w = _clamp_01(self.W[ping, i, j] + (water_add + bonus_w) * m_w)
|
|
650
|
+
|
|
651
|
+
hn = _hash21(ti.Vector([ti.cast(i, ti.f32), ti.cast(j, ti.f32)]) * 2.3 + 91.7)
|
|
652
|
+
hole = 1.0
|
|
653
|
+
if hn > 0.94:
|
|
654
|
+
hole = 0.0
|
|
655
|
+
|
|
656
|
+
n = _hash21(ti.Vector([ti.cast(i, ti.f32), ti.cast(j, ti.f32)]) * 8.0 + 13.7)
|
|
657
|
+
grain = 0.85 + 0.3 * (n - 0.5)
|
|
658
|
+
m_p = _clamp_01(m_p * hole * grain)
|
|
659
|
+
m_w = _clamp_01(m_w * (0.95 + 0.05 * hole))
|
|
660
|
+
|
|
661
|
+
sat_stain = self.A[i, j].w / (self._max_stain_pigment[None] + 1e-6)
|
|
662
|
+
sat_wet = old_p.w / (self._max_wet_pigment[None] + 1e-6)
|
|
663
|
+
sat = ti.max(sat_stain, sat_wet)
|
|
664
|
+
sat = ti.min(1.0, ti.max(0.0, sat))
|
|
665
|
+
m_p = m_p * (1.0 - 0.98 * ti.pow(sat, 3.5))
|
|
666
|
+
|
|
667
|
+
new_m = pig_add * m_p
|
|
668
|
+
total_m_raw = old_p.w + new_m
|
|
669
|
+
|
|
670
|
+
total_m = ti.min(total_m_raw, self._max_wet_pigment[None])
|
|
671
|
+
|
|
672
|
+
kept = total_m / (total_m_raw + 1e-6)
|
|
673
|
+
new_m = new_m * kept
|
|
674
|
+
total_m = old_p.w + new_m
|
|
675
|
+
|
|
676
|
+
new_amt = old_p.xyz + col * new_m
|
|
677
|
+
|
|
678
|
+
self.W[ping, i, j] = w
|
|
679
|
+
self.P[ping, i, j] = ti.Vector([new_amt.x, new_amt.y, new_amt.z, total_m])
|
|
680
|
+
|
|
681
|
+
self._mask[i, j] = 0.0
|
|
682
|
+
self._mask_w[i, j] = 0.0
|
|
683
|
+
|
|
684
|
+
@ti.kernel
|
|
685
|
+
def _apply_fluid_physics_step(self, dt: ti.f32):
|
|
686
|
+
"""Simulation phase 1: Handles drying, settling, gravity, and velocity updates."""
|
|
687
|
+
ping = self._ping[None]
|
|
688
|
+
pong = 1 - ping
|
|
689
|
+
|
|
690
|
+
life = self._stroke_life[None]
|
|
691
|
+
dry = self._drying_rate[None]
|
|
692
|
+
absorb = self._absorption_rate[None]
|
|
693
|
+
settle = self._pigment_settle[None]
|
|
694
|
+
g = self._gravity_strength[None]
|
|
695
|
+
kpress = self._k_pressure[None]
|
|
696
|
+
turb = self._lateral_turbulence[None]
|
|
697
|
+
damp = self._velocity_damping[None]
|
|
698
|
+
vmax = self._v_max[None]
|
|
699
|
+
|
|
700
|
+
for i, j in self.A:
|
|
701
|
+
w_prev = self.W[ping, i, j]
|
|
702
|
+
p_prev = self.P[ping, i, j]
|
|
703
|
+
|
|
704
|
+
if w_prev < 1e-5 and p_prev.w < 1e-5:
|
|
705
|
+
# Early exit for inactive pixels
|
|
706
|
+
self.W[pong, i, j] = 0.0
|
|
707
|
+
self.P[pong, i, j] = ti.Vector([0.0, 0.0, 0.0, 0.0])
|
|
708
|
+
self.V[pong, i, j] = ti.Vector([0.0, 0.0])
|
|
709
|
+
continue
|
|
710
|
+
|
|
711
|
+
# --- Settling & Drying ---
|
|
712
|
+
self.Age[i, j] += dt
|
|
713
|
+
tex = self.T[i, j]
|
|
714
|
+
|
|
715
|
+
tex_impact = 0.2 + 1.2 * tex # Hardcoded feel multiplier
|
|
716
|
+
sink = dt * (dry + absorb * tex_impact)
|
|
717
|
+
w_new = ti.max(0.0, w_prev - sink)
|
|
718
|
+
dw = ti.max(0.0, w_prev - w_new)
|
|
719
|
+
|
|
720
|
+
# Gradients for pressure and edge detection
|
|
721
|
+
wl = self.W[ping, ti.max(0, i-1), j]
|
|
722
|
+
wr = self.W[ping, ti.min(self.res-1, i+1), j]
|
|
723
|
+
wu = self.W[ping, i, ti.max(0, j-1)]
|
|
724
|
+
wd = self.W[ping, i, ti.min(self.res-1, j+1)]
|
|
725
|
+
gradw = ti.Vector([(wr - wl) * 0.5, (wd - wu) * 0.5])
|
|
726
|
+
|
|
727
|
+
edge = ti.sqrt(gradw.x * gradw.x + gradw.y * gradw.y)
|
|
728
|
+
|
|
729
|
+
# Organic jagged edge logic
|
|
730
|
+
en = self._sample_noise(i, j, 8.1)
|
|
731
|
+
edge_j = edge * (0.6 + 0.8 * en)
|
|
732
|
+
edge_cap = 1.2
|
|
733
|
+
edge_factor = 2.0 + 6.0 * self._edge_darkening[None]
|
|
734
|
+
|
|
735
|
+
bn1 = self._sample_noise(i, j, self._time[None] * 1.5)
|
|
736
|
+
bn2 = self._sample_noise(i, j, -self._time[None] * 0.9)
|
|
737
|
+
branch_n = (bn1 * bn1 * bn1 * ti.sqrt(bn1)) * (0.5 + 0.5 * bn2)
|
|
738
|
+
|
|
739
|
+
gran = self._granulation_strength[None]
|
|
740
|
+
branch_mod = 1.0 + 8.0 * branch_n * _clamp_01(edge_j * 16.0) * (0.1 + 3.9 * tex * gran)
|
|
741
|
+
|
|
742
|
+
edge_term = 0.08 + edge_factor * (edge_j * (1.0 - edge_j / edge_cap) if edge_j < edge_cap else edge_cap / 4.0)
|
|
743
|
+
s = _clamp_01(settle * dw * edge_term * branch_mod)
|
|
744
|
+
|
|
745
|
+
old_a = self.A[i, j]
|
|
746
|
+
stain_sat = _clamp_01(old_a.w / (self._max_stain_pigment[None] + 1e-6))
|
|
747
|
+
|
|
748
|
+
one_minus_sat = 1.0 - stain_sat
|
|
749
|
+
s = s * (one_minus_sat * one_minus_sat * ti.sqrt(one_minus_sat))
|
|
750
|
+
|
|
751
|
+
settled_amt = p_prev.xyz * s
|
|
752
|
+
settled_m = p_prev.w * s
|
|
753
|
+
|
|
754
|
+
total_am_raw = old_a.w + settled_m
|
|
755
|
+
total_am = ti.min(total_am_raw, self._max_stain_pigment[None])
|
|
756
|
+
kept_a = total_am / (total_am_raw + 1e-6)
|
|
757
|
+
|
|
758
|
+
res_c = (old_a.xyz + settled_amt) * kept_a
|
|
759
|
+
|
|
760
|
+
self.A[i, j] = ti.Vector([res_c.x, res_c.y, res_c.z, total_am])
|
|
761
|
+
self.P[pong, i, j] = ti.Vector([p_prev.x - settled_amt.x, p_prev.y - settled_amt.y, p_prev.z - settled_amt.z, p_prev.w - settled_m])
|
|
762
|
+
self.W[pong, i, j] = _clamp_01(w_new)
|
|
763
|
+
|
|
764
|
+
# --- Velocity Field Update ---
|
|
765
|
+
v = self.V[ping, i, j]
|
|
766
|
+
v.y -= g * w_prev * dt
|
|
767
|
+
v += (-kpress * gradw) * dt
|
|
768
|
+
|
|
769
|
+
# Sample turbulence unit vectors
|
|
770
|
+
off = ti.cast(self._time[None] * 60, ti.i32)
|
|
771
|
+
nv_i = (ti.cast(i, ti.i32) + off) % self._noise_res
|
|
772
|
+
nv_j = (ti.cast(j, ti.i32) + off * 3) % self._noise_res
|
|
773
|
+
v_noise = self.VNoise[nv_i, nv_j]
|
|
774
|
+
v += v_noise * (turb * w_prev * dt)
|
|
775
|
+
|
|
776
|
+
v *= (1.0 - damp * dt)
|
|
777
|
+
v_sq = v.x * v.x + v.y * v.y
|
|
778
|
+
if v_sq > vmax * vmax:
|
|
779
|
+
v *= (vmax / (ti.sqrt(v_sq) + 1e-6))
|
|
780
|
+
self.V[pong, i, j] = v
|
|
781
|
+
|
|
782
|
+
@ti.kernel
|
|
783
|
+
def _apply_advection_diffusion_step(self, dt: ti.f32):
|
|
784
|
+
"""Simulation phase 2: Handles advection (flow) and diffusion of water and pigment."""
|
|
785
|
+
ping = self._ping[None]
|
|
786
|
+
pong = 1 - ping
|
|
787
|
+
|
|
788
|
+
adv = self._flow_advection[None]
|
|
789
|
+
wd_rate = self._water_diffusion[None]
|
|
790
|
+
pd_rate = self._pigment_diffusion[None]
|
|
791
|
+
|
|
792
|
+
for i, j in self.A:
|
|
793
|
+
v = self.V[pong, i, j]
|
|
794
|
+
|
|
795
|
+
# Semi-Lagrangian Advection
|
|
796
|
+
back_x = ti.cast(i, ti.f32) - v.x * dt * adv
|
|
797
|
+
back_y = ti.cast(j, ti.f32) - v.y * dt * adv
|
|
798
|
+
|
|
799
|
+
w_adv = self._sample_scalar(self.W, back_x, back_y, pong)
|
|
800
|
+
p_adv = self._sample_vec4(self.P, back_x, back_y, pong)
|
|
801
|
+
v_adv = self._sample_vec2(self.V, back_x, back_y, pong)
|
|
802
|
+
|
|
803
|
+
# Diffusion (using 5-point laplacian with edge preservation)
|
|
804
|
+
w = w_adv
|
|
805
|
+
wl = self.W[pong, ti.max(0, i-1), j]
|
|
806
|
+
wr = self.W[pong, ti.min(self.res-1, i+1), j]
|
|
807
|
+
wu = self.W[pong, i, ti.max(0, j-1)]
|
|
808
|
+
wd = self.W[pong, i, ti.min(self.res-1, j + 1)]
|
|
809
|
+
lap_w = (wl + wr + wu + wd - 4.0 * w)
|
|
810
|
+
edge_preserve = w * (1.0 - w)
|
|
811
|
+
w_diff = _clamp_01(w + wd_rate * dt * lap_w * (0.10 + 2.2 * edge_preserve))
|
|
812
|
+
|
|
813
|
+
p = p_adv
|
|
814
|
+
pl = self.P[pong, ti.max(0, i-1), j]
|
|
815
|
+
pr = self.P[pong, ti.min(self.res-1, i+1), j]
|
|
816
|
+
pu = self.P[pong, i, ti.max(0, j-1)]
|
|
817
|
+
pd = self.P[pong, i, ti.min(self.res-1, j+1)]
|
|
818
|
+
lap_p = (pl + pr + pu + pd - 4.0 * p)
|
|
819
|
+
p_diff = p + (pd_rate * dt * _clamp_01(w_diff)) * lap_p
|
|
820
|
+
|
|
821
|
+
self.W[ping, i, j] = _clamp_01(w_diff)
|
|
822
|
+
self.P[ping, i, j] = p_diff
|
|
823
|
+
self.V[ping, i, j] = v_adv
|
|
824
|
+
@ti.kernel
|
|
825
|
+
def _draw_full_canvas(self, debug_wet: ti.i32):
|
|
826
|
+
"""Composites the fluid and stain layers into the final 8-bit image field."""
|
|
827
|
+
ping = self._ping[None]
|
|
828
|
+
|
|
829
|
+
for i, j in self._img:
|
|
830
|
+
# Paper base
|
|
831
|
+
paper = ti.Vector([1.0, 1.0, 1.0])
|
|
832
|
+
|
|
833
|
+
# Wet pigment layer
|
|
834
|
+
p = self.P[ping, i, j]
|
|
835
|
+
wet_m = p.w
|
|
836
|
+
wet_rgb = ti.Vector([0.0, 0.0, 0.0])
|
|
837
|
+
if wet_m > 1e-8:
|
|
838
|
+
wet_rgb = p.xyz / (wet_m + 1e-10)
|
|
839
|
+
|
|
840
|
+
# Stain pigment layer
|
|
841
|
+
a = self.A[i, j]
|
|
842
|
+
stain_m = a.w
|
|
843
|
+
stain_rgb = ti.Vector([0.0, 0.0, 0.0])
|
|
844
|
+
if stain_m > 1e-8:
|
|
845
|
+
stain_rgb = a.xyz / (stain_m + 1e-10)
|
|
846
|
+
|
|
847
|
+
# Convert masses to opacities
|
|
848
|
+
wet_alpha = _clamp_01(wet_m / (self._max_wet_pigment[None] + 1e-6))
|
|
849
|
+
stain_alpha = _clamp_01(stain_m / (self._max_stain_pigment[None] + 1e-6))
|
|
850
|
+
|
|
851
|
+
# Rendering strengths
|
|
852
|
+
wet_strength = 0.85
|
|
853
|
+
stain_strength = 0.85 * (0.5 + 2.0 * self._edge_darkening[None])
|
|
854
|
+
|
|
855
|
+
# Optical density compositing
|
|
856
|
+
absorb_floor = _clamp_01(self._pigment_absorb_floor[None])
|
|
857
|
+
neutral = _clamp_01(self._pigment_neutral_density[None])
|
|
858
|
+
|
|
859
|
+
absorb_wet = ti.max(absorb_floor, _clamp_vec3_01(1.0 - wet_rgb))
|
|
860
|
+
absorb_stain = ti.max(absorb_floor, _clamp_vec3_01(1.0 - stain_rgb))
|
|
861
|
+
|
|
862
|
+
OD_stain = stain_strength * stain_alpha * (absorb_stain + neutral)
|
|
863
|
+
OD_wet = wet_strength * wet_alpha * (absorb_wet + neutral)
|
|
864
|
+
|
|
865
|
+
col = paper
|
|
866
|
+
col = col * ti.exp(-OD_stain)
|
|
867
|
+
col = col * ti.exp(-OD_wet)
|
|
868
|
+
|
|
869
|
+
# Paper texture overlay
|
|
870
|
+
tex = self.T[i, j]
|
|
871
|
+
col = col * (0.94 + 0.06 * tex)
|
|
872
|
+
|
|
873
|
+
# Wetening darkening effect
|
|
874
|
+
col = col * (1.0 - self._wet_darken[None] * _clamp_01(self.W[ping, i, j]))
|
|
875
|
+
|
|
876
|
+
if debug_wet != 0:
|
|
877
|
+
w = self.W[ping, i, j]
|
|
878
|
+
col = ti.Vector([w, w, w])
|
|
879
|
+
|
|
880
|
+
col = ti.max(0.0, ti.min(1.0, col))
|
|
881
|
+
self._img[i, j] = col
|
|
882
|
+
self._img_u8[i, j, 0] = ti.cast(col.x * 255.0, ti.u8)
|
|
883
|
+
self._img_u8[i, j, 1] = ti.cast(col.y * 255.0, ti.u8)
|
|
884
|
+
self._img_u8[i, j, 2] = ti.cast(col.z * 255.0, ti.u8)
|
|
885
|
+
|
|
886
|
+
@ti.kernel
|
|
887
|
+
def _draw_preview_canvas(self, debug_wet: ti.i32):
|
|
888
|
+
"""Low-resolution optimized composition for real-time preview."""
|
|
889
|
+
ping = self._ping[None]
|
|
890
|
+
scale = ti.cast(self.res, ti.f32) / ti.cast(self.preview_res, ti.f32)
|
|
891
|
+
|
|
892
|
+
for i, j in ti.ndrange(self.preview_res, self.preview_res):
|
|
893
|
+
paper = ti.Vector([1.0, 1.0, 1.0])
|
|
894
|
+
|
|
895
|
+
fx = (ti.cast(i, ti.f32) + 0.5) * scale
|
|
896
|
+
fy = (ti.cast(j, ti.f32) + 0.5) * scale
|
|
897
|
+
|
|
898
|
+
p = self._sample_vec4(self.P, fx, fy, ping)
|
|
899
|
+
a = self._sample_vec4_2d(self.A, fx, fy)
|
|
900
|
+
w = self._sample_scalar(self.W, fx, fy, ping)
|
|
901
|
+
|
|
902
|
+
wet_m = p.w
|
|
903
|
+
wet_rgb = p.xyz / (wet_m + 1e-10) if wet_m > 1e-8 else ti.Vector([0.0, 0.0, 0.0])
|
|
904
|
+
|
|
905
|
+
stain_m = a.w
|
|
906
|
+
stain_rgb = a.xyz / (stain_m + 1e-10) if stain_m > 1e-8 else ti.Vector([0.0, 0.0, 0.0])
|
|
907
|
+
|
|
908
|
+
wet_alpha = _clamp_01(wet_m / (self._max_wet_pigment[None] + 1e-6))
|
|
909
|
+
stain_alpha = _clamp_01(stain_m / (self._max_stain_pigment[None] + 1e-6))
|
|
910
|
+
|
|
911
|
+
wet_strength = 0.85
|
|
912
|
+
stain_strength = 0.85 * (0.5 + 2.0 * self._edge_darkening[None])
|
|
913
|
+
|
|
914
|
+
absorb_floor = _clamp_01(self._pigment_absorb_floor[None])
|
|
915
|
+
neutral = _clamp_01(self._pigment_neutral_density[None])
|
|
916
|
+
|
|
917
|
+
absorb_wet = ti.max(absorb_floor, _clamp_vec3_01(1.0 - wet_rgb))
|
|
918
|
+
absorb_stain = ti.max(absorb_floor, _clamp_vec3_01(1.0 - stain_rgb))
|
|
919
|
+
|
|
920
|
+
OD_stain = stain_strength * stain_alpha * (absorb_stain + neutral)
|
|
921
|
+
OD_wet = wet_strength * wet_alpha * (absorb_wet + neutral)
|
|
922
|
+
|
|
923
|
+
col = paper
|
|
924
|
+
col = col * ti.exp(-OD_stain)
|
|
925
|
+
col = col * ti.exp(-OD_wet)
|
|
926
|
+
col = col * (1.0 - self._wet_darken[None] * _clamp_01(w))
|
|
927
|
+
|
|
928
|
+
if debug_wet != 0:
|
|
929
|
+
col = ti.Vector([w, w, w])
|
|
930
|
+
|
|
931
|
+
col = ti.max(0.0, ti.min(1.0, col))
|
|
932
|
+
self._img_preview_u8[i, j, 0] = ti.cast(col.x * 255.0, ti.u8)
|
|
933
|
+
self._img_preview_u8[i, j, 1] = ti.cast(col.y * 255.0, ti.u8)
|
|
934
|
+
self._img_preview_u8[i, j, 2] = ti.cast(col.z * 255.0, ti.u8)
|
|
935
|
+
|
|
936
|
+
@ti.func
|
|
937
|
+
def _sample_vec4_2d(self, f: ti.template(), x: ti.f32, y: ti.f32) -> ti.math.vec4:
|
|
938
|
+
x = ti.max(0.0, ti.min(ti.cast(self.res - 1, ti.f32), x))
|
|
939
|
+
y = ti.max(0.0, ti.min(ti.cast(self.res - 1, ti.f32), y))
|
|
940
|
+
x0 = ti.cast(ti.floor(x), ti.i32)
|
|
941
|
+
y0 = ti.cast(ti.floor(y), ti.i32)
|
|
942
|
+
x1 = ti.min(self.res - 1, x0 + 1)
|
|
943
|
+
y1 = ti.min(self.res - 1, y0 + 1)
|
|
944
|
+
tx = x - ti.cast(x0, ti.f32)
|
|
945
|
+
ty = y - ti.cast(y0, ti.f32)
|
|
946
|
+
|
|
947
|
+
v00 = f[x0, y0]
|
|
948
|
+
v10 = f[x1, y0]
|
|
949
|
+
v01 = f[x0, y1]
|
|
950
|
+
v11 = f[x1, y1]
|
|
951
|
+
|
|
952
|
+
v0 = v00 * (1.0 - tx) + v10 * tx
|
|
953
|
+
v1 = v01 * (1.0 - tx) + v11 * tx
|
|
954
|
+
return v0 * (1.0 - ty) + v1 * ty
|