Skip to content

[BREAKING] Store frustum planes in a typed array - #9196

Merged
mvaligursky merged 3 commits into
mainfrom
mv-frustum-typed-planes
Aug 20, 2026
Merged

[BREAKING] Store frustum planes in a typed array#9196
mvaligursky merged 3 commits into
mainfrom
mv-frustum-typed-planes

Conversation

@mvaligursky

@mvaligursky mvaligursky commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Frustum kept its six planes as Plane objects, so reading a plane walked from the frustum to a Plane to a Vec3 to its components. The planes now live in a single Float32Array, packed as four floats per plane - the normal's x, y and z followed by the distance - in the existing right, left, bottom, top, far, near order.

API Changes:

The public planes array is gone, replaced by an accessor pair:

// before
const normal = entity.camera.frustum.planes[0].normal;

// after
const plane = new pc.Plane();
entity.camera.frustum.getPlane(0, plane);
const normal = plane.normal;
  • getPlane(index, result) writes the plane at index into the supplied Plane and returns it.
  • setPlane(index, plane) stores a plane, normalizing it on the way in - containsSphere compares a plane distance against a sphere radius, so it requires unit length normals. setFromMat4 already normalized, so the two entry points now agree.
  • The packed array is available as planeData for internal use, tagged @ignore.

Changes:

  • containsPoint, containsSphere, copy and setFromMat4 read and write the packed array directly.
  • add is unchanged in behaviour: it reads the other frustum's planes into scratch Plane objects and reuses the existing three-plane corner intersection. It only runs for stereo XR, a couple of times per frame, so the unpacking costs nothing there.
  • The gsplat frustum culler and gsplat shadow renderer both already kept their own Float32Array(24) in exactly this packed layout for their cull compute shaders, so both now copy the array in one go instead of unpacking six plane objects. They keep their own buffer rather than sharing the frustum's, since each may be handed a shared scratch frustum that a later call overwrites.
  • Added test/core/shape/frustum.test.mjs, covering the getPlane / setPlane round trip and normalization, copy and clone independence, containsPoint, containsSphere, and add containment over sampled points.

Performance:

Measured in a browser on a scene of 5072 mesh instances lit by 10 shadow casting omni lights, so 61 frustums are built per frame - one for the camera and one per shadow map face. Timings are of the culling work itself rather than whole frames, which in this scene it is far too small a share of to resolve. Each figure is the best of nine rounds, and the runs were stable to within a couple of percent.

before after
frustum cull all 5072 mesh instances 0.245 ms 0.199 ms 1.23x
build the frame's 61 frustums 0.0071 ms 0.0028 ms 2.5x

The frustum build is the larger relative win, mostly from dropping the Plane.set plus Vec3.normalize call chain rather than from the storage itself, and it scales with the number of shadow casting lights.

Float32 rather than Float64 storage costs no real precision here, since Mat4 is itself backed by a Float32Array and so the planes were already derived from float32 inputs. The extra rounding works out at roughly 5e-6 of the camera's distance from the origin - tens of microns at this scene's scale. It is measurable at the frustum boundary: this scene reports 3550 mesh instances visible after the change and 3548 before, a difference of two objects sitting within that band of a plane. A smaller scene of 1542 mesh instances reported an identical count either way.

Frustum kept its six planes as Plane objects, so every plane access walked from
the frustum to a Plane to a Vec3 to its components. The planes now live in a
single Float32Array, packed as four floats per plane - the normal's x, y and z
followed by the distance - in the existing right, left, bottom, top, far, near
order.

This is a breaking change: the public planes array is replaced by getPlane and
setPlane. setPlane normalizes what it stores, as containsSphere requires unit
length normals. The packed array is available as planeData for internal use.

Measured in a browser on a scene of 1542 mesh instances, per call rather than
per frame: MeshInstance#_isVisible drops from 54 to 42 ns, and setFromMat4 from
320 to 55 ns - the latter mostly from dropping the Plane.set and Vec3.normalize
call chain, and it runs once per frustum per frame, so it scales with the number
of shadow casting lights. Float32 storage measured faster than Float64 in the
browser (42 vs 61 ns), and costs no precision, as Mat4 is itself backed by a
Float32Array and so the planes are already derived from float32 inputs.

The two consumers of the plane data - the gsplat frustum culler and the gsplat
shadow renderer - already kept their own Float32Array in exactly this packed
layout for the cull compute shaders, so both now copy the array directly instead
of unpacking plane objects.
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Public API report

This PR changes the public API surface (+2 / −1), per the docs' rules (@ignore / @Private / undocumented are excluded).

Show API diff
-Frustum.planes: Plane[]
+Frustum.getPlane(index: number, result: Plane): Plane
+Frustum.setPlane(index: number, plane: Plane): Frustum

Informational only — this never fails the build.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Build size report

This PR changes the size of the minified bundles.

Bundle Minified Gzip Brotli
playcanvas.min.js 2371.2 KB (+0.1 KB, +0.01%) 609.3 KB (+0.1 KB, +0.01%) 473.0 KB (−0.0 KB, −0.00%)
playcanvas.min.mjs 2368.5 KB (+0.1 KB, +0.01%) 608.2 KB (+0.1 KB, +0.01%) 472.3 KB (−0.1 KB, −0.02%)

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review by Codex (GPT-5).

I found one blocking compatibility issue and one smaller public-API reporting issue. The API report also removes Frustum.constructor() even though runtime construction remains supported and the examples still show new Frustum(); please retain an explicit empty constructor so this unrelated documented API entry does not disappear.

The packed implementation itself looks correct: extraction order and normalization match the previous code, point/sphere tests use the same boundary conditions, copy remains independent, XR add retains its algorithm without per-call allocation, and both gsplat consumers make the required defensive copy.

Local verification on the exact head commit: 44 focused Frustum, Camera, and local-shadow-renderer tests passed; ESLint passed for all changed files; generated TypeScript declarations and test:types passed; and git diff --check passed. All current GitHub checks are green.

Comment thread src/core/shape/frustum.js
@mvaligursky
mvaligursky merged commit cefe977 into main Aug 20, 2026
10 checks passed
@mvaligursky
mvaligursky deleted the mv-frustum-typed-planes branch August 20, 2026 08:31
@AlexAPPi

Copy link
Copy Markdown
Contributor

Updates of this scale can break a large number of components. Unfortunately, backward compatibility was not preserved at all.

@mvaligursky

Copy link
Copy Markdown
Contributor Author

it's pretty much impossible to make original direct access to internal array backwards compatible.

And yep, in a follow up PR I'm switching culling from spheres to aabbs, so there will be a test function.

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

Labels

area: graphics Graphics related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants