android: [render-validation] support multiple backends (#9820)
This commit introduces several features: - Backend Testing: Support testing across both OpenGL and Vulkan. Test configurations expand per backend, and the Filament Engine alongside ModelViewer is recreated dynamically when the backend switches to isolate resources. - UI Controls: Added a backend filter toggle and a test progress bar to the main activity, themed appropriately using Material design. - Memory Optimizations: Drastically reduced memory usage by displaying scaled-down thumbnails (128x128) in the main list. Full-resolution images are lazy-loaded from disk on demand when clicking a thumbnail to view, and large bitmaps are aggressively recycled. - Device Info Collection: ValidationResultManager now collects and reports detailed GPU driver info for each instantiated backend in the output results.json. - Engine Teardown: Introduced an explicit destroy() method in ModelViewer to ensure full resource cleanup during engine recreation.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "Default Test",
|
||||
"backends": [
|
||||
"opengl"
|
||||
"opengl",
|
||||
"vulkan"
|
||||
],
|
||||
"models": {
|
||||
"DamagedHelmet": "helmet.glb"
|
||||
|
||||
+116
-55
@@ -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<ImageView>()
|
||||
@@ -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"
|
||||
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)
|
||||
}
|
||||
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<Pair<String, Bitmap>>()
|
||||
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<Pair<String, File>>()
|
||||
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()
|
||||
}
|
||||
|
||||
+6
-4
@@ -31,7 +31,7 @@ data class RenderTestConfig(
|
||||
data class TestConfig(
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val backends: List<String>,
|
||||
val backend: String,
|
||||
val models: Set<String>,
|
||||
val rendering: JSONObject,
|
||||
val tolerance: JSONObject?
|
||||
@@ -102,7 +102,7 @@ class ConfigParser {
|
||||
val testsJson = json.getJSONArray("tests")
|
||||
val tests = mutableListOf<TestConfig>()
|
||||
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<String>,
|
||||
presets: Map<String, PresetConfig>,
|
||||
defaultBackends: List<String>
|
||||
): TestConfig {
|
||||
): List<TestConfig> {
|
||||
val name = json.getString("name")
|
||||
val description = json.optString("description")
|
||||
val backends = json.optJSONArray("backends")?.toList<String>() ?: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -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<ValidationResult>()
|
||||
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 ?: "")
|
||||
|
||||
+120
-60
@@ -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<Pair<TestConfig, String>> by lazy {
|
||||
val filtered = config.tests.filter {
|
||||
when (backendFilter) {
|
||||
"gles" -> it.backend == "opengl"
|
||||
"vulkan" -> it.backend == "vulkan"
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
val expanded = mutableListOf<Pair<TestConfig, String>>()
|
||||
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<TestConfig, String>) {
|
||||
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
|
||||
}
|
||||
currentModelIndex = 0
|
||||
startModel(test.models.elementAt(0))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
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(modelViewer.engine, content, deltaTime)
|
||||
// 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()
|
||||
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
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Backend Filter (Hidden by default) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/backend_filter_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:visibility="gone"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Backend:"
|
||||
android:textSize="12sp"
|
||||
android:minWidth="120dp" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/backend_radio_group"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radio_both"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Both"
|
||||
android:textSize="12sp"
|
||||
android:checked="true" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radio_gles"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="GLES"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radio_vulkan"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Vulkan"
|
||||
android:textSize="12sp" />
|
||||
</RadioGroup>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Global Enhancement Controls (Hidden by default) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/enhancement_container"
|
||||
@@ -115,10 +163,19 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Test Results"
|
||||
android:gravity="center"
|
||||
android:textSize="14sp"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingBottom="8dp" />
|
||||
android:paddingBottom="12dp" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/test_progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:gravity="center"
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
+8
-10
@@ -4,7 +4,6 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="#FF202020"
|
||||
android:padding="8dp">
|
||||
|
||||
<!-- Header with title and close button -->
|
||||
@@ -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" />
|
||||
<ImageButton
|
||||
android:id="@+id/btn_close"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
app:tint="?attr/colorOnSurface"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
<!-- Image Area with Arrows -->
|
||||
@@ -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" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_reset"
|
||||
@@ -87,7 +86,7 @@
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_revert"
|
||||
app:tint="#FFFFFF" />
|
||||
app:tint="?attr/colorOnSurface" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_next"
|
||||
@@ -95,7 +94,7 @@
|
||||
android:layout_height="64dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_media_next"
|
||||
app:tint="#FFFFFF" />
|
||||
app:tint="?attr/colorOnSurface" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Enhancement Controls (only for diff images) -->
|
||||
@@ -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" />
|
||||
|
||||
<SeekBar
|
||||
|
||||
Reference in New Issue
Block a user