Issues while exporting image from a rendered scene

I wrote a function (a bit of vibe coding too) that exports the users current canvas as an image.

Reference Images

User’s View:


Please ignore the toolbar in the user’s view image that is not a part of canvas its just an overlay.

Exported Image:

Issue:

If you see users view the object is centered while inside the exported image the object is not in the center and is bit more towards the top-right corner side.

I’ve trying to fix this for quite sometime now,
I think it’s got something to do with how the viewport is being set but I’m not an expert.
Here’s the function to export the canvas:

  exportImageWithBg(bgType, resolution = '8K') {
    const resolutions = {
      '1K': { width: 1280, height: 720 },
      '2K': { width: 2048, height: 1080 },
      '4K': { width: 3840, height: 2160 },
      '8K': { width: 7680, height: 4320 },
    };

    const { width, height } = resolutions[resolution];
    const fileName = `export_${resolution}_${Date.now()}.png`;

    const renderer = this.renderer;
    const scene = this.scene;
    const originalCamera = this.camera;

    // Preserve original settings
    const originalBg = scene.background;
    const originalRenderTarget = renderer.getRenderTarget();
    const originalViewport = renderer.getViewport(new THREE.Vector4());

    // Set background
    switch (bgType) {
      case 'white':
        scene.background = new THREE.Color(0xffffff);
        break;
      case 'dark':
        scene.background = new THREE.Color(0x000000);
        break;
      case 'transparent':
        scene.background = null;
        renderer.setClearColor(0x000000, 0);
        break;
    }

    // Clone and configure camera for correct aspect ratio
    const exportCamera = originalCamera.clone();
    exportCamera.aspect = width / height;
    exportCamera.updateProjectionMatrix();
    exportCamera.position.copy(originalCamera.position);
    exportCamera.quaternion.copy(originalCamera.quaternion);
    exportCamera.updateMatrixWorld(true);

    // Render target
    const renderTarget = new THREE.WebGLRenderTarget(width, height, {
      format: THREE.RGBAFormat,
      type: THREE.UnsignedByteType,
      colorSpace: THREE.SRGBColorSpace,
    });

    // Render offscreen
    renderer.setRenderTarget(renderTarget);
    console.log(renderTarget);
    // renderer.setViewport(0, 0, width, height);
    // renderer.setViewport(0, 0, width * 2, height * 2);
    renderer.setViewport(0, 0, width, height);
    renderer.render(scene, exportCamera);

    // Extract pixels
    const buffer = new Uint8Array(width * height * 4);
    renderer.readRenderTargetPixels(renderTarget, 0, 0, width, height, buffer);

    // Copy to canvas
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext('2d');
    const imageData = ctx.createImageData(width, height);

    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        const srcIdx = (y * width + x) * 4;
        const dstIdx = ((height - y - 1) * width + x) * 4;
        imageData.data[dstIdx] = buffer[srcIdx];
        imageData.data[dstIdx + 1] = buffer[srcIdx + 1];
        imageData.data[dstIdx + 2] = buffer[srcIdx + 2];
        imageData.data[dstIdx + 3] = buffer[srcIdx + 3];
      }
    }

    ctx.putImageData(imageData, 0, 0);

    // Trigger download
    const link = document.createElement('a');
    link.href = canvas.toDataURL('image/png');
    link.download = fileName;
    link.click();

    // Cleanup and restore
    renderTarget.dispose();
    scene.background = originalBg;
    renderer.setRenderTarget(originalRenderTarget);
    renderer.setViewport(originalViewport);
  }

Try this code out, I’ve commented where I’ve made changes:

exportImageWithBg(bgType, resolution = '8K') {
    const resolutions = {
        '1K': { width: 1280, height: 720 },
        '2K': { width: 2048, height: 1080 },
        '4K': { width: 3840, height: 2160 },
        '8K': { width: 7680, height: 4320 },
    };

    const { width, height } = resolutions[resolution];
    const fileName = `export_${resolution}_${Date.now()}.png`;

    const renderer = this.renderer;
    const scene = this.scene;
    const originalCamera = this.camera;

    // Preserve original settings
    const originalBg = scene.background;
    const originalRenderTarget = renderer.getRenderTarget();
    const originalViewport = renderer.getViewport(new THREE.Vector4());

    // --- New Code Start ---
    // Store the original renderer size to calculate the aspect ratio for the current view.
    const originalSize = new THREE.Vector2();
    renderer.getSize(originalSize);
    // --- New Code End ---

    // Set background
    switch (bgType) {
        case 'white':
            scene.background = new THREE.Color(0xffffff);
            break;
        case 'dark':
            scene.background = new THREE.Color(0x000000);
            break;
        case 'transparent':
            scene.background = null;
            renderer.setClearColor(0x000000, 0);
            break;
    }

    // Clone and configure camera for correct aspect ratio
    const exportCamera = originalCamera.clone();
    exportCamera.aspect = width / height;
    exportCamera.updateProjectionMatrix();

    // --- New Code Start ---
    // Calculate the difference in aspect ratios to adjust the camera's view.
    const originalAspect = originalSize.width / originalSize.height;
    const newAspect = width / height;

    // Adjust the camera's FOV to maintain the vertical view and prevent the image from being cut off.
    // This ensures the object stays centered and in view when the new aspect ratio is wider.
    if (newAspect > originalAspect) {
        const fovRad = THREE.MathUtils.degToRad(originalCamera.fov);
        const newFovRad = 2 * Math.atan(Math.tan(fovRad / 2) * (originalAspect / newAspect));
        exportCamera.fov = THREE.MathUtils.radToDeg(newFovRad);
    }
    // --- New Code End ---

    exportCamera.position.copy(originalCamera.position);
    exportCamera.quaternion.copy(originalCamera.quaternion);

    // --- Changed Code Start ---
    // You were already calling this, but it's crucial to ensure it happens after all camera property changes.
    exportCamera.updateProjectionMatrix();
    exportCamera.updateMatrixWorld(true);
    // --- Changed Code End ---

    // Render target
    const renderTarget = new THREE.WebGLRenderTarget(width, height, {
        format: THREE.RGBAFormat,
        type: THREE.UnsignedByteType,
        colorSpace: THREE.SRGBColorSpace,
    });

    // Render offscreen
    renderer.setRenderTarget(renderTarget);
    // --- Changed Code Start ---
    // This is already correct, just uncommented for clarity.
    renderer.setViewport(0, 0, width, height);
    // --- Changed Code End ---
    renderer.render(scene, exportCamera);

    // Extract pixels
    const buffer = new Uint8Array(width * height * 4);
    renderer.readRenderTargetPixels(renderTarget, 0, 0, width, height, buffer);

    // Copy to canvas
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext('2d');
    const imageData = ctx.createImageData(width, height);

    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            const srcIdx = (y * width + x) * 4;
            const dstIdx = ((height - y - 1) * width + x) * 4;
            imageData.data[dstIdx] = buffer[srcIdx];
            imageData.data[dstIdx + 1] = buffer[srcIdx + 1];
            imageData.data[dstIdx + 2] = buffer[srcIdx + 2];
            imageData.data[dstIdx + 3] = buffer[srcIdx + 3];
        }
    }

    ctx.putImageData(imageData, 0, 0);

    // Trigger download
    const link = document.createElement('a');
    link.href = canvas.toDataURL('image/png');
    link.download = fileName;
    link.click();

    // Cleanup and restore
    renderTarget.dispose();
    scene.background = originalBg;
    renderer.setRenderTarget(originalRenderTarget);
    renderer.setViewport(originalViewport);
}