Repository navigation
Expand file tree
/
Copy pathwarp_fluid.py
More file actions
2271 lines (2064 loc) · 107 KB
/
Copy pathwarp_fluid.py
File metadata and controls
2271 lines (2064 loc) · 107 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""A liquid surface simulated with NVIDIA Warp and rendered by threepp.
Position Based Fluids on the GPU, surfaced with GPU marching cubes, drawn as a
refracting water mesh. A dam break collapses into a basin, then a paddle sweeps
back and forth through it so the water never settles into a still pond.
Nothing crosses host memory in the render path, on either backend: the
marching-cubes triangles and their normals are written straight into the
renderer's own vertex buffers -- GLRenderer.gl_buffer_id + wp.RegisteredGLBuffer
on OpenGL, VulkanRenderer.enable_vertex_interop + threepp.cuda_interop (a CUDA
import of the Vulkan allocation) on Vulkan. Both backends publish the live
triangle count with set_draw_range: the raster draw, the BLAS build and the
interop copies all clamp to it, so capacity beyond the live surface costs
nothing per frame.
pip install warp-lang
python warp_fluid.py # window; drag to orbit, Esc quits
python warp_fluid.py --n 400000 # more particles (a taller column)
python warp_fluid.py --scale 2 # 8x particles at half the spacing, same tank
python warp_fluid.py --shot 6 # headless PNG at t=6s
python warp_fluid.py --bench # timed phase breakdown
python warp_fluid.py --vulkan # Vulkan renderer (RT reflections)
python warp_fluid.py --vulkan --no-interop # ... through pinned host memory
python warp_fluid.py --points # draw particles instead of the surface
python warp_fluid.py --probe 3 # print an energy audit, no window
python warp_fluid.py --iters 4 --rho 0 # plain Jacobi, no Chebyshev acceleration
python warp_fluid.py --obstacle part.stl # collide an arbitrary mesh, not the box
python warp_fluid.py --obstacle part.stl --obstacle-height 0.35 --sdf-res 128
python warp_fluid.py --dump out/s2 --scale 2 --seconds 8 # simulate only, positions to disk
python warp_fluid.py --replay out/s2 --vulkan --video 8 # render a dump, no simulation
--dump runs the simulation with no window and no renderer and writes one file
per frame into the directory: positions quantised to 16 bits against the
frame's own bounding box (0.03 mm over the tank, against a spacing of
millimetres) plus a meta.json with the resolution and step settings. --replay
reads them back in place of sim_step, so every render mode (window, --shot,
--video, --points) works unchanged on a machine that never ran the fluid. The
split exists because the two halves want different hardware: the simulation
scales with GPU memory bandwidth (an H100 holds tens of millions of
particles), while the Vulkan renderer needs VK_KHR_ray_query, which datacenter
GPUs do not expose. Velocities are not dumped, so --points replays with a flat
colour. A replay takes --scale, --n and --obstacle from meta.json.
Needs a CUDA device: the zero-copy surface path is CUDA/OpenGL interop.
--obstacle takes any model threepp's ModelLoader reads (.stl, .obj, .gltf/.glb,
.dae) and collides the fluid against a signed distance field baked from it once
at startup, in place of the analytic paddle box. A STEP/IGES solid has no
triangles of its own, so tessellate it first -- FreeCAD, OpenCascade, CAD
Assistant -- and pass the result. The mesh is auto-fitted (uniform scale, centred
where the box was, resting on the floor), because CAD arrives in millimetres as
often as metres. It sweeps like the paddle did, so the tank keeps circulating.
The mesh needs to be CLOSED for the sign to mean anything: the bake asks for a
winding number, and an open shell has none. An unclosed mesh still repels
particles at its surface, so it looks plausible while being an unsigned field --
check the "closed" note the bake prints.
"""
import atexit
import json
import math
import os
import sys
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
# Make the built `threepp` module (in the parent python/ dir) importable.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import numpy as np
import warp as wp
import threepp as tp
from warp_common import (DensitySurface, Encoder, bench_loop, cli_arg,
open_display, parse_size, pbf_constants,
resize_handler, standard_material,
write_radiance_hdr)
try:
from threepp.cuda_interop import VkInteropArray
except ImportError: # older threepp builds have no CUDA<->Vulkan interop
VkInteropArray = None
# Warp compiles with fast_math off, so every sqrt and divide in the neighbour
# loops is a multi-instruction IEEE sequence instead of one hardware op. Nothing
# in the fluid is subnormal-sensitive (the guards sit at 1e-9 and 1e-12), so the
# approximate forms are safe. Module-scoped: the surfacing kernels are untouched.
wp.set_module_options({"fast_math": True})
# Resolution multiplier. --scale k divides the particle spacing by k and, unless
# --n overrides it, multiplies the count by k^3, so the tank, the dam-break
# column and the paddle keep their geometry and the fluid is simply finer.
# Raising --n alone does something different: the fill is footprint-first
# (nx, nz from the tank, ny = N / (nx nz)), so extra particles at the same
# spacing stack into a TALLER column -- 3M at the default spacing is a 2.5 m
# head of water in a 1.1 m tank, which three Jacobi iterations cannot hold
# (measured on an H100: 150% mean compression, |v| peaking at 440 m/s).
SCALE = cli_arg("--scale", 1.0, float)
# --- the WIDE pool ------------------------------------------------------------
# --scale k refines the SAME tank: D shrinks and the surfacing grid is
# domain/CELL in each axis, so cost grows as k^3 and an 80 GB card runs out of
# int32 in the marching cubes (715,827,882 nodes) before it runs out of memory.
# --wide W does the other thing: it enlarges the tank FOOTPRINT at fixed D.
# Particles grow with AREA and the surfacing grid grows in two of its three
# axes, which is the only direction along which a big GPU buys a visibly bigger
# scene rather than a bigger number in a log. At equal bytes the difference is
# not close: 78 GB of widening is 834 m2 of water at 9 mm; 78 GB of refining is
# 2.31 m2.
#
# And it is the SAFE direction. The comment above about 3M particles boiling is
# about DEPTH, not count: --n is footprint-first, so 3M at D = 9 mm in a 1.1 m
# tank is a 2.5 m tower. PBF's density constraint is local (H = 2D spans one
# particle layer), so the residual follows the hydrostatic column. A pool a
# hundred times wider at the SAME depth is no harder to solve than this one.
WIDE = cli_arg("--wide", 1.0, float) # footprint multiplier, both axes
WIDE_X = cli_arg("--wide-x", WIDE, float) # per-axis overrides; a canal is
WIDE_Z = cli_arg("--wide-z", WIDE, float) # --wide-x 16 --wide-z 1
W_AREA = WIDE_X * WIDE_Z # particles, triangles, surface area
W_LIN = math.sqrt(W_AREA) # camera and sun distance
# --fill flat fills the whole footprint to --depth instead of damming a column
# into 38% of it. A dam break is a 2 m event: at --wide 16 the front travels
# 2*sqrt(g*h) = 3.3 m/s and needs 20 s to cross 33.6 m, and long before it
# arrives the tongue is thinner than the splat width, where water does not thin
# -- it vanishes. A pool that is already full, driven by a full-width piston,
# is the scene that survives being 34 m long.
FLAT = cli_arg("--fill", "dam", str).lower() == "flat"
DEPTH = cli_arg("--depth", 0.27, float) # still water depth (m) under --fill flat
# IS_REF is the promise that nothing changed for anyone who passes no new flag.
# It gates the hash-grid dims, the camera, the look and the surfacing ceiling
# back to their shipped values -- bit-exactly, because bucket assignment fixes
# the neighbour iteration order and therefore the floating-point summation
# order in every kernel. Do not tidy these branches away: they are what keeps
# every previously recorded dump replaying to the same pixels.
IS_REF = (WIDE_X == 1.0 and WIDE_Z == 1.0 and not FLAT)
# WHICH END THE CAMERA STANDS AT, and it decides the whole shot.
# The piston makes a wave that LEAVES at sqrt(g*h) = 1.63 m/s. Standing at the
# piston end (--cam-from near, the first design) the camera watches the event
# depart: over a 14 s run the front travels ~23 m away and the near field goes
# glassy, which is exactly what the first 33.6 m film looked like. Standing at
# the FAR end instead, the front approaches, grows and arrives during the shot,
# and the crossing time becomes the drama instead of the problem.
# --cam-from all is the third option and the default for a wide run: pull back
# off the +x end and elevate until the ENTIRE pool is inside the frame. A low
# eye at the waterline cannot ever show a pool as a pool -- the far end
# compresses to a line and the side walls fall outside the lens -- so if you
# want the footprint legible you have to pay for it with height.
CAM_FROM = cli_arg("--cam-from", "near" if IS_REF else "all", str).lower()
CAM_ALL = CAM_FROM == "all"
CAM_FAR_END = CAM_FROM in ("far", "all")
N = cli_arg("--n", int(round(340_000 * SCALE ** 3 * W_AREA)), int)
# Simulate-only / render-only split (see the module docstring). A replay
# reproduces the dump's fill exactly -- same --scale, same requested --n, same
# obstacle -- because N is baked into the buffers the render paths address.
DUMP = cli_arg("--dump", "", str)
DUMP_SECONDS = cli_arg("--seconds", 6.0, float)
REPLAY = cli_arg("--replay", "", str)
_meta = None
if REPLAY:
with open(os.path.join(REPLAY, "meta.json")) as _f:
_meta = json.load(_f)
SCALE = float(_meta["scale"])
N = int(_meta["n_requested"])
N_REQUESTED = N
BENCH = "--bench" in sys.argv
SHOT = "--shot" in sys.argv
SHOT_TIME = cli_arg("--shot", 6.0, float)
POINTS = "--points" in sys.argv
PROBE = "--probe" in sys.argv
# Vulkan gets ray-traced reflections and a dedicated water/glass shader. Its zero
# copy is enable_vertex_interop: the renderer exports its own position/normal
# allocations, CUDA imports them, and the expand kernel writes them in place.
# The live triangle count is published with set_draw_range exactly as on GL.
VULKAN = "--vulkan" in sys.argv
# --no-interop forces the pinned-host round trip on Vulkan. It is also the
# automatic fallback when the export or the CUDA import does not come up.
INTEROP = "--no-interop" not in sys.argv
# --frames N times the real interactive frame model (canvas.animate_once).
# --bench is headless, where a Vulkan render() call runs several full GPU
# frames, so its render figure is not what a window costs.
FRAMES = cli_arg("--frames", 0, int)
# --video S renders S seconds at 60 fps to warp_fluid.mp4 (or PNG frames if
# ffmpeg is absent). Offline: every sim frame is rendered, so the temporal
# pipeline stays converged; works headless.
VIDEO = cli_arg("--video", 0.0, float)
# --egl forces the display-less EGL context (a cluster GPU node); --no-egl
# forces a window. Default: EGL when Linux has no DISPLAY and one is available,
# so a Slurm job needs no flag and a laptop is unaffected.
EGL = True if "--egl" in sys.argv else (False if "--no-egl" in sys.argv else None)
OPAQUE = "--opaque" in sys.argv # debug: render the surface as a plain lit
# solid, to separate a geometry problem
# from a shading one
# --- fluid parameters ---------------------------------------------------------
D = 0.009 / SCALE # rest particle spacing (m); the single
# resolution knob -- H, WALL_EPS, MAX_DP,
# rest density, CELL and the iso level all
# derive from it
H = 2.0 * D # SPH support radius
MASS = 1.0 # unit mass; rest density is measured from a lattice
DT = 1.0 / 60.0
# A finer spacing needs proportionally shorter substeps: the per-substep travel
# at the tank's ~2-3 m/s must stay around one spacing, or particles skip whole
# neighbour shells between projections. Default scales with --scale.
SUBSTEPS = cli_arg("--substeps", max(3 if FLAT else 2, int(round(2 * SCALE))), int)
# A flat pool gets K = 9 (3 substeps x 3 iterations), not 6. Measured on the
# 4070: --wide 8 settles at 8.32% mean compression with K = 9, and --wide 4 sits
# at 18.15% with K = 6. The first Idun film ran the K = 6 default.
ITERATIONS = cli_arg("--iters", 3, int) # density-constraint projections per substep
# Chebyshev acceleration of the Jacobi projection (Wang 2015). RHO estimates
# the spectral radius of the iteration; 0 disables it (plain Jacobi).
# Iteration 1 runs plain, then omega_2 = 2/(2-rho^2), omega_k = 4/(4 - rho^2 omega_{k-1}).
# Two guards in solve_delta keep it stable on a free surface: particles with
# lambda == 0 are never extrapolated, and the total accelerated step is
# re-clamped to MAX_DP.
RHO_CHEB = cli_arg("--rho", 0.8, float)
OMEGAS = [1.0]
for _k in range(1, max(ITERATIONS, 1)):
_w = (2.0 / (2.0 - RHO_CHEB ** 2) if _k == 1
else 4.0 / (4.0 - RHO_CHEB ** 2 * OMEGAS[-1]))
OMEGAS.append(_w)
S_CORR_N = 4.0 # artificial-pressure exponent (solve_delta spells
# the power out as two multiplies)
S_CORR_DQ = 0.20 * H
JACOBI_RELAX = 0.4 # Jacobi projection over-corrects without this
XSPH_C = cli_arg("--xsph", 0.08, float) # viscosity: how much a particle adopts neighbour flow
VORTICITY = cli_arg("--vorticity", 0.22, float) # curl restored after projection damps it
V_MAX = 5.0 # velocity clamp
MAX_DP = 0.35 * D # per-iteration position-correction bound
GRAVITY = -9.81
# --- tank geometry ------------------------------------------------------------
X0, X1 = -1.05 * WIDE_X, 1.05 * WIDE_X # tank interior; --wide scales the
Z0, Z1 = -0.55 * WIDE_Z, 0.55 * WIDE_Z # FOOTPRINT at fixed spacing D
FLOOR = 0.0
WALL_EPS = 0.6 * D # keep particle centres this far off a wall
# A sloping shore at the +x end. A closed 34 m box is a bathtub: the wave train
# reflects off a vertical wall with no loss and stands, and the whole pool
# turns into one 41 s seiche. A beach shoals the train, breaks it, and gives
# the far end of the shot something that reads as surf rather than as a wall.
BEACH_RUN = cli_arg("--beach", 0.0, float) # metres of shore at +x
BEACH_SLOPE = (DEPTH / BEACH_RUN) if BEACH_RUN > 0.0 else 0.0
BEACH_TOE = X1 - max(BEACH_RUN, 1.0e-6)
# A block on the tank floor for the jet to break over. It must be a FINITE box:
# an unbounded half-space would teleport every particle beneath it onto its
# surface.
if FLAT:
# One 0.27 m paddle cannot stir 591 m2. Under a flat fill the box becomes a
# full-width piston at the -x end: the whole tank width moves, so the wave
# it makes is planar and crosses the pool instead of dispersing.
BX0, BX1 = X0 + 0.10, X0 + 0.55
BZ0, BZ1 = Z0, Z1
BY1 = DEPTH + 0.30
else:
BX0, BX1 = 0.15 * WIDE_X, 0.42 * WIDE_X
BZ0, BZ1 = -0.30 * WIDE_Z, 0.30 * WIDE_Z
BY1 = 0.26 # a HEIGHT, not a footprint: --wide does not deepen the tank
# --obstacle PATH swaps that box for an arbitrary triangle mesh, collided
# through a signed distance field baked once at startup. The tank walls stay
# analytic in both modes -- they are six planes and a clamp is unbeatable; only
# the thing in the middle of the tank is worth a field.
#
# An SDF rather than a per-particle mesh query, because collide() runs
# (1 + ITERATIONS) times per particle per substep: a BVH descent per call is a
# real cost, a trilinear volume fetch is not. The bake pays the mesh query once
# per voxel instead, which is why it can afford the robust winding-number sign.
#
# The mesh arrives through threepp's own ModelLoader and is read back out of the
# BufferGeometry it produced, so .stl/.obj/.gltf/.glb/.dae all work and this file
# carries no parser. Millimetres are the CAD norm and metres are the tank's, so
# the mesh is fitted rather than trusted to arrive in the right units.
OBSTACLE = cli_arg("--obstacle", "", str)
OBSTACLE_H = cli_arg("--obstacle-height", 0.0, float) # 0 = fit the box's envelope
SDF_RES = cli_arg("--sdf-res", 64, int) # voxels on the long axis
if REPLAY and _meta.get("obstacle"):
# The obstacle decides which lattice particles are dropped at seeding, so
# the replay must bake the same field; it is also what the picture shows.
if not OBSTACLE:
OBSTACLE = _meta["obstacle"]
OBSTACLE_H = float(_meta["obstacle_height"])
SDF_RES = int(_meta["sdf_res"])
if not os.path.isfile(OBSTACLE):
raise SystemExit(f"--replay: the dump was made with --obstacle {_meta['obstacle']}; "
f"pass the same file (not found: {OBSTACLE})")
# The dam-break column occupies [X0, FILL_X1] and collapses in the first second.
# After that the paddle keeps the water moving.
FILL_X1 = -0.25 * WIDE_X
if FLAT:
# Amplitude and period must scale together or not at all: their ratio sets
# the wave height and steepness, which is a property of the water, not of
# how much of it there is.
PADDLE_AMP = cli_arg("--paddle-amp", 0.14, float)
PADDLE_PERIOD = cli_arg("--paddle-period", 2.4, float)
else:
PADDLE_AMP = cli_arg("--paddle-amp", 0.46 * WIDE_X, float)
PADDLE_PERIOD = cli_arg("--paddle-period", 2.0 * WIDE_X, float)
# A piston that starts at full stroke puts a step into shallow water. Ramp it.
PADDLE_RAMP = cli_arg("--paddle-ramp", 1.0 if FLAT else 0.0, float)
# --- SPH kernels ----------------------------------------------------------------
PBF = pbf_constants(D, H, MASS, S_CORR_DQ, S_CORR_N)
POLY6, SPIKY = PBF["poly6"], PBF["spiky"]
RHO0, W_DQ, SG2_REST = PBF["rho0"], PBF["w_dq"], PBF["sum_grad2"]
EPS_CFM, S_CORR_K = PBF["eps_cfm"], PBF["s_corr_k"]
@wp.func
def w_poly6(r2: float) -> float:
d = H * H - r2
if d <= 0.0:
return 0.0
return POLY6 * d * d * d
@wp.func
def w_spiky_grad(rv: wp.vec3, r: float) -> wp.vec3:
if r <= 1.0e-9 or r >= H:
return wp.vec3(0.0, 0.0, 0.0)
return rv * (SPIKY * (H - r) * (H - r) / r)
@wp.func
def collide(p: wp.vec3, bx0: float, bx1: float, vol: wp.uint64, ox: float,
blo: wp.vec3, bhi: wp.vec3) -> wp.vec3:
# The tank is six planes, so a clamp is the whole of it in both modes.
x = wp.min(wp.max(p[0], X0 + WALL_EPS), X1 - WALL_EPS)
z = wp.min(wp.max(p[2], Z0 + WALL_EPS), Z1 - WALL_EPS)
y = wp.max(p[1], FLOOR + WALL_EPS)
if BEACH_SLOPE > 0.0:
# The shore is a plane, so it is a clamp like every other wall here.
y = wp.max(y, FLOOR + WALL_EPS + (x - BEACH_TOE) * BEACH_SLOPE)
if vol != wp.uint64(0):
# --obstacle: one trilinear fetch yields distance AND gradient. The field
# is baked in the mesh's REST frame, so the paddle sweep is undone on the
# query point rather than re-baked -- a rigid translation is a subtract.
# vol == 0 is the box path below; the branch is uniform across the launch,
# so it costs no divergence.
q = wp.vec3(x - ox, y, z)
# Reject against the field's own bounds before sampling it: a NanoVDB
# fetch is a sparse-tree descent, not a texture read, and most particles
# are nowhere near the obstacle. Outside the grid the sample returns
# bg_value anyway, so the reject changes no result.
if (q[0] > blo[0] and q[0] < bhi[0] and q[1] < bhi[1]
and q[2] > blo[2] and q[2] < bhi[2]):
g = wp.vec3()
d = wp.volume_sample_grad_f(vol, wp.volume_world_to_index(vol, q),
wp.Volume.LINEAR, g)
if d < WALL_EPS:
gl = wp.length(g)
if gl > 1.0e-8:
# Push out along the surface normal to the same standoff the
# box path keeps. The gradient comes back in INDEX space, but
# the voxels are cubic, so normalising recovers the world
# direction.
q = q + g * ((WALL_EPS - d) / gl)
# Re-clamp to the tank. A particle wedged between the mesh and
# a wall would otherwise be pushed straight through the wall,
# and leaked fluid never comes back; ending up slightly inside
# the obstacle is recoverable, because the next iteration
# pushes out again. Deliberately the lesser of two failures.
x = wp.min(wp.max(q[0] + ox, X0 + WALL_EPS), X1 - WALL_EPS)
y = wp.max(q[1], FLOOR + WALL_EPS)
z = wp.min(wp.max(q[2], Z0 + WALL_EPS), Z1 - WALL_EPS)
return wp.vec3(x, y, z)
# obstacle box: if inside, eject through the face of least penetration
ax0 = bx0 - WALL_EPS
ax1 = bx1 + WALL_EPS
az0 = BZ0 - WALL_EPS
az1 = BZ1 + WALL_EPS
ay1 = BY1 + WALL_EPS
if x > ax0 and x < ax1 and z > az0 and z < az1 and y < ay1:
dxm = x - ax0
dxp = ax1 - x
dzm = z - az0
dzp = az1 - z
dyp = ay1 - y
m = wp.min(wp.min(wp.min(dxm, dxp), wp.min(dzm, dzp)), dyp)
if m == dyp:
y = ay1
elif m == dxm:
x = ax0
elif m == dxp:
x = ax1
elif m == dzm:
z = az0
else:
z = az1
return wp.vec3(x, y, z)
@wp.kernel
def predict(x: wp.array(dtype=wp.vec3),
v: wp.array(dtype=wp.vec3),
xp: wp.array(dtype=wp.vec3),
dt: float, bx0: float, bx1: float, vol: wp.uint64, ox: float,
blo: wp.vec3, bhi: wp.vec3):
i = wp.tid()
vi = v[i] + wp.vec3(0.0, GRAVITY, 0.0) * dt
sp = wp.length(vi)
if sp > V_MAX:
vi = vi * (V_MAX / sp)
v[i] = vi
xp[i] = collide(x[i] + vi * dt, bx0, bx1, vol, ox, blo, bhi)
@wp.kernel
def solve_lambda(xp: wp.array(dtype=wp.vec3),
grid: wp.uint64,
rho0: float,
eps_cfm: float,
lam: wp.array(dtype=float)):
# spatially-sorted thread order: neighbour gathers hit cache
i = wp.hash_grid_point_id(grid, wp.tid())
if i < 0:
return
p = xp[i]
rho = float(0.0)
grad_i = wp.vec3(0.0, 0.0, 0.0)
sum_grad2 = float(0.0)
q = wp.hash_grid_query(grid, p, H)
for j in q:
rv = p - xp[j]
r2 = wp.dot(rv, rv)
if r2 < H * H:
rho += MASS * w_poly6(r2)
g = w_spiky_grad(rv, wp.sqrt(r2)) * (MASS / rho0)
grad_i += g
sum_grad2 += wp.dot(g, g)
sum_grad2 += wp.dot(grad_i, grad_i)
# Resist compression only. A free-surface particle is under-dense by
# definition; letting it produce a negative constraint gives it a large
# lambda whose neighbour gradients are all one-sided (nothing cancels), and
# the surface implodes. Cohesion is s_corr's job.
c = wp.max(rho / rho0 - 1.0, 0.0)
lam[i] = -c / (sum_grad2 + eps_cfm)
@wp.kernel
def solve_delta(xp: wp.array(dtype=wp.vec3),
lam: wp.array(dtype=float),
grid: wp.uint64,
rho0: float,
w_dq: float,
k_corr: float,
relax: float,
omega: float,
prev: wp.array(dtype=wp.vec3),
bx0: float,
bx1: float,
vol: wp.uint64,
ox: float,
blo: wp.vec3,
bhi: wp.vec3,
out: wp.array(dtype=wp.vec3)):
# spatially-sorted thread order: neighbour gathers hit cache
i = wp.hash_grid_point_id(grid, wp.tid())
if i < 0:
return
p = xp[i]
li = lam[i]
w_dq_inv = 1.0 / w_dq # hoisted: one divide per thread, not per neighbour
dp = wp.vec3(0.0, 0.0, 0.0)
q = wp.hash_grid_query(grid, p, H)
for j in q:
rv = p - xp[j]
r2 = wp.dot(rv, rv)
if r2 < H * H and r2 > 1.0e-12:
# Artificial pressure repels near-coincident particles, which is
# what keeps droplets round instead of stringy. S_CORR_N == 4, so
# the power is two multiplies in the hottest loop of the sim.
qq = w_poly6(r2) * w_dq_inv
q2 = qq * qq
s_corr = -k_corr * (q2 * q2)
dp += w_spiky_grad(rv, wp.sqrt(r2)) * (li + lam[j] + s_corr)
d = dp * (MASS / rho0 * relax)
dl = wp.length(d)
if dl > MAX_DP:
d = d * (MAX_DP / dl)
# Chebyshev semi-iteration (Wang 2015): extrapolate the relaxed Jacobi
# result through the PREVIOUS iterate. omega == 1 is plain Jacobi, kept as
# its own branch. collide() runs after the extrapolation so momentum never
# carries a particle through a wall.
if omega == 1.0 or li == 0.0:
# li == 0 means a free-surface particle: its one-sided constraint is
# satisfied, so it has nothing to converge to and the extrapolation
# below would be a pure kinetic kick into the surface.
out[i] = collide(p + d, bx0, bx1, vol, ox, blo, bhi)
else:
# Re-clamp the TOTAL move (Jacobi step + extrapolation) to MAX_DP so an
# accelerated iterate never travels farther than plain Jacobi allows;
# the acceleration survives as a better direction, not a bigger step.
e = prev[i] + (p + d - prev[i]) * omega - p
el = wp.length(e)
if el > MAX_DP:
e = e * (MAX_DP / el)
out[i] = collide(p + e, bx0, bx1, vol, ox, blo, bhi)
@wp.kernel
def finalize(x: wp.array(dtype=wp.vec3),
xp: wp.array(dtype=wp.vec3),
v: wp.array(dtype=wp.vec3),
dt: float):
i = wp.tid()
vi = (xp[i] - x[i]) * (1.0 / dt)
sp = wp.length(vi)
if sp > V_MAX:
vi = vi * (V_MAX / sp)
v[i] = vi
x[i] = xp[i]
@wp.kernel
def curl_and_viscosity(x: wp.array(dtype=wp.vec3),
v: wp.array(dtype=wp.vec3),
grid: wp.uint64,
rho0: float,
omega: wp.array(dtype=wp.vec3),
v_out: wp.array(dtype=wp.vec3)):
# spatially-sorted thread order: neighbour gathers hit cache
i = wp.hash_grid_point_id(grid, wp.tid())
if i < 0:
return
p = x[i]
vi = v[i]
w = wp.vec3(0.0, 0.0, 0.0)
dv = wp.vec3(0.0, 0.0, 0.0)
q = wp.hash_grid_query(grid, p, H)
for j in q:
rv = p - x[j]
r2 = wp.dot(rv, rv)
if r2 < H * H:
vij = v[j] - vi
w += wp.cross(vij, w_spiky_grad(rv, wp.sqrt(r2)))
dv += vij * w_poly6(r2)
omega[i] = w * (MASS / rho0)
v_out[i] = vi + dv * (XSPH_C * MASS / rho0)
@wp.kernel
def vorticity_confine(x: wp.array(dtype=wp.vec3),
omega: wp.array(dtype=wp.vec3),
v_in: wp.array(dtype=wp.vec3),
grid: wp.uint64,
dt: float,
v: wp.array(dtype=wp.vec3)):
# spatially-sorted thread order: neighbour gathers hit cache
i = wp.hash_grid_point_id(grid, wp.tid())
if i < 0:
return
p = x[i]
# grad|omega| points at the vortex core; the restoring force is N x omega
eta = wp.vec3(0.0, 0.0, 0.0)
q = wp.hash_grid_query(grid, p, H)
for j in q:
rv = p - x[j]
r2 = wp.dot(rv, rv)
if r2 < H * H and r2 > 1.0e-12:
eta += w_spiky_grad(rv, wp.sqrt(r2)) * wp.length(omega[j])
vi = v_in[i]
le = wp.length(eta)
if le > 1.0e-6:
vi = vi + wp.cross(eta * (1.0 / le), omega[i]) * (VORTICITY * dt)
v[i] = vi
@wp.kernel
def measure_compression(x: wp.array(dtype=wp.vec3),
grid: wp.uint64,
rho0: float,
err: wp.array(dtype=float)):
# Post-solve residual of the density constraint (positive part only, the
# same one-sided form the solver projects). This is the convergence metric
# --probe reports, and what makes solver changes comparable.
i = wp.hash_grid_point_id(grid, wp.tid())
if i < 0:
return
p = x[i]
rho = float(0.0)
q = wp.hash_grid_query(grid, p, H)
for j in q:
rv = p - x[j]
r2 = wp.dot(rv, rv)
if r2 < H * H:
rho += MASS * w_poly6(r2)
err[i] = wp.max(rho / rho0 - 1.0, 0.0)
@wp.kernel
def shade_points(v: wp.array(dtype=wp.vec3), col: wp.array(dtype=wp.vec3)):
i = wp.tid()
f = wp.min(wp.length(v[i]) / 2.6, 1.0)
col[i] = wp.vec3(0.05, 0.28, 0.42) * (1.0 - f) + wp.vec3(0.92, 0.97, 1.0) * f
# --- surfacing: particles -> density grid -> marching cubes -------------------
CELL = cli_arg("--cell", 1.00 if IS_REF else 1.5, float) * D # surface grid spacing (then blurred). Marching
# cubes cannot emit a sheet thinner than roughly
# the combined splat+blur kernel width -- thinner
# water does not thin, it VANISHES -- so the cell
# is fine and the second blur round is narrowed,
# at the cost of more triangles.
# That argument is the shipped dam break seen from 1.9 m, where a cell spans
# several pixels. A wide shot turns it upside down: at --wide 8 from 16 m a
# 9 mm cell is a fifth of a pixel, the thin tongue it protects cannot be seen,
# and what CELL = D actually buys is ONE particle per cell -- a density field
# made of shot noise, which the gradient normals turn into glints that re-roll
# every frame. The first Idun film was twenty seconds of that. 1.5 D holds
# ~3.4 particles per cell for 3.4x fewer nodes; --normal-blur does the rest.
GX0, GY0, GZ0 = X0 - 0.05, FLOOR - 0.035, Z0 - 0.05
# The shipped ceiling was 0.56 m with the comment "paddle spray never gets near
# this". It does: a --probe 3 run of the shipped scene measures y_max = 0.896 m
# at t = 1.20 s. A particle above the top plane deposits NO density at all
# (warp_common._splat returns early), so it vanishes from the surface and pops
# back on the way down, and marching cubes leaves the surface open up there.
# IS_REF keeps the old value so recorded dumps still replay identically;
# everything else gets a ceiling the water cannot reach.
GRID_TOP = cli_arg("--grid-top", 0.56 if IS_REF else DEPTH + 0.95, float)
NGX = int((X1 + 0.05 - GX0) / CELL) + 1
NGY = int((GRID_TOP - GY0) / CELL) + 1
NGZ = int((Z1 + 0.05 - GZ0) / CELL) + 1
if 3 * NGX * NGY * NGZ >= 2 ** 31:
# warp's marching cubes indexes nodes as ti*ny*nz*3 + tj*nz*3 + tk*3 + side
# in int32 and allocates wp.zeros(nx*ny*nz*3), so past 715,827,882 nodes it
# raises a ValueError from deep inside check_array_shape. Say it here, in
# terms of the flags that caused it.
raise SystemExit(
f"surfacing grid {NGX}x{NGY}x{NGZ} = {NGX * NGY * NGZ:,} nodes exceeds warp's "
f"int32 marching-cubes limit (715,827,882). Raise --cell (nodes fall as "
f"1/cell^3) or lower --grid-top.")
# --- the --obstacle mesh ------------------------------------------------------
def load_obstacle_mesh(path):
"""Anything ModelLoader handles -> (M, 3, 3) float32 triangles, world space.
The triangles are read back out of the loaded BufferGeometry rather than
re-parsed here, so every format the library already supports arrives the
same way -- .stl, .obj, .gltf/.glb, .dae -- instead of this example growing
a parser per format. A multi-part assembly comes through as the assembly:
each node's world matrix is baked in, so a .glb's nested parts land where
they belong instead of collapsing onto the origin.
A STEP/IGES solid carries no triangles of its own -- it is NURBS and
topology -- so tessellate it first (FreeCAD, OpenCascade, CAD Assistant)
and pass what that writes out.
"""
root = tp.ModelLoader().load(path)
if root is None:
raise SystemExit(f"--obstacle: threepp could not load {path}")
root.update_matrix_world(True)
parts = []
def visit(node):
# Meshes only. Points and Line carry a geometry too, and their
# "triangles" would be nonsense read three vertices at a time.
if not isinstance(node, tp.Mesh):
return
pos = node.geometry.get_attribute("position")
if pos is None or len(pos) < 3:
return
idx = node.geometry.get_index()
v = pos if idx is None else pos[idx] # STL is already a soup
m = node.matrix_world.to_numpy()
parts.append((v @ m[:3, :3].T + m[:3, 3]).reshape(-1, 3, 3))
root.traverse(visit)
if not parts:
raise SystemExit(f"--obstacle: {path} loaded but carries no triangles")
return np.concatenate(parts).astype(np.float32)
def fit_obstacle(tris, target_h):
"""Scale/place a mesh so it sits on the tank floor where the box was.
CAD arrives in millimetres about as often as metres, and a STEP export is
positioned wherever the assembly origin happened to be -- so fit rather than
trust. Uniform scale, always: an SDF is only a distance field under a rigid
plus uniform map, so anisotropic scaling would make every baked distance lie.
The default matches the box's HEIGHT, since "as tall as the block it
replaces" is what puts the obstacle in the water rather than under it -- but
with the footprint clamped, because height alone is wrong for anything flat:
scaled up to the box's height a flat part spans the tank wall to wall. The
clamp keeps the sweep inside the tank in x and leaves the water somewhere
to go in z.
"""
flat = tris.reshape(-1, 3)
lo, hi = flat.min(axis=0), flat.max(axis=0)
size = np.maximum(hi - lo, 1e-9)
if target_h > 0.0:
s = float(target_h / size[1]) # explicit: the caller's business
else:
s = float(min(BY1 / size[1], # as tall as the box
(0.9 * (X1 - X0) - 2.0 * PADDLE_AMP) / size[0], # sweep stays inside
0.8 * (Z1 - Z0) / size[2])) # leave a flow gap
out = (flat - lo) * s
out[:, 0] += 0.5 * (BX0 + BX1) - 0.5 * size[0] * s # centred in x on the box
out[:, 2] += 0.5 * (BZ0 + BZ1) - 0.5 * size[2] * s # ... and in z
out[:, 1] += FLOOR # resting on the floor
return out.reshape(-1, 3, 3).astype(np.float32), s, size
@wp.kernel
def sample_sdf_kernel(vol: wp.uint64, pts: wp.array(dtype=wp.vec3),
out: wp.array(dtype=float)):
i = wp.tid()
out[i] = wp.volume_sample_f(vol, wp.volume_world_to_index(vol, pts[i]),
wp.Volume.LINEAR)
@wp.kernel
def bake_sdf_kernel(mid: wp.uint64, out: wp.array3d(dtype=float),
lo: wp.vec3, h: float, far: float):
i, j, k = wp.tid()
p = lo + wp.vec3(float(i) * h, float(j) * h, float(k) * h)
# Winding-number sign, not the pseudonormal one: a tessellated CAD solid is
# a triangle SOUP with duplicated vertices and no adjacency, and it is
# routinely a little bit open at the seams. The winding number needs neither
# adjacency nor watertightness to get inside/outside right. It is the
# expensive query, which is affordable precisely because this runs once.
q = wp.mesh_query_point_sign_winding_number(mid, p, far)
if q.result:
out[i, j, k] = q.sign * wp.length(p - wp.mesh_eval_position(mid, q.face, q.u, q.v))
else:
out[i, j, k] = far
def bake_sdf(tris, res, dev):
"""Mesh -> NanoVDB signed distance volume, in the mesh's rest frame."""
flat = tris.reshape(-1, 3)
lo, hi = flat.min(axis=0), flat.max(axis=0)
voxel = float(max(hi - lo) / max(res, 8))
# Pad so the field carries a usable band OUTSIDE the surface: collide() only
# acts within WALL_EPS of it, but the gradient at that distance must be real
# and not clamped against the grid edge.
pad = max(4.0 * voxel, 2.0 * WALL_EPS)
g_lo = lo - pad
dims = np.maximum(np.ceil((hi + pad - g_lo) / voxel).astype(int) + 1, 2)
far = float(np.linalg.norm(hi - lo) + pad)
mesh = wp.Mesh(points=wp.array(flat, dtype=wp.vec3, device=dev),
indices=wp.array(np.arange(len(flat), dtype=np.int32), device=dev),
support_winding_number=True)
grid = wp.zeros(tuple(dims), dtype=float, device=dev)
wp.launch(bake_sdf_kernel, dim=tuple(dims), device=dev,
inputs=[mesh.id, grid, wp.vec3(*g_lo.tolist()), voxel, far])
# bg_value keeps a query that falls outside the grid reading as "far
# outside", so a particle that leaves the padded band simply never collides
# rather than sampling garbage.
field = grid.numpy()
# A closed mesh has an interior, so the field must go negative somewhere. If
# it never does, the winding number found nothing to be inside of -- an open
# shell, or a surface with inconsistent facet winding. Worth saying out loud
# because the failure is quiet: an UNSIGNED field still repels particles at
# the surface, so the fluid looks right while nothing can ever be pushed back
# OUT of the obstacle.
closed = bool((field < 0.0).any())
vol = wp.Volume.load_from_numpy(field, min_world=tuple(g_lo.tolist()),
voxel_size=voxel, bg_value=far)
g_hi = g_lo + (dims - 1) * voxel
return vol, voxel, dims, mesh, g_lo, g_hi, closed
# --- particle initialisation --------------------------------------------------
wp.init()
device = wp.get_preferred_device()
obstacle_tris = None
obstacle_vol = None
OBSTACLE_VOL_ID = wp.uint64(0)
OBSTACLE_LO = wp.vec3(0.0, 0.0, 0.0) # unused on the box path
OBSTACLE_HI = wp.vec3(0.0, 0.0, 0.0)
if OBSTACLE:
if not os.path.exists(OBSTACLE):
raise SystemExit(f"--obstacle: no such file: {OBSTACLE}")
_raw_tris = load_obstacle_mesh(OBSTACLE)
obstacle_tris, _fit_s, _raw_size = fit_obstacle(_raw_tris, OBSTACLE_H)
_t0 = time.perf_counter()
obstacle_vol, _vox, _dims, _obstacle_mesh, _glo, _ghi, _closed = bake_sdf(
obstacle_tris, SDF_RES, device)
OBSTACLE_VOL_ID = obstacle_vol.id
OBSTACLE_LO = wp.vec3(*_glo.tolist())
OBSTACLE_HI = wp.vec3(*_ghi.tolist())
wp.synchronize_device(device)
_fit_size = _raw_size * _fit_s
print(f"obstacle: {os.path.basename(OBSTACLE)} {len(_raw_tris):,} tris "
f"raw {_raw_size[0]:.4g} x {_raw_size[1]:.4g} x {_raw_size[2]:.4g} "
f"-> x{_fit_s:.4g} -> {_fit_size[0]:.3f} x {_fit_size[1]:.3f} x "
f"{_fit_size[2]:.3f} m")
print(f" sdf {_dims[0]}x{_dims[1]}x{_dims[2]} @ {_vox * 1000:.2f} mm/voxel, "
f"baked in {(time.perf_counter() - _t0) * 1000:.0f} ms, "
f"{'closed (signed)' if _closed else 'NOT CLOSED -- unsigned'}")
if not _closed:
print(" warning: no interior found, so the field cannot push a particle")
print(" back OUT of the mesh. Surface repulsion still works.")
nz = int((Z1 - Z0 - 2.0 * WALL_EPS) / D)
if FLAT:
# DEPTH sets ny, not N: a flat pool is defined by how deep the water is,
# and the particle count follows from the footprint. Starting clear of the
# piston keeps the first frame from resolving an interpenetration.
FILL_X0 = BX1 + WALL_EPS
nx = int((X1 - WALL_EPS - FILL_X0) / D)
ny = max(1, int(round(DEPTH / D)))
else:
FILL_X0 = X0 + WALL_EPS
nx = int((FILL_X1 - X0 - 2.0 * WALL_EPS) / D)
ny = max(1, N // (nx * nz))
def seed_lattice(nx, ny, nz, chunk=1 << 22):
"""The fill lattice, in chunks, straight into float32.
np.meshgrid + np.stack builds three int64 index grids and a float64
intermediate before the cast, which is ~84 bytes per lattice point: 17 GB
of HOST memory at 200M particles, single-threaded, for data that ends up as
12 bytes on the GPU. That OOMs a compute node before the GPU is touched.
Chunking writes the answer directly and peaks at a few hundred MB.
"""
m = nx * ny * nz
out = np.empty((m, 3), np.float32)
for lo in range(0, m, chunk):
hi = min(lo + chunk, m)
i = np.arange(lo, hi, dtype=np.int64)
ix, r = np.divmod(i, ny * nz)
iy, iz = np.divmod(r, nz)
out[lo:hi, 0] = FILL_X0 + (ix + 0.5) * D
out[lo:hi, 1] = FLOOR + WALL_EPS + (iy + 0.5) * D
out[lo:hi, 2] = Z0 + WALL_EPS + (iz + 0.5) * D
return out
p0 = seed_lattice(nx, ny, nz)
if BEACH_SLOPE > 0.0:
# Drop what the shore already occupies, before it is ever a particle.
p0 = p0[p0[:, 1] >= FLOOR + WALL_EPS + (p0[:, 0] - BEACH_TOE) * BEACH_SLOPE]
# drop the particles that would start inside the obstacle. Seeding happens at
# t = 0, where the paddle offset is sin(0) == 0, so the rest frame the field was
# baked in IS the world frame here -- no offset to undo.
if obstacle_vol is not None:
_q = wp.array(p0, dtype=wp.vec3, device=device)
_d = wp.zeros(len(p0), dtype=float, device=device)
wp.launch(sample_sdf_kernel, dim=len(p0), device=device,
inputs=[OBSTACLE_VOL_ID, _q, _d])
keep = _d.numpy() >= WALL_EPS
else:
keep = ~((p0[:, 0] > BX0 - WALL_EPS) & (p0[:, 0] < BX1 + WALL_EPS) &
(p0[:, 2] > BZ0 - WALL_EPS) & (p0[:, 2] < BZ1 + WALL_EPS) &
(p0[:, 1] < BY1 + WALL_EPS))
p0 = p0[keep]
rng = np.random.default_rng(17)
p0 = (p0 + rng.uniform(-0.06 * D, 0.06 * D, p0.shape)).astype(np.float32)
N = len(p0)
if REPLAY and N != int(_meta["n"]):
raise SystemExit(f"--replay: the fill produced {N:,} particles but the dump holds "
f"{int(_meta['n']):,}; the obstacle or the tank geometry differs "
f"from the run that made it")
# A cell fully inside the fluid collects (CELL/D)^3 particles; the surface sits
# near half of that, so the iso-threshold follows the resolution automatically.
ISO = 0.5 * (CELL / D) ** 3
# wp.MarchingCubes winds triangles so the CCW-implied normal points INTO the
# density, i.e. opposite the outward -grad normal DensitySurface computes. Both
# backends do `normal *= faceDirection` for a Side.Double material, so shipping
# un-negated normals leaves the water's shading normal pointing DOWN. That does
# not just darken the diffuse term -- it disables transmission: getIBLVolumeRefraction
# refracts along the normal, so the refracted ray goes UP into the sky, samples
# the backdrop above the horizon and returns nothing. Measured: with a bright RED
# pool floor and transmission 1.0, the water showed exactly zero red.
# Measured 2026-09-09: flipping this changes the shading (the water darkens)
# but does NOT restore transmission, so the winding/normal question is real
# but is NOT the see-through bug. Default left at the shipped value; the
# flag stays so the A/B can be repeated.
MC_SIGN = cli_arg("--mc-sign", 1.0, float)
# Reverse the marching-cubes winding so the water's visible surface is
# FRONT-facing. Without this a Side.Double transmissive material silently
# renders with transmissionFactor == 0 (see DensitySurface.expand).
# Reverse the marching-cubes winding so the water's visible surface is
# FRONT-facing. wp.MarchingCubes winds triangles INTO the density, and the
# deferred renderer traces the geometry to measure the glass chord through the
# water -- with the winding inverted it cannot tell inside from outside, the
# chord measures ~0, Beer-Lambert contributes nothing, and the pool renders as
# colourless quicksilver from above while staying turquoise from below.
# Flipping it restores the tropical colour and leaves every non-water surface
# untouched. With the winding corrected the shipped -grad normals are already
# aligned, so MC_SIGN stays 1.0.
MC_FLIP = bool(cli_arg("--mc-flip", 1, int))
# The default followed SCALE**2 and knew nothing about the footprint, so a
# widened run kept the 700k of a 2 m tank against tens of millions of live
# triangles. The clamp is safe (build_surface bounds it, _expand parks the
# overflow and set_draw_range clamps the draw) but it truncates x-major -- it
# cuts the pool off as a slab at the far end, which is exactly where a wide
# shot is looking. The 6.0 is the measured triangles-per-area-per-cell^2 law
# (4.7) with headroom.
_AREA = (X1 - X0) * (Z1 - Z0)
MAX_TRIS = cli_arg("--max-tris",
int(700_000 * SCALE ** 2) if IS_REF
else int(6.0 * _AREA / CELL ** 2), int)
x = wp.array(p0, dtype=wp.vec3, device=device)
v = wp.zeros(N, dtype=wp.vec3, device=device)
xp = wp.zeros(N, dtype=wp.vec3, device=device)
xtmp = wp.zeros(N, dtype=wp.vec3, device=device)
xprev = wp.zeros(N, dtype=wp.vec3, device=device) # Chebyshev's k-1 iterate
lam = wp.zeros(N, dtype=float, device=device)
omega = wp.zeros(N, dtype=wp.vec3, device=device)
vtmp = wp.zeros(N, dtype=wp.vec3, device=device)
col = wp.zeros(N, dtype=wp.vec3, device=device)
# wp.HashGrid(128,128,128) is not a capacity, it is a PERIODIC TILE: warp folds
# world cells with x % dim_x, so at H = 18 mm cells the shipped grid tiles every
# 2.304 m. The tank outgrows it at --wide 1.10, and at --wide 16 it folds 15:1
# in x and 8:1 in z. Nothing breaks and nothing is reported -- every neighbour
# loop re-tests r2 < H*H, so aliased candidates are distance-rejected -- it just
# costs ~25x in the kernels that are already most of the frame.
GRID_CELLS = cli_arg("--grid-cells", 1 << 28, int) # bucket budget (2.0 GB)
def hash_grid_dims():
"""Bucket dims that cover the tank at the neighbour radius."""
if IS_REF and SCALE == 1.0:
# The shipped grid, kept exactly. Bucket assignment fixes the sort
# order of hash_grid_point_id, which fixes the summation order in every
# neighbour loop, so this is what makes a reference run bit-identical
# rather than merely equivalent. Do not tidy it away: it is what keeps
# old dumps replaying to the same pixels.
return [128, 128, 128]
d = [max(8, math.ceil(s / H) + 2)
for s in (X1 - X0, GRID_TOP - FLOOR, Z1 - Z0)]
while d[0] * d[1] * d[2] > GRID_CELLS:
# Fold Y first: the fluid is a shallow layer in a tall box, so a folded
# Y bucket collides occupied cells against EMPTY air. X and Z are
# densely occupied across the whole floor, where folding collides
# occupied against occupied -- the expensive kind.
if d[1] > 16:
d[1] = max(16, d[1] // 2)
else:
d[0 if d[0] >= d[2] else 2] //= 2
return d
GDX, GDY, GDZ = hash_grid_dims()
grid = wp.HashGrid(GDX, GDY, GDZ, device)
grid.reserve(N)
print(f"fluid: {N:,} particles on {device} | pool {X1 - X0:.2f} x {Z1 - Z0:.2f} m "
f"x {ny * D:.3f} m deep (--wide {WIDE_X:g}x{WIDE_Z:g}, "
f"{'flat' if FLAT else 'dam'}"
f"{f', beach {BEACH_RUN:g} m' if BEACH_RUN else ''})\n"
f" surface grid {NGX}x{NGY}x{NGZ} = {NGX * NGY * NGZ / 1e6:.1f}M nodes "
f"@ {CELL * 1000:.1f} mm, top {GRID_TOP:.2f} m | iso={ISO:.2f} | "
f"max_tris {MAX_TRIS:,}\n"
f" hash grid {GDX}x{GDY}x{GDZ} = {GDX * GDY * GDZ / 1e6:.1f}M buckets "
f"@ {H * 1000:.1f} mm cells, ~{N / 8e6:.1f}M occupied\n"
f" rho0={RHO0:.4g} sum_grad2={SG2_REST:.4g} "
f"eps={EPS_CFM:.3g} k_corr={S_CORR_K:.3g} K={SUBSTEPS * ITERATIONS}")
# Round one of the blur stays binomial so grid-aligned noise is killed
# outright; round two is narrowed, which buys thinness without a new noise
# source.
# --normal-blur N: N more binomial rounds of the field AFTER marching cubes, so
# the triangles keep the detail above and only the shading normals see the
# wider kernel. In a wide shot the grain is in the NORMAL, not the geometry --
# the relief is sub-pixel, the tilt is not -- and a post-extraction blur cannot
# thin a sheet, because the sheet is already out. Off for the reference, which
# stays bit-identical. Measured with --tilt at the film's own 9 mm spacing and
# K = 6, normal noise against the wave's tilt: --cell 1.0 unblurred (the first
# Idun film) 0.303 rad vs 0.154, SNR 0.51; --cell 1.5 alone 0.137, SNR 0.93;
# +4 rounds 0.046, SNR 2.0; +8 rounds 0.028, SNR 3.0. Eight is the default
# because a cluster shot sits further away than any preview and has no MSAA.
NORMAL_BLUR = cli_arg("--normal-blur", 0 if IS_REF else 8, int)
surface = DensitySurface((GX0, GY0, GZ0), CELL, (NGX, NGY, NGZ), device,
blur=(0.25, 0.125), normal_blur=NORMAL_BLUR)
sim_time = 0.0
frame_no = 0
def paddle_offset(t):
"""Sweep displacement of the paddle along x at time t."""
a = PADDLE_AMP
if PADDLE_RAMP > 0.0:
# A piston at full stroke from rest is a step function into 0.27 m of
# water: the first half-cycle makes a bore, not a wave.
a *= min(1.0, t / PADDLE_RAMP)
return a * math.sin(2.0 * math.pi * t / PADDLE_PERIOD)
def paddle_extent(t):