├── demo.gif ├── app ├── favicon.ico ├── page.tsx ├── globals.css └── layout.tsx ├── public ├── uni05_53.ttf ├── target_visualization.vv ├── target_visualization_mobile.vv ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── postcss.config.mjs ├── next.config.ts ├── .gitignore ├── package.json ├── tsconfig.json ├── components ├── LandmarkWorker.tsx └── WindowModeDemoPage.tsx ├── LICENSE ├── README.md └── docs └── customize.md /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splatsdotcom/WindowMode/HEAD/demo.gif -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splatsdotcom/WindowMode/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/uni05_53.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splatsdotcom/WindowMode/HEAD/public/uni05_53.ttf -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/target_visualization.vv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splatsdotcom/WindowMode/HEAD/public/target_visualization.vv -------------------------------------------------------------------------------- /public/target_visualization_mobile.vv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splatsdotcom/WindowMode/HEAD/public/target_visualization_mobile.vv -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import WindowModeDemoPage from '@/components/WindowModeDemoPage'; 3 | 4 | export default function Page() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | async headers() { // these headers are needed for WASM multithreading (which spatial-player uses) 5 | return [ 6 | { 7 | source: "/:path*", 8 | headers: [ 9 | { 10 | key: "Cross-Origin-Opener-Policy", 11 | value: "same-origin", 12 | }, 13 | { 14 | key: "Cross-Origin-Embedder-Policy", 15 | value: "require-corp", 16 | }, 17 | ], 18 | }, 19 | ]; 20 | }, 21 | }; 22 | 23 | export default nextConfig; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "window-mode", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@mediapipe/tasks-vision": "^0.10.22-rc.20250304", 12 | "lucide-react": "^0.544.0", 13 | "next": "15.5.4", 14 | "react": "19.1.0", 15 | "react-dom": "19.1.0", 16 | "spatial-player": "^3.2.13" 17 | }, 18 | "devDependencies": { 19 | "@tailwindcss/postcss": "^4", 20 | "@types/node": "^20", 21 | "@types/react": "^19", 22 | "@types/react-dom": "^19", 23 | "tailwindcss": "^4", 24 | "typescript": "^5" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | } 7 | 8 | @theme inline { 9 | --color-background: var(--background); 10 | --color-foreground: var(--foreground); 11 | --font-sans: var(--font-geist-sans); 12 | --font-mono: var(--font-geist-mono); 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | background: var(--background); 24 | color: var(--foreground); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | 28 | @font-face { 29 | font-family: 'PixelFont'; 30 | src: url('/uni05_53.ttf') format('truetype'); 31 | font-weight: 400; 32 | font-style: normal; 33 | font-display: swap; /* optional, improves loading */ 34 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Create Next App", 17 | description: "Generated by create next app", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/LandmarkWorker.tsx: -------------------------------------------------------------------------------- 1 | let detector: any = null; 2 | 3 | self.onmessage = async (e) => { 4 | const { type, payload } = e.data; 5 | 6 | if (type === 'init') { 7 | const { wasmPath, modelPath } = payload; 8 | const mp = await import('@mediapipe/tasks-vision'); 9 | const { FilesetResolver, FaceLandmarker } = mp; 10 | 11 | const vision = await FilesetResolver.forVisionTasks(wasmPath); 12 | detector = await FaceLandmarker.createFromOptions(vision, { 13 | baseOptions: { modelAssetPath: modelPath }, 14 | runningMode: 'VIDEO', 15 | numFaces: 1, 16 | outputFaceBlendshapes: false, 17 | outputFacialTransformationMatrixes: false 18 | }); 19 | 20 | self.postMessage({ type: 'ready' }); 21 | } 22 | 23 | if (type === 'frame' && detector) { 24 | const { bitmap, timestamp } = payload; 25 | try { 26 | const res = detector.detectForVideo(bitmap, timestamp); 27 | self.postMessage({ type: 'landmarks', payload: res.faceLandmarks }); 28 | } finally { 29 | bitmap.close(); 30 | } 31 | } 32 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 True3D Technologies, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Window Mode 2 | This is a demo of True3D labs' "window mode". Check out the live demo [here](https://lab.true3d.com/targets). 3 | 4 | ![Demo](demo.gif) 5 | 6 | ## Getting Started 7 | 8 | To run this project locally: 9 | 10 | ```bash 11 | git clone https://github.com/True3DLabs/WindowMode.git 12 | cd WindowMode 13 | npm install 14 | npm run dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | ## Project Structure 20 | 21 | This is a NextJS project. The core funcionality driving the demo can be found at `components/WindowModeDemoPage.tsx`. We also use a small web worker for offloading tasks to a background thread, this can be found at `components/LandmarkWorker.tsx`. The file containing the 3D scene we render is stored at `public/target_visualization.vv`. `.vv` (voxel volume) is our file format for voxel-based 3D static scenes. More on this in "How do we render the scene." 22 | 23 | ## What is window mode? 24 | Window mode is a 3D camera controller that emulates a window into the virtual world. You can imagine that your computer screen is really a portal into a 3D space. 25 | 26 | It works by tracking the position of your face relative to the webcam, then re-rendering the 3D scene from the perspective of your face. This gives off the illusion that the 3D scene is really there, behind the screen, without the need for specialized hardware. 27 | 28 | [more here](https://x.com/DannyHabibs/status/1973418113996861481) 29 | 30 | It also works on any 3D video on [splats](https://www.splats.com/). Just click on the head icon in the player bar 31 | 32 | ## How does it work? 33 | Here we use [MediaPipe](https://www.npmjs.com/package/@mediapipe/tasks-vision)'s `FaceLandmarker` system to extract the positions of the user's eyes. We use the apparent diameter of the eyes, along with the webcam's FOV in order to estimate the distance of the user's head from the webcam. We can then get an accurate estimate for the metric position of the user's eyes, relative to the webcam. 34 | 35 | Once we have the position of the users' face, we compute an *off-axis projection matrix*. This is a matrix transforming camera-relative coordinates to screen coordinates. It is what simulates the "portal" effect. This is done within our `spatial-player` library. For more information read [this article](https://en.wikibooks.org/wiki/Cg_Programming/Unity/Projection_for_Virtual_Reality). We will also be posting a video explainer to our [YouTube channel](https://www.youtube.com/@true3dlabs) soon. 36 | 37 | ## How do we render the scene? 38 | All the rendering for this demo is done with our `spatial-player` library. You can install it on `npm` [here](https://www.npmjs.com/package/spatial-player). `spatial-player` is our framework for working with voxel-based 3D videos and static scenes. 39 | 40 | The targets are stored in a `.vv` (voxel volume) file. This is our file format for static, voxel-based 3D scenes. `spatial-player` also supports realtime rendering and playback of 3D volumetric videos, this is how our [Steamboat Willie Demo](https://www.splats.com/watch/702?window_mode=true&start_time=21) is rendered. Our volumetric videos are stored in `.splv` files. 41 | 42 | ### Using Your Own 3D Models 43 | 44 | Want to use your own 3D artwork? You can easily convert any static GLB 3D model into a `.vv` file using our conversion tool: 45 | 46 | **[Convert GLB to VV →](https://www.splats.com/tools/voxelize)** 47 | 48 | Simply upload your GLB file (up to 500MB) and download the converted `.vv` file. Then replace the existing `.vv` files in the `public/` directory with your own! 49 | 50 | 51 | 52 | You can render `.splv`s with `spatial-player`. If you want to create `.splv`s or `.vv`s to render, you should check out our python package `spatialstudio`. You can `pip` install it, check out the [documentation](https://pypi.org/project/spatialstudio/). If you have any questions/suggestions/requests for us or our stack, reach out to us on [discord](https://discord.gg/seBPMUGnhR). 53 | 54 | Currently `spatial-player` and `spatialstudio` are only availble to install and use, but we will be open-sourcing them soon! 55 | 56 | ## Troubleshooting 57 | 58 | ### WebGPU Error 59 | If you encounter an error related to WebGPU not being enabled, make sure you go to your browser's developer flags to enable it. This is required for the 3D rendering functionality. 60 | -------------------------------------------------------------------------------- /docs/customize.md: -------------------------------------------------------------------------------- 1 | # Customizing Your 3D Scene 2 | 3 | This documentation explains how to replace the default .vv files with your own 3D models and configure the camera parameters for optimal window mode experience. 4 | 5 | ## Table of Contents 6 | 7 | - [Converting 3D Models to .vv Format](#converting-3d-models-to-vv-format) 8 | - [Replacing .vv Files](#replacing-vv-files) 9 | - [Camera Configuration Parameters](#camera-configuration-parameters) 10 | - [Understanding the Portal Effect](#understanding-the-portal-effect) 11 | 12 | ## Converting 3D Models to .vv Format 13 | 14 | ### GLB to VV Converter 15 | 16 | The easiest way to convert your 3D models is using the official GLB to VV converter: 17 | 18 | **[Convert GLB to VV →](https://www.splats.com/tools/voxelize)** 19 | 20 | **Requirements:** 21 | - **File format**: GLB files only 22 | - **File size limit**: 500MB maximum 23 | - **Model type**: Static 3D models (no animations) 24 | 25 | **Process:** 26 | 1. Upload your GLB file to the converter 27 | 2. Wait for conversion to complete 28 | 3. Download the generated .vv file 29 | 30 | 31 | ## Replacing .vv Files 32 | 33 | The demo uses two .vv files for different orientations: 34 | 35 | - `public/target_visualization.vv` - Desktop/landscape version 36 | - `public/target_visualization_mobile.vv` - Mobile/portrait version 37 | 38 | ### Steps to Replace: 39 | 40 | 1. **Convert your 3D model** to .vv format using the converter above 41 | 2. **Replace the existing files** in the `public/` directory: 42 | ```bash 43 | # Replace desktop version 44 | cp your_model.vv public/target_visualization.vv 45 | 46 | # Replace mobile version (optional - can use same file) 47 | cp your_model.vv public/target_visualization_mobile.vv 48 | ``` 49 | 3. **Restart the development server** to see changes 50 | 51 | ### File Path Configuration 52 | 53 | If you want to use different file names, update the paths in `components/WindowModeDemoPage.tsx`: 54 | 55 | ```typescript 56 | const vvUrl = isPortrait 57 | ? "/your_mobile_model.vv" // Change this 58 | : "/your_desktop_model.vv"; // Change this 59 | ``` 60 | 61 | ## Camera Configuration Parameters 62 | 63 | These parameters control how the 3D scene responds to your head movement. Modify them in `components/WindowModeDemoPage.tsx`: 64 | 65 | ### WORLD_TO_VOXEL_SCALE 66 | 67 | ```typescript 68 | const WORLD_TO_VOXEL_SCALE = 0.0075; 69 | ``` 70 | 71 | **Purpose**: Converts real-world units (centimeters) to voxel space units. 72 | 73 | **Effect**: 74 | - **Higher values** = More exaggerated head movement response 75 | - **Lower values** = Subtler head movement response 76 | 77 | **Typical range**: 0.001 - 0.02 78 | 79 | ### SCREEN_SCALE 80 | 81 | ```typescript 82 | const SCREEN_SCALE = 0.2 * 1.684; 83 | ``` 84 | 85 | **Purpose**: Determines how large the "window" appears in virtual space. 86 | 87 | **Effect**: 88 | - **Higher values** = Larger window, more immersive effect 89 | - **Lower values** = Smaller window, more focused view 90 | 91 | **Typical range**: 0.1 - 0.5 92 | 93 | ### SCREEN_POSITION 94 | 95 | ```typescript 96 | const SCREEN_POSITION = [0.0, 0.0, -0.5]; 97 | ``` 98 | 99 | **Purpose**: Where the screen is positioned in 3D voxel space. 100 | 101 | **Format**: `[x, y, z]` coordinates 102 | 103 | **Effect**: 104 | - **X axis**: Left/right screen position 105 | - **Y axis**: Up/down screen position 106 | - **Z axis**: Forward/back screen position (negative = closer to viewer) 107 | 108 | **Typical range**: 109 | - X: -1.0 to 1.0 110 | - Y: -1.0 to 1.0 111 | - Z: -2.0 to 0.0 112 | 113 | ### SCREEN_TARGET 114 | 115 | ```typescript 116 | const SCREEN_TARGET = [0.0, 0.0, 0.0]; 117 | ``` 118 | 119 | **Purpose**: Where the screen is looking towards in 3D space. 120 | 121 | **Format**: `[x, y, z]` coordinates 122 | 123 | **Effect**: Controls the initial viewing direction of your 3D scene. 124 | 125 | **Common values**: 126 | - `[0.0, 0.0, 0.0]` - Looking at the center 127 | - `[0.0, 0.0, 1.0]` - Looking forward 128 | - `[0.0, 1.0, 0.0]` - Looking up 129 | 130 | ## Understanding the Portal Effect 131 | 132 | The window mode creates a "portal" effect by using an **off-axis projection matrix**. This technique: 133 | 134 | 1. **Tracks your head position** relative to the screen 135 | 2. **Calculates your eye position** in 3D space 136 | 3. **Renders the scene** from your eye's perspective 137 | 4. **Creates the illusion** that the 3D scene exists behind the screen 138 | 139 | ### Camera Position Calculation 140 | 141 | The system automatically calculates your eye position using: 142 | 143 | ```typescript 144 | // Your eye position is calculated from head tracking 145 | let avgPos = [ 146 | (irisPosRight.x + irisPosLeft.x) / 2.0, 147 | (irisPosRight.y + irisPosLeft.y) / 2.0, 148 | (irisPosRight.z + irisPosLeft.z) / 2.0 149 | ]; 150 | 151 | // Applied to the camera 152 | (vvRef.current as any).setCamera('portal', { 153 | eyePosWorld: avgPos, // Your calculated eye position 154 | screenScale: SCREEN_SCALE, // Window size 155 | worldToVoxelScale: WORLD_TO_VOXEL_SCALE, // Movement sensitivity 156 | screenPos: SCREEN_POSITION, // Screen location in 3D space 157 | screenTarget: SCREEN_TARGET // Screen viewing direction 158 | }); 159 | ``` 160 | 161 | ### Optimization Tips 162 | 163 | - **Start with default values** and adjust gradually 164 | - **Test with different head positions** to ensure smooth tracking 165 | - **Consider your 3D model's scale** when setting WORLD_TO_VOXEL_SCALE 166 | - **Adjust SCREEN_POSITION** to center your model in the viewport 167 | 168 | --- 169 | 170 | *For general project information, see the [README](../README.md).* 171 | -------------------------------------------------------------------------------- /components/WindowModeDemoPage.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useRef, useState } from 'react'; 4 | import { ScanFace } from 'lucide-react'; 5 | 6 | // -------------------------------------------------- // 7 | 8 | /** 9 | * a 2D coordinate 10 | */ 11 | type Pt = { x: number; y: number }; 12 | 13 | /** 14 | * 2D landmarks corresponding to a single iris, in image coordinates 15 | */ 16 | type Iris = { 17 | center: Pt, 18 | edges: Pt[] 19 | }; 20 | 21 | /** 22 | * a typical laptop webcam FOV 23 | * TODO: can we query this? 24 | */ 25 | const DEFAULT_HFOV_DEG = 60; 26 | 27 | /** 28 | * these values get passed to spatial-player and determine 29 | * how exagerrated the scene moves with your head 30 | * 31 | * - worldToVoxelScale converts world-space units (cm, ft, etc) to voxels 32 | * - screenScale determines how large the "window" is in virtual space. The voxel volume always occupies [(-1,-1,-1), (1,1,1)] 33 | * - screenPos determines where in voxel space the screen is located 34 | * - screenTarget determines where in voxel space the screen looks towards 35 | */ 36 | const WORLD_TO_VOXEL_SCALE = 0.0075; 37 | const SCREEN_SCALE = 0.2 * 1.684; 38 | const SCREEN_POSITION = [0.0, 0.0, -0.5]; 39 | const SCREEN_TARGET = [0.0, 0.0, 0.0]; 40 | 41 | /** 42 | * the FaceLandmarker indices for the left and right irises 43 | * from https://github.com/google-ai-edge/mediapipe/blob/master/docs/solutions/iris.md#ml-pipeline 44 | */ 45 | const RIGHT_IRIS_IDX = 468; 46 | const LEFT_IRIS_IDX = 473; 47 | 48 | // -------------------------------------------------- // 49 | 50 | export default function WindowModeDemoPage() { 51 | 52 | // ------------------ // 53 | // STATE: 54 | 55 | const isPortrait = useIsPortrait(); 56 | const isWebGPUSupported = (navigator as any).gpu != null; 57 | const vvUrl = isPortrait 58 | ? "/target_visualization_mobile.vv" 59 | : "/target_visualization.vv"; 60 | 61 | const [error, setError] = useState(null); 62 | const [numFramesFaceHidden, setNumFramesFaceHidden] = useState(0); 63 | const [hasPermission, setHasPermission] = useState(false); 64 | const [isRequestingPermission, setIsRequestingPermission] = useState(false); 65 | const [showTiltInstruction, setShowTiltInstruction] = useState(false); 66 | 67 | const vvRef = useRef(null); 68 | const videoRef = useRef(null); 69 | 70 | const irisDistRightRef = useRef(null); 71 | const irisDistLeftRef = useRef(null); 72 | 73 | const isPortraitRef = useRef(isPortrait); 74 | const numFramesFaceHiddenRef = useRef(numFramesFaceHidden); 75 | 76 | // ------------------ // 77 | // UTILITY FUNCTION: 78 | 79 | /** 80 | * sets a cookie 81 | */ 82 | const setCookie = (name: string, value: string, days: number = 365) => { 83 | const expires = new Date(); 84 | expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000)); 85 | document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`; 86 | }; 87 | 88 | /** 89 | * retrieves a cookie 90 | */ 91 | const getCookie = (name: string): string | null => { 92 | const nameEQ = name + "="; 93 | const ca = document.cookie.split(';'); 94 | for (let i = 0; i < ca.length; i++) { 95 | let c = ca[i]; 96 | while (c.charAt(0) === ' ') c = c.substring(1, c.length); 97 | if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); 98 | } 99 | 100 | return null; 101 | }; 102 | 103 | /** 104 | * request and caches camera permissions 105 | */ 106 | const requestCameraPermission = async () => { 107 | setIsRequestingPermission(true); 108 | 109 | try { 110 | const stream = await navigator.mediaDevices.getUserMedia({ 111 | video: { 112 | facingMode: "user", 113 | width: { ideal: 160 }, 114 | height: { ideal: 120 } 115 | }, 116 | audio: false 117 | }); 118 | 119 | // Stop the stream immediately after getting permission 120 | stream.getTracks().forEach(track => track.stop()); 121 | 122 | // Save permission to cookie for future visits 123 | setCookie('camera_permission_granted', 'true', 365); 124 | setHasPermission(true); 125 | 126 | } catch (e: any) { 127 | console.error('Camera permission denied:', e); 128 | setError('Camera access is required for this experience. Please allow camera access and refresh the page.'); 129 | 130 | } finally { 131 | setIsRequestingPermission(false); 132 | } 133 | }; 134 | 135 | /** 136 | * returns the focal length given the horizontal FOV 137 | */ 138 | const focalLengthPixels = (imageWidthPx: number, hFovDeg: number) => { 139 | const a = (hFovDeg * Math.PI) / 180; 140 | return imageWidthPx / (2 * Math.tan(a / 2)); 141 | } 142 | 143 | // ------------------ // 144 | // useEffects: 145 | 146 | /** 147 | * imports spatial-player 148 | * spatial-player uses top-level async/await so we need to import dynamically 149 | */ 150 | useEffect(() => { 151 | import('spatial-player/src/index.js' as any) 152 | }, []); 153 | 154 | /** 155 | * updates isPortraitRef 156 | */ 157 | useEffect(() => { 158 | isPortraitRef.current = isPortrait; 159 | }, [isPortrait]); 160 | 161 | /** 162 | * checks for existing 163 | */ 164 | useEffect(() => { 165 | const savedPermission = getCookie('camera_permission_granted'); 166 | if (savedPermission === 'true') { 167 | setHasPermission(true); 168 | } 169 | }, []); 170 | 171 | /** 172 | * shows instructions 173 | */ 174 | useEffect(() => { 175 | if (!hasPermission) return; 176 | 177 | setShowTiltInstruction(true); 178 | 179 | const hideTiltInstructionTimer = setTimeout(() => { 180 | setShowTiltInstruction(false); 181 | }, 3000); // 3 seconds 182 | 183 | return () => { 184 | clearTimeout(hideTiltInstructionTimer); 185 | }; 186 | }, [hasPermission]); 187 | 188 | /** 189 | * updates numFramesFaceHiddenRef 190 | */ 191 | useEffect(() => { 192 | numFramesFaceHiddenRef.current = numFramesFaceHidden; 193 | }, [numFramesFaceHidden]); 194 | 195 | /** 196 | * main initialization + loop 197 | */ 198 | useEffect(() => { 199 | if (!hasPermission) return; 200 | 201 | let running = true; 202 | let worker: Worker; 203 | 204 | async function init() { 205 | try { 206 | 207 | //get camera: 208 | //----------------- 209 | const stream = await navigator.mediaDevices.getUserMedia({ 210 | video: { 211 | facingMode: "user", 212 | width: { ideal: 160 }, 213 | height: { ideal: 120 } 214 | }, 215 | audio: false 216 | }); 217 | const video = videoRef.current!; 218 | video.srcObject = stream; 219 | await video.play(); 220 | 221 | //spawn facelandmarker worker: 222 | //we do landmarking in a worker so we don't block rendering on the main thread 223 | //----------------- 224 | worker = new Worker(new URL('./LandmarkWorker.tsx', import.meta.url), { 225 | type: 'module' 226 | }); 227 | 228 | worker.postMessage({ 229 | type: 'init', 230 | payload: { 231 | wasmPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm', 232 | modelPath: 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task' 233 | } 234 | }); 235 | 236 | let lastTime = -1; 237 | 238 | let landmarkingReady = false; 239 | let landmarkingInFlight = false; 240 | let lastVideoTime = -1; 241 | let latestLandmarks: any[] | null = null; 242 | 243 | worker.onmessage = (e) => { 244 | if (e.data.type === 'landmarks') { 245 | latestLandmarks = e.data.payload?.[0] ?? null; 246 | landmarkingInFlight = false; 247 | 248 | if(latestLandmarks) 249 | setNumFramesFaceHidden(0); 250 | else 251 | setNumFramesFaceHidden(numFramesFaceHiddenRef.current + 1); 252 | } 253 | 254 | if(e.data.type === 'ready') 255 | landmarkingReady = true; 256 | }; 257 | 258 | //define helpers: 259 | //----------------- 260 | 261 | //reads an iris from the mediapipe landmarks 262 | function extractIris(landmarks: any[], idx: number): Iris { 263 | let edges = []; 264 | for (let i = 0; i < 4; i++) { 265 | let landmark = landmarks[idx + 1 + i]; 266 | edges.push({ x: landmark.x, y: landmark.y }); 267 | } 268 | 269 | return { 270 | center: { x: landmarks[idx].x, y: landmarks[idx].y }, 271 | edges 272 | }; 273 | } 274 | 275 | //computes the distance from the webcam to the iris 276 | //uses the fact that the human iris has a relatively fixed size, regardless of age/genetics 277 | function irisDistance(iris: Iris, hFovDeg = DEFAULT_HFOV_DEG): number { 278 | const IRIS_DIAMETER_MM = 11.7; //average human iris size 279 | 280 | let dx = ((iris.edges[0].x - iris.edges[2].x) + (iris.edges[1].x - iris.edges[3].x)) 281 | / 2.0 * video.videoWidth; 282 | let dy = ((iris.edges[0].y - iris.edges[2].y) + (iris.edges[1].y - iris.edges[3].y)) 283 | / 2.0 * video.videoHeight; 284 | 285 | let irisSize = Math.sqrt(dx * dx + dy * dy); 286 | 287 | const fpx = focalLengthPixels(video.videoWidth, hFovDeg); 288 | 289 | const irisDiamCm = IRIS_DIAMETER_MM / 10; 290 | return (fpx * irisDiamCm) / irisSize; 291 | } 292 | 293 | //uses the distance + screen position of the iris to compute its metric position, relative to the webcam 294 | function irisPosition(iris: Iris, distanceCm: number, hFovDeg = DEFAULT_HFOV_DEG): { x: number; y: number; z: number } { 295 | const W = video.videoWidth; 296 | const H = video.videoHeight; 297 | 298 | const fpx = focalLengthPixels(W, hFovDeg); 299 | 300 | const u = iris.center.x; 301 | const v = iris.center.y; 302 | 303 | const x = -(u * W - W / 2) * distanceCm / fpx; 304 | const y = -(v * H - H / 2) * distanceCm / fpx; 305 | const z = distanceCm; 306 | 307 | return { x, y, z }; 308 | } 309 | 310 | //define main loop: 311 | //----------------- 312 | function loop() { 313 | if (!running) 314 | return; 315 | 316 | const currentTime = performance.now(); 317 | const dt = currentTime - lastTime; 318 | 319 | lastTime = currentTime; 320 | 321 | //send video frame to worker 322 | if(landmarkingReady && !landmarkingInFlight && video.currentTime !== lastVideoTime) { 323 | const videoTimestamp = Math.round(video.currentTime * 1000); 324 | createImageBitmap(video).then((bitmap) => { 325 | worker.postMessage({ type: 'frame', payload: { bitmap, timestamp: videoTimestamp } }, [bitmap]); 326 | }); 327 | 328 | landmarkingInFlight = true; 329 | lastVideoTime = video.currentTime; 330 | } 331 | 332 | if (latestLandmarks) { 333 | 334 | //extract irises 335 | const irisRight = extractIris(latestLandmarks, RIGHT_IRIS_IDX); 336 | const irisLeft = extractIris(latestLandmarks, LEFT_IRIS_IDX); 337 | 338 | //compute distances 339 | 340 | const irisTargetDistRight = irisDistance(irisRight); 341 | const irisTargetDistLeft = irisDistance(irisLeft); 342 | 343 | var irisDistRight = irisDistRightRef.current; 344 | var irisDistLeft = irisDistLeftRef.current; 345 | 346 | //update current distance 347 | //the distance estimation is pretty noisy, so we do this to smooth it out 348 | const distanceDecay = 1.0 - Math.pow(0.99, dt); 349 | 350 | irisDistRight = irisDistRight != null 351 | ? irisDistRight + (irisTargetDistRight - irisDistRight) * distanceDecay 352 | : irisTargetDistRight; 353 | 354 | irisDistLeft = irisDistLeft != null 355 | ? irisDistLeft + (irisTargetDistLeft - irisDistLeft) * distanceDecay 356 | : irisTargetDistLeft; 357 | 358 | irisDistRightRef.current = irisDistRight; 359 | irisDistLeftRef.current = irisDistLeft; 360 | 361 | const minDist = Math.min(irisDistLeft, irisDistRight); 362 | 363 | //compute positions 364 | let irisPosRight = irisPosition(irisRight, minDist); 365 | let irisPosLeft = irisPosition(irisLeft, minDist); 366 | 367 | //update vv camera 368 | //.vv (voxel volume) is our format for 3D voxel scenes 369 | //spatial-player has utilties for rendering them 370 | if (customElements.get('vv-player')) { 371 | let avgPos = [ 372 | (irisPosRight.x + irisPosLeft.x) / 2.0, 373 | (irisPosRight.y + irisPosLeft.y) / 2.0, 374 | (irisPosRight.z + irisPosLeft.z) / 2.0 375 | ]; 376 | 377 | //do some jank manual correction so its more aligned 378 | //TODO: fix this 379 | avgPos[1] -= isPortraitRef.current ? 30.0 : 20.0; 380 | 381 | //to achieve the "window" effect, we use spatial-player's builtin 382 | //"portal" camera mode. this computes an off-axis projection matrix, and uses that 383 | //to render the scene in 3D 384 | 385 | //spatial-player is not yet open source, but the projection matrix is computed 386 | //with the standard off-axis projection formula. for an overview of this, see 387 | // https://en.wikibooks.org/wiki/Cg_Programming/Unity/Projection_for_Virtual_Reality 388 | 389 | (vvRef.current as any).setCamera('portal', { 390 | eyePosWorld: avgPos, 391 | screenScale: SCREEN_SCALE, 392 | worldToVoxelScale: WORLD_TO_VOXEL_SCALE, 393 | 394 | screenPos: SCREEN_POSITION, 395 | screenTarget: SCREEN_TARGET 396 | }); 397 | } 398 | } 399 | 400 | requestAnimationFrame(loop); 401 | } 402 | 403 | //start main loop: 404 | //----------------- 405 | requestAnimationFrame(loop); 406 | } 407 | catch (e: any) { 408 | console.error(e); 409 | setError(e?.message ?? 'Failed to initialize'); 410 | } 411 | } 412 | 413 | //init: 414 | //----------------- 415 | init(); 416 | 417 | return () => { 418 | running = false; 419 | worker?.terminate(); 420 | const v = videoRef.current; 421 | const stream = v && (v.srcObject as MediaStream); 422 | stream?.getTracks()?.forEach(t => t.stop()); 423 | }; 424 | }, [hasPermission]); 425 | 426 | /** 427 | * determines whether we are in portrait or landscale 428 | * orientation, used to render the appropriate .vv 429 | * (a .vv is a voxel volume file, stores a 3D scene) 430 | */ 431 | function useIsPortrait() { 432 | const [isPortrait, setIsPortrait] = useState(false); 433 | 434 | useEffect(() => { 435 | const checkOrientation: any = () => { 436 | if (typeof window !== 'undefined') { 437 | setIsPortrait(window.innerHeight > window.innerWidth); 438 | } 439 | }; 440 | 441 | checkOrientation(); 442 | 443 | window.addEventListener('resize', checkOrientation); 444 | return () => { 445 | window.removeEventListener('resize', checkOrientation); 446 | }; 447 | }, []); 448 | 449 | return isPortrait; 450 | } 451 | 452 | // ------------------ // 453 | // LAYOUT: 454 | 455 | return ( 456 |
461 | 462 | {/* Permission Request Screen */} 463 | {!hasPermission && ( 464 |
473 | {/* Faded overlay */} 474 |
475 |
476 | {/* ScanFace Icon */} 477 |
478 |
479 | 480 |
481 |
482 | 483 | {/* Title */} 484 |

485 | 3D Viewer Demo 486 |

487 | 488 | {/* Description */} 489 |

490 | We use head tracking to enhance this experience. It allows the 3D scene to react naturally to your movements. Try tilting your head to see how the perspective shifts. It is designed for a single viewer.

491 | 492 | {/* Permission Button */} 493 | 507 | 508 | {/* Privacy Note */} 509 |

510 | Your data is processed locally on your device and is not stored or transmitted anywhere. 511 |

512 |
513 |
514 | )} 515 | 516 | {/* Main content - only show when permission is granted */} 517 | {hasPermission && ( 518 | <> 519 | {/* Information icon with tooltip */} 520 |
521 |
522 |
523 | i 524 |
525 | 526 | {/* Tooltip */} 527 |
528 |
3D Viewer Demo
529 |
530 | This demo uses your camera to track your head in real time. We map your head position to 3D camera controls so the video feels immersive and responsive. It is designed and recommended for a single viewer.
531 |
532 |
533 |
534 | 535 |
536 |
538 | 539 |
543 |
544 | {!isWebGPUSupported ? ( 545 | // WebGPU not supported - show error message 546 |
547 |
566 | WebGPU is not supported on your browser 567 |
568 |
569 | ) : ( 570 | // WebGPU supported - show the player 571 | <> 572 | {/* @ts-expect-error - vv-player is a custom element from spatial-player */} 573 | 582 | 583 | )} 584 |
585 |
586 | 587 | {numFramesFaceHidden > 3 && ( 588 | <> 589 | {/* Red edge overlay */} 590 |
610 | 611 |
630 | CAN'T FIND USER 631 |
639 | Please center your face in the camera frame 640 |
641 |
642 | 643 | )} 644 | 645 | {/* Tilt instruction popup - dismisses on head movement or after 8s */} 646 | {showTiltInstruction && isWebGPUSupported && ( 647 |
648 |
649 | Tilt your head 650 |
651 |
652 | )} 653 | 654 | {error &&
{error}
} 655 | 656 | )} 657 |
658 | ); 659 | } --------------------------------------------------------------------------------