# 360 Tour — Beginner Tutorial (Unity 6) This walks you from an empty Unity 6 project to a working desktop tour with clickable hotspots, a map, and a time slider. VR comes at the end — get the desktop version working first, since it's the same scene. **One concept before we start:** in Unity, scripts do nothing on their own. A script becomes active when you *attach it as a component* to a GameObject in the scene — either by selecting the GameObject and clicking **Add Component** in the Inspector, or by dragging the script file onto the GameObject. This tutorial tells you exactly which script goes on which object. The exceptions: `PhotoNodeTools.cs` (an Editor script — it just adds menu items, never attached to anything) and the shader (used by a Material, not attached). --- ## Part 1 — Project preparation 1. Open your Unity 6 project (you've done this). 2. In the **Project window** (bottom panel), make sure you have these folders under `Assets`: `Scripts`, `Shaders`, and create two more: right-click in the Project window > **Create > Folder** → name them `Panoramas` and `Prefabs`. 3. Confirm the scripts compiled: the Console (Window > General > Console) should show no red errors. (Set aside `Scripts/XRI/HotspotXRIBridge.cs` for now if you haven't installed XR Interaction Toolkit — it won't compile without it. Delete it or move it out of Assets; you'll re-add it in Part 9.) 4. **Edit > Project Settings > Player > Other Settings > Active Input Handling** → set to **Both**. Unity will restart the editor. (The camera script uses the classic input API; VR later uses the new one.) ## Part 2 — Import your panoramas 1. Export a few photos from Insta360 Studio as equirectangular JPEGs (they'll be twice as wide as tall, e.g. 5760×2880). 2. Drag them from Windows Explorer into the `Assets/Panoramas` folder in the Project window. 3. Click one imported photo. In the **Inspector** (right panel): - Expand **Advanced** → uncheck **Generate Mip Maps** (leaving it on causes a visible vertical seam line in the panorama). - **Wrap Mode**: Repeat. - **Max Size**: 8192. - Click **Apply**. 4. Repeat for each photo (or select them all at once and change the settings together). ## Part 3 — The skybox material The panorama isn't put on any object — it's drawn as the *skybox*, the background that surrounds everything infinitely far away. The custom shader holds two images and can crossfade between them. 1. In `Assets`, right-click > **Create > Material**. Name it `SkyMat_Tour`. 2. Select it. At the top of the Inspector, click the **Shader** dropdown → **Custom > SkyboxEquirectBlend**. 3. Drag one of your photos into the **Panorama A** slot (just so you can see something before pressing Play). 4. **Window > Rendering > Lighting** → **Environment** tab → set **Skybox Material** to `SkyMat_Tour`. Close the Lighting window. The Scene/Game view background should now show your panorama (stretched around you). ## Part 4 — The camera rig 1. In the **Hierarchy** (left panel), right-click > **Create Empty**. Name it `DesktopRig`. In the Inspector set its Position to (0, 0, 0). 2. Drag the existing **Main Camera** onto `DesktopRig` so it becomes a child. Select Main Camera and set its Position to (0, 0, 0) — it must sit exactly at the rig's origin, because the rig gets teleported to each node. 3. With Main Camera selected: **Add Component** → type `DesktopCameraController` → add it. Leave the default values. Press Play for a moment: you should be able to look around by dragging with the left mouse button. Stop Play. ## Part 5 — The TourManager 1. Hierarchy > right-click > **Create Empty** → name it `TourManager`. **Add Component** → `TourManager`. 2. Fill in its Inspector fields: - **Rig**: drag `DesktopRig` from the Hierarchy into this slot. - **Skybox Material**: drag `SkyMat_Tour` from the Project window. - **Epochs**: set Size to 1. Element 0: **Id** = `2026`, **Label** = `Today (2026)`. - Leave Start Node and Hotspot Prefab empty for now — we make those next. ## Part 6 — Photo nodes Each node = one place you stood with the camera. Positions matter: use real distances (1 Unity unit = 1 metre). If you have a site plan image, you can temporarily put it on a big flat quad at y=0 as a placement guide. 1. Hierarchy > Create Empty → name it `Nodes` (just a folder object). 2. Right-click `Nodes` > Create Empty → name it after the location, e.g. `Node_Lobby`. **Add Component** → `PhotoNode`. 3. Set its **Position**: X/Z where you stood, **Y = camera height when you shot it** (a monopod at head height ≈ 1.6). 4. In the PhotoNode component: - **Display Name**: `Lobby` (shown on hotspot labels). - **Variants**: Size 1. Element 0: **Epoch Id** = `2026` (must match Part 5 exactly), **Media Type** = Photo, drag the matching photo into the **Photo** slot. Leave North Offset at 0 for now. 5. Repeat steps 2–4 for two or three more nodes. You'll see cyan gizmo spheres in the Scene view at each node. 6. **Link neighbors** — which nodes you can hop between: - Automatic: menu bar > **Tools > 360 Tour > Auto-Link Neighbors** (links everything within 12 m; check the Console message). - Manual: select a node, set its **Neighbors** list Size, and drag other node GameObjects from the Hierarchy into the slots. Do this both directions if you want two-way travel. - Yellow gizmo lines show the links. Remove any that pass through walls. 7. Select `TourManager` and drag your first node into its **Start Node** slot. ## Part 7 — The hotspot prefab The floating marker you click to travel. We build it once, save it as a *prefab* (a reusable template), and TourManager spawns copies automatically. 1. Hierarchy > Create Empty → name it `Hotspot`, position (0, 0, 0). 2. **Add Component** → `Sphere Collider`. Set **Radius** to 0.3. (Add the collider *before* the script.) 3. **Add Component** → `Hotspot` (the script). 4. Add the visible part: right-click `Hotspot` in the Hierarchy > **3D Object > Sphere**. Name it `Visual`, set Scale to (0.25, 0.25, 0.25), and **remove its Sphere Collider** component (right-click the component header > Remove Component) — the parent's collider does the work. 5. Add the label: right-click `Hotspot` > **3D Object > Text - TextMeshPro**. (First time, a window pops up — click **Import TMP Essentials**.) Set its Position to (0, 0.35, 0), **Alignment** to Center, and lower **Font Size** until it looks sensible next to the small sphere (try 1–2; adjust to taste). 6. On the root `Hotspot`'s script component: drag the TMP text child into **Label**, and drag `Visual` into **Visual**. 7. Optional looks: create a bright Unlit material and put it on `Visual`. 8. Drag the root `Hotspot` from the Hierarchy into `Assets/Prefabs`. It turns blue — it's now a prefab. **Delete the Hotspot from the Hierarchy.** 9. Select `TourManager` → drag the prefab from `Assets/Prefabs` into **Hotspot Prefab**. ### First test Press **Play**. You should be standing at your start node inside its panorama, with hotspots floating toward each neighbor. Click one → crossfade to the next node. If the hotspots don't point at what you see in the photo (e.g. the hotspot to the lobby appears over a wall), that's expected — fix it in Part 8. ## Part 8 — Yaw calibration (making hotspots line up) Every 360 photo has an arbitrary "which way is forward", so each variant needs a one-time rotation correction. 1. Press Play and travel to a node. 2. In the Hierarchy, select that node and find the variant's **North Offset Degrees** slider in the Inspector. 3. Drag the slider until the imagery lines up with the hotspots — e.g. the hotspot pointing at the hallway should sit *on* the hallway in the photo. 4. **Write the number down.** Changes made during Play mode are discarded when you stop! Exit Play, re-enter the value, and it's saved. 5. Repeat per node (and later, per time-period variant — old and new shots need their own offsets). ## Part 9 — The map 1. Create the layer: top-right of the Inspector in the editor, **Layers** dropdown > **Add Layer...** > expand Layers > type `Map` into an empty User Layer row. 2. Render texture: Project window > Create > Rendering > **Render Texture**, name `MapRT`, set Size to 512×512. 3. Map camera: Hierarchy > right-click > **Camera**, name `MapCamera`. - Position (0, 50, 0), Rotation (90, 0, 0), **Projection: Orthographic** (don't worry about size — the script auto-frames it). - **Environment > Background Type**: Solid Color, pick a dark color. - **Rendering > Culling Mask**: uncheck Everything, check only **Map**. - **Output > Output Texture**: `MapRT`. - **Remove the Audio Listener component** (a scene may only have one; Main Camera has it). 4. Marker prefab: Hierarchy > **3D Object > Quad**, name `MapMarker`. Rotation (90, 0, 0), Scale (1.5, 1.5, 1). Set its **Layer** (top of Inspector) to `Map`. Remove its Mesh Collider. Create a material with the **Universal Render Pipeline/Unlit** shader, assign it. Drag `MapMarker` into `Assets/Prefabs`, then delete it from the Hierarchy. 5. Optional backdrop: a large Quad at y=0, rotation (90,0,0), on the `Map` layer, with your site-plan image on an Unlit material — so the map shows the floor plan behind the dots. 6. The UI: Hierarchy > right-click > **UI > Raw Image**. Unity creates a Canvas and an EventSystem automatically (if a dialog asks to replace the input module, accept). Rename the Raw Image `MapImage`. - In its Rect Transform, click the anchor square and pick **bottom-right** (hold Alt+Shift to also position it there). Set Width/Height to 256. - **Texture**: `MapRT`. - **Add Component** → `MapController`. Assign **Map Camera** = `MapCamera`, **Marker Prefab** = your `MapMarker` prefab. 7. Also uncheck the `Map` layer in **Main Camera's** Culling Mask, so markers never show in the main view. Press Play: the map shows a dot per node (gold = where you are). Click a dot to travel there. ## Part 10 — The time slider 1. Hierarchy > **UI > Slider**. Move it to the top of the screen (anchor top-center), make it wide (~400). 2. Hierarchy > **UI > Text - TextMeshPro**, place it above the slider — this shows the epoch label. 3. Select the Slider → **Add Component** → `TimeControlUI`. Drag the Slider itself into **Slider** and the text into **Label**. 4. Add the past: select `TourManager`, set **Epochs** Size to 2 and *reorder so the oldest is Element 0*, e.g. Element 0: id `2021`, label `Parking lot (2021)`; Element 1: id `2026`, label `Today (2026)`. Set **Start Epoch Index** to 1 (so you start in the present). 5. On each node that has an old photo, add a second variant with **Epoch Id** = `2021` and the old photo. Nodes without a `2021` variant simply won't be visitable in 2021 — their map dots dim and their hotspots hide. Press Play, drag the slider: same spot in both eras crossfades in place ("then/now"); if the current spot has no old photo, you jump to the nearest one that does. ## Part 11 — Save and build (.exe) 1. **File > Save** (save the scene, e.g. as `TourScene`). 2. **File > Build Profiles** > Windows > add your open scene to the Scene List > **Build**. Pick an empty folder. That's your shareable .exe. ## Part 12 — Where to go next - **VR (Quest):** install *XR Plugin Management*, *OpenXR*, and *XR Interaction Toolkit* via Package Manager; import XRI's Starter Assets sample; add an **XR Origin (XR Rig)** to the scene; re-add `HotspotXRIBridge.cs` and put it on the Hotspot prefab; on TourManager switch **Rig** to the XR Origin and disable `DesktopRig`; change the Canvas to World Space with a `TrackedDeviceGraphicRaycaster`. Build details are in README.md under "Builds". - **Video nodes:** add two GameObjects with **Video Player** components (Play On Awake off), assign them to TourManager's Video Player A/B slots, and set a variant's Media Type to Video. - **WebGL:** switch platform in Build Profiles; videos must use the Url mode (file in `Assets/StreamingAssets/`). See README.md. ## Troubleshooting | Symptom | Fix | |---|---| | Skybox is grey/black in Play | Skybox Material not set in Lighting > Environment, or the material on TourManager is a different asset than the one in Lighting — they must be the *same* material. | | Vertical seam line in the sky | Re-check Generate Mip Maps is OFF on that photo, click Apply. | | Hotspots don't line up with the photo | Calibrate North Offset (Part 8). | | Clicks don't travel | The Hotspot prefab needs the collider on the same object as the `Hotspot` script; also check the click wasn't over a UI element. | | Calibration/values lost | You edited during Play mode — re-enter values after stopping. | | Map is blank | MapCamera Culling Mask must be Map only, Output Texture must be MapRT, markers must be on the Map layer. | | Two Audio Listeners warning | Remove the Audio Listener from MapCamera. | | Everything pink/magenta | Your project uses URP but a material uses a Built-in shader (or vice versa). Recreate the material with the matching Unlit shader. | --- # Edits ## Map Aspect and Follow Mode Replace your MapController.cs with this version, then make these editor changes: 1. **Fix the aspect ratio.** Your map is 2:1, so create a new RenderTexture at **1024×512** (or edit MapRT's size), and set the MapImage RawImage to a 2:1 size like **512×256**. RT and RawImage matching aspect = no stretching. The script reads the camera's aspect from the RT automatically when framing. 2. **Pick a framing mode** on the MapController component: - **FitBackdrop** (new default) — drag your MapQuad into the **Backdrop** slot; the camera frames the whole quad, nodes or no nodes. This is what you asked for: the full map shown regardless of node placement. - **FollowCurrent** — fixed zoom (**Follow Zoom** = half the visible height in metres; try 15), smoothly recenters on wherever you are. - **FitNodes** — the old behavior. 3. Your **MapQuad scale note**: (100, 50, 50) is right for a quad rotated (90,0,0) — X scale = 100 m wide, Y scale = 50 m deep, matching your image's 2:1. Since it's aligned so far, it'll stay aligned everywhere: the ortho camera renders backdrop and markers from the same world coordinates, so there's no separate map-to-node mapping to maintain. Just don't move or rescale the quad after placing nodes against it. Camera position and orthographic size are now fully script-driven, so you can ignore MapCamera's transform in the editor. If your ground level isn't y=0, keep the backdrop just below `markerY` so dots draw on top. --- ## Map Zoom Nice idea on the VR control — and it turns out XRI already does exactly that: its XR UI Input Module translates the controller thumbstick into standard UI _scroll events_ while your ray points at a world-space canvas. So if I implement zoom via Unity's `IScrollHandler` interface, mouse wheel (desktop) and thumbstick-while-pointing (VR) both work through the same code path. I'll add zoom-toward-pointer, drag-to-pan, and +/- keys: Created a file, read a file Created a file, read a file Replace MapController.cs — no editor rewiring needed, just three new Inspector groups with sensible defaults. What you get: scroll wheel over the map zooms toward the pointer (the point under your cursor stays put); dragging pans; a click that turns into a drag no longer counts as a node click; +/- keys zoom toward center and R resets to the framing default; zoom is clamped between **Min Zoom** (3 m half-height) and the full framed view, so you can't zoom out past the whole map or in past usefulness. In FollowCurrent mode, panning is disabled and zoom just changes the follow zoom level, since the camera re-centers itself anyway. For VR you guessed the right interaction, and it comes nearly free: when the canvas is World Space with a `TrackedDeviceGraphicRaycaster` and the EventSystem has XRI's **XR UI Input Module**, pointing the controller ray at the map and pushing the thumbstick generates the same `OnScroll` events as a mouse wheel — thumbstick-to-zoom-at-ray-point with zero extra code. Dragging with trigger held pans the same way. The only desktop-isms are the +/- and R keys, which simply won't fire on Quest. One tuning note: `zoomStep` 0.12 assumes one scroll "step" per wheel notch. WebGL browsers sometimes report much larger scroll deltas — if zoom feels violent in a WebGL build, lower it to ~0.03. ---