Skip to content

EXT_mesh_primitive_edge_visibility #2479

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

Open
wants to merge 14 commits into
base: main
Choose a base branch
from

Conversation

pmconne
Copy link

@pmconne pmconne commented Mar 14, 2025

The EXT_mesh_primitive_edge_visibility extension augments a triangle mesh primitive with sufficient information to enable engines to produce non-photorealistic visualizations of 3D objects with visible edges. The edge visibility is encoded in a highly compact form to avoid excessively bloating the glTF asset. Adapted for glTF from iTwin/itwinjs-core#5581. We intend to add support for the extension to the CesiumJS engine.

@pjcozzi
Copy link
Member

pjcozzi commented Mar 15, 2025

@pmconne so excited for this!

  • @lilleyse could you please take a quick look from a 3D Tiles and CesiumJS perspective?
  • @kring could you take a quick look from a Cesium Native and game engine perspective?


## Dependencies

Written against the glTF 2.0 spec. Depends on `BENTLEY_primitive_restart` if primitive restart is used.
Copy link
Member

Choose a reason for hiding this comment

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

Is the BENTLEY_ prefix still TBA based on discussion in #2478?

Copy link
Author

Choose a reason for hiding this comment

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

Yes.

<figcaption><em><b>Figure 4</b> Edges drawn as separate primitives</em></figcaption>
</figure>

The [CESIUM_primitive_outline](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Vendor/CESIUM_primitive_outline) extension provides an additional index buffer describing each hard edge as a line segment (a pair of indices into the triangle mesh's list of vertices). The extension leaves the details of how to render the edges up to the engine, requiring only that they render without depth-fighting. This approach satisfies the use case for which it was intended - displaying the edges of boxy, low-resolution buildings - but suffers some limitations:
Copy link
Member

Choose a reason for hiding this comment

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

It would not need to be near-term, but is there potential that this extension could replace CESIUM_primitive_outline in the future?

Copy link
Author

Choose a reason for hiding this comment

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

Yes, the hard edge visibility encoded by that extension is a subset of that encoded by this extension.

Copy link
Member

Choose a reason for hiding this comment

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

@lilleyse if you agree, do you want to submit an issue to the 3d-tiles repo for future consideration to replace CESIUM_primitive_outline with this extension over time?

- 2: Hard edge - the edge should always be drawn.
- 3: Hard edge encoded in `primitives` - a representation of the edge is included in the extension's `primitives` property. The edge should always be drawn, either by drawing the `primitives` array or by drawing it like an edge with visibility value `2` (never both).

The extension's `visibility` property specifies the index of an accessor of `SCALAR` type and component type 5121 (unsigned byte) that encodes as a bitfield the visibility of every edge of every triangle in the mesh. The ordering of triangles and vertices is as described by [Section 3.7.2.1](https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#meshes-overview) of the glTF 2.0 specification. For each triangle `(v0, v1, v2)`, the bitfield encodes three visibility values for the edges `(v0:v1, v1:v2, v2:v0)` in that order. Therefore, the accessor's `count` **MUST** be `6 * N / 8` rounded up to the nearest whole number, where `N` is the number of triangles in the primitive.
Copy link
Member

Choose a reason for hiding this comment

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

It's an edge case (no pun intended), but should the spec define the value of bits when there are extra/unused bits at the end of the bitstream?

Copy link
Author

Choose a reason for hiding this comment

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

Added requirement that unused bits are zero-initialized.


### Silhouette Normals

The extension's `silhouetteNormals` property specifies the index of an accessor of `SCALAR` type and component type 5123 (unsigned short) providing normal vectors used to determine the visibility of silhouette edges at display time. For each edge encoded as a silhouette (visibility value `1`) in `visibility`, the silhouette normals buffer provides the two outward-facing normal vectors of the pair of triangles sharing the edge. Each normal vector is compressed into 16 bits using the "oct16" encoding described [here](https://jcgt.org/published/0003/02/01/). The ordering of the normal vector pairs corresponds to the ordering of the edges in `visibility`; that is, the first pair of normals corresponds to the first edge encoded with visibility `1`, the second pair to the second occurrence of visibility `1`; and so on. The accessor's `count` **MUST** be twice the number of edges encoded with visibility value `1`.
Copy link
Member

Choose a reason for hiding this comment

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

I don't know if I have a strong opinion but others may ask about the oct16 encoding and, if instead, this should be uncompressed and then able to use meshopt, etc. Would that fit into the ecosystem better and have similar characteristics?

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, I went back and forth on it and decided to leave as-is initially pending feedback. I note that while oct-encoded normals get some use in 3D Tiles 1.0, the closest thing in glTF is KHR_mesh_quantization (min 1 byte per component, plus padding for byte stride).

Copy link
Member

Choose a reason for hiding this comment

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

Gotcha, let's see what others think. Also note the octahedral filter in EXT_meshopt_compression.

Copy link
Author

Choose a reason for hiding this comment

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

The revised extension leaves it up to engines to compute the normals, obviating the need to specify their encoding.


### Extensions

The `BENTLEY_primitive_restart` extension *may* be applied to the `EXT_mesh_primitive_edge_visibility` extension object, in which case it applies to the `primitives` property in the same way that it would apply to `mesh.primitives`.
Copy link
Member

Choose a reason for hiding this comment

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

Same comment as above.


[./schema/primitive.EXT_mesh_primitive_edge_visibility.schema.json](primitive.EXT_mesh_primitive_edge_visibility.schema.json)

## Implementation Notes
Copy link
Member

Choose a reason for hiding this comment

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

Excellent to see these references!

Copy link
Contributor

@kring kring left a comment

Choose a reason for hiding this comment

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

Thanks for opening this @pmconne! I have a few thoughts below.

Overall it looks good! I can't see any particular reason it couldn't be implemented in Unreal Engine or Unity.

@@ -0,0 +1,45 @@
{
Copy link
Contributor

Choose a reason for hiding this comment

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

This file needs to be renamed to match the new extension name.

Copy link
Contributor

Choose a reason for hiding this comment

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

Bump @pmconne (triggered by the request for comments below).


The extension's `visibility` property specifies the index of an accessor of `SCALAR` type and component type 5121 (unsigned byte) that encodes as a bitfield the visibility of every edge of every triangle in the mesh. The ordering of triangles and vertices is as described by [Section 3.7.2.1](https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#meshes-overview) of the glTF 2.0 specification. For each triangle `(v0, v1, v2)`, the bitfield encodes three visibility values for the edges `(v0:v1, v1:v2, v2:v0)` in that order. Therefore, the accessor's `count` **MUST** be `6 * N / 8` rounded up to the nearest whole number, where `N` is the number of triangles in the primitive. Any unused bits in the last byte **MUST** be set to zero.

The visibility of a given edge shared by a given pair of triangles **MUST** be encoded as a non-zero value no more than once. All other occurrences of the same edge in the bitfield **MUST** be encoded as zero, to prevent engines from either producing redundant geometry for shared edges or having to manually detect and account for such redundancies.
Copy link
Contributor

Choose a reason for hiding this comment

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

The rendering technique CesiumJS uses for CESIUM_primitive_outline has the opposite requirement. The edge needs to be known when rendering both triangles. So if only one is flagged, then the rendering engine will have to search for another triangle sharing that edge, which is a pretty expensive thing to do.

Maybe we don't need that technique at all anymore? I don't know. The nice thing about it is it doesn't require any separate draw calls, does no depth manipulation, and has no possibility of depth testing errors (because the lines are drawn on the edges of the triangles while rendering the triangles normally). All of which is quite nice for Cesium OSM Buildings. The downside is it's pretty inflexible in how the lines are styled.

If we do want that rendering technique to be a valid option, one idea is to use another "edge visibility" value to indicate "the other side of a shared hard edge". But going from two bits per edge to three would be expensive.

Copy link
Author

Choose a reason for hiding this comment

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

That technique won't work for silhouettes, nor can it provide consistent edge widths (an important related requirement for AEC) since it can only draw inside the triangles. But I don't want to dictate specific implementations. I have changed the meaning of visibility value 3 to indicate a repeated hard edge.


The visibility of a given edge shared by a given pair of triangles **MUST** be encoded as a non-zero value no more than once. All other occurrences of the same edge in the bitfield **MUST** be encoded as zero, to prevent engines from either producing redundant geometry for shared edges or having to manually detect and account for such redundancies.

Engines **MUST** render all edges according to their specified visibility values. The edges **MUST** draw in front of their corresponding triangles with no depth-fighting. Engines may choose a material with which to draw the edges - e.g., they could apply the primitive's material to the edges, or apply some uniform material to the edges of all primitives in the scene, or some other consistent option. If engines choose to render the edges specified by the `primitives` property, then they **MUST** render those primitives according to the glTF 2.0 specification (e.g., using the specified material) and they **MUST NOT** also produce their own rendering of the edges with visibility value `3`.
Copy link
Contributor

Choose a reason for hiding this comment

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

I think that it would be wise to specify the appearance of the unstyled edges. Clients with a good reason to can of course still override this and do something different. But by default, models should look the same in different clients supporting this extension.

Copy link
Author

Choose a reason for hiding this comment

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

The extension now specifies the default material and permits alternate material(s) to be defined.


The extension's `primitives` property optionally provides an array of primitives representing all of the hard edges encoded with visibility value `3` in the `visibility` property. Engines should render these primitives directly, in which case they **MUST** do so without depth-fighting and **MUST NOT** also produce their own rendering of these edges. The primary use case is for edges that should be drawn using a different material than the corresponding surface - for example, a red outline drawn around a filled green shape.

The `primitives` property **MUST** be defined *if and only if* at least one edge is encoded with visibility value `3` in `visibility`.
Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, so we don't want to use just a normal glTF LINES primitive to render these styled edges, because a normal glTF LINES primitive doesn't have any implication that the lines are coincident with triangles. Nor does it require that the lines and triangles avoid depth fighting. So in a client that doesn't support this extension, it's probably better to avoid rendering the lines entirely, rather than rendering them poorly. I made the same argument in CESIUM_primitive_outline and it makes sense here, too.

However, it seems very odd to me that styled lines are specified redundantly in two places: in the visibility accessor as value 3, and also in the primitives property. Why not use visibility value 0 for these non-automatic lines?

Or, better yet, why not allow the "compact" edge definition to be styled somehow? It's nice that you can use a compact representation for default-styled edges, but as soon as you want to customize their appearance, you're stuck with the full-size representation. One idea, without having thought about it too much, is to let the extension associate ranges of edges with materials. So styled edges would simply use visibility value 1 and then we'd have something like:

"materials": [
  {
    "material": 1,
    "ranges": [0, 10, 111, 187]
  },
  {
    "material": 3,
    "ranges": [11, 110]
  }
]

In the ranges properties, each pair of numbers is a start and end edge index, numbered according to their order in the visibility accessor.

Ranges could also be binary-encoded in an accessor instead of JSON, if that seems worthwhile.

This isn't ideal because there'll be a tension between ideal triangle ordering and grouping same-styled lines together, but I think it'll still be a big compactness win over a normal glTF LINES primitive even if no triangle reordering is done at all.

Copy link
Contributor

Choose a reason for hiding this comment

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

Side benefit: if we don't need visibility value 3 for styled edges, then perhaps it could be used to indicate the other side of a shared hard edge, as I mentioned above.

Copy link
Author

Choose a reason for hiding this comment

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

I have replaced primitives with lineStrings, and those edges are no longer encoded in visibility. Please review the revised spec.


The [pull request](https://github.com/iTwin/itwinjs-core/pull/5581) that informed the design of this extension provides [an iterator](https://github.com/iTwin/itwinjs-core/blob/03b760e1e91bde5221aa7370ea45c52f966e3368/core/frontend/src/common/imdl/CompactEdges.ts#L42) over the `visibility` and `silhouetteNormals` buffers.

iTwin.js [implements](https://github.com/iTwin/itwinjs-core/blob/03b760e1e91bde5221aa7370ea45c52f966e3368/core/frontend/src/internal/render/webgl/glsl/Edge.ts#L107) conditional display of silhouette edges. It draws edges in a separate pass from surfaces to [ensure](https://github.com/iTwin/itwinjs-core/blob/03b760e1e91bde5221aa7370ea45c52f966e3368/core/frontend/src/internal/render/webgl/glsl/FeatureSymbology.ts#L426) the mitigate z-fighting.
Copy link
Contributor

Choose a reason for hiding this comment

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

I looked briefly at the FeatureSymbology.ts code. Have you found this to do a good job of avoiding depth fighting between lines and triangles at all scales, while also avoiding artifacts where depth manipulation causes the lines to "bleed through"? If so (and maybe even if not), I think it would be great to include some implementation guidance in the spec, even if it's non-normative. I've found this to be challenging in the past, and that's why I eventually went with a very different technique for the outlines of Cesium OSM Buildings.

Copy link
Author

Choose a reason for hiding this comment

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

Yes, we invested a lot of effort into ensuring the edges consistently display in front of their surfaces (and with consistent pixel width). I think we might want to rough out an implementation in CesiumJS before writing up implementation guidance here.

"description": "An array of primitives defining geometry that renders all of the edges encoded as `3` in `visibility`.",
"gltf_detailedDescription": "This property **MUST** be defined if and only if `visibility` encodes at least one edge with visibility value `3`",
"items": {
"$ref": "mesh.primitive.schema.json"
Copy link
Contributor

Choose a reason for hiding this comment

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

It strikes me that this is a mesh primitive inside an extension on a mesh primitive. Should there at least be some requirements imposed on this, such as that the POSITION attribute of all of these inner primitive definitions must match the outer one?

Copy link
Contributor

Choose a reason for hiding this comment

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

I haven't looked at all this on a technical level, but looking at this point in isolation, I think that (it could pose some challenges for code generators, due to the "recursion" through an extension, and) it could be worthwhile to point out that this mesh primitive may not itself contain another instance of the extension 😬

Copy link
Author

Choose a reason for hiding this comment

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

I have removed this quirk.

@pmconne
Copy link
Author

pmconne commented Apr 14, 2025

@kring @javagl @pjcozzi thank you for your feedback. I've revised the spec accordingly.

@pjcozzi
Copy link
Member

pjcozzi commented Apr 26, 2025

@kring @javagl @lexaknyazev or others - do you have any additional feedback for @pmconne on this extension?

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.

4 participants