xref: /expo/packages/expo-gl/src/GLView.web.tsx (revision 9b26454e)
1import { CodedError, Platform, UnavailabilityError } from 'expo-modules-core';
2import invariant from 'invariant';
3import * as React from 'react';
4import { Dimensions } from 'react-native';
5
6import Canvas from './Canvas';
7import { WebGLObject } from './GLView';
8import {
9  GLViewProps,
10  ExpoWebGLRenderingContext,
11  GLSnapshot,
12  SnapshotOptions,
13  ComponentOrHandle,
14} from './GLView.types';
15
16function getImageForAsset(asset: {
17  downloadAsync?: () => Promise<any>;
18  uri?: string;
19  localUri?: string;
20}): HTMLImageElement | any {
21  if (asset != null && typeof asset === 'object' && asset.downloadAsync) {
22    const dataURI = asset.localUri || asset.uri || '';
23    const image = new Image();
24    image.src = dataURI;
25    return image;
26  }
27  return asset;
28}
29
30function isOffscreenCanvas(element: any): element is OffscreenCanvas {
31  return element && typeof element.convertToBlob === 'function';
32}
33
34function asExpoContext(gl: ExpoWebGLRenderingContext): WebGLRenderingContext {
35  gl.endFrameEXP = function glEndFrameEXP(): void {};
36
37  if (!gl['_expo_texImage2D']) {
38    gl['_expo_texImage2D'] = gl.texImage2D;
39    gl.texImage2D = (...props: any[]): any => {
40      const nextProps = [...props];
41      nextProps.push(getImageForAsset(nextProps.pop()));
42      return gl['_expo_texImage2D'](...nextProps);
43    };
44  }
45
46  if (!gl['_expo_texSubImage2D']) {
47    gl['_expo_texSubImage2D'] = gl.texSubImage2D;
48    gl.texSubImage2D = (...props: any[]): any => {
49      const nextProps = [...props];
50      nextProps.push(getImageForAsset(nextProps.pop()));
51      return gl['_expo_texSubImage2D'](...nextProps);
52    };
53  }
54
55  return gl;
56}
57
58function ensureContext(
59  canvas?: HTMLCanvasElement,
60  contextAttributes?: WebGLContextAttributes
61): WebGLRenderingContext {
62  if (!canvas) {
63    throw new CodedError(
64      'ERR_GL_INVALID',
65      'Attempting to use the GL context before it has been created.'
66    );
67  }
68
69  // Apple disables WebGL 2.0 and doesn't provide any way to detect if it's disabled.
70  const isIOS = !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform);
71
72  const context =
73    (!isIOS && canvas.getContext('webgl2', contextAttributes)) ||
74    canvas.getContext('webgl', contextAttributes) ||
75    canvas.getContext('webgl-experimental', contextAttributes) ||
76    canvas.getContext('experimental-webgl', contextAttributes);
77  invariant(context, 'Browser does not support WebGL');
78  return asExpoContext(context as ExpoWebGLRenderingContext);
79}
80
81// @needsAudit @docsMissing
82export type GLViewWebProps = GLViewProps & {
83  onContextCreate: (gl: WebGLRenderingContext) => void;
84  onContextRestored?: (gl?: WebGLRenderingContext) => void;
85  onContextLost?: () => void;
86  webglContextAttributes?: WebGLContextAttributes;
87  // type overwrite
88  nativeRef_EXPERIMENTAL?(callback: ComponentOrHandle | HTMLCanvasElement | null);
89};
90
91async function getBlobFromWebGLRenderingContext(
92  gl: WebGLRenderingContext,
93  options: SnapshotOptions = {}
94): Promise<{ width: number; height: number; blob: Blob | null }> {
95  invariant(gl, 'getBlobFromWebGLRenderingContext(): WebGL Rendering Context is not defined');
96
97  const { canvas } = gl;
98
99  let blob: Blob | null = null;
100
101  if (typeof (canvas as any).msToBlob === 'function') {
102    // @ts-ignore: polyfill: https://stackoverflow.com/a/29815058/4047926
103    blob = await canvas.msToBlob();
104  } else if (isOffscreenCanvas(canvas)) {
105    blob = await canvas.convertToBlob({ quality: options.compress, type: options.format });
106  } else {
107    blob = await new Promise((resolve) => {
108      canvas.toBlob((blob: Blob | null) => resolve(blob), options.format, options.compress);
109    });
110  }
111
112  return {
113    blob,
114    width: canvas.width,
115    height: canvas.height,
116  };
117}
118
119export class GLView extends React.Component<GLViewWebProps> {
120  canvas?: HTMLCanvasElement;
121
122  gl?: WebGLRenderingContext;
123
124  static async createContextAsync(): Promise<WebGLRenderingContext | null> {
125    if (!Platform.isDOMAvailable) {
126      return null;
127    }
128    const canvas = document.createElement('canvas');
129    const { width, height, scale } = Dimensions.get('window');
130    canvas.width = width * scale;
131    canvas.height = height * scale;
132    return ensureContext(canvas);
133  }
134
135  static async destroyContextAsync(exgl?: WebGLRenderingContext | number): Promise<boolean> {
136    // Do nothing
137    return true;
138  }
139
140  static async takeSnapshotAsync(
141    gl: WebGLRenderingContext,
142    options: SnapshotOptions = {}
143  ): Promise<GLSnapshot> {
144    const { blob, width, height } = await getBlobFromWebGLRenderingContext(gl, options);
145
146    if (!blob) {
147      throw new CodedError('ERR_GL_SNAPSHOT', 'Failed to save the GL context');
148    }
149
150    return {
151      uri: blob,
152      localUri: '',
153      width,
154      height,
155    };
156  }
157
158  componentWillUnmount() {
159    if (this.gl) {
160      const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
161      if (loseContextExt) {
162        loseContextExt.loseContext();
163      }
164      this.gl = undefined;
165    }
166    if (this.canvas) {
167      this.canvas.removeEventListener('webglcontextlost', this.onContextLost);
168      this.canvas.removeEventListener('webglcontextrestored', this.onContextRestored);
169    }
170  }
171
172  render() {
173    const {
174      onContextCreate,
175      onContextRestored,
176      onContextLost,
177      webglContextAttributes,
178      msaaSamples,
179      nativeRef_EXPERIMENTAL,
180      // @ts-ignore: ref does not exist
181      ref,
182      ...domProps
183    } = this.props;
184
185    return <Canvas {...domProps} canvasRef={this.setCanvasRef} />;
186  }
187
188  componentDidUpdate(prevProps) {
189    const { webglContextAttributes } = this.props;
190    if (this.canvas && webglContextAttributes !== prevProps.webglContextAttributes) {
191      this.onContextLost(null);
192      this.onContextRestored();
193    }
194  }
195
196  private getGLContextOrReject(): WebGLRenderingContext {
197    const gl = this.getGLContext();
198    if (!gl) {
199      throw new CodedError(
200        'ERR_GL_INVALID',
201        'Attempting to use the GL context before it has been created.'
202      );
203    }
204    return gl;
205  }
206
207  private onContextLost = (event: Event | null): void => {
208    if (event && event.preventDefault) {
209      event.preventDefault();
210    }
211    this.gl = undefined;
212
213    if (typeof this.props.onContextLost === 'function') {
214      this.props.onContextLost();
215    }
216  };
217
218  private onContextRestored = (): void => {
219    this.gl = undefined;
220    if (this.getGLContext() == null) {
221      throw new CodedError('ERR_GL_INVALID', 'Failed to restore GL context.');
222    }
223  };
224
225  private getGLContext(): WebGLRenderingContext | null {
226    if (this.gl) return this.gl;
227
228    if (this.canvas) {
229      this.gl = ensureContext(this.canvas, this.props.webglContextAttributes);
230      if (typeof this.props.onContextCreate === 'function') {
231        this.props.onContextCreate(this.gl);
232      }
233      return this.gl;
234    }
235    return null;
236  }
237
238  private setCanvasRef = (canvas: HTMLCanvasElement): void => {
239    this.canvas = canvas;
240
241    if (typeof this.props.nativeRef_EXPERIMENTAL === 'function') {
242      this.props.nativeRef_EXPERIMENTAL(canvas);
243    }
244
245    if (this.canvas) {
246      this.canvas.addEventListener('webglcontextlost', this.onContextLost);
247      this.canvas.addEventListener('webglcontextrestored', this.onContextRestored);
248
249      this.getGLContext();
250    }
251  };
252
253  public async takeSnapshotAsync(options: SnapshotOptions = {}): Promise<GLSnapshot> {
254    if (!GLView.takeSnapshotAsync) {
255      throw new UnavailabilityError('expo-gl', 'takeSnapshotAsync');
256    }
257
258    const gl = this.getGLContextOrReject();
259    return await GLView.takeSnapshotAsync(gl, options);
260  }
261
262  public async startARSessionAsync(): Promise<void> {
263    throw new UnavailabilityError('GLView', 'startARSessionAsync');
264  }
265
266  public async createCameraTextureAsync(): Promise<void> {
267    throw new UnavailabilityError('GLView', 'createCameraTextureAsync');
268  }
269
270  public async destroyObjectAsync(glObject: WebGLObject): Promise<void> {
271    throw new UnavailabilityError('GLView', 'destroyObjectAsync');
272  }
273}
274