component

Player

First/third-person character controller (ecctrl wrapper). WASD moves; mouse-drag or the arrow keys steer the camera in either view. Reads the world's `avatar` setting for its default view and the world's `controls` for key bindings.

⌖ Explore the library in 3D → · View as Markdown

First-person controller — drop it into a world to walk.
Tune every prop in the workshop →

Add it

npx @runek/cli add player

Pulls @react-three/drei@^10.7.7, @react-three/fiber@^9.6.1, @react-three/rapier@^2.2.0, @runek/core@^0.15.0, ecctrl@^1.0.97, three@^0.184.0.

Use it

import { Player } from './runek/Player'

<Player position={[0, 0, 0]} />

Or as a node in a world file (worlds as data):

{ "type": "Player", "props": { "position": [0, 0, 0] } }

Props

Prop Type Default Description
position [x, y, z] [0, 3, 0] Position [x, y, z] in units (1 unit = 1 m, Y-up).
seed number Accepted for the component contract; this component has no seeded variation yet.
view "first" | "third" | "overhead" Camera view: first (through the avatar’s eyes), third (behind it), or overhead (high above at a fixed tilt, following it; scroll zooms, WASD walks relative to the screen). Unset defers to the world default (<World avatar>); falls back to first-person. An explicit value here always wins. A world view control (e.g. controls: { view: ['KeyV'] }) cycles the three at runtime.
yaw number 0 Initial camera yaw in radians (0 faces +z).
fov number 140 How wide the first-person view is, in degrees across, on a 16:9 screen. The default, 140, takes in most of what both eyes see, so the world wraps around you instead of sitting behind a window. Like most games it follows the screen’s shape, not its size: a wider screen (an ultrawide or curved monitor) keeps the same angle top to bottom and sees more across, up to 200°; a narrower one sees less across, down to 110°, then holds that and opens up top to bottom, until a tall phone screen just shows less across. It is drawn through a gently curved (Panini) lens: true to life straight ahead, the sides squeezed in rather than stretched as a flat camera would. Third-person and overhead keep the world camera’s own lens.
peripheralBlur number 4 How blurred the very edge of the first-person view is, in CSS pixels, easing to sharp toward the centre the way peripheral vision does; turn to look at something and it comes into focus. 0 turns it off.
children node (code only) Custom avatar visual, replacing the default capsule. Size it to the capsule envelope (1.3 units tall, centered at the character origin, so feet go at y = -0.65); the Player lowers it onto the ground beneath the floating capsule. It is hidden in first-person view. In world JSON, nest it as a child node of the Player.

Registry manifest

registry manifest /r/components/player.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add player fetches.

Browse the whole catalog in the gallery →.

Source (2 files)

Player.tsx

import { useKeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useRapier } from '@react-three/rapier'
import { type AvatarView, PlayerMotionContext, useWorld, type Vec3 } from '@runek/core'
import Ecctrl, { type CustomEcctrlRigidBody } from 'ecctrl'
import { type ReactNode, type RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { Group, Object3D, Scene } from 'three'
import { PaniniLens } from './player/PaniniLens'

export type PlayerView = AvatarView

// Keyboard look. ecctrl only moves its camera from mouse/touch/gamepad — there are no key
// actions for it — so we drive its camera rig ourselves from the `turnLeft`/`turnRight` and
// `lookUp`/`lookDown` actions in the world's controls (see `DEFAULT_CONTROLS` in `@runek/core`).
// Yaw is the pivot's `rotation.y`; pitch is the follow-cam's `rotation.x` plus a matching
// reposition along a vertical arc, exactly as ecctrl's own mouse handler does — the same math
// ecctrl runs in both views, so this works first- and third-person alike. ecctrl writes these
// only on pointer input (never per frame in CameraBasedMovement), so our additions compose
// cleanly with mouse-drag. A world whose controls omit these actions leaves the camera entirely
// to the mouse.
const TURN_SPEED = 1.8 // yaw, radians/second
const LOOK_SPEED = 1.2 // pitch, radians/second
// Pitch clamp — mirrors ecctrl's camLowLimit / camUpLimit defaults (we pass neither to Ecctrl).
const CAM_LOW = -1.3
const CAM_UP = 1.5

/**
 * ecctrl's camera pivot: a bare Object3D added to the scene whose single child is the (childless)
 * follow-cam, offset back along -z by the camera distance (or -0.01 in first person). Sign and
 * shape identify it in every view without tying anything to one of them.
 */
function findPivot(scene: Scene): Object3D | null {
  return (
    scene.children.find(
      (o) =>
        o.type === 'Object3D' &&
        o.children.length === 1 &&
        o.children[0].type === 'Object3D' &&
        o.children[0].children.length === 0 &&
        o.children[0].position.x === 0 &&
        o.children[0].position.z < -0.001,
    ) ?? null
  )
}

function CameraKeyLook({ pitchable }: { pitchable: boolean }) {
  const scene = useThree((s) => s.scene)
  const [, getKeys] = useKeyboardControls()
  const pivot = useRef<Object3D | null>(null)

  useFrame((_, dt) => {
    // Cached once found; until it mounts (or if the match ever misses) mouse-drag still turns, so
    // a miss just no-ops rather than breaking.
    if (!pivot.current) {
      pivot.current = findPivot(scene)
      if (!pivot.current) return
    }
    const step = Math.min(dt, 0.05)
    const keys = getKeys() as Record<string, boolean>

    // Yaw. Match mouse-drag's sign: dragging right decrements rotation.y, so ArrowRight does too.
    const turn = (keys.turnLeft ? 1 : 0) - (keys.turnRight ? 1 : 0)
    if (turn) pivot.current.rotation.y += turn * TURN_SPEED * step

    // Pitch. ArrowUp lowers the follow-cam to look up; ArrowDown raises it to look down. Repeat
    // ecctrl's mouse-pitch math on the follow-cam so the camera orbits the same vertical arc.
    const pitch = pitchable ? (keys.lookDown ? 1 : 0) - (keys.lookUp ? 1 : 0) : 0
    if (pitch) {
      const cam = pivot.current.children[0]
      const vy = Math.min(Math.max(cam.rotation.x + pitch * LOOK_SPEED * step, CAM_LOW), CAM_UP)
      const dist = cam.position.length()
      cam.rotation.x = vy
      cam.position.y = -dist * Math.sin(-vy)
      cam.position.z = -dist * Math.cos(-vy)
    }
  })
  return null
}

/** Publish the avatar's horizontal speed, so the body it carries walks in step. */
function MotionProbe({
  body,
  motion,
}: {
  body: RefObject<CustomEcctrlRigidBody | null>
  motion: { current: { speed: number } }
}) {
  useFrame(() => {
    const v = body.current?.group?.linvel()
    motion.current.speed = v ? Math.hypot(v.x, v.z) : 0
  })
  return null
}

/** Lower the body the avatar carries onto the ground. ecctrl holds the capsule `FLOAT_HEIGHT`
 *  above it on a spring, applied as one impulse a frame against a frame's worth of gravity, so
 *  the capsule settles lower by m·g·dt / k. Dropping the body by the rest stands its feet (the
 *  capsule's bottom) on the ground at any frame rate. */
function StandOnGround({
  body,
  lift,
}: {
  body: RefObject<CustomEcctrlRigidBody | null>
  lift: RefObject<Group | null>
}) {
  const { gravity } = useWorld()
  useFrame((_, dt) => {
    const rb = body.current?.group
    const group = lift.current
    if (!rb || !group) return
    const g = Math.abs(gravity[1]) * rb.gravityScale()
    const sag = (rb.mass() * g * Math.min(dt, 0.5)) / SPRING_K
    const drop = Math.max(FLOAT_HEIGHT - sag, 0)
    // Eased, so frame-time jitter doesn't shake the body.
    group.position.y += (-drop - group.position.y) * Math.min(1, dt * 8)
  })
  return null
}

// How long the avatar waits for ground to appear beneath it before it falls anyway (spawning
// over open water, say), in seconds.
const GROUND_WAIT = 0.5

/** Hold the avatar kinematic until a ray straight down hits a collider, so it can't drop
 *  through ground whose collider arrives a commit or two after the avatar's body. */
function GroundGuard({
  body,
  onGrounded,
}: {
  body: RefObject<CustomEcctrlRigidBody | null>
  onGrounded: () => void
}) {
  const { world, rapier } = useRapier()
  const waited = useRef(0)
  const done = useRef(false)

  useFrame((_, dt) => {
    const rb = body.current?.group
    if (done.current || !rb) return
    waited.current += dt
    const at = rb.translation()
    const ray = new rapier.Ray(at, { x: 0, y: -1, z: 0 })
    const hit = world.castRay(ray, 1000, true, undefined, undefined, undefined, rb)
    if (hit || waited.current > GROUND_WAIT) {
      done.current = true
      onGrounded()
    }
  })
  return null
}

export interface PlayerProps {
  position?: Vec3
  /** Accepted for the component contract; this component has no seeded variation yet. */
  seed?: number
  /** Camera view: `first` (through the avatar's eyes), `third` (behind it), or `overhead` (high
   *  above at a fixed tilt, following it; scroll zooms, WASD walks relative to the screen). Unset
   *  defers to the world default (`<World avatar>`); falls back to first-person. An explicit value
   *  here always wins. A world `view` control (e.g. `controls: { view: ['KeyV'] }`) cycles the
   *  three at runtime. */
  view?: PlayerView
  /** Initial camera yaw in radians (0 faces +z). */
  yaw?: number
  /** How wide the first-person view is, in degrees across, on a 16:9 screen. The default,
   *  140, takes in most of what both eyes see, so the world wraps around you instead of
   *  sitting behind a window. Like most games it follows the screen's shape, not its size: a
   *  wider screen (an ultrawide or curved monitor) keeps the same angle top to bottom and sees
   *  more across, up to 200°; a narrower one sees less across, down to 110°, then holds that
   *  and opens up top to bottom, until a tall phone screen just shows less across. It is drawn
   *  through a gently curved (Panini) lens: true to life straight ahead, the sides squeezed in
   *  rather than stretched as a flat camera would. Third-person and overhead keep the world
   *  camera's own lens. */
  fov?: number
  /** How blurred the very edge of the first-person view is, in CSS pixels, easing to sharp
   *  toward the centre the way peripheral vision does; turn to look at something and it comes
   *  into focus. 0 turns it off. */
  peripheralBlur?: number
  /** Custom avatar visual, replacing the default capsule. Size it to the capsule
   *  envelope (1.3 units tall, centered at the character origin, so feet go at y = -0.65);
   *  the Player lowers it onto the ground beneath the floating capsule. It is hidden in
   *  first-person view. In world JSON, nest it as a child node of the Player. */
  children?: ReactNode
}

/** How ecctrl frames each view. `pitch` tilts the camera down (radians); a `locked` pitch can't
 *  be changed by dragging or the look keys. */
const CAMERA: Record<
  PlayerView,
  {
    distance: number
    zoom: [number, number]
    pitch: number
    locked: boolean
    collision: boolean
    turnVel: number
    turnSpeed: number
    follow: number
    lerp: number
  }
> = {
  first: {
    distance: -0.01,
    zoom: [-0.01, -0.01],
    pitch: 0,
    locked: false,
    collision: true,
    turnVel: 1,
    turnSpeed: 100,
    follow: 1000,
    lerp: 1000,
  },
  third: {
    distance: -5,
    zoom: [-1.5, -8],
    pitch: 0,
    locked: false,
    collision: true,
    turnVel: 0.2,
    turnSpeed: 15,
    follow: 11,
    lerp: 25,
  },
  // Walls would pull the camera in toward the avatar, so overhead flies over them.
  overhead: {
    distance: -14,
    zoom: [-6, -30],
    pitch: 1,
    locked: true,
    collision: false,
    turnVel: 0.2,
    turnSpeed: 15,
    follow: 8,
    lerp: 20,
  },
}
const VIEWS: PlayerView[] = ['first', 'third', 'overhead']

const CAPSULE_RADIUS = 0.3
const CAPSULE_HALF_HEIGHT = 0.35
// ecctrl's float spring, passed explicitly because `StandOnGround` depends on both.
const FLOAT_HEIGHT = 0.3
const SPRING_K = 1.2
// Where the avatar's eyes sit on the capsule: what others look at when they look at the player.
const EYE_HEIGHT = 0.5

export function Player({
  position = [0, 3, 0],
  view,
  yaw = 0,
  fov = 140,
  peripheralBlur = 4,
  children,
}: PlayerProps) {
  const { avatar, player, keyboard, controls } = useWorld()
  const scene = useThree((s) => s.scene)
  const wanted = view ?? avatar ?? 'first'
  const [current, setCurrent] = useState<PlayerView>(wanted)
  useEffect(() => setCurrent(wanted), [wanted])
  const firstPerson = current === 'first'
  const cam = CAMERA[current]
  const eyes = useRef<Object3D>(null)
  const body = useRef<CustomEcctrlRigidBody>(null)
  const [grounded, setGrounded] = useState(false)
  const motion = useRef({ speed: 0 })
  const lift = useRef<Group>(null)

  // ecctrl reads its camera setup once, at mount, so a new view remounts it: from where the
  // avatar stands now, facing the way the camera faced. The old camera pivot is taken out of the
  // scene so nothing finds it again.
  const [mount, setMount] = useState({ view: current, at: position, yaw })
  useLayoutEffect(() => {
    if (mount.view === current) return
    const pivot = findPivot(scene)
    const t = body.current?.group?.translation()
    pivot?.removeFromParent()
    setMount({
      view: current,
      at: t ? [t.x, t.y, t.z] : mount.at,
      yaw: pivot?.rotation.y ?? mount.yaw,
    })
  }, [current, scene, mount])

  useEffect(() => {
    const keys = controls.view
    if (!keyboard || !keys?.length) return
    // One step per press: the page's key repeat arrives as more keydowns.
    const held = new Set<string>()
    const onDown = (event: Event) => {
      const { code } = event as KeyboardEvent
      if (!keys.includes(code) || held.has(code)) return
      held.add(code)
      setCurrent((v) => VIEWS[(VIEWS.indexOf(v) + 1) % VIEWS.length])
    }
    const onUp = (event: Event) => held.delete((event as KeyboardEvent).code)
    keyboard.addEventListener('keydown', onDown)
    keyboard.addEventListener('keyup', onUp)
    return () => {
      keyboard.removeEventListener('keydown', onDown)
      keyboard.removeEventListener('keyup', onUp)
    }
  }, [keyboard, controls])

  // Publish the avatar so the world can react to the player rather than the camera. Only clear
  // the slot if it's still ours, so a remount elsewhere isn't wiped by this unmount.
  useEffect(() => {
    if (!player) return
    const anchor = eyes.current
    player.current = anchor
    return () => {
      if (player.current === anchor) player.current = null
    }
    // A view change remounts ecctrl, and with it the anchor.
  }, [player, mount.view])

  return (
    <Ecctrl
      key={mount.view}
      ref={body}
      type={grounded ? 'dynamic' : 'kinematicPosition'}
      position={mount.at}
      mode="CameraBasedMovement"
      camInitDir={{ x: cam.pitch, y: mount.yaw }}
      characterInitDir={mount.yaw}
      // A locked capsule can't tip over; ecctrl turns only the visible model to face the way it
      // walks. Its balancing torque, applied once per rendered frame, could topple the avatar
      // when frames came slowly.
      autoBalance={false}
      {...(cam.locked ? { camUpLimit: cam.pitch, camLowLimit: cam.pitch } : {})}
      camCollision={cam.collision}
      capsuleRadius={CAPSULE_RADIUS}
      capsuleHalfHeight={CAPSULE_HALF_HEIGHT}
      floatHeight={FLOAT_HEIGHT}
      springK={SPRING_K}
      camInitDis={cam.distance}
      camMinDis={cam.zoom[0]}
      camMaxDis={cam.zoom[1]}
      turnVelMultiplier={cam.turnVel}
      turnSpeed={cam.turnSpeed}
      camFollowMult={cam.follow}
      camLerpMult={cam.lerp}
    >
      <group ref={lift} visible={!firstPerson} position={[0, -FLOAT_HEIGHT, 0]}>
        <PlayerMotionContext.Provider value={motion}>
          {children ?? (
            <mesh castShadow>
              <capsuleGeometry args={[CAPSULE_RADIUS, CAPSULE_HALF_HEIGHT * 2, 8, 16]} />
              <meshStandardMaterial color="#4a90d9" />
            </mesh>
          )}
        </PlayerMotionContext.Provider>
      </group>
      <object3D ref={eyes} position={[0, EYE_HEIGHT, 0]} />
      <CameraKeyLook pitchable={!cam.locked} />
      {firstPerson && <PaniniLens fov={fov} blur={peripheralBlur} />}
      {!firstPerson && <MotionProbe body={body} motion={motion} />}
      {!firstPerson && <StandOnGround body={body} lift={lift} />}
      {!grounded && <GroundGuard body={body} onGrounded={() => setGrounded(true)} />}
    </Ecctrl>
  )
}

player/PaniniLens.tsx

import { useFrame, useThree } from '@react-three/fiber'
import { type Lens, useWorld } from '@runek/core'
import { useEffect, useLayoutEffect, useMemo } from 'react'
import {
  Matrix4,
  Mesh,
  OrthographicCamera,
  PerspectiveCamera,
  PlaneGeometry,
  Scene,
  ShaderMaterial,
  SRGBColorSpace,
  Vector2,
  Vector3,
  WebGLRenderTarget,
} from 'three'

// A Panini projection: the view is wrapped onto a cylinder around the eye and seen from a point
// `CURVE` radii behind it. Straight ahead it matches a flat camera, verticals stay vertical,
// and the sides squeeze in instead of stretching, so it can show 140° to 200° across without
// the tunnel a flat camera gives. A flat camera can't see that wide, so the scene is drawn
// through three: ahead, left and right, each covering only the slice of the screen it serves,
// then stitched into the curve in one full-screen pass, which also blurs the periphery.

// How far behind the eye the cylinder is seen from: 0 is a flat camera (straight lines, stretched
// sides), 1 squeezes the sides most but bows horizontal lines. Halfway keeps lines gently curved.
const CURVE = 0.5
// How the view fits the screen's shape, as games do: on a 16:9 screen it is exactly `fov` across.
// Wider screens keep the same angle top to bottom and see more across (an ultrawide sees around
// you); narrower ones see less across, down to `NARROWEST` degrees, then hold that and open up top
// to bottom instead, until `MAX_VERTICAL_FOV` makes a tall screen show less across.
const REFERENCE_ASPECT = 16 / 9
const NARROWEST = 110
// The widest the view may open across, with all three cameras still covering it.
const MAX_FOV = 200
// The tallest it may open through the centre, so a tall screen narrows the view instead of
// looking down a well.
const MAX_VERTICAL_FOV = 120
// Where focus starts to fall off and where it is fully blurred, as an ellipse of angles off
// straight ahead (across, then up): wider than tall, like the eye's own field.
const FOCUS = { across: (70 * Math.PI) / 180, up: (45 * Math.PI) / 180, from: 0.5, to: 1.3 }
// Where the side cameras take over from the forward one, either side of straight ahead.
const SEAM = Math.PI / 4
// Slack around each camera's slice (in tangent units), so sampling at a seam never runs off it.
const PAD = 0.02
// Samples in the periphery blur, spread over a disc two standard deviations wide.
const TAPS = 32

/** How far across the projection plane a direction `phi` radians off straight ahead lands. */
const across = (phi: number) => ((CURVE + 1) * Math.sin(phi)) / (CURVE + Math.cos(phi))

/** The inverse of `across`: the angle off straight ahead seen at `x` across the plane. */
function angle(x: number) {
  const k = x / (CURVE + 1)
  return Math.atan(k) + Math.asin((k * CURVE) / Math.sqrt(1 + k * k))
}

const half = (degrees: number) => (degrees * Math.PI) / 360

/** The lens's half-extent on the projection plane, horizontally and vertically, for a screen
 *  `aspect` wide to its height (see `REFERENCE_ASPECT`). */
function bounds(fov: number, aspect: number): Vector2 {
  let x = (across(half(fov)) / REFERENCE_ASPECT) * aspect
  x = Math.max(x, across(half(Math.min(fov, NARROWEST))))
  x = Math.min(x, across(half(MAX_FOV)))
  const y = Math.min(x / aspect, Math.tan(half(MAX_VERTICAL_FOV)))
  return new Vector2(y * aspect, y)
}

function panini(extent: Vector2): Lens {
  return {
    project(p) {
      const h = Math.hypot(p.x, p.z)
      const phi = Math.atan2(p.x, -p.z)
      if (h < 1e-6 || CURVE + Math.cos(phi) < 0.05) return null
      const s = (CURVE + 1) / (CURVE + Math.cos(phi))
      return p.set((s * Math.sin(phi)) / extent.x, (s * p.y) / h / extent.y, 0)
    },
    unproject(ndc, target) {
      const phi = angle(ndc.x * extent.x)
      const t = (ndc.y * extent.y * (CURVE + Math.cos(phi))) / (CURVE + 1)
      return target.set(Math.sin(phi), t, -Math.cos(phi)).normalize()
    },
  }
}

interface View {
  camera: PerspectiveCamera
  /** Turns the forward camera to face this way, in camera space. */
  turn: Matrix4
  target: WebGLRenderTarget
  /** Camera-space direction → this view's clip space, for the stitching pass. */
  sample: Matrix4
  used: boolean
}

function makeView(yaw: number): View {
  const target = new WebGLRenderTarget(1, 1)
  // Marked as XR so three tone-maps and encodes each material into it exactly as it would onto
  // the screen (respecting `toneMapped: false`), stored as plain 8-bit sRGB: the stitching pass
  // then copies pixels rather than redoing the color pipeline.
  target.texture.colorSpace = SRGBColorSpace
  target.texture.internalFormat = 'RGBA8'
  ;(target as { isXRRenderTarget?: boolean }).isXRRenderTarget = true
  const camera = new PerspectiveCamera()
  camera.matrixAutoUpdate = false
  camera.matrixWorldAutoUpdate = false
  return {
    camera,
    turn: new Matrix4().makeRotationY(yaw),
    target,
    sample: new Matrix4(),
    used: true,
  }
}

const VERTEX = /* glsl */ `
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = vec4(position.xy, 0.0, 1.0);
}
`

const FRAGMENT = /* glsl */ `
uniform sampler2D uAhead;
uniform sampler2D uLeft;
uniform sampler2D uRight;
uniform mat4 uAheadM;
uniform mat4 uLeftM;
uniform mat4 uRightM;
uniform vec2 uExtent;
uniform vec2 uResolution;
uniform float uBlur;
varying vec2 vUv;

const float CURVE = ${CURVE.toFixed(4)};

vec4 look(sampler2D view, mat4 m, vec3 dir) {
  vec4 c = m * vec4(dir, 1.0);
  return texture2D(view, c.xy / c.w * 0.5 + 0.5);
}

// The direction seen at a point on the screen, one unit out from the eye's vertical axis.
vec3 ray(vec2 uv) {
  vec2 p = (uv * 2.0 - 1.0) * uExtent;
  float k = p.x / (CURVE + 1.0);
  float phi = atan(k) + asin(k * CURVE / sqrt(1.0 + k * k));
  return vec3(sin(phi), p.y * (CURVE + cos(phi)) / (CURVE + 1.0), -cos(phi));
}

vec4 seen(vec2 uv) {
  vec3 dir = ray(uv);
  float phi = atan(dir.x, -dir.z);
  if (phi > ${SEAM.toFixed(6)}) return look(uRight, uRightM, dir);
  if (phi < -${SEAM.toFixed(6)}) return look(uLeft, uLeftM, dir);
  return look(uAhead, uAheadM, dir);
}

void main() {
  // Focus falls off smoothly with the angle off straight ahead, so a wider screen shows more
  // periphery rather than a wider sharp patch.
  vec3 dir = ray(vUv);
  vec2 off = vec2(atan(dir.x, -dir.z) / ${FOCUS.across.toFixed(6)}, atan(dir.y) / ${FOCUS.up.toFixed(6)});
  float sigma = uBlur * smoothstep(${FOCUS.from.toFixed(2)}, ${FOCUS.to.toFixed(2)}, length(off));
  if (sigma < 0.35) {
    gl_FragColor = seen(vUv);
    return;
  }
  vec4 sum = vec4(0.0);
  float total = 0.0;
  for (int i = 0; i < ${TAPS}; i++) {
    float r = sqrt((float(i) + 0.5) / ${TAPS}.0) * 2.0 * sigma;
    float a = float(i) * 2.39996;
    float w = exp(-0.5 * r * r / (sigma * sigma));
    sum += seen(vUv + vec2(cos(a), sin(a)) * r / uResolution) * w;
    total += w;
  }
  gl_FragColor = sum / total;
}
`

/**
 * Draw the world through a Panini lens `fov` degrees across, in place of the camera's own flat
 * projection, while mounted, blurring toward the edges by up to `blur` CSS pixels. Takes over the
 * frame's render (a `useFrame` at priority 1), maps pointer events through the lens, and
 * publishes it as the world's `lens` so labels follow.
 */
export function PaniniLens({ fov, blur }: { fov: number; blur: number }) {
  const gl = useThree((s) => s.gl)
  const camera = useThree((s) => s.camera)
  const size = useThree((s) => s.size)
  const dpr = useThree((s) => s.viewport.dpr)
  const setEvents = useThree((s) => s.setEvents)
  const getState = useThree((s) => s.get)
  const { lens } = useWorld()

  const extent = useMemo(() => bounds(fov, size.width / size.height), [fov, size])
  const optics = useMemo(() => panini(extent), [extent])

  const views = useMemo(
    () => ({ ahead: makeView(0), left: makeView(SEAM * 2), right: makeView(-SEAM * 2) }),
    [],
  )
  const pass = useMemo(() => {
    const material = new ShaderMaterial({
      vertexShader: VERTEX,
      fragmentShader: FRAGMENT,
      uniforms: {
        uAhead: { value: views.ahead.target.texture },
        uLeft: { value: views.left.target.texture },
        uRight: { value: views.right.target.texture },
        uAheadM: { value: views.ahead.sample },
        uLeftM: { value: views.left.sample },
        uRightM: { value: views.right.sample },
        uExtent: { value: new Vector2() },
        uResolution: { value: new Vector2() },
        uBlur: { value: 0 },
      },
      depthTest: false,
      depthWrite: false,
      toneMapped: false,
    })
    const quad = new Mesh(new PlaneGeometry(2, 2), material)
    quad.frustumCulled = false
    const scene = new Scene()
    scene.add(quad)
    return { scene, camera: new OrthographicCamera(), material, quad }
  }, [views])

  useEffect(
    () => () => {
      for (const view of Object.values(views)) view.target.dispose()
      pass.material.dispose()
      pass.quad.geometry.dispose()
    },
    [views, pass],
  )

  // Fit each camera's frustum to the slice of the screen it serves, and size its target so
  // straight ahead keeps the screen's pixel density.
  useLayoutEffect(() => {
    const { near, far } = camera as PerspectiveCamera
    const widest = angle(extent.x)
    const ahead = Math.min(widest, SEAM)
    // Each view's vertical reach is greatest along the screen's top edge; walk it.
    const reach = (from: number, to: number, depth: (phi: number) => number) => {
      let up = 0
      for (let i = 0; i <= 64; i++) {
        const phi = from + ((to - from) * i) / 64
        const t = (extent.y * (CURVE + Math.cos(phi))) / (CURVE + 1)
        up = Math.max(up, t / depth(phi))
      }
      return up
    }
    const sides = widest > SEAM
    const edge = 1 / Math.tan(widest)
    const spans = [
      {
        view: views.ahead,
        used: true,
        left: -Math.tan(ahead),
        right: Math.tan(ahead),
        up: reach(0, ahead, Math.cos),
      },
      {
        view: views.left,
        used: sides,
        left: edge,
        right: 1,
        up: sides ? reach(SEAM, widest, Math.sin) : 0,
      },
      {
        view: views.right,
        used: sides,
        left: -1,
        right: -edge,
        up: sides ? reach(SEAM, widest, Math.sin) : 0,
      },
    ]

    const density = (size.width * dpr) / (2 * extent.x)
    const limit = gl.capabilities.maxTextureSize
    const turned = new Matrix4()
    for (const span of spans) {
      const { view } = span
      view.used = span.used
      if (!span.used) continue
      const left = span.left - PAD
      const right = span.right + PAD
      const up = span.up + PAD
      view.target.setSize(
        Math.min(limit, Math.max(1, Math.ceil((right - left) * density))),
        Math.min(limit, Math.max(1, Math.ceil(2 * up * density))),
      )
      view.camera.near = near
      view.camera.far = far
      view.camera.projectionMatrix.makePerspective(
        left * near,
        right * near,
        up * near,
        -up * near,
        near,
        far,
      )
      view.camera.projectionMatrixInverse.copy(view.camera.projectionMatrix).invert()
      view.sample.copy(view.camera.projectionMatrix).multiply(turned.copy(view.turn).invert())
    }
    const { uniforms } = pass.material
    uniforms.uExtent.value.copy(extent)
    uniforms.uResolution.value.set(size.width * dpr, size.height * dpr)
    uniforms.uBlur.value = blur * dpr
  }, [views, pass, extent, size, dpr, camera, gl, blur])

  // Publish the lens, and aim pointer rays through it rather than the flat camera.
  useEffect(() => {
    if (lens) lens.current = optics
    const previous = getState().events.compute
    const dir = new Vector3()
    const eye = new Vector3()
    setEvents({
      compute: (event, state) => {
        state.pointer.set(
          (event.offsetX / state.size.width) * 2 - 1,
          -(event.offsetY / state.size.height) * 2 + 1,
        )
        const { camera, raycaster } = state
        optics.unproject(state.pointer, dir).transformDirection(camera.matrixWorld)
        raycaster.set(camera.getWorldPosition(eye), dir)
        raycaster.camera = camera
      },
    })
    return () => {
      if (lens?.current === optics) lens.current = null
      setEvents({ compute: previous })
    }
  }, [optics, lens, setEvents, getState])

  useFrame(({ scene }) => {
    // The controller has moved the camera this frame; nothing has folded that into its matrix.
    camera.updateMatrixWorld()
    const shadows = gl.shadowMap.autoUpdate
    const transforms = scene.matrixWorldAutoUpdate
    const target = gl.getRenderTarget()
    let first = true
    for (const view of Object.values(views)) {
      if (!view.used) continue
      view.camera.layers.mask = camera.layers.mask
      view.camera.matrixWorld.multiplyMatrices(camera.matrixWorld, view.turn)
      view.camera.matrixWorldInverse.copy(view.camera.matrixWorld).invert()
      gl.setRenderTarget(view.target)
      gl.render(scene, view.camera)
      // Shadows and transforms are the same from every side: settle them once a frame.
      if (first) {
        gl.shadowMap.autoUpdate = false
        scene.matrixWorldAutoUpdate = false
      }
      first = false
    }
    gl.shadowMap.autoUpdate = shadows
    scene.matrixWorldAutoUpdate = transforms
    gl.setRenderTarget(target)
    gl.render(pass.scene, pass.camera)
  }, 1)

  return null
}