Camera distortion after several rotations (flight simulator)

Dear colleagues,

I am writing a flight simulator and have noticed that after doing a series of rolls (or in general after some time “flying”) the camera distorts the objects. As the cameral rolls (in the aircraft sense), it seems that the landscape gets compressed and then expanded, ending up in a loss of aspect ratio. I have tried several types of camera and tricks suggested by AI but the problem persists.

I hope you can help.

The GitHub repository for the project is:

GitHub - Openflight-sim/OPENFLIGHT: Educational Flight Simulator

and the cameras are defined here:

OPENFLIGHT/:airplane:_OPENFLIGHT/src/:yellow_circle:JAVASCRIPT​:yellow_circle:/6_SCENE_AND_RENDER_LOOP/6.4_​:cinema:_create_cameras.js at main · Openflight-sim/OPENFLIGHT · GitHub

I have attached a video showing the effect and the video showing the flight simulator at its best is in 2025_05_04 OPENFLIGHT Release Video

Best wishes and thanks in advance.

Raul

I can think of an issue when matrix gets constantly changed bu in your .js, it’s using ArcRotateCamera and view matrix should be re-created, limiting drigting. BTW, I’m not sure what I’m looking at when watching your video. Can you repro in a Playground?

Hi Cedric,

I cannot share easily the code in the playground as it is connected to a Julia server. The complete code is in github and you could run it but the issue, in short, is that when the aircraft is making fast rolls, the aspect ratio of the image seems to get distorted.

After stabilizing the aircraft, the image is significantly distorted, typically in a “vertical” direction with respect to the horizon.

If it really helps, I could try to write a demo to reproduce the problem.

Thank you very much.

Raul

Yes, please repro the problem on a PG: I have no idea how to start investigating :slight_smile:

Hello Cedric,

I’ve isolated the code from the server and added automatic motion to the aircraft and the distortion does not appear. You can check it in (this is a github pages link, just click on it):

OPENFLIGHT Sim 2025

Press “y” to go to “pilot’s” camera, this is where, when running with the server, the distortion is more noticeable.

So it seems that there is something going on with the update from the server of the position and orientation of the aircraft which may cause the distortion. The only other alternative I can think of is that the gamepad, or the way one moves the aircraft manually, may be the source.

Any idea?

Best regards and thanks in advance

Raul

Update is computed on server and send to clients? what is the data of the update? If using quaternion, did you normalize it?

Well, you’re a genius!

It seems the problem was the quaternion normalization. As this is a vibe coding project, I suggested this as a potential problem to both Claude and Gemini and both said something like “After reviewing your code, I found the issue with aspect ratio distortion during camera rotation. The problem is not with quaternion normalization, but rather with the FOV mode and up vector handling.

Issues Found:

1. Quaternion Normalization :white_check_mark:

Your quaternions ARE being normalized:

  • The server sends normalized quaternions (line 147-150 in 1.1_🔁_exchange_aircraft_state_with_server.js)

  • Babylon.js Quaternion.FromEulerAngles() creates normalized quaternions automatically

2. FOV Mode Issue :warning: (MAIN PROBLEM)

The aspect ratio distortion occurs because you’re setting FOVMODE_VERTICAL_FIXED but the FOV values are inconsistent. This mode expects the FOV to represent vertical field of view, but some cameras have very different values.

3. Up Vector Issue :warning:

You’re forcing upVector = (0,1,0) every frame, but this fights against the camera’s natural rotation, causing distortion during rolls.

Recommended Fixes:

File: 6.4_🎦_create_cameras.js

Replace the camera setup section with this corrected version:” but then gave me the code to normalize them anyway and this fixed the problem.

I’m supper happy now because the problem was extremely annoying and effectively making the program unusable.

Many thanks again and all the best.

Raul

PS, this is the code that normalizes the quaternions, produced by Claude4.5

/***************************************************************

  • 1.1_🔁_exchange_aircraft_state_with_server.js
  • REVISED WITH EXPLICIT QUATERNION NORMALIZATION
  • Manages the WebSocket connection with the Julia server using
  • binary MsgPack format for high performance. The code is wrapped
  • in a DOMContentLoaded listener to ensure libraries are loaded first.
  • CHANGES:
    • Added explicit quaternion normalization after receiving from server
    • Added validation to ensure quaternion magnitude is close to 1.0
    • Improved error handling for invalid quaternion data
      ***************************************************************/

// Wait for the DOM and all scripts to be loaded before executing.
window.addEventListener(‘DOMContentLoaded’, (event) => {

// Initialize WebSocket connection
// freeport is a variable that holds the port number of the server, defined in
// "src/🟡JAVASCRIPT🟡/0_INITIALIZATION/0.1_🧾_initializations.js" by the Julia code
// "src/🟣JULIA🟣/1_Maths_and_Auxiliary_Functions/1.0_📚_Check_packages_and_websockets_port/🔌_Find_free_port.jl"
let ws = new WebSocket(`ws://localhost:${freeport}`);

// Set the binary type to 'arraybuffer' to handle binary messages
ws.binaryType = "arraybuffer";

// Keep track of the last update time to compute a variable deltaTime for server calls.
let lastUpdateTime = performance.now();

// Connection opened handler
ws.onopen = () => {
    console.log('Connected to WebSocket server (using MsgPack)');
};

// Error handler
ws.onerror = (error) => {
    console.error('WebSocket Error:', error);
};

// Connection closed handler
ws.onclose = () => {
    console.log('Disconnected from WebSocket server');
    window.initialDataReceived = false; // Reset flag on disconnect
};

// --------------------------------------------------------------------------
// Helper function to normalize a quaternion
// --------------------------------------------------------------------------
function normalizeQuaternion(qx, qy, qz, qw) {
    // Calculate the magnitude (length) of the quaternion
    const magnitude = Math.sqrt(qx * qx + qy * qy + qz * qz + qw * qw);
    
    // Check if the magnitude is valid (not zero or too small)
    if (magnitude < 1e-8) {
        console.warn('Quaternion magnitude too small, returning identity quaternion');
        return { x: 0, y: 0, z: 0, w: 1 }; // Return identity quaternion
    }
    
    // Check if the magnitude is significantly different from 1.0 (should be normalized)
    if (Math.abs(magnitude - 1.0) > 0.01) {
        console.warn(`Quaternion not normalized: magnitude = ${magnitude.toFixed(6)}`);
    }
    
    // Normalize by dividing each component by the magnitude
    return {
        x: qx / magnitude,
        y: qy / magnitude,
        z: qz / magnitude,
        w: qw / magnitude
    };
}

// --------------------------------------------------------------------------
// Helper function to validate quaternion data
// --------------------------------------------------------------------------
function isValidQuaternion(qx, qy, qz, qw) {
    // Check if all components are valid numbers
    if (isNaN(qx) || isNaN(qy) || isNaN(qz) || isNaN(qw)) {
        return false;
    }
    
    // Check if all components are finite (not Infinity or -Infinity)
    if (!isFinite(qx) || !isFinite(qy) || !isFinite(qz) || !isFinite(qw)) {
        return false;
    }
    
    // Calculate magnitude
    const magnitude = Math.sqrt(qx * qx + qy * qy + qz * qz + qw * qw);
    
    // Check if magnitude is in reasonable range (close to 1.0 for a unit quaternion)
    // Allow some tolerance for floating-point precision
    if (magnitude < 0.9 || magnitude > 1.1) {
        console.warn(`Quaternion magnitude out of range: ${magnitude.toFixed(6)}`);
        return false;
    }
    
    return true;
}

// --------------------------------------------------------------------------
// Function to send aircraft state to server.
// --------------------------------------------------------------------------
function sendStateToServer() {
    const currentTime = performance.now();
    const deltaTime = (currentTime - lastUpdateTime) / 1000.0; // ms -> s
    lastUpdateTime = currentTime;

    if (ws.readyState !== WebSocket.OPEN) {
        console.error('WebSocket is not connected');
        return;
    }

    if (!aircraft || !orientation) {
        return;
    }

    // Create the state object to send to the server
    const aircraftState = {
        x: aircraft.position.x,
        y: aircraft.position.y,
        z: aircraft.position.z,
        vx: velocity.x,
        vy: velocity.y,
        vz: velocity.z,
        qx: orientation.x,
        qy: orientation.y,
        qz: orientation.z,
        qw: orientation.w,
        wx: angularVelocity.x,
        wy: angularVelocity.y,
        wz: angularVelocity.z,
        fx: forceX,
        fy: forceY,
        thrust_setting_demand: thrust_setting_demand,
        roll_demand: roll_demand,
        pitch_demand: pitch_demand,
        yaw_demand: yaw_demand,
        thrust_attained: thrust_attained,
        roll_demand_attained: roll_demand_attained,
        pitch_demand_attained: pitch_demand_attained,
        yaw_demand_attained: yaw_demand_attained,
        deltaTime: deltaTime
    };
    
    // Send state as a binary MsgPack object
    try {
        ws.send(msgpack.encode(aircraftState));
    } catch (error) {
        console.error('Error sending state to server:', error);
    }
}

// --------------------------------------------------------------------------
// Message handler for receiving server updates.
// --------------------------------------------------------------------------
ws.onmessage = (event) => {
    try {
        // Parse received binary data using MsgPack.decode
        const responseData = msgpack.decode(new Uint8Array(event.data));
        let dataIsValid = true;

        // --- Position Update ---
        if (aircraft && aircraft.position) {
            const newX = parseFloat(responseData.x);
            const newY = parseFloat(responseData.y);
            const newZ = parseFloat(responseData.z);
            
            if (!isNaN(newX)) aircraft.position.x = newX; else dataIsValid = false;
            if (!isNaN(newY)) aircraft.position.y = newY; else dataIsValid = false;
            if (!isNaN(newZ)) aircraft.position.z = newZ; else dataIsValid = false;
        } else {
            dataIsValid = false;
        }

        // --- Velocity Update ---
        const newVx = parseFloat(responseData.vx);
        const newVy = parseFloat(responseData.vy);
        const newVz = parseFloat(responseData.vz);
        
        if (!isNaN(newVx)) velocity.x = newVx; else dataIsValid = false;
        if (!isNaN(newVy)) velocity.y = newVy; else dataIsValid = false;
        if (!isNaN(newVz)) velocity.z = newVz; else dataIsValid = false;

        // --- Quaternion Update with Validation and Normalization ---
        const newQx = parseFloat(responseData.qx);
        const newQy = parseFloat(responseData.qy);
        const newQz = parseFloat(responseData.qz);
        const newQw = parseFloat(responseData.qw);
        
        // Validate quaternion components
        if (isValidQuaternion(newQx, newQy, newQz, newQw)) {
            // Normalize the quaternion
            const normalizedQuat = normalizeQuaternion(newQx, newQy, newQz, newQw);
            
            // Update the global orientation object
            orientation.x = normalizedQuat.x;
            orientation.y = normalizedQuat.y;
            orientation.z = normalizedQuat.z;
            orientation.w = normalizedQuat.w;
            
            // Optional: Log normalization info (comment out in production for performance)
            // const magnitude = Math.sqrt(newQx * newQx + newQy * newQy + newQz * newQz + newQw * newQw);
            // if (Math.abs(magnitude - 1.0) > 0.001) {
            //     console.log(`Quaternion normalized: ${magnitude.toFixed(6)} -> 1.0`);
            // }
        } else {
            console.error('Invalid quaternion received from server:', {newQx, newQy, newQz, newQw});
            dataIsValid = false;
        }

        // --- Angular Velocity Update ---
        const newWx = parseFloat(responseData.wx);
        const newWy = parseFloat(responseData.wy);
        const newWz = parseFloat(responseData.wz);
        
        if (!isNaN(newWx)) angularVelocity.x = newWx; else dataIsValid = false;
        if (!isNaN(newWy)) angularVelocity.y = newWy; else dataIsValid = false;
        if (!isNaN(newWz)) angularVelocity.z = newWz; else dataIsValid = false;

        // --- Global Forces Update ---
        const newFx = parseFloat(responseData.fx_global);
        const newFy = parseFloat(responseData.fy_global);
        const newFz = parseFloat(responseData.fz_global);
        
        if (!isNaN(newFx)) forceGlobalX = newFx; else dataIsValid = false;
        if (!isNaN(newFy)) forceGlobalY = newFy; else dataIsValid = false;
        if (!isNaN(newFz)) forceGlobalZ = newFz; else dataIsValid = false;

        // --- Apply Quaternion to Aircraft (with explicit normalization) ---
        if (aircraft && aircraft.rotationQuaternion && dataIsValid) {
            aircraft.rotationQuaternion.x = orientation.x;
            aircraft.rotationQuaternion.y = orientation.y;
            aircraft.rotationQuaternion.z = orientation.z;
            aircraft.rotationQuaternion.w = orientation.w;
            
            // **EXPLICIT NORMALIZATION** - Ensure Babylon's quaternion is normalized
            // This is a safety measure even though we normalized the orientation object
            aircraft.rotationQuaternion.normalize();
            
            // Optional: Verify normalization (comment out in production)
            // const qMag = Math.sqrt(
            //     aircraft.rotationQuaternion.x * aircraft.rotationQuaternion.x +
            //     aircraft.rotationQuaternion.y * aircraft.rotationQuaternion.y +
            //     aircraft.rotationQuaternion.z * aircraft.rotationQuaternion.z +
            //     aircraft.rotationQuaternion.w * aircraft.rotationQuaternion.w
            // );
            // if (Math.abs(qMag - 1.0) > 0.0001) {
            //     console.warn(`Aircraft quaternion magnitude after normalization: ${qMag.toFixed(6)}`);
            // }
        }

        // --- Aerodynamic Angles Update ---
        const newAlpha = parseFloat(responseData.alpha_RAD);
        const newBeta = parseFloat(responseData.beta_RAD);
        
        if (!isNaN(newAlpha)) alpha_RAD = newAlpha; else dataIsValid = false;
        if (!isNaN(newBeta)) beta_RAD = newBeta; else dataIsValid = false;

        // --- Control Surface Attained Update ---
        const newPitchAtt = parseFloat(responseData.pitch_demand_attained);
        const newRollAtt = parseFloat(responseData.roll_demand_attained);
        const newYawAtt = parseFloat(responseData.yaw_demand_attained);
        
        if (!isNaN(newPitchAtt)) pitch_demand_attained = newPitchAtt; else dataIsValid = false;
        if (!isNaN(newRollAtt)) roll_demand_attained = newRollAtt; else dataIsValid = false;
        if (!isNaN(newYawAtt)) yaw_demand_attained = newYawAtt; else dataIsValid = false;

        // --- Thrust Attained Update ---
        const newThrustAtt = parseFloat(responseData.thrust_attained);
        if (!isNaN(newThrustAtt)) thrust_attained = newThrustAtt; else dataIsValid = false;

        // --- Server Time Update ---
        if ("server_time" in responseData) {
            const serverTime = parseFloat(responseData.server_time);
            if (!isNaN(serverTime)) {
                window.serverElapsedTime = serverTime;
            } else {
                dataIsValid = false;
            }
        } else {
            window.serverElapsedTime = window.serverElapsedTime || 0;
        }

        // --- Set Initial Data Received Flag ---
        if (!window.initialDataReceived && dataIsValid) {
            window.initialDataReceived = true;
            console.log("Initial VALID data received from server. Enabling line updates.");
            console.log("Quaternion normalization active.");
        }

    } catch (e) {
        console.error("Error processing WebSocket message:", e, "Data:", event.data);
    }
};

// **Make the sendStateToServer function globally accessible**
// The main render loop in 6.1_...js needs to be able to call it.
window.sendStateToServer = sendStateToServer;

}); // Close the DOMContentLoaded listener

I’m glad it works! Yes, LLM can be counter productive sometimes :slight_smile:

1 Like