Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement useGeometry hook to ease creation/update of geometries #1515

Merged
merged 1 commit into from
Oct 31, 2023

Conversation

axelboc
Copy link
Contributor

@axelboc axelboc commented Oct 26, 2023

This is a big one, even though I've tried to keep a few things out of it.

Goal

The goal is to make it more straightforward for us internally but also for consumers of @h5web/lib to implement and use custom buffer geometries (like maybe thick/dashed lines that don't change while zooming). Attn @PeterC-DLS, as this might be of interest to you for Davidia.

Custom buffer geometries are useful to avoid blowing up the bundle and increasing maintenance cost by adding three-stdlib and drei as (peer) dependencies to @h5web/lib and consumer applications. Moreover, Three's/drei's built-in geometry code can be:

  • quite obscure, poorly documented, and difficult to debug;
  • inefficient - e.g. by looping through the data a second time to create an index buffer ... and even a third time inside arrayNeedsUint32;
  • easily misused, especially in a React environment - e.g. recreating/updating on every render by forgetting to memoise things properly; forgetting to set needsUpdate, etc.

Implementation

The main entry point is a new hook called useGeometry, which accepts:

  1. a buffer geometry class that inherits H5WebGeometry (see below);
  2. the number of items to iterate over when updating the geometry (called dataLength);
  3. a bunch of parameters that are required to prepare/update the various buffer attributes in the geometry (abscissas, values, scales, interpolators, etc.

H5WebGeometry is an abstract class that inherits BufferGeometry and specifies two methods: prepare and update.

  • Whenever the number of items to iterate over changes, useGeometry creates a new instance of the given H5WebGeometry sub-class. The sub-class's constructor typically takes care of initialising the buffer attributes.
  • Whenever any of the parameters used by the geometry change, useGeometry updates the geometry instance as follows: first, it calls the prepare method to store the updated parameters in the geometry instance; then it starts a for loop that calls the update method dataLength times. It is then up to the update method to update the content of the various buffer attributes in the geometry.

Justification

With this approach geometries are fully independent from one another. We no longer try to over-optimise things by sharing buffer attributes across geometries or by coupling lots of unrelated computations inside the same loop, which led to code that was difficult to read and impossible to abstract for re-use.

Sure, we end up with more buffers in memory in some cases (like when displaying both the line and points with DataCurve/LineVis), but we gain in code readability and make the geometry code much easier to refactor in the future (for instance to fix the subtle glitches when drawing lines that include points with non-finite coordinates, which are moved to (0, 0, CAMERA_FAR)).

My initial idea was to implement a useGeometries hook that allowed updating multiple geometries within a single loop. However, this required having a small loop (to iterate over the geometries) inside a big loop (to iterate over the data), and I realised that this had no theoretical performance benefit over doing the big loop multiple times in a row (i.e. via multiple, consecutive calls to useGeometry).

Moreover, useGeometry (rather than useGeometries) allows having re-usable components like Line, Glyphs and ErrorBars that each create/update their own geometries independently from one another. As a result, DataCurve is now just a dumb component that helps reduce duplication in LineVis.

In this PR

  • The new useGeometry hook and H5WebGeometry abstract class (documented in Utilities.mdx).
  • 3 new re-usable components and their custom geometries to turn DataCurve into a dumb component:
    • Line, LineGeometry
    • Glyphs, GlyphsGeometry
    • ErrorBars, ErroBarsGeometry, ErrorCapsGeometry
  • 3 new story files to document the new components
  • 2 additional custom geometries used to refactor other components:
    • ScatterPointsGeometry (used in ScatterPoints)
    • SurfaceMeshGeometry (used in SurfaceMesh)
  • 2 new utility functions to create buffer attributes: createBufferAttribute and createIndex (documented in Utilities.mdx).

Naming decisions

I wasn't sure about the naming of the geometries and the components in which they are used, because:

  • geometries aren't necessarily coupled to a specific Three object (e.g. SurfaceMeshGeometry is passed to both <points> and <mesh>);
  • geometries aren't necessarily coupled to a specific material (e.g. GlyphsGeometry would work find with <points>'s default material, PointsMaterial, instead of GlyphMaterial).

As a result, I decided to use the names of the components in which they are used - i.e. SurfaceMesh, ScatterPoints, Line, etc. Was this a good decision?

If it was, are the names of the new components, Line, Glyphs, ErrorBars, appropriate?

  • Line/LineGeometry kind of follows Three's naming (<line>, <lineBasicMaterial>), but obviously conflicts with it ... and somewhat with SvgLine too. It's perhaps too generic.
  • Glyphs/GlyphsGeometry are named after the existing GlyphMaterial, but as mentioned, the geometry itself would work perfectly fine with PointsMaterial and others (for instance, I used it in SurfaceMesh at some point to display the points when debugging).

I wondered about using the names LineCurve/LineCurveGeometry and GlyphCurve/GlyphCurveGeometry, for instance.

@axelboc axelboc force-pushed the data-curve branch 2 times, most recently from a3455e6 to 4b62388 Compare October 31, 2023 08:56
packages/lib/src/index.ts Show resolved Hide resolved
packages/lib/src/vis/hooks.ts Show resolved Hide resolved
packages/lib/src/vis/hooks.ts Show resolved Hide resolved
packages/lib/src/vis/hooks.ts Show resolved Hide resolved
packages/lib/src/vis/line/DataCurve.tsx Show resolved Hide resolved
packages/lib/src/vis/utils.ts Show resolved Hide resolved
packages/lib/src/vis/hooks.ts Show resolved Hide resolved
ordinateScale,
ignoreValue,
},
hasR3FEventHandlers(pointsProps),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This detects whether the component received any R3F event handlers as props. When that's the case, we pass true for the isInteractive argument so geometry's bounding sphere gets recomputed on update.

Copy link
Member

Choose a reason for hiding this comment

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

Is this really needed ? I guess it would not be a huge performance dent to recompute the bounding sphere even if it is not interactive.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hehe, unfortunately it is. Reproduction:

  1. Comment out this line
  2. Start Storybook and expand the sidebar to fill half the screen
  3. Navigate to the DataCurve > Interactive story — notice that all points are interactive
  4. Shrink the sidebar back to its original size — notice that the points at the start and end of the curve are no longer interactive.

What happens is, when the bounding sphere is not computed, Three computes it automatically on first paint. Then, when we update the geometry and repaint, the bounding sphere still exists so Three doesn't know it needs to be recomputed.

For a bit more context: when an interaction occurs, Three checks to see which objects on the scene interacts with the "ray". To speed this up, it first checks if the ray intersects the bounding sphere of each object's geometry. If the bounding sphere doesn't cover the whole geometry and a ray occurs outside of it, then the raycaster will discard the object as a potential intersection candidate very quickly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The above is one solution though. The advantage is that it just works for components like Line and Glyphs; the user doesn't have to know about computeBoundingSphere at all. The downside is that it's a bit magical/implicit.

Another option would be to let users call computeBoundingSphere themselves, perhaps by providing an onAfterUpdate hook (otherwise, they'd have to write a useLayoutEffect with the same dependencies as inside useGeometry...)

@axelboc axelboc requested a review from loichuder October 31, 2023 10:00
Copy link
Member

@loichuder loichuder left a comment

Choose a reason for hiding this comment

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

Great improvement !

I only noticed that errors are not displayed in the Story Data Curve -> With Errors ?

ordinateScale,
ignoreValue,
},
hasR3FEventHandlers(pointsProps),
Copy link
Member

Choose a reason for hiding this comment

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

Is this really needed ? I guess it would not be a huge performance dent to recompute the bounding sphere even if it is not interactive.

packages/lib/src/vis/line/LineVis.tsx Show resolved Hide resolved
@axelboc axelboc merged commit d4b4d46 into main Oct 31, 2023
8 checks passed
@axelboc axelboc deleted the data-curve branch October 31, 2023 13:50
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.

2 participants