Skip to content

Port PlotCurveItem experimental opengl codepath to shaders#3055

Merged
j9ac9k merged 3 commits intopyqtgraph:masterfrom
pijyoi:curve-qtopengl
Jun 21, 2024
Merged

Port PlotCurveItem experimental opengl codepath to shaders#3055
j9ac9k merged 3 commits intopyqtgraph:masterfrom
pijyoi:curve-qtopengl

Conversation

@pijyoi
Copy link
Copy Markdown
Contributor

@pijyoi pijyoi commented Jun 8, 2024

Changes

  1. no more dependency on PyOpenGL, uses QtOpenGL only
  2. works on OpenGLES 2.0 (so far tested on Windows ANGLE)
  3. trivially add support for connect="pairs" (assumed to contain finite data only)
  4. add support for connect="all" and connect="finite"
  5. add support for connect=ndarray (assumed to contain finite data only)
  6. implement shadowPen
  7. implement origin shift to mitigate quantization effects due to single precision

Copy link
Copy Markdown
Member

@j9ac9k j9ac9k left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be other curves with the other connect mechanisms like finite that test with a np.nan value?

Love what you're doing with this!!!

__all__ = ['PlotCurveItem']


class GLC:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a good use-case for a dataclass with frozen=True

program = self.glstate.m_program
program.bind()
with warnings.catch_warnings():
# PySide2 : RuntimeWarning: SbkConverter: Unimplemented C++ array type.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate the comment specifying what warning we're looking to catch!

@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jun 9, 2024

Should there be other curves with the other connect mechanisms like finite that test with a np.nan value?

Implementing "pairs" already allows the user to plot arbitrary line segments and strips.
If we ignore details like join and miter, every additional mode could be reduced to GL_LINES aka pairs.

It shouldn't be difficult by taking reference from the following:

def arrayToLineSegments(x, y, connect, finiteCheck, out=None):

Alternatively for "finite" mode, line strips could be extracted for multiple calls to GL_LINE_STRIP:

def _arrayToQPath_finite(x, y, isfinite=None):

However, I don't think it's a particularly useful thing to implement if the only purpose is to support more of the API of PlotCurveItem. PlotCurveItem already has too many extras baked in like histogram and fill that don't play well with non-finites.

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jun 9, 2024

However, I don't think it's a particularly useful thing to implement if the only purpose is to support more of the API of PlotCurveItem. PlotCurveItem already has too many extras baked in like histogram and fill that don't play well with non-finites.

I'm in complete agreement here. The histogram and fill should either be removed from PlotCurveItem or we create another GraphicsItem (PlotLineItem?) that is a bit more primitive in the functionality, but that's a separate discussion.

Should the library emit warning (or raise an Exception) when using openGL with PlotCurveItem where if a user tries to do a histogram or a fill?

@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jun 9, 2024

A better solution is to only call paintGL for supported modes.
The script in #2878 now "works" whether enableExperimental is True or False.

@NilsNemitz
Copy link
Copy Markdown
Contributor

Hi @pijyoi , this looks great!

This does look like a good opportunity to rethink the architecture we now have with PlotCurveItem and PlotDataItem. It does not seem necessary to replicate everything that PlotCurveItem now does.

From my perspective it might be reasonable for a revised PlotCurveItem to take data

  • that is known-good (e.g. no non-finites)
  • does not require any modification (e.g. no step plots, no log mode)

That would reasonably move the task of preprocessing for NaNs to PlotDataItem. That could include processing all data into 'pairs', which would indeed be a very generic solution that should work for almost everything.

One thing I wanted to add here is that the data I encounter might have very long "good" segments (all finite), then a short section where a failure generates NaN, followed by another "good" segment. If the fastest path is still the equivalent of 'finite', 'skip-tests' mode, then being able to plot multiple long segments of known-good data might be interesting. PlotDataItem would need to figure out what mode to use.


As an additional comment/question, OpenGL is probably very happy to render the filled shapes used with fillLevel or by the FillBetweenItem. Is there a particular arrangement of code that avoids transferring the same curve data to the GPU twice?

@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jun 10, 2024

That would reasonably move the task of preprocessing for NaNs to PlotDataItem. That could include processing all data into 'pairs', which would indeed be a very generic solution that should work for almost everything.

One thing I wanted to add here is that the data I encounter might have very long "good" segments (all finite), then a short section where a failure generates NaN, followed by another "good" segment. If the fastest path is still the equivalent of 'finite', 'skip-tests' mode, then being able to plot multiple long segments of known-good data might be interesting. PlotDataItem would need to figure out what mode to use.

I think a good middle ground would be a hypothetical PlotLineItem that accepts a (not too large) list of "good" segments.
This would satisfy the current connect="finite" mode as well as users wanting to plot a great many line segments using a single GraphicsItem.
The connect="all" mode would just be a list of one segment.
This would map to multiple calls to QPainter.drawPolyline / glDrawArrays(GL_LINE_STRIP)

Although this would also support connect="pairs", it would incur a large overhead. (Nothing would stop a user from doing it, however) So "pairs" and "ndarray" mode could instead map to QPainter.drawLines / glDrawArrays(GL_LINES).

As an additional comment/question, OpenGL is probably very happy to render the filled shapes used with fillLevel or by the FillBetweenItem. Is there a particular arrangement of code that avoids transferring the same curve data to the GPU twice?

The filling API of pyqtgraph does not require the shape to be closed. Take for example the filling of the area under a sine curve. Even for the current pyqtgraph QGraphicsView implementation, this requires two separate calls:

  1. QPainter.fillPath to flood fill the "enclosed" area (pyqtgraph library does the closing)
  2. QPainter.drawPath to draw the sine curve

For OpenGL (caveat, my knowledge of OpenGL is limited), filling the area would require generating the vertices for the triangle strip that covers the area to be filled. So it would in any case be two different sets of vertices to be sent to the GPU. One for the curve to be drawn using GL_LINE_STRIP and the other to be drawn using GL_TRIANGLE_STRIP.

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jun 10, 2024

I think implementing a theoretical PlotLineItem so we can be aggressive about API breakage is a great idea. Also gives us the opportunity to use QPainter.drawPolyline which the benchmark seems to indicate it works better.

The one question I have is, would it make sense for any of the downsampling stuff to occur at that level, or should that remain in the PlotDataItem/higher level objects to implement?

@NilsNemitz
Copy link
Copy Markdown
Contributor

@pijyoi , thank you for your reply!

Your suggestions look very reasonable to me, I have no further comments. I am certain that if you encounter a way to streamline the OpenGL data transfer, you'll be the very first to jump on it. :)

@j9ac9k,

I think the downsampling would want to be placed at the same level where transforms like log mode are handled.

  • for the heavier transforms, it makes sense to have the option to downsample first, THEN transform.
  • either of these actions has to inspect the dataset and generates insights into e.g. the existance of non-finites if it runs. This should help choose the best drawing mode, for example. (See PlotDataItem's 'connect=auto' mode.) Having this information in one place may make things a little more elegant than having cascaded checks through the different levels.

@pijyoi pijyoi force-pushed the curve-qtopengl branch 2 times, most recently from 60ef711 to fc02759 Compare June 10, 2024 14:58
@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jun 11, 2024

I think the downsampling would want to be placed at the same level where transforms like log mode are handled.

I agree that the transforms and downsamples should stick together. In what order that happens, I think there is an argument to be made on how non-linear the transform is; but that's a discussion for another PR.

The next concern I have right now is involving "thick" lines. I don't think this needs to be addressed in this specific PR, but I'd like to at least bring it up. The issue right now is the use of glLineWidth(width_values), The supported glLineWidth range of values to my understanding is limited by the OpenGL driver, and in some drivers it is limited to just 1 pixel, and even if the desired thickness is within the reported range, there are issues with it not working. There is a small github repo that points this out and proposes some alternatives (of various complexities).

https://github.com/mhalber/Lines?tab=readme-ov-file#why

Looking at some of the alternatives listed, the geometry shader seems like a good place to inject some functionality here, but that requires OpenGL 3.2. I'd be curious to hear anyone else's thoughts.

@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jun 12, 2024

Looking at some of the alternatives listed, the geometry shader seems like a good place to inject some functionality here, but that requires OpenGL 3.2. I'd be curious to hear anyone else's thoughts.

Actually it uses a number of OpenGL 4.5 API (https://github.com/mhalber/Lines/blob/f20b5e2509d98e5d8e273d9730036ec5aecaa02d/geometry_shader_lines.h#L203-L214)

@pijyoi pijyoi marked this pull request as ready for review June 12, 2024 13:18
@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jun 12, 2024

Looking at some of the alternatives listed, the geometry shader seems like a good place to inject some functionality here, but that requires OpenGL 3.2. I'd be curious to hear anyone else's thoughts.

Actually it uses a number of OpenGL 4.5 API (https://github.com/mhalber/Lines/blob/f20b5e2509d98e5d8e273d9730036ec5aecaa02d/geometry_shader_lines.h#L203-L214)

Oh yeah, OpenGL 4.5 API is a non-starter, anything above 4.1 won't work , alternative being QRhi, but that's a whole other thing.

do_fill_outline = do_fill and self.opts['fillOutline']

if (
getConfigOption('enableExperimental')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think we should hide this functionality being the enableExperimental config option at this point? The downside of doing this is that it won't get as much usage and we may not get any feedback on its functionality.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#2257 shows that there's 1 user using OpenGL while simultaneously leaving enableExperimental=False. So at this point, enabling paintGL codepath w/o enableExperimental=True may constitute an API breakage for them.
@bbc131, would you care to chime in on this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#2257 shows that there's 1 user using OpenGL while simultaneously leaving enableExperimental=False. So at this point, enabling paintGL codepath w/o enableExperimental=True may constitute an API breakage for them. @bbc131, would you care to chime in on this?

We don't use this configuration (OpenGL and enableExperimental=False) anymore, so this won't break anything for us.

__all__ = ['PlotCurveItem']


class OpenGLState:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may need to inherit from QObject due to the cleanup method being a Slot.

In some non-pyqtgraph Qt related work, I can into issues when connecting to a slot that wasn't decorated and wasn't decorated with the @Slot() decorator.

https://www.riverbankcomputing.com/static/Docs/PyQt6/signals_slots.html#the-pyqtslot-decorator

Connecting a signal to a decorated Python method also has the advantage of reducing the amount of memory used and is slightly faster.

I know pyqtgraph doesn't decorate its slots but I anticipate this being a change that we'll roll out in the near future.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was your issue because of being called from another thread?
In this case here, cleanup has to be a direct call and thus doesn't need to be registered as a QObject metamethod.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in my case, I was working with qasync so I suppose that's another thread under the hood. Your grasp of the respective internals are better than mine, so I'll defer to your judgment 👍🏻

@campagnola
Copy link
Copy Markdown
Member

campagnola commented Jun 14, 2024

@pijyoi, thank you so much for the fantastic work on pyqtgraph performance. This is an excellent addition like so many that came before.

From my perspective it might be reasonable for a revised PlotCurveItem to take data

* that is known-good (e.g. no non-finites) 
* does not require any modification (e.g. no step plots, no log mode)

I am all for rethinking the API and separating concerns, especially where it leads to better performance. However I don't think this PR is the right place to start that work, mostly because (you can hear my experience with vispy here) OpenGL is really weird in some ways, and it tends to guide one toward strange architectural contortions that otherwise make life more difficult.

As an example of that point, there's an even more interesting performance target which is to write GPU shaders that perform all of the data processing. Ideally, we should send only the raw data as provided by the user to the GPU and the rest happens there--filtering for finite values, transformations (both linear and nonlinear), construction of new geometry like triangle strips or histogram steps, etc. In that case it may not be necessary to have a simplifed PlotLineItem if, in the end, PlotCurveItem is able to handle all types of data efficiently.

I think I prefer a somewhat different approach (that we have discussed before), using a PlotData class that encapsulates raw x,y data plus a set of filters / transforms and then allows the user of that data (PlotDataItem, PlotCurveItem, etc) to specify the result that is needed. For example, if PlotCurveItem wants strips of nan-free data, then PlotData can provide that in the most efficient way. If there is a ScatterPlotItem that uses the same PlotData instance, then when it asks for nan-free values, the PlotData instance can return previously cached results. If you wanted to implement all or part of this in GPU, then you could simply request PlotData give you raw or partially-processed data, plus the extra metadata needed to complete the processing in a GPU shader.

The issue right now is the use of glLineWidth(width_values), The supported glLineWidth range of values to my
understanding is limited by the OpenGL driver, and in some drivers it is limited to just 1 pixel,

This is indeed a major issue, and one that I don't recommend taking on lightly. For now, just having the high performance of 1px-width lines covers the vast majority of high performance use cases. If you ever want to go further than that, then let's have a long talk about the architecture of vispy and how similar concepts could be implemented here.

@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jun 18, 2024

I have implemented the origin shifting suggestion in #2417.
This does fix the obvious issue demonstrated in #2020 (comment)

This necessitates extra computation in that the data has to be converted to double precision first to perform the translation before finally converting it back to single precision for OpenGL vertex buffers.
But correctness over speed, right?

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jun 18, 2024 via email

@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jun 18, 2024

Modified the MouseSelection example to demonstrate the quantization effect if origin shift not applied:
Screenshot 2024-06-18 235904

@j9ac9k j9ac9k merged commit 0dd3c01 into pyqtgraph:master Jun 21, 2024
@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jun 21, 2024

Thanks for this PR @pijyoi

Getting a more robust OpenGL implementation throughout the library would be such an amazing improvement. Also having some implementations done makes it easier for contributors to attempt to add OpenGL support for other parts too.

I hope we can revisit the enableExperimental setting that this is being kept behind, but that's a decision for a future PR.

@pijyoi pijyoi deleted the curve-qtopengl branch June 21, 2024 05:52
@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jun 23, 2024

non-working: if QPen is created with a non-solid brush.
#3068 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants