A transform bug in a scene graph
A bug report that sounds trivial: put parts in a folder, move the model, and the parts stay behind.
The fix touches the scene graph, the notification system, and the rendering path, and it is a good example of how an organisational abstraction can quietly stop being a structural one.
Two hierarchies that look like one
Engines built on a scene graph maintain a spatial hierarchy. Each node holds a transform relative to its parent, and a node’s world transform is the product of every transform above it. Move a parent and everything beneath it moves, because the world transform is recomputed by walking up the chain.
Editors also expose an organisational hierarchy — the tree in the sidebar. Users assume these are the same tree. Usually they are.
Folders are where they come apart. A folder is a grouping construct: it exists so a creator can collapse forty parts into one row. It has no position, no rotation, and nothing to render. It is reasonable to implement it as a plain node with no spatial role at all.
And that is the bug. If a folder is not a spatial node, then a part inside a folder has no spatial parent above it, even though the sidebar shows one. The chain from model to part is broken by a node that was never in the spatial tree to begin with.
Model spatial node
└─ Part follows the model
Model spatial node
└─ Folder NOT a spatial node ← chain breaks
└─ Part world transform never recomputed
Why it survives casual testing
Move a model in the editor with a folder inside it and you may see the folder’s contents move perfectly — because the editor recomputed everything on selection change, or the parts were re-parented on save, or nothing was cached yet.
The failure needs a specific combination: a runtime move, parts that are anchored so they are not being re-solved by physics every frame, and a rendering path that caches transforms rather than reading them per frame.
That last one matters. Instanced rendering — drawing many identical meshes in one call — works by uploading a buffer of per-instance transforms. It is fast precisely because it does not re-read each node every frame. So a stale transform stays on screen until something explicitly invalidates the buffer.
Three separate optimisations, each correct on its own, combining so that a break in the spatial chain becomes invisible until exactly the case where it matters.
The fix, in three parts
Give folders a spatial presence. A folder gets a 3D node proxy so it participates in the transform hierarchy. It still has no visual and users still treat it as organisational, but the chain from model to part is now unbroken. The proxy’s own transform is identity — it contributes nothing, which is the point. It only has to exist so the product does not skip a link.
Forward the notifications. Engines do not recompute every world transform every frame; they mark subtrees dirty when a transform changes and recompute on demand. If folders were absent from the spatial tree, they were also absent from the notification path, so the dirty flag never reached the parts underneath. Change notifications now propagate through folder hierarchies.
Invalidate the render cache. Anchored, instance-rendered parts now refresh when an ancestor moves or rotates. Without this the data is correct and the picture is wrong, which is the worse of the two failures because nothing in the state looks broken.
All three are needed. Fixing only the hierarchy leaves the parts correct in memory and stale on screen. Fixing only the notifications gives you a dirty flag with no chain to travel down.
Nesting is the real test
The case that separates a working fix from a plausible one is depth:
Model → Folder → Part
Model → Folder → Folder → Part
A fix that special-cases “folder directly under a model” passes the first and fails the second. Anything that walks the chain generically passes both. I verified both hierarchies manually in the editor rather than trusting that the general case implied the nested one.
Practical note: when a fix restores a chain, the regression test is not the one-link case. It is two links, because that is what distinguishes a general traversal from a special case that happens to work.
What generalises
- When a UI presents a tree, users assume it is the tree. Any node that appears in the display hierarchy but not the functional one is a latent bug.
- Identity-transform proxies are cheap. A node that contributes nothing but preserves a chain is usually better than a special case that skips it.
- Dirty-flag systems fail silently. If a node is not in the propagation path, the symptom is stale data, not an error.
- A fix that restores a chain needs testing at two links, not one. A single link passes for special cases that do not generalise.
The change is an open pull request against the Polytoria engine. It has not been merged, and until it is, the practical advice for anyone hitting this is to avoid folders between a model and parts you intend to move at runtime.