Skip to content

Commit a9cc629

Browse files
Alex-MSFTryantrem
andauthored
Add takeSnapshot support to EngineView (#77)
* Android takeSnapshot native code compiling, not hooked up yet. * Fix useRef declaration. * Add EngineViewHooks for registration of callbacks, dummy promise resolution for now. * Screenshots working on Android * Remove some debugging changes. * Clean up some spacing * iOS screenshots mostly working. Need to investigate hang when grabbing current drawable. * Use UIView approach rather than MTKView approach, so we don't have a race condition on the texture being available. * Add some comments and clean up EngineViewManager.mm * Add toggle for whether or not snapshots are enabled. * Change afterScreenUpdates to no to immediately grab the pixels rather than waiting for the next frame, fix capture quality value. * Add version checks and error handling to Android engineview implementation, and implement the non-deprecated version of receiveCommand. * Change engineViewHooks to a useState, change onInitialized, onSnapshot, snapshotDataReturnedHandler to useEffects or useCallbacks as appropriate. * Apply suggestions from code review Co-authored-by: Ryan Tremblay <[email protected]> * Address PR Comments * Commit suggestion that somehow didn't get applied. * Fix broken incorrect return statement when overlapping snapshots are requested. Co-authored-by: Ryan Tremblay <[email protected]>
1 parent c459315 commit a9cc629

File tree

6 files changed

+225
-9
lines changed

6 files changed

+225
-9
lines changed

Apps/Playground/App.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
*/
77

88
import React, { useState, FunctionComponent, useEffect, useCallback } from 'react';
9-
import { SafeAreaView, StatusBar, Button, View, Text, ViewProps } from 'react-native';
9+
import { SafeAreaView, StatusBar, Button, View, Text, ViewProps, Image } from 'react-native';
1010

11-
import { EngineView, useEngine } from '@babylonjs/react-native';
12-
import { Scene, Vector3, Mesh, ArcRotateCamera, Camera, PBRMetallicRoughnessMaterial, Color3, TargetCamera, WebXRSessionManager } from '@babylonjs/core';
11+
import { EngineView, useEngine, EngineViewCallbacks } from '@babylonjs/react-native';
12+
import { Scene, Vector3, Mesh, ArcRotateCamera, Camera, PBRMetallicRoughnessMaterial, Color3, TargetCamera, WebXRSessionManager, Engine } from '@babylonjs/core';
1313
import Slider from '@react-native-community/slider';
1414

1515
const EngineScreen: FunctionComponent<ViewProps> = (props: ViewProps) => {
1616
const defaultScale = 1;
17+
const enableSnapshots = false;
1718

1819
const engine = useEngine();
1920
const [toggleView, setToggleView] = useState(false);
@@ -22,6 +23,8 @@ const EngineScreen: FunctionComponent<ViewProps> = (props: ViewProps) => {
2223
const [scene, setScene] = useState<Scene>();
2324
const [xrSession, setXrSession] = useState<WebXRSessionManager>();
2425
const [scale, setScale] = useState<number>(defaultScale);
26+
const [snapshotData, setSnapshotData] = useState<string>();
27+
const [engineViewCallbacks, setEngineViewCallbacks] = useState<EngineViewCallbacks>();
2528

2629
useEffect(() => {
2730
if (engine) {
@@ -65,21 +68,37 @@ const EngineScreen: FunctionComponent<ViewProps> = (props: ViewProps) => {
6568
// TODO: Figure out why getFrontPosition stopped working
6669
//box.position = (scene.activeCamera as TargetCamera).getFrontPosition(2);
6770
const cameraRay = scene.activeCamera!.getForwardRay(1);
68-
box.position = cameraRay.origin.add(cameraRay.direction.scale(cameraRay.length));
71+
box.position = cameraRay.origin.add(cameraRay.direction.scale(cameraRay.length));
6972
box.rotate(Vector3.Up(), 3.14159);
7073
}
7174
}
7275
})();
7376
}, [box, scene, xrSession]);
7477

78+
const onInitialized = useCallback(async(engineViewCallbacks: EngineViewCallbacks) => {
79+
setEngineViewCallbacks(engineViewCallbacks);
80+
}, [engine]);
81+
82+
const onSnapshot = useCallback(async () => {
83+
if (engineViewCallbacks) {
84+
setSnapshotData("data:image/jpeg;base64," + await engineViewCallbacks.takeSnapshot());
85+
}
86+
}, [engineViewCallbacks]);
87+
7588
return (
7689
<>
7790
<View style={props.style}>
7891
<Button title="Toggle EngineView" onPress={() => { setToggleView(!toggleView) }} />
7992
<Button title={ xrSession ? "Stop XR" : "Start XR"} onPress={onToggleXr} />
8093
{ !toggleView &&
8194
<View style={{flex: 1}}>
82-
<EngineView style={props.style} camera={camera} />
95+
{ enableSnapshots &&
96+
<View style ={{flex: 1}}>
97+
<Button title={"Take Snapshot"} onPress={onSnapshot}/>
98+
<Image style={{flex: 1}} source={{uri: snapshotData }} />
99+
</View>
100+
}
101+
<EngineView style={props.style} camera={camera} onInitialized={onInitialized} />
83102
<Slider style={{position: 'absolute', minHeight: 50, margin: 10, left: 0, right: 0, bottom: 0}} minimumValue={0.2} maximumValue={2} value={defaultScale} onValueChange={setScale} />
84103
</View>
85104
}

Modules/@babylonjs/react-native/EngineView.tsx

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React, { FunctionComponent, Component, useEffect, useState } from 'react';
2-
import { requireNativeComponent, NativeModules, ViewProps, AppState, AppStateStatus, View, Text } from 'react-native';
1+
import React, { Component, FunctionComponent, SyntheticEvent, useCallback, useEffect, useState, useRef } from 'react';
2+
import { requireNativeComponent, NativeModules, ViewProps, AppState, AppStateStatus, View, Text, findNodeHandle, UIManager } from 'react-native';
33
import { Camera } from '@babylonjs/core';
44
import { IsEngineDisposed } from './EngineHelpers';
55
import { BabylonModule } from './BabylonModule';
@@ -17,6 +17,7 @@ if (EngineViewManager && EngineViewManager.setJSThread && !isRemoteDebuggingEnab
1717
}
1818

1919
interface NativeEngineViewProps extends ViewProps {
20+
onSnapshotDataReturned: (event: SyntheticEvent) => void;
2021
}
2122

2223
const NativeEngineView: {
@@ -27,11 +28,18 @@ const NativeEngineView: {
2728
export interface EngineViewProps extends ViewProps {
2829
camera?: Camera;
2930
displayFrameRate?: boolean;
31+
onInitialized?: (view: EngineViewCallbacks) => void;
32+
}
33+
34+
export interface EngineViewCallbacks {
35+
takeSnapshot: () => Promise<string>;
3036
}
3137

3238
export const EngineView: FunctionComponent<EngineViewProps> = (props: EngineViewProps) => {
3339
const [failedInitialization, setFailedInitialization] = useState(false);
3440
const [fps, setFps] = useState<number>();
41+
const engineViewRef = useRef<Component<NativeEngineViewProps>>(null);
42+
const snapshotPromise = useRef<{promise: Promise<string>, resolve: (data: string) => void}>();
3543

3644
useEffect(() => {
3745
(async () => {
@@ -90,10 +98,50 @@ export const EngineView: FunctionComponent<EngineViewProps> = (props: EngineView
9098
setFps(undefined);
9199
}, [props.camera, props.displayFrameRate]);
92100

101+
// Call onInitialized if provided, and include the callback for takeSnapshot.
102+
useEffect(() => {
103+
if (props.onInitialized) {
104+
props.onInitialized({
105+
takeSnapshot: (): Promise<string> => {
106+
if (!snapshotPromise.current) {
107+
let resolveFunction: ((data: string) => void) | undefined;
108+
const promise = new Promise<string>((resolutionFunc) => {
109+
resolveFunction = resolutionFunc;
110+
});
111+
112+
// Resolution functions should always be initialized.
113+
if (resolveFunction) {
114+
snapshotPromise.current = { promise: promise, resolve: resolveFunction };
115+
}
116+
else {
117+
throw "Resolution functions not initialized after snapshot promise creation.";
118+
}
119+
120+
UIManager.dispatchViewManagerCommand(
121+
findNodeHandle(engineViewRef.current),
122+
"takeSnapshot",
123+
[]);
124+
}
125+
126+
return snapshotPromise.current.promise;
127+
}
128+
});
129+
}
130+
}, [props.onInitialized]);
131+
132+
// Handle snapshot data returned.
133+
const snapshotDataReturnedHandler = useCallback((event: SyntheticEvent) => {
134+
const { data } = event.nativeEvent;
135+
if (snapshotPromise.current) {
136+
snapshotPromise.current.resolve(data);
137+
snapshotPromise.current = undefined;
138+
}
139+
}, []);
140+
93141
if (!failedInitialization) {
94142
return (
95143
<View style={[props.style, {overflow: "hidden"}]}>
96-
<NativeEngineView style={{flex: 1}} />
144+
<NativeEngineView ref={engineViewRef} style={{flex: 1}} onSnapshotDataReturned={snapshotDataReturnedHandler} />
97145
{ fps && <Text style={{color: 'yellow', position: 'absolute', margin: 10, right: 0, top: 0}}>FPS: {Math.round(fps)}</Text> }
98146
</View>
99147
);
@@ -105,4 +153,4 @@ export const EngineView: FunctionComponent<EngineViewProps> = (props: EngineView
105153
</View>
106154
);
107155
}
108-
}
156+
}

Modules/@babylonjs/react-native/android/src/main/java/com/reactlibrary/EngineView.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,33 @@
11
package com.reactlibrary;
22

3+
import android.annotation.TargetApi;
4+
import android.graphics.Bitmap;
5+
import android.os.Build;
6+
import android.os.Handler;
7+
import android.os.HandlerThread;
8+
import android.util.Base64;
39
import android.view.MotionEvent;
10+
import android.view.PixelCopy;
411
import android.view.SurfaceHolder;
512
import android.view.SurfaceView;
613
import android.view.View;
714

815
import com.facebook.react.bridge.ReactContext;
16+
import com.facebook.react.uimanager.UIManagerModule;
17+
import com.facebook.react.uimanager.events.EventDispatcher;
18+
19+
import java.io.ByteArrayOutputStream;
920

1021
public final class EngineView extends SurfaceView implements SurfaceHolder.Callback, View.OnTouchListener {
1122
private final ReactContext reactContext;
23+
private final EventDispatcher reactEventDispatcher;
1224

1325
public EngineView(ReactContext reactContext) {
1426
super(reactContext);
1527
this.reactContext = reactContext;
1628
this.getHolder().addCallback(this);
1729
this.setOnTouchListener(this);
30+
this.reactEventDispatcher = this.reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher();
1831
}
1932

2033
@Override
@@ -37,4 +50,41 @@ public boolean onTouch(View view, MotionEvent motionEvent) {
3750
BabylonNativeInterop.reportMotionEvent(this.reactContext, motionEvent);
3851
return true;
3952
}
53+
54+
@TargetApi(24)
55+
public void takeSnapshot() {
56+
// Only supported on API level 24 and up, return a blank image.
57+
if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
58+
SnapshotDataReturnedEvent snapshotEvent = new SnapshotDataReturnedEvent(this.getId(), "");
59+
reactEventDispatcher.dispatchEvent(snapshotEvent);
60+
}
61+
62+
// Create a bitmap that matches the width and height of the EngineView.
63+
final Bitmap bitmap = Bitmap.createBitmap(
64+
getWidth(),
65+
getHeight(),
66+
Bitmap.Config.ARGB_8888);
67+
68+
// Offload the snapshot worker to a helper thread.
69+
final HandlerThread helperThread = new HandlerThread("ScreenCapture",-1);
70+
helperThread.start();
71+
final Handler helperThreadHandler = new Handler(helperThread.getLooper());
72+
73+
// Request the pixel copy.
74+
PixelCopy.request(this, bitmap, (copyResult) -> {
75+
// If the pixel copy was a success then convert the image to a base 64 encoded jpeg and fire the event.
76+
String encoded = "";
77+
if (copyResult == PixelCopy.SUCCESS) {
78+
ByteArrayOutputStream byteArrayStream = new ByteArrayOutputStream();
79+
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayStream);
80+
byte[] byteArray = byteArrayStream.toByteArray();
81+
bitmap.recycle();
82+
encoded = Base64.encodeToString(byteArray, Base64.DEFAULT);
83+
}
84+
85+
SnapshotDataReturnedEvent snapshotEvent = new SnapshotDataReturnedEvent(this.getId(), encoded);
86+
reactEventDispatcher.dispatchEvent(snapshotEvent);
87+
helperThread.quitSafely();
88+
}, helperThreadHandler);
89+
}
4090
}

Modules/@babylonjs/react-native/android/src/main/java/com/reactlibrary/EngineViewManager.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
import androidx.annotation.NonNull;
44
import androidx.annotation.Nullable;
55

6+
import com.facebook.react.bridge.ReadableArray;
67
import com.facebook.react.common.MapBuilder;
78
import com.facebook.react.uimanager.SimpleViewManager;
89
import com.facebook.react.uimanager.ThemedReactContext;
910

1011
import java.util.Map;
1112

1213
public final class EngineViewManager extends SimpleViewManager<EngineView> {
14+
public static final int COMMAND_TAKE_SNAPSHOT = 0;
15+
public static final String COMMAND_TAKE_SNAPSHOT_NAME = "takeSnapshot";
1316

1417
@NonNull
1518
@Override
@@ -28,4 +31,33 @@ public void onDropViewInstance(@NonNull EngineView view) {
2831
super.onDropViewInstance(view);
2932
// TODO: Native view specific cleanup
3033
}
34+
35+
@Override
36+
public Map<String,Integer> getCommandsMap() {
37+
return MapBuilder.of(
38+
COMMAND_TAKE_SNAPSHOT_NAME,
39+
COMMAND_TAKE_SNAPSHOT
40+
);
41+
}
42+
43+
@Override
44+
public void receiveCommand(final EngineView view, String commandId, ReadableArray args) {
45+
// This will be called whenever a command is sent from react-native.
46+
switch (commandId) {
47+
case COMMAND_TAKE_SNAPSHOT_NAME:
48+
view.takeSnapshot();
49+
break;
50+
default:
51+
throw new IllegalArgumentException(
52+
String.format("Invalid command %s specified for EngineView. Supported Commands: takeSnapshot", commandId));
53+
}
54+
}
55+
56+
@Override
57+
public Map getExportedCustomDirectEventTypeConstants() {
58+
return MapBuilder.builder()
59+
.put(SnapshotDataReturnedEvent.EVENT_NAME,
60+
MapBuilder.of("registrationName", SnapshotDataReturnedEvent.EVENT_NAME))
61+
.build();
62+
}
3163
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.reactlibrary;
2+
3+
import com.facebook.react.bridge.Arguments;
4+
import com.facebook.react.bridge.WritableMap;
5+
import com.facebook.react.uimanager.events.Event;
6+
import com.facebook.react.uimanager.events.RCTEventEmitter;
7+
8+
public class SnapshotDataReturnedEvent extends Event<SnapshotDataReturnedEvent> {
9+
public static final String EVENT_NAME = "onSnapshotDataReturned";
10+
public static final String DATA_NAME = "data";
11+
private final WritableMap payload;
12+
13+
public SnapshotDataReturnedEvent(int viewId, String imageData) {
14+
super(viewId);
15+
this.payload = Arguments.createMap();
16+
this.payload.putString(DATA_NAME, imageData);
17+
}
18+
19+
@Override
20+
public String getEventName() {
21+
return EVENT_NAME;
22+
}
23+
24+
@Override
25+
public void dispatch(RCTEventEmitter rctEventEmitter) {
26+
rctEventEmitter.receiveEvent(getViewTag(), getEventName(), this.payload);
27+
}
28+
}

Modules/@babylonjs/react-native/ios/EngineViewManager.mm

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
#import "BabylonNativeInterop.h"
22

33
#import <React/RCTViewManager.h>
4+
#import <React/RCTUIManager.h>
45

56
#import <Foundation/Foundation.h>
67
#import <UIKit/UIKit.h>
78
#import <MetalKit/MetalKit.h>
89

910
@interface EngineView : MTKView
11+
12+
@property (nonatomic, copy) RCTDirectEventBlock onSnapshotDataReturned;
13+
1014
@end
1115

1216
@implementation EngineView {
@@ -47,6 +51,28 @@ - (void)touchesCancelled:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
4751
[BabylonNativeInterop reportTouchEvent:touches withEvent:event];
4852
}
4953

54+
- (void)takeSnapshot {
55+
// We must take the screenshot on the main thread otherwise we might fail to get a valid handle on the view's image.
56+
dispatch_async(dispatch_get_main_queue(), ^{
57+
// Start the graphics context.
58+
UIGraphicsBeginImageContextWithOptions(self.bounds.size, YES /* opaque */, 0.0f);
59+
60+
// Draw the current state of the view into the graphics context.
61+
[self drawViewHierarchyInRect:self.bounds afterScreenUpdates:NO];
62+
63+
// Grab the image from the graphics context, and convert into a base64 encoded JPG.
64+
UIImage* capturedImage = UIGraphicsGetImageFromCurrentImageContext();
65+
UIGraphicsEndImageContext();
66+
NSData* jpgData = UIImageJPEGRepresentation(capturedImage, 1.0f);
67+
NSString* encodedData = [jpgData base64EncodedStringWithOptions:0];
68+
69+
// Fire the onSnapshotDataReturned event if hooked up.
70+
if (self.onSnapshotDataReturned != nil) {
71+
self.onSnapshotDataReturned(@{ @"data":encodedData});
72+
}
73+
});
74+
}
75+
5076
@end
5177

5278

@@ -59,6 +85,19 @@ @implementation EngineViewManager {
5985

6086
RCT_EXPORT_MODULE(EngineViewManager)
6187

88+
RCT_EXPORT_VIEW_PROPERTY(onSnapshotDataReturned, RCTDirectEventBlock)
89+
90+
RCT_EXPORT_METHOD(takeSnapshot:(nonnull NSNumber*) reactTag) {
91+
// Marshal the takeSnapshot call to the appropriate EngineView.
92+
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber*,UIView*>* viewRegistry) {
93+
EngineView* view = (EngineView*)viewRegistry[reactTag];
94+
if (!view || ![view isKindOfClass:[EngineView class]]) {
95+
return;
96+
}
97+
[view takeSnapshot];
98+
}];
99+
}
100+
62101
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(setJSThread) {
63102
runLoop = [NSRunLoop currentRunLoop];
64103
return nil;

0 commit comments

Comments
 (0)