fuzzyworld

tower / files / src / tour / director.ts

director.ts

13.1 KB · history · blame

1/**
2 * Where the camera looks, while a flyer carries it. See world-generation.md §19.
3 *
4 * A leg of the tour is a craft on a path; the director decides, moment by moment, what is
5 * worth pointing the camera at from there. Everything worth a look is a point of interest
6 * with a weight and a size, and each is scored by how well it can be seen right now:
7 *
8 * - **through the air**, by the same integral of the rotational density profile the
9 * renderer hazes with (canon/atmosphere.ts), so the camera is never turned to stare at
10 * something the haze has eaten;
11 * - **past the ground**, sampled along the sightline, so a summit behind a ridge is not
12 * a subject until the ridge is passed;
13 * - **as big as it looks**, since a tarn is worth a look from 3 km and not from 60;
14 * - and **where a pilot could look**: within so many degrees of the nose.
15 *
16 * The director holds a subject for a while, lets the leg's own subject win most arguments,
17 * tires of anything it has looked at for long, and eases from one aim into the next over a
18 * few seconds. All of it is worked out once, when a leg is first played, so a pose is a pure
19 * function of time: the screenshot tour can hold any moment, and a test can fly every leg.
20 */
21import { LENGTH_M, OMEGA_RAD_S, RADIUS_M } from '../canon/constants.js'
22import { extinctionAtRadius } from '../canon/atmosphere.js'
23import {
24 type At, type V3, add, basisAt, cross, dot, ease, fromV, len, mix, norm, radial, scale, sub, toV,
25} from './geo.js'
26
27export interface Poi {
28 id: string
29 name: string
30 /** How much it is worth looking at, before distance and haze. About 1 for a named summit. */
31 weight: number
32 /** How big it is, metres: what makes it worth a look from further off. */
33 sizeM: number
34 /** Where it is at leg time `t`, or null while it is not there (the sun at night). */
35 at: (t: number) => V3 | null
36 /** Things on the ground can be hidden by it. The sun and the caps cannot. */
37 grounded: boolean
38}
39
40export interface CraftSample {
41 pos: V3
42 /** The way the craft is pointed: along its motion, or its own heading when it hovers. */
43 fwd: V3
44 /** The craft's own up, banked into its turns. */
45 up: V3
46}
47
48export interface Track {
49 durationS: number
50 sample: (t: number) => CraftSample
51}
52
53const DT = 0.25
54
55/**
56 * A craft's track from the path it flies: where it is, which way it points, and how far it
57 * banks. The bank is what a real wing does — lift tilted so that, with the local gravity,
58 * it gives the sideways acceleration the turn needs — so a tight turn near the axis, where
59 * gravity is a tenth of the floor's, banks hard, and the same turn at the floor barely does.
60 * `heading`, radians from deepward toward spinward, is for a craft that points somewhere
61 * other than where it drifts, or hovers.
62 */
63export function trackOf(path: (t: number) => At, durationS: number,
64 opts: { heading?: (t: number) => number; bank?: boolean; level?: boolean } = {}): Track {
65 const n = Math.ceil(durationS / DT) + 1
66 const pos: V3[] = []
67 for (let i = -2; i <= n + 2; i++) pos.push(toV(path(i * DT)))
68 const P = (i: number): V3 => pos[i + 2]!
69 const out: CraftSample[] = []
70 let lastFwd: V3 | null = null
71 for (let i = 0; i <= n; i++) {
72 const p = P(i)
73 const { up, spin, deep } = basisAt(p)
74 const vel = scale(sub(P(i + 1), P(i - 1)), 1 / (2 * DT))
75 let fwd: V3
76 if (opts.heading) {
77 const h = opts.heading(i * DT)
78 fwd = add(scale(deep, Math.cos(h)), scale(spin, Math.sin(h)))
79 // Pitched with the climb, when it is moving — unless it keeps level, as an airship does.
80 if (len(vel) > 0.5 && !opts.level) fwd = norm(add(fwd, scale(up, dot(norm(vel), up))))
81 } else if (len(vel) > 0.2) fwd = norm(vel)
82 else fwd = lastFwd ?? deep
83 lastFwd = fwd
84 let craftUp = norm(sub(up, scale(fwd, dot(up, fwd))))
85 if (opts.level) fwd = norm(sub(fwd, scale(up, dot(fwd, up))))
86 if (opts.bank !== false && !opts.level && len(vel) > 0.5) {
87 const acc = scale(add(sub(P(i + 2), scale(p, 2)), P(i - 2)), 1 / (4 * DT * DT))
88 const side = sub(sub(acc, scale(fwd, dot(acc, fwd))), scale(craftUp, dot(acc, craftUp)))
89 const g = OMEGA_RAD_S ** 2 * Math.max(1000, radial(p))
90 let lift = add(scale(craftUp, g), side)
91 // No steeper than 50°: past that the craft is not turning, it is falling.
92 const tilt = Math.acos(Math.max(-1, Math.min(1, dot(norm(lift), craftUp))))
93 if (tilt > 0.87) lift = mix(craftUp, norm(lift), 0.87 / tilt)
94 craftUp = norm(sub(lift, scale(fwd, dot(lift, fwd))))
95 }
96 out.push({ pos: p, fwd, up: craftUp })
97 }
98 return {
99 durationS,
100 sample: (t) => {
101 const x = Math.max(0, Math.min(n, t / DT))
102 const i = Math.min(n - 1, Math.floor(x)), u = x - i
103 const a = out[i]!, b = out[i + 1]!
104 return { pos: mix(a.pos, b.pos, u), fwd: norm(mix(a.fwd, b.fwd, u)), up: norm(mix(a.up, b.up, u)) }
105 },
106 }
107}
108
109// ------------------------------------------------------------------- seeing
110
111/** Transmission of green light from p to q, through the air between the caps. */
112export function transmission(p: V3, q: V3): number {
113 const N = 16
114 const d = sub(q, p)
115 const step = len(d) / N
116 let tau = 0
117 for (let i = 0; i < N; i++) {
118 const x = add(p, scale(d, (i + 0.5) / N))
119 if (x.z < 0 || x.z > LENGTH_M) continue
120 tau += extinctionAtRadius(Math.min(RADIUS_M, radial(x))) * step
121 }
122 return Math.exp(-tau)
123}
124
125/**
126 * True when nothing on the floor stands between p and q. The last `shortM` of the sightline
127 * is not tested: a summit's point of interest is inside the mountain it names.
128 */
129export function clearOfGround(p: V3, q: V3, ground: (theta: number, z: number) => number, shortM = 0): boolean {
130 const d = sub(q, p)
131 const upTo = Math.max(0, 1 - shortM / (len(d) || 1))
132 for (let i = 1; i < 16; i++) {
133 if (i / 16 > upTo) break
134 const x = add(p, scale(d, i / 16))
135 if (x.z < 0 || x.z > LENGTH_M) continue
136 const at = fromV(x)
137 if (at.altM < ground(at.a / RADIUS_M, at.z) + 3) return false
138 }
139 return true
140}
141
142// -------------------------------------------------------------------- gazing
143
144export interface GazeOpts {
145 pois: Poi[]
146 /** The leg's own subject: preferred, by `subjectBoost`, whenever it can be seen. */
147 subject?: Poi | null
148 subjectBoost?: number
149 /** How far off the nose the camera may turn, radians. */
150 limitRad: number
151 /** How far above the horizon it may look, radians. */
152 maxPitch?: number
153 ground: (theta: number, z: number) => number
154}
155
156export interface Gaze {
157 /** The direction to look at leg time `t`, from the craft. */
158 aim: (t: number) => V3
159 /** What it looked at, and from when. */
160 targets: { t: number; id: string }[]
161}
162
163/** Seconds a new subject holds the camera at least; seconds turning from one to the next. */
164export const MIN_DWELL_S = 4.5
165export const TURN_S = 3
166/** The fastest the aim swings, radians a second, and the step it is worked out in. */
167const MAX_SLEW = 0.7
168const SLEW_DT = 0.1
169/** Seconds after which a subject starts to bore the director. */
170const TIRES_S = 7
171
172/**
173 * Something to fall back on: the way ahead, a little below the horizon, where a pilot's
174 * eye goes when nothing calls it away.
175 */
176const AHEAD: Poi = { id: 'ahead', name: 'the way ahead', weight: 0.12, sizeM: 1, at: () => null, grounded: false }
177
178export function gaze(track: Track, opts: GazeOpts): Gaze {
179 const pois = opts.subject ? [opts.subject, ...opts.pois.filter((p) => p.id !== opts.subject!.id)] : opts.pois
180 const boost = opts.subjectBoost ?? 4
181 const maxPitch = opts.maxPitch ?? 1.2
182
183 const dirTo = (poi: Poi, t: number): V3 => {
184 const c = track.sample(t)
185 if (poi === AHEAD) {
186 const { up } = basisAt(c.pos)
187 return norm(sub(c.fwd, scale(up, 0.2)))
188 }
189 const q = poi.at(t)
190 return q ? norm(sub(q, c.pos)) : c.fwd
191 }
192
193 /** How good a look at `poi` there is from the craft at `t`, before any ground is checked. */
194 const score = (poi: Poi, t: number): number => {
195 if (poi === AHEAD) return AHEAD.weight
196 const q = poi.at(t)
197 if (!q) return 0
198 const c = track.sample(t)
199 const d = sub(q, c.pos)
200 const dist = len(d)
201 if (dist < Math.max(40, poi.sizeM * 0.2) || poi.sizeM / dist < 0.004) return 0
202 const dir = scale(d, 1 / dist)
203 const off = Math.acos(Math.max(-1, Math.min(1, dot(dir, c.fwd))))
204 if (off > opts.limitRad) return 0
205 const { up } = basisAt(c.pos)
206 const pitch = Math.asin(Math.max(-1, Math.min(1, dot(dir, up))))
207 if (pitch > maxPitch || pitch < -1.25) return 0
208 const T = transmission(c.pos, q)
209 if (T < 0.15) return 0
210 const size = Math.sqrt(Math.min(1, poi.sizeM / dist / 0.08))
211 const nose = 1 - 0.5 * (off / opts.limitRad) ** 2
212 return poi.weight * T * T * size * nose * (poi === opts.subject ? boost : 1)
213 }
214
215 const visible = (poi: Poi, t: number): boolean => {
216 if (poi === AHEAD || !poi.grounded) return true
217 const q = poi.at(t)
218 if (!q) return false
219 const from = track.sample(t).pos
220 return clearOfGround(from, q, opts.ground, Math.min(poi.sizeM * 0.3, len(sub(q, from)) * 0.5))
221 }
222
223 /** The best few by score, the first of them that the ground does not hide. */
224 const best = (t: number, weariness: (p: Poi) => number): { poi: Poi; s: number } => {
225 const ranked = pois.map((poi) => ({ poi, s: score(poi, t) * weariness(poi) }))
226 .filter((x) => x.s > 0).sort((a, b) => b.s - a.s)
227 for (const x of ranked.slice(0, 4)) if (visible(x.poi, t)) return x
228 return { poi: AHEAD, s: AHEAD.weight }
229 }
230
231 const targets: { t: number; poi: Poi }[] = []
232 let cur = best(0, () => 1)
233 targets.push({ t: 0, poi: cur.poi })
234 for (let t = 0.5; t < track.durationS - 1; t += 0.5) {
235 const since = t - targets[targets.length - 1]!.t
236 if (since < MIN_DWELL_S) continue
237 const tired = since > TIRES_S ? 0.5 : 1
238 const now = score(cur.poi, t) * tired
239 const stillThere = now > 0 && visible(cur.poi, t)
240 const next = best(t, (p) => (p === cur.poi ? tired : 1))
241 if (next.poi !== cur.poi && (!stillThere || next.s > now * 1.35)) {
242 // Only toward something the turn can reach without swinging the camera right round.
243 if (stillThere && dot(dirTo(next.poi, t), dirTo(cur.poi, t)) < -0.2) continue
244 cur = next
245 targets.push({ t, poi: next.poi })
246 }
247 }
248
249 const raw = (t: number): V3 => {
250 let k = 0
251 while (k + 1 < targets.length && targets[k + 1]!.t <= t) k++
252 const now = dirTo(targets[k]!.poi, t)
253 if (k === 0 || t - targets[k]!.t >= TURN_S) return now
254 const was = dirTo(targets[k - 1]!.poi, t)
255 return norm(mix(was, now, ease((t - targets[k]!.t) / TURN_S)))
256 }
257 /**
258 * And no faster than a camera operator would swing it: something close going by, or a
259 * time-lapse, can sweep the raw aim round faster than anyone could follow.
260 */
261 const n = Math.ceil(track.durationS / SLEW_DT) + 1
262 const aims: V3[] = [raw(0)]
263 for (let i = 1; i <= n; i++) {
264 const want = raw(i * SLEW_DT), had = aims[i - 1]!
265 const ang = Math.acos(Math.max(-1, Math.min(1, dot(want, had))))
266 const most = MAX_SLEW * SLEW_DT
267 aims.push(ang <= most ? want : norm(mix(had, want, most / ang)))
268 }
269 return {
270 targets: targets.map((x) => ({ t: x.t, id: x.poi.id })),
271 aim: (t) => {
272 const x = Math.max(0, Math.min(n, t / SLEW_DT))
273 const i = Math.min(n - 1, Math.floor(x))
274 return norm(mix(aims[i]!, aims[i + 1]!, x - i))
275 },
276 }
277}
278
279// ---------------------------------------------------------------------- rigs
280
281/** Where the camera rides on the craft. */
282export type Rig =
283 /** In the craft, looking where the director says, the horizon tilting with the bank. */
284 | { kind: 'cockpit' }
285 /**
286 * Behind it, on the far side from what the director is looking at, so the craft and its
287 * subject share the frame. `swingRad` walks the camera round the craft over the leg.
288 */
289 | { kind: 'chase'; back: number; raise: number; swingRad?: number }
290
291export interface Eye {
292 eye: V3
293 dir: V3
294 /** The camera's up. */
295 up: V3
296}
297
298/** The camera for a rig, at leg time `t`. Chase cameras are kept `clearM` over the ground. */
299export function rigEye(rig: Rig, track: Track, aim: (t: number) => V3, t: number,
300 ground: (theta: number, z: number) => number, clearM = 15): Eye {
301 const c = track.sample(t)
302 const a = aim(t)
303 const { up } = basisAt(c.pos)
304 if (rig.kind === 'cockpit') {
305 const eye = add(c.pos, scale(c.up, 2))
306 // The horizon tilts with the bank when looking out ahead, and less when looking aside.
307 const lean = 0.8 * Math.max(0, dot(a, c.fwd))
308 return { eye, dir: a, up: norm(mix(up, c.up, lean)) }
309 }
310 // The camera's bearing from the craft: opposite the aim, walked round by the swing.
311 const swing = (rig.swingRad ?? 0) * (t / track.durationS - 0.5)
312 let back = scale(a, -1)
313 if (swing) {
314 const side = cross(up, back)
315 const along = scale(up, dot(back, up))
316 const flat = sub(back, along)
317 back = add(along, add(scale(flat, Math.cos(swing)), scale(side, Math.sin(swing))))
318 }
319 let eye = add(add(c.pos, scale(norm(back), rig.back)), scale(up, rig.raise))
320 const at = fromV(eye)
321 const floor = ground(at.a / RADIUS_M, at.z) + clearM
322 if (at.altM < floor) eye = toV({ ...at, altM: floor })
323 const look = add(c.pos, scale(a, rig.back * 0.8))
324 return { eye, dir: norm(sub(look, eye)), up: norm(mix(up, c.up, 0.3)) }
325}
326