@haiyue/native 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/bridge/audio/pcm-bank.android.ts +81 -0
- package/bridge/audio/pcm-bank.ios.ts +80 -0
- package/bridge/audio/pcm-bank.ts +2 -0
- package/bridge/branding/assets/haiyue-moon.png +0 -0
- package/bridge/branding/engine-splash.ts +122 -0
- package/bridge/branding/launch-page.ts +28 -0
- package/bridge/branding/webpack.cjs +11 -0
- package/bridge/display/orientation-policy.ts +18 -0
- package/bridge/display/orientation.android.ts +31 -0
- package/bridge/display/orientation.ios.ts +57 -0
- package/bridge/display/orientation.ts +2 -0
- package/bridge/feedback/haptics.android.ts +31 -0
- package/bridge/feedback/haptics.ios.ts +35 -0
- package/bridge/feedback/haptics.ts +2 -0
- package/bridge/files/read-bytes.ts +18 -0
- package/bridge/input/native-touch.android.ts +55 -0
- package/bridge/input/native-touch.ios.ts +91 -0
- package/bridge/input/native-touch.ts +2 -0
- package/bridge/input/pointer-target.ts +106 -0
- package/bridge/input/touch-identity.ts +20 -0
- package/bridge/lifecycle/demand-frames.ts +51 -0
- package/bridge/lifecycle/frame-performance.ts +20 -0
- package/bridge/lifecycle/frame-scheduler.ts +33 -0
- package/bridge/lifecycle/host.ts +330 -0
- package/bridge/lifecycle/launch-flags.ts +6 -0
- package/bridge/lifecycle/presentation-pause.ts +18 -0
- package/bridge/lifecycle/runtime.ts +31 -0
- package/bridge/media/save-photo.android.ts +49 -0
- package/bridge/media/save-photo.ios.ts +19 -0
- package/bridge/media/save-photo.ts +2 -0
- package/bridge/motion/android-reading.ts +19 -0
- package/bridge/motion/device-motion.android.ts +109 -0
- package/bridge/motion/device-motion.ios.ts +121 -0
- package/bridge/motion/device-motion.ts +2 -0
- package/bridge/motion/motion-sample.ts +62 -0
- package/bridge/render/canvas-textures.ios.ts +45 -0
- package/bridge/render/canvas-textures.ts +2 -0
- package/bridge/render/device-descriptor.ts +8 -0
- package/bridge/render/frame-capture.android.ts +12 -0
- package/bridge/render/frame-capture.ios.ts +20 -0
- package/bridge/render/frame-capture.ts +2 -0
- package/bridge/render/queue-fence.android.ts +39 -0
- package/bridge/render/queue-fence.ts +1 -0
- package/bridge/render/surface.ts +153 -0
- package/bridge/render/view-capture.android.ts +11 -0
- package/bridge/render/view-capture.ios.ts +17 -0
- package/bridge/render/view-capture.ts +1 -0
- package/bridge/render/view-rect.android.ts +15 -0
- package/bridge/render/view-rect.ios.ts +12 -0
- package/bridge/render/view-rect.ts +2 -0
- package/bridge/render/webgpu-constants.ts +5 -0
- package/bridge/rewards/admob.ts +80 -0
- package/bridge/rewards/controller.ts +163 -0
- package/bridge/rewards/native/android/org/haiyue/rewards/HYRewardedAds.java +116 -0
- package/bridge/rewards/native/ios/HYRewardedAds.swift +169 -0
- package/bridge/storage/clone-runtime.ts +6 -0
- package/bridge/storage/settings-storage.ts +12 -0
- package/index.ts +17 -0
- package/package.json +87 -0
- package/provenance.json +66 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Screen, View, Application, type EventData } from '@nativescript/core';
|
|
2
|
+
import { type TouchGestureEventData } from '@nativescript/core/ui/gestures';
|
|
3
|
+
import type { Canvas } from '@nativescript/canvas';
|
|
4
|
+
import { nativeViewRect } from '../render/view-rect.android';
|
|
5
|
+
import { OrbitPointerTarget, type TouchAction } from './pointer-target';
|
|
6
|
+
export interface NativeTouchSample {
|
|
7
|
+
action: TouchAction | 'suspend' | 'unloaded' | 'dispose';
|
|
8
|
+
points: { id: number; x: number; y: number; nativeHash: number }[];
|
|
9
|
+
input: ReturnType<OrbitPointerTarget['snapshot']>;
|
|
10
|
+
}
|
|
11
|
+
export class NativeTouchInput {
|
|
12
|
+
readonly target: OrbitPointerTarget;
|
|
13
|
+
readonly orbitTarget: OrbitPointerTarget | null;
|
|
14
|
+
private readonly ids = new Set<number>();
|
|
15
|
+
private pinching = false;
|
|
16
|
+
private disposed = false;
|
|
17
|
+
private paused = false;
|
|
18
|
+
get isPinching(): boolean { return this.pinching; }
|
|
19
|
+
constructor(private readonly view: Canvas, private readonly sample: (event: NativeTouchSample) => void, options: { pinchZoom?: boolean } = {}) {
|
|
20
|
+
if (!view.ignoreTouchEvents) throw new Error('Disable Canvas pointer synthesis before attaching Core touch.');
|
|
21
|
+
this.target = new OrbitPointerTarget(() => nativeViewRect(view));
|
|
22
|
+
this.orbitTarget = options.pinchZoom ? new OrbitPointerTarget(() => nativeViewRect(view), 'all') : null;
|
|
23
|
+
// Android dispatches only observers registered on Core's View. A standalone
|
|
24
|
+
// GesturesObserver is never included in that dispatch list. Bypass Canvas's
|
|
25
|
+
// DOM addEventListener override while keeping its synthetic touch disabled.
|
|
26
|
+
View.prototype.addEventListener.call(view, 'touch', this.onTouch);
|
|
27
|
+
view.on('unloaded', this.unloaded);
|
|
28
|
+
view.on('loaded', this.loaded);
|
|
29
|
+
}
|
|
30
|
+
snapshot() { return { pinching: this.pinching, rect: nativeViewRect(this.view), measured: { width: this.view.clientWidth, height: this.view.clientHeight }, ...this.target.snapshot(), nativeIdentities: this.ids.size, nativeObserverCount: this.disposed ? 0 : 1, nativeRecognizerCount: this.disposed ? 0 : 1 }; }
|
|
31
|
+
private touch(event: TouchGestureEventData): void {
|
|
32
|
+
if (this.disposed || this.paused) return;
|
|
33
|
+
const action = event.action as TouchAction, motion = event.android as android.view.MotionEvent;
|
|
34
|
+
if (!['down','move','up','cancel'].includes(action)) return;
|
|
35
|
+
const indices = action === 'move' || action === 'cancel' ? Array.from({length:motion.getPointerCount()},(_,i)=>i) : [motion.getActionIndex()];
|
|
36
|
+
const points = indices.map(i => ({ id: motion.getPointerId(i), nativeHash: motion.getPointerId(i), x: motion.getX(i) / Screen.mainScreen.scale, y: motion.getY(i) / Screen.mainScreen.scale }));
|
|
37
|
+
if (action === 'down') for (const point of points) this.ids.add(point.id);
|
|
38
|
+
if (this.orbitTarget && !this.pinching && this.ids.size >= 2) { this.pinching = true; this.target.suspend(); }
|
|
39
|
+
this.target.handle(action, points); this.orbitTarget?.handle(action, points);
|
|
40
|
+
if (action === 'up' || action === 'cancel') for (const point of points) this.ids.delete(point.id);
|
|
41
|
+
this.sample({ action, points, input: this.target.snapshot() });
|
|
42
|
+
if (this.pinching && this.ids.size !== 2) this.orbitTarget?.suspend();
|
|
43
|
+
if (this.pinching && !this.ids.size) { this.pinching = false; this.target.resume(); this.orbitTarget?.resume(); }
|
|
44
|
+
}
|
|
45
|
+
private readonly onTouch = (event: EventData): void => this.touch(event as TouchGestureEventData);
|
|
46
|
+
private readonly loaded = (): void => { if (!Application.inBackground && !Application.suspended) this.resume(); };
|
|
47
|
+
private cancel(action: 'suspend' | 'unloaded' | 'dispose'): void {
|
|
48
|
+
this.target.suspend(); this.orbitTarget?.suspend(); this.ids.clear(); this.pinching = false; this.paused = true;
|
|
49
|
+
this.sample({ action, points: [], input: this.target.snapshot() });
|
|
50
|
+
}
|
|
51
|
+
private readonly unloaded = (): void => this.cancel('unloaded');
|
|
52
|
+
suspend(): void { if (!this.disposed) this.cancel('suspend'); }
|
|
53
|
+
resume(): void { if (!this.disposed) { this.paused = false; this.target.resume(); this.orbitTarget?.resume(); } }
|
|
54
|
+
dispose(): void { if (this.disposed) return; this.cancel('dispose'); View.prototype.removeEventListener.call(this.view,'touch',this.onTouch); this.view.off('unloaded',this.unloaded); this.view.off('loaded',this.loaded); this.target.dispose(); this.orbitTarget?.dispose(); this.disposed=true; }
|
|
55
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { nativeViewRect } from '../render/view-rect.ios';
|
|
2
|
+
import type { Canvas } from '@nativescript/canvas';
|
|
3
|
+
import { GesturesObserver, GestureTypes, type TouchGestureEventData } from '@nativescript/core/ui/gestures';
|
|
4
|
+
import { OrbitPointerTarget, type TouchAction } from './pointer-target';
|
|
5
|
+
import { TouchIdentity } from './touch-identity';
|
|
6
|
+
|
|
7
|
+
export interface NativeTouchSample {
|
|
8
|
+
action: TouchAction | 'suspend' | 'unloaded' | 'dispose';
|
|
9
|
+
points: { id: number; x: number; y: number; nativeHash: number }[];
|
|
10
|
+
input: ReturnType<OrbitPointerTarget['snapshot']>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class NativeTouchInput {
|
|
14
|
+
readonly target: OrbitPointerTarget;
|
|
15
|
+
readonly orbitTarget: OrbitPointerTarget | null;
|
|
16
|
+
private pinching = false;
|
|
17
|
+
get isPinching(): boolean { return this.pinching; }
|
|
18
|
+
private readonly identity = new TouchIdentity<UITouch>((a, b) => a === b || a.isEqual(b));
|
|
19
|
+
private readonly observer: GesturesObserver;
|
|
20
|
+
private readonly previousMultipleTouch: boolean;
|
|
21
|
+
private disposed = false;
|
|
22
|
+
private paused = false;
|
|
23
|
+
|
|
24
|
+
constructor(private readonly view: Canvas, private readonly sample: (event: NativeTouchSample) => void, options: { pinchZoom?: boolean } = {}) {
|
|
25
|
+
if (!view.ignoreTouchEvents) throw new Error('Disable Canvas pointer synthesis before attaching Core touch.');
|
|
26
|
+
const native = view.nativeViewProtected as UIView;
|
|
27
|
+
this.previousMultipleTouch = native.multipleTouchEnabled;
|
|
28
|
+
native.multipleTouchEnabled = true;
|
|
29
|
+
this.target = new OrbitPointerTarget(() => nativeViewRect(view));
|
|
30
|
+
this.orbitTarget = options.pinchZoom ? new OrbitPointerTarget(() => nativeViewRect(view), 'all') : null;
|
|
31
|
+
this.observer = new GesturesObserver(view, event => this.touch(event as TouchGestureEventData), this);
|
|
32
|
+
this.observer.observe(GestureTypes.touch);
|
|
33
|
+
view.on('unloaded', this.unloaded);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
snapshot() { return { pinching: this.pinching, rect: nativeViewRect(this.view), measured: { width: this.view.clientWidth, height: this.view.clientHeight }, ...this.target.snapshot(), nativeIdentities: this.identity.count, nativeObserverCount: this.disposed ? 0 : 1, nativeRecognizerCount: (this.view.nativeViewProtected as UIView | undefined)?.gestureRecognizers?.count ?? 0 }; }
|
|
37
|
+
|
|
38
|
+
private touch(event: TouchGestureEventData): void {
|
|
39
|
+
if (this.disposed || this.paused) return;
|
|
40
|
+
const action = event.action as TouchAction;
|
|
41
|
+
if (!['down', 'move', 'up', 'cancel'].includes(action)) return;
|
|
42
|
+
const pointers = event.getActivePointers();
|
|
43
|
+
// NSSet iteration has no chronology; earliest timestamp wins simultaneous downs.
|
|
44
|
+
if (action === 'down') pointers.sort((a, b) => (a.ios as UITouch).timestamp - (b.ios as UITouch).timestamp);
|
|
45
|
+
const points: NativeTouchSample['points'] = [];
|
|
46
|
+
for (const pointer of pointers) {
|
|
47
|
+
const touch = pointer.ios as UITouch;
|
|
48
|
+
const id = action === 'down' ? this.identity.begin(touch) : this.identity.find(touch);
|
|
49
|
+
if (id === undefined) continue;
|
|
50
|
+
points.push({ id, x: pointer.getX() - (this.view.nativeViewProtected as UIView).bounds.origin.x, y: pointer.getY() - (this.view.nativeViewProtected as UIView).bounds.origin.y, nativeHash: touch.hash });
|
|
51
|
+
}
|
|
52
|
+
if (this.orbitTarget && !this.pinching && this.identity.count >= 2) {
|
|
53
|
+
this.pinching = true;
|
|
54
|
+
this.target.suspend();
|
|
55
|
+
}
|
|
56
|
+
this.target.handle(action, points);
|
|
57
|
+
this.orbitTarget?.handle(action, points);
|
|
58
|
+
if (action === 'up' || action === 'cancel') for (const point of points) this.identity.end(point.id);
|
|
59
|
+
this.sample({ action, points, input: this.target.snapshot() });
|
|
60
|
+
if (this.pinching && this.identity.count !== 2) this.orbitTarget?.suspend();
|
|
61
|
+
if (this.pinching && this.identity.count === 0) {
|
|
62
|
+
this.pinching = false;
|
|
63
|
+
this.target.resume();
|
|
64
|
+
this.orbitTarget?.resume();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private cancel(action: 'suspend' | 'unloaded' | 'dispose'): void {
|
|
69
|
+
this.target.suspend();
|
|
70
|
+
this.orbitTarget?.suspend();
|
|
71
|
+
this.pinching = false;
|
|
72
|
+
this.identity.clear();
|
|
73
|
+
this.paused = true;
|
|
74
|
+
this.sample({ action, points: [], input: this.target.snapshot() });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private readonly unloaded = (): void => this.cancel('unloaded');
|
|
78
|
+
suspend(): void { if (!this.disposed) this.cancel('suspend'); }
|
|
79
|
+
resume(): void { if (!this.disposed) { this.paused = false; this.target.resume(); this.orbitTarget?.resume(); } }
|
|
80
|
+
dispose(): void {
|
|
81
|
+
if (this.disposed) return;
|
|
82
|
+
this.cancel('dispose');
|
|
83
|
+
this.observer.disconnect();
|
|
84
|
+
this.view.off('unloaded', this.unloaded);
|
|
85
|
+
const native = this.view.nativeViewProtected as UIView | undefined;
|
|
86
|
+
if (native) native.multipleTouchEnabled = this.previousMultipleTouch;
|
|
87
|
+
this.target.dispose();
|
|
88
|
+
this.orbitTarget?.dispose();
|
|
89
|
+
this.disposed = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export interface LogicalRect { x: number; y: number; width: number; height: number }
|
|
2
|
+
export interface TouchPoint { id: number; x: number; y: number }
|
|
3
|
+
export type TouchAction = 'down' | 'move' | 'up' | 'cancel';
|
|
4
|
+
export interface OrbitPointer {
|
|
5
|
+
pointerId: number; pointerType: 'touch'; button: 0;
|
|
6
|
+
clientX: number; clientY: number; defaultPrevented: boolean;
|
|
7
|
+
preventDefault(): void;
|
|
8
|
+
}
|
|
9
|
+
type Listener = (event: OrbitPointer) => void;
|
|
10
|
+
|
|
11
|
+
/** A single-pointer event target. Coordinates and bounds are both UIKit points. */
|
|
12
|
+
export class OrbitPointerTarget {
|
|
13
|
+
private readonly listeners = new Map<string, Set<Listener>>();
|
|
14
|
+
private readonly touches = new Map<number, TouchPoint>();
|
|
15
|
+
private primary: number | null = null;
|
|
16
|
+
private captured: number | null = null;
|
|
17
|
+
private paused = false;
|
|
18
|
+
private disposed = false;
|
|
19
|
+
|
|
20
|
+
constructor(private readonly rect: () => LogicalRect, private readonly mode: 'primary' | 'all' = 'primary') {}
|
|
21
|
+
|
|
22
|
+
getBoundingClientRect() {
|
|
23
|
+
const r = this.rect();
|
|
24
|
+
return { ...r, left: r.x, top: r.y, right: r.x + r.width, bottom: r.y + r.height };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
addEventListener(type: string, listener: Listener): void {
|
|
28
|
+
if (this.disposed) return;
|
|
29
|
+
let entries = this.listeners.get(type);
|
|
30
|
+
if (!entries) this.listeners.set(type, entries = new Set());
|
|
31
|
+
entries.add(listener);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
removeEventListener(type: string, listener: Listener): void {
|
|
35
|
+
this.listeners.get(type)?.delete(listener);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setPointerCapture(id: number): void {
|
|
39
|
+
if ((this.mode === 'primary' && id !== this.primary) || !this.touches.has(id)) throw new Error('Cannot capture an inactive native touch.');
|
|
40
|
+
// UIKit's recognizer owns this UITouch through end/cancel even outside its view.
|
|
41
|
+
this.captured = id;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
releasePointerCapture(id: number): void {
|
|
45
|
+
if (this.captured === id) this.captured = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
handle(action: TouchAction, points: readonly TouchPoint[]): void {
|
|
49
|
+
if (this.disposed || this.paused) return;
|
|
50
|
+
for (const point of points) {
|
|
51
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue;
|
|
52
|
+
if (action === 'down') {
|
|
53
|
+
if (this.touches.has(point.id)) continue;
|
|
54
|
+
this.touches.set(point.id, point);
|
|
55
|
+
if (this.primary !== null && this.mode === 'primary') continue;
|
|
56
|
+
if (this.primary === null) this.primary = point.id;
|
|
57
|
+
this.emit('pointerdown', point);
|
|
58
|
+
} else {
|
|
59
|
+
if (!this.touches.has(point.id)) continue;
|
|
60
|
+
this.touches.set(point.id, point);
|
|
61
|
+
if (this.mode === 'all' || point.id === this.primary) this.emit(`pointer${action}`, point);
|
|
62
|
+
if (action === 'up' || action === 'cancel') {
|
|
63
|
+
this.touches.delete(point.id);
|
|
64
|
+
if (point.id === this.primary) { this.primary = null; this.captured = null; }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
cancel(): void {
|
|
71
|
+
const point = this.primary === null ? undefined : this.touches.get(this.primary);
|
|
72
|
+
if (this.mode === 'all') { for (const touch of this.touches.values()) this.emit('pointercancel', touch); }
|
|
73
|
+
else if (point) this.emit('pointercancel', point);
|
|
74
|
+
this.touches.clear();
|
|
75
|
+
this.primary = null;
|
|
76
|
+
this.captured = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
suspend(): void { this.cancel(); this.paused = true; }
|
|
80
|
+
resume(): void { if (!this.disposed) this.paused = false; }
|
|
81
|
+
dispose(): void {
|
|
82
|
+
if (this.disposed) return;
|
|
83
|
+
this.suspend();
|
|
84
|
+
this.listeners.clear();
|
|
85
|
+
this.disposed = true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
snapshot() {
|
|
89
|
+
return { primary: this.primary, captured: this.captured, trackedTouches: this.touches.size,
|
|
90
|
+
listenerCount: [...this.listeners.values()].reduce((sum, entries) => sum + entries.size, 0),
|
|
91
|
+
paused: this.paused, disposed: this.disposed };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private emit(type: string, point: TouchPoint): void {
|
|
95
|
+
const rect = this.rect();
|
|
96
|
+
const event: OrbitPointer = {
|
|
97
|
+
pointerId: point.id, pointerType: 'touch', button: 0,
|
|
98
|
+
clientX: rect.x + point.x, clientY: rect.y + point.y,
|
|
99
|
+
defaultPrevented: false,
|
|
100
|
+
preventDefault() { this.defaultPrevented = true; },
|
|
101
|
+
};
|
|
102
|
+
// Native view input has no DOM wheel/scroll default. Canvas's own pointer
|
|
103
|
+
// synthesis is disabled by the native adapter, and system cancellation wins.
|
|
104
|
+
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Native wrappers may differ between callbacks; compare the underlying touch identity. */
|
|
2
|
+
export class TouchIdentity<T> {
|
|
3
|
+
private next = 1;
|
|
4
|
+
private readonly entries: { touch: T; id: number }[] = [];
|
|
5
|
+
constructor(private readonly equal: (a: T, b: T) => boolean) {}
|
|
6
|
+
find(touch: T): number | undefined { return this.entries.find(entry => this.equal(entry.touch, touch))?.id; }
|
|
7
|
+
begin(touch: T): number {
|
|
8
|
+
const found = this.find(touch);
|
|
9
|
+
if (found !== undefined) return found;
|
|
10
|
+
const id = this.next++;
|
|
11
|
+
this.entries.push({ touch, id });
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
end(id: number): void {
|
|
15
|
+
const index = this.entries.findIndex(entry => entry.id === id);
|
|
16
|
+
if (index >= 0) this.entries.splice(index, 1);
|
|
17
|
+
}
|
|
18
|
+
clear(): void { this.entries.length = 0; }
|
|
19
|
+
get count(): number { return this.entries.length; }
|
|
20
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface DemandFrameDriver {
|
|
2
|
+
start(): void;
|
|
3
|
+
stop(): void;
|
|
4
|
+
defer(callback: () => void): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Event-driven ownership of Engine.run/stop. No polling timer or idle RAF.
|
|
8
|
+
* Two frames cover GUI callbacks that mutate a scene after its 3D pass. */
|
|
9
|
+
export class NativeDemandFrames {
|
|
10
|
+
private readonly driver: DemandFrameDriver;
|
|
11
|
+
private remaining = 0;
|
|
12
|
+
private active = false;
|
|
13
|
+
private running = false;
|
|
14
|
+
private queued = false;
|
|
15
|
+
private disposed = false;
|
|
16
|
+
constructor(driver: DemandFrameDriver) { this.driver = driver; }
|
|
17
|
+
|
|
18
|
+
request(): void {
|
|
19
|
+
if (this.disposed) return;
|
|
20
|
+
this.remaining = 2;
|
|
21
|
+
if (!this.active || this.running || this.queued) return;
|
|
22
|
+
this.queued = true;
|
|
23
|
+
// Never restart synchronously inside after-update: FrameLoop's current tick
|
|
24
|
+
// would otherwise schedule a second RAF after start() scheduled the first.
|
|
25
|
+
this.driver.defer(() => {
|
|
26
|
+
this.queued = false;
|
|
27
|
+
if (!this.active || this.disposed || this.running) return;
|
|
28
|
+
this.running = true;
|
|
29
|
+
this.driver.start();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
resume(): void { if (!this.disposed) { this.active = true; this.request(); } }
|
|
33
|
+
suspend(): void {
|
|
34
|
+
this.active = false;
|
|
35
|
+
this.running = false;
|
|
36
|
+
this.remaining = 0;
|
|
37
|
+
this.driver.stop();
|
|
38
|
+
}
|
|
39
|
+
afterFrame(animating: boolean): void {
|
|
40
|
+
if (!this.active || this.disposed) return;
|
|
41
|
+
// Render a settling frame even if a deadline expires between update and
|
|
42
|
+
// presentation; final static colors/poses must reach the surface.
|
|
43
|
+
this.remaining = animating ? 2 : Math.max(0, this.remaining - 1);
|
|
44
|
+
if (!animating && this.remaining === 0) {
|
|
45
|
+
this.running = false;
|
|
46
|
+
this.driver.stop();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
snapshot() { return { active: this.active, running: this.running, queued: this.queued, remaining: this.remaining }; }
|
|
50
|
+
dispose(): void { this.disposed = true; this.suspend(); }
|
|
51
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Bounded CPU/frame-interval samples. CPU includes command encoding and present,
|
|
2
|
+
* not GPU execution; frame intervals include scheduling and GPU back-pressure. */
|
|
3
|
+
export class FramePerformance {
|
|
4
|
+
private readonly maxSamples=600;
|
|
5
|
+
private cpu:number[]=[];
|
|
6
|
+
private intervals:number[]=[];
|
|
7
|
+
private lastStart:number|null=null;
|
|
8
|
+
private started=0;
|
|
9
|
+
begin(now:number):void {
|
|
10
|
+
if(this.lastStart!==null && this.intervals.length<this.maxSamples)this.intervals.push(now-this.lastStart);
|
|
11
|
+
this.lastStart=now;this.started=now;
|
|
12
|
+
}
|
|
13
|
+
end(now:number):void {if(this.cpu.length<this.maxSamples)this.cpu.push(now-this.started);}
|
|
14
|
+
reset():void {this.cpu=[];this.intervals=[];this.lastStart=null;}
|
|
15
|
+
take() {
|
|
16
|
+
const stats=(a:number[])=>{const sorted=[...a].sort((a,b)=>a-b);return {samples:a.length,meanMs:a.reduce((s,v)=>s+v,0)/Math.max(1,a.length),p95Ms:sorted[Math.max(0,Math.ceil(sorted.length*.95)-1)]??0};};
|
|
17
|
+
const result={cpu:stats(this.cpu),interval:stats(this.intervals)};
|
|
18
|
+
this.cpu=[];this.intervals=[];return result;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface NativeFrameDriver {
|
|
2
|
+
request(callback: (time: number) => void): number;
|
|
3
|
+
cancel(handle: number): void;
|
|
4
|
+
now(): number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Adapts native handle 0 and native timestamps to Engine's frame contract. */
|
|
8
|
+
export function createFrameScheduler(driver: NativeFrameDriver, onError: (error: unknown) => void) {
|
|
9
|
+
let next = 1;
|
|
10
|
+
const pending = new Map<number, number>();
|
|
11
|
+
return {
|
|
12
|
+
request(callback: (time: number) => void): number {
|
|
13
|
+
const id = next++;
|
|
14
|
+
const nativeId = driver.request(() => {
|
|
15
|
+
if (!pending.delete(id)) return;
|
|
16
|
+
try { callback(driver.now()); } catch (error) { onError(error); }
|
|
17
|
+
});
|
|
18
|
+
pending.set(id, nativeId);
|
|
19
|
+
return id;
|
|
20
|
+
},
|
|
21
|
+
cancel(id: number): void {
|
|
22
|
+
const nativeId = pending.get(id);
|
|
23
|
+
if (nativeId === undefined) return;
|
|
24
|
+
pending.delete(id);
|
|
25
|
+
driver.cancel(nativeId);
|
|
26
|
+
},
|
|
27
|
+
cancelAll(): void {
|
|
28
|
+
for (const id of pending.values()) driver.cancel(id);
|
|
29
|
+
pending.clear();
|
|
30
|
+
},
|
|
31
|
+
get pendingCount(): number { return pending.size; },
|
|
32
|
+
};
|
|
33
|
+
}
|