From 6e0e2d0ceeb9ee88434b79b6d2c4dfe74e93ec8f Mon Sep 17 00:00:00 2001 From: dancho Date: Thu, 22 Aug 2024 06:35:25 -0700 Subject: [PATCH] Add QueuingGlShaderProgram for effects that run outside GL context Implement a QueuingGlShaderProgram which queues up OpenGL frames and allows asynchronous execution of effects that operate on video frames without a performance penalty. PiperOrigin-RevId: 666326611 --- .../androidx/media3/common/util/GlUtil.java | 38 +++ .../effect/QueuingGlShaderProgramTest.java | 179 +++++++++++ .../media3/effect/QueuingGlShaderProgram.java | 302 ++++++++++++++++++ .../QueuingGlShaderProgramTest/pts_0.png | Bin 0 -> 591 bytes .../QueuingGlShaderProgramTest/pts_333333.png | Bin 0 -> 1319 bytes .../QueuingGlShaderProgramTest/pts_666667.png | Bin 0 -> 1434 bytes 6 files changed, 519 insertions(+) create mode 100644 libraries/effect/src/androidTest/java/androidx/media3/effect/QueuingGlShaderProgramTest.java create mode 100644 libraries/effect/src/main/java/androidx/media3/effect/QueuingGlShaderProgram.java create mode 100644 libraries/test_data/src/test/assets/test-generated-goldens/QueuingGlShaderProgramTest/pts_0.png create mode 100644 libraries/test_data/src/test/assets/test-generated-goldens/QueuingGlShaderProgramTest/pts_333333.png create mode 100644 libraries/test_data/src/test/assets/test-generated-goldens/QueuingGlShaderProgramTest/pts_666667.png diff --git a/libraries/common/src/main/java/androidx/media3/common/util/GlUtil.java b/libraries/common/src/main/java/androidx/media3/common/util/GlUtil.java index dcf231e6f9..66da72cc58 100644 --- a/libraries/common/src/main/java/androidx/media3/common/util/GlUtil.java +++ b/libraries/common/src/main/java/androidx/media3/common/util/GlUtil.java @@ -23,6 +23,7 @@ import static androidx.media3.common.util.Assertions.checkState; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.Bitmap; +import android.graphics.Rect; import android.opengl.EGL14; import android.opengl.EGLConfig; import android.opengl.EGLContext; @@ -824,6 +825,43 @@ public final class GlUtil { checkGlError(); } + /** + * Copies the pixels from {@code readFboId} into {@code drawFboId}. Requires OpenGL ES 3.0. + * + *

When the input pixel region (given by {@code readRect}) doesn't have the same size as the + * output region (given by {@code drawRect}), this method uses {@link GLES20#GL_LINEAR} filtering + * to scale the image contents. + * + * @param readFboId The framebuffer object to read from. + * @param readRect The rectangular region of {@code readFboId} to read from. + * @param drawFboId The framebuffer object to draw into. + * @param drawRect The rectangular region of {@code drawFboId} to draw into. + */ + public static void blitFrameBuffer(int readFboId, Rect readRect, int drawFboId, Rect drawRect) + throws GlException { + int[] boundFramebuffer = new int[1]; + GLES20.glGetIntegerv(GLES20.GL_FRAMEBUFFER_BINDING, boundFramebuffer, /* offset= */ 0); + checkGlError(); + GLES30.glBindFramebuffer(GLES30.GL_READ_FRAMEBUFFER, readFboId); + checkGlError(); + GLES30.glBindFramebuffer(GLES30.GL_DRAW_FRAMEBUFFER, drawFboId); + checkGlError(); + GLES30.glBlitFramebuffer( + readRect.left, + readRect.top, + readRect.right, + readRect.bottom, + drawRect.left, + drawRect.top, + drawRect.right, + drawRect.bottom, + GLES30.GL_COLOR_BUFFER_BIT, + GLES30.GL_LINEAR); + checkGlError(); + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, /* framebuffer= */ boundFramebuffer[0]); + checkGlError(); + } + /** * Throws a {@link GlException} with the given message if {@code expression} evaluates to {@code * false}. diff --git a/libraries/effect/src/androidTest/java/androidx/media3/effect/QueuingGlShaderProgramTest.java b/libraries/effect/src/androidTest/java/androidx/media3/effect/QueuingGlShaderProgramTest.java new file mode 100644 index 0000000000..0af36dc595 --- /dev/null +++ b/libraries/effect/src/androidTest/java/androidx/media3/effect/QueuingGlShaderProgramTest.java @@ -0,0 +1,179 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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 + * + * https://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 androidx.media3.effect; + +import static androidx.media3.common.util.Assertions.checkState; +import static androidx.media3.effect.EffectsTestUtil.generateAndProcessFrames; +import static androidx.media3.effect.EffectsTestUtil.getAndAssertOutputBitmaps; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.util.concurrent.Futures.immediateFuture; + +import android.content.Context; +import android.graphics.Color; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.TypefaceSpan; +import android.util.Pair; +import androidx.media3.common.GlTextureInfo; +import androidx.media3.common.util.Consumer; +import androidx.media3.test.utils.TextureBitmapReader; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Future; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; +import org.junit.runner.RunWith; + +/** Tests for {@link QueuingGlShaderProgram}. */ +@RunWith(AndroidJUnit4.class) +public class QueuingGlShaderProgramTest { + @Rule public final TestName testName = new TestName(); + + private static final String ASSET_PATH = "test-generated-goldens/QueuingGlShaderProgramTest"; + + private static final int BLANK_FRAME_WIDTH = 100; + private static final int BLANK_FRAME_HEIGHT = 50; + private static final Consumer TEXT_SPAN_CONSUMER = + (text) -> { + text.setSpan( + new ForegroundColorSpan(Color.BLACK), + /* start= */ 0, + text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan( + new AbsoluteSizeSpan(/* size= */ 24), + /* start= */ 0, + text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan( + new TypefaceSpan(/* family= */ "sans-serif"), + /* start= */ 0, + text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + }; + + private @MonotonicNonNull TextureBitmapReader textureBitmapReader; + private String testId; + + @Before + public void setUp() { + textureBitmapReader = new TextureBitmapReader(); + testId = testName.getMethodName(); + } + + @Test + public void queuingGlShaderProgram_withQueueSizeOne_outputsFramesInOrder() throws Exception { + List> events = new ArrayList<>(); + ImmutableList frameTimesUs = ImmutableList.of(0L, 333_333L, 666_667L); + ImmutableList actualPresentationTimesUs = + generateAndProcessFrames( + BLANK_FRAME_WIDTH, + BLANK_FRAME_HEIGHT, + frameTimesUs, + new TestGlEffect(events, /* queueSize= */ 1), + textureBitmapReader, + TEXT_SPAN_CONSUMER); + + assertThat(actualPresentationTimesUs).containsExactlyElementsIn(frameTimesUs).inOrder(); + assertThat(events) + .containsExactly( + Pair.create("queueInputFrame", 0L), + Pair.create("finishProcessingAndBlend", 0L), + Pair.create("queueInputFrame", 333_333L), + Pair.create("finishProcessingAndBlend", 333_333L), + Pair.create("queueInputFrame", 666_667L), + Pair.create("finishProcessingAndBlend", 666_667L)) + .inOrder(); + + getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId, ASSET_PATH); + } + + @Test + public void queuingGlShaderProgram_withQueueSizeTwo_outputsFramesInOrder() throws Exception { + List> events = new ArrayList<>(); + ImmutableList frameTimesUs = ImmutableList.of(0L, 333_333L, 666_667L); + ImmutableList actualPresentationTimesUs = + generateAndProcessFrames( + BLANK_FRAME_WIDTH, + BLANK_FRAME_HEIGHT, + frameTimesUs, + new TestGlEffect(events, /* queueSize= */ 2), + textureBitmapReader, + TEXT_SPAN_CONSUMER); + + assertThat(actualPresentationTimesUs).containsExactlyElementsIn(frameTimesUs).inOrder(); + assertThat(events) + .containsExactly( + Pair.create("queueInputFrame", 0L), + Pair.create("queueInputFrame", 333_333L), + Pair.create("finishProcessingAndBlend", 0L), + Pair.create("queueInputFrame", 666_667L), + Pair.create("finishProcessingAndBlend", 333_333L), + Pair.create("finishProcessingAndBlend", 666_667L)) + .inOrder(); + getAndAssertOutputBitmaps(textureBitmapReader, actualPresentationTimesUs, testId, ASSET_PATH); + } + + private static class TestGlEffect implements GlEffect { + + private final List> events; + private final int queueSize; + + TestGlEffect(List> events, int queueSize) { + this.events = events; + this.queueSize = queueSize; + } + + @Override + public GlShaderProgram toGlShaderProgram(Context context, boolean useHdr) { + return new QueuingGlShaderProgram<>( + /* useHighPrecisionColorComponents= */ useHdr, + queueSize, + new NoOpConcurrentEffect(events)); + } + } + + private static class NoOpConcurrentEffect + implements QueuingGlShaderProgram.ConcurrentEffect { + private final List> events; + + NoOpConcurrentEffect(List> events) { + this.events = events; + } + + @Override + public Future queueInputFrame(GlTextureInfo textureInfo, long presentationTimeUs) { + checkState(textureInfo.width == BLANK_FRAME_WIDTH); + checkState(textureInfo.height == BLANK_FRAME_HEIGHT); + events.add(Pair.create("queueInputFrame", presentationTimeUs)); + return immediateFuture(presentationTimeUs); + } + + @Override + public void finishProcessingAndBlend( + GlTextureInfo outputFrame, long presentationTimeUs, Long result) { + checkState(result == presentationTimeUs); + events.add(Pair.create("finishProcessingAndBlend", presentationTimeUs)); + } + } +} diff --git a/libraries/effect/src/main/java/androidx/media3/effect/QueuingGlShaderProgram.java b/libraries/effect/src/main/java/androidx/media3/effect/QueuingGlShaderProgram.java new file mode 100644 index 0000000000..3473ebfa12 --- /dev/null +++ b/libraries/effect/src/main/java/androidx/media3/effect/QueuingGlShaderProgram.java @@ -0,0 +1,302 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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 androidx.media3.effect; + +import static androidx.media3.common.util.Assertions.checkArgument; +import static androidx.media3.common.util.Assertions.checkState; + +import android.graphics.Rect; +import androidx.annotation.CallSuper; +import androidx.annotation.IntRange; +import androidx.media3.common.C; +import androidx.media3.common.GlObjectsProvider; +import androidx.media3.common.GlTextureInfo; +import androidx.media3.common.VideoFrameProcessingException; +import androidx.media3.common.util.GlUtil; +import androidx.media3.common.util.UnstableApi; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.MoreExecutors; +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** + * An implementation of {@link GlShaderProgram} that enables {@linkplain ConcurrentEffect + * asynchronous} processing of video frames outside the current OpenGL context without processor + * stalls. + * + *

Data Dependencies and Processor Stalls

+ * + * Sharing image data between GPU and a {@link ConcurrentEffect} running on another processor + * creates a data dependency. The GPU must finish processing the frame before the data can be + * {@linkplain ConcurrentEffect#queueInputFrame submitted} to the other processor. And the other + * processor must finish processing the image data before any modifications can be {@linkplain + * ConcurrentEffect#finishProcessingAndBlend drawn} back to the main video stream. + * + *

If we force a synchronization and data transfer (e.g. via {@link + * android.opengl.GLES20#glReadPixels}) too early a processor would stall without any work + * available. + * + *

To keep multiple processors busy, {@code QueuingGlShaderProgram} maintains a queue of frames + * that are being processed by the provided {@link ConcurrentEffect}. The queue pipelines the + * processing stages and allows one frame to be processed on the GPU, while at the same time another + * frame is processed by the {@link ConcurrentEffect}. The size of the queue is configurable on + * construction, and should be large enough to compensate for the time required to execute the + * {@linkplain ConcurrentEffect asynchronous effect}, and any data transfer that is required between + * the processors. + * + *

The output frame {@link GlTextureInfo} produced by this class contains a copy of the + * {@linkplain #queueInputFrame input frame}, unless the frame contents were modified by the {@link + * ConcurrentEffect}. + * + *

All methods in this class must be called on the thread that owns the OpenGL context. + * + * @param An intermediate type used by {@link ConcurrentEffect} implementations. + */ +@UnstableApi +/* package */ final class QueuingGlShaderProgram implements GlShaderProgram { + + private static final long PROCESSING_TIMEOUT_MS = 500_000L; + + /** A concurrent effect that is applied by the {@link QueuingGlShaderProgram}. */ + public interface ConcurrentEffect { + /** + * Submits a frame to be processed by the concurrent effect. + * + *

The {@linkplain GlTextureInfo textureInfo} will hold the image data corresponding to the + * frame at {@code presentationTimeUs}. The image data will not be modified until the returned + * {@link Future} {@linkplain Future#isDone() completes} or {@linkplain Future#isCancelled() is + * cancelled}. + * + *

The {@linkplain GlTextureInfo textureInfo} will have a valid {@linkplain + * GlTextureInfo#fboId framebuffer object}. + * + *

This method will be called on the thread that owns the OpenGL context. + * + * @param textureInfo The texture info of the current frame. + * @param presentationTimeUs The presentation timestamp of the input frame, in microseconds. + * @return A {@link Future} representing pending completion of the task. + */ + Future queueInputFrame(GlTextureInfo textureInfo, long presentationTimeUs); + + /** + * Finishes processing the frame at {@code presentationTimeUs}. This method optionally allows + * the instance to draw an overlay or blend with the {@linkplain GlTextureInfo output frame}. + * + *

The {@linkplain GlTextureInfo outputFrame} contains the image data corresponding to the + * frame at {@code presentationTimeUs} when this method is invoked. + * + *

This method will be called on the thread that owns the OpenGL context. + * + * @param outputFrame The texture info of the frame. + * @param presentationTimeUs The presentation timestamp of the frame, in microseconds. + * @param result The result of the asynchronous computation in {@link #queueInputFrame}. + */ + void finishProcessingAndBlend(GlTextureInfo outputFrame, long presentationTimeUs, T result) + throws VideoFrameProcessingException; + } + + private final ConcurrentEffect concurrentEffect; + private final TexturePool outputTexturePool; + private final Queue> frameQueue; + private InputListener inputListener; + private OutputListener outputListener; + private ErrorListener errorListener; + private Executor errorListenerExecutor; + private int inputWidth; + private int inputHeight; + + /** + * Creates a {@code QueuingGlShaderProgram} instance. + * + * @param useHighPrecisionColorComponents If {@code false}, uses colors with 8-bit unsigned bytes. + * If {@code true}, use 16-bit (half-precision) floating-point. + * @param queueSize The number of frames to buffer before producing output, and also the capacity + * of the texture pool. + * @param concurrentEffect The asynchronous effect to apply to each frame. + */ + public QueuingGlShaderProgram( + boolean useHighPrecisionColorComponents, + @IntRange(from = 1) int queueSize, + ConcurrentEffect concurrentEffect) { + checkArgument(queueSize > 0); + this.concurrentEffect = concurrentEffect; + frameQueue = new ArrayDeque<>(queueSize); + outputTexturePool = new TexturePool(useHighPrecisionColorComponents, queueSize); + inputListener = new InputListener() {}; + outputListener = new OutputListener() {}; + errorListener = (frameProcessingException) -> {}; + errorListenerExecutor = MoreExecutors.directExecutor(); + inputWidth = C.LENGTH_UNSET; + inputHeight = C.LENGTH_UNSET; + } + + @Override + public void setInputListener(InputListener inputListener) { + this.inputListener = inputListener; + for (int i = 0; i < outputTexturePool.freeTextureCount(); i++) { + inputListener.onReadyToAcceptInputFrame(); + } + } + + @Override + public void setOutputListener(OutputListener outputListener) { + this.outputListener = outputListener; + } + + @Override + public void setErrorListener(Executor errorListenerExecutor, ErrorListener errorListener) { + this.errorListenerExecutor = errorListenerExecutor; + this.errorListener = errorListener; + } + + @Override + public void queueInputFrame( + GlObjectsProvider glObjectsProvider, GlTextureInfo inputTexture, long presentationTimeUs) { + try { + if (inputWidth != inputTexture.width + || inputHeight != inputTexture.height + || !outputTexturePool.isConfigured()) { + inputWidth = inputTexture.width; + inputHeight = inputTexture.height; + outputTexturePool.ensureConfigured(glObjectsProvider, inputWidth, inputHeight); + } + + // Focus on the next free buffer. + GlTextureInfo outputTexture = outputTexturePool.useTexture(); + + // Copy frame from inputTexture fbo to outputTexture fbo. + checkState(inputTexture.fboId != C.INDEX_UNSET); + GlUtil.blitFrameBuffer( + inputTexture.fboId, + new Rect(/* left= */ 0, /* top= */ 0, /* right= */ inputWidth, /* bottom= */ inputHeight), + outputTexture.fboId, + new Rect( + /* left= */ 0, /* top= */ 0, /* right= */ inputWidth, /* bottom= */ inputHeight)); + + Future task = concurrentEffect.queueInputFrame(outputTexture, presentationTimeUs); + frameQueue.add(new TimedTextureInfo(outputTexture, presentationTimeUs, task)); + + inputListener.onInputFrameProcessed(inputTexture); + + if (frameQueue.size() == outputTexturePool.capacity()) { + checkState(outputOneFrame()); + } + } catch (GlUtil.GlException e) { + onError(e); + } + } + + @Override + public void releaseOutputFrame(GlTextureInfo outputTexture) { + if (!outputTexturePool.isUsingTexture(outputTexture)) { + // This allows us to ignore outputTexture instances not associated with this + // GlShaderProgram instance. This may happen if a GlShaderProgram is introduced into + // the GlShaderProgram chain after frames already exist in the pipeline. + // TODO - b/320481157: Consider removing this if condition and disallowing disconnecting a + // GlShaderProgram while it still has in-use frames. + return; + } + outputTexturePool.freeTexture(outputTexture); + inputListener.onReadyToAcceptInputFrame(); + } + + @Override + public void signalEndOfCurrentInputStream() { + while (outputOneFrame()) {} + outputListener.onCurrentOutputStreamEnded(); + } + + @Override + @CallSuper + public void flush() { + cancelProcessingOfPendingFrames(); + outputTexturePool.freeAllTextures(); + inputListener.onFlush(); + for (int i = 0; i < outputTexturePool.capacity(); i++) { + inputListener.onReadyToAcceptInputFrame(); + } + } + + @Override + @CallSuper + public void release() throws VideoFrameProcessingException { + try { + cancelProcessingOfPendingFrames(); + outputTexturePool.deleteAllTextures(); + } catch (GlUtil.GlException e) { + throw new VideoFrameProcessingException(e); + } + } + + /** + * Outputs one frame from {@link #frameQueue}. + * + *

Returns {@code false} if no more frames are available for output. + */ + private boolean outputOneFrame() { + TimedTextureInfo timedTextureInfo = frameQueue.poll(); + if (timedTextureInfo == null) { + return false; + } + try { + T result = + Futures.getChecked( + timedTextureInfo.task, + VideoFrameProcessingException.class, + PROCESSING_TIMEOUT_MS, + TimeUnit.MILLISECONDS); + GlUtil.focusFramebufferUsingCurrentContext( + timedTextureInfo.textureInfo.fboId, + timedTextureInfo.textureInfo.width, + timedTextureInfo.textureInfo.height); + concurrentEffect.finishProcessingAndBlend( + timedTextureInfo.textureInfo, timedTextureInfo.presentationTimeUs, result); + outputListener.onOutputFrameAvailable( + timedTextureInfo.textureInfo, timedTextureInfo.presentationTimeUs); + return true; + } catch (GlUtil.GlException | VideoFrameProcessingException e) { + onError(e); + return false; + } + } + + private void cancelProcessingOfPendingFrames() { + TimedTextureInfo timedTextureInfo; + while ((timedTextureInfo = frameQueue.poll()) != null) { + timedTextureInfo.task.cancel(/* mayInterruptIfRunning= */ false); + } + } + + private void onError(Exception e) { + errorListenerExecutor.execute( + () -> errorListener.onError(VideoFrameProcessingException.from(e))); + } + + private static class TimedTextureInfo { + final GlTextureInfo textureInfo; + final long presentationTimeUs; + final Future task; + + TimedTextureInfo(GlTextureInfo textureInfo, long presentationTimeUs, Future task) { + this.textureInfo = textureInfo; + this.presentationTimeUs = presentationTimeUs; + this.task = task; + } + } +} diff --git a/libraries/test_data/src/test/assets/test-generated-goldens/QueuingGlShaderProgramTest/pts_0.png b/libraries/test_data/src/test/assets/test-generated-goldens/QueuingGlShaderProgramTest/pts_0.png new file mode 100644 index 0000000000000000000000000000000000000000..4ffb8030fd0cc9248247d2c2c83f484fb229831d GIT binary patch literal 591 zcmeAS@N?(olHy`uVBq!ia0vp^DL`z*!3HE(nbz$CQjEnx?oJHr&dIz4a#)I;JVQ8u zpoSx*11R^?)5S5QV$R!}w%H+$B5V)TUxyk>KNc72?CKJ15ozjJ(R(A}rZz{}wSR2& zP4$P41?6nrD8Rz0)U+bB!)XzlebS3;VG6qKheysqBKbz&QsZWv1Az>#t~QpK&ZI`K8vW()Kq8 zRPP+9?3wadiJMGoSdQ8@ z_}&}z{r%e;7T?F3nJf{%@j`OlN}1f3AI<}nX>O2QT@{}le)?kM?5Q%`mp*-ezx>U; zNK3Jl1y3U;n7&l_fBCxom91vlK#|B@_r$8^3%16~c1*2ny`^C(_N2RH_p2@5*QE81 zr%Ud;RIGENx@JP$i}pYF`lS!Yt=wsG_kPA#pgECyCfs_LUa%xPZ}t_vnMb_$ysO?d ztLV))J@5BDoGW)*Ywh(uR>&B>(LKxR-tp~eNnw-k+U~t_%r)k4;mbV{6Y5rp6^d{F zxC$6LPoo4b9kkW_`@b;a*4xh&UG7!icmAui6koDPx#1am@3R0s$N2z&@+hyVZt(Md!>RCt{2+3Rlk zkX>Iq&ty+e&&CEI1p*}0)PL{Olpe$ASK{*U?p&()0iKD&A=B-{9zKa0$AvoZZ-aa z6g5kme{0{8MFwFromSS#Jb3{g+ zs$(wFI-OpfwmK$Mrwur@j_HgrQ<8Z|4;TX;1TF!V0jq&~wIN^Xey-_O^AK>UXU5db z_sr%LbB|*dIOYW4;|wzjY(qx1Z${Nz=9%9dQ*z8lTIXTU-0hf!9QTJxY)1&+NbvEkpWYuXUa)cZfv$N&{U@I_~vb`B;@Sob4D)7EzLf6!0{rtnL^Z68* zl4m|dZoq@cNz79xk0~MN;9t!mq@lyW@|>m>If=u-xshfFIFEe2uodYPL%{hFW{a8& zBFtxwiMk3E86j?tMSg_C^?Ms|9PkA2RfcIrW+jQ413VdNjt8CsdI^t)+1j>g`n_H2 zJRM=$fVpb6MVe={PG|&8Syln>A+wZkkp{oO_!(Bnyc;m{bDABVy_; zGqlb~N}V(_H_}X5nt-E_p?WehHT(uyt*(eL{dG)ZhFL|pCx%R4q*)7WSF^f+xsYNr z*-1+oxC7}Sk3^aZ@F@9)c^SCfF?kL;F$aLh$ai%p1Gga~Fvi@f=5ZqL(?xFTry;%k zJLEx{n+h^3sRCzGoAxe7rh-`pU}>bOItDdO!1o!ZQotm2%E%9zn4QR_$~Rl}^9&;I zBSnVOb--Q7L%&d(^z*NPNo!!EW2%n1Gs7fx_BnsMR_nMYyk6U4OwzV%91}W+p*p!z zWQ3G~HORW*YsUAG7T_xV-s73e9kVB=S)===nG&^?v|A@J!=AZP_xU7XMj}nSV}?9) zh3@ky;i+&zTViq!tJAw7cP`(e?t!6K>=vzkZ$!KCakY__Xf%}17 zj#;k8uhZew37NAq%mDBJ@UwF~Zkz$0HXuWG9WrEh zAx}43kj2_|;CY~n+B=c5n)S#fXm%da6w$ax$mVSS%Kc#bU8oEEbE!VzF2( d7K`PO%|C_w@g`BaI9>n%002ovPDHLkV1hQoaw7l$ literal 0 HcmV?d00001 diff --git a/libraries/test_data/src/test/assets/test-generated-goldens/QueuingGlShaderProgramTest/pts_666667.png b/libraries/test_data/src/test/assets/test-generated-goldens/QueuingGlShaderProgramTest/pts_666667.png new file mode 100644 index 0000000000000000000000000000000000000000..cd7cfdcbd1f2f2945b73b05757da9cb19aa7168e GIT binary patch literal 1434 zcmV;L1!ek)P)Px#1am@3R0s$N2z&@+hyVZuL`g(JRCt{2nMrI_RT#&A9c&9$5uYSggFqWKv7iP7 zhlR6k72`m`DelA}YMjuG3M||i$0!RGjF`9)!Nk~_pfQ%}f-ZC;tyN5oR60;1B4Qaz zUHrexJKlHiefJGEhMZs0G`;u!?m71i-}%ldcsw4D$K&yMJRXn7L^ z22wa{P(yA*BW?f~A-)5XoVX5b23DKXZBFn#u5$=@0fn{>qOJ#*eI(3vpj@)S-IccOLYMM@j=sc8ic0$&01QaUZbUqv_v z%xNsp=|by38g2mobevK``6(!*jQ|&y_^#BfN8zH~{;mT~C3!TwgGTLgRLTF!dE1;7 z1vqb^*>ML7MQNNk+R$L#XzwYAVtTI*=ttq^8kAuzK&jwCU^|N2`$NtTC=6VS)}OG` z4g3*u`p}5G4#oI1PA_WAz?l!!n$u%-3p+c28Nl^uF%CQ3z`m4i^8uQrx1br9l+oo- zOouc9JJEvGiPnOnC}y4y>;g6eZ=x^|I6nfvMmXmIgE3COy{{8kf<|%JSx2_>8?BtM za|AfooGn(4>-5{b&w#}-&N}|xkx^U$bfCeq!ugie6f-5yvfmlhuv<``eHe|bhkz%6 zpHQX(a3<>fD5p8(Y)0WQ?0js_p^&rF%9)I)TiAIJc*2}b5zdkb=KAbC56ULoIC&DP{3Ke4Ywh|{^dWnIyJDPU5zd4;ccYv>%IR|MEk!dg z&Uwu4yUtCHb59;;dTgs`Pc{sE3(QJ*uIwyF4ZfA=BZLNEFB)NU>{>f&yscy#JnW1C z<7h(`I1}bXEshPS5y#19VC|^!K2LB)%n7ooWsWl~r4yzCc*)-LDCu|cIV5dv;t0-l zMo^p$GmJt)4GKH1GfMPX(l81MwPZt8TBjz$Nvd1mG}yJ^xukN!-xH<@4Tb^WQwm(j zU5e?DQTsh7;hFFbdep9+Lwq6;{s_06d`?haAx=QB<4>wv(s>yu49`ZV6@w|BFDtXq zN9;u(5hjB|$=}ZXGk_s;qB=5z8gtya?>cj1oDnN$p5yE`Cu;MVK_fnJD$MyUk27RW zBe|V+HcGXJQCeQoMzEL}GKMmt(}DX@RuNEzrfvrKn(P_X7|N_>0xQuz!F86QhUrEh z@0OF#`6lFS0Zv01&y0|ByW4+(36}hRX>+Fsw zXBD}Jnw997JnRgZ^K^s*T04%Qg}ju*$m5g}@2Y@HfU8jUQ3d=?_UPn50ZtFf^W&UF zz}1ek&nYKrH(ZJn)U7&&(}GThyX>dr*-?&@$V#-1RG|h<^Dok3oqE(z|2N0u@pwEQ okH_Qjcsw4D$K&yMJRXOC0PWql%6UBgg#Z8m07*qoM6N<$g2vyQ-~a#s literal 0 HcmV?d00001