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