# Meshing a smoke simulation

I have followed a tutorial on how to create explosions in Blender and now I want to export the explosion as OBJ. So for that I first need to create a mesh. I have seen multiple solutions, with using fluids or using the add-on CubeSurfer but nothing is working.

So how can I mesh my simulation or directly export it as OBJ. I am new to simulations in blender so a detailed answer would be very nice. I am working on 2.83

This is the blender file (this is a low quality example, so testing can be faster):

So thanks to vklidu I'm almost there, here is my current state, so that he can see what the problem is:

• Are you able to provide a sample file for inspection? You may upload to blend-exchange.giantcowfilms.com – Timaroberts Jun 10 '20 at 16:46
• I personally would recommend sculpting a mesh using the smoke simulation as a reference, but I'll look into your problem to see if I can find an answer. – Nate_Sycro27 Jun 10 '20 at 18:55
• You can change the cache type to final and set file format to OpenVDB and use the vdb files in the cache folder. – Bruk Jun 11 '20 at 14:14
• I will try to write answer tomorrow, but meshed smoke would be far from something similar to smoke so I'm interested what is your visual goal to end up. It wouldn't be enough detailed or too heavy. Or are you looking for something stylized as pixel art? – vklidu Jun 11 '20 at 21:45
• I happened across some openvdb python libraries here : openvdb.org/documentation/doxygen/python.html As Blender 2.83 supports output of smoke and fluid domains to VDB files this should make it possible to write a Python script to convert a frame into a mesh by placing metaballs as for @vklidu's answer - or to create the geometry directly. I don't know how this would be animated (I guess it would be possibly to create shape keys to produce the animation...). – Rich Sedman Jun 15 '20 at 23:59

The simulated smoke is stored in the modifier.

1. Access the correct modifier of the smoke domain object.
2. Go to its domain_settings.
3. The data is stored in a uniform grid, the resolution sets the divisions for the longest dimension.
4. It has the properties density_grid, flame_grid, color_grid. The first two are flat lists, which you can arange in an zyx grid. The color_grid has an additional dimension for rgba values.

I would suggest storing these values in vertex colors.

For further processing I would suggest the remesh modifier and the color transfer using Animation Nodes.

## Example Usage

You can grab the script (also included in the blend) from github. To use the script you have to setup your file first, as the script makes assumptions about the existence of some object.

1. Create a cube-shaped (equal dimensions!) domain object and name it domain.
2. Add other inflow/outflow/obstacle objects and bake the simulation.
3. Create a collection named simulated. For our convenience, the script will place the created geometry there.
4. Open a Text Editor in a Blender area and copy-paste the script into a new textblock. With the script textblock open, go to the text editors menu and selected Text > Run Script. If the script runs correctly, mesh objects are placed in the simulated collection.

Running the script in the example file will immediately crash Blender if you haven't baked the simulation yet.

Follow these steps to test the example file.

1. Go into solid viewport mode. Unhide the Collection and select the smoke domain object.
2. Rebake the simulation under Physics Properties > Fluid (Domain) > Settings > Free Data + Bake.
3. Open the console for logging.
4. Run the script via Text > Run Script. When running the script, both collections have to be active and visible and the smoke simulation cached.

## Step By Step Explanation

The first step of a python script is to import so-called modules. These modules provide functionality, which python can access. The bpy module contains functionality to access Blenders data. bmesh is a representation of mesh objects. numpy is a library to manipulate numbers and in ours case matrices. The result of the smoke simulation is stored in a grid, which numpy can easily manipulate.

import bpy
import bmesh
import numpy as np
import mathutils


Next, I add a utility function. This functions receives a string name as a parameter and returns an object of that name. If the object doesn't exist yet, it creates a new mesh-object. If it exists it returns the same mesh object. With this helper function we can run the meshing function multiple times and simply overwrite our created objects without creating additional object.001 each time.

def get_mesh(name):
if bpy.data.objects.get(name) == None:
me = bpy.data.meshes.new(name)
bpy.data.objects.new( name, me )
return bpy.data.objects[name]


Now come the main method, which gets the smoke simulation and accesses its properties on the current frame. The result gets written into a bmesh (Blenders module for mesh representation and manipulation).

The function definition start with def in python.

def write_smoke_to_bm(smoke_obj, bm):


I'm assuming that the smoke modifier is the first (with the index 0) modifier in the modifier stack modifiers. If it wasn't the index has to be adjusted. The modifiers has a property called domain_settings, which contains the domain settings of the smoke simulation.

    smoke_domain_mod = smoke_obj.modifiers[0]
settings = smoke_domain_mod.domain_settings


The domain_settings object has the properties resolution_max, density_grid, color_grid and flame_grid. The first is simply the grid resolution which we set in the domain settings. We need to know this, because the voxel data is stored in a grid with this resolution. To simplify the matter, I restricted the domain object to uniform dimensions (as mentioned earlier) which result in a three-dimensional grid with resolution_max slots for each axis (x, y, z). The other objects are data object in the form of the grid. However, this data is stored in a long list, which we have to manually convert to a grid form. Converting it is optional, but very helpful. It is much more convenient to access the grid position with x, y, z coordinates instead of the nth position in a long list. This is were the numpy library comes in handy, because it can "reshape" the list into a 3 dimensional list. The density_grid and flame_grid are simply three dimensional grid now with a number representing the amount of density/flame.

Since we only want to add geometry where the density is high enough, I defined a density threshold (thres), which is a fourth of the highest density value in the whole grid.

    res = settings.resolution_max
grid_density = np.array(settings.density_grid).reshape((res, res, res))
grid_col = np.array(settings.color_grid).reshape((res, res, res, 4))
grid_flame = np.array(settings.flame_grid).reshape((res, res, res))
thres = np.max(grid_density)/4


Now, we construct a bounding box of the dimensions. Using numpy again, we can story the values of all the vertices of the smoke object (smoke_obj) in a matrix and extract the smallest and highest values of the vertices' coordinates for each of their axes. The variable minmax stores them as [[x_min, y_min, z_min], [x_max, y_max, z_max]].

In the same manner, we can calculated the step size of a single voxel. Since the number of steps is defined by the resolution_max, the size of the voxel (along the x axis for example) can be defined as (x_max - x_min) / resolution_max.

    verts = np.array([smoke_obj.matrix_world @ v.co for v in smoke_obj.data.vertices])
verts = verts.reshape((*verts.shape, 1))
minmax = np.stack((np.min(verts, axis=0), np.max(verts, axis=0)))
minmax = np.reshape(minmax, minmax.shape[:2])

step = (minmax[1] - minmax[0]) / res


The bmesh object, which we passed as a parameter to the function, story vertex colors in loops, which are the vertices of a single face. Since the vertex color layers probably don't exist, we can create them.

    density = bm.loops.layers.color.new("density")
color = bm.loops.layers.color.new("color")
flame = bm.loops.layers.color.new("flame")


In the next step we loop through the slot of the three-dimensional density grid (or numpy-matrix).

    for z in range(0, grid_density.shape[0]):
for y in range(0, grid_density.shape[1]):
for x in range(0, grid_density.shape[2]):


Then we check if this position in the grid is dense enough to "mesh" it. We use the threshold, which we defined earlier.

                if grid_density[z, y, x] > thres:


If it is dense enough, we can place a cube there. Or a sphere, or a single face or anything else. I use a cube, because there is a simple builtin function in the bmesh module: create_cube.

To properly position this cube, we have to supply it with the translation and scale in form of a matrix. Blenders mathutils method provides functions which can generate a matrix from translation and scale values.

                    mat = mathutils.Matrix.Translation((
minmax[0][0] + (x + 0.5) * step[0],
minmax[0][1] + (y + 0.5) * step[1],
minmax[0][2] + (z + 0.5) * step[2]
)).to_4x4()
mat = mat @ mathutils.Matrix.Diagonal(step * 0.95).to_4x4()

verts = bmesh.ops.create_cube(bm, size=1, matrix=mat)['verts']


The create_cube function returns a dictionary object with an entry for the created vertices. Let's get the linked faces of those vertices.

                    faces = set()
for v in verts:


Once we have the faces, we get the linked loops (think face corners) and assign their vertex-color layers (which we created earlier) the respective values. The density layer gets the density value, the color layer gets the color value and the flame layer gets the flame value. In the cases of the color layer we can simply assign the color rgba values at that slot to the loop. But in the cases of the density and flame we only have one value. That's why we use the python syntax of [density_value] * 4 to create an rgba value as well to store in the vertex color layer: e.g. 1.2 => [1.2, 1.2, 1.2, 1.2].

                    for face in faces:
for loop in face.loops:
loop[density] = [grid_density[z, y, x]] * 4
loop[color] = grid_col[z, y, x]
loop[flame] = [grid_flame[z, y, x]] * 4


This is the end of the function, which adds the geometry to a bmesh.

Now you can loop over all of the frames and create an object with the previous function.

Since we will have to do this for each frame individually, I will first gather a list of frames. The list of frames is defined by the cache start and end of the smoke domain object.

domain = bpy.data.objects['domain']
smoke = domain.modifiers[0]

depsgraph = bpy.context.evaluated_depsgraph_get()
smoke_obj = domain.evaluated_get(depsgraph)
smoke_domain_mod = smoke_obj.modifiers[0]
settings = smoke_domain_mod.domain_settings


Just as before, this was the same code to get to the smoke domain settings, which store the properties cache_frame_start and cache_frame_end.

To organize our Blender file, the created objects are best placed in their own collection: simulated. Let's get a reference to that as well.

objects = bpy.data.collections['simulated'].objects


Now we can loop through all the frames. Since Blender becomes unresponsive for the duration of the execution of a python script, it is helpful to open the blender console (Window > Open Console) and print the current frames, which Blender is working on. frame_set advances the scene to a given frame and updates the objects and the smoke simulation state.

for frame in range(settings.cache_frame_start, settings.cache_frame_end):
print(frame)
bpy.context.scene.frame_set(frame)


Get the current smoke simulation frame with the evaluated dependency graph.

    depsgraph = bpy.context.evaluated_depsgraph_get()
smoke_obj = domain.evaluated_get(depsgraph)


Create a new bmesh to hold the smoke mesh data for this frame. Then pass it to the function, which we wrote ealier.

    bm = bmesh.new()
write_smoke_to_bm(smoke_obj, bm)


Using the helper function (also from above), we can create/get an object named simulated##, where ## stands for the current frame number. We write the bmesh data into that objects mesh and free the bmesh.

    ob = get_mesh('simulated' + str(frame).zfill(2))
bm.to_mesh(ob.data)
bm.free()


If the frame mesh object didn't exist, it would have been created. For that case, we have to move it into the simulated collections objects "list".

    try:
except:


If you were to execute the code uptill now, all mesh objects would be created and visible at once. This could be useful if you want to modify them further, etc. If you want to judge the playback however, you would rather only see the mesh object, which corresponds to the current frame. To achieve this, you can keyframe the visibility property of objects with the function keyframe_insert.

    for i in (frame - 1, frame + 1):
ob.hide_viewport = ob.hide_render = True
ob.keyframe_insert('hide_viewport', frame=i)
ob.keyframe_insert('hide_render', frame=i)
ob.hide_viewport = ob.hide_render = False
ob.keyframe_insert('hide_viewport', frame=frame)
ob.keyframe_insert('hide_render', frame=frame)



If you want to see any colors, set the viewport to vertex color mode or link the objects to a material, which uses the vertex colors we created.

Sorry, my post covers generating smoke as a single mesh in Blender 2.79, and not an entire smoke sim anim, due a buggy particle system (or smoke cache).

Blender 2.83 doesnt have texture type Voxel Data, but there is a Voxel Mesher Modifier on the way https://developer.blender.org/D4960

In meantime my goal with 2.79 was to ...

• use voxel data to generate particle cloud from texture (01) or vertex weight (02)
• particles represent by metaballs
• animation export as Alembic or Wavefront, that converts metaballs (smoke) into Mesh Sequence animation

## test 01 - Mesh from Voxel Data Texture

Blender 2.79 contains texture type Voxel Data that use this kind of data as 3D texture. Blender Manual describes this quite straight forward. And it works for 8-bit RAW and Image Sequence file format (like CT scan or MRI).

It means - it is possible (with some headaches*) to generate mesh from particle system represented by metaball objects, like this ...

Troubles comes with voxel file format Smoke (that should be able to reuse data of existing Smoke Domain on another object) and Blender Voxel (that should use externally saved simulation bake) ... ... I could see Material Preview in Texture Properties, but nothing was rendered.

Usage of these baked smoke simulations (externally) is buggy for years. Smoke doesn't appear in domain at all / or is shifted / or just a few frames of sim is visible. Not always, and that it makes more difficult to hunt issue. (One for all bug report here - as many of them were closed/archived.)

Anyway ... for your needs, when I assigned particle system directly to domain and Domain was set as Texture for particle density I was able to generate a particle cloud and to mesh one frame of smoke, but not entire animation. Probably for the reason mentioned above - bug. I'm not sure how that one frame is selected, probably current frame set on timeline when assigned? Can't confirm due the refresh issue.

For those interested - in case smoke sim is baked ...

• select Smoke Domain
• add Particle system - Emission>Start1 End1 / Source>Volume / Distribution>Grid> / Velocity>zero / Texture>New (Cycles only) / Field Weight>Gravity=zero
• go to Texture (Particles) Properties - Type>Voxel Data / Voxel Data>Smoke / Color>Ramp=ON to adjust density / Influence>Density=ON

• set render particles as Object>Mbals
• export (Mball) as Wavefront(.obj) or Alembic (.abc) that converts metaballs into Mesh Sequence animation
• (in case someone will fix the bug :) )

... another pain as hell is particle system refreshing https://developer.blender.org/T52160 . Even you have visible particle cloud, opening file again hides the particles. To make them visible again I discovered only one weird way - switch to Blender Internal engine into RenderView mode in 3Dview, change particle resolution to something, it refresh particle appearance, switch back to Cycles if needed.

## test02 - Mesh from Dynamic Paint - Weight

Dynamic Paint in video of Miikah can use a smoke sim as a "brush" to paint, so I wanted Weight paint into a dense grid using voxel data. Vertex Weight becomes Particle size solver. But ... I don't even thing this was implemented or I do something wrong here. (I could only approved dynamic paint vertex-weight can generate animated particle cloud, but not driven by smoke.)

Resume - faked Fluid Particles seems to be a way to go ... :(

## Notes to fix setup of your second blend file

(this part I will delete later)

Particle System Properties

• Influence > enable Density
• Field Weights > Gravity = 0
• Render > Size is very small, try 0.4
• looks better to use Random for Emission>Grid

Texture (Particles) Properties

• Color > enable Ramp and move White to left to increase smoke density

Mball Properties

• keep in mind all metaballs in a scene use resolution from first existing meta object in a scene (doesn't matter what other meta objects are set). That means - lowres for Preview and small particle size value make them invisible for its low res grid.

Time range

• you have set Smoke cache frame range 1-90 even scene is set 1-150
• also particles system Life Time is only 50 should be 150

Screens below use – Grid Res 200 / Particle Size 0.1 / Mball Preview 0.2

Smoke Default Preview Smoke Viewports Display Color Ramp Mball Particles

Hight Grid Resolution + Smaller Particle Size + Smaller Metaballs resolution = More detailed final mesh

Bake smoke sim first. If particles are not visible check italic paragraph under my first blend file.

• Thanks for the answer, I don't understand much, where is this voxel data setting? – user11914177 Jun 13 '20 at 8:43
• Thank you very much for investing so much time in this question, lets hope you can find something! – user11914177 Jun 13 '20 at 9:12
• Your new answer looks very promising, where is go to Particle Texture Properties - Type>Voxel Data / Voxel Data>Smoke / Influence>Density I couldn't find it – user11914177 Jun 15 '20 at 10:13
• I wasn't aware that this also requires 2.79, so I remade it in 79, but it doesn't work, there are some particles of the meta ball but they are nowhere on the particles of the explosion. I added the blend file so maybe you can see what's wrong – user11914177 Jun 15 '20 at 16:45
• When I asked devs, Brecht direct me to this link developer.blender.org/D4960 it is a patch of a new modifier "Voxel Mesher" that could be implemented quite soon. – vklidu Jun 16 '20 at 19:37