Repository navigation
Description
Version: 2.23.3 (both the ESM build from code.playcanvas.com and the editor's download build)
Summary
Vec2, Vec3, Vec4, Quat and Color declare their numeric fields as class fields with no initializer, so each field is set to undefined before the constructor assigns a number. V8 then stores those fields as generic tagged values rather than unboxed doubles. As a result, every non-integer number written to x, y, z, w, r, g, b or a allocates a new heap number. Math classes are written to constantly (transforms, bounding boxes, culling, cameras), so this makes a lot of garbage. On a low-end Android phone it shows up as regular garbage-collection stutters.
Where it is
In the ESM build the fields are native declarations (x; y; z;). The editor's download build transpiles them to:
constructor(x=0, y=0, z=0) {
_define_property(this, "x", void 0);
_define_property(this, "y", void 0);
_define_property(this, "z", void 0);
if (x.length === 3) { this.x = x[0]; this.y = x[1]; this.z = x[2]; }
else { this.x = x; this.y = y; this.z = z; }
}Why it allocates
V8 tracks each field's representation per object shape. When the first value written is a number, the field becomes a double field: later float writes go in place and allocate nothing. When the first value is undefined, the field is generalized to tagged. A tagged field holds a non-integer number as an immutable HeapNumber, so every float write allocates a new one.
Minimal repro
Run in any Chrome, with DevTools → Memory → Allocation sampling:
class Declared { x; y; z; constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; }
set(a, b, c) { this.x = a; this.y = b; this.z = c; return this; } }
class Plain { constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; }
set(a, b, c) { this.x = a; this.y = b; this.z = c; return this; } }
const src = new Float32Array(3000).map(() => Math.random() * 10);
function run(v) { let s = 0; for (let i = 0; i < 2997; i++) { v.set(src[i], src[i + 1], src[i + 2]); s += v.x; } return s; }
const d = new Declared(), p = new Plain();
for (let k = 0; k < 200; k++) { run(d); run(p); } // compare the two in the allocation profileMeasured result for about 600,000 set calls:
| Class | Allocated |
|---|---|
Declared |
21 MB |
Plain |
3 KB |
pc.Vec3 behaves like Declared.
Impact in a real game
- Test: 40 s standing idle in our game (engine 2.23.3, release build of the engine, not debug), on a low-end Android phone (Mali-G57 MC2, 2× A76 + 6× A55, 4 GB, Chrome, 90 Hz display).
- Patched engine: the same engine with only the five classes' field declarations removed, served in place of the stock file.
| Stock 2.23.3 | Declarations removed | |
|---|---|---|
| Average fps | 87 | 90.2 |
| Frames over 34 ms | 9 | 0 |
| Worst frame | 67 ms | 22 ms |
| Minor GCs (scavenges) | 71 (227 ms in total, up to 8.3 ms) | 36 (146 ms in total, up to 7.5 ms) |
| Major GC | one of ~25 ms | none |
With the stock engine, the game allocated about 12 MB/s while idle. The top allocating functions in an allocation sample were engine math paths:
GraphNode.getPosition: ~38 MB in 20 sBoundingBox.setFromTransformedAabb: ~21 MBMeshInstance.aabb: ~7 MBsetFromMat4,setRotation,setPosition,forward: a few MB each
Suggested fix
Either of these keeps the fields as doubles:
- Give the declarations a number initializer (
x = 0;and so on). Note thatColor.aandQuat.wdefault to 1 in the constructor. - Or drop the declarations and keep the types as JSDoc on the constructor's assignments.
Every constructor already assigns all of its fields on every path, in the declared order. Removing the declarations therefore leaves the object shape and the behaviour the same, which is what we tested.
It may also be worth checking other hot classes that declare number fields without an initializer.
Workaround
Stripping the _define_property(this,"…",void 0) calls from the five constructors in the download build's js/index.mjs removes the problem.
Activity
mvaligursky commentedon Nov 9, 2026
Fantastic find, thanks!
perf: Keep math class numeric fields as unboxed doubles in V8 (#9987)
perf: Keep math class numeric fields as unboxed doubles in V8 (playca…