//	GGVRCompositorLayer.swift
//
//	© 2026 by Jeff Weeks
//	See TermsOfUse.txt

import SwiftUI
import CompositorServices


struct GGVRCompositorLayerConfiguration: CompositorLayerConfiguration {

	func makeConfiguration(
		capabilities: LayerRenderer.Capabilities,
		configuration: inout LayerRenderer.Configuration
	) {

		//	On visionOS, the color space is always Extended Linear Display P3.
		//
		//	As of visionOS 1.1.1 (March 2024)
		//
		//		The supported colorFormats are
		//			rgba8Unorm_srgb
		//			bgra8Unorm_srgb
		//			rgba16Unorm
		//			bgr10a2Unorm
		//			rgba16Float
		//
		//		The supported depthFormats
		//			depth32Float
		//			depth32Float_stencil8
		//
		//		The default colorFormat is
		//			rgba16Float  (contrary to what was said in WWDC 2023 #10089)
		//
		//		The default depthFormat is
		//			depth32Float
		//
		//	We don't need high dynamic range (HDR), so let's use
		//	the 32-bit bgr10a2Unorm instead of the default 64-bit rgba16Float,
		//	to halve the size of our color buffer and the associated bus traffic.
		//	(Although if there were ever an advantage to using rgba16Float it would
		//	be fine -- the app spends only about 1 ms in the fragment function
		//	either way.) Happily bgr10a2Unorm and rgba16Float both avoid
		//	sRGB encoding (gamma encoding) which makes it easy to avoid
		//	the color errors that mismatched encoding assumptions can lead to.
		//
		//	Here's some code to check supported formats:
		//
		//		print("supported colorFormats \(capabilities.supportedColorFormats.map() { pf in pf.rawValue})")
		//		print("supported depthFormats \(capabilities.supportedDepthFormats.map() { df in df.rawValue})")
		//		print("default colorFormat \(configuration.colorFormat)")
		//		print("default depthFormat \(configuration.depthFormat)")
		//
		configuration.depthFormat = .depth32Float	//	this is the default (rawValue: 252)
		configuration.colorFormat = .bgr10a2Unorm	//	default is rgba16Float  (rawValue: 115)

		//	Note: In Curved-Spaces Pool Hall, hopes of removing some
		//	residual flickering of the billiards table's procedural texture
		//	in my peripheral vision, I tried disabling foveation
		//
		//		let supportedLayouts = capabilities.supportedLayouts(options: [])
		//		configuration.layout = supportedLayouts.contains(.layered) ? .layered : .dedicated
		//		configuration.isFoveationEnabled = false
		//
		//	The flickering did indeed go away in my peripheral vision,
		//	but much to my surprise it got worse in the whole central part
		//	of my vision. Even more surprising, the billiard balls' outlines
		//	became very pixelated and jumpy, as if antialiasing had been turned off
		//	and/or the whole image were being drawn at a lower resolution.
		//	So let's stick with the foveation, which gives good results
		//	in the user's whole field of view most of the time, and
		//	in the central part all the time.
		let supportsFoveation = capabilities.supportsFoveation
		let supportedLayouts = capabilities.supportedLayouts(
			options: supportsFoveation ? [.foveationEnabled] : [])
		configuration.layout = supportedLayouts.contains(.layered) ? .layered : .dedicated
		configuration.isFoveationEnabled = supportsFoveation
	}
}

extension LayerRenderer.Clock.Instant.Duration {

	var timeInterval: TimeInterval {
	
		let theIntegerSeconds = TimeInterval(components.seconds)
		let theFractionalSeconds = TimeInterval(components.attoseconds) * TimeInterval(1.0e-18)
		let theTimeInterval = theIntegerSeconds + theFractionalSeconds
		
		return theTimeInterval
	}
}

final class GGVRRendererTaskExecutor: TaskExecutor {

	private let queue = DispatchQueue(label: "RenderThreadQueue", qos: .userInteractive)

	func enqueue(_ job: UnownedJob) {
		queue.async {
			job.runSynchronously(on: self.asUnownedSerialExecutor())
		}
	}

	func asUnownedSerialExecutor() -> UnownedTaskExecutor {
		return UnownedTaskExecutor(ordinary: self)
	}

	static let shared: GGVRRendererTaskExecutor = GGVRRendererTaskExecutor()
}


func ggvrPipelineState(
	layerRenderer: LayerRenderer,
	vertexFunction: MTLFunction,
	fragmentFunction: MTLFunction,
	vertexDescriptor: MTLVertexDescriptor,
	enableBlending: Bool
) -> MTLRenderPipelineState? {

	let thePipelineDescriptor = MTLRenderPipelineDescriptor()
	thePipelineDescriptor.label
		= "Geometry Games VR render pipeline for \(fragmentFunction.label ?? "???")"
//	thePipelineDescriptor.sampleCount = ???
//		presumably visionOS doesn't do multisampling
	thePipelineDescriptor.vertexFunction = vertexFunction
	thePipelineDescriptor.fragmentFunction = fragmentFunction
	thePipelineDescriptor.vertexDescriptor = vertexDescriptor
	thePipelineDescriptor.colorAttachments[0].pixelFormat = layerRenderer.configuration.colorFormat
	thePipelineDescriptor.depthAttachmentPixelFormat = layerRenderer.configuration.depthFormat
	thePipelineDescriptor.stencilAttachmentPixelFormat = MTLPixelFormat.invalid
	thePipelineDescriptor.maxVertexAmplificationCount = layerRenderer.properties.viewCount

	thePipelineDescriptor.colorAttachments[0].isBlendingEnabled = enableBlending
	if enableBlending {
	
		//	We define textures using pre-multiplied colors (αR, αG, αB, α)
		//	instead of the traditional (R,G,B,α). This makes blending easier
		//	and, even though we're not manually generating any mipmaps here,
		//	it makes mipmap generation easier too. Here are the details:
		//
		//	Blending
		//
		//		Rigorously correct blending requires
		//
		//					   αs*(Rs,Gs,Bs) + (1 - αs)*αd*(Rd,Gd,Bd)
		//			(R,G,B) = ----------------------------------------
		//							 αs      +      (1 - αs)*αd
		//
		//				  α = αs + (1 - αs)*αd
		//
		//		Replacing the traditional (R,G,B,α) with the premultiplied (αR,αG,αB,α)
		//		simplifies the formula to
		//
		//			(αR, αG, αB) = (αs*Rs, αs*Gs, αs*Bs) + (1 - αs)*(αd*Rd, αd*Gd, αd*Bd)
		//
		//					   α = αs + (1 - αs)*αd
		//
		//		Because they share the same coefficients,
		//		we may merge the RGB and α parts into a single formula
		//
		//			(αR, αG, αB, α)
		//				=     1    * (αs*Rs, αs*Gs, αs*Bs, αs)
		//				+ (1 - αs) * (αd*Rd, αd*Gd, αd*Bd, αd)
		//
		//	Mipmap generation (not needed here)
		//
		//		When generating mipmaps, to average two (or more) pixels correctly
		//		we must weight them according to their alpha values.
		//		With traditional (R,G,B,α) the formula is a bit messy
		//
		//								 α0*(R0,G0,B0) + α1*(R1,G1,B1)
		//			(Ravg, Gavg, Bavg) = -----------------------------
		//								            α0 + α1
		//
		//								 α0 + α1
		//						  αavg = -------
		//									2
		//
		//		With premultiplied (αR,αG,αB,α) the formula becomes a simple average
		//
		//			(αavg*Ravg, αavg*Gavg, αavg*Bavg, αavg)
		//
		//				  (α0*R0, α0*G0, α0*B0, α0) + (α1*R1, α1*G1, α1*B1, α1)
		//				= -----------------------------------------------------
		//											2
		//	Performance note:  When drawing opaque content using
		//	tile-based deferred rendering, the GPU calls the fragment function
		//	only for visible fragments that aren't eclipsed by anything
		//	closer to the observer. But with partially transparent content,
		//	the GPU will need to call a fragment function for each primitive
		//	that may affect the pixel in question.
		//
		//	Set the values define above in the formula for (αR, αG, αB, α).
		thePipelineDescriptor.colorAttachments[0].rgbBlendOperation = .add
		thePipelineDescriptor.colorAttachments[0].alphaBlendOperation = .add
		thePipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = .one
		thePipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = .one
		thePipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
		thePipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
	}

	do {
		let thePipelineState
			= try layerRenderer.device.makeRenderPipelineState(	//	returns non-nil
										descriptor: thePipelineDescriptor)
		return thePipelineState
	} catch {
		assertionFailure("Couldn't create the pipeline state for \(vertexFunction.label ?? "???") and \(fragmentFunction.label ?? "???"): \(error)")
		return nil
	}
}

func ggvrDepthStencilState(
	device: MTLDevice,
	depthCompareEnabled: Bool,
	depthWriteEnabled: Bool
) -> MTLDepthStencilState? {

	let theDepthStencilDescriptor = MTLDepthStencilDescriptor()

	theDepthStencilDescriptor.depthCompareFunction
		= depthCompareEnabled ? MTLCompareFunction.greater : MTLCompareFunction.always
	
	theDepthStencilDescriptor.isDepthWriteEnabled = depthWriteEnabled

	let theDepthStencilState = device.makeDepthStencilState(
								descriptor: theDepthStencilDescriptor)
	assert(
		theDepthStencilState != nil,
		"Couldn't create theDepthStencilState for depthCompareEnabled = \(depthCompareEnabled) and depthWriteEnabled = \(depthWriteEnabled)")
		
	return theDepthStencilState
}

func ggvrMeshFacets(
	device: MTLDevice,
	vertexIndices: [UInt16]	//	each set of 3 vertices defines a triangle
) -> MTLBuffer {

	precondition(
		vertexIndices.count % 3 == 0,
		"The number of vertex indices in a triangle mesh must be a multiple of 3.")

	guard let theIndexBuffer = device.makeBuffer(
		bytes: vertexIndices,
		length: vertexIndices.count * MemoryLayout<UInt16>.stride,
		options: [MTLResourceOptions.storageModeShared]
	) else {
		fatalError("Couldn't make the theIndexBuffer in ggvrMeshFacets()")
	}
	
	return theIndexBuffer
}

func ggvrSamplerState(
	device: MTLDevice,
	sMode: MTLSamplerAddressMode,
	tMode: MTLSamplerAddressMode
) -> MTLSamplerState? {
	
	let theDescriptor = MTLSamplerDescriptor()

	theDescriptor.normalizedCoordinates = true

	theDescriptor.sAddressMode = sMode
	theDescriptor.tAddressMode = tMode

	theDescriptor.minFilter = MTLSamplerMinMagFilter.linear
	theDescriptor.magFilter = MTLSamplerMinMagFilter.linear
	theDescriptor.mipFilter = MTLSamplerMipFilter.linear
	
	theDescriptor.maxAnisotropy = 16	//	= 1 to disable anisotropic adjustments

	let theSamplerState = device.makeSamplerState(descriptor: theDescriptor)

	return theSamplerState
}

func ggvrRenderPassDescriptor(
	layerRenderer: LayerRenderer,
	drawable: LayerRenderer.Drawable
) -> MTLRenderPassDescriptor {

	let theRenderPassDescriptor = MTLRenderPassDescriptor()

	let theColorAttachmentDescriptor: MTLRenderPassColorAttachmentDescriptor
		= theRenderPassDescriptor.colorAttachments[0]
	theColorAttachmentDescriptor.texture = drawable.colorTextures[0]
	theColorAttachmentDescriptor.loadAction = .clear
	theColorAttachmentDescriptor.storeAction = .store
	theColorAttachmentDescriptor.clearColor
		= MTLClearColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
	
	let theDepthAttachmentDescriptor: MTLRenderPassDepthAttachmentDescriptor
		= theRenderPassDescriptor.depthAttachment
	theDepthAttachmentDescriptor.texture = drawable.depthTextures[0]
	theDepthAttachmentDescriptor.loadAction = .clear
	theDepthAttachmentDescriptor.storeAction = .store
		//	Unlike on iOS, on visionOS the storeAction must be .store.
		//	If we instead set .dontCare, the whole display would be pure black.
		//	Maybe visionOS uses the depth buffer in its post-processing?
	theDepthAttachmentDescriptor.clearDepth = 0.0	//	assumes z axis points towards the player

	theRenderPassDescriptor.rasterizationRateMap = drawable.rasterizationRateMaps.first
	
	if layerRenderer.configuration.layout == .layered {
		theRenderPassDescriptor.renderTargetArrayLength = drawable.views.count
	}
	
	return theRenderPassDescriptor
}

func ggvrMakeViewProjection(
	virtualHeadsetPose: simd_double4x4,
	drawable: LayerRenderer.Drawable,
	spaceGeometry: SpaceGeometry,
	adjustPerceivedDistances: Bool
) -> GGVRViewProjection {

	let theGameSpaceToHeadsetSpace = convertDouble4x4toFloat4x4(
		geometricInverse(
			of: virtualHeadsetPose,
			geometryType: spaceGeometry.geometryType))

	//	When gAdjustPerceivedDistances is enabled, the GPU should
	//	in principle apply the azimuthalEquidistantMap() based
	//	at the "bridge of the player's nose", that is, at the
	//	midpoint between the player's eyes. If that point were
	//	significantly offset from the headset's nominal position,
	//	we could explicitly factor in a headsetToBridgeOfNose matrix.
	//	But in practice the bridge of the nose differs from the headset's
	//	nominal position by only a few millimeters. For example,
	//	in a test run, visionOS measured my eye offsets to be
	//
	//		left eye:  ( -32 mm, -3 mm, +3 mm )
	//		right eye: ( +36 mm, -3 mm, +6 mm )
	//
	//	So we may let the GPU apply the azimuthalEquidistantMap()
	//	from the headset's nominal position without compromising
	//	the validity of the simulated "native view" of the curved space.
	//
	let theGameSpaceToBridgeOfNoseSpace = theGameSpaceToHeadsetSpace

	let theProjectionGeometry
		= adjustPerceivedDistances ?
			//	The GPU will be applying the azimuthal equidistant map,
			//	so we may (indeed must!) use a Euclidean eye translation
			//	and a Euclidean projection matrix.
			.euclidean :
			//	The GPU will be projecting directly from the not-necessarily-
			//	Euclidean space, so we need to convert the Euclidean eye offset
			//	that the LayerRenderer gave us to an equivalent not-necessarily-
			//	Euclidean eye offset and then apply the appropriate
			//	not-necessarily-Euclidean projection matrix.
			spaceGeometry.geometryType

	let theBridgeOfNoseSpaceToClipSpace: [simd_float4x4]
		= (0 ..< drawable.views.count).map() { eyeIndex in

		let theEyeInfo: LayerRenderer.Drawable.View = drawable.views[eyeIndex]

		let thePhysicalEyePose	//	relative to the headset, space radius = 1 meter
			= convertFloat4x4toDouble4x4(theEyeInfo.transform)

		let theVirtualEyeRotation = simd_double4x4(
				thePhysicalEyePose[0],
				thePhysicalEyePose[1],
				thePhysicalEyePose[2],
				SIMD4<Double>(0.0, 0.0, 0.0, 1.0) )
		
		let theEyeOffsetVectorInMeters = SIMD3<Double>(	//	physical and virtual both
			1.0 * Double(thePhysicalEyePose[3][0]),	//	(1 meter)*(m₃₀)
			1.0 * Double(thePhysicalEyePose[3][1]),	//	(1 meter)*(m₃₁)
			1.0 * Double(thePhysicalEyePose[3][2]))	//	(1 meter)*(m₃₂)

		let theEyeOffsetInRadians = theEyeOffsetVectorInMeters
								  / spaceGeometry.spaceRadius
		let theVirtualEyeTranslation = geodesicTranslation(
										v: theEyeOffsetInRadians,
										geometryType: theProjectionGeometry)
		let theVirtualEyePose = theVirtualEyeTranslation	//	right-to-left matrix action
							  * theVirtualEyeRotation
		let theVirtualEyePoseInverse = geometricInverse(
										of: theVirtualEyePose,
										geometryType: theProjectionGeometry)
								
		//	visionOS uses off-axis viewing, as one would expect with the player's
		//	left and right eyes focusing on the center of the player's field of view.
		//	In visionOS 1, theEyeInfo.tangents told us the angles from the negative z-axis
		//	(in eye coordinates) to each side of the view frustum. More precisely,
		//	theEyeInfo.tangents told us the tangents of those angles (always positive)
		//	in the order left, right, top, bottom. The numerical values were
		//
		//		eyeIndex 0: (tan(60°), tan(45°), tan(45°), tan(50°))
		//		eyeIndex 1: (tan(45°), tan(60°), tan(45°), tan(50°))
		//
		//	and were the same no matter who the interpupillary distance was adjusted for.
		//
		//	visionOS 2 deprecates theEyeInfo.tangents and instead gives us
		//	a Euclidean matrix built from those tangents. Fortunately it's very easy
		//	for ggvrProjectionMatrix() to copy the matrix entries that don't
		//	depend on the geometry while replacing those that do.
		//
		let theSampleProjectionMatrix = convertFloat4x4toDouble4x4( 				drawable.computeProjection(viewIndex: eyeIndex))
		let theProjectionMatrix = ggvrProjectionMatrix(
										sampleProjectionMatrix: theSampleProjectionMatrix,
										geometryType: theProjectionGeometry,
										spaceRadius: spaceGeometry.spaceRadius)

		let theComposition = convertDouble4x4toFloat4x4(
			theProjectionMatrix * theVirtualEyePoseInverse)	//	right-to-left matrix action
		
		return theComposition
	}
	
	let theViewProjection = GGVRViewProjection(
		gameSpaceToBridgeOfNoseSpace: theGameSpaceToBridgeOfNoseSpace,
		bridgeOfNoseSpaceToClipSpace: (
			theBridgeOfNoseSpaceToClipSpace[0],
			theBridgeOfNoseSpaceToClipSpace.count >= 2 ?
				theBridgeOfNoseSpaceToClipSpace[1] :	//	on device
				matrix_identity_float4x4)				//	on simulator
		)
	
	return theViewProjection
}
		
private func ggvrProjectionMatrix(
	sampleProjectionMatrix: simd_double4x4,	//	visionOS's default projection matrix
	geometryType: GeometryType,
	spaceRadius: Double
) -> simd_double4x4 {

	//	Note the radius-of-curvature of the space.
	let ρ = spaceRadius

	//	Thinking in Euclidean geometry for the moment, let the tangent
	//	of the signed angle from the negative z-axis (in eye coordinates)
	//	to a side of the view frustum be
	//
	//		l for the  left  face (l < 0)
	//		r for the right  face (r > 0)
	//		t for the  top   face (t > 0)
	//		b for the bottom face (b < 0)
	//
	//	and let
	//
	//		n be the near clipping distance in meters and
	//		f be the  far clipping distance in meters.
	//
	//	The matrix (written using left-to-right matrix conventions)
	//
	//		      2
	//		    -----           0          0        0
	//		    r - l
	//
	//		                    2
	//		      0           -----        0        0
	//		                  t - b
	//
	//		    r + l         t + b        n
	//		    -----         -----      -----     -1
	//		    r - l         t - b      f - n
	//
	//		                              n f
	//            0             0      ---------    0
	//                                 ρ (f - n)
	//
	//	sends the view frustum's eight corners
	//
	//		( n*l, n*b, -n, ρ )
	//		( n*l, n*t, -n, ρ )
	//		( n*r, n*b, -n, ρ )
	//		( n*r, n*t, -n, ρ )
	//		( f*l, f*b, -f, ρ )
	//		( f*l, f*t, -f, ρ )
	//		( f*r, f*b, -f, ρ )
	//		( f*r, f*t, -f, ρ )
	//
	//	to
	//
	//		( -n, -n, n, n )
	//		( -n, +n, n, n )
	//		( +n, -n, n, n )
	//		( +n, +n, n, n )
	//		( -f, -f, 0, f )
	//		( -f, +f, 0, f )
	//		( +f, -f, 0, f )
	//		( +f, +f, 0, f )
	//
	//	which after dividing through by their last coordinate become
	//
	//		( -1, -1, 1, 1 )
	//		( -1, +1, 1, 1 )
	//		( +1, -1, 1, 1 )
	//		( +1, +1, 1, 1 )
	//		( -1, -1, 0, 1 )
	//		( -1, +1, 0, 1 )
	//		( +1, -1, 0, 1 )
	//		( +1, +1, 0, 1 )
	//
	//	These are the corners of Metal's clipping box, as desired.
	//
	//	Note on reversed Z:
	//		Unlike in the classic Geometry Games apps,
	//		here the clipping box uses a right-handed coordinate system,
	//		with the z axis pointing towards the viewer,
	//		just like in ARKit and RealityKit.
	//		The reason for using this "reversed Z" is that
	//		if makes far better use of the available floating point
	//		values in the range 0.0…1.0 . In brief, it's because
	//		the (finite!) number of values in the range 0.5…1.0
	//		is the same as the number of values in 0.25…0.5, which
	//		is the same as the number of values in 0.125…0.25,
	//		and so on. However... the usual projection matrix
	//		sends nearby objects comfortably into the near part
	//		of the depth buffer, while packing progressively
	//		more-distant objects ever more tightly into the far part
	//		of the depth buffer. By using "reversed Z", we assign
	//		the denser part of the floating point interval 0.0…1.0
	//		(namely the low values) to the more-densely packed
	//		part of the depth buffer (namely the far part).
	//		The article
	//
	//			Depth Precision Visualized
	//			https://developer.nvidia.com/content/depth-precision-visualized
	//
	//		provides full details and careful illustrations.

	//	visionOS 1 told us the tangents l, r, t and b directly.
	//
	//	visionOS 2 instead gives us a ready-made Euclidean matrix
	//				from which we can read the required combinations
	//				of those values.
	//
	let m₀₀ = sampleProjectionMatrix[0][0]
	let m₁₁ = sampleProjectionMatrix[1][1]
	let m₂₀ = sampleProjectionMatrix[2][0]
	let m₂₁ = sampleProjectionMatrix[2][1]
	
	let n = 0.01	//	In meters. Don't let n get too close to zero.

	let m₂₂: Double
	let m₃₂: Double
	switch geometryType {
		
	case .euclidean:
		//	No problem setting the far clip to infinity
		//	in the Euclidean matrix given above.
		m₂₂ = 0.0	// as f -> ∞,  n/(f-n) -> 0
		m₃₂ = n/ρ	// as f -> ∞, nf/(ρ(f-n)) -> n/ρ
	
	case .hyperbolic:
		//	As an object goes off towards infinity in hyperbolic 3-space,
		//	the line segment in Minkowski space connecting the origin
		//	to that object tends towards the "light cone", not towards
		//	the horizontal hyperbolic plane. The light cone has slope 1,
		//	so if we set f = ρ in the Euclidean matrix given above,
		//	the player will be able to see all the way to infinity
		//	in hyperbolic 3-space.
		precondition(ρ > n, "Near clip distance must be less than space radius")
		m₂₂ = n/(ρ-n)	//	n/(f-n) = n/(ρ-n)
		m₃₂ = n/(ρ-n)	//	nf/(ρ(f-n)) = nρ/(ρ(ρ-n)) = n/(ρ-n)

	case .spherical:
		//	The article
		//
		//		Jeff Weeks,
		//		Body coherence in curved-space virtual reality games,
		//		Computers & Graphics 97 (2021) 28–41.
		//
		//		  https://www.sciencedirect.com/science/article/pii/S0097849321000443
		//		or
		//		  https://arxiv.org/abs/2011.00510
		//
		//	shows how the projection works in spherical, Euclidean
		//	and hyperbolic geometry. In particular Figure 21 shows how,
		//	in spherical geometry, the view frustum gets replaced
		//	by a "view banana". The view banana's four far corners
		//	sit directly "below" its four near corners, in the w direction:
		//
		//		( n*l, n*b, -n,  ρ )
		//		( n*l, n*t, -n,  ρ )
		//		( n*r, n*b, -n,  ρ )
		//		( n*r, n*t, -n,  ρ )
		//		( n*l, n*b, -n, -ρ )
		//		( n*l, n*t, -n, -ρ )
		//		( n*r, n*b, -n, -ρ )
		//		( n*r, n*t, -n, -ρ )
		//
		//	The far clipping plane has, in Euclidean terms, "gone beyond infinity".
		//	So what value should we use for f? The key is to note that, for example,
		//
		//		( n*l, n*b, -n, -ρ )
		//
		//	is projectively equivalent to
		//
		//		( (-n)*l, (-n)*b, -(-n), ρ )
		//
		//	Comparing that to the Euclidean expression
		//
		//		( f*l, f*b, -f, ρ )
		//
		//	suggests setting f = -n, and indeed we can check
		//	that the resulting matrix takes
		//
		//		( n*l, n*b, -n,  ρ )
		//		( n*l, n*t, -n,  ρ )
		//		( n*r, n*b, -n,  ρ )
		//		( n*r, n*t, -n,  ρ )
		//		( n*l, n*b, -n, -ρ )
		//		( n*l, n*t, -n, -ρ )
		//		( n*r, n*b, -n, -ρ )
		//		( n*r, n*t, -n, -ρ )
		//
		//	to
		//
		//		( -n, -n, n, n )
		//		( -n, +n, n, n )
		//		( +n, -n, n, n )
		//		( +n, +n, n, n )
		//		( -n, -n, 0, n )
		//		( -n, +n, 0, n )
		//		( +n, -n, 0, n )
		//		( +n, +n, 0, n )
		//
		//	which after dividing through by their last coordinate become
		//
		//		( -1, -1, 1, 1 )
		//		( -1, +1, 1, 1 )
		//		( +1, -1, 1, 1 )
		//		( +1, +1, 1, 1 )
		//		( -1, -1, 0, 1 )
		//		( -1, +1, 0, 1 )
		//		( +1, -1, 0, 1 )
		//		( +1, +1, 0, 1 )
		//
		//	as required.
		//
		//	Health Warning:
		//		There's a health risk (!) here. To focus on objects
		//		sitting more than 1/4 of the way around the 3-sphere
		//		-- but less than 1/2 way -- the player would need to look "walleyed".
		//		I've experimented with this a bit and found that my visual system
		//		can indeed look slightly walleyed, but the experience is
		//		unpleasant and vaguely distressing. Certainly it seems like
		//		a bad idea to let large numbers of people force their eyes
		//		into unnatural orientations, lest it provoke a bad reaction
		//		(migraines or worse). For this reason, when rendering
		//		in spherical geometry, all Geometry Games VR apps require
		//		that adjustPerceivedDistances be enabled to ensure that
		//		players never need to look walleyed. So this spherical
		//		projection matrix never gets used.
		//
			
		//	Here are the values of m₂₂ and m₃₂ that map
		//	the whole front hemisphere into the clipping box.
		//
		//		Note: These values never get used.
		//		See the Health Warning immediately above.
		//
		m₂₂ = -0.5		//	n/(f-n) = n/((-n)-n) = -1/2
		m₃₂ = n/(2.0*ρ)	//	nf/(ρ(f-n)) = n(-n)/(ρ((-n)-n)) = n/(2ρ)
	}
	
	let theProjectionMatrix = simd_double4x4(
		SIMD4<Double>( m₀₀,  0.0,  0.0,  0.0 ),
		SIMD4<Double>( 0.0,  m₁₁,  0.0,  0.0 ),
		SIMD4<Double>( m₂₀,  m₂₁,  m₂₂, -1.0 ),
		SIMD4<Double>( 0.0,  0.0,  m₃₂,  0.0 )
	)
	
	return theProjectionMatrix
}

func ggvrEncodeViewports(
	drawable: LayerRenderer.Drawable,
	renderEncoder: MTLRenderCommandEncoder
) {

	let theViewports = drawable.views.map() { view in view.textureMap.viewport }
	renderEncoder.setViewports(theViewports)

	let theNumViews = drawable.views.count
	if theNumViews > 1 {
	
		var theViewMappings = (0 ..< theNumViews).map() { i in
			MTLVertexAmplificationViewMapping(
				viewportArrayIndexOffset: UInt32(i),
				renderTargetArrayIndexOffset: UInt32(i))
		}
		
		renderEncoder.setVertexAmplificationCount(
			theViewports.count,
			viewMappings: &theViewMappings)
	}
}
