NDC is a screen space coordinate system, great for perspective illusions and raycasting tricks.
X and Y represent the 2D screen coordinates, while Z represents the distance to the camera.

The X and Y coordinates are normalized between 0 and 1, with 0.5 in the middle.

The Z coordinates are 0 at the camera, negative in front and positive behind the camera. Why negative? No idea!

You can convert a world space coordinate to NDC using toNDC():
vector ndcPos = toNDC(chsop("cam"), v@P);
Then convert it back to world space using fromNDC():
vector worldPos = fromNDC(chsop("cam"), ndcPos);
Here’s some NDC tricks you can play with.
| Download the HIP file! | | — |
The origin of NDC space is the camera, so just convert {0, 0, 0} to world space.

// Run this in a detail wrangle
string cam = chsop("cam");
vector camPos = fromNDC(cam, {0, 0, 0}); // or {0, 0, 0} * optransform(cam);
addpoint(0, camPos);
{0.5, 0.5, 0} is technically more correct, but gives the same result.
The Z axis aligns with the camera direction, so move along it to draw a ray.

// Run this in a detail wrangle
string cam = chsop("cam");
float offset = chf("raylength");
// Sample two positions along the Z axis in NDC space to draw a ray
vector camPos = fromNDC(cam, {0.5, 0.5, 0});
vector camPos2 = fromNDC(cam, set(0.5, 0.5, -offset));
int a = addpoint(0, camPos);
int b = addpoint(0, camPos2);
addprim(0, "polyline", a, b);
Using NDC coordinates directly in world space flattens the geometry to how it looks on screen, like a printed photo.

string cam = chsop("cam");
// Flatten by setting Z to a constant value
v@P = toNDC(cam, v@P);
v@P.z = 0;
Another trick is turning this into an outline, much like Labs Extract Silouette.
* and enable outside removal. This triangulates the mesh.![]() |
![]() |
![]() |
![]() |
Using NDC coordinates in camera space lets you flatten geometry but keep it identical from the camera perspective.

string cam = chsop("cam");
float offset = ch("distance");
// Flatten to camera by setting Z to a constant value
vector p = toNDC(cam, v@P);
p.z = -offset;
v@P = fromNDC(cam, p);
Same as above, except subtracting the Z coordinate. Again the geometry is identical from the camera perspective.

string cam = chsop("cam");
float offset = ch("distance");
// Distort relative to camera by adding or multiplying the Z value
vector p = toNDC(cam, v@P);
p.z -= offset;
v@P = fromNDC(cam, p);
Same as above, except using another camera as reference. The Z coordinates are flipped as the cameras cross paths.

string cam = chsop("cam");
float mult = ch("multiply");
float add = ch("add");
// Distort relative to camera by adding or multiplying the Z value
vector p = toNDC(cam, v@P);
p.z = p.z * mult + add;
v@P = fromNDC(cam, p);
The VEX equivalent of Camera Frustum qL.
![]() |
![]() |

string cam = chsop("cam");
// Optionally animate it along the Z axis
v@P.z -= chf("distance");
v@P = fromNDC(cam, v@P);
The VEX equivalent of Camera Plane qL.
![]() |
![]() |

string cam = chsop("cam");
// Optionally animate it along the Z axis
v@P.z = -chf("distance");
v@P = fromNDC(cam, v@P);
This technique is great for holograms. I first saw Entagma use it for a raytracer.
Since the plane is flattened on Z in NDC space, this is easy. Just subtract the camera position from the current position.
string cam = chsop("cam");
vector camPos = fromNDC(cam, {0, 0, 0});
// Projection direction
v@N = normalize(v@P - camPos);


For arbitrary geometry, the same idea applies. Convert to NDC space, flatten Z and convert back to world space.
string cam = chsop("cam");
vector camPos = fromNDC(cam, {0, 0, 0});
// Flatten Z axis in NDC space
vector ndcPos = toNDC(cam, v@P);
ndcPos.z = -1;
vector worldPos = fromNDC(cam, ndcPos);
// Projection direction
v@N = normalize(worldPos - camPos);
Assuming no distortion, depth maps are easy to project using NDC coordinates.
You can get a depth map from Copernicus using Rasterize Geometry with intrinsic:depth_eye.

To project into world space, just set the Z coordinate to the color of the texture. @depth came from Attribute From Map.

string cam = chsop("cam");
v@P.z = -f@depth - chf("offset");
v@P = fromNDC(cam, v@P);
Perhaps the most common use of NDC space is removing offscreen geometry.
So what is offscreen? For X and Y it’s anything outside the 0 to 1 range, and for Z it’s anything positive.

string cam = chsop("cam");
vector ndcPos = toNDC(cam, v@P);
if (ndcPos.x < 0 || ndcPos.x > 1 // Remove outside 0-1 on X
|| ndcPos.y < 0 || ndcPos.y > 1 // Remove outside 0-1 on Y
|| ndcPos.z >= 0) { // Remove behind camera (positive Z)
removepoint(0, i@ptnum);
}
It helps to add some wiggle room near the edges to avoid issues like glitchy motion blur or flickering shadows.

float padding = chf("padding");
string cam = chsop("cam");
vector ndcPos = toNDC(cam, v@P);
if (ndcPos.x < -padding || ndcPos.x > 1 + padding // Remove outside 0-1 on X (with padding)
|| ndcPos.y < -padding || ndcPos.y > 1 + padding // Remove outside 0-1 on Y (with padding)
|| ndcPos.z >= 0) { // Remove behind camera (positive Z)
removepoint(0, i@ptnum);
}
You can measure the correlation between the camera direction and object normal using dot(v@N, camDir).
Using this as the Clip attribute, you clear any geometry facing away from the camera.

string cam = chsop("cam");
// dot() finds correlation between the normal direction and camera direction
vector camPos = fromNDC(cam, {0, 0, 0});
vector camDir = normalize(camPos - v@P);
// Use this attribute with Clip
f@correlation = dot(v@N, camDir);
You can remove occluded geometry by raying towards the camera to see if any collisions occur.
If no hit occurs, it must be visible to the camera. Otherwise it’s occluded and can be removed.

string cam = chsop("cam");
vector camPos = fromNDC(cam, {0, 0, 0});
vector camDir = camPos - v@P;
// Ray towards the camera and see if we hit the 2nd input
vector p, uvw;
if (intersect(1, v@P, camDir, p, uvw) >= 0) {
removepoint(0, i@ptnum);
}
You can use perspective() to build a matrix that has the same effect as toNDC() (assuming the camera is in perspective mode).
Thanks to Igor Elovikov for sharing the code below! He noted this:
VEX
toNDC()feels a bit misleading and confusing.
Usually NDC space is a cube from (-1, 1, 1) to (1, 1, 1), but VEX transforms it to screen space and removes normalization from Z.
So X, Y is normalized but Z is the actual Z, the distance from the camera.
Also when you’re doing stuff manually, you should work with homogenous coordinates (vector4 basically).
string cam = chsop("cam");
4@projection = perspective(
chf(cam + "/focal") / chf(cam + "/aperture"),
chf(cam + "/resx") / chf(cam + "/resy"),
chf(cam + "/aspect"),
0, 1,
{-1, -1, 1, 1}
);
4@world_to_camera = invert(optransform(cam)); // or getspace("space:world", cam);
4@to_ndc = 4@world_to_camera * 4@projection;
// Equivalent to v@P = toNDC(cam, v@P);
vector4 pos = vector4(v@P) * 4@to_ndc;
v@P = vector(pos) / pos.w;
v@P.z = pos.z;
Try this version if you want Z to be normalized:
string cam = chsop("cam");
4@projection = perspective(
chf(cam + "/focal") / chf(cam + "/aperture"),
chf(cam + "/resx") / chf(cam + "/resy"),
chf(cam + "/aspect"),
chf(cam + "/near"),
chf(cam + "/far")
);
4@world_to_camera = invert(optransform(cam)); // or getspace("space:world", cam);
4@to_ndc = 4@world_to_camera * 4@projection;
v@P *= 4@to_ndc;
Igor also shared how to calculate fromNDC() based on a modified version of toNDC().
Inverse transform is tricky as it involves the inversion of what I did before (denormalization of Z)
Also you have to keep the W component of the point
Actually I saw somewhere there should be an implicit 4th component, but not sure.
toNDC()string cam = chsop("cam");
f@near = chf(cam + "/near");
f@far = chf(cam + "/far");
4@projection = perspective(
chf(cam + "/focal") / chf(cam + "/aperture"),
chf(cam + "/resx") / chf(cam + "/resy"),
chf(cam + "/aspect"),
f@near,
f@far,
{-1, -1, 1, 1}
);
4@world_to_camera = invert(optransform(cam)); // or getspace("space:world", cam);
4@to_ndc = 4@world_to_camera * 4@projection;
float clip_z = f@far - f@near;
// Equivalent to v@P = toNDC(cam, v@P);
vector4 pos = vector4(v@P) * 4@to_ndc;
v@P = vector(pos) / pos.w;
v@P.z = f@near + pos.z * clip_z;
f@origin_w = pos.w;
fromNDC()vector4 pos = v@P;
pos.z = invlerp(v@P.z, f@near, f@far) / f@origin_w;
pos *= invert(4@to_ndc);
v@P = vector(pos) / pos.w;