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.
@@ -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