Skip to content

Fluid Simulation

FluxRender.physics.FluidSandbox

FluidSandbox(domain_x_range: Sequence[float] = (-5.0, 5.0), domain_y_range: Sequence[float] = (-5.0, 5.0), resolution: Sequence[int] = (512, 512), fluid_viscosity: float = 0.005, friction_factor: float = 0.0, left_boundary: BoundaryConfiguration = None, right_boundary: BoundaryConfiguration = None, top_boundary: BoundaryConfiguration = None, bottom_boundary: BoundaryConfiguration = None, steps_per_frame: int = 5, spinup_steps: int = 0, smagorinsky_constant: float = 0.15, colliders: list = [])

The core 2D fluid dynamics solver using the Lattice Boltzmann Method (D2Q9).

It manages the main simulation domain, computes fluid physics, and handles interactions with defined boundary conditions and colliders.

Parameters:

Name Type Description Default
domain_x_range Sequence[float]

The (min, max) mathematical coordinates mapping the spatial boundaries of the simulation domain along the X-axis.

(-5.0, 5.0)
domain_y_range Sequence[float]

The (min, max) mathematical coordinates mapping the spatial boundaries of the simulation domain along the Y-axis.

(-5.0, 5.0)
resolution Sequence[int]

The internal grid resolution (width, height) of the LBM solver. Higher values yield more accurate physics and smaller vortices, but demand exponentially more GPU VRAM and processing power.

(512, 512)
fluid_viscosity float

The kinematic viscosity of the fluid. Lower values create chaotic, highly turbulent airflows (high Reynolds number), while higher values result in thick, syrupy, laminar flows.

0.005
friction_factor float

Artificial global damping applied directly to the macroscopic velocity field. Useful for simulating shallow water floor friction or artificially calming the simulation domain.

0.0
left_boundary BoundaryConfiguration

The physical behavior of the left wall edge. Defaults to a standard solid wall.

None
right_boundary BoundaryConfiguration

The physical behavior of the right wall edge. Defaults to a standard solid wall.

None
top_boundary BoundaryConfiguration

The physical behavior of the top wall edge. Defaults to a standard solid wall.

None
bottom_boundary BoundaryConfiguration

The physical behavior of the bottom wall edge. Defaults to a standard solid wall.

None
steps_per_frame int

The number of internal physics collision/streaming iterations calculated before passing the state to the visual renderer. Higher values artificially speed up the flow of time relative to frame rate.

5
spinup_steps int

The number of initial simulation steps performed before rendering begins. This allows the simulation to reach the desired state more quickly.

0
smagorinsky_constant float

The sub-grid scale constant for the Smagorinsky turbulence model. It dynamically injects artificial eddy viscosity into high-shear regions to prevent mathematical domain explosions. Set to 0.0 to completely disable damping (requires extreme caution with viscosity values).

0.15
colliders list

A list of initial Collider objects to permanently place inside the fluid domain during initialization.

[]
Example

Creating an aerodynamic wind tunnel with a spherical obstacle using a context manager:

import FluxRender as fr

scene = fr.create_workspace(resolution=(1600, 950))

# 1. Set up the boundary conditions for the fluid domain.
# The fluid enters from the left and exits freely on the right.
inflow = fr.BoundaryConfiguration(fr.BoundaryType.INFLOW)
outflow = fr.BoundaryConfiguration(fr.BoundaryType.OPEN_OUTFLOW)

# 2. Initialize the fluid simulation environment.
# Using a context manager automatically links defined colliders to this sandbox.
with fr.FluidSandbox(
    domain_y_range=(-8, 8),
    domain_x_range=(-20, 30),
    resolution=(1000, 400),
    fluid_viscosity=0.001,
    left_boundary=inflow,
    right_boundary=outflow,
) as sandbox:

    # 3. Define physical obstacles inside the context manager.
    fr.EquationCollider(equation_function=lambda x, y: (x**2 + y**2) <= 1.0)

# 4. Create a particle system that visualizes the fluid flow.
fr.ParticleSystem(vec_function=sandbox, count=10000)

# 5. Start the engine and render the scene.
scene.run()

Source code in FluxRender/physics.py
def __init__(
    self,
    domain_x_range: Sequence[float] = (-5.0, 5.0),
    domain_y_range: Sequence[float] = (-5.0, 5.0),
    resolution: Sequence[int] = (512, 512),
    fluid_viscosity: float = 0.005,
    friction_factor: float = 0.0,
    left_boundary: BoundaryConfiguration = None,
    right_boundary: BoundaryConfiguration = None,
    top_boundary: BoundaryConfiguration = None,
    bottom_boundary: BoundaryConfiguration = None,
    steps_per_frame: int = 5,
    spinup_steps: int = 0,
    smagorinsky_constant: float = 0.15,
    colliders: list = [],
):
    """
    Args:
        domain_x_range (Sequence[float]): The (min, max) mathematical coordinates mapping the spatial boundaries of the simulation domain along the X-axis.
        domain_y_range (Sequence[float]): The (min, max) mathematical coordinates mapping the spatial boundaries of the simulation domain along the Y-axis.
        resolution (Sequence[int]): The internal grid resolution (width, height) of the LBM solver. Higher values yield more accurate physics and smaller vortices, but demand exponentially more GPU VRAM and processing power.
        fluid_viscosity (float): The kinematic viscosity of the fluid. Lower values create chaotic, highly turbulent airflows (high Reynolds number), while higher values result in thick, syrupy, laminar flows.
        friction_factor (float): Artificial global damping applied directly to the macroscopic velocity field. Useful for simulating shallow water floor friction or artificially calming the simulation domain.
        left_boundary (BoundaryConfiguration, optional): The physical behavior of the left wall edge. Defaults to a standard solid wall.
        right_boundary (BoundaryConfiguration, optional): The physical behavior of the right wall edge. Defaults to a standard solid wall.
        top_boundary (BoundaryConfiguration, optional): The physical behavior of the top wall edge. Defaults to a standard solid wall.
        bottom_boundary (BoundaryConfiguration, optional): The physical behavior of the bottom wall edge. Defaults to a standard solid wall.
        steps_per_frame (int): The number of internal physics collision/streaming iterations calculated before passing the state to the visual renderer. Higher values artificially speed up the flow of time relative to frame rate.
        spinup_steps (int): The number of initial simulation steps performed before rendering begins. This allows the simulation to reach the desired state more quickly.
        smagorinsky_constant (float): The sub-grid scale constant for the Smagorinsky turbulence model. It dynamically injects artificial eddy viscosity into high-shear regions to prevent mathematical domain explosions. Set to 0.0 to completely disable damping (requires extreme caution with viscosity values).
        colliders (list, optional): A list of initial `Collider` objects to permanently place inside the fluid domain during initialization.

    Example:
        Creating an aerodynamic wind tunnel with a spherical obstacle using a context manager:
        ```python
        import FluxRender as fr

        scene = fr.create_workspace(resolution=(1600, 950))

        # 1. Set up the boundary conditions for the fluid domain.
        # The fluid enters from the left and exits freely on the right.
        inflow = fr.BoundaryConfiguration(fr.BoundaryType.INFLOW)
        outflow = fr.BoundaryConfiguration(fr.BoundaryType.OPEN_OUTFLOW)

        # 2. Initialize the fluid simulation environment.
        # Using a context manager automatically links defined colliders to this sandbox.
        with fr.FluidSandbox(
            domain_y_range=(-8, 8),
            domain_x_range=(-20, 30),
            resolution=(1000, 400),
            fluid_viscosity=0.001,
            left_boundary=inflow,
            right_boundary=outflow,
        ) as sandbox:

            # 3. Define physical obstacles inside the context manager.
            fr.EquationCollider(equation_function=lambda x, y: (x**2 + y**2) <= 1.0)

        # 4. Create a particle system that visualizes the fluid flow.
        fr.ParticleSystem(vec_function=sandbox, count=10000)

        # 5. Start the engine and render the scene.
        scene.run()
        ```
    """

    self.iter = int(0)
    self.scene = cr.get_scene()
    self.grid_width = resolution[0]
    self.grid_height = resolution[1]

    self.domain_x_range = domain_x_range
    self.domain_y_range = domain_y_range

    self.friction_factor = friction_factor
    self.smagorinsky_constant = smagorinsky_constant
    self.fluid_viscosity = fluid_viscosity

    self.domain_x_min = domain_x_range[0]
    self.domain_x_max = domain_x_range[1]
    self.domain_y_min = domain_y_range[0]
    self.domain_y_max = domain_y_range[1]

    self.colliders = colliders
    self.steps_per_frame = steps_per_frame
    self.spinup_steps = spinup_steps
    self._initialized_spinup = False

    self._pending_collider = []

    # Stability check: relaxation_time must be strictly greater than 0.5 in LBM
    relaxation_time = 3.0 * fluid_viscosity + 0.5
    if relaxation_time < 0.515:
        warnings.warn(
            f"The specified fluid viscosity ({fluid_viscosity}) is too low for simulation stability, which may cause simulation collapse."
        )
    self.relaxation_time = relaxation_time
    self.inverse_relaxation_time = 1.0 / self.relaxation_time

    # Data fields for the microscopic distribution functions (9 buckets per pixel)
    self.distribution_old = ti.field(dtype=float, shape=(self.grid_width, self.grid_height, 9))
    self.distribution_new = ti.field(dtype=float, shape=(self.grid_width, self.grid_height, 9))

    # Data fields for the macroscopic physical properties
    self.macroscopic_velocity_field = ti.Vector.field(2, dtype=float, shape=(self.grid_width, self.grid_height))
    self.macroscopic_density_field = ti.field(dtype=float, shape=(self.grid_width, self.grid_height))

    # Data fields for interactive objects (Sandbox elements)
    self.solid_collider_mask = ti.field(dtype=ti.i32, shape=(self.grid_width, self.grid_height))
    self.external_force_field = ti.Vector.field(2, dtype=float, shape=(self.grid_width, self.grid_height))

    # Cached NumPy array for lightning-fast CPU/Vectorized evaluation
    self.cached_velocity_numpy = np.zeros((self.grid_width, self.grid_height, 2), dtype=np.float32)

    # LBM D2Q9 Mathematical Constants
    self.lattice_direction_vectors = ti.Vector.field(2, dtype=ti.i32, shape=9)
    self.float_directions_vector = ti.Vector.field(2, dtype=ti.f32, shape=9)
    self.lattice_weights = ti.field(dtype=float, shape=9)
    self.opposite_lattice_indices = ti.field(dtype=ti.i32, shape=9)


    # Apply user configurations or default to solid walls
    default_wall = BoundaryConfiguration(BoundaryType.SOLID_WALL)

    self.left_boundary = left_boundary or default_wall
    self.right_boundary = right_boundary or default_wall
    self.top_boundary = top_boundary or default_wall
    self.bottom_boundary = bottom_boundary or default_wall

    if self.left_boundary.boundary_type == BoundaryType.PERIODIC and self.right_boundary.boundary_type == BoundaryType.PERIODIC:
        _fatal_error("Both left and right boundaries cannot be PERIODIC simultaneously. Please choose different boundary types.", error_type="ValueError")

    if self.top_boundary.boundary_type == BoundaryType.PERIODIC and self.bottom_boundary.boundary_type == BoundaryType.PERIODIC:
        _fatal_error("Both top and bottom boundaries cannot be PERIODIC simultaneously. Please choose different boundary types.", error_type="ValueError")


    cr.Scene.pending_elements.append(self)

    self._initialize_lattice_constants()
    self._initialize_fluid_state()
    self._bake_solid_boundaries(
        self.left_boundary.boundary_type.value,
        self.right_boundary.boundary_type.value,
        self.top_boundary.boundary_type.value,
        self.bottom_boundary.boundary_type.value
    )

    self._apply_colliders()

add_collider

add_collider(*colliders: Collider)

Adds one or more colliders to the simulation sandbox.

Parameters:

Name Type Description Default
*colliders Collider

One or more instances of the Collider class to be added to the sandbox.

()
Source code in FluxRender/physics.py
def add_collider(self, *colliders: Collider):
    """Adds one or more colliders to the simulation sandbox.

    Args:
        *colliders (Collider): One or more instances of the Collider class to be added to the sandbox.
    """
    for collider in colliders:
        if not isinstance(collider, Collider):
            _fatal_error(f"All provided colliders must be instances of the Collider class. Got {type(collider).__name__}.", error_type="TypeError")
        self.colliders.append(collider)

    self._apply_colliders()

load_state

load_state(filepath: str) -> None

Loads a microscopic fluid distribution from a binary NumPy file.

Parameters:

Name Type Description Default
filepath str

The path to the .npy file.

required
Source code in FluxRender/physics.py
def load_state(self, filepath: str) -> None:
    """Loads a microscopic fluid distribution from a binary NumPy file.

    Args:
        filepath (str): The path to the .npy file.
    """

    distribution_data = np.load(filepath)

    expected_shape = (self.grid_width, self.grid_height, 9)
    if distribution_data.shape != expected_shape:
        _fatal_error(
            f"State shape mismatch. Expected {expected_shape}, got {distribution_data.shape}.",
            error_type="ValueError"
        )

    self.distribution_old.from_numpy(distribution_data)
    self.distribution_new.from_numpy(distribution_data)

save_state

save_state(filepath: str) -> None

Saves the current microscopic fluid distribution to a binary NumPy file.

Parameters:

Name Type Description Default
filepath str

The destination file path (must end with .npy).

required
Source code in FluxRender/physics.py
def save_state(self, filepath: str) -> None:
    """Saves the current microscopic fluid distribution to a binary NumPy file.

    Args:
        filepath (str): The destination file path (must end with .npy).
    """

    if not self._initialized_spinup and self.spinup_steps > 0:
        for i in range(self.spinup_steps):
            print(f"[FluxRender] Calculating simulation step {self.spinup_steps} before saving...\nCompleted: {(i * 100) // self.spinup_steps}%", end="\033[1A\r")
            self._step_physics(self.distribution_old, self.distribution_new)
            self.distribution_old, self.distribution_new = self.distribution_new, self.distribution_old

        self._initialized_spinup = True

    # Export the microscopic distribution data to a NumPy array and save it to disk
    distribution_data = self.distribution_old.to_numpy()
    np.save(filepath, distribution_data)

    print("\033[K\n\033[K\033[1F")
    print(f"[FluxRender] Fluid state saved to {filepath}.")