(Plane::class.java),
+ camera.displayOrientedPose,
+ projectionMatrix
+ )
+
+ virtualSceneRenderer.onDrawFrame(surfaceDrawHandler, mesh, frame.camera)
+ onFrame(frame)
+ }
+
+ public fun handleTap(frame: Frame) {
+ virtualSceneRenderer.handleTap(frame)
+ }
+
+ /**
+ * Updates the display geometry. This must be called every frame before calling either of
+ * SurfaceDrawHandler's draw methods.
+ *
+ * @param frame The current `Frame` as returned by [Session.update].
+ */
+ private fun updateDisplayGeometry(frame: Frame) {
+ if (frame.hasDisplayGeometryChanged()) {
+ // If display rotation changed (also includes view size change), we need to re-query the UV
+ // coordinates for the screen rect, as they may have changed as well.
+
+ // The ArCore session knows the screen size has changed because we should have already called
+ // [DisplayRotationHelper.updateSessionIfNeeded] in [onDrawFrame]. This just updates the
+ // camera texture coordinates to match the screen size that the frame has.
+ frame.transformCoordinates2d(
+ Coordinates2d.OPENGL_NORMALIZED_DEVICE_COORDINATES,
+ ndcQuadCoordsBuffer,
+ Coordinates2d.TEXTURE_NORMALIZED,
+ cameraTexCoords
+ )
+ cameraTexCoordsVertexBuffer.set(cameraTexCoords)
+ }
+ }
+
+ /**
+ * Draws the AR background image. The image will be drawn such that virtual content rendered with
+ * the matrices provided by [com.google.ar.core.Camera.getViewMatrix] and
+ * [com.google.ar.core.Camera.getProjectionMatrix] will
+ * accurately follow static physical objects.
+ */
+ private fun drawBackground(surfaceDrawHandler: SurfaceDrawHandler) {
+ surfaceDrawHandler.draw(mesh, cameraFeedShader)
+ }
+
+ override fun onResume(owner: LifecycleOwner) {
+ displayRotationHelper.onResume()
+ // not sure why we do this in onResume
+ hasSetTextureNames = false
+ }
+
+ override fun onPause(owner: LifecycleOwner) {
+ displayRotationHelper.onPause()
+ }
+
+ public fun onClick(it: MotionEvent): Unit = virtualSceneRenderer.onClick(it)
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/DisplayRotationHelper.java b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/DisplayRotationHelper.java
new file mode 100644
index 000000000..ed4a114de
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/DisplayRotationHelper.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2017 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arcgismaps.toolkit.ar.render;
+
+import android.app.Activity;
+import android.content.Context;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraManager;
+import android.hardware.display.DisplayManager;
+import android.hardware.display.DisplayManager.DisplayListener;
+import android.view.Display;
+import android.view.Surface;
+import android.view.WindowManager;
+
+import com.google.ar.core.Session;
+
+/**
+ * Helper to track the display rotations. In particular, the 180 degree rotations are not notified
+ * by the onSurfaceChanged() callback, and thus they require listening to the android display
+ * events.
+ */
+public final class DisplayRotationHelper implements DisplayListener {
+ private boolean viewportChanged;
+ private int viewportWidth;
+ private int viewportHeight;
+ private final Display display;
+ private final DisplayManager displayManager;
+ private final CameraManager cameraManager;
+
+ /**
+ * Constructs the DisplayRotationHelper but does not register the listener yet.
+ *
+ * @param context the Android {@link Context}.
+ */
+ public DisplayRotationHelper(Context context) {
+ displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE);
+ cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
+ WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+ display = windowManager.getDefaultDisplay();
+ }
+
+ /**
+ * Registers the display listener. Should be called from {@link Activity#onResume()}.
+ */
+ public void onResume() {
+ displayManager.registerDisplayListener(this, null);
+ }
+
+ /**
+ * Unregisters the display listener. Should be called from {@link Activity#onPause()}.
+ */
+ public void onPause() {
+ displayManager.unregisterDisplayListener(this);
+ }
+
+ /**
+ * Records a change in surface dimensions. This will be later used by {@link
+ * #updateSessionIfNeeded(Session)}. Should be called from {@link
+ * android.opengl.GLSurfaceView.Renderer
+ * #onSurfaceChanged(javax.microedition.khronos.opengles.GL10, int, int)}.
+ *
+ * @param width the updated width of the surface.
+ * @param height the updated height of the surface.
+ */
+ public void onSurfaceChanged(int width, int height) {
+ viewportWidth = width;
+ viewportHeight = height;
+ viewportChanged = true;
+ }
+
+ /**
+ * Updates the session display geometry if a change was posted either by {@link
+ * #onSurfaceChanged(int, int)} call or by {@link #onDisplayChanged(int)} system callback. This
+ * function should be called explicitly before each call to {@link Session#update()}. This
+ * function will also clear the 'pending update' (viewportChanged) flag.
+ *
+ * @param session the {@link Session} object to update if display geometry changed.
+ */
+ public void updateSessionIfNeeded(Session session) {
+ if (viewportChanged) {
+ int displayRotation = display.getRotation();
+ session.setDisplayGeometry(displayRotation, viewportWidth, viewportHeight);
+ viewportChanged = false;
+ }
+ }
+
+ /**
+ * Returns the aspect ratio of the GL surface viewport while accounting for the display rotation
+ * relative to the device camera sensor orientation.
+ */
+ public float getCameraSensorRelativeViewportAspectRatio(String cameraId) {
+ float aspectRatio;
+ int cameraSensorToDisplayRotation = getCameraSensorToDisplayRotation(cameraId);
+ switch (cameraSensorToDisplayRotation) {
+ case 90:
+ case 270:
+ aspectRatio = (float) viewportHeight / (float) viewportWidth;
+ break;
+ case 0:
+ case 180:
+ aspectRatio = (float) viewportWidth / (float) viewportHeight;
+ break;
+ default:
+ throw new RuntimeException("Unhandled rotation: " + cameraSensorToDisplayRotation);
+ }
+ return aspectRatio;
+ }
+
+ /**
+ * Returns the rotation of the back-facing camera with respect to the display. The value is one of
+ * 0, 90, 180, 270.
+ */
+ public int getCameraSensorToDisplayRotation(String cameraId) {
+ CameraCharacteristics characteristics;
+ try {
+ characteristics = cameraManager.getCameraCharacteristics(cameraId);
+ } catch (CameraAccessException e) {
+ throw new RuntimeException("Unable to determine display orientation", e);
+ }
+
+ // Camera sensor orientation.
+ int sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION);
+
+ // Current display orientation.
+ int displayOrientation = toDegrees(display.getRotation());
+
+ // Make sure we return 0, 90, 180, or 270 degrees.
+ return (sensorOrientation - displayOrientation + 360) % 360;
+ }
+
+ private int toDegrees(int rotation) {
+ switch (rotation) {
+ case Surface.ROTATION_0:
+ return 0;
+ case Surface.ROTATION_90:
+ return 90;
+ case Surface.ROTATION_180:
+ return 180;
+ case Surface.ROTATION_270:
+ return 270;
+ default:
+ throw new RuntimeException("Unknown rotation " + rotation);
+ }
+ }
+
+ @Override
+ public void onDisplayAdded(int displayId) {
+ }
+
+ @Override
+ public void onDisplayRemoved(int displayId) {
+ }
+
+ @Override
+ public void onDisplayChanged(int displayId) {
+ viewportChanged = true;
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Framebuffer.java b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Framebuffer.java
new file mode 100644
index 000000000..becadcd27
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Framebuffer.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arcgismaps.toolkit.ar.render;
+
+import android.opengl.GLES30;
+import android.util.Log;
+
+import java.io.Closeable;
+
+
+/**
+ * A framebuffer associated with a texture.
+ */
+public class Framebuffer implements Closeable {
+ private static final String TAG = Framebuffer.class.getSimpleName();
+
+ private final int[] framebufferId = {0};
+ private final Texture colorTexture;
+ private final Texture depthTexture;
+ private int width = -1;
+ private int height = -1;
+
+ /**
+ * Constructs a {@link Framebuffer} which renders internally to a texture.
+ *
+ * In order to render to the {@link Framebuffer}, use {@link SurfaceDrawHandler#draw(Mesh, Shader,
+ * Framebuffer)}.
+ */
+ public Framebuffer(int width, int height) {
+ try {
+ colorTexture =
+ new Texture(
+ Texture.Target.TEXTURE_2D,
+ Texture.WrapMode.CLAMP_TO_EDGE,
+ /*useMipmaps=*/ false);
+ depthTexture =
+ new Texture(
+ Texture.Target.TEXTURE_2D,
+ Texture.WrapMode.CLAMP_TO_EDGE,
+ /*useMipmaps=*/ false);
+
+ // Set parameters of the depth texture so that it's readable by shaders.
+ GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, depthTexture.getTextureId());
+ GLError.maybeThrowGLException("Failed to bind depth texture", "glBindTexture");
+ GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_COMPARE_MODE, GLES30.GL_NONE);
+ GLError.maybeThrowGLException("Failed to set texture parameter", "glTexParameteri");
+ GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_NEAREST);
+ GLError.maybeThrowGLException("Failed to set texture parameter", "glTexParameteri");
+ GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_NEAREST);
+ GLError.maybeThrowGLException("Failed to set texture parameter", "glTexParameteri");
+
+ // Set initial dimensions.
+ resize(width, height);
+
+ // Create framebuffer object and bind to the color and depth textures.
+ GLES30.glGenFramebuffers(1, framebufferId, 0);
+ GLError.maybeThrowGLException("Framebuffer creation failed", "glGenFramebuffers");
+ GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, framebufferId[0]);
+ GLError.maybeThrowGLException("Failed to bind framebuffer", "glBindFramebuffer");
+ GLES30.glFramebufferTexture2D(
+ GLES30.GL_FRAMEBUFFER,
+ GLES30.GL_COLOR_ATTACHMENT0,
+ GLES30.GL_TEXTURE_2D,
+ colorTexture.getTextureId(),
+ /*level=*/ 0);
+ GLError.maybeThrowGLException(
+ "Failed to bind color texture to framebuffer", "glFramebufferTexture2D");
+ GLES30.glFramebufferTexture2D(
+ GLES30.GL_FRAMEBUFFER,
+ GLES30.GL_DEPTH_ATTACHMENT,
+ GLES30.GL_TEXTURE_2D,
+ depthTexture.getTextureId(),
+ /*level=*/ 0);
+ GLError.maybeThrowGLException(
+ "Failed to bind depth texture to framebuffer", "glFramebufferTexture2D");
+
+ int status = GLES30.glCheckFramebufferStatus(GLES30.GL_FRAMEBUFFER);
+ if (status != GLES30.GL_FRAMEBUFFER_COMPLETE) {
+ throw new IllegalStateException("Framebuffer construction not complete: code " + status);
+ }
+ } catch (Throwable t) {
+ close();
+ throw t;
+ }
+ }
+
+ @Override
+ public void close() {
+ if (framebufferId[0] != 0) {
+ GLES30.glDeleteFramebuffers(1, framebufferId, 0);
+ GLError.maybeLogGLError(Log.WARN, TAG, "Failed to free framebuffer", "glDeleteFramebuffers");
+ framebufferId[0] = 0;
+ }
+ colorTexture.close();
+ depthTexture.close();
+ }
+
+ /**
+ * Resizes the framebuffer to the given dimensions.
+ */
+ public void resize(int width, int height) {
+ if (this.width == width && this.height == height) {
+ return;
+ }
+ this.width = width;
+ this.height = height;
+
+ // Color texture
+ GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, colorTexture.getTextureId());
+ GLError.maybeThrowGLException("Failed to bind color texture", "glBindTexture");
+ GLES30.glTexImage2D(
+ GLES30.GL_TEXTURE_2D,
+ /*level=*/ 0,
+ GLES30.GL_RGBA,
+ width,
+ height,
+ /*border=*/ 0,
+ GLES30.GL_RGBA,
+ GLES30.GL_UNSIGNED_BYTE,
+ /*pixels=*/ null);
+ GLError.maybeThrowGLException("Failed to specify color texture format", "glTexImage2D");
+
+ // Depth texture
+ GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, depthTexture.getTextureId());
+ GLError.maybeThrowGLException("Failed to bind depth texture", "glBindTexture");
+ GLES30.glTexImage2D(
+ GLES30.GL_TEXTURE_2D,
+ /*level=*/ 0,
+ GLES30.GL_DEPTH_COMPONENT32F,
+ width,
+ height,
+ /*border=*/ 0,
+ GLES30.GL_DEPTH_COMPONENT,
+ GLES30.GL_FLOAT,
+ /*pixels=*/ null);
+ GLError.maybeThrowGLException("Failed to specify depth texture format", "glTexImage2D");
+ }
+
+ /**
+ * Returns the color texture associated with this framebuffer.
+ */
+ public Texture getColorTexture() {
+ return colorTexture;
+ }
+
+ /**
+ * Returns the depth texture associated with this framebuffer.
+ */
+ public Texture getDepthTexture() {
+ return depthTexture;
+ }
+
+ /**
+ * Returns the width of the framebuffer.
+ */
+ public int getWidth() {
+ return width;
+ }
+
+ /**
+ * Returns the height of the framebuffer.
+ */
+ public int getHeight() {
+ return height;
+ }
+
+ /* package-private */
+ int getFramebufferId() {
+ return framebufferId[0];
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/GLError.java b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/GLError.java
new file mode 100644
index 000000000..d7b8c5d9b
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/GLError.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arcgismaps.toolkit.ar.render;
+
+import android.opengl.GLES30;
+import android.opengl.GLException;
+import android.opengl.GLU;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Module for handling OpenGL errors.
+ */
+public class GLError {
+ /**
+ * Throws a {@link GLException} if a GL error occurred.
+ */
+ public static void maybeThrowGLException(String reason, String api) {
+ List errorCodes = getGlErrors();
+ if (errorCodes != null) {
+ throw new GLException(errorCodes.get(0), formatErrorMessage(reason, api, errorCodes));
+ }
+ }
+
+ /**
+ * Logs a message with the given logcat priority if a GL error occurred.
+ */
+ public static void maybeLogGLError(int priority, String tag, String reason, String api) {
+ List errorCodes = getGlErrors();
+ if (errorCodes != null) {
+ Log.println(priority, tag, formatErrorMessage(reason, api, errorCodes));
+ }
+ }
+
+ private static String formatErrorMessage(String reason, String api, List errorCodes) {
+ StringBuilder builder = new StringBuilder(String.format("%s: %s: ", reason, api));
+ Iterator iterator = errorCodes.iterator();
+ while (iterator.hasNext()) {
+ int errorCode = iterator.next();
+ builder.append(String.format("%s (%d)", GLU.gluErrorString(errorCode), errorCode));
+ if (iterator.hasNext()) {
+ builder.append(", ");
+ }
+ }
+ return builder.toString();
+ }
+
+ private static List getGlErrors() {
+ int errorCode = GLES30.glGetError();
+ // Shortcut for no errors
+ if (errorCode == GLES30.GL_NO_ERROR) {
+ return null;
+ }
+ List errorCodes = new ArrayList<>();
+ errorCodes.add(errorCode);
+ while (true) {
+ errorCode = GLES30.glGetError();
+ if (errorCode == GLES30.GL_NO_ERROR) {
+ break;
+ }
+ errorCodes.add(errorCode);
+ }
+ return errorCodes;
+ }
+
+ private GLError() {
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/GpuBuffer.java b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/GpuBuffer.java
new file mode 100644
index 000000000..263db7151
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/GpuBuffer.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arcgismaps.toolkit.ar.render;
+
+import android.opengl.GLES30;
+import android.util.Log;
+
+import java.nio.Buffer;
+
+/* package-private */
+class GpuBuffer {
+ private static final String TAG = GpuBuffer.class.getSimpleName();
+
+ // These values refer to the byte count of the corresponding Java datatypes.
+ public static final int INT_SIZE = 4;
+ public static final int FLOAT_SIZE = 4;
+
+ private final int target;
+ private final int numberOfBytesPerEntry;
+ private final int[] bufferId = {0};
+ private int size;
+ private int capacity;
+
+ public GpuBuffer(int target, int numberOfBytesPerEntry, Buffer entries) {
+ if (entries != null) {
+ if (!entries.isDirect()) {
+ throw new IllegalArgumentException("If non-null, entries buffer must be a direct buffer");
+ }
+ // Some GPU drivers will fail with out of memory errors if glBufferData or glBufferSubData is
+ // called with a size of 0, so avoid this case.
+ if (entries.limit() == 0) {
+ entries = null;
+ }
+ }
+
+ this.target = target;
+ this.numberOfBytesPerEntry = numberOfBytesPerEntry;
+ if (entries == null) {
+ this.size = 0;
+ this.capacity = 0;
+ } else {
+ this.size = entries.limit();
+ this.capacity = entries.limit();
+ }
+
+ try {
+ // Clear VAO to prevent unintended state change.
+ GLES30.glBindVertexArray(0);
+ GLError.maybeThrowGLException("Failed to unbind vertex array", "glBindVertexArray");
+
+ GLES30.glGenBuffers(1, bufferId, 0);
+ GLError.maybeThrowGLException("Failed to generate buffers", "glGenBuffers");
+
+ GLES30.glBindBuffer(target, bufferId[0]);
+ GLError.maybeThrowGLException("Failed to bind buffer object", "glBindBuffer");
+
+ if (entries != null) {
+ entries.rewind();
+ GLES30.glBufferData(
+ target, entries.limit() * numberOfBytesPerEntry, entries, GLES30.GL_DYNAMIC_DRAW);
+ }
+ GLError.maybeThrowGLException("Failed to populate buffer object", "glBufferData");
+ } catch (Throwable t) {
+ free();
+ throw t;
+ }
+ }
+
+ public void set(Buffer entries) {
+ // Some GPU drivers will fail with out of memory errors if glBufferData or glBufferSubData is
+ // called with a size of 0, so avoid this case.
+ if (entries == null || entries.limit() == 0) {
+ size = 0;
+ return;
+ }
+ if (!entries.isDirect()) {
+ throw new IllegalArgumentException("If non-null, entries buffer must be a direct buffer");
+ }
+ GLES30.glBindBuffer(target, bufferId[0]);
+ GLError.maybeThrowGLException("Failed to bind vertex buffer object", "glBindBuffer");
+
+ entries.rewind();
+
+ if (entries.limit() <= capacity) {
+ GLES30.glBufferSubData(target, 0, entries.limit() * numberOfBytesPerEntry, entries);
+ GLError.maybeThrowGLException("Failed to populate vertex buffer object", "glBufferSubData");
+ size = entries.limit();
+ } else {
+ GLES30.glBufferData(
+ target, entries.limit() * numberOfBytesPerEntry, entries, GLES30.GL_DYNAMIC_DRAW);
+ GLError.maybeThrowGLException("Failed to populate vertex buffer object", "glBufferData");
+ size = entries.limit();
+ capacity = entries.limit();
+ }
+ }
+
+ public void free() {
+ if (bufferId[0] != 0) {
+ GLES30.glDeleteBuffers(1, bufferId, 0);
+ GLError.maybeLogGLError(Log.WARN, TAG, "Failed to free buffer object", "glDeleteBuffers");
+ bufferId[0] = 0;
+ }
+ }
+
+ public int getBufferId() {
+ return bufferId[0];
+ }
+
+ public int getSize() {
+ return size;
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/IndexBuffer.java b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/IndexBuffer.java
new file mode 100644
index 000000000..65243adf8
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/IndexBuffer.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arcgismaps.toolkit.ar.render;
+
+import android.opengl.GLES30;
+
+import java.io.Closeable;
+import java.nio.IntBuffer;
+
+/**
+ * A list of vertex indices stored GPU-side.
+ *
+ * When constructing a {@link Mesh}, an {@link IndexBuffer} may be passed to describe the
+ * ordering of vertices when drawing each primitive.
+ *
+ * @see glDrawElements
+ */
+public class IndexBuffer implements Closeable {
+ private final GpuBuffer buffer;
+
+ /**
+ * Construct an {@link IndexBuffer} populated with initial data.
+ *
+ *
The GPU buffer will be filled with the data in the direct buffer {@code entries},
+ * starting from the beginning of the buffer (not the current cursor position). The cursor will be
+ * left in an undefined position after this function returns.
+ *
+ *
The {@code entries} buffer may be null, in which case an empty buffer is constructed
+ * instead.
+ */
+ public IndexBuffer(IntBuffer entries) {
+ buffer = new GpuBuffer(GLES30.GL_ELEMENT_ARRAY_BUFFER, GpuBuffer.INT_SIZE, entries);
+ }
+
+ /**
+ * Populate with new data.
+ *
+ *
The entire buffer is replaced by the contents of the direct buffer {@code entries}
+ * starting from the beginning of the buffer, not the current cursor position. The cursor will be
+ * left in an undefined position after this function returns.
+ *
+ *
The GPU buffer is reallocated automatically if necessary.
+ *
+ *
The {@code entries} buffer may be null, in which case the buffer will become empty.
+ */
+ public void set(IntBuffer entries) {
+ buffer.set(entries);
+ }
+
+ @Override
+ public void close() {
+ buffer.free();
+ }
+
+ /* package-private */
+ int getBufferId() {
+ return buffer.getBufferId();
+ }
+
+ /* package-private */
+ int getSize() {
+ return buffer.getSize();
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Mesh.java b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Mesh.java
new file mode 100644
index 000000000..be1896915
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Mesh.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arcgismaps.toolkit.ar.render;
+
+import android.content.res.AssetManager;
+import android.opengl.GLES30;
+import android.util.Log;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.FloatBuffer;
+import java.nio.IntBuffer;
+
+import de.javagl.obj.Obj;
+import de.javagl.obj.ObjData;
+import de.javagl.obj.ObjReader;
+import de.javagl.obj.ObjUtils;
+
+/**
+ * A collection of vertices, faces, and other attributes that define how to render a 3D object.
+ *
+ *
To render the mesh, use {@link SurfaceDrawHandler#draw()}.
+ */
+public class Mesh implements Closeable {
+ private static final String TAG = Mesh.class.getSimpleName();
+
+ /**
+ * The kind of primitive to render.
+ *
+ *
This determines how the data in {@link VertexBuffer}s are interpreted. See here for more on how primitives
+ * behave.
+ */
+ public enum PrimitiveMode {
+ POINTS(GLES30.GL_POINTS),
+ LINE_STRIP(GLES30.GL_LINE_STRIP),
+ LINE_LOOP(GLES30.GL_LINE_LOOP),
+ LINES(GLES30.GL_LINES),
+ TRIANGLE_STRIP(GLES30.GL_TRIANGLE_STRIP),
+ TRIANGLE_FAN(GLES30.GL_TRIANGLE_FAN),
+ TRIANGLES(GLES30.GL_TRIANGLES);
+
+ /* package-private */
+ final int glesEnum;
+
+ PrimitiveMode(int glesEnum) {
+ this.glesEnum = glesEnum;
+ }
+ }
+
+ private final int[] vertexArrayId = {0};
+ private final PrimitiveMode primitiveMode;
+ private final IndexBuffer indexBuffer;
+ private final VertexBuffer[] vertexBuffers;
+
+ /**
+ * Construct a {@link Mesh}.
+ *
+ *
The data in the given {@link IndexBuffer} and {@link VertexBuffer}s does not need to be
+ * finalized; they may be freely changed throughout the lifetime of a {@link Mesh} using their
+ * respective {@code set()} methods.
+ *
+ *
The ordering of the {@code vertexBuffers} is significant. Their array indices will
+ * correspond to their attribute locations, which must be taken into account in shader code. The
+ * layout qualifier must
+ * be used in the vertex shader code to explicitly associate attributes with these indices.
+ */
+ public Mesh(
+ PrimitiveMode primitiveMode,
+ IndexBuffer indexBuffer,
+ VertexBuffer[] vertexBuffers) {
+ if (vertexBuffers == null || vertexBuffers.length == 0) {
+ throw new IllegalArgumentException("Must pass at least one vertex buffer");
+ }
+
+ this.primitiveMode = primitiveMode;
+ this.indexBuffer = indexBuffer;
+ this.vertexBuffers = vertexBuffers;
+
+ try {
+ // Create vertex array
+ GLES30.glGenVertexArrays(1, vertexArrayId, 0);
+ GLError.maybeThrowGLException("Failed to generate a vertex array", "glGenVertexArrays");
+
+ // Bind vertex array
+ GLES30.glBindVertexArray(vertexArrayId[0]);
+ GLError.maybeThrowGLException("Failed to bind vertex array object", "glBindVertexArray");
+
+ if (indexBuffer != null) {
+ GLES30.glBindBuffer(GLES30.GL_ELEMENT_ARRAY_BUFFER, indexBuffer.getBufferId());
+ }
+
+ for (int i = 0; i < vertexBuffers.length; ++i) {
+ // Bind each vertex buffer to vertex array
+ GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, vertexBuffers[i].getBufferId());
+ GLError.maybeThrowGLException("Failed to bind vertex buffer", "glBindBuffer");
+ GLES30.glVertexAttribPointer(
+ i, vertexBuffers[i].getNumberOfEntriesPerVertex(), GLES30.GL_FLOAT, false, 0, 0);
+ GLError.maybeThrowGLException(
+ "Failed to associate vertex buffer with vertex array", "glVertexAttribPointer");
+ GLES30.glEnableVertexAttribArray(i);
+ GLError.maybeThrowGLException(
+ "Failed to enable vertex buffer", "glEnableVertexAttribArray");
+ }
+ } catch (Throwable t) {
+ close();
+ throw t;
+ }
+ }
+
+ /**
+ * Constructs a {@link Mesh} from the given Wavefront OBJ file.
+ *
+ *
The {@link Mesh} will be constructed with three attributes, indexed in the order of local
+ * coordinates (location 0, vec3), texture coordinates (location 1, vec2), and vertex normals
+ * (location 2, vec3).
+ */
+ public static Mesh createFromAsset(AssetManager assets, String assetFileName) throws IOException {
+ try (InputStream inputStream = assets.open(assetFileName)) {
+ Obj obj = ObjUtils.convertToRenderable(ObjReader.read(inputStream));
+
+ // Obtain the data from the OBJ, as direct buffers:
+ IntBuffer vertexIndices = ObjData.getFaceVertexIndices(obj, /*numVerticesPerFace=*/ 3);
+ FloatBuffer localCoordinates = ObjData.getVertices(obj);
+ FloatBuffer textureCoordinates = ObjData.getTexCoords(obj, /*dimensions=*/ 2);
+ FloatBuffer normals = ObjData.getNormals(obj);
+
+ VertexBuffer[] vertexBuffers = {
+ new VertexBuffer(3, localCoordinates),
+ new VertexBuffer(2, textureCoordinates),
+ new VertexBuffer(3, normals),
+ };
+
+ IndexBuffer indexBuffer = new IndexBuffer(vertexIndices);
+
+ return new Mesh(PrimitiveMode.TRIANGLES, indexBuffer, vertexBuffers);
+ }
+ }
+
+ @Override
+ public void close() {
+ if (vertexArrayId[0] != 0) {
+ GLES30.glDeleteVertexArrays(1, vertexArrayId, 0);
+ GLError.maybeLogGLError(
+ Log.WARN, TAG, "Failed to free vertex array object", "glDeleteVertexArrays");
+ }
+ }
+
+ /**
+ * Draws the mesh. Don't call this directly unless you are doing low level OpenGL code; instead,
+ * prefer {@link SurfaceDrawHandler#draw}.
+ */
+ public void lowLevelDraw() {
+ if (vertexArrayId[0] == 0) {
+ throw new IllegalStateException("Tried to draw a freed Mesh");
+ }
+
+ GLES30.glBindVertexArray(vertexArrayId[0]);
+ GLError.maybeThrowGLException("Failed to bind vertex array object", "glBindVertexArray");
+ if (indexBuffer == null) {
+ // Sanity check for debugging
+ int vertexCount = vertexBuffers[0].getNumberOfVertices();
+ for (int i = 1; i < vertexBuffers.length; ++i) {
+ int iterCount = vertexBuffers[i].getNumberOfVertices();
+ if (iterCount != vertexCount) {
+ throw new IllegalStateException(
+ String.format(
+ "Vertex buffers have mismatching numbers of vertices ([0] has %d but [%d] has"
+ + " %d)",
+ vertexCount, i, iterCount));
+ }
+ }
+ GLES30.glDrawArrays(primitiveMode.glesEnum, 0, vertexCount);
+ GLError.maybeThrowGLException("Failed to draw vertex array object", "glDrawArrays");
+ } else {
+ GLES30.glDrawElements(
+ primitiveMode.glesEnum, indexBuffer.getSize(), GLES30.GL_UNSIGNED_INT, 0);
+ GLError.maybeThrowGLException(
+ "Failed to draw vertex array object with indices", "glDrawElements");
+ }
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/PlaneRenderer.kt b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/PlaneRenderer.kt
new file mode 100644
index 000000000..e5dcd51db
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/PlaneRenderer.kt
@@ -0,0 +1,335 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arcgismaps.toolkit.ar.render
+
+import android.content.res.AssetManager
+import android.opengl.Matrix
+import com.google.ar.core.Plane
+import com.google.ar.core.Pose
+import com.google.ar.core.TrackingState
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.FloatBuffer
+import java.nio.IntBuffer
+import java.util.Collections
+import kotlin.math.cos
+import kotlin.math.max
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/** Renders the detected AR planes. */
+public class PlaneRenderer(render: SurfaceDrawHandler?, assets: AssetManager) {
+ private val mesh: Mesh
+ private val indexBufferObject: IndexBuffer
+ private val vertexBufferObject: VertexBuffer
+ private val shader: Shader
+
+ private var vertexBuffer: FloatBuffer = ByteBuffer.allocateDirect(
+ INITIAL_VERTEX_BUFFER_SIZE_BYTES
+ )
+ .order(ByteOrder.nativeOrder())
+ .asFloatBuffer()
+ private var indexBuffer: IntBuffer = ByteBuffer.allocateDirect(
+ INITIAL_INDEX_BUFFER_SIZE_BYTES
+ )
+ .order(ByteOrder.nativeOrder())
+ .asIntBuffer()
+
+ // Temporary lists/matrices allocated here to reduce number of allocations for each frame.
+ private val viewMatrix = FloatArray(16)
+ private val modelMatrix = FloatArray(16)
+ private val modelViewMatrix = FloatArray(16)
+ private val modelViewProjectionMatrix = FloatArray(16)
+ private val planeAngleUvMatrix = FloatArray(4) // 2x2 rotation matrix applied to uv coords.
+ private val normalVector = FloatArray(3)
+
+ private val planeIndexMap: MutableMap = HashMap()
+
+ /**
+ * Allocates and initializes OpenGL resources needed by the plane renderer. Must be called during
+ * a [SampleRender.Renderer] callback, typically in [ ][SampleRender.Renderer.onSurfaceCreated].
+ */
+ init {
+ val texture: Texture =
+ Texture.createFromAsset(
+ assets, TEXTURE_NAME, Texture.WrapMode.REPEAT, Texture.ColorFormat.LINEAR
+ )
+ shader =
+ Shader.createFromAssets(
+ assets,
+ VERTEX_SHADER_NAME,
+ FRAGMENT_SHADER_NAME, /*defines=*/
+ null
+ )
+ .setTexture("u_Texture", texture)
+ .setVec4("u_GridControl", GRID_CONTROL)
+ .setBlend(
+ Shader.BlendFactor.DST_ALPHA, // RGB (src)
+ Shader.BlendFactor.ONE, // RGB (dest)
+ Shader.BlendFactor.ZERO, // ALPHA (src)
+ Shader.BlendFactor.ONE_MINUS_SRC_ALPHA
+ ) // ALPHA (dest)
+ .setDepthWrite(false)
+
+ indexBufferObject = IndexBuffer(null)
+ vertexBufferObject = VertexBuffer(COORDS_PER_VERTEX, null)
+ val vertexBuffers: Array = arrayOf(vertexBufferObject)
+ mesh = Mesh(Mesh.PrimitiveMode.TRIANGLE_STRIP, indexBufferObject, vertexBuffers)
+ }
+
+ /** Updates the plane model transform matrix and extents. */
+ private fun updatePlaneParameters(
+ planeMatrix: FloatArray, extentX: Float, extentZ: Float, boundary: FloatBuffer?
+ ) {
+ System.arraycopy(planeMatrix, 0, modelMatrix, 0, 16)
+ // From the doc of what gets passed in for boundary:
+ // Returns the 2D vertices of a convex polygon approximating the detected plane, in the form [x1, z1, x2, z2, ...].
+ // These X-Z values are in the plane's local x-z plane (y=0) and must be transformed by the pose (getCenterPose())
+ // to get the boundary in world coordinates.
+ if (boundary == null) {
+ vertexBuffer.limit(0)
+ indexBuffer.limit(0)
+ return
+ }
+
+ // Generate a new set of vertices and a corresponding triangle strip index set so that
+ // the plane boundary polygon has a fading edge. This is done by making a copy of the
+ // boundary polygon vertices and scaling it down around center to push it inwards. Then
+ // the index buffer is setup accordingly.
+ boundary.rewind()
+ val boundaryVertices =
+ boundary.limit() / 2 // because they are x,z coordinates so the number of verts is half the number of entries
+
+ val numVertices = boundaryVertices * VERTS_PER_BOUNDARY_VERT
+ // drawn as GL_TRIANGLE_STRIP with 3n-2 triangles (n-2 for fill, 2n for perimeter).
+ val numIndices = boundaryVertices * INDICES_PER_BOUNDARY_VERT
+
+ // Now we are just resizing the vertex and index buffers to make sure they can fit our boundary polygon
+ if (vertexBuffer.capacity() < numVertices * COORDS_PER_VERTEX) {
+ var size = vertexBuffer.capacity()
+ while (size < numVertices * COORDS_PER_VERTEX) {
+ size *= 2
+ }
+ vertexBuffer =
+ ByteBuffer.allocateDirect(BYTES_PER_FLOAT * size)
+ .order(ByteOrder.nativeOrder())
+ .asFloatBuffer()
+ }
+ vertexBuffer.rewind()
+ vertexBuffer.limit(numVertices * COORDS_PER_VERTEX)
+
+ if (indexBuffer.capacity() < numIndices) {
+ var size = indexBuffer.capacity()
+ while (size < numIndices) {
+ size *= 2
+ }
+ indexBuffer =
+ ByteBuffer.allocateDirect(BYTES_PER_INT * size)
+ .order(ByteOrder.nativeOrder())
+ .asIntBuffer()
+ }
+ indexBuffer.rewind()
+ indexBuffer.limit(numIndices)
+
+ // Note: when either dimension of the bounding box is smaller than 2*FADE_RADIUS_M we
+ // generate a bunch of 0-area triangles. These don't get rendered though so it works
+ // out ok.
+ val xScale = max(((extentX - 2 * FADE_RADIUS_M) / extentX).toDouble(), 0.0)
+ .toFloat()
+ val zScale = max(((extentZ - 2 * FADE_RADIUS_M) / extentZ).toDouble(), 0.0)
+ .toFloat()
+
+ while (boundary.hasRemaining()) {
+ val x = boundary.get()
+ val z = boundary.get()
+ vertexBuffer.put(x)
+ vertexBuffer.put(z)
+ vertexBuffer.put(0.0f)
+ vertexBuffer.put(x * xScale)
+ vertexBuffer.put(z * zScale)
+ vertexBuffer.put(1.0f)
+ }
+
+ // step 1, perimeter
+ indexBuffer.put(((boundaryVertices - 1) * 2).toShort().toInt())
+ for (i in 0 until boundaryVertices) {
+ indexBuffer.put((i * 2).toShort().toInt())
+ indexBuffer.put((i * 2 + 1).toShort().toInt())
+ }
+ indexBuffer.put(1.toShort().toInt())
+
+ // This leaves us on the interior edge of the perimeter between the inset vertices
+ // for boundary verts n-1 and 0.
+
+ // step 2, interior:
+ for (i in 1 until boundaryVertices / 2) {
+ indexBuffer.put(
+ ((boundaryVertices - 1 - i) * 2 + 1).toShort()
+ .toInt()
+ )
+ indexBuffer.put((i * 2 + 1).toShort().toInt())
+ }
+ if (boundaryVertices % 2 != 0) {
+ indexBuffer.put(((boundaryVertices / 2) * 2 + 1).toShort().toInt())
+ }
+ }
+
+ /**
+ * Draws the collection of tracked planes, with closer planes hiding more distant ones.
+ *
+ * @param allPlanes The collection of planes to draw.
+ * @param cameraPose The pose of the camera, as returned by [Camera.getPose]
+ * @param cameraProjection The projection matrix, as returned by [ ][Camera.getProjectionMatrix]
+ */
+ public fun drawPlanes(
+ surfaceDrawHandler: SurfaceDrawHandler,
+ allPlanes: Collection,
+ cameraPose: Pose,
+ cameraProjection: FloatArray?
+ ) {
+ // Planes must be sorted by distance from camera so that we draw closer planes first, and
+ // they occlude the farther planes.
+ val sortedPlanes: MutableList = ArrayList()
+
+ for (plane in allPlanes) {
+ if (plane.trackingState != TrackingState.TRACKING || plane.subsumedBy != null) {
+ continue
+ }
+
+ val distance = calculateDistanceToPlane(plane.centerPose, cameraPose)
+ if (distance < 0) { // Plane is back-facing.
+ continue
+ }
+ sortedPlanes.add(SortablePlane(distance, plane))
+ }
+ Collections.sort(
+ sortedPlanes,
+ object : Comparator {
+ override fun compare(a: SortablePlane?, b: SortablePlane?): Int {
+ // if either is null:
+ if (a == null) {
+ return if (b == null) 0 else -1
+ } else if (b == null) {
+ return 1
+ }
+ return java.lang.Float.compare(b.distance, a.distance)
+ }
+ })
+
+ cameraPose.inverse().toMatrix(viewMatrix, 0)
+
+ for (sortedPlane in sortedPlanes) {
+ val plane = sortedPlane.plane
+ val planeMatrix = FloatArray(16)
+ plane.centerPose.toMatrix(planeMatrix, 0)
+
+ // Get transformed Y axis of plane's coordinate system.
+ plane.centerPose.getTransformedAxis(1, 1.0f, normalVector, 0)
+
+ updatePlaneParameters(
+ planeMatrix, plane.extentX, plane.extentZ, plane.polygon
+ )
+
+ // Get plane index. Keep a map to assign same indices to same planes.
+ var planeIndex = planeIndexMap[plane]
+ if (planeIndex == null) {
+ planeIndex = planeIndexMap.size
+ planeIndexMap[plane] = planeIndex
+ }
+
+ // Each plane will have its own angle offset from others, to make them easier to
+ // distinguish. Compute a 2x2 rotation matrix from the angle.
+
+ // this is about the texture mapping, not the orientation of the plane
+ val angleRadians = planeIndex * 0.144f
+ val uScale = DOTS_PER_METER
+ val vScale = DOTS_PER_METER * EQUILATERAL_TRIANGLE_SCALE
+ planeAngleUvMatrix[0] = (+cos(angleRadians.toDouble())).toFloat() * uScale
+ planeAngleUvMatrix[1] = (-sin(angleRadians.toDouble())).toFloat() * vScale
+ planeAngleUvMatrix[2] = (+sin(angleRadians.toDouble())).toFloat() * uScale
+ planeAngleUvMatrix[3] = (+cos(angleRadians.toDouble())).toFloat() * vScale
+
+ // Build the ModelView and ModelViewProjection matrices
+ // for calculating cube position and light.
+ Matrix.multiplyMM(modelViewMatrix, 0, viewMatrix, 0, modelMatrix, 0)
+ Matrix.multiplyMM(modelViewProjectionMatrix, 0, cameraProjection, 0, modelViewMatrix, 0)
+
+ // Populate the shader uniforms for this frame.
+ shader.setMat4("u_Model", modelMatrix)
+ shader.setMat4("u_ModelViewProjection", modelViewProjectionMatrix)
+ shader.setMat2("u_PlaneUvMatrix", planeAngleUvMatrix)
+ shader.setVec3("u_Normal", normalVector)
+
+ // Set the position of the plane
+ vertexBufferObject.set(vertexBuffer)
+ indexBufferObject.set(indexBuffer)
+
+ surfaceDrawHandler.draw(mesh, shader)
+ }
+ }
+
+ private class SortablePlane(val distance: Float, val plane: Plane)
+
+ public companion object {
+ private val TAG: String = PlaneRenderer::class.java.simpleName
+
+ // Shader names.
+ private const val VERTEX_SHADER_NAME = "shaders/plane.vert"
+ private const val FRAGMENT_SHADER_NAME = "shaders/plane.frag"
+ private const val TEXTURE_NAME = "models/trigrid.png"
+
+ private const val BYTES_PER_FLOAT = java.lang.Float.SIZE / 8
+ private const val BYTES_PER_INT = Integer.SIZE / 8
+ private const val COORDS_PER_VERTEX = 3 // x, z, alpha
+
+ private const val VERTS_PER_BOUNDARY_VERT = 2
+ private const val INDICES_PER_BOUNDARY_VERT = 3
+ private const val INITIAL_BUFFER_BOUNDARY_VERTS = 64
+
+ private const val INITIAL_VERTEX_BUFFER_SIZE_BYTES =
+ BYTES_PER_FLOAT * COORDS_PER_VERTEX * VERTS_PER_BOUNDARY_VERT * INITIAL_BUFFER_BOUNDARY_VERTS
+
+ private const val INITIAL_INDEX_BUFFER_SIZE_BYTES = (BYTES_PER_INT
+ * INDICES_PER_BOUNDARY_VERT
+ * INDICES_PER_BOUNDARY_VERT
+ * INITIAL_BUFFER_BOUNDARY_VERTS)
+
+ private const val FADE_RADIUS_M = 0.25f
+ private const val DOTS_PER_METER = 10.0f
+ private val EQUILATERAL_TRIANGLE_SCALE = (1 / sqrt(3.0)).toFloat()
+
+ // Using the "signed distance field" approach to render sharp lines and circles.
+ // {dotThreshold, lineThreshold, lineFadeSpeed, occlusionScale}
+ // dotThreshold/lineThreshold: red/green intensity above which dots/lines are present
+ // lineFadeShrink: lines will fade in between alpha = 1-(1/lineFadeShrink) and 1.0
+ // occlusionShrink: occluded planes will fade out between alpha = 0 and 1/occlusionShrink
+ private val GRID_CONTROL = floatArrayOf(0.2f, 0.4f, 2.0f, 1.5f)
+
+ // Calculate the normal distance to plane from cameraPose, the given planePose should have y axis
+ // parallel to plane's normal, for example plane's center pose or hit test pose.
+ public fun calculateDistanceToPlane(planePose: Pose, cameraPose: Pose): Float {
+ val normal = FloatArray(3)
+ val cameraX = cameraPose.tx()
+ val cameraY = cameraPose.ty()
+ val cameraZ = cameraPose.tz()
+ // Get transformed Y axis of plane's coordinate system.
+ planePose.getTransformedAxis(1, 1.0f, normal, 0)
+ // Compute dot product of plane's normal with vector from camera to plane center.
+ return (cameraX - planePose.tx()) * normal[0] + ((cameraY - planePose.ty()) * normal[1]
+ ) + ((cameraZ - planePose.tz()) * normal[2])
+ }
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Shader.java b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Shader.java
new file mode 100644
index 000000000..461631a6f
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Shader.java
@@ -0,0 +1,706 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arcgismaps.toolkit.ar.render;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.content.res.AssetManager;
+import android.opengl.GLES30;
+import android.opengl.GLException;
+import android.util.Log;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+
+/**
+ * Represents a GPU shader, the state of its associated uniforms, and some additional draw state.
+ */
+public class Shader implements Closeable {
+ private static final String TAG = Shader.class.getSimpleName();
+
+ /**
+ * A factor to be used in a blend function.
+ *
+ * @see glBlendFunc
+ */
+ public enum BlendFactor {
+ ZERO(GLES30.GL_ZERO),
+ ONE(GLES30.GL_ONE),
+ SRC_COLOR(GLES30.GL_SRC_COLOR),
+ ONE_MINUS_SRC_COLOR(GLES30.GL_ONE_MINUS_SRC_COLOR),
+ DST_COLOR(GLES30.GL_DST_COLOR),
+ ONE_MINUS_DST_COLOR(GLES30.GL_ONE_MINUS_DST_COLOR),
+ SRC_ALPHA(GLES30.GL_SRC_ALPHA),
+ ONE_MINUS_SRC_ALPHA(GLES30.GL_ONE_MINUS_SRC_ALPHA),
+ DST_ALPHA(GLES30.GL_DST_ALPHA),
+ ONE_MINUS_DST_ALPHA(GLES30.GL_ONE_MINUS_DST_ALPHA),
+ CONSTANT_COLOR(GLES30.GL_CONSTANT_COLOR),
+ ONE_MINUS_CONSTANT_COLOR(GLES30.GL_ONE_MINUS_CONSTANT_COLOR),
+ CONSTANT_ALPHA(GLES30.GL_CONSTANT_ALPHA),
+ ONE_MINUS_CONSTANT_ALPHA(GLES30.GL_ONE_MINUS_CONSTANT_ALPHA);
+
+ /* package-private */
+ final int glesEnum;
+
+ BlendFactor(int glesEnum) {
+ this.glesEnum = glesEnum;
+ }
+ }
+
+ private int programId = 0;
+ private final Map uniforms = new HashMap<>();
+ private int maxTextureUnit = 0;
+
+ private final Map uniformLocations = new HashMap<>();
+ private final Map uniformNames = new HashMap<>();
+
+ private boolean depthTest = true;
+ private boolean depthWrite = true;
+ private boolean cullFace = true;
+ private BlendFactor sourceRgbBlend = BlendFactor.ONE;
+ private BlendFactor destRgbBlend = BlendFactor.ZERO;
+ private BlendFactor sourceAlphaBlend = BlendFactor.ONE;
+ private BlendFactor destAlphaBlend = BlendFactor.ZERO;
+
+ /**
+ * Constructs a {@link Shader} given the shader code.
+ *
+ * @param defines A map of shader precompiler symbols to be defined with the given names and
+ * values
+ */
+ public Shader(
+ String vertexShaderCode,
+ String fragmentShaderCode,
+ Map defines) {
+ int vertexShaderId = 0;
+ int fragmentShaderId = 0;
+ String definesCode = createShaderDefinesCode(defines);
+ try {
+ vertexShaderId =
+ createShader(
+ GLES30.GL_VERTEX_SHADER, insertShaderDefinesCode(vertexShaderCode, definesCode));
+ fragmentShaderId =
+ createShader(
+ GLES30.GL_FRAGMENT_SHADER, insertShaderDefinesCode(fragmentShaderCode, definesCode));
+
+ programId = GLES30.glCreateProgram();
+ GLError.maybeThrowGLException("Shader program creation failed", "glCreateProgram");
+ GLES30.glAttachShader(programId, vertexShaderId);
+ GLError.maybeThrowGLException("Failed to attach vertex shader", "glAttachShader");
+ GLES30.glAttachShader(programId, fragmentShaderId);
+ GLError.maybeThrowGLException("Failed to attach fragment shader", "glAttachShader");
+ GLES30.glLinkProgram(programId);
+ GLError.maybeThrowGLException("Failed to link shader program", "glLinkProgram");
+
+ final int[] linkStatus = new int[1];
+ GLES30.glGetProgramiv(programId, GLES30.GL_LINK_STATUS, linkStatus, 0);
+ if (linkStatus[0] == GLES30.GL_FALSE) {
+ String infoLog = GLES30.glGetProgramInfoLog(programId);
+ GLError.maybeLogGLError(
+ Log.WARN, TAG, "Failed to retrieve shader program info log", "glGetProgramInfoLog");
+ throw new GLException(0, "Shader link failed: " + infoLog);
+ }
+ } catch (Throwable t) {
+ close();
+ throw t;
+ } finally {
+ // Shader objects can be flagged for deletion immediately after program creation.
+ if (vertexShaderId != 0) {
+ GLES30.glDeleteShader(vertexShaderId);
+ GLError.maybeLogGLError(Log.WARN, TAG, "Failed to free vertex shader", "glDeleteShader");
+ }
+ if (fragmentShaderId != 0) {
+ GLES30.glDeleteShader(fragmentShaderId);
+ GLError.maybeLogGLError(Log.WARN, TAG, "Failed to free fragment shader", "glDeleteShader");
+ }
+ }
+ }
+
+ /**
+ * Creates a {@link Shader} from the given asset file names.
+ *
+ * The file contents are interpreted as UTF-8 text.
+ *
+ * @param defines A map of shader precompiler symbols to be defined with the given names and
+ * values
+ */
+ public static Shader createFromAssets(
+ AssetManager assets,
+ String vertexShaderFileName,
+ String fragmentShaderFileName,
+ Map defines)
+ throws IOException {
+ return new Shader(
+ inputStreamToString(assets.open(vertexShaderFileName)),
+ inputStreamToString(assets.open(fragmentShaderFileName)),
+ defines);
+ }
+
+ @Override
+ public void close() {
+ if (programId != 0) {
+ GLES30.glDeleteProgram(programId);
+ programId = 0;
+ }
+ }
+
+ /**
+ * Sets depth test state.
+ *
+ * @see glEnable(GL_DEPTH_TEST).
+ */
+ public Shader setDepthTest(boolean depthTest) {
+ this.depthTest = depthTest;
+ return this;
+ }
+
+ /**
+ * Sets depth write state.
+ *
+ * @see glDepthMask.
+ */
+ public Shader setDepthWrite(boolean depthWrite) {
+ this.depthWrite = depthWrite;
+ return this;
+ }
+
+ /**
+ * Sets cull face state.
+ *
+ * @see glEnable(GL_CULL_FACE).
+ */
+ public Shader setCullFace(boolean cullFace) {
+ this.cullFace = cullFace;
+ return this;
+ }
+
+ /**
+ * Sets blending function.
+ *
+ * @see glBlendFunc
+ */
+ public Shader setBlend(BlendFactor sourceBlend, BlendFactor destBlend) {
+ this.sourceRgbBlend = sourceBlend;
+ this.destRgbBlend = destBlend;
+ this.sourceAlphaBlend = sourceBlend;
+ this.destAlphaBlend = destBlend;
+ return this;
+ }
+
+ /**
+ * Sets blending functions separately for RGB and alpha channels.
+ *
+ * @see glBlendFunc
+ */
+ public Shader setBlend(
+ BlendFactor sourceRgbBlend,
+ BlendFactor destRgbBlend,
+ BlendFactor sourceAlphaBlend,
+ BlendFactor destAlphaBlend) {
+ this.sourceRgbBlend = sourceRgbBlend;
+ this.destRgbBlend = destRgbBlend;
+ this.sourceAlphaBlend = sourceAlphaBlend;
+ this.destAlphaBlend = destAlphaBlend;
+ return this;
+ }
+
+ /**
+ * Sets a texture uniform.
+ */
+ public Shader setTexture(String name, Texture texture) {
+ // Special handling for Textures. If replacing an existing texture uniform, reuse the texture
+ // unit.
+ int location = getUniformLocation(name);
+ Uniform uniform = uniforms.get(location);
+ int textureUnit;
+ if (!(uniform instanceof UniformTexture)) {
+ textureUnit = maxTextureUnit++;
+ } else {
+ UniformTexture uniformTexture = (UniformTexture) uniform;
+ textureUnit = uniformTexture.getTextureUnit();
+ }
+ uniforms.put(location, new UniformTexture(textureUnit, texture));
+ return this;
+ }
+
+ /**
+ * Sets a {@code bool} uniform.
+ */
+ public Shader setBool(String name, boolean v0) {
+ int[] values = {v0 ? 1 : 0};
+ uniforms.put(getUniformLocation(name), new UniformInt(values));
+ return this;
+ }
+
+ /**
+ * Sets an {@code int} uniform.
+ */
+ public Shader setInt(String name, int v0) {
+ int[] values = {v0};
+ uniforms.put(getUniformLocation(name), new UniformInt(values));
+ return this;
+ }
+
+ /**
+ * Sets a {@code float} uniform.
+ */
+ public Shader setFloat(String name, float v0) {
+ float[] values = {v0};
+ uniforms.put(getUniformLocation(name), new Uniform1f(values));
+ return this;
+ }
+
+ /**
+ * Sets a {@code vec2} uniform.
+ */
+ public Shader setVec2(String name, float[] values) {
+ if (values.length != 2) {
+ throw new IllegalArgumentException("Value array length must be 2");
+ }
+ uniforms.put(getUniformLocation(name), new Uniform2f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code vec3} uniform.
+ */
+ public Shader setVec3(String name, float[] values) {
+ if (values.length != 3) {
+ throw new IllegalArgumentException("Value array length must be 3");
+ }
+ uniforms.put(getUniformLocation(name), new Uniform3f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code vec4} uniform.
+ */
+ public Shader setVec4(String name, float[] values) {
+ if (values.length != 4) {
+ throw new IllegalArgumentException("Value array length must be 4");
+ }
+ uniforms.put(getUniformLocation(name), new Uniform4f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code mat2} uniform.
+ */
+ public Shader setMat2(String name, float[] values) {
+ if (values.length != 4) {
+ throw new IllegalArgumentException("Value array length must be 4 (2x2)");
+ }
+ uniforms.put(getUniformLocation(name), new UniformMatrix2f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code mat3} uniform.
+ */
+ public Shader setMat3(String name, float[] values) {
+ if (values.length != 9) {
+ throw new IllegalArgumentException("Value array length must be 9 (3x3)");
+ }
+ uniforms.put(getUniformLocation(name), new UniformMatrix3f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code mat4} uniform.
+ */
+ public Shader setMat4(String name, float[] values) {
+ if (values.length != 16) {
+ throw new IllegalArgumentException("Value array length must be 16 (4x4)");
+ }
+ uniforms.put(getUniformLocation(name), new UniformMatrix4f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code bool} array uniform.
+ */
+ public Shader setBoolArray(String name, boolean[] values) {
+ int[] intValues = new int[values.length];
+ for (int i = 0; i < values.length; ++i) {
+ intValues[i] = values[i] ? 1 : 0;
+ }
+ uniforms.put(getUniformLocation(name), new UniformInt(intValues));
+ return this;
+ }
+
+ /**
+ * Sets an {@code int} array uniform.
+ */
+ public Shader setIntArray(String name, int[] values) {
+ uniforms.put(getUniformLocation(name), new UniformInt(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code float} array uniform.
+ */
+ public Shader setFloatArray(String name, float[] values) {
+ uniforms.put(getUniformLocation(name), new Uniform1f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code vec2} array uniform.
+ */
+ public Shader setVec2Array(String name, float[] values) {
+ if (values.length % 2 != 0) {
+ throw new IllegalArgumentException("Value array length must be divisible by 2");
+ }
+ uniforms.put(getUniformLocation(name), new Uniform2f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code vec3} array uniform.
+ */
+ public Shader setVec3Array(String name, float[] values) {
+ if (values.length % 3 != 0) {
+ throw new IllegalArgumentException("Value array length must be divisible by 3");
+ }
+ uniforms.put(getUniformLocation(name), new Uniform3f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code vec4} array uniform.
+ */
+ public Shader setVec4Array(String name, float[] values) {
+ if (values.length % 4 != 0) {
+ throw new IllegalArgumentException("Value array length must be divisible by 4");
+ }
+ uniforms.put(getUniformLocation(name), new Uniform4f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code mat2} array uniform.
+ */
+ public Shader setMat2Array(String name, float[] values) {
+ if (values.length % 4 != 0) {
+ throw new IllegalArgumentException("Value array length must be divisible by 4 (2x2)");
+ }
+ uniforms.put(getUniformLocation(name), new UniformMatrix2f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code mat3} array uniform.
+ */
+ public Shader setMat3Array(String name, float[] values) {
+ if (values.length % 9 != 0) {
+ throw new IllegalArgumentException("Values array length must be divisible by 9 (3x3)");
+ }
+ uniforms.put(getUniformLocation(name), new UniformMatrix3f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Sets a {@code mat4} uniform.
+ */
+ public Shader setMat4Array(String name, float[] values) {
+ if (values.length % 16 != 0) {
+ throw new IllegalArgumentException("Value array length must be divisible by 16 (4x4)");
+ }
+ uniforms.put(getUniformLocation(name), new UniformMatrix4f(values.clone()));
+ return this;
+ }
+
+ /**
+ * Activates the shader. Don't call this directly unless you are doing low level OpenGL code;
+ * instead, prefer {@link SurfaceDrawHandler#draw}.
+ */
+ public void lowLevelUse() {
+ // Make active shader/set uniforms
+ if (programId == 0) {
+ throw new IllegalStateException("Attempted to use freed shader");
+ }
+ GLES30.glUseProgram(programId);
+ GLError.maybeThrowGLException("Failed to use shader program", "glUseProgram");
+ GLES30.glBlendFuncSeparate(
+ sourceRgbBlend.glesEnum,
+ destRgbBlend.glesEnum,
+ sourceAlphaBlend.glesEnum,
+ destAlphaBlend.glesEnum);
+ GLError.maybeThrowGLException("Failed to set blend mode", "glBlendFuncSeparate");
+ GLES30.glDepthMask(depthWrite);
+ GLError.maybeThrowGLException("Failed to set depth write mask", "glDepthMask");
+ if (depthTest) {
+ GLES30.glEnable(GLES30.GL_DEPTH_TEST);
+ GLError.maybeThrowGLException("Failed to enable depth test", "glEnable");
+ } else {
+ GLES30.glDisable(GLES30.GL_DEPTH_TEST);
+ GLError.maybeThrowGLException("Failed to disable depth test", "glDisable");
+ }
+ if (cullFace) {
+ GLES30.glEnable(GLES30.GL_CULL_FACE);
+ GLError.maybeThrowGLException("Failed to enable backface culling", "glEnable");
+ } else {
+ GLES30.glDisable(GLES30.GL_CULL_FACE);
+ GLError.maybeThrowGLException("Failed to disable backface culling", "glDisable");
+ }
+ try {
+ // Remove all non-texture uniforms from the map after setting them, since they're stored as
+ // part of the program.
+ ArrayList obsoleteEntries = new ArrayList<>(uniforms.size());
+ for (Map.Entry entry : uniforms.entrySet()) {
+ try {
+ entry.getValue().use(entry.getKey());
+ if (!(entry.getValue() instanceof UniformTexture)) {
+ obsoleteEntries.add(entry.getKey());
+ }
+ } catch (GLException e) {
+ String name = uniformNames.get(entry.getKey());
+ throw new IllegalArgumentException("Error setting uniform `" + name + "'", e);
+ }
+ }
+ uniforms.keySet().removeAll(obsoleteEntries);
+ } finally {
+ GLES30.glActiveTexture(GLES30.GL_TEXTURE0);
+ GLError.maybeLogGLError(Log.WARN, TAG, "Failed to set active texture", "glActiveTexture");
+ }
+ }
+
+ private interface Uniform {
+ void use(int location);
+ }
+
+ private static class UniformTexture implements Uniform {
+ private final int textureUnit;
+ private final Texture texture;
+
+ public UniformTexture(int textureUnit, Texture texture) {
+ this.textureUnit = textureUnit;
+ this.texture = texture;
+ }
+
+ public int getTextureUnit() {
+ return textureUnit;
+ }
+
+ @Override
+ public void use(int location) {
+ if (texture.getTextureId() == 0) {
+ throw new IllegalStateException("Tried to draw with freed texture");
+ }
+ GLES30.glActiveTexture(GLES30.GL_TEXTURE0 + textureUnit);
+ GLError.maybeThrowGLException("Failed to set active texture", "glActiveTexture");
+ GLES30.glBindTexture(texture.getTarget().glesEnum, texture.getTextureId());
+ GLError.maybeThrowGLException("Failed to bind texture", "glBindTexture");
+ GLES30.glUniform1i(location, textureUnit);
+ GLError.maybeThrowGLException("Failed to set shader texture uniform", "glUniform1i");
+ }
+ }
+
+ private static class UniformInt implements Uniform {
+ private final int[] values;
+
+ public UniformInt(int[] values) {
+ this.values = values;
+ }
+
+ @Override
+ public void use(int location) {
+ GLES30.glUniform1iv(location, values.length, values, 0);
+ GLError.maybeThrowGLException("Failed to set shader uniform 1i", "glUniform1iv");
+ }
+ }
+
+ private static class Uniform1f implements Uniform {
+ private final float[] values;
+
+ public Uniform1f(float[] values) {
+ this.values = values;
+ }
+
+ @Override
+ public void use(int location) {
+ GLES30.glUniform1fv(location, values.length, values, 0);
+ GLError.maybeThrowGLException("Failed to set shader uniform 1f", "glUniform1fv");
+ }
+ }
+
+ private static class Uniform2f implements Uniform {
+ private final float[] values;
+
+ public Uniform2f(float[] values) {
+ this.values = values;
+ }
+
+ @Override
+ public void use(int location) {
+ GLES30.glUniform2fv(location, values.length / 2, values, 0);
+ GLError.maybeThrowGLException("Failed to set shader uniform 2f", "glUniform2fv");
+ }
+ }
+
+ private static class Uniform3f implements Uniform {
+ private final float[] values;
+
+ public Uniform3f(float[] values) {
+ this.values = values;
+ }
+
+ @Override
+ public void use(int location) {
+ GLES30.glUniform3fv(location, values.length / 3, values, 0);
+ GLError.maybeThrowGLException("Failed to set shader uniform 3f", "glUniform3fv");
+ }
+ }
+
+ private static class Uniform4f implements Uniform {
+ private final float[] values;
+
+ public Uniform4f(float[] values) {
+ this.values = values;
+ }
+
+ @Override
+ public void use(int location) {
+ GLES30.glUniform4fv(location, values.length / 4, values, 0);
+ GLError.maybeThrowGLException("Failed to set shader uniform 4f", "glUniform4fv");
+ }
+ }
+
+ private static class UniformMatrix2f implements Uniform {
+ private final float[] values;
+
+ public UniformMatrix2f(float[] values) {
+ this.values = values;
+ }
+
+ @Override
+ public void use(int location) {
+ GLES30.glUniformMatrix2fv(location, values.length / 4, /*transpose=*/ false, values, 0);
+ GLError.maybeThrowGLException("Failed to set shader uniform matrix 2f", "glUniformMatrix2fv");
+ }
+ }
+
+ private static class UniformMatrix3f implements Uniform {
+ private final float[] values;
+
+ public UniformMatrix3f(float[] values) {
+ this.values = values;
+ }
+
+ @Override
+ public void use(int location) {
+ GLES30.glUniformMatrix3fv(location, values.length / 9, /*transpose=*/ false, values, 0);
+ GLError.maybeThrowGLException("Failed to set shader uniform matrix 3f", "glUniformMatrix3fv");
+ }
+ }
+
+ private static class UniformMatrix4f implements Uniform {
+ private final float[] values;
+
+ public UniformMatrix4f(float[] values) {
+ this.values = values;
+ }
+
+ @Override
+ public void use(int location) {
+ GLES30.glUniformMatrix4fv(location, values.length / 16, /*transpose=*/ false, values, 0);
+ GLError.maybeThrowGLException("Failed to set shader uniform matrix 4f", "glUniformMatrix4fv");
+ }
+ }
+
+ private int getUniformLocation(String name) {
+ Integer locationObject = uniformLocations.get(name);
+ if (locationObject != null) {
+ return locationObject;
+ }
+ int location = GLES30.glGetUniformLocation(programId, name);
+ GLError.maybeThrowGLException("Failed to find uniform", "glGetUniformLocation");
+ if (location == -1) {
+ throw new IllegalArgumentException("Shader uniform does not exist: " + name);
+ }
+ uniformLocations.put(name, Integer.valueOf(location));
+ uniformNames.put(Integer.valueOf(location), name);
+ return location;
+ }
+
+ private static int createShader(int type, String code) {
+ int shaderId = GLES30.glCreateShader(type);
+ GLError.maybeThrowGLException("Shader creation failed", "glCreateShader");
+ GLES30.glShaderSource(shaderId, code);
+ GLError.maybeThrowGLException("Shader source failed", "glShaderSource");
+ GLES30.glCompileShader(shaderId);
+ GLError.maybeThrowGLException("Shader compilation failed", "glCompileShader");
+
+ final int[] compileStatus = new int[1];
+ GLES30.glGetShaderiv(shaderId, GLES30.GL_COMPILE_STATUS, compileStatus, 0);
+ if (compileStatus[0] == GLES30.GL_FALSE) {
+ String infoLog = GLES30.glGetShaderInfoLog(shaderId);
+ GLError.maybeLogGLError(
+ Log.WARN, TAG, "Failed to retrieve shader info log", "glGetShaderInfoLog");
+ GLES30.glDeleteShader(shaderId);
+ GLError.maybeLogGLError(Log.WARN, TAG, "Failed to free shader", "glDeleteShader");
+ throw new GLException(0, "Shader compilation failed: " + infoLog);
+ }
+
+ return shaderId;
+ }
+
+ private static String createShaderDefinesCode(Map defines) {
+ if (defines == null) {
+ return "";
+ }
+ StringBuilder builder = new StringBuilder();
+ for (Map.Entry entry : defines.entrySet()) {
+ builder.append("#define " + entry.getKey() + " " + entry.getValue() + "\n");
+ }
+ return builder.toString();
+ }
+
+ private static String insertShaderDefinesCode(String sourceCode, String definesCode) {
+ String result =
+ sourceCode.replaceAll(
+ "(?m)^(\\s*#\\s*version\\s+.*)$", "$1\n" + Matcher.quoteReplacement(definesCode));
+ if (result.equals(sourceCode)) {
+ // No #version specified, so just prepend source
+ return definesCode + sourceCode;
+ }
+ return result;
+ }
+
+ private static String inputStreamToString(InputStream stream) throws IOException {
+ InputStreamReader reader = new InputStreamReader(stream, UTF_8);
+ char[] buffer = new char[1024 * 4];
+ StringBuilder builder = new StringBuilder();
+ int amount = 0;
+ while ((amount = reader.read(buffer)) != -1) {
+ builder.append(buffer, 0, amount);
+ }
+ reader.close();
+ return builder.toString();
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/SurfaceDrawHandler.kt b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/SurfaceDrawHandler.kt
new file mode 100644
index 000000000..c1cccc7f9
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/SurfaceDrawHandler.kt
@@ -0,0 +1,130 @@
+/*
+ *
+ * Copyright 2024 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.ar.render
+
+import android.opengl.GLES30
+import android.opengl.GLSurfaceView
+import android.util.Log
+import com.arcgismaps.toolkit.ar.render.SurfaceDrawHandler.Renderer
+
+/**
+ * Handles the rendering of a [Mesh] with a [Shader] to a [Framebuffer] or the default framebuffer.
+ * This class is intended to be used with a [GLSurfaceView].
+ * The [Renderer] interface must be implemented to provide the rendering logic.
+ */
+public class SurfaceDrawHandler(glSurfaceView: GLSurfaceView, renderer: Renderer) {
+
+ private var viewportWidth = 1
+ private var viewportHeight = 1
+
+ init {
+ glSurfaceView.preserveEGLContextOnPause = true
+ glSurfaceView.setEGLContextClientVersion(3)
+ glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 24, 8)
+ glSurfaceView.setRenderer(object : GLSurfaceView.Renderer {
+ override fun onSurfaceCreated(
+ gl: javax.microedition.khronos.opengles.GL10?,
+ config: javax.microedition.khronos.egl.EGLConfig?
+ ) {
+ GLES30.glEnable(GLES30.GL_BLEND)
+ GLError.maybeThrowGLException("Failed to enable blending", "glEnable")
+ renderer.onSurfaceCreated(this@SurfaceDrawHandler)
+ }
+
+ override fun onSurfaceChanged(
+ gl: javax.microedition.khronos.opengles.GL10?,
+ width: Int,
+ height: Int
+ ) {
+ viewportWidth = width
+ viewportHeight = height
+ renderer.onSurfaceChanged(this@SurfaceDrawHandler, width, height)
+ }
+
+ override fun onDrawFrame(gl: javax.microedition.khronos.opengles.GL10?) {
+ clear(null, 0f, 0f, 0f, 1f)
+ renderer.onDrawFrame(this@SurfaceDrawHandler)
+ }
+ })
+ glSurfaceView.renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
+ glSurfaceView.setWillNotDraw(false)
+ }
+
+ public interface Renderer {
+ public fun onSurfaceCreated(surfaceDrawHandler: SurfaceDrawHandler)
+ public fun onSurfaceChanged(surfaceDrawHandler: SurfaceDrawHandler, width: Int, height: Int)
+ public fun onDrawFrame(surfaceDrawHandler: SurfaceDrawHandler)
+ }
+
+ /** Draw a [Mesh] with the specified [Shader]. */
+ public fun draw(mesh: Mesh, shader: Shader) {
+ draw(mesh, shader, null)
+ }
+
+ /**
+ * Draw a [Mesh] with the specified [Shader] to the given [Framebuffer].
+ *
+ *
+ * The `framebuffer` argument may be null, in which case the default framebuffer is used.
+ */
+ public fun draw(mesh: Mesh, shader: Shader, framebuffer: Framebuffer?) {
+ // tell opengl to use the framebuffer
+ useFramebuffer(framebuffer)
+ // this will be the background shader
+ shader.lowLevelUse()
+ mesh.lowLevelDraw()
+ GLError.maybeLogGLError(Log.WARN, "RenderDebug", "Failed to draw mesh", "glDrawElements")
+ }
+
+ /**
+ * Clear the given framebuffer.
+ *
+ *
+ * The `framebuffer` argument may be null, in which case the default framebuffer is
+ * cleared.
+ */
+ public fun clear(framebuffer: Framebuffer?, r: Float, g: Float, b: Float, a: Float) {
+ useFramebuffer(framebuffer)
+ GLES30.glClearColor(r, g, b, a)
+ GLError.maybeThrowGLException("Failed to set clear color", "glClearColor")
+ GLES30.glDepthMask(true)
+ GLError.maybeThrowGLException("Failed to set depth write mask", "glDepthMask")
+ GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT or GLES30.GL_DEPTH_BUFFER_BIT)
+ GLError.maybeThrowGLException("Failed to clear framebuffer", "glClear")
+ }
+
+ private fun useFramebuffer(framebuffer: Framebuffer?) {
+ val framebufferId: Int
+ val viewportWidth: Int
+ val viewportHeight: Int
+ if (framebuffer == null) {
+ framebufferId = 0
+ viewportWidth = this.viewportWidth
+ viewportHeight = this.viewportHeight
+ } else {
+ framebufferId = framebuffer.framebufferId
+ viewportWidth = framebuffer.width
+ viewportHeight = framebuffer.height
+ }
+ GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, framebufferId)
+ GLError.maybeThrowGLException("Failed to bind framebuffer", "glBindFramebuffer")
+ GLES30.glViewport(0, 0, viewportWidth, viewportHeight)
+ GLError.maybeThrowGLException("Failed to set viewport dimensions", "glViewport")
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Texture.java b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Texture.java
new file mode 100644
index 000000000..53ad4d15e
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/Texture.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arcgismaps.toolkit.ar.render;
+
+import android.content.res.AssetManager;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.opengl.GLES11Ext;
+import android.opengl.GLES30;
+import android.util.Log;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+/**
+ * A GPU-side texture.
+ */
+public class Texture implements Closeable {
+ private static final String TAG = Texture.class.getSimpleName();
+
+ private final int[] textureId = {0};
+ private final Target target;
+
+ /**
+ * Describes the way the texture's edges are rendered.
+ *
+ * @see GL_TEXTURE_WRAP_S.
+ */
+ public enum WrapMode {
+ CLAMP_TO_EDGE(GLES30.GL_CLAMP_TO_EDGE),
+ MIRRORED_REPEAT(GLES30.GL_MIRRORED_REPEAT),
+ REPEAT(GLES30.GL_REPEAT);
+
+ /* package-private */
+ final int glesEnum;
+
+ WrapMode(int glesEnum) {
+ this.glesEnum = glesEnum;
+ }
+ }
+
+ /**
+ * Describes the target this texture is bound to.
+ *
+ * @see glBindTexture.
+ */
+ public enum Target {
+ TEXTURE_2D(GLES30.GL_TEXTURE_2D),
+ TEXTURE_EXTERNAL_OES(GLES11Ext.GL_TEXTURE_EXTERNAL_OES),
+ TEXTURE_CUBE_MAP(GLES30.GL_TEXTURE_CUBE_MAP);
+
+ final int glesEnum;
+
+ Target(int glesEnum) {
+ this.glesEnum = glesEnum;
+ }
+ }
+
+ /**
+ * Describes the color format of the texture.
+ *
+ * @see glTexImage2d.
+ */
+ public enum ColorFormat {
+ LINEAR(GLES30.GL_RGBA8),
+ SRGB(GLES30.GL_SRGB8_ALPHA8);
+
+ final int glesEnum;
+
+ ColorFormat(int glesEnum) {
+ this.glesEnum = glesEnum;
+ }
+ }
+
+
+ public Texture(Target target, WrapMode wrapMode) {
+ this(target, wrapMode, /*useMipmaps=*/ true);
+ }
+
+ public Texture(Target target, WrapMode wrapMode, boolean useMipmaps) {
+ this.target = target;
+
+ GLES30.glGenTextures(1, textureId, 0);
+ GLError.maybeThrowGLException("Texture creation failed", "glGenTextures");
+
+ int minFilter = useMipmaps ? GLES30.GL_LINEAR_MIPMAP_LINEAR : GLES30.GL_LINEAR;
+
+ try {
+ GLES30.glBindTexture(target.glesEnum, textureId[0]);
+ GLError.maybeThrowGLException("Failed to bind texture", "glBindTexture");
+ GLES30.glTexParameteri(target.glesEnum, GLES30.GL_TEXTURE_MIN_FILTER, minFilter);
+ GLError.maybeThrowGLException("Failed to set texture parameter", "glTexParameteri");
+ GLES30.glTexParameteri(target.glesEnum, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_LINEAR);
+ GLError.maybeThrowGLException("Failed to set texture parameter", "glTexParameteri");
+
+ GLES30.glTexParameteri(target.glesEnum, GLES30.GL_TEXTURE_WRAP_S, wrapMode.glesEnum);
+ GLError.maybeThrowGLException("Failed to set texture parameter", "glTexParameteri");
+ GLES30.glTexParameteri(target.glesEnum, GLES30.GL_TEXTURE_WRAP_T, wrapMode.glesEnum);
+ GLError.maybeThrowGLException("Failed to set texture parameter", "glTexParameteri");
+ } catch (Throwable t) {
+ close();
+ throw t;
+ }
+ }
+
+ /**
+ * Create a texture from the given asset file name.
+ */
+ public static Texture createFromAsset(
+ AssetManager assets, String assetFileName, WrapMode wrapMode, ColorFormat colorFormat)
+ throws IOException {
+ Texture texture = new Texture(Target.TEXTURE_2D, wrapMode);
+ Bitmap bitmap = null;
+ try {
+ // The following lines up to glTexImage2D could technically be replaced with
+ // GLUtils.texImage2d, but this method does not allow for loading sRGB images.
+
+ // Load and convert the bitmap and copy its contents to a direct ByteBuffer. Despite its name,
+ // the ARGB_8888 config is actually stored in RGBA order.
+ bitmap =
+ convertBitmapToConfig(
+ BitmapFactory.decodeStream(assets.open(assetFileName)),
+ Bitmap.Config.ARGB_8888);
+ ByteBuffer buffer = ByteBuffer.allocateDirect(bitmap.getByteCount());
+ bitmap.copyPixelsToBuffer(buffer);
+ buffer.rewind();
+
+ GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texture.getTextureId());
+ GLError.maybeThrowGLException("Failed to bind texture", "glBindTexture");
+ GLES30.glTexImage2D(
+ GLES30.GL_TEXTURE_2D,
+ /*level=*/ 0,
+ colorFormat.glesEnum,
+ bitmap.getWidth(),
+ bitmap.getHeight(),
+ /*border=*/ 0,
+ GLES30.GL_RGBA,
+ GLES30.GL_UNSIGNED_BYTE,
+ buffer);
+ GLError.maybeThrowGLException("Failed to populate texture data", "glTexImage2D");
+ GLES30.glGenerateMipmap(GLES30.GL_TEXTURE_2D);
+ GLError.maybeThrowGLException("Failed to generate mipmaps", "glGenerateMipmap");
+ } catch (Throwable t) {
+ texture.close();
+ throw t;
+ } finally {
+ if (bitmap != null) {
+ bitmap.recycle();
+ }
+ }
+ return texture;
+ }
+
+
+ @Override
+ public void close() {
+ if (textureId[0] != 0) {
+ GLES30.glDeleteTextures(1, textureId, 0);
+ GLError.maybeLogGLError(Log.WARN, TAG, "Failed to free texture", "glDeleteTextures");
+ textureId[0] = 0;
+ }
+ }
+
+ /**
+ * Retrieve the native texture ID.
+ */
+ public int getTextureId() {
+ return textureId[0];
+ }
+
+ /* package-private */
+ Target getTarget() {
+ return target;
+ }
+
+ private static Bitmap convertBitmapToConfig(Bitmap bitmap, Bitmap.Config config) {
+ // We use this method instead of BitmapFactory.Options.outConfig to support a minimum of Android
+ // API level 24.
+ if (bitmap.getConfig() == config) {
+ return bitmap;
+ }
+ Bitmap result = bitmap.copy(config, /*isMutable=*/ false);
+ bitmap.recycle();
+ return result;
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/VertexBuffer.java b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/VertexBuffer.java
new file mode 100644
index 000000000..158447f83
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/VertexBuffer.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arcgismaps.toolkit.ar.render;
+
+import android.opengl.GLES30;
+
+import java.io.Closeable;
+import java.nio.FloatBuffer;
+
+/**
+ * A list of vertex attribute data stored GPU-side.
+ *
+ * One or more {@link VertexBuffer}s are used when constructing a {@link Mesh} to describe vertex
+ * attribute data; for example, local coordinates, texture coordinates, vertex normals, etc.
+ *
+ * @see glVertexAttribPointer
+ */
+public class VertexBuffer implements Closeable {
+ private final GpuBuffer buffer;
+ private final int numberOfEntriesPerVertex;
+
+ /**
+ * Construct a {@link VertexBuffer} populated with initial data.
+ *
+ *
The GPU buffer will be filled with the data in the direct buffer {@code entries},
+ * starting from the beginning of the buffer (not the current cursor position). The cursor will be
+ * left in an undefined position after this function returns.
+ *
+ *
The number of vertices in the buffer can be expressed as {@code entries.limit() /
+ * numberOfEntriesPerVertex}. Thus, The size of the buffer must be divisible by {@code
+ * numberOfEntriesPerVertex}.
+ *
+ *
The {@code entries} buffer may be null, in which case an empty buffer is constructed
+ * instead.
+ */
+ public VertexBuffer(int numberOfEntriesPerVertex, FloatBuffer entries) {
+ if (entries != null && entries.limit() % numberOfEntriesPerVertex != 0) {
+ throw new IllegalArgumentException(
+ "If non-null, vertex buffer data must be divisible by the number of data points per"
+ + " vertex");
+ }
+
+ this.numberOfEntriesPerVertex = numberOfEntriesPerVertex;
+ buffer = new GpuBuffer(GLES30.GL_ARRAY_BUFFER, GpuBuffer.FLOAT_SIZE, entries);
+ }
+
+ /**
+ * Populate with new data.
+ *
+ *
The entire buffer is replaced by the contents of the direct buffer {@code entries}
+ * starting from the beginning of the buffer, not the current cursor position. The cursor will be
+ * left in an undefined position after this function returns.
+ *
+ *
The GPU buffer is reallocated automatically if necessary.
+ *
+ *
The {@code entries} buffer may be null, in which case the buffer will become empty.
+ * Otherwise, the size of {@code entries} must be divisible by the number of entries per vertex
+ * specified during construction.
+ */
+ public void set(FloatBuffer entries) {
+ if (entries != null && entries.limit() % numberOfEntriesPerVertex != 0) {
+ throw new IllegalArgumentException(
+ "If non-null, vertex buffer data must be divisible by the number of data points per"
+ + " vertex");
+ }
+ buffer.set(entries);
+ }
+
+ @Override
+ public void close() {
+ buffer.free();
+ }
+
+ /* package-private */
+ int getBufferId() {
+ return buffer.getBufferId();
+ }
+
+ /* package-private */
+ int getNumberOfEntriesPerVertex() {
+ return numberOfEntriesPerVertex;
+ }
+
+ /* package-private */
+ int getNumberOfVertices() {
+ return buffer.getSize() / numberOfEntriesPerVertex;
+ }
+}
diff --git a/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/VirtualSceneRenderer.kt b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/VirtualSceneRenderer.kt
new file mode 100644
index 000000000..4c5d2c4e3
--- /dev/null
+++ b/toolkit/ar/src/main/java/com/arcgismaps/toolkit/ar/render/VirtualSceneRenderer.kt
@@ -0,0 +1,140 @@
+/*
+ *
+ * Copyright 2024 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.ar.render
+
+import android.content.res.AssetManager
+import android.opengl.Matrix
+import android.view.MotionEvent
+import com.google.ar.core.Anchor
+import com.google.ar.core.Camera
+import com.google.ar.core.Frame
+import com.google.ar.core.Plane
+import com.google.ar.core.Point
+import com.google.ar.core.Trackable
+import com.google.ar.core.TrackingState
+
+public class VirtualSceneRenderer(private val zNear: Float, private val zFar: Float) {
+
+
+ private val wrappedAnchors: MutableList = mutableListOf()
+
+
+ private lateinit var virtualObjectShader: Shader
+ private lateinit var virtualObjectMesh: Mesh
+ private lateinit var virtualObjectAlbedoTexture: Texture
+ private lateinit var virtualSceneFramebuffer: Framebuffer
+
+ // Temporary matrix allocated here to reduce number of allocations for each frame.
+ private val modelMatrix = FloatArray(16)
+ private val viewMatrix = FloatArray(16)
+ private val projectionMatrix = FloatArray(16)
+ private val modelViewMatrix = FloatArray(16) // view x model
+ private val modelViewProjectionMatrix = FloatArray(16) // projection x view x model
+
+ private var lastTap: MotionEvent? = null
+
+ public fun onSurfaceCreated(assets: AssetManager) {
+ virtualSceneFramebuffer = Framebuffer(1, 1)
+ // Virtual object to render (ARCore pawn)
+ virtualObjectAlbedoTexture =
+ Texture.createFromAsset(
+ assets,
+ "models/pawn_albedo.png",
+ Texture.WrapMode.CLAMP_TO_EDGE,
+ Texture.ColorFormat.SRGB
+ )
+
+ virtualObjectMesh = Mesh.createFromAsset(assets, "models/pawn.obj")
+ virtualObjectShader =
+ Shader.createFromAssets(
+ assets,
+ "shaders/unlit.vert",
+ "shaders/unlit.frag",
+ mapOf()
+ )
+ .setTexture("u_AlbedoTexture", virtualObjectAlbedoTexture)
+ }
+
+ public fun onSurfaceChanged(surfaceDrawHandler: SurfaceDrawHandler, width: Int, height: Int) {
+ virtualSceneFramebuffer.resize(width, height)
+ }
+
+ public fun onDrawFrame(surfaceDrawHandler: SurfaceDrawHandler, mesh: Mesh, camera: Camera) {
+ // Visualize anchors created by touch.
+ surfaceDrawHandler.clear(virtualSceneFramebuffer, 0f, 0f, 0f, 0f)
+
+ // Get projection matrix.
+ camera.getProjectionMatrix(projectionMatrix, 0, zNear, zFar)
+
+ // Get camera matrix and draw.
+ camera.getViewMatrix(viewMatrix, 0)
+
+ for (wrappedAnchor in wrappedAnchors.filter { it.trackable.trackingState == TrackingState.TRACKING }) {
+ val anchor = wrappedAnchor.anchor
+ // Get the current pose of an Anchor in world space. The Anchor pose is updated
+ // during calls to session.update() as ARCore refines its estimate of the world.
+ val pose = anchor.pose
+ pose.toMatrix(modelMatrix, 0)
+
+ // Calculate model/view/projection matrices
+ Matrix.multiplyMM(modelViewMatrix, 0, viewMatrix, 0, modelMatrix, 0)
+ Matrix.multiplyMM(modelViewProjectionMatrix, 0, projectionMatrix, 0, modelViewMatrix, 0)
+
+ // Update shader properties and draw
+ virtualObjectShader.setMat4("u_ModelViewProjection", modelViewProjectionMatrix)
+ val texture = virtualObjectAlbedoTexture
+ virtualObjectShader.setTexture("u_AlbedoTexture", texture)
+
+ // uses the default framebuffer, so it draws over the camera feed
+ surfaceDrawHandler.draw(virtualObjectMesh, virtualObjectShader)
+ }
+ }
+
+ public fun handleTap(frame: Frame) {
+ val tap = lastTap ?: return
+ val hit = frame.hitTest(tap).firstOrNull { it.trackable is Plane || it.trackable is Point }
+ if (hit != null) {
+ // Cap the number of objects created. This avoids overloading both the
+ // rendering system and ARCore.
+ if (wrappedAnchors.size >= 20) {
+ wrappedAnchors[0].anchor.detach()
+ wrappedAnchors.removeAt(0)
+ }
+
+ // Adding an Anchor tells ARCore that it should track this position in
+ // space. This anchor is created on the Plane to place the 3D model
+ // in the correct position relative both to the world and to the plane.
+ wrappedAnchors.add(WrappedAnchor(hit.createAnchor(), hit.trackable))
+ }
+ lastTap = null
+ }
+
+ public fun onClick(it: MotionEvent) {
+ lastTap = it
+ }
+}
+
+/**
+ * Associates an Anchor with the trackable it was attached to. This is used to be able to check
+ * whether or not an Anchor originally was attached to an {@link InstantPlacementPoint}.
+ */
+private data class WrappedAnchor(
+ val anchor: Anchor,
+ val trackable: Trackable,
+)