Skip to content

Class field declarations on Vec2/Vec3/Vec4/Quat/Color make V8 allocate on every float write (~12 MB/s of garbage, GC hitches on mobile) #9986

Closed

Description

@Nickneem

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 profile

Measured 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 s
  • BoundingBox.setFromTransformedAabb: ~21 MB
  • MeshInstance.aabb: ~7 MB
  • setFromMat4, 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 that Color.a and Quat.w default 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

  1. mvaligursky commented on Nov 9, 2026

    @mvaligursky
    Contributor

    Fantastic find, thanks!

  2. added a commit that references this issue on Nov 9, 2026
    6e58b00
  3. added a commit that references this issue on Nov 9, 2026
    8c05b22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions