diff --git a/android/filament-utils-android/src/main/cpp/DeviceUtils.cpp b/android/filament-utils-android/src/main/cpp/DeviceUtils.cpp index 1b98e61c8b12..81d99b3c2b89 100644 --- a/android/filament-utils-android/src/main/cpp/DeviceUtils.cpp +++ b/android/filament-utils-android/src/main/cpp/DeviceUtils.cpp @@ -30,8 +30,8 @@ using namespace filament; namespace { constexpr std::array VULKAN_INFO = { - backend::Platform::DeviceInfoType::VULKAN_DEVICE_NAME, backend::Platform::DeviceInfoType::VULKAN_DRIVER_NAME, + backend::Platform::DeviceInfoType::VULKAN_DEVICE_NAME, backend::Platform::DeviceInfoType::VULKAN_DRIVER_INFO, }; diff --git a/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ModelViewer.kt b/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ModelViewer.kt index 81d1f7ef5541..cbd3cfc6ae45 100644 --- a/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ModelViewer.kt +++ b/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ModelViewer.kt @@ -390,36 +390,43 @@ class ModelViewer( view.addOnAttachStateChangeListener(object : android.view.View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: android.view.View) {} override fun onViewDetachedFromWindow(v: android.view.View) { - uiHelper.detach() + destroy() + } + }) + } - destroyModel() - assetLoader.destroy() - materialProvider.destroyMaterials() - materialProvider.destroy() - resourceLoader.destroy() + /** + * Explicitly destroys the ModelViewer and its underlying Filament engine and resources. + */ + fun destroy() { + uiHelper.detach() - if (indirectLightCubemap != null) { - engine.destroyTexture(indirectLightCubemap!!) - indirectLightCubemap = null - } + destroyModel() + assetLoader.destroy() + materialProvider.destroyMaterials() + materialProvider.destroy() + resourceLoader.destroy() + + if (indirectLightCubemap != null) { + engine.destroyTexture(indirectLightCubemap!!) + indirectLightCubemap = null + } - if (skyboxCubemap != null) { - engine.destroyTexture(skyboxCubemap!!) - skyboxCubemap = null - } + if (skyboxCubemap != null) { + engine.destroyTexture(skyboxCubemap!!) + skyboxCubemap = null + } - engine.destroyEntity(light) - engine.destroyRenderer(renderer) - engine.destroyView(this@ModelViewer.view) - engine.destroyScene(scene) - engine.destroyCameraComponent(camera.entity) - EntityManager.get().destroy(camera.entity) + engine.destroyEntity(light) + engine.destroyRenderer(renderer) + engine.destroyView(this@ModelViewer.view) + engine.destroyScene(scene) + engine.destroyCameraComponent(camera.entity) + EntityManager.get().destroy(camera.entity) - EntityManager.get().destroy(light) + EntityManager.get().destroy(light) - engine.destroy() - } - }) + engine.destroy() } /** diff --git a/android/samples/sample-render-validation/src/main/assets/default_test.json b/android/samples/sample-render-validation/src/main/assets/default_test.json index 11e318422d08..25d6a3cf1e81 100644 --- a/android/samples/sample-render-validation/src/main/assets/default_test.json +++ b/android/samples/sample-render-validation/src/main/assets/default_test.json @@ -1,7 +1,8 @@ { "name": "Default Test", "backends": [ - "opengl" + "opengl", + "vulkan" ], "models": { "DamagedHelmet": "helmet.glb" diff --git a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/MainActivity.kt b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/MainActivity.kt index 44c7d595c174..c17d4f5aa488 100644 --- a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/MainActivity.kt +++ b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/MainActivity.kt @@ -59,22 +59,23 @@ class MainActivity : Activity(), ValidationRunner.Callback { private lateinit var surfaceView: SurfaceView private lateinit var choreographer: Choreographer - private lateinit var modelViewer: ModelViewer private lateinit var statusTextView: TextView private lateinit var testResultsHeader: TextView + private lateinit var testProgress: android.widget.ProgressBar private lateinit var resultsContainer: LinearLayout private lateinit var inputManager: ValidationInputManager private var currentInput: ValidationInputManager.ValidationInput? = null private var currentAlphaDiffBitmap: Bitmap? = null private var globalEnhancementFactor: Float = 1.0f + private var modelViewer: ModelViewer? = null private data class TestImages( val testName: String, - val golden: Bitmap?, - val rendered: Bitmap?, - val diff: Bitmap?, - val alphaDiff: Bitmap? + val golden: File?, + val rendered: File?, + val diff: File?, + val alphaDiff: File? ) private val diffImageViews = mutableListOf() @@ -84,6 +85,8 @@ class MainActivity : Activity(), ValidationRunner.Callback { private lateinit var loadButton: Button private lateinit var optionsButton: Button private lateinit var enhancementContainer: LinearLayout + private lateinit var backendFilterContainer: LinearLayout + private lateinit var backendRadioGroup: android.widget.RadioGroup private lateinit var enhancementLabel: TextView private lateinit var enhancementSlider: android.widget.SeekBar @@ -94,7 +97,7 @@ class MainActivity : Activity(), ValidationRunner.Callback { private val frameScheduler = object : Choreographer.FrameCallback { override fun doFrame(frameTimeNanos: Long) { choreographer.postFrameCallback(this) - modelViewer.render(frameTimeNanos) + modelViewer?.render(frameTimeNanos) validationRunner?.onFrame(frameTimeNanos) } } @@ -109,12 +112,15 @@ class MainActivity : Activity(), ValidationRunner.Callback { statusTextView = findViewById(R.id.status_text) testResultsHeader = findViewById(R.id.test_results_header) + testProgress = findViewById(R.id.test_progress) resultsContainer = findViewById(R.id.results_container) runButton = findViewById(R.id.run_button) loadButton = findViewById(R.id.load_button) optionsButton = findViewById(R.id.options_button) enhancementContainer = findViewById(R.id.enhancement_container) + backendFilterContainer = findViewById(R.id.backend_filter_container) + backendRadioGroup = findViewById(R.id.backend_radio_group) enhancementLabel = findViewById(R.id.enhancement_label) enhancementSlider = findViewById(R.id.enhancement_slider) @@ -150,6 +156,7 @@ class MainActivity : Activity(), ValidationRunner.Callback { popup.menu.add(0, 4, 0, "Test ADB Info") popup.menu.add(0, 5, 0, "Result ADB Info") popup.menu.add(0, 6, 0, "Toggle Enhancement Slider") + popup.menu.add(0, 7, 0, "Toggle Backend Filter") popup.setOnMenuItemClickListener { item -> when (item.itemId) { @@ -166,6 +173,9 @@ class MainActivity : Activity(), ValidationRunner.Callback { 6 -> { enhancementContainer.visibility = if (enhancementContainer.visibility == View.VISIBLE) View.GONE else View.VISIBLE } + 7 -> { + backendFilterContainer.visibility = if (backendFilterContainer.visibility == View.VISIBLE) View.GONE else View.VISIBLE + } } true } @@ -175,12 +185,8 @@ class MainActivity : Activity(), ValidationRunner.Callback { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) choreographer = Choreographer.getInstance() - modelViewer = ModelViewer(surfaceView=surfaceView, manipulator=null) inputManager = ValidationInputManager(this) - // Initialize IBL - createIndirectLight() - handleIntent() } @@ -315,29 +321,31 @@ class MainActivity : Activity(), ValidationRunner.Callback { private fun createIndirectLight() { try { - val engine = modelViewer.engine - val scene = modelViewer.scene - val iblName = "default_env" - - fun readAsset(path: String): ByteBuffer { - val input = assets.open(path) - val bytes = input.readBytes() - return ByteBuffer.wrap(bytes) - } + modelViewer?.let { mv -> + val engine = mv.engine + val scene = mv.scene + val iblName = "default_env" + + fun readAsset(path: String): ByteBuffer { + val input = assets.open(path) + val bytes = input.readBytes() + return ByteBuffer.wrap(bytes) + } - readAsset("envs/$iblName/${iblName}_ibl.ktx").let { - val bundle = KTX1Loader.createIndirectLight(engine, it) - scene.indirectLight = bundle.indirectLight - modelViewer.indirectLightCubemap = bundle.cubemap - scene.indirectLight!!.intensity = 30_000.0f - } + readAsset("envs/$iblName/${iblName}_ibl.ktx").let { + val bundle = KTX1Loader.createIndirectLight(engine, it) + scene.indirectLight = bundle.indirectLight + mv.indirectLightCubemap = bundle.cubemap + scene.indirectLight!!.intensity = 30_000.0f + } - readAsset("envs/$iblName/${iblName}_skybox.ktx").let { - val bundle = KTX1Loader.createSkybox(engine, it) - scene.skybox = bundle.skybox - modelViewer.skyboxCubemap = bundle.cubemap + readAsset("envs/$iblName/${iblName}_skybox.ktx").let { + val bundle = KTX1Loader.createSkybox(engine, it) + scene.skybox = bundle.skybox + mv.skyboxCubemap = bundle.cubemap + } + Log.i(TAG, "IBL loaded successfully") } - Log.i(TAG, "IBL loaded successfully") } catch (e: Exception) { Log.e(TAG, "Failed to load IBL", e) statusTextView.text = "Warning: Failed to load IBL" @@ -369,10 +377,8 @@ class MainActivity : Activity(), ValidationRunner.Callback { } private fun createResultManager(outputDir: File): ValidationResultManager { - val gpuDriverInfo = com.google.android.filament.utils.DeviceUtils.getGpuDriverInfo(modelViewer.engine) return ValidationResultManager( outputDir = outputDir, - gpuDriverInfo = gpuDriverInfo, deviceName = android.os.Build.MODEL, deviceCodeName = android.os.Build.DEVICE, androidVersion = android.os.Build.VERSION.RELEASE, @@ -389,10 +395,24 @@ class MainActivity : Activity(), ValidationRunner.Callback { Log.i(TAG, "Output dir: ${input.outputDir.absolutePath}") testResultsHeader.text = "${input.config.name}" + testProgress.visibility = View.VISIBLE + testProgress.progress = 0 resultManager = createResultManager(input.outputDir) - validationRunner = ValidationRunner(this, modelViewer, input.config, resultManager!!) + val backendFilter = when (backendRadioGroup.checkedRadioButtonId) { + R.id.radio_gles -> "gles" + R.id.radio_vulkan -> "vulkan" + else -> "both" + } + + // If we are starting another run, we need to clean up the previous runner's + // resources before we can proceed. + validationRunner?.let { + it.cleanup() + } + + validationRunner = ValidationRunner(this, surfaceView, input.config, resultManager!!, backendFilter) validationRunner?.callback = this validationRunner?.generateGoldens = input.generateGoldens validationRunner?.start() @@ -403,6 +423,14 @@ class MainActivity : Activity(), ValidationRunner.Callback { } } + override fun onModelViewerRecreated(modelViewer: ModelViewer?) { + runOnUiThread { + this.modelViewer = modelViewer + // Re-apply IBL to the new engine/scene + createIndirectLight() + } + } + override fun onResume() { super.onResume() choreographer.postFrameCallback(frameScheduler) @@ -468,12 +496,18 @@ class MainActivity : Activity(), ValidationRunner.Callback { val imagesRow = LinearLayout(this) imagesRow.orientation = LinearLayout.HORIZONTAL + val outDir = resultManager!!.getOutputDir() + val renderedFile = File(outDir, "${result.testName}.png") + val diffFile = File(outDir, "${result.testName}_diff.png") + val alphaDiffFile = File(outDir, "${result.testName}_alpha_diff.png") + val goldenFile = result.goldenPath?.let { File(it) } + val testImages = TestImages( testName = result.testName, - golden = currentGoldenBitmap, - rendered = currentRenderedBitmap, - diff = currentDiffBitmap, - alphaDiff = currentAlphaDiffBitmap + golden = goldenFile?.takeIf { currentGoldenBitmap != null }, + rendered = renderedFile.takeIf { currentRenderedBitmap != null }, + diff = diffFile.takeIf { currentDiffBitmap != null }, + alphaDiff = alphaDiffFile.takeIf { currentAlphaDiffBitmap != null } ) fun addImage(label: String, bitmap: Bitmap?, isDiff: Boolean) { @@ -528,8 +562,16 @@ class MainActivity : Activity(), ValidationRunner.Callback { } } + override fun onTestProgress(current: Int, total: Int) { + runOnUiThread { + testProgress.max = total + testProgress.progress = current + } + } + override fun onAllTestsFinished() { runOnUiThread { + testProgress.visibility = View.GONE statusTextView.text = "All tests finished!" enhancementSlider.isEnabled = true Log.i(TAG, "All tests finished " + if (currentInput?.autoExport == true) "Exporting bundle" else "x") @@ -582,20 +624,24 @@ class MainActivity : Activity(), ValidationRunner.Callback { } override fun onImageResult(type: String, bitmap: Bitmap) { + // Create a scaled-down thumbnail (e.g. 128x128) to save memory in the UI scroll view. + // We use true for filter to smooth the scaling. + val scaledBitmap = Bitmap.createScaledBitmap(bitmap, 128, 128, true) + runOnUiThread { // Update the "live" views when (type) { "Rendered" -> { - currentRenderedBitmap = bitmap + currentRenderedBitmap = scaledBitmap } "Golden" -> { - currentGoldenBitmap = bitmap + currentGoldenBitmap = scaledBitmap } "Diff" -> { - currentDiffBitmap = bitmap + currentDiffBitmap = scaledBitmap } "Alpha Diff" -> { - currentAlphaDiffBitmap = bitmap + currentAlphaDiffBitmap = scaledBitmap } } } @@ -633,17 +679,23 @@ class MainActivity : Activity(), ValidationRunner.Callback { titleView.text = images.testName - val availableImages = mutableListOf>() - images.rendered?.let { availableImages.add(Pair("Rendered", it)) } - images.golden?.let { availableImages.add(Pair("Golden", it)) } - images.diff?.let { availableImages.add(Pair("Diff", it)) } - images.alphaDiff?.let { availableImages.add(Pair("Alpha Diff", it)) } + val availableFiles = mutableListOf>() + images.rendered?.takeIf { it.exists() }?.let { availableFiles.add(Pair("Rendered", it)) } + images.golden?.takeIf { it.exists() }?.let { availableFiles.add(Pair("Golden", it)) } + images.diff?.takeIf { it.exists() }?.let { availableFiles.add(Pair("Diff", it)) } + images.alphaDiff?.takeIf { it.exists() }?.let { availableFiles.add(Pair("Alpha Diff", it)) } - if (availableImages.isEmpty()) return + if (availableFiles.isEmpty()) return - var currentIndex = availableImages.indexOfFirst { it.first == initialLabel } + var currentIndex = availableFiles.indexOfFirst { it.first == initialLabel } if (currentIndex == -1) currentIndex = 0 + var currentDialogBitmap: Bitmap? = null + dialog.setOnDismissListener { + currentDialogBitmap?.recycle() + currentDialogBitmap = null + } + var currentDialogEnhancement = globalEnhancementFactor val matrix = android.graphics.Matrix() @@ -686,9 +738,13 @@ class MainActivity : Activity(), ValidationRunner.Callback { } fun updateView() { - val (label, bitmap) = availableImages[currentIndex] + val (label, file) = availableFiles[currentIndex] typeView.text = label - imageView.setImageBitmap(bitmap) + + currentDialogBitmap?.recycle() + currentDialogBitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath) + + imageView.setImageBitmap(currentDialogBitmap) (imageView.drawable as? android.graphics.drawable.BitmapDrawable)?.setAntiAlias(false) (imageView.drawable as? android.graphics.drawable.BitmapDrawable)?.setFilterBitmap(false) imageView.imageMatrix = matrix @@ -706,8 +762,11 @@ class MainActivity : Activity(), ValidationRunner.Callback { val drawable = imageView.drawable ?: return val width = imageView.width.toFloat() val height = imageView.height.toFloat() + if (width <= 0f || height <= 0f) return + val dw = drawable.intrinsicWidth.toFloat() val dh = drawable.intrinsicHeight.toFloat() + if (dw <= 0f || dh <= 0f) return val scaleX = width / dw val scaleY = height / dh @@ -722,14 +781,20 @@ class MainActivity : Activity(), ValidationRunner.Callback { imageView.imageMatrix = matrix } + imageView.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> + if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { + resetMatrix() + } + } + btnClose.setOnClickListener { dialog.dismiss() } btnReset.setOnClickListener { resetMatrix() } btnPrev.setOnClickListener { - currentIndex = (currentIndex - 1 + availableImages.size) % availableImages.size + currentIndex = (currentIndex - 1 + availableFiles.size) % availableFiles.size updateView() } btnNext.setOnClickListener { - currentIndex = (currentIndex + 1) % availableImages.size + currentIndex = (currentIndex + 1) % availableFiles.size updateView() } @@ -748,10 +813,6 @@ class MainActivity : Activity(), ValidationRunner.Callback { override fun onStopTrackingTouch(seekBar: android.widget.SeekBar?) {} }) - imageView.post { - resetMatrix() - } - updateView() dialog.show() } diff --git a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/TestConfig.kt b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/TestConfig.kt index e145e5e7ae23..9b044f0d59fa 100644 --- a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/TestConfig.kt +++ b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/TestConfig.kt @@ -31,7 +31,7 @@ data class RenderTestConfig( data class TestConfig( val name: String, val description: String?, - val backends: List, + val backend: String, val models: Set, val rendering: JSONObject, val tolerance: JSONObject? @@ -102,7 +102,7 @@ class ConfigParser { val testsJson = json.getJSONArray("tests") val tests = mutableListOf() for (i in 0 until testsJson.length()) { - tests.add(parseTestConfig(testsJson.getJSONObject(i), models.keys, presets, backends)) + tests.addAll(parseTestConfig(testsJson.getJSONObject(i), models.keys, presets, backends)) } return RenderTestConfig(name, backends, models, tests) @@ -125,7 +125,7 @@ class ConfigParser { existingModels: Set, presets: Map, defaultBackends: List - ): TestConfig { + ): List { val name = json.getString("name") val description = json.optString("description") val backends = json.optJSONArray("backends")?.toList() ?: defaultBackends @@ -165,7 +165,9 @@ class ConfigParser { val tolerance = json.optJSONObject("tolerance") ?: lastTolerance - return TestConfig(name, description, backends, combinedModels, rendering, tolerance) + return backends.map { backend -> + TestConfig(name, description, backend, combinedModels, rendering, tolerance) + } } } } diff --git a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationResultManager.kt b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationResultManager.kt index 2b808f929489..9c8e76511618 100644 --- a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationResultManager.kt +++ b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationResultManager.kt @@ -28,12 +28,12 @@ import org.json.JSONObject data class ValidationResult( val testName: String, val passed: Boolean, - val diffMetric: Float = 0f + val diffMetric: Float = 0f, + val goldenPath: String? = null ) class ValidationResultManager( private val outputDir: File, - private val gpuDriverInfo: String, private val deviceName: String, private val deviceCodeName: String, private val androidVersion: String, @@ -45,6 +45,7 @@ class ValidationResultManager( } private val results = mutableListOf() + private val gpuDriverInfos = JSONObject() init { if (!outputDir.exists()) { @@ -52,6 +53,10 @@ class ValidationResultManager( } } + fun addGpuDriverInfo(backend: String, info: String) { + gpuDriverInfos.put(backend, info) + } + fun addResult(result: ValidationResult) { results.add(result) } @@ -235,7 +240,7 @@ class ValidationResultManager( // Backends (optional override) val testBackends = JSONArray() - test.backends.forEach { testBackends.put(it) } + testBackends.put(test.backend) testJson.put("backends", testBackends) testsArray.put(testJson) @@ -277,7 +282,7 @@ class ValidationResultManager( val rootObject = JSONObject() val metadataObject = JSONObject() - metadataObject.put("gpu_driver_info", gpuDriverInfo ?: "") + metadataObject.put("gpu_driver_info", gpuDriverInfos) metadataObject.put("total_time_ms", totalTimeMs) metadataObject.put("device_name", deviceName ?: "") metadataObject.put("device_code_name", deviceCodeName ?: "") diff --git a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationRunner.kt b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationRunner.kt index 53fa4baf6873..526da844c2b8 100644 --- a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationRunner.kt +++ b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationRunner.kt @@ -29,9 +29,10 @@ import java.nio.ByteBuffer class ValidationRunner( private val context: Context, - private val modelViewer: ModelViewer, + private val surfaceView: android.view.SurfaceView, private val config: RenderTestConfig, - private val resultManager: ValidationResultManager + private val resultManager: ValidationResultManager, + private val backendFilter: String = "both" // "gles", "vulkan", or "both" ) { private var currentState = State.IDLE @@ -40,9 +41,32 @@ class ValidationRunner( private var currentEngine: AutomationEngine? = null private var currentTestConfig: TestConfig? = null private var currentModelName: String? = null + private var currentBackend: String? = null private var frameCounter = 0 private var suiteStartTime: Long = 0 + private var modelViewer: ModelViewer? = null + + private val sortedTests: List> by lazy { + val filtered = config.tests.filter { + when (backendFilter) { + "gles" -> it.backend == "opengl" + "vulkan" -> it.backend == "vulkan" + else -> true + } + } + + val expanded = mutableListOf>() + filtered.forEach { test -> + test.models.forEach { model -> + expanded.add(test to model) + } + } + + // Sort by backend then model. This tries to minimize the number of times we need to + // recreate the Engine, and then the ModelViewer. + expanded.sortedWith(compareBy({ it.first.backend }, { it.second })) + } enum class State { IDLE, @@ -52,42 +76,79 @@ class ValidationRunner( } interface Callback { + fun onTestProgress(current: Int, total: Int) fun onTestFinished(result: ValidationResult) fun onAllTestsFinished() fun onStatusChanged(status: String) fun onImageResult(type: String, bitmap: Bitmap) + fun onModelViewerRecreated(modelViewer: ModelViewer?) } var callback: Callback? = null var generateGoldens: Boolean = false fun start() { - if (config.tests.isEmpty()) { + if (sortedTests.isEmpty()) { callback?.onAllTestsFinished() return } suiteStartTime = System.currentTimeMillis() + currentBackend = null currentTestIndex = 0 - currentModelIndex = 0 - startTest(config.tests[0]) + runTest(sortedTests[0]) } - private fun startTest(test: TestConfig) { + private fun runTest(testPair: Pair) { + callback?.onTestProgress(currentTestIndex, sortedTests.size) + val (test, modelName) = testPair currentTestConfig = test - if (test.models.isEmpty()) { - nextTest() - return + + if (currentBackend != test.backend) { + currentBackend = test.backend + callback?.onStatusChanged("Switching to backend: ${test.backend}") + recreateEngine(test.backend) + // After recreation, we need to reload the model + currentModelName = null + } + + startModel(modelName) + } + + public fun cleanup() { + // When the runner ends, we need to destroy the resources to ensure that they are free for + // another run. + modelViewer?.destroy() + } + + private fun recreateEngine(backendName: String) { + val backend = when (backendName.lowercase()) { + "vulkan" -> com.google.android.filament.Engine.Backend.VULKAN + else -> com.google.android.filament.Engine.Backend.OPENGL } - currentModelIndex = 0 - startModel(test.models.elementAt(0)) + + // ModelViewer's detach listener will handle engine destruction when surface is detached, + // but here we are still using the same surface. We need a way to swap the engine. + // The easiest way is to recreate ModelViewer with a new Engine. + modelViewer?.destroy() + + val engine = com.google.android.filament.Engine.create(backend) + + resultManager.addGpuDriverInfo(backendName, com.google.android.filament.utils.DeviceUtils.getGpuDriverInfo(engine)) + + // Setting the camera manipulator to null enables changing the camera outside of ModelViewer + val newModelViewer = ModelViewer(surfaceView, engine, manipulator=null) + + // Update the reference in MainActivity (via callback) + callback?.onModelViewerRecreated(newModelViewer) + modelViewer = newModelViewer } private fun startModel(modelName: String) { if (currentModelName == modelName) { Log.i("ValidationRunner", "Reusing model $modelName") - callback?.onStatusChanged("Reusing $modelName for ${currentTestConfig?.name}") + callback?.onStatusChanged("Reusing $modelName for ${currentTestConfig?.name} (${currentTestConfig?.backend})") - modelViewer.resetToDefaultState() + modelViewer?.resetToDefaultState() frameCounter = 0 currentState = State.WARMUP @@ -98,10 +159,10 @@ class ValidationRunner( val modelPath = config.models[modelName] if (modelPath == null) { Log.e("ValidationRunner", "Model $modelName not found") - nextModel() + nextTest() return } - callback?.onStatusChanged("Loading $modelName for ${currentTestConfig?.name}") + callback?.onStatusChanged("Loading $modelName for ${currentTestConfig?.name} (${currentTestConfig?.backend})") // Load model on main thread (required by ModelViewer) loadModel(modelPath) @@ -109,21 +170,21 @@ class ValidationRunner( private fun loadModel(path: String) { // Assume called on Main Thread - modelViewer.destroyModel() + modelViewer?.destroyModel() try { Log.i("ValidationRunner", "Reading model file: $path") val bytes = File(path).readBytes() Log.i("ValidationRunner", "Loading GLB buffer... (${bytes.size} bytes)") val buffer = ByteBuffer.wrap(bytes) - modelViewer.loadModelGlb(buffer) + modelViewer?.loadModelGlb(buffer) Log.i("ValidationRunner", "Model loaded.") - modelViewer.transformToUnitCube() + modelViewer?.transformToUnitCube() currentState = State.WAITING_FOR_RESOURCES frameCounter = 0 Log.i("ValidationRunner", "State set to WAITING_FOR_RESOURCES") } catch (e: Exception) { Log.e("ValidationRunner", "Failed to load $path", e) - nextModel() + nextTest() } } @@ -131,37 +192,41 @@ class ValidationRunner( when (currentState) { State.IDLE -> {} State.WAITING_FOR_RESOURCES -> { - val progress = modelViewer.progress - if (progress >= 1.0f) { - Log.i("ValidationRunner", "Resources loaded. Starting warmup.") - frameCounter = 0 - currentState = State.WARMUP - } + modelViewer?.let { mv -> + val progress = mv.progress + if (progress >= 1.0f) { + Log.i("ValidationRunner", "Resources loaded. Starting warmup.") + frameCounter = 0 + currentState = State.WARMUP + } + } } State.WARMUP -> { frameCounter++ - if (frameCounter > 5) { // 5 frames warmup + if (frameCounter > 1) { // 1 frames warmup startAutomation() } } State.RUNNING_TEST -> { currentEngine?.let { engine -> - val content = AutomationEngine.ViewerContent() - content.view = modelViewer.view - content.renderer = modelViewer.renderer - content.scene = modelViewer.scene - content.lightManager = modelViewer.engine.lightManager - - // Tick - val deltaTime = 1.0f / 60.0f - engine.tick(modelViewer.engine, content, deltaTime) - - frameCounter++ - if (engine.shouldClose()) { - Log.i("ValidationRunner", "Finishing test (frames: $frameCounter)") - // Test finished (for this spec) - currentState = State.IDLE - captureAndCompare() + modelViewer?.let { mv -> + val content = AutomationEngine.ViewerContent() + content.view = mv.view + content.renderer = mv.renderer + content.scene = mv.scene + content.lightManager = mv.engine.lightManager + + // Tick + val deltaTime = 1.0f / 60.0f + engine.tick(mv.engine, content, deltaTime) + + frameCounter++ + if (engine.shouldClose()) { + Log.i("ValidationRunner", "Finishing test (frames: $frameCounter)") + // Test finished (for this spec) + currentState = State.IDLE + captureAndCompare() + } } } } @@ -178,7 +243,7 @@ class ValidationRunner( currentEngine = AutomationEngine(fullSpec) val options = AutomationEngine.Options() options.sleepDuration = 0.0f // Minimal sleep, let frames drive it - options.minFrameCount = 5 // Ensure some frames pass + options.minFrameCount = 1 // Ensure some frames pass currentEngine?.setOptions(options) // Use batch mode to ensure shouldClose() works reliably @@ -191,8 +256,8 @@ class ValidationRunner( private fun captureAndCompare() { - callback?.onStatusChanged("Comparing ${currentTestConfig?.name}...") - modelViewer.debugGetNextFrameCallback { bitmap -> + callback?.onStatusChanged("Comparing ${currentTestConfig?.name} (${currentTestConfig?.backend})...") + modelViewer?.debugGetNextFrameCallback { bitmap -> compareCapturedImage(bitmap) } } @@ -200,7 +265,7 @@ class ValidationRunner( private fun compareCapturedImage(bitmap: Bitmap) { val testName = currentTestConfig!!.name val modelName = currentModelName!! - val backend = currentTestConfig?.backends?.firstOrNull() ?: "opengl" + val backend = currentTestConfig?.backend ?: "opengl" val testFullName = "${testName}.${backend}.${modelName}" // Golden path @@ -312,9 +377,12 @@ class ValidationRunner( alphaDiffImg.setPixels(alphaPixels, 0, width, 0, 0, width, height) callback?.onImageResult("Alpha Diff", alphaDiffImg) resultManager.saveImage("${testFullName}_alpha_diff", alphaDiffImg) + alphaDiffImg.recycle() } + diffImg.recycle() } } + golden.recycle() } else { callback?.onStatusChanged("Failed to load golden") } @@ -327,35 +395,27 @@ class ValidationRunner( // Save output resultManager.saveImage(testFullName, flipped) - val result = ValidationResult(testFullName, passed, diffMetric) + val result = ValidationResult(testFullName, passed, diffMetric, goldenFile?.absolutePath) resultManager.addResult(result) callback?.onTestFinished(result) android.os.Handler(android.os.Looper.getMainLooper()).post { - nextModel() + nextTest() } - } catch (e: Exception) { + } catch (e: Throwable) { Log.e("ValidationRunner", "Comparison failed", e) - android.os.Handler(android.os.Looper.getMainLooper()).post { nextModel() } + // Recycle even on error + bitmap.recycle() + android.os.Handler(android.os.Looper.getMainLooper()).post { nextTest() } } }.start() } - private fun nextModel() { - currentModelIndex++ - if (currentTestConfig != null && currentModelIndex < currentTestConfig!!.models.size) { - startModel(currentTestConfig!!.models.elementAt(currentModelIndex)) - } else { - nextTest() - } - } - - private fun nextTest() { currentTestIndex++ - if (currentTestIndex < config.tests.size) { - startTest(config.tests[currentTestIndex]) + if (currentTestIndex < sortedTests.size) { + runTest(sortedTests[currentTestIndex]) } else { currentState = State.IDLE diff --git a/android/samples/sample-render-validation/src/main/res/layout/activity_main.xml b/android/samples/sample-render-validation/src/main/res/layout/activity_main.xml index 461a6adc1c44..69451266a5ca 100644 --- a/android/samples/sample-render-validation/src/main/res/layout/activity_main.xml +++ b/android/samples/sample-render-validation/src/main/res/layout/activity_main.xml @@ -27,6 +27,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" + android:gravity="center_horizontal" android:paddingStart="20dp" android:paddingEnd="20dp" android:paddingTop="20dp" @@ -82,6 +83,53 @@ android:layout_marginStart="8dp"/> + + + + + + + + + + + + + + + + android:paddingBottom="12dp" /> + + diff --git a/android/samples/sample-render-validation/src/main/res/layout/dialog_image_viewer.xml b/android/samples/sample-render-validation/src/main/res/layout/dialog_image_viewer.xml index c61ed7d1b244..8b866cce9c6b 100644 --- a/android/samples/sample-render-validation/src/main/res/layout/dialog_image_viewer.xml +++ b/android/samples/sample-render-validation/src/main/res/layout/dialog_image_viewer.xml @@ -4,7 +4,6 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" - android:background="#FF202020" android:padding="8dp"> @@ -20,14 +19,14 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="Test Result" - android:textColor="#FFFFFF" - android:textSize="16sp" + android:textAppearance="?attr/textAppearanceTitleLarge" android:textStyle="bold" /> @@ -38,8 +37,8 @@ android:layout_height="wrap_content" android:gravity="start" android:text="Rendered" - android:textColor="#BBBBBB" - android:textSize="14sp" + android:textAppearance="?attr/textAppearanceBodyMedium" + android:textColor="?android:attr/textColorSecondary" android:paddingBottom="8dp" /> @@ -78,7 +77,7 @@ android:layout_height="64dp" android:background="?attr/selectableItemBackgroundBorderless" android:src="@android:drawable/ic_media_previous" - app:tint="#FFFFFF" /> + app:tint="?attr/colorOnSurface" /> + app:tint="?attr/colorOnSurface" /> + app:tint="?attr/colorOnSurface" /> @@ -114,8 +113,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Enhance: 1.0x" - android:textColor="#FFFFFF" - android:textSize="12sp" + android:textAppearance="?attr/textAppearanceBodySmall" android:minWidth="100dp" />