Skip to content

Create partial reference implementation for BENTLEY_materials_planar_fill (behind)#13178

Open
markschlosseratbentley wants to merge 33 commits into
mainfrom
BENTLEY_materials_planar_fill
Open

Create partial reference implementation for BENTLEY_materials_planar_fill (behind)#13178
markschlosseratbentley wants to merge 33 commits into
mainfrom
BENTLEY_materials_planar_fill

Conversation

@markschlosseratbentley

@markschlosseratbentley markschlosseratbentley commented Feb 2, 2026

Copy link
Copy Markdown
Contributor

Description

This PR adds support for the BENTLEY_materials_planar_fill glTF extension, enabling CAD-style planar polygon fill rendering with proper depth sorting and configurable fill behavior including background color masking and coplanar geometry ordering. Note: The wireframeFill property is currently a no-op as CesiumJS does not yet have a proper wireframe rendering mode. See this wireframe rendering mode draft PR for a possible way forward.

image

Issue number and link

Partially fixes #12890

Testing plan

  • Various unit tests have been added to CesiumJS related to this feature.
  • Otherwise, changes have been verified with the comprehensive test case added in the gltf-planar-fill-dev Sandcastle sample.
  • Test these changes using the planar fill Sandcastle sample deployed right here.

Author checklist

  • I have submitted a Contributor License Agreement
  • I have added my name to CONTRIBUTORS.md
  • I have updated CHANGES.md with a short summary of my change
  • I have added or updated unit tests to ensure consistent code coverage
  • I have updated the inline documentation, and included code examples where relevant
  • I have performed a self-review of my code

@github-actions

github-actions Bot commented Feb 2, 2026

Copy link
Copy Markdown
Contributor

Thank you for the pull request, @markschlosseratbentley!

✅ We can confirm we have a CLA on file for you.

@markschlosseratbentley markschlosseratbentley changed the title Create reference implementation for BENTLEY_materials_planar_fill Create reference implementation for BENTLEY_materials_planar_fill (wireframeFill) Mar 5, 2026
@markschlosseratbentley markschlosseratbentley changed the title Create reference implementation for BENTLEY_materials_planar_fill (wireframeFill) Create partial reference implementation for BENTLEY_materials_planar_fill (backgroundFill) Mar 5, 2026
@markschlosseratbentley markschlosseratbentley changed the title Create partial reference implementation for BENTLEY_materials_planar_fill (backgroundFill) Create partial reference implementation for BENTLEY_materials_planar_fill (behind) Mar 5, 2026
@markschlosseratbentley
markschlosseratbentley marked this pull request as ready for review March 17, 2026 15:04
@danielzhong

danielzhong commented Mar 18, 2026

Copy link
Copy Markdown
Member

Great work! I did a quick review of the glTF loading and the feature ID comparison logic. The approach of introducing a new pass and comparing feature IDs to determine what’s behind makes sense to me. I haven’t run it locally yet, but the overall idea and implementation look solid and follow a similar path to how edge visibility was handled.

However, While testing with some cases I generated from the tileset publisher encoding work (PR: https://github.com/iTwin/imodel-native-internal/pull/950), I noticed the results weren’t behaving as expected. The results still didn’t match the rendering in the iTwinJS PR (iTwin/itwinjs-core#8906). Note: I temporarily commented out the edge visibility code locally since support for “line string” hasn’t been merged yet.

For example, the “behind” flag in following test cases do not appear to be applied correctly:
PlanarFillBehind.zip

in iTwinJS:
image
image

But in the current branch in CesiumJS, both files appear as if the “behind” flag is not being read at all.
image

I have a question about the yellow color in this example. If both flags are enabled, my understanding is that it should either render as the black background or appear behind the other two colors. But it doesn’t seem to match either case here(it's rendering yellow and z fighting (meaning it's not behind anything)).

There’s also a purple test case with the same flags enabled compare with the yellow color test case I mentioned. The only difference is wireframe mode (which maybe shouldn’t affect the result), but the output looks different. The purple I highlighted isn’t visible—possibly rendering as black instead?

Am I misunderstanding how this is supposed to work?
image

Additionally, I’ve verified that the tileset publisher encoding behaves as expected in iTwinJS for planar fill. It might be a good idea to also use this renderer to validate the tileset publisher encoding, so we can confirm that both the tileset publisher and this implementation are working correctly. We can also add more test cases if needed.

@markschlosseratbentley

Copy link
Copy Markdown
Contributor Author

For example, the “behind” flag in following test cases do not appear to be applied correctly

Investigating.

I have a question about the yellow color in this example. If both flags are enabled, my understanding is that it should either render as the black background or appear behind the other two colors. But it doesn’t seem to match either case here(it's rendering yellow and z fighting (meaning it's not behind anything)).

There’s also a purple test case with the same flags enabled compare with the yellow color test case I mentioned. The only difference is wireframe mode (which maybe shouldn’t affect the result), but the output looks different. The purple I highlighted isn’t visible—possibly rendering as black instead?

Am I misunderstanding how this is supposed to work?

Some of the colors in the test case are a fair bit similar - sorry about that. The "yellow" color you point out is actually slightly yellow with a tint of green and this is what it corresponds to, which appears to work as expected to me:

image

Regarding the purple test case: you are correct, the deep purple test case is indeed not visible. Since it has bgFill set to true, it will render as the background color (black). This is expected.

Additionally, I’ve verified that the tileset publisher encoding behaves as expected in iTwinJS for planar fill. It might be a good idea to also use this renderer to validate the tileset publisher encoding, so we can confirm that both the tileset publisher and this implementation are working correctly. We can also add more test cases if needed.

100% agree, and this is on my list but doesn't need to block PR review. I want to get a good "infrastructure" example converted through the tiler using this extension (and your tiler branch for planar fill). The one I have included with this PR is good for testing.

@mzschwartz5 mzschwartz5 left a comment

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.

Hey @markschlosseratbentley, looking good to me. Most of my comments are code style nits or questions. Functionally, seems alright. I have more reading and testing to do but shipping this round of comments in the mean time.

Comment thread packages/engine/Source/Scene/Model/MaterialPipelineStage.js
defined(primitive.featureIds) && primitive.featureIds.length > 0;

if (hasFeatureIds) {
const featureIdMember = primitive.featureIds[0].positionalLabel;

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.

Is there something in the planar fill spec that guarantees a primitive has one (and only one) feature ID?

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.

The spec does not guarantee a single feature ID set. The spec intentionally leaves "belonging to the same logical object" implementation-defined (for Bentley/iTwin it means "originating from the same element").

This implementation defines it as the primitive's first feature ID set, which holds for Bentley-produced tiles; primitives with no feature IDs simply don't participate in the behind test. I've now documented this assumption explicitly in the new processPlanarFill() docs and at the featureIds[0] access.

Comment on lines +200 to +214
const hasFeatureIds =
defined(primitive.featureIds) && primitive.featureIds.length > 0;

if (hasFeatureIds) {
const featureIdMember = primitive.featureIds[0].positionalLabel;
shaderBuilder.addDefine(
"HAS_PLANAR_FILL_BEHIND",
undefined,
ShaderDestination.FRAGMENT,
);
shaderBuilder.addDefine(
"PLANAR_FILL_FEATURE_ID",
featureIdMember,
ShaderDestination.FRAGMENT,
);

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.

Since this logic is needed in both branches, it can be hoisted above both.

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.

Hoisted.

Comment on lines +223 to +224
// Mark this primitive for the pre-pass derived command.
renderResources.planarFillIdPass = true;

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.

Not sure I understand why only this primitives not marked "hasBehind" get this flag set to true. Your comment above implies to me that both behind and non-behind planar-fill geometry participate in the feature-ID pre-pass.

Or is it only non-behind geometry that populates the feature ID texture? (I suppose that could make sense... "behind" geometry only cares about / needs to check "non-behind" geometry.)

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.

Non-behind fills write the ID texture in the pre-pass; behind fills only read it. I've reworded the comments to say READ/WRITE explicitly.

Comment on lines +247 to +249
uniformMap.u_isPlanarFillIdPass = function () {
return false;
};

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.

A comment here that this is overridden by the derived command could be helpful

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.

Added a comment noting the default returns false for the main command and that derivePlanarFillIdCommand overrides it to return true.

Comment on lines +1776 to +1779
const planarFill = new PlanarFill();
planarFill.wireframeFill = wireframeFill;
planarFill.backgroundFill = backgroundFill;
planarFill.behind = behind;

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.

Any reason for not just giving PlanarFill a constructor that takes and deconstructs a PlanarFillInfo object?

side note- could we make a jsdoc typedef for the input object here?

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.

Yep, that's a good suggestion and cleans it up a lot. Done.

PlanarFill now has a constructor that takes and destructures the extension JSON and has @param docs.

* and non-behind fills — but NOT behind other objects.
*
* Layout:
* Color attachment 0 – RGBA: R = encoded feature ID (float), GBA reserved.

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.

What are GBA reserved for? Could we use not just use an attachment with a single channel (R) to save memory?

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.

GBA was a conceptual reservation during development. It ended up we didn't need them in this implementation. Good callout. I moved this to a single-channel R attachment. All seems to work correctly still.

// indicates we can render to float textures, not just create them.
// Fall back to UNSIGNED_BYTE if neither is supported.
let pixelDatatype;
if (context.colorBufferFloat) {

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.

I wonder if we really have to check for support here. I think we decided recently that new features don't need to support webgl1, and in webgl2 this extension is established in all browsers we support.

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.

Also- why not use an UNSIGNED_INT data type? Does it have to do with the fact that the same shader doubles for both feature IDs and the fill?

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.

Thanks for the pointer about not needing webgl1 support.

I went ahead and dropped the HALF_FLOAT branch. I did keep a minimal context.colorBufferFloat ? FLOAT : UNSIGNED_BYTE guard, because tests could possibly not advertise the extension.

Open to changing this further.

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.

RE: UNSIGNED_INT: integer output would require an integer output declaration. Due to shader program sharing, we keep the vec4 output here (float is fine range for representing IDs).

// ──────────────────────────────────────────────────────────────────────
// BENTLEY_materials_planar_fill constants
// ──────────────────────────────────────────────────────────────────────
// Depth pull factor (0.05% toward camera) for all planar fills.

@mzschwartz5 mzschwartz5 Jul 8, 2026

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.

Did you mean to write 5% instead of 0.05% (and same for 2%)?

Also, I think this comment is a little misleading. I think it needs to call out that the depth pull/push occur in log space, after czm_writeLogDepth. This 5% pull is 5% in log space, which is a variable amount in eye space depending on the fragment's distance from the camera. (Which may be the intended effect)

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.

The percentages are actually correct as written: 1 − 0.9995 = 0.0005 = 0.05%, and 1.0002 − 1 = 0.02%. But your second point about log space is fair. It's intended behavior. The comment block now spells all of this out.

Comment on lines +224 to +238
#ifdef HAS_PLANAR_FILL_BEHIND
{
vec2 screenCoord = gl_FragCoord.xy / czm_viewport.zw;
float storedEncoded = texture(czm_planarFillIdTexture, screenCoord).r;
float storedFeatureId = storedEncoded - FEATURE_ID_OFFSET;
float myFeatureId = float(featureIds.PLANAR_FILL_FEATURE_ID);

// storedFeatureId < 0 means "no planar fill at this pixel".
if (storedFeatureId >= 0.0 && abs(storedFeatureId - myFeatureId) < FEATURE_ID_TOLERANCE) {
// Proportional push: multiply by >1 to move away from camera.
// Net effect: PLANAR_DEPTH_PULL * BEHIND_DEPTH_PUSH ≈ 0.9997,
// still in front of non-planar but behind same-feature non-behind fills.
gl_FragDepth *= BEHIND_DEPTH_PUSH;
}
}

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.

I need to think about this more... generally speaking, I've encountered numerous issues (many of which in my work with billboards) when trying to fudge depth values. It's very prone to sneaky edge cases.

With billboards, there were some issues I never solved and ultimately concluded a more robust solution would involve a separate render pass entirely...

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.

Fair concern.

In the end, it's the same family of technique as the u_polygonOffset built into czm_writeLogDepth. Also, a similar proportional depth tolerance is employed in the edge visibility system. So there is precedent, which I was leaning on.

Also worth noting: the spec's "planar renders in front of non-planar at the same surface" semantics require some depth override regardless; a separate pass would still need the feature-ID comparison and a bias or depth-test trick inside it, so it isolates the problem more than it removes it (unless we sort things....)

That said, if some blocker artifact turns up, we could eventually move this into a dedicated pass and try to figure something out. Most of the scaffolding here could carry over.

@markschlosseratbentley

Copy link
Copy Markdown
Contributor Author

Thanks @mzschwartz5 - I'll be going through your feedback.

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.

Create reference implementation for proposed glTF extension BENTLEY_materials_planar_fill

3 participants