# Portal VR Media Viewer Godot 4.6 Forward Plus / Mobile renderer project. Flat file structure -- all files in project root. See [README.md](README.md) for human-facing overview. ## Architecture ``` boot.tscn (Node2D, game_loader.gd) | +-- portal.tscn (Node3D, portal.gd) <-- main scene | +-- XROrigin3D (XR tracking root) | +-- Perspective (XRCamera3D) <-- viewer camera | +-- AudioStreamPlayer (music) | +-- MeshInstance3D (outer tunnel, r=2.0) | +-- MeshInstance3D2 (mid tunnel, r=1.5) | +-- MeshInstance3D3 (inner tunnel, r=1.0) | +-- Blurrycap (sphere cap shader) | +-- prompt_1 / prompt_2 timers | +-- EmissionPoint (emission_point.tscn) | +-- SubViewport (shared render target) +-- VideoStreamPlayer (shared video) +-- tile.tscn instances (presentation layout) ``` **Boot -> Portal -> EmissionPoint -> Tile shell** is the entry flow. EmissionPoint creates a configurable tile layout (hemisphere, wraparound, 9-patch, full sphere, ring of rings, spiral staircase) of tile instances, each showing the shared video via a SubViewport texture. ## Key Files | File | Role | |---|---| | [project.godot](project.godot) | Project config: Forward Plus renderer, Jolt Physics, OpenXR, mobile renderer, feature flags | | [game_loader.gd](game_loader.gd) | Single script: loads `portal.tscn` at startup | | [portal.gd](portal.gd) | Main scene controller: tunnel shader params, XR init, gamepad/mouse look, auto-centering, prompt timers, tunnel color/spiral export vars | | [emission_point.gd](emission_point.gd) | Core innovation: builds tile layouts from sphere face directions, shared video pool, pulse animation, format switching. Key exports: `slot_limit`, `base_radius`, `pulse_amplitude`, `pulse_speed_hz`, `hemisphere_coverage_degrees`, `sphere_rings`, `sphere_segments`, `deterministic_seed`, `presentation_format` (enum), per-format params (`wraparound_radius`, `nine_patch_radius`, `full_sphere_radius`, `ring_of_rings_*`, `spiral_staircase_*`, `transition_duration_sec`) | | [tile.gd](tile.gd) | Individual video tile: `place_on_shell()`, `set_global_position_unclamped()`, billboard to camera, pulse scale animation. `class_name Tile` | | [cloud_1.gdshader](cloud_1.gdshader) | Tunnel shader: cloud + spiral helix on CylinderMesh. Parameters: `movement_Factor`, `rotation_speed`, `scroll_speed`, `vertex_displacement`, `inner_fade`, `outer_fade`, `color`, `spiral_color`, `spiral_turns`, `spiral_swirl`, `spiral_width`, `spiral_strands`. Inputs: `noise_tex`, `cloud_tex` (NoiseTexture2D) | | [cap.gdshader](cap.gdshader) | Blurrycap shader: screen-space distortion with Fresnel swirl + fog. Parameters: `distortion_strength`, `swirl_strength`, `noise_amount`, `fog_density`, `tint_color`. Uses `SCREEN_UV` + `hint_screen_texture` for world-behind-sphere effect | | [portal.tscn](portal.tscn) | Main scene: 3 CylinderMesh (radii 2.0/1.5/1.0, each 10 tall), 3 tunnel materials, Blurrycap sphere, EmissionPoint child (base_radius=9.0, seed=0), prompt planes, timers (45s/70s), audio player | | [tile.tscn](tile.tscn) | Tile scene: PlaneMesh (1.6x0.9) with StandardMaterial3D, AnimationPlayer with "flying" loop (3.2s) | | [emission_point.tscn](emission_point.tscn) | EmissionPoint node + Timer (1.67s) | | [boot.tscn](boot.tscn) | Entry scene: Node2D with game_loader.gd | | [export_presets.cfg](export_presets.cfg) | 48 presets: 24 Windows + 24 Linux + Android variants. Each has unique `custom_features` + `include_filter` | | [openxr_action_map.tres](openxr_action_map.tres) | OpenXR actions for left/right hands (trigger, grip, trigger_click, trigger_touch, etc.) | | [animations.res](animations.res) | Animation library for prompt overlays | | [convert.sh](convert.sh) | ffmpeg utility: `ffmpeg -i "$1" -vf "scale=-1:420" -q:v 2 -q:a 1 -g:v 50 "$1".ogv` | ## Shader Reference ### cloud_1.gdshader (spatial, unshaded, cull_disabled) **Inputs**: `noise_tex` (NoiseTexture2D), `cloud_tex` (NoiseTexture2D) Both must be NoiseTexture2D with FastNoiseLite -- set in scene file. | Parameter | Purpose | |---|---| | `movement_Factor` | How fast the cloud pattern drifts | | `rotation_speed` | Rotation of the tunnel texture | | `scroll_speed` | UV scrolling speed | | `vertex_displacement` | Vertex displacement intensity | | `inner_fade` | Fade at tunnel inner radius | | `outer_fade` | Fade at tunnel outer radius | | `color` | Base tunnel color | | `spiral_color` | Spiral overlay color | | `spiral_turns` | Number of spiral rotations | | `spiral_swirl` | Tightness of spiral curl | | `spiral_width` | Width of spiral arms | | `spiral_strands` | Number of spiral arms (1 or 2 for double helix) | ### cap.gdshader (spatial, unshaded, cull_disabled) **Uses**: `SCREEN_UV` + `hint_screen_texture` for world-behind-the-sphere effect. | Parameter | Purpose | |---|---| | `distortion_strength` | How much the screen is warped | | `swirl_strength` | Swirl distortion intensity | | `noise_amount` | Procedural noise added to distortion | | `fog_density` | Fog depth for the blurred look | | `tint_color` | Color tint of the sphere | ## Code Patterns ### Tunnel parameter propagation `portal.gd` has `@export` Color/float properties for each of 3 tunnels. Setters call `_set_mesh_shader_parameter()` on `CylinderMesh.material -> ShaderMaterial`. Editor sync via `_process()` + `_enter_tree()`. ### Shared video pool EmissionPoint creates ONE SubViewport + ONE VideoStreamPlayer. All tiles share the viewport texture -- no per-tile video players. ### Hemisphere layout `_build_hemisphere_slots()` samples sphere mesh face centers (via `sphere_rings` x `sphere_segments`), filters by view direction dot product (`hemisphere_coverage_degrees`), downsamples to `slot_limit`. Uses `deterministic_seed` for reproducible layout. ### Tile pulse Sine wave in `_process()` on `mesh.scale`, synced via `phase_offset` per tile. ### Billboard Tiles `look_at(camera.global_position)` every frame. `inward_face_flip` rotates PI on Y to show the correct face. ### Auto-centering After 1.5s of no input, perspective rotates back to zero at 0.05 rad/s. ### Editor mode guard `Engine.is_editor_hint()` checks throughout `portal.gd` to prevent runtime errors in Godot editor. ### @tool on portal.gd Enables editor property sync for all tunnel shader parameters while editing. ## Presentation Formats EmissionPoint supports 6 presentation layouts, switchable at runtime via `presentation_format` enum (`@export`). Tiles are **never** created or destroyed on format switch — they are repositioned via a smooth two-phase transition (position lerp + scale fade) over `transition_duration_sec` seconds. ### Available Formats | Format | Layout | Animation | |---|---|---| | `HEMISPHERE` | Hemisphere of directions facing camera (existing) | Sinusoidal radius pulse | | `WRAPAROUND` | Horizontal circle at camera height around viewer | Circle rotates at `wraparound_rotation_speed_rad_s` | | `NINE_PATCH` | Fixed 3x3 grid in camera-local space | Per-tile scale wobble via `nine_patch_wobble_*` params | | `FULL_SPHERE` | Uniform sphere via Fibonacci golden-angle distribution | Static positions, only pulse (no rotation) | | `RING_OF_RINGS` | 3 concentric rings (N/4 inner, N/2 middle, N/4 outer) | Each ring rotates at independent speed → parallax | | `SPIRAL_STAIRCASE` | Rising helix at fixed radius, climbing over `spiral_staircase_turns` rotations | Slow spiral rotation + per-tile vertical drift; partial billboard | ### Per-Format Parameters **Wraparound**: `wraparound_radius`, `wraparound_rotation_speed_rad_s` **Nine-Patch**: `nine_patch_radius`, `nine_patch_wobble_speed_hz`, `nine_patch_wobble_amplitude`, `nine_patch_phase_spread` **Full Sphere**: `full_sphere_radius`, `full_sphere_use_fibonacci` **Ring of Rings**: `ring_of_rings_inner_radius`, `ring_of_rings_middle_radius`, `ring_of_rings_outer_radius`, `ring_of_rings_inner_speed`, `ring_of_rings_middle_speed`, `ring_of_rings_outer_speed`, `ring_of_rings_vertical_wobble` **Spiral Staircase**: `spiral_staircase_radius`, `spiral_staircase_height`, `spiral_staircase_turns`, `spiral_staircase_rotation_speed_rad_s`, `spiral_staircase_wobble_amplitude` **Common**: `transition_duration_sec` — shared transition duration for all format switches ### Transition System The transition uses `_handle_transition(time_sec)` in `emission_point.gd`: 1. **Phase 1 (0 → 0.5)**: Positions lerp from start → target via `smoothstep()` 2. **Phase 2 (0.5 → 1.0)**: Scale multiplier fades from 0 → 1 (tiles pop in) 3. On completion: `_current_format = _target_format`, `_format_transition_progress = 1.0` Extra slots in smaller layouts (e.g., 9-patch with 60 tile capacity) are placed at `Vector3.INF` and become visible during the fade-in. ### Key Math - **Wraparound**: `angle = (2π*i/N) + accumulator` on Y=0 plane - **Nine-Patch**: `center + cam_right*(col-1)*spacing + cam_up*(row-1)*spacing - cam_forward*radius` - **Full Sphere**: Fibonacci golden angle `γ = π(3-√5)`, `y = 1 - (2i+1)/N` - **Ring of Rings**: N/4 inner ring, N/2 middle ring, rest outer — each with independent rotation - **Spiral Staircase**: `angle = (i/N)*turns*τ + accumulator`, `height = (i/N - 0.5)*height` ### Setting via Export Preset Presentation format can be governed externally by setting the `presentation_format` export on the EmissionPoint node. In timeline/content editor workflows, this parameter is bindable via Godot's property binding system — a server or external controller can call `emission_point.set_presentation_format(EmissionPoint.PresentationFormat.NINE_PATCH)` at any time. ## Export Preset System Content is controlled entirely through `export_presets.cfg`, not separate scene files. Each preset: - **`custom_features`**: comma-separated flags (e.g., `smoke,poppers,stoner_dad_3`). Used at runtime for conditional logic. - **`include_filter`**: glob for video files to bundle (e.g., `*chubs*.ogv`, `stoner_dad_3.ogv`). - **`export_files`**: which scene files to include (always `boot.tscn`, `tile.tscn`, `emission_point.tscn`; main portal varies). - **`export_path`**: output binary path in `bin/`. ### Content variants | Variant | Video Pattern | |---|---| | stoner_dad_2 | `*stoner_dad_2*.ogv` | | stoner_dad_3 | `*stoner_dad_3*.ogv` | | stoner_plastic | `*stoner_plastic*.ogv` | | chubs_compilation | `*chubs*.ogv` | | 420_special | `*420_special*.ogv` | | 420_special_diet | `*420_special_diet*.ogv` | | orc_domination | `*orc*.ogv` | | furry_poppers_daddy | `*poppers*.ogv` | | furry_poppers_stable_stud | `*poppers*.ogv` | | toon_goon | `*toon*.ogv` | | furry_goon_encouragement | `*furry_goon*.ogv` | | furry_big_bad_wolf | `*wolf*.ogv` | ### Platform outputs - Windows: `bin/windows/portal_.exe` - Linux: `bin/linux/portal_.x86_64` - Android (Meta Quest): `bin/android/portal_.apk` ## Common Modifications **Change tunnel colors/spirals**: Edit `portal.tscn` -> Node3D node properties or `portal.gd` defaults. **Change tile count/coverage**: Edit `emission_point.tscn` -> EmissionPoint node -> `slot_limit`, `hemisphere_coverage_degrees`, `sphere_rings`, `sphere_segments`. **Add new content variant**: Add export preset in Godot (Export -> Presets), set `custom_features` + `include_filter` + `export_files` + `export_path`. **Add prompt overlay**: Add MeshInstance3D + Animation to `portal.tscn`, connect timer -> AnimationPlayer.play(). **Change video**: Place `.ogv` in project root (use [convert.sh](convert.sh)), set `video_stream` export on EmissionPoint, update `include_filter` in export preset. ## Important Notes - No subdirectories for source code. Everything is flat in project root. - Audio files are `.mp3` (not ogv). Music player uses `AudioStreamMP3`. - Video content uses `.ogv` format, not bundled in git (filtered by `include_filter`). - Clear color is transparent (`Color(0,0,0,0)`) -- not black. - `animations.res` contains animation libraries for prompt overlays. - Boot scene hardcodes main scene as `"portal.tscn"` string rather than UID -- a minor maintenance fragility. - Hand tracking enabled by default on Meta Quest (via custom Android manifest).