Skip to content

impr: Access vector components through their backing fields - #3270

Draft
collectioneur wants to merge 1 commit into
software-mansion:mainfrom
collectioneur:perf/vec-component-getters
Draft

collectioneur wants to merge 1 commit into
software-mansion:mainfrom
collectioneur:perf/vec-component-getters

Conversation

@collectioneur

@collectioneur collectioneur commented Oct 24, 2026 •

Copy link
Copy Markdown

Vectors keep their components in plain fields, e0 to e3. The named accessors (.x/.y/.z/.w and .r/.g/.b/.a) didn't read those fields directly, though. They went through the numeric index first:

get 0() { return this.e0; }
get x() { return this[0]; } // before this PR

That extra hop is where the time went. Vectors extend Array and call super(N), so each vector is an array of length N whose slots are all empty. To evaluate this[0], the engine looks for element 0 on the vector itself, finds a hole, walks up the prototype chain and only then finds the 0 accessor on the class prototype. Engines don't optimize that path, so every v.x paid for a slow lookup just to reach a field that was right there. this.e0 is an ordinary property read that the JIT caches and inlines.

Setters took the same detour, and in Vec2 and Vec3 they also cast the value twice on the way (once in set x, again in set 0).

  • The x/y/z/w and r/g/b/a getters and setters on Vec2, Vec3 and Vec4 now read and write e0–e3 directly. Each setter casts the value once.
  • Nothing observable changes. The index getters returned these same fields, and casting a value once gives the same result as casting it twice for every element type (checked for f32, f16, i32, u32 and bool).
  • Indexed access (v[0]), swizzles (v.xy) and the compiled buffer writer still go through the index accessors. I've left them for separate PRs.

The CPU implementations of std functions read components through .x/.y/…, so std.length, std.dot and similar functions get faster too.

I built typegpu from main and from this branch and ran both with NODE_ENV=production on Node 24 (V8) and Bun 1.3 (JavaScriptCore). Each number is the median of 7 fresh processes per build, alternating between the two builds:

Node, main Node, this PR Bun, main Bun, this PR
Read v.x + v.y + v.z, 100k vec3f 25.6 ms 1.20 ms 9.72 ms 0.81 ms
v.x += 1 (and y, z), 100k vec3f 60.6 ms 4.55 ms 42.1 ms 9.96 ms
std.length, 100k vec3f 27.8 ms 3.54 ms 15.8 ms 6.36 ms
std.dot, 100k vec3f 61.5 ms 13.7 ms 44.4 ms 24.0 ms
jelly-slider physics, one frame 4.06 ms 2.00 ms 3.35 ms 2.30 ms

The last row is the CPU physics that the jelly-slider example runs every frame (Slider.update without the GPU writes). One frame does about 25k component reads and 6.6k d.vec2f constructions, and the resulting positions are identical on both builds. Most of the remaining 2 ms is spent constructing those vectors, which this PR doesn't touch.

Draft #3035 makes the same getter change as part of a much larger PR. This pulls it out on its own, together with the setters, so it can land independently. Whichever merges second will need a trivial rebase.

pnpm test:unit passes locally, except for the Deno integration test in unplugin-typegpu, which needs Deno ≥ 2.8.

@collectioneur collectioneur changed the title Access vector components through their backing fields impr: Access vector components through their backing fields Oct 24, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant