community icon indicating copy to clipboard operation
community copied to clipboard

Graphics instructions don't seem to be designed to have multiple parents, yet there is no check preventing this.

Open gottadiveintopython opened this issue 2 months ago • 4 comments

Software Versions

  • Python: CPython 3.13
  • OS: Linux Mint
  • Kivy: 91dc72d7cc979f37e2b1344bf9edd5db7957bbd1 (master branch around Sep 2025)
  • Kivy installation method: development install ($ pip install -e ".[dev,full]")

Describe the bug

The Instruction class has an attribute that holds its parent.

https://github.com/kivy/kivy/blob/8ac27255c6c07e88577855c078eedfd16da66d0f/kivy/graphics/instructions.pxd#L22-L25

To me, this implies that graphics instructions cannot be shared between multiple parents.
However, when you run the following code, no error or warning is raised:

import kivy.core.window  # Ensures OpenGL context exists. This doesn't seem to matter, though.
from kivy.graphics import InstructionGroup, Color

g1 = InstructionGroup()
g2 = InstructionGroup()
color = Color()

g1.add(color)
g2.add(color)

I have made this mistake a couple of times when working with stencil instructions:

with canvas:
    StencilPush()
    shared = RoundedRectangle(...)
    StencilUse()
    ...
    StencilUnUse()
    canvas.add(shared)
    StencilPop()

I haven't encountered any incorrect rendering results from this, though. Still, I feel that some kind of check—or at least documentation—is neccesarry.

Additional context

InstructionGroup.add() calls Instruction.radd()

https://github.com/kivy/kivy/blob/8ac27255c6c07e88577855c078eedfd16da66d0f/kivy/graphics/instructions.pyx#L196-L201

which calls Instruction.set_parent()

https://github.com/kivy/kivy/blob/8ac27255c6c07e88577855c078eedfd16da66d0f/kivy/graphics/instructions.pyx#L107-L109

which performs no check.

https://github.com/kivy/kivy/blob/8ac27255c6c07e88577855c078eedfd16da66d0f/kivy/graphics/instructions.pyx#L121-L122

gottadiveintopython avatar Nov 28 '25 12:11 gottadiveintopython

@gottadiveintopython I’m no expert in the kivy graphics stack, but given this code is implemented in Cython, it could very well be a decision to focus on performance, rather than ease of use.

ElliotGarbus avatar Nov 28 '25 18:11 ElliotGarbus

I blindly added a widget’s canvas into an FBO to apply a shader effect, while the same canvas remained in its parent, because the effect output would be used elsewhere and the effects were meant to be applied dynamically to widgets.

To my surprise, it worked. Everything drawn, instructions added or removed, or attributes updated, automatically appeared in the output. I didn’t have to do anything extra, like exporting to a texture every frame, to capture changes to the widget’s canvas.

It’s a surprisingly simple solution, even though it relies on behavior that Kivy never promised or expected.

It was only later that it occurred to me, but I just left it like that, no stress 😄.

EBabz avatar Nov 29 '25 00:11 EBabz

Thanks.

@ElliotGarbus , @EBabz

it could very well be a decision to focus on performance, rather than ease of use.

It might be, and if it actually is, how about adding the check in the debug build, similar to how flag_update is implemented? But after hearing how you use it, Ebabz, I'm starting to feel that the downside of a graphics instruction not being able to be added in multiple places far outweighs the performance cost of the check itself. Perhaps, we shouldn't perform the check at all.

Anyway, I'm currently investigating the issue and will probably share the details of my findings within the next few days.

gottadiveintopython avatar Nov 29 '25 15:11 gottadiveintopython

Summary

I’ve done the investigation and found a couple of issues. I don’t consider them critical, but one of them appears easy to fix without any drawbacks, so I'd like to work on it.

Issues

(To run the code below, you need to expose Instruction.parent to Python as follows)

cdef class Instruction(ObjectWithUid):
    cdef int flags
    cdef public str group
-    cdef InstructionGroup parent
+    cdef public InstructionGroup parent

Instruction.parent is overwritten with the last parent the instruction is added to

from kivy.graphics import Color, InstructionGroup, Canvas

def print_children(group):
    print([c.__class__.__name__ for c in group.children])

parent1 = InstructionGroup()
parent2 = Canvas()
color = Color()
parent1.add(color)
print(f"{color.parent = }")
parent2.add(color)
print(f"{color.parent = }")
print_children(parent1)
print_children(parent2)
color.parent = <kivy.graphics.instructions.InstructionGroup object at ...>
color.parent = <kivy.graphics.instructions.Canvas object at ...>
['Color']
['Color']

As you can see, both parent1 and parent2 hold a reference to color, but in the end, color holds a reference only to the latter. This can lead to the following bug:

from kivy.utils import get_random_color
from kivy.app import App
from kivy.clock import Clock
from kivy.uix.widget import Widget
from kivy.graphics import Color, Rectangle, InstructionGroup

class BugReproducingApp(App):
    def build(self):
        root = Widget()
        canvas = root.canvas
        group = InstructionGroup()
        bgcolor = Color()
        canvas.add(bgcolor)
        canvas.add(Rectangle(size=(9999, 9999)))
        group.add(bgcolor)  # color.parent is overwritten!

        def change_bgcolor(dt):
            bgcolor.rgba = get_random_color()
        Clock.schedule_interval(change_bgcolor, 1)
        return root

if __name__ == '__main__':
    BugReproducingApp().run()

The example above is supposed to change the background color every second, but it fails because the redraw requests from bgcolor never reaches the Window. The diagram below illustrates this. (I'm assuming Instruction.parent is used to issue a redraw but I'm not 100% certain.)

flowchart BT
    bgcolor e1@== parent ==> group -- parent --> None
    bgcolor --x root.canvas e2@== parent ==> Window.canvas
    rectangle e3@== parent ==> root.canvas
    e1@{ animate: true }
    e2@{ animate: true }
    e3@{ animate: true }

You can see the background color change if a redraw is triggered by other reasons, e.g. resizing the window.

Instruction.parent can be None even when there is a parent holding a reference to the instruction in its children list

What happens if an instruction is shared by multiple parents and removed from one of them?

from kivy.graphics import Color, InstructionGroup, Canvas

def print_children(group):
    print([c.__class__.__name__ for c in group.children])

parent1 = InstructionGroup()
parent2 = Canvas()
color = Color()
parent1.add(color)
print(f"{color.parent = }")
parent2.add(color)
print(f"{color.parent = }")
print_children(parent1)
print_children(parent2)

parent2.remove(color)
print("---- after removal ----")
print(f"{color.parent = }")
print_children(parent1)
print_children(parent2)

color.parent = <kivy.graphics.instructions.InstructionGroup object at ...>
color.parent = <kivy.graphics.instructions.Canvas object at ...>
['Color']
['Color']
---- after removal ----
color.parent = None
['Color']
[]

As shown, color.parent becomes None even when there is a parent holding a reference to the instruction. The same occurs when an instruction is added twice to the same parent and removed once.

This leads to the same bug as above (BugReproducingApp), but it also causes the issue.

An Instruction that is not a VertexInstruction and whose parent is None cannot be removed from a parent.

Instruction.rremove() does nothing when self.parent is None:

https://github.com/kivy/kivy/blob/8ac27255c6c07e88577855c078eedfd16da66d0f/kivy/graphics/instructions.pyx#L111-L115

This means that once an Instruction has been added to multiple parents, or added multiple times to the same parent, it can no longer be removed from all of its parent(s) unless you manipulate InstructonGroup.children directly.

from kivy.graphics import Color, InstructionGroup

def print_children(group):
    print([c.__class__.__name__ for c in group.children])

group = InstructionGroup()
color = Color()
group.add(color)
group.add(color)
print("---- before removal ----")
print(f"{color.parent = }")
print_children(group)

group.remove(color)
print("---- after 1st removal ----")
print(f"{color.parent = }")
print_children(group)

group.remove(color)
print("---- after 2nd removal ----")
print(f"{color.parent = }")
print_children(group)

group.children.remove(color)
print("---- after removed from children directly ----")
print(f"{color.parent = }")
print_children(group)
---- before removal ----
color.parent = <kivy.graphics.instructions.InstructionGroup object at 0x7a1e622097e0>
['Color', 'Color']
---- after 1st removal ----
color.parent = None
['Color']
---- after 2nd removal ----
color.parent = None
['Color']
---- after removed from children directly ----
color.parent = None
[]

However, this doesn’t apply to VertexInstruction, because it overrides rremove() and skips the self.parent is None check:

https://github.com/kivy/kivy/blob/8ac27255c6c07e88577855c078eedfd16da66d0f/kivy/graphics/instructions.pyx#L333-L338

I believe this behavior is correct. Regardless of whether Instruction.parent is None, the instruction should still be removed from the children list, and I think this is what needs to be fixed. I'd like to update Instruction.rremove() so that its behavior matches that of VertexInstruction.rremove().

gottadiveintopython avatar Dec 03 '25 09:12 gottadiveintopython