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.
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 whatrunek 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
}