Nodes/ComfyUI-Darkroom/Water Refraction
ComfyUI Node

Water Refraction

Water over a screen, simulated as actual water — and why it's slow on purpose

By jeremieLouvaert·Created 6 months ago·Updated 11 days ago· 107
Water Refraction
  • image
  • mask
  • image
  • grain_deficit
field_width_mm40.0
surfacestanding pool (heavier, whole frame)
water_ml10.0
pour_sweep1.00
sweep_angle45
sample_ms150
settle_ms0
depth_scale1.00
aperture0.020
seed0
mask_min0.15
mask_gamma2.00
sim_resolution0
aperture_samples32
env_strength1.00
dispersionfalse
grain_restore1.00
vary_per_framefalse

This is the slowest node in the Darkroom pack - around 45 seconds at 1024 - and it earns every one of them. Water Refraction doesn't displace your image through a noise texture. It runs a depth-averaged fluid simulation, reads the free surface h(x,y) from it, and refracts the image through that surface with exact Snell's law. The water is actually water. That's why it can do things a displacement filter physically cannot: it folds.

Snell's law is where the whole character comes from. Refraction displaces at most 0.881× the water depth, because the transmitted angle saturates at the 48.6° critical angle. That bound is why field_width_mm is the most important control on the node - the tooltip is blunt about it. Frame a whole tablet (250mm) and you correctly get almost nothing. Frame 20–50mm of screen, and the reference look appears. This effect is inherently macro; fighting that is how people end up disappointed.

Where the two big levers are:

  • field_width_mm - how wide a patch of screen the frame covers, in mm. THE control. 20–50mm is the sweet spot.
  • surface - standing pool pours into water already there, so the disturbance reaches the whole frame and it's heavier and more liquid. dry screen only disturbs where the pour landed, leaving large areas untouched and the subject readable. The tooltip's right that these are two looks, not two ends of a dial.

Then water_ml is your intensity dial (more water = deeper = more displacement, and it shrinks the undistorted area), pour_sweep is really a concentration control (low values pile one deep mound for heaviest distortion, high values spread a gentler disturbance - measured folding runs 39% at 0 down to 24% at 1.0), sample_ms is when the shutter fires, and settle_ms lets the surface calm first (viscous damping kills a 2.4mm ripple in ~72ms but a 12mm one takes ~1.8s, so it removes chop and keeps the big forms). depth_scale is the honest artistic cheat: multiplying the depth while keeping the optics exact.

The physics knobs you shouldn't zero. aperture (lens aperture over camera distance - a 100mm macro at f/8 from 300mm is about 0.021) blends the multiple images at a fold instead of seaming them; at 0 you get a pinhole and a hard chrome look. sim_resolution should stay at 0 = AUTO, which sizes the grid so the 2.727mm capillary ripple stays resolved - that ripple is what supplies the slope that makes a pool fold. Pin the grid and widening the framing silently coarsens it and the folds quietly disappear (the README's numbers: 57.7% folding at 20mm, 2.4% at 80mm, all at grid 160). Leave it alone.

Outputs. image plus grain_deficit - a MASK of the fine detail the warp genuinely destroyed (the aperture eats some of it, the resampler eats the rest). The node probes its own warp to measure the loss, and grain_restore puts the detail back calibrated to the source's own grain level, so 1.0 doesn't add grain on top. Set grain_restore to 0 if you'd rather chain grain_deficit into a dedicated grain node and find the calibration yourself.

There's an optional mask (with mask_min and mask_gamma) to scale the effect per pixel - feather it, because a hard mask edge shows a seam. dispersion refracts R/G/B at their own indices, which is real but subtle and triples the optics cost. Worth switching on once to see; I leave it off.

Install. ComfyUI Manager, search "Darkroom", or:

cd ComfyUI/custom_nodes/
git clone https://github.com/jeremieLouvaert/ComfyUI-Darkroom.git
pip install -r ComfyUI-Darkroom/requirements.txt

Restart, and it's under AKURATE/Darkroom/Lens. Only scipy and opensimplex, no models, no keys. Then: set field_width_mm around 40, pick standing pool, hit queue, and wait the 45 seconds. This is the one Darkroom node where the wait is the feature - it's a fluid sim.

CategoryAKURATE/Darkroom/Lens

Inputs (20)

NameTypeDefaultDescription
imageIMAGE
field_width_mmFLOAT40.08–250How wide a patch of screen the frame covers, in millimetres. THE most important control. Refraction displaces at most 0.881x the water depth, so framing a whole tablet (250mm) correctly gives almost nothing. The reference look lives at 20-50mm.
surfaceCOMBOstanding pool (heavier, whole frame)What the water is poured onto. THE biggest look lever, and the two options are different looks rather than two ends of a dial. Standing pool: pouring INTO water that is already there. The disturbance reaches the whole frame, which gives the heavier, more liquid result. Dry screen: the water only exists where it has been poured, so large areas stay completely undistorted and the subject stays very readable. (A free puddle cannot hold more than ~4mm on its own, so the pool option implies a tray or a bezel.)
water_mlFLOAT10.01–60How much water is poured. The intensity dial: more water is deeper water is more displacement. Raising it also shrinks the undistorted area, which is what keeps the subject readable.
pour_sweepFLOAT1.000–1How far the stream is swept while pouring, which is really a CONCENTRATION control. The same water drawn along a longer stroke is shallower, so it displaces and folds less: measured, folding runs 39% at 0, 30% at 0.5 and 24% at 1.0. Low values pile the pour into one deep mound for the heaviest distortion; high values spread it into a longer, gentler disturbance.
sweep_angleFLOAT450–360Direction the stream is swept across the frame.
sample_msFLOAT15020–300When the photograph is taken, measured from the start of the pour. Below the pour duration (100ms) you catch the live event; well above it the water has spread thin and flattened.
settle_msFLOAT00–600Extra time for the surface to calm before the shot. Viscous damping kills short ripples far faster than long ones (a 2.4mm ripple dies in ~72ms, a 12mm one in ~1.8s), so this removes fine chop and keeps the large forms. Costs folding if pushed far.
depth_scaleFLOAT1.000.25–3Multiplies the water depth. The honest artistic knob: anyone wanting more than physics allows is really asking for deeper water, so this scales h and the optics stay exact.
apertureFLOAT0.0200–0.06Lens aperture over camera distance (A/L). A 100mm macro at f/8 from 300mm is about 0.021. This is required physics, not softening: it blends the multiple images at a fold instead of seaming them. 0 gives a pinhole and a hard chrome look.
seedINT00–4294967295Pour seed. Same seed gives the same water.
maskoptMASKModulates how strongly the refraction applies at each output pixel. Mask values run from mask_min (where black) up to full effect (where white), and the warp itself is scaled so partial values do not ghost; the surface sheen and the grain_deficit output follow the same modulation. The water is simulated across the whole frame regardless, so a mask does not make the node faster. With mask_min at 0 a hard mask edge shows a visible seam line; feather by about a quarter of the local displacement, typically tens of pixels at pool depths.
mask_minoptFLOAT0.150–1Effect strength where the mask is black. The mask is remapped to run from this floor up to 1, so the default keeps a subtle refraction everywhere and uses the mask to push intensity where it is white. Set 0 to make the mask a hard gate: black means bitwise-untouched pixels and a zero grain_deficit. Ignored when no mask is connected.
mask_gammaoptFLOAT2.000.25–4Contrast of the mask response, applied before mask_min. Above 1 pulls mid grey mask values toward the floor so the painted highlights stand out clearly against them; below 1 lifts the mids instead. 1 is the plain linear remap. Feathered masks are mostly mid grey, which is why the default adds contrast.
sim_resolutionoptINT00–2560 = AUTO, which is what you want. Auto sizes the fluid grid so each cell is 0.25mm whatever the field width, i.e. about 11 cells across the 2.727mm capillary length. That matters because the ripple at that scale is what supplies the slope that makes a pool FOLD: measured, 7.6 cells gives 2% folding and 10.9 gives 20%. Pinning this to a fixed number is a trap, because dx = field_width / this, so widening the framing silently coarsens the grid and the folds quietly disappear (57.7% at 20mm, 31.3% at 40mm, 2.4% at 80mm, all at 160). Set it by hand only to trade quality for speed deliberately. Cost scales roughly with the square.
aperture_samplesoptINT328–64Rays per pixel across the lens. Lower is faster and noisier at folds.
env_strengthoptFLOAT1.000–3Brightness of the environment reflected off the water by the Fresnel term, strongest at grazing angles on the steep fold lines.
dispersionoptBOOLEANfalseRefract R/G/B at their own refractive indices. Real but subtle, and it triples the optics cost.
grain_restoreoptFLOAT1.000–1.5Put back the fine detail the warp destroyed. The refraction genuinely eats film grain — some of it is real physics (the aperture) and some was the resampler — so this restores it, weighted by a MEASURED deficit map and calibrated so 1.0 lands at the source's own grain level rather than adding on top. 0 leaves the image alone and you can chain the grain_deficit mask into a grain node instead, but then the calibration is yours to find.
vary_per_frameoptBOOLEANfalseRe-pour with a different seed for each batch frame. Off means one surface across the batch.

Outputs (2)

NameTypeDescription
imageIMAGE
grain_deficitMASK