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:
Powei Feng
2026-03-31 04:56:35 +00:00
committed by GitHub
parent ffcccb336e
commit 3ab0b41b7d
9 changed files with 356 additions and 165 deletions
@@ -1,7 +1,8 @@
{
"name": "Default Test",
"backends": [
"opengl"
"opengl",
"vulkan"
],
"models": {
"DamagedHelmet": "helmet.glb"
@@ -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()
}
@@ -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)
}
}
}
}
@@ -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 ?: "")
@@ -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>
@@ -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