feat(orb): implement orb animation
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -37,7 +37,6 @@ android {
|
||||
dependencies {
|
||||
// Project modules
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:domain"))
|
||||
|
||||
// AndroidX
|
||||
implementation(libs.androidx.core.ktx)
|
||||
@@ -49,6 +48,7 @@ dependencies {
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
|
||||
|
||||
@@ -22,11 +22,21 @@
|
||||
// Constants
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Background radial gradient colors (light gray center to darker gray edge)
|
||||
// #d0d0d0 at center (0%)
|
||||
const GRADIENT_CENTER: vec3<f32> = vec3<f32>(0.816, 0.816, 0.816);
|
||||
// #a8a8a8 at 70%
|
||||
const GRADIENT_MID: vec3<f32> = vec3<f32>(0.659, 0.659, 0.659);
|
||||
// #909090 at edge (100%)
|
||||
const GRADIENT_EDGE: vec3<f32> = vec3<f32>(0.565, 0.565, 0.565);
|
||||
|
||||
// Surface intersection threshold - rays closer than this are considered hits
|
||||
const EPS: f32 = 0.01;
|
||||
// Larger value = faster convergence but less precision (mobile optimization)
|
||||
const EPS: f32 = 0.04;
|
||||
|
||||
// Maximum raymarching iterations to prevent infinite loops
|
||||
const MAX_ITR: i32 = 100;
|
||||
// Reduced from 100 for mobile GPU performance
|
||||
const MAX_ITR: i32 = 32;
|
||||
|
||||
// Maximum ray travel distance before giving up (ray miss)
|
||||
const MAX_DIS: f32 = 10.0;
|
||||
@@ -104,31 +114,47 @@ fn sd_sph(p: vec3<f32>, r: f32) -> f32 {
|
||||
// Evaluates the distance field at point `p`.
|
||||
// Combines a base sphere with animated noise displacement.
|
||||
fn map(p: vec3<f32>) -> f32 {
|
||||
// Base UV from world position (scaled down for tiling)
|
||||
let u = p.xy * 0.2;
|
||||
// Animated UV for displacement (single sample for mobile performance)
|
||||
var um = p.xy * 0.06;
|
||||
um.x = um.x + uniforms.time * 0.1;
|
||||
um.y = um.y - uniforms.time * 0.025;
|
||||
um.x = um.x + um.y * 2.0;
|
||||
|
||||
// Animated UV for large-scale displacement
|
||||
// Creates slow, flowing motion across the surface
|
||||
var um = u * 0.3;
|
||||
um.x = um.x + uniforms.time * 0.1; // Horizontal drift
|
||||
um.y = um.y - uniforms.time * 0.025; // Slower vertical drift
|
||||
um.x = um.x + um.y * 2.0; // Shear for diagonal motion
|
||||
// Single noise sample (mobile optimization - was 2 samples)
|
||||
let noise = textureSampleLevel(noiseTexture, texSampler, um, 0.0).x;
|
||||
|
||||
// Sample noise at two frequencies:
|
||||
// - hlg: Large-scale, animated features (the "goo" blobs)
|
||||
// - hfn: Fine detail, static relative to surface
|
||||
let hlg = textureSampleLevel(noiseTexture, texSampler, um, 0.0).x;
|
||||
let hfn = textureSampleLevel(noiseTexture, texSampler, u, 0.0).x;
|
||||
|
||||
// Combine noise samples with amplitude control
|
||||
// Fine detail is reduced where large features are prominent
|
||||
// Apply displacement with amplitude control
|
||||
let amp = max(uniforms.amplitude, 0.15);
|
||||
let disp = (hlg * 0.4 + hfn * 0.1 * (1.0 - hlg)) * amp;
|
||||
let disp = noise * 0.5 * amp;
|
||||
|
||||
// Return displaced sphere distance
|
||||
return sd_sph(p, 1.5) + disp;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Background Gradient
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Computes the radial gradient background color based on distance from center.
|
||||
// Gradient: #d0d0d0 (center) -> #a8a8a8 (70%) -> #909090 (edge)
|
||||
fn gradient_background(uv: vec2<f32>, aspect: f32) -> vec3<f32> {
|
||||
// Center the UV coordinates
|
||||
var centered = uv - vec2<f32>(0.5, 0.5);
|
||||
// Apply aspect ratio correction so gradient is circular not elliptical
|
||||
centered.x = centered.x * aspect;
|
||||
// Distance from center (0 at center, ~0.5-0.7 at edges depending on aspect)
|
||||
let dist = length(centered) * 2.0; // Scale so edges are roughly 1.0
|
||||
|
||||
if (dist < 0.7) {
|
||||
// Center to 70%: blend from CENTER to MID
|
||||
let t = dist / 0.7;
|
||||
return mix(GRADIENT_CENTER, GRADIENT_MID, t);
|
||||
} else {
|
||||
// 70% to edge: blend from MID to EDGE
|
||||
let t = min((dist - 0.7) / 0.3, 1.0);
|
||||
return mix(GRADIENT_MID, GRADIENT_EDGE, t);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Lighting Utilities
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -162,15 +188,32 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// -------------------------
|
||||
// Camera Setup
|
||||
// -------------------------
|
||||
let campos = vec3<f32>(0.0, 0.0, -2.9); // Camera position (in front of sphere)
|
||||
let campos = vec3<f32>(0.0, 0.0, -5.5); // Camera position (moved back for smaller orb)
|
||||
let raydir = normalize(vec3<f32>(d.x, -d.y, 1.0)); // Ray direction per pixel
|
||||
|
||||
// -------------------------
|
||||
// Early-out: Skip rays that clearly miss the sphere
|
||||
// -------------------------
|
||||
// Ray-sphere intersection test for bounding sphere (radius 2.0 to account for displacement)
|
||||
let oc = campos; // origin to center (center is at origin)
|
||||
let b = dot(oc, raydir);
|
||||
let c = dot(oc, oc) - 4.0; // 4.0 = 2.0^2 (bounding radius)
|
||||
let discriminant = b * b - c;
|
||||
|
||||
// If ray misses bounding sphere entirely, return gradient background
|
||||
if (discriminant < 0.0) {
|
||||
let bg = gradient_background(uv, uniforms.aspectRatio);
|
||||
return vec4<f32>(bg, 1.0);
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Raymarching Loop
|
||||
// -------------------------
|
||||
var pos = campos;
|
||||
var tdist: f32 = 0.0; // Total distance traveled
|
||||
var dist: f32 = EPS; // Current step distance
|
||||
// Start ray at intersection with bounding sphere for faster convergence
|
||||
let tstart = max(0.0, -b - sqrt(discriminant));
|
||||
var pos = campos + tstart * raydir;
|
||||
var tdist: f32 = tstart;
|
||||
var dist: f32 = EPS;
|
||||
|
||||
for (var i: i32 = 0; i < MAX_ITR; i = i + 1) {
|
||||
if (dist < EPS || tdist > MAX_DIS) {
|
||||
@@ -185,13 +228,15 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Shading (on hit)
|
||||
// -------------------------
|
||||
if (dist < EPS) {
|
||||
// Compute surface normal via central differences
|
||||
let eps = vec2<f32>(0.0, EPS);
|
||||
let normal = normalize(vec3<f32>(
|
||||
map(pos + eps.yxx) - map(pos - eps.yxx),
|
||||
map(pos + eps.xyx) - map(pos - eps.xyx),
|
||||
map(pos + eps.xxy) - map(pos - eps.xxy)
|
||||
));
|
||||
// Compute surface normal via tetrahedral sampling (4 samples instead of 6)
|
||||
// More efficient than central differences while maintaining quality
|
||||
let e = vec2<f32>(1.0, -1.0) * 0.5773 * EPS;
|
||||
let normal = normalize(
|
||||
e.xyy * map(pos + e.xyy) +
|
||||
e.yyx * map(pos + e.yyx) +
|
||||
e.yxy * map(pos + e.yxy) +
|
||||
e.xxx * map(pos + e.xxx)
|
||||
);
|
||||
|
||||
// Diffuse lighting (Lambertian)
|
||||
let diffuse = max(0.0, dot(LIGHT_DIR, normal));
|
||||
@@ -219,7 +264,8 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Background (ray miss)
|
||||
// Background (ray miss after marching)
|
||||
// -------------------------
|
||||
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
||||
let bg = gradient_background(uv, uniforms.aspectRatio);
|
||||
return vec4<f32>(bg, 1.0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package io.visus.orbis.feature.orb.data
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Represents the rendering state of an orb visualization.
|
||||
*
|
||||
* @property color The color of the orb. Defaults to a blue-gray shade (0xFF547691).
|
||||
* @property amplitude The amplitude of the orb's animation, ranging from 0.0 to 1.0. Defaults to 0.5.
|
||||
*/
|
||||
data class OrbRenderState(
|
||||
val color: Color = Color(0xFF547691),
|
||||
val amplitude: Float = 0.5f
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the UI state of the orb feature.
|
||||
*
|
||||
* @property renderState The current rendering configuration of the orb.
|
||||
* @property isInitialized Whether the orb has been successfully initialized.
|
||||
* @property error An error message if something went wrong, or null if no error occurred.
|
||||
* @property fps The current frames per second of the orb rendering.
|
||||
*/
|
||||
data class OrbUiState(
|
||||
val renderState: OrbRenderState = OrbRenderState(),
|
||||
val isInitialized: Boolean = false,
|
||||
val error: String? = null,
|
||||
val fps: Int = 0
|
||||
)
|
||||
@@ -1,6 +1,14 @@
|
||||
package io.visus.orbis.feature.orb.di
|
||||
|
||||
import io.visus.orbis.feature.orb.service.OrbRenderService
|
||||
import io.visus.orbis.feature.orb.service.OrbRenderServiceImpl
|
||||
import io.visus.orbis.feature.orb.ui.OrbViewModel
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val orbModule = module {
|
||||
singleOf(::OrbRenderServiceImpl) { bind<OrbRenderService>() }
|
||||
viewModelOf(::OrbViewModel)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package io.visus.orbis.feature.orb.service
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Surface
|
||||
import io.visus.orbis.feature.orb.data.OrbRenderState
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Service interface for rendering the orb using WebGPU.
|
||||
*
|
||||
* This service manages the complete lifecycle of WebGPU rendering, including GPU resource
|
||||
* initialization, surface management, and frame rendering. It exposes reactive state flows
|
||||
* for monitoring initialization status, errors, and performance metrics.
|
||||
*
|
||||
* Typical usage:
|
||||
* 1. Call [initialize] to set up WebGPU resources
|
||||
* 2. Call [attachSurface] when a rendering surface becomes available
|
||||
* 3. Call [startRenderLoop] to begin continuous rendering
|
||||
* 4. Update visual parameters via [updateRenderState]
|
||||
* 5. Call [stopRenderLoop] and [detachSurface] when the surface is no longer available
|
||||
* 6. Call [release] to clean up all GPU resources
|
||||
*/
|
||||
interface OrbRenderService {
|
||||
|
||||
/**
|
||||
* Emits `true` when WebGPU initialization is complete and rendering is possible.
|
||||
*/
|
||||
val isInitialized: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* Emits error messages when initialization or rendering fails, `null` otherwise.
|
||||
*/
|
||||
val error: StateFlow<String?>
|
||||
|
||||
/**
|
||||
* Emits the current frames per second, updated approximately once per second.
|
||||
*/
|
||||
val fps: StateFlow<Int>
|
||||
|
||||
/**
|
||||
* Initializes WebGPU resources including the GPU instance, adapter, device, shaders,
|
||||
* textures, and pipeline layouts.
|
||||
*
|
||||
* This must be called before any other rendering operations. The [isInitialized] flow
|
||||
* will emit `true` upon successful completion, or [error] will emit a message on failure.
|
||||
*
|
||||
* @param context Android context used to load shader and texture assets.
|
||||
*/
|
||||
suspend fun initialize(context: Context)
|
||||
|
||||
/**
|
||||
* Attaches a rendering surface and configures it for WebGPU output.
|
||||
*
|
||||
* This creates the GPU surface, configures its format and size, and builds the render
|
||||
* pipeline. Must be called after [initialize] and before [startRenderLoop].
|
||||
*
|
||||
* @param surface The Android [Surface] to render to.
|
||||
* @param width The surface width in pixels.
|
||||
* @param height The surface height in pixels.
|
||||
*/
|
||||
suspend fun attachSurface(surface: Surface, width: Int, height: Int)
|
||||
|
||||
/**
|
||||
* Detaches and releases the current rendering surface.
|
||||
*
|
||||
* This stops any active render loop and unconfigures the GPU surface. Call this when
|
||||
* the surface is destroyed or no longer available for rendering.
|
||||
*/
|
||||
fun detachSurface()
|
||||
|
||||
/**
|
||||
* Updates the render state parameters used for the next frame.
|
||||
*
|
||||
* @param state The new [OrbRenderState] containing color and amplitude values.
|
||||
*/
|
||||
fun updateRenderState(state: OrbRenderState)
|
||||
|
||||
/**
|
||||
* Renders a single frame to the attached surface.
|
||||
*
|
||||
* This updates uniforms, acquires a surface texture, executes the render pass,
|
||||
* and presents the result. Typically called automatically by the render loop,
|
||||
* but can be invoked manually for single-frame rendering.
|
||||
*/
|
||||
fun render()
|
||||
|
||||
/**
|
||||
* Starts the continuous render loop synchronized with the display refresh rate.
|
||||
*
|
||||
* Uses [android.view.Choreographer] to schedule frame callbacks, ensuring smooth
|
||||
* rendering aligned with VSync. The loop continues until [stopRenderLoop] is called.
|
||||
*/
|
||||
fun startRenderLoop()
|
||||
|
||||
/**
|
||||
* Stops the continuous render loop.
|
||||
*
|
||||
* Frame callbacks are removed and no further frames will be rendered automatically.
|
||||
*/
|
||||
fun stopRenderLoop()
|
||||
|
||||
/**
|
||||
* Releases all WebGPU resources and resets the service to an uninitialized state.
|
||||
*
|
||||
* This stops any active render loop and closes all GPU objects including the device,
|
||||
* adapter, textures, buffers, and pipelines. After calling this, [initialize] must
|
||||
* be called again before rendering.
|
||||
*/
|
||||
fun release()
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
@file:Suppress("WrongConstant")
|
||||
|
||||
package io.visus.orbis.feature.orb.service
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Choreographer
|
||||
import android.view.Surface
|
||||
import androidx.webgpu.GPU
|
||||
import androidx.webgpu.GPUAdapter
|
||||
import androidx.webgpu.GPUBindGroup
|
||||
import androidx.webgpu.GPUBindGroupDescriptor
|
||||
import androidx.webgpu.GPUBindGroupEntry
|
||||
import androidx.webgpu.GPUBindGroupLayout
|
||||
import androidx.webgpu.GPUBindGroupLayoutDescriptor
|
||||
import androidx.webgpu.GPUBindGroupLayoutEntry
|
||||
import androidx.webgpu.GPUBuffer
|
||||
import androidx.webgpu.GPUBufferBindingLayout
|
||||
import androidx.webgpu.GPUBufferDescriptor
|
||||
import androidx.webgpu.GPUColor
|
||||
import androidx.webgpu.GPUColorTargetState
|
||||
import androidx.webgpu.GPUDevice
|
||||
import androidx.webgpu.GPUExtent3D
|
||||
import androidx.webgpu.GPUFragmentState
|
||||
import androidx.webgpu.GPUInstance
|
||||
import androidx.webgpu.GPUOrigin3D
|
||||
import androidx.webgpu.GPUPipelineLayout
|
||||
import androidx.webgpu.GPUPipelineLayoutDescriptor
|
||||
import androidx.webgpu.GPURenderPassColorAttachment
|
||||
import androidx.webgpu.GPURenderPassDescriptor
|
||||
import androidx.webgpu.GPURenderPipeline
|
||||
import androidx.webgpu.GPURenderPipelineDescriptor
|
||||
import androidx.webgpu.GPUSampler
|
||||
import androidx.webgpu.GPUSamplerBindingLayout
|
||||
import androidx.webgpu.GPUSamplerDescriptor
|
||||
import androidx.webgpu.GPUShaderModule
|
||||
import androidx.webgpu.GPUShaderModuleDescriptor
|
||||
import androidx.webgpu.GPUShaderSourceWGSL
|
||||
import androidx.webgpu.GPUSurface
|
||||
import androidx.webgpu.GPUSurfaceConfiguration
|
||||
import androidx.webgpu.GPUSurfaceDescriptor
|
||||
import androidx.webgpu.GPUSurfaceSourceAndroidNativeWindow
|
||||
import androidx.webgpu.GPUTexelCopyBufferLayout
|
||||
import androidx.webgpu.GPUTexelCopyTextureInfo
|
||||
import androidx.webgpu.GPUTexture
|
||||
import androidx.webgpu.GPUTextureBindingLayout
|
||||
import androidx.webgpu.GPUTextureDescriptor
|
||||
import androidx.webgpu.GPUTextureView
|
||||
import androidx.webgpu.GPUTextureViewDescriptor
|
||||
import androidx.webgpu.GPUVertexState
|
||||
import androidx.webgpu.helper.Util
|
||||
import androidx.webgpu.helper.initLibrary
|
||||
import io.visus.orbis.feature.orb.data.OrbRenderState
|
||||
import io.visus.orbis.feature.orb.util.CubemapGenerator
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* WebGPU-based implementation of [OrbRenderService].
|
||||
*
|
||||
* This implementation renders a procedural orb effect using WGSL shaders with support for:
|
||||
* - Noise texture sampling for surface distortion
|
||||
* - Cubemap environment reflections
|
||||
* - Dynamic color and amplitude parameters via uniform buffers
|
||||
*
|
||||
* The renderer uses a reduced resolution (60% by default) for performance optimization
|
||||
* while maintaining the correct aspect ratio. Frame timing is synchronized with the
|
||||
* display's VSync via [android.view.Choreographer].
|
||||
*
|
||||
* GPU resources are allocated during [initialize] and [attachSurface], and must be
|
||||
* explicitly released via [release] to avoid memory leaks.
|
||||
*/
|
||||
class OrbRenderServiceImpl : OrbRenderService {
|
||||
|
||||
private val _isInitialized = MutableStateFlow(false)
|
||||
override val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
override val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
private val _fps = MutableStateFlow(0)
|
||||
override val fps: StateFlow<Int> = _fps.asStateFlow()
|
||||
|
||||
private var instance: GPUInstance? = null
|
||||
private var adapter: GPUAdapter? = null
|
||||
private var device: GPUDevice? = null
|
||||
private var gpuSurface: GPUSurface? = null
|
||||
private var shaderModule: GPUShaderModule? = null
|
||||
private var uniformBuffer: GPUBuffer? = null
|
||||
private var noiseTexture: GPUTexture? = null
|
||||
private var noiseTextureView: GPUTextureView? = null
|
||||
private var cubemapTexture: GPUTexture? = null
|
||||
private var cubemapTextureView: GPUTextureView? = null
|
||||
private var sampler: GPUSampler? = null
|
||||
private var bindGroupLayout: GPUBindGroupLayout? = null
|
||||
private var pipelineLayout: GPUPipelineLayout? = null
|
||||
private var renderPipeline: GPURenderPipeline? = null
|
||||
private var bindGroup: GPUBindGroup? = null
|
||||
|
||||
// State
|
||||
@Volatile private var currentState = OrbRenderState()
|
||||
private var surfaceFormat: Int = TEXTURE_FORMAT_BGRA8_UNORM
|
||||
private var surfaceWidth: Int = 0
|
||||
private var surfaceHeight: Int = 0
|
||||
private var renderWidth: Int = 0
|
||||
private var renderHeight: Int = 0
|
||||
private var startTime: Long = 0L
|
||||
@Volatile private var renderLoopRunning = false
|
||||
|
||||
private var choreographer: Choreographer? = null
|
||||
private var choreographerHandler: Handler? = null
|
||||
private val frameCallback = object : Choreographer.FrameCallback {
|
||||
override fun doFrame(frameTimeNanos: Long) {
|
||||
if (renderLoopRunning) {
|
||||
render()
|
||||
updateFpsCounter()
|
||||
choreographer?.postFrameCallback(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var frameCount = 0
|
||||
private var lastFpsUpdateTime = 0L
|
||||
|
||||
// Reusable uniform buffer to avoid allocations per frame
|
||||
private val uniformData = ByteBuffer.allocateDirect(48).order(ByteOrder.nativeOrder())
|
||||
|
||||
// Pre-allocated render objects to avoid per-frame allocations
|
||||
private val clearColor = GPUColor(0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
companion object {
|
||||
private const val UNIFORM_BUFFER_SIZE = 48L
|
||||
|
||||
// Resolution scale for performance (1.0 = full, 0.5 = half resolution)
|
||||
private const val RESOLUTION_SCALE = 0.6f
|
||||
|
||||
// WebGPU constants (raw values to avoid RestrictedApi warnings)
|
||||
private const val TEXTURE_FORMAT_RGBA8_UNORM = 0x00000016
|
||||
private const val TEXTURE_FORMAT_BGRA8_UNORM = 0x0000001b
|
||||
private const val TEXTURE_USAGE_COPY_DST = 0x00000002
|
||||
private const val TEXTURE_USAGE_TEXTURE_BINDING = 0x00000004
|
||||
private const val TEXTURE_USAGE_RENDER_ATTACHMENT = 0x00000010
|
||||
private const val BUFFER_USAGE_COPY_DST = 0x00000008
|
||||
private const val BUFFER_USAGE_UNIFORM = 0x00000040
|
||||
private const val SHADER_STAGE_VERTEX = 0x00000001
|
||||
private const val SHADER_STAGE_FRAGMENT = 0x00000002
|
||||
private const val BUFFER_BINDING_TYPE_UNIFORM = 0x00000002
|
||||
private const val TEXTURE_SAMPLE_TYPE_FLOAT = 0x00000002
|
||||
private const val TEXTURE_VIEW_DIMENSION_2D = 0x00000002
|
||||
private const val TEXTURE_VIEW_DIMENSION_CUBE = 0x00000004
|
||||
private const val TEXTURE_DIMENSION_2D = 0x00000002
|
||||
private const val SAMPLER_BINDING_TYPE_FILTERING = 0x00000002
|
||||
private const val ADDRESS_MODE_REPEAT = 0x00000002
|
||||
private const val FILTER_MODE_LINEAR = 0x00000002
|
||||
private const val LOAD_OP_CLEAR = 0x00000002
|
||||
private const val STORE_OP_STORE = 0x00000001
|
||||
|
||||
// Align to 256 bytes (WebGPU requirement for bytesPerRow)
|
||||
private fun alignTo256(value: Int): Int = (value + 255) and 0xFF.inv()
|
||||
}
|
||||
|
||||
override suspend fun initialize(context: Context) {
|
||||
try {
|
||||
initLibrary()
|
||||
|
||||
instance = GPU.createInstance()
|
||||
adapter = instance?.requestAdapter()
|
||||
device = adapter?.requestDevice()
|
||||
|
||||
if (device == null) {
|
||||
_error.value = "Failed to create GPU device"
|
||||
return
|
||||
}
|
||||
|
||||
loadShader(context)
|
||||
createUniformBuffer()
|
||||
loadNoiseTexture(context)
|
||||
createCubemapTexture()
|
||||
createSampler()
|
||||
createBindGroupLayout()
|
||||
createPipelineLayout()
|
||||
|
||||
startTime = System.nanoTime()
|
||||
_isInitialized.value = true
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Initialization failed: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun attachSurface(surface: Surface, width: Int, height: Int) {
|
||||
val dev = device ?: return
|
||||
val inst = instance ?: return
|
||||
|
||||
surfaceWidth = width
|
||||
surfaceHeight = height
|
||||
renderWidth = (width * RESOLUTION_SCALE).toInt().coerceAtLeast(1)
|
||||
renderHeight = (height * RESOLUTION_SCALE).toInt().coerceAtLeast(1)
|
||||
|
||||
gpuSurface = inst.createSurface(
|
||||
GPUSurfaceDescriptor(
|
||||
surfaceSourceAndroidNativeWindow = GPUSurfaceSourceAndroidNativeWindow(
|
||||
window = Util.windowFromSurface(surface)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val caps = gpuSurface?.getCapabilities(adapter!!)
|
||||
surfaceFormat = caps?.formats?.firstOrNull() ?: TEXTURE_FORMAT_BGRA8_UNORM
|
||||
|
||||
gpuSurface?.configure(
|
||||
GPUSurfaceConfiguration(
|
||||
device = dev,
|
||||
width = renderWidth,
|
||||
height = renderHeight,
|
||||
format = surfaceFormat,
|
||||
usage = TEXTURE_USAGE_RENDER_ATTACHMENT
|
||||
)
|
||||
)
|
||||
|
||||
createRenderPipeline()
|
||||
createBindGroup()
|
||||
}
|
||||
|
||||
override fun detachSurface() {
|
||||
stopRenderLoop()
|
||||
gpuSurface?.unconfigure()
|
||||
gpuSurface?.close()
|
||||
gpuSurface = null
|
||||
}
|
||||
|
||||
override fun updateRenderState(state: OrbRenderState) {
|
||||
currentState = state
|
||||
}
|
||||
|
||||
override fun render() {
|
||||
val dev = device ?: return
|
||||
val surf = gpuSurface ?: return
|
||||
val pipeline = renderPipeline ?: return
|
||||
val bg = bindGroup ?: return
|
||||
|
||||
try {
|
||||
updateUniforms()
|
||||
|
||||
val surfaceTexture = surf.getCurrentTexture()
|
||||
val textureView = surfaceTexture.texture.createView()
|
||||
val commandEncoder = dev.createCommandEncoder()
|
||||
|
||||
val renderPass = commandEncoder.beginRenderPass(
|
||||
GPURenderPassDescriptor(
|
||||
colorAttachments = arrayOf(
|
||||
GPURenderPassColorAttachment(
|
||||
view = textureView,
|
||||
loadOp = LOAD_OP_CLEAR,
|
||||
storeOp = STORE_OP_STORE,
|
||||
clearValue = clearColor
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
renderPass.setPipeline(pipeline)
|
||||
renderPass.setBindGroup(0, bg)
|
||||
renderPass.draw(6)
|
||||
renderPass.end()
|
||||
|
||||
val commandBuffer = commandEncoder.finish()
|
||||
dev.queue.submit(arrayOf(commandBuffer))
|
||||
|
||||
// Close command buffer immediately after submit - GPU has its own copy
|
||||
commandBuffer.close()
|
||||
commandEncoder.close()
|
||||
renderPass.close()
|
||||
|
||||
surf.present()
|
||||
textureView.close()
|
||||
} catch (_: Exception) {
|
||||
// Continue on error
|
||||
}
|
||||
}
|
||||
|
||||
override fun startRenderLoop() {
|
||||
if (renderLoopRunning) return
|
||||
|
||||
renderLoopRunning = true
|
||||
frameCount = 0
|
||||
lastFpsUpdateTime = System.nanoTime()
|
||||
|
||||
choreographerHandler = Handler(Looper.getMainLooper())
|
||||
choreographerHandler?.post {
|
||||
choreographer = Choreographer.getInstance()
|
||||
choreographer?.postFrameCallback(frameCallback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopRenderLoop() {
|
||||
renderLoopRunning = false
|
||||
choreographerHandler?.post {
|
||||
choreographer?.removeFrameCallback(frameCallback)
|
||||
}
|
||||
choreographerHandler = null
|
||||
choreographer = null
|
||||
}
|
||||
|
||||
private fun updateFpsCounter() {
|
||||
frameCount++
|
||||
|
||||
val now = System.nanoTime()
|
||||
val elapsed = now - lastFpsUpdateTime
|
||||
|
||||
if (elapsed >= 1_000_000_000L) {
|
||||
_fps.value = (frameCount * 1_000_000_000L / elapsed).toInt()
|
||||
frameCount = 0
|
||||
lastFpsUpdateTime = now
|
||||
}
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
stopRenderLoop()
|
||||
|
||||
bindGroup?.close()
|
||||
renderPipeline?.close()
|
||||
pipelineLayout?.close()
|
||||
bindGroupLayout?.close()
|
||||
sampler?.close()
|
||||
cubemapTextureView?.close()
|
||||
cubemapTexture?.close()
|
||||
noiseTextureView?.close()
|
||||
noiseTexture?.close()
|
||||
uniformBuffer?.close()
|
||||
shaderModule?.close()
|
||||
gpuSurface?.close()
|
||||
device?.close()
|
||||
adapter?.close()
|
||||
instance?.close()
|
||||
|
||||
_isInitialized.value = false
|
||||
}
|
||||
|
||||
private fun loadShader(context: Context) {
|
||||
val shaderCode = context.assets.open("shaders/Orb.wgsl").bufferedReader().use {
|
||||
it.readText()
|
||||
}
|
||||
shaderModule = device?.createShaderModule(
|
||||
GPUShaderModuleDescriptor(
|
||||
shaderSourceWGSL = GPUShaderSourceWGSL(code = shaderCode)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createUniformBuffer() {
|
||||
uniformBuffer = device?.createBuffer(
|
||||
GPUBufferDescriptor(
|
||||
usage = BUFFER_USAGE_UNIFORM or BUFFER_USAGE_COPY_DST,
|
||||
size = UNIFORM_BUFFER_SIZE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadNoiseTexture(context: Context) {
|
||||
val bitmap = context.assets.open("textures/noise_map.png").use { inputStream ->
|
||||
BitmapFactory.decodeStream(inputStream)
|
||||
}
|
||||
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val bytesPerPixel = 4
|
||||
val bytesPerRow = alignTo256(width * bytesPerPixel)
|
||||
|
||||
noiseTexture = device?.createTexture(
|
||||
GPUTextureDescriptor(
|
||||
usage = TEXTURE_USAGE_TEXTURE_BINDING or TEXTURE_USAGE_COPY_DST,
|
||||
size = GPUExtent3D(width, height, 1),
|
||||
format = TEXTURE_FORMAT_RGBA8_UNORM
|
||||
)
|
||||
)
|
||||
|
||||
val pixels = IntArray(width * height)
|
||||
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
|
||||
// Create buffer with aligned row size
|
||||
val buffer = ByteBuffer.allocateDirect(bytesPerRow * height).order(ByteOrder.nativeOrder())
|
||||
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
val pixel = pixels[y * width + x]
|
||||
|
||||
buffer.put((pixel shr 16 and 0xFF).toByte()) // R
|
||||
buffer.put((pixel shr 8 and 0xFF).toByte()) // G
|
||||
buffer.put((pixel and 0xFF).toByte()) // B
|
||||
buffer.put((pixel shr 24 and 0xFF).toByte()) // A
|
||||
}
|
||||
|
||||
// Pad to aligned row size
|
||||
val padding = bytesPerRow - (width * bytesPerPixel)
|
||||
|
||||
for (i in 0 until padding) {
|
||||
buffer.put(0)
|
||||
}
|
||||
}
|
||||
buffer.rewind()
|
||||
|
||||
device?.queue?.writeTexture(
|
||||
destination = GPUTexelCopyTextureInfo(texture = noiseTexture!!),
|
||||
data = buffer,
|
||||
writeSize = GPUExtent3D(width, height, 1),
|
||||
dataLayout = GPUTexelCopyBufferLayout(
|
||||
offset = 0,
|
||||
bytesPerRow = bytesPerRow,
|
||||
rowsPerImage = height
|
||||
)
|
||||
)
|
||||
|
||||
noiseTextureView = noiseTexture?.createView()
|
||||
|
||||
bitmap.recycle()
|
||||
}
|
||||
|
||||
private fun createCubemapTexture() {
|
||||
val faceSize = CubemapGenerator.getFaceSize()
|
||||
val bytesPerPixel = 4
|
||||
val bytesPerRow = alignTo256(faceSize * bytesPerPixel)
|
||||
|
||||
cubemapTexture = device?.createTexture(
|
||||
GPUTextureDescriptor(
|
||||
usage = TEXTURE_USAGE_TEXTURE_BINDING or TEXTURE_USAGE_COPY_DST,
|
||||
size = GPUExtent3D(faceSize, faceSize, 6),
|
||||
format = TEXTURE_FORMAT_RGBA8_UNORM,
|
||||
dimension = TEXTURE_DIMENSION_2D
|
||||
)
|
||||
)
|
||||
|
||||
val cubemapData = CubemapGenerator.generate()
|
||||
val sourceBytesPerRow = faceSize * bytesPerPixel
|
||||
|
||||
for (face in 0 until 6) {
|
||||
// Create buffer with aligned row size
|
||||
val faceBuffer = ByteBuffer.allocateDirect(bytesPerRow * faceSize).order(ByteOrder.nativeOrder())
|
||||
|
||||
for (y in 0 until faceSize) {
|
||||
// Copy one row from source
|
||||
cubemapData.position(face * faceSize * sourceBytesPerRow + y * sourceBytesPerRow)
|
||||
for (x in 0 until sourceBytesPerRow) {
|
||||
faceBuffer.put(cubemapData.get())
|
||||
}
|
||||
// Pad to aligned row size
|
||||
val padding = bytesPerRow - sourceBytesPerRow
|
||||
for (i in 0 until padding) {
|
||||
faceBuffer.put(0)
|
||||
}
|
||||
}
|
||||
faceBuffer.rewind()
|
||||
|
||||
device?.queue?.writeTexture(
|
||||
destination = GPUTexelCopyTextureInfo(
|
||||
texture = cubemapTexture!!,
|
||||
origin = GPUOrigin3D(0, 0, face)
|
||||
),
|
||||
data = faceBuffer,
|
||||
writeSize = GPUExtent3D(faceSize, faceSize, 1),
|
||||
dataLayout = GPUTexelCopyBufferLayout(
|
||||
offset = 0,
|
||||
bytesPerRow = bytesPerRow,
|
||||
rowsPerImage = faceSize
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
cubemapTextureView = cubemapTexture?.createView(
|
||||
GPUTextureViewDescriptor(
|
||||
usage = TEXTURE_USAGE_TEXTURE_BINDING,
|
||||
dimension = TEXTURE_VIEW_DIMENSION_CUBE,
|
||||
arrayLayerCount = 6
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createSampler() {
|
||||
sampler = device?.createSampler(
|
||||
GPUSamplerDescriptor(
|
||||
addressModeU = ADDRESS_MODE_REPEAT,
|
||||
addressModeV = ADDRESS_MODE_REPEAT,
|
||||
addressModeW = ADDRESS_MODE_REPEAT,
|
||||
magFilter = FILTER_MODE_LINEAR,
|
||||
minFilter = FILTER_MODE_LINEAR
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createBindGroupLayout() {
|
||||
bindGroupLayout = device?.createBindGroupLayout(
|
||||
GPUBindGroupLayoutDescriptor(
|
||||
entries = arrayOf(
|
||||
GPUBindGroupLayoutEntry(
|
||||
binding = 0,
|
||||
visibility = SHADER_STAGE_VERTEX or SHADER_STAGE_FRAGMENT,
|
||||
buffer = GPUBufferBindingLayout(type = BUFFER_BINDING_TYPE_UNIFORM)
|
||||
),
|
||||
GPUBindGroupLayoutEntry(
|
||||
binding = 1,
|
||||
visibility = SHADER_STAGE_FRAGMENT,
|
||||
texture = GPUTextureBindingLayout(
|
||||
sampleType = TEXTURE_SAMPLE_TYPE_FLOAT,
|
||||
viewDimension = TEXTURE_VIEW_DIMENSION_2D
|
||||
)
|
||||
),
|
||||
GPUBindGroupLayoutEntry(
|
||||
binding = 2,
|
||||
visibility = SHADER_STAGE_FRAGMENT,
|
||||
texture = GPUTextureBindingLayout(
|
||||
sampleType = TEXTURE_SAMPLE_TYPE_FLOAT,
|
||||
viewDimension = TEXTURE_VIEW_DIMENSION_CUBE
|
||||
)
|
||||
),
|
||||
GPUBindGroupLayoutEntry(
|
||||
binding = 3,
|
||||
visibility = SHADER_STAGE_FRAGMENT,
|
||||
sampler = GPUSamplerBindingLayout(type = SAMPLER_BINDING_TYPE_FILTERING)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPipelineLayout() {
|
||||
pipelineLayout = device?.createPipelineLayout(
|
||||
GPUPipelineLayoutDescriptor(bindGroupLayouts = arrayOf(bindGroupLayout!!))
|
||||
)
|
||||
}
|
||||
|
||||
private fun createRenderPipeline() {
|
||||
val shader = shaderModule ?: return
|
||||
val layout = pipelineLayout ?: return
|
||||
|
||||
renderPipeline = device?.createRenderPipeline(
|
||||
GPURenderPipelineDescriptor(
|
||||
layout = layout,
|
||||
vertex = GPUVertexState(module = shader, entryPoint = "vs_main"),
|
||||
fragment = GPUFragmentState(
|
||||
module = shader,
|
||||
entryPoint = "fs_main",
|
||||
targets = arrayOf(GPUColorTargetState(format = surfaceFormat))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createBindGroup() {
|
||||
val ub = uniformBuffer ?: return
|
||||
val noiseView = noiseTextureView ?: return
|
||||
val cubeView = cubemapTextureView ?: return
|
||||
val samp = sampler ?: return
|
||||
val layout = bindGroupLayout ?: return
|
||||
|
||||
bindGroup = device?.createBindGroup(
|
||||
GPUBindGroupDescriptor(
|
||||
layout = layout,
|
||||
entries = arrayOf(
|
||||
GPUBindGroupEntry(binding = 0, buffer = ub, size = UNIFORM_BUFFER_SIZE),
|
||||
GPUBindGroupEntry(binding = 1, textureView = noiseView),
|
||||
GPUBindGroupEntry(binding = 2, textureView = cubeView),
|
||||
GPUBindGroupEntry(binding = 3, sampler = samp)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateUniforms() {
|
||||
val ub = uniformBuffer ?: return
|
||||
val dev = device ?: return
|
||||
|
||||
val elapsedTime = (System.nanoTime() - startTime) / 1_000_000_000f
|
||||
// Use original aspect ratio for correct projection
|
||||
val aspectRatio = if (surfaceHeight > 0) surfaceWidth.toFloat() / surfaceHeight else 1f
|
||||
|
||||
// Reuse the pre-allocated buffer
|
||||
uniformData.clear()
|
||||
|
||||
// resolution (vec2<f32>) - offset 0 (use render resolution)
|
||||
uniformData.putFloat(renderWidth.toFloat())
|
||||
uniformData.putFloat(renderHeight.toFloat())
|
||||
|
||||
// time (f32) - offset 8
|
||||
uniformData.putFloat(elapsedTime)
|
||||
|
||||
// amplitude (f32) - offset 12
|
||||
uniformData.putFloat(currentState.amplitude)
|
||||
|
||||
// color (vec4<f32>) - offset 16 (16-byte aligned)
|
||||
uniformData.putFloat(currentState.color.red)
|
||||
uniformData.putFloat(currentState.color.green)
|
||||
uniformData.putFloat(currentState.color.blue)
|
||||
uniformData.putFloat(currentState.color.alpha)
|
||||
|
||||
// aspectRatio (f32) - offset 32
|
||||
uniformData.putFloat(aspectRatio)
|
||||
|
||||
// Padding to 48 bytes
|
||||
uniformData.putFloat(0f)
|
||||
uniformData.putFloat(0f)
|
||||
uniformData.putFloat(0f)
|
||||
|
||||
uniformData.rewind()
|
||||
|
||||
dev.queue.writeBuffer(ub, 0, uniformData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package io.visus.orbis.feature.orb.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import io.visus.orbis.core.ui.components.Slider
|
||||
import io.visus.orbis.core.ui.components.SliderDefaults
|
||||
import io.visus.orbis.core.ui.LocalContentColor
|
||||
import io.visus.orbis.core.ui.components.Icon
|
||||
import io.visus.orbis.core.ui.components.IconButton
|
||||
import io.visus.orbis.core.ui.components.IconButtonVariant
|
||||
import io.visus.orbis.core.ui.components.ModalBottomSheet
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.visus.orbis.core.ui.OrbisTheme
|
||||
import io.visus.orbis.core.ui.components.Text
|
||||
|
||||
@Composable
|
||||
fun OrbScreen(
|
||||
viewModel: OrbViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var showSettingsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.initialize(context)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
// WebGPU Surface (background)
|
||||
if (uiState.isInitialized) {
|
||||
OrbSurface(
|
||||
renderService = viewModel.getRenderService(),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
// Error message
|
||||
uiState.error?.let { error ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
style = OrbisTheme.typography.body1,
|
||||
color = OrbisTheme.colors.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.isInitialized) {
|
||||
// FPS Counter
|
||||
Text(
|
||||
text = "${uiState.fps} FPS",
|
||||
style = OrbisTheme.typography.label1,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.statusBarsPadding()
|
||||
.padding(start = 16.dp, top = 16.dp)
|
||||
)
|
||||
|
||||
// Settings Icon
|
||||
CompositionLocalProvider(LocalContentColor provides Color.White) {
|
||||
IconButton(
|
||||
onClick = { showSettingsSheet = true },
|
||||
variant = IconButtonVariant.Ghost,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.statusBarsPadding()
|
||||
.padding(end = 8.dp, top = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
isVisible = showSettingsSheet,
|
||||
onDismissRequest = { showSettingsSheet = false }
|
||||
) {
|
||||
ControlsPanel(
|
||||
color = uiState.renderState.color,
|
||||
amplitude = uiState.renderState.amplitude,
|
||||
onRedChange = viewModel::updateRed,
|
||||
onGreenChange = viewModel::updateGreen,
|
||||
onBlueChange = viewModel::updateBlue,
|
||||
onAlphaChange = viewModel::updateAlpha,
|
||||
onAmplitudeChange = viewModel::updateAmplitude,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlsPanel(
|
||||
color: Color,
|
||||
amplitude: Float,
|
||||
onRedChange: (Float) -> Unit,
|
||||
onGreenChange: (Float) -> Unit,
|
||||
onBlueChange: (Float) -> Unit,
|
||||
onAlphaChange: (Float) -> Unit,
|
||||
onAmplitudeChange: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Color",
|
||||
style = OrbisTheme.typography.label1,
|
||||
color = OrbisTheme.colors.text
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(color)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = OrbisTheme.colors.outline,
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// RGBA Sliders
|
||||
ColorSlider(
|
||||
label = "R",
|
||||
value = color.red,
|
||||
onValueChange = onRedChange,
|
||||
trackColor = Color.Red.copy(alpha = 0.3f),
|
||||
thumbColor = Color.Red
|
||||
)
|
||||
ColorSlider(
|
||||
label = "G",
|
||||
value = color.green,
|
||||
onValueChange = onGreenChange,
|
||||
trackColor = Color.Green.copy(alpha = 0.3f),
|
||||
thumbColor = Color.Green
|
||||
)
|
||||
ColorSlider(
|
||||
label = "B",
|
||||
value = color.blue,
|
||||
onValueChange = onBlueChange,
|
||||
trackColor = Color.Blue.copy(alpha = 0.3f),
|
||||
thumbColor = Color.Blue
|
||||
)
|
||||
ColorSlider(
|
||||
label = "A",
|
||||
value = color.alpha,
|
||||
onValueChange = onAlphaChange,
|
||||
trackColor = OrbisTheme.colors.outline,
|
||||
thumbColor = OrbisTheme.colors.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Amplitude slider
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Amp",
|
||||
style = OrbisTheme.typography.label2,
|
||||
color = OrbisTheme.colors.textSecondary,
|
||||
modifier = Modifier.width(32.dp)
|
||||
)
|
||||
Slider(
|
||||
value = amplitude,
|
||||
onValueChange = onAmplitudeChange,
|
||||
valueRange = 0f..2f,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = OrbisTheme.colors.primary,
|
||||
activeTrackColor = OrbisTheme.colors.primary,
|
||||
inactiveTrackColor = OrbisTheme.colors.outline
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${(amplitude * 100).toInt()}%",
|
||||
style = OrbisTheme.typography.label2,
|
||||
color = OrbisTheme.colors.textSecondary,
|
||||
modifier = Modifier.width(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorSlider(
|
||||
label: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
trackColor: Color,
|
||||
thumbColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = OrbisTheme.typography.label2,
|
||||
color = OrbisTheme.colors.textSecondary,
|
||||
modifier = Modifier.width(32.dp)
|
||||
)
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
valueRange = 0f..1f,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = thumbColor,
|
||||
activeTrackColor = thumbColor,
|
||||
inactiveTrackColor = trackColor
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${(value * 255).toInt()}",
|
||||
style = OrbisTheme.typography.label2,
|
||||
color = OrbisTheme.colors.textSecondary,
|
||||
modifier = Modifier.width(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.visus.orbis.feature.orb.ui
|
||||
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.visus.orbis.feature.orb.service.OrbRenderService
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun OrbSurface(
|
||||
renderService: OrbRenderService,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
DisposableEffect(renderService) {
|
||||
onDispose {
|
||||
renderService.stopRenderLoop()
|
||||
renderService.detachSurface()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SurfaceView(context).apply {
|
||||
holder.addCallback(object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
// Surface created but dimensions may not be ready
|
||||
}
|
||||
|
||||
override fun surfaceChanged(
|
||||
holder: SurfaceHolder,
|
||||
format: Int,
|
||||
width: Int,
|
||||
height: Int
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
renderService.attachSurface(holder.surface, width, height)
|
||||
renderService.startRenderLoop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
renderService.stopRenderLoop()
|
||||
renderService.detachSurface()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package io.visus.orbis.feature.orb.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.visus.orbis.feature.orb.data.OrbUiState
|
||||
import io.visus.orbis.feature.orb.service.OrbRenderService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel for managing the Orb feature's UI state and render service interactions.
|
||||
*
|
||||
* @property renderService The service responsible for handling orb rendering operations.
|
||||
*/
|
||||
class OrbViewModel(
|
||||
private val renderService: OrbRenderService
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(OrbUiState())
|
||||
|
||||
/**
|
||||
* Observable UI state containing the current state of the orb feature.
|
||||
*/
|
||||
val uiState: StateFlow<OrbUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
// Observe render service state
|
||||
viewModelScope.launch {
|
||||
renderService.isInitialized.collectLatest { initialized ->
|
||||
_uiState.update { it.copy(isInitialized = initialized) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
renderService.error.collectLatest { error ->
|
||||
_uiState.update { it.copy(error = error) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
renderService.fps.collectLatest { fps ->
|
||||
_uiState.update { it.copy(fps = fps) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the orb render service with the provided context.
|
||||
*
|
||||
* @param context The Android context required for service initialization.
|
||||
*/
|
||||
fun initialize(context: Context) {
|
||||
viewModelScope.launch {
|
||||
renderService.initialize(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the orb's amplitude.
|
||||
*
|
||||
* @param amplitude The new amplitude value.
|
||||
*/
|
||||
fun updateAmplitude(amplitude: Float) {
|
||||
_uiState.update { state ->
|
||||
val newRenderState = state.renderState.copy(amplitude = amplitude)
|
||||
|
||||
renderService.updateRenderState(newRenderState)
|
||||
|
||||
state.copy(renderState = newRenderState)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the red component of the orb's color.
|
||||
*
|
||||
* @param red The new red value (0.0 to 1.0).
|
||||
*/
|
||||
fun updateRed(red: Float) {
|
||||
_uiState.update { state ->
|
||||
val currentColor = state.renderState.color
|
||||
val newColor = currentColor.copy(red = red)
|
||||
val newRenderState = state.renderState.copy(color = newColor)
|
||||
|
||||
renderService.updateRenderState(newRenderState)
|
||||
|
||||
state.copy(renderState = newRenderState)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the green component of the orb's color.
|
||||
*
|
||||
* @param green The new green value (0.0 to 1.0).
|
||||
*/
|
||||
fun updateGreen(green: Float) {
|
||||
_uiState.update { state ->
|
||||
val currentColor = state.renderState.color
|
||||
val newColor = currentColor.copy(green = green)
|
||||
val newRenderState = state.renderState.copy(color = newColor)
|
||||
|
||||
renderService.updateRenderState(newRenderState)
|
||||
|
||||
state.copy(renderState = newRenderState)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the blue component of the orb's color.
|
||||
*
|
||||
* @param blue The new blue value (0.0 to 1.0).
|
||||
*/
|
||||
fun updateBlue(blue: Float) {
|
||||
_uiState.update { state ->
|
||||
val currentColor = state.renderState.color
|
||||
val newColor = currentColor.copy(blue = blue)
|
||||
val newRenderState = state.renderState.copy(color = newColor)
|
||||
|
||||
renderService.updateRenderState(newRenderState)
|
||||
|
||||
state.copy(renderState = newRenderState)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the alpha (transparency) component of the orb's color.
|
||||
*
|
||||
* @param alpha The new alpha value (0.0 to 1.0).
|
||||
*/
|
||||
fun updateAlpha(alpha: Float) {
|
||||
_uiState.update { state ->
|
||||
val currentColor = state.renderState.color
|
||||
val newColor = currentColor.copy(alpha = alpha)
|
||||
val newRenderState = state.renderState.copy(color = newColor)
|
||||
|
||||
renderService.updateRenderState(newRenderState)
|
||||
|
||||
state.copy(renderState = newRenderState)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the underlying render service instance.
|
||||
*
|
||||
* @return The [OrbRenderService] used by this ViewModel.
|
||||
*/
|
||||
fun getRenderService(): OrbRenderService = renderService
|
||||
|
||||
/**
|
||||
* Called when the ViewModel is cleared. Releases render service resources.
|
||||
*/
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
|
||||
renderService.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package io.visus.orbis.feature.orb.util
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Generates procedural sky cubemaps for environment mapping and reflections.
|
||||
*
|
||||
* This generator creates a 6-face cubemap texture representing a simple sky gradient
|
||||
* that transitions from a blue sky at the top, through a bright horizon, down to a
|
||||
* dark ground color. The cubemap can be used for:
|
||||
* - Environment reflections on glossy surfaces
|
||||
* - Skybox rendering
|
||||
* - Image-based lighting approximations
|
||||
*
|
||||
* The generated cubemap uses RGBA8 format with faces ordered as: +X, -X, +Y, -Y, +Z, -Z
|
||||
* following the standard cubemap convention.
|
||||
*
|
||||
* @see generate
|
||||
*/
|
||||
object CubemapGenerator {
|
||||
|
||||
/** Resolution of each cubemap face in pixels (width and height). */
|
||||
private const val FACE_SIZE = 64
|
||||
|
||||
/** Bytes per pixel in RGBA8 format. */
|
||||
private const val BYTES_PER_PIXEL = 4
|
||||
|
||||
// Sky color constants (stored individually to avoid array access overhead)
|
||||
/** Red component of the sky color at zenith (top of sky dome). */
|
||||
private const val SKY_TOP_R = 0.4f
|
||||
/** Green component of the sky color at zenith. */
|
||||
private const val SKY_TOP_G = 0.6f
|
||||
/** Blue component of the sky color at zenith. */
|
||||
private const val SKY_TOP_B = 0.9f
|
||||
|
||||
/** Red component of the horizon color. */
|
||||
private const val SKY_HORIZON_R = 0.8f
|
||||
/** Green component of the horizon color. */
|
||||
private const val SKY_HORIZON_G = 0.85f
|
||||
/** Blue component of the horizon color. */
|
||||
private const val SKY_HORIZON_B = 0.95f
|
||||
|
||||
/** Red component of the ground color (below horizon). */
|
||||
private const val GROUND_R = 0.15f
|
||||
/** Green component of the ground color. */
|
||||
private const val GROUND_G = 0.12f
|
||||
/** Blue component of the ground color. */
|
||||
private const val GROUND_B = 0.1f
|
||||
|
||||
/**
|
||||
* Generates a procedural sky cubemap with 6 faces.
|
||||
*
|
||||
* The returned buffer contains pixel data for all 6 faces laid out sequentially
|
||||
* in the standard cubemap order: +X, -X, +Y, -Y, +Z, -Z. Each face is [FACE_SIZE]
|
||||
* pixels square, with 4 bytes per pixel (RGBA8 format).
|
||||
*
|
||||
* The buffer is allocated as a direct ByteBuffer with native byte order for
|
||||
* efficient upload to GPU textures.
|
||||
*
|
||||
* @return A [ByteBuffer] positioned at the start, containing the complete cubemap
|
||||
* data. Total size is `FACE_SIZE * FACE_SIZE * 4 * 6` bytes.
|
||||
*/
|
||||
fun generate(): ByteBuffer {
|
||||
val totalSize = FACE_SIZE * FACE_SIZE * BYTES_PER_PIXEL * 6
|
||||
val buffer = ByteBuffer.allocateDirect(totalSize).order(ByteOrder.nativeOrder())
|
||||
|
||||
// Pre-allocate reusable arrays to avoid per-pixel allocations
|
||||
val direction = FloatArray(3)
|
||||
val color = FloatArray(4)
|
||||
|
||||
for (face in 0 until 6) {
|
||||
generateFace(buffer, face, direction, color)
|
||||
}
|
||||
|
||||
buffer.rewind()
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates pixel data for a single cubemap face.
|
||||
*
|
||||
* Iterates over all pixels in the face, converts each pixel coordinate to a 3D
|
||||
* direction vector, samples the sky color for that direction, and writes the
|
||||
* resulting RGBA values to the buffer.
|
||||
*
|
||||
* @param buffer The destination buffer to write pixel data to.
|
||||
* @param face The face index (0-5) corresponding to +X, -X, +Y, -Y, +Z, -Z.
|
||||
* @param direction Reusable array for storing the computed direction vector.
|
||||
* @param color Reusable array for storing the sampled RGBA color.
|
||||
*/
|
||||
private fun generateFace(buffer: ByteBuffer, face: Int, direction: FloatArray, color: FloatArray) {
|
||||
for (y in 0 until FACE_SIZE) {
|
||||
for (x in 0 until FACE_SIZE) {
|
||||
// Convert pixel coordinates to direction vector
|
||||
val u = (x + 0.5f) / FACE_SIZE * 2.0f - 1.0f
|
||||
val v = (y + 0.5f) / FACE_SIZE * 2.0f - 1.0f
|
||||
|
||||
faceToDirection(face, u, v, direction)
|
||||
sampleSky(direction, color)
|
||||
|
||||
// Write RGBA bytes (no coerceIn needed - colors are already in valid 0-1 range)
|
||||
buffer.put((color[0] * 255f).toInt().toByte())
|
||||
buffer.put((color[1] * 255f).toInt().toByte())
|
||||
buffer.put((color[2] * 255f).toInt().toByte())
|
||||
buffer.put((color[3] * 255f).toInt().toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts 2D face coordinates to a 3D direction vector.
|
||||
*
|
||||
* Maps normalized UV coordinates on a cubemap face to the corresponding
|
||||
* world-space direction vector pointing outward from the cube center.
|
||||
*
|
||||
* @param face The face index (0-5): 0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z, 5=-Z.
|
||||
* @param u Horizontal coordinate in range [-1, 1], left to right.
|
||||
* @param v Vertical coordinate in range [-1, 1], bottom to top.
|
||||
* @param out Output array to store the normalized direction vector [x, y, z].
|
||||
*/
|
||||
private fun faceToDirection(face: Int, u: Float, v: Float, out: FloatArray) {
|
||||
// Cubemap face ordering: +X, -X, +Y, -Y, +Z, -Z
|
||||
when (face) {
|
||||
0 -> { out[0] = 1.0f; out[1] = -v; out[2] = -u } // +X
|
||||
1 -> { out[0] = -1.0f; out[1] = -v; out[2] = u } // -X
|
||||
2 -> { out[0] = u; out[1] = 1.0f; out[2] = v } // +Y (top)
|
||||
3 -> { out[0] = u; out[1] = -1.0f; out[2] = -v } // -Y (bottom)
|
||||
4 -> { out[0] = u; out[1] = -v; out[2] = 1.0f } // +Z
|
||||
5 -> { out[0] = -u; out[1] = -v; out[2] = -1.0f } // -Z
|
||||
}
|
||||
normalize(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a 3D vector in-place to unit length.
|
||||
*
|
||||
* @param v The vector to normalize, modified in-place. Must have at least 3 elements.
|
||||
*/
|
||||
private fun normalize(v: FloatArray) {
|
||||
val lenSq = v[0] * v[0] + v[1] * v[1] + v[2] * v[2]
|
||||
if (lenSq > 0f) {
|
||||
val invLen = (1.0 / sqrt(lenSq.toDouble())).toFloat()
|
||||
v[0] *= invLen
|
||||
v[1] *= invLen
|
||||
v[2] *= invLen
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples the sky color for a given direction vector.
|
||||
*
|
||||
* Uses the Y component of the direction to determine vertical position:
|
||||
* - Positive Y (above horizon): Interpolates from horizon color to sky top color
|
||||
* - Negative Y (below horizon): Interpolates from horizon color to ground color
|
||||
*
|
||||
* This creates a simple gradient sky with a bright horizon that fades to
|
||||
* a deeper blue at the zenith and a dark ground below.
|
||||
*
|
||||
* @param direction The normalized direction vector to sample.
|
||||
* @param out Output array to store the RGBA color values in range [0, 1].
|
||||
*/
|
||||
private fun sampleSky(direction: FloatArray, out: FloatArray) {
|
||||
val y = direction[1]
|
||||
|
||||
if (y > 0f) {
|
||||
// Above horizon: interpolate from horizon to sky top
|
||||
val t = if (y > 1f) 1f else y
|
||||
out[0] = SKY_HORIZON_R + (SKY_TOP_R - SKY_HORIZON_R) * t
|
||||
out[1] = SKY_HORIZON_G + (SKY_TOP_G - SKY_HORIZON_G) * t
|
||||
out[2] = SKY_HORIZON_B + (SKY_TOP_B - SKY_HORIZON_B) * t
|
||||
out[3] = 1f
|
||||
} else {
|
||||
// Below horizon: interpolate from horizon to ground
|
||||
val t = if (y < -1f) 1f else -y
|
||||
out[0] = SKY_HORIZON_R + (GROUND_R - SKY_HORIZON_R) * t
|
||||
out[1] = SKY_HORIZON_G + (GROUND_G - SKY_HORIZON_G) * t
|
||||
out[2] = SKY_HORIZON_B + (GROUND_B - SKY_HORIZON_B) * t
|
||||
out[3] = 1f
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolution (width and height) of each cubemap face in pixels.
|
||||
*
|
||||
* @return The size of each square cubemap face.
|
||||
*/
|
||||
fun getFaceSize(): Int = FACE_SIZE
|
||||
}
|
||||
Reference in New Issue
Block a user