How to get sharp edge with particles

This is my current output:

image

As you can see, the edges are “blocky”.

This is already using round particles through discarding pixels in the Points material custom fragment shader:

float distanceToCenter = length(gl_PointCoord - 0.5);
if (distanceToCenter > 0.5)
    discard; 

This is the expected result:
image

Crisp edges when the particles are in their original position (distanceToOrigin < 0.001)

Any help would be much appreciated, thanks in advance guys!

You’re getting blocky edges because it’s impossible to draw a smooth circle with pixels that are either visible or not, so using discard is not the soution. Instead, you need to create the illusion of a smooth circle by creating a very small opacity gradient around the edges.

Here’s a good explanation of how to antialias a shape using smoothstep or derivatives: hlsl - How do I use screen-space derivatives to antialias a parametric shape in a pixel shader? - Game Development Stack Exchange

1 Like

You can also use SDFs, they have theoretically-infinite precision similar to SVGs.

A related topic: Shader to create an offset, inward-growing stroke?

Sorry for the late response guys!

So, I tried a bunch of AA methods I could find (MSAA, SMAA, FXAA), using them as a postprocess pass, but none of them were able to correct the jagged edges sufficiently, I even tried tweakin their code to sample more neighbouring texels and still no good.

Then I guess there’s only SDFs left… Though I’m still trying to wrap my head around how to achieve AA with that since I’m using a Points mesh that has no exact shape/edge defined

Btw I tried the method described here, grabbing the implementation from pmndrs @react-three/drei PointMaterial:

      vec2 cxy = 2.0 * gl_PointCoord - 1.0;
      float r = dot(cxy, cxy);
      float delta = fwidth(r); // abs(dFdx(p)) + abs(dFdy(p))
      float mask = 1.0 - smoothstep(1.0 - delta, 1.0 + delta, r);
      gl_FragColor = vec4(gl_FragColor.rgb, mask * gl_FragColor.a );

Sadly it does not fix the edges, and also introduces some artifacts that completely ruin the solid look of the shape:
image

As a general rule, to get smooth edge with discrete elements (squares, circles, giraffes, whatever), they should:

  • either be too small and too many, so the ‘blocky’ edge is invisible to human eye
  • or overlap each other in order to fill the gaps

Another two options are:

  • Using postprocessing effect, but it has limited span, it can fix only relatively small irregularities
  • Build a mesh over the points, but this is hard to do

Here is a video of overlapping particles. When they form a ring, it has almost sharp edge. I do not know how big are your particles, how close are they, so I cannot be of any further help.

Sure.

1 Like

Are you still using discard in your shader? If you try to use the drei pointmaterial instead of your shader, do you get the same edges and artifacts?

Turns out overlapping particles was exactly the way to go! Though I still had to combine other techniques to get an even better result.

This is the final output:
image
(The shape is so crisp you can barely tell its made entirely out of tiny particles)

In order to achieve that, this is what I did:

  • Load the desired object as SVG (SVGLoader) and iterate through its paths in order to get shapes
svg.paths.forEach((path, index) => {
    const shape = path.toShapes(false)[0] // THREE.Shape

With the Shape object, we can call a method that divides the entire path equally into the specified ammount, returning all those divisions as points with coordinates exactly on top of the path, thus giving us a perfect outline (as long as there’s enough divisions)

// Outline
const points = shape.getSpacedPoints(1500)
points.forEach((point) => {
      // Calculate the normalized coordinates
      const ndcX = (point.x / svgWidth) * 2 - 1
      const ndcY = (-point.y / svgHeight) * 2 + 1


      // Adjust the normalized coordinates to viewport coordinates considering the object aspect ratio
      const position = new THREE.Vector3(
        (ndcX * viewport.width) / 2,
        (ndcY * svgHeightInViewport) / 2,
        0,
      )

      // positions array can later be fed into a buffer geometry
      positions.push(position.x, position.y, position.z)
    })
  • To further improve the outline look, I applied antialiasing to the particles shader (code taken from pmndrs/PointMaterial:
    // Fragment shader
    vec2 cxy = 2.0 * gl_PointCoord - 1.0;
    float r = dot(cxy, cxy);
    float delta = fwidth(r);     
    float mask = 1.0 - smoothstep(1.0 - delta, 1.0 + delta, r);
    gl_FragColor = vec4(gl_FragColor.rgb, mask * gl_FragColor.a);
  • And for the final touch, make sure you have antialias: true set in your renderer.

I thought I’d chime in as well. The issue is generally to do with filtering.

That is, you are likely using Nearest magFilter option on the texture, and you get he very characteristic pixel boundaries like here
image

and here
image

How to solve that? Well, one relatively simple way is to use higher-order filtering, instead of using Nearest filter, use Linear, that will get you a bit further, you’ll still see stair-stepping, but it will be a more like curved steps instead of sharp and jagged ones.

If you want to go further, I recommend cubic-order filters such as CatmullRom, it will required you to write a bit of shader code though. Here is a good example of texture filter differences:

The filters from left to right are:
Nearest, Linear, Cubic Lagrange, Cubic Hermite

Now imagine instead of color boundaries that are very sharp, it would be alpha values (transparency) that is filtered.

With a good cubic-order filter you will get close-enough to a smooth circular outline that the difference will not matter too much I believe. Especially when particles are smaller on screen and are under motion.

PS

Turns out I didn’t read the question in full detail. Still, going to keep the above as it might be useful for some.

Back to your specific question, overlap will help, and will be necessary, but it’s still an aliasing issue, to help hide it I would recommend using some kind of noise and jitter positions of individual particles within a single pixel. A good blue noise will do the trick.

For an easier option with jitter, a repeated Halton sequence will work too.

1 Like