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)
Changes
@@@ -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.nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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)++ }++}@@@ -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): vpool.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). */@@@ -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',@@@ -149,6 +149,15 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegacStill over the 400 KB per-chunk line: Babylon's core (one 2.09 MB chunk: engines, materials, meshes, maths). A splitby Babylon folder was tried and refused: circular chunks, and it pulled the lazily loaded shaders into the initialgraph (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` inChromium 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 (thennew file mode 100644@@@ -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.
@@ -17,9 +17,9 @@ jobs:
@@ -17,9 +17,9 @@ jobs:
extra: |
extra: |
(cd apps/server && npx openvibe-contracts-check --service games --src src)
(cd apps/server && npx openvibe-contracts-check --service games --src src)
pnpm --filter @openvibe/client build
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
# 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
# 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.
# editors on one map). They ran only by hand before, and 15 checks had quietly gone red; this keeps them green.
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -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)
+ }
+}
@@ -1,5 +1,4 @@
@@ -1,5 +1,4 @@
import { Engine } from '@babylonjs/core/Engines/engine.js'
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 type { AbstractEngine } from '@babylonjs/core/Engines/abstractEngine.js'
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
import { Texture } from '@babylonjs/core/Materials/Textures/texture.js'
import { Texture } from '@babylonjs/core/Materials/Textures/texture.js'
@@ -16,7 +15,6 @@ import { LayeredSurfaceMaterial, maskTextureFrom } from './layeredSurface.js'
@@ -16,7 +15,6 @@ import { LayeredSurfaceMaterial, maskTextureFrom } from './layeredSurface.js'
import { applyPaintedStatic, createModelPaintOverlay } from './paintedStatic.js'
import { applyPaintedStatic, createModelPaintOverlay } from './paintedStatic.js'
import { staticExtent } from './surfaceProjection.js'
import { staticExtent } from './surfaceProjection.js'
import { ModelCache } from './modelCache.js'
import { ModelCache } from './modelCache.js'
-import { ParticleSystem } from '@babylonjs/core/Particles/particleSystem.js'
import { PointLight } from '@babylonjs/core/Lights/pointLight.js'
import { PointLight } from '@babylonjs/core/Lights/pointLight.js'
import { Color4 as BColor4 } from '@babylonjs/core/Maths/math.color.js'
import { Color4 as BColor4 } from '@babylonjs/core/Maths/math.color.js'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
@@ -62,7 +60,13 @@ const engineQuality = new WeakMap<AbstractEngine, QualitySettings>()
@@ -62,7 +60,13 @@ const engineQuality = new WeakMap<AbstractEngine, QualitySettings>()
export async function createEngine(canvas: HTMLCanvasElement): Promise<AbstractEngine> {
export async function createEngine(canvas: HTMLCanvasElement): Promise<AbstractEngine> {
const quality = resolveStartupQuality()
const quality = resolveStartupQuality()
let engine: AbstractEngine
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 })
const webgpu = new WebGPUEngine(canvas, { antialias: quality.antialias })
await webgpu.initAsync()
await webgpu.initAsync()
engine = webgpu
engine = webgpu
@@ -468,24 +472,33 @@ function buildFountain(scene: Scene, base: Mesh, s: StaticBody, water: Water): v
@@ -468,24 +472,33 @@ function buildFountain(scene: Scene, base: Mesh, s: StaticBody, water: Water): v
pool.material = water.makeCalmSurface('fountain-pool')
pool.material = water.makeCalmSurface('fountain-pool')
pool.isPickable = false
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). */
/** Tiny soft droplet sprite (no asset needed). */
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite'
import { defineConfig } from 'vite'
import wasm from 'vite-plugin-wasm'
import wasm from 'vite-plugin-wasm'
import { fileURLToPath } from 'node:url'
import { fileURLToPath } from 'node:url'
+import { vendorLayers } from './scripts/vendorLayers'
export default defineConfig({
export default defineConfig({
// Physics (plan M6): the deterministic Rapier build whose wasm is a file of its own, compiled by the browser as it
// 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({
@@ -54,6 +55,8 @@ export default defineConfig({
leaderboard: 'leaderboard.html',
leaderboard: 'leaderboard.html',
casual: 'casual.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',
outDir: 'dist',
target: 'es2022',
target: 'es2022',
@@ -149,6 +149,15 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
@@ -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
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
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.
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
*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:
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
- the paint checks stroked the terrain's centre, which the bottom dock had pushed under the brush settings bar (the
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.