zgba Network

The child that vanished: an order-dependent bug in my 2D render pipeline

I’m building BeeEngine, a 2D HTML5 Canvas game engine in plain JavaScript. One of its core pieces is BeeLayer, the draw pipeline. Every frame it walks the entities, decides which pass each one belongs to, and hands them to the renderer in the right order. It looked solid, and the tests were green. Then a child entity simply stopped showing up on screen, and it only happened when the entity list was in a certain order. This post covers what was wrong, the first fix, the two bugs that fix still had, and the version that ships in 2.9.6. How the pipeline works There are four default passes: Pass order Space Sort background 0 world stable world 10 world stable ysort 20 world by feet (y + height, or sortY) ui 100 screen stable Entities can have children (a hat on a hero, an HP label above an enemy). The renderer, engine.drawEntity, draws a node and then recurses into its children, skipping any child whose drawLayer differs from the current pass: for (const child of entity.children) { if (child && child.drawLayer && child.drawLayer !== pass) continue; this.drawEntity(ctx, child, { space, pass }); } So the job of BeeLayer is narrow. It only puts two kinds of entity in the buckets: roots (no parent), and split children, whose explicit drawLayer differs from the one they inherit. A child on the same layer as its parent gets drawn inside the parent’s draw call. A child on a different layer gets its own slot. No entity is ever drawn twice. The bug Collection walked currentScene.entities, then engine.entities, calling #visit on each item: #visit(entity, engine, inherited) { if (!entity || entity.destroyed || this.#seen.has(entity)) return; this.#seen.add(entity); // marked immediately if (entity.visible === false) return; const explicit = typeof entity.drawLayer === ‘string’ && entity.drawLayer; const isRoot = !entity.parent; const split = !!(explicit && inherited && explicit !== inherited); if (isRoot || split) { /* push into the bucket */ } // recurse into children with the inherited layer… } Now picture a hero with a gun on ysort and a hud on ui, and a list in this order: [hud, gun, hero] hud is visited first, straight from the list, so inherited is null. It isn’t a root (it has a parent), and split needs inherited, so it’s false. It doesn’t go in any bucket, but it’s already in #seen. Then hero is visited and recurses into hud, which is already seen, so it returns. The hud never gets drawn. With the list in the order [hero, gun, hud] everything works. That’s a bug that depends on the order of an array, which is the worst kind to chase. First fix: two passes The idea is to stop letting children be visited before their parents: Roots pass: visit only entities with no parent, recursing into children as before. Orphans pass: anything still not in #seen has a parent that was never reached (the parent isn’t in a list, or is hidden). Work out its inherited layer by climbing the parent chain, and treat it as an orphan root. collect(engine) { // …reset buckets, #seen.clear() this.#walkRoots(sceneList, engine); this.#walkRoots(engineList, engine); this.#walkOrphans(sceneList, engine); this.#walkOrphans(engineList, engine); } The tests passed, including [hud, gun, hero] against [hero, gun, hud]. I almost published. Review: two more bugs Reading the new code line by line turned up two cases the tests didn’t cover. 1. A double draw, order-dependent again Take a grandparent G that is not in any list, a parent P and a child C that are, with no drawLayer anywhere. The list is [C, P]. The orphans pass reaches C first. Its parent was never seen, so C goes in the bucket as an orphan root. Then it reaches P, which also goes in the bucket as an orphan root. At draw time, drawEntity(P) recurses into its children and draws C again. With [P, C] it’s fine. The bug I had just fixed was back in a new form. 2. The child of a destroyed parent #visit returns on destroyed before adding the node to #seen, so the children of a destroyed parent are never marked. The orphan check only looked at visible === false, so those children became orphans and got drawn. Before the fix that never happened, because drawEntity on a destroyed parent draws nothing. Final fix Enter each branch from the top. Each frame I fill a reused Set with everything in both lists. For each orphan, I climb to the highest ancestor that is listed and not yet seen, and visit that one instead: #listedRoot(entity) { let top = entity; let node = entity.parent; while (node) { if (this.#listed.has(node) && !this.#seen.has(node)) top = node; node = node.parent; } return top; } Destroyed hides a branch, the same as invisible: #hiddenAncestor(entity) { let node = entity.parent; while (node) { if (node.visible === false || node.destroyed === true) return true; node = node.parent; } return false; } The orphan walk now looks like this: #walkOrphans(list, engine) { for (const entity of list) { if (!entity || this.#seen.has(entity)) continue; if (this.#hiddenAncestor(entity)) continue; const root = this.#listedRoot(entity); if (!root || this.#seen.has(root)) continue; this.#visit(root, engine, this.#inheritedLayer(root), true); } } Both Sets are cleared and reused every frame, and the buckets keep their rows, so the hot path doesn’t allocate anything new. The tests that matter The most useful test didn’t check the buckets. It simulated drawEntity and counted how many times each entity was actually drawn: [hero, gun, hud] and [hud, gun, hero] give the same result. With G missing, [C, P] and [P, C] give the same result: only P is in a bucket, and C is drawn once. A parent in the scene with a child in engine.entities: the child doesn’t vanish. The same entity in both lists is collected once. An invisible or destroyed parent turns off its whole branch, even a ui child that is in a list. Regressions: default passes, sortY = 0 used as a key, stable ties, add(”) and duplicates throw. Takeaways A “seen” set marked too early means the first visit wins. If the first visit has the least context, you lose information you can’t get back. If the result depends on array order, test with the array reversed. Then test with a missing middle node. Assert on the output, not on internal state. Checking the buckets looked fine. Counting the actual draws is what exposed the double draw. Read your fix as if somebody else wrote it. Green tests only prove the cases you thought of. The fix ships in BeeEngine 2.9.6. If you’ve handled parent/child draw ordering differently in your own engine, I’d love to hear how in the comments.

View original article