Merging Space Colonization Solver with Pyro Simulation to Create Looping Smoke Bursts
Karlis Stigis explains how his frustration with his Instagram account getting hacked inspired the entrancing smoke ball effect, with numerous branches spreading and bursting inside it, and talks us through the process of creating the effect using Houdini's geometry nodes.
Introduction
I'm a 3D and Motion Designer based in Nuremberg, creating visuals and animations for product presentations, advertising, and brand communication. With a background in fine arts, visual communication, and graphic design, I combine an artistic approach with technical experimentation.
A few years ago, my Instagram account was hacked and eventually banned. For a while, I thought I would never create another one. If something built over years could vanish in a second, why start again? But who was I kidding? I still wanted to see what other creatives were doing and share my own work.
For my first post back, I wanted to make something that would visually rhyme with rebirth – a new beginning. I'm not sure whether that idea came before I started or emerged during the process. Sometimes the unconscious mind takes the lead.
The result is a branching structure resembling a circulatory system. Instead of blood, it carries something intangible and ephemeral: the force of creativity.
Steps & Ingredients for Space Colonization Solver
Here is what I needed to make this effect come to life:
• Branching paths
• Velocity derived from path directions
• Density from selected emission points
• Smoke simulation with Pyro
• Rendering in Solaris and Karma
Branched Network
The technical part of this setup was inspired by Entagma's VEX in Houdini: Space Colonization tutorial and the 2007 paper Modeling Trees with a Space Colonization Algorithm by Adam Runions, Brendan Lane, and Przemyslaw Prusinkiewicz. My implementation adapts that idea by connecting the existing points rather than generating new growth positions from averaged attraction directions.
First, we need to choose the shape of the branching network. I used a simple cylinder created with the Labs Cylinder geometry node and filled it using the Points from Volume geometry node. Point spacing controls how densely the branches can develop.
Now comes the interesting part. We choose one or more points as a starting group and repeat three steps:
- Search: active points find unused neighbors within a fixed radius.
- Connect: each neighbor chooses the closest proposing parent. A line joins the pair.
- Advance: newly connected points become active; the previous generation stops searching.
Each child has one parent, while a parent can have several children. Groups track the unused, active, and selected points; candidate-parent lists resolve competing connections. Growth stops when no reachable unused points remain.
Green – starting point. Orange – active points and their search radii. Red – previously connected points. White – unused points.
Controlling the Branches
Changing the search distance and candidate limit produces different structures. A larger radius allows longer connections; the candidate limit controls how many neighbors each active point can propose.
Here are four variations of the branching rules on the same point layout.
Implementation
The loop uses four wrangles: INIT, find_neighbours, connect_logic, and set_groups. INIT runs once; the other three repeat.
Here you can see that INIT marks the starting points as active and the remaining points as free. Each point begins with its own __id; connected children later inherit their parent's ID.
Search & Connect
As you can see in the screenshot below, find_neighbours records candidate parents and marks their proposed neighbors as selected. This version requests Pts Max + 1 candidates, so a displayed value of 3 permits up to four.
In the following screenshot, you can see that connect_logic chooses the closest candidate parent, inherits its __id, and creates the connecting segment.
Advance & Repeat
Here, the final wrangle, set_groups, runs over __infected and __selected. It retires the current active points, clears their parent lists, and makes the selected points active for the next pass.
Applied to the points inside the cylinder, this produces clusters spreading from each starting point. Coloring by __id lets us distinguish the clusters and isolate an individual one.
Below, you can see the full network and an isolated cluster. Each cluster inherits the ID of its starting point.
Once the structure is ready, the Edge Transport geometry node measures the distance along the branches from the start group. I store it as dist, with global normalization enabled. This gives us a way to distinguish points closer to a root from points farther along a branch.
Here's the dist attribute visualized with color.
Preparing the Paths
At this stage, every segment between two connected points is a separate primitive. PolyPath joins the segments into continuous paths. I resample them, fuse coincident points, restore the start group with Group Transfer, and smooth the network for a more organic shape.
The processing chain ends with direction_calc, which writes the velocity attribute, as you can see below.
Comparing neighboring dist values establishes the outward direction. In direction_calc, I normalize the vector toward the neighbor with the greater distance and write it to that neighbor’s v attribute.
Edge Transport provides dist; the wrangle converts the ordering into outward direction vectors, as the image below shows.
Variations of the Network
For sanity’s sake, I bundled this into an HDA with a few additional controls. The branching logic is the same; the asset just makes the next step easier to manage.
I put Space Colonization inside another loop, choosing a different random starting group for each iteration. The inner loop grows one network. This outer loop creates ten variations, each packed separately for selection later.
The outer loop generates and packs ten networks.
In the next two images, you can see ten overlapping networks at the top, and the networks offset along the X axis for comparison at the bottom.
During the animation, I select one network at a time. Switching between the variations changes the directions supplied to the smoke, making the motion dynamic and less repetitive.
This expression in the Blast geometry node switches between networks at an increasing rate. Keyframes would give more control over the timing, but the expression worked well for this piece.
I unpack the selected network and choose random points from the start group at the centers of the branch clusters. These are the main emitters: smoke can spread from them along the branches in every direction. A few additional points across the network create smaller bursts. Their random seed changes every two frames.
I copy small spheres onto these points, then fill them with Points from Volume to give each emitter some volume.
Here are the animated emission points that feed the smoke simulation.
From Paths to Volume Fields
I use Pyro Source to add density and temperature to the emission points, linking Particle Separation to the same parameter in Points from Volume. After experimenting, I settled on a Density Scale of 10 for this setup.
Volume Rasterize Attributes converts density and temperature from point attributes into volume fields. The settings below define their resolution and the footprint of each point.
For velocity, I connect another Volume Rasterize Attributes node directly to the unpacked paths and set Attributes to v. This rasterizes the direction vectors along the full network.
I merge density, temperature, and v, then connect the result to the first input of Pyro Solver.
To contain the smoke, I thicken the original cylinder into a shell and convert it to a signed distance field with VDB from Polygons. I name the field collision and connect it to Pyro's second input. Preserve Holes in VDB from Polygons keeps the hollow interior open. The collider uses a larger voxel size because it doesn't really need much detail.
Simulating & Caching the Smoke
Performance
I used the Minimal OpenCL solver to take advantage of my GPU. It suits this setup because the smoke stays within a fixed container.
Animated sources take some time to prepare: Minimal OpenCL caches the source frame range before simulating. In this case, the faster simulation was worth that initial wait.
The collision VDB is static, so I keep Collision Frame Range set to Static Frame. The Collision SDF parameter below identifies the field the solver reads.
Velocity sourcing makes the biggest difference here. With Add, incoming velocity accumulates with the existing flow, and the branches become less distinct. Copy replaces the velocity in the sourced region on each update, continually reinforcing the paths. Turbulence still acts on the simulation, but the flow is free to deviate once it leaves those regions.
I leave density and temperature at their default source operations and disable Flame. The source Frame Range covers the animation.
Below you can see what happens when I hit Add on the left. And on the right, you can see what Copy does: it keeps the branching flow clearly defined.
In the Fields tab, I also disable Flame and leave the other settings at their defaults.
In the Shape tab, I disable Buoyancy as I don't want the smoke to rise or fall. I enable Turbulence and adjust its scale and strength until the flow has enough variation while the branches remain visible.
In the Output settings, I keep density for rendering and velocity for motion blur. Minimal OpenCL outputs Houdini volumes, with velocity split into three scalar fields for X, Y, and Z.
I convert the fields to VDBs with Convert VDB, then combine the three velocity components using VDB Vector from Scalar.
VDB Resample reduces the velocity field to half the resolution, which is sufficient for motion blur in this shot. Finally, the Primitive node sets the density to 16-bit storage to reduce cache size.
Rendering in Solaris & Karma
With the simulation cached, I move into Solaris. The setup here is straightforward: one Scene Import brings in the volume; the other imports the cameras.
I assign the volume material through the Material Library. Two disk lights are attached to the first camera through the Parent Constraint node, keeping their placement consistent relative to that camera as it moves.
The material and lighting work together here. Light passing through several overlapping branches is absorbed more strongly than light passing through a thin wisp, so the same shader can produce quite different colors and contrast across the volume.
Inside the Material Library, I use a Karma Volume shader. The MtlX Geometry Property Value node reads the density field.
Feeding density into Karma Ramp Const lets me control which colors are absorbed at different density levels. This creates color variation and helps emphasize the denser branches. I leave the scattering uncolored and adjust the scattering and absorption multipliers separately to control the look.
Motion Blur & Render Settings
The cached velocity field supplies motion blur. Render Geometry Settings enables velocity blur on the volume, and motion blur is also enabled in Karma Render Settings.
Final Touches
For the final render, I used 128 Path Traced Samples with OIDN denoising. That gave me a good balance of quality and render time for this shot. Depth of field is disabled.
I created the audio in Adobe Audition using samples from Freesound. Sound design isn't my specialty, but I enjoy working on it occasionally. It brings the visuals to life and can completely change how a piece is perceived.
And that's all pretty much. I'm always open to new ideas, so if you have a project in mind, let's get in touch!