Visualization Library 2.0.0

A lightweight C++ OpenGL middleware for 2D/3D graphics

VL     Star     Watch     Fork     Issue

[Download] [Tutorials] [All Classes] [Grouped Classes]
Volume Rendering and Isosurfaces Extraction with Marching Cubes Tutorial

This tutorial demonstrates how to use the marching cubes algorithm to extract isosurfaces from volume data.

[From App_MarchingCubes.cpp]

class App_MarchingCubes: public BaseDemo
//
// This applet demonstrates: how to use marching cubes to generate isosurfaces, how to visualize multiple intersecting volumes,
// how to manage transparencies, how to color the isosurface using texturing and vertex color, how to implement a simple metaballs demo
// and how to simulate a metaballs/water fountain.
//
// Test 0: drag and drop a volume in the window and use the mouse wheel to visualize the appropriate iso-surface level.
// Test 1: multiple self-intersecting transparent isosurfaces visualization.
// Test 2: isosurface colorize via texturing.
// Test 3: isosurface colorize via vertex color.
// Test 4: animated metaball demo implemented on top of the marching cube algorithm.
// Test 5: animated fountain based on marching cubes.
// Test 6: 3D function plotting with vl::VolumePlot
//
{
public:
App_MarchingCubes(): mTest(4) {}
void initEvent()
{
vl::Log::notify(appletInfo());
openglContext()->setContinuousUpdate(true);
srand((unsigned int)time(NULL));
mThreshold = 0.44f;
mFountainSpeed = 2.5f;
// setup metaballs data
mMetaball.resize(mParticleCount);
mMetaballVelocity.resize(mParticleCount);
mMetaballsFrames.resize(mFrameCount);
for(int iframe=0; iframe<mFrameCount; ++iframe)
{
mMetaballsFrames[iframe].resize(mParticleCount);
for(int iball=0; iball<mParticleCount; ++iball)
{
mMetaballsFrames[iframe][iball].x() = (rand()%100 / 100.0f)*0.6f+0.2f;
mMetaballsFrames[iframe][iball].y() = (rand()%100 / 100.0f)*0.6f+0.2f;
mMetaballsFrames[iframe][iball].z() = (rand()%100 / 100.0f)*0.6f+0.2f;
}
}
// load images
mVolumeImage = vl::loadImage("/volume/VLTest.dat")->convertFormat(vl::IF_LUMINANCE)->convertType(vl::IT_FLOAT);
mColorImage = vl::loadImage("/volume/VLTest.dat");
// transform
mTransform = new vl::Transform;
rendering()->as<vl::Rendering>()->transform()->addChild( mTransform.get() );
// text setup
mText = new vl::Text();
mText->setDisplayListEnabled(!vl::Has_GLES);
mText->setFont( vl::defFontManager()->acquireFont("/font/bitstream-vera/VeraMono.ttf", 10) );
mText->setMargin(5);
mText->setViewportAlignment(vl::AlignTop | vl::AlignHCenter);
mText->setAlignment(vl::AlignTop | vl::AlignHCenter);
mText->setTextAlignment(vl::TextAlignCenter);
mText->setColor(vl::white);
mText->setBackgroundColor(vl::fvec4(0,0,0,.75f));
mText->setBackgroundEnabled(true);
mText->setText("Marching Cubes Demo");
text_fx->shader()->enable(vl::EN_BLEND);
mTextActor = new vl::Actor( mText.get(), text_fx.get() );
sceneManager()->tree()->addActor( mTextActor.get() );
setupTest();
}
virtual void updateScene()
{
if (mTest == 4)
runTest4();
else
if (mTest == 5)
runTest5();
}
void runTest4()
{
// rotate metaballs
vl::vec3 axis;
axis.x() = sin( vl::Time::currentTime()*3.1415f*2.0f/8.0f );
axis.y() = 0;
axis.z() = cos( vl::Time::currentTime()*3.1415f*2.0f/8.0f );
axis.normalize();
if (axis.isNull())
return;
mTransform->setLocalMatrix( vl::mat4::getRotation( vl::Time::currentTime()*120.0f, axis) );
// animate metaballs
float t = (float)vl::fract( vl::Time::currentTime()*0.05f );
t = vl::clamp(t, 0.0f, 0.999f);
int frame1 = int(t * mFrameCount);
int frame2 = (frame1+1) % mFrameCount;
t = vl::fract(t*mFrameCount);
float Ha = 2*t*t*t - 3*t*t + 1;
float Hb = -2*t*t*t + 3*t*t;
for(unsigned iball=0; iball<mMetaball.size(); ++iball)
mMetaball[iball] = mMetaballsFrames[frame1][iball]*Ha + mMetaballsFrames[frame2][iball]*Hb;
int idx = 0;
float* values = &mMarchingCubes.volumeInfo()->at(0)->volume()->value(0);
for(int z=0; z<mMetaballsResolution; ++z)
{
float pz = (float)z/mMetaballsResolution;
for(int y=0; y<mMetaballsResolution; ++y)
{
float py = (float)y/mMetaballsResolution;
for(int x=0; x<mMetaballsResolution; ++x, ++idx)
{
values[idx] = metaballFunction((float)x/mMetaballsResolution, py, pz);
}
}
}
// notify that the volume data has changed
mMarchingCubes.volumeInfo()->at(0)->volume()->setDataDirty();
mMarchingCubes.run(false);
}
void runTest5()
{
// animate metaballs
for(unsigned iball=0; iball<mMetaball.size(); ++iball)
{
mMetaballVelocity[iball] -= vl::fvec3(0,1.5f,0)*(float)mTimer.elapsed()*mFountainSpeed;
mMetaball[iball] = mMetaball[iball] + mMetaballVelocity[iball]*(float)mTimer.elapsed();
if (mMetaball[iball].y() < 0)
{
mMetaball[iball].y() = 0;
mMetaball[iball].x() = 0.5f;
mMetaball[iball].z() = 0.5f;
mMetaballVelocity[iball].x() = (float)vl::random(-12,+12);
mMetaballVelocity[iball].z() = (float)vl::random(-12,+12);
mMetaballVelocity[iball].y() = 100;
mMetaballVelocity[iball].normalize();
mMetaballVelocity[iball] *= (17.0f + (float)vl::random(0,5))*0.05f*mFountainSpeed;
}
}
mTimer.start();
int idx = 0;
float* values = &mMarchingCubes.volumeInfo()->at(0)->volume()->value(0);
for(int z=0; z<mMetaballsResolution; ++z)
{
float pz = (float)z/mMetaballsResolution;
for(int y=0; y<mMetaballsResolution; ++y)
{
float py = (float)y/mMetaballsResolution;
for(int x=0; x<mMetaballsResolution; ++x, ++idx)
values[idx] = metaballFunction((float)x/mMetaballsResolution, py, pz);
}
}
// notify that the volume data has changed
mMarchingCubes.volumeInfo()->at(0)->volume()->setDataDirty();
mMarchingCubes.run(false);
}
float metaballFunction(float x, float y, float z)
{
float val = 0;
for(unsigned i=0; i<mMetaball.size(); ++i)
{
float rx = x-mMetaball[i].x();
float ry = y-mMetaball[i].y();
float rz = z-mMetaball[i].z();
float r2 = rx*rx+ry*ry+rz*rz;
if (r2 == 0.0f)
return 1.0e+10f;
val += vl::fast1_inversesqrt(r2*r2); // threshold = 400
// val += vl::fast2_inversesqrt(r2*r2); // threshold = 400
// val += 1.0f / r2; // threshold = 400
// val += vl::fast1_inversesqrt(r2); // threshold = 70
// val += vl::fast2_inversesqrt(r2); // threshold = 70
}
return val;
}
void keyPressEvent(unsigned short ch, vl::EKey key)
{
BaseDemo::keyPressEvent(ch,key);
bool update = false;
if (key == vl::Key_Right)
{
mTest++;
update = true;
}
else
if (key == vl::Key_Left)
{
mTest--;
update = true;
}
if (update)
{
if (mTest > 6) mTest = 0;
if (mTest < 0) mTest = 6;
setupTest();
updateText();
}
}
// generates the 2 transparent volumes or a single volume
vl::Actor* showVolumes(bool test1)
{
rendering()->as<vl::Rendering>()->camera()->setModelingMatrix( vl::mat4::getTranslation(0,0,20) );
geom->setVertexArray(mMarchingCubes.mVertsArray.get());
geom->setNormalArray(mMarchingCubes.mNormsArray.get());
geom->drawCalls().push_back(mMarchingCubes.mDrawElements.get());
fx->shader()->setRenderState( new vl::Light, 0 );
// two side lighting
fx->shader()->gocLightModel()->setTwoSide(true);
vl::ref<vl::Actor> act = new vl::Actor(geom.get(), fx.get(), NULL);
sceneManager()->tree()->actors()->clear();
sceneManager()->tree()->addActor(act.get());
sceneManager()->tree()->addActor( mTextActor.get() );
volume->setup( (float*)mVolumeImage->pixels(), false, true, vl::fvec3(-5,-5,-5), vl::fvec3(+5,+5,+5), vl::ivec3(mVolumeImage->width(), mVolumeImage->height(), mVolumeImage->depth()) );
mMarchingCubes.reset();
mMarchingCubes.volumeInfo()->push_back( new vl::VolumeInfo( volume.get(), 0.40f, /*yellow*/vl::fvec4(1, 1, 0, 0.5f)) );
if (test1)
{
// generate second volume
mMarchingCubes.volumeInfo()->push_back( new vl::VolumeInfo( volume.get(), 0.50f, /*red*/vl::fvec4(1, 0, 0, 0.5f)) );
// enable blend
// use color array
// polygon depth sorting
act->actorEventCallbacks()->push_back( new vl::DepthSortCallback );
// binds the marching cubes color array
geom->setColorArray (mMarchingCubes.mColorArray.get());
}
mMarchingCubes.run(true);
// stats
vl::String stats;
stats += vl::Say("vertices = %n\n") << mMarchingCubes.mVertsArray->size();
return act.get();
}
// Coloring a volume using a 3d texture can be very useful when the color of the texture is animated
// i.e. changes relatively rapidly or when the isosurface itself changes or is animated.
// Cons:
// 1 - the texture can occupy a large amount of memory
// 2 - to guarantee maximum compatibility the texture should be a cube (not a rectangle) whose side length is a power of 2.
void textureVolumeColor(vl::Actor* vol_act)
{
{
vl::Log::notify("textureVolumeColor() requires 3D texturing.\n");
return;
}
vl::Effect* fx = vol_act->effect();
// automatic 3D texture coordinate generation
#if 1
fx->shader()->gocTextureSampler(0)->setTexture( new vl::Texture( mColorImage.get() ) );
/* the settings below are the defaults
fx->shader()->gocTexGen(0)->setObjectPlaneS(vl::fvec4(1,0,0,0));
fx->shader()->gocTexGen(0)->setObjectPlaneT(vl::fvec4(0,1,0,0));
fx->shader()->gocTexGen(0)->setObjectPlaneR(vl::fvec4(0,0,1,0));
fx->shader()->gocTexEnv(0)->setMode(vl::TEM_MODULATE);*/
vol_act->lod(0)->computeBounds();
vl::AABB box = vol_act->lod(0)->boundingBox();
// transforms object vertex coordinates to 0..1 cube, this might create a "bleeding color" effect at the very side of the cube.
// note that to be really precise we should use 1/2N ... 1-1/2N in each direction instead of 0..1.
vl::mat4 tex_mat = vl::mat4::getTranslation( vl::vec3(0.5f,0.5f,0.5f) ) *
vl::mat4::getScaling(1.0f/box.width(),1.0f/box.height(),1.0f/box.depth()) *
#endif
// the above code is equivalent to the code below that manually generates the texture coordinates.
#if 0
// transforms object vertex coordinates to 0..1 cube.
vol_act->lod(0)->computeBounds();
vl::AABB box = vol_act->lod(0)->boundingBox();
vl::mat4 tex_mat = vl::mat4::getTranslation( vl::vec3(0.5f,0.5f,0.5f) ) *
vl::mat4::getScaling(1.0f/box.width(),1.0f/box.height(),1.0f/box.depth()) *
fx->shader()->gocTextureSampler(0)->setTexture( new vl::Texture( mColorImage.get() ) );
tex_array->resize( geom->vertexArray()->size() );
geom->setTexCoordArray(0, tex_array.get());
for(int i=0; i<tex_array->size(); ++i)
tex_array->at(i) = ((vl::fvec4)(tex_mat * geom->vertexArray()->getAsVec4(i))).xyz();
#endif
}
// Coloring a volume assigning a color to each vertex directly can save memory compared to using a 3D texture,
// and no 3D texturing is needed so there aren't compatibility issues.
// This technique is not particularly efficient if your isosurfaces are animated and you need to recompute the colors often.
void vertexVolumeColor(vl::Actor* vol_act)
{
vl::Geometry* geom = vl::cast<vl::Geometry>(vol_act->lod(0));
vl::Effect* fx = vol_act->effect();
// use color array
// transforms object vertex coordinates to 0..1 cube.
vol_act->lod(0)->computeBounds();
vl::AABB box = vol_act->lod(0)->boundingBox();
vl::mat4 tex_mat = vl::mat4::getTranslation( vl::vec3(0.5f,0.5f,0.5f) ) *
vl::mat4::getScaling(1.0f/box.width(),1.0f/box.height(),1.0f/box.depth()) *
color_array->resize( geom->vertexArray()->size() );
geom->setColorArray(color_array.get());
for(size_t i=0; i<color_array->size(); ++i)
{
vl::vec4 px = tex_mat * geom->vertexArray()->getAsVec4(i);
px.x() *= mColorImage->width();
px.y() *= mColorImage->height();
px.z() *= mColorImage->depth();
color_array->at(i) = mColorImage->sampleLinear(px.x(), px.y(), px.z());
}
}
// Setups the good old metaballs demo!
vl::Actor* setupMetaballs()
{
rendering()->as<vl::Rendering>()->camera()->setModelingMatrix( vl::mat4::getTranslation(0,0,25) );
sceneManager()->tree()->actors()->clear();
sceneManager()->tree()->addActor( mTextActor.get() );
mMarchingCubes.reset();
mMarchingCubes.volumeInfo()->push_back( new vl::VolumeInfo( volume.get(), 400.0f) );
mMarchingCubes.volumeInfo()->at(0)->volume()->setup( NULL, false, false, vl::fvec3(-10,-10,-10), vl::fvec3(+10,+10,+10), vl::ivec3(mMetaballsResolution,mMetaballsResolution,mMetaballsResolution) );
geom->setVertexArray(mMarchingCubes.mVertsArray.get());
geom->setNormalArray(mMarchingCubes.mNormsArray.get());
geom->drawCalls().push_back(mMarchingCubes.mDrawElements.get());
// disable BufferObject since we update the vertices every frame
geom->setBufferObjectEnabled(false);
fx->shader()->setRenderState( new vl::Light, 0 );
// two side lighting
fx->shader()->gocLightModel()->setTwoSide(true);
{
vl::ref<vl::Image> texture = vl::loadImage("/images/spheremap.png");
fx->shader()->gocTextureSampler(0)->setTexture( new vl::Texture( texture.get() ) );
}
vl::ref<vl::Actor> act = sceneManager()->tree()->addActor(geom.get(), fx.get(), mTransform.get());
return act.get();
}
// Setups the metaballs fountain demo!
vl::Actor* setupFountain()
{
rendering()->as<vl::Rendering>()->camera()->setModelingMatrix( vl::mat4::getTranslation(0,0,25) );
sceneManager()->tree()->actors()->clear();
sceneManager()->tree()->addActor( mTextActor.get() );
mMarchingCubes.reset();
mMarchingCubes.volumeInfo()->push_back( new vl::VolumeInfo( volume.get(), 400.0f) );
mMarchingCubes.volumeInfo()->at(0)->volume()->setup( NULL, false, false, vl::fvec3(-10,-10,-10), vl::fvec3(+10,+10,+10), vl::ivec3(mMetaballsResolution,mMetaballsResolution,mMetaballsResolution) );
geom->setBoundingBox( vl::AABB( vl::vec3(-10,-10,1-10), vl::vec3(10,10,10) ) );
geom->setBoundingSphere( geom->boundingBox() );
geom->setVertexArray(mMarchingCubes.mVertsArray.get());
geom->setNormalArray(mMarchingCubes.mNormsArray.get());
geom->drawCalls().push_back(mMarchingCubes.mDrawElements.get());
// disable BufferObject since we update the vertices every frame
geom->setBufferObjectEnabled(false);
fx->shader()->setRenderState( new vl::Light, 0 );
// two side lighting
fx->shader()->gocLightModel()->setTwoSide(true);
fx->shader()->gocMaterial()->setDiffuse(vl::royalblue);
vl::ref<vl::Actor> act = sceneManager()->tree()->addActor(geom.get(), fx.get(), mTransform.get());
for(unsigned iball=0; iball<mMetaball.size(); ++iball)
{
mMetaball[iball].y() = 0;
mMetaball[iball].x() = 0.5f;
mMetaball[iball].z() = 0.5f;
mMetaballVelocity[iball].x() = (float)vl::random(-15,+15);
mMetaballVelocity[iball].z() = (float)vl::random(-15,+15);
mMetaballVelocity[iball].y() = 100;
mMetaballVelocity[iball].normalize();
mMetaballVelocity[iball] *= (5.0f + (float)vl::random(0,20))*0.05f*mFountainSpeed;
}
mTransform->setLocalMatrix(vl::mat4());
mTimer.start();
return act.get();
}
class my_func: public vl::VolumePlot::Function
{
public:
virtual float operator()(float x, float y, float z) const
{
// return sqrt(x*x+y*y+z*z); == 0.9f
// return exp(-y)*sin(z)+cos(z*x); // == 2.0f
return -x/5.0f*sin(z/5.0f)+exp(y*y*y/5.0f/5.0f/5.0f); // == 0.900f
}
};
// Shows how to use vl::VolumePlot to create a 3D plot.
void setup3Dplot()
{
// reset actors and camera
sceneManager()->tree()->actors()->clear();
sceneManager()->tree()->addActor( mTextActor.get() );
rendering()->as<vl::Rendering>()->camera()->setViewMatrix( vl::mat4::getLookAt( vl::vec3(5,10,20), vl::vec3(0,0,0), vl::vec3(0,1,0)) );
float range = 5.0f;
vl::fvec3 min_corner(-range,-range,-range);
vl::fvec3 max_corner(+range,+range,+range);
plot.setMinCorner(min_corner);
plot.setMaxCorner(max_corner);
plot.compute( my_func(), 0.900f );
sceneManager()->tree()->addChild(plot.actorTreeMulti());
}
void loadVolume(vl::ref<vl::Image> vol_img)
{
// reset camera
rendering()->as<vl::Rendering>()->camera()->setModelingMatrix( vl::mat4::getTranslation(0,0,20) );
// reset actors
sceneManager()->tree()->actors()->clear();
sceneManager()->tree()->addActor( mTextActor.get() );
// convert the image to a one-component float volume
// and keep it alive since we use its data directly
mDropImage = vol_img->convertFormat(vl::IF_LUMINANCE)->convertType(vl::IT_FLOAT);
// note: we use the image data directly without making copies:
volume->setup( (float*)mDropImage->pixels(), true, false, vl::fvec3(-5,-5,-5), vl::fvec3(+5,+5,+5), vl::ivec3(mDropImage->width(), mDropImage->height(), mDropImage->depth()) );
// start timing
vl::Time time;
#if 0
// downsample volume data and perform timing.
time.start();
volume = volume->downsample();
vl::Log::print( vl::Say("Downsampling time = %.2n\n") << time.elapsed() );
#endif
mMarchingCubes.reset();
mMarchingCubes.volumeInfo()->push_back( new vl::VolumeInfo(volume.get(), mThreshold) );
// run MarchingCubes with timing.
time.start();
mMarchingCubes.run(false);
vl::Log::print( vl::Say("Marching cubes: time = %.2n, verts = %n\n") << time.elapsed() << mMarchingCubes.mVertsArray->size() );
// setup isosurface geometry, actor and effect
// geometry
mIsosurfGeom = new vl::Geometry;
// install vertex and normal arrays and primitives generated by the marching cube algorithm
mIsosurfGeom->setVertexArray(mMarchingCubes.mVertsArray.get());
mIsosurfGeom->setNormalArray(mMarchingCubes.mNormsArray.get());
mIsosurfGeom->drawCalls().push_back(mMarchingCubes.mDrawElements.get());
#if 0
time.start();
ps.simplify( 0.1f, mIsosurfGeom.get() );
mIsosurfGeom->computeNormals();
vl::Log::print( vl::Say("Simplification: time = %.2n\n") << time.elapsed() );
#endif
// effect
fx->shader()->setRenderState( new vl::Light, 0 );
fx->shader()->enable(vl::EN_DEPTH_TEST);
fx->shader()->enable(vl::EN_LIGHTING);
fx->shader()->gocLightModel()->setTwoSide(true);
fx->shader()->gocMaterial()->setBackDiffuse(vl::green);
// show the volume in wireframe to see the tessellation.
#if defined(VL_OPENGL)
fx->lod(0)->push_back( new vl::Shader );
fx->shader(0,1)->enable(vl::EN_CULL_FACE);
fx->shader(0,1)->enable(vl::EN_DEPTH_TEST);
fx->shader(0,1)->enable(vl::EN_POLYGON_OFFSET_LINE);
fx->shader(0,1)->gocPolygonOffset()->set(-1.0f, -1.0f);
fx->shader(0,1)->gocPolygonMode()->set(vl::PM_LINE, vl::PM_LINE);
fx->shader(0,1)->gocColor()->setValue(vl::royalblue);
#endif
// actor
vl::ref<vl::Actor> actor = new vl::Actor(mIsosurfGeom.get(), fx.get());
// add actor to the scene
sceneManager()->tree()->addActor( actor.get() );
updateText();
}
void setupTest()
{
sceneManager()->tree()->eraseAllChildren();
if (mTest == 1)
showVolumes(true);
else
if (mTest == 2)
textureVolumeColor( showVolumes(false) );
else
if (mTest == 3)
vertexVolumeColor( showVolumes(false) );
else
if (mTest == 4)
setupMetaballs();
else
if (mTest == 5)
setupFountain();
else
if (mTest == 6)
setup3Dplot();
updateText();
}
void updateText()
{
if(mTest == 0)
str = vl::Say("Marching Cubes Test #0 - Volume Viewer - threshold = %.2n\n(drop a .dat file)") << mThreshold;
else
if(mTest == 1)
str = "Marching Cubes Test #1 - Multiple Volumes & Transparency";
else
if(mTest == 2)
str = "Marching Cubes Test #2 - Texture Colorized Volumes";
else
if(mTest == 3)
str = "Marching Cubes Test #3 - Vertex Colorized Volumes";
else
if(mTest == 4)
str = "Marching Cubes Test #4 - Metaballs";
else
if(mTest == 5)
str = "Marching Cubes Test #5 - Fountain";
else
if(mTest == 6)
str = "Marching Cubes Test #6 - 3D Function Plotting";
str += "\n(press the <- or -> key to change test)";
mText->setText( str );
mText->setDisplayListDirty(true);
}
void mouseWheelEvent(int w)
{
if (mTest != 0)
return;
if (w>0)
mThreshold += 0.010f;
else
mThreshold -= 0.010f;
mThreshold = vl::clamp(mThreshold, 0.0f, 1.0f);
vl::Time time; time.start();
mMarchingCubes.volumeInfo()->at(0)->setThreshold(mThreshold);
mMarchingCubes.run(false);
if (mIsosurfGeom)
mIsosurfGeom->setBufferObjectDirty(true);
vl::Log::print( vl::Say("Marching cubes: time = %.2n, verts = %n\n") << time.elapsed() << mMarchingCubes.mVertsArray->size() );
updateText();
openglContext()->update();
}
void fileDroppedEvent(const std::vector<vl::String>& files)
{
mTest = 0;
if(files.size() == 1)
{
if (files[0].endsWith(".dat"))
{
vl::ref<vl::Image> vol_img = vl::loadImage(files[0]);
loadVolume(vol_img);
}
}
else
{
std::vector<vl::String> files_sorted = files;
std::sort(files_sorted.begin(), files_sorted.end());
std::vector< vl::ref<vl::Image> > images;
for(unsigned int i=0; i<files_sorted.size(); ++i)
images.push_back(vl::loadImage(files_sorted[i])->convertFormat(vl::IF_LUMINANCE)->convertType(vl::IT_UNSIGNED_BYTE));
if (vol_img)
loadVolume(vol_img);
}
}
void resizeEvent(int w, int h)
{
BaseDemo::resizeEvent(w,h);
mText->setDisplayListDirty(true);
}
protected:
// volume
vl::MarchingCubes mMarchingCubes;
float mThreshold;
std::vector<vl::fvec3> mMetaball;
std::vector<vl::fvec3> mMetaballVelocity;
vl::Time mTimer;
std::vector< std::vector<vl::fvec3> > mMetaballsFrames;
static const int mMetaballsResolution = 32;
static const int mParticleCount = 25;
static const int mFrameCount = 20;
float mFountainSpeed;
vl::ref<vl::Image> mVolumeImage;
vl::ref<vl::Image> mColorImage;
vl::ref<vl::Image> mDropImage;
vl::ref<vl::Geometry> mIsosurfGeom;
int mTest;
};
// Have fun!