# Three.js fit for Delhi Pulse

30 September 2026 · design-stage assessment. Reviewed `BRIEF.md`, `design/TECH-AUDIT.md`, the current map/model code, concept GLBs and Ring & River v2 tokens. Checked current official documentation. This document proposes future integration; it changes no renderer, dependencies, live layers or backend. There are no device benchmarks here.

## Current integration update — v4

The recommended deck.gl experiment is now implemented in `/miniatures.html`: seven original GLBs use ScenegraphLayer while the existing map, feeds, transforms and picking remain in charge. Static model footprints filter overlapping building features, retaining tiles and roads. Nearby detailed bus instances are capped at 64 and simpler meshes remain for the fleet. The current models in `miniatures-v3/` use **+Z up, +Y forward** and normalized dimensions. The standalone model-viewer gallery converts them to +Y up with `orientation="0deg -90deg 0deg"`. The older asset measurements and axis convention below describe the earlier v1 study assets only.

The UI is now the crisp v4 system. Three.js is used internally by the standalone model-viewer gallery; a separate custom Three renderer has not been added to the live map. Device performance and Unity/Android packaging have not been validated. See `MINIATURE-ASSETS.md`, `MINIATURE-MASKING.md` and `QA.md` for current scope.

## Recommendation

**Keep MapLibre + deck.gl for the city and its fourteen data layers. Use Three.js selectively for a polished, user-opened landmark/detail scene when that scene needs richer materials or interaction.** First prove the same improved asset in deck.gl `ScenegraphLayer`; a GLB upgrade alone does not require Three.js.

The biggest immediate gains are art direction, better silhouettes, clean normals, quieter city context, selection and touch controls. A different renderer cannot supply these decisions. Preserve the brand-first order: Ring & River identity, responsive information hierarchy, branded map, refined models, then an optional Three.js experiment. No mascot belongs in the data or interface.

## What Three.js adds, and what is already available

| Desired improvement | Existing stack | Useful Three.js addition |
|---|---|---|
| Delhi landmark shapes | `SimpleMeshLayer` already draws real meshes; `ScenegraphLayer` can load glTF/GLB | A scene graph for individually editable model parts, materials and authored close-up scenes |
| More convincing stone, metal and glass | Scenegraph supports glTF PBR through experimental `_lighting: 'pbr'`; prototype this first | `MeshStandardMaterial` offers metalness/roughness and environment lighting; `MeshPhysicalMaterial` adds clearcoat, transmission and other material effects |
| Many buses, planes and trains | Geographic instancing, picking and transforms are already part of deck.gl | `InstancedMesh` is useful inside a separate Three.js scene, but does not remove the geographic/data plumbing needed to replace deck.gl |
| Map depth, roads, labels and buildings | MapLibre supplies vector styling, extrusions, sky, camera and zoom | A custom layer can place bespoke geometry into the same map view |
| Touch orbit around a selected object | MapLibre already owns map gestures and camera | `OrbitControls` supplies bounded orbit/zoom for an isolated detail canvas |
| Readable phone UI, sources and freshness | HTML/CSS and existing state/UI hooks | These remain DOM responsibilities; Three.js adds no automatic accessibility or data provenance |

`ScenegraphLayer` supports geographic position, orientation, scale, glTF animation and picking. Its PBR and image-based-lighting options are explicitly experimental; validate the installed deck.gl 9.4 behavior before making them a product dependency. [deck.gl ScenegraphLayer](https://deck.gl/docs/api-reference/mesh-layers/scenegraph-layer).

Three.js standard materials use a metallic/roughness workflow, with environment lighting recommended for good results. Physical materials offer additional surface effects at a greater rendering cost; matte sandstone does not need every effect enabled. [MeshStandardMaterial](https://threejs.org/docs/pages/MeshStandardMaterial.html), [MeshPhysicalMaterial](https://threejs.org/docs/pages/MeshPhysicalMaterial.html). Instancing reduces draw calls for objects sharing geometry/material, but instance count still affects work. [InstancedMesh](https://threejs.org/docs/pages/InstancedMesh.html).

## Lessons from the Jelly Baby reference

The main task's browser inspection of [Jelly Baby](https://jelly.scottsun.io/) observed glossy/translucent forms, warm grounded lighting, direct manipulation and touch/camera controls. Public entry-script inspection confirmed that it loads a Three.js-containing chunk: [entry bundle](https://jelly.scottsun.io/assets/index-D-EmqkAZ.js), [Three chunk](https://jelly.scottsun.io/assets/three.tsl-gP3RZRZ4.js). The chunk contains `MeshPhysicalMaterial` and renderer implementations. This confirms library presence, not the active renderer, exact version, physics method or performance on our target phones. These hashed URLs describe the inspected build and may change.

Borrow its **material quality and interaction polish** for a landmark close-up: deliberate highlights, a convincing contact with the ground, limited orbit and a reachable reset control. Delhi Pulse should retain a calm city-observatory character. Translucent jelly, soft-body bouncing and toy controls are not a useful default for readings or transport positions.

## Specific findings in this repository

| Hook | Finding / integration consequence |
|---|---|
| `public/js/main.js`: `styleCity()`, `SKY_NORMAL`, `makeWorld()`, `flowLayers()` | Colour, building depth, river, perimeter and sky already have styling hooks. Apply v2 here before introducing another renderer. |
| `main.js`: `LANDMARK_GROUPS`, `tick()` | Landmarks appear above zoom 11.5 through one mesh layer per family. This is the narrowest place to trial one improved India Gate asset while preserving selection and geographic coordinates. |
| `public/js/models.js`: `MeshBuilder.tri()` | Most faces deliberately receive flat normals, with some smooth-normal primitives. Refine normals and bevels where the silhouette needs them; changing the renderer does not smooth authored hard edges. |
| `public/js/layers.js`, `metro.js`, `rail.js` | Existing transport meshes and zoom switches suit small repeated objects. Preserve dots/meshes by zoom and use richer art only for a selected or sufficiently close object. |
| `main.js`: `MapboxOverlay`, `getTooltip`, `onClick` | Existing deck picking resolves source objects. A Three custom layer needs an explicit picking/selection bridge; it will not automatically enter these handlers. |
| `public/js/ui.js`: `initUI()` / focus and drawer hooks | A user-opened 3D detail module can sit beside the current source reading. Keep values, units, timestamps and keyboard controls in HTML. |
| `public/design/brand/tokens.json` | Delhi Night `#070E13`, sandstone `#E9AA69`, mint `#9AF5C7`; condensed display type and Plex data/body type. This is the active art direction. |

The supporting GLBs have normals, metallic/roughness materials, no textures, skins or animations. Local metadata and binary JSON inspection give these asset counts:

| Concept | Triangles | File bytes | Material primitives |
|---|---:|---:|---:|
| India Gate | 2,264 | 82,140 | 3 |
| DTC bus | 4,744 | 144,108 | 9 |
| Metro car | 6,296 | 189,048 | 9 |

These are **asset measurements**, not rendered frame time or observed draw-call totals. The bus/Metro are too elaborate to substitute blindly for every current moving mesh; simplify repeated variants and combine materials where practical. Their current colours come from the older concept palette and need a v2 pass.

The GLBs use **+Y up, +Z forward**, while the procedural map meshes use **+Z up, +Y forward**. Normalize orientation, origin and scale explicitly. India Gate is a 4.355 m-tall miniature in its manifest; the map's landmark reference height is 42 m. A loader does not infer the intended display scale. Keep the map's illustrative size treatment clearly separate from accurate physical proportions.

## Selective integration architecture

1. **First prototype: deck.gl GLB landmark.** Keep the current overlay, source objects and camera; replace one landmark family with a cached `ScenegraphLayer`, retaining stable IDs and transforms. Try both flat and PBR rendering. Keep map labels above the model with deliberate `beforeId` ordering. Interleaved `MapboxOverlay` already shares MapLibre's WebGL2 context and camera; MapLibre owns its pixel ratio. [MapboxOverlay](https://deck.gl/docs/api-reference/mapbox/mapbox-overlay).

2. **If richer interaction earns its cost: lazy-loaded Three.js detail canvas.** Open one refined landmark after an explicit selection. Use one pinned Three.js release and matching addons, `GLTFLoader`, a small environment, standard materials and bounded `OrbitControls`. Render while the object changes or the user interacts; stop hidden work and release resources on teardown. The main map keeps its existing controls. [GLTFLoader](https://threejs.org/docs/pages/GLTFLoader.html), [OrbitControls](https://threejs.org/docs/pages/OrbitControls.html), [BufferGeometry disposal](https://threejs.org/docs/pages/BufferGeometry.html#dispose), [Material disposal](https://threejs.org/docs/pages/Material.html#dispose).

3. **Only if the richer material must live on the geographic map: Three custom layer.** Use MapLibre `CustomLayerInterface`, `renderingMode: '3d'`, the map's canvas/context, georeferenced metre-to-Mercator transforms, and the map-provided projection. Set `autoClear = false` and reset Three's GL state before rendering. MapLibre's official Three.js example demonstrates this arrangement. Check the pinned MapLibre 5.24 render signature rather than copying a newer API blindly. [MapLibre Three.js example](https://maplibre.org/maplibre-gl-js/docs/examples/add-a-3d-model-using-threejs/), [WebGLRenderer shared-state API](https://threejs.org/docs/pages/WebGLRenderer.html#resetState).

A custom layer must handle context loss/restoration and removal, share depth correctly and preserve the disc/mask behavior. Its projected meshes do not automatically receive Three shadows from MapLibre buildings; coherent cross-engine shadows need additional work. Schedule repaint only for visible changes instead of copying the example's perpetual `triggerRepaint()`. [CustomLayerInterface](https://maplibre.org/maplibre-gl-js/docs/API/interfaces/CustomLayerInterface/).

Use `WebGLRenderer` for a shared-context prototype. Three's `WebGPURenderer` can choose WebGPU or fall back to WebGL2, but this does not establish compatibility with the current shared MapLibre/deck context. Treat a WebGPU map integration as a separate investigation. [WebGPURenderer](https://threejs.org/docs/pages/WebGPURenderer.html).

## Brand treatment and engineering targets

Give ordinary city buildings quiet slate volumes; sandstone defines landmark identity, mint defines selection/fresh signal, cyan supplies water reference and lilac supplies sky reference. Use matte, slightly varied stone with controlled highlights. Keep data severity colours and official Metro line colours intact. Keep the Ring & River mark, values and type crisp in the HTML layer; do not turn the wordmark into an extruded scene object. Decorative lighting should never make a stale reading appear current.

Starting targets for a prototype, **not measured guarantees**:

| Area | Target / policy |
|---|---|
| Initial map load | Zero eager Three.js/detail-scene payload; load on deliberate detail intent |
| First detail asset | Below 300 KB transferred, no 4K textures; account for renderer/addon/environment bytes separately |
| Repeated near-view vehicle | About 300–1,500 triangles and 1–2 material groups; retain far-view dots |
| Hero landmark | About 3k–12k triangles and at most 4 material groups; current India Gate is a useful smaller baseline |
| Pixel cost | Preserve current map cap of 1.5; cap an independent detail canvas similarly and lower it adaptively |
| Motion | Aim for stable 30 fps on target phones; no idle auto-orbit, decorative pulses or reduced-motion animation |
| Effects | Start without full-screen bloom, SSAO, transmission or dynamic shadow passes; add only after profiling |

Stable data references and narrow update triggers remain important: deck.gl uses shallow data comparisons and unnecessary array replacements can rebuild GPU attributes. Preserve current feed-version caching and zoom gates while comparing variants. [deck.gl performance guide](https://deck.gl/docs/developer-guide/performance).

## Next experiment and acceptance

After the brand/UI review, create a separate visual experiment with the same refined India Gate in (a) current mesh rendering, (b) deck Scenegraph and (c) one lazy Three detail scene. Use identical camera framing and artwork so the comparison isolates integration value. Review at 390px and 1440px with the v2 palette and source-aware HTML.

Measure cold transferred bytes, first useful map, first detail render, frame-time distribution during drag/zoom, GPU/memory where available, and five minutes of sustained use on physical mid-range Android and iOS Safari. Exercise busy transport views, hidden/resumed tabs, context recovery, keyboard focus and reduced motion. Record actual device/browser, layer counts and settings. Keep the simpler integration unless Three.js provides an obvious material/interaction gain within the measured budget.

The existing tech audit holds the wider data/source review. This assessment adds a rendering decision and a contained experiment; it is not authorization to rewrite map mechanics or the feed service.
