# 360 Tour — Unity 6 Plan & Build Guide
A node-based 360° photo/video navigation experience with time travel. Targets: Meta Quest (standalone), Windows .exe, WebGL. All scripts in `Assets/Scripts`, shader in `Assets/Shaders`. Everything lives in the `Tour360` namespace.
## Core design
Each vantage point is a **PhotoNode** GameObject placed in the Unity scene at the real capture position (X/Z from your site plan, Y = tripod/eye height). A node holds a list of **MediaVariants** — one per time **epoch** (e.g. "2021 parking lot", "2026 building"), each with a photo or video and a per-capture yaw calibration (`northOffsetDegrees`).
The panorama is rendered as the **skybox** using a custom shader (`Custom/SkyboxEquirectBlend`) that holds two equirect textures and a blend value — every transition (node-to-node or time change) is a crossfade between the two slots. Because the skybox is infinitely distant, "moving" is just teleporting the rig and swapping the panorama; hotspot directions stay correct as long as each panorama's yaw is calibrated.
**TourManager** is the single source of truth: current node, current epoch, rig movement, crossfade, video players, hotspot spawning. Everything else (map, time slider, hotspots) talks to it via `MoveToNode()`, `SetEpoch()`, and the `NodeChanged`/`EpochChanged` events.
**Time behavior:** when the epoch changes, if the current node has media for that epoch it crossfades in place; otherwise the rig jumps to the *nearest node* (scene-space distance) that does — this handles your "vantage points don't match across eras" case. Hotspots and map markers are filtered/dimmed for nodes with no media in the current epoch.
## Script inventory
| File | Role |
|---|---|
| `PhotoNode.cs` | Node data: neighbors, media variants per epoch, gizmos |
| `TourManager.cs` | Navigation, epoch logic, skybox crossfade, video, hotspot spawning |
| `Hotspot.cs` | Billboarded clickable marker toward a neighbor node |
| `DesktopCameraController.cs` | Mouse look + click raycast (exe/WebGL) |
| `MapController.cs` | Top-down clickable map (RenderTexture + RawImage), marker colors per epoch |
| `TimeControlUI.cs` | Slider + label bound to the epoch list |
| `XRI/HotspotXRIBridge.cs` | Makes hotspots selectable by VR controller rays (import only after installing XRI) |
| `Editor/PhotoNodeTools.cs` | Menu items to auto-link/clear neighbors by distance |
| `Shaders/SkyboxEquirectBlend.shader` | Two-slot equirect skybox with per-slot rotation and blend |
## Project setup (once)
1. **Unity 6 (6000.x)**, URP template ("Universal 3D").
2. Packages (Window > Package Manager): **XR Plugin Management**, **OpenXR Plugin**, **XR Interaction Toolkit** (3.x, import its Starter Assets sample), **TextMeshPro** (usually present).
3. Project Settings > XR Plug-in Management: enable **OpenXR** on the Android tab (Quest) and Standalone tab (PC VR via Link, optional). Add the Meta Quest interaction/touch controller profiles under OpenXR settings.
4. Project Settings > Player > **Active Input Handling: Both** (XRI uses the new Input System; `DesktopCameraController` uses the legacy API for simplicity).
5. Copy `Assets/Scripts` and `Assets/Shaders` in. Hold back `Scripts/XRI/HotspotXRIBridge.cs` until XRI is installed.
## Asset pipeline (Insta360 → Unity)
- Export from Insta360 Studio as **equirectangular** JPEG (photos) and H.264 MP4 (videos). 2:1 aspect (e.g. 5760×2880).
- Photo import settings: sRGB on, **Generate Mip Maps OFF** (prevents the vertical seam artifact where the equirect UV wraps), Wrap Mode: Repeat (U), Max Size 8192 (desktop/WebGL) — see per-platform overrides below.
- Compression: desktop = DXT/BC7 default; **Android tab override: ASTC 6x6, Max Size 5760 or 4096** (Quest GPU memory is limited); WebGL tab: Max Size 4096 recommended.
- Videos for Quest/desktop: import as VideoClip, disable transcode if the source is already H.264. Videos for **WebGL: do NOT use VideoClips** — put the .mp4 in `Assets/StreamingAssets/` and set the variant's Video Source to **Url** with just the file name (the code resolves StreamingAssets automatically), or host it on a server and paste the full URL. Keep WebGL video ≤4K; browser decoders vary.
- Memory note: a 5760×2880 uncompressed-in-VRAM texture is large; with more than ~20–30 nodes per scene, move to Addressables and load textures on demand (see Roadmap).
## Scene construction (step by step)
Scene hierarchy target:
```
TourScene
├── TourManager (TourManager component)
├── Nodes/ (empty parent)
│ ├── Node_Lobby (PhotoNode)
│ ├── Node_Entrance (PhotoNode)
│ └── ...
├── XR Origin (XR Rig) (from XRI Starter Assets — VR builds)
├── DesktopRig (empty; child: Camera + DesktopCameraController — flat builds)
├── VideoPlayers (2× VideoPlayer components: A and B)
├── Map/
│ ├── MapCamera (ortho, looks down, culls "Map" layer, outputs RenderTexture)
│ └── MapBackdrop (optional) (quad with site-plan image, layer "Map")
└── UI Canvas
├── MapImage (RawImage + MapController)
└── TimeSlider + Label (TimeControlUI)
```
1. **Skybox material.** Create a Material from `Custom/SkyboxEquirectBlend`; assign it in Lighting > Environment > Skybox Material AND to TourManager's `skyboxMaterial` slot.
2. **Site plan underlay.** Drop your site plan/floor plan on a big ground quad (temporarily) to place nodes against. Scale it so 1 Unity unit = 1 metre.
3. **Place nodes.** Create empty GameObjects with `PhotoNode`, position at each capture spot, Y = camera height at capture (~1.6 m tripod). Fill `variants`: one entry per epoch the spot was shot in, oldest first (last entry doubles as fallback).
4. **Link neighbors.** Run Tools > 360 Tour > Auto-Link Neighbors, then prune links that pass through walls. Gizmos show links as yellow lines.
5. **Epochs.** On TourManager, define the epoch list oldest-first (id + label), set `startNode` and `startEpochIndex`.
6. **Hotspot prefab.** Sphere or ring mesh + SphereCollider + `Hotspot` + a child TMP text for the destination name. Add `HotspotXRIBridge` for VR. Assign to TourManager.
7. **Video players.** Two VideoPlayer components (Play On Awake OFF); assign as `videoPlayerA/B` on TourManager. Only needed if you use video variants — two players let video↔video transitions crossfade.
8. **Map.** Create a "Map" layer. MapCamera: orthographic, rotation (90,0,0), culling mask = Map, target = a new RenderTexture (512×512). RawImage in the canvas shows that RT and carries `MapController` (assign map camera + a marker prefab: small quad rotated flat, **URP/Unlit** material, layer Map, no collider). Camera framing is automatic.
9. **Rig wiring.** VR build: TourManager `rig` = XR Origin, and set the XR Origin's Camera Y Offset / tracking origin to **Floor** with the node Y representing eye height — simplest is Tracking Origin Mode = **Device** with Camera Y Offset 0, so the head sits exactly at the node point. Flat build: `rig` = DesktopRig (camera at local 0,0,0). Keep both rigs in the scene and enable exactly one per build (a one-line bootstrap script or two scene variants — see Builds).
### Yaw calibration (`northOffsetDegrees`)
For each variant: enter Play mode at that node, look toward a landmark whose direction you know in the scene (e.g. a neighbor node's hotspot), and adjust the offset until the photo's view of that landmark sits behind its hotspot. Tip: Insta360 exports usually put the camera's power-button direction at the image center; if you noted your camera heading during capture you can precompute the offset. Do it once per capture; it's what makes hotspots appear "at" the real doorway or path.
## Time navigation UX
- The slider (or two buttons calling `TourManager.SetEpoch(i±1)`) switches epochs globally.
- Same spot, both eras → in-place crossfade: a powerful "then/now" effect.
- No coverage at the current spot → jump to the nearest covered node; the map shows dimmed markers for uncovered nodes so users understand coverage per era.
- Optional upgrade: a "time portal" hotspot variant that appears only where another epoch exists at (nearly) the same spot.
## Builds
**Quest (standalone).** Platform: Android. IL2CPP, ARM64 only. Texture compression ASTC. OpenXR enabled on Android tab with Meta Quest Support feature. Use the XR Origin rig; disable DesktopRig. XRI ray interactors hit hotspot colliders via `HotspotXRIBridge`; add a `TrackedDeviceGraphicRaycaster` to the canvas and make it **world-space** (attach near the left controller or floating at 1.4 m) so map + slider work with the ray.
**Windows .exe.** Same scene. Non-VR: enable DesktopRig, disable XR Origin, screen-space canvas. PC VR (optional): keep OpenXR enabled on Standalone tab and use the XR rig — same as Quest wiring.
**WebGL.** No VR in a stock Unity WebGL build (Unity doesn't ship WebXR; the community De-Panther WebXR Export package can add it later). Use DesktopRig + screen-space canvas. Videos must be URL-based (StreamingAssets or hosted). Build settings: compression Brotli, and watch total texture memory — prefer 4096-wide panoramas.
Practical approach for three targets: one scene, plus a tiny `Bootstrap` step — keep both rigs and both canvases in the scene, and enable the right pair per build with `#if UNITY_ANDROID` / `XRSettings.isDeviceActive` in a 10-line script, or just maintain two scene variants (VR / flat) that share all nodes via a prefab.
## Roadmap / scaling up
1. **MVP:** 3–5 nodes, one epoch, photos only, desktop build.
2. Add second epoch + time slider; calibrate offsets.
3. Add Quest build + XRI; then WebGL.
4. **Scale:** move panoramas to Addressables and load/unload per node (keeps Quest memory flat); store node data in JSON if you want to author outside Unity.
5. Polish: teleport fade-to-black comfort option, footstep "ground ring" hotspots, then/now split-slider shader (the blend shader already supports a screen-side wipe with a small modification), spatial audio per node.
## Known gotchas
- Vertical seam line in the panorama → mipmaps are on; turn them off (or accept it).
- Blend never changes → the material in Lighting settings is a different instance than the one on TourManager; assign the *same asset*.
- Hotspots misaligned with the imagery → `northOffsetDegrees` not calibrated for that variant.
- WebGL video silent/black → autoplay policy: require one user click before first video plays (start on a photo node), and serve .mp4 with correct MIME type.
- Editing `skyboxMaterial` at runtime permanently changes the asset in the editor — that's normal; blend/textures get overwritten on next Play.