Skip to content

Support tree-shakable builds: per-element entry points backed by an import-time subsystem registry #390

Description

@willeastcott

<pc-app> assembles the engine application from hardcoded lists: 23 component systems and 25 resource handlers, plus SoundManager, Lightmapper, BatchManager and XrManager (app.ts#L364-L419). These are named imports from playcanvas (app.ts#L2-L65), which is external in our build — so every consumer that bundles the library gets the engine's entire framework layer in their app, regardless of what their scene uses. A page that shows one glTF still ships particles, physics glue, UI layout, scroll views, lightmapping, batching and XR.

The engine is designed for this to work better: it ships unbundled ESM with a sideEffects manifest precisely so bundlers can drop unused subsystems, and AppOptions exists so an application can register only what it needs. The library currently negates that design by demanding everything.

The element side has the same shape: @playcanvas/web-components is a single side-effectful entry point that defines all 29 elements, so the library's own code is also all-or-nothing.

Measured impact

Bundled with Rollup 4.62 + terser against playcanvas@2.21.4. "Current" mirrors the value imports of app.ts (plus the invariant core the other modules need: Entity, Asset, math types). "Minimal" is what a pc-app + pc-entity + pc-camera + pc-light + pc-render procedural scene needs: camera/render/light systems, no resource handlers, no optional managers.

Engine payload minified gzip
Current lists 901 KB 227 KB
Minimal subset 858 KB 215 KB
Saving 43 KB 12 KB

Two honest observations from the numbers:

  • The engine-side saving is real but modest, because systems and handlers are mostly thin glue over renderer code that AppBase pulls in regardless. The deeper problem is that the mandatory floor grows with every element we addpc-joint recently added JointComponentSystem to every consumer's bundle, whether or not they use physics.
  • The library's own barrel is the larger prize: pwc.min.mjs is 162 KB minified / 40 KB gzip for all 29 elements, and none of it is shakable today (sideEffects: true, one entry). Per-element entry points would let a minimal page keep only the handful of element classes it uses. Combined, a minimal page stands to shed roughly 35–40 KB gzip.

Proposal

An import-time registry, plus per-element entry points. Fully additive — the default entry point keeps registering everything, so nothing breaks for existing users (npm or CDN), and this can land before or after 1.0.0.

// registry.ts (new)
export const registerComponentSystems = (...systems) => { /* add to a Set */ };
export const registerResourceHandlers = (...handlers) => { /* add to a Set */ };

Each element module registers what it needs as a side effect of being imported:

// components/light-component.ts
registerComponentSystems(LightComponentSystem);

AppElement then builds createOptions.componentSystems / resourceHandlers from the registry instead of its hardcoded lists. Consumers choose their granularity:

// Today, and still the default: everything
import '@playcanvas/web-components';

// Opt-in: only what the page uses
import '@playcanvas/web-components/app';
import '@playcanvas/web-components/entity';
import '@playcanvas/web-components/camera';
import '@playcanvas/web-components/light';
import '@playcanvas/web-components/render';

Design notes

  • The element → subsystem mapping is not 1:1. pc-model/pc-node instantiate glTF hierarchies that arrive with authored render/anim components, so the model module must register those systems plus the container pipeline's handlers (container, render, texture, material, animation). Part of this work is a per-element audit of what each one actually needs to function.
  • pc-asset cannot know its handler statically — its type attribute is runtime data, and a conditional import would defeat static bundling. Instead, each consuming element registers the handlers for the asset types it consumes (pc-sky → texture; pc-sounds → audio; pc-element → font; pc-gsplat → gsplat), and the registry functions are exported publicly for everything else (json, binary, script-driven loads).
  • Systems with no fronting element (sprite, zone, legacy model/animation) stay registered by the default entry point; subset builds whose scripts addComponent them opt in via the public registry.
  • Elements import()ed after boot register too late for AppOptions. The engine supports late additions (ComponentSystemRegistry#add, ResourceLoader#addHandler), so a live AppElement can forward registry additions to the running app — or the first version simply documents that element modules must be imported before <pc-app> connects.
  • UMD/CDN users are unaffected: pwc.min.js continues to register everything against the global pc, which ships the full engine anyway.

Tasks

  • Add the import-time registry; build AppOptions from it in AppElement
  • Audit each element's true subsystem needs and move registration into its module
  • Decide the home for element-less systems and loose asset handlers (default entry + public registry export)
  • Add per-element subpath exports (Rollup multi-entry, exports map, .d.ts per entry, publint green)
  • Late-registration forwarding, or a documented import-before-connect constraint
  • Verify the CEM/editor-integration pipeline is unaffected (manifest is generated from source, not from the bundle)
  • User Manual page on subset builds (developer-site)

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions