Skip to content

Adding TextOutline component to add outlining by upstreaming implemen… #19639

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 15 commits into
base: main
Choose a base branch
from

Conversation

TotalKrill
Copy link
Contributor

Add Text Outline component to add text borders/outline to text nodes by upstreaming implementation from bevy_cobweb_ui

fixes: #17076,#7210

@hukasu
Copy link
Contributor

hukasu commented Jun 14, 2025

Can you add some screenshots for showcase? With and without the component

I don't know if permission to upstream something is necessary, but have you talked with either someone from bevy and/or bevy_cobweb_ui?

@hukasu
Copy link
Contributor

hukasu commented Jun 14, 2025

Also have 2 different lines for the fixes, GitHub only linked to the first issue

…tation from bevy_cobweb_ui

fixes: bevyengine#17076
fixes: bevyengine#7210

Co-authored-by: koe <ukoe@protonmail.com>
@alice-i-cecile alice-i-cecile added A-Rendering Drawing game state to the screen A-Text Rendering and layout for characters M-Needs-Release-Note Work that should be called out in the blog due to impact labels Jun 14, 2025
@alice-i-cecile alice-i-cecile added the S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged label Jun 14, 2025
@alice-i-cecile alice-i-cecile requested a review from UkoeHB June 14, 2025 19:41
@alice-i-cecile
Copy link
Member

@UkoeHB are you okay if we upstream this?

@UkoeHB
Copy link
Contributor

UkoeHB commented Jun 14, 2025

@alice-i-cecile yes, no problem.

Comment on lines 1036 to 1037
for offset_x in -width..=width {
for offset_y in -width..=width {
Copy link
Contributor

@ickshonpe ickshonpe Jun 14, 2025

Choose a reason for hiding this comment

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

It's not obvious, but this has catastrophically awful worst case performance.

For example, suppose that a Text has three glyphs, each from a different font, and an outline width of 3. In the inner PositionedGlyphs loop the font texture atlas changes for each glyph, so a new batch is generated per glyph. The inner loop is repeated 48 times, so 144 batches are produced for just three glyphs.

Instead the PositionedGlyphs need to be iterated in the outer loop. Then all the outline sprites for each glyph will be batched together, at worst resulting in one batch per glyph.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Please have a look if I understood you correctly when you have a chance, I just moved the order of the iterations as I think you meant.

TotalKrill and others added 4 commits June 15, 2025 21:42
Co-authored-by: UkoeHB <37489173+UkoeHB@users.noreply.github.com>
Co-authored-by: UkoeHB <37489173+UkoeHB@users.noreply.github.com>
Co-authored-by: UkoeHB <37489173+UkoeHB@users.noreply.github.com>
Comment on lines 1081 to 1084
if text_layout_info.glyphs.get(i + 1).is_none_or(|info| {
info.span_index != *span_index
|| info.atlas_info.texture != atlas_info.texture
}) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if text_layout_info.glyphs.get(i + 1).is_none_or(|info| {
info.span_index != *span_index
|| info.atlas_info.texture != atlas_info.texture
}) {
if text_layout_info.glyphs.get(i + 1).is_none_or(|info| {
info.atlas_info.texture != atlas_info.texture
}) {

I think it's safe to remove this, since span-bound text color is irrelevant. @ickshonpe what do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, this ExtractedUiNode bit needs to be moved to the outer loop.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If i move the extraced_uinodes.push bit, I get strange artifacts in the output...

Copy link
Contributor

Choose a reason for hiding this comment

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

If i move the extraced_uinodes.push bit, I get strange artifacts in the output...

Only extracted_uinodes.uinodes should be in the outer loop (after the inner loop). The .glyphs needs to stay in the inner loop.

Copy link
Contributor Author

@TotalKrill TotalKrill Jun 15, 2025

Choose a reason for hiding this comment

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

Yeah, thats what I tried, and that ended up giving me the artifacts, I am not quite sure as to why, but the output hints at me pushing the values wrongly somehow..

                    extracted_uinodes.glyphs.push(ExtractedGlyph {
                        transform: Affine2::from_mat3(
                            transform * Mat3::from_translation(*position),
                        ),
                        rect,
                    });
                } // offset y
            } // offset x
            if text_layout_info.glyphs.get(i + 1).is_none_or(|info| {
                info.span_index != *span_index || info.atlas_info.texture != atlas_info.texture
            }) {
                start = end;
                extracted_uinodes.uinodes.push(ExtractedUiNode {
                    render_entity: commands.spawn(TemporaryRenderEntity).id(),
                    stack_index: uinode.stack_index,
                    color,
                    image: atlas_info.texture.id(),
                    clip: clip.map(|clip| clip.clip),
                    extracted_camera_entity,
                    rect,
                    item: ExtractedUiItem::Glyphs { range: start..end },
                    main_entity: entity.into(),
                });
            }
            end += 1;
        }

Copy link
Contributor

@UkoeHB UkoeHB Jun 15, 2025

Choose a reason for hiding this comment

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

Ah looks like end += 1; needs to be moved to the inner loop after pushing to .glyphs. And start = end; needs to move to the top of the outer loop. The conditional there should be moved to a separate bool variable since you'll have two blocks conditioned on it.

Copy link
Contributor

Choose a reason for hiding this comment

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

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 it's safe to remove this, since span-bound text color is irrelevant. @ickshonpe what do you think?

Yep not needed.

Copy link
Contributor

@ickshonpe ickshonpe left a comment

Choose a reason for hiding this comment

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

Just had another look before I go to sleep, I see more problems now but too tired to think things through properly:

  • It's going to be awkward to get it to work well with scale factor or transform scaling transform, might not be possible even.
  • The offsets should radiate out rather than being arranged in a grid.
  • Outline width should be clamped to a relatively low max value.


let transform = Affine2::from(*global_transform)
* Affine2::from_translation(
-0.5 * uinode.size() + offset / uinode.inverse_scale_factor(),
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 multiplying the offset scale factor is probably wrong, it might cause gaps in the outline at higher scale factors even. The outline width needs to be scaled instead.

Comment on lines 1051 to 1052
for offset_x in -width..=width {
for offset_y in -width..=width {
Copy link
Contributor

Choose a reason for hiding this comment

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

The offsets could be in a circular pattern instead, so the outline doesn't get distorted.

@UkoeHB
Copy link
Contributor

UkoeHB commented Jun 16, 2025

@TotalKrill I applied @ickshonpe's feedback: https://github.com/UkoeHB/bevy_cobweb_ui/blob/main/src/ui_bevy/ui_ext/text_rendering.rs

Note we need to batch anti-aliased glyphs separate from non-anti-aliased glyphs since they have different colors.

@UkoeHB
Copy link
Contributor

UkoeHB commented Jun 16, 2025

Outline width should be clamped to a relatively low max value.

I don't like this, but you are probably right that it's necessary. With a UI scale of 6, on an M1 Mac (scale factor 2), with width 4, the lag penetrated my OS (laggy cursor even without the test app focused).

@ickshonpe
Copy link
Contributor

ickshonpe commented Jun 16, 2025

Outline width should be clamped to a relatively low max value.

I don't like this, but you are probably right that it's necessary. With a UI scale of 6, on an M1 Mac (scale factor 2), with width 4, the lag penetrated my OS (laggy cursor even without the test app focused).

Yep it's very non-obvious that text outlines should be so expensive.

We could make width into an enum even. Have thin, medium, and thick variants or something?

TotalKrill and others added 2 commits June 16, 2025 18:02
Co-authored-by: UkoeHB <37489173+UkoeHB@users.noreply.github.com>
Co-authored-by: ickshonpe <david.curthoys@googlemail.com>
@TotalKrill
Copy link
Contributor Author

We could make width into an enum even. Have thin, medium, and thick variants or something?

I would argue that we could go with a clearer enum of Px1, Px2, Px3. Mostly because Px4 is so expensive as to be almost unusable. It would also quite clearly communicate the limitations of this implementation, while still allowing us to have it.

I imagine that for non-changing outlined text, this would be very usable anyway.

@TotalKrill
Copy link
Contributor Author

Let me know what You think of this impl, I dont particulary like it, but it is very hard to miss the potential performance cost of using this feature.

For my usecase to be able to read white text, on a mostly white background when viewed from certain angles, this will be very sufficient

Copy link
Contributor

@UkoeHB UkoeHB left a comment

Choose a reason for hiding this comment

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

@TotalKrill can you check the updated reference implementation? In order to get antialiasing right, some changes are needed.

TotalKrill and others added 2 commits June 17, 2025 17:56
Co-authored-by: UkoeHB <37489173+UkoeHB@users.noreply.github.com>
@TotalKrill
Copy link
Contributor Author

Updated with your reference implementation, just adjusted for the changes from GlobalTransform -> UiGlobalTransform

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Rendering Drawing game state to the screen A-Text Rendering and layout for characters M-Needs-Release-Note Work that should be called out in the blog due to impact S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged
Projects
Status: No status
Development

Successfully merging this pull request may close these issues.

Add Text Effects such as border and shadow
5 participants