openvibers/openvibe.games Commits

Games M6: Babylon's core as a chain of layers (largest chunk 350 KB, was 2.09 MB); WebGPU and particles on demand (#66)

OP OpenVibers committed 30 days ago · f604279e04 · 7 files, +248 −24

M6: Babylon's core is no longer one 2.09 MB chunk, and CI now enforces the 400 KB chunk line (ADR-0007 decision 7).

- **WebGPU on demand** (`render/sceneSetup.ts`): the WebGPU engine is imported only where `navigator.gpu` exists. A device without WebGPU never downloads or parses it (−245 KiB from the shared chunk).
- **Fountain particles on demand:** the particle system loads when a fountain is built. The jet starts a moment later, is disposed with its fountain, and a failed load leaves the fountain without its jet.
- **Babylon's core as a chain of layers** (`apps/client/scripts/vendorLayers.ts`, the Rollup `manualChunks`):
  - Each layer is the static dependency closure of a few seeds (maths, engine, scene, meshes, materials, post-processing, cameras, lights) minus the earlier layers. A layer imports only itself and earlier layers, so the chunks cannot form a circle.
  - Only vendor code that both pages already load statically is placed. Shaders, loaders and anything else lazy stay Rollup's.
  - Vite's preload helper is pinned to the first layer; left alone it caused every circular-chunk warning.
  - The earlier folder split failed on exactly these two points. Babylon's static graph has no cycle at all.
- **Measured:** play's initial JS is 2.02 MB / 511 KB gzip (was 2.35 MB / 579 KB). The largest chunk is 350 KB (was 2.09 MB). The vendor layers change only with a dependency, so they stay cached across Games deploys.
- **CI:** `bundleBudget.ts` without `--chunks=report`, so both limits fail the build.

Checks run:
- `vendorLayers.test.ts` (synthetic graph): the placement, what is left to Rollup, the preload helper, no layer importing a later one, and nothing placed without both pages.
- `npm test`: 124/124.
- Typecheck and eslint are clean.
- The real build has no circular-chunk warning.
- `pnpm test:editor` locally: 137/137 on the layered build.
- The play page renders: sky, water, clouds, viewmodel and HUD. `visualTest.ts`'s physgun step fails the same way on main, so that failure predates this change.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 51e6b62..a86ebcc 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -17,9 +17,9 @@ jobs:
extra: |
(cd apps/server && npx openvibe-contracts-check --service games --src src)
pnpm --filter @openvibe/client build
-- # Initial JS is enforced; the 400 KB chunk limit is reported until Babylon's core (instancedMesh.pure, 2.09 MB)
-- # is split (rapier is a wasm file since #61). Drop --chunks=report then.
-- node --import tsx apps/client/scripts/bundleBudget.ts --chunks=report
++ # Initial JS and the 400 KB chunk limit are both enforced: Babylon's core is a chain of layers since
++ # scripts/vendorLayers.ts (it was one 2.09 MB chunk), and Rapier is a wasm file since #61.
++ node --import tsx apps/client/scripts/bundleBudget.ts
# The editor's browser acceptance suites (ADR-0007 M6: editor E2E in CI). The real server and the built client in
# Chromium: pnpm test:editor (one editor, every tool, live saves reaching the server) and pnpm test:collab (two
# editors on one map). They ran only by hand before, and 15 checks had quietly gone red; this keeps them green.
ddiff --git a/apps/client/scripts/vendorLayers.test.ts b/apps/client/scripts/vendorLayers.test.ts
nnew file mode 100644
iindex 0000000..9d3b2d0
---- /dev/null
++++ b/apps/client/scripts/vendorLayers.test.ts
@@@ -0,0 +1,93 @@
++/**
++ * The layering's two promises (scripts/vendorLayers.ts) on a synthetic module graph: a layer only ever depends on
++ * itself and the layers before it (the chunks form a chain, never a circle), and only vendor code both pages load
++ * statically is placed — a module either page imports lazily, and the app's own code, stay Rollup's.
++ */
++import { describe, expect, it } from 'vitest'
++import { LAYERS, REST, computeLayers } from './vendorLayers.js'
++
++const nm = (p: string) => `/repo/node_modules/${p}`
++const BJS = (p: string) => nm(`@babylonjs/core/${p}`)
++
++/** id → [static imports]; the two pages are entries. */
++function api(graph: Record<string, string[]>) {
++ const ids = new Set(Object.keys(graph))
++ for (const deps of Object.values(graph)) for (const d of deps) ids.add(d)
++ return {
++ getModuleIds: () => ids.values(),
++ getModuleInfo: (id: string) =>
++ ids.has(id) ? { isEntry: id.endsWith('.html'), importedIds: graph[id] ?? [] } : null,
++ }
++}
++
++const graph: Record<string, string[]> = {
++ '/repo/apps/client/play.html': ['/repo/apps/client/src/main.ts'],
++ '/repo/apps/client/editor.html': ['/repo/apps/client/src/editor/main.ts'],
++ '/repo/apps/client/src/main.ts': [
++ BJS('scene.pure.js'),
++ BJS('Meshes/mesh.pure.js'),
++ '/repo/packages/protocol/index.ts',
++ '\0vite/preload-helper.js',
++ ],
++ '/repo/apps/client/src/editor/main.ts': [
++ BJS('scene.pure.js'),
++ BJS('Meshes/mesh.pure.js'),
++ BJS('Layers/highlightLayer.js'),
++ ],
++ [BJS('scene.pure.js')]: [
++ BJS('Maths/math.vector.pure.js'),
++ BJS('Engines/engine.pure.js'),
++ BJS('Misc/sceneHelpers.js'),
++ ],
++ [BJS('Meshes/mesh.pure.js')]: [
++ BJS('Meshes/abstractMesh.pure.js'),
++ BJS('Maths/math.vector.pure.js'),
++ ],
++ [BJS('Meshes/abstractMesh.pure.js')]: [BJS('scene.pure.js'), BJS('Culling/ray.js')],
++ [BJS('Engines/engine.pure.js')]: [BJS('Maths/math.vector.pure.js')],
++ [BJS('Maths/math.vector.pure.js')]: [],
++ [BJS('Misc/sceneHelpers.js')]: [],
++ [BJS('Culling/ray.js')]: [],
++ [BJS('Layers/highlightLayer.js')]: [BJS('scene.pure.js')],
++ '/repo/packages/protocol/index.ts': [nm('zod/index.js')],
++ [nm('zod/index.js')]: [],
++}
++
++describe('vendorLayers', () => {
++ const layers = computeLayers(api(graph))
++ const order = [...LAYERS.map(([name]) => name), REST]
++
++ it('places the vendor code both pages load, in the layer of its first seed', () => {
++ expect(layers.get(BJS('Maths/math.vector.pure.js'))).toBe('vendor-maths')
++ expect(layers.get(BJS('Engines/engine.pure.js'))).toBe('vendor-engine')
++ expect(layers.get(BJS('scene.pure.js'))).toBe('vendor-scene')
++ expect(layers.get(BJS('Misc/sceneHelpers.js'))).toBe('vendor-scene')
++ expect(layers.get(BJS('Meshes/mesh.pure.js'))).toBe('vendor-meshes')
++ expect(layers.get(BJS('Culling/ray.js'))).toBe('vendor-meshes')
++ })
++
++ it('leaves the app, editor-only code and vendor code of one page to Rollup', () => {
++ expect(layers.has('/repo/apps/client/src/main.ts')).toBe(false)
++ expect(layers.has('/repo/packages/protocol/index.ts')).toBe(false)
++ expect(layers.has(BJS('Layers/highlightLayer.js'))).toBe(false)
++ expect(layers.has(nm('zod/index.js'))).toBe(false)
++ })
++
++ it('puts the preload helper in the first layer', () => {
++ expect(layers.get('\0vite/preload-helper.js')).toBe(LAYERS[0]![0])
++ })
++
++ it('never lets a layer import a later one', () => {
++ for (const [id, layer] of layers) {
++ for (const dep of graph[id] ?? []) {
++ const depLayer = layers.get(dep)
++ if (depLayer) expect(order.indexOf(depLayer)).toBeLessThanOrEqual(order.indexOf(layer))
++ }
++ }
++ })
++
++ it('places nothing without both pages', () => {
++ const { ['/repo/apps/client/editor.html']: _editor, ...playOnly } = graph
++ expect(computeLayers(api(playOnly)).size).toBe(0)
++ })
++})
ddiff --git a/apps/client/scripts/vendorLayers.ts b/apps/client/scripts/vendorLayers.ts
nnew file mode 100644
iindex 0000000..949d119
---- /dev/null
++++ b/apps/client/scripts/vendorLayers.ts
@@@ -0,0 +1,103 @@
++/**
++ * Babylon's core in layers (plan M6; ADR-0007 decision 7: largest chunk ≤ 400 KB).
++ *
++ * Left to itself Rollup puts every vendor module the play and editor pages share into one chunk (2.09 MB: engines,
++ * materials, meshes, maths). A split by Babylon folder was tried and refused: folders import each other both ways, so
++ * the chunks imported each other in a circle, and naming whole folders pulled the lazily loaded shaders into the
++ * initial graph.
++ *
++ * Layers avoid both. Each layer is the static dependency closure of a few seed modules, minus every earlier layer, so a
++ * layer only ever imports from itself and the layers before it: the chunks form a chain, never a circle. And only the
++ * modules both pages already load statically are placed (the vendor code of the old shared chunk); everything a page
++ * imports lazily (shaders, loaders, the WebGPU engine, particles) is left to Rollup, exactly as before. Babylon's
++ * static graph has no cycle of any size, which is what makes the closures small.
++ *
++ * Measured 2026-10-14 (built): the largest layer is 350 KB (the rest), then post-processing 309, meshes 299, scene 201,
++ * maths 158, engine 157; play's initial JS 2.02 MB (511 KB gzip). The layers hold vendor code only, so a Games
++ * deploy that changes no dependency leaves their file names, and the browser's copies of them, as they were.
++ */
++
++type ModuleInfo = { isEntry: boolean; importedIds: readonly string[] } | null
++interface ChunkApi {
++ getModuleInfo(id: string): ModuleInfo
++ getModuleIds(): IterableIterator<string>
++}
++
++/** The layers in order: a name and the modules whose static closure it takes. */
++export const LAYERS: readonly (readonly [string, readonly RegExp[]])[] = [
++ [
++ 'vendor-maths',
++ [
++ /@babylonjs\/core\/Maths\//,
++ /@babylonjs\/core\/Misc\/observable/,
++ /@babylonjs\/core\/Misc\/tools\.pure/,
++ ],
++ ],
++ [
++ 'vendor-engine',
++ [
++ /@babylonjs\/core\/Engines\/engine\.pure/,
++ /@babylonjs\/core\/Materials\/effect\.pure/,
++ /@babylonjs\/core\/node\.pure/,
++ ],
++ ],
++ ['vendor-scene', [/@babylonjs\/core\/scene\.pure/]],
++ [
++ 'vendor-meshes',
++ [/@babylonjs\/core\/Meshes\/abstractMesh\.pure/, /@babylonjs\/core\/Meshes\/mesh\.pure/],
++ ],
++ ['vendor-materials', [/@babylonjs\/core\/Materials\/standardMaterial\.pure/]],
++ ['vendor-post', [/@babylonjs\/core\/PostProcesses\//]],
++ ['vendor-cameras', [/@babylonjs\/core\/Cameras\//]],
++ ['vendor-lights', [/@babylonjs\/core\/Lights\//, /@babylonjs\/core\/Rendering\//]],
++]
++/** Whatever vendor code both pages load that no layer took. */
++export const REST = 'vendor-rest'
++
++const VENDOR = /[\\/]node_modules[\\/]/
++const PAGES = [/[\\/]play\.html$/, /[\\/]editor\.html$/]
++const PRELOAD_HELPER = /vite\/preload-helper/
++
++/** Every module `from` reaches by static imports (itself included). */
++function staticClosure(api: ChunkApi, from: Iterable<string>, within?: Set<string>): Set<string> {
++ const seen = new Set<string>()
++ const stack = [...from]
++ while (stack.length > 0) {
++ const id = stack.pop()!
++ if (seen.has(id) || (within && !within.has(id))) continue
++ seen.add(id)
++ for (const dep of api.getModuleInfo(id)?.importedIds ?? []) stack.push(dep)
++ }
++ return seen
++}
++
++/** Module id → layer name, for the vendor modules both pages load statically. */
++export function computeLayers(api: ChunkApi): Map<string, string> {
++ const ids = [...api.getModuleIds()]
++ const pages = PAGES.map((page) =>
++ ids.find((id) => page.test(id) && api.getModuleInfo(id)?.isEntry),
++ )
++ const layerOf = new Map<string, string>()
++ if (pages.some((p) => p === undefined)) return layerOf
++ const [play, editor] = pages.map((p) => staticClosure(api, [p!]))
++ const shared = new Set([...play!].filter((id) => VENDOR.test(id) && editor!.has(id)))
++ for (const [name, seeds] of LAYERS) {
++ const from = [...shared].filter((id) => seeds.some((s) => s.test(id)))
++ for (const id of staticClosure(api, from, shared)) if (!layerOf.has(id)) layerOf.set(id, name)
++ }
++ for (const id of shared) if (!layerOf.has(id)) layerOf.set(id, REST)
++ // Vite's preload helper (behind every lazy import, in every layer) has no imports of its own: it goes in the first
++ // layer. Left to Rollup it joined the last one, and every layer imported that one back.
++ const helper = ids.find((id) => PRELOAD_HELPER.test(id))
++ if (helper && layerOf.size > 0) layerOf.set(helper, LAYERS[0]![0])
++ return layerOf
++}
++
++/** `build.rollupOptions.output.manualChunks`: the layer of a shared vendor module; everything else is Rollup's. */
++export function vendorLayers(): (id: string, api: ChunkApi) => string | undefined {
++ let layerOf: Map<string, string> | null = null
++ return (id, api) => {
++ layerOf ??= computeLayers(api)
++ return layerOf.get(id)
++ }
++}
ddiff --git a/apps/client/src/render/sceneSetup.ts b/apps/client/src/render/sceneSetup.ts
iindex 860c783..7b95f8d 100644
---- a/apps/client/src/render/sceneSetup.ts
++++ b/apps/client/src/render/sceneSetup.ts
@@@ -1,5 +1,4 @@
import { Engine } from '@babylonjs/core/Engines/engine.js'
--import { WebGPUEngine } from '@babylonjs/core/Engines/webgpuEngine.js'
import type { AbstractEngine } from '@babylonjs/core/Engines/abstractEngine.js'
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
import { Texture } from '@babylonjs/core/Materials/Textures/texture.js'
@@@ -16,7 +15,6 @@ import { LayeredSurfaceMaterial, maskTextureFrom } from './layeredSurface.js'
import { applyPaintedStatic, createModelPaintOverlay } from './paintedStatic.js'
import { staticExtent } from './surfaceProjection.js'
import { ModelCache } from './modelCache.js'
--import { ParticleSystem } from '@babylonjs/core/Particles/particleSystem.js'
import { PointLight } from '@babylonjs/core/Lights/pointLight.js'
import { Color4 as BColor4 } from '@babylonjs/core/Maths/math.color.js'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
@@@ -62,7 +60,13 @@ const engineQuality = new WeakMap<AbstractEngine, QualitySettings>()
export async function createEngine(canvas: HTMLCanvasElement): Promise<AbstractEngine> {
const quality = resolveStartupQuality()
let engine: AbstractEngine
-- if (await WebGPUEngine.IsSupportedAsync) {
++ // WebGPU's engine (about a fifth of Babylon's core) is fetched only where the browser has WebGPU at all (plan M6):
++ // a device without it never downloads or parses it.
++ const gpu = (navigator as Navigator & { gpu?: unknown }).gpu
++ const { WebGPUEngine } = gpu
++ ? await import('@babylonjs/core/Engines/webgpuEngine.js')
++ : { WebGPUEngine: null }
++ if (WebGPUEngine && (await WebGPUEngine.IsSupportedAsync)) {
const webgpu = new WebGPUEngine(canvas, { antialias: quality.antialias })
await webgpu.initAsync()
engine = webgpu
@@@ -468,24 +472,33 @@ function buildFountain(scene: Scene, base: Mesh, s: StaticBody, water: Water): v
pool.material = water.makeCalmSurface('fountain-pool')
pool.isPickable = false
-- // Spray: a slim upward jet that arcs back into the bowl.
-- const spray = new ParticleSystem('fountain:spray', 220, scene)
-- spray.particleTexture = new Texture(dropletTexture(), scene)
-- spray.emitter = new Vector3(cx, baseY + 2.55, cz)
-- spray.minEmitBox = new Vector3(-0.05, 0, -0.05)
-- spray.maxEmitBox = new Vector3(0.05, 0, 0.05)
-- spray.direction1 = new Vector3(-0.35, 2.6, -0.35)
-- spray.direction2 = new Vector3(0.35, 3.2, 0.35)
-- spray.gravity = new Vector3(0, -9.8, 0)
-- spray.minSize = 0.05
-- spray.maxSize = 0.12
-- spray.minLifeTime = 0.7
-- spray.maxLifeTime = 1.1
-- spray.emitRate = 120
-- spray.color1 = new BColor4(0.75, 0.87, 0.95, 0.85)
-- spray.color2 = new BColor4(0.55, 0.75, 0.9, 0.7)
-- spray.colorDead = new BColor4(0.6, 0.8, 0.95, 0)
-- spray.start()
++ // Spray: a slim upward jet that arcs back into the bowl. Babylon's particle system is a module of its own, fetched
++ // once a fountain is built rather than with the core every page loads (plan M6); the jet starts a moment later.
++ void import('@babylonjs/core/Particles/particleSystem.js')
++ .then(({ ParticleSystem }) => {
++ if (scene.isDisposed || base.isDisposed()) return
++ const spray = new ParticleSystem('fountain:spray', 220, scene)
++ spray.particleTexture = new Texture(dropletTexture(), scene)
++ spray.emitter = new Vector3(cx, baseY + 2.55, cz)
++ spray.minEmitBox = new Vector3(-0.05, 0, -0.05)
++ spray.maxEmitBox = new Vector3(0.05, 0, 0.05)
++ spray.direction1 = new Vector3(-0.35, 2.6, -0.35)
++ spray.direction2 = new Vector3(0.35, 3.2, 0.35)
++ spray.gravity = new Vector3(0, -9.8, 0)
++ spray.minSize = 0.05
++ spray.maxSize = 0.12
++ spray.minLifeTime = 0.7
++ spray.maxLifeTime = 1.1
++ spray.emitRate = 120
++ spray.color1 = new BColor4(0.75, 0.87, 0.95, 0.85)
++ spray.color2 = new BColor4(0.55, 0.75, 0.9, 0.7)
++ spray.colorDead = new BColor4(0.6, 0.8, 0.95, 0)
++ spray.start()
++ base.onDisposeObservable.addOnce(() => spray.dispose())
++ })
++ .catch(() => {
++ // Decoration only: a fountain without its jet (the module failed to load, say across a deploy) is still a fountain.
++ })
}
/** Tiny soft droplet sprite (no asset needed). */
ddiff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts
iindex 5fd28e8..7002906 100644
---- a/apps/client/vite.config.ts
++++ b/apps/client/vite.config.ts
@@@ -1,6 +1,7 @@
import { defineConfig } from 'vite'
import wasm from 'vite-plugin-wasm'
import { fileURLToPath } from 'node:url'
++import { vendorLayers } from './scripts/vendorLayers'
export default defineConfig({
// Physics (plan M6): the deterministic Rapier build whose wasm is a file of its own, compiled by the browser as it
@@@ -54,6 +55,8 @@ export default defineConfig({
leaderboard: 'leaderboard.html',
casual: 'casual.html',
},
++ // Babylon's core as a chain of layers no larger than 400 KB each, not one 2 MB chunk (scripts/vendorLayers.ts).
++ output: { manualChunks: vendorLayers() },
},
outDir: 'dist',
target: 'es2022',
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex 1f8783b..50836be 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -149,6 +149,15 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
Still over the 400 KB per-chunk line: Babylon's core (one 2.09 MB chunk: engines, materials, meshes, maths). A split
by Babylon folder was tried and refused: circular chunks, and it pulled the lazily loaded shaders into the initial
graph (3.97 MB). Left for M6: that chunk, KTX2 and atlases, baked icons.
++ *2026-10-14, that chunk:* gone. WebGPU's engine is fetched only where the browser has WebGPU, and the fountain's
++ particle system once a fountain is built. The rest of Babylon's core is a chain of layers
++ (`apps/client/scripts/vendorLayers.ts`): each layer is the static dependency closure of a few seeds (maths, engine,
++ scene, meshes, materials, post-processing, cameras, lights) minus the layers before it, so a layer imports only
++ itself and earlier layers and the chunks cannot import each other in a circle; only vendor code both pages already
++ load statically is placed, so nothing lazy becomes initial. Babylon's static graph turned out to have no cycle at
++ all, which is what the folder split had missed. Play's initial JS is 2.02 MB (511 KB gzip; was 2.35 MB, 579 KB),
++ the largest chunk 350 KB, and CI enforces the 400 KB line now. The vendor layers change only with a dependency, so
++ they stay cached across Games deploys. Left for M6: KTX2 and atlases, baked icons.
*2026-10-14, later:* the editor E2E runs in CI (`editor-e2e` job: `pnpm test:editor` and `pnpm test:collab` in
Chromium against the real server and the built client). Run by hand only until now, 15 of its checks had gone red:
- the paint checks stroked the terrain's centre, which the bottom dock had pushed under the brush settings bar (the
ddiff --git a/test/apps__client__scripts__vendorLayers.test.js b/test/apps__client__scripts__vendorLayers.test.js
nnew file mode 100644
iindex 0000000..e38b8a3
---- /dev/null
++++ b/test/apps__client__scripts__vendorLayers.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/client/scripts/vendorLayers.test.ts'
++// covers: 'apps/client/scripts'
++// Generated by node test/run.js --index; do not edit.