Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/core/CameraState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,5 @@ export const lensEquals = (a: CameraState, b: CameraState): boolean =>
a.viewOffset[1] === b.viewOffset[1];

export { lerp } from './blend/lerpCameraState';
export { mix } from './groups/mixCameraStates';
// Groups are not public until they are finished and tested.
// export { mix } from './groups/mixCameraStates';
32 changes: 24 additions & 8 deletions src/core/Klipp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export class Klipp extends EventDispatcher<CameraTransitionEventMap> {
readonly state = klippState.create();
/** The pose and lens new virtual cameras start from. */
readonly initialCameraState: CameraState;
mode: KlippMode;

protected readonly cameras = new Set<VirtualCamera>();
private readonly updates = new Set<FrameUpdate>();
Expand All @@ -45,6 +44,7 @@ export class Klipp extends EventDispatcher<CameraTransitionEventMap> {
private readonly liveIdListeners = new Set<() => void>();
private draining = false;
private roundRobinTurn = 0;
private _mode: KlippMode = 'enabled';

constructor(options: KlippOptions = {}) {
super();
Expand All @@ -56,6 +56,16 @@ export class Klipp extends EventDispatcher<CameraTransitionEventMap> {
};
}

get mode(): KlippMode {
return this._mode;
}

/** Switching back to `'enabled'` writes the next shot even if it did not change, to take the camera back. */
set mode(mode: KlippMode) {
if (mode === 'enabled' && this._mode !== 'enabled') this.settled = false;
this._mode = mode;
}

setDefaultBlend(defaultBlend?: BlendDefinition): void {
this.params.defaultBlend = defaultBlend ?? DEFAULT_BLEND;
}
Expand Down Expand Up @@ -300,13 +310,19 @@ export class Klipp extends EventDispatcher<CameraTransitionEventMap> {
if (this.draining) return;
this.draining = true;
const events = this.state.events;
for (let i = 0; i < events.length; i++) {
const event = events[i];
if (event.type === 'activeIdChanged') for (const listener of this.activeIdListeners) listener();
else if (event.type === 'liveIdChanged') for (const listener of this.liveIdListeners) listener();
else this.dispatchEvent(event);
let i = 0;
try {
for (; i < events.length; i++) {
const event = events[i];
if (event.type === 'activeIdChanged') for (const listener of this.activeIdListeners) listener();
else if (event.type === 'liveIdChanged') for (const listener of this.liveIdListeners) listener();
else this.dispatchEvent(event);
}
} finally {
// A throwing listener drops only the events delivered so far, the rest wait for the next drain.
if (i >= events.length) events.length = 0;
else events.splice(0, i + 1);
this.draining = false;
}
events.length = 0;
this.draining = false;
}
}
4 changes: 1 addition & 3 deletions src/core/aim/PanTiltAim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ export class PanTiltAim<T = TargetPose | null> {
this.target = target;
}

update = (out: CameraState, dt: number): void => {
panTilt.update(out, this.state, this.targetRotation(), dt);
};
update = (out: CameraState, dt: number): boolean => panTilt.update(out, this.state, this.targetRotation(), dt);

/** Seeds `pan`/`tilt` from `rotation`'s forward direction, relative to the current reference frame. */
setFromRotation = (rotation: Quat, referenceUp: Vec3): void => {
Expand Down
9 changes: 5 additions & 4 deletions src/core/aim/panTilt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,16 @@ function projectOnPlane(out: Vec3, v: Vec3, normal: Vec3): Vec3 {
return vec3.scaleAndAdd(out, v, normal, -vec3.dot(normal, v) / lengthSq);
}

/** Advances both axes and writes the resulting rotation to `out`. */
export function update(out: CameraState, state: PanTiltState, targetRotation: Quat | null, dt: number): void {
state.pan.update(dt);
state.tilt.update(dt);
/** Advances both axes and writes the resulting rotation to `out`. Returns `true` while an axis still has to move. */
export function update(out: CameraState, state: PanTiltState, targetRotation: Quat | null, dt: number): boolean {
const panMoving = state.pan.update(dt);
const tiltMoving = state.tilt.update(dt);

referenceFrame(scratchFrame, targetRotation, out.referenceUp);
scratchEuler[0] = -degreesToRadians(state.tilt.value);
scratchEuler[1] = -degreesToRadians(state.pan.value);
quat.multiply(out.quaternion, scratchFrame, quat.fromEuler(scratchRotation, scratchEuler));
return panMoving || tiltMoving;
}

/**
Expand Down
2 changes: 0 additions & 2 deletions src/core/blend/BlendHints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ export const BlendHints = {
none: 0,
cylindricalPosition: 1 << 0,
sphericalPosition: 1 << 1,
screenSpaceAimWhenTargetsDiffer: 1 << 2,
ignoreTarget: 1 << 3,
inheritPosition: 1 << 4,
} as const;

export type BlendHints = number;
Expand Down
2 changes: 1 addition & 1 deletion src/core/extension/groupFraming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export function update(
damping.reset(state.screenPositionYDamper);
}
const boundsRadius = computeBounds(scratchGroupPosition, members, positionMode);
if (boundsRadius <= 0) return false;
if (boundsRadius < 0 || (boundsRadius === 0 && params.padding <= 0)) return false;

const verticalHalfFov = degreesToRadians(out.fov) / 2;
const aspect = params.viewportWidth / params.viewportHeight;
Expand Down
4 changes: 2 additions & 2 deletions src/core/groups/MixingCamera.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as cameraState from '../CameraState';
import type { CameraState } from '../CameraState';

import type { MixingCameraSlot } from './mixCameraStates';
import { mix, type MixingCameraSlot } from './mixCameraStates';

const MAX_SLOTS = 8;

Expand All @@ -28,6 +28,6 @@ export class MixingCamera {
/** Recomputes the weighted mix from the slots' current weights and returns it - same scratch instance
* every call. If every weight is zero (or negative), returns the previous output unchanged. */
tick(): CameraState {
return cameraState.mix(this.output, this.slots);
return mix(this.output, this.slots);
}
}
3 changes: 2 additions & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export * from './body/index';
export * from './damping/index';
export * from './debug/index';
export * from './extension/index';
export * from './groups/index';
// Groups are not public until they are finished and tested.
// export * from './groups/index';
export * from './impulse/index';
export * from './input/index';
export * from './noise/index';
4 changes: 2 additions & 2 deletions src/core/input/InputAxis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export class InputAxis implements InputAxisData {

applyDelta = (delta: number): void => inputAxis.applyDelta(this, delta);

/** Advance the axis and apply damping or recentering. */
update = (dt: number): void => inputAxis.update(this, dt);
/** Advance the axis and apply damping or recentering. Returns `true` while it still has to move. */
update = (dt: number): boolean => inputAxis.update(this, dt);

/** Reset damping so the next update snaps to the raw value. */
reset = (): void => inputAxis.reset(this);
Expand Down
14 changes: 12 additions & 2 deletions src/core/input/axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,17 @@ export function normalize(axis: InputAxisData): void {
axis.rawValue = min + repeat(axis.rawValue - min, span);
}

/** Advance the axis and apply damping or recentering. */
export function update(axis: InputAxisData, dt: number): void {
/** Whether the axis still has to move: easing toward its target, or a recentering yet to finish. */
function isMoving(axis: InputAxisData): boolean {
if (axis.value !== axis.rawValue) return true;
if (axis.held || !axis.recentering.enabled) return false;
const offCenter =
axis.wrap && axis.range ? shortestWrappedDelta(axis.value, axis.center, axis.range) : axis.center - axis.value;
return Math.abs(offCenter) > 1e-9;
}

/** Advance the axis and apply damping or recentering. Returns `true` while it still has to move. */
export function update(axis: InputAxisData, dt: number): boolean {
const active = axis.held || axis.hadDelta;
axis.hadDelta = false;
if (active) axis.idleTime = 0;
Expand All @@ -112,6 +121,7 @@ export function update(axis: InputAxisData, dt: number): void {
if (isRecentering) axis.rawValue = axis.value;

if (axis.autoNormalize && axis.value === axis.rawValue) normalize(axis);
return isMoving(axis);
}

/** Reset damping so the next update snaps to the raw value. */
Expand Down
2 changes: 2 additions & 0 deletions src/react/Klipp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type KlippProps = Pick<KlippOptions, 'defaultBlend' | 'customBlends'> & {
export function Klipp({ children, defaultBlend, customBlends, camera: cameraProp, mode = 'enabled' }: KlippProps) {
const defaultCamera = useThree((state) => state.camera);
const size = useThree((state) => state.size);
const invalidate = useThree((state) => state.invalidate);
const camera = cameraProp ?? defaultCamera;
const [klipp] = useState(() => new KlippThree(camera, { defaultBlend, customBlends }));
klipp.camera = camera;
Expand All @@ -33,6 +34,7 @@ export function Klipp({ children, defaultBlend, customBlends, camera: cameraProp

useEffect(() => klipp.setDefaultBlend(defaultBlend), [klipp, defaultBlend]);
useEffect(() => klipp.setCustomBlends(customBlends), [klipp, customBlends]);
useEffect(() => invalidate(), [mode, invalidate]);

useFrame((state, rawDelta) => {
const delta = state.frameloop === 'demand' ? Math.min(rawDelta, DEMAND_MODE_MAX_DELTA) : rawDelta;
Expand Down
20 changes: 18 additions & 2 deletions src/react/input/InputController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function InputController(props: InputControllerProps) {
const isLive = useIsLiveVirtualCamera();
const shouldConnect = isActive && (waitForBlend ? isLive : true);
const domElement = useThree((state) => state.gl.domElement);
const invalidate = useThree((state) => state.invalidate);

const [controller] = useState(() => new InputAxisController(emptyConfig));
useImperativeHandle(ref, () => controller, [controller]);
Expand All @@ -118,8 +119,23 @@ export function InputController(props: InputControllerProps) {
useEffect(() => {
if (!shouldConnect) return;
controller.connect(domElement);
return () => controller.disconnect();
}, [controller, domElement, shouldConnect]);
// frameloop="demand" needs a frame for each event that feeds the axes, not for plain hovering.
const onPointer = (event: PointerEvent) => {
const dragging = event.buttons !== 0 || event.pointerType === 'touch';
if (event.type !== 'pointermove' || dragging || document.pointerLockElement === domElement) invalidate();
};
domElement.addEventListener('pointerdown', onPointer);
domElement.addEventListener('pointermove', onPointer);
domElement.addEventListener('pointerup', onPointer);
domElement.addEventListener('pointercancel', onPointer);
return () => {
controller.disconnect();
domElement.removeEventListener('pointerdown', onPointer);
domElement.removeEventListener('pointermove', onPointer);
domElement.removeEventListener('pointerup', onPointer);
domElement.removeEventListener('pointercancel', onPointer);
};
}, [controller, domElement, shouldConnect, invalidate]);

return null;
}
29 changes: 29 additions & 0 deletions test/core/Klipp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,35 @@ describe('Klipp — update(dt): the frame loop', () => {
}
});

it('takes the camera back when switched to enabled again, even with an unchanged shot (real bug: it stayed where standby left it)', () => {
for (const mode of ['standby', 'disabled'] as const) {
const { klipp } = twoShots();
klipp.update(0.1);
klipp.mode = mode;
klipp.update(0.1);
klipp.mode = 'enabled';
klipp.update(0.1);
expect(klipp.writes.map((w) => w.position[0])).toEqual([-10, -10]);
}
});

it('keeps delivering events after a listener throws (real bug: one throw stopped every later event)', () => {
const klipp = new Klipp();
const thrower = () => {
throw new Error('listener');
};
const activated: string[] = [];
klipp.addEventListener('activated', thrower);
klipp.addEventListener('activated', (event) => activated.push(event.incoming));

expect(() => klipp.addCamera('a', { priority: 1 })).toThrow('listener');
klipp.removeEventListener('activated', thrower);
klipp.addCamera('b', { priority: 2 });

expect(activated).toEqual(['b']);
expect(klipp.state.events).toEqual([]);
});

it('runs registered updates before the cameras, and stops them once removed', () => {
const { klipp, left } = twoShots();
const order: string[] = [];
Expand Down
9 changes: 1 addition & 8 deletions test/core/blend/BlendHints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,7 @@ import { BlendHints, hasBlendHint } from '../../../src/core/blend/BlendHints';

describe('BlendHints', () => {
it('each flag is a distinct bit (no accidental overlap)', () => {
const flags = [
BlendHints.cylindricalPosition,
BlendHints.sphericalPosition,
BlendHints.screenSpaceAimWhenTargetsDiffer,
BlendHints.ignoreTarget,
BlendHints.inheritPosition,
];
const flags = [BlendHints.cylindricalPosition, BlendHints.sphericalPosition, BlendHints.ignoreTarget];
const seen = new Set(flags);
expect(seen.size).toBe(flags.length);
});
Expand All @@ -26,6 +20,5 @@ describe('BlendHints', () => {
expect(hasBlendHint(combined, BlendHints.cylindricalPosition)).toBe(true);
expect(hasBlendHint(combined, BlendHints.ignoreTarget)).toBe(true);
expect(hasBlendHint(combined, BlendHints.sphericalPosition)).toBe(false);
expect(hasBlendHint(combined, BlendHints.inheritPosition)).toBe(false);
});
});
16 changes: 16 additions & 0 deletions test/core/input/InputAxis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,5 +199,21 @@ describe('InputAxis', () => {
for (let i = 0; i < 50; i++) axis.update(0.1);
expect(axis.value).toBeCloseTo(0, 1);
});

it('update returns true through the wait and the recentering (real bug: an idle demand loop never started it)', () => {
const axis = new InputAxis({ value: 10, center: 0, range: null, wrap: false, recentering: recentering(1, 0.5) });
expect(axis.update(0.1)).toBe(true);
expect(axis.value).toBe(10);

let frames = 0;
while (axis.update(0.1) && frames < 1000) frames++;
expect(frames).toBeLessThan(1000);
expect(axis.value).toBeCloseTo(0, 6);

axis.held = true;
axis.applyDelta(5);
settle(axis);
expect(axis.update(0.1)).toBe(false);
});
});
});
10 changes: 10 additions & 0 deletions test/three/extension/GroupFramingExtensionThree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ describe('GroupFramingExtensionThree', () => {
}
});

it('frames a single point member by its padding (real bug: a zero-size group was always skipped)', () => {
const out = camera();
new GroupFramingExtensionThree(new TargetGroup([{ target: new Vector3() }]), {
padding: 1,
viewportWidth: 100,
viewportHeight: 100,
}).update(out, 0.1, false);
expect(out.position[2]).toBeCloseTo(sphereFit(1), 10);
});

it("backs away along the camera's own view axis until the group fits", () => {
const out = camera();
new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 2).toArray(out.quaternion);
Expand Down
Loading