Skip to content

Fov #108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft

Fov #108

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions PR_FOV_CORE_FEATURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Add Field of View (FOV) as Core Feature

## Description

This PR implements Field of View (FOV) as a core feature in Hyperfy, allowing users to adjust their camera field of view through the settings interface. The FOV setting is now available in both the SettingsPane and MenuMain components, with proper server-side synchronization and camera integration.

**Key Changes:**
- Added FOV setting to Sidebar Prefs component with number input (30-120degrees)
- Added FOV setting to MenuMainGraphics component with range slider
- Implemented server-side FOV synchronization via `world.settings.set(fov, value, true)`
- Added FOV synchronization in PlayerLocal entity for proper camera control
- Fixed duplicate Ambient Occlusion setting by using server-side settings
- Temporarily hidden ContextMenu for PR focus on core FOV feature

## Type of Change

- [x] New feature (non-breaking change which adds functionality)
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] Code refactoring (no functional changes)

## Testing

- [x] FOV setting appears in SettingsPane graphics section
- [x] FOV setting appears in MenuMain graphics section
- [x] FOV changes are properly synchronized to server
- [x] Camera FOV updates immediately when setting is changed
- [x] FOV persists across sessions via server-side settings
- [x] FOV range is properly constrained (30-120ees)
- [x] Default FOV value of 70 degrees is applied correctly

## Implementation Details

### Server-Side Settings Integration
- FOV is stored in `world.settings.fov` (server-side)
- Changes are broadcast to all clients with `world.settings.set('fov', fov, true)`
- Default value of 70es
- Proper serialization/deserialization

### Camera Integration
- Direct camera FOV updates: `world.camera.fov = value`
- Projection matrix updates: `world.camera.updateProjectionMatrix()`
- Synchronization between settings and camera
- Fallback to camera FOV if no setting exists

### UI Integration
- FOV slider in SettingsPane (30120grees)
- FOV range slider in MenuMainGraphics
- Number input in Sidebar Prefs for precise control
- Proper state management and change listeners

### Player Camera Synchronization
- Added FOV sync in PlayerLocal entity's `lateUpdate` method
- Ensures player camera control respects FOV settings
- Prevents camera control from overriding FOV changes

## Files Changed

### Core Implementation
- `src/core/entities/PlayerLocal.js` - Added FOV synchronization
- `src/core/systems/ClientControls.js` - FOV settings change handling
- `src/core/systems/Settings.js` - Server-side FOV property

### UI Components
- `src/client/components/SettingsPane.js` - Added FOV setting with range slider
- `src/client/components/MenuMain.js` - Added FOV setting with range slider
- `src/client/components/Sidebar.js` - Added FOV setting with number input
- `src/client/components/ContextMenu.js` - Temporarily hidden for PR focus

## Breaking Changes

None - this is a purely additive feature that doesn't break existing functionality.

## Additional Notes

- ContextMenu has been temporarily hidden to focus on the core FOV feature implementation
- Ambient Occlusion setting was also fixed to use server-side settings instead of client preferences
- FOV implementation follows the same pattern as other server-side settings like AO

## Screenshots

FOV setting now appears in:
- SettingsPane graphics section with range slider
- MenuMain graphics section with range slider
- Sidebar Prefs with number input for precise control

The FOV setting properly integrates with the existing settings system and provides immediate visual feedback when adjusted.
150 changes: 150 additions & 0 deletions example-fov-app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// FOV Demo App
// Demonstrates camera FOV manipulation through direct camera access

// Configuration for the app
app.configure([
{
key: 'initialFov',
type: 'number',
label: 'Initial FOV',
hint: 'Starting field of view in degrees (30-120)',
min: 30,
max: 120,
initial: 70
},
{
key: 'showUI',
type: 'switch',
label: 'Show UI',
hint: 'Whether to show the FOV control UI',
options: [
{ label: 'Show', value: 'show', hint: 'Display FOV controls' },
{ label: 'Hide', value: 'hide', hint: 'Hide FOV controls' }
],
initial: 'show'
}
]);

app.keepActive = true;

// Variables to track state
let control, ui, fovText, currentFov;

// Initialize the app
if (world.isClient) {
// Set initial FOV directly on camera
currentFov = props.initialFov || 70;
if (world.camera) {
world.camera.fov = currentFov;
world.camera.updateProjectionMatrix();
}

console.log('FOV Demo loaded! Current FOV:', currentFov);

// Set up keyboard controls for FOV adjustment
control = app.control();
control.keyF.onPress = () => {
currentFov = Math.min(120, currentFov + 10);
if (world.camera) {
world.camera.fov = currentFov;
world.camera.updateProjectionMatrix();
}
console.log('FOV increased to:', currentFov);
updateFovDisplay();
};

control.keyG.onPress = () => {
currentFov = Math.max(30, currentFov - 10);
if (world.camera) {
world.camera.fov = currentFov;
world.camera.updateProjectionMatrix();
}
console.log('FOV decreased to:', currentFov);
updateFovDisplay();
};

control.keyR.onPress = () => {
currentFov = 70;
if (world.camera) {
world.camera.fov = currentFov;
world.camera.updateProjectionMatrix();
}
console.log('FOV reset to:', currentFov);
updateFovDisplay();
};

// Create UI if enabled
if (props.showUI === 'show') {
createUI();
}

// Display instructions
app.chat('FOV Demo loaded! Press F to increase FOV, G to decrease, R to reset');
}

// Create UI for FOV display and controls
function createUI() {
// Create UI container
ui = app.create('ui', {
width: 200,
height: 120,
backgroundColor: 'rgba(0,15,30,0.9)',
borderRadius: 8,
padding: 10,
justifyContent: 'center',
gap: 8,
alignItems: 'center'
});
ui.billboard = 'y'; // Face camera on Y-axis
ui.position.set(0, 1, 0); // Position above app

// Create FOV display text
fovText = app.create('uitext', {
value: `FOV: ${currentFov}°`,
fontSize: 18,
color: '#ffffff',
textAlign: 'center'
});

// Create instructions text
const instructionsText = app.create('uitext', {
value: 'F: +10°\nG: -10°\nR: Reset',
fontSize: 14,
color: '#cccccc',
textAlign: 'center'
});

// Add text to UI container
ui.add(fovText);
ui.add(instructionsText);

// Add UI to app
app.add(ui);
}

// Update FOV display
function updateFovDisplay() {
if (fovText) {
fovText.value = `FOV: ${currentFov}°`;
}
}

// Update loop
app.on('update', () => {
// Keep the app active and update FOV display
if (fovText && world.camera) {
// Update display with current FOV from camera
const cameraFov = Math.round(world.camera.fov);
if (cameraFov !== currentFov) {
currentFov = cameraFov;
updateFovDisplay();
}
}
});

// Clean up when app is destroyed
app.on('destroy', () => {
if (control) {
control.release();
}
});
88 changes: 88 additions & 0 deletions src/client/components/ContextMenu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react'
import { css } from '@firebolt-dev/css'
import { Menu, MenuItemBtn, MenuItemNumber } from './Menu'

export function ContextMenu({ world, visible, position, onClose }) {
const [fov, setFov] = useState(world?.settings?.fov || 70)
const menuRef = useRef()

useEffect(() => {
if (visible && world?.settings) {
setFov(world.settings.fov)
}
}, [visible, world?.settings?.fov])

useEffect(() => {
if (!visible) return

const handleClickOutside = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
onClose()
}
}

const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose()
}
}

document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)

return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [visible, onClose])

if (!visible || !world) return null

const handleFovChange = (newFov) => {
setFov(newFov)
// Update settings which will update the camera
world.settings.set('fov', newFov, true)
// Also directly update camera for immediate feedback
if (world.camera) {
world.camera.fov = newFov
world.camera.updateProjectionMatrix()
}
}

const resetFov = () => {
handleFovChange(70)
}

return (
<div
ref={menuRef}
className="context-menu"
css={css`
position: fixed;
top: ${position.y}px;
left: ${position.x}px;
z-index: 1000;
pointer-events: auto;
border-radius: 1.375rem;
overflow: hidden;
`}
>
<Menu title="Camera Settings" blur={false}>
<MenuItemNumber
label="Field of View"
hint="Adjust the camera's field of view (30-120 degrees)"
min={30}
max={120}
step={1}
value={fov}
onChange={handleFovChange}
/>
<MenuItemBtn
label="Reset to Default"
hint="Reset FOV to default 70 degrees"
onClick={resetFov}
/>
</Menu>
</div>
)
}
28 changes: 28 additions & 0 deletions src/client/components/CoreUI.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ControlPriorities } from '../../core/extras/ControlPriorities'
// import { MenuApp } from './MenuApp'
import { ChevronDoubleUpIcon, HandIcon } from './Icons'
import { Sidebar } from './Sidebar'
import { ContextMenu } from './ContextMenu'

export function CoreUI({ world }) {
const ref = useRef()
Expand All @@ -31,6 +32,7 @@ export function CoreUI({ world }) {
const [disconnected, setDisconnected] = useState(false)
const [apps, setApps] = useState(false)
const [kicked, setKicked] = useState(null)
const [contextMenu, setContextMenu] = useState({ visible: false, position: { x: 0, y: 0 } })
useEffect(() => {
world.on('ready', setReady)
world.on('player', setPlayer)
Expand Down Expand Up @@ -70,6 +72,24 @@ export function CoreUI({ world }) {
// elem.addEventListener('touchmove', onEvent)
// elem.addEventListener('touchend', onEvent)
}, [])

useEffect(() => {
const handleContextMenu = (e) => {
// Only show context menu when not in pointer lock mode
if (!world.controls.pointer.locked) {
e.preventDefault()
setContextMenu({
visible: true,
position: { x: e.clientX, y: e.clientY }
})
}
}

document.addEventListener('contextmenu', handleContextMenu)
return () => {
document.removeEventListener('contextmenu', handleContextMenu)
}
}, [world])
useEffect(() => {
document.documentElement.style.fontSize = `${16 * world.prefs.ui}px`
function onChange(changes) {
Expand Down Expand Up @@ -109,6 +129,14 @@ export function CoreUI({ world }) {
{ready && isTouch && <TouchBtns world={world} />}
{ready && isTouch && <TouchStick world={world} />}
{confirm && <Confirm options={confirm} />}
{/* Temporarily hidden for PR - ContextMenu
<ContextMenu
world={world}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={() => setContextMenu({ visible: false, position:[object Object] x: 0, y:0})}
/>
*/}
<div id='core-ui-portal' />
</div>
)
Expand Down
Loading