feat: бинокулярный видеорендер в app-glasses + иммерсивный режим + тест-план
- render/: Grafika-связка (EglCore/WindowSurface/FullFrameRect/Texture2dProgram/GlUtil/Drawable2d/EglSurfaceBase) — перенос из pair-view - ui/BinocularVideoPlayer: один SimpleExoPlayer → SurfaceTexture → EGL-блит в оба глаза (EglVideoRenderer шарится между LEFT/RIGHT) - ui/ViewUtils: Binocular/BinocularScreenSide/BinocularScreenSize (Compose, из pair-view) - ui/GlassesMovieScreen: «нос к носу», calculateVideoSizeFull, FLAG_KEEP_SCREEN_ON - MovieController: PlayVideo/MediaCommand (PLAY/PAUSE/TOGGLE/SEEK/CLOSE), доступ к ExoPlayer только с main-потока - HostConnection: PlayVideo→плеер, реальная позиция в PlaybackPosition (withContext Main) - MainActivity: иммерсивный режим (setDecorFitsSystemWindows(false) + hide systemBars) - TESTING.md: 10 тест-кейсов human-style; QA-прогон (opencode, 88.19) — 10/10 ПРОЙДЕН
This commit is contained in:
@@ -17,10 +17,14 @@ class GlassesApp : Application() {
|
||||
lateinit var hostConnection: HostConnection
|
||||
private set
|
||||
|
||||
lateinit var movieController: MovieController
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
hostConnection = HostConnection(GlassesConfig.HOST_URL)
|
||||
movieController = MovieController(applicationContext)
|
||||
hostConnection = HostConnection(GlassesConfig.HOST_URL, movieController)
|
||||
hostConnection.start()
|
||||
log("app", "HostConnection стартует: ${GlassesConfig.HOST_URL}")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -26,10 +27,11 @@ import pw.binom.viewmate.core.protocol.Welcome
|
||||
/**
|
||||
* Обвязка над [GlassesWsClient] для UI очков:
|
||||
* реконнект, Hello при подключении, периодический статус/позиция,
|
||||
* обработка входящих HostToGlasses в StateFlow (пока — лог).
|
||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером).
|
||||
*/
|
||||
class HostConnection(
|
||||
private val url: String,
|
||||
private val movieController: MovieController,
|
||||
) {
|
||||
private val client = GlassesWsClient(url)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@@ -62,9 +64,10 @@ class HostConnection(
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(5_000)
|
||||
// статус/позиция — заглушки (реальные батарея/память/таймкод — позже)
|
||||
// статус/позиция — заглушки (реальные батарея/память — позже)
|
||||
client.send(GlassesStatus(batteryPercent = 80, storageUsedGb = 10.5, storageTotalGb = 32.0))
|
||||
client.send(PlaybackPosition(positionMs = 0, playing = false))
|
||||
val (positionMs, playing) = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
client.send(PlaybackPosition(positionMs = positionMs, playing = playing))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,9 +81,15 @@ class HostConnection(
|
||||
|
||||
is SetMode -> addMessage("SetMode: ${msg.mode}")
|
||||
|
||||
is PlayVideo -> addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
|
||||
is PlayVideo -> {
|
||||
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
|
||||
movieController.playVideo(msg)
|
||||
}
|
||||
|
||||
is MediaCommandMsg -> addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})")
|
||||
is MediaCommandMsg -> {
|
||||
addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})")
|
||||
movieController.applyMediaCommand(msg.command, msg.seekMs)
|
||||
}
|
||||
|
||||
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ package pw.binom.viewmate.glasses
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -17,13 +20,40 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
ConnectionScreen()
|
||||
val controller = GlassesApp.instance.movieController
|
||||
val movieActive by controller.movieActive.collectAsState()
|
||||
val title by controller.title.collectAsState()
|
||||
val player = controller.player
|
||||
if (movieActive && player != null) {
|
||||
GlassesMovieScreen(player = player, title = title)
|
||||
} else {
|
||||
ConnectionScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
hideSystemBars()
|
||||
log("ui", "иммерсивный режим включён")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
hideSystemBars()
|
||||
}
|
||||
|
||||
private fun hideSystemBars() {
|
||||
window.decorView.post {
|
||||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
|
||||
/**
|
||||
* Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer,
|
||||
* выполняет команды из WS-протокола. Сам плеер создаётся только по PlayVideo.
|
||||
*/
|
||||
class MovieController(private val context: Context) {
|
||||
|
||||
private val _movieActive = MutableStateFlow(false)
|
||||
val movieActive: StateFlow<Boolean> = _movieActive.asStateFlow()
|
||||
|
||||
private val _title = MutableStateFlow<String?>(null)
|
||||
val title: StateFlow<String?> = _title.asStateFlow()
|
||||
|
||||
private var _player: SimpleExoPlayer? = null
|
||||
val player: SimpleExoPlayer? get() = _player
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun playVideo(msg: PlayVideo) {
|
||||
mainHandler.post {
|
||||
val player = _player ?: SimpleExoPlayer.Builder(context).build().also {
|
||||
_player = it
|
||||
log("glasses", "SimpleExoPlayer создан")
|
||||
}
|
||||
_title.value = msg.title
|
||||
log("glasses", "PlayVideo: «${msg.title}» — ${msg.videoUrl}")
|
||||
player.setMediaItem(MediaItem.fromUri(msg.videoUrl))
|
||||
player.prepare()
|
||||
player.seekTo(msg.startPositionMs)
|
||||
player.playWhenReady = true
|
||||
_movieActive.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun applyMediaCommand(command: MediaCommand, seekMs: Long) {
|
||||
mainHandler.post {
|
||||
val player = _player ?: run {
|
||||
log("glasses", "MediaCommand $command проигнорирован: плеер не активен")
|
||||
return@post
|
||||
}
|
||||
when (command) {
|
||||
MediaCommand.PLAY -> player.play()
|
||||
|
||||
MediaCommand.PAUSE -> player.pause()
|
||||
|
||||
MediaCommand.TOGGLE -> {
|
||||
if (player.isPlaying) player.pause() else player.play()
|
||||
}
|
||||
|
||||
MediaCommand.SEEK_FORWARD -> {
|
||||
val target = player.currentPosition + seekMs
|
||||
player.seekTo(target)
|
||||
}
|
||||
|
||||
MediaCommand.SEEK_BACKWARD -> {
|
||||
val target = (player.currentPosition - seekMs).coerceAtLeast(0L)
|
||||
player.seekTo(target)
|
||||
}
|
||||
|
||||
// Звук — на телефоне, переключение аудио-дорожек очкам не нужно.
|
||||
MediaCommand.NEXT_AUDIO, MediaCommand.PREV_AUDIO -> {
|
||||
log("glasses", "MediaCommand $command игнорируется (звук на телефоне)")
|
||||
}
|
||||
|
||||
MediaCommand.CLOSE -> close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
mainHandler.post {
|
||||
val player = _player
|
||||
_player = null
|
||||
if (player != null) {
|
||||
log("glasses", "CLOSE: плеер остановлен и освобождён")
|
||||
player.playWhenReady = false
|
||||
player.stop()
|
||||
player.release()
|
||||
}
|
||||
_movieActive.value = false
|
||||
_title.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Читать ТОЛЬКО с главного потока. */
|
||||
fun playbackPosition(): Pair<Long, Boolean> {
|
||||
val player = _player ?: return 0L to false
|
||||
return player.currentPosition to player.isPlaying
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package pw.binom.viewmate.glasses.render
|
||||
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
class Drawable2d(shape: Prefab) {
|
||||
companion object {
|
||||
private const val SIZEOF_FLOAT: Int = 4
|
||||
|
||||
/**
|
||||
* Simple equilateral triangle (1.0 per side). Centered on (0,0).
|
||||
*/
|
||||
private val TRIANGLE_COORDS: FloatArray = floatArrayOf(
|
||||
0.0f, 0.577350269f, // 0 top
|
||||
-0.5f, -0.288675135f, // 1 bottom left
|
||||
0.5f, -0.288675135f // 2 bottom right
|
||||
)
|
||||
|
||||
private val TRIANGLE_TEX_COORDS: FloatArray = floatArrayOf(
|
||||
0.5f, 0.0f, // 0 top center
|
||||
0.0f, 1.0f, // 1 bottom left
|
||||
1.0f, 1.0f, // 2 bottom right
|
||||
)
|
||||
|
||||
private val TRIANGLE_BUF: FloatBuffer = GlUtil.createFloatBuffer(TRIANGLE_COORDS)
|
||||
|
||||
private val TRIANGLE_TEX_BUF = GlUtil.createFloatBuffer(TRIANGLE_TEX_COORDS)
|
||||
|
||||
/**
|
||||
* Simple square, specified as a triangle strip. The square is centered on (0,0) and has
|
||||
* a size of 1x1.
|
||||
*
|
||||
* Triangles are 0-1-2 and 2-1-3 (counter-clockwise winding).
|
||||
*/
|
||||
private val RECTANGLE_COORDS = floatArrayOf(
|
||||
-0.5f, -0.5f, // 0 bottom left
|
||||
0.5f, -0.5f, // 1 bottom right
|
||||
-0.5f, 0.5f, // 2 top left
|
||||
0.5f, 0.5f, // 3 top right
|
||||
)
|
||||
|
||||
private val RECTANGLE_TEX_COORDS = floatArrayOf(
|
||||
0.0f, 1.0f, // 0 bottom left
|
||||
1.0f, 1.0f, // 1 bottom right
|
||||
0.0f, 0.0f, // 2 top left
|
||||
1.0f, 0.0f // 3 top right
|
||||
)
|
||||
|
||||
private val RECTANGLE_BUF = GlUtil.createFloatBuffer(RECTANGLE_COORDS)
|
||||
|
||||
private val RECTANGLE_TEX_BUF = GlUtil.createFloatBuffer(RECTANGLE_TEX_COORDS)
|
||||
|
||||
/**
|
||||
* A "full" square, extending from -1 to +1 in both dimensions. When the model/view/projection
|
||||
* matrix is identity, this will exactly cover the viewport.
|
||||
*
|
||||
* The texture coordinates are Y-inverted relative to RECTANGLE. (This seems to work out
|
||||
* right with external textures from SurfaceTexture.)
|
||||
*/
|
||||
private val FULL_RECTANGLE_COORDS = floatArrayOf(
|
||||
-1.0f, -1.0f, // 0 bottom left
|
||||
1.0f, -1.0f, // 1 bottom right
|
||||
-1.0f, 1.0f, // 2 top left
|
||||
1.0f, 1.0f, // 3 top right
|
||||
)
|
||||
|
||||
private val FULL_RECTANGLE_TEX_COORDS = floatArrayOf(
|
||||
0.0f, 0.0f, // 0 bottom left
|
||||
1.0f, 0.0f, // 1 bottom right
|
||||
0.0f, 1.0f, // 2 top left
|
||||
1.0f, 1.0f // 3 top right
|
||||
)
|
||||
|
||||
private val FULL_RECTANGLE_BUF = GlUtil.createFloatBuffer(FULL_RECTANGLE_COORDS)
|
||||
|
||||
private val FULL_RECTANGLE_TEX_BUF = GlUtil.createFloatBuffer(FULL_RECTANGLE_TEX_COORDS)
|
||||
}
|
||||
|
||||
val mVertexArray: FloatBuffer
|
||||
val mTexCoordArray: FloatBuffer
|
||||
|
||||
private var mPrefab: Prefab? = null
|
||||
val mVertexCount: Int
|
||||
val mCoordsPerVertex: Int
|
||||
val mVertexStride: Int
|
||||
val mTexCoordStride: Int
|
||||
|
||||
/**
|
||||
* Enum values for constructor.
|
||||
*/
|
||||
enum class Prefab {
|
||||
TRIANGLE, RECTANGLE, FULL_RECTANGLE
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a drawable from a "pre-fabricated" shape definition.
|
||||
*
|
||||
* Does no EGL/GL operations, so this can be done at any time.
|
||||
*/
|
||||
init {
|
||||
when (shape) {
|
||||
Prefab.TRIANGLE -> {
|
||||
mVertexArray = TRIANGLE_BUF
|
||||
mTexCoordArray = TRIANGLE_TEX_BUF
|
||||
mCoordsPerVertex = 2
|
||||
mVertexStride = mCoordsPerVertex * SIZEOF_FLOAT
|
||||
mVertexCount = TRIANGLE_COORDS.size / mCoordsPerVertex
|
||||
}
|
||||
|
||||
Prefab.RECTANGLE -> {
|
||||
mVertexArray = RECTANGLE_BUF
|
||||
mTexCoordArray = RECTANGLE_TEX_BUF
|
||||
mCoordsPerVertex = 2
|
||||
mVertexStride = mCoordsPerVertex * SIZEOF_FLOAT
|
||||
mVertexCount = RECTANGLE_COORDS.size / mCoordsPerVertex
|
||||
}
|
||||
|
||||
Prefab.FULL_RECTANGLE -> {
|
||||
mVertexArray = FULL_RECTANGLE_BUF
|
||||
mTexCoordArray = FULL_RECTANGLE_TEX_BUF
|
||||
mCoordsPerVertex = 2
|
||||
mVertexStride = mCoordsPerVertex * SIZEOF_FLOAT
|
||||
mVertexCount = FULL_RECTANGLE_COORDS.size / mCoordsPerVertex
|
||||
}
|
||||
|
||||
else -> throw RuntimeException("Unknown shape $shape")
|
||||
}
|
||||
mTexCoordStride = 2 * SIZEOF_FLOAT
|
||||
mPrefab = shape
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the array of vertices.
|
||||
*
|
||||
* To avoid allocations, this returns internal state. The caller must not modify it.
|
||||
*/
|
||||
fun getVertexArray() = mVertexArray
|
||||
|
||||
/**
|
||||
* Returns the array of texture coordinates.
|
||||
*
|
||||
* To avoid allocations, this returns internal state. The caller must not modify it.
|
||||
*/
|
||||
fun getTexCoordArray() = mTexCoordArray
|
||||
|
||||
/**
|
||||
* Returns the number of vertices stored in the vertex array.
|
||||
*/
|
||||
fun getVertexCount(): Int {
|
||||
return mVertexCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the width, in bytes, of the data for each vertex.
|
||||
*/
|
||||
fun getVertexStride(): Int {
|
||||
return mVertexStride
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the width, in bytes, of the data for each texture coordinate.
|
||||
*/
|
||||
fun getTexCoordStride(): Int {
|
||||
return mTexCoordStride
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of position coordinates per vertex. This will be 2 or 3.
|
||||
*/
|
||||
fun getCoordsPerVertex(): Int {
|
||||
return mCoordsPerVertex
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return if (mPrefab != null) {
|
||||
"[Drawable2d: $mPrefab]"
|
||||
} else {
|
||||
"[Drawable2d: ...]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package pw.binom.viewmate.glasses.render
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLContext
|
||||
import android.opengl.EGLDisplay
|
||||
import android.opengl.EGLExt
|
||||
import android.opengl.EGLSurface
|
||||
import android.view.Surface
|
||||
import pw.binom.viewmate.glasses.log
|
||||
|
||||
class EglCore {
|
||||
companion object {
|
||||
const val TAG = "GlUtil"
|
||||
|
||||
/**
|
||||
* Constructor flag: surface must be recordable. This discourages EGL from using a
|
||||
* pixel format that cannot be converted efficiently to something usable by the video
|
||||
* encoder.
|
||||
*/
|
||||
const val FLAG_RECORDABLE: Int = 0x01
|
||||
|
||||
/**
|
||||
* Constructor flag: ask for GLES3, fall back to GLES2 if not available. Without this
|
||||
* flag, GLES2 is used.
|
||||
*/
|
||||
const val FLAG_TRY_GLES3: Int = 0x02
|
||||
|
||||
// Android-specific extension.
|
||||
const val EGL_RECORDABLE_ANDROID: Int = 0x3142
|
||||
}
|
||||
|
||||
private var mEGLDisplay: EGLDisplay? = EGL14.EGL_NO_DISPLAY
|
||||
private var mEGLContext: EGLContext = EGL14.EGL_NO_CONTEXT
|
||||
private var mEGLConfig: EGLConfig? = null
|
||||
private var mGlVersion = -1
|
||||
|
||||
/**
|
||||
* Prepares EGL display and context.
|
||||
*
|
||||
* Equivalent to EglCore(null, 0).
|
||||
*/
|
||||
constructor() : this(null, 0) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares EGL display and context.
|
||||
*
|
||||
* @param sharedContext The context to share, or null if sharing is not desired.
|
||||
* @param flags Configuration bit flags, e.g. FLAG_RECORDABLE.
|
||||
*/
|
||||
constructor(sharedContext: EGLContext?, flags: Int) {
|
||||
var sharedContext: EGLContext? = sharedContext
|
||||
if (mEGLDisplay !== EGL14.EGL_NO_DISPLAY) {
|
||||
throw RuntimeException("EGL already set up")
|
||||
}
|
||||
|
||||
if (sharedContext == null) {
|
||||
sharedContext = EGL14.EGL_NO_CONTEXT
|
||||
}
|
||||
|
||||
mEGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
if (mEGLDisplay === EGL14.EGL_NO_DISPLAY) {
|
||||
throw RuntimeException("unable to get EGL14 display")
|
||||
}
|
||||
val version = IntArray(2)
|
||||
if (!EGL14.eglInitialize(mEGLDisplay, version, 0, version, 1)) {
|
||||
mEGLDisplay = null
|
||||
throw RuntimeException("unable to initialize EGL14")
|
||||
}
|
||||
|
||||
// Try to get a GLES3 context, if requested.
|
||||
if ((flags and FLAG_TRY_GLES3) != 0) {
|
||||
val config: EGLConfig? = getConfig(flags, 3)
|
||||
if (config != null) {
|
||||
val attrib3_list = intArrayOf(
|
||||
EGL14.EGL_CONTEXT_CLIENT_VERSION, 3,
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
val context: EGLContext = EGL14.eglCreateContext(
|
||||
mEGLDisplay, config, sharedContext,
|
||||
attrib3_list, 0
|
||||
)
|
||||
|
||||
if (EGL14.eglGetError() == EGL14.EGL_SUCCESS) {
|
||||
mEGLConfig = config
|
||||
mEGLContext = context
|
||||
mGlVersion = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mEGLContext === EGL14.EGL_NO_CONTEXT) { // GLES 2 only, or GLES 3 attempt failed
|
||||
val config: EGLConfig = getConfig(flags, 2)
|
||||
?: throw RuntimeException("Unable to find a suitable EGLConfig")
|
||||
val attrib2_list = intArrayOf(
|
||||
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
val context: EGLContext = EGL14.eglCreateContext(
|
||||
mEGLDisplay, config, sharedContext,
|
||||
attrib2_list, 0
|
||||
)
|
||||
checkEglError("eglCreateContext")
|
||||
mEGLConfig = config
|
||||
mEGLContext = context
|
||||
mGlVersion = 2
|
||||
}
|
||||
|
||||
// Confirm with query.
|
||||
val values = IntArray(1)
|
||||
EGL14.eglQueryContext(
|
||||
mEGLDisplay, mEGLContext, EGL14.EGL_CONTEXT_CLIENT_VERSION,
|
||||
values, 0
|
||||
)
|
||||
log(TAG, "EGLContext created, client version " + values[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a suitable EGLConfig.
|
||||
*
|
||||
* @param flags Bit flags from constructor.
|
||||
* @param version Must be 2 or 3.
|
||||
*/
|
||||
private fun getConfig(flags: Int, version: Int): EGLConfig? {
|
||||
var renderableType = EGL14.EGL_OPENGL_ES2_BIT
|
||||
if (version >= 3) {
|
||||
renderableType = renderableType or EGLExt.EGL_OPENGL_ES3_BIT_KHR
|
||||
}
|
||||
|
||||
// The actual surface is generally RGBA or RGBX, so situationally omitting alpha
|
||||
// doesn't really help. It can also lead to a huge performance hit on glReadPixels()
|
||||
// when reading into a GL_RGBA buffer.
|
||||
val attribList = intArrayOf(
|
||||
EGL14.EGL_RED_SIZE, 8,
|
||||
EGL14.EGL_GREEN_SIZE, 8,
|
||||
EGL14.EGL_BLUE_SIZE, 8,
|
||||
EGL14.EGL_ALPHA_SIZE, 8, //EGL14.EGL_DEPTH_SIZE, 16,
|
||||
//EGL14.EGL_STENCIL_SIZE, 8,
|
||||
EGL14.EGL_RENDERABLE_TYPE, renderableType,
|
||||
EGL14.EGL_NONE, 0, // placeholder for recordable [@-3]
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
if ((flags and FLAG_RECORDABLE) != 0) {
|
||||
attribList[attribList.size - 3] = EGL_RECORDABLE_ANDROID
|
||||
attribList[attribList.size - 2] = 1
|
||||
}
|
||||
val configs: Array<EGLConfig?> = arrayOfNulls<EGLConfig>(1)
|
||||
val numConfigs = IntArray(1)
|
||||
if (!EGL14.eglChooseConfig(
|
||||
mEGLDisplay, attribList, 0, configs, 0, configs.size,
|
||||
numConfigs, 0
|
||||
)
|
||||
) {
|
||||
log(TAG, "unable to find RGB8888 / $version EGLConfig")
|
||||
return null
|
||||
}
|
||||
return configs[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards all resources held by this class, notably the EGL context. This must be
|
||||
* called from the thread where the context was created.
|
||||
*
|
||||
* On completion, no context will be current.
|
||||
*/
|
||||
fun release() {
|
||||
if (mEGLDisplay !== EGL14.EGL_NO_DISPLAY) {
|
||||
// Android is unusual in that it uses a reference-counted EGLDisplay. So for
|
||||
// every eglInitialize() we need an eglTerminate().
|
||||
EGL14.eglMakeCurrent(
|
||||
mEGLDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE,
|
||||
EGL14.EGL_NO_CONTEXT
|
||||
)
|
||||
EGL14.eglDestroyContext(mEGLDisplay, mEGLContext)
|
||||
EGL14.eglReleaseThread()
|
||||
EGL14.eglTerminate(mEGLDisplay)
|
||||
}
|
||||
|
||||
mEGLDisplay = EGL14.EGL_NO_DISPLAY
|
||||
mEGLContext = EGL14.EGL_NO_CONTEXT
|
||||
mEGLConfig = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the specified surface. Note the EGLSurface won't actually be destroyed if it's
|
||||
* still current in a context.
|
||||
*/
|
||||
fun releaseSurface(eglSurface: EGLSurface?) {
|
||||
EGL14.eglDestroySurface(mEGLDisplay, eglSurface)
|
||||
}
|
||||
|
||||
fun createWindowSurface(surface: Surface): EGLSurface = createWindowSurface(surface)
|
||||
fun createWindowSurface(surface: SurfaceTexture): EGLSurface = createWindowSurface(surface)
|
||||
|
||||
/**
|
||||
* Creates an EGL surface associated with a Surface.
|
||||
*
|
||||
* If this is destined for MediaCodec, the EGLConfig should have the "recordable" attribute.
|
||||
*/
|
||||
fun createWindowSurface(surface: Any): EGLSurface {
|
||||
if (surface !is Surface && surface !is SurfaceTexture) {
|
||||
throw RuntimeException("invalid surface: $surface")
|
||||
}
|
||||
|
||||
// Create a window surface, and attach it to the Surface we received.
|
||||
val surfaceAttribs = intArrayOf(
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
val eglSurface: EGLSurface? = EGL14.eglCreateWindowSurface(
|
||||
mEGLDisplay, mEGLConfig, surface,
|
||||
surfaceAttribs, 0
|
||||
)
|
||||
checkEglError("eglCreateWindowSurface")
|
||||
if (eglSurface == null) {
|
||||
throw RuntimeException("surface was null")
|
||||
}
|
||||
return eglSurface
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an EGL surface associated with an offscreen buffer.
|
||||
*/
|
||||
fun createOffscreenSurface(width: Int, height: Int): EGLSurface {
|
||||
val surfaceAttribs = intArrayOf(
|
||||
EGL14.EGL_WIDTH, width,
|
||||
EGL14.EGL_HEIGHT, height,
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
val eglSurface: EGLSurface? = EGL14.eglCreatePbufferSurface(
|
||||
mEGLDisplay, mEGLConfig,
|
||||
surfaceAttribs, 0
|
||||
)
|
||||
checkEglError("eglCreatePbufferSurface")
|
||||
if (eglSurface == null) {
|
||||
throw RuntimeException("surface was null")
|
||||
}
|
||||
return eglSurface
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes our EGL context current, using the supplied surface for both "draw" and "read".
|
||||
*/
|
||||
fun makeCurrent(eglSurface: EGLSurface?) {
|
||||
if (mEGLDisplay === EGL14.EGL_NO_DISPLAY) {
|
||||
// called makeCurrent() before create?
|
||||
log(TAG, "NOTE: makeCurrent w/o display")
|
||||
}
|
||||
if (!EGL14.eglMakeCurrent(mEGLDisplay, eglSurface, eglSurface, mEGLContext)) {
|
||||
throw RuntimeException("eglMakeCurrent failed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes our EGL context current, using the supplied "draw" and "read" surfaces.
|
||||
*/
|
||||
fun makeCurrent(drawSurface: EGLSurface?, readSurface: EGLSurface?) {
|
||||
if (mEGLDisplay === EGL14.EGL_NO_DISPLAY) {
|
||||
// called makeCurrent() before create?
|
||||
log(TAG, "NOTE: makeCurrent w/o display")
|
||||
}
|
||||
if (!EGL14.eglMakeCurrent(mEGLDisplay, drawSurface, readSurface, mEGLContext)) {
|
||||
throw RuntimeException("eglMakeCurrent(draw,read) failed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes no context current.
|
||||
*/
|
||||
fun makeNothingCurrent() {
|
||||
if (!EGL14.eglMakeCurrent(
|
||||
mEGLDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE,
|
||||
EGL14.EGL_NO_CONTEXT
|
||||
)
|
||||
) {
|
||||
throw RuntimeException("eglMakeCurrent failed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls eglSwapBuffers. Use this to "publish" the current frame.
|
||||
*
|
||||
* @return false on failure
|
||||
*/
|
||||
fun swapBuffers(eglSurface: EGLSurface?): Boolean {
|
||||
return EGL14.eglSwapBuffers(mEGLDisplay, eglSurface)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the presentation time stamp to EGL. Time is expressed in nanoseconds.
|
||||
*/
|
||||
fun setPresentationTime(eglSurface: EGLSurface?, nsecs: Long) {
|
||||
EGLExt.eglPresentationTimeANDROID(mEGLDisplay, eglSurface, nsecs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if our context and the specified surface are current.
|
||||
*/
|
||||
fun isCurrent(eglSurface: EGLSurface): Boolean {
|
||||
return mEGLContext.equals(EGL14.eglGetCurrentContext()) &&
|
||||
eglSurface.equals(EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW))
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a simple surface query.
|
||||
*/
|
||||
fun querySurface(eglSurface: EGLSurface?, what: Int): Int {
|
||||
val value = IntArray(1)
|
||||
EGL14.eglQuerySurface(mEGLDisplay, eglSurface, what, value, 0)
|
||||
return value[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries a string value.
|
||||
*/
|
||||
fun queryString(what: Int): String {
|
||||
return EGL14.eglQueryString(mEGLDisplay, what)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the GLES version this context is configured for (currently 2 or 3).
|
||||
*/
|
||||
fun getGlVersion(): Int {
|
||||
return mGlVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current display, context, and surface to the log.
|
||||
*/
|
||||
fun logCurrent(msg: String) {
|
||||
val display: EGLDisplay = EGL14.eglGetCurrentDisplay()
|
||||
val context: EGLContext = EGL14.eglGetCurrentContext()
|
||||
val surface: EGLSurface = EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW)
|
||||
log(
|
||||
TAG, "Current EGL ($msg): display=$display, context=$context, surface=$surface"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for EGL errors. Throws an exception if an error has been raised.
|
||||
*/
|
||||
private fun checkEglError(msg: String) {
|
||||
val error: Int
|
||||
if ((EGL14.eglGetError().also { error = it }) != EGL14.EGL_SUCCESS) {
|
||||
throw RuntimeException(msg + ": EGL error: 0x" + Integer.toHexString(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package pw.binom.viewmate.glasses.render
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLSurface
|
||||
import android.opengl.GLES20
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
open class EglSurfaceBase {
|
||||
companion object {
|
||||
const val TAG: String = "GlUtil"
|
||||
}
|
||||
|
||||
// EglCore object we're associated with. It may be associated with multiple surfaces.
|
||||
protected lateinit var mEglCore: EglCore
|
||||
|
||||
private var mEGLSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
private var mWidth = -1
|
||||
private var mHeight = -1
|
||||
|
||||
protected constructor(eglCore: EglCore) {
|
||||
mEglCore = eglCore
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a window surface.
|
||||
*
|
||||
* @param surface May be a Surface or SurfaceTexture.
|
||||
*/
|
||||
fun createWindowSurface(surface: Any) {
|
||||
check(mEGLSurface === EGL14.EGL_NO_SURFACE) { "surface already created" }
|
||||
mEGLSurface = mEglCore.createWindowSurface(surface)
|
||||
|
||||
// Don't cache width/height here, because the size of the underlying surface can change
|
||||
// out from under us (see e.g. HardwareScalerActivity).
|
||||
//mWidth = mEglCore.querySurface(mEGLSurface, EGL14.EGL_WIDTH);
|
||||
//mHeight = mEglCore.querySurface(mEGLSurface, EGL14.EGL_HEIGHT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an off-screen surface.
|
||||
*/
|
||||
fun createOffscreenSurface(width: Int, height: Int) {
|
||||
check(mEGLSurface === EGL14.EGL_NO_SURFACE) { "surface already created" }
|
||||
mEGLSurface = mEglCore.createOffscreenSurface(width, height)
|
||||
mWidth = width
|
||||
mHeight = height
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the surface's width, in pixels.
|
||||
*
|
||||
* If this is called on a window surface, and the underlying surface is in the process
|
||||
* of changing size, we may not see the new size right away (e.g. in the "surfaceChanged"
|
||||
* callback). The size should match after the next buffer swap.
|
||||
*/
|
||||
fun getWidth(): Int {
|
||||
return if (mWidth < 0) {
|
||||
mEglCore.querySurface(mEGLSurface, EGL14.EGL_WIDTH)
|
||||
} else {
|
||||
mWidth
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the surface's height, in pixels.
|
||||
*/
|
||||
fun getHeight(): Int {
|
||||
return if (mHeight < 0) {
|
||||
mEglCore.querySurface(mEGLSurface, EGL14.EGL_HEIGHT)
|
||||
} else {
|
||||
mHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the EGL surface.
|
||||
*/
|
||||
fun releaseEglSurface() {
|
||||
mEglCore.releaseSurface(mEGLSurface)
|
||||
mEGLSurface = EGL14.EGL_NO_SURFACE
|
||||
mHeight = -1
|
||||
mWidth = mHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes our EGL context and surface current.
|
||||
*/
|
||||
fun makeCurrent() {
|
||||
mEglCore.makeCurrent(mEGLSurface)
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes our EGL context and surface current for drawing, using the supplied surface
|
||||
* for reading.
|
||||
*/
|
||||
fun makeCurrentReadFrom(readSurface: EglSurfaceBase) {
|
||||
mEglCore.makeCurrent(mEGLSurface, readSurface.mEGLSurface)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls eglSwapBuffers. Use this to "publish" the current frame.
|
||||
*
|
||||
* @return false on failure
|
||||
*/
|
||||
fun swapBuffers(): Boolean {
|
||||
val result: Boolean = mEglCore.swapBuffers(mEGLSurface)
|
||||
if (!result) {
|
||||
log(TAG, "WARNING: swapBuffers() failed")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the presentation time stamp to EGL.
|
||||
*
|
||||
* @param nsecs Timestamp, in nanoseconds.
|
||||
*/
|
||||
fun setPresentationTime(nsecs: Long) {
|
||||
mEglCore.setPresentationTime(mEGLSurface, nsecs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the EGL surface to a file.
|
||||
*
|
||||
* Expects that this object's EGL surface is current.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun saveFrame(file: File) {
|
||||
if (!mEglCore.isCurrent(mEGLSurface)) {
|
||||
throw RuntimeException("Expected EGL context/surface is not current")
|
||||
}
|
||||
|
||||
// glReadPixels fills in a "direct" ByteBuffer with what is essentially big-endian RGBA
|
||||
// data (i.e. a byte of red, followed by a byte of green...). While the Bitmap
|
||||
// constructor that takes an int[] wants little-endian ARGB (blue/red swapped), the
|
||||
// Bitmap "copy pixels" method wants the same format GL provides.
|
||||
val filename = file.toString()
|
||||
|
||||
val width = getWidth()
|
||||
val height = getHeight()
|
||||
val buf = ByteBuffer.allocateDirect(width * height * 4)
|
||||
buf.order(ByteOrder.LITTLE_ENDIAN)
|
||||
GLES20.glReadPixels(
|
||||
0, 0, width, height,
|
||||
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buf
|
||||
)
|
||||
checkGlError("glReadPixels")
|
||||
buf.rewind()
|
||||
|
||||
var bos: BufferedOutputStream? = null
|
||||
try {
|
||||
bos = BufferedOutputStream(FileOutputStream(filename))
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bmp.copyPixelsFromBuffer(buf)
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 90, bos)
|
||||
bmp.recycle()
|
||||
} finally {
|
||||
bos?.close()
|
||||
}
|
||||
log(TAG, "Saved $width" + "x" + "$height frame as '$filename'")
|
||||
}
|
||||
|
||||
private fun checkGlError(op: String) {
|
||||
val error = GLES20.glGetError()
|
||||
if (error != GLES20.GL_NO_ERROR) {
|
||||
val msg = op + ": glError 0x" + Integer.toHexString(error)
|
||||
log(TAG, msg)
|
||||
throw RuntimeException(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package pw.binom.viewmate.glasses.render
|
||||
|
||||
/**
|
||||
* @param program The program to use. FullFrameRect takes ownership, and will release
|
||||
* the program when no longer needed.
|
||||
*/
|
||||
class FullFrameRect(program: Texture2dProgram) {
|
||||
private val mRectDrawable = Drawable2d(Drawable2d.Prefab.FULL_RECTANGLE)
|
||||
private var mProgram: Texture2dProgram? = program
|
||||
|
||||
/**
|
||||
* Releases resources.
|
||||
*
|
||||
* This must be called with the appropriate EGL context current (i.e. the one that was
|
||||
* current when the constructor was called). If we're about to destroy the EGL context,
|
||||
* there's no value in having the caller make it current just to do this cleanup, so you
|
||||
* can pass a flag that will tell this function to skip any EGL-context-specific cleanup.
|
||||
*/
|
||||
fun release(doEglCleanup: Boolean) {
|
||||
if (doEglCleanup) {
|
||||
mProgram?.release()
|
||||
}
|
||||
mProgram = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the program currently in use.
|
||||
*/
|
||||
fun getProgram(): Texture2dProgram? = mProgram
|
||||
|
||||
/**
|
||||
* Changes the program. The previous program will be released.
|
||||
*
|
||||
* The appropriate EGL context must be current.
|
||||
*/
|
||||
fun changeProgram(program: Texture2dProgram?) {
|
||||
mProgram?.release()
|
||||
mProgram = program
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a texture object suitable for use with drawFrame().
|
||||
*/
|
||||
fun createTextureObject(): Int {
|
||||
val mProgram = mProgram
|
||||
checkNotNull(mProgram) { "FrameRect already closed" }
|
||||
return mProgram.createTextureObject()
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a viewport-filling rect, texturing it with the specified texture object.
|
||||
*/
|
||||
fun drawFrame(textureId: Int, texMatrix: FloatArray) {
|
||||
val mProgram = mProgram
|
||||
checkNotNull(mProgram) { "FrameRect already closed" }
|
||||
// Use the identity matrix for MVP so our 2x2 FULL_RECTANGLE covers the viewport.
|
||||
mProgram.draw(
|
||||
mvpMatrix = GlUtil.IDENTITY_MATRIX,
|
||||
vertexBuffer = mRectDrawable.mVertexArray,
|
||||
firstVertex = 0,
|
||||
vertexCount = mRectDrawable.mVertexCount,
|
||||
coordsPerVertex = mRectDrawable.mCoordsPerVertex,
|
||||
vertexStride = mRectDrawable.mVertexStride,
|
||||
texMatrix = texMatrix,
|
||||
texBuffer = mRectDrawable.mTexCoordArray,
|
||||
textureId = textureId,
|
||||
texStride = mRectDrawable.mTexCoordStride,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package pw.binom.viewmate.glasses.render
|
||||
|
||||
import android.opengl.GLES20
|
||||
import android.opengl.GLES30
|
||||
import android.opengl.Matrix
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
object GlUtil {
|
||||
const val TAG: String = "Grafika"
|
||||
|
||||
/** Identity matrix for general use. Don't modify or life will get weird. */
|
||||
val IDENTITY_MATRIX = FloatArray(16).also {
|
||||
Matrix.setIdentityM(it, 0)
|
||||
}
|
||||
|
||||
private const val SIZEOF_FLOAT: Int = 4
|
||||
|
||||
/**
|
||||
* Creates a new program from the supplied vertex and fragment shaders.
|
||||
*
|
||||
* @return A handle to the program, or 0 on failure.
|
||||
*/
|
||||
fun createProgram(vertexSource: String?, fragmentSource: String?): Int {
|
||||
val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource)
|
||||
if (vertexShader == 0) {
|
||||
error("Can't create Vertex Shader")
|
||||
return 0
|
||||
}
|
||||
val pixelShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
|
||||
if (pixelShader == 0) {
|
||||
error("Can't create Pixel Shader")
|
||||
return 0
|
||||
}
|
||||
|
||||
var program = GLES20.glCreateProgram()
|
||||
checkGlError("glCreateProgram")
|
||||
if (program == 0) {
|
||||
log(TAG, "Could not create program")
|
||||
}
|
||||
GLES20.glAttachShader(program, vertexShader)
|
||||
checkGlError("glAttachShader")
|
||||
GLES20.glAttachShader(program, pixelShader)
|
||||
checkGlError("glAttachShader")
|
||||
GLES20.glLinkProgram(program)
|
||||
val linkStatus = IntArray(1)
|
||||
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
|
||||
if (linkStatus[0] != GLES20.GL_TRUE) {
|
||||
log(TAG, "Could not link program: ")
|
||||
log(TAG, GLES20.glGetProgramInfoLog(program))
|
||||
GLES20.glDeleteProgram(program)
|
||||
program = 0
|
||||
}
|
||||
return program
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the provided shader source.
|
||||
*
|
||||
* @return A handle to the shader, or 0 on failure.
|
||||
*/
|
||||
fun loadShader(shaderType: Int, source: String?): Int {
|
||||
var shader = GLES20.glCreateShader(shaderType)
|
||||
checkGlError("glCreateShader type=$shaderType")
|
||||
GLES20.glShaderSource(shader, source)
|
||||
GLES20.glCompileShader(shader)
|
||||
val compiled = IntArray(1)
|
||||
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
|
||||
if (compiled[0] == 0) {
|
||||
log(TAG, "Could not compile shader $shaderType:")
|
||||
log(TAG, " " + GLES20.glGetShaderInfoLog(shader))
|
||||
GLES20.glDeleteShader(shader)
|
||||
shader = 0
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if a GLES error has been raised.
|
||||
*/
|
||||
fun checkGlError(op: String) {
|
||||
val error = GLES20.glGetError()
|
||||
if (error != GLES20.GL_NO_ERROR) {
|
||||
val msg = op + ": glError 0x" + Integer.toHexString(error)
|
||||
log(TAG, msg)
|
||||
throw RuntimeException(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if the location we obtained is valid. GLES returns -1 if a label
|
||||
* could not be found, but does not set the GL error.
|
||||
*
|
||||
* Throws a RuntimeException if the location is invalid.
|
||||
*/
|
||||
fun checkLocation(location: Int, label: String) {
|
||||
if (location < 0) {
|
||||
throw RuntimeException("Unable to locate '$label' in program")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a texture from raw data.
|
||||
*
|
||||
* @param data Image data, in a "direct" ByteBuffer.
|
||||
* @param width Texture width, in pixels (not bytes).
|
||||
* @param height Texture height, in pixels.
|
||||
* @param format Image data format (use constant appropriate for glTexImage2D(), e.g. GL_RGBA).
|
||||
* @return Handle to texture.
|
||||
*/
|
||||
fun createImageTexture(data: ByteBuffer?, width: Int, height: Int, format: Int): Int {
|
||||
val textureHandles = IntArray(1)
|
||||
|
||||
GLES20.glGenTextures(1, textureHandles, 0)
|
||||
val textureHandle = textureHandles[0]
|
||||
GlUtil.checkGlError("glGenTextures")
|
||||
|
||||
// Bind the texture handle to the 2D texture target.
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle)
|
||||
|
||||
// Configure min/mag filtering, i.e. what scaling method do we use if what we're rendering
|
||||
// is smaller or larger than the source image.
|
||||
GLES20.glTexParameteri(
|
||||
GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER,
|
||||
GLES20.GL_LINEAR
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER,
|
||||
GLES20.GL_LINEAR
|
||||
)
|
||||
GlUtil.checkGlError("loadImageTexture")
|
||||
|
||||
// Load the data from the buffer into the texture handle.
|
||||
GLES20.glTexImage2D(
|
||||
GLES20.GL_TEXTURE_2D, /*level*/0, format,
|
||||
width, height, /*border*/0, format, GLES20.GL_UNSIGNED_BYTE, data
|
||||
)
|
||||
GlUtil.checkGlError("loadImageTexture")
|
||||
|
||||
return textureHandle
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a direct float buffer, and populates it with the float array data.
|
||||
*/
|
||||
fun createFloatBuffer(coords: FloatArray): FloatBuffer {
|
||||
// Allocate a direct ByteBuffer, using 4 bytes per float, and copy coords into it.
|
||||
val bb = ByteBuffer.allocateDirect(coords.size * SIZEOF_FLOAT)
|
||||
bb.order(ByteOrder.nativeOrder())
|
||||
val fb = bb.asFloatBuffer()
|
||||
fb.put(coords)
|
||||
fb.position(0)
|
||||
return fb
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes GL version info to the log.
|
||||
*/
|
||||
fun logVersionInfo() {
|
||||
log(TAG, "vendor : " + GLES20.glGetString(GLES20.GL_VENDOR))
|
||||
log(TAG, "renderer: " + GLES20.glGetString(GLES20.GL_RENDERER))
|
||||
log(TAG, "version : " + GLES20.glGetString(GLES20.GL_VERSION))
|
||||
|
||||
if (false) {
|
||||
val values = IntArray(1)
|
||||
GLES30.glGetIntegerv(GLES30.GL_MAJOR_VERSION, values, 0)
|
||||
val majorVersion = values[0]
|
||||
GLES30.glGetIntegerv(GLES30.GL_MINOR_VERSION, values, 0)
|
||||
val minorVersion = values[0]
|
||||
if (GLES30.glGetError() == GLES30.GL_NO_ERROR) {
|
||||
log(TAG, "iversion: $majorVersion.$minorVersion")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package pw.binom.viewmate.glasses.render
|
||||
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import pw.binom.viewmate.glasses.render.GlUtil.checkGlError
|
||||
import pw.binom.viewmate.glasses.render.GlUtil.checkLocation
|
||||
import pw.binom.viewmate.glasses.render.GlUtil.createProgram
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
class Texture2dProgram(private val mProgramType: ProgramType) {
|
||||
companion object {
|
||||
private const val TAG: String = GlUtil.TAG
|
||||
|
||||
// Simple vertex shader, used for all programs.
|
||||
private const val VERTEX_SHADER = "uniform mat4 uMVPMatrix;\n" +
|
||||
"uniform mat4 uTexMatrix;\n" +
|
||||
"attribute vec4 aPosition;\n" +
|
||||
"attribute vec4 aTextureCoord;\n" +
|
||||
"varying vec2 vTextureCoord;\n" +
|
||||
"void main() {\n" +
|
||||
" gl_Position = uMVPMatrix * aPosition;\n" +
|
||||
" vTextureCoord = (uTexMatrix * aTextureCoord).xy;\n" +
|
||||
"}\n"
|
||||
|
||||
// Simple fragment shader for use with "normal" 2D textures.
|
||||
private const val FRAGMENT_SHADER_2D = "precision mediump float;\n" +
|
||||
"varying vec2 vTextureCoord;\n" +
|
||||
"uniform sampler2D sTexture;\n" +
|
||||
"void main() {\n" +
|
||||
" gl_FragColor = texture2D(sTexture, vTextureCoord);\n" +
|
||||
"}\n"
|
||||
|
||||
// Simple fragment shader for use with external 2D textures (e.g. what we get from
|
||||
// SurfaceTexture).
|
||||
private const val FRAGMENT_SHADER_EXT = "#extension GL_OES_EGL_image_external : require\n" +
|
||||
"precision mediump float;\n" +
|
||||
"varying vec2 vTextureCoord;\n" +
|
||||
"uniform samplerExternalOES sTexture;\n" +
|
||||
"void main() {\n" +
|
||||
" gl_FragColor = texture2D(sTexture, vTextureCoord);\n" +
|
||||
"}\n"
|
||||
|
||||
// Fragment shader that converts color to black & white with a simple transformation.
|
||||
private val FRAGMENT_SHADER_EXT_BW = "#extension GL_OES_EGL_image_external : require\n" +
|
||||
"precision mediump float;\n" +
|
||||
"varying vec2 vTextureCoord;\n" +
|
||||
"uniform samplerExternalOES sTexture;\n" +
|
||||
"void main() {\n" +
|
||||
" vec4 tc = texture2D(sTexture, vTextureCoord);\n" +
|
||||
" float color = tc.r * 0.3 + tc.g * 0.59 + tc.b * 0.11;\n" +
|
||||
" gl_FragColor = vec4(color, color, color, 1.0);\n" +
|
||||
"}\n"
|
||||
|
||||
// Fragment shader with a convolution filter. The upper-left half will be drawn normally,
|
||||
// the lower-right half will have the filter applied, and a thin red line will be drawn
|
||||
// at the border.
|
||||
const val KERNEL_SIZE = 9
|
||||
private val FRAGMENT_SHADER_EXT_FILT = "#extension GL_OES_EGL_image_external : require\n" +
|
||||
"#define KERNEL_SIZE " + KERNEL_SIZE + "\n" +
|
||||
"precision highp float;\n" +
|
||||
"varying vec2 vTextureCoord;\n" +
|
||||
"uniform samplerExternalOES sTexture;\n" +
|
||||
"uniform float uKernel[KERNEL_SIZE];\n" +
|
||||
"uniform vec2 uTexOffset[KERNEL_SIZE];\n" +
|
||||
"uniform float uColorAdjust;\n" +
|
||||
"void main() {\n" +
|
||||
" int i = 0;\n" +
|
||||
" vec4 sum = vec4(0.0);\n" +
|
||||
" if (vTextureCoord.x < vTextureCoord.y - 0.005) {\n" +
|
||||
" for (i = 0; i < KERNEL_SIZE; i++) {\n" +
|
||||
" vec4 texc = texture2D(sTexture, vTextureCoord + uTexOffset[i]);\n" +
|
||||
" sum += texc * uKernel[i];\n" +
|
||||
" }\n" +
|
||||
" sum += uColorAdjust;\n" +
|
||||
" } else if (vTextureCoord.x > vTextureCoord.y + 0.005) {\n" +
|
||||
" sum = texture2D(sTexture, vTextureCoord);\n" +
|
||||
" } else {\n" +
|
||||
" sum.r = 1.0;\n" +
|
||||
" }\n" +
|
||||
" gl_FragColor = sum;\n" +
|
||||
"}\n"
|
||||
}
|
||||
|
||||
enum class ProgramType {
|
||||
TEXTURE_2D, TEXTURE_EXT, TEXTURE_EXT_BW, TEXTURE_EXT_FILT
|
||||
}
|
||||
|
||||
// Handles to the GL program and various components of it.
|
||||
private var mProgramHandle = 0
|
||||
private val muMVPMatrixLoc: Int
|
||||
private val muTexMatrixLoc: Int
|
||||
private var muKernelLoc = 0
|
||||
private val muTexOffsetLoc: Int
|
||||
private val muColorAdjustLoc: Int
|
||||
private val maPositionLoc: Int
|
||||
private val maTextureCoordLoc: Int
|
||||
|
||||
private val mTextureTarget: Int
|
||||
|
||||
private val mKernel = FloatArray(KERNEL_SIZE)
|
||||
private var mTexOffset: FloatArray? = null
|
||||
private var mColorAdjust = 0f
|
||||
|
||||
/**
|
||||
* Prepares the program in the current EGL context.
|
||||
*/
|
||||
init {
|
||||
when (mProgramType) {
|
||||
ProgramType.TEXTURE_2D -> {
|
||||
mTextureTarget = GLES20.GL_TEXTURE_2D
|
||||
mProgramHandle = createProgram(VERTEX_SHADER, FRAGMENT_SHADER_2D)
|
||||
}
|
||||
|
||||
ProgramType.TEXTURE_EXT -> {
|
||||
mTextureTarget = GLES11Ext.GL_TEXTURE_EXTERNAL_OES
|
||||
mProgramHandle = createProgram(VERTEX_SHADER, FRAGMENT_SHADER_EXT)
|
||||
}
|
||||
|
||||
ProgramType.TEXTURE_EXT_BW -> {
|
||||
mTextureTarget = GLES11Ext.GL_TEXTURE_EXTERNAL_OES
|
||||
mProgramHandle = createProgram(VERTEX_SHADER, FRAGMENT_SHADER_EXT_BW)
|
||||
}
|
||||
|
||||
ProgramType.TEXTURE_EXT_FILT -> {
|
||||
mTextureTarget = GLES11Ext.GL_TEXTURE_EXTERNAL_OES
|
||||
mProgramHandle = createProgram(VERTEX_SHADER, FRAGMENT_SHADER_EXT_FILT)
|
||||
}
|
||||
|
||||
else -> throw RuntimeException("Unhandled type " + mProgramType)
|
||||
}
|
||||
if (mProgramHandle == 0) {
|
||||
throw RuntimeException("Unable to create program")
|
||||
}
|
||||
log(TAG, "Created program $mProgramHandle ($mProgramType)")
|
||||
|
||||
// get locations of attributes and uniforms
|
||||
maPositionLoc = GLES20.glGetAttribLocation(mProgramHandle, "aPosition")
|
||||
checkLocation(maPositionLoc, "aPosition")
|
||||
maTextureCoordLoc = GLES20.glGetAttribLocation(mProgramHandle, "aTextureCoord")
|
||||
checkLocation(maTextureCoordLoc, "aTextureCoord")
|
||||
muMVPMatrixLoc = GLES20.glGetUniformLocation(mProgramHandle, "uMVPMatrix")
|
||||
checkLocation(muMVPMatrixLoc, "uMVPMatrix")
|
||||
muTexMatrixLoc = GLES20.glGetUniformLocation(mProgramHandle, "uTexMatrix")
|
||||
checkLocation(muTexMatrixLoc, "uTexMatrix")
|
||||
muKernelLoc = GLES20.glGetUniformLocation(mProgramHandle, "uKernel")
|
||||
if (muKernelLoc < 0) {
|
||||
// no kernel in this one
|
||||
muKernelLoc = -1
|
||||
muTexOffsetLoc = -1
|
||||
muColorAdjustLoc = -1
|
||||
} else {
|
||||
// has kernel, must also have tex offset and color adj
|
||||
muTexOffsetLoc = GLES20.glGetUniformLocation(mProgramHandle, "uTexOffset")
|
||||
checkLocation(muTexOffsetLoc, "uTexOffset")
|
||||
muColorAdjustLoc = GLES20.glGetUniformLocation(mProgramHandle, "uColorAdjust")
|
||||
checkLocation(muColorAdjustLoc, "uColorAdjust")
|
||||
|
||||
// initialize default values
|
||||
setKernel(floatArrayOf(0f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 0f), 0f)
|
||||
setTexSize(256, 256)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the program.
|
||||
*
|
||||
* The appropriate EGL context must be current (i.e. the one that was used to create
|
||||
* the program).
|
||||
*/
|
||||
fun release() {
|
||||
log(TAG, "deleting program $mProgramHandle")
|
||||
GLES20.glDeleteProgram(mProgramHandle)
|
||||
mProgramHandle = -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the program type.
|
||||
*/
|
||||
fun getProgramType() = mProgramType
|
||||
|
||||
/**
|
||||
* Creates a texture object suitable for use with this program.
|
||||
*
|
||||
* On exit, the texture will be bound.
|
||||
*/
|
||||
fun createTextureObject(): Int {
|
||||
val textures = IntArray(1)
|
||||
GLES20.glGenTextures(1, textures, 0)
|
||||
checkGlError("glGenTextures")
|
||||
|
||||
val texId = textures[0]
|
||||
GLES20.glBindTexture(mTextureTarget, texId)
|
||||
checkGlError("glBindTexture $texId")
|
||||
|
||||
GLES20.glTexParameterf(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER,
|
||||
GLES20.GL_NEAREST.toFloat()
|
||||
)
|
||||
GLES20.glTexParameterf(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER,
|
||||
GLES20.GL_LINEAR.toFloat()
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S,
|
||||
GLES20.GL_CLAMP_TO_EDGE
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T,
|
||||
GLES20.GL_CLAMP_TO_EDGE
|
||||
)
|
||||
checkGlError("glTexParameter")
|
||||
|
||||
return texId
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the convolution filter values.
|
||||
*
|
||||
* @param values Normalized filter values; must be KERNEL_SIZE elements.
|
||||
*/
|
||||
fun setKernel(values: FloatArray, colorAdj: Float) {
|
||||
require(values.size == KERNEL_SIZE) {
|
||||
"Kernel size is " + values.size +
|
||||
" vs. " + KERNEL_SIZE
|
||||
}
|
||||
System.arraycopy(values, 0, mKernel, 0, KERNEL_SIZE)
|
||||
mColorAdjust = colorAdj
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the size of the texture. This is used to find adjacent texels when filtering.
|
||||
*/
|
||||
fun setTexSize(width: Int, height: Int) {
|
||||
val rw = 1.0f / width
|
||||
val rh = 1.0f / height
|
||||
|
||||
// Don't need to create a new array here, but it's syntactically convenient.
|
||||
mTexOffset = floatArrayOf(
|
||||
-rw, -rh, 0f, -rh, rw, -rh,
|
||||
-rw, 0f, 0f, 0f, rw, 0f,
|
||||
-rw, rh, 0f, rh, rw, rh
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues the draw call. Does the full setup on every call.
|
||||
*/
|
||||
fun draw(
|
||||
mvpMatrix: FloatArray?, vertexBuffer: FloatBuffer, firstVertex: Int,
|
||||
vertexCount: Int, coordsPerVertex: Int, vertexStride: Int,
|
||||
texMatrix: FloatArray?, texBuffer: FloatBuffer, textureId: Int, texStride: Int
|
||||
) {
|
||||
checkGlError("draw start")
|
||||
|
||||
// Select the program.
|
||||
GLES20.glUseProgram(mProgramHandle)
|
||||
checkGlError("glUseProgram")
|
||||
|
||||
// Set the texture.
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(mTextureTarget, textureId)
|
||||
|
||||
// Copy the model / view / projection matrix over.
|
||||
GLES20.glUniformMatrix4fv(muMVPMatrixLoc, 1, false, mvpMatrix, 0)
|
||||
checkGlError("glUniformMatrix4fv")
|
||||
|
||||
// Copy the texture transformation matrix over.
|
||||
GLES20.glUniformMatrix4fv(muTexMatrixLoc, 1, false, texMatrix, 0)
|
||||
checkGlError("glUniformMatrix4fv")
|
||||
|
||||
// Enable the "aPosition" vertex attribute.
|
||||
GLES20.glEnableVertexAttribArray(maPositionLoc)
|
||||
checkGlError("glEnableVertexAttribArray")
|
||||
|
||||
// Connect vertexBuffer to "aPosition".
|
||||
GLES20.glVertexAttribPointer(
|
||||
maPositionLoc, coordsPerVertex,
|
||||
GLES20.GL_FLOAT, false, vertexStride, vertexBuffer
|
||||
)
|
||||
checkGlError("glVertexAttribPointer")
|
||||
|
||||
// Enable the "aTextureCoord" vertex attribute.
|
||||
GLES20.glEnableVertexAttribArray(maTextureCoordLoc)
|
||||
checkGlError("glEnableVertexAttribArray")
|
||||
|
||||
// Connect texBuffer to "aTextureCoord".
|
||||
GLES20.glVertexAttribPointer(
|
||||
maTextureCoordLoc, 2,
|
||||
GLES20.GL_FLOAT, false, texStride, texBuffer
|
||||
)
|
||||
checkGlError("glVertexAttribPointer")
|
||||
|
||||
// Populate the convolution kernel, if present.
|
||||
if (muKernelLoc >= 0) {
|
||||
GLES20.glUniform1fv(muKernelLoc, KERNEL_SIZE, mKernel, 0)
|
||||
GLES20.glUniform2fv(muTexOffsetLoc, KERNEL_SIZE, mTexOffset, 0)
|
||||
GLES20.glUniform1f(muColorAdjustLoc, mColorAdjust)
|
||||
}
|
||||
|
||||
// Draw the rect.
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, firstVertex, vertexCount)
|
||||
checkGlError("glDrawArrays")
|
||||
|
||||
// Done -- disable vertex array, texture, and program.
|
||||
GLES20.glDisableVertexAttribArray(maPositionLoc)
|
||||
GLES20.glDisableVertexAttribArray(maTextureCoordLoc)
|
||||
GLES20.glBindTexture(mTextureTarget, 0)
|
||||
GLES20.glUseProgram(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package pw.binom.viewmate.glasses.render
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.view.Surface
|
||||
|
||||
class WindowSurface : EglSurfaceBase {
|
||||
private var mSurface: Surface? = null
|
||||
private var mReleaseSurface = false
|
||||
|
||||
/**
|
||||
* Associates an EGL surface with the native window surface.
|
||||
*
|
||||
* Set releaseSurface to true if you want the Surface to be released when release() is
|
||||
* called. This is convenient, but can interfere with framework classes that expect to
|
||||
* manage the Surface themselves (e.g. if you release a SurfaceView's Surface, the
|
||||
* surfaceDestroyed() callback won't fire).
|
||||
*/
|
||||
constructor(eglCore: EglCore, surface: Surface, releaseSurface: Boolean) : super(eglCore) {
|
||||
createWindowSurface(surface)
|
||||
mSurface = surface
|
||||
mReleaseSurface = releaseSurface
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates an EGL surface with the SurfaceTexture.
|
||||
*/
|
||||
constructor(eglCore: EglCore, surfaceTexture: SurfaceTexture) : super(eglCore) {
|
||||
createWindowSurface(surfaceTexture)
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases any resources associated with the EGL surface (and, if configured to do so,
|
||||
* with the Surface as well).
|
||||
*
|
||||
* Does not require that the surface's EGL context be current.
|
||||
*/
|
||||
fun release() {
|
||||
releaseEglSurface()
|
||||
val mSurface = mSurface
|
||||
if (mSurface != null) {
|
||||
if (mReleaseSurface) {
|
||||
mSurface.release()
|
||||
}
|
||||
this.mSurface = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate the EGLSurface, using the new EglBase. The caller should have already
|
||||
* freed the old EGLSurface with releaseEglSurface().
|
||||
*
|
||||
* This is useful when we want to update the EGLSurface associated with a Surface.
|
||||
* For example, if we want to share with a different EGLContext, which can only
|
||||
* be done by tearing down and recreating the context. (That's handled by the caller;
|
||||
* this just creates a new EGLSurface for the Surface we were handed earlier.)
|
||||
*
|
||||
* If the previous EGLSurface isn't fully destroyed, e.g. it's still current on a
|
||||
* context somewhere, the create call will fail with complaints from the Surface
|
||||
* about already being connected.
|
||||
*/
|
||||
fun recreate(newEglCore: EglCore) {
|
||||
val mSurface = mSurface
|
||||
checkNotNull(mSurface) { "not yet implemented for SurfaceTexture" }
|
||||
mEglCore = newEglCore // switch to new context
|
||||
createWindowSurface(mSurface) // create new surface
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.GLES20
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import pw.binom.viewmate.glasses.render.EglCore
|
||||
import pw.binom.viewmate.glasses.render.FullFrameRect
|
||||
import pw.binom.viewmate.glasses.render.Texture2dProgram
|
||||
import pw.binom.viewmate.glasses.render.WindowSurface
|
||||
|
||||
/**
|
||||
* Бинокулярный видеорендер: один [SimpleExoPlayer] → SurfaceTexture → EGL-блит
|
||||
* в несколько выводов. Внутри [Binocular] content вызывается ДВАЖДЫ (левый/правый
|
||||
* глаз), поэтому [EglVideoRenderer] создаётся ОДИН на экран и шарится между
|
||||
* двумя экземплярами [BinocularVideoPlayer] — иначе второй setVideoSurface
|
||||
* перезаписал бы первый.
|
||||
*/
|
||||
@Composable
|
||||
fun BinocularVideoPlayer(
|
||||
player: SimpleExoPlayer,
|
||||
renderer: EglVideoRenderer,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
// Два SurfaceView на экземпляр (левый/правый глаз). Все они
|
||||
// регистрируются в общем рендерере и получают один и тот же кадр.
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
SurfaceView(context).also { sv ->
|
||||
sv.holder.addCallback(renderer.createCallback())
|
||||
}
|
||||
}
|
||||
)
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
SurfaceView(context).also { sv ->
|
||||
sv.holder.addCallback(renderer.createCallback())
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Общее GL-состояние: один EglCore + FullFrameRect + SurfaceTexture для плеера,
|
||||
* кадр дублируется во все готовые выводы (окна поверхностей).
|
||||
*
|
||||
* Всё создаётся на главном потоке (SurfaceHolder.Callback и OnFrameAvailableListener),
|
||||
* поэтому защита от гонок — null-проверки: кадр может прийти между созданиями выводов.
|
||||
*/
|
||||
class EglVideoRenderer(private val player: SimpleExoPlayer) {
|
||||
|
||||
private var eglCore: EglCore? = null
|
||||
private var fullFrameBlit: FullFrameRect? = null
|
||||
private var textureId = 0
|
||||
private var videoSurfaceTexture: SurfaceTexture? = null
|
||||
private val transformMatrix = FloatArray(16)
|
||||
private var playerSurface: Surface? = null
|
||||
|
||||
private val outputs = mutableListOf<Output>()
|
||||
|
||||
private class Output(
|
||||
val holder: SurfaceHolder,
|
||||
val surface: WindowSurface,
|
||||
) {
|
||||
var width = 0
|
||||
var height = 0
|
||||
}
|
||||
|
||||
private var released = false
|
||||
|
||||
private val frameAvailableListener = SurfaceTexture.OnFrameAvailableListener { _ ->
|
||||
if (released) return@OnFrameAvailableListener
|
||||
val st = videoSurfaceTexture
|
||||
val blit = fullFrameBlit
|
||||
if (st == null || blit == null) return@OnFrameAvailableListener
|
||||
|
||||
st.updateTexImage()
|
||||
st.getTransformMatrix(transformMatrix)
|
||||
|
||||
outputs.forEach { output ->
|
||||
if (output.width > 0 && output.height > 0) {
|
||||
drawFrame(blit, output.surface, output.width, output.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Колбэк для очередного SurfaceView (каждая половина экрана регистрирует свой вывод). */
|
||||
fun createCallback(): SurfaceHolder.Callback = object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) = attach(holder)
|
||||
|
||||
override fun surfaceChanged(
|
||||
holder: SurfaceHolder, format: Int, width: Int, height: Int
|
||||
) {
|
||||
outputs.firstOrNull { it.holder === holder }?.let {
|
||||
it.width = width
|
||||
it.height = height
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
val index = outputs.indexOfFirst { it.holder === holder }
|
||||
if (index >= 0) {
|
||||
val output = outputs.removeAt(index)
|
||||
output.surface.release()
|
||||
log("glasses", "EglVideoRenderer: вывод удалён")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attach(holder: SurfaceHolder) {
|
||||
if (released) return
|
||||
val egl = eglCore ?: EglCore().also { eglCore = it }
|
||||
val ws = WindowSurface(egl, holder.surface, false)
|
||||
outputs.add(Output(holder, ws))
|
||||
|
||||
if (fullFrameBlit == null) {
|
||||
// Первый вывод: создаём общий GL-стэйт, текущий контекст — его.
|
||||
ws.makeCurrent()
|
||||
fullFrameBlit = FullFrameRect(Texture2dProgram(Texture2dProgram.ProgramType.TEXTURE_EXT))
|
||||
textureId = fullFrameBlit!!.createTextureObject()
|
||||
videoSurfaceTexture = SurfaceTexture(textureId).also {
|
||||
it.setOnFrameAvailableListener(frameAvailableListener)
|
||||
}
|
||||
playerSurface = Surface(videoSurfaceTexture).also {
|
||||
player.setVideoSurface(it)
|
||||
}
|
||||
log("glasses", "EglVideoRenderer: GL-стэйт создан, плеер привязан к SurfaceTexture")
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawFrame(blit: FullFrameRect, windowSurface: WindowSurface, viewWidth: Int, viewHeight: Int) {
|
||||
windowSurface.makeCurrent()
|
||||
GLES20.glViewport(0, 0, viewWidth, viewHeight)
|
||||
blit.drawFrame(textureId, transformMatrix)
|
||||
windowSurface.swapBuffers()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (released) return
|
||||
released = true
|
||||
log("glasses", "EglVideoRenderer: release")
|
||||
|
||||
// Отвязать плеер (плеер мог быть уже освобождён — не падаем).
|
||||
val ps = playerSurface
|
||||
if (ps != null) {
|
||||
try {
|
||||
player.setVideoSurface(null)
|
||||
} catch (e: Throwable) {
|
||||
log("glasses", "setVideoSurface(null) пропущен: ${e.message}")
|
||||
}
|
||||
ps.release()
|
||||
playerSurface = null
|
||||
}
|
||||
|
||||
videoSurfaceTexture?.release()
|
||||
videoSurfaceTexture = null
|
||||
|
||||
outputs.forEach { it.surface.release() }
|
||||
outputs.clear()
|
||||
|
||||
fullFrameBlit?.release(false)
|
||||
fullFrameBlit = null
|
||||
|
||||
eglCore?.release()
|
||||
eglCore = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import com.google.android.exoplayer2.video.VideoSize
|
||||
import pw.binom.viewmate.glasses.log
|
||||
|
||||
/**
|
||||
* Экран кино: один плеер, вывод — дважды (левый/правый глаз) через [Binocular].
|
||||
* «Дубль» на эмуляторе 1280×480 — ожидаемое поведение.
|
||||
*/
|
||||
@Composable
|
||||
fun GlassesMovieScreen(
|
||||
player: SimpleExoPlayer,
|
||||
title: String?,
|
||||
) {
|
||||
log("glasses", "GlassesMovieScreen: ${title ?: "без названия"}")
|
||||
|
||||
val context = LocalContext.current
|
||||
val window = (context as Activity).window
|
||||
DisposableEffect(window) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
onDispose {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
var videoWidth by remember { mutableIntStateOf(640) }
|
||||
var videoHeight by remember { mutableIntStateOf(480) }
|
||||
|
||||
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
|
||||
// глаз из Binocular) регистрируют в нём свои SurfaceView.
|
||||
val renderer = remember { EglVideoRenderer(player) }
|
||||
|
||||
DisposableEffect(player) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (videoSize.width > 0 && videoSize.height > 0) {
|
||||
log("glasses", "videoSize: ${videoSize.width}x${videoSize.height}")
|
||||
videoWidth = videoSize.width
|
||||
videoHeight = videoSize.height
|
||||
}
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
onDispose {
|
||||
player.removeListener(listener)
|
||||
renderer.release()
|
||||
}
|
||||
}
|
||||
|
||||
Binocular(scale = true) {
|
||||
val screenWidthPx = BinocularScreenSize.current.width
|
||||
val screenHeightPx = BinocularScreenSize.current.height
|
||||
|
||||
val (playerWidth, playerHeight) = calculateVideoSizeFull(
|
||||
videoWidth = videoWidth,
|
||||
videoHeight = videoHeight,
|
||||
maxWidth = screenWidthPx, // padding = 0
|
||||
maxHeight = screenHeightPx - 25,
|
||||
)
|
||||
|
||||
// «Нос к носу»: окно каждого глаза прижато к центру экрана.
|
||||
val horizontalArrangement = when (BinocularScreenSide.current) {
|
||||
Side.LEFT -> Arrangement.End
|
||||
Side.RIGHT -> Arrangement.Start
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(Color.Black)
|
||||
.width((screenWidthPx).px),
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
) {
|
||||
BinocularVideoPlayer(
|
||||
player = player,
|
||||
renderer = renderer,
|
||||
modifier = Modifier.size(playerWidth.px, playerHeight.px),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fit с сохранением пропорций: вариант по ширине/по высоте, берём помещающийся. */
|
||||
fun calculateVideoSizeFull(
|
||||
videoWidth: Int, videoHeight: Int,
|
||||
maxWidth: Int, maxHeight: Int,
|
||||
paddingStart: Int = 0, paddingEnd: Int = 0,
|
||||
paddingTop: Int = 0, paddingBottom: Int = 0
|
||||
): Pair<Int, Int> {
|
||||
val availableWidth = maxWidth - (paddingStart + paddingEnd)
|
||||
val availableHeight = maxHeight - (paddingTop + paddingBottom)
|
||||
val aspectRatio = videoWidth.toFloat() / videoHeight.toFloat()
|
||||
|
||||
// Вариант: ширина по максимальной ширине
|
||||
val widthByWidth = availableWidth
|
||||
val heightByWidth = (widthByWidth / aspectRatio).toInt()
|
||||
// Вариант: высота по максимальной высоте
|
||||
val heightByHeight = availableHeight
|
||||
val widthByHeight = (heightByHeight * aspectRatio).toInt()
|
||||
|
||||
// Выбираем вариант, который помещается целиком
|
||||
return if (heightByWidth <= availableHeight) {
|
||||
Pair(widthByWidth, heightByWidth)
|
||||
} else {
|
||||
Pair(widthByHeight, heightByHeight)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/** Перевод пикселей (например, из расчёта размера видео) в Dp текущей плотности. */
|
||||
@get:Composable
|
||||
val Int.px: Dp
|
||||
get() = with(LocalDensity.current) { this@px.toDp() }
|
||||
@@ -0,0 +1,161 @@
|
||||
package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
enum class Side {
|
||||
LEFT, RIGHT,
|
||||
}
|
||||
|
||||
val BinocularScreenSide = staticCompositionLocalOf<Side> {
|
||||
error("Rayneo Layout not active")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> ProvidableCompositionLocal<T>.getValue(thisRef: Any?, property: KProperty<*>): T = current
|
||||
|
||||
data class ScreenSize(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
val BinocularScreenSize = staticCompositionLocalOf<ScreenSize> {
|
||||
error("Rayneo Layout not active")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BinocularSize(scale: Boolean, func: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val screenWidthPx =
|
||||
remember { (context as Activity).windowManager.currentWindowMetrics.bounds.width() }
|
||||
val screenHeightPx =
|
||||
remember { (context as Activity).windowManager.currentWindowMetrics.bounds.height() }
|
||||
if (scale) {
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSize provides ScreenSize(
|
||||
width = screenWidthPx,
|
||||
height = screenHeightPx,
|
||||
)
|
||||
) {
|
||||
func()
|
||||
}
|
||||
} else {
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSize provides ScreenSize(
|
||||
width = screenWidthPx / 2,
|
||||
height = screenHeightPx,
|
||||
)
|
||||
) {
|
||||
func()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("UnusedBoxWithConstraintsScope")
|
||||
fun Binocular(
|
||||
modifier: Modifier = Modifier,
|
||||
eachModifier: Modifier = Modifier,
|
||||
leftModifier: Modifier = Modifier,
|
||||
rightModifier: Modifier = Modifier,
|
||||
leftRotation: Float = 0f,
|
||||
rightRotation: Float = 0f,
|
||||
scale: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BinocularSize(scale) {
|
||||
if (scale) {
|
||||
BoxWithConstraints {
|
||||
val parentWidth = constraints.maxWidth
|
||||
PositionedBox(
|
||||
x = 0, y = 0, modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = 0.5f
|
||||
transformOrigin = TransformOrigin(0f, 0.5f)
|
||||
}
|
||||
.graphicsLayer {
|
||||
transformOrigin = TransformOrigin.Center
|
||||
rotationZ = leftRotation
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSide provides Side.LEFT
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
PositionedBox(
|
||||
x = (parentWidth),
|
||||
y = 0,
|
||||
modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = 0.5f
|
||||
transformOrigin = TransformOrigin(0f, 0.5f)
|
||||
}
|
||||
.graphicsLayer {
|
||||
transformOrigin = TransformOrigin.Center
|
||||
rotationZ = rightRotation
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSide provides Side.RIGHT
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(modifier = modifier.focusable()) {
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSide provides Side.LEFT
|
||||
) {
|
||||
Column(
|
||||
modifier = eachModifier
|
||||
.weight(1f, true)
|
||||
.then(leftModifier)
|
||||
) { content() }
|
||||
}
|
||||
// Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(color= Color.Green)) { Text("") }
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSide provides Side.RIGHT
|
||||
) {
|
||||
Column(
|
||||
modifier = eachModifier
|
||||
.weight(1f, true)
|
||||
.then(rightModifier)
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PositionedBox(
|
||||
x: Int,
|
||||
y: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Layout(content = content, modifier = modifier) { measurables, constraints ->
|
||||
val placeable = measurables.first().measure(constraints)
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
placeable.placeRelative(x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user