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 | 
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.
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 |
537 |
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 |