javascript

3D-кино с трекингом глаз: технический разбор моей реализации и открытые вопросы

  • суббота, 2 мая 2026 г. в 00:00:04
https://habr.com/ru/articles/1027980/

В моей домашней коллекции есть несколько фильмов в формате Top-Bottom стереопары — Marvel-овские, «Аватар», «Гравитация». Без 3D-телевизора или VR-очков смотреть их без потерь нельзя: на обычном мониторе видна сжатая по вертикали стереопара. Поляризованные очки и активные затворы на десктопе работают плохо или дорого. Анаглифные красно-синие очки убивают цвет.

Хотелось третьего варианта: смотреть на обычном мониторе, без очков, с минимальным железом, и при этом получать ощущение глубины. Идея, на которую опирался — head-coupled perspective: показывать изображение для одного глаза за раз, но переключать его в зависимости от того, куда смотрит зритель. Двигаешь голову влево — видишь левое изображение из стереопары. Двигаешь вправо — правое. Между этими положениями — плавный переход. Мозг получает не настоящую стереопару (двух кадров одновременно нет), а правильную картинку с правильной перспективой для текущего положения головы.

Эффект известен с 2008 года, когда Johnny Chung Lee выложил свой Wii Remote head-tracking demo — выступал тогда с этим на TED, видео разошлось в пределах разработческого сообщества по всему миру. С тех пор подход не раз пытались перенести на новые платформы: на iPhone X с TrueDepth-камерой, на Android, на VR-headset’ы. Свежее академическое исследование 2025 года из японского университета — статья «Real-time 3D Light-field Viewing with Eye-tracking on Conventional Displays» — описывает примерно эту же идею для light-field контента. И самое важное для контекста этой статьи: 1 октября 2025 года бывший инженер Meta Daniel Habib опубликовал свой проект True3D — head-tracked Window Mode на чистом веб-стеке. Это сейчас одна из самых обсуждаемых демок в индустрии 3D-видео, и ниже мне придётся подробно разобрать, что у Habib’а получилось и почему мой подход устроен принципиально иначе.

True3D: к чему пришли в Meta

Habib работал в Meta и ушёл, чтобы сделать True3D Labs. На сайте lab.true3d.com/targets лежит живая демка: открываешь в Chrome, разрешаешь камеру, и на экране в комнате «висит» 3D-сцена — серия концентрических мишеней на разных глубинах. Двигаешь головой — мишени двигаются ровно так, как двигались бы реальные предметы в окне. Без очков. Habib называет это Window Mode. В пресс-релизах и видео он показывает, среди прочего, диснеевский «Steamboat Willie» 1928 года, рендеренный в Window Mode: ранний Микки Маус будто заякорен в твоей комнате, и ты смотришь на него «через окно» монитора.

Технологически True3D работает так:

  1. Front-camera + MediaPipe FaceLandmarker для трекинга 6DoF позиции головы (включая iris-landmarks для поправки на положение зрачка)

  2. Метрическая оценка расстояния глаз от веб-камеры по apparent diameter глаз и FOV камеры

  3. Off-axis projection matrix — расчёт асимметричного frustum’а, который и создаёт эффект «окна»

  4. Рендер сцены из перспективы зрителя через spatial-player library

Ключевая фраза из их документации: «scene is treated as a literal window: the camera origin is your eye position, and the projection math ensures that parallax and occlusion behave exactly as they would if you were looking through a glass window». Это именно то, что у Lee на Wii в 2008-м, только теперь в браузере и без инфракрасных датчиков на голове.

Самое интересное для технической дискуссии — то, что они рендерят. Не готовое видео. У True3D под капотом volumetric video pipeline: контент представлен как voxels или 3D Gaussian splats. Гауссовый сплат — это точечный трёхмерный эллипсоид с цветом, прозрачностью и spherical-harmonic-коэффициентами для view-dependent цвета. Технология родом из работы Inria 2023 года «3D Gaussian Splatting for Real-Time Radiance Field Rendering», даёт реал-тайм рендер 3D-сцены с разных углов на потребительском железе. Steamboat Willie у Habib’а — это не плоское видео, прокинутое в Window Mode, это исходник, превращённый в volumetric scene через их pipeline (.splv файлы для динамики, .vv для статики). Когда зритель двигает голову, рендер делается из новой точки в 3D-сцене — а значит, off-axis projection даёт принципиально корректную картинку с любой позиции глаз.

И вот здесь у моей реализации огромный архитектурный компромисс.

Где у меня радикально иначе

Я не имею права превращать готовый коммерческий 3D-фильм в Gaussian splats. Во-первых, это юридически серая зона (производное произведение). Во-вторых, processing-pipeline для двухчасового фильма в splats — это часы (а то и сутки) GPU-времени на каждом фильме, и сжатие splv-формата сейчас далеко от MPEG-эффективности. Реалистично у меня есть только готовая Top-Bottom стереопара в MKV, и нужно работать с ней.

То есть у меня нет volumetric data. У меня есть два фиксированных 2D-вида (левый глаз и правый глаз), снятых из двух фиксированных позиций камер. Любое промежуточное положение головы — это интерполяция между этими двумя видами. И отсюда сразу следует: настоящий off-axis projection (как у Habib’а) у меня в принципе не работает — пересчитывать frustum не из чего. Нет 3D-сцены, есть две картинки.

Поэтому мой подход — гибридный: переключение видов (показать тот глаз, в чью сторону повёрнута голова) + 2D UV-shift двух готовых видов в шейдере для имитации параллакса. Это не математически корректное Window Mode, это трюк. Дальше — рассказ, как этот трюк сделан, что в нём работает, а где он трещит.


Архитектура моей реализации

Стек:

  • React + Vite — UI и сборка

  • Three.js + WebGL — рендер видео через ShaderMaterial

  • MediaPipe Face Landmarker (478 точек, включая iris) — трекинг лица

  • Custom fragment shader (GLSL) — извлечение видов из стереопары + view switching

Поток данных в одном кадре:

webcam (30 FPS)
  → MediaPipe.detectForVideo()
  → 478 landmarks
  → eye center + iris position
  → smoothing pipeline (4 stages)
  → predictive tracker (Holt's method)
  → uHeadPosition uniform
  → fragment shader
  → switch / blend между left/right view
  → canvas

Видео-источник — MKV в формате Top-Bottom: верхняя половина кадра — правый глаз, нижняя — левый. Распаковывается на лету через THREE.VideoTexture, без перекодирования. Аудио в AC3 предварительно конвертируется в AAC отдельным npm-скриптом — браузеры AC3 не поддерживают.


Слой 1: получение позиции головы

MediaPipe Face Landmarker даёт 478 трёхмерных landmark’ов. Для head-coupled perspective нужны:

  • Координаты глаз (центр) — для определения горизонтального и вертикального смещения головы

  • Расстояние между глазами — для оценки расстояния от камеры (Z-координата)

  • Iris-landmarks (468 для левого, 473 для правого) — для определения направления взгляда в дополнение к положению головы

Конфигурация модели:

import { FaceLandmarker, FilesetResolver } from '@mediapipe/tasks-vision';

const vision = await FilesetResolver.forVisionTasks(
  'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm'
);

const faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
  baseOptions: {
    modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/' +
                    'face_landmarker/face_landmarker/float16/1/face_landmarker.task',
    delegate: 'GPU'
  },
  outputFaceBlendshapes: false,
  outputFacialTransformationMatrixes: false,
  runningMode: 'VIDEO',
  numFaces: 1
});

delegate: 'GPU' критичен — на CPU модель работает в 5-7 раз медленнее. На интегрированной графике (Intel Iris Xe) нейросеть берёт ~3-4 ms на кадр. У True3D, кстати, тот же самый MediaPipe — это сейчас стандарт de-facto для лицевого трекинга в браузере. Вся разница — в том, что они делают с координатами после извлечения.

Извлечение позиции и взгляда:

const detect = async () => {
  const results = await faceLandmarker.detectForVideo(cameraVideo, Date.now());
  if (!results.faceLandmarks || !results.faceLandmarks.length) return;
  
  const landmarks = results.faceLandmarks[0];
  const leftEye  = landmarks[33];
  const rightEye = landmarks[263];

  const centerX = (leftEye.x + rightEye.x) / 2;
  const centerY = (leftEye.y + rightEye.y) / 2;
  const eyeDistance = Math.hypot(rightEye.x - leftEye.x, 
                                 rightEye.y - leftEye.y);

  // Iris-based gaze tracking
  const leftIris = landmarks[468];
  const leftEyeInner = landmarks[133];
  const leftEyeWidth = Math.abs(leftEye.x - leftEyeInner.x);
  const leftEyeCenterX = (leftEyeInner.x + leftEye.x) / 2;
  let leftGaze = (leftIris.x - leftEyeCenterX) / (leftEyeWidth / 2);
  
  // Non-linear amplification for weak signals
  leftGaze *= 3.0;
  leftGaze = Math.sign(leftGaze) * Math.pow(Math.abs(leftGaze), 0.7);
  leftGaze = Math.max(-2.0, Math.min(2.0, leftGaze));
  
  const normalizedX = (centerX - 0.5) * 2;
  const normalizedY = (centerY - 0.5) * 2;
  const normalizedZ = eyeDistance * 10;
  
  onPositionUpdate({ 
    x: normalizedX, y: normalizedY, z: normalizedZ, gazeX: leftGaze 
  });
};

Усиление gaze через Math.pow(x, 0.7) нужно потому, что iris-движения мелкие — несколько пикселей в пределах глаза. Без нелинейной функции мелкий поворот зрачка едва заметен в выходе.

В сыром виде эти числа использовать нельзя: они дёргаются на каждом кадре из-за шумов модели и микродвижений зрачков. Дальше — пайплайн сглаживания.


Слой 2: пайплайн сглаживания

Одно экспоненциальное сглаживание (стандартный EMA-фильтр) даёт либо лагающую, либо дёрганую картинку. Если коэффициент 0.7 — позиция видимо отстаёт от движения. Если 0.95 — почти не дёргается, но реакция на резкое движение падает.

Я собрал четырёхступенчатый пайплайн:

let smoothedPosition = { x: 0, y: 0, z: 1 };
let previousPosition = { x: 0, y: 0, z: 1 };
const velocityBuffer = [];

const smoothingFactor = 0.95;
const maxJumpThreshold = 0.05;
const maxVelocityThreshold = 0.15;
const velocityBufferSize = 5;

function smoothPosition(rawPosition) {
  // Stage 1: exponential moving average
  smoothedPosition.x = smoothedPosition.x * smoothingFactor 
                     + rawPosition.x * (1 - smoothingFactor);
  smoothedPosition.y = smoothedPosition.y * smoothingFactor 
                     + rawPosition.y * (1 - smoothingFactor);
  smoothedPosition.z = smoothedPosition.z * smoothingFactor 
                     + rawPosition.z * (1 - smoothingFactor);

  // Stage 2: track velocity in sliding window
  const dx = smoothedPosition.x - previousPosition.x;
  const dy = smoothedPosition.y - previousPosition.y;
  const dz = smoothedPosition.z - previousPosition.z;
  
  velocityBuffer.push(Math.hypot(dx, dy, dz));
  if (velocityBuffer.length > velocityBufferSize) velocityBuffer.shift();
  const avgVelocity = velocityBuffer.reduce((a, b) => a + b, 0) 
                    / velocityBuffer.length;

  // Stage 3: scale movement if velocity exceeds physical limits
  if (avgVelocity > maxVelocityThreshold) {
    const scale = maxVelocityThreshold / avgVelocity;
    smoothedPosition.x = previousPosition.x + dx * scale;
    smoothedPosition.y = previousPosition.y + dy * scale;
    smoothedPosition.z = previousPosition.z + dz * scale;
  }

  // Stage 4: hard cap on per-frame jumps
  if (Math.abs(dx) > maxJumpThreshold) {
    smoothedPosition.x = previousPosition.x 
                       + Math.sign(dx) * maxJumpThreshold;
  }
  if (Math.abs(dy) > maxJumpThreshold) {
    smoothedPosition.y = previousPosition.y 
                       + Math.sign(dy) * maxJumpThreshold;
  }
  
  previousPosition = { ...smoothedPosition };
  return smoothedPosition;
}

Каждый этап — на конкретный класс артефактов. EMA убирает высокочастотный шум модели (5-10 пикселей дрожания на неподвижном лице). Velocity buffer ловит ложные срабатывания: иногда модель «теряет» лицо и возвращается с другой позиции в соседнем кадре. Velocity scaling ограничивает максимальную скорость — голова физически не может двигаться быстрее. Hard jump threshold — последняя страховка на случай выпадения трекинга (рука перекрыла лицо).

Числа подбирались эмпирически. smoothingFactor = 0.95 — компромисс между лагом и стабильностью. maxVelocityThreshold = 0.15 соответствует ~30 см/с реального движения головы. У Habib’а в твиттере про latency сказано так: «If that delay is too long, the scene lags and looks wobbly. Filtering out jitter and rejecting outliers keeps edges steady instead of swimming». Дословно та же проблема, к которой я пришёл, и тот же класс решений.


Слой 3: predictive tracker

Помимо сглаживания, есть отдельный PredictiveTracker на double exponential smoothing (Holt’s method), который пытается компенсировать end-to-end лаг. Он предсказывает позицию на 20 ms вперёд:

class PredictiveTracker {
  constructor(alpha = 0.7, beta = 0.8) {
    this.alpha = alpha;
    this.beta = beta;
    this.position = { x: 0, y: 0, z: 0 };
    this.velocity = { x: 0, y: 0, z: 0 };
    this.measuredLatency = 0.020;
    this.latencyHistory = [];
    this.lastRenderTime = performance.now();
  }

  update(measurement, deltaTime) {
    const dt = Math.max(deltaTime, 1e-6);
    ['x', 'y', 'z'].forEach(axis => {
      const prevPos = this.position[axis];
      
      // Holt's level update with prediction
      this.position[axis] = this.alpha * measurement[axis] 
        + (1 - this.alpha) * (prevPos + this.velocity[axis] * dt);
      
      // Holt's trend update
      const velocityEstimate = (this.position[axis] - prevPos) / dt;
      this.velocity[axis] = this.beta * velocityEstimate 
        + (1 - this.beta) * this.velocity[axis];
    });
    return this.position;
  }
  
  measureLatency() {
    const now = performance.now();
    const frameTime = (now - this.lastRenderTime) / 1000;
    this.lastRenderTime = now;
    this.latencyHistory.push(frameTime);
    if (this.latencyHistory.length > 30) this.latencyHistory.shift();
    
    if (this.latencyHistory.length > 5) {
      const avg = this.latencyHistory.reduce((a, b) => a + b, 0) 
                / this.latencyHistory.length;
      this.measuredLatency = Math.min(avg * 1.5, 0.050);
    }
  }

  predict(futureTime = null) {
    const t = futureTime ?? this.measuredLatency;
    return {
      x: this.position.x + this.velocity.x * t,
      y: this.position.y + this.velocity.y * t,
      z: this.position.z + this.velocity.z * t
    };
  }
}

measuredLatency измеряется динамически по среднему frame time через performance.now() — компенсация подстраивается под реальную нагрузку. Тормозит браузер — prediction time увеличивается; рендер быстрый — уменьшается.


Слой 4: видео и текстура

Top-Bottom MKV открывается как обычный <video> элемент. Three.js берёт его в VideoTexture:

const texture = new THREE.VideoTexture(video);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.format = THREE.RGBAFormat;
texture.generateMipmaps = false; // Critical for sharpness
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();

generateMipmaps = false — иначе на текстуре 1080×1080 (половина от 1080×2160 Top-Bottom) появляется заметное замыливание.

Текстура подаётся в ShaderMaterial вместе с uniform’ами:

const material = new THREE.ShaderMaterial({
  uniforms: {
    uTexture:        { value: texture },
    uHeadPosition:   { value: new THREE.Vector3(0, 0, 0) },
    uFormat3D:       { value: 0.0 }, // 0 = top-bottom, 1 = side-by-side
    uDepthStrength:  { value: 0.5 },
    uConvergence:    { value: 0.0 },
    uTexelSize:      { value: new THREE.Vector2(1/width, 1/height) }
  },
  vertexShader:   STEREO_SHADER.vertexShader,
  fragmentShader: STEREO_SHADER.fragmentShader
});

В render-loop’е каждое изменение позиции головы (после сглаживания и prediction) пробрасывается в uHeadPosition:

function animate() {
  requestAnimationFrame(animate);
  predictiveTracker.measureLatency();
  const headPos = predictiveTracker.predict();
  material.uniforms.uHeadPosition.value.set(headPos.x, headPos.y, headPos.z);
  renderer.render(scene, camera);
}

Слой 5: фрагментный шейдер

Вот тут начинается самое спорное. И именно тут лежит главное отличие от True3D.

precision highp float;

uniform sampler2D uTexture;
uniform vec3 uHeadPosition;
uniform float uFormat3D;
uniform float uDepthStrength;
uniform float uConvergence;
varying vec2 vUv;

void main() {
  float viewPosition = uHeadPosition.x;
  
  // Dead zone for stability around center
  if (abs(viewPosition) < 0.05) viewPosition = 0.0;
  
  // Extract base UVs for left and right views from stereopair
  vec2 leftBaseUv, rightBaseUv;
  if (uFormat3D > 0.5) {
    // Side-by-side: left half = left eye, right half = right eye
    leftBaseUv  = vec2(vUv.x * 0.5, vUv.y);
    rightBaseUv = vec2(vUv.x * 0.5 + 0.5, vUv.y);
  } else {
    // Top-bottom: bottom half = left eye, top half = right eye
    leftBaseUv  = vec2(vUv.x, vUv.y * 0.5 + 0.5);
    rightBaseUv = vec2(vUv.x, vUv.y * 0.5);
  }
  
  // Apply parallax shift in opposite directions for each eye
  float parallax = viewPosition * uDepthStrength * 0.03;
  float convergenceShift = uConvergence * 0.02;
  
  vec2 leftUv = vec2(
    clamp(leftBaseUv.x + parallax - convergenceShift, 0.0, 1.0), 
    leftBaseUv.y
  );
  vec2 rightUv = vec2(
    clamp(rightBaseUv.x - parallax + convergenceShift, 0.0, 1.0), 
    rightBaseUv.y
  );
  
  vec4 leftColor  = texture2D(uTexture, leftUv);
  vec4 rightColor = texture2D(uTexture, rightUv);
  
  // View switching with smooth blend zone
  vec4 finalColor;
  float blendZone = 0.3;
  
  if (abs(viewPosition) < 0.001) {
    finalColor = leftColor;
  } else if (viewPosition < -blendZone) {
    finalColor = leftColor;
  } else if (viewPosition > blendZone) {
    finalColor = rightColor;
  } else {
    float blendFactor = smoothstep(-blendZone, blendZone, viewPosition);
    finalColor = mix(leftColor, rightColor, blendFactor);
  }
  
  gl_FragColor = finalColor;
}

По шагам. Шаг 1: вычисляется позиция головы (только X — лево/право). Если человек практически по центру, view зануляется через dead zone, чтобы не было дрожи на нулевой точке. Шаг 2: из стереопары извлекаются базовые UV для левого и правого вида — каждый вид занимает половину текстуры. Шаг 3: к UV каждого вида добавляется горизонтальный сдвиг в противоположных направлениях — это псевдо-параллакс. Шаг 4: между левым и правым видом плавный переход через smoothstep в зоне ±0.3.

Помимо normal-режима в шейдере есть отдельные ветки для anaglyph (красно-синие очки): красный канал берётся из левого вида, зелёный и синий — из правого. Здесь стереоэффект полноценный, без переключения видов — он работает за счёт разделения цветовых каналов на физическом уровне через очки.

Сравните это с True3D:

Моя реализация

True3D Window Mode

Источник

Готовая Top-Bottom стереопара (2 фиксированных вида)

Volumetric scene (voxels / Gaussian splats)

Перспектива

UV-shift двух 2D-видов

Off-axis projection в 3D-сцене

При движении головы

Переключение / blend между двумя готовыми видами

Рендер из новой точки 3D-сцены

Зона эффекта

Только между двумя позициями съёмочных камер

Любая позиция в пределах FOV

Occlusion

Невозможен — данных нет

Корректный — сцена 3D

Параллакс

Имитированный через UV-shift

Геометрически корректный

И вот тут видна моя архитектурная боль: я пытаюсь делать Window Mode на данных, для которых Window Mode принципиально не выводится. У меня нет той сцены, из которой нужно было бы рендерить новые виды. У Habib’а в Steamboat Willie работает корректное окклюжен — потому что у него под капотом 3D-данные, и вид «из другой точки» — это просто новый рендер той же сцены. У меня всё, что я могу — это интерполировать между двумя готовыми снимками. И эта интерполяция везде, где появляется глубинная граница, неизбежно ломается.


Что работает

  • MediaPipe-трекинг стабильно. На i7-1260P с интегрированной графикой держится 30-35 FPS трекинга при 60 FPS рендера видео. iris-landmarks дают разумную оценку направления взгляда.

  • Извлечение Top-Bottom на лету, без перекодирования.

  • Anaglyph-режим работает корректно. С нормальными красно-синими очками глубина видна полноценно.

  • Плавный transition в blend zone — пока зритель не двигается резко, переход глаз-в-глаз глазом не ловится.


Что не работает

Несколько связанных проблем, которые я не решил.

Проблема 1: пляски картинки на резких движениях

Когда зритель быстро поворачивает голову, происходит следующее: сглаживание предсказывает один вид, картинка переключается, через 30-40 ms приходит новая измерительная точка с другим знаком — и картинка переключается обратно. Получается визуальное «дрожание» в течение 100-200 ms.

Я не нашёл хорошего способа отличить намеренное движение головы (на которое нужно реагировать быстро) от случайного дрожания (на которое реагировать вообще не нужно). Velocity threshold помогает с шумом MediaPipe, но не с реальными быстрыми движениями зрителя.

Проблема 2: edge artifacts на границах глубины

В blend zone, когда mix(leftColor, rightColor, ...) интерполирует между двумя видами, на границах глубины (контуры лиц, волосы, рёбра объектов) возникает заметный «ghosting»: там, где левый и правый виды показывают объект на разных позициях, простая линейная интерполяция даёт двоение.

Я экспериментировал с edge detection в шейдере (abs(leftColor - rightColor) с порогом), но это даёт ложные срабатывания на любых высокочастотных текстурах (трава, одежда), а реальные depth-границы пропускает.

Корректное решение — view synthesis с использованием depth map: вместо смешивания двух готовых видов нужно из одного вида плюс карты глубины восстановить промежуточный. Это техника DIBR (depth-image-based rendering). У меня есть Python-скрипт, который через OpenCV StereoSGBM пытается извлечь disparity:

stereo = cv2.StereoSGBM_create(
    minDisparity=0,
    numDisparities=16 * 6,
    blockSize=7,
    P1=8 * 3 * 7**2,
    P2=32 * 3 * 7**2,
    disp12MaxDiff=1,
    uniquenessRatio=10,
    speckleWindowSize=100,
    speckleRange=32,
    mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY
)
disparity = stereo.compute(left_gray, right_gray).astype(np.float32) / 16.0

depth_smooth = cv2.bilateralFilter(
    cv2.normalize(disparity, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8),
    d=5, sigmaColor=50, sigmaSpace=50
)

Качество получается слишком плохим для real-world фильмов: SGBM хорошо работает на статичных сценах с чёткими текстурами, а на движении (туман, частицы, motion blur) — даёт мусор. Я не интегрировал этот pipeline в плеер, потому что результат хуже, чем простое переключение видов.

Проблема 3: половина вертикального разрешения

Top-Bottom — это 1920×2160, что после распаковки даёт 1920×1080 на глаз. Половина исходного разрешения теряется. Просто масштабировать через THREE.LinearFilter помогает мало — детали уже потеряны при кодировании. Real-time апскейл (FSR/DLSS-аналог в браузере) не существует. Запускать DepthAnything или MiDaS на 60 FPS на интегрированной графике — нет, не получится.

Проблема 4: латентность

End-to-end лаг от движения головы до обновления изображения:

  • Webcam capture: ~16 ms

  • MediaPipe inference: ~30 ms

  • Smoothing pipeline: ~3 ms

  • Three.js render + browser presentation: ~16 ms

  • Итого: ~65 ms в среднем

Predictive tracker компенсирует часть, но не всё. Для bullet-proof эффекта надо < 20 ms total — это уровень VR-headset с iris-tracking, а не webcam + MediaPipe в браузере.

Проблема 5: UV-параллакс ≠ настоящий 3D

Самое фундаментальное и то, что отличает мою реализацию от True3D. Я делаю 2D UV-shift двух готовых видов. Настоящий head-coupled perspective требует off-axis projection: пересчёт frustum’а виртуальной камеры в зависимости от позиции глаза, как у Lee и у Habib’а. У них это работает, потому что у них 3D-сцена с реальной геометрией. У меня — два готовых снимка с двух фиксированных позиций.

Эффект работает только в зоне между этими двумя позициями. Если зритель сдвинется больше, чем расстояние между изначальными камерами при съёмке, рендерить будет нечего — данных за пределами этого диапазона у меня нет. UV-shift это маскирует, но не решает. Habib про то же самое в своих заметках: «You cannot ‘look around’ objects unless that visual data exists, which becomes evident when foreground occlusions reveal missing detail». У него это проявляется на краях сцены. У меня — везде, потому что мой «диапазон визуальных данных» это два кадра, а не volumetric reconstruction.


Открытые вопросы

Что я попробовал бы дальше, если бы знал направление:

  1. Заменить SGBM на нейросетевую depth estimation. DepthAnything v2 даёт куда более чистые карты глубины, чем classical stereo matching. Вопрос — можно ли запустить её через WebGPU + ONNX Runtime Web в реальном времени, и хватит ли качества для DIBR без артефактов. Бенчмарков на реальных фильмах в браузере я не нашёл.

  2. Frame interpolation между left и right views. RIFE, DAIN, FILM — нейросетевые интерполяторы для motion. Если бы они работали как «view interpolators» (а не «time interpolators»), можно было бы синтезировать промежуточные виды между двумя готовыми. Кто-нибудь применял их именно к стереопарам, а не к последовательным кадрам?

  3. WebGPU compute shader для view synthesis. Если уйти от UV-shift’а к настоящей DIBR — реально ли уложить это в frame budget на 60 FPS? Compute shader с 1920×1080 disparity-map’ой и forward warping.

  4. Альтернативы MediaPipe для трекинга. WebGazer.js быстрее, но iris-точность хуже. ONNX Runtime Web с лёгкой моделью + SharedArrayBuffer для отдельного thread’а — возможно ли получить total latency < 20 ms?

  5. Гибрид: depth на CPU отдельным thread’ом. Если depth-extraction через OpenCV.js (или custom WASM) делать в Web Worker не на каждый кадр, а на каждый, скажем, 5-й, и интерполировать между ключевыми — можно ли получить достаточное качество для blend-зоны при приемлемой нагрузке?

  6. Принципиальный вопрос: можно ли из готовой стереопары восстановить volumetric scene в реальном времени, чтобы делать настоящий off-axis projection? Habib решает это за счёт того, что у него на входе уже volumetric data (Gaussian splats). Я начинаю с двух 2D-видов. Существуют ли работы, где на лету (~33 ms на кадр) реконструируется хотя бы упрощённая 3D-сцена из стереопары достаточного качества для off-axis projection? Или это упирается в фундаментальное ограничение — два фиксированных кадра не дают volumetric data?

Если у вас был опыт с похожими системами или вы видели работы, которые я не нашёл — напишите в комментариях. На замечание «всё это не нужно, купи 3D-телевизор» — справедливо, но любопытство сильнее.