SculptForge
ZBrush-style sculpting for VFX, animation and games. VFX Reference Platform CY2026 and CY2027 builds, OpenUSD, a Python API and Send to Unreal. Free built-in AI: describe a brush and get it, or turn a
Head-tracking parallax 3D display engine using webcam-based eye tracking
Translation pending
DesignTypeScript · HTMLGitHub @jasondecampOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗Head-tracking parallax 3D display engine that creates the illusion of depth on a flat screen. Move your head and the screen becomes a window into a virtual 3D space. The view can be driven by webcam eye tracking, the pointer, page scroll, or device tilt, and can switch between them live.
Inspired by Johnny Lee's Wii Remote head tracking project, rebuilt for the browser using MediaPipe face tracking and modern web APIs.
Live demos: ethereal.dev
An eye position (where the viewer's eyes are relative to the screen) drives the projection. It comes from a source: the pointer, the page's scroll position, device tilt, or a webcam (see Sources). With the webcam, MediaPipe tracks the viewer's eye position in real time using MediaPipe Face Mesh. The position drives an off-axis (asymmetric) perspective projection that shifts based on where the viewer is relative to the screen. This makes the screen behave like a physical window — objects behind the screen recede as you move, objects in front pop out.
Two rendering backends are supported:
perspective and translateZ()The package isn't published to npm yet. Install it straight from GitHub:
npm install github:jasondecamp/desktop-vr#semver:^0.3.0
The #semver: range picks the newest matching release tag. npm clones the repo and runs the prepare script, which builds dist/ on install. If your npm version asks you to approve install scripts, allow the build for parallax-display (npm install-scripts approve parallax-display). Without it, the package installs with no built files.
All peer dependencies are optional, depending on what you use:
# Only for webcam tracking (FaceSource, or an engine with no source)
npm install @mediapipe/[email protected]
# Optional (only for parallax-display/three)
npm install three
# Optional (only for parallax-display/react)
npm install react
The library is split into three entry points to keep your bundle lean:
parallax-display — Core (no Three.js or React dependency)Includes the engine, CSS adapter, sources, face tracking, projection math, filters, and the CalibrationPanel (DOM-only UI).
import {
ParallaxEngine,
CSSAdapter,
PointerSource,
ScrollSource,
OrientationSource,
FaceSource,
CalibrationPanel,
FaceTracker,
CoordinateMapper,
OneEuroFilter,
EMAFilter,
computeOffAxisFrustum,
screenFromViewport,
} from 'parallax-display';
parallax-display/three — Three.js adapter + UI overlaysIncludes everything from core, plus the Three.js adapter and all Three.js-dependent UI components (CalibrationOverlay, DiagnosticOverlay, GridRoom).
import {
// Everything from core, plus:
ThreeJSAdapter,
CalibrationOverlay,
DiagnosticOverlay,
GridRoom,
} from 'parallax-display/three';
parallax-display/react — React hooksIncludes everything from core, plus React hooks for easy integration.
import {
// Everything from core, plus:
useParallaxCSS,
useParallaxEngine,
} from 'parallax-display/react';
A source produces eye positions for the engine. Pass one as source, and switch live with engine.setSource():
import { ParallaxEngine, CSSAdapter, PointerSource, FaceSource } from 'parallax-display';
const engine = new ParallaxEngine({
adapter: new CSSAdapter({ container }),
source: new PointerSource(), // no permission prompt
});
await engine.start();
// Later, from a click: opt in to the webcam
cameraButton.onclick = () =>
engine.setSource(new FaceSource()).catch((err) => showError(err.message));
With no source, the engine uses the webcam (FaceSource) built from the tracking and calibration options, exactly as in v0.2.
| Source | Input | Permission | Notes |
|---|---|---|---|
PointerSource | Mouse or trackpad over the window | None | Touch is ignored (dragging is scrolling) |
ScrollSource | Scroll position of an element | None | Pointer is ignored |
OrientationSource | Device tilt | iOS motion prompt | start() / setSource() must run in a user gesture |
FaceSource | Webcam + MediaPipe | Camera prompt | MediaPipe is loaded on first start |
Switching. setSource() starts the new source while the current one keeps driving the view (so the pointer still works while the camera opens), then blends from the current view to the new source over blendMs (default 500, eased), so the view never jumps. If the new source fails to start (permission denied, no sensor), the current one stays and the promise rejects. The previous source is destroyed, which turns the camera off; sources can be started again, so you can pass one back later.
Losing tracking. When a source loses tracking (no face in frame), the view holds where it is and onTrackingLost fires. When tracking returns, the view blends back from where it was held.
PointerSourceThe pointer acts as a virtual head at a fixed distance. Moving to the viewport edge leans the eye toward that side of the screen.
const pointer = new PointerSource({
distance: 0.6, // meters from the screen (EyePosition.z)
range: 0.75, // viewport edge = 75% of the screen's half-width (1.0 puts the vanishing point under the cursor)
restOnLeave: true, // ease back to center when the pointer leaves the window
easeMs: 400, // duration of that ease, and of easing in on entry
});
pointer.setDistance(0.7); // e.g. a slight dolly during a transition; applied immediately
The engine passes its screen size to the source, so range stays proportional to the viewport after resizes and PPI changes.
ScrollSourceThe scroll position of an element acts as a virtual head that rises and falls with the page: at the top of the content the eye is raised, at the bottom it's lowered. Scrolling the front layer slides the depth layers behind it. Built for layered UIs where each layer scrolls inside a fixed 3D container:
const scroll = new ScrollSource({
element: frontLayer, // the scroll container to follow
distance: 0.6,
range: 0.5, // top/bottom = 50% of the screen's half-height
});
// When a different layer comes to the front:
scroll.setElement(newFrontLayer); // eases to the new layer's scroll position
Content that doesn't scroll sits at center. Horizontal position stays centered.
OrientationSourcePhone or tablet tilt, measured from how the device was held when it started (recenter() resets that). On iOS, starting it shows the motion permission prompt, so call start() or setSource() directly from a tap:
tiltButton.onclick = () =>
engine.setSource(new OrientationSource({ maxTiltDegrees: 20 }))
.catch((err) => showError(err.message)); // e.g. "Motion and orientation access was denied"
FaceSourceWebcam head tracking. Options are the tracking options plus calibration and the MediaPipe asset locations:
new FaceSource({
smoothing: 'one-euro',
wasmPath: '/mediapipe/wasm', // default: jsDelivr, pinned to MEDIAPIPE_VERSION
modelPath: '/mediapipe/face_landmarker.task', // default: Google's public model bucket
});
@mediapipe/tasks-vision is imported dynamically the first time a face source starts. The core bundle doesn't include it, and pages that never use the camera never download it.MEDIAPIPE_VERSION (0.10.34), which must match the installed @mediapipe/tasks-vision (the peer dependency is pinned to it). To use another version, or to self-host, set wasmPath to WASM files from the version you have installed.CalibrationConfig defaults. The calibration overlay and panel are optional developer tools.Importing any entry point (parallax-display, /three, /react) under Node is safe: nothing touches window, document, or navigator at import time or in constructors, and the React hooks only create the engine in an effect. Pages that import the package can be prerendered.
Add head-tracked parallax to any Three.js scene in ~10 lines:
import * as THREE from 'three';
import { ParallaxEngine, ThreeJSAdapter, screenFromViewport } from 'parallax-display/three';
// Your existing Three.js scene
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.01, 100);
// Add parallax — screen dimensions auto-computed from viewport
const screen = screenFromViewport();
const adapter = new ThreeJSAdapter({ camera, screen });
const engine = new ParallaxEngine({ adapter });
await engine.start(); // requests camera permission
// Your render loop — no changes needed, the engine updates the camera automatically
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
// Handle resize
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
engine.updateScreenFromViewport();
});
Scene coordinate system:
z = 0 is the screen planeAdd depth to any webpage without Three.js:
import { ParallaxEngine, CSSAdapter } from 'parallax-display';
// Screen dimensions are auto-computed from the viewport when omitted
const container = document.getElementById('scene');
const adapter = new CSSAdapter({ container });
const engine = new ParallaxEngine({ adapter });
await engine.start();
Child elements use transform: translateZ() for depth layers:
<div id="scene">
<div style="transform: translateZ(-200px)">Behind the screen</div>
<div style="transform: translateZ(0px)">At screen plane</div>
<div style="transform: translateZ(100px)">Pops forward</div>
</div>
Important CSS notes:
transform-style: preserve-3d (the adapter sets this automatically)backdrop-filter and opacity on depth-layered children — they break preserve-3d z-sortingopacity, and solid backgrounds instead of backdrop-filter: blur()The calibration overlay guides users through a two-step process to establish their natural viewing position:
import {
ParallaxEngine, ThreeJSAdapter, CalibrationOverlay, screenFromViewport,
} from 'parallax-display/three';
const screen = screenFromViewport();
const adapter = new ThreeJSAdapter({ camera, screen });
const engine = new ParallaxEngine({ adapter });
// Auto-launches after engine.start(), re-invokable with 'c' key
const calibration = new CalibrationOverlay({
engine,
autoStart: true, // launch automatically (default)
triggerKey: 'c', // re-open with this key (default)
onComplete: (offset) => console.log('Calibrated:', offset),
onDismiss: () => {
// Show your scene after calibration closes (complete or skipped)
renderer.domElement.style.visibility = 'visible';
},
});
await engine.start();
A collapsible side panel with live controls, camera preview with landmark visualization, and a PPI ruler:
import { CalibrationPanel } from 'parallax-display';
const panel = new CalibrationPanel({
engine,
toggleKey: '`', // backtick to toggle (default)
startCollapsed: true, // start hidden (default: false)
});
The panel provides runtime controls for:
Verify tracking accuracy with three built-in tests:
import { DiagnosticOverlay } from 'parallax-display/three';
const diagnostics = new DiagnosticOverlay({
engine,
triggerKey: 'd', // default
});
Press d to open. Three tests:
A viewport-anchored grid room that creates the "looking into a box" effect:
import { GridRoom, screenFromViewport } from 'parallax-display/three';
const screen = screenFromViewport();
const room = new GridRoom(screen, {
depth: 0.60, // how far back the grid extends (meters)
gridSpacing: 0.053, // distance between grid lines
lineWidth: 1.5, // line weight in pixels
showBackWall: false, // open-ended or closed box
wallStyle: 'grid', // 'grid' (wireframe) or 'solid' (lit mesh)
});
scene.add(room.getGroup());
// On resize — rebuild to match new viewport edges
window.addEventListener('resize', () => {
room.updateScreen(engine.getScreenConfig());
room.updateResolution();
});
// When PPI or screen changes at runtime
const engine = new ParallaxEngine({
adapter,
onScreenChange: (s) => room.updateScreen(s),
});
Putting it all together — a complete Three.js app with all amenities:
import * as THREE from 'three';
import {
ParallaxEngine, ThreeJSAdapter, CalibrationPanel,
CalibrationOverlay, DiagnosticOverlay, GridRoom, screenFromViewport,
} from 'parallax-display/three';
// Scene
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.domElement.style.visibility = 'hidden'; // hide until calibrated
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.01, 100);
// Grid room
const screen = screenFromViewport();
const room = new GridRoom(screen, { depth: 0.60, gridSpacing: 0.053 });
scene.add(room.getGroup());
// Lighting
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
// Add your scene content here (z=0 is the screen plane)
const box = new THREE.Mesh(
new THREE.BoxGeometry(0.05, 0.05, 0.05),
new THREE.MeshStandardMaterial({ color: 0x44aaff }),
);
box.position.set(0, 0, -0.10); // 10cm behind screen
scene.add(box);
// Engine — auto-computes screen from viewport
const adapter = new ThreeJSAdapter({ camera, screen });
const engine = new ParallaxEngine({
adapter,
onScreenChange: (s) => room.updateScreen(s),
});
// UI
new CalibrationPanel({ engine, startCollapsed: true });
new CalibrationOverlay({
engine,
onDismiss: () => { renderer.domElement.style.visibility = 'visible'; },
});
new DiagnosticOverlay({ engine });
// Start
await engine.start();
// Render loop
(function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
})();
// Resize
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
engine.updateScreenFromViewport();
room.rebuild();
room.updateResolution();
});
The useParallaxCSS hook handles all setup, teardown, and resize for you:
import { useRef } from 'react';
import { useParallaxCSS } from 'parallax-display/react';
function ParallaxScene() {
const containerRef = useRef<HTMLDivElement>(null);
const { tracking, eyePosition } = useParallaxCSS(containerRef, {
sensitivity: 1.2,
smoothing: 'one-euro',
});
return (
<div ref={containerRef}>
<div style={{ transform: 'translateZ(-100px)' }}>Background layer</div>
<div style={{ transform: 'translateZ(0px)' }}>Screen plane</div>
<div style={{ transform: 'translateZ(80px)' }}>Foreground layer</div>
{tracking && <p>Tracking at z={eyePosition?.z.toFixed(2)}m</p>}
</div>
);
}
useParallaxCSS options:
| Option | Default | Description |
|---|---|---|
sensitivity | 1.0 | CSS parallax intensity multiplier |
ppi | 96 | CSS pixels per physical inch |
smoothing | 'one-euro' | Filter type (webcam only) |
source | webcam | Initial EyeSource; change it later with setSource |
blendMs | 500 | Blend duration when switching sources |
autoStart | see below | Start tracking on mount |
onTrack | — | Callback each frame with EyePosition |
onTrackingLost | — | Callback when face not detected |
onScreenChange | — | Callback when screen config changes |
autoStart defaults to true for PointerSource and ScrollSource (and when no source is given, as in v0.2), and to false for FaceSource and OrientationSource, which show a permission prompt and should start from a deliberate start() or setSource() call.
source is read on mount. Create it once (for example useState(() => new PointerSource())) rather than inline on every render.
Returns:
| Field | Type | Description |
|---|---|---|
engine | ParallaxEngine | null | Engine instance for advanced control |
tracking | boolean | Whether tracking is active |
eyePosition | EyePosition | null | Current eye position (updates each frame) |
start | () => Promise<void> | Start tracking manually |
stop | () => void | Stop tracking |
setSource | (source: EyeSource) => Promise<void> | Switch sources with a blend; rejects if the new one can't start |
For Three.js (works with @react-three/fiber or vanilla Three.js in React), use useParallaxEngine with a ThreeJSAdapter:
import { useRef, useMemo, useEffect } from 'react';
import { useParallaxEngine } from 'parallax-display/react';
import { ThreeJSAdapter, CalibrationOverlay, screenFromViewport } from 'parallax-display/three';
import * as THREE from 'three';
function ThreeScene() {
const mountRef = useRef<HTMLDivElement>(null);
// Set up Three.js scene (once)
const { camera, renderer, scene } = useMemo(() => {
const renderer = new THREE.WebGLRenderer({ antialias: true });
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.01, 100);
const scene = new THREE.Scene();
return { camera, renderer, scene };
}, []);
const adapter = useMemo(
() => new ThreeJSAdapter({ camera, screen: screenFromViewport() }),
[camera],
);
const { tracking } = useParallaxEngine({ adapter });
useEffect(() => {
const el = mountRef.current;
if (!el) return;
renderer.setSize(innerWidth, innerHeight);
el.appendChild(renderer.domElement);
// Add your scene content
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const box = new THREE.Mesh(
new THREE.BoxGeometry(0.05, 0.05, 0.05),
new THREE.MeshStandardMaterial({ color: 0x44aaff }),
);
box.position.z = -0.10;
scene.add(box);
const animate = () => {
requestAnimationFrame(animate);
renderer.render(scene, camera);
};
animate();
return () => { renderer.dispose(); };
}, [renderer, scene, camera]);
return <div ref={mountRef} />;
}
useParallaxEngine accepts the same options as ParallaxEngineConfig (including source) plus autoStart (same defaults as above). It returns the same UseParallaxReturn shape as useParallaxCSS.
Source: Pointer | Scroll | Orientation | Face
↓
Camera Feed → MediaPipe Face Mesh → 8 Facial Landmarks
↓
Coordinate Mapper (camera pixels → meters relative to screen)
↓
One-Euro Filter (adaptive smoothing)
↓
Engine (blends between sources, holds on tracking loss)
↓
Off-Axis Frustum Computation
↓
Adapter → Three.js (projection matrix) or CSS (perspective-origin)
| Landmark | ID | Role |
|---|---|---|
| Left/Right iris | 468, 473 | Precise eye position (40% weight) |
| Inner canthi | 133, 362 | Stable anchors, blink-resistant (60% weight) |
| Outer canthi | 33, 263 | Fallback IPD for depth estimation |
| Nose bridge | 168 | Vertical reference |
| Nose tip | 1 | Vertical reference |
Position tracking blends inner canthi (60%) with iris centers (40%) for stability without sacrificing precision. Depth is estimated from inter-pupillary distance.
c)The offset between the two captures becomes the "zero origin" for all tracking, so the parallax is centered on your natural sitting position.
`)The system auto-computes screen dimensions from viewport pixels using CSS PPI (default: 96). For accuracy, open the calibration panel and drag the ruler handle until the blue line matches exactly 1 inch on a physical ruler. This corrects all screen dimension calculations.
d)Three verification tests:
interface ParallaxEngineConfig {
adapter: ThreeJSAdapter | CSSAdapter;
screen?: Partial<ScreenConfig>; // auto-computed from viewport if omitted
source?: EyeSource; // default: FaceSource from tracking + calibration
calibration?: Partial<CalibrationConfig>;
tracking?: { maxFps?, facingMode?, smoothing?, wasmPath?, modelPath? };
ppi?: number; // CSS pixels per inch (default: 96)
blendMs?: number; // source switch / reacquire blend (default: 500)
onTrack?: (position: EyePosition) => void;
onTrackingLost?: () => void; // the view holds until tracking returns
onScreenChange?: (screen: ScreenConfig) => void;
}
| Method | Description |
|---|---|
start() | Start the source (for the webcam: camera + MediaPipe) |
stop() | Pause tracking |
destroy() | Full cleanup |
setSource(source) | Switch sources live, blending over blendMs |
getSource() | The active source (e.g. to call setDistance()) |
setPpi(ppi) | Update PPI and recompute screen dimensions |
updateCalibration(updates) | Adjust camera FOV, IPD, offset, mirror |
updateScreen(updates) | Set screen dimensions directly |
updateScreenFromViewport() | Recompute screen from viewport + PPI |
setEyeOffset(offset) | Set the tracked position zero-origin |
setSmoothing(type) | Switch filter: 'none', 'ema', 'one-euro' (webcam only) |
getEyePosition() | Current adjusted eye position |
getRawEyePosition() | Position before offset applied |
getRawFaceData() | All tracked landmarks + blink state (webcam only) |
getScreenConfig() | Current physical screen dimensions |
getAdapter() | The active adapter instance |
getFilter() | The active smoothing filter instance |
getPpi() | Current PPI value |
interface EyeSource {
start(emit: (position: EyePosition) => void, lost: () => void): Promise<void>;
stop(): void;
destroy(): void;
updateScreen?(screen: ScreenConfig): void; // receives the engine's screen size
}
interface EyePosition {
x: number; // meters, positive = right of screen center
y: number; // meters, positive = above screen center
z: number; // meters, distance from screen (always positive)
}
interface ScreenConfig {
widthMeters: number; // physical viewport width in meters
heightMeters: number; // physical viewport height in meters
}
interface CalibrationConfig {
realIPD: number; // inter-pupillary distance (default: 0.063m)
cameraFovDegrees: number; // webcam horizontal FOV (default: 60)
cameraOffsetY: number; // camera height above screen center (default: 0.02m)
mirrorX: boolean; // flip X for front camera (default: true)
}
function screenFromViewport(ppi?: number): ScreenConfig
Computes physical screen dimensions from window.innerWidth/Height divided by PPI, converted to meters. Default PPI is 96 (CSS standard). Use the calibration panel's ruler tool for accurate PPI.
Both adapters implement:
update(eye: EyePosition): void
updateScreen(screen: ScreenConfig): void
ThreeJSAdapter — sets a custom off-axis projection matrix on the camera each frame.
new ThreeJSAdapter({
camera: THREE.PerspectiveCamera,
screen: ScreenConfig,
near?: number, // clipping plane (default: 0.05)
far?: number, // clipping plane (default: 100)
})
CSSAdapter — sets perspective and perspective-origin on a container element.
new CSSAdapter({
container: HTMLElement,
screen?: ScreenConfig, // auto-computed from viewport if omitted
ppi?: number, // used when screen is omitted (default: 96)
sensitivity?: number, // parallax intensity multiplier (default: 1.0)
})
Additional methods: setSensitivity(value), getSensitivity(), and setScrollOffsetY(px): if the 3D container scrolls inside an element rather than the window, pass that element's scrollTop so the vanishing point stays centered on screen.
| Component | Import | Trigger | Purpose |
|---|---|---|---|
CalibrationPanel | parallax-display | ` key | Side panel with controls + camera preview |
CalibrationOverlay | parallax-display/three | c key | Full-screen guided calibration |
DiagnosticOverlay | parallax-display/three | d key | Tracking accuracy verification |
GridRoom | parallax-display/three | — | Viewport-anchored grid room background |
One-Euro Filter (default) — adapts smoothing based on movement speed:
minCutoff: 3.0 — base responsiveness (higher = less lag)beta: 0.05 — speed sensitivity (higher = faster response to movement)EMA Filter — simple exponential moving average:
alpha: 0.3 — blend factor (higher = more responsive, more jitter)Both can be tuned at runtime via the calibration panel or engine.getFilter().updateParams().
src/
index.ts # core entry point (no Three.js or React dependency)
three.ts # Three.js entry point (re-exports core + adds Three.js deps)
react.ts # React hooks entry point (re-exports core + adds hooks)
core/
ParallaxEngine.ts # orchestrator: runs a source, blends between sources
transition.ts # eased blends
sources/
types.ts # EyeSource interface
PointerSource.ts # mouse / trackpad
ScrollSource.ts # scroll position of an element
OrientationSource.ts # device tilt
FaceSource.ts # webcam (wraps FaceTracker)
tracking/
FaceTracker.ts # webcam + MediaPipe (loaded on demand)
mediapipe.ts # MEDIAPIPE_VERSION and default asset URLs
filters.ts # One-Euro and EMA smoothing
types.ts # EyePosition, RawFaceData, TrackingConfig
projection/
frustum.ts # off-axis perspective math
coordinateMapper.ts # camera pixels → world meters
screenFromViewport.ts # viewport → physical screen dimensions
types.ts # Frustum, ScreenConfig, CalibrationConfig
adapters/
ThreeJSAdapter.ts # Three.js projection matrix
CSSAdapter.ts # CSS perspective + perspective-origin
ui/
CalibrationOverlay.ts # guided two-step calibration (Three.js)
CalibrationPanel.ts # side panel with controls (DOM-only)
DiagnosticOverlay.ts # tracking verification tests (Three.js)
gridRoom.ts # viewport-anchored grid background (Three.js)
examples/
three-demo/ # 3D targets in a grid room
css-demo/ # depth-layered cards and shapes (no Three.js)
ui-controls-demo/ # spatial navigation through stacked depth layers; pointer, scroll, camera, tilt
test/ # Vitest suite (jsdom, plus Node for SSR)
npm run dev # start dev server (serves examples)
npm run build # build library (ESM + CJS) + type declarations
npm run build:site # build demo site for deployment (multi-page app)
npm run preview # preview production site build
npm run typecheck # type-check without emitting
npm test # run the Vitest suite
The demo site builds as a multi-page app (separate from the library build) with a landing page linking to all three demos.
The live demos at ethereal.dev are served by the Vercel project desktop-vr (team DesktopVR), which is connected to this GitHub repo:
main deploys to ethereal.dev. main is protected, so changes land through pull requests rather than direct pushes.The vercel.json configures:
npm run build:sitedist-siteTo deploy manually from a local checkout (for example, to roll forward without a PR), run vercel --prod from the repo root. This needs the Vercel CLI, logged in with access to the DesktopVR team.
vercel.json, so there's nothing to configureAny static hosting that can run a build command works:
npm run build:site
# serve the dist-site/ directory
Note: webcam tracking requires HTTPS. Most hosting providers (Vercel, Netlify, GitHub Pages) provide this by default.
Optional peer dependencies:
0.10.34 — face landmark detection (only for webcam tracking)parallax-display/three)parallax-display/react)MIT