Particle Stock – Part 2

This got pushed by the wayside a little as a number of other tools took up more time, but I’ve finally gotten it to a suitable standard to release it. There are a number of improvements and optimizations I plan to add over the coming days, but at the moment it’s pretty useful as is! The premise is simple: Effects can be saved out as 2D image sequences, and read in to render out 3D particle effects with considerable speed and easy customization.

BurstGif

Installing

Download from here.

Simply place the folder in your plugins directory and include the path in the init.py using nuke.addPluginPath().

Code Breakdown

The basics of a particle renderer are explained in the first part of this blog post, but there’s a lot more going on in this version. There is quite a lot of code, and a lot of it is likely to change so I won’t be going through it as thoroughly as other posts.

The main premise is that using nukes hidden node, ParticleToImage (Thank you to Hagbarth for pointing this one out, I strongly recommend looking at his work) can convert particle effects to 2D image sequences, storing position, size and colour data. We can also store other attributes like velocity, age etc… by writing them to the colour using a particle expression node. We store all of these as layers in a single exr and pass it to the particle renderer.

The tool is broken up into a lot of standard nuke ops to save us doing all the heavy lifting. The most important thing is being able to place the effect where we want it, so we use a standard wireframe cube to show it’s bounding box. But how to work out the bounds of the particles? Blink can use reduction kernels to work out these values, but is not going to give us the information in a way we can easily use. So we’ll use good old fashioned Min Color nodes. These scan the image for the minimum colour in a channel. If we invert the values before we run, we’ll find the max.

def getMinMax( srcNode, channel='depth.Z' ):
    MinColor = nuke.nodes.MinColor( channels=channel, target=0, inputs=[srcNode] )
    Inv = nuke.nodes.Invert( channels=channel, inputs=[srcNode])
    MaxColor = nuke.nodes.MinColor( channels=channel, target=0, inputs=[Inv] )

    curFrame = nuke.frame()
    nuke.execute( MinColor, curFrame, curFrame )
    minV = -MinColor['pixeldelta'].value()

    nuke.execute( MaxColor, curFrame, curFrame )
    maxV = MaxColor['pixeldelta'].value() + 1

    for n in ( MinColor, MaxColor, Inv ):
        nuke.delete( n )
    return minV, maxV

This is a useful little function which is explained in the nuke python documentation and allows us to grab the min/max from each channel of our position image. We then calculate the center of this box and apply it as an offset to the position image, and center our box on the origin. Now the particles are aligned with the bounding box and ready to be placed where we need them. Unfortunately, there is no nuke callback to update per frame (A surprising oversight) so users will have to update the bounding box manually. However, because we offset the particles when doing this, we’ll effectively move the particles each time. This can be frustrating if you’ve carefully aligned them to your scene, so there’s an option to prevent it from centering the pivot.

So now that we have the particles where we want them, we’re ready to begin rendering. Part one of this post explains how to render points to screen space, so what we want to look at here is how to give these points size, shape and motion.

 

Size

This one is easy. We’ll treat each particle as a quad in 3D space, offsetting it’s size and project two opposing corners to the screen which will give us the bounds for our particle. We use the ratio of the filter image to decide our quad size (or leave filterAspectWidth and filterAspectHeight at 1 for a square if we’re not using a filter).

// Add size to create a quad centered on the particle
// Can use just bottom left and top right for a non distorted plane
float psize = use_psize ? size * particle.w : size;
float4 botleft = point_local - float4( psize * filterAspectWidth, psize * filterAspectHeight, 0.0f, 0.0f );
float4 topright = point_local + float4( psize * filterAspectWidth, psize * filterAspectHeight, 0.0f, 0.0f );

// Transform position to screen space
float4 screen_bl = multVectMatrix( botleft, perspM );
float4 screen_tr = multVectMatrix( topright, perspM );

// Fit screen space to NDC space ( 0 to 1 range ), multiply to get cornerpoints of particle
float bl_x = ( screen_bl.x + 1 ) * 0.5f * width + overscan;
float bl_y = ( screen_bl.y + 1 ) * 0.5f * height + overscan;
float tr_x = ( screen_tr.x + 1 ) * 0.5f * width + overscan;
float tr_y = ( screen_tr.y + 1 ) * 0.5f * height + overscan;

Now we can just iterate over each pixel in the bounds. It’s unlikely that these bounds will lie exactly on pixels, so we’ll need to fade the edges. We can measure the distance from each pixel edge to the opposite projected screen edge to find out what portion of the edge pixel is covered. We multiply the distances to get the total amount of the pixel that’s covered.

// Percentage area covered
float distanceFromLeft = min( out.x + 1 - bl_x, 1.0f );
float distanceFromBot = min( out.y + 1 - bl_y, 1.0f );
float distanceFromRight = min( tr_x - out.x, 1.0f );
float distanceFromTop = min( tr_y - out.y, 1.0f );

float4 result = out_colour * ( distanceFromBot * distanceFromLeft * distanceFromRight * distanceFromTop );

However, if we have a lot of pixels close to camera, or really large in size, we could end up doing far too much calculation. Running on the GPU, this could easily cause it to exceed the graphics timeout, or just take a really long time. To avoid this, I added a safety option that caps the size at a given limit. It also outlines the particle with a red border by default to let the user know it’s capping. This can be disabled to have controlled size limits without the borders.

// Limit maximum size to safety limit : prevents timeout crashes
bool edging = false;
if ( safety ) {
  if ( range.x > safety_limit || range.y > safety_limit ) {
    start += int2( max( 0, ( range.x - safety_limit ) / 2 ), max( 0, ( range.y - safety_limit ) / 2 ) );
    range = int2( safety_limit, safety_limit );
    edging = !edge_disable;
  }
}

 

Shape

We can give any filter image now and fit it to these bounds. Simply fit our on screen bounds to a 0 to 1 range, then scale up to the filter size to find the matching pixel. To avoid oddly sharp images, blink has a handy bilinear() function that allows us to sample a point inside a pixel and estimate the appropriate value of the four closest pixels.

if ( use_filter ) {
  // Fit the new size to the filter image, exit if 0 alpha
  float filterX = ( x / float( range.x ) ) * filterWidth;
  float filterY = ( y / float( range.y ) ) * filterHeight;
  float4 filter_value = bilinear( filterImage, filterX, filterY );
  if ( filter_value.w <= 0.0f )
    continue;
  for ( int component = 0; component < 4; component++ )
    result[ component ] *= filter_value[ component ];
}

 

Motion

Motion blur is a major part of particle effects, and a tricky thing to do. Rather than render the blur in camera (Alhough possible to do, and would allow us to subsample to motion for curved blur, it would make the node quite heavy), we’ll just render the on screen velocity vector and let the user control it with a vector blur. We can easily pull the position of the particle on the previous, current and next frame, so we can get a simplified interpolation of the movement by getting the direction from the previous to the next position.

float2 out_vel = 0.0f;
if ( add_velocity ) {
  // Calculate position from previous frame, project, and trace screen space vector motion
  float4 vel = velocity();
  float4 prev = particle - vel;
  float4 next = particle + velocityNext();

  // Smooth derivative of the particle at current point
  float4 dir = prev - next;
  // Apply velocity length to smoothed direction
  dir[3] = 0.0f;
  vel[3] = 0.0f;
  dir = normalize(dir) * length(vel);

  // Move new end position to screen space
  particleSpace = multVectMatrix( particle + dir, particleTransform );
  point_local = multVectMatrix( particleSpace, worldToCamM );
  screen_center = multVectMatrix( point_local, perspM );

  // Calculate screen velocity
  float last_x = ( screen_center.x + 1 ) * 0.5f * width + overscan;
  float last_y = ( screen_center.y + 1 ) * 0.5f * height + overscan;
  out_vel = float2( ct_x - last_x, ct_y - last_y );
}

You may notice from the code above that I’m doing something a bit of a roundabout way. Really, the particles should all be moved to local position before giving it to the renderer, it would save a lot of hassle and is one of the first things I’m doing in the next version.

Once we have the direction, we get the distance of the movement and apply it in the direction. We can just project this to screen and output the screen distance.

 

Depth sorting

One of the biggest problems we’ll run into after all this, is depth sorting. Although we’re able to calculate the depth of the particle, because we’re calculating them individually, we have no easy way to compare them while rendering so particles won’t necessarily appear in the right order. The best method (At least for blink) is to calculate the depth first, storing it in it’s own channel (the zbuffer), and then reading it in when rendering to ensure our foremost particle gets the priority. This works for opaque objects, but once transparency is involved we hit a road block. We can’t solve it with a single channel anymore, we need to know how many particles overlap and there’s no reasonable way in blink to pass that much information between pixels. So, I cheat. Sorry, but there’s no easy solution here.

We instead ensure there’s always space for the foremost particle to be drawn (As determined by the zbuffer) and then fit the remaining values into the space as they come. In the case of near opaque images, we’ll barely see the secondary object through the first, and in the case of highly transparent images, they’ll blend together enough to be near indistinguishable. While the solution is far from perfect, it’s excusable in 99% of cases.

We only need one channel for the zbuffer though, and we’ll already be doing most of the calculation of the rendering to find it. We’ll also need all four channels when rendering to get the colour and alpha, so if we need any other information, now’s the time to get it. So we’ll calculate the velocity now if required and shuffle it back in as a pass later. Also, because we’ve calculated what particles actually hit the screen, we can use the last channel to separate these. Ones that don’t contribute to the final image are set to inactive, so the renderer can safely ignore them.

 

Conclusion

And that is that. There’s a few other things at play in the setup, but those are fairly self explanatory for anyone looking them over. Once again, if anyone has any questions feel free to ask!

Advertisement
Particle Stock – Part 2

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s