In active development. API and architecture are subject to breaking changes. Not recommended for production use yet.
A projection mapping library for Three.js.
The main use case is to match your Three.js camera to the physical projector's real-world position and optics using ProjectorCamera, so the virtual scene aligns with the physical surface, which can then be fine-tuned with warping. It accepts any THREE.WebGLRenderTarget or THREE.Texture, so it works with 3D scenes, canvas textures, videos, or any other source. See the examples for 2D content usage.
Pass any THREE.Texture to ProjectionMapper and it gives you interactive control points to warp and align the output to match your projection surface. All calibration data is saved automatically so your setup persists across sessions.
Texture source → ProjectionMapper → Projector
↕
Drag control points
to align on surface
The texture source can be a 3D scene rendered into a WebGLRenderTarget, a plain HTML canvas or p5.js sketch wrapped with THREE.CanvasTexture, a static image, or anything else that produces a THREE.Texture.
- Corner control points: 4 outer points for broad perspective correction
- Grid control points: configurable inner grid for fine-grained surface warping (Bilinear or Bicubic Warping)
- Keyboard warp point control: select a warp point and nudge it with the arrow keys, including past the window edge, where the pointer cannot follow. Off-screen warp points keep a marker on the edge that points at them
- Multiple surfaces: several independently warped surfaces in one output. Click a surface on the canvas to select it, then drag its body to move it
- Per-surface everything: each surface owns its warp, resolution, source texture, crop, image adjustments and masks
- Polygon mask: interactive closed polygon evaluated as an SDF in the fragment shader. Click edges to insert nodes, double-click to remove, with feather and invert support
- Image adjustments: contrast, hue, gamma, saturation, blacks/whites, ACES tonemapping (per surface, for matching projectors)
- Edge feather: per-surface feather mask for blending overlapping projections
- Testcard overlay: procedural pattern (resolution- and aspect-independent)
- GUI: Tweakpane based UI included
- Auto-save: all settings saved to
localStorage, restored on reload - Multi-window mode: separate controller and projector windows, synced in real time (no server needed)
- Hardware optics support: camera class for physical throw ratio and lens shift correction
npm install github:bhoffmann93/three-projection-mapperThe core idea: Render your scene to a WebGLRenderTarget, then hand its texture to ProjectionMapper.
Resolution & Quality: While you should at least match your projector's native resolution, it is highly recommended to oversample the RenderTarget (e.g., 1.5x or 2x). This prevents aliasing artifacts and maintains sharpness when the texture is stretched or compressed during the warping process.
In your animation loop, simply call mapper.render() as the final step.
import * as THREE from 'three';
import { ProjectionMapper, ProjectionMapperGUI } from 'three-projection-mapper';
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const projectorRes = { width: 1280, height: 800 };
const aspect = projectorRes.width / projectorRes.height;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000);
scene.add(new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial()));
// Render your scene off-screen into target
const oversampling = 1.5;
const renderTarget = new THREE.WebGLRenderTarget(projectorRes.width * oversampling, projectorRes.height * oversampling);
const mapper = new ProjectionMapper(renderer, renderTarget.texture);
const gui = new ProjectionMapperGUI(mapper, { title: 'Projection Mapper', anchor: 'left' });
function animate() {
requestAnimationFrame(animate);
// 1. Render your scene into the render target
renderer.setRenderTarget(renderTarget);
renderer.render(scene, camera);
// 2. Render the warped output to screen
renderer.setRenderTarget(null);
mapper.render();
}
animate();
// Panel hotkeys are not built into the library, so wire them yourself. The warp
// point keys (arrows, Tab, Esc) are built in and need no wiring.
const hint = document.createElement('div');
hint.style.cssText =
'position:fixed;bottom:16px;left:16px;color:rgba(255,255,255,0.5);font:12px/1.6 monospace;pointer-events:none';
hint.innerHTML = '<span>G</span> toggle UI<br><span>T</span> test card<br><span>W</span> warp controls';
document.body.appendChild(hint);
window.addEventListener('keydown', (e) => {
if (e.key === 'g' || e.key === 'p') gui.toggle();
if (e.key === 't') gui.toggleTestCard();
if (e.key === 'w') gui.toggleWarpUI();
});Canvas / p5.js: If you're drawing with p5.js or a plain 2D canvas instead of a 3D scene, skip the render target and wrap the canvas element directly with
new THREE.CanvasTexture(canvasEl)and setcanvasTexture.needsUpdate = trueeach frame. See/examples/p5-canvasfor a working example.
A mapper can hold several independently warped surfaces. Each one owns its warp, resolution, source texture, crop, image adjustments and masks.
Click a surface on the canvas to select it, then drag its body to move it. Only the active surface shows warp points, the others stay as dimmed outlines.
Breaking change.
multiSurfacenow defaults tofalse, because one mesh showing one scene is what most apps want. An app that callsaddSurface()without passingmultiSurface: truewill have the call refused, and extra surfaces already in storage ignored rather than restored. The calibration is not deleted, so adding the flag brings it back.
const mapper = new ProjectionMapper(renderer, texture, {
appId: 'my-app',
multiSurface: true, // off by default, so this is what opts in
resolution: { width: 1920, height: 1080 }, // the output canvas
surfaceResolution: { width: 1080, height: 1080 }, // default surface shape
});
// A surface that overrides the default shape
mapper.addSurface({ resolution: { width: 1080, height: 1920 } });
// Change the canvas later. Surfaces keep their own shapes and warps
mapper.setOutputResolution(1080, 1920);With several surfaces the controller draws the canvas as a dashed boundary so
you can see what is actually projected. It previews at zoom < 1, deliberately
showing more than the output. Anything outside the dashed frame is not
projected.
The frame follows multiSurface, because with one surface it mostly reads as
chrome in a host app that frames its own output. Pass canvasBoundary: true to
get it back, which is worth doing while calibrating a single surface. Warp a
corner inwards and the quad stops marking the canvas edge, leaving nothing to
say where the projector stops. canvasBoundary: false suppresses it with
several surfaces. It hides only the frame, never the surface outlines.
These are separate on purpose, and mixing them up is the main way multi-surface layouts go wrong:
ProjectionMapper
resolution → the output canvas: the dashed frame on the controller,
and the size the projector window opens at
surfaceResolution → the shape given to surfaces that do not declare their own
(buffer) → the source texture, entirely the app's business
| is | multi-surface example | |
|---|---|---|
resolution |
the output canvas, what the projector frames | 1920×1080 |
surfaceResolution |
default shape of a surface | 1080×1080 |
| buffer | pixel size of the source texture, set by your render target | 2160×1080 |
uvRect |
which slice of the buffer a surface samples | 0.5, 0 → 0.5, 1 |
The library never creates the buffer. You do, at whatever size your pipeline needs, and it is unrelated to either resolution above.
resolution is really an aspect declaration. Only the ratio is used: a plane
is WORLD_PLANE_HEIGHT tall with an aspect-correct width, so 1920×1080 and
3840×2160 behave identically. The absolute numbers matter in exactly one place,
the size the projector window first opens at. Resizing that window scales the
output. Giving it a different aspect letterboxes rather than distorting, because
the camera contains the canvas on whichever axis is tighter.
A surface shows undistorted content when its resolution matches the region it samples:
uvRect.scaleX / uvRect.scaleY = surfaceAspect / bufferAspect
Set resolution alone and surfaces inherit it, which is right when a surface
fills the output. Set surfaceResolution too when they should not, because an atlas
layout wants the region's shape, not the canvas's.
Click a corner or grid warp point to select it. It brightens and grows. Then move it with the keyboard:
| Key | Action |
|---|---|
← ↑ ↓ → |
nudge the selected warp point by one screen pixel |
Shift + arrow |
nudge by ten |
Tab / Shift+Tab |
step to the next warp point, within its own group |
Esc |
deselect the warp point |
Steps are measured in screen pixels, so a nudge covers the same visible distance at any zoom.
This exists because a corner sometimes has to end up outside the window, and dragging cannot put it there, because the pointer runs out of screen first. Where the controller window is also the output, zooming out to make room is not an option either, since the view is exactly what the projector shows.
Warp points pushed past the edge keep a marker pinned to the window edge pointing at where they went. Clicking one selects that warp point, so the arrow keys can walk it back without it ever being visible. Grid points get markers too, since dragging a corner pulls the grid through the homography with it.
These four are the only keys the library claims. The panel hotkeys are yours to
wire. They stand down while a GUI input has focus, when Meta, Ctrl or Alt
is held, on projector windows, and while warp controls are hidden. Arrows also
stay free until a warp point is selected, so they reach your scene until the user
clicks a handle.
Tab is the exception. While handles are visible it is swallowed page-wide, even
with nothing selected, because stepping to a point that is off screen is the one
way to reach it. If the mapper is embedded in a larger UI with its own focus
order, hand the keys back:
mapper.setKeyboardEnabled(false); // handles stay draggable
// Rebind to your own keys. These are the same calls the built-ins make.
// Nudge through the surface rather than the warper, because the surface
// reports the move, and a projector window that is not told keeps the old warp.
const surface = mapper.getActiveSurface();
surface.nudgeSelectedHandle(dx, dy); // world units
const warper = surface.getWarper();
warper.selectNextHandle(1); // or -1 to step backwards
warper.clearSelectedHandle();
warper.commitHandlePositions(); // persist once the key burst ends, not per pressDragging the corners does placement and perspective in one gesture, which is what calibration wants. These cover what dragging cannot express: exact sizes, equal sizes, programmatic layout:
| Method | Warp | Use |
|---|---|---|
translate(dx, dy) / setPosition(x, y) |
kept | move the quad |
scale(factorX, factorY?) |
kept | resize about the centroid |
setWarpedSize(width, height) |
kept | resize to an exact world size |
setBounds(x, y, width, height) |
discarded | lay out as a rectangle, before calibrating |
scale and setWarpedSize multiply each corner's offset from the centre, so a
calibrated perspective survives being resized. setBounds replaces the quad
outright. Reach for it when arranging surfaces inside the output canvas, not
after aligning one to a physical object.
// give two surfaces exactly the same size
const { width, height } = surfaceA.getWarpedSize();
surfaceB.setWarpedSize(width, height);
surface.scale(1.05); // 5% larger, perspective intactSurfaces are drawn in list order, last on top, and clicking picks whatever is visible, and the picker follows the same order. Overlap matters for edge blending, so it is set explicitly rather than left to depth sorting between coplanar surfaces:
mapper.moveSurface(id, +1); // towards the front
mapper.moveSurface(id, -1); // towards the back
mapper.getSurfaceIndex(id); // where it currently sitsThe order persists with the surface list, and the built-in pane exposes it as the chevron buttons in the surfaces row: Add, back, forward, then remove at the far end.
A surface can be scaled to any shape, which stretches whatever it samples. When the content is generated, say a shader drawing into your buffer, it can compensate instead, if it knows the shape it will land on:
// each frame, before rendering your buffer
material.uniforms.uSurfaceAspect.value = surface.getWarpedAspect();// a circle that stays round however the surface is scaled
vec2 p = (uv - 0.5) * vec2(uSurfaceAspect, 1.0);
float circle = step(length(p), 0.4);getWarpedSize() returns the same measurement in world units. Both describe the
surface as drawn, scaling and warping included, averaged over opposite
edges of the quad, unlike getResolution(), which is its undeformed shape.
Read it per frame rather than on a callback: dragging a corner changes the size continuously, and no notification fires for it.
Each surface owns its own texture uniform, so these are the same model rather than two modes:
// Atlas: one buffer, sliced by uvRect
mapper.setUvRect(0, 0, 0.5, 1, wideSurface.id);
mapper.setUvRect(0.5, 0, 0.5, 1, squareSurface.id);
// Per-surface media: this surface ignores the shared buffer entirely
mapper.setTexture(myImageTexture, squareSurface.id);/examples/multi-surface does both at once: two
surfaces slicing one atlas, and a third sampling its own image and taking that
image's shape. It also ships a projector window, showing that only calibration
crosses the channel. Both windows build their own textures.
Two independent questions decide how a mapper behaves, and it is worth answering both deliberately:
outputWindow?: boolean // is this window the projector, or a preview of it?
multiSurface?: boolean // can it hold more than one surface?outputWindow is about whether the view can lie. A controller previews: it pulls
back to show world beyond the output canvas, draws that canvas as a dashed
boundary, and scales the view to whatever size its window happens to be. An
output window cannot do any of that. What it draws is what the projector emits,
so zooming out would shrink the projection, and the window edge already is the
canvas boundary.
outputWindow: true |
controller (default) | |
|---|---|---|
default zoom |
pulled back, or exactly 1 when synced by WindowSync |
pulled back to preview |
| dashed canvas boundary | not drawn, the window edge is it | with multiSurface, or forced |
| move / resize a lone surface | off, opt in with surfaceMove/surfaceScale |
off, it fills the output |
| move / resize with several | on | on |
The four useful combinations. Note that only multiSurface defaults to the
common case. outputWindow does not, so the single window that is itself the
projector, probably the setup you want first, still has to ask for it:
// this window IS the projector, one surface. Align it to a physical object.
// One flag, because outputWindow still defaults to false.
new ProjectionMapper(renderer, texture, { outputWindow: true });
// bare default: one surface, but a controller previewing a projector window.
// Pulled back from the canvas, not an output. No dashed canvas with one surface.
new ProjectionMapper(renderer, texture, {});
// controller arranging several surfaces
new ProjectionMapper(renderer, texture, { multiSurface: true });
// this window is the projector, several surfaces in it
new ProjectionMapper(renderer, texture, { outputWindow: true, multiSurface: true });Projector windows opened through WindowSync need nothing here. They are
receive-only, and the addon already sets their zoom and hides every control.
This is the default, so most apps need to say nothing. addSurface() is refused,
extra surfaces left in storage are ignored rather than restored, and the GUI
drops its surface and crop controls. A single surface also cannot be moved or
scaled: it fills the output, so there is nothing to arrange it against and
resizing only loses pixels. The dashed boundary is off for the same reason.
const mapper = new ProjectionMapper(renderer, texture, {
appId: 'my-app',
outputWindow: true,
});The one case that wants a single surface placed is aligning it to a physical
object, where moving the quad moves light on a wall. That is not implied by
outputWindow, since a full-frame output does not need it either. Ask for it:
const mapper = new ProjectionMapper(renderer, texture, {
appId: 'my-app',
outputWindow: true,
surfaceMove: true, // drag the surface
surfaceScale: true, // and resize it
canvasBoundary: true, // and mark where the projector stops
});Pass multiSurface: true for the atlas case, where several surfaces are arranged
inside one output and each takes its pixels from a region of the input. Those
apps usually want surfaceResolution as well, since a surface no longer fills
the canvas.
For real installations, you'll typically want two separate browser windows:
- Controller window (your laptop): GUI, drag controls, preview
- Projector window (your projector display): output only, no controls
State syncs automatically between them via the browser's BroadcastChannel API, with no server or network needed.
┌─────────────────────────┐ ┌─────────────────────────┐
│ Controller Window │◄── local sync ────►│ Projector Window │
├─────────────────────────┤ ├─────────────────────────┤
│ • Tweakpane GUI │ warp points, │ • No GUI │
│ • Drag controls │ settings, etc. │ • Drag disabled │
│ • Testcard toggle │ │ • Fullscreen output │
│ • previews at zoom < 1, │ │ • frames the canvas │
│ canvas drawn dashed │ │ exactly, at zoom 1 │
└─────────────────────────┘ └─────────────────────────┘
Only calibration crosses the channel, never pixels. A THREE.Texture cannot
be sent over a BroadcastChannel, so both windows build their own: they run the
same scene class, and an app showing media loads its own copy in each window and
binds it to the agreed surface id.
Give both windows the same resolutions and the same appId. They share
localStorage, so the projector restores calibration on boot. If the two
disagree about the output canvas or the default surface shape, their planes
differ and the projector's output will not match the controller's preview.
Note what appId is scoping here. It is not a multi-window setting. It names the
app, and every app on an origin needs its own, single window or not, because
localStorage is shared by all of them. Two windows of one installation are
still one app, which is why they pass the same id. Two different sketches
served from one origin are two apps, so they need different ids even though
neither has a second window. You can omit it only when yours is the sole app on
that origin.
Put the shared values in one config module both windows import:
// projection.config.ts, imported by controller and projector
export const PROJECTION_CONFIG = {
appId: 'my-installation',
resolution: { width: 1920, height: 1080 }, // output canvas
surfaceResolution: { width: 1080, height: 1080 }, // default surface shape
} as const;The projector window opens at the output resolution's aspect, scaled to fit the
screen, so a 9:16 output opens a portrait window rather than a landscape one.
See /examples/multi-surface for this with several
surfaces, including one that samples its own image instead of the shared buffer.
Step 1: Shared scene class (used in both windows):
// ProjectionScene.ts
import * as THREE from 'three';
export class ProjectionScene {
public readonly scene: THREE.Scene;
public readonly camera: THREE.PerspectiveCamera;
public readonly renderTarget: THREE.WebGLRenderTarget;
private cube: THREE.Mesh;
constructor(config: { width: number; height: number }) {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(75, config.width / config.height, 0.1, 1000);
this.cube = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial());
this.scene.add(this.cube);
this.renderTarget = new THREE.WebGLRenderTarget(config.width, config.height);
}
public animate(): void {
this.cube.rotation.y += 0.01;
}
public render(renderer: THREE.WebGLRenderer): void {
renderer.setRenderTarget(this.renderTarget);
renderer.render(this.scene, this.camera);
}
public getTexture(): THREE.Texture {
return this.renderTarget.texture;
}
}Step 2: Controller window
// controller.ts
import * as THREE from 'three';
import { ProjectionMapper, ProjectionMapperGUI } from 'three-projection-mapper';
import { WindowSync, WINDOW_SYNC_MODE } from 'three-projection-mapper/addons';
import { ProjectionScene } from './ProjectionScene';
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const projectionScene = new ProjectionScene({ width: 1280, height: 800 }); //Projector Resolution
const mapper = new ProjectionMapper(renderer, projectionScene.getTexture());
const sync = new WindowSync(mapper, { mode: WINDOW_SYNC_MODE.CONTROLLER });
const gui = new ProjectionMapperGUI(mapper, {
title: 'Controller',
anchor: 'left',
eventChannel: sync.getEventChannel(),
windowManager: sync.getWindowManager(),
});
// Hotkeys are not built into the library, so wire them yourself:
const hint = document.createElement('div');
hint.style.cssText =
'position:fixed;bottom:16px;left:16px;color:rgba(255,255,255,0.5);font:12px/1.6 monospace;pointer-events:none';
hint.innerHTML =
'<span>G</span> toggle UI<br><span>T</span> test card<br><span>W</span> warp controls<br><span>O</span> open projector';
document.body.appendChild(hint);
window.addEventListener('keydown', (e) => {
if (e.key === 'g' || e.key === 'p') gui.toggle();
if (e.key === 't') gui.toggleTestCard();
if (e.key === 'w') gui.toggleWarpUI();
if (e.key === 'o') sync.openProjectorWindow();
});
function animate() {
requestAnimationFrame(animate);
projectionScene.animate();
projectionScene.render(renderer);
renderer.setRenderTarget(null);
mapper.render();
}
animate();Step 3: Projector window
// projector.ts
import * as THREE from 'three';
import { ProjectionMapper } from 'three-projection-mapper';
import { WindowSync, WINDOW_SYNC_MODE } from 'three-projection-mapper/addons';
import { ProjectionScene } from './ProjectionScene';
const renderer = new THREE.WebGLRenderer();
renderer.setSize(1280, 800);
document.body.appendChild(renderer.domElement);
const projectionScene = new ProjectionScene({ width: 1280, height: 800 });
const mapper = new ProjectionMapper(renderer, projectionScene.getTexture());
const sync = new WindowSync(mapper, { mode: WINDOW_SYNC_MODE.PROJECTOR });
// WindowSync automatically hides controls and disables drag in projector mode
function animate() {
requestAnimationFrame(animate);
projectionScene.animate();
projectionScene.render(renderer);
renderer.setRenderTarget(null);
mapper.render();
}
animate();See the full working example in /examples/multi-window/.
new ProjectionMapper(
renderer: THREE.WebGLRenderer,
inputTexture: THREE.Texture,
config?: ProjectionMapperConfig
)Config options:
interface ProjectionMapperConfig {
resolution?: { width: number; height: number }; // View aspect + default surface resolution
segments?: number; // Mesh density
gridControlPoints?: { x: number; y: number }; // Grid size (auto-calculated if omitted)
antialias?: boolean; // Enable SMAA (default: true)
zoom?: number; // Fill factor, below 1 pulls back to show world beyond the canvas
outputWindow?: boolean; // This window is the projector, not a preview (default: false)
multiSurface?: boolean; // Allow more than one surface (default: false)
// Interaction affordances. Each follows multiSurface unless set, and has a runtime setter.
canvasBoundary?: boolean; // Dashed output boundary on a controller (default: follows multiSurface)
surfaceMove?: boolean; // Select and body-drag a surface (default: follows multiSurface)
surfaceScale?: boolean; // Scale handle on the active surface (default: follows multiSurface)
appId?: string; // Scopes saved calibration, required if several apps share an origin
}
appIdmatters more than it looks. Everything is saved tolocalStorage, which is shared by every app on an origin. Without anappIdtwo apps overwrite each other's surfaces and warp points. This has nothing to do with how many windows you open. One app needs one id however many windows show it, and two apps on one origin need two ids even if each is a single window.
Methods:
| Method | Description |
|---|---|
render() |
Render the warped output |
setTexture(texture, surfaceId?) |
Swap the shared buffer, or one surface's own texture |
getTexture(surfaceId?) |
The shared buffer, or one surface's texture |
setShowTestCard(show) |
Toggle testcard |
setShowControlLines(show) |
Show/hide control line overlay |
resize(width, height) |
Handle window resize |
setControlsVisible(visible) |
Show/hide all control points |
setGridPointsVisible(visible) |
Show/hide grid points |
setCornerPointsVisible(visible) |
Show/hide corner points |
setOutlineVisible(visible) |
Show/hide outline |
setCanvasBoundaryVisible(v) |
Show/hide the dashed frame, outlines untouched |
setSurfaceMoveEnabled(v) |
Enable/disable select and body-drag |
setSurfaceScaleEnabled(v) |
Show/hide the scale handle |
setGridSize(x, y) |
Change grid density (2 to 10) |
setZoom(scale) |
Set fill factor (0 to 1) |
setShouldWarp(enabled) |
Bypass warping (no GUI button, for host apps) |
setCameraOffset(x, y) |
Offset the orthographic camera |
getCameraOffset() |
Get current camera offset |
reset(surfaceId?) |
Reset one surface's warp, or all |
getWarper() |
The active surface's MeshWarper |
dispose() |
Clean up GPU resources |
Surfaces:
| Method | Description |
|---|---|
addSurface({ id?, resolution?, uvRect? }) |
Add a surface and return it, or null without multiSurface: true |
removeSurface(id) |
Remove a surface and its saved calibration |
getSurfaces() / getSurface(id) |
The surface list, or one by id |
getActiveSurface() / setActiveSurface(id) |
The selected surface |
isMultiSurface() |
Whether more than one surface is allowed |
setUvRect(ox, oy, sx, sy, surfaceId?) |
Which slice of the buffer a surface samples |
setImageSettings(settings, surfaceId?) |
Image adjustments for one surface |
setEdgeMask(enabled, feather?, surfaceId?) |
Edge feather for one surface |
onSurfacesChanged / onActiveSurfaceChanged |
Callbacks for host-app UI |
Calibration interface built on Tweakpane.
import { ProjectionMapperGUI } from 'three-projection-mapper';
const gui = new ProjectionMapperGUI(mapper, {
title: 'My Projection',
anchor: 'left', // or 'right'
enableWhiteOut: true, // optional: adds a full-screen white button beside Testcard
});The panel is a flat folder list. Output-wide controls come first, then the
surface selector, then the folders it scopes: Image, Masks and Warp. Everything
below the selector acts on the active surface, and follows canvas selection.
The surface folder appears only with multiSurface: true.
This pane is a calibration harness, not an app panel. It deliberately has no
uv-crop section: choosing which slice of a buffer a surface samples is app work,
and four 0 to 1 sliders express it poorly. The mechanism stays on the mapper
(setUvRect), and UvRectEditor provides a
visual one, or build your own.
gui.toggle(); // show/hide the GUI panel
gui.show();
gui.hide();
gui.toggleTestCard(); // toggle testcard overlay
gui.toggleWhiteOut(); // toggle full-screen white (only meaningful when enableWhiteOut: true)
gui.toggleWarpUI(); // toggle warp control points
gui.collapse();
gui.dispose();
// Hotkeys are not built in, so wire keydown to the public methods yourself:
window.addEventListener('keydown', (e) => {
if (e.key === 'g' || e.key === 'p') gui.toggle();
if (e.key === 't') gui.toggleTestCard();
if (e.key === 'w') gui.toggleWarpUI();
});A camera class that mirrors real projector optics, useful when your 3D scene should match what a physical projector would render.
import { ProjectorCamera } from 'three-projection-mapper';
const camera = new ProjectorCamera(
1.65, // throwRatio: distance-to-width ratio (check your projector's spec sheet)
1.0, // lensShiftY: vertical lens shift (1.0 = 100%)
16 / 10, // aspect ratio
);
camera.position.set(0, 0.5, 2.0); // The Y position is the lens centerParameters:
| Parameter | Description |
|---|---|
throwRatio |
Distance-to-width ratio (typical range: 0.8 to 2.5) |
lensShiftY |
Vertical lens shift as multiplier (1.0 = 100%) |
aspect |
Width / height |
near, far |
Clipping planes |
Multi-window synchronization addon.
import { WindowSync, WINDOW_SYNC_MODE } from 'three-projection-mapper/addons';
// Controller
const sync = new WindowSync(mapper, { mode: WINDOW_SYNC_MODE.CONTROLLER });
sync.openProjectorWindow();
sync.onProjectorReady(() => console.log('Projector connected'));
// Projector
const sync = new WindowSync(mapper, { mode: WINDOW_SYNC_MODE.PROJECTOR });| Method | Description |
|---|---|
openProjectorWindow() |
Open the projector window |
closeProjectorWindow() |
Close the projector window |
onProjectorReady(callback) |
Called when projector connects |
getEventChannel() |
IPC event channel (pass to GUI) |
getWindowManager() |
Window manager (pass to GUI) |
destroy() |
Clean up |
An interactive polygon mask that clips the texture in the fragment shader via a signed distance field. The mask shape is defined in UV space and is independent of the perspective warp.
// Add a polygon mask (starts as a default rectangle)
const mask = mapper.addPolygonMask();
// Editing (via GUI or programmatically)
mapper.setPolygonMaskEnabled(true);
mapper.setPolygonFeather(0.02); // 0.0 = hard edge
mapper.setPolygonInvert(false);
// Reset shape to default rectangle
mapper.resetPolygonMask();
// Remove mask entirely
mapper.removePolygonMask();
// Access current nodes (UV space, read-only)
mapper.getPolygonMask()?.nodes;Editing interactions (when handles are visible):
| Action | Result |
|---|---|
| Click on an edge | Insert node at that position |
| Double-click a handle | Remove node (minimum 3) |
| Drag a handle | Move node |
Direct access to the warp mesh for custom setups.
const warper = mapper.getWarper();
warper.setDragEnabled(false);
warper.setWarpMode(WARP_MODE.bicubic);
warper.setShouldWarp(true);npm start # Dev server at http://localhost:8080
npm run build # Production build
npm run build:lib # Build library for distribution
npm test # Run tests with Vitest-
Change a surface's aspect ratio after it exists
-
Fit media to a surface: contain / cover, without hand-computing a crop
-
Bezier mask: SDF-based interactive Bezier mask in fragment shader
-
Scale UI utility
-
Mask Shapes
-
Surface Shapes
-
Save and Load Warp Settings (JSON Export Import)
-
Tutorial: Optical Alignment of Virtual Threejs Camera with the Physical Projector
-
Test React Three Fiber Compatibility
-
Publish on npm
-
Optional: Edge Blending for Multiple Projector Setups
MIT
This library is licensed under the MIT License.
- Bicubic Warp Algorithm: Adapted to GLSL from Omnidome by Michael Winkelmann. Used with explicit permission to re-license from AGPL to MIT for this project.
- Perspective Transform: Homography solver adapted from perspective-transform (MIT).
- Soft Mask: Gaussian Filtered Rectangle adapted from One Shade and Raph Levien.
- Dithering: Hash without Sine by Dave Hoskins (MIT).
