PixiJS 8: Stray Lines Across the Screen on Phones, and the Fix
📅 October 10, 2026 · ⏱️ 10 min read
If PixiJS 8 Graphics draws thin wedges or lines from your shapes to the corner or the centre of the screen on phones, while the same scene looks clean on a desktop, the vertex buffer has NaN in it. In pixi.js 8.21.0 two things put it there. First, a stroke whose outline has the same point twice in a row, most often a moveTo or lineTo that ends exactly where the next arc begins: the stroke builder divides by a segment length of zero and writes NaN vertices. Second, after an arc has been stroked, the next path starts with moveTo(undefined, undefined), because Pixi reads the wrong two arguments of the arc as “the last point”. The fix is to start every path with its own moveTo, make Pixi start each new path empty instead of from that broken point, and drop repeated points before Pixi builds the outline. This is the patch, run once before anything is drawn:
import { GraphicsContext, ShapePath } from 'pixi.js';
// A new path after a fill or stroke starts empty instead of at a broken "last point".
// Only safe if every path in your code begins with its own moveTo.
GraphicsContext.prototype._initNextPathLocation = function initNextPathLocation() { this._activePath.clear(); };
// Drop repeated consecutive points before the stroke builder sees them.
const buildShapePath = ShapePath.prototype.buildPath;
ShapePath.prototype.buildPath = function buildPathNoRepeats() {
buildShapePath.call(this);
for (const prim of this.shapePrimitives) {
const sh = prim.shape, p = sh && sh.points;
if (!p || p.length < 4) continue;
let w = 2;
for (let r = 2; r < p.length; r += 2) {
if (Math.abs(p[r] - p[w - 2]) > 1e-6 || Math.abs(p[r + 1] - p[w - 1]) > 1e-6) { p[w++] = p[r]; p[w++] = p[r + 1]; }
}
if (sh.closePath && w > 4 && Math.abs(p[w - 2] - p[0]) <= 1e-6 && Math.abs(p[w - 1] - p[1]) <= 1e-6) w -= 2;
p.length = w;
}
};
That is the code that runs in DankLoot, a browser dungeon game drawn with PixiJS, where the lines first showed up on a phone. The rest of this page shows each cause on its own in a node repro, a counter you can run on your own Graphics objects, and the two earlier fixes that looked right and were not.
What the stray lines look like
The shapes themselves draw correctly. On top of them, a few thin triangles or hairlines run from a shape to a fixed spot on the screen: a corner in one case, the middle of the screen in another. They did not appear in a desktop browser on the same build. In this game they hung off the level ring in the HUD, a small icon on the floor, and the arc drawn behind the swing attack. All three are strokes that mix straight segments with arc calls.
The phone-only part is an observation from this project, not a rule I can cite. Commit c48ffb5 in the game’s repository records it as “Desktop GPUs drop the NaN triangles; phone GPUs draw them as wedges to the corner”. I have not checked what any graphics specification says a GPU must do with a NaN vertex position, so read it as what happened on the devices this game was tested on. The useful part does not depend on it: whatever the GPU does, a NaN in the vertex buffer is a bug, and you can count them without a GPU at all.
The repro: building Pixi’s geometry in node
Pixi does not need a browser to build Graphics geometry. buildContextBatches is the function Pixi’s renderer uses to turn a GraphicsContext into triangles, and pixi.js 8.21.0 exports it along with GpuGraphicsContext, which holds the output. Both work in plain node once the pixi.min.js ES module is imported, with no canvas and no WebGL. This counter returns how many NaN values end up in the vertex array:
import { GpuGraphicsContext, buildContextBatches } from 'pixi.js';
function countNaN(graphics) {
const gpu = new GpuGraphicsContext();
buildContextBatches(graphics.context, gpu);
return gpu.geometryData.vertices.filter(Number.isNaN).length;
}
The vertex array is flat, x then y, so 2 NaN values are one broken vertex. The repro draws four small scenes into a fresh Graphics called g, counts NaN with that function, and does it four times: unpatched, with only the “start empty” half of the patch, with only the “drop repeats” half, and with both. Every figure below is from that run against the pixi.js 8.21.0 build, with the patch above executed exactly as printed.
| Scene | Unpatched | Start empty only | Drop repeats only | Both halves |
|---|---|---|---|---|
moveTo to the arc’s start, then arc | 8 NaN of 44 values | 8 NaN of 44 | 0 of 36 | 0 of 36 |
arc, stroke, then a bare arc | 8 NaN of 80 values | 0 of 72 | 8 NaN of 44 | 0 of 72 |
arc, stroke, then moveTo + arc | 8 NaN of 80 values | 8 NaN of 80 | 0 of 72 | 0 of 72 |
circle, stroke, then a bare arc | 0 NaN of 288 values | 0 of 284 | 0 of 288 | 0 of 284 |
Each half of the patch fixes a different row, and only both together clear all four.
Cause 1: the same point twice in a row
g.moveTo(70, 50).arc(50, 50, 20, 0, Math.PI / 2).stroke({ width: 4 });
This looks like careful code: move to where the arc starts, then draw the arc. But arc adds its own starting point, (70, 50), to the outline, so the outline now holds that point twice. Pixi’s stroke builder computes each segment’s normal by dividing by the segment’s length. For two equal points the length is 0, 0 divided by 0 is NaN, and the run shows 8 NaN values, 4 vertices, at the start of the vertex array, out of 44 values in total.
Pixi already ignores a plain lineTo to the point it is standing on, which is why this is easy to miss in a test that only uses straight lines. The run’s control scene shows it:
g.moveTo(0, 0).lineTo(50, 0).lineTo(50, 0).lineTo(50, 50).stroke({ width: 4 });
That builds 0 NaN unpatched, and the same 12 values with or without the patch. It is the point added by arc that slips through. The path itself is clean: the run counts 0 NaN or undefined values in the outline’s points for this scene. The NaN exists only in the triangles built from it.
The “drop repeats” half wraps ShapePath.prototype.buildPath, so it runs after Pixi has turned your calls into outline points and before the stroke builder reads them. It removes any point within 1e-6 of the one before it, and the closing point of a closed shape if it repeats the first. With it, the same scene builds 36 values and 0 NaN.
Cause 2: the path after an arc starts at (undefined, undefined)
g.arc(50, 50, 20, 0, Math.PI / 2).stroke({ width: 4 });
g.arc(150, 50, 20, 0, Math.PI / 2).stroke({ width: 4 });
After a fill or stroke, pixi.js 8.21.0 starts the next path at the previous path’s last point, in GraphicsContext._initNextPathLocation. For an arc it takes that point from the wrong places in the arc’s stored arguments: the optional counter-clockwise flag and the slot after it, which does not exist. With no flag passed, both are undefined, so the second path in the run begins with moveTo(undefined, undefined). That puts 2 undefined coordinates into the outline and 8 NaN values into the vertex array.
It is not only arcs:
g.circle(50, 50, 20).stroke({ width: 4 });
g.arc(150, 50, 20, 0, Math.PI / 2).stroke({ width: 4 });
After the circle, the next path begins with moveTo(0, 0): the value in a shared scratch point, not anywhere the code drew. That makes no NaN, so a NaN count does not see it, but the bare arc after it gets a real straight line from (0, 0) to its starting point. With the Graphics object sitting at the top-left of the screen, that is a line to the corner.
The “start empty” half replaces _initNextPathLocation so that a new path starts with nothing in it. The second path then begins with the arc itself, at the arc’s own start point, and the NaN is gone: 0 of 72 values. The same half removes the line from (0, 0) after the circle.
There is a condition attached, and it is in the comment for a reason. With a path that starts empty, a path that begins with a bare lineTo has no starting point of its own. So the game also gave every path its own moveTo first. The underscore on _initNextPathLocation also means it is not public API, and a later Pixi release can rename or rework it. I have not checked whether a newer version than 8.21.0 changes this behaviour, so pin your Pixi version with the patch and re-run the counter when you upgrade.
How two earlier fixes missed
The fixes went in as three commits, and the first two were wrong in a way worth knowing about.
The first, commit 6d9d5d9, gave every arc its own line: move to the arc’s start point, then draw the arc, so no arc ever joins a stale point. That is exactly the scene in the first row of the table. It removes the stray straight line and creates a repeated point.
The second, commit c48ffb5, is the “start empty” half above. It was checked by counting NaN in the paths’ points, and at that level it worked. The third row of the table shows why that check was the wrong one:
g.arc(50, 50, 20, 0, Math.PI / 2).stroke({ width: 4 });
g.moveTo(170, 50).arc(150, 50, 20, 0, Math.PI / 2).stroke({ width: 4 });
With only “start empty” applied, that second arc, which starts with its own moveTo as the first fix required, has 0 NaN or undefined values in its path points and still 8 NaN in its vertex array. The path was clean; the triangles built from it were not.
The third, commit 8fa14dc, came from looking at what was actually sent to the GPU: the vertex data itself, not the path that produces it. Those vertices were still NaN, which led to the stroke builder and the zero-length segment, and to the “drop repeats” half. If you are chasing stray lines in Pixi, count NaN in the vertex array, the thing the GPU draws, rather than in the shapes you passed in.
Limits
- The figures are from pixi.js 8.21.0. The repeated-point arithmetic and the
_initNextPathLocationbehaviour are in that build; other versions may differ. - The repro builds geometry with Pixi’s own
buildContextBatchesin node. It does not render, so it shows NaN going into the vertex buffer, not what a particular GPU draws from it. - The
circlerow is a real stray line with no NaN in it. A NaN counter will not catch that one; the “start empty” half or an explicitmoveTobefore thearcdoes. - The patch changes two prototype methods globally, for every Graphics object in the page. In this game that was the point, but it also affects any library on the page that draws with the same Pixi.
The rest of how this game is built, including how it keeps a crowd of monsters moving on a phone, is in A One-Thumb Action RPG in PixiJS.
Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.