This document is the single source of truth for all architectural and workflow decisions. It must be re-read at the start of every session, after every context compaction, and before every implementation decision. It is immutable unless the user explicitly requests changes.
- Zero WebGL fallback, no legacy wrappers, no abstraction layers for older APIs.
- Built entirely around WebGPU paradigms: render pipelines, compute shaders, bind groups, command buffers.
- Strictly typed TypeScript. Exceptionally clean, modular API.
- Designed specifically to be entirely tree-shakable — unused features completely stripped from final build.
- Project infrastructure, dev server, and production bundling built strictly on Vite.
- Lightning-fast module resolution and highly optimized builds.
- Significantly faster and smaller than standard Babylon.js.
- Avoid heavy OOP overhead where data-oriented design or flat arrays serve GPU buffer transfers better.
- We do NOT copy Babylon.js code. We understand the math, then write the minimum code that produces identical pixels.
- Bundle size = runtime bytes only, excluding local NME payload modules and vendor runtimes whose bytes are outside Lite's own engine code. The bundle size tests measure JS bytes actually fetched at runtime via Playwright network interception, then subtract (a) local
*-nme.tsgraph payload modules so scene-specific checked-in NME data is not counted as engine/runtime code, and (b) bundled third-party WASM/shaping runtimes —text-shaper,manifold-3d, and@recast-navigation— so engine-size ceilings track Lite's own runtime code rather than upstream vendor blobs. Dynamic-import chunks that are never loaded (e.g. animation-group for a static model, pbr-reflectance-ext when no reflectance textures) are correctly excluded. Unused chunks in the build output are fine — only fetched counted bytes matter. - WGSL minification in production bundles. The bundle build (scripts/bundle-scenes-core.ts) uses a Vite plugin that strips comments,
\r, leading whitespace, and blank lines from?rawWGSL imports. Inline WGSL template strings in TypeScript source should also use minimal whitespace (no leading indentation). This keeps production bundles lean while source stays readable. - Never parse emitted WGSL strings for structured data. The WGSL minification plugin rewrites inline template-literal content (collapses whitespace, strips newlines). Code that splits WGSL strings on
\nor uses regex to extract field names WILL break in production bundles even if it works in dev mode. Always use typed interfaces (e.g.UboField[],BindingDecl[]) for structured data; reserve WGSL strings for shader code only. - Zero module-level side effects. No module may execute code at import time (no
register*()calls, noglobalThismutations, nonew Map(), nonew WeakMap(), nonew Set()). Module-levelconst cache = new Map()kills tree-shaking — the bundler treats the allocation as a side-effect and cannot eliminate the module even when nothing is imported from it. Use lazy-init instead:let cache: Map | null = null; function getCache() { if (!cache) cache = new Map(); return cache; }. Typed-array constants (new Float32Array([...])) are safe — bundlers treat them as pure. Caches must auto-invalidate on device change (comparedevice !== _cachedDevice). Material-swap rebuilders are discovered via_buildGroup._rebuildSingleproperty, not a global registry.
- Only the scene knows its contents. Components never reference the scene.
- A light is plain data. A camera is plain data. A mesh is plain data. None of them hold a reference to the scene.
- The scene holds arrays of lights, cameras, meshes. The scene is the owner.
- Factory functions like
createHemisphericLight()return plain data — they do NOT take a scene parameter. The caller adds the result to the scene viaaddToScene(). - This ensures zero circular dependencies, trivial serialization, and maximum tree-shakability.
- All public interfaces are pure state — no attached methods.
EngineContext,SceneContext,Camera,ArcRotateCamera,FreeCamera,Mesh,LightBase, etc. are plain data objects.- Behaviour is provided by standalone functions that accept the interface as their first argument:
registerScene(scene),startEngine(engine),addToScene(scene, entity),getViewMatrix(camera), etc. - This maximises tree-shakability: unused functions are fully eliminated. Methods on interfaces cannot be tree-shaken.
- Do NOT split a type into a public
Foo+ aFooInternalcompanion just to hide implementation details. Put the internal members directly onFooand tag each with/** @internal */. The build's d.ts trimming pass (vite.config.ts→trim-internal-dts) re-runs api-extractor withpublicTrimmedFilePathto strip every@internaldeclaration — and any top-level imports kept alive only by them — fromdist/index.d.ts. Public consumers see a clean type; internal code reads the field directly with full TypeScript typing. File-local*Internalinterfaces are still fine for cases where the internal shape is genuinely a separate concrete type (e.g. an internal subtype not tied 1:1 to the public type), but the "two types for one thing" pattern is forbidden. - When a property needs a different access modifier in the public API than internally (e.g.
readonlyexternally but mutable internally), expose two fields on the same object that alias the same value: a publicfoowith the public-facing modifier and an@internal_foowith the internal one. Both point to the same underlying storage (typically the same array/object reference). Example:SpriteRenderer.layers: readonly Sprite2DLayer[]paired with_layers: Sprite2DLayer[], where the factory setslayers = _layers = opts.layers.slice(). Internal mutation goes throughsr._layers.push(...); public consumers can only readsr.layers. The d.ts trim pass strips_layersentirely. Avoid this pattern unless you actually need divergent modifiers — most internal members just need@internal.
- User-facing APIs must never expose raw WebGPU handles (
GPUTexture,GPUTextureView,GPUSampler,GPUBuffer,GPUDevice). - Textures are represented by the
Texture2Dtype (returned byloadTexture2D()andcreateSolidTexture2D()). - Material property interfaces (e.g.
SheenProps.texture,ClearCoatProps) acceptTexture2D, not raw GPU objects. - Only internal modules (
_gpu, pipeline builders, renderable builders) may touch GPU handles. - Scene setup code in
lab/lite/src/is the user-facing reference — it must read like a high-level API demo, never like a WebGPU tutorial.
- Shaders are managed by materials, not by the render pipeline.
- A material encapsulates: shader source (WGSL), bind group layout, pipeline descriptor, and bind group creation.
- The render pipeline works through materials — it asks each material to provide its pipeline and bind groups.
- The renderer never imports shader files directly. It only sees materials.
- This keeps rendering logic generic and makes materials self-contained, swappable units.
- Every optional feature MUST be expressed as an extension module. The three extension surfaces are:
- glTF loader extensions →
packages/babylon-lite/src/loader-gltf/gltf-ext-*.tsorgltf-feature-*.tsimplementingGltfFeature(hooks:preMesh,applyMaterial,applyMesh,applyAsset). Registered inload-gltf.tsas[needs(json), () => import(...)]tuples — dynamic-imported only when the asset triggers them. - PBR material extensions →
packages/babylon-lite/src/material/pbr/fragments/*-fragment.tsimplementingPbrExt(detect,frag,writeUbo,bind,textures). Registered via_registerPbrExt(ext)after dynamic-import frompbr-renderable.ts. - Standard material extensions → follow the same pattern on the standard material side.
- glTF loader extensions →
- Never hardcode feature-specific logic in the core loader or core material builders. No
if (primitive.extensions?.KHR_...)inload-gltf.ts. Noif (mat.subsurface)inside the core PBR pipeline. The core walks an opaque feature list; feature modules own their triggers and their code paths. - Why: zero bytes for unused features (tree-shaking + dynamic import), no coupling between core and feature code, new extensions can be added without touching the core. Violating this rule breaks bundle-size ceilings for all scenes.
- Define extension-only feature bits INSIDE the lazy fragment, not in the shared
pbr-flag-bits.ts. Afeatures2bit (e.g.PBR2_CC_UV_TX) that is set in a fragment'sdetect()and read in itsfrag()is used only within that one lazy fragment module. If youexport constit from the sharedpbr-flag-bits.ts, the constant is retained in the entry/shared chunk (the lazy fragment chunks import it cross-chunk, so it cannot be tree-shaken) and every scene'sscene*.jsgrows by ~18 bytes per bit — including scenes that never use the feature (e.g. scene1/BoomBox moved +90 bytes from 5 such bits). Instead, declare the bit as a plainconstat the top of the fragment file and leave only a reservation comment inpbr-flag-bits.tsdocumenting which bit numbers are taken (to prevent collisions). This yields literally 0 KB movement on non-feature scenes. Verified: moving 5 UV-transform bits out ofpbr-flag-bits.tsreturned scene1 to byte-identical vs the pre-feature commit, while feature scenes (scene26 subsurface, scene28 clearcoat) grew as expected.
- Rendering output must be mathematically and visually identical to Babylon.js.
- No approximations. No "close enough."
- Validated via automated pixel diff against Spector.GPU reference captures.
- Public API must feel like Babylon.js — a developer ports a standard scene with minimal friction.
- Internal implementation is completely different, but developer experience stays familiar.
- Always aim for the long term solution. Never hack a fix
Babylon Lite uses BJS Y-up UVs throughout the mesh and shader stack (V=1 is top of texture). WebGPU samplers are Y-down (V=0 is row 0 = top of texture in storage). A V-axis conversion is therefore required somewhere on every textured surface. The codebase performs that conversion through exactly two paths; do not invent new ones:
-
Raster upload-flip.
texture-2d.tscallscopyExternalImageToTexture({ flipY: invertY=true })on image-decoded uploads (PNG/JPG/HTMLImage/Bitmap). The decoded image data is row 0 = top; the upload writes row 0 = bottom into the GPU texture, so subsequenttextureSample(uv)with V=1=top reads back upright. This is the default for rasterloadTexture2D. -
Material-side V-flip via
invertY. Codec-decoded textures (ktx2/basis) store data row 0 = top in the GPU texture (no upload flip) and setTexture2D.invertY = true. Standard/PBR pipelines see this flag and emit a UV V-flip in the material shader (v = 1 - v, implemented as a(scaleY, offsetY)UV uniform instandard-pipeline.ts). This works for clamp/repeat/mirror-repeat where UVs land in[0, 1]; for clamp-to-edge with UVs outside[0, 1]the V-flip is still safe by codebase convention (atlases always stay in[0, 1]).
Offscreen render targets are not a third path. As of PR #192 they render upright (Y-up, no projection flip, frontFace = "ccw" throughout) — the old per-RT Y-flip convention (RenderTargetDescriptor.flipY / RenderTargetSignature._flipY, viewProj row-1 negation, frontFace = "cw") has been removed. Downstream sampling of an RT is handled uniformly by the copy-to-texture / post-process vertex shaders' single unconditional UV convention, so there is no per-RT flipY field to set or override.
async function main(): Promise<void> {
const engine = await createEngine(canvas);
const scene = createSceneContext(engine);
addToScene(scene, await loadGltf(engine, "https://playground.babylonjs.com/scenes/BoomBox.glb"));
await loadEnvironment(scene, ".../environment.env");
// Components are plain data — scene is the owner
const light = createHemisphericLight([0, 1, 0], 0.7);
addToScene(scene, light);
const camera = createDefaultCamera(scene);
camera.alpha += Math.PI;
// Materials own their renderable builders — no explicit pipeline building
await registerScene(scene); // builds deferred work, partitions renderables
await startEngine(engine); // resolves after first frame rendered; renders all registered scenes
}- Never run
git checkout --,git restore, orgit reseton files you did not personally modify in this session. - If you see unexpected changes in
git diff, ask the user before reverting — those may be the user's uncommitted work. - Reverting unstaged changes is destructive and irreversible. There is no undo.
- Only revert files that you can confirm were modified by your own edits or sub-agents in the current session.
- Before committing, delete ALL temporary/debug files created during the session: isolated test scenes, debug screenshots, amplified diff images, pixel comparison scripts, Spector JSON captures, and any other artifacts not part of the final deliverable.
- Remove debug code:
console.warn/console.logdiagnostics,(window as any).__bjsSceneexposures, and shader debug overrides (e.g.color = vec4(reflectionColor * 10, 1)) must be reverted before commit. - Run
git status --shortand verify every untracked file (??) belongs in the commit. If it's a temp file, delete it.
- Agents MUST NOT run
pnpm test:perf. Performance tests are machine-sensitive and reserved for the user / CI; running them from an agent session wastes time and produces unreliable signal. - Agents run only:
pnpm build:bundle-scenesandpnpm test:parity(or the individual spec viapnpm exec playwright test tests/lite/parity/scenes/<spec>.spec.ts). These cover parity MAD + bundle-size ceilings, which are the agent-enforceable guardrails. - The per-scene bundle manifest is MANDATORY on every PR. The bundle-size baseline is distributed: one tracked file per scene at
lab/public/bundle/manifest/<scene>.json(the per-scene runtime-fetched bundle sizes). The single aggregatelab/public/bundle/manifest.jsonis now a generated, gitignored build artifact — do not commit it. After runningpnpm build:bundle-scenes, the regenerated per-scene files underlab/public/bundle/manifest/must be committed as part of the PR. This keeps the committed bundle sizes in sync with the code, lets reviewers see bundle-size deltas directly in the diff, and avoids merge conflicts because PRs touching different scenes no longer collide on one shared manifest file. A PR that changes runtime code (packages/babylon-lite/src/**) or scenes but leaves the per-scene manifest stale is incomplete — always rebuild and commit it. pnpm testchains build + parity (no perf), which is acceptable.- The parity suite is slow (many minutes). Only run it when the Lite engine changed. Run
pnpm test/pnpm test:parityonly if you modifiedpackages/babylon-lite/src/**(the engine/runtime). Changes confined to demos (lab/lite/src/demos/**), scenes (lab/lite/src/scenes/**), lab UI, thumbnails, docs, manifests, or other static lab assets do not require running parity or any test suite — they cannot move engine parity. Skip the suites in those cases unless the user explicitly asks. - Lab-only UI changes do not require parity/test suites. When a task only changes the lab UI or static lab presentation, do not run parity or other test suites unless explicitly requested; use lightweight static inspection or a lab build only if validation is needed.
- Iterate on one scene first. When working on a specific scene, run only that scene's parity spec during the edit/test loop (e.g.
pnpm exec playwright test tests/lite/parity/scenes/scene36-basis-texture.spec.ts) instead of the fullpnpm test:paritysuite. This dramatically cuts iteration time. Only run the full suite +pnpm build:bundle-scenesas the final guardrail check before declaring success. - If perf validation is needed, ask the user to run
pnpm test:perflocally.
- Use the Spector.GPU MCP tools (
spector-gpu-navigate,spector-gpu-capture,spector-gpu-get_resource, etc.) to capture reference frames from Babylon.js (WebGPU mode). - Extract: buffer data, pipeline states, matrix math, shader outputs.
- All screenshots / visual captures shared in a request MUST be encoded as JPG at low-enough quality to stay well under the 5 MB per-request limit. PNG captures routinely exceed it. Use quality ≤ 60 (e.g.
magick screenshot.png -quality 60 screenshot.jpgor equivalent) and verify the file is under 1 MB before attaching. If it is still too large, reduce quality further (try 40, then 25) until it fits. - The parity harness runs Babylon.js (iframe oracle) side-by-side with Babylon Lite.
- Zero guesswork — every rendering decision is validated against captured GPU state.
- ALWAYS capture and compare with Spector before making rendering changes.
- Capture BOTH the reference scene AND our scene.
- Compare: shaders, pipeline configs, uniform buffer contents, texture formats/sizes, draw call order.
- Never guess pixel values, color formulas, or material parameters. Extract them from the captures.
When a parity diff exists on specific meshes:
- Identify the mesh — use BJS picking (
scene.pick(x, y)) on the hotspot pixel to get the mesh name and material. - Create a minimal isolated scene — render ONLY the offending mesh(es) in both BJS and Lite with a black background. This eliminates occlusion, blending, and sorting noise.
- Capture both with Spector — with only 1-2 draw calls, you can directly compare UBO data, shader source, and texture bindings without searching through hundreds of commands.
- Compare buffer values — use
spector-gpu-get_resourceto read the exact float values in each UBO (world matrix, material uniforms, light data) and diff them between engines. - Compare shaders — extract the fragment shader from both captures and diff the key statements (lighting equation, reflection computation, alpha handling).
- Fix and verify — make the fix, re-run the isolated scene to confirm the specific mesh now matches, then run the full parity test.
Hard-won gotchas that have each caused multiple parity failures. Check these first when a glTF scene renders black, garbled, exploded, or mis-coloured:
- Interleaved vertex attributes must honor
bufferView.byteStride.resolveAccessorreads a tight typed-array view and ignores stride. Any feature that calls it on a strided source reads padding / a neighbouring attribute and corrupts the result. Each attribute family needs its own de-stride: skinned rigs interleaveJOINTS_0/WEIGHTS_0(mis-read → exploded or mis-posed mesh — seegltf-feature-skeleton.ts);COLOR_0is often interleaved and normalizedUNSIGNED_BYTE/VEC4 (mis-read → rainbow garbage — seeresolveColorVec3ingltf-interleave.ts). The tight path de-strides viagltf-interleave.ts; mirror its handling for any new attribute consumer. - A primitive without
NORMALneeds generated normals on every path. The tight loader callscomputeSmoothNormals; the interleaved path previously zero-filled, yieldingnormalize(0)= NaN → pure-black lit fragments (material-less skinned meshes hit this). Always synthesize normals, never zero-fill. But the glTF spec requires no-NORMALprimitives to be flat-shaded (one normal per face), which BJS does — so Lite setsMSH_FLAT_NORMALand derives the face normal per-fragment fromdpdx/dpdy(worldPos)(oriented to the viewer) instead of interpolating the smooth normal. The WGSL lives in lazily-importedflat-normal-wgsl.ts(zero bytes for normal-having scenes). - Vertex color is
float32x4(RGBA), not RGB. glTFCOLOR_0alpha modulates the fragment alpha (vertex-color-driven alpha blending / alpha-clip), so the rgb multiplies base color andalpha *= vColor.ais threaded before the alpha-test discard. VEC3 sources geta = 1. This matches the engine's existing vec4 convention for procedural/node/shader meshes. KHR_animation_pointermaterial-factor targets need their UBO slot to pre-exist. Material flags (e.g.PBR2_HAS_BASE_COLOR_FACTOR,PBR_HAS_EMISSIVE_COLOR) are computed at first render from!!mat.field. Seed the animated field during load (before first render) or the pointer animates nothing.emissiveColoris stored pre-multiplied (factor × strength); keep factor and strength separate so either pointer can recombine.
- Match
loadEnvironment's image processing in the BJS reference scene.loadEnvironmentenables tone mapping and setsexposure = 0.8,contrast = 1.2(mirroring BJScreateDefaultEnvironment). A flat-clearColorBJS scene that skipscreateDefaultEnvironmentleaves all three at their defaults (tone mapping OFF, 1.0/1.0) — a non-linear mismatch that silently inflates MAD on every IBL-lit model (the dominant residual, ~10-50% darker mid-tones). Set all three in the BJS scene:scene.imageProcessingConfiguration.exposure = 0.8; contrast = 1.2; toneMappingEnabled = true;. (Tone mapping was the single biggest parity lever on the cx20 scene batch — it dropped multiple scenes from MAD ~1.5 to ~0.01.) - BJS loading overlay leaks into canvas screenshots.
page.locator("canvas").screenshot()captures whatever HTML composites over the canvas box, including Babylon'sbabylonjsLoadingDivspinner. A still-fading overlay darkens the whole frame by a scene-dependent amount and inflates MAD (worst on heavy scenes). In BJS reference scenes that import@babylonjs/core/Loading/loadingScreen(a required side-effect for some assets), no-op the overlay right after engine init:engine.displayLoadingUI = function () {};. - Use the same flat
clearColorin both engines for IBL-only test scenes (a buffer clear is pixel-identical across BJS/Lite, unlike a skybox whose projected geometry diverges at arbitrary framings) so the full-image compare passes with no background masking.
- Engine is built progressively, one reference scene at a time.
- Each scene adds capability; all previous scenes must remain pixel-perfect (regression).
- Scene 1:
playground.babylonjs.com/full.html?webgpu=1#QCU8DJ#800(BoomBox + default env) - Before adding or fixing a scene, you MUST study existing scenes first:
- Read the BJS scene code (
bjs-sceneN.ts) to understand exactly which BJS APIs are used (e.g.createDefaultEnvironment(),PBRMaterial,CubeTexture, etc.). - Find existing Lite scenes that use the same BJS features. Search all
lab/lite/src/lite/scene*.tsfiles for similar patterns (DDS skybox, environment loading, material types, camera setup, etc.). - Reuse their implementation patterns. If scene14 already loads a DDS cube skybox, scene20 should use the same
buildDdsSkyboxRenderableapproach — not reinvent a flat-color approximation. If scene7 already handles animated glTF withseekTime, copy that pattern. - Use Spector.GPU captures to compare BJS and Lite shader pipelines side-by-side. Never guess what a BJS shader does — extract and read the actual WGSL from the capture.
- Read the BJS scene code (
- When adding a new scene, you MUST:
- Create
lab/lite/sceneN.html+lab/lite/src/lite/sceneN.ts - Add the entry to
lab/vite.config.tsrollup inputs - Add a Playwright parity test in
tests/lite/parity/scenes/sceneN-*.spec.ts - Add a reference screenshot to
reference/lite/sceneN-*/babylon-ref-golden.png - Save a downscaled JPG thumbnail (≤720p, e.g. 1280×720) of the golden to
lab/public/thumbnails/sceneN.jpg - Add a card to
lab/index.html(the scene gallery) - Add a bundle-size ceiling test in
tests/lite/parity/bundle-size.spec.ts - Add an entry to
scene-config.jsonwithid,slug,name, andmaxMad - Never change a bundle-size ceiling without explicit user approval. If a ceiling is exceeded, report the numbers and ask the user before raising the limit.
- Create
- All golden and test images live under
reference/lite/sceneN-<slug>/— never intests/lite/or anywhere else. - Golden reference:
babylon-ref-golden.png(every scene, no exceptions). - Test actual output:
test-actual.png(written by the parity test). - Live reference (optional):
live-ref.png(captured at test time from Babylon.js; falls back to golden if capture fails). - Thumbnail: A downscaled JPG of the golden lives at
lab/public/thumbnails/sceneN.jpg— see §2b″ Thumbnail Convention. - Parity specs define
REFERENCE_DIR = path.resolve(__dirname, '../../../../reference/lite/sceneN-<slug>')and resolve all images relative to it.
Gallery thumbnails are presentation assets for the lab/pages cards — not parity ground truth. The lossless PNGs under reference/lite/** are reserved for pixel-diffing and must never be served to cards.
- Format: JPG only. Never commit a PNG to
lab/public/thumbnails/. - Resolution: exactly 1280×720 (720p). This is "far enough" for how cards display them and keeps the repo lean. Cover-crop (center, fill, crop overflow) rather than letterbox — gallery cards are 16:9
object-fit: cover, so anything taller/wider is cropped at render time anyway. Quality ≈ 78 (raise selectively only if a flat/dark image shows banding; keep files well under ~250 KB). - Naming:
sceneN.jpgfor scenes,demo-<slug>.jpgfor demos. - Source: scene thumbnails are a downscaled JPG of that scene's
babylon-ref-golden.png; demo thumbnails are a JPG of a representative in-app screenshot. - Cards load thumbnails, not reference images. Both the scene gallery and the demo gallery
<img src>point at/lite/thumbnails/…jpgand hide on error (onerror). Do not point cards atreference/lite/**ortest-actual.png. - Every static-server MIME map must map
.jpg/.jpeg(lab/vite.config.ts,scripts/bundle-scenes-core.ts,scripts/coverage-scene.ts,scripts/test-scenes-quick.ts). Adding a new server? Add the JPEG MIME entries. - This convention is distinct from the request-shared screenshots rule in §1 (JPG, quality ≤ 60, < 1 MB) which governs images attached to a chat turn, not committed thumbnails.
scene-config.jsonat the repo root is the single source of truth for all per-scene MAD ceilings.- Each entry has:
id,slug,name,maxMad(full-image ceiling), and optionallymaxRegionMad(region-only ceiling). - Parity tests read thresholds via
getSceneConfig(id)fromtests/lite/parity/compare-utils.ts— no hardcoded MAD values in test files. - Lab parity tab fetches
/scene-config.jsonat runtime and uses per-scenemaxMadfor pass/fail coloring. - When adding a new scene, add its entry to
scene-config.jsonwith an appropriatemaxMad. - Never raise a scene's
maxMadwithout explicit user approval. If a parity test fails, fix the rendering — don't loosen the threshold.
- Animated scenes use
?seekTime=to freeze at a deterministic pose. Both the BJS reference HTML and the Lite scene must support?seekTime=(seek toseekTime * 60frames, freeze, setcanvas.dataset.animationFrozen = 'true'). - The golden is captured ONCE from BJS (manually or via a one-off script) with the desired
seekTime, then committed asbabylon-ref-golden.png. It is never regenerated at test time. - Parity tests compare Lite against the golden only — they do NOT open a BJS page. The test loads
sceneN.html?seekTime=X, waits foranimationFrozen, screenshots, and compares against the golden. - No parity test may open a BJS reference page at runtime. Goldens are the ground truth. Only regenerate them when the user explicitly asks.
- Every module gets unit tests + integration tests driven by the parity harness.
- Babylon Lite must remain provably stable as it evolves.
- Generate exhaustive architectural documentation first, before code.
- Docs contain: complete API signatures, data structures, shader logic outlines, pipeline configs.
- Must be so rigorous that if all source code were deleted, an LLM could regenerate it perfectly in one shot from the docs alone.
- Docs are the formal specification; code is the implementation of the spec.
- Every time context is compacted or a new session starts:
- Re-read this file (
GUIDANCE.md) - Re-read the plan (
plan.mdin session state) - Re-read the scene spec (
files/scene1-spec.mdin session state)
- Re-read this file (
- These instructions are immutable. They always take priority.
- All code you write must pass ESLint and Prettier before being considered done.
- ESLint config:
eslint.config.mjs(flat config). Prettier config:.prettierrc. - Prettier settings: 4-space tabs, 180 print width, trailing commas (es5), auto line endings.
- Key ESLint rules enforced:
prettier/prettier: error— formatting is enforced via ESLint/Prettier integration.@typescript-eslint/no-floating-promises: error— all promises must be awaited, caught, or explicitly voided.@typescript-eslint/consistent-type-imports: error— useimport typefor type-only imports.@typescript-eslint/await-thenable: error— don't await non-Promise values.no-console: error— onlyconsole.warn,console.error,console.time,console.timeEnd,console.traceallowed.curly: error— always use braces for control flow.
- Commands:
pnpm run lint— runs ESLint +tsc --noEmit(full check).pnpm run lint:fix— auto-fix ESLint/Prettier issues.pnpm run format— run Prettier on all source files.pnpm run format:check— verify Prettier formatting without changing files.
- After every code change, run
pnpm run lint:fixto auto-format, then verify withpnpm run lint.
pnpm run analyze-bundle <sceneN>— builds a single scene withrollup-plugin-visualizerand prints a per-chunk, per-module size breakdown (rendered bytes + gzip).- Script:
scripts/analyze-bundle.ts. Also writes an interactive treemap to/tmp/<scene>-bundle-stats.html. - Use this tool whenever investigating bundle size regressions or exploring reduction opportunities.
- Example:
pnpm run analyze-bundle scene7
- After
pnpm build:bundle-scenes, the lab's Bundle tab exposes a 📄 Files button on every scene card that opens a per-scene breakdown of every chunk, every module, and every exported symbol (token chip) that survived tree-shaking, annotated with runtime-loaded vs built-but-not-fetched. - Backing data lives in
lab/public/bundle/bundle-info/<scene>.json(full module + export list) and the per-scenelab/public/bundle/manifest/<scene>.json(runtime-fetched chunk set per scene). - When tasked with reducing bundle size, you MUST consult the bundle files data before proposing changes. Compare the exported tokens retained for a scene against what the scene's
.tsfile actually imports:- Tokens that survive tree-shaking but aren't needed by the scene's features reveal unconditional imports, side-effectful modules, or missing feature gates — these are the real optimization targets.
- Runtime-loaded chunks whose functionality the scene has explicitly opted out of (e.g.
background-renderabledespiteskipSkybox+skipGround,skeleton-*for a non-skinned GLB) indicate conditional dynamic imports that are missing. - Duplicate exports appearing in multiple chunks indicate re-exports or accidental duplication worth collapsing.
- Read the JSON directly (e.g.
lab/public/bundle/bundle-info/scene12.json) for scriptable analysis; use the lab UI for interactive exploration.
- Location:
C:\Repos\Babylon.js - Use for understanding internal math and algorithms — never for copying code.
- Key paths:
- WebGPU engine:
packages/dev/core/src/Engines/webgpuEngine.ts - PBR material:
packages/dev/core/src/Materials/PBR/ - PBR shaders:
packages/dev/core/src/Shaders/pbr.vertex.fx,pbr.fragment.fx - WGSL shader includes:
packages/dev/core/src/ShadersWGSL/ShadersInclude/ - Scene helpers:
packages/dev/core/src/Helpers/sceneHelpers.ts - Environment helper:
packages/dev/core/src/Helpers/environmentHelper.ts - glTF loader:
packages/dev/loaders/src/glTF/2.0/glTFLoader.ts - Env texture loader:
packages/dev/core/src/Materials/Textures/Loaders/envTextureLoader.ts
- WebGPU engine:
For any feature, we follow this process:
- Capture — Use Spector.GPU to extract the exact GPU state Babylon.js produces.
- Understand — Read Babylon.js source to understand the mathematical algorithm (not the implementation).
- Specify — Write the one-shot doc describing our minimal implementation.
- Implement — Write the smallest possible code that produces identical output.
- Validate — Pixel-diff against the Spector reference capture.
We never ask "how does Babylon.js implement this?" — we ask "what math produces these pixels?" Then we write that math in the cleanest, most direct WGSL/TypeScript possible.
Example: Babylon's PBR fragment shader is 1,373 lines because it handles every possible feature. For Scene 1 (BoomBox), the active math is ~150 lines of WGSL:
- GGX normal distribution
- Smith-GGX geometry function
- Schlick Fresnel
- IBL cubemap sampling (split-sum)
- Hemispheric light contribution
- Tangent-space normal mapping
- Emissive additive term
Every module gets a doc in docs/lite/architecture/ using this format:
# Module: [name]
> Package path: `packages/babylon-lite/src/[name]/`
## Purpose
## Public API Surface (types, functions, constants — full signatures)
## Internal Architecture (data structures, memory layouts)
## Pipeline Configuration (vertex/fragment stages, bind groups, depth/stencil)
## Shader Logic (WGSL outline or pseudocode with exact math)
## State Machine / Lifecycle
## Babylon.js Equivalence Map
## Dependencies
## Test Specification
## File Manifest
The documentation must be complete enough that deleting all source code and regenerating from docs alone produces a working, pixel-identical engine.