Skip to main content
typo
Source Link
DMGregory
  • 136.3k
  • 22
  • 247
  • 373

To demonstrate that this works, I built a test scene in Unity that would render a series of cubes with the standard rasterization pipeline, then also fire waysrays and overlay a greyscale version calculated raycasting-style. You can see from this cross-fade between the two, the raycasting approach accurately reproduces the same linear perspective, with no erroneous curving of the walls:

To demonstrate that this works, I built a test scene in Unity that would render a series of cubes with the standard rasterization pipeline, then also fire ways and overlay a greyscale version calculated raycasting-style. You can see from this cross-fade between the two, the raycasting approach accurately reproduces the same linear perspective, with no erroneous curving of the walls:

To demonstrate that this works, I built a test scene in Unity that would render a series of cubes with the standard rasterization pipeline, then also fire rays and overlay a greyscale version calculated raycasting-style. You can see from this cross-fade between the two, the raycasting approach accurately reproduces the same linear perspective, with no erroneous curving of the walls:

Adding demonstration
Source Link
DMGregory
  • 136.3k
  • 22
  • 247
  • 373
offset = ((i * 2.0 / (WIDTH_MM - 1.0)) - 1.0) * halfWidth;

rayDirection.x = forward.x + offset * right.x;
rayDirection.y = forward.y + offset * right.y;

You can optionally normalize or scale this direction vector as needed to suit your ray intersection routines.


To demonstrate that this works, I built a test scene in Unity that would render a series of cubes with the standard rasterization pipeline, then also fire ways and overlay a greyscale version calculated raycasting-style. You can see from this cross-fade between the two, the raycasting approach accurately reproduces the same linear perspective, with no erroneous curving of the walls:

Cross-fade between rasterized & raycast scene

Here's the code I use to fire my rays, and save the depths into a buffer to use in drawing the raycast version:

void Update()
{
    // Compute worldspace width of the projection plane (at a depth of 1)
    // using the camera's field of view (which in Unity is a vertical FoV).
    float halfHeight = Mathf.Tan(_camera.fieldOfView * Mathf.Deg2Rad / 2.0f);
    float halfWidth = _camera.aspect * halfHeight;   

    // For each pixel column in our raycasting buffer...
    for(int i = 0; i < _depths.Length; i++) {
        // Compute a ray firing through that point on the image plane.
        float offset = (2.0f * i) / (_depths.Length - 1.0f) - 1.0f;
        var ray = new Ray(
            transform.position, 
            transform.forward + transform.right * halfWidth * offset
        );

        // If it hits something, get its depth along the camera forward vector.
        RaycastHit hit;
        float depth = float.PositiveInfinity;
        if(Physics.Raycast(ray, out hit)) {
            depth = Vector3.Dot(hit.point - transform.position, transform.forward);                
        }
        _depths[i] = new Color(depth, depth, depth, depth);
    }

    _depthRibbon.SetPixels(_depths);
    _depthRibbon.Apply(false, false);
}

And inside the shader:

fixed4 frag (v2f i) : SV_Target
{
    // Sample depth from the buffer populated by our rays.
    float depth = tex2D(_MainTex, i.uv).r;

    // Wall height is our master height divided by depth.       
    float height = _WallScale / depth;

    // Default floor / ceiling to black.
    fixed4 col = fixed4(0, 0, 0, _Alpha);

    // If the pixel we're shading is closer to the middle of the screen
    // than the edge of the wall, shade it as a wall.
    if (abs(i.uv.y - 0.5f) < height) {
        col = lerp(fixed4(0, 0, 0, _Alpha), fixed4(1, 1, 1, _Alpha), exp(-depth * 0.3f + 0.3f));
    }
    
    return col;
}
offset = ((i * 2.0 / WIDTH_MM) - 1.0) * halfWidth;

rayDirection.x = forward.x + offset * right.x;
rayDirection.y = forward.y + offset * right.y;

You can optionally normalize or scale this direction vector as needed to suit your ray intersection routines.

offset = ((i * 2.0 / (WIDTH_MM - 1.0)) - 1.0) * halfWidth;

rayDirection.x = forward.x + offset * right.x;
rayDirection.y = forward.y + offset * right.y;

You can optionally normalize or scale this direction vector as needed to suit your ray intersection routines.


To demonstrate that this works, I built a test scene in Unity that would render a series of cubes with the standard rasterization pipeline, then also fire ways and overlay a greyscale version calculated raycasting-style. You can see from this cross-fade between the two, the raycasting approach accurately reproduces the same linear perspective, with no erroneous curving of the walls:

Cross-fade between rasterized & raycast scene

Here's the code I use to fire my rays, and save the depths into a buffer to use in drawing the raycast version:

void Update()
{
    // Compute worldspace width of the projection plane (at a depth of 1)
    // using the camera's field of view (which in Unity is a vertical FoV).
    float halfHeight = Mathf.Tan(_camera.fieldOfView * Mathf.Deg2Rad / 2.0f);
    float halfWidth = _camera.aspect * halfHeight;   

    // For each pixel column in our raycasting buffer...
    for(int i = 0; i < _depths.Length; i++) {
        // Compute a ray firing through that point on the image plane.
        float offset = (2.0f * i) / (_depths.Length - 1.0f) - 1.0f;
        var ray = new Ray(
            transform.position, 
            transform.forward + transform.right * halfWidth * offset
        );

        // If it hits something, get its depth along the camera forward vector.
        RaycastHit hit;
        float depth = float.PositiveInfinity;
        if(Physics.Raycast(ray, out hit)) {
            depth = Vector3.Dot(hit.point - transform.position, transform.forward);                
        }
        _depths[i] = new Color(depth, depth, depth, depth);
    }

    _depthRibbon.SetPixels(_depths);
    _depthRibbon.Apply(false, false);
}

And inside the shader:

fixed4 frag (v2f i) : SV_Target
{
    // Sample depth from the buffer populated by our rays.
    float depth = tex2D(_MainTex, i.uv).r;

    // Wall height is our master height divided by depth.       
    float height = _WallScale / depth;

    // Default floor / ceiling to black.
    fixed4 col = fixed4(0, 0, 0, _Alpha);

    // If the pixel we're shading is closer to the middle of the screen
    // than the edge of the wall, shade it as a wall.
    if (abs(i.uv.y - 0.5f) < height) {
        col = lerp(fixed4(0, 0, 0, _Alpha), fixed4(1, 1, 1, _Alpha), exp(-depth * 0.3f + 0.3f));
    }
    
    return col;
}
Source Link
DMGregory
  • 136.3k
  • 22
  • 247
  • 373

The trick here is to think of your image plane as though it was actually a flat object sitting in front of the camera, perpendicular to its view direction. This parallels the way the player's screen is a flat object in front of their eyes, perpendicular to their gaze - and this similarity is what makes the optical illusion of linear perspective work.

Diagram of raycasting setup

Following this line of thinking, each column of pixels on your screen corresponds to a specific point on this image plane in your game space.

Because the pixels on our screen are spaced out evenly, we need to space out these samples evenly in space along the image plane - I've visualized that with the line of little squares in the diagram above. So we need to find a direction to shoot each ray so it first "through" one of these evenly-spaced pixel points.

If we sweep our rays using equal angular increments like you do in this code:

angle = (w->cam->angle + (w->cam->fov / 2) - (i * w->cam->fov / WIDTH_MM));

then we don't get evenly-spaced points on our image plane - they'll bunch up in the middle and spread out at the sides, breaking our illusion. It makes straight edges of walls start looking bent.

In reality, the angular gap between adjacent pixels changes as we sweep our ray across the screen - with smaller angular spread at the sides and bigger strides in the middle:

Diagram of angles

So, working in angles can be a bit of a trap, and it helps to get out of angular space and into cartesian coordinates & vectors early. As a bonus, this will also save us most of our trig calculations! :)

First we'll need a unit vector in the direction the camera is facing, our forward vector. We'll use this a lot, so it's worth saving rather than looking up the trig values every time:

 forward.x = tcos(w->cam->angle);
 forward.y = tsin(w->cam->angle);

Next we need a vector pointing parallel to the image plane, to the camera's right. We can use a little trick to compute this from forward with no more trig:

right.x = forward.y;
right.y = -forward.x;

And we'll need to now how wide to sweep from the center of our view to one edge.

halfWidth = ttan(w->cam->fov / 2)

Now the ray through the leftmost pixel in our image plane points along forward - halfWidth * right, and the ray through the rightpmost pixel points along forward + halfWidth * right.

We can calculate the direction the ith ray should travel like so:

offset = ((i * 2.0 / WIDTH_MM) - 1.0) * halfWidth;

rayDirection.x = forward.x + offset * right.x;
rayDirection.y = forward.y + offset * right.y;

You can optionally normalize or scale this direction vector as needed to suit your ray intersection routines.