android: [render-validation] collection of adjustments (#9898)

- A triangle tracker under the progress bar to track the test
   currently in view within the scroll view.
 - Text label to indicate the current device under test when a
   test is running.
 -  add filter intent arg
 - Add Fox (animation, skinning) and TransmissionRoughnessTest
   (transmission) to the test.
 - Add applyAnimation to the ValidationRunner
 - Make camera zoom in for default test and adjust tolerance
   acoordingly
 - Fix non-determinism of SSR by
     - waiting for more frames
     - account for false returned from beginFrame
     - Clear frame history on new test entry
 - Include Build.HARDWARE information into result
 - Make results array more thread-safe
 - Remove unused paths
 - Adjust tolerance for two tests
This commit is contained in:
Powei Feng
2026-04-16 12:28:48 -07:00
committed by GitHub
parent a2547731c0
commit afeae22423
8 changed files with 216 additions and 35 deletions

View File

@@ -316,9 +316,9 @@ class ModelViewer(
* @param frameTimeNanos time in nanoseconds when the frame started being rendered,
* typically comes from {@link android.view.Choreographer.FrameCallback}
*/
fun render(frameTimeNanos: Long) {
fun render(frameTimeNanos: Long): Boolean {
if (!uiHelper.isReadyToRender) {
return
return false
}
// Allow the resource loader to finalize textures that have become ready.
@@ -337,7 +337,9 @@ class ModelViewer(
}
// Render the scene, unless the renderer wants to skip the frame.
var rendered = false
if (renderer.beginFrame(swapChain!!, frameTimeNanos)) {
rendered = true
renderer.render(view)
debugFrameCallback?.let {
@@ -360,6 +362,7 @@ class ModelViewer(
renderer.endFrame()
}
return rendered
}
/*

View File

@@ -16,14 +16,15 @@ filament {
iblOutputDir = project.layout.projectDirectory.dir("src/main/assets/envs")
}
// don't forget to update MainACtivity.kt when/if changing this.
tasks.register('copyDamagedHelmetGltf', Copy) {
// don't forget to update MainActivity.kt when/if changing this.
tasks.register('copyModels', Copy) {
from file("../../../third_party/models/DamagedHelmet/DamagedHelmet.glb")
from file("../../../third_party/models/Fox/Fox.glb")
from file("../../../third_party/models/TransmissionRoughnessTest/TransmissionRoughnessTest.glb")
into file("src/main/assets/models")
rename {String fileName -> "helmet.glb"}
}
preBuild.dependsOn copyDamagedHelmetGltf
preBuild.dependsOn copyModels
clean.doFirst {
delete "src/main/assets/envs"

View File

@@ -5,7 +5,9 @@
"vulkan"
],
"models": {
"DamagedHelmet": "helmet.glb"
"DamagedHelmet": "DamagedHelmet.glb",
"Fox": "Fox.glb",
"TransmissionRoughnessTest": "TransmissionRoughnessTest.glb"
},
"presets": [
{
@@ -20,7 +22,7 @@
"center": [
0,
0,
0
-0.8
],
"lookAt": [
0,
@@ -81,7 +83,7 @@
"tolerance": {
"maxAbsDiff": 0.09,
"shiftRadius": 2,
"blurRadius": 3,
"blurRadius": 4,
"maxFailingPixelsFraction": 0.0005
},
"rendering": {}
@@ -201,7 +203,8 @@
{
"name": "ssao_bent_normals",
"apply_presets": [
"base"
"base",
"relaxed_tolerance"
],
"rendering": {
"view.ssao.enabled": true,
@@ -442,7 +445,8 @@
{
"name": "shadow_vsm_highPrecision",
"apply_presets": [
"base"
"base",
"relaxed_tolerance"
],
"rendering": {
"view.shadowType": "VSM",
@@ -574,6 +578,36 @@
"view.antiAliasing": "FXAA",
"view.dof.enabled": true
}
},
{
"name": "fox_animation",
"models": [
"Fox"
],
"apply_presets": [
"base"
],
"rendering": {
"animation": {
"enabled": true,
"time": 0.5
}
}
},
{
"name": "transmission",
"models": [
"TransmissionRoughnessTest"
],
"apply_presets": [
"base"
],
"rendering": {
"animation": {
"enabled": true,
"time": 0.5
}
}
}
]
}

View File

@@ -31,8 +31,10 @@ import android.view.View
import android.view.WindowManager
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.Spinner
import android.widget.TextView
import com.google.android.filament.utils.KTX1Loader
@@ -62,8 +64,12 @@ class MainActivity : Activity(), ValidationRunner.Callback {
private lateinit var choreographer: Choreographer
private lateinit var statusTextView: TextView
private lateinit var testResultsHeader: TextView
private lateinit var progressContainer: FrameLayout
private lateinit var testProgress: com.google.android.filament.validation.TestProgressBar
private lateinit var progressTriangle: ImageView
private lateinit var scrollView: ScrollView
private lateinit var testSummaryText: TextView
private lateinit var deviceInfoText: TextView
private lateinit var resultsContainer: LinearLayout
private lateinit var inputManager: ValidationInputManager
private var currentInput: ValidationInputManager.ValidationInput? = null
@@ -100,8 +106,10 @@ class MainActivity : Activity(), ValidationRunner.Callback {
private val frameScheduler = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
choreographer.postFrameCallback(this)
modelViewer?.render(frameTimeNanos)
validationRunner?.onFrame(frameTimeNanos)
val rendered = modelViewer?.render(frameTimeNanos) ?: false
if (rendered) {
validationRunner?.onFrame(frameTimeNanos)
}
}
}
@@ -115,10 +123,22 @@ class MainActivity : Activity(), ValidationRunner.Callback {
statusTextView = findViewById(R.id.status_text)
testResultsHeader = findViewById(R.id.test_results_header)
progressContainer = findViewById(R.id.progress_container)
testProgress = findViewById(R.id.test_progress)
progressTriangle = findViewById(R.id.progress_triangle)
scrollView = findViewById(R.id.scroll_view)
testSummaryText = findViewById(R.id.test_summary_text)
deviceInfoText = findViewById(R.id.device_info_text)
deviceInfoText.text = "Running on: ${android.os.Build.MODEL}"
resultsContainer = findViewById(R.id.results_container)
scrollView.viewTreeObserver.addOnScrollChangedListener {
updateTrianglePosition()
}
scrollView.viewTreeObserver.addOnGlobalLayoutListener {
updateTrianglePosition()
}
runButton = findViewById(R.id.run_button)
loadButton = findViewById(R.id.load_button)
optionsButton = findViewById(R.id.options_button)
@@ -438,6 +458,7 @@ class MainActivity : Activity(), ValidationRunner.Callback {
return ValidationResultManager(
outputDir = outputDir,
deviceName = android.os.Build.MODEL,
deviceHardware = android.os.Build.HARDWARE,
deviceCodeName = android.os.Build.DEVICE,
androidVersion = android.os.Build.VERSION.RELEASE,
androidBuildNumber = android.os.Build.DISPLAY
@@ -470,7 +491,9 @@ class MainActivity : Activity(), ValidationRunner.Callback {
Log.i(TAG, "Output dir: ${input.outputDir.absolutePath}")
testProgress.visibility = View.VISIBLE
progressContainer.visibility = View.VISIBLE
testSummaryText.visibility = View.GONE
deviceInfoText.visibility = View.VISIBLE
totalPassed = 0
totalFailed = 0
testProgress.reset(1)
@@ -493,6 +516,7 @@ class MainActivity : Activity(), ValidationRunner.Callback {
validationRunner = ValidationRunner(this, surfaceView, config, resultManager!!, backendFilter)
validationRunner?.callback = this
validationRunner?.generateGoldens = input.generateGoldens
validationRunner?.testFilter = input.testFilter
validationRunner?.start()
} catch (e: Exception) {
@@ -540,6 +564,36 @@ class MainActivity : Activity(), ValidationRunner.Callback {
private var currentDiffBitmap: Bitmap? = null
private var totalPassed = 0
private var totalFailed = 0
private var totalTestsCount = 1
private fun updateTrianglePosition() {
if (progressContainer.visibility != View.VISIBLE || resultsContainer.childCount == 0) return
val scrollY = scrollView.scrollY
val visibleHeight = scrollView.height
val centerY = scrollY + visibleHeight / 2f
var bestIndex = -1
var minDistance = Float.MAX_VALUE
for (i in 0 until resultsContainer.childCount) {
val child = resultsContainer.getChildAt(i)
val childCenterY = child.top + child.height / 2f
val dist = Math.abs(childCenterY - centerY)
if (dist < minDistance) {
minDistance = dist
bestIndex = i
}
}
if (bestIndex >= 0) {
val progressWidth = testProgress.width
if (progressWidth > 0 && totalTestsCount > 0) {
val segmentWidth = progressWidth.toFloat() / totalTestsCount
val targetX = (bestIndex * segmentWidth) + (segmentWidth / 2f)
progressTriangle.translationX = targetX - progressTriangle.width / 2f
}
}
}
override fun onTestFinished(result: ValidationResult) {
runOnUiThread {
@@ -640,6 +694,7 @@ class MainActivity : Activity(), ValidationRunner.Callback {
resultContainer.addView(imagesRow)
resultsContainer.addView(resultContainer)
resultsContainer.post { updateTrianglePosition() }
// Clear current images for next test
currentRenderedBitmap = null
@@ -651,7 +706,9 @@ class MainActivity : Activity(), ValidationRunner.Callback {
override fun onTestProgress(current: Int, total: Int) {
runOnUiThread {
totalTestsCount = Math.max(1, total)
testProgress.setMax(total)
updateTrianglePosition()
}
}
@@ -663,6 +720,7 @@ class MainActivity : Activity(), ValidationRunner.Callback {
val html = "Passed: <font color='$colorPassed'><b>$totalPassed</b></font> / $total &nbsp;&nbsp;&nbsp; Failed: <font color='$colorFailed'><b>$totalFailed</b></font>"
testSummaryText.text = Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY)
testSummaryText.visibility = View.VISIBLE
deviceInfoText.visibility = View.GONE
statusTextView.text = "All tests finished!"
// Re-enable UI

View File

@@ -37,7 +37,8 @@ class ValidationInputManager(private val context: Context) {
val outputDir: File,
val generateGoldens: Boolean,
val autoRun: Boolean = false,
val sourceZip: File? = null
val sourceZip: File? = null,
val testFilter: String? = null
)
public fun getBaseDir() : File {
@@ -71,6 +72,8 @@ class ValidationInputManager(private val context: Context) {
val autoRun = intent.getBooleanExtra("auto_run", false) ||
intent.getBooleanExtra("generate_goldens", false)
val testFilter = if (autoRun) intent.getStringExtra("test_filter") else null
val outputPath = intent.getStringExtra("output_path")
Log.i(TAG, "Resolving config with outputPath: $outputPath")
@@ -134,7 +137,7 @@ class ValidationInputManager(private val context: Context) {
else -> null
}
return@withContext ValidationInput(config, outputDir, generateGoldens, autoRun, sourceZipFile)
return@withContext ValidationInput(config, outputDir, generateGoldens, autoRun, sourceZipFile, testFilter)
}
private var lastUnzippedFile: String? = null
@@ -150,7 +153,8 @@ class ValidationInputManager(private val context: Context) {
outputDir = outputDir,
generateGoldens = false,
autoRun = false,
sourceZip = file
sourceZip = file,
testFilter = null
)
return newInput
}
@@ -227,14 +231,15 @@ class ValidationInputManager(private val context: Context) {
}
}
// Copy DamagedHelmet.glb
// Copy models
val modelsDir = File(filesDir, "models")
modelsDir.mkdirs()
val modelOut = File(modelsDir, "helmet.glb")
assetManager.open("models/helmet.glb").use { input ->
FileOutputStream(modelOut).use { output ->
input.copyTo(output)
assetManager.list("models")?.forEach { modelFileName ->
val modelOut = File(modelsDir, modelFileName)
assetManager.open("models/$modelFileName").use { input ->
FileOutputStream(modelOut).use { output ->
input.copyTo(output)
}
}
}
@@ -247,9 +252,15 @@ class ValidationInputManager(private val context: Context) {
val models = configJson.getJSONObject("models")
// Ensure the default model points to the extracted file
// We can use absolute path to be safe since we know where it is now.
models.put("DamagedHelmet", modelOut.absolutePath)
// Update all model paths to point to the extracted files in the models directory
val keys = models.keys()
val newModels = JSONObject()
while (keys.hasNext()) {
val key = keys.next()
val fileName = models.getString(key)
newModels.put(key, java.io.File(modelsDir, fileName).absolutePath)
}
configJson.put("models", newModels)
configOut.writeText(configJson.toString(2))

View File

@@ -35,6 +35,7 @@ data class ValidationResult(
class ValidationResultManager(
private val outputDir: File,
private val deviceName: String,
private val deviceHardware: String,
private val deviceCodeName: String,
private val androidVersion: String,
private val androidBuildNumber: String
@@ -44,7 +45,7 @@ class ValidationResultManager(
private const val TAG = "ValidationResultManager"
}
private val results = mutableListOf<ValidationResult>()
private val results = java.util.concurrent.CopyOnWriteArrayList<ValidationResult>()
private val gpuDriverInfos = JSONObject()
init {
@@ -293,6 +294,7 @@ class ValidationResultManager(
metadataObject.put("gpu_driver_info", gpuDriverInfos)
metadataObject.put("total_time_ms", totalTimeMs)
metadataObject.put("device_name", deviceName ?: "")
metadataObject.put("device_hardware", deviceHardware ?: "")
metadataObject.put("device_code_name", deviceCodeName ?: "")
metadataObject.put("android_version", androidVersion ?: "")
metadataObject.put("android_build_number", androidBuildNumber ?: "")

View File

@@ -65,7 +65,22 @@ class ValidationRunner(
// 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 }))
val sorted = expanded.sortedWith(compareBy({ it.first.backend }, { it.second }))
val filter = testFilter
if (filter != null && filter.count { it == '.' } == 2) {
val parts = filter.split(".")
val nameRegex = ("^" + parts[0].replace("*", ".*") + "$").toRegex()
val backendRegex = ("^" + parts[1].replace("*", ".*") + "$").toRegex()
val modelRegex = ("^" + parts[2].replace("*", ".*") + "$").toRegex()
sorted.filter { (test, model) ->
val testBackend = test.backend ?: "opengl"
test.name.matches(nameRegex) && testBackend.matches(backendRegex) && model.matches(modelRegex)
}
} else {
sorted
}
}
enum class State {
@@ -86,6 +101,7 @@ class ValidationRunner(
var callback: Callback? = null
var generateGoldens: Boolean = false
var testFilter: String? = null
fun start() {
if (sortedTests.isEmpty()) {
@@ -215,11 +231,12 @@ class ValidationRunner(
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)
applyAnimation(mv, currentTestConfig!!.rendering)
frameCounter++
if (engine.shouldClose()) {
Log.i("ValidationRunner", "Finishing test (frames: $frameCounter)")
@@ -233,6 +250,30 @@ class ValidationRunner(
}
}
private fun applyAnimation(mv: com.google.android.filament.utils.ModelViewer, rendering: JSONObject) {
val animator = mv.animator ?: return
if (animator.animationCount == 0) return
var enabled = false
var time = 0.0f
if (rendering.has("animation")) {
val anim = rendering.optJSONObject("animation")
if (anim != null) {
enabled = anim.optBoolean("enabled", false)
time = anim.optDouble("time", 0.0).toFloat()
}
} else {
enabled = rendering.optBoolean("animation.enabled", false)
time = rendering.optDouble("animation.time", 0.0).toFloat()
}
if (enabled) {
animator.applyAnimation(0, time)
animator.updateBoneMatrices()
}
}
private fun startAutomation() {
val test = currentTestConfig!!
val specJson = JSONObject()
@@ -243,13 +284,19 @@ class ValidationRunner(
currentEngine = AutomationEngine(fullSpec)
val options = AutomationEngine.Options()
options.sleepDuration = 0.0f // Minimal sleep, let frames drive it
options.minFrameCount = 1 // Ensure some frames pass
options.minFrameCount = 15 // Ensure enough frames pass for temporal accumulation
currentEngine?.setOptions(options)
// Use batch mode to ensure shouldClose() works reliably
currentEngine?.startBatchMode()
currentEngine?.signalBatchMode() // Start immediately
// Clear the frame history so that temporal effects (like SSR or TAA) don't bleed over
// or ghost from the visual output of the previous test.
modelViewer?.let { mv ->
mv.view.clearFrameHistory(mv.engine)
}
frameCounter = 0
currentState = State.RUNNING_TEST
}
@@ -297,8 +344,6 @@ class ValidationRunner(
Log.i("ValidationRunner", "Found golden at ${goldenFile.absolutePath}")
} else {
Log.w("ValidationRunner", "Golden not found for $testFullName. Searched in: ${searchPaths.joinToString { it.absolutePath }}")
// Fallback to old behavior for reference if everything else fails
goldenFile = modelParent.parentFile?.resolve("golden/${testFullName}.png") ?: File(modelParent, "golden/${testFullName}.png")
}
Thread {

View File

@@ -28,6 +28,7 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:clipChildren="false"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:paddingTop="20dp"
@@ -168,13 +169,30 @@
android:paddingTop="0dp"
android:paddingBottom="12dp" />
<com.google.android.filament.validation.TestProgressBar
android:id="@+id/test_progress"
<FrameLayout
android:id="@+id/progress_container"
android:layout_width="200dp"
android:layout_height="4dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:visibility="gone" />
android:clipChildren="false"
android:visibility="gone">
<com.google.android.filament.validation.TestProgressBar
android:id="@+id/test_progress"
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_marginBottom="8dp" />
<ImageView
android:id="@+id/progress_triangle"
android:layout_width="8dp"
android:layout_height="8dp"
android:layout_gravity="bottom"
android:layout_marginTop="4dp"
android:scaleType="fitXY"
android:src="@drawable/ic_triangle_up" />
</FrameLayout>
<TextView
android:id="@+id/test_summary_text"
@@ -184,9 +202,18 @@
android:layout_marginBottom="12dp"
android:visibility="gone" />
<TextView
android:id="@+id/device_info_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:layout_marginBottom="12dp"
android:visibility="gone" />
</LinearLayout>
<ScrollView
android:id="@+id/scroll_view"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/controls_container"