From ef4a377db346ee67f3e05c5232a260582d033d3b Mon Sep 17 00:00:00 2001 From: Samuel Pantze <83579186+smlpt@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:53:55 +0200 Subject: [PATCH 1/3] 3D cursor now reflects manvr3d's sphere scale factor --- .../graphics/scenery/controls/CursorTool.kt | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/graphics/scenery/controls/CursorTool.kt b/src/main/kotlin/graphics/scenery/controls/CursorTool.kt index 84e98f17d..ea87e0e21 100644 --- a/src/main/kotlin/graphics/scenery/controls/CursorTool.kt +++ b/src/main/kotlin/graphics/scenery/controls/CursorTool.kt @@ -12,7 +12,8 @@ import kotlin.getValue /** * A spherical cursor that can be attached to VR controllers via [attachCursor]. * You can access the cursor's world position via [getPosition]. - * The cursor can be scaled up or down with [scaleByFactor] or set directly with [setRadius]. + * The cursor can be scaled up or down with [scaleByFactor] or set directly with [radius]. + * [visualScale] is decoupled from the actual radius and only affects the sphere scale, not the reported radius. * The radius is constrained by [minRadius] and [maxRadius]. * Its color is defined by [defaultColor], but can be adjusted with [setColor] and reset with [resetColor]. * @author Samuel Pantze @@ -22,35 +23,50 @@ class CursorTool( val initPos: Vector3f = Vector3f(-0.01f, -0.05f, -0.03f), val defaultColor: Vector3f = Vector3f(0.15f, 0.2f, 1f), minRadius: Float = 0.001f, - maxRadius: Float = 0.15f + maxRadius: Float = 0.15f, + visualScale: Float = 1f ) { private val logger by lazyLogger() - private val initRadius = radius + val initRadius = radius + var visualScale = visualScale + set(value) { + field = value + updateTransforms() + } var radius = radius - private set + set(value) { + field = value + updateTransforms() + } var minRadius = minRadius private set var maxRadius = maxRadius private set - val cursor = Sphere(radius) + val cursorSphere = Sphere(radius) + + private fun updateTransforms() { + cursorSphere.spatial().scale = Vector3f(radius / initRadius * visualScale) + cursorSphere.spatial().position = Vector3f(initPos) + + Vector3f(initPos).normalize().times(radius - initRadius) + } /** Get the current world space position of this cursor. */ - fun getPosition() = cursor.spatial().worldPosition() + fun getPosition() = cursorSphere.spatial().worldPosition() /** Attach the cursor to another object, typically a VR controller. * Enabling [debug] will also attach the cursor's bounding grid to the [parent]. */ fun attachCursor(parent: Node, debug: Boolean = false) { - cursor.name = "VR Cursor" - cursor.material { + cursorSphere.name = "VR Cursor" + cursorSphere.material { diffuse = Vector3f(0.15f, 0.2f, 1f) } - cursor.spatial().position = initPos - parent.addChild(cursor) + cursorSphere.spatial().position = initPos + parent.addChild(cursorSphere) if (debug) { val bb = BoundingGrid() - bb.node = cursor + bb.node = cursorSphere bb.name = "Cursor BB" bb.lineWidth = 2f bb.gridColor = Vector3f(1f, 0.3f, 0.25f) @@ -67,14 +83,7 @@ class CursorTool( clampedFac = factor } radius *= clampedFac - cursor.spatial().scale = Vector3f(radius / initRadius) - cursor.spatial().position = Vector3f(initPos) + - Vector3f(initPos).normalize().times(radius - initRadius) - } - /** Set the radius directly. */ - fun setRadius(r: Float) { - radius = r } /** Reset the radius to its initial value. */ @@ -84,12 +93,12 @@ class CursorTool( /** Set the cursor to a specified [color]. */ fun setColor(color: Vector3f) { - cursor.material().diffuse = color + cursorSphere.material().diffuse = color } /** Reset the cursor color to [defaultColor]. */ fun resetColor() { - cursor.material().diffuse = defaultColor + cursorSphere.material().diffuse = defaultColor } /** Set the minimum radius the cursor can be scaled down to. */ From 34d3c541c923d4213bc854cacb81a85e30624668 Mon Sep 17 00:00:00 2001 From: Samuel Pantze <83579186+smlpt@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:52:03 +0200 Subject: [PATCH 2/3] Add volume lensing --- .../kotlin/graphics/scenery/volumes/Volume.kt | 14 +++++++++++-- .../graphics/scenery/volumes/VolumeManager.kt | 6 +++++- .../scenery/volumes/SampleBlockVolume.frag | 20 +++++++++++++++++-- .../scenery/volumes/SampleSimpleVolume.frag | 19 ++++++++++++++++-- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/graphics/scenery/volumes/Volume.kt b/src/main/kotlin/graphics/scenery/volumes/Volume.kt index 4b7b17b7f..50fd81bb6 100644 --- a/src/main/kotlin/graphics/scenery/volumes/Volume.kt +++ b/src/main/kotlin/graphics/scenery/volumes/Volume.kt @@ -186,6 +186,11 @@ open class Volume( /** Plane equations for slicing planes mapped to origin */ var slicingPlaneEquations = mapOf() + /** Standard deviation of the gauss function used for lensing */ + var lensingRadius = 1f + /** Center of the lensing gauss function */ + var lensingPosition = Vector3f(0f) + /** Modes how assigned slicing planes interact with the volume */ var slicingMode = SlicingMode.None @@ -205,7 +210,10 @@ open class Volume( Slicing(2), // The slice around the slicing planes is rendered with no transparency // while also the cropping rule applies for the rest of the volume. - Both(3) + Both(3), + // Only render the volume within a defined radius either as a 3D gaussian or a hard falloff + LensingSmooth(4), + LensingHard(5) } @Transient @@ -218,7 +226,7 @@ open class Volume( /** Current timepoint. */ var currentTimepoint: Int = 0 get() { - // despite IDEAs warning this might be not be false if kryo uses its de/serialization magic + // despite IDEAs warning this might not be false if kryo uses its de/serialization magic return if (dataSource is VolumeDataSource.NullSource) { 0 } else { @@ -427,6 +435,8 @@ open class Volume( this.colormap = fresh.colormap this.transferFunction = fresh.transferFunction this.slicingMode = fresh.slicingMode + this.lensingRadius = fresh.lensingRadius + this.lensingPosition = fresh.lensingPosition this.multiResolutionLevelLimits = fresh.multiResolutionLevelLimits this.origin = fresh.origin diff --git a/src/main/kotlin/graphics/scenery/volumes/VolumeManager.kt b/src/main/kotlin/graphics/scenery/volumes/VolumeManager.kt index 971fe6d6d..72b418abc 100644 --- a/src/main/kotlin/graphics/scenery/volumes/VolumeManager.kt +++ b/src/main/kotlin/graphics/scenery/volumes/VolumeManager.kt @@ -335,13 +335,15 @@ class VolumeManager( "slicingMode", "usedSlicingPlanes", "sceneGraphVisibility", + "lensingRadius", + "lensingPosition" ) segments[SegmentType.SampleVolume] = SegmentTemplate( "SampleSimpleVolume.frag", "im", "sourcemax", "intersectBoundingBox", "volume", "transferFunction", "colorMap", "sampleVolume", "convert", "slicingPlanes", "slicingMode", "usedSlicingPlanes", - "sceneGraphVisibility", "volTextureSize" + "sceneGraphVisibility", "volTextureSize", "lensingRadius", "lensingPosition" ) segments[SegmentType.Convert] = SegmentTemplate( "Converter.frag", @@ -564,6 +566,8 @@ class VolumeManager( currentProg.setUniform(i, "usedSlicingPlanes", min(state.node.slicingPlaneEquations.size, Volume.MAX_SUPPORTED_SLICING_PLANES)) currentProg.setUniform(i, "sceneGraphVisibility", if (state.node.visible) 1 else 0) + currentProg.setUniform(i, "lensingRadius", state.node.lensingRadius) + currentProg.setUniform(i, "lensingPosition", state.node.lensingPosition) context.bindTexture(state.transferFunction) context.bindTexture(state.colorMap) diff --git a/src/main/resources/graphics/scenery/volumes/SampleBlockVolume.frag b/src/main/resources/graphics/scenery/volumes/SampleBlockVolume.frag index e7364a24d..9c4d26eee 100644 --- a/src/main/resources/graphics/scenery/volumes/SampleBlockVolume.frag +++ b/src/main/resources/graphics/scenery/volumes/SampleBlockVolume.frag @@ -6,6 +6,8 @@ uniform vec3 sourcemax; uniform vec4 slicingPlanes[16]; uniform int slicingMode; uniform int usedSlicingPlanes; +uniform float lensingRadius; +uniform vec3 lensingPosition; void intersectBoundingBox( vec4 wfront, vec4 wback, out float tnear, out float tfar ) { @@ -25,6 +27,7 @@ vec4 sampleVolume( vec4 wpos, sampler3D volumeCache, vec3 cacheSize, vec3 blockS { bool cropping = slicingMode == 1 || slicingMode == 3; bool slicing = slicingMode == 2 || slicingMode == 3; + bool lensing = slicingMode == 4 || slicingMode == 5; bool isCropped = false; bool isInSlice = false; @@ -41,6 +44,17 @@ vec4 sampleVolume( vec4 wpos, sampler3D volumeCache, vec3 cacheSize, vec3 blockS isInSlice = isInSlice || dist < 0.02f; } + float lensingAlpha = 1.0; + if (lensing) { + vec3 dp = wpos.xyz - lensingPosition; + float distSq = dot(dp, dp); + float sigma2 = lensingRadius * lensingRadius; + if (distSq > 9.0 * sigma2) return vec4(0); + if (slicingMode == 4) { + lensingAlpha = exp(-distSq / (2.0 * sigma2)); + } + } + if ( (!cropping && slicing && !isInSlice) || ( cropping && !slicing && isCropped) || ( cropping && slicing && !(!isCropped || isInSlice ))){ @@ -62,6 +76,8 @@ vec4 sampleVolume( vec4 wpos, sampler3D volumeCache, vec3 cacheSize, vec3 blockS float tf = texture(transferFunction, vec2(rawsample + 0.001f, 0.5f)).r; vec3 cmapplied = tf * texture(colorMap, vec2(rawsample + 0.001f, 0.5f)).rgb; - int intransparent = int( slicing && isInSlice) ; - return vec4(cmapplied*tf,1) * intransparent + vec4(cmapplied, tf) * (1-intransparent); + int isOpaque = int(slicing && isInSlice); + vec4 opaque = vec4(cmapplied * tf, 1.0); + vec4 transparent = vec4(cmapplied, tf * lensingAlpha); // transparency modulated by lensingAlpha + return mix(transparent, opaque, float(isOpaque)); } diff --git a/src/main/resources/graphics/scenery/volumes/SampleSimpleVolume.frag b/src/main/resources/graphics/scenery/volumes/SampleSimpleVolume.frag index 7a0081db4..f4289c29e 100644 --- a/src/main/resources/graphics/scenery/volumes/SampleSimpleVolume.frag +++ b/src/main/resources/graphics/scenery/volumes/SampleSimpleVolume.frag @@ -3,6 +3,8 @@ uniform vec3 sourcemax; uniform vec4 slicingPlanes[16]; uniform int slicingMode; uniform int usedSlicingPlanes; +uniform float lensingRadius; +uniform vec3 lensingPosition; uniform ivec3 volTextureSize; void intersectBoundingBox( vec4 wfront, vec4 wback, out float tnear, out float tfar ) @@ -20,6 +22,7 @@ vec4 sampleVolume( vec4 wpos ) { bool cropping = slicingMode == 1 || slicingMode == 3; bool slicing = slicingMode == 2 || slicingMode == 3; + bool lensing = slicingMode == 4; bool isCropped = false; bool isInSlice = false; @@ -36,6 +39,15 @@ vec4 sampleVolume( vec4 wpos ) isInSlice = isInSlice || dist < 0.02f; } + float lensingAlpha = 1.0; + if (lensing) { + vec3 dp = wpos.xyz - lensingPosition; + float distSq = dot(dp, dp); + float sigma2 = lensingRadius * lensingRadius; + if (distSq > 7.0 * sigma2) return vec4(0); + lensingAlpha = exp(-distSq / (2.0 * sigma2)); + } + if ( (!cropping && slicing && !isInSlice) || ( cropping && !slicing && isCropped) || ( cropping && slicing && !(!isCropped || isInSlice ))){ @@ -49,6 +61,9 @@ vec4 sampleVolume( vec4 wpos ) float tf = texture(transferFunction, vec2(rawsample + 0.001f, 0.5f)).r; vec3 cmapplied = texture(colorMap, vec2(rawsample + 0.001f, 0.5f)).rgb; - int intransparent = int( slicing && isInSlice) ; - return vec4(cmapplied*tf,1) * intransparent + vec4(cmapplied, tf) * (1-intransparent); + int isOpaque = int(slicing && isInSlice); + vec4 opaque = vec4(cmapplied * tf, 1.0); + vec4 transparent = vec4(cmapplied, tf * lensingAlpha); // transparency modulated by lensingAlpha + return mix(opaque, transparent, float(isOpaque)); + } From f36ea4775427d70114c143cbd9eacb5e968981fb Mon Sep 17 00:00:00 2001 From: Samuel Pantze <83579186+smlpt@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:52:17 +0200 Subject: [PATCH 3/3] Add BDVLensingExample --- .../examples/volumes/BDVLensingExample.kt | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/test/kotlin/graphics/scenery/tests/examples/volumes/BDVLensingExample.kt diff --git a/src/test/kotlin/graphics/scenery/tests/examples/volumes/BDVLensingExample.kt b/src/test/kotlin/graphics/scenery/tests/examples/volumes/BDVLensingExample.kt new file mode 100644 index 000000000..593ce5adb --- /dev/null +++ b/src/test/kotlin/graphics/scenery/tests/examples/volumes/BDVLensingExample.kt @@ -0,0 +1,173 @@ +package graphics.scenery.tests.examples.volumes + +import bdv.spimdata.XmlIoSpimDataMinimal +import bvv.core.VolumeViewerOptions +import org.joml.Vector3f +import graphics.scenery.Camera +import graphics.scenery.DetachedHeadCamera +import graphics.scenery.PointLight +import graphics.scenery.SceneryBase +import graphics.scenery.backends.Renderer +import graphics.scenery.volumes.Colormap +import graphics.scenery.volumes.TransferFunction +import graphics.scenery.volumes.Volume +import net.imagej.lut.LUTService +import net.imagej.ops.OpService +import net.imglib2.histogram.Histogram1d +import org.scijava.Context +import org.scijava.ui.UIService +import org.scijava.ui.behaviour.ClickBehaviour +import org.scijava.widget.FileWidget +import java.io.File +import java.util.* +import kotlin.concurrent.thread +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.system.measureTimeMillis + +/** + * Lens cropping example on BDV volumes + * + * @author Ulrik Günther + * @author Samuel Pantze + */ +class BDVLensingExample: SceneryBase("BDV Lensing example", 1280, 720) { + var volume: Volume? = null + var maxCacheSize = 512 + val context = Context(UIService::class.java, OpService::class.java) + var ui: UIService? = null + val ops = context.getService(OpService::class.java) + + override fun init() { + val files = ArrayList() + + // we can either read a file given via a system property, + // or we open a dialog for the user to select a file. + val fileFromProperty = System.getProperty("bdvXML") + if(fileFromProperty != null) { + files.add(fileFromProperty) + } else { + // If we are running in headless mode, e.g. on CI, we don't + // try to show any UI, but set file to null. + var file = if(!settings.get("Headless", false)) { + ui = context.getService(UIService::class.java) + ui?.chooseFile(null, FileWidget.OPEN_STYLE) + } else { + null + } + + // If file is null, we'll use one of our example datasets. + if(file == null) { + file = File(getDemoFilesPath() + "/volumes/t1-head.xml") + } + files.add(file.absolutePath) + } + + logger.info("Loading BDV XML from ${files.first()}") + + renderer = hub.add(Renderer.createRenderer(hub, applicationName, scene, windowWidth, windowHeight)) + + val cam: Camera = DetachedHeadCamera() + with(cam) { + perspectiveCamera(50.0f, windowWidth, windowHeight) + + scene.addChild(this) + } + + val options = VolumeViewerOptions().maxCacheSizeInMB(maxCacheSize) + val v = Volume.fromSpimData(XmlIoSpimDataMinimal().load(files.first()), hub, options) + v.name = "volume" + v.colormap = Colormap.get("jet") + + // we set some known properties here for the T1 head example dataset + if(files.first().endsWith("t1-head.xml")) { + cam.spatial().position = Vector3f(0.3f, -0.6f, 2.0f) + v.transferFunction = TransferFunction.ramp(0.01f, 0.8f) + v.spatial().scale = Vector3f(5.0f) + v.setTransferFunctionRange(0.0f, 1000.0f) + } + v.transferFunction = TransferFunction.ramp(0f, 0.15f) + v.multiResolutionLevelLimits = 0 to 1 + + // Switch between LensingSmooth and LensingHard + v.slicingMode = Volume.SlicingMode.LensingSmooth + v.lensingRadius = 0.2f + + var i = 0 + thread { + while (true) { + v.lensingPosition.x = sin(i / 100f) * 0.8f + Thread.sleep(20) + i += 1 + } + } + + v.lensingPosition = Vector3f(0f) + + v.viewerState.sources.firstOrNull()?.spimSource?.getSource(0, 0)?.let { rai -> + var h: Any? + val duration = measureTimeMillis { + h = ops.run("image.histogram", rai, 32) + } + + val histogram = h as? Histogram1d<*> ?: return@let + logger.info("Got histogram $histogram for t=0 l=0 in $duration ms") + + logger.info("min: ${histogram.min()} max: ${histogram.max()} bins: ${histogram.binCount} DFD: ${histogram.dfd().modePositions().firstOrNull()?.joinToString(",")}") + histogram.forEachIndexed { index, longType -> + val relativeCount = (longType.get().toFloat()/histogram.totalCount().toFloat()) * histogram.binCount + val bar = "*".repeat(relativeCount.roundToInt()) + val position = (index.toFloat()/histogram.binCount.toFloat())*(65536.0f/histogram.binCount.toFloat()) + logger.info("%.3f: $bar".format(position)) + } + } + scene.addChild(v) + + volume = v + + val lights = (0 until 3).map { + PointLight(radius = 15.0f) + } + + lights.mapIndexed { i, light -> + light.spatial().position = Vector3f(2.0f * i - 4.0f, i - 1.0f, 0.0f) + light.emissionColor = Vector3f(1.0f, 1.0f, 1.0f) + light.intensity = 50.0f + scene.addChild(light) + } + } + + override fun inputSetup() { + setupCameraModeSwitching() + + val nextTimePoint = ClickBehaviour { _, _ -> volume?.nextTimepoint() } + val prevTimePoint = ClickBehaviour { _, _ -> volume?.previousTimepoint() } + val tenTimePointsForward = ClickBehaviour { _, _ -> + val current = volume?.currentTimepoint ?: 0 + volume?.goToTimepoint(current + 10) + } + val tenTimePointsBack = ClickBehaviour { _, _ -> + val current = volume?.currentTimepoint ?: 0 + volume?.goToTimepoint(current - 10) + } + + inputHandler?.addBehaviour("prev_timepoint", prevTimePoint) + inputHandler?.addKeyBinding("prev_timepoint", "H") + + inputHandler?.addBehaviour("10_prev_timepoint", tenTimePointsBack) + inputHandler?.addKeyBinding("10_prev_timepoint", "shift H") + + inputHandler?.addBehaviour("next_timepoint", nextTimePoint) + inputHandler?.addKeyBinding("next_timepoint", "L") + + inputHandler?.addBehaviour("10_next_timepoint", tenTimePointsForward) + inputHandler?.addKeyBinding("10_next_timepoint", "shift L") + } + + companion object { + @JvmStatic + fun main(args: Array) { + BDVExample().main() + } + } +}