Is Path slow? What is the best tool for spectrum analyser graph?

I’m not familiar with Compose, but my assumption would be that with that approach you’re drawing to some sort of graphics context directly. Whereas with the juce::Path approach you have that copying step as I mentioned above which, for a path with a lot of points, is going to be very costly.

What’s wrong with this approach? This would likely be more comparable to Compose

I also think that the number of segments of the path is probably the biggest problem.

Looking at what you are drawing, I think that you could compose your path using Bézier curves instead of lines. That would allow you to greatly reduce the size of the path.

Here is a function I use for this. Experiment to find the smallest number of points which produce a good result. Hope this helps!


/** Creates a curved path (a Catmull-Rom spline) which passes through a set of points.
 
    The provided points determine the path's curvature, with no additional control points
    being necessary. The path is composed entirely of cubic Bézier curves.
    
    @param alpha  determines the parameterization of the spline.
                A value of 0.0f will cause it to be uniform, 0.5f centripetal, and 1.0f chordal.
    
    @param roundness determines the general roundness of the spline.
                Values above 1.0f will exaggerate it, a value of 0.0f will produce straight lines.
    
    @param treatFirstAndLastPointsAsControls if false, the curvatures of the first
                and last line segments are calculated automatically. If true, the path is
                drawn from the second to the penultimate point, and the first and last points
                control the curvatures of the first and last drawn line segments.
 */
inline Path createCurvedPath (const std::vector<Point<float>>& points, 
                              float alpha = 0.5f,
                              float roundness = 1.0f,
                              bool treatFirstAndLastPointsAsControls = false) noexcept
{
    jassert (points.size() >= 3); /// You need to provide at least three points
   
    auto controlPoint = [alpha, roundness] (Point<float> p0, Point<float> p1, Point<float> p2)
    {
        auto d1 = p1.getDistanceFrom (p0);
        auto d2 = p2.getDistanceFrom (p1);
        
        if (d1 == 0)
            return p1;
        
        auto d1a = pow (d1, alpha);
        auto d2a = pow (d2, alpha);
        auto d1a2 = pow (d1, alpha * 2.0f);
        auto d2a2 = pow (d2, alpha * 2.0f);
        
        auto a = d1a2 * p2 - d2a2 * p0 + (d2a2 - d1a2) * p1;
        auto b = 3.0f * d1a * (d1a + d2a);
        
        return p1 + (a * roundness) / b;
    };

    auto mirror = [] (const Point<float> p, const Point<float> m)
    {
        return m - (p - m);
    };
    
    Path path;
    auto s = int (points.size());
    path.preallocateSpace (s * 7 + 4);
    path.startNewSubPath (points[treatFirstAndLastPointsAsControls ? 1 : 0]);
    
    if (! treatFirstAndLastPointsAsControls)
        path.cubicTo (controlPoint (mirror (points[1], points[0]), points[0], points[1]),
                      controlPoint (points[2], points[1], points[0]),
                      points[1]);
    
    for (int p = 1; p < s - 2; ++p)
        path.cubicTo (controlPoint (points[p-1], points[p], points[p+1]),
                      controlPoint (points[p+2], points[p+1], points[p]),
                      points[p+1]);

    if (! treatFirstAndLastPointsAsControls)
        path.cubicTo (controlPoint (points[s-3], points[s-2], points[s-1]),
                      controlPoint (mirror (points[s-2], points[s-1]), points[s-1], points[s-2]),
                      points[s-1]);
        
    return path;
}
1 Like

Looking at the profile data the bulk of it is in core graphics calls so I’m not sure there is a lot we can do about that. Kotlin Compose may be doing more work on the GPU.

I don’t think it’s likely there will be some quick and easy solution on the JUCE framework end that we’re going to be able to offer, but I’m fairly confident there are plenty of other optimisations that can be made, such as the ones already suggested above.

I’m not sure how easy that would be, I think it would need to have access to the context, so right now you can pre-compute your path and then only those bits that need the context are done in the paint function. If you wanted a context that you could use outside the paint function then isn’t that what an Image gives you? I realise if you’re creating the path and filling/stroking it in the paint function then there are two loops, but the work from both loops still needs to be done. I wonder how much of a speedup there would actually be if those were combined. Although maybe I’m missing something?

Do I need to call preallocateSpace() every time I prepare to paint the path, or just once when the Path object is created?
If the former, does that imply the Path is freeing all its memory every time clear() is called?

Not sure if it’s a coincidence, but when I commented out transform I got 26.6-28.8% cpu. It just dropped from 59.6.

case Path::Iterator::lineTo:
            // transform.transformPoint (i.x1, i.y1);
            CGContextAddLineToPoint (context.get(), i.x1, flipHeight - i.y1);
case Path::Iterator::startNewSubPath:
            // transform.transformPoint (i.x1, i.y1);
            CGContextMoveToPoint (context.get(), i.x1, flipHeight - i.y1);


[Edit] I’m not entirely sure, maybe it switches to Efficient vs Productive Cores?

1 Like

IIRC, clear() will deallocate any data so yes, you’d need to call preallocateSpace() again before adding more points. Don’t quote me on that though - would have to look at the implementation to be sure.

That’s not surprising - transforms involve many multiplications, so saving that on every point you’re drawing will have a big impact.

1 Like

I’m not entirely sure, maybe it switches to Efficient vs Productive Cores?
{Edit} - Nope, I checked from the activity monitor all 4 Efficiency cores empty and only 1 Pro core is loaded.

Yeah I don’t think it would be doable with the current way the graphics contexts work. Maybe something to consider is being able to do an equivalent path rendering technique directly with juce::Graphics. E.g.

void paint(juce::Graphics& g)
{
    g.startPath (x, y);
    g.lineTo (x2, y2);
    // etc.
    g.closePath();
    g.fillPath(juce::Colours::red);
    g.strokePath(juce::Colours::blue, 3.0f);
}

Contexts could fallback to building a juce::Path internally if they don’t have a native path renderer

I think it actually calls into the quick clear function, which deletes the path data but keeps the memory allocated. Calling clear() then preallocateSpace() will keep adding to the pile and you’ll end up allocating all the memory.

You’re right, it calls down to juce::Array::clearQuick()… so calling preallocateSpace() after calling clear() won’t allocate any more space if the required space is already allocated.

The sources reveal that is not the case:

clearQuick() is just setting size = 0;

Fanduss beat me to it

ah yes, I misread the function:

void Path::preallocateSpace (int numExtraCoordsToMakeSpaceFor)
{
    data.ensureStorageAllocated (data.size() + numExtraCoordsToMakeSpaceFor);
}

data.size() will be 0 now. For some reason my after midnight brain thought size was capacity.

Yeah I did consider that, but I still wonder just how much that would actually save, if you’re seeing the cost is in that function the work has to be done at some point. You could probably easily mock it up Core Graphics allows you to access the current context so you could just write it directly in Core Graphics and see what sort of difference you see.

I would strongly recommend using a profiler rather than relying on Activity Monitor, that should give you a bit more detail.

1 Like

Using a path you esentially have something like

Data Source (waveform, FFT, etc.)
copy to
juce::Path
copy to
CoreGraphics

If juce::Graphics had a path building API you remove that second copy as it becomes

Data Source
copy to
CoreGraphics

Just did a quick test:

In paint(), I have two variations - paintWithPath() which adds the points to juce::Path and then calls fillPath(), and paintDirectToCoreGraphics() which uses some example methods on juce::Graphics to draw directly to the CG context:

    void paint(juce::Graphics& g) final
    {
        std::array<juce::Point<float>, 50000> points;
        auto& rng = juce::Random::getSystemRandom();

        for (auto& point : points)
        {
            point = {
                rng.nextFloat() * getWidth(),
                rng.nextFloat() * getHeight(),
            };
        }

        g.setColour(juce::Colours::white);

        auto start = juce::Time::getMillisecondCounter();

        while ((juce::Time::getMillisecondCounter() - start) < 30000)
        {
            paintWithPath(g, points);
            // paintDirectToCoreGraphics(g, points);
        }

        DBG(paintCounter);
    }

    void paintWithPath(juce::Graphics& g)
    {
        path.clear(); // space preallocated in constructor
        path.startNewSubPath(points[0]);

        std::for_each(std::begin(points) + 1,
                      std::end(points),
                      [this](const auto& point) {
                          path.lineTo(point);
                      });

        path.closeSubPath();

        g.fillPath(path);
        paintCounter++;
    }

    void paintDirectToCoreGraphics(juce::Graphics& g)
    {
        g.startPath(points[0]);

        std::for_each(std::begin(points) + 1,
                      std::end(points),
                      [&g](const auto& point) {
                          g.lineTo(point);
                      });

        g.closePath();
        g.fillLastPath();
        paintCounter++;
    }

The additional Graphics methods are as so:

void CoreGraphicsContext::startPath(Point<float> p)
{
    CGContextBeginPath (context.get());
    CGContextMoveToPoint (context.get(), p.x, flipHeight - p.y);
}

void CoreGraphicsContext::lineTo(Point<float> p)
{
    CGContextAddLineToPoint (context.get(), p.x, flipHeight - p.y);
}

void CoreGraphicsContext::closePath()
{
    CGContextClosePath (context.get());
}

void CoreGraphicsContext::fillLastPath()
{
    CGContextFillPath (context.get());
}

Commenting out one of the painting approaches at a time and running the app for 30s:

  • paintWithPath() - 2241 iterations
  • paintDirectToCoreGraphics() - 7730 iterations

I’m sure there’s better ways to benchmark these approachs, but that was what I got with a quick mockup this evening

4 Likes

Interesting! Is there a windows/d2d variant of this, too?

I’m not familiar with how Windows handles fillPath(), but I wouldn’t be surprised if it does something similar with Direct2D.

Edit - yes, it looks like there’s something similar to CoreGraphicsContext::createPath() called pathToGeometrySink(), so you could presumably implement Graphics::lineTo() etc. to call down to ID2D1GeometrySink directly.

I ran some similar tests yesterday too, I didn’t find the weightings as extreme as yours but I could see some improvement, however the savings were really coming from there being no need to do any work in Path::lineTo. The work of “copying” to CoreGraphics is really just building the path, so this work still happens it’s just moved from fillPath to all the calls to lineTo. In other words as you point out you save the “copy to juce::Path” step rather than the “copy to CoreGraphics”. There are some downsides though.

  1. According to the docs you can’t use the same path twice on the CoreGraphics context (for example if you want to fill and stroke - they have a separate method for that), this will probably create some foot guns

  2. Doing heavy work in lineTo means heavy work in your loop which could have performance penalties in terms of cache misses, I had cases where juce::Path was doing more work but the overall performance was actually better, presumably because the CPU had a better time handling the loop

In the end the benefit in terms of actual time was relatively small, but I agree there are some benefits that can be gained with this approach. Looking into the CoreGraphics SDK more there is a CGPath so we could probably store one of these in juce::Path directly and use that when it comes to drawing, which would also mean everyone can reap the benefits without API changes.

That being said, I need to say thank you because this lead me to look at strokePath(). When we call strokePath() the LowLevelGraphicsContext creates a new Path based on the given Path and the StrokeType. Not only does this cause allocations for larger paths, but it also means more “copying” of the path to Core Graphics. I found if we implement strokePath using Core Graphics calls there are potentially some much more significant gains to be made with no API changes required or any need to change/update user code.

6 Likes

The only other thing I’ll mention that may be a nice to have, is the ability to use the path object itself as a type of container.

My current use case is a scrolling waveform. Each frame only a few points change, yet you still need to clear the path and recreate all the points. It would be cool if you could remove points. Then you could pop, transform, add new points.

Not sure how beneficial this would be, but it’s the other major copy that happens when using paths.

1 Like