A browser-based 3D viewer for the Yali Middle School campus, built on photogrammetry reconstruction tiles. Fly through the grounds freely with keyboard + mouse — no plugins required.
- Browser-native 3D — Renders 18 photogrammetry OBJ tiles with textured phong materials in a WebGL2 canvas via React Three Fiber.
- Hybrid controls — Orbit / pan / zoom with the mouse, plus WASD + Q/E keyboard fly controls for first-person-style exploration. Shift doubles the fly speed.
- Atmosphere switch — Toggle between
dayanddusklighting moods with matching sky gradient and fog. - Resilient loader — Serial download→parse queue with capped exponential-backoff retries and a recovery pass so a single aborted fetch never cascades.
- Performance-aware — Frustum culling per tile, DPR clamped to 2, live FPS meter, target 60 fps desktop / 30 fps mobile.
- "Survey archive" aesthetic — Deep charcoal backdrop with a single amber accent, glassmorphic overlays, serif + monospaced typography.
| Layer | Choice |
|---|---|
| Framework | React 18 + TypeScript + Vite 6 |
| 3D | three 0.169 · @react-three/fiber · @react-three/drei · @react-three/postprocessing |
| State | Zustand |
| Styling | Tailwind CSS 3 |
| Icons | lucide-react |
| Backend | None — pure static assets |
Models/OBJ/Data/ # 18 photogrammetry tiles (*.obj + *.mtl + *.jpg)
public/Models # directory junction -> ../Models (served by Vite publicDir)
src/
├── components/
│ ├── scene/ # CampusScene, TileModels, CameraRig, Lighting, SkyDome, FpsMeter
│ └── ui/ # TopBar, ControlHints, LoadingScreen
├── pages/Home.tsx # single page: 3D viewport + UI overlays + loading screen
├── store/sceneStore.ts # Zustand store (load progress / atmosphere / fps)
├── three/
│ └── tiles.ts # MTL+OBJ loader, serial queue with retries
└── App.tsx
- Node.js 18+
- The
public/Modelsjunction must point at the project-rootModels/folder. Create it once:
New-Item -ItemType Junction -Path public\Models -Target Modelsnpm install
npm run dev # start Vite dev server
npm run build # type-check + production build
npm run preview # preview the production build
npm run check # tsc --noEmit type-check only| Input | Action |
|---|---|
| Left mouse drag | Orbit |
| Right mouse drag | Pan |
| Mouse wheel | Zoom |
W / S |
Fly forward / back (along the camera's horizontal forward axis) |
A / D |
Strafe left / right |
Q / E |
Move down / up |
Shift (held) |
Double fly speed |
Keyboard input is captured on the canvas element. WASD motion is projected onto the horizontal plane so flight stays level regardless of pitch; the OrbitControls target follows the camera so orbit and free-flight stay coherent.
The scene is driven by a photogrammetric reconstruction of the Yali Middle School campus, exported as a tiled OBJ dataset.
- Origin — Photogrammetry reconstruction produced by DaSpatial (the MTL header reads
Wavefront MTL generated by www.daspatial.com for 3D Users). The original capture is a drone aerial survey of the campus. - Container — Wavefront OBJ geometry + companion MTL material file per tile, plus one or more JPEG textures referenced by
map_Kd. - Layout — 18 tiles arranged in a 4×5 (with gaps) ENU grid under
Models/OBJ/Data/<Tile>/, namedTile_+XXX_+YYYafter their grid offsets.
Tile_+000_+001 Tile_+000_+002 Tile_+000_+003
Tile_+001_+000 Tile_+001_+001 Tile_+001_+002 Tile_+001_+003 Tile_+001_+004
Tile_+002_+000 Tile_+002_+001 Tile_+002_+002 Tile_+002_+003 Tile_+002_+004
Tile_+003_+000 Tile_+003_+001 Tile_+003_+002 Tile_+003_+003 Tile_+003_+004
- Per-tile contents —
<Tile>.obj(geometry),<Tile>.mtl(material), and 1–3 JPEG textures named<Tile>_texture_N.jpg. Most tiles use a single texture; a few denser ones split albedo into 2–3 atlases.
Models/OBJ/metadata.xml records the geodetic anchor:
<ModelMetadata version="1">
<SRS>ENU:28.1721900000,112.9845800000</SRS>
<SRSOrigin>-444.000000,-510.000000,28.000000</SRSOrigin>
</ModelMetadata>- SRS — Local East-North-Up (ENU) tangent frame, anchored at 28.17219°N, 112.98458°E (Changsha, Hunan — the Yali Middle School campus).
- SRSOrigin —
(-444, -510, 28)meters; the model's local origin is offset so vertex coordinates stay in a manageable numeric range. - Vertex axes — Raw OBJ coordinates are ENU meters with Z-up: X ≈ 200–600 (east), Y ≈ 295–800 (north), Z ≈ 24–52 (up, i.e. elevation above the anchor).
The viewer applies a fixed transform at load time so the campus sits at the three.js origin with ground at y = 0:
- Rotate the assembled tile group by
-π/2around X so the Z-up ENU height axis maps to three.js's Y-up. - Compute the world-space bounding box of the rotated group.
- Translate by
(-centerX, -minY, -centerZ)so the campus is horizontally centered and the lowest ground point drops toy = 0.
OrbitControls targets the origin; the camera starts at [420, 320, 460] for an overview framing of the whole site.
Each tile's MTL declares a Lambertian-style material with white ambient/diffuse, zero specular, and a single diffuse map_Kd JPEG. At runtime the loader forces side = FrontSide (photogrammetry shells are single-sided) and leaves the rest to three's built-in phong shading plus the scene's hemisphere + directional lighting.
- Serial queue (concurrency = 1) —
OBJLoaderparses each large OBJ synchronously on the main thread. With concurrency > 1, a parse blocks the main thread and starves/aborts in-flight sibling fetches (net::ERR_ABORTED). Serial download→parse guarantees no fetch is in flight during a parse. - Retry with backoff — Each tile download is wrapped with capped exponential backoff + jitter (5 retries, 500ms base).
fetchusescache: 'no-store'to bypass dev-server304revalidation paths that interact badly with three'sFileLoaderunder load. - Recovery pass — Tiles that still fail after the first pass get a second quiet pass once connections are idle; most aborts succeed here.
- Non-fatal partial loads — A failed tile is logged but does not block the rest from rendering.
- Palette — charcoal
#0E0F13background, amber#E8A33Dsingle accent, fog gray#A8A8AEsecondary text, slate#3A3D44panels. - Typography —
Cormorant Garamondfor titles (serif, archival),JetBrains Monofor UI (monospaced, technical). - Layout — full-bleed 3D canvas with edge-anchored glassmorphic overlays; nothing invades the central viewport.
- Responsive — desktop-first (1280px+); tablet collapses the side panel into a drawer; mobile moves panels to a bottom drawer and hides the FPS meter.
- Frustum culling — every tile mesh has
frustumCulled = true; three.js skips off-screen tiles automatically. - DPR clamp —
dprcapped atMath.min(devicePixelRatio, 2)to avoid mobile oversampling. - Keep-alive tuning — a custom
tameKeepAliveVite plugin widens the dev server's keep-alive window so the browser never races a connection reuse against a closing socket (a major source ofnet::ERR_ABORTEDon large OBJ/texture streams). - Targets — 60 fps on desktop, 30 fps on mobile; ~30–50k vertices total across all tiles.
- Browsers — any WebGL2-capable browser from the past 2 years (Chrome / Edge / Firefox / Safari).
Project-specific. Model data © its respective photogrammetry source.