Skip to content

The webgl2 and webgpu subpath builds are not backend-isolated #175

Description

@cdrake

Summary

@niivue/niivue/webgl2 and @niivue/niivue/webgpu exist so a consumer can ship
one backend. They do not do that. Both subpaths pull a shared 1.83 MB chunk that
contains the WebGL2 renderer and the WebGPU renderer, so picking a backend entry
point buys almost nothing.

Evidence

Chunk closure of each entry in packages/niivue/dist, reached by following the
relative imports:

niivue.webgl2.js   5 chunks, 1.99 MB
    cortex-CBhDullr.js                     23,401
    fetchOmeZarrChunkedSource-DoKkgzlW.js   1,831,612
    index-Cbb_IM7q.js                        114,347
    niivue.webgl2.js                           7,493
    slide-BFGmEger.js                          8,247

niivue.webgpu.js   5 chunks, 1.99 MB
    cortex-CBhDullr.js                     23,401
    fetchOmeZarrChunkedSource-DoKkgzlW.js   1,831,612
    index-Cbb_IM7q.js                        114,347
    niivue.webgpu.js                           7,527
    slide-CIscnGdf.js                          9,979

Three of the five chunks are byte-identical between the two, including the
1.83 MB one. Token counts inside fetchOmeZarrChunkedSource-DoKkgzlW.js:

GPUBufferUsage       67
createShaderModule   18
@group(0)            53
drawElements         10
texImage3D           12

Both backends, one chunk, shipped to both entry points. (The per-backend slide
renderers do split correctly: slide-BFGmEger.js vs slide-CIscnGdf.js. That
part works.)

Cause

src/control/viewBoth.ts holds two unrelated things:

  1. The ViewLifecycle implementation for the universal NVControl (backend
    'both'), which constructs NVViewGL or NVViewGPU on a runtime branch and
    therefore legitimately imports both:

    const candidate =
      backend === 'webgl2'
        ? new NVViewGL(canvas, ctrl.model, ctrl.opts)
        : new NVViewGPU(canvas, ctrl.model, ctrl.opts)
  2. Backend-agnostic canvas and viewport helpers: getCanvasViewport,
    setCanvasViewport, getCanvasInstances, computeBoundsPixelRect,
    isIdentityViewport, registerCanvasInstance, unregister,
    IDENTITY_VIEWPORT, BoundsPixelRect.

Group 2 is imported by modules on every backend path:

NVControlWebGL2.ts -> NVControlBase.ts    -> viewBoth.ts -> wgpu/NVViewGPU
NVControlWebGPU.ts -> NVControlBase.ts    -> viewBoth.ts -> gl/NVViewGL
                      control/interactions.ts -> viewBoth.ts (computeBoundsPixelRect)
                      control/viewWebGL2.ts   -> viewBoth.ts (registerCanvasInstance)
                      control/viewWebGPU.ts   -> viewBoth.ts (registerCanvasInstance)

ESM imports are module-granular, so importing one helper from viewBoth.ts
pulls the whole module and both renderers with it. No entry point can tree-shake
this, because the edge is static and the constructors are reachable.

Fix

The injection seam already exists and already works: NVControlBase calls
lifecycle through this._viewLifecycle, and control/viewWebGL2.ts and
control/viewWebGPU.ts are single-backend implementations of it. Only the
helper co-location breaks it.

  1. Move group 2 into its own module (control/canvasRegistry.ts or similar)
    that imports neither gl/ nor wgpu/.
  2. Repoint NVControlBase.ts, control/interactions.ts, control/viewWebGL2.ts
    and control/viewWebGPU.ts at that module.
  3. Leave viewBoth.ts holding only the dual-backend lifecycle, imported solely
    by NVControl.ts.

Acceptance

  • The chunk closure of dist/niivue.webgl2.js contains no WGSL or WebGPU
    tokens (@group(, GPUBufferUsage, createShaderModule), and the closure of
    dist/niivue.webgpu.js contains no WebGL2 tokens (drawElements,
    texImage3D).
  • A test asserts that, so it cannot regress the way this did. The natural home
    is next to src/entryPoints.test.ts, which already pins the export side of
    the entry-point contract.
  • packages/niivue/AGENTS.md currently documents this leak under
    "The single-backend distributions are not backend-isolated". Delete that
    section when it stops being true.

Context

Found while auditing the export surface on chore/export-surface-audit. Not
fixed there, because breaking the static edge is a real refactor rather than an
entry-point change.


Generated with Claude Code

https://claude.ai/code/session_01RxnaB17kqcirGHNyAjPbgG

Activity

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