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});
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);
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 use perspective()
to build a matrix that has the same effect as toNDC()
.
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;