Steve's Roc Apps roc-apps on GitHub

Safari Ride Chapters

Chapter 36

The math layer

Every sighting so far has turned a place on the road into a place on the screen, on trust. This chapter explains that step, with the trigonometry of perspective: one point travels from a segment of the road ahead, round the bends to the rider, and through the camera's lens onto the screen.

RoadPoint on a segment ahead RoadPoint on the rider's segment  point_before_worked_bend,  once for each bend RiderPoint right and forward  to_rider_point turns it  by the camera's yaw RiderPointAhead at or past the near plane  RiderView.rider_point_ahead  checks the near plane PointAtHeightAhead right, forward and height  at_height raises it Point x and y, in pixels  Camera.project divides  by forward
The trip of one point to the screen

Each box is a coordinate system, and its type says which. along and across are on the road, right and forward are from the camera, and x and y are on the screen. So a function's annotation says which space it works in, and the compiler rejects a call that passes a point from another space.

Round the bends §

A point on the road is measured within its own segment, and each segment holds only its length and the turn at its end. So a point three segments ahead comes back round three bends, one at a time, before the camera can see it:

RoadAhead.roc
## A point on the segment at `position` on the road ahead, carried back round
## each bend between it and the rider's segment, as the camera at `vantage`
## there sees it.
rider_point_round_bends : RoadAhead.RoadView,
{
	vantage : Geom.Vantage,
	position : SegmentFromRider.Position,
	road_point : Geom.RoadPoint,
} -> RiderView.RiderPoint
rider_point_round_bends = |road, { vantage, position, road_point }| {
	bends = bends_ahead(road)
	var $point = road_point
	var $bends_left = position.to_u32().to_u64()
	while $bends_left > 0 {
		$bends_left = $bends_left - 1
		bend = bends.get($bends_left) ?? crash "RoadAhead.rider_point_round_bends: past the road's end"
		$point = Geom.point_before_worked_bend($point, bend)
	}
	to_rider_point($point, vantage)
}

position is a SegmentFromRider.Position, the count of bends between the rider's segment and the point's. Each pass of the loop sets $point to the same point, measured on the segment before. For an Err, ?? runs a crash, as it does for the bird's trees.

Each pass is one rotation and one shift:

Geom.roc
## `point`, on the segment after a bend already worked out, measured on the
## segment before it instead: turned by the bend, and moved to where the
## segment after starts.
point_before_worked_bend : Geom.RoadPoint, Geom.WorkedBend -> Geom.RoadPoint
point_before_worked_bend = |point, { start_after: start, cos_turn, sin_turn }| {
	along = point.along.to_metres()
	across = point.across.to_metres()
	{
		along: start.along.plus(Distance.metres(cos_turn * along - sin_turn * across)),
		across: start.across.plus(Distance.metres(sin_turn * along + cos_turn * across)),
	}
}

start_after: start names the record's start_after field start. Turning a point by an angle mixes its two parts: the cosine says how much of each part stays where it was, and the sine how much moves over into the other. Then the shift moves the point to where the next segment starts. A WorkedBend holds that start and the turn's cosine and sine, worked out once for each bend when the road ahead is built.

To the rider §

On the rider's own segment, one more turn brings the point into the camera's view:

RoadAhead.roc
## A point on the rider's segment, as the camera at `vantage` sees it.
to_rider_point : Geom.RoadPoint, Geom.Vantage -> RiderView.RiderPoint
to_rider_point = |{ along, across }, vantage| {
	ahead = along.distance_from(vantage.along).to_metres()
	aside = across.distance_from(vantage.across).to_metres()
	cos_yaw = vantage.yaw.to_radians().cos()
	sin_yaw = vantage.yaw.to_radians().sin()
	{
		forward: Distance.metres(ahead * cos_yaw + aside * sin_yaw),
		right: Distance.metres((0.0 - ahead) * sin_yaw + aside * cos_yaw),
	}
}

The camera stands at vantage, and its yaw is the way it points, as a turn from straight down the segment. Turning the camera one way turns the world the other way in its view, so the point turns by minus the yaw into forward and right.

Through the pinhole §

Next, RiderView.rider_point_ahead, from the “Errors as tag unions” chapter, checks the point against the near plane, 0.4 metres ahead of the camera. It refuses a point short of that as BehindCamera, and returns one at or past it as a RiderPointAhead. at_height raises it to a PointAtHeightAhead. Both types are opaque, and every way to build one starts from a point at or past the near plane.

Camera.project projects the point onto the screen. The camera is a pinhole, and a pinhole is similar triangles:

Camera.roc
## Where a point in front of the camera lands on the screen, through a
## lens of `focal_length`.
project : RiderView.PointAtHeightAhead, Pixels -> Point
project = |p, focal_length| {
	slope_right = p.right().ratio_to(p.forward())
	slope_up = p.height().minus(eye_height).ratio_to(p.forward())
	{
		x: Lens.x_for_slope({ slope: slope_right, focal_length }),
		y: Lens.y_for_slope({ slope: slope_up, focal_length }),
	}
}

A point 2 metres to the right and 10 metres ahead has a slope of a fifth: its line of sight runs a fifth of a metre right for every metre forward. Lens.x_for_slope multiplies the slope by the focal length, in pixels, and measures from the middle of the screen. The height works the same way, from the eye, 1.2 metres up, and from the horizon on the screen.

Dividing by forward is all of perspective: a thing twice as far off lands twice as close to the middle. The near plane keeps forward at 0.4 metres or more, so the division always has an answer.

Two more things act on the way. The ground's curve lowers a thing standing far off, as the “Radio towers” chapter showed. And far scenery, the sun and the mountains, works from heights above the horizon, with Lens.far_point for the sun.

Cutting at the near plane §

A single point is in view or refused, but a solid's face can be both at once. A patch of road can run from ahead of the camera to beside it, so a solid's corners stay plain PointAtHeights until RiderView.clipped_points cuts the face at the near plane. Here's a rectangle 2 metres wide, from 2 metres ahead of the camera to a metre behind:

Corners, as metres right and metres forward
Before (-1, 2), (1, 2), (1, -1), (-1, -1)
After (-1, 2), (1, 2), (1, 0.4), (-1, 0.4)

The two corners behind the camera have gone, and two new ones stand where the rectangle's sides cross the near plane.

The near plane is one region a face can be cut to. The ground's horizon is another: past it, 490 metres out, the ground curves out of sight. So one cut serves any region:

RiderView.roc
## A region of the rider's view that a polygon can be cut to: which of its
## corners lie in the region, and where its edges cross the boundary.
Region(point) : {
	## `corner` as a point of the cut polygon, if it lies in the region.
	corner_in_region : RiderView.PointAtHeight -> Try(point, [Outside]),

	## Where the edge from `from` to `to` crosses the region's boundary,
	## strictly between its ends, nearest `from` first.
	edge_crossings : {
		from : RiderView.PointAtHeight,
		to : RiderView.PointAtHeight,
	} -> List(point),
}

A region is two functions in a record: whether a corner lies inside, and where an edge crosses the boundary. The near plane's region returns PointAtHeightAheads, ready for the camera, and the horizon's returns plain PointAtHeights. Here's the cut:

RiderView.roc
## The polygon cut to what lies in `region`. Where an edge leaves the
## region and another comes back into it, a straight line joins them.
points_in_region : List(RiderView.PointAtHeight), RiderView.Region(point) -> List(point)
points_in_region = |polygon_points, region| {
	next_corners = polygon_points.drop_first(1).concat(polygon_points.take_first(1))
	edges = polygon_points.map2(next_corners, |from, to| { from, to, region })
	edges.fold(List.with_capacity(2 * polygon_points.len()), edge_points_onto)
}

next_corners turns the corners round by one, with drop_first and take_first, so map2 can pair each corner with the next. Each pair is an edge. fold builds the cut face from the edges, starting from List.with_capacity, an empty list with room set aside. edge_points_onto keeps an edge's first corner when it lies in the region, then adds each point where the edge crosses the boundary. The near plane's cut is one line:

RiderView.roc
## The polygon cut to what lies at or past the near plane.
clipped_points : List(RiderView.PointAtHeight) -> List(RiderView.PointAtHeightAhead)
clipped_points = |polygon_points| RiderView.points_in_region(polygon_points, near_plane_region)

So a point on the road reaches the screen by four moves: a rotation for each bend, one turn to the camera, a check at the near plane, and a projection, which divides by forward.