android: render-validation sample UI and additions (#9692)

- Update UI
 - Wait for resources to finish loading before testing
 - Refactor validation to input/output
 - Move assets to be generated from the repo
 - Fix AutomationEngine java binding to add missing fields
This commit is contained in:
Powei Feng
2026-02-13 13:53:20 -08:00
committed by GitHub
parent 1cd48619e3
commit 308668a705
10 changed files with 807 additions and 172 deletions

View File

@@ -71,8 +71,10 @@ Java_com_google_android_filament_utils_AutomationEngine_nStartBatchMode(JNIEnv*
extern "C" JNIEXPORT void JNICALL
Java_com_google_android_filament_utils_AutomationEngine_nTick(JNIEnv* env, jclass klass,
jlong nativeAutomation, jlong nativeEngine,
jlong view, jlongArray materials, jlong renderer, jfloat deltaTime) {
jlong view, jlongArray materials, jlong renderer, jlong nativeIbl, jint sunlightEntity,
jintArray assetLights, jlong nativeLm, jlong scene, jfloat deltaTime) {
using MaterialPointer = MaterialInstance*;
jsize materialCount = 0;
jlong* longMaterials = nullptr;
MaterialPointer* ptrMaterials = nullptr;
@@ -84,12 +86,28 @@ Java_com_google_android_filament_utils_AutomationEngine_nTick(JNIEnv* env, jclas
ptrMaterials[i] = (MaterialPointer) longMaterials[i];
}
}
jsize lightCount = 0;
jint* intLights = nullptr;
if (assetLights) {
lightCount = env->GetArrayLength(assetLights);
intLights = env->GetIntArrayElements(assetLights, nullptr);
}
static_assert(sizeof(jint) == sizeof(Entity));
AutomationEngine* automation = (AutomationEngine*) nativeAutomation;
AutomationEngine::ViewerContent content = {
.view = (View*) view,
.renderer = (Renderer*) renderer,
.materials = ptrMaterials,
.materialCount = (size_t) materialCount,
.lightManager = (LightManager*) nativeLm,
.scene = (Scene*) scene,
.indirectLight = (IndirectLight*) nativeIbl,
.sunlight = (Entity&) sunlightEntity,
.assetLights = (Entity*) intLights,
.assetLightCount = (size_t) lightCount,
};
Engine* engine = (Engine*)nativeEngine;
automation->tick(engine, content, deltaTime);
@@ -97,6 +115,9 @@ Java_com_google_android_filament_utils_AutomationEngine_nTick(JNIEnv* env, jclas
env->ReleaseLongArrayElements(materials, longMaterials, 0);
delete[] ptrMaterials;
}
if (intLights) {
env->ReleaseIntArrayElements(assetLights, intLights, 0);
}
}
extern "C" JNIEXPORT void JNICALL

View File

@@ -178,7 +178,12 @@ public class AutomationEngine {
}
long nativeView = content.view.getNativeObject();
long nativeRenderer = content.renderer.getNativeObject();
nTick(mNativeObject, engine.getNativeObject(), nativeView, nativeMaterialInstances, nativeRenderer, deltaTime);
long nativeIbl = content.indirectLight == null ? 0 : content.indirectLight.getNativeObject();
long nativeLm = content.lightManager == null ? 0 : content.lightManager.getNativeObject();
long nativeScene = content.scene == null ? 0 : content.scene.getNativeObject();
nTick(mNativeObject, engine.getNativeObject(), nativeView, nativeMaterialInstances,
nativeRenderer, nativeIbl, content.sunlight, content.assetLights, nativeLm,
nativeScene, deltaTime);
}
/**
@@ -287,7 +292,8 @@ public class AutomationEngine {
private static native void nStartRunning(long nativeObject);
private static native void nStartBatchMode(long nativeObject);
private static native void nTick(long nativeObject, long nativeEngine,
long view, long[] materials, long renderer, float deltaTime);
long view, long[] materials, long renderer, long ibl, int sunlight, int[] assetLights,
long lightManager, long scene, float deltaTime);
private static native void nApplySettings(long nativeObject, long nativeEngine,
String jsonSettings, long view,
long[] materials, long ibl, int sunlight, int[] assetLights, long lightManager,

View File

@@ -1,6 +1,7 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'filament-plugin'
}
project.ext.isSample = true
@@ -9,6 +10,25 @@ kotlin {
jvmToolchain(versions.jdk)
}
filament {
cmgenArgs = "-q --format=ktx --size=256 --extract-blur=0.1 --deploy=src/main/assets/envs/default_env"
iblInputFile = project.layout.projectDirectory.file("../../../third_party/environments/lightroom_14b.hdr")
iblOutputDir = project.layout.projectDirectory.dir("src/main/assets/envs")
}
// don't forget to update MainACtivity.kt when/if changing this.
tasks.register('copyDamagedHelmetGltf', Copy) {
from file("../../../third_party/models/DamagedHelmet/DamagedHelmet.glb")
into file("src/main/assets/models")
rename {String fileName -> "helmet.glb"}
}
preBuild.dependsOn copyDamagedHelmetGltf
clean.doFirst {
delete "src/main/assets"
}
android {
namespace 'com.google.android.filament.validation'
@@ -27,9 +47,9 @@ android {
dependencies {
implementation deps.kotlin
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation deps.coroutines.core
implementation project(':filament-android')
implementation project(':gltfio-android')
implementation project(':filament-utils-android')
implementation project(':filament-utils-android')
}

View File

@@ -17,16 +17,36 @@
package com.google.android.filament.validation
import android.app.Activity
import android.graphics.Bitmap
import android.os.Bundle
import android.util.Log
import android.view.Choreographer
import android.view.SurfaceView
import android.view.View
import android.view.WindowManager
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import com.google.android.filament.utils.ModelViewer
import com.google.android.filament.utils.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.google.android.filament.utils.KTX1Loader
import com.google.android.filament.IndirectLight
import com.google.android.filament.Skybox
import android.graphics.Color
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.ByteBuffer
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.Spinner
import android.widget.AdapterView
class MainActivity : Activity(), ValidationRunner.Callback {
@@ -42,8 +62,15 @@ class MainActivity : Activity(), ValidationRunner.Callback {
private lateinit var choreographer: Choreographer
private lateinit var modelViewer: ModelViewer
private lateinit var statusTextView: TextView
private lateinit var resultsContainer: LinearLayout
private lateinit var inputManager: ValidationInputManager
private var currentInput: ValidationInputManager.ValidationInput? = null
private lateinit var modeSpinner: Spinner
private lateinit var runButton: Button
private var resultManager: ValidationResultManager? = null
private var validationRunner: ValidationRunner? = null
// Frame callback
private val frameScheduler = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
@@ -55,58 +82,109 @@ class MainActivity : Activity(), ValidationRunner.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Simple layout
val layout = android.widget.FrameLayout(this)
surfaceView = SurfaceView(this)
layout.addView(surfaceView)
statusTextView = TextView(this)
statusTextView.setTextColor(0xFFFFFFFF.toInt())
statusTextView.textSize = 16f
statusTextView.setPadding(20, 20, 20, 20)
statusTextView.text = "Initializing..."
layout.addView(statusTextView)
setContentView(layout)
setContentView(R.layout.activity_main)
// SurfaceView container
surfaceView = findViewById(R.id.surface_view)
surfaceView.holder.setFixedSize(512, 512)
statusTextView = findViewById(R.id.status_text)
modeSpinner = findViewById(R.id.mode_spinner)
runButton = findViewById(R.id.run_button)
resultsContainer = findViewById(R.id.results_container)
// Setup Spinner
val modes = arrayOf("Run Validation", "Generate Goldens")
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, modes)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
modeSpinner.adapter = adapter
// Setup Run Button
runButton.setOnClickListener {
currentInput?.let { input ->
val generateGoldens = modeSpinner.selectedItemPosition == 1
val newInput = input.copy(generateGoldens = generateGoldens)
startValidation(newInput)
}
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
choreographer = Choreographer.getInstance()
modelViewer = ModelViewer(surfaceView)
// Check permissions?
// We assume 'adb install -g' or permissions granted.
// But for scoped storage we might not need PERMISSION if reading from app-specific dirs,
// but user mentioned /sdcard/ so we need MANAGE_EXTERNAL_STORAGE or READ_EXTERNAL_STORAGE.
// For waiting/simplicity, we just try.
inputManager = ValidationInputManager(this)
// Initialize IBL
createIndirectLight()
handleIntent()
}
private fun handleIntent() {
val intent = intent
val testConfigPath = intent.getStringExtra("test_config")
if (testConfigPath != null) {
startValidation(testConfigPath)
} else {
statusTextView.text = "No test_config provided via Intent.\nUse -e test_config <path>"
Log.w(TAG, "No test config provided")
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)
}
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}_skybox.ktx").let {
val bundle = KTX1Loader.createSkybox(engine, it)
scene.skybox = bundle.skybox
modelViewer.skyboxCubemap = bundle.cubemap
}
Log.i(TAG, "IBL loaded successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to load IBL", e)
statusTextView.text = "Warning: Failed to load IBL"
}
}
private fun startValidation(configPath: String) {
private fun handleIntent() {
statusTextView.text = "Resolving configuration..."
CoroutineScope(Dispatchers.Main).launch {
try {
val input = inputManager.resolveConfig(intent)
currentInput = input
// Sync spinner with intent
modeSpinner.setSelection(if (input.generateGoldens) 1 else 0)
startValidation(input)
} catch (e: Exception) {
Log.e(TAG, "Failed to resolve config", e)
statusTextView.text = "Error: ${e.message}"
}
}
}
private fun startValidation(input: ValidationInputManager.ValidationInput) {
try {
Log.i(TAG, "Parsing config from $configPath")
val config = ConfigParser.parseFromPath(configPath)
val outputDir = File(getExternalFilesDir(null), "validation_results")
Log.i(TAG, "Output dir: ${outputDir.absolutePath}")
validationRunner = ValidationRunner(this, modelViewer, config, outputDir)
resultsContainer.removeAllViews()
Log.i(TAG, "Starting validation with config: ${input.config.name}")
Log.i(TAG, "Output dir: ${input.outputDir.absolutePath}")
resultManager = ValidationResultManager(input.outputDir)
validationRunner = ValidationRunner(this, modelViewer, input.config, resultManager!!)
validationRunner?.callback = this
validationRunner?.generateGoldens = input.generateGoldens
validationRunner?.start()
// Sync spinner in case it was called programmatically or changed implicitly
modeSpinner.setSelection(if (input.generateGoldens) 1 else 0)
} catch (e: Exception) {
Log.e(TAG, "Failed to start validation", e)
statusTextView.text = "Error: ${e.message}"
@@ -128,11 +206,68 @@ class MainActivity : Activity(), ValidationRunner.Callback {
choreographer.removeFrameCallback(frameScheduler)
}
override fun onTestFinished(result: ValidationRunner.TestResult) {
private var currentRenderedBitmap: Bitmap? = null
private var currentGoldenBitmap: Bitmap? = null
private var currentDiffBitmap: Bitmap? = null
override fun onTestFinished(result: ValidationResult) {
runOnUiThread {
val status = "Test ${result.name} finished: ${if(result.passed) "PASS" else "FAIL"}"
val status = "Test ${result.testName} finished: ${if(result.passed) "PASS" else "FAIL"}"
statusTextView.text = status
Log.i(TAG, status)
// Container for this result
val resultContainer = LinearLayout(this)
resultContainer.orientation = LinearLayout.VERTICAL
resultContainer.setPadding(0, 10, 0, 20)
// Header
val resultView = TextView(this)
resultView.text = "${result.testName}: ${if(result.passed) "PASS" else "FAIL"} (Diff: ${result.diffMetric})"
resultView.setTextColor(if(result.passed) Color.GREEN else Color.RED)
resultView.textSize = 16f
resultView.setTypeface(null, android.graphics.Typeface.BOLD)
resultContainer.addView(resultView)
// Images Row
val imagesRow = LinearLayout(this)
imagesRow.orientation = LinearLayout.HORIZONTAL
fun addImage(label: String, bitmap: Bitmap?) {
if (bitmap != null) {
val container = LinearLayout(this)
container.orientation = LinearLayout.VERTICAL
container.setPadding(0, 0, 10, 0)
val labelView = TextView(this)
labelView.text = label
labelView.textSize = 12f
container.addView(labelView)
val iv = ImageView(this)
iv.setImageBitmap(bitmap) // Use the same bitmap (or copy if needed, but same is usually fine for UI)
iv.layoutParams = LinearLayout.LayoutParams(250, 250) // Smaller thumbnails
iv.scaleType = ImageView.ScaleType.FIT_CENTER
iv.setBackgroundColor(0xFF404040.toInt())
container.addView(iv)
imagesRow.addView(container)
}
}
addImage("Rendered", currentRenderedBitmap)
addImage("Golden", currentGoldenBitmap)
if (!result.passed) {
addImage("Diff", currentDiffBitmap)
}
resultContainer.addView(imagesRow)
resultsContainer.addView(resultContainer)
// Clear current images for next test
currentRenderedBitmap = null
currentGoldenBitmap = null
currentDiffBitmap = null
}
}
@@ -140,8 +275,6 @@ class MainActivity : Activity(), ValidationRunner.Callback {
runOnUiThread {
statusTextView.text = "All tests finished!"
Log.i(TAG, "All tests finished")
// Optional: Auto-close activity?
// finish()
}
}
@@ -150,5 +283,68 @@ class MainActivity : Activity(), ValidationRunner.Callback {
statusTextView.text = status
}
}
override fun onImageResult(type: String, bitmap: Bitmap) {
runOnUiThread {
// Update the "live" views
when (type) {
"Rendered" -> {
currentRenderedBitmap = bitmap
}
"Golden" -> {
currentGoldenBitmap = bitmap
}
"Diff" -> {
currentDiffBitmap = bitmap
}
}
}
}
}
/*
* Scripts for reference:
*
* generate_goldens.sh:
* --------------------
* #!/bin/bash
* set -e
*
* # Config path (on device)
* CONFIG_PATH=$1
* if [ -z "$CONFIG_PATH" ]; then
* echo "Usage: $0 <device_config_path>"
* echo "Example: $0 /sdcard/Android/data/com.google.android.filament.validation/files/default_test.json"
* exit 1
* fi
*
* echo "Starting Golden Generation for $CONFIG_PATH..."
* adb shell am force-stop com.google.android.filament.validation
* adb shell am start -n com.google.android.filament.validation/.MainActivity \
* -e test_config "$CONFIG_PATH" \
* --ez generate_goldens true
*
* echo "Check device or logcat for progress."
* echo "adb logcat -s FilamentValidation:I ValidationRunner:I"
* echo "To pull results: ./samples/sample-render-validation/pull_goldens.sh"
*
* pull_goldens.sh:
* ----------------
* #!/bin/bash
* set -e
*
* # Default destination is local golden directory relative to script
* SCRIPT_DIR=$(cd $(dirname $0); pwd)
* DEST_DIR=${1:-"$SCRIPT_DIR/golden"}
*
* echo "Pulling goldens to $DEST_DIR..."
* mkdir -p "$DEST_DIR"
*
* # Path on device
* DEVICE_GOLDEN_DIR="/storage/emulated/0/Android/data/com.google.android.filament.validation/files/golden/."
*
* adb pull "$DEVICE_GOLDEN_DIR" "$DEST_DIR"
*
* echo "Done."
* ls -l "$DEST_DIR"
*/

View File

@@ -70,6 +70,26 @@ class ConfigParser {
}
}
// Explicit models map override
val modelsJson = json.optJSONObject("models")
if (modelsJson != null) {
val keys = modelsJson.keys()
while (keys.hasNext()) {
val name = keys.next()
val path = modelsJson.getString(name)
// Resolve path relative to baseDir if not absolute
val file = File(path)
if (file.isAbsolute) {
models[name] = path
} else if (baseDir != null) {
models[name] = File(baseDir, path).absolutePath
} else {
models[name] = path
}
}
}
val presetsJson = json.optJSONArray("presets")
val presets = mutableMapOf<String, PresetConfig>()
if (presetsJson != null) {

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.filament.validation
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
/**
* Handles the retrieval and preparation of test configuration and assets.
* Supports loading from:
* 1. Intent extras (local path or URL)
* 2. Default embedded assets (fallback)
*/
class ValidationInputManager(private val context: Context) {
companion object {
private const val TAG = "ValidationInputManager"
}
data class ValidationInput(
val config: RenderTestConfig,
val outputDir: File,
val generateGoldens: Boolean
)
/**
* Resolves the test configuration based on the provided intent extras.
* This may involve extracting assets or downloading files.
*/
suspend fun resolveConfig(intent: Intent): ValidationInput = withContext(Dispatchers.IO) {
val testConfigPath = intent.getStringExtra("test_config")
val urlConfig = intent.getStringExtra("url_config")
val urlModelsBase = intent.getStringExtra("url_models_base")
val generateGoldens = intent.getBooleanExtra("generate_goldens", false)
val outputPath = intent.getStringExtra("output_path")
val outputDir = if (outputPath != null) {
File(outputPath).apply { mkdirs() }
} else {
File(context.getExternalFilesDir(null), "validation_results").apply { mkdirs() }
}
val config = when {
urlConfig != null -> downloadConfig(urlConfig, urlModelsBase)
testConfigPath != null -> ConfigParser.parseFromPath(testConfigPath)
else -> extractDefaultAssets()
}
return@withContext ValidationInput(config, outputDir, generateGoldens)
}
private suspend fun extractDefaultAssets(): RenderTestConfig {
Log.i(TAG, "Extracting default assets...")
val filesDir = context.getExternalFilesDir(null) ?: context.filesDir
val assetManager = context.assets
// Copy default_test.json
val configDir = File(filesDir, "config")
configDir.mkdirs()
val configOut = File(configDir, "default_test.json")
assetManager.open("default_test.json").use { input ->
FileOutputStream(configOut).use { output ->
input.copyTo(output)
}
}
// Copy DamagedHelmet.glb
val modelsDir = File(filesDir, "models")
modelsDir.mkdirs()
val modelOut = File(modelsDir, "DamagedHelmet.glb")
assetManager.open("DamagedHelmet.glb").use { input ->
FileOutputStream(modelOut).use { output ->
input.copyTo(output)
}
}
// Update config to point to relative path (standardizing on relative for portability where possible)
// or absolute. Here we use relative as per previous logic.
val configJson = JSONObject(configOut.readText())
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)
configOut.writeText(configJson.toString(2))
return ConfigParser.parseFromPath(configOut.absolutePath)
}
private suspend fun downloadConfig(urlConfig: String, urlModelsBase: String?): RenderTestConfig {
Log.i(TAG, "Downloading config from $urlConfig")
val filesDir = context.getExternalFilesDir(null) ?: context.filesDir
val configDir = File(filesDir, "config")
configDir.mkdirs()
val modelsDir = File(filesDir, "models")
modelsDir.mkdirs()
val configName = "downloaded_config.json"
val configFile = File(configDir, configName)
downloadFile(urlConfig, configFile)
if (urlModelsBase != null) {
val configJson = JSONObject(configFile.readText())
val models = configJson.optJSONObject("models")
if (models != null) {
val keys = models.keys()
while (keys.hasNext()) {
val key = keys.next()
val modelPath = models.getString(key)
val fileName = File(modelPath).name
val modelFile = File(modelsDir, fileName)
val modelUrl = "$urlModelsBase/$fileName"
Log.i(TAG, "Downloading model: $fileName from $modelUrl")
downloadFile(modelUrl, modelFile)
// Update config to point to absolute path
models.put(key, modelFile.absolutePath)
}
configFile.writeText(configJson.toString())
}
}
return ConfigParser.parseFromPath(configFile.absolutePath)
}
private fun downloadFile(urlStr: String, destFile: File) {
val url = URL(urlStr)
val connection = url.openConnection() as HttpURLConnection
connection.connect()
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw Exception("Server returned HTTP ${connection.responseCode} for $urlStr")
}
destFile.parentFile?.mkdirs()
connection.inputStream.use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.filament.validation
import android.graphics.Bitmap
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import org.json.JSONArray
import org.json.JSONObject
data class ValidationResult(
val testName: String,
val passed: Boolean,
val diffMetric: Float = 0f
)
class ValidationResultManager(private val outputDir: File) {
companion object {
private const val TAG = "ValidationResultManager"
}
private val results = mutableListOf<ValidationResult>()
init {
if (!outputDir.exists()) {
outputDir.mkdirs()
}
}
fun addResult(result: ValidationResult) {
results.add(result)
}
fun saveImage(name: String, bitmap: Bitmap) {
val file = File(outputDir, "$name.png")
try {
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to save image $name", e)
}
}
fun getOutputDir(): File {
return outputDir
}
fun finalizeResults(): File? {
// Write results JSON
writeResultsJson()
// Zip results
val zipFile = File(outputDir, "results.zip")
try {
ZipOutputStream(FileOutputStream(zipFile)).use { zos ->
outputDir.walkTopDown().filter { it.isFile && it.name != "results.zip" }.forEach { file ->
val entryName = file.relativeTo(outputDir).path
zos.putNextEntry(ZipEntry(entryName))
file.inputStream().use { it.copyTo(zos) }
zos.closeEntry()
}
}
Log.i(TAG, "Zipped results to ${zipFile.absolutePath}")
return zipFile
} catch (e: Exception) {
Log.e(TAG, "Failed to zip results", e)
return null
}
}
private fun writeResultsJson() {
val jsonArray = JSONArray()
for (result in results) {
val jsonObject = JSONObject()
jsonObject.put("test_name", result.testName)
jsonObject.put("passed", result.passed)
jsonObject.put("diff_metric", result.diffMetric)
jsonArray.put(jsonObject)
}
val jsonFile = File(outputDir, "results.json")
try {
FileOutputStream(jsonFile).use { out ->
out.write(jsonArray.toString(4).toByteArray())
}
} catch (e: Exception) {
Log.e(TAG, "Failed to write results.json", e)
}
}
}

View File

@@ -19,25 +19,19 @@ package com.google.android.filament.validation
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import com.google.android.filament.Engine
import com.google.android.filament.Renderer
import com.google.android.filament.View
import com.google.android.filament.utils.AutomationEngine
import com.google.android.filament.utils.ImageDiff
import com.google.android.filament.utils.ModelViewer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
class ValidationRunner(
private val context: Context,
private val modelViewer: ModelViewer,
private val config: RenderTestConfig,
private val outputDir: File
private val resultManager: ValidationResultManager
) {
private var currentState = State.IDLE
@@ -46,25 +40,30 @@ class ValidationRunner(
private var currentEngine: AutomationEngine? = null
private var currentTestConfig: TestConfig? = null
private var currentModelName: String? = null
private var loadStartFence: com.google.android.filament.Fence? = null
private var loadStartTime = 0L
private var frameCounter = 0
enum class State {
IDLE,
LOADING_MODEL,
WAITING_FOR_FENCE,
WAITING_FOR_RESOURCES,
WARMUP,
RUNNING_TEST,
COMPARING
}
interface Callback {
fun onTestFinished(result: TestResult)
fun onTestFinished(result: ValidationResult)
fun onAllTestsFinished()
fun onStatusChanged(status: String)
fun onImageResult(type: String, bitmap: Bitmap)
}
var callback: Callback? = null
var generateGoldens: Boolean = false
fun start() {
if (config.tests.isEmpty()) {
@@ -94,12 +93,11 @@ class ValidationRunner(
nextModel()
return
}
currentState = State.LOADING_MODEL
callback?.onStatusChanged("Loading $modelName for ${currentTestConfig?.name}")
// Load model on main thread (required by ModelViewer)
// We assume this is called from main thread or we dispatch
loadModel(modelPath)
}
@@ -107,13 +105,18 @@ class ValidationRunner(
// Assume called on Main Thread
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)
Log.i("ValidationRunner", "Model loaded. initializing fence.")
modelViewer.transformToUnitCube()
loadStartFence = modelViewer.engine.createFence()
loadStartTime = System.nanoTime()
currentState = State.WAITING_FOR_FENCE
frameCounter = 0 // Reset for fence timeout tracking
Log.i("ValidationRunner", "State set to WAITING_FOR_FENCE")
} catch (e: Exception) {
Log.e("ValidationRunner", "Failed to load $path", e)
nextModel()
@@ -121,37 +124,67 @@ class ValidationRunner(
}
fun onFrame(frameTimeNanos: Long) {
if (frameCounter % 60 == 0) {
Log.i("ValidationRunner", "onFrame: $currentState (frame: $frameCounter)")
}
when (currentState) {
State.IDLE -> {}
State.WAITING_FOR_FENCE -> {
frameCounter++
if (frameCounter > 600) {
Log.w("ValidationRunner", "Fence timed out after 600 frames! Forcing proceed.")
modelViewer.engine.destroyFence(loadStartFence!!)
loadStartFence = null
currentState = State.WAITING_FOR_RESOURCES
return
}
loadStartFence?.let { fence ->
if (fence.wait(com.google.android.filament.Fence.Mode.FLUSH, 0) == com.google.android.filament.Fence.FenceStatus.CONDITION_SATISFIED) {
if (fence.wait(com.google.android.filament.Fence.Mode.FLUSH, 0) ==
com.google.android.filament.Fence.FenceStatus.CONDITION_SATISFIED) {
modelViewer.engine.destroyFence(fence)
loadStartFence = null
// Compile materials (simplified)
modelViewer.scene.forEach { entity ->
// ... existing material compilation logic ...
}
startAutomation()
currentState = State.WAITING_FOR_RESOURCES
}
}
}
State.WAITING_FOR_RESOURCES -> {
val progress = modelViewer.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
startAutomation()
}
}
State.RUNNING_TEST -> {
currentEngine?.let { engine ->
// Log.i("ValidationRunner", "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
// Delta time?
val deltaTime = 1.0f / 60.0f // Fixed step for consistency?
val deltaTime = 1.0f / 60.0f
engine.tick(modelViewer.engine, content, deltaTime)
if (!engine.isRunning) {
frameCounter++
if (engine.shouldClose()) {
Log.i("ValidationRunner", "Finishing test (frames: $frameCounter)")
// Test finished (for this spec)
currentState = State.COMPARING
captureAndCompare()
@@ -175,104 +208,83 @@ class ValidationRunner(
options.sleepDuration = 0.0f // Minimal sleep, let frames drive it
options.minFrameCount = 5 // Ensure some frames pass
currentEngine?.setOptions(options)
currentEngine?.startRunning()
// Use batch mode to ensure shouldClose() works reliably
currentEngine?.startBatchMode()
currentEngine?.signalBatchMode() // Start immediately
frameCounter = 0
currentState = State.RUNNING_TEST
}
private fun captureAndCompare() {
callback?.onStatusChanged("Comparing ${currentTestConfig?.name}...")
val view = modelViewer.view
val renderer = modelViewer.renderer
val width = view.viewport.width
val height = view.viewport.height
val buffer = ByteBuffer.allocateDirect(width * height * 4)
val pbd = com.google.android.filament.Texture.PixelBufferDescriptor(
buffer,
com.google.android.filament.Texture.Format.RGBA,
com.google.android.filament.Texture.Type.UBYTE,
1, 0, 0, 0, 0, // alignment, left, top, stride (0=default)
null // handler (null = current thread? no, handler is for callback)
) {
// Callback when readPixels is done
// Dispatch to background thread for comparison to avoid blocking UI?
// "it" is undefined here? The callback interface is Runnable?
// Kotlin lambda for Runnable.
compareCapturedImage(buffer, width, height)
modelViewer.debugGetNextFrameCallback { bitmap ->
compareCapturedImage(bitmap)
}
renderer.readPixels(0, 0, width, height, pbd)
}
private fun compareCapturedImage(buffer: java.nio.Buffer, width: Int, height: Int) {
// This runs on... which thread? Filament driver thread possibly.
// We should use a helper to process.
private fun compareCapturedImage(bitmap: Bitmap) {
val testName = currentTestConfig!!.name
val modelName = currentModelName!!
val backend = "opengl" // Hardcoded for now, or get from View/Engine?
val backend = currentTestConfig?.backends?.firstOrNull() ?: "opengl"
val testFullName = "${testName}.${backend}.${modelName}"
// Golden path
// We expect a golden directory.
val goldenFile = File(config.models.get(modelName)!!).parentFile.parentFile.resolve("golden/${testFullName}.png")
// Strategy: models are in .../models/model.glb
// Goldens are in .../golden/
val modelFile = File(config.models.get(modelName)!!)
val goldenFile = modelFile.parentFile!!.parentFile!!.resolve("golden/${testFullName}.png")
Thread {
try {
// Convert buffer to Bitmap
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(buffer)
// Flip Y? ReadPixels is typically bottom-up?
// Filament readPixels is bottom-left? YES.
// Bitmap is top-left.
// We need to flip.
val matrix = android.graphics.Matrix()
matrix.postScale(1f, -1f)
val flipped = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true)
val flipped = bitmap
callback?.onImageResult("Rendered", flipped)
var passed = false
if (goldenFile.exists()) {
val golden = android.graphics.BitmapFactory.decodeFile(goldenFile.absolutePath)
if (golden != null) {
// Populate tolerance from config
val tol = currentTestConfig?.tolerance ?: org.json.JSONObject()
val tolJson = tol.toString()
val result = ImageDiff.compare(golden, flipped, tolJson, null)
passed = (result.status == ImageDiff.Result.Status.PASSED)
// Save diff if failed?
if (!passed) {
val diffFile = File(outputDir, "${testFullName}_diff.png")
if (result.diffImage != null) {
FileOutputStream(diffFile).use { out ->
result.diffImage.compress(Bitmap.CompressFormat.PNG, 100, out)
}
}
}
} else {
Log.e("ValidationRunner", "Failed to load golden: ${goldenFile.absolutePath}")
var diffMetric = 0f
if (generateGoldens) {
goldenFile.parentFile?.mkdirs()
FileOutputStream(goldenFile).use { out ->
flipped.compress(Bitmap.CompressFormat.PNG, 100, out)
}
passed = true // Generating goldens always passes if successful
callback?.onStatusChanged("Golden generated")
} else {
Log.w("ValidationRunner", "Golden not found: ${goldenFile.absolutePath}")
}
// Save output
val outFile = File(outputDir, "${testFullName}.png")
FileOutputStream(outFile).use { out ->
flipped.compress(Bitmap.CompressFormat.PNG, 100, out)
if (goldenFile.exists()) {
val golden = android.graphics.BitmapFactory.decodeFile(goldenFile.absolutePath)
if (golden != null) {
callback?.onImageResult("Golden", golden)
val tol = currentTestConfig?.tolerance ?: org.json.JSONObject()
val tolJson = tol.toString()
val result = ImageDiff.compare(golden, flipped, tolJson, null)
passed = (result.status == ImageDiff.Result.Status.PASSED)
diffMetric = result.failingPixelCount.toFloat()
if (!passed) {
if (result.diffImage != null) {
callback?.onImageResult("Diff", result.diffImage!!)
resultManager.saveImage("${testFullName}_diff", result.diffImage!!)
}
}
} else {
callback?.onStatusChanged("Failed to load golden")
}
} else {
Log.w("ValidationRunner", "Golden not found: ${goldenFile.absolutePath}")
callback?.onStatusChanged("Golden not found")
}
}
callback?.onTestFinished(TestResult(testFullName, passed))
// Schedule next model on main thread
// Use Handler or View.post
modelViewer.view.viewport
// dispatch nextModel()
// Save output
resultManager.saveImage(testFullName, flipped)
val result = ValidationResult(testFullName, passed, diffMetric)
resultManager.addResult(result)
callback?.onTestFinished(result)
android.os.Handler(android.os.Looper.getMainLooper()).post {
nextModel()
}
@@ -300,28 +312,8 @@ class ValidationRunner(
startTest(config.tests[currentTestIndex])
} else {
currentState = State.IDLE
zipResults()
resultManager.finalizeResults()
callback?.onAllTestsFinished()
}
}
private fun zipResults() {
callback?.onStatusChanged("Zipping results...")
val zipFile = File(outputDir, "results.zip")
try {
java.util.zip.ZipOutputStream(java.io.FileOutputStream(zipFile)).use { zos ->
outputDir.walkTopDown().filter { it.isFile && it.name != "results.zip" }.forEach { file ->
val entryName = file.relativeTo(outputDir).path
zos.putNextEntry(java.util.zip.ZipEntry(entryName))
file.inputStream().use { it.copyTo(zos) }
zos.closeEntry()
}
}
Log.i("ValidationRunner", "Zipped results to ${zipFile.absolutePath}")
} catch (e: Exception) {
Log.e("ValidationRunner", "Failed to zip results", e)
}
}
data class TestResult(val name: String, val passed: Boolean)
}

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/surface_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<SurfaceView
android:id="@+id/surface_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/surface_container"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/status_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Initializing..."
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="10dp"
android:paddingBottom="10dp">
<Spinner
android:id="@+id/mode_spinner"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:id="@+id/run_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Run" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Test Results"
android:textSize="18sp"
android:paddingTop="20dp"
android:paddingBottom="10dp" />
<LinearLayout
android:id="@+id/results_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="5dp">
<TextView
android:id="@+id/image_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Label" />
<ImageView
android:id="@+id/image_view"
android:layout_width="300px"
android:layout_height="300px"
android:scaleType="fitCenter"
android:background="#404040" />
</LinearLayout>